三、原型模式
3.2 原型模式
同工厂模式一样,原型(Prototype) 模式也是一种创建型模式。原型模式通过一个对象 (原型对象)克隆出多个一模一样的对象。实际上,该模式与其说是一种设计模式,不如说是 一种创建对象的方法(对象克隆),尤其是创建给定类的对象(实例)过程很复杂(例如,要设 置许多成员变量的值)时,使用这种设计模式就比较合适。
3.2.1 通过工厂方法模式演变到原型模式
回顾一下前面讲解工厂方法模式时的范例,由图3.2,可以看到:
- 怪物相关类 M_Undead 、M_Element 、M_Mechanic 分别继承自怪物父类Monster;
- 怪物工厂相关类 M_UndeadFactory 、M_ElementFactory 、M_MechanicFactory 分 别 继承自工厂父类 M_ParFactory;
- 怪物工厂类 M_UndeadFactory 、M_ElementFactory 、M_MechanicFactory 中的成员 函数createMonster 分别用于创建怪物类 M_Undead 、M_Element 、M_Mechanic 对 象 。
现在,把上述类的层次结构(源码)进行一下变换,请读者仔细观察:
- (1)把怪物父类 Monster 和工厂父类 M_ParFactory 合二为一 (或者说成是把 M_ParFactory 类中的能力搬到 Monster 中去并把 M_ParFactory 类删除掉),让怪物父类 Monster 本身具有克隆自己的能力。改造后的代码如下:
class Monster
{public:Monster(int life,int magic,int attack);virtual ~Monster();virtual Monster* createMonster() = 0;protected:int m_life;int m_magic;int m_attack;
};Monster::~Monster(){}
- (2)遵从传统习惯(但不是一定要这样做),将上述成员函数createMonster 重新命名为 clone,clone 的中文翻译为“克隆”,意味着调用该成员函数就会从当前类对象复制出一个完 全相同的对象(通过克隆自己来创建出新对象),这当然也是一种创建该类所属对象的方式,虽然读者可能以往没见过这种创建对象的方式,但相信在将来阅读大型项目的源码时会遇 到这种创建对象的方式。改造后的代码如下:
virtual Monster* clone() = 0;
- (3)把 M_UndeadFactory、M_ElementFactory、M_MechanicFactory 这3个怪物工厂 类中的createMonster 成员函数分别搬到M_Undead 、M_Element 、M_Mechanic 中并将该成 员函数重新命名为clone, 同时将M_UndeadFactory 、M_ElementFactory 、M_MechanicFactory类 删除掉。改造后的代码如下:
class M_Undead :public Monster {public:M_Undead(int life, int magic, int attack);virtual Monster* clone() override;
};class M_Element :public Monster {public:M_Element(int life, int magic, int attack);virtual Monster* clone() override;};class M_Mechanic :public Monster {public:M_Mechanic(int life, int magic, int attack);virtual Monster* clone() override;
};
- (4)当然,既然是克隆,那么上述M Undead 、M Element 、M Mechanic 中 的clone 成员 函数的实现体是需要修改的。例如,某个机械类怪物因为被主角砍了一刀失去了100点生 命值,导致该怪物对象的m life 成员变量(生命值)从原来的400变成300,那么调用 clone 方法克隆出来的新机械类怪物对象也应该是300点生命值,所以此时 M_Mechanic 类中 clone 成员函数中的代码行
return new M_Mechanic(400,0,110);
就不合适,因为这样会 创建(克隆)出一个400点生命值的新怪物,不符合 clone这个成员函数的本意(复制出一个 完全相同的对象)。
克隆对象自身实际上是需要调用类的拷贝构造函数的。阅读过笔者的《C++ 新经典: 对象模型》的读者都知道:
- ① 如果程序员在类中没有定义自己的拷贝构造函数,那么编译器会在必要的时候(但 不是一定)合成出一个拷贝构造函数;
- ② 在某些情况下,程序员必须书写自己的拷贝构造函数,例如在涉及深拷贝的情形之 下,如果读者对深拷贝和浅拷贝这两个概念理解模糊,建议一定要通过搜索引擎或者《C++新 经典:对象模型》这本书理解清楚,因为这决定着你能否写出正确的程序代码。克隆对象意 味着复制出一个全新的对象,所以在涉及深拷贝和浅拷贝概念时都是要实现深拷贝的(这样 后续如果需要对克隆出来的对象进行修改才不会影响原型对象)。
为方便查看测试结果,笔者为M Element 类编写了一个拷贝构造函数供读者参考,在 M_Element 中,加入如下代码:
M_Element::M_Element(M_Element const& tmpobj):Monster(tmpobj)
{std::cout << "调用了M_Element的拷贝构造函数" << std::endl;
}Monster* M_Element::clone() {return new M_Element(*this);
}
对 M_Undead 、M_Element 、M_Mechanic 中的clone 成员函数的实现体分别进行修改, 通过调用类的拷贝构造函数的方式真正实现类对象的克隆,修改后的代码如下
class Monster
{public:Monster(int life, int magic, int attack);virtual ~Monster();virtual Monster* clone() = 0;protected:int m_life;int m_magic;int m_attack;
};class M_Undead :public Monster {public:M_Undead(int life, int magic, int attack);M_Undead(const M_Undead& tmpobj);virtual Monster* clone() override;
};class M_Element :public Monster {public:M_Element(int life, int magic, int attack);M_Element(M_Element const& tmpobj);virtual Monster* clone() override;};class M_Mechanic :public Monster {public:M_Mechanic(int life, int magic, int attack);M_Mechanic(M_Mechanic const& tmpobj);virtual Monster* clone() override;};Monster::Monster(int life, int magic, int attack):m_life(life), m_magic(magic), m_attack(attack)
{}Monster::~Monster()
{
}M_Undead::M_Undead(int life, int magic, int attack):Monster(life, magic, attack)
{std::cout << "一只亡灵怪物来到了这个世界" << std::endl;
}M_Undead::M_Undead(const M_Undead& tmpobj):Monster(tmpobj)
{std::cout << "调用M_Undead的拷贝构造函数" << std::endl;
}Monster* M_Undead::clone() {return new M_Undead(*this);
}M_Element::M_Element(int life, int magic, int attack):Monster(life, magic, attack)
{std::cout << "一只元素怪物来到了这个世界" << std::endl;
}M_Element::M_Element(M_Element const& tmpobj):Monster(tmpobj)
{std::cout << "调用了M_Element的拷贝构造函数" << std::endl;
}Monster* M_Element::clone() {return new M_Element(*this);
}M_Mechanic::M_Mechanic(int life, int magic, int attack):Monster(life, magic, attack)
{std::cout << "一只机械怪物来到了这个世界" << std::endl;
}M_Mechanic::M_Mechanic(M_Mechanic const& tmpobj):Monster(tmpobj)
{std::cout << "调用了M_Mechanic的拷贝构造函数" << std::endl;
}Monster* M_Mechanic::clone() {return new M_Mechanic(*this);
}
-
(5)如果在上述(4)中确定要编写自己的拷贝构造函数,应确保无误。因为只有拷贝构 造函数正确,调用clone 成员函数时才能正确地克隆出新的对象。
-
(6)在实际项目中,可以先创建出原型对象,原型对象一般只是用于克隆目的而存在, 然后就可以调用这些对象所属类的clone 成员函数来随时克隆出新的对象,并通过这些新 对象实现项目的业务逻辑。在main 主函数中,增加如下测试代码:
Monster* pmyPropUndMonster = new M_Undead(300, 50, 80);Monster* pmyPropEleMonster = new M_Element(200, 80, 100);M_Mechanic myPropMecMonster(400, 0, 110);Monster* p_CloneObj1 = pmyPropUndMonster->clone();Monster* p_CloneObj2 = pmyPropEleMonster->clone();Monster* p_CloneObj3 = myPropMecMonster.clone();delete p_CloneObj1;delete p_CloneObj2;delete p_CloneObj3;delete pmyPropUndMonster;delete pmyPropEleMonster;
从代码中可以看到,分别在栈和堆上创建了一个原型对象以用于克隆的目的,当然,在 堆中创建的原型对象最终不要忘记释放对应的内存以防止内存泄漏。甚至可以根据项目的 需要,将多个原型对象保存在例如 map 容器中,甚至可以书写专门的管理类来管理这些原 型对象,当需要用这些原型对象创建(克隆)新对象时,可以从容器中取出来使用。在对原型 对象的使用方面,程序员完全可以发挥自己的想象力。
3.2.2 引入原型模式
在前面的范例中,原型对象通过clone 成员函数调用怪物子类的拷贝构造函数,可能有 些读者认为这有些多余——直接利用怪物子类的拷贝构造函数生成新对象不是更直接、更方便吗?例如在main 主函数利用代码行
Monster* p_CloneObj3 = new M_Mechanic(myPropMecMonst);
也可以克隆出一个新的对象。其实这样认为也没错,但读者要认识 到,设计模式是独立于计算机编程语言而存在的,这意味着虽然 C++ 语言中怪物子类的 clone 成员函数可以直接调用拷贝构造函数,但在其他计算机编程语言中可能并没有拷贝构 造函数这种概念,此时,本该在拷贝构造函数中的实现代码就必须放在clone 成员函数中实 现 了 。
引入“原型”(Prototype)模式的定义:用原型实例指定创建对象的种类,并且通过复制 这些原型创建新的对象。简单来说,就是通过克隆来创建新的对象实例。
前面范例中的main 主函数中,myPropMecMonster 对象就是原型实例,通过调用该对象的clone 成员函数就指定了所创建的对象种类——当然,创建的是M Mechanic 类型的 对象而不是M_Undead 或 M_Element 类型的对象。通过复制myPropMecMonster 这个原 型对象以及 pmyPropEleMonster 所指向的原型对象创建了两个新对象: 一个是机械类怪 物对象,一个是元素类怪物对象,指针 p_CloneObjl 和 p_CloneObj2 分别指向这两个新对象。
针对前面的代码范例绘制原型模式的 UML 图,如图3.7所示。
原型模式的 UML 图中,包含两种角色。
- (1)Prototype (抽象原型类):所有具体原型类的父类,在其中声明克隆方法。这里指 Monster 类。
- (2)ConcretePrototype (具体原型类):实现在抽象原型类中声明的克隆方法,在克隆方法 中返回自己的一个克隆对象。这里指M_Undead 类 、M_Element 类 和M_Mechanic 类。
和工厂方法模式相比,原型模式有什么明显的特点呢?什么情况下应该采用原型模式 来克隆对象呢?
设想一下,如果某个对象的内部数据比较复杂且多变,例如一个实际游戏中的怪物 对 象 :
- 在战斗中它的生命值可能因为被玩家攻击而不断减少;
- 如果这个怪物会魔法,那么它施法后自身的魔法值也会减少;
- 在生命值过低时怪物还可能自己使用一些药剂类物品或者治疗类魔法来替自己增 加生命值;
- 玩家也可能通过施法导致怪物产生一些负面效果,例如中毒会持续让怪物丢失生命 值、混乱会让怪物乱跑而无法攻击玩家、石化导致怪物完全原地不动若干秒等。
如果使用工厂方法模式创建这种怪物对象(战斗中的,自身生命值、魔法值、状态等数据 随时在变化的怪物对象),那么大概要执行的步骤是:
- 先通过调用工厂方法模式中的 createMonster 创建出该怪物对象(实际上就是创建
一个怪物对象); - 通过怪物所属类中暴露出的设置接口(成员函数)来设置该怪物当前的生命值、魔法 值、状态(例如,中毒、混乱、石化)等,这些程序代码可能会比较烦琐。
显然,在这种情形下,使用工厂模式创建当前这个怪物对象就不如使用克隆方式来克隆 当前怪物对象容易,如果采用克隆方式来克隆当前对象,仅仅需要调用clone 成员函数,那 么因为clone 调用的实际是类的拷贝构造函数,所以这个怪物对象当前的内部数据(生命 值、魔法值、状态等)都会被立即克隆到新产生的对象中而不需要程序员额外通过程序代码 设置这些数据,也就是说,在调用clone 成员函数的那个时刻,克隆出来的对象与原型对象 内部的数据是完全一样的。例如,当游戏中的一个 BOSS 级别的怪物被攻击失血到一定程 度时,它会产生自己的分身,这种情况下使用clone 成员函数来产生这个分身就很合适,当 然,一旦新的对象被克隆出来后依旧可以单独设置该克隆对象自身的数据而丝毫不会影响 原型对象。
所以,如果对象的内部数据比较复杂且多变并且在创建对象的时候希望保持对象的当 前的状态,那么用原型模式显然比用工厂方法模式更合适。
总结一下工厂方法模式和原型模式在创建对象时的异同点:
-
在前面范例中创建怪物对象时,这两种模式其实都不需要程序员知道所创建对象所 属的类名;
-
工厂方法模式是调用相应的创建接口,例如使用createMonster 接口来创建新的怪 物对象,该接口中采用代码行“new 类名(参数…);”来完成对象的最终创建工作,这 仍旧是属于根据类名来生成新对象;
-
原型模式是调用例如 clone(程序员可以修改成任意其他名字)接口来创建新的怪物 对象,按照惯例,这个接口一般不带任何参数,以免破坏克隆接口的统一性。该接口 中采用的是代码行“new 类名(* this);” 完成对类拷贝构造函数的调用来创建对象, 所以这种创建对象的方式是根据现有对象来生成新对象。
当然,有些读者把原型模式看成是一种特殊的工厂方法模式(工厂方法模式的变体),这 也是可以的——把原型对象所属的类本身(例如,M_Undead、M_Element、M_Mechanic) 看 成是创建克隆对象的工厂,而工厂方法指的自然就是克隆方法(clone)。
看一看原型模式的优缺点:
-
(1)如果创建的新对象内部数据比较复杂且多变,那么使用原型模式可以简化对象的 创建过程,提高新对象的创建效率。设想一下,如果对象内部数据是通过复杂的算法(例如 通过排序、计算哈希值等)计算得到,或者是通过从网络、数据库、文件中读取得到,那么用原 型模式从原型对象中直接复制生成新对象而不是每次从零开始创建全新的对象,对象的创 建效率显然会提高很多。
-
(2)通过观察图3.2(工厂方法模式UML 图)可以发现,工厂方法模式往往需要创建一 个与产品等级结构(层次)相同的工厂等级结构,这当然是一种额外的开销,而原型模式不存 在这种额外的等级结构——原型模式不需要额外的工厂类,只要通过调用类中的克隆方法 就可以生产新的对象。
-
(3)在产品类中,必须存在一个克隆方法以用于根据当前对象克隆出新的对象(加重开发者负担,这算是缺点)。当然,不一定采用“new 类名(* this);” 的形式来调用所属类的拷 贝构造函数实现对原型对象自身的克隆,也可以采用“new 类名(参数…);”先生成新的对 象,然后通过调用类的成员函数、直接设置成员变量等手段把原型对象内部的所有当前数据 赋给新对象,例如,可以把 M_Undead 的 clone 成员函数实现成下面的样子:
M_Undead::M_Undead(const M_Undead& tmpobj):Monster(tmpobj)
{Monster* pmonster = new M_Undead(300, 50, 80);pmonster->setLife(m_life);pmonster->setMagic(m_magic);pmonster->setAttack(m_attack);std::cout << "调用M_Undead的拷贝构造函数" << std::endl;
}
- (4)在某些情况下,产品类中存在一个克隆方法也会给开发提供一些明显的便利。设 想一个全局函数Gbl CreateMonster, 其形参为Monster* 类型的指针,如果期望创建一 个与该指针所指向的对象相同类型的对象,那么传统做法的代码可能如下:
void Gbl_CreateMonster(Monster *pMonster);void Gbl_CreateMonster(Monster* pMonster) {Monster* ptmpobj = nullptr;if (dynamic_cast<M_Undead*>(pMonster)) {ptmpobj = new M_Undead(300, 50, 80);}if (dynamic_cast<M_Element*>(pMonster)) {ptmpobj = new M_Element(200, 80, 100);}if (dynamic_cast<M_Mechanic*>(pMonster)) {ptmpobj = new M_Mechanic(300, 0, 110);}if (ptmpobj != nullptr) {std::cout << "实现相关业务" << std::endl;delete ptmpobj;}
}
但是,如果每一个Monster 子类(M_Undead 、M_Element 、M_Mechanic) 都提供一个克 隆方法,那么Gbl CreateMonster函数的实现就简单得多,此时根本不使用dynamic_cast 和通过类名进行类型判断就可以直接利用已有对象来创建新对象,新的实现代码如下:
void Gbl_CreateMonster(Monster *pMonster);void Gbl_CreateMonster(Monster* pMonster)
{Monster* ptmpobj = pMonster->clone();std::cout << "实现相关业务" << std::endl;delete ptmpobj;
}
从这个范例中不难看到,根本就不需要知道Gbl_CreateMonster 的形参 pMonster 所 指向的对象到底是什么类型就可以创建出新的该形参所属类型的对象,这也减少了Gbl_CreateMonster 函数中需要知道的产品类名的数目。