【C++11】C++11 新特性全套详解(列表初始化、右值引用、lambda 表达式、function 与 bind 包装器等)

一.C++11的发展历史

C++11 - cppreference.com

2003 年 C++ 标准委员会发布了一份技术勘误表 TC1,于是 C++03 就诞生了,用来替代原先的 C++98,成为 C++11 之前最新的标准。不过 C++03 本质只是修补 C++98 里的各类漏洞,语言核心语法几乎没有改动,所以大家习惯把二者合在一起称作 C++98/03 标准。

C++11 可以说是十年磨一剑,是真正意义上的又一版重大标准。对比 C++98/03,C++11 新增了大概 140 项新特性,同时修复了 C++03 里约 600 处缺陷,改动幅度非常大,几乎相当于一门新语言。不管是系统开发还是库开发,C++11 都表现更好,语法更通用简洁,代码也更稳定安全,功能更强,还能提高开发效率。现在企业项目里 C++11 使用十分普遍,也是我们学习的重点,下面主要介绍工作里常用的实用语法。

C++ 标准委员会 1998 年成立,原本计划每 5 年更新一次标准。在做完 C++03 之后,下一版标准原本打算 2007 年推出,因此暂定名叫 C++07。到 2006 年,委员会发现 2007 年肯定完成不了,就连 2008 年也很难落地,索性改名为 C++0x。这里的 x 代表未知数,意思是不确定会是 07、08 还是 09 年正式发布。可项目一直延期,2010 年依旧没能定稿,直到 2011 年新标准才正式落地,于是最终命名为 C++11。


二.统一的列表初始化

1.C++98传统的{}

C++98中⼀般数组和结构体可以⽤花括号{}进⾏进行统一的列表初始值设定。

cpp 复制代码
struct Hjq
{
	int _x;
	int _y;
};

int main()
{
	int array[] = { 1,2,3,4,5,6 };
	int array1[] = { 0,1,2 };
	Hjq h = { 1,2 };
	return 0;
}

C++11 拓展了大括号初始化列表的适用场景,内置类型和自定义类型都支持用花括号完成初始化。写代码的时候,前面可以带上等号=,也可以直接省略不用。

