0%

现代 C++ 学习

现代 C++ 教程

1. 迈向现代 C++

1.1. 被弃用的特性(弃用并非不能用,而是暗示程序员应该尽量避免使用)

  • 不再允许字符串字面值常量赋值给一个 char *,应使用 const char*auto
  • C++98 异常说明、unexpected_handlerset_unexpected()等特性弃用,应使用 noexcept
  • 弃用 auto_ptr,应用 unique_ptr
  • 弃用 register 关键字。
  • bool 类型的 ++ 操作被弃用。
  • 如果一个class类有析构函数,那么为其生成拷贝构造函数和拷贝赋值运算符的特性被弃用。
  • 弃用C语言风格的类型转换(即在变量前使用(new_type)),应用 static_castreinterpret_catconst_cast
  • C++17标准弃用了一下C标准库,如 <ccomplex>cstdaligncstdboolctgmath等。

1.2. 与 C 的兼容性

图 1.2: C 和 C++ 互相兼容情况

2. 语言可用性的强化

2.1. 常量

2.1.1. nullptr

  • C++11 引入 nullptr 关键字,用来区分空指针和0,nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针类型,也可以与他们进行相等或不等的比较。
  • C++ 不允许将 void* 隐式转换为其他类型,因此 ((void*)0) 不是 NULL 的合法实现
    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
    #include <iostream>
    #include <type_traits>

    void foo(char *);
    void foo(int);

    int main() {
    if (std::is_same<decltype(NULL), decltype(0)>::value)
    std::cout << "NULL == 0" << std::endl;
    if (std::is_same<decltype(NULL), decltype((void*)0)>::value)
    std::cout << "NULL == (void *)0" << std::endl;
    if (std::is_same<decltype(NULL), std::nullptr_t>::value)
    std::cout << "NULL == nullptr" << std::endl;

    foo(0); // 调用 foo(int)
    // foo(NULL); // 该行不能通过编译
    foo(nullptr); // 调用 foo(char*)
    return 0;
    }

    void foo(char *) {
    std::cout << "foo(char*) is called" << std::endl;
    }
    void foo(int i) {
    std::cout << "foo(int) is called" << std::endl;
    }
    以上使用clang++将输出:
    1
    2
    3
    NULL == 0
    foo(int) is called
    foo(char*) is called
    取消对 foo(NULL) 的注释,但实际上能够通过编译
    1
    2
    3
    4
    NULL == 0
    foo(int) is called
    foo(int) is called
    foo(char*) is called
    使用g++进行编译:
    1
    2
    foo(int) is called
    foo(char*) is called
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    PS D:\repositories\blog_hexo\code\cpp\modern_cpp_tutorial> g++ -std=c++2a .\2_nullptr.cpp -o 2_nullptr
    .\2_nullptr.cpp: In function 'int main()':
    .\2_nullptr.cpp:16:8: error: call of overloaded 'foo(NULL)' is ambiguous
    16 | foo(NULL); // 璇ヨ? ~~~^~~~~~
    .\2_nullptr.cpp:4:6: note: candidate: 'void foo(char*)'
    4 | void foo(char *);
    | ^~~
    .\2_nullptr.cpp:5:6: note: candidate: 'void foo(int)'
    5 | void foo(int);
    | ^~~
  • 可以看出在clang++中 NULL 等于 0,从而 foo(NULL) 可以编译通过;但g++不行。
  • decltype 用于类型推导,std::is_same 用于比较两个类型是否相同

2.1.2. 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
#include <iostream>
#define LEN 10

int len_foo() {
int i = 2;
return i;
}
constexpr int len_foo_constexpr() {
return 5;
}

constexpr int fibonacci(const int n) {
return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}

int main() {
char arr_1[10]; // 合法
char arr_2[LEN]; // 合法

int len = 10;
// char arr_3[len]; // 非法

const int len_2 = len + 1;
constexpr int len_2_constexpr = 1 + 2 + 3;
// char arr_4[len_2]; // 非法
char arr_4[len_2_constexpr]; // 合法

// char arr_5[len_foo()+5]; // 非法
char arr_6[len_foo_constexpr() + 1]; // 合法

std::cout << fibonacci(10) << std::endl;
// 1, 1, 2, 3, 5, 8, 13, 21, 34, 55
std::cout << fibonacci(10) << std::endl;
return 0;
}
  • constexpr 让用户显示的声明函数或对象构造函数在编译期会成为常量表达式,会让编译器去验证它修饰的函数或对象在编译器就应该是一个常量表达式。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #include <iostream>

    // C++11 可用
    constexpr int fibonacci_1(const int n) {
    return n == 1 || n == 2 ? 1 : fibonacci_1(n-1)+fibonacci_1(n-2);
    }
    // C++14 可用
    constexpr int fibonacci_2(const int n) {
    if(n == 1) return 1;
    if(n == 2) return 1;
    return fibonacci_2(n-1) + fibonacci_2(n-2);
    }

    int main() {
    std::cout << fibonacci_1(10) << std::endl;
    std::cout << fibonacci_2(10) << std::endl;
    char test1[fibonacci_1(4)]; // 合法
    char test2[fibonacci_2(4)]; // 合法
    return 0;
    }

2.2. 变量及其初始化

2.2.1. if/switch 变量声明强化

  • 传统C++中,虽然可以在 for 语句中声明一个临时变量,但无法在 if 和 switch 语句中声明一个临时变量,例如:
    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 <vector>
    #include <algorithm>

    int main() {
    std::vector<int> vec = {1, 2, 3, 4};

    // 在 c++17 之前
    const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 2);
    if (itr != vec.end()) {
    *itr = 3;
    }

    // 需要重新定义一个新的变量
    const std::vector<int>::iterator itr2 = std::find(vec.begin(), vec.end(), 3);
    if (itr2 != vec.end()) {
    *itr2 = 4;
    }

    // 将输出 1, 4, 3, 4
    for (std::vector<int>::iterator element = vec.begin(); element != vec.end();
    ++element)
    std::cout << *element << std::endl;
    }
  • 从 C++17 开始,可以在 if 或 switch 语句中声明临时变量(和 Go 语言很想):
    1
    2
    3
    4
    // 将临时变量放到 if 语句中
    if(const std::vector<int>::iterator itr = std::find(vec.begin(), vec.end(), 3)); itr != vec.end()) {
    *itr = 4;
    }

2.2.2. 初始化列表

  • 在传统C++中,不同的对象有不同的初始化方法,例如普通数组、POD(Plain Old Data,即没有构造、析构和虚函数的类或结构体)类型使用 {} 进行初始化。对于类对象的初始化,要么使用拷贝构造,要么使用 () 进行初始化。因此传统C++中不同对象初始化方式不统一:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    #include <iostream>
    #include <vector>

    class Foo {
    public:
    int value_a;
    int value_b;
    Foo(int a, int b) : value_a(a), value_b(b) {}
    };

    int main() {
    // before C++11
    int arr[3] = {1, 2, 3};
    Foo foo(1, 2);
    std::vector<int> vec = {1, 2, 3, 4, 5};

    std::cout << "arr[0]: " << arr[0] << std::endl;
    std::cout << "foo:" << foo.value_a << ", " << foo.value_b << std::endl;
    for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it) {
    std::cout << *it << std::endl;
    }
    return 0;
    }
  • C++11 开始,提供统一的语法 {} 来初始化任意对象:
    1
    Foo foo2 {3, 4};
  • C++11把初始化列表的概念绑定到类型上,称其为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这为类对象的初始化与普通数组和POD的初始化提供了统一的桥梁:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <initializer_list>
    #include <vector>
    #include <iostream>

    class MagicFoo {
    public:
    std::vector<int> vec;
    MagicFoo(std::initializer_list<int> list) {
    for (std::initializer_list<int>::iterator it = list.begin();
    it != list.end(); ++it)
    vec.push_back(*it);
    }
    };
    int main() {
    // after C++11
    MagicFoo magicFoo = {1, 2, 3, 4, 5};

    std::cout << "magicFoo: ";
    for (std::vector<int>::iterator it = magicFoo.vec.begin();
    it != magicFoo.vec.end(); ++it)
    std::cout << *it << std::endl;
    }
  • 初始化列表还可以作为普通函数的形参:
    1
    2
    3
    4
    5
    6
    7
    public:
    void foo(std::initializer_list<int> list) {
    for(std::initializer_list<int>::iterator it = list.begin(); it != list.end(); ++it)
    vec.push_back(*it);
    }

    magic.foo({6, 7, 8, 9});

