侧边栏壁纸
博主头像
Setyg的博客

当秋风染红落叶的时候,就是新生~

  • 累计撰写 86 篇文章
  • 累计创建 66 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

23 种设计模式学习实践(C# 实现)

Setyg
2026-09-28 / 0 评论 / 0 点赞 / 4 阅读 / 0 字

设计模式(Design Pattern)不是语法技巧,也不是「高级写法」的代名词。它是一批被反复验证过的、针对特定变化场景的代码组织方案——GoF(Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides)在 1994 年的《Design Patterns: Elements of Reusable Object-Oriented Software》里把常见的 23 种沉淀下来,并按解决什么问题分成了创建型、结构型、行为型三类。

这份文档把 23 种模式按这三类顺序过一遍,每种给出意图、适用场景和一份可以直接跑的 C# 实现,最后补上容易混淆模式的对比和 .NET 里已经内置的模式落地。代码基于现代 C#(文件范围命名空间、record、可空引用类型、目标类型 new()),如果你在维护 .NET Framework 4.x 的老项目,语法需要回退,但一套结构本身是通用的。

先纠正两个常见误区:

一是背模式没有意义。面试能默写 23 种 UML,不代表写业务代码时知道什么时候该用——模式的价值在「识别变化点」这一步,代码只是结果。二是分类不是按代码形态分的。同样是「包一层」,装饰器是为了运行时叠加职责,代理是为了控制访问,适配器是为了转换接口,它们长得像但意图完全不同,选错了比不选还糟。

一、23 种模式总览

先给一张全局表,后面按行展开。最后一列是我个人的使用频率判断,仅供参考:

分类模式一句话意图使用频率
创建型
(5 种)
单例 Singleton保证一个类只有一个实例,并提供全局访问点高(但多半该交给 DI 容器)
工厂方法 Factory Method把「创建哪个实现」交给子类决定高
抽象工厂 Abstract Factory创建一整个相互配套的产品族中
建造者 Builder把复杂对象的构造过程拆成步骤,可复用同一流程造出不同表示中高
原型 Prototype通过克隆已有对象来创建新对象,避开重复的初始化中低
结构型
(7 种)
适配器 Adapter把不兼容的接口转换成本方期望的接口高
桥接 Bridge把抽象和实现分离,让两个维度各自独立扩展中低
组合 Composite把「单个对象」和「对象容器」用同一接口统一对待中高
装饰器 Decorator运行时给对象叠加职责,不改变原类中高
外观 Facade为一组复杂子系统提供一个简化入口高
享元 Flyweight共享对象的公共部分,降低大量细粒度对象的内存开销低
代理 Proxy为目标对象提供替身,以控制访问高
行为型
(11 种)
责任链 Chain of Responsibility让多个对象依次处理请求,直到有一个处理为止高
命令 Command把请求封装成对象,从而支持排队、记录、撤销中高
解释器 Interpreter用小型的类层次表示一套文法并解释它低
迭代器 Iterator顺序访问聚合元素而不暴露其内部结构极高(已内建)
中介者 Mediator用中心对象收敛对象之间的多对多引用中
备忘录 Memento在不破坏封装的前提下保存和恢复对象状态中
观察者 Observer状态变化时通知所有依赖方,实现一对多解耦极高(event 已内建)
状态 State把状态相关的行为抽成独立类,消除庞大的 switch中高
策略 Strategy把可替换的算法族抽象成独立类,运行时切换极高
模板方法 Template Method父类定好流程骨架,把若干步骤交给子类实现高
访问者 Visitor在不改元素类的前提下,为它们增加新的操作低

三类划分的依据是「变化的哪一部分」:创建型封装对象的产生,结构型封装对象的组合,行为型封装对象之间的交互与职责分配。

二、创建型模式

创建型的核心思路是把 new 这个动作从调用方手里拿走。直接 new 的问题不在写法,而在于:调用方一旦硬编码了具体类型,就同时绑定了它的构造过程、初始化顺序和依赖关系,后面换实现、加缓存、改初始化步骤都要回调用的地方。

1. 单例模式(Singleton)

意图:保证一个类只有一个实例,并提供全局访问点。典型场景是配置、连接池、日志器、进程内的缓存。

public sealed class AppConfig
{
    private static readonly Lazy<AppConfig> _instance = new(
        () => new AppConfig(),
        LazyThreadSafetyMode.ExecutionAndPublication);

    public static AppConfig Instance => _instance.Value;

    // 私有构造函数:挡住外部 new
    private AppConfig()
    {
        LoadFromFile("appsettings.json");
    }

    public string ConnectionString { get; private set; } = string.Empty;

    private void LoadFromFile(string path)
    {
        // 省略实际读取逻辑
        ConnectionString = "Server=localhost;Database=Demo;";
    }
}

要点与坑:

1)不要手写双检锁。if (_instance == null) { lock (...) { if (_instance == null) ... } } 这种写法在 .NET 的内存模型下必须配 volatile 字段,否则可能读到「已赋值引用、构造函数还没跑完」的半成品对象。用 Lazy<T> 让它去处理,语义清晰且没有坑。

2)sealed 别省。非密封类的单例在子类里可以再 new 一个,约束就破了。

3)真正该用的时候,多半不该用它。单例把依赖藏进了实例内部,单元测试里换不掉、并行测试会互相污染。在 ASP.NET Core 这类有 DI 容器的环境里,正确做法是注册普通类 + 单例生命周期:

// Program.cs
builder.Services.AddSingleton<IAppConfigService, AppConfigService>();
builder.Services.AddSingleton<IOrderRepository, OrderRepository>();   // 无状态、线程安全才可单例
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();                // 每请求一个实例

容器拿到的「单例」和手写单例效果一致,但依赖是显式的、可被 Mock 替换的。只有在你完全不依赖容器(比如一个独立类库)时,才值得手写单例。

2. 工厂方法模式(Factory Method)

意图:定义创建对象的接口,让子类决定实例化哪一个类。调用方只依赖抽象产品,新增产品时改的是工厂子类,不是调用方。

// 产品接口
public interface IExportFile
{
    void Write(string content);
}

public class PdfExport : IExportFile
{
    public void Write(string content) => Console.WriteLine($"写入 PDF:{content}");
}

public class ExcelExport : IExportFile
{
    public void Write(string content) => Console.WriteLine($"写入 Excel:{content}");
}

// 工厂:把「创建」这一步留给子类
public abstract class ExportService
{
    protected abstract IExportFile CreateExportFile();

    public void Export(string content)
    {
        var file = CreateExportFile();   // 这里不出现任何具体类型
        file.Write(content);
    }
}

public class PdfExportService : ExportService
{
    protected override IExportFile CreateExportFile() => new PdfExport();
}

public class ExcelExportService : ExportService
{
    protected override IExportFile CreateExportFile() => new ExcelExport();
}

调用方只写 new PdfExportService().Export("报表内容"),完全不认识 PdfExport。将来加「导出 Word」,只需要加两个类(WordExport + WordExportService),老代码一行不动。

注意区分三个容易混的概念:

名称关系说明
简单工厂不是 GoF 的 23 种一个 switch 返回不同产品的静态方法。够用就用它,别过度设计
工厂方法一个产品等级一个抽象产品 + 多个具体产品,靠继承扩展
抽象工厂多个产品等级一个工厂负责创建一整套配套产品,见下节

3. 抽象工厂模式(Abstract Factory)

意图:提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们的具体类。典型场景是换皮肤、换数据库驱动、换云厂商 SDK——一旦切换,整族实现要一起换,绝不能出现「浅色主题的按钮 + 深色主题的输入框」这种混搭。

// 产品族 A:按钮
public interface IButton { void Render(); }
// 产品族 B:文本框
public interface ITextBox { void Render(); }

// 抽象工厂:负责生产一整套
public interface IUiFactory
{
    IButton CreateButton();
    ITextBox CreateTextBox();
}

public class LightButton : IButton { public void Render() => Console.WriteLine("浅色按钮"); }
public class LightTextBox : ITextBox { public void Render() => Console.WriteLine("浅色文本框"); }