cpp 复制代码
class Date
{
public:
    Date(int year, int month, int day)
        :_year(year)
        ,_month(month)
        ,_day(day)
    {
        cout << "Date(int year, int month, int day)" << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
 
int main()
{
    Date d1(2026, 9, 2); // old style
    // C++11支持的列表初始化,这里会调用构造函数初始化
    Date d2{ 2026, 9, 2 };
    Date d3 = { 2026, 9, 3 };
    return 0;
}

2.std::initializer_list

https://cplusplus.com/reference/initializer_list/initializer_list/

std::initializer_list 是 C++11 提供的库模板类型 。当我们写{...}初始化列表时,编译器会自动生成该类型对象 。它相当于对一组常量元素的轻量视图,不拷贝数据,只持有指针,内部元素只读,很多 STL 容器依靠它实现花括号批量初始化。

cpp 复制代码
int main()
{
    // the type of il is an initializer_list 
    auto il = { 10, 20, 30 };
    cout << typeid(il).name() << endl;
    return 0;
}
  • auto il = {10,20,30}推导类型为std::initializer_list<int>。
  • auto il{10,20,30}(无等号)C++17 之后会编译报错,不能推导出 initializer_list。
  • initializer_list仅作为视图,本身不存数据**,编译器自动生成临时数组保存元素。**
  • typeid(il).name()的输出各个编译器不一样,gcc 打印重整后的名字St16initializer_listIiE,VS 会输出可读的完整类型。
  • typeid() 是 C++ 运算符,在运行时获取对象的类型信息.name() 是它返回对象的成员函数,用来拿到类型对应的字符串。

std::initializer_list使用场景

std::initializer_list 最常见就是用作构造函数参数。C++11 为大量 STL 容器增加了该参数的构造函数,实现花括号直接初始化容器。 它同样可以作为operator=的参数,支持花括号直接赋值。我们自定义类也可以利用它实现批量初始化,注意列表内元素只读,不允许修改里面的数据

cpp 复制代码
// vector initializer_list 构造
cplusplus.com/reference/vector/vector/vector/

// vector initializer_list 赋值 operator=
cplusplus.com/reference/vector/vector/operator=/

// list initializer_list 构造
cplusplus.com/reference/list/list/list/

// list initializer_list 赋值 operator=
cplusplus.com/reference/list/list/operator=/

// map initializer_list 构造
cplusplus.com/reference/map/map/map/

// map initializer_list 赋值 operator=
cplusplus.com/reference/map/map/operator=/

// std::initializer_list 本身
cplusplus.com/reference/initializer_list/initializer_list/

https://cplusplus.com/reference/map/map/map/

https://cplusplus.com/reference/vector/vector/vector/

https://cplusplus.com/reference/list/list/list/

https://cplusplus.com/reference/vector/vector/operator=/

cpp 复制代码
#include<iostream>
using namespace  std;
#include<vector>
#include<list>
#include<map>

int main()
{
    vector<int> v = { 1,2,3,4 };
    list<int> lt = { 1,2 };
    // 这里{"sort", "排序"}会先初始化构造一个pair对象
    map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
    // 使用大括号对容器赋值
    v = {10, 20, 30};
    return 0;
}
  • vector、list、map 这些 STL 容器,C++11 都增加了接收 std::initializer_list 的构造函数和赋值函数,所以既可以花括号初始化,也可以花括号赋值。
  • list<int> lt = {1,2,3};:{1,2,3} 编译器隐式转为 std::initializer_list<int>,传给 list 的构造函数。
  • 注意坑:map里面放的是pair<string,string>,所以初始化要嵌套一层大括号 {"key","value"}。

接下来看一下,让模拟实现的 vector 也支持 {} 初始化和赋值

cpp 复制代码
namespace hjq
{
    template<class T>
    class vector {
    public:
        typedef T* iterator;

        // 接收initializer_list的构造函数
        vector(initializer_list<T> l)
        {
            // 根据initializer_list的元素个数开辟空间
            _start = new T[l.size()];
            _finish = _start + l.size();
            _endofstorage = _start + l.size();

            iterator vit = _start;
            // 获取initializer_list的迭代器遍历
            typename initializer_list<T>::iterator lit = l.begin();
            while (lit != l.end())
            {
                *vit++ = *lit++;
            }

            // 范围for也可以简化上面遍历
            //for (auto e : l)
            //    *vit++ = e;
        }

        // 支持花括号赋值 operator=
        vector<T>& operator=(initializer_list<T> l) {
            // 利用构造函数创建临时对象tmp
            vector<T> tmp(l);
            // 交换资源,tmp出作用域自动释放旧空间
            std::swap(_start, tmp._start);
            std::swap(_finish, tmp._finish);
            std::swap(_endofstorage, tmp._endofstorage);
            return *this;
        }
    private:
        iterator _start;         // 数据起始位置
        iterator _finish;        // 有效数据末尾
        iterator _endofstorage;  // 容量末尾
    };
}

三.声明

在c++11当中提供了多种简化声明的方式,尤其是在使用模板时候。

1.auto

C++98 里的auto是存储类型说明符,用来标记局部变量为自动存储。但局部作用域定义的变量默认就是自动存储,所以这个关键字在 C++98 里几乎没有实际用处。

**C++11 废弃了auto旧的语义,把它改成自动类型推断关键字。**使用 auto 定义变量时,必须显式给出初始化值,编译器会根据初始化表达式,自动推导变量的实际类型。

代码使用如下:

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
using namespace  std;
#include<vector>
#include<list>
#include<map>
int main()
{
    int i = 10;
    auto p = &i;
    auto pf = strcpy;

    cout << typeid(p).name() << endl;
    cout << typeid(pf).name() << endl;

    map<string, string> dict = { {"sort", "排序"}, {"insert", "插入"} };
    //map<string, string>::iterator it = dict.begin();
    auto it = dict.begin();

    return 0;
}

解析:

  • int * __ptr64auto p = &i;,p推导出来类型是int*,整型指针。
  • char * __ptr64 (__cdecl*)(char * __ptr64,char const * __ptr64)auto pf = strcpy;,pf推导成strcpy 的函数指针类型。

strcpy 函数原型:char* strcpy(char* dest, const char* src);,pf就是这个函数类型的指针。

  • auto it = dict.begin(); 不用写很长的 map<string,string>::iterator,auto 自动推导为 map 迭代器类型。

2.decltype

decltype可以获取表达式的类型,用它来定义变量时,变量的类型就等于该表达式的类型。 和auto不一样:decltype是拿表达式本身推导类型不需要变量做初始化赋值

cpp 复制代码
#include<iostream>
using namespace  std;
// decltype的一些使用使用场景
template<class T1, class T2>
void F(T1 t1, T2 t2)
{
    decltype(t1 * t2) ret;
    cout << typeid(ret).name() << endl;
}

int main()
{
    const int x = 1;
    double y = 2.2;

    decltype(x * y) ret; // ret的类型是double
    decltype(&x) p;      // p的类型是int*
    cout << typeid(ret).name() << endl;
    cout << typeid(p).name() << endl;

    F(1, 'a');

    return 0;
}

3.nullptr

C++ 里传统的NULL本质就是字面量 0,它既可以当作整型常量,也可以当作空指针使用。一个 0 值同时具备两种含义,函数重载的时候就容易出现调用歧义,引发意料之外的 bug。

为了解决这个问题,C++11 新增nullptr,专门用来表示空指针,它拥有独立的空指针类型,不会和整数 0 混淆。

cpp 复制代码
#ifndef NULL
#ifdef __cplusplus
#define NULL   0
#else
#define NULL   ((void *)0)
#endif
#endif

四.范围 for 循环(C++11)

普通 for 循环需要程序员手动控制循环起始、结束边界,手写边界很容易出错。C++11 引入范围 for 循环,用来简化容器、数组的遍历。

cpp 复制代码
void TestFor()
{
    int array[] = {1, 2, 3, 4, 5};

    // 传统下标遍历
    for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
    {
        array[i] *= 2;
    }

    // 范围for:依次把集合里每个元素赋值给e,自动判断起止
    for(auto e : array)
    {
        cout << e << " ";
    }
    return;
}

和普通 for 循环一样,范围 for 同样支持continue结束本次循环,break跳出整个循环。


范围 for 的使用条件

范围 for 本质是编译器在底层把它转换成普通迭代代码,和传统遍历逻辑等价,但有使用限制:

  • 迭代范围必须可以确定

原生数组:编译器要能拿到数组首元素与末尾的范围。数组传参到函数时会退化成指针,丢失数组大小信息,不能使用范围 for

cpp 复制代码
//错误写法,array已经退化为指针,无法确定范围
void TestFor(int array[])
{
    for(auto& e : array)
    {
        cout << e << endl;
    }
}

自定义类型:类必须提供begin()和end()成员方法,返回迭代器,以此确定循环的起止区间。

  • 迭代器必须支持++自增、!=不等比较运算,才能完成遍历。

补充小细节:

如果要修改容器内元素,要用auto& e : 集合引用接收;

只做读取,推荐const auto& e : 集合,避免不必要拷贝。


五.STL 中的一些变化

1.新容器

C++11 新增了一批 STL 容器,其中日常写代码最实用的是 unordered_map、unordered_set,哈希实现,查找效率很高。剩下几个简单了解概念就行,工作学习里用得不多。

  • unordered_set:哈希集合,元素去重,无序,底层哈希表,查找平均 O (1)。
  • unordered_map:哈希键值对,key 不重复,无序,底层哈希表,查找平均 O (1)。

对比老容器:set/map底层是红黑树,元素自动排序,查找是 O (logN);不需要有序优先选 unordered 系列,速度更快。


其余 C++11 新增容器了解即可:

  • forward_list:单向链表,只支持向后遍历,内存开销比 list 更小,不能反向迭代。
  • array:封装的静态数组,大小编译期固定,比原生数组多了迭代器、size 等接口,不能扩容。

2.容器中的一些新方法

翻看 STL 容器接口可以看到,C++11 给大部分容器都新增了不少成员方法,不过很多日常开发用到的机会不多,属于锦上添花的特性。比如新增的cbegin()、cend(),专门返回const迭代器,只能读元素,不能修改。 但其实容器本身的begin()、end(),在 const 对象上调用时,本来就会自动返回 const 迭代器,所以cbegin/cend实际使用场景比较有限。

真正写代码里会高频用到的几个 C++11 容器新接口:

  • emplace / emplace_back push_back是先构造对象,再拷贝 / 移动存入容器;emplace_back直接在容器内存空间原地构造对象,省去拷贝,效率更高。emplace对应任意位置原地构造。
cpp 复制代码
vector<pair<string,int>> v;
v.emplace_back("aaa",1); //直接原地构造pair,不用手动传pair对象
  • 移动语义相关:move赋值、移动构造 容器支持移动构造、移动赋值,对象资源直接转移,不用大批量拷贝数据。
  • shrink_to_fit() vector、deque 可用,把多余容量释放掉,让容量缩小到和有效元素个数一致,回收空闲内存

实际上 C++11 更新后,容器中增加的新方法最后用的插入接口函数的右值引用版本:

https://cplusplus.com/reference/vector/vector/emplace_back/

https://cplusplus.com/reference/vector/vector/push_back/

https://cplusplus.com/reference/map/map/insert/

https://cplusplus.com/reference/map/map/emplace/

这些接口是如何提高效率的?

那咱们请看下面的右值引用移动语义 的讲解。同时emplace 还会涉及模板的可变参数,也需要我们再继续深入学习后面章节的知识。


六.右值引用和移动语义

1.左值引用和右值引用

什么是左值?什么是左值引用?

左值,简单说就是有地址、能取址的表达式。比如变量名、解引用的指针,这些都可以用 & 取到地址,也能放在赋值号左边。不过要注意,用const 修饰的左值虽然不能赋值,但地址依然能取,所以它还是左值。

我们之前学的 int &a = b; 这种引用,就是左值引用,本质上是给左值起个别名。

C++11 引入的右值引用(int &&a = 10;)是另一个独立的概念,用来绑定临时对象、支持移动语义,和左值引用不是一回事。在之前C++学的引用,现在确实统称为"左值引用"。右值引用是新增的工具,功能上更侧重资源转移,而不是简单的别名。

cpp 复制代码
int main()
{
    // 以下的a、b、c、*p都是左值
    int* a = new int(0);
    int b = 1;
    const int c = 2;
 
    // 以下几个是对上面左值的左值引用
    int*& rp = a;
    int& rb = b;
    const int& rc = c;
    int& pvalue = *p;
 
    return 0;
}

什么是右值,什么是右值引用?

右值 这个东西,你就当它是**"用完就扔"** 的临时货。比如你写的 10、a + b 这种表达式的结果、还有函数返回的临时值前提是函数不是返回左值引用),这些都是右值。

它和左值最大的区别就两点:

