前言

本文简单梳理关于 C++ 对象的生存期以及严格别名规则下的表达式转换的相关细节。

关键字:对象模型、存储期(storage duration)、生存期(lifetime)、reinterpret_caststd::bit_cast(C++20)、std::start_lifetime_as(C++23)、std::launder(C++17)。

其实……就算不了解这些也能写出看着正常的代码(UB ≠ 真的有问题),我不太建议花时间在这种地方。另外,文章并非文档,有很多语言标准的细节都没有提及,这方面的内容还请自行查阅。

对象模型:字节与对象的澄清

当我们在聊对象的时候到底在聊什么?

struct foo {
    std::uint32_t a;
    std::uint32_t b;
};

// 一个简单的对象 f
foo f{2, 3};

C++ 作为一门系统编程语言,支持直接对虚拟地址空间的访问与修改,很容易得出经验结论:对象不过是一些存储在地址上的值。

foo foo 的值表示

进一步的经验结论是,这些值也可以重新解释(reinterpret)为不同类型。

foo-reinterpret 这些值也许可以按位(或字节)重新解释为……

但是经验结论真的正确吗?不妨先猜测下这段代码输出什么:

std::uint32_t bar(std::uint64_t &i, const foo &f) noexcept {
    if(f.a == 2) {
        i = 4;
    }
    if(f.a == 2) {
        return f.a;
    }
    return f.b;
}

int main() {
    foo f{2, 3};
    auto ret = bar(reinterpret_cast<std::uint64_t&>(f), f);
    std::cout << "ret: " << ret << std::endl;
}

由于假定对象可以重新解释,f 使用类型双关的技巧解释成为 std::uint64_t 类型的对象,并传入了 bar() 函数。很显然函数内第一个 if 条件为真,从而覆写 i,间接使得 {f.a, f.b} 的值变动为 {4, 0},第二个 if 分支因此跳过。这种推理可得出结论:函数 bar() 将返回 0。但是事与愿违,x86-64 平台下 gcc-13 的测试结果返回 2。

问题出现在哪里?问题出现在对 C++ 对象模型的错误理解。从编译器的角度来看,C++ 对象除了具有值以外,还有以下性质(property):

  • 存储期。
  • 生存期。
  • 类型
  • 一个可选的名字。

后面的章节将为对象的性质进行梳理。

reinterpret_cast:重新解释 bit

reinterpret_cast 即重新解释位模式。其功能大致分为两种:

  1. 指针和任意整数(合适大小和对齐)之间的转换。
  2. 不同类型的指针(或引用)之间的转换。

前面对象模型中也提到了类型(type)。尽管 reinterpret_cast 可以根据功能 2 任意转换类型,但是对象的类型具有严格别名(strict aliasing)的规则,它规定了往对象的值执行读写操作必须经过某种符合条件的类型的泛左值(glvalue)。除此以外,均为 UB。

简单来说,「某种」条件指的是两个(任意其一的)判断:一是相似类型,即相同的类型,但宽限至可加上 cv 限定等修饰;二是特许的 charunsigned charstd::byte 类型。

我认为其根本原因在于 TBAA(type-based alias analysis),编译器会假定一个地址不存在两种不同的类型(部分情况除外),因此一个类型的写操作不应影响到另一类型的读操作。

很显然前面例子的重新解释并不符合上述的严格别名规则,导致合法的性能优化反而生成错误的代码。

bit_cast:安全的类型“双关”

既然 reinterpret_cast 不能安全的做到类型双关,那么 static_cast 行不行,C-style cast 行不行?

首先 static_cast 也是不安全的,它一般用于隐式转换本来就有的操作和隐式转换的逆操作。有一条看似特殊的规则:void* 指针可以被 static_cast 转换为任意类型的指针,其实也不符合严格别名描述的前提。实际上 reinterpet_cast<AliasedType*> 的做法等价于 static_cast<AliasedType*>(static_cast<void*>(expression))

而 C-style cast(或者还包括显式类型转换的其它方式)同样不安全,因为它的做法只是按顺序尝试:

  • const_cast
  • static_cast
  • static_cast + const_cast
  • reinterpret_cast
  • reinterpret_cast + const_cast