public class DarkButton : IButton { public void Render() => Console.WriteLine("深色按钮"); }
public class DarkTextBox : ITextBox { public void Render() => Console.WriteLine("深色文本框"); }

public class LightThemeFactory : IUiFactory
{
    public IButton CreateButton() => new LightButton();
    public ITextBox CreateTextBox() => new LightTextBox();
}

public class DarkThemeFactory : IUiFactory
{
    public IButton CreateButton() => new DarkButton();
    public ITextBox CreateTextBox() => new DarkTextBox();
}

客户端只持有 IUiFactory,切换主题时改一处工厂构造,整族界面随之改变,且天然保证了族内一致性——这是它相对「三个独立工厂方法」的核心价值。

代价是扩展产品族成本高:要给 IUiFactory 加一个 CreateCheckBox(),所有已存在的工厂实现都得改。所以抽象工厂适合产品族稳定、实现多变的场景。

4. 建造者模式(Builder)

意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。判断要不要用建造者的信号是:构造函数参数超过四五个、且其中有可选参数或参数组合有约束。

public sealed class HttpRequest
{
    public string Url { get; init; } = string.Empty;
    public string Method { get; init; } = "GET";
    public IReadOnlyDictionary<string, string> Headers { get; init; }
        = new Dictionary<string, string>();
    public string? Body { get; init; }
    public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);

    public override string ToString()
        => $"{Method} {Url}({Headers.Count} 个请求头,超时 {Timeout.TotalSeconds}s)";
}

下面是配套的建造者:每一步拆成一个方法,分步时做字段级校验,Build() 时做跨字段校验:

public class HttpRequestBuilder
{
    private string _url = string.Empty;
    private string _method = "GET";
    private readonly Dictionary<string, string> _headers = new();
    private string? _body;
    private TimeSpan _timeout = TimeSpan.FromSeconds(30);

    public HttpRequestBuilder To(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out _))
            throw new ArgumentException($"URL 不合法:{url}", nameof(url));
        _url = url;
        return this;                       // 链式调用
    }

    public HttpRequestBuilder WithMethod(string method)
    {
        _method = method.ToUpperInvariant();
        return this;
    }

    public HttpRequestBuilder WithHeader(string key, string value)
    {
        _headers[key] = value;
        return this;
    }

    public HttpRequestBuilder WithBody(string body)
    {
        _body = body;
        return this;
    }

    public HttpRequestBuilder WithTimeout(TimeSpan timeout)
    {
        if (timeout <= TimeSpan.Zero)
            throw new ArgumentOutOfRangeException(nameof(timeout), "超时必须为正数");
        _timeout = timeout;
        return this;
    }

    public HttpRequest Build()
    {
        // 跨字段约束在 Build 时统一校验
        if (_method is "POST" or "PUT" && _body is null)
            throw new InvalidOperationException($"{_method} 请求必须提供 Body");

        return new HttpRequest
        {
            Url = _url,
            Method = _method,
            Headers = new Dictionary<string, string>(_headers),
            Body = _body,
            Timeout = _timeout
        };
    }
}

// 使用
var request = new HttpRequestBuilder()
    .To("https://api.example.com/orders")
    .WithMethod("POST")
    .WithHeader("Authorization", "Bearer xxx")
    .WithBody("""{"id":1}""")
    .WithTimeout(TimeSpan.FromSeconds(10))
    .Build();

和 C# 对象初始化器的关系:new HttpRequest { Url = ..., Method = ... } 在不需要分步校验、不需要跨字段约束、不需要多表示时更好用,不必强行上建造者。建造者的不可替代之处在于:把校验错误定位到具体步骤、支持「Director 复用同一套构造流程造出不同结果」、以及构造过程本身有顺序要求(比如必须先建连接再发指令)。

5. 原型模式(Prototype)

意图:用原型实例指定创建对象的种类,并通过拷贝这些原型创建新对象。适用于初始化成本高(要查库、算大数组)而对象之间只差少量字段的场景。

public class DocumentTemplate
{
    public string Title { get; set; } = string.Empty;
    public List<string> Sections { get; set; } = new();
    public Dictionary<string, string> Metadata { get; set; } = new();

    // 浅拷贝:引用类型字段仍然指向同一份数据
    public DocumentTemplate ShallowCopy() => (DocumentTemplate)MemberwiseClone();

    // 深拷贝:引用类型逐层复制,互不影响
    public DocumentTemplate DeepCopy() => new()
    {
        Title = Title,
        Sections = new List<string>(Sections),
        Metadata = new Dictionary<string, string>(Metadata)
    };
}

// 使用
var template = new DocumentTemplate
{
    Title = "季度报告",
    Sections = { "概述", "数据", "结论" }
};

var copy = template.DeepCopy();
copy.Sections.Add("附录");        // 不影响 template
Console.WriteLine(template.Sections.Count);   // 3

要点与坑:

1)MemberwiseClone() 是浅拷贝,这是原型模式最常见的 bug 来源——改了副本的 List,原对象跟着变。深层结构必须手写深拷贝,或者用 record 的 with 表达式(对不可变类型很合适)、System.Text.Json 序列化往返等方式。

2)别实现 ICloneable。这个接口只约定「返回 object」,浅还是深全凭实现者心情,调用方无法从类型上判断,.NET 官方文档也不推荐在新代码中使用。自定义 DeepCopy() 方法名字直白、返回强类型,更安全。

3)现代 C# 里 record 天然适合做原型:var copy = original with { Title = "新标题" }; 就是「拷贝 + 改少量字段」的标准表达,但注意 with 对引用类型成员同样是浅拷贝。

三、结构型模式

结构型解决的是「对象怎么拼」。它们的共同手段是组合——用持有引用的方式把对象串起来,而不是靠继承把类层次堆高。下面 7 种里有 4 种(适配器、装饰器、代理、外观)都是「包一层」,区别只在那层壳的意图,我会在每个模式末尾点明它和另外几个的分界。

6. 适配器模式(Adapter)

意图:将一个类的接口转换成客户端期望的另一个接口,让原本因接口不兼容而无法协作的类可以一起工作。它是为了兼容已有代码而生的,不是设计新系统时的首选。

// 本方系统统一使用的支付接口
public interface IPaymentGateway
{
    void Pay(decimal amount, string orderNo);
}

// 第三方老旧 SDK:接口签名完全对不上,且不能修改(第三方包)
public class LegacyAlipaySdk
{
    public void MakePayment(string moneyInYuan, string tradeNo)
    {
        Console.WriteLine($"老 SDK 支付 {moneyInYuan} 元,交易号 {tradeNo}");
    }
}

// 对象适配器:用组合持有被适配者,这是推荐做法
public class AlipayAdapter : IPaymentGateway
{
    private readonly LegacyAlipaySdk _sdk = new();

    public void Pay(decimal amount, string orderNo)
    {
        // 接口转换 + 单位换算:本系统用 decimal 元,SDK 要两位小数字符串
        _sdk.MakePayment(amount.ToString("F2"), orderNo);
    }
}

