学习30 分钟阅读编辑本文 ↗

Standard C++ 基础回炉|第 1 课(1-5):生命周期与所有权的 8 个核心问题

围绕作用域与对象生命周期、返回值优化、构造异常、成员初始化顺序、vector 失效规则、裸指针与智能指针所有权,以及异步 Lambda 引用捕获,系统回顾现代 C++ 中最容易混淆的 8 个基础问题。

Standard C++ 基础回炉|第 1 课(1-5):生命周期与所有权的 8 个核心问题

1. 本次回顾目标

这一组问题不是单纯检查 C++ 语法,而是围绕一条主线展开:

对象什么时候存在
        ↓
谁拥有对象
        ↓
资源由谁释放
        ↓
引用 / 指针什么时候有效
        ↓
异步执行时对象是否还活着

本次回顾 8 个问题:

  1. 局部变量的名字离开作用域,与对象生命周期结束,总是同一件事吗?
  2. 为什么按值返回局部 std::string 通常是正确的?
  3. 构造函数抛出异常时,哪些析构函数会执行?
  4. 成员构造顺序由初始化列表还是声明顺序决定?
  5. std::vector::push_back() 为什么可能让已有引用、指针和迭代器失效?
  6. 裸指针能否安全使用?它应该表达什么关系?
  7. std::shared_ptr 是否应该作为动态对象的默认选择?
  8. 异步任务按引用捕获局部变量,风险来自哪里?

C++ 中真正需要管理的不是"一个地址",而是对象的生命周期、资源的所有权以及访问者与对象之间的关系。


2. 作用域结束和对象生命周期结束是一回事吗?

原始回答

局部变量离开作用域,生命周期结束会调用析构释放,对象生命周期与局部变量离开作用域不是一回事。

方向基本正确,但需要把 scope 和 lifetime 严格区分。

作用域

作用域描述:

一个名字在源代码的什么范围内可见。

{
    std::string name = "Deng";
    std::cout << name;
}
// 这里不能再通过名字 name 访问

生命周期

生命周期描述:

一个对象从什么时候开始存在,到什么时候不再存在。

普通自动存储期局部对象通常在离开作用域时析构,所以二者经常同时结束,但概念并不相同。

例如:

int* p = nullptr;
{
    p = new int(10);
}

大括号结束不会自动销毁 new int(10) 创建的对象。

再看:

int* create()
{
    int value = 10;
    return &value;
}

函数返回后:

value 名字离开作用域
        ↓
value 对象生命周期结束
        ↓
地址值可能仍被返回
        ↓
但该地址上的 int 对象已经不存在
        ↓
悬空指针

结论

作用域描述名字的可见范围,生命周期描述对象存在的时间范围。普通自动存储期局部对象通常在离开作用域时结束生命周期,因此二者经常同时发生,但不是同一个概念。


3. 为什么按值返回局部 std::string 通常是正确的?

原始回答

返回值优化,直接将有名字的资源构造在调用函数的返回值地址处。

已经抓到了 NRVO,但答案不能只依赖 NRVO。

std::string make_name()
{
    std::string name = "Deng";
    return name;
}

编译器可能执行 NRVO:

调用方准备返回对象存储位置
          ↓
name 直接构造到最终位置
          ↓
省去中间 copy / move

即使没有 NRVO,现代 C++ 通常也可以利用移动语义转移资源。

真正危险的是:

const std::string& make_name()
{
    std::string name = "Deng";
    return name; // 悬空引用
}

结论

按值返回局部 std::string 是正常且推荐的现代 C++ 写法。RVO/NRVO 可以消除不必要的拷贝或移动;即使没有 NRVO,也通常可以利用移动语义。真正危险的是返回局部对象的引用或指针。


4. 构造函数抛出异常时,哪些析构函数会执行?

原始回答

会析构异常前构造的成员,按照构造顺序逆向析构,但是通过 new 的成员不会析构,即使在该对象的析构调用 delete,因为正在构造的对象不会调用析构。

核心方向正确,但"通过 new 的成员不会析构"需要更精确。

class Device {
    Resource first_;
    Resource second_;

public:
    Device()
    {
        throw std::runtime_error("failed");
    }

