65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 概念在模板特化中的应用简介

starIconstarIconstarIconstarIconstarIcon

5.00/5 (19投票s)

2022年9月8日

MIT

10分钟阅读

viewsIcon

27180

概念如何在模板编程中用于部分特化

引言

本文将介绍C++中的模板特化和部分模板特化,并使用多种语言特性,包括C++20中新增的功能。

文章首先介绍C++模板和特化。然后,介绍C++20之前使用的一些方法。最后,展示如何使用概念以更清晰、更易读的方式完成所有这些操作。

请注意,要使用这些语言特性,您必须将项目设置 -> C++ -> 语言 -> 更新为 C++20

背景

C++模板编程是一种功能,它允许程序员编写通用的、类型无关的代码,其类型将在编译时填充。模板编程是C++独有的,旨在简化为不同类型实现相似代码的过程,并简化这些代码在更大代码库中的使用方式。

模板简介

本节旨在为模板新手或从未编写过自己模板的人提供快速入门。如果您已经熟悉,请随意跳过。

模板编程可用于类和函数。为简单起见,我将通过一个简单的模板函数来演示本文。

template<typename T>
void foo(T t) {
    cout << "Generic foo for " << typeid(t).name() << endl;
}

foo(1.23);    //prints: Generic foo for double
foo(true);    //prints: Generic foo for bool
foo(42);      //prints: Generic foo for int 

这就是我们对通用模板的期望。在编译时,编译器会为三个不同的函数创建代码,每个函数都有不同的参数列表。

显式特化

模板函数功能非常强大,因为您可以获得类型正确的代码,而无需为每种类型编写不同的函数。如果函数体可以为所有类型执行,这会很有效。例如,您可以为 intfloatstd::string 执行“+”运算符,但不能为 int*char* 执行。

鉴于类型可以具有不同的行为模式或特征,您可能希望在大多数情况下使用通用函数,并能够告诉编译器“`在除了这个特定情况以外使用通用函数,在这个特定情况下我希望你使用另一个特定函数`”。这称为模板特化。

template<typename T>
void foo(T t) {
    cout << "Generic foo for " << typeid(t).name() << endl;
}

template<>
void foo<int>(int t) {
    cout << "Specific foo<int> for " << typeid(t).name() << endl;
}

foo(1.23);    //prints: Generic foo for double
foo(true);    //prints: Generic foo for bool
foo(42);      //prints: Specific foo<int> for int 

空类型列表和对 foo 的显式限定告诉编译器,当它编译 foo(42) 时,它应该使用 foo,这比通用的 foo 更具体。

模板重载

与普通函数一样,模板函数可以重载。如果您想在保留一定“通用性”的同时实现不同的行为,这会很有用。考虑一下

template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<typename T>
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

template<typename T>
void foo(vector<T> &t) {
    cout << "foo(vector<T> &t) for " << typeid(t).name() << endl;
}

foo(42);                //prints: Specific foo<int>(int t) for int
foo((void*)NULL);       //prints: Specific foo(T* t) for void * __ptr64
vector<int> v;
foo(v);
foo(v);                 //prints: foo(vector<T> &t) for 
                        //class std::vector<int,class std::allocator<int> > 

模板函数有三个重载。编译器有多种选择

  1. 如果我们传入任何类型的向量,使用 foo(vector &t)
  2. 如果我们传入任何指针类型,使用 foo(T* t)
  3. 如果以上都不适用,使用 foo(T t)

编译器将尝试匹配 foo 的最佳候选,并使用它。

混合重载和特化

您可以混合重载和特化,它们看起来非常相似,但有一些区别。考虑以下代码

template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<>
void foo<int>(int t) {
    cout << "Specific foo for " << typeid(t).name() << endl;
}

template<typename T>
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

foo(42);                //prints: Specific foo<int>(int t) for int
foo((void*)NULL);       //prints: Specific foo(T* t) for void * __ptr64 

