构造函数模板推导

它允许编译器推导类模板的参数类型,无需程序员显式指定。这带来了编写模板代码时的便利性,使得代码更加简洁。在此之前,创建模板类的对象时必须显式指定模板参数,即使这些参数可以从构造函数的参数中推导出来。

在C++17之前,如果我们有如下模板类:

template <typename T>
class MyClass {
public:
    MyClass(T value) : value_(value) {}

private:
    T value_;
};

构造一个该模板类的对象,我们必须这样写:

MyClass<int> myObject(10);  // 显式指定模板类型参数为int

但是在C++17中,可以这样:

MyClass myObject(10);  // 编译器自动推导模板参数为int

编译器能够根据传给构造函数的参数类型推导出类模板的类型参数。这就是构造函数模板推导。

注意事项

1. 默认构造函数的影响:如果一个类模板定义了默认构造函数,CTAD可能不会按预期工作。当试图利用CTAD时,若没有提供足够的上下文以推断模板参数,编译器可能无法确定正确的模板参数类型。

2. 复制和移动构造函数不参与模板推导:C++标准规定,类模板的复制和移动构造函数不用于CTAD。当通过复制或移动另一个实例来构造一个对象时,必须显式地指定模板参数。

3. 部分模板参数推导:C++17的CTAD不支持部分模板参数推导。如果一个类模板接受多个模板参数,要么所有参数都由CTAD推导得出,要么所有参数都必须显式指定。

结构化绑定

结构化绑定(Structured Bindings),允许将对象的多个成员绑定到一组变量上,从而方便地一次性解包多个返回值或对象的多个成员。这种方法提高了代码的可读性和便捷性,尤其在处理 tuple、pair 或结构体时特别有用。

在C++17之前,访问tuple或结构体的元素需要使用std::get<index>或者通过成员访问操作符.,如下所示:

std::pair<int, double> p = std::make_pair(1, 3.14);
int x = p.first;
double y = p.second;

std::tuple<int, double, std::string> t = std::make_tuple(1, 3.14, "example");
int a = std::get<0>(t);
double b = std::get<1>(t);
std::string c = std::get<2>(t);

而在C++17中,可以使用结构化绑定简化这个过程:

auto [x, y] = std::make_pair(1, 3.14);
auto [a, b, c] = std::make_tuple(1, 3.14, "example");

这样xy被绑定到了pair的两个元素上;abc被绑定到了tuple的三个元素上。另外,结构化绑定也可以用于数组和结构体:

int arr[] = {1, 2, 3};
auto [x, y, z] = arr;

struct MyStruct {
    int i;
    double d;
    std::string s;
};

MyStruct ms {1, 3.14, "example"};
auto [i, d, s] = ms;

结构化绑定如何帮助简化访问 tuple 和 pair 的过程?

1. 减少代码量

在C++17之前,访问tuplepair中的元素需要使用std::get<>函数或直接访问成员变量(对于pair)。

2. 提高代码可读性

使用结构化绑定后,可以直接将tuplepair的元素分配给多个变量,如下:

auto [num, text] = std::make_pair(1, "one");
auto [i, d, s] = std::make_tuple(1, 3.14, "example");

3. 类型推导

结构化绑定还能自动推导出绑定到各个变量的正确类型,避免了显示类型指定的需要或潜在的类型不匹配错误。例如,不需要显式声明numinttextstd::string,编译器会自动根据tuplepair中的元素类型进行推导。

注意事项

  1. 非公有成员无法绑定:如果尝试对一个结构体进行结构化绑定,那么这个结构体的所有成员都必须是公有的。对于包含私有或受保护成员的结构体,结构化绑定将无法编译通过。
  2. 数组的引用绑定:尽管可以对数组使用结构化绑定,但绑定的变量是数组元素的副本而非引用。这意味着对绑定变量的修改不会影响原始数组。
  3. 无法直接应用于动态数组:结构化绑定不能直接应用于动态分配的数组,或是它们的指针。它主要应用于静态分配的数组、STL容器(例如std::array),以及其他可被分解为已知数量元素的类型。
  4. 需要可解构的类型:结构化绑定要求被绑定的类型是可解构的,即类型需要有相应的std::tuple_sizestd::get的特化或自定义的解构方式。对于没有这样特性的类型,将不能使用结构化绑定。
  5. 无法用于类型转换:结构化绑定不支持任何形式的类型转换。即,不允许在绑定的同时进行类型强制转换。
  6. 只能用于声明:结构化绑定必须在变量声明的同时使用,不能用于已声明的变量。
  7. 无法指定修饰符:对于结构化绑定的变量,不能单独为其中的变量指定修饰符(如constvolatile)。所有变量的修饰符都必须与结构化绑定的整体修饰符一致。
  8. 无法用于修改结构或类类型的访问规则:对于结构体或类的成员,结构化绑定遵循原有的访问控制规则。即使通过结构化绑定创建了成员的别名,也不能绕过成员的访问权限。

if-switch语句初始化

C++17之前,通常将在if或switch语句中用于条件判断的变量声明在这些语句之前。这种方式的缺点是,任何在if或switch语句外声明的变量都会在整个作用域内保持活跃状态,即使其只被用于条件判断。这可能会导致作用域污染,因为这些变量可能会被后续的代码段不正确地访问或修改。为了限制变量的作用域,开发者有时会使用额外的代码块来限定变量的范围。在C++17中,引入了if和switch语句的初始化器特性来解决这个问题。初始化器使得开发者能够在这些语句内部声明一个变量,并且该变量的作用域被局限于该语句块及其控制的代码块中。

初始化器的工作原理主要基于两点:

作用域:变量在声明时会创建一个新的作用域,这样变量的生命周期就被限定在if或switch语句及其相关的代码块内部。

语句执行流程:if或switch语句的初始化器会首先执行,然后才是条件判断。只有在初始化器执行后,条件判断中使用的变量才被创建和初始化,随后这些变量在相关条件分支的代码块中可用。

if语句初始化

在C++17之前,如果你想在if语句中使用一个只用于该判断逻辑的变量,你通常需要这样写:

auto val = getValue();
if (val.isValid()) {
    // 使用val
}
// val 这里仍然是可见的,即使我们可能不再需要它。

在C++17以后,可以将初始化语句放在if语句中:

if (auto val = getValue(); val.isValid()) {
    // 使用val
}
// val 在这一作用域外不可见,不会造成变量污染。

这个特性使得你可以在条件判断的作用域内声明变量,让代码更加紧凑且防止了变量的作用域外溢。

