C++ 对象生存期(不)完全指南

标题:An (In-) Complete Guide to C++ Object Lifetimes

日期:2024/09/07

作者:Jonathan Müller

链接:https://www.youtube.com/watch?v=oZyhq4D-QL4

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注一:Gemini 排版非常好,就是不知道哪里冒出来的代码,读者需仔细甄别,以 幻灯片 为准。

备注二:其实个人之前已经做过总结,但是这位演讲者我挺喜欢的,就顺手转录过来了。

备注三:简单对照了一下,是把作者口述的部分直接转代码了,凑合看吧。


我今天要讨论的是对象生存期 (object lifetimes)。我最初给幻灯片的标题是《C++ 对象生存期完全指南》,但后来我觉得这个标题可能有点太雄心勃勃了。所以我在这里给自己留了点余地。事实证明,这确实是正确的,因为这属于那种我自己也想学习,所以才提交的议题。所以,在我写这个演讲的大纲时,我其实并不知道所有细节。

随着我学得越多,我越发意识到这份指南有多么不“完全”,而且至关重要的是,我发现标准中关于这个主题存在着多少 bug。我们接下来就会看到。

所以,这次演讲是关于对象生存期的。那么最直接的问题就是:什么是对象?什么是生存期?

我这里所说的“对象”,不是面向对象编程(OOP)意义上的对象。在 C++ 标准中,“对象 (object)”是一个专业术语。本质上,对象是 C++ 程序的基础。你在 C++ 程序中所做的一切——创建、销毁、引用、访问和操纵的——都是对象。这基本上就是你所做的全部事情。为了理解对象,我们需要理解三样东西:存储 (storage)、值 (value) 和类型 (type)。

存储、值与类型

让我们从存储 (storage) 开始。C++ 中基本的存储单元是字节 (byte)。在 C++ 程序中,我们有一块内存,它由一串连续的字节组成,每个字节都有一个唯一的地址。这就是计算机内存,里面有一些字节。每个字节存储着若干个比特位。我在图中标亮了其中一个字节,它存储的比特是 0x42

现在的问题是,这代表什么意思?这就涉及到了值 (value)0x42 到底是什么意思?

  • 它可以是 8 位的整数 65。

  • 它可以是字符 ‘A’。

  • 它可以是某个以 ‘A’ 开头的字符串的起始部分。

  • 它也可能是一个 32 位整数的一部分,仅仅是第一个字节,后面还有更多字节。

  • 或者任何其他东西,比如某种枚举类型。

它到底意味着什么?在《Elements of Programming》这本书里,对“数据元 (datum)”和“值 (value)”做了区分。数据元仅仅是 0 和 1 的序列,而数据元加上一种解释 (interpretation) 才构成一个值。所以我们有了一堆比特,当我们用一种特定的方式去解释它们时,这就创造了这些比特所存储的值。

观众提问:这个定义是你自己创造的还是引用的?
演讲者:这是《Elements of Programming》中对“值”的定义。哦,好的。是的,C++ 标准并没有真正以这种方式来定义它,但用这种方式来理解短语会更有意义。

接下来是类型 (type)。类型就是用来描述如何解释一个数据元的。所以,一个类型本质上就是一种从比特到其解释的映射。

  • 例如,我们可以有 unsigned char 类型,它占用一个字节的内存,我们直接将其解释为一个 8 位的无符号整数。

  • 或者我们也可以有 int 类型,它占用四个字节,我们将其解释为一个有符号的 32 位二进制补码整数。

  • std::string 则更复杂。它可能占用 24 个字节,其中前 8 个字节可能是一个指向其他内存区域的指针,等等。

所以,类型仅仅描述了如何去解释数据。

现在我们来看对象 (object)。非正式地讲,一个对象拥有一个特定的类型,占据一块特定地址的存储区域,并存储着一个值。

C++

// 这里我们有一堆对象
int x = 42;    // 对象 x,类型为 int,存储值为 42
float y = 3.14; // 对象 y,类型为 float,存储值为 3.14

x = 11; // 我们改变了对象 x 中存储的值,现在它存储的是 11

所以,这就是一个对象:它在内存中有一个地址,用于存储一个值,而这个值是由它的类型和存储在该地址的数据元共同定义的。

那么,有哪些东西不是对象呢?关键有两样:

  1. 函数 (Functions):函数本身不是对象。它们可能在计算机中占据物理存储空间,但它们不被视为对象。然而,一个函数指针是一个对象。一个函数可以隐式地转换为指向其自身的函数指针,这是一个值,可以被存储在一个对象里。

    观众提问:但是标准库里的 is_object 对一个指针类型返回 false
    演讲者:不,对于一个指针,指针本身是一个对象。好的。函数不是。

  2. 引用 (References):引用本身也不是对象,它们仅仅是对象的别名 (alias)

    C++

    int x = 42;      // 对象 x
    int& ref = x;    // ref 是对象 x 的一个别名
    

    我们有一个对象 x,然后我们给它起了一个别名,也就是引用 ref,它仅仅是同一个对象的另一个名字。所以,自然地,所有适用于原始对象的属性也同样适用于该引用。我们使用哪个名字来指代那个对象其实无关紧要,它们是别名,是等同的。

对象生存期基础

那么,什么是生存期 (lifetime)?一个对象的生存期,简单来说,是该对象的一个运行时属性。对象有很多属性,比如类型,而生存期是它的另一个属性。而且这是一个相当重要的属性,对吧?因为一个对象的所有其他属性,实际上只有在其生存期内才有效。特别地,在一个对象的生存期开始之前或结束之后,你如何使用该对象会受到严格的限制。所以,生存期是一个至关重要的属性。