模板函数有两个重载和一个特化。在这种情况下,您可以互换使用它们,因为它们在支持哪种类型方面没有重叠。当有多个候选时,可能会出现令人惊讶的结果。

template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<typename T>       //overload of foo(T t)
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

template<>
void foo<int*>(int* t) {   //specialization of foo(T)
    cout << "Specific foo<int*>(int *t) for " << typeid(t).name() << endl;
}

foo((int*)NULL);           //prints: Specific foo(T* t) for int * __ptr64 

我们有一个重载和一个特化,它们都对 int* 类型有效。编译器仍然选择重载,而不是特化。原因是编译器在特化之前执行重载解析。foo(T* t) 是比 foo(T t) 更好的候选,所以它选择了该模板。foo(T* t) 没有特化,这意味着选择了更通用的函数。

现在我们为 foo(T* t) 添加一个特化,看看会发生什么。

template<typename T>
void foo(T t) {
    cout << "Generic foo(T t) for " << typeid(t).name() << endl;
}

template<typename T>       //overload of foo(T t)
void foo(T* t) {
    cout << "Specific foo(T* t) for " << typeid(t).name() << endl;
}

template<>
void foo<int*>(int* t) {   //specialization of foo(T t)
    cout << "Specific foo<int*>(int *t) for " << typeid(t).name() << endl;
}

template<>
void foo<int>(int* t) {    //specialization of foo(T* y )
    cout << "Specific foo<int>(int *t) for " << typeid(t).name() << endl;
}

foo((int*)NULL);           //prints: Specific foo<int>(int *t) for int * __ptr64 

现在选择第二个特化,因为 foo(T* t) 仍然是比 foo(T t) 更好的候选。而 foo(T* t) 具有 foo(int* t) 作为特化,因此它进一步特化。

限制

基线模板特化和重载功能非常强大,但相当有限。它无法创建更复杂的规则,例如“此重载/特化应用于所有具有特定基类的类”或“此重载/特化应用于浮点类型”。

通过 SFINAE 实现复杂规则

当编译器评估模板类型替换时,可能会提供无法使用并生成错误的参数。编译器不会生成编译错误并停止编译,而是简单地丢弃该模板,这反过来可以指导模板选择。这个原则被称为 “替换失败不是错误”或 SFINAE

总体的想法是,替换失败可以转换为布尔逻辑,并用于选择重载的特化。我无意深入探讨 SFINAE。在 C++11 之前,标准特性在此方面的使用方式受到限制,并且非常冗长。从 C++11 开始,有了更多的选项。特别是,`enable_if` 函数提供了一种清晰的方式来表达需求。结合编译器支持,您可以完成一些强大的事情,而无需让您的大脑受折磨。

例如:以下代码可用于为派生自给定基类的类特化模板。

class Base {};
class Derived : public Base {};

template<
    typename T,
    std::enable_if_t<std::is_base_of_v<Base, T>, bool> Dummy = true>
void Bar(T t) {
    cout << "Bar for T derived from Base" << typeid(t).name() << endl;
};

template<
    typename T,
    std::enable_if_t< not std::is_base_of_v<Base, T>, bool> Dummy = true>
void Bar(T t) {
    cout << "Bar for T not derived from Base " << typeid(t).name() << endl;
};

Derived der;
Bar(der);
Bar(123) 

我们有两个模板。如果我们为派生自 Base 的类进行编译,std::enable_if_t, bool> 将替换为类型 bool,可以赋值为 true,这是一个有效的模板。

另一方面,std::enable_if_t< not std::is_base_of_v, bool> 不会替换为类型 bool,因此无法赋值为 true,这会导致编译器将其取消资格。

关键是这两个定义是互斥的。当编译 Bar(der)Bar(123) 时,编译器将选择对该语句有效的那个模板。您可以实现两个以上选项,只要它们是互斥的。

