C++20 特性杂谈
C++ 的发展方向最重要的就是 C++ 标准委员会的统一决策,这种从不同行业、公司、国家和组织中集合的一群人,能达成共识并推出一些有价值的成果,已经非常难得。然而,C++ 仍然因此和其他新兴语言之间产生了差距。
C++ 标准的一种最重要的宗旨就是确保 C++ 在更新的过程中,看起来还是同一个语言,所以它需要满足一种统一的演化标准。这些标准概括起来有:
- 基于过去已有的成功模式作为演化方向,比如 RAII、类型安全检查
- 简单的事情用简单的方法做
- 从代码到硬件的零开销原则和直接映射
- 基于意见反馈来发展
- 保持稳定性和兼容性
- 维持与硬件的直接映射,简单的抽象机制,最小化的运行时系统
然而,过去很长一段时间,C++ 标准委员会都只专注于编程语言特性和标准库的方向,而没有在语言工具链方面做额外的事情,比如构建系统、包管理、链接原则等方面,这导致了 C++ 语言没有标准统一的开发和运行环境,每个操作系统、平台,甚至编程框架都可以规定自己的开发模式,各自为政。相关的软件也都依赖第三方企业或开源社区维护,用法和实现五花八门,没有统一标准的指导方针。
说回 C++20,这一次标准版本是可以和 C++11 不相上下的发布,其中包括了好几个大型特性,比如模块、概念、协程。C++20 对 C++ 的影响可以说又是改头换面的。
本文是 C++ 特性杂谈系列文章的第四篇:
# 模块
模块用来替代之前 C++ 中的头文件机制。
头文件引入的机制存在一些明显的缺点,已经饱受诟病:
- 头文件的引入只是通过宏对代码的直接展开,不同头文件中的代码相互之间可见,可能发生名字冲突问题,不同头文件的来源不同,很难做到统一和安全。
- 已经有太多的代码被放入头文件中。过去 C++ 继承 C 的头文件机制时,头文件中只会放一些 export 的函数声明,但现在,一个工程中大于 90% 的代码都在头文件中。
- 常见的头文件可能在其他文件中被 include 很多次,虽然有避免重复编译的头文件宏,但依然无法阻止预处理器一遍又一遍的展开这些重复的 include 文件代码。这导致编译效率降低。
模块机制的优势是:
- 同一个模块,编译器只需要处理一次,这会一定程度上减少编译时间
- 避免了不同头文件中可能存在的名字冲突
- 模块中区分导出的符号和内部符号,只保留必要的接口,保证了实现上的封装性
- 编译过程中的代码整洁很多,只会引入被调用的符号,没有调用但也被暴露出来的符号不会被编译
之后,标准委员会最大的困难便是如何兼容过去的 include 机制与新引入的模块机制,最后应该是基本解决了。模块的引入方式可以做到比头文件的引入方式在编译上快一个数量级。
模块的引入,不可避免地会彻底改变 C++ 代码的导入导出方式,对过去几十年人们已经在头文件系统上做过的大量工作来说,是一个明显的冲击。但理念和技术都在发展进步,有些东西确实只能被时代抛弃。
虽然模块是 C++20 中的一个正式特性,但因为特性比较复杂,存在对旧代码的影响,所以目前(2024年9月)编译器(比如 gcc)还是需要用额外的参数来指定启用该功能,编译器实现也有可能存在 bug 和不完善。未来编译器实现稳定之后,编译参数会被取消。
1 | g++ -std=c++20 -fmodules-ts -o main main.cpp math_module.cppm |
后缀 .cppm 是标准规定的后缀形式,但编译器并不强依赖后缀来区分模块文件。
# 协程
协程是一种比线程更高效的并发机制。看似是一种很新的概念,但实际上,协程在 C++最初版本中就存在,当时利用协程来实现一个任务库,进而支持一些常见算法的仿真。然而,由于实现比较丑陋,移植性差,在后来的 C++98 版本中,没有继续支持,直到 C++20 中才重新实现。
如果你还不了解协程,我简单介绍一下。协程就是用户态的线程,多个协程运行在同一个线程上,通过用户程序控制下做分时复用,以伪并发的状态执行。协程可以暂停和恢复运行。比如,我可以让一个协程函数暂停,然后去执行其他内容,回过头来再继续执行协程函数。操作系统不感知协程,它是一种语言级的行为。C++20 的协程是无栈协程,所以效率很高。
协程之间切换不需要内核参与,所以效率更高,但协程需要配合异步 IO 操作,在计算密集型任务中,协程没有用武之地。
C++20 的协程使用 co_await, co_yield, co_return 关键字,又是一个考虑兼容性的命名。它使用普通函数,而不是 lambda 来实现协程函数。如果一个普通函数中存在这 3 个关键字之一,它就会被编译器处理为一个协程。
这个协程提案被批评的主要原因是不利于学习、没有和现有语言特性融合、过于灵活。解决方案便是提供一套标准库来包装接口,再提供给普通用户使用。然而,由于时间原因,协程的标准库并没有被添加到 C++20 中。
# 增强的 constexpr
之前我们在 C++14 的总结中,提到过 C++14 中的 constexpr 是对 C++11 的一个增强,当时的改进相对来说比较温和,应该理解为一种完善。在 C++20 中,constexpr 才被进一步的增强,这次更新,constexpr 中加入了一些新的修饰符、一些标准类库支持、constexpr 函数中允许使用 new/delete、虚函数、RTTI 等特性。
C++20 进一步增强 constexpr 的目的,是继续将 C++ 的编译期计算推举到更重要的位置,从而让更多的运行时计算变成静态计算,进一步改进运行时性能。
但是,在工程中,将运行时函数修改为编译期函数的努力可能很艰难,我们应该学着评判什么是值得花精力去改善的。
# 运算符 <=>
这个运算符应该怎么称呼,目前中文 C++ 社区还没有完全达成一致,英文是 Three-way comparison operator,也许应该叫三向比较运算符。它返回一个特殊的类型 std::strong_ordering ,有三个取值,分别是 less equal greater ,用于确定两个值的大小关系。
它的引入最大的意义是,允许编译器能有更多的优化空间。
另一个话题是重载 <=> 运算符,和其他运算符一样,也可以重载它,或者让编译器生成 default 版的重载函数。如果重载这个运算符,那么其他比较运算符也会自动由编译器生成。一个例外是字符串的 == 运算符不会自动生成,理由是性能很差,我很难给出积极的评价,这让整个语法变的不统一,不过,C++ 里这种操作也挺多的,为了性能会妥协语言的复杂性和优雅性。
# 范围 ranges 和视图 view
千呼万唤始出来的特性,使用 ranges 终于可以在很多场合里摆脱 func(vec.begin(), vec.end()) 这种写法了,换之 func(vec),这才是现代编程语言应该有的特性。
奈何我们在 2020 年之后的 C++ 中才用到了这个特性。
C++20 的 STL 中所有容器都已经支持了 ranges。
另外,STL 中还加入了很多与 ranges 相关的算法,比如 filter、take 等,这些算法定义在 std::ranges::views 中。与此同时,引入了管道 pipe 运算,使用这种编程风格让 C++ 更像是函数式编程:
1 | using namespace std::ranges; |
这段代码中,views.iota(1) 会生成从 1 开始,步进是 1 的 ranges 序列(注意不是 itoa),通过管道运算符传递,之后会经过 2 个我们自定义的 predicate 函数,最后使用 views.take 来获取前 5 个元素,并返回给 for 循环遍历。
C++ 被人吐槽很多的一个话题是,它提供的 std::string 中,没有 split 操作(实现很简单,但一直没有通过委员会,因为性能和类型统一等问题),这让过去很多项目都需要自己写 split 函数。C++20 中终于有了 split view,便可以这样写:
1 | string s("hello world"); |
返回的类型是 split_view,可以通过 ranges::to<> 进一步转换为自己想要的容器类型。
# span
在目前的 C++ 中,通过下标访问一个数组的元素,是没办法通过编译器检查是否发生访问越界的,而这种错误经常会出现在工程代码中,动态的越界检查又很难因性能开销而被接受。一种简单的实现,就是把数组的长度绑定到数组指针中,统一引用这个复合的指针类型。曾经,有提案给到 C 标准委员会,但没有得到通过。
C++20 中,引入了 span 这个特性,从而让编译器的数组静态越界检查成为可能。因为数组长度是静态的,并且跟着 span 类型的传递到达任何需要遍历数组的地方,所以编译器便有机会在这些遍历和下标访问的地方检查越界操作。
同时,span 也扩展了数组类型的遍历,可以让它和其他复合容器一样操作:
1 | void func(span<int> arr) { |
在编译时,便可以检查可能的越界操作:
1 | int arr[100]; |
# 格式化字符串
又是一个应该属于现代化的编程语言中一个常用的特性。
因为 pre-C++20 中没有原生的类型安全的格式化字符串方法,所以过去涌现出了好多第三方的库。我使用过 LLVM 实现的 llvm::formatv 和 fmt,用起来还算可以。C++20 之后,终于可以使用原生的实现了,它的参照原型是 fmt 库,所以基本可以无缝切过去。
除了类型安全这个特点外,format 和 C 的 printf 之间另一个区别是,format 可以支持用户自定义的类型输出格式,对于一些复杂且在特定领域很常用的格式化模板,就非常的方便。
# 并发
C++20 中引入了一些非常有意义的并发编程的特性。比如,使用 RAII 的 thread 对象,被命名为 jthread,当 thread 对象超出作用域时,触发 join 行为,直到 thread 终止后,才完成 join 并退出主线程的当前作用域。
另外一个之前便已经提到过的,通过主线程终止子线程执行,通过在主线程中设置一个 flag,然后要求子线程去周期性地检查这个 flag,如果主线程需要终止子线程,则置位 flag。
# 新增的一些属性
像我之前提到的那样,在必要的时候,去尝试使用属性,可以让代码更优雅,也有助于编译器做一些分析。C++20 中增加了 [[likely]] 和 [[unlinkely]] 属性,用于指导编译器在处理分支逻辑时,更好地做优化。
本文同步发布在知乎账号下:C++20 特性杂谈 - 知乎 (zhihu.com)
封面图片来自:Photo by Geio Tischler on Unsplash














