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);。这会导致切片,派生类部分被丢弃。
嗯,继承与移动语义的配合,说白了就是「知道自己在做什么」。基类负责基类的移动,派生类负责派生类的移动,两者各司其职。至于多态对象,接受现实吧——用智能指针管理生命周期,比强行移动要安全得多。