简而言之,一个对象的生命周期如下:

  1. 分配存储空间 (Allocate storage):因为我们需要一个地方来放置我们的对象。

  2. 初始化对象 (Initialize an object):我把它放在引号里,因为我们稍后会看到原因。这一步开始了生存期

  3. 使用对象 (Use the object):现在我们有了一个存活在该存储空间中的对象。我们可以读取它的值,改变它的值,做任何我们想做的事。

  4. 销毁对象 (Destroy that object):这一步结束了生存期

  5. 回收存储空间 (Deallocate the storage)

这基本上就是每个对象经历的过程。所有有趣的部分都在细节里,比如生存期到底何时开始,何时结束,存储空间何时分配。

关于术语的一点说明:在标准中,它说对象可以被“创建 (created)”。这并不意味着它们的生存期开始了。它仅仅意味着我们现在有了一个可以讨论的、带有一系列属性的东西。然而,标准也说对象可以被“销毁 (destroyed)”。这实际上是你对一个对象执行的操作,并且它确实会结束生存期。

观众提问:那么,在这种情况下,“创建”是否等同于存储分配?
演讲者:不,“创建”仅仅意味着我们现在有了这个东西,我们之后可以讨论它。
观众提问:所以它甚至不保证有存储空间?
演讲者:是的。它只是说我们有了一个可以讨论的对象。比如说,它的初始化可能已经开始但尚未完成。甚至还没到那一步。它就像,一个对象是我们想要讨论的一个东西。
观众提问:就像一个对象在它被销毁后仍然存在,处于一个“已销毁”的状态。你只是分配了一个标识符。
演讲者:我为某样东西分配了一个标识符,我给某样东西起了个名字,这样我们就可以讨论它了。是的。
观众提问:你能给一个简单的例子,一个对象被创建了,但它的生存期还没有开始吗?
演讲者:可以。一个简单的例子是全局变量的初始化,或者线程局部变量的初始化。你有一个线程局部变量,但你还没有启动任何线程。对吧?这个对象已经被创建了吗?是的,因为你有一个声明创建了它,你可以谈论它。创建不是一个一次性的操作。它只是意味着我们想要抽象地谈论某个东西。比如,我们声明了一个对象。然后最终,一旦我们这么做了,我们就可以为它分配存储空间,开始它的生存期。但对象创建并不对应任何一次性操作。它只是说我们有了一个可以讨论的对象。
观众提问:我觉得你好像在谈论变量,而不是对象。这就是我困惑的地方。
演讲者:嗯,变量会创建对象。变量可能存在,但它的对象或……可能还没有被创建。你是这个意思吗?不,变量存在,对象也存在,但它的生存期可能还没有开始。它的存储空间可能还没有被分配。我们还没有到对它做任何事情的那个时间点。就像函数局部的 static 变量。是的。你在源代码里有这个函数局部的 static 变量,你可以谈论它。但在你调用那个函数之前,运行时什么都还没发生。对吧?
演讲者:好的,我们继续。这只是一个微妙的区别,其实并不那么重要,我只是想指出来。

此外,生存期是抽象机 (abstract machine) 发明的东西。它与物理 CPU 没有任何关系。它只是我们在标准中用来描述对象属性的一个概念。它不是任何实际发生的事情的真实属性。所有这些都只是“标准黑话”。我讨论的是抽象机上的细节,而不是物理 CPU 上的任何东西。

好的,让我们开始。这次演讲按层次结构组织,我们会一层层深入。


第一层:变量声明

让我们从最简单的开始:变量声明。这是我们将要多次看到的一句标准中的话,我们会看它的不同部分:

“一个对象通过定义 (definition) 被创建。”

这仅仅意味着,例如,当我们有一个变量定义时,这就创建了一个对象。

C++

int x = 42; // 这条定义创建了一个对象

在运行时,一旦控制流到达这里,存储空间就被分配,生存期也开始了。但我们可以开始谈论 x,因为它就在那里,它被这段代码创建了。然后我们可以使用它,最后结束它的生存期并回收存储空间。

让我们更详细地看看这些规则,存储空间究竟何时分配,生存期何时开始等等。为此,我们首先需要了解存储期 (storage duration)

存储期是对象的一个属性,它定义了包含该对象的最小潜在生存期。它取决于我们如何创建该对象。在这一层,我们只关心静态 (static)线程 (thread)自动 (automatic) 存储期,因为这些是声明所产生的。

  • 自动存储期 (Automatic storage duration):这是最简单的。当你在一个块作用域或参数作用域中有一个变量,且没有任何特殊关键字时,它就具有自动存储期。其存储持续到该块退出为止。

    C++

    {
        A a; B b; // 分配 a  b 的存储空间
        {
            C c; // 分配 c 的存储空间
        } // 回收 c 的存储空间
    } // 回收 a  b 的存储空间
    
  • 静态存储期 (Static storage duration):当你有一个在命名空间作用域的变量,或者使用了 staticextern 关键字时,它就具有静态存储期。它们在整个程序运行期间都存在。

    C++

    // 这些变量都有静态存储期
    A a;
    static B b;
    extern C c;
    // 程序启动时即为它们分配存储空间
    // 程序结束时才回收存储空间
    
  • 线程存储期 (Thread storage duration):这就像静态存储期,你需要使用 thread_local 关键字,但它会为每个线程创建一份独立的副本。存储空间在线程被创建时分配。

    C++

    thread_local A a;
    // 程序启动时,为主线程创建 a 的一个副本
    // 当新线程启动时,会为新线程分配另一个 a 的副本的存储空间
    // 线程退出时,回收其副本的存储空间
    

    观众提问:它被认为是已分配的吗?我的经验是线程局部变量在使用前并未初始化。
    演讲者:我谈论的是存储分配,不是生存期。
    观众提问:那么它被认为是已分配的吗?
    演讲者:是的。
    观众提问:它必须执行分配操作吗?
    演讲者:我的意思是,根据 as-if 规则,它可以在任何时候做。但它被认为是已分配的。是的。在抽象机上,我们分配了存储。
    观众提问:当你创建线程栈时,你就为局部变量获得了存储。
    演讲者:不,不,不,在 Windows 上不是这样。哇。是的。我不是在谈论任何硬件。我是在谈论抽象机分配存储。就像我们也不会在函数执行中途回收栈空间,对吧?

