跳转至

一、模板类/泛类型

模板类定义:template <class Type> class Stack{...};,类中可直接使用类型说明符Type。 模板类的外部实现:也要放在头文件。template<class Type> bool Stack<Type>::funcName(...){...} 模板类继承:template<typename Type> class GrowArray : public Array<Type>{...};

  • 允许递归使用模板,如二维数组。(C++98 里 > > 中间要有空格,C++11 起不再需要——这是标准良定义的词法规则变更。)
  • 模板的参数可以是类型参数和非类型参数。<> 中可以为类型 class T/typename T,也可以是非类型(non-type)参数 int n——非类型参数必须是常量表达式。类型间用逗号隔开。(C++17 起非类型参数可用 auto,C++20 起进一步放宽到浮点数和字面类类型。)
  • 将模板作为参数,即模板可以包含本身就是模板的参数。template <template <typename T> class Thing>
  • 可以为类型参数提供默认值,template <class T1, class T2=int> class Topo{...};
  • 模板成员(member template):早期很多编译器不支持,现代主流编译器都已支持。P585。嵌套写法:template <typename T> template <typename V> void beta<T>::func(V v){...}
  • 实例化(instantiation):根据具体类型生成具体的代码(如汇编)。模板本身不是函数/类,不能脱离实例化请求单独编译;使用时必须由具体的模板实参触发实例化。声明必须位于模板定义所在的命名空间中。 template class ArrayTP<string,100>;(显式实例化)

标准与实现

模板何时实例化、特化如何匹配、名字如何查找这些规则是标准良定义的,各编译器应一致;但实例化产物在二进制层如何去重、跨翻译单元如何合并、最大实例化深度是多少——这些是编译器+链接器的实现细节(GCC 用 COMDAT 折叠 + 链接器去重,MSVC 机制不同)。-ftemplate-depth=N(GCC/Clang)的最大递归深度是实现定义的,标准只给下限(C++11 起推荐至少 1024)。模板实例化出的符号经 name mangling 后由 Itanium ABI(或平台 ABI)描述,所以跨编译器/跨 ABI 的模板库二进制通常不兼容。

*特例化~~具体化~~(specialization):替代模板类,对一些特别参数的情形进行单独输出。

  • 全特化(total template specialization):template<> className<char *> {...};
  • 偏特化(partial specialization):template<class T1> class Pair<T1,int> {...};template<class T1> class Feeb<T1*> {...};注意:偏特化只适用于类模板和变量模板,函数模板不能偏特化——只能用重载或 SFINAE/concepts 替代。 使用模板切忌盲目使用。

模板函数的使用时,可忽略<>而直接通过参数配型自动匹配模板。如showArray(pd, 3),但自己更推荐showArray<>(pd, 3)。 非模板函数可以和同名的模板函数共存,当函数调用时不加<>,则仅当完全匹配时才运行非模板函数。当函数调用时添加<>,则无论是否完全匹配非模板函数,都会运行模板函数。P294

在C++中,接收任意数量参数的特性是通过模板和可变参数模板(variadic templates)来实现的。这个特性是在C++11标准中引入的,它是C++语言的一次重大更新,发布于2011年。 这让屎山般的模版可读性提升的一个层次。C++14让返回值和lambda支持auto,再次让模版更具可读性。

1.1 模板类的友元

  • 非模板友元:即调用模板类和模板类对象,把模板类作为参数。可看出,非模板友元函数针对该模板类的每一个具体实例化(如 HasFriend<int>HasFriend<double>)各写一个版本。
template <typename T> class HasFriend{
    friend void counts();
    friend void reports(HasFriend<T> &);//注意是 T 的实例,不是 T&
};
void counts(){ cout << "int count: " << HasFriend<int>::ct << ";";}
void reports(HasFriend<int> & hf){ cout << "HasFriend<int>: " << hf.item << endl;}
void reports(HasFriend<double> & hf){ cout << "HasFriend<double>: " << hf.item << endl;}
  • 约束(bound)模板友元:即友元的类型取决于类被实例化时的类型。可看出,模板类与约束模板友元函数是一一对应的关系。
