C++20 新特性总结

专栏目录

说明

本专栏的文章同步发布在CSDN和我的个人网站上. 由于CSDN的排版限制, 诸如代码高亮, 链接到Compiler Explorer的代码段等特殊格式可能会被移除, 因此我强烈推荐您访问我的个人网站以获得最佳阅读体验.

此外, 文中包含许多指向我个人网站的链接, 逐一修正这些链接需要大量时间和精力, 暂时无法完成, 敬请谅解. 感谢您的理解与支持!

简要总结

C++20 引入了四项非常大的更新, 分别是:

  1. 概念(Concepts). 用来简化模板编程, 强化表达能力. 并且使得出错原因更容易查找.
  2. 模块(Modules). 这是代码组织方面非常大的更新. 提供了新的方式来组织代码, 并且可以减少编译时间.
  3. 范围库(Ranges and Views). 轻量级的, 非拥有的范围库, 允许对数据进行各种操作.
  4. 协程(Coroutine). 多线程编程方面的一次重大更新.

本文将会对 C++20 的新特性做一个简要总结, 方便读者快速了解.

1. 三路比较运算符 <=>

C++20 引入了一个新的运算符 <=>, 称为 “三路比较运算符”“spaceship 运算符” . 这个运算符用于同时执行小于, 等于和大于的比较操作, 并返回一个特殊类型的值, 该类型表示两个对象之间的关系. 具体来说, 它会返回一个 std::strong_ordering, std::weak_ordering, 或者 std::partial_ordering 类型的对象, 分别对应强有序, 弱有序以及部分有序的情况.

示例代码

 1```cpp {linenos=inline hl_lines="7 13 17"}
 2#include <compare>
 3#include <iostream>
 4
 5struct Point {
 6  int x, y;
 7
 8  auto operator<=>(const Point&) const = default;
 9};
10
11int main() {
12  Point a{1, 2}, b{1, 2}, c{2, 3};
13
14  if (a <=> b == 0) {
15    std::cout << "a and b are equal\n";
16  }
17
18  if ((a <=> c) < 0) {
19    std::cout << "a is less than c\n";
20  }
21
22  return 0;
23}

在这个示例中, Point 结构体通过默认的方式实现了三路比较运算符, 这意味着编译器会自动生成所有基于成员变量的比较逻辑.

进一步阅读: C++20 Spaceship 操作符 ('<=>'): 现代 C++ 的比较利器

2. 函数参数支持占位符类型

C++20 支持在函数参数中使用auto关键字作为占位符. 这一特性允许你编写更加通用和灵活的函数, 而不需要指定具体的参数类型.

 1```cpp {linenos=inline hl_lines="3"}
 2#include <iostream>
 3
 4auto add(auto a, auto b) { return a + b; }
 5
 6int main() {
 7  std::cout << "Integers: " << add(1, 2) << "\n";     // 输出3
 8  std::cout << "Doubles: " << add(1.5, 2.5) << "\n";  // 输出4.0
 9
10  return 0;
11}

3. 概念和要求

