$ cat ~ / posts /c++ /oop3 3.2k Words ~ 12 Mins
cover.png
面向对象程序设计03

#面向对象程序设计03

exdoubled Lv5

存储区域与生命周期

栈对象

局部变量和函数形参通常在栈上

函数调用时,编译器先计算当前函数所需的栈帧大小,然后生成类似 sub rsp, Nx86_64)的指令一次性扩展栈帧

本质上只是移动栈指针,因此开销非常低

1
2
3
4
void f() {
std::string name{"Wang"};
Person p{25, name};
} // p 先析构,name 后析构,最后栈帧整体回收

栈对象的生命周期和作用域绑定,进入作用域时构造,离开作用域时析构;不管是正常执行到右花括号,还是因为异常离开作用域,编译器都会在对应路径上插入析构代码

这也是为什么不能返回局部变量的指针或引用:

1
2
3
4
int* bad() {
int x = 42;
return &x; // 错误:函数返回后 x 的生命周期结束
}

返回值中的地址可能仍然是某个栈地址,但那个地址上的对象已经不存在,之后再访问就是未定义行为

堆对象

堆对象由程序员显式控制,写法是 newdelete

1
2
Person* p = new Person{25, "Wang"};
delete p;

newmalloc 不同:

  • 调用 operator new 分配原始内存;
  • 在这块内存上执行对象构造

delete 也不是单纯的 free

  • 调用对象的析构过程;
  • 调用 operator delete 归还内存。

如果只分配了内存而没有构造对象,这块内存只是 raw memory,里面可能是任意字节;如果析构完成但尚未释放内存,那么内存仍在,但对象已经不在

要特别注意配对:

1
2
auto p = new Person[10];
delete[] p; // 正确

new[] 必须配 delete[]

如果用 delete 释放数组,元素析构数量和内存布局都无法匹配,属于未定义行为

现代 C++ 推荐把堆对象交给 RAII 类型管理:

1
2
3
void g() {
auto p = std::make_unique<Person>(25, "Wang");
} // unique_ptr 析构时自动 delete

这样堆对象也获得了类似栈对象的语义边界:管理者离开作用域时,资源自动释放

读写数据段对象

全局对象和静态对象通常位于 .data.bss

它们的内存在程序加载时由操作系统映射,程序退出时回收

1
2
3
4
5
6
Logger global_logger;

Logger& logger() {
static Logger instance;
return instance;
}

全局对象在 main() 执行前构造,在 main() 返回或 exit() 时析构

静态局部对象在第一次执行到声明处时构造,C++11 保证这个初始化过程是线程安全的

跨编译单元的全局对象初始化顺序是未定义的,这会导致静态初始化顺序问题:

1
2
3
4
5
6
// file_a.cpp
Config config(getLogger());

// file_b.cpp
Logger logger;
Logger& getLogger() { return logger; }

如果 config 先于 logger 构造,就会使用一个尚未构造完成的对象

常见解决方式是 construct on first use:

1
2
3
4
Logger& getLogger() {
static Logger logger;
return logger;
}

把全局依赖推迟到第一次调用时构造,避免跨编译单元初始化顺序不确定

构造过程

构造过程通常分为两个阶段:

  1. 初始化阶段:构造基类和数据成员;
  2. 构造函数体阶段:执行用户写在 {} 里的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
uint16_t age_{};
std::string name_;

public:
Person(uint16_t age, std::string name)
: age_(age), name_(std::move(name)) {
if (age_ > 120) {
throw std::invalid_argument("age out of range");
}
if (name_.empty()) {
throw std::invalid_argument("empty name");
}
}
};

age_{} 是 NSDMI,也就是非静态数据成员默认初始化

对基本类型使用 {} 可以避免未初始化垃圾值,初始化列表中的初始化会覆盖 NSDMI

初始化列表和构造函数体赋值不一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Bad {
std::string name_;

public:
Bad(std::string name) {
name_ = std::move(name); // name_ 先默认构造,再赋值
}
};

class Good {
std::string name_;

public:
Good(std::string name) : name_(std::move(name)) {}
};

Bad 多了一次默认构造和一次赋值;Good 是直接构造

也就是说, Bad 先经历了一次空串构造,再经历一次移动赋值;Good 直接经历一次移动构造

对于 const 成员、引用成员、没有默认构造函数的成员,必须使用初始化列表,因为它们根本不能先默认构造再赋值,即不能使用 Bad 这类写法

成员构造顺序

成员构造顺序由类内声明顺序决定,不由初始化列表书写顺序决定

1
2
3
4
5
6
7
class Foo {
int a_;
int b_;

public:
Foo(int x) : b_(x), a_(b_ * 2) {}
};

看起来 b_ 写在前面,但实际仍然先初始化 a_,再初始化 b_

因此 a_(b_ * 2) 读取的是尚未初始化的 b_,行为未定义

正确习惯是:初始化列表顺序和成员声明顺序保持一致

继承下的构造顺序

派生类对象构造时,先完整构造基类,再构造派生类自己的成员,最后执行派生类构造函数体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
std::string species_;

public:
// explicit 关键字防止隐式类型转换,要求必须显式调用构造函数
explicit Animal(std::string species)
: species_(std::move(species)) {}

virtual ~Animal() = default;
};