switch语句初始化

类似的,对于switch语句,C++17之前你可能会写:

auto result = computeValue();
switch (result.type()) {
    case TypeA:
        // 处理TypeA
        break;
    case TypeB:
        // 处理TypeB
        break;
    // 更多情况...
}
// result 在这里仍然是可见的。

而C++17引入初始化语句后,你可以这样写:

switch (auto result = computeValue(); result.type()) {
    case TypeA:
        // 处理TypeA
        break;
    case TypeB:
        // 处理TypeB
        break;
    // 更多情况...
}
// result 在这一作用域外不可见。

注意事项

  1. 单一语句限制:在ifswitch的初始化部分只能包含一条语句。如果需要执行多条语句,必须将它们封装到一个作用域块中,或者使用逗号运算符来连接。
  2. 作用域限制:初始化语句中声明的变量仅在ifswitch语句的作用域内有效。这意味着一旦离开了该语句的作用域,这些变量就无法访问了。虽然这有助于防止变量泄露到外部作用域,但在某些情况下可能会限制变量的可用性。
  3. 条件必须存在:使用初始化语法时,必须有一个条件表达式跟随初始化声明。即使你只想要初始化而不需要条件判断,也必须提供至少一个始终为真或假的条件。
  4. 对嵌套使用的限制:在嵌套的ifswitch语句中使用这个特性时,最内层的条件判断只能访问最近的一个初始化变量。这限制了在嵌套结构中对外层初始化变量的访问,有时可能需要更多额外的变量声明来规遍这个限制。

内联变量

这是一个针对全局变量和静态成员变量的新特性。该特性主要是为了解决程序中静态存储期的常量和变量的多个定义可能导致的链接问题。

在C++17之前,如果你在头文件中定义了一个非内联的静态成员变量或全局变量,当该头文件被多个源文件包含时,每个源文件都会产生该变量的一个实例。链接时,会因为找到多个相同符号的定义而导致错误。

解决这个问题传统上的做法是在头文件中声明变量(使用extern)而在一个单独的源文件中定义它。这样可以保证变量只定义一次,不会引起链接冲突。

// header.h
extern int globalVariable; // 声明

// source.cpp
int globalVariable; // 定义

但是对于模板和常量表达式,这样做不太方便,因为你可能想要它们在头文件中即被定义以便内联。

使用C++17的内联变量特性后,你可以在头文件中安全地定义全局变量或静态成员变量,编译器会保证在整个程序中这个变量只有一个实例。即使头文件被多个源文件包含,也不会产生链接错误。

// header.h
inline int globalVariable = 42; // 内联定义

// 或者对于类的静态成员:
class MyClass {
public:
    static inline int staticMember = 42; // 内联定义
};

内联变量的一个常见用途是定义类模板的静态数据成员。在C++17之前,这通常需要特别的定义在一个源文件中,而现在可以直接在类定义中内联定义它们,这样每个模板实例化都会得到正确的链接。

template<typename T>
class MyClass {
public:
    static inline T staticMember; // 内联定义
};

内联变量的原理是通过内联修饰符将变量定义为可在多个编译单元(通常是多个.cpp源文件)中共享的,而不会违反一次定义规则(One Definition Rule, ODR)。当内联变量在头文件中定义时,这个定义可以在包含了该头文件的每个编译单元中见到。

这里的关键是链接器(linker)如何处理内联变量:

链接器与内联变量

链接器的作用是将多个编译单元合并到一起,形成一个可执行程序。对于内联变量,链接器将遵守以下规则:

  1. 弱符号:内联变量的每个实例在编译时都会被编译器标记为“弱符号”(Weak Symbol)。这表示该符号可能会在其他编译单元中有重复定义,链接器在链接的过程中需要处理这些定义。
  2. 合并符号:当链接器发现多个弱符号定义(例如来自不同编译单元的内联变量的实例)时,它会将这些符号的实例合并成一个单一的实例。这种处理方式保证了程序的所有部分看到的内联变量只有一个共同的实体。
  3. 清除多余的实例:通过合并符号的方式,链接器会避免在最终的程序中包含重复的变量定义,因此程序不会因为违反ODR而出错。

内存中的表现

内联变量被存储在程序的数据段,类似于其他全局变量。在运行时,无论内联变量在程序的哪个地方被访问,它总是指向同一个内存位置。这就是为什么你可以在一个头文件中定义内联变量,并在多个源文件中使用它,却不会造成链接错误或多份拷贝的原因。

注意事项

  1. 初始化位置的限制:根据C++17的规定,内联变量必须在声明它的地点同时进行定义。这意味着,与普通的外部链接变量(可以在声明时不定义)不同,内联变量在声明的同时需要提供其初始化器。
  2. 非内联情景:尽管内联变量主要用于解决头文件中的变量多重定义问题,但它们并不适用于所有需要内部链接的情况。对于在单个翻译单元内使用的局部静态变量,使用普通的静态变量而不是内联变量通常更合适。
  3. 与静态成员变量的兼容性:虽然内联变量特别适用于类的静态成员定义,但是对于不需要跨多个翻译单元共享的静态成员变量,使用传统的静态成员变量定义方法(在类定义外定义和初始化静态成员)可能更为直接。
  4. 缺乏动态初始化顺序保证:和其他全局对象一样,内联变量的动态初始化(即运行时初始化)顺序在不同翻译单元之间是不确定的。这意味着如果你的程序依赖于两个全局内联变量的初始化顺序,那么在不同的编译环境中可能会遇到问题。

折叠表达式

一个与可变参数模板一起使用的特性,允许对包中的参数执行一个包含二元运算符的递归展开。

可变参数模板可以接受任意数量的模板参数,但在C++11和C++14中处理它们通常需要递归模板或递归函数。这样的代码不仅写起来复杂,阅读起来也很困难。折叠表达式提供了一种更简洁和直接的方式来处理可变模板参数。

折叠表达式的种类

C++17 提供了两种形式的折叠表达式:

  1. 二元右折(Binary Right Fold): (... op pack),其中op是二元运算符,pack是模板参数包或函数参数包。展开后会变成(pack1 op (... (packN-1 op packN)))
  2. 二元左折(Binary Left Fold): (pack op ...) ,展开后会变成 ((pack1 op pack2) op ...) op packN

折叠表达式的使用

示例:使用折叠表达式实现可变参数模板的和。

template<typename... Args>
auto sum(Args... args) {
    return (... + args); // 右折叠
}

