C++

C++20部分语法介绍

Posted by wyj on May 2, 2020 / Edited on September 24, 2020

这篇文章姗姗来迟了。按照之前的惯例,我升级Ubuntu系统至20.04之后尝试了一下 C++20 的新语法。在g++-10clang++-10中已经有了-std=c++20的编译选项,支持的 C++20 语法特性已经比一年前多了很多。现在我来由浅入深地总结一下自己觉得有用的内容。

编译环境:g++ 10.0.1;clang 10.0.0;Ubuntu 20.04 LTS。

参考资料:cppreference.com,与这篇知乎文章

<bit> 头文件

有很多人已经知道了__builtin_popcount(计算二进制中 1 的个数)、__builtin_clz(一般使用$32$减去,用来计算二进制表示的位数)这些函数。但是这些名字实在是太长了,甚至比手动实现还长:

r=__builtin_popcount(x);
for(r=0;x;x&=x-1)r++;

lg=32-__builtin_clz(x);
lg=__lg(x)+1;

在头文件<bit>里提供了popcountbit_width这两个函数,缩短了代码长度,也不用担心编译器是否兼容的问题了。此头文件已经被bits/stdc++.h默认包含,无需单独#include。唯一要注意的是:参数必须是无符号类型

还有一段大家常常用到的语句,FFT 和 zkw 线段树里肯定有,其他代码里也经常出现:

int N=1,lg=0;
for(;N<n;N*=2)lg++;

使用<bit>中的“二进制上取整函数”以及__lg,可以表示成:int N=bit_ceil(n),lg=__lg(N);既缩短了代码也提升了速度

clz 系列函数也是存在的:countl_zerocountr_zero。还有它们统计 1 个数的变种:countl_onecountr_one。别的函数用处不大,这里省略了。

numbers

不需要再手动写const double pi=acos(-1),e=exp(1);这样的语句了。现在可以这样:

using namespace std::numbers;
printf("%.10lf %.10lf\n",pi,e);

不仅仅是 $\pi$ 和 $e$,$\texttt{phi}=\phi=1.618\dots$ 和 $\texttt{egamma}=\gamma=0.577215\dots$ 以及其他一些常数也被包含了。

三路比较运算符 <=>

根据andyli的建议加上了这一段。这也是一个小小的语法糖。

在 C++ 里偶尔要手写一些“数”,比如说有理数、高精度整数/小数、有时候还有超实数。如果是自己用用的,一般定义一个<运算符就足够了。然而如果想要像内置类型那样足够顺手,必须挨个把<<=>>===!=定义一遍,就变得相当繁琐。C++20 提供的<=>运算符极大地简化了这个过程。

<=>运算符接收操作数 $x$ 和 $y$,如果 $x\gt y$ 返回 $\gt 0$ 的值;否则如果 $x=y$ 返回 $=0$ 的值;否则返回 $\lt 0$ 的值。相当于是返回 $x-y$。这个行为近似于strcmpqsortbsearch的回调函数。

重载了<=>运算符之后,编译器会自动生成<<=>>=运算符。同样的,重载==运算符之后,会自动生成!=。只需要写两个函数就能替代原来六个函数的功能。

很多时候我会觉得std::pair很烦,不仅定义很烦(pair<int,int>),使用时也要写firstsecond,一般我会写几个宏替换掉这些冗长的单词。但是现在不需要了,可以声明自己的 struct 代替,也拥有自动生成的按字典序比较。具体是这么写的:

struct pa{
	int x,y;
	auto operator<=>(const pa&)const=default;
};

声明的pa类型就会拥有全部六种二路比较运算符,并且与std::pair<int,int>一样是按照字典序比较的。如果想要自定义的比较方法(比如先比较 $y$ 而不是 $x$)的话,可以这么写:

struct pa{int x,y;};
bool operator==(pa a,pa b){return a.x==b.x && a.y==b.y;}
int operator<=>(pa a,pa b){
	return a.y==b.y ? a.x-b.x : a.y-b.y;
}

for 和 if 和 switch 的初始化

语法演示:

for(auto v=getList(); auto x:v)...
if(auto x=getX(); isXXX(x))...
switch(auto v=getOpt(); v%2)...

常见使用场景:取列表中的最小值及下标。这里的“列表”是抽象的概念,所以不能使用min_element。为了避免元素的重复计算使常数 $\times 2$,经常要使用临时变量保存:

// 原有写法
for(int i=0,t;i<n;i++) if((t=getValue_slow_and_long(i))<mn)
	mn=t,mp=i;

// 现写法
for(int i=0;i<n;i++) if(int t=getValue_slow_and_long(i); t<mn)
	mn=t,mp=i;

提升了代码可读性,并且减少了犯低级错误(如赋值语句两侧忘加括号)的可能。

auto 占位的函数形参以及concepts

C++11 引入了auto新的语义,随后的每个版本都大大扩展了auto的适用范围。现在,不仅模板的参数,连函数的参数都可以使用auto了!虽然本质上只是函数模板的语法糖,但是会让代码清晰好懂很多,有点像动态类型语言的风格。

