原型模式(Prototype):深拷贝与浅拷贝,C++拷贝构造函数与clone()方法,原型注册表
原型模式,说白了就是「复制粘贴」的设计模式。你想想看,有时候创建一个对象特别费劲——要加载配置、要建立网络连接、要计算一堆初始值。这时候如果有个现成的对象,直接复制一份改改,是不是省事多了?
我在项目中遇到过好几次这样的场景。比如游戏开发里,一个怪物的生成需要加载模型、绑定动画、初始化AI状态。如果每个怪物都从头创建,性能扛不住。用原型模式,先造一个「模板怪物」,后面全是复制出来的。
深拷贝 vs 浅拷贝:一个指针引发的血案
先聊拷贝这件事。C++里默认的拷贝构造函数,做的是浅拷贝。什么意思?就是只复制指针的值,不复制指针指向的内容。
class Monster {
public:
int* hp; // 指向堆上的血量数据
Monster(int h) { hp = new int(h); }
// 默认拷贝构造函数——浅拷贝
// Monster(const Monster& other) : hp(other.hp) {}
~Monster() { delete hp; }
};
Monster a(100);
Monster b = a; // 浅拷贝:a.hp 和 b.hp 指向同一块内存
delete a.hp; // 完了,b.hp 变成悬空指针
我曾经在一个网络库的序列化模块里踩过这个坑。两个对象共享同一块缓冲区,一个释放了,另一个还在用。查了两天才定位到问题——嗯,从那以后我对浅拷贝格外敏感。
深拷贝就不一样了。它会复制指针指向的内容,让每个对象拥有独立的数据副本。
class Monster {
public:
int* hp;
Monster(int h) { hp = new int(h); }
// 深拷贝构造函数
Monster(const Monster& other) {
hp = new int(*other.hp); // 重新分配内存,复制值
}
~Monster() { delete hp; }
};
核心区别一句话:浅拷贝复制指针,深拷贝复制数据。浅拷贝快但危险,深拷贝安全但慢。
拷贝构造函数 vs clone() 方法
C++里实现原型模式,有两种主流方式。一种是利用拷贝构造函数,另一种是自定义 clone() 虚函数。
拷贝构造函数是C++内置的机制。你写 Monster b = a; 或者 Monster b(a); 就会调用它。好处是语法自然,坏处是——它不支持多态。
Monster* ptr = new DragonMonster(100); // 派生类对象
Monster copy = *ptr; // 调用的是 Monster 的拷贝构造,不是 DragonMonster 的!
// 对象切割(slicing)发生了
为什么会这样?因为拷贝构造函数的调用是在编译期决定的,不是运行时多态。你想想看,如果基类指针指向派生类对象,用拷贝构造只能复制基类部分,派生类的成员全丢了。
这时候就需要 clone() 方法了。它是一个虚函数,在运行时根据实际类型调用对应的拷贝逻辑。
class Monster {
public:
virtual ~Monster() = default;
virtual Monster* clone() const = 0; // 纯虚函数
};
class DragonMonster : public Monster {
public:
int firePower;
DragonMonster(int hp, int fp) : firePower(fp) {}
DragonMonster* clone() const override {
return new DragonMonster(*this); // 调用自己的拷贝构造
}
};
class GoblinMonster : public Monster {
public:
int stealth;
GoblinMonster(int hp, int s) : stealth(s) {}
GoblinMonster* clone() const override {
return new GoblinMonster(*this);
}
};
// 使用
Monster* base = new DragonMonster(100, 50);
Monster* cloned = base->clone(); // 正确复制 DragonMonster 的全部数据
我个人习惯:只要类有继承关系,就老老实实写 clone() 虚函数。拷贝构造函数只用在「确定类型」的场景,比如栈上对象的复制。
原型注册表:让复制更灵活
光有 clone() 还不够。你想想看,如果我有几十种怪物,每次创建都要知道具体类型名,那跟直接 new 有什么区别?原型注册表就是来解决这个问题的。
说白了,注册表就是一个「原型对象仓库」。你把各种类型的原型对象注册进去,后面只需要传一个字符串或者枚举值,就能拿到对应的克隆体。
class MonsterRegistry {
private:
std::unordered_map<std::string, Monster*> prototypes;
public:
void registerPrototype(const std::string& key, Monster* proto) {
prototypes[key] = proto;
}
Monster* createMonster(const std::string& type) {
auto it = prototypes.find(type);
if (it != prototypes.end()) {
return it->second->clone(); // 克隆原型
}
return nullptr;
}
~MonsterRegistry() {
for (auto& [key, proto] : prototypes) {
delete proto; // 清理原型对象
}
}
};
// 使用
MonsterRegistry registry;
registry.registerPrototype("dragon", new DragonMonster(100, 50));
registry.registerPrototype("goblin", new GoblinMonster(80, 30));
Monster* m1 = registry.createMonster("dragon"); // 不用知道具体类名
Monster* m2 = registry.createMonster("goblin");
我曾经踩过的坑:注册表里的原型对象必须是「完全初始化」的。有一次我注册了一个半成品的原型,结果克隆出来的对象缺胳膊少腿。建议在注册前,先确保原型对象处于「可用状态」。
知识体系结构图
下面这张图把原型模式的核心脉络梳理清楚了。从拷贝方式到实现手段,再到注册表的管理,一条线串下来。
什么时候用原型模式?
我总结了几条经验,供你参考:
- 对象创建成本高——比如需要加载配置文件、建立数据库连接、初始化复杂数据结构。这时候复制一个现成对象比从头创建快得多。
- 对象类型在运行时才能确定——比如工厂模式里,根据用户输入创建不同类型的对象。用原型注册表可以避免大量的 if-else 或 switch-case。
- 需要避免子类数量爆炸——如果每个类型都对应一个工厂类,类会越来越多。原型模式把「创建」和「类型」解耦了。
一个小建议:如果你的类里没有指针成员,或者所有成员都是值类型,直接用默认的拷贝构造函数就行,别画蛇添足。原型模式不是万能的,别为了用模式而用模式。
避坑指南
最后分享几个我踩过的坑:
- 循环引用问题:如果原型对象内部有指向其他原型对象的指针,深拷贝时要小心。我曾经复制一个对象,结果把整个对象图都复制了一遍,内存直接爆了。建议用「写时复制」或者「引用计数」来管理。
- 虚函数陷阱:在构造函数里调用虚函数,不会触发多态。所以不要在拷贝构造函数里调用
clone(),那会无限递归。 - 注册表线程安全:如果原型注册表被多个线程访问,记得加锁。我有个同事没注意这个,线上出了偶发的「怪物生成失败」bug,查了一周才发现是并发问题。
原型模式其实不复杂,核心就是「复制」两个字。但深拷贝和浅拷贝的区别、拷贝构造函数和 clone() 的选择、注册表的管理,这些细节决定了你的代码是优雅还是埋雷。嗯,多写几次自然就熟了。