为了高效且安全的实现类型双关,C++20 在标准库新增了 constexprstd::bit_cast 函数。只要是源和目标双方具有合适大小且为可平凡复制类型,均可使用:

#include <cstdint>
#include <bit>

int main() {
    double d = 0.1;

    // 别名违规
    // std::int64_t n = reinterpret_cast<std::int64_t&>(d);
    // std::int64_t n = *reinterpret_cast<std::int64_t*>(&d);

    // OK
    // std::bit_cast 在这里接收 const double &,返回 std::int64_t
    std::int64_t n = std::bit_cast<std::int64_t>(d);
}

唯一的不满在于它返回纯右值,我能不能原地进行双关?可以,但还需要了解更多的条条框框。

lifetime:对象不止存储

对象的性质有两个互相关联的性质:存储期和生存期。

一个对象的存储期可以是:automatic、static、thread 和 dynamic。它们决定了存储的分配(allocate)与解分配(deallocate)的时机。而存储期本身通常是由说明符或者所在命名空间决定的。比方说局部的无说明符对象就具有自动(automatic)存储期:代码块开始时分配,代码块结束时解分配。其它细则可以翻阅语言标准或者 cppreference

一个对象的生存期决定了对象的访问是否合法。换句话说,对象在生存期外的访问就是 UB。对象的生存期要求在存储期以内,一般情况下实际会在构造时开始生存期,在析构时结束生存期。

为什么要区分存储期和生存期?一个可能的原因是存储允许被不同的对象所重用(storage reuse)。

一切听起来都很自然,那么立刻看下这段代码:

const auto ptr = (std::string*)std::malloc(sizeof(std::string) * 4);
if(!ptr) {
    throw std::bad_alloc();
}
for(int i = 0; i < 4; ++i) {
    ptr[i] = std::to_string(i);
}

这段代码非常可疑,但从存储的角度来看又似乎合理:std::malloc() 提供了合适大小的存储可以容纳作为四个元素大小的 ptr 数组,然后通过赋值的方式存放四个实际的 std::string。但是从标准来看这就是 UB,因为对象的生存期尚未开始,却尝试执行访问操作。

const auto ptr = (std::string*)std::malloc(sizeof(std::string) * 4);
if(!ptr) {
    throw std::bad_alloc();
}
for(int i = 0; i < 4; ++i) {
    // OK
    new (ptr + i) std::string(std::to_string(i));
}

至于如何修复,一个办法就是显式的开始生存期:比如使用 placement new 完成构造。注意此时即使是平凡类型(比如提供 (int*)std::malloc(sizeof(int)) 存储),也需要显式的开始生存期。

