.NET Core3 设计模式教程(四)
十五、命令
想一个琐碎的变量赋值,比如meaningOfLife = 42。变量被赋值了,但是没有任何记录表明赋值发生了。没有人能给我们以前的值。我们不能将赋值的事实在某处序列化。这是有问题的,因为没有变更的记录,我们就不能回滚到以前的值,执行审计,或者进行基于历史的调试。 1
命令设计模式提出,我们向对象发送命令:关于如何做某事的指令,而不是通过 API 操纵它们来直接处理对象。命令只不过是一个数据类,其成员描述做什么和如何做。让我们来看一个典型的场景。
方案
让我们试着为一个有余额和透支额度的典型银行账户建模。我们将在其上实现Deposit()和Withdraw()方法:
public class BankAccount
{
private int balance;
private int overdraftLimit = -500;
public void Deposit(int amount)
{
balance += amount;
WriteLine($"Deposited ${amount}, balance is now {balance}");
}
public void Withdraw(int amount)
{
if (balance - amount >= overdraftLimit)
{
balance -= amount;
WriteLine($"Withdrew ${amount}, balance is now {balance}");
}
}
public override string ToString()
{
return $"{nameof(balance)}: {balance}";
}
}
当然,现在我们可以直接调用这些方法,但是让我们假设,为了审计的目的,我们需要记录每一笔存款和取款,但是我们不能在BankAccount中直接这样做,因为——你猜怎么着——我们已经设计、实现并测试了那个类。 2
实现命令模式
我们将从定义一个命令的接口开始:
public interface ICommand
{
void Call();
}
有了这个接口,我们现在可以用它来定义一个BankAccountCommand,它将封装关于如何处理银行账户的信息:
public class BankAccountCommand : ICommand
{
private BankAccount account;
public enum Action
{
Deposit, Withdraw
}
private Action action;
private int amount;
public BankAccountCommand
(BankAccount account, Action action, int amount) { ... }
}
该命令中包含的信息包括:
-
要操作的帐户。
-
要采取的操作;选项集和存储操作的变量都在类中定义。
-
存入或取出的金额。
一旦客户提供了这些信息,我们就可以利用这些信息进行存款或取款:
public void Call()
{
switch (action)
{
case Action.Deposit
account.Deposit(amount);
succeeded = true;
break;
case Action.Withdraw:
succeeded = account.Withdraw(amount);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
使用这种方法,我们可以创建命令,然后在命令上执行帐户权限的修改:
var ba = new BankAccount();
var cmd = new BankAccountCommand(ba,
BankAccountCommand.Action.Deposit, 100);
cmd.Call(); // Deposited $100, balance is now 100
WriteLine(ba); // balance: 100
这会在我们的账户上存 100 美元。放轻松!如果你担心我们仍然向客户端公开原始的Deposit()和Withdraw()成员函数,那么,隐藏它们的唯一方法就是让命令成为BankAccount本身的内部类。
撤消操作
因为一个命令封装了关于对一个BankAccount的修改的所有信息,它同样可以回滚这个修改,并将其目标对象返回到其先前的状态。
首先,我们需要决定是否将撤销相关的操作放入我们的Command接口。出于简洁的目的,我将在这里这样做,但一般来说,这是一个需要尊重我们在本书开始时讨论的接口分离原则的设计决策。例如,如果您设想一些命令是最终的,并且不受撤销机制的影响,那么将ICommand拆分成ICallable和IUndoable可能是有意义的。
不管怎样,这是更新后的ICommand:
public interface ICommand
{
void Call();
void Undo();
}
这里有一个对BankAccountCommand.Undo()的天真(但可行)的实现,其动机是(不正确地)假设Deposit()和Withdraw()是对称操作:
public void Undo()
{
switch (action)
{
case Action.Deposit:
account.Withdraw(amount);
break;
case Action.Withdraw:
account.Deposit(amount);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
为什么这个实现被打破了?因为如果你试图提取相当于一个发达国家国内生产总值的金额,你不会成功,但当回滚交易时,我们没有办法知道它失败了!
为了获得这个信息,我们修改Withdraw()来返回一个成功标志:
public bool Withdraw(int amount)
{
if (balance - amount >= overdraftLimit)
{
balance -= amount;
Console.WriteLine($"Withdrew ${amount}, balance is now {balance}");
return true; // succeeded
}
return false; // failed
}
那就好多了!我们现在可以修改整个BankAccountCommand来做两件事:
-
取款时,在内部存储一个
succeeded标志。我们假设Deposit()不可能失败。 -
调用
Undo()时使用该标志。
我们开始吧:
public class BankAccountCommand : ICommand
{
...
private bool succeeded;
}
好了,现在我们有了标志,我们可以改进我们的Undo()实现了:
public void Undo()
{
if (!succeeded) return;
switch (action)
{
case Action.Deposit:
account.Deposit(amount); // assumed to always succeed
succeeded = true;
break;
case Action.Withdraw:
succeeded = account.Withdraw(amount);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
Tada!我们终于可以用一致的方式撤销撤回命令了。
var ba = new BankAccount();
var cmdDeposit = new BankAccountCommand(ba,
BankAccountCommand.Action.Deposit, 100);
var cmdWithdraw = new BankAccountCommand(ba,
BankAccountCommand.Action.Withdraw, 1000);
cmdDeposit.Call();
cmdWithdraw.Call();
WriteLine(ba); // balance: 100
cmdWithdraw.Undo();
cmdDeposit.Undo();
WriteLine(ba); // balance: 0
当然,这个练习的目的是说明除了存储关于要执行的操作的信息之外,命令还可以存储一些中间信息,这些信息对于审计之类的事情还是很有用的。如果您检测到一系列 100 次失败的取款尝试,您可以调查潜在的黑客攻击。
复合命令(也称为宏)
可以用两个命令模拟从账户 A 到账户 B 的资金转移:
-
从 a 处提取 X 美元。
-
将 X 美元存入 b。
如果不是创建和调用这两个命令,而是创建和调用一个封装了这两个命令的命令,那就太好了。这是我们将在后面讨论的复合设计模式的本质。
让我们定义一个框架复合命令。我将从List<BankAccountCommand>继承,当然,实现ICommand接口:
abstract class CompositeBankAccountCommand
: List<BankAccountCommand>, ICommand
{
public virtual void Call()
{
ForEach(cmd => cmd.Call());
}
public virtual void Undo()
{
foreach (var cmd in
((IEnumerable<BankAccountCommand>)this).Reverse())
{
cmd.Undo();
}
}
}
如您所见,CompositeBankAccountCommand既是列表又是Command,这符合复合设计模式的定义。我已经实现了Undo()和Redo()操作;注意,Undo()进程以相反的顺序执行命令;希望我不用解释为什么你希望这是默认行为。演员阵容在那里是因为一个List<T>有它自己的void-回归,突变Reverse(),这是我们绝对不想要的。如果你不喜欢你在这里看到的,你可以使用一个for循环或者其他不做原地反转的基本类型。
那么现在,专门用于转账的复合命令怎么样?我将它定义如下:
class MoneyTransferCommand : CompositeBankAccountCommand
{
public MoneyTransferCommand(BankAccount from,
BankAccount to, int amount)
{
AddRange(new []
{
new BankAccountCommand(from,
BankAccountCommand.Action.Withdraw, amount),
new BankAccountCommand(to,
BankAccountCommand.Action.Deposit, amount)
});
}
}
如你所见,我们所做的只是提供一个构造函数来初始化对象。我们一直重用基类Undo()和Redo()实现。
但是等等,这不对吧?基类实现并不完全符合它,因为它们没有包含失败的思想。如果我不能从 A 处取钱,我就不应该把钱存到 B 处:整个链条会自动取消。
为了支持这一想法,需要进行更剧烈的变革。我们需要
-
给
Command添加一个Success标志。这当然意味着我们不能再使用接口了——我们需要一个抽象类。 -
记录每一次操作的成功或失败。
-
确保该命令只有在最初成功时才能撤消。
-
引入一个名为
DependentCompositeCommand的新中间类,它非常小心地回滚命令。
让我们假设我们已经执行了重构,使得Command现在是一个具有布尔Success成员的抽象类;现在的BankAccountCommand既是Undo()又是Redo()。
当调用每个命令时,我们只有在前一个命令成功的情况下才这样做;否则,我们只需将success标志设置为false。
public override void Call()
{
bool ok = true;
foreach (var cmd in this)
{
if (ok)
{
cmd.Call();
ok = cmd.Success;
}
else
{
cmd.Success = false;
}
}
}
没有必要覆盖Undo(),因为我们的每个命令都检查它自己的Success标志,并且只有当它被设置为true时才撤销操作。这里有一个场景,演示了当源帐户没有足够的资金进行成功转账时,新方案的正确操作。
var from = new BankAccount();
from.Deposit(100);
var to = new BankAccount();
var mtc = new MoneyTransferCommand(from, to, 1000);
mtc.Call();
WriteLine(from); // balance: 100
WriteLine(to); // balance: 0
人们可以想象出一种更强的形式,在上面的代码中,复合命令只有在其所有部分都成功的情况下才会成功(想象一下这样一种转账,其中取款成功,但存款失败,因为帐户被锁定——您希望它通过吗?)–这有点难以实现,我把它留给读者作为练习。
本节的全部目的是说明当考虑到现实世界的业务需求时,一个简单的基于命令的方法是如何变得非常复杂的。你是否真的需要这种复杂性……嗯,这取决于你。
功能命令
命令设计模式通常使用类来实现。然而,也可以用函数的方式实现这种模式。
首先,有人可能会说,一个只有一个Call()方法的ICommand接口是完全不必要的:我们已经有了像Func和Action这样的委托,它们可以作为事实上的接口。类似地,在调用命令时,我们可以直接调用所述委托,而不是调用某个接口的成员。
下面是该方法的一个简单示例。我们首先简单地将BankAccount定义为
public class BankAccount
{
public int Balance;
}
然后,我们可以定义不同的命令,作为独立的方法对银行帐户进行操作。或者,这些可以打包成现成的函数对象——两者之间没有真正的区别:
public void Deposit(BankAccount account, int amount)
{
account.Balance += amount;
}
public void Withdraw(BankAccount account, int amount)
{
if (account.Balance >= amount)
account.Balance -= amount;
}
每一个方法都代表一个命令。因此,我们可以将命令捆绑在一个简单的列表中,并一个接一个地处理它们:
var ba = new BankAccount();
var commands = new List<Action>();
commands.Add(() => Deposit(ba, 100));
commands.Add(() => Withdraw(ba, 100));
commands.ForEach(c => c());
你可能会觉得这个模型是我们之前讨论ICommand时的模型的极大简化。毕竟,任何调用都可以简化为无参数的Action,它只是捕获 lambda 中所需的元素。然而,这种方法有明显的缺点,即:
-
直接引用:捕获特定对象的 lambda 必然会延长其寿命。虽然从正确性的角度来看这很好(您永远不会用一个不存在的对象调用命令),但是有些情况下您希望命令比它们需要影响的对象持续更长时间。
-
记录:如果你想记录在一个账户上执行的每一个动作,你仍然需要某种命令处理器。但是如何确定调用的是哪个命令呢?你看到的只是一个
Action或者类似的难以描述的代表;你如何确定它是存款还是取款或者完全不同的东西,比如复合命令? -
封送:很简单,你不能封送一个 lambda。您也许可以编组一个表达式树(例如,an
Expression<Func<>>),但即使这样,解析表达式树也不是最容易的事情。传统的基于 OOP 的方法更容易,因为类可以被确定性地序列化(反序列化)。 -
二次操作:与功能对象不同,OOP 命令(或其接口)可以定义除调用之外的操作。我们已经看到了像
Undo()这样的例子,但是其他的操作可以包括像Log()、Print()或者其他的东西。函数式方法不会给你这种灵活性。
总而言之,虽然功能模式确实表示了一些需要完成的动作,但它只是封装了它的主要行为。一个函数很难检查/遍历,也很难序列化,如果它捕获了上下文,这显然会影响整个生命周期。慎用!
查询和命令-查询分离
命令-查询分离(CQS)的概念是指系统中的操作大致分为以下两类:
-
命令,是系统执行某些操作的指令,这些操作涉及状态突变,但不产生任何值
-
查询是对产生值但不改变状态的信息的请求
GoF 的书并没有将查询定义为一种独立的模式,所以为了彻底解决这个问题,我提出了以下非常简单的定义:
查询是一种特殊类型的命令,它不会改变状态。相反,查询指示组件提供一些信息,例如基于与一个或多个组件的交互计算的值。
那里。我们现在可以说,CQS 的两个部分都属于命令设计模式,唯一的区别是查询有返回值——当然,不是在return的意义上,而是具有任何命令处理器都可以初始化或修改的可变字段/属性。
摘要
命令设计模式很简单:它基本上建议组件可以使用封装指令的特殊对象相互通信,而不是将这些相同的指令指定为方法的参数。
有时候,你不希望这样的对象使目标发生变异,或者导致它做一些特定的事情;相反,您希望使用这样的对象从目标获取一些信息,在这种情况下,我们通常将这样的对象称为查询。虽然在大多数情况下,查询是依赖于方法的返回类型的不可变对象,但是当您希望返回的结果被其他组件修改时,会出现和的情况(例如,参见责任链“代理链”示例)。但是组件本身仍然没有修改,只有结果。
UI 系统中大量使用命令来封装典型的动作(例如,复制或粘贴),然后允许通过几种不同的方式调用单个命令。例如,您可以通过使用顶级应用菜单、工具栏上的按钮、上下文菜单或按键盘快捷键来进行复制。
最后,这些动作可以组合成复合命令(宏)——可以被记录然后随意重放的动作序列。注意,复合命令也可以由其他复合命令组成(按照复合设计模式)。
Footnotes 1我们有专门的历史调试工具,比如 Visual Studio 的 IntelliTrace 或 Undo LiveRecorder。
2
您可以以命令优先的方式设计您的代码,也就是说,确保命令是您的对象提供的唯一可公开访问的 API。
十六、解释器
任何优秀的软件工程师都会告诉你,编译器和解释器是可以互换的。
—蒂姆·伯纳斯·李
解释器设计模式的目标是,你猜对了,解释输入,特别是文本输入,尽管公平地说这真的无关紧要。解释器的概念与大学教授的编译理论和类似课程有很大联系。因为我们在这里没有足够的空间来深入研究不同类型的解析器的复杂性,所以本章的目的是简单地展示一些你可能想要解释的事情的例子。
这里有几个相当明显的例子:
-
像
42或1.234e12这样的数字文字需要被解释为有效地存储在二进制中。在 C# 中,这些操作通过Int.Parse()等方法覆盖。 1 -
正则表达式帮助我们找到文本中的模式,但是你需要意识到的是,正则表达式本质上是一种独立的、嵌入式的特定领域语言(DSL)。当然,在使用它们之前,必须对它们进行正确的解释。
-
任何结构化数据,无论是 CSV、XML、JSON 还是更复杂的数据,在使用之前都需要解释。
-
在解释器应用的顶峰,我们有完全成熟的编程语言。毕竟,像 C 或 Python 这样的语言的编译器或解释器在编译可执行文件之前必须真正理解这种语言。
鉴于与口译有关的挑战的扩散和多样性,我们将简单地看一些例子。这些用来说明如何构建一个解释器:从头开始构建,或者使用专门的库或解析器框架。
数值表达式计算器
假设我们决定解析非常简单的数学表达式,比如 3+(5-4),也就是说,我们将限制自己使用加法、减法和括号。我们想要一个程序,可以读取这样的表达式,当然,计算表达式的最终值。
我们将手工构建计算器*,而不求助于任何解析框架。这应该有望突出*解析文本输入所涉及的复杂性。**
*### 乐星
解释一个表达式的第一步叫做词法分析,它涉及到将一个字符序列转换成一个符号序列。一个标记通常是一个基本的语法元素,我们应该以这样一个简单的序列结束。在我们的例子中,令牌可以是
-
整数
-
运算符(加号或减号)
-
左括号或右括号
因此,我们可以定义以下结构:
public class Token
{
public enum Type
{
Integer, Plus, Minus, Lparen, Rparen
}
public Type MyType;
public string Text;
public Token(Type type, string text)
{
MyType = type;
Text = text;
}
public override string ToString()
{
return $"`{Text}`";
}
}
你会注意到Token不是一个enum,因为除了类型之外,我们还想存储与这个令牌相关的文本,因为它并不总是预定义的。(或者,我们可以存储一些引用原始字符串的Range。)
现在,给定一个包含表达式的string,我们可以定义一个词法分析过程,将文本转换成List<Token>:
static List<Token> Lex(string input)
{
var result = new List<Token>();
for (int i = 0; i < input.Length; i++)
{
switch (input[i])
{
case '+':
result.Add(new Token(Token.Type.Plus, "+"));
break;
case '-':
result.Add(new Token(Token.Type.Minus, "-"));
break;
case '(':
result.Add(new Token(Token.Type.Lparen, "("));
break;
case ')':
result.Add(new Token(Token.Type.Rparen, ")"));
break;
default:
// todo
}
}
return result;
}
解析预定义的令牌很容易。事实上,我们可以把它们作为
Dictionary<BinaryOperation.Type, char>
为了简化事情。但是解析一个数字并不容易。如果打了一个1,就要等等看下一个字符是什么。为此,我们定义了一个单独的例程:
var sb = new StringBuilder(input[i].ToString());
for (int j = i + 1; j < input.Length; ++j)
{
if (char.IsDigit(input[j]))
{
sb.Append(input[j]);
++i;
}
else
{
result.Add(new Token(Token.Type.Integer, sb.ToString()));
break;
}
}
本质上,当我们不断读取(抽取)数字时,我们将它们添加到缓冲区中。完成后,我们从整个缓冲区中创建一个Token,并将其添加到结果列表中。
从语法上分析
解析的过程将一系列标记转换成有意义的、通常面向对象的结构。在顶部,拥有一个树的所有元素都实现的抽象类或接口通常很有用:
public interface IElement
{
int Value { get; }
}
类型的Value计算这个元素的数值。接下来,我们可以创建一个元素来存储整数值(如 1、5 或 42):
public class Integer : IElement
{
public Integer(int value)
{
Value = value;
}
public int Value { get; }
}
如果我们没有一个Integer,就必须有一个加法或者减法之类的运算。在我们的例子中,所有操作都是二进制,这意味着它们有两个部分。例如,我们模型中的2+3可以用伪代码表示为BinaryOperation{Literal{2}, Literal{3}, addition}:
public class BinaryOperation : IElement
{
public enum Type
{
Addition,
Subtraction
}
public Type MyType;
public IElement Left, Right;
public int Value
{
get
{
switch (MyType)
{
case Type.Addition:
return Left.Value + Right.Value;
case Type.Subtraction:
return Left.Value - Right.Value;
default:
throw new ArgumentOutOfRangeException();
}
}
}
}
但是不管怎样,继续解析过程。我们需要做的就是将一系列的Token转换成一棵IExpression的二叉树。
static IElement Parse(IReadOnlyList<Token> tokens)
{
var result = new BinaryOperation();
bool haveLHS = false;
for (int i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
// look at the type of token
switch (token.MyType)
{
// process each token in turn
}
}
return result;
}
从前面的代码中我们唯一需要讨论的是haveLHS变量。记住,我们试图得到的是一棵树,在那棵树的根,我们期待一个BinaryExpression,根据定义,它有左右两边。但是当我们在一个数字上时,我们怎么知道它是表达式的左边还是右边呢?没错,我们不知道,这就是为什么我们使用haveLHS来追踪这件事。
现在让我们一个案例一个案例地检查一下。首先,整数——它们直接映射到我们的Integer结构,所以我们所要做的就是将文本转换成数字。(顺便说一句,如果我们愿意,我们也可以在 lexing 阶段这样做。)
case Token.Type.Integer:
var integer = new Integer(int.Parse(token.Text));
if (!haveLHS)
{
result.Left = integer;
haveLHS = true;
} else
{
result.Right = integer;
}
break;
plus和minus标记简单地决定了我们当前正在处理的操作的类型,所以它们很简单:
case Token.Type.Plus:
result.MyType = BinaryOperation.Type.Addition;
break;
case Token.Type.Minus:
result.MyType = BinaryOperation.Type.Subtraction;
break;
然后是左括号。是的,只有左边,我们不能明确地检测到右边。基本上,这里的想法很简单:找到右括号(我现在忽略嵌套的括号),取出整个子表达式,Parse()递归地将它设置为我们当前正在处理的表达式的左边或右边:
case Token.Type.Lparen:
int j = i;
for (; j < tokens.Count; ++j)
if (tokens[j].MyType == Token.Type.Rparen)
break; // found it!
// process subexpression w/o opening
var subexpression = tokens.Skip(i+1).Take(j - i - 1).ToList();
var element = Parse(subexpression);
if (!haveLHS)
{
result.Left = element;
haveLHS = true;
} else result.Right = element;
i = j; // advance
break;
在真实的场景中,您会希望这里有更多的安全特性:不仅处理嵌套括号(我认为这是必须的),还处理缺少右括号的不正确表达式。如果真的不见了,你会怎么处理?抛出异常?尝试解析剩下的内容,并假设结束在最后?还有别的吗?所有这些问题都留给读者去练习。
使用词法分析器和语法分析器
实现了Lex()和Parse()之后,我们最终可以解析表达式并计算其值:
var input = "(13+4)-(12+1)";
var tokens = Lex(input);
WriteLine(string.Join("\t", tokens));
// `(` `13` `+` `4` `)` `-` `(` `12` `+` `1` `)`
var parsed = Parse(tokens);
WriteLine($"{input} = {parsed.Value}");
// (13-4)-(12+1) = -4
功能范式中的阐释
如果您查看由词法分析或解析过程产生的一组元素,您会很快发现它们是非常简单的结构,可以非常整齐地映射到 F# 的有区别的联合上。这反过来又允许我们在需要遍历一个(递归的)有区别的并集时使用模式匹配,以便将它转换成其他东西。
这里有一个例子:假设给你一个数学表达式的定义,你想打印或计算它。 2 让我们定义 XML 中的结构,这样我们就不必经历一个困难的解析过程:
<math>
<plus>
<value>2</value>
<value>3</value>
</plus>
</math>
我们可以创建一个递归的有区别的并集来表示这个结构:
type Expression =
Math of Expression list
| Plus of lhs:Expression * rhs:Expression
| Value of value:string
正如您所看到的,XML 元素和相应的Expression案例之间存在一一对应的关系(例如,<math> → Math)。为了实例化案例,我们需要使用反射。我在这里采用的一个技巧是使用来自Microsoft.FSharp.Reflection名称空间的 API 预先计算 case 构造函数:
let cases = FSharpType.GetUnionCases (typeof<Expression>)
|> Array.map(fun f ->
(f.Name,FSharpValue.PreComputeUnionConstructor(f)))
|> Map.ofArray
然后,我们可以编写一个函数,在给定一个名称和一组参数的情况下构造一个联合事例:
let makeCase parameters =
try
let caseInfo = cases.Item name
(caseInfo parameters) :?> Expression
with
| exp -> raise <| new Exception(String.Format("Failed to create {0} : {1}", name, exp.Message))
在前面的清单中,变量name被隐式捕获,因为makeCase函数是一个内部函数。但是我们不要急于求成。当然,我们感兴趣的是解析和转换一些 XML。这个过程是这样开始的:
use stringReader = new StringReader(text)
use xmlReader = XmlReader.Create(stringReader)
let doc = XDocument.Load(xmlReader)
let parsed = recursiveBuild doc.Root
那么,这个recursiveBuild功能是什么呢?顾名思义,它是一个递归地将 XML 元素转化为我们的有区别的并集的函数。以下是完整列表:
let rec recursiveBuild (root:XElement) =
let name = root.Name.LocalName |> makeCamelCase
let makeCase parameters =
// as before
let elems = root.Elements() |> Seq.toArray
let values = elems |> Array.map(fun f -> recursiveBuild f)
if elems.Length = 0 then
let rootValue = root.Value.Trim()
makeCase [| box rootValue |]
else
try
values |> Array.map box |> makeCase
with
| _ -> makeCase [| values |> Array.toList |]
让我们试着慢慢了解这里发生的事情:
-
因为我们的联合用例是骆驼大小写的,XML 文件是小写的,所以我将 XML 元素的名称(我们称之为
root)转换成骆驼大小写。 -
我们将当前元素的子元素序列具体化为一个数组。
-
对于每个内部元素,我们递归调用
recursiveBuild(惊喜!). -
现在我们检查当前元素有多少个子元素。如果是零,它可能只是一个包含文本的
<value>。如果不是,有两种可能:-
该项目接受一组原语,这些原语都可以打包成参数。
-
该项目需要一串表达式。
-
这将构建表达式树。如果我们想计算表达式的数值,由于模式匹配,现在很简单:
let rec eval expr =
match expr with
| Math m -> eval m.Head
| Plus (lhs, rhs) -> eval lhs + eval rhs
| Value v -> v |> int
类似地,您可以定义一个函数来打印表达式:
let rec print expr =
match expr with
| Math m -> print m.Head
| Plus (lhs, rhs) -> String.Format("({0}+{1})", print lhs, print rhs)
| Value v -> v
将所有这些放在一起,我们现在可以以人类可读的形式打印表达式,并评估其结果:
let parsed = recursiveBuild doc.Root
printf "%s = %d" (print parsed) (eval parsed)
// (2+3) = 5
当然,这两个函数都是 Visitor 设计模式的简单实现,没有任何传统的 OOP 特征(当然,它们存在于幕后)。一些需要注意的事项如下:
-
我们的
Value案例是of string。如果我们希望它存储一个整数或浮点数,我们的解析代码必须使用反射来获取这些信息。 -
我们可以赋予
Expression自己的方法甚至属性,而不是制作顶级函数。例如,我们可以给它一个名为Val的属性来计算它的数值:type Expression = // union members here member self.Val = let rec eval expr = match expr with | Math m -> eval(m.Head) | Plus (lhs, rhs) -> eval lhs + eval rhs | Value v -> v |> int eval self -
严格地说,受歧视的结合违反了开闭原则,因为没有办法通过继承来扩大这种结合。因此,如果您决定支持新的案例,就必须修改原始的联合类型。
总之,有区别的联合、模式匹配以及列表理解(我们在演示中没有用到,但通常会在这样的场景中用到)都使得解释器和访问者模式在函数范式下易于实现。
摘要
首先,需要说明的是,相对而言,解释器设计模式有点不常见——构建解析器的挑战现在被认为是无关紧要的,这就是为什么我们看到它在许多大学(包括我自己的大学)的计算机科学课程中被删除。此外,除非你打算从事语言设计,或者制作静态代码分析工具,否则你不太可能找到需求量很大的构建解析器的技能。
也就是说,解释的挑战是计算机科学的一个完全独立的领域,一本设计模式书的一章无法合理地公正对待它。如果您对这个主题感兴趣,我建议您查看诸如 Lex/Yacc、ANTLR 等专门针对 lexer/parser 构造的框架。我还可以推荐为流行的 ide 编写静态分析插件——这是一个很好的方式来感受真正的 ast 是什么样子,它们是如何被遍历甚至修改的。
Footnotes 1数字解析是算法交易系统的开发者重新定义(优化)的首要操作。默认实现非常强大,可以处理许多不同的数字格式,但在现实生活中,股票市场通常以统一的精度和符号向您提供数据,从而允许构建更快(数量级)的解析器。
2
这是一个名为 MathSharp 的真实商业产品的小插图,这是一个将 MathML 符号转换为可编译代码的工具。详见active mesa . net/math sharp。
*
十七、迭代器
简单地说,迭代器是用于遍历某种结构的对象。通常,迭代器引用当前访问的元素,并有向前移动的方法。双向迭代器还允许您向后遍历,随机访问迭代器允许您访问任意位置的元素。
英寸 NET 中,支持迭代器的东西通常实现了IEnumerator<T>接口。它有以下成员:
-
Current指当前位置的元素。 -
MoveNext()让你移动到集合的下一个元素,如果成功,返回true,否则返回false。 -
Reset()将枚举器设置到初始位置。
枚举器也是一次性的,但是我们并不太关心这个。关键是,任何时候你写作
foreach (x in y)
Console.WriteLine(x);
你真正做的相当于
var enumerator = ((IEnumerable<Foo>)y).GetEnumerator();
while (enumerator.MoveNext())
{
temp = enumerator.Current;
Console.WriteLine(temp);
}
换句话说,实现IEnumerable<T>的类需要有一个名为GetEnumerator()的方法,该方法返回一个IEnumerator<T>。并使用枚举器来遍历对象。
不用说,你必须做出自己的IEnumerator是非常罕见的。通常,您可以编写如下代码…
IEnumerable<int> GetSomeNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
…其余的操作将由编译器负责。或者,您可以只使用现有的集合类(array,List<T>等)。)已经有了你需要的所有管道。
数组支持的属性
不是所有的东西都容易迭代。例如,除非使用反射,否则不能迭代一个类中的所有字段。但有时你需要。让我给你看一个场景。
假设你在做一个游戏,里面有生物。这些生物有不同的属性,例如力量、敏捷和智慧。您可以将它们实现为
public class Creature
{
public int Strength { get; set; }
public int Agility { get; set; }
public int Intelligence { get; set; }
}
但是现在您还想输出一些关于这个生物的汇总统计数据。例如,您决定计算它所有能力的总和:
public double SumOfStats => Strength + Agility + Intelligence;
如果你添加一个额外的Wisdom属性,这段代码是不可能自动重构的(哦,这对你来说是不是太无聊了?),但是让我给你看更糟糕的东西。如果你想要所有能力的平均值,你可以写:
public double AverageStat => SumOfStats / 3.0;
哇哦。那个 3.0 是一个真正的幻数,如果代码的结构改变,完全不安全。让我给你看另一个丑陋的例子。假设你决定计算一个生物的最大能力值。你需要写下这样的内容:
public double MaxStat => Math.Max(
Math.Max(Strength, Agility), Intelligence);
嗯,你明白了。这段代码并不健壮,任何微小的改变都会破坏它,所以我们将修复它,实现将利用数组支持的属性。
数组支持的属性的思想很简单:相关属性的所有支持字段都存在于一个数组中:
private int [] stats = new int[3];
然后,每个属性将其 getter 和 setter 投射到数组中。为了避免使用整数索引,可以引入私有常量:
private const int strength = 0;
public int Strength
{
get => stats[strength];
set => stats[strength] = value;
}
// same for other properties
现在,当然,计算总和/平均值/最大值统计数据非常容易,因为底层字段是一个数组,而数组在 LINQ 是受支持的:
public double AverageStat => stats.Average();
public double SumOfStats => stats.Sum();
public double MaxStat => stats.Max();
如果您想要添加额外的属性,您需要做的就是
-
将数组扩展一个元素
-
用 getter 和 setter 创建属性
就是这样!统计数据仍然会被正确计算。此外,如果你愿意,你可以避开所有我们喜欢的方法
public IEnumerable<int> Stats => stats;
让客户端直接执行自己的 LINQ 查询,例如,creature.Stats.Average()。
最后,如果你想让stats成为可枚举的集合,也就是让人写foreach ( var stat in creature),你可以简单地实现IEnumerable(或许还有一个索引器):
public class Creature : IEnumerable<int>
{
// as before
public IEnumerator<int> GetEnumerator()
=> stats.AsEnumerable().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
public int this[int index]
{
get => stats[index];
set => stats[index] = value;
}
}
这种方法很实用,但也有很多缺点。其中一个缺点与变更通知有关。例如,假设您的 UI 应用将一个 UI 元素绑定到SumOfStats属性。你改变了Strength,但是SumOfStat如何让你知道它确实也改变了?如果SumOfStats被定义为不同属性的基本总和,我们可以将这个总和视为一个表达式树,解析它,并提取依赖关系。但是因为我们使用 LINQ,这现在是不可能的,或者至少是非常困难的。我们可以尝试提供一些特殊的元数据来表明一些属性是数组支持的,然后在确定依赖关系时读取这些元数据,但是正如您可以猜到的,这既有计算成本,也有认知成本。
让我们做一个迭代器
为了理解如果你决定直接使用迭代器会有多难看,我们将实现一个经典的 Comp Sci 例子:树遍历。让我们从定义二叉树的单个节点开始:
public class Node<T>
{
public T Value;
public Node<T> Left, Right;
public Node<T> Parent;
public Node(T value)
{
Value = value;
}
public Node(T value, Node<T> left, Node<T> right)
{
Value = value;
Left = left;
Right = right;
left.Parent = right.Parent = this;
}
}
我添加了一个额外的构造函数,用左右两个子节点初始化它的节点。这允许我们定义链式构造器树,例如
// 1
// / \
// 2 3
var root = new Node<int>(1,
new Node<int>(2), new Node<int>(3));
好,现在我们要遍历树。如果你记得你的数据结构和算法课程,你会知道有三种方法:按序,前序和后序。假设我们决定定义一个无秩序者。下面是它的样子:
public class InOrderIterator<T>
{
public Node<T> Current { get; set; }
private readonly Node<T> root;
private bool yieldedStart;
public InOrderIterator(Node<T> root)
{
this.root = Current = root;
while (Current.Left != null)
Current = Current.Left;
}
public bool MoveNext()
{
// todo
}
}
到目前为止还不错:就像我们在实现IEnumerator<T>一样,我们有一个名为Current的属性和一个MoveNext()方法。但是事情是这样的:因为迭代器是有状态的,所以每次调用MoveNext()都必须将我们带到当前遍历方案中的下一个元素。这并不像听起来那么简单:
public bool MoveNext()
{
if (!yieldedStart)
{
yieldedStart = true;
return true;
}
if (Current.Right != null)
{
Current = Current.Right;
while (Current.Left != null)
Current = Current.Left;
return true;
}
else
{
var p = Current.Parent;
while (p != null && Current == p.Right)
{
Current = p;
p = p.Parent;
}
Current = p;
return Current != null;
}
}
哇哦。我打赌你没想到会这样!如果你直接实现你自己的迭代器,这就是你所得到的:一团乱麻。但是很管用!我们可以直接使用迭代器,C++风格:
var it = new InOrderIterator<int>(root);
while (it.MoveNext())
{
Write(it.Current.Value);
Write(',');
}
WriteLine();
// prints 213
或者,如果我们愿意,我们可以构造一个专用的BinaryTree类,将这个有序迭代器公开为默认迭代器:
public class BinaryTree<T>
{
private Node<T> root;
public BinaryTree(Node<T> root)
{
this.root = root;
}
public InOrderIterator<T> GetEnumerator()
{
return new InOrderIterator<T>(root);
}
}
注意,我们甚至不必实现IEnumerable(感谢鸭子打字、??【1】、)。我们现在可以写了
var root = new Node<int>(1,
new Node<int>(2), new Node<int>(3));
var tree = new BinaryTree<int>(root);
foreach (var node in tree)
WriteLine(node.Value); // 2 1 3
改进迭代
我们的有序迭代实现实际上是不可读的,与你在教科书中读到的完全不同。为什么呢?缺乏递归。毕竟,MoveNext()不能保存它的状态,所以每次它被调用时,它都是从零开始,不记得它的上下文:它只记得前一个元素,在我们使用的迭代方案中找到下一个元素之前,需要找到前一个元素。
这就是yield return存在的原因:你可以在幕后构建一个状态机。这意味着如果我想创建一个更自然的有序实现,我可以简单地写成
public IEnumerable<Node<T>> NaturalInOrder
{
get
{
IEnumerable<Node<T>> TraverseInOrder(Node<T> current)
{
if (current.Left != null)
{
foreach (var left in TraverseInOrder(current.Left))
yield return left;
}
yield return current;
if (current.Right != null)
{
foreach (var right in TraverseInOrder(current.Right))
yield return right;
}
}
foreach (var node in TraverseInOrder(root))
yield return node;
}
}
注意这里所有的调用都是递归的。现在我们可以直接使用它,例如:
var root = new Node<int>(1,
new Node<int>(2), new Node<int>(3));
var tree = new BinaryTree<int>(root);
WriteLine(string.Join(",", tree.NaturalInOrder.Select(x => x.Value)));
// 2,1,3
呜-呼!这样好多了。算法本身是可读的,同样,我们可以得到这个属性,然后对它做 LINQ,没问题。
迭代器适配器
通常你希望一个对象以某种特殊的方式是可迭代的。例如,假设您想计算一个矩阵中所有元素的总和——LINQ 没有提供一个矩形数组的Sum()方法,所以您可以做的是构建一个适配器,比如
public class OneDAdapter<T> : IEnumerable<T>
{
private readonly T[,] arr;
private int w, h;
public OneDAdapter(T[,] arr)
{
this.arr = arr;
w = arr.GetLength(0);
h = arr.GetLength(1);
}
public IEnumerator<T> GetEnumerator()
{
for (int y = 0; y < h; ++y)
for (int x = 0; x < w; ++x)
yield return arr[x, y];
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
只要您想以 1D 方式迭代 2D 数组,就可以使用这个适配器。例如,总和的计算现在非常简单
var data = new [,] { { 1, 2 }, { 3, 4 } };
var sum = new OneDAdapter<int>(data).Sum();
当然,我们仍然受困于 C# 无法在构造函数中派生类型参数,所以工厂方法在这里可能会有用。
这是另一个例子,它支持 1D 数组的反向迭代:
public class ReverseIterable<T> : IEnumerable<T>
{
private readonly T[] arr;
public ReverseIterable(T[] arr) => this.arr = arr;
public IEnumerator<T> GetEnumerator()
{
for (int i = arr.Length - 1; i >= 0; --i)
yield return arr[i];
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
同样,如果您不想显式指定类型参数,您必须创建另一个非泛型ReverseIterable类并提供一个工厂方法:
public static class ReverseIterable
{
public static ReverseIterable<T> From<T>(T[] arr)
{
return new ReverseIterable<T>(arr);
}
}
当然,正如我们之前无数次讨论的那样,这意味着构造函数是公共的,唯一使其私有的方法是使工厂成为迭代器适配器的嵌套类。
摘要
迭代器设计模式被有意隐藏在 C# 中,以支持简单的IEnumerator / IEnumerable双头垄断,一切都建立在这种双头垄断之上。注意,这些接口只支持正向迭代——在IEnumerator中没有MoveBack()。yield的存在允许你非常快速地返回元素作为一个集合,这个集合可以被其他人使用,而不用担心后台构建的状态机。
鸭子打字就是“如果它走路像鸭子,叫声像鸭子,那它就是鸭子”的想法在编程术语中,duck typing 意味着正确的代码将被使用,即使它没有实现任何特定的接口来识别它。在我们的例子中,foreach 关键字一点也不关心你的类型是否实现 IEnumerable 它所寻找的只是迭代类中GetEnumerator()的实现。如果它找到了,一切都正常了。
十八、中介
我们编写的大部分代码都有不同的组件(类)通过直接引用相互通信。但是,也有不希望对象一定意识到对方存在的情况。或者,也许你确实希望他们知道彼此,但是你仍然不希望他们通过引用进行交流,因为一旦你保持和持有对某个对象的引用,你就延长了该对象的寿命,超过了最初可能期望的寿命(当然,除非它是一个WeakReference)。
因此,中介是一种促进组件间通信的机制。自然地,中介本身需要能够被参与的每个组件访问,这意味着它要么是一个公开可用的静态变量,要么只是一个注入到每个组件中的引用。
聊天室
典型的互联网聊天室是中介设计模式的经典例子,所以在进入更复杂的内容之前,让我们先实现它。
聊天室中参与者最简单的实现可以是
public class Person
{
public string Name;
public ChatRoom Room;
private List<string> chatLog = new List<string>();
public Person(string name) => Name = name;
public void Receive(string sender, string message)
{
string s = $"{sender}: '{message}'";
WriteLine($"[{Name}'s chat session] {s}");
chatLog.Add(s);
}
public void Say(string message) => Room.Broadcast(Name, message);
public void PrivateMessage(string who, string message)
{
Room.Message(Name, who, message);
}
}
所以我们有一个拥有Name(用户 ID)、聊天日志和对实际ChatRoom的引用的人。我们有一个构造函数和三个方法:
-
允许我们接收信息。通常,该功能会在用户的屏幕上显示消息,并将其添加到聊天日志中。
-
Say()允许此人向房间里的每个人广播消息。 -
PrivateMessage()是私人信息传递功能。您需要指定邮件收件人的姓名。
Say()和PrivateMessage() 1 都只是对聊天室的中继操作。说到这里,让我们实际实现一下ChatRoom——这并不特别复杂。
public class ChatRoom
{
private List<Person> people = new List<Person>();
public void Broadcast(string source, string message) { ... }
public void Join(Person p) { ... }
public void Message(string source, string destination,
string message) { ... }
}
所以,我决定在这里用指针。ChatRoom API 非常简单:
-
让一个人加入房间。我们不打算实现
Leave(),而是将这个想法推迟到本章的后续例子中。 -
将消息发送给每个人…嗯,不完全是每个人:我们不需要将消息发送回发送它的人。
-
Message()发送私人信息。
Join()的实现如下:
public void Join(Person p)
{
string joinMsg = $"{p.Name} joins the chat";
Broadcast("room", joinMsg);
p.Room = this;
people.Add(p);
}
就像经典的 IRC 聊天室一样,我们向房间里的每个人广播某人已经加入的消息。Broadcast()的第一个参数,即origin参数,在本例中被指定为“room”,而不是被加入的人。然后,我们设置此人的room参考,并将他们添加到房间中的人员列表中。
现在,让我们看看Broadcast():这是向每个房间参与者发送消息的地方。记住,每个参与者都有自己的处理消息的Person.Receive()方法,所以实现有些琐碎:
public void Broadcast(string source, string message)
{
foreach (var p in people)
if (p.Name != source)
p.Receive(source, message);
}
我们是否想要阻止广播信息被转发给我们自己是一个争论点,但我在这里积极地避免它。不过,其他人都明白这一点。
最后,这里是用Message()实现的私有消息:
public void Message(string source, string destination, string message)
{
people.FirstOrDefault(p => p.Name == destination)
?.Receive(source, message);
}
这会在people列表中搜索收件人,如果找到了收件人(因为谁知道呢,他们可能已经离开房间了),就会将消息发送给那个人。
回到Person对Say()和PrivateMessage()的实现,它们是:
public void Say(string message) => Room.Broadcast(Name, message);
public void PrivateMessage(string who, string message)
{
Room.Message(Name, who, message);
}
至于Receive(),这是一个在屏幕上显示消息并将其添加到聊天日志的好地方:
public void Receive(string sender, string message)
{
string s = $"{sender}: '{message}'";
WriteLine($"[{Name}'s chat session] {s}");
chatLog.Add(s);
}
我们在这里做了额外的工作,不仅显示消息来自谁,还显示我们当前在谁的聊天会话中——这将有助于诊断谁在何时说了什么。
这是我们将要经历的场景:
var room = new ChatRoom();
var john = new Person("John");
var jane = new Person("Jane");
room.Join(john);
room.Join(jane);
john.Say("hi room");
jane.Say("oh, hey john");
var simon = new Person("Simon");
room.Join(simon);
simon.Say("hi everyone!");
jane.PrivateMessage("Simon", "glad you could join us!");
以下是输出:
[john's chat session] room: "jane joins the chat"
[jane's chat session] john: "hi room"
[john's chat session] jane: "oh, hey john"
[john's chat session] room: "simon joins the chat"
[jane's chat session] room: "simon joins the chat"
[john's chat session] simon: "hi everyone!"
[jane's chat session] simon: "hi everyone!"
[simon's chat session] jane: "glad you could join us, simon"
这是聊天室操作的一个例子:
事件中介
在聊天室的例子中,我们遇到了一个一致的主题:每当有人发布消息时,参与者都需要通知。对于观察者模式来说,这似乎是一个完美的场景,这将在本书的后面讨论:中介者拥有一个所有参与者共享的事件;然后,参与者可以订阅该事件以接收通知,他们还可以引发该事件,从而触发所述通知。
让我们来看一个更简单的例子,而不是再次重做聊天室:想象一场有球员和足球教练的足球比赛。教练看到自己的球队得分,自然要恭喜球员。当然,他们需要一些关于这个事件的信息,比如谁进了球,以及他们到目前为止进了多少球。
我们可以为任何类型的事件数据引入一个基类:
abstract class GameEventArgs : EventArgs
{
public abstract void Print();
}
我特意添加了Print()来将事件的内容打印到命令行。现在,我们可以从这个类派生出一些与目标相关的数据:
class PlayerScoredEventArgs : GameEventArgs
{
public string PlayerName;
public int GoalsScoredSoFar;
public PlayerScoredEventArgs
(string playerName, int goalsScoredSoFar)
{
PlayerName = playerName;
GoalsScoredSoFar = goalsScoredSoFar;
}
public override void Print()
{
WriteLine($"{PlayerName} has scored! " +
$"(their {GoalsScoredSoFar} goal)");
}
}
我们将再次构建一个中介器,但是它将有没有行为!说真的,有了事件驱动的基础设施,就不再需要它们了:
class Game
{
public event EventHandler<GameEventArgs> Events;
public void Fire(GameEventArgs args)
{
Events?.Invoke(this, args);
}
}
如你所见,我们刚刚做了一个生成所有游戏事件的中心位置。生成本身是多态的:事件使用一个GameEventArgs类型,您可以针对应用中可用的各种类型来测试参数。Fire()实用程序方法只是帮助我们安全地引发事件。
我们现在可以构造Player类。一名球员有一个名字,他们在比赛中的进球数,当然还有一个仲裁人Game的参考:
class Player
{
private string name;
private int goalsScored = 0;
private Game game;
public Player(Game game, string name)
{
this.name = name;
this.game = game;
}
public void Score()
{
goalsScored++;
var args = new PlayerScoredEventArgs(name, goalsScored);
game.Fire(args);
}
}
Player.Score()方法是我们制作PlayerScoredEventArgs并发布给所有订阅者看的地方。谁得到这个事件?为什么,当然是一个Coach:
class Coach
{
private Game game;
public Coach(Game game)
{
this.game = game;
// celebrate if player has scored <3 goals
game.Events += (sender, args) =>
{
if (args is PlayerScoredEventArgs scored
&& scored.GoalsScoredSoFar < 3)
{
WriteLine($"coach says: well done, {scored.PlayerName}");
}
};
}
}
Coach类的实现很简单;我们的教练连名字都没有。但是我们确实给了他一个构造函数,在那里创建了一个对游戏的Events的订阅,这样无论什么时候发生了什么,教练都可以在提供的 lambda 中处理事件数据。
注意 lambda 的参数类型是GameEventArgs——我们不知道一个球员是得分了还是被罚下了,所以我们需要一个 cast 来确定我们得到了正确的类型。
有趣的是,所有的魔法都发生在设置阶段:不需要明确地订阅特定的事件。客户端可以自由地使用它们的构造函数创建对象,然后当玩家得分时,就会发送通知:
var game = new Game();
var player = new Player(game, "Sam");
var coach = new Coach(game);
player.Score(); // coach says: well done, Sam
player.Score(); // coach says: well done, Sam
player.Score(); //
输出只有两行长,因为在第三个目标上,教练不再感兴趣了。
mediasr 简介
Mediator 是许多在. NET 中提供收缩包装中介实现的库之一。 2 它为客户端提供了一个中央Mediator组件,以及请求和请求处理程序的接口。它支持同步和异步/await 范例,并为定向消息和广播提供支持。
正如您可能已经猜到的,MediatR 被设计为使用 IoC 容器。它附带了如何让它在最流行的容器上运行的例子;对于我的例子,我将使用 Autofac。
第一步是一般性的:我们简单地在 IoC 容器下设置 MediatR,并通过它们实现的接口注册我们自己的类型。
var builder = new ContainerBuilder();
builder.RegisterType<Mediator>()
.As<IMediator>()
.InstancePerLifetimeScope(); // singleton
builder.Register<ServiceFactory>(context =>
{
var c = context.Resolve<IComponentContext>();
return t => c.Resolve(t);
});
builder.RegisterAssemblyTypes(typeof(Demo).Assembly)
.AsImplementedInterfaces();
我们注册为单例的中央Mediator负责将请求路由到请求处理程序,并从它们那里获得响应。每个请求都应该实现IRequest<T>接口,其中T是该请求预期的响应类型。如果没有要返回的数据,可以使用非通用的IRequest来代替。
这里有一个简单的例子:
public class PingCommand : IRequest<PongResponse> {}
所以在我们简单的演示中,我们打算发送一个PingCommand并接收一个PongResponse。响应不必实现任何接口;我们将这样定义它:
public class PongResponse
{
public DateTime Timestamp;
public PongResponse(DateTime timestamp)
{
Timestamp = timestamp;
}
}
将请求和响应连接在一起的粘合剂是 MediatR 的IRequestHandler接口。它有一个名为Handle的成员,接受一个请求和一个取消令牌,并返回调用结果:
[UsedImplicitly]
public class PingCommandHandler
: IRequestHandler<PingCommand, PongResponse>
{
public async Task<PongResponse> Handle(PingCommand request,
CancellationToken cancellationToken)
{
return await Task
.FromResult(new PongResponse(DateTime.UtcNow))
.ConfigureAwait(false);
}
}
注意前面使用的 async/await 范例,Handle方法返回一个Task<T>。如果您实际上不需要您的请求产生响应,那么您可以不使用IRequestHandler,而是使用AsyncRequestHandler基类,它的Handle()方法返回一个普通的非泛型Task。哦,如果你的请求是同步的,你可以从RequestHandler<TRequest,TResponse>类继承。
这就是实际设置两个组件并让它们通过中央中介进行对话所需要做的全部工作。请注意,中介本身并没有出现在我们创建的任何类中:它在幕后工作。
综上所述,我们可以如下使用我们的设置:
var container = builder.Build();
var mediator = container.Resolve<IMediator>();
var response = await mediator.Send(new PingCommand());
Console.WriteLine($"We got a pong at {response.Timestamp}");
您会注意到请求/响应消息是有目标的:它们被分派到单个处理程序。MediatR 还支持通知消息,可以将通知消息分派给多个处理程序。在这种情况下,您的请求需要实现INotification接口:
public class Ping : INotification {}
现在您可以创建任意数量的INotification<Ping>类来处理这些通知:
public class Pong : INotificationHandler<Ping>
{
public Task Handle(Ping notification,
CancellationToken cancellationToken)
{
Console.WriteLine("Got a ping");
return Task.CompletedTask;
}
}
public class AlsoPong : INotificationHandler<Ping> { ... }
对于通知,我们不使用Send()方法,而是使用Publish()方法:
await mediator.Publish(new Ping());
在 MediatR 的官方维基页面上有更多关于 MediatR 的信息( https://github.com/jbogard/MediatR )。
摘要
中介设计模式就是要有一个中间组件,系统中的每个人都可以引用这个组件,并可以用它来相互通信。代替直接引用,通信可以通过标识符(用户名、唯一 id、GUIDs 等)进行。).
中介器最简单的实现是一个成员列表和一个函数,它遍历列表并做它想要做的事情——无论是对列表的每个元素,还是有选择地。
更复杂的 Mediator 实现可以使用事件来允许参与者订阅(和取消订阅)系统中发生的事情。这样,从一个组件发送到另一个组件的消息可以被视为事件。在这种设置中,如果参与者对某些事件不再感兴趣或者如果他们将要完全离开系统,他们也很容易取消订阅这些事件。
Footnotes 1在现实世界中,我可能会称这个方法为PM(),考虑到这个缩写已经变得如此普遍。
2
NuGet 上有 MediatR 源代码可以在 https://github.com/jbogard/MediatR 找到。
十九、备忘录
当我们查看命令设计模式时,我们注意到理论上记录每一个单独的更改列表允许您将系统回滚到任何时间点——毕竟,您已经保存了所有修改的记录。
虽然有时候,你并不真的关心回放系统的状态,但是你确实关心如果需要的话,能够将系统回滚到一个特定的状态。
这正是 Memento 模式所做的:它通常存储系统的状态,并将其作为一个专用的、只读的对象返回,没有自己的行为。如果你愿意的话,这个“令牌”只能用于将它反馈到系统中,以将它恢复到它所代表的状态。
我们来看一个例子。
银行存款
让我们用一个我们以前做过的银行账户的例子…
public class BankAccount
{
private int balance;
public BankAccount(int balance)
{
this.balance = balance;
}
// todo: everything else :)
}
…但现在我们决定用Deposit()开一个银行账户。与前面示例中的void不同,Deposit()现在将返回一个Memento:
public Memento Deposit(int amount)
{
balance += amount;
return new Memento(balance);
}
并且该备忘录然后将可用于将账户回滚到先前的状态:
public void Restore(Memento m)
{
balance = m.Balance;
}
至于备忘录本身,我们可以做一个简单的实现:
public class Memento
{
public int Balance { get; }
public Memento(int balance)
{
Balance = balance;
}
}
您会注意到Memento类是不可变的。想象一下,如果你可以,事实上,改变平衡:你可以回滚到一个从未有过的帐户状态!
下面是如何使用这样的设置:
var ba = new BankAccount(100);
var m1 = ba.Deposit(50);
var m2 = ba.Deposit(25);
WriteLine(ba); // 175
// restore to m1
ba.Restore(m1);
WriteLine(ba); // 150
// restore back to m2
ba.Restore(m2);
WriteLine(ba); // 175
这个实现足够好了,尽管还缺少一些东西。例如,你永远不会得到代表期初余额的备忘录,因为构造函数不能返回值。当然,您可以添加一个out参数,但是这太难看了。
Undo and Redo
如果你要存储BankAccount生成的每一个备忘录会怎么样?在这种情况下,您会遇到类似于我们的Command模式实现的情况,撤销和重做操作是这个记录的副产品。让我们看看如何用备忘录获得撤销/重做功能。
我们将引入一个新的BankAccount类来保存它所生成的每一个备忘录:
public class BankAccount
{
private int balance;
private List<Memento> changes = new List<Memento>();
private int current;
public BankAccount(int balance)
{
this.balance = balance;
changes.Add(new Memento(balance));
}
}
我们现在已经解决了返回初始平衡的问题:初始变化的备忘录也被存储。当然,这个备忘录实际上并没有被返回,所以为了回滚到它,嗯,我想你可以实现一些Reset()函数之类的东西——完全由你决定。
BankAccount类有一个current成员,存储最新时刻的索引。等等,我们为什么需要这个?不就是current永远比changes的名单少一个吗?仅当您希望支持撤消/回滚操作时;如果你也想重做操作,你需要这个!
现在,这里是Deposit()方法的实现:
public Memento Deposit(int amount)
{
balance += amount;
var m = new Memento(balance);
changes.Add(m);
++current;
return m;
}
这里发生了几件事:
-
余额会根据您想存入的金额而增加。
-
用新的余额构建新的纪念物,并将其添加到变更列表中。
-
我们增加了
current的值(你可以把它想象成一个指向changes列表的指针)。
现在有趣的事情来了。我们添加了一个基于备忘录恢复帐户状态的方法:
public void Restore(Memento m)
{
if (m != null)
{
balance = m.Balance;
changes.Add(m);
current = changes.Count - 1;
}
}
恢复过程与我们之前看到的过程有很大不同。首先,我们实际上检查了 memento 是否被初始化——这是相关的,因为我们现在有了一种发出不操作信号的方式:只返回一个默认值。此外,当我们恢复一个备忘录时,我们实际上是将该备忘录添加到更改列表中,这样撤销操作就可以正确地对其进行操作。
现在,这里是Undo()的(相当棘手的)实现:
public Memento Undo()
{
if (current > 0)
{
var m = changes[--current];
balance = m.Balance;
return m;
}
return null;
}
如果current指向大于零的变化,我们只能Undo()。如果是这种情况,我们将指针移回来,在那个位置抓取更改,应用它,然后返回那个更改。如果我们不能回滚到前一个备忘录,我们返回null,这应该解释为什么我们在Restore()中检查null。
Redo()的实现非常相似:
public Memento Redo()
{
if (current + 1 < changes.Count)
{
var m = changes[++current];
balance = m.Balance;
return m;
}
return null;
}
同样,我们需要能够重做一些事情:如果可以,我们安全地做——如果不行,我们什么都不做并返回null。综上所述,我们现在可以开始使用撤销/重做功能了:
var ba = new BankAccount(100);
ba.Deposit(50);
ba.Deposit(25);
WriteLine(ba);
ba.Undo();
WriteLine($"Undo 1: {ba}"); // Undo 1: 150
ba.Undo();
WriteLine($"Undo 2: {ba}"); // Undo 2: 100
ba.Redo();
WriteLine($"Redo 2: {ba}"); // Redo 2: 150
Using Memento for Interop
有时,托管代码是不够的。例如,你需要在 GPU 上运行一些计算,这些计算(通常)是使用 CUDA C 等编程的。您最终不得不从您的 C# 代码中使用 C 或 C++库,所以您从托管(。NET)端到非托管(本机代码)端。
如果您想来回传递简单的数据,比如数字或数组,这不是问题。。NET 具有锁定数组并将其发送到“非托管”端进行处理的功能。大多数情况下,它工作得很好。
当您在非托管代码中分配一些面向对象的构造(例如,一个类)并希望将其返回给托管调用方时,问题就出现了。现在,这通常通过在一端序列化(编码)所有数据,然后在另一端解包来处理。这里有很多方法,包括简单的方法,比如返回 XML 或 JSON,或者复杂的行业级解决方案,比如 Google 的协议缓冲区。
但是,在某些情况下,您并不真的需要返回完整的对象本身。相反,您只是想返回一个句柄,以便该句柄随后可以再次在非托管端使用。您甚至不需要来回传递对象的额外内存流量。有很多原因可以解释为什么你想这样做,但是主要的原因是你想只让一方管理对象的生命周期,因为在双方管理它是一个没有人真正需要的噩梦。
在这种情况下,你要做的是归还一个备忘录。这可以是任何东西——字符串标识符、整数、全局唯一标识符(GUID)——任何可以让您以后引用该对象的东西。然后,托管端持有该令牌,并在需要对底层对象进行某些操作时使用该令牌传递回非托管端。
这种方法引入了生命周期管理的问题。假设我们希望底层对象在拥有令牌的情况下一直存在。我们如何实现这一点?这意味着,在非托管端,令牌是永久存在的,而在托管端,我们将它包装在一个IDisposable中,用Dispose()方法向非托管端发回一条消息,表明令牌已经被释放。但是,如果我们复制令牌并拥有它的两个或更多实例会怎么样呢?然后,我们最终不得不为令牌构建一个引用计数系统:这是很有可能的,但会给我们的系统带来额外的复杂性。
还有一个对称问题:如果托管方销毁了令牌所代表的对象,该怎么办?如果我们尝试使用令牌,需要进行额外的检查以确保令牌实际上是有效的,并且需要向非托管调用提供某种有意义的返回值,以便告诉托管方令牌已经过时。同样,这是额外的工作。
Summary
Memento 模式就是分发令牌,这些令牌可以用来将系统恢复到以前的状态。通常,令牌包含将系统移动到特定状态所需的所有信息,如果它足够小,您还可以使用它来记录系统的所有状态,以便不仅允许将系统任意重置到先前的状态,还允许控制系统所有状态的向后(撤消)和向前(重做)导航。
我之前在演示中做的一个设计决定是让备忘录成为一个class。这允许我使用null值来编码缺少备忘录的情况。如果我们想把它变成一个struct,我们将不得不重新设计 API,这样,Restore()方法将能够接受Nullable<Memento>、一些Option<Memento>类型(。NET 还没有内置的选项类型),或者拥有一些容易识别的特征的备忘录(例如,int.MinValue的余额)。
二十、空对象
我们并不总是选择我们工作的界面。例如,我宁愿让我的车自己把我送到目的地,而不用我把 100%的注意力放在路上和旁边开车的危险的疯子身上。软件也是如此:有时你并不真的想要某项功能,但它已经内置在界面中了。那你是做什么的?你创建了一个空对象。
方案
假设您继承了一个使用以下接口的库:
public interface ILog
{
void Info(string msg);
void Warn(string msg);
}
该库使用这个接口来操作银行账户,例如
public class BankAccount
{
private ILog log;
private int balance;
public BankAccount(ILog log)
{
this.log = log;
}
// more members here
}
事实上,BankAccount可以有类似于
public void Deposit(int amount)
{
balance += amount;
log.Info($"Deposited ${amount}, balance is now {balance}");
}
那么,这里的问题是什么?嗯,如果你需要日志记录,没有问题,你只需要实现你自己的日志记录类...
class ConsoleLog : ILog
{
public void Info(string msg)
{
WriteLine(msg);
}
public void Warn(string msg)
{
WriteLine("WARNING: " + msg);
}
}
…您可以直接使用它。但是,如果您根本不想登录呢?
侵入式方法
如果你准备打破开闭原则,有两种侵入性方法(侵入程度不同)可以帮助你避开这种情况。
最简单的方法,也是最难看的方法,是将接口改为抽象类,也就是将ILog改为
public abstract class ILog
{
void Info(string msg) {}
void Warn(string msg) {}
}
您可能希望通过从ILog到Log的重命名重构来跟进这一变化,但希望方法是显而易见的:通过在基类中提供默认的无操作实现,您现在可以简单地创建这个新的ILog的虚拟继承,并将其提供给任何需要它的人。或者你可以更进一步,使它非抽象,然后ILog 是你的空对象,就无操作行为而言。
这种方法很容易出错——毕竟,您可能有客户明确假设 ILog 是一个接口,所以他们可能在自己的类中实现它和其他接口,这意味着这种修改会破坏现有的代码。
前一种方法的另一种替代方法是简单地到处添加null检查。然后,您可以重写BankAccount构造函数,使其具有默认的 null 参数:
public BankAcccount(ILog log = null) { ... }
通过这一更改,您现在需要将日志中的每个呼叫都更改为安全呼叫,例如,log?.Info(...)。这是可行的,但是如果到处都使用日志,会导致大量的更改。还有一个小问题是,使用null表示缺席在习惯用法上是不正确的(不明显)——也许更好的方法是使用某种Option<T>类型,但这样的使用会导致整个代码库发生更剧烈的变化。
空对象虚拟代理
最后一种侵入式方法只需要在BankAccount类中进行一次修改,而且是危害最小的:它涉及到在ILog上构建一个虚拟代理(参见“代理”一章)。本质上,我们在日志上做了一个代理/装饰器,其中底层允许是null:
class OptionalLog: ILog
{
private ILog impl;
public OptionalLog(ILog impl) { this.impl = impl; }
public void Info(string msg) { impl?.Info(msg); }
public void Warn(string msg) { impl?.Warn(msg); }
}
然后,我们更改BankAccount构造函数,在主体中添加可选的null值和包装器的使用。事实上,如果您能忍受在BankAccount类中多一行,我们可以通过引入一个叫做NoLogging的漂亮的描述性常量来使用它:
private const ILog NoLogging = null;
public BankAccount([CanBeNull] ILog log = NoLogging)
{
this.log = new OptionalLog(log);
}
这种方法可能是侵入性最小也是最卫生的,它允许在迄今为止不允许使用这种值的地方使用null,同时,使用缺省值的名称来暗示正在发生什么。
空对象
有些情况下,没有一种侵入性的方法会起作用,最明显的情况是,您实际上并不拥有使用相关组件的代码。在这种情况下,我们需要构造一个单独的空对象,这就产生了我们正在讨论的模式。
再次查看BankAccount的构造函数:
public BankAccount(ILog log)
{
this.log = log;
}
因为构造函数有一个日志记录器,所以假设你可以通过传递给它一个null而逃脱,这是不安全的。BankAccount 可能在调度之前会在内部检查引用,但你不知道它会这样做,而且没有额外的文档也不可能知道。
因此,唯一合理的传递给BankAccount的是一个空对象——一个符合接口但不包含任何功能的类:
public sealed class NullLog : ILog
{
public void Info(string msg) { }
public void Warn(string msg) { }
}
注意这个类是sealed:这是一个设计选择,它假定从一个故意没有行为的对象继承是没有意义的。本质上,NullLog是一个没有价值的家长。
动态空对象
为了构造一个正确的空对象,你必须实现所需接口的每个成员。嘘-响!难道我们不能只写一个方法,说“请不要对任何调用做任何事情”吗?多亏了动态语言运行时(DLR ),我们可以做到。
对于这个例子,我们将创建一个名为Null<T>的类型,它将从DynamicObject继承而来,并简单地对任何调用它的方法提供一个无操作响应:
public class Null<T> : DynamicObject where T:class
{
public override bool TryInvokeMember(InvokeMemberBinder binder,
object[] args, out object result)
{
var name = binder.Name;
result = Activator.CreateInstance(binder.ReturnType);
return true;
}
}
正如您所看到的,这个动态对象所做的只是构造一个默认实例,无论这个方法实际返回什么类型。因此,如果我们的记录器返回一个指示写入日志的行数的int,我们的动态对象将返回0(零)。
现在,我忘记提到Null<T>中的 T 实际上是什么了。正如您可能已经猜到的,这就是我们需要无操作对象的接口。我们可以创建一个实用属性 getter 来实际构造满足接口 t 的Null<T>的实例。 1
public static T Instance
{
get
{
if (!typeof(T).IsInterface)
throw new ArgumentException("I must be an interface type");
return new Null<T>().ActLike<T>();
}
}
在前面的代码中,ImpromptuInterface 的ActLike()方法获取一个dynamic对象,并在运行时使其符合所需的接口T。
将所有内容放在一起,我们现在可以编写以下内容:
var log = Null<ILog>.Instance;
var ba = new BankAccount(log);
ba.Deposit(100);
ba.Withdraw(200);
同样,这段代码的计算成本与动态对象的构造有关,该动态对象不仅不执行任何操作,而且还符合所选的接口。
摘要
空对象模式提出了一个 API 设计的问题:我们可以对我们依赖的对象做什么样的假设?如果我们引用一个参考,那么我们有义务在每次使用时检查这个参考吗?
如果您觉得没有这样的义务,那么客户端可以实现空对象的唯一方法是构造所需接口的无操作实现,并传入该实例。也就是说,这只对方法有效:例如,如果对象的字段也被使用,那么你就真的有麻烦了。同样的道理也适用于非类型方法,在非类型方法中,返回值实际上是用于某些事情的。
如果您想主动支持将空对象作为参数传递的想法,您需要明确这一点:要么将参数类型指定为某个Optional,给参数一个暗示可能有null,的默认值,要么只编写文档来解释在这个位置应该有什么类型的值。
ImpromptuInterface 是一个开源的动态“鸭铸”库,构建在 DLR 和Reflection.Emit之上。它的源代码可以在 https://github.com/ekonbenefits/impromptu-interface 获得,你可以直接从 NuGet 安装。