实现方式有两种:对象适配器(上面这种,组合)和类适配器(让适配器同时继承被适配者和实现目标接口,C# 不支持多继承类,只能用接口 + 继承一个类来实现,且会继承到不需要的成员)。对象适配器的组合关系更灵活,优先选它。

与装饰器/代理的分界:适配器改变接口,装饰器和代理保持接口不变。适配器通常只包一个固定对象且不做增强。

7. 桥接模式(Bridge)

意图:将抽象部分与它的实现部分分离,使它们都可以独立地变化。判断信号是:一个类存在两个以上独立变化的维度,用继承会导致子类数量呈乘法爆炸(M 个抽象 × N 个实现 = M×N 个类)。

// 维度一:形状(抽象部分)
public abstract class Shape
{
    protected readonly IRenderer Renderer;      // 桥:持有实现维度的引用

    protected Shape(IRenderer renderer) => Renderer = renderer;

    public abstract void Draw();
}

// 维度二:渲染方式(实现部分)
public interface IRenderer
{
    void RenderCircle(double radius);
    void RenderSquare(double side);
}

public class VectorRenderer : IRenderer
{
    public void RenderCircle(double radius) => Console.WriteLine($"矢量绘制圆,半径 {radius}");
    public void RenderSquare(double side) => Console.WriteLine($"矢量绘制方形,边长 {side}");
}

public class RasterRenderer : IRenderer
{
    public void RenderCircle(double radius) => Console.WriteLine($"像素绘制圆,半径 {radius}");
    public void RenderSquare(double side) => Console.WriteLine($"像素绘制方形,边长 {side}");
}

public class Circle : Shape
{
    private readonly double _radius;
    public Circle(IRenderer renderer, double radius) : base(renderer) => _radius = radius;

    public override void Draw() => Renderer.RenderCircle(_radius);
}

public class Square : Shape
{
    private readonly double _side;
    public Square(IRenderer renderer, double side) : base(renderer) => _side = side;

    public override void Draw() => Renderer.RenderSquare(_side);
}

// 使用:2 种形状 + 2 种渲染 = 4 个类;继承方案需要 2×2=4 个具体类,
// 而抽象/实现各自再增加一种时,桥接只需加 1 个类,继承方案要加 N 个
new Circle(new VectorRenderer(), 5).Draw();
new Square(new RasterRenderer(), 3).Draw();

桥接与策略长得非常像(都是「持有接口引用 + 委托调用」),分界在意图和出现时机:桥接是结构设计阶段为了拆开两个维度而预设的骨架,抽象部分本身就是继承体系;策略是运行期替换一段可选的算法,宿主通常只有一个类、且不关心算法家族的继承结构。

8. 组合模式(Composite)

意图:将对象组合成树形结构以表示「部分-整体」的层次,使客户端对单个对象和组合对象的调用具有一致性。

public abstract class FileSystemNode
{
    protected FileSystemNode(string name) => Name = name;
    public string Name { get; }

    public abstract long GetSize();
    public virtual void Print(string indent = "")
        => Console.WriteLine($"{indent}{Name}({GetSize()} 字节)");
}

public class FileNode : FileSystemNode
{
    private readonly long _size;
    public FileNode(string name, long size) : base(name) => _size = size;

    public override long GetSize() => _size;
}

public class DirectoryNode : FileSystemNode
{
    private readonly List<FileSystemNode> _children = new();

    public DirectoryNode(string name) : base(name) { }

    public DirectoryNode Add(FileSystemNode node)
    {
        _children.Add(node);
        return this;                       // 链式添加,方便构造树
    }

    public override long GetSize() => _children.Sum(c => c.GetSize());

    public override void Print(string indent = "")
    {
        base.Print(indent);                // 目录自身
        foreach (var child in _children)
            child.Print(indent + "  ");    // 递归下去,叶子节点会自然终止
    }
}

// 使用
var root = new DirectoryNode("src")
    .Add(new FileNode("Program.cs", 2048))
    .Add(new DirectoryNode("Models")
        .Add(new FileNode("User.cs", 512))
        .Add(new FileNode("Order.cs", 1024)));

Console.WriteLine(root.GetSize());   // 3584,递归聚合
root.Print();

关键在于叶子节点和容器节点共享同一个抽象接口——调用方写 node.GetSize() 时不用判断它是文件还是目录。代价是接口设计变宽:文件节点也不得不「实现」Add() 这类只在容器上有意义的方法,常见的处理是抛 NotSupportedException,或者像上面这样把 Add 只放在容器类上(牺牲一点透明性,换来类型安全)。

与装饰器的分界:装饰器是一条链(每层包一个),组合是一棵树(一个节点持有 N 个子节点)。

9. 装饰器模式(Decorator)

意图:动态地给一个对象添加一些额外的职责。相比继承,它可以在运行时按需组合,且不会因为组合爆炸产生大量子类。System.IO 里的 Stream 体系就是它的教科书级实现。

public interface IDataStore
{
    void Write(string data);
    string Read();
}

public class MemoryStore : IDataStore
{
    private string _data = string.Empty;
    public void Write(string data) => _data = data;
    public string Read() => _data;
}

// 装饰器基类:实现同一接口 + 持有同接口引用
public abstract class StoreDecorator : IDataStore
{
    protected readonly IDataStore Inner;
    protected StoreDecorator(IDataStore inner) => Inner = inner;

    public virtual void Write(string data) => Inner.Write(data);
    public virtual string Read() => Inner.Read();
}

public class CompressionDecorator : StoreDecorator
{
    public CompressionDecorator(IDataStore inner) : base(inner) { }

    public override void Write(string data)
    {
        Console.WriteLine("[压缩] 写入前压缩");
        Inner.Write($"<compressed>{data}</compressed>");
    }

    public override string Read()
    {
        var raw = Inner.Read();
        Console.WriteLine("[压缩] 读取后解压");
        return raw.Replace("<compressed>", "").Replace("</compressed>", "");
    }
}

public class EncryptionDecorator : StoreDecorator
{
    public EncryptionDecorator(IDataStore inner) : base(inner) { }

    public override void Write(string data)
    {
        Console.WriteLine("[加密] 写入前加密");
        Inner.Write(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(data)));
    }

    public override string Read()
    {
        var raw = Inner.Read();
        Console.WriteLine("[加密] 读取后解密");
        return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(raw));
    }
}

// 使用:运行时任意叠加,顺序即语义
IDataStore store = new EncryptionDecorator(new CompressionDecorator(new MemoryStore()));
store.Write("hello");
Console.WriteLine(store.Read());     // hello

要点与坑:

1)叠加顺序有语义。上面是「先压缩再加密」,反过来就是「先加密再压缩」——读取顺序也必须严格逆序。这两件事在构造时就绑定了,别在业务代码里手工拆装。

2)装饰器和被装饰者必须是同一个接口,否则调用方就得知道装饰链的具体结构,模式的意义就没了。

3)与代理的分界:装饰器为了加功能(客户端主动、可任意叠加多层),代理为了控访问(对客户端透明、通常只有一层,且由代理自己决定是否/何时创建真实对象)。与适配器的分界:装饰器接口不变。

10. 外观模式(Facade)

意图:为子系统中的一组接口提供一个一致的、更高层的入口,让调用方不必了解子系统内部的协作顺序。

// 子系统:三个各自独立、协作顺序有讲究的服务
public class InventoryService
{
    public bool Check(string sku, int qty)
    {
        Console.WriteLine($"检查库存 {sku} x{qty}");
        return true;
    }
}

public class PaymentService
{
    public bool Charge(string userId, decimal amount)
    {
        Console.WriteLine($"扣款 {amount} 元,用户 {userId}");
        return true;
    }
}

public class ShippingService
{
    public void Arrange(string sku, int qty)
    {
        Console.WriteLine($"安排发货 {sku} x{qty}");
    }
}

// 外观:把「先校验、再扣款、最后发货,且任一步失败要回滚」的编排收在这里
public class OrderFacade
{
    private readonly InventoryService _inventory = new();
    private readonly PaymentService _payment = new();
    private readonly ShippingService _shipping = new();

    public bool PlaceOrder(string userId, string sku, int qty, decimal amount)
    {
        if (!_inventory.Check(sku, qty))
            return false;

        if (!_payment.Charge(userId, amount))
            return false;

        _shipping.Arrange(sku, qty);
        return true;
    }
}

// 调用方:一行搞定,不需要知道三个子系统及其顺序
var ok = new OrderFacade().PlaceOrder("u1001", "SKU-9", 2, 199.00m);

外观的价值在降低调用方和子系统的耦合:下单流程变了只改 OrderFacade,所有调用点不受影响;子系统也不依赖外观,仍然可以被直接使用(外观不是强制门禁)。

与中介者的分界:外观是单向的——调用方通过它进,子系统不会反向通过它互相通信,它也不新增行为,只是编排;中介者是多方的——同事对象之间通过它互相发消息,它承载了交互逻辑本身。

11. 享元模式(Flyweight)

意图:运用共享技术有效地支持大量细粒度对象。核心是把对象状态拆成内在状态(可共享、不变)和外在状态(随场景变化、由调用方传入)。

