二、类和函数模板

C++的模板编程特性是一个又大又复杂的话题,有许多著作专门传授这种特性和技巧。在本书中,我们会用到许多C++中的高级泛型编程特性。那么我们该如何去理解贯穿次数中的这些语言结构呢?本章采用了非正式的方法,抛弃那些精准的定义,我们通过例子来演示如何使用模板以及解释这些语言特性具体做了什么。如果你觉得在这里遇到了知识盲区,我建议可以通过阅读关于C++语法和语义的专著来加深你的理解。当然,如果读者想要知道精准的定义,可以参考C++标准文档。

下面是本章将要讨论的主题:

  • C++中的模板
  • 类模板和函数模板
  • 模板实例化
  • 模板特化
  • 模板函数的重载
  • 变量模板
  • Lambda表达式

1 C++中的模板

C++最强大的特性之一就是其支持泛型编程范式。在泛型编程中,算法和数据结构由后续才会定义的泛化类型来书写。这就允许程序员只需一次性实现类和函数,在后续对于不同类型的变量进行实例化。C++中的模板就是一种允许使用泛化类型定义类和函数的特性。C++支持三种类型的模板:函数模板,类模板和变量模板。

1.1 函数模板

函数模板就是泛型的函数,与通常的函数不同,一个泛型函数不需要声明它参数的类型,而是用模板参数来代替:

template <typename T>
T increment(T x) { return x + 1; }

这个模板函数可以用来对任何类型的变量进行+1操作,只要这个操作对于该类型合法:

increment(5); //T 是int类型,返回6
increment(4.2); //T 是double类型,返回5.2
char c[10];
increment(c); //T 是char*类型,返回&c[1]

通常模板函数在模板参数中含有对于实例化其类型的一些限制。例如,我们的模板函数中,就隐含了要求+1操作必须合法的限制条件。否则,试图对不满足要求的类型进行模板函数实例化就会失败,并且输出一些编译错误提示。

非成员函数和类的成员函数都可以作为函数模板;然而,虚函数不可以做为模板。泛化类型只能用作函数模板参数的声明,不能用在函数体内用来声明任何变量:

template <typename T>
T sum(T from, T to, T step) {T res = from;while ((from += step) < to) { res += from; }return res;
}

后续我们会看到更多的模板函数,但是接下来先让我们介绍类模板。

1.2 类模板

类模板是使用泛化类型的类,通常用泛化类型来声明其数据成员,也可以用来声明成员方法及其局部变量:

template <typename T>
class ArrayOf2 {public:T& operator[](size_t i) { return a_[i]; }const T& operator[](size_t i) const { return a_[i]; }T sum() const { return a_[0] + a_[1]; }
private:T a_[2];
};

这个类型只需实现一次,就可以被用来定义任何类型变量的二元数组:

ArrayOf2<int> i;
i[0] = 1; i[1] = 5;
std::cout << i.sum(); // 6
ArrayOf2<double> x;
x[0] = -3.5; x[1] = 4;
std::cout << x.sum(); // 0.5
ArrayOf2<char*> c;
char s[] = "Hello";
c[0] = s; c[1] = s + 2;

请注意最后一个例子,你可能会认为ArrayOf2对于char*类型应该不合法,毕竟这个类具有一个成员方法sum(),这对于a_[0]a_[1]是指针的时候无法通过编译。然而,在我们的例子中是可以通过编译的,一个类模板的成员方法只要我们不使用它,他就不需要合法。如果我们永远都不会调用c.sum(),那么事实上编译错误永远不会出现,因此程序也是合法的。

1.3 变量模板

C++中最后一种模板就是可变参模板,在C++14中被引入。这种模板允许我们通过泛化类型来定义一个变量:

template <typename T>
constexpr T pi =
T(3.14159265358979323846264338327950288419716939937510582097494459230781L);
pi<float>; // 3.141592
pi<double>; // 3.141592653589793

变量模板在本书中不会被用到,我们也不会细究他的用法。

1.4 非泛化类型的模板参数

通常来说,模板参数是类型,但是C++允许集中非类型的模板参数。首先,模板参数可以是整型值或者枚举类型:

template <typename T, size_t N> class Array {public:T& operator[](size_t i) {if (i >= N) throw std::out_of_range("Bad index");return data_[i];}
private:T data_[N];
};
Array<int, 5> a; // OK
cin >> a[0];
Array<int, a[0]> b; // Error

这是具有两个参数的模板,第一个参数是类型,而第二个参数不是。这个模板参数是一个类型为size_t的值,它决定了数组的大小。这样做的有点是使得内置的C风格数组具有了边界检查的功能。在实际编程实践中,应当优先使用std::array而非自行实现数组,但是上述例子也可作为一个典型的实现方式参考。

用于模板的非类型参数必须是编译时可知的常量或者是constexpr,上述例程中最后一行是非法的,因为a[0]的值并非编译时常量,而是运行时决定的。数值类型的模板参数在C++编程中非常受欢迎,因为这种做法可以实现复杂的编译时运算,不过在最近的C++标准中,constexpr函数的引入也可以获得同样的效果,并且在可读性表现上更为优秀。

第二种值得探讨的非类型的模板参数是所谓的“模板的模板”参数(template template),一个自身是模板的模板参数。我们需要在本书的最后一章中使用它。这个模板参数将不会以类的名字被替换,而是以整个模板被替换。下面是一个典型的例子:

template <template <typename> class Out_container,template <typename> class In_container,typename T>
Out_container<T> resequence(const In_container<T>& in_container) {Out_container<T> out_container;for (auto x : in_container) {out_container.push_back(x);}return out_container;
}

这个函数可以输入任意的容器作为参数,并返回另一种容器,它们是以同一类型实例化的不同种类的模板,容器中的值以拷贝的方式从一个容器转移到另一个容器中:

std::vector<int> v { 1, 2, 3, 4, 5 };
auto d = resequence<std::deque>(v); // 输出为std::deque 内含值 1, 2, 3, 4, 5

总而言之,模板是一种用来生成代码的“配方”。接下来,我们会讨论如何将这些“配方”转化为可以运行的实际代码。

2 模板实例化

模板的名字并非类型,因此无法用于声明变量或者调用函数。为了能够创建参数或定义函数,模板必须实例化。通常来说,在使用模板的时候会对模板进行隐式的实例化。我们先从函数模板为例进行讲解。