2.2.3. 结构化绑定

  • C++17 提供了 结构化绑定 用于实现类似于其他语言提供的多返回值的功能:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>
    #include <tuple>

    std::tuple<int, double, std::string> f() {
    return std::make_tuple(1, 2.3, "456");
    }

    int main() {
    auto [x, y, z] = f();
    std::cout << x << ", " << y << ", " << z << std::endl;
    return 0;
    }

2.3. 类型推导

C++11 引入了 autodecltype 这两个关键字来实现类型推导,让编译器来操心变量的类型。

2.3.1. auto

  • 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

    #include <initializer_list>
    #include <vector>
    #include <iostream>

    class MagicFoo {
    public:
    std::vector<int> vec;
    MagicFoo(std::initializer_list<int> list) {
    // 从 C++11 起, 使用 auto 关键字进行类型推导
    for (auto it = list.begin(); it != list.end(); ++it) {
    vec.push_back(*it);
    }
    }
    };
    int main() {
    MagicFoo magicFoo = {1, 2, 3, 4, 5};
    std::cout << "magicFoo: ";
    for (auto it = magicFoo.vec.begin(); it != magicFoo.vec.end(); ++it) {
    std::cout << *it << ", ";
    }
    std::cout << std::endl;

    auto i = 5; // i 被推导为 int
    auto arr = new auto(10); // arr 被推导为 int *

    return 0;
    }
  • 从C++14开始,auto 能用于 lambda 表达式的函数传参,C++20起该功能推广到一般函数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    auto add14 = [](auto x, auto y) -> int {
    return x+y;
    };

    int add20(auto x, auto y) {
    reutrn x+y;
    }

    auto i=5;
    auto j=6;
    std::cout << add14(i, j) << std::endl;
    std::cout << add20(i, j) << std::endl;
  • auto 还不能用于推导数组类型:
    1
    auto auto_arr2[10] = {arr}; // 错误, 无法推导数组元素类型

2.3.2. decltype

  • decltype 关键字用于解决 auto 关键字只能对变量进行类型推导的缺陷,用法与 typeof 类型:
    1
    decltype(表达式)
  • 计算某个表达式的类型:
    1
    2
    3
    auto x=1;
    auto y=2;
    decltype(x+y) z;
  • decltype 还可以结合 std::is_same 用于判断两个类型是否相等:
    1
    2
    3
    4
    5
    6
    if (std::is_same<decltype(x), int>::value)
    std::cout << "type x == int" << std::endl;
    if (std::is_same<decltype(x), float>::value)
    std::cout << "type x == float" << std::endl;
    if (std::is_same<decltype(x), decltype(z)>::value)
    std::cout << "type z == type x" << std::endl;

2.3.3. 尾返回类型推导

  • C++11中,可以利用 auto 关键字将返回类型后置,再利用 decltype 关键字自动推导返回类型:
    1
    2
    3
    4
    template<typename T, typename U>
    auto add2(T x, U y) -> decltype(x+y) {
    return x+y;
    }
  • C++14开始,可以直接只使用 auto 关键字,让普通函数具备返回值推导:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    template<typename T, typename U>
    auto add3(T x, U y) {
    return x+y;
    }

    // after c++11
    auto w = add2<int, double>(1, 2.0);
    if (std::is_same<decltype(w), double>::value) {
    std::cout << "w is double: ";
    }
    std::cout << w << std::endl;

    // after c++14
    auto q = add3<double, int>(1.0, 2);
    std::cout << "q: " << q << std::endl;

2.3.4. decltype(auto)

  • decltype(auto) 用于对转发函数或封装的返回类型进行推导,它让我们无需显示的指定 decltype 的参数表达式
  • 考虑看下面的例子,当我们需要对下面两个函数进行封装时:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    std::string  lookup1();
    std::string& lookup2();
    在 C++11 中,封装实现是如下形式:

    std::string look_up_a_string_1() {
    return lookup1();
    }
    std::string& look_up_a_string_2() {
    return lookup2();
    }
  • 而有了 decltype(auto),我们可以让编译器完成这一件烦人的参数转发:
    1
    2
    3
    4
    5
    6
    decltype(auto) look_up_a_string_1() {
    return lookup1();
    }
    decltype(auto) look_up_a_string_2() {
    return lookup2();
    }

2.4. 控制流

2.4.1. if constexpr

  • C++11 引入了 constexpr,使得表达式或函数在编译的时候就表现为常量结果。如果将这一特性引入条件判断,那么代码在编译时就可以完成分支判断,从而让程序效率更高。
  • C++17 将 constexpr 关键字引入到 if 语句中:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include <iostream>

    template<typename T>
    auto print_type_info(const T& t) {
    if constexpr (std::is_integral<T>::value) {
    return t + 1;
    } else {
    return t + 0.001;
    }
    }
    int main() {
    std::cout << print_type_info(5) << std::endl;
    std::cout << print_type_info(3.14) << std::endl;
    }
  • 在编译时,实际代码就会表现为如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int print_type_info(const int& t) {
    return t + 1;
    }
    double print_type_info(const double& t) {
    return t + 0.001;
    }
    int main() {
    std::cout << print_type_info(5) << std::endl;
    std::cout << print_type_info(3.14) << std::endl;
    }

2.4.2. 区间 for 迭代

  • C++11 引入基于范围的迭代写法:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>
    #include <vector>
    #include <algorithm>

    int main() {
    std::vector<int> vec = {1, 2, 3, 4};
    if (auto itr = std::find(vec.begin(), vec.end(), 3); itr != vec.end()) *itr = 4;
    for (auto element : vec)
    std::cout << element << std::endl; // read only
    for (auto &element : vec) {
    element += 1; // writeable
    }
    for (auto element : vec)
    std::cout << element << std::endl; // read only
    }

2.5. 模板

C++ 的模板一直是这门语言的一种特殊的艺术,模板甚至可以独立作为一门新的语言来进行使用。模板的哲学在于将一切能够在编译期处理的问题丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅优化运行期的性能。因此模板也被很多人视作 C++ 的黑魔法之一。

2.5.1. 外部模板

  • 传统C++中,模板只有在使用时才会被编译器实例化。因此,只要每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化,从而就发生重复实例化,进而导致编译时间增加。
  • C++11 引入了外部模板,让我们能够显式的通知编译器何时进行模板的实例化:
    1
    2
    template class std::vector<bool>;   // 强制实例化
    extern template class std::vector<double>; // 不在当前编译文件中实例化

2.5.2. 尖括号”>”

  • 传统C++中,>> 一律被当作右移运算符来处理,导致嵌套的模板代码如:std::vector<std::vector<int>> matrix; 不能被编译
  • C++开始,连续的右尖括号将变得合法:
    1
    2
    3
    4
    5
    6
    7
    template<bool T>
    class MagicType {
    bool magic = T;
    };

    // in main function:
    std::vector<MagicType<(1>2)>> magic; // 合法, 但不建议写出这样的代码

