C++17 相比于 C++14 来说,新增了不少新特性,然而,它并没有做到它被期望做到的发布版本,也就是没有特别重大的改变。

尽管在 C++17 之前,一些特性已经经过了深思熟虑的考虑,但最终依然出于各种原因,没有进入最终的版本中,有一些特性,方案讨论其实很早就开始并得到确定,但在后来的评定中被搁置,直到在 C++20 中才最终加入,比如概念(Concept),协程(Coroutine),这些大的特性任何一个放入 C++17 中,都可以让其变成一个大的版本发布。

因为按预期,C++ 标准发布采用一大一小的模式,C++17 作为继 C++11 之后的又一个大版本发布,所以当时有人主张将 C++17 延迟发布,以增加更多的重要特性,不过被 Bjarne Stroustrup 拒绝,拒绝理由其实也非常合理,“如果一项特性值得为之改变 C++ 标准的发布周期,那么就会有更多特性要求这么做”,C++11 已经是一个延迟的版本,并带来了很多因延迟导致的遗憾。

然而不可否认的是,C++17 依然是一个非常有价值的版本,其中有一些特性实实在在的帮助了程序员改善他们使用 C++ 的体验。


本文是 C++ 特性杂谈系列文章的第三篇:

  1. C++11 特性杂谈
  2. C++14 特性杂谈
  3. C++17 特性杂谈
  4. C++20 特性杂谈

# 构造函数模板参数推导

举个例子来说明这个特性:

1
2
std::unique_lock<std::mutex> lock {mtx};  // pre-C++17 只能这样写
std::unique_lock lock {mtx}; // pre-C++17 会出错,C++17 支持

例子中,std::mutex 是 std::unique_lock 的一个模板类型参数,在 C++17 中,允许通过 std::unique_lock 的构造函数推导出这个模板类型参数(因为 mtx 是 std::mutex 类型的)。

这种简化的写法让代码看起来更清爽,在一些常用的标准库类型中,非常常用,比如智能指针、锁、pair 和 tuple 等。

# 结构化绑定

非常实用的一个特性,在其他语言中已经得到非常普及的用法,比如 python:

1
2
3
4
5
6
def options():
# ...
return arg1, arg2, arg3

def func()
a1, a2, a3 = options()

在 C++14 中,我们引入了一种略微方便的返回多个值的写法:

1
2
3
tuple<Type1, Type2, Type3> func() {
return {a, b, c};
}

然而,当时却没有好好的处理接受 tuple 类型时的解包语法,以至于只能用这些写法来实现:

1
2
3
4
5
6
7
8
9
10
// 第一种做法是继续用 tuple 类型接收返回值,并用 get<T>() 解包
tuple<Type1, Type2, Type3> res = func();
Type1 t1 = std::get<0>(res);
Type2 t2 = std::get<1>(res);
Type3 t3 = std::get<2>(res);
// 这种写法实际上很丑,尤其是要把 0,1,2 这种字面量数字写入模板参数中,而且还带来了运行时创建后赋值开销
// 第二种做法是使用 tie
Type1 t1; Type2 t2; Type3 t3;
std::tie(t1, t2, t3) = func();
// 看起来似乎整洁了一些,带却带来了更多问题,比如引入未初始化的变量,事实上也躲不开创建后赋值开销

C++17 中,终于拿出了像样的写法:

1
auto [t1, t2, t3] = func();

干净清晰,而且没有创建后赋值的开销,类型自动推导。

# 折叠表达式

折叠表达式是 C++17 中的一个重要特性,我认为它可以排到前三,虽然很多介绍 C++17 新特性的文章中并不愿意提及折叠表达式。

它很大程度上改变了变长参数模板的使用方式。在这之前,想要对变长参数模板做解包,代码逻辑相对复杂,只能使用递归解包或者用逗号表达式解包,无论是可读性,还是排查问题,都很困难。折叠表达式是一个非常好的设计,让变长参数的解包变的轻松。

除了单纯的解包,折叠表达式还可以直接对解包参数做运算,比如,我们想要对折叠参数做累加时:

1
2
3
4
5
6
7
8
template<typename... Args>
auto sum(Args... args) {
return (... + args);
}
int main() {
std:: cout << sum(1, 2, 3, 4) << std::endl;
return 0;
}

# 替代 union

