Re:思考·重建·记录 现代C++ C++11篇 (三) 深度解构:可变参数模板、类功能演进与 STL 的新版图

◆ 博主名称: 晓此方-CSDN博客 大家好,欢迎来到晓此方的博客。
⭐️现代C++系列个人专栏: 插曲:现代C++
⭐️ Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


文章目录


概要&序論

这里是此方,好久不见。 本专栏是【主题曲:C++程序设计 】专栏的补充篇【插曲:现代C++ 】。本系列将优先深度解析C++11标准,力求内容详实,无微不至。C++14~C++20的进阶内容将在后续间隔一段时间后连载。本期将重点讲解:可变参数模板、C++11新的类功能以及STL中的新变化.好的,让我们现在开始吧。


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

(本篇某种意义上也算是模版的现代进阶篇了(doge))

模板初阶Re:从零开始的 C++ 入門篇(十二)<过渡章节>模板初阶与STL简单介绍
模板进阶Re:从零开始的 C++ 进阶篇(一)超全的模板进阶详解:非类型模板参数、模板特化、与模板的分离编译

一,模板中的模板:可变参数模板

1.1从概念开始------什么是可变参数与参数包

1.1.1从C库中的printf引入

在梦开始的地方,C语言时期我们接触过一个函数printf,他就是一个可变参数的函数。

可变参数意味着我们可以传递任意多个参数给函数并完成相关操作 。但是这个函数的可变参数的底层是通过一个数组来存储参数并打印的。我们接下来学习的模板的可变参数与此又有不同。

1.1.2模板的可变参数

C++11支持可变参数模板,也就是说支持可变数量参数 的函数模板和类模板,可变数目的参数被称为参数包,存在两种参数包:

  • 模板参数包:表示零或多个模板参数;
  • 函数参数包:表示零或多个函数参数。
cpp 复制代码
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... 是 C++11 引入的一个 预处理器/编译时操作符,返回变长参数模板中**"参数包"所包含的参数个数。**

cpp 复制代码
#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,我们可以传递 任意类型,任意数量,不分左右值的参数给函数print。 函数内部的sizeof...操作符表达式语句打印结果分别是1,2,8.

1.2简化代码的利器------可变参数模板该怎么用

虽然我们通过 sizeof... 拿到了参数包的数量,但仅仅知道"有多少个"是不够的。在实际开发中,我们最迫切的需求是如何取出参数包里的每一个参数。

与普通数组不同,你不能通过 args[i] 这种下标方式来访问参数包 。在 C++11 中,展开参数包主流的方式是:编译递归法。

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

这是最经典、最符合"模板直觉"的方法。它的核心思想是将参数包拆解为:第一个参数(Head) + 剩余参数包(Tail)。通过不断递归调用自身,一层层"剥开"参数包。

cpp 复制代码
#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;
}

就代码进行原理分析:

  1. 匹配 Print(int, string, double),它内部调用 ShowList(1, "xxxxx", 2.2)。
  2. 匹配 ShowList(T x, Args... args),此时 x 是 1,剩下的包是 ("xxxxx", 2.2)。编译器实例化出一个处理 int 的函数。
  3. 递归调用 ShowList("xxxxx", 2.2),此时 x 是 "xxxxx",剩下的包是 (2.2)。编译器实例化出一个处理 string 的函数。
  4. 递归调用 ShowList(2.2),此时 x 是 2.2,剩下的包为空。编译器实例化出一个处理 double 的函数。
  5. 递归调用 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)...);

cpp 复制代码
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++这种设计太挫了,为什么参数包展开不设计成"底层用一个容器来存放参数+遍历展开 "呢?实际上不是不想,而是不能:C++17之前,都不允许往一共容器里面放不同类型的参数。

1.3库里面的可变参数模板------emplace系列接口

1.3.1C++11新增emplace系列接口

cpp 复制代码
template <class... Args> void emplace_back (Args&&... args);
template <class... Args> iterator emplace (const_iterator position, Args&&... args);

C++11以后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一样,左值调用构造,右值调用拷贝构造。

cpp 复制代码
int main()
{
    list<pair<bit::string, int>> lt1;
    // 跟push_back一样
    // 构造pair + 拷贝/移动构造pair到list的节点中
    pair<bit::string, int> kv("苹果", 1);
    lt1.emplace_back(kv);
    cout << "**********************" << endl;
    // 跟push_back一样
    lt1.emplace_back(move(kv));
    cout << "**********************" << endl;
    return 0;
}

上面代码的测试结果

cpp 复制代码
string(char* str)-构造
string(const string& s) -- 拷贝构造
**************************
string(string&& s) -- 移动构造
**************************

2.和push_back不同,传参加不加{}有规矩

  1. push_back的参数个数和类型已经被固定了,必须传递一个{}扩起来的值。
  2. emplace_back不能传递一个{}括起来的值,因为 { "苹果", 1 } 这种初始化列表(braced-init-list)本身没有确定的类型,它无法直接推导出模板参数 Args。 ------是的,emplace_back不支持列表构造
cpp 复制代码
int main()
{
    list<pair<bit::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)... , 否则编译时包扩展后右值引用变量表达式就变成了左值。

cpp 复制代码
// 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个默认成员函数,默认成员函数就是我们不写编译器会生成一个默认的。C++11 新增了两个默认成员函数,移动构造函数和移动赋值运算符重载。

构造函数、析构函数、拷贝构造函数

拷贝赋值重载、取地址重载、const 取地址重载

如果你没有自己实现移动构造函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个。委员会认为这三者是绑定在一起的,你写了其中一者就会去写其他几者) 那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数:

  • 对于内置类型成员会执行逐成员按字节拷贝 (浅拷贝)。并没有"移动语义"中的夺取资源。
  • 自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。

如果你没有自己实现移动赋值重载函数,且没有实现析构函数、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动赋值重载函数:

  • 对于内置类型成员会执行逐成员按字节拷贝。
  • 自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。(默认移动赋值跟上面移动构造完全类似

如果你提供了移动构造或者移动赋值,编译器不会自动提供拷贝构造和拷贝赋值。

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

成员变量声明时给缺省值是给初始化列表用的,如果没有显示在初始化列表初始化,就会在初始化列表用这个却绳子初始化 ,这个我们在类和对象部分讲过了。精准投送,在这篇文章的第3.5点,讲得非常详细 Re:从零开始的 C++ 入門篇(九)类和对象·最终篇上:缓冲区同步与流绑定、取地址运算符重载、const成员函数、初始化列表

2.3 defult和delete

C++11可以让你更好的控制要使用的默认函数。假设你要使用某个默认的函数,但是因为一些原因这个函数没有默认生成。比如:我们提供了拷贝构造,就不会生成移动构造了,那么我们可以使用 default 关键字显示指定移动构造生成。

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

    // 因为写了这个,编译器原本不会自动生成 MyClass()
    MyClass(int x) : _val(x) {} 

private:
    int _val;
};
MyClass obj; // 有了 default,这里才不会报错

如果能想要限制某些默认函数的生成,在C++98中,是该函数设置成 private,并且只声明补丁已(应为"只声明不定义"),这样只要其他人想要调用就会报错。在C++11中更简单,只需在该函数声明加上 =delete 即可,该语法指示编译器不生成对应函数的默认版本,称 =delete 修饰的函数为删除函数。

cpp 复制代码
class NoCopy {
public:
    NoCopy() = default;

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

库里面的举例: IO流不允许拷贝。

2.4 final与override

这个我们在继承和多态章节已经进行了详细讲过了,精准投送,这篇文章的第五点 :Re:从零开始的 C++ 进阶篇(三)彻底搞懂 C++ 多态:虚函数、虚表与动态绑定的底层原理

三,STL中一些变化

下图1圈起来的就是STL中的新容器,但是实际最有用的是 unordered_map 和 unordered_set。 这两个我们前面已经进行了非常详细的讲解,其他的大家了解一下即可。

STL中容器的新接口也不少,最重要的就是右值引用和移动语义相关的 push/insert/emplace 系列接口和移动构造和移动赋值 ,还有 initializer_list 版本的构造等,这些前面都讲过了,还有一些无关痛痒的如 cbegin/cend 等需要时查文档即可。

容器的范围 for 遍历,这个在容器部分也讲过了。


好了,本期内容到此结束,我是此方,我们下期再见。バイバイ!

相关推荐
小坏讲微服务2 小时前
Claude Code 终极实战指南:从终端 Agent 到 AI+Java 开发
java·开发语言·人工智能
爱学习的小囧2 小时前
ESXi 8.0 vSwitch与dvSwitch(分布式交换机)核心区别
服务器·开发语言·分布式·php·虚拟化
人道领域2 小时前
2026年Java后端热点科普:Java 26新特性+Java 21落地实战,解锁后端开发新范式
java·开发语言
测绘第一深情2 小时前
Transformer:从基础原理到自动驾驶 BEV 矢量化地图构建
开发语言·人工智能·经验分享·深度学习·机器学习·自动驾驶·transformer
周末也要写八哥2 小时前
Java面试时,线程为什么不安全?
java·开发语言·面试
Rust研习社2 小时前
Rust Clone 特征保姆级解读:显式复制到底怎么用?
开发语言·后端·rust
Albert Edison2 小时前
【RabbitMQ】七种工作模式
java·开发语言·分布式·rabbitmq
咸鱼翻身小阿橙2 小时前
QT总结-P2
开发语言·qt
We་ct2 小时前
JS手撕:手写Koa中间件与Promise核心特性
开发语言·前端·javascript·中间件·node.js·koa·co