函数模板

本节将介绍函数模板

初识函数模板

函数模板不是函数,只有实例化函数模板,编译器才能生成实际的函数定义。不过在很多时候,它看起来就像普通函数一样。

定义模板

下面是一个函数模板,返回两个对象中较大的那个:

template<typename T>
T max(T a,T b){
    return a > b ? a : b;
}

这应该很简单,即使我们还没有开始讲述函数模板的语法。

如果要声明一个函数模板,我们通常要使用:

template< 形参列表 > 函数声明

我们前面示例中的形参列表是 typename T,关键字 typename 顾名思义,引入了一个类型形参。

类型形参是 T,也可以使用其他标识符作为类型形参名(T 或 Ty 等,是约定的惯例),你也可以在需要的时候自定义一些有明确意义的名字。在调用函数模板 max 时,根据传入参数,编译器可以推导出类型形参的类型,实例化函数模板。我们需要传入支持函数模板操作的类型,如 int 或 重载了 > 运算符的类。注意 maxreturn 这意味着我们的模板形参 T 还需要是可复制/移动的,以便返回。

C++17 之前,类型 T 必须是可复制或移动才能传递参数。C++17 以后,即使复制构造函数和移动构造函数都无效,因为 C++17 强制的复制消除,也可以传递临时纯右值。

因为一些历史原因,我们也可以使用 class 关键字来定义模板类型形参。所以先前的模板 max 可以等价于:

template<class T>
T max(T a,T b){
    return a > b ? a : b;
}

但是与类声明不同,在声明模板类型形参时,不能使用 struct。

使用模板

下面展示了如何使用函数模板 max()

#include <iostream>

template<typename T>
T max(T a, T b) {
    return a > b ? a : b;
}

struct Test{
    int v_{};
    Test() = default;
    Test(int v) :v_(v) {}
    bool operator>(const Test& t) const{
        return this->v_ > t.v_;
    }
};

int main(){
    int a{ 1 };
    int b{ 2 };
    std::cout << "max(a, b) : " << ::max(a, b) << '\n';

    Test t1{ 10 };
    Test t2{ 20 };
    std::cout << "max(t1, t2) : " << ::max(t1, t2).v_ << '\n';

}

看起来的确和调用普通函数没区别,那么这样调用和普通函数相比,编译器会做什么呢?

编译器会实例化两个函数,也就是生成了一个参数为 int 的 max 函数,一个参数为 Test 的函数。

int max(int a, int b)
{
  return a > b ? a : b;
}

Test max(Test a, Test b)
{
  return a > b ? a : b;
}

我们可以使用 cppinsights 验证我们的想法。

用一句非常不严谨的话来说:

  • 模板,只有你“用”了它,才会生成实际的代码

这里的“用”,其实就是指代会隐式实例化,生成代码。

并且需要注意,同一个函数模板生成的不同类型的函数,彼此之间没有任何关系。


除了让编译器自己去推导函数模板的形参类型以外,我们还可以自己显式的指明:

template<typename T>
T max(T a, T b) {
    return a > b ? a : b;
}

int main(){
    int a{ 1 };
    int b{ 2 };
    max(a, b);          // 函数模板 max 被推导为 max<int>

    max<double>(a, b);  // 传递模板类型实参,函数模板 max 为 max<double>
}

模板参数推导

当使用函数模板(如 max())时,模板参数可以由传入的参数推导。如果类型 T 传递两个 int 型参数,那编译器就会认为 T 是 int 型。

然而,T 可能只是类型的“一部分”。若声明 max() 使用 const&

template<typename T>
T max(const T& a, const T& b) {
    return a > b ? a : b;
}

如果我们 max(1, 2) 或者说 max<int>(x,x),T 当然会是 int,但是函数形参类型会是 const int&

不过我们需要注意,有不少情况是没有办法进行推导的:

// 省略 max
using namespace std::string_literals;

int main(){
    max(1, 1.2);            // Error 无法确定你的 T 到底是要 int 还是 double
    max("luse"s, "乐");     // Error 无法确定你的 T 到底是要 std::string 还是 const char[N]
}

