#面向对象程序设计03
存储区域与生命周期
栈对象
局部变量和函数形参通常在栈上
函数调用时,编译器先计算当前函数所需的栈帧大小,然后生成类似 sub rsp, N (x86_64)的指令一次性扩展栈帧
本质上只是移动栈指针,因此开销非常低
1 | void f() { |
栈对象的生命周期和作用域绑定,进入作用域时构造,离开作用域时析构;不管是正常执行到右花括号,还是因为异常离开作用域,编译器都会在对应路径上插入析构代码
这也是为什么不能返回局部变量的指针或引用:
1 | int* bad() { |
返回值中的地址可能仍然是某个栈地址,但那个地址上的对象已经不存在,之后再访问就是未定义行为
堆对象
堆对象由程序员显式控制,写法是 new 和 delete:
1 | Person* p = new Person{25, "Wang"}; |
new 与 malloc 不同:
- 调用
operator new分配原始内存; - 在这块内存上执行对象构造
delete 也不是单纯的 free:
- 调用对象的析构过程;
- 调用
operator delete归还内存。
如果只分配了内存而没有构造对象,这块内存只是 raw memory,里面可能是任意字节;如果析构完成但尚未释放内存,那么内存仍在,但对象已经不在
要特别注意配对:
1 | auto p = new Person[10]; |
new[] 必须配 delete[]
如果用 delete 释放数组,元素析构数量和内存布局都无法匹配,属于未定义行为
现代 C++ 推荐把堆对象交给 RAII 类型管理:
1
2
3 void g() {
auto p = std::make_unique<Person>(25, "Wang");
} // unique_ptr 析构时自动 delete这样堆对象也获得了类似栈对象的语义边界:管理者离开作用域时,资源自动释放
读写数据段对象
全局对象和静态对象通常位于 .data 或 .bss 段
它们的内存在程序加载时由操作系统映射,程序退出时回收
1 | Logger global_logger; |
全局对象在 main() 执行前构造,在 main() 返回或 exit() 时析构
静态局部对象在第一次执行到声明处时构造,C++11 保证这个初始化过程是线程安全的
跨编译单元的全局对象初始化顺序是未定义的,这会导致静态初始化顺序问题:
1 | // file_a.cpp |
如果 config 先于 logger 构造,就会使用一个尚未构造完成的对象
常见解决方式是 construct on first use:
1 | Logger& getLogger() { |
把全局依赖推迟到第一次调用时构造,避免跨编译单元初始化顺序不确定
构造过程
构造过程通常分为两个阶段:
- 初始化阶段:构造基类和数据成员;
- 构造函数体阶段:执行用户写在
{}里的代码
1 | class Person { |
age_{} 是 NSDMI,也就是非静态数据成员默认初始化
对基本类型使用 {} 可以避免未初始化垃圾值,初始化列表中的初始化会覆盖 NSDMI
初始化列表和构造函数体赋值不一样:
1 | class Bad { |
Bad 多了一次默认构造和一次赋值;Good 是直接构造
也就是说, Bad 先经历了一次空串构造,再经历一次移动赋值;Good 直接经历一次移动构造
对于 const 成员、引用成员、没有默认构造函数的成员,必须使用初始化列表,因为它们根本不能先默认构造再赋值,即不能使用 Bad 这类写法
成员构造顺序
成员构造顺序由类内声明顺序决定,不由初始化列表书写顺序决定
1 | class Foo { |
看起来 b_ 写在前面,但实际仍然先初始化 a_,再初始化 b_
因此 a_(b_ * 2) 读取的是尚未初始化的 b_,行为未定义
正确习惯是:初始化列表顺序和成员声明顺序保持一致
继承下的构造顺序
派生类对象构造时,先完整构造基类,再构造派生类自己的成员,最后执行派生类构造函数体
1 | class Animal { |
在 Animal 构造期间,对象的动态类型还是 Animal,不是最终的 Dog
如果基类构造函数中调用虚函数,不会派发到派生类版本
原因为:派生类部分尚未构造完成,把调用派发给派生类函数是不安全的
析构过程拆解
析构顺序和构造顺序严格相反
析构过程分为:
- 执行当前类的析构函数体;
- 按声明顺序的逆序析构数据成员;
- 对派生类对象,还要继续析构基类部分
1 | class Person { |
如果是继承:
1 | class Dog : public Animal { |
基类析构函数必须是 virtual,只要这个类可能被通过基类指针删除:
1 | Animal* a = new Dog("Canine", "Labrador"); |
如果 Animal::~Animal() 不是虚函数,delete a 可能只调用基类析构,派生类持有的资源不会被正确释放
多态基类的析构函数写成 virtual 是基本规则
考虑基类非虚析构:
1
2
3
4
5
6
7
8
9
10
11
12 class Base {
public:
~Base() { /* ... */ } // 非虚
};
class Derived : public Base {
public:
~Derived() { /* ... */ }
};
Base* ptr = new Derived();
delete ptr;此时
delete ptr,由于ptr类型为Base,则调用Base的析构函数,而不是Derived的析构函数。这会导致Derived中的资源没有被正确释放,此时Base和Derived的析构函数都不是虚的若:
1
2
3
4
5
6
7
8
9
10
11
12 class Base {
public:
virtual ~Base() { /* ... */ } // 虚
};
class Derived : public Base {
public:
~Derived() override { /* ... */ }
};
Base* ptr = new Derived();
delete ptr;此时
delete ptr,会来到ptrDerived的 vtable,此时 vtableDerived的析构函数地址,正确调用Derived的析构函数,然后析构Derived对象, 之后再调用Base的析构函数,对象,最后释放内存此时
Base和Derived的析构函数都是虚的,把子类析构和父类析构看作同一类,此时子类析构也为虚函数,自然在虚函数表中覆盖了父类析构函数地址且
Derived的析构函数使用override明确标记覆盖了基类的虚析构函数
Base析构函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 // Base 的完整对象析构(用于静态调用)
void Base::~Base(Base* this) {
// 1. 执行用户写的析构函数体
// 2. 自动析构 Base 的数据成员
}
// Base 的删除析构(用于 delete)
void Base::~Base(Base* this) {
// 1. 调用完整对象析构
this->~Base(); // 静态调用上面的完整析构
// 2. 释放内存
operator delete(this);
}
Derived析构函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 // Derived 的完整对象析构
void Derived::~Derived(Derived* this) {
// 1. 执行用户写的 ~Derived 函数体
// 2. 自动析构 Derived 的数据成员(breed_)
// 3. 自动调用基类完整析构
this->Base::~Base(this); // 调用 Base 的完整对象析构
}
// Derived 的删除析构
void Derived::~Derived(Derived* this) {
// 1. 调用完整对象析构
this->~Derived(); // 调用上面的完整析构
// 2. 释放内存
operator delete(this);
}
delete ptr执行:
- 静态类型为
Base*,动态类型为Derived*- 通过 vtable 查找析构函数
Derived::~Derived,调用Derived的删除析构函数- 删除析构函数调用完整对象析构函数
Derived::~Derived,执行Derived的析构过程Derived完整对象析构调用基类完整对象析构Base::~Base,执行Base的析构过程- 最后返回到
Derived的删除析构函数,调用operator delete释放内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 delete ptr (ptr 是 Base*)
│
├─ 1. 通过 ptr->vptr 找到 Derived vtable
│ ↓
├─ 2. 从 vtable[0] 取出 Derived::删除析构函数地址
│ ↓
├─ 3. 调用 Derived::删除析构函数
│ │
│ ├─ 3.1 调用 Derived::完整对象析构
│ │ │
│ │ ├─ 执行 ~Derived 函数体
│ │ ├─ 析构 breed_(数据成员)
│ │ └─ 调用 Base::完整对象析构
│ │ │
│ │ ├─ 执行 ~Base 函数体
│ │ └─ 析构 name_、age_(数据成员)
│ │
│ └─ 3.2 operator delete(ptr)
│
└─ 4. 内存释放完成
构造失败与异常展开
构造函数没有返回值,如果构造失败,建议直接抛异常,表示这个对象没有成功建立
1 | class Connection { |
如果构造函数体抛出异常:
- 已经构造完成的成员会自动析构;
- 对象自身的析构函数不会执行,因为对象从未完整构造成功;
- 调用方不会得到一个半有效对象
这比“两阶段构造”更符合对象契约:
1 | class BadConnection { |
两阶段构造让对象在 init() 前处于无效状态,调用方必须记得检查返回值,一旦忘记检查,就会在无效对象上继续操作
异常抛出后,C++ 运行时会进行栈展开:从抛出点沿调用链向上寻找匹配的 catch,并在离开每一层作用域时析构已经成功构造的局部对象
1 | void foo() { |
这是 RAII 能工作的基础:资源绑定在对象上,对象离开作用域时自动清理,即使离开方式是异常
类不变式
构造函数的职责是建立类不变式,类不变式是对象存活期间必须始终成立的条件
1 | class Person { |
构造函数是第一道门,成员函数是之后每一道门
每个会改变对象状态的成员函数,都必须在返回时继续保证不变式成立
这也是 class 和 struct 的一个语义区别:如果类型需要维护约束,更适合用默认 private 的 class;如果只是无约束的数据聚合,struct 更适合
析构函数不应抛异常
析构函数在栈展开期间被调用
如果此时析构函数又抛出异常,运行时需要同时处理两个异常,C++ 的处理方式是调用 std::terminate() 终止程序
C++11 起,析构函数默认隐式 noexcept,如果析构函数中确实要调用可能失败的清理逻辑,应当捕获所有异常,不让异常逃出析构函数
1 | class Connection { |
好的设计是把“可能失败的清理”和“必须不失败的析构”分开:调用方可以主动 close() 并处理错误;析构函数只做兜底,保证资源不泄漏
。