Standard C++ 基础回炉|第 1 课(1-1):对象、存储期与生命周期
从对象何时真正存在开始,重新区分作用域、存储期、生命周期与所有权,并用可运行实验观察构造和析构顺序。
Standard C++ 基础回炉|第 1 课(1-1):对象、存储期与生命周期
生命周期问题经常不是“语法不会”,而是对象已经失效,代码却还保留着指针、引用或迭代器。
这一课只解决一个核心问题:
当代码使用某个名字、指针或引用时,它指向的对象是否仍然存在?
1. 先区分四个概念
1.1 作用域 Scope
作用域决定一个名字在哪里可见。
int main() {
int outer = 1;
{
int inner = 2;
// outer 和 inner 在这里都可见
}
// outer 可见,inner 这个名字已经不可见
}
名字不可见,不一定代表对象已经销毁;名字仍然可见,也不代表它引用的对象仍然有效。
1.2 存储期 Storage Duration
存储期描述对象所占用的存储空间会保持多久。常见类型包括:
- 自动存储期:普通局部变量,离开块作用域时释放。
- 静态存储期:全局变量、命名空间变量和函数内的
static对象,通常持续到程序结束。 - 线程存储期:
thread_local对象,持续到所属线程结束。 - 动态存储期:通过动态分配获得,直到被显式释放或由资源管理对象释放。
1.3 生命周期 Lifetime
生命周期描述一个对象从构造完成到析构开始的有效存在区间。
存储空间存在,不代表其中一定有一个处于生命周期内的对象。底层内存复用、placement new、union 和手动对象管理都会让两者产生区别。
基础阶段先记住:
只有对象仍在生命周期内,才能通过指针或引用把它当作该类型的对象使用。
1.4 所有权 Ownership
所有权不是 C++ 语法关键字,而是代码设计关系:谁负责让资源最终被释放。
std::unique_ptr<Device> owner = std::make_unique<Device>();
Device* observer = owner.get();
这里 owner 负责对象的生命周期,observer 只是临时观察。observer 不能比 owner 活得更久。
2. 用日志观察对象的一生
先写一个最小跟踪类型:
#include <iostream>
#include <string>
class Tracker {
public:
explicit Tracker(std::string name)
: name_(std::move(name)) {
std::cout << "construct: " << name_ << '\n';
}
~Tracker() {
std::cout << "destroy: " << name_ << '\n';
}
private:
std::string name_;
};
int main() {
Tracker a{"a"};
{
Tracker b{"b"};
Tracker c{"c"};
}
Tracker d{"d"};
}
运行前先预测输出,再实际执行。块内对象通常按构造的逆序销毁:c 先于 b,最后才轮到外层对象。
这条“逆序销毁”规则是 RAII 能够组合的基础。后创建的对象往往依赖先创建的对象,因此销毁时先解除后建立的依赖。
3. 四种常见存储期
3.1 自动存储期
void work() {
Tracker task{"task"};
// 离开 work 时自动析构
}
无论函数正常返回、提前 return,还是异常向外传播,已经构造完成的自动对象都会按规则析构。
3.2 静态存储期
Tracker global_tracker{"global"};
void initialize_once() {
static Tracker local_static{"local static"};
}
函数内静态对象从 C++11 起具有线程安全的首次初始化保证。它只构造一次,并在程序正常结束时析构。
需要警惕不同编译单元之间静态对象的初始化顺序。基础阶段的简单策略是:尽量用函数内静态对象延迟初始化,避免让全局对象互相依赖。
3.3 线程存储期
thread_local int request_count = 0;
每个线程都有自己的实例。它适合线程私有状态,但不应被误认为是自动线程安全:多个线程共享的其他数据仍然需要同步。
3.4 动态存储期
auto tracker = std::make_unique<Tracker>("heap object");
对象位于动态存储区域,但它的所有权由栈上的 std::unique_ptr 管理。离开作用域时,智能指针析构并释放对象。
现代 C++ 代码中,优先让动态对象进入 RAII 类型,而不是保留裸 new / delete:
// 不推荐:中间出现 return 或异常时容易遗漏 delete
Tracker* raw = new Tracker("raw");
delete raw;
// 推荐:所有权清楚,退出路径统一
auto owned = std::make_unique<Tracker>("owned");
4. 构造完成之前与析构开始之后
对象的生命周期不是“分配到内存”就开始,而是构造达到相应阶段后才开始。
如果一个构造函数抛出异常:
- 当前对象本身没有构造完成,因此不会调用它的析构函数。
- 已经构造完成的成员和基类子对象会被正确析构。
- 尚未构造的成员不会析构。
class Session {
public:
Session()
: connection_{"connection"}, parser_{"parser"} {
throw std::runtime_error("initialize failed");
}
private:
Tracker connection_;
Tracker parser_;
};
这也是成员本身应当使用 RAII 类型的原因。构造中途失败时,已经完成的部分仍然能够自动清理。
5. 析构顺序
一个派生类对象销毁时,可以先按下面的简化顺序理解:
- 执行派生类析构函数体。
- 按成员声明顺序的逆序销毁成员。
- 销毁基类子对象。
成员的构造顺序由声明顺序决定,不由初始化列表中的书写顺序决定:
class Example {
public:
Example()
: second_{"second"}, first_{"first"} {}
private:
Tracker first_;
Tracker second_;
};
实际仍然是 first_ 先构造,second_ 后构造。不要依赖初始化列表的视觉顺序,最好始终按成员声明顺序书写初始化列表。
6. 最常见的生命周期错误
6.1 返回局部对象的引用或指针
const std::string& bad_name() {
std::string name = "device";
return name; // 返回后 name 已销毁
}
正确做法通常是按值返回:
std::string make_name() {
std::string name = "device";
return name;
}
不要因为担心拷贝而返回局部引用。返回值优化和移动语义会处理大部分成本问题。
6.2 保存临时对象的观察指针
const char* pointer = std::string("temporary").c_str();
// 完整表达式结束后,临时 string 销毁,pointer 悬空
6.3 容器扩容导致引用失效
std::vector<int> values{1, 2, 3};
int& first = values[0];
values.push_back(4); // 可能触发重新分配
std::cout << first; // first 可能已经悬空
重点不只是记住 vector 会失效,而是养成习惯:任何改变容器容量或结构的操作之后,都重新检查已有指针、引用和迭代器是否还有效。
6.4 Lambda 捕获局部引用后异步执行
std::function<void()> make_task() {
std::string message = "done";
return [&message] {
std::cout << message << '\n';
};
}
返回的任务执行时,message 已经销毁。需要根据语义改为值捕获,或让共享状态拥有足够长的生命周期。
7. 值、引用与指针的选择
可以先使用下面的基础判断:
- 对象很自然地属于当前作用域:直接按值保存。
- 参数只读且不接管所有权:使用
const T&;小型标量通常按值传递。 - 参数可能为空且不接管所有权:使用
T*,并明确它只是观察者。 - 独占动态所有权:使用
std::unique_ptr<T>。 - 确实存在共享生命周期:才使用
std::shared_ptr<T>。 - 观察
shared_ptr管理的对象但不延长生命周期:使用std::weak_ptr<T>。
“裸指针不安全”这个说法不够准确。真正的问题是:裸指针没有表达所有权,而且无法自动证明它指向的对象仍然存在。
8. 动手实验
实验 A:预测构造和析构顺序
给 Tracker 增加拷贝构造、移动构造、拷贝赋值和移动赋值日志,然后观察:
Tracker make_tracker() {
Tracker value{"result"};
return value;
}
int main() {
Tracker result = make_tracker();
}
分别使用默认编译选项和 -fno-elide-constructors 编译,记录输出差异。不要把“标准允许省略”误解为“编译器偷偷优化但语义不变”;某些拷贝消除在 C++17 中已经是语言保证。
实验 B:让 ASan 找到悬空访问
写一个函数返回局部变量地址,并在调用端解引用。先阅读编译器警告,再开启 AddressSanitizer 运行。
记录三件事:
- 编译器是否给出警告?
- 程序是否每次都会立即崩溃?
- Sanitizer 报告指出对象在哪里创建、在哪里失效、在哪里被再次访问?
未定义行为“看起来能运行”不代表代码正确。
实验 C:观察 vector 失效
记录 values.data() 和 values.capacity(),持续 push_back,直到地址发生变化。每次变化都表示底层存储发生重新分配。
再调用一次 reserve,比较重新分配次数。
9. 自测问题
不运行代码,先口头回答:
- 局部变量的名字离开作用域,与对象生命周期结束,总是同一件事吗?
- 为什么按值返回局部
std::string通常是正确的? - 构造函数抛出异常时,哪些析构函数会执行?
- 成员构造顺序由初始化列表还是声明顺序决定?
std::vector::push_back为什么可能让已有引用失效?- 裸指针能否安全使用?它应当表达什么关系?
shared_ptr是否应该作为动态对象的默认选择?为什么?- 异步任务按引用捕获局部变量,风险来自哪里?
如果只能说出结论,不能解释对象从创建到销毁的时间线,就还没有真正掌握。
10. 本课完成标准
- 能准确区分作用域、存储期、生命周期和所有权。
- 能预测局部对象、成员和基类的基本析构顺序。
- 能识别返回局部引用、临时对象指针、容器失效和异步引用捕获。
- 完成三个实验,并保存输出与结论。
- 能用一张时间线解释一个悬空引用是如何产生的。
- 所有实验在
-Wall -Wextra -Wpedantic下编译,并至少运行一次 ASan/UBSan。
下一课进入 RAII:把“知道对象何时销毁”进一步变成“利用析构函数可靠管理资源”。