2.1 函数模板实例化

为了使用函数模板来创建函数,首先我们必须明确应用于模板参数的所有类型。例如,我们可以直接确定类型:

template <typename T>
T half(T x) { return x/2; }
int i = half<int>(5);

这段代码使用int类型实例化了一个half函数模板。他的类型是被显式确定的,我们也可以用另外的实参传递给函数模板,只要这些类型之间存在隐式转换:

double x = half<double>(5); //5是整型值,但是可以隐式转换为double类型

尽管我们可以通过显示声明来确定函数模板的参数类型,但是几乎很少有人这么做。大多数情况下,使用函数模板的时候都会引入自动类型推导机制。考虑如下的例子:

auto x = half(8); //int
auto y = half(1.5); //double

这种情况下,函数模板的类型只能通过实参的类型进行推导,编译器会选择与实参类型最接近的类型来实例化函数模板。在本例中,函数模板的参数x具有类型T,当调用这个函数时,必然会传入一个参数,这个参数的类型就会决定T的类型。在第一行中,传入的8是int类型,在当前情况下,没有比int类型更适合作为推导结果的类型了,因此函数模板的参数T就被推导为int类型的。同理,第二行中函数模版参数T被推导为double类型。

经过类型推导,编译器就会进行类型替换:函数模板在所有其他提到T的位置都会被替换为推导出的类型;在本例中,这个其他的地方就是,就是函数的返回值。

模板参数类型推导被广泛用于难以确定的类型的捕获:

long x = ...;
unsigned int y = ...;
auto x = half(y + z);

在这里,推导出的T的类型将会是表达式y + z的返回值(他的结果是long,但是借助于模板类型推导,我们不需要显式指定,即使将来我们改变了y和z的类型,推导出的类型也会随之发生变化)。参考如下例子:

template <typename U> auto f(U);
half(f(5));

我们可以推导出T的类型应当符合f()接受一个int参数时返回值的类型,当然,模板函数f()的定义必须提前提供,然而我们并不需要深入头文件去弄清f()的细节,因为编译器会帮我们搞定。

只有出现在模板函数参数中的类型才能参与类型推导。但是,对于函数模板的模板参数而言,并没有要求所有模板参数必须出现在函数参数列表中,因此在这种情况下,我们必须显示指定模板参数的类型:

template <typename U, typename V>
U half(V x) { return x/2; }
auto y = half<double>(8);

由于第一个参数U不在函数参数列表中,因此我们必须显式指定它的类型,而V被自动推导为int类型。

有时,编译器也无法自动推导出模板参数的类型,即使它们都被用来声明函数参数:

template <typename T>
T Max(T x, T y) { return (x > y) ? x : y; }
auto x = Max(7L, 11); //编译错误,无法推导正确的类型

这里,我们可以根据第一个参数的类型推导出T为long,但是对于第二个参数,推导的结果是int。但是令人惊奇的是,在本例中,T的类型不会被推导为long。许多人可能认为,毕竟一旦我们将T替换为long,那么第二个参数就会被隐式转换,函数也能通过编译啊。那么在C++中,为什么不是较大的类型作为推导的结果呢?因为编译器不会去尝试所有参数转换的可能性;毕竟,满足转换条件的类型不止一个。在我们的例子中,T也可以是double或者是unsigned long,并且函数依然合法。如果类型推导可以从多于一个参数上进行,那么所有的推导结果必须一致。否则,实例化的函数就会有歧义,错误输出如下:

t.cpp:18:25: error: no matching function for call to ‘Max(long int, int)’18 |     auto x = Max(7L, 11);|                       ^
t.cpp:12:3: note: candidate: ‘template<class T> T Max(T, T)’12 | T Max(T x, T y)|   ^~~~
t.cpp:12:3: note:   template argument deduction/substitution failed:
t.cpp:18:25: note:   deduced conflicting types for parameter ‘T’ (‘long int’ and ‘int’)18 |     auto x = Max(7L, 11);|                       ^

类型推导并不总是直截了当地使用参数类型作为推导结果。实际的参数的类型可能是一个比模板参数类型复杂得多的类型:

template <typename T>
T decrement(T* p) { return --(*p); }
int i = 7;
decrement(&i); // i == 6

在这里,参数的类型是int*指针,但是T的类型被推导为int。类型推导的结果可以任意复杂,只要满足非歧义性:

template <typename T>
T first(const std::vector<T>& v) { return v[0]; }
std::vector<int> v{11, 25, 67};
first(v); // T is int, returns 11

这里的参数是用另一个模板来实例化的,我们需要用到实例化这个std::vector模板的同类型参数来实例化这个函数模板。

我们可以看到,如果可以从超过一个函数参数中推导出类型,那么这个推导结果就必须一致。另一方面,一个参数可以用来推导出不止一种类型:

template <typename U, typename V>
std::pair<V, U> swap12(const std::pair<U, V>& x) {return std::pair<V, U>(x.second, x.first);
}
swap12(std::make_pair(7, 4.2); // pair of 4.2, 7

这里我们使用了一个函数参数来推导出U和V两个模板参数类型,并且利用这两个参数类型构造了一个新类型,std::pair<V, U>。本例难以避免显得十分冗长,然而我们可以利用更多的C++特性使其既紧凑也容易维护。首先,我们可以使用标准库已经提供的函数std::make_pair()对已经推导出来的类型建立pair。其次,函数的返回值也可以通过return语句进行类型推导(C++14中的新特性)。通过这些方式,我们可以把例子简化为:

template <typename U, typename V>
auto swap12(const std::pair<U, V>& x) {return std::make_pair(x.second, x.first);
}

注意,事实上我们并没有显式使用U,V这两个模板类型参数。但是我们仍然需要把这个函数声明为函数模板,因为pair具有的两个类型必须到实例化时才能获知。然而,我们可以将其声明为一个模板参数作为替代:

template <typename T>
auto swap12(const T& x) {return std::make_pair(x.second, x.first);
}

这两种变体有显著的区别:第二种写法的函数模板可以对任何单参数的调用成功进行类型推导,不论参数的类型。也就是说,即便这个参数不是类,不是strut,甚至可能不具有firstsecond成员,都能推导成功。但是在进行替换(substitution)时,会发生失败。另一方面,第一种写法完全不考虑除了std::pair以外的参数,即使某些类含有类似的结构。但是对于任意的std::pair,都可以推导成功并正确处理。

成员函数模板和非成员函数模板非常类似,他们的模板参数也是以类似的方式进行推导的。成员函数模板可以用于类模板或是普通类,接下来我们会详细介绍。

2.2 类模板实例化

实例化类模板与函数模板类似,使用模板时就会隐式创建一个模板的实例。要使用类模板,我们需要指定类模板的模板参数:

template <typename N, typename D>
class Ratio {public:Ratio() : num_(), denom_() {}Ratio(const N& num, const D& denom) : num_(num), denom_(denom) {}explicit operator double() const {return double(num_)/double(denom_);}
private:N num_;D denom_;
};
Ratio<int, double> r;

对变量r1的定义隐式实例化了这个Ratio类,并分别使用了intdouble类型作为类模板的模板参数。同时,这个行为也实例化了该类的默认构造函数。第二个构造函数在本例中未使用,因此也就没有被实例化。类模板的一个特性之一是:实例化一个模板会的同时会实例化所有数据成员,但是如果成员方法未被使用,则其不会被实例化;这样就允许我们为一些特别的类型编写特殊的成员方法。如果我们使用第二个构造函数来初始化Ratio对象的值时,那么这个构造函数就会被实例化,并且必须按照给定的合法形式来进行:

Ratio<int, double> r(5, 0.1);

C++17标准中,这些构造函数可以通过传入的参数类型进行类型推导,因此可以省略成如下的形式:

Ratio r(5, 0.1); // C++17

当然,只有给定足够的构造函数参数才能进行如此的类型推导。例如,Ratio类的默认构造函数必须被显式指定模板参数;简单点说,这是因为没有足够的类型来推导出模板参数的类型。在C++17标准之前,通常有一个辅助模板函数被用来构造一个类模板的对象,从而使得类模板的模板参数能够通过函数模板的参数中推导而来。类似的如std::make_pair,正如我们上一节看到的那样,我们可以实现一个make_ratio函数来帮助我们做到C++17标准的构造函数参数类型推导的效果:

template <typename N, typename D>
Ratio<N, D> make_ratio(const N& num, const D& denom) {return { num, denom };
}
auto r(make_ratio(5, 0.1)); //利用函数模板和auto进行类型推导

C++17标准的模板参数推导方式,如果可用的话,更推荐使用。因为他不需要额外编写那类仅仅是重复了构造函数工作的冗余函数,并且在拷贝和移动对象时,不需要额外产生函数调用的开销(尽管通常情况下编译器会智能地进行优化)。

当一个模板被用来产生一个类型时,他就被隐式地实例化了。类模板和函数模板都可以被显式地实例化。我们可以这样显式地实例化模板,而不必先使用它:

template class Ratio<long, long>;
template Ratio<long, long> make_ratio(const long&, const long&);

显式实例化的应用场景很少,在本书中不会再出现。

类模板,以我们目前了解的内容,允许我们声明泛型的类,也就是说,可以用不同的类型来对其实例化。到目前为止,我们所见的类都长得几乎一个样子,并且产生相同的代码,除了类型以外。但这并不总是我们想要的,有些特殊类型需要特殊处理。

例如,比方说我们不仅想要表示存储在Ratio类中的两个数字,同时也想要存储在其他数据结构中的两个数字的比率(Ratio意味比率,即分数)。显然,Ratio对象中的某些方法,比如向double类型的转换运算符,对于以指针方式存储的分数和分母的数据结构需要做特殊处理。在C++中,我们称这种手段为“偏特化”,接下来我们就要研究一下相关的知识点。

3 模板的偏特化

模板的偏特化允许我们针对某些类型产生不一样的代码,即不仅仅做类型的简单替换,而是完全不同的一套代码。在C++中存在两种类别的偏特化:显式地,或者说完全的“显式特化”,另一种是部分的特化——偏特化。我们先来看前者。

3.1 显式特化:“全特化”

模板显式特化针对某些特定类型,定义了一个特殊的版本。在显式特化中,所有的泛型类型都被替换为特定的,具体的类型。由于显式特化不会产生泛型类或者泛型函数,因此也不需要实例化。也正因为此,这种特化也被称为全特化。注意显式特化不要与模板显式实例化混淆,尽管两者都创建了泛型代码的实例,并在代码中以具体类型替换了泛型类型。显式特化所创建的函数或者类的同名实例将会覆写原有的实现,因此,产生的最终代码可能会完全不一样。下面的例子将为我们展示具体的差异。

我们先以类模板为例。假设Ratio类的分子和分母都是double类型的,我们想要计算这个分数的比率,并存储到单一的变量中。泛型的Ratio代码应该保持一致,但是对于某个特定的类型组合,我们希望有完全不一样的效果。由此我们可以编写出如下的显式特化代码:

template <>
class Ratio<double, double> {public:Ratio() : value_() {}template <typename N, typename D>Ratio(const N& num, const D& denom) :value_(double(num)/double(denom)) {}explicit operator double() const { return value_; }
private:double value_;
};

模板类的两个模板参数都被指定为了double类型。类的实现也和泛型版本完全不同,即只有1个数据成员而非2个;转换运算符(operator double())变成了返回数据成员的值,并且构造函数直接计算出了分子和分母的比率。但是,这甚至都不是同一个构造函数,因为泛型版本的构造函数并非模板函数;为了显式地表示我们使用了2个double类型参数来进行构造,我们吧这个显式特化中的有参构造函数改写成了模板函数,并且将传入的参数显式转换为了double类型。(好处是,即使传入构造函数的参数任一为Ratio类型,也能正常的执行构造)

有时候,我们并不需要对整个类模板进行特化,因为大部分情况下泛型代码以及够用。然而,我们会想要改变某些成员方法的实现。例如,我们可以显式特化这个成员函数:

template <>
Ratio<float, float>::operator double() const { return num_/denom_; }

模板函数也可以被显式特化。同理,与显式实例化不同的是,我们需要重写整个函数体,并且可以依照我们的想法重新实现它:

template <typename T>
T do_something(T x) { return ++x; }
template <>
double do_something<double>(double x) { return x/2; }
do_something(3); // 4
do_something(3.0); // 1.5

然而,我们不能做的是,改变参数的类型以及返回值的类型,他们必须符合泛型类型在类模板中的替换规则,因此以下的代码例子将无法通过编译:

template <>
long do_something<int>(int x) { return x*x; } // 类型替换不匹配

显式特化必须在第一次使用模板前发生,因为使用模板时会发生隐式实例化。这很合理,因为隐式实例化会产生具有同样类型的类或者函数的显式特化版本。这样一来,在我们的程序中就会出现两个版本的同名类或者同名函数,也就违反了一次定义的法则,导致程序病态(ill formed)。

显式特化通常在当我们需要让模板的对某些类型表现得不太一样时效果显著。然而,这并没有解决我们之前提到的问题:计算两个指针变量的比率。我们希望一种仍然是某种意义上的特化;它能够处理任意类型的指针,而不是任意其他类型。这个方法可以通过偏特化来实现,接下来我们就盘它。

3.2 偏特化

好的,现在我们即将进入C++模板编程中最有趣的部分了:模板的偏特化。当一个类模板被偏特化时,他依然保留了泛型代码,但是相比原始的模板时却没那么“泛”了。最简单的偏特化形式就是,模板中的部分泛型类型被替换为具体类型,而其他的类型依然保持泛型特征:

template <typename N, typename D>
class Ratio {.....
};
template <typename D>
class Ratio<double, D> {public:Ratio() : value_() {}Ratio(const double& num, const D& denom) : value_(num/double(denom)) {}
explicit operator double() const { return value_; }
private:double value_;
};

在这里,我们可以吧Ratio转换为double类型,只要分子是double类型,不论分母是什么类型。在同一个模板中,可以定义多个偏特化。例如,我们也可以对分母是double类型的情况进行偏特化:

template <typename N>
class Ratio<N, double> {public:Ratio() : value_() {}Ratio(const N& num, const double& denom) : value_(double(num)/denom) {}explicit operator double() const { return value_; }
private:double value_;
};

符合给定类型组合的最佳特化版本将被选择用来实例化模板。在我们的例子中,如果分子和分母都不是double类型,那么通用的模板将会被实例化,因为别无选择。如果分子是double类型,那么第一个偏特化版本将被选择。反之则是第二个偏特化版本。但是如果分子和分母都是double类型时会发生什么呢?在本例中,这两个偏特化的版本是等价的;没有任何一个比另一个更合适。因此,这种情况就被认为是有歧义的,并且会引发实例化的失败。注意,仅有这种情况下的实例化会失败,即Ratio<double, double>,但是定义这两个特化版本本身不是一个错误(至少从语法上讲不是错误),它错在无法决议出一个唯一的实例化版本。为了让我们这个示例代码能够对任意的类型进行实例化,我们必须消除这种歧义,而为了做到这点,我们必须提出更窄的特化版本,对于本例的情况,以2个double类型作为模板参数的全特化版本是唯一的解法:

template <>
class Ratio<double, double> {public:Ratio() : value_() {}template <typename N, typename D>Ratio(const N& num, const D& denom) : value_(double(num)/double(denom)) {}explicit operator double() const { return value_; }
private:double value_;
};

至此,对于Ratio<double, double>的偏特化产生的歧义就被完全消除了,因为我们有一个比偏特化更具体的版本,因此编译器会最终选择它。

偏特化不需要完全指定某些泛型类型,因此,可以完全保留泛型类型,但同时对它们做出某些限制。例如,我们仍然希望一个针对分子和分母是指针情况下的特化。它们可以是任何类型的指针,因此它们也是泛化类型,但是相比于任意类型而言,没那么“泛”:

template <typename N, typename D>
class Ratio<N*, D*> {public:Ratio(N* num, D* denom) : num_(num), denom_(denom) {}explicit operator double() const {return double(*num_)/double(*denom_);}
private:N* const num_;D* const denom_;
};
int i = 5; double x = 10;
auto r(make_ratio(&i, &x)); // 实例化为 Ratio<int*, double*>
double(r); // 0.5
x = 2.5;
double(r); // 2

这个偏特化版本拥有两个泛化类型,但他们都是指针类型。这个版本中的实现也和通用模板完全不同。当我们对两个指针类型的参数实例化模板时,这样的偏特化版本相比而言就更加“具体”,因此会被优先选择。注意,在我们的例子中,分母是double,那么为什么不选择分母是double类型的偏特化版本呢?因为,尽管分母作为double类型在逻辑上行得通,技术上而言,他是double*类型的,是完全不一样的类型,并且我们也没有为之定义偏特化。

为了定义偏特化,泛型模板需要被首先声明。然而,它并不需要被定义,可以说,存在这样的“只有偏特化版本而没有泛化版本的模板”的情况。如果要这样做,我们必须前向声明泛化模板,然后在定义那些所需的特化版本:

template <typename T> class Value; // 声明
template <typename T> class Value<T*> {public:explicit Value(T* p) : v_(*p) {}
private:T v_;
};
template <typename T> class Value<T&> {public:explicit Value(T& p) : v_(p) {}
private:T v_;
};
int i = 5;
int* p = &i;
int& r = i;
Value<int*> v1(p); // T* 特化
Value<int&> v2(r); // T& 特化

这里,我们的Value类就没有泛化模板,只有针对指针和引用的特化版本。如果我们试图对其他的类型进行实例化,比如说int类型,我们会得到一个错误提示,说Value<int>类型是不完整的,这种行为与试图使用一个仅有声明而无定义的前向声明类别无二致。

到目前为止,我们仅仅看到了类模板的偏特化例子。与早前讨论的全特化不同,我们还没有看到函数模板偏特化的情况。原因是,C++中并不存在偏特化的函数模板。有时候错误地称为“偏特化”的函数模板不过是调用了函数模板的重载。另一方面,重载模板函数可以非常复杂,并且值得我们继续学习,下面我们就来研究一下它。

4 函数模板的重载

函数的重载应该是大家必知必会的内容了,无论是类的方法还是普通函数。每次当被调用时,能够最佳匹配参数的函数才会被调用:

void whatami(int x) { std::cout << x << " is int" << std::endl; }
void whatami(long x) { std::cout << x << " is long" << std::endl; }
whatami(5); // 5 是 int
whatami(5.0); // 编译错误

如果参数能完美匹配给定的函数名对应的重载,那个重载函数就会被调用。否则,编译器就会考虑对参数进行隐式转换以试图满足可用的函数。如果其中一个函数提供了更好的转换,那么那个函数就会被选择。否则,这个调用就会产生歧义,正如上例中最后一行显式的那样。对于最佳转换的精确定义可以参考标准手册。通常来说,最“廉价”的转换,是诸如增加一个const限定或者移除引用之列的行为,其次就是内建类型之间的转换,或者从派生类到基类指针的转换,以此类推。对于多个参数的情况下,每个参数对于选定的函数而言必然具有最佳转换结果。这里不会发生“投票”现象,例如一个函数含有3个参数,其中2个对于重载1完全匹配,即使第3个参数对于重载2完全匹配,这样的重载调用也是歧义的。(译注:就是完全平等的概念,不存在少数服从多数,只要不完全匹配,就可能发生歧义)

模板的出现使得函数重载更加复杂。多个同名的函数模板,即可能具有同样的参数数量,也可能存在同名的非模板函数定义。所有的这些同名函数,都会作为过来凑一凑重载函数的热闹,但是我们要知道,模板函数是可以被任意类型实例化的,所以我们如何确定重载函数们到底有哪些呢?确切的规则相比非模板函数而言更加复杂,但是基本思想是这样的:如果一个非模板函数对于调用时的参数而言更加接近完美匹配,那么这个非模板函数将会被选择。当然,在C++标准中,使用的字眼更加精准,称为trivial conversion(平凡转换)。例如增加一个const限定,几乎不需要任何开销。如果没有这样的函数,编译器就会尝试用按照“接近完美”的函数来实例化所有的函数模板,并对模板参数进行推导。如果恰好一个模板能够正确实例化,那么这个实例化产生的函数就会被调用。否则,重载决议就会继续在非模板函数中寻找可能的函数。

这是对于一个异常复杂过程的简单描述,但是我们需要注意两点。第一,如果有两个同样好的匹配,那么相对于函数模板而言,非模板函数会被优先选择;第二,编译器不会尝试去实例化那些对我们传入参数需要进行转换的模板。函数模板必须在经过类型推导后“接近完美”,否则将不会被调用。让我们在之前的例子中增加一个函数模板:

template <typename T>
void whatami(T* x) { std::cout << x << " is pointer" << std::endl; }
int i = 5;
whatami(i); // 5 是 int
whatami(&c); // 0x???? 是一个指针

这里,我们就有了一个看上去向偏特化的函数模板。但是事实上,它只是一个函数模板,它的类型参数是通过同样的参数进行推导而来,并采用了不同的规则。这个模板的参数可以有任何类型的指针推导而来。其中就包括了指向const的指针,也就是说,T可以是const类型的。因此,如果我们调用whatami(ptr),当ptr是一个const int*时,第一个“接近完美”的模板重载就是当Tconst int的情况。如果类型推导成功,模板实例化产生的这个函数就会被添加到重载集合中。

对于int*参数,这是唯一能够工作的重载,因此只有它被调用了。但是如果多于一个函数模板可以满足这样的调用会发生什么事呢?二者都能成为合法的重载吗?我们来看看下面这个例子:

template <typename T>
void whatami<T&& x> { std::cout << "Something weird" << std::endl; }
class C { ..... };
C c;
whatami(&c); // 输出 0x???? 因为传入的是地址
whatami(c); // 输出 "Something weird"

这个模板函数使用“万能引用”作为形参来接受参数,因此它可以被任意的调用实例化。对于whatami(c)调用,很显然应该选择后一个具有T&&形参的重载,因为只有他能够被调用。调用这个重载不会发生任何从c向指针或者整型的转换。但是另一个调用就比较微妙了,对于这个调用,完美匹配的函数重载选项不唯一,且都不需要发生转换。那么为什么这不被认为是重载歧义呢?因为重载函数模板的决议规则相对于非模板函数而言是不同的,类似于选择偏特化类模板的法则(这就是为什么函数模板重载总是会被误认为是模板偏特化的原因)。相比之下,函数模板更加确切(more specific)也因此更符合最优匹配。

在我们的例子中,第一个模板更加确切,它可以且只能接受一个指针参数。第二个模板可以接受任意类型的参数,因此,任何情况下,第一个模板都只是一个可能的匹配。如果更加确切的模板可以被用来实例化为一个合法的重载函数,那么这个模板就会被使用。否则,我们就要回落到更泛化的模板中去寻求答案。

偏偏是泛化模板在函数重载中有时会导致意想不到的结果。假设我们有以下3个重载分别处理intdouble和任意类型:

void whatami(int x) { std::cout << x << " is int" << std::endl; }
void whatami(double x) { std::cout << x << " is double" << std::endl; }
template <typename T>
void whatami<T&& x> { std::cout << "Something weird" << std::endl; }
int i = 5;
float x = 4.2;
whatami(i); // i is int
whatami(x); // Something weird

第一个调用中,参数具有int类型,因此whatami(int)是一个完美匹配。对于第二个调用,如果我们没有定义模板函数重载的话,那么最佳的匹配将会落到whatami(double)上,因为从floatdouble可以发生隐式转换(当然floatint也行,但是floatdouble会被优先选择)。由于这般选择仍然会发生一个类型转换,因此模板实例化的函数whatami(double&&)会作为最佳匹配,并被选择作为重载函数。

最后,还有一种类型的函数在重载决议中存在特殊地位,就是可变参函数。

可变参函数就是以...声明其参数的函数,并且可以通过传递任意数量的参数来调用它,例如printf()就是这样的一种例子。这种函数会被作为重载决议的“最后防线”,只要当其他的重载函数都匹配失败时才会被调用:

void whatami(...) {std::cout << "It's something or somethings" << std::endl;
}

只要我们的whatami(T&&)模板可用,那么可变参函数就永远不会被重载调用,至少对于任意的单参数调用而言不会令其参与重载。如果不存在那个模板,那么当传递一个非指针或者数字参数而调用的时候,whatami(...)就会被调用。可变参函数在C的时代就已经出现,但是不要与C++11标准中提出的可变参模板混淆了,下面我们将要讨论它。

5 可变参模板

C和C++中泛型编程的最大区别就是类型安全。在C中,编写泛型代码是可行的,标准库中的qsort函数就是典型应用,可以通过给他传递void*类型的指针,使其能够排列任意类型的值。当然,程序员必须事先知道真实的类型是什么,并且手动将其转换为对于类型。然而在C++的泛型编程中,类型通常是被显式指定的,或者在实例化时通过类型推导而确定的,并且类型系统对于泛型类型而言与通常类型一样强大——除了不能确定参数的数量场合,但这是仅对于C++11标准之前而言的,因为在那之前,只可以使用C风格的可变参函数。而编译器是无法获知参数的类型究竟为何,因此正确解析参数的重大责任就落在了程序员身上。

C++11标准引入了可变参函数的现代化等价物,可变参模板。现在,我们可以对任意数量的参数声明泛型函数了:

template <typename ... T>
auto sum(const T& ... x);

这个函数可以接受一个或者多个参数,甚至可以是不同类型的参数,并且计算他们的和。想要定义返回值确实不易,但幸运的是,我们可以让编译器帮我们搞定他,这里只需要把返回值声明为auto。那么我们如何来实现这个即不知道参数数量,又不知道泛型类型的“不可名状函数”呢?在C++17标准中,这很简单:

template <typename ... T>
auto sum(const T& ... x) {return (x + ...);
}
sum(5, 7, 3); // 15, int
sum(5, 7L, 3); // 15, long
sum(5, 7L, 2.9); // 14.9, double

然而在C++14中(C++17中也通用),当折叠表达式(fold expression)不足以解决问题时(折叠运算符通常只在一元或二元运算符的上下文中有效),递归是解决这个问题的最为标准的技术手段,同时也是模板编程中经久不衰的风格:

template <typename T1>
auto sum(const T& x1) {return x1;
}
template <typename T1, typename ... T>
auto sum(const T1& x1, const T& ... x) {return x1 + sum(x ...);
}

第一个重载函数(注意不是偏特化)是为了应对当sum()函数仅有一个参数的情况,这是将返回值。第二个重载比第一个重载具有更多的参数,其中第1个参数被显式地用来与剩余参数的和相加。递归会一直持续知道最后仅剩余一个参数,这是第一个重载会被调用,此时递归结束。这是对可变参模板中参数包进行解包的标准技巧,我们会在本书的其他地方多次看到类似的技巧。另外,编译器会把这些递归调用全部内联,然后生成所有参数相加的直接代码。

类模板的参数也可以是可变参的,它们可以有任意数量的类型参数,并且可以通过不同数量和类型的对象构造这个类。例如,我们构造一个类模板Group,它可以容纳不同类型的任意数量的对象,并且可以在转换为其中所持类型时返回正确的对象:

template <typename ... T>
struct Group;

这类模板的的通常实现仍然是递归的,使用深度嵌套的集成方式,尽管非递归的实现方式有时也是可能的。我们在下一节中会见到实例。递归需要在仅剩一个类型参数剩余时停止。这个过程通过偏特化来实现,因此我们把上面的泛型声明仅当作前向声明使用,然后再定义单模板参数时的偏特化实现:

template <typename T1>
struct Group<T1> {T1 t1_;Group() = default;explicit Group(const T1& t1) : t1_(t1) {}explicit Group(T1&& t1) : t1_(std::move(t1)) {}explicit operator const T1&() const { return t1_; }explicit operator T1&() { return t1_; }
};

这个类持有了一个T1类型的值,可以通过移动或者拷贝进行初始化,并在调用转换函数时返回一个T1类型的引用。任意参数数量的偏特化包含了第一个模板参数作为数据成员,同时也包含了与之配套的构造函数及转换函数,并且从Group类模板中继承了其他的类型。

template <typename T1, typename ... T>
struct Group<T1, T ...> : Group<T ...> {T1 t1_;Group() = default;explicit Group(const T1& t1, T&& ... t) :Group<T ...>(std::forward<T>(t) ...), t1_(t1) {} //第一个参数可以是左值引用explicit Group(T1&& t1, T&& ... t) :Group<T ...>(std::forward<T>(t) ...), t1_(std::move(t1)) {} //或者是右值引用,其余参数包通过完美转发处理explicit operator const T1&() const { return t1_; }explicit operator T1&() { return t1_; }
};

对于Group类中包含的每一种类型而言,有两种对其初始化的可能性,拷贝或者移动。幸运的是,我们不需要为每一种组合去实现拷贝或者移动操作。我们只需要对第一个模板参数编写2种构造函数,剩下的参数可以使用完美转发来处理。

现在,我们可以通过Group类来存放一些不同类型的值了(然而它不能处理同一类型的若干值,因为这样会引发歧义):

Group<int, long> g(3, 5);
int(g); // 3
long(t); // 5

这样显式地写出Group中的所有类型太过不方便了,还得确保这些类型与参数一一对应。通常的解决方案是使用一个辅助可变参函数模板用于创建对象,这样可以充分利用类型推导的效果。

template <typename ... T>
auto makeGroup(T&& ... t) {return Group<T ...>(std::forward<T>(t) ...);
}
auto g = makeGroup(3, 2.2, std::string("xyz"));
int(g); // 3
double(g); // 2.2
std::string(g); // "xyz"

注意C++标准库中存在一个类模板std::tuple,它相当于一个全功能版本的Group类的完全体。

可变参模板,尤其在完美转发的加持下,对于编写许多泛型类模板是非常有用的。例如,一个可以包含任意类型的向量:可以通过就地构造这些对象而不必拷贝,此时对于不同的类型需要以不同数量的参数来调用他们的构造函数,因此这里就必须使用可变参模板(std::vector中的emplace_back使用的就是可变参模板)。

在C++中,我们还有必要了解有一种类似于模板的实体,它看起来同时向一个类和函数——lambda表达式。下一节我们将讨论之。

6 Lambda表达式

在C++中,普通函数的语法含义被扩展至“可调用对象”,它是某种可以类似函数一样被调用的“东西”。可调用对象有哪些呢?例如:函数(当然), 函数指针或者具备operator()的对象——也被成为“仿函数”(functors):

void f(int i);
struct G {void operator()(int i);
};
f(5); // Function - 函数
G g; g(5); // Functor - 仿函数

通常我们会在需要使用是,在局部上下文中定义可调用对象。例如,在需要排序一个序列时,我们可能需要自定义一个比较函数,通常而言可以这么做:

bool compare(int i, int j) { return i < j; }
void do_work() {std::vector<int> v;
.....
std::sort(v.begin(), v.end(), compare);
}

然而,在C++中,函数不可以在其他函数中定义,因此我们的compair()函数必须在离调用位置相对远的地方定义,这样的分离会导致代码的可读性,易用性和便利性下降。有一种办法可以破除这样的限制——我们不能在函数中定义函数,那么在函数中定义对象总是可以的吧,那就定义一个可调用的对象试试:

void do_work() {std::vector<int> v;
.....
struct compare {bool operator()(int i, int j) const { return i < j; }
};
std::sort(v.begin(), v.end(), compare());
}

这样的定义非常紧凑,但是过于繁文缛节。事实上我们不需要给这样的类一个名字,甚至只需要它的一个实例即可。在C++11中,我们有了一个更佳的选择,即lambda表达式:

void do_work() {std::vector<int> v;
.....
auto compare = [](int i, int j) { return i < j; };
std::sort(v.begin(), v.end(), compare);
}

这几乎是能够得到的最紧凑的方案了。lambda表达式的返回值可以指定,但通常由编译器自动推导。Lambda表达式会创建一个对象,因此他也有一个类型,但是这个类型是由编译器创造的,因此这个对象的类型必须用auto修饰。

同时,lambda表达式也是一个对象,因此他也有自己的数据成员。当然,一个局部定义的可调用类也是拥有数据成员。通常,他们可以通过局部作用域内的局部变量进行初始化:

void do_work() {std::vector<double> v;.....struct compare_with_tolerance {const double tolerance; // 1explicit compare_with_tolerance(double tol) :tolerance(tol) {} // 2bool operator()(double x, double y) const {return x < y && std::abs(x - y) > tolerance; // 3}};double tolerance = 0.01;std::sort(v.begin(), v.end(), compare_with_tolerance(tolerance)); // 4
}

这样的做法依然是把简单的事情复杂化了。我们需要额外多次重复书写tolerance变量,在数据成员处,在构造函数参数处,在成员初始化列处……而lambda表达式可以把问题简化,因为它可以捕获局部变量。在局部类中,我们不被允许从局部作用域中引用变量,除非把他们传入构造函数参数中。但是对于lambda表达式,编译器会自动生成一个构造函数,并可以将局部作用域中的所有引用作为参数传递给它:

void do_work() {std::vector<double> v;.....double tolerance = 0.01;auto compare_with_tolerance = [=](auto x, auto y) {  // 参数为auto,注意这是C++14特性return x < y && std::abs(x - y) > tolerance;}std::sort(v.begin(), v.end(), compare_with_tolerance);
}

在这里,lambda表达式中的tolerance表示了局部同名变量。这个变量是通过lambda表达式的[=]方式“按值捕获”的,我们也可以通过[&]的方式对局部变量进行“按引用捕获”。在C++14中,我们可以将参数声明为auto类型,这样就轻松地把lambda表达式变成了模板(C++14的特性)。

Lambda表达式最常用于局部函数的场景。然而他们并不是真正的函数,他们是可调用对象,因此,他们也就不具备函数的一些特殊功能,例如重载。但是在本章中,我们需要学习的最后一个技巧就是如何“重载”一系列的lambda表达式。

首先,从概念上说,确实没有办法重载可调用对象。但是另一方面,我们却可以轻易地重载同一对象的若干个operator()成员方法,而成员方法也可以像函数那样被重载。当然, lambda表达式的operator()是由编译器生成的,并不是由我们自己决定的。因此,我们不可以强制编译器为给定的lambda表达式生成多个operator()。但是,我们学习过类的主要特性之一就是可以被继承。Lambda表达式是一个对象,因此也就属于某个类的类型,因此我们也可以继承它。如果一个类可以通过public方式继承基类,那么基类的所有public成员方法都会成为派生类的public成员方法。如果一个类从多个基类中public继承,那么他将会具有多个基类的所有public成员方法。如果这些继承而来的多个成员方法同名,那么他们就变成了可重载的函数集合,并且遵循通常的函数重载规则(因此也需要注意不要引发重载歧义,否则会导致编译错误)。

那么,我们需要创建一个类,它可以自动地继承自任意数量的基类。可以想象,最合适这个场景的工具就是可变参模板。正如我们之前学习的那样,通过递归的方式来解开可变参的参数包:

template <typename ... F> struct overload_set; //泛型模板,前向声明
template <typename F1> //单参数偏特化
struct overload_set<F1> : public F1 {overload_set(F1&& f1) : F1(std::move(f1)) {}overload_set(const F1& f1) : F1(f1) {}using F1::operator();
};
template <typename F1, typename ... F> //递归解包偏特化
struct overload_set<F1, F ...> : public F1, public overload_set<F ...> {overload_set(F1&& f1, F&& ... f) :F1(std::move(f1)), overload_set<F ...>(std::forward<F>(f) ...) {}overload_set(const F1& f1, F&& ... f) :F1(f1), overload_set<F ...>(std::forward<F>(f) ...) {}using F1::operator();
};
template <typename ... F> // 使用递归可变参模板
auto overload(F&& ... f) { // 注意auto返回值推导是C++14的特性return overload_set<F ...>(std::forward<F>(f) ...);
}

如上所示,overload_set是一个可变参模板,它的泛化形式必须在特化之前被提前声明,但不需要定义。第一个定义是仅有一个参数的偏特化,它继承了其中的一个lambda表达式,并添加了基类的operator()到派生类的public接口中。第二个定义是对于N个参数(N>1)的偏特化,它从第一个参数中继承了接口,并同样从剩余的N-1个参数中获得继承。最后,我们设计了一个辅助函数,通过传入的若干lambda表达式来构造一个overload_set对象。由于我们无法显式指定lambda表达式的类型,因此需要让函数模板自行推导,至此,我们就可以来使用这个对象了:

int i = 5;
double d = 7.3;
auto l = overload([](int* i) { std::cout << "i=" << *i << std::endl; },[](double* d) { std::cout << "d=" << *d << std::endl; }
);
l(&i); // i=5
l(&d); // d=5.3

这个解决方案并不完美,因为他仍存在可能的重载歧义的问题。在C++17中,我们可以做得更好,同时也可以展示一种非递归的解包方式,以下是C++17的版本:

template <typename ... F>
struct overload_set : public F ... {overload_set(F&& ... f) : F(std::forward<F>(f)) ... {}using F::operator() ...; // C++17
};
template <typename ... F>
auto overload(F&& ... f) {return overload_set<F ...>(std::forward<F>(f) ...);
}

在C++17偏特化对于这种可变参模板不再是必须的,它可以直接从参数包中继承接口(实现中的这个部分在C++14中也能工作,但是using声明的部分是C++17的特性)。模板辅助函数也是如法炮制,通过对所有的lambda表达式的类型推导,它构造了一个由这些lambda表达式类型组成的overload_set对象。这些lambda表达式自身通过完美转发传递给基类,在那里它们被用来初始化这个overload_set对象的所有基类对象(注意,lambda表达式是可移动的)。这种紧凑且直白的模板不再需要借助递归和偏特化等繁琐手段。它与之前版本的overload_set的用法一模一样,但是却能更好地处理重载歧义的情况。

在本书后续的章节中,我们还会看到这种类似的用法,例如在我们可以编写一些代码片段并让它们附加到对象中,在后续使用它们。

总结

模板,可变参模板以及lambda表达式是C++中最强大的功能特性,既简单易用,又能具备丰富的复杂细节。本章出现的例子可以帮助读者为后续章节中介绍设计模式的实现技术做好知识铺垫。我们可以利用现代C++语言,同时实现传统的以及新风格的设计模式。如果读者希望进一步深入探索这类复杂而又强大的技术,可以参考其他的专著,本章的末尾会有推荐。

至此,我们即将迈入下一个章节:关于C++中的一些惯用法,以及内存所有权相关的内容。

扩展阅读:

  • C++ Fundamentals
  • C++ Data Structure and Algorithms
  • Mastering C++ Programming

C++设计模式由浅入深(二)—— 类模板和函数模板相关推荐

  1. C++模板学习02(类模板)(类模板语法、类模板与函数模板的区别、类模板中的成员函数创建时机、类模板对象做函数参数、类模板与继承、类模板成员函数类外实现、类模板分文件编写、类模板与友元)

    C++引用详情(引用的基本语法,注意事项,做函数的参数以及引用的本质,常量引用) 函数高级C++(函数的默认参数,函数的占位参数,函数重载的基本语法以及注意事项) C++类和对象-封装(属性和行为作为 ...

  2. <C++模板:(函数模板)+(类模板)--详细说明>

    文章目录 一泛型编程 二:模板 1.函数模板 2.类模板 一泛型编程 泛型编程是代码复用的一种手段,通过编写与类型无关的通用代码,完成函数重载,实现代码复用.模板是泛型编程的基础. 二:模板 模板分为 ...

  3. C++_static,类模板、函数模板、namespace

    C++_static,类模板.函数模板.namespace 1.static 2.类模板 3.函数模板 4.namespace 5.深入,更多细节 参考:侯捷<C++面向对象高级编程>

  4. 【C++ 语言】面向对象 ( 模板编程 | 函数模板 | 类模板 )

    文章目录 函数模板 类模板 代码示例 函数模板 1. 模板编程 : 类似于 Java 中的泛型编程 ; ① 函数模板 : 对应着 Java 中的泛型方法 ; ② 类模板 : 对应 Java 中的泛型类 ...

  5. C++提高部分_C++类模板与函数模板的区别---C++语言工作笔记088

    然后我们再去看看类模板和函数模板的区别, 类模板没有自动类型推导,这一种使用方式. 类模板在模板参数列表中是可以有默认参数的. 用例子去说明一下,可以看到我们写了一个Person类,然后 这个类有两个 ...

  6. C++基础:模板,函数模板和类模板

    文章目录 1. 函数模板 2. 类模板 3. 模板特化 3.1 函数模板特化 3.2 类模板特化 4. 非类型模板参数 模板是允许函数或类通过泛性的形式表现或运行的特性 1. 函数模板 模板可以使函数 ...

  7. 模板有函数模板和类模板,这个在上学期的java课里面就学了,C++应该是一样的。

    模板有函数模板和类模板,这个在上学期的java课里面就学了,C++应该是一样的. .

  8. 类模板与函数模板区别

    类模板与函数模板区别主要有两点 类模板没有自动类型推导的使用方式 类模板在模板参数列表中可以有默认参数 测试代码 #include <iostream> #include <stri ...

  9. C++模板(函数模板,类模板)的基本使用与非类型模板参数与模板的特化

    C++模板 模板初阶 泛型编程 函数模板 函数模板概念 函数模板格式 函数模板的原理 函数模板的实例化 隐式实例化 显式实例化:在函数名后的<>中指定模板参数的实际类型 模板参数的匹配原则 ...

最新文章

  1. 如何正确的终止正在运行的子线程
  2. 【渝粤题库】国家开放大学2021春2320物流管理定量分析方法题目
  3. 一行 Python 代码能实现什么丧心病狂的功能? | CSDN博文精选
  4. JS与Jquery学习笔记(二)
  5. 以下哪些可以成为html文件的扩展名_今天在我的visual studio code里装了以下插件,现在用着很爽...
  6. 单链表反转--Java实现
  7. windows 界面设计规则与规范
  8. 视频搬运伪原创 视频修改MD5值
  9. MyBatis下载和使用(保姆级)
  10. Adobe又逆天!不用机器学习,用13.5M软件把《长安十二时辰》变成水墨动画
  11. spring 演变_团队的演变
  12. MGC TOKEN—必将超越PlusToken的搬砖套利项目!
  13. 75个JavaScript面试题集锦,内含解答,自测 JS 掌握程度
  14. A Verifiable Secret Shuffle of Homomorphic Encryptions学习笔记
  15. 腾讯云增值税发票识别
  16. 渗透之信息收集准备工作(利用辅助工具与网站查询)
  17. c语言中的位移位操作
  18. 谷歌2013年搜索热榜 全球榜曼德拉抢榜首 中国区小爸爸第一
  19. ios 去掉底部状态栏_iOS开发之状态栏隐藏(问题篇)
  20. android 跳转到应用通知设置界面【Android 8.0 需要特殊处理】

热门文章

  1. Python正则表达式匹配猫眼电影HTML信息
  2. web渗透-------信息收集
  3. 【Uni-App】用 uView 组件库中的u-picker 实现地区的 省-市-区 三级联动确认回显
  4. smack android 示例代码,Smack-Android客户端入门一
  5. 连续Hopfield神经网络的优化——旅行商问题优化计算
  6. android小程序日历,微信小程序:日历功能实现
  7. linux hub设备,Linux设备驱动之USB hub驱动(续)
  8. 唤客猫SCRM功能详解(一)
  9. ITE SoC HMI产品介绍
  10. 统计调查:目前国内银行有哪些;哪些银行开设网络银行服务。目前进驻中国的国外银行有哪些,哪些银行开设网络银行服务