// 内在状态:树种,形状/贴图/颜色,可被成千上万棵树共享
public sealed class TreeType
{
    public TreeType(string name, string color, string texture)
    {
        Name = name; Color = color; Texture = texture;
    }

    public string Name { get; }
    public string Color { get; }
    public string Texture { get; }

    public void Draw(int x, int y, int ageYears)
        => Console.WriteLine($"在 ({x},{y}) 画 {Color} 的 {Name},树龄 {ageYears} 年");
}

// 享元工厂:保证同 key 只创建一次
public static class TreeTypeFactory
{
    private static readonly Dictionary<string, TreeType> Cache = new();

    public static TreeType Get(string name, string color, string texture)
    {
        var key = $"{name}|{color}|{texture}";
        if (!Cache.TryGetValue(key, out var type))
        {
            type = new TreeType(name, color, texture);
            Cache[key] = type;
            Console.WriteLine($"[工厂] 创建新的 TreeType:{key}");
        }
        return type;
    }

    public static int Count => Cache.Count;
}

// 外在状态:位置和树龄,每棵树各不相同,不参与共享
public sealed class Tree
{
    private readonly TreeType _type;      // 共享
    private readonly int _x, _y, _age;    // 独有

    public Tree(TreeType type, int x, int y, int age)
        => (_type, _x, _y, _age) = (type, x, y, age);

    public void Draw() => _type.Draw(_x, _y, _age);
}

// 使用:一百万棵树,TreeType 只有 3 个实例
var rand = new Random(42);
for (var i = 0; i < 1_000_000; i++)
{
    var type = TreeTypeFactory.Get(
        rand.Next(2) == 0 ? "橡树" : "松树",
        "绿色",
        "tree.png");
    _ = new Tree(type, i % 1000, i / 1000, rand.Next(1, 50));
}
Console.WriteLine($"TreeType 实例数:{TreeTypeFactory.Count}");   // 2

要点与坑:

1)线程安全。Dictionary 并发写入会损坏内部结构,必须上锁或改用 ConcurrentDictionary,上面的代码只适合单线程演示。

2)享元的对象必须不可变。共享的对象一旦被某个使用方改动,所有引用它的地方都会跟着出错,这是最隐蔽的 bug。上面的 TreeType 用只读属性 + 构造注入来保证。

3)别硬套。C# 里字符串字面量驻留、string.Intern()、ArrayPool<T>、ValueTask 缓存等,本质都是享元思想的已有实现。业务代码里真正需要自己写享元的场景不多,多见于游戏、地图、图形渲染。

12. 代理模式(Proxy)

意图:为其他对象提供一种代理以控制对这个对象的访问。常见变体有:虚拟代理(延迟创建开销大的对象)、保护代理(权限校验)、远程代理(本地代表远程对象)、写时复制代理。

public interface IReportService
{
    string Generate(string reportId);
}

// 真实对象:构造开销大,或者访问代价高
public class HeavyReportService : IReportService
{
    public HeavyReportService()
    {
        Console.WriteLine("[真实对象] 初始化中,加载数据集…");
        Thread.Sleep(500);          // 模拟昂贵初始化
    }

    public string Generate(string reportId) => $"报表 {reportId} 的内容";
}

// 虚拟代理 + 保护代理:延迟创建 + 权限校验 + 结果缓存
public class ReportServiceProxy : IReportService
{
    private readonly string _currentUserRole;
    private HeavyReportService? _real;                       // 延迟初始化
    private readonly Dictionary<string, string> _cache = new();

    public ReportServiceProxy(string currentUserRole) => _currentUserRole = currentUserRole;

    public string Generate(string reportId)
    {
        if (_currentUserRole != "Admin")
            throw new UnauthorizedAccessException($"角色 {_currentUserRole} 无权生成报表");

        if (_cache.TryGetValue(reportId, out var cached))
        {
            Console.WriteLine("[代理] 命中缓存,未创建真实对象");
            return cached;
        }

        _real ??= new HeavyReportService();     // 真正需要时才创建,且只创建一次
        var result = _real.Generate(reportId);
        _cache[reportId] = result;
        return result;
    }
}

// 使用
IReportService service = new ReportServiceProxy("Admin");
Console.WriteLine(service.Generate("R-001"));    // 触发真实对象创建
Console.WriteLine(service.Generate("R-001"));    // 走缓存

要点与坑:

1)延迟初始化注意线程安全。_real ??= new HeavyReportService() 在并发下可能创建两个实例,用 Lazy<HeavyReportService> 包一层最稳妥。

2)C# 里能自动生成代理,不必手写:静态织入用 DispatchProxy(内置,适合按接口生成)或 Castle DynamicProxy(AOP 框架如 Autofac.Extras.DynamicProxy 的基础),编译期织入用 PostSharp/Fody。手写代理适合逻辑简单、只包一两个方法的场景。

3)与装饰器的分界再强调一次:代理决定「要不要让你访问真实对象」,装饰器决定「访问前后再干点什么」。上面这个例子同时做了缓存和鉴权,两者边界模糊时,问自己一句话——如果去掉这层壳,功能还完整吗?装饰器去掉后功能缺失(少了加密),代理去掉后功能仍在、只是慢或无保护。

四、行为型模式

行为型关注对象之间「怎么协作、职责怎么分」。这一类数量最多(11 种),也是日常业务代码里用得最多的——尤其是责任链、观察者、策略、模板方法这四种。

13. 责任链模式(Chain of Responsibility)

意图:使多个对象都有机会处理请求,从而避免请求的发送者与接收者耦合。把这些对象连成一条链,沿着链传递请求,直到有一个对象处理它为止。ASP.NET Core 的中间件管道就是它的标准实现。

public record PurchaseRequest(string Item, decimal Amount, string Applicant);

public abstract class Approver
{
    private Approver? _next;

    // 返回抽象类型,支持链式串联
    public Approver SetNext(Approver next)
    {
        _next = next;
        return next;                  // 返回 next 而非 this,才写得出 a.SetNext(b).SetNext(c)
    }

    public virtual void Handle(PurchaseRequest request)
    {
        if (Approve(request))
        {
            Console.WriteLine($"{GetType().Name} 批准了 {request.Item}");
            return;
        }

        if (_next is null)
        {
            Console.WriteLine($"无人能批准 {request.Amount} 元的 {request.Item}");
            return;
        }

        _next.Handle(request);        // 交给下一环
    }

    protected abstract bool Approve(PurchaseRequest request);   // 返回是否已处理完
}

public class TeamLead : Approver
{
    protected override bool Approve(PurchaseRequest request)
    {
        if (request.Amount > 1000) return false;
        Console.WriteLine($"组长审批:{request.Item}");
        return true;
    }
}

public class Manager : Approver
{
    protected override bool Approve(PurchaseRequest request)
    {
        if (request.Amount > 10000) return false;
        Console.WriteLine($"经理审批:{request.Item}");
        return true;
    }
}

public class Director : Approver
{
    protected override bool Approve(PurchaseRequest request)
    {
        Console.WriteLine($"总监审批:{request.Item}");
        return true;                  // 兜底,一律批准
    }
}

// 组装链条:顺序即权限,可运行时增删
var chain = new TeamLead();
chain.SetNext(new Manager()).SetNext(new Director());

chain.Handle(new PurchaseRequest("显示器", 800, "张三"));       // 组长
chain.Handle(new PurchaseRequest("服务器", 50000, "李四"));     // 一路到总监

要点与坑:

1)一定要有兜底环节,否则请求会静默消失——这是责任链最常见的线上问题。可以在链尾加一个默认处理器,或者在 Handle 里显式打印告警。

2)链太长会影响性能,且调试时调用栈被层层 Handle 淹没。中间件那种十来个环是常态,几十个就要考虑用表驱动(Dictionary + 顺序列表)替代显式链式调用。

3)返回 bool(是否已处理)还是 void(每人做一点),决定了它是「找到处理者就停」还是「管道路由」。ASP.NET Core 中间件是后者:每一环都可以决定是否 await _next(context)。

14. 命令模式(Command)

意图:将一个请求封装为一个对象,从而可以用不同的请求对客户进行参数化、支持排队、记录日志和撤销。GUI 的按钮、编辑器的撤销重做、事务日志都是它的应用。

// 命令接口:支持执行与撤销
public interface ICommand
{
    void Execute();
    void Undo();
}

// 接收者:真正干活的对象
public class Light
{
    public string Room { get; init; } = string.Empty;
    public bool IsOn { get; private set; }

    public void TurnOn() { IsOn = true; Console.WriteLine($"{Room} 的灯亮了"); }
    public void TurnOff() { IsOn = false; Console.WriteLine($"{Room} 的灯灭了"); }
}

// 具体命令:把「对谁做什么」封成一个对象
public class TurnOnCommand : ICommand
{
    private readonly Light _light;
    public TurnOnCommand(Light light) => _light = light;

    public void Execute() => _light.TurnOn();
    public void Undo() => _light.TurnOff();     // 撤销即反向操作
}

public class TurnOffCommand : ICommand
{
    private readonly Light _light;
    public TurnOffCommand(Light light) => _light = light;

    public void Execute() => _light.TurnOff();
    public void Undo() => _light.TurnOn();
}

// 调用者:不认识 Light,只认识 ICommand,并维护历史用于撤销
public class RemoteControl
{
    private readonly Stack<ICommand> _history = new();

    public void Press(ICommand command)
    {
        command.Execute();
        _history.Push(command);
    }

    public void PressUndo()
    {
        if (_history.Count == 0)
        {
            Console.WriteLine("没有可撤销的操作");
            return;
        }
        _history.Pop().Undo();
    }
}

// 使用
var light = new Light { Room = "客厅" };
var remote = new RemoteControl();
remote.Press(new TurnOnCommand(light));
remote.Press(new TurnOffCommand(light));
remote.PressUndo();          // 又把灯打开
remote.PressUndo();          // 又关掉

要点与坑:

1)撤销的复杂度在于「不可逆操作」。关灯可以 TurnOn 撤销,但「发送邮件」「扣款」不可逆,正确做法是补偿事务(再发一封道歉邮件、再退一笔款),而不是假装能回滚。Undo 方法名骗人,语义上应是 Compensate。

2)别忘了宏命令。把一组命令封装成一个 CompositeCommand(本身也实现 ICommand),撤销时逆序 Undo,就能支持「一键关全屋灯」这类批量操作。

3)在 C# 里,如果命令只是「调一个方法、不需要撤销/排队」,用一个 Action 或 Func<T> 委托就够了,不必为它造接口和类。命令模式的真正价值在需要把请求对象化的时候。

15. 解释器模式(Interpreter)

意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器,用来解释语言中的句子。它是 23 种里最不常用的一种,通常只在需要自定义表达式(规则引擎、SQL 片段、搜索语法)时出现。

// 抽象表达式
public interface IExpression
{
    int Interpret(Dictionary<string, int> context);
}

// 终结符表达式
public class NumberExpression : IExpression
{
    private readonly int _value;
    public NumberExpression(int value) => _value = value;

    public int Interpret(Dictionary<string, int> context) => _value;
}

public class VariableExpression : IExpression
{
    private readonly string _name;
    public VariableExpression(string name) => _name = name;

    public int Interpret(Dictionary<string, int> context)
    {
        if (!context.TryGetValue(_name, out var value))
            throw new InvalidOperationException($"未定义变量:{_name}");
        return value;
    }
}

// 非终结符表达式:组合子表达式
public class AddExpression : IExpression
{
    private readonly IExpression _left, _right;
    public AddExpression(IExpression left, IExpression right) => (_left, _right) = (left, right);

    public int Interpret(Dictionary<string, int> context)
        => _left.Interpret(context) + _right.Interpret(context);
}

public class MultiplyExpression : IExpression
{
    private readonly IExpression _left, _right;
    public MultiplyExpression(IExpression left, IExpression right) => (_left, _right) = (left, right);

    public int Interpret(Dictionary<string, int> context)
        => _left.Interpret(context) * _right.Interpret(context);
}

// 使用:手工构造表达式树,解释 x * 2 + 10
var context = new Dictionary<string, int> { ["x"] = 5 };
IExpression expr = new AddExpression(
    new MultiplyExpression(new VariableExpression("x"), new NumberExpression(2)),
    new NumberExpression(10));

Console.WriteLine(expr.Interpret(context));      // 20

要点与坑:

1)文法一变,类就爆炸。每种语法规则都要一个类,复杂语法的类数量会失控。所以它只适合文法简单且稳定的场合。

2)现实项目中几乎都有更省事的替代品:.NET 内置的 System.Linq.Expressions(表达式树)、ANTLR(完整 DSL)、或者直接把规则存成数据结构用 switch 解释。真正需要自己写解释器的场合,通常是表达式本身要序列化后存库、由用户配置生成。

3)如果要动手写解释器,先想想能不能用现成的规则/表达式库。这类代码的正确性维护成本极高(运算符优先级、括号、错误恢复都要处理),不值得为一个小需求自己造。

16. 迭代器模式(Iterator)

意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。这是 23 种里唯一已经被语言完全内建的模式——C# 里你几乎不需要「实现」它,只需要「使用」它。

public class Playlist : IEnumerable<string>
{
    private readonly List<string> _songs = new();

    public void Add(string song) => _songs.Add(song);

    // 编译器会把这个方法体变成状态机,yield return 是迭代器模式的语法糖
    public IEnumerator<string> GetEnumerator()
    {
        foreach (var song in _songs)
        {
            if (string.IsNullOrWhiteSpace(song))
                continue;                  // 过滤逻辑写在迭代器里
            yield return song;
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// 使用
var playlist = new Playlist();
playlist.Add("Song A");
playlist.Add("");
playlist.Add("Song B");

foreach (var song in playlist)          // foreach 编译后就是 GetEnumerator/MoveNext/Current
    Console.WriteLine(song);            // 只输出 A 和 B

要点与坑:

1)延迟执行。yield return 的方法体在你第一次 MoveNext() 时才执行。这意味着「构造 IEnumerable 时不会做任何事」,副作用(查库、打日志)发生在遍历时——把同一个 IEnumerable 遍历两次,查询会执行两次。

2)别在迭代过程中修改集合。foreach 期间对 List 增删会抛 InvalidOperationException("Collection was modified"),需要先 ToList() 快照。

3)遍历完要 Dispose。foreach 会在 finally 里调 Dispose(),但如果你手写 GetEnumerator() 却不 Dispose,文件句柄/数据库游标就会泄漏。这也是为什么应该优先用 foreach 而不是手动调 MoveNext。

17. 中介者模式(Mediator)

意图:用一个中介对象来封装一系列对象之间的交互。中介者使各对象不需要显式地相互引用,从而降低耦合,并可以独立地改变它们之间的交互。

// 中介者接口
public interface IChatRoom
{
    void Register(User user);
    void Send(string message, User sender);
}

public class ChatRoom : IChatRoom
{
    private readonly List<User> _users = new();

    public void Register(User user)
    {
        _users.Add(user);
        Console.WriteLine($"[系统] {user.Name} 加入聊天室");
    }

    public void Send(string message, User sender)
    {
        // 广播给除发送者外的所有人:多对多关系收敛到这一处
        foreach (var user in _users.Where(u => u != sender))
            user.Receive(message, sender.Name);
    }
}

public abstract class User
{
    protected readonly IChatRoom Room;      // 只认识中介者,不认识其他 User

    protected User(string name, IChatRoom room)
    {
        Name = name;
        Room = room;
        room.Register(this);
    }

    public string Name { get; }

    public void Send(string message)
    {
        Console.WriteLine($"{Name} 说:{message}");
        Room.Send(message, this);
    }

    public void Receive(string message, string from)
        => Console.WriteLine($"  {Name} 收到来自 {from} 的消息:{message}");
}

// 使用
var room = new ChatRoom();
var alice = new ChatRoomUser("Alice", room);
var bob = new ChatRoomUser("Bob", room);
alice.Send("大家好");      // Bob 收到,Alice 不重复收到

public class ChatRoomUser : User
{
    public ChatRoomUser(string name, IChatRoom room) : base(name, room) { }
}

要点与坑:

1)中介者会变成上帝对象。所有交互逻辑都挤进一个类,它会越来越大、越来越难测。缓解办法是拆成多个中介者,或者当交互规则本身可以组合时改用事件/消息总线。

2)和观察者的区别:观察者是一对多广播(主题只管通知,不知道订阅者是谁、也不关心谁收到),中介者是多对多协调(中介者知道所有参与者,并按规则决定谁该收到什么)。聊天室这个例子用观察者也能实现广播,但「私聊」「按房间路由」这类规则就必须有中介者。

3)实际项目里,消息总线(MediatR、RabbitMQ 的 topic 交换)就是中介者思路的工程化形态,不必手写。

18. 备忘录模式(Memento)

意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后可以将该对象恢复到原先保存的状态。

// 备忘录:只读快照,对外只能读取不能修改
public sealed class EditorMemento
{
    internal EditorMemento(string content, int cursor)
        => (Content, Cursor) = (content, cursor);

    public string Content { get; }
    public int Cursor { get; }
    public DateTime SavedAt { get; } = DateTime.Now;
}

// 发起人:自己决定存什么、怎么恢复
public class TextEditor
{
    public string Content { get; private set; } = string.Empty;
    private int _cursor;

    public void Type(string text)
    {
        Content += text;
        _cursor = Content.Length;
    }

    public void MoveCursor(int position)
    {
        _cursor = Math.Clamp(position, 0, Content.Length);
        Console.WriteLine($"光标移动到 {_cursor}");
    }

    public EditorMemento Save() => new(Content, _cursor);   // 快照

    public void Restore(EditorMemento memento)
    {
        Content = memento.Content;
        _cursor = memento.Cursor;
        Console.WriteLine($"已恢复到 {memento.SavedAt:HH:mm:ss} 的状态");
    }
}

// 管理者:只管存取,不关心里面装的是什么
public class History
{
    private readonly Stack<EditorMemento> _snapshots = new();

    public void Push(EditorMemento memento) => _snapshots.Push(memento);

    public EditorMemento? Pop() => _snapshots.Count > 0 ? _snapshots.Pop() : null;
}

// 使用
var editor = new TextEditor();
var history = new History();

editor.Type("第一版内容");
history.Push(editor.Save());

editor.Type(",第二版补充");
editor.MoveCursor(0);

var snapshot = history.Pop();
if (snapshot is not null)
    editor.Restore(snapshot);

Console.WriteLine(editor.Content);      // 第一版内容

要点与坑:

1)大对象快照会吃内存。每存一版就把 Content 整份复制,长文档 + 高频快照很快爆内存。常见优化:只存增量(diff)、限制历史条数(环形缓冲)、快照写入磁盘。

2)别把备忘录暴露给外部。备忘录类应该只允许发起人读写,History 之类的管理者只能「整体存取」。C# 里可用 internal 构造函数 + 嵌套类实现更严格的封装。

3)与命令模式的区别:命令的 Undo 是反向操作(再点一次灯),备忘录是状态回滚(把整个编辑器状态倒回去)。复杂编辑场景两者常配合使用:每个命令执行前存一份备忘录。

19. 观察者模式(Observer)

意图:定义对象间的一对多依赖关系,当一个对象的状态改变时,所有依赖它的对象都得到通知并被自动更新。C# 的 event 关键字和委托机制就是它的语言级实现。

// 手写版本:理解结构
public interface IStockObserver
{
    void Update(string symbol, decimal price);
}

public class StockMarket
{
    private readonly List<IStockObserver> _observers = new();
    private readonly Dictionary<string, decimal> _prices = new();

    public void Subscribe(IStockObserver observer) => _observers.Add(observer);
    public void Unsubscribe(IStockObserver observer) => _observers.Remove(observer);

    public void SetPrice(string symbol, decimal price)
    {
        _prices[symbol] = price;
        Notify(symbol, price);
    }

    private void Notify(string symbol, decimal price)
    {
        // 注意:遍历的是副本,防止观察者在回调里退订导致集合被修改
        foreach (var observer in _observers.ToList())
            observer.Update(symbol, price);
    }
}

public class EmailAlert : IStockObserver
{
    public void Update(string symbol, decimal price)
        => Console.WriteLine($"[邮件] {symbol} 现价 {price}");
}

public class RiskMonitor : IStockObserver
{
    public void Update(string symbol, decimal price)
    {
        if (price > 100)
            Console.WriteLine($"[风控] {symbol} 价格过高:{price}");
    }
}

C# 中的惯用写法是直接用 event,省掉订阅列表的维护:

public class StockMarketWithEvent
{
    // 委托类型即观察者接口,event 保证外部只能 += / -=,不能直接赋值或触发
    public event EventHandler<StockPriceChangedEventArgs>? PriceChanged;

    public void SetPrice(string symbol, decimal price)
        => PriceChanged?.Invoke(this, new StockPriceChangedEventArgs(symbol, price));
}

public class StockPriceChangedEventArgs : EventArgs
{
    public StockPriceChangedEventArgs(string symbol, decimal price)
        => (Symbol, Price) = (symbol, price);

    public string Symbol { get; }
    public decimal Price { get; }
}

// 订阅:lambda 亦可
var market = new StockMarketWithEvent();
market.PriceChanged += (sender, e) => Console.WriteLine($"[邮件] {e.Symbol} 现价 {e.Price}");
market.SetPrice("AAPL", 120m);

要点与坑:

1)内存泄漏是最大的坑。事件源持有订阅者的引用,如果订阅者是个短生命周期对象(比如临时窗口)却忘记 -=,它会一直被事件源拽着不放。长生命周期的事件源(静态类、单例)尤其危险。方案:用完就退订、用弱事件(WeakEventManager)、或者让订阅者实现 IDisposable 统一清理。

2)回调里的异常会打断后续通知。Invoke 是同步逐个调用的,某个订阅者抛异常,后面的就收不到通知了。关键业务要考虑逐个 try-catch 或改用异步队列。

3)执行顺序不确定。event 的调用顺序是订阅顺序,但依赖这个顺序是脆弱的——业务上不应该有「必须 B 先于 C 收到」的假设。

20. 状态模式(State)

意图:允许一个对象在其内部状态改变时改变它的行为,看起来像是修改了它的类。核心价值是把散落各处的 switch (status) 收敛成一组状态类。

public interface IOrderState
{
    void Pay(Order order);
    void Ship(Order order);
    void Complete(Order order);
    string Name { get; }
}

// 上下文:把行为委托给当前状态
public class Order
{
    public Order(string orderNo)
    {
        OrderNo = orderNo;
        State = new PendingPaymentState();
    }

    public string OrderNo { get; }
    public IOrderState State { get; set; }

    public void Pay() => State.Pay(this);
    public void Ship() => State.Ship(this);
    public void Complete() => State.Complete(this);

    public override string ToString() => $"订单 {OrderNo}({State.Name})";
}

public class PendingPaymentState : IOrderState
{
    public string Name => "待付款";

    public void Pay(Order order)
    {
        Console.WriteLine($"{order.OrderNo} 支付成功");
        order.State = new PaidState();          // 状态迁移在这里发生
    }

    public void Ship(Order order) => Console.WriteLine("未付款,无法发货");
    public void Complete(Order order) => Console.WriteLine("未付款,无法完成");
}

public class PaidState : IOrderState
{
    public string Name => "已付款";

    public void Pay(Order order) => Console.WriteLine("已付款,请勿重复支付");

    public void Ship(Order order)
    {
        Console.WriteLine($"{order.OrderNo} 已发货");
        order.State = new ShippedState();
    }

    public void Complete(Order order) => Console.WriteLine("未发货,无法完成");
}

public class ShippedState : IOrderState
{
    public string Name => "已发货";

    public void Pay(Order order) => Console.WriteLine("订单已发货,无需支付");
    public void Ship(Order order) => Console.WriteLine("请勿重复发货");

