目录
2.5 全局模块段(Global module fragment)
2.6 私有模块段(Private module fragment)
6.4 constexpr dynamic_cast 和多态 typeid
6.9 std::is_constant_evaluated()
9.1 lambda 捕获和存储结构化绑定的类指定符(specifiers)
11.1 [[likely]] 和 [[unlikely]]
26.6 不可见的 ADL(Argument-Dependent Lookup) 和函数模板
26.7 指定何时需要 constexpr 函数定义来进行常量求值
1. 概念及其简洁语法
概念的基本思想是明确模板参数需要满足哪些条件,以便编译器在实例化之前进行检查。这样一来,错误信息(如果有的话)就更加清晰,例如“约束 X 未满足”。在 C++20 之前,可以使用复杂的 enable_if 结构,或者在模板实例化过程中直接报错,并显示晦涩难懂的错误信息。而有了概念,错误会更早发生,错误信息也更加简洁明了。
1.1 要求(requires)表达式
我们先来看 requires 表达式。它是一个包含模板参数实际要求的表达式,如果这些要求得到满足,则其结果为 true,否则为 false。
template<typename T> /*...*/
requires (T x) // 可选的虚构参数集
{
// 简单要求: 表达式必须有效
x++; // 表达式必须有效
// 类型要求: `typename T`, T 类型必须是一个有效类型
typename T::value_type;
typename S<T>;
// 复合要求: {expression}[noexcept][-> Concept];
// {expression} -> Concept<A1, A2, ...> 相当于
// requires Concept<decltype((expression)), A1, A2, ...>
{*x}; // 解引用必须有效
{*x} noexcept; // 解引用必须是类型 noexcept
// 解引用必须返回 T::value_type
{*x} noexcept -> std::same_as<typename T::value_type>;
// 内嵌要求: requires ConceptName<...>;
requires Addable<T>; // 必须满足约束 Addable<T>
};
1.2 概念
概念只是一组命名的约束或其逻辑组合。概念和 requires 表达式都会生成编译时 bool 值,并且可以像普通值一样使用,例如在 if constexpr 中。
template<typename T>
concept Addable = requires(T a, T b)
{
a + b;
};
template<typename T>
concept Dividable = requires(T a, T b)
{
a/b;
};
template<typename T>
concept DivAddable = Addable<T> && Dividable<T>;
template<typename T>
void f(T x)
{
if constexpr(Addable<T>){ /*...*/ }
else if constexpr(requires(T a, T b) { a + b; }){ /*...*/ }
}
1.3 requires 子句
要真正约束某些内容,我们需要使用 requires 子句。它可以出现在 template<> 代码块之后,也可以作为函数声明的最后一个元素,甚至可以同时出现在这两个位置,包括 lambda 表达式:
template<typename T>
requires Addable<T>
auto f1(T a, T b) requires Subtractable<T>; // Addable<T> && Subtractable<T>
auto l = []<typename T> requires Addable<T>
(T a, T b) requires Subtractable<T>{};
template<typename T>
requires Addable<T>
class C;
// 丑陃的 `requires requires`. 第一个 `requires` 是 requires 子句,
// 第二个 `requires` 是 requires 表达式 。 若你不想引入新的概念则有用
template<typename T>
requires requires(T a, T b) {a + b;}
auto f4(T x);
更简洁的方法是使用概念名称而不是模板参数列表中的类名/类型名关键字:
template<Addable T>
void f();
模板的参数也可以受到约束。在这种情况下,参数的约束程度必须小于或等于参数本身的约束程度。不受约束的模板参数仍然可以接受受约束的模板作为参数:
template<typename T>
concept Integral = std::integral<T>;
template<typename T>
concept Integral4 = std::integral<T> && sizeof(T) == 4;
// requires子旬在此同样有效
template<template<typename T1> requires Integral<T1> typename T>
void f2(){}
// f() 和 f2() 形式等价
template<template<Integral T1> typename T>
void f(){
f2<T>();
}
// 无约束模板之模板参数可接受约束参数
template<template<typename T1> typename T>
void f3(){}
template<typename T>
struct S1{};
template<Integral T>
struct S2{};
template<Integral4 T>
struct S3{};
void test(){
f<S1>(); // 正确
f<S2>(); // 正确
// 错误, S3 受 Integral4 的约束,而 Integral4 的约束比 f() 的 Integral 的约束更大。
f<S3>();
// 均正确
f3<S1>();
f3<S2>();
f3<S3>();
}
约束条件未满足的函数会变得“不可见”:
template<typename T>
struct X{
void f() requires std::integral<T>
{}
};
void f(){
X<double> x;
x.f(); // 错误
auto pf = &X<double>::f; // 错误
}
1.4 约束的 auto
现在允许普通函数使用 auto 参数,使其像泛型 lambda 表达式一样具有泛型特性。概念可用于在各种上下文中约束占位符类型(auto/decltype(auto))。对于参数包,MyConcept... Ts 要求包中的每一个元素的 MyConcept 都必须为真,而不是整个包一次性为真,例如:requires<T1> && requires<T2> && ... && requires<TLast>。
template<typename T>
concept is_sortable = true;
auto l = [](auto x){};
void f1(auto x){} // 不受约束模板
void f2(is_sortable auto x){} // 受约束模板
template<is_sortable auto NonTypeParameter, is_sortable TypeParameter>
is_sortable auto f3(is_sortable auto x, auto y)
{
// 注意:约束名和 auto 之间不允许有任何内容
is_sortable auto z = 0;
return 0;
}
template<is_sortable auto... NonTypePack, is_sortable... TypePack>
void f4(TypePack... args){}
int f();
// 带两个参数
template<typename T1, typename T2>
concept C = true;
// binds second parameter
C<double> auto v = f(); // 意味着 C<int, double>
struct X{
operator is_sortable auto() {
return 0;
}
};
auto f5() -> is_sortable decltype(auto){
f4<1,2,3>(1,2,3);
return new is_sortable auto(1);
}
1.5 籍约束之偏序
除了为单个声明指定要求外,约束还可以用于为普通函数、模板函数或类模板选择最佳方案。为此,约束具有偏序的概念,即,一个约束可能至少比其他约束更严格,或者它们可以是无序的(不相关的)。编译器会将约束分解(标准使用术语“规范化”)为原子约束的合取/析取。直观地说,`C1 && C2` 比 `C1` 更严格,`C1` 比 `C1 || C2` 更严格,并且任何约束都比未受约束的声明更严格。当存在多个满足约束的候选方案时,选择约束最严格的那个。如果约束是无序的,则其用法会存在歧义。
template<typename T>
concept integral_or_floating = std::integral<T> || std::floating_point<T>;
template<typename T>
concept integral_and_char = std::integral<T> && std::same_as<T, char>;
void f(std::integral auto){} // #1
void f(integral_or_floating auto){} // #2
void f(std::same_as<char> auto){} // #3
// 调用 #1 ,因为 std::integral 比 integral_or_floating(#2) 更具约束
f(int{});
// 调用 #2 ,因为其是约束满足 f(double{})的唯一函数;
// 错误, #1, #2 和 #3 的约束满足但无序
// 因为 std::same_as<char> 仅出现在 #3 中
f(char{});
void f(integral_and_char auto){} // #4
// 调用 #4 ,因为 integral_and_char 比 std::same_as<char>(#3) 和 std::integral(#1) 更具约束
f(char{});
理解编译器如何分解约束,以及何时能够识别出具有共同原子约束并推断它们之间的顺序至关重要。在分解过程中,概念名称会替换为其定义,但 requires 表达式不会进一步分解。只有当两个原子约束由同一位置的相同表达式表示时,它们才是相同的。例如,概念 C = C1 && C2 会分解为 C1 和 C2 的合取,而 concept C = requires{...} 则会分解为 concept C = Expression-Location-Pair,并且其主体不会进一步分解。如果两个概念的 requires 表达式中存在共同的甚至相同的需求,它们始终是无序的,因为它们的 requires 表达式要么不相等,要么相等但位于不同的源位置。裸类型 trait 的重复使用也会发生同样的情况——由于位置不同,它们始终表示不同的原子约束,因此不能用于排序。
template<typename T>
requires std::is_integral_v<T> // 使用特征类型替代概念
void f1(){} // #1
template<typename T>
requires std::is_integral_v<T> || std::is_floating_point_v<T>
void f1(){} // #2
// 错误, #1 和 #2 具有共同的 `std::is_integral_v<T>` 表达式
// 但在不同的 locations (2行对比6行), 因此, 约束 #1 和 #2 无序且调用出现歧义
f1(int{});
template<typename T>
concept C1 = requires{ // requires 表达式不会分解
requires std::integral<T>;
};
template<typename T>
concept C2 = requires{ // requires 表达式不会分解
requires (std::integral<T> || std::floating_point<T>);
};
void f2(C1 auto){} // #3
void f2(C2 auto){} // #4
// 错误, 因为 requires 表示达式不会分解, #3 和 #4 完全不相关,从而是无序约束,因些调用出现歧义
f2(int{});
1.6 条件平凡的特殊成员函数
对于像 std::optional 或 std::variant 这样的包装类型,从它们包装的类型传播平凡性非常有用。例如,std::optional<int> 应该是平凡的,但 std::optional<std::string> 则不应该。在 C++17 中,这可以通过相当繁琐的机制来实现。概念为此提供了一个自然的解决方案:我们可以创建同一个特殊成员函数的多个版本,并赋予它们不同的约束,编译器会选择最佳版本并忽略其他版本。在这个特定情况下,当包装类型是平凡类型时,我们需要一组平凡的函数;当包装类型不是平凡类型时,我们需要一组非平凡的函数。为了实现这一点,平凡类型的定义进行了一些更新。在 C++17 中,一个平凡可复制的类必须将其所有复制和移动操作都删除或设为平凡操作。为了考虑概念,引入了合格特殊成员函数的概念。这是一个未被删除的函数,其约束(如果有)均已满足,且没有其他同类型、具有相同第一个参数类型(如果有)的特殊成员函数的约束比它更严格。简而言之,它是满足约束条件最严格的函数(如果有)。所有现有的析构函数(是的,现在可以有多个析构函数)现在被称为潜在析构函数。只允许有一个“激活”的析构函数,它通过正常的重载解析方式选择。
template<typename T>
class optional{
public:
optional() = default;
// 平凡复制构造函数
optional(const optional&) = default;
// 非平凡复制构造函数
optional(const optional& rhs)
requires(!std::is_trivially_copy_constructible_v<T>){
// ...
}
// 平凡析构函数
~optional() = default;
// 非平凡析构函数
~optional() requires(!std::is_trivial_v<T>){
// ...
}
// ...
private:
T value;
};
static_assert(std::is_trivial_v<optional<int>>);
static_assert(!std::is_trivial_v<optional<std::string>>);
2. 模块(Modules)
模块是一种将 C++ 代码组织成逻辑组件的新方法。历史上,C++ 使用的是基于预处理器和重复文本包含的 C 模型。它存在诸多问题,例如宏在头文件中的泄漏、头文件包含顺序依赖性、重复编译相同代码、循环依赖、实现细节封装性差等等。模块有望解决这些问题,但并非一蹴而就。只有当编译器和构建工具(例如 CMake)也支持模块时,我们才能充分发挥其优势。模块的完整描述远超本文范围,我仅介绍其基本概念和用例。
模块的核心思想在于限制客户端使用(导入)模块时可以访问(导出)的内容。这实现了对实现细节的真正隐藏。
// module.cpp
// 模块名中的点旨在增强可读性, 并无他意
export module my.tool; // 模块声明
export void f(){} // 导出 f()
void g(){} // 非导出 g()
// client.cpp
import my.tool;
f(); // 正确
g(); // 错误, 非导出
模块对宏不太友好,你不能将手动定义的宏传递给模块(编译器内置宏和命令行宏仍然可见),而且只有在一种特殊情况下,你才能从模块导入宏。模块不能有循环依赖。模块是一个自包含的实体,编译器可以对每个模块进行一次预编译,从而大大缩短整体编译时间。模块的导入顺序无关紧要。
注意:在 VS2026 中需要做一设置才能通过编译:
(1) 将文件【项类型】设置为【C/C++ 编译器】
在【解决方案资源管理器】中,选中文件(.cpp,.h,或.ixx文件)右键,选择【属性】,【配置属性】-> 【常规】,【项类型】选择 【C/C++ 编译器】
(2) 然后会显示 【C/C++ 】属性,展开,选择 【高级】-> 【编译为】,选择 【作为 C++ 模块代码编译 (/interface )】即可。
2.1 模块单元
模块可以是接口模块单元,也可以是实现模块单元。只有接口单元才能为模块的接口做出贡献,因此它们的声明中包含 export 关键字。模块可以是单个文件,也可以分布在多个分区中。每个分区的命名格式为 module_name:partition_name。分区只能在同一个模块内导入,客户端也只能导入整个模块。这种设计比头文件提供了更好的封装性。
export module tool; // 主模块接口单元
export import :helpers; // 重新导出(见下) helpers 分区
export void f();
export void g();
// tool.internals.cpp
module tool:internals; // 实现分区
void utility();
// tool.impl.cpp
module tool; // 实现单元,隐式导入主模块单元
import :internals;
void utility(){}
void f(){
utility();
}
// tool.impl2.cpp
module tool; // 另一个实现单元
void g(){}
// tool.helpers.cpp
export module tool:helpers; // 模块接口分区
import :internals;
export void h(){
utility();
}
// client.cpp
import tool;
f();
g();
h();
请注意,导入分区时无需指定模块名称。这可以防止导入其他模块的分区。允许存在多个实现单元(模块工具),但所有其他单元和任何类型的分区都必须是唯一的。所有接口分区都必须由模块通过导出导入重新导出。
2.2 导出(Export)
以下是几种导出形式,总的规则是不能导出带有内部链接的名称:
module tool;
export import :helpers; // 导入并重新导出 helpers 接口分区
export int x{}; // 导出单声明
export{ // 导出多声明
int y{};
void f(){};
}
export namespace A{ // 导出整个命名空间
void f();
void g();
}
namespace B{
export void f();//导出命名空间中的单声明
void g();
}
namespace{
export int x; // 错误, x 有内链接
export void f();// 错误, f() 有内链接
}
export class C; // 作为不完整类型导出
class C{};
export C get_c();
// client.cpp
import tool;
C c1; // 错误, C 是不完整的
auto c2 = get_c(); // 正确
2.3 导入(import)
导入声明应位于任何其他“非模块”声明之前,这样可以快速进行依赖关系分析。除此之外,其他方面都相当直观:
export module tool;
import :helpers; // 导入 helpers 分区
export void f(){}
export module tool:helpers;
export void g(){}
// client.cpp
import tool;
f();
g();
2.4 标头单元 (Header units)
有一种特殊的导入方式允许导入可导入的头文件:import <header.h> 或 `import "header.h"`。编译器会创建一个合成的头文件单元,并将所有声明隐式导出。哪些头文件实际可导入取决于具体实现,但所有 C++ 库头文件都是可导入的。或许,未来会有一种方法可以告诉编译器哪些用户提供的头文件是可导入的,这类头文件不应包含非内联函数定义或带有外部链接的变量。这是唯一允许从头文件中导入宏的导入方式(但你仍然无法通过 `export import "header.h"` 重新导出它们)。如果你不确定某个旧头文件的内容,请不要使用它来导入它。
2.5 全局模块段(Global module fragment)
如果需要在模块中使用传统的头文件,可以安全地将 #include 放在一个特殊的地方:全局模块片段:
#pragma once
class A{};
void g(){}
// tool.cpp
module; // 全局模块段
#include "header.h"
export module tool; // 结构
export void f(){ // 使用来自 header.h 的声明
g();
A a;
}
它必须出现在指定模块声明之前,并且只能包含预处理器指令。所有全局模块片段和非模块化翻译单元的声明都附加到同一个全局模块。因此,所有适用于普通头文件的规则都适用于此。
2.6 私有模块段(Private module fragment)
最后一种比较特殊的是私有模块段。它的目的是将实现细节隐藏在一个单文件模块中(在其他地方是不允许这样做的)。理论上,当私有模块片段中的内容发生变化时,客户端可能不会重新编译:
export module tool; // 接口
export void f(); // 以此声明
module :private; // 实现细节
void f(){} // 在此定义
2.7 不那么隐式的 inline
关于内联函数,还有一个有趣的改动。如果类附加到一个命名模块中,那么在类定义中定义的成员函数不会隐式地内联。命名模块中的内联函数只能使用对客户端可见的名称。
// header.h
struct C{
void f(){} // 仍为 inline,因为附加到一个附加模块
};
// tool.cpp
module;
#include "header.h"
export module tool;
class A{}; // 未导出
export struct B{// B 附加到 "tool"
void f(){ // 始终未显式的 inline
A a; // 可以使用未导出的名称
}
inline void g(){
A a; // 挂了, 使用未导出的名称
}
inline void h(){
f(); // 可行, f() 不是 inline 的
}
};
// client.cpp
import tool;
B b;
b.f(); // 正确
b.g(); // 错误, A 未定义
b.h(); // 正确
3. 协程(Coroutines)
C++ 中终于有了无栈协程(其状态存储在堆中,而非栈中)。C++20 提供了几乎最底层的 API,其余部分都留给了用户。我们有 co_await、co_yield 和 co_return 关键字,以及用于调用者和被调用者之间交互的规则。这些规则非常底层,我觉不在这里赘述。
cppcoro::task<int> someAsyncTask()
{
int result;
// 想办法得到结果
co_return result;
}
// task<> 类似于普通函数的 void 返回类型
cppcoro::task<> usageExample()
{
// 创建一个新的任务但不启动例程
cppcoro::task<int> myTask = someAsyncTask();
// ...
// 例程仅在后者 co_await 到这个任何时才启动
auto result = co_await myTask;
}
// 将自动生成 0 到 9 之间的数字。
cppcoro::generator<std::size_t> getTenNumbers()
{
std::size_t n{0};
while (n != 10)
{
co_yield n++;
}
}
void printNumbers()
{
for(const auto n : getTenNumbers())
{
std::cout << n;
}
}
4. 三向比较运算符(<=>)(飞船运算符)
在 C++20 之前,要为类提供比较操作,需要实现 6 个运算符:==、!=、<、<=、> 和 >= 。通常,其中四个运算符包含样板代码,用于处理 == 和 < 运算符,而后者包含真正的比较逻辑。常见的做法是将它们实现为接受 const T& 参数的自由函数,以便比较可转换类型。如果需要支持不可转换类型,则需要添加两组 6 个函数:op(const T1&, const T2&) 和 op(const T2&, const T1&),这样就有了 18 个比较运算符(参见 std::optional)。C++20 为我们提供了一种更好的比较处理和思考方式。现在,你需要重点关注 operator<=>(),有时也需要关注 operator==()。新的 operator<=>(飞船运算符)实现了三向比较,它只需一次调用即可判断 a 是否小于、等于或大于 b,就像 strcmp() 一样。它返回一个可以与零进行比较的比较类别(见下文)。有了这个类别,编译器可以将对 <、<=、>、>= 的调用替换为对 operator<=>() 的调用并检查其结果(例如,a < b 变为 a <=> b < 0),并将对 ==、!= 的调用替换为对 operator==() 的调用(例如,a != b 变为 !(a == b))。由于新的查找规则,它们可以处理非对称比较,例如,当你提供一个 T1::operator==(const T2&) 时,你将同时得到 T1 == T2 和 T2 == T1,operator<=>() 也同样适用。现在,你最多需要编写 2 个函数来获得可转换类型之间的所有 6 种比较,以及 2 个函数来获得不可转换类型之间的所有 12 种比较。
4.1 比较类别
标准提供了三种比较类别(但这并不妨碍你创建自己的类别)。强序(strong_ordering)意味着 a < b、a > b 和 a == b 中必须有且仅有一个为真,并且如果 a == b,则 f(a) == f(b)。弱序(weak_ordering)意味着 a < b、a > b 和 a == b 中必须有且仅有一个为真,并且如果 a == b,则 f(a) 可以不等于 f(b) 。这样的元素是等价的,但不相等。偏序(partial_ordering)意味着 a < b、a > b 和 a == b 中可能都为真,并且如果 a == b,则 f(a) 可以不等于 f(b)。即,某些元素可能无法比较。这里需要注意的是,f() 表示一个仅访问关键属性的函数。例如,std::vector<int> 是强序的,尽管两个具有相同值的向量可能具有不同的容量。在这里,容量不是一个关键属性。弱序类型的一个例子是 CaseInsensitiveString,它可以按原样存储字符串,但比较时不区分大小写。偏序类型的一个例子是 float/double,因为 NaN 值无法与其他任何值进行比较。这些类别构成了一个层次结构,即强序类型可以转换为弱序类型和偏序类型,弱序类型也可以转换为偏序类型。
4.2 默认比较
比较操作可以像特殊成员函数一样设置默认值。在这种情况下,它们会逐个成员地进行操作,将所有底层非静态数据成员与其对应的运算符进行比较。默认的 operator<=>() 也会声明默认的 operator==()(如果不存在),因此你可以写成 auto operator<=>(const T&) const = default; ,从而获得所有六种具有逐个成员语义的比较操作。
template<typename T1, typename T2>
void TestComparisons(T1 a, T2 b)
{
(a < b), (a <= b), (a > b), (a >= b), (a == b), (a != b);
}
struct S2
{
int a;
int b;
};
struct S1
{
int x;
int y;
// 支持同质性比较
auto operator<=>(const S1&) const = default;
// 必需如此,因为存在阻止默认的 operator==() 隐式声明的 operator==(const S2&)
bool operator==(const S1&) const = default;
// 支持异质性比较
std::strong_ordering operator<=>(const S2& other) const
{
if (auto cmp = x <=> other.a; cmp != 0)
return cmp;
return y <=> other.b;
}
bool operator==(const S2& other) const
{
return (*this <=> other) == 0;
}
};
TestComparisons(S1{}, S1{});
TestComparisons(S1{}, S2{});
TestComparisons(S2{}, S1{});
隐式声明的 operator==() 与 operator<=>() 具有相同的签名,只是返回类型为 bool 。
template<typename T>
struct X
{
friend constexpr std::partial_ordering operator<=>(X, X) requires(sizeof(T) != 1) = default;
// 隐式声明
// friend constexpr bool operator==(X, X) requires(sizeof(T) != 1) = default;
[[nodiscard]] virtual std::strong_ordering operator<=>(const X&) const = default;
// 隐式声明:
//[[nodiscard]] virtual bool operator==(const X&) const = default;
};
推导出的比较类别是该类型成员中最弱的一个。
struct S3{
int x; // int 是强序的
double d; // 但 double 是偏序的
// 因此, 最终的类别是 std::partial_ordering
auto operator<=>(const S3&) const = default;
};
static_assert(std::is_same_v<decltype(S3{} <=> S3{}), std::partial_ordering>);
他们必须是成员或友成员,而且只有友成员才能通过值取得。
struct S4
{
int x;
int y;
// 成员版必须有 op(const T&) const; 这种形式
auto operator<=>(const S3&) const = default;
// 友成员版可通过 const 引用或值取得参数
// friend auto operator<=>(const S3&, const S3&) = default;
// friend auto operator<=>(S3, S3) = default;
};
可以像特殊成员函数一样,设置类外默认值。
struct S5
{
int x;
std::strong_ordering operator<=>(const S5&) const;
bool operator==(const S5&) const;
};
std::strong_ordering S5::operator<=>(const S5&) const = default;
bool S5::operator==(const S5&) const = default;
默认的 operator<=>() 使用类成员的 operator<=>(),或者可以使用现有的 Member::operator==() 和 Member::operator<() 来合成成员的排序。请注意,它仅适用于成员,而不适用于类本身,现有的 T::operator<() 永远不会在默认的 T::operator<=>() 中使用。
// 不在我们的直接控制范围内
struct Legacy
{
bool operator==(Legacy const&) const;
bool operator<(Legacy const&) const;
};
struct S6
{
int x;
Legacy l;
// 划掉,因为 Legacy 无 operator<=>(), 无法推导比较类型
auto operator<=>(const S6&) const = default;
};
struct S7
{
int x;
Legacy l;
std::strong_ordering operator<=>(const S7& rhs) const = default;
/*
因为比较类型显式提供, 排序可以使用 operator<() 和 operator==() 组合而成。 为使其有效它们必须准确返回
`bool` 。对于弱痛了和偏序,同样有效。
下面是合成 operator<=>() 的一个例子:
std::strong_ordering operator<=>(const S7& rhs) const
{
// use operator<=>() for int
if(auto cmp = x <=> rhs.x; cmp != 0) return cmp;
// synthesize ordering for Legacy using operator<() and operator==()
if(l == rhs.l) return std::strong_ordering::equal;
if(l < rhs.l) return std::strong_ordering::less;
return std::strong_ordering::greater;
}
*/
};
struct NoEqual
{
bool operator<(const NoEqual&) const = default;
};
struct S8
{
NoEqual n;
// 划掉, NoEqual 无 operator<=>()
// auto operator<=>(const S8&) const = default;
// 同样划掉,因为 NoEqual 无 operator==()
std::strong_ordering operator<=>(const S8&) const = default;
};
struct W
{
std::weak_ordering operator<=>(const W&) const = default;
};
struct S9
{
W w;
// 寻求 strong_ordering 但 W 仅可以提供 weak_ordering, 这在实例化过程中将报错
std::strong_ordering operator<=>(const S9&) const = default;
void f()
{
(S9{} <=> S9{}); // 错误
}
};
不支持 union 成员和引用成员。
struct S4
{
int& r;
// 因为为引用成员,划掉
auto operator<=>(const S4&) const = default;
};
5. lambda 表达式
5.1 允许 lambda 捕捉 [=, this]
当隐式捕获时,即使使用 [=],this 也总是按引用捕获。为了消除这种混淆,C++20 弃用了这种行为,并允许更显式的 [=, this]:
struct S{
void f(){
[=]{}; // 通过引用捕捉 this , 自 C++20 起废弃
[=, *this]{}; // 自 C++17 起正确, 通过值捕捉 this
[=, this]{}; // 自 C++20 起正确, 通过引用捕 this
}
};
5.2 泛型 lambda 模板参数列表
有时泛型 lambda 表达式过于泛型。C++20 允许使用熟悉的模板函数语法直接引入类型名称。
// 期望 std::vector<T> 的 lambda
// C++20 以前:
[](auto vector){
using T =typename decltype(vector)::value_type;
// 使用 T
};
// C++20 起:
[]<typename T>(std::vector<T> vector){
// 使用 T
};
// 访问参数类型
// C++20以前
[](const auto& x){
using T = std::decay_t<decltype(x)>;
// using T = decltype(x); // 无 decay_t<> 它会是 const T&, 因此
T copy = x; // copy 会是一个引用类型且不会有效
T::static_function();
using Iterator = typename T::iterator;
};
// C++20 起
[]<typename T>(const T& x){
T copy = x;
T::static_function();
using Iterator = typename T::iterator;
};
// 完备转发
// C++20 以前:
[](auto&&... args){
return f(std::forward<decltype(args)>(args)...);
};
// C++20 起:
[]<typename... Ts>(Ts&&... args){
return f(std::forward<Ts>(args)...);
};
// 当然,你可以用 auto 参数融合
[]<typename T>(const T& a, auto b){};
5.3 未求值上下文中的 lambda 表达式
lambda 表达式可以在未求值的上下文中使用,例如 sizeof()、typeid()、decltype() 等。以下是此功能的一些要点,有关更实际的示例,请参阅默认可构造和可赋值的无状态 lambda。
主要原理是 lambda 具有唯一的未知类型,两个 lambda 的类型永远不会相等。
using L = decltype([]{}); // lambda 无链接
L PublicApi(); // L 不会用于外部链接
// 在模板中 , 两种不同的声明
template<class T> void f(decltype([]{}) (*s)[sizeof(T)]);
template<class T> void f(decltype([]{}) (*s)[sizeof(T)]);
// 同样, lambda 类型永不等价
static decltype([]{}) f();
static decltype([]{}) f(); // 错误, 返回类型不匹配
static decltype([]{}) g();
static decltype(g()) g(); // 正确, 重复声明
// 每一个特化都因为具有唯一类型而具有其自己的 lambda
template<typename T>
using R = decltype([]{});
static_assert(!std::is_same_v<R<int>, R<char>>);
// 基于 lambda的 SFINAE(Substitution Failure Is Not An Error) 且约束不受支持, 因此其无效
template <class T>
auto f(T) -> decltype([]() { T::invalid; } ());
void f(...);
template<typename T>
void g(T) requires requires{
[](){typename T::invalid x;}; }
{}
void g(...){}
f(0); // 错误
g(0); // 错误
在以下示例中,f() 在两个编译单元中递增相同的计数器,因为内联函数的行为就好像它只有一个定义一样。然而,g_s 违反了 ODR(One Definition Rule),因为尽管它只有一个定义,但仍然存在多个不同的声明,这是因为 a.cpp 和 b.cpp 中有两个不同的 lambda 表达式,因此 S 具有不同的非类型模板参数:
// a.h
template<typename T>
int counter(){
static int value{};
return value++;
}
inline int f(){
return counter<decltype([]{})>();
}
template<auto> struct S{ void call(){} };
// cast lambda to pointer
inline S<+[]{}> g_s;
// a.cpp
#include "a.h"
auto v = f();
g_s.call();
// b.cpp
#include "a.h"
auto v = f();
g_s.call();
5.4 默认可构造和可赋值的无状态 lambda 表达式
在 C++20 中,无状态 lambda 表达式默认是可构造和可赋值的,这允许我们使用 lambda 表达式的类型来稍后构造/赋值它。对于未求值的上下文中的 lambda 表达式,我们可以使用 decltype() 获取 lambda 表达式的类型,并在稍后创建该类型的变量:
auto greater = [](auto x,auto y)
{
return x > y;
};
// 要求默认可构造类型
std::map<std::string, int, decltype(greater)> map;
auto map2 = map; // 要求默认可赋值类型
这里,std::map 接受一个比较器类型作为参数,以便稍后实例化它。虽然在 C++17 中我们可以获得 lambda 类型,但由于 lambda 表达式默认不可构造,因此无法实例化它。
5.5 lambda 初始捕中的包扩展
C++20 简化了 lambda 表达式中参数包的捕获。在 C++20 之前,我们可以按值或按引用捕获参数包,或者如果需要移动参数包,则需要使用 std::tuple 进行一些技巧操作。现在,捕获参数包变得更加容易,我们可以创建一个初始化捕获包,并用要捕获的参数包对其进行初始化。它不仅限于 std::move 或 std::forward,任何函数都可以应用于参数包元素。
void g(int, int){}
// C++17
template<class F, class... Args>
auto delay_apply(F&& f, Args&&... args) {
return [f=std::forward<F>(f), tup=std::make_tuple(std::forward<Args>(args)...)]()
-> decltype(auto) {
return std::apply(f, tup);
};
}
// C++20
template<typename F, typename... Args>
auto delay_call(F&& f, Args&&... args) {
return [f = std::forward<F>(f), ...f_args=std::forward<Args>(args)]()
-> decltype(auto) {
return f(f_args...);
};
}
void f(){
delay_call(g, 1, 2)();
}
6. 常量表达式
6.1 立即函数(immediate function)
constexpr 表示函数可以在编译时求值,而 consteval 则规定函数必须在编译时(且仅限编译时)求值。虚函数可以声明为 consteval,但它们只能被另一个 consteval 函数覆盖,也就是说,不允许混合使用 consteval 和非 consteval 函数。析构函数和内存分配/释放函数不能声明为 consteval 。
consteval int GetInt(int x){
return x;
}
constexpr void f(){
auto x1 = GetInt(1);
constexpr auto x2 = GetInt(x1); // 错误,x1 不是常量表达式
}
6.2 constexpr 虚函数
现在虚函数可以是 constexpr 函数。constexpr 函数可以覆盖非 constexpr 函数,反之亦然。
struct Base{
constexpr virtual ~Base() = default;
virtual int Get() const = 0; // 非 constexpr
};
struct Derived1 : Base{
constexpr int Get() const override {
return 1;
}
};
struct Derived2 : Base{
constexpr int Get() const override {
return 2;
}
};
constexpr auto GetSum(){
const Derived1 d1;
const Derived2 d2;
const Base* pb1 = &d1;
const Base* pb2 = &d2;
return pb1->Get() + pb2->Get();
}
static_assert(GetSum() == 1 + 2); // 编译时计算
6.3 constexpr try-catch 块
现在 constexpr 函数内部允许使用 try-catch 代码块,但不允许使用 throw,因此 catch 代码块会被直接忽略。这很有用,例如,结合 constexpr new,我们可以编写一个在运行时/编译时都能正常工作的函数:
constexpr void f(){
try{
auto p = new int;
// ...
delete p;
}
catch(...){ // 编译时忽略
// ...
}
}
6.4 constexpr dynamic_cast 和多态 typeid
既然虚函数现在可以是 constexpr,那么就没有理由不允许在 constexpr 中使用 dynamic_cast 和多态 typeid。遗憾的是,std::type_info 目前还没有 constexpr 成员,所以现在它的应用还很少。
struct Base1{
virtual ~Base1() = default;
constexpr virtual int get() const = 0;
};
struct Derived1 : Base1{
constexpr int get() const override {
return 1;
}
};
struct Base2{
virtual ~Base2() = default;
constexpr virtual int get() const = 0;
};
struct Derived2 : Base2{
constexpr int get() const override {
return 2;
}
};
template<typename Base, typename Derived>
constexpr auto downcasted_get(){
const Derived d;
const Base& upcasted = d;
const auto& downcasted = dynamic_cast<const Derived&>(upcasted);
return downcasted.get();
}
static_assert(downcasted_get<Base1, Derived1>() == 1);
static_assert(downcasted_get<Base2, Derived2>() == 2);
// 编译时错误, 不能转换 Derived1 到 Base2
static_assert(downcasted_get<Base2, Derived1>() == 1);
6.5 在 constexpr 中更改联合体的活动成员
对常量表达式的另一项放宽限制是:可以更改联合体中的活动成员,但不能读取非活动成员,因为这违反了未定义行为(UB——Undefined Behavior),而未定义行为在 constexpr 上下文中是不允许的。(注:最后一个被写或初始化的成员)
union Foo {
int i;
float f;
};
constexpr int f() {
Foo foo{};
foo.i = 3; // i 是一全活动成员
foo.f = 1.2f; // 自 C++20 有效, f 成为一个活动成员
// return foo.i; // 错误, 读取非活动 union 成员
return foo.f;
}
6.6 constexpr 分配(内存)
C++20 为 constexpr 容器奠定了基础。首先,它允许字面量类型(可用作 constexpr 变量的类型)使用 constexpr 析构函数,甚至允许使用虚 constexpr 析构函数。其次,它允许调用 std::allocator<T>::allocate() 和 new 表达式,如果在编译时释放了已分配的内存,则会导致调用全局 new 运算符。也就是说,内存可以在编译时分配,但也必须在编译时释放。如果最终数据需要在运行时使用,这会造成一些不便。别无选择,只能将其存储在类似 std::array 的非分配容器中,并获取两次编译时值:第一次获取其大小,第二次实际复制它:
constexpr auto get_str()
{
std::string s1{"hello "};
std::string s2{"world"};
std::string s3 = s1 + s2;
return s3;
}
constexpr auto get_array()
{
constexpr auto N = get_str().size();
std::array<char, N> arr{};
std::copy_n(get_str().data(), N, std::begin(arr));
return arr;
}
static_assert(!get_str().empty());
// 错误,因此其存储了编译时分配的数据
constexpr auto str = get_str();
// 正确, string 存储于 std::array<char>
constexpr auto result = get_array();
6.7 constexpr 函数中的平凡默认初始化
在 C++17 中,constexpr 构造函数除其他要求外,必须初始化所有非静态数据成员。这条规则在 C++20 中已被移除。但是,由于 constexpr 上下文中不允许未定义行为,因此您不能读取这些未初始化的成员,只能写入它们:
struct NonTrivial{
bool b = false;
};
struct Trivial{
bool b;
};
template <typename T>
constexpr T f1(const T& other) {
T t; // default initialization
t = other;
return t;
}
template <typename T>
constexpr auto f2(const T& other) {
T t;
return t.b;
}
void test(){
constexpr auto a = f1(Trivial{}); // 在 C++17 下错误,在 C++20 下正确
constexpr auto b = f1(NonTrivial{});// 正确
constexpr auto c = f2(Trivial{}); // 错误, 使用了未初始化的Trivial::b
constexpr auto d = f2(NonTrivial{}); // 正确
}
6.8 constexpr 函数中未求值的 asm 声明
现在,即使 constexpr 函数在编译时未求值,汇编声明也可以出现在 constexpr 函数内部。这使得单个函数中可以同时包含编译时代码和运行时代码(现在使用汇编语言):
constexpr int add(int a, int b){
if (std::is_constant_evaluated()){
return a + b;
}
else{
asm("asm magic here");
//...
}
}
6.9 std::is_constant_evaluated()
使用 std::is_constant_evaluated() 可以检查当前调用是否发生在常量求值的上下文中。我本想说“在编译时”,但正如作者所说,“C++ 并没有明确区分编译时和运行时”。C++20 声明了一个表达式列表,这些表达式显然是在常量求值的,该函数在这些表达式求值期间返回 true,否则返回 false。
请注意,不要在明显使用常量求值的表达式(例如,if constexpr、数组大小、模板参数等)中直接使用此函数。根据定义,在这种情况下,即使外层函数并非使用常量求值,std::is_constant_evaluated() 也会返回 true。
constexpr int GetNumber(){
if(std::is_constant_evaluated()){ // 不应是 `if constexpr`
return 1;
}
return 2;
}
constexpr int GetNumber(int x){
if(std::is_constant_evaluated()){ // 不应是 `if constexpr`
return x;
}
return x+1;
}
void f(){
constexpr auto v1 = GetNumber();
const auto v2 = GetNumber();
// 非常量变量初始化, 非常量求值
auto v3 = GetNumber();
assert(v1 == 1);
assert(v2 == 1);
assert(v3 == 2);
constexpr auto v4 = GetNumber(1);
int x = 1;
// x 不是一个常量表达式, 非常量求值
const auto v5 = GetNumber(x);
assert(v4 == 1);
assert(v5 == 2);
}
// 病态例子
// 总是返回 `true`
constexpr bool IsInConstexpr(int){
if constexpr(std::is_constant_evaluated()){ // 总是`true`
return true;
}
return false;
}
// 总是返回 `sizeof(int)`
constexpr std::size_t GetArraySize(int){
int arr[std::is_constant_evaluated()]; // 总是 int arr[1];
return sizeof(arr);
}
// 总是返回 `1`
constexpr std::size_t GetStdArraySize(int){
std::array<int, std::is_constant_evaluated()> arr; // std::array<int, 1>
return arr.size();
}
7. 聚合
7.1 禁止使用用户声明的构造函数进行聚合
现在聚合类型不能再拥有用户声明的构造函数了。此前,聚合类型只允许拥有已删除或默认的构造函数。这导致拥有默认/已删除构造函数的聚合类型出现奇怪的行为(它们是用户声明的,但并非用户提供的)。
// 在 C++20 中,下列类型均不是聚合类型
struct S{
int x{2};
S(int) = delete; // 用户声明构造函数,禁用此构造函数
};
struct X{
int x;
X() = default; // 用户声明构造函数,编译器生成
};
struct Y{
int x;
Y(); // 用户提供构造函数
};
Y::Y() = default;
void f(){
S s(1); // 总是报错
S s2{1}; // 在 C++17 下正确, 在 C++20 标准下错误, 现在 S 不是聚合类型了
X x{1}; // 在 C++17 下正确, 在 C++20 标准下错误
Y y{2}; // 总是报错
}
7.2 聚合类模板参数推导
在 C++17 中,要将聚合函数与 CTAD(Class Template Argument Deduction) 结合使用,我们需要显式的推导指引,而现在则没有必要了:
template<typename T, typename U>
struct S{
T t;
U u;
};
// 在 C++17 下需要推导指引
// template<typename T, typename U>
// S(T, U) -> S<T,U>;
S s{1, 2.0}; // S<int, double>
当有用户提供的推导指引时,CTAD(Class Template Argument Deduction) 不参与其中:
template<typename T>
struct MyData{
T data;
};
MyData(const char*) -> MyData<std::string>;
MyData s1{"abc"}; // 正确, MyData<std::string> 使用推导指引
MyData<int> s2{1}; // 正确, 显示模板参数
MyData s3{1}; // 错误, 不涉及 CTAD
可推导数组类型:
template<typename T, std::size_t N>
struct Array{
T data[N];
};
Array a{{1, 2, 3}}; // Array<int, 3>, 注意附加括号
Array str{"hello"}; // Array<char, 6>
对于依赖的非数组类型或依赖边界的数组类型,省略大括号不起作用。
template<typename T, typename U>
struct Pair{
T first;
U second;
};
template<typename T, std::size_t N>
struct A1{
T data[N];
T oneMore;
Pair<T, T> p;
};
template<typename T>
struct A2{
T data[3];
T oneMore;
Pair<int, int> p;
};
// A1::data 是一个依赖边界数组,A1::p 是一个依赖类型,因此,他们没有省略大括号。
A1 a1{{1,2,3}, 4, {5, 6}}; // A1<int, 3>
// A2::data 是一个非依赖边界数组,A1::p 是一个非依赖类型。
// 因此,大括号省略有效
A2 a2{1, 2, 3, 4, 5, 6}; // A2<int>
支持扩展包。作为扩展包的尾部聚合元素对应于所有剩余元素:
template<typename... Ts>
struct Overload : Ts...{
using Ts::operator()...;
};
// 不再需要推导指引
Overload p{[](int){
std::cout << "called with int";
}, [](char){
std::cout << "called with char";
}
}; // Overload<lambda(int), lambda(char)>
p(1); //用 int 调用
p('c'); // 用 char 调用
非尾随元素(即扩展包)对应于零个元素:
template<typename T, typename...Ts>
struct Pack : Ts... {
T x;
};
// 仅可推导第一个参数
Pack p1{1}; // Pack<int>
Pack p2{[]{}}; // Pack<lambda()>
Pack p3{1, []{}}; // error
包中的元素数量只需计算一次,但如果元素类型重复,则类型必须完全匹配:
struct A{};
struct B{};
struct C{};
struct D{
operator C(){return C{};}
};
template<typename...Ts>
struct P : std::tuple<Ts...>, Ts...{
};
P{std::tuple<A, B, C>{}, A{}, B{}, C{}}; // P<A, B, C>
// 同上等价, 由于对 std::tuple<A, B, C> 推导包元素,
// 因此不必重复其
P{std::tuple<A, B, C>{}, {}, {}, {}}; // P<A, B, C>
// 由于在 std::tuple 初始化器之后,我们知道整个 P<A, B, C> 类型,我们可以
// 忽略尾部初始化器, 元素将按常规方式进行值初始化
P{std::tuple<A, B, C>{}, {}, {}}; // P<A, B, C>
// 错误, 对于属部包,从第一个初始化器推导的包是 <A, B, C> but got <A, B, D>,
// ,不考虑隐式转换
P{std::tuple<A, B, C>{}, {}, {}, D{}};
7.3 聚合的括号初始化
现在,聚合的括号初始化方式与花括号初始化方式相同,区别在于:允许类型窄化转换,不允许指定初始化器,临时变量的生命周期不会延长,并且不会省略花括号。没有初始化器的元素将进行值初始化。这使得工厂函数(例如 std::make_unique<>()/emplace())可以无缝地与聚合一起使用。
struct S{
int a;
int b = 2;
struct S2{
int d;
} c;
};
struct Ref{
const int& r;
};
int GetInt(){
return 21;
}
S{0.1}; // 错误, 窄化
S(0.1); // 正确
S{.a=1}; // 正确
S(.a=1); // 错误, 未分配初始化器
Ref r1{GetInt()}; // 正确, 生命周期扩展
Ref r2(GetInt()); // 悬空, 生命周期未扩展
S{1, 2, 3}; // 正确, 花括号忽略, 同 S{1,2,{3}}
S(1, 2, 3); // 错误, 无花括号忽略
// 无初始化器的值取默认值或 value-initialized(T{})
S{1}; // {1, 2, 0}
S(1); // {1, 2, 0}
// make_unique 现在有效
auto ps = std::make_unique<S>(1, 2, S::S2{3});
// 数组同样获支持
int arr1[](1, 2, 3);
int arr2[2](1); // {1, 0}
8. 非类型模板参数(无类型是指常量而非数据类型)
8.1 非类型模板参数中的类类型
现在,非类型模板参数可以是字面量类类型(可用作 constexpr 变量的类型),其所有基类和非静态成员均为公共且不可变(字面意义上,不应有任何可变说明符)。此类的实例存储为 const 对象,您甚至可以调用其成员函数。此外,还有一种新的非类型模板参数:用于推导类类型的占位符。在下面的示例中,fixed_string 是一个模板名称,而不是类型名称,但我们可以用它来声明模板参数 template<fixed_string S>。在这种情况下,编译器会在实例化 f<>() 之前,使用类似 T x = template-argument; 的自定义声明来推导 fixed_string 的模板参数。以下是如何使用它来创建一个简单的编译时字符串类:
template<std::size_t N>
struct fixed_string{
constexpr fixed_string(const char (&s)[N+1]) {
std::copy_n(s, N + 1, str);
}
constexpr const char* data() const {
return str;
}
constexpr std::size_t size() const {
return N;
}
char str[N+1];
};
template<std::size_t N>
fixed_string(const char (&)[N])->fixed_string<N-1>;
// 用户定义文字量(字面值)同样受支持
template<fixed_string S>
constexpr auto operator""_cts(){
return S;
}
// 将推导出 `S` 的 N 值。
template<fixed_string S>
void f(){
std::cout << S.data() << ", " << S.size() << '\n';
}
f<"abc">(); // abc, 3
constexpr auto s = "def"_cts;
f<s>(); // def, 3
8.2 泛非类型模板参数
非类型模板参数泛化为所谓的结构类型。结构类型是以下类型之一:
标量类型(算术类型、指针类型、成员指针类型、枚举类型、std::nullptr_t)
左值引用
字面量类类型,具有以下属性:所有基类和非静态数据成员均为公共且不可变,并且它们的类型为结构体或数组类型。
这样就可以将浮点类型和类类型用作模板参数:
template<auto T> // 任意非类型模板参数的占位符
struct X{};
template<typename T, std::size_t N>
struct Arr{
T data[N];
};
X<5> x1;
X<'c'> x2;
X<1.2> x3;
// 聚合类型有 CTAD 的帮助
X<Arr{{1,2,3}}> x4; // X<Arr<int, 3>>
X<Arr{"hi"}> x5; // X<Arr<char, 3>>
这里有趣的地方在于,非类型模板参数的比较并非使用它们的运算符 ==(),而是以类似位运算的方式进行(具体规则见此处)。即,比较使用的是它们的位表示。联合体是例外,因为编译器可以跟踪它们的活动成员。如果两个联合体都没有活动成员,或者它们都具有相同且值相等的活动成员,则这两个联合体相等。
template<auto T>
struct S{};
union U{
int a;
int b;
};
enum class E{
A = 0,
B = 0
};
struct C{
int x;
bool operator==(const C&) const{ // 从不相等
return false;
}
};
constexpr C c1{1};
constexpr C c2{1};
assert(c1 != c2); // 使用运算符 ==() 不等
assert(memcmp(&c1, &c2, sizeof(C)) == 0); // 但按位等
// 因此, 编译时相等, 不使用 operator==()
static_assert(std::is_same_v<S<c1>, S<c2>>);
constexpr E e1{E::A};
constexpr E e2{E::B};
// 按位等, 不考虑枚举的恒等
assert(memcmp(&e1, &e2, sizeof(E)) == 0);
static_assert(std::is_same_v<S<e1>, S<e2>>); // 因此, 编译时相等
constexpr U u1{.a=1};
constexpr U u2{.b=1};
// 按位等, 但具有不同的活动成员(a 对比 b)
assert(memcmp(&u1, &u2, sizeof(U)) == 0);
// 因此, 编译时不等
static_assert(!std::is_same_v<S<u1>, S<u2>>);
9. 结构化绑定
9.1 lambda 捕获和存储结构化绑定的类指定符(specifiers)
结构化绑定允许使用 [[maybe_unused]] 属性、static 和 thread_local 说明符。此外,现在可以在 lambda 表达式中按值或按引用捕获它们。请注意,绑定的位域(bit-fields)只能按值捕获。
struct S{
int a: 1;
int b: 1;
int c;
};
static auto [A,B,C] = S{};
void f(){
[[maybe_unused]] thread_local auto [a,b,c] = S{};
auto l = [=](){
return a + b + c;
};
auto m = [&](){
// 错误, 不能通过引用捕获位域 'a' 和 'b'
// 返回 a + b + c;
return c;
};
}
9.2 放宽结构化绑定自定义点查找规则
类型分解以实现结构化绑定的一种方法是使用类似元组的 API。它包含三个“函数”:std::tuple_element、std::tuple_size 和两个 get 选项:e.get<I>() 或 get<I>(e),其中前者优先级高于后者。也就是说,成员函数 get() 优先于非成员函数 get()。假设有一个类型有 get() 函数,但它并非用于类似元组的 API,例如 std::shared_ptr::get()。这样的类型无法分解,因为编译器会尝试使用成员函数 get(),而这将导致失败。现在,这条规则已经得到修正:只有当它是模板且其第一个模板参数是非类型模板参数时,成员函数版本才会被优先使用。
struct X : private std::shared_ptr<int>{
std::string payload;
};
// 由于新规则,此函数将代替 std::shared_ptr<int>::get
template<int N>
std::string& get(X& x) {
if constexpr(N==0) return x.payload;
}
namespace std {
template<>
class tuple_size<X>
: public std::integral_constant<int, 1>
{};
template<>
class tuple_element<0, X> {
public:
using type = std::string;
};
}
void f(){
X x;
auto& [payload] = x;
}
9.3 允许结构化成员绑定到可访问成员
此修复程序不仅允许对公共成员进行结构化绑定,还允许在结构化绑定声明的上下文中对可访问成员进行结构化绑定。
struct A {
friend void foo();
private:
int i;
};
void foo() {
A a;
auto x = a.i; // 正确
auto [y] = a; // C++20 以前为错误格式, 现在正确
}
10. 范围(界域) for 循环
10.1 界域 for 的初始化语句
与 if 语句类似,界域 for 循环现在也可以包含 init 语句。它可以用来避免悬空引用:
class Obj{
std::vector<int>& GetItems();
};
Obj GetObj();
// 县空引用, Obj 的通过 GetObj() 返回的生命周期不扩展
for(auto x : GetObj().GetCollection()){
// ...
}
// 正确
for(auto obj = GetObj(); auto item : obj.GetCollection()){
// ...
}
// 同样可用于维护索引
for(std::size_t i = 0; auto& v : collection){
// use v...
i++;
}
10.2 放宽界域 for 循环自定义点查找规则
这类似于结构化绑定自定义点的修复。要遍历一个范围,界域 for 循环需要自由函数或成员函数 begin/end。旧规则的工作方式是,如果找到任何名为 begin/end 的成员(函数或变量),编译器就会尝试使用成员函数。这会给具有 begin 但没有 end 成员,或者反之亦然的类型带来问题。现在,只有当两个名称都存在时才使用成员函数,否则使用自由函数。
struct X : std::stringstream {
// ...
};
std::istream_iterator<char> begin(X& x){
return std::istream_iterator<char>(x);
}
std::istream_iterator<char> end(X& x){
return std::istream_iterator<char>();
}
void f(){
X x;
// X 有继承自 std::stringstream 的名为 `end` 的成员
// 但由于新规则,现在可以自由使用的 begin()/end() 函数。
for (auto&& i : x) {
// ...
}
}
11. 属性
11.1 [[likely]] 和 [[unlikely]]
属性[[likely]] 和 [[unlikely]] 可以向编译器提供执行路径可能性的提示,以便更好地优化代码。它们可以应用于语句(例如 if/else 语句、循环)或标签 case/default)。
int f(bool b){
if(b) [[likely]] {
return 12;
}
else{
return 10;
}
}
11.2 [[no_unique_address]]
[[no_unique_address]] 可以应用于非静态非位域数据成员,以表明它不需要唯一地址。实际上,它应用于可能为空的数据成员,编译器可以对其进行优化,使其不占用任何空间(类似于成员的空基类优化)。这样的成员可以与其他成员或基类共享地址。
struct Empty{};
template<typename T>
struct Cpp17Widget{
int i;
T t;
};
template<typename T>
struct Cpp20Widget{
int i;
[[no_unique_address]] T t;
};
static_assert(sizeof(Cpp17Widget<Empty>) > sizeof(int));
static_assert(sizeof(Cpp20Widget<Empty>) == sizeof(int));
11.3 带消息的 [[nodiscard]]
与 [[deprecated("reason")]] 类似,nodiscard 现在也可以有原因了。
// 检测是否其受支持
static_assert(__has_cpp_attribute(nodiscard) == 201907L);
[[nodiscard("Don't leave me alone")]]
int get();
void f(){
get(); // 警告: 忽略使用 nodiscard 属性声明的函数的返回值:Don't leave me alone
}
11.4 构造函数的 [[nodiscard]]
此修复明确允许将 [[nodiscard]] 应用于构造函数(在 C++20 之前,编译器无需支持它)。
struct resource{
// 空, 抛弃无妨
resource() = default;
[[nodiscard("don't discard non-empty resource")]]
resource(int fd);
};
void f(){
resource{}; // 正确
resource{1}; // 警告
}
12. 字符编码
12.1 char8_t
C++17 引入了用于 UTF-8 字符串的 u8 字符字面量,但其类型为普通的 char 类型。由于无法通过类型区分编码,导致代码必须使用各种技巧来处理不同的编码。为了表示 UTF-8 字符,引入了新的 char8_t 类型。它与 unsigned char 具有相同的大小、符号、对齐方式等,但它是一个独立的类型,而不是别名。
void HandleString(const char*){}
// 在 C++17 下,要求使用不同的函数名来处理 UTF-8
void HandleStringUTF8(const char*){}
// 现在可以方便地使用函数重载处理
void HandleString(const char8_t*){}
void Cpp17(){
HandleString("abc"); // char[4]
HandleStringUTF8(u8"abc"); // C++17: char[4] 而UTF-8,
// C++20: 错误, 类型是 char8_t[4]
}
void Cpp20(){
HandleString("abc"); // char
HandleString(u8"abc"); // char8_t
}
12.2 更强的 Unicode 要求
现在明确要求 char16_t 和 char32_t 类型分别表示 UTF-16 和 UTF-32 字符串字面量。通用字符名称(\Unnnnnnnn 和 \uNNNN)必须对应于 ISO/IEC 10646 代码点(0x0 - 0x10FFFF,含 0x0 和 0x10FFFF),而不能对应于代理代码点(0xD800 - 0xDFFF,含 0xD800 和 0xDFFF),否则程序格式错误。
char32_t c{'\U00110000'}; // 错误: 无效的通用字符
13. 其它更新之语法特征
13.1 指定初始化器
现在可以初始化特定的(指定的)聚合成员,而跳过其他成员。与 C 语言不同,初始化顺序必须与聚合声明中的顺序相同:
struct S{
int x;
int y{2};
std::string s;
};
S s1{.y = 3}; // {0, 3, {}}
S s2 = {.x = 1, .s = "abc"}; // {1, 2, {"abc"}}
S s3{.y = 1, .x = 2}; // 错误, x 应当在 y 之前初始化
13.2 位域的默认成员初始化器
在 C++20 之前,要为位域提供默认值,必须创建一个默认构造函数;现在,可以使用便捷的默认成员初始化语法来实现这一点:
// C++20 之前:
struct S{
int a : 1;
int b : 1;
S() : a{0}, b{1}{}
};
// 自 C++20 起:
struct S{
int a : 1 {0},
int b : 1 = 1;
};
13.3 更多 typename 类型成为可选项
在只能出现类型名称的上下文中(例如类型转换中的类型、返回类型、类型别名、成员类型、成员函数的参数类型等),可以省略 typename:
template <class T>
T::R f(); // 正确, 全局作用域中函数声明的返回类型
template <class T>
void f(T::R); // 格式错误(无需诊断),尝试声明一个 void 变量模板
template<typename T>
struct PtrTraits{
using Ptr = void*;
};
template <class T>
struct S {
using Ptr = PtrTraits<T>::Ptr; // 正确, 位于一个定义类型 id 中
T::R f(T::P p) { // 正确, 类作用域
return static_cast<T::R>(p); // 正确, 一个 static_cast 的 type-id
}
auto g() -> S<T*>::Ptr; // 正确, 尾部返回类型
T::SubType t;
};
template <typename T>
void f() {
void (*pf)(T::X); // 用 T::X 初始化的 void* 类型变量 pf
void g(T::X); // 错误: 块作用域内的 T::X 不表示一种类型
// (尝试声明一个 void 变量)
}
13.4 嵌入的 inline 命名空间
inline 关键字允许出现在嵌入的命名空间定义中:
// C++20
namespace A::B::inline C{
void f(){}
}
// C++17
namespace A::B{
inline namespace C{
void f(){}
}
}
使用 using enum:
作用域枚举很棒,唯一的缺点是使用起来比较冗长(例如 my_enum::enum_value)。例如,在一个检查所有可能枚举值的 switch 语句中,my_enum:: 部分需要为每个 case 标签重复一次。使用枚举声明会将所有枚举名称引入当前作用域,使它们以非限定名称的形式可见,从而可以省略 my_enum:: 部分。这种方法也适用于非作用域枚举,甚至可以应用于单个枚举器。
namespace my_lib {
enum class color { red, green, blue };
enum COLOR {RED, GREEN, BLUE};
enum class side {left, right};
}
void f(my_lib::color c1, my_lib::COLOR c2){
using enum my_lib::color; // 引入作用域枚举
using enum my_lib::COLOR; // 引用非作用域枚举
using my_lib::side::left; // 引用单个枚举器 id
// C++17
if(c1 == my_lib::color::red){/*...*/}
// C++20
if(c1 == green){/*...*/}
if(c2 == BLUE){/*...*/}
auto r = my_lib::side::right; // 需要授权 id 才能使用 right
auto l = left; // 而 left 缺不需要
}
13.5 新表达式中的数组大小推导
此修复允许编译器像处理局部变量一样,推断新表达式中的数组大小。
// C++20 之前
int p0[]{1, 2, 3};
int* p1 = new int[3]{1, 2, 3}; // 要求明确的大小
// 自 C++20 之后
int* p2 = new int[]{1, 2, 3};
int* p3 = new int[]{}; // 空
char* p4 = new char[]{"hi"};
// 适用于带括号的聚合初始化
int p5[](1, 2, 3);
int* p6 = new int[](1, 2, 3);
13.6 别名模板的类模板参数推导
CTAD (Class Template Argument Deduction) 现在支持类型别名:
template<typename T>
using IntPair = std::pair<int, T>;
double d{};
IntPair<double> p0{1, d}; // C++17
IntPair p1{1, d}; // std::pair<int, double>
IntPair p2{1, p1}; // std::pair<int, std::pair<int, double>>
14. constinit
C++ 中存在一个臭名昭著的“静态初始化顺序混乱”问题,即来自不同翻译单元的静态存储变量的初始化顺序未定义。零初始化/常量初始化的变量可以避免这个问题,因为它们在编译时就已经初始化了。constinit 强制变量在编译时初始化,并且与 constexpr 不同,它允许非平凡析构函数。constinit 的第二个用例是用于非初始化的 thread_local 声明。在这种情况下,它会告诉编译器该变量已经初始化,否则编译器通常会在每次使用时添加代码来检查并根据需要进行初始化。
struct S {
constexpr S(int) {}
~S(){}; // non-trivial
};
constinit S s1{42}; // 正确
constexpr S s2{42}; // 错误,因为析构函数不是平凡的
// tls_definitions.cpp
thread_local constinit int tls1{1};
thread_local int tls2{2};
// main.cpp
extern thread_local constinit int tls1;
extern thread_local int tls2;
int get_tls1() {
return tls1; // 纯 TLS 访问
}
int get_tls2() {
return tls2; // 具有 TLS 初始化代码
}
15. 明确有符号数是二进制补码
即,现在有符号整数保证是二进制补码。这消除了一些未定义和实现定义的行为,因为二进制表示是固定的。有符号整数的溢出仍然是未定义行为,但现在这些行为已经明确定义:
int i1 = -1;
// 有符号数左移是负整数(在这之前这是未定义行为)
i1 <<= 1; // -2
int i2 = INT_MAX;
// 有符号整数的 “无法表示的” 左移(在此之前是未定义行为)
i2 <<= 1; // -2
int i3 = -1;
// 有符号数的右移是负整数, 执行符号扩展(在此之前是实现定义)
i3 >>= 1; // -1
int i4 = 1;
i4 >>= 1; // 0
// 将“无法表示的”值转换为有符号整数(在此之前是实现定义)
int i5 = UINT_MAX; // -1
16. __VA_OPT__ 用于可变参数宏
允许更简单地手动编写可变参数宏。如果 __VA_ARGS__ 为空,则展开为空;否则展开为 __VA_ARGS__ 的内容。当宏调用一个带有预定义参数(或多个参数)且后跟可选的 __VA_ARGS__ 的函数时,此功能尤其有用。在这种情况下,__VA_OPT__ 允许在 __VA_ARGS__ 为空时省略末尾的逗号。
#define LOG1(...) \
__VA_OPT__(std::printf(__VA_ARGS);) \
std::printf("\n");
LOG1(); // std::printf("\n");
LOG1("number is %d", 12); // std::printf("number is %d", 12); std::printf("\n");
#define LOG2(msg, ...) \
std::printf("[" __FILE__ ":%d] " msg, __LINE__, __VA_ARGS__)
#define LOG3(msg, ...) \
std::printf("[" __FILE__ ":%d] " msg, __LINE__ __VA_OPT__(,) __VA_ARGS__)
// OK, std::printf("[" "file.cpp" ":%d] " "%d errors.\n", 14, 0);
LOG2("%d errors\n", 0);
// Error, std::printf("[" "file.cpp" ":%d] " "No errors\n", 17, );
LOG2("No errors\n");
// OK, std::printf("[" "file.cpp" ":%d] " "No errors\n", 20);
LOG3("No errors\n");
17. 具有不同异常规范的显式默认函数
此修复允许显式声明的函数的异常规范与隐式声明的函数的异常规范不同。在 C++20 之前,这种声明会导致程序格式错误。现在允许了,当然,提供的异常规范才是实际的异常规范。当需要强制某些操作不抛出异常时,这非常有用。例如,由于强异常保证,std::vector 仅在其移动构造函数为 noexcept 时才将其元素移动到新的存储空间,否则元素将被复制。有时,即使元素在移动过程中可能抛出异常,也希望允许这种更快的实现方式。与往常一样,当标记为 noexcept 的函数抛出异常时,将调用 std::terminate()。
struct S1{
// 在 C++20 下格式错误, 因为隐式构造函数是 noexcept(true)
S1(S1&&)noexcept(false) = default; // 现在可以抛出异常
};
struct S2{
S2(S2&&) noexcept = default;
// 隐式生成的移动构造函数会是 `noexcept(false)`
// 由于`s1`, 现在它被管教是 `noexcept(true)`
S1 s1;
};
static_assert(std::is_nothrow_move_constructible_v<S1> == false);
static_assert(std::is_nothrow_move_constructible_v<S2> == true);
struct X1{
X1(X1&&) noexcept = default;
std::map<int, int> m; // `std::map(std::map&&)` 可以抛出异常
};
struct X2{
// 同隐式生成, 其为 `noexcept(false)` ,因为 `std::map` 之故
X2(X2&&) = default;
std::map<int, int> m; // `std::map(std::map&&)` 可以抛出异常
};
std::vector<X1> v1;
std::vector<X2> v2;
// ... 同时, `push_back()` 必须重新分配存储
// 高效使用 `X1(X1&&)` 来移动新的存储元素,
// 若其抛出异常则调用 `std::terminate()`
v1.push_back(X1{});
// 使用`X2(const X2&)`, 因此, 通过复制而非移动元素到新的存储位置
v2.push_back(X2{});
18. 销毁运算符 operator delete
C++20 引入了一个与类关联的 operator delete() ,它接受一个特殊的 std::destroying_delete_t 标签。在这种情况下,编译器不会在调用 operator delete() 之前调用对象的析构函数,析构函数需要手动调用。这在需要使用对象成员来提取释放其占用内存所需的信息时非常有用,例如,提取其有效大小并调用针对该大小的 delete函数。
struct TrickyObject{
void operator delete(TrickyObject *ptr, std::destroying_delete_t){
// 若无 destroying_delete_t ,则对象在此就会已被销毁
const std::size_t realSize = ptr->GetRealSizeSomehow();
// 现在我们手动销毁
ptr->~TrickyObject();
// 并释放其占用之内存
::operator delete(ptr, realSize);
}
// ...
};
19. 条件显式构造函数
正如 noexcept(bool) ,我们现在有了 explicit(bool) 来使构造函数/转换具有条件显式性。
template<typename T>
struct S{
explicit(!std::is_convertible_v<T, int>) S(T){}
};
void f(){
S<char> sc = 'x'; // 正确
S<std::string> ss1 = "x"; // 错误, 构造函数是显式的的
S<std::string> ss2{"x"}; // 正确
}
20. 特征检测宏
C++20 定义了一组用于测试各种语言和库特性的预处理器宏。完整列表参见 此处 。
#ifdef __has_cpp_attribute // check __has_cpp_attribute itself before using it
# if __has_cpp_attribute(no_unique_address) >= 201803L
# define CXX20_NO_UNIQUE_ADDR [[no_unique_address]]
# endif
#endif
#ifndef CXX20_NO_UNIQUE_ADDR
# define CXX20_NO_UNIQUE_ADDR
#endif
template<typename T>
class Widget{
int x;
CXX20_NO_UNIQUE_ADDR T obj;
};
21. 已知到未知边界数组的转换
允许将已知边界的数组转换为未知边界的数组引用。重载解析规则也已更新,使得大小匹配的重载优于大小未知或不匹配的重载。
void f(int (&&)[]){}; //未知边界的整数数组右值引用
void f(int (&)[1]){}; //已知边界的整数数组右值引用
void g() {
int arr[1];
f(arr); // 调用 `f(int (&)[1])`
f({1, 2}); // 调用 `f(int (&&)[])`
int(&r)[] = arr;
}
22. 隐式移动以适应更多局部对象和右值引用
在某些情况下,编译器允许用移动代替复制。但事实证明,这些规则过于严格。C++17 不允许在 return 语句中移动右值引用,不允许在 throw 表达式中移动函数参数,各种类型的转换也无理地阻止了移动操作。C++20 修复了这些问题,但仍然存在一些问题,参见 P2266R0 Simpler implicit move 。
std::unique_ptr<T> f0(std::unique_ptr<T> && ptr) {
return ptr; // copied in C++17(thus, error), moved in C++20, OK
}
std::string f1(std::string && x) {
return x; // copied in C++17, moved in C++20
}
struct Widget{};
void f2(Widget w){
throw w; // copied in C++17, moved in C++20
}
struct From {
From(Widget const &);
From(Widget&&);
};
struct To {
operator Widget() const &;
operator Widget() &&;
};
From f3() {
Widget w;
return w; // moved (no NRVO because of different types)
}
Widget f4() {
To t;
return t;// copied in C++17(conversions were not considered), moved in C++20
}
struct A{
A(const Widget&);
A(Widget&&);
};
struct B{
B(Widget);
};
A f5() {
Widget w;
return w; // moved
}
B f6() {
Widget w;
return w; // copied in C++17(because there's no B(Widget&&)), moved in C++20
}
struct Derived : Widget{};
std::shared_ptr<Widget> f7() {
std::shared_ptr<Derived> result;
return result; // moved
}
Widget f8() {
Derived result;
// copied in C++17(because there's no Base(Derived)), moved in C++20
return result;
}
23. 从 T* 到 bool 的转换是窄化转换
现在,指针或指向成员的指针类型到 bool 类型的转换是窄化,不能在不允许此类转换的地方使用。直接初始化时,nullptr 是可以接受的。
struct S{
int i;
bool b;
};
void f(){
void* p;
S s{1, p}; // error
bool b1{p}; // error
bool b2 = p; // OK
bool b3{nullptr}; // OK
bool b4 = nullptr; // error
bool b5 = {nullptr};// error
if(p){/*...*/} // OK
}
24. 弃用部分 volatile 关键字的用法
在各种情况下弃用 volatile:
对 volatile 限定变量使用内置的前缀/后缀递增/递减运算符
使用赋值给 volatile 限定对象的结果
当 E1 为 volatile 限定变量时,可以使用 E1 op = E2 形式的内置复合赋值(例如 a += b)
volatile 限定的返回值/参数类型
volatile 限定的结构化绑定声明
请注意,volatile 限定符指的是顶层限定符,而不仅仅是类型中的任何 volatile 关键字。例如,volatile int* px 实际上是指向 volatile int 类型的指针,因此它不是 volatile 限定符。
volatile int x{};
x++; // 废弃
int y = x = 1; // 废弃
x = 1; // OK
y = x; // OK
x += 2; // 废弃
volatile int //废弃
f(volatile int); //废弃
25. 弃用下标中的逗号运算符
下标内的逗号运算符已被弃用,以便将来支持多维(可变参数)下标运算符。目前的实现方法是使用自定义的 path_type,并重载 path_type::operator,() 和 operator[](path_type)。可变参数 operator[] 将彻底消除这种繁琐的操作。
// 当前方案
struct SPath{
SPath(int);
SPath operator,(const SPath&); // 以某种方式存储路径
};
struct S1{
int operator[](SPath); // 使用路径
};
S1 s1;
auto x1 = s1[1,2,3]; // 废弃
auto x2 = s1[(1,2,3)]; // 正确
// 未来方案
struct S2{
int operator[](int, int, int);
// 功者, 作为一个变量模板
template<typename... IndexType>
int operator[](IndexType...);
};
S2 s2;
auto x3 = s2[1,2,3];
26. 一些修正
这里一些小的修正。其中一些修正编译器已经实现了一段时间,但尚未反映在标准中。或许在实际应用中你不会注意到任何重大变化。
26.1 类模板参数推导中的初始化列表构造函数
// C++17
std::tuple t{std::tuple{1, 2}}; // std::tuple<int, int>
std::vector v{std::vector{1,2,3}}; // std::vector<std::vector<int>>
在这个例子中,两个语法相似的初始化操作却得到了截然不同的 CTAD (Class Template Argument Deduction) 推导类型。这是因为 std::vector 拥有并优先使用 std::initializer_list 构造函数,而 std::tuple`没有这样的构造函数,所以它优先使用复制构造函数。
通过这个修复,当从单个元素初始化时,如果该元素的类型是正在构造的类模板的特化或特化的子元素,则优先使用复制构造函数而不是列表构造函数。
// C++20
std::tuple t{std::tuple{1, 2}}; // std::tuple<int, int>
std::vector v{std::vector{1,2,3}}; // std::vector<int>
//这个例子出自 N. Josuttis 的《C++17》一书,第 9.1.1 节。现在它在不同的编译器中表现一致。
template<typename... Args>
auto make_vector(const Args&... elems)
{
return std::vector{elems...};
}
auto v2 = make_vector(std::vector{1,2,3}); // std::vector<int>
26.2 const& 修饰成员指针
问题在于,使用带有引用限定指针指向成员函数的右值时,不能使用 `.*`。现在可以了。
struct S {
void f() const& {}
};
S{}.f(); // 正确
(S{}.*&S::f)(); // 在某些旧的编译器上会报错
26.3 简化隐式 lambda 捕捉
这简化了 lambda 捕获。默认成员初始化器中的 lambda 表达式现在可以正式拥有捕获列表,它们的封闭作用域是类作用域:
struct S{
int x{1};
int y{[&]{ return x + 1; }()}; // 正确, 捕捉 'this'
};
即使在被废弃的语句和 typeid 中,实体也会被隐式捕获:
template<bool B>
void f1() {
std::unique_ptr<int> p;
[=]() {
if constexpr (B) {
(void)p; // 只是捕 p
}
}();
}
f1<false>(); // 错误, 不能通过值捕 unique_ptr
void f2() {
std::unique_ptr<int> p;
[=]() {
typeid(p); // 错误, 不能通过值捕 unique_ptr
}();
}
void f3() {
std::unique_ptr<int> p;
[=]() {
sizeof(p); // 正确, 未计算的操作数
}();
}
26.4 const 与默认复制构造函数不匹配
此修复允许类型具有默认的复制构造函数,该构造函数通过 const 引用接收参数,即使其某些成员或基类具有通过非常量引用接收参数的复制构造函数,直到实际需要该构造函数为止:
struct NonConstCopyable{
NonConstCopyable() = default;
NonConstCopyable(NonConstCopyable&){} // 通过非 const 引用取得
NonConstCopyable(NonConstCopyable&&){}
};
// std::tuple(const std::tuple& other) = default; // 通过 const 引用取得
void f(){
std::tuple<NonConstCopyable> t; // 在 C++17 下错误, 在 C++20 下正确
auto t2 = t; // 总是错误
auto t3 = std::move(t); // 正确, 使用移动构造
}
26.5 基于特化的访问检测
允许将受保护/私有类型用作模板参数,以实现部分特化、显式特化和显式实例化。
template<typename T>
void f(){}
template<typename T>
struct Trait{};
class C{
class Impl; // private
};
template<>
struct Trait<C::Impl>{}; // OK
template struct Trait<C::Impl>; // OK
class C2{
template<typename T>
struct Impl; // private
};
template<typename T>
struct Trait<C2::Impl<T>>; // OK
26.6 不可见的 ADL(Argument-Dependent Lookup) 和函数模板
后面跟着 < 且名称查找找不到任何内容或找到一个函数的未限定 ID 将视为模板名称,以便可能执行参数相关的查找。
int h;
void g();
namespace N {
struct A {};
template<class T> int f(T);
template<class T> int g(T);
template<class T> int h(T);
}
// 正确: 查找 `f` 无果, `f` 视为模板名
auto a = f<N::A>(N::A{});
// 正确: 查找 `g` 找到一个函数, `g` 视为一个模板名
auto b = g<N::A>(N::A{});
// 错误: `h` 是变量, 非模板函数
auto c = h<N::A>(N::A{};
// 正确, `N::h` 是有效 id
auto d = N::h<N::A>(N::A{});
在极少数情况下,如果函数中使用了运算符 <(),这可能会破坏现有代码,但委员会认为这是一个极端情况:
struct A {};
bool operator <(void (*fp)(), A);
void f(){}
int main() {
A a;
f < a; // C++20 前正确, 现在错误
(f) < a; // 正确
}
26.7 指定何时需要 constexpr 函数定义来进行常量求值
此修复方案指定了 constexpr 函数的实例化时机。这些规则相当复杂,但大多数情况下都能正常工作。我不会在此赘述,而是只举几个例子来说明问题。
struct duration {
constexpr duration() {}
constexpr operator int() const { return 0; }
};
// duration d = duration(); // #1
int n = sizeof(short{duration(duration())}); // 自 C++20 起总是有效
请记住,特殊成员函数只有在使用时才会被定义。按照 C++17 的术语,移动构造函数在这里既没有使用也没有定义,因此程序应该是不规范的。但是,如果取消第 1 行的注释,移动构造函数就会使用和定义,程序也就变得可以正常运行了。这显然是不合理的,规则也已经修改以反映这一点。
另一例:
template<typename T> constexpr int f() { return T::value; }
template<bool B, typename T> void g(decltype(B ? f<T>() : 0));
template<bool B, typename T> void g(...);
template<bool B, typename T> void h(decltype(int{B ? f<T>() : 0}));
template<bool B, typename T> void h(...);
void x() {
g<false, int>(0); // OK
h<false, int>(0); // error
}
这里有一个 constexpr 模板函数,它可能会以 int 类型实例化,这会导致错误,因为 int::value 是错误的。然后有两个函数使用了 B ? f<int>() : 0,其中 B 始终为 false,因此 f<int>() 永远不需要。问题是:f<int> 是否应该在这里实例化?
新规则明确了常量求值的要求,此类表达式中的模板变量或函数总是会被实例化,即使它们并非表达式求值所必需。其中一种情况是带花括号的初始化列表,因此,在表达式 int{B ? f<T>() : 0} 中,f<T> 总是会被实例化,从而导致错误。
26.8 隐式创建对象以进行底层对象操作
在 C++17 中,可以通过定义、new 表达式或更改联合体的活动成员来创建对象。现在,请看以下示例:
struct X { int a, b; };
X *make_x() {
X* p = (X*)malloc(sizeof(struct X));
p->a = 1; // UB(Undefined behavior) in C++17, OK in C++20
return p;
}
虽然看起来很自然,但在 C++17 中,这段代码的行为是未定义行为,因为 X 的创建不符合语言规则,而写入一个不存在的实体的成员是未定义行为。针对这种情况,C++17 通过明确哪些类型可以隐式创建以及哪些操作可以隐式创建这些对象,来澄清相关规则。可以隐式创建的类型(隐式生命周期类型):
标量类型
聚合类型
具有任何合格的平凡构造函数和平凡析构函数的类类型
可以隐式创建隐式生命周期对象的操作:
始于 char、unsigned char 或 std::byte 数组生命周期的操作
operator new 和 operator new[]
std::allocator<T>::allocate(std::size_t n)
C 库内存分配函数: aligned_alloc, calloc, malloc, 和 realloc
memcpy 和 memmove
std::bit_cast
此外,伪析构函数(内置类型的析构函数)的规则也发生了变化。在 C++20 之前,它不起作用;现在,它会终止对象的生命周期。
int f(){
using T = int;
T n{1};
n.~T(); // 在 C++17 下无效, 在 C++20 下会终止 n 的生命周期
return n; //在 C++17 下正确, 在 C++20 下未定义, 现在 n 忆消亡
}
731

被折叠的 条评论
为什么被折叠?



