備忘錄模式:幫你撤銷錯(cuò)誤而不泄露內(nèi)部實(shí)現(xiàn)
在我们的设计模式系列中,今天我们来看看另一个经典模式:建造者模式如何清理构造复杂对象的混乱,你可以在这里阅读更多关于建造者模式解决你用构造函数制造的混乱的内容。今天,我们将探讨另一个经典模式:备忘录模式。这个模式主要是保存对象状态的快照,这样你就可以在未来回滚更改——而不暴露对象的内部细节给外部世界。
记忆模式是什么?备忘录(Memento) 设计模式是一种行为模式,让对象的状态可以在稍后的某个时间点保存和恢复,而不违反封装性。简单来说,这种模式允许你将对象的内部状态(如成员变量等)保存到一个独立的备忘录对象里,当需要时,可以将对象恢复到之前的那个状态。这在实现撤销和重做功能时非常方便,因为你能够轻松地恢复到之前的状态。
备忘模式
另一个名称: Memento模式也称为Snapshot模式。这个名字暗示了正在发生的事情——你为对象的状态拍一个快照,以便以后参考。
Memento的核心目的是保存并恢复一个对象的状态而不暴露该对象的实现细节。对象的数据保持封装;只有对象本身知道如何保存或恢复其状态。外部代码(通常称为“保管人”)不应查看或修改保存的状态——它只是保存状态。Memento的一个使用场景是将对象恢复到之前的某个状态(实现撤销功能)。它也可以用于状态版本管理或实现自定义对象的序列化保存以实现持久化。
实际上,任何时候你需要一个检查点机制——例如,如果出现问题可以回退工作流程或事务——Memento 是一个很好的选择。它常用于像撤销/重做这样的功能中,每个命令或更改都可以通过返回到保存的状态来撤销。
备忘模式是如何工作的让我们来分解一下备忘模式的运作原理。在这一模式中有三个主要角色:
- 发起体: 具有内部状态的对象。它可以创建一个包含其当前状态快照的备忘录,并可以利用备忘录来恢复其状态。发起体知道保存哪些信息并知道如何恢复这些信息。
- 备忘录: 一个独立的对象,用于存储发起体的内部状态。备忘录的内容对于其他对象来说是不透明的——只有发起体可以查看或使用备忘录。可以将其视为一个密封的状态快照或“保险箱”。
- 监护者: 负责保存和恢复过程的对象。监护者决定何时要求发起体创建一个备忘录(保存状态)以及何时恢复先前的状态。它保存一个或多个备忘录但不会修改它们的内容。监护者可以是一个历史管理器、一个撤销管理器,或者仅仅是知道何时保存检查点和回滚的客户端代码。重要的是,监护者应将备忘录视为不透明的令牌——不应试图解读其中的具体内容。
基本工作流: 通常的交互流程如下 — 守护者向发起者请求其在执行某些操作前的状态保存点(备忘录)。发起者创建一个状态保存点并传递给守护者。如果需要“撤销”或回滚,守护者将状态保存点返还给发起者,发起者使用它来恢复其之前的状态。在此过程中,发起者状态的内部细节保持封装;守护者只知道一个可以用来在需要时恢复发起者到之前状态的令牌。整个过程中,发起者通过状态保存点实现了状态的灵活管理和恢复,而守护者则通过这些保存点实现了对发起者状态的灵活控制。备忘录对象是不透明的,守护者不能更改它。
例如,想象一个文档编辑器(一个常见的场景),在做出重大更改之前,编辑器应用会将当前文档状态保存为一个快照。如果用户点击“撤销”按钮,应用将使用那个快照将文档恢复到之前的状态。编辑器不会将文档的内部结构暴露给撤销管理器;它只是提供一个快照,并在需要时可以重新应用这个快照。
封装保持: 使用 Memento 的一个重要原因是它保持了封装边界。原发者是唯一需要了解其状态细节的一方。Memento 可以设计成外部代码无法查看或修改其封装的状态。例如,在许多实现中,Memento 类被嵌套在原发者内部或具有受限访问权限,因此其他方只能将其视为黑盒对象。这确保了外部组件(管理者或其他)不会意外(或恶意)篡改原发者的内部状态——它们只是被动地持有 memento 作为标记。
多个快照(历史): 此模式不限制你只能保存一个检查点。监护者可以维护一个历史栈中的备忘录列表,以实现多级撤销或状态变化的时间线。例如,应用程序可以在用户执行操作时将备忘录压入栈中,从栈中弹出以逐步撤销操作。这样,备忘录模式可以支持完整的撤销和重做栈或状态历史导航(但要注意内存使用,我们稍后再讨论)。
关于范围的说明: 需要注意的是,经典的备忘录模式(Memento 模式)一次只保存一个对象(原始者)的状态。如果你的操作影响到多个需要保持一致性的对象,单个备忘录可能无法涵盖整个情况。该模式通常的实现方式是“仅操作单一对象”。如果原始者的状态包含对其他对象或外部资源的引用,在恢复原始者的状态时,这些对象或资源的变化可能不会因为恢复原始者的状态而被自动回滚。在这种情况下,你可能需要多个备忘录或更广泛的事务机制。
比如:游戏中的存盘点为了记住这个概念,让我们用一个更有趣且独特的现实世界类比,而不是通常使用的文字编辑器例子。想象你在玩一个冒险 游戏 ,这个游戏允许你在检查点保存进度。当你到达一个安全点时,你创建一个“存档”——这实际上是你在那一刻的所有游戏状态(角色的物品栏、生命值、位置、等级等)的快照。假设你去挑战一个强大的Boss,结果情况变得非常糟糕。不用担心——你可以从检查点 读取存档 。游戏会将这些保存的变量(如生命值、物品栏和位置等)恢复到存档时的状态,有效地撤销检查点之后的所有变化(字面意思)。
在下面的比方中:
- 游戏本身(或游戏引擎)是源头——它拥有所有的内部状态(玩家数据、世界状态)。
- 保存的游戏文件是存档——游戏某一时刻的状态快照。请勿编辑这个文件;它是一个只有游戏才能理解的黑盒子。
- 作为看护者,你决定何时保存(创建一个存档)以及何时加载(从存档恢复)。你不需要知道存档的具体细节——你只要相信游戏可以从存档中顺利恢复就行。
这正是许多游戏实现保存/加载和类似撤消机制的方式——通过将游戏状态保存为快照。这一概念与软件设计中的备忘模式相同。就像玩家依赖检查点来重试困难的关卡或部分而不必重新开始一样,程序可以使用备忘来在出现问题时将对象恢复到之前的安全状态。
Kotlin 示例:Memento 在管理工作流状态中的应用让我们用 Kotlin 编写一个具体的例子来展示备忘录模式的实际应用。假设我们有一个简单的多步骤(状态)工作流程,一个实体需要依次经历这些步骤——例如,入职流程或文档审批流程。我们希望能够在某些检查点保存流程的状态,并且如果需要的话可以回退到之前的某一步(比如某个审批被拒绝或者发生了错误时,我们会回退到流程中的较早一步)。
在这个例子里面:
Workflow
将作为我们的 原发者。它有一个内部状态表示工作流当前的步骤(我们还可以想象它有其他数据)。它将提供方法来推进工作并创建/恢复快照。WorkflowHistory
将作为 保管人,保存状态栈(历史记录)并提供撤销功能。Workflow.Snapshot
(内部类)是 备忘录,保存状态(比如当前步骤的索引)。
我们将模拟一个包含三个阶段的工作流程:开始,处理,和完成。我们将依次推进到各个阶段,保存状态,并回退到之前的状态。下面是一个完整的Kotlin代码示例:
// 发起者
class Workflow(val name: String) {
private val steps = listOf("Start", "Processing", "Finish")
private var currentStep: Int = 0
fun proceed() {
if (currentStep < steps.lastIndex) {
currentStep++
}
// 如果已经到达最后一步,则不会执行任何操作(无法继续前进)。
}
fun createSnapshot(): Snapshot {
// 备忘录:捕获重要状态
return Snapshot(currentStep)
}
fun restoreSnapshot(snapshot: Snapshot) {
// 从备忘录恢复内部状态
currentStep = snapshot.step
}
override fun toString(): String {
return "工作流 '$name' 当前处于阶段 '${steps[currentStep]}'"
}
// 备忘录类(内部类,封装状态)
data class Snapshot(val step: Int)
}
// 保管者
class WorkflowHistory {
private val history = mutableListOf<Workflow.Snapshot>()
fun backup(workflow: Workflow) {
history.add(workflow.createSnapshot())
}
fun undo(workflow: Workflow) {
if (history.isNotEmpty()) {
val snapshot = history.removeAt(history.lastIndex)
workflow.restoreSnapshot(snapshot)
}
}
}
// --- 使用示例 ---
fun main() {
val workflow = Workflow("Onboarding")
val history = WorkflowHistory()
println("初始状态: $workflow") // 初始状态: 工作流 'Onboarding' 当前处于阶段 'Start'
history.backup(workflow) // 保存初始状态
workflow.proceed()
println("第一步之后: $workflow") // 第一步之后: 工作流 'Onboarding' 当前处于阶段 'Processing'
history.backup(workflow) // 保存第一步后的状态
workflow.proceed()
println("第二步之后: $workflow") // 第二步之后: 工作流 'Onboarding' 当前处于阶段 'Finish'
history.undo(workflow)
println("撤销第一步之后: $workflow") // 撤销第一步之后: 工作流 'Onboarding' 当前处于阶段 'Processing'
history.undo(workflow)
println("撤销第二步之后: $workflow") // 撤销第二步之后: 工作流 'Onboarding' 当前处于阶段 'Start'
}
下面的代码是这样的:
Workflow.proceed()
方法模拟将工作流推进到下一阶段。我们通过一个私有索引 (currentStep
) 来追踪当前阶段。我们还重写了toString()
方法以便于打印工作流的状态。Workflow
中的createSnapshot()
方法创建一个新的Snapshot
(备忘录),其中包含当前步骤索引。Snapshot
定义在Workflow
内部,这意味着它与Workflow
紧密耦合,不打算在其他地方通用。在一个更封装的设计中,Snapshot
可能会是一个私有内部类或实现一个接口,以便外部代码无法访问其内容。restoreSnapshot()
方法接收一个Snapshot
,并将currentStep
设置回保存的值,从而将工作流回滚到该阶段。WorkflowHistory
是一个简单的保管人,使用列表来存储快照。其backup()
方法会请求来源者 (Workflow
) 提供当前状态的快照并存储起来。undo()
方法会弹出历史中的最后一个保存快照,并请求工作流进行恢复。需要注意的是,WorkflowHistory
不知道它在保存什么,它只是持有Workflow.Snapshot
对象,将它们视为不透明的快照。- 在
main()
中,我们模拟工作流通过两个步骤(从 开始 到 处理 再到 完成),并在初始状态和完成第一个步骤后进行备份。接着我们执行两次撤销:第一次撤销将工作流从 完成 回到 处理,第二次撤销将它从 处理 回到 开始,以恢复初始状态。打印的输出(如注释所示)确认每次状态都被正确恢复。
这个示例是有意简化的(我们只保存了步骤索引)。在实际应用场景中,Snapshot
可能会包含更多的数据——例如表单输入、状态标志、时间戳等。概念保持不变:捕捉所需的状态以便完整恢复工作流。发起者(工作流)决定在快照中包含什么以及如何使用它来恢复。保管员(工作流历史)仅在适当的时候触发保存和恢复,并在其间保存快照。
一个重要的观察点是,我们没有将Workflow
的任何内部字段暴露给外部。Workflow
类封装了状态,并提供了用于保存和恢复的控制方法。外部代码不能直接访问或修改currentStep
,而是必须通过createSnapshot
和restoreSnapshot
方法来实现。这确保即使内部表示发生变化,也不会影响到外部代码——这就是封装性的特点。
让我们跳过推销——到现在为止,Memento 模式的好处应该已经很清楚了:它允许你在不破坏封装性的情况下保存和恢复对象的状态,特别适合用于撤销/重做系统,并且它能够将业务逻辑与控制流程清晰地分离。但这并不是故事的全部。和任何模式一样,使用它会带来了一些需要理解的权衡:
- 内存开销: 每个保存的状态都是一个额外的对象,会占用内存。如果发起者状态很大,或者你频繁创建快照,内存使用量会迅速增加。例如,一个图像编辑器在每次编辑后保存整个高分辨率图像的快照会占用大量内存。这种开销意味着你需要谨慎地控制保存的快照数量及其大小。实践中,人们通常会限制撤销级别数量,或者使用差分快照(仅存储更改)等策略来缓解这个问题。
- 性能损失: 创建快照可能涉及复制整个对象的状态信息。如果频繁这样做,尤其是对于大型状态或在性能敏感的应用程序中,会严重影响性能。恢复状态也可能有成本(例如重新初始化复杂结构)。本质上,你是在用CPU/内存的开销来换取容易回滚的便利性。如果状态变化的频率很高,一个简单的快照方法(每次小改动都创建快照)可能效率低下。监护人可能需要实现节流或防抖策略(例如,不要过于频繁地捕获新快照,或如果状态变化不大)。
- 不自动管理外部资源: 快照通常只涵盖一个对象的状态。如果发起者的状态与其他对象或系统资源相互依赖,恢复一个对象的状态可能不会使其他对象恢复。经典的备忘录模式“仅操作单个对象”,并不能解决多对象的一致性问题。例如,如果你的工作流对象的状态依赖于两个不同的子系统,你可能需要一个更广泛的机制来捕获两者。如果不仔细设计,你可能会恢复一部分状态而另一部分保持不变,导致不一致性。这是已知的局限性——解决它可能需要多个快照或一个自定义的复合备忘录来聚合多个对象的状态(这会增加复杂性)。
- 非常大状态的复杂性: 如果一个对象的状态非常复杂(有许多字段、集合、深层对象图),编写用于快照和恢复的代码可能会很困难。你需要确保每个相关的状态都得到保存并正确恢复。如果有任何遗漏,对象可能无法完全恢复。这使得为大型类实现备忘录的实施变得复杂且繁琐。还有一个问题是可变和不可变状态——如果你的状态包括可变对象或集合的引用,你必须决定是否为备忘录进行深层复制(以便真正捕获那一刻的状态),这可能代价高昂,或者冒险让它们在你不知情的情况下发生变化(这不可取)。所有这些都增加了设计复杂性。
- 过度使用会适得其反: 在简单的解决方案存在的情况下(例如,仅仅为一个或两个变量保留一个单一的备份值),实现备忘录可能会是过度设计。例如,为实现一个文本字段的基本“撤销”功能,你可能只需要保留最后的字符串值,而不是整个备忘录系统。重要的是评估问题的复杂性——有时一个基于备忘录的完整历史栈可能超过了你的需求。
总之,Memento 在需要撤销或回滚操作和保持封装性的好处的情况下表现出色,但要注意其对内存和复杂性的问题。接下来,我们将讨论如何判断 Memento 是否适合您的情况。
当什么时候不该用备忘录模式?备忘录模式在你需要支持撤销、回退或版本化而不破坏对象封装的情况下特别适合。如果你的软件需要将对象恢复到之前的某个状态——例如,在一个编辑器、游戏中或审批流程或工作流中——备忘录模式提供了一个干净、封装良好的机制来实现这一目标。当你希望在不暴露或耦合对象内部实现的情况下保存状态时,该模式特别有用。它也适用于任何需要检查点、快照或场景切换的上下文。只要状态不是特别复杂或状态变化不是特别频繁,内存和性能上的权衡是可以接受的。
但是,也有一些注意事项。如果你面对的是大规模且快速变化的状态,频繁保存完整快照的成本可能过高,这种情况可能不可接受。在这种情况下,只存储差异、采用事件溯源或基于命令的撤销等替代方案通常更高效。此外,当需要协调多个相互依赖对象的变更时,备忘录模式表现不佳——它的适用范围通常仅限于单个发起对象。最后,如果你只需要保存少数几个值或需要一步回滚,一个简单的机制可能就足够了。备忘录模式在结构化的撤销系统中表现优异,而不是简单的历史记录存储。
数优点、陷阱以及边缘情况该模式的关键优势在于它对封装性的尊重:它允许一个对象在不暴露其内部的情况下保存和恢复状态。这使得它非常适合实现干净的撤销和重做功能,以及状态历史管理。这提供了很大的灵活性——对象拥有其状态保存逻辑,而看护者可以在无需了解快照内部信息的情况下触发恢复。
但是这种使用的简便掩盖了一些风险。如果处理不当,快照复杂或可变的状态可能会引入难以察觉的bug。如果你存储了过多的完整快照,或者忘记定期清理它们,内存使用量可能会急剧增加。与更细粒度的策略不同,这种方法要求你存储完整的状态,而不仅仅是更改——这既是好处也是负担。
最后,恢复一个状态不一定能消除副作用,或其他与对象的交互,这可能引起不一致性。你需要评估快照是否足够,或者是否需要更广泛的协调措施。不过,如果设计合理,备忘录则提供了一种稳健而优雅的方法,在不破坏对象边界的情况下,让你的代码实现时光旅行。
最后部分备忘录模式提供了一种在不损害对象完整性或暴露内部结构的情况下恢复过去状态的方法。通过捕获并外部化对象的状态,你可以在对象的生命历程中实现向前或向后的时间旅行的神奇效果。我们看到了它如何实现撤销/重做功能,如何在错误时回滚,以及如何帮助实现状态历史或版本控制等功能——所有这些都不违反封装或对象完整性。就像任何魔法一样,它也有代价:一切都有代价,你必须谨慎管理这些快照(过多的备忘录或非常大的备忘录可能会导致问题)。然而,当适当使用时,备忘录模式提供了一种优雅的方式来处理时间变化的状态。
你有没有在项目中设计过撤销功能或回滚功能?你是否用了备忘录模式,还是更喜欢用命令或其他方法,比如使用差异比较?分享你的经历和看法吧——听听别人怎么处理这些问题总是很有趣的。
如果你觉得这篇文章很有帮助,点个赞(甚至多个),把它分享给你的小伙伴们,别忘了订阅,获取更多关于设计模式的深度解析。祝你编程愉快,希望你的状态快照总能正确恢复!
共同學(xué)習(xí),寫下你的評(píng)論
評(píng)論加載中...
作者其他優(yōu)質(zhì)文章