命令模式是我最喜欢的模式之一。大部分我写的大型程序,游戏或者其他的,最后都是在某些地方使用了命令模式。当我在正确的地方使用它时,它真的干净整洁地清理了一些粗糙的代码。对于这样一个极好的模式,“四人帮”有一个预见性的深奥的解释:
把一个请求封装成一个对象,从而可以令用户用不同的请求作为参数表示客户端,可以对请求排队、记录,还可以支持撤销操作。
我认为我们都同意这个句子太糟糕了。首先,他损坏了他建立的所有隐喻。在单词可以代表任何东西的奇怪的软件世界,一个“client”,代表一个人-跟你做生意的某个人。最后,我核查,一个人是不能被“参数化”的。
然后,剩余的句子只是一个你有可能会使用模式的情况的列表。说的并不明确,除非你碰巧使用模式的情况在这个列表中。我对命令模式的简练的宣传就是:
一个命令就是一个具体化的调用方法。
当然,“简练”经常意味着“费解的简洁”,所以这可能并不意味着有多少进步。让我来分析分析。“具体化”,防止你从没听过,我解释一下,就是“让东西变得真实”。另一个术语解释具体化,就是使东西变成“一级”的类型。
译者注: 详细见知乎
类型:规定了变量可以取的值得范围,以及该类型的值可以进行的操作。根据类型的值的可赋值状况,可以把类型分为三类:
一级(First Class)。该类型的值可以作为函数的参数和返回值,也可以赋给变量。
二级(Second Class)。该类型的值可以作为函数的参数,但不能从函数返回,也不能赋给变量。
- 如果函数是一级的,那么就称为一级函数。
三级(Third Class)。该类型的值作为函数参数也不行
两种说法都是采取一些概念并且把它变成一块数据-一个对象-你可以将其赋值给一个变量,作为参数传入函数,等等。所以说命令模式是个“具体化的函数”,其实就是包裹在对象中的函数。
这听上去像是“回调函数”,“一级函数”,“函数指针”,“闭包”,“偏应用函数”依据你使用的语言,实际上他们大致相同。“四人帮”后面又说:
命令是对回调函数的面向对象的替换。
这个是一个更好的解释相比之前的那个。
但是所有这些都太抽象和模糊了。我喜欢以具体的东西开讲,但是我搞砸了。为了弥补,从这里开始全都是最适合使用命令模式的例子。
Configuring Input
每个游戏的某个地方都肯定有一段代码用来读取原始的用户输入-按钮按下,键盘事件,鼠标点击,等等。它接受一个输入,并且把它转化为一个游戏中有意义的动作:
下面是一个简单死板的实现:
void InputHandler::handleInput() { if (isPressed(BUTTON_X)) jump(); else if (isPressed(BUTTON_Y)) fireGun(); else if (isPressed(BUTTON_A)) swapWeapon(); else if (isPressed(BUTTON_B)) lurchIneffectively(); }
这个函数通常会每一帧游戏循环(Game Loop)都会被调用,而且我很确定你能知道他是干什么的。如果我们想把用户输入到动作的映射写死,那么他的确能工作,但是,许多游戏允许用户配置输入到动作的映射。
为了支持这点,我们需要把那些直接调用的函数jump()和fireGun()变成可以交换的。“交换”听上去像赋值一个变量,所以我们需要一个对象用来代表一个游戏动作。走入:命令模式。
我们定义一个基类代表可以触发的游戏命令:
class Command { public: virtual ~Command() {} virtual void execute() = 0; };
然后,我们为每一个不同的动作创建子类:
class JumpCommand : public Command { public: virtual void execute() { jump(); } }; class FireCommand : public Command { public: virtual void execute() { fireGun(); } }; // You get the idea...
在我们的输入处理类中,我们对每一个按钮定义一个command指针:
class InputHandler { public: void handleInput(); // Methods to bind commands... private: Command* buttonX_; Command* buttonY_; Command* buttonA_; Command* buttonB_; };
现在输入处理就委托给这些command了:
void InputHandler::handleInput() { if (isPressed(BUTTON_X)) buttonX_->execute(); else if (isPressed(BUTTON_Y)) buttonY_->execute(); else if (isPressed(BUTTON_A)) buttonA_->execute(); else if (isPressed(BUTTON_B)) buttonB_->execute(); }
原来输入直接调用函数,现在中间有了一个间接层:
这就是在壳中的命令模式,如果你已经看到了其中的价值,那么把剩余的部分当做福利吧。
Directions For Actors
我们刚才定义的那些Command类是为了用在上面这个例子,这相当局限。问题是我们假设有Jump(),fireGun(),等全局函数,这些函数会暗中找到玩家控制的角色并且可以控制角色像木偶一样跳舞。
这个假设的耦合限制了那些命令的使用。JumpCommand可以控制的角色也只有玩家一个。让我们减少那个限制。
我们传给函数我们想要控制的角色,而不是让函数自己找角色:
class Command { public: virtual ~Command() {} virtual void execute(GameActor& actor) = 0; };
这里,GameActor就是我们的“game object”类用来代表游戏世界中的角色。我们把它传给execute()函数,这样那些子类就可以调用我们指定的角色的方法了,就像这样:
class JumpCommand : public Command { public: virtual void execute(GameActor& actor) { actor.jump(); } };
现在,我们可以使用这个类驱使任意角色跳舞了。
我们就只缺少在input handler与command之间的处理部分,这个部分接受command然后传递给它正确的GameActor进行调用。首先,我们改写handleInput(),那样它会返回Command:
Command* InputHandler::handleInput() { if (isPressed(BUTTON_X)) return buttonX_; if (isPressed(BUTTON_Y)) return buttonY_; if (isPressed(BUTTON_A)) return buttonA_; if (isPressed(BUTTON_B)) return buttonB_; // Nothing pressed, so do nothing. return NULL; }
它不会立即执行Command,因为它并不知道我们要传递哪个GameActor。这也是一个我们利用命令模式的第一个特性-我们可以延迟执行Command当用户输入时。
然后,我们需要一些代码来接受Command并且传给它代表玩家的GameActor调用。就像这样:
Command* command = inputHandler.handleInput(); if (command) { command->execute(actor); }
假设actor是玩家角色的引用,这就会根据玩家输入正确地驱使他,所以我们又回到了第一个例子的相同的行为。但是,我们在Command与GameActor之间添加了一个间接层,使我们有个一个整洁能力:我们可以让玩家控制任意角色通过传递不同的GameActor给Command。
在实际中,这不是特别普遍,但还是会经常突然出现这样相似的使用案例。到目前为止,我们只考虑了玩家控制的角色,但是游戏世界中其他的角色呢?那些是被游戏AI控制着的。我们同样可以使用命令模式作为AI引擎与GameActor之间的接口;AI代码简单地发出Command对象。
选择Command的AI引擎与驱使GameActor行为的代码之间的解耦合给我们提供了灵活性。我们可以为不同的GameActor使用不同的AI模块。或者为不同的行为动作混合匹配AI。想要一个更具侵略性的对手?那就插入一个更具侵略性的AI,来生成command。实际上,我们甚至可以把AI拴在玩家控制的角色身上,这对需要自动驾驶的游戏demo非常有用。
通过使驱动GameActor的command变成“一级”的对象,我们已经解除了直接调用方法的紧耦合。相反,我们可以把command实现为一个队列或流:
一些代码(输入处理类或者AI)产生命令并且把他们放到流中。其他代码(分发器或角色本身)接收命令并且调用命令。通过把队列放到中间,我们把生产者和消费者解耦合分到两端。
Undo and Redo
最后一个例子是使用命令模式最著名的。如果一个命令对象可以做事,那么再有一小步我们就可以做到撤消命令。撤消多用于策略类游戏,比如撤消不喜欢的移动。在创建游戏时设计这个功能是符合礼节的。让游戏设计者恨你的最有效的方式就是给他们一个没有撤消他们输入错误的关卡编辑器。
如果没有命令模式,想实现撤消是令人吃惊的困难。使用命令模式,他就是小菜一碟。让我们假设在做一个单人回合制游戏,而且我们想让用户可以撤消移动,这样他们就可以专注与制定策略而不是猜测。
我们已经很方便地使用命令模式抽象了输入处理,所以玩家的每一次移动都被封装到命令对象中。例如,移动一个单位,可能会像这样:
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), x_(x), y_(y) {} virtual void execute() { unit_->moveTo(x_, y_); } private: Unit* unit_; int x_, y_; };
注意,这里跟之前的command有一点不同。再上一个例子中,我们想要把命令从被命令驱动角色之中分离开来。但是在这种情况下,我们明确地把command与被驱动角色绑定到一起了。一个这样的command实例不再是一个你可以在各种场合使用的一般的“移动”操作;相反,它是游戏回合中一个特殊的具体的移动操作。
这凸显出一个如何实现命令模式的差异。在某些情况下,像前几个例子,一个命令是可以复用的对象,代表可以进行的操作。我们早期的输入处理程序是当右键点击时总是找到同一个command对象并且调用它的execute()函数。
这里command就更加特殊化。它代表在某一个特定时间点要被做的事。这意味着每次玩家进行操作,输入处理程序都回返回一个命令的实例。就像这样:
Command* handleInput() { Unit* unit = getSelectedUnit(); if (isPressed(BUTTON_UP)) { // Move the unit up one. int destY = unit->y() - 1; return new MoveUnitCommand(unit, unit->x(), destY); } if (isPressed(BUTTON_DOWN)) { // Move the unit down one. int destY = unit->y() + 1; return new MoveUnitCommand(unit, unit->x(), destY); } // Other moves... return NULL; }
这是第二个命令模式的特性,命令都是一次性的。为了让命令可以撤消,我们为command定义了另一个方法:
class Command { public: virtual ~Command() {} virtual void execute() = 0; virtual void undo() = 0; };
一个undo()操作会回退到execute()执行之前的游戏状态。这是我们之前的移动command加入撤消的样子:
class MoveUnitCommand : public Command { public: MoveUnitCommand(Unit* unit, int x, int y) : unit_(unit), xBefore_(0), yBefore_(0), x_(x), y_(y) {} virtual void execute() { // Remember the unit's position before the move // so we can restore it. xBefore_ = unit_->x(); yBefore_ = unit_->y(); unit_->moveTo(x_, y_); } virtual void undo() { unit_->moveTo(xBefore_, yBefore_); } private: Unit* unit_; int xBefore_, yBefore_; int x_, y_; };
注意我们又在类中添加了一些状态。当一个单位移动了,他就忘了它之前的位置。如果我们想要实现撤消功能,那我们就必须记录之前的位置,这就是xBefore_和yBefore_的作用。
为了让玩家撤消一个移动,我们保留上一个执行过的命令。当他们按下ctrl+z键时,我们调用上个命令的undo()方法。(如果他们已经撤消过,那么就便成了“redo”,我们就又执行了命令)
支持多级撤消并不是特别困难。但不是只记录上一个执行的命令,而是保存一个命令列表list,然后一个指向当前命令的引用“current”。当玩家执行了一个命令,我们把命令添加到这个list中,并且把“current”指向这个命令。
当玩家选择撤消时,我们调用“current”指向命令的undo(),把“current”指针后移。当玩家选择“redo”时,我们前移“current”指针,并执行其指向的命令。如果玩家在撤消了一些命令后选择了一个新的命令,那么“current”指向的命令之前的所有命令丢弃。
我第一次在关卡编辑器中实现这个功能时,我感觉自己是个天才。我感到吃惊,因为它太直白了也太好用了。它有一个约束来保证所有的数据修改一定经过命令,但是一旦你实现了它,其他的就非常简单了。
See Also
最后你可能定义了许多个不同的command类。为了实现起来比较容易,通常先定义一个基类,基类里定义一堆虚函数,这样继承类就可以分别实现不同的行为。这样就使command类的execute()函数变成子类沙盒模式(Subclass Sandbox)。
在我们的这些例子中,我们显示地选择我们想要驱动的GameActor。在一些情况中,尤其是你的对象模型是分层级的情况下,可能就不用这么做了。一个GameActor可能会自己响应命令,或者它可能会把命令推给下级GameActor。如果你那么做,你就使用了责任链模式(Chain Of Responsiblity)。
一些命令是无状态数据的只有虚函数的代码段像第一个例子中JumpCommand一样。像那种情况,产生多余一个的实例就是在浪费内存,因为所有的实例都是一样的。享元模式(flyweight)会提到这点。