第21章 核心语言的小改进
# 第21章 核心语言的小改进
本章介绍C++20为核心语言引入的其他特性和扩展,这些内容在本书之前尚未提及。
下一章将介绍泛型编程的其他小特性。
# 21.1 带初始化的基于范围的for循环
C++17为if
和switch
控制结构引入了可选的初始化功能。C++20现在为基于范围的for
循环也引入了这种可选的初始化。
例如,以下代码在遍历集合元素的同时递增计数器:
for (int i = 1; const auto& elem : coll) {
std::cout << std::format("{:3}: {}\n", i, elem);
++i;
}
2
3
4
再比如,这段代码遍历目录dirname
中的条目:
for (std::filesystem::path p{dirname};
const auto& e : std::filesystem::directory_iterator{p}) {
std::cout << " " << e.path().lexically_normal().string() << "\n";
}
2
3
4
还有一个例子,以下代码在遍历集合时锁定互斥锁:
for (std::lock_guard lg{collMx}; const auto& elem : coll) {
std::cout << "elem: " << elem << "\n";
}
2
3
注意,控制结构中初始化器的常见注意事项同样适用于此:初始化器需要声明一个有名称的变量。否则,初始化本身就是一个创建并立即销毁临时对象的表达式。因此,未命名的锁守卫(lock guard)初始化是一个逻辑错误,因为在迭代发生时,该守卫不再起锁定作用:
for (std::lock_guard{collMx}; const auto& elem : coll) { // 运行时错误
std::cout << "elem: " << elem << "\n"; // - 不再锁定
}
2
3
带初始化的基于范围的for
循环还可以作为解决基于范围的for
循环中一个缺陷的方法。根据其规范,在遍历对临时对象的引用时,使用基于范围的for
循环可能会导致(致命的)运行时错误(这个问题自2009年就已为人所知(见http://wg21.link/cwg900 (opens new window))。然而,C++标准委员会到目前为止还不愿意按照例如http://wg21.link/p2012 (opens new window)中所提议的那样修复这个缺陷。)。例如:
std::optional<std::vector<int>> getValues(); // 前置声明
for (int i : getValues().value()) { // 致命运行时错误
...
}
2
3
4
使用带初始化的基于范围的for
循环可以避免这个问题:
std::optional<std::vector<int>> getValues(); // 前置声明
for (auto&& optColl = getValues(); int i : optColl) { // 没问题
...
}
2
3
4
同样,你可以通过这种方式修复使用跨度(span)时出现的错误迭代问题:
for (auto elem : std::span{getCollOfConst()}) ... // 致命运行时错误
for (auto&& coll = getCollOfConst(); auto elem : std::span{coll}) ... // 没问题
2
# 21.2 对枚举值使用using
假设我们有一个作用域枚举类型(使用enum class
声明):
enum class Status{open, progress, done = 9};
与无作用域枚举类型(不带class
的enum
)不同,这种类型的值需要用其类型名称进行限定:
auto x = Status::open; // 没问题
auto x = open; // 错误
2
然而,在某些明显不会产生冲突的上下文中,始终对每个值进行限定可能会有点繁琐。为了更方便地使用作用域枚举类型,现在可以使用using enum
声明。
一个典型的例子是对所有可能的枚举值进行switch
操作。现在可以这样实现:
void print(Status s) {
switch (s) {
using enum Status; // 使枚举值在当前作用域可用
case open:
std::cout << "open ";
break;
case progress:
std::cout << "in progress ";
break;
case done:
std::cout << "done ";
break;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
只要在print()
的作用域中没有声明与open
、progress
或done
同名的其他符号,这段代码就能正常工作。
现在还可以对特定的枚举值使用多个using
声明:
void print(Status s) {
switch (s) {
using Status::open, Status::progress, Status::done;
case open:
std::cout << "open ";
break;
case progress:
std::cout << "in progress ";
break;
case done:
std::cout << "done ";
break;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
通过这种方式,你可以确切地知道在当前作用域中哪些名称是可用的。
注意,也可以对无作用域枚举类型使用using
声明。虽然这不是必需的,但这样做的话,你就不必知道枚举类型是如何定义的:
enum Status{open, progress, done = 9}; // 无作用域枚举
auto s1 = open; // 没问题
auto s2 = Status::open; // 没问题
using enum Status; // 没问题,但没有效果
auto s3 = open; // 没问题
auto s4 = Status::open; // 没问题
2
3
4
5
6
7
8
# 21.3 将枚举类型委托到不同作用域
using enum
声明也可用于将枚举值委托到不同作用域。例如:
namespace MyProject {
class Task {
public:
enum class Status{open, progress, done = 9};
Task();
...
};
using enum Task::Status; // 将Status的值暴露到MyProject作用域
}
auto x = MyProject::open; // 正确:x的值为MyProject::Task::open
auto y = MyProject::done; // 正确:y的值为MyProject::Task::done
2
3
4
5
6
7
8
9
10
11
12
13
请注意,using enum
声明仅暴露值,不暴露类型:
MyProject::Status s; // 错误
要同时暴露类型及其值,还需要一个普通的using
声明(类型别名):
namespace MyProject {
using Status = Task::Status; // 暴露Task::Status类型
using enum Task::Status; // 暴露Task::Status的值
}
MyProject::Status s = MyProject::done; // 正确
2
3
4
5
6
对于暴露的枚举值,甚至参数依赖查找(ADL)也能正常工作。例如,我们可以将上述示例扩展如下:
namespace MyProject {
void foo(MyProject::Task::Status) {
}
}
namespace MyScope {
using enum MyProject::Task::Status; // 正确
}
foo(MyProject::done); // 正确:使用MyProject::Task::Status::done调用MyProject::foo()
foo(MyScope::done); // 正确:使用MyProject::Task::Status::done调用MyProject::foo()
2
3
4
5
6
7
8
9
10
11
注意,类型别名通常不会被参数依赖查找(ADL)使用:
namespace MyScope {
void bar(MyProject::Task::Status) { }
using MyProject::Task::Status; // 将枚举类型暴露到MyScope
using enum MyProject::Task::Status; // 将枚举值暴露到MyScope
}
MyScope::Status s = MyScope::open; // 正确
bar(MyScope::done); // 错误
MyScope::bar(MyScope::done); // 正确
2
3
4
5
6
7
8
9
# 21.4 新字符类型char8_t
为了更好地支持UTF - 8编码,C++20引入了新的字符类型char8_t
以及新的相应字符串类型std::u8string
。
char8_t
是一个新关键字。它用于存储UTF - 8字符和字符序列。例如:
char8_t c = u8'@'; // 字符@的UTF-8编码字符
const char8_t* s = u8"K\u00F6ln"; // "Köln"的UTF-8编码字符序列
2
这个类型的引入是一个不兼容变更:
u8
字符字面量现在使用char8_t
而不是char
。- 对于UTF - 8字符串,现在使用新类型
std::u8string
和std::u8string_view
。
考虑以下示例:
auto c = u8'@';
auto s1 = u8"K\u00F6ln";
using namespace std::literals;
auto s2 = u8"K\u00F6ln"s;
auto sv = u8"K\u00F6ln"sv;
2
3
4
5
这里c
和s
的类型发生了变化:
- 在C++20之前,这等效于:
char c = u8'@'; // @的UTF-8编码字符
const char* s1 = u8"K\u00F6ln"; // "Köln"的UTF-8编码字符序列
using namespace std::literals;
std::string s2 = u8"K\u00F6ln"s; // C++20之前的UTF-8字符串类型
std::string_view sv = u8"K\u00F6ln"s; // C++20之前的UTF-8字符串视图类型
2
3
4
5
- 自C++20起,这等效于:
char8_t c = u8'@'; // C++20起的UTF-8字符类型
const char8_t* s1 = u8"K\u00F6ln"; // C++20起的UTF-8字符序列类型
using namespace std::literals;
std::u8string s2 = u8"K\u00F6ln"s; // C++20起的UTF-8字符串类型
std::u8string_view sv = u8"K\u00F6ln"sv; // C++20起的UTF-8字符串视图类型
2
3
4
5
做出这个变更的原因很简单:我们现在可以为UTF - 8字符和字符串实现特殊行为:
- 我们可以为UTF - 8字符序列重载函数:
void store(const char* s) {
storeInFile(convertToUTF8(s)); // 转换为UTF-8编码后存储
}
void store(const char8_t* s) {
storeInFile(s); // 直接存储,因为它已经是UTF-8编码
}
2
3
4
5
6
7
- 我们可以在泛型代码中实现特殊行为:
void store(const CharT* s) {
if constexpr(std::same_as<CharT, char8_t>) {
storeInFile(s); // 直接存储,因为它已经是UTF-8编码
} else {
storeInFile(convertToUTF8(s)); // 转换为UTF-8编码后存储
}
}
2
3
4
5
6
7
你仍然只能将单字节的UTF - 8字符存储在char8_t
类型的对象中(请记住,UTF - 8字符的宽度是可变的):
- 符号
@
的十进制值为64(十六进制为0x40
)。因此,你可以将其值存储在char8_t
中,所以u8
字符字面量是定义良好的:
char8_t c = u8'@'; // 正确(c的值为64)
- 欧元符号
€
由三个代码单元组成:226 130 172(十六进制:0xE2 0x82 0xAC
)。因此,你不能将其值存储在char8_t
中:
char8_t cEuro = u8'€'; // 错误:无效的字符字面量
相反,你必须初始化一个字符序列或UTF - 8字符串:
const char8_t* cEuro = u8"\u20AC"; // 正确
std::u8string sEuro = u8"\u20AC"; // 正确
2
这里,我们使用Unicode表示法指定UTF - 8字符的值,这会创建一个包含四个const char8_t
(包括末尾的空字符)的数组,然后用于初始化cEuro
和sEuro
。
如果你的编译器接受源文件中的€
字符(这意味着它必须支持如UTF - 8或ISO - 8859 - 15这样的字符集的源文件编码),你甚至可以直接在字面量中使用这个符号:
const char8_t* cEuro = u8"€"; // 如果编译器支持该字符,则正确
std::u8string sEuro = u8"€"; // 如果编译器支持该字符,则正确
2
然而,由于这样的源代码不具备可移植性,你应该使用Unicode字符(例如用\u20AC
表示€
符号)。
注意,C++标准库中的一些内容也相应发生了变化:
- 添加了针对新字符类型
char8_t
的重载。 - 使用或返回UTF - 8字符串的函数现在使用新的UTF - 8字符串类型。
此外,请注意,char8_t
并不保证有8位。它在内部被定义为使用unsigned char
,通常unsigned char
有8位,但也可能更多。和往常一样,你可以使用std::numeric_limits<>
来检查位数:
std::cout << "char8_t has "
<< std::numeric_limits<char8_t>::digits << " bits\n";
2
# 21.4.1 C++标准库对char8_t
的支持变化
C++标准库为支持char8_t
做出了以下改变:
- 现在提供了
u8string
(定义为std::basic_string<char8_t>
)。 - 现在提供了
u8string_view
(定义为std::basic_string_view<char8_t>
)。 - 现在定义了
std::numeric_limits<char8_t>
。 - 现在定义了
std::char_traits<char8_t>
。 - 现在提供了针对
char8_t
字符串和字符串视图的哈希函数。 - 现在提供了
std::mbrtoc8()
和c8rtomb()
函数。 - 现在提供了用于
char8_t
与char16_t
或char32_t
之间转换的编解码面(codecvt facets)。 - 对于文件系统路径,
u8string()
现在返回std::u8string
而不是std::string
。 - 现在提供了
std::atomic<char8_t>
。
需要注意的是,这些变化可能会导致在使用C++20编译现有代码时出现问题,下面将对此进行讨论。
# 21.4.2 向后兼容性问题
由于C++20改变了UTF-8字面量的类型以及返回UTF-8字符串的函数签名,使用UTF-8字符的代码可能无法再编译通过。
# 使用字符类型时的代码错误
例如:
std::string s0 = u8"text"; // C++17中正确,自C++20起错误
auto s = u8"K\u00F6ln"; // 在C++17中s是const char*,自C++20起是const char8_t*
const char* s2 = s; // C++17中正确,自C++20起错误
std::cout << s << '\n'; // C++17中正确,自C++20起错误
auto c = u8'c'; // 在C++17中c是char,自C++20起是char8_t
char c2 = c; // (即使c是char8_t也正确)
char* cp = &c; // C++17中正确,自C++20起错误
std::cout << c; // C++17中正确,自C++20起错误
2
3
4
5
6
7
8
# 使用新字符串类型时的代码错误
特别是,返回UTF-8字符串的代码现在可能会引发问题。
例如,以下代码无法再编译,因为u8string()
现在返回std::u8string
而不是std::string
:
// 遍历目录项:
for (const auto& entry : fs::directory_iterator(path)) {
std::string name = entry.path().u8string(); // C++17中正确,自C++20起错误
// ...
}
2
3
4
5
你必须通过使用不同的类型、auto
,或者同时支持两种类型来调整代码:
// 遍历目录项(C++17和C++20):
for (const auto& entry : fs::directory_iterator(path)) {
#ifdef cpp_char8_t
std::u8string name = entry.path().u8string(); // 自C++20起正确
#else
std::string name = entry.path().u8string(); // C++17中正确
#endif
// ...
}
2
3
4
5
6
7
8
9
# UTF-8字符串的输入输出代码错误
你也不能再将UTF-8字符或字符串输出到std::cout
(或任何其他标准输出流):
std::cout << u8"text"; // C++17中正确,自C++20起错误
std::cout << u8'X'; // C++17中正确,自C++20起错误
2
实际上,C++20删除了所有扩展字符类型(wchar_t
、char8_t
、char16_t
、char32_t
)的输出运算符,除非输出流支持相同的字符类型:
std::cout << "text "; // 正确
std::cout << L "text "; // wchar_t字符串:C++17中输出异常,自C++20起错误
std::cout << u8 "text "; // UTF-8字符串:C++17中正确,自C++20起错误
std::cout << u "text "; // UTF-16字符串:C++17中输出异常,自C++20起错误
std::cout << U "text "; // UTF-32字符串:C++17中输出异常,自C++20起错误
std::wcout << "text "; // 正确
std::wcout << L "text "; // 正确
std::wcout << u8 "text "; // UTF-8字符串:C++17中正确,自C++20起错误
std::wcout << u "text "; // UTF-16字符串:C++17中输出异常,自C++20起错误
std::wcout << U "text "; // UTF-32字符串:C++17中输出异常,自C++20起错误
2
3
4
5
6
7
8
9
10
注意标记为“输出异常”的语句:它们在C++17中都能编译,但输出的是字符串的地址而不是其值。因此,C++20不仅禁用了UTF-8字符的输出,还禁用了那些原本就无法正常工作的输出。
# 处理错误代码
你可能想知道如何处理以前能处理UTF-8字符的代码。最简单的方法是使用reinterpret_cast<>
:
auto s = u8"text"; // 自C++20起`s`是`const char8_t*`
std::cout << s; // 自C++20起错误
std::cout << reinterpret_cast<const char*>(s); // 正确
2
3
对于单个字符,使用static_cast
就足够了:
auto c = u8'x'; // 自C++20起`c`是`char8_t`
std::cout << c; // 自C++20起错误
std::cout << static_cast<char>(c); // 正确
2
3
你可以将其使用或其他解决方法的使用与char8_t
字符特性的特性测试宏绑定:
auto s = u8 "text "; // 自C++20起`s`是`const char8_t*`
#ifdef __cpp_char8_t
std::cout << reinterpret_cast<const char *>(s); // 正确
#else
std::cout << s; // C++17中正确,自C++20起错误
#endif
2
3
4
5
6
如果你想知道为什么C++20没有为UTF-8字符提供可用的输出运算符,请注意这是一个相当复杂的问题,目前还没有足够的时间来解决。你可以在这里了解更多相关信息:http://stackoverflow.com/a/58895428 (opens new window)。
由于在大量使用UTF-8字符时,使用reinterpret_cast<>
可能并不适用,汤姆·霍纳曼(Tom Honermann)撰写了一份指南,介绍如何处理C++20前后与UTF-8字符相关的代码:“P1423R3 char8_t Backward Compatibility Remediation”。如果你处理UTF-8字符和字符串,一定要阅读这份指南。你可以从http://wg21.link/p1423 (opens new window)下载它。
对于C++20指定的最终行为,cpp_char8_t
至少应该取值为201907。
# 21.5 聚合体(Aggregates)的改进
C++20对聚合体进行了一些改进,本节将对此进行描述:
- (部分)支持指定初始化器(为特定成员指定初始值)。
- 可以使用括号初始化聚合体。
- 聚合体的固定定义以及对
std::is_default_constructible<>
的影响。
此外,本书的其他章节还描述了聚合体在泛型代码中使用时的其他新特性:
- 对聚合体使用类模板参数推导(CTAD,class template argument deduction)。
- 聚合体可用作非类型模板参数(NTTP,non-type template parameters)。
# 21.5.1 指定初始化器
对于聚合体,C++20提供了一种指定用传递的初始值初始化哪个成员的方法。不过,你只能用它来跳过某些参数的初始化。
例如,假设我们有以下聚合体类型:
struct Value {
double amount = 0;
int precision = 2;
std::string unit = "Dollar ";
};
2
3
4
5
那么,现在支持以下初始化该类型值的方式:
Value v1{100}; // 正确(非指定初始化器)
Value v2{ .amount = 100, .unit = "Euro "}; // 正确(第二个成员使用默认值)
Value v3{ .precision = 8, .unit = "$ "}; // 正确(第一个成员使用默认值)
2
3
完整示例见lang/designated.cpp
。请注意以下限制:
- 必须使用
=
或{}
传递初始值。 - 可以跳过成员,但必须遵循成员顺序。按名称初始化的成员顺序必须与声明中的顺序一致。
- 必须对所有参数都使用指定初始化器,或者都不使用。不允许混合初始化。
- 不支持对数组使用指定初始化器。
- 可以进行嵌套指定初始化,但不能直接使用
.mem.mem
的形式。 - 使用括号初始化聚合体时不能使用指定初始化器。
- 指定初始化器也可用于联合(unions)。
例如:
Value v4{100, .unit = "Euro "}; // 错误:必须全部或都不使用指定初始化器
Value v5{ .unit = "$ " , .amount = 20}; // 错误:顺序无效
Value v6(.amount = 29.9, .unit = "Euro "); // 错误:仅大括号初始化支持指定初始化器
2
3
指定初始化器需遵循成员顺序,要么对所有参数都使用指定初始化器,要么都不使用,不支持直接嵌套,并且不支持数组,与编程语言C相比,这些都是限制。遵循成员顺序的原因是为了确保初始化顺序与构造函数的调用顺序一致(这与析构函数的调用顺序相反)。
下面是一个使用=
、{}
和联合体进行嵌套初始化的示例:
union Sub {
double x = 0;
int y = 0;
};
struct Data {
std::string name;
Sub val;
};
Data d1{.val{ .y=42}}; // 正确
Data d2{.val = { .y{42}}}; // 正确
2
3
4
5
6
7
8
9
10
11
12
不能直接嵌套指定初始化器:
Data d2{ .val .y = 42}; // 错误
# 21.5.2 带括号的聚合初始化
假设你声明了以下聚合类型:
struct Aggr {
std::string msg;
int val;
};
2
3
4
在C++20之前,只能使用花括号对聚合类型进行赋值初始化:
Aggr a0; // 正确,但未初始化
Aggr a1{}; // 正确,用""和0进行值初始化
Aggr a2{ "hi "}; // 正确,用"hi"和0初始化
Aggr a3{ "hi " , 42}; // 正确,用"hi"和42初始化
Aggr a4 = {}; // 正确,用""和0初始化
Aggr a5 = { "hi "}; // 正确,用"hi"和0初始化
Aggr a6 = { "hi " , 42}; // 正确,用"hi"和42初始化
2
3
4
5
6
7
自C++20起,也可以使用括号作为直接初始化的外部符号(不用=
):
Aggr a7( "hi "); // 自C++20起正确:用"hi"和0初始化
Aggr a8( "hi " , 42); // 自C++20起正确:用"hi"和42初始化
Aggr a9({ "hi " , 42}); // 自C++20起正确:用"hi"和42初始化
2
3
使用=
或内部括号仍然不行:
Aggr a10 = "hi " ; // 错误
Aggr a11 = ( "hi " , 42); // 错误
Aggr a12(( "hi " , 42)); // 错误
2
3
使用内部括号甚至可能会编译通过。在这种情况下,它们会被当作围绕使用逗号运算符的表达式的普通括号。
注意,甚至可以使用括号初始化未知大小的数组:
int a1[]{1, 2, 3}; // 自C++11起正确
int a2[](1, 2, 3); // 自C++20起正确
int a3[] = {1, 2, 3}; // 正确
int a4[] = (1, 2, 3); // 仍然错误
2
3
4
然而,不支持“省略花括号”(没有嵌套花括号可省略):
struct Arr {
int elem[10];
};
Arr arr1{1, 2, 3}; // 正确
Arr arr2(1, 2, 3); // 错误
Arr arr3{{1, 2, 3}}; // 正确
Arr arr4({1, 2, 3}); // 正确(甚至在C++20之前就正确)
2
3
4
5
6
7
8
因此,要初始化std::array
,仍然需要使用花括号:
std::array<int,3> a1{1, 2, 3}; // 正确:std::array{{1, 2, 3}}的简写
std::array<int,3> a2(1, 2, 3); // 仍然错误
2
# 支持带括号的聚合初始化的原因
支持带括号的聚合初始化的原因是,这允许你使用括号调用operator new
:
struct Aggr {
std::string msg;
int val;
};
auto p1 = new Aggr{ "Rome " , 200}; // 自C++11起正确
auto p2 = new Aggr( "Rome " , 200); // 自C++20起正确(在C++20之前错误)
2
3
4
5
6
7
这有助于在内部使用括号调用new
以将值存储在现有内存中的类型(如容器和智能指针)中使用聚合类型。事实上,自C++20起,以下操作是可行的:
- 现在可以对聚合类型使用
std::make_unique<>()
和std::make_shared<>()
:
auto up = std::make_unique<Aggr>( "Rome " , 200); // 自C++20起正确
auto sp = std::make_shared<Aggr>( "Rome " , 200); // 自C++20起正确
2
在C++20之前,无法对聚合类型使用这些辅助函数。
- 现在可以将新值插入到聚合类型的容器中:
std::vector<Aggr> cont;
cont.emplace_back( "Rome " , 200); // 自C++20起正确
2
注意,仍然有一些类型不能用括号初始化,但可以用花括号初始化:作用域枚举(enum class
类型)。std::byte
类型(自C++17引入)就是一个例子:
std::byte b1{0}; // 正确
std::byte b2(0); // 仍然错误
auto upb2 = std::make_unique<std::byte>(0); // 仍然错误
auto upb3 = std::make_unique<std::byte>(std::byte{0}); // 正确
2
3
4
对于std::array
,仍然需要使用花括号(如上文所述):
std::vector<std::array<int, 3>> ca;
ca.emplace_back(1, 2, 3); // 错误
ca.emplace_back({1, 2, 3}); // 错误
ca.push_back({1, 2, 3}); // 仍然正确
2
3
4
# 带括号的聚合初始化详解
引入带括号初始化的提案列出了以下设计准则:
Type(val)
的任何现有含义不应改变。- 带括号的初始化和带花括号的初始化应尽可能相似,但也需要有必要的区别,以符合现有的花括号列表和带括号列表的思维模式。
实际上,带花括号的聚合初始化和带括号的聚合初始化之间存在以下差异:
- 带括号的初始化不会检测窄化转换。
- 带括号的初始化允许所有隐式转换(不仅仅是从派生类到基类的转换)。
- 使用括号时,引用成员不会延长传递的临时对象的生命周期。
- 使用括号不支持省略花括号(使用括号类似于给参数传递参数)。
- 即使对于显式成员,使用空括号进行初始化也可行。
- 使用括号不支持指定初始化器。
下面是一个未检测窄化的示例:
struct Aggr {
std::string msg;
int val;
};
Aggr a1{"hi " , 1.9}; // 错误:窄化
Aggr a2("hi " , 1.9); // 正确,但初始化为1
std::vector<Aggr> cont;
cont.emplace_back( "Rome " , 1.9); // 初始化为1
2
3
4
5
6
7
8
9
10
注意,emplace
函数从不检测窄化。
下面是一个处理隐式转换时差异的示例:
struct Other {
...
operator Aggr(); // 定义到Aggr的隐式转换
};
Other o;
Aggr a7{o}; // 错误:不支持隐式转换
Aggr a8(o); // 正确,隐式转换可行
2
3
4
5
6
7
8
注意,聚合类型本身不能定义这些转换,因为聚合类型不能有用户定义的构造函数。
缺少省略花括号以及花括号初始化的复杂规则导致了以下行为:
Aggr a01{ "x " , 65}; // 用"x"初始化字符串,用65初始化int
Aggr a02( "x " , 65); // 自C++20起正确(效果相同)
Aggr a11{{ "x " , 65}}; // 运行时错误:"x"没有65个字符
Aggr a12({ "x " , 65}); // 即使在C++20之前也正确:用"x"初始化字符串,用65初始化int
Aggr a21{{{ "x " , 65}}}; // 错误:无法用"x"和65的初始化列表初始化字符串
Aggr a22({{ "x " , 65}}); // 运行时错误:"x"没有65个字符
Aggr a31{ 'x' , 65}; // 错误:无法用'x'初始化字符串
Aggr a32( 'x' , 65); // 错误:无法用'x'初始化字符串
Aggr a41{{ 'x' , 65}}; // 用'x'和char(65)初始化字符串
Aggr a42({ 'x' , 65}); // 自C++20起正确(效果相同)
Aggr a51{{{ 'x' , 65}}}; // 用'x'和char(65)初始化字符串
Aggr a52({{ 'x' , 65}}); // 即使在C++20之前也正确:用'x'和65初始化
Aggr a61{{{{ 'x' , 65}}}}; // 错误
Aggr a62({{{ 'x' , 65}}}); // 即使在C++20之前也正确:用'x'和65初始化
2
3
4
5
6
7
8
9
10
11
12
13
14
在进行复制初始化(使用=
初始化)时,explicit
很重要,空括号可能会产生不同的效果:
struct C {
explicit C() = default ;
};
struct A { // 聚合类型
int i;
C c;
};
auto a1 = A{42, C{}}; // 正确:显式初始化
auto a2 = A(42, C()); // 自C++20起正确:显式初始化
auto a3 = A{42}; // 错误:无法调用显式构造函数
auto a4 = A(42); // 错误:无法调用显式构造函数
auto a5 = A{}; // 错误:无法调用显式构造函数
auto a6 = A(); // 正确:可以调用显式构造函数
2
3
4
5
6
7
8
9
10
11
12
13
14
15
不过,这在C++20中并不是新特性,因为在C++20之前就已经支持对a6
的初始化。
最后,如果你对具有右值引用成员的聚合类型使用括号进行初始化,初始值的生命周期不会延长。因此,不应传递临时对象(纯右值):
struct A {
int a;
int&& r;
};
int f();
int n = 10;
A a1{1, f()}; // 正确,生命周期延长
std::cout << a1.r << "\n"; // 正确
A a2(1, f()); // 糟糕:悬空引用
std::cout << a2.r << "\n"; // 运行时错误
A a3(1, std::move(n)); // 只要在n存在时使用a3就正确
std::cout << a3.r << "\n"; // 正确
2
3
4
5
6
7
8
9
10
11
12
13
14
由于这些复杂的规则和陷阱,只有在必要时(例如使用std::make_unique<>()
、std::make_shared<>()
或emplace
函数时)才应使用带括号的聚合初始化。
# 21.5.3 聚合体的定义
C++20再次对聚合体的定义进行了修改。这一次,修改扭转了C++11引入的一个扩展,事实证明这个扩展是个错误。从C++11到C++17,用户提供的构造函数是不被允许的。因此,以下代码在当时是合法的聚合体定义:
struct A { // 从C++11到C++17是聚合体
...
A() = delete; // 用户声明但未提供的构造函数
};
2
3
4
根据这个定义,即使该类型有一个被删除的默认构造函数,聚合初始化也是有效的:
A a1; // 错误
A a2{}; // 从C++11到C++17正确
2
这不仅适用于默认构造函数。例如:
struct D { // 从C++11到C++17是聚合体
int i = 0;
D(int) = delete; // 用户声明但未提供的构造函数
};
D d1(3); // 错误
D d2{3}; // 从C++11到C++17正确
2
3
4
5
6
这种特殊行为是为了一个非常特殊的情况而引入的,但完全违背直觉6。C++20通过恢复到要求聚合体不能有用户声明的构造函数(和C++11之前一样)来修复这个问题:
struct A { // 自C++20起不是聚合体
...
A() = delete; // 用户声明但未提供的构造函数
};
A a1; // 错误
A a2{}; // 自C++20起错误
2
3
4
5
6
因此,对于上面声明的类型A
和D
,类型特性std::is_default_constructible_v<>
不再为true
。
不过,注意有些程序员确实利用这个特性强制在创建聚合类型对象时使用(可能为空的)花括号(这确实能确保成员总是被初始化)。对于他们来说,实现相同行为的解决方法是从一个基类派生,如下所示:
struct MustInit {
MustInit(MustInit&&) = default;
};
struct A : MustInit {
...
};
A a1; // 错误
A a2{}; // 正确
2
3
4
5
6
7
8
9
10
6这个特性作为一种支持原子类型初始化时与C兼容的 “技巧” 被引入。然而,甚至一些委员会成员都不知道这种行为,并且对此感到非常惊讶,结果发现这种支持实际上从未被需要过。
总之,自C++20起,聚合体的定义如下:
- 要么是一个数组;
- 要么是一个类类型(类、结构体或联合体),且满足:
- 没有用户声明的构造函数;
- 没有通过
using
声明继承的构造函数; - 没有私有或受保护的非静态数据成员;
- 没有虚函数;
- 没有虚基类、私有基类或受保护基类。
为了允许对聚合体进行初始化,还应用以下附加约束:
- 没有私有或受保护的基类成员;
- 没有私有或受保护的构造函数。
# 21.6 新属性和属性特性
自C++11起,就可以指定属性(用于启用或禁用警告的正式注释)。C++20又引入了新属性,并对现有属性进行了扩展。
# 21.6.1 [[likely]]
和[[unlikely]]
属性
C++20引入了新属性[[likely]]
和[[unlikely]]
,以帮助编译器进行分支优化。
当代码中有多个路径时,你可以使用这些属性向编译器提示哪个路径最有可能或不太可能被执行。
例如:
int f(int n) {
if (n <= 0) [[unlikely]] { // 认为n <= 0的可能性极低
return n;
}
else {
return n * n;
}
}
2
3
4
5
6
7
8
例如,这可能会促使编译器生成汇编代码,直接处理else
分支,而对于if
分支则跳转到后面的汇编指令。
它可能与下面的代码有相同的效果,但不能保证:
int f(int n) {
if (n <= 0) {
return n;
}
else [[likely]] { // 认为n > 0的可能性极高
return n * n;
}
}
2
3
4
5
6
7
8
再看另一个例子:
int g(int n) {
switch (n) {
case 1:
...
break;
[[likely]] case 2: // 认为n == 2的可能性极高
...
break;
}
...
}
2
3
4
5
6
7
8
9
10
11
这些属性的效果因编译器而异,并且不能保证这些属性一定会产生任何影响。
使用这些属性时应该谨慎,并仔细检查其效果。通常情况下,编译器更清楚如何优化代码,这意味着过度使用这些属性可能会适得其反。
# 21.6.2 [[no_unique_address]]
属性
类中常常存在一些影响行为但不提供状态的成员。例如,无序容器的哈希函数、std::unique_ptr
的删除器,或者容器和字符串的标准分配器。它们所提供的只是成员函数(和静态成员),而没有非静态数据成员。
然而,这些成员通常即使不存储任何内容也需要占用内存。例如,考虑以下代码:
struct Empty {}; // 空类:大小通常为1
struct I { // 大小与sizeof(int)相同
int i;
};
struct EandI { // 大小为成员大小之和加上对齐
Empty e;
int i;
};
std::cout << "sizeof(Empty): " << sizeof(Empty) << "\n";
std::cout << "sizeof(I): " << sizeof(I) << "\n";
std::cout << "sizeof(EandI): " << sizeof(EandI) << "\n";
2
3
4
5
6
7
8
9
10
11
12
根据int
的大小,输出可能如下:
sizeof(E): 1
sizeof(I): 4
sizeof(EandI): 8
2
3
这是不必要的空间浪费。在C++20之前,你可以使用空基类优化(Empty Base Class Optimization,EBCO)来避免这种不必要的开销。通过从一个没有数据成员的类派生,编译器可以节省相应的空间:
struct EbasedI : Empty { // 使用EBCO
int i;
};
std::cout << "sizeof(EbasedI): " << sizeof(EbasedI) << "\n";
2
3
4
在上述大小的平台上,输出为:
sizeof(EbasedI): 4
然而,这种解决方法有点笨拙,并且可能并不总是有效。例如,如果空基类是final
的,就不能使用EBCO。
自C++20起,有了另一种实现相同效果的方法。你只需要用[[no_unique_address]]
属性声明那些不提供状态的成员:
struct EattrI { // 与EBCO效果相同
[[no_unique_address]] Empty e;
int i;
};
struct IattrE { // 与EBCO效果相同
int i;
[[no_unique_address]] Empty e;
};
std::cout << "sizeof(EattrI): " << sizeof(EattrI) << "\n";
std::cout << "sizeof(IattrE): " << sizeof(IattrE) << "\n";
2
3
4
5
6
7
8
9
10
11
12
在支持该属性的上述平台上,输出变为:
sizeof(EattrI): 4
sizeof(IattrE): 4
2
注意,编译器不一定要支持这个属性。
用[[no_unique_address]]
标记的成员在初始化时仍被视为成员:
EattrI ei = {42}; // 错误:不能用42初始化成员e
EattrI ei = {{},42}; // 正确
2
这种优化还意味着成员e
的地址与同一对象的成员i
的地址相同。但这仍然意味着两个不同对象的成员e
具有不同的地址。
如果一个数据类型只有带此属性的数据成员,那么类型特性std::is_empty_v<>
是否返回true
由实现定义:
struct OnlyEmpty {
[[no_unique_address]] Empty e;
};
std::is_empty_v<OnlyEmpty> // 可能返回true或false
2
3
4
最后,注意Visual C++目前忽略这个属性。原因是Visual C++最初允许这个属性但并不支持它,现在支持它会导致应用程序二进制接口(ABI)中断。不过,Visual C++可能会在未来一个会破坏ABI的版本中支持它。在此之前,你可以使用[[msvc::no_unique_address]]
代替:
struct EattrI { // 对Visual C++也有效
[[no_unique_address]] [[msvc::no_unique_address]] Empty e;
Type i;
};
2
3
4
# 21.6.3 带参数的[[nodiscard]]
属性
C++17引入了[[nodiscard]]
属性,如果函数的返回值未被使用,该属性可促使编译器发出警告。
[[nodiscard]]
通常应用于当返回值未被使用时会导致不良行为的情况。这些不良行为可能包括:
- 内存泄漏,比如未使用返回的已分配内存;
- 出现意外或不符合直觉的行为,例如不使用返回值时会得到不同或意外的结果;
- 产生不必要的开销,例如调用某个函数,如果其返回值未被使用,该函数实际上是无意义的操作。
然而,在C++17中,无法为程序员为何应使用返回值指定解释信息。C++20为此引入了一个可选参数。
例如:
class MyType {
public:
...
[[nodiscard("Possible memory leak ")]] // 自C++20起可行
char* release();
void clear();
[[nodiscard("Did you mean clear()? ")]] // 自C++20起可行
bool empty() const;
};
2
3
4
5
6
7
8
9
第二条声明要求当empty()
的返回值未被使用时,编译器打印警告信息“Did you mean clear()?”。事实上,C++20为所有标准容器的成员函数empty()
都引入了这个属性。从反馈来看,我们知道编译器确实能发现程序员误以为自己已请求清空集合,但实际未使用返回值的错误情况。
# 21.7 特性测试宏
每个C++版本都会引入各种语言和库特性,而编译器对这些特性的支持是逐步实现的。因此,仅仅知道编译器支持哪个C++版本通常是不够的;对于可移植代码而言,了解特定特性是否可用可能更为重要。
为此,C++20正式引入了特性测试宏。对于每一个新的语言和库特性,都有一个宏可用于指示该特性是否可用。这个宏甚至能提供关于所支持特性版本的信息。
例如,以下源代码会根据(以及以何种形式)通用lambda是否可用而使用不同的代码:
#ifdef __cpp_generic_lambdas
#if __cpp_generic_lambdas >= 201707
... // 可以使用带模板参数的通用lambda
#else
... // 可以使用通用lambda
#else
... // 无法使用通用lambda
#endif
2
3
4
5
6
7
8
所有用于语言特性的特性测试宏都以cpp
开头。
再例如,如果std::as_const()
尚未可用,以下代码会提供并使用一种变通方法:
#ifndef __cpp_lib_as_const
template<typename T>
const T& asConst(T& t) {
return t;
}
#endif
#ifdef __cpp_lib_as_const
auto printColl = [&coll = std::as_const(coll)] {
#else
auto printColl = [&coll = asConst(coll)] {
#endif
...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
所有用于库特性的特性测试宏都以cpp_lib
开头。
用于语言特性的特性测试宏由编译器定义。用于库特性的特性测试宏由新的<version>
头文件提供。
以__cpp_char8_t
的使用为例,这是使使用UTF-8字符的代码在C++20前后都具有可移植性的另一种方式。
# 21.8 补充说明
带初始化的基于范围的for
循环最初由Thomas Köppe在http://wg21.link/p0614r0 (opens new window)中提出。最终被接受的表述由Thomas Köppe在http://wg21.link/p0614r1 (opens new window)中制定。
对枚举值使用using
最初由Gasper Azman和Jonathan Müller在http://wg21.link/p1099r0 (opens new window)中提出,最终被接受的表述在http://wg21.link/p1099r5 (opens new window)中制定。
对UTF-8字符采用不同类型的提议最初由Tom Honermann在http://wg21.link/p0482r0 (opens new window)中提出。最终被接受的表述由Tom Honermann在http://wg21.link/p0482r6 (opens new window)中制定。对输出运算符的相应修正由Tom Honermann在http://wg21.link/p1423r3 (opens new window)中提出并被接受。
支持指定初始化器的提议最初由Tim Shen、Richard Smith、Zhihao Yuan和Chandler Carruth在http://wg21.link/p0329r0 (opens new window)中提出。最终被接受的表述由Tim Shen和Richard Smith在http://wg21.link/p0329r4 (opens new window)中制定。
使用括号进行聚合初始化的提议最初由Ville Voutilainen在http://wg21.link/p0960r0 (opens new window)中提出。最终被接受的表述由Ville Voutilainen和Thomas Köppe在http://wg21.link/p0960r3 (opens new window)中制定。
聚合定义的修改由Timur Doumler、Arthur O’Dwyer、Richard Smith、Howard E. Hinnant和Nicolai Josuttis在http://wg21.link/p1008r1 (opens new window)中提出并被接受。
[[likely]]
和[[unlikely]]
属性由Clay Trycht在http://wg21.link/p0479r5 (opens new window)中提出并被接受。
[[no_unique_address]]
属性由Richard Smith在http://wg21.link/p0840r2 (opens new window)中提出并被接受。
允许[[nodiscard]]
属性带有参数由JeanHeyd Meneide和Isabella Muerte在http://wg21.link/p1301r4 (opens new window)中提出并被接受。
特性测试宏由Ville Voutilainen和Jonathan Wakely在http://wg21.link/p0941r2 (opens new window)中提出并被纳入C++标准。