    public void Complete(Order order)
    {
        Console.WriteLine($"{order.OrderNo} 交易完成");
        order.State = new CompletedState();
    }
}

public class CompletedState : IOrderState
{
    public string Name => "已完成";

    public void Pay(Order order) => Console.WriteLine("订单已完成");
    public void Ship(Order order) => Console.WriteLine("订单已完成");
    public void Complete(Order order) => Console.WriteLine("订单已完成");
}

// 使用
var order = new Order("SO-20260928-001");
order.Ship();       // 未付款,无法发货
order.Pay();        // 支付成功 → 状态转为已付款
order.Ship();       // 已发货
order.Complete();   // 交易完成
Console.WriteLine(order);

要点与坑:

1)什么时候值得用:状态数 ≥ 3,或者状态迁移规则会持续变化。只有两三个状态、且规则稳定时,switch 表达式反而更清晰——状态模式不是「反 switch 教条」,它解决的是状态相关行为散落在多个方法里、且每加一个状态要改 N 处的问题。

2)状态迁移的归属。让状态类自己决定下一个状态(上面的写法)最内聚,但迁移规则分散在各状态类里;另一种做法是让 Order 集中判断迁移合法性(状态类只提供行为)。前者适合迁移规则简单、后者适合规则需要全局校验(比如权限、时间窗)。

3)和策略的分界:策略的各个实现彼此不知道对方,由客户端选定,通常全程不变;状态之间知道彼此并主动触发迁移,客户端不关心当前是哪个状态。判断方法:客户端需要「指定用哪个」——策略;客户端只说「发生了什么事件」——状态。

21. 策略模式(Strategy)

意图:定义一系列算法,把它们一个个封装起来,并使它们可以相互替换。这是业务代码里出现频率最高的模式之一,也是被过度使用最多的一个。

// 策略接口
public interface IShippingStrategy
{
    decimal Calculate(decimal weightKg, string destination);
    string Name { get; }
}

public class StandardShipping : IShippingStrategy
{
    public string Name => "标准快递";
    public decimal Calculate(decimal weightKg, string destination)
        => 10m + weightKg * 2m;
}

public class ExpressShipping : IShippingStrategy
{
    public string Name => "顺丰特快";
    public decimal Calculate(decimal weightKg, string destination)
        => 25m + weightKg * 5m;
}

public class FreeShipping : IShippingStrategy
{
    public string Name => "包邮";
    public decimal Calculate(decimal weightKg, string destination) => 0m;
}

// 上下文:持有策略并在运行期可替换
public class ShippingCalculator
{
    private IShippingStrategy _strategy;

    public ShippingCalculator(IShippingStrategy strategy) => _strategy = strategy;

    public void SetStrategy(IShippingStrategy strategy) => _strategy = strategy;

    public decimal GetCost(decimal weightKg, string destination)
    {
        var cost = _strategy.Calculate(weightKg, destination);
        Console.WriteLine($"{_strategy.Name}:{cost} 元");
        return cost;
    }
}

// 使用
var calculator = new ShippingCalculator(new StandardShipping());
calculator.GetCost(3, "北京");              // 16

calculator.SetStrategy(new ExpressShipping());
calculator.GetCost(3, "北京");              // 40

要点与坑:

1)最容易被滥用的模式。如果某个「策略」永远只有一个实现、也没有替换需求,直接写个方法就够了——加个接口 + 类 + 注册,除了让代码多三倍,没有任何收益。「未来可能会换」不是理由,等真换了再抽。

2)策略是纯函数时用委托就够:

public class ShippingCalculatorFunc
{
    private Func<decimal, string, decimal> _rule = (weight, dest) => 10m + weight * 2m;

    public void SetRule(Func<decimal, string, decimal> rule) => _rule = rule;

    public decimal GetCost(decimal weightKg, string destination) => _rule(weightKg, destination);
}

// 调用
var calc = new ShippingCalculatorFunc();
calc.SetRule((w, d) => w > 10 ? 0m : 15m);        // 包邮规则随时换

只有一个方法的策略接口,和一个 Func<...> 在表达力上等价,后者少写两个类。需要「策略带多个方法」「需要按名称从容器解析」「需要被 DI 管理生命周期」时,才值得上升为接口 + 类。

3)消除选择逻辑。策略模式的收益在于把 if/switch 换成多态;但如果选择逻辑本身仍在客户端(if (type == "express") strategy = new Express()),只是把判断搬了个位置。配合字典或 DI 按 key 解析才完整:

builder.Services.AddKeyedScoped<IShippingStrategy, StandardShipping>("standard");
builder.Services.AddKeyedScoped<IShippingStrategy, ExpressShipping>("express");

// 注入 IServiceProvider 后按名称取
var strategy = provider.GetRequiredKeyedService<IShippingStrategy>(userChoice);

22. 模板方法模式(Template Method)

意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

public abstract class DataImporter
{
    // 模板方法:骨架固定,用 sealed 防止子类破坏流程
    public void Import(string filePath)
    {
        Open(filePath);
        var rows = Parse();
        Validate(rows);            // 可选钩子
        Save(rows);
        Close();                   // 无论成功失败都要清理
    }

    protected abstract void Open(string filePath);
    protected abstract List<string[]> Parse();
    protected abstract void Save(List<string[]> rows);

    // 钩子(Hook):有默认实现,子类按需覆盖
    protected virtual void Validate(List<string[]> rows)
    {
        if (rows.Count == 0)
            throw new InvalidOperationException("文件内容为空");
    }

    protected virtual void Close() => Console.WriteLine("释放资源");
}

public class CsvImporter : DataImporter
{
    private StreamReader? _reader;

    protected override void Open(string filePath)
    {
        Console.WriteLine($"打开 CSV:{filePath}");
        _reader = new StreamReader(filePath);
    }

    protected override List<string[]> Parse()
    {
        Console.WriteLine("按逗号切分");
        return new List<string[]> { new[] { "1", "张三" }, new[] { "2", "李四" } };
    }

    protected override void Validate(List<string[]> rows)
    {
        base.Validate(rows);                       // 复用父类默认校验
        foreach (var row in rows)
            if (row.Length != 2)
                throw new InvalidOperationException("CSV 列数不正确");
    }

    protected override void Save(List<string[]> rows)
        => Console.WriteLine($"写入数据库,共 {rows.Count} 行");

    protected override void Close()
    {
        _reader?.Dispose();
        base.Close();
    }
}

// 使用:调用方只认 Import,不知道内部有多少步骤
new CsvImporter().Import("users.csv");

要点与坑:

1)「好莱坞原则」:别调用我们,我们会调用你。父类控制流程、子类被动填充,这是模板方法和普通继承最本质的区别。

2)抽象方法的数量要克制。父类要求子类实现的抽象成员越多,子类的负担越重。能给出合理默认值的步骤就做成 virtual 钩子,只把真正必需的留成 abstract。

3)现代替代:组合 + 委托。模板方法依赖继承,一个类只能有一个父类,扩展方向被锁死。如果发现流程需要按多个维度变化,改用「骨架类持有若干 Func/接口,由构造函数注入」的组合写法更灵活——这实际上是策略 + 模板方法的混合,.NET 里 HttpClient、IHostedService(StartAsync/StopAsync 由框架按固定时机调用)都是这个思路。

23. 访问者模式(Visitor)

意图:表示一个作用于某对象结构中的各元素的操作,它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。理解它的关键是双分派。

// 元素接口
public interface IShape
{
    void Accept(IShapeVisitor visitor);      // 第一次分派:确定元素类型
}

public class Circle : IShape
{
    public double Radius { get; init; }
    public void Accept(IShapeVisitor visitor) => visitor.Visit(this);
}

public class Rectangle : IShape
{
    public double Width { get; init; }
    public double Height { get; init; }
    public void Accept(IShapeVisitor visitor) => visitor.Visit(this);
}

// 访问者接口:每个元素类型一个 Visit 重载
public interface IShapeVisitor
{
    void Visit(Circle circle);           // 第二次分派:确定访问者类型
    void Visit(Rectangle rectangle);
}