假设我们想要定义一个函数模板, 该模板只接受那些可以进行比较操作(例如<)的类型. 在 C++20 之前, 这可能需要使用 SFINAE 或者static_assert来实现, 但现在我们可以直接使用概念来简化这一过程.

 1```cpp {linenos=inline hl_lines="5-8 11"}
 2#include <concepts>
 3#include <string>
 4
 5// 定义一个名为Sortable的概念, 要求类型T支持小于运算符
 6template <typename T>
 7concept Sortable = requires(T a, T b) {
 8  { a < b } -> std::convertible_to<bool>;
 9};
10
11// 仅对Sortable类型的参数有效
12void sortFunction(Sortable auto& container) {
13  // 假设这里实现了排序逻辑
14}
15
16struct MyType {
17  int value;
18  bool operator<(const MyType& other) const { return value < other.value; }
19};
20
21int main() {
22  int arr[] = {1, 3, 2};
23  sortFunction(arr);  // 正确, int支持<运算
24
25  MyType myArr[] = {{1}, {3}, {2}};
26  sortFunction(myArr);  // 正确, MyType也重载了<运算符
27
28  return 0;
29}

关键点解析

使用概念不仅使代码更易于理解, 而且当传入不符合概念要求的类型时, 编译器能提供更有意义的错误消息, 从而大大提高了开发效率. 此外, 概念还支持复杂的组合和继承关系, 使得构建复杂的需求系统成为可能.

进一步阅读: C++20 Concepts简介

4. ranges/views 库

C++20 引入了RangesViews, 它们是标准库的一部分, 旨在简化容器, 数组和其他序列数据的操作. 通过 Ranges 和 Views, 可以更直观地处理数据序列, 并支持函数式编程风格. 它们提供了强大的工具来创建, 组合以及操作数据流, 而无需手动迭代或复制数据.

Ranges是 C++20 中的一个新概念, 它扩展了 STL(标准模板库)的容器和算法, 使得操作序列更加直观和高效. 一个 Range 是一个可以遍历的数据序列, 它可以是有限的也可以是无限的. Ranges 的核心思想是将算法与容器解耦, 允许以声明式的方式定义数据处理流程.

Views是轻量级, 非拥有的范围, 它们提供了一种方式来查看原始数据的一种特定视角, 而不实际拥有数据. 这意味着 views 不会复制数据, 而是基于原始数据进行计算, 这使得它们非常高效且节省内存.

以下是一个使用 C++20 Ranges 和 Views 的例子, 展示了如何使用这些新特性来过滤和转换数据:

 1```cpp {linenos=inline hl_lines="12 13"}
 2#include <fmt/ranges.h>
 3
 4#include <algorithm>
 5#include <iostream>
 6#include <ranges>
 7#include <vector>
 8
 9int main() {
10  std::vector<int> vec = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
11
12  // 使用views过滤出偶数, 并对每个元素加1
13  auto result = vec | std::views::filter([](int n) { return n % 2 == 0; }) |
14                std::views::transform([](int n) { return n * n; });
15
16  // 打印结果
17  fmt::println("{}", result);
18  // 输出: [0, 4, 16, 36, 64]
19
20  return 0;
21}

关键点解析

进一步阅读: Modern C++ Ranges/View库简介

5. std::span

std::span 是一个轻量级, 非拥有的视图, 用于表示连续内存序列(如数组, std::vector 等). 它不拥有数据, 而是提供对数据的引用, 因此避免了不必要的复制, 同时提供了对序列的安全访问.

主要特点:

  1. 非拥有: std::span 不管理内存, 仅作为数据的视图.
  2. 连续内存: 适用于连续内存的数据结构, 如数组, std::vector 等.
  3. 安全性: 支持边界检查, 避免越界访问.
  4. 灵活性: 可以表示固定大小或动态大小的序列.
 1```cpp {linenos=inline hl_lines="6 11 15 19"}
 2#include <fmt/ranges.h>
 3
 4#include <span>
 5#include <vector>
 6
 7void print(std::span<int> span) { fmt::println("{}", span); }
 8
 9int main() {
10  // 使用 std::span 查看数组
11  int arr[] = {1, 2, 3, 4, 5};
12  print(arr);  // 输出: 1 2 3 4 5
13
14  // 使用 std::span 查看 vector
15  std::vector<int> vec = {6, 7, 8, 9, 10};
16  print(vec);  // 输出: 6 7 8 9 10
17
18  // 使用 std::span 查看部分数据
19  std::span<int> partial(vec.data() + 1, 3);
20  print(partial);  // 输出: 7 8 9
21
22  return 0;
23}

std::span 是处理连续内存序列的强大工具, 尤其适用于需要高效传递和操作数据的场景.

进一步阅读: C++20 Span 简介

6. 类型模板参数扩展

C++20 对非类型模板参数(Non-Type Template Parameters, NTTP)进行了扩展, 允许使用更多类型的非类型模板参数. 在 C++17 及之前, 非类型模板参数只能是整型, 指针, 引用或枚举类型. C++20 扩展了这一能力,

NTTP 支持如下类型:

  1. 浮点类型
  2. 结构体和简单类
  3. Lambda
 1```cpp {linenos=inline hl_lines="5 12"}
 2#include <iostream>
 3#include <string_view>
 4
 5// 使用浮点类型作为非类型模板参数
 6template <double Value>
 7void print() {
 8  std::cout << "Double value: " << Value << "\n";
 9}