template <typename TT> class HasFriendT{
    friend void counts<TT>();//无法推断,则必须显式写明
    friend void reports<>(HasFriend<TT> &);//可推导,无需写明
};
template <typename T> void counts(){ cout << "template count: " << sizeof(HasFriendT<T>) << ";";}
template <typename T>  void reports(HasFriend<T> & hf){ cout <<  hf.item << endl;}
  • 非约束(unbound)模板友元:每个函数特例化都是每个类特例化的友元。可看出,模板类与非约束模板友元是多对多的关系。
template <typename T> class ManyFriend{
    template <typename C, typename D> friend void show2(C&,D&);
};
template <typename C, typename D>  void show2(C& c, D& d){
    cout << c.item << "," << d.item << endl;
}

待核对:上面三段友元示例摘自书里,但 counts/reports 都未在类内声明 ct/item 成员,原样照抄编译不过,仅作"三类友元写法对照"用。

1.2 别名

模板别名 / 别名模板(alias template,C++11) 类内不能定义别名模板:template <typename T> using arrtype = std::array<T,12>;(别名模板必须放在命名空间作用域) 类内可以用普通 typedef 或非模板 using:typedef std::array<double, 12> arrd;

关于 typename 关键字(C++98):依赖于模板参数的限定名(dependent qualified name)既可能是类中的静态成员/静态函数,也可能是嵌套类型。因此:如果该限定名表示的是依赖于模板参数的类型,前面必须加 typename 消歧(除非在基类列表或成员初始化列表中)。

typedef(类型别名)和 using(别名声明) typedef 只能给类型取别名。using 关键字可以给模板取别名,或者说:别名声明把模板模板化,这种情况下它们被称为别名模板(alias template)。

template<typename T> struct Node { T a; };
// typedef Node Test;              // error,Node 是模板,必须带实参
typedef Node<int> Test;            // yes,给模板实参后可以 typedef
template<typename T> using Test = Node<T>; // yes,别名模板
// 另外,函数内不想写全限定名,可以 using 限定名,从而可以不加说明地使用缩写名。
// 但我常常误记为可以在 using namespace 作用域名,实际上并不行。

typename 关键字的两重身份(参考):① 在模板参数声明中,typename Tclass T 完全等价;② 用于消歧嵌套依赖类型名,如 typename T::value_type,告诉编译器 T::value_type 是个类型而非静态成员。

二、模板参数推导

2.1 输入推导

模板型别推导:(M条款1)(C++98)

  • 普通实参: template隐式参数(即param)推断:
  • 非引用非指针:cv限定符不会传导至typename对象中。param是一个完全独立于cx和rx存在的对象——是cx和rx的一个副本。从而cx和rx不可修改这一事实并不能说明param是否可以修改。
  • 引用或指针:cv 限定符会传导至 typename 对象中。
  • 万能引用:cv限定符会传导至typename对象中。当传入实参为左值时,param为左值引用;当传入实参是右值时,&可以传导到typename中,param类型为&&。(小旋涡)
  • 指针实参(&varpvar):cv限定符会传导至typename对象中。
  • 数组实参:cv限定符会传导至typename对象中。若param是非引用,typename会被推导为指针;若param是引用,则typename会被推导为对应大小的数组。(大漩涡)
  • 函数实参:若param是非引用,typename会被推导为函数指针;若param是引用,则typename会被推导为函数引用。

C++17 中引入了类模板实参推导(CTAD, Class Template Argument Deduction),允许在实例化类模板时省略模板参数,由构造函数参数反推。这个特性对于标准库中的类模板特别有用:std::pair p{1, 2.0};(C++17 之前必须写 std::pair<int, double>)。 注意:函数模板的实参推导从 C++98 就有;CTAD 是把这个能力扩展到类模板,是 C++17 引入的(不是 C++14,也不是 C++11)。

标准与实现

CTAD 的推导规则(基于构造函数、推导指引 deduction guide)是标准良定义的,各编译器一致;但 CTAD 触发的诊断信息质量是编译器相关的。

