0%

C++ Primer - 第 16 章 模板与泛型编程

16.1 定义模板

16.1.1 函数模板

  1. 我们可以定义一个通用的函数模板(function template),而不是为每个类型都定义一个新函数。一个函数模板就是一个公式,可用来生成针对特定类型的函数版本。

  2. 模板定义以关键字 template 开始,后跟一个模板参数列表(template parameter list),这是一个逗号分隔的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。

    Note: 在模板定义中,模板参数列表不能为空。

  3. 模板参数表示在类或函数定义中用到的类型或值。当使用模板时,我们(隐式地或显式地)指定模板实参(template argument),将其绑定到模板参数上。

  4. 当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。即,编译器使用实参的类型来确定绑定到模板参数 T 的类型。

    编译器用推断出的模板参数来为我们实例化(instantiate)一个特定版本的函数。当编译器实例化一个模板时,它使用实际的模板实参代替对应的模板参数来创建出模板的一个新实例(instantiation)。

  5. 模板类型参数(type parameter)。一般来说,我们可以将类型参数看作类型说明符,就像内置类型或类类型说明符一样使用。特别是,类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。

  6. 类型参数前必须使用关键字 classtypename

    1
    2
    3
    // 错误:U 之前必须加上 class 或 typename
    template <typename T, U>
    T calc(const T &, const U &);

    在模板参数列表中,这两个关键字的含义相同,可以互换使用。一个模板参数列表中可以同时使用这两个关键字:

    1
    2
    3
    // 正确:在模板参数列表中,typename 和 class 没有什么不同
    template <typename T, class U>
    T calc(const T &, const U &);
  7. 除了定义类型参数,还可以在模板中定义非类型参数(nontype parameter)。一个非类型参数表示一个值而非一个类型。 我们通过一个特定的类型名而非关键字 classtypename 来指定非类型参数。

    当一个模板被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替。
    这些值必须是常量表达式,从而允许编译器在编译时实例化模板。

    例如,我们可以编写一个 compare 版本处理字符串字面常量。这种字面常量是 const char 的数组。由于不能拷贝一个数组,所以我们将自己的参数定义为数组的引用,由于我们希望能比较不同长度的字符串字面常量,因此为模板定义了两个非类型的参数。第一个模板参数表示第一个数组的长度,第二个参数表示第二个数组的长度:

    1
    2
    3
    4
    5
    template <unsigned N, unsigned M>
    int compare(const char (&p1)[N], const char (&p2)[M])
    {
    return strcmp(p1, p2);
    }

    当我们调用这个版本的 compare 时:

    1
    compare("hi", "mom")

    编译器会使用字面常量的大小来代替 NM,从而实例化模板。记住,编译器会在一个字符串字面常量的末尾插入一个空字符作为终结符,因此编译器会实例化出如下版本:

    1
    int compare(const char (&p1)[3], const char (&p2)[4])

    (需要注意对于字符串字面常量来说,sizeofstrlen 的区别——博主注。)

  8. 一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存期,我们不能用一个普通(非 static)局部变量或动态对象作为指针或引用非类型模板参数的实参。指针参数也可以用 nullptr 或一个值为 0 的常量表达式来实例化。

  9. 在模板定义内,模板非类型参数是一个常量值。在需要常量表达式的地方,可以使用非类型参数,例如,指定数组大小。

    Note: 非类型模板参数的模板实参必须是常量表达式。

  10. 函数模板可以声明为 inlineconstexpr 的,如同非模板函数一样。inlineconstexpr 说明符放在模板参数列表之后,返回类型之前:

    1
    2
    3
    4
    5
    6
    // 正确:inline 说明符跟在模板参数列表之后
    template <typename T>
    inline T min(const T &, const T &);
    // 错误:inline 说明符的位置不正确
    inline template <typename T>
    T min(const T &, const T &);
  11. 编写泛型代码的两个重要原则:

    • 模板中的函数参数是 const 的引用。
    • 函数体中的条件判断仅使用 < 比较运算。
  12. Best Practices: 模板程序应该尽量减少对实参类型的要求。

  13. 当编译器遇到一个模板定义时,它并不生成代码。只有当我们实例化出模板的一个特定版本时,编译器才会生成代码。

  14. 通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。

    模板则不同:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。

    Note:函数模板和类模板成员函数的定义通常放在头文件中。

  15. 模板包含两种名字:

    • 那些不依赖于模板参数的名字
    • 那些依赖于模板参数的名字


    当使用模板时,所有不依赖于模板参数的名字都必须是可见的,这是由模板的提供者来保证的。而且,模板的提供者必须保证,当模板被实例化时,模板的定义,包括类模板的成员的定义,也必须是可见的。

    用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的,这是由模板的用户来保证的。

  16. 模板的设计者应该提供一个头文件,包含模板定义以及在类模板或成员定义中用到的所有名字的声明。模板的用户必须包含模板的头文件,以及用来实例化模板的任何类型的头文件。

  17. 模板直到实例化时才会生成代码,这一特性影响了我们何时才会获知模板内代码的编译错误。通常,编译器会在三个阶段报告错误。

    • 第一个阶段是编译模板本身时。在这个阶段,编译器通常不会发现很多错误。编译器可以检查语法错误,例如忘记分号或者变量名拼错等,但也就这么多了。
    • 第二个阶段是编译器遇到模板使用时。在此阶段,编译器仍然没有很多可检查的。对于函数模板调用,编译器通常会检查实参数目是否正确。它还能检查参数类型是否匹配。对于类模板,编译器可以检查用户是否提供了正确数目的模板实参,但也仅限于此了。
    • 第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误。依赖于编译器如何管理实例化,这类错误可能在链接时才报告。
  18. WARNING: 保证传递给模板的实参支持模板所要求的操作,以及这些操作在模板中能正确工作,是调用者的责任。

16.1.2 类模板

  1. 类模板(class template)。与函数模板的不同之处是,编译器不能为类模板推断模板参数类型。如我们已经多次看到的,为了使用类模板,我们必须在模板名后的尖括号中提供额外信息——用来代替模板参数的模板实参列表。

  2. 类似函数模板,类模板以关键字 template 开始,后跟模板参数列表。

  3. 当使用一个类模板时,我们必须提供额外信息。这些额外信息是显式模板实参(explicit template argument)列表,它们被绑定到模板参数。编译器使用这些模板实参来实例化出特定的类。

  4. Note: 一个类模板的每个实例都形成一个独立的类。

  5. 类模板的名字不是一个类型名。类模板用来实例化类型,而一个实例化的类型总是包含模板参数的。

  6. 一个类模板中的代码如果使用了另外一个模板,通常不将一个实际类型(或值)的名字用作其模板实参。相反的,我们通常将模板自己的参数当作被使用模板的实参。

  7. 与其他任何类相同,我们既可以在类模板内部,也可以在类模板外部为其定义成员函数,且定义在类模板内的成员函数被隐式声明为内联函数

  8. 类模板的成员函数具有和模板相同的模板参数。因而,定义在类模板之外的成员函数就必须以关键字 template 开始,后接类模板参数列表。当我们在类外定义一个成员时,必须说明成员属于哪个类。而且,从一个模板生成的类的名字中必须包含其模板实参。当我们定义一个成员函数时,模板实参与模板形参相同。例如像下面这样定义 Blob 模板类的一些成员函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    template <typename T>
    void Blob<T>::check(size_type i, const std::string &msg) const
    {
    if (i >= data->size())
    throw std::out_of_range(msg);
    }

    template <typename T>
    T &Blob<T>::back()
    {
    check(o, "back on empty Blob");
    return data->back();
    }

    template <typename T>
    T &Blob<T>::operator[](size_type i)
    {
    // 如果 i 太大,check 会抛出异常,阻止访问一个不存在的元素
    check(i, "subscript out of range");
    return (*data)[i];
    }

    template <typename T>
    void Blob<T>::pop_back()
    {
    check(0, "pop back on empty Blob");
    data->pop_back();
    }
  9. 与其他任何定义在类模板外的成员一样,构造函数的定义要以模板参数开始:

    1
    2
    template <typename T>
    Blob<T>::Blob() : data(std::make_shared<std::vector<T>>()) {}
  10. 如果一个成员函数没有被使用,则它不会被实例化。成员函数只有在被用到时才进行实例化,这一特性使得即使某种类型不能完全符合模板操作的要求,我们仍然能用该类型实例化类。

    Note:默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。

  11. 当我们使用一个类模板类型时必须提供模板实参,但这一规则有一个例外。在类模板自己的作用域中,我们可以直接使用模板名而不提供实参

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 若试图访问一个不存在的元素,BlobPtr 抛出一个异常
    template <typename T>
    class BlobPtr
    {
    public:
    BlobPtr() : curr(0) {}
    BlobPtr(Blob<T> &a, size_t sz = 0) : wptr(a.data), curr(sz) {}
    T &operator*() const
    {
    auto p = check(curr, "dereference past end");
    return (*p)[curr]; // (*p) 为本对象指向的 vector
    }
    // 递增和递减
    BlobPtr &operator++(); // 前置运算符
    BlobPtr &operator--();

    private:
    // 若检查成功,check 返回一个指向 vector 的 shared_ptr
    std::shared_ptr<std::vector<T>> check(std::size_t, const std::string &) const;
    // 保存一个 weak_ptr,表示底层 vector 可能被销毁
    std::weak_ptr<std::vector<T>> wptr;
    std::size_t curr; // 数组中的当前位置
    };

    BlobPtr 的前置递增和递减成员返回 BlobPtr&,而不是 BlobPtr<T>&当我们处于一个类模板的作用域中时,编译器处理模板自身引用时就好像我们已经提供了与模板参数匹配的实参一样。 即,就好像我们这样编写代码一样:

    1
    2
    BlobPtr<T> &operator++();
    BlobPtr<T> &operator--();
  12. 当我们在类模板外定义其成员时,必须记住,我们并不在类的作用域中,直到遇到类名才表示进入类的作用域

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 后置:递增/递减对象但返回原值
    template <typename T>
    BlobPtr<T> BlobPtr<T>::operator++(int)
    {
    // 此处无须检查;调用前置递增时会进行检查
    BlobPtr ret = *this; // 保存当前值
    ++*this; // 推进一个元素;前置 ++ 检查递增是否合法
    return ret; // 返回保存的状态
    }

    由于返回类型位于类的作用域之外,我们必须指出返回类型是一个实例化的 BlobPtr,它所用类型与类实例化所用类型一致。在函数体内,我们已经进入类的作用域,因此在定义 ret 时无须重复模板实参。如果不提供模板实参,则编译器将假定我们使用的类型与成员实例化所用类型一致。因此,ret 的定义与如下代码等价:

    1
    BlobPtr<T> ret = *this;

    Note: 在一个类模板的作用域内,我们可以直接使用模板名而不必指定模板实参。

  13. 当一个类包含一个友元声明时,类与友元各自是否是模板是相互无关的。如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。

  14. 类模板与另一个(类或函数)模板间友好关系的最常见的形式是建立对应实例及其友元间的友好关系。

  15. 为了引用(类或函数)模板的一个特定实例,我们必须首先声明模板自身。一个模板声明包括模板参数列表:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 前置声明,在 Blob 中声明友元所需要的
    template <typename>
    class BlobPtr;

    template <typename>
    class Blob; // 运算符 == 中的参数所需要的

    template <typename T>
    bool operator==(const Blob<T> &, const Blob<T> &);

    template <typename T>
    class Blob
    {
    // 每个 Blob 实例将访问权限授予用相同类型实例化的 BlobPtr 和相等运算符
    friend class BlobPtr<T>;
    friend bool operator==<T>(const Blob<T> &, const Blob<T> &);
    // 其他成员定义
    };

    我们首先将 BlobBlobPtroperator== 声明为模板。这些声明是 operator== 函数的参数声明以及 Blob 中的友元声明所需要的。

    友元的声明用 Blob 的模板形参作为它们自己的模板实参。因此,友好关系被限定在用相同类型实例化的 BlobBlobPtr 相等运算符之间:

    1
    2
    Blob<char> ca; // BlobPtr<char> 和 operator==<char> 都是本对象的友元
    Blob<int> ia; // BlobPtr<int> 和 operator==<int> 都是本对象的友元

    BlobPtr<char> 的成员可以访问 ca(或任何其他 Blob<char> 对象)的非 public 部分,但 caia(或任何其他 Blob<int> 对象)或 Blob 的任何其他实例都没有特殊访问权限。

  16. 一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    // 前置声明,在将模板的一个特定实例声明为友元时要用到
    template <typename T>
    class Pal;

    class C // C 是一个普通的非模板类
    {
    friend class Pal<C>; // 用类 C 实例化的 Pal 是 C 的一个友元

    // Pal2 的所有实例都是 C 的友元;这种情况无须前置声明
    template <typename T>
    friend class Pal2;
    };

    template <typename T>
    class C2 // C2 本身是一个类模板
    {
    // C2 的每个实例将相同实例化的 Pal 声明为友元
    friend class Pal<T>; // Pal 的模板声明必须在作用域之内

    // Pal2 的所有实例都是 C2 的每个实例的友元,不需要前置声明
    template <typename X>
    friend class Pal2;

    // Pal3 是一个非模板类,它是 C2 所有实例的友元
    friend class Pal3; // 不需要 Pal3 的前置声明
    };

    为了让所有实例成为友元,友元声明中必须使用与类模板本身不同的模板参数。

  17. 在新标准中,我们可以将模板类型参数声明为友元

    1
    2
    3
    4
    5
    6
    template <typename Type>
    class Bar
    {
    friend Type; // 将访问权限授予用来实例化 Bar 的类型
    // ...
    };

    此处我们将用来实例化 Bar 的类型声明为友元。因此,对于某个类型名 FooFoo 将成为 Bar<Foo> 的友元,Sales_data 将成为 Bar<Sales_data> 的友元,依此类推。

    值得注意的是,虽然友元通常来说应该是一个类或是一个函数,但我们完全可以用一个内置类型来实例化 Bar。这种与内置类型的友好关系是允许的,以便我们能用内置类型来实例化 Bar 这样的类。

  18. 类模板的一个实例定义了一个类类型,与任何其他类类型一样,我们可以定义一个 typedef 来引用实例化的类:

    1
    typedef Blob<string> StrBlob;

    由于模板不是一个类型,我们不能定义一个 typedef 引用一个模板。 即,无法定义一个 typedef 引用 Blob<T>

    但是,新标准允许我们为类模板定义一个类型别名

    1
    2
    3
    template <typename T>
    using twin = pair<T, T>;
    twin<string> authors; // authors 是一个 pair<string, string>

    在这段代码中,我们将 twin 定义为成员类型相同的 pair 的别名。这样,twin 的用户只需指定一次类型。一个模板类型别名是一族类的别名:

    1
    2
    twin<int> win_loss; // win_loss 是一个 pair<int, int>
    twin<double> area; // area 是一个 pair<double, double>

    就像使用类模板一样,当我们使用 twin 时,需要指出希望使用哪种特定类型的 twin当我们定义一个模板类型别名时,可以固定一个或多个模板参数

    1
    2
    3
    4
    5
    6
    template <typename T>
    using partNo = pair<T, unsigned>;

    partNo<string> books; // books 是一个 pair<string, unsigned>
    partNo<Vehicle> cars; // cars 是一个 pair<Vehicle, unsigned>
    partNo<Student> kids; // kids 是一个 pair<Student, unsigned>

    这段代码中我们将 partNo 定义为一族类型的别名,这族类型是 second 成员为 unsignedpairpartNo 的用户需要指出 pairfirst 成员的类型,但不能指定 second 成员的类型。

  19. 与任何其他类相同,类模板可以声明 static 成员

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template <typename T>
    class Foo
    {
    public:
    static std::size_t count() { return ctr; }
    // 其他接口成员
    private:
    static std::size_t ctr;
    // 其他实现成员
    };

    在这段代码中,Foo 是一个类模板,它有一个名为 countpublic static 成员函数和一个名为 ctrprivate static 数据成员。每个 Foo 的实例都有其自己的 static 成员实例。即,对任意给定类型 X,都有一个 Foo<X>::ctr 和一个 Foo<X>::count 成员。所有 Foo<X> 类型的对象共享相同的 ctr 对象和 count 函数。例如,

    1
    2
    3
    4
    // 实例化 static 成员 Foo<string>::ctr 和 Foo<string>::count
    Foo<string> fs;
    // 所有三个对象共享相同的 Foo<int>::ctr 和 Foo<int>::count 成员
    Foo<int> fi, fi2, fi3;

    与任何其他 static 数据成员相同,模板类的每个 static 数据成员必须有且仅有一个定义。但是,类模板的每个实例都有一个独有的 static 对象。因此,与定义模板的成员函数类似,我们将 static 数据成员也定义为模板:

    1
    2
    template <typename T>
    size_t Foo<T>::ctr = 0; // 定义并初始化 ctr

    与类模板的其他任何成员类似,定义的开始部分是模板参数列表,随后是我们定义的成员的类型和名字。与往常一样,成员名包括成员的类名,对于从模板生成的类来说,类名包括模板实参。因此,当使用一个特定的模板实参类型实例化 Foo 时,将会为该类类型实例化一个独立的 ctr,并将其初始化为 0。

    与非模板类的静态成员相同,我们可以通过类类型对象来访问一个类模板的 static 成员,也可以使用作用域运算符直接访问成员。当然,为了通过类来直接访问 static 成员,我们必须引用一个特定的实例

    1
    2
    3
    4
    Foo<int> fi;                 // 实例化 Foo<int> 类和 static 数据成员 ctr
    auto ct = Foo<int>::count(); // 实例化 Foo<int>::count
    ct = fi.count(); // 使用 Foo<int>::count
    ct = Foo::count(); // 错误:使用哪个模板实例的 count?

    类似任何其他成员函数,一个 static 成员函数只有在使用时才会实例化。

16.1.3 模板参数

  1. 模板参数遵循普通的作用域规则。一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。与任何其他名字一样,模板参数会隐藏外层作用域中声明的相同名字。但是,与大多数其他上下文不同,在模板内不能重用模板参数名

    1
    2
    3
    4
    5
    6
    7
    typedef double A;
    template <typename A, typename B>
    void f(A a, B b)
    {
    A tmp = a; // tmp 的类型为模板参数 A 的类型,而非 double
    double B; // 错误:重声明模板参数 B
    }

    正常的名字隐藏规则决定了 Atypedef 被类型参数 A 隐藏。因此,tmp 不是一个 double,其类型是使用 f 时绑定到类型参数 A 的类型。由于我们不能重用模板参数名,声明名字为 B 的变量是错误的。

    由于参数名不能重用,所以一个模板参数名在一个特定模板参数列表中只能出现一次

    1
    2
    // 错误:非法重用模板参数名 V
    template <typename V, typename V> // ...
  2. 模板声明必须包含模板参数

    1
    2
    3
    4
    5
    // 声明但不定义 compare 和 Blob
    template <typename T>
    int compare(const T &, const T &);
    template <typename T>
    class Blob;

    与函数参数相同,声明中的模板参数的名字不必与定义中相同

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 3 个 calc 都指向相同的函数模板
    template <typename T>
    T calc(const T &, const T &); // 声明
    template <typename U>
    U calc(const U &, const U &); // 声明
    // 模板的定义
    template <typename Type>
    Type calc(const Type &a, const Type &b)
    {
    /*...*/
    }

    当然,一个给定模板的每个声明和定义必须有相同数量和种类(即,类型或非类型)的参数

  3. Best Practices:一个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。

  4. 我们用作用域运算符(::)来访 static 成员和类型成员。 在普通(非模板)代码中,编译器掌握类的定义。因此,它知道通过作用域运算符访问的名字是类型还是 static 成员。

    但对于模板代码就存在困难。例如,假定 T 是一个模板类型参数,当编译器遇到类似 T::mem 这样的代码时,它不会知道 mem 是一个类型成员还是一个 static 数据成员,直至实例化时才会知道。但是,为了处理模板,编译器必须知道名字是否表示一个类型。例如,假定 T 是一个类型参数的名字,当编译器遇到如下形式的语句时:

    1
    T::size_type *p;

    它需要知道我们是正在定义一个名为 p 的变量还是将一个名为 size_typestatic 数据成员与名为 p 的变量相乘。

    默认情况下,C++ 语言假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模板类型参数的类型成员,就必须显式告诉编译器该名字是一个类型。我们通过使用关键字 typename 来实现这一点

    1
    2
    3
    4
    5
    6
    7
    8
    template <typename T>
    typename T::value_type top(const T &c)
    {
    if (!c.empty())
    return c.back();
    else
    return typename T::value_type();
    }

    Note:当我们希望通知编译器一个名字表示类型时,必须使用关键字 typename,而不能使用 class

  5. 就像我们能为函数参数提供默认实参一样,我们也可以提供默认模板实参(default template argument)。在新标准中,我们可以为函数和类模板提供默认实参。 而更早的 C++ 标准只允许为类模板提供默认实参。

    例如,我们重写 compare,默认使用标准库的 less 函数对象模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // compare 有一个默认模板实参 less<T> 和一个默认函数实参 F()
    template <typename T, typename F = less<T>>
    int compare(const T &v1, const T &v2, F f = F())
    {
    if (f(v1, v2))
    return -1;
    if (f(v2, v1))
    return 1;
    return 0;
    }

    在这段代码中,我们为模板添加了第二个类型参数,名为 F,表示可调用对象的类型;并定义了一个新的函数参数 f,绑定到一个可调用对象上。

    我们为此模板参数提供了默认实参,并为其对应的函数参数也提供了默认实参。默认模板实参指出 compare 将使用标准库的 less 函数对象类,它是使用与 compare 一样的类型参数实例化的。默认函数实参指出 f 将是类型 F 的一个默认初始化的对象。

    当用户调用这个版本的 compare 时,可以提供自己的比较操作,但这并不是必需的:

    1
    2
    3
    4
    bool i = compare(0, 42); // 使用 less;i 为 -1
    // 结果依赖于 item1 和 item2 中的 isbn
    Sales_data item1(cin), item2(cin);
    bool j = compare(item1, item2, compareIsbn);

    第一个调用使用默认函数实参,即,类型 less<T> 的一个默认初始化对象。在此调用中,Tint,因此可调用对象的类型为 less<int>compare 的这个实例化版本将使用 less<int> 进行比较操作。

    在第二个调用中,我们传递给 compare 三个实参:compareIsbn 和两个 Sales_data 类型的对象。当传递给 compare 三个实参时,第三个实参的类型必须是一个可调用对象,该可调用对象的返回类型必须能转换为 bool 值,且接受的实参类型必须与 compare 的前两个实参的类型兼容。与往常一样,模板参数的类型从它们对应的函数实参推断而来。在此调用中,T 的类型被推断为 Sales_dataF 被推断为 compareIsbn 的类型。

    与函数默认实参一样,对于一个模板参数,只有当它右侧的所有参数都有默认实参时,它才可以有默认实参。

  6. 无论何时使用一个类模板,我们都必须在模板名之后接上尖括号。尖括号指出类必须从一个模板实例化而来。特别是,如果一个类模板为其所有模板参数都提供了默认实参,且我们希望使用这些默认实参,就必须在模板名之后跟一个空尖括号对(类似于函数调用时必须要要有 ()——博主注):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    template <class T = int> // T 默认为 int
    class Numbers
    {
    public:
    Numbers(T v = 0) : val(v) {}
    // 对数值的各种操作
    private:
    T val;
    };
    Numbers<long double> lots_of_precision;
    Numbers<> average_precision; // 空 <> 表示我们希望使用默认类型

    此例中我们实例化了两个 Numbers 版本:average_precision 是用 int 代替 T 实例化得到的:lots_of_precision 是用 long double 代替 T 实例化而得到的。

16.1.4 成员模板

  1. 一个类(无论是普通类还是类模板)可以包含本身是模板的成员函数。这种成员被称为成员模板(member template)。成员模板不能是虚函数。

  2. 作为普通类包含成员模板的例子,我们定义一个类,类似 unique_ptr 所使用的默认删除器类型。类似默认删除器,我们的类将包含一个重载的函数调用运算符,它接受一个指针并对此指针执行 delete。与默认删除器不同,我们的类还将在删除器被执行时打印一条信息。由于希望删除器适用于任何类型,所以我们将调用运算符定义为一个模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 函数对象类,对给定指针执行 delete
    class DebugDelete
    {
    public:
    DebugDelete(std::ostream &s = std::cerr) : os(s) {}
    // 与任何函数模板相同,T 的类型由编译器推断
    template <typename T>
    void operator()(T *p) const
    {
    os << "deleting unique_ptr" << std::endl;
    delete p;
    }

    private:
    std::ostream &os;
    };

    我们可以像下面这样使用 DebugDelete 类:

    1
    2
    3
    4
    5
    6
    double *p = new double;
    DebugDelete d; // 可像 delete 表达式一样使用的对象
    d(p); // 调用 DebugDelete::operator()(double*),释放 p
    int *ip = new int;
    // 在一个临时 DebugDelete 对象上调用 operator()(int*)
    DebugDelete()(ip);

    由于调用一个 DebugDelete 对象会 delete 其给定的指针,我们也可以将 DebugDelete 用作 unique_ptr 的删除器。为了重载 unique_ptr 的删除器,我们在尖括号内给出删除器类型,并提供一个这种类型的对象给 unique_ptr 的构造函数:

    1
    2
    3
    4
    5
    6
    // 销毁 p 指向的对象
    // 实例化 DebugDelete::operator()<int>(int*)
    unique_ptr<int, DebugDelete> p(new int, DebugDelete());
    // 销毁 sp 指向的对象
    // 实例化 DebugDelete::operator()<string>(string*)
    unique_ptr<string, DebugDelete> sp(new string, DebugDelete());
  3. 对于类模板,我们也可以为其定义成员模板。在此情况下,类和成员各自有自己的、独立的模板参数。例如,我们将为 Blob 类定义一个构造函数,它接受两个选代器,表示要拷贝的元素范围。由于我们希望支持不同类型序列的选代器,因此将构造函数定义为模板:

    1
    2
    3
    4
    5
    6
    7
    template <typename T>
    class Blob
    {
    template <typename It>
    Blob(It b, It e);
    // ...
    };

    当我们在类模板外定义一个成员模板时,必须同时为类模板和成员模板提供模板参数列表。类模板的参数列表在前,后跟成员自己的模板参数列表

    1
    2
    3
    4
    5
    template <typename T>  // 类的类型参数
    template <typename It> // 构造函数的类型参数
    Blob<T>::Blob(It b, It e) : data(std::make_shared<std::vector<T>>(b, e))
    {
    }
  4. 为了实例化一个类模板的成员模板,我们必须同时提供类和函数模板的实参。 与往常一样,我们在哪个对象上调用成员模板,编译器就根据该对象的类型来推断类模板参数的实参。与普通函数模板相同,编译器通常根据传递给成员模板的函数实参来推断它的模板实参:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int ia[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    vector<long> vi = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    list<const char *> w = {"now", "is", "the", "time"};
    // 实例化 Blob<int> 类及其接受两个 int* 参数的构造函数
    Blob<int> a1(begin(ia), end(ia));
    // 实例化 Blob<int> 类的接受两个 vector<long>::iterator 的构造函数
    Blob<int> a2(vi.begin(), vi.end());
    // 实例化 Blob<string> 及其接受两个 list<const char*>::iterator 参数的构造函数
    Blob<string> a3(w.begin(), w.end());

16.1.5 控制实例化

  1. 当模板被使用时才会进行实例化这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。

  2. 在大系统中,在多个文件中实例化相同模板的额外开销可能非常严重。在新标准中,我们可以通过显式实例化(explicit instantiation)来避免这种开销(与自动实例化相对——博主注)。一个显式实例化有如下形式:

    1
    2
    extern template declaration; // 实例化声明
    template declaration; // 实例化定义

    declaration 是一个类或函数声明,其中所有模板参数已被替换为模板实参。例如,

    1
    2
    3
    // 实例化声明与定义
    extern template class Blob<string>; // 声明
    template int compare(const int &, const int &); // 定义

    当编译器遇到 extern 模板声明时,它不会在本文件中生成实例化代码。将一个实例化声明为 extern 就表示承诺在程序其他位置有该实例化的一个非 extern 声明(定义)。对于一个给定的实例化版本,可能有多个 extern 声明,但必须只有一个定义。

    由于编译器在使用一个模板时自动对其实例化,因此 extern 声明必须出现在任何使用此实例化版本的代码之前

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // Application.cc
    // 这些模板类型必须在程序其他位置进行实例化
    extern template class Blob<string>;
    extern template int compare(const int &, const int &);
    Blob<string> sa1, sa2; // 实例化会出现在其他位置
    // Blob<int> 及其接受 initializer_list 的构造函数在本文件中实例化
    Blob<int> a1 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    Blob<int> a2(a1); // 拷贝构造函数在本文件中实例化
    int i = compare(a1[0], a2[0]); // 实例化出现在其他位置

    文件 Application.o 将包含 Blob<int> 的实例及其接受 initializer_list 参数的构造函数和拷贝构造函数的实例。而 compare<int> 函数和 Blob<string> 类将不在本文件中进行实例化。这些模板的定义必须出现在程序的其他文件中:

    1
    2
    3
    4
    5
    // templateBuild.cc
    // 实例化文件必须为每个在其他文件中声明为 extern 的类型和函数提供一个(非 extern)
    // 的定义
    template int compare(const int &, const int &);
    template class Blob<string>; // 实例化类模板的所有成员

    当编译器遇到一个实例化定义(与声明相对)时,它为其生成代码。因此,文件 templateBuild.o 将会包含 compareint 实例化版本的定义和 Blob<string> 类的定义。当我们编译此应用程序时,必须将 templateBuild.oApplication.o 链接到一起。

  3. WARNING:对每个实例化声明,在程序中某个位置必须有其显式的实例化定义。

  4. 一个类模板的实例化定义会实例化该模板的所有成员,包括内联的成员函数。当编译器遇到一个实例化定义时,它不了解程序使用哪些成员函数。因此,与处理类模板的普通实例化(自动实例化——博主注)不同,编译器会实例化该类的所有成员。即使我们不使用某个成员,它也会被实例化。因此,我们用来显式实例化一个类模板的类型,必须能用于模板的所有成员。

    Note: 在一个类模板的实例化定义中,所用类型必须能用于模板的所有成员函数。

  5. 练习 16.26: 假设 NoDefault 是一个没有默认构造函数的类,我们可以显式实例化 vector<NoDefault> 吗?如果不可以,解释为什么。

    答:不可以,因为显示实例化会 vector<NoDefault> 时会实例化 vector 的所有成员函数,包括其接受容器大小的构造函数,该构造函数需要使用元素类型的默认构造函数对容器元素进行值初始化。

  6. 练习 16.27: 对下面每条带标签的语句,解释发生了什么样的实例化(如果有的话)。如果一个模板被实例化,解释为什么;如果未实例化,解释为什么没有。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    template <typename T>
    class Stack
    {
    };
    void f1(Stack<char>); // (a)
    class Exercise
    {
    Stack<double> &rsd; // (b)
    Stack<int> si; // (c)
    };
    int main()
    {
    Stack<char> *sc; // (d)
    f1(*sc); // (e)
    int iObj = sizeof(Stack<string>); // (f)
    }

    答:(a)、(b)、(c)、(f) 分别会发生 Stack 针对 char double int string 类型的模板自动实例化;(d)、(e) 不会发生实例化,因为所涉及到的模板类类型 Stack<char> 在此前已被实例化出来。

16.1.6 效率与灵活性

    • shared_ptrunique_ptr 之间的明显不同是它们管理所保存的指针的策略前者给予我们共享指针所有权的能力;后者则独占指针。
    • 这两个类的另一个差异是它们允许用户重载默认删除器的方式。我们可以很容易地重载一个 shared_ptr 的删除器,只要在创建或 reset 指针时传递给它一个可调用对象即可。与之相反,删除器的类型是一个 unique_ptr 对象的类型的一部分。用户必须在定义 unique_ptr 时以显式模板实参的形式提供删除器的类型。
  1. shared_ptr 不是将删除器直接保存为一个成员,因为删除器的类型直到运行时才会知道。实际上,在一个 shared_ptr 的生存期中,我们可以随时改变其删除器的类型。我们可以使用一种类型的删除器构造一个 shared_ptr,随后使用 reset 赋予此 shared_ptr 另一种类型的删除器。通常,类成员的类型在运行时是不能改变的。因此,不能直接保存删除器。

  2. unique_ptr 类中,删除器的类型是类类型的一部分。即,unique_ptr 有两个模板参数,一个表示它所管理的指针,另一个表示删除器的类型。由于删除器的类型是 unique_ptr 类型的一部分,因此删除器成员的类型在编译时是知道的,从而删除器可以直接保存在 unique_ptr 对象中。

  3. 通过在编译时绑定删除器,unique_ptr 避免了间接调用删除器的运行时开销(一个 if 判断——博主注)。通过在运行时绑定删除器,shared_ptr 使用户重载删除器更为方便。

16.2 模板实参推断

  1. 从函数实参来确定模板实参的过程被称为模板实参推断(template argument deduction)。

16.2.1 类型转换与模板类型参数

  1. 与往常一样,顶层 const 无论是在形参中还是在实参中,都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项。

    • const 转换:可以将一个非 const 对象的引用(或指针)传递给一个 const 的引用(或指针)形参。
    • 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。类似的,一个函数实参可以转换为一个该函数类型的指针。


    其他类型转换,如算术转换、派生类向基类的转换以及用户定义的转换,都不能应用于函数模板。

  2. 如果形参是一个引用,则数组不会转换为指针。

  3. Note: 将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有 const 转换及数组或函数到指针的转换。

  4. 一个模板类型参数可以用作多个函数形参的类型。由于只允许有限的几种类型转换,因此传递给这些形参的实参必须具有相同的类型。如果推断出的类型不匹配,则调用就是错误的。

  5. 如果希望允许对函数实参进行正常的类型转换,我们可以将函数模板定义为两个类型参数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 实参类型可以不同,但必须兼容
    template <typename A, typename B>
    int flexibleCompare(const A &v1, const B &v2)
    {
    if (v1 < v2)
    return -1;
    if (v2 < v1)
    return 1;
    return 0;
    }
  6. 函数模板可以有用普通类型定义的参数,即,不涉及模板类型参数的类型。这种函数实参不进行特殊处理;它们正常转换为对应形参的类型。

    1
    2
    3
    4
    5
    template <typename T>
    ostream &print(ostream &os, const T &obj)
    {
    return os << obj;
    }

    第一个函数参数是一个已知类型 ostream&。第二个参数 obj 则是模板参数类型。由于 os 的类型是固定的,因此当调用 print 时,传递给它的实参会进行正常的类型转换:

    1
    2
    3
    print(cout, 42); // 实例化 print(ostream&, int)
    ofstream f("output");
    print(f, 10); // 使用 print(ostream&, int);将 f 转换为 ostream&

    Note: 如果函数参数类型不是模板参数,则对实参进行正常的类型转换。

16.2.2 函数模板显式实参

  1. 在某些情况下,编译器无法推断出模板实参的类型。其他一些情况下,我们希望允许用户控制模板实例化。当函数返回类型与参数列表中任何类型都不相同时,这两种情况最常出现。

    我们可以定义表示返回类型的第三个模板参数,从而允许用户控制返回类型:

    1
    2
    3
    // 编译器无法推断 T1,它未出现在函数参数列表中
    template <typename T1, typename T2, typename T3>
    T1 sum(T2, T3);

    在本例中,没有任何函数实参的类型可用来推断 T1 的类型。每次调用 sum 时调用者都必须为 T1 提供一个显式模板实参(explicit template argument)。

    我们提供显式模板实参的方式与定义类模板实例的方式相同。显式模板实参在尖括号中给出,位于函数名之后,实参列表之前:

    1
    2
    // T1 是显式指定的,T2 和 T3 是从函数实参类型推断而来的
    auto val3 = sum<long long>(i, lng); // long long sum(int, long)

    此调用显式指定 T1 的类型。而 T2T3 的类型则由编译器从 ilng 的类型推断出来。

  2. 显式模板实参按由左至右的顺序与对应的模板参数匹配;第一个模板实参与第一个模板参数匹配,第二个实参与第二个参数匹配,依此类推。只有尾部(最右)参数的显式模板实参才可以忽略,而且前提是它们可以从函数参数推断出来。如果我们的 sum 函数按照如下形式编写:

    1
    2
    3
    // 糟糕的设计:用户必须指定所有三个模板参数
    template <typename T1, typename T2, typename T3>
    T3 alternative_sum(T2, T1);

    则我们总是必须为所有三个形参指定实参:

    1
    2
    3
    4
    // 错误:不能推断前几个模板参数
    auto val3 = alternative_sum<long long>(i, lng);
    // 正确:显式指定了所有三个参数
    auto val2 = alternative_sum<long long, int, long>(i, lng);
  3. 对于用普通类型定义的函数参数,允许进行正常的类型转换,出于同样的原因,对于模板类型参数已经显式指定了的函数实参,也进行正常的类型转换

    1
    2
    3
    4
    long lng;
    compare(lng, 1024); // 错误:模板参数不匹配
    compare<long>(lng, 1024); // 正确:实例化 compare(long, long)
    compare<int>(lng, 1024); // 正确:实例化 compare(int, int)
  4. 练习 16.38: 当我们调用 make_shared 时,必须提供一个显式模板实参。解释为什么需要显式模板实参以及它是如何使用的。

    答:make_shared 的返回类型是 shared_ptr,而 shared_ptr 是个类模板,返回类型里的模板参数只能显式指定。

16.2.3 尾置返回类型与类型转换

  1. 我们可能希望编写一个函数,接受表示序列的一对迭代器和返回序列中一个元素的引用:

    1
    2
    3
    4
    5
    6
    template <typename It>
    ??? &fcn(It beg, It end)
    {
    // 处理序列
    return *beg; // 返回序列中一个元素的引用
    }

    我们并不知道返回结果的准确类型,但知道所需类型是所处理的序列的元素类型:

    1
    2
    3
    4
    vector<int> vi = {1, 2, 3, 4, 5};
    Blob<string> ca = {"hi", "bye"};
    auto &i = fcn(vi.begin(), vi.end()); // fcn 应该返回 int&
    auto &s = fcn(ca.begin(), ca.end()); // fcn 应该返回 string&

    此例中,我们知道函数应该返回 *beg,而且知道我们可以用 decltype(*beg) 来获取此表达式的类型。但是,在编译器遇到函数的参数列表之前,beg 都是不存在的。为了定义此函数,我们必须使用尾置返回类型。由于尾置返回出现在参数列表之后,它可以使用函数的参数

    1
    2
    3
    4
    5
    6
    7
    // 尾置返回允许我们在参数列表之后声明返回类型
    template <typename It>
    auto fcn(It beg, It end) -> decltype(*beg)
    {
    // 处理序列
    return *beg; // 返回序列中一个元素的引用
    }

    此例中我们通知编译器 fcn 的返回类型与解引用 beg 参数的结果类型相同。解引用运算符返回一个左值,因此通过 decltype 推断的类型为 beg 表示的元素的类型的引用。 因此,如果对一个 string 序列调用 fcn,返回类型将是 string&。如果是 int 序列,则返回类型是 int&

  2. 有时我们无法直接获得所需要的类型。例如,我们可能希望编写一个类似 fcn 的函数,但返回一个元素的值而非引用。

    在编写这个函数的过程中,我们面临一个问题:对于传递的参数的类型,我们几乎一无所知。在此函数中,我们知道唯一可以使用的操作是迭代器操作,而所有迭代器操作都不会生成元素,只能生成元素的引用

    为了获得元素类型,我们可以使用标准库的类型转换(type transformation)模板。这些模板定义在头文件 type_traits 中。

    在本例中,我们可以使用 remove_reference 来获得元素类型。remove_reference 模板有一个模板类型参数和一个名为 type 的(public)类型成员。如果我们用一个引用类型实例化 remove_reference,则 type 将表示被引用的类型。例如,如果我们实例化 remove_reference<int&>,则 type 成员将是 int。类似的,如果我们实例化 remove_reference<string&>,则 type 成员将是 string,依此类推。更一般的,给定一个迭代器 beg

    1
    remove_reference<decltype(*beg)>::type

    将获得 beg 引用的元素的类型:decltype(*beg) 返回元素类型的引用类型。remove_reference::type 脱去引用,剩下元素类型本身。

    组合使用 remove_reference、尾置返回及 decltype,我们就可以在函数中返回元素值的拷贝

    1
    2
    3
    4
    5
    6
    7
    8
    // 为了使用模板参数的成员,必须用 typename
    template <typename It>
    auto fcn2(It beg, It end) ->
    typename remove_reference<decltype(*beg)>::type
    {
    // 处理序列
    return *beg; // 返回序列中一个元素的拷贝
    }

    注意,type 是一个类的成员,而该类依赖于一个模板参数。因此,我们必须在返回类型的声明中使用 typename 来告知编译器,type 表示一个类型。

  3. 下表列出了标准类型转换模板:

    标准类型转换模板
    对 Mod<T>,其中 Mod 为 若 T 为 则 Mod<T>::type 为
    remove_reference X& 或 X&&
    否则
    X
    T
    add_const X&、const X 或函数
    否则
    T
    const T
    add_lvalue_reference X&
    X&&
    否则
    T
    X&
    T&
    add_rvalue_reference X& 或 X&&
    否则
    T
    T&&
    remove_pointer X*
    否则
    X
    T
    add_pointer X& 或 X&&
    否则
    X*
    T*
    make_signed unsigned X
    否则
    X
    T
    make_unsigned 带符号类型
    否则
    unsigned X
    T
    remove_extent X[n]
    否则
    X
    T
    remove_all_extents X[n1][n2]...
    否则
    X
    T

    上表中描述的每个类型转换模板的工作方式都与 remove_reference 类似。每个模板都有一个名为 typepublic 成员,表示一个类型。此类型与模板自身的模板类型参数相关,其关系如模板名所示。如果不可能(或者不必要)转换模板参数,则 type 成员就是模板参数类型本身。例如,如果 T 是一个指针类型,则 remove_pointer<T>::typeT 指向的类型。如果 T 不是一个指针,则无须进行任何转换,从而 type 具有与 T 相同的类型。

16.2.4 函数指针和实参推断

  1. 当我们用一个函数模板初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模板实参。
    例如,假定我们有一个函数指针,它指向的函数返回 int,接受两个参数,每个参数都是指向 const int 的引用。我们可以使用该指针指向 compare 的一个实例:

    1
    2
    3
    4
    template <typename T>
    int compare(const T &, const T &);
    // pf1 指向实例 int compare(const int&, const int&)
    int (*pf1)(const int &, const int &) = compare;

    pf1 中参数的类型决定了 T 的模板实参的类型。在本例中,T 的模板实参类型为 int。指针 pf1 指向 compareint 版本实例。如果不能从函数指针类型确定模板实参,则产生错误:

    1
    2
    3
    4
    // func 的重载版本;每个版本接受一个不同的函数指针类型
    void func(int (*)(const string &, const string &));
    void func(int (*)(const int &, const int &));
    func(compare); // 错误:使用 compare 的哪个实例?

    这段代码的问题在于,通过 func 的参数类型无法确定模板实参的唯一类型。对 func 的调用既可以实例化接受 intcompare 版本,也可以实例化接受 string 的版本。由于不能确定 func 的实参的唯一实例化版本,此调用将编译失败。

    我们可以通过使用显式模板实参来消除 func 调用的歧义:

    1
    2
    // 正确:显式指出实例化哪个 compare 版本
    func(compare<int>); // 传递 compare(const int&, const int&)

    此表达式调用的 func 版本接受一个函数指针,该指针指向的函数接受两个 const int& 参数。

    Note: 当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值。

16.2.5 模板实参推断和引用

  1. 当一个函数参数是模板类型参数的一个普通(左值)引用时(即,形如 T&),绑定规则告诉我们,只能传递给它一个左值(如,一个变量或一个返回引用类型的表达式)。实参可以是 const 类型,也可以不是。如果实参是 const 的,则 T 将被推断为 const 类型:

    1
    2
    3
    4
    5
    6
    template <typename T>
    void f1(T &); // 实参必须是一个左值
    // 对 f1 的调用使用实参所引用的类型作为模板参数类型
    f1(i); // i 是一个 int;模板参数类型 T 是 int
    f1(ci); // ci 是一个 const int;模板参数 T 是 const int
    f1(5); // 错误:传递给一个 & 参数的实参必须是一个左值

    如果一个函数参数的类型是 const T&,正常的绑定规则告诉我们可以传递给它任何类型的实参——一个对象(const 或非 const)、一个临时对象或是一个字面常量值。当函数参数本身是 const 时,T 的类型推断的结果不会是一个 const 类型。const 已经是函数参数类型的一部分;因此,它不会也是模板参数类型的一部分:

    1
    2
    3
    4
    5
    6
    7
    template <typename T>
    void f2(const T &); // 可以接受一个右值
    // f2 中的参数是 const &;实参中的 const 是无关的
    // 在每个调用中,f2 的函数参数都被推断为 const int&
    f2(i); // i 是一个 int;模板参数 T 是 int
    f2(ci); // ci 是一个 const int,但模板参数 T 是 int
    f2(5); // 一个 const & 参数可以绑定到一个右值;T 是 int
  2. 当一个函数参数是一个右值引用(即,形如 T&&)时,正常绑定规则告诉我们可以传递给它一个右值。当我们这样做时,类型推断过程类似普通左值引用函数参数的推断过程。推断出的 T 的类型是该右值实参的类型:

    1
    2
    3
    template <typename T>
    void f3(T &&);
    f3(42); // 实参是一个 int 类型的右值;模板参数 T 是 int
  3. 通常我们不能将一个右值引用绑定到一个左值上。但是,C++ 语言在正常绑定规则之外定义了两个例外规则,允许这种绑定。这两个例外规则是 move 这种标准库设施正确工作的基础。

    • 第一个例外规则影响右值引用参数的推断如何进行。当我们将一个左值(如 i)传递给函数的右值引用参数,且此右值引用指向模板类型参数(如 T&&)时,编译器推断模板类型参数为实参的左值引用类型。 因此,当我们调用 f3(i) 时,编译器推断 T 的类型为 int&,而非 intT 被推断为 int& 看起来好像意味着 f3 的函数参数应该是一个类型 int& 的右值引用。通常,我们不能(直接)定义一个引用的引用。但是,通过类型别名或通过模板类型参数间接定义是可以的。

    • 在这种情况下,我们可以使用第二个例外绑定规则:如果我们间接创建一个引用的引用,则这些引用形成了“折叠”。在所有情况下(除了一个例外),引用会折叠成一个普通的左值引用类型。在新标准中,折叠规则扩展到右值引用。只在一种特殊情况下引用会折叠成右值引用:右值引用的右值引用。 即,对于一个给定类型 X

      • X& &X& &&X&& & 都折叠成类型 X&
      • 类型 X&& && 折叠成 X&&

    Note: 引用折叠只能应用于间接创建的引用的引用,如类型别名或模板参数。

  4. 如果将引用折叠规则和右值引用的特殊类型推断规则组合在一起,则意味着我们可以对一个左值调用 f3。当我们将一个左值传递给 f3 的(右值引用)函数参数时,编译器推断 T 为一个左值引用类型:

    1
    2
    f3(i);  // 实参是一个左值;模板参数 T 是 int&
    f3(ci); // 实参是一个左值;模板参数 T 是一个 const int&

    当一个模板参数 T 被推断为引用类型时,折叠规则告诉我们函数参数 T&& 折叠为一个左值引用类型。例如,f3(i) 的实例化结果可能像下面这样:

    1
    2
    // 无效代码,只是用于演示目的
    void f3<int &>(int & &&); // 当 T 是 int& 时,函数参数为 int& &&

    f3 的函数参数是 T&&Tint&,因此 T&&int& &&,会折叠成 int&。因此,即使 f3 的函数参数形式是一个右值引用(即,T&&),此调用也会用一个左值引用类型(即,int&)实例化 f3

    1
    void f3<int &>(int &); // 当 T 是 int& 时,函数参数折叠为 int&

    这两个规则导致了两个重要结果:

    • 如果一个函数参数是一个指向模板类型参数的右值引用(如,T&&),则它可以被绑定到一个左值;且
    • 如果实参是一个左值,则推断出的模板实参类型将是一个左值引用,且函数参数将被实例化为一个(普通)左值引用参数(T&

    另外值得注意的是,这两个规则暗示,我们可以将任意类型的实参传递给 T&& 类型的函数参数。对于这种类型的参数,(显然)可以传递给它右值,而如我们刚刚看到的,也可以传递给它左值。

    Note:如果一个函数参数是指向模板参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(T&)。

  5. 模板参数可以推断为一个引用类型,这一特性对模板内的代码可能有令人惊讶的影响:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template <typename T>
    void f3(T &&val)
    {
    T t = val; // 拷贝还是绑定一个引用?
    t = fcn(t); // 赋值只改变 t 还是既改变 t 又改变 val?
    if (val == t)
    {
    /*...*/
    } // 若 T 是引用类型,则一直为 true
    }

    当我们对一个右值调用 f3 时,例如字面常量 42Tint。在此情况下,局部变量 t 的类型为 int,且通过拷贝参数 val 的值被初始化。当我们对 t 赋值时,参数 val 保持不变。

    另一方面,当我们对一个左值 i 调用 f3 时,则 Tint&。当我们定义并初始化局部变量 t 时,赋予它类型 int&。因此,对 t 的初始化将其绑定到 val。当我们对 t 赋值时,也同时改变了 val 的值。在 f3 的这个实例化版本中,if 判断永远得到 true

  6. 练习 16.42: 对下面每个调用,确定 Tval 的类型:

    1
    2
    3
    4
    template <typename T>
    void g(T &&val);
    int i = 0;
    const int ci = i;

    (a)g(i);(b)g(ci);(c)g(i * ci);

    答:(a)int&;(b)int&;(c)const int&&

16.2.6 理解 std::move

  1. 虽然不能直接将一个右值引用绑定到一个左值上,但可以用 move 获得一个绑定到左值上的右值引用。

    标准库是这样定义 move 的:

    1
    2
    3
    4
    5
    6
    // 在返回类型和类型转换中也要用到 typename
    template <typename T>
    typename remove_reference<T>::type &&move(T &&t)
    {
    return static_cast<typename remove_reference<T>::type &&>(t);
    }
  2. 我们既可以传递给 move 一个左值,也可以传递给它一个右值

    1
    2
    3
    string s1("hi!"), s2;
    s2 = std::move(string("bye!")); // 正确:从一个右值移动数据
    s2 = std::move(s1); // 正确:但在赋值之后,s1 的值是不确定的

    在第一个赋值中,传递给 move 的实参是 string 的构造函数的右值结果 string("bye!")。如我们已经见到过的,当向一个右值引用函数参数传递一个右值时,由实参推断出的类型为被引用的类型。因此,在 std::move(string("bye!")) 中:

    • 推断出的 T 的类型为 string
    • 因此,remove_referencestring 进行实例化。
    • remove_reference<string>type 成员是 string
    • move 的返回类型是 string&&
    • move 的函数参数 t 的类型为 string&&

    因此,这个调用实例化 move<string>,即函数

    1
    string &&move(string &&t)

    函数体返回 static_cast<string&&>(t)t 的类型已经是 string&&,于是类型转换什么都不做。因此,此调用的结果就是它所接受的右值引用。

    第二个赋值中调用了 std::move()。在此调用中,传递给 move 的实参是一个左值。这样:

    • 推断出的 T 的类型为 string&string 的引用,而非普通 string)。
    • 因此,remove_referencestring& 进行实例化。
    • remove_reference<string&>type 成员是 string
    • move 的返回类型仍是 string&&
    • move 的函数参数 t 实例化为 string& &&,会折叠为 string&

    因此,这个调用实例化 move<string&>,即

    1
    string &&move(string &t)

    这正是我们所寻求的——我们希望将一个右值引用绑定到一个左值。这个实例的函数体返回 static_cast<string&&>(t)。在此情况下,t 的类型为 string&cast 将其转换为 string&&

  3. 虽然不能隐式地将一个左值转换为右值引用,但我们可以用 static_cast 显示地将一个左值转换为一个右值引用。

16.2.7 转发

  1. 某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是 const 的以及实参是左值还是右值。

  2. 通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息。而使用引用参数(无论是左值还是右值)使得我们可以保持 const 属性,因为在引用类型中的 const 是底层的。如果我们将函数参数定义为 T1&&T2&&,通过引用折叠就可以保持翻转实参的左值/右值属性:

    1
    2
    3
    4
    5
    template <typename F, typename T1, typename T2>
    void flip2(F f, T1 &&t1, T2 &&t2)
    {
    f(t2, t1);
    }

    与较早的版本一样,如果我们调用 flip2(f, j, 42),将传递给参数 t1 一个左值 j。但是,在 flip2 中,推断出的 T1 的类型为 int&,这意味着 t1 的类型会折叠为 int&。由于是引用类型,t1 被绑定到 j 上。

    Note:如果一个函数参数是指向模板类型参数的右值引用(如 T&&),它对应的实参的 const 属性和左值/右值属性将得到保持。

    这个版本的 flip2 解决了一半问题。它对于接受一个左值引用的函数工作得很好,但不能用于接受右值引用参数的函数。例如:

    1
    2
    3
    4
    void g(int &&i, int &j)
    {
    cout << i << " " << j << endl;
    }

    如果我们试图通过 flip2 调用 g,则参数 t2 将被传递给 g 的右值引用参数。即使我们传递一个右值给 flip2

    1
    flip2(g, i, 42); // 错误:不能从一个左值实例化 int&&

    传递给 g 的将是 flip2 中名为 t2 的参数。函数参数与其他任何变量一样,都是左值表达式。因此,flip2 中对 g 的调用将传递给 g 的右值引用参数一个左值。

  3. 我们可以使用一个名为 forward 的新标准库设施来传递 flip2 的参数,它能保持原始实参的类型。类似 moveforward 定义在头文件 utility 中。与 move 不同,forward 必须通过显式模板实参来调用。forward 返回该显式实参类型的右值引用。 即,forward<T> 的返回类型是 T&&

    通常情况下,我们使用 forward 传递那些定义为模板类型参数的右值引用的函数参数。通过其返回类型上的引用折叠,forward 可以保持给定实参的左值/右值属性

    1
    2
    3
    4
    5
    6
    template <typename Type>
    intermediary(Type &&arg)
    {
    finalFcn(std::forward<Type>(arg));
    // ...
    }

    本例中我们使用 Type 作为 forward 的显式模板实参类型,它是从 arg 推断出来的。由于 arg 是一个模板类型参数的右值引用,Type 将表示传递给 arg 的实参的所有类型信息。如果实参是一个右值,则 Type 是一个普通(非引用)类型,forward<Type> 将返回 Type&&。如果实参是一个左值,则通过引用折叠,Type 本身是一个左值引用类型。在此情况下,返回类型是一个指向左值引用类型的右值引用。再次对 forward<Type> 的返回类型进行引用折叠,将返回一个左值引用类型。

    Note:当用于一个指向模板参数类型的右值引用函数参数(T&&)时,forward 会保持实参类型的所有细节。

    使用 forward,我们可以再次重写翻转函数:

    1
    2
    3
    4
    5
    template <typename F, typename T1, typename T2>
    void flip(F f, T1 &&t1, T2 &&t2)
    {
    f(std::forward<T2>(t2), std::forward<T1>(t1));
    }

    Note:std::move 相同,对 std::forward 不使用 using 声明是一个好主意。

16.3 重载与模板

  1. 函数模板可以被另一个模板或一个普通非模板函数重载。与往常一样,名字相同的函数必须具有不同数量或类型的参数。

  2. 如果涉及函数模板,则函数匹配规则会在以下几方面受到影响:

    • 对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例。
    • 候选的函数模板总是可行的,因为模板实参推断会排除任何不可行的模板。
    • 与往常一样,可行函数(模板与非模板)按类型转换(如果对此调用需要的话)来排序。当然,可以用于函数模板调用的类型转换是非常有限的。
    • 与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。但是,如果有多个函数提供同样好的匹配,则:
      • 如果同样好的函数中只有一个是非模板函数,则选择此函数。
      • 如果同样好的函数中没有非模板函数,而有多个函数模板,且其中一个模板比其他模板更特例化,则选择此模板。
      • 否则,此调用有歧义。
  3. 我们有下面两个函数模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 打印任何我们不能处理的类型
    template <typename T>
    string debug_rep(const T &t)
    {
    ostringstream ret;
    ret << t; // 使用 T 的输出运算符打印 t 的一个表示形式
    return ret.str(); // 返回 ret 绑定的 string 的一个副本
    }

    // 打印指针的值,后跟指针指向的对象
    // 注意:此函数不能用于 char*
    template <typename T>
    string debug_rep(T *p)
    {
    ostringstream ret;
    ret << "pointer:" << p; // 打印指针本身的值
    if (p)
    ret << "" << debug_rep(*p); // 打印 p 指向的值
    else
    ret << "null pointer"; // 或指出 p 为空
    return ret.str(); // 返回 ret 绑定的 string 的一个副本
    }

    第二个模板不能用于打印字符指针,因为 IO 库为 char* 值定义了一个 << 版本。此 << 版本假定指针表示一个空字符结尾的字符数组,并打印数组的内容而非地址值。

    我们可以这样使用这些函数:

    1
    2
    string s("hi");
    cout << debug_rep(s) << endl;

    对于这个调用,只有第一个版本的 debug_rep 是可行的。第二个 debug_rep 版本要求一个指针参数,但在此调用中我们传递的是一个非指针对象。因此编译器无法从一个非指针实参实例化一个期望指针类型参数的函数模板,因此实参推断失败。由于只有一个可行函数,所以此函数被调用。

    如果我们用一个指针调用 debug_rep

    1
    cout << debug_rep(&s) << endl;

    两个函数都生成可行的实例:

    • debug_rep(const string *&),由第一个版本的 debug_rep 实例化而来,T 被绑定到 string *
    • debug_rep(string *),由第二个版本的 debug_rep 实例化而来,T 被绑定到 string

    第二个版本的 debug_rep 的实例是此调用的精确匹配。第一个版本的实例需要进行普通指针到 const 指针的转换。正常函数匹配规则告诉我们应该选择第二个模板,实际上编译器确实选择了这个版本。

    作为另外一个例子,考虑下面的调用:

    1
    2
    const string *sp = &s;
    cout << debug_rep(sp) << endl;

    此例中的两个模板都是可行的,而且两个都是精确匹配:

    • debug_rep(const string *&),由第一个版本的 debug_rep 实例化而来,T 被绑定到 string *
    • debug_rep(const string *),由第二个版本的 debug_rep 实例化而来,T 被绑定到 const string

    在此情况下,正常函数匹配规则无法区分这两个函数。我们可能觉得这个调用将是有歧义的。但是,根据重载函数模板的特殊规则,此调用被解析为 debug_rep(T *),即,更特例化的版本。

    设计这条规则的原因是,没有它,将无法对一个 const 的指针调用指针版本的 debug_rep。问题在于模板 debug_rep(const T &) 本质上可以用于任何类型,包括指针类型。此模板比 debug_rep(T *) 更通用,后者只能用于指针类型。没有这条规则,传递 const 的指针的调用永远是有歧义的。

  4. Note:当有多个重载模板对一个调用提供同样好的匹配时,应选择最特例化的版本。(我们都更倾向于确定性——博主注)

  5. Note:对于一个调用,如果一个非函数模板与一个函数模板提供同样好的匹配,则选择非模板版本。

  6. 考虑这个调用:

    1
    cout << debug_rep("hi world!") << endl; // 调用 debug_rep(T *)

    所有三个 debug_rep 版本都是可行的:

    • debug_rep(const T &)T 被绑定到 char[10]
    • debug_rep(T *)T 被绑定到 const char
    • debug_rep(const string &),要求从 const char *string 的类型。

    对给定实参来说,两个模板都提供精确匹配——第二个模板需要进行一次(许可的)数组到指针的转换,而对于函数匹配来说,这种转换被认为是精确匹配。非模板版本是可行的,但需要进行一次用户定义的类型转换,因此它没有精确匹配那么好,所以两个模板成为可能调用的函数。与之前一样,T* 版本更加特例化,编译器会选择它。

  7. 通常,如果使用了一个忘记声明的函数,代码将编译失败。但对于重载函数模板的函数而言,则不是这样。如果编译器可以从模板实例化出与调用匹配的版本,则缺少的声明就不重要了。

    Tip: 在定义任何函数之前,记得声明所有重载的函数版本。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。

  8. 练习 16.49: 解释下面每个调用会发生什么:

    1
    2
    3
    4
    5
    6
    7
    8
    template <typename T> void f(T);
    template <typename T> void f(const T *);
    template <typename T> void g(T);
    template <typename T> void g(T *);
    int i = 42, *p = &i;
    const int ci = 0, *p2 = &ci;
    g(42); g(p); g(ci); g(p2);
    f(42); f(p); f(ci); f(p2);

    答:g(42):匹配 g(T)T 被推断为 int
    g(p):匹配 g(T *)T 被推断为 int
    g(ci):匹配 g(T)T 被推断为 int
    g(p2):匹配 g(T *)T 被推断为 const int
    f(42):匹配 f(T)T 被推断为 int
    f(p):匹配 f(T)T 被推断为 int *
    f(ci):匹配 f(T)T 被推断为 int
    f(p2):匹配 f(const T *)T 被推断为 int

    验证代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    #include <iostream>
    #include <type_traits>

    template <typename T>
    void f(T param)
    {
    std::cout << " Called function: f(T)" << std::endl;
    std::cout << " T: " << typeid(T).name() << std::endl;
    std::cout << " T is const: " << std::is_const<T>::value << std::endl;
    }

    template <typename T>
    void f(const T *param)
    {
    std::cout << " Called function: f(const T*)" << std::endl;
    std::cout << " T: " << typeid(T).name() << std::endl;
    std::cout << " T is const: " << std::is_const<T>::value << std::endl;
    }

    template <typename T>
    void g(T param)
    {
    std::cout << " Called function: g(T)" << std::endl;
    std::cout << " T: " << typeid(T).name() << std::endl;
    std::cout << " T is const: " << std::is_const<T>::value << std::endl;
    }

    template <typename T>
    void g(T *param)
    {
    std::cout << " Called function: g(T *)" << std::endl;
    std::cout << " T: " << typeid(T).name() << std::endl;
    std::cout << " T is const: " << std::is_const<T>::value << std::endl;
    }

    int i = 42, *p = &i;
    const int ci = 0, *p2 = &ci;

    std::cout << "g(42)" << std::endl;
    g(42);

    std::cout << "g(p)" << std::endl;
    g(p);

    std::cout << "g(ci)" << std::endl;
    g(ci);

    std::cout << "g(p2)" << std::endl;
    g(p2);

    std::cout << "f(42)" << std::endl;
    f(42);

    std::cout << "f(p)" << std::endl;
    f(p);

    std::cout << "f(ci)" << std::endl;
    f(ci);

    std::cout << "f(p2)" << std::endl;
    f(p2);

16.4 可变参数模板

  1. 一个可变参数模板(variadic template)就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包(parameter packet)。存在两种参数包:模板参数包(template parameter packet),表示零个或多个模板参数;函数参数包(function parameter packet),表示零个或多个函数参数。

  2. 我们用一个省略号来指出一个模板参数或函数参数表示一个包。在一个模板参数列表中,class...typename... 指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。在函数参数列表中,如果一个参数的类型是一个模板参数包,则此参数也是一个函数参数包。例如:

    1
    2
    3
    4
    5
    // Args 是一个模板参数包;rest 是一个函数参数包
    // Args 表示零个或多个模板类型参数
    // rest 表示零个或多个函数参数
    template <typename T, typename... Args>
    void foo(const T &t, const Args &...rest);

    声明了 foo 是一个可变参数函数模板,它有一个名为 T 的类型参数,和一个名为 Args 的模板参数包。这个包表示零个或多个额外的类型参数。foo 的函数参数列表包含一个 const & 类型的参数,指向 T 的类型,还包含一个名为 rest 的函数参数包,此包表示零个或多个函数参数。

    与往常一样,编译器从函数的实参推断模板参数类型。对于一个可变参数模板,编译器还会推断包中参数的数目。例如,给定下面的调用:

    1
    2
    3
    4
    5
    6
    7
    int i = 0;
    double d = 3.14;
    string s = "how now brown cow";
    foo(i, s, 42, d); // 包中有三个参数
    foo(s, 42, "hi"); // 包中有两个参数
    foo(d, s); // 包中有一个参数
    foo("hi"); // 空包

    编译器会为 foo 实例化出四个不同的版本:

    1
    2
    3
    4
    void foo(const int &, const string &, const int &, const double &);
    void foo(const string &, const int &, const char[3] &);
    void foo(const double &, const string &);
    void foo(const char[3] &);

    在每个实例中,T 的类型都是从第一个实参的类型推断出来的。剩下的实参(如果有的话)提供函数额外实参的数目和类型。

  3. 当我们需要知道包中有多少元素时,可以使用 sizeof... 运算符,sizeof... 也返回一个常量表达式,而且不会对其实参求值:

    1
    2
    3
    4
    5
    6
    template <typename... Args>
    void g(Args... args)
    {
    cout << sizeof...(Args) << endl; // 类型参数的数目
    cout << sizeof...(args) << endl; // 函数参数的数目
    }

16.4.1 编写可变参数函数模板

  1. 可以使用一个 initializer_list 来定义一个可接受可变数目实参的函数。但是,所有实参必须具有相同的类型(或它们的类型可以转换为同一个公共类型)。

  2. 可变参数函数通常是递归的。 第一步调用处理包中的第一个实参,然后用剩余实参调用自身。我们的 print 函数也是这样的模式,每次递归调用将第二个实参打印到第一个实参表示的流中。为了终止递归,我们还需要定义一个非可变参数的 print 函数,它接受一个流和一个对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 用来终止递归并打印最后一个元素的函数
    // 此函数必须在可变参数版本的 print 定义之前声明
    template <typename T>
    ostream &print(ostream &os, const T &t)
    {
    return os << t; // 包中最后一个元素之后不打印分隔符
    }
    // 包中除了最后一个元素之外的其他元素都会调用这个版本的 print
    template <typename T, typename... Args>
    ostream &print(ostream &os, const T &t, const Args &...rest)
    {
    os << t << ","; // 打印第一个实参
    return print(os, rest...); // 递归调用,打印其他实参
    }

    第一个版本的 print 负责终止递归并打印初始调用中的最后一个实参。第二个版本的 print 是可变参数版本,它打印绑定到 t 的实参,并调用自身来打印函数参数包中的剩余值。

    这段程序的关键部分是可变参数函数中对 print 的调用:

    1
    return print(os, rest...); // 递归调用,打印其他实参

    我们的可变参数版本的 print 函数接受三个参数:一个 ostream &,一个 const T & 和一个参数包。而此调用只传递了两个实参。其结果是 rest 中的第一个实参被绑定到 t,剩余实参形成下一个 print 调用的参数包。因此,在每个调用中,包中的第一个实参被移除,成为绑定到 t 的实参。 即,给定:

    1
    print(cout, i, s, 42); // 包中有两个参数

    递归会执行如下:

    调用 t rest...
    print(cout, i, s, 42) i s, 42
    print(cout, s, 42) s 42
    print(cout, 42) 调用非可变参数版本的 print

    前两个调用只能与可变参数版本的 print 匹配,非可变参数版本是不可行的,因为这两个调用分别传递四个和三个实参,而非可变参数 print 只接受两个实参。

    对于最后一次递归调用 print(cout, 42),两个 print 版本都是可行的。这个调用传递两个实参,第一个实参的类型为 ostream &。因此,可变参数版本的 print 可以实例化为只接受两个参数:一个是 ostream & 参数,另一个是 const T & 参数。

    对于最后一个调用,两个函数提供同样好的匹配。但是,非可变参数模板比可变参数模板更特例化,因此编译器选择非可变参数版本。

    WARNING: 当定义可变参数版本的 print 时,非可变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。

16.4.2 包扩展

  1. 对于一个参数包,除了获取其大小外,我们能对它做的唯一的事情就是扩展(expand)它。当扩展一个包时,我们还要提供用于每个扩展元素的模式(pattern)。扩展一个包就是将它分解为构成的元素,对每个元素应用模式,获得扩展后的列表。我们通过在模式右边放一个省略号(...)来触发扩展操作。

    例如,我们的 print 函数包含两个扩展:

    1
    2
    3
    4
    5
    6
    template <typename T, typename... Args>
    ostream &print(ostream &os, const T &t, const Args &...rest) // 扩展 Args
    {
    os << t << ",";
    return print(os, rest...); // 扩展 rest
    }

    第一个扩展操作扩展模板参数包,为 print 生成函数参数列表。第二个扩展操作出现在对 print 的调用中。此模式为 print 调用生成实参列表。

    Args 的扩展中,编译器将模式 const Arg & 应用到模板参数包 Args 中的每个元素。因此,此模式的扩展结果是一个逗号分隔的零个或多个类型的列表,每个类型都形如 const type &。例如:

    1
    print(cout, i, s, 42); // 包中有两个参数

    最后两个实参的类型和模式一起确定了尾置参数的类型。此调用被实例化为:

    1
    ostream &print(ostream &, const int &, const string &, const int &);

    第二个扩展发生在对 print 的(递归)调用中。在此情况下,模式是函数参数包的名字(即 rest)。此模式扩展出一个由包中元素组成的、逗号分隔的列表。因此,这个调用等价于:

    1
    print(os, s, 42);
  2. print 中的函数参数包扩展仅仅将包扩展为其构成元素,C++ 语言还允许更复杂的扩展模式。例如,我们可以编写第二个可变参数函数,对其每个实参调用 debug_rep,然后调用 print 打印结果 string

    1
    2
    3
    4
    5
    6
    7
    // 在 print 调用中对每个实参调用 debug_rep
    template <typename... Args>
    ostream &errorMsg(ostream &os, const Args &...rest)
    {
    // print(os, debug_rep(a1), debug_rep(a2), ..., debug_rep(an)
    return print(os, debug_rep(rest)...);
    }

    这个 print 调用使用了模式 debug_rep(rest)。此模式表示我们希望对函数参数包 rest 中的每个元素调用 debug_rep。扩展结果将是一个逗号分隔的 debug_rep 调用列表。即,下面调用:

    1
    errorMsg(cerr, fcnName, code.num(), otherData, "other", item);

    就好像我们这样编写代码一样

    1
    2
    print(cerr, debug_rep(fcnName), debug_rep(code.num()), debug_rep(otherData),
    debug_rep("otherData"), debug_rep(item));

    与之相对,下面的模式会编译失败

    1
    2
    // 将包传递给 debug_rep; print(os, debug_rep(al, a2, ..., an))
    print(os, debug_rep(rest...)); // 错误:此调用无匹配函数

    这段代码的问题是我们在 debug_rep 调用中扩展了 rest,它等价于

    1
    print(cerr, debug_rep(fcnName, code.num(), otherData, "otherData", item));

    在这个扩展中,我们试图用一个五个实参的列表来调用 debug_rep,但并不存在与此调用匹配的 debug_rep 版本。debug_rep 函数不是可变参数的,而且没有哪个 debug_rep 版本接受五个参数。

  3. Note: 扩展中的模式会独立地应用于包中的每个元素。

16.4.3 转发参数包

  1. 在新标准下,我们可以组合使用可变参数模板与 forward 机制来编写函数,实现将其实参不变地传递给其他函数。

  2. 标准库容器的 emplace_back 成员是一个可变参数成员模板,它用其实参在容器管理的内存空间中直接构造一个元素。

  3. 保持类型信息是一个两阶段的过程。首先,为了保持实参中的类型信息,必须将 emplace_back 的函数参数定义为模板类型参数的右值引用

    1
    2
    3
    4
    5
    6
    7
    class StrVec
    {
    public:
    template <class... Args>
    void emplace_back(Args &&...);
    // 其他成员的定义
    }

    模板参数包扩展中的模式是 &&,意味着每个函数参数将是一个指向其对应实参的右值引用。

    其次,当 emplace_back 将这些实参传递给 construct 时,我们必须使用 forward 来保持实参的原始类型

    1
    2
    3
    4
    5
    6
    template <class... Args>
    inline void StrVec::emplace_back(Args &&...args)
    {
    chk_n_alloc(); // 如果需要的话重新分配 StrVec 内存空间
    alloc.construct(first_free++, std::forward<Args>(args)...);
    }

    emplace_back 的函数体调用了 chk_n_alloc 来确保有足够的空间容纳一个新元素,然后调用了 constructfirst_free 指向的位置中创建了一个元素。construct 调用中的扩展为

    1
    std::forward<Args>(args)...

    它既扩展了模板参数包 Args,也扩展了函数参数包 args。此模式生成如下形式的元素

    1
    std::forward<Ti>(ti)

    其中 Ti 表示模板参数包中第 i 个元素的类型,ti 表示函数参数包中第 i 个元素。例如,假定 svec 是一个 StrVec,如果我们调用

    1
    svec.emplace_back(10, 'c'); // 将 cccccccccc 添加为新的尾元素

    construct 调用中的模式会扩展出

    1
    std::forward<int>(10), std::forward<char>(c)

    通过在此调用中使用 forward,我们保证如果用一个右值调用 emplace_back,则 construct 也会得到一个右值。例如,在下面的调用中:

    1
    svec.emplace_back(s1 + s2); // 使用移动构造函数

    传递给 emplace_back 的实参是一个右值,它将以如下形式传递给 construct

    1
    std::forward<string>(string("the end"))

    forward<string> 的结果类型是 string &&,因此 construct 将得到一个右值引用实参。construct 会继续将此实参传递给 string 的移动构造函数来创建新元素。

  4. 可变参数函数通常将它们的参数转发给其他函数。这种函数通常具有与我们的 emplace_back 函数一样的形式:

    1
    2
    3
    4
    5
    6
    7
    // fun 有零个或多个参数,每个参数都是一个模板参数类型的右值引用
    template <typename... Args>
    void fun(Args &&...args) // 将 Args 扩展为一个右值引用的列表
    {
    // work 的实参既扩展 Args 又扩展 args
    work(std::forward<Args>(args)...);
    }

    这里我们希望将 fun 的所有实参转发给另一个名为 work 的函数,假定由它完成函数的实际工作。类似 emplace_back 中对 construct 的调用,work 调用中的扩展既扩展了模板参数包也扩展了函数参数包。

    由于 fun 的参数是右值引用,因此我们可以传递给它任意类型的实参;由于我们使用 std::forward 传递这些实参,因此它们的所有类型信息在调用 work 时都会得到保持。

  5. 右值引用引用的是右值,但右值引用本身是左值。能被赋值的一定是左值,但左值不一定能被赋值,例如右值引用。——博主注

  6. 使用变参模板的关键——完美转发:右值引用 + forward——博主注

16.5 模板特例化

  1. 在某些情况下,通用模板的定义对特定类型是不适合的:通用定义可能编译失败或做得不正确。当我们不能(或不希望)使用模板版本时,可以定义类或函数模板的一个特例化版本。

  2. 我们希望 compare 通过调用 strcmp 比较两个字符指针而非比较指针值。实际上,我们已经重载了 compare 函数来处理字符串字面常量:

    1
    2
    3
    4
    5
    6
    // 第一个版本;可以比较任意两个类型
    template <typename T>
    int compare(const T &, const T &);
    // 第二个版本处理字符串字面常量
    template <size_t N, size_t M>
    int compare(const char (&)[N], const char (&)[M]);

    但是,只有当我们传递给 compare 一个字符串字面常量或者一个数组时,编译器才会调用接受两个非类型模板参数的版本。如果我们传递给它字符指针,就会调用第一个版本:

    1
    2
    3
    const char *p1 = "hi", *p2 = "mom";
    compare(p1, p2); // 调用第一个模板
    compare("hi", "mom"); // 调用有两个非类型参数的版本

    我们无法将一个指针转换为一个数组的引用,因此当参数是 p1 和 p2 时,第二个版本的 compare 是不可行的。

    为了处理字符指针(而不是数组),可以为第一个版本的 compare 定义一个模板特例化(template specialization)版本。一个特例化版本就是模板的一个独立的定义,在其中个或多个模板参数被指定为特定的类型。

  3. 当我们特例化一个函数模板时,必须为原模板中的每个模板参数都提供实参。 为了指出我们正在实例化一个模板,应使用关键字 template 后跟一个空尖括号对(<>)。空尖括号指出我们将为原模板的所有模板参数提供实参:

    1
    2
    3
    4
    5
    6
    // compare 的特殊版本,处理字符数组的指针
    template <>
    int compare(const char *const &p1, const char *const &p2)
    {
    return strcmp(p1, p2);
    }

    理解此特例化版本的困难之处是函数参数类型。当我们定义一个特例化版本时,函数参数类型必须与一个先前声明的模板中对应的类型匹配。本例中我们特例化:

    1
    2
    template <typename T>
    int compare(const T &, const T &);

    其中函数参数为一个 const 类型的引用。我们希望定义此函数的一个特例化版本,其中 Tconst char*。我们的函数要求一个指向此类型 const 版本的引用。一个指针类型的 const 版本是一个常量指针而不是指向 const 类型的指针。我们需要在特例化版本中使用的类型是 const char* const&,即一个指向 const charconst 指针的引用。

    函数模板只允许全特化,不允许偏特化。——博主注

  4. 当定义函数模板的特例化版本时,我们本质上接管了编译器的工作。即,我们为原模板的一个特殊实例提供了定义。重要的是要弄清:一个特例化版本本质上是一个实例,而非函数名的一个重载版本。

    Note: 特例化的本质是实例化一个模板,而非重载它。因此,特例化不影响函数匹配。

  5. 我们将一个特殊的函数定义为一个特例化版本还是一个独立的非模板函数,会影响到函数匹配。例如,我们已经定义了两个版本的 compare 函数模板,一个接受数组引用参数,另一个接受 const T&。我们还定义了一个特例化版本来处理字符指针,这对函数匹配没有影响。当我们对字符串字面常量调用 compare

    1
    compare("hi", "mom")

    对此调用,两个函数模板都是可行的,且提供同样好的(即精确的)匹配。但是,接受字符数组参数的版本更特例化,因此编译器会选择它。

    如果我们将接受字符指针的 compare 版本定义为一个普通的非模板函数(而不是模板的一个特例化版本),此调用的解析就会不同。在此情况下,将会有三个可行的函数:两个模板和非模板的字符指针版本。所有三个函数都提供同样好的匹配。如前所述,当一个非模板函数提供与函数模板同样好的匹配时,编译器会选择非模板版本。

    当提供同样好的匹配时,非模板函数优先于模板函数,特例化模板函数优先于非特例化模板函数。——博主注

  6. 为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。

    对于普通类和函数,丢失声明的情况(通常)很容易发现——编译器将不能继续处理我们的代码。但是,如果丢失了一个特例化版本的声明,编译器通常可以用原模板生成代码。由于在丢失特例化版本时编译器通常会实例化原模板,很容易产生模板及其特例化版本声明顺序导致的错误,而这种错误又很难查找。

    如果一个程序使用一个特例化版本,而同时原模板的一个实例具有相同的模板实参集合,就会产生错误。但是,这种错误编译器又无法发现。

    Best Practices:模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。

  7. 除了特例化函数模板,我们还可以特例化类模板。默认情况下,无序容器使用 hash<key_type> 来组织其元素。为了让我们自己的数据类型也能使用这种默认组织方式,必须定义 hash 模板的一个特例化版本。一个特例化 hash 类必须定义:

    • 一个重载的调用运算符,它接受一个容器关键字类型的对象,返回一个 size_t
    • 两个类型成员,result_typeargument_type,分别是调用运算符的返回类型和参数类型。
    • 默认构造函数和拷贝赋值运算符(可以隐式定义)。

    在定义此特例化版本的 hash 时,唯一复杂的地方是:必须在原模板定义所在的命名空间中特例化它。我们可以向命名空间添加成员。

    下面的代码定义了一个能处理 Sales_data 的特例化 hash 版本:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 打开 std 命名空间,以便特例化 std::hash
    namespace std {

    template <> // 我们正在定义一个特例化版本,模板参数为 Sales_data
    struct hash<Sales_data>
    {
    // 用来散列一个无序容器的类型必须要定义下列类型
    typedef size_t result_type;
    typedef Sales_data argument_type; // 默认情况下,此类型需要 ==
    size_t operator()(const Sales_data &s) const;
    // 我们的类使用合成的拷贝控制成员和默认构造函数
    };
    size_t hash<Sales_data>::operator()(const Sales_data &s) const
    {
    return hash<string>()(s.bookNo) ^ hash<unsigned>()(s.units_sold) ^
    hash<double>()(s.revenue);
    }

    } // namespace std

    我们的 hash<Sales_data> 定义以 template <> 开始,指出我们正在定义一个全特例化的模板。重载的调用运算符必须为给定类型的值定义一个哈希函数。对于一个给定值,任何时候调用此函数都应该返回相同的结果。一个好的哈希函数对不相等的对象(几乎总是)应该产生不同的结果。标准库为内置类型和很多标准库类型定义了 hash 类的特例化版本。

    默认情况下,为了处理特定关键字类型,无序容器会组合使用 key_type 对应的特例化 hash 版本和 key_type 上的相等运算符。假定我们的特例化版本在作用域中,当将 Sales_data 作为容器的关键字类型时,编译器就会自动使用此特例化版本:

    1
    2
    // 使用 hash<Sales_data> 和 Sales_data 的 operator==
    unordered_multiset<Sales_data> SDset;

    由于 hash<Sales_data> 使用 Sales_data 的私有成员,我们必须将它声明为 Sales_data 的友元:

    1
    2
    3
    4
    5
    6
    7
    template <class T>
    class std::hash; // 友元声明所需要的
    class Sales_data
    {
    friend class std::hash<Sales_data>;
    // 其他成员定义,如前
    };

    这段代码指出特殊实例 hash<Sales_data>Sales_data 的友元。由于此实例定义在 std 命名空间中,我们必须记得在 friend 声明中应使用 std::hash

    Note: 为了让 Sales_data 的用户能使用 hash 的特例化版本,我们应该在 Sales_data 的头文件中定义该特例化版本。

  8. 与函数模板不同,类模板的特例化不必为所有模板参数提供实参。我们可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性。一个类模板的部分特例化(partial specialization)本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参。

    Note:我们只能部分特例化类模板,而不能部分特例化函数模板。

  9. 标准库 remove_reference 类型是通过一系列的特例化版本来完成其功能的:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 原始的、最通用的版本
    template <class T>
    struct remove_reference
    {
    typedef T type;
    };
    // 部分特例化版本,将用于左值引用和右值引用
    template <class T>
    struct remove_reference<T &> // 左值引用
    {
    typedef T type;
    };
    template <class T>
    struct remove_reference<T &&> // 右值引用
    {
    typedef T type;
    };

    第一个模板定义了最通用的模板。它可以用任意类型实例化;它将模板实参作为 type 成员的类型。接下来的两个类是原始模板的部分特例化版本。

    由于一个部分特例化版本本质是一个模板,与往常一样,我们首先定义模板参数。类似任何其他特例化版本,部分特例化版本的名字与原模板的名字相同。对每个未完全确定类型的模板参数,在特例化版本的模板参数列表中都有一项与之对应。在类名之后,我们为要特例化的模板参数指定实参,这些实参列于模板名之后的尖括号中。这些实参与原始模板中的参数按位置对应。

    部分特例化版本的模板参数列表是原始模板的参数列表的一个子集或者是一个特例化版本。在本例中,特例化版本的模板参数的数目与原始模板相同,但是类型不同。两个特例化版本分别用于左值引用和右值引用类型:

    1
    2
    3
    4
    5
    6
    7
    int i;
    // decltype(42) 为 int,使用原始模板
    remove_reference<decltype(42)>::type a;
    // decltype(i) 为 int&,使用第一个(T&)部分特例化版本
    remove_reference<decltype(i)>::type b;
    // decitype(std::move(i)) 为int&&,使用第二个(即 T&&)部分特例化版本
    remove_reference<decltype(std::move(i))>::type c;

    三个变量 a、b 和 c 均为 int 类型。

  10. 我们可以只特例化特定成员函数而不是特例化整个模板。例如,如果 Foo 是一个模板类,包含一个成员 Bar,我们可以只特例化该成员:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    template <typename T>
    struct Foo
    {
    Foo(const T &t = T()) : mem(t) {}
    void Bar() { /* ... */ }
    T mem;
    // Foo 的其他成员
    };
    template <> // 我们正在特例化一个模板
    void Foo<int>::Bar() // 我们正在特例化 Foo<int> 的成员 Bar
    {
    // 进行应用于 int 的特例化处理
    }

    本例中我们只特例化 Foo<int> 类的一个成员,其他成员将由 Foo 模板提供:

    1
    2
    3
    4
    Foo<string> fs; // 实例化 Foo<string>::Foo()
    fs.Bar(); // 实例化 Foo<string>::Bar()
    Foo<int> fi; // 实例化 Foo<int>::Foo()
    fi.Bar(); // 使用我们特例化版本的 Foo<int>::Bar()

    当我们用 int 之外的任何类型使用 Foo 时,其成员像往常一样进行实例化。当我们用 int 使用 Foo 时,Bar 之外的成员像往常一样进行实例化。如果我们使用 Foo<int> 的成员 Bar,则会使用我们定义的特例化版本。

小结

  1. 一个模板就是一个编译器用米生成特定类类型或函数的蓝图。生成特定类或函数的过程称为实例化。标准库算法都是函数模板,标准库容器都是类模板。

  2. 显式模板实参允许我们固定一个或多个模板参数的类型或值。对于指定了显式模板实参的模板参数,可以应用正常的类型转换。

术语表

第 16 章术语表(1)

第 16 章术语表(2)


Thank you for your donate!