Standard C++ 基础回炉|第 1 课(1-5):生命周期与所有权的 8 个核心问题
围绕作用域与对象生命周期、返回值优化、构造异常、成员初始化顺序、vector 失效规则、裸指针与智能指针所有权,以及异步 Lambda 引用捕获,系统回顾现代 C++ 中最容易混淆的 8 个基础问题。
Standard C++ 基础回炉|第 1 课(1-5):生命周期与所有权的 8 个核心问题
1. 本次回顾目标
这一组问题不是单纯检查 C++ 语法,而是围绕一条主线展开:
对象什么时候存在
↓
谁拥有对象
↓
资源由谁释放
↓
引用 / 指针什么时候有效
↓
异步执行时对象是否还活着
本次回顾 8 个问题:
- 局部变量的名字离开作用域,与对象生命周期结束,总是同一件事吗?
- 为什么按值返回局部
std::string通常是正确的? - 构造函数抛出异常时,哪些析构函数会执行?
- 成员构造顺序由初始化列表还是声明顺序决定?
std::vector::push_back()为什么可能让已有引用、指针和迭代器失效?- 裸指针能否安全使用?它应该表达什么关系?
std::shared_ptr是否应该作为动态对象的默认选择?- 异步任务按引用捕获局部变量,风险来自哪里?
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 时会扩容,将旧内存中的资源搬到新地址并释放原地址,导致旧引用、迭代器、指针等失效。
主体正确,需要补充两个细节:
- 触发重新分配的关键是容量不足。
- 原元素可能被
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. 本轮核心结论
- 作用域和对象生命周期不是同一个概念。
- 地址仍然存在,不代表地址对应的对象仍然存在。
- 按值返回局部标准库对象是正常且推荐的现代 C++ 写法。
- RVO/NRVO 可以消除不必要的返回值拷贝或移动。
- 没有 NRVO 时仍可能利用移动语义。
- 构造失败的完整对象不会调用自身析构函数。
- 已成功构造的成员和基类仍会在异常展开中析构。
- RAII 可以避免构造过程中异常导致的资源泄漏。
- 成员按照声明顺序构造,析构顺序相反。
vector::push_back()是否导致已有观察者失效取决于是否重新分配。- reallocation 后原元素相关的指针、引用和迭代器全部失效。
- vector 搬迁元素时可能 move,也可能 copy。
- 裸指针本身不是错误,模糊的 ownership 和 lifetime 才是问题。
- 裸指针通常更适合表达 non-owning access。
unique_ptr适合表达独占所有权。shared_ptr表达共享所有权,不应该仅因为对象动态分配就使用。weak_ptr可用于非拥有观察 shared object,并打破循环强引用。- Lambda 引用捕获不会延长被捕获对象生命周期。
[this]在异步任务中同样存在对象提前析构风险。- 很多 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++ 对象、资源和并发模型。