10
11int main() {
12  // 浮点类型模板参数
13  print<3.14>();  // 输出: Double value: 3.14
14
15  return 0;
16}

优势:

C++20 的非类型模板参数扩展为模板编程带来了更多可能性, 尤其是在需要编译时处理浮点数或字符串的场景中非常有用.

7. 编译时计算

C++20 在编译时计算(Compile-Time Computing)方面引入了一些新特性, 使得在编译时进行复杂的计算和优化变得更加方便.

  1. constexpr 增强: C++20 进一步扩展了 constexpr 的使用范围, 允许在编译时执行更多的操作, 包括动态内存分配, 异常处理等.
  2. constinit: 表示一个全局变量或常 static 类型变量在编译时完成初始化. constinit = constexpr - const. 注意constinit修饰的变量是允许后续修改内容的.
  3. consteval : 用于定义必须在编译时求值的函数.
constexpr int square(int x) { return x * x; }
consteval int triple(int x) { return x * 3; }

int main() {
  constexpr int CX_VAL = square(5);  // 编译期计算
  // CX_VAL = 1;                     // 错误, 常量不可修改
  int val = square(5);  // 初始化普通变量
  square(val);          // OK, 可以在运行时计算

  constinit static int value = 10;  // 在编译期初始化, 但不是常量
  value = 20;
  // constinit int value2 = 10; // 错误, constinit 只能用于静态变量或者全局变量

  const int data = triple(4);  // 合法, 因为在编译期求值
  // triple(runtime);          // 错误, 不能在在运行期求值

  return 0;
}

进一步阅读: C++ constexpr, consteval和 constinit简要介绍

8. lambda 扩展

C++20 对 Lambda 表达式进行了一些扩展, 使得 Lambda 更加灵活和强大. 以下是 C++20 中 Lambda 表达式的主要扩展特性:

  1. 模板 Lambda: C++20 允许在 Lambda 表达式中使用模板参数, 使得 Lambda 可以处理不同类型的参数.

    auto genericLambda = []<typename T>(T a, T b) {
      // logic here
    };
  2. this 捕获: C++20 引入了 [*this] 捕获方式, 允许在 Lambda 中捕获当前对象的副本, 而不是引用.

  3. constevalconstexpr Lambda: Lambda 表达式现在可以标记为 constevalconstexpr, 以便在编译时求值.

 1```cpp {linenos=inline hl_lines="6 12 20-22"}
 2#include <fmt/core.h>
 3#include <fmt/ranges.h>
 4
 5int main() {
 6  // 1. 泛型 Lambda
 7  auto generic = []<typename T>(T a, T b) { return a + b; };
 8
 9  fmt::println("Generic Lambda (int): {}", generic(1, 2));         // 输出: 3
10  fmt::println("Generic Lambda (double): {}", generic(1.5, 2.5));  // 输出: 4.0
11
12  // 2. `constexpr` Lambda
13  constexpr auto constexprLambda = [](int a, int b) constexpr { return a + b; };
14
15  constexpr int result = constexprLambda(3, 4);
16  fmt::println("Constexpr Lambda: {}", result);  // 输出: 7
17
18  // 3. `[*this]` 捕获
19  struct MyStruct {
20    int value = 42;
21    auto getLambda() {
22      return [*this]() { return value; };
23    }
24  };
25
26  MyStruct obj;
27  auto lambda = obj.getLambda();
28  obj.value = 100;
29  fmt::println("Lambda with [*this] capture: {}", lambda());  // 输出: 42
30
31  return 0;
32}

9. 格式化输出

C++20 引入了新的格式化输出库 std::format, 它提供了一种类型安全且更灵活的字符串格式化方式, 类似于 Python 的 str.format(). std::format 通过使用格式化字符串和占位符来生成格式化的输出, 避免了传统 printf 风格函数中的类型不安全问题.

  1. 类型安全: std::format 是类型安全的, 编译器会在编译时检查格式字符串和参数的类型是否匹配.
  2. 简洁易读: 格式化字符串使用 {} 作为占位符, 语法简洁且易于理解.
  3. 支持自定义类型: 可以通过重载 std::formatter 来支持自定义类型的格式化输出.
 1```cpp {linenos=inline hl_lines="10 14-15 20-21"}
 2#include <format>
 3#include <iostream>
 4
 5int main() {
 6  int num = 42;
 7  double pi = 3.14159;
 8  std::string name = "Alice";
 9
10  // 基本格式化
11  std::cout << std::format("Number: {}, Pi: {:.2f}, Name: {}\n", num, pi, name);
12  // 输出: Number: 42, Pi: 3.14, Name: Alice
13
14  // 格式化字符串存储到变量
15  std::string formatted =
16      std::format("Number: {}, Pi: {:.2f}, Name: {}", num, pi, name);
17  std::cout << formatted << std::endl;
18  // 输出: Number: 42, Pi: 3.14, Name: Alice
19
20  // 格式化字符串中的位置参数
21  std::cout << std::format("Name: {2}, Pi: {1:.2f}, Number: {0}\n", num, pi,
22                           name);
23  // 输出: Name: Alice, Pi: 3.14, Number: 42
24
25  return 0;
26}