  • 不能取地址,你写 &10 编译器直接报错
  • 不能放等号左边,10 = a;

右值引用就是给这种临时货起个名儿,写法是 int &&r = 10;。以前你碰到 10 这种字面量,想引用都引用不了(int &r = 10; 是错的),现在有了右值引用,就能绑定了。它的主要用途不是让你给常量起名玩,而是用来偷临时对象的资源,省掉不必要的拷贝,这也就是所谓的**"移动语义"**。

所以你以前学的 & 是左值引用,只能绑左值;现在新出的 && 是右值引用,专门绑右值。一个盯"长住的",一个盯"临时的",各管各的。

cpp 复制代码
int main()
{
	double x = 1.1, y  = 2.2;

	//以下是常见的右值

	10;
	x + y;
	fmin(x + y);

	//以下是对右值的右值引用

	int&& rr1 = 10;
	double rr2 = x + y;
	double&& rr3 = fmin(x + y);

	// 这里编译会报错:error C2106: "=": 左操作数必须为左值
	10 = 1;
	x + y = 1;
	fmin(x, y) = 1;
}

2.左值引用与右值引用比较

左值引用默认只能绑左值,比如 int &r = a; 没问题,但 int &r = 10; 直接报错。

不过有个例外:加了 const 的左值引用就比较通融,左值右值都能绑 。比如 const int &r = 10; 这样写是合法的,编译器会给你临时生成一个变量来保住这个右值的命

所以你可以这么记:普通的左值引用挺挑食,只吃左值;const 左值引用不挑食,左右通吃。

cpp 复制代码
int main()
{
	// 右值引用只能右值,不能引用左值。
	int&& r1 = 10;
	// error C2440: "初始化": 无法从"int"转换为"int &&"
   //所以 : 无法将左值绑定到右值引用
	int a = 10;
	int&& r2 = a;
	// 右值引用可以引用move以后的左值
	int&& r3 = std::move(a);
	return 0;
}

我们看到这里有个std::move 到底干了啥?

它不是真的字面意思"移动"什么东西,就是做个类型转换 ,把左值 a 强行转成右值类型,骗过编译器让它觉得 a 是个右值,这样右值引用 r3 就能绑上去了。至于数据有没有真被移走,那是后面代码的事,move 就好像"伪装"这个变量a一样。

注意哈:就是 move 的全名别忘了加 std::,还得包含 <utility> 头文件。


3.右值引用使用场景和意义

前面讲了,const 左值引用左右通吃,那问题来了:C++11 为啥还要多此一举搞个右值引用?是不是吃饱了撑的?

其实不是。const 左值引用虽然能绑右值,但它有个硬伤:只读。你绑上右值之后只能看不能改,这就限制了很多操作场景。

cpp 复制代码
namespace hjq
{
    class string
    {
    public:
        typedef char* iterator;
        iterator begin() 
        {
            return _str;
        }
 
        iterator end()
        {
            return _str + _size;
        }
 
        string(const char* str = "")
            :_size(strlen(str))
            , _capacity(_size)
        {
            //cout << "string(char* str)" << endl;
            _str = new char[_capacity + 1];
            strcpy(_str, str);
        }
 
        // s1.swap(s2)
        void swap(string& s)
        {
            ::swap(_str, s._str);
            ::swap(_size, s._size);
            ::swap(_capacity, s._capacity);
        }
 
        //拷贝构造(左值引用版本)
        // 参数是 const 左值引用,能绑左值也能绑右值
        // 但不管绑什么,都是老老实实做深拷贝,开销大
        // 场景:用已有对象拷贝一个新对象
        string(const string& s)
            :_str(nullptr)
        {
            cout << "string(const string& s) -- 深拷贝" << endl;
            string tmp(s._str);      // 用 s 的字符串构造一个临时对象
            swap(tmp);               // 把临时对象的资源换过来,tmp 带旧资源析构
        }
 
        //  拷贝赋值(左值引用版本)
        // 参数传值,实参传进来时要么拷贝构造要么移动构造
        // 然后和当前对象交换资源
        string& operator=(const string& s)
        {
            cout << "string& operator=(string s) -- 深拷贝" << endl;
            string tmp(s);           // 拷贝构造一个临时对象
            swap(tmp);               // 交换资源,tmp 带旧资源析构
            return *this;
        }
 
        //移动构造(右值引用版本)
        // 参数是右值引用,专门绑临时对象或者 std::move 过来的对象
        // 不深拷贝,直接跟临时对象交换资源,把它的资源"偷"过来
        // 场景:函数返回临时对象、用 std::move 强制转移资源
        string(string&& s)           // s 是右值引用,绑定的就是快死的临时对象
            :_str(nullptr)           // 先把自己置空,方便 swap
            ,_size(0)
            ,_capacity(0)
        {
            cout << "string(string&& s) -- 移动语义" << endl;
            swap(s);                 // 和临时对象交换资源:this 拿到临时对象的数据,临时对象拿到 this 的空指针
            // 函数结束 s(临时对象)析构,但它现在只拿着空指针,啥也没释放,资源已经被 this 偷走了
        }
 
        // 移动赋值(右值引用版本)
        // 和移动构造同理,只不过是用在赋值场景
        // 场景:把临时对象或 std::move 的对象赋值给一个已有对象
        string& operator=(string&& s)
        {
            cout << "string& operator=(string&& s) -- 移动语义" << endl;
            swap(s);                 // 直接交换资源,this 拿到 s 的资源,s 拿到 this 的旧资源
            return *this;            // s 是右值引用,马上要销毁,它的旧资源(也就是 this 原来的资源)跟着 s 一起析构释放
        }
 
        ~string()
        {
            delete[] _str;
            _str = nullptr;
        }
 
        char& operator[](size_t pos)
        {
            assert(pos < _size);
            return _str[pos];
        }
 
        void reserve(size_t n)
        {
            if (n > _capacity)
            {
                char* tmp = new char[n + 1];
                strcpy(tmp, _str);
                delete[] _str;
                _str = tmp;
                _capacity = n;
            }
        }
 
        void push_back(char ch)
        {
            if (_size >= _capacity)
            {
                size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;
                reserve(newcapacity);
            }
            _str[_size] = ch;
            ++_size;
            _str[_size] = '\0';
        }
 
        string& operator+=(char ch)
        {
            push_back(ch);
            return *this;
        }
 
        const char* c_str() const
        {
            return _str;
        }
    private:
        char* _str;
        size_t _size;
        size_t _capacity; // 不包含最后做标识的\0
    };
}

左值引用的使用场景

做参数和做返回值都可以提高效率。

cpp 复制代码
void func1(hjq::string s)      // 传值:实参拷进来,有拷贝开销
{}

void func2(const hjq::string& s) // 传引用:不拷贝,直接绑
{}

int main()
{
    hjq::string s1("hello world");

    // func1传值,s1得拷一份给s,有深拷贝
    func1(s1);
    // func2传const左值引用,s直接绑s1,零拷贝
    func2(s1);


    // string operator+=(char ch) 传值返回存在深拷贝
    // string& operator+=(char ch) 传左值引用没有拷贝提高了效率
    // 之前 += 要是写成传值返回,每次返回都得拷一份临时对象
    // 现在写成传引用返回,返回的就是s1本身,没有拷贝
    s1 += '!';

    return 0;
}
  • 函数参数用左值引用尤其是 const 引用),能省掉传值时的一次拷贝

  • 函数返回值用左值引用,能省掉返回时临时对象的拷贝

所以左值引用在这两种场景下确实能提效。不过右值引用提效的场景不一样------它是在临时对象或者转移资源的时候发挥作用,比如移动构造和移动赋值


左值引用的短板:

当函数要返回一个局部对象时,这个对象出了函数作用域就被销毁了,所以没法用左值引用返回它------引用绑定的对象都死了,用不得。这时候只能老老实实传值返回。

比如 hjq::string to_string(int value) 这种函数,内部构造的字符串对象是局部变量,只能传值返回。而传值返回就避不开拷贝构造,至少一次,旧一点的编译器甚至可能拷两次,性能损失在所难免。这就是左值引用搞不定的场景,也是右值引用要出场的地方。右值引用能偷走临时对象的资源,把这次拷贝省掉,补上左值引用这个短板

来看代码:

cpp 复制代码
hjq::string to_string(int value)
{
    hjq::string str;
    // 这里把 value 转成字符串存到 str 里,具体代码省略
    return str;
}

int main()
{
    hjq::string ret = to_string(123);
    return 0;
}

这个函数返回了一个局部对象 str。局部变量嘛,函数一结束它就析构了,所以没法用左值引用返回它------引用绑一个将死之物,那是悬垂引用,写了就是 bug。

那咋整?只能传值返回。

传值返回就意味着要拷一份出去。按最原始的理论,这里至少要发生一次拷贝构造(严格来说是两次,但现在的编译器一般会做返回值优化,优化成一次)。

这倒不算啥大问题,毕竟编译器帮我们兜了底。但问题是,这次拷贝能不能也省掉?

答案是能,用右值引用。


cpp 复制代码
namespace hjq
{
    // 拷贝构造 ------ 左值引用版本,走深拷贝
    string(const string& s)
        : _str(nullptr)
    {
        cout << "string(const string& s) -- 深拷贝" << endl;
        string tmp(s._str);
        swap(tmp);
    }

    // 整数转字符串
    hjq::string to_string(int value)
    {
        bool flag = true;
        if (value < 0)
        {
            flag = false;
            value = 0 - value;
        }

        hjq::string str;          // 局部对象
        while (value > 0)
        {
            int x = value % 10;
            value /= 10;
            str += ('0' + x);
        }

        if (flag == false)
        {
            str += '-';
        }
        std::reverse(str.begin(), str.end());
        return str;               // 传值返回局部对象
    }
}