#include<bits/stdc++.h>
using namespace std;
using ll=long long;
void read(auto &x){ // 相当于 template<class T> void read(T &x)
	x=0;char c;
	do c=getchar();while(!isdigit(c));
	do x=x*10+c-48,c=getchar();while(isdigit(c));
}
int main(){
	int x;read(x);
	ll y;read(y);
	//string z;read(z); // CE
	cout<<x+y;
	return 0;
}

这个read函数当然是不能读入std::string的,如果你尝试取消注释string z;read(z);这行语句,和任何一个版本的 C++ 一样,都会得到海量的、人类难以理解的编译错误信息。为了解决这个问题,C++20 向先进的 Rust 学习引入了概念(concept)

利用integral这个概念判断模板参数是否为整数,把函数声明改成void read(integral auto &x),就可以使试图read一个string时的错误信息变得简洁明了,从而易于改正:

1.cpp:12:11: error: no matching function for call to ‘read’
1.cpp:4:11: note: because ‘std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >’ does not satisfy ‘integral’

如果使用传统的函数模板写法,也可以写成template<integral T>void read(T &x)。相比之下,如果想使用 C++11 的语法完成同样的事,需要这么写:

template<class T,class=typename enable_if<is_integral<T>::value>::type>void read(T &x)

但由于 C++20 的概念并不是像 Rust 的trait一样强制性的措施,只是个可有可无的提醒,所以个人觉得意义不大。这里就不详细展开了。(其实我也不太懂

模块

是否曾经想象过,C++ 也可以拥有与 python 一样的模块系统?现在就有了具体实现。目前只有 clang++ 支持,并且需要 libc++ 而不是(所有 GNU/Linux 共有的)libstdc++。所以暂时不能幻想import <iostream>;这样的语句了,只能编译自己写的 module。并且目前还处在试验阶段,支持并不完整

我首先尝试在 Ubuntu 上安装 libc++,按照 Google 上面搜到的方案都要从源码编译,这可太烦了。然后试着从 apt 源里面找一个,也不能正常使用。然后忽然想到 Android 手机上默认用的是 libc++,于是又试着在手机上编译了一下import <iostream>;,还是编译错误(全部 23 个错误貌似都是由于和 C 库函数的小冲突)。所以我放弃了这个尝试,被迫继续用#include<bits/stdc++.h>

下面是一个简单的演示:main.cpp使用了模块abc.cpp中的 $F$ 函数。这两个文件和 makefile 放在同一个目录下。

// abc.cpp
export module abc; // 声明导出模块名为 abc。强烈建议此名称与文件名相同(类似于 Free Pascal 的 unit)。

export int F(){ // export 关键字令此函数在外部可见。
	return 1;
}
// main.cpp
#include<bits/stdc++.h>
import abc; // 导入模块 abc。

int main(){
	printf("%d\n",F());
	return 0;
}

编译用的 makefile,编译器参数是根据这个 Youtube 视频写的:

# 使用的编译器,目前不能是 g++
CC = clang++-10
CXX = $(CC) -std=c++20 -fmodules-ts

all: main

abc.pcm: abc.cpp
	$(CXX) -o abc.pcm --precompile -x c++-module abc.cpp

main: abc.pcm abc.cpp main.cpp
	$(CXX) -o main -fprebuilt-module-path=. main.cpp abc.cpp

这个 abc.pcm 就是 abc.cpp 编译出来的二进制模块文件,类似于头文件预编译出的 .gch 文件。然而编译 main.cpp 时仍然要把 abc.cpp 一起编译(就算已经预处理出了 .pcm 文件,也添加了当前路径到模块查找路径中),否则链接器就找不到 $F$ 函数了。这简直类似于把 bits/stdc++.h 放进编译命令里。觉得这个特性还有待改进。

使用 make && ./main 来测试。输出 $1$。这种由多个文件组成的程序,使用 makefile 会比一个一个手动编译方便很多,并且只修改部分文件时可以不用从头重新编译。

ranges

如果读者是和我一样的 STL 重度依赖者,肯定在代码中有一个宏(或者某种“编辑器宏”)指代(a).begin(),(a).end()。使用 std::ranges 就可以摆脱这些 begin 和 end,让代码更加清爽:

#include<bits/stdc++.h>
using namespace std::ranges;
using ll=long long;
std::vector a{7,2,5,3};
int main(){
	sort(a);
	printf("%d\n",*lower_bound(a,4));
	reverse(a);
	copy(a,std::ostream_iterator<int>(std::cout," ")); //输出数组的小技巧
	puts("");
	return 0;
}
// 输出: 
// 5
// 7 5 3 2

这里还使用了 C++17 的模板参数类型自动推导,我在之前的某篇洛谷日报里面提到过。

所有 STL 中的参数中包含一对迭代器的函数,基本上都可以这么写。唯一需要注意的是不能同时using namespace std;using namespace std::ranges;,因为会产生重载冲突(就像__gnu_pbds::priority_queue一样)。

views

提供了一种像 bash 的管道一样的语法,重载运算符|来串联处理一串元素。之前有两种常用的写法:

//这种写法常见于大多数语言,这里按照python的习惯把callback前置(c++的STL中是后置的)
auto A=getA();
A=transform_3(func_3,transform_2(func_2,transform_1(func_1,A)));
//这种写法常见于JavaScript,c++中很少用。
auto A=getA().transform_1(func_1).transform_2(func_2).transform_3(func_3);

现在的写法相当于把上面第二种写法的.换成了|

// 这种写法常见于shell脚本和liquid语言,别的语言里很少见到。
auto A=getA() | transform_1(func_1) | transform_2(func_2) | transform_3(func_3);

这让我联想到了我写的一行 bash 命令(用来统计最近的常用命令的,并不能兼容不同的发行版和不同的 Shell):

history | awk '{print $2}' | sort | uniq -c | sort -nr | less

但是由于 C++ 的匿名函数比较长,所以显得比 bash 繁琐。比如说

#include<bits/stdc++.h>
using namespace std::views;
int main(){
	std::ranges::copy(iota(0) | take(10) |
		filter([](int x){return x&1;}) |
		transform([](int x){return x*x;}),
		std::ostream_iterator<int>(std::cout," "));
	puts("");
	return 0;
}
// 输出:
// 1 9 25 49 81 

这段程序的作用等价于 python3 语句

print(list(map(lambda x: x**2, filter(lambda x: x&1, range(10)))))

意义是:取出前 $10$ 个自然数,用filter筛选出奇数,再用transform变换成平方,然后输出。这里使用了前文提到的std::ranges::copy进行输出。

注:clang++-10 尚未完全支持此功能。所以是用 g++ 编译的。

每一个变换步骤都是惰性求值的,所以不用担心iota(0)产生了无限长的数列(全体自然数),就会永久执行下去。

constexpr 系列

从 C++11 加入constexpr这个关键字以来,允许使用的场合每个 C++ 版本都在扩大。现在的 C++20 标准中,又加入了两个新的关键字:constevalconstinit

consteval,顾名思义,要求编译器必须在编译期间像个解释器一样 eval 你的代码。用此关键字声明的函数必须在编译期就能被计算成常量。和 constexpr 的区别:在编译器就能被计算成常量的函数必须用 constexpr 声明。(就是充分条件和必要条件的区别)

constinit,顾名思义,要求初始化必须通过编译期常量表达式进行。和 constexpr 的区别:constexpr 只能声明常量,而 constinit 可以声明变量(只不过初始值可以在编译期间确定)。

声明:上面我的解释都只是形象化的,并不严谨,只是便于理解而已。可能存在一些细节上的错误。

我最近写了一篇文章:C++编译期多项式exp。那篇文章侧重于表达模板元编程的能力。下面我使用 constexpr 重写一份,同样是在编译期完成的:

#include<bits/stdc++.h>
using namespace std;
using ll=long long;
const int N=1010,mod=998244353;
constexpr ll po(ll a,ll b=mod-2){ll r=1;for(;b;b/=2,a=a*a%mod)if(b&1)r=r*a%mod;return r;}
constexpr auto exp(array<ll,N> F){
	array<ll,N> res={1};
	for(int i=1;i<N;i++){
		for(int j=0;j<i;j++)(res[i]+=res[j]*F[i-j]%mod*(i-j))%=mod;
		(res[i]*=po(i))%=mod;
	}
	return res;
}
constexpr array<ll,N> F={0,1,2,3},G=exp(F);
int main(){
	for(int i=0;i<N;i++)printf("%lld\n",G[i]);
	return 0;
}

仍然是 $O(n^2)$ 的实现,分治NTT实现当然也可以写,只不过太过麻烦。毕竟这只是一个演示而已。

编译命令(假设文件名为1.cpp):

g++ 1.cpp -o 1 -std=c++17 -O2 -fconstexpr-ops-limit=100000000
clang++ 1.cpp -o 1 -std=c++17 -O2 -fconstexpr-steps=100000000 # 如果使用clang

注意到编译命令是 C++17 而非 C++20。如果按照 C++20 的标准,应该把两个函数的声明从constexpr改成consteval。最后那个参数是用来增加 constexpr 求解运算数的上限的。在我的电脑上,g++ 和 clang++ 分别耗时 10s 和 5s 完成前 $N=1010$ 项的计算。

为什么使用std::array呢?这是目前我唯一的选择。因为 constexpr 肯定意味着定义之后不能修改,那么必须使用“函数式”的写法,每次必须创建新变量而不是修改之前的变量。而编译期是肯定不能动态分配内存的,所以函数的返回值不能是T[]。并且 c++ 也不存在const T[]这种返回值,所以就不能使用数组了。除非像我之前写的那篇一样用模板元编程的写法从模板参数一步到位变成数组内容。同时显然也不能使用任何需要动态分配内存的数据结构(比如 vector 和 basic_string),所以只有一个可选项:std::array

为什么需要-std=c++17呢?不能用 C++14 是因为std::array<T,N>::operator[]直到 C++17 才变成 constexpr。否则res[i]+=res[j]*...这一行就会调用到非 constexpr 的函数,这显然是非法的。