auto 类型推导就是模板型别推导。(M条款2)(C++11)唯一不同的是:当用 {...} 初始化时,auto 能推导为 std::initializer_list<T>,但把 {...} 传给模板参数 T 时推导失败(除非模板参数显式写 std::initializer_list<T>)。

2.2 输出推导与decltype

从C++14 开始 auto 还可以推导函数的返回值类型。 C++11中,decltype的主要用途大概就是在于声明哪些返回值类别依赖于形参类别的函数模板。返回值型别的尾置写法(trailing return type):template<typename Container, typename Index> auto func(Container& c, Index i) -> decltype(c[i]) { return c[i];} C++11允许对单表达式的lambda式的返回值型别实施推导。C++14则将这个允许范围扩展到了一切lambda式和一切函数。template<typename Container, typename Index> decltype(auto) func(Container& c, Index i) {return c[i];} decltype的行为名副其实,得出的该名字就是声明型别。即总是保留CV限定符,但注意int x; decltype((x))返回的型别为int&。(M条款3)

2.3 特定需求的推导

std::remove_reference: If the type T is a reference type, provides the member typedef type which is the type referred to by T. Otherwise type is T.

C++11:

#include <type_traits>
std::remove_const<T>::type          // 使用时,每次需要在前面加 typename
std::remove_reference<T>::type
std::add_lvalue_reference<T>::type
std::decay<T>::type                 // 清除 cv 限定符和各种引用,并数组/函数变指针
std::is_same<T, U>::value
std::is_constructible<Fd, Gp>::value

C++14:

std::remove_const_t<T>
std::remove_reference_t<T>
std::add_lvalue_reference_t<T>

Q:为什么 C++11 不用 C++14 的 _t 简写形式? A:std::remove_const_t<T> 这种 _t 后缀版本是 C++14 才在标准库提供的便捷别名(其定义就是 template<class T> using remove_const_t = typename remove_const<T>::type;)。C++11 标准库里还没有这个别名,所以每次都得手写 typename std::remove_const<T>::type

使用1:用于模板重载,定义标签类型(tag)以实现完美匹配。详见本章末尾。 使用2:static_assert(std::is_constructible<MyClass, int>::value, "MyClass should be constructible from int");

2.4 类型推导的核心原理--引用折叠

  • 编译器可以折叠,但用户不能折叠;
  • T型别的左值推导结果为T&,而T型别的右值推导为T。
  • 引用的引用会折叠为单个引用。如果引用中任一引用为左值引用,则结果为左值引用;否则,结果为右值引用,
  • 万能引用的类型推导是引用折叠的一个用例。
  • 引用折叠的语境:模板实例化、auto 型别生成、typedef 和别名声明的生成与使用。

感觉叫引用崩塌好理解点(reference-collapsing rules)

三、为什么使用auto:(条款5)

  • 避免变量未初始化。
  • 类型更准确。如 auto sz = v.size();,没有人会愿意写 std::vector<int>::size_type,若用 int 接收则会导致精度的缺失。
  • int index = doubleVal * c.size()会显得含含糊糊,而auto index = static_cast<int>(d * c.size());
  • C++98时,模板中迭代器的类型需要通过traits技术获取,而C++11可直接通过auto currValue =* it获取。更直观。
  • C++14让lambda表达式的输入参数型别为auto,进而让lambda表达式具有模板的能力。
  • auto 在调用和内存上都较 std::functional优。P43
  • 慎用:将auto指向std::vector<bool>::operator[],避免隐藏代理类导致的问题。

四、reference

4.1 step1

左值具有以下特征:可通过取地址运算符获取其地址; 可修改的左值可用作内建赋值和内建复合赋值运算符的左操作数; 可以用来初始化左值引用(后面有讲) C++11将右值分为纯右值(prvalue)和将亡值(xvalue)两种。纯右值就是C++98标准中右值的概念,如非引用返回的函数返回的临时值;一些运算表达式,如 1+2 产生的临时值;不跟对象关联的字面量,如 2'c'true;这些值都不能被取地址。注意:字符串字面量 "hello"左值(类型为 const char[N] 的数组,可取地址),不是纯右值——这是常见误解。而将亡值(xvalue)则是 C++11 新增的和右值引用相关的表达式,这样的表达式通常是将要移动的对象、T&& 函数返回值、std::move() 函数的返回值等。参考

