C++ 14&17

2022-12-16
6 min read

《C++17 The Complete Guide》 & cppstd17 / 《现代C++语言核心特性解析》 /

功能增强

lambda

lambda 是 C++11 引入的特性,C++14 新增了广义捕获、泛型 lambda 等功能

// 广义捕获。C++11 时捕获列表只能是一些变量,而 C++14 后可以在捕获列表里初始化变量
int x = 5;
auto foo = [r = x + 1] { return r; };

auto foo = [](auto a) { return a; }; // 泛型 lambda

C++17 相比于 C++14,对 lambda 的增强包括 *this 捕获。异步编程需要捕获当前对象时,可以直接使用 [*this] 的形式,lambda 内部可以直接使用副本对象的成员

聚合类扩展

Aggregates are either arrays or simple, C-like classes that have no user-provided constructors, no private or protectednon-static data members, no virtual functions, and before C++17, no base classes

这里要先明确 C++ 中对 Aggregates 的定义,细节请参考 is_aggregate<...>。C++17 之后私有继承非aggragate 类获得的新类也可以是 aggragate 对象

aggregate 初始化和调用初始化函数的方式是不同的概念,使用大括号进行初始化时编译器会优先考虑 aggregate 初始化方式,当然了前提是对象是 aggregate 对象。aggregate 对象可以使用大括号初始化每一个参数,C++17 之后此类初始化也可以用于有继承关系的类。其他细节请参考 《现代C++语言核心特性解析》 第十五章

常量表达式

C++11 引入的 constexpr 关键字可以定义编译时类型。常量表达式函数可以退化为运行时求解,如此可以减少编码量

如果想定义常量表达式类型的对象,需要给类的构造函数添加 constexpr 修饰。使用constexpr声明自定义类型的变量,必须确保这个自定义类型的析构函数是平凡(Trivial)的

  1. C++14 后常量函数可以包含 switch/if 语句,且可以定义变量
  2. 从 C++17 开始,lambda 表达式在条件允许的情况下都会隐式声明为 constexpr
  3. C++17 后可以使用 if constexpr 实现编译时代码块的选择。if constexpr 不支持常规 if 的短路规则
  4. C++20 后虚函数可以使用 constexpr 修饰;C++20 后 constexpr 函数可以包含 try/catch 语句
  5. C++20 引入的 std::is_constant_evaluated 函数可以判断当前环境是否支持常量求值

细节请参考 《现代C++语言核心特性解析》 第二十七章

explicit

C++11 后除构造函数,explicit 可用于自定义类型转换函数。为了方便,标准放松了对 bool 类型的转换的限制,在一些场景下(例如 if/while/for/!/&&/||等)即使使用了 explicit,标准也允许默认的转换

新特性

预处理与宏

C++17 引入的 __has_include 可以用于判断头文件是否可以被引入,可以利用这个工具实现兼容性处理。其他宏包括:__VA_ARGS____VA_OPT__

部分新函数

std::launder

如果新的对象在已被某个对象占用的内存上进行构建,那么原始对象的指针、引用以及对象名都会自动转向新的对象,除非对象是一个常量类型或对象中有常量数据成员或者引用类型

当前函数用于解决 const 变量和 placement new 之间的冲突而提出的,具体细节请参考 cppref

语法特性

结构化绑定

所谓结构化绑定是指将一个或者多个名称绑定到初始化对象中的一个或者多个子对象(或者元素)上,相当于给初始化对象的子对象(或者元素)起了别名,请注意别名不同于引用

在结构化绑定中编译器会根据限定符生成一个等号右边对象的匿名副本,而绑定的对象正是这个副本而非原对象本身。另外,这里的别名真的是单纯的别名,别名的类型和绑定目标对象的子对象类型相同,而引用类型本身就是一种和非引用类型不同的类型

for (const auto& [id, str]:id2str_map) {
	std::cout << "id=" << id << ", str=" << str << std::endl;
}

细节可参考 cppref《现代C++语言核心特性解析》 第二十章

结构化绑定可以作用于 3 种类型,包括原生数组、结构体和类对象、元组和类元组的对象

结构绑定的实现机制如下,绑定的实现方式对限定符的使用与实现有很大的影响。别名和引用类型的区别主要体现在 decltype 相关场景,也因为别名的因素,结构绑定不触发类型 decay

auto [u,v] = ms; // 等价下述过程
auto e = ms;
aliasname u = e.i;
aliasname v = e.s;

// 引用场景下的结构绑定,引用语义作用于对象而非成员
const auto& [u,v] = ms; // decltype(u) is const decltype(ms.i), without ref
auto& e = ms;
aliasname u = e.i; // 注意这里是别名,u/v 的类型是 i/s 是加上 const 约束,u/v 没有引用属性
aliasname v = e.s;

含初始化语句的 if/switch

细节可参考 if statementswitch statement。示例如下

