第24章 C++14特性

第24章 C++14特性

如果说C++11是C++的文艺复兴,那C++14就是这场运动的"配套设施完善年"。功能还是那些功能,但用起来更顺手了,就像是你家楼下终于开了家便利店——你不需要再跑五公里买泡面了。本章我们就来聊聊C++14那些让人会心一笑的小改进。

24.1 泛型Lambda

什么是Lambda?

在聊泛型Lambda之前,先给Lambda(匿名函数)下个定义。顾名思义,Lambda就是没有名字的函数——就像你点了份外卖,不需要知道厨师叫什么,只需要知道味道好不好吃就行了。

Lambda的基本语法是这样的:

1
[capture](parameters) -> return_type { body }

其中capture是捕获列表(决定你能访问哪些外部变量),parameters是参数,return_type是返回类型,body是函数体。

C++11 vs C++14:参数类型的进化

在C++11中,Lambda的参数必须是明确指定的类型,比如:

1
2
3
4
// C++11风格的Lambda,参数类型必须写死
auto add = [](int a, int b) {
    return a + b;
};

这样就只能做整数加法了,想做浮点数加法?对不起,再写一个。

但到了C++14,参数类型可以用auto声明,编译器会自动帮你推导类型。就像一个万能钥匙,能开多种锁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    // C++14: Lambda参数可以使用auto
    // 编译器会根据传入的参数自动推断a和b的类型
    auto add = [](auto a, auto b) {
        return a + b;
    };
    
    // 整数加法
    std::cout << "add(1, 2) = " << add(1, 2) << std::endl;  // 输出: 3
    
    // 浮点数加法
    std::cout << "add(1.5, 2.5) = " << add(1.5, 2.5) << std::endl;  // 输出: 4
    
    // 字符串拼接(重载了+运算符)
    std::cout << "add(std::string(\"a\"), std::string(\"b\")) = " 
              << add(std::string("a"), std::string("b")) << std::endl;  // 输出: ab
    
    // 甚至可以用在STL算法中!
    std::vector<int> nums = {1, 2, 3, 4, 5};
    std::for_each(nums.begin(), nums.end(), [](auto& n) {
        n *= 2;  // 每个元素翻倍
    });
    
    return 0;
}

工作原理

auto在Lambda参数中的原理其实很朴素:编译器会为每种不同的类型生成一份专门的函数实例。这叫做模板实例化——想象一下,编译器是个超级复制粘贴机器人,你写一个auto参数,它会根据实际调用生成N个版本。

💡 为什么不用模板函数?

直接写模板函数当然也行,但Lambda更简洁,而且可以就地定义、就地使用,不用先跑到别处声明一个模板函数再回来调用。

实际应用场景

泛型Lambda在STL算法中使用频率极高,比如排序自定义、查找过滤、批量转换等等:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<double> prices = {99.9, 199.9, 299.9, 399.9, 499.9};
    
    // 泛型Lambda配合for_each打折
    double discount = 0.8;  // 八折
    std::for_each(prices.begin(), prices.end(), [discount](auto& price) {
        price *= discount;
    });
    
    // 打印打折后的价格
    for (const auto& price : prices) {
        std::cout << price << " ";  // 输出: 79.92 159.92 239.92 319.92 399.92 
    }
    std::cout << std::endl;
    
    return 0;
}

幽默一刻

想象你走进一家餐厅:

  • C++11的服务员:“您好,请问您要点什么?”

  • 你:“来份加法。”

  • 服务员:“好的,请问是整数加法还是浮点数加法?”

  • 你:"…我只是想算1+1。"

  • C++14的服务员:“您好,请问您要点什么?”

  • 你:“来份加法,随便什么类型都行。”

  • 服务员:“好的,1+1=2,您的外卖已准备好!”

泛型Lambda就是那个能读懂你心思的服务员。


24.2 变量模板

模板的进化:从函数到变量

C++的模板最初是为了实现泛型编程,但很长一段时间里,模板只能用在函数和类上。到了C++14,模板家族迎来了新成员——变量模板(Variable Template)。

简单来说,变量模板就是用模板定义的变量。就像类模板生成类、函数模板生成函数一样,变量模板生成一系列相关的变量。

语法很简单

1
2
template<typename T>
constexpr 类型 变量名 = 表达式;

std::is_integral_v 的前世今生

有一个经典的需求:判断一个类型是否是整数类型。在C++11里,你只能这么写:

1
2
3
// C++11 风格:类型萃取类模板 + ::value
std::is_integral<int>::value  // 返回true
std::is_integral<double>::value  // 返回false

这玩意儿又臭又长,每次写都要敲半天。于是人们很自然地想:能不能把它变成一个变量?

1
2
// C++17 才有的标准写法
std::is_integral_v<int>  // 更简洁,效果一样

⚠️ 一个极易搞混的时间点(很多教程都写错):

  • 变量模板这个语言特性是 C++14 引入的;
  • 但标准库里那一大批 _v 后缀(is_integral_v、is_floating_point_v、is_same_v…) 是 C++17 才补上的(提案 P0006R0)。

换句话说:C++14 里根本没有 std::is_integral_v。你要么写 std::is_integral<int>::value, 要么自己定义一个变量模板——而这正是 C++14 让这件事变简单的原因。下面就是完整的例子:

这个_v后缀后来成了标准库的约定。而在 C++14 里,你完全可以自己写一个同样好用的变量模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <cstddef>
#include <type_traits>

// C++14: 变量模板
// 语法:template<typename T> constexpr 类型 变量名 = 初始值;

// 判断T是否是整数类型,结果是一个编译期常量bool
// (注意:这是在"自己造一个 C++17 才有的 std::is_integral_v",所以故意起了同名,
//   用来说明"C++14 里这个得你自己写"。真实项目里请直接写 std::is_integral_v)
template<typename T>
constexpr bool is_integral_v = std::is_integral<T>::value;

// 获取T类型的大小(字节数),结果是一个编译期常量size_t
template<typename T>
constexpr std::size_t type_size_v = sizeof(T);

