Skip to content

Latest commit

 

History

History
412 lines (294 loc) · 20 KB

02.1-Command.md

File metadata and controls

412 lines (294 loc) · 20 KB

命令模式

目的

命令模式是我最喜爱的模式之一。在我写过的许多大型游戏或者其他程序中,都有用到它。正确的使用它,会让你的代码变得更加优雅。对于这个模式,Gang of Four 有着一个预见性的深奥说明:

将一个请求(request)封装成一个对象,从而让你使用不同的请求,请求队列或请求日志来参数化客户端,同时支持请求操作的撤销与恢复。

我想你也和我一样觉得这句话晦涩难明。首先,它分割了自己试图建立的物象。在软件世界之外,一词往往多义。“客户(client)”就是一个_人_的意思-一个你与它做生意的人。据我查证,人类(human beings)是不可“参数化”的。(译者注:作者在这里的意思是Gang of Four 的说明因为太具概括性,涵盖了软件开发之外的一些定义,使得句子很难理解。)

然后,句子的剩余部分就像你可能使用的模式的一串列表一样。不是特别明朗,除非你的用例恰巧在列表中。我对命令模式的精炼(pithy)概括如下:

命令就是一个对象化(实例化)的方法调用。(A command is a reified method call.)

当然,“精炼”(pithy)通常意味着“令人费解的简洁”,所以我可能改得不是很好。让我解释一下:你可能没听过“Reify”一词,意即“具象化”(make real)。另一个术语reifying的意思是 使一些事物成为“第一类”(first-class)。(译者注:你可能在其他书籍中见到说是“第一类值”的类似说法)

注解

“Reify”出自拉丁文“res”,意思为“thing”,加上英语后缀“-fy”,所以就成为了“thingify”,坦白说,这是个很有趣的单词。

这两个术语都意味着,将某个_概念_(concept)转化为一块_数据_(data),即一个对象,你可以认为是传入函数的变量等等。所以说命令模式是一个“对象化的方法调用”,我的意思就是封装在一个对象的一个方法调用。

你可能对“回调“(callback),”第一类函数“(first-class function),”函数指针“(function pointer),”闭包“(closure),。”局部函数“(partially applied function)更耳熟,至于耳熟哪个就取决于你所使用的语言,而它们都具共性。The Gang of Four 之后这样阐述:

命令就是回调的面向对象化。(Commands are an object-oriented replacement for callbacks.)

这个比他们对模式的概括要好多了。

注解

一些语言的_反射系统_(译者注: 如.NET)可以让你在运行时使用类型处理。你可以得到一个对象,它代表着某些其他对象的类,你也可以玩玩看类型可以处理哪些问题。换句话说,反射是一个具体化的类型系统。

但是这些都比较抽象和模糊。正如我所推崇的那样,我喜欢用一些具体点的东西来开头。为弥补这点,现在开始我将举例,它们都非常适合命令模式。

#动机

输入配置

每个游戏都有一处代码块用来读取用户原始输入-按钮点击,键盘事件,鼠标点击,或者其他等等。它记录每次的输入,并将之转换为游戏中一个有意义的动作(action):

注解

专业级贴士:请勿常按B键。

下面是个简单的实现:

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();
}

这个函数通常会通过[游戏循环](03.2-Game Loop.md)被每帧调用,我想你能理解这段代码在干些什么。如果我们将用户的输入硬关联到游戏的动作(game actions),上面的代码是有效的,但是许多游戏允许用户_配置_他们的按钮与动作的映射。

为了支持自定义配置,我们需要把那些对jump()fireGun()的直接调用转换为我们可以换出(swap out)的东西。”换出“(swapping out)听起来很像分配变量,所以我们需要个_对象_来代表一个游戏动作。这就用到了命令模式。

我们定义了一个基类用来代表一个可激活的游戏命令:

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...

在我们的输入处理中,我们为每个按钮存储一个指针指向他们。

class InputHandler
{
public:
  void handleInput();

  // Methods to bind commands...

private:
  Command* buttonX_;
  Command* buttonY_;
  Command* buttonA_;
  Command* buttonB_;
};

现在输入处理成了下面这样:

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();
}

注解

注意到我们这里没有检查命令是否为null没?这里假设了每个按钮有某个命令与之对应关联。

如果你想要支持不处理任何事情的按钮,而不用明确检查是否为null,我们可以定义一个命令类,这个命令类中的execute()方法不做任何事情。然后,我们将按钮处理器(button handler)指向一个空对象(null object)代替指向null。这个模式叫空对象(Null Object)

以前每个输入都会直接调用一个函数,现在则会有一个间接调用层。

简而言之,这就是命令模式。如果你已经看到了它的优点,不妨看完本章的剩余部分。

模式

关于角色的说明

我们刚才定义的命令类在上个例子中是有效的,但他们很受限。问题在于,他们假设存在jump()fireGun()等这样的能找到玩家的头像,使得玩家像木偶一样进行动作处理的顶级函数。

这种假设耦合限制了这些命令的的效用。__JumpCommand__类_唯一_能做的事情就是使得 player 进行跳跃。让我们放宽限制。我们传进去一个我们想要控制的对象而不是用命令对象自身来调用函数:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute(GameActor& actor) = 0;
};