int main() {
    auto total = sum(1, 2, 3, 4); // 返回 10 ,计算过程是1 + (2 + (3 + 4))
}

折叠表达式对所有内建的二元运算符有效(例如+, -, *, /, &&, ||, &, |等等),并且当它们被使用时,你可以省去编写明确的递归模板实例化代码,直接将参数包中的元素应用到相应的运算符上。

示例:使用折叠表达式实现逻辑与。

template<typename... Args>
bool logicalAnd(Args... args) {
    return (... && args); // 右折叠
}

int main() {
    bool result = logicalAnd(true, true, false, true); // 返回 false ,计算过程是 true && (true && (false && true))
}

注意事项

  1. 操作符的限制:折叠表达式可以使用大部分C++的二元操作符,但并非所有操作符都支持。特殊的操作符,如成员访问符(.)、成员指针访问符(->)、下标操作符([])、函数调用操作符(()),并不能直接用于折叠表达式。若需要使用这些操作,可能需要结合其他技术,如Lambda表达式。
  2. 只适用于可变参数模板:折叠表达式仅适用于模板参数包,无法将其用于非模板的可变参数函数或其他上下文中的参数列表。
  3. 空参数包的处理:对于空的模板参数包,折叠表达式需要特殊处理,因为没有直接的元素可以参与运算。在这种情况下,需要为折叠表达式提供一个显式的初始化值或者使用特定的操作符,这些操作符在展开空包时有明确定义的行为(例如,逻辑与(&&)展开为true,逻辑或(||)展开为false)。
  4. 推导和模板参数类型:由于折叠表达式在编译时展开,编译器必须能够推导出所有参与操作的参数类型。对于一些复杂的情况,如参数类型不一致或需要隐式类型转换,可能会导致推导失败或产生意外的结果。

constexpr lambda表达式

在C++17中,constexpr关键字的应用范围得到了极大的扩展,包括对lambda表达式的支持。这一特性使得lambda表达式能在编译时被求值,进一步拓展了常量表达式和编译时计算的应用场景。

当一个lambda表达式被标记为constexpr时,编译器会尝试在编译时对这个lambda表达式进行求值,前提是所有的输入也都是编译时可知的常量表达式。这样的lambda表达式可以用于任何需要常量表达式的场景,比如模板参数、数组大小、编译时断言等。

注意事项

  1. 函数体中的操作限制: constexpr函数体内只能包含有限的操作类型。这主要包括返回语句、类型定义、使用constexpr变量、条件语句(如ifswitch)、循环(如forwhile),以及对其他constexpr函数的调用。其中对循环的使用在C++20中才正式支持,C++17对此有较为严格的限制。
  2. 不允许使用动态内存分配:constexpr函数中,不能使用newdelete操作符,因为这些操作涉及到运行时的内存分配和释放,而constexpr函数必须在编译时完成计算。
  3. 不允许有副作用: constexpr函数不应包含改变函数外部状态的副作用,例如修改非局部变量、进行输入输出操作或调用非constexpr函数等。
  4. 限制递归深度: 尽管C++17标准中没有明确指出递归深度的限制,但编译器可能会对constexpr函数的递归深度做一定的限制以避免编译时资源耗尽。
  5. 声明与定义: 如果一个constexpr函数被声明在某个头文件中,则其定义也必须与之一同出现在同一头文件中,以确保在编译时可以查到其定义。
  6. 虚函数限制: 在C++17中,虚函数不能被声明为constexpr。这是因为虚函数主要用于运行时多态,而constexpr函数必须在编译时就能够确定其行为。
  7. 返回类型和参数: constexpr函数的返回类型以及所有参数类型必须是字面类型。字面类型包括算术类型、枚举、指针、引用以及某些类类型,这些类型在编译时必须有已知的固定大小,并且能够在编译期构造和析构。

namespace嵌套

嵌套命名空间定义(nested namespace definition),允许开发者以更简洁的方式定义嵌套命名空间。这项特性通过减少代码冗余,提高了代码的可读性和书写效率。

嵌套命名空间的简化语法并不影响C++的编译器如何处理命名空间。在编译时,这种简化的写法会被展开为传统的多层嵌套方式,因此它不会影响命名空间的作用域规则、名字查找或者链接规则。
实质上,嵌套命名空间的简化语法是一种语法糖,意味着它给程序员提供了一种更为便捷和直观的书写方式,而在编译器处理时,它与传统的长格式写法在逻辑上是等价的。

旧式嵌套命名空间的书写方式

在C++17之前,定义嵌套的命名空间:

namespace A {
    namespace B {
        namespace C {
            // 在这里定义或声明C中的内容
        }
    }
}

这种方式在定义深层嵌套的命名空间时会导致代码逐渐右移,增加了代码的视觉嵌套深度。

C++17中嵌套命名空间的书写方式

C嵌套命名空间定义:

namespace A::B::C {
    // 在这里定义或声明C中的内容
}

这种新语法允许你通过使用单列的::运算符来定义多层嵌套的命名空间,大幅降低了代码的嵌套深度,使得代码更加整洁。

注意事项

  1. 不允许局部嵌套命名空间:嵌套命名空间的定义语法不能在函数内部或任何局部作用域内部使用。它们只能在全局作用域或其他命名空间内使用。
  2. 不支持命名空间别名定义中使用:不能在命名空间别名(namespace alias)的定义中使用嵌套命名空间的语法。例如,namespace XYZ = A::B::C; 是有效的,但不能在这种别名定义中进一步引入嵌套。
  3. 匿名命名空间:匿名命名空间(通常用于定义仅在当前文件中可见的类型或变量)不能与嵌套命名空间的新语法结合使用。匿名命名空间需要被单独声明。

__has_include预处理表达式

预处理表达式__has_include,这是一个条件编译特性,用于检查编译环境中是否存在指定的头文件或模块。

原理详解

__has_include是一个编译时预处理指令,其工作原理如下:

  1. 检查提供的路径__has_include接收一个指定的头文件或模块路径作为参数。这个路径可以是标准库的头文件(使用<header>),也可以是用户定义的头文件(使用"header")。
  2. 编译器搜索:在预处理阶段,编译器会在其配置的搜索路径中查找指定的文件。这些路径包括了编译器的内建路径、通过命令行参数指定的路径以及源码中指定的路径。
  3. 返回检查结果:如果编译器找到了指定的文件,__has_include表达式的结果为true(实际上就是1),否则为false(即0)。这个结果可以被用在预处理命令#if#elif中,从而影响条件编译的流程。