int main() {
    std::cout << std::boolalpha;  // 输出true/false而不是1/0
    
    // 判断各种类型是否是整数
    std::cout << "is_integral_v<int> = " << is_integral_v<int> << std::endl;  // 输出: true
    std::cout << "is_integral_v<double> = " << is_integral_v<double> << std::endl;  // 输出: false
    std::cout << "is_integral_v<char> = " << is_integral_v<char> << std::endl;  // 输出: true (char也是整数类型)
    std::cout << "is_integral_v<bool> = " << is_integral_v<bool> << std::endl;  // 输出: true
    
    std::cout << std::endl;
    
    // 获取各种类型的大小
    std::cout << "type_size_v<int> = " << type_size_v<int> << std::endl;  // 输出: 4
    std::cout << "type_size_v<double> = " << type_size_v<double> << std::endl;  // 输出: 8
    std::cout << "type_size_v<char> = " << type_size_v<char> << std::endl;  // 输出: 1
    std::cout << "type_size_v<long long> = " << type_size_v<long long> << std::endl;  // 输出: 8
    
    return 0;
}

背后的原理

变量模板的本质是:每当你用不同的类型参数去访问这个变量时,编译器会生成一个对应类型的专门版本:

1
2
3
4
5
6
7
8
// 你写了:
template<typename T>
constexpr bool is_integral_v = std::is_integral<T>::value;

// 编译器生成类似这样的东西(伪代码):
constexpr bool is_integral_v_int = std::is_integral<int>::value;      // true
constexpr bool is_integral_v_double = std::is_integral<double>::value;  // false
constexpr bool is_integral_v_char = std::is_integral<char>::value;     // true

常见标准库变量模板

C++17 起,标准库补齐了大量 _v 变量模板(C++14 里一个都没有,得自己写):

变量模板判断的类型
is_integral_v<T>T是否是整数类型
is_floating_point_v<T>T是否是浮点类型
is_array_v<T>T是否是数组类型
is_pointer_v<T>T是否是指针类型
is_enum_v<T>T是否是枚举类型

📌 顺带认识两个同类后缀(都是命名约定,不是不同的语言特性):

后缀含义新写法老写法
_v变量模板,取值std::is_integral_v<int>std::is_integral<int>::value
_t别名模板,取类型std::remove_reference_t<int&>std::remove_reference<int&>::type

这两个后缀不是同一批进来的,正好可以用来检验你有没有记住本节的重点:

  • _t(取类型,如 std::remove_const_t<T>)是 C++14 就有的(N3655);
  • _v(取值,如 std::is_integral_v<T>)要等到 C++17(P0006R0)。

实测一下就很清楚:clang++ -std=c++14 编译 std::remove_const_t<const int> 通过, 编译 std::is_integral_v<int> 则报 no template named 'is_integral_v'。

幽默一刻

变量模板的故事是个典型的"起了个大早,赶了个晚集":

  1. C++11:只有 std::is_integral<int>::value,又臭又长。
  2. C++14:语言层面给了你变量模板这个工具,但标准库一个都没用上—— 你得自己写 template<typename T> constexpr bool is_integral_v = std::is_integral<T>::value;。
  3. C++17:标准库终于把 _v 版本补齐了(P0006R0),从此 std::is_integral_v<int> 开箱即用。

所以你记住这条容易记反的结论:变量模板是 C++14 的,std::xxx_v 是 C++17 的。


24.3 放宽的constexpr限制

constexpr:从"编译期计算"到"更自由的编译期计算"

constexpr是C++11引入的关键字,意思是"常量表达式",告诉编译器"这个值可以在编译期算出来"。C++11对constexpr的限制非常严格——它只允许很简单的单行表达式,不许有局部变量,不许有循环,不许有多条语句。

这就好比你去相亲,对象说:“我可以和你一起吃饭,但只允许用筷子夹一粒米,不许嚼,不许咽,不许说话。”

到了C++14,这些限制被大幅放宽了。

C++11 vs C++14:constexpr的限制对比

特性C++11 constexprC++14 constexpr
局部变量❌ 不允许✅ 允许
局部静态变量❌ 不允许❌ 不允许
循环语句❌ 不允许✅ 允许
if语句❌ 不允许(只能用三元运算符)✅ 允许
多个return❌ 不允许✅ 允许
try/catch❌ 不允许❌ 不允许

阶乘和斐波那契:经典constexpr案例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <iostream>

// C++14: constexpr函数限制放宽
// 可以在constexpr函数中使用:
// - 局部变量
// - 循环语句
// - 改变量语句(虽然这个例子没用到)
// - if-else分支

// 计算阶乘:5! = 5*4*3*2*1 = 120
constexpr int factorial(int n) {
    int result = 1;  // 局部变量,C++11不允许!
    for (int i = 2; i <= n; ++i) {  // 循环,C++11不允许!
        result *= i;
    }
    return result;
}

// 计算斐波那契数列第n项
constexpr int fibonacci(int n) {
    // C++14允许使用if分支了!
    if (n <= 1) return n;
    
    int a = 0, b = 1;  // 局部变量
    for (int i = 2; i <= n; ++i) {  // 循环
        int c = a + b;
        a = b;
        b = c;
    }
    return b;
}

int main() {
    // constexpr变量:编译期就计算出结果
    constexpr int fact5 = factorial(5);  // 编译期计算,结果是120
    constexpr int fib10 = fibonacci(10);  // 编译期计算,结果是55
    
    // 普通变量:运行时计算
    int runtime_fact = factorial(6);  // 运行时计算
    
    std::cout << "factorial(5) = " << fact5 << std::endl;  // 输出: 120
    std::cout << "fibonacci(10) = " << fib10 << std::endl;  // 输出: 55
    std::cout << "factorial(6) (runtime) = " << runtime_fact << std::endl;  // 输出: 720
    
    return 0;
}

编译期计算的优势

为什么要在编译期计算?因为这样可以:

  1. 提升性能:结果直接嵌入二进制,不需要每次运行都算一遍
  2. 用于数组大小:编译期常量可以作为数组维度
  3. 用于模板参数:模板参数必须是编译期常量
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

constexpr int factorial(int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i) {
        result *= i;
    }
    return result;
}

