Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

命令模式 (Command)

将一个请求封装为一个对象,从而使你可以用不同的请求对客户进行参数化、对请求排队或记录请求日志,以及支持可撤销的操作。

— GoF 定义

动机

命令模式是我最喜欢的模式之一。我写过的大部分程序——游戏也好,别的什么也罢——最终都会在某处用到它。用对地方的时候,它能把一团糟的代码理得干干净净。

对于这样一个了不起的模式,不出所料,“四人帮”(GoF)给了一个深奥难懂的定义:

将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。

我觉得没人会反对——这个句子写得真不怎么样。首先,它试图建立的比喻本身就是乱的。在软件这个词语可以指代任何东西的诡异世界之外,“客户”(client)指的是——和你做生意的人。据我所知,人是没法被“参数化“的。

然后,句子的后半部分只是列举了一堆可能会用到的场景。除非你的用例恰好在这个列表里,否则它对你毫无帮助。

我对命令模式的精辟总结是:

命令就是“具现化的方法调用“(a reified method call)。

“Reify“这个词来自拉丁语的“res”(意为“事物“),加上英语后缀“–fy“(“使成为”)。所以它基本上就是“事物化“(thingify)的意思——老实说,“thingify“这个词用起来有趣多了。

当然,“精辟“往往意味着“过于简练,难以理解”,所以这可能也没好到哪去。让我展开说说。如果你没听过“具现化“这个词,它的意思就是“使其成为真实存在的“。另一个说法是让它成为“一等公民“(first-class)。

某些语言中的反射系统允许你在运行时以命令式的方式操作程序中的类型。你可以获取一个代表某个类的对象,然后拿它来探索这个类型能做些什么。换句话说,反射就是一个具现化的类型系统(a reified type system)。

这两个术语的核心都是:把某个概念变成一块数据——一个对象——你可以把它存在变量里、传给函数等等。所以,说命令模式是“具现化的方法调用“,意思就是:把方法调用包装成一个对象

这听起来很像“回调“(callback)、“一等公民函数”(first-class function)、“函数指针”(function pointer)、“闭包”(closure)或“偏函数“(partially applied function)——取决于你从哪种语言的角度来看——它们确实属于同一类东西。四人帮后来也说过:

命令模式是回调的一种面向对象替代方案。

这个总结比他们选的那个定义好多了。

但所有这些都还是抽象而模糊的。我本来喜欢用具体的例子来开篇,结果搞砸了。作为补偿,接下来全是命令模式大显身手的实际例子。


配置输入

在每个游戏中,都有一段代码负责读取原始用户输入——按钮按下、键盘事件、鼠标点击等等。它把每个输入翻译成游戏中有意义的行为:

一个极简的实现长这样:

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

专业建议:别老按 B。

这个函数通常在游戏循环中每帧调用一次,我想你明白它在干什么。如果我们愿意把用户输入和游戏行为硬编码在一起,这段代码没问题。但很多游戏允许玩家自定义按键映射。

要支持这个功能,我们需要把对 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(); }
};

// 你懂的……

在输入处理器中,为每个按键存储一个指向命令的指针:

class InputHandler {
 public:
  void handleInput();
  // 绑定命令的方法……

 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() 方法什么都不做的命令类。然后把按键处理器指向这个对象,而不是设为 NULL。这个模式叫做空对象模式

以前每个输入直接调用函数,现在有了一层间接引用:

这就是命令模式的精髓。如果你已经看出它的好处了,那这章剩下的部分就当是 bonus 吧。


给演员的指令

我们刚才定义的类在前面的例子里可以正常工作,但限制很大。问题在于,它们假设存在一些顶层函数(如 jump()fireGun() 等),这些函数能隐式地找到玩家角色,然后像操纵木偶一样让他动起来。

这种假设的耦合限制很大。JumpCommand只能让玩家角色跳跃。我们来放松这个限制。不让函数自己去寻找被命令的对象,而是传入我们要指挥的对象:

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(),让它返回命令:

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

  // 什么都没按,什么都不做
  return NULL;
}

它不能立即执行命令,因为还不知道要传给哪个角色。这正是命令作为“具现化调用“的优势所在——我们可以延迟调用的执行。

然后,我们需要一段代码来接收命令,并将其应用到代表玩家的角色上:

Command* command = inputHandler.handleInput();
if (command) {
  command->execute(actor);
}

假设 actor 是玩家角色的引用,这段代码就能根据用户输入正确地驱动他。这样我们又回到了第一个例子的行为。但通过在命令和执行命令的角色之间加一层间接引用,我们获得了一个灵巧的能力:现在只需改变执行命令时传入的角色,就可以让玩家控制游戏中的任意角色了。

在实际开发中,这个特性不常用,但有一个类似的用例经常出现。到目前为止,我们只考虑了玩家控制的角色,那游戏中其他的角色呢?它们由游戏 AI 驱动。我们可以用同样的命令模式作为 AI 引擎和角色之间的接口——AI 代码只需要生成 Command 对象。

选择命令的 AI 和执行命令的角色代码之间的解耦,给了我们很大的灵活性。我们可以为不同的角色使用不同的 AI 模块,或者混合搭配不同的 AI 以获得不同的行为。想要更有攻击性的对手?只要插入一个更具攻击性的 AI 来生成命令就行了。事实上,我们甚至可以把 AI 装在玩家角色上——这在需要游戏自动运行的演示模式中非常有用。

通过把控制角色的命令变成一等公民对象,我们摆脱了直接方法调用的紧耦合。相反,你可以把它想象成一个命令队列或命令流:

关于队列能带来哪些更多好处,请看事件队列

