目录
- 引言
- {}初始化
- std::initializer_list
- auto
- decltype
- 尾置返回类型
- 左值引用和右值引用
- lambda表达式
- 可变参数模板
- STL中的一些变化
- 新的类功能
- 包装器
- 智能指针
引言
C++11是C++的一次重大更新,引入了许多十分重要十分实用的新特性,对现在的C++可谓影响深远,本文是笔者我在学习C++11新特性的笔记、心得,C++11的特性非常多,这里笔者只选取了实用重要的部分进行学习,希望能对你有帮助。
{}初始化
{}初始化,我们也能称它列表初始化。在C语言中,我们其实就见过这样的形式了:
cpp
struct Point
{
int _a;
int _b;
};
int main()
{
int arr1[4] = { 1, 2, 3, 4 };
int arr2[] = { 1, 2, 3, 4 }; // 等价上一行
Point pt = { 1, 2 };
return 0;
}
但是C语言(和早期C++)中列表初始化只适用于数组和结构体,C++11则扩大了用大括号括起的列表的使用范围,使其可用于所有的内置类型和用户自定义的类型,使用列表初始化时,可添加等号(=),也可不添加。
cpp
struct Point
{
Point(int a, int b) : _a(a), _b(b)
{
std::cout << "Point(int a, int b)" << std::endl;
}
int _a;
int _b;
};
int main()
{
int a = { 1 };
int b { 1 };
Point pt = { 1, 2 };
// 这里就能看出C++对C语言的兼容了,如果没有写构造,汇编代码就只是对结构体成员变量直接进行赋值,
// 但是如果有构造函数,则是进行构造函数的调用,而且如果构造函数的参数不符合,还会报错
return 0;
}
这里能看出C++想要打造一个万物皆能列表初始化的理念,只是不加等号的初始化方式其实有点反直觉,建议日常写代码为了可读性,还是应该加上=,遇到不加等号的代码,也要能看懂。
我们都知道单参构造引起的隐式类型转化(不知道的可以看我的这篇博客【c++】类与对象详解),在C++11之后,列表初始化这种方式也能引起隐式类型转化了:
cpp
struct Point
{
Point(int a, int b) : _a(a), _b(b)
{
std::cout << "Point(int a, int b)" << std::endl;
}
Point(const Point& pt) : _a(pt._a), _b(pt._b)
{
std::cout << "Point(const Point& pt)" << std::endl;
}
int _a;
int _b;
};
int main()
{
Point pt1 { 1, 2 };
Point pt2 = { 1, 2 };
return 0;
}
我们都知道给构造函数前面加explicit 关键字,这样我们必须显式地调用该函数,不能进行隐式类型转化了。这里如果我们给Point的构造添加的话,Point pt2 = { 1, 2 };就会报错,Point pt1 { 1, 2 };则不会,由此看来前者会先进行隐式类型转换形成临时对象再进行赋值(最终被优化),而后者则是直接转化成构造函数的调用。
std::initializer_list
initializer_list,翻译过来就是初始化列表,和列表初始化不是一个东西,如下:
cpp
struct Point
{
Point(int a, int b) : _a(a), _b(b)
{
std::cout << "Point(int a, int b)" << std::endl;
}
Point(const Point& pt) : _a(pt._a), _b(pt._b)
{
std::cout << "Point(const Point& pt)" << std::endl;
}
int _a;
int _b;
};
int main()
{
std::vector<int> arr1 { 1, 2, 3 };
std::vector<int> arr2 = { 1, 2, 3 };
Point pt1 { 1, 2 };
Point pt2 = { 1, 2 };
return 0;
}
乍一看,vector和Point 似乎是类似的,都是列表初始化,但是我们仔细一想就会发现不对,因为vecor对象的这种初始化方式可是能随意改变{}中的变量数量的,而Point 则不能,所以两者实际上不是一个东西。我们查阅C++手册会发现,在C++11之后,很多stl容器都增添了构造函数,

这里以vector为例,可以看到有一个initializer_list版本的构造,initializer_list其实是C++11之后加入到标准库中的一个模板类,其核心价值在于它允许函数和构造函数接受花括号列表作为参数,从而简化代码并提高一致性。花括号中的变量当然也是可变的,我们演示一下:
cpp
auto init = { 1, 2, 3 };
std::cout << typeid(init).name() << std::endl;
// 输出结果: class std::initializer_list<int>
如果我们打印一下这个对象的大小:
cpp
auto init1 = { 1, 2, 3 };
auto init2 = { 1, 2, 3, 4, 5, 6 };
std::cout << sizeof(init1) << std::endl;
std::cout << sizeof(init2) << std::endl;
// 输出结果: 16 16
可以发现其大小不是由{}中的变量数量决定的,也就是说,它并不直接存储这些变量。实际上,{}中的变量最终其实还是以临时常量数组的方式存储的,而initializer_list只保存了头指针和尾指针,这样正好16bit(8字节)。这点从手册也能知晓,

可以看到,无论是引用还是const引用,其实都是const引用,因为本身就是常量数组,另外,initializer_list也支持迭代器,这使得我们可以很方便的使用它。
cpp
vector(std::initializer_list<T> il) // 自己实现的vector中定义一个initializer_list的构造
{
auto it = il.begin();
while (it != il.end())
{
push_back(*it);
++it;
}
}
jiunian::vector<int> arr1{ 1, 2, 3 }; // 这样我们就能使用{}初始化vector对象
jiunian::vector<int> arr2 = {1, 2, 3};
我们当然也能进一步的对诸如unordered_map这样的容器或vector<pair<int, int>>这样嵌套的容器进行这种初始化方式:
cpp
std::unordered_map<int, int> mp{ {1, 1}, {2, 2}, {3, 30} };
std::vector<std::pair<int, int>> arr1{ {1, 2}, {1, 2}, {1, 2} };
这种方式就是里面用的列表初始化初始一个个变量对象,外面则是initializer_list。
auto
在C++98中auto是一个存储类型的说明符,表明变量是局部自动存储类型,但是局部域中定义局部的变量默认就是自动存储类型,所以auto就没什么价值了。C++11中废弃auto原来的用法,将其用于实现自动类型腿断。这样要求必须进行显示初始化,让编译器将定义对象的类型设置为初始化值的类型。改版后的auto可以说相当好用了,但是我们也绝不能乱用瞎用,这会让代码的可读性下降,