关于 SFINAE,我就说这么多。它确实很强大,但它很冗长,而且可能难以阅读和理解。C++14 和 C++17 改进了这一点,允许您减少冗余,但它们仍然需要高度实现非常明显和人工的障碍,让编译器跳过,只是为了让它落在特定的位置。

概念

概念是程序员对一种机制的需求的答案,该机制允许我们为模板中可以使用的类型定义约束和要求。

实现单个要求

让我们从一个简单的例子开始。假设我们正在实现一个函数,它只应与非指针变量一起使用。我们可以这样做

template<typename T>
concept NonPointer = not is_pointer_v<T>;

template<NonPointer T>
void Baz(T &t) {
    cout << "Baz for NonPointer T: " << typeid(t).name() << endl;
}

int main()
{
    int i = 123;
    int* ip = &i;
    Baz(i);             //Prints: Baz for NonPointer T: int
    Baz(ip);            //C7602 'Baz': the associated constraints are not satisfied  
}

概念本身有一个名称,并声明为一个可以在编译时评估的表达式。声明概念后,我们可以使用它来代替之前的“typename”说明符。实际上,它充当一个类型名称说明符,其中类型要求作为先决条件。我们不再需要执行 SFINAE 黑魔法。

如果我们将 Baz 与数据类型一起使用,它会按预期编译。如果提供指针类型,它会生成一个清晰的编译器错误,准确地告诉您哪里出了问题。这太微不足道了,几乎就像作弊一样。

实现多重需求

现在让我们把例子变得更有趣:我们只允许 Baz 用于非指针类型,并且它们是基本类型,例如 intbooldouble 等。

template<typename T>
concept NonPointer = not is_pointer_v<T>;

template<typename T>
concept Fundamental = is_fundamental_v<T>;

template<NonPointer T> requires Fundamental<T>
void Baz(T &t) {
    cout << "Baz for Fundamental NonPointer T: " << typeid(t).name() << endl;
}

int main()
{
    int i = 123;
    string s(L"123");
    Baz(i);             //Prints: Baz for Fundamental NonPointer T: int
    Baz(s);             //C7602 'Baz': the associated constraints are not satisfied  
}

如您所见,除了使用概念来指示模板类型要求外,我们还可以在定义模板函数本身时使用它们来定义附加约束。当然,以下也是等效且有效的

template<typename T>
concept FundamentalNonPointer = is_fundamental_v<T> && not is_pointer_v<T>;

template<FundamentalNonPointer T>
void Baz(T &t) {
    cout << "Baz for Fundamental NonPointer T: " << typeid(t).name() << endl;
}

如您所见,做同一件事可以有多种方法。选择哪种方法取决于您要尝试做的其他事情。

检查基类

我们再来一个,添加一个特定版本的 Baz,它适用于 stringwstring 等类型;基本上所有派生自 basic_string 的类型。

template<typename T>
concept FundamentalNonPointer = is_fundamental_v<T> && not is_pointer_v<T>;

template<FundamentalNonPointer T>
void Baz(T &t) {
    cout << "Baz for Fundamental NonPointer T: " << typeid(t).name() << endl;
}

template<typename T>
concept BasicStringDerived = derived_from<T, basic_string<typename T::value_type>>;

template<BasicStringDerived T>
void Baz(T& t) {
    cout << "Baz for BasicStringDerived T: " << typeid(t).name() << endl;
}

int main()
{
    int i = 123;
    string s(L"123");
    Baz(i);          //Prints: Baz for Fundamental NonPointer T: int
    Baz(s);          //Prints: Baz for BasicStringDerived T: class std::basic_string ...
}

我们只需定义一个概念 BasicStringDerived,它评估 T 是否派生自 basic_string。如果您仔细观察,您会发现我们在这里使用了一点 SFINAE。有三种可能性

  1. T 派生自 basic_string,它必须有一个名为 value_typetypedef,这是派生 string 中字符的字符类型。这意味着编译器实际上可以评估 derived_from>true 还是 false
  2. T 不派生自 basic_string。这很可能意味着没有名为 value_typetypedef。这意味着编译器在尝试评估 derived_from> 时已经遇到错误,这反过来意味着它将完全忽略该模板可能性。这就是 SFINAE。
  3. T 不派生自 basic_string,但由于某种原因存在 value_type typedef。这不是问题,因为编译器实际上将能够评估 derived_from>,并将其评估为 false。这也会将模板从考虑中删除。

