C++11初探:C++发展史上的里程碑
作者:jinyishu_
一、C++11:十年磨一剑
如果你问一个C++程序员哪个版本的C++最重要,他大概率会脱口而出:C++11。
这不是没有道理的。在C++11之前,C++98/03标准已经用了整整十几年。这期间,其他编程语言如同雨后春笋般涌现:Java凭借JVM的跨平台和自动垃圾回收攻城略地,Python靠着简洁的语法和无数的第三方库异军突起,C#在Windows生态里如鱼得水。而C++呢?还是那套"老古董"标准,程序员们一边用着"落后的生产力",一边眼巴巴望着Boost库里的好东西进不了标准。
终于,在2011年,C++11标准正式发布。这个版本的更新量之大,被人戏称为"像是一门新语言"。C++之父Bjarne Stroustrup甚至说:C++11 feels like a new language.
这次更新涵盖了核心语言、标准库两大层面的海量特性。本文将从 C++11的历史背景 讲起,一路深入到 统一初始化、右值引用、移动语义、万能引用、完美转发 这些核心机制,帮助你奠定理解C++11的基础。
二、统一的列表初始化
2.1 C++98的初始化之痛
C++98里,初始化语法简直可以用"五花八门"来形容:
// C++98支持的初始化方式
int a1[] = { 1, 2, 3, 4, 5 }; // 数组可以用{}
int a2[5] = { 0 }; // 部分初始化
Point p = { 1, 2 }; // 聚合体(struct)可以用{}
int x = 2; // 基本类型用=
// 但是STL容器呢?
vector<int> v; // 只能先构造空容器
v.push_back(1); // 再一个个插入
v.push_back(2);
v.push_back(3);
// 没法像数组一样 vector<int> v = {1, 2, 3}; 😢你能发现什么问题?数组能用 {},结构体能用 {},偏偏 vector 不能用 {}! 这种不一致性让无数新手抓狂,也让老手在写代码时心里总绷着一根弦。
2.2 C++11:一切皆可{}
C++11引入了 std::initializer_list,彻底统一了初始化语法:
#include <vector>
#include <map>
#include <string>
#include <iostream>
using namespace std;
struct Point {
int _x;
int _y;
};
class Date {
public:
Date(int year = 1, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {
cout << "Date(int year, int month, int day)" << endl;
}
Date(const Date& d)
: _year(d._year), _month(d._month), _day(d._day) {
cout << "Date(const Date& d)" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
// ===== 内置类型也支持{} =====
int x1 = { 2 }; // C++11新支持
int x2 = 2; // 传统方式
// ===== 自定义类型支持{} =====
// {2025, 1, 1}构造一个Date临时对象,然后拷贝给d1
// 但编译器通常会优化成直接构造d1(拷贝省略)
Date d1 = { 2025, 1, 1 };
Date d20(2025, 1, 1); // 等价写法
// 引用绑定到{}创造的临时对象上
const Date& d2 = { 2024, 7, 25 };
// 隐式类型转换也支持{}
Date d3 = { 2025 }; // C++11 style
Date d4 = 2025; // C++98 style(单参数隐式转换)
// ===== 省略=号也OK =====
Point p1{ 1, 2 };
int x3{ 2 };
Date d6{ 2024, 7, 25 };
const Date& d7{ 2024, 7, 25 };
// ===== STL容器的好日子来了 =====
vector<int> v1{ 1, 2, 3, 4 };
vector<int> v2{ 10, 20, 30, 1, 1, 1, 1, 1, 1, 1, 1, 1 };
const vector<int>& v4{ 10, 20, 30, 1, 1, 1, 1, 1, 1, 1, 1, 1 };
// 函数传参时用{}简直不要太爽
vector<Date> v;
v.push_back(d1); // 传已有对象
v.push_back(Date(2025, 1, 1)); // 传匿名对象
v.push_back({ 2025, 1, 1 }); // {}语法——最短最直观!
map<string, string> dict;
dict.insert({ "xxx", "yyyy" }); // 键值对一气呵成
// 甚至可以直接用{}初始化整个map
map<string, string> dict2 = {
{ "xxx", "yyyy" },
{ "sort", "zzzz" }
};
return 0;
}2.3initializer_list究竟是何方神圣?
你以为 {1, 2, 3, 4} 是啥?它本质上就是一个 std::initializer_list<int> 对象。
initializer_list<int> il = { 10, 20, 30, 1, 1, 1, 1, 1, 1, 1, 1, 1 };
cout << typeid(il).name() << endl; // 看看它的真名
initializer_list 的背后,编译器会帮你在栈上创建一个底层常量数组,然后 initializer_list 对象只是保存了指向这个数组的起始指针和长度。所以它非常轻量,拷贝一个 initializer_list 也只是拷贝指针和长度,不会拷贝实际数据。
为了让自定义容器也支持 {} 初始化,你只需要添加一个接受 initializer_list 的构造函数:
template<class T>
class list {
public:
list(initializer_list<T> il) {
empty_init();
for (auto& e : il) {
push_back(e);
}
}
// ...
};
// 现在就可以这样用了!
list<int> lt0({ 1, 2, 3, 4, 5, 6 }); // 直接构造
list<int> lt1 = { 1, 2, 3, 4, 5, 6 }; // 隐式类型转换小结:初始化列表统一了C++的初始化语法,解决了"数组能用{}容器不能用"的历史遗留问题。它让代码更简洁、更一致,是C++11最接地气的改进之一。
三、左值与右值
在深入右值引用之前,我们必须先搞清楚一个根本问题:什么算是左值,什么算是右值?
请注意:赋值号左边的不一定是左值,赋值号右边的也不一定是右值。
最靠谱的判断标准其实很简单:
能不能用
&取地址?能取地址的就是左值,不能的就是右值。
我们来验证一下:
int main() {
int* p = new int(0);
int b = 1;
const int c = b;
*p = 10;
string s("111111");
s[0] = 'x';
// 下面这些都能取地址——它们都是左值
cout << &b << endl; // b是左值 ✓
cout << &c << endl; // const变量也是左值 ✓
cout << &p << endl; // 指针变量是左值 ✓
cout << &(*p) << endl; // *p解引用后是左值 ✓
cout << &s << endl; // string对象是左值 ✓
cout << (void*)&s[0] << endl; // 字符引用是左值 ✓
}再看看哪些不能取地址——它们就是右值:
int main() {
double x = 1.1, y = 2.2;
// 下面这些都不能取地址——它们都是右值
10; // 字面常量是右值
x + y; // 表达式结果是右值
fmin(x, y); // 函数返回值是右值
string("11111"); // 匿名对象是右值
// 下面这些代码全部编译报错
// cout << &10 << endl; // error!
// cout << &(x + y) << endl; // error!
// cout << &(fmin(x, y)) << endl; // error!
// cout << &string("11111") << endl; // error!
}左值 = 有明确内存地址、生命周期持续存在的对象。变量、解引用指针、数组元素等都属于左值。
右值 = 临时性的、没有稳定地址的表达式结果。字面常量、表达式临时结果、匿名对象等都属于右值。
四、左值引用与右值引用
4.1 左值引用T&
左值引用大家都很熟悉了,它可以绑定到左值上:
int main() {
int* p = new int(0);
int b = 1;
string s("111111");
// 左值引用绑定到左值——正常工作
int& r1 = b; // ✓
int*& r2 = p; // ✓
int& r3 = *p; // ✓
string& r4 = s; // ✓
char& r5 = s[0]; // ✓
}普通左值引用不能直接绑定到右值:
int& r = 10; // 编译错误!
但 const左值引用可以绑定到右值,这是C++98就有的规则:
const int& rx1 = 10; // ✓ const左值引用能绑定右值
const double& rx2 = 1.1 + 2.2; // ✓
const double& rx3 = fmin(1.1, 2.2); // ✓
const string& rx4 = string("hello"); // ✓
这个特性非常重要——它使得我们可以用const引用来接收临时对象,避免了不必要的拷贝,同时也延长了临时对象的生命周期。
4.2 右值引用T&&
C++11引入了右值引用,它可以直接绑定到右值:
int main() {
double x = 1.1, y = 2.2;
int&& rr1 = 10; // ✓ 绑定到字面常量
double&& rr2 = x + y; // ✓ 绑定到表达式结果
double&& rr3 = fmin(x, y); // ✓ 绑定到函数返回值
string&& rr4 = string("11111"); // ✓ 绑定到匿名对象
}那右值引用能不能绑定到左值呢?直接绑不行,但用 std::move() 就可以绑定了。
int main() {
int b = 1;
int* p = new int(0);
string s("hello");
// 右值引用直接绑左值 → 编译错误
// int&& rr = b; // error!
// 用move()把左值转成右值(xvalue),就能绑了
int&& rrx1 = move(b); // ✓
int*&& rrx2 = move(p); // ✓
int&& rrx3 = move(*p); // ✓
string&& rrx4 = move(s); // ✓
string&& rrx5 = (string&&)s; // 等价于move——强制类型转换
}4.3 一个极易忽略的细节
右值引用变量本身,是一个左值!
这个结论可能让你觉得绕,但它是理解后续所有内容的基础。来看看为什么:
int main() {
int&& rr1 = 10;
// rr1是一个右值引用变量,它有自己的名字和地址
cout << &rr1 << endl; // 能取地址 → rr1是左值!
// 所以:rr1可以绑定到左值引用
int& r6 = rr1; // ✓ 没问题
// 但不能再次绑定到右值引用(即使它"长着"右值引用的脸)
// int&& rrx6 = rr1; // error! rr1是左值
// 如果你想再转成右值,需要move一下
int&& rrx6 = move(rr1); // ✓
return 0;
}这个"身份转换"是很多初学者踩坑的地方。记住一句话:有名字的右值引用是左值,没名字的右值(临时对象、表达式结果)才是纯右值。
五、移动语义:把深拷贝变成"资源掠夺"
5.1 问题从何而来?
来看一个场景:写一个函数返回一个大对象,或者向容器中插入一个即将销毁的对象。
// 自定义的简化版string类
namespace jys {
class string {
public:
string(const char* str = "")
: _size(strlen(str)), _capacity(_size) {
cout << "string(char* str)——构造" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 拷贝构造——深拷贝,代价大!
string(const string& s) : _str(nullptr) {
cout << "string(const string& s) —— 深拷贝构造" << endl;
reserve(s._capacity);
for (auto ch : s) {
push_back(ch);
}
}
// 拷贝赋值——也是深拷贝
string& operator=(const string& s) {
cout << "string& operator=(const string& s) —— 深拷贝赋值" << endl;
if (this != &s) {
_str[0] = '\0';
_size = 0;
reserve(s._capacity);
for (auto ch : s) {
push_back(ch);
}
}
return *this;
}
~string() { delete[] _str; _str = nullptr; }
// ... 其他成员函数省略
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
}现在考虑这样一个函数:
class Solution {
public:
jys::string addStrings(jys::string num1, jys::string num2) {
jys::string str;
// ... 计算过程,把结果存到str里 ...
return str; // 返回——会发生什么?
}
};
在C++98中,return str 会触发拷贝构造——把 str 的内容完整复制一份到返回值中,然后 str 自己析构。str都要析构了,我们还要老老实实深拷贝它。
同样的问题也出现在 vector::push_back、list::push_back 等场景——我们把一个临时对象插入容器,明明原来的对象马上就没人用了,还要深拷贝一次,完全是浪费。
C++98对于这个问题束手无策,只能依赖编译器的返回值优化(RVO/NRVO)来救场,但优化不是总能生效的。
5.2 移动构造与移动赋值:不要让到嘴的鸭子飞了
C++11给出了一个彻底的解决方案:移动语义。核心思想很简单——既然你的生命周期马上就要结束,那你的东西自然就归我了,直接用就行,费那劲深拷贝干啥?
来看看移动构造和移动赋值的写法:
// 移动构造
string(string&& s) {
cout << "string(string&& s) —— 移动构造" << endl;
swap(s); // 直接把s的内部资源"偷"过来
// s现在指向原本属于我的空资源
// s析构时会安全地delete[] nullptr
}
// 移动赋值
string& operator=(string&& s) {
cout << "string& operator=(string&& s) —— 移动赋值" << endl;
swap(s); // 同理,直接交换资源
return *this;
}就这么简单!用 swap 交换资源的所有权——原对象的资源归我了,我原有的资源给对方(反正对方马上就要析构了,让它帮我清理垃圾)。
我们来对比一下效果:
int main() {
jys::string s1("11111111111111111");
// 拷贝构造:深拷贝,分配新内存 + 逐字符复制
jys::string s3 = s1;
// 移动构造:只交换指针,O(1)复杂度!
jys::string s4 = jys::string("222222222");
// move(s1)把s1强制转成右值,触发移动构造
// 注意:之后s1就变成空的了,不要再使用
jys::string s5 = move(s1);
return 0;
}输出:
string(char* str)——构造 ← s1的构造 string(const string& s) —— 深拷贝构造 ← s3 = s1,深拷贝 string(char* str)——构造 ← 匿名对象构造 string(string&& s) —— 移动构造 ← s4,移动走匿名对象的资源 string(string&& s) —— 移动构造 ← s5,移动走s1的资源
移动构造只做了一件事:交换指针。深拷贝的O(n)直接降到了O(1),这对于大对象来说是天壤之别。
5.3 回到最初的例子
还记得那个 addStrings 函数吗?在C++11下:
int main() {
// 妙啊——函数内局部变量str在return时,
// 编译器会优先选择移动构造(如果已定义的话)
jys::string ret = Solution().addStrings(
"11111111111111111111",
"222222222222222222222222222"
);
cout << ret.c_str() << endl;
return 0;
}有了移动构造,即使编译器不做RVO优化,return一个局部变量也是移动而非拷贝——因为局部变量在return时会被自动识别为右值。
再来看一个传参和插入容器的场景:
int main() {
jys::string s1("11111111111");
vector<jys::string> v;
v.push_back(s1); // s1是左值 → 拷贝
v.push_back(move(s1)); // move后是右值 → 移动!
v.push_back("22222222222"); // 临时对象是右值 → 移动!
return 0;
}小结:移动语义让C++程序在不改变使用方式的前提下获得了巨大的性能提升。STL容器(vector、string、map等)在C++11中全面支持了移动语义,很多以前必须深拷贝的场景现在自动变成了廉价的指针交换。
六、万能引用与引用折叠
6.1 一个神奇的现象
先来看两行代码:
template<class T>
void f1(T& x) {} // 只能接收左值
template<class T>
void f2(T&& x) {} // 既能接收左值,也能接收右值?!诶,T&& 不是右值引用吗?怎么 f2 既能接收左值也能接收右值?
int main() {
int n = 0;
f2(n); // n是左值 → 居然可以!实参类型变成 int&
f2(0); // 0是右值 → 实参类型是 int&&
}这就是C++11中万能引用(Universal Reference)。规则是这样的:
当
T&&出现在模板中,且T是被推导的类型参数时,它就不是普通的右值引用,而是万能引用——它可以根据传入的实参类型,自动变成左值引用或右值引用。
具体来说:
- 传入左值 →
T推导为int&,T&&变成int& - 传入右值 →
T推导为int,T&&变成int&&
6.2 引用折叠——幕后的规则
万能引用的背后是一套叫**引用折叠(Reference Collapsing)**的规则。C++不允许"引用的引用",但当模板推导产生这种情况时,编译器会按照以下规则折叠:
| 折叠前 | 折叠后 |
|---|---|
T& & | T& |
T& && | T& |
T&& & | T& |
T&& && | T&& |
也就是说,只有两个都是右值引用时,结果才是右值引用;其他情况统统是左值引用。
来看具体的例子验证:
void f1(T& x) {} // 模板参数是 T&
void f2(T&& x) {} // 模板参数是 T&&
int main() {
typedef int& lref;
typedef int&& rref;
int n = 0;
// ===== 引用折叠演示 =====
lref& r1 = n; // int& & → int&
lref&& r2 = n; // int& && → int&
rref& r3 = n; // int&& & → int&
rref&& r4 = 1; // int&& && → int&&
// ===== f1(T& x)的实例化情况 =====
f1<int>(n); // T=int,void f1(int& x)
// f1<int>(0); // error! int& 不能绑右值
f1<int&>(n); // T=int&,折叠后 void f1(int& x)
// f1<int&>(0); // error! int& 不能绑右值
f1<int&&>(n); // T=int&&,折叠后 void f1(int& x)
// f1<int&&>(0); // error! int& 不能绑右值
f1<const int&>(n); // T=const int&,void f1(const int& x)
f1<const int&>(0); // ✓ const int& 可以绑右值
// ===== f2(T&& x)的实例化情况 =====
// f2<int>(n); // error! int&& 不能绑左值
f2<int>(0); // T=int,void f2(int&& x)
f2<int&>(n); // T=int&,折叠后 void f2(int& x)
// f2<int&>(0); // error! int& 不能绑右值
// f2<int&&>(n); // error! int&& 不能绑左值
f2<int&&>(0); // T=int&&,void f2(int&& x)
}6.3 自动推导到底推了什么
对于万能引用 T&&,模板实参的推导遵循这个规则:
template<class T>
void Function(T&& t) {
int a = 0;
T x = a; // 这行可以用来"探测"T到底是什么
cout << &a << endl;
cout << &x << endl;
}
int main() {
int a = 0;
Function(10); // 右值 → T=int → void Function(int&& t)
Function(a); // 左值 → T=int& → void Function(int& t)
Function(std::move(a)); // 右值 → T=int → void Function(int&& t)
const int b = 8;
Function(b); // const左值 → T=const int& → void Function(const int& t)
Function(std::move(b)); // const右值 → T=const int → void Function(const int&& t)
return 0;
}注意 const int b 的情况——const 属性是不会丢的,推导时 const 会被保留在 T 中。如果 Function 内部尝试对 x 做修改操作,当传入 const int b 时就会编译报错。
七、完美转发——属性一个都不能少
7.1 为什么要"转发"?
万能引用虽然能同时接收左值和右值,但有一个致命的问题——右值引用变量本身是左值。这意味着:
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 <class T>
void Function(T&& t) {
Fun(t); // 不管传进来的是啥,t本身是左值,Fun(t)永远调左值版本!
}
int main() {
Function(10); // 传右值,希望调Fun(int&&),实际调了Fun(int&) 😭
Function(std::move(a)); // 同样被当左值处理
return 0;
}传进来的明明是右值,为什么转发出去就变成左值了?
问题的根源就是右值引用变量 t 本身是一个左值(我们讲过)。t 有了名字,就可以取地址,所以 Fun(t) 永远会匹配到 Fun(int&)。
我们需要一种机制,能够保持原始参数的"左值/右值"属性不变地转发给下一个函数——这就是完美转发(Perfect Forwarding)。
7.2std::forward——属性还原术
std::forward<T>(t) 的用法很简单:
template <class T>
void Function(T&& t) {
Fun(forward<T>(t)); // 用forward把t的属性"恢复"回去
}
int main() {
// 右值 → T=int → forward<int>(t) → 右值 → Fun(int&&)
Function(10);
int a;
// 左值 → T=int& → forward<int&>(t) → 左值 → Fun(int&)
Function(a);
// 右值 → T=int → forward<int>(t) → 右值 → Fun(int&&)
Function(std::move(a));
const int b = 8;
// const左值 → T=const int& → forward<const int&>(t) → const左值 → Fun(const int&)
Function(b);
// const右值 → T=const int → forward<const int>(t) → const右值 → Fun(const int&&)
Function(std::move(b));
return 0;
}输出:
右值引用 左值引用 右值引用 const 左值引用 const 右值引用
完美!传入什么属性,传出什么属性,一丝一毫都没有丢失。这就是"完美"的含义。
7.3forward是怎么做到的?
forward 的源码其实非常精简(简化版):
// forward一个左值 → 返回左值引用
template<class T>
T&& forward(typename remove_reference<T>::type& arg) noexcept {
return static_cast<T&&>(arg);
}
// forward一个右值 → 返回右值引用
template<class T>
T&& forward(typename remove_reference<T>::type&& arg) noexcept {
return static_cast<T&&>(arg);
}关键在于 static_cast<T&&>(arg) 结合引用折叠规则:
| 调用方式 | T的类型 | T&&折叠后 | 返回 |
|---|---|---|---|
| forward<int>(arg) | int | int&& | 右值引用 |
| forward<int&>(arg) | int& | int& | 左值引用 |
| forward<int&&>(arg) | int&& | int&& | 右值引用 |
T 的类型由调用者通过模板推导或显式指定,完美保留原始参数的值类别属性。
7.4 实战:在list中用完美转发优化插入
来看看完美转发在实际容器类中的应用。下面是一个简化版的list实现,它的 push_back 和 insert 都用了完美转发:
template<class T>
struct list_node {
T _data;
list_node<T>* _next;
list_node<T>* _prev;
// 万能引用 + forward → 根据实参属性选择拷贝或移动
template<class X>
list_node(X&& data)
: _data(forward<X>(data)) // 左值→拷贝,右值→移动
, _next(nullptr)
, _prev(nullptr)
{}
};
template<class T>
class list {
public:
// 万能引用版本——一个函数替代了 const T& 和 T&& 两个重载
template<class X>
void push_back(X&& x) {
insert(end(), forward<X>(x));
}
template<class X>
iterator insert(iterator pos, X&& x) {
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(forward<X>(x));
newnode->_next = cur;
cur->_prev = newnode;
newnode->_prev = prev;
prev->_next = newnode;
++_size;
return newnode;
}
// ...
};使用起来非常自然:
int main() {
jys::list<jys::string> lt;
jys::string s1("11111111111");
lt.push_back(s1); // s1是左值 → 拷贝构造
jys::string s2("33333333333");
lt.push_back(move(s2)); // move后是右值 → 移动构造
lt.push_back("22222222222"); // 字面量是右值 → 移动构造
return 0;
}一个函数模板,同时处理了拷贝和移动两种情况——这就是完美转发 + 万能引用的威力。STL中所有容器的 push_back、emplace_back、insert 等方法都是这样实现的。
八、总结
本文从C++11的历史背景出发,沿着一条清晰的逻辑链讲解了以下核心内容:
初始化列表 (initializer_list)
↓
左值与右值的本质区别(能取地址吗?)
↓
左值引用 T& vs 右值引用 T&&(能绑啥不能绑啥)
↓
移动构造与移动赋值(把深拷贝变"换家")
↓
万能引用 T&& + 引用折叠规则(模板中的变色龙)
↓
完美转发 forward<T>(属性一个不少地传下去)
这些特性不是孤立存在的——它们是C++11为提升性能和简化代码而精心设计的一整套体系:
- 初始化列表让语法更统一
- 右值引用让"识别临时对象"成为可能
- 移动语义让临时对象的传递不再昂贵
- 万能引用+完美转发让泛型代码能写得更简洁且保持高效
读到这里,你对C++11语法的理解是不是更清晰了呢?
到此这篇关于C++11初探:C++发展史上的里程碑的文章就介绍到这了,更多相关C++11语法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
