-NET-Core3-设计模式教程-全-
.NET Core3 设计模式教程(全)
一、SOLID 设计原则
SOLID 是一个缩写词,代表以下设计原则(及其缩写):
-
单一责任原则
-
开闭原则(OCP)
-
利斯科夫替代原理
-
接口隔离原则(ISP)
-
从属倒置原则
这些原则是罗伯特·c·马丁在 21 世纪初提出的——事实上,它们只是罗伯特的书和博客中表达的几十条原则中的五条。这五个特殊的主题贯穿了对模式和软件设计的讨论,所以,在我们深入设计模式之前(我知道你们都很渴望),我们将简要回顾一下 SOLID 原则是什么。
单一责任原则
假设你决定记下你最私密的想法。这本杂志有一个标题和许多条目。您可以如下建模:
public class Journal
{
private readonly List<string> entries = new List<string>();
// just a counter for total # of entries
private static int count = 0;
}
现在,您可以添加向日志添加条目的功能,以条目在日志中的序号为前缀。您还可以拥有删除条目的功能(在下面以一种非常简单的方式实现)。这很简单:
public void AddEntry(string text)
{
entries.Add($"{++count}: {text}");
}
public void RemoveEntry(int index)
{
entries.RemoveAt(index);
}
该日志现在可以用作
var j = new Journal();
j.AddEntry("I cried today.");
j.AddEntry("I ate a bug.");
将该方法作为Journal
类的一部分是有意义的,因为添加日志条目是日志实际需要做的事情。杂志的责任是记录条目,所以任何与之相关的事情都是公平的。
现在,假设您决定通过将日志保存到文件中来使其持久化。您将这段代码添加到Journal
类中:
public void Save(string filename, bool overwrite = false)
{
File.WriteAllText(filename, ToString());
}
这种方法是有问题的。日志的责任是保存日志条目,而不是将它们写到磁盘。如果您将持久性功能添加到Journal
和类似的类中,持久性方法的任何改变(比如,您决定写入云而不是磁盘)都需要在每个受影响的类中进行许多微小的改变。
我想在这里暂停一下,提出一个观点:如果可能的话,通常最好避免让你不得不在很多类中做很多微小的改变的架构。现在,它实际上取决于具体情况:如果你正在重命名一个在上百个地方使用的符号,我认为这通常是可以的,因为 ReSharper、Rider 或任何你使用的 IDE 实际上会让你执行一次重构,并让变化传播到每个地方。但是当你需要完全重做一个界面的时候…嗯,那会变成一个非常痛苦的过程!
因此,我们声明持久性是一个单独的关注点,最好在一个单独的类中表达。我们使用术语关注点分离(遗憾的是,缩写 SoC 已经被采用 1 )来讨论将代码按照功能划分到不同类的一般方法。在我们的例子中的持久性的情况下,我们会像这样将它外部化:
public class PersistenceManager
{
public void SaveToFile(Journal journal, string filename,
bool overwrite = false)
{
if (overwrite || !File.Exists(filename))
File.WriteAllText(filename, journal.ToString());
}
}
这正是我们所说的单一责任的含义:每个类只有一个责任,因此也只有一个改变的理由。只有在条目的内存存储方面需要做更多的事情时,才需要改变;例如,您可能希望每个条目都有一个时间戳作为前缀,因此您可以更改Add()
方法来做到这一点。另一方面,如果你想改变持久性机制,这将在PersistenceManager
中改变。
违反 SRP 的反模式 2 的极端例子被称为上帝对象。一个 God 对象是一个巨大的类,它试图处理尽可能多的问题,成为一个很难处理的巨大怪物。严格地说,您可以将任何规模的任何系统放入一个类中,但通常情况下,您最终会得到一个无法理解的混乱局面。对我们来说幸运的是,God 对象很容易被视觉或自动识别(只需计算成员函数的数量),由于持续集成和源代码控制系统,负责任的开发人员可以被快速识别并受到适当的惩罚。
开闭原理
假设我们在数据库中有一系列(完全假设的)产品。每个产品都有颜色和尺寸,定义如下:
public enum Color
{
Red, Green, Blue
}
public enum Size
{
Small, Medium, Large, Yuge
}
public class Product
{
public string Name;
public Color Color;
public Size Size;
public Product(string name, Color color, Size size)
{
// obvious things here
}
}
现在,我们希望为一组给定的产品提供一定的过滤功能。我们制作一个ProductFilter
服务类。为了支持按颜色过滤产品,我们实现如下:
public class ProductFilter
{
public IEnumerable<Product> FilterByColor
(IEnumerable<Product> products, Color color)
{
foreach (var p in products)
if (p.Color == color)
yield return p;
}
}
我们目前通过颜色过滤项目的方法很好,虽然当然可以通过使用语言集成查询(LINQ)来大大简化。因此,我们的代码投入生产,但不幸的是,过了一段时间,老板进来要求我们也实现按大小过滤。所以我们跳回ProductFilter.cs
,添加以下代码,并重新编译:
public IEnumerable<Product> FilterBySize
(IEnumerable<Product> products, Size size)
{
foreach (var p in products)
if (p.Size == size)
yield return p;
}
这感觉像是完全的复制,不是吗?为什么我们不写一个带谓词的通用方法呢?嗯,一个原因可能是不同形式的过滤可以以不同的方式完成:例如,一些记录类型可能被索引,需要以特定的方式进行搜索;有些数据类型适合在图形处理单元(GPU)上搜索,而有些则不适合。
此外,您可能希望限制可以过滤的标准。例如,如果您查看亚马逊或类似的在线商店,您只能根据有限的一组标准进行过滤。如果亚马逊发现,比如说,根据评论数量进行排序会影响底线,它可以增加或删除这些标准。
好了,我们的代码投入生产了,但是老板又一次回来告诉我们,现在需要同时使用尺寸和颜色进行搜索。那么,除了增加另一个功能,我们还能做什么呢?
public IEnumerable<Product> FilterBySizeAndColor(
IEnumerable<Product> products,
Size size, Color color)
{
foreach (var p in products)
if (p.Size == size && p.Color == color)
yield return p;
}
从前面的场景来看,我们想要的是实施开闭原则,该原则声明类型对扩展是开放的,但对修改是封闭的。换句话说,我们希望过滤是可扩展的(可能在不同的程序集中),而不必修改它(并重新编译已经工作并可能已经提供给客户机的东西)。
如何才能实现?嗯,首先我们概念上分开(SRP!)我们的过滤过程分为两个部分:一个过滤器(一个接受所有项目并只返回一些项目的构造)和一个规范(一个应用于数据元素的谓词)。
我们可以对规范接口做一个非常简单的定义:
public interface ISpecification<T>
{
bool IsSatisfied(T item);
}
在这个接口中,类型T
是我们选择的任何类型:它当然可以是Product
,但也可以是其他类型。这使得整个方法可以重用。
接下来,我们需要一种基于ISpecification<T>
的过滤方法;这是通过定义完成的,你猜对了,an IFilter<T>
:
public interface IFilter<T>
{
IEnumerable<T> Filter(IEnumerable<T> items,
ISpecification<T> spec);
}
同样,我们所做的只是为一个名为Filter()
的方法指定签名,该方法接受所有项目和一个规范,并只返回那些符合规范的项目。
基于上述数据,改进滤波器的实现非常简单:
public class BetterFilter : IFilter<Product>
{
public IEnumerable<Product> Filter(IEnumerable<Product> items,
ISpecification<Product> spec)
{
foreach (var i in items)
if (spec.IsSatisfied(i))
yield return i;
}
}
同样,您可以把传入的ISpecification<T>
看作是Predicate<T>
的强类型等价物,它有一组适合问题域的具体实现。
现在,这是最简单的部分。要制作滤色器,您需要制作一个ColorSpecification
:
public class ColorSpecification : ISpecification<Product>
{
private Color color;
public ColorSpecification(Color color)
{
this.color = color;
}
public bool IsSatisfied(Product p)
{
return p.Color == color;
}
}
有了这个规范,有了一个产品列表,我们现在可以对它们进行如下筛选:
var apple = new Product("Apple", Color.Green, Size.Small);
var tree = new Product("Tree", Color.Green, Size.Large);
var house = new Product("House", Color.Blue, Size.Large);
Product[] products = {apple, tree, house};
var pf = new ProductFilter();
WriteLine("Green products:");
foreach (var p in pf.FilterByColor(products, Color.Green))
WriteLine($" - {p.Name} is green");
前面的代码得到了“苹果”和“树”,因为它们都是绿色的。现在,到目前为止我们唯一没有实现的是搜索尺寸和颜色(或者,实际上,解释了如何搜索尺寸或颜色,或者混合不同的标准)。答案是你简单地做一个组合子。例如,对于逻辑 AND,您可以使其如下所示:
public class AndSpecification<T> : ISpecification<T>
{
private readonly ISpecification<T> first, second;
public AndSpecification(ISpecification<T> first, ISpecification<T> second)
{
this.first = first;
this.second = second;
}
public override bool IsSatisfied(T t)
{
return first.IsSatisfied(t) && second.IsSatisfied(t);
}
}
而现在,你可以在更简单的ISpecifications
的基础上自由创建复合条件。重用我们之前制定的green
规范,找到绿色的大东西现在就像
foreach (var p in bf.Filter(products,
new AndSpecification<Product>(
new ColorSpecification(Color.Green),
new SizeSpecification(Size.Large))))
{
WriteLine($"{p.Name} is large and green");
}
// Tree is large and green
这需要很多代码来做一些看似简单的事情,但好处是值得的。唯一真正烦人的部分是必须为AndSpecification
指定泛型参数——记住,与颜色/大小规格不同,组合子并不局限于Product
类型。
请记住,由于 C# 的强大功能,您可以简单地为两个ISpecification<T>
对象引入一个operator &
(重要的是:这里是单个&符号,&&
是副产品),从而使过滤过程由两个(或更多!)标准稍微简单一些……唯一的问题是我们需要从一个接口变成一个抽象类(随意删除名字中的前导I
)。
public abstract class ISpecification<T>
{
public abstract bool IsSatisfied(T p);
public static ISpecification<T> operator &(
ISpecification<T> first, ISpecification<T> second)
{
return new AndSpecification<T>(first, second);
}
}
如果现在避免为尺寸/颜色规格制造额外的变量,复合规格可以减少到单行 3 :
var largeGreenSpec = new ColorSpecification(Color.Green)
& new SizeSpecification(Size.Large);
自然,您可以通过在所有可能的规范对上定义扩展方法来将这种方法发挥到极致:
public static class CriteriaExtensions
{
public static AndSpecification<Product> And(this Color color, Size size)
{
return new AndSpecification<Product>(
new ColorSpecification(color),
new SizeSpecification(size));
}
}
随着后续的使用
var largeGreenSpec = Color.Green.And(Size.Large);
然而,这将需要一组所有可能的标准,这并不太现实,当然,除非您使用代码生成。遗憾的是,C# 中没有办法在一个enum Xxx
和一个XxxSpecification
之间建立隐式关系。
这是我们刚刚构建的整个系统的示意图:
所以,让我们回顾一下什么是 OCP,以及这个例子是如何执行它的。基本上,OCP 指出,你不应该需要回到你已经编写和测试的代码,并改变它。这正是这里正在发生的事情!我们创建了ISpecification<T>
和IFilter<T>
,从那时起,我们所要做的就是实现其中一个接口(不需要修改接口本身)来实现新的过滤机制。这就是“开放供扩展,封闭供修改”的含义
值得注意的一点是,只有在面向对象的范例中,才可能符合 OCP。例如,F# 的受歧视的联合从定义上来说不符合 OCP,因为不修改它们的原始定义就不可能扩展它们。
利斯科夫替代原理
以 Barbara Liskov 命名的 Liskov 替换原则指出,如果一个接口接受一个类型为Parent
的对象,那么它应该同样接受一个类型为type Child
的对象,而不破坏任何东西。我们来看一个 LSP 坏掉的情况。
这是一个长方形。它有宽度和高度,还有一堆计算面积的 getters 和 setters:
public class Rectangle
{
public int Width { get; set; }
public int Height { get; set; }
public Rectangle() {}
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public int Area => Width * Height;
}
假设我们做了一种特殊的Rectangle
叫做Square
。这个对象覆盖设置器来设置宽度和高度:
public class Square : Rectangle
{
public Square(int side)
{
Width = Height = side;
}
public new int Width
{
set { base.Width = base.Height = value; }
}
public new int Height
{
set { base.Width = base.Height = value; }
}
}
这种做法就是恶。您还看不到它,因为它看起来确实非常无辜:设置器只是简单地设置了两个维度(因此正方形始终是正方形),这可能会出错吗?好吧,假设我们引入一个利用Rectangle
的方法:
public static void UseIt(Rectangle r)
{
r.Height = 10;
WriteLine($"Expected area of {10*r.Width}, got {r.Area}");
}
如果与Rectangle
一起使用,这个方法看起来足够简单:
var rc = new Rectangle(2,3);
UseIt(rc);
// Expected area of 20, got 20
然而,无害的方法如果与Square
一起使用,可能会产生严重的反效果:
var sq = new Square(5);
UseIt(sq);
// Expected area of 50, got 100
前面的代码将公式Area = Width × Height
作为不变量。它获取宽度,将高度设置为 10,并正确地期望乘积等于计算的面积。但是用Square
调用前面的函数得到的值是 100 而不是 50。我相信你能猜到这是为什么。
所以这里的问题是,尽管UseIt()
很乐意接受任何一个Rectangle
类,但它却无法接受一个Square
,因为Square
内部的行为破坏了它的操作。那么,你会如何解决这个问题呢?嗯,一种方法是简单地弃用Square
类,并开始将某些Rectangles
作为特例。例如,您可以引入一个IsSquare
属性。
您可能还需要一种方法来检测Rectangle
实际上是一个正方形:
public bool IsSquare => Width == Height;
类似地,代替构造函数,你可以引入工厂方法(参见“工厂”一章)来构造矩形和正方形,并且有相应的名字(例如,NewRectangle()
和NewSquare()
),这样就不会有歧义。
就设置属性而言,在这种情况下,解决方案是引入一个统一的SetSize(width,height)
方法并完全移除Width/Height
设置器。这样,您就避免了通过 setter 设置高度的同时悄悄改变宽度的情况。
在我看来,这个矩形/正方形的挑战是一个极好的面试问题:它没有正确的答案,但允许许多解释和变化。
界面分离原理
哦,好吧,这里有另一个人为的例子,但仍然适合说明这个问题。假设您决定定义一台多功能打印机:一台可以打印、扫描以及传真文档的设备。所以你这样定义它:
class MyFavouritePrinter /* : IMachine */
{
void Print(Document d) {}
void Fax(Document d) {}
void Scan(Document d) {}
};
这很好。现在,假设您决定定义一个接口,该接口需要由计划制造多功能打印机的每个人来实现。因此,您可以在您最喜欢的 IDE 中使用 Extract Interface 函数,您将得到如下内容:
public interface IMachine
{
void Print(Document d);
void Fax(Document d);
void Scan(Document d);
}
这是一个问题。问题的原因是这个接口的一些实现者可能不需要扫描或传真,只需要打印。然而,您是在强迫他们实现那些额外的特性:当然,它们都可以是不可操作的,但是为什么要这么麻烦呢?
一个典型的例子是没有任何扫描或传真功能的老式打印机。在这种情况下实现IMachine
接口成为一个真正的挑战。这种情况下特别令人沮丧的是,没有正确的方式让事情不被实现——这实际上是接口分离不良的一个很好的标志。我的意思是,当然,你可以抛出一个异常,我们甚至有一个专门的异常,正是为了这个目的:
public class OldFashionedPrinter : IMachine
{
public void Print(Document d)
{
// yep
}
public void Fax(Document d)
{
throw new System.NotImplementedException();
}
public void Scan(Document d)
{
throw new System.NotImplementedException();
}
}
但是你还是把用户搞糊涂了!他们可以将OldFashionedPrinter.Fax()
视为 API 的一部分,所以他们认为这种类型的打印机也可以传真是情有可原的!所以你还能做什么?嗯,你可以把多余的方法留为 no-op(空),就像前面的Scan()
方法一样。同样,这种方法违反了最小惊奇原则:你的用户希望事情尽可能的可预测。无论是默认抛出的方法还是什么都不做的方法都不是最可预测的解决方案——即使您在文档中明确说明了这一点!
在编译时唯一可行的选择是将所有不必要的方法标记为过时的核心选项:
[Obsolete("Not supported", true)]
public void Scan(Document d)
{
throw new System.NotImplementedException();
}
如果有人试图使用OldFashionedPrinter.Scan()
,这将阻止编译。事实上,好的 ide 会提前意识到这一点,并且经常会在您调用该方法时删除它,以表明它不会工作。这种方法的唯一问题是它非常不通顺:这种方法不是真的过时了,而是没有实现。别再对客户撒谎了!
因此,接口分离原则建议您做的是拆分接口,以便实现者可以根据他们的需求进行挑选。由于打印和扫描是不同的操作(例如,扫描仪不能打印),我们为它们定义了单独的接口:
public interface IPrinter
{
void Print(Document d);
}
public interface IScanner
{
void Scan(Document d);
}
然后,打印机可以只实现和所需的功能,其他什么都不做:
public class Printer : IPrinter
{
public void Print(Document d)
{
// implementation here
}
}
类似地,如果我们想要实现复印机,我们可以通过实现IPrinter
和IScanner
接口来实现:
public class Photocopier : IPrinter, IScanner
{
public void Print(Document d) { ... }
public void Scan(Document d) { ... }
}
现在,如果我们真的想要一个多功能设备的专用接口,我们可以将其定义为上述接口的组合:
public interface IMultiFunctionDevice
: IPrinter, IScanner // also IFax etc.
{
// nothing here
}
当您为多功能设备创建一个类时,这是要使用的接口。例如,您可以使用简单的委托来确保Machine
重用由特定的IPrinter
和IScanner
提供的功能(这实际上是装饰模式的一个很好的例子):
public class MultiFunctionMachine : IMultiFunctionDevice
{
// compose this out of several modules
private IPrinter printer;
private IScanner scanner;
public MultiFunctionMachine(IPrinter printer, IScanner scanner)
{
this.printer = printer;
this.scanner = scanner;
}
public void Print(Document d)
{
printer.Print(d);
}
public void Scan(Document d)
{
scanner.Scan(d);
}
}
所以,简单重述一下,这里的想法是将复杂接口的各个部分分离成单独的接口,以避免强迫客户实现他们并不真正需要的功能。任何时候,当你为某个复杂的应用编写插件时,你会得到一个有 20 种令人困惑的方法的接口,要用各种各样的 no-ops 和return null
来实现,很可能 API 作者已经违反了 ISP。
参数对象
当我们谈论接口时,我们通常会谈论interface
关键字,但 ISP 的本质也可以应用于一个更加局部的现象:传统意义上的接口,例如,由构造函数公开的参数列表。
考虑一个(完全任意的)带有大量参数的构造函数的例子。这些参数中的大多数都有默认值,但有些没有:
public class Foo
{
public Foo(int a, int b, bool c = false, int d = 42, float e = 1.0f)
{
// meaningful code here
}
}
这里构造函数的接口的问题是,它向一个毫无戒心的客户端抛出了很多东西。如果客户必须提供参数a
、b
和e
,情况会变得更加滑稽,因为这样他们会不必要地重复一些默认设置。
在这种情况下,ISP 的核心原则(不要把所有东西都扔进一个接口)在这里也有意义,尽管原因不同。你需要提供一组合理的输入,让用户避免任何额外的麻烦。
任何有自尊的 IDE 都为您提供了参数对象重构功能——一种将所有参数放入一个类中并保留所有默认值的能力:
public class MyParams
{
public int a;
public int b;
public bool c = false;
public int d = 42;
public float e = 1.0f;
public MyParams(int a, int b)
{
this.a = a;
this.b = b;
}
}
然后这个参数对象将被传递到Foo
的构造函数中:
public Foo(MyParams myParams)
{
// meaningful work here
}
注意MyParams
是如何制作的:它有自己的构造函数,要求您初始化前两个参数,但它也公开了其他参数供您任意初始化。
我想说的是:原则和模式不一定要在宏观(类)尺度上运行——它们在微观尺度上运行也足够好。
从属倒置原则
依赖性反转原则的原始定义陈述如下 4 :
-
高层模块不应该依赖低层模块。两者都应该依赖于抽象。
这句话的基本意思是,如果您对日志感兴趣,您的报告组件不应该依赖于具体的
ConsoleLogger
,而是可以依赖于ILogger
接口。在这种情况下,我们认为报告组件是高级的(更接近于业务领域),而日志记录是一个基本问题(有点像文件 I/O 或线程,但不完全是),被认为是一个低级模块。 -
抽象不应该依赖于细节。细节应该依赖于抽象。
这再次重申了对接口或基类的依赖优于对具体类型的依赖。希望这种说法的真实性是显而易见的,因为这样的方法支持更好的可配置性和可测试性…特别是如果您正在使用一个好的框架来为您处理这些依赖性。
让我们来看一个 DIP 的例子。假设我们决定使用以下定义来模拟人与人之间的谱系关系:
public enum Relationship
{
Parent,
Child,
Sibling
}
public class Person
{
public string Name;
// DoB and other useful properties here
}
我们可以创建一个专门用于存储关系信息的(低级)类。它看起来会像下面这样:
public class Relationships // low-level
{
public List<(Person,Relationship,Person)> relations
= new List<(Person, Relationship, Person)>();
public void AddParentAndChild(Person parent, Person child)
{
relations.Add((parent, Relationship.Parent, child));
relations.Add((child, Relationship.Child, parent));
}
}
现在,假设我们想对我们捕捉到的关系做一些研究。例如,为了找到 John 的所有孩子,我们创建以下(高级)类:
public class Research
{
public Research(Relationships relationships)
{
// high-level: find all of John's children
var relations = relationships.Relations;
foreach (var r in relations
.Where(x => x.Item1.Name == "John"
&& x.Item2 == Relationship.Parent))
{
WriteLine($"John has a child called {r.Item3.Name}");
}
}
}
这里说明的方法直接违反了 DIP,因为高级模块Research
直接依赖于低级模块Relationships
。为什么这样不好?因为Research
直接依赖于Relationships
的数据存储实现:你可以看到它在迭代元组列表。如果您以后想要改变Relationships
的底层存储,也许是通过将它从元组列表移动到适当的数据库,该怎么办呢?你不能,因为你有依赖它的高级模块。
那么我们想要什么?我们希望我们的高级模块依赖于一个抽象,用 C# 术语来说,这意味着依赖于某种接口。但是我们还没有界面!没问题,让我们创建一个:
public interface IRelationshipBrowser
{
IEnumerable<Person> FindAllChildrenOf(string name);
}
这个接口有一个单一的方法,可以通过名字找到某个人的所有孩子。我们希望像Relationships
这样的低级模块能够实现这个方法,从而保持其实现细节的私密性:
public class Relationships : IRelationshipBrowser // low-level
{
// no longer public!
private List<(Person,Relationship,Person)> relations
= new List<(Person, Relationship, Person)>();
public IEnumerable<Person> FindAllChildrenOf(string name)
{
return relations
.Where(x => x.Item1.Name == name
&& x.Item2 == Relationship.Parent)
.Select(r => r.Item3);
}
}
这是我们的Research
模块可以依赖的东西!我们可以将一个IRelationshipBrowser
注入到它的构造函数中,并安全地执行研究,而无需深入底层模块的内部:
public Research(IRelationshipBrowser browser)
{
foreach (var p in browser.FindAllChildrenOf("John"))
{
WriteLine($"John has a child called {p.Name}");
}
}
请注意,DIP 并不等同于依赖注入,这本身就是另一个重要的话题。DI 可以通过简化依赖关系的表示来促进 DIP 的应用,但是这两个是不同的概念。
二、函数视角
C# 和 F# 语言都支持函数范式。这两种语言都可以声称是多方面的,因为它们完全支持 OOP 和函数式编程,尽管 F# 更倾向于“函数优先”的思想,为了完整性还添加了面向对象,而在 C# 中,函数式编程方面的集成似乎更加和谐。
这里,我们将非常粗略地看一下 C# 和 F# 语言中的函数式编程。有些材料你可能已经很熟悉了;在这种情况下,请随意跳过这一部分。
函数基础
首先,关于符号的说明。在本书中,我交替使用了方法和函数这两个词来表示同一个东西:一个接受零个或多个输入并拥有零个或多个输出(返回值)的自包含操作。当在 C# 领域工作时,我将使用单词方法,同样,当处理函数领域时,我将使用单词函数。
在 C# 中,函数不是独立的:它们必须是某个类的成员。例如,要定义整数加法,必须将Add()
方法打包到某个类中(姑且称之为Ops
):
class Ops
{
public static int Add(int a, int b)
{
return a + b;
}
}
这个函数应该被称为Ops.Add()
,但是如果你使用 C# 的import static
指令,你可以把它简化为Add()
。尽管如此,这仍然是数学家们的一个特别痛点,因为即使你加上using``static System.Math
;对于项目中的单个文件,您仍然不得不对像Sin()
这样的函数使用大写名称——这不是一个理想的情况!
在 F# 中,方法完全不同。前面的加法函数可以定义为
let add a b = a + b
看起来好像发生了一些奇迹:我们没有定义一个类,也没有指定参数的数据类型。但是,如果您查看 C# 的等效代码,您会看到类似下面这样的内容:
[CompilationMapping]
public static class Program
{
[CompilationArgumentCounts(new int[] {1, 1})]
public static int add(int a, int b)
{
return a + b;
}
}
正如您可能已经猜到的,静态类Program
的名称来自代码所在的文件的名称(在本例中是Program.fs
)。争论的类型被选择作为一个猜测。如果我们用不同的参数类型添加一个调用会怎么样?
let ac = add "abra" "cadabra"
printfn "%s" ac
当然,前面的代码打印了“abracadabra ”,但是有趣的是生成的代码…你已经猜到了,不是吗?
[CompilationArgumentCounts(new int[] {1, 1})]
public static string add(string a, string b)
{
return a + b;
}
这种可能性的原因被称为类型推断:编译器计算出你在一个函数中实际使用的类型,并试图通过构造一个具有相应参数的函数来适应。可悲的是,这不是一个银弹。例如,如果您随后添加另一个调用——这一次使用整数——它将失败:
let n = add 1 2
// Error: This expression was expected to have type "string" but here has type "int"
C# 中的函数文字
在类内部定义函数并不总是很方便:有时你想在你需要的地方创建一个函数,也就是在另一个函数中。这些类型的函数被称为匿名,因为它们没有持久的名字;相反,函数存储在委托中。
C# 2.0 定义匿名函数的老式方法是使用一个delegate
关键字,如下所示:
BinaryOperation multiply = delegate(int a, int b) { return a * b; };
int x = multiply(2, 3); // 6
当然,从 C# 3.0 开始,我们有了一种更方便的方式来定义同样的事情:
BinaryOperation multiply = (a, b) => { return a * b; };
注意a
和b
旁边的类型信息消失了:这又是一次类型推理!
最后,从 C# 6 开始,我们有了表达式体成员,它允许我们在单语句求值中省略关键字return
,将定义缩短为:
BinaryOperation multiply = (a, b) => a * b;
当然,如果你不把匿名函数存储在某个地方,匿名函数是没有用的,一旦你存储了某个东西,这个东西就需要一个类型。幸运的是,我们也有这种类型。
在 C# 中存储函数
函数式编程的一个关键特性是能够引用函数并通过引用调用它们。在 C# 中,最简单的方法是使用委托。
委托类型对于函数就像类对于实例一样。给定前面的Add()
函数,我们可以定义一个类似如下的委托:
public delegate int BinaryOperation(int a, int b);
委托不必存在于 C# 类中:它可以存在于命名空间级别。所以,在某种程度上,你可以把它当作一个类型声明。当然,你也可以把一个委托放入一个类中,在这种情况下,你可以把它当作一个嵌套的类型声明。
有了这样的委托,我们可以在变量中存储对函数的引用:
BinaryOperation op = Ops.Add;
int x = op(2, 3);
与类的实例相比,这里需要注意的是——委托实例不仅知道需要调用哪个函数的,而且还知道应该调用这个方法的类的实例。这种区别非常重要,因为它允许我们区分静态和非静态函数。
具有相同签名的任何其他函数也可以分配给该委托,而不管谁是其逻辑所有者。例如,您可以在任何地方定义一个名为Subtract()
的函数,并将它分配给代理。这包括将其定义为普通的成员函数
class Program
{
static int Subtract(int a, int b) => a - b;
static void Main(string[] args)
{
BinaryOperation op = Subtract;
int x = op(10, 2); // 8
}
}
但是,它很容易成为局部(嵌套)函数:
static void Main(string[] args)
{
int Multiply(int a, int b) => a * b;
BinaryOperation op = Multiply;
int x = op(10, 2); // 20
}
甚至匿名委托或 lambda 函数:
void SomeMethod()
{
BinaryOperation op = (a, b) => a / b;
int x = op(10, 2); // 5
}
现在,重要的部分来了,注意:在大多数情况下,定义你自己的委托是不必要的。为什么呢?因为。NET 基础类库(BCL)带有长度多达 16 个参数的预定义委托(C# 没有可变模板 1 ),涵盖了你可能感兴趣的大多数情况。
Action
委托代表一个不返回值的函数(是void
)。它的泛型参数与该函数采用的参数类型相关。所以你可以这样写
Action doStuff = () => Console.WriteLine("doing stuff!");
doStuff(); // prints "doing stuff!"
Action<string> printText = x => Console.WriteLine(x);
printText("hello"); // prints "hello"
需要Action
的通用参数来指定参数类型。如果一个函数没有参数,就使用一个非泛型的Action
。
如果您的函数确实需要返回值,那么您可以使用预定义的委托Func<T1, T2, ..., TR>
。这总是通用的,其中 TR 具有返回值的类型。在我们的例子中,我们可以将二元运算定义为
Func<int, int, int> mul = Multiply;
// or
Func<int, int, int> div = (a, b) => a / b;
总之,Action
和Func
涵盖了你可能遇到的代表的所有现实需求。遗憾的是,这些委托本身不能通过类型推断来推断。换句话说,你不能写
var div = (int a, int b) => a / b;
期望div
是Func<int, int, int>
类型——这根本无法编译。
F# 中的函数文字
在 F# 中,定义函数的过程要协调得多。例如,在全局范围内定义变量的语法和定义方法的语法之间没有真正的区别。
let add a b = a + b
[<EntryPoint>]
let main argv =
let z = add
let result = z 1 2
0
然而,这段代码的反编译结果太可怕了,不能在这里展示。重要的是要意识到 F# 确实,事实上,自动将你的函数映射到一个类型,而不需要任何额外的提示。但是它没有将它映射到一个Func
委托,而是将其映射到自己的类型FSharpFunc
。
为了理解FSharpFunc's
存在的原因,我们需要理解一种叫做的东西在讨好。Currying(与印度食物无关)是定义和调用函数的一种完全不同的方法。还记得我们的 F# 函数add a b
变成了 C# 的等价函数int add
( int a, int b
)?让我给你看一个非常相似的情况,这种情况不会发生:
let printValues a b =
printf "a = %i; b = %i" a b
这编译成什么?好吧,在不显示额外的 gore 级别的情况下,编译器生成了一个继承自FSharpFunc<int
、Unit>
( Unit
可以被视为 F# 的等同物void
)的类,该类碰巧还有另一个FSharpFunc<int, Unit>
作为可调用成员。为什么呢?!?
为了简单起见,您的 printValues 调用实际上变成了类似于
let printValues a =
let printValues@10-1 b =
printf "a = %i; b = %i" a b
return printValues@10-1
所以,用简化的 C# 术语来说,我们没有让函数像printValues(a,b)
一样可调用,而是让函数像printValues(a)(b)
一样可调用。
这样有什么好处?好吧,让我们回到我们的add
函数:
let add a b = a + b
我们现在可以使用这个函数来定义一个名为addFive
的新函数,它给一个给定的数加 5。该函数可定义如下:
let addFive x = add 5 x
我们现在可以称之为
let z = addFive 5 // z = 10
有了这个定义,编译器就可以将对add x y
的任何调用表示为与add(x)(y)
等价。但是add(x)
(没有y
)已经被预先打包成一个独立的FSharpFunc<int,int>
,它本身产生一个函数,该函数接受一个y
并将它添加到结果中。因此,addFive
的实现可以重用这个函数,而不需要再派生任何对象!
现在我们回到为什么 F# 使用FSharpFunc
而不是Func
的问题。答案是……继承!由于参数的调用不仅涉及单个函数调用,还涉及整个调用链,所以组织这个调用链的一个真正有用的方法是使用良好的老式继承。
作文
F# 有特殊的语法来一个接一个地调用几个函数。在 C# 中,如果你需要取值x
并应用于函数g
和f
,你可以简单地写为f(g(x))
。在 F# 中,可能性更有趣。
让我们实际看看如何定义和使用这些函数。我们将考虑两个函数的连续应用,一个是把一个数加 5,另一个是把它加倍。
let addFive x = x + 5
let timesTwo x = x * 2
printfn "%i" (addFive (timesTwo 3)) // 11
如果你想一想,前面的数字 3 经历了一个操作管道:首先,它被馈送到timesTwo
,然后被馈送到addFive
。这种管道的概念通过 F# 前向管道和后向管道操作符在代码中表示,可用于实现前面的操作,如下所示:
printfn "%i" (3 |> timesTwo |> addFive)
printfn "%i" (addFive <| (timesTwo <| 3))
注意,虽然向前操作符|>
的例子非常简洁,但是向后操作符<|
就不那么简洁了。由于关联性规则,额外的括号是必需的。
我们可能想要定义一个新的函数,将timesTwo
后跟addFive
应用于任何参数。当然,你可以简单地将其定义为
let timesTwoAddFive x =
x |> timesTwo |> addFive
但是,F# 还定义了函数组合运算符>>
(向前)和<<
(向后),用于将几个函数组合成一个函数。自然,他们的论点必须一致。
let timesTwoAddFive = timesTwo >> addFive
printfn "%i" timesTwoAddFive 3 // 11
函数相关的语言特性
虽然不是函数式编程讨论的核心,但是某些特性经常伴随着它。这包括以下内容:
-
尾部递归有助于以递归方式定义算法。
-
有区别的联合允许用原始存储机制非常快速地定义相关类型。可悲的是,这一特性打破了 OCP,因为在不改变其原始定义的情况下,不可能扩展一个受歧视的联盟。
-
模式匹配扩展了
if
语句的范围,能够与模板匹配。这在 F# 中无处不在(对于列表、记录类型等等),现在也出现在 C# 中。 -
函数列表是一个独特的特性(与
List<T>
完全无关),利用了模式匹配和尾部递归。
这些特性与函数式编程范例相结合,可以帮助实现本书中描述的一些模式。
三、构建器
构建器模式与复杂的对象的创建有关,也就是说,不能在一行构造器调用中构建的对象。这些类型的对象本身可能由其他对象组成,并且可能包含不太明显的逻辑,因此需要一个专门用于对象构造的单独组件。
我想值得预先注意的是,虽然我说过构建器关注的是复杂的对象,但我们将看一个相当简单的例子。这样做纯粹是为了优化空间,因此领域逻辑的复杂性不会影响读者理解模式的实际实现。
方案
假设我们正在构建一个呈现网页的组件。一个页面可能只包含一个段落(让我们暂时忘记所有典型的 HTML 陷阱),要生成它,您可能需要编写如下代码:
var hello = "hello";
var sb = new StringBuilder();
sb.Append("<p>");
sb.Append(hello);
sb.Append("</p>");
WriteLine(sb);
这是一些严重的过度工程,Java 风格,但它是一个很好的例子,说明了我们已经在。NET 框架:StringBuilder
!当然,StringBuilder
是一个独立的组件,用于连接字符串。它有一些实用的方法,比如AppendLine()
,所以你可以添加文本和换行符(如Enrivonment.NewLine
)。但是StringBuilder
真正的好处是,与导致大量临时字符串的字符串连接不同,它只是分配一个缓冲区,并用追加的文本填充它。
那么,我们尝试输出一个简单的无序(项目符号)列表,其中有两项包含单词 hello 和 world 怎么样?一个非常简单的实现可能如下所示:
var words = new[] { "hello", "world" };
sb.Append("<ul>");
foreach (var word in words)
{
sb.AppendFormat("<li>{0}</li>", word);
}
sb.Append("</ul>");
WriteLine(sb);
这实际上给了我们想要的东西,但是这种方法不太灵活。我们如何将这个列表从项目符号列表变成编号列表呢?在列表被创建后,我们如何添加另一个项目?显然,在我们这个严格的方案中,一旦StringBuilder
被初始化,这是不可能的。
因此,我们可以走 OOP 路线,定义一个HtmlElement
类来存储关于每个 HTML 标签的信息:
class HtmlElement
{
public string Name, Text;
public List<HtmlElement> Elements = new List<HtmlElement>();
private const int indentSize = 2;
public HtmlElement() {}
public HtmlElement(string name, string text)
{
Name = name;
Text = text;
}
}
这个类模拟了一个单独的 HTML 标签,它有一个名字,也可以包含文本或者一些孩子,这些孩子本身就是HtmlElement
的。使用这个方法,我们现在可以用一种更合理的方式创建我们的列表:
var words = new[] { "hello", "world" };
var tag = new HtmlElement("ul", null);
foreach (var word in words)
tag.Elements.Add(new HtmlElement("li", word));
WriteLine(tag); // calls tag.ToString()
这工作得很好,给了我们一个更可控的、OOP 驱动的项目列表的表示。它还极大地简化了其他操作,如删除条目。但是构建每个HtmlElement
的过程不是很方便,特别是如果这个元素有子元素或者有一些特殊的需求。因此,我们转向构建器模式。
简单生成器
Builder 模式只是试图将对象的分段构造外包给一个单独的类。我们的第一次尝试可能会产生这样的结果:
class HtmlBuilder
{
protected readonly string rootName;
protected HtmlElement root = new HtmlElement();
public HtmlBuilder(string rootName)
{
this.rootName = rootName;
root.Name = rootName;
}
public void AddChild(string childName, string childText)
{
var e = new HtmlElement(childName, childText);
root.Elements.Add(e);
}
public override string ToString() => root.ToString();
}
这是一个构建 HTML 元素的专用组件。构建器的构造器接受一个rootName
,它是正在构建的根元素的名称:如果我们正在构建一个无序列表,它可以是"ul"
,如果我们正在创建一个段落,它可以是"p"
,等等。在内部,我们将根存储为一个HtmlElement
,并在构造函数中赋予它的Name
。但是我们也保持rootName
不变,所以如果我们想的话,我们可以在以后重置构建器。
AddChild()
方法是用于向当前元素添加更多子元素的方法,每个子元素被指定为一个名称-文本对。它可以按如下方式使用:
var builder = new HtmlBuilder("ul");
builder.AddChild("li", "hello");
builder.AddChild("li", "world");
WriteLine(builder.ToString());
你会注意到,此时,AddChild()
方法是void
-返回。我们可以使用返回值做很多事情,但返回值最常见的用途之一是帮助我们构建一个流畅的界面。
流畅的构建器
让我们将AddChild()
的定义更改如下:
public HtmlBuilder AddChild(string childName, string childText)
{
var e = new HtmlElement(childName, childText);
root.Elements.Add(e);
return this;
}
通过返回对构建器本身的引用,现在可以链接构建器调用。这就是所谓的流畅界面:
var builder = new HtmlBuilder("ul");
builder.AddChild("li", "hello").AddChild("li", "world");
WriteLine(builder.ToString());
返回this
的“一个简单的技巧”允许您构建接口,将几个操作塞进一个语句中。注意StringBuilder
本身也公开了一个 fluent 接口。流畅的界面通常很好,但是制作使用它们的装饰器(例如,使用自动化工具,如 ReSharper 或 Rider)可能是个问题——我们稍后会遇到这个问题。
传达意图
我们为 HTML 元素实现了一个专用的构建器,但是我们类的用户如何知道如何使用它呢?一个想法是简单地强迫他们在构建一个对象时使用构建器。你需要做的是:
class HtmlElement
{
protected string Name, Text;
protected List<HtmlElement> Elements = new List<HtmlElement>();
protected const int indentSize = 2;
// hide the constructors!
protected HtmlElement() {}
protected HtmlElement(string name, string text)
{
Name = name;
Text = text;
}
// factory method
public static HtmlBuilder Create(string name) => new HtmlBuilder(name);
}
我们的方法是双管齐下的。首先,我们隐藏了所有的构造函数,所以它们不再可用。我们还隐藏了构建器本身的实现细节,这是我们以前没有做过的。然而,我们已经创建了一个工厂方法(这是一个我们将在后面讨论的设计模式),用于从HtmlElement
中创建一个构建器。这也是一个静态方法!下面是如何使用它的方法:
var builder = HtmlElement.Create("ul");
builder.AddChild("li", "hello")
.AddChild("li", "world");
WriteLine(builder);
在前面的例子中,我们是强迫客户端使用静态Create()
方法,因为,嗯,确实没有其他方法来构造HtmlElement
——毕竟,所有的构造器都是protected
。所以客户机创建了一个HtmlBuilder
,然后被迫在对象的构造中与它交互。清单的最后一行只是打印正在构造的对象。
但是我们不要忘记,我们的最终目标是建造一个HtmlElement
,到目前为止我们还没有办法实现它!因此,锦上添花可以是构建器上的implicit operator HtmlElement
的实现,以产生最终值:
protected HtmlElement root = new HtmlElement();
public static implicit operator HtmlElement(HtmlBuilder builder)
{
return builder.root;
}
运算符的添加允许我们编写以下内容:
HtmlElement root = HtmlElement
.Create("ul")
.AddChildFluent("li", "hello")
.AddChildFluent("li", "world");
WriteLine(root);
遗憾的是,没有办法明确地告诉其他用户以这种方式使用 API。希望对构造函数的限制以及静态Create()
方法的出现鼓励用户使用构造函数,但是,除了操作符,给HtmlBuilder
本身添加一个相应的Build()
函数也是有意义的:
public HtmlElement Build() => root;
复合助洗剂
让我们通过一个使用多个构建器来构建一个对象的例子来继续讨论构建器模式。这种场景适用于构建过程非常复杂的情况,以至于构建者本身受到单一责任原则的约束,并且需要被分割成更小的部分。
假设我们决定记录一个人的一些信息:
public class Person
{
// address
public string StreetAddress, Postcode, City;
// employment info
public string CompanyName, Position;
public int AnnualIncome;
}
Person
有两个方面:他们的地址和就业信息。如果我们想为每一个都有单独的构建器,那该怎么办呢——我们如何提供最方便的 API 呢?为此,我们将构建一个复合构建器。这个构造并不简单,所以要注意:即使我们需要两个独立的构造器来处理工作和地址信息,我们也会产生不少于三个不同的类。
我们称第一节课为PersonBuilder
:
public class PersonBuilder
{
// the object we're going to build
protected Person person; // this is a reference!
public PersonBuilder() => person = new Person();
protected PersonBuilder(Person person) => this.person = person;
public PersonAddressBuilder Lives => new PersonAddressBuilder(person);
public PersonJobBuilder Works => new PersonJobBuilder(person);
public static implicit operator Person(PersonBuilder pb)
{
return pb.person;
}
}
这比我们之前的简单构建器要复杂得多,所以让我们依次讨论每个成员:
-
引用
person
是对正在构建的对象的引用。这个字段被标记为protected
,这是为子构建器特意做的。值得注意的是,这种方法只对引用类型有效——如果person
是一个struct
,我们将会遇到不必要的重复。 -
Lives
和Works
是返回构建器方面的属性:分别初始化地址和雇佣信息的子构建器。 -
是我们以前用过的一个技巧。
需要注意的非常重要的一点是构造函数:我们只在公共的、无参数的构造函数中这样做,而不是到处都用一个new Person()
初始化person
引用。还有另一个构造函数接受引用并保存它——这个构造函数被设计为由继承者使用,而不是由客户端使用,这就是它受到保护的原因。这样设置的原因是,每次使用构建器时,即使使用了子构建器,也只能实例化一次Person
。
现在,让我们来看看子构建器类的实现:
public class PersonAddressBuilder : PersonBuilder
{
public PersonAddressBuilder(Person person) : base(person)
{
this.person = person;
}
public PersonAddressBuilder At(string streetAddress)
{
person.StreetAddress = streetAddress;
return this;
}
public PersonAddressBuilder WithPostcode(string postcode)
{
person.Postcode = postcode;
return this;
}
public PersonAddressBuilder In(string city)
{
person.City = city;
return this;
}
};
如你所见,PersonAddressBuilder
为建立一个人的地址提供了一个流畅的界面。注意,它实际上是从PersonBuilder
继承了(意味着它获得了Lives
和Works
属性)。它有一个构造器,接受并存储对正在被构造的对象的引用,所以当你使用这些子构造器时,你总是只处理一个Person
的实例——你不会意外地产生多个实例。调用基本构造函数是关键的——如果不是,子构建器将自动调用无参数构造函数,导致额外Person
实例的不必要实例化。
正如您所猜测的,PersonJobBuilder
是以相同的方式实现的,所以我在这里省略了它。
现在,您期待已久的时刻到了——这些建筑商的一个实例:
var pb = new PersonBuilder();
Person person = pb
.Lives
.At("123 London Road")
.In("London")
.WithPostcode("SW12BC")
.Works
.At("Fabrikam")
.AsA("Engineer")
.Earning(123000);
WriteLine(person);
// StreetAddress: 123 London Road, Postcode: SW12BC, City: London,
// CompanyName: Fabrikam, Position: Engineer, AnnualIncome: 123000
你能看到这里发生了什么吗?我们创建一个构建器,然后使用Lives
属性得到一个PersonAddressBuilder
,但是一旦我们完成了地址信息的初始化,我们只需调用Works
并切换到使用一个PersonJobBuilder
来代替。如果你需要我们刚才所做的直观演示,这并不复杂:
当我们完成构建过程时,我们使用与之前相同的隐式转换技巧来将正在构建的对象作为Person
。或者,您可以调用Build()
来获得相同的结果。
这种方法有一个相当明显的缺点:它不可扩展。一般来说,一个基类知道自己的子类是一个坏主意,然而这正是这里所发生的—PersonBuilder
通过特殊的 API 公开自己的子类来知道它们。如果你想有一个额外的子建造者(比如说,一个PersonEarningsBuilder
),你必须打破 OCP,直接编辑PersonBuilder
;你不能简单地子类化它来添加一个接口成员。
构建器参数
正如我所演示的,强制客户端使用构建器而不是直接构造对象的唯一方法是使对象的构造函数不可访问。但是,在某些情况下,您希望从一开始就明确地强制用户与构建器进行交互,甚至可能隐藏他们实际构建的对象。
例如,假设您有一个用于发送电子邮件的 API,其中每封电子邮件的内部描述如下:
public class Email
{
public string From, To, Subject, Body;
// other members here
}
注意,我在这里说的是内部的——你不想让用户直接与这个类交互,可能是因为其中存储了一些额外的服务信息。保持它的公共性是很好的,只要你不公开允许客户端直接发送Email
的 API。电子邮件的某些部分(如Subject
)是可选的,所以对象不必完全指定。
您决定实现一个流畅的构建器,人们将使用它在幕后构建一个Email
。它可能如下所示:
public class EmailBuilder
{
private readonly Email email;
public EmailBuilder(Email email) => this.email = email;
public EmailBuilder From(string from)
{
email.From = from;
return this;
}
// other fluent members here
}
现在,为了强制客户端只使用构建器来发送电子邮件,您可以实现如下的邮件服务:
public class MailService
{
public class EmailBuilder { ... }
private void SendEmailInternal(Email email) {}
public void SendEmail(Action<EmailBuilder> builder)
{
var email = new Email();
builder(new EmailBuilder(email));
SendEmailInternal(email);
}
}
如您所见,客户端应该使用的SendEmail()
方法接受一个函数,而不仅仅是一组参数或一个预先打包的对象。这个函数接受一个EmailBuilder
,然后使用构建器来构建消息体。一旦完成,我们使用MailService
的内部机制来处理一个完全初始化的Email
。
您会注意到这里有一个巧妙的花招:构建器不是在内部存储对电子邮件的引用,而是在构造函数参数中获取该引用。我们这样实现它的原因是为了让EmailBuilder
不必在其 API 的任何地方公开暴露一个Email
。
从客户的角度来看,这个 API 的用法如下:
var ms = new MailService();
ms.SendEmail(email => email.From("foo@bar.com")
.To("bar@baz.com")
.Body("Hello, how are you?"));
简而言之,构建器参数方法迫使 API 的消费者使用构建器,不管他们喜不喜欢。我们使用的这个Action
技巧确保了客户端有办法接收已经初始化的构建器对象。
带有递归泛型的生成器扩展
一个有趣的问题是继承的问题,这个问题不仅影响到 fluent 构建器,而且影响到任何带有 fluent 接口的类。一个流利的构建者从另一个流利的构建者那里继承有可能吗(也是现实的)?是的,但是不容易。
问题就在这里。假设您从以下(非常简单的)想要构建的对象开始:
public class Person
{
public string Name;
public string Position;
}
您创建了一个基类构建器来帮助构建Person
对象:
public abstract class PersonBuilder
{
protected Person person = new Person();
public Person Build()
{
return person;
}
}
接着是一个指定Person
名称的专用类:
public class PersonInfoBuilder : PersonBuilder
{
public PersonInfoBuilder Called(string name)
{
person.Name = name;
return this;
}
}
这是可行的,绝对没有问题。但是现在,假设我们决定子类化PersonInfoBuilder
来指定雇佣信息。您可能会这样写:
public class PersonJobBuilder : PersonInfoBuilder
{
public PersonJobBuilder WorksAsA(string position)
{
person.Position = position;
return this;
}
}
可悲的是,我们现在破坏了流畅的界面,使整个设置不可用:
var me = Person.New
.Called("Dmitri") // returns PersonInfoBuilder
.WorksAsA("Quant") // will not compile
.Build();
为什么前面的代码无法编译?很简单:Called()
返回this
,是一个PersonInfoBuilder
类型的对象;那个对象根本没有WorksAsA()
方法!
您可能认为这种情况是没有希望的,但事实并非如此:您可以在考虑继承的情况下设计流畅的 API,但这会有点棘手。让我们看看重新设计PersonInfoBuilder
类会涉及到什么。这是它的新化身:
public class PersonInfoBuilder<SELF> : PersonBuilder
where SELF : PersonInfoBuilder<SELF>
{
public SELF Called(string name)
{
person.Name = name;
return (SELF) this;
}
}
如果您不熟悉递归泛型,前面的代码可能会让人不知所措,所以让我们讨论一下我们实际做了什么以及为什么。
首先,我们本质上引入了一个新的通用参数,SELF
。更让人好奇的是,这个SELF
被指定为PersonInfoBuilder<SELF>
的传承人;换句话说,该类的泛型参数需要从这个确切的类继承。这看起来很疯狂,但实际上是在 C# 中进行 CRTP 式继承的一个非常流行的技巧。本质上,我们正在实施一个继承链:我们说只有当Foo
从Bar
派生出来的时候Foo<Bar>
才是一个可接受的专门化,其他所有情况都不符合where
约束。
流畅接口继承中最大的问题是能够返回对你当前所在类的引用,即使你正在调用一个基类的流畅接口成员。有效传播这一点的唯一方法是拥有一个贯穿整个继承层次结构的通用参数(SELF
)。
为了理解这一点,我们还需要看看PersonJobBuilder
:
public class PersonJobBuilder<SELF>
: PersonInfoBuilder<PersonJobBuilder<SELF>>
where SELF : PersonJobBuilder<SELF>
{
public SELF WorksAsA(string position)
{
person.Position = position;
return (SELF) this;
}
}
看它的基类!它不像以前那样只是一辆普通的PersonInfoBuilder
,而是一辆PersonInfoBuilder<PersonJobBuilder<SELF>>
!因此,当我们从一个PersonInfoBuilder
继承时,我们将它的SELF
设置为PersonJobBuilder<SELF>
,这样它的所有流畅接口都返回正确的类型,而不是只是所属类的类型。
这有道理吗?如果没有,花点时间再看一遍源代码。在这里,我们来测试一下你的理解:假设我引入另一个名为DateOfBirth
的成员和一个对应的PersonDateOfBirthBuilder
,它会从哪个类继承?
如果你回答了
PersonInfoBuilder<PersonJobBuilder<PersonBirthDateBuilder<SELF>>>
那你就错了,但我不能责怪你的尝试。想想看:PersonJobBuilder
已经是的一个PersonInfoBuilder
了,所以这个信息不需要作为继承类型列表的一部分被显式地重述。相反,您应该按如下方式定义生成器:
public class PersonBirthDateBuilder<SELF>
: PersonJobBuilder<PersonBirthDateBuilder<SELF>>
where SELF : PersonBirthDateBuilder<SELF>
{
public SELF Born(DateTime dateOfBirth)
{
person.DateOfBirth = dateOfBirth;
return (SELF)this;
}
}
我们的最后一个问题是:我们如何实际构建这样一个生成器,考虑到它总是采用一个通用的参数。嗯,恐怕你现在需要一个新的型,而不仅仅是一个变量。因此,例如,Person.New
(开始构造过程的属性)的实现可以如下实现:
public class Person
{
public class Builder : PersonJobBuilder<Builder>
{
internal Builder() {}
}
public static Builder New => new Builder();
// other members omitted
}
这可能是最烦人的实现细节:事实上,你需要有一个递归泛型类型的非泛型继承才能使用它。
也就是说,将所有东西放在一起,您现在可以使用构建器,利用继承链中的所有方法:
var builder = Person.New
.Called("Natasha")
.WorksAsA("Doctor")
.Born(new DateTime(1981, 1, 1));
惰性函数生成器
前面使用递归泛型的例子需要做大量的工作。一个合理的问题是:继承应该被用来扩展构建器吗?毕竟,我们可以使用扩展方法来代替。
如果我们采用函数式方法,实现会变得简单很多,不需要递归泛型。让我们再次构建一个定义如下的Person
类:
public class Person
{
public string Name, Position;
}
这一次,我们将定义一个惰性构建器,它只在调用其Build()
方法时构造对象。在此之前,它将简单地保存一个在构建对象时需要执行的Action
列表:
public sealed class PersonBuilder
{
private readonly List<Func<Person, Person>> actions =
new List<Func<Person, Person>>();
public PersonBuilder Do(Action<Person> action)
=> AddAction(action);
public Person Build()
=> actions.Aggregate(new Person(), (p, f) => f(p));
private PersonBuilder AddAction(Action<Person> action)
{
actions.Add(p => { action(p); return p; });
return this;
}
}
想法很简单:我们不需要一调用任何构建器方法就修改可变的“构造中的对象”,而是简单地存储一个每当有人调用Build()
时需要应用于对象的动作列表。但是在我们的实现中还有额外的复杂性。
第一个是对人采取的动作,虽然作为一个Action<T>
参数,但实际上是作为一个Func<T,T>
存储的。背后的动机是提供这个流畅的接口,我们允许Build()
内部的Aggregate()
调用正确工作。当然,我们可以用一辆老式的ForEach()
来代替。
第二个复杂因素是,为了允许符合 OCP 标准的可扩展性,我们真的不想将actions
公开为公共成员,因为这将允许列表上太多的操作(例如,任意移除),我们不一定希望在将来向扩展该构建器的任何人公开这些操作。相反,我们只公开一个操作,Do()
,它允许您指定要对正在构建的对象执行的操作。然后,该操作被添加到整个操作集中。
在这个范例下,我们现在可以给这个构建器一个具体的方法来指定一个Person
的名字:
public PersonBuilder Called(string name)
=> Do(p => p.Name = name);
但是现在,由于构建器的构造方式,我们可以使用扩展方法而不是继承来为构建器提供额外的功能,例如指定一个人的位置的能力:
public static class PersonBuilderExtensions
{
public static PersonBuilder WorksAs
(this PersonBuilder builder, string position)
=> builder.Do(p => p.Position = position);
}
使用这种方法,没有继承问题,也没有递归魔法。任何时候我们想要额外的行为,我们简单地添加它们作为扩展方法,保持对 OCP 的坚持。
下面是如何使用这种设置:
var person = new PersonBuilder()
.Called("Dmitri")
.WorksAs("Programmer")
.Build();
严格地说,前面的函数方法可以成为一个可重用的通用基类,用于构建不同的对象。唯一的问题是,您必须将派生类型传播到基类中,这又一次需要递归泛型。您可以将基数FunctionalBuilder
定义为
public abstract class FunctionalBuilder<TSubject, TSelf>
where TSelf: FunctionalBuilder<TSubject, TSelf>
where TSubject : new()
{
private readonly List<Func<TSubject, TSubject>> actions
= new List<Func<TSubject, TSubject>>();
public TSelf Do(Action<TSubject> action)
=> AddAction(action);
private TSelf AddAction(Action<TSubject> action)
{
actions.Add(p => {
action(p);
return p;
});
return (TSelf) this;
}
public TSubject Build()
=> actions.Aggregate(new TSubject(), (p, f) => f(p));
}
现在将PersonBuilder
简化为
public sealed class PersonBuilder
: FunctionalBuilder<Person, PersonBuilder>
{
public PersonBuilder Called(string name)
=> Do(p => p.Name = name);
}
而PersonBuilderExtensions
类保持原样。使用这种方法,您可以轻松地重用FunctionalBuilder
作为应用中其他函数构建器的基类。请注意,在函数范式下,我们仍然坚持派生的构建器都是sealed
并通过使用扩展方法来扩展的想法。
F# 中的 DSL 构造
许多编程语言(如 Groovy、Kotlin 或 F#)都试图引入一种语言特性来简化创建 DSL(特定于领域的语言)的过程,即帮助描述特定问题领域的小型语言。这种嵌入式 DSL 的许多应用被用来实现构建器模式。例如,如果你想建立一个 HMTL 页面,你不必直接摆弄类和方法;相反,你可以用你的代码写一些非常接近 HTML 的东西!
在 F# 中实现这一点的方法是使用列表理解:定义列表而不需要显式调用构建器方法的能力。例如,如果您想要支持 HTML 段落和图像,您可以定义以下生成器函数:
let p args =
let allArgs = args |> String.concat "\n"
["<p>"; allArgs; "</p>"] |> String.concat "\n"
let img url = "<img src=\"" + url + "\"/>"
注意,img
标签只有一个文本参数,而<p>
标签接受一系列的args
,允许它包含任意数量的内部 HTML 元素,包括普通的纯文本。因此,我们可以构建一个包含文本和图像的段落:
let html =
p [
"Check out this picture";
img "pokemon.com/pikachu.png"
]
printfn "%s" html
这会产生以下输出:
<p>
Check out this picture
<img src="pokemon.com/pikachu.png"/>
</p>
这种方法用于 web 框架,如 WebSharper。这种方法有许多变体,包括记录类型的使用(让人们使用花括号而不是列表)、指定纯文本的自定义操作符等等。 2
需要注意的是,只有当我们使用不可变的、只追加的结构时,这种方法才是方便的。一旦你开始处理可变的对象(例如,使用 DSL 来构造微软项目文档的定义),你最终会回到 OOP。当然,最终的 DSL 语法使用起来仍然非常方便,但是使其工作所需的管道却一点也不漂亮。
摘要
构建器模式的目标是定义一个完全致力于复杂对象或对象集的分段构建的组件。我们已经观察到建造者的以下关键特征:
-
构建者可以拥有一个流畅的接口,该接口可用于使用单个调用链的复杂构建。为了支持这一点,构建器函数应该返回
this
。 -
为了强制 API 的用户使用构建器,我们可以使目标类的构造器不可访问,然后定义一个静态的
Create()
方法来返回构建器的实例。(命名由你决定,你可以叫它Make()
、New()
,或者别的什么。) -
通过定义适当的隐式转换运算符,可以将生成器强制转换为对象本身。
-
通过将生成器指定为参数函数的一部分,可以强制客户端使用生成器。
-
这样你可以完全隐藏正在构建的对象。
-
单个构建器接口可以公开多个子构建器。通过巧妙地使用继承和流畅的接口,人们可以轻松地从一个构建器跳到另一个构建器。
-
通过递归泛型,流畅接口的继承(不仅仅是构建者)是可能的。
只是为了重申我已经提到的一些东西,当对象的构造是一个重要的过程时,使用构建器模式是有意义的。由有限数量的合理命名的构造函数参数明确构造的简单对象可能应该使用构造函数(或依赖注入),而不需要这样的构造函数。
四、工厂
我遇到了一个问题,试图使用 Java,现在我遇到了一个问题工厂。
—
古老的爪哇笑话
本章涵盖了两种 GoF 模式:工厂方法和抽象工厂。这些模式密切相关,因此我们将一起讨论它们。然而,事实是,真正的设计模式被称为工厂,工厂方法和抽象工厂都只是重要的变体,但肯定没有主体重要。
方案
让我们从一个激励人心的例子开始。假设您想要在笛卡尔(X-Y)空间中存储关于一个Point
的信息。因此,您继续执行类似这样的操作:
public class Point
{
private double x, y;
public Point(double x, double y)
{
this.x = x;
this.y = y;
}
}
目前为止,一切顺利。但是现在,你也想用极坐标来初始化这个点。您需要另一个带有签名的构造函数:
Point(float r, float theta)
{
x = r * Math.Cos(theta);
y = r * Math.Sin(theta);
}
不幸的是,你已经有了一个带有两个float
的构造函数,所以你不能有另一个。 1 你是做什么的?一种方法是引入枚举:
public enum CoordinateSystem
{
Cartesian,
Polar
}
然后向点构造函数添加另一个参数:
public Point(double a,
double b, // names do not communicate intent
CoordinateSystem cs = CoordinateSystem.Cartesian)
{
switch (cs)
{
case CoordinateSystem.Polar:
x = a * Math.Cos(b);
y = a * Math.Sin(b);
break;
default:
x = a;
y = b;
break;
}
}
请注意前两个参数的名称是如何更改为a
和b
的:我们再也不能告诉用户这些值应该来自哪个坐标系。与使用x
、y
、rho
和theta
来传达意图相比,这是一种明显的表现力的丧失。
总的来说,我们的构造函数设计是可用的,但是很难看。特别是,为了添加一些第三坐标系,例如,你需要
-
给
CoordinateSystem
一个新的枚举值 -
更改构造函数以支持新的坐标系
做这件事一定有更好的方法。
工厂方法
构造函数的问题在于它的名字总是与类型相匹配。这意味着我们不能在其中传递任何额外的信息,不像在普通的方法中。此外,由于名称总是相同的,我们不能有两个重载,一个采用x,y
,另一个采用r,theta
。
那么我们能做什么呢?那么,把构造函数protected
2 做出来,然后暴露一些静态函数用于创建新点,怎么样?
public class Point
{
protected Point(double x, double y)
{
this.x = x;
this.y = y;
}
public static Point NewCartesianPoint(double x, double y)
{
return new Point(x, y);
}
public static Point NewPolarPoint(double rho, double theta)
{
return new Point(rho*Math.Cos(theta), rho*Math.Sin(theta));
}
// other members omitted
}
前面的每个静态函数都被称为工厂方法。它所做的只是创建一个Point
并返回它,这样做的好处是方法名和参数名清楚地传达了需要哪种坐标。
现在,要创建一个点,你只需写
var point = Point.NewPolarPoint(5, Math.PI / 4);
从前面的代码中,我们可以清楚地推测出我们正在创建一个新的点,它的极坐标是𝑟= 5,𝜃 = 𝜋/4.
异步工厂方法
当我们谈论构造函数时,我们总是假设构造函数的主体是同步的。构造函数总是返回被构造对象的类型——它不能返回Task
或Task<T>
;所以不能异步。但是,有些情况下,您确实希望以异步方式初始化对象。
(至少)有两种方法可以解决这个问题。第一个是约定的:我们只是同意任何异步初始化的类型都有一个方法,比如说,InitAsync()
:
public class Foo
{
private async Task InitAsync()
{
await Task.Delay(1000);
}
}
这里的假设是,客户端会识别这个成员,并会记得调用它,如:
var foo = new Foo();
await foo.InitAsync();
但这是非常乐观的。更好的方法是隐藏构造函数(使其成为protected
),然后创建一个static
工厂方法,该方法创建一个Foo
的实例并初始化它。我们甚至可以给它一个流畅的接口,这样得到的对象就可以使用了:
public class Foo
{
protected Foo() { /* init here */ }
public static Task<Foo> CreateAsync()
{
var result = new Foo();
return result.InitAsync();
}
}
这现在可以用作
var foo = await Foo.CreateAsync();
当然,如果您需要构造函数参数,您可以将它们添加到构造函数中,并从工厂方法转发它们。
工厂
就像 Builder 一样,我们可以将所有的Point
-创建函数从Point
中取出,放入一个单独的类中,我们称之为工厂。其实很简单:
class PointFactory
{
public static Point NewCartesianPoint(float x, float y)
{
return new Point(x, y); // needs to be public
}
// same for NewPolarPoint
}
值得注意的是,Point
构造函数不再是private
或protected
,因为它需要外部访问。不像C++
,没有friend
关键词供我们使用;稍后我们将采用不同的技巧。
但是现在,就这样了——我们有一个专门为创建Point
实例而设计的专用类,使用如下:
var myPoint = PointFactory.NewCartesian(3, 4);
内部工厂
内部工厂就是它所创建的类型中的内部(嵌套)类。内部工厂之所以存在,是因为内部类可以访问外部类的成员,反过来,外部类也可以访问内部类的私有成员。这意味着我们的Point
类也可以定义如下:
public class Point
{
// typical members here
// note the constructor is again private
private Point(double x, double y) { ... }
public static class Factory
{
public static Point NewCartesianPoint(double x, double y)
{
return new Point(x, y); // using a private constructor
}
// similar for NewPolarPoint()
}
}
好吧,这是怎么回事?嗯,我们已经将工厂嵌入到工厂创建的类中。如果一个工厂只使用一种类型,这是很方便的,如果一个工厂依赖于几种类型,这就不那么方便了(如果它还需要它们的private
成员,这几乎是不可能的)。
用这种方法,我们现在可以写
var point = Point.Factory.NewCartesianPoint(2, 3);
您可能会觉得这种方法很熟悉,因为。NET 框架使用这种方法来公开工厂。例如,TPL 可以让你用Task.Factory.StartNew()
完成新的任务。
物理分离
如果你不喜欢将Factory
的完整定义放在Point.cs
文件中,你可以使用partial
关键字,因为,你猜怎么着,它也适用于内部类。首先,在Point.cs
中,您可以将Point
类型修改为
public partial class Point { ... }
然后,简单地创建一个新文件(如Point.Factory.cs
),并在其中定义Point
的另一部分,即:
public partial class Point
{
public static class Factory
{
// as before
}
}
就这样!现在,您已经将工厂从类型本身中物理地分离出来了,尽管从逻辑上来说,它们仍然是缠绕在一起的,因为一个包含另一个。
抽象工厂
到目前为止,我们一直在看单个对象的构造。有时,您可能会参与创建对象族。这实际上是一个非常罕见的情况,所以与工厂方法和简单的旧工厂模式不同,抽象工厂是一种只出现在复杂系统中的模式。不管怎样,我们需要谈论它,主要是出于历史原因。
我们在这里要看的场景是网络上许多来源都展示过的场景,所以我希望你能原谅我的重复。我们将考虑要绘制的几何图形的层次结构。我们将只考虑线条以直角连接的形状:
public interface IShape
{
void Draw();
}
public class Square : IShape
{
public void Draw() => Console.WriteLine("Basic square");
}
public class Rectangle : IShape
{
public void Draw() => Console.WriteLine("Basic rectangle");
}
实现了IShape
接口的Square
和Rectangle
组成了一个家族:它们是用直角连接的直线绘制的简单几何图形。我们现在可以想象另一个平行的现实,直角被认为是不美观的,正方形和长方形的角都是圆的:
public class RoundedSquare : IShape
{
public void Draw() => Console.WriteLine("Rounded square");
}
public class RoundedRectangle : IShape
{
public void Draw() => Console.WriteLine("Rounded rectangle");
}
您会注意到这两个层次结构在概念上是相关的,但是没有代码元素表明它们是同一事物的一部分。我们可以通过多种方式引入这样的元素,一种方式是简单枚举系统支持的所有可能的形状:
public enum Shape
{
Square,
Rectangle
}
所以我们现在有两个系列的物体:一个基本形状系列和一个圆形系列。考虑到这一点,我们可以创建一个基本形状的工厂:
public class BasicShapeFactory : ShapeFactory
{
public override IShape Create(Shape shape)
{
switch (shape)
{
case Shape.Square:
return new Square();
case Shape.Rectangle:
return new Rectangle();
default:
throw new ArgumentOutOfRangeException(nameof(shape), shape, null);
}
}
}
类似的RoundedShapeFactory
用于圆形。因为这两个工厂的方法是相同的,所以它们都可以从如下定义的抽象工厂继承:
public abstract class ShapeFactory
{
public abstract IShape Create(Shape shape);
}
我们最后得到的是一种情况,一个形状层次结构有一个相应的工厂层次结构。我们现在可以创建一种方法,根据是否实际需要形状倒圆来生成特定类型的工厂:
public static ShapeFactory GetFactory(bool rounded)
{
if (rounded)
return new RoundedShapeFactory();
else
return new BasicShapeFactory();
}
就这样!我们现在有了一种可配置的方法,不仅可以实例化单个对象,还可以实例化整个对象系列:
var basic = GetFactory(false);
var basicRectangle = basic.Create(Shape.Rectangle);
basicRectangle.Draw(); // Basic rectangle
var roundedSquare = GetFactory(true).Create(Shape.Square);
roundedSquare.Draw(); // Rounded square
自然地,我们之前所做的手动配置可以很容易地使用 IoC 容器来完成——您只需定义对ShapeFactory
的请求是否应该产生BasicShapeFactory
、RoundedShapeFactory
或其他工厂类型的实例。事实上,与之前的GetFactory()
方法不同,IoC 容器的使用不会遭受(轻微的)OCP 违规,因为如果引入新的ShapeFactory
,除了容器配置之外,没有任何代码需要重写。
关于Shape
enum 和IShape
inherites 之间的关系,还有另外一件事不得不说。严格地说,虽然我们的例子是可行的,但并没有真正强制要求枚举成员与整个可能的层次结构一一对应。你可以在编译时引入这样的验证,但是要导出枚举成员的集合(也许通过 ??/罗斯林?),您可能需要引入额外的IShape
——实现抽象类(例如BasicShape
和RoundedShape
,这样您就可以清楚地划分两个不同的层次。这取决于你来决定这种方法在你的特殊情况下是否有意义。
IoC 中的代理工厂
我们在使用依赖注入和 IoC 容器时遇到的一个问题是,有时候,你有一个对象,它有一堆依赖的服务(可以被注入),但是它也有一些你需要的构造函数参数。
例如,给定一个服务,例如
public class Service
{
public string DoSomething(int value)
{
return $"I have {value}";
}
}
设想一个依赖于此服务的域对象,但它也有一个需要提供的构造函数参数,并随后在依赖的服务中使用:
public class DomainObject
{
private Service service;
private int value;
public DomainObject(Service service, int value)
{
this.service = service;
this.value = value;
}
public override string ToString()
{
return service.DoSomething(value);
}
}
您将如何配置您的 DI 容器(例如,Autofac)来构造一个注入服务的DomainObject
实例,并为该值指定值 42?嗯,有一种蛮力的方法,但它相当丑陋:
var cb = new ContainerBuilder();
cb.RegisterType<Service>();
cb.RegisterType<DomainObject>();
using var container = cb.Build();
var dobj = container.Resolve<DomainObject>(
new PositionalParameter(1, 42));
Console.WriteLine(dobj); // I have 42
这是可行的,但是这段代码很脆弱,不适合重构。参数value
的位置发生变化怎么办?这将使Resolve()
步骤无效。是的,我们可以尝试通过名称获取参数,但是重构(例如,重命名)构造函数的能力会受到影响。
幸运的是,这个问题有一个解决方案,它叫做委托 工厂。简单地说,委托工厂就是一个初始化对象的委托,但是它只要求你传递那些不会自动注入的参数。例如,我们的域对象的委托工厂就像
public class DomainObject
{
public delegate DomainObject Factory(int value);
// other members here
}
现在,当您在 IoC 容器中使用DomainObject
时,不是解析对象本身,而是解析工厂!
var factory = container.Resolve<DomainObject.Factory>();
var dobj2 = factory(42);
Console.WriteLine(dobj2); // I have 42
注册步骤保持不变。幕后发生的事情是这样的:IoC 容器初始化委托,以构造一个对象的实例,该实例利用依赖的服务和委托中提供的值。然后,当您解析它时,该委托被完全初始化并准备好使用!
功能工厂
在纯函数范式下,工厂模式的用途有限,因为 F# 更喜欢尽可能使用具体类型,使用函数和函数组合来表达实现中的可变性。
如果你想使用接口(这是 F# 允许的),那么,给定下面的定义
type ICountryInfo =
abstract member Capital : string
type Country =
| USA
| UK
您可以定义一个工厂函数,对于一个给定的国家,它产生一个正确初始化的ICountryInfo
对象:
let make country =
match country with
| USA -> { new ICountryInfo with
member x.Capital = "Washington" }
| UK -> { new ICountryInfo with
member x.Capital = "London" }
假设您希望能够通过将国家名称指定为字符串来创建一个国家。在这种情况下,除了拥有一个给你正确的Country
类型的独立函数之外,你还可以拥有一个静态工厂方法,非常类似于我们在 OOP 世界中拥有的方法:
type Country =
| USA
| UK
with
static member Create = function
| "USA" | "America" -> USA
| "UK" | "England" -> UK
| _ -> failwith "No such country"
let usa = Country.Create "America"
自然,抽象工厂方法同样可以使用功能组合而不是继承来实现。
摘要
让我们回顾一下术语:
-
一个工厂方法是一个类成员,作为创建对象的一种方式。它通常替换构造函数。
-
一个工厂通常是一个知道如何构造对象的单独的类,尽管如果你传递一个构造对象的函数(如
Func<T>
或类似的函数),这个参数也被称为工厂。 -
顾名思义,抽象工厂是一个抽象类,可以被提供一系列类型的具体类继承。抽象工厂在野外很少见。
与构造函数调用相比,工厂有几个关键优势,即:
-
一个工厂可以说 no ,这意味着它可以不实际返回一个对象,例如,返回某个
Option<T>
类型的null
或None
。 -
命名更好,不受约束,不像构造函数名。
-
一个工厂可以制造许多不同类型的物品。
-
工厂可以展示多态行为,实例化一个类并通过对其基类或接口的引用返回它。
-
工厂可以实现缓存和其他存储优化;这也是诸如池或单例模式等方法的自然选择。
-
工厂可以在运行时改变它的行为;
new
应该总是产生一个新的实例。
Factory 与 builder 的不同之处在于,使用 Factory 时,您通常一次创建一个对象(即一条语句),而使用 Builder 时,您可以通过几条语句分段构造对象,或者,如果 Builder 支持流畅的接口,也可以使用一条语句。# 原型
想想你每天使用的东西,比如汽车或手机。很有可能,它不是从零开始设计的;相反,制造商选择了一个现有的设计,进行了一些改进,使其在视觉上与旧设计有所区别(这样人们就可以炫耀),并开始销售它,使旧产品退役。这是一种自然状态,在软件世界中,我们会遇到类似的情况:有时,不是从头开始创建一个完整的对象(工厂和构建器模式在这里会有所帮助),而是希望获得一个预构造的对象,或者使用它的副本(这很容易),或者对它进行一点定制。
这让我们想到了拥有一个原型的想法:一个模型对象,我们可以制作副本,定制这些副本,然后使用它们。原型模式的挑战实际上是复制部分;其他的都好办。
*五、原型
深层复制与浅层复制
假设我们将类Person
定义为
public class Person
{
public readonly string Name;
public readonly Address Address;
public Person(string name, Address address) { ... }
}
地址定义为
public class Address
{
public readonly string StreetName;
public int HouseNumber;
public Address(string streetName, int houseNumber) { ... }
}
假设约翰·史密斯和简·史密斯是邻居。应该可以构造约翰,然后复制他,换个门牌号就行了吧?好吧,使用赋值操作符(=
)肯定没用:
var john = new Person(
"John Smith",
new Address("London Road", 123));
var jane = john;
jane.Name = "Jane Smith"; // John's name changed!
jane.Address.HouseNumber = 321; // John's address changed!
这不起作用,因为现在 john 和 jane 引用同一个对象,所以对jane
的所有更改也会影响john
。我们想要的是jane
成为一个新的、独立的物体,它的修改不会以任何方式影响john
。
ICloneable 不好
那个。NET Framework 附带了一个名为ICloneable
的接口。这个接口有一个单独的方法,Clone()
,但是这个方法被错误地指定了:文档没有建议这应该是一个浅层拷贝还是深层拷贝。此外,这个方法的名字Clone
,在这里并没有真正的帮助,因为我们不知道克隆到底做什么。类型(比如说,人)的典型实现是这样的:
public class Person : ICloneable
{
// members as before
public Person Clone()
{
return (Person)MemberwiseClone();
}
}
方法Object.MemberwiseClone()
是Object
的一个受保护方法,所以它被每个引用类型自动继承。它创建了对象的浅拷贝。换句话说,在我们的例子中,如果您要在Address
和Person
上实现它,您会遇到以下问题:
var john = new Person(
"John Smith",
new Address("London Road", 123));
var jane = john.Clone();
jane.Name = "Jane Smith"; // John's name DID NOT change (good!)
jane.Address.HouseNumber = 321; // John's address changed :(
这有所帮助,但不是很多。尽管名称现在被正确地分配了,john
和jane
现在共享一个Address
引用——它只是被简单地复制了一下,所以它们都指向同一个Address
。所以浅层复制不适合我们:我们想要深层复制,也就是说,递归复制所有对象的成员并构建闪亮的新对应物,每个对象都用相同的数据初始化。
使用特殊界面进行深度复制
如果你想有一个接口明确地表明你的对象支持深度复制的概念,我建议你明确地说明这一点,即:
interface IDeepCopyable<T>
{
T DeepCopy();
}
其中T
是要克隆的对象的类型。下面是一个实现示例:
public class Person : IDeepCopyable<Person>
{
public string[] Names;
public Address Address;
public Person DeepCopy()
{
var copy = new Person();
copy.Names = Array.Copy(Names); // string[] is not IDeepCopyable
copy.Address = Address.DeepCopy(); // Address is IDeepCopyable
return copy;
}
// other members here
}
您会注意到,在DeepCopy()
的实现中,我们根据成员本身是否是IDeepCopyable
而采用不同的策略。如果是的话,事情就相当简单了。如果不是,我们需要为给定的类型使用适当的深度复制机制。例如,对于一个数组,你可以调用Array.Copy()
。
与ICloneable
相比,这有两个好处:
-
它的意图很明确:它特别谈到了深度复制。
-
它是强类型的,而
ICloneable
返回一个您期望强制转换的object
。
深度复制对象
我们要讨论的是如何对各种基本面进行深度复制。NET 数据类型。
值类型,如int
、double
等,以及任何属于struct
( DateTime
、Guid
、Decimal
等的类型。)可以使用复制分配进行深度复制:
var dt = new DateTime(2016, 1, 1);
var dt2 = dt; // deep copy!
string
类型有点特殊:尽管它是一个引用类型,但它也是不可变的,这意味着一个特定字符串的值不能被改变——我们所能做的就是重新分配一个指向某个字符串的引用。结果是,当深度复制单个字符串时,我们可以继续愉快地使用=
操作符:
string s = "hello";
string w = s; // w refers to "hello"
w = "world"; // w refers to "world"
Console.WriteLine(s); // still prints "hello"
还有一些你无法控制的数据结构。例如,可以使用Array.Copy()
复制数组。要深度复制一个Dictionary
< >,可以使用它的复制构造函数:
var d = new Dictionary<string, int>
{
["foo"] = 1,
["bar"] = 2
};
var d2 = new Dictionary<string, int>(d);
d2["foo"] = 55;
Console.WriteLine(d["foo"]); // prints 1
但是即使是像Dictionary
这样的结构也不知道如何深度复制它所包含的引用类型。所以,如果你试图用这种方法来深度复制一个Dictionary<string, Address>
,你就要倒霉了:
var d = new Dictionary<string, Address>
{
["sherlock"] = new Address {HouseNumber = 221, StreetName = "Baker St"}
};
var d2 = new Dictionary<string, Address>(d);
d2["sherlock"].HouseNumber = 222;
Console.WriteLine(d["sherlock"].HouseNumber); // prints "222"
相反,您必须确保对字典的每个值执行深度复制,例如:
var d2 = d.ToDictionary(x => x.Key, x => x.Value.DeepCopy());
对于其他集合来说也是一样:Array.Copy
如果存储字符串或整数是可以的,但是对于复合对象来说就不行了。这就是 LINQ 的各种收集生成操作非常有用的地方,比如ToArray()
/ ToList()
/ ToDictionary()
。另外,不要忘记,即使您不能让 BCL 类型如Dictionary<>
实现您想要的接口,您仍然可以给它们适当的DeepCopy()
成员作为扩展方法。
通过复制结构复制
实现适当复制的最简单方法是实现复制构造函数。复制构造函数是一个直接来自 C++世界的工件——它是一个构造函数,接受我们所在类型的另一个实例,并将该类型复制到当前对象中,例如:
public Address(Address other)
{
StreetAddress = other.StreetAddress;
City = other.City;
Country = other.Country;
}
同样地
public Person(Person other)
{
Name = other.Name;
Address = new Address(other.Address); // uses a copy constructor here
}
这允许我们执行从john
到jane
的深度复制:
var john = new Person(
"John Smith",
new Address("London Road", 123));
var jane = new Person(john); // copy constructor!
jane.Name = "Jane Smith";
jane.Address.HouseNumber = 321; // john is still at 123
您会注意到,尽管字符串是引用类型,但我们不必执行任何操作来深度复制它们。这是因为字符串是不可变的,你实际上不能修改一个字符串,只能构造一个新的字符串并重新绑定引用。少了一件要担心的事,嗯?
但是要小心。例如,如果我们有一个由名字组成的数组(即string [] names
),我们将必须使用Array.Copy
显式复制整个数组,因为,你猜怎么着,数组是可变的。这同样适用于除了基本类型、string
或struct
之外的任何其他数据类型。
现在,复制构造函数非常好,因为它提供了一个统一的复制接口,但是如果客户端不能发现它,它就没有什么帮助了。至少,当开发者看到一个带有DeepCopy()
方法的IDeepCopyable
接口时,他们知道自己得到的是什么;复制构造函数的可发现性是可疑的。这种方法的另一个问题是它的侵入性很强:它要求组合链中的每个类都实现一个复制构造函数,如果任何一个类没有正确实现,就可能会出现故障。因此,在预先存在的数据结构上使用这种方法是非常具有挑战性的,因为如果你想支持这种事后处理,你将会大规模地违反 OCP。
序列化
我们需要感谢 C# 的设计者,因为 C# 中的大多数对象,无论是原始类型还是集合,都是“平凡可序列化的”——默认情况下,您应该能够获取一个类,并将其保存到文件或内存中,而无需向该类添加额外的代码(嗯,最多可能是一两个属性),也不必修改反射。
为什么这与手头的问题相关?因为如果您可以将某些东西序列化到文件或内存中,那么您可以反序列化它,保留所有信息,包括所有依赖对象。这不是很方便吗?例如,您可以使用二进制序列化为内存克隆定义一个扩展方法(正式名称为行为混合):
public static T DeepCopy<T>(this T self)
{
using (var stream = new MemoryStream())
{
BinaryFormatter formatter = new BinaryFormatter();
formatter.Serialize(stream, self);
stream.Seek(0, SeekOrigin.Begin);
object copy = formatter.Deserialize(stream);
return (T) copy;
}
}
这段代码简单地获取一个 any 类型T
的对象,在内存中执行二进制序列化,然后从内存中反序列化,从而获得原始对象的深层副本。
这种方法相当通用,可以让您轻松地克隆对象:
var foo = new Foo { Stuff = 42, Whatever = new Bar { Baz = "abc"} };
var foo2 = foo.DeepCopy();
foo2.Whatever.Baz = "xyz"; // works fine
只有一个问题:二进制序列化要求每个类都用[Serializable]
标记;否则,序列化程序只是抛出一个异常(这不是一件好事)。因此,如果我们想在一组现有的类上使用这种方法,包括那些没有被标记为[Serializable]
的而不是,我们可能会采用一种不需要上述属性的不同方法。例如,您可以改用 XML 序列化:
public static T DeepCopyXml<T>(this T self)
{
using (var ms = new MemoryStream())
{
XmlSerializer s = new XmlSerializer(typeof(T));
s.Serialize(ms, self);
ms.Position = 0;
return (T) s.Deserialize(ms);
}
}
您可以使用任何想要的序列化程序,唯一的要求是它知道如何遍历对象图中的每个元素。大多数序列化器足够聪明,可以处理不应该序列化的东西(比如只读属性),但有时它们需要一点帮助来理解更复杂的结构。例如,XML 序列化器不会序列化一个IDictionary
,所以如果您在类中使用一个字典,您需要将它标记为[XmlIgnore]
并创建一个属性代理,我们将在“适配器”一章中讨论。
原型工厂
如果您有想要复制的预定义对象,那么您实际上在哪里存储它们呢?某个类的静态字段。也许吧。实际上,假设我们公司既有主办公室又有辅助办公室。现在我们可以尝试声明一些静态变量,例如:
static Person main = new Person(null,
new Address("123 East Dr", "London", 0));
static Person aux = new Person(null,
new Address("123B East Dr", "London", 0));
我们可以将这些成员插入到Person
中,以便提供一个提示,当你需要一个在总部工作的人时,只需克隆main
,同样,对于辅助办公室,你可以克隆aux
。但这一点也不直观:如果我们想禁止人们在这两个办公室之外的任何地方工作,该怎么办?而且,从 SRP 的角度来看,将可能的地址集分开也是有意义的。
这就是原型工厂发挥作用的地方。就像一个普通的工厂一样,它可以存储这些静态成员,并为创建新员工提供方便的方法:
public class EmployeeFactory
{
private static Person main =
new Person(null, new Address("123 East Dr", "London", 0));
private static Person aux =
new Person(null, new Address("123B East Dr", "London", 0));
public static Person NewMainOfficeEmployee(string name, int suite) =>
NewEmployee(main, name, suite);
public static Person NewAuxOfficeEmployee(string name, int suite) =>
NewEmployee(aux, name, suite);
private static Person NewEmployee(Person proto, string name, int suite)
{
var copy = proto.DeepCopy();
copy.Name = name;
copy.Address.Suite = suite;
return copy;
}
}
请注意,遵循不重复(DRY)原则,我们不会在多个位置调用DeepCopy()
:所有不同的NewXxxEmployee()
方法只是将它们的参数转发给一个私有的NewEmployee()
方法,将构造新对象时使用的原型传递给它。
前面提到的原型工厂现在可以用作
var john = EmployeeFactory.NewMainOfficeEmployee("John Doe", 100);
var jane = EmployeeFactory.NewAuxOfficeEmployee("Jane Doe", 123);
自然,这个实现假设Person
的构造函数是可访问的;如果您想保留它们private/protected
,您需要实现“工厂”一章中概述的内部工厂方法。
摘要
原型设计模式体现了对象的深度复制的概念,这样你就可以获得一个预制的对象,复制它,稍加改动,然后独立于原始对象使用它,而不是每次都进行完全初始化。
实际上只有两种实现原型模式的方法。它们如下:
-
编写正确复制对象的代码,即执行深度复制。这可以在复制构造函数中完成,或者你可以定义一个适当命名的方法,可能有相应的接口(但是不是
ICloneable
)。 -
编写支持序列化/反序列化的代码,然后使用这种机制将克隆实现为序列化紧接着反序列化。这带来了额外的计算成本;它的重要性取决于你需要多长时间复制一次。这种方法的优点是,您可以在不显著修改现有结构的情况下脱身。这也更安全,因为您不太可能忘记正确地克隆成员。
别忘了,对于值类型来说,克隆问题并不真正存在:如果你想克隆一个struct
,只需要把它赋给一个新的变量。此外,字符串是不可变的,所以您可以对它们使用赋值操作符=
,而不用担心后续的修改会影响更多的对象。# Singleton
当讨论放弃哪些模式时,我们发现我们仍然爱它们。(不尽然——我赞成放弃 Singleton。它的使用几乎总是一种设计气味。)
—埃里希伽马
在(相当有限的)设计模式历史中,单体模式是迄今为止最令人讨厌的设计模式。然而,仅仅陈述这个事实并不意味着你不应该使用 singleton:马桶刷也不是最令人愉快的设备,但有时它只是必要的。
单例设计模式源于一个非常简单的想法,即应用中应该只有一个特定组件的实例。例如,将数据库加载到内存中并提供只读接口的组件是单例组件的主要候选对象,因为浪费内存来存储几个相同的数据集实在没有意义。事实上,您的应用可能有这样的约束,即两个或更多的数据库实例不适合内存,或者会导致内存不足,从而导致程序出现故障。
六、单例
习俗上的单例
解决这个问题的简单方法是同意我们不会多次实例化这个对象,也就是说:
public class Database
{
/// <summary>
/// Please do not create more than one instance.
/// </summary>
public Database() {}
};
这种方法的问题在于,除了你的开发人员同事可能会简单地忽略这个建议之外,对象可以以隐蔽的方式创建,其中对构造函数的调用并不明显。这可以是任何东西——通过反射的调用、在工厂中的创建(例如Activator.CreateInstance
)或者 IoC 容器的类型注入。
想到的最明显的想法是提供一个单一的静态全局对象:
public static class Globals
{
public static Database Database = new Database();
}
然而,从安全性的角度来看,这真的没什么作用:客户端不会被阻止构建他们认为合适的额外的Database
。客户端如何找到Globals
类呢?
经典实现
那么现在我们知道了问题是什么,对于那些对制作一个对象的多个实例感兴趣的人来说,我们如何才能让生活变得不愉快呢?只需在构造函数中放一个静态计数器,如果值增加了,就放throw
:
public class Database
{
private static int instanceCount = 0;
Database()
{
if (++instanceCount > 1)
throw new InvalidOperationExeption("Cannot make >1 database!");
}
};
这是一种特别不友好的解决问题的方法:尽管它通过抛出异常来防止创建多个实例,但是它没有向传达我们不希望任何人多次调用构造函数的事实。即使您用大量的 XML 文档来修饰它,我保证仍然会有一些可怜的人试图在一些不确定的环境中不止一次地调用它——很可能在生产中也是如此!
防止显式构造Database
的唯一方法是将其构造函数设为私有,并引入一个属性或方法来返回唯一的实例:
public class Database
{
private Database() { ... }
public static Database Instance { get; } = new Database();
}
注意我们如何通过隐藏构造函数来消除直接创建Database
实例的可能性。当然,您可以使用反射来访问私有成员,因此构造这个类并不是不可能的,但是它确实需要额外的限制,希望这足以防止大多数人试图构造一个。
通过将实例声明为static
,我们消除了控制数据库生命周期的任何可能性:它现在和程序一样长。
惰性加载和线程安全
上一节中展示的实现恰好是线程安全的。毕竟,在创建类的任何实例或访问任何静态成员之前,每个 AppDomain 只能运行一次静态构造函数。
但是如果不希望在静态构造函数中初始化呢?相反,如果你想只在对象第一次被访问时初始化单例(即调用它的构造函数)呢?在这种情况下,你可以用Lazy<T>
1 :
public class MyDatabase
{
private MyDatabase()
{
Console.WriteLine("Initializing database");
}
private static Lazy<MyDatabase> instance =
new Lazy<MyDatabase>(() => new MyDatabase());
public static MyDatabase Instance => instance.Value;
}
这也是一种线程安全的方法,因为默认情况下Lazy<T>
创建的对象是线程安全的。在多线程设置中,第一个访问Lazy<T>
的Value
属性的线程是为所有线程的所有后续访问初始化该属性的线程。
The Trouble with Singleton
现在让我们考虑一个具体的例子。假设我们的数据库包含一个首都城市及其人口的列表。我们的单例数据库将遵循的接口是
public interface IDatabase
{
int GetPopulation(string name);
}
我们有一种方法可以给出一个给定城市的人口。现在,让我们假设这个接口被一个名为SingletonDatabase
的具体实现所采用,这个实现以和我们之前所做的一样的方式来实现 Singleton:
public class SingletonDatabase : IDatabase
{
private Dictionary<string, int> capitals;
private static int instanceCount;
public static int Count => instanceCount;
private SingletonDatabase()
{
WriteLine("Initializing database");
capitals = File.ReadAllLines(
Path.Combine(
new FileInfo(typeof(IDatabase).Assembly.Location) .DirectoryName, "capitals.txt")
)
.Batch(2) // from MoreLINQ
.ToDictionary(
list => list.ElementAt(0).Trim(),
list => int.Parse(list.ElementAt(1)));
}
public int GetPopulation(string name)
{
return capitals[name];
}
private static Lazy<SingletonDatabase> instance =
new Lazy<SingletonDatabase>(() =>
{
instanceCount++;
return new SingletonDatabase();
});
public static IDatabase Instance => instance.Value;
}
数据库的构造器从一个文本文件中读取各个首都的名称和人口,并将它们存储在一个Dictionary<>
中。GetPopulation()
方法用作获取给定城市人口的访问器。
正如我们之前提到的,像前面这样的单例的真正问题是它们在其他组件中的使用。我的意思是:假设,基于前面的数据,我们构建一个组件来计算几个不同城市的总人口:
public class SingletonRecordFinder
{
public int TotalPopulation(IEnumerable<string> names)
{
int result = 0;
foreach (var name in names)
result += SingletonDatabase.Instance.GetPopulation(name);
return result;
}
}
麻烦的是SingletonRecordFinder
现在牢牢依赖SingletonDatabase
。这给测试带来了一个问题:如果我们想检查SingletonRecordFinder
是否正常工作,我们需要使用实际数据库中的数据,也就是说:
[Test]
public void SingletonTotalPopulationTest()
{
// testing on a live database
var rf = new SingletonRecordFinder();
var names = new[] {"Seoul", "Mexico City"};
int tp = rf.TotalPopulation(names);
Assert.That(tp, Is.EqualTo(17500000 + 17400000));
}
这是一个糟糕的单元测试。它试图读取一个活动的数据库(这通常是你不希望经常做的事情),但是它也非常脆弱,因为它依赖于数据库中的具体值。如果首尔的人口发生变化(也许是朝鲜开放边境的结果)会怎样?那么测试将会失败。但是当然,许多人在与实时数据库隔离的持续集成系统上运行测试,所以这个事实使得这种方法更加可疑。
这个测试也是因为意识形态原因不好。记住,我们想要一个单元测试,其中我们测试的单元是SingletonRecordFinder
。然而,之前的测试不是单元测试,而是一个集成测试,因为 record finder 使用了SingletonDatabase
,所以实际上我们是在同时测试两个系统。如果集成测试是您想要的,这没有什么错,但是我们真的更喜欢单独测试 record finder。
所以我们知道我们不想在测试中使用实际的数据库。我们可以用一些我们可以在测试中控制的虚拟组件来替换数据库吗?在我们目前的设计中,这是不可能的,而正是这种不灵活导致了单例模式的失败。
那么,我们能做什么呢?首先,我们需要停止对SingletonDatabase
的依赖。因为我们需要的只是实现Database
接口的东西,所以我们可以创建一个新的ConfigurableRecordFinder
,让我们配置数据来自哪里:
public class ConfigurableRecordFinder
{
private IDatabase database;
public ConfigurableRecordFinder(IDatabase database)
{
this.database = database;
}
public int GetTotalPopulation(IEnumerable<string> names)
{
int result = 0;
foreach (var name in names)
result += database.GetPopulation(name);
return result;
}
}
我们现在使用database
引用,而不是显式地使用 singleton。这让我们可以专门为测试记录查找器创建一个虚拟数据库:
public class DummyDatabase : IDatabase
{
public int GetPopulation(string name)
{
return new Dictionary<string, int>
{
["alpha"] = 1,
["beta"] = 2,
["gamma"] = 3
}[name];
}
}
现在,我们可以重写我们的单元测试来利用这个DummyDatabase
:
[Test]
public void DependentTotalPopulationTest()
{
var db = new DummyDatabase();
var rf = new ConfigurableRecordFinder(db);
Assert.That(
rf.GetTotalPopulation(new[]{"alpha", "gamma"}),
Is.EqualTo(4));
}
这个测试更加健壮,因为如果实际数据库中的数据发生变化,我们不必调整我们的单元测试值——虚拟数据保持不变。此外,它开启了有趣的可能性。我们现在可以对一个空数据库运行测试,或者说,对一个大小大于可用 RAM 的数据库运行测试。你明白了。
单线态和控制反转
显式地使组件成为单例组件的方法显然是侵入性的,如果您决定以后不再将组件视为单例组件,那么所需的更改最终可能会非常昂贵。另一种解决方案是采用一种约定,不直接实施类的生存期,而是将此功能外包给控制反转(IoC)容器。
下面是使用 Autofac 依赖注入框架时定义单一组件的样子:
var builder = new ContainerBuilder();
builder.RegisterType<Database>().SingleInstance(); // <-- singleton!
builder.RegisterType<RecordFinder>();
var container = builder.Build();
var finder = container.Resolve<RecordFinder>();
var finder2 = container.Resolve<RecordFinder>();
WriteLine(ReferenceEquals(finder, finder2)); // True
// finder and finder2 refer to the same database
许多人认为在阿迪容器中使用单例是社会上唯一可以接受的单例用法。至少,使用这种方法,如果您需要用其他东西替换单例对象,您可以在一个中心位置完成:容器配置代码。一个额外的好处是,您不必自己实现任何单例逻辑,这可以防止可能的错误。哦,我提到过 Autofac 中所有的容器操作都是线程安全的吗?
事实上,IoC 容器强调的一点是,单例只是生命周期管理的一个特例(整个应用的每个生命周期一个对象)。不同的生存期是可能的——每个线程可以有一个对象,每个 web 请求可以有一个对象,等等。您还可以使用池——在这种情况下,活动对象实例的数量可以在 0 到 X 之间,不管 X 是多少。
Monostate
单态是单态模式的变体。它是一个表现像一个单例,而表现为一个普通类的类。
例如,假设您正在建模一个公司结构,一个公司通常只有一个 CEO。您可以做的是定义以下类:
public class ChiefExecutiveOfficer
{
private static string name;
private static int age;
public string Name
{
get => name;
set => name = value;
}
public int Age
{
get => age;
set => age = value;
}
}
你能看到这里发生了什么吗?这个类看起来像一个普通的类,有 getters 和 setters,但是它们实际上是在处理static
数据!
这似乎是一个非常巧妙的技巧:你让人们实例化ChiefExecutiveOfficer
任意多次,但是所有的实例都引用相同的数据。然而,用户应该如何知道这些呢?一个用户会愉快地实例化两个 CEO,给他们分配不同的id
s,当他们两个完全相同时会非常惊讶!
单稳态方法在某种程度上是可行的,并且有几个优点。例如,它易于继承,可以利用多态性,并且它的生命周期被合理地定义(但是话说回来,您可能并不总是希望这样)。它最大的优点是,您可以获取一个已经在整个系统中使用的现有对象,对其进行修补,使其以单稳态方式运行,如果您的系统能够很好地处理非大量的对象实例,您就可以获得一个类似单例的实现,而无需重写额外的代码。
但这就是 Monostate 真正的作用:当您希望一个组件成为整个代码库中的单一组件,而不进行任何大规模更改时,这只是一个权宜之计。这种模式不适合生产,因为它会造成太多的混乱。如果你需要对事情进行集中控制,阿迪集装箱是你最好的选择。
Multiton
顾名思义,多音是一种模式,它不是强迫我们只有一个实例,而是让我们拥有某个特定组件的有限数量的命名实例。例如,假设我们有两个子系统,一个是主系统,另一个是备份系统:
enum Subsystem
{
Main,
Backup
}
如果每个子系统只有一台打印机,我们可以如下定义Printer
类:
class Printer
{
private Printer() { }
public static Printer Get(Subsystem ss)
{
if (instances.ContainsKey(ss))
return instances[ss];
var instance = new Printer();
instances[ss] = instance;
return instance;
}
private static readonly Dictionary<Subsystem, Printer> instances
= new Dictionary<Subsystem, Printer>();
}
和以前一样,我们隐藏了构造函数,并创建了一个访问器方法,该方法惰性地构造并返回对应于所需子系统的打印机。当然,前面的实现不是线程安全的,但是可以很容易地通过使用ConcurrentDictionary
来纠正。
还要注意的是,前面的实现在直接依赖方面有着与 Singleton 相同的问题。如果您的代码依赖于Printer.Get(Subsystem.Main)
,您将如何用不同的实现替换结果?嗯,就像我们看到的数据库例子一样,最好的解决方案是提取一些IPrinter
接口并依赖于它。
摘要
单例并不完全是邪恶的,但是当不小心使用时,它们会弄乱应用的可测试性和可重构性。如果你真的必须使用单例,试着避免直接使用它(就像在,写SomeComponent.Instance.Foo()
),而是继续把它指定为一个依赖项(例如,一个构造函数参数),所有的依赖项都从你的应用中的一个单独的位置得到满足(例如,一个控制容器的倒置)。依赖抽象(接口/抽象类)符合 DIP,如果您想稍后执行替换,这通常是个好主意。
七、适配器
我过去经常旅行,通常只有当你到达一个新的国家时,你才记得他们的插座是不同的,而你对此没有准备。这就是为什么机场旅行商店有旅行适配器,也是为什么一些酒店(更好的酒店)至少有一个非本地类型的插座,以防客户忘记带适配器,但需要不间断地使用笔记本电脑。
一个可以让我将欧洲插头插入英国或美国插座的旅行适配器 1 非常类似于软件世界中的适配器模式:我们得到了一个接口,但我们想要一个不同的接口,在接口上构建一个适配器可以让我们到达我们想要的地方。
方案
假设您正在使用一个非常擅长绘制像素的库。另一方面,你处理的是几何图形,线条,矩形之类的东西。你想继续处理这些对象,但也需要渲染,所以你需要调整你的矢量几何图形到基于像素的表示。
让我们从定义示例中的(相当简单的)域对象开始:
public class Point
{
public int X, Y;
// other members omitted
}
public class Line
{
public Point Start, End;
// other members omitted
}
现在让我们从理论上研究矢量几何。典型的矢量对象很可能是由一组Line
对象定义的。因此,我们可以创建一个简单地从Collection<Line>
继承的类:
public abstract class VectorObject : Collection<Line> {}
因此,这样一来,如果你想定义一个Rectangle
,你可以简单地继承这个类型,而不需要定义额外的存储:
public class VectorRectangle : VectorObject
{
public VectorRectangle(int x, int y, int width, int height)
{
Add(new Line(new Point(x,y), new Point(x+width, y) ));
Add(new Line(new Point(x+width,y), new Point(x+width, y+height) ));
Add(new Line(new Point(x,y), new Point(x, y+height) ));
Add(new Line(new Point(x,y+height), new Point(x+width, y+height) ));
}
}
现在,这是设置。假设我们想在屏幕上画线。长方形,甚至!不幸的是,我们不能,因为绘图的唯一界面实际上是这样的:
// the interface we have
public static void DrawPoint(Point p)
{
bitmap.SetPixel(p.X, p.Y, Color.Black);
}
我在这里使用Bitmap
类进行说明,但是实际的实现并不重要。让我们从表面上来看:我们只有一个绘制像素的 API。就这样。
适配器
好吧,假设我们想画几个矩形:
private static readonly List<VectorObject> vectorObjects
= new List<VectorObject>
{
new VectorRectangle(1, 1, 10, 10),
new VectorRectangle(3, 3, 6, 6)
};
为了绘制这些对象,我们需要将它们中的每一个从一系列线转换成大量的点,因为我们仅有的用于绘制的接口是一个DrawPoint()
方法。为此,我们创建一个单独的类来存储这些点,并将它们作为一个集合公开。没错,这就是我们的适配器模式!
public class LineToPointAdapter : Collection<Point>
{
private static int count = 0;
public LineToPointAdapter(Line line)
{
WriteLine($"{++count}: Generating points for line"
+ $" [{line.Start.X},{line.Start.Y}]-"
+ $"[{line.End.X},{line.End.Y}] (no caching)");
int left = Math.Min(line.Start.X, line.End.X);
int right =Math.Max(line.Start.X, line.End.X);
int top = Math.Min(line.Start.Y, line.End.Y);
int bottom = Math.Max(line.Start.Y, line.End.Y);
if (right - left == 0)
{
for (int y = top; y <= bottom; ++y)
{
Add(new Point(left, y));
}
} else if (line.End.Y - line.Start.Y == 0)
{
for (int x = left; x <= right; ++x)
{
Add(new Point(x, top));
}
}
}
}
前面的代码被简化了:我们只处理完全垂直或水平的行,而忽略其他的。从一条线到多个点的转换正好发生在构造函数中,所以我们的适配器是急切的;别担心,我们会在本章末尾让它变得懒惰。
我们现在可以使用这个适配器来实际呈现一些对象。我们取前面的两个矩形,简单地渲染成这样:
private static void DrawPoints()
{
foreach (var vo in vectorObjects)
{
foreach (var line in vo)
{
var adapter = new LineToPointAdapter(line);
adapter.ForEach(DrawPoint);
}
}
}
太美了!我们所做的就是,对于每一个 vector 对象,获取它的每一条线,为那条线构造一个LineTo PointAdapter
,然后迭代由适配器产生的点集,给它们提供to DrawPoint()
。而且很管用!(相信我,确实如此。)
临时适配器
不过,我们的代码有一个主要问题:DrawPoints()
在我们可能需要的每一次屏幕刷新时都会被调用,这意味着相同行对象的相同数据会被适配器重新生成无数次。我们能做些什么呢?
嗯,一方面,我们可以制定一些惰性加载方法,例如:
private static List<Point> points = new List<Point>();
private static bool prepared = false;
private static void Prepare()
{
if (prepared) return;
foreach (var vo in vectorObjects)
{
foreach (var line in vo)
{
var adapter = new LineToPointAdapter(line);
adapter.ForEach(p => points.Add(p));
}
}
prepared = true;
}
然后DrawPoints()
的实现简化为
private static void DrawPointsLazy()
{
Prepare();
points.ForEach(DrawPoint);
}
但是让我们假设一下,vectorObjects
的原始集合可以改变。永远保存这些点是没有意义的,但是我们仍然希望避免不断地重新生成潜在的重复数据。我们该如何应对?当然是带缓存的!
首先,为了避免再生,我们需要独特的识别线的方法,这就意味着我们需要独特的识别点的方法。ReSharper 的 Generate | Equality 成员前来救援:
public class Point
{
// other members here
protected bool Equals(Point other) { ... }
public override bool Equals(object obj) { ... }
public override int GetHashCode()
{
unchecked { return (X * 397) ^ Y; }
}
}
public class Line
{
// other members here
protected bool Equals(Line other) { ... }
public override bool Equals(object obj) { ... }
public override int GetHashCode()
{
unchecked
{
return ((Start != null ? Start.GetHashCode() : 0) * 397)
^ (End != null ? End.GetHashCode() : 0);
}
}
}
如您所见,ReSharper(或者 Rider,如果您喜欢 IDE 的话)已经生成了不同的Equals()
和GetHashCode()
实现。后者更重要,因为它允许我们通过散列码唯一地(在某种程度上)标识一个对象,而无需执行直接比较。现在,我们可以构建一个新的LineToPointCachingAdapter
,这样它可以缓存这些点,并仅在必要时重新生成它们,也就是说,当它们的哈希值不同时。除了以下细微差别之外,实现几乎是相同的。
首先,适配器现在有一个对应于特定行的点的static
缓存:
static Dictionary<int, List<Point>> cache
= new Dictionary<int, List<Point>>();
这里的类型int
正是从GetHashCode()
返回的类型。现在,当在构造函数中处理一个Line
时,我们首先检查该行是否已经被缓存:如果是,我们不需要做任何事情:
hash = line.GetHashCode();
if (cache.ContainsKey(hash)) return; // we already have it
注意,我们实际上将当前适配器的散列值存储在它的非静态字段中。这允许我们存储和使用对应于单个线路的适配器。或者,我们可以制作整个适配器static.
构造函数的完整实现和以前一样,只是我们没有为生成的点调用Add()
,而是简单地将它们添加到缓存中:
public LineToPointAdapter(Line line)
{
hash = line.GetHashCode();
if (cache.ContainsKey(hash)) return; // we already have it
List<Point> points = new List<Point>();
// points are added to the "points" member as before, then
cache.Add(hash, points);
}
最后,我们需要实现IEnumerable<Point>
。这很简单:我们使用hash
字段来访问缓存并产生正确的一组点:
public IEnumerator<Point> GetEnumerator()
{
return cache[hash].GetEnumerator();
}
耶!多亏了哈希函数和缓存,我们大大减少了转换的次数。这种实现的唯一问题是,在长时间运行的程序中,缓存可能会累积大量不必要的点集合。你会怎么清理?一种方法是设置一个计时器,定期清除整个缓存。看看你能否想出解决这个问题的其他可能的办法。
哈希的问题是
我们以这种方式实现适配器的一个原因是,我们当前的实现对于对象的变化是健壮的。如果Rectangle
的任何方面发生变化,适配器将计算不同的哈希值,并重新生成适当的点集。
这可以通过轮询来有效地完成:任何时候需要一个修改过的数据集,我们就获取目标对象并重新计算它的散列。假设总是可以快速计算散列,并且散列冲突——两个不同对象具有相同散列的情况——不太可能发生。让我们提醒自己如何计算Point
散列:
public override int GetHashCode()
{
unchecked
{
return (X * 397) ^ Y;
}
}
事实是,Point
的哈希函数是一个非常糟糕的哈希函数,会给我们带来很多冲突。例如,点(0,0)和(1,397)将给出相同的哈希值 0,这反过来意味着具有这些Start
点和一个相同的End
点的两行将最终用不正确的数据覆盖彼此生成的点集,不可避免地会导致问题。
你会如何解决这个问题?你可以选择一个大于 397 的质数 N。这样,如果你能保证你的值小于这个更大的 N,你就不会有任何碰撞。或者,您可以使用更健壮的散列函数。在Point
的情况下,假设正的X
和Y
,这可以简单为
public long MyHashFunction()
{
return (X << 32) | Y;
}
如果你真的想保留GetHashCode()
接口(记住,它返回一个int
,你可以通过将坐标降级为一个short
来实现——它的范围对于一个屏幕坐标来说足够了(直到我们得到 64K 屏幕)。最后,还有很多复杂的函数(康托配对函数,Szudzik 函数,等等。)能够处理数字范围边界的情况。
我在这里想说的是,散列函数的计算是一个滑坡:ide 生成的代码可能没有您想象的那么健壮。我们能做些什么来避免这一切?为什么,我们可以在缓存中保存对 adaptee 的引用,而不是散列。这很简单
public class LineToPointAdapter
: IEnumerable<Point>
{
static Dictionary<Line, List<Point>> cache
= new Dictionary<Line, List<Point>>();
private Line line;
public LineToPointAdapter(Line line)
{
if (cache.ContainsKey(line)) return; // we already have it
this.line = line;
// as before
cache.Add(line, points);
}
public IEnumerator<Point> GetEnumerator()
{
return cache[line].GetEnumerator();
}
}
有什么区别?不同的是,当在字典中搜索时,GetHashCode()
和Equals()
都被用来找到正确的条目。因此,碰撞仍然会发生,但不会打乱最终值。不过这种方法也有它的缺点:例如,行的生命周期现在被绑定到适配器上,因为它有对它们的强引用。
*这种坚持引用的方法给了我们一个额外的好处:懒惰。我们可以将点的准备工作分解成一个单独的函数,只在迭代适配器点时调用,而不是计算构造函数中的所有内容:
public class LineToPointAdapter : IEnumerable<Point>
{
...
private void Prepare()
{
if (cache.ContainsKey(line)) return; // we already have it
// rest of code as before
}
public IEnumerator<Point> GetEnumerator()
{
Prepare();
return cache[line].GetEnumerator();
}
}
属性适配器(代理)
适配器设计模式的一个非常常见的应用是让您的类提供仅用于一个目的的附加属性:获取现有的字段或属性,并以某种有用的方式公开它们,通常是作为不同数据类型的投影。虽然在单独的类中这样做通常是有意义的(例如,在构建视图模型时),但有时您不得不在保存原始数据的类中这样做。
考虑下面的例子:如果你的类中有一个IDictionary
成员,你不能使用XmlSerializer
,因为微软“由于时间限制”没有实现这个功能。因此,如果您想要一个可序列化的字典,您有两个选择:要么上网搜索一个SerializableDictionary
实现,要么构建一个属性适配器(或代理),以一种易于序列化的方式公开字典。
例如,假设您需要序列化以下属性:
public Dictionary<string, string> Capitals { get; set; }
要实现这一点,首先要将属性标记为[XmlIgnore]
。然后,您将构造另一个类型的属性,使能够被序列化,比如元组数组:
public (string, string)[] CapitalsSerializable
{
get
{
return Capitals.Keys.Select(country =>
(country, Capitals[country])).ToArray();
}
set
{
Capitals = value.ToDictionary(x => x.Item1, x => x.Item2);
}
}
在这里,我非常仔细地选择了序列化的类型:
-
变量的总体类型是一个数组。如果你把这个设为
List
,序列化器将永远不会调用 setter,而是尝试使用 getter,然后用Add()
调用 getter–
,我们绝对不希望这样。 -
我们用的是
ValueTuple
而不是普通的Tuple
。传统的元组不能被序列化,因为它们没有无参数的构造函数,而ValueTuples
没有这个问题。
如果您想知道,下面是一个序列化的类在 XML 中的样子:
<?xml version="1.0" encoding="utf-16"?>
<CountryStats xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<CapitalsSerializable>
<ValueTupleOfStringString>
<Item1>France</Item1>
<Item2>Paris</Item2>
</ValueTupleOfStringString>
</CapitalsSerializable>
</CountryStats>
这里呈现的适配器与您可能期望的完全不同,因为我们试图使类适应的 API 是隐式的——序列化机制被我们使用的序列化程序所隐藏,所以了解这个问题的唯一方法是通过反复试验。 2
这个例子对于可靠的原则来说有点模糊:一方面,我们将序列化问题分离出来。另一方面,这真的应该是类本身的一部分吗?如果我们可以用一些[SerializeThis- Dictionary]
属性来修饰成员,并在其他地方处理转换过程,那就更简洁了。唉,这就是. NET 中序列化实现方式的局限性。
通用值适配器
与 C# 不同,在 C++中,泛型参数不必是类型:它们可以是文字。例如,您可以编写template <int n> class X {}
,然后实例化一个类型为X<42>
的类。在有些情况下,这种功能在 C# 中是必要的,即使这种语言不允许在泛型参数中使用值,我们也可以构建适配器来帮助我们使值适应泛型类型。
这背后的想法非常简单,所以为了让它变得有趣,我将加入一个额外的奖励:我们不仅要使用泛型值适配器模式,而且我们还将利用一些高级的泛型魔法来增加趣味。
首先,这是我提出的场景。假设您在数学或图形领域工作,并且想要不同大小和使用不同数据类型的(几何)向量。例如,您希望Vector2i
是一个具有两个整数值的向量,而Vector3f
是一个具有浮点值的三维向量。
我们真正想要的是有一个类Vector<T, D>
( T =
类型,D =
维度),它将被定义为
public class Vector<T, D>
{
protected T[] data;
public Vector()
{
data = new T[D]; // impossible
}
}
然后将其实例化为
var v = new Vector<int, 2>(); // impossible
在 C# 中,构造函数初始化和实例化都是不可能的。这个问题的解决方案并不漂亮:我们基本上在类中包装文字,比如 2、3 等等。为此,首先,我们定义一个返回整数的接口:
public interface IInteger
{
int Value { get; }
}
现在,这个接口可以由具体的类来实现,这些类将产生值 2、3 等等。为了使用起来更简洁,我将所有这些放在一个类中,该类将充当类似于enum
的实体:
public static class Dimensions
{
public class Two : IInteger
{
public int Value => 2;
}
public class Three : IInteger
{
public int Value => 3;
}
}
所以现在我们终于可以定义一个可以正确初始化数据的工作类了:
public class Vector<T, D>
where D : IInteger, new()
{
protected T[] data;
public Vector()
{
data = new T[new D().Value];
}
}
这种方法可能令人费解,但事实上,它确实有效。我们要求D
是一个也有默认构造函数的IInteger
,当初始化数据存储时,我们旋转一个D
的实例并取其值。
要使用这个新类,您需要编写如下代码:
var v = new Vector<int, Dimensions.Two>();
或者,您可以通过定义继承类型来使事物可重用,例如:
public class Vector2i : Vector<int, Dimensions.Two> {}
// and then
var v = new Vector2i();
就这样,我们完成了对泛型值适配器模式的讨论。我相信你能理解,这个想法既琐碎又不幸,同时很丑陋。想象一下要做出Dimensions.Three
、Dimensions.Four
等等!不过,也许一些代码生成可以有所帮助。
现在,如果我放弃这个示例,让您从这一点开始自己照顾自己,那将是非常不公平的,所以让我们讨论一些关于如何将这个示例投入生产状态的想法。尽管这些想法并不是设计模式的核心,但是让这个Vector
完全发挥作用的尝试带来了 C# 的一个棘手方面——即递归泛型。
让我们从显而易见的事情开始:我们希望以某种方式访问和修改向量中的数据,就像它是一个数组一样。一种简单的方法是简单地公开一个索引器:
public class Vector<T, D>
where D : IInteger, new()
{
// ... other members omitted
public T this[int index]
{
get => data[index];
set => data[index] = value;
}
}
类似地,如果您决定继承,您可以为命名坐标创建额外的 getters 和 setters:
public class Vector2i : Vector<int, Dimensions.Two>
{
public int X
{
get => data[0];
set => data[0] = value;
}
// similarly for Y
}
理论上,您也可以将可预测的属性(如X,Y,Z
)粘贴到基类中,但这可能会有点混乱,因为这样一来,您可能会有一个带有暴露的 Z 坐标的一维向量,在被访问时会简单地抛出一个异常。
不管怎样,有了这些设置,你现在可以如下初始化一个向量:
var v = new Vector2i();
v[0] = 123; // using an indexer
v.Y = 456; // using a property
当然,如果我们能以某种方式在构造函数中初始化数据,那就太好了。感谢params
关键字,我们的 base Vector
可以有一个接受任意数量参数的构造函数。我们只需要确保被初始化的数据大小正确:
public Vector(params T[] values)
{
var requiredSize = new D().Value;
data = new T[requiredSize];
var providedSize = values.Length;
for (int i = 0; i < Math.Min(requiredSize, providedSize); ++i)
data[i] = values[i];
}
现在我们可以使用构造函数初始化一个向量;但是,我们不能这样真正初始化派生的Vector2i
,除非我们创建一个转发构造函数:
public class Vector2i : Vector<int, Dimensions.Two>
{
public Vector2i() {}
public Vector2i(params int[] values) : base(values) {}
}
所以现在我们终于可以做一个new Vector2i(2, 3)
了,一切都可以编译了。这实际上是实例化这些向量的两种可能方法之一,另一种涉及到工厂方法的使用。但是,在我们到达那里之前,让我们首先考虑一个问题,这个问题将会带来相当大的麻烦。
以下是我希望能够写的内容:
var v = new Vector2i(1, 2);
var vv = new Vector2i(3, 2);
var result = v + vv;
这是一个悲伤的故事。我们不能进入我们的Vector<T, D>
并给它一个operator +
。为什么不呢?因为T
不局限于数字。可能是一个Guid
什么的,添加两个 GUIDs 的操作没有定义。我们没有办法让 C# 将T
约束为数字类型(其他语言,比如 Rust,已经解决了这个问题),所以我们唯一能让这一切工作的方法就是创建更多从Vector
派生的类型——比如VectorOfInt
、VectorOfFloat
等等。
public class VectorOfInt<D> : Vector<int, D>
where D : IInteger, new()
{
public VectorOfInt() {}
public VectorOfInt(params int[] values) : base(values) {}
public static VectorOfInt<D> operator +
(VectorOfInt<D> lhs, VectorOfInt<D> rhs)
{
var result = new VectorOfInt<D>();
var dim = new D().Value;
for (int i = 0; i < dim; i++)
{
result[i] = lhs[i] + rhs[i];
}
return result;
}
}
从清单中可以看出,我们必须复制Vector
的构造器 API,但是我们设法提供了一个很好的operator +
实现,将两个向量加在一起。现在,我们需要做的就是修改我们的 Vector2i,一切就绪:
public class Vector2i : VectorOfInt<Dimensions.Two>
{
public Vector2i(params int[] values) : base(values)
{
}
}
注意一些有趣的事情:我们移除了无参数构造函数,因为不再需要它,因为它现在包含在VectorOfInt
中。然而,我们仍然必须保留params
构造函数,这样我们就可以初始化一个Vector2i
实例。
这是我们可以考虑的最后一个复杂因素。假设您对到处进行这种构造函数传播并不感兴趣。假设你决定所有的派生类(VectorOfInt
、VectorOfFloat
、Vector2i
等等)代替构造函数。)体内会有 no 构造函数。相反,我们决定所有这些类型的创建将由一个单独的Vector<T, D>.Create()
工厂方法来处理。我们如何才能做到这一点?
这种情况并不简单,需要使用递归泛型。为什么呢?因为静态的Vector.Create()
方法需要返回正确的类型。如果我调用Vector3f.Create()
,我希望返回的是Vector3f
,而不是Vector<float
、Dimensions.Three>
,也不是VectorOfFloat<Dimensions.Three>
。
这意味着我们需要做一些修改。首先,Vector
现在获得了一个新的泛型参数TSelf
,引用了从它派生的类:
public abstract class Vector<TSelf, T, D>
where D : IInteger, new()
where TSelf : Vector<TSelf, T, D>, new()
{
// ...
}
如您所见,TSelf
被约束为Vector<TSelf, T, D>
的继承者。现在,任何派生类型(比如,VectorOfFloat
)都需要改为
public class VectorOfFloat<TSelf, D>
: Vector<TSelf, float, D>
where D : IInteger, new()
where TSelf : Vector<TSelf, float, D>, new()
{
// wow, such empty!
}
注意,这个类不再有任何转发构造函数,因为我们计划使用工厂方法。类似地,您必须修改从VectorOfFloat
派生的任何类,例如:
public class Vector3f
: VectorOfFloat<Vector3f, Dimensions.Three>
{
// empty again
}
注意TSelf
是如何在层次结构中向上传播的:首先,Vector3f
向上传播到VectorOfFloat
,然后向上传播到Vector
。这样,我们可以确定Vector
知道它的工厂方法需要返回一个Vector3f
。哦,说到工厂方法,我们终于可以写了!
public static TSelf Create(params T[] values)
{
var result = new TSelf();
var requiredSize = new D().Value;
result.data = new T[requiredSize];
var providedSize = values.Length;
for (int i = 0; i < Math.Min(requiredSize, providedSize); ++i)
result.data[i] = values[i];
return result;
}
这就是TSelf
派上用场的地方——它是我们工厂方法的返回类型。现在,无论您创建哪个派生类,创建该类的一个实例就像编写代码一样简单
var coord = Vector3f.Create(3.5f, 2.2f, 1);
这就是了!自然地,coord here
的类型是Vector3f
——不需要施法或任何其他魔法。这是递归泛型允许你拥有的功能。以下是我们整个场景的示例:
依赖注入中的适配器
有些高级适配器场景可以由依赖注入框架(如 Autofac)很好地处理。不可否认,这里的方法与本章剩余部分讨论的“使组件 X 适应接口 Y”有些不同。
考虑一个场景,您的应用有一堆您想要调用的命令。每个命令都能够自己执行,仅此而已。
public interface ICommand
{
void Execute();
}
public class SaveCommand : ICommand
{
public void Execute()
{
Console.WriteLine("Saving current file");
}
}
public class OpenCommand : ICommand
{
public void Execute()
{
Console.WriteLine("Opening a file");
}
}
现在,在您的编辑器中,您想要创建一组按钮。每个按钮被按下时,执行相应的命令。我们可以将按钮表示如下:
public class Button
{
private ICommand command;
private string name;
public Button(ICommand command, string name)
{
this.command = command;
this.name = name;
}
public void Click() { command.Execute(); }
public void PrintMe()
{
Console.WriteLine($"I am a button called {name}");
}
}
现在,这里有一个挑战:如何制作一个编辑器,为系统中注册的每个命令创建一个按钮?我们可以这样定义它:
public class Editor
{
public IEnumerable<Button> Buttons { get; }
public Editor(IEnumerable<Button> buttons)
{
Buttons = buttons;
}
}
现在,我们可以用所有可能的命令建立一个依赖注入容器。我们还可以为每个命令添加一些元数据,存储其名称:
var b = new ContainerBuilder();
b.RegisterType<OpenCommand>()
.As<ICommand>()
.WithMetadata("Name", "Open");
b.RegisterType<SaveCommand>()
.As<ICommand>()
.WithMetadata("Name", "Save");
我们现在可以在 DI 容器中注册一个适配器,它将为每个注册的命令构造一个Button
,此外,还将从每个命令中获取元数据Name
值,并将其作为第二个构造函数参数传递:
b.RegisterAdapter<Meta<ICommand>, Button>(cmd =>
new Button(cmd.Value, (string)cmd.Metadata["Name"]));
我们现在可以注册Editor
本身并构建容器。当我们解析编辑器时,它的构造函数将收到一个IEnumerable<Button>
,每个注册的命令都有一个按钮:
b.RegisterType<Editor>();
using var c = b.Build();
var editor = c.Resolve<Editor>();
foreach (var btn in editor.Buttons) btn.PrintMe();
// I am a button called Open
// I am a button called Save
因此,正如您所看到的,虽然这不是传统意义上的适配器,但它允许我们在符合某些标准的一组类型和与这些类型相关的一组实例之间实施一对一的对应。
中的适配器。NET 框架
中有许多适配器模式的用途。NET 框架,包括以下内容:
-
位于
System.Data
的 adapt 提供者,比如SqlCommand,
,使用 SQL 修改 OOP 定义的数据库命令或查询。每个 ADO.NET 提供程序都是特定数据库类型的适配器。 -
数据库数据适配器——继承自
DbDataAdapter
的类型——执行类似的、更高级别的操作。在内部,它们表示一组数据命令和到特定数据源(通常是数据库)的连接,它们的目标是填充一个DataSet
并更新数据源。 -
LINQ 提供者也是适配器,每个都通过 LINQ 操作符(
Select
、Where
等)调整一些底层存储技术以供使用。).表达式树存在的主要目的是将传统的 C# lambda 函数翻译成其他查询语言和机制,如 SQL。 -
流适配器(例如,
TextReader
、StreamWriter
)适配流以将特定类型的数据(二进制、文本)读入特定类型的对象。例如,StringWriter
写入由StringBuilder
保存的缓冲区。 -
WPF 使用
IValueConverter
接口允许将文本字段绑定到数值上。与这里的大多数适配器不同,这个适配器是双向的 ??,这意味着接口可以双向适应:对数值字段/属性的更改被转换成显示在控件中的文本,反之,输入到控件中的文本被解析并转换成数值。 -
C# 中与互操作相关的实体代表适配器模式。例如,您编写虚拟 P/Invoke 类型允许您修改 C/C++库以满足您的 C# 需求。运行时可调用包装器(rcw)也是如此,它允许托管类和 COM 组件进行交互,尽管它们有明显的接口差异。
摘要
适配器是一个非常简单的概念:它允许您将您拥有的接口适配到您需要的接口。适配器的唯一真正问题是,在适配过程中,您有时会生成临时数据,以满足以目标 API 可接受的形式表示数据的相关需求。当这种情况发生时,我们求助于缓存:确保新数据只在必要时生成。如果我们用一个特殊的键实现缓存,我们需要确保冲突是不可能的,或者得到适当的处理。如果我们使用对象本身作为底层键,GetHashCode()
和Equals()
的存在为我们解决了这个问题。
作为额外的优化,我们可以确保适配器不会立即生成临时变量,而是只在实际需要时才生成它们。进一步的优化是可能的,但是是特定于域的:例如,在我们的例子中,行可以是其他行的一部分,这将让我们进一步节省所创建的Point
对象的数量。# 桥
在设计软件时,一个非常常见的情况是所谓的状态空间爆炸,其中表示所有可能状态所需的相关实体的数量以笛卡尔乘积的方式“爆炸”。例如,如果你有不同颜色的圆和正方形,你可能会得到像RedSquare/BlueSquare/RedCircle/BlueCircle and so on
这样的类。显然没人希望如此。
相反,我们所做的是把事物联系起来,有不同的方式来做到这一点。例如,如果对象颜色只是一个特征,我们就创建一个enum
。但是如果 color 有可变的字段、属性或行为,我们就不能把自己限制在一个enum
中:如果我们这样做了,我们就会在不相关的类中有大量的if/switch
语句。再说一次,这不是我们想要的。
桥模式本质上是使用引用来连接一个对象的组成部分。不是很令人兴奋,是吗?好吧,我可以在我们探索的最后提供一些令人兴奋的东西,但是我们首先需要看一下该模式的一个常规实现。
*八、桥接
传统桥接
假设我们对在屏幕上画不同种类的形状感兴趣。让我们假设我们有各种各样的形状(圆形、方形等。)以及用于渲染这些的不同 API(比如光栅渲染和矢量渲染)。
我们希望创建指定形状类型和形状用于渲染的渲染机制的对象。我们如何做到这一点?一方面,我们可以定义无限多的类(RasterSquare
、VectorCircle
等)。)并为每一个提供一个实现。或者我们可以以某种方式让每个形状引用它正在使用的渲染器。
让我们从定义一个IRenderer
开始。这个接口将决定不同的形状如何被所需的机制渲染: 1
public interface IRenderer
{
void RenderCircle(float radius);
// RenderSquare, RenderTriangle, etc.
}
另一方面,我们可以为形状层次定义一个抽象类(不是接口)。为什么是抽象类?因为我们希望保留对渲染器的引用。
public abstract class Shape
{
protected IRenderer renderer;
// a bridge between the shape that's being drawn and
// the component which actually draws it
public Shape(IRenderer renderer)
{
this.renderer = renderer;
}
public abstract void Draw();
public abstract void Resize(float factor);
}
这可能看起来违反直觉,所以让我们停下来问问自己:我们到底在试图防范什么?嗯,我们试图处理两种情况:当新的渲染器被添加时和当新的形状被添加到系统中时。我们不希望这些中的任何一个导致多个变化。这里有两种情况:
-
如果添加了一个新的形状,它所要做的就是继承
Shape
并实现它的成员(假设有 M 个不同的成员)。然后每个呈现器只需要实现一个新成员(RenderXxx
)。因此,如果有 M 个不同的渲染器,一个新形状所需的操作总数是 M+N。 -
如果添加了一个新的渲染器,它所要做的就是实现 M 个不同的成员,每个成员对应一个形状。
如你所见,我们要么实现 M 个成员,要么实现 M+N 个成员。在任何时候,我们都不会遇到 M 乘 N 的情况,这是该模式试图避免的。另一个额外的好处是,渲染器总是知道如何渲染系统中所有可用的形状,因为每个形状Xxx
都有一个明确调用RenderXxx()
的Draw()
方法。
这里是Circle
的实现:
public class Circle : Shape
{
private float radius;
public Circle(IRenderer renderer, float radius) : base(renderer)
{
this.radius = radius;
}
public override void Draw()
{
renderer.RenderCircle(radius);
}
public override void Resize(float factor)
{
radius *= factor;
}
}
这是其中一个渲染器的示例实现:
public class VectorRenderer : IRenderer
{
public void RenderCircle(float radius)
{
WriteLine($"Drawing a circle of radius {radius}");
}
}
注意,Draw()
方法只是使用了桥:它为这个特定的对象调用相应的渲染器的绘制实现。
为了使用这个设置,你必须实例化一个IRenderer
和形状。这可以直接完成:
var raster = new RasterRenderer();
var vector = new VectorRenderer();
var circle = new Circle(vector, 5);
circle.Draw(); // Drawing a circle of radius 5
circle.Resize(2);
circle.Draw(); // Drawing a circle of radius 10
或者,如果您正在使用依赖注入框架,您可以定义一个在整个应用中使用的默认呈现器。这样,Circle
的所有构造实例都将被集中定义的渲染器预初始化。下面是一个使用 Autofac 容器的示例:
var cb = new ContainerBuilder();
cb.RegisterType<VectorRenderer>().As<IRenderer>();
cb.Register((c, p) => new Circle(c.Resolve<IRenderer>(),
p.Positional<float>(0)));
using (var c = cb.Build())
{
var circle = c.Resolve<Circle>(
new PositionalParameter(0, 5.0f)
);
circle.Draw();
circle.Resize(2);
circle.Draw();
}
前面的代码指定,默认情况下,当有人请求一个IRenderer
时,应该提供一个VectorRenderer
。此外,由于形状需要一个额外的参数(大概是它们的大小),我们指定默认值为零。
动态原型桥
您可能已经注意到,桥只不过是依赖倒置原则的应用,其中您通过一个公共参数将两个不同的层次结构连接在一起。现在我们来看一个更复杂的例子,它涉及到动态原型。
动态原型是一种编辑技术。NET 程序在运行时。您已经体验过 Visual Studio 中的“编辑&继续”功能。动态原型的思想是允许用户通过编辑和运行时编译程序的源代码,对当前运行的程序进行即时更改。
它是如何工作的?好吧,假设你坚持“每个文件一个类”的方法,并且你预先知道你的 DI 容器可以满足给定类的所有依赖关系。在这种情况下,您可以执行以下操作:
-
允许用户编辑这个类的源代码。如果类和文件之间有一对一的对应关系,这种方法效果最好。大多数现代 ide 都试图实施这种方法。
-
编辑并保存新的源代码后,使用 C# 编译器编译该类,并获得新类型的内存实现。你基本上会得到一个
System.Type
。如果你愿意,你可以实例化那个新类型,并用它来更新一些引用,或者… -
您可以在 DI 容器中更改注册选项,这样您的新类型就可以替代原来的类型。这自然要求您使用某种抽象。
最后一点需要解释。如果你有一个具体的类型Foo.Bar
并且你构建了一个全新的内存类型Foo.Bar
,那么即使这些类型的 API 保持不变,这些类型也是不兼容的。不能用新的引用来分配对旧的Bar
的引用。互换使用它们的唯一方法是通过dynamic
或反射,这两者都是小众案例。
让我来说明整个过程是如何工作的。假设您有一个被Payroll
类使用的Log
类。使用假设的依赖注入,您可以将其定义为
// Log.cs
public class Log
{
void Info(string msg) { ... }
}
// Payroll.cs
public class Payroll
{
[Service]
public Log Log { get; set; }
}
注意,我将Log
定义为注入属性,而不是通过构造函数注入。现在,要创建动态桥,您需要引入一个接口,即:
// ILog.cs
public interface ILog
{
void Info(string msg);
}
// Log.cs
public class Log : ILog { /* as before */ }
// Payroll.cs
public class Payroll
{
[Service]
public ILog Log { get; set; }
}
也要注意文件名。这很重要:每种类型都在自己的文件中。现在,当您运行这个程序时,假设您想在不停止应用的情况下更改Log
的实现。您要做的如下所示:
-
用
Log.cs
文件打开一个编辑器并编辑该文件。 -
关闭编辑器。现在
Log.cs
被编译成内存中的程序集。 -
创建在这个新程序集中找到的第一个类型。肯定会是一个
Log
,但是和之前的Log
不兼容!然而,它实现了一个ILog
,这对我们来说已经足够好了。 -
检查容器已经创建的对象,并用新对象更新所有标记了
[Service]
的对ILog
的引用。
这最后一部分可能会很棘手。首先,你需要一个容器,它可以检查自己的注入点,老实说,你也可以使用好的老式反射来达到这个目的。我提到容器的原因是它使用起来更方便。另外,注意这种方法只适用于属性注入,并且有一个隐含的假设,即服务是不可变的(没有状态)。如果服务有状态,您必须将其序列化,然后将数据反序列化到新的对象中——这并非不可能,但是一个健壮的实现需要处理许多极端情况。
所以这个故事的寓意是,为了能够用一个运行时构造的类型替换另一个,它们都需要实现相同的接口。而且在你问之前, no ,你不能动态改变任何基类(类或者接口)。
摘要
正如我们所看到的,桥设计模式的主要目标是避免数据类型的过度增长,在这种情况下,有两个或更多的“维度”,也就是说,系统的各个方面,可能在数量上成倍增长。桥接的最佳方法仍然是主动避免(例如,如果可能的话,用枚举替换类),但是如果那是不可能的,我们就简单地抽象掉两个层次,并找到一种连接它们的方法。
九、组合
现实生活中,对象通常由其他对象组成(或者,换句话说,它们聚合了其他对象)。请记住,在本书这一部分的开始,我们同意将聚合和合成等同起来。
一个对象有几种方式来表明它是由某些东西组成的。最明显的方法是让一个对象要么实现IEnumerable<T>
(其中T
是您准备公开的任何内容),要么公开自己实现IEnumerable<T>
的公共成员。
作为组合广告的另一个选择是从已知的集合类继承,如Collection<T>
、List<T>
或类似的类。这当然让您不仅可以隐式地实现IEnumerable<T>
,还为您提供了一个内部存储机制,因此像向集合中添加新对象这样的问题会自动为您处理。
那么,组合模式是什么呢?本质上,我们试图给单个对象和对象组一个相同的接口,并让这些接口成员正确工作,而不管底层是哪个类。
组合图形对象
想象一下像 PowerPoint 这样的应用,您可以选择几个不同的对象,然后将它们作为一个对象拖动。然而,如果你要选择一个单一的对象,你也可以抓住那个对象。渲染也是如此:你可以渲染一个单独的图形对象,或者你可以将几个图形组合在一起,然后它们作为一个组来绘制。
这种方法的实现相当容易,因为它只依赖于一个基类,如下所示:
public class GraphicObject
{
public virtual string Name { get; set; } = "Group";
public string Color;
// todo members
}
public class Circle : GraphicObject
{
public override string Name => "Circle";
}
public class Square : GraphicObject
{
public override string Name => "Square";
}
这似乎是一个相当普通的例子,除了GraphicObject
是抽象的,以及由于某种原因被设置为“Group”的virtual string Name
属性之外,没有任何突出之处。因此,尽管GraphicObject
的继承者显然是标量实体,GraphicObject
本身保留充当进一步项目的容器的权利。
实现这一点的方法是给GraphicObject
提供一个懒散构建的孩子列表:
public class GraphicObject
{
...
private readonly Lazy<List<GraphicObject>> children =
new Lazy<List<GraphicObject>>();
public List<GraphicObject> Children => children.Value;
}
所以GraphicObject
既可以作为一个单一的标量元素(例如,你继承它并得到一个Circle
),也可以作为元素的容器。我们可以实现一些方法来打印它的内容:
public class GraphicObject
{
private void Print(StringBuilder sb, int depth)
{
sb.Append(new string('*', depth))
.Append(string.IsNullOrWhiteSpace(Color) ? string.Empty : $"{Color} ")
.AppendLine($"{Name}");
foreach (var child in Children)
child.Print(sb, depth + 1);
}
public override string ToString()
{
var sb = new StringBuilder();
Print(sb, 0);
return sb.ToString();
}
}
前面的代码使用星号来表示每个元素的深度级别。有了这些,我们现在可以构建一个包含形状和形状组的绘图,并将其打印出来:
var drawing = new GraphicObject {Name = "My Drawing"};
drawing.Children.Add(new Square {Color = "Red"});
drawing.Children.Add(new Circle{Color="Yellow"});
var group = new GraphicObject();
group.Children.Add(new Circle{Color="Blue"});
group.Children.Add(new Square{Color="Blue"});
drawing.Children.Add(group);
WriteLine(drawing);
这是我们得到的输出:
My Drawing
*Red Square
*Yellow Circle
*Group
**Blue Circle
**Blue Square
因此,这是组合设计模式最简单的实现,它基于继承和一系列子元素的可选包含。敏锐的读者会指出,唯一的问题是像Circle
或Square
这样的标量类拥有一个Children
成员是完全没有意义的。如果有人使用这样的 API 会怎么样?这没有什么意义。
在下一个例子中,我们将会看到真正的标量对象,在它们的接口中没有无关的成员。
神经网络
机器学习是热门的新事物,我希望它保持这种状态,否则我将不得不更新这一段。机器学习的一部分是使用人工神经网络:试图模仿我们大脑中神经元工作方式的软件结构。
神经网络的核心概念当然是一个神经元。一个神经元可以产生一个(通常是数字的)输出,作为其输入的函数,我们可以将该值反馈到网络中的其他连接上。我们将只关心连接,所以我们将这样模拟神经元:
public class Neuron
{
public List<Neuron> In, Out;
}
这是一个简单的神经元,与其他神经元有出入连接。你可能想做的是将一个神经元和另一个神经元连接起来,这可以通过
public void ConnectTo(Neuron other)
{
Out.Add(other);
other.In.Add(this);
}
这种方法做了相当可预测的事情:它在当前(this
)神经元和其他某个神经元之间建立连接。目前为止一切顺利。
现在,假设我们也想创建神经元层。一层相当简单,就是特定数量的神经元组合在一起。这可以很容易地通过继承一个Collection<T>
来完成,即:
public class NeuronLayer : Collection<Neuron>
{
public NeuronLayer(int count)
{
while (count --> 0)
Add(new Neuron());
}
}
看起来不错,对吧?我甚至还附上了箭头符供你欣赏。 1 但是现在,我们遇到了一点麻烦。
问题是这样的:我们希望神经元能够连接到神经元层(在两个方向上),我们还希望层可以连接到其他层。概括地说,我们希望这样做:
var neuron1 = new Neuron();
var neuron2 = new Neuron();
var layer1 = new NeuronLayer(3);
var layer2 = new NeuronLayer(4);
neuron1.ConnectTo(neuron2); // works already :)
neuron1.ConnectTo(layer1);
layer2.ConnectTo(neuron1);
layer1.ConnectTo(layer2);
如你所见,我们有四个不同的案例要处理:
-
神经元连接到另一个神经元
-
神经元连接到层
-
连接到神经元的层
-
连接到另一层的层
正如您可能已经猜到的,在 Baator 中,我们不可能对ConnectTo()
方法进行四次重载。如果有三个不同的类——我们真的会考虑创建九个方法吗?我不这么认为。
用单一方法解决这个问题的方法是认识到Neuron
和NeuronLayer
都可以被视为可枚举的。在NeuronLayer
的情况下,没有问题——它已经是可枚举的了,但是在Neuron
的情况下,嗯……我们需要做一些工作。
为了做好准备,我们将
-
移除它自己的
ConnectTo()
方法,因为它不够通用 -
实现
IEnumerable<Neuron>
接口,让出…我们自己(!)当有人要枚举我们的时候
下面是新的Neuron
类的样子:
public class Neuron : IEnumerable<Neuron>
{
public List<Neuron> In, Out;
public IEnumerator<Neuron> GetEnumerator()
{
yield return this;
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
现在,段 de resistance :由于Neuron
和NeuronLayer
现在都符合IEnumerable<Neuron>
,我们剩下要做的就是实现一个扩展方法,将两个枚举连接在一起:
public static class ExtensionMethods
{
public static void ConnectTo(
this IEnumerable<Neuron> self, IEnumerable<Neuron> other)
{
if (ReferenceEquals(self, other)) return;
foreach (var from in self)
foreach (var to in other)
{
from.Out.Add(to);
to.In.Add(from);
}
}
}
就是这样!我们现在有了一个方法,可以调用这个方法将任何由Neuron
类组成的实体粘合在一起。现在,如果我们决定做一些NeuronRing
,只要它支持IEnumerable<Neuron>
,我们可以很容易地把它连接到一个Neuron
、NeuronLayer
或另一个NeuronRing
!
收缩包装组合
毫无疑问,你们中的许多人想要某种预打包的解决方案,允许标量对象被视为可枚举的。如果你的标量类不是从另一个类派生的,你可以简单地定义一个基类,如下所示:
public abstract class Scalar<T> : IEnumerable<T>
where T : Scalar<T>
{
public IEnumerator<T> GetEnumerator()
{
yield return (T) this;
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
这个类是通用的,类型参数T
指的是我们试图“标量化”的对象现在,让任何对象将自己公开为一个元素的集合就像
public class Foo : Scalar<Foo> {}
并且该对象可以立即在一个foreach
循环中使用:
var foo = new Foo();
foreach (var x in foo)
{
// will yield only one value of x
// where x == foo referentially :)
}
这种方法只有在你的类型没有父类型时才有效,因为多重继承是不可能的。当然,最好有一些标记接口(可能是从IEnumerable<T>
继承的,尽管这不是绝对必要的)将GetEnumerator()
实现为扩展方法。可悲的是,C# 语言的设计者没有留下这个选项——GetEnumerator()
必须严格地是实例方法才能被foreach
选中。
遗憾的是,我们不能滥用 C# 8 的默认接口成员来使用接口而不是类来收缩包装组合。这样做的原因是,你必须显式地将类强制转换为包含默认成员的接口,所以如果你希望GetEnumerator()
duck typing,你就不走运了。
我们能想出的最好的办法是这样的:
public interface IScalar<out T>
where T : IScalar<T>
{
public IEnumerator<T> GetEnumerator()
{
yield return (T) this;
}
}
注意这个接口不能从IEnumerable<T>
继承。好吧,你可以继承它,但是它不会让你免于在类的中实现GetEnumerator()
对,这完全违背了要点。
你能用前面提到的界面做什么?你可以在课堂上使用它:
public class Foo : IScalar<Foo> { ... }
但不幸的是,当涉及到迭代时,您必须在 duck typing 完成其工作之前执行强制转换:
var foo = new Foo();
var scalar = foo as IScalar<Foo>; // :(
foreach (var f in scalar)
{
...
}
当然,如果我们可以用这个来欺骗系统,我们同样可以在几年前通过为标量定义扩展方法和标记接口来欺骗它。不幸的是,我们在这里运气不好。
组合规格
当我介绍开闭原则时,我演示了规范模式。该模式的关键方面是基本类型IFilter
和ISpecification
,它们允许我们使用继承来构建符合 OCP 的可扩展过滤框架。该实现的一部分涉及到组合子——在 AND 或 or 运算符机制下将几个规范组合在一起的规范。
AndSpecification
和OrSpecification
都使用了两个操作数(我们称之为left
和right
,但是这种限制是完全任意的:事实上,我们可以将两个以上的元素组合在一起,此外,我们可以用一个可重用的基类来改进 OOP 模型,如下所示:
public abstract class CompositeSpecification<T> : ISpecification<T>
{
protected readonly ISpecification<T>[] items;
public CompositeSpecification(params ISpecification<T>[] items)
{
this.items = items;
}
}
前面的代码应该很熟悉,因为我们以前实现过这种方法。我们创建了一个ISpecification
,实际上,它是不同规范的组合,在构造函数中作为params
传递。
通过这种方法,AndSpecification
组合子现在可以用一点 LINQ 来实现:
public class AndSpecification<T> : CompositeSpecification<T>
{
public AndSpecification(params ISpecification<T>[] items) : base(items)
{
}
public override bool IsSatisfied(T t)
{
return items.All(i => i.IsSatisfied(t));
}
}
类似地,如果您想要一个OrSpecification
,您可以将对All
()的调用替换为对Any
()的调用。您甚至可以制定支持其他更复杂标准的规范。例如,您可以制作一个组合,要求该项目最多/至少/特别地满足其中包含的一些规格。
摘要
组合设计模式允许我们为单个对象和对象集合提供相同的接口。这可以通过两种方式之一实现:
-
让您打算使用的每个标量对象都包含一个集合,或者让它包含一个集合并以某种方式公开它。您可以使用
Lazy<T>
,这样您就不会分配太多实际上不需要的数据结构。这是一个非常简单的方法,有点不地道。 -
教导标量对象作为集合出现。这是通过实现
IEnumerable<T>
然后在GetEnumerator()
中调用yield return this
来完成的。严格来说,让标量值 exposeIEnumerable
也是不符合规则的,但它在美学上更好,并且计算成本更小。
十、装饰器
假设您正在使用您同事编写的一个类,并且您想要扩展该类的功能。在不修改原始代码的情况下,你会怎么做呢?嗯,一种方法是继承:你创建一个派生类,添加你需要的功能,甚至可能是override
什么的,然后你就可以开始了。
是的,除了这并不总是有效,原因有很多。最常见的原因是您不能继承该类——要么是因为您的目标类需要继承其他东西(多重继承是不可能的),要么是因为您想要扩展的类是sealed
。
装饰模式允许我们在不修改原始类型(开闭原则)或导致派生类型数量激增的情况下增强现有类型。
自定义字符串生成器
假设您正在进行代码生成,并且想要扩展StringBuilder
以提供额外的实用方法,比如支持缩进或范围或者任何有意义的代码生成功能。简单地从StringBuilder
继承会很好,但是它是sealed
(出于安全原因)。此外,由于您可能想要存储当前的缩进级别(比如说,提供Indent()/Unindent()
方法),您不能简单地继续使用扩展方法,因为这些方法是无状态的。?? 1
所以解决方案是创建一个装饰器:一个全新的类,它聚合了一个StringBuilder
,但也存储和公开了与 StringBuilder 相同的成员,甚至更多。从一开始,该类可能如下所示:
public class CodeBuilder
{
private StringBuilder builder = new StringBuilder();
private int indentLevel = 0;
public CodeBuilder Indent()
{
indentLevel++;
return this;
}
}
如你所见,我们既有“底层”StringBuilder and
一些与扩展功能相关的额外成员。我们现在需要做的是将StringBuilder
的成员公开为CodeBuilder
的成员,委托调用。StringBuilder
有一个非常大的 API,所以手工做是不合理的:相反,你应该使用代码生成(例如 ReSharper 的 Generate | Delegated members)来自动创建必要的 API。
该操作可应用于StringBuilder
的每个成员,并将生成以下签名:
public class CodeBuilder
{
public StringBuilder Append(string value)
{
return builder.Append(value);
}
public StringBuilder AppendLine()
{
return builder.AppendLine();
}
// other generated members omitted
}
乍一看,这似乎很棒,但实际上,实现是不正确的。请记住,StringBuilder
公开了一个流畅的 API,以便能够编写类似
myBuilder.Append("Hello").AppendLine(" World");
换句话说,它提供了一个流畅的界面。但是我们的室内设计师没有!例如,它不让我们写myBuilder.Append("x").Indent()
,因为 ReSharper 生成的Append()
的结果是一个没有Indent()
成员的StringBuilder
。没错——ReSharper 不知道我们想要一个合适的流畅界面。你想要的是在CodeBuilder
流畅的通话表现为
public class CodeBuilder
{
public CodeBuilder Append(char value, int repeatCount)
{
builder.Append(value, repeatCount);
return this; // return a CodeBuilder, not a StringBuilder
}
...
}
这是您需要手动或通过正则表达式来解决的问题。这一修改,当应用于每一个委托给StringBuilder
的呼叫时,将允许我们将StringBuilder
的呼叫与我们唯一的、CodeBuilder
特定的呼叫链接在一起。
适配器装饰器
你也可以有一个装饰器作为适配器。例如,假设我们想从前面获取CodeBuilder
,但是我们想让它开始充当string
。也许我们想把一个CodeBuilder
放入一个 API,该 API 期望我们的对象实现从string
赋值的=
操作符和追加额外字符串的+=
操作符。我们能让CodeBuilder
适应这些需求吗?我们当然可以;我们所要做的就是添加适当的功能:
public static implicit operator CodeBuilder(string s)
{
var cb = new CodeBuilder();
cb.sb.Append(s);
return cb;
}
public static CodeBuilder operator +(CodeBuilder cb, string s)
{
cb.Append(s);
return cb;
}
有了这个实现,我们现在可以开始处理一个CodeBuilder
,就好像它是一个string
:
CodeBuilder cb = "hello";
cb += " world";
WriteLine(cb); // prints "hello world"
奇怪的是,即使我们没有显式地实现operator +
,前面代码中的第二行也能工作。为什么?你自己想办法!
带接口的多重继承
除了扩展sealed
类之外,装饰器还会在你想要多个基类的时候出现……当然这是不可能的,因为 C# 不支持多重继承。例如,假设你有一条既是鸟又是蜥蜴的龙。这样写是有意义的:
public class Bird
{
public void Fly() { ... }
}
public class Lizard
{
public void Crawl() { ... }
}
public class Dragon : Bird, Lizard {} // cannot do this!
可悲的是,这是不可能的,所以你怎么做?嗯,你从Bird
和Lizard
中提取接口:
public interface IBird
{
void Fly();
}
public interface ILizard
{
void Crawl();
}
然后创建一个实现这些接口的Dragon
类,聚合Bird
和Lizard
的实例,并委托调用:
public class Dragon: IBird, ILizard
{
private readonly IBird bird;
private readonly ILizard lizard;
public Dragon(IBird bird, ILizard lizard)
{
this.bird = bird;
this.lizard = lizard;
}
public void Crawl()
{
lizard.Crawl();
}
public void Fly()
{
bird.Fly();
}
}
您会注意到这里有两种选择:要么在类中初始化默认的实例Bird
和Lizard
,要么通过在构造函数中使用这两个对象来为客户端提供更多的灵活性。这将允许你构建更复杂的IBird/ILizard
类,并把它们变成龙。此外,如果您走 IoC 路线,这种方法自动支持构造函数注入。
装饰器的一个有趣问题是 C++的“钻石继承”问题。假设一条龙只爬行到 10 岁,从那以后就只飞了。在这种情况下,Bird
和Lizard
类都有一个独立实现的Age
属性:
public interface ICreature
{
int Age { get; set; }
}
public interface IBird : ICreature
{
void Fly();
}
public interface ILizard : ICreature
{
void Crawl();
}
public class Bird : IBird
{
public int Age { get; set; }
public void Fly()
{
if (Age >= 10)
WriteLine("I am flying!");
}
}
public class Lizard : ILizard
{
public int Age { get; set; }
public void Crawl()
{
if (Age < 10)
WriteLine("I am crawling!");
}
}
注意,我们必须引入一个新的接口ICreature
,这样我们就可以将Age
作为IBird
和ILizard
接口的一部分公开。这里真正的问题是Dragon
类的实现,因为如果你使用 ReSharper 或类似工具的代码生成特性,你将简单地得到
public class Dragon : IBird, ILizard
{
...
public int Age { get; set; }
}
这再次表明,生成的代码并不总是您想要的。请记住,Bird.Fly()
和Lizard.Crawl()
都有自己的Age
的实现,这些实现需要保持一致,以便这些方法正确运行。这意味着Dragon.Age
的正确实现如下:
public int Age
{
get => bird.Age;
set => bird.Age = lizard.Age = value;
}
注意,我们的 setter 分配了两者,而 getter 只使用底层的bird
——这种选择是任意的;我们可以很容易地用lizard
的年龄来代替。setter 确保了一致性,所以理论上,这两个值总是相等的…除了在初始化期间,我们还没有注意到这一点。一个懒人对这个问题的解决方案是这样重新定义Dragon
构造函数:
public Dragon(IBird bird, ILizard lizard)
{
this.bird = bird;
this.lizard = lizard;
bird.Age = lizard.Age;
}
如您所见,构建装饰器通常很容易,除了两个细微差别:保持流畅界面的困难和钻石继承的挑战。我在这里演示了如何解决这两个问题。
具有默认接口成员的多重继承
使用 C# 8 的默认接口成员可以部分缓解Bird
和Lizard
的Age
属性之间的冲突。虽然它们没有给我们“适当的”、C++风格的多重继承,但它们给了我们足够的东西。
首先,我们为一个生物实现了一个基本接口:
public interface ICreature
{
int Age { get; set; }
}
这一步是必不可少的,因为现在我们可以定义接口IBird
和ILizard
,它们具有实际使用属性的默认方法实现:
public interface IBird : ICreature
{
void Fly()
{
if (Age >= 10)
WriteLine("I am flying");
}
}
public interface ILizard : ICreature
{
void Crawl()
{
if (Age < 10)
WriteLine("I am crawling!");
}
}
最后,我们可以创建一个实现这两个接口的类。当然,这个类必须提供Age
属性的实现,因为没有接口能够这样做:
public class Dragon : IBird, ILizard
{
public int Age { get; set; }
}
现在我们有了一个继承了两个接口行为的类。唯一需要注意的是,要真正利用这些行为,需要进行显式强制转换:
var d = new Dragon {Age = 5};
if (d is IBird bird)
bird.Fly();
if (d is ILizard lizard)
lizard.Crawl();
动态装饰组合
当然,一旦我们开始在现有类型上构建 decorator,我们就会遇到 decorator 组合的问题,也就是说,是否有可能用另一个 decorator 来装饰一个 decorator。我当然希望这是可能的——装饰器应该足够灵活地做到这一点!
对于我们的场景,让我们假设我们有一个名为Shape
的抽象基类,它有一个名为AsString()
的成员,这个成员返回一个描述这个形状的字符串(我在这里故意避免使用ToString()
):
public abstract class Shape
{
public virtual string AsString() => string.Empty;
}
我选择让Shape
成为一个具有默认无操作实现的抽象类。对于这个例子,我们同样可以使用一个IShape
接口。
我们现在可以定义一个具体的形状,比如说圆形或方形:
public sealed class Circle : Shape
{
private float radius;
public Circle() : this(0)
{}
public Circle(float radius)
{
this.radius = radius;
}
public void Resize(float factor)
{
radius *= factor;
}
public override string AsString() => $"A circle of radius {radius}";
}
// similar implementation of Square with “side” member omitted
我特意创建了Circle
和类似的类sealed
,所以我们不能简单地继承它们。相反,我们将再次构建装饰器:这一次,我们将构建两个——一个用于为形状添加颜色……
public class ColoredShape : Shape
{
private readonly Shape shape;
private readonly string color;
public ColoredShape(Shape shape, string color)
{
this.shape = shape;
this.color = color;
}
public override string AsString()
=> $"{shape.AsString()} has the color {color}";
}
另一个用于提供透明形状:
public class TransparentShape : Shape
{
private readonly Shape shape;
private readonly float transparency;
public TransparentShape(Shape shape, float transparency)
{
this.shape = shape;
this.transparency = transparency;
}
public override string AsString() =>
$"{shape.AsString()} has {transparency * 100.0f}% transparency";
}
如你所见,这两个装饰器都继承自抽象的Shape
类,所以它们本身是Shape
的,它们通过在构造函数中引入它们来装饰其他的Shape
。这允许我们一起使用它们,例如:
var circle = new Circle(2);
WriteLine(circle.AsString());
// A circle of radius 2
var redSquare = new ColoredShape(circle, "red");
WriteLine(redSquare.AsString());
// A circle of radius 2 has the color red
var redHalfTransparentSquare = new TransparentShape(redSquare, 0.5f);
WriteLine(redHalfTransparentSquare.AsString());
// A circle of radius 2 has the color red and has 50% transparency
如您所见,装饰器可以按照您希望的任何顺序应用到其他的Shape
中,保持AsString()
方法的一致输出。他们没有防范的一件事是循环重复:你可以构造一个ColoredShape(ColoredShape(Square))
,系统不会抱怨;即使我们想检测,我们也无法检测到这种情况。
这就是动态装饰器实现:我们称之为动态的原因是因为这些装饰器可以在运行时构建,对象将对象包装成洋葱的层。一方面,它非常方便,但另一方面,当你装饰对象时,你丢失了所有的类型信息。例如,修饰的Circle
不再能够访问它的Resize()
成员:
var redSquare = new ColoredShape(circle, "red");
redCircle.Resize(2); // oops!
这个问题不可能解决:因为ColoredShape
需要一个Shape
,允许调整大小的唯一方法是将Resize()
添加到Shape
本身,但是这个操作可能对所有形状都没有意义。这是动态装饰器的一个局限性。
静态装饰组合
当你得到一个动态修饰的ColorShape
时,如果不查看AsString()
的输出,就无法判断这个形状是圆形、方形还是其他形状。那么,如何将被修饰对象的底层类型“烘焙”成您所拥有的对象类型呢?事实证明你可以用泛型做到这一点。
这个想法很简单:我们的装饰器,比方说ColoredShape
,采用一个通用参数来指定它正在装饰的对象的类型。自然,该对象必须是一个Shape
,因为我们正在聚合它,所以它也需要一个构造函数:
public class ColoredShape<T> : Shape
where T : Shape, new()
{
private readonly string color;
private readonly T shape = new T();
public ColoredShape() : this("black") {}
public ColoredShape(string color) { this.color = color; }
public override string AsString() =>
return $"{shape.AsString()} has the color {color}";
}
好吧,这是怎么回事?我们有一个新的通用的ColoredShape
;它需要一个T
来继承一个Shape
。在内部,它存储了一个T
实例以及颜色信息。为了灵活起见,我们提供了两个构造函数:因为 C# 不像 C++,不支持构造函数转发,所以默认的构造函数对组合很有用(看,我们有new()
需求)。
我们现在可以提供一个类似的TransparentShape<T>
实现,有了这两者,我们现在可以构建如下形式的静态装饰器:
var blueCircle = new ColoredShape<Circle>("blue");
WriteLine(blueCircle.AsString());
// A circle of radius 0 has the color blue
var blackHalfSquare = new TransparentShape<ColoredShape<Square>>(0.4f);
WriteLine(blackHalfSquare.AsString());
// A square with side 0 has the color black and has transparency 40
这种静态方法有一定的优点和缺点。优点是我们保存了类型信息:给定一个Shape
,我们可以知道这个形状是一个ColoredShape<Circle>
,也许我们可以以某种方式对这个信息采取行动。可悲的是,这种方法有很多缺点:
-
请注意前面示例中的半径/边值都为零。这是因为我们不能在构造函数中初始化这些值:C# 没有构造函数转发。
-
我们仍然无法访问底层成员;比如
blueCircle.Resize()
还是不合法。 -
这些装饰器不能在运行时组合。
总而言之,在没有 CRTP 和 mixin 继承的情况下, 2 静态装饰器在 C# 中的用途非常非常有限。
功能装饰
功能装饰器是功能组合的自然结果。如果我们可以组合函数,我们同样可以将函数与其他函数包装在一起,以便提供日志记录等前后功能。
这里有一个非常简单的实现。假设您有一些工作需要完成:
let doWork() =
printfn "Doing some work"
我们现在可以创建一个装饰器函数(一个函数式装饰器!)对于任何给定的函数,它测量执行该函数需要多长时间:
let logger work name =
let sw = Stopwatch.StartNew()
printfn "%s %s" "Entering method" name
work()
sw.Stop()
printfn "Exiting method %s; %fs elapsed" name sw.Elapsed.TotalSeconds
我们现在可以在doWork
周围使用这个包装器,用一个具有相同接口但也执行一些测量的函数替换一个unit -> unit
函数:
let loggedWork() = logger doWork "doWork"
loggedWork()
// Entering method doWork
// Doing some work
// Exiting method doWork; 0.097824s elapsed
请注意本例中的圆括号:删除它们可能很诱人,但这将极大地改变数据结构的类型。请记住,任何let x = ...
构造将总是计算一个变量(可能是一个unit
类型!)而不是无参数函数,除非添加一个空的参数列表。
在这个实现中有几个问题。比如,doWork
不返回值;如果是这样,我们就必须以独立于类型的方式缓存它,这在 C++中是可能实现的,但在任何语言中都很难实现。网语。另一个问题是,我们无法确定包装函数的名称,所以我们最终将它作为一个单独的参数传递——这不是一个理想的解决方案!
摘要
装饰器给了一个类额外的功能,同时遵守了 OCP,减轻了与sealed
类和多重继承相关的问题。它的关键方面是可组合性:几个装饰器可以以任何顺序应用于一个对象。我们已经了解了以下类型的装饰器:
-
动态装饰器(Dynamic decorator),可以存储对被装饰对象的引用,并提供动态(运行时)可组合性。
-
静态装饰器,其保存关于装饰中涉及的对象类型的信息;因为它们不公开底层对象的成员,也不允许我们有效地构造构造函数调用,所以它们的用途有限。
在这两种情况下,我们完全忽略了与循环使用相关的问题:API 中没有任何东西阻止多次应用同一个静态或动态装饰器。
十一、外观
首先,让我们来解决语言问题:字母\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\欢迎你们中特别迂腐的人在代码中使用字母,,因为编译器对此处理得很好。?? 1
现在,关于模式本身…本质上,我能想到的最好的类比是一个典型的房子。当你买房子的时候,你通常关心外观和内部。你不太关心内部:电气系统,绝缘,卫生,诸如此类的东西。这些部分都同样重要,但我们希望它们“正常工作”而不会损坏。你更有可能购买新家具,而不是更换锅炉的电线。
同样的想法也适用于软件:有时你需要以简单的方式与复杂的系统交互。我们所说的“系统”可以指一组组件,或者只是一个具有相当复杂的 API 的组件。例如,考虑一下从 URL 下载一串文本这个看似简单的任务。使用各种System.Net
数据类型的完整解决方案如下所示:
string url = "http://www.google.com/robots.txt";
var request = WebRequest.Create(url);
request.Credentials = CredentialCache.DefaultCredentials;
var response = request.GetResponse();
var dataStream = response.GetResponseStream();
var reader = new StreamReader(dataStream);
string responseFromServer = reader.ReadToEnd();
Console.WriteLine(responseFromServer);
reader.Close();
response.Close();
这是很大的工作量!此外,我几乎可以保证,如果不在 MSDN 上查找,你们中的大多数人都无法写出这段代码。这是因为有几个底层数据类型使得该操作成为可能。如果你想异步完成,你必须使用由XxxAsync()
方法组成的补充 API 集。
因此,每当我们遇到需要不同部分进行复杂交互才能完成某件事情的情况时,我们可能会希望将其隐藏在一个外观之后,也就是说,一个简单得多的界面。在下载网页的情况下,所有前面的代码都简化为一行:
new WebClient().DownloadString(url);
在这个例子中,WebClient
类是 facade,也就是一个漂亮的、用户友好的界面,它可以快速地完成您想要的任务,没有任何仪式。当然,您也可以使用原始的 API,这样,如果您需要更复杂的东西(例如,提供凭证),您可以使用更技术性的部分来微调程序的操作。
通过这个例子,您已经掌握了外观设计模式的要点。然而,为了进一步说明这个问题(以及讲述 OOP 在实践中是如何被使用和滥用的),我想再举一个例子。
魔术方块
虽然一个适当的门面演示要求我们制作超级复杂的系统,实际上保证一个门面放在它们前面,让我们考虑一个平凡的例子:制作幻方的过程。幻方是一种矩阵,例如
1 | 14 | 14 | 4
----+----+----+----
11 | 8 | 6 | 9
----+----+----+----
8 | 10 | 10 | 5
----+----+----+----
13 | 2 | 3 | 15
如果您将任何行、任何列或任何对角线上的值相加,您将得到相同的数字——在本例中是 33。如果我们想要生成我们自己的幻方,我们可以把它想象成三个不同子系统的相互作用:
-
Generator
:一个简单地产生一系列特定大小的随机数的组件 -
Splitter
:获取一个矩形矩阵并输出一组代表矩阵中所有行、列和对角线的列表的组件 -
Verifier
:检查传入的所有列表的总和是否相同的组件
我们从实现Generator
开始:
public class Generator
{
private static readonly Random random = new Random();
public List<int> Generate(int count)
{
return Enumerable.Range(0, count)
.Select(_ => random.Next(1, 6))
.ToList();
}
}
请注意,生成器生成一维列表,而下一个组件Splitter
接受一个矩阵:
public class Splitter
{
public List<List<int>> Split(List<List<int>> array)
{
// implementation omitted
}
}
Splitter
的实现相当冗长,所以我在这里省略了它——查看源代码了解它的具体细节。如您所见,Splitter
返回一个列表列表。我们的最后一个组件,Verifier
,检查这些列表加起来都是同一个数字:
public class Verifier
{
public bool Verify(List<List<int>> array)
{
if (!array.Any()) return false;
var expected = array.First().Sum();
return array.All(t => t.Sum() == expected);
}
}
这就是你要的——我们有三个不同的子系统,它们应该协同工作来产生随机的幻方。但是它们好用吗?如果我们将这些类交给客户,他们将很难正确操作它们。那么,怎样才能让他们的生活变得更好呢?
答案很简单:我们构建一个外观,本质上是一个包装类,它隐藏了所有这些实现细节,并提供了一个非常简单的接口。当然,它在幕后使用了所有三个类:
public class MagicSquareGenerator
{
public List<List<int>> Generate(int size)
{
var g = new Generator();
var s = new Splitter();
var v = new Verifier();
var square = new List<List<int>>();
do
{
square = new List<List<int>>();
for (int i = 0; i < size; ++i)
square.Add(g.Generate(size));
} while (!v.Verify(s.Split(square)));
return square;
}
}
这就是了!现在,如果客户想要生成一个 3x3 的幻方,他们所要做的就是调用
var gen = new MagicSquareGenerator();
var square = gen.Generate(3);
他们会得到类似
3 1 5
5 3 1
1 5 3
好的,所以这个是一个魔方,但是也许这个 API 的用户有一个额外的要求:他们不希望数字重复。我们如何让他们轻松实现这一点?首先,我们更改Generate()
以将每个子系统作为通用参数:
private List<List<int>> generate
<TGenerator, TSplitter, TVerifier>(int size)
where TGenerator : Generator, new()
where TSplitter : Splitter, new()
where TVerifier : Verifier, new()
{
var g = new TGenerator();
var s = new TSplitter();
var v = new TVerifier();
// rest of code as before
}
现在我们简单地创建一个重载的Generate()
,它应用了所有三个默认的通用参数:
public List<List<int>> Generate(int size)
{
return Generate<Generator, Splitter, Verifier>(size);
}
在缺少缺省通用参数的情况下,这是我们能够提供合理的缺省值,同时允许定制的唯一方法。现在,如果用户想要确保所有的值都是唯一的,他们可以创建一个UniqueGenerator
:
public class UniqueGenerator : Generator
{
public override List<int> Generate(int count)
{
List<int> result;
do
{
result = base.Generate(count);
} while (result.Distinct().Count() != result.Count);
return result;
}
}
然后把它放在正面,这样就有了一个更好的魔方:
var gen = new MagicSquareGenerator();
var square = gen
.Generate<UniqueGenerator, Splitter, Verifier>(3);
这给了我们
8 1 6
3 5 7
4 9 2
当然,以这种方式生成幻方实际上是不切实际的,但是这个例子所展示的是,您可以将不同系统之间复杂的交互隐藏在一个门面后面,并且您还可以合并一定量的可配置性,以便用户可以在需要时定制该机制的内部操作。
建设交易终端
我花了很多时间在定量金融和算法交易领域工作。正如你可能猜到的,一个好的交易终端需要的是将信息快速传递到交易者的大脑中:你希望事情尽可能快地呈现出来,没有任何延迟。
大多数财务数据(除了图表)实际上都是纯文本呈现的:黑色屏幕上的白色字符。在某种程度上,这类似于终端/控制台/命令行界面在您自己的操作系统中的工作方式,但是有一个微妙的区别。
终端窗口的第一部分是缓冲区。这是存储渲染角色的地方。缓冲区是内存的一个矩形区域,通常是 1D 2 或 2D char
数组。一个缓冲区可以比终端窗口的可视区域大得多,所以它可以存储一些您可以回滚到的历史输出。
通常,缓冲器具有指定当前输入行的指针(例如,整数)。这样,一个满的缓冲区不会重新分配所有的行;它只是覆盖最老的一个。
然后还有一个视口的想法。视口呈现特定缓冲区的一部分。缓冲区可能很大,因此视口只需从缓冲区中取出一个矩形区域并进行渲染。当然,视口的大小必须小于或等于缓冲区的大小。
最后,还有控制台(终端窗口)本身。控制台显示视口,允许上下滚动,甚至接受用户输入。控制台实际上是一个门面:一个相当复杂的幕后设置的简化表示。
通常,大多数用户与单个缓冲区和视口进行交互。然而,是,可以有一个控制台窗口,比如说,在两个视口之间垂直分割区域,每个视口都有相应的缓冲区。这可以通过使用实用程序来完成,比如 Linux 命令screen
。
先进的终端
典型的操作系统终端的一个问题是,如果你通过管道向它输入大量数据,它会非常慢(??)。比如一个 Windows 终端窗口(cmd.exe
)使用 GDI 来渲染字符,完全没有必要。在快节奏的交易环境中,您希望渲染是硬件加速的:角色应该呈现为使用 API(如 OpenGL)放置在表面上的预渲染纹理。 3
一个交易终端由多个缓冲区和视窗组成。在典型的设置中,不同的缓冲区可能会同时更新来自不同交易所或交易机器人的数据,所有这些信息都需要显示在一个屏幕上。
缓冲区还提供了比 1D 或 2D 线性存储更令人兴奋的功能。例如,TableBuffer
可以定义为
public class TableBuffer : IBuffer
{
private readonly TableColumnSpec[] spec;
private readonly int totalHeight;
private readonly List<string[]> buffer;
private static readonly Point invalidPoint = new Point(-1,-1);
private readonly short[,] formatBuffer;
public TableBuffer(TableColumnSpec [] spec, int totalHeight)
{
this.spec = spec;
this.totalHeight = totalHeight;
buffer = new List<string[]>();
for (int i = 0; i < (totalHeight - 1); ++i)
{
buffer.Add(new string[spec.Length]);
}
formatBuffer = new short[spec.Max(s => s.Width),totalHeight];
}
public struct TableColumnSpec
{
public string Header;
public int Width;
public TableColumnAlignment Alignment;
}
}
换句话说,一个缓冲区可以接受一些规范并构建一个表(是的,一个很好的老式 ASCII 格式的表!)并呈现在屏幕上。 4
视口负责从缓冲区获取数据。它的一些特征包括如下:
-
对它所显示的缓冲区的引用。
-
它的大小。
-
如果视口小于缓冲区,它需要指定要显示缓冲区的哪一部分。这用绝对 x-y 坐标表示。
-
整个控制台窗口上视区的位置。
-
光标的位置,假设该视口当前正在接受用户输入。
门面在哪里?
控制台本身是这个特殊系统的门面。在内部,控制台必须管理许多不同的内部设置:
public class Console : Form
{
private readonly Device device;
private readonly PresentParameters pp;
private IList<Viewport> viewports;
private Size charSize;
private Size gridSize;
// many more fields here
}
控制台的初始化通常也是一件非常讨厌的事情。至少,您需要指定单个字符的大小以及控制台的宽度和高度(根据字符数)。在某些情况下,您确实希望极其详细地指定控制台参数,但是在紧急情况下,您只需要一组合理的默认值。
然而,由于它是一个外观,它实际上试图给出一个真正可访问的 API。这可能需要一些合理的参数来初始化所有的内容。
private Console(bool fullScreen, int charWidth, int charHeight,
int width, int height, Size? clientSize)
{
int windowWidth =
clientSize == null ? charWidth*width : clientSize.Value.Width;
int windowHeight =
clientSize == null ? charHeight*height : clientSize.Value.Height;
// and a lot more code
// single buffer and viewport created here
// linked together and added to appropriate collections
// image textures generated
// grid size calculated depending on whether we want fullscreen mode
}
或者,可以将所有这些参数打包到一个对象中,这个对象也有一些合理的缺省值:
public static Console Create(ConsoleCreationParameters ccp) { ... }
public class ConsoleCreationParameters
{
public Size? ClientSize;
public int CharacterWidth = 10;
public int CharacterHeight = 14;
public int Width = 20;
public int Height = 30;
public bool FullScreen;
public bool CreateDefaultViewAndBuffer = true;
}
如您所见,对于我们构建的外观,至少有三种设置控制台的方式:
-
使用低级 API 显式配置控制台,包括视口和缓冲区。
-
使用
Console
构造函数,它要求你提供更少的值,并做一些有用的假设(例如,你只需要一个带有底层缓冲区的视口)。 -
使用接受
ConsoleCreationParameters
对象的构造函数。这要求您提供更少的信息,因为该结构的每个字段都有合适的默认值。
摘要
外观设计模式是一种将简单界面放在一个或多个复杂子系统前面的方式。正如我们在 magic square 示例中所看到的,除了提供方便的界面之外,还可以公开内部机制,并允许高级用户进一步定制。类似地,在我们的最后一个例子中,可以直接使用涉及许多缓冲区和视窗的复杂设置,或者如果您只是想要一个具有单个缓冲区和相关视窗的简单控制台,您可以通过一个非常容易访问和直观的 API 来获得它。
十二、享元
Flyweight(有时也称为令牌或 cookie )是一个临时组件,充当某个东西的“智能引用”。通常,flyweights 用于拥有大量非常相似的对象的情况,并且您希望最小化专用于存储所有这些值的内存量。
让我们看一些与这种模式相关的场景。
用户名
想象一个大型多人在线游戏。我跟你赌 20 美元,有不止一个用户叫约翰·史密斯——很简单,因为这是一个流行的名字。因此,如果我们要反复存储这个名字(用 UTF-16 格式),我们将花费 10 个字符(再加上每个string
的几个字节)。相反,我们可以将名称存储一次,然后存储对使用该名称的每个用户的引用。那是相当节省的。
此外,史密斯这个姓氏本身也很受欢迎。因此,不是存储全名,而是将名称分成第一个和最后一个将允许进一步的优化,因为您可以简单地将"Smith"
存储在索引存储中,然后简单地存储索引值而不是实际的字符串。
让我们看看如何实现这样一个节省空间的系统。实际上,我们将通过强制垃圾收集和使用 dotMemory 测量占用的内存量来科学地做到这一点。
这是第一个简单的User
类的实现。请注意,全名保留为单个字符串。
public class User
{
public string FullName { get; }
public User(string fullName)
{
FullName = fullName;
}
前面代码的含义是“约翰·史密斯”和“简·史密斯”是不同的字符串,各自占用自己的内存。现在我们可以构造一个替代类型User2
,它在公开相同 API 的同时,在存储方面更加智能(为了简洁,我在这里避免提取一个IUser
接口):
public class User2
{
private static List<string> strings = new List<string>();
private int[] names;
public User2(string fullName)
{
int getOrAdd(string s)
{
int idx = strings.IndexOf(s);
if (idx != -1) return idx;
else
{
strings.Add(s);
return strings.Count - 1;
}
}
names = fullName.Split(' ').Select(getOrAdd).ToArray();
}
public string FullName => string.Join(" ", names.Select(i => strings[i]));
}
如您所见,实际的字符串存储在一个单独的List
中。当全名输入构造函数时,它被分成几个组成部分。每个部分都被插入到字符串列表中(除非它已经在那里了),names
数组只是存储列表中名称的索引,不管有多少。这意味着,尽管有字符串,非静态内存User2
占用的内存量是 64 位(两个Int32
)。
现在是时候停下来解释一下 Flyweight 的确切位置了。本质上,flyweight 是我们存储的索引。flyweight 是一个很小的对象,内存占用很小,指向存储在其他地方的更大的对象。
剩下的唯一问题是这种方法实际上是否有意义。虽然很难在实际用户身上模拟这种情况(这需要一个实时数据集),但我们将采取以下措施:
-
生成 100 个名字和 100 个姓氏作为随机字符串。制作随机字符串的算法如下:
-
接下来,我们将每个名和姓连接起来(叉积),并初始化 100x100 个用户:
public static string RandomString()
{
Random rand = new Random();
return new string(
Enumerable.Range(0, 10)
.Select(i => (char) ('a' + rand.Next(26))).ToArray());
}
-
为了安全起见,我们在这一点上强制使用 GC。
-
最后,我们使用 dotMemory 单元测试 API 来输出程序占用的内存总量。
var users = new List<User>(); // or User2
foreach (var firstName in firstNames)
foreach (var lastName in lastNames)
users.Add(new User($"{firstName} {lastName}"));
在我的机器上运行这个完全不科学(但具有指示性)的测试告诉我,User2
实现为我们节省了 329,305 字节。这有意义吗?好吧,让我们试着计算一下:一个 10 个字符的字符串占用 34 个字节(14 个字节 1 + 2x10 个字节为字母),所以所有的字符串有 340,000 个字节。这意味着我们减少了 97%的内存占用量!如果这不是庆祝的理由,我不知道什么才是。
文本格式
假设您正在使用一个文本编辑器,并且想要为文本添加格式,例如,将文本加粗、倾斜或大写。你会怎么做?一种选择是单独处理每个字符:如果你的文本由 X 个字符组成,你可以创建一个大小为 X 的bool
数组,如果你想改变文本,只需翻转每个标志。这将导致以下实现:
public class FormattedText
{
private string plainText;
public FormattedText(string plainText)
{
this.plainText = plainText;
capitalize = new bool[plainText.Length];
}
public void Capitalize(int start, int end)
{
for (int i = start; i <= end; ++i)
capitalize[i] = true;
}
private bool[] capitalize;
}
我在这里使用大写字母(因为这是文本控制台可以呈现的),但是您也可以想到其他格式。对于每种类型的格式,你都要创建另一个布尔数组,在构造函数中将其初始化为正确的大小(想象一下如果文本改变了会有多可怕!),然后,当然,当您实际想要在某处显示文本时,您需要考虑这些布尔标志:
public override string ToString()
{
var sb = new StringBuilder();
for (var i = 0; i < plainText.Length; i++)
{
var c = plainText[i];
sb.Append(capitalize[i] ? char.ToUpper(c) : c);
}
return sb.ToString();
}
这种方法实际上是可行的:
var ft = new FormattedText("This is a brave new world");
ft.Capitalize(10, 15);
WriteLine(ft); // This is a BRAVE new world
但是我们当然是在浪费内存。即使文本没有任何 ?? 格式,我们仍然分配了数组。的确,我们可以让它变得懒惰,只在有人使用Capitalize()
方法时才创建它,但这样我们在第一次使用时仍然会丢失很多内存,特别是对于大文本。
这正是 Flyweight 设计模式的初衷!在这个特殊的例子中,我们将把 flyweight 定义为一个Range
类,它存储关于字符串中子串的开始和结束位置的信息,以及我们需要的所有格式信息:
public class TextRange
{
public int Start, End;
public bool Capitalize; // also Bold, Italic, etc.
public bool Covers(int position)
{
return position >= Start && position <= End;
}
}
现在,我们可以定义一个BetterFormattedText
类,它简单地存储所有应用的格式列表:
public class BetterFormattedText
{
private readonly string plainText;
private readonly List<TextRange> formatting
= new List<TextRange>();
public BetterFormattedText(string plainText)
{
this.plainText = plainText;
}
public TextRange GetRange(int start, int end)
{
var range = new TextRange {Start = start, End = end};
formatting.Add(range);
return range;
}
public class TextRange { ... }
}
注意TextRange
是一个内部类——这是一个设计决策,你可以很容易地将它保留在外部。现在,代替专用的Capitalize()
方法,我们简单地用一个叫做GetRange()
的方法来做三件事:它创建一个新的范围,把它添加到一个格式列表中,而且还把它返回给客户机进行操作。
现在剩下的就是对ToString()
做一个新的实现,结合这种基于 flyweight 的方法。这是:
public override string ToString()
{
var sb = new StringBuilder();
for (var i = 0; i < plainText.Length; i++)
{
var c = plainText[i];
foreach (var range in formatting)
if (range.Covers(i) && range.Capitalize)
c = char.ToUpperInvariant(c);
sb.Append(c);
}
return sb.ToString();
}
如你所见,我们简单地迭代每个字符。对于每个字符,我们用Covers()
方法检查所有的范围,如果该范围覆盖了这一点并且有特殊的格式,我们就向最终用户显示该格式。下面是新 API 的使用方法:
var bft = new BetterFormattedText("This is a brave new world");
bft.GetRange(10, 15).Capitalize = true;
WriteLine(bft); // This is a BRAVE new world
不可否认,我们的是一个相当低效的 Flyweight 实现(遍历每个字符都太单调乏味了),但是很明显,从长远来看,这种通用方法节省了大量内存。
摘要
Flyweight 模式基本上是一种节省空间的技术。它的具体体现是多种多样的:有时您将 Flyweight 作为 API 令牌返回,允许您对生成它的任何人进行修改,而在其他时候,Flyweight 是隐式的,隐藏在幕后——就像我们的User
的情况一样,客户端并不知道实际使用的 Flyweight。
在。NET 框架中,主要的类似 Flyweight 的对象当然是Span<T>
。就像我们在处理字符串时实现的TextRange
一样,Span<T>
是一种类型,它拥有关于数组的一部分的信息:起始位置和长度。对Span
的操作应用于Span
引用的对象。NET 为在不同类型的对象上创建跨度提供了丰富的 API。Span
也大量使用 C# 7 的ref
相关的 API(比如ref
returns)。
十三、代理
当我们查看装饰设计模式时,我们看到了增强对象功能的不同方式。代理设计模式是类似的,但是它的目标通常是精确地(或者尽可能接近地)保留正在使用的 API,同时提供某些内部增强。
代理是一种不寻常的设计模式,因为它并不是真正同质的。人们建立的许多不同种类的代理相当多,并且服务于完全不同的目的。在这一章中,我们将看看不同的代理对象的选择,你可以在网上找到更多。
保护代理
顾名思义,保护代理的思想是提供对现有对象的访问控制。例如,您可能从一个名为Car
的对象开始,它有一个让您驾驶汽车的Drive()
方法(这是另一个合成示例)。
public class Car // : ICar
{
public void Drive()
{
WriteLine("Car being driven");
}
}
但是,后来,你决定只让年龄足够大的人开车。如果你不想改变汽车本身,你想额外的检查在其他地方进行(SRP)呢?让我们看看…首先,你提取ICar
接口(注意这个操作不会对 Car 有任何显著的影响):
public interface ICar
{
void Drive();
}
我们要构建的保护代理将依赖于这样定义的驱动程序:
public class Driver
{
public int Age { get; set; }
public Driver(int age)
{
Age = age;
}
}
代理本身将在构造函数中接受一个Driver
,它将公开与原始汽车相同的ICar
接口,唯一的区别是会进行一些内部检查,以确保司机足够老:
public class CarProxy : ICar
{
private Car car = new Car();
private Driver driver;
public CarProxy(Driver driver)
{
this.driver = driver;
}
public void Drive()
{
if (driver.Age >= 16)
car.Drive();
else
{
WriteLine("Driver too young");
}
}
}
下面是使用这个代理的方法:
ICar car = new CarProxy(new Driver(12));
car.Drive(); // Driver too young
有一个难题我们还没有真正解决。尽管Car
和CarProxy
都实现了ICar
,但是它们的构造函数并不相同!这意味着,严格来说,这两个对象的接口并不完全相同。这是个问题吗?这要看情况
-
如果您的代码依赖于
Car
而不是ICar
(违反了 DIP),那么您将需要在代码中搜索并替换这种类型的每一次使用。使用 ReSharper/Rider 这样的工具并非不可能,只是真的很烦人。 -
如果您的代码依赖于
ICar
,但是您显式地调用了Car
构造函数,那么您必须找到所有这些构造函数调用,并为它们提供一个Driver
。 -
如果使用依赖注入,只要在容器中注册一个
Driver
就可以了。
因此,在其他事情中,我们构建的保护代理是使用具有构造函数注入支持的 IoC 容器的好处的一个例证。
财产代理
C# 使属性的使用变得简单:你可以使用“完整”或自动属性,现在有了基于表达式的 getters 和 setters 符号,所以你可以保持属性的简洁。然而,这并不总是您想要的:有时,您希望代码中每个属性的 getter 或 setter 除了默认操作之外还做一些事情。例如,您可能希望 setters 阻止自赋值,并且(出于说明的目的)输出一些关于什么值被赋值给什么属性的信息。
因此,不使用普通的属性,您可能想要引入一个属性代理——一个类,对于所有意图和目的来说,它的行为像一个属性,但实际上是一个具有特定于域的行为(以及相关的性能成本)的单独的类。您可以通过包装一个简单的值并添加您希望属性拥有的任何额外信息(例如,属性名)来开始构建这个类:
public class Property<T> where T : new()
{
private T value;
private readonly string name;
public T Value
{
get => value;
set
{
if (Equals(this.value, value)) return;
Console.WriteLine($"Assigning {value} to {name}");
this.value = value;
}
}
public Property() : this(default(T)) {}
public Property(T value, string name = "")
{
this.value = value;
this.name = name;
}
}
目前,我们拥有的只是一个简单的包装器,但是它的代理部分在哪里呢?毕竟,我们希望一个Property<int>
尽可能地接近一个int
。为此,我们可以定义几个隐式转换运算符:
public static implicit operator T(Property<T> property)
{
return property.Value; // int n = p_int;
}
public static implicit operator Property<T>(T value)
{
return new Property<T>(value); // Property<int> p = 123;
}
第一个操作符让我们隐式地将属性类型转换为它的底层值;第二个操作符让我们从一个值初始化一个属性(当然没有name
)。遗憾的是,C# 不允许我们覆盖赋值操作符=
。
你将如何使用这个属性代理?嗯,我能想到两种方法。一个,也是最明显的,是将属性公开为公共字段:
public class Creature
{
public Property<int> Agility
= new Property<int>(10, nameof(Agility))
}
不幸的是,这种方法不是一种“合适的”代理,因为虽然它复制了普通属性的接口,但它没有提供我们想要的行为:
var c = new Creature();
c.Agility = 12; // <nothing happens!>
当你赋一个值时,就像你赋一个普通的属性一样,什么也不会发生。为什么呢?原因是我们调用了隐式转换操作符,它没有改变现有的属性,而是给了我们一个新的属性!这肯定不是我们想要的,此外,我们已经丢失了name
值,因为它从未被操作符传播。
因此,如果我们真的希望属性既像鸭子又像鸭子一样嘎嘎叫,这里的解决方案是创建一个包装器(委托)属性,并将代理作为私有支持字段:
public class Creature
{
public readonly Property<int> agility
= new Property<int>(10, nameof(agility));
public int Agility
{
get => agility.Value;
set => agility.Value = value;
}
}
通过这种方法,我们最终得到了想要的行为:
var c = new Creature();
c.Agility = 12; // Assigning 12 to Agility
纯粹主义者可能会认为这不是一个理想的代理(因为我们必须生成一个新的类以及重写一个现有的属性),但这纯粹是 C# 编程语言的一个局限。
价值代理
值代理是围绕原始值(如整数)的代理。你为什么想要这样的代理?嗯,是因为某些原始值可以有特殊的含义。
考虑百分比。乘以 50 与乘以 50%不同,因为后者实际上是乘以 0.5。但是你还是想在你的代码里把 50%称为 50%,对吧?看看能不能造一个Percentage
型。
首先,我们需要在结构上达成一致。让我们假设我们确实在幕后存储了一个decimal
,它实际上是一个乘数。在这种情况下,我们可以如下开始我们的Percentage
类:
[DebuggerDisplay("{value*100.0f}%")]
public struct Percentage
{
private readonly decimal value;
internal Percentage(decimal value)
{
this.value = value;
}
// more members here
}
对于如何实际构造百分比值,我们有不同的选择。一种方法是采用扩展方法:
public static class PercentageExtensions
{
public static Percentage Percent(this int value)
{
return new Percentage(value/100.0m);
}
public static Percentage Percent(this decimal value)
{
return new Percentage(value/100.0m);
}
}
我们希望这个百分比与 Microsoft Excel 中的百分比值一样。乘以 50%应该有效乘以 0.5;其他操作应该以类似的方式工作。因此,我们需要定义许多运算符,例如
public static decimal operator *(decimal f, Percentage p)
{
return f * p.value;
}
我们不要忘记,百分比也可以对其他百分比进行运算:例如,你可以将 5%和 10%相加,同样,你可以取 50%的 50%(得到 25%)。所以你需要更多的操作符,比如
public static Percentage operator +(Percentage a, Percentage b)
{
return new Percentage(a.value + b.value);
}
此外,您还需要常见的装饰:Equals()
、GetHashCode()
,以及一个有意义的ToString()
,比如
public override string ToString()
{
return $"{value*100}%";
}
这就是你的价值代理。现在,如果您需要在应用中对百分比进行操作,并将其显式存储为百分比,您可以这样做。
Console.WriteLine(10m * 5.Percent()); // 0.50
Console.WriteLine(2.Percent() + 3m.Percent()); // 5.00%
复合代理:SoA/AoS
许多应用,如游戏引擎,对数据局部性非常敏感。例如,考虑下面的类:
class Creature
{
public byte Age;
public int X, Y;
}
如果你的游戏中有几个生物,放在一个数组中,你的数据的内存布局将显示为
Age X Y Age X Y Age X Y ... and so on
这意味着,如果您想要更新数组中所有对象的 X 坐标,您的迭代代码将不得不跳过其他字段来获取每个 X。
原来 CPU 一般都喜欢数据局部性,也就是数据放在一起。这通常被称为 AoS/SoA(结构的阵列/阵列的结构)问题。对我们来说,如果内存布局采用 SoA 形式会好得多,如下所示:
Age Age Age ... X X X ... Y Y Y
如何才能实现这一点?嗯,我们可以构建一个数据结构,完全保持这样的布局,然后将Creature
对象作为代理公开。
我的意思是。首先,我们创建一个Creatures
集合(我使用数组作为底层数据类型),为每个“字段”实施数据局部性:
class Creatures
{
private readonly int size;
private byte [] age;
private int[] x, y;
public Creatures(int size)
{
this.size = size;
age = new byte[size];
x = new int[size];
y = new int[size];
}
}
现在,Creature
类型可以被构造成一个空心代理(一个无状态代理/备忘录合并),指向Creatures
容器中的一个元素。
public struct Creature
{
private readonly Creatures creatures;
private readonly int index;
public Creature(Creatures creatures, int index)
{
this.creatures = creatures;
this.index = index;
}
public ref byte Age => ref creatures.age[index];
public ref int X => ref creatures.x[index];
public ref int Y => ref creatures.y[index];
}
注意前面的类是嵌套在Creatures
中的。这样做的原因是它的属性 getters 需要访问Creatures
的private
成员,如果类和容器在同一个范围内,这是不可能的。
所以现在我们有了这个代理,我们可以给Creatures
容器额外的特性,比如一个索引器或者一个GetEnumerator()
实现:
public class Creatures
{
// members here
public Creature this[int index]
=> new Creature(this, index);
public IEnumerator<Creature> GetEnumerator()
{
for (int pos = 0; pos < size; ++pos)
yield return new Creature(this, pos);
}
}
就这样!我们现在可以对 AoS 方法和新的 SoA 方法进行对比:
// AoS
var creatures = new Creature[100];
foreach (var c in creatures)
{
c.X++; // not memory-efficient
}
// SoA
var creatures2 = new Creatures(100);
foreach (var c in creatures2)
{
c.X++;
}
当然,我在这里展示的是一个简单的模型。如果用像List<T>
这样更灵活的数据结构来代替数组,会更有用,并且可以添加更多的特性来使Creatures
更加用户友好。
具有阵列支持属性的复合代理
假设您正在开发一个生成砌砖设计的应用。您需要决定要用砖块覆盖哪些表面,因此您需要制作如下复选框列表:
-
台柱
-
墙壁
-
地面
-
全部
其中大多数都很简单,可以一对一地绑定到boolean
变量,但是最后一个选项All
不能。你如何用代码实现它?嗯,你可以试试下面的方法:
public class MasonrySettings
{
public bool Pillars, Walls, Floors;
public bool All
{
get { return Pillars && Walls && Floors; }
set {
Pillars = value;
Walls = value;
Floors = value;
}
}
}
这种实现可能有效,但不是 100%正确。最后一个名为All
的复选框实际上甚至不是boolean
,因为它可以有三种状态:
-
如果检查了所有项目,则检查
-
如果取消选中所有项目,则取消选中
-
如果某些项目被选中,而其他项目未被选中,则显示为灰色
这使它变得有点困难:我们如何为这个元素的状态创建一个变量,并可靠地绑定到 UI?
首先,那些用&&
的组合很丑。我们已经有了一个叫做数组支持属性的工具,它可以帮助我们处理这个问题,将类转换成
public class MasonrySettings
{
private bool[] flags = new bool[3];
public bool Pillars
{
get => flags[0];
set => flags[0] = value;
}
// similar for Floors and Walls
}
现在,想猜猜All
变量应该是什么类型吗?就我个人而言,我会选择bool?
(又名Nullable<bool>
),其中null
可以表示一种不确定的状态。这意味着我们检查数组中每个元素的同质性,如果它是同质的(即所有的元素都是相同的)就返回它的第一个元素,否则返回null
:
public bool? All
{
get
{
if (flags.Skip(1).All(f => f == flags[0]))
return flags[0];
return null;
}
set
{
if (!value.HasValue) return;
for (int i = 0; i < flags.Length; ++i)
flags[i] = value.Value;
}
}
前面的 getter 是不言自明的。对于 setter,它的值被赋给数组中的每个元素。如果传入的值是null
,我们什么都不做。例如,另一种实现可以翻转数组中的每个布尔成员——这是您的选择!
虚拟代理
有些情况下,您只想在对象被访问时构造它,而不想过早地分配它。如果这是您的开始策略,您通常会使用Lazy<T>
或类似的机制,将初始化代码输入到它的构造函数 lambda 中。但是,有些情况下,当您在稍后的时间点添加惰性实例化时,您无法更改现有的 API。
在这种情况下,您最终构建的是一个虚拟代理:一个与原始对象具有相同 API 的对象,给出了实例化对象的外观,但是在幕后,代理仅在实际需要时实例化该对象。
想象一个典型的图像界面:
interface IImage
{
void Draw();
}
一个Bitmap
(与System.Drawing.Bitmap
无关)的热切(与懒惰相反)的实现!)将在构造时从文件中加载图像,即使该图像实际上并不需要。是的,下面的代码是一个模拟:
class Bitmap : IImage
{
private readonly string filename;
public Bitmap(string filename)
{
this.filename = filename;
WriteLine($"Loading image from {filename}");
}
public void Draw()
{
WriteLine($"Drawing image {filename}");
}
}
这个Bitmap
的构造动作将触发图像的加载:
var img = new Bitmap("pokemon.png");
// Loading image from pokemon.png
那不完全是我们想要的。我们想要的是那种只在使用Draw()
方法时才加载自身的位图。现在,我想我们可以跳回到Bitmap
中,让它变得懒惰,但是我们要假设最初的实现是固定的,不可修改的。
因此,我们可以构建一个虚拟代理,它将使用原始的Bitmap
,提供一个相同的接口,并重用原始的Bitmap’s
功能:
class LazyBitmap : IImage
{
private readonly string filename;
private Bitmap bitmap;
public LazyBitmap(string filename)
{
this.filename = filename;
}
public void Draw()
{
if (bitmap == null)
bitmap = new Bitmap(filename);
bitmap.Draw();
}
}
我们到了。正如你所看到的,这个LazyBitmap
的构造函数要简单得多:它所做的只是存储要从中加载图像的文件名,仅此而已——图像实际上并没有被加载。1
所有的神奇都发生在Draw()
中:这是我们检查bitmap
引用的地方,以查看底层的(eager!)位图已被构造。如果没有,我们就构造它,然后调用它的Draw()
函数来实际绘制图像。
现在假设您有一些使用IImage
类型的 API:
public static void DrawImage(IImage img)
{
WriteLine("About to draw the image");
img.Draw();
WriteLine("Done drawing the image");
}
我们可以使用带有实例LazyBitmap
的 API 来代替Bitmap
(万岁,多态!)渲染图像,以惰性方式加载图像:
var img = new LazyBitmap("pokemon.png");
DrawImage(img); // image loaded here
// About to draw the image
// Loading image from pokemon.png
// Drawing image pokemon.png
// Done drawing the image
通信代理
假设您在类型为Bar
的对象上调用方法Foo()
。你的典型假设是Bar
已经被分配到运行你的代码的同一台机器上,你同样期望Bar.Foo()
在同一个进程中执行。
现在想象一下,您做出一个设计决策,将Bar
及其所有成员转移到网络上的另一台机器上。但是你仍然希望旧代码工作!如果你想继续像以前一样,你需要一个通信代理——一个代理“通过线路”调用的组件,当然,如果必要的话,还可以收集结果。
让我们实现一个简单的乒乓服务来说明这一点。首先,我们定义一个接口:
interface IPingable
{
string Ping(string message);
}
如果我们正在构建乒乓,我们可以如下实现Pong
:
class Pong : IPingable
{
public string Ping(string message)
{
return message + " pong";
}
}
基本上,您 ping 一个Pong
,它将单词"pong"
附加到消息的末尾并返回该消息。请注意,我在这里没有使用StringBuilder
,而是在每一次循环中创建一个新的字符串:这种缺乏变化的情况有助于将这个 API 复制为 web 服务。
我们现在可以试用这个设置,看看它在流程中是如何工作的:
void UseIt(IPingable pp)
{
WriteLine(pp.ping("ping"));
}
Pong pp = new Pong();
for (int i = 0; i < 3; ++i)
{
UseIt(pp);
}
最终的结果是我们打印了三次“ping pong
”,正如我们所希望的那样。
现在,假设您决定将Pingable
服务重新部署到一个很远很远的 web 服务器上。也许你甚至决定通过一个特殊的框架如 ASP 来公开它。网络:
[Route("api/[controller]")]
public class PingPongController : Controller
{
[HttpGet("{msg}")]
public string Get(string msg)
{
return msg + " pong";
}
}
有了这个设置,我们将构建一个名为RemotePong
的通信代理来代替Pong
:
class RemotePong : IPingable
{
string Ping(string message)
{
string uri = "http://localhost:9149/api/pingpong/" + message;
return new WebClient().DownloadString(uri);
}
}
实施后,我们现在可以进行一项更改:
RemotePong pp; // was Pong
for (int i = 0; i < 3; ++i)
{
UseIt(pp);
}
就是这样,您得到的是相同的输出,但是实际的实现可以在 Kestrel 上运行,在地球另一边的某个 Docker 容器中。
日志记录的动态代理
假设您正在测试一段代码,您想要记录特定方法被调用的次数,以及调用它们时使用的参数。您有几个选择,包括
-
使用 AOP 方法(如 PostSharp 或 Fody)创建程序集,将所需的功能编织到代码中
-
而是使用分析/跟踪软件
-
在测试中为对象创建动态代理
动态代理是运行时创建的代理。它允许我们获取一个现有的对象,如果遵循一些规则,覆盖或包装它的一些行为来执行额外的操作。
因此,假设您正在编写覆盖BankAccount
操作的测试,该类实现了以下接口:
public interface IBankAccount
{
void Deposit(int amount);
bool Withdraw(int amount);
}
假设您的起点是如下测试:
var ba = new BankAccount();
ba.Deposit(100);
ba.Withdraw(50);
WriteLine(ba);
当执行这些操作时,您还需要对被调用的方法数量进行计数。因此,实际上,您希望用某种动态构造的代理来包装一个BankAccount
,该代理实现了IBankAccount
接口并保存了所有被调用方法的日志。
我们将构造一个新的类,我们称之为Log<T>
,它将成为任何类型 T 的动态代理:
public class Log<T> : DynamicObject
where T : class, new()
{
private readonly T subject;
private Dictionary<string, int> methodCallCount =
new Dictionary<string, int>();
protected Log(T subject)
{
this.subject = subject;
}
}
我们的类接受一个subject
,这是它正在包装的类,并且有一个简单的方法调用计数字典。
现在,前面的类继承了DynamicObject
,这很好,因为我们想记录对它的各种方法的调用,然后才真正调用这些方法。我们可以这样实现:
public override bool TryInvokeMember(
InvokeMemberBinder binder, object[] args, out object result)
{
try
{
if (methodCallCount.ContainsKey(binder.Name))
methodCallCount[binder.Name]++;
else
methodCallCount.Add(binder.Name, 1);
result = subject
?.GetType()
?.GetMethod(binder.Name)
?.Invoke(subject, args);
return true;
}
catch
{
result = null;
return false;
}
}
如您所见,我们所做的只是记录对特定方法的调用次数,然后使用反射调用方法本身。
现在,只有一个小问题需要我们处理:我们如何让我们的Log<T>
假装它正在实现某个接口I
?这就是动态代理框架的用武之地。我们要用的这个叫做 ImpromptuInterface。 2 这个框架有一个叫做ActLike()
的方法,它允许dynamic
对象假装自己是一个特定的接口类型。
有了这个,我们可以给我们的Log<T>
一个静态工厂方法,它将构造一个新的T
实例,将其包装在一个Log<T>
中,然后将其作为某个接口I
公开:
public static I As<I>() where I : class
{
if (!typeof(I).IsInterface)
throw new ArgumentException("I must be an interface type");
// duck typing here!
return new Log<T>(new T()).ActLike<I>();
}
这一切的最终结果是,我们现在可以执行一个简单的替换,并获得对银行帐户类的所有调用的记录:
//var ba = new BankAccount();
var ba = Log<BankAccount>.As<IBankAccount>();
ba.Deposit(100);
ba.Withdraw(50);
WriteLine(ba);
// Deposit called 1 time(s)
// Withdraw called 1 time(s)
自然地,为了让前面的代码工作,我重写了Log<T>.ToString()
来输出调用计数。遗憾的是,我们制作的包装器不会自动代理对ToString()/Equals()/GetHashCode()
的调用,因为每个object
都内置了这些调用。如果您确实想将这些连接到底层,您必须在Log<T>
中添加覆盖,然后使用subject
字段进行适当的调用。
摘要
本章介绍了一些代理人。与装饰模式不同,代理不会试图通过添加新成员来扩展对象的公共 API 表面(除非实在没办法)。它所做的只是增强现有成员的底层行为。
-
存在大量不同的代理。
-
属性代理是替代对象,可以在分配和/或访问期间替换字段并执行附加操作。
-
虚拟代理提供对底层对象的虚拟访问,并且可以实现诸如惰性对象加载之类的行为。您可能觉得自己正在处理一个真实的对象,但是底层的实现可能还没有创建,例如,可以按需加载。
-
通信代理允许我们改变对象的物理位置(例如,将它移动到云中),但允许我们使用几乎相同的 API。当然,在这种情况下,API 只是远程服务的一个垫片,比如一些可用的 REST API。
-
除了调用底层函数之外,日志代理还允许您执行日志记录。
还有很多其他的代理,您自己构建的代理可能不会属于一个预先存在的类别,而是会执行一些特定于您的领域的操作。
十四、责任链
想想公司渎职的典型例子:内幕交易。假设某个交易员因内幕消息交易被当场抓获。这件事该怪谁?如果管理层不知道,那就是交易员。但也许交易员的同事也参与其中,在这种情况下,团队经理可能是负责人。或者这种做法是制度性的,在这种情况下,首席执行官应该承担责任。 1
前面的场景是一个责任链的例子:你有一个系统的几个不同的元素,它们都可以一个接一个地处理一个消息。作为一个概念,它很容易实现,因为它所隐含的就是使用一个列表。
方案
想象一个电脑游戏,其中每种生物都有一个名字和两个特征值——Attack
和Defense
:
public class Creature
{
public string Name;
public int Attack, Defense;
public Creature(string name, int attack, int defense) { ... }
}
现在,随着生物在游戏中的进展,它可能会拿起一个物品(例如,一把魔剑),或者它可能会被附魔。无论哪种情况,它的攻击和防御值都会被我们称为CreatureModifier
的东西修改。
此外,几个修改器被应用的情况并不少见,所以我们需要能够在一个生物上堆叠修改器,允许它们按照附着的顺序被应用。
让我们看看如何实现这一点。
方法链
在传统的责任实施链中,我们将定义CreatureModifier
如下:
public class CreatureModifier
{
protected Creature creature;
protected CreatureModifier next;
public CreatureModifier(Creature creature)
{
this.creature = creature;
}
public void Add(CreatureModifier cm)
{
if (next != null) next.Add(cm);
else next = cm;
}
public virtual void Handle() => next?.Handle();
}
这里发生了很多事情,我们依次讨论:
-
该类获取并存储一个对它计划修改的
Creature
的引用。 -
这个类实际上并没有做很多事情,但是它不是抽象的:它的所有成员都有实现。
-
next
成员指向这个成员之后的一个可选的CreatureModifier
。言外之意当然是,修饰者也可以是CreatureModifier
的某个继承者。 -
方法将另一个生物修改器添加到修改器链中。这是迭代完成的:如果当前的修改量是
null,
,我们将其设置为该值;否则,我们遍历整个链并把它放在末端。自然这种遍历具有 O(n) 的复杂性。 -
Handle()
方法只是处理链中的下一项,如果它存在的话;它没有自己的行为。它是virtual
的事实意味着它应该被覆盖。
到目前为止,我们所拥有的只是一个穷人的只加单链表的实现。但是当我们开始继承它的时候,事情将有希望变得更加清楚。例如,下面是你如何制作一个可以让生物的attack
值翻倍的修改器:
public class DoubleAttackModifier : CreatureModifier
{
public DoubleAttackModifier(Creature creature)
: base(creature) {}
public override void Handle()
{
WriteLine($"Doubling {creature.Name}'s attack");
creature.Attack *= 2;
base.Handle();
}
}
好吧,我们终于有进展了。所以这个修改器从CreatureModifier
继承而来,在它的Handle()
方法中,做了两件事:加倍攻击值和从基类调用Handle()
。第二部分很关键:修饰符的链可以应用的唯一方式是如果每个继承者不忘记在自己的Handle()
实现结束时调用基类。
这是另一个更复杂的修饰词。该调整值为attack
等于或小于 2 的生物增加 1 点防御:
public class IncreaseDefenseModifier : CreatureModifier
{
public IncreaseDefenseModifier(Creature creature)
: base(creature) {}
public override void Handle()
{
if (creature.Attack <= 2)
{
WriteLine($"Increasing {creature.Name}'s defense");
creature.Defense++;
}
base.Handle();
}
}
最后我们再次调用基类。综上所述,我们现在可以创建一个生物,并对其应用修改器组合:
var goblin = new Creature("Goblin", 1, 1);
WriteLine(goblin); // Name: Goblin, Attack: 1, Defense: 1
var root = new CreatureModifier(goblin);
root.Add(new DoubleAttackModifier(goblin));
root.Add(new DoubleAttackModifier(goblin));
root.Add(new IncreaseDefenseModifier(goblin));
// eventually...
root.Handle();
WriteLine(goblin); // Name: Goblin, Attack: 4, Defense: 1
正如你所看到的,前面的地精是 4/1,因为它的攻击增加了一倍,而防御调整值虽然增加了,但并不影响它的防御分数。
这里还有一个奇怪的地方。假设你决定对一个生物施一个法术,这样它就不会有任何加值。容易做到吗?实际上很简单,因为你所要做的就是避免调用基类handle()
——这避免了执行整个链:
public class NoBonusesModifier : CreatureModifier
{
public NoBonusesModifier(Creature creature)
: base(creature) {}
public override void Handle()
{
WriteLine("No bonuses for you!");
// no call to base.Handle() here
}
}
就这样!现在,如果您将NoBonusesModifier
放在链的开始处,将不会应用更多的元素。
经纪人链
指针链的例子是非常人为的。在现实世界中,你会希望生物能够任意接受和失去奖励,这是只附加链表所不支持的。此外,你不希望永久地修改基础生物属性(就像我们所做的),相反,你希望保持临时的修改。
实现责任链的一种方式是通过一个集中的组件。这个组件可以保存游戏中所有可用的修正值的列表,并且可以通过确保所有相关的奖励都被应用来帮助查询特定生物的攻击或防御。
我们将要构建的组件称为事件代理。因为它连接到每个参与的组件,所以它代表了中介设计模式,而且,因为它通过事件响应查询,所以它利用了观察者设计模式。
让我们建造一个。首先,我们将定义一个名为Game
的结构,它将代表一个正在进行的游戏:
public class Game // mediator pattern
{
public event EventHandler<Query> Queries; // effectively a chain
public void PerformQuery(object sender, Query q)
{
Queries?.Invoke(sender, q);
}
}
类Game
就是我们通常所说的事件代理:在系统的不同部分之间代理(传递)事件的核心组件。这里它是使用普通的。NET 事件,但是您同样可以想象使用某种消息队列的实现。
在游戏中,我们使用的是一个名为Queries
的事件。本质上,这让我们可以引发该事件,并让每个订阅者(侦听组件)处理它。但是事件与质疑生物的攻击或防御有什么关系呢?
好吧,假设你想查询一个生物的统计数据。您当然可以尝试读取一个字段,但是请记住——在知道最终值之前,我们需要应用所有的修饰符。因此,我们将把一个查询封装在一个单独的对象中(这是命令模式 2 ),定义如下:
public class Query
{
public string CreatureName;
public enum Argument
{
Attack, Defense
}
public Argument WhatToQuery;
public int Value; // bidirectional!
}
我们在前面的类中所做的一切都包含了从生物中查询特定值的概念。我们需要提供的只是生物的名字和我们感兴趣的统计数据。正是这个值(嗯,是对它的引用)将被Game
构造和使用。Queries
应用修改器并返回最终的Value
。
现在,让我们继续讨论Creature
的定义。和我们之前的很像。就字段而言,唯一的区别是对Game
的引用:
public class Creature
{
private Game game;
public string Name;
private int attack, defense;
public Creature(Game game, string name, int attack, int defense)
{
// obvious stuff here
}
// other members here
}
现在,注意attack
和defense
现在是私有字段了。这意味着要获得最终(后置修饰)攻击值,你需要调用一个单独的只读属性,例如:
public int Attack
{
get
{
var q = new Query(Name, Query.Argument.Attack, attack);
game.PerformQuery(this, q);
return q.Value;
}
}
这就是奇迹发生的地方!我们不只是返回一个值或静态地应用一些基于引用的链,而是用正确的参数创建一个Query
,然后将查询发送给订阅了Game.Queries
的任何人来处理。每个监听组件都有机会修改基线attack
值。
所以现在让我们实现修饰符。我们将再次创建一个基类,但这一次它没有用于Handle()
方法的主体:
public abstract class CreatureModifier : IDisposable
{
protected Game game;
protected Creature creature;
protected CreatureModifier(Game game, Creature creature)
{
this.game = game;
this.creature = creature;
game.Queries += Handle; // subscribe
}
protected abstract void Handle(object sender, Query q);
public void Dispose()
{
game.Queries -= Handle; // unsubscribe
}
}
对,所以这次的CreatureModifier
类更加复杂。很明显,它保留了一个对它想要修改的生物的引用,但也保留了对正在播放的Game
的引用。为什么呢?正如你所看到的,在构造函数中,它订阅了Queries
事件,这样它的继承者可以在一组修饰符被一个接一个地应用时注入它们自己。我们还实现了IDisposable
,以便取消订阅查询事件并防止内存泄漏。 3
CreatureModifier.Handle()
方法被有意地做成抽象的,以便继承者可以实现它,并根据发送的Query
处理修改过程。让我们看看在这个新的范例中,如何通过重新实现DoubleCreatureModifier
来使用它:
public class DoubleAttackModifier : CreatureModifier
{
public DoubleAttackModifier(Game game, Creature creature)
: base(game, creature) {}
protected override void Handle(object sender, Query q)
{
if (q.CreatureName == creature.Name &&
q.WhatToQuery == Query.Argument.Attack)
q.Value *= 2;
}
}
对,所以现在我们有了Handle()
的具体实现。这里需要特别注意的是,要确定这个查询实际上是我们想要处理的查询。由于 a DoubleAttackModifier
只关心攻击值的查询,我们验证这个特殊的参数(WhatToQuery
),并确保查询与我们要调查的生物相关。
如果我们现在增加一个IncreaseDefenseModifier
(将defense
增加 2;实现省略),我们现在可以运行以下场景:
var game = new Game();
var goblin = new Creature(game, "Strong Goblin", 2, 2); WriteLine(goblin); // Name: Strong Goblin, attack: 2, defense: 2
using (new DoubleAttackModifier(game, goblin))
{
WriteLine(goblin); // Name: Strong Goblin, attack: 4, defense: 2
using (new IncreaseDefenseModifier(game, goblin))
{
WriteLine(goblin); // Name: Strong Goblin, attack: 4, defense: 4
}
}
WriteLine(goblin); // Name: Strong Goblin, attack: 2, defense: 2
这里发生了什么事?在被改造之前,地精是 2/2。然后,我们制造一个范围,在范围内地精受到一个DoubleAttackModifier
的影响,所以在范围内,它是一个 4/2 生物。一旦我们退出这个范围,修饰符的析构函数就会触发,并且它会断开自己与代理的连接,从而在查询值时不再影响这些值。因此,地精本身再次回复为 2/2 生物。
摘要
责任链是一个非常简单的设计模式,它让组件依次处理一个命令(或一个查询)。CoR 最简单的实现是简单地创建一个引用链,理论上,您可以用一个普通的List
来替换它,或者,如果您也想快速删除的话,可以用一个LinkedList
来替换它。
一个更复杂的代理链实现也利用了中介者和观察者模式,允许我们处理对事件的查询,让每个订阅者在最终值返回给客户端之前,对最初传递的对象(它是贯穿整个链的单个引用)进行修改。
十五、命令
想一个琐碎的变量赋值,比如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 系统中大量使用命令来封装典型的动作(例如,复制或粘贴),然后允许通过几种不同的方式调用单个命令。例如,您可以通过使用顶级应用菜单、工具栏上的按钮、上下文菜单或按键盘快捷键来进行复制。
最后,这些动作可以组合成复合命令(宏)——可以被记录然后随意重放的动作序列。注意,复合命令也可以由其他复合命令组成(按照复合设计模式)。
十六、解释器
任何优秀的软件工程师都会告诉你,编译器和解释器是可以互换的。
—蒂姆·伯纳斯·李
解释器设计模式的目标是,你猜对了,解释输入,特别是文本输入,尽管公平地说这真的无关紧要。解释器的概念与大学教授的编译理论和类似课程有很大联系。因为我们在这里没有足够的空间来深入研究不同类型的解析器的复杂性,所以本章的目的是简单地展示一些你可能想要解释的事情的例子。
这里有几个相当明显的例子:
-
像
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 是什么样子,它们是如何被遍历甚至修改的。
*十七、迭代器
简单地说,迭代器是用于遍历某种结构的对象。通常,迭代器引用当前访问的元素,并有向前移动的方法。双向迭代器还允许您向后遍历,随机访问迭代器允许您访问任意位置的元素。
英寸 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
的存在允许你非常快速地返回元素作为一个集合,这个集合可以被其他人使用,而不用担心后台构建的状态机。
十八、中介
我们编写的大部分代码都有不同的组件(类)通过直接引用相互通信。但是,也有不希望对象一定意识到对方存在的情况。或者,也许你确实希望他们知道彼此,但是你仍然不希望他们通过引用进行交流,因为一旦你保持和持有对某个对象的引用,你就延长了该对象的寿命,超过了最初可能期望的寿命(当然,除非它是一个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 实现可以使用事件来允许参与者订阅(和取消订阅)系统中发生的事情。这样,从一个组件发送到另一个组件的消息可以被视为事件。在这种设置中,如果参与者对某些事件不再感兴趣或者如果他们将要完全离开系统,他们也很容易取消订阅这些事件。
十九、备忘录
当我们查看命令设计模式时,我们注意到理论上记录每一个单独的更改列表允许您将系统回滚到任何时间点——毕竟,您已经保存了所有修改的记录。
虽然有时候,你并不真的关心回放系统的状态,但是你确实关心如果需要的话,能够将系统回滚到一个特定的状态。
这正是 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,
的默认值,要么只编写文档来解释在这个位置应该有什么类型的值。
二十一、观察者
观察者
简单地说,观察者模式让一个组件通知其他组件发生了什么。这种模式到处都在使用:例如,当将数据绑定到 UI 时,我们可以对域对象进行编程,这样当它们发生变化时,它们会生成通知,UI 可以订阅并更新视觉效果。
Observer 模式是一种流行且必要的模式,所以 C# 的设计者决定通过使用event
关键字将该模式大规模整合到语言中也就不足为奇了。C# 中事件的使用通常使用一个约定,该约定要求:
-
事件可以是类的成员,并用 event 关键字修饰。
-
事件处理程序——每当事件发生时调用的方法——用
+=
操作符附加到事件上,用-=
操作符分离。 -
事件处理程序通常有两个参数:
-
关于到底是谁引发了这一事件的引用
-
一个(通常)从
EventArgs
派生的对象,包含关于事件的任何必要信息
-
所使用的事件的确切类型通常是委托。就像 lambdas 的Action/Func
包装器一样,事件的委托包装器被称为EventHandler
,存在于非泛型(采用一个EventArgs
)和泛型(采用一个从EventArgs
派生的类型参数)的第二个参数中。第一个论点总是一个object
。
这里有一个微不足道的例子:假设,每当一个人生病,我们叫医生。首先,我们定义事件自变量;在我们的案例中,我们只需要发送医生的地址:
public class FallsIllEventArgs : EventArgs
{
public string Address;
}
现在,我们可以实现一个Person
类型,如下所示:
public class Person
{
public void CatchACold()
{
FallsIll?.Invoke(this,
new FallsIllEventArgs { Address = "123 London Road" });
}
public event EventHandler<FallsIllEventArgs> FallsIll;
}
如您所见,我们使用强类型的EventHandler
委托来公开公共事件。使用CatchACold()
方法引发事件,安全访问?
。操作符用于确保如果事件没有任何订阅者,我们不会得到一个NullReferenceException
。
剩下的工作就是建立一个场景并提供一个事件处理程序:
static void Main()
{
var person = new Person();
person.FallsIll += CallDoctor;
person.CatchACold();
}
private static void CallDoctor(object sender, FallsIllEventArgs eventArgs)
{
Console.WriteLine($"A doctor has been called to {eventArgs.Address}");
}
事件处理程序可以是一个普通的(成员)方法、一个局部函数或者一个 lambda——由你选择。签名由原始代表授权;因为我们使用的是强类型的EventHandler
变量,所以第二个参数是FallsIllEventArgs
。一旦CatchACold()
被调用,就会触发CallDoctor()
方法。
任何给定的事件都可以有多个处理程序(毕竟 C# 委托是多播的)。事件处理程序的移除通常是通过-=
操作符来完成的。当所有订阅者都取消订阅一个事件时,事件实例被设置为null
。
弱事件模式
你知道吗?NET 程序会有内存泄漏?当然,不是从 C++的角度来说,但是有可能保持一个对象超过必要的时间。具体来说,您可以创建一个对象,并将其引用设置为null
,但它仍然是活动的。怎么做?让我展示给你看。
首先,让我们创建一个按钮类:
public class Button
{
public event EventHandler Clicked;
public void Fire()
{
Clicked?.Invoke(this, EventArgs.Empty);
}
}
现在,让我们假设我们在一个窗口中有这个按钮。为了简单起见,我将把它放入一个Window
构造函数中:
public class Window
{
public Window(Button button)
{
button.Clicked += ButtonOnClicked;
}
private void ButtonOnClicked(object sender, EventArgs eventArgs)
{
WriteLine("Button clicked (Window handler)");
}
~Window()
{
WriteLine("Window finalized");
}
}
看起来很无辜,但事实并非如此。如果你做了一个按钮和一个窗口,那么把窗口设置为null
;它还会活着!证据:
var btn = new Button();
var window = new Window(btn);
var windowRef = new WeakReference(window);
btn.Fire();
window = null;
FireGC();
WriteLine($"Is window alive after GC? {windowRef.IsAlive}"); // True
窗口引用仍然存在的原因是它订阅了按钮。当点击一个按钮时,预期会发生一些合理的事情:因为有对该事件的订阅,所以不能允许碰巧进行了该订阅的对象死亡,即使对该对象的唯一引用已经被设置为null
。这是中的内存泄漏。网感。
我们如何解决这个问题?一种方法是使用来自System.Windows
的WeakEventManager
类。这个类是专门设计来允许侦听器的处理程序被垃圾收集的,即使源对象仍然存在。这个类使用起来非常简单:
public class Window2
{
public Window2(Button button)
{
WeakEventManager<Button, EventArgs>
.AddHandler(button, "Clicked", ButtonOnClicked);
}
// rest of class same as before
}
再次重复这个场景,这个Window2
实现根据需要给出了False
的windowRef.IsAlive
结果。
事件流
通过对 Observer 的所有这些讨论,您可能有兴趣了解。NET Framework 自带两个接口:IObserver<T>
和IObservable<T>
。这些接口与反应性扩展(Rx)的发布相一致,主要用于处理反应性流。虽然我无意讨论整个反应式扩展,但这两个接口值得一提。
先说IObservable<T>
。这是一个通常类似于典型接口的接口。网络事件。唯一的区别是,这个接口要求您实现一个名为Subscribe()
的方法,而不是使用+=
操作符进行订阅。这个方法将一个IObserver<T>
作为它唯一的参数。请记住,这是一个接口,与事件/委托的情况不同,没有规定的存储机制。你可以随意使用任何你想要的东西。
还有一些额外的锦上添花:接口中明确支持 un 订阅的概念。Subscribe()
方法返回一个IDisposable
,并理解返回令牌(Memento 模式在起作用!)有一个Dispose()
方法,让观察者从可观察对象中退订。
拼图的第二块是IObserver<T>
界面。它旨在通过三种特定方法提供基于推送的通知:
-
每当新事件发生时被调用。
-
当数据源没有更多的数据时被调用。
-
OnError()
当观察者遇到错误情况时被调用。
再说一次,这只是一个接口,如何处理由你决定。例如,你可以完全忽略OnCompleted()
和OnError()
。
因此,有了这两个接口,我们琐碎的医生-病人示例的实现突然变得不那么琐碎了。首先,我们需要封装一个事件订阅的想法。之所以需要这样做,是因为我们需要一个实现IDisposable
的备忘录,通过它可以取消订阅。
private class Subscription : IDisposable
{
private Person person;
public IObserver<Event> Observer;
public Subscription(Person person, IObserver<Event> observer)
{
this.person = person;
Observer = observer;
}
public void Dispose()
{
person.subscriptions.Remove(this);
}
}
这个类是Person
的内部类,它很好地暗示了任何想要支持事件流的对象日益增长的复杂性。现在,回到Person
,我们希望它实现IObservable<T>
接口。但是什么是T?
不像传统的事件,没有指导方针要求我们从EventArgs
继承——当然,我们可以继续使用那种类型, 1 或者我们可以构建我们自己的,完全任意的层次结构:
public class Event
{
// anything could be here
}
public class FallsIllEvent : Event
{
public string Address;
}
继续,我们现在有了一个基类Event
,所以我们可以声明Person
是这类事件的生成器。因此,我们的Person
类型将实现IObservable<Event>
,并在其Subscribe()
方法中采用一个IObserver<Event>
。下面是整个Person
类,省略了Subscription
内部类的主体:
public class Person : IObservable<Event>
{
private readonly HashSet<Subscription> subscriptions
= new HashSet<Subscription>();
public IDisposable Subscribe(IObserver<Event> observer)
{
var subscription = new Subscription(this, observer);
subscriptions.Add(subscription);
return subscription;
}
public void CatchACold()
{
foreach (var sub in subscriptions)
sub.Observer.OnNext(new FallsIllEvent {Address = "123 London Road"});
}
private class Subscription : IDisposable { ... }
}
我相信你会同意这比仅仅发布一个event
供客户订阅要复杂得多!但是这样做也有好处:例如,您可以选择自己的重复订阅策略,也就是说,当订户试图再次订阅某个事件时的情况。值得注意的一点是HashSet<Subscription>
不是线程安全容器。这意味着如果你想让Subscribe()
和CatchACold()
同时被调用,你需要使用线程安全的集合、锁定或者更好的东西,比如ImmutableList
。
问题并没有就此结束。记住,订户现在必须实现一个IObserver<Event>
。这意味着,为了支持我们之前展示的场景,我们必须编写以下代码:
public class Demo : IObserver<Event>
{
static void Main(string[] args)
{
new Demo();
}
public Demo()
{
var person = new Person();
var sub = person.Subscribe(this);
}
public void OnNext(Event value)
{
if (value is FallsIllEvent args)
WriteLine($"A doctor has been called to {args.Address}");
}
public void OnError(Exception error){}
public void OnCompleted(){}
}
这又是一个相当复杂的问题。我们可以通过使用一个特殊的Observable.Subscribe()
静态方法来简化订阅,但是Observable
(没有I
)是反应扩展的一部分,一个单独的库,你可以使用也可以不使用。
这就是你如何使用?NET 自己的接口,不使用event
关键字。这种方法的主要优点是由一个IObservable
生成的事件流可以直接输入到不同的 Rx 操作符中。例如,使用System.Reactive
,前面展示的整个演示程序可以变成一条语句:
person
.OfType<FallsIllEvent>()
.Subscribe(args =>
WriteLine($"A doctor has been called to {args.Address}"));
财产观察员
中最常见的观察者实现之一。NET 在属性更改时会得到通知。这是必要的,例如,当底层数据改变时更新 UI。这种机制使用普通事件以及一些在. NET 中已经成为标准的接口。
属性观察器可能会变得非常复杂,所以我们将逐步介绍它们,从基本的接口和操作开始,然后转到更复杂的场景。
基本变更通知
中更改通知的核心部分。NET 是一个名为INotifyPropertyChanged
的接口:
public interface INotifyPropertyChanged
{
/// <summary>Occurs when a property value changes.</summary>
event PropertyChangedEventHandler PropertyChanged;
}
这个事件所做的只是公开一个你应该使用的事件。给定一个具有名为Age
的属性的类Person
,该接口的典型实现如下所示:
public class Person : INotifyPropertyChanged
{
private int age;
public int Age
{
get => age;
set
{
if (value == age) return;
age = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler PropertyChanged;
[NotifyPropertyChangedInvocator]
protected virtual void OnPropertyChanged(
[CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
}
}
这里有很多要讨论的。首先,属性获得一个支持字段。这是必需的,以便在分配属性之前查看其以前的值。注意,只有当属性和发生变化时,才会调用OnPropertyChanged()
方法。如果没有,就没有通知。
就 IDE 生成的OnPropertyChanged()
方法而言,该方法被设计为通过[CallerMemberName]
元数据接受受影响属性的名称,然后,如果PropertyChanged
事件有订阅者,则通知那些订阅者具有该名称的属性实际上发生了更改。
当然,你可以构建你自己的变更通知机制,但是 WinForms 和 WPF 本质上都知道INotifyPropertyChanged
,就像许多其他框架一样。因此,如果您需要更改通知,我会坚持使用这个接口。
需要添加一个关于INotifyPropertyChanging
的特别说明,这是一个用于发送事件的接口,表明一个属性正在发生变化。这个接口很少被使用。如果能够使用这个属性来取消一个属性更改就好了,但是遗憾的是这个接口没有提供这个功能。事实上,取消属性更改可能是您想要实现自己的接口而不是这些接口的原因之一。
双向绑定
INotifyPropertyChanged
对于通知用户界面某个标签所绑定属性的变化非常有用。但是,如果您有一个编辑框,并且该编辑框还需要在幕后更新代码元素,该怎么办呢?
这实际上是可行的,甚至不会导致无限递归!这个问题概括为:如何绑定两个属性,使得改变一个属性会改变另一个属性,换句话说,它们的值总是相同的?
让我们试试这个。假设我们有一个有 ?? 的 ??,我们也有一个有 ?? 的 ??。我们希望Name
和ProductName
绑定在一起。
var product = new Product{Name="Book"};
var window = new Window{ProductName = "Book"};
product.PropertyChanged += (sender, eventArgs) =>
{
if (eventArgs.PropertyName == "Name")
{
Console.WriteLine("Name changed in Product");
window.ProductName = product.Name;
}
};
window.PropertyChanged += (sender, eventArgs) =>
{
if (eventArgs.PropertyName == "ProductName")
{
Console.WriteLine("Name changed in Window");
product.Name = window.ProductName;
}
};
常识告诉我们,当这个代码被触发时,会导致一个StackOverflowException
:窗口影响产品,产品影响窗口,等等。只是这不会发生。为什么呢?因为这两个属性中的 setter 都有一个安全措施来检查值是否真的发生了变化。如果没有,它会执行一个return
操作,并且不会发生进一步的通知。所以我们在这里很安全。
前面的解决方案是可行的,但是像 WinForms 这样的框架试图将这样的情况收缩包装到单独的数据绑定对象中。在数据绑定中,您可以指定对象及其属性,以及它们如何联系在一起。例如,Windows 窗体使用属性名(作为字符串),但是现在我们可以更聪明一点,使用表达式树来代替。
因此,让我们构造一个BidirectionalBinding
类,在其构造函数中将两个属性绑定在一起。为此,我们需要四条信息:
-
第一笔财产的所有者
-
访问第一个对象的属性的表达式树
-
第二处房产的所有者
-
访问第二个对象的属性的表达式树
遗憾的是,在这种情况下减少参数的数量是不可能的,但至少它们或多或少是人类可读的。我们还将避免在这里使用泛型,尽管从理论上讲,它们可以引入额外的类型安全。
这是整个班级:
public sealed class BidirectionalBinding : IDisposable
{
private bool disposed;
public BidirectionalBinding(
INotifyPropertyChanged first, Expression<Func<object>> firstProperty,
INotifyPropertyChanged second, Expression<Func<object>> secondProperty)
{
if (firstProperty.Body is MemberExpression firstExpr
&& secondProperty.Body is MemberExpression secondExpr)
{
if (firstExpr.Member is PropertyInfo firstProp
&& secondExpr.Member is PropertyInfo secondProp)
{
first.PropertyChanged += (sender, args) =>
{
if (!disposed)
{
secondProp.SetValue(second, firstProp.GetValue(first));
}
};
second.PropertyChanged += (sender, args) =>
{
if (!disposed)
{
firstProp.SetValue(first, secondProp.GetValue(second));
}
};
}
}
}
public void Dispose()
{
disposed = true;
}
}
前面的代码依赖于关于表达式树的许多前提条件,特别是这些条件:
-
每个表达式树都应该是一个
MemberExpression
。 -
每个成员表达式都应该访问一个属性(因此,
PropertyInfo
)。
如果满足这些条件,我们就为每个属性订阅对方的更改。这个类增加了一个额外的 dispose guard,允许用户在必要时停止处理订阅。
前面是一个简单的例子,说明了在本质上支持数据绑定的框架中,幕后可能发生的事情。
属性依赖关系
在 Microsoft Excel 中,您可以让单元格包含使用其他单元格的值进行的计算。这非常方便:每当特定单元格的值发生变化时,Excel 都会重新计算该单元格影响的每个单元格(包括其他工作表上的单元格)。然后那些单元格导致依赖于它们的每个单元格的重新计算。如此循环下去,直到遍历完整个依赖图,不管花多长时间。太美了。
属性的问题(通常也是 Observer 模式的问题)完全相同:有时类的一部分不仅生成通知,还会影响到类的其他部分,然后那些成员也会生成他们自己的事件通知。与 Excel 不同。NET 没有内置的处理方式,所以这种情况很快就会变得一团糟。
让我举例说明。16 岁或 16 岁以上的人(在你的国家可能不同)可以投票,所以假设我们希望在一个人的投票权发生变化时得到通知:
public class Person : PropertyNotificationSupport
{
private int age;
public int Age
{
get => age;
set
{
if (value == age) return;
age = value;
OnPropertyChanged();
}
}
public bool CanVote => Age <= 16;
}
在前面的法典中,一个人年龄的改变会影响他的投票能力。然而,我们也期望为CanVote
生成适当的变更通知…但是在哪里呢?毕竟CanVote
没有二传手!
你可以试着把它们放入年龄设置器,例如:
public int Age
{
get => age;
set
{
if (value == age) return;
age = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CanVote));
}
}
这是可行的,但是考虑一个场景:如果年龄从 5 岁变成 6 岁呢?当然,年龄变了,但是CanVote
没有变,那么我们为什么要无条件地在上面做通知呢?这是不正确的。功能上正确的实现应该如下所示:
set
{
if (value == age) return;
var oldCanVote = CanVote;
age = value;
OnPropertyChanged();
if (oldCanVote != CanVote)
OnPropertyChanged(nameof(CanVote));
}
如您所见,确定CanVote
受到影响的唯一方法是缓存它的旧值,对age
执行更改,然后获取它的新值并检查它是否被修改,然后才执行通知。
即使没有这个特殊的痛点,我们对属性依赖采取的方法也是不可伸缩的。在一个复杂的场景中,属性依赖于其他属性,我们如何跟踪所有的依赖关系并发出所有的通知呢?显然,需要某种集中机制来自动跟踪所有这些。
让我们建立这样一个机制。我们将构建一个名为PropertyNotificationSupport
的基类,它将实现INotifyPropertyChanged
并处理依赖关系。下面是它的实现:
public class PropertyNotificationSupport : INotifyPropertyChanged
{
private readonly Dictionary<string, HashSet<string>> affectedBy
= new Dictionary<string, HashSet<string>>();
public event PropertyChangedEventHandler PropertyChanged;
[NotifyPropertyChangedInvocator]
protected virtual void OnPropertyChanged
([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(propertyName));
foreach (var affected in affectedBy.Keys)
if (affectedBy[affected].Contains(propertyName))
OnPropertyChanged(affected);
}
protected Func<T> property<T>(string name,
Expression<Func<T>> expr) { ... }
private class MemberAccessVisitor : ExpressionVisitor { ... }
}
这门课很复杂,所以我们慢慢来,弄清楚这是怎么回事。
首先,我们有affectedBy
,这是一个字典,列出了每个属性和受其影响的属性的HashSet
。例如,如果投票能力受年龄和你是否是公民的影响,这本字典将包含一个关键字"CanVote"
和值{"Age", "Citizen"}
。
然后,我们修改默认的OnPropertyChanged()
实现,以确保通知发生在属性本身和它影响的所有属性上。现在唯一的问题是——属性是如何被收入这本字典的?
要求开发人员手动填充这个字典太过分了。相反,我们通过使用表达式树来自动完成。只读属性的 getter 作为表达式树提供给基类,这完全改变了依赖属性的构造方式:
public class Person : PropertyNotificationSupport
{
private readonly Func<bool> canVote;
public bool CanVote => canVote();
public Person()
{
canVote = property(nameof(CanVote),
() => Citizen && Age >= 16);
}
// other members here
}
显然,一切都变了。现在,使用基类的“property()
方法在构造函数中初始化该属性。该属性获取一个表达式树,解析它以找到依赖属性,然后将表达式编译成一个普通的Func<T>
:
protected Func<T> property<T>(string name, Expression<Func<T>> expr)
{
Console.WriteLine($"Creating computed property for expression {expr}");
var visitor = new MemberAccessVisitor(GetType());
visitor.Visit(expr);
if (visitor.PropertyNames.Any())
{
if (!affectedBy.ContainsKey(name))
affectedBy.Add(name, new HashSet<string>());
foreach (var propName in visitor.PropertyNames)
if (propName != name) affectedBy[name].Add(propName);
}
return expr.Compile();
}
表达式树的解析是通过使用我们创建的私有嵌套类MemberAccessVisitor
来完成的。该类遍历表达式树寻找成员访问,并将所有属性名收集到一个简单的列表中:
private class MemberAccessVisitor : ExpressionVisitor
{
private readonly Type declaringType;
public readonly IList<string> PropertyNames = new List<string>();
public MemberAccessVisitor(Type declaringType)
{
this.declaringType = declaringType;
}
public override Expression Visit(Expression expr)
{
if (expr != null && expr.NodeType == ExpressionType.MemberAccess)
{
var memberExpr = (MemberExpression)expr;
if (memberExpr.Member.DeclaringType == declaringType)
{
PropertyNames.Add(memberExpr.Member.Name);
}
}
return base.Visit(expr);
}
}
请注意,我们将自己限制在所属类的声明类型上——处理类之间存在属性依赖的情况是可行的,但要复杂得多。
总之,将所有这些放在一起,我们现在可以编写如下内容:
var p = new Person();
p.PropertyChanged += (sender, eventArgs) =>
{
Console.WriteLine($"{eventArgs.PropertyName} has changed");
};
p.Age = 16;
// Age has changed
// CanVote has changed
p.Citizen = true;
// Citizen has changed
// CanVote has changed
所以它是有效的。但是,我们的执行情况仍然很不理想。如果我在前面的代码中将年龄改为 10,CanVote
仍然会收到通知,尽管它不应该收到通知!这是因为,目前,我们正在无条件地发送这些通知。如果我们想只在相关属性改变时才触发这些,我们将不得不求助于INotifyPropertyChanging
(或类似的接口),在那里我们将不得不缓存每个受影响属性的旧值,直到INotifyPropertyChanged
调用,然后检查那些属性实际上已经改变了。我将此作为读者的一个练习。
最后,一个小注意。你可以看到一些过度拥挤的现象发生在房地产设定者身上。三行代码已经很多了,但是如果考虑到额外的调用,比如使用INotifyPropertyChanging
,那么将整个属性 setter 外部化是有意义的。将每个属性转换成一个Property<T>
(参见代理模式的“属性代理”部分)有点矫枉过正,但是我们可以向基类注入类似
protected void setValue<T>(T value, ref T field,
[CallerMemberName] string propertyName = null)
{
if (value.Equals(field)) return;
OnPropertyChanging(propertyName);
field = value;
OnPropertyChanged(propertyName);
}
属性现在简化为
public int Age
{
get => age;
set => setValue(value, ref age);
}
注意,在前面的代码中,我们必须进行propertyName
传播,因为OnPropertyChanged()
中的[CallerMemberName]
属性在开箱即用时将不再为我们工作。
视图
财产观察者有一个很大、很大、很明显的问题:这种方法是侵入性的,并且明显违背了关注点分离的思想。变更通知是一个单独的问题,所以将它添加到您的域对象中可能不是一个好主意。
为什么不呢?好吧,假设你决定改变主意,从使用 INotifyPropertyChanged (INPC)转向使用IObservable
界面。如果你要在你的域对象中分散 INPC 的使用,你必须仔细检查每一个,修改每一个属性以使用新的范例,更不用说你还必须修改那些类以停止使用旧的接口并开始使用新的接口。这是乏味且容易出错的,而这正是我们试图避免的事情。
那么,如果您希望在发生更改的对象之外处理更改通知,您应该将它们添加到哪里呢?这应该不难——毕竟,我们已经看到了像 Decorator 这样的模式就是为了这个目的而设计的。
一种方法是将另一个对象放在域对象的前面,它将处理变更通知和其他事情。这就是我们通常所说的视图——例如,就是这个东西将被绑定到 UI。
要使用视图,您应该保持对象简单,使用普通属性(甚至公共字段!)而不用任何额外的行为来修饰它们:
public class Person
{
public string Name;
}
事实上,保持数据对象尽可能简单是值得的;在 Kotlin 等语言中,这就是所谓的数据类。现在你要做的是在对象的顶部构建一个视图。视图可以包含其他关注点,包括属性观察者:
public class PersonView : View
{
protected Person person;
public PersonView(Person person)
{
this.person = person;
}
public string Name
{
get => person.Name;
set {
setValue(value, () => person.Name);
}
}
}
当然,前面的代码是一个装饰器。它用执行通知的镜像 getter/setter 包装底层对象。如果你需要更多的复杂性,这里是你得到它的地方。例如,如果您希望在表达式树中跟踪属性依赖关系,那么您应该在这个构造函数中而不是在底层对象的构造函数中这样做。
您会注意到,在前面的清单中,我试图隐藏任何实现细节。我们只是从某个类View
继承而来,我们并不关心它是如何处理通知的:也许它使用了INotifyPropertyChanged
,也许它使用了IObservable
,也许还有别的什么。我们不在乎。
唯一真正的问题是如何调用这个类的 setter,考虑到我们希望它具有关于我们正在分配的属性的名称(只是以防需要)和正在分配的值的信息。这个问题没有统一的解决方案,显然,你在这个临时的setValue()
方法中包含的信息越多越好。如果person.Name
是一个字段,事情将会大大简化,因为我们可以简单地传递一个对要分配的字段的引用,但是我们仍然需要传递一个nameof()
,以便基类在必要时通过 INPC 进行通知。
可观察的集合
如果你在 WinForms 或 WPF 中将一个List<T>
绑定到一个列表框,改变列表不会更新 UI。为什么不呢?因为List<T>
不支持观察者模式——当然,它的单个成员可能支持,但是列表作为一个整体没有明确的方式通知它的内容已经改变。诚然,你可以做一个包装器,让Add()
和Remove()
这样的方法生成通知。然而,WinForms 和 WPF 都有可观察集合——分别是BindingList<T>
和ObservableCollection<T>
类。
这两种类型都表现为一个Collection<T>
,但是这些操作会生成额外的通知,例如,当集合发生变化时,UI 组件可以使用这些通知来更新表示层。例如,ObservableCollection<T>
实现了INotifyCollectionChanged
接口,该接口又有一个CollectionChanged
事件。该事件将告诉您对集合应用了什么操作,并将为您提供一个新旧项的列表,以及关于新旧起始索引的信息:换句话说,您获得了根据操作正确重绘列表框所需的一切。
需要注意的一点是BindingList<T>
和ObservableCollection<T>
都不是线程安全的。因此,如果您计划从多个线程读取/写入这些集合,您需要构建一个线程代理(嘿,代理模式!).事实上,这里有两种选择:
-
从一个可观察的集合中继承,只是将常见的集合操作如
Add()
放在锁后面。 -
从并发集合(例如,
ConcurrentBag<T>
)继承并添加INotifyCollectionChanged
功能。
您可以在 StackOverflow 和其他地方找到这两种方法的实现。我更喜欢第一种选择,因为它简单得多。
可观测的 LINQ
当我们讨论属性观察者时,我们也设法讨论了影响其他属性的属性的概念。但这并不是它们影响的全部。例如,一个属性可能包含在产生某些结果的 LINQ 查询中。那么,当某个特定查询所依赖的属性发生变化时,我们如何知道我们需要重新查询该查询中的数据呢?
随着时间的推移,出现了 CLINQ(连续 LINQ)和可绑定 LINQ 等框架,试图解决 LINQ 查询在其组成部分之一失败时生成必要事件(即CollectionChanged
)的问题。还存在其他框架,我不能在这里推荐您使用哪一个。请记住,这些框架试图解决一个真正困难的问题。
Autofac 中的声明性订阅
到目前为止,我们的大部分讨论都集中在明确的概念上,即命令式订阅事件,不管是通过通常的方式。NET 机制、反应式扩展或其他。然而,这并不是事件订阅发生的唯一方式。
您还可以声明性地定义事件订阅。这通常是因为应用使用了一个中央 IoC 容器,在这个容器中可以找到声明,并且可以在幕后进行事件连接。
*声明性事件连接有两种流行的方法。第一种使用属性:您只需将某个方法标记为[Publishes("foo")]
,将某个其他类中的某个其他方法标记为[Subscribes("foo")]
,IoC 容器就会在幕后建立一个连接。
另一种方法是使用接口,这就是我们将要演示的,以及 Autofac 库的使用。首先,我们定义了事件的概念,并为事件的发送和处理充实了接口:
public interface IEvent {}
public interface ISend<TEvent> where TEvent : IEvent
{
event EventHandler<TEvent> Sender;
}
public interface IHandle<TEvent> where TEvent : IEvent
{
void Handle(object sender, TEvent args);
}
我们现在可以制造事件的具体实现。例如,假设我们正在处理点击事件,其中用户可以按下按钮一定次数(例如,双击它):
public class ButtonPressedEvent : IEvent
{
public int NumberOfClicks;
}
我们现在可以创建一个生成此类事件的Button
类。为了简单起见,我们将简单地添加一个触发事件的Fire()
方法。采用声明式方法,我们用ISend<ButtonPressedEvent>
接口来修饰Button
:
public class Button : ISend<ButtonPressedEvent>
{
public event EventHandler<ButtonPressedEvent> Sender;
public void Fire(int clicks)
{
Sender?.Invoke(this, new ButtonPressedEvent
{
NumberOfClicks = clicks
});
}
}
现在,对于接收方,假设我们想要记录按钮按压。这意味着我们想要处理ButtonPressedEvent
s,幸运的是,我们已经有了这样的接口:
public class Logging : IHandle<ButtonPressedEvent>
{
public void Handle(object sender, ButtonPressedEvent args)
{
Console.WriteLine(
$"Button clicked {args.NumberOfClicks} times");
}
}
现在,我们想要的是,在幕后,我们的 IoC 容器在幕后自动订阅Logging
到Button.Sender
事件,而不需要我们手动操作。首先,让我向您展示这样做所需的一段代码:
var cb = new ContainerBuilder();
var ass = Assembly.GetExecutingAssembly();
// register publish interfaces
cb.RegisterAssemblyTypes(ass)
.AsClosedTypesOf(typeof(ISend<>))
.SingleInstance();
// register subscribers
cb.RegisterAssemblyTypes(ass)
.Where(t =>
t.GetInterfaces()
.Any(i =>
i.IsGenericType &&
i.GetGenericTypeDefinition() == typeof(IHandle<>)))
.OnActivated(act =>
{
var instanceType = act.Instance.GetType();
var interfaces = instanceType.GetInterfaces();
foreach (var i in interfaces)
{
if (i.IsGenericType
&& i.GetGenericTypeDefinition() == typeof(IHandle<>))
{
var arg0 = i.GetGenericArguments()[0];
var senderType = typeof(ISend<>).MakeGenericType(arg0);
var allSenderTypes =
typeof(IEnumerable<>).MakeGenericType(senderType);
var allServices = act.Context.Resolve(allSenderTypes);
foreach (var service in (IEnumerable) allServices)
{
var eventInfo = service.GetType().GetEvent("Sender");
var handleMethod = instanceType.GetMethod("Handle");
var handler = Delegate.CreateDelegate(
eventInfo.EventHandlerType, null, handleMethod);
eventInfo.AddEventHandler(service, handler);
}
}
}
})
.SingleInstance()
.AsSelf();
让我们一步一步地看看前面的代码中发生了什么。
-
首先,我们注册所有实现
ISend<>
的程序集类型。那里不需要采取特殊的步骤,因为它们只需要存在于某个地方。为了简单起见,我们将它们注册为单例——如果不是这样,连接的情况会变得更加复杂,因为系统必须跟踪每个构造的实例。 2 -
然后我们注册实现
IHandle<>
的类型。这就是事情变得疯狂的地方,因为我们指定了一个在返回对象之前必须执行的额外的OnActivated()
步骤。 -
然后,给定这个
IHandle<Foo>
类型,我们使用反射定位实现ISend<Foo>
接口的所有类型。这是一个相当繁琐的过程。 -
对于找到的每一种类型,我们都连接了订阅。同样,这是使用反射完成的,你也可以在这里和那里看到一些神奇的字符串。
有了这个设置,我们可以构建容器并解析一个Button
和一个Logging
组件,订阅将在后台完成:
var container = cb.Build();
var button = container.Resolve<Button>();
var logging = container.Resolve<Logging>();
button.Fire(1); // Button clicked 1 times
button.Fire(2); // Button clicked 2 times
以类似的方式,您可以使用属性来实现声明性订阅。如果您不使用 Autofac,不要担心:大多数流行的 IoC 容器都能够实现这种声明性事件连接。
摘要
一般来说,我们可以避免讨论 C# 中的观察者模式,因为该模式本身已经融入到语言中了。也就是说,我已经展示了观察器的一些实际用途(属性更改通知)以及与之相关的一些问题(依赖属性)。此外,我们还研究了观察者模式支持反应流的方式。
无论我们谈论的是单个事件还是整个集合,线程安全都是 Observer 的一个关注点。它出现的原因是因为一个组件上的几个观察者形成了一个列表(或类似的结构),然后问题立即出现了,即该列表是否是线程安全的,以及当它同时被修改和迭代(出于通知的目的)时到底发生了什么。
*二十二、状态
我必须承认:我的行为受我的状态支配。如果我睡眠不足,我会有点累。如果我喝了酒,我就不会开车了。所有这些都是状态,它们支配着我的行为:我的感受,我能做什么和不能做什么。
当然,我可以从一种状态转换到另一种状态。我可以去喝杯咖啡,这会让我从困倦中清醒过来(我希望!).所以我们可以把咖啡想象成一个触发器,让你真正从困倦状态转变为清醒状态。在这里,我笨拙地为你说明一下:?? 1
coffee
sleepy --------> alert
因此,状态设计模式是一个非常简单的想法:状态控制行为,状态可以改变,唯一没有定论的是谁触发从一个状态到另一个状态的改变。
有两种方法可以模拟状态:
-
状态是具有行为的实际类,这些行为导致从一个状态到另一个状态的转换。换句话说,一个州的成员是我们从那个州走向何方的选项。
-
状态和转换只是枚举。我们有一个叫做状态机的特殊组件来执行实际的转换。
这两种方法都是可行的,但第二种方法才是最常见的。我们将看一看这两个,但是我必须警告,我将浏览第一个,因为这不是人们通常做事情的方式。
状态驱动的状态转换
我们从最简单的例子开始:一个只能处于开和关状态的灯开关。我们之所以选择这样一个简单的域,是因为我想强调一个经典的 State 实现所带来的疯狂(没有别的词可以形容),这个例子非常简单,不需要生成代码清单。
我们将构建一个模型,其中任何状态都能够切换到其他状态:这反映了状态设计模式的“经典”实现(根据 GoF 书)。首先,让我们建立电灯开关的模型。它只有一个状态和一些从一个状态切换到另一个状态的方法:
public class Switch
{
public State State = new OffState();
}
这一切看起来完全合理;我们有一个处于某种状态的开关(或者是打开或者是关闭)。我们现在可以定义State
,在这个特殊的例子中,它将是一个实际的类。
public abstract class State
{
public virtual void On(Switch sw)
{
Console.WriteLine("Light is already on.");
}
public virtual void Off(Switch sw)
{
Console.WriteLine("Light is already off.");
}
}
这个实现远非直观,以至于我们需要慢慢地、小心地讨论它,因为从一开始,关于State
类的任何东西都没有意义。
虽然State
是抽象的(意味着您不能实例化它),但它有非抽象成员,允许从一种状态切换到另一种状态。这……对一个通情达理的人来说,毫无意义。想象一下电灯开关:它是改变状态的开关。人们并不指望这个州本身会改变本身,然而它似乎确实在改变。
然而,也许最令人困惑的是,State.On()
/ Off()
的默认行为声称我们已经处于这种状态!请注意,这些方法是虚拟的。当我们实现示例的其余部分时,这将在某种程度上结合在一起。
我们现在实现开和关状态:
public class OnState : State
{
public OnState()
{
Console.WriteLine("Light turned on.");
}
public override void Off(Switch sw)
{
Console.WriteLine("Turning light off...");
sw.State = new OffState();
}
}
// similarly for OffState
每个状态的构造器简单地通知我们,我们已经完成了转换。但是过渡本身发生在OnState.Off()
和OffState.On()
。这就是转变发生的地方。
我们现在可以完成Switch
类,为它提供实际开关灯的方法:
public class Switch
{
public State State = new OffState();
public void On() { State.On(this); }
public void Off() { State.Off(this); }
}
因此,将所有这些放在一起,我们可以运行以下场景:
LightSwitch ls = new LightSwitch(); // Light turned off
ls.On(); // Switching light on...
// Light turned on
ls.Off(); // Switching light off...
// Light turned off
ls.Off(); // Light is already off
下面是一个从OffState
到OnState
的转换示意图:
LightSwitch.On() -> OffState.On()
OffState------------------------------------>OnState
另一方面,从OnState
到OnState
的转换使用基本的State
类,它告诉你你已经处于那个状态了:
LightSwitch.On() -> State.On()
OnState --------------------------------> OnState
让我第一个说这里呈现的实现是可怕的。虽然这是 OOP 平衡的一个很好的演示,但它是一个不可读的、不直观的混乱,违背了我们对 OOP 的所有了解,特别是对设计模式的了解,具体来说:
-
一个状态通常不会自行切换。
-
可能的转换列表不应该到处出现;最好放在一个地方(SRP)。
-
没有必要用实际的类来模拟状态,除非它们有特定于类的行为;这个例子可以简化得更简单。
也许我们一开始就应该使用enum
s?
手工状态机
让我们试着为一个典型的电话对话定义一个状态机。
首先,我们将描述电话的状态:
public enum State
{
OffHook,
Connecting,
Connected,
OnHold
}
我们现在还可以定义状态之间的转换,也称为enum
:
public enum Trigger
{
CallDialed,
HungUp,
CallConnected,
PlacedOnHold,
TakenOffHold,
LeftMessage
}
现在,这个状态机的确切的规则,也就是哪些变迁是可能的,需要存储在某个地方。下面是一个 UML 状态机图,显示了我们想要的转换类型:
让我们使用状态到触发器/状态对的字典:
private static Dictionary<State, List<(Trigger, State)>> rules
= new Dictionary<State, List<(Trigger, State)>>() { /* todo */ }
这有点笨拙,但本质上字典的键是我们从移动的State
,值是在这个状态和使用触发器时进入的状态中代表可能的触发器的Trigger-State
对的列表。
让我们初始化这个数据结构:
private static Dictionary<State, List<(Trigger, State)>> rules
= new Dictionary<State, List<(Trigger, State)>>
{
[State.OffHook] = new List<(Trigger, State)>
{
(Trigger.CallDialed, State.Connecting)
},
[State.Connecting] = new List<(Trigger, State)>
{
(Trigger.HungUp, State.OffHook),
(Trigger.CallConnected, State.Connected)
},
// more rules here
};
我们还需要一个起始(当前)状态,如果我们希望状态机在到达该状态时停止执行,我们还可以添加一个退出(终止)状态:
State state = State.OffHook, exitState = State.OnHook;
所以在前面一行中,我们从OffHook
状态开始(当您准备好打电话时),退出状态是当电话被放置OnHook
并且呼叫结束时。
这样,我们就不必为实际运行(我们使用术语编排)状态机构建单独的组件。例如,如果我们想建立一个交互式的电话模型,我们可以这样做:
do
{
Console.WriteLine($"The phone is currently {state}");
Console.WriteLine("Select a trigger:");
for (var i = 0; i < rules[state].Count; i++)
{
var (t, _) = rules[state][i];
Console.WriteLine($"{i}. {t}");
}
int input = int.Parse(Console.ReadLine());
var (_, s) = rules[state][input];
state = s;
} while (state != exitState);
Console.WriteLine("We are done using the phone.");
该算法相当明显:我们让用户选择当前状态的一个可用触发器,如果该触发器有效,我们通过使用之前创建的rules
字典转换到正确的状态。
如果我们到达的状态是退出状态,我们就跳出循环。这是一个与程序交互的例子:
The phone is currently OffHook
Select a trigger:
0\. CallDialed
0
The phone is currently Connecting Select a trigger:
0.HungUp
1.CallConnected
1
The phone is currently Connected
Select a trigger:
0.LeftMessage
1.HungUp
2.PlacedOnHold
2
The phone is currently OnHold
Select a trigger:
0.TakenOffHold
1.HungUp
1
We are done using the phone.
这种手工创建的状态机的主要好处是非常容易理解:状态和转换是普通的枚举,转换集在一个Dictionary
中定义,开始和结束状态是简单的变量。我相信你会同意这比我们在本章开始时的例子更容易理解。
基于开关的状态机
在我们对状态机的探索中,我们已经从用类表示状态的不必要的复杂的经典例子发展到用枚举表示状态的手工例子,现在我们将经历退化的最后一步,因为我们完全停止使用状态的专用数据类型。
但是我们的简化不会就此结束:我们不会从一个方法调用跳到另一个方法调用,而是将自己限制在一个无限循环的switch
语句中,在该语句中,状态将被检查,并且由于状态改变而发生转换。
我想让你考虑的场景是一个密码锁。锁有一个四位数的代码(如1234
),您可以一次输入一个数字。当您输入代码时,如果您输入错误,您会得到“FAILED
”输出,但是如果您输入的所有数字都正确,您会得到“UNLOCKED
”,然后退出状态机。
整个场景可以放在一个清单中:
string code = "1234";
var state = State.Locked;
var entry = new StringBuilder();
while (true)
{
switch (state)
{
case State.Locked:
entry.Append(Console.ReadKey().KeyChar);
if (entry.ToString() == code)
{
state = State.Unlocked;
break;
}
if (!code.StartsWith(entry.ToString()))
{
// the code is blatantly wrong
state = State.Failed;
}
break;
case State.Failed:
Console.CursorLeft = 0;
Console.WriteLine("FAILED");
entry.Clear();
state = State.Locked;
break;
case State.Unlocked:
Console.CursorLeft = 0;
Console.WriteLine("UNLOCKED");
return;
}
}
如您所见,这在很大程度上是一个状态机,尽管它缺乏任何结构。你不能从顶层检查它,也不能说出所有可能的状态和转换是什么。除非你真的检查代码,否则不清楚转换是如何发生的——我们很幸运这里没有goto
语句在案例之间跳转!
这种基于开关的状态机方法适用于状态和转换数量非常少的情况。它在结构、可读性和可维护性方面有所损失,但是如果您确实急需一个状态机并且懒得创建 enum 用例,它可以作为一个快速补丁。
总的来说,这种方法不可伸缩,并且难以管理,所以我不建议在生产代码中使用。唯一的例外是,这种机器是基于某种外部模型使用代码生成制造的。
用开关表达式编码转换
基于switch
的状态机可能很笨拙,但这部分是由于关于状态和转换的信息的构造方式(因为事实并非如此)。但是有一种不同的switch
—switch
—语句(相对于表达式)由于模式匹配,允许我们灵活地定义状态转换。
好了,是时候举个简单的例子了。你正在寻宝,发现了一个可以打开或关闭的宝箱…除非它是锁着的,在这种情况下,情况就有点复杂了(你需要有一把钥匙来打开或关闭宝箱)。因此,我们可以将状态和可能的转换编码如下:
enum Chest
{
Open, Closed, Locked
}
enum Action
{
Open, Close
}
有了这个定义,我们可以编写一个名为Manipulate
的方法,将我们从一个状态带到另一个状态。胸部手术的一般规则如下:
-
如果箱子是锁着的,只有有钥匙才能打开。
-
如果箱子是开着的,你拿着钥匙关上它,你就锁上了它。
-
如果箱子是开着的,而你没有钥匙,你就关上它。
-
不管你有没有钥匙,一个封闭的(但没有上锁的)箱子都可以被打开。
可能转换的集合可以被编码在模式匹配表达式的结构中。事不宜迟,就是这样:
static Chest Manipulate(Chest chest,
Action action, bool haveKey) =>
(chest, action, haveKey) switch
{
(Chest.Closed, Action.Open, _) => Chest.Open,
(Chest.Locked, Action.Open, true) => Chest.Open,
(Chest.Open, Action.Close, true) => Chest.Locked,
(Chest.Open, Action.Close, false) => Chest.Closed,
_ => chest
};
这种方法有许多优点和缺点。其优点是
-
这个状态机很容易阅读
-
像
haveKey
这样的保护条件很容易合并,并且非常适合模式匹配
也有不利之处:
-
这个状态机的正式规则集是以一种无法提取的方式定义的。没有保存规则的数据存储,因此您不能生成报告或图表,也不能运行任何超出编译器所做的验证检查(它检查穷举性)。
-
如果您需要任何行为,比如状态进入或退出行为,这在 switch 表达式中是不容易做到的——您需要定义一个包含 switch 语句的老式方法。
总而言之,这种方法非常适合简单的状态机,因为它会产生非常易读的代码。但这并不完全是一个“企业”解决方案。
无状态的状态机
虽然手工滚动状态机适用于最简单的情况,但是您可能希望利用工业级的状态机框架。通过这种方式,您可以获得一个经过测试的功能更多的库。这也是合适的,因为我们需要讨论额外的与状态机相关的概念,并且手工实现它们是相当乏味的。
在我们继续讨论我想讨论的概念之前,让我们首先使用无状态来重建我们之前的电话呼叫示例。 2 假设和以前一样存在相同的枚举State
和Trigger
,状态机的定义非常简单:
var call = new StateMachine<State, Trigger>(State.OffHook);
phoneCall.Configure(State.OffHook)
.Permit(Trigger.CallDialed, State.CallConnected);
// and so on, then, to cause a transition, we do
call.Fire(Trigger.CallDialed); // call.State is now State.CallConnected
如你所见,Stateless' StateMachine
class 是一个具有流畅接口的构建器。当我们讨论无状态的不同复杂性时,这个 API 设计背后的动机将变得显而易见。
类型、动作和忽略过渡
让我们来谈谈无状态和状态机的许多特性。
首先也是最重要的,无状态支持 any 的状态和触发器。NET 类型——它不局限于enums
。你可以使用字符串,数字,任何你想要的。例如,一个灯开关可以用一个bool
来表示状态(false
=关,true
=开);我们将继续使用enums
作为触发器。下面是如何实现LightSwitch
的例子:
enum Trigger { On, Off }
var light = new StateMachine<bool, Trigger>(false);
light.Configure(false) // if the light is off...
.Permit(Trigger.On, true) // we can turn it on
.Ignore(Trigger.Off); // but if it's already off we do nothing
// same for when the light is on
light.Configure(true)
.Permit(Trigger.Off, false)
.Ignore(Trigger.On)
.OnEntry(() => timer.Start())
.OnExit(() => timer.Stop()); // calculate time spent in this state
light.Fire(Trigger.On); // Turning light on
light.Fire(Trigger.Off); // Turning light off
light.Fire(Trigger.Off); // Light is already off!
这里有几件有趣的事情值得讨论。首先,这个状态机有动作——当我们进入特定状态时发生的事情。这些都是在OnEntry()
中定义的,在这里你可以提供一个做某事的 lambda 类似地,您可以在使用OnExit()
退出状态时调用一些东西。这种转换动作的一个用途是在进入一个转换时启动一个计时器,在退出一个转换时停止计时器,这可以用于跟踪在每个状态中花费的时间量。例如,您可能想要测量灯亮着的时间,以验证电费。
另一件值得注意的事情是Ignore()
构建器方法的使用。这基本上是告诉状态机完全忽略这个转换:如果灯已经关了,我们试图关掉它(如前面清单的最后一行),我们指示状态机简单地忽略它,所以在这种情况下没有输出。
为什么这很重要?因为如果您忘记Ignore()
这个转换或者没有明确指定它,无状态将抛出一个InvalidOperationException
:
对于触发器“False ”,不允许从状态“False”进行有效的离开转换。考虑忽略触发器。
再次重入
“冗余交换”难题的另一个替代方案是无状态对可重入状态的支持。为了复制本章开始时的例子,我们可以配置状态机,以便在重新进入一个状态的情况下(意味着我们从false
转换到false
),调用一个动作。下面是如何配置它的:
var light = new StateMachine<bool, Trigger>(false);
light.Configure(false) // if the light is off...
.Permit(Trigger.On, true) // we can turn it on
.OnEntry(transition =>
{
if (transition.IsReentry)
WriteLine("Light is already off!");
else
WriteLine("Turning light off");
})
.PermitReentry(Trigger.Off);
// same for when the light is on
light.Fire(Trigger.On); // Turning light on
light.Fire(Trigger.Off); // Turning light off
light.Fire(Trigger.Off); // Light is already off!
在前面的清单中,PermitReentry()
允许我们在一个Trigger.Off
触发器上返回到false
(关闭)状态。注意,为了向控制台输出相应的消息,我们使用了不同的 lambda:一个有Transition
参数的 lambda。该参数具有完整描述转换的公共成员。这包括Source
(我们正在转换的状态)、Destination
(我们将要转换的状态)、Trigger
(导致转换的原因),以及IsReentry
,一个布尔标志,我们使用它来确定这是否是一个可重入的转换。
分级状态
在打电话的情况下,可以认为OnHold
状态是Connected
状态的子状态,这意味着当我们在等待时,我们也是连接的。无状态允许我们这样配置状态机:
phoneCall.Configure(State.OnHold)
.SubstateOf(State.Connected)
// etc.
现在,如果我们处于OnHold
状态,phoneCall.State
会给我们OnHold
,但是还有一个a phoneCall.IsInState(State)
方法,当用State.Connected
或State.OnHold
调用时,它会返回true
。
更多功能
让我们再讨论几个与无状态实现的状态机相关的特性。
-
Guard 子句允许你通过调用
PermitIf()
和提供bool
-返回 lambda 函数来随意启用和禁用转换,例如: -
参数化触发器是一个有趣的概念。本质上,您可以将参数附加到触发器上,这样,除了触发器本身之外,还有其他信息可以传递。例如,如果一个状态机需要通知一个特定的雇员,您可以指定一个用于通知的电子邮件:
phoneCall.Configure(State.OffHook)
.PermitIf(Trigger.CallDialled, State.Connecting, () => IsValidNumber)
.PermitIf(Trigger.CallDialled, State.Beeping, () =>!IsValidNumber);
- 外部存储是无状态的一个特性,它允许你在外部存储一个状态机的内部状态(例如,在一个数据库中),而不是使用
StateMachine
类本身。要使用它,只需在StateMachine
构造函数中定义 getter 和 setter 方法:
var notifyTrigger = workflow.SetTriggerParameters<string>(Trigger.Notify);
workflow.Configure(State.Notified)
.onEntryFrom(assignTrigger, email => SendEmail(email));
workflow.Fire(notifyTrigger, "foo@bar.com");
- 内省允许我们通过
PermittedTriggers
属性实际查看可以从当前状态触发的触发器表。
var stateMachine = new StateMachine<State, Trigger>(
() => database.ReadState(),
s => database.WriteState(s));
这远不是无状态提供的特性的详尽列表,但是它涵盖了所有重要的部分。
摘要
如您所见,状态机的整个业务远远超出了简单的转换:它允许大量的复杂性来处理最苛刻的业务案例。让我们回顾一下我们已经讨论过的一些状态机特性:
-
状态机包括两个集合:状态和触发器。状态为系统的可能状态建模,并触发我们从一个状态到另一个状态的转换。不限于枚举:可以使用普通的数据类型。
-
尝试未配置的转换将导致异常。
-
可以为每个状态显式配置进入和退出操作。
-
API 中可以显式地允许重入,而且,您可以确定在进入/退出动作中是否发生了重入。
-
转换可以通过保护条件打开或关闭。它们也可以被参数化。
-
状态可以是分层的,也就是说,它们可以是其他状态的子状态。然后需要一个额外的方法来确定您是否处于特定的(父)状态。
虽然这些特性中的大部分看起来像是过度工程化,但是这些特性在定义真实世界的状态机时提供了很大的灵活性。
二十三、策略
假设您决定接受一个包含几个字符串的数组或向量,并将它们作为一个列表输出
-
仅仅
-
喜欢
-
这
如果您考虑不同的输出格式,您可能知道您需要获取每个元素并使用一些附加标记输出它。但是对于 HTML 或 LaTeX 这样的语言,列表也需要开始和结束标签或标记。
我们可以制定一个呈现列表的策略:
-
呈现开始标记/元素。
-
对于每个列表项,呈现该项。
-
呈现结束标记/元素。
可以为不同的输出格式制定不同的策略,然后可以将这些策略输入到一个通用的、不变的算法中来生成文本。
这是存在于动态(运行时可替换)和静态(基于泛型的、固定的)实例中的另一种模式。让我们来看看他们两个。
动态策略
因此,我们的目标是以下列格式打印一个简单的文本项列表:
public enum Output Format
{
Markdown,
Html
}
我们的策略框架将在下面的基类中定义:
public interface IListStrategy
{
void Start(StringBuilder sb);
void AddListItem(StringBuilder sb, string item);
void End(StringBuilder sb);
}
现在,让我们跳到我们的文本处理组件。这个组件有一个特定于列表的方法,比如说,AppendList()
。
public class TextProcessor
{
private StringBuilder sb = new StringBuilder();
private IListStrategy listStrategy;
public void AppendList(IEnumerable<string> items)
{
listStrategy.Start(sb);
foreach (var item in items)
listStrategy.AddListItem(sb, item);
listStrategy.End(sb);
}
public override string ToString() => sb.ToString();
}
所以我们有一个名为sb
的缓冲区,所有的输出都放在那里,我们使用的listStrategy
用于呈现列表,当然还有AppendList()
,它指定了使用给定策略来实际呈现列表所需的一组步骤。
现在,注意这里。如前所述,组合是两种可能的选择之一,可以用来实现骨架算法的具体实现。相反,我们可以添加像AddListItem()
这样的函数作为抽象或虚拟成员,由派生类重写:这就是模板方法模式所做的。
不管怎样,回到我们的讨论,我们现在可以继续为列表实现不同的策略,比如一个HtmlListStrategy
:
Public class HtmlListStrategy : ListStrategy
{
public void Start(StringBuilder sb) => sb.AppendLine("<ul>");
public void End(StringBuilder sb) => sb.AppendLine("</ul>");
public void AddListItem(StringBuilder sb, string item)
{
sb.AppendLine($" <li>{item}</li>");
}
}
通过实现覆盖,我们填补了指定如何处理列表的空白。我们以类似的方式实现了一个MarkdownListStrategy
,但是因为 Markdown 不需要开始/结束标签,所以我们只在AddListItem()
方法中工作:
public class MarkdownListStrategy : IListStrategy
{
// markdown doesn't require list start/end tags
public void Start(StringBuilder sb) {}
public void End(StringBuilder sb) {}
public void AddListItem(StringBuilder sb, string item)
{
sb.AppendLine($" * {item}");
}
}
我们现在可以开始使用TextProcessor
,给它输入不同的策略,得到不同的结果。例如:
var tp = new TextProcessor(); tp.SetOutputFormat(OutputFormat.Markdown);
tp.AppendList(new []{"foo", "bar", "baz"});
WriteLine(tp);
// Output:
// * foo
// * bar
// * baz
我们可以规定策略在运行时可切换——这就是为什么我们称这种实现为动态策略。这是在SetOutputFormat()
方法中完成的,它的实现很简单:
public void SetOutputFormat(OutputFormat format)
{
switch (format) {
case OutputFormat.Markdown:
listStrategy = new MarkdownListStrategy();
break;
case OutputFormat.Html:
listStrategy = new HtmlListStrategy();
break;
default:
throw new ArgumentOutOfRangeException(nameof(format), format, null);
}
}
现在,从一种策略切换到另一种策略是很简单的,您可以立即看到结果:
tp.Clear(); // erases underlying buffer
tp.SetOutputFormat(OutputFormat.Html);
tp.AppendList(new[] { "foo", "bar", "baz" });
WriteLine(tp);
// Output:
// <ul>
// <li>foo</li>
// <li>bar</li>
// <li>baz</li>
// </ul>
静态策略
多亏了泛型的魔力,你可以将任何策略嵌入到类型中。仅需要对TextStrategy
类进行最小的修改:
public class TextProcessor<LS>
where LS : IListStrategy, new()
{
private StringBuilder sb = new StringBuilder();
private IListStrategy listStrategy = new LS();
public void AppendList(IEnumerable<string> items)
{
listStrategy.Start(sb);
foreach (var item in items)
listStrategy.AddListItem(sb, item);
listStrategy.End(sb);
}
public override string ToString() => return sb.ToString();
}
动态实现中的变化如下:我们添加了LS
泛型参数,用这个类型创建了一个listStrategy
成员,并开始使用它来代替我们之前的引用。调用调整后的AppendList()
的结果与我们之前的结果相同。
var tp = new TextProcessor<MarkdownListStrategy>();
tp.AppendList(new []{"foo", "bar", "baz"});
WriteLine(tp);
var tp2 = new TextProcessor<HtmlListStrategy>();
tp2.AppendList(new[] { "foo", "bar", "baz" });
WriteLine(tp2);
前面示例的输出与动态策略的输出相同。请注意,我们必须创建两个TextProcessor
实例,每个都有不同的列表处理策略,因为不可能中途切换类型的策略:它已经被嵌入到类型中了。
平等和比较策略
里面最广为人知的使用策略模式。NET 当然是平等和比较策略的使用。
考虑一个简单的类,如下所示:
class Person
{
public int Id;
public string Name;
public int Age;
}
目前,你可以在一个List
中放几个Person
实例,但是在这样的列表中调用Sort()
是没有意义的。
var people = new List<Person>();
people.Sort(); // does not do what you want
使用==
和!=
操作符的比较也是如此:目前,所有这些比较都是基于引用的比较。
我们需要明确区分两种类型的操作:
-
相等根据您定义的规则检查一个对象的两个实例是否相等。这由
IEquatable<T>
接口(Equals()
方法)以及通常在内部使用Equals()
方法的操作符==
和!=
覆盖。 -
比较允许您比较两个对象,并找出哪个小于、等于或大于另一个。这包含在
IComparable<T>
接口中,并且是排序之类的事情所需要的。
通过实现IEquatable<T>
和IComparable<T>
,每个对象都可以公开自己的比较和相等策略。例如,如果我们假设人们有唯一的Id
,我们可以使用 ID 值进行比较:
public int CompareTo(Person other)
{
if (ReferenceEquals(this, other)) return 0;
if (ReferenceEquals(null, other)) return 1;
return Id.CompareTo(other.Id);
}
所以现在,调用people.Sort()
是有意义的——它将使用我们编写的内置CompareTo()
方法。但是有一个问题:一个典型的类只能有一个默认的CompareTo()
实现来比较这个类和它自己。平等也是如此。那么如果你的比较策略在运行时改变了呢?
幸运的是,BCL 设计者也想到了这一点。我们可以在调用点指定比较策略,只需传入一个 lambda:
people.Sort((x, y) => x.Name.CompareTo(y.Name));
这样,即使Person
的默认比较行为是按 ID 进行比较,如果需要,我们也可以按名称进行比较。
但这还不是全部!有第三种方法可以定义比较策略。如果一些策略是通用的,并且您想在类本身中保留它们,那么这种方法是有用的。
想法是这样的:定义一个实现了IComparer<T>
接口的嵌套类。然后将该类作为静态变量公开:
public class Person
{
// ... other members here
private sealed class NameRelationalComparer :
IComparer<Person>
{
public int Compare(Person x, Person y)
{
if (ReferenceEquals(x, y)) return 0;
if (ReferenceEquals(null, y)) return 1;
if (ReferenceEquals(null, x)) return -1;
return string.Compare(x.Name, y.Name,
StringComparison.Ordinal);
}
}
public static IComparer<Person> NameComparer { get; }
= new NameRelationalComparer();
}
正如您所看到的,前面的类定义了一个独立的策略,使用名称来比较两个Person
实例。我们现在可以简单地获取这个类的一个静态实例,并将其提供给Sort()
方法:
people.Sort(Person.NameComparer);
正如您可能已经猜到的,相等比较的情况非常相似:您可以使用一个IEquatable<T>
,传入一个 lambda,或者生成一个实现了IEqualityComparer<T>
的类。你的选择!
职能策略
策略模式的功能变化很简单:所有 OOP 结构都简单地被函数所取代。首先,TextProcessor
从一个类退化为一个函数。这实际上是惯用的(即正确的做法),因为TextProcessor
只有一个操作。
let processList items startToken itemAction endToken =
let mid = items |> (Seq.map itemAction) |> (String.concat "\n")
[startToken; mid; endToken] |> String.concat "\n"
前面的函数有四个参数:一个项目序列、起始标记(注意:这是一个标记,不是函数)、处理每个元素的函数和结束标记。因为这是一个函数,所以这种方法假设processList
是无状态的,也就是说,它不在内部保存任何状态。
正如您从前面的文本中看到的,我们的策略不仅仅是一个简单的、自包含的元素,而是三个不同项目的组合:开始和结束标记以及对序列中的每个元素进行操作的函数。我们现在可以专门化processList
,以便像以前一样实现 HTML 和 Markdown 处理:
let processListHtml items =
processList items "<ul>" (fun i -> " <li>" + i + "</li>") "</ul>"
let processListMarkdown items =
processList items "" (fun i -> " * " + i) ""
这就是您如何使用这些专业化,并获得可预测的结果:
let items = ["hello"; "world"]
printfn "%s" (processListHtml items)
printfn "%s" (processListMarkdown items)
关于这个例子值得注意的有趣的事情是,processList
的接口绝对没有给出任何关于客户端应该作为itemAction
提供什么的提示。他们只知道这是一个'a -> string
,所以我们依靠他们来猜测它实际上是做什么的。
摘要
策略设计模式允许您定义算法的框架,然后使用组合来提供与特定策略相关的缺失的实现细节。这种方法有两种表现形式:
-
动态策略简单地保持对当前使用的策略的引用。想换个不同的策略吗?换个参照物就行了。放轻松!
-
静态策略要求你在编译时选择策略并坚持下去——以后没有改变主意的余地。
应该使用动态策略还是静态策略?嗯,动态的允许你在对象被构造后重新配置它们。想象一个控制文本输出形式的 UI 设置:你更愿意拥有一个可切换的TextProcessor
还是两个类型为TextProcessor<MarkdownStrategy>
和TextProcessor<HtmlStrategy>
的变量?这真的取决于你。
二十四、模板方法
策略和模板方法设计模式非常相似,以至于就像工厂一样,我很想将这些模式合并成某种“框架方法”设计模式。我会忍住冲动。
策略和模板方法的区别在于,策略使用组合(不管是静态的还是动态的),而模板方法使用继承。但是在一个地方定义算法的框架,在其他地方定义其实现细节的核心原则仍然存在,再次观察 OCP(我们简单地用扩展系统)。
游戏模拟
大多数棋盘游戏都非常相似:游戏开始(发生某种设置),玩家轮流玩,直到决定一个赢家,然后可以宣布赢家。不管是什么游戏——国际象棋,跳棋,还是别的什么——我们可以将算法定义如下:
public abstract class Game
{
public void Run()
{
Start();
while (!HaveWinner)
TakeTurn();
WriteLine($"Player {WinningPlayer} wins.");
}
}
如您所见,运行游戏的run()
方法只是使用了一组其他方法和属性。这些方法是抽象的,并且具有protected
可见性,因此它们不会被外部调用:
protected abstract void Start();
protected abstract bool HaveWinner { get; }
protected abstract void TakeTurn();
protected abstract int WinningPlayer { get; }
平心而论,前面的一些成员,尤其是void
-returning 成员,不一定要抽象。例如,如果一些游戏没有明确的start()
过程,将start()
作为抽象就违反了 ISP,因为不需要它的成员仍然必须实现它。在“策略”一章我们特意做了一个界面,但是用模板的方法,情况就不那么一目了然了。
现在,除了上述内容之外,我们还可以拥有某些与所有游戏相关的受保护字段——玩家数量和当前玩家的索引:
public abstract class Game
{
public Game(int numberOfPlayers)
{
this.numberOfPlayers = numberOfPlayers;
}
protected int currentPlayer;
protected readonly int numberOfPlayers;
// other members omitted
}
从现在开始,Game
类可以被扩展来实现一个国际象棋游戏:
public class Chess : Game
{
public Chess() : base(2) { /* 2 players */ }
protected override void Start()
{
WriteLine($"Starting a game of chess with {numberOfPlayers} players.");
}
protected override bool HaveWinner => turn == maxTurns;
protected override void TakeTurn()
{
WriteLine($"Turn {turn++} taken by player {currentPlayer}.");
currentPlayer = (currentPlayer + 1) % numberOfPlayers;
}
protected override int WinningPlayer => currentPlayer;
private int maxTurns = 10;
private int turn = 1;
}
一局国际象棋涉及两个玩家,所以这是提供给基类的构造函数的值。然后,我们继续覆盖所有必要的方法,实现一些非常简单的模拟逻辑,以便在十回合后结束游戏。我们现在可以使用带有 new Chess().Run()
的类了——下面是输出:
Starting a game of chess with 2 players
Turn 0 taken by player 0
Turn 1 taken by player 1
...
Turn 8 taken by player 0
Turn 9 taken by player 1
Player 0 wins.
这差不多就是全部了!
功能模板法
正如您可能已经猜到的,模板方法的函数方法是简单地定义一个独立的函数runGame()
,它将模板化的部分作为参数。唯一的问题是游戏是一个固有可变的 ?? 场景,这意味着我们必须有某种容器来表示游戏的状态。我们可以尝试使用一种记录类型:
type GameState = {
CurrentPlayer: int;
NumberOfPlayers: int;
HaveWinner: bool;
WinningPlayer: int;
}
有了这个设置,我们最终不得不将一个GameState
的实例传递给作为模板方法一部分的每个函数。请注意,方法本身相当简单:
let runGame initialState startAction takeTurnAction haveWinnerAction =
let state = initialState
startAction state
while not (haveWinnerAction state) do
takeTurnAction state
printfn "Player %i wins." state.WinningPlayer
象棋游戏的实现也不是特别困难,唯一真正的问题是内部状态的初始化和修改:
let chess() =
let mutable turn = 0
let mutable maxTurns = 10
let state = {
NumberOfPlayers = 2;
CurrentPlayer = 0;
WinningPlayer = -1;
}
let start state =
printfn "Starting a game of chess with %i players" state.NumberOfPlayers
let takeTurn state =
printfn "Turn %i taken by player %i." turn state.CurrentPlayer
state.CurrentPlayer <- (state.CurrentPlayer+1) % state.NumberOfPlayers
turn <- turn + 1
state.WinningPlayer <- state.CurrentPlayer
let haveWinner state =
turn = maxTurns
runGame state start takeTurn haveWinner
所以,简单重述一下,我们在这里做的是在外部函数中初始化方法/函数所需的所有函数(这在 C# 和 F# 中都是完全合法的),然后将每个函数传递给runGame
。还要注意,我们有一些可变的状态,在整个子函数调用中使用。
总的来说,如果您准备在代码中引入记录类型和可变性,使用函数而不是对象实现模板方法是完全可能的。当然,从理论上来说,你可以重写这个例子,通过存储每个游戏状态的快照,并在递归设置中传递它来摆脱可变状态——这将有效地把模板方法变成一种模板化的状态模式。试试看!
摘要
与使用组合并因此分为静态和动态变化的策略不同,模板方法使用继承,因此,它只能是静态的,因为一旦对象被构造,就没有办法操纵它的继承特征。
模板方法中唯一的设计决策是你是否希望模板方法使用的方法是抽象的或者实际上有一个主体,即使主体是空的。如果你预见到有些方法对于所有的继承者来说都是不必要的,那就让它们为空/非抽象,以符合 ISP。
二十五、访问者
为了解释这个模式,我将首先跳到一个例子中,然后讨论这个模式本身。希望你不要介意!
假设您已经解析了一个数学表达式(当然,使用了解释器模式!)由double
值和加法运算符组成,例如:
(1.0 + (2.0 + 3.0))
此表达式可以使用类似于下面的对象层次结构来表示:
public abstract class Expression { /* nothing here (yet) */ }
public class DoubleExpression : Expression
{
private double value;
public DoubleExpression(double value) { this.value = value; }
}
public class AdditionExpression : Expression
{
private Expression left, right;
public AdditionExpression(Expression left, Expression right)
{
this.left = left;
this.right = right;
}
}
根据这个设置,您对两件事感兴趣:
-
将 OOP 表达式打印为文本
-
评估表达式的值
您还希望尽可能统一和简洁地完成这两件事(以及对这些树的许多其他可能的操作)。你会怎么做?嗯,有很多方法,我们将从打印操作的实现开始,逐一查看。
不速之客
最简单的解决方案是获取基类Expression
并向其添加一个抽象成员。
public abstract class Expression
{
// adding a new operation
public abstract void Print(StringBuilder sb);
}
除了破坏 OCP 之外,这种修改还依赖于这样一个假设,即您实际上可以访问该层次结构的源代码——这并不总是有保证的。但我们总得从某个地方开始,对吧?因此,随着这种变化,我们需要在DoubleExpression
中实现Print()
(这很简单,所以我在这里省略了)以及在AdditionExpression
中实现Print()
:
public class AdditionExpression : Expression
{
...
public override void Print(StringBuilder sb)
{
sb.Append(value: "(");
left.Print(sb);
sb.Append(value: "+");
right.Print(sb);
sb.Append(value: ")");
}
}
哦,这太有趣了!我们在子表达式上多态地递归调用Print()
。太好了,让我们来测试一下:
var e = new AdditionExpression(
left: new DoubleExpression(1),
right: new AdditionExpression(
left: new DoubleExpression(2),
right: new DoubleExpression(3)));
var sb = new StringBuilder();
e.Print(sb);
WriteLine(sb); // (1.0 + (2.0 + 3.0))
嗯,这很简单。但是现在假设您在层次结构中有 10 个继承者(顺便说一下,在现实世界的场景中并不少见),您需要添加一些新的Eval()
操作。那是需要在十个不同的类中完成的十个修改。但是 OCP 不是真正的问题。
真正的问题是 SRP。你知道,像印刷这样的问题是需要特别关注的。与其说每个表达式都应该打印自己,为什么不引入一个知道如何打印表达式的ExpessionPrinter
?并且,稍后,您可以引入一个知道如何执行实际计算的ExpressionEvaluator
——所有这些都不会以任何方式影响Expression
层次结构。
反射式打印机
既然我们已经决定制作一个独立的打印机组件,让我们去掉Print()
成员函数(当然,要保留基类)。
abstract class Expression
{
// nothing here!
};
现在让我们试着实现一个ExpressionPrinter
。我的第一反应会是这样写:
public static class ExpressionPrinter
{
public static void Print(DoubleExpression e, StringBuilder sb)
{
sb.Append(de.Value);
}
public static void Print(AdditionExpression ae, StringBuilder sb)
{
sb.Append("(");
Print(ae.Left, sb); // will not compile!!!
sb.Append("+");
Print(ae.Right, sb); // will not compile!!!
sb.Append(")");
}
}
前面编译的几率:零。C# 知道,ae.Left
是一个Expression
,但是由于它不在运行时检查类型(不像各种动态类型的语言),它不知道调用哪个重载。太糟糕了!
这里能做什么?嗯,只有一件事——移除重载并在运行时检查类型:
public static class ExpressionPrinter
{
public static void Print(Expression e, StringBuilder sb)
{
if (e is DoubleExpression de)
{
sb.Append(de.Value);
}
else if (e is AdditionExpression ae)
{
sb.Append("(");
Print(ae.Left, sb);
sb.Append("+");
Print(ae.Right, sb);
sb.Append(")");
}
}
}
前面的代码实际上是一个可用的解决方案:
var e = new AdditionExpression(
left: new DoubleExpression(1),
right: new AdditionExpression(
left: new DoubleExpression(2),
right: new DoubleExpression(3)));
var sb = new StringBuilder();
ExpressionPrinter.Print(e, sb);
WriteLine(sb);
这种方法有一个相当大的缺点:没有编译器检查,你有,事实上,为层次结构中的每个元素实现了打印。当添加新元素时,您可以继续使用ExpressionPrinter
而无需修改,它将跳过任何新类型的元素。
但这是一个可行的解决方案。说真的,很有可能就此打住,不再进一步研究访问者模式:is
操作符没有那么昂贵,我认为许多开发人员会记得在if
语句中覆盖每一种类型的对象。
扩展方法?
如果你认为分离出一个ExpressionPrinter
的问题可以在不使用类型检查的情况下得到解决,这是可以理解的。可悲的是,这种设置也转移到使用反射访问者。
当然,你可以同时获取DoubleExpression
和AdditionExpression
并给它们Print()
扩展方法,这些方法可以在驻留在其他地方时直接在对象上调用。然而,您的AdditionExpression.Print()
实现仍然会有几个问题:
public static void Print(this AdditionExpression ae, StringBuilder sb)
{
sb.Append("(");
ae.Left.Print(sb); // oops
sb.Append("+");
ae.Right.Print(sb);
sb.Append(")");
}
第一个问题是,由于这是一个扩展方法,我们需要将Left
和Right
成员公开,以便扩展方法可以访问它们。
但这不是真正的问题。这里的主要问题是ae.Left.Print()
不能被调用,因为ae.Left
是一般的Expression
。你会如何支持它?嗯,这就是你通过在层次结构的根元素上实现一个扩展方法并执行类型检查来转移到反射打印机的地方:
public static void Print(this Expression e, StringBuilder sb)
{
switch (e)
{
case DoubleExpression de:
de.Print(sb);
break;
case AdditionExpression ae:
ae.Print(sb);
break;
// and so on
}
}
这个解决方案遇到了与原始方案相同的问题,即没有验证来确保switch
语句覆盖了Expression
的每个继承者。现在,不可否认的是,这是我们实际上可以强制的,因此通过使用…反射赋予了反射打印机它真正的名字!
Extension method classes
are static, and can have both static fields and constructors, so we can map out all the inheritors and attempt to find the methods that handle them
:
public static class ExpressionPrinter
{
private static Dictionary<Type, MethodInfo> methods
= new Dictionary<Type, MethodInfo>();
static ExpressionPrinter()
{
var a = typeof(Expression).Assembly;
var classes = a.GetTypes()
.Where(t => t.IsSubclassOf(typeof(Expression)));
var printMethods = typeof(ExpressionPrinter).GetMethods();
foreach (var c in classes)
{
// find extension method that takes this class
var pm = printMethods.FirstOrDefault(m =>
m.Name.Equals(nameof(Print)) &&
m.GetParameters()?[0]?.ParameterType == c);
methods.Add(c, pm);
}
}
}
有了这个设置,为基本类型Expression
实现的扩展方法Print()
现在转移到:
public static void Print(this Expression e, StringBuilder sb)
{
methods[e.GetType()].Invoke(null, new object[] {e, sb});
}
当然,这种方法有很大的性能成本。有一些方法可以抵消这些成本,比如使用Delegate.CreateDelegate()
来避免存储那些MethodInfo
对象,而是在需要时使用随时可以调用的委托。
最后,总有一个“核心选项”:生成在运行时创建这些调用的代码。当然,这也带来了一系列问题:您要么基于反射生成代码(这意味着您几乎总是落后一步,因为您需要二进制文件来提取类型信息),要么使用 Roslyn、ReSharper、Rider 或其他类似机制提供的解析器框架来检查实际编写的代码。
功能反射访问者
值得注意的是,前面采用的方法是确切地说是在诸如 F# 之类的语言中你将采用的方法,唯一的区别当然是,你将主要处理函数,而不是继承层次。
如果您不是在层次结构中定义表达式类型,而是在有区别的联合中定义表达式类型,例如
type Expression =
| Add of Expression * Expression
| Mul of Expression * Expression
...
那么您将实现的任何访问者很可能具有类似于下面的结构:
let rec process expr =
match expr with
| And(lhs,rhs) -> ...
| Mul(lhs,rhs) -> ...
...
这种方法完全等同于我们在 C# 实现中采用的方法。一个match
表达式中的每一种情况都将被有效地转化为一个is
检查。然而,还是有很大的不同。首先,F# 中具体的 case 过滤器和 guard 条件比 C# 中嵌套的if
语句更容易阅读。整个过程可能的递归性更具表现力。
改进
虽然在前面的例子中不可能静态地强制每个必要类型检查的存在,但是如果缺少适当的实现,那么有可能生成异常。为此,只需创建一个字典,将支持的类型映射到处理这些类型的 lambda 函数,即:
private static DictType actions = new DictType
{
[typeof(DoubleExpression)] = (e, sb) =>
{
var de = (DoubleExpression) e;
sb.Append(de.Value);
},
[typeof(AdditionExpression)] = (e, sb) =>
{
var ae = (AdditionExpression) e;
sb.Append("(");
Print(ae.Left, sb);
sb.Append("+");
Print(ae.Right, sb);
sb.Append(")");
}
};
现在,您可以用更简单的方式实现顶级的Print()
方法。事实上,为了加分,您可以使用 C# 扩展方法机制来添加Print()
作为任何Expression
的方法:
public static void Print(this Expression e, StringBuilder sb)
{
actionse.GetType();
}
// sample use:
myExpression.Print(sb)
对于 SRP 的目的来说,您是否在一个Printer
上使用扩展方法或普通的静态或实例方法是完全不相关的。一个普通的类和一个扩展方法类都用来将打印功能与数据结构本身隔离开来,唯一的区别是你是否考虑打印Expression's
API 的一部分,我个人认为这是合理的:我喜欢expression.Print()
、expression.Eval()
等等的想法。尽管如果你是一个 OOP 纯粹主义者,你可能会讨厌这种方法。
什么是调度?
每当人们谈到来访者,就会提到派遣这个词。这是什么?简而言之,“分派”是一个计算要调用哪些方法的问题——具体来说,需要多少条信息才能进行调用。
这里有一个简单的例子:
interface IStuff { }
class Foo : IStuff { }
class Bar : IStuff { }
public class Something
{
static void func(Foo foo) { }
static void func(Bar bar) { }
}
现在,如果我创建一个普通的Foo
对象,我可以用它调用func()
:
Foo foo = new Foo();
func(foo); // this is fine
但是如果我决定将它强制转换为基类型(接口或类),编译器将不知道调用哪个重载:
Stuff stuff = new Foo;
func(stuff); // oops!
现在,让我们从多方面考虑这个问题:有没有什么方法可以让我们强迫系统调用正确的重载,而不需要任何运行时(is
、as
和类似的)检查?原来是有的。
看,当你在一个IStuff
上调用某个东西时,那个调用可以是多态的,它可以被直接分派给必要的组件,而组件又可以调用必要的重载。这被称为双调度是因为以下原因:
-
首先对实际对象进行多态调用。
-
在多态调用中,调用重载。因为在对象内部,
this
有一个精确的类型(例如,Foo
或Bar
),所以正确的重载被触发。
我的意思是:
interface Stuff {
void call();
}
class Foo : Stuff {
void call() { func(this); }
}
class Bar : Stuff {
void call() { func(this); }
}
void func(Foo foo) {}
void func(Bar bar) {}
你能看到这里发生了什么吗?我们不能只把一个通用的call()
实现粘在Stuff
中:不同的实现必须在它们各自的类中,这样this
指针才能被正确地类型化。
该实现允许您编写以下内容:
Stuff foo = new Foo;
foo.call();
这是一个示意图,展示了正在发生的事情:
this = Foo
foo.call() ------------> func(foo)
动态访问者
让我们回到我声称没有零成功机会的ExpressionPrinter
例子:
public class ExpressionPrinter
{
public void Print(AdditionExpression ae, StringBuilder sb)
{
sb.Append("(");
Print(ae.Left, sb);
sb.Append("+");
Print(ae.Right, sb);
sb.Append(")");
}
public void Print(DoubleExpression de, StringBuilder sb)
{
sb.Append(de.Value);
}
}
如果我告诉你,我可以通过添加两个关键字并提高Print(ae,sb)
方法的计算成本来实现它,会怎么样?我相信你已经猜到我在说什么了。是的,我说的是动态调度:
public void Print(AdditionExpression ae, StringBuilder sb)
{
sb.Append("(");
Print((dynamic)ae.Left, sb); // <-- look closely here
sb.Append("+");
Print((dynamic)ae.Right, sb); // <-- and here
sb.Append(")");
}
为了支持动态类型语言,dynamic
的全部业务都被添加到了 C# 中。其中一些语言的一个方面是动态调度的能力,也就是说,在运行时而不是编译时做出调用决策。这正是我们在这里所做的!
你可以这样称呼它:
var e = ...; // as before
var ep = new ExpressionPrinter();
var sb = new StringBuilder();
ep.Print((dynamic)e, sb); // <-- note the cast here
WriteLine(sb);
通过将一个变量赋给dynamic
,我们将调度决策推迟到运行时。因此,我们得到了正确的调用;只有几个问题,即:
-
这种类型的分派会带来相当大的性能损失。
-
如果缺少一个需要的方法,您将得到一个运行时错误。
-
你可能会在继承方面遇到严重的问题。
如果您希望访问的对象图很小,并且调用不频繁,那么动态访问者是一个很好的解决方案。否则,性能损失可能会使整个努力难以为继。
经典访客
访问者设计模式的“经典”实现使用了双重分派。访问者成员函数的调用有一些约定:
-
访问者的方法通常被称为
Visit()
。 -
在整个层次结构中实现的方法通常被称为
Accept().
所以现在,我们再一次将一些东西放入基类Expression
中:函数Accept()
。
public abstract class Expression
{
public abstract void Accept(IExpressionVisitor visitor);
}
正如你所看到的,前面的代码引用了一个名为IExpressionVisitor
的接口类型,它可以作为各种访问者的基本类型,比如ExpressionPrinter
、ExpressionEvaluator
等等。现在,Expression
的每一个实现者现在都被要求以相同的方式实现Accept()
,特别是:
public override void Accept(IExpressionVisitor visitor)
{
visitor.Visit(this);
}
从表面上看,这似乎违反了干(不要重复自己),另一个自我描述的原则。然而,如果你仔细想想,每个实现者都会有一个不同类型的this
引用,所以这不是另一个静态分析工具如此喜欢抱怨的剪切粘贴编程的例子。
现在,在另一边,我们可以如下定义IExpressionVisitor
接口:
public interface IExpressionVisitor
{
void Visit(DoubleExpression de);
void Visit(AdditionExpression ae);
}
注意,我们绝对必须为所有表达式对象定义重载;否则,在实现相应的Accept()
时,我们会得到一个编译错误。我们现在可以实现这个接口来定义我们的ExpressionPrinter
:
public class ExpressionPrinter : IExpressionVisitor
{
StringBuilder sb = new StringBuilder();
public void Visit(DoubleExpression de)
{
sb.Append(de.Value);
}
public void Visit(AdditionExpression ae)
{
// wait for it!
}
public override string ToString() => sb.ToString();
}
DoubleExpression
的实现非常明显,但是下面是AdditionExpression
的实现:
public void Visit(AdditionExpression ae)
{
sb.Append("(");
ae.Left.Accept(this);
sb.Append("+");
ae.Right.Accept(this);
sb.Append(")");
}
注意现在调用是如何发生在子表达式本身上的,再次利用了双重分派。至于新的双派遣访问者的用法,这里是:
var e = new AdditionExpression(
new DoubleExpression(1),
new AdditionExpression(
new DoubleExpression(2),
new DoubleExpression(3)));
var ep = new ExpressionPrinter();
ep.Visit(e);
WriteLine(ep.ToString()); // (1 + (2 + 3))
遗憾的是,不可能构造一个类似于前面实现的扩展方法,因为,你猜怎么着,静态类不能实现接口。如果您想将表达式 printer 隐藏在稍微好一点的 API 后面,您可以使用以下方法:
public static class ExtensionMethods
{
public void Print(this DoubleExpression e, StringBuilder sb)
{
var ep = new ExpressionPrinter();
ep.Print(e, sb);
}
// other overloads here
}
当然,实现所有正确的重载取决于您,所以这种方法实际上没有太大帮助,并且不提供安全检查来确保您已经为每个Expression
继承者重载。
实现附加访问者
那么,双重调度方法的优势是什么呢?好处是你只需要通过层次结构实现一次成员。你再也不用碰任何一个成员了。例如,假设您现在想要有一种方法来评估表达式的结果。这很容易,但是需要记住的是,Visit()
目前被声明为一个void
方法,所以AdditionExpression
的实现可能看起来有点奇怪:
public class ExpressionCalculator : IExpressionVisitor
{
public double Result;
public void Visit(DoubleExpression de)
{
Result = de.Value;
}
public void Visit(AdditionExpression ae)
{
// in a moment!
}
// maybe, what you really want is double Visit(...)
}
…但是需要记住的是,Visit()
目前被声明为一个void
方法,所以一个AdditionExpression
的实现可能看起来有点奇怪:
public void Visit(AdditionExpression ae)
{
ae.Left.Accept(this);
var a = Result;
ae.Right.Accept(this);
var b = Result;
Result = a + b;
}
前面的代码是无法从Accept()
得到return
的副产品,所以我们将结果缓存在变量a
和b
中,然后返回它们的总和。它工作得很好:
var calc = new ExpressionCalculator();
calc.Visit(e);
WriteLine($"{ep} = {calc.Result}");
// prints "(1+(2+3)) = 6"
这种方法的有趣之处在于,现在您可以在单独的类中编写新的访问者,即使您无法访问层次结构本身的源代码。除了让你的代码更容易理解之外,这也让你忠于 SRP 和 OCP。
非循环访问者
现在是一个很好的时机来提及访问者设计模式实际上有两种类型。它们如下:
-
循环访客,基于函数重载。由于层次结构(必须知道访问者的类型)和访问者(必须知道层次结构中每个类的)之间的循环依赖,该方法的使用仅限于不经常更新的稳定层次结构。
-
非循环 访问者,这也是基于类型转换的。这里的优点是对被访问的层次结构没有限制,但是正如您可能已经猜到的那样,这里存在性能问题。
非循环访问者实现的第一步是实际的访问者接口。我们没有为层次结构中的每一个类型定义一个Visit()
重载,而是尽可能地使事情通用化:
public interface IVisitor<TVisitable>
{
void Visit(TVisitable obj);
}
我们需要领域模型中的每个元素都能够接受这样的访问者,但是由于每个专门化都是唯一的,我们所做的就是引入一个标记接口——一个空接口,里面什么也没有。
public interface IVisitor {} // marker interface
前面的接口没有成员,但是我们将使用它作为我们想要实际访问的任何对象中的Accept()
方法的参数。现在,我们能做的是重新定义我们之前的Expression
类,如下所示:
public abstract class Expression
{
public virtual void Accept(IVisitor visitor)
{
if (visitor is IVisitor<Expression> typed)
typed.Visit(this);
}
}
下面是新的Accept()
方法的工作原理:我们获取一个IVisitor
,然后尝试将其转换为一个IVisitor<T>
,其中T
是我们当前所在的类型。如果转换成功,这个访问者知道如何访问我们的类型,所以我们调用它的Visit()
方法。如果它失败了,那就没用了。关键是要理解为什么typed
本身没有一个我们可以调用的Visit()
。如果是这样的话,就需要为每一个有兴趣调用它的类型重载,这正是引入循环依赖的原因。
在我们模型的其他部分实现了Accept()
(同样,每个Expression
类中的实现是相同的)之后,我们可以通过再次定义一个ExpressionPrinter
将所有东西放在一起,但是这一次,它看起来如下:
public class ExpressionPrinter : IVisitor, IVisitor<Expression>,
IVisitor<DoubleExpression>,
IVisitor<AdditionExpression>
{
StringBuilder sb = new StringBuilder();
public void Visit(DoubleExpression de) { ... }
public void Visit(AdditionExpression ae) { ... }
public void Visit(Expression obj)
{
// default handler?
}
public override string ToString() => sb.ToString();
}
如你所见,我们实现了IVisitor
标记接口以及一个Visitor<T>
用于我们想要访问的每个T
。如果我们省略了一个特定的类型T
(例如,假设我注释掉了Visitor<DoubleExpression>
),程序仍然会编译,并且相应的Accept()
调用,如果它来了,将简单地作为空操作执行
在前面的文本中,Visit()
方法的实现与我们在传统的 visitor 实现中的实现是相同的,结果也是如此。
然而,这个例子和传统的访问者之间有一个基本的区别。传统的访问者使用一个接口,而我们的非循环访问者使用一个抽象类作为层次结构的根。这是什么意思?嗯,一个抽象类可以有一个可以用作“后备”的实现,这就是为什么在ExpressionPrinter
的定义中,我可以实现IVisitor``<Expression>
并提供一个Visit(Expression obj)
方法,该方法可以用来处理缺失的Visit()
重载。例如,您可以在这里添加日志记录,或者抛出一个异常。
功能访问者
当我们讨论解释器设计模式时,我们已经看到了实现访问者的函数方法,所以我在这里不再重复。一般的方法归结为使用类型的模式匹配和其他有用的特性(比如列表理解)遍历递归的有区别的联合(当然,假设您使用的是列表)。
函数范式中的访问者与面向对象的范式有着本质的不同。在面向对象编程中,访问者是一种机制,它“在侧面”为一组相关的类提供额外的功能,同时理想地能够
-
将功能组合在一起
-
避免类型检查,而是依靠接口
有区别的联合上的模式匹配相当于使用is
C# 关键字(isinst
IL 指令)来检查每种类型。然而,与 C# 不同,F# 会告诉您丢失的情况,因此它提供了更大的编译时安全性。
因此,与 OOP 实现相比,规范的 F# 访问者将实现一个反射访问者方法。
F# 中 Visitor 的实现有很多问题。首先,正如我们之前提到的,受歧视的工会本身打破了 OCP,因为除了改变他们的定义之外,没有其他方法来扩展他们。但是 Visitor 实现使问题变得更加复杂:因为我们的函数 Visitor 本质上是一个巨大的switch
语句,所以增加对特定类型支持的唯一方法就是在 Visitor 中也违反 OCP!
摘要
访问者设计模式允许我们向对象层次结构中的每个元素添加一些独特的行为。我们看到的方法包括以下几种:
-
介入式:向层次结构中的每个对象添加一个方法。有可能(假设你有源代码),但打破 OCP。
-
反射:添加一个不需要改变对象的单独访客;每当需要运行时调度时,使用
is
/as
。 -
动态:通过将层次对象强制转换为
dynamic
,强制通过 DLR 进行运行时调度。这以非常大的计算成本提供了最好的接口。 -
经典(双重调度):整个等级被修改,但是只有一次,而且是以一种非常普通的方式。这个层级的每一个成员都学会了如何接待访客。然后,我们对 visitor 进行子类化,以在各个方向增强层次结构的功能。
** 非循环:就像反射类一样,为了正确调度而进行强制转换。然而,它打破了访问者和被访问者之间的循环依赖,允许访问者更加灵活的组合。*
访问者经常与解释器模式一起出现:在解释了一些文本输入并将其转换成面向对象的结构之后,我们需要,例如,以特定的方式呈现抽象语法树。Visitor 帮助在整个层次结构中传播一个StringBuilder
(或类似的累加器对象)并将数据整理在一起。*
第一部分:介绍
第二部分:创建模式
Creational Patterns
在像 C# 这样的“托管”语言中,创建一个新对象的过程很简单:只需new
它并忘记它。嗯,有stackalloc
,但是我们主要说的是需要持久化的对象。现在,随着依赖注入的扩散,另一个问题是手动创建对象是否仍然是可接受的,或者我们是否应该将基础设施的所有关键方面的创建推迟到专门的构造,例如工厂(稍后将详细介绍它们!)还是控制容器的倒置?
无论您选择哪一个选项,创建对象仍然是一件苦差事,尤其是如果构建过程很复杂或者需要遵守特殊的规则。这就是创造模式的用武之地:它们是与创建对象相关的常见方法。
万一您对 C# 中构造对象的方法感到生疏,让我们回顾一下主要的方法:
-
调用
new
在托管堆上创建一个对象。对象不需要被显式销毁,因为垃圾收集器(GC)会替我们处理它。 -
使用
stackalloc
的堆栈分配在堆栈上而不是堆上分配内存。堆栈分配的对象只存在于它们被创建的范围内,当它们超出范围时会被自动清除。此构造只能用于值类型。 -
您可以用
Marshal.AllocHGlobal
和Co-TaskMemAlloc
分配非托管(本机)内存,并且必须用Marshal.FreeHGlobal
和CoTaskMem-Free
显式释放它。这主要是与非托管代码进行互操作所需要的。
不用说,一些托管组件可能在幕后使用非托管内存。这也是IDisposable
接口存在的主要原因之一。这个接口只有一个方法Dispose()
,它可以包含清理逻辑。如果你正在处理一个实现了IDisposable
的对象,将它的使用封装在一个using
语句中(我们现在也有了using var
)可能是有意义的,这样一旦不再需要这个对象,它的清理代码就会被执行。
第三部分:结构模式
Structural Patterns
顾名思义,结构模式就是建立应用的结构,从而提高代码的一致性以及可用性和可维护性。
当涉及到确定物体的结构时,我们可以应用这些相当众所周知的方法:
-
继承:一个对象自动获取所有成员的基类。为了允许实例化,对象必须实现来自其父对象的每个抽象成员;如果没有,那它就是抽象的,不能被创造(但是你可以从中继承)。
-
构成:一般暗示孩子离开父母就无法存在。这通常是用嵌套类实现的。例如,一个类
Car
可以有一个嵌套类Wheel
。 -
聚合:一个对象可以包含另一个对象,但是那个对象也可以独立存在。假设一个
Car
有一个Person driver
字段或属性。
如今,组合和聚合都以相同的方式处理。如果你有一个字段类型为Address
的Person
类,你可以选择Address
是外部类型还是嵌套类型。在这两种情况下,只要它是public
,就可以将其实例化为Address
或Person.Address
。
我认为,当我们真正指的是聚合时,使用单词 composition 已经变得如此普遍,以至于我们也可以以可互换的方式使用它们。这里有一些证据:当我们谈到 IoC 容器时,我们谈到一个组合根。但是等等,IoC 容器不是单独控制每个对象的生存期吗?确实如此,所以当我们真正指“聚合”时,我们使用“组合”这个词
基本上,在 C# 中有三种定义数据结构的方法:
-
静态地:当你简单地编写类,然后它们被编译。这是最常见的情况。
-
通过代码生成:当从 ?? 模板或数据库或一些用户脚本中创建结构时,就会发生这种情况。例如,在 WinForms 或 Windows Presentation Foundation(WPF)应用中编辑用户界面时,会在后台生成大量代码。
-
动态,也就是运行时:这是最复杂的选项。高级库能够构建数据结构,并在应用执行的那一刻将它们编译成可执行代码。这种方法有时在设计模式中被利用,并导致它们的静态-动态二元性。
值得注意的是,许多数据结构是由编译器在幕后隐式创建的。这包括像ValueTuple
这样的东西,匿名类型的类,以及负责编排枚举器(yield
功能)或异步(async/await
)操作的状态机。
在 F# 中,在后台创建的数据结构(和相应的分配)的数量是巨大的,这些数据结构的复杂性也是如此。例如,任何类型的 currying 操作都会产生深层次的继承。或者,让我们说,您决定将一个操作符(如(+)
)作为参数传递给一个函数——在这种情况下,将创建一个完整的struct
,纯粹是为了存放一个调用该操作符的方法。如果认为所有这样的分配在 JIT 编译期间都是自动内联的,那就太天真了!
第四部分:行为模式
Behavioral Patterns
当大多数人听说行为模式时,主要是与动物有关,以及如何让它们做你想做的事情。嗯,在某种程度上,所有的编码都是关于程序做你想做的事情,所以行为软件设计模式涵盖了非常广泛的行为,尽管如此,这些行为在编程中还是很常见的。
作为一个例子,考虑软件工程领域。我们有经过编译的语言,其中包括词法分析、语法分析和数以百万计的其他事情(解释器模式),并且已经为一个程序构建了一个抽象语法树(AST),您可能想要分析程序中可能存在的错误(访问者模式)。所有这些行为都很常见,可以用模式来表达,这就是我们今天在这里的原因。
与创造模式(专门关注对象的创建)或结构模式(关注对象的组合/聚合/继承)不同,行为设计模式不遵循一个中心主题。虽然不同的模式之间有某些相似之处(例如,策略和模板方法以不同的方式做同样的事情),但是大多数模式都提供了解决特定问题的独特方法。