我为什么觉得有必要给你画一张“流“的图?而且它为什么看起来像根管子?

一些代码(输入处理器或 AI)产生命令并将其放入流中;另一些代码(调度器或角色自身)消费并执行命令。通过在中间加入一个队列,我们解耦了生产者和消费者。

如果把这些命令序列化,我们就可以通过网络传输这个命令流。我们可以获取玩家的输入,通过网络发送到另一台机器,然后重放它。这是构建网络多人游戏的重要一环。


撤销与重做

最后一个例子是这个模式最广为人知的用途。如果一个命令对象可以一件事,那它只需再进一步就可以撤销这件事。撤销功能用在一些策略游戏中,允许玩家回滚不满意的操作。在人们用来创作游戏的工具中,它是必备功能。让游戏设计师恨你最直接的办法,就是给他们一个无法撤销手误操作的关卡编辑器。

——我可能是在说自己的经历。

没有命令模式,实现撤销出奇地难。有了它,简直小菜一碟。假设我们在做一个单人回合制游戏,希望允许玩家撤销操作,这样他们可以更专注于策略而不是靠猜。

碰巧,我们已经用命令模式抽象了输入处理,所以玩家的每个操作都已经封装在命令中了。例如,移动单位的代码可能是这样的:

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

注意这和之前的命令稍有不同。在上一个例子中,我们想把命令和它修改的角色解耦。而在这个例子中,我们恰恰是想把命令绑定到被移动的单位上。这个命令的实例不是可以在多种上下文中使用的通用“移动某物“操作;它是游戏回合序列中一个具体的移动动作。

这揭示了命令模式实现中的一种变化。在一些情况下(比如前两个例子),命令是一个可重用的对象,代表一个可执行的操作。我们之前的输入处理器持有一个命令对象,在对应按键按下时调用它的 execute() 方法。

而这里的命令更加具体。它们代表在特定时间点可以做的一件事情。这意味着输入处理代码每次在玩家选择移动时都要创建一个实例。像这样:

Command* handleInput() {
  Unit* unit = getSelectedUnit();

  if (isPressed(BUTTON_UP)) {
    // 将单位向上移动一格
    int destY = unit->y() - 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  if (isPressed(BUTTON_DOWN)) {
    // 将单位向下移动一格
    int destY = unit->y() + 1;
    return new MoveUnitCommand(unit, unit->x(), destY);
  }

  // 其他移动……

  return NULL;
}

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

命令是一次性的这个事实马上就要派上用场了。为了让命令可撤销,我们在每个命令类中再定义一个需要实现的方法:

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

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() {
    // 记住移动前的位置,以便之后恢复
    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() 方法。(如果他们已经撤销了,就变成了“重做“,我们再次执行命令。)

支持多级撤销也并不复杂。不是只记住最后一条命令,而是维护一个命令列表和一个指向“当前“命令的引用。当玩家执行一条命令时,将其追加到列表末尾,并将“当前“指针指向它:

当玩家选择“撤销“时,撤销当前命令,并将当前指针向后移。当选择“重做“时,将当前指针向前移,然后执行该命令。如果玩家在撤销之后选择了新的操作,就清除当前指针之后的所有命令。

第一次在关卡编辑器中实现这个功能时,我感觉自己简直就是个天才。我惊讶于它是如此简洁而有效。你只需要约束自己:确保每个数据修改都通过命令完成。一旦做到了,剩下的就很简单了。

游戏里不常见“重做“,但重放很常见。一种简单的重放实现是记录游戏每帧的状态然后回放,但那会消耗太多内存。

相反,很多游戏记录每个实体每帧执行的命令。为了重放游戏,引擎只需正常运行,执行之前存储的命令即可。


用类还是用函数?

之前我提到过,命令与一等公民函数或闭包类似,但这里展示的每个例子都是用类完成的。如果你更熟悉函数式编程,你可能会疑惑:函数去哪儿了?

我用类写这些例子,是因为 C++ 对一等公民函数的支持非常有限。函数指针没有状态,仿函数(functors)很奇怪而且仍然需要定义类,C++11 的 lambda 需要大量人工辅助才能用起来。

并不是说你在其他语言中不能用函数来实现命令模式。如果你使用的语言支持闭包,尽管用!在某种程度上,命令模式就是为那些没有闭包的语言模拟闭包。

(我说某种程度上,是因为即使是在支持闭包的语言中,为命令建立真正的类或结构体也是有价值的。如果你的命令有多个操作——比如可撤销的命令——把它们全部塞进同一个函数中并不优雅。)

定义一个带有字段的真实类,能帮助读者理解命令包含了哪些数据。闭包是自动打包状态的完美方案,但它们太过自动化了,以至于很难看清楚到底打包了哪些状态。

举个例子,如果我们用 JavaScript 来写游戏,可以用这种方式实现单位移动命令:

function makeMoveUnitCommand(unit, x, y) {
  // 这个函数就是命令对象:
  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() 引向了子类沙箱模式

  • 在上面的例子中,我们明确指定了哪个角色会处理命令。在某些情况下——特别是当对象模型有层次结构时——可以不这么简单粗暴。对象可以自己响应命令,也可以将命令转交给其从属对象。如果这样做,你就实现了一个职责链模式

  • 有些命令是无状态的纯行为——比如第一个例子中的 JumpCommand。这种情况下,有多个实例就是在浪费内存,因为所有实例都是等价的。可以用享元模式来解决。

    你也可以把它实现为单例,但真正的朋友不会让你用单例。


实践 Demo

命令模式 - 棋盘移动


笔记与思考