所以,我刚才谈论的是存储期。存储期和对象的生存期不是一回事。存储期必须比对象的生存期更长,否则就没有意义了。但总的来说它们是不同的。生存期是在我们同时拥有了存储空间并且完成了初始化之后才开始的。

那么初始化何时发生呢?这取决于存储期。

  • 对于自动存储期,它们实际上是匹配的。当我们分配存储空间时,我们也开始了生存期。(有一些条款和条件可能适用,我们稍后会讨论。)

  • 对于静态和线程存储期,情况就复杂了。我们有函数局部的 static,全局作用域的,有常量初始化和动态初始化,还有“nifty counters”等等。我曾经做过一个整整 60 分钟的演讲专门讨论静态初始化。你可以去看那个演讲了解细节,我不会在这里重复。总之,情况很复杂,它们会在某个时刻被初始化,然后我们就可以使用它们了。

另一个需要注意的点:通常情况下,一个对象的生存期可以开始,但它还没有一个已知的值。

C++

int x;      // 分配存储并开始生存期,但 x 的值是“不确定的”
std::cout << x; // UB:使用了一个不确定的值

当我们为一个对象分配存储空间时,该对象拥有一个不确定的值 (indeterminate value)。如果我们没有进行任何初始化(例如,因为它是一个 int,或者是一个有默认构造函数的类),那么它就保持着不确定的值。此时如果我们使用它,就会导致未定义行为 (undefined behavior, UB)

上面这段代码分配了存储并开始了生存期,因为我们没有做任何初始化,所以初始化算是“完成”了(因为什么都没做)。所以它有一个不确定的值。使用它(cout << x)是未定义行为,因为我们正在求一个表达式的值,而这个表达式的结果是一个不确定的值。我们必须先给它赋值,然后就没问题了。

C++

int x;
x = 11;         // OK
std::cout << x; // OK

赋值操作是没问题的,因为我们可以使用那个对象,它在其生存期内。问题在于读取操作会产生一个不确定的值。

这个规则实际上在 C++26 中将会改变,从“未定义行为”变为“谬误行为 (erroneous behavior)”。这是一个新术语。本质上,谬误行为是我们希望在很多情况下替代未定义行为的东西。编译器不被允许做任何事情,它只被允许发出一份诊断信息并终止程序的执行。这是一种更温和的未定义行为,使得使用未初始化内存变得更安全一些。当然,这个规则可能还会再次改变,所有这些都还在讨论中。

观众提问:当它说“允许在未指定的时间终止执行”,我理解这也包括了“不终止”。
演讲者:是的。
观众提问:这基本上是给了 memcpy 一个许可,让它在最后一个而不是第一个未初始化的读取之后才终止。
演讲者:是的。
观众提问:我可能理解错了,但如果它是未定义行为,不是本来就允许这么做吗?
演讲者:是的,但它也允许做更多的事情。
观众提问:那现在它不被允许做什么?
演讲者:发射导弹。它不能再基于“这会导致未定义行为”这一事实来优化掉一个分支。
观众提问:所以它甚至不能把它当作初始值为零来处理?
演讲者:不能。它不能因为这是未定义行为就进行优化。
观众提问:所以如果你打印一个未初始化的 int,它不能打印 0?它必须终止?
演讲者:不,不,不。这似乎是你这里说的意思,对吧?你说它不能做除了那些之外的任何事。它被“允许”这么做。是的。它被“要求”这么做。但你说如果它能做别的事情,那它本来就允许做别的事情。如果它不能做别的事情,那它就不能打印 0。这是措辞问题。这不是标准的原话。这是非正式的描述,因为标准的原话有好几页。哦,我以为那是原话。不,不,不。抱歉,我应该说清楚。相关的论文是 Thomas Köppe 写的 P2795,《Erroneous Behavior for Uninitialized Reads》。是的,它是一种更温和的形式。抱歉,那不是原话。

好了,我们可以整天讨论变量声明,但我还有更有趣的东西要讲。


第二层:newdelete

“一个对象通过 new 表达式被创建。”

然后我们有一个对应的 delete 表达式来销毁一个对象。

C++

// new int 创建了一个对象并开始了其生存期
// 因为我们分配了存储并执行了初始化
int* p = new int(42);

// 我们可以像之前一样使用这个对象
*p = 11;
std::cout << *p;

// delete 销毁了对象,结束了生存期,并回收了存储
delete p;

这里非常基础,new int 创建了一个对象并开始了它的生存期,因为我们分配了存储并执行了初始化。在这里我们其实有两个对象:一个是在堆上的 int,另一个是指针 p,它本身也是一个对象,遵循上一层讲的规则。这里同样可能出现不确定的值,导致未定义行为,除非我们像之前一样先给它赋值。这部分其实更简单。


第三层:临时对象

“一个对象在临时对象被创建时被创建。”

这真是一个很棒的同义反复。例如,这里我们有一个函数,它接受一个引用,而引用需要成为一个对象的别名。

C++

void foo(const int&);

foo(42); // 42 是一个字面量,不是对象