左右值区分的简单记忆:是否可取地址,是否有名字。 但由于将亡值的存在,不能再以对象的生命周期来区分左右值了。所有复杂对象都是有地址的,就看这个对象是否有固定的地址了,对没有地址的对象取地址,应该是编译不过的。

特殊情况:static const int 成员变量可以仅有声明(类内),但若要对它取地址(如用作地址类型的非类型模板参数、绑定引用等 ODR-use 场景),就需要类外定义。仅作为整型常量表达式的非类型模板参数使用时不需要类外定义。建议把这种"边界情形"避免用作模板参数。

4.2 step2

理解符号 → 区分通用引用和右值引用 → 确定通用引用的类别推导结果

从符号到定义: 从概念上可分为左值引用和右值引用。 从符号上看,& 可以是左值引用,也可以是取址;&&可以是通用引用,也可以是右值引用。

比较指针和右值引用: 引用更接近于const指针。 引用的对象不能是右值,进而导致引用的类型一般需要保持一致(多态除外),否则将生成右值,导致编译无法通过。引用对象不能为空。 应避免返回指向"函数结束时已不存在的内存单元"的引用。返回引用不加 const 的典型应用是 operator<<。 左值是可以通过地址访问的变量。但常规变量输出可修改的左值,而 const 变量属于不可修改的左值。 如果数据对象是数组,则使用指针。 括号不会改变表达式的值和左值性。

区分通用引用和右值引用: type deduction(类别推导)发生在哪些场景?template、auto、decltype、结构化绑定……

  • If you see "T&&" without type deduction, you're looking at an rvalue reference.
  • For a reference to be universal, type deduction is necessary, but it's not sufficient.(即必要不充分)
  • Even the simple presence of a const qualifier is enough to disqualify a reference from being universal.
  • 在满足上一条的情况下,&& 修饰被推导的类型时(而不是推导类型的容器),&& 为通用引用。
  • template 和类型推导(type deduction)不总是一起出现。当 template 修饰模板类时,C++17 之前类模板必须显式给实参(不进行类型推导),C++17 起 CTAD 才让构造时也能推导;修饰方法或函数时,则进行了类型推导。
  • 推导还可以是 auto
  • 防呆:There's no requirement that you use the name T.

通用引用/万能引用/向前引用/转发引用的类别推导: Universal references correspond to rvalue references if they're initialized with rvalues(即传入 val 类型的右值时,T 推导为 val,param 类型为 val&&);they correspond to lvalue references if they're initialized with lvalues(即传入 val 类型的左值时,T 推导为 val&,经引用折叠后 param 类型为 val&)。 传入右值引用,void func(Widget&& && param)→ void func(Widget&& param); 传入左值或左值引用, void func(Widget& && param)→ void func(Widget& param)

因此,在具体应用中(即通用引用已经初始化了),就只剩下左值引用和右值引用的概念了。

4.3 存在的意义

左值引用本质是指针,但避免了绝大多数情况下指针为空的问题。

右值引用的存在并不是为了取代左值引用,而是充分利用右值(特别是临时对象)的构造来减少对象构造和析构操作以达到提高效率的目的。参考 右值引用用来绑定到右值,绑定到右值以后本来会被销毁的右值的生存期会延长至与绑定到它的右值引用的生存期。

万能引用的意义:同时兼具左值引用和右值引用的优点,避免了代码的冗余。当特例化为右值引用时,作用与右值引用同。但当特例化为左值引用时,虽然还是引用,但要考虑模板的实现细节:例如 std::bind 会按值拷贝保存传入的实参(即便是引用类型也被按值收集),因此若想要以引用形式修改目标函数中的参数,需要使用 std::ref()参考)。这也是推荐使用 lambda 表达式而不是 std::bind 的原因之一。

