欢迎光临
我们一直在努力

现代C++⊂C++11篇(三)可变参数模板、emplace系列与新类功能全解析

C++ 模板相关内容往期回顾:

初始模板:C++泛型编程与模板核心知识全解析

模板进阶:C++ 模板进阶:非类型参数、特化与分离编译全解析

目录

一、模板里的黑魔法——可变参数模板

1.1 先从概念聊起——什么是可变参数与参数包

1.1.1 从C库的printf引入

1.1.2 模板的可变参数

1.2 可变参数模板到底怎么用

1.2.1 递归函数方式展开参数包(包扩展)

1.2.2 包扩展的终止函数的设计为何重要

1.2.3 强化对参数包的理解——另外一种调用场景分析

1.2.4 总结——可变参数模板就是模板中的模板

1.3 标准库里的可变参数模板——emplace 系列接口

1.3.1 C++11 新增的 emplace 系列

1.3.1.1 说好的“更高效”,到底高在哪儿?

1.3.1.2 push_back与emplace_back调用上的异同

1.4 在模拟list中看emplace_back的运行过程

二、新的类功能

2.1 默认的移动构造和移动赋值

2.2 成员变量声明时给缺省值

2.3 default和delete

2.4 final与override

三、STL中的一些变化


一、模板里的黑魔法——可变参数模板

1.1 先从概念聊起——什么是可变参数与参数包
1.1.1 从C库的printf引入

梦开始的地方,还要数C语言里的printf。这应该是不少人接触到的第一个可变参数函数,支持传入任意数量的参数,完成格式化输出。

但C语言这套可变参数的实现比较朴素,底层是靠宏和栈空间遍历参数,局限性不小,也没有类型安全保障。我们接下来要讲的模板可变参数,是完全不同的实现逻辑,能力和灵活性都要高出一个量级。

1.1.2 模板的可变参数

C++11正式把可变参数能力带进了模板体系,支持定义参数数量可变的函数模板和类模板。这一串数目不固定的参数,统一叫做参数包,具体分成两类:

  • 模板参数包:代表零个或多个模板类型参数;
  • 函数参数包:代表零个或多个函数形参。

写法核心就是省略号…,规则很好记:

template <class …Args> void Func(Args… args) {}
template <class …Args> void Func(Args&… args) {}
template <class …Args> void Func(Args&&… args) {}

模板参数列表里的class… /typename…,表示后面跟着零到多个类型;函数形参列表里,类型名后加 …,就代表零到多个参数对象。和普通模板一致,函数参数包也支持左值引用、右值引用写法,实例化时同样遵循引用折叠规则。

说穿了可变参数模板没那么玄乎,底层逻辑和普通模板一脉相承:编译器会根据传入的参数类型和数量,实例化出对应版本的函数,所有工作都在编译期完成

这里补充一个配套的实用操作符:sizeof…

这是C++11专门为参数包新增的编译期操作符,功能很直接:返回参数包里包含的参数个数。

#include<iostream>
#include<string>
using namespace std;

template<class …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函数,可以接收任意类型、任意数量、不分左右值的参数。三次调用分别传入 1个、2个、8个参数,sizeof…(args) 就会对应输出1、2、8,整个计算过程在编译阶段就全部完成了。

1.2 可变参数模板到底怎么用

光靠sizeof…拿到参数个数肯定不够用。实际开发里,我们真正需要的是把参数包里的每一个元素都取出来逐个处理。 但参数包不是普通数组,没法用args[i]按下标直接访问。在C++11的标准下,最经典、最主流的展开方式,就是编译期递归法。

1.2.1 递归函数方式展开参数包(包扩展)

这是最符合模板思维的展开方式,核心思路很朴素:把参数包拆成第一个参数Head+剩余参数包 Tail两部分,每次处理第一个参数,剩下的继续递归调用,一层层把整个参数包剥开

#include <iostream>
#include <string>
using namespace std;

// 1. 递归终止函数:参数包为空时匹配,作为递归出口
void ShowList(){ cout << endl;}