int main()
{
    hjq::string ret1 = hjq::to_string(1234);
    hjq::string ret2 = hjq::to_string(-1234);

    return 0;
}

这段代码想说明什么?

to_string 函数里定义了一个局部对象 str,它出了函数就销毁,所以没法用左值引用返回它,只能传值返回。

return str; 返回的是一个临时对象 ,也就是右值 。ret1 和 ret2 用这个右值来构造,如果没有移动构造,编译器会去匹配拷贝构造(因为 const 左值引用能绑右值 ),于是就走到了上面那个拷贝构造,发生一次深拷贝。

短板在哪儿?

str 马上就要销毁了,它手里的堆空间反正也没人要,按理说直接"偷"过来给 ret1 用就行了,省一次深拷贝。但左值引用干不了这事,它只能老老实实拷贝。这就是左值引用处理不了"将亡对象资源转移"的短板。


右值引用和移动语义解决上述问题

上面那个 to_string 返回局部对象的问题,用右值引用就能解决。

具体做法:在 hjq::string 里增加一个移动构造。移动构造说白了就是偷------把右值临时对象的资源直接"抢"过来占为己有,省掉深拷贝的开销。所以叫"移动构造",本质就是窃取别人的资源。

cpp 复制代码
// 移动构造
string(string&& s)
    : _str(nullptr)
    , _size(0)
    , _capacity(0)
{
    cout << "string(string&& s) -- 移动语义" << endl;
    swap(s);   // 把自己的空指针和临时对象的资源交换,资源就到手了
}

加上移动构造之后,再跑一遍 hjq::to_string 的调用:

cpp 复制代码
int main()
{
    hjq::string ret2 = hjq::to_string(-1234);
    return 0;
}

这回就不走深拷贝的拷贝构造了,而是匹配到移动构造。移动构造里没开新空间、没拷贝数据,只是交换了几个指针,效率一下就上去了。


在 hjq::string 类中增加移动赋值函数,再去调用 xyl::to_string(1234),不过这次是将 hjq::to_string(1234) 返回的右值对象赋值给 ret1 对象,这时调用的是移动构造。

cpp 复制代码
// 移动赋值 ------ 右值引用版本
string& operator=(string&& s)
{
    cout << "string& operator=(string&& s) -- 移动语义" << endl;
    swap(s);        // 直接偷资源,不拷贝
    return *this;
}


int main()
{
    hjq::string ret1;                 // 先构造一个空对象
    ret1 = hjq::to_string(1234);      // 把临时对象赋值给 ret1
    return 0;
}


// 运行结果:
// string(string&& s) -- 移动语义
// string& operator=(string&& s) -- 移动语义

为什么调了两次移动?

因为 ret1 是已经存在的对象,编译器没办法做返回值优化(RVO)。所以过程是:

to_string 里的局部对象 str 先被编译器识别成右值,触发移动构造,生成一个临时对象。

这个临时对象赋值给 ret1,触发移动赋值。两步都是移动语义,没有发生深拷贝。


STL 中的容器都是增加了移动构造和移动赋值(比如下面的): .

https://cplusplus.com/reference/string/string/string/

https://cplusplus.com/reference/vector/vector/vector/


4.右值引用引用左值及其一些更深入的使用场景分析

按照语法规则,右值引用只能绑定右值,那是不是右值引用就绝对不能关联左值?

有些场景我们希望对左值对象触发移动语义,这时就可以使用std::move,把左值强制转换成右值,从而可以被右值引用绑定。

C++11 中std::move()定义在<utility>头文件。这个函数名字很容易让人误解:它本身不会移动、搬运任何数据,仅仅做类型转换,将左值强制转为右值引用类型,以此触发移动构造或者移动赋值,实现移动语义。

cpp 复制代码
// std::move 的底层实现(简化版)
template<class _Ty>
inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT
{
    // 功能:把传入的参数强转成右值引用类型
    // 注意:它啥也没搬,只是做了个类型转换
    return ((typename remove_reference<_Ty>::type&&)_Arg);
}

看不懂上面的模板语法没关系,记住 std::move 的本质就行:它就是给左值披上右值的外衣,让编译器以为它是右值,从而去匹配移动构造或移动赋值


cpp 复制代码
int main()
{
    hjq::string s1("hello world");

    // s1 是左值,匹配拷贝构造 → 深拷贝,s2 独立拥有一份数据
    hjq::string s2(s1);

    // std::move(s1) 把 s1 强转成右值,匹配移动构造 → 资源被偷走
    // s3 拿到了 s1 的堆空间,s1 被置空
    hjq::string s3(std::move(s1));

    // 注意:走到这里 s1 已经空了,不能再拿来用
    // 除非重新给 s1 赋值,比如 s1 = "new string";

    return 0;
}

s1 本身是左值,正常情况下只能匹配拷贝构造 。但std::move(s1) 把它"伪装"成右值,于是匹配到了移动构造,s1 的资源被 s3 偷走

不过要小心: 这种写法一般不建议随便用,因为 std::move(s1) 之后,s1 的资源已经被转移了,它变成了空壳,再访问它就可能出问题。除非你明确知道自己在干啥,或者之后重新给 s1 赋值。

STL容器插入接口函数也增加了右值引用版本:

https://cplusplus.com/reference/list/list/push_back/

https://cplusplus.com/reference/vector/vector/push_back/

cpp 复制代码
#include <utility>   // std::move
#include <list>      // std::list
#include <iostream>  // std::cout
using namespace std;

namespace hjq
{
    class string
    {
    public:
        // 构造函数
        string(const char* str = "")
            : _size(strlen(str))
            , _capacity(_size)
        {
            _str = new char[_capacity + 1];
            strcpy(_str, str);
        }

        // 拷贝构造 ------ 打印日志
        string(const string& s)
            : _str(nullptr)
        {
            cout << "string(const string& s) -- 深拷贝" << endl;
            string tmp(s._str);
            swap(tmp);
        }

        // 移动构造 ------ 打印日志
        string(string&& s)
            : _str(nullptr)
            , _size(0)
            , _capacity(0)
        {
            cout << "string(string&& s) -- 移动语义" << endl;
            swap(s);
        }

        void swap(string& s)
        {
            ::swap(_str, s._str);
            ::swap(_size, s._size);
            ::swap(_capacity, s._capacity);
        }

        ~string()
        {
            delete[] _str;
            _str = nullptr;
        }

    private:
        char* _str;
        size_t _size;
        size_t _capacity;
    };
}

int main()
{
    std::list<hjq::string> v;        // 存放 string 的链表
    hjq::string s1("1111");          // s1 是左值

    v.push_back(s1);                 // 传左值 --> 拷贝构造
    v.push_back("2222");             // 传右值(字面量) --> 移动构造
    v.push_back(std::move(s1));      // 传右值(move后的左值) --> 移动构造

    return 0;
}

运行结果:

  • v.push_back(s1) s1 是左值 push_back(const value_type& val) 拷贝构造 string(const string& s) -- 深拷贝
  • v.push_back("2222") 字面量是右值 push_back(value_type&& val) 移动构造 string(string&& s) -- 移动语义
  • v.push_back(std::move(s1)) move后是右值 push_back(value_type&& val) 移动构造 string(string&& s) -- 移动语义

简单小结一下就是:push_back 有两个重载版本------左值引用版本走拷贝构造,右值引用版本走移动构造。传左值就老老实实拷贝一份,传右值就直接把资源偷过来,省掉深拷贝的开销。


5.完美转发

【模板中的 && ------ 万能引用】

模板里出现 T&&,别一看是 && 就以为是右值引用。它有个专门的名字叫万能引用(也叫转发引用),这东西左右通吃------既能绑左值,也能绑右值。

cpp 复制代码
template<typename T>
void func(T&& arg);   // arg 是万能引用,左值右值都能接

不过有个坑:万能引用 虽然接收的时候能区分左右值但一旦进了函数体,arg 本身就成了一个左值因为它有名字了,能取地址)。这就导致如果你想把它继续传给下一个函数,它的左右值属性就丢了。比如你写了个转发函数,想把参数原封不动地传给另一个函数,结果传过去的一律成了左值,移动语义全白瞎了。

怎么办?

不用怕:C++11 提供了 std::forward(完美转发),它能保证参数在传递过程中保持原来的左右值属性------左值还是左值,右值还是右值。

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }

void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }

template<typename T>
void PerfectForward(T&& t)
{
    Fun(t);
}

int main()
{
    PerfectForward(10);           // 左值引用

    int a;
    PerfectForward(a);            // 左值引用
    PerfectForward(std::move(a)); // 左值引用

    const int b = 8;
    PerfectForward(b);            // const 左值引用
    PerfectForward(std::move(b)); // const 左值引用

    return 0;
}

std::forward 完美转发在传参的过程中保留对象原生类型属性。

cpp 复制代码
void Fun(int& x) { cout << "左值引用" << endl; }
void Fun(const int& x) { cout << "const 左值引用" << endl; }

void Fun(int&& x) { cout << "右值引用" << endl; }
void Fun(const int&& x) { cout << "const 右值引用" << endl; }
// std::forward<T>(t)在传参的过程中保持了t的原生类型属性。
template<typename T>
void PerfectForward(T&& t)
{
    Fun(std::forward<T>(t));
}

int main()
{
    PerfectForward(10);           // 右值引用

    int a;
    PerfectForward(a);            // 左值引用
    PerfectForward(std::move(a)); // 右值引用

    const int b = 8;
    PerfectForward(b);            // const 左值引用
    PerfectForward(std::move(b)); // const 右值引用

    return 0;
}

我们看完美转发在实际中的使用场景(实现双向循环链表):

cpp 复制代码
#include <list>      
#include <iostream>  
using namespace std;

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

template<class T>
struct ListNode
{
    ListNode* _next = nullptr;
    ListNode* _prev = nullptr;
    T _data;
};

template<class T>
class List
{
    typedef ListNode<T> Node;
public:
    List()
    {
        _head = new Node;
        _head->_next = _head;
        _head->_prev = _head;
    }

    template<typename U>
    void PushBack(U&& x)
    {
        Insert(_head, std::forward<U>(x));
    }

    template<typename U>
    void PushFront(U&& x)
    {
        Insert(_head->_next, std::forward<U>(x));
    }

    template<typename U>
    void Insert(Node* pos, U&& x)
    {
        Node* prev = pos->_prev;
        Node* newnode = new Node;
        newnode->_data = std::forward<U>(x);

        prev->_next = newnode;
        newnode->_prev = prev;
        newnode->_next = pos;
        pos->_prev = newnode;
    }

    void Insert(Node* pos, const T& x)
    {
        Node* prev = pos->_prev;
        Node* newnode = new Node;
        newnode->_data = x;

        prev->_next = newnode;
        newnode->_prev = prev;
        newnode->_next = pos;
        pos->_prev = newnode;
    }

private:
    Node* _head;
};

int main()
{
    List<string> l;
    string s1("hello");

    l.PushBack(s1);          // 左值 --> 拷贝
    l.PushBack("1111");      // 右值 --> 移动
    l.PushFront("2222");     // 右值 --> 移动

    return 0;
}

七.新的类功能

默认成员函数

C++98 的类中一共有 6 个默认成员函数,不手动写的话,编译器会自动生成:

  1. 构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值重载
  5. 取地址重载
  6. const 取地址重载

实际开发里重点关注前 4 个,后两个几乎很少用到。

C++11 又新增两个默认成员函数:移动构造函数移动赋值运算符重载

关于移动构造与移动赋值,有几条关键规则:

  • 如果我们没有手写移动构造,同时也没有手写析构、拷贝构造、拷贝赋值当中的任意一个,编译器才会自动生成默认移动构造。 默认移动构造做逐成员处理:内置类型成员直接拷贝;自定义类型成员,如果该类实现了移动构造就调用移动构造,没有实现就退回去调用拷贝构造。
  • 如果我们没有手写移动赋值重载,同时也没有手写析构、拷贝构造、拷贝赋值当中的任意一个,编译器才会自动生成默认移动赋值。 默认移动赋值逻辑和默认移动构造大体一致:内置类型逐成员拷贝;自定义类型成员优先调用自身移动赋值,没有移动赋值就调用拷贝赋值。
  • 只要我们自己实现了移动构造或者移动赋值其中任意一个,编译器就不会再自动生成拷贝构造和拷贝赋值。

来看代码:

cpp 复制代码
#include <utility>
#include <iostream>
#include <string>


class Person
{
public:
    Person(const char* name = "", int age = 0)
        :_name(name)
        , _age(age)
    {}

    // 放开下面任意一个,编译器就不会自动生成移动构造/移动赋值
    /* Person(const Person& p)
        :_name(p._name)
        ,_age(p._age)
    {} */
    
    /* Person& operator=(const Person& p)
    {
        if(this != &p)
        {
            _name = p._name;
            _age = p._age;
        }
        return *this;
    }*/
 
    /*~Person()
    {}*/
 
private:
    std::string _name;
    int _age;
};
 
int main()
{
    Person s1;
    Person s2 = s1;                // s1是左值,调用拷贝构造
    Person s3 = std::move(s1);     // move转右值,触发编译器默认生成的移动构造
    Person s4;
    s4 = std::move(s2);            // move转右值,触发编译器默认生成的移动赋值
 
    return 0;
}
  • Person 类,没有手写析构、拷贝构造、拷贝赋值,编译器自动生成移动构造和移动赋值。 std::move把左值转为右值,触发默认移动函数;
  • 一旦我们手动实现析构、拷贝构造、拷贝赋值任意一个,编译器就不再生成移动构造和移动赋值,移动场景会降级走拷贝。
  • VS2013 编译器对该特性支持不全,需要 VS2019 及以上运行观察效果。

类成员变量初始化

C++11 支持在类定义内部直接给成员变量设置默认初始值,编译器自动生成的默认构造函数,会使用这些缺省值完成成员初始化。这部分在类和对象章节已经讲过,这里不展开细说。

强制生成默认函数关键字 default

C++11 提供default关键字,可以手动控制编译器生成默认成员函数。 有些情况下编译器不会自动生成默认函数,比如:手写了拷贝构造之后,编译器就不再生成移动构造。这时如果仍然想要编译器生成默认版本的移动构造,就可以用default显式指定,强制编译器生成。

来看一个实际例子:

cpp 复制代码
#include <utility>
#include <string>

class Person
{
public:
    Person(const char* name = "", int age = 0)
        :_name(name)
        , _age(age)
    {}
 
    // 手写拷贝构造,按照规则编译器原本不会自动生成移动构造
    Person(const Person& p)
        :_name(p._name)
        ,_age(p._age)
    {}
 
    // 显式default,强制编译器生成默认版本的移动构造
    Person(Person&& p) = default;
 
private:
    std::string _name;
    int _age;
};
 
int main()
{
    Person s1;
    Person s2 = s1;        // s1是左值,调用手写的拷贝构造
    Person s3 = std::move(s1); // std::move把s1转为右值,调用default生成的默认移动构造
 
    return 0;
}

禁止生成默认函数的关键字 delete

如果想要限制某些默认函数的生成,C++98 的做法是:把该函数声明为private,并且只声明不实现。外部一旦尝试调用,就会编译报错。

到 C++11 写法更加简洁,直接在函数声明后面加上=delete;即可。告诉编译器不要生成该函数的默认版本,被=delete修饰的函数叫做删除函数。

cpp 复制代码
#include <utility>
#include <string>

class Person
{
public:
    Person(const char* name = "", int age = 0)
        :_name(name)
        , _age(age)
    {}
 
    // =delete:删除拷贝构造函数
    Person(const Person& p) = delete;
 
private:
    std::string _name;
    int _age;
};
 
int main()
{
    Person s1;
    // Person s2 = s1;   // 编译报错!拷贝构造已经被delete,不能拷贝对象
    // Person s3 = std::move(s1); // 编译报错!
 
    return 0;
}

接下来就到了继承和多态中的 final 与 override 关键字

这个我们在继承和多态章节已经进行了详细讲解这里就不再细讲了,如果需要的话去复习继承和多态章节,那里有你有你想要的答案哈。


八.可变参数模板