哪些场景用auto合适呢?这里给几个常见的场景:
(1)我们遇到一些类型比较长的变量定义时可以用,比如迭代器。
(2)我们也不清楚类型是什么,比如lambda(后面会讲)。
(3)函数返回值是模板类型等不知道具体类型的场合可以用。
auto也有好用的语法糖,即范围for循环遍历容器:
cpp
std::vector<int>arr{1, 2, 3};
for (auto& e : arr)
std::cout << e << std::endl;
std::unordered_map<int, int>mp{ {1, 1}, {2, 2}, {3, 3} };
for (auto& [k, v] : mp) // 结构化绑定,C++17支持
std::cout << k << " " << v << std::endl;
其底层依靠迭代器实现,如果我们自己实现的容器也有迭代器,则也能支持范围for。
auto在使用上也有一些坑需要注意:
(1)auto在推导类型时,会忽略掉顶层const和引用,推导出的是基础类型 。
cpp
int i = 10;
const int ci = i;
int& ri = i;
auto a = ci; // a 的类型是 int,而非 const int
auto b = ri; // b 的类型是 int,而非 int&
如果需要保留这些属性,必须在auto上显式声明。
cpp
auto& c = ri; // c 的类型是 int&
const auto& d = ci; // d 的类型是 const int&
(2)在同一条语句中用auto声明多个变量时,所有变量的初始基本数据类型必须一致 。
cpp
auto x = 5, *y = &x; // 正确:x是int,y是int*
// auto a = 1, b = 2.0; // 错误:a和b的类型不一致
(3)auto不能作为函数的参数类型(但是之后的更新中好像lambda和普通函数都支持了auto参数,这里我不做讨论)。
decltype
关键字decltype将变量的类型声明为表达式指定的类型。我们使用auto定义变量,如果想查看类型的话,可以使用typeid,
cpp
std::vector<int>arr{ 1, 2, 3 };
auto it = arr.begin();
std::cout << typeid(it).name() << std::endl;
但是我们不能以这样的方式来定义变量,因为这只是一个字符串,
cpp
std::vector<int>arr{ 1, 2, 3 };
auto it = arr.begin();
//typeid(it).name() tmp; // 报错
但是如果我们想定义一个和it一样类型的变量,但是我们又不确定或者单纯太长懒得写怎么办呢?我们可以使用decltype,
cpp
std::vector<int>arr{ 1, 2, 3 };
auto it = arr.begin();
//typeid(it).name() tmp; // 报错
decltype(it) tmp = it; // 直接推导变量
std::vector<decltype(1 * 1.2)> num; // 也能当模板参数,里面也能传表达式
decltype由于可以精确推导变量类型,所以和auto搭配使用可以避免忽略const和引用的问题。
cpp
int a = 1;
int& b = a;
decltype(auto) c = b; // int&
auto d = b; // int
c++;
d++;
std::cout << c << std::endl; // 打印结果: 2
尾置返回类型
尾置返回类型是 C++11 引入的一项重要语法特性,它允许你将函数的返回类型声明放在参数列表之后,而非函数名之前。这种语法为我们编写现代 C++ 代码,尤其是在模板编程和处理复杂类型时,提供了极大的灵活性和清晰度。比如我们函数的返回值依赖模板参数类型,这时我们就很头疼,
cpp
template<typename T, typename U>
decltype(a + b) add(T a, U b) { ... }
上述写法会错误,因为在编译器解析返回类型decltype(a + b) 的时候,函数参数 a 和 b 还未被声明,它们处于作用域之外,所以编译器根本不认识它们。这是我们就可以用尾置返回值类型将类型推导延迟到参数列表之后。
cpp
template<typename T, typename U>
auto add(T a, U b) -> decltype(a + b) { ... } // 此时 a, b 已在作用域内
此外,如果返回的是函数指针,也能搭配这个提高可读性。
cpp
auto getFunction() -> int (*)(double);
C++14之后甚至可以直接省略尾置->返回值类型的部分,直接靠auto进行返回类型推导。但是请注意,这个推导依赖返回值,所以如果想要返回的类型不是return的类型本身或者声明定义分离头文件中的声明没有函数体无法推导时还是要写明。
左值引用和右值引用
传统的C++语法中就有引用的语法,而C++11中新增了的右值引用语法特性,所以从现在开始我们之前学习的引用就叫做左值引用。无论左值引用还是右值引用,都是给对象取别名。右值引用的表示方法就是比左值引用多一个&
什么是左值呢?左值是一个表示数据的表达式(如变量名或解引用的指针),我们可以获取它的地址,可能可以对它赋值(const修饰就不可以),左值可以出现赋值符号的左边,也可以出现在赋值符号的右边,而右值可以出现在赋值符号的右边,但是不能出现在赋值符号左边,所以我们不能简单地认为左值出现在左边,右值出现在右边,我们判断左右值最好的方式就是看能不能取地址,能取地址的一定是左值,反之右值。定义时以const修饰符后的左值,不能给他赋值,但是可以取它的地址。左值引用就是给左值的引用,给左值取别名,也是我们原本所认识的引用。
什么是右值呢?右值也是一个表示数据的表达式,如:字面常量、表达式返回值,函数返回值(这个不能是左值引用返回)等等,右值可以出现在赋值符号的右边,但是不能出现出现在赋值符号的左边,右值不能取地址。右值引用就是对右值的引用,给右值取别名。
cpp
int add(int a, int b)
{
return a + b;
}
int main()
{
int a = 1; // 右值可以在右边
int b = a; // 左值可以在右边
// 1 = a; // 右值不能在左边
// 1 + 1 = a;
// add(1, 1) = a;
int& ref_a = a; // 左值引用可以引用左值
int&& ref_c = 1; // 右值引用可以引用右值
int&& ref_d = add(1, 1);
int&& ref_e = 1 + 1;
return 0;
}
为什么右值就不能取地址呢?首先我们需要了解右值的本质,右值本质是临时值,是纯粹的数据本身,而左值是有身份的持久对象。这也就是为什么判断一个表达式是左值还是右值,最关键、最本质的依据是它是否代表一个具有持久身份的、可定位的存储位置,简单说就是能否用取地址运算符&获取其内存地址。像字面常量、表达式返回值,函数返回值,它们大多生命周期只在这一行,它们大多直接存在cpu寄存器或嵌入在指令中,压根不落内存,根本没有地址可言,而且从C++语言的设计逻辑来看,对右值取地址本就是没有意义的,取地址的目的是为了后续能够通过指针访问或修改该内存位置的内容,右值则做不到。下面讲的一个例子就是很好的证明,
cpp
"abcd"; // 乍一看是右值
const char* str = "abcd"; // 其实是左值,只是不能修改
std::cout << &"abcd" << std::endl; // 可以取地址,不过这里这个表达式自动返回自己的首元素地址,再取地址就是指针的地址
std::cout << &str[2] << std::endl; // 这里则是最好的证明,即使加上偏移量还是能取地址
在C++中,这种字符串表达式是一种特例,如果是其他的内置类型这样写则是字面常量,而字符串则是常量,常量不等于右值,因为常量会被存在常量区,有地址的,只是不能修改,所以是const修饰的,而其他的字面常量,可以认为被优化到了寄存器或嵌入指令中了,这个例子切实反映了左右值的特性,要好好记住。
左值引用只能引用左值,右值引用只能引用右值吗?
对于左值引用来说,可以引用右值,但是前面要加上const。
cpp
// int& ref_a = 1; // 错误
const int& ref_a = 1;
对于右值引用来说,不能直接引用左值,但是可以借助move函数,将左值转化成右值,这个函数也是C++11新加入的函数,后面详细讲解。
cpp
int b = 1;
// int&& ref_b = b; // 错误
int&& ref_b = std::move(b); // move函数转化后可以
右值引用的价值是什么?在搞清楚这个问题之前,我们不妨想一想左值引用的价值,我们使用左值引用最多的场景毫无疑问是用来做函数参数,其次是返回值,正常写代码时我们比较少用到左值引用。因为左值引用最大的价值就是减少拷贝 ,我们在进行比较大的参数传参时,如果直接传,那就会直接进行拷贝,这会有很大的性能开销,所以我们会选择传引用,引用只是取别名,所以和直接拷贝相比开销大大降低。而对于返回值,我们只有局部static变量才可能会用到,因为正常的局部变量在函数返回时生命周期就结束了,会自行销毁,这时返回的引用是悬空引用,如果是内置类型,且返回后没有调用其他函数,那还有可能访问到正确的值(此时已经非法,但是检查可能不严格),如果是自定义变量,且涉及深拷贝,那即使访问到了栈上的正确的变量,其管理的堆空间也早已释放,这会涉及非法访问,所以这是万万不可的。传统的左值引用并不能解决返回值过大带来的性能开销,所以在C++11之前,成熟的C++项目大多不会直接返回很大的变量,而是采用诸如输出型参数(函数外提前定义好变量,左值引用传递进去,函数内直接对这个引用进行修改,最终结果就是这个引用,无需返回,返回值可以传bool、int等小变量)、堆上申请空间返回指针等方式避开这个问题。而这正是设计右值引用的原因,右值引用的价值和左值引用一样------减少拷贝。
首先我们先明确一点,以下函数可以形成函数重载吗?
cpp
void func(int&& a)
{
std::cout << "void func(int&& a)" << std::endl;
}
void func(const int& a)
{
std::cout << "void func(const int& a)" << std::endl;
}
答案是构成,
cpp
void func(int&& a)
{
std::cout << "void func(int&& a)" << std::endl;
}
void func(const int& a)
{
std::cout << "void func(const int& a)" << std::endl;
}
int main()
{
func(1);
int a = 1;
func(a);
// 结果:
// void func(int&& a)
// void func(int& a)
return 0;
}
由于重载函数的特性,会自动匹配最合适的函数,如果没有右值引用重载,则两个都会匹配左值引用的函数。
所以,我们就能实现以下操作,
cpp
#pragma once
#include<iostream>
#include<vector>
#include<cassert>
namespace jiunian
{
template<class T>
class vector
{
public:
using iterator = T*;
using const_iterator = const T*;
iterator begin() { return _start; }
iterator end() { return _finish; }
const_iterator begin() const { return _start; }
const_iterator end() const { return _finish; }
size_t size() { return _finish - _start; }
size_t size() const { return _finish - _start; }
size_t capacity() { return _end_of_storage - _start; }
size_t capacity() const { return _end_of_storage - _start; }
vector() {}
vector(int n, const T& value = T()) { resize(n, value); } // TODO?也许可以增加效率
vector(std::initializer_list<T> il)
{
auto it = il.begin();
while (it != il.end())
{
push_back(*it);
++it;
}
}
template<class InputIterator>
vector(InputIterator first, InputIterator last)
{
while (first != last)
{
push_back(*first);
++first;
}
}
vector(const vector<T>& v) noexcept
{
std::cout << "vector(const vector<T>& v) --深拷贝" << std::endl;
reserve(v.capacity());
for (auto e : v)
push_back(e);
}
vector(vector<T>&& v) noexcept
{
std::cout << "vector(vector<T>&& v) --移动构造" << std::endl;
swap(v);
}
void swap(vector<T>& v)
{
std::swap(v._start, _start);
std::swap(v._finish, _finish);
std::swap(v._end_of_storage, _end_of_storage);
}
const T& operator[](size_t pos)const
{
assert(pos < size());
return _start[pos];
}
vector<T>& operator= (const vector<T>& v)
{
std::cout << "vector<T>& operator= (const vector<T>& v) --深拷贝" << std::endl;
vector<T> tmp(v);
swap(tmp);
return *this;
}
vector<T>& operator= (vector<T>&& v)
{
std::cout << "vector<T>& operator= (vector<T>&& v) --移动拷贝" << std::endl;
swap(v);
return *this;
}
~vector()
{
if (_start == nullptr) return;
free(_start);
_start = nullptr;
_finish = nullptr;
_end_of_storage = nullptr;
}
void reserve(size_t sz)
{
if (sz < capacity()) return;
size_t new_size = capacity() == 0 ? 4 : capacity();
while (new_size < sz) new_size *= 2;
//iterator tmp = new T[new_size]{};
iterator tmp = (iterator)malloc(sizeof(T) * new_size);
if (!tmp) { printf("new error."); return; }
size_t old_size = size();
if (old_size > 0)
{
for (size_t i = 0; i < old_size; ++i)
tmp[i] = std::move(_start[i]); // 如果 T 支持移动,这里会很高效
free(_start);
}
_start = tmp;
_finish = tmp + old_size;
_end_of_storage = tmp + new_size;
}
iterator insert(iterator pos, const T& value)
{
size_t len = pos - _start;
if (len > size()) return iterator();
if (_finish == _end_of_storage)
{
reserve(capacity() + 1);
pos = _start + len;
}
iterator cur = _finish;
while (cur >= _start && cur != pos)
{
*(cur) = *(cur - 1);
cur--;
}
new (cur)T(value);
_finish++;
return pos;
}
iterator insert(iterator pos, T&& value)
{
size_t len = pos - _start;
if (len > size()) return iterator();
if (_finish == _end_of_storage)
{
reserve(capacity() + 1);
pos = _start + len;
}
iterator cur = _finish;
while (cur >= _start && cur != pos)
{
*(cur) = *(cur - 1);
cur--;
}
new (cur)T(std::forward<T>(value));
_finish++;
return pos;
}
template <class... Args>
iterator emplace(iterator pos, Args&&... args)
{
size_t len = pos - _start;
if (len > size()) return iterator();
if (_finish == _end_of_storage)
{
reserve(capacity() + 1);
pos = _start + len;
}
iterator cur = _finish;
while (cur >= _start && cur != pos)
{
*(cur) = *(cur - 1);
cur--;
}
new (cur)T(std::forward<Args>(args)...);
_finish++;
return pos;
}
iterator erase(iterator pos)
{
if (pos > _finish) return;
size_t len = pos - _start;
while (pos + 1 != _finish) *pos = *(pos + 1), pos++;
_finish--;
return _start + len;
}
void resize(size_t sz, const T& value = T())
{
if (sz < size()) { _finish = _start + sz; return; }
reserve(sz);
while (_finish != _start + sz)
{
*_finish = value;
++_finish;
}
}
void pop_back()
{
iterator pos = end() - 1;
erase(pos);
}
void push_back(const T& value) { insert(end(), value); }
void push_back(T&& value) { insert(end(), std::forward<T>(value)); }
template <class... Args>
void emplace_back(Args&&... args) { emplace(end(), std::forward<Args>(args)...); }
private:
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _end_of_storage = nullptr;
};
}
这是我自己实现的简易vector,不是本章重点,不做讲解。
cpp
jiunian::vector<int> func()
{
jiunian::vector<int> ret1{ 1, 2, 3 };
jiunian::vector<int> ret2{ 4, 5, 6 };
return (rand() % 2) ? ret1 : ret2;
}
int main()
{
srand(time(NULL));
jiunian::vector<int> arr;
arr = func();
return 0;
}
如果我们直接运行这段代码,会发生什么呢?结果将是:
bash
vector(const vector<T>& v) --深拷贝
vector<T>& operator= (vector<T> v) --深拷贝
vector(const vector<T>& v) --深拷贝
1 2 3
or
vector(const vector<T>& v) --深拷贝
vector<T>& operator= (vector<T> v) --深拷贝
vector(const vector<T>& v) --深拷贝
4 5 6
也就是函数返回时拷贝一次临时变量,然后赋值一次拷贝(这里赋值运算符重载内部调用了拷贝构造,所以两个打印实际是一次拷贝),总共两次拷贝,这是一个很大的性能开销,而且无意义,性能被白白浪费了。大家也能看到我是用的是随机返回的方式,这能防止编译器的优化,如果不这样,实际其实会少一次拷贝,编译器会直接在main函数中创建函数返回值,也就是说,这相当于直接把ret返回了,直接不拷贝,由于我老喜欢升级IDE,我用的已经是VS2026了,即使用最低的C++版本,禁用优化选项,也无法阻止这个优化,这个优化的本意当然是好的,但是确实影响到了我演示,但是也能从中窥见C++本身也极其厌恶这种性能浪费,想方设法地优化了。
那么我们可以怎么办呢?我们可以在赋值运算符重载那定义一个右值引用的重载,
cpp
vector<T>& operator= (vector<T>&& v)
{
std::cout << "vector<T>& operator= (vector<T>&& v) --移动拷贝" << std::endl;
swap(v);
return *this;
}
前面也讲过了,这是可以形成重载的,这样会发生什么呢?
bash
vector(const vector<T>& v) --深拷贝
vector<T>& operator= (vector<T>&& v) --移动拷贝
1 2 3
or
vector(const vector<T>& v) --深拷贝
vector<T>& operator= (vector<T>&& v) --移动拷贝
4 5 6
可以发现,还是两次拷贝,但是,真是两次拷贝吗?我们看看右值引用的重载会发现,函数内部只是进行了交换,也就是偷梁换柱。为什么可以这么做呢?因为右值本身就生命短暂,马上就会被销毁,这时如果其本身管理着堆上的一部分空间,那么与其让它就这么死了释放掉,还不如给我,反正我要被你赋值,你马上就挂了,那把你的给我,不正好吗?省的我拷贝了不是吗?那这种拷贝就只是浅拷贝而已,不会发生错误的浅拷贝,和深拷贝的开销不是一个量级的。这点左值就做不到,因为左值不会马上销毁,我不会死,结果你把我的内部浅拷贝了一份,那就会有double free的问题了。
再严谨一点说明,为什么C++11要特地区分左值右值,设计右值引用,吃饱了撑的?常规给那些字面量搞个右值拷贝没有任何意义,右值拷贝真正的意义在这。这里func函数返回纯右值,之后被赋值运算符的右值引用引用到了,在函数内部完成了资源的转移,这样的拷贝方式我们可以称之为移动拷贝,这个函数可以称为移动赋值。
再进一步的,我们如果写成这种格式,
cpp
jiunian::vector<int> func()
{
jiunian::vector<int> ret{ 1, 2, 3 };
return move(ret);
}
int main()
{
srand(time(NULL));
jiunian::vector<int> arr = func();
for (auto& e : arr)
std::cout << e << " ";
std::cout << std::endl;
return 0;
}
这么写会发生什么呢?
bash
vector(const vector<T>& v) --深拷贝
1 2 3
会有一次深拷贝,其实如果没有优化还是会有两次的,先构造再拷贝构造,但是这里因为连续构造拷贝构造,就直接变成了将ret传给arr做拷贝构造,省去了返回值临时对象的一次拷贝。这里加move,是为了破坏编译器的优化,让它认为我们要进行移动构造,然而我们还没有写移动构造呢,所以才有了这一次拷贝,如果不加,那就什么都不打印,ret直接被定义在arr内部,也就是函数内的ret其实是arr,一次也不会拷贝,这里为了演示,不得已为之,这是C++17之后推出的优化策略。
如果我们写一个拷贝构造的右值引用重载,
cpp
vector(vector<T>&& v) noexcept
{
std::cout << "vector(vector<T>&& v) --移动拷贝" << std::endl;
swap(v);
}
那么结果就会变成,
bash
vector(vector<T>&& v) --移动构造
1 2 3
其内部原理和赋值运算符重载一样,性能开销很低,所以这时的开销又减小了。当然,如果不加move,这里什么都不会打印,我们也不推荐在函数返回值加move,为什么呢?因为其实如果没有C++17的强制优化,我们不加move函数也能触发移动构造,即身为左值的ret被直接返回给右值引用构造,但是可以作为右值,可以认为编译器自动加上了move函数,为什么呢?在C++11中,原本的左值、右值被细分成了左值、将亡值、纯右值。左值有身份、有地址、没有临时性、不可被移动。纯右值无身份、无地址、有临时性、可以被移动。将亡值有身份、有地址、有临时性、可以被移动。将亡值位于左右值之间,是一种介于两者之间的产物,但是将亡值和纯右值都被当作右值。 纯右值是传统意义上的"临时值"。它们通常不与任何内存位置(地址)直接关联,只是在表达式运算过程中产生的中间结果。将亡值是 C++11 新引入的概念。它指的是一个虽然有内存地址,但其资源已经"获准"被转移的对象,我们可以理解为一个左值打上了标记,认为其是右值。这里直接返回左值,编译器认为太浪费,反正也快死了,这里的ret和将亡值很像,所以将其转化成了将亡值(这也是move函数真正的作用),即一个有身份,有地址,即将被销毁(和右值一样的临时性,会被被延长至该函数结束),其资源已经"获准"被转移的对象(如果这里写了move编译器一点优化没有,会调用两次移动构造,一次将亡值调用,一次返回值纯右值调用)。当然这样做也是为了老C++代码考虑,让他们不用修改老代码也能获得性能优化,这个优化是强制要求所有编译器实现的,所以我们本来也不用自己加,然后又在C++17中,再次进行了优化,所以我们就跟不要加了。
既然这里func函数返回值会调用右值引用函数,那么直接返回右值引用呢?答案是大错特错。首先,我们不能因为编译器的优化就主次颠倒,我们要明白,局部变量引用返回无论左值右值都是万万不可的,只有static局部变量可以这么做。而且,返回右值引用确实还是可以触发右值引用版本的移动构造和移动赋值,但是此时因为出了生命周期了,ret已经被销毁,所以调用了也不会拷贝成功。我们在这里要明确两个事,一:写代码不要去想编译器会干嘛,做什么优化,所以我要干嘛,我们应该着眼于代码本身应该正确,编译器是跟着我们做事的才对,不应本末倒置;二:C++11之后,返回值的优化是强制的,不然设计右值引用就没意义了,所以放心写。
move函数
接下来我们来讲一下move函数,
cpp
template <class T>
typename remove_reference<T>::type&& move (T&& arg) noexcept;
move函数到底是干什么的,直接改变传入值得性质吗?我们可以看到该函数的返回值是一个右值引用,我们不妨试验一下,
cpp
jiunian::vector<int> arr1{ 1, 2, 3 };
std::move(arr1);
jiunian::vector<int> arr2 = arr1; // 这也证明了左值的资源不会轻易就被别人抢走
// 打印结果: vector(const vector<T>& v) --深拷贝
所以实际上这并不会改变传入参数的性质,那么到底怎样才能改变呢?也很简单,
cpp
jiunian::vector<int> arr1{ 1, 2, 3 };
jiunian::vector<int> arr2 = std::move(arr1);
// 打印结果: vector(vector<T>&& v) --移动构造
可以认为move函数就是返回传入值强转后的右值引用,如果传入左值,就相当于给他打上标签,标明其可以被移动,这样的返回值可以触发移动构造和赋值从而完成资源转移,但是必须接住这个返回值,move函数本身不会改变传入参数性质,就像我们使用的类型强转一样。
cpp
int a = 1;
size_t b = (size_t)a;
// a仍然是int类型
我们可以理解就像中间有了一个浅拷贝的临时变量这样的东西(实际并不不是,可以这样辅助理解)。
除了移动构造和移动赋值之外,右值引用还在以下几个容器函数中提升了效率:
(1)push_back
不少stl容器都有push_back,我们先拿标准库的试试,后面我们自己实现一下,
cpp
std::vector<jiunian::vector<int>> arr;
arr.reserve(10); // 提前扩容,不然触发扩容迁移数据影响观察结果
jiunian::vector<int> a{ 1, 2, 3 };
arr.push_back(a);
std::cout << "----------------------------------------" << std::endl;
arr.push_back(std::move(a));
std::cout << "----------------------------------------" << std::endl;
arr.push_back({ 1, 2, 3 });
// 打印结果:
// vector(const vector<T>&v) --深拷贝
// ----------------------------------------
// vector(vector<T> && v) --移动构造
// ----------------------------------------
// vector(vector<T> && v) --移动构造
可以看到,当我们push_back的是左值时,那就会触发深拷贝,这也很正常,因为人家是左值,不能随便把人家资源偷走了。但是如果是使用了move允许转移那就会触发移动构造,节省了资源。当然我们日常也不太会经常是第二种写法,我们第三种写法较多,这种写法也能触发移动构造优化,因为initializer_list触发隐式类型转化返回临时对象,是右值,可以调用移动构造,这简直不要太舒服。
(2)insert
这个和push_back同理,
cpp
std::vector<jiunian::vector<int>> arr;
arr.reserve(10); // 提前扩容,不然触发扩容迁移数据影响观察结果
jiunian::vector<int> a{ 1, 2, 3 };
arr.insert(arr.begin(), a);
std::cout << "----------------------------------------" << std::endl;
arr.insert(arr.begin(), std::move(a));
std::cout << "----------------------------------------" << std::endl;
arr.insert(arr.begin(), jiunian::vector<int>{ 1, 2, 3 });
// insert正好有initializer_list版本的重载,这里不在前面声明jiunian::vector<int>会变成插入三个容量分别为1, 2, 3的jiunian::vector<int>
// 打印结果:
// vector(const vector<T>& v) --深拷贝
// ----------------------------------------
// vector(vector<T> && v) --移动构造
// vector(vector<T> && v) --移动构造
// vector<T>&operator= (vector<T> && v) --移动拷贝
// ----------------------------------------
// vector(vector<T> && v) --移动构造
// vector(vector<T> && v) --移动构造
// vector<T>&operator= (vector<T> && v) --移动拷贝
// vector<T>&operator= (vector<T> && v) --移动拷贝
和push_back同理。
(3)emplace系列(建议看完后面的完美转发 和可变参数模板 再来看)
emplace系列是C++11新加入的函数,我们以vector为例,


