我第一次真正理解“对象生命周期”。
是在一次线上事故之后。
那会儿我们在一个老项目里。
还保留着很 C 的写法。
先 malloc。
再 init_xxx。
最后再 destroy_xxx。
你一眼就能看出来。
这套东西的核心不是优雅。
是自律。
而自律这东西。
最不靠谱。
有人忘了调用 init。
对象看起来“存在”。
但里面的资源没装上。
有人忘了调用 destroy。
资源在系统里挂着。
等你压测。
等你夜里报警。
等你开始怀疑人生。
后来我们迁到 C++。
很多新同学松了口气。
“有构造函数了。”
“有析构函数了。”
“RAII 了。”
我当时也开心。
但开心之前。
你得先把一件事说清楚。
对象的“出生”。
其实分两步。
先拿到一块原始内存。
再把它变成一个真正的对象。
这两步。
在 C 里是两句代码。
在 C++ 里经常被你写成一句。
而事故。
就喜欢藏在“被你写成一句”的地方。
先把两个词拆开
“分配内存”。
说的是拿到一段可用的存储。
它可能来自栈。
也可能来自堆。
也可能来自静态区。
但此刻它还不是对象。
它只是字节。
“构造完成”。
说的是这段字节被赋予了类型。
构造函数跑过了。
成员都按规则初始化了。
不变式建立起来了。
从这一刻开始。
你才可以用 T* 当成 T 来对待。
这不是学院派抬杠。
这是标准在替你守底线。
你越接近底层。
越能感觉到这条线有多硬。
栈上那位同事:看起来最“自然”
先看最普通的。
struct File {
File(const char* path);
~File();
};
int main() {
File f("/tmp/a.txt");
}
你写的是一行。
但编译器做的是两件事。
先在栈帧里给 f 留出一块位置。
这是“分配”。
然后调用 File::File。
这是“构造”。
离开作用域时。
先调用 ~File()。
再把那块栈空间当成“无事发生”。
你没有 free。
因为栈不是你管的。
你只负责让对象体面收尾。
堆上那位同事:new 其实做了两件事
我们再看你更熟的那句。
struct Widget {
explicit Widget(int x);
~Widget();
};
Widget* p = new Widget(7);
delete p;
很多人把 new 当成“C++ 的 malloc”。
老程序员听到这句。
一般会先叹口气。
因为 new 不是一个动作。
它是两个动作绑在一起。
大概等价于这样。
#include <new>
void* mem = ::operator new(sizeof(Widget));
Widget* p = nullptr;
try {
p = new (mem) Widget(7);
} catch (...) {
::operator delete(mem);
throw;
}
p->~Widget();
::operator delete(p);
别纠结这是不是“标准规定的展开”。
不是。
但它抓住了关键。
::operator new 负责拿内存。
placement new 负责在那块内存上“点火”。
也就是调用构造函数。
如果构造函数抛异常。
内存要被回收。
对象没出生。
但也不能留下垃圾。
这是 new 比 malloc 更像“语言特性”的地方。
它把失败路径也算进了契约里。
malloc 能用吗?能。
但你得知道你在干什么。
#include <cstdlib>
#include <new>
void demo() {
void* mem = std::malloc(sizeof(Widget));
Widget* p = new (mem) Widget(7);
p->~Widget();
std::free(mem);
}
这段代码能跑。
但它不“现代”。
它像你把安全带拆掉。
然后对自己说。
“我很小心。”
现实里你需要额外操心两件事。
第一件事是对齐。
malloc 只保证满足基础对齐。
如果你在玩更夸张的对齐要求。
你得换工具。
第二件事是异常。
构造抛了。
你要记得 free。
你自己负责把失败路径补齐。
所以这类写法一般只出现在内存池。
或者你在写库。
你想把“分配策略”和“对象构造”分离。
这时候。
“分配”和“构造”就必须在你脑子里分开。
一个高手会点赞的例子:vector::reserve
我最喜欢用 std::vector 来解释“分配”和“构造”的区别。
因为它是标准库。
不是玄学。
#include <vector>
std::vector<int> v;
v.reserve(100);
reserve(100) 做了什么。
它会去申请一块能放下 100 个 int 的内存。
但它不会构造 100 个 int。
它甚至不会让 size() 变大。
这一步只有“分配”。
没有“出生”。
你再看。
std::vector<int> w(100);
这句不一样。
它不仅分配。
还会构造 100 个元素。
所以 w.size() 立刻是 100。
你要是真理解了这两句的差别。
很多性能问题就不再神秘。
你也更容易写出“少构造、少析构”的代码。
什么时候默认构造函数会“自动出现”
很多人以为。
类没有写构造函数。
编译器就会“送你一个”。
这话只对一半。
看一个最朴素的类型。
struct A {
int x;
};
A a;
A 的默认构造函数会被隐式声明。
但它不会替你把 x 变成 0。
A a; 是默认初始化。
x 的值。
你别期待。
你如果想要确定性。
就把规则写进类型。
struct B {
int x = 0;
};
B b;
再看一个更真实的坑。
struct C {
C(int);
};
struct D {
C c;
};
D d;
这里 D d; 会直接编译不过。
不是编译器小气。
是它没法凭空把 c 构造出来。
默认构造函数要么存在。
要么被删除。
它不是魔法。
它是“你能不能把每个成员都好好生出来”的结果。
对象“出生”之后,会怎么“离开”
出生靠构造。
离开靠析构。
但析构也有两层。
栈上对象。
离开作用域。
编译器插入析构调用。
然后栈空间自然回收。
堆上对象。
delete 也做两件事。
先调用析构。
再释放那块内存。
所以你才会被强调那句老规矩。
new 出来的。
就用 delete。
new[] 出来的。
就用 delete[]。
写错了。
你不是“少释放一点”。
你是在让运行时去猜。
而它猜错的时候。
通常不温柔。
最后留一句话
对象不是一块“会自己活”的内存。
内存也不是一个“天然就是对象”的东西。
你写下 T t;。
或者 new T(...)。
你其实是在和编译器签合同。
合同里最重要的一行。
就是这句。
先分配。
再构造。
先析构。
再释放。
你记住这四个动作。
后面我们讲成员初始化顺序。
讲委托构造。
讲 placement new。
很多坑。
你会提前闻到味道。