C++11 是 C++ 的分水岭:它把"现代 C++"需要的语法基础设施一次性补齐了------移动语义、Lambda、
auto、nullptr、智能指针、无序容器。这篇按原文的 16 个考点整理,保留原有编号,修正了原文里的笔误和过时结论,所有可运行示例都用
g++ -std=c++11实际编译验证过。说白了就是:C++11 让 C++ 写起来更像现代语言,跑起来还更快。
C++11 特性总览
| 分类 | 特性 |
|---|---|
| 提高运行效率 | 右值引用与移动语义、泛化的常量表达式(constexpr) |
| 原有语法的易用性增强 | 初始化列表、统一的初始化语法、类型推导(auto / decltype)、范围 for 循环、Lambda 表达式、final 和 override、构造函数委托 |
| 语言能力提升 | 空指针 nullptr、default 和 delete、长整数(long long)、静态断言 static_assert |
| 标准库更新 | 智能指针、正则表达式、哈希表(无序容器)、tuple 等 |
1. 空指针 nullptr
nullptr 出现的目的是为了替代 NULL。
在某种意义上来说,传统 C++ 会把 NULL、0 视为同一种东西,这取决于编译器如何定义 NULL:C 语言里常定义为 ((void*)0),C++ 里则多定义为 0(如 MSVC)或 __null(如 GCC / glibc)。
C++ 不允许直接将 void* 隐式转换到其他类型,所以 C 语言里 ((void*)0) 那种写法在 C++ 中行不通,实现只能换一种方式定义 NULL(0 或 __null)。这依然会产生问题:它会导致 C++ 的重载特性发生混乱。
cpp
void func(int) { std::cout << "func(int)\n"; }
void func(char*) { std::cout << "func(char*)\n"; }
int main() {
func(nullptr); // 明确调用 func(char*)
// func(NULL); // MSVC 下调 func(int),GCC / glibc 下直接重载歧义
}
对于这两个函数来说,如果 NULL 被定义为 0(如 MSVC),func(NULL) 会去调用 func(int);如果被定义为 __null 或 0L(如 GCC / glibc),则直接产生重载歧义。两种结果都违反直觉。
为了解决这个问题,C++11 引入了 nullptr 关键字,专门用来区分空指针和 0:
nullptr的类型为std::nullptr_t;能够隐式地转换为任何指针 或成员指针的类型;
也能和它们进行相等或者不等的比较;
不能 隐式转换为整数(这点和
0不同)。
cpp
#include <type_traits>
static_assert(std::is_same<decltype(nullptr), std::nullptr_t>::value, "");
static_assert(sizeof(nullptr) == sizeof(void*), "");
当需要使用 NULL 的时候,养成直接使用 nullptr 的习惯。
2. Lambda 表达式
Lambda 表达式实际上就是提供了一个类似匿名函数的特性,而匿名函数则是在需要一个函数、但是又不想费力去命名一个函数的情况下使用的。
利用 Lambda 表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象,并且使代码更可读。
从本质上来讲,Lambda 表达式只是一种语法糖,因为所有其能完成的工作都可以用其它稍微复杂的代码来实现,但是它简便的语法却给 C++ 带来了深远的影响。
从广义上说,Lambda 表达式产生的是函数对象 。在类中,可以重载函数调用运算符 (),此时类的对象可以具有类似函数的行为,我们称这些对象为函数对象 (Function Object)或者仿函数 (Functor)。相比 Lambda 表达式,函数对象有自己独特的优势(可以携带状态、可以被继承、可以重载多个 ())。
Lambda 表达式一般都是从方括号 [] 开始,然后结束于花括号 {},花括号里面就像定义函数那样,包含了 Lambda 表达式体。
cpp
// 最简单的 lambda 表达式,没有参数
auto basicLambda = [] { std::cout << "Hello, world!" << std::endl; };
basicLambda();
// 指明返回类型:拖尾返回类型
auto add = [](int a, int b) -> int { return a + b; };
// 自动推断返回类型
auto multiply = [](int a, int b) { return a * b; };
std::cout << add(2, 5) << " " << multiply(2, 5) << std::endl; // 7 10
闭包与捕获
最前边的 [] 是 Lambda 表达式的一个很重要的功能,就是闭包。
Lambda 的大致原理:每当你定义一个 Lambda 表达式后,编译器会自动生成一个匿名类(这个类当然重载了 () 运算符),我们称为闭包类型(closure type)。
在运行时,这个 Lambda 表达式就会返回一个匿名的闭包实例,其实是一个右值 。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕获模式以及变量,我们又称其为 Lambda 捕获块。
cpp
int x = 10;
auto add_x = [x](int a) { return a + x; }; // 复制捕获 x,lambda 内不能修改 x
auto mul_x = [&x](int a) { x += 1; return a * x; }; // 引用捕获 x,可以修改
std::cout << add_x(10) << " " << mul_x(10) << std::endl; // 20 110
捕获的方式可以是引用也可以是复制,具体有以下几种:
| 捕获列表 | 含义 |
|---|---|
[] |
默认不捕获任何变量 |
[=] |
默认以值捕获所有变量 |
[&] |
默认以引用捕获所有变量 |
[x] |
仅以值捕获 x,其它变量不捕获 |
[&x] |
仅以引用捕获 x,其它变量不捕获 |
[=, &x] |
默认以值捕获所有变量,但是 x 是例外,通过引用捕获 |
[&, x] |
默认以引用捕获所有变量,但是 x 是例外,通过值捕获 |
[this] |
通过引用捕获当前对象(其实是复制指针) |
[*this] |
通过传值方式捕获当前对象(C++17) |
注意:值捕获的变量在 Lambda 的
operator()里默认是const的,想修改必须加mutable。
cpp
int n = 0;
auto counter = [n]() mutable { return ++n; };
counter(); // 1
counter(); // 2
// 外部 n 仍然是 0,修改的是闭包里的副本
Lambda 一个更重要的应用是其可以用于函数的参数,通过这种方式可以实现回调函数 。最常用的是在 STL 算法中,比如统计一个数组中满足特定条件的元素数量,通过 Lambda 表达式给出条件,传递给 count_if:
cpp
int val = 3;
std::vector<int> v{1, 8, 5, 3, 6, 10};
int count = std::count_if(v.begin(), v.end(),
[val](int x) { return x > val; });
std::cout << count << std::endl; // 4(8、5、6、10 都大于 3)
完整语法
cpp
[ capture-list ] ( params ) mutable constexpr exception attribute -> ret { body }
// 可选的简化语法
[ capture-list ] ( params ) -> ret { body }
[ capture-list ] ( params ) { body }
[ capture-list ] { body }
capture-list:捕获列表,不能省略;
params:参数列表,可以省略(但是后面必须紧跟函数体);
mutable:可选,标记后函数体可以修改传值方式捕获的变量;
constexpr:可选,C++17 起可以指定 Lambda 是常量函数;
exception:可选,指定 Lambda 可以抛出的异常(动态异常规范在 C++17 已移除,了解即可);
attribute:可选,指定 Lambda 的特性;
ret:可选,返回值类型;
body:函数执行体。
3. 右值引用
C++03 及之前的标准中,右值是不允许被改变的,实践中也通常使用 const T& 的方式传递右值。然而这是效率低下的做法,例如:
cpp
Person get() {
Person p;
return p;
}
Person p = get();
上述获取右值并初始化 p 的过程,在最坏情况 (完全不进行任何复制消除)下包含了 Person 的 3 个构造过程和 2 个析构过程。这是 C++ 广受诟病的一点,但 C++11 的右值引用 特性允许我们对右值进行修改。借此可以实现 move 语义,即从右值中直接拿数据过来初始化或修改左值,而不需要重新构造左值后再析构右值。
这里要纠正一个流传很广的说法:这个"3 构造 2 析构"只是 C++03 在完全不做复制消除 时的理论值。C++17 起,
Person p = get();这种纯右值初始化保证不产生任何拷贝/移动 (guaranteed copy elision);而return p;是具名返回值,属于 NRVO,编译器几乎都会优化掉。实际运行时你通常只会看到 1 次构造、1 次析构。想观察拷贝/移动,需要显式关闭优化(-fno-elide-constructors)。
一个 move 构造函数是这样声明的:
cpp
class Person {
public:
Person() = default;
Person(const Person& rhs) { std::cout << "copy ctor\n"; }
Person(Person&& rhs) noexcept { std::cout << "move ctor\n"; } // 右值引用
};
cpp
std::vector<int> a{1, 2, 3};
std::vector<int> b = std::move(a); // 直接把 a 的堆内存"偷"过来
// a 变为合法但未指定状态(通常为空),b 拥有原来的 3 个元素
几个必须记住的点:
std::move本身不移动任何东西 ,它只是一个static_cast<T&&>,把左值转成右值引用,真正干活的是移动构造/移动赋值;具名的右值引用是左值 :
Person&& r = get();之后r是左值,想继续传递要std::move(r);移动构造函数应标记
noexcept,否则std::vector扩容时会退化为拷贝(因为要保证强异常安全);移动后源对象仍必须处于可析构、可赋值的合法状态,但值未指定,不要依赖。
4. 泛化的常量表达式
还记得刚开始学习 C++ 时的苦恼吗?
cpp
int N = 5;
int arr[N]; // 文件作用域下报错:variable length array declaration not allowed
编译器会报错,但 N 就是 5,只不过编译器不知道这一点,于是我们需要声明为 const int N = 5 才可以。C++11 的泛化常量表达式给出了解决方案:
cpp
constexpr int N = 5;
// N 变成了一个只读的编译期常量
int arr[N]; // OK
constexpr int getFive() { return 5; }
int arr2[getFive() + 1]; // OK,数组大小在编译期确定
constexpr 告诉编译器这是一个编译期常量,甚至可以把一个函数声明为编译期常量表达式。
几个细节:
constexpr变量隐含const,且必须在编译期可求值;C++11 的
constexpr函数体只能有一条 return 语句,C++14 才放宽为普通函数体;
constexpr函数在传入编译期常量时于编译期求值,传入运行期值时退化为普通函数;C++20 又补充了
consteval(必须编译期求值)和constinit。
5. 初始化列表
接下来几个特性属于原有语言特性的易用性增强:这些操作原来也可以实现,不过现在语法上更加简洁。比如首先要介绍的初始化列表。
C++11 提供了 std::initializer_list 来接受变长的对象初始化列表:
cpp
#include <initializer_list>
class A {
public:
A(std::initializer_list<int> list) {
for (int v : list) std::cout << v << ' ';
}
};
A a = {1, 2, 3}; // 输出:1 2 3
注意:初始化列表特性只是现有语法增强 ,并不是提供了动态的可变参数。该列表的元素类型必须一致(或可转换到 T),元素个数在编译期确定,不能像可变参数模板那样在运行期动态展开。
补充:initializer_list 的元素是 const 的,底层通常指向编译器生成的只读数组,不要返回局部 initializer_list。
6. 统一的初始化语法
不同的数据类型具有不同的初始化语法:如何初始化字符串?如何初始化数组?如何初始化多维数组?如何初始化对象?
C++11 给出了统一的初始化语法 :均可使用"{}-初始化变量列表":
cpp
// 假设 X 有 X(int, int) 构造函数
X x1 = X{1, 2};
X x2 = {1, 2}; // 此处的 '=' 可有可无
X x3{1, 2};
X* p = new X{1, 2};
struct D : X {
D(int x, int y) : X{x, y} { /* ... */ }
};
struct S {
int a[3];
// 对于旧有问题的解决方案:数组成员也能在初始化列表里初始化
S(int x, int y, int z) : a{x, y, z} { /* ... */ }
};
三个实用要点:
{}初始化会优先匹配initializer_list构造函数;它禁止窄化转换 ,
int x{3.14};直接编译报错,比()更安全;
std::vector<int> v{10, 2};是"两个元素 10 和 2",而std::vector<int> v(10, 2);是"10 个值为 2 的元素"------这是实际工程里最容易踩的坑之一。
7. 类型推导
C++ 提供了 auto 和 decltype 来静态推导类型,在我们知道类型没有问题但又不想完整地写出类型的时候,便可以使用静态类型推导。
虽然写起来和动态语言(如 JavaScript 的 var)很像,但 C++ 仍然是强类型的,会执行静态类型检查的语言。这只是语法上的简化,并未改变 C++ 的静态类型检查。
cpp
for (std::vector<int>::const_iterator it = v.begin(); it != v.end(); ++it);
// 可以改写为
for (auto it = v.begin(); it != v.end(); ++it);
decltype 用于获取一个表达式的类型,而不对表达式进行求值 (类似于 sizeof)。decltype(e) 规则如下:
若
e为一个无括号的变量、函数参数、类成员,则返回类型为该变量/参数/类成员在源程序中的声明类型;否则根据表达式的值分类(value categories),设
T为e的类型:
若
e是一个左值 (lvalue,即"可寻址值"),返回T&;若
e是一个将亡值 (xvalue),则返回值为T&&;若
e是一个纯右值 (prvalue),则返回值为T。
cpp
const std::vector<int> v(1);
auto a = v[0]; // a 为 int(auto 会丢掉引用和顶层 const)
decltype(v[0]) b = 0; // b 为 const int&
// 即 vector<int>::operator[](size_type) const 的返回值类型
auto c = 0; // c、d 均为 int
auto d = c;
decltype(c) e; // e 为 int,即 c 的类型
decltype((c)) f = e; // f 为 int&,因为带括号的 (c) 是左值
decltype(0) g; // g 为 int,因为 0 是纯右值
记忆口诀:
auto会退化(去引用、去顶层 const),decltype忠实保留;decltype看括号,加括号变左值。
8. 基于范围的 for 循环
Boost 中定义了很多"范围",很多标准库函数都使用了范围风格的实现。这一概念被 C++11 提了出来:
cpp
int arr[5] = {1, 2, 3, 4, 5};
std::vector<int> v{1, 2, 3};
for (int x : arr); // 值拷贝,只读
for (const int& x : arr); // 只读,避免拷贝
for (int x : v);
for (auto& x : v) { x *= 2; } // 引用,可修改
// 只读遍历的推荐写法
for (const auto& x : v) { std::cout << x << ' '; }
本质是语法糖,编译器会展开成迭代器循环。注意:遍历过程中不要修改容器结构 (push_back / erase),否则迭代器失效。
9. 构造函数委托
在 C# 和 Java 中,一个构造函数可以调用另一个来实现代码复用,但 C++ 一直不允许这样做。
现在可以了,这使得构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的:
cpp
class MyBase {
public:
MyBase() : MyBase(0, "invalid") {}
MyBase(int i) : MyBase(i, "guest") {}
MyBase(const std::string& s) : MyBase(1, s) { PostInit(); }
private:
MyBase(int i, const std::string& s) : number_(i), name_(s) {}
void PostInit() { /* ... */ }
int number_;
std::string name_;
};
几个要点:
委托构造函数不能再同时初始化其他成员(初始化列表里只能有那一个委托目标);
委托链不能成环,否则是未定义行为;
被委托的构造函数执行完后,才会执行当前构造函数体(所以
PostInit()里能用到已初始化的成员)。
原文示例里的
myBase(int i, string& s)形参是非 const 引用 ,却用字符串字面量"invalid"去调用,这在 C++ 中无法编译;正确写法是const std::string&。
10. final 和 override
C++ 借由虚函数实现运行时多态,但 C++ 的虚函数又有很多脆弱的地方:
无法禁止子类重写它。可能到某一层级时,我们不希望子类继续来重写当前虚函数了;
容易不小心隐藏父类的虚函数。比如在重写时,不小心声明了一个签名不一致但有同样名称的新函数。
C++11 提供了 final 来禁止虚函数被重写 / 禁止类被继承,override 来显式地重写虚函数。这样编译器给我们不小心行为提供更多有用的错误和警告。
cpp
struct Base1 final { };
// struct Derived1 : Base1 {}; // 编译错:Base1 不允许被继承
struct Base2 {
virtual void f1() final;
virtual void f2();
};
struct Derived2 : Base2 {
// virtual void f1(); // 编译错:f1 不允许重写
// virtual void f2(int) override; // 编译错:父类中没有 void f2(int)
virtual void f2() override; // OK
};
工程建议:重写虚函数一律写
override,它能把你以为重写了、其实只是重载了一个新函数的问题在编译期暴露出来;不希望再被继承的类直接标final。
11. default 和 delete
我们知道编译器会为类自动生成一些方法,比如构造和析构函数(完整列表见 Effective C++: Item 5)。
现在我们可以显式地指定和禁止这些自动行为了:
cpp
class A {
public:
A() = default; // 显式要求编译器生成默认构造
A(int v) : v_(v) {}
A(const A&) = delete; // 禁止拷贝
A& operator=(const A&) = delete; // 禁止拷贝赋值
void* operator new(std::size_t) = delete; // 禁止在堆上 new 出对象
private:
int v_ = 0;
};
原文示例中 classA() = defauult; 是拼写错误(应为 = default),classA(T value) 里的 T 也没有定义,已在上面的可编译版本中修正。
要点:
一旦声明了任何构造函数(如
A(int)),编译器就不再生成默认构造函数 ,需要时用= default显式要回来;
= delete的函数仍参与重载决议,因此new A会得到一个清晰的"use of deleted function"错误,比把它设为private更好;
= default可以写在类内,也可以写在类外(如A::A() = default;),类外写法常用于保持头文件简洁。
12. 静态断言 static_assert
C++ 里常见的断言手段有两种:一种是 assert 宏,另一种是预处理指令 #error。前者在运行期 起作用,而后者是预处理期 起作用。它们对模板都不好使,因为模板是编译期的概念。C++11 因此补充了编译期的 static_assert。
static_assert 关键字的使用方式如下:
cpp
template <class T>
struct Check {
static_assert(sizeof(int) <= sizeof(T), "T is not big enough!");
};
Check<char> c; // 编译错:T is not big enough!
要点:
static_assert在编译期检查,条件为假直接编译失败,不生成运行期代码;C++11 要求必须提供第二个参数(提示字符串),C++17 起可以省略;
它最典型的用途就是配合模板做类型约束、检查
sizeof、对齐、平台假设。
13. 智能指针
接下来介绍 C++11 对于 C++ 标准库的变更。C++11 把 TR1 并入了进来,废弃了 C++98 中的 auto_ptr (C++17 已彻底移除),同时将 shared_ptr 和 unique_ptr 并入 std 命名空间。
cpp
int main() {
std::shared_ptr<double> p_first = std::make_shared<double>(0.0);
{
std::shared_ptr<double> p_copy = p_first; // 引用计数 +1
*p_copy = 21.2;
} // p_copy 被销毁,里面的 double 还有一个引用,因此仍然保持
std::cout << *p_first << std::endl; // 21.2
return 0; // p_first 及其里面的 double 销毁
}
三种智能指针的分工:
| 智能指针 | 语义 | 典型场景 |
|---|---|---|
std::unique_ptr |
独占所有权,不可拷贝、只能移动 | 默认首选,替代裸 new |
std::shared_ptr |
共享所有权,引用计数归零才释放 | 多个对象共享同一资源 |
std::weak_ptr |
不增加引用计数的观察者 | 打破 shared_ptr 循环引用 |
cpp
// C++11 没有 std::make_unique(C++14 才有),这样写:
std::unique_ptr<int> up(new int(42));
// C++14 起推荐:
// auto up = std::make_unique<int>(42);
高频追问:
shared_ptr循环引用会泄漏,因为双方引用计数都不为 0。解决方式是把其中一边改成weak_ptr,用lock()提升为shared_ptr再访问。
14. 正则表达式
这个任何一门现代的编程语言都会提供的特性终于进标准:
cpp
#include <regex>
const char* reg_esp = "[ ,.\\t\\n;:]";
std::regex rgx(reg_esp);
std::cmatch match;
const char* target = "Polytechnic University of Turin ";
if (std::regex_search(target, match, rgx)) {
const size_t n = match.size();
for (size_t a = 0; a < n; a++) {
std::string str(match[a].first, match[a].second);
std::cout << str << "\n";
}
}
用原始字符串可以少写一层转义,更直观:
cpp
std::regex rgx(R"([ ,.\t\n;:])");
要点:正则的编译有成本,循环里反复构造 std::regex 是性能杀手,应把它提到循环外;GCC 4.8 及更早版本的 <regex> 实现不完整,建议 GCC 4.9+。
15. 增强的元组
在 C++ 中本已有一个 pair 模板可以定义二元组,C++11 更进一步地提供了变长参数 的 tuple 模板:
cpp
#include <tuple>
std::tuple<int, double, std::string> t1;
std::tuple<char, short, const char*> t2('X', 2, "Hola!");
t1 = t2; // 隐式逐元素类型转换:char→int,short→double,const char*→std::string
std::cout << std::get<0>(t1) << " " // 88('X' 的 ASCII 码)
<< std::get<1>(t1) << " " // 2
<< std::get<2>(t1) << std::endl; // Hola!
auto t3 = std::make_tuple(1, 2.0, std::string("hi")); // 自动推导元素类型
原文这里的
typedef std::tuple<...> tuple_1 t1;语法是错的(把 typedef 和变量声明混在了一起),上面已改为正确的变量定义写法。另外tuple还支持std::tie解包和std::tuple_size/std::tuple_element编译期查询。
16. 哈希表
C++ 的 map、multimap、set、multiset 使用红黑树 实现,插入和查询都是 O(log n) 的复杂度。
C++11 为这四种模板类提供了底层哈希实现的版本,以达到平均 O(1) 的复杂度:
| 散列表类型 | 是否有映射值(mapped value) | 接受相同键值 |
|---|---|---|
std::unordered_set |
否 | 否 |
std::unordered_multiset |
否 | 是 |
std::unordered_map |
是 | 否 |
std::unordered_multimap |
是 | 是 |
cpp
#include <unordered_map>
std::unordered_map<std::string, int> score;
score["Tom"] = 90; // 平均 O(1)
score.insert({"Jerry", 85});
if (score.find("Tom") != score.end()) { /* ... */ }
for (const auto& kv : score) {
std::cout << kv.first << "=" << kv.second << "\n"; // 顺序不保证
}
必须补充的三点:
复杂度是平均
O(1),最坏情况(大量哈希冲突)是O(n);键类型需要提供
std::hash和operator==,自定义类型要么特化std::hash,要么传自定义哈希/相等函数对象;遍历顺序不保证 ,需要有序输出请用
map或遍历后排序;已知规模时先reserve()可以减少 rehash。
面试速记表
| 特性 | 一句话理解 |
|---|---|
nullptr |
类型 std::nullptr_t,只转指针不转整数,解决 NULL 的重载歧义 |
| Lambda | 编译器生成匿名闭包类并重载 operator();[] 决定捕获方式 |
| 捕获 | [=] 值捕获、[&] 引用捕获、[this] 捕获对象指针;改值捕获副本要 mutable |
| 右值引用 | T&& 绑定右值,配合移动构造"偷"资源;std::move 只是类型转换 |
| 移动构造 | 标记 noexcept,否则容器扩容会退化成拷贝 |
constexpr |
编译期常量/函数;C++11 函数体只能一条 return,C++14 放宽 |
initializer_list |
变长同类型初始化列表,元素 const,个数编译期确定 |
| 统一初始化 | {} 优先匹配 initializer_list,禁止窄化转换 |
auto / decltype |
auto 去引用去顶层 const;decltype 忠实保留,带括号变左值引用 |
| 范围 for | 语法糖;修改元素用 auto&,只读用 const auto& |
| 委托构造 | 构造函数初始化列表里只写一个"同类构造函数",可复用逻辑 |
final / override |
override 让签名不匹配在编译期报错;final 禁止重写/继承 |
= default / = delete |
显式要回/禁用编译器生成的特殊成员函数 |
static_assert |
编译期断言,模板约束利器;C++17 起提示字符串可省略 |
| 智能指针 | unique_ptr 独占首选、shared_ptr 共享计数、weak_ptr 破循环 |
regex |
<regex>,regex_search / regex_match / regex_replace;编译有成本 |
tuple |
std::get<i> 访问,make_tuple 构造,tie 解包 |
| 无序容器 | 平均 O(1)、最坏 O(n)、遍历无序、需 hash 与 == |
参考
-
cppreference:C++ language - cppreference.com
-
Wikipedia C++11:https://zh.wikipedia.org/wiki/C%2B%2B11
-
原文参考:Redirecting...
-
Effective C++(Item 5、Item 13)
下一篇可以继续整理 C++14/17 的增量特性:泛型 Lambda、
make_unique、结构化绑定、if constexpr、折叠表达式。