但我们用 42 来调用它,42 不是一个对象,它是一个字面量。所以为了让代码工作,我们需要创建一个对象。

这是一个很好的例子,说明了我们可以在还没有为其分配存储空间的情况下就创建了一个对象。因为临时对象会通过复制省略 (copy elision) 神奇地被“提升”到它最终会落地的任何地方。所以当你创建它时,你实际上并不知道它的存储在哪里。但我们可以谈论这个必须存在于某处的临时对象,以便让这个调用能够工作。

这个过程的技术术语叫做“临时对象具现化转换 (temporary materialization conversion)”,这个词组很适合玩“猜词游戏”。它的描述读起来就像有人在凑字数:

“一个类型为 T 的 prvalue (纯右值) 可以被转换为一个类型为 T 的 xvalue (亡值)。这个转换通过将该 prvalue 作为结果对象来求值,从而用该 prvalue 初始化一个类型为 T 的临时对象,并产生一个指代该临时对象的 xvalue。”

谢谢你,Jens(暗指 Jens Maurer,C++标准委员会的核心人物)。

简单来说,我们会创建一个由 prvalue 初始化的对象。这在以下情况会发生:

  • 当我们将引用绑定到一个 prvalue 时(必须是 const 左值引用或右值引用)。

  • 当我们在一个 prvalue 上进行成员访问时。this 指针需要一个内存地址,所以我们需要创建一个临时对象。

  • 当我们使用一个数组 prvalue 时。数组很喜欢退化成指针,所以我们需要创建一个对象让它工作。

  • 一个有趣的情况是,当我们丢弃一个返回 prvalue 的函数调用的结果时。

考虑一个按值返回 std::string 的函数:

C++

std::string get_string();

get_string(); // 结果被丢弃

这个函数调用的结果是一个 prvalue。但我们想销毁这个字符串以释放它的内存。所以我们需要调用析构函数,而析构函数需要一个对象。因此,我们从这个 prvalue 创建了一个对象,然后立即销毁它,仅仅是为了让析构函数的调用能够工作。

那么临时对象的生存期是怎样的呢?当我们创建临时对象时,它的生存期也开始了(当控制流到达时)。然后,它在计算完整表达式的最后一步被销毁(通常是在分号处)。

C++

void foo(const A&, const B&);
foo(A{}, B{}); // 在调用 foo 之前创建两个临时对象
               // 在分号处销毁它们

这意味着在函数内部使用它们是安全的。否则,如果你创建对象,立即销毁,然后只传递一个悬垂引用,那就太傻了。

观众提问:如果我没记错的话,函数参数的求值顺序是不保证的。
演讲者:是的。
观众提问:那么这些对象被销毁或初始化的顺序有任何实际保证吗?
演讲者:按相反顺序销毁。
观众提问:嗯,是按照实际执行顺序的相反顺序吗?
演讲者:是的。
观众提问:但是否有保证会按什么顺序执行?
演讲者:没有。没有这个保证非常重要。

不过,也有例外。这被称为临时对象生存期延长 (temporary lifetime extension)

  1. 当我们将一个引用绑定到一个临时对象上时,该临时对象的生存期会被延长,以匹配该引用的生存期。

    C++

    {
        const std::string& s = "hello"; // 创建临时对象以绑定引用
        // ... s 可以在这里安全使用
    } // s 的生存期结束,临时对象在这里被销毁
    

    按照之前的规则,它会在分号处立即被销毁,这就太傻了。所以生存期被延长以匹配引用的生存期。我还没介绍引用的生存期,因为它们不是对象,但引用的生存期就像它是一个对象一样。所以这段代码是没问题的。

    然而,这里非常微妙,因为它只在直接绑定到临时对象时发生。

    C++

    std::vector<std::string> get_vec();
    
    // OK:直接将引用绑定到 get_vec() 返回的临时对象上
    const std::vector<std::string>& v = get_vec();
    
    // 错误:悬垂引用!
    const std::string& s = get_vec()[0];
    

    在第二种情况中,我们调用了 operator[],它返回一个引用。所以我们不是将引用绑定到一个 prvalue,而是将一个引用绑定到另一个引用。我们创建了一个临时 vector 对象来调用 operator[],它返回一个引用。这个返回的引用被绑定到 s。但是,当控制流到达分号时,我们创建的那个临时的 vector 对象被销毁了,于是 s 立刻就成了一个悬垂引用。所以,对于变量来说,临时对象生存期延长并不总是那么好用。

  2. 第二个例外是基于范围的 for 循环。在冒号后的范围表达式 (range expression) 中创建的所有临时对象,都只在循环结束时才被销毁。

    C++

    // 这段代码是完全正常的
    // get_vec() 创建的临时 vector 在循环结束时才销毁
    for (const auto& s : get_vec()) {
        // ...
    }
    
    // 这段代码现在也是正常的(C++23 起)
    // get_vec() 创建的临时 vector 在循环结束时才销毁
    for (char c : get_vec()[0]) {
        // ...
    }
    

    谢谢你,Nico(指 Nico Josuttis,他推动了这个改动)。是的,谢谢 Nico。这是一个最近的改动,使得第二种情况也能工作。在此之前,只有第一种情况是 OK 的,因为那时我们内部确实有一个指向临时对象的引用。而第二种情况会产生悬垂引用。但现在,两种情况都没问题了,因为我们人为地延长了生存期。

观众提问:这发生在 C++20 还是 23?
演讲者:23。
观众提问:这对于范围 for 循环中的任意数量的临时对象都成立吗?
演讲者:是的,范围表达式里的任意数量的临时对象都适用。这是唯一一个有这样特殊规则的地方。
观众提问:为什么这里要为 for 循环设置这个特例?
演讲者:去读那篇论文和大约 1500 封不同的邮件吧。是的,之前为范围 for 循环编写有 bug 的代码太容易了。现在仍然容易,但至少在这里不会了。