这里,__GameActor__是我们用来表示游戏世界中的角色的”游戏对象“类。我们将它传入execute()中,以便子类化的命令可以针对我们选择的角色进行调用,就像这样:

class JumpCommand : public Command
{
public:
  virtual void execute(GameActor& actor)
  {
    actor.jump();
  }
};

现在,我们可以使用这个类来让游戏中的任何角色进行来回跳动。在输入处理和记录命令以及调用正确的对象之间,我们缺少了一部分。首先,我们改变下handleInput(),像下面这样返回一个命令(commands):

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* command = inputHandler.handleInput();
if (command)
{
  command->execute(actor);
}

假设actor是玩家角色的一个引用,这将会基于用户的输入来驱动角色,所以我们可以赋予角色与前例一致的行为。在命令和角色之间加入的间接层使得我们可以让玩家控制游戏中的任何角色,只需通过改变命令执行时传入的角色对象即可。

在实践中,这并不是一个常见的功能,但是有一种情况却经常见到。迄今为止,我们只考虑了玩家驱动角色(player-driven character),但是对于游戏世界中的其他角色呢?他们由游戏的AI来驱动。我们可以使用相同的命令模式来作为AI引擎和角色的接口;AI代码部分提供命令(Command)对象用来执行。(译者注:command->execute(AI对象);

AI选择命令,角色执行命令,它们之间的解耦给了我们很大的灵活性。我们可以为不同的角色使用不同的AI模块。或者我们可以为不同种类的行为混合AI。你想要一个更加具有侵略性的敌人?只需要插入一段更具侵略性的AI代码来为它生成命令。事实上,我们甚至可以将AI使用到玩家的角色身上,这对于像游戏需要自动运行的demo模式是很有用的。

通过将控制角色的命令作为第一类对象,我们便去掉了直接的函数调用这样的紧耦合。相反的,把它想象成一个队列或者一个命令流(queue or stream of commands):

注解

关于队列更多信息,见[事件队列](./05.2-Event Queue.md)

注解

为什么我感觉有必要通过图片来解释“流(stream)”呢?为什么它看起来就像一个管道(tube)一样?

一些代码(输入处理(the input handler)或者AI)生成命令并将它们放置于命令流中,一些代码(发送者(the dispatcher)或者角色自身(actor))执行命令并且调用它们。通过中间的队列,我们解耦了一端的生产者和另一端的消费者。

注解

如果我们把这些命令_序列化_,我们便可以通过互联网发送数据流。我们可以把玩家的输入,通过网络发送到另外一台机器上,然后进行回放。这是多人网络游戏很重要的一块。

撤销和重做(Undo and Redo)

最后这个例子(译者注:作者指的是撤销和重做)是命令模的成名应用了。如果一个命令对象可以 do 一些事情,那么应该可以很轻松的 undo(撤销) 它们。撤销这个行为经常在一些策略游戏中见到,在游戏中如果你不喜欢的话可以回滚一些步骤。在_创建_游戏时这是一个很常见的工具。如果你想让你的游戏设计师们讨厌你,最可靠的办法就是在关卡编辑器中不要提供撤销命令,让他们不能撤销不小心犯的错误。

注解

这里可能是我的经验之谈。

如果没有命令模式,实现撤销是很困难的。有了它,小菜一碟啊。我们假定一个情景,我们在制作一款单人回合制的游戏,我们想让我们的玩家能够撤销一些行动以便他们能够更多的专注于策略而不是猜测。

我们已经可以很方便的使用命令模式来抽象输入处理,所以每次对角色的移动要封装起来。例如,像下面这样来移动一个单位:

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_;
};

注意到这个和我们上一个命令不太相同。在上个例子中,我们想要_抽象_出命令,执行命令时可以针对不同的角色。在这个例子中,我们特别希望将命令_绑定_到移动的单位上。这个命令的实例不是一般性质的”移动某些物体“这样适用于很多情境下的的操作,在游戏的回合次序中,它是一个特定具体的移动。

这凸显了命令模式在实现时的一个变化。在某些情况下,像我们的第一对的例子,一个命令代表了一个可重用的对象,表示_ a thing that can be done_(一件可完成的事情)。我们前面的输入处理程序仅针对单一的命令对象,并要求在对应按钮被按下的时候其execute()方法被调用。

这里,这些命令更加具体。他们表示_a thing that can be done at a specific point in time_(一件可在特定时间点完成的事情)。这意味着每次玩家选择移动,输入处理程序代码都会创建一个命令实例。像下面这样:

Command* handleInput()
{
  // Get the selected unit...
  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;
}

一次性命令的特质很快能为我们所用。为了撤销命令,我们定义了一个操作,每个命令类都需要来实现它:

class Command
{
public:
  virtual ~Command() {}
  virtual void execute() = 0;
  virtual void undo() = 0;
};

注解

当然了,在没有垃圾回收的语言如C++中,这意味着执行命令的代码也要负责释放它们申请的内存。

undo()方法会反转由对应的execute()方法改变的游戏状态。下面我们针对上一个移动命令加入了撤销支持:

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_变量的功能。

