C++ 标准定义的用户类型分类¶
标题:A Tour of C++ Recognised User Type Categories
日期:2023/03/10
作者:Nina Ranns
链接:https://www.youtube.com/watch?v=pdoUnvTwnr4
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注:主题是聊 [class.prop];不会翻译标题,让 GLM 随机选了一个。
我的名字是 Nina Ranz。我从 2013 年起就是标准委员会的成员,但我大多数时候都躲在阴暗的角落里,很少出来和真人打交道。我所在的 C++ 核心语言工作组(Core Working Group),负责管理标准的前 16 个章节,这部分标准主要讨论语言语法,基本不涉及库。我就是那种确保所有逗号都各就其位、所有规则都清晰明确并表达其应有之意的人之一。显然,我对字母 C 也有一种特殊的偏好。这完全是无意的。
我为什么会在这里?
很多人在封城期间做了各种有趣的事情,比如学习一门新语言,或者补完所有没读过的书和没看过的电影。而我,则帮助了我亲爱的朋友 John Lakos。哎呀!没关系,一切都好。我帮助了我的好朋友 John Lakos 以及其他几位非常非常聪明的人写了一本书。我在其中的角色,基本上是一个语言律师(language lawyer)。我的职责是确保书中的写法绝对、完全正确。
那是一种度过封城期的有趣方式。这本书的目标读者是那些了解 C++03,并希望熟悉 C++11 和 C++14 中新特性的人。所以我们分解了所有被引入的新特性,然后根据它们的使用难度进行分类。“安全”(Safe)意味着你基本不可能用错。“有条件安全”(Conditionally safe)意味着你应该小心使用。“不安全”(unsafe)则意味着,只有在你真的必须用的时候才去用。我们还描述了常见的陷阱以及如何避免它们。
我强烈推荐这本书。不仅仅因为我参与了写作,更因为它确实是一本好书。
为什么只写到 C++11 和 C++14?因为把所有新版本都写进去会是一项浩大的工程。我想 C++17 的版本会在某个时候推出,但我们还没开始动工。
总之,在做研究的时候,我注意到我们现在已经有了一系列相当不错的、拥有特殊规定的类型。我觉得把它们都过一遍,看看我们何时需要它们以及为何需要它们,会是一件很有意思的事情。我们在标准中有特殊类型的原因是,我们想赋予用户定义的类型在某些情况下像内建类型一样行事的能力。有时我们会有新特性,而因为有了新特性,我们同样需要为该特定特性定义一个类型子集。然后,我们还有一些将在最后看到的类型,它们与某些常见的编程模式有关,这些模式以前是未定义行为,但我们现在想让它成为良定义的(well-defined)。所以我们算是“祝福”了这一子集类型,允许它们做一些通常不被允许的特殊事情。
但在开始之前,我想我们应该回顾一下 C++ 的对象模型。这是标准中的一句话。我会大量引用标准,因为我喜欢标准,而且我说不出比标准更好的话。
那么,C++ 对象模型。“C++ 程序中的构造创建、销毁、引用、访问和操纵对象。”这句话的要点是,没有对象,你就不可能有 C++ 程序。对象是 C++ 程序的基本构建块。
这是另一句与我们这次演讲非常相关的话。“一个对象在其构造期间、整个生命周期以及析构期间占据一块存储区域。”这里有几个词很关键,一个是“存储”(storage),我们会讨论它。我们还会讨论“生命周期”(lifetime),以及在构造和析构时发生的某些事情。
还有两个定义。这是对象表示(object representation)的定义,它实际上是一个对象所占据的所有比特位。然后我们还讨论值表示(value representation),它是那些比特位的一个子集,代表了一个特定类型对象的值。这两者通常是不一样的。它们是两个不同的东西。
那么,我们有哪些类型的对象呢?
我们有标量类型(scalar types),包括算术类型、枚举类型、指针类型、成员指针类型。
还有
std::nullptr_t
,这是一个确保空指针安全的类型。我们有用来定义类型的对象,即类类型(class types)。在有人纠正我之前要说明一下,在标准中,联合体(unions)也被定义为类类型。所以,我们这里也包括了联合体。
然后我们还有数组(arrays)。
当谈论用户定义类型的对象时,通常有几条规则适用于它们:
通常,它们的构造函数会被调用。当生命周期开始时,它们的析构函数通常会被调用。
它们的值通常通过拷贝构造函数和赋值运算符进行复制。
它们通常有某种我们不知道的内存布局。
通常,我们前面提到的对象表示和值表示是不匹配的。
然而,当我们在这里讨论这些特殊类型时,上述规则中的部分或全部将不再适用。
好了,我们开始吧。
1. 聚合类型 (Aggregate Types)¶
聚合类型在 C++11 中并不是什么新东西。我们在 C++98 中就有它们。如果你把数组看作是同类对象的集合,那么聚合真的就是非同类对象的集合。我们可以像初始化数组一样初始化它们。这种方式被称为聚合初始化(aggregate initialization)。它是 C++11 中试图实现统一初始化(uniform initialization)的一部分。我们希望能够用花括号初始化(或称列表初始化)来初始化所有类型的对象,这曾被称为统一初始化。这是一个绝妙的想法。
suffice to say is that these days we refer to it as unicorn initialization because it is as pretty as a unicorn and about as real as one.
(只需说,这些天我们称之为“独角兽初始化”,因为它像独角兽一样美丽,也像独角兽一样不真实。)
那么,我们如何初始化一个聚合呢?这里有一个聚合,它包含两个整数、一个字符和一个长整型。
// 生成代码,仔细甄别
struct Agg {
int i;
int j;
char c;
long l;
};
Agg x1 = {1, 2, 'a', 4L};
我可以使用列表初始化(也就是这些花括号),为我的每一个子对象提供一个初始化器。或者,我可以只为第一个提供初始化器,然后其他所有成员都会用空的花括号进行初始化。我甚至可以使用同一个类型的另一个对象来初始化,这样你就在两者之间进行了成员级别的初始化。这很酷。
但问题是,如果你在某个时候修改了你的聚合包含的内容,你的初始化器可能会有不同的含义。所以,我们在 C++20 中添加了指定初始化器(designated initializers)。现在你可以明确指出你想要初始化哪个子对象。
// 生成代码,仔细甄别
struct Agg2 {
int a = 1;
int b;
char c = 'z';
};
Agg2 x1{.c = 'b'}; // a=1, b=0, c='b'
这次我甚至有了一些默认初始化器。现在我写 x1
并为我的 c
成员提供一个初始化器。这真的很酷,因为现在当我再添加一个整数成员时,我的初始化器不会改变含义。
有几件事需要注意。其中之一是名称查找(name lookup)。在我的第二个例子中,我用一个 a
来初始化 b
。那个 a
实际上不是 x2
结构体的成员 a
,而是外部的 a
。
// 生成代码,仔细甄别
int a = 42;
Agg2 x2{.b = a, .a = 1}; // x2.b 被外部的 a 初始化为 42
另外,子对象的初始化是按照它们声明的顺序发生的。所以,如果你的初始化器顺序是错的,你可能会得到像我第三行那样的结果,.a
用 .b
来初始化,而此时 .b
还没有任何值。所以 a
会得到一个不确定的值。大多数编译器编写者会对此发出警告。但尽管如此,当你使用指定初始化器时,请确保你按照它们声明的顺序进行初始化。
聚合的定义已经改变了。在每一个标准版本中,它的定义都不同。这是看待我们在标准中如何工作的一个好方式。我们学习,我们扩展。我们努力把事情做对。但有时候,有时候我们做不到。
所以,这是我们聚合定义的一段旅程。让我们从 C++03 开始。
C++03: 聚合是一个数组或一个类,该类没有用户声明的构造函数,没有私有或受保护的非静态数据成员,没有基类,也没有虚函数。
到了 C++11,你会注意到我们对构造函数做了一些改动。这是因为 explicit
构造函数是 C++11 的一个新特性。但“用户提供”(user-provided)和“用户声明”(user-declared)的区别在于,在 C++11 中,你可以显式地 default
或 delete
构造函数。所以在 C++11 中,我们实际上允许一个类拥有一个显式 delete
或显式 default
的构造函数,它仍然是一个聚合。最后一行关于“花括号或等号初始化器”的规定,其实并不是在限制类型的范围,只是因为 C++03 中没有这些初始化器。所以这实际上没有改变任何东西。事实上,这是我们在 C++14 中首先移除的东西。所以现在你可以为聚合提供默认初始化器。如果你没有提供初始化表达式,它就会采用你的花括号或等号初始化器。
在 C++17 中,我们添加了基类——公有基类。看起来我们好像也动了构造函数,但实际上没有。之所以有“继承的构造函数”(inherited constructors)这一条,是因为我们添加了基类,而我们不希望把继承来的构造函数也算进去。那你如何初始化你的基类呢?嗯,你先初始化基类,然后再初始化非静态数据成员。
这里是一个有基类的聚合的例子:
// 生成代码,仔细甄别
struct Base { int b; };
struct Agg4 : Base { char c; long l; };
Agg4 x = {{1}, 'a', 2L};
首先,我用 {1}
初始化我的基类,然后我初始化 Agg4
的非静态数据成员。有一种叫做“花括号省略”(brace elision)的技巧,意味着你实际上不必在基类初始化器周围提供那两层花括号。你可以写成 Agg4 x = {1, 'a', 2L};
,它的含义完全相同。你省了两次击键。但也可能给你带来很多头痛,因为当你回过头来看你的初始化器时,你到底在初始化什么就不那么清楚了。但是,你知道,你开心就好。
然后 C++20 来了。我不确定你是否注意到了这一点。我们从“用户提供”回到了“用户声明”,也就是 C++03 的状态。所以,尽管我们之前明确允许了 default
和 delete
的构造函数,我们现在又把它们收回了。为什么?
因为有人指出了这个问题。这是一个用户声明的类。它有一个默认构造函数,但用户显然不希望这种类型的对象被默认构造。所以如果你尝试默认构造它,编译器会说,不,你不能那么做。
// 生成代码,仔细甄别
struct NoDefault {
NoDefault() = delete;
int i;
};
NoDefault nd1; // 错误,构造函数被删除
NoDefault nd2{}; // C++17 中是 OK 的,聚合初始化!哎呀。
但是如果你使用聚合初始化,一切都好。哎呀。或者像这样的例子,我们有一个值构造函数,并且这个值构造函数被显式 delete
了,因为无论什么原因,这个用户不希望从一个整数初始化这个类型。如果你尝试使用括号初始化,你会得到一个编译错误。但如果你尝试使用聚合初始化,它就编译通过了。哎呀。哎呀。所以,我们修复了我们的规则。甚至有一篇论文 P1008R1 你可以看看,它提供了动机和具体的措辞。这是一个很好的例子,展示了委员会是如何工作的。
有一个库特性 is_aggregate
。它被添加是为了一个特定的目的,即用于 in_place_construct
这样的库习语中,其中类型需要接受一些参数然后将它们转发给另一个类型。所以它们需要为此使用括号初始化,因为括号初始化和列表初始化有不同的语义。如果你尝试对非聚合类使用括号,你的 std::initializer_list
会被优先选择。这不太好。然而,如果你只对聚合使用括号,你会得到一个错误,因为你不能。那是假的。你不能用括号初始化一个聚合。所以你会根据这个库特性进行分支,对非聚合使用括号初始化,对聚合使用列表初始化。至少我们当时是这么做的。
然后有人说,嗯,我其实不喜欢那样。我们来修复聚合怎么样?我们为聚合添加括号初始化怎么样?这就是我们所做的。这就是那篇论文的内容。所以现在如果你尝试用括号初始化你的聚合,尽管你实际上没有一个接受那么多参数的构造函数,你仍然可以得到成员级别的初始化。
// 生成代码,仔细甄别
Agg x(1, 2, 'a', 4L); // 在 C++20 中 OK
有一些限制。不支持指定初始化器。允许窄化转换(narrowing conversions)。我没提这个,但是如果你使用列表初始化,无论在哪里使用,都不能有窄化转换。但在这里是允许的。它真的在试图模仿一个非聚合的初始化。你不能使用指定初始化器。对于在任何这些初始化器中创建的任何临时对象,有不同的规则。你不能使用 sizeof...
表达式。你也不能做整个花括号省略。你必须指定所有的花括号。所以,你知道,现在你得打那么多字了。
既然我们有了这个特性,我不太确定 std::is_aggregate
是否还有任何用处。我的意思是,它的动机消失了。我猜想在某个时候,这个 trait 也会消失。
好的,一个讲完了,还有七个。耶!
2. 标准布局类型 (Standard-Layout Types)¶
我喜欢标准布局类型。它们的名字起得真好。我试着用更好的方式来描述它们,因为标准布局类型确实有标准的布局。或者换句话说,它们有可预测的布局。标准非常明确地说明了它们的用途:它们是用来与其他编程语言(比如 C)编写的代码进行通信的。
它们长什么样?
所有非静态数据成员具有相同的访问控制。这是因为如果你看标准,我们唯一一次谈论非静态数据成员的布局,就是针对那些具有相同访问控制的成员。无论是
public
,private
还是protected
都不重要,但如果访问控制相同,我们就有一些关于它们如何布局的规则。所有非静态数据成员和位域要么首先在最派生的类中声明,要么只在一个基类中声明。这有点拗口。我们来看看这到底是什么意思。
我们这里有一个结构体
X
,它是标准布局。它没有非静态数据成员。没问题。然后我们有一个结构体
Y
,它有一些非静态数据成员,首次在结构体Y
中声明。没问题,这里没有。好的。我们有结构体
Z
,没有非静态数据成员,然后在其中一个基类中有一些,这里没有。同样,好的。然后如果你去看结构体
Q
,它在这里有一些非静态数据成员,然后又在……哦,我写错了。哦,那里本应该是Z
。抱歉。你们能假装这里写的是Z
吗?所以如果这里是Z
,那么我在这里有一些非静态数据成员,在这里也有一些非静态数据成员。那么这就不是一个标准布局了。为什么?因为我们从不讨论基类之间的布局。所以如果多个基类有非静态数据成员,那么我们同样无法推断它们是如何布局的。
没有需要不同地址的基类。好吧,如果我们不能……我们不能使用相同的地址。大多数时候,在 C++ 中两个对象不能占据同一个地址,除非……除非其中一个是另一个的子对象,或者至少其中一个是零大小的基类,并且它们是不同类型的。因为两个不同类型的对象不能存在于同一个地方。我们在 C++20 中对此做了一点扩展,现在它不说基类,而是说一个子对象。这是因为我们引入了一个很酷的东西叫做
[[no_unique_address]]
,它是一个属性,允许即使是非静态数据成员也可以占据另一个成员的地址,前提是其中一个是零大小的。这是一个属性,所以,你知道,你得到什么就是什么。但从中可以得出的要点是关于“不同类型”的。所以如果两个对象是相同类型的,它们绝不可能在同一个地址。所以如果你看结构体
A
,它是一个标准布局,没问题。结构体
B
有一个基类,没问题。C
有一个基类,没问题。然后
D
有B
和C
作为基类,它们都有一个A
类型的基类。这两个A
基类不能住在同一个地址。所以这违反了“基类拥有不同地址”的规则。然后
E
有一个带有A
类子对象的基类,然后它还有一个A
类型的非静态数据成员。这两样东西不能住在同一个地址,所以它不是一个标准布局类。
没有虚函数和虚基类。为什么?因为我们不想要任何虚函数表。
所有基类和非静态数据成员都遵循完全相同的规则。
没有引用成员,因为我们不讨论 C++ 中引用的表示。
我们可以用这样的类做什么?嗯,正如我已经说过的,布局真的只取决于非静态数据成员。这从规则中就能直接看出来。它有一个很酷的特性,就是整个对象的地址与第一个非静态数据成员以及所有基类的地址相同。所以你可以在基类、第一个非静态数据成员对象和外部对象之间进行指针的相互转换。
你还可以使用 offsetof
。现在 offsetof
对于标准布局类类型是良定义的,但对于非标准布局类型是条件性支持的。所以你的编译器可能会让你在非标准布局类上使用 offsetof
,只是这样的代码是不可移植的。
关于标准布局类型,还有一件很酷的事情。就是共同初始序列(common initial sequence)的概念。它是指当你声明非静态数据成员时,如果它们的类型是布局兼容的(layout-compatible),也就是实际上是相同的底层类型,并且它们在声明顺序上匹配,那么这就是两个标准布局类型之间的共同初始序列。你能用它做什么呢?哦是的,现在我们添加了 [[no_unique_address]]
,要么两者都需要有它,要么都不能有,因为它是一个属性,所以它可能起作用也可能不起作用。
所以你能做什么?你可以把它们放在一个联合体(union)里,然后你可以读取一个活跃(active)成员和一个不活跃(inactive)成员之间的共同初始序列。意思是,如果你有一个活跃成员,并且它与一个不活跃成员共享一个共同初始序列,你可以通过那个不活跃成员来读取那个共同初始序列。有一个限制,就是读取必须通过联合体对象进行,而不是通过指向不活跃成员的指针或引用。
我们来看看这是什么样子。我这里有两个结构体,A
和 B
。它们在那个 int
成员上共享一个共同初始序列。然后我把它们放进一个联合体里。我让 A
成为活跃成员。然后如果我通过 B
的 int
成员去读,那是没问题的。但是如果我尝试去读那个 char
,那就是未定义行为。
这看起来有点……但如果我用一些更好听的名字,我做的事情可能就更明显了。
// 生成代码,仔细甄别
// 假设 MsgA 和 MsgB 都是标准布局且有共同初始序列
struct MsgA { int type; /* ... */ };
struct MsgB { int type; /* ... */ };
union Msg { MsgA a; MsgB b; };
Msg msg;
msg.a = {1, /* ... */}; // 激活 a
if (msg.b.type == 1) { // OK: 通过 b 读取共同初始序列
// ...
}
所以如果我把我的类型 ID 作为共同初始序列,我就可以通过任何共享共同初始序列的联合体成员来检查类型 ID。然后我就可以根据类型 ID 做一些事情。
再次强调,读取需要通过联合体进行。而不是做像这样的事情:我绑定一个引用到不活跃成员,然后通过那个引用去读。这会破坏别名(aliasing)规则,你可能会得到不想要的结果。所以,就通过联合体去读。
有一个库 trait std::is_standard_layout
,在 C++11 中引入。我试着想出它的一些用途,但老实说我想不出来。如果有人知道任何用途,我非常乐意了解。我还试着写了一些代码示例,然后断言 std::is_standard_layout
,结果发现了不少 bug,这表明它没有被广泛使用,因为编译器们确实会修复你报告的、并且他们认为被广泛使用的 bug,因为他们是好人。所以,是的,你有那个库 trait,你能用它做什么,我不知道。
在炉边谈话中有一个问题是,我们应该以某种方式在标准中标记出哪些东西是有用的,哪些是没用的。Daisy 说了一句很酷的话,就是对一个人有用的东西不一定对另一个人有用。所以很难确定标准中什么是“有用”的,什么是“没用”的。但是这个,是没用的。我可没这么说。
3. 平凡可复制类型 (Trivially Copyable Types)¶
好的。在讲这个之前,我们需要回顾一下什么是平凡特殊成员函数(trivial special member functions)。
我们从平凡默认构造函数(trivial default constructor)开始。它不是用户提供的,意味着用户不希望对这个类型做任何特殊的事情。它没有虚函数和虚基类,这实际上意味着没有虚函数表。所有的基类和非静态数据成员都有平凡的默认构造函数,这只是意味着这个对象的所有子对象在构造时也什么都不用做。并且非静态数据成员没有默认成员初始化器。换句话D说,一个平凡的默认构造函数什么也不做。它是个空操作。
我们有平凡拷贝构造函数和赋值运算符。现在它说的是拷贝(copy),但每当我说拷贝时,我实际上也指拷贝或移动(move)。所以它是拷贝或移动构造函数,或者拷贝和移动赋值运算符。同样,它不是用户提供的。这个类没有虚函数,没有虚基类,所有的基类都有等等等等。同样的规则适用。
平凡析构函数(trivial destructor),同样,不是用户提供的,不是虚的,并且所有基类和非静态数据成员的析构函数都是平凡的。同样,对于这样的析构函数,当对象被销毁时,编译器什么都不用做,即使你显式调用析构函数或者析构函数被隐式调用。
那么,什么是平凡可复制类型?它是一个至少有一个非 delete
的拷贝操作的类型,意味着至少有一种方式我们可以复制这个对象,要么通过赋值,要么通过拷贝。在 C++20 中,我们修改了这个定义,也考虑了约束(constraints)。所以我们谈论的不仅是非 delete
的,还有“最具约束性”(most constrained)的拷贝操作。它拥有的所有拷贝操作都是平凡的,并且它有一个平凡的、非 delete
的析构函数。
这可能有点出人意料。但如果你想一想,一个拷贝操作可以是两件事之一。它可以是将一个类型的值赋给另一个,或者它可以被看作是销毁一个对象然后从你的源对象拷贝构造它。在后一种情况下,你确实关心析构函数。所以析构函数在那里,需要是非 delete
的。
有什么东西缺失了。对访问控制和更深层次的调用没有要求。对于哪个拷贝操作是非 delete
的也没有要求。这一点稍后会变得重要。
那么这有什么影响呢?值被包含在底层的字节表示中。所以当我们之前谈到对象表示和值表示时,对于一个平凡可复制类型,你的对象表示就是你的值表示。所以如果你取你的类型的比特位,然后直接把它们“轰”到另一个对象里,那个对象的类型将会有完全相同的值。这正是你能做的。你可以在这种类型的对象之间进行复制,你可以把它复制到一个 char
数组里,这实际上是把它表示为对象表示的一种方式,然后再把它复制回同一个类型的对象里。同样,你得到相同的值。酷。
不仅如此,编译器也可以这么做。所以编译器可以看到你的对象是平凡可复制的,然后它就懒得去管拷贝构造函数和赋值运算符了。它就直接做同样的那种比特“轰炸”。
有一个 trait std::is_trivially_copyable
。这实际上是一个有用的 trait。它在 C++11 中引入。如果你的对象不满足平凡可复制,而你做了像 memcpy
这样的操作,那就是未定义行为。所以确保你检查了这一点。但它不保证的是,你的对象在你做 memcpy
的上下文中真的想要被复制。
所以如果你写这样的东西,首先,别这么写,因为这很糟糕。但如果你想做类似这样的事:我有一个结构体 X
,这个结构体故意地只支持移动构造。然而,我这里有一个 copy
函数,它接受一个目标和一个源,然后做 memcpy
,它甚至检查了 is_trivially_copyable
。所以,你知道,没有未定义行为。但问题是,这真的可以吗?因为写这个类的人只希望发生移动构造。而这里发生的是从一个左值进行的拷贝。所以这可能不是用户想要的。所以你可能不仅想检查 is_trivially_copyable
,你还想检查 is_copy_constructible
或者 is_move_constructible
,看哪个在那个上下文中是相关的。
4. 平凡类型 (Trivial Types)¶
这让我觉得好笑,因为平凡可复制类型是如此有用。然后你来到平凡类型。我真的不知道该说什么。平凡类型作为一个定义出现,是因为我们需要讨论那些存在于 C++03 中的类型。所以一个平凡类型是一个既平凡可复制又平凡默认可构造的类型。就是这样。
你能用它做什么?什么也做不了。我们在标准中有它,是为了描述某些情况。但是作为一个用户,再次,这是你直接忽略的东西之一。你知道它存在,但你永远不需要用它。有一个 trait std::is_trivial
。我不知道该对这个 trait 说什么。它可能和 is_standard_layout
一样有用。你可以用它来检查你的类型是不是 POD(Plain Old Data),我们接下来会讲到。但除此之外,平凡类型没有任何专属于它们的特殊之处。你要么是想讨论平凡可构造性,这你可以用 trait 检查,要么是想讨论平凡拷贝构造性,同样,你也有一个 trait。但是“平凡”,它不是很有用。
5. POD 类型 (Plain Old Data Types)¶
说完这个,我们转向 POD 类型。我对 POD 类型有噩梦。书中关于 POD 的章节花了大约两个月才完成,因为有太多的规则在变化和演进。花了很长时间才把它写对。
但无论如何,什么是 POD 类型?我们在 C++98 中定义了它们,是为了与 C 兼容。它实际上是一组类型,其物理表示模仿 C 类型,并且在构造、析构和拷贝时的行为也像 C 类型。但是正如我们之前提到的,物理表示可以用在某些有用的方式中,而不需要整个构造、拷贝和析构部分。而且我们可以利用平凡可复制性,而无需真正关心标准布局的事情。
所以为了做得更好,在 C++11 中,我们决定将这两个属性分开。与对象布局相关的属性变成了标准布局类型。对于这些类型,你可以获得我们已经谈到的好处。而与对象构造、拷贝和析构相关的属性变成了平凡类型。虽然,再次,它不完全是平凡类型。我们关心的是平凡可复制类型,因为那是你为对象类型获得好处的地方。所以平凡类型只是为了覆盖 C++03 定义的整个子集。
这只是意味着原始的术语(POD)不再需要了。现在我们有了一种不同的方式来描述 POD 类型。我们先是弃用了这个术语,然后在 C++20 中移除了它。所以,我们现在不谈论 POD,而是谈论平凡的标准布局类型 (trivial standard-layout types)。
这里有一个小例子。这是标准中的字符串库。它曾经写的是 POD type
。现在它写的是 trivial standard layout
。所以它让标准稍微简化了一些。我想这又回到了炉边谈话。有人问你喜欢删除什么。这就是那种让人满足的事情之一。你知道,它让一切都变得稍微简单一点。你不需要维护好几个定义。
有一个库 trait std::is_pod
。它在 C++11 中引入,在 C++20 中被弃用。你能用它做什么。你可以检查你的类型是否可以与库的某些部分一起使用,但也就只能做这么多了。
6. 字面值类型 (Literal Types)¶
字面值类型伴随着向编译期求值的迁移而来。有一个整体的趋势是扩展在编译期能做的事情,其目标甚至是……好吧,不是可能,而是目标是使用这些 constexpr
构造来实现整个反射(reflection)。所以当我们开始讨论你可以在编译期创建什么对象时,我们需要为这些类型有一个定义。这就是字面值类型的由来。
所以一个字面值类型是这样一种类型:你可以在编译期创建它的一个对象。可以。但你不能保证一定能这么做。所以有一种方法可以在编译期创建它们,但不是所有可能的创建都可以在编译期进行。而且,也不能保证你可以在编译期使用那个对象。只是保证有一种方法让你能以某种方式创建它。
标准并不真正谈论“编译期”。标准谈论的是常量表达式(constant expressions)。所以每当我说常量表达式,你就想成编译期。我们假装编译器不存在。那么什么是常量表达式?它是常量初始化(constant initialization)的一个要求。我们通常讨论常量初始化,因为所有在编译期发生的事情都需要以某种方式被触发。而且它通常是由一个对象的创建触发的。所以它通常在某个初始化的上下文中。
我们有一些新的关键字。我们有 constexpr
,这是第一个被引入的。你有 constexpr
函数,它们可以在编译期被调用,但不一定非要在编译期被调用。你还有 constexpr
变量,它们在编译期被初始化,但它们也是常量,所以一旦你创建了它们,你就不能改变它们的值。
我们意识到这还缺了点东西。所以我们有了 consteval
,它适用于函数。consteval
函数只能在编译期被求值,意味着在运行时它们甚至不可用。你无法获取到它们。然后我们有了 constinit
,它适用于变量,这样的变量在编译期被初始化,但之后你也可以修改它们。
我们对常量表达式有限制,我曾考虑过要不要谈这个。但是很难列出所有在编译期可能做的事情。更多的是一个你不能在编译期做什么的问题。而且即使是这个集合,随着编译器经验越来越丰富,也在变得越来越小。但它基本上是两类东西:一类是那些在编译期根本不可能或不正确的事情,比如各种 locale
相关的东西以及其他。另一类是那些目前实现起来很困难的事情。
有一场非常好的演讲,来自 David,他是整个反射和编译期求值领域的关键人物之一。他谈到了你能在编译期做什么和不能做什么,以及编译器未来可能的发展方向。他还提到了一个非常有趣的事情,就是当你把求值移到编译期时,它节省了你的运行时间,但你同时也得不到你在运行时能得到的那些优化。所以他有一个很好的演示,展示了只要你让你的类稍微复杂一点,编译时间就会大幅增加。为了理解为什么会这样,我推荐 Andrew Sutton 的一篇论文,它解释了你能在编译期做什么和不能做什么,以及为什么。你为什么得到你得到的结果?你为什么没有那个常量灵活性?你为什么不用那个?
所以这些是字面值类型,那些你可以在编译期创建的东西。你可能会觉得 void
在这里很奇怪。它在那里是因为 constexpr
函数最初的要求是它们的返回值必须是一个字面值类型,这意味着 void
的 constexpr
函数是不被允许的。而你会有一个 void
的 constexpr
函数的原因,只是因为你遍历了你的代码,在所有可能的地方都贴上了 constexpr
,然后其中一个恰好调用了一个不返回任何东西的函数。所以我们让 void
成为了一个字面值类型。
但我们真正关心的是这些字面值类类型以及它们长什么样。
一个聚合类型。
或者一个至少有一个非拷贝或移动构造函数的
constexpr
构造函数的类型,意味着必须有某种方式在编译期创建这个东西。在 C++17 中,我们还把 lambda,抱歉,是闭包类型(closure type)加入了列表,它实际上就是 lambda。这是我们在标准中称呼 lambda 的另一种方式。它还说,每个构造函数调用和所有的花括号或等号初始化器都必须是常量表达式。
该类型的所有部分都必须是字面值类型。
它需要有一个平凡的析构函数。
在 C++20 中,我们做了两个改动。我们把平凡析构函数改成了 constexpr
析构函数。这是因为现在在 C++20 中,你的析构函数实际上可以有函数体。只要那个函数体是常量表达式,你就没问题。我们还移除了“每个构造函数调用……”那个要求。看起来我们移除了它,但如果你想一想,它其实不是必需的。因为如果你在谈论一个 constexpr
构造函数,那么这个构造函数调用的所有东西都需要是 constexpr
的,因此,“每个构造函数调用和完整表达式”就自然地归入了第一条规则,你实际上不必把它写出来。这是不言而喻的。
有一个 trait,一个可怜的小 trait,std::is_literal_type
,它在 C++11 中引入,C++17 中弃用,C++20 中移除。它的生命不长。原因是我刚才提到的,因为问题不在于你的类型是否能在编译期被构造,而在于这个特定的构造是否能在编译期发生。所以这是你需要问自己的。当我们指定某些东西时也是一样。我们不必指定这个类型必须有一种在编译期被创建的方式。我们必须说的是,当你像这样创建它时,它必须是一个常量表达式。所以是的,你不需要那个 trait。
我说的意思是,我们这里有一个结构体,它是一个字面值类型,我可以在编译期默认构造它,但我不能在编译期对它进行值初始化。这就是我的意思。所以我真正想问的是,我用来初始化的那个构造函数是不是一个常量表达式?这才是我关心的。
和几个人聊过之后,我甚至不确定我们是否需要字面值类型的定义。有可能,就像我们把子对象初始化的规则塞进了“构造函数必须是常量表达式”的整体规则中一样,我们可能在某个时候可以简单地移除字面值类型的定义,而只是通过常量初始化的规则来表达它。
7. 结构化类型 (Structural Types)¶
于是我们来到了相关的结构化类型。当谈论非类型模板参数(non-type template parameters)时,我们过去对能放什么类型有一些限制,我们不能放任何类型,这很不幸,因为为什么呢?我们现在有用户定义的类型可以在编译期创建了。所以为什么不能有用户定义类型的非类型模板参数呢?这正是我们所做的。
所以我们定义了结构化类型,它们现在可以作为非类型模板参数。它们包括那些通常的东西,然后还有字面值类类型,其所有子对象都是公有的、非 mutable
的,并且本身也是结构化类型。
你可能会想知道为什么。答案是,因为你需要有某种方式知道是否已经有了某个模板的特定实例化。所以你需要有某种方式来比较两种类型的对象。但是相等运算符在这里并不是正确的答案,因为你不仅需要知道这两样东西是否相同,你还需要能够查找它。所以编译器实际做的是,它为所有公有的非静态数据成员创建一个哈希值,然后把它挂在一个表里,然后你就可以根据这个哈希值来查找。
这是一个相当有限的定义。有论文正在尝试扩展它。我不知道这里的障碍在哪里。有一些人正在写一个提案来扩展这个,以允许某种方式指定即使是有非公有数据成员或 mutable
成员的类,也能够与非类型模板参数一起工作。敬请关注。
8. 隐式生命周期类型 (Implicit-Lifetime Types)¶
我们来到了最后一个。哦,太棒了。我们能讲完的。坚持住。
在讲这个之前,再次提醒一下什么是对象生命周期。同样,引用标准的话。“一个对象通过一个定义、一个 new
表达式、当隐式改变一个联合体的活跃成员时,或者当一个临时对象被创建时被创建。”太棒了。
然后我们有一些代码,非常常见的代码:
// 生成代码,仔细甄别
void* make_int() {
void* p = malloc(sizeof(int));
*(int*)p = 42; // 我们总是初始化
return p;
}
我们 malloc
了一些内存,然后我们初始化它,因为我们是好人,我们总是初始化所有需要被初始化的东西,然后我们返回它,我们觉得我们没问题。我有没有打错字?没有?好吧。但然后我去看我的定义,那里没有定义语句,没有 new
表达式,没有联合体或临时对象。所以那是未定义行为。是的。
或者你有这种情况。我 malloc
了一个数组,然后我计算出结尾在哪里。我甚至没用这个数组。我只是在做一些指针算术。
// 生成代码,仔细甄别
void do_stuff() {
char* p = (char*)malloc(10);
char* end = p + 10; // 指针算术
// ...
free(p);
}
然后我去看标准,看什么时候那个指针算术是良定义的。嗯,如果其中一个是空指针,它是定义的,但它不是。如果有一个数组对象,它是良定义的,但没有,因为没有 new
表达式。没有改变联合体。没有所有那些我现在记不起来的东西。所以我们落入了最后一个情况,就是这个行为是未定义的。而这就是 std::vector
。
这是 Bjarne 在这周说的一句话。他说,不是所有未定义行为都意味着编译器不会做一些良定义的事情。这就是其中一种情况,因为现在编译器实际上会针对这个进行优化。因为它们为什么不呢?如果它们对此做了什么,你就会破坏 vector
,你基本上就破坏了整个世界。所以这必须能工作。所以人们开始着手修复它。不,再次,不是因为它会坏掉,而是因为我们只是想堵上标准的漏洞,让它成为良定义的。我们是有点强迫症的人。
所以我们研究了那些类型,我们推断了那些类型是什么。它们通常是来自 C 的类型。所以它们有一个平凡的构造函数,一个平凡的析构函数。所以我们创建了一个类型子集,当它们被需要时,就会得到这种特殊的“凭空出现”的能力。我们创建了隐式生命周期类型,它们是:
标量类型
数组类型(当我们谈论数组对象时,我们不一定指数组的子对象,而是指数组本身)
聚合类
以及具有一个平凡析构函数和至少一个平凡构造函数的类。
现在,如果你非常仔细地看,你会注意到我们没有谈论聚合类的析构函数。再次,这只是为了展示委员会是如何工作的。关于这是否是一个足够好的定义,或者我们是否需要对聚合类的析构函数在隐式生命周期方面说些什么,有一场完整的讨论。但无论如何,这是目前的定义。
然后一旦我们有了这些隐式生命周期的规则,我们就必须修复我们的对象生命周期。这是它之前的样子。然后我们还说,你可以通过一个“隐式创建对象的操作”来开始生命周期。酷。然后我们还得做一些其他的事情。我们必须指定那些操作是什么。这就是隐式创建对象的操作列表。我们还对数组做了一点修复,我们说如果你创建了一个 char
数组和所有其他这些东西的数组,那么你也在该数组占据的存储区域内隐式地创建了对象。
所以现在那两个例子都能工作了,这真的很酷。
没有针对这个的 trait。没有 trait 的原因是,这不打算被刻意使用。你不应该走出去,试图让你的类型成为隐式生命周期类型,然后用它们做一些特殊的、花哨的事情。你只是,如果你必须写我展示的那种代码,你可能已经有那种代码了。尽量不要写新的。因为,原因在于这些隐式生命周期类型是平凡可构造和平凡可析构的。如果你只是以 C++ 设计用来使用对象的方式来使用它们,你的编译器无论如何都会围绕构造函数和析构函数进行优化。如果你在某个时候添加了构造函数和析构函数,那么它会对此进行推理并做正确的事情。但是,是的,它只是在那里堵一个漏洞。
总结¶
哦,太棒了。我们到了总结部分。
这是我们这次演讲中讨论的所有类型的列表。我把所有相关的 trait 都放上去了,我还写了你什么时候会关心它们。但我希望你已经领会到一些暗示,就是这些东西中的一些你永远不会关心。一些东西你应该知道它们存在。然后,你知道,和你的朋友们一起“极客一下”,显摆你知道这些规则。
但你真正关心的是:
标准布局类型。
你关心平凡可复制类型。
你关心字面值类型和结构化类型,从这个意义上说,你需要理解你需要做什么才能获得编译期初始化或者把东西放进一个非类型模板参数。
如果你想利用好用的聚合初始化,我猜你也需要理解聚合类型。
但是,你知道,平凡类型和隐式生命周期类型真的只是用来“极客一下”的。
就说到这里,我们结束了。
好的,有什么问题吗?
问答环节¶
观众:我有个问题。你是否知道这些类型 trait,它们能在 C++ 中实现吗?还是……
Nina Ranz:我想有一些是被指定的,我想 Marshall(Clow)对此会有更好的答案。我想其中一些你实际上不能特化。你不被允许特化。我想其中一些是由编译器实现的。是的,是的。你在库里得到它们。
Marshall Clow (来自观众席):是的,你必须在库里把它们存根(stub)出来。其中一些需要由编译器实现。你不能在库本身中实现它们,没有编译器的支持。好的。
观众:你好。你好。谢谢你的演讲。我不太确定我同意你对那些 trait 的评估,特别是标准布局和隐式生命周期。例如,你提到标准布局对于与 C 和其他语言集成很有用。所以,你可能想用 static_assert
来确保,就像你在幻灯片上加的那样,类似的事情发生,这可能是合理的。所以我看到了它的价值。另一件事是,你也想确保没有人错误地改变了它而你没有注意到。所以在那里放一个 static_assert
是有意义的。我想对隐式生命周期提出类似的论点。几天前我听了 Robert Leahy 的一场很棒的演讲,他展示了一个用例,他们想确保能够高效地将字节与对象之间进行转换,用于一个非常高速的性能数据库系统。他们正在使用,比方说,依赖于某个类型是隐式生命周期的函数,但他们无法真正断言它。所以我认为拥有那个 trait 实际上会很有价值,只是为了确保在你的系统中某些东西能工作。我实际上在和 Timor 谈,我们提议为此写一篇论文。但你似乎对此不同意。所以我想知道你对此的看法。
Nina Ranz:我不同意。我想我试图说的是,当我们谈论 is_aggregate
时,你实际上可以,你知道,你可以根据某个东西是否是标准布局来分支你的代码,如果它不是就做别的事情。抱歉,是聚合。或者对于平凡可复制也是一样。如果它是平凡可复制的,你做一件事,如果不是,就做另一件事。你不能真正为标准布局这样做,也不能真正为隐式生命周期这样做。它要么工作,要么不工作。但你是对的。如果你有一个断言只是为了确保你的代码工作正确,那绝对是用途之一。
观众:好的,所以当你在谈论“有用性”时,你可能指的是在它上面进行分支,但对于健全性检查(sanity check),拥有它是有意义的。
Nina Ranz:它确实有意义。好的,谢谢你。是的,你是对的。
观众:嗨,谢谢你的演讲。关于标准布局,有一件让我很恼火的事情是,很多次我想调用 offsetof
,但因为它是一个宏,它在模板中不工作。你知道为什么它在模板中不工作吗?就类型而言。是的。
Marshall Clow:就像 Marshall 说的,模板不是类型。它必须是一个类型。
观众:所以你必须有一个……当然,但我的意思是一个完全实例化的模板,好吗?宏 offsetof
对完全实例化的模板不起作用。
Nina Ranz:它不应该。如果它是标准布局的话就应该工作。是的。我们可以在之后看看示例代码,然后我可以给一个更好的答案吗?Inigo 会帮你的。我让 Inigo 帮忙。非常感谢。我们来谈谈那个,是的。我们之后看一个例子,看看发生了什么。
观众:是的,非常感谢你的演讲。我发现它非常有用。你提到了,那个是什么?[[no_unique_address]]
属性。是的。你指出如果……
Nina Ranz:不,不,不,不。不是的。在你之前,唯一能利用零大小的东西的情况是,如果它是一个基类,那么它实际上可以不占用任何空间。但如果它是一个非静态数据成员,并且它,它仍然没有数据成员,它必须占据一些空间。但如果你用 [[no_unique_address]]
注解它或者它旁边的成员,那么编译器就可以,可以“压平”这种类型的布局。
观众:好的。这回答你的问题了吗?听起来我好像误解了,但我会去查一下,如果我想,如果我有一个后续问题,我会在其他时间找你。
另一位观众 (可能是 Marshall Clow):好的。谢谢。哦天哪。我给你一个 [[no_unique_address]]
真的非常好的例子。大多数分配器(allocator)没有实际的状态。你知道,比如 std::allocator
,就没有实际的状态。标准库里的每个容器都有一个分配器。每个包含它的容器,除了 array
。好的。它们每一个都有分配器。如果你的标准库实现必须存储一个分配器,那在一个 64 位系统上,每个容器就是 8 个字节。那完全是浪费的空间。所以如果你有,你知道,如果你有一个没有状态的分配器,并且你可以使用 [[no_unique_address]]
,或者 C++17 之前大家用的各种 hacky 技巧,你就不必在每个 vector
、每个 list
、每个 deque
、每个 queue
、每个 stack
里都实际占用 8 个字节来存储一个空东西。所以,当你在写组件的时候,这是个大问题,因为那些东西被大量使用。所以,我只是想说那个。谢谢你的演讲。非常好。我想说我这周早些时候也看到了一个关于隐式布局类型,或者说隐式生命周期类型的演讲。是周一吗?我想是在这个房间。非常好。所以,抱歉?Robert Leahy。Robert Leahy,谢谢。所以,任何对隐式布局类型真正感兴趣的人,所有的细节,那是一个值得去听的演讲。谢谢。
主持人:谢谢。有一个来自在线参与者的问题。好的。是关于 std::launder
的。所以,问题是……
Nina Ranz:你有一个关于 std::launder
的问题?是的。Launder,对。
主持人:是的,问题是,对于隐式生命周期类型,我们有任何需要用到 std::launder
的地方吗?
Nina Ranz:Marshall,是的,请。
Marshall Clow:Robert Leahy 周一谈过这个。答案是什么?是。你能详细说明一下吗?我会告诉你。我会告诉你。……(听不清)……所以,再次,Robert Leahy 周一在他的演讲中谈过这个。他有一个例子,其中必须使用 launder
。
Nina Ranz:好的。我猜,去看他的演讲?就是这样吗?我们结束了?Pablo 只是跟着我。好的。非常感谢。谢谢你们。好的。非常感谢。谢谢大家。谢谢。