C++11 新增可变参数模板支持编写可以接收任意数量模板参数的函数模板和类模板 。 在 C++98/03 时代,函数模板、类模板的参数个数必须写死,只能接收固定数量参数;可变模板参数算是一个很大的升级。但可变参数模板语法比较抽象,写代码有固定套路,理解起来挺困难的。日常学习工作,掌握基础用法即可,有余力再深入学习。

cpp 复制代码
// Args是模板参数包,args是函数参数包,可以接收任意个任意类型参数
template<class ...Args>
void Show(Args&&... args)
{

}
  • class ...Args:模板参数包,代表 0 个或多个不同类型的模板参数。
  • Args&&... args:函数参数包,对应模板参数包,接收 0 个或多个函数实参。
  • ... 省略号不是随便写,跟在参数包后面,代表参数包的展开。

注意: 可变参数包不能直接遍历,C++11 没有直接获取参数包长度、按下标取参数的语法。一般靠递归拆解参数包,逐个取出参数

1.递归函数方式展开参数包

cpp 复制代码
// 递归终止函数
template <class T>
void ShowList(const T& t)
{
    cout << t << endl;
}
 
// 展开函数
template <class T, class ...Args>
void ShowList(T value, Args... args)
{
    cout << value <<" ";
    ShowList(args...);
}
 
int main()
{
    ShowList(1);
    ShowList(1, 'A');
    ShowList(1, 'A', std::string("sort"));
 
    return 0;
}

2.逗号表达式展开参数包

还有一种参数包展开思路,不用写递归终止函数,直接在expand函数内部就地展开。这里的printarg并不是递归终止函数,它就负责单独处理每一个参数,整个技巧的核心靠逗号表达式实现。

逗号表达式的执行规则是先跑逗号左边的代码,再返回逗号右边的值。 拿(printarg(args), 0)来说,会先调用printarg(args)处理参数,整个表达式最终得到结果 0。

代码里还搭配了 C++11 的初始化列表,用来创建一个临时 int 数组: {(printarg(args), 0)...},参数包展开之后就变成: ((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0) ...)

最后得到一个全部元素都是 0 的数组,数组长度由sizeof...(Args)拿到的参数个数决定。 在数组初始化构造的时候,逗号表达式前面的printarg(args)就会挨个执行,把参数包里所有参数处理完。这个 int 数组本身没有业务用处,纯粹是借数组初始化这个语法特性,把参数包给展开。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

template <class T>
void PrintArg(T t)
{
    cout << t << " ";
}

//展开函数
template <class ...Args>
void ShowList(Args... args)
{
    // 逗号表达式+初始化列表展开参数包
    int arr[] = { (PrintArg(args), 0)... };
    cout << endl;
}

int main()
{
    ShowList(1);
    ShowList(1, 'A');
    ShowList(1, 'A', std::string("sort"));

    return 0;
}

STL 容器中的 empalce 相关接口函数:

https://cplusplus.com/reference/vector/vector/emplace_back/

cpp 复制代码
template <class... Args>
void emplace_back (Args&&... args);

emplace 系列接口

emplace系列接口支持模板可变参数以及万能引用。那对比insert、push_back,emplace/emplace_back优势到底在哪?

cpp 复制代码
#include <iostream>
#include <list>
#include <utility>
#include <string>
using namespace std;

int main()
{
    list<pair<int, char>> mylist;

    // emplace_back:接收构造对象所需参数,直接在容器内存原地构造pair对象
    mylist.emplace_back(10, 'a');
    mylist.emplace_back(20, 'b');

    // 也可以传已经构造好的pair对象,这时用法和push_back差别不大
    mylist.emplace_back(make_pair(30, 'c'));

    // push_back:需要传入一个完整已经构造好的pair对象
    mylist.push_back(make_pair(40, 'd'));
    mylist.push_back({ 50, 'e' });

    for (auto e : mylist)
    {
        cout << e.first << ":" << e.second << endl;
    }

    return 0;
}
cpp 复制代码
int main()
{
    // 使用带有拷贝、移动构造的string来观察区别
    list<pair<int, string>> mylist;

    // emplace_back:传入构造参数,容器内原地直接构造pair,省去拷贝/移动
    mylist.emplace_back(10, "sort");

    // 传入现成pair对象,就会走移动/拷贝,优势体现不出来
    mylist.emplace_back(make_pair(20, "sort"));

    // push_back:必须先外部构造好pair临时对象,再移动进容器
    mylist.push_back(make_pair(30, "sort"));
    mylist.push_back({ 40, "sort" });

    return 0;
}

emplace_back最核心的好处就是原地构造。 直接把构造对象要用的参数丢给它,容器就在自己的内存空间里把对象直接造出来。不用先在外面生成临时对象,再拷贝或者移动到容器内部,省下了拷贝、移动的开销。

但如果你传给emplace_back的本身就是一个已经造好的对象,比如make_pair返回的结果,这时候它的表现和push_back基本没区别,照样会触发移动或者拷贝,体现不出性能优势。

反观push_back,只能接收完整对象。得先把对象在外部构造完毕,再通过移动或者拷贝把对象放进容器里。


九.lambda 表达式

1.举C++98 中的一个例子

C++98 里,如果要对一组数据排序,可以使用std::sort。

cpp 复制代码
#include <algorithm>
#include <functional>
int main()
{
    int array[] = {5,2,9,1,7,3,0,8,4,6};
    // 默认使用小于比较,排序结果为升序
    std::sort(array, array + sizeof(array)/sizeof(array[0]));

    // 如果想要降序,就要传入自定义比较规则
    std::sort(array, array + sizeof(array)/sizeof(array[0]), greater<int>());

    return 0;
}

std::sort前两个参数传指针,排序区间是左闭右开。sizeof(array)/sizeof(array0)用来算出数组元素个数。不给第三个参数,默认用<比较,排升序。 要降序就传仿函数greater<int>(),记得包含<functional>头文件。

C++98 想改比较规则,要么用现成仿函数,要么自己写全局函数。

问题在于:哪怕逻辑很简单,也要额外定义函数,写起来麻烦,lambda 就是为了解决这个麻烦。


如果待排序元素为自定义类型,需要用户定义排序时的比较规则:

cpp 复制代码
#include <vector>
#include <algorithm>
#include <string>
using namespace std;

struct Goods
{
    string _name;  // 商品名称
    double _price; // 商品价格
    int _score;    // 评分

    Goods(const char* str, double price, int score)
        :_name(str)
        , _price(price)
        , _score(score)
    {}
};

// 仿函数:按价格升序
struct ComparePriceLess
{
    bool operator()(const Goods& gl, const Goods& gr)
    {
        return gl._price < gr._price;
    }
};

// 仿函数:按价格降序
struct ComparePriceGreater
{
    bool operator()(const Goods& gl, const Goods& gr)
    {
        return gl._price > gr._price;
    }
};

int main()
{
    // 商品数据:奶茶,价格,用户评分
    vector<Goods> v = { {"珍珠奶茶",12.5,4}, {"果茶",15.2,5}, {"可乐",4.5,3}, {"咖啡",18.0,4} };
    sort(v.begin(), v.end(), ComparePriceLess());   // 按价格从小到大排序
    sort(v.begin(), v.end(), ComparePriceGreater()); // 按价格从大到小排序

    return 0;
}

这里拿奶茶商品做示例,要给商品容器排序。C++98 如果想改排序规则,就得单独写仿函数结构体,重载operator()。

ComparePriceLess仿函数,用<对比价格,实现价格升序。 ComparePriceGreater仿函数,用>对比价格,实现价格降序。

创建仿函数对象传给 sort 的第三个参数,sort 内部就会调用仿函数的operator()做元素对比。

缺点也很实在:明明就很短的比较逻辑,还得额外定义两个结构体,代码分得很散,写着麻烦。C++11 的 lambda 就解决了这个问题,可以直接在 sort 调用的地方就地写比较代码,不用再单独写仿函数。


2.lambda 表达式

随着 C++ 语法的发展,人们开始觉得上面的写法太复杂了,每次为了实现一个 algorithm 算法, 都要重新去写一个类,如果每次比较的逻辑不一样,还要去实现多个类,特别是相同类的命名, 这些都给编程者带来了极大的不便。因此,在 C++11 语法中出现了 Lambda 表达式