int main() {
    // 编译期常量:可以作为数组维度
    constexpr int SIZE = factorial(5);  // SIZE = 120
    int arr[SIZE] = {0};  // 合法!数组大小必须是编译期常量
    
    std::cout << "数组大小是编译期计算的: " << SIZE << std::endl;  // 输出: 120
    std::cout << "数组arr的元素个数: " << (sizeof(arr) / sizeof(arr[0])) << std::endl;  // 输出: 120
    
    return 0;
}

幽默一刻

C++11的constexpr就像是一个完美的石膏像——漂亮但一动不动。你想让它循环?不行。想让它分支判断?不行。想让它存个中间结果?更不行。

C++14的constexpr终于允许你"动"了,但仍然有些事情做不到——比如try/catch(编译期异常处理太复杂)、比如动态内存分配。所以constexpr函数里不能new一个对象出来——这大概就是"戴着镣铐跳舞"吧。


24.4 二进制字面量

什么是字面量(Literal)?

字面量就是代码里直接写出来的值,比如42、3.14、"hello"。它们就像是语言的基本词汇——你不需要定义,直接使用。

在C++14之前,我们有十进制字面量(42)、十六进制字面量(0x2A)、八进制字面量(052),但就是没有二进制字面量。

二进制字面量:程序员的"0和1"情怀

程序员喜欢说"一切都是0和1",但C++11之前你却没法直接在代码里写二进制数。你得手动算:0b1010在C++11之前是不存在的,你得写10(十进制)或者0xA(十六进制)。

这就好比一个中世纪铁匠说"我天天和火打交道",但却不让他直接点火,必须用火柴慢慢磨。

C++14终于带来了0b前缀,让你直接写二进制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>

int main() {
    // C++14: 二进制字面量
    // 语法:0b + 二进制数字串(或0B也行,大小火鸡均可)
    
    int bin1 = 0b1010;       // 二进制1010 = 十进制10
    int bin2 = 0b11111111;   // 二进制11111111 = 十进制255
    
    std::cout << "0b1010 = " << bin1 << std::endl;  // 输出: 10
    std::cout << "0b11111111 = " << bin2 << std::endl;  // 输出: 255
    
    // 也可以用大写B
    int bin3 = 0B1010;  // 效果一样
    
    // 和位运算组合使用——这是二进制字面量的主战场!
    int flags = 0b1100 | 0b0011;  // 按位或 = 0b1111 = 15
    std::cout << "0b1100 | 0b0011 = " << flags << std::endl;  // 输出: 15
    
    // 权限控制示例
    const int READ = 0b0001;    // 读权限
    const int WRITE = 0b0010;   // 写权限
    const int EXECUTE = 0b0100; // 执行权限
    const int ADMIN = 0b1000;   // 管理员权限
    
    int user_permissions = READ | WRITE;  // 普通用户:读+写
    int admin_permissions = READ | WRITE | EXECUTE | ADMIN;  // 管理员:全部
    
    // 检查权限
    bool can_read = (user_permissions & READ) != 0;  // true
    bool can_execute = (user_permissions & EXECUTE) != 0;  // false
    
    std::cout << "普通用户能否读: " << std::boolalpha << can_read << std::endl;  // 输出: true
    std::cout << "普通用户能否执行: " << can_execute << std::endl;  // 输出: false
    
    return 0;
}

典型应用场景

二进制字面量在底层编程、位掩码、权限控制、硬件寄存器操作等场景非常有用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

// 网络协议中的标志位
struct TCPFlags {
    static constexpr int FIN = 0b00000001;
    static constexpr int SYN = 0b00000010;
    static constexpr int RST = 0b00000100;
    static constexpr int PSH = 0b00001000;
    static constexpr int ACK = 0b00010000;
    static constexpr int URG = 0b00100000;
};

int main() {
    // 构建一个SYN-ACK包
    int packet = TCPFlags::SYN | TCPFlags::ACK;
    
    std::cout << "TCP包标志位: " << std::endl;
    // 注意:没打开 boolalpha,所以 bool 会打印成 1 / 0
    std::cout << "  SYN: " << ((packet & TCPFlags::SYN) != 0) << std::endl;  // 输出: 1(即 true)
    std::cout << "  ACK: " << ((packet & TCPFlags::ACK) != 0) << std::endl;  // 输出: 1(即 true)
    std::cout << "  FIN: " << ((packet & TCPFlags::FIN) != 0) << std::endl;  // 输出: 0(即 false)
    
    return 0;
}

幽默一刻

二进制字面量的命名之争也很有意思。2008年有人提议用0b前缀,遭到了C语言委员会的反对,他们坚持要用B开头的写法——比如B1010。理由是"不能和十六进制的0x太像"。

最后C++14还是选择了0b(和0x保持队形)。现在看来这是个正确的决定——统一的美才是真的美。


24.5 数字分隔符

顾名思义的特性

数字分隔符(Digit Separator)允许在数字字面量中插入单引号,让长数字更容易阅读。1'000'000就是100万,比1000000好看多了。

这个特性的灵感来自Pascal语言(用点作为分隔符)和Fortran语言(用空格),但C++选择了单引号——大概是因为程序员对单引号最熟悉。

