PAYATHON 2026

C++ 子对象生命周期结束会影响外围对象和其他子对象吗?

数据小许

在新对象的大小、对齐和存储范围均满足要求的前提下,第二次 std::construct_at 会结束 c1 和外围 A 对象的生命周期。没有被覆盖的 d、i、d1、d2 不会仅因 A 的生命周期结束而自动结束生命周期。

根据对象生命周期规则和 std::launder 的前置条件,题目中的两个 std::launder 表达式都可以有效:第一个取得新建的 std::uint16_t 对象,第二个取得仍然存活的 d1。

为什么 c1 和 A 的生命周期都会结束?

[basic.life]/2.5 规定:对象占用的存储被释放,或者被一个不 nested within 该对象的新对象复用时,该对象的生命周期结束。对应条文

对 c1 和 A 分别判断:

  • 新建的 std::uint16_t 与 c1 占用的存储重叠,且不是 nested within c1 的对象,因此 c1 的生命周期结束。
  • 这部分存储也属于 A。新对象与原成员类型不同,不能作为原成员的同类型替代对象成为 A 的子对象;它也没有通过其他关系 nested within A,因此 A 的生命周期同样结束。

触发这条规则不需要覆盖 A 的全部字节。决定因素是存储被复用,以及新旧对象之间是否存在标准规定的嵌套关系。子对象替换条件与 nested within 的定义

std::byte 数组为什么仍然存活?

provides storage 是标准定义的特定关系,不能简单理解为“地址落在某个对象内部”。满足条件的 unsigned char 数组或 std::byte 数组可以为新建的完整对象提供存储。

在这个例子中,storage 为最初的 A 提供存储,也为后来构造的 std::uint16_t 提供存储。因此,新对象 nested within storage,它的构造不会结束这个字节数组的生命周期。不过,新对象并不因此 nested within A。provides storage 与嵌套关系

为什么其他成员没有一起结束生命周期?

外围对象生命周期结束,与执行外围对象的析构过程是两回事。

这里通过存储复用结束了 A 的生命周期,没有调用 A 的析构函数。标准允许这样做,也不会因此隐式调用析构函数。跳过析构而结束生命周期的规则

再看 d、i、d1、d2:它们没有被销毁,各自占用的存储也没有被释放或复用。因此,不能仅凭外围 A 的生命周期已经结束,就认定这些成员的生命周期也结束了。

外围对象生命周期结束后,成员访问受到限制,不能再通过旧 A* 执行 a->d1 来访问成员。应当使用事先保存的成员指针,或者从底层字节数组地址取得指向仍存活成员的指针。生命周期结束后的指针使用限制

两个 std::launder 表达式如何判断?

std::launder 要求指定地址处存在符合类型和生命周期条件的对象,并满足存储可达性要求。它不会创建对象,也不会恢复已经结束的生命周期。std::launder 规定

对于:

*std::launder(reinterpret_cast<std::uint16_t*>(
    storage + offsetof(A, c1)))

该地址处已经构造了一个存活的 std::uint16_t 对象,新对象的类型正好是 std::uint16_t。不能仅因为原来的 double c1 已经结束生命周期,就认定这个表达式是 UB。

对于:

*std::launder(reinterpret_cast<double*>(
    storage + offsetof(A, d1)))

只要新对象没有覆盖 d1 的存储,该地址仍有存活的 double 对象。参数源于 storage 的数组元素指针,其存储可达范围足以覆盖目标对象。存储可达性的定义

如果在原 c1 地址请求取得存活的 double,则不能满足上述生命周期条件。

怎样写出便于核对的示例?

下面的示例保留构造函数返回的指针,并在复用前保存 d1 的指针。std::construct_at 的效果由标准规定为相应的 placement new 构造。construct_at 的规定

#include <cstddef>
#include <cstdint>
#include <memory>

struct A {
    double d;
    int i;
    double c1;
    double d1;
    double d2;
};

int main() {
    // 限定为新对象可以放入 c1,且地址满足其对齐要求的实现。
    static_assert(sizeof(std::uint16_t) <= sizeof(double));
    static_assert(alignof(double) % alignof(std::uint16_t) == 0);

    alignas(A) std::byte storage[sizeof(A)];

    A* a = std::construct_at(
        reinterpret_cast<A*>(storage),
        1., 2, 3., 4., 5.);

    double* saved_d1 = &a->d1;

    auto* replacement = std::construct_at(
        reinterpret_cast<std::uint16_t*>(
            storage + offsetof(A, c1)),
        std::uint16_t{0});

    // 此时原 A 和 c1 的生命周期已经结束。
    std::uint16_t n = *replacement; // 0
    double x = *saved_d1;           // 4.0

    auto* recovered_d1 = std::launder(
        reinterpret_cast<double*>(storage + offsetof(A, d1)));
    double y = *recovered_d1;       // 4.0

    // 不再通过 a->d1 访问成员。
}

这里没有假定 double 固定占 8 字节,也没有假定任意实现都提供 std::uint16_t。示例以该类型存在、且两个 static_assert 成立为适用条件。

仅显式销毁 c1,结论是否相同?

不同。若在另一次独立执行中,仅对仍存活的成员执行:

std::destroy_at(&a->c1);

这会结束 c1 的生命周期,但没有创建新对象来复用 A 的存储。因此,不能沿用前面的存储复用推理,认定 A 也结束生命周期;其他成员也不会仅因这次操作而结束生命周期。判断时应逐个检查对象的生命周期结束条件,区分销毁、释放存储和复用存储。生命周期结束条件

备注:内容仅供参考。