基本用法

__has_include预处理表达式接受一个头文件名或模块名作为参数,以判断该文件或模块是否可被包含或导入。基本语法如下:

#if __has_include(<header_or_module>)
    #include <header_or_module>
#else
    // 备选方案
#endif

或者是针对用户定义的头文件:

#if __has_include("my_header.h")
    #include "my_header.h"
#else
    // 备选方案
#endif

注意事项

1. 不能确定文件内容

__has_include仅能检查指定的头文件或模块是否存在于编译器的搜索路径中,但不能保证文件的内容符合期望。例如,即使检测到某个库的头文件存在,也无法确定该库是否实现了你所需要的特定特性或API。

2. 预处理器的局限

__has_include是一个预处理器指令,这意味着它的执行在编译之前。由于预处理器只能进行简单的文本替换和条件编译,__has_include无法访问或评估C++代码中的类型、模板或函数等元素。这限制了其使用场景,主要局限于对编译环境的适配上。

在lambda表达式用*this捕获对象副本

这一改进增加了lambda表达式的灵活性,并提供了一种更加直观和安全的方式来使用当前对象的成员变量和成员函数,特别是在异步编程和多线程环境下。

*this捕获的引入

  1. 对象的复制:当使用*this捕获时,编译器将创建当前对象的一个副本。这个副本是通过调用当前对象的拷贝构造函数生成的。这意味着,在lambda表达式内部使用的是这个副本,而不是原始对象。
  2. 封装到lambda体内:编译器将这个副本封装到生成的闭包类(即,转换后的lambda表达式)内部。每个通过*this捕获的lambda表达式都会拥有当前对象的一个唯一副本。这保证了即使原始对象的生命周期结束,lambda表达式也能安全地操作副本上的数据,避免了悬挂指针或未定义行为的发生。
  3. 简化异步编程和多线程使用:在需长时间运行或在不确定原始对象生命周期的环境中,使用*this捕获确保了操作的对象在lambda执行期间始终有效,大大降低了编程的复杂性和出错率。

传统的捕获方式

在C++17之前,在lambda表达式中访问类的成员变量或成员函数,通常有两个选择:

  1. 通过[this]捕获:这会捕获当前对象的指针,然后在lambda表达式中通过this访问成员。
  2. 通过[=][&]捕获:通过值或引用捕获所有外部自动变量(包括this),然后在lambda表达式中通过对象的成员访问它们。

这两种方式都有潜在的风险。特别是在涉及到异步调用或者长时间运行的操作时,原始对象可能在lambda表达式执行时已经不再存在,这可能导致对悬挂指针的访问或未定义行为。

使用*this捕获的好处

C++17通过允许在lambda表达式中使用*this来捕获当前对象的副本,当lambda表达式被执行时,它将使用一个当前对象的副本,而不是直接访问原始对象。这有几个显著的好处:

  1. 避免悬挂指针:由于lambda表达式持有当前对象的副本而非原始对象的引用或指针,所以即使原始对象被销毁,lambda表达式内部的操作仍然是安全的。
  2. 值捕获的直观性:使用*this明确表明你想通过值而非通过指针或引用来捕获当前对象,增加了代码的明确性和可读性。
  3. 易于异步编程:在异步编程和多线程应用中,确保操作的对象在执行期间有效且不会改变是非常重要的。通过*this捕获可以简化这一点的管理。

使用示例

考虑一个类,该类有一个成员变量和一个成员函数,我们希望在成员函数中创建一个lambda表达式,捕获当前对象的副本,并在稍后执行:

class MyClass {
public:
    int value = 0;

    void doSomething() {
        // 使用*this捕获当前对象的副本
        auto lambda = [*this]() {
            // 这里可以安全地使用value,因为我们操作的是副本
            std::cout << value << std::endl;
        };

        // 在某个异步操作或延迟执行lambda...
    }
};

在这个示例中,不论原始的MyClass对象何时析构,lambda表达式内部的value访问都是安全的,因为它操作的是一个独立的副本。

注意事项

  1. 拷贝构造函数的要求:当使用*this来捕获对象副本时,必须保证该对象是可拷贝的。即对象需要有一个可访问的拷贝构造函数。如果类的拷贝构造函数被删除(例如通过= delete)或未定义,那么使用*this捕获会导致编译错误。
  2. 副本与原始对象的独立性:捕获的副本在修改时不会影响到原始对象。这虽然在多数情形下是期望的行为,但如果存在某些场景需要对原始对象进行操作,这种独立性可能导致不符合预期的结果。在这种情况下,应考虑其他捕获方式或设计。
  3. 继承和多态性:由于捕获的是对象的副本,该副本的类型将严格对应于*this的静态类型,这意味着如果在带有继承关系的类中使用*this进行捕获,捕获的对象副本将不会保持任何多态性质。这可能会在处理基于类层次结构的系统时引入限制。

新增Attribute

属性是被方括号[[ ]]包围的注解,可以应用于代码中几乎任何地方,包括类型声明、语句、表达式等,用于提供关于代码行为的额外信息给编译器。在C++11和C++14中已经引入了部分属性,C++17则进一步扩展了这个概念。

[[nodiscard]]

[[nodiscard]]属性可以用来指示函数的返回值不应该被忽略。当编译器检测到一个被标记为[[nodiscard]]的函数的返回值没有被使用时,它将发出警告。这对于那些执行重要任务,其返回值表明操作成功与否的函数特别有用,确保了程序逻辑的正确性。

示例:

[[nodiscard]] int computeValue() {
    return 42;
}

void example() {
    computeValue(); // 假设没有使用返回值,编译器可能会警告
}

[[maybe_unused]]

在某些情况下,变量、函数、类型等可能声明了但未被使用,这通常会引发编译器警告。通过使用[[maybe_unused]]属性,可以告诉编译器该实体可能不被使用,从而抑制出现相关警告。

示例:

[[maybe_unused]] void unusedFunction() {
    // Implementation
}

[[fallthrough]]

switch语句中,一般认为每个case后都应该跟随一个break来阻止代码继续向下“跌落”。如果某个case故意设计为需要“跌落”到下一个case[[fallthrough]]属性就可以用来消除编译器针对这种有意为之的情况所发出的警告。

示例:

switch (value) {
    case 1:
        doSomethingForCase1();
        [[fallthrough]]; // 明确指出意图跌落
    case 2:
        doSomethingForCase2();
        break;
}