语法规则

  1. 单引号只能放在两个数字之间,并且只能放一个(1'000 ✅,1''000 ❌)
  2. 不能放在数字的开头或结尾(1'000' ❌;写成 '1000' 就更糟——那会变成"多字符字面量",见下文)
  3. 不能紧挨着进制前缀(0x'FF ❌、0b'1010 ❌)
  4. 十六进制里的字母也算"数字",所以 0xFF'00'00 合法;小数点在两边有数字时也能用(1'000.000'1 ✅)

🧪 这是"实测才能记住"的一条。下面几种写法在 Apple clang 21 上的真实结果(别背规则,亲手试一遍):

1
2
3
4
5
6
int a = 1''000;   // ❌ error: expected ';' after top level declarator
                  //    (另有一条 warning: empty character constant)
int b = 0x'FF;    // ❌ error: invalid suffix 'x'FF' on integer constant
int c = 1'000';   // ❌ error: expected ';' after top level declarator
                  //    (另有一条 warning: missing terminating ' character)
int e = 0b'1010;  // ❌ error: invalid digit 'b' in octal constant

⚠️ 最阴的一个坑在这里:int d = '1000';(单引号开头、单引号结尾、里面塞了 4 位数字) 既不报错也不报警告——因为它是合法的多字符字面量(multi-character literal),值是实现定义的。 在 Apple clang 21 上打印出来是 825241648,而标准只保证"它是某个 int"。 看到这种数字,基本可以断定作者本来想写 1'000。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cstdint>

int main() {
    // C++14: 数字分隔符(单引号)
    // 目的:让长数字更易读,完全不影响数值!
    
    // 十进制:千位分隔
    int million = 1'000'000;          // 一百万
    int credit = 4'999'999;           // 信用卡号?不,这只是五百万减一
    int population = 1'400'000'000;    // 十四亿
    
    // 二进制:每4位一组(符合人类的阅读习惯)
    int mask = 0b1111'0000'1111'0000;  // 高低四位都是1
    
    // 十六进制:每2位一组
    int color = 0xFF'00'00;  // 纯红色(RGB 格式:红 FF、绿 00、蓝 00)
    
    // long long:天文数字也能readably
    long long big = 9'223'372'036'854'775'807LL;  // LLONG_MAX
    
    std::cout << "million = " << million << std::endl;  // 输出: 1000000
    std::cout << "credit = " << credit << std::endl;  // 输出: 4999999
    std::cout << "population = " << population << std::endl;  // 输出: 1400000000
    
    std::cout << std::hex;  // 切换到十六进制输出
    std::cout << "mask = 0x" << mask << std::endl;  // 输出: mask = 0xf0f0
    std::cout << "color = 0x" << color << std::endl;  // 输出: color = 0xff0000
    
    std::cout << std::dec;  // 切回十进制
    std::cout << "big = " << big << std::endl;  // 输出: 9223372036854775807
    
    return 0;
}

实际应用

数字分隔符在以下场景特别有用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
#include <cstddef>   // std::size_t

int main() {
    // 物理/数学常数
    const double AVOGADRO = 6.022'140'76e23;  // 阿伏伽德罗常数
    const double PLANCK = 6.626'070'15e-34;   // 普朗克常数
    
    // 金额(会计领域)
    long double price = 1'999'999.99;  // 将近两百万
    long double tax = 199'999.90;      // 约二十万税
    
    // 字节数
    const std::size_t KB = 1'024;            // 1KB = 1024字节
    const std::size_t MB = 1'048'576;        // 1MB = 1024*1024
    const std::size_t GB = 1'073'741'824;    // 1GB = 1024^3
    const std::size_t TB = 1'099'511'627'776;  // 1TB(64 位上没问题,32 位会溢出)
    
    std::cout << "1KB = " << KB << " bytes" << std::endl;  // 输出: 1KB = 1024 bytes
    std::cout << "1MB = " << MB << " bytes" << std::endl;  // 输出: 1MB = 1048576 bytes
    std::cout << "1GB = " << GB << " bytes" << std::endl;  // 输出: 1GB = 1073741824 bytes
    
    return 0;
}

幽默一刻

数字分隔符的灵感来源其实很有趣:

  • Pascal:用点(1.000.000)——但这和浮点数小数点冲突了
  • Ada:用下划线(1_000_000)——C++也曾考虑过这个
  • Fortran:用空格(1 000 000)——但空格在代码里经常被忽略
  • 最终C++:用单引号(1'000'000)

为什么选单引号?因为它是键盘上唯一"不属于数字、运算符或标识符"的字符。所以当你写int x = 0b1010'0101;时,编译器可以明确知道分隔符只是用来提升可读性的,不会影响实际数值。


24.6 返回类型推导

什么是返回类型推导?

返回类型推导(Return Type Deduction)允许编译器自动推断函数的返回类型,你不需要显式写出返回值类型。这就像是点菜时说"来个招牌菜",服务员会根据你的口味推荐具体是哪道。

语法

1
2
3
auto 函数名(参数) { return 表达式; }
// 或者
decltype(auto) 函数名(参数) { return 表达式; }

auto vs decltype(auto)

这两者有什么区别?

形式行为
auto推导返回值类型,会丢掉引用和const
decltype(auto)推导返回值类型,保持引用和const
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

// C++14: 函数返回类型推导
// 使用auto作为返回类型,编译器会自动推断

auto add(int a, int b) {
    return a + b;  // 编译器推导返回类型为int
}

// 使用decltype(auto)保持表达式的类型语义
decltype(auto) multiply(int a, int b) {
    return a * b;  // 保持表达式类型,这里是int
}

// 引用类型演示
int x = 10;
auto get_x() { return x; }           // 返回int,不是int&
// decltype(auto) get_x_ref() { return x; }  // 返回int&

int main() {
    std::cout << "add(3, 4) = " << add(3, 4) << std::endl;  // 输出: 7
    std::cout << "multiply(5, 6) = " << multiply(5, 6) << std::endl;  // 输出: 30
    
    // 验证返回类型
    std::cout << "sizeof(add(3,4)) = " << sizeof(add(3, 4)) << std::endl;  // 输出: 4 (int的大小)
    
    return 0;
}

decltype规则简介

decltype会根据表达式推导类型,规则如下:

  • 如果表达式是标识符或类成员访问,返回该对象的类型
  • 如果表达式是函数调用,返回该函数的声明返回类型
  • 其他情况,返回该表达式的类型
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <type_traits>

int global_var = 42;
int& get_ref() { return global_var; }
const int const_val = 100;

int main() {
    // decltype(auto)的行为演示
    
    // 返回int(标识符规则)
    decltype(auto) a = global_var;  // int
    
    // 返回int&(引用规则)
    decltype(auto) b = get_ref();   // int&
    
    // 返回const int(常量规则)
    decltype(auto) c = const_val;   // const int
    
    std::cout << std::boolalpha;
    std::cout << "is_same<decltype(a), int>: " 
              << std::is_same<decltype(a), int>::value << std::endl;  // 输出: true
    std::cout << "is_same<decltype(b), int&>: " 
              << std::is_same<decltype(b), int&>::value << std::endl;  // 输出: true
    std::cout << "is_same<decltype(c), const int>: " 
              << std::is_same<decltype(c), const int>::value << std::endl;  // 输出: true
    
    return 0;
}

限制和注意事项

返回类型推导不是万能的:

  1. 前向声明问题:如果你声明了返回类型推导的函数,在定义之前调用它,编译器可能无法正确处理
  2. 多返回值的函数:如果函数有多个return语句且返回类型不一致,编译器会报错
  3. 虚函数:虚函数不能使用返回类型推导
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

// 错误示例:多个返回类型不一致
// auto wrong(int flag) {
//     if (flag > 0) return 1;      // int
//     else return 2.0;             // double - 冲突!
// }

// 正确示例:多个return但类型一致
auto absolute(int n) {
    if (n >= 0) return n;     // 返回int
    else return -n;           // 返回int,类型一致
}

int main() {
    std::cout << "absolute(-5) = " << absolute(-5) << std::endl;  // 输出: 5
    
    return 0;
}

幽默一刻

返回类型推导有一个著名的"陷阱":

1
auto make_int() { return 1; }

你觉得返回类型是什么?int?恭喜你,答对了。

但如果是:

1
2
const int& foo();
auto bar() { return foo(); }

bar()的返回类型是什么?int?还是const int&?

答案是int——因为auto会丢掉引用和const修饰符。如果你想要保留const int&,得用decltype(auto)。

所以记住:auto是"差不多就行",decltype(auto)是"原汁原味"。


24.7 std::make_unique

智能指针的进化史

在C++11之前,管理动态内存是一项技术活——new和delete配对使用,稍有不慎就会内存泄漏。到了C++11,标准库引入了智能指针,让内存管理变得自动化。

C++11有std::shared_ptr(共享所有权)和std::unique_ptr(独占所有权),但有个问题:std::make_shared早就有了,std::make_unique却迟迟没有加入标准库。

这就好比餐厅提供打包盒(shared),但不提供外卖袋(unique)——你得自己找袋子装。

C++14终于补上了这个漏洞。

std::make_unique是什么?

std::make_unique是一个辅助函数,用于创建std::unique_ptr对象。它比直接使用new构造更安全、更简洁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <memory>

// 定义一个Widget类来演示构造和析构
struct Widget {
    Widget() { std::cout << "Widget constructed" << std::endl; }
    ~Widget() { std::cout << "Widget destructed" << std::endl; }
};

int main() {
    // C++14: std::make_unique(之前只有make_shared)
    // 语法:std::make_unique<T>(构造参数...)
    
    // 创建单个对象
    auto up1 = std::make_unique<int>(42);
    std::cout << "Value: " << *up1 << std::endl;  // 输出: 42
    
    // 创建数组(C++14支持数组形式的make_unique)
    auto upArr = std::make_unique<int[]>(5);
    upArr[0] = 10;
    upArr[1] = 20;
    std::cout << "upArr[0] = " << upArr[0] << std::endl;  // 输出: 10
    std::cout << "upArr[1] = " << upArr[1] << std::endl;  // 输出: 20
    
    // 异常安全演示
    // make_unique在构造前分配内存,更安全
    // 如果构造过程中抛出异常,已分配的内存会被自动释放
    auto w = std::make_unique<Widget>();  // 会打印 "Widget constructed"
    
    std::cout << "Widget正在发挥作用..." << std::endl;
    
    // 函数结束时,w会自动销毁,打印 "Widget destructed"
    
    return 0;
}

为什么推荐使用make_unique?

  1. 异常安全:在"一行里干好几件事"的表达式里,裸 new 可能在你还没把指针交给智能指针时就抛异常,内存就丢了
  2. 不会泄漏:手动 new/delete 一旦提前 return 或抛异常,delete 就永远执行不到;unique_ptr 的析构总会被调用
  3. 代码简洁:不需要重复写两遍类型名(std::unique_ptr<T>(new T(...)) vs std::make_unique<T>(...))

❌ 一个常见的错误说法:有些教程写"make_unique 可以一次性分配对象和引用计数块,所以更快"。 这是把 make_shared 的特性套到了 make_unique 头上——unique_ptr 根本没有引用计数块, 也就不存在"合并分配"这回事。make_unique 的快,主要体现在少写一遍类型名、少一次手写 new, 真正的价值是异常安全,不是性能。

先看最直观的一条差异——“提前返回"时会发生什么:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <memory>

struct Resource {
    Resource()  { std::cout << "Resource acquired" << std::endl; }
    ~Resource() { std::cout << "Resource released" << std::endl; }
};

// ① 手动 new/delete:一旦提前返回(或抛异常),delete 就永远执行不到 → 泄漏
void with_raw_pointer(bool earlyExit) {
    Resource* r = new Resource();
    if (earlyExit) {
        return;                 // ⚠️ 泄漏!r 指向的内存没人释放
    }
    delete r;
}

// ② 智能指针:不管怎么离开作用域,析构函数都会跑
void with_unique_ptr(bool earlyExit) {
    auto r = std::make_unique<Resource>();
    if (earlyExit) {
        return;                 // ✅ 依然会释放
    }
}

int main() {
    std::cout << "=== 裸指针版本(提前返回 → 泄漏)===" << std::endl;
    with_raw_pointer(true);     // 只看到 acquired,看不到 released

    std::cout << "=== unique_ptr 版本(提前返回 → 仍然释放)===" << std::endl;
    with_unique_ptr(true);      // acquired 和 released 都会出现

    return 0;
}

输出:

1
2
3
4
5
=== 裸指针版本(提前返回 → 泄漏)===
Resource acquired
=== unique_ptr 版本(提前返回 → 仍然释放)===
Resource acquired
Resource released

还有一条更隐蔽的异常安全问题,也是 make_unique 存在的最大理由:当它出现在"一个函数调用的实参列表"里时, 手写 new 有可能在两个实参求值之间抛异常,导致已经分配的内存无人认领:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <memory>
#include <stdexcept>

struct Widget {
    Widget()  { std::cout << "Widget 构造" << std::endl; }
    ~Widget() { std::cout << "Widget 析构" << std::endl; }
};

int mayThrow() {
    throw std::runtime_error("出错了");
}

void use(std::unique_ptr<Widget> w, int n) { (void)w; (void)n; }

int main() {
    try {
        // ❌ 危险写法(注释掉,跑起来会泄漏):
        //    new Widget() 先把裸指针造好,接着给 use 求第二个实参时抛异常,
        //    那个裸指针就没人负责了。
        // use(std::unique_ptr<Widget>(new Widget), mayThrow());

        // ✅ 安全写法:make_unique 返回时所有权已经在 unique_ptr 里,
        //    之后求值即使抛异常,临时对象也会把 Widget 释放掉。
        use(std::make_unique<Widget>(), mayThrow());
    } catch (const std::exception& e) {
        std::cout << "捕获异常: " << e.what() << std::endl;
    }
    return 0;
}

输出(注意:Widget 构造 之后紧跟着 Widget 析构,说明异常路径上资源也没丢):

1
2
3
Widget 构造
Widget 析构
捕获异常: 出错了

实际使用场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <memory>
#include <vector>

class Node {
public:
    int value;
    std::unique_ptr<Node> next;
    
    Node(int v) : value(v), next(nullptr) {
        std::cout << "Node(" << value << ") created" << std::endl;
    }
    
    ~Node() {
        std::cout << "Node(" << value << ") destroyed" << std::endl;
    }
};

int main() {
    // 创建链表
    auto head = std::make_unique<Node>(1);
    head->next = std::make_unique<Node>(2);
    head->next->next = std::make_unique<Node>(3);
    
    // 遍历链表
    std::cout << "链表内容: ";
    for (auto* cur = head.get(); cur != nullptr; cur = cur->next.get()) {
        std::cout << cur->value << " ";
    }
    std::cout << std::endl;
    
    // unique_ptr超出作用域后自动释放所有节点
    
    return 0;
}

幽默一刻

上一版这里写了个段子,说"有人问 Bjarne Stroustrup 为什么,他说:呃……我们忘了?"—— 这个引语是编的,Stroustrup 没说过这话,标准委员会的会议记录里也没有"我们忘了一件事"这种承认。

真实的情况平淡得多:make_shared 在 C++11 就进了标准(它和 shared_ptr 一起设计), 而 make_unique 的提案(N3588,作者 Stephan T. Lavavej)提交得太晚,赶不上 C++11 的特性冻结, 只能排到 C++14。于是就有了这么个尴尬的十年:标准库教你别写 new, 却偏偏没给你 make_unique,大家只好在项目里自己写一个 make_unique 垫着用。

📝 顺带一提:这也解释了为什么很多老代码库里都有一个自己实现的 make_unique—— 它们是在 C++14 之前写的。如果你在旧代码里看到自己实现的 make_unique, 而且项目已经升级到 C++14 以上,那就可以把自定义版本删掉、直接用 std::make_unique 了。


24.8 带初始化捕获的Lambda

什么是Lambda捕获?

Lambda表达式可以"捕获"外部变量,使其在Lambda内部可见。但C++11的捕获方式有限:

  • [=]:按值捕获所有变量
  • [&]:按引用捕获所有变量
  • [x]:按值捕获x
  • [&x]:按引用捕获x

问题来了:**如果我想捕获一个表达式结果,而不是变量本身呢?**比如捕获一个std::unique_ptr的移动后的状态。

C++11的困境:移动捕获的缺失

在C++11中,Lambda不支持移动捕获(也没有 std::make_unique——它是C++14的)。 如果你想把一个"只能移动"的对象(如 std::unique_ptr)弄进 Lambda,是做不到的:

1
2
3
4
5
6
7
// C++11 的写法:只能移动的对象根本进不了捕获列表
std::unique_ptr<int> up(new int(42));   // C++11 只能用 new 来造

// auto f = [up]() { return *up; };        // ❌ 按值捕获要拷贝,unique_ptr 不可拷贝
// auto f = [&up]() { return *up; };       // ⚠️ 能编译,但只是在引用外面的 up,
//                                         //    一旦 up 离开作用域就成了悬垂引用
// auto f = [up = std::move(up)]() {...};  // ❌ 初始化捕获是 C++14 才有的语法

换句话说:C++11 里想把 unique_ptr 安全地搬进 Lambda,无解。 你只能退而求其次——捕获裸指针(丢掉了所有权语义),或者干脆不用 Lambda。

C++14:初始化捕获

C++14引入了初始化捕获(Generalized Lambda Captures),允许你用任意表达式初始化捕获值:

1
[identifier = expression] (params) { body }

这解决了两个问题:

  1. 移动捕获:将即将销毁的对象移动到Lambda中
  2. 重命名捕获:给捕获的变量起个新名字
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <memory>
#include <vector>
#include <algorithm>
#include <utility>

int main() {
    // C++14: Lambda初始化捕获(移动捕获)
    // 语法:[捕获名 = 表达式]
    
    // 创建unique_ptr
    auto up = std::make_unique<int>(42);
    
    // 初始化捕获:将std::move(up)的结果捕获为value
    // up的所有权被转移给Lambda内部的value
    auto captured = [value = std::move(up)]() {
        return *value;  // 现在unique_ptr在Lambda内部,up变成nullptr
    };
    
    std::cout << "captured() = " << captured() << std::endl;  // 输出: 42
    
    // 更复杂的例子:同时获取最小值和最大值
    std::vector<int> data = {1, 2, 3, 4, 5};
    
    // 使用初始化捕获移动data到Lambda中
    auto result = [data = std::move(data)]() {
        auto minmax = std::minmax_element(data.begin(), data.end());
        return std::pair<int, int>(*minmax.first, *minmax.second);
    };
    
    // C++14写法:用auto接收pair,再分别提取
    auto result_pair = result();
    int minVal = result_pair.first;
    int maxVal = result_pair.second;
    std::cout << "min=" << minVal << ", max=" << maxVal << std::endl;  // 输出: min=1, max=5
    
    // data现在已经被移动走了
    std::cout << "data的大小: " << data.size() << std::endl;  // 输出: 0
    
    return 0;
}

初始化捕获的工作原理

初始化捕获其实是两个步骤的简写:

  1. 创建临时变量:用表达式的结果初始化一个变量
  2. 捕获该变量:按值或按引用捕获这个新变量
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <memory>
#include <string>

int main() {
    // [x = expr] 相当于:
    // 1. 用expr初始化一个隐藏的变量
    // 2. 按值捕获这个变量
    
    // 例子:捕获计算结果
    auto compute_and_capture = [result = 1 + 2 + 3]() {
        return result;  // 返回6
    };
    std::cout << "计算结果: " << compute_and_capture() << std::endl;  // 输出: 6
    
    // 例子:捕获字符串的一部分
    std::string full_name = "John Doe";
    // "John Doe" 的下标:J0 o1 h2 n3 (空格)4 D5 o6 e7
    // substr(5) 从下标 5 开始,得到的是 "Doe"(不包含空格)
    auto last_name = [name = full_name.substr(5)]() {
        return name;
    };
    std::cout << "姓氏: '" << last_name() << "'" << std::endl;  // 输出: 'Doe'
    
    // 例子:按引用初始化捕获
    int counter = 0;
    auto increment = [&cnt = counter]() {
        ++cnt;  // 修改外部的counter
    };
    increment();
    std::cout << "计数器: " << counter << std::endl;  // 输出: 1
    
    return 0;
}

实际应用场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
#include <memory>
#include <functional>

class Database {
public:
    int query(const char* sql) {
        std::cout << "执行SQL: " << sql << std::endl;
        return 42;
    }
};

int main() {
    // 场景1:绑定移动后的对象
    auto conn = std::make_unique<Database>();
    
    // 移动conn到Lambda中,外部的conn变为空
    auto query_func = [db = std::move(conn)](const char* sql) {
        return db->query(sql);
    };
    
    // 场景2:创建带状态的回调
    int invocation_count = 0;
    auto counting_callback = [count = 0]() mutable {
        ++count;  // mutable 允许修改"按值捕获"出来的那份副本
        return count;
    };
    
    std::cout << "第1次调用: " << counting_callback() << std::endl;  // 输出: 1
    std::cout << "第2次调用: " << counting_callback() << std::endl;  // 输出: 2
    std::cout << "第3次调用: " << counting_callback() << std::endl;  // 输出: 3
    
    // 关键点:count 是 Lambda 对象自己的一个数据成员,从 0 初始化之后就一直在那儿。
    // 正是因为有 mutable,才能每次调用都改它,于是依次得到 1、2、3——
    // 这恰恰说明它**不是**每次新建,而是在同一个 Lambda 对象里累加。
    //
    // 至于外部的 invocation_count,它和这个 count 是两个毫无关系的变量,
    // 所以调用三次之后,invocation_count 仍然是 0。
    std::cout << "外部 invocation_count = " << invocation_count << std::endl;  // 输出: 0
    
    return 0;
}

幽默一刻

初始化捕获的语法刚出来的时候,很多人吐槽:“这不就是把赋值语句塞进中括号里吗?”

没错,确实是这样。但就是这么一个简单的语法扩展,解决了C++元编程中的一大痛点。

想象一下没有初始化捕获的世界:你有一个unique_ptr,想把它移动到Lambda里,怎么办?你得先std::move到一个局部变量,然后捕获那个变量。但问题是——那个局部变量本身就是个累赘。

有了初始化捕获,一行搞定:[ptr = std::move(original_ptr)]()。



24.9 容易被漏掉的几个 C++14 特性

C++14 的"八大特性"是各大教程的常驻嘉宾,但标准里其实还塞了几个不显眼但很好用的东西。 它们不像泛型 Lambda 那样天天上头条,却经常在你需要的时候突然出现。

24.9.1 [[deprecated]]:正式的标准属性(注意:这是 C++14,不是 C++11!)

[[...]] 这种属性语法是 C++11 引入的,但 C++11 里只定义了 [[noreturn]] 和 [[carries_dependency]]。 大家最常用的 [[deprecated]] 其实是 C++14 才进入标准的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iostream>

[[deprecated("请改用 newAPI()")]]      // 可以带一句解释,编译警告里会原样打出来
void oldAPI() {
    std::cout << "老接口" << std::endl;
}

struct [[deprecated]] OldStruct {      // 属性写在 struct 关键字之后
    int x;
};

int main() {
    // oldAPI();     // ⚠️ 编译警告:'oldAPI' is deprecated: 请改用 newAPI()
    // OldStruct s;  // ⚠️ 编译警告:'OldStruct' is deprecated

    std::cout << "已标记废弃的接口,调用时会收到编译警告" << std::endl;
    return 0;
}

⚠️ 一个实测小坑:Apple clang 21 在 -std=c++11 下也接受 [[deprecated]] 并且照样报 -Wdeprecated-declarations 警告——这是编译器的"向前兼容”,不代表它是 C++11 的特性。 换个严格遵守标准的编译器(或看特性宏 __has_cpp_attribute(deprecated)),就能看出差别。

24.9.2 std::exchange:一步完成"取值 + 换值"

这是 <utility> 里的小工具,签名是 exchange(obj, new_value)——返回 obj 的旧值,同时把 obj 改成 new_value。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <iostream>
#include <string>
#include <utility>

int main() {
    std::string a = "hello";

    // 把 a 换成 "world",同时把旧值 "hello" 拿走
    std::string b = std::exchange(a, "world");

    std::cout << "a = " << a << std::endl;   // 输出: a = world
    std::cout << "b = " << b << std::endl;   // 输出: b = hello
    return 0;
}

它在实现移动构造函数、移动赋值运算符时特别顺手:

1
2
3
4
5
6
7
8
9
class Buffer {
    int* data_ = nullptr;
    std::size_t size_ = 0;

public:
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)),   // 一步:接管指针 + 把对方置空
          size_(std::exchange(other.size_, 0)) {}
};

