直接用list.append()存命令会出问题,因undo()/redo()需维护history与redo_stack双栈、新命令须清空redo_stack、命令须幂等实现execute()/unexecute()、操作可变对象时需深拷贝状态、复合命令须逆序unexecute(),且gui中需防信号污染与内存泄漏。

为什么直接用 list.append() 存命令会出问题
因为 undo() 和 redo() 需要精确控制执行顺序和状态快照,单纯把命令对象塞进列表,容易在连续撤销后调用 redo() 时丢失重做链——比如用户撤销两次后又执行新命令,按标准行为应清空重做栈,但很多实现忘了这步。
真正关键的不是“存命令”,而是维护两个栈:history(已执行命令)和 redo_stack(被撤销后暂存的命令),且每次新命令必须清空 redo_stack。
-
history只增不删,undo()是从尾部弹出并调用其unexecute() -
redo_stack在undo()时接收弹出的命令,在redo()时弹出并调用execute() - 新命令插入前,必须
redo_stack.clear(),否则重做历史会残留过期状态
命令类必须实现 execute() 和 unexecute() 且保持幂等
很多初学者只写 execute(),结果 unexecute() 直接抛异常或逻辑错位。更隐蔽的问题是:如果命令操作的是可变对象(如 list、dict),unexecute() 必须还原到执行前的**值状态**,而不是引用状态。
例如对一个 document.text 字符串赋值的命令,unexecute() 应该恢复旧字符串;但如果操作的是 document.lines.append(...),就得在 __init__() 里深拷贝原始 lines 列表,否则 unexecute() 用 .pop() 可能误删后续添加的项。
- 所有命令类必须继承统一基类(如
Command),强制定义execute()和unexecute() - 避免在命令中直接修改全局变量或单例状态;改用传入明确上下文对象(如
editor实例) - 如果命令涉及异步操作(如文件保存),
unexecute()不能简单“取消IO”,而应记录前置状态并 revert 到本地缓存
如何安全处理嵌套命令与复合操作
用户一次“粘贴”可能触发插入文本、更新光标、刷新视图三个子操作。若每个子操作都单独入栈,撤销时会分三步回退,体验割裂。此时要用 CompositeCommand 把多个命令聚合成一个原子单位。
但要注意:复合命令的 unexecute() 必须逆序执行子命令的 unexecute(),而它的 execute() 是正序执行子命令的 execute()。漏掉这个顺序,撤销就会乱套。
-
CompositeCommand自身不保存状态,只管理子命令列表 - 子命令加入时,需检查是否已处于某个
CompositeCommand中,防止重复嵌套 - 当复合命令执行失败(某子命令抛异常),应立即对已执行的子命令反向调用
unexecute()回滚,再抛出异常
PyQt/PySide 中集成时绕不开的信号陷阱
在 GUI 场景下,常有人把 undo() 绑定到 Ctrl+Z 的 triggered 信号,但没禁用按钮或菜单项的重复点击。结果用户狂点 Ctrl+Z,history 被弹空后继续调用 pop() 就报 IndexError。
更麻烦的是:某些操作(如文本替换)会触发 Qt 的 textChanged 信号,进而误发新命令,导致撤销栈污染。必须在命令执行前后手动阻断相关信号。
- 所有 UI 触发的命令入口,必须加
if not self.history: return防空栈 - 撤销/重做按钮的
setEnabled()状态要实时绑定len(history) > 0和len(redo_stack) > 0 - 在
execute()前调用blockSignals(True),执行完立刻blockSignals(False),避免副作用触发
history 栈会阻止 GC,导致界面关闭后命令还驻留内存。得在 unexecute() 或命令出栈后显式清理弱引用或置空属性。Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











