C++11篇 (三) 深度解构:可变参数模板、类功能演进与 STL 的新版图

📅 发布时间:2026/9/24 5:01:45
C++11篇 (三) 深度解构:可变参数模板、类功能演进与 STL 的新版图
目录一模板中的模板可变参数模板1.1从概念开始——什么是可变参数与参数包1.1.1从C库中的printf引入1.1.2模板的可变参数Tips新操作符追加“sizeof…”1.2简化代码的利器——可变参数模板该怎么用1.2.1 递归函数方式展开参数包包扩展1.2.2包扩展的终止函数的设计为何重要1.2.3强化对参数包的理解——另外一种调用场景分析1.2.4总结——可变参数模板就是模板中的模板1.3库里面的可变参数模板——emplace系列接口1.3.1C11新增emplace系列接口1.3.1.1这里的“更高效”到底是怎么回事1.3.1.2push_back与emplace_back调用上的异同1.4在模拟list中看emplace_back的运行过程二新的类功能2.1默认的移动构造和移动赋值2.2成员变量声明时给缺省值2.3 defult和delete2.4 final与override三STL中一些变化一模板中的模板可变参数模板1.1从概念开始——什么是可变参数与参数包1.1.1从C库中的printf引入在梦开始的地方C语言时期我们接触过一个函数printf他就是一个可变参数的函数。可变参数意味着我们可以传递任意多个参数给函数并完成相关操作。但是这个函数的可变参数的底层是通过一个数组来存储参数并打印的。我们接下来学习的模板的可变参数与此又有不同。1.1.2模板的可变参数C11支持可变参数模板也就是说支持可变数量参数的函数模板和类 模板可变数目的参数被称为参数包存在两种参数包模板参数包表示零或多个模板参数函数参数包表示零或多个函数参数。template class ...Args void Func(Args... args) {} template class ...Args void Func(Args... args) {} template class ...Args void Func(Args... args) {}我们用省略号来指出一个模板参数或函数参数表示一个包在模板参数列表中class… 或 typename… 指出接下来的参数表示零或多个类型列表在函数参数列表中类型名后面跟 … 指出接下来表示零或多个形参对象列表函数参数包可以用左值引用或右值引用表示跟前面普通模板一样每个参数实例化时遵循引用折叠规则。可变参数模板的原理跟模板类似本质还是去实例化对应类型和个数的多个函数。Tips新操作符追加“sizeof…”sizeof… 是 C11 引入的一个预处理器/编译时操作符返回变长参数模板中**“参数包”所包含的参数个数。**#includeiostream #includestring using namespace std; templateclass ...Args void print(Args ... args){ cout sizeof...(args) endl; } int main(){ int i 0;int j 1;double k 2; char l 3;string m 123456; print(i); print(i, j); print(i, j, k, l, m, 1, 9, 546); return 0; }如上图我们写了一个可变模板参数右值引用版本的函数print我们可以传递任意类型任意数量不分左右值的参数给函数print。函数内部的sizeof…操作符表达式语句打印结果分别是128.1.2简化代码的利器——可变参数模板该怎么用虽然我们通过 sizeof… 拿到了参数包的数量但仅仅知道“有多少个”是不够的。在实际开发中我们最迫切的需求是如何取出参数包里的每一个参数。与普通数组不同你不能通过 args[i] 这种下标方式来访问参数包。在 C11 中展开参数包主流的方式是编译递归法。1.2.1 递归函数方式展开参数包包扩展这是最经典、最符合“模板直觉”的方法。它的核心思想是将参数包拆解为第一个参数Head 剩余参数包Tail。通过不断递归调用自身一层层“剥开”参数包。#include iostream #include string using namespace std; // 1. 递归终止函数,当参数包 args... 为空时会匹配这个无参的 ShowList void ShowList(){ cout endl;} // 2. 展开函数递归推导,每次调用都会将参数包的第一个参数赋给 x剩下的 N-1 个参数包给 args... template class T, class ...Args void ShowList(T x, Args... args){ cout x ; ShowList(args...); } // 3. 可变参数模板入口 template class ...Args void Print(Args... args){ ShowList(args...); } int main(){ Print(1, string(xxxxx), 2.2); return 0; }就代码进行原理分析匹配 Print(int, string, double)它内部调用 ShowList(1, “xxxxx”, 2.2)。匹配 ShowList(T x, Args… args)此时 x 是 1剩下的包是 (“xxxxx”, 2.2)。编译器实例化出一个处理 int 的函数。递归调用 ShowList(“xxxxx”, 2.2)此时 x 是 “xxxxx”剩下的包是 (2.2)。编译器实例化出一个处理 string 的函数。递归调用 ShowList(2.2)此时 x 是 2.2剩下的包为空。编译器实例化出一个处理 double 的函数。递归调用 ShowList()匹配到那个最简单的无参终止函数递归结束。1.2.2包扩展的终止函数的设计为何重要有人看到我上面的这段代码可能会问了递归调用在函数内部写一个递归终止条件不就好了怎么要这么麻烦“不对这和以前的递归还不一样上面模板的递归实际上是一个编译时递归实例化的过程。如下图在编译时通过不断的递归生成对应参数个数的函数模板。再通过参数类型将这些模板实例化成对应参数类型的函数。在运行时沿着如下的调用顺序依次调用这些已经被实例化出来的函数。于是回答上面的问题终止函数void ShowList(){ cout endl;}就非常必要因为如果没有它作为递归的出口编译器在面对“剥离”后变为空的参数包时会由于找不到匹配的函数原型而直接罢工报错。1.2.3强化对参数包的理解——另外一种调用场景分析如下代码Arguments(GetArg(args)…);要传递一个参数包给Arguments函数。这个调用Arguments函数参数包的每一个参数都是GetArg函数的调用返回值同时GetArg函数每次只能接收一个参数也就是说GetArg函数被调用了sizeof…(args)次每一次按顺序接收一个来自参数包… args的参数。在这个过程中编译器底层实际上也是帮助你生成了很多的函数并调用的就像这样Arguments(GetArg(1), GetArg(2), GetArg(3)…);template class T int GetArg(const T x){ cout x ; return 0; } template class ...Args void Arguments(Args... args){} template class ...Args void Print(Args... args) { // 注意GetArg必须返回或者到的对象这样才能组成参数包给Arguments. Arguments(GetArg(args)...); }1.2.4总结——可变参数模板就是模板中的模板综上可变参数模板本质上是一个更加灵活的模板。如果不支持可变模板参数那么我也需要写五个这样的函数。就是说这个就是一个模板的模板。如下图对上文的包展开进行总结。需要注意的是这里的可变参数模板在编译器的实例化有两步编译器可能会合二为一成一步。实际上通过类似于这样的递归生成函数不能说是“递归调用”而应该是函数重载只能说编译时是递归编译生成运行时本质是函数重载我还想讲两句可能有小伙伴说C这种设计太挫了为什么参数包展开不设计成“底层用一个容器来存放参数遍历展开”呢实际上不是不想而是不能C17之前都不允许往一共容器里面放不同类型的参数。1.3库里面的可变参数模板——emplace系列接口1.3.1C11新增emplace系列接口template class... Args void emplace_back (Args... args); template class... Args iterator emplace (const_iterator position, Args... args);C11以后STL容器新增了emplace系列的接口emplace系列的接口均为模板可变参数功能上兼容push和insert 系列但是emplace还支持新玩法假设容器为container T emplace还支持直接插入构造T对象的参数这样有些场景会更高效一些可以直接在容器空间上构造T对象。emplace_back总体而言是更高效推荐以后使用emplace系列替代insert和push系列1.3.1.1这里的“更高效”到底是怎么回事很多小伙伴看完上文说emplace_back 比 push_back 快但总觉得云里雾里的。咱们直接看图里的核心逻辑其实就是“少了一次搬运”。push_back看图中左上角push_back 的参数类型是固定的 value_type这里咱们显式实例化成了 string。当你传一个字符串字面量 “1111111111” 进去时编译器发现类型不匹配一个是 const char*一个是 string。于是编译器会默默地先用这个字面量构造一个 string 临时对象。然后push_back 再把这个临时对象拷贝构造或移动构造到 list 的新节点里。代价 产生了一个临时对象并多了一次构造调用。emplace_back再看左下角emplace_back 是个可变参数模板。它的参数类型是在调用那一刻才确定的。当你传 “1111111111” 时它直接把这个 const char* 类型的参数原封不动地“传递”给了 list node 的构造函数。最终在容器申请好的那块内存空间上直接原地调用构造函数生成 string。代价 没有中间商赚差价直接在目的地合体.1.3.1.2push_back与emplace_back调用上的异同1.和push_back一样左值调用构造右值调用拷贝构造。int main() { listpairbit::string, int lt1; // 跟push_back一样 // 构造pair 拷贝/移动构造pair到list的节点中 pairbit::string, int kv(苹果, 1); lt1.emplace_back(kv); cout ********************** endl; // 跟push_back一样 lt1.emplace_back(move(kv)); cout ********************** endl; return 0; }上面代码的测试结果string(char* str)-构造 string(const string s) -- 拷贝构造 ************************** string(string s) -- 移动构造 **************************2.和push_back不同传参加不加{}有规矩。push_back的参数个数和类型已经被固定了必须传递一个{}扩起来的值。emplace_back不能传递一个{}括起来的值因为 { “苹果”, 1 } 这种初始化列表braced-init-list本身没有确定的类型它无法直接推导出模板参数 Args。——是的emplace_back不支持列表构造int main() { listpairbit::string, int lt1; //这里达到的效果是push_back做不到的 lt1.emplace_back(苹果, 1); //lt1.push_back(苹果, 1); // ← 编译错误被注释或报错 lt1.push_back({苹果, 1}); // 反而emplace不能这么写 // lt1.emplace_back({ 苹果, 1 }); // ← 错误写法被划掉或注释 lt1.emplace_back(苹果, 1); lt1.push_back({ 苹果, 1 }); // ← 正确使用初始化列表构造 pair return 0; }1.4在模拟list中看emplace_back的运行过程我们模拟实现了list的emplace_back接口这里把参数包不段往下传递最终在结点的构造中直接去匹配容器存储 的数据类型T的构造所以达到了前面说的emplace支持直接插入构造T对象的参数这样有些场景会更高效一些可以直接在容器空间上构造T对象。传递参数包过程中如果是 Args… args 的参数包要用完美转发参数包方式如下std::forward(args)… 否则编译时包扩展后右值引用变量表达式就变成了左值。// Test.cpp #include List.h #includeiostream using namespace std; int main(){ bit::listpairstring, int lt1; lt1.emplace_back(苹果, 1); return 0; } //List.h #pragma once namespace bit { templateclass T struct ListNode { ListNodeT* _next; ListNodeT* _prev; T _data; ListNode(T data) : _next(nullptr) , _prev(nullptr) , _data(move(data)) { } template class... Args ListNode(Args... args) : _next(nullptr) , _prev(nullptr) , _data(std::forwardArgs(args)...) { } }; templateclass T, class Ref, class Ptr struct ListIterator { typedef ListNodeT Node; typedef ListIteratorT, Ref, Ptr Self; Node* _node; ListIterator(Node* node) : _node(node) { } // it Self operator() { _node _node-_next; return *this; } Self operator--() { _node _node-_prev; return *this; } Ref operator*() { return _node-_data; } bool operator!(const Self it) { return _node ! it._node; } }; templateclass T class list { public: typedef ListNodeT Node; typedef ListIteratorT, T, T* iterator; typedef ListIteratorT, const T, const T* const_iterator; iterator begin() { return iterator(_head-_next); } iterator end() { return iterator(_head); } void empty_init() { _head new Node(); _head-_next _head; _head-_prev _head; } list() { empty_init(); } void push_back(const T x) { insert(end(), x); } void push_back(T x) { insert(end(), move(x)); } iterator insert(iterator pos, const T x) { Node* cur pos._node; Node* newnode new Node(x); Node* prev cur-_prev; // prev newnode cur prev-_next newnode; newnode-_prev prev; newnode-_next cur; cur-_prev newnode; return iterator(newnode); } iterator insert(iterator pos, T x) { Node* cur pos._node; Node* newnode new Node(move(x)); Node* prev cur-_prev; // prev newnode cur prev-_next newnode; newnode-_prev prev; newnode-_next cur; cur-_prev newnode; return iterator(newnode); } template class... Args void emplace_back(Args... args) { insert(end(), std::forwardArgs(args)...); } template class... Args iterator insert(iterator pos, Args... args) { Node* cur pos._node; Node* newnode new Node(std::forwardArgs(args)...); Node* prev cur-_prev; // prev newnode cur prev-_next newnode; newnode-_prev prev; newnode-_next cur; cur-_prev newnode; return iterator(newnode); } private: Node* _head; }; }二新的类功能2.1默认的移动构造和移动赋值原来C类中有6个默认成员函数,默认成员函数就是我们不写编译器会生成一个默认的。C11 新增了两个默认成员函数移动构造函数和移动赋值运算符重载。构造函数、析构函数、拷贝构造函数拷贝赋值重载、取地址重载、const 取地址重载如果你没有自己实现移动构造函数且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个。委员会认为这三者是绑定在一起的你写了其中一者就会去写其他几者 那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数 :对于内置类型成员会执行逐成员按字节拷贝(浅拷贝)。并没有“移动语义”中的夺取资源。自定义类型成员则需要看这个成员是否实现移动构造如果实现了就调用移动构造没有实现就调用拷贝构造。如果你没有自己实现移动赋值重载函数且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个那么编译器会自动生成一个默认移动赋值。默认生成的移动赋值重载函数:对于内置类型成员会执行逐成员按字节拷贝。自定义类型成员则需要看这个成员是否实现移动赋值如果实现了就调用移动赋值没有实现就调用拷贝赋值。默认移动赋值跟上面移动构造完全类似如果你提供了移动构造或者移动赋值编译器不会自动提供拷贝构造和拷贝赋值。2.2成员变量声明时给缺省值成员变量声明时给缺省值是给初始化列表用的如果没有显示在初始化列表初始化就会在初始化列表用这个却绳子初始化这个我们在类和对象部分讲过了。2.3 defult和deleteC11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数但是因为一些原因这个函数没有默认生成。比如我们提供了拷贝构造就不会生成移动构造了那么我们可以使用 default 关键字显示指定移动构造生成。class MyClass { public: // 强制编译器生成默认构造函数 MyClass() default; // 因为写了这个编译器原本不会自动生成 MyClass() MyClass(int x) : _val(x) {} private: int _val; }; MyClass obj; // 有了 default这里才不会报错如果能想要限制某些默认函数的生成在C98中是该函数设置成 private并且只声明补丁已应为“只声明不定义”这样只要其他人想要调用就会报错。在C11中更简单只需在该函数声明加上 delete 即可该语法指示编译器不生成对应函数的默认版本称 delete 修饰的函数为删除函数。class NoCopy { public: NoCopy() default; // 禁止拷贝构造和赋值 NoCopy(const NoCopy) delete; NoCopy operator(const NoCopy) delete; }; NoCopy a; // NoCopy b a; // 编译直接报错库里面的举例IO流不允许拷贝。2.4 final与override这个我们在继承和多态章节已经进行了详细讲过了.补充与澄清final 可以修饰类表示不能被继承但它也可以修饰 虚函数限制重写不是“只能修饰类”。final 只能修饰虚函数或者 override 的虚函数普通非虚函数不能加 final。final 修饰虚函数时表示该函数不能被子类重写 (override)不是不能“继承”。子类仍然会继承该函数只是不能重写。三STL中一些变化下图1圈起来的就是STL中的新容器但是实际最有用的是unordered_map 和 unordered_set。这两个我们前面已经进行了非常详细的讲解其他的大家了解一下即可。STL中容器的新接口也不少最重要的就是右值引用和移动语义相关的 push/insert/emplace 系列接口和移动构造和移动赋值还有 initializer_list 版本的构造等这些前面都讲过了还有一些无关痛痒的如 cbegin/cend 等需要时查文档即可。容器的范围 for 遍历这个在容器部分也讲过了。