.NET Core3 设计模式教程(二)
五、原型
深层复制与浅层复制
假设我们将类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,如果您想稍后执行替换,这通常是个好主意。
注意,与 C# 类似,F# 的默认实现也使用lazy。唯一的区别是 F# 有一个更简洁的语法:编写lazy(x + y())会在幕后自动构造一个Lazy<’T>。
七、适配器
我过去经常旅行,通常只有当你到达一个新的国家时,你才记得他们的插座是不同的,而你对此没有准备。这就是为什么机场旅行商店有旅行适配器,也是为什么一些酒店(更好的酒店)至少有一个非本地类型的插座,以防客户忘记带适配器,但需要不间断地使用笔记本电脑。
一个可以让我将欧洲插头插入英国或美国插座的旅行适配器 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语句。再说一次,这不是我们想要的。
桥模式本质上是使用引用来连接一个对象的组成部分。不是很令人兴奋,是吗?好吧,我可以在我们探索的最后提供一些令人兴奋的东西,但是我们首先需要看一下该模式的一个常规实现。
Footnotes 1如果你像我一样是欧洲人,并想抱怨每个人都应该使用欧洲插头和插座:不,英国插头的设计在技术上更好,也更安全,所以如果我们确实想要一个标准,英国的将是一个选择。
2
如果可以,你真的想使用第三方序列化组件。中支持二进制和 XML 序列化。NET 是非常不完整的,有许多令人不快的警告。如果您使用像 JSON.NET 这样的 JSON 序列化器,前面描述的问题都不会成为问题。
*
八、桥接
传统桥接
假设我们对在屏幕上画不同种类的形状感兴趣。让我们假设我们有各种各样的形状(圆形、方形等。)以及用于渲染这些的不同 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 ,你不能动态改变任何基类(类或者接口)。
摘要
正如我们所看到的,桥设计模式的主要目标是避免数据类型的过度增长,在这种情况下,有两个或更多的“维度”,也就是说,系统的各个方面,可能在数量上成倍增长。桥接的最佳方法仍然是主动避免(例如,如果可能的话,用枚举替换类),但是如果那是不可能的,我们就简单地抽象掉两个层次,并找到一种连接它们的方法。
Footnotes 1我在这里使用了一个呼叫约定。这纯粹是为了说明的目的。如果每个渲染的形状都不共享另一个形状的父形状或子形状,则可以通过创建一系列名称相似的重载来简化这种情况,即,Render(Circle c), Render(Square s)等等。选择权在你。
九、组合
现实生活中,对象通常由其他对象组成(或者,换句话说,它们聚合了其他对象)。请记住,在本书这一部分的开始,我们同意将聚合和合成等同起来。
一个对象有几种方式来表明它是由某些东西组成的。最明显的方法是让一个对象要么实现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也是不符合规则的,但它在美学上更好,并且计算成本更小。
当然,没有-->运算符;很简单,后缀减量--后面跟着大于>。然而,效果正如-->箭头所示:在 while ( count --> 0)中,我们迭代直到计数为零。