那么我们如何处理这种错误呢?可以使用前面提到的显式指定函数模板的(T)类型

max<double>(1, 1.2);           
max<std::string>("luse"s, "乐");

又或者说显式类型转换

max(static_cast<double>(1), 1.2);

但是 std::string 没有办法如此操作,我们可以显式的构造一个无名临时对象:

max("luse"s, std::string("乐"));    // Error 为什么?

此时就不是我们的 T 不明确了,而是函数模板 max 不明确,它会和标准库的 std::max 产生冲突,虽然我们没有使用 std::,但是根据 C++ 的查找规则,(实参依赖查找)ADL,依然可以查找到。

那么我们如何解决呢?很简单,进行有限定名字查找,即使用 ::std:: 说明,你到底要调用 “全局作用域”的 max,还是 std 命名空间中的 max。

::max("luse"s, std::string("乐"));

万能引用与引用折叠

所谓的万能引用(又称转发引用),即接受左值表达式那形参类型就推导为左值引用,接受右值表达式,那就推导为右值引用

比如:

template<typename T>
void f(T&&t){}

int a = 10;
f(a);       // a 是左值表达式,f 是 f<int&> 但是它的形参类型是 int&
f(10);      // 10 是右值表达式,f 是 f<int> 但它的形参类型是 int&&

被推导为 f<int&> 涉及到了特殊的推导规则:如果 P 是到无 cv 限定模板形参的右值引用(也就是转发引用)且对应函数的调用实参是左值,那么将到 A 的左值引用类型用于 A 的位置进行推导。


通过模板或 typedef 中的类型操作可以构成引用的引用,此时适用引用折叠(reference collapsing)规则:

  • 右值引用的右值引用折叠成右值引用,所有其他组合均折叠成左值引用

typedef int&  lref;
typedef int&& rref;
int n;
 
lref&  r1 = n; // r1 的类型是 int&
lref&& r2 = n; // r2 的类型是 int&
rref&  r3 = n; // r3 的类型是 int&
rref&& r4 = 1; // r4 的类型是 int&&
template <class Ty>
constexpr Ty&& forward(Ty& Arg) noexcept {
    return static_cast<Ty&&>(Arg);
}

int a = 10;            // 不重要
::forward<int>(a);     // 返回 int&& 因为 Ty 是 int,Ty&& 就是 int&&
::forward<int&>(a);    // 返回 int& 因为 Ty 是 int&,Ty&& 就是 int&
::forward<int&&>(a);   // 返回 int&& 因为 Ty 是 int&&,Ty&& 就是 int&&

有默认实参的模板类型形参

就如同函数形参可以有默认值一样,模板形参也可以有默认值。当然了,这里是“类型形参”(后面会讲非类型的)。

template<typename T = int>
void f();

f();            // 默认为 f<int>
f<double>();    // 显式指明为 f<double>
using namespace std::string_literals;

template<typename T1,typename T2,typename RT = 
    decltype(true ? T1{} : T2{}) >

RT max(const T1& a, const T2& b) { // RT 是 std::string
    return a > b ? a : b;
}

int main(){
    auto ret = ::max("1", "2"s);
    std::cout << ret << '\n';
}

#25 GCC 编译器有 BUG,自行注意。

以上这个示例你可能有很多疑问,我们第一次使用了多个模板类型形参,并且第三个模板类型形参给了默认值,但是这个值似乎有点难以理解,我们后面慢慢讲解。

max(const T1& a, const T2& b)

让 max 函数模板接受两个参数的时候不需要再是相同类型,那么这自然而然就会引入另一个问题了,如何确定返回类型?

typename RT = decltype(true ? T1{} : T2{})

我们从最里面开始看:

decltype(true ? T1{} : T2{})