cpp 复制代码
#include <vector>
#include <algorithm>
#include <string>
using namespace std;

struct Goods
{
    string _name;      // 商品名
    double _price;     // 价格
    int _evaluate;     // 评价分数

    Goods(const char* str, double price, int evaluate)
        :_name(str)
        , _price(price)
        , _evaluate(evaluate)
    {}
};

int main()
{
    vector<Goods> v = { { "苹果", 2.1, 5 }, { "香蕉", 3, 4 }, { "橙子", 2.2, 3 }, { "菠萝", 1.5, 4 } };

    // lambda:按价格升序
    sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2){
        return g1._price < g2._price; });

    // lambda:按价格降序
    sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2){
        return g1._price > g2._price; });

    // lambda:按评价分数升序
    sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2){
        return g1._evaluate < g2._evaluate; });

    // lambda:按评价分数降序
    sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2){
        return g1._evaluate > g2._evaluate; });

    return 0;
}

上述代码就是使用 C++11 中的 lambda 表达式来解决,可以看出 lambda 表达式实际是一个匿名函数。


3.lambda 表达式语法

lambda 完整书写格式:capture‑list (parameters) mutable -> return‑type { statement }

a.各部分说明

  1. capture‑list 捕捉列表 写在 lambda 最开头,编译器靠\[\]识别这是 lambda。作用是把上下文的变量捕获进来,供 lambda 内部使用。
  2. (parameters) 参数列表 和普通函数参数用法一样。如果不需要参数,可以把括号直接省掉。
  3. mutable lambda默认自带 const 属性,不能修改捕获到的值。加mutable可以取消这个 const 限制。只要用了 mutable,参数括号就不能省略,就算没有参数也要写()。
  4. -> return‑type 返回值类型 追踪返回类型写法。没有返回值可以不写;返回值类型很明确的时候,也可以省略,交给编译器自动推导。{statement} 函数体 里面写业务逻辑。除了传入的参数,也可以使用捕获过来的外部变量。

注意 参数列表、返回值类型是可选捕捉列表和函数体不能丢

所以 C++11 最简单合法的 lambda 就是:\[\]{};,不过这个 lambda 什么都干不了。

来看代码:

cpp 复制代码
#include <iostream>
using namespace std;

int main()
{
    // 最简单的lambda,捕获、函数体都为空,没有实际作用
    []{};

    int a = 3, b = 4;
    // [=] 值捕获:把外部变量按值拷贝进来,返回值int交给编译器推导
    [=]{return a + 3; };

    // [&] 引用捕获,省略返回值,无返回;用auto接收lambda对象
    auto fun1 = [&](int c){b = a + c; };
    fun1(10);
    cout<<a<<" "<<b<<endl;

    // 混合捕获:a值捕获,b引用捕获,显式指定返回值int
    auto fun2 = [=, &b](int c)->int{return b += a+ c; };
    cout<<fun2(10)<<endl;

    // 值捕获x,mutable取消const,允许修改拷贝过来的x副本
    int x = 10;
    auto add_x = [x](int a) mutable { x *= 2; return a + x; };
    cout << add_x(10) << endl;

    return 0;
}

通过上面代码可以看出,lambda 表达式实际上可以理解为无名函数,该函数无法直接调用,如果想要直接调用,可借助 auto 将其赋值给一个变量。


b.捕获列表说明

捕捉列表

捕捉列表决定 lambda 可以拿到哪些外部变量,是拷贝过来用,还是直接拿原变量引用。

  • var:值捕获变量 var
  • =:把父作用域所有变量都值捕获,也包含 this 指针
  • \&var:引用捕获变量 var
  • \&:把父作用域所有变量都引用捕获,也包含 this 指针
  • this:值捕获当前对象的 this 指针

注意

  1. 父作用域,就是包裹这个 lambda 的代码块。
  2. 捕捉列表支持多个捕获项,逗号隔开。 举例子:=, \&a, \&b:a、b 用引用捕获,剩下其他变量全部值捕获。\&, a, this:a 和 this 值捕获,其余变量全部引用捕获。
  3. 同一个变量不能重复捕获,不然直接编译报错。 像=, a,=已经把全部变量值捕获了,再写 a 就重复。
  4. 如果 lambda 写在代码块外面(全局位置),捕获列表必须是空\[\]。
  5. 写在代码块内部的 lambda,只能抓父作用域的局部变量。去抓别的作用域、非局部变量,编译报错。
  6. lambda 之间不能互相赋值,就算看着类型一样也不行。
cpp 复制代码
#include <iostream>
using namespace std;

// 函数指针类型
void (*PF)();
int main()
{
    auto f1 = []{cout << "hello world" << endl; };
    auto f2 = []{cout << "hello world" << endl; };

    // f1 = f2;   // 编译报错,lambda不支持赋值重载operator=()

    // 可以用lambda拷贝构造出新副本
    auto f3(f2);
    f3();

    // 无捕获的lambda可以赋值给对应类型的函数指针
    PF = f2;
    PF();

    return 0;
}

大致解析:

f1 和 f2 两个 lambda,哪怕代码写得一模一样,也是完全不同的类型。所以f1=f2会编译报错,lambda 本身没有赋值运算符。

但拷贝构造是可以的,auto f3(f2)直接复制 f2 生成 f3 副本,正常调用f3()没问题。

重点:只有捕获为空的 lambda,才可以赋值给普通函数指针。只要 lambda 有捕获,就不能往函数指针 PF 上赋值。

**小提示:**编译器会给每一个 lambda 单独生成专属仿函数类,这就是写法相同但类型不一样的原因,学完 lambda 底层原理就好懂了。


4.函数对象与 lambda 表达式

函数对象也叫仿函数,就是类里面重载了operator(),让类的对象可以像函数一样调用。

cpp 复制代码
#include <iostream>
using namespace std;

class Rate
{
public:
    Rate(double rate): _rate(rate)
    {}

    double operator()(double money, int year)
    {
        return money * _rate * year;
    }

private:
    double _rate;
};

int main()
{
    // 使用仿函数
    double rate = 0.49;
    Rate r1(rate);
    r1(10000, 2);

    // lambda实现同样功能
    auto r2 = [=](double money, int year)->double{return money*rate*year; };
    r2(10000, 2);

    return 0;
}

大致解析:

仿函数对象 r1 创建出来之后,直接r1(参数)就能调用,底层实际执行的就是类里的operator()。

lambda 的 r2 完成完全一样的计算。编译器背后会把 lambda 转成仿函数类,所以 lambda 和仿函数本质是一回事。

=把 rate 变量按值捕获进来,接收本金和年限,算出利息。

我们仔细对比来看:仿函数得我们自己手写类;lambda 不用写类,写代码更省事。

实际在底层编译器对于 lambda 表达式的处理方式,完全就是按照函数对象的方式处理的,即:如果定义了一个 lambda 表达式,编译器会自动生成一个类,在该类中重载了operator()。


十.包装器

1.function 包装器

function 包装器也叫适配器,C++ 里function本质是一个类模板,用来包装各类可调用对象

为什么要用 function?

ret = func(x); 这里的func可以是普通函数、函数指针、仿函数、lambda 对象,这些全都属于可调用类型。 如果直接拿它们去传模板参数,每一种不同类型都会实例化出一份全新的模板,会造成模板代码膨胀

cpp 复制代码
#include <iostream>
using namespace std;

template<class F, class T>
T useF(F f, T x)
{
    static int count = 0;
    cout << "count:" << ++count << endl;
    cout << "&count:" << &count << endl;
    return f(x);
}

// 普通函数
double f(double i)
{
    return i / 2;
}

// 仿函数
struct Functor
{
    double operator()(double d)
    {
        return d / 3;
    }
};

int main()
{
    // 传普通函数名
    cout << useF(f, 11.11) << endl;

    // 传仿函数对象
    cout << useF(Functor(), 11.11) << endl;

    // 传lambda表达式
    cout << useF([](double d)->double{ return d/4; }, 11.11) << endl;

    return 0;
}

模板参数F会根据传入可调用对象的类型进行实例化。 这里传了三种不一样的可调用类型:普通函数、仿函数、lambda。 所以模板useF会生成三份不同实例。 函数内部的静态变量count,每一份实例都有属于自己的静态变量,看&count打印出来的地址就能看出来地址不一样。