2.5.3. 类型别名模板

  • 模板类型 的不同:模板是用来产生类型的。
  • 传统C++中,typedef 可以用来为类型定义一个新的名称,但无法为模板定义一个新的名称,因为模板不是类型:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename T, typename U>
    class MagicType {
    public:
    T dark;
    U magic;
    };

    // 不合法
    template<typename T>
    typedef MagicType<std::vector<T>, std::string> FakeDarkMagic;
  • 同时,typedef 定义别名的语法是:typedef 原名称 新名称,但对函数指针等别名的定义语法却不同,容易造成阅读困难。
  • C++11 引入 using,实现对类型和模板定义别名:
    1
    2
    3
    4
    5
    6
    7
    8
    typedef int (*process)(void*);
    using NewProcess = int (*)(void*);
    template<typename T>
    using TrueDarkMagic = MagicType<std::vector<T>, std::string>;

    int main() {
    TrueDarkMagic<bool> you;
    }

2.5.4. 变长参数模板

  • C++11允许类模板或函数模板接受任意个数、任意类别的模板参数(0个也行):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename... Ts> class Magic;

    class Magic<int,
    std::vector<int>,
    std::map<std::string,
    std::vector<int>>> darkMagic;

    class Magic<> nothing; // 0个模板参数

    template<typename Require, typename... Args> class Magic; // 至少1个模板参数

2.5.5. 折叠表达式

  • C++ 17 中将变长参数这种特性进一步带给了表达式
    1
    2
    3
    4
    5
    6
    7
    8
    #include <iostream>
    template<typename ... T>
    auto sum(T ... t) {
    return (t + ...);
    }
    int main() {
    std::cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << std::endl;
    }
  • C++17 中,折叠表达式提供了一种对 变长模板参数包(parameter pack)进行操作的简洁方式。语法 (args op …) 表示对变长参数包 args… 进行逐个运算,并应用操作符 op。
  • t + ... 表示将变长参数 t… 中的所有元素按顺序相加。这里的 + 是 二元操作符,表示对所有的 t 参数执行累加操作。

2.5.6. 非类型模板参数推导

  • 除了让类型作为模板参数,还可以让不同 字面量 成为模板参数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template <typename T, int BufSize>
    class buffer_t {
    public:
    T& alloc();
    void free(T& item);
    private:
    T data[BufSize];
    }

    buffer_t<int, 100> buf; // 100 作为模板参数
  • C++17开始,可以通过使用占位符 auto 让编译器辅助我们进行字面量的类型推导:
    1
    2
    3
    4
    5
    6
    7
    8
    template<auto value> void foo() {
    std::cout << value << std::endl;
    return;
    }

    int main() {
    foo<10>(); // value 被推导为 int 类型
    }

2.6. 面向对象

2.6.1. 委托构造

  • C++11 引入委托构造,使得一个类中的一个构造函数可以调用另一个构造函数,从而达到简化代码的目的:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <iostream>
    class Base {
    public:
    int value1;
    int value2;
    Base() {
    value1 = 1;
    }
    Base(int value) : Base() { // 委托 Base() 构造函数
    value2 = value;
    }
    };

    int main() {
    Base b(2);
    std::cout << b.value1 << std::endl;
    std::cout << b.value2 << std::endl;
    }

2.6.2. 继承构造

  • 传统C++中,构造函数如果需要继承需要将参数一一传递,导致效率低下。C++11 中可以利用关键字 using 引入继承构造函数的概念:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    #include <iostream>
    class Base {
    public:
    int value1;
    int value2;
    Base() {
    value1 = 1;
    }
    Base(int value) : Base() { // 委托 Base() 构造函数
    value2 = value;
    }
    };
    class Subclass : public Base {
    public:
    using Base::Base; // 继承构造
    };
    int main() {
    Subclass s(3);
    std::cout << s.value1 << std::endl;
    std::cout << s.value2 << std::endl;
    }

2.6.3. 显式虚函数重载

  • C++11 引入了 override 和 final 这两个关键字来防止 子类中不小心加入了一个与父类虚函数相同名字的函数 或者 当基类的虚函数被删除后,子类拥有旧的函数就不再重载该虚拟函数并摇身一变成为了一个普通的类方法,这将造成灾难性的后果。
  • override: 当重载虚函数时,引入 override 关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的其函数签名一致的虚函数,否则将无法通过编译:
    1
    2
    3
    4
    5
    6
    7
    struct Base {
    virtual void foo(int);
    };
    struct SubClass: Base {
    virtual void foo(int) override; // 合法
    virtual void foo(float) override; // 非法, 父类没有此虚函数
    };
  • final: 防止类被继续继承,以及终止虚函数继续重载引入:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    struct Base {
    virtual void foo() final;
    };
    struct SubClass1 final: Base {
    }; // 合法

    struct SubClass2 : SubClass1 {
    }; // 非法, SubClass1 已 final

    struct SubClass3: Base {
    void foo(); // 非法, foo 已 final
    };

2.6.4. 显式禁用默认函数

  • C++11 允许显式的声明采用或拒绝编译器自带的函数:
    1
    2
    3
    4
    5
    6
    class Magic {
    public:
    Magic() = default; // 显式声明使用编译器生成的构造
    Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
    Magic(int magic_number);
    }

2.6.5. 强类型枚举

  • 传统C++中,枚举类型并非类型安全,枚举类型会被视作整数,从而使得两个不同的枚举类型可以进行直接的比较,甚至同一个命名空间的不同枚举类型的枚举值不能相同
  • C++11 引入枚举类:
    1
    2
    3
    4
    5
    6
    enum class new_enum: unsigned int {
    value1,
    value2,
    value3 = 100,
    value4 = 100
    };
  • 在这个语法中,枚举类型后面使用了冒号及类型关键字来指定枚举中枚举值的类型,这使得我们能够为枚举赋值(未指定时将默认使用 int)。
  • 而我们希望获得枚举值的值时,将必须显式的进行类型转换,不过我们可以通过重载 << 这个算符来进行输出,可以收藏下面这个代码段:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>
    template<typename T>
    std::ostream& operator<<(
    typename std::enable_if<std::is_enum<T>::value,
    std::ostream>::type& stream, const T& e)
    {
    return stream << static_cast<typename std::underlying_type<T>::type>(e);
    }

    int main() {
    std::cout << new_enum::value3 << std::endl;
    }

3. 语言运行期的强化

3.1. Lambda 表达式

Lambda 表达式提供了类似于匿名函数的特性,用于需要一个函数又不想费力去命名的场景。

3.1.1. 基础

基础

  • Lambda 表达式的基本语法:
    1
    2
    3
    [捕获列表](参数列表) mutable(可选) 异常属性 -> 返回类型 {
    // 函数体
    }

值捕获

  • 值捕获的前提是变量是可拷贝的,另外被捕获的变量是在 Lambda 表达式被创建时拷贝,而非调用时拷贝:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void lambda_value_capture() {
    int value = 1;
    auto copy_value = [value] {
    return value;
    };
    value = 100;
    auto stored_value = copy_value();
    std::cout << "stored_value = " << stored_value << std::endl;
    // 这时, stored_value == 1, 而 value == 100.
    // 因为 copy_value 在创建时就保存了一份 value 的拷贝
    }

