Chapter8 其他语言特性

在C++17中还有一些微小的核心语言特性的变更,将在这一章中介绍。

8.1 嵌套命名空间

自从2003年第一次提出,到现在C++标准委员会终于同意了以如下方式定义嵌套的命名空间:

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

等价于:

  1. namespace A {
  2. namespace B {
  3. namespace C {
  4. ...
  5. }
  6. }
  7. }

注意目前还没有对嵌套内联命名空间的支持。 这是因为inline是作用于最内层还是整个命名空间还有歧义(两种情况都很有用)。

8.2 有定义的表达式求值顺序

许多C++书籍里的代码如果按照直觉来看似乎是正确的,但严格上讲它们有可能导致未定义的行为。 一个简单的例子是在一个字符串中替换多个子串:

  1. std::string s = "I heard it even works if you don't believe";
  2. s.replace(0, 8, "").replace(s.find("even", 4, "sometimes")
  3. .replace(s.find("you don't"), 9, "I");

通常的假设是前8个字符被空串替换,"even""sometimes"替换, "you don't""I"替换。因此结果是:

  1. it sometimes works if I believe

然而在C++17之前最后的结果实际上并没有任何保证。因为查找子串位置的find() 函数可能在需要它们的返回值之前的任意时刻调用,而不是像直觉中的那样从左向右按顺序执行表达式。 事实上,所有的find()调用可能在执行第一次替换之前就全部执行,因此结果变为:

  1. it even worsometimesf youIlieve

其他的结果也是有可能的:

  1. it sometimes workIdont believe
  2. it even worsometiIdont believe

作为另一个例子,考虑使用输出运算符打印几个相互依赖的值:

  1. std::cout << f() << g() << h();

通常的假设是依次调用f()g()h()函数。 然而这个假设实际上是错误的。f()g()h()有可能以任意顺序调用, 当这三个函数的调用顺序会影响返回值的时候可能就会出现奇怪的结果。

作为一个具体的例子,直到C++17之前,下面代码的行为都是未定义的:

  1. i = 0;
  2. std::cout << ++i << ' ' << --i << '\n';

在C++17之前,它 可能 会输出1 0,但也可能输出0 -1 或者0 0,这和变量iint还是用户自定义类型无关(不过对于基本类型, 编译器一般会在这种情况下给出警告)。

为了解决这种未定义的问题,C++17标准重新定义了 一些 运算符的的求值顺序, 因此这些运算符现在有了固定的求值顺序:

  • 对于运算
    1. e1 [ e2 ]
    2. e1 . e2
    3. e1 .* e2
    4. e1 ->* e2
    5. e1 << e2
    6. e1 >> e2
    e1 现在保证一定会在 e2 之前求值,因此求值顺序是从左向右。 然而,注意同一个函数调用中的不同参数的计算顺序仍然是未定义的。也就是说:
    1. e1.f(a1, a2, a3);
    中的e1.f保证会在a1a2a3之前求值。 但a1a2a3的求值顺序仍是未定义的。
  • 所有的赋值运算
    1. e2 = e1
    2. e2 += e1
    3. e2 *= e1
    4. ...
    中右侧的 e1 现在保证一定会在左侧的 e2 之前求值。
  • 最后,类似于如下的new表达式
    1. new Type(e)
    中保证内存分配的操作在对 e 求值之前发生。 新的对象的初始化操作保证在第一次使用该对象之前完成。

所有这些保证适用于所有基本类型和自定义类型。

因此,自从C++17起

  1. std::string s = "I heard it even works if you don't believe";
  2. s.replace(0, 8, "").replace(s.find("even"), 4, "always")
  3. .replace(s.find("don't believe"), 13, "use C++17");

保证将会把s的值修改为:

  1. it always works if you use C++17

因为现在每个find()之前的替换操作都保证 会在对该find()表达式求值之前完成。

另一个例子,如下语句:

  1. i = 0;
  2. std::cout << ++i << ' ' << --i << '\n';

对于任意类型的i都保证输出是1 0

然而,其他大多数运算符的运算顺序仍然是未知的。例如:

  1. i = i++ + i; // 仍然是未定义的行为

这里,最右侧的i可能在i自增之前求值也可能在自增之后求值。

新的表达式求值顺序的另一个应用是定义一个在参数之前插入空格的函数。

向后的不兼容性

新的有定义的求值顺序可能会影响现有程序的输出。例如,考虑如下程序:

  1. #include <iostream>
  2. #include <vector>
  3. void print10elems(const std::vector<int>& v) {
  4. for (int i = 0; i < 10; ++i) {
  5. std::cout << "value: " << v.at(i) << '\n';
  6. }
  7. }
  8. int main()
  9. {
  10. try {
  11. std::vector<int> vec{7, 14, 21, 28};
  12. print10elems(vec);
  13. }
  14. catch (const std::exception& e) { // 处理标准异常
  15. std::cerr << "EXCEPTION: " << e.what() << '\n';
  16. }
  17. catch (...) { // 处理任何其他异常
  18. std::cerr << "EXCEPTION of unknown type\n";
  19. }
  20. }

因为这个程序中的vector<>只有4个元素,因此在print10elems()的循环中 使用无效的索引调用at()时将会抛出异常:

  1. std::cout << "value: " << v.at(i) << "\n";

在C++17之前,输出可能是:

  1. value: 7
  2. value: 14
  3. value: 21
  4. value: 28
  5. EXCEPTION: ...

因为at()允许在输出"value: "之前调用, 所以当索引错误时可以跳过开头的"value: "输出。

自从C++17以后,输出保证是:

  1. value: 7
  2. value: 14
  3. value: 21
  4. value: 28
  5. value: EXCEPTION: ...

因为现在"value: "的输出保证在at()调用之前。

8.3 更宽松的用整型初始化枚举值的规则

对于一个有固定底层类型的枚举类型变量,自从C++17开始可以用一个整型值直接进行列表初始化。 这可以用于带有明确类型的无作用域枚举和所有有作用域的枚举,因为它们都有默认的底层类型:

  1. // 指明底层类型的无作用域枚举类型
  2. enum MyInt : char { };
  3. MyInt i1{42}; // 自从C++17起OK(C++17以前ERROR)
  4. MyInt i2 = 42; // 仍然ERROR
  5. MyInt i3(42); // 仍然ERROR
  6. MyInt i4 = {42}; // 仍然ERROR
  7. // 带有默认底层类型的有作用域枚举
  8. enum class Weekday { mon, tue, wed, thu, fri, sat, sun };
  9. Weekday s1{0}; // 自从C++17起OK(C++17以前ERROR)
  10. Weekday s2 = 0; // 仍然ERROR
  11. Weekday s3(0); // 仍然ERROR
  12. Weekday s4 = {0}; // 仍然ERROR

如果Weekday有明确的底层类型的话结果完全相同:

  1. // 带有明确底层类型的有作用域枚举
  2. enum class Weekday : char { mon, tue, wed, thu, fri, sat, sun };
  3. Weekday s1{0}; // 自从C++17起OK(C++17以前ERROR)
  4. Weekday s2 = 0; // 仍然ERROR
  5. Weekday s3(0); // 仍然ERROR
  6. Weekday s4 = {0}; // 仍然ERROR

对于 没有 明确底层类型的无作用域枚举类型(没有classenum), 你仍然不能使用列表初始化:

  1. enum Flag { bit1=1, bit2=2, bit3=4 };
  2. Flag f1{0}; // 仍然ERROR

注意列表初始化不允许窄化,所以你不能传递一个浮点数:

  1. enum MyInt : char { };
  2. MyInt i5{42.2}; // 仍然ERROR

一个定义新的整数类型的技巧是简单的定义一个以某个已有整数类型作为底层类型的枚举类型, 就像上面例子中的MyInt一样。 这个特性的动机之一就是为了支持这个技巧,如果没有这个特性,在不进行转换的情况下将无法初始化新的对象。

事实上自从C++17起标准库提供的std::byte就直接使用了这个特性。

8.4 修正auto类型的列表初始化

自从在C++11中引入了花括号 统一 初始化之后, 每当使用auto代替明确类型进行列表初始化时就会出现一些和直觉不一致的结果:

  1. int x{42}; // 初始化一个int
  2. int y{1, 2, 3}; // ERROR
  3. auto a{42}; // 初始化一个std::initializer_list<int>
  4. auto b{1, 2, 3}; // OK:初始化一个std::initializer_list<int>

这些 直接 使用列表初始化(没有使用=)时的不一致行为现在已经被修复了。 因此如下代码的行为变成了:

  1. int x{42}; // 初始化一个int
  2. int y{1, 2, 3}; // ERROR
  3. auto a{42}; // 现在初始化一个int
  4. auto b{1, 2, 3}; // 现在ERROR

注意这是一个 破坏性的更改(breaking change) ,因为它可能导致很多代码的行为在 无声无息中发生改变。因此,支持了这个变更的编译器现在即使在C++11模式下也会启用这个变更。 对于主流编译器,接受这个变更的版本分别是Visual Studio 2015,g++5,clang3.8。

注意当使用auto进行 拷贝列表初始化 (使用了=)时仍然是初始化一个 std::initializer_list<>

  1. auto c = {42}; // 仍然初始化一个std::initializer_list<int>
  2. auto d = {1, 2, 3}; // 仍然OK:初始化一个std::initializer_list<int>

因此,现在直接初始化(没有=)和拷贝初始化(有=)之间又有了显著的不同:

  1. auto a{42}; // 现在初始化一个int
  2. auto c = {42}; // 仍然初始化一个std::initializer_list<int>

这也是更推荐使用直接列表初始化(没有=的花括号初始化)的原因之一。

8.5 十六进制浮点数字面量

C++17允许指定十六进制浮点数字面量(有些编译器甚至在C++17之前就已经支持)。 当需要一个精确的浮点数表示时这个特性非常有用(如果直接用十进制的浮点数字面量不保证 存储的实际精确值是多少)。

例如:

  1. #include <iostream>
  2. #include <iomanip>
  3. int main()
  4. {
  5. // 初始化浮点数
  6. std::initializer_list<double> values {
  7. 0x1p4, // 16
  8. 0xA, // 10
  9. 0xAp2, // 40
  10. 5e0, // 5
  11. 0x1.4p+2, // 5
  12. 1e5, // 100000
  13. 0x1.86Ap+16, // 100000
  14. 0xC.68p+2, // 49.625
  15. };
  16. // 分别以十进制和十六进制打印出值:
  17. for (double d : values) {
  18. std::cout << "dec: " << std::setw(6) << std::defaultfloat << d
  19. << " hex: " << std::hexfloat << d << '\n';
  20. }
  21. }

程序通过使用已有的和新增的十六进制浮点记号定义了不同的浮点数值。 新的记号是一个以2为基数的科学记数法记号:

  • 有效数字/尾数用十六进制书写
  • 指数部分用十进制书写,表示乘以2的n次幂

例如0xAp2的值为40(10*2^2)。这个值也可以被写作0x1.4p+5, 也就是1.25*32(0.4是十六进制的分数,等于十进制的0.25,2^5=32)。

程序的输出如下:

  1. dec: 16 hex: 0x1p+4
  2. dec: 10 hex: 0x1.4p+3
  3. dec: 40 hex: 0x1.4p+5
  4. dec: 5 hex: 0x1.4p+2
  5. dec: 5 hex: 0x1.4p+2
  6. dec: 100000 hex: 0x1.86ap+16
  7. dec: 100000 hex: 0x1.86ap+16
  8. dec: 49.625 hex: 0x1.8dp+5

就像上例展示的一样,十六进制浮点数的记号很早就存在了, 因为输出流使用的std::hexfloat操作符自从C++11起就已经存在了。

8.6 UTF-8字符字面量

自从C++11起,C++就已经支持以u8为前缀的UTF-8字符串字面量。 然而,这个前缀不能用于字符字面量。C++17修复了这个问题,所以现在可以这么写:

  1. auto c = u8'6'; // UTF-8编码的字符6

在C++17中,u8'6'的类型是char,在C++20中可能会变为char8_t, 因此这里使用auto会更好一些。

通过使用该前缀现在可以保证字符值是UTF-8编码。你可以使用所有的7位的US-ASCII字符, 这些字符的UTF-8表示和US-ASCII表示完全相同。 也就是说,u8'6'也是有效的以7位US-ASCII表示的字符’6’ (也是有效的ISO Latin-1、ISO-8859-15、基本Windows字符集中的字符)。

通常情况下你的源码字符被解释为US-ASCII或者UTF-8的结果是一样的,所以这个前缀并不是必须的。 c的值永远是54(十六进制36)。

这里给出一些背景知识来说明这个前缀的必要性:对于源码中的字符和字符串字面量, C++标准化了你可以使用的字符而不是这些字符的值。这些值取决于 源码字符集 。 当编译器为源码生成可执行程序时它使用 运行字符集 。源码字符集几乎总是7位的 US-ASCII编码,而运行字符集通常是相同的。这意味着在任何C++程序中,所有相同的字符和字符串字面量 (不管有没有u8前缀)总是有相同的值。

然而,在一些特别罕见的场景中并不是这样的。例如,在使用EBCDIC字符集的旧的IBM机器上,字符’6’ 的值将是246(十六进制为F6)。在一个使用EBCDIC字符集的程序中上面的字符c的值将是 246而不是54,如果在UTF-8编码的平台上运行这个程序可能会打印出字符ö,这个字符在ISO/IEC 8859-x 编码中的值为246.在这种情况下,这个前缀就是必须的。

注意u8只能用于单个字符,并且该字符的UTF-8编码必须只占一个字节。一个如下的初始化:

  1. char c = u8'ö';

是不允许的,因为德语的曲音字符ö的UTF-8编码是两个字节的序列,分别是195和182(十六进制为C3 B6)。

因此,字符和字符串字面量现在接受如下前缀:

  • u8用于单字节US-ASCII和UTF-8编码
  • u用于两字节的UTF-16编码
  • U用于四字节的UTF-32编码
  • L用于没有明确编码的宽字符,可能是两个或者四个字节

8.7 异常声明作为类型的一部分

自从C++17之后,异常处理声明变成了函数类型的一部分。也就是说,如下的两个函数的类型是不同的:

  1. void fMightThrow();
  2. void fNoexcept() noexcept; // 不同类型

在C++17之前这两个函数的类型是相同的。这样的一个问题就是如果把一个可能抛出异常的函数赋给 一个保证不抛出异常的函数指针,那么调用时有可能会抛出异常:

  1. void (*fp)() noexcept; // 指向不抛异常的函数的指针
  2. fp = fNoexcept; // OK
  3. fp = fMightThrow; // 自从C++17起ERROR

把一个不会抛出异常的函数赋给一个可能抛出异常的函数指针仍然是有效的:

  1. void (*fp2)(); // 指向可能抛出异常的函数的指针
  2. fp2 = fNoexcept; // OK
  3. fp2 = fMightThrow; // OK

因此,如果程序中只使用了没有noexcept声明的函数指针,那么将不会受该特性影响。 但请注意现在不能再违反函数指针中的noexcept声明(这可能会善意的破坏现有的程序)。

重载一个签名完全相同只有异常声明不同的函数是不允许的(就像不允许重载只有返回值不同的函数一样):

  1. void f3();
  2. void f3() noexcept; // ERROR

注意其他的规则不受影响。例如,你仍然不能忽略基类中的noexcept声明:

  1. class Base {
  2. public:
  3. virtual void foo() noexcept;
  4. ...
  5. };
  6. class Derived : public Base {
  7. public:
  8. void foo() override; // ERROR:不能重载
  9. ...
  10. };

这里,派生类中的成员函数foo()和基类中的foo()类型不同所以不能重载。 这段代码不能通过编译,即使没有override修饰符代码也不能编译,因为我们不能用 更宽松的异常声明重载。

使用传统的异常声明

当使用传统的noexcept声明时,函数的是否抛出异常取决于条件为true还是false:

  1. void f1();
  2. void f2() noexcept;
  3. void f3() noexcept(sizeof(int)<4); // 和f1()或f2()的类型相同
  4. void f4() noexcept(sizeof(int)>=4); // 和f3()的类型不同

这里f3()的类型取决于条件的值:

  • 如果sizeof(int)返回4或者更大,签名等价于
    1. void f3() noexcept(false); // 和f1()类型相同
  • 如果sizeof(int)返回的值小于4,签名等价于
    1. void f3() noexcept(true); // 和f2()类型相同

因为f4()的异常条件和f3()的恰好相反,所以f3()f4() 的类型总是不同(也就是说,它们一定是一个可能抛异常,另一个不可能抛异常)。

旧式的不抛异常的声明仍然有效但自从C++11起就被废弃:

  1. void f5() throw(); // 和void f5() noexcept等价但已经被废弃

带参数的动态异常声明不再被支持(自从C++11起被废弃):

  1. void f6() throw(std::bad_alloc); // ERROR:自从C++17起无效

对泛型库的影响

noexcept做为类型的一部分意味着会对泛型库产生一些影响。例如,下面的代码 直到C++14是有效的,但从C++17起不能再通过编译:

  1. #include <iostream>
  2. template<typename T>
  3. void call(T op1, T op2)
  4. {
  5. op1();
  6. op2();
  7. }
  8. void f1() {
  9. std::cout << "f1()\n";
  10. }
  11. void f2() noexcept {
  12. std::cout << "f2()\n";
  13. }
  14. int main()
  15. {
  16. call(f1, f2); // 自从C++17起ERROR
  17. }

问题在于自从C++17起f1()f2()的类型不再相同, 因此在实例化模板函数call()时编译器无法推导出类型T

自从C++17起,你需要指定两个不同的模板参数来通过编译:

  1. template<typename T1, typename T2>
  2. void call(T1 op1, T2 op2)
  3. {
  4. op1();
  5. op2();
  6. }

现在如果你想重载所有可能的函数类型,那么你需要重载的数量将是原来的两倍。 例如,对于标准库类型特征std::is_function<>的定义, 主模板的定义如下,该模板用于匹配T不是函数的情况:

  1. // 主模板(匹配泛型类型T不是函数的情况):
  2. template<typename T> struct is_function : std::false_type { };

该模板从std::false_type派生, 因此is_function<T>::value对任何类型T都会返回false

对于任何 函数的类型,存在从std::true_type 派生的部分特化版,因此成员value总是返回true

  1. // 对所有函数类型的部分特化版
  2. template<typename Ret, typename... Params>
  3. struct is_function<Ret (Params...)> : std::true_type { };
  4. template<typename Ret, typename... Params>
  5. struct is_function<Ret (Params...) const> : std::true_type { };
  6. template<typename Ret, typename... Params>
  7. struct is_function<Ret (Params...) &> : std::true_type { };
  8. template<typename Ret, typename... Params>
  9. struct is_function<Ret (Params...) const &> : std::true_type { };
  10. ...

在C++17之前该特征总共有24个部分特化版本:因为函数类型可以用constvolatile修饰符修饰,另外还可能有左值引用(&)或右值引用(&&)修饰符, 还需要重载可变参数列表的版本。

现在在C++17中部分特化版本的数量变为了两倍,因为还需要为所有版本添加一个带noexcept 修饰符的版本:

  1. ...
  2. // 对所有带有noexcept声明的函数类型的部分特化版本
  3. template<typename Ret, typename... Params>
  4. struct is_function<Ret (Params...) noexcept> : std::true_type { };
  5. template<typename Ret, typename... Params>
  6. struct is_function<Ret (Params...) const noexcept> : std::true_type { };
  7. template<typename Ret, typename... Params>
  8. struct is_function<Ret (Params...) & noexcept> : std::true_type { };
  9. template<typename Ret, typename... Params>
  10. struct is_function<Ret (Params...) const & noexcept> : std::true_type { };
  11. ...

那些没有实现noexcept重载的库可能在需要使用带有 noexcept的函数的场景中不能通过编译了。

8.8 单参数static_assert

自从C++17起,以前static_assert()要求的用作错误信息的参数变为可选的了。 也就是说现在断言失败时输出的诊断信息完全依赖平台的实现。例如:

  1. #include <type_traits>
  2. template<typename t>
  3. class C {
  4. // 自从C++11起OK
  5. static_assert(std::is_default_constructible<T>::value,
  6. "class C: elements must be default-constructible");
  7. // 自从C++17起OK
  8. static_assert(std::is_default_constructible_v<T>);
  9. ...
  10. };

不带错误信息参数的新版本静态断言的示例也使用了类型特征后缀_v

8.9 预处理条件__has_include

C++17扩展了预处理,增加了一个检查某个头文件是否可以被包含的宏。例如:

  1. #if __has_include(<filesystem>)
  2. # include <filesystem>
  3. # define HAS_FILESYSTEM 1
  4. #elif __has_include(<experimental/filesystem>)
  5. # include <experimental/filesystem>
  6. # define HAS_FILESYSTEM 1
  7. # define FILESYSTEM_IS_EXPERIMENTAL 1
  8. #elif __has_include("filesystem.hpp")
  9. # include "filesystem.hpp"
  10. # define HAS_FILESYSTEM 1
  11. # define FILESYSTEM_IS_EXPERIMENTAL 1
  12. #else
  13. # define HAS_FILESYSTEM 0
  14. #endif

当相应的#include指令有效时__has_include(...)会被求值为1。 其他的因素都不会影响结果(例如,相应的头文件是否已被包含过并不影响结果)。

另外,虽然求值为真可以说明相应的头文件确实存在但不能保证它的内容符合预期。 它的内容可能是空的或者无效的。

__has_include是一个纯粹的预处理指令。 所以不能将它用作源码里的条件表达式:

  1. if (__has_include(<filesystem>)) { // ERROR
  2. }