    ~Device();
};

如果两个成员已经成功构造:

first_ 构造
    ↓
second_ 构造
    ↓
Device() 抛异常
    ↓
second_ 析构
    ↓
first_ 析构

但 Device::~Device() 不执行,因为 Device 本身从未完成构造。

危险的是:

class Device {
    Resource* resource_;

public:
    Device()
    {
        resource_ = new Resource;
        throw std::runtime_error("failed");
    }

    ~Device()
    {
        delete resource_;
    }
};

此时完整对象析构函数不会执行,delete resource_ 也就不会执行,可能造成泄漏。

RAII 可以解决:

class Device {
    std::unique_ptr<Resource> resource_;

public:
    Device()
        : resource_(std::make_unique<Resource>())
    {
        throw std::runtime_error("failed");
    }
};

虽然 Device::~Device() 不执行,但已经成功构造的 resource_ 会析构,从而释放 Resource。

结论

构造函数抛异常时,完整对象自身的析构函数不会执行,但已经成功构造的基类和成员会逆序析构。资源应优先交给 RAII 对象管理,而不是依赖完整对象析构函数中的手动 delete。


5. 成员构造顺序由初始化列表还是声明顺序决定?

原始回答

按照声明顺序构造和逆向析构,与初始化顺序无关。

回答正确。

class Example {
    int first_;
    int second_;

public:
    Example()
        : second_(2),
          first_(1)
    {
    }
};

实际构造顺序仍然是:

first_
  ↓
second_

析构反过来:

second_
  ↓
first_

涉及继承时,大体顺序为:

虚基类
  ↓
直接基类
  ↓
成员变量(声明顺序)
  ↓
派生类构造函数体

结论

非静态数据成员按照类中的声明顺序构造,与初始化列表书写顺序无关;析构顺序相反。工程中初始化列表最好仍按声明顺序书写,避免误导。


6. std::vector::push_back() 为什么可能让已有引用失效?

原始回答

不扩容时 end 迭代器会失效;大于 capacity 时会扩容,将旧内存中的资源搬到新地址并释放原地址,导致旧引用、迭代器、指针等失效。

主体正确,需要补充两个细节:

  1. 触发重新分配的关键是容量不足。
  2. 原元素可能被 move,也可能被 copy,不能统一说成复制。
std::vector<int> values;
values.reserve(2);
values.push_back(10);
values.push_back(20);

int* p = &values[0];
int& ref = values[0];
auto it = values.begin();

values.push_back(30);

此时:

size == capacity
        ↓
申请更大的连续存储区域
        ↓
move / copy 原元素
        ↓
构造新元素
        ↓
释放旧区域
        ↓
p / ref / it 全部失效

如果没有发生重新分配:

  • 已有元素的指针通常有效
  • 已有元素的引用通常有效
  • 已有元素的迭代器通常有效
  • 原 end() 失效

结论

push_back() 并不一定让所有引用失效。关键在于是否发生 reallocation。发生重新分配后,所有指向原元素的指针、引用和迭代器都会失效;没有重新分配时,已有元素通常保持有效,但原 end() 会失效。


7. 裸指针能否安全使用?

原始回答

裸指针可以安全使用,但是需要做好手动管理,如初始化、new、delete、置空等。是直接操作内存的手段。

"可以安全使用"是对的,但现代 C++ 中不应该把裸指针默认理解为需要手动 new/delete 的资源所有者。

更推荐的 ownership 模型:

std::unique_ptr<T>
        ↓
独占所有权

std::shared_ptr<T>
        ↓
共享所有权

T*
        ↓
通常表达 non-owning access

T&
        ↓
通常表达 non-owning reference

例如:

void process(Device* device)
{
    if (device) {
        device->start();
    }
}

这里 process() 只是访问 Device,不拥有它,因此不应该 delete device。

传统:

Resource* p = new Resource;
// ...
delete p;

要求所有控制流都正确走到 delete。

RAII:

auto p = std::make_unique<Resource>();

则把释放绑定到对象生命周期。

结论

裸指针本身并不危险,模糊的生命周期和所有权才危险。现代 C++ 中裸指针通常更适合表达非拥有型访问;需要拥有动态资源时,应优先使用值语义或 RAII 类型。