注解

这看起来挺像备忘录模式的,但是我发现备忘录模式用在这里并不能有效的工作。因为命令试图去修改一个对象状态的一小部分,而为对象的其他数据创建快照是浪费内存。只手动存储被修改的部分相对来说就节省很多内存了。

持久化数据结构是另一个选择。通过它们,每次对一个对象进行修改都会返回一个新的对象,保留原对象不变。通过这样明智的实现,这些新对象与原对象共享数据,所以比拷贝整个对象的代价要小的多。

使用持久化数据结构,每个命令存储着命令执行前对象的一个引用,所以撤销意味着切换到原来老的对象。

为了让玩家能够撤销一次移动,我们保留了他们执行的上一个命令。当他们敲击Control-Z时,我们便会调用undo()方法。(如果他们已经撤销了,那么会变为”重做“,我们会再次执行那个命令。)

支持多次撤销并不难。这次我们不再保存最后一个命令,取而代之的是,我们保存了一个命令列表和”current“(当前)命令的一个引用。当玩家执行了一个命令,我们将这个命令添加到列表中,并将”current“指向它。

当玩家选择”撤销“时,我们撤销当前的命令并且将当前的指针移回去。当他们选择”重做“,我们将指针前移然后执行命令。如果他们在撤销之后选择了一个新的命令,列表中位于当前命令之后的所有命令被舍弃掉。

我第一次在一个关卡编辑器中实现了这一点,顿时自我感觉良好。我很惊讶它是如此的简单而且高效。我们需要指定规则来确保每个数据的更改都经由一个命令实现,但只要定了规则,剩下的就容易得多。

注解

重做在游戏中并不常见,但回放(re-play)却不是。一个很老实的实现方法就是记录每一帧的游戏状态以便能够回放,但是这样会使用大量的内存。

相反,许多游戏会记录每一帧每个实体所执行的一系列命令。为了回放游戏,引擎只需要运行正常游戏的模拟,执行预先录制的命令。

#设计决策

类风格化还是函数风格化?

此前,我说命令(commands)和第一类函数或者闭包相似,但是这里我举的每个例子都用了类定义。如果你熟悉函数式编程,你可能想知道如何用函数式风格实现命令模式。

我用这种方式写例子是因为 C++ 对于第一类函数的支持非常有限。函数指针无须过多阐述,仿函数(译者注:关于仿函数可以看百科的介绍)看起来比较怪异,还需要定义一个类,C++11 中的闭包使用起来比较棘手因为要手动管理内存。

这并不是说在其他语言中你不应该使用函数来实现命令模式。如果你使用的语言中有闭包的实现,无论怎样,使用它们!在某些方面(In some ways),命令模式对于没有闭包的语言来说是模拟闭包的一种方式。

注解

我说在某些方面(In some ways),是因为即使在有闭包的语言中为命令构建实际的类或结构仍然是有用的。如果你的命令有多个操作(如可撤销命令),映射到一个单一函数是比较尴尬的。

定义一个实际的附带字段的类也有助于读者很容易分辨该命令中包含哪些数据。闭包自动包装一些状态是比较简洁,但它们太过于自动化了以至于很难分辨出它们实际上持有的状态。

举个例子,如果我们在用 JavaScript 编写游戏,我们可以像下面这样创建一个单位移动命令:

function makeMoveUnitCommand(unit, x, y) {
  // This function here is the command object:
  return function() {
    unit.moveTo(x, y);
  }
}

我们也可以通过闭包来添加对撤销的支持:

function makeMoveUnitCommand(unit, x, y) {
  var xBefore, yBefore;
  return {
    execute: function() {
      xBefore = unit.x();
      yBefore = unit.y();
      unit.moveTo(x, y);
    },
    undo: function() {
      unit.moveTo(xBefore, yBefore);
    }
  };
}

如果你熟悉函数式风格,上面这么做你会感到很自然。如果不熟悉,我希望这个章节能够帮助你了解一些。对于我来说,命令模式的作用能够真正的显示函数式编程在解决许多问题时是多么的高效。

参考

  • 你可能最终会有很多不同的命令类。为了更容易地实现这些类,定义一个具体的基类,里面有着一些方便的高层次的方法,这样派生的命令可以将它们组合来定义自身的行为,这么做通常是有帮助的。它会将命令的主要方法execute()变成[子类沙盒](04.2-Subclass Sandbox.md)。
  • 在我们的例子中,我们明确地选择了那些角色会执行一个命令。在某些情况下,尤其是在对象模型是分层的情况下,它可能没这么直观。一个对象可以响应一个命令,或者它可以决定于关闭一些从属对象。如果你这样做,你需要了解下责任链(Chain of Responsibility)
  • 一些命令如第一个例子中的JumpCommand是一些纯行为的代码块,无需过多阐述。在类似情况下,拥有不止一个这样命令类的实例会浪费内存,因为所有的实例是等价的。享元模式就是解决这个问题的。

注解

你可以用单例模式实现它,但我奉劝你别这么做。

=============================== [上一节](02-Design Patterns Revisited.md)

目录

下一节