4.4 关键代码

在 C++11 中,标准库在 <utility>(实践中很多常用头文件会间接包含它,所以"感觉不用显式 #include")中提供了一个有用的函数 std::movestd::move 并不能移动任何东西,它唯一的功能是将其参数无条件地强制转换为右值引用,继而可以通过右值引用使用该值以触发移动语义。 总之,左右值引用及其辅助函数std::movestd::forward只是起到了程序链路控制的作用。 即:左右引用作为实参传入函数 → 重载机制决定不同的赋值/构造函数 → 决定是重新分配内存空间(new/delete),还是指针所有权的转移。

(参考 左右值引用与移动语义

/// remove_reference
template<typename _Tp>
  struct remove_reference
  { typedef _Tp   type; };

template<typename _Tp>
  struct remove_reference<_Tp&>
  { typedef _Tp   type; };

template<typename _Tp>
  struct remove_reference<_Tp&&>
  { typedef _Tp   type; };

// 使用重载模板来获取对象右值
template<typename _Tp>
  constexpr typename std::remove_reference<_Tp>::type&&
  move(_Tp&& __t) noexcept
  { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

// std::forward 的简化版(条件性转换:仅当 T 被推导为左值引用时才折叠成左值)
template<typename T>
  constexpr T&& forward(typename std::remove_reference<T>::type& param) noexcept
  { return static_cast<T&&>(param); }

Q:既然右值引用/万能引用存在传参的过程,那是按值还是按址传递的? A:这是学习中的混淆点。C++ 标准层面只有"按值/按引用/按指针",没有"按址"——"引用"在标准里语义上是别名,并不规定实现方式。实践中编译器通常用指针实现引用,所以对复杂对象按引用传递时底层确实是在传地址;但写代码时应按标准的"按引用传递"来理解。rvalue reference / universal reference 都属于"按引用传递",只是引用类型不同。"引用在二进制层如何实现"是实现定义/ABI 相关,标准不定。

五、std::move / std::forward 的使用

如果理解了「关键代码」小节中所述的程序链路控制的含义,那么,联想拷贝赋值运算符/构造函数的实现,很合理地得出:参数的成员转移后需要修改(如改为 nullptr),避免临时对象的析构函数将资源释放掉。 因为:移动语义意味着两点

  • 原对象不再被使用,如果对其使用会造成不可预知的后果。
  • 所有权转移,资源的所有权被转移给新的对象。参考

Use std::move on rvalue references, std::forward on universal references.

It's especially important to bear in mind that a parameter is always an lvalue, even if its type is an rvalue reference. C++这样设计的目的——应对特殊情况。在某些情况,你可能需要在一个函数中多次使用绑定到右值引用或者通用引用的对象,并且确保在完成其他操作前,这个对象不会被移动。这时,你只想在最后一次使用时,使用std::move(对右值引用)或者std::forward(对通用引用)。

std::move unconditionally casts its argument to an rvalue, std::forward does it only under certain conditions. 当函数参数为通用引用时,函数内的参数默认还是左值,为了将左右值引用信息传递给子函数,需使用std::forward

本节写得有点乱,回头重理一遍:核心是 std::move(无条件 cast 成右值)与 std::forward(仅当实参原本是右值时才 cast 成右值)的区别。

5.1 基础使用

熟知的使用例子:

  • vector 的 push_back 有 const& 版本和 && 版本。
  • std::forward:android Log 的实现。
  • 栈的移动构造、移动赋值(参考)。 ref-qualifier 讨论ref-qualifier 例子
  • std::unique_ptrstd::thread 这些仅可以移动、不能拷贝的对象。
类型&& 引用名 = 右值表达式;
int&& i = 10;
int&& iii = std::move(i); // 把 i 转为右值再绑定到 iii(对 int 而言只是语法演示)
auto&& nodeIOMaps = std::move(pBuilder->mNodeIOMaps);
pFrame->setNodeIOMaps(nodeIOMaps);
auto MAIN_CLASS_NAME::setNodeIOMaps(std::map<NodeId_T, IOMaps>&& arg) -> void {
  std::unique_lock<std::mutex> _l(mCtrlLock);
  mNodeIOMaps = std::move(arg);//要是移动构造函数才有意义
}

template< class F, class... Args >/* unspecified */ bind( F&& f, Args&&... args );,使用万能引用。在bind实现中,传入的引用类型的形参将会以值传递的形式进行参数收集保存,导致需要和std::ref配合使用才能真的按引用参数给目标函数。

5.2 RVO

RVO (Return Value Optimization) 通常分 URVO(返回匿名临时对象)和 NRVO(返回具名局部对象)。NRVO 的两个前提:局部对象的型别与函数返回值型别相同;返回的就是该局部对象本身。 即使 NRVO 的前提条件满足,编译器也可能不实施 NRVO;但此时编译器会把返回的局部对象隐式地当作右值处理(重载决议时会优先选移动构造而非拷贝构造)。 这意味着:在函数中按值返回局部对象时,手动对它施加 std::move 不但帮不上忙,反而可能帮倒忙(堵死 NRVO 的机会,强制走移动构造)。

标准与实现

复制省略(copy elision)在 C++17 起对返回 prvalue 的场景是强制的(guaranteed copy elision,标准良定义);但 NRVO(具名返回值优化)至今仍是实现定义/允许的优化,编译器可做可不做,且 -O0 下通常不做。三种主流编译器(GCC/Clang/MSVC)的 NRVO 触发条件也略有差异。

5.3 模版在函数重载中的问题

万能引用之标签分派 发挥作用的关键:存在一个单版本(无法重载的)函数作为客户端 API。该单版本函数会把待完成的工作分派到实现函数。

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(
        std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>()// is_integral 接收左值会返回 false
    );
}

std::multiset<std::string> names;

template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type)// 完全匹配
{
    logAndAdd(nameFromIndex(idx));// nameFromIndex 是个用户定义函数
}