引用捕获

  • 保存变量的引用,值会发生变化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void lambda_reference_capture() {
    int value = 1;
    auto copy_value = [&value] {
    return value;
    };
    value = 100;
    auto stored_value = copy_value();
    std::cout << "stored_value = " << stored_value << std::endl;
    // 这时, stored_value == 100, value == 100.
    // 因为 copy_value 保存的是引用
    }

隐式捕获

  • 可以在捕获列表中写一个 &= 来向编译器声明采样引用捕获还是值捕获
  • 总结一下,捕获提供了 Lambda 表达式对外部值进行使用的功能,捕获列表的最常用的四种形式可以是:
    • [] 空捕获列表
    • [name1, name2, …] 捕获一系列变量
    • [&] 引用捕获, 从函数体内的使用确定引用捕获列表
    • [=] 值捕获, 从函数体内的使用确定值捕获列表

表达式捕获

  • C++14 允许捕获的成员用任意的表达式进行初始化,这就允许了右值捕获,被声明的捕获变量的类型会根据表达式进行判断,判断方式与使用 auto 本质上相同:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <iostream>
    #include <memory> // std::make_unique
    #include <utility> // std::move

    void lambda_expression_capture() {
    auto important = std::make_unique<int>(1);
    auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
    return x+y+v1+(*v2);
    };
    std::cout << add(3,4) << std::endl;
    }

泛型 Lambda

  • C++14 开始,Lambda 函数的形式参数可以使用 auto 关键字来产生意义上的泛型:
    1
    2
    3
    4
    5
    6
    auto add = [](auto x, auto y) {
    return x+y;
    };

    add(1, 2);
    add(1.1, 2.2);

3.2. 函数对象包装器

3.2.1. std::function

  • Lambda 表达式的本质是一个与函数对象类型相似的类类型(称为闭包类型)的对象,当 Lambda 表达式的捕获列表为空时,闭包类型可以转换为函数指针值进行传递:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <iostream>

    using foo = void(int); // 定义函数类型, using 的使用见上一节中的别名语法
    void functional(foo f) { // 参数列表中定义的函数类型 foo 被视为退化后的函数指针类型 foo*
    f(1); // 通过函数指针调用函数
    }

    int main() {
    auto f = [](int value) {
    std::cout << value << std::endl;
    };
    functional(f); // 传递闭包对象,隐式转换为 foo* 类型的函数指针值
    f(1); // lambda 表达式调用
    return 0;
    }
  • 在 C++11,进行了这些可调用对象的统一,将能够被调用的对象的类型,统一称为 可调用对象,通过 std::function 引入。
  • std::function 是一种通用、多态的函数封装,它的实例可以对任何可调用的目标实体进行存储、复制和调用操作,它是C++中可调用实体的一种类型安全的包裹(相对而言,函数指针的调用不是类型安全的)。因此,std::function 就是函数的容器,同时可以像常规函数一样使用 () 调用它,实现调用其内部封装的函数的功能:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include <functional>
    #include <iostream>

    int foo(int para) {
    return para;
    }

    int main() {
    // std::function 包装了一个返回值为 int, 参数为 int 的函数
    std::function<int(int)> func = foo;

    int important = 10;
    std::function<int(int)> func2 = [&](int value) -> int {
    return 1+value+important;
    };
    std::cout << func(10) << std::endl;
    std::cout << func2(10) << std::endl;
    }

3.2.2. std::bind 和 std::placeholder

  • std::bind 是用来绑定函数调用的参数的,而 std::placeholder 是用来对某个参数进行占位的,当在使用 std::bind 进行参数绑定时,如果某个参数并不能立即提供,可以使用 std::placeholder 进行占位,之后调用函数时再将占位的参数填到 () 中:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int foo(int a, int b, int c) {
    ;
    }
    int main() {
    // 将参数1,2绑定到函数 foo 上,
    // 但使用 std::placeholders::_1 来对第一个参数进行占位
    auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
    // 这时调用 bindFoo 时,只需要提供第一个参数即可
    bindFoo(1);
    }

3.3. 右值引用

右值引用的引入解决了 C++ 中大量的历史遗留问题,消除了诸如 std::vector、std::string 之类的额外开销,也使得函数对象容器 std::function 成为可能。

3.3.1. 左值、右值的纯右值、将亡值、右值

  • 左值(lvalue, left value),顾名思义就是赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表示式)后依旧存在的持久对象。

  • 右值(rvalue, right value),右边的值,指表达式结束后就不再存在的临时对象。

  • C++11 中为了引入右值引用,将右值的概念进一步划分,分为:纯右值、将亡值:

    • 纯右值(prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如:10, true;要么是求值结果相当于字面量或匿名临时对象,例如:1+2。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、Lambda表达式等都是纯右值。
    • 但是,字面量中有一个例外,字符串字面量是左值,而非纯右值,字符串字面量的类型是 const char 数组
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    #include <type_traits>

    int main() {
    // 正确,"01234" 类型为 const char [6],因此是左值
    const char (&left)[6] = "01234";

    // 断言正确,确实是 const char [6] 类型,注意 decltype(expr) 在 expr 是左值
    // 且非无括号包裹的 id 表达式与类成员表达式时,会返回左值引用
    static_assert(std::is_same<decltype("01234"), const char(&)[6]>::value, "");

    // 错误,"01234" 是左值,不可被右值引用
    // const char (&&right)[6] = "01234";
    }
    • 但是注意,数组可以被隐式转换成相对应的指针类型,而转换表达式的结果(如果不是左值引用)则一定是个右值(右值引用为将亡值,否则为纯右值)。例如:
      1
      2
      3
      const char*   p   = "01234";  // 正确,"01234" 被隐式转换为 const char*
      const char*&& pr = "01234"; // 正确,"01234" 被隐式转换为 const char*,该转换的结果是纯右值
      // const char*& pl = "01234"; // 错误,此处不存在 const char* 类型的左值
    • 将亡值(xvalue, expiring value),指即将被销毁、却能够被移动的值:
      1
      2
      3
      4
      5
      6
      std::vector<int> foo() {
      std::vector<int> temp = {1, 2, 3, 4};
      return temp;
      }

      std::vector<int> v = foo();
      在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v, 然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大, 这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中,v 是左值、 foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到, 而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。 而将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。
      在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换, 等价于 static_cast<std::vector<int> &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。 也就是后面我们将会提到的移动语义。

3.3.2. 右值引用和左值引用

  • C++11 提供了 std::move 方法将左值参数无条件的转换为右值:
    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 <string>

    void reference(std::string& str) {
    std::cout << "左值" << std::endl;
    }
    void reference(std::string&& str) {
    std::cout << "右值" << std::endl;
    }

    int main()
    {
    std::string lv1 = "string,"; // lv1 是一个左值
    // std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
    std::string&& rv1 = std::move(lv1); // 合法, std::move可以将左值转移为右值
    std::cout << rv1 << std::endl; // string,

    const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
    // lv2 += "Test"; // 非法, 常量引用无法被修改
    std::cout << lv2 << std::endl; // string,string,

    std::string&& rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
    rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
    std::cout << rv2 << std::endl; // string,string,string,Test

    reference(rv2); // 输出左值

    return 0;
    }
  • rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值

3.3.3. 移动语义

  • 传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。 右值引用的出现恰好就解决了这两个概念的混淆问题,例如:
    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
    #include <iostream>
    class A {
    public:
    int *pointer;
    A():pointer(new int(1)) {
    std::cout << "构造" << pointer << std::endl;
    }
    A(A& a):pointer(new int(*a.pointer)) {
    std::cout << "拷贝" << pointer << std::endl;
    } // 无意义的对象拷贝
    A(A&& a):pointer(a.pointer) {
    a.pointer = nullptr;
    std::cout << "移动" << pointer << std::endl;
    }
    ~A(){
    std::cout << "析构" << pointer << std::endl;
    delete pointer;
    }
    };
    // 防止编译器优化
    A return_rvalue(bool test) {
    A a,b;
    if(test) return a; // 等价于 static_cast<A&&>(a);
    else return b; // 等价于 static_cast<A&&>(b);
    }
    int main() {
    A obj = return_rvalue(false);
    std::cout << "obj:" << std::endl;
    std::cout << obj.pointer << std::endl;
    std::cout << *obj.pointer << std::endl;
    return 0;
    }
  • 上面的代码, 避免了无意义的拷贝构造,加强了性能:
    • 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
    • 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。
  • 再看看标准库的例子:
    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> // std::cout
    #include <utility> // std::move
    #include <vector> // std::vector
    #include <string> // std::string

    int main() {

    std::string str = "Hello world.";
    std::vector<std::string> v;

    // 将使用 push_back(const T&), 即产生拷贝行为
    v.push_back(str);
    // 将输出 "str: Hello world."
    std::cout << "str: " << str << std::endl;

    // 将使用 push_back(const T&&), 不会出现拷贝行为
    // 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
    // 这步操作后, str 中的值会变为空
    v.push_back(std::move(str));
    // 将输出 "str: "
    std::cout << "str: " << str << std::endl;

    return 0;
    }

3.3.4. 完美转发

  • 由于一个声明的右值引用其实是一个左值,这就为我们进行参数转发(传递)造成了问题:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    void reference(int& v) {
    std::cout << "左值" << std::endl;
    }
    void reference(int&& v) {
    std::cout << "右值" << std::endl;
    }
    template <typename T>
    void pass(T&& v) {
    std::cout << "普通传参:";
    reference(v); // 始终调用 reference(int&)
    }
    int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1); // 1是右值, 但输出是左值

    std::cout << "传递左值:" << std::endl;
    int l = 1;
    pass(l); // l 是左值, 输出左值

    return 0;
    }
  • 对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。 因此 reference(v) 会调用 reference(int&),输出『左值』。 而对于pass(l)而言,l是一个左值,为什么会成功传递给 pass(T&&) 呢?
  • 这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用, 但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用, 既能左引用,又能右引用。但是却遵循如下规则:
函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&
  • 因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。 更准确的讲,无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。 这才使得 v 作为左值的成功传递。
  • 需理解以下代码及其输出的原理:
    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 <utility>
    void reference(int& v) {
    std::cout << "左值引用" << std::endl;
    }
    void reference(int&& v) {
    std::cout << "右值引用" << std::endl;
    }
    template <typename T>
    void pass(T&& v) {
    std::cout << " 普通传参: ";
    reference(v);
    std::cout << " std::move 传参: ";
    reference(std::move(v));
    std::cout << " std::forward 传参: ";
    reference(std::forward<T>(v));
    std::cout << "static_cast<T&&> 传参: ";
    reference(static_cast<T&&>(v));
    }
    int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1);

    std::cout << "传递左值:" << std::endl;
    int v = 1;
    pass(v);

    return 0;
    }
    输出结果为:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    传递右值:
    普通传参: 左值引用
    std::move 传参: 右值引用
    std::forward 传参: 右值引用
    static_cast<T&&> 传参: 右值引用
    传递左值:
    普通传参: 左值引用
    std::move 传参: 右值引用
    std::forward 传参: 左值引用
    static_cast<T&&> 传参: 左值引用

4. 容器

4.1. 线性容器

4.1.1. std::array

  • 相比于 std::vector,std::array 对象的大小是固定的,对 std::vector 容器进行删除操作后,容器并不会自动归还删除元素相应的内存(capicity部分),需要手动运行 shrink_to_fit() 释放这部分内存:
    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
    std::vector<int> v;
    std::cout << "size:" << v.size() << std::endl; // 输出 0
    std::cout << "capacity:" << v.capacity() << std::endl; // 输出 0

    // 如下可看出 std::vector 的存储是自动管理的,按需自动扩张
    // 但是如果空间不足,需要重新分配更多内存,而重分配内存通常是性能上有开销的操作
    v.push_back(1);
    v.push_back(2);
    v.push_back(3);
    std::cout << "size:" << v.size() << std::endl; // 输出 3
    std::cout << "capacity:" << v.capacity() << std::endl; // 输出 4

    // 这里的自动扩张逻辑与 Golang 的 slice 很像
    v.push_back(4);
    v.push_back(5);
    std::cout << "size:" << v.size() << std::endl; // 输出 5
    std::cout << "capacity:" << v.capacity() << std::endl; // 输出 8

    // 如下可看出容器虽然清空了元素,但是被清空元素的内存并没有归还
    v.clear();
    std::cout << "size:" << v.size() << std::endl; // 输出 0
    std::cout << "capacity:" << v.capacity() << std::endl; // 输出 8

    // 额外内存可通过 shrink_to_fit() 调用返回给系统
    v.shrink_to_fit();
    std::cout << "size:" << v.size() << std::endl; // 输出 0
    std::cout << "capacity:" << v.capacity() << std::endl; // 输出 0
  • std::array 封装了一些操作函数,比如获取数组大小以及检查是否非空,同时能够支持使用标准库的容器算法,比如 std::sort:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    std::array<int, 4> arr = {1, 2, 3, 4};

    arr.empty(); // 检查容器是否为空
    arr.size(); // 返回容纳的元素数

    // 迭代器支持
    for (auto &i : arr)
    {
    // ...
    }

    // 用 lambda 表达式排序
    std::sort(arr.begin(), arr.end(), [](int a, int b) {
    return b < a;
    });

    // 数组大小参数必须是常量表达式
    constexpr int len = 4;
    std::array<int, len> arr = {1, 2, 3, 4};

    // 非法,不同于 C 风格数组,std::array 不会自动退化成 T*
    // int *arr_p = arr;
  • std::array 兼容 C 风格的接口:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void foo(int *p, int len) {
    return;
    }

    std::array<int, 4> arr = {1,2,3,4};

    // C 风格接口传参
    // foo(arr, arr.size()); // 非法, 无法隐式转换
    foo(&arr[0], arr.size());
    foo(arr.data(), arr.size());

    // 使用 `std::sort`
    std::sort(arr.begin(), arr.end());

4.1.2. std::forward_list

  • std::forward_list 是列表容器,使用单向列表进行实现,提供了 O(1) 复杂度的元素插入,不支持快速随机访问(这也是链表的特点),不提供 size() 方法(标准库唯一),相比于 std::list 不需要双向迭代,具有更高的空间利用率。

4.2. 无序容器

  • C++11 引入了2组无序容器:std::unordered_map/std::unordered_multimap 和 std::unordered_set/std::unordered_multiset,用法与 std::map/std::multimap/std::set/std::multiset 基本类似
  • 无序容器内部的元素是不进行排序的,通过 Hash 表实现,插入和搜索元素的平均复杂度为 O(constant),在不关心容器内部元素顺序时,可以显著提升性能:
    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>
    #include <string>
    #include <unordered_map>
    #include <map>

    int main() {
    // 两组结构按同样的顺序初始化
    std::unordered_map<int, std::string> u = {
    {1, "1"},
    {3, "3"},
    {2, "2"}
    };
    std::map<int, std::string> v = {
    {1, "1"},
    {3, "3"},
    {2, "2"}
    };

    // 分别对两组结构进行遍历
    std::cout << "std::unordered_map" << std::endl;
    for( const auto & n : u)
    std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";

    std::cout << std::endl;
    std::cout << "std::map" << std::endl;
    for( const auto & n : v)
    std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";
    }

