《设计模式手册:通过C#代码示例学习常见的设计模式》
设计模式是针对软件设计中常见问题的、可复用的解决方案。可以把它们看作是蓝图:并非完整的代码,而是经过验证的模板,你可以根据自己的需求将其调整过来,用于解决自己代码库中的特定问题。 这本手册旨在帮助大家切实理解软件设计模式。我编写这本书是为了所有开发者,无论你使用哪种编程语言。书中的示例是用C#编写的,但这里提到的每一个概念同样适用于Python、Java、TypeScript、Go等语言。 源代码可以在这里找到: github.com/Clifftech123/design-patterns-handbook 。 需要注意的事项: 设计模式本身并不是代码。 它们是一种思考代码结构的方式,是解决
设计模式是针对软件设计中常见问题的、可复用的解决方案。可以把它们看作是蓝图:并非完整的代码,而是经过验证的模板,你可以根据自己的需求将其调整过来,用于解决自己代码库中的特定问题。
这本手册旨在帮助大家切实理解软件设计模式。我编写这本书是为了所有开发者,无论你使用哪种编程语言。书中的示例是用C#编写的,但这里提到的每一个概念同样适用于Python、Java、TypeScript、Go等语言。
源代码可以在这里找到:github.com/Clifftech123/design-patterns-handbook。
需要注意的事项:
设计模式本身并不是代码。它们是一种思考代码结构的方式,是解决特定设计问题的工具,而非万能的解决方案。
这些概念具有普遍性。
虽然书中的示例是用C#编写的,但同样的设计模式存在于所有编程语言中。如果你使用Python、Java、Go或TypeScript进行开发,那么你很可能已经在不知不觉中运用了一些这样的设计模式了。并没有一种适用于所有情况的设计模式。
每种设计模式都是为了解决某一类特定问题而存在的。理解某种设计模式“能解决什么问题”,比记住它的实现方式更为重要。
我在这里使用C#作为教学语言,是因为这种语言清晰易懂,也被广泛使用。编写这本手册的目的是让你真正理解设计模式本身,而不仅仅是了解用C#实现的这些模式。
设计模式主要分为三类:创建型模式、结构型模式和行为型模式。我们将依次探讨这三类模式,首先从创建型模式开始。
我们将会涵盖的内容:
创建型设计模式
简单来说,创建型设计模式关注的是对象的创建方式。它们可以分为类创建型模式和对象创建型模式:前者通过继承来决定实例化哪个类,而后者则利用委托机制来完成对象的创建工作。
维基百科对它们的描述如下:
"创建型设计模式的目的是将系统与对象的创建、组合及表示方式分离开来。这些模式能够提高系统在对象创建的‘什么、谁、如何以及何时’等方面的灵活性。"
(来源)
因此,创建型设计模式能够将对象创建的细节隐藏起来,不让客户端代码直接了解这些细节,从而使系统更易于管理和维护。
它们还抽象出了对象的创建、组合及表示方式,这样其他部分的代码就无需关心这些具体实现细节了。
总共有五种创建型设计模式,下面我们将逐一介绍它们:
单例模式:确保一个类只有一个实例,并提供一个全局访问点来获取该实例。
工厂方法模式:定义一个用于创建对象的接口,但让子类来决定具体实例化哪个类。
抽象工厂模式:提供一个接口,用于创建一组相关或相互依赖的对象,而无需指定这些对象的具体实现类。
建造者模式:将复杂对象的构建过程与其表示方式分离开来,使得相同的构建过程可以产生不同的结果。
原型模式:通过克隆现有的对象来创建新的对象,而无需从头开始重新构建。
1. 单例设计模式
现实世界中的例子:
想象一下管弦乐团的指挥。一个乐团只有一位指挥,舞台上的每一位音乐家都会听从这位指挥的指示:何时开始演奏、何时停止、演奏的速度以及音量应该多大。
指挥就是所有音乐家共同依赖的唯一权威,他们都会从这位指挥那里获得指令。绝对不能有两位指挥同时站在舞台上发出不同的指令,那样只会造成混乱。无论哪位音乐家需要指导,他们都只能向同一个人寻求帮助。
在代码中,单例模式也正是这样工作的:只有一个实例存在,所有需要使用这个实例的地方都会共享它,并且所有的操作都来自同一个地方。
它解决的问题:
如果两位音乐家分别听从不同的指挥指令,那么演出就会陷入混乱。因此,必须有一个所有人都唯一依赖的指挥。
音乐家们是如何找到这位指挥的呢?他们不需要自己去寻找——有一个大家都熟知的地方,而那位指挥总是会在那里。
为什么没有人可以任命第二位指挥呢?因为这是由乐团本身来控制的。一旦有了一位指挥站在指挥台上,就再也没有人能够取代他了。
简单来说,这个类只有一个实例,系统中所有需要使用它的部分都会访问这个唯一的实例(永远不会创建新的实例)。
维基百科对单例模式的描述如下:
“在面向对象编程中,单例模式是一种软件设计模式,它将一个类的实例化限制为只有一个实例。当系统中只需要一个对象来协调各种操作时,这种模式非常有用。”(来源)
编程示例:
我们现在就来直接用这个例子进行说明。OrchestraConductor就是单例模式:整个系统中只有一个这样的实例,所有音乐家都共享这个实例,并且所有的决策都是由这个实例来做出的。
public class OrchestraConductor
{
// 第一步:将唯一的实例保存在这里
private static OrchestraConductor _instance;
// 第二步:私有构造函数——外部无法创建新的实例:new OrchestraConductor()
private OrchestraConductor() { }
// 第三步:获取这个实例的唯一方式
public static OrchestraConductor GetInstance()
{
if (_instance == null)
{
_instance = new OrchestraConductor();
}
return _instance;
}
// 这些是指挥员执行的操作
public void Start() => Console.WriteLine("指挥员:开始演奏。");
public void Stop() => Console.WriteLine("指挥员:停止演奏。");
public void SetTempo(string tempo) => Console.WriteLine($"指挥员:当前速度为 {tempo}。」);
}
现在让我们看看这个模式在实际中的运行效果:
// 小提琴手请求获取指挥员实例
OrchestraConductor violinist = OrchestraConductorGetInstance();
// 钢琴家也请求获取指挥员实例
OrchestraConductor pianist = OrchestraConductor.GetInstance();
// 他们请求的是同一个指挥员吗?
Console.WriteLine(object.ReferenceEquals(violinist, pianist)); // 结果为 True
violinist.SetTempo("Allegro");
pianist.Start();
输出结果:
True
指挥员:当前速度为 Allegro。
指挥员:开始演奏。
这两位音乐家得到的都是同一个指挥员实例。构造函数根本就没有被调用过两次。这就是单例模式的运作方式。
何时使用单例模式
当你需要一个在整个应用程序中都被共享的资源时,就可以使用单例模式。例如日志记录器、配置管理器或者数据库连接池等。
当创建多个实例会导致错误行为或状态冲突时,使用单例模式也是个不错的选择。
另外,当你希望某个对象能够被全局访问,而不需要在程序的各个地方都重复传递这个对象的引用时,单例模式也非常有用。
2. 工厂方法模式
想象一下招聘机构的情况。一家公司联系这家机构,说“我们需要一名员工”。但是这家公司并不会自己去创建这名员工,而是只是提出这个请求而已。
中介机构会根据公司的实际需求来决定派遣哪位具体的人员:是开发人员、设计师还是测试员。公司并不需要也不关心到底会派来谁,它们只需要知道这个人能够完成这项工作即可。
这就是“工厂模式”。你的代码要求创建一个对象,而“工厂”则会决定具体应该创建哪种类型的对象,并将其返回给你。你可以使用这个对象,而不必了解它的内部实现细节。
它解决的问题:
公司不需要知道会派来谁,它们只需要有人能够完成这项工作即可。中介机构负责决定派遣哪位人员,公司完全不必关心这些细节。
如果明天公司需要另一种类型的员工怎么办?他们只需联系同一个中介机构,由该机构来做出决定。公司的运作流程并不会改变,只有中介机构的选择会发生变化而已。
如果需要引入一种新的类型员工怎么办?可以成立一个新的专业中介机构来负责这项工作,其他一切都会保持不变。
简单来说,我们定义了一个用于创建对象的接口,但让子类来决定具体应该实例化哪个类。工厂模式允许一个类将实例化的任务委托给它的子类来完成。
维基百科对这一概念的描述如下:
“在面向对象编程中,工厂模式是一种设计模式,它利用工厂方法来解决在创建对象时无需指定其具体类的问题。工厂方法可以定义在一个接口中,由子类来实现;或者也可以定义在基类中,让子类选择是否覆盖这个方法。”(来源)
编程示例:
在这里,中介机构就相当于工厂,不同类型的员工则相当于产品,而公司则是客户。
// 员工接口——所有员工都能完成这项工作
public interface IWorker
{
void DoWork();
}
// 具体类型的员工类
public class Developer : IWorker
{
public void DoWork() => Console.WriteLine("开发人员:编写代码。");
}
public class Designer : IWorker
{
public void DoWork() => Console.WriteLine("设计师:创建设计图。");
}
// 基础中介机构类——定义工厂方法
public abstract class RecruitmentAgency
{
// 这就是工厂方法——子类负责决定雇佣哪位员工
public abstract IWorker HireWorker();
}
// 具体类型的中介机构类——每个机构都负责决定派遣哪种类型的员工
public class TechAgency : RecruitmentAgency
{
public override IWorker HireWorker() => new Developer();
}
public class DesignAgency : RecruitmentAgency
{
public override IWorker HireWorker() => new Designer();
}
<现在,让我们来看看它的实际效果吧:// A公司需要一名技术工人
RecruitmentAgency agency = new TechAgency();
IWorker worker = agency.HireWorker();
worker.DoWork();
// B公司需要一名设计人员
RecruitmentAgency agency2 = new DesignAgency();
IWorker worker2 = agency2.HireWorker();
worker2.DoWork();
输出结果:
开发人员:编写代码。
设计师:制作设计图。
公司从未直接使用过`new Developer()`或`new Designer()`这样的代码。这些决策都是由招聘机构来做出的。这就是“工厂模式”的作用。
3. 抽象工厂设计模式
现实世界中的例子
想象一下,有一家销售家具系列的商店。当你走进这家商店时,首先需要选择一种风格:现代风格还是维多利亚风格。一旦做出了选择,你所购买的所有家具都会属于同一个系列——沙发、椅子和咖啡桌都会保持一致的设计风格。商店会确保你不会买到现代风格的沙发和维多利亚风格的椅子搭配在一起。因为你并不是单独挑选每一件家具,然后希望它们能够相互搭配;而是这个系列本身就保证了这种搭配的合理性。
这就是抽象工厂模式的作用:你选择了一个设计风格,而“工厂”则会为你生成属于这个风格的全部相关产品,确保这些产品之间能够完美配合使用。
它解决的问题:
如果顾客购买了来自不同系列的家具,房间会显得很不协调。商店通过将所有家具归类到同一个系列中来解决这个问题——顾客只需选择一个系列,就能获得该系列中所有相互搭配的家具。
如果商店想要推出一个新的家具系列,它们只需要创建一个新的系列即可,而现有的系列不会受到任何影响。这样一来,顾客的购物体验也不会发生变化,只是可供选择的选项增多了而已。
如果不同的商店销售不同系列的家具,每个商店都可以被视为一个独立的“工厂”。顾客无论去哪家商店,都能遵循相同的购买流程,而商店会负责提供具体哪些产品。
简单来说,抽象工厂模式提供了一种创建相关对象家族的方法,而不需要指定这些对象的具体实现类。
维基百科对这一模式的描述如下:
“抽象工厂模式允许我们在不指定具体实现类的情况下,创建一组相互关联的对象。它通过将具有共同功能的多个工厂封装在一起来实现这一目标。”
来源: 维基百科 - 抽象工厂模式
编程示例:
在这个例子中,家具商店就是抽象工厂,“现代风格”和“维多利亚风格”则是具体的实现工厂,而“沙发”和“椅子”则属于这些工厂生产的产品。
// 产品接口——每种家具类型都有相应的接口
public interface ISofa { void Describe(); }
public interface IChair { void Describe(); }
// 现代风格系列
public class ModernSofa : ISofa
{
public void Describe() => Console.WriteLine("沙发:时尚现代的设计。");
}
public class ModernChair : IChair
{
public void Describe() => Console.WriteLine("椅子:极简主义的现代风格。");
}
// 维多利亚风格系列
public class VictorianSofa : ISofa
{
public void Describe() => Console.WriteLine("沙发:华丽繁复的维多利亚风格。");
}
public class VictorianChair : IChair
{
public void Describe() => Console.WriteLine("椅子:经典的维多利亚风格。");
}
// 抽象工厂模式——每个工厂都可以生产沙发和椅子
public interface IFurnitureFactory
{
ISofa CreateSofa();
IChair CreateChair();
}
// 具体工厂模式——每个工厂只生产自己风格的系列产品
public class ModernFurnitureFactory : IFurnitureFactory
{
public ISofa CreateSofa() => new ModernSofa();
public IChair CreateChair() => new ModernChair();
}
public class VictorianFurnitureFactory : IFurnitureFactory
{
public ISofa CreateSofa() => new VictorianSofa();
public IChair CreateChair() => new VictorianChair();
}
现在让我们来看看实际应用效果:
// 客户订购现代风格系列的产品
IFurnitureFactory factory = new ModernFurnitureFactory();
ISofa sofa = factory.CreateSofa();
IChair chair = factory.CreateChair();
sofa.Describe();
chair.Describe();
// 客户订购维多利亚风格系列的产品
IFurnitureFactory factory2 = new VictorianFurnitureFactory();
ISofa sofa2 = factory2.CreateSofa();
IChair chair2 = factory2.CreateChair();
sofa2.Describe();
chair2.Describe();
输出结果:
沙发:时尚现代的设计。
椅子:极简主义的现代风格。
沙发:华丽繁复的维多利亚风格。
椅子:经典的维多利亚风格。
所有这些产品其实都属于同一个系列。客户并没有直接使用过 `new ModernSofa()` 或 `new VictorianChair()` 这样的代码。正是抽象工厂模式让这些不同风格的系列产品能够被统一管理起来。
何时使用抽象工厂模式:
当你的系统需要使用多个相关的对象系列,并且必须确保这些对象总是被一起使用时,就应该采用抽象工厂模式。
当你想要在某个地方替换整个对象系列,而不会影响到其他部分的代码时,这种模式也非常适用。
此外,当你需要保证相关对象之间保持一致性,防止不同系列的组件被错误地混合使用时,抽象工厂模式也是一个不错的选择。
4. 构建者设计模式
实际应用示例
想象一下,裁缝为顾客制作一套西装。每一位顾客都会经历相同的流程:测量尺寸、选择面料、挑选衬里、决定纽扣样式,最后确定领口的形状。
裁缝对于每一份订单都会遵循相同的步骤进行制作,但最终完成的西装对每一位顾客来说都是独一无二的。一位商人会得到一套正式且精致的西装,而一位婚礼宾客则会得到完全不同的套装。虽然流程相同,裁缝也是一样的,但每次得到的结果却都不同。
这其实就是“建造者模式”的运作方式——建造过程本身是不变的,变化的只是每一步中所做出的选择而已。
它解决的问题:
如果一套西装必须一次性完成制作,而不允许分步骤进行,那么就必须事先了解所有的细节,并且一次性把所有环节都处理好。而裁缝通过将制作过程分解成多个步骤,使得每一个决策都能被清晰地、逐个地做出。
如果两位顾客想要完全不同的西装,但却找的是同一位裁缝,那么裁缝也会对这两份订单遵循相同的制作流程。只有每一步中的选择会不同而已。
如果需要推出一种新的西装款式,那么只需要为这种新款定义一套新的制作选项即可,而原有的制作流程本身并不会发生任何变化。
简单来说,这种模式就是将复杂对象的构建过程与其表现形式分开,这样同样的构建过程就可以产生不同的结果。
维基百科对这一概念的描述是这样的:
"建造者模式将复杂对象的构建过程与其表现形式分离开来,这样一来,相同的构建过程就能够生成不同的对象表现结果。" (来源)
编程示例:
在这里,裁缝相当于“导演”,西装则是最终的产品,“建造者”则负责执行一步步的制作流程。
// 产品类
public class Suit
{
public string Fabric { get; set; }
public string Lining { get; set; }
public string Buttons { get; set; }
public void Describe()
{
Console.WriteLine($"这套西装采用{Fabric}材质作为外层面料,内衬为{Lining}材质,纽扣为{Buttons}材质。");
}
}
// 建造者接口
public interface ISuitBuilder
{
void SetFabric();
void SetLining();
void SetButtons();
Suit GetSuit();
}
// 商务西装建造者类
public class BusinessSuitBuilder : ISuitBuilder
{
private Suit _suit = new Suit();
public void SetFabric() => _suit.Fabric = "深色羊毛";
public void SetLining() => _suit.Lining = "丝绸";
public void SetButtons() => _suit Buttons = "黑色牛角纽扣";
public Suit GetSuit() => _suit;
}
// 婚礼西装建造者类
public class WeddingSuitBuilder : ISuitBuilder
{
private Suit _suit = new Suit();
public void SetFabric() => _suit.Fabric = "象牙色亚麻面料";
public void SetLining() => _suit.Lining = "缎面材质";
public void SetButtons() => _suit Buttons = "珍珠色纽扣";
public Suit GetSuit() => _suit;
}
// 这位“裁缝”其实就是负责指导整个构建过程的导演
public class Tailor
{
public Suit MakeSuit(ISuitBuilder builder)
{
builder.SetFabric();
builder.SetLining();
builder.SetButtons();
return builder.GetSuit();
}
}
现在让我们看看这个过程在实际中的应用:
Tailor tailor = new Tailor();
Suit businessSuit = tailor.MakeSuit(new BusinessSuitBuilder());
businessSuit.Describe();
Suit weddingSuit = tailor.MakeSuit(new WeddingSuitBuilder());
weddingSuit.Describe();
输出结果:
套装1:深色羊毛面料,丝绸内衬,黑色纽扣。
套装2:象牙白亚麻面料,缎面内衬,珍珠纽扣。
同一个“裁缝”按照相同的流程,最终制作出了两种完全不同的套装。这就是建造者模式的作用所在。
何时使用建造者设计模式
当一个对象由许多部分组成,且如果一次性完成所有构建步骤会显得非常复杂时,就应该使用建造者模式。
当你希望相同的构建过程能够根据每一步中所做的不同选择产生不同的结果时,这种模式也是个不错的选择。
另外,当你想要将对象的构建逻辑与其本身分离出来,使两者可以独立进行修改时,建造者模式也非常适用。
5. 原型设计模式
现实世界中的例子
假设你正在开发一款绘图应用程序。用户可以创建圆形、矩形或三角形等形状,每种形状都可以拥有不同的颜色、大小和位置。
现在想象一下,如果用户需要在画布上放置10个大小相同的红色圆圈,那么从头开始创建每一个圆圈意味着需要重复同样的操作10次。而如果这些形状的结构比较复杂,包含许多可配置的属性,这样的操作就会变得非常繁琐且效率低下。
原型设计模式解决了这个问题——它允许你使用一个已经配置完成了的形状来进行复制。复制的对象会与原始对象完全一致,之后用户可以自由地移动、更改颜色或调整大小,而原始对象本身却不会受到任何影响。此外,这种模式还允许在运行时添加新的形状类型,而应用程序无需事先了解这些新类型的存在。
它解决的问题:
每次都从头开始创建一个新的形状会浪费大量资源。如果一个形状包含许多可配置的属性,那么重复设置这些属性无疑是非常低效的。而复制一个已经配置好的对象则要省去很多麻烦。
应用程序不需要事先知道它所复制的对象的具体类型。在运行时,可以动态地添加或删除各种形状,程序只需调用复制方法,就能得到一个准备好的对象,无论这个对象属于哪种类型。
对复制对象的修改绝不应该影响到原始对象。每个被复制的对象都是完全独立的,对复制对象所做的任何更改都只会影响复制对象本身。
简单来说,你可以通过复制现有的对象来创建新的对象。副本在最初与原对象完全相同,之后就可以对其进行独立的修改了。
维基百科对此有这样的描述:
“当需要创建的对象的类型由一个原型实例决定时,就可以使用原型模式。这个原型会被复制出来,从而生成新的对象。”(来源)
编程示例:
所有的形状都知道如何复制自己。在运行时,应用程序从来不会直接调用new Circle()或new Rectangle(),而是利用已存在的对象进行复制。
// 原型接口——所有形状都必须具备自我复制的能力
public abstract class Shape
{
public string Colour { get; set; }
public int Size { get; set; }
public abstract Shape Clone();
public abstract void Describe();
}
// 具体实现的形状类
public class Circle : Shape
{
public override Shape Clone() => (Shape)this.MemberwiseClone();
public override void Describe() => Console.WriteLine($"Circle | Colour: {Colour} | Size: {Size}");
}
public class Rectangle : Shape
{
public override Shape Clone() => (Shape>this.MemberwiseClone();
public override void Describe() => Console.WriteLine($"Rectangle | Colour: {Colour} | Size: {Size}");
}
现在让我们来看一下这个示例的实际运行效果:
// 创建一个已配置好的圆形对象
Circle original = new Circle { Colour = "Red", Size = 50 };
// 直接复制该对象,而不是从头开始创建
Shape clone1 = original.Clone();
Shape clone2 = original.Clone();
// 独立地修改这两个副本
clone2.Colour = "Blue";
original.Describe();
clone1.Describe();
clone2.Describe();
输出结果:
Circle | Colour: Red | Size: 50
Circle | Colour: Red | Size: 50
Circle | Colour: Blue | Size: 50
clone2的颜色被改成了蓝色,而原始对象的颜色仍然保持为红色。每个对象都是完全独立的,这就是原型模式的作用。
何时使用原型设计模式:
当从头开始创建一个新对象非常耗时或复杂,而现有的对象已经具备了所有所需的配置时,就可以使用原型模式。
当应用程序需要在运行时创建对象,但事先并不知道这些对象的具体类型时,这种模式也非常有用。
另外,当你需要某个对象的多种变体,并且希望从一种已知的状态开始进行开发,而不是每次都需要重新构建这个对象时,原型模式也会派上大用场。
结构设计模式
简单来说,结构设计模式就是研究类和对象是如何组合在一起形成更大的结构的。它们利用继承和组合机制,帮助你构建出灵活且高效的结构,而无需从头开始重新编写所有的代码。
维基百科对它们的描述如下:
“在软件工程中,结构模式是一种设计模式,它们通过找到一种简单的方法来体现各种实体之间的关系,从而简化设计过程。”(来源)
结构设计模式说明了对象和类是如何组合在一起形成更大、更复杂的结构的,同时还能保证这些结构的灵活性与高效性。
这些模式更注重组合而非继承:它们关注的是事物之间的连接方式,而不是这些事物本身是什么。
一共有七种结构设计模式:
适配器模式:将一种接口转换为另一种客户端期望的接口,从而使不兼容的接口能够协同工作。
桥接模式:将抽象层与其实现层分离,使两者可以独立地进行演化。
组合模式:通过将对象组合成树形结构来表示部分与整体的关系,从而使客户端能够以统一的方式处理这些对象及其组合体。
装饰器模式:动态地为对象添加额外的功能,为继承提供了一种灵活的替代方案。
外观模式:为复杂的子系统提供简化统一的接口。
享元模式:通过共享机制高效地管理大量细粒度的对象。
代理模式:为另一个对象提供一个替代品或占位符,以控制对该对象的访问。
1. 适配器设计模式
现实世界中的例子
想象一下,在一次商务会议上使用语言翻译器的场景。一位英国首席执行官需要向一个日本团队发表讲话,但这位CEO只会说英语,而日本团队只会说日语。这时,翻译员就充当了中间桥梁的角色,将CEO说的每一句话都翻译成日语,然后传递给日本团队。双方仍然使用自己熟悉的语言进行交流,无论是CEO还是日本团队,都没有改变自己的沟通方式——正是翻译员让它们之间实现了顺畅的沟通。
这就是适配器模式的作用:客户端使用一种接口进行通信,而另一方则使用另一种不同的接口;适配器则介于两者之间,使它们能够协同工作,而无需任何一方做出修改。
它解决的问题:
CEO不会说日语,日本团队也不会说英语,他们之间的沟通方式本来就是不兼容的。适配器模式让双方能够使用彼此能理解的语言进行交流。
如果CEO现在需要向一个法国团队发表讲话,只需要更换翻译员即可,CEO原来的工作流程并不会发生任何变化。
如果某个现有类拥有有用的方法,但其接口设计并不合适,也可以使用适配器模式来解决问题——系统的其他部分仍然可以与这个适配器进行交互,而原有类本身则无需做出任何修改。
简单来说,就是用一个新的接口包装现有的类,这样客户端就可以在不对任何一方进行任何修改的情况下使用它。
维基百科对此有这样的描述:
"在软件工程中,适配器模式是一种软件设计模式(也被称为封装器),它可以使现有类的接口被用作另一种接口。这种模式通常用于让现有的类与其他类协同工作,而无需修改它们的源代码。" (来源)
编程示例:
首席执行官是客户端,而日本团队成员则是被适配的对象。它们本身都很有用,但使用的接口并不兼容。而“翻译器”就是起到适配作用的组件。
// 首席执行官期望的是一个能够用英语接收消息的实体
public interface IEnglishSpeaker
{
void Speak(string message);
}
// 日本团队成员只能使用日语进行交流(即被适配的对象)
public class JapaneseTeamMember
{
public void SpeakJapanese(string message)
{
Console.WriteLine($"日本团队成员:{message}");
}
}
// “翻译器”将日本团队成员的功能适配成英语接口
public class Translator : IEnglishSpeaker
{
private readonly JapaneseTeamMember _teamMember;
public Translator(JapaneseTeamMember teamMember)
{
_teamMember = teamMember;
}
public void Speak(string message)
{
string translated = TranslateToJapanese(message);
_teamMember.SpeakJapanese(translated);
}
private string TranslateToJapanese(string english)
{
"Good morning, team." => "おはようございます、チームの皆さん。",
"Please review the proposal." => "提案書を確認してください。",
_ => $"[日语: {english}]"
};
}
// 首席执行官只知道如何与IEnglishSpeaker进行交互
public class CEO
{
private readonly IEnglishSpeaker _speaker;
public CEO(IEnglishSpeaker speaker)
{
_speaker = speaker;
}
public void Address(string message)
{
Console.WriteLine($"首席执行官(英语):{message}");
_speaker.Speak(message);
}
}
现在让我们来看这个示例的实际运行效果:
JapaneseTeamMember teamMember = new JapaneseTeamMember();
IEnglishSpeaker translator = new Translator(teamMember);
CEO ceo = new CEO(translator);
ceo.Address("Good morning, team.");
ceo.Address("Please review the proposal.");
输出结果:
首席执行官(英语):Good morning, team.
日本团队成员:おはようございます、チームの皆さん。
首席执行官(英语):Please review the proposal.
日本团队成员:提案書を確認してください。
首席执行官根本不知道JapaneseTeamMember的存在,而日本团队成员也并不了解首席执行官所使用的接口。正是“翻译器”让这两者能够无缝协作,而无需对任何一方进行修改。这就是适配器的作用。
何时使用它
当您想要使用某个现有的类,但其接口与您的代码需求不匹配时,就可以使用适配器模式。
当您需要创建一个能够与那些接口不兼容的类协同工作的可重用类时,适配器模式也会非常有用。
另外,在您需要集成第三方库或旧有代码而又不想修改它们时,适配器模式也是解决问题的有效手段。
2. 桥接设计模式
现实世界中的例子
以电视遥控器和电视机为例吧。遥控器和电视机是两种不同的东西。遥控器有普通的,也有智能的;电视机有索尼品牌的,也有三星品牌的。任何一款遥控器都可以与任何一款电视机配合使用,两者之间并不需要了解对方的内部结构。购买了一台新的三星电视机后,旧的遥控器仍然可以使用;如果买了一个智能通用遥控器,那么它就能适配您拥有的所有电视机。这种设计正是桥接模式的核心所在。
在这里,抽象层(遥控器)和实现层(电视机)是两个相互独立的层次结构,它们可以独立发展、独立变化,而不会互相影响。
它解决的问题:
如果每种遥控器都只能与特定品牌的电视机配合使用,那会怎么样呢?您就需要为每个品牌分别创建一个遥控器类:SonyBasicRemote、SamsungBasicRemote、SonySmartRemote、SamsungSmartRemote……这样一来,每增加一个新品牌,遥控器类的数量就会翻倍。而桥接模式可以避免这种问题。
如果您想添加一种新的遥控器类型,但不想修改现有的电视机,使用桥接模式也很方便。您只需要创建一个新的遥控器类即可,电视机本身无需做任何改动。
同样地,如果您想增加一个新品牌的电视机,也不必修改现有的遥控器。只需为这个新品牌创建一个对应的电视机类,所有现有的遥控器都可以继续与之配合使用。
简单来说,桥接模式就是将一个复杂的类拆分为两个独立的层次结构,这样每个部分都可以独立地进行修改和扩展,而不会影响到其他部分。
维基百科对桥接模式的描述如下:
"桥接模式是一种软件工程中的设计模式,它的目的是将抽象层与实现层分离出来,从而使两者能够独立发展。" 来源)
编程示例:
在这里,遥控器代表抽象层,电视机品牌代表实现层。它们通过ITV接口连接在一起,但这两个层次结构彼此之间并不依赖对方的细节。
// 实现接口——任何电视机都必须具备这些功能
public interface ITV
{
void TurnOn();
void TurnOff();
void SetChannel(int channel);
void SetVolume(int volume);
}
// 具体实现类——不同品牌的电视机会以自己的方式来实现这些接口
public class SonyTV : ITV
{
public void TurnOn() => Console.WriteLine("Sony TV:正在开机。BRAVIA显示屏已准备就绪。");
public void TurnOff() => Console.WriteLine("Sony TV:正在关机。");
public void SetChannel(int ch) =&> Console.WriteLine($"Sony TV:正在切换到频道{ch}。");
public void SetVolume(int vol) =&> Console.WriteLine($"Sony TV:音量已设置为{vol}。");
}
public class SamsungTV : ITV
{
public void TurnOn() => Console.WriteLine("Samsung TV:正在开机。Smart Hub正在加载中。");
public void TurnOff() => Console.WriteLine("Samsung TV:正在关机。");
public void SetChannel(int ch) =&> Console.WriteLine($"Samsung TV:已选择频道{ch}。");
public void SetVolume(int vol) =&> Console.WriteLine($"Samsung TV:音量已设置为{vol}。");
}
// 这是一种抽象层次:远程控制设备会持有它所控制的电视机的引用
public abstract class RemoteControl
{
protected ITV _tv;
protected RemoteControl(ITV tv) { _tv = tv; }
public abstract void TurnOn();
public abstract void TurnOff();
public abstract void SetChannel(int channel);
}
// 更高级的抽象层次:基本的远程控制设备,它的功能与电视机完全相同
public class BasicRemote : RemoteControl
{
public BasicRemote(ITV tv) : base(tv) { }
public override void TurnOn() => _tv.TurnOn();
public override void TurnOff() => _tv.TurnOff();
public override void SetChannel(int ch) => _tv.SetChannel(ch);
}
// 更高级的抽象层次:智能远程控制设备,在基本功能的基础上增加了额外的功能
public class SmartRemote : RemoteControl
{
public SmartRemote(ITV tv) : base(tv) { }
public override void TurnOn()
{
Console.WriteLine("智能遥控器:正在启用语音控制功能.");
_tv.TurnOn();
}
public override void TurnOff()
{
Console.WriteLine("智能遥控器:正在保存观看记录.");
_tv.TurnOff();
}
public override void SetChannel(int ch)
{
Console.WriteLine("智能遥控器:正在查询频道指南。」
_tv.SetChannel(ch);
}
public void SetVolume(int vol) => _tv.SetVolume(vol);
}
现在让我们来看看这些远程控制设备在实际使用中的表现:
// 基本的远程控制设备与索尼电视机搭配使用
Console.WriteLine("--- 基本遥控器 + 索尼电视机 ---");
RemoteControl basicSony = new BasicRemote(new SonyTV());
basicSony.TurnOn();
basicSony.SetChannel(5);
basicSony.TurnOff();
// 智能遥控器与三星电视机搭配使用
Console.WriteLine("\n--- 智能遥控器 + 三星电视机 ---");
SmartRemote smartSamsung = new SmartRemote(new SamsungTV());
smartSamsung.TurnOn();
smartSamsung.SetChannel(10);
smartSamsung.SetVolume(20);
smartSamsung.TurnOff();
// 这两种遥控器可以自由切换——将智能遥控器换成索尼遥控器也不需要修改任何代码
Console.WriteLine("\n--- 智能遥控器 + 索尼电视机 ---");
SmartRemote smartSony = new SmartRemote(new SonyTV());
smartSony.TurnOn();
smartSony.SetChannel(3);
smartSony.TurnOff();
输出结果:
--- 基本遥控器 + 索尼电视机 ---
索尼电视机:正在开机。BRAVIA显示屏已准备就绪。
索尼电视机:正在切换到第5频道。
索尼电视机:已经关机。
--- 智能遥控器 + 三星电视机 ---
智能遥控器:正在启用语音控制功能。
三星电视机:正在开机。Smart Hub正在加载中。
智能遥控器:正在查询频道指南。
三星电视机:已选择第10频道。
三星电视机的音量设置为20。
智能遥控器:正在保存观看记录。
三星电视机:已经关机。
--- 智能遥控器 + 索尼电视机 ---
智能遥控器:正在启用语音控制功能。
索尼电视机:正在开机。BRAVIA显示屏已准备就绪。
智能遥控器:正在查询频道指南。
索尼电视机:正在切换到第3频道。
智能遥控器:正在保存观看记录。
索尼电视机:已经关机。
同样的SmartRemote控制设备既可以用于索尼品牌的电视,也可以用于三星品牌的电视,而且完全不需要进行任何修改。如果需要添加像LG这样的新电视品牌,那就需要为该品牌创建一个新的控制类;不过现有的所有遥控器都可以立即用来控制这些新品牌的电视。这就是“Bridge”系统的作用所在。何时使用它
当您希望避免抽象层与其实现层之间形成永久性的绑定关系,以便在运行时可以互换这两者时,就可以使用桥接模式。
此外,当抽象层和实现层都需要通过继承来进行独立扩展时,该模式也同样适用。
另外,如果对实现层的修改不会影响客户端代码,那么桥接模式也是个不错的选择——因为在这种情况下,客户端无需重新编译代码即可继续使用程序。
3. 组合设计模式
现实世界中的例子
以公司的组织结构为例:公司有一位首席执行官,首席执行官之下是各个部门负责人,而这些部门负责人各自领导着一支由员工组成的团队;在某些部门中,还存在着更小的子团队。
现在假设您想了解整个公司的总薪资支出。您可以询问某一位员工,让他告诉您自己的薪资;您也可以询问整个部门,这样就能统计出该部门内所有员工的薪资总和,包括那些隶属于该部门的子团队的成员;当然,您还可以直接询问整个公司,这样就能得到所有层级员工的薪资总额。
无论您是与一个人还是与成千上万人交流,提问的方式都是相同的,得到的结果也会是一样的。
这就是组合模式的作用:各个单独的元素或元素组都遵循相同的接口规范,因此调用者根本不需要知道自己正在操作的是哪一个具体的元素或元素组。
它解决的问题:
如果需要为处理单个员工和整个部门编写不同的代码,那么程序中就会充斥着各种
if语句来判断当前正在操作的是哪个对象。而组合模式完全消除了这种必要性——无论处理的是单个元素还是整个组,都只需要使用同一个接口即可。如果某个部门内部还可以包含其他部门,那么组合模式能够自然地处理任意深度的嵌套结构。调用者只需向层次结构的顶层发起请求,后续的操作就会自动向下进行。
如果需要添加新的团队类型或角色,也完全不需要对现有代码进行任何修改——只需要为这些新元素实现相同的接口即可,它们在层次结构中的位置并不会影响其他部分的正常运行。
简单来说,组合模式就是将对象组织成树状结构,这样无论是单个对象还是由多个对象组成的组,都可以通过同一个接口来进行操作,调用者完全不需要关心它们之间的区别。
维基百科对组合模式的描述如下:
“组合模式描述了一组对象,这些对象可以被当作同一类型对象的单一实例来进行处理。组合模式的目的是将对象组织成树状结构,从而表示出‘部分-整体’的层次关系。” (来源)
编程示例:
树结构中的每一个节点(无论是单个员工还是整个部门)都实现了IEmployee接口,因此调用者可以以完全相同的方式来处理它们。
// 组件接口——所有叶节点和组合对象都遵循这个接口规范
public interface IEmployee
{
string Name { get; }
int GetSalary();
void GetDetails(string indent = "");
}
// 单个员工——没有下属员工的员工
public class Employee : IEmployee
{
private readonly int _salary;
public string Name { get; }
public Employee(string name, int salary)
{
Name = name;
_salary = salary;
}
public int GetSalary() => _salary;
public void GetDetails(string indent = "") => Console.WriteLine($"{indent}- {Name} (£{_salary:N0})");
}
// 复合体——包含其他员工或部门的部门
public class Department : IEmployee
{
private readonly List _members = new();
public string Name { get; }
public Department(string name) { Name = name; }
public void Add(IEmployee employee) => _members.Add(employee);
public void Remove(IEmployee employee) => _members.Remove(employee);
public int GetSalary() => _members.Sum(m => m.GetSalary());
public void GetDetails(string indent = "")
{
Console.WriteLine($"{indent}[{Name}] 总工资:£{GetSalary():N0}");
foreach (var member in _members)
member.GetDetailsindent + " ");
}
}
现在让我们看看这些代码在实际中的应用:
// 单个员工
var ceo = new Employee("Alice (CEO)", 120_000);
var cto = new Employee("Bob (CTO)", 95_000);
var dev1 = new Employee("Carol (Developer)", 65_000);
var dev2 = new Employee("David (Developer)", 62_000);
var cfo = new Employee("Eve (CFO)", 90_000);
var accountant = new Employee("Frank (Accountant)", 55_000);
// 创建工程部
var engineering = new Department("Engineering");
engineering.Add(cto);
engineering.Add(dev1);
engineering.Add/dev2);
// 创建财务部
var finance = new Department("Finance");
finance.Add(cfo);
finance.Add(accountant);
// 创建整个公司
var company = new Department("Acme Corp");
company.Add(ceo);
company.Add(engineering);
company.Add(finance);
// 查询整个公司的信息——只需一次调用即可获取所有数据
Console.WriteLine("=== 整个公司 ===");
company.GetDetails();
// 查询仅工程部的信息——同样只需一次调用,使用相同的接口
Console.WriteLine("\n=== 仅工程部 ===");
engineering.GetDetails();
// 查询单个员工的信息——还是只需一次调用,使用相同的接口
Console.WriteLine("\n=== 单个员工 ===");
dev1.GetDetails();
输出结果:
=== 整个公司 ===
[Acme Corp] 总工资:£487,000
- Alice (CEO) (£120,000)
[Engineering] 总工资:£222,000
- Bob (CTO) (£95,000)
- Carol (Developer) (£65,000)
- David (Developer) (£62,000)
[Finance] 总工资:£145,000
- Eve (CFO) (£90,000)
- Frank (Accountant) (£55,000)
=== 仅工程部 ===
[Engineering] 总工资:£222,000
- Bob (CTO) (£95,000)
- Carol (Developer) (£65,000)
- David (Developer) (£62,000)
=== 单个员工 ===
- Carol (Developer) (£65,000)
company.GetDetails()、engineering.GetDetails()以及dev1.GetDetails():这些方法都是在树的三个不同层级上被调用的。调用者从未检查过自己实际上是在与什么对象进行交互。这就是组合模式的作用。
何时使用它
当你需要表示部分与整体的层次结构时,比如树木这种结构——其中单个项目或项目组可以互换使用——就可以使用组合模式。
当你希望客户端代码能够以统一的方式处理单个对象以及对象集合,而无需进行任何特殊处理时,组合模式也非常适用。
此外,当结构可以嵌套到任意深度,并且这种嵌套深度不会影响调用者与结构的交互方式时,组合模式也是理想的选择。
4. 装饰器设计模式
现实世界中的例子
想象一下在咖啡馆点咖啡的过程。你首先点了一杯纯浓缩咖啡,然后加了牛奶,接着又加了香草糖浆,最后再淋上奶油。每一项添加的内容都是建立在原有基础之上的,它们各自增加了新的成本和描述信息,但中心的浓缩咖啡本身却从未发生变化。你只是依次为它添加各种配料而已。你可以加两份糖浆,也可以完全不加牛奶,任何组合都是可行的,而且无需为每种组合创建一个新的咖啡种类。
这就是装饰器模式。你从一个基础对象开始,然后通过一层层地添加装饰组件来改变它的行为,而所有新增的行为都会被委托给底层的基础对象去处理。
它解决的问题:
如果需要为每种组合创建一个单独的类,比如EspressoWithMilk、EspressoWithMilkAndVanilla等等,这样的做法会导致类数量急剧增加。而装饰器模式可以在运行时动态地添加行为,因此完全不需要这些额外的类。
如果基础对象本身不应该发生任何变化,那么装饰器模式就能确保这一点。浓缩咖啡这个基础类保持不变,而各种装饰组件只是独立地叠加在其上面,扩展它的功能而已。
如果需要添加新的配料,只需创建一个新的装饰器类即可,所有现有的组合方式仍然可以正常使用。
简单来说,装饰器模式就是将一个对象用一层或多层装饰组件包裹起来,每一层都会在委托给下一层之前或之后为该对象添加新的行为。
维基百科对装饰器模式的描述如下:
"装饰器模式是一种设计模式,它允许动态地为某个对象添加新的行为,而不会影响同一类中其他实例的行为。" (来源)
编程示例:
在这里,咖啡就是基础组件,而各种配料则相当于装饰器。每个装饰器都会包裹住基础组件,并为其增加描述信息和成本信息。
// 基础组件接口——所有的咖啡,无论是纯的还是加了配料的,都遵循这个接口
public interface ICoffee
{
string GetDescription();
double GetCost();
}
// 基本组件——纯浓缩咖啡
public class Espresso : ICoffee
{
public string GetDescription() => "浓缩咖啡";
public double GetCost() => 1.00;
}
// 基本装饰器——可以包装任何ICoffee对象,并将其方法委托给被包装的对象
public abstract class CoffeeDecorator : ICoffee
{
protected readonly ICoffee _coffee;
protected CoffeeDecorator(ICoffee coffee) { _coffee = coffee; }
public virtual string GetDescription() => _coffee.GetDescription();
public virtual double GetCost() => _coffee.GetCost();
}
// 具体装饰器——每个装饰器都会为咖啡添加新的功能层
public class Milk : CoffeeDecorator
{
public Milk(ICoffee coffee) : base(coffee) { }
public override string GetDescription() => _coffee.GetDescription() + ", 奶油";
public override double GetCost() => _coffee.GetCost() + 0.30;
}
public class VanillaSyrup : CoffeeDecorator
{
public VanillaSyrup(ICoffee coffee) : base(coffee) { }
public override string GetDescription() => _coffee.GetDescription() + ", 香草糖浆";
public override double GetCost() => _coffee.GetCost() + 0.50;
}
public class WhippedCream : CoffeeDecorator
{
public WhippedCream(ICoffee coffee) : base(coffee) { }
public override string GetDescription() => _coffee.GetDescription() + ", 奶油泡沫";
public override double GetCost() => _coffee.GetCost() + 0.75;
}
现在让我们来看看实际应用的效果:
// 纯浓缩咖啡
ICoffee order = new Espresso();
Console.WriteLine($"{order.GetDescription()} => £{order.GetCost():F2});
// 加上奶油
order = new Milk(order);
Console.WriteLine($"{order.GetDescription()} => £{order.GetCost():F2};
// 再加上香草糖浆
order = new VanillaSyrup(order);
Console.WriteLine($"{order.GetDescription()) => £{order.GetCost():F2});
// 最后再加上奶油泡沫
order = new WhippedCream(order);
Console.WriteLine($"{order.GetDescription()) => £{order.GetCost():F2}");
输出结果:
浓缩咖啡 => £1.00
浓缩咖啡, 奶油 => £1.30
浓缩咖啡, 奶油, 香草糖浆 => £1.80
浓缩咖啡, 奶油, 香草糖浆, 奶油泡沫 => £2.55
每一行都表示在之前的基础上添加了新的功能层。浓缩咖啡本身并没有发生变化,但其价格和描述信息会随着装饰器的添加而改变。这就是装饰器模式的作用。
何时使用装饰器模式
当您想要为某个对象添加新的功能,而不影响同一类中的其他对象时,就可以使用装饰器模式。
当通过继承来实现各种功能组合会导致类数量急剧增加时,装饰器模式也是一个很好的选择。
此外,当您需要在运行时以任意顺序组合这些功能,并且让这些功能的执行相互独立时,装饰器模式也非常有用。
5. 外观设计模式
实际应用示例
想象一下,在一个购物网站上点击“下单”按钮。在这个简单的操作背后,实际上会发生许多事情:系统会检查商品是否有库存,然后收取你的付款费用,生成发货标签,并发送确认邮件给你。但你并不会看到这些过程。你只是点击了一个按钮,就得到了最终的结果。四个独立系统的复杂性被隐藏在了这个简单的操作之后。
这就是外观设计模式的作用:它为许多复杂的内部组件提供了一个简洁的接口。使用该模式的用户无需了解这些组件背后究竟发生了什么。
它能解决的问题:
如果客户端必须直接调用每个子系统会怎样?比如先检查库存,然后处理付款,接着生成发货标签,最后发送邮件,而且所有这些操作都必须按照正确的顺序进行,同时还要分别处理可能出现的错误。而外观设计模式可以将这一切整合成一个简单的调用。
如果某个子系统发生了变化会怎样?外观设计模式可以吸收这种变化,客户端代码根本不需要知道这一点,只需要更新外观设计模式的相关部分即可。
如果不同的客户端需要相同的操作流程会怎样?他们都可以调用同一个外观设计模式方法。这样,所有的逻辑都集中在一个地方,而不会在每个使用该模式的客户端中都被重复编写。
简单来说,外观设计模式就是提供一个简洁的接口,用来隐藏一组复杂子系统的复杂性。
维基百科对它的描述是这样的:
“外观设计模式是一种常用于面向对象编程中的软件设计模式。就像建筑学中的立面一样,外观设计模式也是一个充当前端接口的对象,它用来屏蔽那些更复杂的底层代码。”(来源)
编程示例:
每个子系统都负责完成自己的任务。OrderFacade则是协调这些子系统的唯一入口点。客户端只需要与这个外观设计模式进行交互即可。
// 子系统1:检查商品是否有库存
public class InventoryService
{
public bool CheckStock(string item)
{
Console.WriteLine($"正在检查{item}的库存情况。」);
return true;
}
}
// 子系统2:处理付款操作
public class PaymentService
{
public bool ProcessPayment(string cardNumber, double amount)
{
Console.WriteLine($"正在为卡号{cardNumber[^4..]}充入{amount:F2}英镑。」);
return true;
}
}
// 子系统3:生成发货标签
public class ShippingService
{
public string GenerateLabel(string item, string address)
{
Console.WriteLine($"正在为{item}生成发往{address}的发货标签。」);
return "TRACK-29384";
}
}
// 子系统4:发送确认邮件
public class EmailService
{
public void SendConfirmation(string email, string trackingCode)
{
Console.WriteLine($"已向 {email} 发送确认邮件。跟踪码为:{trackingCode}.");
}
}
// 外观层——仅提供一个方法,隐藏了所有的四个子系统
public class OrderFacade
{
private readonly InventoryService _inventory = new();
private readonly PaymentService _payment = new();
private readonly ShippingService _shipping = new();
private readonly EmailService _email = new();
public void PlaceOrder(string item, string cardNumber, double amount, string address, string email)
{
Console.WriteLine("=== 下单中 ===");
if (!_inventory.CheckStock(item))
{
Console.WriteLine("订单失败:商品缺货。」;
return;
}
if (!_payment.ProcessPayment(cardNumber, amount))
{
Console.WriteLine("订单失败:支付失败。」;
return;
}
string trackingCode = _shipping.GenerateLabel(item, address);
_email.SendConfirmation(email, trackingCode);
Console.WriteLine($"\n订单已完成。您的跟踪码是 {trackingCode}。」);
}
}
现在让我们看看它的实际运行效果:
OrderFacade store = new OrderFacade();
store.PlaceOrder(
item: "无线耳机",
cardNumber: "4111111111111234",
amount: 79.99,
address: "伦敦枫叶街42号",
email: "customer@email.com"
);
输出结果:
=== 下单中 ===
库存检查:正在查询无线耳机的库存情况。
支付处理:正在从卡号为1234的卡片中扣除79.99英镑。
物流配送:正在为发送到伦敦枫叶街42号的无线耳机生成运输标签。
邮件发送:已向customer@email.com发送确认邮件。跟踪码为TRACK-29384。
订单已完成。您的跟踪码是TRACK-29384。
客户端仅调用了一个方法,而四个子系统却按正确的顺序依次运行。对于调用者来说,这些复杂的内部逻辑完全不可见。这就是外观层的意义所在。
何时使用它
当您希望为复杂的子系统提供一个简单的接口,使调用者不必了解其内部实现细节时,就可以使用外观层。
当您希望将系统分层设计,让高层代码通过外观层与各子系统进行交互,而不是直接访问低层子系统时,外观层也是一个非常有效的工具。
另外,当您需要一个统一的入口点来协调多个服务之间的操作流程时,使用外观层也是个不错的选择。
6. 享元设计模式
实际应用示例
想象有一款游戏需要渲染一片森林,而这片森林中有了一万棵树。每棵树都有不同的类型、颜色和纹理,但其中大部分树实际上是橡树,而这些橡树的外观都完全相同。
创建一万个独立的对象,每个对象都存储相同的名称、颜色和纹理信息,会浪费大量内存。相反,应该创建一个共享的`TreeType`对象来保存所有这些数据。森林中的每一棵树都指向这个共享对象,而只存储自己在地图上的位置信息。
这就是“享元模式”。那些在多个实例中相同的数据会被共享;而每个实例独有的数据则会单独存储,并且只在需要时才会被传递出去。
它解决的问题:
如果为每一棵树都创建一个完整的对象,那么对于一万棵树来说,就会重复存储相同的名称、颜色和纹理信息十万次。而享元模式只会将这些共享数据存储一次,然后在所有地方重复使用。
如果引入了新的树种,工厂会为这种新树种创建一个共享对象。所有属于这种类型的树都会直接使用这个共享对象,而不会占用额外的内存。
如果需要将每一棵树渲染在它所在的位置上,由于每棵树的位置信息都是唯一的,因此这些位置信息会存储在树本身上,只有在渲染时才会被传递给共享对象。共享对象本身从来不会保存这些位置信息。
简单来说,就是将一个对象的数据分为两部分:一部分在多个实例中是共享的,另一部分则是每个实例独有的。共享的部分只需要存储一次;而独有的部分则只在需要时才被传递。
维基百科对这一模式的描述如下:
“享元是一种通过尽可能多地与其他相似对象共享数据来减少内存使用量的对象。当大量使用对象会导致内存占用过高时,享元模式就能有效地解决这个问题。” (来源)
编程示例
TreeType就是这种享元对象,它负责存储共享的数据。Tree对象则只存储自己独有的位置信息以及一个指向共享的TreeType对象的引用。工厂会确保每个TreeType对象只被创建一次。
// 享元对象——用于存储所有属于同一类型的树共有的数据
public class TreeType
{
public string Name { get; }
public string Colour { get; }
public string Texture { get; }
public TreeType(string name, string colour, string texture)
{
Name = name;
Colour = colour;
Texture = texture;
}
public void Render(int x, int y)
{
Console.WriteLine($"正在渲染名为{Name}、颜色为{Colour}、纹理为{Texture}的树,位置在({x}, {y})");
}
}
// 享元工厂——负责创建并缓存各种树类型对象,以避免重复创建
public class TreeTypeFactory
{
private readonly Dictionary _cache = new();
public TreeType GetTreeType(string name, string colour, string texture)
{
string key =($"{name}_{colour}_{texture}";
if (!_cache.ContainsKey(key))
{
Console.WriteLine($"工厂正在为‘{name}’创建新的TreeType对象。");
_cache[key] = new TreeType(name, colour, texture);
}
return _cache[key];
}
public int TotalTypes => _cache.Count;
}
// “环境”对象包含唯一的外部状态(位置信息)以及对共享“轻量级对象”的引用
public class Tree
{
private readonly int _x;
private readonly int _y;
private readonly TreeType _type;
public Tree(int x, int y, TreeType type)
{
_x = x;
_y = y;
_type = type;
}
public void Render() => _type.Render(_x, _y);
}
// “森林”类使用共享的“轻量级对象”来种植树木
public class Forest
{
private readonly List _trees = new();
private readonly TreeTypeFactory _factory = new();
public void PlantTree(int x, int y, string name, string colour, string texture)
{
TreeType type = _factory.GetTreeType(name, colour, texture);
_trees.Add(new Tree(x, y, type));
}
public void Render()
{
foreach (var tree in _trees)
tree.Render();
}
public int TreeCount => _trees.Count;
public int TreeTypeCount => _factory.TotalTypes;
}
现在让我们看看实际运行效果:
Forest forest = new Forest();
// 种植6棵树——但只有2种不同的树类型
forest.PlantTree(1, 5, "Oak", "Dark Green", "Rough bark");
forest.PlantTree(3, 12, "Oak", "Dark Green", "Rough bark");
forest.PlantTree(7, 2, "Oak", "Dark Green", "Rough bark");
forest.PlantTree(10, 8, "Pine", "Light Green", "Smooth bark");
forest.PlantTree(15, 3, "Pine", "Light Green", "Smooth bark");
forest.PlantTree(20, 14, "Pine", "Light Green", "Smooth bark");
forest.Render();
Console.WriteLine($"种植的树木数量: {forest.TreeCount}");
Console.WriteLine($"内存中存在的不同树类型数量: {forest TreeTypeCount}");
运行结果:
Factory: 正在为“Oak”创建新的TreeType对象。
Factory: 正在为“Pine”创建新的TreeType对象。
正在渲染位于(1, 5)处的橡树(颜色:Dark Green,树皮特征:Rough bark)
正在渲染位于(3, 12)处的橡树(颜色:Dark Green,树皮特征:Rough bark)
正在渲染位于(7, 2)处的橡树(颜色:Dark Green,树皮特征:Rough bark)
正在渲染位于(10, 8)处的松树(颜色:Light Green,树皮特征:Smooth bark)
正在渲染位于(15, 3)处的松树(颜色:Light Green,树皮特征:Smooth bark)
正在渲染位于(20, 14)处的松树(颜色:Light Green,树皮特征:Smooth bark)
种植的树木数量: 6
内存中存在的不同树类型数量: 2
虽然种植了6棵树,但实际上只创建了2个TreeType对象。如果将这个场景扩展到1万棵树,工厂仍然只会创建2个这样的对象。每棵树的位置信息是唯一的,而外观特征则是共享的——这就是“轻量级对象”机制的作用。
何时使用它
当你的应用程序需要创建大量相似的对象,而这些对象否则会占用过多内存时,就应该使用“轻量级对象”机制。
当一个对象的大部分状态可以在多个实例之间共享,只有其中一小部分状态在每个实例中都是唯一的时候,使用“轻量级对象”也非常有用。
另外,当那些唯一的状态信息可以通过外部方式传递进来,而不是存储在每个对象内部时,使用“轻量级对象”也是一个很好的选择。
7. 代理设计模式
现实世界中的例子
想象一下,在办公楼入口处有保安值守。你不能直接走进大楼,必须先经过保安的检查。保安会将你的名字与授权名单进行核对,记录你的来访信息,只有确认无误后才会让你进入。如果你不在名单上,就会被拒之门外。而大楼本身并不参与这些流程,它只是负责让人们进入而已。所有的检查、记录以及决策工作都是由保安(即代理)来完成的,大楼根本不会插手这些事务。
这就是代理模式。它位于调用者与真实对象之间,控制着哪些请求能够被允许通过,并且可以在不让真实对象知晓的情况下添加诸如访问权限检查或日志记录等功能。
它解决的问题:
如果有人可以直接进入大楼会怎么样?那就不存在访问控制了。代理会拦截所有的请求,然后决定是否允许这些请求通过。
如果你需要记录所有人员的进出情况,但又不想改变大楼本身的结构,代理就可以承担这个任务。这样,大楼就能保持简单,继续专注于它自身的功能。
如果创建真实对象的成本很高,而你希望延迟这一过程,代理也可以起到这样的作用:它会推迟创建真实对象的时机,直到真正有人需要使用它时才进行创建。
简单来说,就是在一个对象前面放置另一个对象,以此来控制对它的访问。调用者以为自己是在直接与真实对象交互,但实际上是由代理在处理这些请求。
维基百科对代理模式的描述如下:
"在最一般的形式中,代理是一个充当其他事物接口的类。代理可以用于连接任何资源:网络连接、内存中的大型对象、文件,或是那些创建成本高昂或无法复制的资源。" 来源
编程示例:
客户端与IBuilding进行交互。SecurityGuard就是代理:它实现了相同的接口,负责控制访问权限,只有经过授权的访客才能进入OfficeBuilding。
// 主体接口——大楼和代理都实现了这个接口
public interface IBuilding
{
void Enter(string visitorName);
}
// 真实主体——也就是实际的大楼,它只负责允许人员进入
public class OfficeBuilding : IBuilding
{
public void Enter(string visitorName)
{
Console.WriteLine($"大楼:{visitorName}已进入。");
}
}
// 代理——保安负责控制谁可以进入
public class SecurityGuard : IBuilding
{
private readonly OfficeBuilding _building = new();
private readonly List _authorisedVisitors = new() { "Alice", "Bob", "Carol" };
public void Enter(string visitorName)
{
Console.WriteLine($"保安:{visitorName}请求进入。");
if (_authorisedVisitors.Contains(visitorName))
{
Console.WriteLine("保安:身份已验证,允许进入。」;
_building.Enter访客Name);
}
else
{
Console.WriteLine($"保安:{visitorName}不在授权名单上,无法进入。");
}
}
}
现在让我们看看它的实际运行效果:
IBuilding entrance = new SecurityGuard();
entrance.Enter("Alice");
Console.WriteLine();
entrance.Enter("David");
Console.WriteLine();
entrance.Enter("Bob");
输出结果:
守卫:Alice正在请求进入。
守卫:身份已验证,允许进入。
建筑内:Alice已经进入。
守卫:David正在请求进入。
守卫:David不在允许进入的名单上,拒绝进入。
守卫:Bob正在请求进入。
守卫:身份已验证,允许进入。
建筑内:Bob已经进入。
客户端调用了Enter()方法,但它实际上是在与“安全守卫”交互。是守卫来决定是否允许某人进入;建筑本身只知道那些被允许通过的人而已。这就是代理模式的作用。
何时使用它
当你需要实现访问控制功能时,比如只允许某些调用者才能访问真实对象,就应该使用代理模式。
当你想要添加一些额外的功能,比如日志记录、缓存处理或数据验证,而又不想修改真实对象的结构时,代理模式也非常适用。
另外,当创建真实对象的成本很高,而你希望推迟其创建过程,直到真正需要使用时才进行操作时,代理模式也能发挥重要作用。
行为设计模式
简单来说,行为设计模式关注的是对象之间如何进行通信以及如何分担职责。这些模式着重于对象之间的责任分配方式,以及它们是如何协作来完成任务的。
维基百科对行为设计模式的定义是:
"在软件工程中,行为设计模式是一种用于描述对象间常见通信模式的设计模式。通过这些模式,可以提升对象间通信的灵活性。"(来源)
行为设计模式关注的是对象之间的交互方式及职责分配机制,而不仅仅是对象的结构本身。它们着重于对象之间的通信流程:谁与谁进行交互,以及每一方对另一方的了解程度。
目前共有11种行为设计模式:
责任链模式:将请求依次传递给一系列处理者,由每个处理者决定是否继续处理或转发请求。
命令模式:将请求封装成对象的形式,从而可以方便地对客户端进行参数化处理、排队执行操作,甚至支持撤销操作。
解释器模式:针对某种特定语言,定义其语法结构及相应的解释器,用于解析该语言编写的程序。
迭代器模式:提供了一种顺序访问集合中元素的方法,同时隐藏了集合内部的实现细节。
中介者模式:定义了一个中介对象,用于协调一组对象之间的交互,使这些对象无需直接相互引用即可完成协作。
备忘录模式:允许捕获一个对象的内部状态,并在以后需要时恢复该状态,同时不会破坏对象的封装性。
观察者模式:建立了一种一对多的依赖关系,当某个对象的状态发生变化时,所有依赖于它的对象都会自动收到通知。
状态模式:使一个对象能够根据其内部状态的变化来改变自己的行为,就像改变了对象的类一样。
策略模式:定义了一组可互换的算法,并允许客户端在运行时选择使用其中哪一种算法。
模板方法模式:在一个方法中定义了算法的基本框架,留出一些步骤让子类来具体实现。
访问者模式:允许在不修改被访问对象类的情况下,为这些对象添加新的操作功能。
1. 责任链设计模式
现实世界中的例子
想象一下公司中的费用审批流程。员工向他们的主管提交申请,如果金额较小,主管就会批准,事情就此结束;但如果金额太大,主管无法单独决定,那么申请就会上报给副总裁;如果金额仍然超标,最终会交给首席执行官处理。
在这个流程中,每个环节的参与者只需要知道两件事:自己被授权审批哪些类型的申请,以及当自己无法处理时应该将申请转交给谁。员工根本不需要知道最终是谁批准了这项申请。
这就是责任链设计模式的核心理念——申请会沿着处理者链条逐级传递,直到有某个环节负责处理它,而每个处理者只需要关注自己在这个链条中的职责而已。
它解决的问题:
如果发送者必须确切知道应该由谁来处理这项申请,那么就会导致发送者与特定的处理者绑定在一起,而一旦审批流程发生变化,这种绑定关系就会失效。责任链模式允许发送者在不知道最终由谁处理的情况下提交申请。
如果某个处理者只能批准或拒绝申请,而没有其他处理选项,那么那些超出其权限范围的申请就会直接被拒收。但责任链模式允许处理者将无法处理的申请转交给下一个环节。
如果需要调整审批流程的结构,只需要重新安排各个处理者的顺序即可,发送者和其他处理者都不需要做出任何更改。
简单来说,就是将申请沿着处理者链条传递,每个处理者会根据自己的职责决定是否继续处理这项申请,或者将其转交给下一个处理者。
维基百科对这一设计模式的描述如下:
“在面向对象设计中,责任链模式是一种行为设计模式。它由命令对象和一系列处理对象组成,每个处理对象都包含自身的逻辑,用于确定自己能够处理哪些类型的命令对象;其他命令对象则会依次传递给下一个处理对象。”
(来源)
编程示例:
每个Approver对象都清楚自己的审批权限范围,并且知道链条中下一个负责审批的节点是谁。ExpenseRequest对象会沿着这个链条依次传递,直到有某个节点能够批准它,或者没有人能够批准它。
// 这个申请会沿着责任链逐级传递
public class ExpenseRequest
{
public string Description { get; }
public decimal Amount { get; }
public ExpenseRequest(string description, decimal amount)
{
Description = description;
Amount = amount;
}
}
// 这个处理类链中的每一个节点都实现了这个接口
public abstract class Approver
{
private Approver? _next;
public void SetNext(Approver next) => _next = next;
public void Approve(ExpenseRequest request)
{
if (CanApprove(request))
{
Console.WriteLine($"{GetType().Name}: 已批准 '{request.Description}' (${request.Amount}).");
}
else if (_next is not null)
{
Console.WriteLine($"{GetType().Name}: 无法批准 '{request.Description}' (${request_amount}).将请求转发给下一级审批者.");
_next.Approve(request);
}
else
{
Console.WriteLine($"{GetType().Name}: 没有其他人可以审批此请求。请求被拒绝.");
}
}
protected abstract bool CanApprove(ExpenseRequest request);
}
// 具体的处理程序,每个处理程序都有自己的审批限额
public class Director : Approver
{
protected override bool CanApprove(ExpenseRequest request) => request.Amount <= 1000;
}
public class VicePresident : Approver
{
protected override bool CanApprove(ExpenseRequest request) => request.Amount <= 20000;
}
public class Chief : Approver
{
protected override bool CanApprove(ExpenseRequest request) => request.Amount <= 50000;
}
现在让我们来看实际运行效果:
Approver directorApprover = new Director();
Approver vpApprover = new VicePresident();
Approver ceoApprover = new Chief();
directorApprover.SetNext(vpApprover);
vpApprover.SetNext(ceoApprover);
directorApprover.Approve(new ExpenseRequest("笔记本电脑", 800));
Console.WriteLine();
directorApprover.Approve(new ExpenseRequest("团队外出活动", 12000));
Console.WriteLine();
directorApprover.Approve(new ExpenseRequest("新办公室租赁", 90000));
输出结果:
Director: 批准了“笔记本电脑”申请(800美元)。
Director: 无法批准“团队外出活动”申请(12000美元),将请求转发给下一级处理者。
VicePresident: 批准了“团队外出活动”申请(12000美元)。
Director: 无法批准“新办公室租赁”申请(90000美元),将请求转发给下一级处理者。
VicePresident: 无法批准“新办公室租赁”申请(90000美元),将请求转发给下一级处理者。
Chief: 没有其他人可以批准“新办公室租赁”申请(90000美元),请求被拒绝。
员工只需要与主管联系即可。最终是由主管、副总裁还是首席执行官来批准该申请,这一决定是由处理流程本身决定的,而非员工所能控制的。
何时使用它
当有多个对象可能处理某个请求,且事先无法确定具体由哪个对象进行处理时,就应该使用责任链模式。
当你想要发起一个请求,但并不想明确指定接收者时,这种模式也是一个不错的选择。
此外,当处理程序的集合及其执行顺序需要能够进行配置而非硬编码时,责任链模式也会非常有用。
2. 命令设计模式
现实世界中的例子
以通用遥控器为例。每个按钮都被设定为执行特定的功能:打开灯光、关闭灯光等等。当你按下某个按钮时,遥控器并不了解灯光的内部工作原理,它只是执行该按钮被设定的功能而已。由于每个按钮的功能都是独立的,因此遥控器也可以反向操作该按钮,从而撤销刚才执行的动作。
这就是命令设计模式。一个“打开灯光”的请求会被封装成一个独立的对象。触发这个请求的对象并不需要了解该请求具体是如何被执行的。
它解决的问题:
如果按钮必须了解灯光的具体工作原理会怎样?那么每当灯光的内部结构发生变化时,每个按钮都需要重新编写代码。而将操作封装成命令对象,就可以让遥控器完全忽略这些细节。
如果你想要撤销上一个操作,没有命令对象的话,就没有任何办法可以逆转这个操作了。但使用命令对象,就可以实现这一功能。
如果你想要将多个操作排成队列、记录下来,或者稍后再执行它们,普通的方法调用会立即完成并且不会留下任何痕迹。而命令对象则可以被存储、排队,并且可以重新执行。
简单来说,就是将一个请求封装成一个对象,这样触发该请求的代码就无需了解具体执行方式了,而且这个操作还可以被放入队列中等待执行、被记录下来,或者被撤销。
维基百科对这一概念的描述如下:
“命令模式是一种行为设计模式,通过这种模式,一个对象可以封装执行某个操作或在未来触发某个事件所需的所有信息。”(来源)
编程示例:
RemoteControl就是调用者,它只知道ICommand这个接口。LightOnCommand和LightOffCommand则是具体的命令对象,它们各自封装了对应的Light接收对象以及要对其执行的操作。
// 命令接口,所有命令类都实现这个接口
public interface ICommand
{
void Execute();
void Undo();
}
// 接收对象,即实际执行操作的对象
public class Light
{
private readonly string _room;
public Light(string room) => _room = room;
public void On() => Console.WriteLine($"{_room}的灯被打开了.");
public void Off() => Console.WriteLine($"{_room}的灯被关掉了.");
}
// 具体命令类,每个命令类都封装了接收对象和操作方法
public class LightOnCommand : ICommand
{
private readonly Light _light;
public LightOnCommand(Light light) => _light = light;
public void Execute() => _light.On();
public void Undo() => _light.Of();
}
public class LightOffCommand : ICommand
{
private readonly Light _light;
public LightOffCommand(Light light) => _light = light;
public void Execute() => _light.Of();
public void Undo() => _light.On();
}
// 调用者,它持有命令对象并执行它,但无需了解具体操作细节
public class RemoteControl
{
private ICommand? _command;
public void SetCommand(ICommand command) => _command = command;
public void PressButton() => _command?.Execute();
public void PressUndo() => _command?.Undo();
}
现在让我们来看这个示例的实际运行效果:
var livingRoomLight = new Light("Living Room");
var remote = new RemoteControl();
remote.SetCommand(new LightOnCommand(livingRoomLight));
remote.PressButton();
remote.SetCommand(new LightOffCommand(livingRoomLight));
remote.PressButton();
Console.WriteLine();
Console.WriteLine("撤销上一次操作...");
remote.PressUndo();
输出结果:
Living Room的灯被打开了。
Living Room的灯被关掉了。
撤销上一次操作...
Living Room的灯被打开了。
远程控制设备从未直接调用过_light.On()或_light.Of()方法,它只是调用了所持有的命令对象上的Execute()和Undo()方法。这就是命令模式的核心原理:请求本身被封装成了一个对象。
何时使用它
当您希望用某个动作来参数化对象,而不是将其硬编码时,可以使用命令模式。
在需要为请求添加队列处理、日志记录功能或支持撤销操作时,也可以采用这种模式。
另外,当您想将调用某个动作的对象与负责执行该动作的对象分离开来时,也可以考虑使用命令模式。
3. 解释器设计模式
实际应用示例
以一个基本的计算器为例。当它遇到像 (5 + 3) - 2 这样的表达式时,并不会为每种可能的表达式都编写单独的方法来处理它们。相反,这个表达式会被分解成若干较小的部分:数字和运算符号,每个部分都知道如何自己进行计算,并与其他部分结合在一起。(5 + 3) - 2 实际上表示的是先计算 5 加 3 的结果,然后再用这个结果减去 2。每个部分只需要知道自己该如何被解释即可。
这就是解释器模式。语法结构被表示成一棵由小型对象组成的树,而每一个对象都知道如何解读自己所在的那部分语法。
它解决的问题:
如果尝试在同一个方法中计算整个表达式会怎么样?一旦语法结构变得复杂,这种方法就会变得难以维护。将语法结构分解成多个小类,每个规则对应一个类,这样就能让每个部分都保持简单。
如果语法结构需要扩展怎么办?比如新增一种运算方式,只需要创建一个新的类即可,现有的代码无需进行任何修改。
如果同一个表达式需要在不同的情况下被多次计算怎么办?因为每个部分都只是一个对象,所以同样的语法结构可以被反复使用,而无需重新构建。
简单来说,就是将语法结构表示成一棵由小型对象组成的树,其中每一个对象都负责解读自己所在的那部分语法。
维基百科对这种模式的描述如下:
“在计算机编程中,解释器模式是一种用于指定如何解析某种语言中语句的设计模式。其基本思想是为专门设计的计算机语言中的每个符号(无论是终端符号还是非终端符号)都创建一个对应的类。”来源
编程示例:
Number 是一种终端表达式,表示一个具体的数值。Add 和 Subtract 则是非终端表达式,它们各自由两个其他表达式组合而成。无论是终端节点还是非终端节点,每一个节点都懂得如何自己被“解释”。
// 这是一个抽象表达式类,语法结构中的每个节点都继承自这个类
public abstract class Expression
{
public abstract int Interpret();
}
// Number 是一种终端表达式,表示一个不需要进一步解析的数值
public class Number : Expression
{
private readonly int _value;
public Number(int value) => _value = value;
public override int Interpret() => _value;
}
// 非终结表达式,它们由其他表达式组合而成
public class Add : Expression
{
private readonly Expression _left;
private readonly Expression _right;
public Add(Expression left, Expression right)
{
_left = left;
_right = right;
}
public override int Interpret() => _left.Interpret() + _right.Interpret();
}
public class Subtract : Expression
{
private readonly Expression _left;
private readonly Expression _right;
public Subtract(Expression left, Expression right)
{
_left = left;
_right = right;
}
public override int Interpret() => _left.Interpret() - _right.Interpret();
}
现在让我们来看实际应用示例:
// (5加3)减2
Expression expression = new Subtract(
new Add(new Number(5), new Number(3)),
new Number(2)
);
Console.WriteLine($"结果: {expression.Interpret()});
// (10减4)加(2加2)
Expression another = new Add(
new Subtract(new Number(10), new Number(4)),
new Add(new Number(2), new Number(2))
);
Console.WriteLine($"结果: {another.Interpret()}");
输出结果:
结果: 6
结果: 10
实际上,没有任何程序会一次性计算出整个表达式的值。Subtract类会让自身的_left和_right成员分别进行计算,而这两个成员也会让它们内部的子表达式依次进行计算,最终这些计算都会落实到最基础的数字上。这就是“解释器模式”:语法结构会逐个部分地被自我解读。
何时使用它
当你需要解析一种简单的语言或语法结构,并且将其表示为表达式树的形式时,这种模式就能帮助你有效地管理这些数据。
当这种语法结构相对稳定时,这种模式也会表现得非常好。例如,添加新的规则只需要创建新的类,而无需修改现有的代码。
另外,当你希望使用许多专门针对特定功能的小类,而不是用一个庞大的方法来同时处理所有的解析和计算工作时,这种模式也非常有用。
4. 迭代器设计模式
实际应用示例
想象一下书架。你想要一次只拿出一本书来阅读,从左到右依次翻阅,而无需关心这些书是存放在数组中、堆里还是其他数据结构中。你只需要有一种方法能够询问“下一本是什么?”以及知道何时已经读到了书的末尾即可。书架内部是如何存储这些书的,并不重要。
这就是迭代器模式。它为你提供了一种统一的方式来遍历集合中的元素,一次只处理一个元素,同时隐藏了集合内部的实现细节。
它解决的问题:
如果客户端必须了解集合的内部存储方式才能对其进行遍历,那么任何对这种内部结构的修改都会导致所有使用该集合的代码出现错误。迭代器通过简单的“获取下一个元素”的接口来隐藏这些细节。
如果你需要同时进行多次遍历操作,那么使用单一的共享位置是不可行的。每个迭代器都会维护自己的遍历位置,因此可以独立地进行多轮遍历。
如果你想使用编程语言自带的
foreach循环来遍历集合,只要实现语言所要求的迭代器接口,你的自定义集合就能直接获得这种支持。
简单来说,这种设计方法可以让人们依次访问集合中的每个元素,而无需了解该集合的实际内部结构。
维基百科对此有如下描述:
"在面向对象编程中,迭代器模式是一种设计模式,它通过迭代器来遍历集合并访问其中的元素。" (来源)
编程示例:
Bookshelf这个类就是一个这样的例子:它公开了一个IEnumerator接口,但却没有透露自己实际上是将书籍存储在List中。而BookshelfIterator则负责依次访问这些书籍。
// 这个类公开了迭代器接口,但隐藏了书籍的存储方式
public class Bookshelf : IEnumerable
{
private readonly List _books = new();
public void Add(string title) => _books.Add(title);
public IEnumerator GetEnumerator() => new BookshelfIterator(_books);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// 这个迭代器负责依次访问集合中的元素
public class BookshelfIterator : IEnumerator
{
private readonly List _books;
private int _position = -1;
public BookshelfIterator(List books) => _books = books;
public string Current => _books[_position];
object IEnumerator.Current => Current;
public bool MoveNext()
{
_position++;
return _position < _books.Count;
}
public void Reset() => _position = -1;
public void Dispose() { }
}
现在让我们来看这个示例的实际运行效果:
var bookshelf = new Bookshelf();
bookshelf.Add("Clean Code");
bookshelf.Add("The Pragmatic Programmer");
bookshelf.Add("Design Patterns");
foreach (var book in bookshelf)
{
Console.WriteLine($"书架上有:{book}");
}
输出结果:
书架上有:Clean Code
书架上有:The Pragmatic Programmer
书架上有:Design Patterns
foreach循环并没有直接访问Bookshelf内部的List,而是通过BookshelfIterator依次获取并输出书籍名称。这就是迭代器模式的作用:遍历逻辑被独立于集合本身来实现。
何时使用它
当您需要遍历一个集合,同时又不想暴露其内部结构时,就可以使用迭代器模式。
此外,当您需要支持对同一集合进行多次并行遍历时,迭代器模式也是一个很好的选择。
另外,如果您希望自己的自定义集合能够与语言内置的遍历语法(比如foreach)配合使用,那么迭代器模式也是必不可少的。
5. 中介者设计模式
现实世界中的例子
想象一下空中交通控制塔。飞机并不会直接通过无线电互相沟通来决定谁先降落——那样肯定会陷入混乱:数十名飞行员同时尝试进行协调。
实际上,每架飞机都只与控制塔进行通信。控制塔了解跑道的使用状况,并告诉每架飞机该做什么。飞机们根本不需要知道还有多少其他飞机在空中,也不知道它们正在做什么。
这就是中介者模式。各个对象并不直接相互交流,而是都通过一个中央对象来进行协调。
它解决的问题:
如果每架飞机都必须与其他所有飞机直接通信会怎样?随着飞机数量的增加,连接数量将会呈指数级增长,而且每一架飞机都需要了解其他所有飞机的状态。而使用中介者模式后,每架飞机只需要关注控制塔的信息即可。
如果协调逻辑分散在各个相关对象中会怎样?想要改变降落的优先顺序,就需要修改每一个相关对象的代码。但使用中介者模式后,这些逻辑都被集中放在一个地方。
如果你想向系统中添加一架新飞机会怎样?这架新飞机只需要知道如何与控制塔进行通信,而无需了解已经在空中飞行的其他所有飞机。
简单来说,就是不让对象们直接相互交流,而是让所有的通信都通过一个能够协调它们行为的中央对象来完成。
维基百科对这一模式的描述是:
“在软件工程中,中介者模式定义了一个用于封装一组对象之间交互方式的对象。由于这种模式能够改变程序的运行行为,因此它被认为是一种行为模式。”(来源)
编程示例:
ControlTower就是这个中介者。它是Aircraft唯一会与之通信的对象。它根据自身掌握的信息来决定飞机是否可以降落,而且没有任何飞机会直接与其他飞机进行交流。
// 中介者接口
public interface IControlTower
{
void RequestLanding(Aircraft requester);
}
// 具体的中介者类,负责协调所有飞机的行为
public class ControlTower : IControlTower
{
private readonly List _aircraft = new();
private bool _runwayFree = true;
public void Register(Aircraft aircraft) => _aircraft.Add(aircraft);
public void RequestLanding(Aircraft requester)
{
if (_runwayFree)
{
_runwayFree = false;
Console.WriteLine($"控制塔:跑道可用。{requester.Name},您可以降落了。”);
}
else
{
Console.WriteLine($"控制塔:跑道被占用。{requester.Name},请保持当前飞行状态。”);
}
}
}
// 这种同事只与调解者进行交流,从不直接与其他飞机对话
public class Aircraft
{
public string Name { get; }
private readonly IControlTower _tower;
public Aircraft(string name, IControlTower tower)
{
Name = name;
_tower = tower;
}
public void RequestLanding()
{
Console.WriteLine($"{Name}: 正在请求降落许可.");
_tower.RequestLanding(this);
}
}
现在让我们来看看实际运行效果:
var tower = new ControlTower();
var flight101 = new Aircraft("Flight 101", tower);
var flight202 = new Aircraft("Flight 202", tower);
tower.Register(flight101);
tower.Register(flight202);
flight101.RequestLanding();
flight202.RequestLanding();
输出结果:
Flight 101: 正在请求降落许可。
Tower: 跑道已空闲。Flight 101,可以降落了。
Flight 202: 正在请求降落许可。
Tower: 跑道被占用。Flight 202,请保持当前飞行状态。
Flight 101和Flight 202从未直接交流过,它们甚至都不知道对方的存在。这两架飞机都只与调度中心进行沟通,而调度中心则决定后续该发生什么。这就是调解者模式的作用。
何时使用它
当一组对象以复杂且相互交织的方式进行通信时,如果你希望将这些通信过程集中起来处理,就可以使用调解者模式。
当你希望独立地重用这些对象,而不让它们因为彼此之间的直接引用而被绑定在一起时,这种模式也会非常有用。
另外,当对象之间的交互方式经常发生变化,而你更愿意在某个统一的位置进行修改,而不是在所有涉及的对象中逐一进行调整时,调解者模式也是个不错的选择。
6. 备忘录设计模式
现实世界中的例子
以文本编辑器中的撤销功能为例。编辑器会定期自动保存文档当前的状态。它并不会要求文档暴露其内部结构来实现这一功能,而是简单地复制当前的内容。
当你点击“撤销”按钮时,编辑器会将这个保存的快照恢复回来,文档就会恢复到之前的状态。撤销历史记录会保存这些快照,但永远不会查看或修改它们的内容,只是将它们存储起来并在需要时使用而已。
这就是备忘录模式。它允许你捕获并恢复对象的状态,而无需暴露该状态的内部实现细节。
它解决的问题:
如果撤销操作需要暴露文档的每一个私有字段会怎么样?那样就会破坏封装性,而且对文档内部结构的任何修改都会影响到所有使用撤销功能的部分。而备忘录模式可以将这些内部结构隐藏起来,只有文档本身才知道如何读取这些信息。
如果撤销历史记录需要查看或修改之前的快照会怎么样?这是不允许的。备忘录模式只负责存储和返回快照,从不读取或更改其中的内容。
如果你需要多个恢复点而不仅仅是一个,怎么办?因为每个备忘录都只是一个对象,所以你可以根据需要将它们堆叠起来、列出或者丢弃,从而获得任意数量的恢复点。
简单来说,这种机制就是将对象的状态保存为快照,之后可以恢复到该状态,而无需暴露对象内部实现的具体方式。
维基百科对这一概念的描述如下:
“备忘录模式是一种软件设计模式,它能够使对象恢复到之前的状态(通过回滚来实现撤销操作)。”
(来源:https://en.wikipedia.org/wiki/Memento_pattern)
编程示例:
TextEditor 是这个模式的发起者:它会创建自己的 EditorMemento 快照,并能够从中恢复到之前的状态。History 则负责存储这些快照,但它从不查看快照中的具体内容。
// 备忘录类,用于保存编辑器的不可变状态
public class EditorMemento
{
public string Content { get; }
public EditorMemento(string content) => Content = content;
}
class TextEditor
{
public string Content { get; private set; } = string.Empty;
public void Write(string text) => Content += text;
public EditorMemento Save() => new(EditorMemento(Content));
public void Restore(EditorMemento memento) => Content = memento.Content;
}
class History
{
private readonly Stack _snapshots = new();
public void Save(EditorMemento memento) => _snapshots.Push(memento);
public EditorMemento Undo() => _snapshots.Pop();
}
var editor = new TextEditor(); var history = new History(); editor.Write("Hello"); history.Save(editorSave()); editor.Write(", world"); history.Save.editorSave()); editor.Write("!!!"); Console.WriteLine($"当前状态: {editor.Content}); editor.Restore(history.Undo()); Console.WriteLine($"撤销操作后的状态: {editor.Content}); editor.Restore(history.Undo()); Console.WriteLine($"再次撤销操作后的状态: {editor.Content}");运行结果如下:
当前状态: Hello, world!!! 撤销操作后的状态: Hello, world 再次撤销操作后的状态: Hello
History类从不会读取或修改快照中的内容,它只是负责将这些快照添加到栈中或从栈中取出。只有TextEditor才知道如何处理这些快照中的数据。这就是备忘录模式的运作原理。何时使用这种模式
当你需要实现撤销/重做功能,并且希望在不暴露对象内部结构的情况下保存对象的状态时,就可以使用备忘录模式。
另外,当直接进行状态备份会因为暴露对象的私有字段而破坏封装性时,这种模式也是一个不错的选择。
最后,当你希望负责存储历史记录的对象保持“无知”的状态——也就是说,只是简单地保存快照而不关心其中的具体内容时,备忘录模式也会非常有用。
7. 观察者设计模式
实际应用示例
想象一下,你订阅了一个YouTube频道。你不需要一直刷新页面去查看是否有新视频上传。只需一次订阅,一旦该频道有新的内容发布,你就会自动收到通知。
这个频道并不知道也不关心每个订阅者会如何处理这些通知,它只知道自己有一份订阅者名单,当有变化发生时,就会通知所有订阅者。
这就是观察者模式。一个对象维护着一份依赖对象的列表,每当它的状态发生变化时,就会自动通知这些依赖对象。
它解决的问题:
如果每个订阅者都需要不断检查频道是否有更新,那不仅会浪费精力,还会增加延迟。而通过直接通知订阅者,他们就能在第一时间得知变化。
如果频道需要知道每个订阅者想如何处理新发布的视频,那么它其实并不需要这样做。频道只需调用
Notify()方法,每个订阅者再自行决定该如何响应这个通知。如果在运行时需要添加或删除订阅者,频道本身并不需要做出任何更改。它只需要维护一份订阅者列表,而添加或删除订阅者只会影响这份列表而已。
简单来说,一个对象会维护一份依赖对象的列表,每当它的状态发生变化时,就会自动通知这些依赖对象。
维基百科对观察者模式的描述如下:
"观察者模式是一种软件设计模式,在这种模式下,一个被称为‘主题’的对象会维护一份称为‘观察者’的依赖对象列表,并在自身状态发生变化时自动通知它们,通常是通过调用它们的某个方法来实现的。" (来源)
编程示例:
YouTubeChannel就是主题对象:它维护着一份ISubscriber对象的列表,每当有新视频上传时,就会通知所有这些订阅者。Subscriber则是具体的观察者对象,它们会自行决定如何处理这些通知。
// 观察者接口,所有观察者都需实现这个接口
public interface ISubscriber
{
void Notify(string channelName, string videoTitle);
}
// 具体的观察者类
public class Subscriber : ISubscriber
{
private readonly string _name;
public Subscriber(string name) => _name = name;
public void Notify(string channelName, string videoTitle)
{
Console.WriteLine($"{_name}: {channelName} 已上传 '{videoTitle}'!");
}
}
// 主题对象,负责管理订阅者并通知它们状态变化
public class YouTubeChannel
{
private readonly string _name;
private readonly List _subscribers = new();
public YouTubeChannel(string name) => _name = name;
public void Subscribe(ISubscriber subscriber) => _subscribers.Add(subscriber);
public void Unsubscribe(ISubscriber subscriber) => _subscribers.Remove(subscriber);
public void UploadVideo(string title)
{
Console.WriteLine($"{_name}: 已上传 '{title}'.");
foreach (var subscriber in _subscribers)
{
subscriber.Notify(_name, title);
}
}
}
现在让我们来看看它的实际运行效果:
var channel = new YouTubeChannel("Code With Isaiah");
var alice = new Subscriber("Alice");
var bob = new Subscriber("Bob");
channel.subscribe(alice);
channel.subscribe(bob);
channel.uploadVideo("Design Patterns Explained");
channel.unsubscribe(bob);
channel.uploadVideo("Understanding the Observer Pattern");
运行结果:
Code With Isaiah: 已上传视频 'Design Patterns Explained'。
Alice: Code With Isaiah 刚刚上传了 'Design Patterns Explained'!
Bob: Code With Isaiah 刚刚上传了 'Design Patterns Explained'!
Code With Isaiah: 已上传视频 'Understanding the Observer Pattern'。
Alice: Code With Isaiah 刚刚上传了 'Understanding the Observer Pattern'!
一旦 Bob 取消订阅,他就再也不会收到关于该频道新视频发布的通知了。该频道并没有特别针对他进行操作,只是不再将他列入需要通知的对象列表中而已。这就是观察者模式的作用。
何时使用观察者模式
当一个对象的状态发生变化时,需要自动更新其他多个未知数量的对象时,就应该使用观察者模式。
当你希望各个对象之间保持松耦合的关系时,观察者模式也非常有用:主题对象只需要知道观察者接口的存在,而无需了解具体的实现细节。
8. 状态设计模式
现实世界中的例子
以在线订单的生命周期为例:订单处于待处理状态,然后被发送出去,最后送达客户手中。在每个阶段,“进入下一个步骤”所代表的含义是不同的。对于待处理状态的订单来说,意味着将包裹交给快递公司;对于已发货状态的订单来说,意味着标记为已收到;而对于已送达状态的订单来说,已经没有下一步可走了。与其编写一个包含大量 if 语句的庞大方法来处理每一个阶段,不如让每个阶段只关注自己之后的操作即可。
这就是状态设计模式的作用:对象的行为会根据其当前所处的状态而发生变化,而且每个状态都知道如何过渡到下一个状态。
它解决的问题:
如果有一个方法需要通过冗长的条件判断来处理每一个阶段,那么每当新增一个阶段时,这个方法的复杂性就会增加。为每个阶段分别创建一个类,可以让该阶段的逻辑保持独立性。
如果添加一个新的阶段意味着必须修改那个庞大的方法,那么在修改过程中很容易引入错误。而使用状态设计模式,只需要为新的状态创建一个新的类即可。
如果某个对象根据其生命周期中的不同阶段需要表现出完全不同的行为,那么将相关逻辑委托给当前的状态对象处理,就可以避免上下文代码需要了解过多的细节。
简单来说,通过让一个对象改变自己所持有的状态对象,就可以让它改变自己的行为方式。因此,当对象的状态发生变化时,它的表现形式也会随之改变。
维基百科对它的描述如下:
“状态模式是一种行为型软件设计模式,它允许对象在其内部状态发生变化时改变自身的行为。这种模式与有限状态机的概念非常相似。” (来源)
编程示例:
Order充当上下文对象:它记录自己当前所处的IOrderState状态,并将相应的操作委托给该状态对象。每一个具体的状态类,如PendingState、ShippedState和DeliveredState,都清楚下一个状态应该是什么。
// 状态接口,所有状态类都必须实现这个接口
public interface IOrderState
{
void Next(Order order);
string Name { get; }
}
// 上下文对象,将行为委托给当前所处的状态
public class Order
{
public IOrderState State { get; set; } = new PendingState();
public void Next()
{
Console.WriteLine($"当前订单状态:{State.Name}");
State.Next(this);
}
}
// 具体的状态类,每个状态类都知道下一个状态是什么
public class PendingState : IOrderState
{
public string Name => "Pending";
public void Next(Order order) => order.State = new ShippedState();
}
public class ShippedState : IOrderState
{
public string Name => "Shipped";
public void Next(Order order) => order.State = new DeliveredState();
}
public class DeliveredState : IOrderState
{
public string Name => "Delivered";
public void Next(Order order)
{
Console.WriteLine("订单已经送达,无需再做任何操作。");
}
}
现在让我们来看一下这个模式的实际应用:
var order = new Order();
order.Next();
order.Next();
order.Next();
order.Next();
输出结果:
当前订单状态:Pending
当前订单状态:Shipped
当前订单状态:Delivered
订单已经送达,无需再做任何操作。
Order对象从未自己判断“如果是待处理状态就执行这个操作,如果是已发货状态就执行那个操作”。它只是询问当前所处的状态应该做什么,而具体的操作由相应的状态类来决定。这就是状态模式的作用。
何时使用它
当一个对象的行为取决于它的当前状态,并且这种行为需要在运行时根据状态的变化而实时调整时,就应该使用状态模式。
当你遇到那些需要根据对象的当前状态或类型来执行不同操作的复杂代码块时,状态模式也会非常有用。
另外,当状态之间的转换需要被明确地定义并且保持独立性时,而不是分散在一个庞大的方法中时,选择状态模式也是个不错的选择。
9. 策略设计模式
现实世界中的例子
想象一下,在一家在线商店结账的场景。你可以使用信用卡支付,也可以通过PayPal付款。购物车并不关心你选择哪种支付方式,它只知道总金额,然后将这个信息传递给你所选择的支付方式,由该支付方式来处理实际的收费流程。即使你更换了支付方式,购物车本身的代码也不会发生任何变化。
这就是策略模式。某种算法(在这个例子中就是“如何进行支付”)被抽取出来,形成一个可以独立使用的对象,而客户端只需选择使用其中哪一个即可。
它解决的问题:
如果购物车为每种支付方式都编写了复杂的
if/else逻辑,那么每添加一种新的支付方式,就都需要修改相关的代码。而将每种支付方式分别封装成独立的类,就可以确保购物车的代码永远不需要更改。如果在运行时想要更换支付方式,硬编码的算法是无法被替换的。但策略对象完全可以被另一个实现相同接口的对象所替代。
如果两种不同的支付方式需要共享同一个接口,但除此之外没有其他共同点,那么将每种支付方式都封装成独立的类也是完全可行的。每个类都实现相同的接口,而其内部的实现细节(比如信用卡号或电子邮件地址)则保持私密性。
简单来说,就是把某种算法抽取出来,形成一个可以独立使用的对象,这样使用该算法的类就无需关心具体是哪个版本在运行。
维基百科对策略模式的描述如下:
“策略模式是一种行为型软件设计模式,它允许在运行时选择某种算法。” (来源)
编程示例:
ShoppingCart充当上下文对象:它保存了一个IPaymentStrategy接口实例,并将实际的支付任务委托给这个对象。CreditCardPayment和PayPalPayment则是具体的实现类,分别代表不同的支付方式。
// 战略接口,所有支付方式都需实现此接口
public interface IPaymentStrategy
{
void Pay(decimal amount);
}
// 具体实现类,代表不同的支付方式
public class CreditCardPayment : IPaymentStrategy
{
private readonly string _cardNumber;
public CreditCardPayment(string cardNumber) => _cardNumber = cardNumber;
public void Pay(decimal amount)
{
Console.WriteLine($"已从信用卡${_cardNumber[^4..]}中扣除${amount}元。");
}
}
public class PayPalPayment : IPaymentStrategy
{
private readonly string _email;
public PayPalPayment(string email) => _email = email;
public void Pay(decimal amount)
{
Console.WriteLine($"已通过PayPal账户${_email}扣除${amount}元。");
}
}
// 上下文对象,保存支付策略并负责调用相应的支付方法
public class ShoppingCart
{
private readonly decimal _total;
private IPaymentStrategy? _paymentMethod;
public ShoppingCart(decimal total) => _total = total;
public void SetPaymentMethod(IPaymentStrategy method) => _paymentMethod = method;
public void Checkout()
{
if (_paymentMethod is null)
{
Console.WriteLine("未选择任何支付方式。");
return;
}
_paymentMethod.Pay(_total);
}
}
现在让我们来看实际运行效果:
var cart = new ShoppingCart(59.99m);
cart.SetPaymentMethod(new CreditCardPayment("4111 1111 1111 1111"));
cart.Checkout();
cart.SetPaymentMethod(new PayPalPayment("isaiah@example.com"));
cart.Checkout();
输出结果:
已从卡号为1111的信用卡中扣除59.99美元。
也通过PayPal账户isaiah@example.com扣除了59.99美元。
ShoppingCart类根本不知道支付过程是如何进行的。它只是根据当前所使用的支付方式,调用相应的Pay()方法而已。这就是策略模式的作用:算法的具体实现可以随时更换,但使用该模式的类本身却保持不变。
何时使用策略模式
当您有多种不同的算法实现方式,并且希望在运行时根据需要切换它们时,就可以使用策略模式。
此外,当您希望避免让某个类中充斥着大量的条件语句,从而根据某种类型或标志来决定程序的行为时,策略模式也会非常有用。
另外,如果相关类之间的区别仅在于它们所使用的具体行为方式,而这些行为方式是可以互相替换的,那么策略模式也是一个不错的选择。
10. 模板方法设计模式
实际应用示例
以制作热饮为例,无论是茶还是咖啡,其基本步骤都是相同的:烧水、冲泡、倒入杯中,然后根据个人口味添加调味料。不同之处仅在于具体操作细节——茶需要浸泡,而咖啡则需要用咖啡粉来冲泡;茶会加柠檬,而咖啡则会加糖和牛奶。总体来说,制作流程本身是不变的,只有其中某些步骤的具体内容会有所不同。
这就是模板方法模式。基类定义了算法的固定框架,而子类只需补充那些可以发生变化的步骤即可。
模板方法模式解决的问题
如果每种饮料都需要从头开始重新编写整个制作流程,那么每个类中都会重复出现“烧水”和“倒入杯中”这些步骤。而模板方法模式可以将这些通用步骤集中到一处,只需编写一次即可。
如果某个子类想要改变步骤的顺序,或者完全跳过某些步骤,那么这种做法可能会导致每种饮料的制作流程都与整体规则相违背。但由于算法的框架是在基类中以单一方法的形式存在的,因此步骤的顺序和结构仍然是固定的。
如果想要添加新的饮料类型,那么只需要编写那些有区别的部分,比如具体的冲泡方法或调味方式即可。其余的部分都已经由基类处理好了。
简单来说,您可以在基类中定义算法的固定框架,而子类只需负责补充那些可以发生变化的细节部分。
维基百科对模板方法模式的描述如下:
"在面向对象编程中,模板方法模式是Gamma等人在其著作《设计模式》中提出的一种行为设计模式。模板方法模式定义在一个超类中(通常是抽象超类),它通过一系列高层次的步骤来描述某个操作的总体流程。" 来源
编程示例:
Beverage将Prepare()定义为模板方法:即那些步骤的固定顺序。Tea和Coffee仅重写了Brew()和AddCondiments()这两个可以自由调整的步骤。
// 这个抽象类定义了算法的框架
public abstract class Beverage
{
// 模板方法,这些步骤及其顺序是不可更改的
public void Prepare()
{
BoilWater();
Brew();
PourInCup();
AddCondiments();
}
private void BoilWater() => Console.WriteLine("烧水。");
private void PourInCup() => Console.WriteLine("将液体倒入杯中。");
// 其余步骤由子类自行实现
protected abstract void Brew();
protected abstract void AddCondiments();
}
// 这个具体类实现了茶的制作流程
public class Tea : Beverage
{
protected override void Brew() => Console.WriteLine("浸泡茶包。");
protected override void AddCondiments() => Console.WriteLine("加入柠檬。");
}
// 另一个具体类实现了咖啡的制作流程
public class Coffee : Beverage
{
protected override void Brew() => Console.WriteLine("冲泡咖啡粉。");
protected override void AddCondiments() => Console.WriteLine("加入糖和牛奶。");
}
现在让我们看看实际运行效果:
Beverage tea = new Tea();
Beverage coffee = new Coffee();
tea.Prepare();
Console.WriteLine();
coffee.Prepare();
输出结果:
烧水。
浸泡茶包。
将液体倒入杯中。
加入柠檬。
烧水。
冲泡咖啡粉。
将液体倒入杯中。
加入糖和牛奶。
这两种饮品的制作过程完全相同,因为基类中的Prepare()方法已经处理了这些步骤。只有“冲泡”和“添加配料”这两个环节是每个子类需要自行实现的。这就是模板方法模式的作用。
何时使用它
当多个类具有相同的整体算法结构,但在某些具体步骤上存在差异时,可以使用模板方法模式。
当你希望强制执行固定的步骤顺序,同时又允许子类对这些步骤进行自定义修改时,这种模式非常适用。
此外,当你需要避免重复编写那些在所有子类中都不变的算法部分时,模板方法也能帮助你有效地组织代码。
11. 访问者设计模式
现实世界中的应用示例
想象一下,一个购物车里包含了各种不同的商品,比如书籍和电子产品,在结账时这些商品的征税方式是不同的。你肯定不希望将定价逻辑直接写到Book和Electronic这两个类中,尤其是当你后来还需要对这些商品执行其他操作(比如生成发货标签或保修信息摘要)时。相反,你应该让每个商品都接受一个“访问者”,然后由这个访问者来决定如何为该商品定价。
这就是访问者模式。这种设计模式将具体的操作逻辑从它所操作的对象中分离出来,让每个对象只需告诉访问者自己属于哪种类型即可。
它解决的问题:
如果将定价逻辑直接写在
Book和Electronic类中会怎么样?每当需要添加新的操作(如征税、运费计算、保修处理)时,就意味着必须反复修改这两个类。而访问者模式会将这些新操作放在独立的类中,从而避免这种问题。如果想要添加新的操作,却又不想修改现有的对象类,该怎么办?通常情况下,这需要修改所有与该操作相关的类。但使用访问者模式时,只需创建一个新的类即可,而
Book和Electronic类本身却无需任何更改。如果某个通用循环需要判断每个对象的具体类型,通常会涉及到一系列类型检查。而通过让
Accept()>方法调用Visit(this),编译器可以自动选择合适的重载版本,完全不需要编写任何if语句或进行类型检查。
简单来说,就是把具体的操作逻辑提取出来,放在一个独立的类中,而每个对象只需告诉访问者自己属于哪种类型即可。
维基百科对访问者模式的描述如下:
“访问者设计模式是一种将算法与其操作的对象结构分离出来的方法。”(来源)
编程示例:
Book和Electronic都实现了IItem接口,因此它们都可以调用visitor.Visit(this)方法。PricingVisitor类实现了IVisitor接口,并为每种具体的对象类型提供了相应的重载版本,这样正确的定价逻辑就能自动被执行。
// 元素接口,购物车中的所有对象都实现这个接口
public interface IItem
{
void Accept(IVisitor visitor);
}
// 具体对象类,它们都会接受访问者并将自己传递给访问者进行处理
public class Book : IItem
{
public string Title { get; }
public decimal Price { get; }
public Book(string title, decimal price)
{
Title = title;
Price = price;
}
public void Accept(IVisitor visitor) => visitor.Visit(this);
}
public class Electronic : IItem
{
public string Name { get; }
public decimal Price { get; }
public Electronic(string name, decimal price)
{
Name = name;
Price = price;
}
public void Accept(IVisitor visitor) => visitor.Visit(this);
}
// 访问者接口,为每种具体对象类型提供了对应的访问方法
public interface IVisitor
{
void Visit(Book book);
void Visit(Electronic electronic);
}
// 这是一个具体的访问者类,它会在不修改“Book”或“Electronic”对象的情况下添加新的操作功能
public class PricingVisitor : IVisitor
{
public decimal Total { get; private set; }
public void Visit(Book book)
{
Console.WriteLine($"书籍:{book.Title} — ${book.Price:F2}(不含税).");
Total += book.Price;
}
public void Visit(Electronic electronic)
{
var priceWithTax = electronic.Price * 1.15m;
Console.WriteLine($"电子产品:{electronic.Name} — ${priceWithTax:F2}(含15%税费).");
Total += priceWithTax;
}
}
现在让我们来看看它的实际应用效果:
var cart = new List
{
new Book("设计模式", 45.00m),
new Electronic("耳机", 120.00m)
};
var pricingVisitor = new PricingVisitor();
foreach (var item in cart)
{
item.Accept(pricingVisitor);
}
Console.WriteLine($"总金额: ${pricingVisitor.Total:F2}");
输出结果:
Book: 设计模式 — 45.00美元(不含税)。
Electronic: 耳机 — 138.00美元(含15%税费)。
总金额: 183.00美元
Book和Electronic这两个类中都没有包含任何与定价相关的逻辑代码。它们仅仅知道如何接受PricingVisitor的访问请求而已。真正决定这些对象应该如何被定价的,是PricingVisitor这个类。这就是“访问者模式”的核心所在:相关的操作并不存在于对象的内部结构中,而是存在于外部。
何时使用它
当你需要对一组互不相关的类执行某些操作,但又不想让这些逻辑代码充斥到每个类中时,就可以使用访问者模式。
此外,当你经常需要添加新的操作功能,而对象的结构本身却很少发生变化时,访问者模式也会非常有用。
另外,当你需要通过类型检查或类型转换来确定如何处理集合中的各个对象时,访问者模式也是一个不错的选择。
结论
以上内容涵盖了创建型、结构型和行为型这三大设计模式家族中的所有23种经典模式。
这些模式并不是你必须严格遵守的规则,它们其实是针对软件开发中那些反复出现的问题而提出的解决方案:如何在不硬编码对象具体类型的情况下创建对象,如何用较小的结构来构建更大的整体,以及如何让对象之间进行通信而不需要彼此紧密绑定。
有几点值得记住:
大多数情况下,你并不会经常使用这些模式。真正重要的是能够判断“什么时候应该使用某种模式”。如果强行将某个模式应用到并不适合它的场景中,通常只会使代码变得更难理解,而不会让它变得更容易使用。
通过现实世界中的类比来帮助自己理解这些模式,是为了帮助建立直觉,而不是将这些类比照字面意思来理解。一旦你弄清楚了某个模式的适用场景,那么在看到实际代码中运用这种模式时,就会很容易理解它的工作原理。
这些模式是可以相互组合使用的。例如,工厂方法创建的对象本身也可以作为装饰器使用;复合结构可以通过建造者模式来构建。在实际的系统中,各种模式往往是混合在一起、层层嵌套使用的,而不会孤立地使用某一种模式。
使用哪种编程语言并不重要。这里的例子都是用C#编写的,但同样的设计模式在Python、Java、TypeScript、Go、Rust等语言中也同样存在。只要你能理解某种模式所解决的问题,将其翻译成任何一种语言都不会有太大困难。
我们的目标并不是记住这23个模式的名称,而是要能够识别出这些模式所解决的根本问题。这样,当你在自己的代码中遇到类似的问题时,就能立即想到已经存在成熟的解决方案来应对它。
<如果这本手册对您有所帮助,其源代码托管在 github.com/Clifftech123/design-patterns-handbook 这一地址上。您可以给这个项目添加星标、克隆它,或者针对那些您认为还缺失的设计模式提交Pull Request。“每种模式都描述了在我们所处的环境中反复出现的问题,同时也阐述了解决这些问题的核心方法;这种解决方法可以被重复使用无数次,但每次应用时都不会采用完全相同的方式。”
来源: Christopher Alexander所著的《模式语言》——这部建筑学著作最初为软件设计模式的发展提供了灵感。
相关文章
构建者设计模式:构建复杂对象的一种更有效的方法
有些对象非常简单,比如字符串、数字或布尔值。你可以用一行代码创建它们,然后继续进行其他操作。 而另一些对象则完全不简单。例如,一个轮播组件就需要包含项目数量、项目生成函数、控制器、高度、视图窗口比例、自动播放设置、页面切换回调函数以及无限滚动配置等信息。再比如,一个HTTP请求需要URL地址、请求头信息、认证令牌、请求体内容、超时时间以及重试逻辑。又或者,一条通知消息需要标题、正文、图标、发送渠道、优先级、声音效果、振动功能以及操作按钮等等。 当你需要构建这类对象时,一种常见的方法是使用带有大量参数的构造函数。这种方法虽然可行,但随着对象结构变得越来越复杂,就会带来一系列问题:这些参数很难区分
阅读全文
如何使用Pydantic AI构建具备生产级功能的智能代理
使用原始的LLM SDK来构建AI代理,在开发原型阶段确实可行,但一旦你需要结构化输出、可测试的代码以及具备生产环境可靠性的系统,这些问题就会显现出来。 这些问题的出现具有很强的规律性。你的笔记本代码可以正常运行,于是你将其应用到生产环境中,并开始添加各种补丁:比如为`json.loads`添加异常处理逻辑,编写辅助函数来去除Markdown格式的标记,使用`if`语句检查字段类型,设置重试机制,以及创建一个将工具名称与对应的可调用函数关联起来的映射函数。这些代码单独来看并不复杂,但当它们汇集在一起时,就会占据你代码库的大部分内容,而真正的代理逻辑反而被这些辅助代码所掩盖。 本文将按照这些问题
阅读全文
如何在没有服务器的情况下为静态网站添加动态功能
静态网站目前正受到人们的青睐,这是有原因的。一个包含HTML、CSS和JavaScript文件的文件夹,加载速度很快,托管成本也很低,而且几乎不可能出现故障。 像 Astro 、 Eleventy 和 Hugo 这样的工具,能够利用Markdown文件和模板帮您生成这样的网站结构。而Netlify、Vercel以及Cloudflare Pages等托管服务,则可以通过内容分发网络来提供这些生成的网站内容,而且通常还是免费的。 不过,您的网站还需要具备实际的功能。读者可能想要留下评论,或者有人想通过电子邮件与您联系。也许您还想实时显示价格信息,需要用户登录后才能查看某些页面,或者在网站发布之前收
阅读全文
Flutter前端系统设计:在人工智能时代,如何像资深工程师一样思考
系统设计长期以来一直被视为后端领域的问题。 如果你问一群Flutter工程师“系统设计到底意味着什么”,他们中的大多数人会提到服务器架构:负载均衡器、数据库以及微服务。 但如果你让他们设计一个分布式缓存系统或画出一个消息队列的示意图,他们会犹豫不决。而当你要求他们为社交Feed应用开发Flutter客户端时,他们就会立刻打开新文件开始编写组件代码。 这种差距确实存在,不过正在迅速缩小。 随着Flutter应用程序变得越来越复杂——它们具备了实时功能、离线支持、多平台兼容性,同时还包含需要维护的人工智能生成代码——在编写任何一个组件之前所做出的架构决策,其重要性已经与后端架构相当了。 在那些以产
阅读全文