23. 继承中的构造与析构:派生类构造函数调用基类构造函数、继承中的拷贝与移动控制

继承,是C++面向对象的三大特性之一。但说实话,很多人在写继承的时候,只关注了“子类能用父类的接口”,却忽略了构造和析构的细节。嗯,今天我们就来聊聊这块。

我个人习惯把继承中的构造和析构比作“搭积木”。你搭一个复杂的结构,得从底层开始一层层往上搭。拆的时候呢?得从顶层一层层往下拆。这个顺序,C++是强制规定的,你改不了。

派生类构造函数:先父后子

先看一个最简单的例子:

class Base {
public:
    Base() { cout << "Base构造\n"; }
    ~Base() { cout << "Base析构\n"; }
};

class Derived : public Base {
public:
    Derived() { cout << "Derived构造\n"; }
    ~Derived() { cout << "Derived析构\n"; }
};

int main() {
    Derived d;
    return 0;
}

输出是什么?

Base构造
Derived构造
Derived析构
Base析构

看到了吧?构造顺序:先基类,后派生类。析构顺序:先派生类,后基类。这个顺序是铁律,C++标准强制保证的。

核心原则:派生类对象构造时,基类部分先被构造;析构时,派生类部分先被析构,然后才轮到基类。

为什么会这样?你想想看,派生类可能要用到基类的成员。如果基类还没构造好,派生类怎么用?反过来,析构时如果先把基类拆了,派生类再去访问基类成员,那就出大问题了。

显式调用基类构造函数

如果基类没有默认构造函数,或者你想传参数给基类,那就得在派生类的初始化列表里显式调用:

class Base {
public:
    Base(int x) { cout << "Base构造,x=" << x << "\n"; }
};

class Derived : public Base {
public:
    // 必须在初始化列表中调用Base的构造函数
    Derived(int a, int b) : Base(a), m_data(b) {
        cout << "Derived构造\n";
    }
private:
    int m_data;
};

这里有个坑,我曾经踩过。如果你忘了在初始化列表里调用基类构造函数,而基类又没有默认构造函数,编译器会直接报错。嗯,这个错误信息有时候不太直观,新手容易懵。

注意:基类构造函数调用必须放在派生类初始化列表中,不能放在函数体内。而且,它必须排在派生类成员初始化之前。

继承中的拷贝控制

拷贝构造、拷贝赋值、移动构造、移动赋值——这些在继承体系下,行为会变得复杂。说白了,派生类的拷贝操作,不仅要处理自己的成员,还得处理基类的那部分。

默认行为

如果你不显式定义,编译器会生成默认的拷贝和移动操作。对于派生类,默认的拷贝构造会调用基类的拷贝构造,默认的移动构造会调用基类的移动构造。听起来挺省心?

但问题来了:如果基类把拷贝构造删除了(= delete),或者声明为private,那派生类的拷贝构造也会被隐式删除。我见过有人把基类设计成不可拷贝,结果派生类也跟着遭殃。

自定义拷贝构造

当你需要手动实现派生类的拷贝构造时,记得显式调用基类的拷贝构造:

class Base {
public:
    Base(const Base& other) : m_value(other.m_value) {}
    // ...
private:
    int m_value;
};

class Derived : public Base {
public:
    // 显式调用基类拷贝构造
    Derived(const Derived& other) 
        : Base(other), m_name(other.m_name) {}
    // ...
private:
    string m_name;
};

注意这里:Base(other) 把派生类对象传给基类的拷贝构造。这是合法的,因为派生类对象可以隐式转换为基类引用(切片)。

小技巧:写派生类的拷贝构造时,我习惯先写基类的拷贝调用,再写派生类自己的成员初始化。这样顺序清晰,不容易漏。

拷贝赋值运算符

拷贝赋值稍微麻烦一点。你需要显式调用基类的拷贝赋值:

Derived& operator=(const Derived& other) {
    if (this != &other) {
        Base::operator=(other);  // 调用基类拷贝赋值
        m_name = other.m_name;   // 处理自己的成员
    }
    return *this;
}

这里有个细节:Base::operator=(other) 中的 other 虽然是 Derived 类型,但可以隐式转换为 Base 引用,所以没问题。

移动操作

移动构造和移动赋值的写法,跟拷贝类似,只是把引用类型换成右值引用:

class Derived : public Base {
public:
    // 移动构造
    Derived(Derived&& other) noexcept
        : Base(std::move(other)), m_name(std::move(other.m_name)) {}
    
    // 移动赋值
    Derived& operator=(Derived&& other) noexcept {
        if (this != &other) {
            Base::operator=(std::move(other));
            m_name = std::move(other.m_name);
        }
        return *this;
    }
private:
    string m_name;
};

注意 std::move(other) 传给基类。虽然 other 是右值引用,但一旦有了名字,它就变成左值了。所以必须用 std::move 把它转回右值,才能触发基类的移动构造。

经验之谈:我在项目中遇到过一个问题——派生类定义了析构函数,但忘了定义移动操作。结果编译器没有生成默认移动构造,所有移动操作都退化成了拷贝。性能直接掉了一个数量级。所以,如果你在继承体系中定义了析构函数,记得把移动操作也补上,或者用 = default 显式要求编译器生成。

知识体系总览

下面这张图,把继承中构造与析构的核心逻辑串起来了:

继承中的构造与析构 · 知识体系 构造顺序 基类构造 → 派生类构造 显式调用:初始化列表中调用基类构造 无默认构造时,必须显式调用 析构顺序 派生类析构 → 基类析构 自动调用,无需显式写 虚析构函数:基类必须声明为virtual 拷贝控制 拷贝构造:Base(other) 显式调用 拷贝赋值:Base::operator=(other) 注意自赋值检查 移动控制 移动构造:Base(std::move(other)) 移动赋值:Base::operator=(std::move(other)) noexcept 声明,提升性能

避坑指南

最后,分享几个我踩过的坑:

  • 基类析构函数不是虚函数:如果你通过基类指针删除派生类对象,析构函数不调用派生类的,内存泄漏妥妥的。我曾经在一个老项目里排查了三天,才发现是这个问题。
  • 拷贝赋值忘了调用基类版本:派生类的拷贝赋值如果不显式调用基类的,基类部分就不会被赋值。嗯,这个bug特别隐蔽,因为看起来好像一切正常,但数据就是不对。
  • 移动操作被隐式删除:只要派生类定义了析构函数、拷贝构造或拷贝赋值中的任何一个,编译器就不会生成默认移动操作。如果你需要移动语义,记得用 = default 或者手动实现。

一句话总结:继承中的构造和析构,记住“先父后子,先子后父”这八个字。拷贝和移动操作,记得显式调用基类的对应函数,别让编译器猜你的心思。


公众号:蓝海资料掘金营,微信deep3321