这一章在干嘛?

全书的最后一章,也是 C++11 的总结陈词。前 17 章零散介绍过的 C++11 特性在这里先做一轮盘点;然后集中火力讲四个此前没讲透的大件:移动语义与右值引用(C++11 最深刻的性能革新)、lambda 表达式function 包装器可变参数模板。读完这章,你对”C++11 相对于 C++98 多了什么”就有完整地图了。

18.1 C++11 特性大盘点

18.2 右值引用

18.3 移动语义

18.4 类的新特性

18.5 lambda 函数

18.6 function 包装器

18.7 可变参数模板

18.8 更多新特性与 Boost

18.1 C++11 特性大盘点

先快速回顾散落在前面各章的 C++11 改动,一张清单过一遍:

  • 新类型long long/unsigned long long(64 位整数)、char16_t/char32_t、原始字符串。
  • 统一初始化:花括号列表适用于一切类型——int x = {5};double y {2.75};Stump s2{5, 43.4};,连 new 表达式都行:int * ar = new int[4] {2, 4, 6, 7};
  • 收窄检查:花括号初始化禁止有信息丢失的转换。char c2 = 459585821; 悄悄截断;char c2 {459585821}; 直接编译错误。这是花括号相对圆括号的核心优势。
  • 声明简化auto 类型推导;decltype(x) y; 让 y 与表达式 x 同类型(模板里的救星);后置返回类型 auto f2(double, int) -> double; 解决”T、U 还没进作用域没法写返回类型”的难题:template<typename T, typename U> auto eff(T t, U u) -> decltype(T*U) {...}
  • 模板别名using itType = std::vector<std::string>::iterator;,且支持 typedef 做不到的部分特化template<typename T> using arr12 = std::array<T,12>;
  • nullptr:专用的空指针关键字。0 身兼整数常量与指针常量二职容易引发歧义重载,nullptr 是纯指针类型,nullptr == 0 为 true 但编译器会拒绝把 nullptr 传给 int 参数。
  • 智能指针换代:auto_ptr 弃用,unique_ptr/shared_ptr/weak_ptr 上位,且为配合 STL 容器和移动语义专门设计。
  • 异常规范弃用throw(bad_dog) 这套声明退出舞台,只保留 noexcept 表示”本函数不抛异常”。
  • 作用域内枚举enum class New1 {never, sometimes};——成员必须用 New1::never 访问,不同枚举不再撞名,底层类型也更可控。
  • explicit 转换运算符:explicit 的管辖范围从单参构造函数扩展到转换函数:explicit operator double() const; 阻止隐式转换。
  • 类内成员初始化int mem1 = 10; double mem2 {1966.54}; 直接写在类定义里,构造函数初始化列表里的值可以覆盖它。从此不用在每个构造函数里重复写默认值。
  • STL 更新:新增容器 forward_list 与四个哈希容器;cbegin()/cend() 返回 const 迭代器;容器获得移动构造/移动赋值;>> 相邻的模板尖括号不再要求空格:std::vector<std::list<int>> v1; 合法了。

18.2 右值引用

先分清两种”值”:

  • 左值(lvalue):能取地址的、有持久身份的数据。变量名、解引用的指针都是。传统引用(现在叫左值引用 &)只能绑定左值。
  • 右值(rvalue):字面常量(13)、表达式计算结果(x+y)、函数按值返回的临时值。它们”飘在空中”,取不了地址。右值引用 && 专门绑定右值
// rvref.cpp -- simple uses of rvalue references
#include <iostream>
 
inline double f(double tf) {return 5.0*(tf-32.0)/9.0;}
 
int main()
{
    using namespace std;
    double tc = 21.5;
    double && rd1 = 7.07;          // 绑定字面量
    double && rd2 = 1.8 * tc + 32.0;  // 绑定表达式结果 70.7
    double && rd3 = f(rd2);        // 绑定函数返回的临时值
    cout << "rd1 value and address: " << rd1 << ", " << &rd1 << endl;
    return 0;
}

最有趣的一点:右值本来取不了地址,但绑定到右值引用之后就有地址了&rd1 合法)——数据被安放到了具体位置,右值引用就是通向它的门。rd2 绑定的是当时 x+y 的,之后改 x、y 都影响不了它。

右值引用的核心使命是实现移动语义,下一节揭晓。

18.3 移动语义

问题:深拷贝有时候纯属浪费。

vector<string> vstr_copy1(vstr);          // #1:真复制,20 万字符一份份拷
 
vector<string> vstr_copy2(allcaps(vstr)); // #2:拷贝 allcaps 返回的临时对象

#2 里 allcaps() 返回的临时对象马上就要销毁,却还要为它做一次 2000 万字符级别的深拷贝再整体销毁——等于把整套家具搬进新家又连夜扔掉旧房子。更好的做法是:数据原地不动,把”房本”直接过户。这就是移动语义——名叫”移动”,实际移动的只是簿记信息(指针),数据本身纹丝不动。

实现需要两步

  1. 编译器能区分”该拷贝”还是”该移动”——靠重载:左值实参匹配拷贝构造 Useless(const Useless &),右值实参匹配移动构造 Useless(Useless &&)
  2. 移动构造里写”过户”逻辑。

看移动构造的真身:

// useless.cpp -- 拷贝构造 vs 移动构造(核心对比)
Useless::Useless(const Useless & f) : n(f.n)   // 深拷贝:一字节一字节抄
{
    ++ct;
    pc = new char[n];
    for (int i = 0; i < n; i++)
        pc[i] = f.pc[i];
}
 
Useless::Useless(Useless && f) : n(f.n)        // 移动构造:直接偷指针
{
    ++ct;
    pc = f.pc;          // 窃取地址(pilfering)
    f.pc = nullptr;     // 原对象置空,防止析构时 delete 两次
    f.n = 0;
}

两个要点:① 移动构造会修改源对象,所以参数不能是 const;② 接管指针后必须把源指针置 nullptr——对空指针 delete [] 是安全的,delete 同一地址两次是灾难。程序中 Useless four (one + three); 里 one+three 是右值,自动匹配移动构造:four 的数据地址与 operator+ 里临时对象的地址完全相同,数据没有搬动过一寸。

移动赋值同理:

Useless & Useless::operator=(Useless && f)   // 移动赋值
{
    if (this == &f)
        return *this;
    delete [] pc;       // 先释放自己原有的数据
    n = f.n;
    pc = f.pc;          // 过户
    f.n = 0;
    f.pc = nullptr;
    return *this;
}

强制移动 std::move():左值默认走拷贝,但有时你就是想把左值”处理掉”换效率(比如从候选数组里挑出一个,其余丢弃)。<utility> 里的 std::move(one) 把左值 one 伪装成右值,从而触发移动赋值:

four = one;             // 拷贝赋值,one 完好无损
four = std::move(one);  // 移动赋值,one 被掏空(变成空对象)

注意 std::move() 本身不做任何移动,它只是”改标签”;如果类没定义移动赋值,编译器会退回用拷贝赋值。

常见坑:move 过的对象别再用

two = std::move(one); 之后 one 已被掏空(指针置 nullptr),再解引用或读它的数据是未定义行为。move 的语义就是”我承诺不再用它”——签名式的所有权交接。

对大多数程序员的实际意义在于:你不用天天写右值引用,但库替你写好了——STL 容器现在都有移动构造和移动赋值,容器搬家、扩容、传值的开销因此大幅下降。

18.4 类的新特性

C++11 给类设计加了一堆减负工具:

特殊成员函数从 4 个变 6 个:默认构造、拷贝构造、拷贝赋值、析构,加上移动构造移动赋值。规则(重要):

  • 定义了析构/拷贝构造/拷贝赋值中的任何一个 → 移动构造和移动赋值不再自动生成
  • 定义了移动构造或移动赋值 → 拷贝构造和拷贝赋值不再自动生成。
  • 需要被用到且你没定义时,编译器才生成默认版本(defaulted)。

defaulted 与 deleted 方法

class Someclass
{
public:
    Someclass() = default;                      // 显式要求编译器生成默认版
    Someclass(const Someclass &) = delete;      // 禁用拷贝(比 private 方案直白)
    Someclass & operator=(const Someclass &) = delete;
    Someclass(Someclass &&) = default;          // 允许移动
    Someclass & operator=(Someclass &&) = default;
};

delete 还能禁用特定重载:void redo(double); 加上 void redo(int) = delete; 后,sc.redo(5) 会被编译器拦下(而不是悄悄把 5 提升为 5.0)——deleted 函数参与重载决议,只是选中即报错。

委托构造:构造函数调用构造函数,公共逻辑只写一份:

Notes::Notes(int kk, double xx, std::string stt) : k(kk), x(xx), st(stt) {}
Notes::Notes() : Notes(0, 0.01, "Oh") {}       // 委托给全参版
Notes::Notes(int kk) : Notes(kk, 0.01, "Ah") {}

继承构造using BS::BS; 把基类构造函数引入派生类(默认/拷贝/移动构造除外)。继承来的构造只初始化基类成员,派生类新增成员走默认初始化;签名冲突时派生类自己的版本优先。

override 与 final:修虚函数手滑的神器。签名写错(如 f(char * ch)f(char ch))不会覆盖而是隐藏基类版本,运行时才发现多态失效。加上 override 后缀,签名对不上直接编译报错:

class Action { public: virtual void f(char ch) const; };
class Bingo : public Action {
public:
    virtual void f(char * ch) const override;  // 编译错误:签名不匹配!
};

final 则禁止后续派生类再覆盖:virtual void f(char ch) const final;。两者是”有特殊含义的标识符”而非关键字,别的场合仍可当普通变量名。

18.5 lambda 函数

lambda(λ 表达式)是匿名函数,长这样:

[](int x) { return x % 3 == 0; }

它解决的问题是:给 STL 算法传”一个小判断”,用函数指针要全局定义、用 functor 要写整个类,都离使用点太远。lambda 的三段式:[捕获列表](参数列表){函数体},返回类型自动推导(多 return 语句时用后置语法 -> double 显式声明)。

函数指针、functor、lambda 三种写法对比同一个需求”数出能被 3/13 整除的随机数个数”:

// lambda0.cpp -- 三种函数对象的对比(节选)
std::vector<int> numbers(Size);
std::generate(numbers.begin(), numbers.end(), std::rand);
 
// ① 函数指针:要先在全局定义 bool f3(int x) {...}
int count3 = std::count_if(numbers.begin(), numbers.end(), f3);
 
// ② functor:要定义一个类,但可参数化
class f_mod {
    int dv;
public:
    f_mod(int d = 1) : dv(d) {}
    bool operator()(int x) { return x % dv == 0; }
};
count3 = std::count_if(numbers.begin(), numbers.end(), f_mod(3));
 
// ③ lambda:定义即使用
count3 = std::count_if(numbers.begin(), numbers.end(),
            [] (int x) { return x % 3 == 0; });

捕获列表是 lambda 的独门武功——它能”看见”作用域内的局部变量:

  • [z]:按值捕获 z;[&count]:按引用捕获 count(lambda 内改它就是改原件)
  • [&]:全部按引用;[=]:全部按值
  • 混搭:[ted, &ed][&, ted][=, &ed]

于是统计可以写成一行 lambda 双杀:

// lambda1.cpp -- 按引用捕获,一个 lambda 统计两个数
int count3 = 0, count13 = 0;
std::for_each(numbers.begin(), numbers.end(),
    [&](int x){ count3 += x % 3 == 0; count13 += x % 13 == 0; });

x % 13 == 0 是 bool,转成 0/1 加进计数器——把谓词当累加器用,这是传统函数指针做不到的。lambda 用两次也不用复制粘贴:auto mod3 = [](int x) { return x % 3 == 0; }; 起个名,之后 mod3 既能传给算法也能当普通函数调。

18.6 function 包装器

问题:模板的低效实例化。函数指针、functor、lambda 都是”可调用类型”,但对模板来说它们是不同的类型

// somedefs.h
template <typename T, typename F>
T use_f(T v, F f) { ... return f(v); }
 
// callable.cpp -- 六种调用
use_f(y, dub);                        // F = double(*)(double)
use_f(y, square);                     // 同上,共用一次实例化
use_f(y, Fp(5.0));                    // F = Fp,新的实例化!
use_f(y, Fq(5.0));                    // F = Fq,又一种!
use_f(y, [](double u) { return u*u; });      // 编译器内部类型,又一种!
use_f(y, [](double u) { return u+u/2.0; });  // 又一种!

虽然六种调用的行为签名都是 double(double),模板参数 F 却被推导成五种不同类型,use_f() 被实例化了五份,可执行文件白白膨胀。

解法:std::function<double(double)>(头文件 <functional>)。function 按调用签名(返回类型 + 参数类型列表)统一封装一切可调用体:

// wrapped.cpp -- using a function wrapper(核心)
function<double(double)> ef1 = dub;
function<double(double)> ef2 = square;
function<double(double)> ef3 = Fq(10.0);
function<double(double)> ef5 = [](double u) { return u*u; };
use_f(y, ef1);   // 六次调用的 F 全是 function<double(double)>
use_f(y, ef5);   // → 只实例化一份,count 地址始终相同

也可以反过来在模板形参上用 function:T use_f(T v, std::function<T(T)> f),调用时写 use_f<double>(y, dub)。顺带一提,C++11 还有一批新包装器:bind(比 bind1st/bind2nd 灵活的绑定器)、mem_fn(成员函数转普通可调用体)、reference_wrapper(可拷贝的”引用”)。

18.7 可变参数模板

可变参数模板(variadic template)让函数/类接受任意数量、任意类型的参数。目标:show_list(n, x)show_list(x*x, '!', 7, mr) 都能用一个模板搞定。四个关键词:参数包、展开、递归、终止

// variadic2.cpp -- 可变参数模板的最终形态
#include <iostream>
#include <string>
 
// 0 个参数:终止递归
void show_list() {}
 
// 恰好 1 个参数:打印换行而非逗号,同时是递归的终点
template<typename T>
void show_list(const T& value)
{
    std::cout << value << '\n';
}
 
// 2 个及以上:打印第一个,递归处理剩下的
template<typename T, typename... Args>
void show_list(const T& value, const Args&... args)
{
    std::cout << value << ", ";
    show_list(args...);        // 展开:把包拆回一个个参数
}
 
int main()
{
    int n = 14;
    double x = 2.71828;
    std::string mr = "Mr. String objects!";
    show_list(n, x);
    show_list(x*x, '!', 7, mr);
    return 0;
}

拆解机制:

  • typename... Args模板参数包(一串类型),Args... args函数参数包(一串值)。Args 能匹配任意个类型,包括 0 个。
  • args... 放在包名右侧表示展开show_list(args...) 把包还原成独立的参数列表。
  • 每次递归用普通参数 T value 吃掉第一个,包里剩下的传给下一层;包空了就落到无参重载,递归终止。
  • const Args&... args 给包里的每个参数统一套上 const 引用模式,避免大对象按值传递的开销。

没有下标访问包的能力(不能写 Args[2]),所以”处理一个、递归其余”是标准套路——和链表遍历一个思路。

18.8 更多新特性与 Boost

并发编程:多核时代需要线程。C++11 定义了支持线程的内存模型,新增 thread_local 关键字(变量的生命周期与线程绑定),并提供 <atomic> 原子操作库与 <thread><mutex><condition_variable><future> 线程支持库。

库扩容<random>(多种随机数引擎和分布)、<chrono>(时间时长)、<tuple>(pair 的任意元推广)、<ratio>(编译期有理数)、<regex>(正则表达式——配合原始字符串 R"(\d\t\w\d)" 使用,不用再写双反斜杠)。

低层支持:放宽 POD 定义便于字节级复制;联合允许含构造/析构的成员;alignof()/alignas 管内存对齐;constexpr 扩大编译期常量求值范围(const 变量可放入只读存储);static_assert 把断言提前到编译期(调试模板利器);支持用户自定义字面量(如二进制字面量 1001001b)。

语言演进模式值得一提:标准委员会节奏慢,而社区通过库来先行创新。STL 就是这么被 HP 实验室”用脚投票”进标准的。Boost 项目(始于 1998,www.boost.org,100+ 个库)延续这条路:先在 Boost 社区孵化、评审,成熟后经 TR1(Technical Report 1)过渡进 C++11。tuple、array、bind、function、智能指针、regex、random 都出自这条流水线。标准之外的 Boost 依然值得挖掘,比如 lexical_cast(数字与字符串互转,boost::lexical_cast<string>(weight) 一行搞定,缺点是对浮点格式控制不足,精细格式化还得用第 17 章的 stringstream)。

学完本书之后:掌握规则只是起点,更长的旅程是学会高效地用 C++——多读好代码,了解 UML、用例分析、CRC 卡片这些分析与设计方法,按”迭代演化”而非”一步到位”的思路构建大型系统。

通关标准:

能说清左值/右值/左值引用/右值引用的关系;能手写移动构造(记住偷指针 + 置空两步);能用捕获列表写出带状态累加的 lambda;能解释 function 包装器如何把 5 次模板实例化压成 1 次;能默写”参数包 + 递归 + 终止重载”的可变参数模板三件套。

常见坑:移动构造的参数不能是 const

移动构造/移动赋值要修改源对象(置空它的指针),参数必须是 Useless && 而非 const Useless &&。另外接管指针后忘记把源指针置 nullptr,会导致析构阶段对同一地址 delete 两次,程序当场崩溃。

、[=]、[&, ted] 分别是什么意思?

[&] 把作用域内所有自动变量按引用捕获(修改即改原件);[=] 全部按值捕获(副本,不可改原件);[&, ted] 表示 ted 按值、其余全部按引用。同理 [=, &ed] 是 ed 按引用、其余按值。捕获是 lambda 访问局部变量并携带状态的唯一通道。