[[noreturn]]

[[noreturn]]属性指示函数不会通过正常返回来返回到调用者。这主要用于那些通过抛出异常或终止程序来“返回”的函数。

示例:

[[noreturn]] void fatalError(const std::string& message) {
    std::cerr << message;
    std::exit(1);
}

注意事项

  1. 编译器支持度:虽然这些属性是C++17标准的一部分,不同的编译器对这些新特性的支持程度可能有所不同。一些较旧的编译器版本可能不完全支持C++17的所有属性。
  2. 误用和滥用
  • [[nodiscard]]:如果过度使用,几乎所有函数都标记为[[nodiscard]],可能会导致代码中充满警告,从而降低此属性对于关键函数返回值检查的实际作用和意图。
  • [[maybe_unused]]:应该只在确实预期到某些变量或函数在某些条件下不被使用时使用,滥用可能会隐藏实际的代码质量问题。
  • [[fallthrough]]:仅应在确实希望在switch语句中无条件“跌落”到下一个case时使用,错误使用会掩盖潜在的逻辑错误。
  1. 运行时影响为零:这些属性是在编译时处理的,它们不会改变程序的运行时行为。需要注意的是,虽然它们可以帮助改进代码的质量和避免某些类型的错误,但它们并不替代良好的编程实践和代码审查。

字符串转换

对字符串与数值类型之间转换的增强,特别是通过std::from_charsstd::to_chars这两个函数模板。这些功能是在头文件<charconv>中定义的,它们提供了一种高效的方式来将数值转换为字符串形式,以及将字符串解析为数值类型,比之前的解决方案如std::stoistd::stofstd::to_string等提供了更好的性能和更多控制。

std::from_chars

std::from_chars是用于将字符串转换为数值的函数。与std::stoistd::stol等相比,std::from_chars提供了无异常、低开销的解析方法。

#include <charconv>
#include <iostream>

int main() {
    const char* str = "1234";
    int value = 0;
    auto [ptr, ec] = std::from_chars(str, str + std::strlen(str), value);

    if(ec == std::errc()) {
        std::cout << "Parsed value: " << value << std::endl; // 输出: Parsed value: 1234
    }
    return 0;
}

std::to_chars

std::from_chars相对应,std::to_chars用于将数值转换为字符串。与传统的std::to_string相比,std::to_chars提供了更好的控制和性能,特别是不产生内存分配。

#include <charconv>
#include <iostream>
#include <array>

int main() {
    std::array<char, 10> str;
    int value = 1234;
    auto [ptr, ec] = std::to_chars(str.data(), str.data() + str.size(), value);

    if(ec == std::errc()) {
        std::string result(str.data(), ptr); // ptr 指向最后一个字符的下一个位置
        std::cout << "Converted string: " << result << std::endl; // 输出: Converted string: 1234
    }
    return 0;
}

std::variant

提供了一种类型安全的方式来存储和访问一个值,这个值可以是预先定义类型列表中的任何类型。这使得std::variant成为处理多种类型数据的理想选择,特别是在不确定应该使用哪种类型时。

类型安全的联合体

  • std::variant基本上是一个更加安全、更灵活的联合体(union)。与传统的联合体不同,std::variant会自动管理存储的类型,保证任何时候只有一种类型的值被正确地存储。

自动生成的成员函数

  • std::variant自动生成默认构造函数、拷贝构造函数、移动构造函数、赋值操作符等,这些都是基于其持有的类型的相应函数安全地执行的。

访问存储的值

  • 可以通过std::get<>函数使用编译时的类型安全检查来访问std::variant中的值。如果请求的类型与当前存储的类型不匹配,将会抛出std::bad_variant_access异常。
  • std::holds_alternative<>用于在运行时检查std::variant是否存储了特定类型的值。

访问与访问者模式

  • std::visit允许对std::variant存储的值应用函数或函数对象。这是使用访问者模式的一种强大方式,可以基于存储的值类型执行不同的操作,而不需要显式地检查其类型。
#include <variant>
#include <iostream>

struct PrintVisitor {
    void operator()(int i) const { std::cout << "int: " << i << '\n'; }
    void operator()(float f) const { std::cout << "float: " << f << '\n'; }
    void operator()(const std::string& s) const { std::cout << "string: " << s << '\n'; }
};

int main() {
    std::variant<int, float, std::string> v = "Hello, std::variant!";
    std::visit(PrintVisitor{}, v);
}

std::variant的递归和间接使用

  • std::variant支持存储类型为std::shared_ptrstd::unique_ptr的情况,使其可以间接地存储同一std::variant类型,从而实现递归数据结构。

注意事项

  1. 大小限制std::variant的大小至少等于其可保存类型中最大类型的大小,加上类型信息的管理需要的额外空间。这意味着std::variant可能会比存储单一类型的变量消耗更多内存空间。
  2. 异常处理:在通过std::get<T>访问std::variant中错误类型的值时,会抛出std::bad_variant_access异常。这要求开发者必须小心处理访问逻辑,尤其是在不能使用异常的环境中(如一些嵌入式系统),使用std::variant可能会变得复杂。
  3. 不支持引用类型std::variant不能直接存储引用类型。虽然可以通过使用std::reference_wrapper间接支持引用,但这增加了使用复杂性。
  4. 递归类型需要特殊处理:直接使用std::variant定义递归类型(例如,一个树节点可能包含一个子节点列表,这些子节点也是相同类型)是不可能的。通常需要通过std::shared_ptr来间接实现。

std::optional

提供了一种表达可选(optional)语义的方式。使用 std::optional 可以表示一个变量可能持有某种类型的值,或者不持有任何值。std::optional 主要用于处理那些可能不存在值的情况,从而避免了使用原始指针或特殊值来表达“空”或“无效”值的需要。

存储

std::optional 内部通过联合体(union)来存储其包含的值。联合体是一种特殊的数据结构,它可以存储不同的数据类型,但在任何给定时刻只能包含其中一种类型的值。std::optional 使用这种结构来选择性地存储其包装的值类型 T。为了处理 T 类型对象的构造和析构,std::optional 在其联合体内同时管理一个表示存储状态(即值是否存在)的布尔标志。

构造和析构

std::optional 被构造并包含一个值时,它会在其内部联合体中构造这个值,并设置存在标志为 true。当 std::optional 被析构或被赋予一个新的值(或无值)时,如果当前存储了一个值,则会调用这个值的析构函数,并更新存在标志。

值访问