24.9.3 std::integer_sequence:编译期的整数序列

std::integer_sequence<T, Is...> 和它的别名 std::index_sequence<0,1,2,...> 是 C++14 放进 <utility> 的。 它的价值在于:把"参数包"变成"一串可以用在编译期的下标",这是展开 tuple、实现泛型 apply 的基础工具。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <cstddef>
#include <iostream>
#include <utility>

template<typename T, T... Is>
void print_seq(std::integer_sequence<T, Is...>) {
    // C++14 里还没有折叠表达式,用数组初始化的老技巧把包展开
    int dummy[] = { (std::cout << Is << ' ', 0)... };
    (void)dummy;
    std::cout << std::endl;
}

int main() {
    print_seq(std::integer_sequence<int, 0, 1, 2, 3>{});   // 手写序列
    print_seq(std::make_index_sequence<5>{});              // 自动生成 0 1 2 3 4

    std::cout << "index_sequence<0,1,2,3,4>::size() = "
              << std::index_sequence<0, 1, 2, 3, 4>{}.size() << std::endl;
    return 0;
}

输出:

1
2
3
0 1 2 3
0 1 2 3 4
index_sequence<0,1,2,3,4>::size() = 5

24.9.4 带大小的释放函数(sized deallocation)

这可能是 C++14 里最"存在感为零"的一个特性。C++14 允许你写这样一个释放函数:

1
static void operator delete(void* p, std::size_t n) noexcept;

多了个 n 参数,表示"这块内存有多大"。为什么有用? 因为分配器以前必须把"每块内存多大"记在旁边, 才能在你 delete 的时候还回去;现在编译器可以直接把大小告诉你,分配器就省下了那份记录。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <cstddef>
#include <iostream>
#include <new>

struct Big {
    double payload[2];

    static void* operator new(std::size_t n) {
        std::cout << "new: 申请 " << n << " 字节" << std::endl;
        return ::operator new(n);
    }

    // C++14 新增:带 size 参数的释放函数
    static void operator delete(void* p, std::size_t n) noexcept {
        std::cout << "sized delete: 归还 " << n << " 字节" << std::endl;
        ::operator delete(p);
    }
};

int main() {
    Big* b = new Big;
    delete b;          // 对象大小在编译期已知,于是走"带大小"的版本
    return 0;
}

输出:

1
2
new: 申请 16 字节
sized delete: 归还 16 字节

💡 注意:上面两个 std::size_t 的值都是 16,因为 Big 里有两个 double——不是 sizeof(Big) 之外的什么东西, 就是对象本身的大小(new 申请的就是它)。这也顺便解释了为什么数组 new[] 需要额外记录元素个数、 而这里的单对象 new 不需要。

