0%

C++17小结--语言特性篇

继上次14标准小结过了三年多…难道我这系列博客也要和标准一样三年一个吗😂,眼见23标准都出完了,我也该加速了,废话不多说,开始17标准语言特性改动:

新的语言特性

变量相关

inline变量

在以往标准中inline关键字通常是做为编译器进行内联函数替换的指示器,优化不必要的函数调用而使用的,但比较容易忽略的是inline函数还有指示“允许多次定义”的功能,如在多个编译单元内引用了同一个包含inline函数定义的头文件,链接多个编译单元时不会出现多重定义问题;该“允许多次定义”的功能在17标准中扩展到了变量上,inline变量在每个编译单元内将拥有相同的地址。

结构化绑定声明

结构化绑定声明是c++17加入的语法糖,用于更方便的绑定数组、元组、成员变量,语法:

1
2
3
属性(可选) cv-auto 引用运算符(可选) [ 标识符列表 ] = 表达式 ;
属性(可选) cv-auto 引用运算符(可选) [ 标识符列表 ]{ 表达式 };
属性(可选) cv-auto 引用运算符(可选) [ 标识符列表 ]( 表达式 );

1
2
3
4
5
6
7
8
9
10
11
12
13
tuple current_date(2023, 5, 26);
auto& [year, month, day] = current_date;

int nums[3] = {1,2,3};
auto [first, second, third]{nums};

struct my_date
{
int year_;
int month_;
int day_;
};
auto [y, m, d](my_date{2023, 5, 26});

数组、元组、成员变量的数量必须和绑定申明的列表中变量数量一致。
这里测试时发现几个有意思的地方:

1
2
3
4
SomeClass s(1);
auto [s_] = s; // 发生复制构造,s_绑定到复制构造后的匿名临时对象的成员对象上,为了保证s_的生存周期,该匿名临时对象拥有s_相同的生存周期

auto [s_] = SomeClass(1); // 将不进行复制构造,发生为复制消除,s_绑定到构造后的匿名临时对象的成员对象上,生存周期如上

即结构化绑定声明拥有普通对象构造类似的编译优化。

if 和 switch 语句中的初始化器

17标准在if和switch语句中可以增加一条初始化语句以方便逻辑判断,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::map<int, std::string> m;
if (auto it = m.find(10); it != m.end()) { return it->second.size(); }

switch (auto dev = Device{}; dev.state())
{
case Device::SLEEP:
/*...*/
break;
case Device::READY:
/*...*/
break;
case Device::BAD:
/*...*/
break;
}

其等价于

1
2
3
4
5
6
7
8
9
10
{
初始化语句
if ( 条件 )
true分支语句
}

{
初始化语句
switch ( 条件 ) 语句
}

强制的复制消除

在以往标准中,返回值优化和构造优化是编译器完成的优化动作,即在以下代码中:

1
2
3
4
5
6
7
8
9
MyClass create()
{
return MyClass(); // 1.返回临时对象
}

int main()
{
MyClass c = 1; // 2. =号构造对象
}

1、2两处皆由编译器完成优化,不会进行“构造一个临时对象再调用复制/移动构造最终对象”的过程,而是直接构造最终对象,但是如果该类型不提供复制/移动构造函数时,编译器会报复制/移动构造函数不存在错误,也就是虽然编译器会执行优化但仍然会做复制/移动构造检查。这一情况在17标准中得以改善,17标准规定以上两种情况必须进行强制复制/移动构造消除,在语言上规定了这两种情况不会出现复制/移动构造,且编译器无须检查类型是否有复制/移动构造。为了支持该改动,17标准对值类型(Value category)做了一定程度的修改,并增加了一个新的概念 (临时对象)的实质化(materialization)。

临时量实质化(temporary materializetion)