复杂标签

class Person {
public:
    // 初衷:让字符串进来就能直接被完美转发,但要剔除索引号构造和拷贝/移动构造的重载。
    template<
        typename T,
        typename = typename std::enable_if<
                    !std::is_base_of<Person, // 不用 std::is_same,避免继承引入的问题
                                    typename std::decay<T>::type
                                    >::value
                    && !std::is_integral<std::remove_reference_t<T>>::value
                    >::type
    >
    explicit Person(T&& n)
    :name(std::forward<T>(n)) {
        static_assert(
            std::is_constructible<std::string, T>::value,
            "Parameter n can't be used to construct a std::string"
        );
        ...
    }

    explicit Person(int idx)
    :name(nameFromIdx(idx)) {...}
private:
    std::string name;
};

万能引用转发的层数越多,错误信息越让人摸不着头脑。

六、使用注释

C++11 引入的新特性是左右值区分对待。欲实现函数以利用可复制右值型别的移动语义,就需要重载或使用万能引用两者之一,但这两者都有一定的缺点。对于可复制的、移动成本低廉的型别,并且传入的函数总是对其实施复制这种特殊情况,在对象切片(slicing)也无需担心的前提下,按值传递可以提供一个易于实现的替代方案,它和按引用传递的竞争对手效率相近,但避免了它们的不足。(M条款41)

(M条款42)

// 置入更优的情形
vs.push_back(std::string("xyzzy")); // 插入:先单参构造 string,再移动构造进容器
vs.emplace_back("xyzzy");           // 置入:只施加一次构造,直接在容器内构造

// 置入并没有变差
string str;
vs.push_back(str);
vs.emplace_back(str);

// 与 explicit 的关系:这种情形下,置入和插入说不上孰优孰劣,主要看使用者的意图
std::vector<std::regex> regexes;
regexes.emplace_back(nullptr);  // 能编译:置入直接构造,无需隐式转换
regexes.push_back(nullptr);     // 编译错误:push_back 要求 nullptr→regex 的隐式转换,但 explicit 构造阻止之