一个是构造并插入,一个是构造并插入在尾部,两者原理相同,我以emplace_back为例。
我们观察这个函数的参数,可变参数模板 + 万能引用,这个就厉害了,tm感觉什么都能传,这个函数有什么用呢,给个例子就明白了,
cpp
std::vector<jiunian::vector<int>> arr;
arr.emplace_back(jiunian::vector<int>{1, 2, 3});
arr.emplace_back(10, 1);
jiunian::vector<int> tmp{ 4, 5, 6 };
arr.emplace_back(tmp);
for (auto& e : arr)
{
for (auto& n : e)
std::cout << n << " ";
std::cout << std::endl;
}
// 打印结果:
// vector(vector<T> && v) --移动构造
// vector(vector<T> && v) --移动构造
// vector(const vector<T>&v) --深拷贝
// vector(vector<T> && v) --移动构造
// vector(vector<T> && v) --移动构造
// 1 2 3
// 1 1 1 1 1 1 1 1 1 1
// 4 5 6
这个函数的意思就是,我们想尾插一个值,我们可以不用构造好(不论是临时还是普通的)再传给它了,当然我们构造好再传也可以,因为这个函数可以用我们传进去的参数构造好对象然后尾插。我们传initializer_list,那就initializer_list初始化;我们传大小和填充数字,那就调用那个构造;我们传对象呢?那就调用拷贝构造。左值右值都没关系,万能引用。所以可见这个函数还是比较方便的,特别是我们如果是这样的场景,
cpp
std::vector<std::pair<int, int>> arr;
arr.emplace_back(1, 1);
arr.emplace_back(2, 2);
我们可以不用写make_pair了。
emplace_back和push_back相比谁效率更高呢?差不多。emplace_back是将参数传到构造哪里一次构造完就尾插,而push_back是将构造好的变量一层层传递之后尾插。如果没有右值引用的移动语义,emplace_back会更优,但是有了之后两者只能说旗鼓相当。
cpp
std::vector<jiunian::vector<int>> arr;
arr.reserve(10);
std::cout << "emplace_back: " << std::endl;
arr.emplace_back(jiunian::vector<int>{1, 2, 3});
std::cout << "push_back: " << std::endl;
arr.push_back(jiunian::vector<int>{1, 2, 3});
std::cout << "----------------------------------------" << std::endl;
std::cout << "emplace_back: " << std::endl;
arr.emplace_back(10, 1);
std::cout << "push_back: " << std::endl;
arr.push_back(jiunian::vector<int>(10, 1));
std::cout << "----------------------------------------" << std::endl;
jiunian::vector<int> tmp{ 4, 5, 6 };
std::cout << "emplace_back: " << std::endl;
arr.emplace_back(tmp);
std::cout << "push_back: " << std::endl;
arr.push_back(tmp);
// 打印结果:
// emplace_back:
// vector(vector<T> && v) --移动构造
// push_back :
// vector(vector<T> && v) --移动构造
// ----------------------------------------
// emplace_back :
// push_back :
// vector(vector<T> && v) --移动构造
// ----------------------------------------
// emplace_back :
// vector(const vector<T>&v) --深拷贝
// push_back :
// vector(const vector<T>&v) --深拷贝
通过上面的例子也能看出来,移动语义的效率相当高,使得两者旗鼓相当。移动语义对于深拷贝的效率高,对于纯内置类型的浅拷贝来说,可效率上会差一点,对比emplace_back。
我们根据以上原理,实现一个自己的emplace系列,
cpp
template <class... Args>
iterator emplace(iterator pos, Args&&... args)
{
size_t len = pos - _start;
if (len > size()) return iterator();
if (_finish == _end_of_storage)
{
reserve(capacity() + 1);
pos = _start + len;
}
iterator cur = _finish;
while (cur >= _start && cur != pos)
{
*(cur) = *(cur - 1);
cur--;
}
new (cur)T(std::forward<Args>(args)...);
_finish++;
return pos;
}
void emplace_back(Args&&... args) { emplace(end(), std::forward<Args>(args)...); }
注意std::forward<Args>(args)...因为std::forward只接受一个参数,所以把...放在后面,表示向后展开,每个都有一个std::forward。
完美转发
大家看向下面这段代码,
cpp
void Func(int& x)
{
std::cout << "void Func(int& x)" << std::endl;
}
void Func(const int& x)
{
std::cout << "void Func(const int& x)" << std::endl;
}
void Func(int&& x)
{
std::cout << "void Func(int& x)" << std::endl;
}
void Func(const int&& x)
{
std::cout << "void Func(const int&& x)" << std::endl;
}
template<class T>
void PerfectForword(T&& val)
{
Func(val);
}
int main()
{
int a = 1;
const int b = 1;
PerfectForword(a);
PerfectForword(b);
PerfectForword(1);
PerfectForword(std::move(b));
return 0;
}
这里的模板函数的模板参数T&& val被称为万能引用,不仅可以引用右值,也能引用左值,实参是左值时,就是左值引用(引用折叠了),实参是右值时,是右值引用。但是这必须得是靠模板参数推导的才行,像类模板的成员函数,如果直接使用T&&是不管用的,因为模板参数已经被传参显式实例化了,想用的话得在上面加一个template<>为这个函数单独声明模板参数,让其可以使用模板参数推导。
这里的结果是什么呢?按照我们以往的思考,应该是
bash
void Func(int& x)
void Func(const int& x)
void Func(int&& x)
void Func(const int&& x)
才对,但是其实结果是
bash
void Func(int& x)
void Func(const int& x)
void Func(int& x)
void Func(const int& x)
为什么会这样呢?这就得牵扯出之前一直没讲的一个东西,我们看向下面这段代码,
cpp
int&& a = 1;
a++;
std::cout << a << std::endl;
可以运行吗?可以的。右值引用需要引用右值,但是右值引用本身是左值。 我们之前说哪些是左值,哪些是右值,但是右值引用我们从未提过是右值,右值引用是右值,那设计出来还有什么意义?右值无法取地址,无法修改,之前的移动拷贝都将化为泡影。引用的底层是指针,引用这个类型本身一定是有身份有地址的,所以一定是左值。我们可以认为右值本身没有身份地址还是临时的,但是被引用后有了名字,也有了确切的地址,对于字面量,可能需要真的在内存中存起来,对于自定义类等,还是依赖指针,因为说是没有地址,但是只要参与了运算,怎么会不在内存中呢,除了嵌入指令存在于寄存器中的情况(字面量,需要先在内存上找块空间存起来),其他的都还是和左值一样,存的指针,并且延长了寿命直到引用销毁。
所以这里的问题就是,右值引用引用了右值,但是因为其本身是左值,所以调用了左值引用,这样的情况也不一定是我们不想要的,像之前的移动拷贝构造等,调用了swap函数,我们当然希望调用左值引用的那个(我们只实现了那个),但是这里我们想要调用右值引用的函数,怎么办呢?两个办法,一个是对右值引用继续使用move,但是这样左值又调用不了了,到底怎么办呢?使用完美转发。
cpp
template<class T>
void PerfectForword(T&& val)
{
//Func(val);
Func(std::forward<T>(val));
//Func(std::move(val));
}
int main()
{
int a = 1;
const int b = 1;
PerfectForword(a);
PerfectForword(b);
PerfectForword(1);
PerfectForword(std::move(b));
return 0;
}
cpp
lvalue (1)
template <class T> T&& C(typename remove_reference<T>::type& arg) noexcept;
rvalue (2)
template <class T> T&& forward (typename remove_reference<T>::type&& arg) noexcept;
forward函数用于实现完美转发,在模板函数中将参数原封不动(保持其左值或右值属性)传递给其他函数,保证移动语义的正确传递,避免不必要的拷贝,提升性能。
那么这样我们就能回过头来实现一下push_back和insert的右值版本了,
cpp
void push_back(const T& value) { insert(end(), value); }
void push_back(T&& value) { insert(end(), value); }
我们直接给出右值引用版本的push_back,这样就可以了吗?
bash
vector<T>& operator= (const vector<T>& v) --深拷贝
vector(const vector<T>& v) --深拷贝
1 2 3
结果还是深拷贝,所以不对,原因不用说,完美转发,
cpp
void push_back(const T& value) { insert(end(), value); }
void push_back(T&& value) { insert(end(), std::forward<T>(value)); }
但是结果还是一样的,为什么呢?因为调用的insert还没有右值重载呢。
cpp
iterator insert(iterator pos, T&& value)
{
size_t len = pos - _start;
if (len > size()) return iterator();
if (_finish == _end_of_storage)
{
reserve(capacity() + 1);
pos = _start + len;
}
iterator cur = _finish;
while (cur >= _start && cur != pos)
{
*(cur) = *(cur - 1);
cur--;
}
new (cur)T(value);
_finish++;
return pos;
}
还是不对,为什么呢?因为里面的*cur = value;调用了赋值运算符重载,但是这里没有完美转发,所以
cpp
iterator insert(iterator pos, T&& value)
{
size_t len = pos - _start;
if (len > size()) return iterator();
if (_finish == _end_of_storage)
{
reserve(capacity() + 1);
pos = _start + len;
}
iterator cur = _finish;
while (cur >= _start && cur != pos)
{
*(cur) = *(cur - 1);
cur--;
}
new (cur)T(std::forward<T>(value));
_finish++;
return pos;
}
这下终于成了。
bash
vector<T>& operator= (vector<T>&& v) --移动拷贝
1 2 3
这里我们也能用move,因为不涉及左值的问题。顺带一提这里的push_back可以实现成万能引用,但是这样模板参数就要靠推导,没法直接传initializer_list初始化了。
lambda表达式
lambda表达式是C++11引入的新特性之一,其本身并不是C++原创,属于舶来品,因为比较好用比较受欢迎,所以引入了。
lambda表达式在C++中的定位,取代了一部分函数指针,也取代了一部分仿函数。我们看下面这个例子。
cpp
struct Product
{
std::string _name;
int _price;
int _sales;
Product(const std::string& name, int price, int sales) : _name(name), _price(price), _sales(sales) {}
};
struct compare_by_price
{
bool operator()(const Product& a, const Product& b)
{
return a._price > b._price;
}
};
struct compare_by_sales
{
bool operator()(const Product& a, const Product& b)
{
return a._sales > b._sales;
}
};
struct compare_by_name
{
bool operator()(const Product& a, const Product& b)
{
return a._name > b._name;
}
};
int main()
{
std::vector<Product> arr = { {"banana", 3, 10}, {"apple", 2, 35}, {"pear", 4, 20}};
std::sort(arr.begin(), arr.end(), compare_by_price());
for (auto& e : arr)
printf("name: %-8s price: %2d sales: %2d\n", e._name.c_str(), e._price, e._sales);
std::cout << std::endl << std::endl;
std::sort(arr.begin(), arr.end(), compare_by_sales());
for (auto& e : arr)
printf("name: %-8s price: %2d sales: %2d\n", e._name.c_str(), e._price, e._sales);
std::cout << std::endl << std::endl;
std::sort(arr.begin(), arr.end(), compare_by_name());
for (auto& e : arr)
printf("name: %-8s price: %2d sales: %2d\n", e._name.c_str(), e._price, e._sales);
return 0;
}
我们实现了一个简单的商品类,有名字、价格、销量,我们想分别对这几个进行降序排序,以往我们可以怎么做呢?我们可以对这个类的比较运算符进行重载,但是这样的话就只能实现一个比较逻辑。不然的话就是仿函数,可以看到,仿函数这么使用还是有点麻烦的,这里还是命名相对规范,且定义使用考的很近的情况,如果命名不规范,定义和使用又不在一个文件,那不轮阅读还是使用起来都听坐牢的,所以我们可以使用lambda表达式来解决。
cpp
struct Product
{
std::string _name;
int _price;
int _sales;
Product(const std::string& name, int price, int sales) : _name(name), _price(price), _sales(sales) {}
};
int main()
{
std::vector<Product> arr = { {"banana", 3, 10}, {"apple", 2, 35}, {"pear", 4, 20} };
auto compare_by_price = [](const Product& a, const Product& b)->bool {
return a._price > b._price;
}; // 其实这里可以直接写进sort函数中,不用中间值
std::sort(arr.begin(), arr.end(), compare_by_price);
for (auto& e : arr)
printf("name: %-8s price: %2d sales: %2d\n", e._name.c_str(), e._price, e._sales);
std::cout << std::endl << std::endl;
auto compare_by_sales = [](const Product& a, const Product& b)->bool {
return a._sales > b._sales;
};
std::sort(arr.begin(), arr.end(), compare_by_sales);
for (auto& e : arr)
printf("name: %-8s price: %2d sales: %2d\n", e._name.c_str(), e._price, e._sales);
std::cout << std::endl << std::endl;
auto compare_by_name = [](const Product& a, const Product& b)->bool {
return a._name > b._name;
};
std::sort(arr.begin(), arr.end(), compare_by_name);
for (auto& e : arr)
printf("name: %-8s price: %2d sales: %2d\n", e._name.c_str(), e._price, e._sales);
return 0;
}
下面是lambda表达式各部分的说明,
| 组成部分 | 语法/关键字 | 说明与用途 | 示例片段 |
|---|---|---|---|
| 捕获列表 (Capture Clause) | [], [=], [&], [var], [&var], [this], [=, &var] |
定义lambda如何从外部作用域捕获变量。值捕获是只读的(除非用mutable),引用捕获可修改原变量。 |
int a=1, b=2; auto f = [a, &b](){ return a + b; }; |
| 参数列表 (Parameters) | (), (int a, int b), (auto a, auto b) (C++14) |
与普通函数的参数列表类似。可以为空。C++14起可使用auto定义泛型lambda。 |
auto add = [](int x, int y) { return x + y; }; |
| 可变规范 (Mutable Specifier) | mutable |
允许lambda修改按值捕获的变量副本,并允许非常量成员函数被调用。注意:这并不影响原始外部变量的值。 | auto counter = [n=0]() mutable { return n++; }; |
| 异常规范 (Exception Specifier) | noexcept |
指示该lambda表达式不会抛出任何异常,有助于编译器优化。 | auto safe_op = []() noexcept { /* 无异常操作 */ }; |
| 返回类型 (Return Type) | -> type |
显式指定返回值类型。通常可省略,编译器会自动推导。在代码路径复杂或返回初始化列表时需要显式指定。 | auto get = []() -> float { return 3.14; }; |
| 函数体 (Body) | {} |
包含lambda表达式要执行的代码逻辑。与普通函数体规则相同。 | []() { std::cout << "Hello, Lambda!"; }(); |
首先我们来讲一下捕获列表,我们在使用lambda时,不能直接使用当前函数中的其他变量,如果真的想要使用,我们可以将其写进捕获列表,这样我们就能在lambda表达式内部使用了。
cpp
int k = 4;
auto add = [k](int a, int b) { return (a + b) * k; };
std::cout << add(1, 1) << std::endl;
// 打印结果: 8
当然,捕获的值默认是const的,不可修改,一般我们也不会修改,但是如果真的想要修改,我们可以使用mutable关键词,
cpp
int k = 4;
auto add = [k](int a, int b) mutable { ++k; return (a + b) * k; };
std::cout << add(1, 1) << std::endl;
std::cout << k << std::endl;
// 打印结果:
// 10
// 4
但是我们这时会发现,lambda表达式中的k的值++了,但是main函数中的k没有变,这是因为捕获的值是拷贝进去的。如果我们想要能修改到外面的k,我们可以使用引用捕获,
cpp
int k = 4;
auto add = [&k](int a, int b) { ++k; return (a + b) * k; };
std::cout << add(1, 1) << std::endl;
std::cout << k << std::endl;
// 打印结果:
// 10
// 5
引用捕获不仅可以捕获普通对象,也能捕获const对象,这是自动识别的。
cpp
const int k = 4;
auto add = [&k](int a, int b) { std::cout << &k << std::endl; return (a + b) * k; };
std::cout << add(1, 1) << std::endl;
如果我们想要捕捉使用函数时该函数之前的所有变量,我们可以在捕捉列表使用=,
cpp
int k = 4;
int v = 8;
auto add = [=](int a, int b) { return (a + b) * k * v; };
std::cout << add(1, 1) << std::endl;
std::cout << k << " " << v << std::endl;
// 打印结果:
// 64
// 4 8
如果我们想要捕捉使用函数时该函数之前的所有变量的引用,我们可以在捕捉列表使用&,
cpp
int k = 4;
int v = 8;
auto add = [&](int a, int b) { ++k; ++v; return (a + b) * k * v; };
std::cout << add(1, 1) << std::endl;
std::cout << k << " " << v << std::endl;
// 打印结果:
// 90
// 5 9
我们也可以组合使用,比如我想要值传递捕获除了k以外的所有变量,但是k我以引用传递捕获。
cpp
int k = 4;
int v = 8;
auto add = [=, &v](int a, int b) mutable { ++k, ++v; return (a + b) * k * v; };
std::cout << add(1, 1) << std::endl;
std::cout << k << " " << v << std::endl;
// 打印结果:
// 90
// 4 9
又或者我想要引用传递捕获除了v以外的所有变量,但是v我以值传递捕获。
cpp
int k = 4;
int v = 8;
auto add = [&, v](int a, int b) mutable { ++k, ++v; return (a + b) * k * v; };
std::cout << add(1, 1) << std::endl;
std::cout << k << " " << v << std::endl;
// 打印结果:
// 90
// 5 8
如果我们在lambda表达式中想要调用全局函数,淡然可以直接调用,但是想要调用其他的局部函数的话,我们也要捕获,因为其他的lambda表达式本质也是一个个对象。
cpp
auto add = [&, v](int a, int b) { return a + b; };
auto red = [add](int a, int b) { std::cout << add(a, b) << std::endl; return (a - b); };
red(1, 1);
需要注意mutable的问题,可修改的可以传给可修改和不可修改的,但是不可修改的不能传给可修改的,权限不能放大。
lambda的类型到底是什么,我们之前都是用的auto,我们先看这段代码,
cpp
auto f1 = [](int a, int b) { return a + b; };
auto f2 = [](int a, int b) { return a + b; };
f1 = f2; // 错误,不能赋值
为什么会这样呢?typeid打印一下。
cpp
auto f1 = [](int a, int b) { return a + b; };
auto f2 = [](int a, int b) { return a + b; };
std::cout << typeid(f1).name() << std::endl;
std::cout << typeid(f2).name() << std::endl;
// 打印结果:
// class `int __cdecl main(void)'::`2'::<lambda_1>
// class `int __cdecl main(void)'::`2'::<lambda_2>
其实lambda的底层还是仿函数,只不过是编译器帮我们实现的,我们定义一个lambda表达式对象,编译器帮我们生成一个仿函数,为了防止冲突,所以每一个命名都使用了uuid防止命名冲突,即使两个仿函数的实现一模一样。uuid在编译时生成,所以我们无法得知这个lambda的类型到底是什么,所以我们只能使用auto。
cpp
f1(1, 1);
0005266E push 1
00052670 push 1
00052672 lea ecx,[f1]
00052675 call `main'::`2'::<lambda_1>::operator() (052360h)
0005267A nop
f2(1, 1);
0005267B push 1
0005267D push 1
0005267F lea ecx,[f2]
00052682 call `main'::`2'::<lambda_2>::operator() (0523B0h)
00052687 nop
两个的汇编代码,可以看到确实call了operator()。
可变参数模板
C++11允许我们定义可变参数模板,即创建可以接受可变参数的函数模板和类模板,下面就是一个基本可变参数的函数模板。
cpp
// Args是一个模板参数包,args是一个函数形参参数包
// 声明一个参数包Args... args,这个参数包中可以包含0到任意个模板参数。
template <class ...Args>
void func(Args... args)
{}
我们用它来写一个简单的函数
cpp
template<class ...Args>
void func(Args... args)
{
std::cout << sizeof...(args) << std::endl;
}
int main()
{
func(1, 2, 3);
return 0;
}
这个函数可以打印输入参数的个数,注意sizeof...是一个单独的运算符,专门用来编译时计算参数包个数的。
如果我们想要打印参数包的内容该怎么办呢?注意我们不能像访问数组一样直接[]解析,我们得这么写,
cpp
template<class T>
void func(T val)
{
std::cout << val << std::endl;
}
template<class T, class ...Args>
void func(T val, Args... args)
{
std::cout << val << std::endl;
func(args...);
}
int main()
{
func(1, 2, 3);
return 0;
}
看起来挫爆了,但是不得不这样,我们得用一个模板参数做辅助,利用模板编译时的推演,将参数包中的值一步步推出来。这里要注意的点是,我们还得注意这个的终止条件,即当参数只剩一个的时候,只打印不去递归调用了。args...表示展开这个参数包,只有这样才能接着调用下一层。这里我们不能像一般的递归一样采用if语句,因为模板的推导是编译时就发生了,if是运行时逻辑,编译器在编译阶段需要实例化所有可能被调用的函数模板,这里可能用到void func(T val),所以必须得有。
我们在实际封装时,可能不想要单独一个T模板,那么此时我们可以再封装一层,
cpp
void _func()
{
return;
}
template<class T, class ...Args>
void _func(T val, Args... args)
{
std::cout << val << std::endl;
_func(args...);
}
template<class ...Args>
void func(Args... args)
{
_func(args...);
}
int main()
{
func(1, 2, 3);
return 0;
}
需要注意的是因为参数包可以为0,所以我们需要保证这种情况也能调用到函数,所以我们实现了void _func(),并且此时由于无形间也完成了函数的终止条件,所以void func(T val)可以不用写。
除此之外,我们还能这么写:
cpp
void _func()
{
return;
}
template<class T>
void _func(T t)
{
std::cout << t << std::endl;
}
template<class ...Args>
void func(Args... args)
{
int a[] = { (_func(args), 0)... };
}
int main()
{
func(1, 2, 3);
return 0;
}
这个是什么意思呢?数组没有写明大小,所以会不断的推演,配合void _func(T t)把参数包的值一步步推演出来,因为逗号表达式会返回最后一个的值,所以总是返回0。只能说这个表达式真是脑洞大开。
可变参数模板还可以这么玩,
cpp
struct Date
{
int _year;
int _month;
int _day;
Date(int year = 1, int month = 1, int day = 1) : _year(year), _month(month), _day(day) {}
};
template<class ...Args>
void func(Args... args)
{
Date* d = new Date(args...);
}
int main()
{
func(2025, 12, 31);
func(2025, 12);
func(2025);
func();
func(Date{ 2025, 12, 31 });
func(Date{ 2025, 12 });
func(Date{ 2025 });
return 0;
}
可以看到,参数包可以很智能地匹配各种重载函数,这使得我们可以很方便的在函数外传各种参数初始化Date对象,这也是emplace系列的原理。
STL中的一些变化
首先是增加了新容器