当访问 std::optional 存储的值时,std::optional 提供了 valueoperator* 方法。这些方法首先检查存在标志,确认是否有值可以返回。如果尝试访问一个不存在的值,std::optional 将抛出 std::bad_optional_access 异常。

状态检查

std::optional 提供 has_value 方法来检查其是否包含一个值。这直接返回内部的存在标志。

与引用类型交互

虽然 std::optional 不能直接存储引用类型,但可以通过 std::reference_wrapper 来包装引用,使得 std::optional<std::reference_wrapper<T>>变为可能的,从而在一定程度上模拟对引用的可选性。

类型转换

std::optional 提供了条件显式转换操作符,允许 std::optional<T> 在需要 bool 类型的上下文中被隐式使用,使得可以直接在条件语句中使用。

实现示例

下面是一个简化的 std::optional 实现示例,展示了一些核心概念:

template<typename T>
class Optional {
private:
    alignas(T) unsigned char storage[sizeof(T)];
    bool hasValue;

public:
    Optional(): hasValue(false) {}

    Optional(const T& value) {
        new (storage) T(value); // 在 storage 上就地构造 T 类型对象
        hasValue = true;
    }

    ~Optional() {
        if (hasValue) {
            reinterpret_cast<T*>(storage)->~T(); // 调用析构函数
        }
    }

    bool has_value() const { return hasValue; }

    T& value() {
        if (!hasValue) throw std::bad_optional_access{};
        return *reinterpret_cast<T*>(storage);
    }
};

注意事项

  1. 使用 value() 方法时,如果 std::optional 不含值,将抛出 std::bad_optional_access 异常。这要求调用者必须准备好处理这种潜在的异常情况,或者在访问值前通过 has_value() 检查状态,以避免异常。
  2. std::optional 不直接支持引用类型。尽管这并非完全是限制,但它要求开发者使用如 std::reference_wrapper 等工具来间接实现对引用的支持,这增加了使用的复杂度。
  3. std::optional 包含一个对象时,该对象处于完全构造的状态;当 std::optional 为空时,表示不含任何对象。在这两种状态之间转换需要注意对象的构造和析构,特别是需要关注所含对象的生命周期和资源管理。

std::any

用于在单个变量中存储任意类型的值。与其他语言中的类似构造相比,C++ 的 std::any 提供了类型安全的方式来处理不确定类型的值,同时依旧保持了静态类型语言的特性和优势。

主要特性

  • 类型安全:即使 std::any 可以存储任意类型的值,但在访问这些值时,必须明确指定正确的类型。尝试以错误的类型访问 std::any 中的值将导致抛出 std::bad_any_cast 异常。
  • 灵活性std::any 可以用来存储任何拷贝构造的类型,从简单的基本类型到复杂的自定义类型都行,这使得它在处理不同类型的数据时非常灵活。
  • 容易使用std::any 提供了简单的 API,比如 any_cast,用于安全地从 std::any 对象中提取值。

使用场景

std::any 常见的使用场景包括但不限于:

  • 函数返回多种类型:当函数可能返回不同的类型时,std::any 可以用作返回类型,从而使得函数更加灵活。
  • 容器存储不同类型的对象:在需要在同一个容器中存储不同类型对象时,std::any 提供了一种类型安全的解决方案。
  • API 和库开发:在创建可用于不同上下文和带有不同类型参数的 API 或库时,std::any 可以用来处理那些在编写时未知的类型。

基本用法

#include <iostream>
#include <any>
#include <string>

int main() {
    std::any a = 10; // 存储 int
    std::any b = std::string("Hello, world!"); // 存储 string

    // 尝试提取值
    try {
        std::cout << std::any_cast<int>(a) << std::endl;
        std::cout << std::any_cast<std::string>(b) << std::endl;
        // 错误的类型抛出 bad_any_cast
        std::cout << std::any_cast<float>(a) << std::endl; 
    } catch (const std::bad_any_cast& e) {
        std::cout << e.what() << std::endl;
    }

    // 检查是否有值
    if (a.has_value()) {
        std::cout << "a has a value." << std::endl;
    }

    // 重置 a,使其不再包含任何值
    a.reset();
    if (!a.has_value()) {
        std::cout << "a now has no value." << std::endl;
    }

    return 0;
}

在这个例子中,我们创建了两个 std::any 对象,它们分别存储了一个 int 和一个 std::string。然后,我们使用 std::any_cast 尝试提取存储的值,并且展示了如何正确处理类型不匹配的情况。

注意事项

  1. 尽管 std::any 提供了以类型安全的方式存放和访问任意类型的能力,但这个特性依赖于运行时检查。因此,在尝试使用 std::any_cast 提取值时,如果类型不匹配,将抛出 std::bad_any_cast 异常。这要求开发者必须非常确信他们知道存储在 std::any 中的实际类型,或已准备好处理可能的异常。
  2. 与直接使用具体类型相比,std::any 内部实现可能涉及到动态内存分配(尤其是当存储的类型大小超过一个小对象优化阈值时),并且类型擦除和运行时类型检查会引入额外的性能成本。这可能不适合对性能有严格要求的应用。
  3. std::any 不能直接存储引用类型。尽管可以通过包装如 std::reference_wrapper 来间接实现这一点,但这增加了使用复杂度,并且不如直接持有引用直观。
  4. 不支持不可拷贝构造的类型,std::any 要求存储的类型必须是拷贝构造的,这意味着对于无法拷贝构造的类型(例如,具有删除拷贝构造函数的类型),std::any 不能使用。
  5. 类型检查仅在运行时, 虽然 std::any 引入了一种处理类型不确定性的手段,但类型的安全性检查仅限于运行时。

std::apply

允许你将一个元组的各个元素作为参数,应用于某个函数或可调用对象。这提供了一种便利的方式来动态地以元组形式存储参数,然后在需要时解包(unpack)这些参数并调用函数。

std::apply 实现的核心是利用模板递归和参数包展开(unpack)机制来将元组中的每个元素作为参数传给一个函数或可调用对象。这个过程涉及到了模板编程和一些 C++11 开始引入的高级特性,如变参模板和参数包。

在底层,std::apply 的实现通常会涉及到几个关键步骤:

1. 索引序列的生成

为了能够在编译时处理元组中的每个元素,std::apply 需要一种机制来遍历元组的每个元素。C++14 引入的 std::index_sequencestd::make_index_sequence 就是专门用于这种情况的工具,它们可以生成一个编译时常量序列,这个序列可以用作模板参数,以索引访问元组中的每个元素。