8. shared_ptr 是否应该作为动态对象的默认选择?

原始回答

shared_ptr 是共享智能指针,还有 unique_ptr 独占指针,weak_ptr 解决 shared_ptr 循环引用。通常 shared_ptr 不是动态对象默认选择,因为有引用计数、原子操作等开销,还有循环引用问题;通常 unique_ptr 是默认选择,而且可以转换为 shared_ptr。

整体理解较好。

但最核心的理由不是性能,而是:

ownership 语义。

如果:

DeviceManager
      │
      └── owns Device

只有一个 owner:

std::unique_ptr<Device>

最准确。

只有真正存在多个 owner 共同决定生命周期时,才考虑:

std::shared_ptr<Device>

shared_ptr 的成本包括:

  • 控制块
  • 强/弱引用计数
  • 引用计数同步成本
  • ownership 关系更复杂
  • 循环强引用风险

weak_ptr 不拥有对象,可用于观察由 shared_ptr 管理的对象,并打破强引用环。

所有权可以从:

unique_ptr
    ↓
shared_ptr

转移到共享模型。

但不能简单从:

shared_ptr
    ↓
unique_ptr

恢复,因为此时可能已经存在多个 owner。

结论

shared_ptr 不应该因为对象是动态分配的就成为默认选择。首先根据 ownership 建模:单一所有者优先 unique_ptr,真正共享生命周期时使用 shared_ptr,非拥有观察关系使用引用、裸指针或 weak_ptr。


9. 异步任务按引用捕获局部变量,风险来自哪里?

原始回答

异步引用捕获时,捕获的变量可能已经被释放,继续使用捕获地址的资源会导致 UB。

核心正确。

void start_task()
{
    int value = 10;

    std::thread worker([&value]() {
        std::cout << value << '\n';
    });

    worker.detach();
}

可能出现:

value 构造
    ↓
启动线程
    ↓
start_task() 返回
    ↓
value 生命周期结束
    ↓
线程稍后真正执行
    ↓
通过引用访问 value
    ↓
dangling reference
    ↓
Undefined Behavior

关键点:

引用捕获不会延长被捕获对象的生命周期。

[this] 同样需要警惕:

std::thread([this]() {
    process();
}).detach();

如果对象先析构,后台任务随后继续使用 this,就会形成悬空指针。

按值捕获也不是绝对安全。如果捕获的是裸指针,复制的只是地址,指针指向的对象仍可能提前销毁。

真正应该分析:

Lambda 保存了什么?
        ↓
任务执行时依赖什么对象?
        ↓
这些对象那时还活着吗?

结论

异步引用捕获的风险来自生命周期错位。Lambda 保存引用并不会延长对象生命周期;如果任务执行时间晚于对象销毁时间,就会形成悬空引用并产生未定义行为。


10. 八个问题之间的共同主线

表面上涉及:

作用域
返回值
异常
构造析构
vector
裸指针
智能指针
Lambda
线程

实际上可以统一成:

Scope
  ↓
名字在哪里可见?

Lifetime
  ↓
对象什么时候存在?

Ownership
  ↓
谁负责对象生命周期和资源释放?

RAII
  ↓
如何让资源释放成为结构的一部分?

Pointer / Reference / Iterator
  ↓
只是访问对象,还是拥有对象?

Container Reallocation
  ↓
对象搬迁后旧观察者是否仍有效?

Async Task
  ↓
任务真正执行时对象是否还活着?

很多 C++ Bug:

内存泄漏
悬空指针
use-after-free
vector 迭代器失效
Lambda 异步崩溃
shared_ptr 循环引用
构造异常泄漏

最终都可以从:

Lifetime + Ownership

两个角度分析。


11. 本轮需要修正的几个认识

1. 按值返回局部对象不只是因为 NRVO

NRVO 是优化手段之一;没有 NRVO 时仍可能使用移动语义。

2. 构造异常时不是"new 的成员一定不会释放"

真正的问题是裸指针本身没有析构行为,而完整对象的析构函数又不会执行。若资源被 unique_ptr 等 RAII 成员持有,该成员仍会在异常展开时析构并释放资源。