对象可以使用定义、new 表达式、throw 表达式、更改联合体的活跃成员和求值要求临时对象的表达式显式创建。显式对象创建完全定义了所创建的对象。(来源:zh.cppreference

start_lifetime_as:隐式创建对象

平凡类型要显式开始生存期也太忍无可忍了,调用 std::malloc() 后直接使用数据本应是很自然的事情。一个实际场合是来自网络 IO 得到的原始(字节)数据,这里的数据是有实际意义的,难道要像上面那样 placement new 直接覆盖掉?

所以标准又追加了隐式创建对象的规则。大意是自 C++20 起,隐式生存期类型的对象不再需要显式开始生存期。隐式生存期类型的约束比平凡类型略微宽松,通常要求是聚合类,或者至少有一个平凡构造函数和一个平凡析构函数。

一些操作可以对隐式生存期类型施加隐式创建对象:

  • std::malloc()
  • std::memcpy() 或者 std::memmove()
  • char[]unsigned char[]std::byte[] 开始生存期。
  • operator new 或者 operator new[]

现在回到之前的例子,但是改成 int 类型:

const auto ptr = (int*)std::malloc(sizeof(int) * 4);
if(!ptr) {
    throw std::bad_alloc();
}
for(int i = 0; i < 4; ++i) {
    // OK
    ptr[i] = i;
}

由于 ptr 数组是一个聚合,符合对隐式生存期类型的约束,所以 std::malloc() 同时提供了存储和开始生存期,这段代码得以合法。注意如果把上述例子改为 std::string 是不可行的,因为后者不是隐式生存期类型。

隐式生存期启发了一种基于 std::byte 数组和 std::memcpy() 进行原地类型双关的用法:

int my_bit_cast(const void *ptr) noexcept {
    // 合适大小和对齐要求
    alignas(int) std::byte buffer[sizeof(int)];
    const auto retr = std::memcpy(buffer, ptr, sizeof(int));
    return *reinterpret_cast<int*>(retr);
}

此时 buffer[] 可以完成原地类型双关,重新解释为 int 类型(或者其它隐式生存期类型)。这里实现的是类似 std::bit_cast() 的函数,但它接收任意指向已提供存储的指针并解引用。不过标准库 std::bit_cast() 是一个 constexpr 函数,这是通过编译器开洞完成的。

NOTE: 只要实现符合标准,上述操作能被编译器优化为形如直接从 ptr 执行类型转换并解引用的效果。

// -O1 反汇编结果
my_bit_cast(void const*):
        movl    (%rdi), %eax
        ret

更进一步的,C++23 提供了显式的隐式创建对象的能力:std::start_lifetime_as

上述的例子不必再使用标准规定的 std::memcpy() 来间接完成双关了,对于指向已提供存储的指针 ptr,只需 *std::start_lifetime_as<int>(ptr) 即可完成操作。

launder:清洗指针来源

std::launder() 函数同样用于解决生存期问题(但是不算严格别名问题)。launder 是字面意思:清洗指针的来源,就像洗钱(money laundering)一样阻止编译器分析来源。

std::launder(T *p) 接收一个指向地址 X 的指针 p,返回仍在生存期内的位于地址 X 的对象的指针。也就是说,从旧对象的指针中获取新对象的指针。

std::launder() 的典型用途是对象重用。当指针 p 执行 placement new 后,因为原有地址 X 上的对象(由 p 指向)已经结束生存期,虽然指针 p 指向同一地址 X,但不再是合法访问。此时要么使用 placement new 返回的新的指针,要么使用 std::launder(p) 以合法访问对象。

// 示例来自:https://en.cppreference.com/w/cpp/utility/launder
#include <cassert>
#include <cstddef>
#include <new>

struct Base {
    virtual int transmogrify();
};

struct Derived : Base {
    int transmogrify() override {
        new(this) Base;
        return 2;
    }
};

int Base::transmogrify() {
    new(this) Derived;
    return 1;
}

static_assert(sizeof(Derived) == sizeof(Base));

int main() {
    // Case 1: the new object failed to be transparently replaceable because
    // it is a base subobject but the old object is a complete object.
    Base base;
    int n = base.transmogrify();
    // int m = base.transmogrify(); // undefined behavior
    int m = std::launder(&base)->transmogrify(); // OK
    assert(m + n == 3);

    // Case 2: access to a new object whose storage is provided
    // by a byte array through a pointer to the array.
    struct Y { int z; };
    alignas(Y) std::byte s[sizeof(Y)];
    Y* q = new(&s) Y{2};
    const int f = reinterpret_cast<Y*>(&s)->z; // Class member access is undefined
                                               // behavior: reinterpret_cast<Y*>(&s)
                                               // has value "pointer to s" and does
                                               // not point to a Y object
    const int g = q->z; // OK
    const int h = std::launder(reinterpret_cast<Y*>(&s))->z; // OK

    [](...){}(f, g, h); // evokes [[maybe_unused]] effect
}

CFLAGS:你说的对,但是……

gcc-13 编译器会在 -O2 及以上的优化等级默认开启严格别名(见优化器查询)。如果实在没耐心处理这些语言上的边边角角(比如大量的历史代码),愿意放弃性能以换来代码安全,有一个办法是在编译选项中添加 -fno-strict-aliasing,表示禁用严格别名(见测试代码)。

不必为违背语言标准感到愧疚,反正 Linux 内核也是这么干的。(见 Makefile

虽说这不是 C++ 标准的事情,但还是附上 Linus 对 C 标准的严格别名的评价。省流版总结:Fuck.

References

Taking a Byte Out of C++: Avoiding Punning by Starting Lifetimes (CppCon 2022) – YouTube
Implicit creation of objects for low-level object manipulation – ISOCPP
reinterpret_cast – cppreference
Storage duration – cppreference
Lifetime – cppreference
storage duration vs lifetime – Stack Overflow
std::launder – cppreference