14、移动语义与继承:基类与派生类的移动操作、虚析构函数与移动、多态对象的移动问题

移动语义在单类中表现得很完美,但一旦进入继承体系,事情就开始变得微妙了。我最早在重构一个大型游戏引擎时,就踩过这个坑——派生类明明写了移动构造函数,结果性能还不如拷贝。后来才发现,是基类的移动操作根本没被调用。

这一章,我们就来聊聊继承体系下的移动语义。说白了,就是搞清楚三件事:派生类怎么正确实现移动操作?虚析构函数会不会影响移动?多态对象到底能不能移动?

派生类的移动操作:别忘了基类部分

先看一个简单的例子。假设我们有基类 Base 和派生类 Derived

class Base {
public:
    Base() = default;
    Base(Base&& other) noexcept 
        : data_(std::move(other.data_)) {
        other.data_ = nullptr;
    }
    Base& operator=(Base&& other) noexcept {
        if (this != &other) {
            data_ = std::move(other.data_);
            other.data_ = nullptr;
        }
        return *this;
    }
private:
    int* data_ = nullptr;
};

class Derived : public Base {
public:
    Derived() = default;
    // 错误写法:只移动了派生类部分
    Derived(Derived&& other) noexcept
        : extra_(std::move(other.extra_)) {
        // 基类部分没有被移动!
    }
private:
    std::string extra_;
};

看到问题了吗?派生类的移动构造函数只初始化了自己的成员 extra_,但基类部分 data_ 还是调用了默认构造函数。这意味着基类的资源没有被转移,而是被重新创建了。

注意:派生类移动构造函数必须显式调用基类的移动构造函数,否则基类部分会被默认构造。

正确的写法是这样的:

class Derived : public Base {
public:
    Derived(Derived&& other) noexcept
        : Base(std::move(other))  // 显式调用基类移动构造
        , extra_(std::move(other.extra_)) {
    }
    
    Derived& operator=(Derived&& other) noexcept {
        if (this != &other) {
            Base::operator=(std::move(other));  // 移动基类部分
            extra_ = std::move(other.extra_);   // 移动派生类部分
        }
        return *this;
    }
private:
    std::string extra_;
};

我个人习惯在写派生类移动操作时,总是先检查基类部分是否被正确处理。你想想看,如果基类有几十个成员,漏掉一个就是内存泄漏。

虚析构函数与移动:一个常见的误解

很多初学者以为,只要类里有虚函数,移动语义就自动失效了。其实不是这样。虚析构函数本身不会阻止移动操作,真正影响移动的是——用户自定义的析构函数会抑制编译器生成的移动构造函数。

看这个例子:

class Base {
public:
    virtual ~Base() = default;  // 虚析构函数
    // 移动操作被隐式删除了!
    // 因为用户声明了析构函数
};

class Derived : public Base {
public:
    Derived(Derived&&) = default;  // 错误:基类移动被删除
};

为什么会这样?C++ 的规则是:如果你显式声明了析构函数(即使是 = default),编译器就不会隐式生成移动构造函数和移动赋值运算符。这是为了防止你忘记处理资源释放逻辑。

经验之谈:如果你需要虚析构函数,同时还需要移动语义,记得显式声明移动操作:
class Base {
public:
    virtual ~Base() = default;
    Base(Base&&) = default;           // 显式要求生成
    Base& operator=(Base&&) = default;
};

我曾经在一个网络库中看到过这样的代码:基类有虚析构函数,派生类用 std::vector 存储数据,结果每次重新分配都要拷贝整个数组。加上 = default 的移动操作后,性能提升了 3 倍。

多态对象的移动问题:一个无解的难题

这是本章最核心的问题。多态对象通过基类指针或引用操作,但移动语义要求你知道对象的完整类型。这两者天然矛盾。

看这个场景:

std::vector<std::unique_ptr<Base>> objects;
objects.push_back(std::make_unique<Derived>());

// 想移动第一个对象?
auto obj = std::move(objects[0]);  // 这是移动指针,不是移动对象!
// obj 现在指向原来的 Derived 对象
// objects[0] 变成了 nullptr

这里移动的是 unique_ptr 本身,而不是它指向的 Derived 对象。如果你真的想移动多态对象内部的数据,那就麻烦了。

为什么?因为:

  • 移动构造函数需要知道对象的完整类型,才能调用正确的移动操作
  • 通过基类指针调用移动构造,无法触发派生类的移动逻辑
  • 即使你写了 virtual 版本的移动函数,C++ 也不支持虚移动构造函数
核心结论:多态对象不支持真正的移动语义。你只能移动指针或智能指针,不能移动对象本身。

那有没有变通方案?有的。我见过两种常见的做法:

方案一:虚 clone 方法 + 移动语义

class Base {
public:
    virtual ~Base() = default;
    virtual std::unique_ptr<Base> clone() const = 0;
    virtual void moveFrom(Base&& other) = 0;
};

class Derived : public Base {
public:
    std::unique_ptr<Base> clone() const override {
        return std::make_unique<Derived>(*this);
    }
    void moveFrom(Base&& other) override {
        auto& derived = static_cast<Derived&&>(other);
        *this = std::move(derived);
    }
};

这个方案的问题在于:你需要知道派生类的具体类型才能做 static_cast。如果类型不确定,就得用 dynamic_cast,但那样又引入了运行时开销。

方案二:使用 std::variant 替代继承

如果多态对象的类型集合是有限的,可以考虑用 std::variant

using GameObject = std::variant<Player, Enemy, Bullet>;

std::vector<GameObject> objects;
objects.push_back(Player{});
objects.push_back(Enemy{});

// 现在可以正常移动了
GameObject obj = std::move(objects[0]);

std::variant 知道当前存储的是哪种类型,所以移动操作可以正确完成。但代价是:你失去了开放扩展的能力——不能随意添加新的派生类。

避坑指南:我踩过的几个坑

最后分享几个我实际项目中遇到的教训:

  • 基类移动赋值不调用派生类:如果你写了 Base& operator=(Base&&),它只会移动基类部分。派生类的成员不会被触及。我曾经因此丢过数据。
  • noexcept 不一致:基类移动操作标记了 noexcept,派生类没标记。结果 std::vector 在重新分配时选择了拷贝而不是移动,性能骤降。
  • 切片问题:永远不要写 Base obj = std::move(derivedObj);。这会导致切片,派生类部分被丢弃。

嗯,继承与移动语义的配合,说白了就是「知道自己在做什么」。基类负责基类的移动,派生类负责派生类的移动,两者各司其职。至于多态对象,接受现实吧——用智能指针管理生命周期,比强行移动要安全得多。

移动语义与继承:核心知识体系 继承体系中的移动语义 派生类移动操作 虚析构函数与移动 多态对象移动问题 显式调用基类移动构造 移动赋值运算符链式调用 析构函数抑制移动生成 显式 =default 恢复移动 只能移动指针/智能指针 虚clone + moveFrom方案 核心原则:各司其职,显式调用 多态对象不支持真正的移动语义,只能通过智能指针或 variant 变通 ⚠️ 常见陷阱 • 切片问题 • noexcept不一致 • 基类移动不调用派生类
公众号:蓝海资料掘金营,微信 deep3321