24.9.5 std::quoted:让字符串能"带着引号"来回走

<iomanip> 里的 std::quoted。它的用途很具体:输出时给字符串加引号,输入时把引号去掉, 这样含空格的字符串也能安全地读写。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>

int main() {
    std::string s = "hello world";

    std::cout << std::quoted(s) << std::endl;        // 输出: "hello world"
    std::cout << "长度 = " << s.size() << std::endl;  // 输出: 长度 = 11

    std::stringstream ss;
    ss << std::quoted(s);          // 写进去,带引号
    std::string back;
    ss >> std::quoted(back);       // 读出来,引号自动剥掉
    std::cout << "读回来 = " << back << std::endl;    // 输出: 读回来 = hello world
    return 0;
}

🎯 为什么需要它:std::cin >> str 遇到空格就停,所以直接把 "hello world" 写出去再读回来, 只会得到 "hello"。std::quoted 用一对引号把内容"包起来",读的时候再拆掉, 于是含空格的字符串就能原样往返。写简单的数据文件、做日志的字段级解析时很好用。

本章小结

C++14是C++11之后的一次重要补丁更新,它不是在开创新的范式,而是在完善已有的功能。本章我们学习了C++14的核心特性:

24.1 泛型Lambda

  • 核心变化:auto可以用于Lambda参数
  • 解决的问题:同一个Lambda可以处理不同类型的参数
  • 应用场景:STL算法、回调函数、泛型编程