检查功能

到目前为止,我们只使用概念来考虑类型标识。假设我们想问:这种类型是否能够做这个或那个。让我们用一个实际的例子来说明。我们刚刚实现了一个模板约束来检查类型 T 是否派生自 basic_string。现在假设由于某种原因,我们需要考虑所有具有 T::c_str() 成员的类型。通常,这意味着它派生自 basic_string,但也可能我们正在处理存在实现 c_str() 方法的附加类。

template<typename T>
concept HasCStr = requires (T t) { {t.c_str()}; };

template<HasCStr T>
void Baz(T& t) {
    cout << "Baz for HasCStr T: " << typeid(t).name() << endl;
}

int main()
{
    string s(L"123");
    Baz(s);             //Prints: Baz for HasCStr T: class std::basic_string ...
}

在这里,我们的概念有一个带参数列表和主体的要求部分。主体本身不执行。真正的测试是它是否编译。如果编译,则满足要求。如果因为 T 没有 c_str() 方法而无法编译,则不满足要求。

我们可以将几个要求串联起来。在我们的例子中,我们可能不仅希望 c_str() 方法存在,而且我们希望要求 c_str() 返回一个指针。

template<typename T>
concept HasCStr = requires (T t) { {t.c_str()} -> std::convertible_to<const void*>; };

这只是我们可以实现这种检查的一种方式。因为需求检查实际上归结为“此语句是否编译”,所以我们可以通过多种方式实现这一点。

template<typename T>
concept HasCStr = requires (T t) { { *(t.c_str()) }; };  //can we dereference 
                                                         //what comes out of c_Str()?
template<typename T>
concept HasCStr = requires (T t) 
{ { static_cast<const void*>(t.c_str()) }; }; //can we cast the result to const void*

现在假设我们不仅要检查 c_str() 是否返回指针,而且我们还希望确保 T 具有 value_type typedef,以便 Baz 可以使用类型信息。在这里,我们也可以通过几种方式实现这一点。

template<typename T>
concept HasCStr = requires (T t) 
{ { t.c_str() } -> same_as<const typename T::value_type*>; };
template<typename T>
concept HasCStr = requires (T t) { { *(t.c_str()) }; typename T::value_type; };

这两者相似但不完全相同。第一个示例检查 t.c_str() 是否可以求值,并且返回类型是指向 T::value_type 的指针。第二个示例检查 t.c_str() 是否可以求值并解引用其结果,以及 T 是否具有 value_type typedef。一个不派生自 basic_string 的假设实现可能有一个假设的 c_str(),它返回 void*,并且也可能有一个 value_type typedef

这样的实现会在这两种实现中失败。它会使第一个失败,因为 c_str() 的结果不是 value_type*。它会使第二个失败,因为我们无法解引用 void*。但是,如果我们需要允许这种人为的、假设的实现,我们可以这样捕捉它

template<typename T>
concept HasCStr = requires (T t) { {t.c_str()} -> std::convertible_to<const void*>; 
                  typename T::value_type;};

关注点

本文不涵盖模板概念语言特性的所有可能用法。那将太深入了。有关具体细节,您可以查阅 C++20 标准本身,或查阅 www.cppreference.com 等参考网站。

相反,通过本文,我希望能够介绍这种语言特性,并证明概念不仅仅是语法糖,而是一种以各种方式和情况清晰定义类型参数要求的方法。

历史

  • 2022年9月9日:初始版本
  • 2022年9月9日:修正拼写错误
  • 2022年9月10日:将 std::convertible_to 更改为 std::convertible_to
© . All rights reserved.