4.3. 元组

C++ 中元组是类似于 std::pair 能够存储多个不同类型数据的容器。

4.3.1. 元组基本操作

  • std::make_tuple: 构造元组
  • std::get: 获得元组某个位置的值
  • std::tie: 元组拆包
    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 <tuple>
    #include <iostream>

    auto get_student(int id)
    {
    // 返回类型被推断为 std::tuple<double, char, std::string>

    if (id == 0)
    return std::make_tuple(3.8, 'A', "张三");
    if (id == 1)
    return std::make_tuple(2.9, 'C', "李四");
    if (id == 2)
    return std::make_tuple(1.7, 'D', "王五");
    return std::make_tuple(0.0, 'D', "null");
    // 如果只写 0 会出现推断错误, 编译失败
    }

    int main()
    {
    auto student = get_student(0);
    std::cout << "ID: 0, "
    << "GPA: " << std::get<0>(student) << ", "
    << "成绩: " << std::get<1>(student) << ", "
    << "姓名: " << std::get<2>(student) << '\n';

    double gpa;
    char grade;
    std::string name;

    // 元组进行拆包
    std::tie(gpa, grade, name) = get_student(1);
    std::cout << "ID: 1, "
    << "GPA: " << gpa << ", "
    << "成绩: " << grade << ", "
    << "姓名: " << name << '\n';
    }
  • std::get 除了使用常量获取元组对象外,C++14 增加了使用类型来获取元组中的对象:
    1
    2
    3
    4
    std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8);
    std::cout << std::get<std::string>(t) << std::endl;
    std::cout << std::get<double>(t) << std::endl; // 非法, 引发编译期错误
    std::cout << std::get<3>(t) << std::endl;

4.3.2. 运行期索引

  • std::get<> 依赖于一个编译器的常量,因此 int index=1; std::get<index>(t); 是不合法的
  • C++17 引入 std::variant<> ,通过提供给 variant<> 的类型模板参数让 variant<> 可以容纳提供的几种类型的变量:
    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 <variant>
    template <size_t n, typename... T>
    constexpr std::variant<T...> _tuple_index(const std::tuple<T...>& tpl, size_t i) {
    if constexpr (n >= sizeof...(T))
    throw std::out_of_range("越界.");
    if (i == n)
    return std::variant<T...>{ std::in_place_index<n>, std::get<n>(tpl) };
    return _tuple_index<(n < sizeof...(T)-1 ? n+1 : 0)>(tpl, i);
    }
    template <typename... T>
    constexpr std::variant<T...> tuple_index(const std::tuple<T...>& tpl, size_t i) {
    return _tuple_index<0>(tpl, i);
    }
    template <typename T0, typename ... Ts>
    std::ostream & operator<< (std::ostream & s, std::variant<T0, Ts...> const & v) {
    std::visit([&](auto && x){ s << x;}, v);
    return s;
    }

    int main() {
    int i = 1;
    std::cout << tuple_index(t, i) << std::endl;
    return 0;
    }

4.3.3. 元组合并与遍历

  • 通过 std::tuple_cat 实现:
    1
    auto new_tuple = std::tuple_cat(get_student(1), std::move(t));
  • 遍历元组:首先需要知道元组的长度,然后借用之前实现的 tuple_index() 获取指定index的元素:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename T>
    auto tuple_len(T &tpl) {
    return std::tuple_size<T>::value;
    }

    // 迭代
    for(int i = 0; i != tuple_len(new_tuple); ++i)
    // 运行期索引
    std::cout << tuple_index(new_tuple, i) << std::endl;

5. 智能指针与内存管理

5.1. RAII与引用计数

  • 在传统C++中,对于一个对象而言,在构造函数时申请空间,在析构函数时释放空间,也就是常说的 RAII 资源获取即初始化技术。
  • 引用计数是为了防止内存泄漏,基本想法是对于动态分配的对象,每当增加一次对同一个对象的引用,那么引用对象的引用计数就会增加一次,每删除一次引用,引用计数就会减一,当一个对象的引用计数减为0,就会自动删除指向的堆内存。
  • C++11 引入智能指针指针的概念,使用引用计数的想法,让程序员不再需要关心手动释放内存,这些智能指针包括:std::shared_ptr / std::unique_ptr / std::weak_ptr,需要包含头文件
1
2
注意:引用计数不是垃圾回收,引用计数能够尽快收回不再被使用的对象,
同时在回收的过程中也不会造成长时间的等待, 更能够清晰明确的表明资源的生命周期。

5.2. std::shared_ptr

  • std::shared_ptr 能够记录多少个 shared_ptr 共同指向一个对象,从而消除显示调用 delete,当引用计数变为0时就会将对象自动删除。
  • std::shared_ptr 除了可以用 new 来构造对象,还可以使用 std::make_shared 来消除显式的使用 new,std::make_shared 会分配创建传入参数中对象,并返回这个对象类型的 std::shared_ptr 指针。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include <iostream>
    #include <memory>
    void foo(std::shared_ptr<int> i) {
    (*i)++;
    }
    int main() {
    // auto pointer = new int(10); // illegal, no direct assignment
    // Constructed a std::shared_ptr
    auto pointer = std::make_shared<int>(10);
    foo(pointer);
    std::cout << *pointer << std::endl; // 11
    // The shared_ptr will be destructed before leaving the scope
    return 0;
    }
  • std::shared_ptr 可以通过 get() 方法来获取原始指针,通过 reset() 来减少一个引用计数,通过 use_count() 来查看一个对象的引用计数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    auto pointer = std::make_shared<int>(10);
    auto pointer2 = pointer; // 引用计数+1
    auto pointer3 = pointer; // 引用计数+1
    int *p = pointer.get(); // 这样不会增加引用计数
    std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 3
    std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 3
    std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 3

    pointer2.reset();
    std::cout << "reset pointer2:" << std::endl;
    std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 2
    std::cout << "pointer2.use_count() = "
    << pointer2.use_count() << std::endl; // pointer2 已 reset; 0
    std::cout << "pointer3.use_count() = " << pointer3.use_count() << std::endl; // 2
    pointer3.reset();
    std::cout << "reset pointer3:" << std::endl;
    std::cout << "pointer.use_count() = " << pointer.use_count() << std::endl; // 1
    std::cout << "pointer2.use_count() = " << pointer2.use_count() << std::endl; // 0
    std::cout << "pointer3.use_count() = "
    << pointer3.use_count() << std::endl; // pointer3 已 reset; 0

5.3. std::unique_ptr

  • std::unique_ptr 是独占智能指针,禁止其他智能指针与其共享同一个对象:
    1
    2
    std::unique_ptr<int> pointer = std::make_unique<int>(10); // make_unique 从 C++14 引入
    std::unique_ptr<int> pointer2 = pointer; // 非法
  • 既然是独占,换句话就是不可复制。但是可以利用 std::move 将其转移给其他的 unique_ptr:
    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 Foo {
    Foo() { std::cout << "Foo::Foo" << std::endl; }
    ~Foo() { std::cout << "Foo::~Foo" << std::endl; }
    void foo() { std::cout << "Foo::foo" << std::endl; }
    };

    void f(const Foo &) {
    std::cout << "f(const Foo&)" << std::endl;
    }

    int main() {
    std::unique_ptr<Foo> p1(std::make_unique<Foo>());
    // p1 不空, 输出
    if (p1) p1->foo();
    {
    std::unique_ptr<Foo> p2(std::move(p1));
    // p2 不空, 输出
    f(*p2);
    // p2 不空, 输出
    if(p2) p2->foo();
    // p1 为空, 无输出
    if(p1) p1->foo();
    p1 = std::move(p2);
    // p2 为空, 无输出
    if(p2) p2->foo();
    std::cout << "p2 被销毁" << std::endl;
    }
    // p1 不空, 输出
    if (p1) p1->foo();
    // Foo 的实例会在离开作用域时被销毁
    }