第四层:定位 new

“一个对象通过 new 表达式被创建。”

但有一种特殊的 new 表达式,叫做定位 new (placement new),它本质上是一个显式的构造函数调用。

C++

// 我们有一些内存
void* memory = ...;

// 我们可以在该内存中显式地创建一个对象
T* ptr = new (memory) T();

你必须小心一点,因为定位 new 可以被重载。本质上,你可以写一个接受任意参数并做任何事情的 new 表达式。所以,当你想要调用你真正想用的那个定位 new 时,你必须使用 :: 来进入全局作用域,然后 static_castvoid*

C++

// 确保调用的是全局的 placement new
::new (static_cast<void*>(ptr)) T();

因为你可能写一个接受 int* 的重载并做任何事。所以你必须这样做。标准库里还有一个函数 std::construct_at,它能帮你做这件事。

有两点需要注意。首先,std::construct_atconstexpr 的,但定位 new 不是。不过有个例外,比如在 Clang 上,定位 new 只有在 std::construct_at 函数内部调用时才是 constexpr 的。我曾经想避免包含 <memory> 头文件,所以我写了我自己的 construct_at 重载,它接受一个特殊的标签类型,这样我就能在 Clang 下拥有 constexpr 的定位 new 了。不过,我在幻灯片的代码里不会用它,因为它接受一个 T*,在很多情况下我需要先 reinterpret_castT*,而我宁愿 static_castvoid*。所以我还是会坚持用定位 new

我们既然是手动创建对象,那就意味着我们也需要手动销毁它,因为编译器不会帮我们。为此,我们可以显式调用析构函数。

C++

ptr->~T();

这个语法有点奇怪,因为 T 必须是一个标识符,不能是关键字(比如你不能对 int 调用),也不能是命名空间限定的名称。你仍然可以销毁那些东西,只是不能用这个语法。你必须用 typedef 来让它工作,这有点奇怪和烦人。所以我实际上会使用标准库函数 std::destroy_at,它总是能工作。

那么,我们想在一些内存里创建对象,我们如何得到没有对象的内存呢?

  • malloc 给你存储空间,但不给你对象。

    C++

    void* memory = malloc(sizeof(int));
    // placement new 创建对象并开始其生存期
    int* p = new (memory) int(42);
    // 使用对象...
    // 显式销毁,结束生存期
    std::destroy_at(p);
    // 回收存储空间
    free(memory);
    

    这里,我们完全显式地控制了对象的整个生命周期。

  • operator new 是 C++ 版本的 malloc。注意,这不是 new 表达式,它只分配内存,不创建对象。但处理方式是一样的。

  • 更有趣的是,当你有一个 unsigned charstd::byte 的数组时。这会分配存储空间。

    C++

    alignas(int) std::byte buffer[sizeof(int)];
    // 在 buffer 中显式开始一个对象的生存期
    int* p = new (buffer) int(42);
    // 使用对象...
    // 结束生存期
    std::destroy_at(p);
    // 存储空间是自动回收的,因为 buffer 是栈上的
    

我们也可以重用一个已存在对象的内存

C++

int x = 11;
// 销毁这个 int
std::destroy_at(&x);
// 现在我们有了一个曾经是 int 的空壳
// 在同一个位置 placement new 一个新的 int
int* p = new (&x) int(42);
// 我们可以使用这个新对象
std::cout << *p;

这里发生了一些有趣的事情,我稍后会精确地讲。这结束了我们 int 对象的生存期。

但你必须非常小心。

  • const 就是 const。你不能在一个 const 对象的内存上调用定位 new,因为这意味着我们突然改变了 const 内存。这是未定义行为。

  • const 在堆上有点特殊。

    C++

    const std::string* p = new const std::string("hello");
    std::destroy_at(p);
    // 编译器仍然会插入对析构函数的调用
    // 这里字符串会被销毁两次!
    

    如果你有一个非平凡析构函数的类型,编译器会在作用域结束时插入析构调用。上面这段代码会销毁字符串两次。所以我们必须小心,在作用域结束前放回一些东西。通常,如果类型没有析构函数,比如 int,编译器实际上不会调用析构函数。

透明替换

让我们回到之前的代码:

C++

int x = 11;
std::destroy_at(&x);
new (&x) int(42);

// 问题:我们还能使用 x 吗?
std::cout << x; // ???

答案是,我们可以。这就要谈到对象的透明替换 (transparent replacement)

标准中有一段很棒的话:

“如果在某个对象的生存期结束后,一个新对象被创建,那么一个指向原始对象的指针、一个引用原始对象的引用、或者原始对象的名称,将会自动指代这个新对象,如果原始对象可以被新对象透明地替换。”

那么,“透明可替换”是什么意思?定义很复杂,但本质上,A 可以被 B 透明替换,如果:

  • A 和 B 使用相同的存储空间。

  • A 和 B 有相同的类型(忽略顶层的 cv 限定符)。

  • 我们没有涉及到 const 对象、基类或带有 [[no_unique_address]] 的成员。

在实践中,这意味着之前的代码是没问题的。我们对 int 进行了一次透明替换,所以我们仍然可以使用 x,因为它被透明地更新以指向新对象。同样,任何指向 x 的指针或引用也都可以继续使用。

观众提问:从编译器的角度看,这样做的坏处是它们不能把 x 放到寄存器里。
演讲者:是的。编译器可能不喜欢我们做的事情,但它必须支持。

std::launder

但是,当我们重用堆上一个 const 对象的内存时,情况就不同了。

C++

