Skip to content

Stage07|Modern C++ Foundation ​

Lesson083|Class、Object、构造、析构与对象生命周期 ​

Tags: #C++17 #Class #Constructor #DestructorDifficulty: ⭐⭐⭐⭐☆


一、问题场景:为什么成员已经“初始化”却仍出错 ​

cpp
class TextureView {
    int width_;
    std::vector<int> pixels_;
public:
    TextureView(int width) {
        width_ = width;
        pixels_.resize(width_);
    }
};

构造函数体执行前,成员已经按声明顺序完成初始化。上面的代码先默认构造成员,再赋值或 resize;更准确的写法是成员初始化列表。

二、本课目标 ​

  1. 理解类型、对象、存储和生命周期的区别。
  2. 掌握构造、析构和成员初始化顺序。
  3. 理解默认构造、委托构造和 explicit。
  4. 识别继承析构和虚函数风险。
  5. 设计始终处于有效状态的类。

三、回到 TaskSystem:把任务从字段变成对象 ​

cpp
class Task {
public:
    explicit Task(int id) : id_(id) {}
    int id() const noexcept { return id_; }
private:
    int id_;
};

这个类的构造函数建立了不变量:Task 一旦构造成功就有合法 ID。后续加入回调、状态和结果时,仍要保持“对象可用”与“任务已经执行”是两个不同状态。

四、对象生命周期 ​

text
取得存储
→ 开始对象生命周期
→ 构造基类
→ 按声明顺序构造成员
→ 执行构造函数体
→ 使用对象
→ 执行析构函数体
→ 逆序析构成员和基类
→ 释放或复用存储

存储仍存在不代表其中对象的生命周期仍存在。这正是 placement new、对象池和跨语言对象映射容易出错的边界。

五、成员初始化顺序 ​

cpp
class SpriteBatch {
    std::size_t capacity_;
    std::vector<int> vertices_;
public:
    explicit SpriteBatch(std::size_t capacity)
        : capacity_(capacity), vertices_(capacity) {}
};

成员始终按类中声明顺序初始化,不按初始化列表书写顺序。让列表顺序与声明一致,开启 -Wreorder。

六、默认值与 explicit ​

cpp
class Seconds {
public:
    explicit Seconds(double value) : value_(value) {}
private:
    double value_{};
};

explicit 阻止 Seconds s = 3.0 这类隐式转换,使单位和领域类型更清楚。成员默认初始化器则给所有构造路径提供安全基线。

七、析构与多态 ​

如果通过基类指针删除派生对象,基类通常需要虚析构:

cpp
struct Task {
    virtual ~Task() = default;
    virtual void run() = 0;
};

构造和析构期间调用虚函数不会表现为“完整派生对象的动态分派”。此时派生部分尚未构造或已经析构,不应依赖它。

八、可编译验证:记录顺序 ​

cpp
#include <iostream>
#include <string>

struct Trace {
    std::string name;
    explicit Trace(std::string n) : name(std::move(n)) {
        std::cout << "+ " << name << '\n';
    }
    ~Trace() { std::cout << "- " << name << '\n'; }
};

struct Player {
    Trace transform{"transform"};
    Trace renderer{"renderer"};
    Player() { std::cout << "Player body\n"; }
    ~Player() { std::cout << "Player destroy body\n"; }
};

int main() { Player player; }

先预测输出,再编译运行。然后交换成员声明顺序,确认构造顺序随声明变化。

九、TaskSystem 中的生命周期问题 ​

队列中的 Task 可能经历“创建、入队、取出、执行、完成、销毁”。如果析构函数释放了回调捕获的资源,执行线程必须在销毁前结束;如果通过基类接口销毁派生任务,基类需要虚析构。先画状态和 owner,再设计类。

十、故障诊断 ​

text
构造中崩溃 → 成员初始化顺序、空依赖、异常
析构中崩溃 → 重复释放、依赖已先析构、虚调用
对象状态偶发无效 → 是否允许半初始化、移动后是否继续使用
通过基类释放异常 → 虚析构、所有权和 allocator 是否匹配

十一、设计原则:建立类不变量 ​

构造成功后,类应满足可描述的不变量。例如 Texture(width, height) 要么拥有匹配尺寸的像素缓冲,要么构造失败;不要创建一个必须再调用 init() 才勉强可用的半成品,除非框架生命周期确实强制如此。

十二、练习与答案 ​

1. 初始化列表和构造函数体赋值有什么区别? ​

初始化列表直接构造成员;函数体赋值发生在成员已初始化之后,对不可默认构造、引用或 const 成员尤其关键。

2. 析构顺序为什么是构造的逆序? ​

后构造的对象可能依赖先构造对象,逆序析构可让依赖在使用期间仍存在。

3. 什么是类不变量? ​

对象在公开操作前后必须持续满足的约束。它让调用方不必反复猜对象是否完成初始化。

十三、本课总结 ​

类不仅是一组字段和函数,更是对有效状态、生命周期和资源责任的约束。构造建立不变量,析构收回对象负责的资源。