C++20完全指南:革命性特性与现代编程范式

C++20完全指南:革命性特性与现代编程范式 #

本文详细介绍了C++20标准中引入的所有重要特性。 C++20 包含以下新的语言特性:

C++20 包含以下新的库特性:

C++20 语言特性 #

协程 #

注意: 虽然这些示例展示了如何在基本层面上使用协程,但在编译代码时会发生更多事情。这些示例并不旨在完全涵盖 C++20 的协程。由于标准库尚未提供 generatortask 类,我使用了 cppcoro 库来编译这些示例。

协程 是一种特殊的函数,其执行可以被挂起和恢复。要定义一个协程,函数体中必须包含 co_returnco_awaitco_yield 关键字。C++20 的协程是无栈的;除非被编译器优化掉,否则它们的状态会分配在堆上。

一个协程的示例是 生成器 函数,它在每次调用时生成一个值:

generator<int> range(int start, int end) {
  while (start < end) {
    co_yield start;
    start++;
  }

  // 函数末尾的隐式 co_return:
  // co_return;
}

for (int n : range(0, 10)) {
  std::cout << n << std::endl;
}

上面的 range 生成器函数从 start 开始生成值,直到 end(不包括 end),每次迭代步骤生成当前存储在 start 中的值。生成器在每次调用 range 时保持其状态(在这个例子中,每次 for 循环的调用都是如此)。co_yield 接收给定的表达式,生成(即返回)其值,并在该点挂起协程。恢复执行时,从 co_yield 之后继续执行。

另一个协程的示例是 任务,这是一个异步计算,当任务被等待时执行:

task<void> echo(socket s) {
  for (;;) {
    auto data = co_await s.async_read();
    co_await async_write(s, data);
  }

  // 函数末尾的隐式 co_return:
  // co_return;
}

在这个例子中,引入了 co_await 关键字。这个关键字接收一个表达式,如果等待的对象(在这个例子中,是读取或写入操作)尚未准备好,则挂起执行,否则继续执行。(注意,在底层,co_yield 使用了 co_await。)

使用任务惰性计算一个值:

task<int> calculate_meaning_of_life() {
  co_return 42;
}

auto meaning_of_life = calculate_meaning_of_life();
// ...
co_await meaning_of_life; // == 42

概念 #

概念 是命名的编译时谓词,用于约束类型。它们的形式如下:

template <模板参数列表>
concept 概念名称 = 约束表达式;

其中 约束表达式 是一个 constexpr 布尔表达式。约束 应该模拟语义要求,例如类型是否为数值类型或可哈希。如果给定类型不满足它所绑定的概念(即 约束表达式 返回 false),则会导致编译器错误。由于约束是在编译时评估的,因此它们可以提供更有意义的错误消息和运行时安全性。

// `T` 没有任何约束。
template <typename T>
concept always_satisfied = true;
// 限制 `T` 为整数类型。
template <typename T>
concept integral = std::is_integral_v<T>;
// 限制 `T` 为 `integral` 约束和有符号性。
template <typename T>
concept signed_integral = integral<T> && std::is_signed_v<T>;
// 限制 `T` 为 `integral` 约束和非 `signed_integral` 约束。
template <typename T>
concept unsigned_integral = integral<T> && !signed_integral<T>;

有多种语法形式用于强制执行概念:

// 函数参数的形式:
// `T` 是一个受约束的类型模板参数。
template <my_concept T>
void f(T v);

// `T` 是一个受约束的类型模板参数。
template <typename T>
  requires my_concept<T>
void f(T v);

// `T` 是一个受约束的类型模板参数。
template <typename T>
void f(T v) requires my_concept<T>;

// `v` 是一个受约束的推导参数。
void f(my_concept auto v);

// `v` 是一个受约束的非类型模板参数。
template <my_concept auto v>
void g();

// 自动推导变量的形式:
// `foo` 是一个受约束的自动推导值。
my_concept auto foo = ...;

// lambda 的形式:
// `T` 是一个受约束的类型模板参数。
auto f = []<my_concept T> (T v) {
  // ...
};
// `T` 是一个受约束的类型模板参数。
auto f = []<typename T> requires my_concept<T> (T v) {
  // ...
};
// `T` 是一个受约束的类型模板参数。
auto f = []<typename T> (T v) requires my_concept<T> {
  // ...
};
// `v` 是一个受约束的推导参数。
auto f = [](my_concept auto v) {
  // ...
};
// `v` 是一个受约束的非类型模板参数。
auto g = []<my_concept auto v> () {
  // ...
};

requires 关键字用于开始一个 requires 子句或一个 requires 表达式:

template <typename T>
  requires my_concept<T> // `requires` 子句。
void f(T);

template <typename T>
concept callable = requires (T f) { f(); }; // `requires` 表达式。

template <typename T>
  requires requires (T x) { x + x; } // 同一行中的 `requires` 子句和表达式。
T add(T a, T b) {
  return a + b;
}

注意,在 requires 表达式中,参数列表是可选的。requires 表达式中的每个要求是以下之一:

  • 简单要求 - 断言给定表达式是有效的。
template <typename T>
concept callable = requires (T f) { f(); };
  • 类型要求 - 由 typename 关键字后跟类型名称表示,断言给定的类型名称是有效的。
struct foo {
  int foo;
};

struct bar {
  using value = int;
  value data;
};

struct baz {
  using value = int;
  value data;
};

// 使用 SFINAE,如果 `T` 是 `baz`,则启用。
template <typename T, typename = std::enable_if_t<std::is_same_v<T, baz>>>
struct S {};

template <typename T>
using Ref = T&;

template <typename T>
concept C = requires {
                     // 类型 `T` 的要求:
  typename T::value; // A) 有一个名为 `value` 的内部成员
  typename S<T>;     // B) 必须有 `S` 的有效类模板特化
  typename Ref<T>;   // C) 必须是有效的别名模板替换
};

template <C T>
void g(T a);

g(foo{}); // 错误:未满足要求 A。
g(bar{}); // 错误:未满足要求 B。
g(baz{}); // 通过。
  • 复合要求 - 由大括号中的表达式后跟尾随返回类型或类型约束组成。
template <typename T>
concept C = requires(T x) {
  {*x} -> std::convertible_to<typename T::inner>; // 表达式 `*x` 的类型可以转换为 `T::inner`
  {x + 1} -> std::same_as<int>; // 表达式 `x + 1` 满足 `std::same_as<decltype((x + 1))>`
  {x * 1} -> std::convertible_to<T>; // 表达式 `x * 1` 的类型可以转换为 `T`
};
  • 嵌套要求 - 由 requires 关键字表示,指定额外的约束(例如对局部参数参数的约束)。
template <typename T>
concept C = requires(T x) {
  requires std::same_as<sizeof(x), size_t>;
};

详见:概念库

三向比较 #

C++20 引入了宇宙飞船运算符(<=>),这是一种新的比较函数编写方式,可以减少样板代码,并帮助开发者定义更清晰的比较语义。定义一个三向比较运算符将自动生成其他比较运算符函数(即 ==!=< 等)。

引入了三种排序方式:

  • std::strong_ordering:强排序区分项目是否相等(相同且可互换)。提供 lessgreaterequivalentequal 排序。比较示例:在列表中查找特定值、整数值、区分大小写的字符串。
  • std::weak_ordering:弱排序区分项目是否等价(不相同,但可用于比较目的的互换)。提供 lessgreaterequivalent 排序。比较示例:不区分大小写的字符串、排序、比较类的部分可见成员。
  • std::partial_ordering:部分排序遵循弱排序的相同原则,但包括无法排序的情况。提供 lessgreaterequivalentunordered 排序。比较示例:浮点值(例如 NaN)。

默认的三向比较运算符进行成员逐个比较:

struct foo {
  int a;
  bool b;
  char c;

  // 先比较 `a`,然后比较 `b`,最后比较 `c` ...
  auto operator<=>(const foo&) const = default;
};

foo f1{0, false, 'a'}, f2{0, true, 'b'};
f1 < f2; // == true
f1 == f2; // == false
f1 >= f2; // == false

您也可以定义自己的比较方式:

struct foo {
  int x;
  bool b;
  char c;
  std::strong_ordering operator<=>(const foo& other) const {
      return x <=> other.x;
  }
};

foo f1{0, false, 'a'}, f2{0, true, 'b'};
f1 < f2; // == false
f1 == f2; // == true
f1 >= f2; // == true

指定初始化器 #

C 风格的指定初始化器语法。未在指定初始化器列表中明确列出的任何成员字段将进行默认初始化。

struct A {
  int x;
  int y;
  int z = 123;
};