const int* pointer = new const int(11);
// 销毁
std::destroy_at(pointer);
// 重用内存,这本身是合法的
int* new_pointer = new (const_cast<int*>(pointer)) int(42);

// UB: pointer 仍然指向一个 const int,编译器认为它不会变
std::cout << *pointer; // 可能打印 11

这次替换是不透明的,因为涉及到了 const 对象。编译器假定 pointer 指向的 const int 永远不会改变。然后我们改变了它。虽然改变本身是合法的(堆上的 const 没那么严格),但编译器仍然假定 *pointer 没有改变,所以它被允许优化,比如直接打印 11 而不是 42。

怎么解决这个问题?跟我一起说:Launder

C++

// 使用 std::launder 来“修复”指针
std::cout << *std::launder(pointer); // OK,打印 42

std::launder 是一个神奇的恒等函数。它不改变指针的值,但它神奇地让代码变得正确了。

std::launder 同样可以帮助引用。但 std::launder 不是万能的,它不能阻止所有的 UB

C++

// 假设 float 和 int 大小和对齐相同
float* f_ptr = new float(3.14f);
std::destroy_at(f_ptr);
int* i_ptr = new (f_ptr) int(42);

// UB: 即使 laundered,f_ptr 指向的也不是一个 float
std::cout << *std::launder(f_ptr);

这里的替换是不透明的,因为 intfloat 是不同的类型。我们可以使用 i_ptr,但不能使用 f_ptr。我们那里没有 float 对象。即使我们 launder 了它,那里仍然没有 float。这仍然是未定义行为。

那么,什么时候你需要使用 launder 呢?

  • 当你重用 const 对象的存储时。(非堆的 const 对象你无论如何都不能重用其存储。)

  • 当你涉及到基类或者 [[no_unique_address]] 成员时。

  • 当你在重用内存时改变了类型。(我们马上会讲到)

总而言之,常规代码中几乎永远不需要 launder


第五层:隐式对象创建

“一个对象通过隐式创建对象的操作被创建。”

这又是标准里的一个伟大的同义反复。其意图是让下面的代码合法:

C++

// C 程序员风格的代码
int* p = (int*)malloc(sizeof(int));
*p = 42; // 这应该是合法的!

这段代码应该不是未定义行为。然而,根据我们目前讨论的所有规则,这应该是 UB,因为我们没有一个 int 对象,我们只有一些分配的存储空间。那么我们如何让这段代码良构 (well-formed) 呢?因为我们想让它良构。在很长一段时间里,根据标准,这确实是未定义行为,但没人对此做什么,因为显然它的意图是能工作的。然后,Richard Smith 来了,并在 C++20 中最终修复了它。

“某些操作被定义为可以隐式地创建并开始隐式生存期类型 (implicit lifetime types) 对象的生存期,如果这样做能让程序具有已定义的行为。”

换句话说,这是一个非常独特的情况:如果能帮到你,编译器会为你创建对象。编译器会特意为你避免未定义行为,这很不错。

那么,什么是“隐式生存期类型”?正式定义很长,但本质上,就是那些构造函数和析构函数什么都不做的类型。如果它们有副作用,编译器不能悄悄地确保那些副作用发生。但像 int、所有平凡类型,都是隐式生存期类型。

哪些操作会隐式创建对象?

  • malloc 及其变体,operator new 等分配函数。

  • 一个 unsigned charstd::byte 数组的生存期内。

    C++

    alignas(int) std::byte buffer[sizeof(int)];
    // 这隐式地创建了一个 int,因为我们需要它来让代码良构
    int* p = reinterpret_cast<int*>(buffer);
    *p = 42; // OK
    

    除了,这其实是未定义行为。因为标准中的一个 bug,你在这里需要 std::launder

    C++

    int* p = std::launder(reinterpret_cast<int*>(buffer)); // 因为 bug 才需要
    

    这对于教学来说真的很糟糕。我稍后会谈论这个问题,但现在,请假装 launder 不在那里。这是一个标准中的 bug,缺少了一些措辞。

  • memcpymemmove

    C++

    char buffer[sizeof(int)]; // char 不是 unsigned char 或 std::byte
                               // 所以这本身不会创建任何东西
    int i = 42;
    // memcpy 隐式地在 buffer 中创建了一个 int
    memcpy(buffer, &i, sizeof(int));
    // 同样,因为那个 bug,需要 launder
    int* p = std::launder(reinterpret_cast<int*>(buffer));
    std::cout << *p;
    

编译器是如何知道要创建什么对象的?答案很简单:它使用时间旅行

C++

alignas(int) std::byte buffer[sizeof(int)];
if (coin_flip()) {
    // 编译器回到过去,在 buffer 中放入了一个 int
    *reinterpret_cast<int*>(buffer) = 42;
} else {
    // 编译器回到过去,在 buffer 中放入了一个 float
    *reinterpret_cast<float*>(buffer) = 3.14f;
}

重要的是,reinterpret_cast 并没有创建对象。是上面的声明。一旦我们到达其中一行代码,编译器就会“时间旅行”回到过去,以便在那里放入一个 int,因为我们需要一个 int 来使代码良构。然后执行继续。或者,我们可以把它想象成量子力学:编译器在 buffer 中同时创建了一个 int 和一个 float,一旦你需要其中一个,波函数就坍缩了。

观众提问:实际上,编译器是在这样做吗?还是这只是抽象机?
演讲者:这是为了抽象机。是的,这只是逻辑上的。为了实现这一点,编译器不需要做任何事情。这是一种确保行为不是未定义行为的机制。是的。这是标准委员会(Richard Smith)想出来的一个巫毒戏法,让它能够工作。