class Dog : public Animal {
std::string breed_;

public:
Dog(std::string species, std::string breed)
: Animal(std::move(species)),
breed_(std::move(breed)) {}
};

Animal 构造期间,对象的动态类型还是 Animal,不是最终的 Dog

如果基类构造函数中调用虚函数,不会派发到派生类版本

原因为:派生类部分尚未构造完成,把调用派发给派生类函数是不安全的

析构过程拆解

析构顺序和构造顺序严格相反

析构过程分为:

  1. 执行当前类的析构函数体;
  2. 按声明顺序的逆序析构数据成员;
  3. 对派生类对象,还要继续析构基类部分
1
2
3
4
5
6
7
8
9
10
11
class Person {
uint16_t age_;
std::string name_;

public:
// noexcept 指定函数不会抛出异常,编译器可以优化异常处理路径
~Person() noexcept {
// 此时 age_ 和 name_ 仍然存在,可以访问
}
// 之后 name_ 自动析构,age_ 是基本类型无需析构
};

如果是继承:

1
2
3
4
5
6
7
8
9
10
11
class Dog : public Animal {
std::string breed_;

public:
// override 关键字明确标记这是覆盖基类的虚函数,编译器会检查签名是否匹配
~Dog() noexcept override {
// 先执行 Dog 的析构函数体
}
// 然后 breed_ 析构
// 最后 Animal 析构
};

基类析构函数必须是 virtual,只要这个类可能被通过基类指针删除:

1
2
Animal* a = new Dog("Canine", "Labrador");
delete a;

如果 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 中的资源没有被正确释放,此时 BaseDerived 的析构函数都不是虚的

若:

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,会来到 ptr Derived 的 vtable,此时 vtable Derived 的析构函数地址,正确调用 Derived 的析构函数,然后析构 Derived 对象, 之后再调用 Base 的析构函数,对象,最后释放内存

此时 BaseDerived 的析构函数都是虚的,把子类析构和父类析构看作同一类,此时子类析构也为虚函数,自然在虚函数表中覆盖了父类析构函数地址

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 执行:

  1. 静态类型为 Base*,动态类型为 Derived*
  2. 通过 vtable 查找析构函数 Derived::~Derived,调用 Derived删除析构函数
  3. 删除析构函数调用完整对象析构函数 Derived::~Derived,执行 Derived 的析构过程
  4. Derived 完整对象析构调用基类完整对象析构 Base::~Base,执行 Base 的析构过程
  5. 最后返回到 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
2
3
4
5
6
7
8
9
10
11
class Connection {
int socket_ = -1;

public:
Connection(const std::string& host, int port) {
socket_ = connect_to(host, port);
if (socket_ < 0) {
throw std::runtime_error("connect failed");
}
}
};

如果构造函数体抛出异常:

  • 已经构造完成的成员会自动析构;
  • 对象自身的析构函数不会执行,因为对象从未完整构造成功;
  • 调用方不会得到一个半有效对象

这比“两阶段构造”更符合对象契约:

1
2
3
4
5
class BadConnection {
public:
BadConnection() = default;
bool init(const std::string& host, int port);
};

两阶段构造让对象在 init() 前处于无效状态,调用方必须记得检查返回值,一旦忘记检查,就会在无效对象上继续操作

异常抛出后,C++ 运行时会进行栈展开:从抛出点沿调用链向上寻找匹配的 catch,并在离开每一层作用域时析构已经成功构造的局部对象

1
2
3
4
5
void foo() {
Resource a("a");
Resource b("b");
throw std::runtime_error("error");
} // b 和 a 会在栈展开中逆序析构

这是 RAII 能工作的基础:资源绑定在对象上,对象离开作用域时自动清理,即使离开方式是异常

类不变式

构造函数的职责是建立类不变式,类不变式是对象存活期间必须始终成立的条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Person {
uint16_t age_{};
std::string name_;

public:
Person(uint16_t age, std::string name)
: age_(age), name_(std::move(name)) {
if (age_ > 120) {
throw std::invalid_argument("age out of range");
}
if (name_.empty()) {
throw std::invalid_argument("name cannot be empty");
}
}

void setAge(uint16_t age) {
if (age > 120) {
throw std::invalid_argument("age out of range");
}
age_ = age;
}
};

构造函数是第一道门,成员函数是之后每一道门

每个会改变对象状态的成员函数,都必须在返回时继续保证不变式成立

这也是 classstruct 的一个语义区别:如果类型需要维护约束,更适合用默认 private 的 class;如果只是无约束的数据聚合,struct 更适合

析构函数不应抛异常

析构函数在栈展开期间被调用

如果此时析构函数又抛出异常,运行时需要同时处理两个异常,C++ 的处理方式是调用 std::terminate() 终止程序

C++11 起,析构函数默认隐式 noexcept,如果析构函数中确实要调用可能失败的清理逻辑,应当捕获所有异常,不让异常逃出析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Connection {
bool closed_ = false;

public:
void close() {
do_close(); // 可以抛异常,让调用方处理
closed_ = true;
}

~Connection() noexcept {
if (!closed_) {
try {
do_close();
} catch (...) {
// 析构函数是最后防线,不能传播异常
}
}
}
};

好的设计是把“可能失败的清理”和“必须不失败的析构”分开:调用方可以主动 close() 并处理错误;析构函数只做兜底,保证资源不泄漏

$ discussion
# Comments
waline