进一步阅读: C++23 格式化输出新特性详解: std::print 和 std::println

10. chrono 新增日期和时区

C++20 在 <chrono> 库中引入了对日期, 时间和时区的支持, 使得处理日期和时间变得更加方便和强大. 新的特性包括对日历日期的支持, 时区转换以及时间点之间的计算等.

  1. 日历日期支持: C++20 引入了 year, month, day 等类型, 用于表示日历日期.
  2. 时间点扩展: sys_timelocal_time 分别表示系统时间和本地时间.
  3. 时区支持: time_zonezoned_time 用于处理时区转换.
  4. 日历运算: 支持日期的加减运算, 如计算两个日期之间的天数.
#include <chrono>
#include <format>
#include <iostream>

int main() {
  using namespace std::chrono;

  // 1. 创建日历日期
  year_month_day today = 2025y / March / 6d;
  std::cout << "Today is: " << today << std::endl;  // 输出: 2025-03-06

  // 2. 创建时间点
  sys_time<seconds> sys_now = time_point_cast<seconds>(system_clock::now());
  std::cout << "System time now: " << sys_now << std::endl;  // 输出当前系统时间

  // 3. 时区转换
  auto utc_time = sys_now;
  auto local_time = zoned_time{current_zone(), utc_time};
  std::cout << "Local time now: " << local_time
            << std::endl;  // 输出当前本地时间

  // 4. 计算两个日期之间的天数
  year_month_day next_week = sys_days{today} + days{7};
  std::cout << "Next week is: " << next_week << std::endl;  // 输出: 2025-03-13

  // 5. 格式化输出
  std::cout << std::format("Today is {:%Y-%m-%d}\n",
                           today);  // 输出: Today is 2025-03-06

  return 0;
}

进一步阅读: 一文读懂C++ chrono库: duration, clocks, date, timezone

11. 协程

C++20 的协程设计比较复杂, 可以参考以下链接: Modern C++ Coroutine简介

12. std::jthreadstop_token

C++20 引入了 std::jthreadstd::stop_token, 它们为多线程编程提供了更安全和更方便的线程管理机制. std::jthreadstd::thread 的增强版本, 支持在离开作用域时自动join, 而 std::stop_token 提供了一种机制来请求线程停止执行.

  1. std::jthread:

    • 自动管理线程的生命周期, 析构时会自动调用 join(), 确保线程在销毁前完成执行.
    • 支持 std::stop_token, 允许线程在外部请求停止执行.
  2. std::stop_tokenstd::stop_source:

    • std::stop_token 用于检查是否收到停止请求.
    • std::stop_source 用于发出停止请求.
    • 两者可以配合使用, 实现线程的优雅停止.

以下是一个简单的示例, 展示了如何使用 std::jthreadstd::stop_token:

#include <iostream>
#include <thread>
#include <stop_token>
#include <chrono>