A a {.x = 1, .z = 2}; // a.x == 1, a.y == 0, a.z == 2

lambda 的模板语法 #

在 lambda 表达式中使用熟悉的模板语法。

auto f = []<typename T>(std::vector<T> v) {
  // ...
};

带初始化器的范围 for 循环 #

此功能简化了常见的代码模式,有助于保持作用域紧凑,并为常见的生命周期问题提供了一个优雅的解决方案。

for (auto v = std::vector{1, 2, 3}; auto& e : v) {
  std::cout << e;
}
// 输出 "123"

[[likely]] 和 [[unlikely]] 属性 #

为优化器提供提示,标记的语句有很高的执行概率。

switch (n) {
case 1:
  // ...
  break;

[[likely]] case 2:  // 认为 n == 2 的概率远高于 n 的其他任何值
  // ...
  break;
}

如果其中一个 likely/unlikely 属性出现在 if 语句的右括号之后, 则表示该分支可能/不太可能执行其子语句(主体)。

int random = get_random_number_between_x_and_y(0, 3);
if (random > 0) [[likely]] {
  // if 语句的主体
  // ...
}

它也可以应用于迭代语句的子语句(主体)。

while (unlikely_truthy_condition) [[unlikely]] {
  // while 语句的主体
  // ...
}

弃用隐式捕获 this #

现在弃用使用 [=] 在 lambda 捕获中隐式捕获 this;建议明确使用 [=, this][=, *this] 进行捕获。

struct int_value {
  int n = 0;
  auto getter_fn() {
    // 不好:
    // return [=]() { return n; };

    // 好:
    return [=, *this]() { return n; };
  }
};

非类型模板参数中的类类型 #

现在可以在非类型模板参数中使用类。作为模板参数传递的对象具有类型 const T,其中 T 是对象的类型,并且具有静态存储期。

struct foo {
  foo() = default;
  constexpr foo(int) {}
};

template <foo f = {}>
auto get_foo() {
  return f;
}

get_foo(); // 使用隐式构造函数
get_foo<foo{123}>();

constexpr 虚函数 #

虚函数现在可以是 constexpr,并在编译时进行评估。constexpr 虚函数可以覆盖非 constexpr 虚函数,反之亦然。

struct X1 {
  virtual int f() const = 0;
};

struct X2: public X1 {
  constexpr virtual int f() const { return 2; }
};

struct X3: public X2 {
  virtual int f() const { return 3; }
};

struct X4: public X3 {
  constexpr virtual int f() const { return 4; }
};

constexpr X4 x4;
x4.f(); // == 4

explicit(bool) #

在编译时有条件地选择构造函数是否为 explicitexplicit(true) 与指定 explicit 相同。

struct foo {
  // 指定非整数类型(字符串、浮点数等)需要显式构造。
  template <typename T>
  explicit(!std::is_integral_v<T>) foo(T) {}
};

foo a = 123; // OK
foo b = "123"; // 错误:显式构造函数不是候选(explicit 指定符评估为 true)
foo c {"123"}; // OK

即时函数 #

类似于 constexpr 函数,但带有 consteval 修饰符的函数必须产生一个常量。这些被称为 即时函数

consteval int sqr(int n) {
  return n * n;
}

constexpr int r = sqr(100); // OK
int x = 100;
int r2 = sqr(x); // 错误:'x' 的值在常量表达式中不可用
                 // 如果 `sqr` 是 `constexpr` 函数,则可以

using enum #

将枚举的成员引入作用域,以提高可读性。之前:

enum class rgba_color_channel { red, green, blue, alpha };

std::string_view to_string(rgba_color_channel channel) {
  switch (channel) {
    case rgba_color_channel::red:   return "red";
    case rgba_color_channel::green: return "green";
    case rgba_color_channel::blue:  return "blue";
    case rgba_color_channel::alpha: return "alpha";
  }
}

之后:

enum class rgba_color_channel { red, green, blue, alpha };

std::string_view to_string(rgba_color_channel my_channel) {
  switch (my_channel) {
    using enum rgba_color_channel;
    case red:   return "red";
    case green: return "green";
    case blue:  return "blue";
    case alpha: return "alpha";
  }
}

lambda 捕获参数包 #

按值捕获参数包:

template <typename... Args>
auto f(Args&&... args){
    // 按值捕获:
    return [...args = std::forward<Args>(args)] {
        // ...
    };
}

按引用捕获参数包:

template <typename... Args>
auto f(Args&&... args){
    // 按引用捕获:
    return [&...args = std::forward<Args>(args)] {
        // ...
    };
}

char8_t #

为表示 UTF-8 字符串提供一个标准类型。

char8_t utf8_str[] = u8"\u0123";

constinit #

constinit 修饰符要求变量必须在编译时初始化。

const char* g() { return "dynamic initialization"; }
constexpr const char* f(bool p) { return p ? "constant initializer" : g(); }

constinit const char* c = f(true); // OK
constinit const char* d = g(false); // 错误:`g` 不是 constexpr,因此无法在编译时评估 `d`。

__VA_OPT__ #

通过在非空变长宏中评估给定参数来支持变长宏。

#define F(...) f(0 __VA_OPT__(,) __VA_ARGS__)
F(a, b, c) // 替换为 f(0, a, b, c)
F()        // 替换为 f(0)

C++20 库特性 #

概念库 #

标准库还提供了用于构建更复杂概念的概念。其中包括:

核心语言概念:

  • same_as - 指定两个类型相同。
  • derived_from - 指定一个类型是从另一个类型派生的。
  • convertible_to - 指定一个类型可以隐式转换为另一个类型。
  • common_with - 指定两个类型有一个共同的类型。
  • integral - 指定一个类型是整数类型。
  • default_constructible - 指定可以默认构造该类型的对象。

比较概念:

  • boolean - 指定一个类型可以在布尔上下文中使用。
  • equality_comparable - 指定 operator== 是一个等价关系。

对象概念:

  • movable - 指定可以移动和交换该类型的对象。
  • copyable - 指定可以复制、移动和交换该类型的对象。
  • semiregular - 指定可以复制、移动、交换和默认构造该类型的对象。
  • regular - 指定该类型是 正则的,即它既是 semiregular,又是 equality_comparable

可调用概念:

  • invocable - 指定一个可调用类型可以使用给定的一组参数类型进行调用。
  • predicate - 指定一个可调用类型是一个布尔谓词。

详见:概念

格式化库 #

结合 printf 的简单性和 iostream 的类型安全性。使用大括号作为占位符,并支持类似 printf 风格的自定义格式化。

std::format("{1} {0}", "world", "hello"); // == "hello world"

int x = 123;
std::string str = std::format("x: {}", x); // str == "x: 123"

// 格式化到输出迭代器:
for (auto x : {1, 2, 3}) {
  std::format_to(std::ostream_iterator<char>{std::cout, "\n"}, "{}", x);
}

要格式化自定义类型:

struct fraction {
  int numerator;
  int denominator;
};

template <>
struct std::formatter<fraction>
{
    constexpr auto parse(std::format_parse_context& ctx) {
      return ctx.begin();
    }

    auto format(const fraction& f, std::format_context& ctx) const {
      return std::format_to(ctx.out(), "{0:d}/{1:d}", f.numerator, f.denominator);
    }
};

fraction f{1, 2};
std::format("{}", f); // == "1/2"

同步缓冲输出流 #

为包装的输出流缓冲输出操作,确保同步(即不会交错输出)。

std::osyncstream{std::cout} << "The value of x is:" << x << std::endl;

std::span #

std::span 是一个容器的视图(即非拥有者),提供对一组连续元素的边界检查访问。由于视图不拥有其元素,因此它们的构造和复制成本很低——可以简化地认为视图是持有对其数据的引用。与维护指针/迭代器和长度字段相比,std::span 将它们封装在一个对象中。

std::span 可以是动态大小的,也可以是固定大小的(称为它们的 范围)。固定大小的 std::span 从边界检查中受益。

std::span 不传播 const,因此要构造一个只读的 std::span,请使用 std::span<const T>

示例:使用动态大小的 std::span 打印来自各种容器的整数。

void print_ints(std::span<const int> ints) {
    for (const auto n : ints) {
        std::cout << n << std::endl;
    }
}

print_ints(std::vector{ 1, 2, 3 });
print_ints(std::array<int, 5>{ 1, 2, 3, 4, 5 });

int a[10] = { 0 };
print_ints(a);
// 等等。

示例:静态大小的 std::span 如果容器不匹配 std::span 的范围,则无法编译。

void print_three_ints(std::span<const int, 3> ints) {
    for (const auto n : ints) {
        std::cout << n << std::endl;
    }
}

print_three_ints(std::vector{ 1, 2, 3 }); // 错误
print_three_ints(std::array<int, 5>{ 1, 2, 3, 4, 5 }); // 错误
int a[10] = { 0 };
print_three_ints(a); // 错误

std::array<int, 3> b = { 1, 2, 3 };
print_three_ints(b); // OK

// 如果需要,可以手动构造一个 span:
std::vector c{ 1, 2, 3 };
print_three_ints(std::span<const int, 3>{ c.data(), 3 }); // OK:设置指针和长度字段。
print_three_ints(std::span<const int, 3>{ c.cbegin(), c.cend() }); // OK:使用迭代器对。

位操作 #

C++20 提供了一个新的 <bit> 头文件,其中包含一些位操作,包括 popcount

std::popcount(0u); // 0
std::popcount(1u); // 1
std::popcount(0b1111'0000u); // 4

数学常量 #

<numbers> 头文件中定义的数学常量,包括 π、欧拉数等。

std::numbers::pi; // 3.14159...
std::numbers::e; // 2.71828...

std::is_constant_evaluated #

当在编译时上下文中调用时为真的谓词函数。

constexpr bool is_compile_time() {
    return std::is_constant_evaluated();
}

constexpr bool a = is_compile_time(); // true
bool b = is_compile_time(); // false

std::make_shared 支持数组 #

auto p = std::make_shared<int[]>(5); // 指向 `int[5]` 的指针
// 或者
auto p = std::make_shared<int[5]>(); // 指向 `int[5]` 的指针

字符串的 starts_with 和 ends_with #

字符串(和字符串视图)现在有了 starts_withends_with 成员函数,用于检查字符串是否以给定字符串开头或结尾。

std::string str = "foobar";
str.starts_with("foo"); // true
str.ends_with("baz"); // false

检查关联容器是否包含元素 #

像集合和映射这样的关联容器有了一个 contains 成员函数,可以用它来代替"查找并检查迭代器是否为末尾"的惯用法。

std::map<int, char> map {{1, 'a'}, {2, 'b'}};
map.contains(2); // true
map.contains(123); // false

std::set<int> set {1, 2, 3};
set.contains(2); // true

std::bit_cast #

一种更安全的方式来重新解释一个对象从一种类型转换为另一种类型。

float f = 123.0;
int i = std::bit_cast<int>(f);

std::midpoint #

安全地(不会溢出)计算两个整数的中点。

std::midpoint(1, 3); // == 2

std::to_array #

将给定的数组或"类似数组"的对象转换为 std::array

std::to_array("foo"); // 返回 `std::array<char, 4>`
std::to_array<int>({1, 2, 3}); // 返回 `std::array<int, 3>`

int a[] = {1, 2, 3};
std::to_array(a); // 返回 `std::array<int, 3>`

std::bind_front #

将前 N 个参数(其中 N 是给定函数之后的参数数量)绑定到一个自由函数、lambda 或成员函数。

const auto f = [](int a, int b, int c) { return a + b + c; };
const auto g = std::bind_front(f, 1, 1);
g(1); // == 3

统一容器擦除 #

为各种 STL 容器(如字符串、列表、向量、映射等)提供 std::erase 和/或 std::erase_if

使用 std::erase 按值擦除,或者使用 std::erase_if 指定何时擦除元素。这两个函数都返回擦除的元素数量。

std::vector v{0, 1, 0, 2, 0, 3};
std::erase(v, 0); // v == {1, 2, 3}
std::erase_if(v, [](int n) { return n == 0; }); // v == {1, 2, 3}

三向比较辅助函数 #

为比较结果提供名称的辅助函数:

std::is_eq(0 <=> 0); // == true
std::is_lteq(0 <=> 1); // == true
std::is_gt(0 <=> 1); // == false

详见:三向比较

std::lexicographical_compare_three_way #

使用三向比较对两个范围进行字典序比较,并产生最强适用比较类别类型的结果。

std::vector a{0, 0, 0}, b{0, 0, 0}, c{1, 1, 1};

auto cmp_ab = std::lexicographical_compare_three_way(
    a.begin(), a.end(), b.begin(), b.end());
std::is_eq(cmp_ab); // == true

auto cmp_ac = std::lexicographical_compare_three_way(
    a.begin(), a.end(), c.begin(), c.end());
std::is_lt(cmp_ac); // == true

详见:三向比较三向比较辅助函数

致谢 #