问题就在这里:功能差不多,因为可调用对象类型不同,就生成多份模板代码,带来代码膨胀。 std::function就是用来解决这个问题,把不同种类可调用对象包装成同一种function类型,模板就只会实例化一份。


通过上面的程序验证,我们会发现 useF 函数模板实例化了三份。包装器function就可以很好的解决上面的问题

cpp 复制代码
// std::function在头文件<functional>
// 类模板原型如下
template <class T> function;     // undefined
 
template <class Ret, class... Args>
class function<Ret(Args...)>;
 
// 模板参数说明:
// Ret: 被调用函数的返回类型
// Args...:被调用函数的形参
cpp 复制代码
// 使用方法如下:
#include <functional>
#include <iostream>
using namespace std;

int f(int a, int b)
{
    return a + b;
}

struct Functor
{
public:
    int operator() (int a, int b)
    {
        return a + b;
    }
};

class Plus
{
public:
    static int plusi(int a, int b)
    {
        return a + b;
    }

    double plusd(double a, double b)
    {
        return a + b;
    }
};

int main()
{
    // 函数名(函数指针)
    std::function<int(int, int)> func1 = f;
    cout << func1(1, 2) << endl;
    
    // 函数对象
    std::function<int(int, int)> func2 = Functor();
    cout << func2(1, 2) << endl;
    
    // lambda表达式
    std::function<int(int, int)> func3 = [](const int a, const int b) 
    {
        return a + b;
    };

    cout << func3(1, 2) << endl;

    // 类的静态成员函数
    std::function<int(int, int)> func4 = &Plus::plusi;
    cout << func4(1, 2) << endl;

    // 类的普通成员函数,需要传入对象实例
    std::function<double(Plus, double, double)> func5 = &Plus::plusd;
    cout << func5(Plus(), 1.1, 2.2) << endl;

    return 0;
}

std::function<返回值(参数列表)>就是包装器的写法,各种可调用对象都能包成同一种 function 类型。

  • func1:包装普通函数或者函数指针
  • func2:包装仿函数对象
  • func3:包装 lambda 表达式
  • func4:包装类静态成员函数,直接拿函数地址就行
  • func5:包装普通成员函数。普通成员函数离不开类对象,调用的时候得传类实例,这里直接传临时对象Plus()

包装完之后,不管原本是函数、仿函数还是 lambda,全都直接func(实参)调用。 记得要包含头文件<functional>

有了包装器,如何解决模板的效率低下,实例化多份的问题呢?

来看代码:

cpp 复制代码
#include <iostream>
#include <functional>
using namespace std;

template<class F, class T>
T useF(F f, T x)
{
    static int count = 0;
    cout << "count:" << ++count << endl;
    cout << "count:" << &count << endl;
    return f(x);
}

double f(double i)
{
    return i / 2;
}

struct Functor
{
    double operator()(double d)
    {
        return d / 3;
    }
};

int main()
{
    // 普通函数包装进function
    std::function<double(double)> func1 = f;
    cout << useF(func1, 11.11) << endl;

    // 仿函数包装进function
    std::function<double(double)> func2 = Functor();
    cout << useF(func2, 11.11) << endl;

    // lambda包装进function
    std::function<double(double)> func3 = [](double d)->double{ return d / 4; };
    cout << useF(func3, 11.11) << endl;

    return 0;
}

现在用std::function,把普通函数、仿函数、lambda 全都包装成function<double(double)>这同一个类型。

接着把 func1、func2、func3 传给模板 useF,模板参数 F 就变成同一种 function 类型,模板只会生成一份实例。 里面的静态变量 count 也就只有一份,打印&count,地址全部一样。

这样就解决了前面的问题:底层调用对象虽然不一样,经过 function 包装之后类型统一,不会生成多份模板代码,避免代码膨胀。


2.bind

std::bind定义在<functional>头文件,属于函数模板,也算是函数适配器。接收一个可调用对象,生成出新的可调用对象,用来适配原来函数的参数列表。

简单说:原本函数接收 N 个参数,可以用 bind 绑定一部分实参,得到一个接收 M 个参数的新函数;也可以用来调换参数顺序。

bind 原型:

cpp 复制代码
template <class Fn, class... Args>
/* unspecified */ bind (Fn&& fn, Args&&... args);

template <class Ret, class Fn, class... Args>
/* unspecified */ bind (Fn&& fn, Args&&... args);

可以把 bind 当成通用的函数适配器。接收一个可调用对象,产出一个新的可调用对象,用来调整原函数的参数。

bind 常用写法:auto newCallable = bind(callable, arg_list);

  • newCallable 就是 bind 生成的新可调用对象。
  • arg_list 是一串参数,逗号隔开,用来对应原 callable 的参数。调用 newCallable 的时候,内部就会跑原来的 callable,把 arg_list 里面的参数传过去。

arg_list 里可以写_1、_2这种占位符。 _1代表新函数的第一个参数,_2代表第二个,以此类推。占位符用来占住参数位置,也可以调换参数的先后顺序。

来看代码:

cpp 复制代码
// 使用举例
#include <iostream>
#include <functional>
using namespace std;

int Plus(int a, int b)
{
    return a + b;
}

class Sub
{
public:
    int sub(int a, int b)
    {
        return a - b;
    }
};

int main()
{
    //表示绑定函数plus 参数分别由调用 func1 的第一,二个参数指定
    std::function<int(int, int)> func1 = std::bind(Plus, placeholders::_1, placeholders::_2);
    //auto func1 = std::bind(Plus, placeholders::_1, placeholders::_2);

    //func2的类型为 function<void(int, int, int)> 与func1类型一样
    //表示绑定函数 plus 的第一,二为: 1, 2
    auto  func2 = std::bind(Plus, 1, 2);
    cout << func1(1, 2) << endl;
    cout << func2() << endl;

    Sub s;
    // 绑定成员函数
    std::function<int(int, int)> func3 = std::bind(&Sub::sub, s,
    placeholders::_1, placeholders::_2);

    // 参数调换顺序
    std::function<int(int, int)> func4 = std::bind(&Sub::sub, s,
    placeholders::_2, placeholders::_1);
    cout << func3(1, 2) << endl;
    cout << func4(1, 2) << endl;

    return 0;
}
  • func1:绑定普通函数 Plus。_1、_2是占位符,调用新函数时传进来的两个参数,直接交给原来的 Plus。
  • func2:直接把 Plus 的两个参数固定成 1 和 2,调用func2()不用给参数,内部就跑Plus(1,2)。 代码注释里写的function<void(int, int, int)>是写错了,并不是真实类型。
  • func3:绑定类普通成员函数 sub。普通成员函数得依赖对象,这里传入对象 s;_1、_2接收外面传的参数,等价于s.sub(1,2)。
  • func4:靠占位符调换参数顺序。把_2放前面,_1放后面。调用func4(1,2)实际执行s.sub(2,1),算出结果2‑1=1。

**注意:**placeholders::_1、_2属于std::placeholders命名空间,代码记住要包含头文件<functional>。

相关推荐
无小道1 小时前
C/C++——可变参数
c语言·开发语言·c++·可变参数
hele_two2 小时前
C++模拟蚁群(二)
c++·算法·数学建模·图形渲染
WK100%2 小时前
LeetCode双题:滑动窗口破解最小子数组
c++·经验分享·笔记·算法
@福者2 小时前
C++ QT 入门
开发语言·c++·qt
沧海一笑-dj2 小时前
【C++】Visual Studio 2026下载和安装
c++·visual studio·vs·vs2026
HugoStudio_SWAN2 小时前
洛谷 P1321 单词覆盖还原——从蜡板上的密信到数字水印
c++·学习·程序人生·算法
励志不掉头发的内向程序员2 小时前
【LibreCAD 2D架构】从鼠标点击到屏幕像素:LibreCAD绘图架构全链路解析之Action与命令系统
linux·开发语言·c++·qt·学习·系统架构
码匠许师傅3 小时前
【设计模式精讲】15.享元模式(Flyweight)
c++·设计模式·享元模式·uml
pingglala3 小时前
C++线程安全队列对比
开发语言·c++