23是个小版本,主要在于「完善」二字,而非「新增」。因此,值得单独拿出来写篇文章的特性其实并不多,大多特性都是些琐碎小点,三言两语便可讲清。
本篇包含绝大多数C++23特性,难度三星就表示只会介绍基本用法,但有些特性的原理也会深入讲讲。
1
Deducing this(P0847)
Deducing this是C++23中最主要的特性之一。msvc在去年3月份就已支持该特性,可以在v19.32之后的版本使用。
为什么我们需要这个特性?
大家知道,成员函数都有一个隐式对象参数,对于非静态成员函数,这个隐式对象参数就是this指针;而对于静态成员函数,这个隐式对象参数被定义为可以匹配任何参数,这仅仅是为了保证重载决议可以正常运行。
Deducing this所做的事就是提供一种将非静态成员函数的「隐式对象参数」变为「显式对象参数」的方式。为何只针对非静态成员函数呢?因为静态成员函数并没有this指针,隐式对象参数并不能和this指针划等号,静态函数拥有隐式对象参数只是保证重载决议能够正常运行而已,这个参数没有其他用处。
于是,现在便有两种写法编写非静态成员函数。
通过Deducing this,可以将隐式对象参数显式地写出来,语法为this+type。
该提案最根本的动机是消除成员函数修饰所带来的冗余,举个例子:
原本你也许得为同一个成员函数编写各种版本的修饰,比如&, const&, &&, const &&,其逻辑并无太大变化,完全是重复的机械式操作。如今借助Deducing this,你只需编写一个版本即可。
这里使用了模板形式的参数,通常来说,建议是使用Self作为显式对象参数的名称,顾名思义的同时又能和其他语言保持一致性。
该特性还有许多使用场景,同时也是一种新的定制点表示方式。
比如,借助Deducing this,可以实现递归Lambdas。
这使得Lambda函数再次得到增强。
又比如,借助Deducing this,可以简化CRTP。
这种新的方式实现CRTP,可以省去CR,甚至是T,要更加自然,更加清晰。
这也是一种新的定制点方式,稍微举个简单点的例子:
这种方式依旧属于静态多态的方式,但代码更加清晰、无侵入,并支持显式opt-in,是一种值得使用的方式。
定制点并非一个简单的概念,若是看不懂以上例子,跳过便是。
下面再来看其他的使用场景。
Deducing this还可以用来解决根据closure类型完美转发Lambda捕获参数的问题。
亦即,如果Lambda函数的类型为左值,那么捕获的参数就以左值转发;如果为右值,那么就以右值转发。下面是一个例子:
若是没有Deducing this,那么将无法简单地完成这个操作。
另一个用处是可以将this以值形式传递,对于小对象来说,可以提高性能。
一个例子:
对于隐式的this指针,生成的汇编代码需要先分配栈空间,保存this指针到rcx寄存器中,再将42赋值到data_中,然后调用foo(),最后平栈。
而以值形式传递this,则无需那些操作,因为值传递的this不会影响s变量,中间的步骤都可以被优化掉,也不再需要分配和平栈操作,所以可以直接将42保存到寄存器当中,再jmp到foo()处执行。
Deducing this是个单独就可写篇四五星难度文章的特性,用处很多,值得深入探索的地方也很多,所以即便是概述这部分也写得比较多。
2
Monadic std::optional(P0798R8)
P0798提议为std::optional增加三个新的成员:map(), and_then()和or_else()。
功能分别为:
map:对optional的值应用一个函数,返回optional中wrapped的结果。若是optional中没有值,返回一个空的optional;
and_then:组合使用返回optional的函数;
or_else:若是有值,返回optional;若是无值,则调用传入的函数,在此可以处理错误。
在R2中map()被重命名为transform(),因此实际新增的三个函数为transform(),and_then()和or_else()。
这些函数主要是避免手动检查optional值是否有效,比如:
一个使用的小例子:
错误的情况:
目前GCC 12,Clang 14,MSVC v19.32已经支持该特性。
3
std::expected(P0323)
该特性用于解决错误处理的问题,增加了一个新的头文件。
错误处理的逻辑关系为条件关系,若正确,则执行A逻辑;若失败,则执行B逻辑,并需要知道确切的错误信息,才能对症下药。
当前的常用方式是通过错误码或异常,但使用起来还是多有不便。
std::expected表示期望,算是std::variant和std::optional的结合,它要么保留T(期望的类型),要么保留E(错误的类型),它的接口又和std::optional相似。
一个简单的例子:
这种方式无疑会简化错误处理的操作。
该特性目前在GCC 12,Clang 16(还未发布),MSVC v19.33已经实现。
4
Multidimensional Arrays(P2128)
这个特性用于访问多维数组,之前C++ operator[]只支持访问单个下标,无法访问多维数组。
因此要访问多维数组,以前的方式是:
重载operator(),于是能够以m(1, 2)来访问第1行第2个元素。但这种方式容易和函数调用产生混淆;
重载operator[],并以std::initializer_list作为参数,然后便能以m[]来访问元素。但这种方式看着别扭。
链式链接operator[],然后就能够以m[1][2]来访问元素。同样,看着别扭至极。
定义一个at()成员,然后通过at(1, 2)访问元素。同样不方便。
感谢该提案,在C++23,我们终于可以通过m[1, 2]这种方式来访问多维数组。
一个例子:
该特性目前在GCC 12和Clang 15以上版本已经支持。
5
if consteval(P1938)
该特性是关于immediate function的,即consteval function。
解决的问题其实很简单,在C++20,consteval function可以调用constexpr function,而反过来却不行。
以上代码无法编译通过,因为constexpr functiong不是强保证执行于编译期,在其中自然无法调用consteval function。
但是,即便加上if std::is_constant_evaluated()也无法编译成功。
这就存在问题了,P1938通过if consteval修复了这个问题。在C++23,可以这样写:
该特性目前在GCC 12和Clang 14以上版本已经实现。
6
Formatted Output(P2093)
该提案就是std::print(),之前已经说过,这里再简单地说下。
标准cout的设计非常糟糕,具体表现在:
可用性差,基本没有格式化能力;
会多次调用格式化I/0函数;
默认会同步标准C,性能低;
内容由参数交替组成,在多线程环境,内容会错乱显示;
二进制占用空间大;
……
随着Formatting Library加入C++20,已在fmt库中使用多年的fmt::print()加入标准也是顺理成章。
格式化输出的目标是要满足:可用性、Unicode编码支持、良好的性能,与较小的二进制占用空间。为了不影响现有代码,该特性专门加了一个新的头文件,包含两个主要函数:
这对cout来说绝对是暴击,std::print的易用性和性能简直完爆它。
其语法就是Formatting Library的格式化语法。
性能对比:
结果显示,printf与print几乎要比cout快三倍,print默认会打印到stdout。当打印到cout并同步标准C的流时(print_cout_sync),print大概要快14%;当不同步标准C的流时(print_cout),依旧要快不少。
遗憾的是,该特性目前没有编译器支持。
7
Formatting Ranges(P2286)
同样属于Formatting大家族,该提案使得我们能够格式化输出Ranges。
也就是说,我们能够写出这样的代码:
这意味着再也不用迭代来输出Ranges了。
这是非常有必要的,考虑一个简单的需求:文本分割。
Python的实现:
Java的实现:
Rust的实现:
JS的实现:
Go的实现:
Kotlin的实现:
C++的实现:
借助fmt,可以简化代码:
因为views::split()返回的是一个subrange,因此需要将其转变成string_view,否则,输出将为:
总之,这个特性将极大简化Ranges的输出,是值得兴奋的特性之一。
该特性目前没有编译器支持。
7
import std(P2465)
C++20模块很难用的一个原因就是标准模块没有提供,因此这个特性的加入是自然趋势。
现在,可以写出这样的代码:
性能对比:
如何你是混合C和C++,那可以使用std.compat module,所有的C函数和标准库函数都会包含进来。
目前基本没有编译器支持此特性。
8
out_ptr(P1132r8)
23新增了两个对于指针的抽象类型,std::out_ptr_t和std::inout_ptr_t,两个新的函数std::out_ptr()和std::inout_ptr()分别返回这两个类型。
主要是在和C API交互时使用的,一个例子对比一下:
该特性目前在MSVC v19.30支持。
9
auto(x) decay copy(P0849)
该提案为auto又增加了两个新语法:auto(x)和auto。两个作用一样,只是写法不同,都是为x创建一份拷贝。
为什么需要这么个东西?
看一个例子:
foo()中调用bar(),希望传递一份param的拷贝,则我们需要单独多声明一个临时变量。或是这样:
这种方式需要手动去除多余的修饰,只留下T,要更加麻烦。
auto(x)就是内建的decay copy,现在可以直接这样写:
大家可能还没意识到其必要性,来看提案当中更加复杂一点的例子。
请注意该程序的输出,是否如你所想的一样。若没有发现问题,请让我再提醒一下:pop_front_alike()要移除容器中所有跟第1个元素相同的元素。
因此,理想的结果应该为:
是哪里出了问题呢?让我们来看看gcc std::erase()的实现:
std::remove()最终调用的是remove_if(),因此关键就在这个算法里面。这个算法每次会比较当前元素和欲移除元素,若不相等,则用当前元素覆盖当前__result迭代器的值,然后__result向后移一位。重复这个操作,最后全部有效元素就都跑到__result迭代器的前面去了。
问题出在哪里呢?欲移除元素始终指向首个元素,而它会随着元素覆盖操作被改变,因为它的类型为const T&。
此时,必须重新copy一份值,才能得到正确的结果。
故将代码小作更改,就能得到正确的结果。
然而这种方式是非常反直觉的,一般来说这两种写法的效果应该是等价的。
我们将copy定义为一个单独的函数,表达效果则要好一点。
而auto和auto(x),就相当于这个copy()函数,只不过它是内建到语言里面的而已。
10
Narrowing contextual conversions to bool
这个提案允许在static_assert和if constexpr中从整形转换为布尔类型。
以下表格就可以表示所有内容。
对于严格的C++编译器来说,以前在这种情境下int无法向下转换为bool,需要手动强制转换,C++23这一情况得到了改善。
目前在GCC 9和Clang 13以上版本支持该特性。
11
forward_like(P2445)
这个在Deducing this那节已经使用过了,是同一个作者。
使用情境让我们回顾一下这个例子:
std::forward_like加入到了中,就是根据模板参数的值类别来转发参数。
如果closure type为左值,那么m将转发为左值;如果为右值,将转发为右值。
听说Clang 16和MSVC v19.34支持该特性,但都尚未发布。
12
#eifdef and #eifndef(P2334)
这两个预处理指令来自WG14(C的工作组),加入到了C23。C++为了兼容C,也将它们加入到了C++23。
也是一个完善工作。
#ifdef和#ifndef分别是#if defined()和#if !defined()的简写,而#elif defined()和#elif !defined()却并没有与之对应的简写指令。因此,C23使用#eifdef和#eifndef来补充这一遗漏。
总之,是两个非常简单的小特性。目前已在GCC 12和Clang 13得到支持。
13
#warning(P2437)
#warning是主流编译器都会支持的一个特性,最终倒逼C23和C++23也加入了进来。
这个小特性可以用来产生警告信息,与#error不同,它并不会停止翻译。
用法很简单:
目前MSVC不支持该特性,其他主流编译器都支持。
14
constexpr std::unique_ptr(P2273R3)
std::unique_ptr也支持编译期计算了,一个小例子:
目前GCC 12和MSVC v19.33支持该特性。
15
improving string and string_view(P1679R3, P2166R1, P1989R2, P1072R10, P2251R1)
string和string_view也获得了一些增强,这里简单地说下。
P1679为二者增加了一个contain()函数,小例子:
目前GCC 11,Clang 12,MSVC v19.30支持该特性。
P2166使得它们从nullptr构建不再产生UB,而是直接编译失败。
目前GCC 12,Clang 13,MSVC v19.30支持该特性。
P1989是针对std::string_view的,一个小例子搞定:
以前无法直接从Ranges构建std::string_view,而现在支持这种方式。
该特性在GCC 11,Clang 14,MSVC v19.30已经支持。
P1072为string新增了一个成员函数:
可以通过提案中的一个示例来理解:
主要是两个操作:改变大小和覆盖内容。第1个参数是新的大小,第2个参数是一个op,用于设置新的内容。
然后的逻辑是:
如果maxsize
如果maxsize > s.size(),追加maxsize-size()个默认元素;
调用erase(begin() + op(data(), maxsize), end())。
这里再给出一个例子,可以使用上面的逻辑来走一遍,以更清晰地理解该函数。
注意一下,maxsize是最大的可能大小,而op返回才是实际大小,因此逻辑的最后才有一个erase()操作,用于删除多余的大小。
这个特性在GCC 12,Clang 14,MSVC v19.31已经实现。
接着来看P2251,它更新了std::span和std::string_view的约束,从C++23开始,它们必须满足TriviallyCopyable Concept。
主流编译器都支持该特性。
最后来看P0448,其引入了一个新的头文件。
大家都知道,stringstream现在被广泛使用,可以将数据存储到string或vector当中,但这些容器当数据增长时会发生「挪窝」的行为,若是不想产生这个开销呢?
提供了一种选择,你可以指定固定大小的buffer,它不会重新分配内存,但要小心数据超出buffer大小,此时内存的所有权在程序员这边。
一个小例子:
目前GCC 12和MSVC v19.31已支持该特性。
16
static operator()(P1169R4)
因为函数对象,Lambdas使用得越来越多,经常作为标准库的定制点使用。这种函数对象只有一个operator (),如果允许声明为static,则可以提高性能。
至于原理,大家可以回顾一下Deducing this那节的Pass this by value提高性能的原理。明白静态函数和非静态函数在重载决议中的区别,大概就能明白这点。
顺便一提,由于mutidimensional operator[]如今已经可以达到和operator()一样的效果,它也可以作为一种新的函数语法,你完全可以这样调用foo[],只是不太直观。因此,P2589也提议了static operator[]。
17
std::unreachable(P0627R6)
当我们知道某个位置是不可能执行到,而编译器不知道时,使用std::unreachalbe可以告诉编译器,从而避免没必要的运行期检查。
一个简单的例子:
该特性位于,在GCC 12,Clang 15和MSVC v19.32已经支持。
18
std::to_underlying(P1682R3)
同样位于,用于枚举到其潜在的类型,相当于以下代码的语法糖:
一个简单的例子就能看懂:
的确很简单吧!
该特性目前在GCC 11,Clang 13,MSVC v19.30已经实现。
19
std::byteswap(P1272R4)
位于,顾名思义,是关于位操作的。
同样,一个例子看懂:
可以看到,其作用是逆转整型的字节序。当需要在两个不同的系统传输数据,它们使用不同的字节序时(大端小端),这个工具就会很有用。
该特性目前在GCC 12,Clang 14和MSVC v19.31已经支持。
20
std::stacktrace(P0881R7, P2301R1)
位于,可以让我们捕获调用栈的信息,从而知道哪个函数调用了当前函数,哪个调用引发了异常,以更好地定位错误。
一个小例子:
输出如下。
注意,目前GCC 12.1和MSVC v19.34支持该特性,GCC 编译时要加上-lstdc++_libbacktrace参数。
std::stacktrace是std::basic_stacktrace使用默认分配器时的别名,定义为:
而P2301,则是为其添加了PMR版本的别名,定义为:
于是使用起来就会方便一些。
这个特性到时再单独写篇文章,在此不细论。
21
Attributes(P1774R8, P2173R1, P2156R1)
Attributes在C++23也有一些改变。
首先,P1774新增了一个Attribute [[assume]],其实在很多编译器早已存在相应的特性,例如__assume()(MSVC, ICC),__builtin_assume()(Clang)。GCC没有相关特性,所以它也是最早实现标准[[assume]]的,目前就GCC 13支持该特性(等四月发布,该版本对Rangs的支持也很完善)。
现在可以通过宏来玩:
论文当中的一个例子:
第一个是假设size永不为0,总是正数;第二个告诉编译器size总是32的倍数;第三个表明数据不是NaN或无限小数。
这些假设不会被评估,也不会被检查,编译器假设其为真,依此优化代码。若是假设为假,可能会产生UB。
使用该特性与否编译产生的指令数对比结果如下图。
其次,P2173使得可以在Lambda表达式上使用Attributes,一个例子:
注意,Attributes属于closure type,而不属于operator ()。
因此,有些Attributes不能使用,比如[[noreturn]],它表明函数的控制流不会返回到调用方,而对于Lambda函数是会返回的。
除此之外,此处我还展示了C++的另一个Lambda特性。
在C++23之前,最简单的Lambda表达式为[](){},而到了C++23,则是[]{},可以省略无参时的括号,这得感谢P1102。
早在GCC 9就支持Attributes Lambda,Clang 13如今也支持。
最后来看P2156,它移除了重复Attributes的限制。
简单来说,两种重复Attributes的语法评判不一致。例子:
为了保证一致性,去除此限制,使得标准更简单。
什么时候会出现重复Attributes,看论文怎么说:
During this discussion, it was brought up that
the duplication across attribute-specifiers are to support cases where macros are used to conditionally add attributes to an
attribute-specifier-seq, however it is rare for macros to be used to generate attributes within the same attribute-list. Thus,
removing the limitation for that reason is unnecessary.
在基于宏生成的时候可能会出现重复Attributes,因此允许第二种方式;宏生成很少使用第一种形式,因此标准限制了这种情况。但这却并没有让标准变得更简单。因此,最终移除了该限制。
目前使用GCC 11,Clang 13以上两种形式的结果将保持一致。
22
Lambdas(P1102R2, P2036R3, P2173R1)
Lambdas表达式在C++23也再次迎来了一些新特性。
像是支持Attributes,可以省略(),这在Attributes这一节已经介绍过,不再赘述。
另一个新特性是P2036提的,接下来主要说说这个。
这个特性改变了trailing return types的Name Lookup规则,为什么?让我们来看一个例子。
counter最终的类型是什么?是int吗?还是double?其实是double。
无论捕获列表当中存在什么值,trailing return type的Name Lookup都不会查找到它。
这意味着单独这样写将会编译出错:
因为对于trailing return type来说,根本就看不见捕获列表中的j。
以下例子能够更清晰地展示这个错误:
在C++23,trailing return types的Name Lookup规则变为:在外部查找之前,先查找捕获列表,从而解决这个问题。
目前没有任何编译器支持该特性。
23
Literal suffixes for (signed) size_t(P0330R8)
这个特性为std::size_t增加了后缀uz,为signed std::size_t加了后缀z。
有什么用呢?看个例子:
这代码在32 bit平台编译能够通过,而放到64 bit平台编译,则会出现错误:
在32 bit平台上,i被推导为unsigned int,v.size()返回的类型为size_t。而size_t在32 bit上为unsigned int,而在64 bit上为unsigned long long。(in MSVC)
因此,同样的代码,从32 bit切换到64 bit时就会出现错误。
而通过新增的后缀,则可以保证这个代码在任何平台上都能有相同的结果。
如此一来就解决了这个问题。
目前GCC 11和Clang 13支持该特性。
24
std::mdspan(P0009r18)
std::mdspan是std::span的多维版本,因此它是一个多维Views。
看一个例子,简单了解其用法。
目前没有编译器支持该特性,使用的是https://raw.githubusercontent.com/kokkos/mdspan/single-header/mdspan.hpp实现的版本,所以在experimental下面。
ms2是将数据以二维形式访问,ms3则以三维访问,Views可以改变原有数据,因此最终遍历的结果为:
这个特性值得剖析下其设计,这里不再深究,后面单独出一篇文章。
25
flat_map, flat_set(P0429R9, P1222R4)
C++23多了flat version的map和set:
flat_map
flat_set
flat_multimap
flat_multiset
过去的容器,有的使用二叉树,有的使用哈希表,而flat版本的使用的连续序列的容器,更像是容器的适配器。
无非就是时间或空间复杂度的均衡,目前没有具体测试,也没有编译器支持,暂不深究。
26
总结
本篇已经够长了,C++23比较有用的特性基本都包含进来了。
其中的另一个重要更新Ranges并没有包含。读至此,大家应该已经感觉到C++23在于完善,而不在于增加。没有什么全新的东西,也没什么太大的特性,那些就得等到C++26了。
大家喜欢哪些C++23特性?
- EOF -
领取专属 10元无门槛券
私享最新 技术干货