3. vector 扩容不是统一"复制"

元素可能 move,也可能 copy。

4. 裸指针不等于手动资源管理

现代 C++ 中更适合让裸指针表达 non-owning access。

5. shared_ptr 的第一判断标准不是性能

首先看 ownership 是否真的共享,性能和循环引用属于进一步考虑的问题。


12. 面试高频回答

作用域和生命周期有什么区别?

作用域描述名字在源代码中的可见范围,生命周期描述对象从开始存在到销毁的时间范围。普通自动存储期局部对象通常在离开作用域时结束生命周期,因此二者经常同时发生,但概念上并不相同。

为什么可以按值返回局部 std::string?

按值返回是正常的值语义。编译器可以通过 RVO/NRVO 消除不必要的拷贝或移动;即使没有执行 NRVO,也通常可以利用移动语义。不能返回局部对象的引用或指针,因为函数返回后局部对象生命周期结束。

构造函数抛异常会调用析构函数吗?

完整对象自身的析构函数不会执行,因为对象没有完成构造;但已经成功构造的基类和成员对象会按照构造顺序的逆序自动析构。

成员变量按什么顺序构造?

按照成员在类中的声明顺序构造,而不是初始化列表中的书写顺序;析构顺序相反。

push_back() 一定导致迭代器失效吗?

不一定。发生 reallocation 时所有元素相关的指针、引用和迭代器都会失效;没有重新分配时,已有元素通常仍有效,但原 end() 会失效。

裸指针应该怎么理解?

裸指针可以安全使用,但现代 C++ 中更适合表达非拥有型访问。需要拥有资源时优先使用值语义或 RAII 类型。

为什么不应该默认使用 shared_ptr?

因为首先应该根据 ownership 建模。单一所有者使用 unique_ptr 更明确;只有多个对象真正共享生命周期时才需要 shared_ptr。

异步 Lambda 引用捕获为什么危险?

引用捕获不会延长被引用对象生命周期。如果异步任务真正执行时原对象已经销毁,Lambda 中的引用就会悬空,继续访问属于未定义行为。


13. 本轮核心结论

  1. 作用域和对象生命周期不是同一个概念。
  2. 地址仍然存在,不代表地址对应的对象仍然存在。
  3. 按值返回局部标准库对象是正常且推荐的现代 C++ 写法。
  4. RVO/NRVO 可以消除不必要的返回值拷贝或移动。
  5. 没有 NRVO 时仍可能利用移动语义。
  6. 构造失败的完整对象不会调用自身析构函数。
  7. 已成功构造的成员和基类仍会在异常展开中析构。
  8. RAII 可以避免构造过程中异常导致的资源泄漏。
  9. 成员按照声明顺序构造,析构顺序相反。
  10. vector::push_back() 是否导致已有观察者失效取决于是否重新分配。
  11. reallocation 后原元素相关的指针、引用和迭代器全部失效。
  12. vector 搬迁元素时可能 move,也可能 copy。
  13. 裸指针本身不是错误,模糊的 ownership 和 lifetime 才是问题。
  14. 裸指针通常更适合表达 non-owning access。
  15. unique_ptr 适合表达独占所有权。
  16. shared_ptr 表达共享所有权,不应该仅因为对象动态分配就使用。
  17. weak_ptr 可用于非拥有观察 shared object,并打破循环强引用。
  18. Lambda 引用捕获不会延长被捕获对象生命周期。
  19. [this] 在异步任务中同样存在对象提前析构风险。
  20. 很多 C++ 内存和并发问题,本质都可以回到 Lifetime + Ownership 上分析。

14. 后续学习衔接

这 8 个问题可以继续串到下一组知识:

对象生命周期
    ↓
RAII / Ownership
    ↓
unique_ptr / shared_ptr
    ↓
std::move
    ↓
移动构造
    ↓
noexcept
    ↓
vector 扩容时的 move / copy
    ↓
Rule of Five / Rule of Zero
    ↓
std::thread
    ↓
线程与对象生命周期

后续不再把这些内容当作彼此独立的面试题,而是逐步建立统一的现代 C++ 对象、资源和并发模型。