array是类似固定数组的类,其利用了非类型模板参数,相比较而言对于数组越界的检查更严格。
forward_list是 C++11 引入的单向链表容器,占用内存很少,每个元素只包含指向下一个元素的指针。但是因为单向链表的关系,接口方面有很大限制,

可以看到没有尾插删,因为效率太差,需要遍历整个链表。insert和erase也是插后面的,因为每个节点只有一个指向后面节点的指针,所以往后插删更方便。forward_list因为这样的限制,其实使用的人不多,唯一的优点是剩内存,但是现如今除了嵌入式设备之外正常的内存空间已经很富裕了,只能说没有必要。
其余的都是老熟人了,不做介绍。
除此之外,就是容器中增加了新的接口,这在之前的右值引用、可变参数模板那已经将的很清楚了。
新的类功能
之前我们都说C++的类在默认情况下会生成6个成员函数,在C++11之后,新增了两个:移动构造函数 和移动赋值运算符重载。
对于移动构造函数,如果你没有自己实现移动构造函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个。那么编译器会自动生成一个默认移动构造。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动构造,如果实现了就调用移动构造,没有实现就调用拷贝构造。
对于移动赋值重载函数,如果你没有自己实现移动赋值重载函数,且没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个,那么编译器会自动生成一个默认移动赋值。默认生成的移动构造函数,对于内置类型成员会执行逐成员按字节拷贝,自定义类型成员,则需要看这个成员是否实现移动赋值,如果实现了就调用移动赋值,没有实现就调用拷贝赋值。
移动构造函数和移动赋值重载函数默认生成条件一致,原理相同,这里拿移动构造函数举例。
为什么需要额外设置没有实现析构函数 、拷贝构造、拷贝赋值重载中的任意一个这样一个条件呢?两个原因:
(1)由于默认生成的成员函数在进行拷贝时,会对内置类型进行值拷贝,对自定义类型调用对应的拷贝构造,所以如果类本身不管理着特殊的资源,是不用特意自己实现的。如果实现了析构函数 、拷贝构造、拷贝赋值重载中的任意一个,可以认为这个类就是有特殊的资源要管理,那么这种情况自己就应该全部实现析构函数 、拷贝构造、赋值运算符重载、移动构造函数、移动赋值运算符重载这五个函数,这时如果没有,编译器自己默认实现了,让代码跑通了反而可能帮倒忙,所以这里就不会生成了。
(2)兼容性考虑,语言的更新需要背负历史包袱,C++11之前的代码没有这两个默认成员函数,如果有特殊的资源要管理,且已经实现了析构函数 、拷贝构造、拷贝赋值重载,这时如果换用了新版本,默认生成了这两个函数,这时只是浅拷贝,就可能因此导致代码出现bug,这是万万不能接受的。
当然,如果我们就是想要编译器生成默认的,我们也能这样,
cpp
class A
{
public:
A(A&& a) = default;
};
函数后面加=default和=delete也是C++11引入的新特性,表示编译器生成该函数的默认实现和不生成该函数的默认实现。
包装器
function包装器也叫作适配器。C++中的function本质是一个类模板,也是一个包装器。在lambda表达式出来之后,形如func()这样的表达式可以是这三种:函数指针、仿函数、lambda。我们在实际写代码当中,可能因此导致模板实例化多份导致效率低下,比如:
cpp
template<class F>
int use_F(F f, int a)
{
static int var = 0;
++var;
std::cout << var << std::endl;
std::cout << &var << std::endl;
return f(a);
}
struct functor
{
int operator()(int a) { return a; }
};
int func(int a) { return a / 2; }
int main()
{
int a = 8;
std::cout << use_F(functor(), a) << std::endl;
std::cout << use_F(func, a) << std::endl;
std::cout << use_F([](int a) { return a / 4; }, a) << std::endl;
return 0;
}
// 打印结果:
// 1
// 00007FF70CD5F5E4
// 8
// 1
// 00007FF70CD5F5EC
// 4
// 1
// 00007FF70CD5F5F8
// 2
三者混用不仅会导致效率上的问题,我们在实际写代码时也确实不够方便。比如我们在网络编程等场景中常常会有注册函数的场景,即将一个操作和一个条件绑定,满足这个条件时就调用对应的函数,这就要求我们得提前保存好对应的函数,这在以前我们会用回调函数的方式完成,但是这就有一个问题,我们用一个容器去存的话,存函数指针就只能存函数指针,存仿函数就只能存仿函数,lambda由于说不清类型还用不了。可以发现,写是能写,就是比较麻烦。所以我们可以使用包装器,
cpp
template <class T> function; // undefined
template <class Ret, class... Args> class function<Ret(Args...)>;
我们直接使用一下,
cpp
std::function<int(int)> func1 = functor();
std::function<int(int)> func1 = func;
std::function<int(int)> func1 = [](int a) { return a / 4; };
function采用模板特化的方式使其接收函数返回值(参数类型)形式的模板参数,可以看到,function既可以被函数指针赋值,也可以被仿函数赋值,也能被lambda赋值,这就是它的方便之处了。
以后我们传函数指针、仿函数、lambda时,可以统一用包装器封装,这样可以避免模板多次实例化,
cpp
std::function<int(int)> func1 = functor();
std::function<int(int)> func2 = func;
std::function<int(int)> func3 = [](int a) { return a / 4; };
std::cout << use_F(func1, a) << std::endl;
std::cout << use_F(func2, a) << std::endl;
std::cout << use_F(func3, a) << std::endl;
// 打印结果:
// 1
// 00007FF7D823F5E4
// 8
// 2
// 00007FF7D823F5E4
// 4
// 3
// 00007FF7D823F5E4
// 2
可以看到,这样就只用实例化function的就行了。
我们也能将这三个用包装器封装保存在容器中,方便使用。
cpp
std::unordered_map<std::string, std::function<int(int, int)>> channel;
channel["+"] = [](int a, int b) { return a + b; };
channel["-"] = [](int a, int b) { return a - b; };
channel["*"] = [](int a, int b) { return a * b; };
channel["/"] = [](int a, int b) { return a / b; };
bind
bind函数定义在头文件中,是一个函数模板,它就像一个函数包装器(适配器),接受一个可调用对象(callable object),生成一个新的可调用对象来"适应"原对象的参数列表。一般而言,我们用它可以把一个原本接收N个参数的函数fn,通过绑定一些参数,返回一个接收M个(M可以大于N,但这么做没什么意义)参数的新函数。同时,使用bind函数还可以实现参数顺序调整等操作。
cpp
template <class Fn, class... Args>
/* unspecified */ bind (Fn&& fn, Args&&... args);
我们来用一下,
cpp
void PRINT(char a, char b, char c, char d)
{
printf("a: %c, b: %c, c: %c, d: %c.\n", a, b, c, d);
}
int main()
{
std::function<void(char, char)> func1 = std::bind(PRINT, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
func1('c', 'd');
return 0;
}
如上,bind可以将一个原本接收4个参数的函数包装成接受两个,前两个在bind中给出,这样之后的调用前两个参数永远都是绑定的这两个,后面未绑定的要用占位符占好位,这里的占位符我们用std::placeholders命名域中的,这是C++为我们准备好的,专门用于bind的占位符,占位符从_1开始,顺次往后,最大估计上百,肯定够用。
我们需要对占位符有一个深刻的理解,std::placeholders::_1并不是表示第一个空位,它的意思是包装后的函数传的第一个参数,什么意思?bind函数并没有创造一个全新的函数,而是记录下原本的函数以及绑定的参数还有占位符,其本身会生成一个类,这个类也是一个仿函数,
cpp
func1('c', 'd');
00007FF7B627A222 mov r8b,64h
00007FF7B627A225 mov dl,63h
00007FF7B627A227 lea rcx,[func1]
00007FF7B627A22B call std::_Func_class<void,char,char>::operator() (07FF7B62712CBh)
00007FF7B627A230 nop
在调用operator()时将接收的参数配合绑定的参数调用原本的函数,接受的第一个参数传给占位符std::placeholders::_1占位的位置,以此类推。所以如果我们这样,
cpp
std::function<void(char, char)> func1 = std::bind(PRINT, 'a', 'b', std::placeholders::_1, std::placeholders::_1);
func1('c', 'd');
// 打印结果: a: a, b: b, c: c, d: c.
那么结果就是传的第二个参数没有用上,因为占位符全给的_1,也就是将func1的第一个参数传给PRINT的后两个参数。
同样的,我们也能使用这个原理调换函数参数的位置,
cpp
std::function<void(char, char)> func1 = std::bind(PRINT, 'a', 'b', std::placeholders::_2, std::placeholders::_1);
func1('c', 'd');
// 打印结果: a: a, b: b, c: d, d: c.
这就是它的灵活之处。
需要注意bind绑定的参数的位置很讲究的,前后顺序直接决定了传给原函数的顺序,不论是参数还是占位符都是如此,第一个位置就是对应第一个参数,以此类推,我们利用这种位置对应的特性,一个占位符的实际意义,就能对函数指针、仿函数、lambda、包装器(function包装后可以用function接收,哈哈)进行包装。
此外,bind类的成员函数时还有几点注意事项:
(1)对于静态成员函数,如果是在类外bind则需要加上类域。
cpp
class Print
{
public:
static void PRINT(char a, char b, char c, char d)
{
printf("a: %c, b: %c, c: %c, d: %c.\n", a, b, c, d);
}
void test()
{
std::function<void(char, char)> func = std::bind(PRINT, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
func('c', 'd');
}
};
int main()
{
Print pt;
std::function<void(char, char)> func = std::bind(Print::PRINT, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
pt.test();
func('c', 'd');
// 打印结果:
// a : a, b : b, c : c, d : d.
// a : a, b : b, c : c, d : d.
return 0;
}
(2)非静态成员函数名前面要加&转化成函数指针,不能隐式转换,这是规定。此外,由于非静态成员函数第一个参数是this指针,所以注意别忘记绑定了。还有,非静态成员函数即使在类内也要加类域,不然会识别成静态成员函数,这也是规定。
cpp
class Print
{
public:
void PRINT(char a, char b, char c, char d)
{
printf("a: %c, b: %c, c: %c, d: %c.\n", a, b, c, d);
}
void test()
{
std::function<void(char, char)> func = std::bind(&Print::PRINT, this, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
func('c', 'd');
}
};
int main()
{
Print pt;
std::function<void(char, char)> func = std::bind(&Print::PRINT, &pt, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
pt.test();
func('c', 'd');
// 打印结果:
// a : a, b : b, c : c, d : d.
// a : a, b : b, c : c, d : d.
return 0;
}
最后,我们还要注意的是,非静态成员函数那里我们也可以不绑this,直接传对象也行,因为bind生成的类会用这个对象或指针去调用对应的函数,只不过传指针->调用,传对象.调用罢了。
cpp
class Print
{
public:
void PRINT(char a, char b, char c, char d)
{
printf("a: %c, b: %c, c: %c, d: %c.\n", a, b, c, d);
}
void test()
{
std::function<void(char, char)> func = std::bind(&Print::PRINT, this, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
func('c', 'd');
}
};
int main()
{
Print pt;
std::function<void(char, char)> func1 = std::bind(&Print::PRINT, pt, 'a', 'b', std::placeholders::_1, std::placeholders::_2);
std::function<void(char, char)> func2 = std::bind(&Print::PRINT, Print(), 'a', 'b', std::placeholders::_1, std::placeholders::_2);
pt.test();
func1('c', 'd');
func2('c', 'd');
// 打印结果:
// a : a, b : b, c : c, d : d.
// a : a, b : b, c : c, d : d.
// a : a, b : b, c : c, d : d.
return 0;
}
智能指针
智能指针内容较多,单独写成了一篇文章,详情见:【C++11】智能指针详解。