5.4. std::weak_ptr

  • std::shared_ptr 无法解决循环引用的问题:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    struct A;
    struct B;

    struct A {
    std::shared_ptr<B> pointer;
    ~A() {
    std::cout << "A 被销毁" << std::endl;
    }
    };
    struct B {
    std::shared_ptr<A> pointer;
    ~B() {
    std::cout << "B 被销毁" << std::endl;
    }
    };
    int main() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->pointer = b;
    b->pointer = a;
    }
  • 运行结果是 A, B 都不会被销毁,这是因为 a,b 内部的 pointer 同时又引用了 a,b,这使得 a,b 的引用计数均变为了 2,而离开作用域时,a,b 智能指针被析构,却只能造成这块区域的引用计数减一,这样就导致了 a,b 对象指向的内存区域引用计数不为零,而外部已经没有办法找到这块区域了,也就造成了内存泄露,如图:
    alt text
  • 解决方案是其中一个对象的成员指针类型修改为弱引用指针 std::weak_ptr。std::weak_ptr是一种弱引用(相比较而言 std::shared_ptr 就是一种强引用)。弱引用不会引起引用计数增加,当换用弱引用时候,最终的释放流程如图所示:
    alt text
  • 具体原理和使用可以参考:weak_ptr弱引用智能指针详解
  • std::weak_ptr 没有 * 和 -> 运算符,所以不能对资源进行操作,可以通过其 lock() 方法在原始对象未释放时返回一个指向原始对象的 std::shared_ptr 指针,否则返回 nullptr;expired() 方法能在资源未被释放时,会返回 false,否则返回 true。

6. 正则表达式

6.1. 正则表达式简介

正则表达式描述了一种字符串匹配的模式。一般使用正则表达式主要是实现下面三个需求:

  1. 检查一个串是否包含某种形式的子串;
  2. 将匹配的子串替换;
  3. 从某个串中取出符合条件的子串。
    正则表达式是由普通字符(例如 a 到 z)以及特殊字符组成的文字模式。模式描述在搜索文本时要匹配的一个或多个字符串。 正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。

6.1.1. 普通字符

普通字符包括没有显式指定为元字符的所有可打印和不可打印字符。这包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。

6.1.2. 特殊字符

特殊字符是正则表达式里有特殊含义的字符,也是正则表达式的核心匹配语法。参见下表:

特别字符 描述
$ 匹配输入字符串的结尾位置。
() 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。
* 匹配前面的子表达式零次或多次。
+ 匹配前面的子表达式一次或多次。
. 匹配除换行符 \n 之外的任何单字符。
[ 标记一个中括号表达式的开始。
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。
\ 将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如, n 匹配字符 n。\n 匹配换行符。序列 \ 匹配 ‘' 字符,而 ( 则匹配 ‘(‘ 字符。
^ 匹配输入字符串的开始位置,除非在方括号表达式中使用,此时它表示不接受该字符集合。
{ 标记限定符表达式的开始。
` `

6.1.3. 限定符

限定符用来指定正则表达式的一个给定的组件必须要出现多少次才能满足匹配。见下表:

字符 描述
* 匹配前面的子表达式零次或多次。例如,foo* 能匹配 fo 以及 foooo。* 等价于{0,}。
+ 匹配前面的子表达式一次或多次。例如,foo+ 能匹配 foo 以及 foooo,但不能匹配 fo。+ 等价于 {1,}。
? 匹配前面的子表达式零次或一次。例如,Your(s)? 可以匹配 Your 或 Yours 中的Your 。? 等价于 {0,1}。
{n} n 是一个非负整数。匹配确定的 n 次。例如,o{2} 不能匹配 for 中的 o,但是能匹配 foo 中的两个 o。
{n,} n 是一个非负整数。至少匹配 n 次。例如,o{2,} 不能匹配 for 中的 o,但能匹配 foooooo 中的所有 o。o{1,} 等价于 o+。o{0,} 则等价于 o*。
{n,m} m 和 n 均为非负整数,其中 n 小于等于 m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 foooooo 中的前三个 o。o{0,1} 等价于 o?。注意,在逗号和两个数之间不能有空格。

6.2. std::regex 及其相关

  • C++11 提供的正则表达式操作 std::string 对象,模式 std::regex (本质是 std::basic_regex ) 进行初始化,通过 std::regex_match 进行匹配,从而产生 std::smatch (本质是 std::match_results 对象)
  • std::regex_match 用于匹配字符串和正则表达式,有很多不同的重载形式。 最简单的一个形式就是传入 std::string 以及一个 std::regex 进行匹配, 当匹配成功时,会返回 true,否则返回 false。例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <iostream>
    #include <string>
    #include <regex>

    int main() {
    std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
    // 在 C++ 中 \ 会被作为字符串内的转义符,
    // 为使 \. 作为正则表达式传递进去生效,需要对 \ 进行二次转义,从而有 \\.
    std::regex txt_regex("[a-z]+\\.txt");
    for (const auto &fname: fnames)
    std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
    }
  • 另一种常用的形式就是依次传入 std::string/std::smatch/std::regex 三个参数, 其中 std::smatch 的本质其实是 std::match_results。 故而在标准库的实现中, std::smatch 被定义为了 std::match_resultsstd::string::const_iterator, 也就是一个子串迭代器类型的 match_results。 使用 std::smatch 可以方便的对匹配的结果进行获取,例如:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    std::regex base_regex("([a-z]+)\\.txt");
    std::smatch base_match;
    for(const auto &fname: fnames) {
    if (std::regex_match(fname, base_match, base_regex)) {
    // std::smatch 的第一个元素匹配整个字符串
    // std::smatch 的第二个元素匹配了第一个括号表达式
    if (base_match.size() == 2) {
    std::string base = base_match[1].str();
    std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
    std::cout << fname << " sub-match[1]: " << base << std::endl;
    }
    }
    }

6.3. 习题

在 Web 服务器开发中,我们通常希望服务某些满足某个条件的路由。正则表达式便是完成这一目标的工具之一。
给定如下请求结构:

1
2
3
4
5
6
7
8
9
10
struct Request {
// request method, POST, GET; path; HTTP version
std::string method, path, http_version;
// use smart pointer for reference counting of content
std::shared_ptr<std::istream> content;
// hash container, key-value dict
std::unordered_map<std::string, std::string> header;
// use regular expression for path match
std::smatch path_match;
};

请求的资源类型:

1
2
3
typedef std::map<
std::string, std::unordered_map<
std::string,std::function<void(std::ostream&, Request&)>>> resource_type;

以及服务端模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename socket_type>
class ServerBase {
public:
resource_type resource;
resource_type default_resource;

void start() {
// TODO
}
protected:
Request parse_request(std::istream& stream) const {
// TODO
}
}

请实现成员函数 start() 与 parse_request。使得服务器模板使用者可以如下指定路由:

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
template<typename SERVER_TYPE>
void start_server(SERVER_TYPE &server) {

// process GET request for /match/[digit+numbers],
// e.g. GET request is /match/abc123, will return abc123
server.resource["fill_your_reg_ex"]["GET"] =
[](ostream& response, Request& request)
{
string number=request.path_match[1];
response << "HTTP/1.1 200 OK\r\nContent-Length: "
<< number.length() << "\r\n\r\n" << number;
};

// peocess default GET request;
// anonymous function will be called
// if no other matches response files in folder web/
// default: index.html
server.default_resource["fill_your_reg_ex"]["GET"] =
[](ostream& response, Request& request)
{
string filename = "www/";

string path = request.path_match[1];

// forbidden use `..` access content outside folder web/
size_t last_pos = path.rfind(".");
size_t current_pos = 0;
size_t pos;
while((pos=path.find('.', current_pos)) != string::npos && pos != last_pos) {
current_pos = pos;
path.erase(pos, 1);
last_pos--;
}

// (...)
};

server.start();
}

7. 并行与并发

7.1. 并行基础

std::thread 用于创建一个执行的线程实例,是一切并发编程的基础,需要头文件 ,它提供了基本的线程的操作,如何 get_id() 来获取所创建的线程的ID,join() 来等待一个线程结束(与该线程汇合):

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <thread>

int main() {
std::thread t([](){
std::cout << "hello world." << std::endl;
});
t.join();
return 0;
}

std::thread::detach 方法用于将新创建的线程从 std::thread 对象分离,使得线程与主线程独立,;一旦线程被分离,std::thread 对象将不再用于该线程,因此无法对其进行操作,如等待其结束或获取其状态。

屏障(barrier)可用于协调多个线程并行工作的同步机制,屏障允许每个线程等待,直到所有线程到达某个点,然后从该店继续执行。

7.2. 互斥量与临界区

std::mutex 是 C++11 中最基本的互斥量类,可以通过构造 std::mutex 对象来创建互斥量,通过其成员函数 lock() 进行上锁,再使用 unlock() 进行解锁。

但是,在实际编写代码时,最好不要去直接调用成员函数,因为需要在每个临界区的出口处调用 unlock() (可能忘记)。C++11 为互斥量提供了一个 RAII 机制的模板类 std::lock_guard,在不失代码间接性的同时保证了代码的异常安全性,用法:只需在作用域的开始部分进行临界区互斥量的创建:

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>
#include <mutex>
#include <thread>

int v = 1;

void critical_section(int change_v)
{
static std::mutex mtx;
std::lock_guard<std::mutex> lock(mtx);

// 执行竞争操作
v = change_v;

// 离开此作用域后 mtx 会被释放
}

int main()
{
std::thread t1(critical_section, 2), t2(critical_section, 4);

t1.join();
t2.join();

std::cout << "v = " << v << std::endl;

return 0;
}

由于 C++ 保证了所有栈对象在生命周期结束时会被销毁,所以以上代码是异常安全的,无论 critical_section() 正常返回还是中途抛出异常,都会引发栈回溯,从而都会自动调用 unlock()。

std::unique_lock 相比于 std::lock_guard 更加灵活,std::unique_lock 的对象会以独占所有权(没有其他的 std::unique_lopck 对象同时拥有某个 mutex 对象的所有权)的方式管理 mutex 对象的上锁和解锁的操作。std::unique_lock 可以在声明后的任意位置调用 lock 和 unlock,可以缩小锁的作用范围,提供更高的并发度。(如果你用到了条件变量 std::condition_variable::wait 则必须使用 std::unique_lock 作为参数。)

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
#include <iostream>
#include <mutex>
#include <thread>

int v = 1;

void critical_section(int change_v) {
static std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
std::cout << "v from " << v << " to "<< change_v << std::endl;
// 执行竞争操作
v = change_v;
// std::cout << v << std::endl;
// 将锁进行释放
lock.unlock();

// 在此期间,任何人都可以抢夺 v 的持有权

// 开始另一组竞争操作,再次加锁
lock.lock();
std::cout << "after unlock and lock v from " << v << " to "<< v+1 << std::endl;
v += 1;
// std::cout << v << std::endl;
}

int main() {
std::thread t1(critical_section, 2), t2(critical_section, 3);
t1.join();
t2.join();

std::cout << "finally v = " << v << std::endl;
return 0;
}

7.3. 期物

期物(Future)表现为 std::future,提供一种访问异步操作结果的途径。

举例说明:若主线程A希望开辟一个线程B去执行期望任务,并返回一个结果;而这时,线程A可能正在忙其他事情,无暇顾及B的结果,所以很自然希望能够在某个特定时间获得线程B的结果。

在C++11之前,通常做法:创建一个线程A,在线程A里启动线程B,当准备完毕后发送一个事件,并将结果保存在全局变量中,而主线程A正在做其他事情,当需要结果时,调用一个线程等待函数来获得执行的结果。

C++11 提供的 std::future 可以简化此过程获取异步任务结果。自然地,我们很容易能够想象到把它作为一种简单的线程同步手段,即屏障(barrier)。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <future>
#include <thread>

int main() {
std::packaged_task<int()> task([](){return 7;});
std::future<int> result = task.get_future();
std::thread(std::move(task)).detach();
std::cout << "waiting...";
result.wait();
std::cout << "done!" << std::endl << "future result is " << result.get() << std::endl;
return 0;
}

7.4. 条件变量

条件变量 std::condition_variable 是为了解决死锁(忙等待)而产生。比如,线程可能需要某个条件为真才能继续执行,而一个忙等待循环(某条件不为真而一直while循环进行条件判断)中可能会导致所有其他线程无法进入临界区使得条件为真,就会发生死锁。 所以,condition_variable 对象被创建出现主要就是用于唤醒等待线程从而避免死锁。 std::condition_variable的 notify_one() 用于唤醒一个线程; notify_all() 则是通知所有线程。下面是一个生产者和消费者模型的例子:

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
47
48
49
50
51
52
53
54
55
56
57
#include <queue>
#include <chrono>
#include <mutex>
#include <thread>
#include <iostream>
#include <condition_variable>


int main() {
std::queue<int> produced_nums;
std::mutex mtx;
std::condition_variable cv;
bool notified = false; // 通知信号

// 生产者
auto producer = [&]() {
for (int i = 0; ; i++) {
std::this_thread::sleep_for(std::chrono::milliseconds(900));
std::unique_lock<std::mutex> lock(mtx);
std::cout << "producing " << i << std::endl;
produced_nums.push(i);
notified = true;
cv.notify_all(); // 此处也可以使用 notify_one
}
};
// 消费者
auto consumer = [&]() {
while (true) {
std::unique_lock<std::mutex> lock(mtx);
while (!notified) { // 避免虚假唤醒
cv.wait(lock);
}
// 短暂取消锁,使得生产者有机会在消费者消费空前继续生产
lock.unlock();
// 消费者慢于生产者
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
lock.lock();
while (!produced_nums.empty()) {
std::cout << "consuming " << produced_nums.front() << std::endl;
produced_nums.pop();
}
notified = false;
}
};

// 分别在不同的线程中运行
std::thread p(producer);
std::thread cs[2];
for (int i = 0; i < 2; ++i) {
cs[i] = std::thread(consumer);
}
p.join();
for (int i = 0; i < 2; ++i) {
cs[i].join();
}
return 0;
}

7.x. 协程

协程最简单的理解是可以重入的特殊函数,这个函数在执行的过程,可以挂起(通过 co_await 或者 co_yield),然后在外部恢复运行(通过 coroutine_handle)

C++ 20 协程是一个无栈(stackless)的协程,用于异步编程。

协程是一个支持挂起(suspend)和恢复(rusume)的函数,挂起可以理解为暂停,恢复可以理解为从暂停的地方继续运行。

C++协程会在开始执行的第一步使用 operator new 来开辟一块内存来存放协程的状态信息(挂起的位置、协程函数的参数、局部变量等)

区别一个函数是不是协程,是通过它的返回值类型来判断的。如果它的返回值类型满足协程的规则,那这个函数就会被编译成协程。