一、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 的应用,但是这两个是不同的概念。
SoC 是 System on a Chip 的缩写,是一种集成了计算机所有(或大部分)方面的微处理器。
2
一个反模式是一个设计模式,不幸的是,它也经常出现在代码中,足以被全球认可。模式和反模式的区别在于,反模式通常是糟糕设计的模式,导致代码难以理解、维护和重构。
3
注意,我们在评估中使用了一个&
。如果您想使用&&
,您还需要覆盖ISpecification
中的true
和false
操作符。
4
Martin,Robert C. (2003),敏捷软件开发,原则,模式和实践,Prentice Hall,第 127–131 页。
二、函数视角
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>
完全无关),利用了模式匹配和尾部递归。
这些特性与函数式编程范例相结合,可以帮助实现本书中描述的一些模式。
可变模板主要是一个 C++概念。它们允许您定义模板(泛型)类型和方法,这些类型和方法接受任意数量的类型参数,并提供有效迭代参数类型列表的语法。。NET 泛型的实现不同于 C++模板(它们的“泛型”在运行时被保留),所以。NET 是不可能的。
三、构建器
构建器模式与复杂的对象的创建有关,也就是说,不能在一行构造器调用中构建的对象。这些类型的对象本身可能由其他对象组成,并且可能包含不太明显的逻辑,因此需要一个专门用于对象构造的单独组件。
我想值得预先注意的是,虽然我说过构建器关注的是复杂的对象,但我们将看一个相当简单的例子。这样做纯粹是为了优化空间,因此领域逻辑的复杂性不会影响读者理解模式的实际实现。
方案
假设我们正在构建一个呈现网页的组件。一个页面可能只包含一个段落(让我们暂时忘记所有典型的 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()
,或者别的什么。) -
通过定义适当的隐式转换运算符,可以将生成器强制转换为对象本身。
-
通过将生成器指定为参数函数的一部分,可以强制客户端使用生成器。
-
这样你可以完全隐藏正在构建的对象。
-
单个构建器接口可以公开多个子构建器。通过巧妙地使用继承和流畅的接口,人们可以轻松地从一个构建器跳到另一个构建器。
-
通过递归泛型,流畅接口的继承(不仅仅是构建者)是可能的。
只是为了重申我已经提到的一些东西,当对象的构造是一个重要的过程时,使用构建器模式是有意义的。由有限数量的合理命名的构造函数参数明确构造的简单对象可能应该使用构造函数(或依赖注入),而不需要这样的构造函数。
CRTP——奇怪地重复出现的模板模式——是一种流行的 C++模式,如下所示:class Foo<T> : T
。换句话说,你继承了一个泛型参数,这在 C# 中是不可能的。
2
例如,请看托马斯·皮特里切克在 http://fssnip.net/hf.
的基于 F# 的 HTML 构造 DSL 的片段
四、工厂
我遇到了一个问题,试图使用 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 支持流畅的接口,也可以使用一条语句。# 原型
想想你每天使用的东西,比如汽车或手机。很有可能,它不是从零开始设计的;相反,制造商选择了一个现有的设计,进行了一些改进,使其在视觉上与旧设计有所区别(这样人们就可以炫耀),并开始销售它,使旧产品退役。这是一种自然状态,在软件世界中,我们会遇到类似的情况:有时,不是从头开始创建一个完整的对象(工厂和构建器模式在这里会有所帮助),而是希望获得一个预构造的对象,或者使用它的副本(这很容易),或者对它进行一点定制。
这让我们想到了拥有一个原型的想法:一个模型对象,我们可以制作副本,定制这些副本,然后使用它们。原型模式的挑战实际上是复制部分;其他的都好办。
一些编程语言,最著名的是 Objective-C 和 Swift,确实允许仅参数名不同的函数重载。不幸的是,这种想法导致了所有调用中参数名称的病毒式传播。大多数时候,我还是更喜欢位置参数。
2
每当你想阻止一个客户访问某个东西时,我总是建议你把它设置成protected
而不是private
,因为这样你就可以使类继承友好。
*