这是一个三目运算符表达式。然后外面使用了 decltype 获取这个表达式的类型,那么问题是,为什么是 true 呢?以及为什么需要 T1{},T2{} 这种形式?

  1. 我们为什么要设置为 true

    其实无所谓,设置 false 也行,true 还是 false 不会影响三目表达式的类型。这涉及到了一些复杂的规则,简单的说就是三目表达式要求第二项和第三项之间能够隐式转换,然后整个表达式的类型会是 “公共”类型

    比如第二项是 int 第三项是 double,三目表达式当然会是 double。

    using T = decltype(true ? 1 : 1.2);
    using T2 = decltype(false ? 1 : 1.2);

    T 和 T2 都是 double 类型

  2. 为什么需要 T1{}T2{} 这种形式?

    没有办法,必须构造临时对象来写成这种形式,这里其实是不求值语境,我们只是为了写出这样一种形式,让 decltype 获取表达式的类型罢了。

    模板的默认实参的和函数的默认实参大部分规则相同。

    decltype(true ? T1{} : T2{}) 解决了。

    事实上上面的写法都十分的丑陋与麻烦,我们可以使用 auto 简化这一切。


template<typename T,typename T2>
auto max(const T& a, const T2& b) -> decltype(true ? a : b){
    return a > b ? a : b;
}

这是 C++11 后置返回类型,它和我们之前用默认模板实参 RT 的区别只是稍微好看了一点吗?

不,它们的返回类型是不一样的,如果函数模板的形参是类型相同 true ? a : b 表达式的类型是 const T&;如果是 max(1, 2) 调用,那么也就是 const int&;而前面的例子只是 Tint(前面都是用模板类型参数直接构造临时对象,而不是有实际对象,自然如此,比如 T{})。

假设以 max(1,1.0) 调用,那么自然返回类型不是 const T&

max(1, 2.0); // 返回类型为 double

下面的 max 定义也类似,涉及的规则不再强调。

使用 C++20 简写函数模板,我们可以直接再简化为:

decltype(auto) max(const auto& a, const auto& b)  {
    return a > b ? a : b;
}