// 新增操作 = 新增访问者,元素类一行不用改
public class AreaCalculator : IShapeVisitor
{
    public double TotalArea { get; private set; }

    public void Visit(Circle circle)
        => TotalArea += Math.PI * circle.Radius * circle.Radius;

    public void Visit(Rectangle rectangle)
        => TotalArea += rectangle.Width * rectangle.Height;
}

public class SvgExporter : IShapeVisitor
{
    public void Visit(Circle circle)
        => Console.WriteLine($"<circle r=\"{circle.Radius}\" />");

    public void Visit(Rectangle rectangle)
        => Console.WriteLine($"<rect width=\"{rectangle.Width}\" height=\"{rectangle.Height}\" />");
}

// 使用
var shapes = new List<IShape>
{
    new Circle { Radius = 2 },
    new Rectangle { Width = 3, Height = 4 }
};

var calculator = new AreaCalculator();
foreach (var shape in shapes)
    shape.Accept(calculator);            // 元素决定调哪个 Visit,访问者决定做什么

Console.WriteLine($"总面积:{calculator.TotalArea:F2}");   // 24.57

要点与坑:

1)它的取舍和「给类加方法」正好相反。访问者的好处是新增操作容易(加个访问者类),坏处是新增元素类型困难(IShapeVisitor 和所有访问者都要加上新的 Visit 重载,编译器会一个个报错逼你处理)。所以它适合元素类型稳定、操作经常增加的场景,比如编译器 AST、报表导出、文档转换。

2)C# 里经常有更简单的替代。如果只需要对固定几种类型做分派,用 switch 模式匹配(C# 9 起)就够了,不需要双分派那套骨架:

public static double GetArea(IShape shape) => shape switch
{
    Circle c => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
    _ => throw new NotSupportedException($"未知形状:{shape.GetType().Name}")
};

模式匹配在新增元素类型时的表现更好(不需要改所有访问者),代价是它绕开了编译器对「穷尽性」的强制检查,别漏了 _ 分支。

3)访问者会破坏封装。为了让访问者能读到属性,元素常常被迫暴露内部细节。如果元素的字段本该私有,访问者模式要慎用。

五、容易混淆的模式对比

面试和代码评审里最容易吵起来的就是这几组。它们的代码骨架往往一模一样(持有接口引用 + 委托调用),区别只在意图,而意图决定了后续的扩展方向。

对比分界判断方法
策略 vs 状态策略的算法彼此独立、由客户端选定;状态之间互相知道并主动迁移客户端说「用哪个」→ 策略;客户端只说「发生了什么」→ 状态
适配器 vs 装饰器 vs 代理适配器换接口;装饰器加功能;代理控访问去掉这层壳,功能还完整吗?完整 → 代理;缺失 → 装饰器;接口对不上 → 适配器
工厂方法 vs 抽象工厂前者管一个产品等级,后者管一整套产品族扩产品要改所有工厂 → 抽象工厂;只加一个工厂子类 → 工厂方法
外观 vs 中介者外观是单向入口,子系统之间不通过它通信;中介者是多对多的协调中枢去掉它之后,子系统之间还需要互相调用吗?需要 → 中介者
组合 vs 装饰器组合是一棵树(一个节点持 N 个子节点),装饰器是一条链(每层只包一个)结构形状是分支还是直线
模板方法 vs 策略模板方法用继承定骨架,编译期固定;策略用组合换算法,运行期可变变化的是「流程中的某几步」还是「整个算法」
观察者 vs 中介者观察者是一对多广播,主题不知道订阅者是谁;中介者知道所有参与者并按规则路由需要「按规则谁该收到什么」→ 中介者
命令 vs 备忘录命令的撤销是反向操作,备忘录的撤销是状态回滚能写出一个「逆操作」方法吗?能 → 命令
享元 vs 单例缓存享元共享的是不可变的内在状态,且外在状态由调用方传入;缓存是为了省重复计算共享对象是否只读、是否区分内外状态
桥接 vs 策略桥接在设计期拆开两个都要独立扩展的维度;策略在运行期换一段可选算法被持有的接口本身是不是一个继承体系的一部分

六、C# / .NET 里已经内建的模式

很多模式不需要手写——.NET 基类库和常用框架已经把最成熟的那些实现好了。认识它们有两个好处:一是少造轮子,二是读框架源码时能看懂作者的意图。

场景对应模式具体类型 / 机制
遍历集合迭代器IEnumerable<T> / IEnumerator<T>,yield return 由编译器生成状态机
事件通知观察者event + EventHandler<T>,或 IObservable<T> / IObserver<T>(Rx)
依赖注入单例 / 抽象工厂 / 工厂方法IServiceCollection 的 AddSingleton / AddScoped / AddTransient;IServiceProvider 本身是抽象工厂
HTTP 请求管道责任链DelegatingHandler 链、ASP.NET Core 中间件(RequestDelegate)
流式 IO装饰器GZipStream、BufferedStream、CryptoStream 层层包裹 FileStream
统一数据访问入口外观DbContext、HttpClient、各类 *Client SDK
AOP / 日志 / 事务 / 缓存代理 / 装饰器DispatchProxy、Castle DynamicProxy、Autofac 拦截器
LINQ 查询解释器 / 迭代器 / 策略IQueryable<T> 的表达式树即解释器,Where/Select 是全链延迟执行的迭代器,排序/聚合的比较器是策略(IComparer<T>)
异步任务编排命令Task 把「要执行的动作」对象化,可排队、可取消、可组合(WhenAll)
对象构造建造者StringBuilder、UriBuilder、HostBuilder、WebApplicationBuilder
宿主生命周期模板方法IHostedService:框架按固定时机调用 StartAsync / StopAsync
资源释放—(是语言机制)IDisposable + using,与模式无关,但常被错误归入「工厂」类讨论

七、实践建议

把 23 种模式过完之后,真正要带走的是下面这几条判断原则:

1. 先看到变化,再引入模式。 模式是对变化的封装。如果一段代码从来没有变化过、未来也没有明确的替换需求,为它套上接口 + 工厂 + 策略只会让阅读成本翻倍。判断标准很实际:这个类是不是每次需求变更都要改?改动是不是集中在同一个维度上?

2. 优先用语言特性和框架内置机制。 C# 的委托、event、yield return、record、模式匹配、Func<T>,已经吃掉了策略、观察者、迭代器、原型、访问者的一部分适用场景。能用一行 lambda 表达的策略,不必造三个类。

3. 组合优于继承。 23 种里有 20 种以上依赖组合(持有引用)而非继承。继承的硬伤是编译期绑定、且一个类只能有一个父类,一旦有第二个变化维度就立刻僵住——桥接模式本质上就是为解决这个问题而生的。

4. 分清「为了复用」和「为了隔离」。 工厂/建造者是为了把创建过程隔离出去,外观是为了把子系统隔离出去,代理是为了把访问控制隔离出去。想清楚要隔离的是什么,模式的选择就自然浮现了,而不是反过来先选模式再找地方塞。

5. 警惕三类典型误用。 一是为模式而模式——写了 IUserServiceFactory 但只有一个实现且永不替换;二是模式叠加失控——一个下单流程里出现抽象工厂 + 建造者 + 策略 + 责任链 + 访问者,新人接手要读一整天,这时该做的是拆流程而不是加模式;三是把简单问题抽象化——三个 if 能说清的分支,写成状态机反而没人看得懂。

6. 模式是团队沟通的词汇表。 这是它最被低估的价值。一句「这里用责任链,各校验器串起来」能让整个评审会省下十分钟。前提是团队对模式的理解一致,所以统一术语、明确分界(比如本文第五节的对比表)比会背定义更重要。

最后一点经验:23 种里真正高频的其实只有十来个——单例(多由容器代管)、工厂方法、建造者、适配器、装饰器、外观、代理、责任链、观察者、策略、模板方法。剩下的解释器、访问者、享元、桥接、中介者属于「知道它存在、遇到合适场景能想起来」就够了,不必刻意在项目里找机会用。

参考资料

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区