【设计模式系列 (五) 】原型模式

⭐️在这个怀疑的年代,我们依然需要信仰。

个人主页 :YYYing.

⭐️设计模式系列专栏:设计模式系列

系列上期内容:【设计模式系列 (四) 】建造者模式

系列下期内容:暂无


目录

[1. 概述](#1. 概述)

[2. 结构](#2. 结构)

[3. 核心矛盾:浅克隆 vs 深克隆](#3. 核心矛盾:浅克隆 vs 深克隆)

[4. 实现一:浅克隆(proto/ex1)](#4. 实现一:浅克隆(proto/ex1))

[4.1 抽象原型](#4.1 抽象原型)

[4.2 具体原型](#4.2 具体原型)

[4.3 客户端验证共享](#4.3 客户端验证共享)

[5. 实现二:深克隆(proto/ex2)](#5. 实现二:深克隆(proto/ex2))

[5.1 这段代码里的两个坑](#5.1 这段代码里的两个坑)

[5.2 深克隆的另一种实现:序列化](#5.2 深克隆的另一种实现:序列化)

[6. 为什么 clone() 不直接返回具体类型?](#6. 为什么 clone() 不直接返回具体类型?)

[7. 优缺点与适用环境](#7. 优缺点与适用环境)

[7.1 典型用法:原型管理器](#7.1 典型用法:原型管理器)

[8. 面试专题](#8. 面试专题)

[8.1 开场题:说说原型模式吧](#8.1 开场题:说说原型模式吧)

[8.2 高频追问清单](#8.2 高频追问清单)

[8.3 什么时候不该用原型](#8.3 什么时候不该用原型)

结语


创建型模式里,原型模式是最容易被低估的一个。它的核心只有两个字:克隆。

本文代码取自 design-pattern-cpp 仓库的 design-pattern/proto/,使用 C++11。为阅读方便,片段省略了头文件保护宏。


1. 概述

Wikipedia :The prototype pattern is a creational design pattern in software development. It is used when the type of objects to create is determined by a prototypical instance, which is cloned to produce new objects.

原型模式是软件开发中的一种创造设计模式。当要创建的对象类型由原型实例确定时使用,该实例被克隆以生成新对象。

GoF:Specify the kinds of objects to create using a prototypical instance, and create new objects by copying this prototype.

使用原型实例指定要创建的对象的种类,并通过复制此原型来创建新对象。

翻译过来一句话:用原型实例指定要创建的对象种类,并通过复制这个原型来创建新对象。

两个关键词:

① 原型实例(prototypical instance)

创建对象不再通过 new 类名(),而是从一个已经存在的实例身上复制。

② 克隆(clone)

原型模式解决的问题是"创建代价高":

  • 对象的构造需要查数据库、发网络请求、做大量计算 → 造一个就够,剩下的复制;

  • 运行时才知道要造哪种子类(手里只有一个基类指针)→ 让它自己 clone 自己,比写一堆 if/else 判断类型再 new 优雅;

  • 需要一个与现有对象状态相同、后续独立演化的副本 → 比如游戏关卡编辑器里的"复制粘贴"、撤销栈里的对象快照。


2. 结构

角色 职责
Prototype(抽象原型) 声明 clone() 接口。所有原型必须实现它
ConcretePrototype(具体原型) 实现 clone(),返回自己的一个副本
Client(客户端) 调用 clone() 得到新对象,不关心具体类型

结构很简单:一个虚函数 + 一次拷贝构造。难的是"拷贝"到底拷多深。


3. 核心矛盾:浅克隆 vs 深克隆

这是原型模式真正要考的东西。

C++ 默认的拷贝构造是逐成员拷贝(逐字节复制),对指针成员只复制地址本身:

成员类型 默认拷贝的行为 后果
基本类型 / 值语义成员 复制一份值 ✅ 两个对象互不影响
指针 / 引用成员 只复制地址 ❌ 两个对象指向同一份资源

于是有了两个概念:

  • 浅克隆(Shallow Clone) :只复制值成员和指针本身,指针指向的资源仍是共享 的。改副本会影响原型;两个对象析构时还会重复释放同一块内存。

  • 深克隆(Deep Clone) :连指针指向的对象也复制一份,副本与原型完全独立。

一句话:浅克隆共享资源,深克隆复制资源。


4. 实现一:浅克隆(proto/ex1)

4.1 抽象原型

cpp 复制代码
namespace proto
{
    class Prototyppe          // 仓库里的原拼写,多了一个 p
    {
    public:
        virtual Prototyppe* clone() = 0;
    };
}

4.2 具体原型

仓库里用 CC_SYNTHESIZE 系列宏自动生成 getter/setter,先看一眼宏展开才读得懂代码:

cpp 复制代码
/** 定义受保护的变量,并提供get、set方法 */
#define CC_SYNTHESIZE(varType, varName, funName)\
protected: varType varName;\
public: varType get##funName(void) const { return varName; }\
public: void set##funName(varType var){ varName = var; }

Level 的成员里有 std::vector<Monster*> 和 Rewards* 两个指针成员,这正是浅克隆会出问题的地方:

cpp 复制代码
class Level : public Prototyppe
{
    CC_SYNTHESIZE(std::string, name, Name);
    CC_SYNTHESIZE(std::string, descrip, Descrip);
    CC_SYNTHESIZE_CR_GET(std::vector<Monster*>, monsters, Monsters);  // 返回引用,才能 push_back
    CC_SYNTHESIZE(Rewards*, rew, Rew);
    CC_SYNTHESIZE_SET(bool, release, Release);                        // 只给 set,不需要 get
public:
    Prototyppe* clone() override
    {
        // 使用默认拷贝构造进行浅克隆
        Level* copy = new Level(*this);
        copy->setRelease(false);
        return copy;
    }
    Level() {
        release = true;
        this->rew = nullptr;
    }
    ~Level() {
        if (!release) return;          // ← 副本不负责释放资源
        if (this->rew) delete rew;
        for (auto one : monsters) delete one;
        monsters.clear();
    }
};

关键在于 release 这个标记:clone() 造出的副本把 release 置为 false,于是它析构时直接 return ,不碰那些共享的 Rewards 和 Monster。

这是"浅克隆避免 double free"的典型权宜写法------资源只有原型自己释放,副本只借用 。代价也很明显:副本一旦自己新增了 Monster / Rewards,就泄漏了。

4.3 客户端验证共享

cpp 复制代码
Level* proto = new Level();
proto->setName("普通山庄");
proto->setDescrip("这是一个比较危险的山庄,经常有山蜇出没.");
proto->getMonsters().push_back(new Monster(10, 20));
proto->setRew(new Rewards(10));
​
Level* copy = dynamic_cast<Level*>(proto->clone());
copy->setDescrip("我修改了描述");          // 值成员:互不影响
std::cout << "proto:" << proto->getDescrip() << std::endl;   // 原描述
std::cout << "copy:"  << copy->getDescrip()  << std::endl;   // 我修改了描述
​
copy->getRew()->setGold(30);              // 指针成员:改副本 = 改原型
std::cout << "proto:" << proto->getRew()->getGold() << std::endl;  // 30 ← 被带跑了
std::cout << "copy:"  << copy->getRew()->getGold()  << std::endl;  // 30

copy->getRew()->setGold(30) 这一行就是浅克隆的"罪证":只改了副本的奖励,原型的金币也跟着变成 30。

顺带一提,Prototyppe* 要 dynamic_cast<Level*> 才能拿回具体类型------这个问题下一节会讲怎么优雅解决。


5. 实现二:深克隆(proto/ex2)

思路:绕过默认拷贝构造,自己写一套 copy() / release(),让拷贝构造和赋值运算符共用同一份深拷贝逻辑。

cpp 复制代码
class Level : public Prototyppe
{
    CC_SYNTHESIZE(std::string, name, Name);
    CC_SYNTHESIZE(std::string, descrip, Descrip);
    CC_SYNTHESIZE_CR_GET(std::vector<Monster*>, monsters, Monsters);
    CC_SYNTHESIZE(Rewards*, rew, Rew);
private:
    void copy(const Level& l) {
        this->name = l.name;
        this->descrip = l.descrip;   // 值成员直接赋
        if (l.rew)
        {
            this->rew = new Rewards(*l.rew);          // ★ 指针成员:重新 new 一份
        }
        for (auto m : l.monsters)
        {
            this->monsters.push_back(new Monster(*m)); // ★ 容器里的指针同样逐个深拷贝
        }
    }
    void release() {
        if (this->rew) { delete rew; this->rew = nullptr; }
        for (auto one : monsters) delete one;
        monsters.clear();
    }
public:
    Prototyppe* clone() override
    {
        // 使用拷贝构造进行克隆
        return new Level(*this);
    }
    Level() { this->rew = nullptr; }
    ~Level() { release(); }
    // 重写拷贝构造
    Level(const Level& l) { copy(l); }
    // 重载赋值运算符
    Level& operator=(const Level& l) {
        release();       // 先释放资源,防止覆盖前泄漏
        copy(l);         // 再复制
        return *this;
    }
};

三个要点:

  1. copy() 是深拷贝的唯一实现点,拷贝构造和赋值运算符都复用它。这样两份逻辑不会走偏,改一处即可。

  2. operator= 必须"先 release 再 copy",否则旧资源会被直接覆盖,造成泄漏。

  3. clone() 里的 new Level(*this) 走的就是被重写过的拷贝构造,所以自动变成深克隆------clone() 本身不需要懂"深"还是"浅"。

此时再跑一遍客户端的验证代码:原型打印 10,副本打印 30,两者彻底独立。

5.1 这段代码里的两个坑

① 自赋值没有防护

cpp 复制代码
Level& operator=(const Level& l) {
    release();   // 如果 this == &l,这一步已经把资源删了
    copy(l);     // 再去读已释放的内存 → UB
    return *this;
}

标准写法要在 release() 之前判断:

cpp 复制代码
if (this == &l) return *this;

② 异常安全

copy() 进行到一半抛异常(比如 new 失败),对象会停在"已经 release、只拷了一半"的半残状态,析构时还可能二次释放。

工程里更稳的做法是把裸指针换成智能指针 (std::unique_ptr<Rewards>、std::vector<std::unique_ptr<Monster>>),拷贝构造交给 RAII 管,深克隆只需要在 clone() 里显式 std::make_unique 一份。

5.2 深克隆的另一种实现:序列化

先把原型序列化 成字节流,再从字节流反序列化出新对象。

  • 优点:不用给每个类手写 copy(),天然深拷贝,对类的新增成员自动生效;

  • 缺点:引入序列化框架、性能差、遇到不可序列化的成员(文件描述符、句柄、锁)就歇菜。

Java 的 Object.clone() 要求实现 Cloneable 接口,本质上也是同一类思路的产物。C++ 里没有语言级的 clone 支持,只能自己实现。


6. 为什么 clone() 不直接返回具体类型?

4.3 节提到过,仓库里返回 Prototyppe*,客户端要 dynamic_cast 才能拿回 Level*。其实 C++ 支持协变返回类型(covariant return type):

cpp 复制代码
Level* clone() override { return new Level(*this); }   // 合法,派生类可以收窄返回类型

派生类把返回类型改写为更具体的类型是允许的(前提是返回指针或引用),这样客户端就不用 dynamic_cast 了。C++ 里这是零成本的。

但更值得注意的是原型模式的根本价值 ------多态地复制:

cpp 复制代码
Prototyppe* p = getSomePrototype();   // 运行时才知道是 Level 还是 Boss
Prototyppe* copy = p->clone();        // 克隆出来的必然是正确的那一种

这件事拷贝构造函数做不到 :拷贝构造是编译期静态绑定的,Prototyppe p = someLevel; 会发生对象切片 (slicing),派生部分被切掉。想在"只有一个基类指针"的前提下复制出正确的派生类对象,只能是虚函数 ------这就是 clone() 存在的意义。

7. 优缺点与适用环境

主要优点

  • 创建新对象不必知道具体类型,客户端与具体类解耦;

  • 逃避构造函数的约束------不需要为每种原型写一堆工厂类,减少类的数量;

  • 可以动态地增删原型(运行期注册原型即可,见下);

  • 对"创建代价高"的对象,复制比重新构造便宜得多。

主要缺点

  • 每个类都必须实现 clone(),而且深克隆要处理每一层引用关系(引用环、共享成员),实现复杂;

  • 深克隆的成本可能接近甚至超过重新构造,需要权衡;

  • 必须小心处理浅克隆带来的资源重复释放问题;

  • 与所有创建型模式一样,克隆细节对客户端透明,出问题时排查链更长。

适用环境

  • 对象之间大同小异,差异只是若干属性的取值;

  • 对象的创建代价大(数据准备、网络、复杂计算);

  • 需要动态加载/注册原型,运行期才知道要造什么(配合原型管理器)。

7.1 典型用法:原型管理器

仓库里的 ClassFactory(工厂篇讲过的自注册反射机制)就是同一思路的另一个形态。如果要做"原型管理器",通常就是一张表:

cpp 复制代码
class PrototypeManager {
    std::map<std::string, Prototyppe*> protos;   // 注册表:名字 → 原型
public:
    void add(const std::string& key, Prototyppe* p) { protos[key] = p; }
    Prototyppe* create(const std::string& key) { return protos[key]->clone(); }  // 克隆出副本
};

注意 :注册表里存的是"原型样板",取出来的一定是 clone() 的副本,而不是原型本身。否则所有客户端会共用同一个对象,就退化成全局变量了。

8. 面试专题

8.1 开场题:说说原型模式吧

考察点是概念、角色、应用场景,按这个顺序答:

概念 :原型模式用原型实例指定创建对象的种类,并通过拷贝这些原型来创建新对象。它属于创建型模式。

角色 :抽象原型 Prototype 声明 clone();具体原型 ConcretePrototype 实现 clone() 返回自己的副本;客户端通过调用 clone() 得到新对象,不关心具体类型。

使用场景:对象创建代价高(要查库、走网络、复杂计算)时;对象之间只有少量属性不同时;运行时才知道要创建哪种子类、手里只有基类指针时。

核心难点:浅克隆与深克隆的取舍。

可扩展:把浅/深克隆的区别、和拷贝构造/工厂模式的区别接上(见下)。

8.2 高频追问清单

追问 答法
浅克隆和深克隆的区别?(必问) 浅克隆只复制值成员和指针本身,指针指向的资源仍是共享的;深克隆连指针指向的对象一起复制,两者完全独立。C++ 默认拷贝构造就是浅克隆
深克隆怎么实现?(必问) 一是自己重写拷贝构造 + 重载赋值运算符,集中到 copy() 里处理每一层引用;二是序列化/反序列化,通用但要引入框架且性能差
原型模式和拷贝构造函数有什么区别? 拷贝构造是编译期静态绑定 的,用基类对象接派生类对象会发生对象切片 ;clone() 是虚函数,能多态地复制------拿基类指针就能克隆出正确的派生类对象
原型模式和工厂模式有什么区别? 工厂关注"造哪个"(用类名/参数决定),原型关注"复制哪一个已有的"(用原型实例决定)。工厂要新增产品时通常要加工厂类,原型只需要多加一个原型对象(甚至运行期动态注册)
为什么 clone() 返回基类指针? 为了让客户端通过基类接口调用,实现多态复制。C++ 支持协变返回类型 ,派生类可以把返回类型收窄为具体类型,从而免掉 dynamic_cast
浅克隆时怎么防止 double free? 引用计数、智能指针(推荐)、或像仓库里那样用一个 release 标记让副本不参与资源释放
原型对象的析构谁负责? 原型本身由创建它的人负责;克隆出的副本由客户端负责。所有权关系必须写清楚,否则就是上一问的 double free
深克隆会遇到什么麻烦? 引用环(A 引用 B、B 又引用 A)会导致无限递归;多个成员共享同一个对象时,深拷贝要保证"共享关系"也被正确复制,而不是各拷一份
原型模式有什么缺点? 每个类都要实现 clone();深克隆实现复杂、成本可能不比重新构造低;容易踩浅克隆的资源重复释放
什么场景不适合用原型? 对象结构简单、构造开销不大时,直接 new 更直观;对象里有不该被复制的资源(连接、句柄、锁)时,深拷贝语义难以定义

8.3 什么时候不该用原型

判断标准就一条:这个对象复制起来,真的比重新造便宜吗?

  • 成员只有几个值类型、构造函数就是几个赋值 → 直接 new 或拷贝构造,clone() 是纯成本;

  • 对象持有外部资源(数据库连接、文件句柄、互斥锁)→ 深拷贝语义说不清楚,浅拷贝又会共享,别硬套;

  • 只想复用一段初始化逻辑 → 那是一个工厂或构造辅助函数的事,不是原型模式。

原型模式的价值在于把"复制一个复杂对象"这件事封装成一次虚函数调用。对象不复杂的时候,它换不来任何东西。


结语

我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。

无限进步,我们下次再见!

相关推荐
重生之小比特1 小时前
【C++进阶】哈希表实现
开发语言·c++·哈希
m0_380743871 小时前
VisualStudio中OpenCV的创建与配置使用小结
开发语言·c++
FYKJ_20101 小时前
springboot鲜花销售系统91056-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·mysql·django·课程设计
苏supper2 小时前
记一次Nacos鉴权报错unknown user,403排查|SecurityProxy源码,fastjson2扩展包缺失
java·后端
凉茶钱2 小时前
【C++】动态内存管理完整解析:从内存划分到 new delete 底层原理
c语言·开发语言·c++·内存管理·动态内存
小七在进步2 小时前
类和对象(五)
java·开发语言·算法
Rebecca_aLi2 小时前
码匠教育:零基础学 Python,标识符到循环全套基础语法解析
开发语言·python·正则表达式
IvanCodes2 小时前
Python 正则表达式(十四):文本匹配、查找与替换
开发语言·python·正则表达式
夜雪一千2 小时前
Python URL编码踩坑:单层编码、双重编码实战解析
开发语言·python