效果和上面使用后置返回类型的写法完全一样;C++14 引入了两个特性:

  1. 返回类型推导(也就是函数可以直接写 auto 或 decltype(auto) 做返回类型,而不是像 C++11 那样,只是后置返回类型。

  2. decltype(auto)如果返回类型没有使用 decltype(auto),那么推导遵循模板实参推导的规则进行”。我们上面的 max 示例如果不使用 decltype(auto),按照模板实参的推导规则,是不会有引用和 cv 限定的,就只能推导出返回 T 类型。

大家需要注意后置返回类型和返回类型推导的区别,它们不是一种东西,后置返回类型虽然也是写的 auto ,但是它根本没推导,只是占位。

模板的默认实参无处不在,比如标准库的 std::vectorstd::string,当然了,这些都是类模板,我们以后会讲到。

非类型模板形参

既然有”类型模板形参“,自然有非类型的,顾名思义,也就是模板不接受类型,而是接受值或对象。

template<std::size_t N>
void f() { std::cout << N << '\n'; }

f<100>();

非类型模板形参有众多的规则和要求,目前,你简单认为需要参数是“常量”即可。

非类型模板形参当然也可以有默认值:

template<std::size_t N = 100>
void f() { std::cout << N << '\n'; }

f();     // 默认      f<100>
f<66>(); // 显式指明  f<66>

后续我们会有更多详细讲解和应用。

重载函数模板

函数模板与非模板函数可以重载。

这里会涉及到非常复杂的函数重载决议,即选择到底调用哪个函数。

我们用一个简单的示例展示一部分即可:

template<typename T>
void test(T) { std::puts("template"); }

void test(int) { std::puts("int"); }

test(1);        // 匹配到test(int)
test(1.2);      // 匹配到模板
test("1");      // 匹配到模板
  • 通常优先选择非模板的函数。

可变参数模板

和其他语言一样,C++ 也是支持可变参数的,我们必须使用模板才能做到。

老式 C 语言的变长实参有众多弊端,参见

同样的,它的规则同样众多繁琐,我们不会说太多,以后会用到的,我们当前还是在入门阶段。

我们提一个简单的需求:

我需要一个函数 sum,支持 sum(1,2,3.5,x,n...) 即函数 sum 支持任意类型,任意个数的参数进行调用,你应该如何实现?

首先就要引入一个东西:形参包

本节以 C++14 标准进行讲述。

模板形参包是接受零个或更多个模板实参(非类型、类型或模板)的模板形参。函数形参包是接受零个或更多个函数实参的函数形参。

template<typename...Args>
void sum(Args...args){}

这样一个函数,就可以接受任意类型的任意个数的参数调用,我们先观察一下它的语法和普通函数有什么不同。

模板中需要 typename 后跟三个点 Args,函数形参中需要用模板类型形参包后跟着三个点 再 args。

args 是函数形参包,Args 是类型形参包,它们的名字我们可以自定义。

args 里,就存储了我们传入的全部的参数,Args 中存储了我们传入的全部参数的类型。

那么问题来了,存储很简单,我们要如何把这些东西取出来使用呢?这就涉及到另一个知识:形参包展开

void f(const char*, int, double) { puts("值"); }
void f(const char**, int*, double*) { puts("&"); }

template<typename...Args>
void sum(Args...args){  // const char * args0, int args1, double args2
    f(args...);   // 相当于 f(args0, args1, args2)
    f(&args...);  // 相当于 f(&args0, &args1, &args2)
}

int main() {
    sum("luse", 1, 1.2);
}

sum 的 Args...args 被展开为 const char * args0, int args1, double args2

这里我们需要定义一个术语:模式

后随省略号且其中至少有一个形参包的名字的模式会被展开 成零个或更多个逗号分隔的模式实例。

&args...&args 就是模式,在展开的时候,模式,也就是省略号前面的一整个表达式,会被不停的填入对象并添加 &,然后逗号分隔。直至形参包的元素被消耗完。

那么根据这个,我们就能写出一些有意思的东西,比如一次性把它们打印出来:

template<typename...Args>
void print(const Args&...args){    // const char (&args0)[5], const int & args1, const double & args2
    int _[]{ (std::cout << args << ' ' ,0)... };
}

int main() {
    print("luse", 1, 1.2);
}

一步一步看:(std::cout << args << ' ' ,0)... 是一个包展开,那么它的模式是:(std::cout << args << ' ' ,0),实际展开的时候是:

(std::cout << arg0 << ' ' ,0), (std::cout << arg1 << ' ' ,0),(std::cout << arg2 << ' ' ,0)

很明显是为了打印,对,但是为啥要括号里加个逗号零呢?这是因为逗号表达式是从左往右执行的,返回最右边的值作为整个逗号表达式的值,也就是说:每一个 (std::cout << arg0 << ' ' ,0) 都会返回 0,这主要是为了符合语法,用来初始化数组。我们创建了一个数组 int _[] ,最终这些 0 会用来初始化这个数组,当然,这个数组本身没有用,只是为了创造合适的包展开场所

为了简略,我们不详细说明有哪些展开场所,不过我们上面使用到的是在花括号包围的初始化器中展开。

  • 只有在合适的形参包展开场所才能进行形参包展开

template<typename ...Args>
void print(const Args &...args) {
   (std::cout << args << " ")...; // 不是合适的形参包展开场所 Error!
}
细节
  template<typename...Args>
  void print(const Args&...args){    
      int _[]{ (std::cout << args << ' ' ,0)... };
  }

我们先前的函数模板 print 的实现还存在一些问题,如果形参包为空呢?

也就是说,假设我如此调用:

print(); // Error!

目前这个写法在参数列表为空时会导致 _非良构0 长度数组,在严格的模式下造成编译错误

解决方案是在数组中添加一个元素使其长度始终为正。

 template<typename...Args>
  void print(const Args&...args){    
      int _[]{ 0, (std::cout << args << ' ' ,0)... };
  }

大家不用感到奇怪,当形参包为空的时候,也就基本相当于

int _[]{ 0, };

也就是当做直接去掉 (std::cout << args << ' ' ,0)... 即可。

不用感到奇怪,花括号初始化器列表一直都允许有一个尾随的逗号。从古代 C++ 起 int a[] = {0, }; 就是合法的定义

可是我为什么要允许空参(print())调用呢?

这里的确完全没必要,不过我们出于教学目的,可以提及一下。


这个示例其实还有修改的余地:我们的本意并非是创造一个局部的数组,我们只是想执行其中的副作用(打印)。

让这个数组对象直到函数结束生存期才结束,实在是太晚了,我们可以创造一个临时的数组对象,这样它的生存期也就是那一行罢了:

template<typename...Args>
void print(const Args&...args) {
    using Arr = int[]; // 创建临时数组,需要使用别名
    Arr{ 0, (std::cout << args << ' ' ,0)... };
}

这没问题,但是还不够,在某些编译器(clang)这会造成编译器的警告,想要解决也很简单,将它变为一个弃值表达式,也就是转换到 void

弃值表达式 是只用来实施它的副作用的表达式。从这种表达式计算的值会被舍弃。

template<typename...Args>
void print(const Args&...args){
    using Arr = int[];
    (void)Arr{ 0, (std::cout << args << ' ' ,0)... };
}

此时编译器就开心了,不再有警告。并且也很合理,我们的确只是需要实施副作用而不需要“值”。


我们再给出一个数组的示例:

template<typename...Args>
void print(const Args&...args) {
    int _[]{ (std::cout << args << ' ' ,0)... };
}

template<typename T,std::size_t N, typename...Args>
void f(const T(&array)[N], Args...index) {
    print(array[index]...);
}

int main() {
    int array[10]{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    f(array, 1, 3, 5);
}

函数形参列表中展开。

我们复用了之前写的 print 函数,我们看新的 f 函数即可。

const T(&array)[N] 注意,这是一个数组引用,我们也使用到了非类型模板形参 N;加括号,(&array) 只是为了区分优先级。那么这里的 T 是 int,N 是 10,组成了一个数组类型。

不必感到奇怪,内建的数组类型,其 size 也是类型的一部分,这就如同 int[1]int[2] 不是一个类型一样,很正常。

print(array[index]...); 其中 array[index]... 是包展开,array[index] 是模式,实际展开的时候就是:

array[arg0], array[arg1], array[arg2]

到此,如果你自己写了,理解了这两个示例,那么你应该就能正确的使用形参包展开,那就可以正确的使用基础的可变参数函数。


那么回到最初的需求,实现一个 sum

#include <iostream>
#include <type_traits>

template<typename...Args,typename RT = std::common_type_t<Args...>>
RT sum(const Args&...args) {
    RT _[]{ static_cast<RT>(args)... };
    RT n{};
    for (int i = 0; i < sizeof...(args); ++i) {
        n += _[i];
    }
    return n;
}

int main() {
    double ret = sum(1, 2, 3, 4, 5, 6.7);
    std::cout << ret << '\n';       // 21.7
}

std::common_type_t 的作用很简单,就是确定我们传入的共用类型,说白了就是这些东西都能隐式转换到哪个,那就会返回那个类型。

RT _[]{ static_cast<RT>(args)... }; 创建一个数组,形参包在它的初始化器中展开,初始化这个数组,数组存储了我们传入的全部的参数。

因为窄化转换禁止了列表初始化中 int 到 double 的隐式转换,所以我们需要显式的转换为“公共类型” RT

至于 sizeof... 很简单,单纯的获取形参包的元素个数。

其实也可以不写这么复杂,我们不用手动写循环,直接调用标准库的求和函数。

我们简化一下:

template<typename...Args,typename RT = std::common_type_t<Args...>>
RT sum(const Args&...args) {
    RT _[]{ args... };
    return std::accumulate(std::begin(_), std::end(_), RT{});
}

RT{} 构造一个临时无名对象,表示初始值,std::begin 和 std::end 可以获取数组的首尾地址。


当然了,非类型模板形参也可以使用形参包,我们举个例子:

template<std::size_t... N>
void f(){
    std::size_t _[]{ N... }; // 展开相当于 1UL, 2UL, 3UL, 4UL, 5UL
    std::for_each(std::begin(_), std::end(_), 
        [](std::size_t n){
            std::cout << n << ' ';
        }
    );
}
f<1, 2, 3, 4, 5>();

这很合理,无非是让模板形参存储的不再是类型形参包,而是参数形参包罢了。


在后面的内容,我们还会向你展示新的形参包展开方式:C++17 折叠表达式。不用着急。

模板分文件

新手经常会有一个想法就是,对模板进行分文件,写成 .h .cpp 这种形式。

这显然是不可以的,我们给出了一个项目示例

后续会讲如何处理

在聊为什么不可以之前,我们必须先从头讲解编译链接,以及 #include 的知识,不然你将无法理解。

include 指令

先从预处理指令 #include 开始,你知道它会做什么吗?

很多人会告诉你,它就是简单的替换,的确,没有问题,但是我觉得不够明确,我给你几个示例:

array.txt

1,2,3,4,5

main.cpp

#include<iostream>

int main(){
    int arr[] = {
#include"array.txt"
    };
    for(int i = 0; i < sizeof(arr)/sizeof(int); ++i)
            std::cout<< arr[i] <<' ';
    std::cout<<'\n';
}

g++ main.cpp -o main

./main

直接编译运行,会打印出 1 2 3 4 5

#include"array.txt" 直接被替换为了 1,2,3,4,5,所以 arr 是:

int arr[] = {1,2,3,4,5};

或者我们可以使用 gcc 的 -E 选项来查看预处理之后的文件内容:

main2.cpp

int main(){
    int arr[] = {
#include"array.txt"
    };
}

去除头文件打印之类的是因为,iostream 的内容非常庞大,不利于我们关注数组 arr。

g++ -E main2.cpp

# 0 "main2.cpp"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "main2.cpp"
int main(){
    int arr[] = {
# 1 "array.txt" 1
1,2,3,4,5
# 4 "main2.cpp" 2
    };
}

# 0 # 1 这些是 gcc 的行号更改指令。不用过多关注,不是当前的重点,明白 #include 会进行替换即可。

分文件的原理是什么?

我们通常将函数声明放在 .h 文件中,将函数定义放在 .cpp 文件中,我们只需要在需要使用的文件中 include 一个 .h 文件;我们前面也说了,include 就是复制,事实上是把函数声明复制到了我们当前的文件中。

//main.cpp
#include "test.h"

int main(){
    f();    // 非模板,OK
}

test.h 只是存放了函数声明,函数定义在 test.cpp 中,我们编译的时候是选择编译了 main.cpptest.cpp 这两个文件,那么为什么程序可以成功编译运行呢?

是怎么找到函数定义的呢?明明我们的 main.cpp 其实预处理过后只有函数声明而没有函数定义。

这就是链接器做的事情,如果编译器在编译一个翻译单元(如 main.cpp)的时候,如果发现找不到函数的定义,那么就会空着一个符号地址,将它编译为目标文件。期待链接器在链接的时候去其他的翻译单元找到定义来填充符号。

我们的 test.cpp 里面存放了 f 的函数定义,并且具备外部链接,在编译成目标文件之后之后,和 main.cpp 编译的目标文件进行链接,链接器能找到函数 f 的符号。

不单单是函数,全局变量等都是这样,这是编译链接的基本原理和步骤

类会有所不同,总而言之后续视频会单独讲解的。


那么不能模板不能分文件的原因就显而易见了,我们在讲使用模板的时候就说了:

  • 模板,只有你“用”了它,才会生成实际的代码

你单纯的放在一个 .cpp 文件中,它不会生成任何实际的代码,自然也没有函数定义,也谈不上链接器找符号了。

所以模板通常是直接放在 .h 文件中,而不会分文件。或者说用 .hpp 这种后缀,这种约定俗成的,代表这个文件里放的是模板。

总结

事实上函数模板的各种知识远不止如此,但也足够各位目前的学习与使用了。

不用着急,后面会有更多的技术和函数模板一起结合使用的,本节所有的代码示例请务必全部理解和自己亲手写一遍,通过编译,有任何不懂一定要问,提出来。

Last updated