传统的 union 是一种可以将不同类型的值复用相同的内存空间,然后由程序员来管理当前使用 union 时的值类型。如果在这种地方犯错,就会取到奇怪的值,编译器并不会为这种错误操作做任何检查。

C++17 中引入了 variant,它是标准库的一个类型,用来取代 union 的一部分工作。它能够实现 union 的特性,同时还能允许编译器对非法的使用做检查。

我不认为这个特性在目前的时间上会产生什么重大的影响,使用 union 的人,并不是想要创建混合类型来用,而是想要极度的压缩内存,或者使用 union 来实现快速的数据分割或转换。

按 C++ 委员会的设想,variant 可以用于创建混合类型,进而可以创建动态类型容器,比如:

1
2
3
using var_t = std::variant<int, long, double, std::string>;

std::vector<var_t> vec = {10, 20L, 30.4, "hello"};

目前看来用处并不大,也许将来配合模式匹配的功能,可以让 variant 大放异彩。另外,C++17 中还拿出了 std::any,任意数据类型,好吧,总之,从这里来看,C++ 向着动态类型语言迈出了一步。

# 并发补丁

C++11 中引入的并发特性其实缺少一些很常用的特性,在 C++17 中终于打了补丁:

  • scoped_lock:可以同时获得多个锁
  • shared_mutex/shared_lock:读写锁

等了这么多年才补充,不是忘记疏忽了,而是在曾经的提案中没有得到通过。这些特性的迟迟到来,让 C++ 程序员已经不太习惯在并发中使用它们了。但只要你能想起来去用它们优化你的代码,就非常有益。

# 并行算法

在 C++17 之前,我们在使用 STL 中的算法接口时,没办法让其利用好硬件提供的并行计算能力,比如通过多核来加速排序算法。这些在 C++17 中终于成为了可能。虽然 C++17 并不是最完善的一个版本(C++20 中对其做了补充),但也足够使用其改进我们的程序性能。

用法是在 STL 算法接口中,加入一个参数:

1
sort(par_unseq, begin(v), end(v));

除了要求程序尝试并行化之外,程序也会尝试使用向量化来加速算法,也就是使用硬件的 SIMD 来优化运算,这在很早期就应该得到支持,避免程序员手写 builtin 自己组装数据了。SIMD 是硬件少有的还能在单线程上提升性能的贡献了,一定要用起来。

然而,C++17 依然不支持取消线程的操作,这实际上增加了并行算法的开销,比如并行查找一个元素,我们希望有线程查到元素时,停止其他线程,但目前还是做不到。

# 文件系统

我只能说 C++ 标准委员会的工作可能真的太多了,这种实用的功能,在 C++17 才拿出来真的不应该,类似的还有网络库(它没有加入 C++17,甚至没有加入 C++20)。因为它们太常用了,以至于大家在过去都需要自己造轮子实现。

这时就体现出有一个活跃的包管理机制有多重要了,虽然有 Boost,但一方面,大家并不愿意在自己的项目中引入这个沉重的包,另一方面,也是习惯问题,毕竟 Boost 被定位为 C++ 新标准的测试库,拿到工程中用总感觉不太合适。最后,大多数项目都选择自己写一套,有需求随便改,有 bug 自己修。

话说回来,C++17 引入的这套文件系统操作还是很全面的,如果不是用其他成熟的框架(比如 LLVM 的 llvm::fs),切换到 C++17 的接口还是值得的。

# 新增的一些属性

C++ 中有一些属性,用来对代码中的一些内容给编译器做提示,C++17 中加入了以下的一些属性:

  • [[nodiscard]]:用来指示函数的返回值不应该被忽略,编译器会在没有接收返回值时报警告
  • [[maybe_unused]]:用来指示某个变量,参数或类型可能不会被使用,避免编译器报告变量等未使用的警告
  • [[fallthrought]]:用于在 switch case 中,如果两个分支使用相同的处理逻辑块,前一个分支想要进入后边的代码块时使用,避免编译器报告可能丢失 break 语句的警告

在 C++11 和后续的版本中,还引入了其他一些属性,这些属性看似不影响功能,但实际上对代码的可读性有着很大的改善,强烈建议学会怎么使用。


本文同步发布在知乎账号下:C++17 特性杂谈 - 知乎 (zhihu.com)

封面图片是巴厘岛的一个海滩,来自:Photo by Iswanto Arif on Unsplash