2. 模板递归和参数包展开

通过获取元组的大小来生成一个对应的 std::index_sequence 之后,std::apply 会利用变参模板和参数包的特性,通过模板递归来遍历这个序列,并对元组中的每个元素进行访问。

具体来说,std::apply 会定义一个辅助模板函数,这个函数接受一个函数(或可调用对象)、一个元组和一个索引序列。通过模板展开,它会使用 std::get 来按索引依次获取元组中的每个元素,并传递给目标函数。

3. 完成调用

当所有元素都被准备好后,就可以直接将它们作为参数调用目标函数了。这一过程完全在编译时完成,不涉及运行时类型检查或者类型转换,因此是类型安全的。

基本用法

下面是 std::apply 的一个基础示例,展示如何使用它来将一个元组的元素作为参数传递给函数:

#include <iostream>
#include <tuple>
#include <utility>

// 一个简单的函数,用于演示
void print(int a, const std::string& b, float c) {
    std::cout << a << ", " << b << ", " << c << std::endl;
}

int main() {
    // 创建一个元组,存放将要传递给函数的参数
    auto arguments = std::make_tuple(1, "Hello", 3.14f);

    // 使用 std::apply 将元组的元素解包,作为参数传递给 print 函数
    std::apply(print, arguments);

    return 0;
}

在上面的示例中,std::apply 接受了一个函数(print)和一个元组(arguments),然后将元组中的元素作为参数传递给了 print 函数。

注意事项

  1. std::apply 要求被调用的函数或可调用对象的参数列表与元组中的类型完全匹配。如果类型不完全一致,比如参数类型不能自动转换为目标函数所需的类型,会导致编译错误。
  2. 由于元组本身存储的是值(即使它们是引用的包装器如 std::ref),std::apply 不能直接用来传递引用参数除非通过一些特殊手段(例如使用 std::reference_wrapper)。
  3. 无法用于非拷贝构造类型,由于元组本质上存储的是值,如果你尝试在元组中存储一个不可拷贝构造的类型,比如某些封装了资源管理的类,可能会遇到问题。std::apply 自身并不限制这一点,但元组的这一特性限制了无法直接存储非拷贝构造的类型。

std::make_from_tuple

实用特性,允许你从一个元组(tuple)中直接构造对象。这种能力特别有用于场景,其中对象的构造函数参数被存储或转发为一个元组,而需要从这个元组中构造对象。

基本用法

std::make_from_tuple 需要 #include 头文件,并可以如下使用:

#include <tuple>
#include <string>
#include <iostream>

struct MyStruct {
    int x;
    std::string y;
};

int main() {
    // 创建一个元组,用作构造MyStruct的参数
    auto t = std::make_tuple(10, "Hello World");

    // 使用std::make_from_tuple从元组t构造MyStruct的实例
    MyStruct s = std::make_from_tuple<MyStruct>(t);

    std::cout << s.x << ", " << s.y << std::endl; // 输出: 10, Hello World
    
    return 0;
}

注意事项

  1. std::make_from_tuple 试图使用元组中的值来构造一个对象,对象的构造函数的参数类型和数量必须与元组中的元素相匹配。如果没有匹配的构造函数,代码将无法编译。这要求构造函数必须可以接受元组中提供的所有参数。
  2. 每个元组中的元素类型必须与目标对象构造函数中对应参数的类型精确匹配,或者至少能够隐式转换。如果类型间不兼容,导致不能隐式转换,那么构造过程将失败。
  3. 处理引用和指针时需要小心。因为 std::make_from_tuple 直接按值传递元组内的元素到构造函数,如果目标构造函数期望一个引用或指针作为参数,那么元组中相应的元素也必须是引用或指针,否则可能不会按预期工作。
  4. 如果目标对象的构造函数需要的是一个非拷贝和非移动类型的参数,std::make_from_tuple 可能无法正常使用。由于元组内的元素在传递给构造函数时本质上是被拷贝或移动的,如果你的类型不能拷贝或移动,那么将不能使用 std::make_from_tuple

std::string_view

提供一种轻量级、非拥有的字符串访问方式。std::string_view 代表了对字符序列的视图(即查看或访问字符序列的方式),它并不拥有这些字符序列,而是提供了对原始字符串数据的引用。

特性

  • 无需拷贝: 使用 std::string_view 可以避免在字符串操作中进行不必要的拷贝。例如,提取子字符串或传递字符串到函数中时,不需要创建字符串的副本。
  • 非拥有性: std::string_view 只是提供对字符串(或任何字符序列)的视图,它不拥有字符串的数据,因此不负责管理数据的生命周期。
  • 灵活性: 它能够以统一的方式访问不同类型的字符串或字符数组,包括 std::string、字符串字面量、字符数组等。
  • 高效的字符串操作: 提供了一系列用于字符串处理的方法,如 substrfindcompare 等,这些操作通常比 std::string 更高效,因为它们不需要创建字符串副本。

使用示例

以下是使用 std::string_view 的一个简单示例:

#include <iostream>
#include <string_view>
#include <string>

int main() {
    std::string str = "Hello, world!";
    std::string_view sv(str);
    
    // 输出原始字符串
    std::cout << sv << std::endl;  
    
    // 输出子字符串
    std::cout << sv.substr(7) << std::endl;  // "world!"

    // 查找字符串
    size_t pos = sv.find(",");
    if (pos != std::string_view::npos) {
        std::cout << "Comma found at position: " << pos << std::endl;
    } else {
        std::cout << "Comma not found" << std::endl;
    }
    
    return 0;
}

注意事项

  1. 由于 std::string_view 不拥有其所引用的字符串数据,因此需要保证在 std::string_view 的生命周期内,其底层的字符串数据仍然有效且未被修改。如果底层数据被销毁或超出作用域,通过 std::string_view 访问这些数据将导致未定义的行为,可能会引发程序崩溃或数据损坏。
  2. std::string_view 主要设计为只读视图,它不提供直接修改字符串内容的功能。如果需要修改字符串,你需要操作原始字符串或基于 std::string_view 的内容创建一个新的 std::string 实例进行修改。
  3. std::string_view 可能并不保证引用的字符串是以空字符(‘\0’)终止的,尤其是当它引用字符串的一部分(如通过 substr 方法创建的视图)时。这意味着不能直接将 std::string_view 用于那些期望C风格字符串(即空终止字符串)的API,如某些C标准库函数。

as_const