一旦我们这么做了,比如在 buffer 中放入了一个 int,那么 buffer 中就有一个 int 了,一直都有一个 int,并且永远也不会有除了 int 之外的其他东西。所以你之后就不能把它用作 float 了。

std::start_lifetime_as

当你从网络读取数据时:

C++

// bytes 是从网络来的
Data* data = reinterpret_cast<Data*>(bytes);
use(data); // 这可能是 UB!

这可能是未定义行为,取决于 bytes 是如何得到的。如果它们来自一个被“魔法祝福”可以为你创建对象的函数,那就没问题。但如果来自其他任何地方,就可能是 UB。为了修复它,我们需要一种方法来显式地、隐式地在该处创建对象。

你可能会想,简单,用定位 new

C++

new (bytes) Data;

然而,如果 Data 是可平凡构造的,这不会执行任何初始化,但定位 new 开始了新对象的生存期。这意味着之前存储在那里的任何值在语义上可能已经改变了。这不保证会打印出实际在那里的数据,因为我们创建了一个新对象,旧对象的所有属性(包括值)都不再适用。

所以,正确的修复方法是调用 std::start_lifetime_as (C++23)。

C++

Data* data = std::start_lifetime_as<Data>(bytes);
use(data); // OK

std::start_lifetime_as 就像定位 new,但它真的什么都不做。我们只是说:“这是一个缓冲区,在里面开始一个 Data 对象的生存期,然后给我一个指针。”然后我们就可以使用它了。这没问题。

std::start_lifetime_as 的一个实现可以是这样的:

C++

template <class T>
T* start_lifetime_as(void* p) {
    // memmove 隐式地在目标存储中创建对象
    memmove(p, p, sizeof(T));
    // 需要 launder,因为我们不知道 p 来自哪里
    return std::launder(static_cast<T*>(p));
}

当你重用存储时,比如在一个 int 上调用定位 new,这会隐式地结束 int 的生存期,并开始新对象的生存期。这也意味着内存泄漏不是未定义行为,只是内存泄漏。


第六层:出处 (Provenance)

C++

int x = 42, y = 11;

do_something(&x);

return y; // 编译器优化为 return 11;

编译器会优化这段代码为 return 11,因为它看到我们没有碰 y。但如果 do_something 是这样的呢?

C++

void do_something(int* p) {
    // 假设 &x + 1 恰好等于 &y 的地址
    *(p + 1) = 99; // 这改变了 y!
}

如果恰好 &x + 1 的地址就是 &y 的地址,那么这个函数就改变了 y。但这必须是未定义行为,否则优化就不可能了。

原因在于:仅仅因为两个指针相等,并不意味着它们指向同一个对象。 仅仅因为两个指针存储了完全相同的比特位,指向同一个地址,不意味着它们指向同一个对象,你也不能用一个来代替另一个。

逻辑上,一个指针是一个序对 (pair):(地址, 出处)

  • 地址 (address):内存中的物理位置。

  • 出处 (provenance):标识该指针所属的对象或内存分配。

一个指针的解引用只有在以下情况下才有效:地址在该出处允许的地址范围内,并且该地址上当前对象的出处与指针的出处相匹配。至关重要的是,指针算术不改变出处

所以,在之前的例子中,即使 &x + 1&y 的地址相同,它们的出处也不同。解引用 p + 1 是 UB,因为 x 的出处不允许这次解引用。

演讲者:出处(Provenance)不是标准中使用的术语。它是编译器优化者使用的术语。我在这里用它来解释 std::launder 的作用。std::launder 不是一个神奇的恒等函数,它更新了出处。它保持地址不变,但更新了出处。我认为这样比说它“不知怎么地就让代码 OK 了”要清晰得多。

现在,我们再看那个 const 堆对象的例子:

C++

const int* pointer = new const int(11); // 出处 A
std::destroy_at(pointer);
new (const_cast<int*>(pointer)) int(42); // 创建了新对象,出处 B

我们不被允许使用旧的 pointer,因为出处不匹配。为了修复它,我们使用 launder,因为它本质上是更新了出处,使其正确。

观众提问:那么,如果你 launderp + 1,它能工作吗?
演讲者:我认为不行。launder 有一些前置条件。launder 的一个前置条件是,通过给定指针可达的所有存储字节…
另一位观众:是的,结果也必须通过给定指针可达。因为你不能解引用一个尾后指针,所以没有字节可以从尾后指针到达,所以 launder 帮不了你。

总结一下出处:

  • 每个对象都有独特的出处。

  • 数组中的所有对象具有相同的出处,所以你可以用指针算术在它们之间移动。

  • 当你重用一个对象的内存时,这会改变出处,除非你进行的是透明替换


第七层:类型双关 (Type Punning)

严格别名规则 (Strict Aliasing Rule)

C++

int i = 42;
float* f_p = reinterpret_cast<float*>(&i); // 这本身是 OK 的
*f_p = 3.14f; // UB!

我们常说“你不能在不相关的类型之间 reinterpret_cast”,但这不完全对。你可以 cast。你不能做的是通过一个与存储对象的实际类型不“相似”的类型去访问它

标准说,访问时使用的类型必须与存储在该地址的对象的类型相匹配。这意味着我们可以这样做:

C++

int i = 42;
// 销毁 int,在原地创建一个 float
new (&i) float(3.14f);
// 现在我们可以通过 float 指针访问它了
float* f_p = reinterpret_cast<float*>(&i);
*f_p = 1.0f; // OK

// 但我们不能再把它当作 int 使用了,因为那里没有 int 了
// i = 10; // UB!

严格别名规则有两个例外:

  1. 你可以将其解释为有符号或无符号的对应类型。

  2. 你可以将其解释为 charunsigned charstd::byte。这个规则的意图是让你能够访问对象的对象表示 (object representation),即它所占据的字节序列。

    C++

    int i = 42;
    // 这应该是合法的,但因为标准里的一个 bug,它目前是 UB
    unsigned char* p = reinterpret_cast<unsigned char*>(&i);
    for (size_t k = 0; k < sizeof(int); ++k) {
        std::cout << (int)p[k] << " ";
    }
    

    这里的问题是,因为对象表示的规范方式被意外改变了,我们实际上没有一个数组来进行指针算术。有一个正在审议的提案来修复这个问题。

安全的类型双关方法

  • memcpystd::bit_cast:这不会有别名问题,因为你把字节复制到了一个不同的对象里。

    C++

    int i = 0x41424344; // 'ABCD'
    float f;
    // 假设 sizeof(int) == sizeof(float)
    std::memcpy(&f, &i, sizeof(f)); // OK
    f = std::bit_cast<float>(i); // C++20, 更好
    
  • 指针互换 (Pointer-interconvertible):一个 struct 和它的第一个非静态数据成员(如果是标准布局)是“指针可互换的”。你可以用 reinterpret_cast 在它们之间转换指针。

    C++

    struct A { int i; double d; };
    A a;
    int* p = reinterpret_cast<int*>(&a); // OK
    *p = 42; // OK
    

不安全的类型双关方法

  • union:通过 union 进行类型双关是不行的。

    C++

    union U { int i; float f; };
    U u;
    u.i = 42;
    // 当你给 f 赋值时,你就结束了 i 的生存期,开始了 f 的生存期
    u.f = 3.14f;
    // std::cout << u.i; // UB!
    

    例外是共同初始序列 (Common Initial Sequence)。如果你有一个 union,它的成员是两个 struct,而这两个 struct 都以相同的成员序列开头,那么你可以通过任一 struct 访问这个共同的前缀。这与生存期无关,只是一个让访问合法的编译器技巧。


无效指针和僵尸指针

当我们在对象生存期之外访问它时会发生什么?

  • 僵尸指针 (Zombie Pointers):指向生存期已结束、但存储尚未被回收的对象的指针。

    C++

    int* p = new int(42);
    std::destroy_at(p); // 结束生存期,但存储还在
    // *p = 10; // UB: 不能访问值
    // p == nullptr; // OK: 可以比较
    

    你只能以有限的方式使用它们(比如比较、取地址),但不能解引用来访问值。

  • 无效指针 (Invalid Pointers):指向已被回收的存储的指针。

    C++

    int* p = new int(42);
    delete p; // 回收存储,p 变为无效指针
    // 几乎任何对 p 的使用都是“实现定义的行为”
    // bool b = (p == nullptr); // 实现定义的,可能为 true,可能崩溃
    

    “实现定义的行为”可能包括程序崩溃。

这在原子操作和无锁数据结构中会引起严重问题。演讲者展示了一个经典的无锁栈的例子,其中 ABA 问题因为无效指针和出处问题而变得更加复杂。一个线程可以 delete 一个节点,而另一个线程仍然持有指向该节点的指针。这个指针变成了无效指针。当第二个线程在 compare_exchange_weak 中使用这个无效指针时,行为是实现定义的,可能导致崩溃或微妙的数据竞争。

这是 C++ 内存模型中一个已知的、严重的问题。有一个提案正在尝试解决它,方案包括让对无效指针的比较行为变得有意义,并提供一种递归更新出处的 launder 机制。但这有严重的优化影响,所以这在 C++ 委员会中仍然是一个开放的研究问题。


个人准则与总结

我以一些个人准则来结束这次演讲:

  1. 不要依赖隐式对象创建。它真的是为了让 C 风格代码能工作而设的“后门”。显式调用 std::start_lifetime_as 会让你的意图更清晰。

  2. 尽可能直接使用 placement newstd::start_lifetime_as 返回的指针。这样你就不必担心任何 launder 的问题,因为你已经在使用带有正确出处的有效指针了。

  3. 不要用 unsigned char 缓冲区来做延迟构造。你会遇到 reinterpret_cast 的烦恼。使用 union 来提供存储会更好,比如 union { char empty; T value; }。这样更优雅,你也不用担心各种问题。

我工作于 think-cell,我们正在招聘。如果你想和我们一起工作,我这里有一些袜子。

现在,我可能会也可能不会回答你们的问题,但你们可以自由提问。

(问答环节精选)

观众:使用 union 存储技巧的一个问题是,你不能用对象前缀中的魔法值作为标记(例如实现一个使用哨兵值的优化版 optional)。
演讲者:是的,如果你有哨兵值,那么它就给了你一个 T。不,哨兵值只是告诉你对象是否在那里。但你不能读取部分对象,因为它不是一个数组。

观众:我想说,我非常喜欢这次演讲,并为你愿意做这样的演讲而点赞。在 C++Now 做这种演讲需要很大的勇气。
演讲者:谢谢。

观众:我想补充一个准则:[[no_unique_address]] 的使用要极其小心。因为它会把很多只存在于抽象机中的问题,变成你代码中实实在在的 bug。它真的会让代码崩溃。因为 [[no_unique_address]] 的存在,你不再拥有你的填充字节 (padding bytes)。如果你在类内部对成员做 placement new(比如实现一个 optional),而外部用户对你的类型实例使用了 [[no_unique_address]],你的实现可能会覆盖掉被藏在你填充字节里的用户对象。

演讲者:是的,这正是为什么在“透明替换”的规则里有关于 [[no_unique_address]] 的警告。如果你做那种操作,你需要确保你的对象末尾没有任何填充字节。

时间不早了,大家想去吃午饭,我们就到这里吧。谢谢大家。