你是否曾经在应用程序或计划好的任务中使用过“撤销”功能?这两种功能其实都基于同一个原理:将操作封装成对象

这就是命令模式。你不需要直接调用方法,而是将该方法及其参数一起包装成一个对象。这个对象可以被存储起来、传递给其他部分、在以后执行,或者被撤销。

在这个教程中,你将会了解什么是命令模式,并通过一个支持撤销功能的实用文本编辑器示例来学习如何在Python中实现它。

你可以在GitHub上找到这个教程的代码。

先决条件

在开始之前,请确保你满足以下要求:

  • 已安装Python 3.10或更高版本

  • 对Python类和方法有基本的了解

  • 熟悉面向对象编程的概念

那我们开始吧!

目录

什么是命令模式?

命令模式是一种行为设计模式,它将一个请求封装成一个对象。这样你就可以:

  • 为调用者提供不同的操作选项

  • 将某些操作排队或安排在以后执行

  • 通过记录已执行的命令来支持撤销/重做功能

这个模式包含四个主要的参与者:

  • 命令对象:提供一个具有execute()方法(以及可选的undo()方法)的接口

  • 具体命令类:为实现特定操作而实现execute()undo()方法

  • 接收者对象:实际执行操作的对象(例如文档文件)

  • 调用者对象:负责触发命令并管理操作历史记录

举个例子来说,想象一下在餐厅里的情况。顾客告诉服务员他们想要什么食物,服务员会将这个请求记录下来,并交给厨房去准备。服务员并不负责烹饪,他们的职责只是传递这些指令。如果顾客改变了主意,服务员可以在指令传达到厨房之前取消它。

设置接收者对象

我们将构建一个简单的文档编辑器。在这里,接收者就是Document类。这个类知道如何插入和删除文本,但它根本不知道是谁在调用它,以及为什么要调用它。

class Document:
    def __init__(self):
        self.content = ""

    def insert(self, text: str, position: int) -> None:
        self.content = (
            self.content[:position] + text + self.content[position:]
        )

    def delete(self, position: int, length: int) -> None:
        self.content = (
            self.content[:position] + self.content[position + length:]
        )

    def show(self) -> None:
        print(f'Document: "{self.content}"')

insert方法会将文本插入到指定的位置。delete方法则会从指定位置删除length个字符。这两种方法都非常简单,它们既不记录操作历史,也不了解用户执行了哪些命令。而这正是我们的设计意图。

定义命令

现在,让我们使用抽象类来定义一个基础的Command接口:

from abc import ABC, abstractmethod

class Command(ABC):
    @abstractmethod
    def execute(self) -> None:
        pass

    @abstractmethod
    def undo(self) -> None:
        pass

任何具体的命令都必须同时实现executeundo方法。正是这样的设计使得操作历史的记录成为可能。

InsertCommand

InsertCommand类在创建时会保存要插入的文本以及位置信息:

class InsertCommand(Command):
    def __init__(self, document: Document, text: str, position: int):
        self.document = document
        self.text = text
        self.position = position

    def execute(self) -> None:
        self.document.insert(self.text, self.position)

    def undo(self) -> None:
        self.document.delete(self.position, len(self.text))

当调用execute()方法时,就会将文本插入到指定位置;而调用undo()方法时,就会删除刚刚插入的那些文本。需要注意的是,undo方法所做的正是execute方法的相反操作——这就是这个设计的关键所在。

DeleteCommand

现在,让我们来编写DeleteCommand类的代码:

class DeleteCommand(Command):
    def __init__(self, document: Document, position: int, length: int):
        self.document = document
        self.position = position
        self.length = length
        self._deleted_text = ""  # 在执行操作时保存被删除的文本,用于撤销操作

    def execute(self) -> None:
        self._deleted_text = self.document.content[
            self.position : self.position + self.length
        ]
        self.document.delete(self.position, self.length)

    def undo(self) -> None:
        self.document.insert(self._deleted_text, self.position)

有一个重要的特点:它会在execute()方法被执行时捕获被删除的文本,而不是在创建命令的时候。这是因为在命令真正运行之前,我们并不知道该位置上原本是什么文本。如果没有这样的机制,undo()就无法知道应该恢复什么内容。

调用者:命令的执行与撤销

调用者是负责执行命令并管理操作历史记录的对象。它本身并不了解文档的具体结构或文本编辑的原理,它只是负责管理各种命令对象而已。

class EditorInvoker:
    def __init__(self):
        self._history: list[Command] = []

    def run(self, command: Command) -> None:
        command.execute()
        self._history.append(command)

    def undo(self) -> None:
        if not self._history:
            print("没有可以撤销的操作。")
            return
        command = self._history.pop()
        command.undo()
        print("撤销操作成功。")