获取对象的常量引用。这个功能使得在需要常量引用的地方,能够轻松从非常量对象获得其常量引用,从而避免不必要的对象复制,同时也确保了对象内容不会被修改。

功能概述

std::as_const 函数位于头文件 <utility> 中。它接受一个引用作为参数,并返回参数的const类型引用。这个函数非常简单,但在需要确保不修改传入对象的情境下非常有用。

使用场景

std::as_const 最常见的用途之一是在调用需要常量引用参数的函数时,确保传递的对象不会被函数修改。此外,也可以用它来避免重载函数时引发的歧义。

注意事项

  1. std::as_const 要求其参数必须是左值,因为它返回一个对原始对象的引用。尝试将 std::as_const 用于临时对象(非左值)将引发编译错误,因为这会产生一个对临时对象的悬挂引用,是危险的且未定义的行为。
  2. 当你需要修改对象时,自然不能使用 std::as_const,因为它返回的是对象的常量引用。这意味着它仅适用于那些需要保障不修改对象的场景。
  3. std::as_const 不会改变对象的生命周期。它返回的常量引用与原始对象共享相同的生命期。因此,使用 std::as_const 时,需要确保原始对象的生命周期至少与其返回的引用一样长。如果原始对象先于其引用销毁,那么引用将成为悬挂引用,访问它将导致未定义行为。
  4. std::as_const 仅用于添加 const 限定符。它不进行任何类型转换或其他任何操作。如果需要转换类型,比如进行静态转换(static_cast)或动态转换(dynamic_cast),需要单独进行。

file_system

<filesystem> 头文件是文件系统库的一部分,提供了一组用于操作文件系统的类和函数。这标志着 C++ 对文件和路径操作的现代化和标准化。在这之前,文件系统的操作依赖于平台特定的API或第三方库,而 <filesystem> 库的引入旨在提供一种跨平台、统一且易于使用的解决方案。

主要特性

  1. 路径操作
  • std::filesystem::path 类提供了一个灵活的方式来构建和操作文件路径,无论是绝对路径还是相对路径,支持路径的拼接、解析等操作。
  1. 文件和目录的检查
  • 提供了诸如 exists(), is_directory(), is_empty(), is_regular_file() 等函数,可用于检查文件或目录的状态和属性。
  1. 文件和目录的创建与删除
  • 支持创建和删除目录(create_directory(), remove(), remove_all() 等),以及移动或重命名文件和目录(rename())。
  1. 文件大小和文件最后修改时间
  • 可以通过 file_size() 获取文件大小,通过 last_write_time() 获取文件的最后修改时间。
  1. 目录遍历
  • std::filesystem::directory_iteratorstd::filesystem::recursive_directory_iterator 提供了遍历目录和子目录的能力,使得文件的搜索和处理变得更加简单。
  1. 文件权限
  • 支持检查和修改文件权限(例如,只读、可执行等)。
  1. 空间信息
  • 可以获取磁盘或文件系统的空间信息,如总空间、可用空间和空闲空间(space())。

示例代码

以下是一个简单的示例,展示如何使用 <filesystem> 库查询当前工作目录并列出其中的文件:

#include <iostream>
#include <filesystem>

namespace fs = std::filesystem;

int main() {
    fs::path current_dir = fs::current_path();  // 获取当前工作目录
    std::cout << "Current directory: " << current_dir << std::endl;

    std::cout << "Files in directory:" << std::endl;
    for (const auto& entry : fs::directory_iterator(current_dir)) {
        std::cout << entry.path() << std::endl;
    }

    return 0;
}

注意事项

  1. 符号链接和循环引用:在处理符号链接时,<filesystem> 库的某些操作可能导致意外的行为,例如无限循环(在递归目录时)。
  2. 不适用于所有类型的文件系统<filesystem> 库主要设计用于常见的磁盘文件系统,可能不适用于特殊类型的文件系统,如网络文件系统或虚拟文件系统。

std::shared_mutex

一个互斥锁,特别适合于“读多写少”的场景。与 std::mutex 相比,std::shared_mutex 提供了更加灵活的锁定策略,支持共享锁定(多个读者)和排他锁定(单个写者)。

基本特性

  • 共享锁定(Shared Locking):允许多个线程同时对数据进行读取操作。当数据被共享锁定时,任何尝试进行排他锁定的操作都将被阻塞,直到所有的共享锁都被释放。
  • 排他锁定(Exclusive Locking):仅允许一个线程进行写操作或独占访问。当数据被排他锁定时,任何其他的排他锁定或共享锁定请求都将被阻塞,直到排他锁被释放。

std::mutexstd::shared_timed_mutex 的比较

  • std::mutex:提供基本的互斥锁定,不区分共享和排他锁定,任何时候只允许一个线程持有锁。
  • std::shared_timed_mutex:C++14 引入,除了支持共享和排他锁定外,还提供了带有超时的锁定操作。std::shared_mutex 在 C++17 中被加入作为一个不支持超时操作的简化版本,提供更高的性能。

使用场景

std::shared_mutex 特别适用于访问模式为“读多写少”的场景,比如缓存系统,其中数据的读取操作远多于更新操作。使用 std::shared_mutex 可以显著提高这类应用的并发性能,因为它允许多个读者线程同时安全地访问数据,而无需等待其他读者线程释放锁。

示例代码

#include <shared_mutex>
#include <iostream>
#include <thread>
#include <vector>

std::shared_mutex mutex;
int shared_data = 0;

void reader(int id) {
    std::shared_lock<std::shared_mutex> lock(mutex);
    // 安全地读取 shared_data
    std::cout << "Reader " << id << " sees shared_data = " << shared_data << std::endl;
}

void writer(int id) {
    std::unique_lock<std::shared_mutex> lock(mutex);
    // 安全地修改 shared_data
    ++shared_data;
    std::cout << "Writer " << id << " updated shared_data to " << shared_data << std::endl;
}

int main() {
    std::vector<std::thread> readers;
    for(int i = 0; i < 5; ++i) {
        readers.push_back(std::thread(reader, i));
    }
    
    std::vector<std::thread> writers;
    for(int i = 0; i < 2; ++i) {
        writers.push_back(std::thread(writer, i));
    }

    for(auto& reader_thread : readers) {
        reader_thread.join();
    }
    for(auto& writer_thread : writers) {
        writer_thread.join();
    }
    return 0;
}

此代码示例创建了几个读者和写者线程,展示了如何通过 std::shared_mutex 安全地对共享数据进行读写操作。

————————————————————————————————————————————————————————————