void worker(std::stop_token stopToken) {
    while (!stopToken.stop_requested()) {
        std::cout << "Working...\n";
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    std::cout << "Stopped by request.\n";
}

int main() {
    // 创建一个 jthread, 并传递 worker 函数
    std::jthread t(worker);

    // 主线程等待 3 秒
    std::this_thread::sleep_for(std::chrono::seconds(3));

    // 请求线程停止
    t.request_stop();

    // jthread 析构时会自动 join, 确保线程完成执行
    return 0;
}

输出

Working...
Working...
Working...
Stopped by request.

进一步阅读: C++20 std::jthread 完全指南 - 简化多线程编程与线程管理

13. 并发特性

latchesbarriers

std::latch 是一种同步原语, 用于确保一组线程在继续执行之前等待某个操作完成. std::latch 一旦倒数到零, 就不能重置. 它非常适合用于等待一组线程完成初始化或其他初始化操作.

#include <iostream>
#include <latch>
#include <thread>
#include <vector>

void worker(std::latch& latch) {
  // 模拟工作
  std::this_thread::sleep_for(std::chrono::seconds(1));
  std::cout << "Worker thread done\n";
  latch.count_down();
}

int main() {
  std::latch latch(3);  // 等待3个线程

  std::vector<std::thread> threads;
  for (int i = 0; i < 3; ++i) {
    threads.emplace_back(worker, std::ref(latch));
  }

  latch.wait();  // 主线程等待所有工作线程完成
  std::cout << "All worker threads are done\n";

  for (auto& t : threads) {
    t.join();
  }

  return 0;
}

Barriers (栅栏)

std::barrier 类似于 std::latch, 但它可以被重置. std::barrier 用于一组线程在继续执行之前等待所有线程到达某个同步点. 所有线程到达后, 它们可以继续执行, 而 std::barrier 也可以被重置以便再次使用.

#include <barrier>
#include <iostream>
#include <thread>
#include <vector>

void worker(std::barrier<>& barrier, int id) {
  std::cout << "Worker " << id << " is ready\n";
  barrier.arrive_and_wait();  // 等待所有线程到达
  std::cout << "Worker " << id << " is processing\n";
  barrier.arrive_and_wait();  // 等待所有线程完成处理
  std::cout << "Worker " << id << " is done\n";
}

int main() {
  std::barrier barrier(3);  // 等待3个线程

  std::vector<std::thread> threads;
  for (int i = 0; i < 3; ++i) {
    threads.emplace_back(worker, std::ref(barrier), i);
  }

  for (auto& t : threads) {
    t.join();
  }

  return 0;
}

更多阅读: C++ Latch 和 Barrier: 新手指南

Semaphores

C++20 引入了 std::counting_semaphorestd::binary_semaphore 作为同步原语, 用于多线程编程中的资源管理. 信号量(Semaphore)是一种用于控制多个线程对共享资源访问的机制. 信号量维护一个计数器, 该计数器表示可用资源的数量. 当一个线程需要访问资源时, 它会尝试获取信号量; 如果计数器大于零, 则线程可以访问资源, 并将计数器减一; 如果计数器为零, 则线程会被阻塞, 直到有其他线程释放资源.

使用 std::counting_semaphore

#include <iostream>
#include <semaphore>
#include <thread>
#include <vector>

std::counting_semaphore<5> sem(5);  // 指定模板参数类型为 int

void worker(int id) {
  sem.acquire();  // 尝试获取信号量
  std::cout << "Worker " << id << " is working\n";
  std::this_thread::sleep_for(std::chrono::seconds(1));  // 模拟工作
  std::cout << "Worker " << id << " is done\n";
  sem.release();  // 释放信号量
}

int main() {
  std::vector<std::thread> threads;
  for (int i = 0; i < 10; ++i) {
    threads.emplace_back(worker, i);
  }

  for (auto& t : threads) {
    t.join();
  }

  return 0;
}

使用 std::binary_semaphore

#include <iostream>
#include <semaphore>
#include <thread>
#include <vector>

std::binary_semaphore sem(0);  // 初始状态为0, 表示资源不可用

void worker(int id) {
  sem.acquire();  // 尝试获取信号量
  std::cout << "Worker " << id << " is working\n";
  std::this_thread::sleep_for(std::chrono::seconds(1));  // 模拟工作
  std::cout << "Worker " << id << " is done\n";
}

void producer() {
  std::this_thread::sleep_for(std::chrono::seconds(2));  // 模拟生产时间
  std::cout << "Producer is ready\n";
  sem.release();  // 释放信号量, 表示资源可用
}

int main() {
  std::thread t1(worker, 1);
  std::thread t2(producer);

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

  return 0;
}

更多阅读: 条件变量 vs 信号量: 如何选择适合你的多线程同步工具?

Synchronized Output Streams

C++20 引入了 std::osyncstream 作为同步输出流, 用于确保在多线程环境中对输出流的写操作是线程安全的. std::osyncstream 是一个同步输出流缓冲区, 它将输出操作缓冲起来, 然后在析构时将缓冲区的内容原子地写入到目标输出流中. 这避免了多个线程同时写入输出流时可能出现的交错输出问题.

#include <iostream>
#include <syncstream>
#include <thread>
#include <vector>

void worker(std::ostream& os, int id) {
  std::osyncstream sync_os(os);
  sync_os << "Worker " << id << " is starting\n";
  std::this_thread::sleep_for(std::chrono::milliseconds(100));
  sync_os << "Worker " << id << " is done\n";
}

int main() {
  std::vector<std::thread> threads;
  for (int i = 0; i < 5; ++i) {
    threads.emplace_back(worker, std::ref(std::cout), i);
  }

  for (auto& t : threads) {
    t.join();
  }

  return 0;
}

输出示例

Worker 1 is starting
Worker 1 is done
Worker 0 is starting
Worker 0 is done
Worker 4 is starting
Worker 4 is done
Worker 2 is starting
Worker 2 is done
Worker 3 is starting
Worker 3 is done

每个线程的输出都是完整的, 不会与其他线程的输出交错, 这得益于 std::osyncstream 的线程安全特性.

进一步阅读: C++20 std::osyncstream 完全指南 - 解决多线程输出混乱问题

14. Modules

C++20 引入了模块(Modules), 这是一种新的代码组织方式, 旨在替代传统的头文件(#include)机制. 模块提供了更好的编译性能, 更清晰的代码结构以及更强的封装性.

  1. 编译性能提升: 模块避免了头文件的重复解析, 从而显著减少了编译时间.
  2. 封装性增强: 模块可以控制哪些符号对外可见, 哪些符号仅在模块内部使用.
  3. 减少宏污染: 模块不会像头文件那样引入宏定义, 避免了宏污染问题.
  4. 简化依赖管理: 模块的依赖关系更加清晰, 减少了复杂的头文件包含顺序问题.

代码示例

以下是一个简单的模块示例, 展示了如何定义和使用模块:

export module math;

export int add(int a, int b) { return a + b; }

export int multiply(int a, int b) { return a * b; }
import math;
#include <iostream>

int main() {
  std::cout << "Add: " << add(2, 3) << "\n";            // 输出: Add: 5
  std::cout << "Multiply: " << multiply(2, 3) << "\n";  // 输出: Multiply: 6
  return 0;
}
add_library(simple_module)
target_sources(simple_module
        PUBLIC
        FILE_SET CXX_MODULES FILES
        math.ixx
)
add_executable(simple_demo main.cpp)
target_link_libraries(simple_demo simple_module)

进一步阅读: CMake构建C++20 Module实例(使用MSVC)

其他改进

String Members starts_with() and ends_with()

std::string str = "Hello, World!";

// 检查字符串是否以 "Hello" 开头
assert(str.starts_with("Hello"));

// 检查字符串是否以 '!' 结尾
assert(str.ends_with('!'));

受限的 string 成员函数 reserve()

对于 string 来说, 成员函数 reserve不能再用于请求缩小字符串的容量(内存分配给字符串的值).

新的工具类函数ssize()

// int与size_t的比较会导致编译器警告
for (int i = 0; i < std::ssize(str); ++i) {
}

// 使用ssize, 得到有符号的类型
for (int i = 0; i < std::ssize(str); ++i) {
}

std::source_location

代码位置, 帮助追踪代码位置.

#include <fmt/core.h>

#include <source_location>

int main() {
  auto sl = std::source_location::current();
  fmt::println("file: {}", sl.file_name());
  fmt::println("function: {}", sl.function_name());
  fmt::println("line/col: {}/{}", sl.line(), sl.column());

  return 0;
}

Tags: