十、装饰器
假设您正在使用您同事编写的一个类,并且您想要扩展该类的功能。在不修改原始代码的情况下,你会怎么做呢?嗯,一种方法是继承:你创建一个派生类,添加你需要的功能,甚至可能是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 中没有任何东西阻止多次应用同一个静态或动态装饰器。
严格来说,有可能在扩展方法中存储状态,尽管是以一种非常迂回的方式。本质上,您要做的是让您的扩展类保存一个类型为Dictionary<WeakReference, Dictionary<string,object>>
的static member
,然后修改字典中的条目,将一个对象映射到它的属性集。这里需要做大量的修改,包括处理弱引用(我们不希望这个存储延长原始对象的生命周期,对吗?)以及存储一堆对象所带来的装箱和解箱。
2
Mixin 继承是一种 C++技术,通过使用继承向类添加功能。在装饰器的上下文中,它将允许我们组合一个类型为T<U<V>>
的类,该类将从U
和V
中继承,从而允许我们访问所有底层成员。此外,由于构造函数转发和 C++的可变模板,构造函数可以正常工作。
十一、外观
首先,让我们来解决语言问题:字母\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\欢迎你们中特别迂腐的人在代码中使用字母,因为编译器对此处理得很好。?? 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 来获得它。
多年来,我看到了许多在 C# 源文件中使用 Unicode(通常是 UTF-8)编码的技巧。最阴险的情况是一个开发者坚持称他的扩展方法为‘第一个参数this
——当然,这是一个完全有效的标识符,因为this
中的字母i
是乌克兰字母i
,而不是拉丁字母。
2
大多数缓冲区通常是一维的。这样做的原因是,在某个地方传递单指针比双指针更容易,当结构的大小是确定的和不可变的时,使用array
或vector
没有多大意义。1D 方法的另一个优势是,当涉及到 GPU 处理时,CUDA 等系统使用多达六维来寻址*,因此过一段时间后,从 N 维块/网格位置计算 1D 指数就成了第二天性。*
* 3
我们也使用 ASCII,因为很少需要 Unicode。如果不需要支持额外的字符集,那么 1 char = 1 byte 是一个很好的做法。虽然与当前的讨论无关,但它也极大地简化了 GPU 和 FPGAs 上的字符串处理算法的实现。
4
许多交易终端已经放弃了纯 ASCII 表示,转而支持更混合模式的方法,例如在普通 UI 控件中简单地使用等宽字体,或者在单独的窗口 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)。
一个string
的大小实际上取决于操作系统的位和版本。您正在使用的. NET。
十三、代理
当我们查看装饰设计模式时,我们看到了增强对象功能的不同方式。代理设计模式是类似的,但是它的目标通常是精确地(或者尽可能接近地)保留正在使用的 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。
-
除了调用底层函数之外,日志代理还允许您执行日志记录。
还有很多其他的代理,您自己构建的代理可能不会属于一个预先存在的类别,而是会执行一些特定于您的领域的操作。
这对于这个特殊的例子并不重要,但是这个实现并不是线程安全的。想象两个线程都进行null
检查,通过检查,然后都一个接一个地分配bitmap
——构造函数将被调用两次。这就是我们使用System.Lazy
的原因,它的设计是线程安全的。
2
可以从 NuGet 获得;源代码在 https://github.com/ekonbenefits/impromptu-interface
。
十四、责任链
想想公司渎职的典型例子:内幕交易。假设某个交易员因内幕消息交易被当场抓获。这件事该怪谁?如果管理层不知道,那就是交易员。但也许交易员的同事也参与其中,在这种情况下,团队经理可能是负责人。或者这种做法是制度性的,在这种情况下,首席执行官应该承担责任。 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
来替换它。
一个更复杂的代理链实现也利用了中介者和观察者模式,允许我们处理对事件的查询,让每个订阅者在最终值返回给客户端之前,对最初传递的对象(它是贯穿整个链的单个引用)进行修改。
如果我们谈论的是银行业,十有八九没有人会受到惩罚。没有人因为次贷危机而受到惩罚。在 LIBOR 操纵丑闻中,只有一名交易员被判有罪(六名银行家在英国受到指控,但后来被证明无罪)。这和设计模式有什么关系?绝对没有!只是想分享一下。
2
实际上,这里有点混乱。命令-查询分离(CQS)的概念建议将操作分为命令(改变状态,不产生任何值)和查询(不改变任何东西,但产生一个值)。GoF 没有查询的概念,所以我们让对组件的任何封装指令被称为命令。
3
这正是在反应式扩展中所做的。有关更多信息,请参见“备忘录”一章。