// 2. 递归展开函数:每次拆出第一个参数处理,剩余参数包继续递归
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(1, string("xxxxx"), 2.2),匹配入口模板,内部转发调用ShowList(1, "xxxxx", 2.2);
  • 匹配带模板参数的ShowList:第一个参数x拿到整型值1,剩余参数包是 ("xxxxx", 2.2),编译器实例化出对应int开头的函数版本;
  • 递归调用ShowList("xxxxx", 2.2):x拿到字符串对象,剩余参数包只剩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函数。有意思的地方在于,这个参数包里的每一个参数,都是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 必须返回一个实实在在的对象(哪怕只是个 int),
    // 才能拼出一个参数包喂给 Arguments。
    Arguments(GetArg(args)…);
    }

    1.2.4 总结——可变参数模板就是模板中的模板

    说来说去,可变参数模板骨子里就是一个更灵活的模板。你想啊,如果不支持可变模板参数,想支持不同个数的参数,我就得吭哧吭哧手写五个、六个重载函数。也就是说,这玩意儿,是“模板的模板”,帮你自动生成模板。

    结合前面的包展开,可以这样理解:编译器在实例化可变参数模板的时候,其实有两大步(有时编译器偷个懒,一步就给你捏完了)。另外,像之前那样一层层剥参数包产生出来的函数,严格来说不能叫“递归调用”,而应该看作函数重载。更准确的说法是:编译期递归式地生成,运行期本质靠的是重载。

    我还想多啰嗦两句: 可能有小伙伴嘀咕,C++这设计也太挫了吧,参数包展开干吗不搞成“底层用一个容器装着所有参数,再遍历展开”?其实,不是不想,是真做不到,C++17以前,根本不允许往一个容器里塞不同类型的参数。

    1.3 标准库里的可变参数模板——emplace 系列接口
    1.3.1 C++11 新增的 emplace 系列

    template <class… Args> void emplace_back (Args&&… args);
    template <class… Args> iterator emplace (const_iterator position, Args&&… args);

    C++11 之后,STL容器新增了emplace系列接口,一个个全都是可变参数模板。它们在功能上能完全覆盖原来的push和insert系列,但emplace还多出一手新玩法:假设容器是container<T>,emplace可以直接接收那些用来构造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的构造函数。没有中间商赚差价,对象就这么在目的地“合体”了。

    所以说,emplace_back又快又直接,能上emplace的地方,就别再惦记push和insert了。

    1.3.1.2 push_back与emplace_back调用上的异同

    1. 和push_back一样——左值走拷贝,右值走移动 先看代码和输出,然后再细说。

    int main()
    {
    list<pair<bit::string, int>> lt1;
    // 跟 push_back 一样,左值 → 拷贝构造
    pair<bit::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) — 移动构造
    **************************

    行为跟push_back完全一致:传一个左值进去,它老老实实调用拷贝构造把pair “抄”进节点里;一旦转成右值(move),它就机灵地走移动构造,省去一次深拷贝。

    2. 和push_back不同——带不带{},规矩差很多

    这里头有个很容易踩的坑:push_back的参数个数、类型是写死的,所以直接传多个构造参数它不认,必须用大括号{}包成一个整体。 emplace_back刚好反过来,它能直接收一堆构造参数,原地搭出对象,可你要是随手给它 {…}这种初始化列表,它瞬间懵圈。因为{"苹果", 1}这种大括号包裹的东西没有确定的类型,模板参数Args根本推导不出来。结论就是:emplace_back不支持列表构造。

    来看对比代码:

    int main()
    {
    list<pair<bit::string, int>> lt1;
    // emplace_back 直接传构造参数,这种玩法 push_back 想都别想
    lt1.emplace_back("苹果", 1);

    // push_back 必须用 {} 把参数拢起来,这才能构造出 pair
    lt1.push_back({"苹果", 1});

    // 但是 emplace_back 反过来就不行
    // lt1.emplace_back({ "苹果", 1 }); // ← 直接编译报错
    lt1.emplace_back("苹果", 1);
    lt1.push_back({ "苹果", 1 }); // ← 正确:借助初始化列表构造 pair
    return 0;
    }

    push_bac吃大括号,emplace_back吃裸参数,别喂反了。

    1.4 在模拟list中看emplace_back的运行过程

    咱们自己搓了一个list,把emplace_back接口用可变参数模板实现了一遍。整个思路不复杂:参数包沿着调用链一路往下传,最终溜进节点(ListNode)的构造函数里,直接去匹配容器元素类型T的构造参数。这就坐实了前面说的,emplace支持直塞构造参数,在某些场景下不用先捏一个临时对象再搬来搬去,而是在容器的内存上就地构造,自然更高效

    有个容易踩坑的细节:如果形参是Args&&… args这样的万能引用参数包,往下传递时记得顺手配上完美转发,写法是std::forward<Args>(args)…。如果偷懒不加这一步,包展开之后那些右值引用变量表达式就退化成左值,本打算“移动”的悄悄变成了“拷贝”,性能就白瞎了。

    下面把整段模拟代码贴出来,重点盯着emplace_back模板、insert模板以及节点构造函数里对完美转发的用法。

    // Test.cpp
    #include "List.h"
    #include<iostream>
    using namespace std;
    int main(){
    bit::list<pair<string, int>> lt1;
    lt1.emplace_back("苹果", 1);
    return 0;
    }
    //List.h
    #pragma once
    namespace bit
    {
    template<class T>
    struct ListNode
    {
    ListNode<T>* _next;
    ListNode<T>* _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::forward<Args>(args)…)
    {
    }
    };

    template<class T, class Ref, class Ptr>
    struct ListIterator
    {
    typedef ListNode<T> Node;
    typedef ListIterator<T, 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;
    }
    };

    template<class T>
    class list
    {
    public:
    typedef ListNode<T> Node;
    typedef ListIterator<T, T&, T*> iterator;
    typedef ListIterator<T, 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::forward<Args>(args)…);
    }
    template <class… Args>
    iterator insert(iterator pos, Args&&… args)
    {
    Node* cur = pos._node;
    Node* newnode = new Node(std::forward<Args>(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个“你不写编译器也会偷偷生成”的默认成员函数:构造函数、析构函数、拷贝构造函数、拷贝赋值运算符重载、取地址运算符重载、const 取地址运算符重载。 C++11在此基础上又塞了两个新成员:移动构造函数 和 移动赋值运算符重载。

    不过,编译器可不是每次都这么好心。它只在你没自己实现移动构造,同时也没碰过析构函数、拷贝构造、拷贝赋值重载这三个中的任何一个时,才会自动生成一个默认移动构造。 (标准委员会觉得这仨是绑在一根绳上的蚂蚱,你只要自己动了其中一个,大概率另外两个你也得自己搞定。)

    默认移动构造的行为大致是这样的:

    • 对于内置类型成员,直接按字节逐成员拷贝,也就是浅拷贝。别指望有什么“夺取资源”的移动语义,那是不存在的。

    • 对于自定义类型成员,就看这个成员自己有没有实现移动构造。实现了,就调它的移动构造;没实现,乖乖退回去调拷贝构造。

    默认移动赋值重载的生成条件和行为,完全是一个模子刻出来的:同样要求你没手写移动赋值,也没碰那三个“绑定”函数;内置类型逐字节拷贝,自定义类型能移动就移动,不能就拷贝

    另外有个重要的“连带后果”:一旦你提供了移动构造或者移动赋值,编译器就不会再自动帮你生成拷贝构造和拷贝赋值了。

    2.2 成员变量声明时给缺省值

    成员变量声明时给的缺省值,本质上是给初始化列表准备的备胎:如果你没有在初始化列表里显式初始化某个成员,它就会拿这个缺省值顶上。 这个知识点在类和对象部分已经聊过了,更详细的拆解可以精准投送到那篇文章的第3.4小节,讲得非常细:零基础也能学会的C++类与对象————保姆级教程(下篇)

    2.3 default和delete

    C++11给你更好的手段来控制那些“默认函数”的生杀大权。

    有时候,你明明想用某个默认函数,可编译器因为某些原因愣是不生成。比如,你手写了拷贝构造,移动构造就自动不给了。这时候,只要用显式告诉编译器:“喂,这个你还是得给我生成一下。”

    class MyClass {
    public:
    // 强制编译器生成默认构造函数
    MyClass() = default;

    // 因为存在这个带参构造,编译器原本不会自动生成 MyClass()
    MyClass(int x) : _val(x) {}

    private:
    int _val;
    };
    MyClass obj; // 多亏了 default,这里才没报错

    反过来,如果你想禁止某些默认函数,C++98时代得把它们设成private,并且只声明不定义,让人一调就链不上。到了C++11,简单粗暴多了:直接在声明后面加个=delete ,这门函数就算“删除了”,编译器根本不生成对应版本。

    class NoCopy {
    public:
    NoCopy() = default;

    // 禁止拷贝构造和拷贝赋值
    NoCopy(const NoCopy&) = delete;
    NoCopy& operator=(const NoCopy&) = delete;
    };
    NoCopy a;
    // NoCopy b = a; // 这行直接编译挂掉

    标准库里就有现成的例子:IO 流对象就是不允许拷贝的,背后的实现用的就是=delete这套机制。

    2.4 final与override

    关于final和override,继承和多态那章我们已经掰开揉碎了聊过,这里不再从头啰嗦,直接精准投送到那篇文章的第五点,需要补课的可以空降过去:深入理解C++多态(构成条件、重写机制与底层实现)

    不过我发现有些小伙伴对final有那么一丢丢小误会,趁这儿多嘴澄清几句:

    • final并不是只能往类上怼,它还能用来修饰虚函数,而且只能修饰虚函数(或是已经加了 override 的虚函数)。普通非虚函数想加final?编译器第一个不答应。
    • 修饰类的时候,final的意思很干脆,“这个类到此为止,别想再继承我”。
    • 修饰虚函数的时候,final的意思就稍微绕一点:它不是说这个函数不能被继承,子类照样会把函数乖乖继承下来,只是不能重写(override)它了。相当于在虚函数的门口挂了块“到此为止,禁止改造”的牌子。

    这么一区分,就不会把“不能重写”和“不能继承”搞混了。

    三、STL中的一些变化

    C++11给STL塞了几个新容器,下图里圈出来的那些就是。不过说句实话,真正在日常开发里频繁露脸的,还得是unordered_map和unordered_set。这俩我们前面已经花大力气拆解过了,剩下的几个混个脸熟就行,知道有这么个东西,用到的时候再去翻文档也不迟。

    容器这边新增的接口也不少,但真正称得上“重量级”的,基本都和右值引用、移动语义脱不开关系,push/insert/emplace系列接口、移动构造、移动赋值,还有 initializer_list 版本的构造等等,这些前头都细细聊过了。至于像cbegin/cend这种只读迭代器之类的小修小补,属于“无关痛痒但偶尔救急”的范畴,真要用了现查文档绰绰有余。还有容器的范围for遍历,也在容器那部分讲透了,这里就不再赘述。

    说到底,C++11给STL带来的这些变化,核心其实就两条线:

            一条是“能省就省”,右值引用、移动语义,还有emplace系列,都是在帮我们少做不必要的拷贝,把性能抠到骨头里;

            另一条是“想用就有”,新容器、初始化列表构造、default/delete、范围for这些,纯粹让代码写起来更顺手,表达意图更直白。

    这一整套组合拳打下来,STL没换骨架,但骨血里的效率和安全又往上拔了一截。后面的内容,咱们就继续顺着这条路走下去。

    赞(0)
    未经允许不得转载:171主机测评 » 现代C++⊂C++11篇(三)可变参数模板、emplace系列与新类功能全解析
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址