24.2 变量模板

  • 核心变化:模板可以用于定义变量
  • 解决的问题:给"编译期的值"一个正式的写法,写起来像变量而不是像类型
  • ⚠️ 最易记反的一点:变量模板是 C++14;标准库里成批的 std::xxx_v 是 C++17(_t 别名则是 C++14)
  • 应用场景:编译期类型查询、类型大小判断

24.3 放宽的constexpr限制

  • 核心变化:constexpr函数中可以使用局部变量和循环
  • 解决的问题:更强大的编译期计算能力
  • 应用场景:编译期数学计算、查表优化

24.4 二进制字面量

  • 核心变化:0b前缀可以直接写二进制数
  • 解决的问题:不需要手动转换进制
  • 应用场景:位掩码、权限控制、硬件编程

24.5 数字分隔符

  • 核心变化:数字字面量中可以用单引号分隔
  • 解决的问题:长数字的可读性
  • 应用场景:金额、字节数、科学常数

24.6 返回类型推导

  • 核心变化:auto可以推导函数返回类型
  • 解决的问题:简化函数声明
  • 注意事项:区分auto和decltype(auto)的类型推导规则

24.7 std::make_unique

  • 核心变化:make_unique加入标准库
  • 解决的问题:创建unique_ptr更安全、更简洁
  • 应用场景:独占所有权的资源管理

24.8 带初始化捕获的Lambda

  • 核心变化:捕获列表可以使用表达式初始化
  • 解决的问题:移动捕获、重命名捕获
  • 应用场景:移动语义与Lambda结合

24.9 其他容易被漏掉的特性

  • [[deprecated]]:大家最常用的属性,其实是 C++14 才进标准的(C++11 只有 [[noreturn]]、[[carries_dependency]])
  • std::exchange:exchange(obj, 新值) 一步完成"取旧值 + 换新值",写移动构造/移动赋值特别顺手
  • std::integer_sequence / std::index_sequence:把参数包变成可用在编译期的下标序列
  • 带大小的释放函数:operator delete(void*, std::size_t),让分配器不必再单独记录每块内存的大小
  • std::quoted:让含空格的字符串"带引号"地写出去、再原样读回来

📝 学习建议:C++14的特性虽然看起来简单,但都是"用时方恨少"的实用工具。建议读者在实践中多使用这些特性,感受它们带来的便利。