// b和b1的生命周期并不相同。其中变量b的生命周期会贯穿整个if结构(包括else if)
if (bool b = foo(); b) {
  std::cout << std::boolalpha << "foo()=" << b << std::endl;
} else if (bool b1 = bar(); b1) {
  std::cout << std::boolalpha << "foo()=" << b << ", bar()=" << b1 << std::endl;
}

noexcept 类型

C++17 标准将异常规范引入了类型系统。这样一来,fp = &foo 就无法通过编译了,因为 fp 和 &foo 变成了不同的类型

void(*fp)() noexcept = nullptr;
void foo() { ... }

fp = &foo; // error

inline Variables

根据 ODR,对于全局变量,非常量型静态变量不允许在头文件中定义。常量型静态变量编译器会将其转化为占位符,如果不取其地址,变量在内存中是不占空间的,如果取地址,常量型静态变量还是只能在一个编译单元中定义

C++17 之前如果想在头文件中使用具值静态变量,可以考虑如下方式

  • inline 函数中使用 static 变量 / 静态成员函数中使用 static 变量 / 模板方法中使用 static 变量

上述方法都不太直观,如果想进一步解释为什么可以这样做,需要更深入的知识,太复杂

C++17 引入了 inline variables 概念,使用 inline 关键字可以在头文件中定义全局唯一的变量

inline 结合 static 关键字时需要注意 static 的具体含义,如果 static 本身就是用于限制可见性的,那么 inline 的全局性将失效,如下示例

#include "inline.h"
/* inline.h content
    #pragma once
    inline int a;
    inline static int b;
*/

#include <iostream>

int64_t a_addr(); // int64_t a_addr() { return reinterpret_cast<int64_t>(&a); }
int64_t b_addr();

int main() {
    std::cout << reinterpret_cast<int64_t>(&a) << ", " << a_addr() << std::endl;
    std::cout << reinterpret_cast<int64_t>(&b) << ", " << b_addr() << std::endl;
    /* output 
     4324700208, 4324700208
     4324700212, 4324700220
    */
}

inline static

C++17 之后允许内联定义静态变量(inline static),这样就不需要在 cpp 中额外定义,在这种情况下,编译器会在类 X 的定义首次出现时对内联静态成员变量进行定义和初始化

New Attributes and Attribute

细节请参考 cppref

[[nodiscard]] / [[maybe_unused]] / [[fallthrough]] / [[deprecated]]

泛型编程相关

CTAD

C++17 之前使用类模板时必须提供所有模板参数,C++17 之后因为 CTAD(Class Template Argument Deduction)的存在,类模板的构造函数将尝试自动推演模板参数。示例如下

std::mutex mx;
std::lock_guard lg{mx}; // OK: std::lock_guard<std_mutex> deduced

std::vector v1 {1, 2, 3}; // OK: std::vector<int> deduced
std::vector v2 {"hello", "world"}; // OK: std::vector<const char*> deduced

std::tuple t{42,'x', nullptr}; // deduction for variadic templates is also supported
// 非类型模板参数也可以自动推导,这里不展开

CTAD 是在模板框架下的概念,所以需要满足模板与类型推导的一些约束:

std::complex c5{5,3.3}; // ERROR: attempts to int and double as T

推导指引

template<typename T1, typename T2> MyPair(T1, T2)->MyPair<T1, T2>;
MyPair p6(5, "hello");

性能提升

返回值优化

返回值优化分为 RVO(Return Value Optimization)和 NRVO(Named Return Value Optimization),不过在优化方法上的区别并不大,一般来说当返回语句的操作数为临时对象时,我们称之为 RVO;而当返回语句的操作数为具名对象时,我们称之为 NRVO。在C ++ 11标准中,这种优化技术被称为复制消除。如果使用 GCC 作为编译器,则这项优化技术是默认开启的,取消优化需要额外的编译参数“-fno-elide-constructors”

C++17 后,在传递临时对象或者从函数返回临时对象的情况下,编译器应该省略对象的复制和移动构造函数,即使这些复制和移动构造还有一些额外的作用,最终还是直接将对象构造到目标的存储变量上,从而避免临时对象的产生

C++17 之前,标准建议某些场景下可以忽略拷贝/移动构造函数,即直接在目标对象空间构造新的对象。因为不是强制要求,所以即使对象只用在可以忽略拷贝/移动构造的场景,其依旧需要实现拷贝/移动构造函数。为了解决这个问题,C++17 之后把某些场景下的建议变成了强制要求。细节请参考 cppref

模块

模块(module)是C++20标准引入的一个新特性,它的主要用途是将大型工程中的代码拆分成独立的逻辑单元,以方便大型工程的代码管理。模块能够大大减少使用头文件带来的问题,例如在使用头文件时经常会遇到宏和函数的重定义,而模块则会好很多,因为宏和未导出名称对于导入模块是不可见的。使用模块也能大幅提升编译效率,因为编译后的模块信息会存储在一个二进制文件中,编译器对于它的处理速度要远快于单纯使用文本替换的头文件方法