run()方法会执行命令并将其添加到操作历史记录栈中,而undo()方法则会从栈顶取出最后一个命令并调用其undo()方法来撤销该操作。由于操作是按照加入栈的顺序执行的,因此这种方法能够确保撤销操作的正确性。

整体流程梳理

让我们把整个流程整理一下,并通过一个实际的编辑示例来说明它的运作方式:

doc = Document()
editor = EditorInvoker()

# 输入标题
editor.run(InsertCommand(doc, "季度报告", 0))
doc.show()

# 添加副标题
editor.run(InsertCommand(doc, " - 财务", 16))
doc.show()

# 哎呀,副标题搞错了——撤销它
editor.undo()
doc.show()

# 删除“Quarterly”并替换为“Annual”
editor.run(DeleteCommand(doc, 0, 9))
doc.show()

editor.run(InsertCommand(doc, "Annual", 0))
doc.show()

# 撤销之前的插入操作
editor.undo()
doc.show()

# 再次撤销删除操作,恢复“Quarterly”
editor.undo()
doc.show()

最终的输出结果如下:

Document: "季度报告"
Document: "季度报告 - 财务"
撤销操作成功。
Document: "季度报告"
Document: "报告"
Document: "年度报告"
撤销操作成功。
Document: "报告"
撤销操作成功。
Document: "季度报告"

下面是这一流程的详细步骤说明:

  • 每个InsertCommandDeleteCommand都会包含执行该操作以及撤销操作的相应指令。

  • EditorInvoker并不会直接查看命令内部的实现细节,它只会调用execute()undo()方法而已。

  • 文档对象(Document)本身也不负责管理操作历史记录,它只会在被要求时修改自己的内容。

在这个系统中,每个组件都承担着明确且单一的任务。

使用宏进行扩展

命令模式的一个不太为人所知的优势在于:命令本身其实只是普通的对象,因此我们可以将它们组合起来使用。下面是一个MacroCommand示例,它可以将多个命令合并在一起,并一次性执行它们的撤销操作:

class MacroCommand(Command):
    def __init__(self, commands: list[Command]):
        selfcommands = commands

    def execute(self) -> None:
        for cmd in selfcommands:
            cmd.execute()

    def undo(self) -> None:
        for cmd in reversed(selfcommands):
            cmd.undo()

# 一次性应用标题格式:清除内容,插入格式化的标题
macro = MacroCommand([
    DeleteCommand(doc, 0, len(doc.content)),
    InsertCommand(doc, "== 年度报告 ==", 0),
])

editor.run(macro)
doc.show()

editor.undo()
doc.show()

执行后会产生以下结果:

文档: „== 年度报告 ==
撤销操作成功。
文档: „季度报告“

该宏会以相反的顺序执行其包含的命令。这种处理方式是正确的,因为最后执行的操作应该最先被撤销。

何时使用命令模式

在以下情况下,命令模式非常适用:

  • 需要实现撤销/重做功能:命令模式正是为这类需求而设计的。可以通过将已执行的命令存储在栈中,然后按相反顺序执行它们来实现这一功能。

  • 需要对操作进行排队或安排执行时间:命令本身就是对象,因此可以将它们放入队列中、进行序列化处理,或者延迟其执行。

  • 希望将调用者与具体操作解耦:调用者无需了解命令的具体功能,只需简单地执行它即可。

  • 需要支持宏或批量操作

    : 如上例所示,可以将多个命令组合在一起进行批量处理。

但在以下情况下应避免使用命令模式:

  • 操作非常简单,根本不需要撤销或排队功能。对于简单的CRUD操作来说,命令模式会增加额外的类结构和间接调用机制,这些可能并不值得。

  • 如果命令需要共享大量状态,那么“将请求封装起来”的设计原则就会失效。

总结

希望这篇教程对您有所帮助。总之,命令模式将具体的操作转化成了对象,而这一理念为许多实际问题提供了解决方案:撤销/重做、操作排队、宏功能的使用,以及明确区分谁触发操作以及操作具体执行什么内容。

我们从头开始构建了这个文档编辑器,其中使用了InsertCommandDeleteCommand、带有历史记录栈的EditorInvoker,以及用于批量编辑的MacroCommand。每个类都只负责一项具体的功能,并且都能将其完成得很好。

作为下一步,您可以尝试为这个编辑器添加RedoCommand。为了实现重做功能,您需要再建立一个栈结构,用于存储需要重新执行的命令。

祝编码愉快!

Comments are closed.