任何完整类型T的纯右值可以转换为类型T的亡值(xvalue 或称过期值),此转换以该纯右值初始化一个T类型的临时对象,并产生一个代表该临时对象的亡值;当通过一个右值进行直接初始化或者拷贝初始化时,临时量实质化将不会发生,确保上面提到的强制复制消除。
个人感觉临时量实质化是Bjarne为了在语义逻辑上通顺而制造的概念,11标准后纯右值、亡值、左值概念就开始比较混乱,17标准为了强制复制消除或者为了统一这些概念,做了这些新的规定,但是个人感觉这并没有让现代c++这些语义更明朗…

模板相关

折叠表达式

对于c++11新增的形参包增加折叠语法:

1
2
3
4
( pack op ... )        (1)
( ... op pack ) (2)
( pack op ... op init )(3)
( init op ... op pack )(4)
  1. 一元右折叠 (E 运算符 …) 成为 (E1 运算符 ( … 运算符 (En-1 运算符 En))
  2. 一元左折叠 (… 运算符 E) 成为 (((E1 运算符 E2) 运算符 …) 运算符 En)
  3. 二元右折叠 (E 运算符 … 运算符 I) 成为 (E1 运算符 (… 运算符 (En-1 运算符 (En 运算符 I)))
  4. 二元左折叠 (I 运算符 … 运算符 E) 成为 ((((I 运算符 E1) 运算符 E2) 运算符 …) 运算符 En)
    二元折叠两个操作符op必须相同,左折叠与右折叠区别在于圆括号合并优先逻辑
    例子:
1
2
3
4
5
6
7
8
9
10
11
template<class T, class... Args>
void emplace_back(vector<T>& v, Args&&... args)
{
static_assert((is_constructible_v<T, Args&> && ...));
(v.emplace_back(args), ...);
}

{
vector<int> t;
emplace_back(t,1,2,3,4,5);
}

以上折叠表达式等于

1
((t.emplace_back(1), (t.emplace_back(2), (t.emplace_back(3), (t.emplace_back(4), t.emplace_back(5)))))

这里stackoverflow上有一个比较经典的大小端转换的例子:

1
2
3
4
5
6
7
8
9
10
template<class T, std::size_t... N>
constexpr T bswap_impl(T i, std::index_sequence<N...>)
{
return (((i >> N*CHAR_BIT & std::uint8_t(-1)) << (sizeof(T)-1-N)*CHAR_BIT) | ...);
}
template<class T, class U = std::make_unsigned_t<T>>
constexpr U bswap(T i)
{
return bswap_impl<U>(i, std::make_index_sequence<sizeof(T)>{});
}

以上代码大致逻辑为生成一个0,1,2…N-1的序列,N为T类型的字节大小,然后将类型T的i低端每个字节取出左移至目标位置,再将所有左移结果通过折叠表达式进行按位或运算。

类模板实参推导 Class template argument deduction (CTAD)

与函数模板推导类似,c++17标准加入类模板参数推导,也就是自17标准后可以直接写形如:

1
2
std::vector v{1,2,3,4}; //vector<int>
std::pair p{1,2}; //pair<int, int>

对于需要进行推导的类模板C,编译器会隐式生成推导指引,该推导指引逻辑为:

  • 对于类模板C所有已声明的构造函数或构造函数模板,各生成一个虚设的构造函数模板
  • 如果还没有定义C或没有声明任何C的构造函数,添加一个假想的C()构造函数模板
  • 在任何情况下,添加一个假想复制构造函数模板C©,称为复制推导候选
    然后在以上假想的构造函数模板中进行函数模板参数推导和重载决议,从而进行类模板实参推导。
    除此之外,17标准允许编写自定义的推导指引,语法为:
1
explicit 说明符(可选) 模板名 ( 形参声明子句 ) -> 简单模板标识;

自定义推导指引与隐式生成的推导指引一同进行重载决议;自定义推导指引可以不是模板;
CTAD只在没有显示指定模板类型时才会发生,指定了部分模板类型参数时不会发生推导。

auto声明非类型模板参数

模板参数中的非类型参数在17标准后可使用auto进行推导,但推导结果类型只能是可允许的非类型模板参数类型,多个非类型参数包情况下每个参数类型分别推导:

1
2
3
4
5
6
7
template<auto n>struct B { /* ... */ };
B<5> b1; // OK:模板非类型形参的类型是 int
B<'a'> b2; // OK:模板非类型形参的类型是 char
B<2.5> b3; // 错误(C++20 前):模板非类型形参的类型不能是 double

template<auto...>struct C {};
C<'C', 0, 2L, nullptr> x; // OK

命名空间相关

简化的嵌套命名空间定义

现在可以使用更方便的方式嵌套定义命名空间:

1
2
3
namespace A::B::C { 
//...
}

注意以上命名空间A和B无须之前定义,这等同于以往的

1
namespace A { namespace B { namespace C { //... } } }

using声明语句可以声明多个名称

现在可以使用逗号分割的列表来指示using声明:

1
using std::vector,std::string;

在派生类定义中可以使用后随省略号表示包展开:

1
2
3
4
5
template<typename... Ts>
struct Overloader : Ts...
{
using Ts::operator()...; // 从每个基类暴露 operator()
};

属性命名空间不必重复

可以使用如下格式的using方式来标识多个属性的命名空间

1
[[ using 属性命名空间 : 属性列表]]

1
[[ using CC : opt(1), debug]]    // 等同于 [[ CC::opt(1), CC::debug ]]

新属性

[[fallthrough]]

在switch语句中用于指示编译器,该条case语句逻辑落空到下一case语句时不产生编译告警;为此,fallthrough属性必须放在case语句内,且该属性之后必须有下一条case或default语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void f(int n)
{
void g(), h(), i();
switch (n)
{
case 1:
case 2:
g();
[[fallthrough]];
case 3: // 直落时不警告
h();
case 4: // 编译器可在发生直落时警告
if (n < 3)
{
i();
[[fallthrough]]; // OK
}
else
{
return;
}
case 5:
while (false)
{
[[fallthrough]]; // 非良构:下一语句不是同一迭代的一部分
}
case 6:
[[fallthrough]]; // 非良构:没有后继的 case 或 default 标号
}
}

[[nodiscard]]

声明在函数、枚举或类前,用于指示编译器在以下场景时发出编译告警提示不可忽略:

  • 调用声明为nodiscard的函数,或
  • 调用按值返回声明为nodiscard的枚举或类的函数,或
  • 以显示类型转换或static_cast形式调用声明为nodiscard的构造函数,或
  • 以显示类型转换或static_cast形式构造声明为nodiscard的枚举或类型的对象
    如:
1
2
3
4
5
6
7
8
9
[[nodiscard]]
int Test(int c);

Test(1); // 编译器告警,未使用Test调用的返回值

class [[nodiscard]] ClassTest;
ClassTest Test();

Test(); // 编译器告警,未使用声明为nodiscard的ClassTest返回对象

[[maybe_unused]]

指示编译器无须对未使用对象做出告警,此属性可声明在:

  • class/struct/union:struct [[maybe_unused]] S;
  • typedef, 包括别名声明:[[maybe_unused]] typedef S* PS; using PS [[maybe_unused]] = S*;
  • 变量,包括静态数据成员:[[maybe_unused]] int x;
  • 非静态数据成员:union U { [[maybe_unused]] int n; };
  • 函数:[[maybe_unused]] E {};
  • 枚举:enum [[maybe_unused]] E {};
  • 枚举项:enum { A [[maybe_unused]], B [[maybe_unused]] = 42 };
  • 结构化绑定:[[maybe_unused]] auto [a, b] = std::make_pair(42, 0.23);

其余语言特性

u8字符字面量

在c++11标准增加了utf-8字符串字面量(如:u8"中文"),但是不可声明单个字符的u8字面量,17标准补上了这个漏洞,现在可以正常声明u8字符字面量,如:u8’中’

noexcept作为类型系统的一部分

17标准后标识有noexcept的函数与未标识noexcept的函数不再是同一类型,带有noexcept标识的函数指针不再可指向未带有noexcept的相同签名的函数:

1
2
3
4
5
6
7
8
9
10
void FuncThrow();
void FuncNoThrow() noexcept;

typedef void (*NoThrowPointer)(void) noexcept; // 17标准后可在声明函数指针时加上noexcept标识
typedef void (*ThrowPointer)(void);

{
NoThrowPointer np = FuncThrow; // 编译报错,保证无抛异常的指针指向了可能抛异常的函数
ThrowPointer p = FuncNoThrow; // 编译正常,一方面向后兼容以往代码,一方面可能抛出异常的指针指向了无抛异常方法逻辑上也说的通
}

noexcept(false)与未声明noexcept同理,但须注意noexcept并不作为函数签名一部分,与返回值一样不能声明重载两个同名但是noexcept不同的函数:

1
2
3
4
5
6
7
8
9
10
void FuncTest()
{
// ...
}


void FuncTest() noexcept // 编译错误,报错重复定义FuncTest函数
{
// ....
}

旧标准中的throw(std::bad_alloc)式异常声明在17标准中不再支持,将编译报错。

新的求值顺序规则

17标准制定了一些新的规则保证语句的执行顺序,具体来说对于

  • 运算符运算:
1
2
3
4
5
6
e1[e2]
e1.e2
e1.*e2
e1->*e2
e1<<e2
e1>>e2

e1现在保证一定会在e2之前求值,但是在函数调用中不同参数的计算顺序仍然是未定义的

1
e1.f(a1, a2, a3);     // a1,a2,a3三个表达式的求值顺序是未定义的
  • 赋值运算
1
2
3
4
e2 = e1
e2 += e1
e2 *= e1
...

右侧e1保证在左侧的e2之前求值

  • new表达式
    内存分配operator new的调用规定须早于构造函数的调用。

lambda表达式捕获*this

17标准后可按值捕获当前对象,将发生复制构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
class SomeClass
{
void Func()
{
auto f =[*this]()
{
cout << i_ << this->i_; // 此两处i_皆是复制后对象中的i_
};
}

private:
inti i_;
};

编译期if语句

通过if constexpr语法完成编译期的条件判断,与预编译宏的区别在于预编译宏不生效的判断中代码不会进行编译,if constexpr判断不生效的代码虽然最终不会被编译进目标文件,但是仍然会由编译器进行语法检查:

1
2
3
4
5
6
7
8
9
10
11
{
if constexpr(sizeof(int) == 4)
{
//...
}
else
{
Undefined(); // 未声明方法,编译报错
static_assert(sizeof(int) != 4); // static_assert失败,编译报错
}
}

在标准中constexpr是作为可选关键字放入if语句里的,所以上文提到的if初始化器也可以在if constexpr语句中使用,if constexpr语句主要用于编译期类型判断和模板编程中。

constexpr lambda表达式

17标准后,编译器将尽可能的将lambda表达式隐式声明为constexpr,任何只使用编译期上下文的lambda都可以被用于编译期,也可以显示将lambda声明为constexpr:

1
2
3
auto double = [](auto val) constexpr {  //如果编译器无法编译为constexpr函数将报错
return val * 2;
}

预编译指令条件__has_include

用于检查头文件是否可以包含,仅检查并不实际include,例:

1
2
3
#if __has_include(<filesystem>)
# include <filesystem>
#endif

移除的语言特性

以下为17标准移除的语言特性

  • 三标符
    从C语言继承来的三标符(如"??!“等价为”|",由编译器替换)在C++17标准中移除,C23标准中也移除了该语法
  • register 存储周期说明符
    以往C++标准中register对象与不带任何存储周期说明符的对象没有区别,17标准以后不能将变量声明为register,但由于register仍然是C标准语法编译器可能仍然会允许声明…
  • bool类型++操作
    bool类型的自增操作被移除(包含前、后++),自减--操作旧标准就不支持
  • 动态异常说明
    C++11标准中弃用(deprecated)的throw(xxx)声明在17标准中移除,throw(xxx)在17标准后编译报错,但throw()空异常声明仍可使用(仍被deprecated)