C#命令模式的实现






4.73/5 (8投票s)
.NET 委托和泛型允许优雅地实现命令模式。
目录
引言
我最近从在东非做志愿工作 [^] 返回,这次旅行本应持续一年,但由于一次相当糟糕的毒蜘蛛咬伤,不幸缩短到六个月。因此,我发现自己回到了英国,腿上有一个大伤口,一堆照片,还有很多空闲时间,于是我坐下来写了Simple Slide Show [^],这是一个没有华丽功能的幻灯片脚本和显示实用程序,作为“慈善软件”提供——欢迎捐赠给Project African Wilderness [^],我被蜘蛛咬伤时就在那里工作。其中一项要求是实现命令模式。我在网上搜索了一下,但只能找到一些非常简化的实现,于是我自己设计了这个。我不确定它是否完全符合 GOF 的设想,但它能完成工作,而且在我看来,它是一个使用委托和泛型来实现所需结果的优雅实现。它是使用 Visual Studio 2005 开发的,而且可能有一些方面使用 VS2008 或 VS2010 的功能可以得到改进。请随时发表您的评论。
它的作用
命令模式的主要功能是提供一个框架来修改数据;然后可以撤销修改,再重新执行。这里隐含了一个重要的假设,即任何操作都必须在这个框架内进行;也就是说,撤销操作必须能够假设自执行要撤销的操作以来数据没有改变,而重做操作必须能够假设自撤销要重做的操作以来数据没有改变(我将大量使用“执行”、“撤销”和“重做”的概念,所以希望您能跟上!)。一个例外是,当程序运行时最初设置数据时,这无法撤销,也无需在框架内进行(持久化命令堆栈虽然完全可行,但会带来各种问题,并不常用,并且超出了本项目范围)。
该模式的一个次要功能是提供数据是否已修改的指示。请注意,命令模式的实现对实际数据一无所知。这可以是用户应用程序的任何特定内容。另请注意,在某些情况下,开发人员必须就什么构成数据修改操作做出设计决策——例如,一些程序在命令框架内执行数据选择,允许用户撤销和重做选择。
它是如何工作的
命令模式的基本原理是维护一个“操作描述符”堆栈,其中“操作描述符”包含执行操作所需的所有信息。这包括指定一个在用户应用程序代码中定义的、将执行特定操作的方法。
网上许多示例都过于简化,做出了如下假设:添加操作用减法操作撤销,或者操作不需要参数。我避免了这些假设;然而,在参数方面,有一个我无法避免——虽然在 C# .NET 中可以定义参数类型可变的(泛型)方法,但我所知,无法定义参数数量可变的方法。正如后面将要清楚的,这意味着我们必须预先指定我们的操作方法将有多少个参数。我选择了两个,这似乎适用于大多数情况。当然,此处提供的实现可以轻松扩展以允许更多参数。关键是,用户应用程序代码中定义的所有执行命令操作的方法都必须接受两个参数。如果它们不需要两个参数,那么未使用的参数只需命名为“dummy”(或其他任何名称)并忽略即可。
我避免的另一个假设是,撤销操作仅仅是执行操作的反向操作,即撤销添加操作等同于执行减法操作。可能是这样,但这是应用程序程序员的决定。事实上,命令模式不应该知道操作方法实际做什么。
为了实现这一点,我在命令实现框架内定义了一个操作方法,它接受两个参数并返回另一个操作定义。返回的定义定义了将撤销原始操作的操作,或者,在撤销的情况下,将重做已被撤销的操作的方法。
我所做的另一个假设是,撤销和重做堆栈将拥有几乎无限的空间——它们的大小没有限制。如果应用程序可能生成非常大的撤销/重做操作堆栈,则可能需要修改 Command 类以限制堆栈的大小。
那么,让我们看看代码,它在 Command.cs 中。首先声明的是一个泛型委托,它将用于定义我们的操作方法。
public delegate OperationBase OperationMethod<T1, T2>(T1 operand1, T2 operand2);
请注意,这不仅仅是一个委托(对于 C++ 程序员来说,这是 C# 中函数指针的等价物),而是一个泛型委托(如上所述),因此其操作数可以是任何类型。它返回一个 OperationBase
类型的对象。
接下来,我们找到一个事件处理程序委托。
public delegate void CommandEventHandler(object sender, CommandEventArgs e);
以及一个派生自 EventArgs
的类,名为 CommandEventArgs
。
public class CommandEventArgs : EventArgs
{
private bool isModified = false;
/// <summary>
/// Constructor. Sets the initial value if the isModified flag
/// </summary>
/// <param name="isModified">True if </param>
public CommandEventArgs(bool isModified)
{
this.isModified = isModified;
}
/// <summary>
/// Returns true if the EventArgs object indicates
/// that modifications have taken place.
/// </summary>
public bool IsModified
{
get
{
return isModified;
}
}
}
这些用于定义一个在用户数据修改状态更改时触发的事件——即,它从未修改变为已修改,或反之。稍后会对此进行澄清。接下来,我们找到 OperationBase
类。这是一个抽象类,定义了一个操作,其中包含了命令模式实现的大部分内容。最重要的是,OperationBase
定义了两个 List<OperationBase>
类型的静态对象。它们构成了两个堆栈,即撤销堆栈和重做堆栈(它们是 List<>
类型,但功能上用作堆栈)。它还定义了一个 static int SavePoint
。这是撤销列表中最后一个保存发生的位置索引,因此定义了自上次保存以来数据是否已被修改。用户应用程序有责任在数据保存时通知命令框架,稍后将对此进行说明。
另一个静态成员是一个 object
类型的对象,名为 parent
。如果需要,用户应用程序可以设置它,并且当 modifiedChanged
事件触发时,它将作为 sender
返回给用户应用程序。OperationBase
有一个非静态成员变量,一个名为 name
的 string
。这是操作的名称,对用户应用程序很有用,可以放入应用程序菜单——编辑菜单通常包含撤销和重做项,它们会显示要撤销/重做的内容,因此,例如,在添加之后,菜单将包含“撤销添加”。为此,正如在演示程序中将看到的,撤销和重做操作都将被赋予操作的名称;即,插入操作的撤销操作的名称是“insert”,而不是“remove”或“delete”。
最后一个静态成员是 modifiedChanged
事件,该事件在修改状态更改时触发。构造函数仅设置名称,并提供属性访问器来设置父对象,而 ToString()
重写返回名称。IsModified
属性访问器设置或获取修改状态。
public static bool IsModified
{
get
{
return savePoint != undoList.Count;
}
set
{
bool wasModified = (savePoint != undoList.Count);
if (value)
{
// forces IsModified to always return true
// until IsModified = false is called
savePoint = -1;
}
else
{
// sets the savePoint to the end of the undo list
// so IsModified will return false
savePoint = undoList.Count;
}
if (wasModified != IsModified && modifiedChanged != null)
{
modifiedChanged(parent, new CommandEventArgs(IsModified));
}
}
}
get
访问器仅在 savePoint
未指向撤销堆栈顶部时返回 true
(请注意,savePoint
可以是 -1,如下文所述)。当用户应用程序保存其数据时,应使用 false
值调用 set
访问器。这将 savePoint
重置为指向撤销堆栈的顶部,并且如果修改状态实际已更改(即,在调用访问器之前已修改),则还会触发 modifiedChanged
事件。请注意,保存后仍然可以使用撤销和重做——在任何一种情况下,数据将再次被视为已修改。
在某些情况下,访问器也可能使用 true
值进行调用。这会将 savePoint
设置为 -1(并在必要时触发 modifiedChanged
事件),这意味着无论执行多少撤销和重做操作,修改状态将始终为 true
,直到再次设置为 false
为止。(例如,在 Simple Slide Show 中,整个图片文件在程序启动时被读取。这不是通过命令框架完成的,因为这会很复杂且不必要,但如果发现图片文件不存在,用户可以选择将其从脚本中删除。如果将其删除,这也并非在命令框架内完成——它无法撤销——但是,有必要表明数据已被修改)。该类现在定义了一个 abstract
方法。
protected abstract OperationBase Execute();
这个方法没有主体,稍后我们将看到,它将被 OperationBase
的泛型派生类覆盖。该类的核心由 Do()
、Undo()
和 Redo()
三个方法提供。Do()
基本上调用 Execute()
,它返回一个 OperationBase
类型的对象,该对象是撤销操作。如果它不为 null
,它将被“推”到撤销堆栈上,然后清除重做列表(重做只能在撤销之后进行)。
public void Do()
{
bool wasModified = IsModified;
OperationBase undoItem = Execute();
if (undoItem != null)
// operation can return null if there is no undo
{
undoList.Add(undoItem);
if (savePoint >= undoList.Count)
{
// IsModified will always return true until another
// save is done and it is specifically set to false.
savePoint = -1;
}
redoList.Clear();
}
if (wasModified != IsModified && modifiedChanged != null)
{
modifiedChanged(parent, new CommandEventArgs(IsModified));
}
}
也请注意这段代码。
if (savePoint >= undoList.Count)
{
savePoint = -1;
}
考虑这种情况:用户保存数据,撤销上一个操作,然后执行另一个操作。此时,就不可能恢复到未修改的状态了——IsModified
必须在任何情况下都返回 true
,直到数据再次保存并调用 IsModified = false
。Undo()
和 Redo()
只是从各自的堆栈中“弹出”一个操作,通过调用 Execute()
来执行操作,并将返回的 OperationBase
“推”到另一个堆栈上,因此撤销会将返回的操作放入重做堆栈,而重做会将返回的操作放入撤销堆栈。
public static bool Undo()
{
bool result = false;
bool wasModified = IsModified;
if (undoList.Count > 0)
{
OperationBase redoItem = undoList[undoList.Count - 1].Execute();
if (redoItem == null)
{
throw (new ArgumentException
("An undo method cannot return a null redo method"));
}
redoList.Add(redoItem);
undoList.RemoveAt(undoList.Count - 1);
result = true;
}
if (wasModified != IsModified && modifiedChanged != null)
{
modifiedChanged(parent, new CommandEventArgs(IsModified));
}
return result;
}
public static bool Redo()
{
bool result = false;
bool wasModified = IsModified;
if (redoList.Count > 0)
{
undoList.Add(redoList[redoList.Count - 1].Execute());
redoList.RemoveAt(redoList.Count - 1);
result= true;
}
if (wasModified != IsModified && modifiedChanged != null)
{
modifiedChanged(parent, new CommandEventArgs(IsModified));
}
return result;
}
请注意,Do()
和 Redo()
的操作方法可能返回 null
(有些操作无法有意义地撤销,例如复制到剪贴板),但 Undo()
方法必须返回一个非 null
的重做操作。UndoName
和 RedoName
是 get
访问器,分别返回撤销堆栈或重做堆栈顶部的操作名称。用户应用程序可以使用它们来设置“重做”和“撤销”菜单项中的文本。最后,静态方法 Clear()
通过清空两个堆栈、重置 savePoint
来重置整个框架,当然,如果合适,还会触发 modifiedChanged
事件。
OperationBase
是一个抽象基类,永远不会被实例化。我们现在需要一个可以使用的实际类,这就是 Operation
,它提供了 OperationBase
的泛型派生类。
public class Operation<T1, T2> : OperationBase
构造函数接受四个参数——基类所需的名称,指向(用 C++ 的说法)要执行的方法的泛型委托 OperationMethod<T1, T2>
,以及两个类型分别为 T1
和 T2
的参数,它们将被传递给方法。提供了两个 get
访问器,以便在需要时返回两个操作数。最后……Execute()
方法被重写,仅仅调用操作委托,并传递两个参数。
为什么我们需要基类和派生类?因为静态成员。C# 语言定义规定“C# 允许你定义使用泛型类型参数的静态方法。但是,在调用此类静态方法时,你需要在调用站点提供包含类的具体类型”(msdn.microsoft.com/en-us/library/ms379564%28VS.80%29.aspx) [^],所以,如果我们去掉 OperationBase
并将所有内容都实现在泛型类 Operation
中,我们就再也无法调用(例如)。
Operation.IsModified = false;
因为我们将不得不指定*哪个* Operation
我们正在谈论——也就是说,我们需要指定类型参数。(在我看来,这是语言的一个弱点,似乎没有理由语言不支持调用属于泛型类的非泛型静态方法。也许这在 VS 的后期版本中得到了扩展,或者可能有一个我错过的根本原因导致这不允许。我欢迎对此问题的评论。)
我们如何使用它
Windows 应用程序 CommandDemo 说明了该框架的使用。
使用这个命令模式实现可能会显得有些混乱,在描述的最后,我将讨论一些使代码更具可读性的方法。重要的要求是识别各种操作,并提供执行它们和撤销它们的方法。CommandDemo 实现了一个名称列表,以及添加名称和删除多个名称的操作。它支持排序或未排序列表,以及单选或多选列表。代码可在 CommandDemoForm.cs 中找到。添加操作由 DoAdd()
方法定义。
private Operation<string, int> DoAdd(string name, int dummy)
{
// Add the name. We want to cater for sorted lists
// so we need to keep the index where it is added
int index = namesListBox.Items.Add(name);
// Return the undo operation.
// This requires both the name and the index
return new Operation<string, int>("Add", UndoAdd, name, index);
}
以及对应的撤销方法。
private Operation<string, int> UndoAdd(string name, int index)
{
// Remove the item from the specified index
namesListBox.Items.RemoveAt(index);
// Return the Redo operation
// which is the Do operation and takes the name as parameter
return new Operation<string, int>("Add", DoAdd, name, 0);
}
DoAdd()
只需一个字符串即可添加到列表中,第二个参数将被忽略。UndoAdd()
需要添加的字符串和添加它的位置——索引告诉方法要删除哪个项(我们不假定列表中的字符串是唯一的),并且名称是重做操作所必需的。请注意,每种方法在执行完向列表中添加或删除项的任务后,都会实例化一个新的 Operation
对象返回。对于 DoAdd()
,会返回一个撤销操作,传递名称、撤销方法以及撤销方法所需的两个参数。同样,UndoAdd()
返回一个重做操作,它只是再次使用 DoAdd()
方法。请注意,名称始终是“Add”,如前所述。
删除操作支持多选,并接受一个索引列表。
private Operation<List<int>, List<string>>
DoDelete(List<int> indices, int dummy)
{
// We need to delete in reverse order, so sort the list
indices.Sort();
// Now delete the items, keeping a list of the names for the Undo
List<string> names = new List<string>();
for (int x = indices.Count - 1; x >= 0; x--)
{
names.Insert(0, namesListBox.Items[indices[x]] as string);
namesListBox.Items.RemoveAt(indices[x]);
}
// Return the undo operation
return new Operation<List<int>,
List<string>>("Delete", UndoDelete, indices, names);
}
与所有数组一样,按索引删除项目时必须小心,因为每次删除都会改变索引。为了避免这种情况,索引列表被排序,并从末尾开始向后删除项目。再次,会实例化并返回一个撤销操作。UndoDelete()
需要所有名称和所有索引。
private Operation<List<int>, int>
UndoDelete(List<int> indices, List<string> names)
{
// We need to put the names back at the given indices.
// Note that the fundamental principle of Do/Undo/Redo is that
// the data cannot have been changed between a Do and an Undo.
// The list of indices is already sorted,
// and the items were removed in reverse order,
// so if we replace them in forward order
// they will go back in their original places.
// Note that if the list is sorted
// the indices won't make any difference.
for (int x = 0; x < indices.Count; x++)
{
namesListBox.Items.Insert(indices[x], names[x]);
}
// Now we create a redo operation to delete them again
return new Operation<List<int>,
int>("Delete", DoDelete, indices, 0);
}
我们知道这个操作是由 DoDelete()
生成的,所以我们也知道索引列表已经排序,并且因为我们知道项目是按反向顺序删除的,所以我们也知道我们可以按正向顺序重新插入它们,并且它们会处于正确的位置。(作为题外话,让我们暂时想象一下,出于某种原因,项目是按正向顺序删除的——例如,DoDelete()
传递了索引 1、3 和 5。DoDelete()
将不得不删除索引为 1 的项目,此时索引为 3 的项目将移动到索引 2,因此下一个项目 2 将被删除。项目 5 现在将位于索引 3,最后被删除。设计者现在可以选择传递原始索引(1、3 和 5)或结果索引(1、2 和 3)作为要取消删除的索引,而 UnDelete()
则必须进行相应的调整。我将其留给读者练习。)一如既往,UnDelete()
实例化一个重做操作,该操作将 DoDelete
方法作为其委托参数。
要执行一个操作,例如添加操作,该操作在“添加”按钮的点击处理程序中执行,只需实例化一个正确类型的 Operation
对象并调用其 Do()
方法即可。
private void addButton_Click(object sender, EventArgs e)
{
new Operation<string, int>("Add", DoAdd, newNameTextBox.Text, 0).Do();
newNameTextBox.Text = "";
}
同样,删除(在 KeyDown
处理程序识别出删除键时执行)。
private void namesListBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Delete)
{
// We cannot just pass a reference to namesListBox.SelectedItems
// as the Delete operation is also used to redo the operation.
// Instead we pass a list of indices.
List<int> indices = new List<int>();
foreach (int index in namesListBox.SelectedIndices)
{
indices.Add(index);
}
new Operation<List<int>, int>("Delete", DoDelete, indices, 0).Do();
}
}
要撤销由“撤销”菜单项的点击处理程序执行的上一个操作,我们只需调用静态 Undo()
方法。
private void undoToolStripMenuItem_Click(object sender, EventArgs e)
{
OperationBase.Undo();
}
同样,要重做由“重做”菜单项的点击处理程序执行的操作。
private void redoToolStripMenuItem_Click(object sender, EventArgs e)
{
OperationBase.Redo();
}
最后,我们可以通过在窗体构造函数中向 modifiedChanged
事件添加处理程序,在应用程序的标题栏中添加数据修改标记——标准的标记是星号。
OperationBase.modifiedChanged += new CommandEventHandler(OperationBase_modifiedChanged);
传递给处理程序的 CommandEventArgs
对象告诉我们数据是否已修改。
void OperationBase_modifiedChanged(object sender, CommandEventArgs e)
{
if (OperationBase.IsModified)
{
this.Text += " *";
}
else
{
this.Text = this.Text.Substring(0, this.Text.Length - 2);
}
}
不要忘记在“保存”菜单项处理程序(它实际上将列表保存到名为 CommandDemo.txt 的文本文件中)保存数据时重置修改状态。
private void saveToolStripMenuItem_Click(object sender, EventArgs e)
{
Save();
OperationBase.IsModified = false;
}
使代码更具可读性
现在,这些泛型类型可能会使代码有点难以阅读,因此设计者可能想要采用一些技巧来整理一下。首先,她可以实现一个类工厂来实例化各种 Operation
类。在 CommandDemo 中,类工厂位于(毫不奇怪地)标记为 ClassFactory
的区域,我通过定义或不定义符号 UseTypeDefinitions
来使其可选择。例如,类工厂方法 AddOperation()
接受要添加的字符串,并返回一个 Add
操作,如下所示。
private Operation<string, int> AddOperation(string name)
{
return new Operation<string, int>("Add", DoAdd, name, 0);
}
因此,不是:
new Operation<string, int>("Add", DoAdd, newNameTextBox.Text, 0).Do();
添加操作可以通过(添加按钮点击事件)执行,如下所示:
AddOperation(newNameTextBox.Text).Do();
提高可读性的第二种方法是使用“using
”(对于 C++ 程序员来说,这相当于 #define
类型)。在 CommandDemo 中,可以通过取消注释符号定义 UseTypeDefinitions
来启用此功能,工作方式如下:在代码顶部,在命名空间内,但在任何类定义之外,我们为泛型类定义别名。因此,对于添加操作,我们放置:
using AddOpType = Operation<string, int>;
因此,UndoAdd()
方法定义从:
private Operation<string, int> UndoAdd(string name, int index)
to
private AddOpType UndoAdd(string name, int index)
以及上面描述的添加操作的类工厂方法变为:
private AddOpType AddOperation(string name)
{
return new Operation<string, int>("Add", DoAdd, name, 0);
}
结论
一如既往,命令实现和 CommandDemo 程序的所有源代码都可供下载。如果您喜欢用乏味的假期快照来让朋友们感到无聊,请看看Simple Slide Show [^]。如果您对非洲野生动物感兴趣,请看看Project African Wilderness [^],如果您喜欢我的代码,请给我发邮件并提供一份工作——我的腿现在已经好了,我作为一名自由 C++/C# 软件工程师又回到了承包商市场。