这篇笔记主要写一下 C++11 的新特性。
C++11 距离上一版标准(C++03)隔了八年,改动之大让很多人说"这像是一门新语言"。它引入的不只是语法糖:移动语义改变了值传递的代价模型,智能指针改变了资源管理的默认做法,Lambda 让标准库算法真正可用。下面按重要性而不是字母顺序来过一遍。
自动类型推导 —— auto
auto 让编译器根据初始化表达式推导变量类型,代码更简洁,也减少了冗余的类型声明。
C++auto x = 10; // int
auto it = vec.begin(); // std::vector<int>::iterator,不用写全名
auto p = std::make_unique<Widget>(); // std::unique_ptr<Widget>
需要注意推导规则:auto 会丢弃引用和顶层 const。
C++const std::string s = "hello";
auto a = s; // std::string(拷贝,const 丢了)
auto& b = s; // const std::string&(保留)
const auto& c = s; // const std::string&,最常用的只读遍历写法
对容器遍历尤其关键:for (auto x : vec) 会拷贝每个元素,容器里装的是 std::string 时这是实打实的开销。默认应该写 for (const auto& x : vec)。
auto 也不是越多越好。返回类型不明显的时候(auto result = compute();),读代码的人得跳到定义才知道是什么。经验规则是:类型显而易见或者写出来很啰嗦时用 auto,否则写清楚。
范围 for 循环
遍历容器的简洁语法:
C++std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto& elem : vec) {
std::cout << elem << std::endl;
}
它展开成基于 begin() / end() 的普通循环。任何提供了这两个成员函数(或者可以通过 ADL 找到自由函数版本)的类型都能用,包括你自己写的类型。
一个陷阱:不要在范围 for 里修改容器的大小。vec.push_back() 可能触发扩容,让内部的迭代器全部失效,行为未定义。
智能指针
C++11 引入 std::unique_ptr 和 std::shared_ptr 自动管理动态内存,避免手动释放带来的错误。
std::unique_ptr:独占所有权,不可拷贝只可移动。零开销——它的大小就是一个裸指针,析构就是一次delete。这应该是默认选择。std::shared_ptr:共享所有权,引用计数管理生命周期。代价不小:对象之外还有一个控制块,引用计数的增减是原子操作。std::weak_ptr:不参与计数的观察者,用来打破shared_ptr的循环引用。
C++auto p = std::make_unique<Widget>(args...); // C++14 才有 make_unique
auto q = std::make_shared<Widget>(args...);
void consume(std::unique_ptr<Widget> w);
consume(std::move(p)); // 所有权转移,p 之后为空
关于循环引用:两个对象互相持有 shared_ptr,引用计数永远不会归零,内存泄漏。把其中一个方向改成 weak_ptr 即可。父子结构里的惯例是——父持有子用 shared_ptr,子指回父用 weak_ptr。
匿名函数 / Lambda
Lambda 表达式可以简洁地定义匿名函数,特别适合回调和简单计算。
C++auto add = [](int a, int b) -> int {
return a + b;
};
std::cout << add(3, 4) << std::endl;
完整形态是 [捕获](参数) 修饰符 -> 返回类型 { 函数体 },其中捕获列表决定了 Lambda 能访问外层的哪些变量:
C++int factor = 3;
auto f1 = [factor](int x) { return x * factor; }; // 按值捕获,拍快照
auto f2 = [&factor](int x) { return x * factor; }; // 按引用捕获,用当前值
auto f3 = [=](int x) { return x * factor; }; // 全部按值
auto f4 = [&](int x) { return x * factor; }; // 全部按引用
按引用捕获要格外小心生命周期。 Lambda 存进成员变量或者异步任务里,而被捕获的局部变量早就出了作用域,就是悬垂引用。默认用按值捕获,需要修改外部状态时再改引用。
Lambda 本质上是编译器生成的一个匿名类,捕获的变量变成它的成员,函数体变成 operator()。所以每个 Lambda 都是独立的类型——两个长得一样的 Lambda 类型也不同,这就是为什么要用 auto 接。需要统一类型时用 std::function,但它有类型擦除的开销。
移动语义与右值引用
C++11 引入移动语义和右值引用,用 && 表示。移动语义让程序可以避免不必要的拷贝来提升性能。
右值引用可以绑定到临时对象(右值),通过移动构造函数或移动赋值运算符转移资源。
C++std::vector<int> vec1 = {1, 2, 3};
std::vector<int> vec2 = std::move(vec1);
// vec1 的资源被转移给 vec2,避免了拷贝
判断左值右值有个简单办法:能取地址的是左值,不能的是右值。具名变量永远是左值——哪怕它的类型是右值引用:
C++void f(std::string&& s) {
g(s); // s 是具名的,这里传的是左值!
g(std::move(s)); // 这才会走 g 的移动版本
}
这一点几乎是所有移动语义 bug 的来源。关于移动构造的正确写法、noexcept 为什么关键,以及 Rule of Five,见《C++ #7 拷贝控制与运算符重载》。
完美转发
模板里的 T&& 不是右值引用,而是万能引用(forwarding reference):传左值时推导成 T&,传右值时推导成 T。配合 std::forward 就能把参数的值类别原样传下去:
C++template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
std::forward 和 std::move 一样只是类型转换,区别在于 move 无条件转成右值,forward 只在原本就是右值时才转。
nullptr 关键字
C++11 引入 nullptr 作为空指针常量,取代 NULL,提供更强的类型安全。
问题出在 NULL 通常就是 0:
C++void f(int);
void f(char*);
f(NULL); // 调用 f(int)!几乎肯定不是你想要的
f(nullptr); // 调用 f(char*)
nullptr 的类型是 std::nullptr_t,能隐式转成任何指针类型,但不能转成整数。新代码里不应该再出现 NULL。
using 关键字与类型别名
using 可以创建类型别名,取代传统的 typedef,在模板场景下更简洁。
C++using int_ptr = int*;
typedef int* int_ptr_old; // 等价
真正的优势是 typedef 做不到的别名模板:
C++template <typename T>
using Dict = std::map<std::string, T>;
Dict<int> counts; // std::map<std::string, int>
而且 using 的读法是从左到右的,函数指针这类复杂声明会清晰很多:
C++typedef void (*Callback)(int, const char*); // 得从中间往外读
using Callback = void (*)(int, const char*); // 名字在左,类型在右
constexpr 关键字
constexpr 用于定义编译期常量。与 const 不同,constexpr 保证表达式在编译期求值。
C++constexpr int square(int x) {
return x * x;
}
constexpr int result = square(5); // 编译期算出 25
两者的区别值得说清楚:const 表示"这个变量之后不会被修改",是只读性;constexpr 表示"这个值在编译期就能确定",是编译期可求值性。const int n = readFromFile(); 是合法的,constexpr int n = readFromFile(); 不是。
C++11 的 constexpr 函数限制很严——函数体基本只能有一条 return,有循环就得写成递归。这个限制在 C++14 被放宽,见《C++ #10 C++14 新特性》。
变参模板
变参模板(variadic template)让模板接受任意数量、任意类型的参数。printf 那种不类型安全的可变参数从此有了替代品。
C++template <typename... Args>
void log(const Args&... args);
typename... Args 是模板参数包,args... 是函数参数包。C++11 里展开参数包要靠递归加一个终止重载:
C++void print() {} // 终止条件
template <typename T, typename... Rest>
void print(const T& first, const Rest&... rest) {
std::cout << first << ' ';
print(rest...); // 剥掉一个,递归处理剩下的
}
print(1, "hello", 3.14); // 输出:1 hello 3.14
sizeof...(Args) 给出包里的元素个数。这种递归写法在 C++17 里可以用折叠表达式一行搞定:(std::cout << ... << args);
变参模板最重要的用途其实在标准库内部:std::make_unique、std::tuple、emplace_back 都靠它,配合完美转发把任意构造参数原样送到目标类型的构造函数。
显式默认与删除函数
C++11 允许显式要求或禁止编译器生成的特殊成员函数。
C++class Widget {
public:
Widget() = default; // 明确要一个默认构造
Widget(const Widget&) = delete; // 禁止拷贝
Widget& operator=(const Widget&) = delete;
Widget(Widget&&) noexcept = default; // 要编译器生成的移动版本
};
在此之前,禁止拷贝的做法是把拷贝构造声明为 private 且不定义——报错发生在链接期,信息还很难懂。= delete 让它变成清晰的编译错误。
= delete 不限于特殊成员函数,还能用来屏蔽不想要的重载:
C++void process(int);
void process(char) = delete; // 防止 char 被隐式提升成 int
其它值得知道的
override 与 final。 显式标记虚函数重写,签名写错时编译器会报错,而不是默默创建一个新函数。这是《C++ #8 多态》里最实用的一条防御措施。
统一初始化。 {} 语法适用于所有类型,并且禁止窄化转换:int x{3.14}; 编译错误,而 int x = 3.14; 只是个警告。注意它与 std::initializer_list 重载的优先级——std::vector<int> v{3, 1} 得到的是两个元素 {3, 1},不是三个 1。
enum class。 强类型枚举,不会隐式转成整数,枚举值也不会泄漏到外层作用域。普通 enum 的两个名字冲突问题就此解决。
标准库线程支持。 std::thread、std::mutex、std::atomic、std::future 进入标准库,C++ 第一次有了平台无关的并发模型和内存模型。
std::array 与 std::unordered_map。 固定大小数组的零开销封装,以及基于哈希表的关联容器。
小结
C++11 里真正改变编码习惯的是三件事:智能指针让裸 new/delete 变成代码异味,移动语义让按值返回不再需要担心开销,Lambda 让标准库算法从"理论上可用"变成"实际好用"。其余特性大多是在这三件事周围做支撑。