二、模板方法模式
很多事情是由几个固定的步调来完成的,比方到饭馆吃饭,需要履历点餐→用餐→结账 这样的步调,由于这几个步调是固定的,所以被作为一种样板,这就是“模板方法(Template Method) 模式”名字的由来。但是在这几个固定步调中,有很多细微的部分可以有差异,例 如在点餐这个环节,有人点的是粤菜,有人点的是鲁菜;在结账这个环节,有人用人民币结 账,有人用名誉卡结账……在固定步调确定的情况下,通过多态机制在多个子类中对每个步 骤的细节进行差异化实现,这就是模板方法模式可以或许达到的效果。
模板方法模式是一种举动型模式,其实现简单且被经常利用,实现代码具有代表性,甚 至很多步调员在不知不觉中就会利用到该模式而不自知。从最简单的模式讲起,总是让人 更容易接受。
2.1 一个详细实现范例的逐步重构
这里讲解一个实际工作中的范例。 A 公司有一个小的游戏项目组,要开发一个单机的 闯关打斗类游戏(类似街机打拳类游戏)。 一般来讲, 一个游戏项目组中最少需要由3名担 任不同角色的员工构成,分别是游戏策划、游戏步调、游戏美术。
- (1)游戏策划(简称策划)负责提出游戏的各种玩法需求、确定游戏中的各种数值,比方 角色人物(包罗敌人)的生命值、魔法值等。
- (2)游戏步调(简称步调)需要与游戏策划紧密配合通过代码来实现游戏策划要求的各 种功能。
- (3)游戏美术需要负担一切看得见的游戏内容的筹划工作,比方角色筹划、道具筹划、 游戏特效筹划等,由于游戏美术与本书所讲解的筹划模式没有直接关系,故不详细介绍。
游戏策划给出的游戏项目需求是这样的:游戏主角是一个战士(攻击力不够强,但生命 值比较多),主角通过不断往前走来闯关,碰到敌人就进行攻击,敌人会进行反击,也会在距 离比较近时主动攻击主角。主角有生命值、魔法值、攻击力3个属性,主角生命值消耗为0 则主角殒命(游戏竣事),攻击力决定打敌人一下敌人会失去多少点生命,魔法值临时用不 上,先写在代码中留存,待以后扩展。主角的起始生命值为1000,起始魔法值为0,起始攻击 力为200。
于是,步调根据策划提出的需求开始誊写第一个版本的源代码,先把主角人物这个类定 义出来,代码如下:
- class Warrior
- {
- public:
- explicit Warrior(int life, int magic, int attack)
- :m_life(life), m_magic(magic), m_attack(attack)
- {}
-
- public:
- void JN_burn();
-
- private:
- int m_life;
- int m_magic;
- int m_attack;
复制代码 这里 Warrior 类中可能有很多成员函数来实现“战士”这个角色的各种功能,这些不重 要,所以这里不深究。
某一天,策划盼望给主角人物增加一个名字叫作“燃烧”的技能,目的是当主角被一群敌 人包围的时间利用该技能可能会挽救主角的性命,该技能是这样描述的:利用“燃烧”技能 可以使附近的所有敌人每人失去500点生命值,但主角自身也会丧失掉300点生命值。显 然这是一个杀敌一千自损八百的技能,但关键时候主角如果被群殴,利用该技能可能会瞬间 杀死一堆敌人从而使自己脱离险境。步调在接到策划的该需求后,继续为 Warrior 类增加 新的成员函数,代码如下:
- void Warrior::JN_burn()
- {
- std::cout << "让所有敌人都失去500点生命值,同时自己收到伤害300点" << std::endl;
- m_life -= 300;
- std::cout << "播放技能特效!" << std::endl;
- }
复制代码 可以看到,在代码中创建了一个主角对象,然后主角释放了“燃烧”技能,结果正确。
过了几天时间,策划对步调说:游戏中只有“战士”这样一个主角,可玩性不强,需要再 增加一个“法师(攻击力很强,但生命值相对比较少)”作为主角,玩家可以自由选择以“战士”
大概“法师”的身份参加战斗,“法师”主角的起始生命值为800,起始魔法值为200,起始攻击 力为300。法师也有一个名字叫作“燃烧”的技能,引入该技能的初志与战士相同,该技能是 这样描述的:利用“燃烧”技能可以使附近的所有敌人每人失去650点生命值,但主角自身 会丧失掉100点魔法值。显然这个技能是通过魔法值来杀敌,那么魔法值对于法师来讲就 显得特殊珍贵了。
步调拿到这个需求的时间,就开始思考代码该如何誊写了。如果重新实现一个 Mage (法师)类,那么内部的代码会与 Warrior 类大同小异(造成代码的大量重复),更何况,大概 日后策划再增加一个其他类型的主角,则又要写一个新的类来应付,这实在是会让代码变得 特殊貌寝。于是,步调员利用自己丰富的编码履历,重新实现了一个 Fighter (战斗者)类作 为父类,而创建F Warrior 和 F Mage 作为子类,父类Fighter 中的内容尽量不做变更大概 少做变更,而变更重要会合在F Warrior 和 F Mage 子类中进行,如果将来策划需要增加新 类型的主角,只需要增加新的子类即可。
于是,步调根据自己的想法,开始编写第二个版本的源代码(代码重构),起首实现父类:
- class Fighter {
- public:
- explicit Fighter(int life, int magic, int attack)
- :m_life(life), m_magic(magic), m_attack(attack)
- {}
- virtual ~Fighter();
- void JN_Burn();
-
- protected:
- int m_life;
- int m_magic;
- int m_attack;
- };
复制代码 这里Fighter 类的实现代码与Warrior 类的实现代码类似,但现在的关键标题是“燃烧” 这个技能的代码如何实现,通过
与策划进行沟通,策划确认了两件事情:
- (1)游戏中近期至少还会增加一个“牧师”作为主角;
- ( 2 ) 每 个 主 角都 有一个“燃烧”这样的技能,燃烧技能在释放时产生的效果各不相同,但 毫无疑问有两点是肯定稳定的: 一是对主角自身会产生影响;二是对敌人会产生影响。
有了策划这样的承诺,步调就知道“燃烧”这个技能该怎样编写代码了。
- (1)对敌人产生影响的函数,取名为effect enemy, 由于不同的主角释放“燃烧”技能会 对敌人产生的影响不同,所以 effect enemy 应该是一个虚函数,在子类中重新实现该虚 函 数 。
- (2)对主角自身产生影响的函数取名为effect_self, 由于不同的主角释放“燃烧”技会对主角自身产生的影响不同,所以effect_self也应该是一个虚函数,在子类中重新实现该虚 函 数 。
- (3)播放技能“燃烧”的技能特效,由于策划确定所有主角在释放“燃烧”技能时,所播放 的技能特效是一样的,所以,可以写一个专门的播放函数(而不是把这些代码直接放在JN Burn函数中,否则代码显得太散乱了),取名为play effect, 该函数并不需要是一个虚函数, 由于无须在子类中重新实现。
于是,Fighter 类的JN Burn 成员函数代码应该如下:
- effect_enemy();
- effect_self();
- play_effect();
复制代码 同时,也需要在Fighter 类中增加effect enemy 和 effect self 这两个虚函数以及 play_effect 非虚函数:
- virtual void effect_enemy() = 0;
- virtual void effect_self() = 0;
- void play_effect();
复制代码- void Fighter::play_effect()
- {
- std::cout << "播放技能给玩家看!" << std::endl;
- }
复制代码 接着,实现战士这个主角类F_Warrior, 代码如下:
- class F_Warrior : public Fighter
- {
- public:
- explicit F_Warrior(int life, int magic, int attack)
- :Fighter(life,magic,attack)
- {}
- private:
- virtual void effect_enemy() override;
- virtual void effect_self()override;
- virtual bool canUseJN()override;
- };
复制代码 然后,实现法师这个主角类F_Mage, 代码如下:
- //派生类:F_Mage
- class F_Mage : public Fighter
- {
- public:
- explicit F_Mage(int life, int magic, int attack)
- :Fighter(life, magic, attack)
- {}
- private:
- virtual void effect_enemy() override;
- virtual void effect_self() override;
- virtual bool canUseJN() override;
- };
复制代码 在main函数中调用:
- Fighter* prole_war = new F_Warrior(1000, 0, 200);
- prole_war->JN_Burn();
- std::cout << "------------------" << std::endl;
- Fighter* prole_mag = new F_Mage(800, 200, 300);
- prole_mag->JN_Burn();
- delete prole_war;
- delete prole_mag;
复制代码 执行起来,看一看结果:
从结果可以看到,战士作为主角发挥“燃烧”技能时的表现与法师作为主角发挥“燃烧” 技能时的表现是不一样的,这种不一样的表现重要是通过 F_Warrior 和 F_Mage 子类中的 effect_enemy 和 effect_self 虚函数来表现的。
上面的代码经过了重构,实际上是逐步引入了筹划模式,通过这个范例,正式引入模板 方法模式。
2.2 引入模板方法模式
起首要提醒读者在筹划模式运用过程中始终要把握的一条最重要原则:软件开发中需 求的变化黑白常频仍的,开发人员必须尝试探求变化点,将变化的部分和稳定的部分分离 开,并在变化点地点的位置处应用筹划模式,步调员必须不断提升自己的眼界和本领,逐步 掌握这种抽象(把代码的组织按一定层次结构划分)的本领,云云才能更好地运用筹划模式。 所以,在学习筹划模式过程中,往往强调的是:学习一个筹划模式并不难,难的是选择该设 计模式的场合和时机。
在前面的范例中,Fighter 类中的JN_Burn 成员函数的实现就利用了模板方法模式。 观察JN_Burn, 它具有非常稳定的结构,换句话说,该成员函数固定调用如下3个成员函数:
- effect_enemy();
- effect_self();
- play_effect();
复制代码 这种非常稳定的结构(也称为算法的骨架/框架:这里的算法说的就是JN Burn, 筹划 模式术语中往往会把某个成员函数说成是一个算法,而骨架是指JN Burn 中调用的是很固 定的3个成员函数)是在JN Burn 中可以或许运用模板方法模式的前提(否则就不得当用模板 方法模式实现JN Burn) 。 这种非常稳定的结构(只调用若干固定的成员函数)就可被看作 一个样板大概说一个模板,这就是“模板方法”模式名字的由来,由于成员函数往往可以被称 为方法,所 以JN Burn 成员函数在这里其实就被称为模板方法。
固然,在JN_Burn 中,针对effect_enemy 、effect_self 的调用,需要做出不同的改变,例 如战士利用“燃烧”技能对敌人和对自身的影响与法师利用“燃烧”技能对敌人和对自身的影 响是不同的。换句话说,骨架开发人员(JN_Burn 开发者)无法决定 effect_enemy、effect_ self 如何实现,要留给子类F_Warrior 、F_Mage 去实现。
在模板方法模式中,有一个值得分析的开发技巧。 main 主函数中的代码行采用了父类指针指向子类对象的编码方式,
- Fighter*prole_war=new F_Warrior(1000,0,200);
复制代码 这样代码行“prole_war->JN_Burn();” 通过JN_Burn (该函数并不是虚函数)来间接调用effect_enemy、effect_self虚函数时,由于虚函数的动态绑定机制,就可以达到正确执行子类 F_Warrior 、F_Mage 中 effect_enemy 、effect_self 虚函数的效果。
很多开发者将这种在子类中重新实现某些虚函数以产生不同步调执行结果的代码编写 方法称为晚绑定,也就是说,在步调运行的时间,才能根据new 后面的类型名知道究竟执行 的是F Warrior 类还是F Mage 类中的 effect enemy 、effect self 函数,相对应的另有一个 早绑定概念,如果在main 主函数中参加如下代码:
- F_Warrior role_war(1000,0,200);
- role_war.JN_Burn();
复制代码 上面这种代码编写方式就称为早绑定,由于在编译(非步调运行)阶段就已经知道,代码行“role war.JN_Burn();”
通过JN_Burn 来间接调用effect_enemy 、effect_self 时,调用的 肯定是F Warrior 类(肯定不会是F_Mage 类)中的effect_enemy 、effect_self 函数。
引入“模板方法”筹划模式的定义(实现意图):定义一个利用中的算法的骨架(稳定部 分),而将一些步调延迟到子类中去实现(父类中定义虚函数,让子类实现/重写这个虚函数) 从而达到在团体稳定的情况下产生一些变化的目的。
这里引用一句对筹划模式的经典总结:筹划模式的作用就是在变化和稳定中心探求隔 离点,去分离稳定和变化,从而管理变化,但如果整个筹划中到处都是变化或到处都稳定,那 么天然也就不需要利用任何筹划模式了。
模板方法模式是一种代码复用技术(子类复用了父类的JN_Burn 代码),同时这种模式 也被以为导致了一种反向的控制结构,这种结构被称为“好莱坞法则”,也就是“不要来调用 (骚扰——好莱坞大导演就是这样有脾气)我,我会去调用你(有事我天然会联系你——演 员,地位显然与导演不能比)”,固然单独提这个法则会让人特殊困惑,但只要结合前面的范 例就非常好理解,这里指的反向控制结构就是父类的JN_Burn 会去调用子类的 effec_ enemy 或 effect_self, 固然从常理来讲,父类成员函数调用子类成员函数是一件感觉比较奇 怪的事,但在这里却是很正常的,由于main 主函数中的new 代码行是利用父类指针指向了 一个子类对象,比方:
- Fighter* prole_war = new F_Warrior(1000, 0, 200);
复制代码 那么接下来的代码行中涉及的对虚函数 effect_enemy 或 effect_self 的调用显然调用的都 应该是F_Warrior 子类的effect_enemy 或 effect_self, 这也正是虚函数的晚绑定机制的本领:
- prole_war->JN_Burn(); //这会调用F_Warrior子类的effect_enemy或effect_self
复制代码 需要注意的是,在实际的工作岗位中,尤其是在一些大型的项目中,往往项目司理或主 步调会负责实现Fighter 父类(固然包含其中的JN_Burn 成员函数的实现代码),并给其他 同项目组的平常开发者一个开发分析文档,其他平常开发者负责实现F_Warrior 、F_Mage 子类以及子类中的 effect_enemy 、effect_self 等接口,甚至可能出现父类是第三方开发厂商 开发的,平常开发者看不到父类的源码(拿到手的只是一个编译好了的库),唯一能看到的就 是开发分析文档,此时平常开发者大概会由于无法看到父类的实现代码而产生只见树木不 见森林的开发困惑,这是一个非常普遍的标题——筹划模式的运用,在很大水平上增加了程 序员从团体上理解代码的难度。
固然,话说回来,步调员如果仅仅负责实现 F_Warrior 、F_Mage 子类中的effect_enemy 、effect_self 功能,那么从开发的角度来讲,编写代码变得更简 单了。如果读者是平常开发者中的一员,则建议不要试图以冲破砂锅问到底的态度去尝试 理解整个 Fighter 父类的实现方式,那可能会花费大量的时间,须要性值得商榷, 一定要优 先实现好 F_Warrior 、F_Mage 子类中的effect_enemy 、effect_self 接口,这样做至少可以或许顺 利完成工作任务。
2.3 模板方法模式的 UML图
UML 的全称是统一建模语言(Unified Modeling Language),这里不详细介绍 UML, 有兴趣的读者可以通过搜索引擎详细相识,读者可以将 UML 理解为一种工具,通过这种工具可以以图形的方式绘制出一个类的结构图以及类与类之间关系,把所编写的代码以图形 方式呈现出来对于代码的全局理解和掌握利益巨大。现在,就利用UML 工具,针对前面的 代码范例绘制一下模板方法模式的UML 图,如图2.1所示。
参考上图,简单介绍UML 图的绘制方法如下。当用UML 图表示类结构(以Fighter 类为例)和类与类之间关系时:
- (1)一个类用一个长方形表示,长方形从上到下被分为3个地域,分别是类名、成员变 量名、成员函数名。
- (2)用public修饰的成员变量名或成员函数名前面额外用一个“+”表示,用protected 修饰的成员变量名或成员函数名前面额外用一个“#”表示,用private 修饰的成员变量名或 成员函数名前面额外用一个“一”表示。
- (3)在既有平常成员函数又有虚成员函数的类中,绘制类结构时往往利用斜体文字表 示虚成员函数以示与平常成员函数的区别。
- (4)在父类(Fighter) 中,笔者刻意将稳定部分(JN_Burn) 字号放大,变化部分(effect enemy 和 effect_self) 字号缩小以突出表现哪些部分是稳定的,哪些部分是变化的,以此帮 助读者加深对模板方法模式的理解。
- (5)类与类之间以实线箭头表示父子关系,子类(F_Warrior、F_Mage) 与父类(Fighter) 之间有一条带箭头的实线,箭头方向指向父类。
2.4 步调代码的进一步完善及应用联想
不出几天,步调员就以极快的速率写好了代码,并迅速提交给测试人员进行测试。没想 到,仅仅测试了半小时,测试人员就发现了步调功能不完善的地方:
- (1)战士主角利用一次“燃烧”技能会使自身失去300点生命值,但是如果战士主角的 生命值已经不够300点了,那么就不应该答应战士主角利用“燃烧”技能。
- (2)法师也同样存在类似的标题,法师主角利用一次“燃烧”技能会使自身失去100点 魔法值,但是如果法师主角的魔法值已经不够100点了,那么就不应该答应法师主角利用“燃烧”技能。
步调员拿到测试人员的这个反馈,简直是无地自容,作为一个多年的老步调员,犯这种 低级错误简直是没脸见人,赶紧通宵加班补救标题吧。
鉴于步调员可以直接修改Fighter 父类中的代码,所以步调员决定直接修改Fighter 父 类中的JN_Burn 成员函数,前面说过JN_Burn 成员函数是稳定的,但稳定是相对的概念而 不是稳定到永久稳定,所以,对JN_Burn 成员函数的适当修改也完全在情理之中,修改后的 代码如下:
- void Fighter::JN_Burn()
- {
- if (!canUseJN()) {
- std::cout << "无法使用JN_Burn技能!" << std::endl;
- return;
- }
- effect_enemy();
- effect_self();
- play_effect();
- }
复制代码 上面的代码增加了一个canUseJN 成员函数,用来判断是否可以或许利用“燃烧”技能,如果 不满足利用条件,则步调执行流程直接从JN_Burn 中返回。现在标题的重点是如何实现 canUseJN 成员函数,考虑到F_Warrior 和 F_Mage 这两个子类都需要重新实现canUseJN 来判断主角自身到底能否释放“燃烧”技能,因此,在Fighter 父类中,有须要将 canUseJN 成 员函数声明为纯虚函数,代码如下:
- virtual bool canUseJN() override;
复制代码 接着,在F_Warrior 子类中增加 canUseJN 的实现代码:
- bool F_Warrior::canUseJN()
- {
- return m_life >= 300 ;
- }
复制代码 在F_Mage 子类中增加 canUseJN 的实现代码:
- bool F_Mage::canUseJN()
- {
- return m_magic >= 100;
- }
复制代码 这样,代码就修改完毕了。
做个测试,在main 主函数中,创建一个生命值只有50的战士主角,让其释放“燃烧”技能,显然无法乐成释放:
- Fighter* prole_war2 = new F_Warrior(50, 0, 200); //创建生命值只有50的战士主角
- prole_war2->JN_Burn(); //该战士无法成功释放"燃烧"技能,不输出任何结果
- delete prole_war2;
复制代码 这里的canUseJN 成员函数有另外一个称呼,叫作“钩子方法”,笔者以为这个名字并不 好,由于会增加理解的难度,其实这无非就是一个子类可以控制父类举动的方法,比方,子类 的 canUseJN 成员函数返回true, 主角就可以释放“燃烧”技能,否则,主角就无法释放“燃 烧”技能。这有那么一点子类钩住父类从而反向控制父类举动的意思,因此起名为钩子方法 。
固然前面的范例针对的是一个游戏项目的开发,但是只要轻微拓展一下思路,就会发现 在很多场合都得当利用模板方法模式,读者必须善于识别,还要大开脑洞、发挥想象。
尤其 对于一些步调框架,比方MFC(微软基础类库)框架,很容易想象其中一定会用到很多模板 方法模式——由框架来控制完成哪些事务,而框架内各种事务的实现细节可以由详细的程 序开发人员根据需求来确定和实现。比方,通过MFC 创建一个基于对话框的应用步调,程 序执行起来后,当创建对话框时就会主动调用对话框所属类的OnInitDialog 成员函数,这 个成员函数就是一个虚函数,就是为了给 MFC 框架的开发者提供变化,相当于 effect_enemy 、effect_self 这样的虚函数。
固然,对于平常的开发者来说,就不必考虑开发框架了,办理一些日常标题更加实际,再 试举一例 :
某车间可以或许装配很多种零件,如果这些零件的装配工序都非常固定,只有在涉及某道工 序细节时有一些小的变化,那么就可以针对零件创建一个父类,其中的零件装配工序(成员 函数)就非常得当采用模板方法模式来实现,而处置惩罚某道工序的细节可以直接放在子类(针 对某个详细零件的类)虚函数中进行。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |