Skip to content
Go back

C# 模板方法设计模式:完整指南与代码示例

当你需要一套固定的算法流程,但每个步骤的具体实现因情况而异,模板方法设计模式(Template Method Pattern)就是为这个场景而生的行为型模式。基类定义算法骨架——固定的执行顺序——把每个步骤的具体实现留给子类来填充。子类只负责细节,不改变整体流程。

这是 GoF(Gang of Four)经典目录里的行为型模式,对应的原则是好莱坞原则(Hollywood Principle):“不要来找我们,我们会去找你”——基类控制流程,子类提供实现。

模式结构

模板方法模式包含两个参与者:

抽象类(Abstract Class):声明模板方法本身,以及它所调用的各个步骤。模板方法通常是非虚拟的(C# 里不加 virtual),或者显式标记为 sealed,防止子类改变步骤顺序。步骤分两种:

具体类(Concrete Classes):继承抽象类,覆写 abstract 方法,按需覆写钩子。绝不覆写模板方法本身——子类只填步骤,不改顺序。

把它理解成菜谱框架:模板方法规定了”预热→备料→烹饪→摆盘→上菜”的顺序,每道菜都遵循这个顺序,但”备料”和”烹饪”的具体做法因菜品而异。框架是固定的,细节是可换的。

C# 基础实现

以数据处理管道为例:不同数据源需要不同的解析和验证逻辑,但整体工作流——加载、验证、转换、导出——始终一致。

定义抽象基类

using System;
using System.Collections.Generic;

public abstract class DataProcessor
{
    // 模板方法:定义固定顺序,子类不能覆写
    public void ProcessData()
    {
        IReadOnlyList<string> rawData = LoadData();
        IReadOnlyList<string> validData = ValidateData(rawData);
        IReadOnlyList<string> transformed = TransformData(validData);

        BeforeExport(transformed); // 钩子

        ExportData(transformed);

        Console.WriteLine("Processing complete.");
    }

    // 必须覆写的抽象步骤
    protected abstract IReadOnlyList<string> LoadData();
    protected abstract IReadOnlyList<string> ValidateData(IReadOnlyList<string> data);
    protected abstract IReadOnlyList<string> TransformData(IReadOnlyList<string> data);
    protected abstract void ExportData(IReadOnlyList<string> data);

    // 可选钩子:默认什么都不做
    protected virtual void BeforeExport(IReadOnlyList<string> data)
    {
        // 子类可以在导出前注入行为,默认为空
    }
}

ProcessData 就是模板方法,它定义了精确的执行顺序:加载→验证→转换→(可选钩子)→导出。子类无法改变这个顺序,只能实现各个步骤。BeforeExport 是钩子,默认为空实现,给子类一个可选的注入点。

创建具体子类

处理 CSV 数据的子类:

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

public sealed class CsvDataProcessor : DataProcessor
{
    private readonly string _filePath;

    public CsvDataProcessor(string filePath)
    {
        _filePath = filePath;
    }

    protected override IReadOnlyList<string> LoadData()
    {
        Console.WriteLine($"Loading CSV from {_filePath}");
        return File.ReadAllLines(_filePath).ToList();
    }

    protected override IReadOnlyList<string> ValidateData(IReadOnlyList<string> data)
    {
        Console.WriteLine("Validating CSV rows...");
        return data
            .Where(line => !string.IsNullOrWhiteSpace(line))
            .ToList();
    }

    protected override IReadOnlyList<string> TransformData(IReadOnlyList<string> data)
    {
        Console.WriteLine("Trimming whitespace from CSV fields...");
        return data
            .Select(line => string.Join(",",
                line.Split(',').Select(f => f.Trim())))
            .ToList();
    }

    protected override void ExportData(IReadOnlyList<string> data)
    {
        Console.WriteLine($"Exporting {data.Count} cleaned CSV rows.");
    }
    // 没有覆写 BeforeExport:使用基类的空实现
}

处理 JSON 数据的子类,额外使用了 BeforeExport 钩子:

public sealed class JsonDataProcessor : DataProcessor
{
    private readonly string _filePath;

    public JsonDataProcessor(string filePath)
    {
        _filePath = filePath;
    }

    protected override IReadOnlyList<string> LoadData()
    {
        Console.WriteLine($"Loading JSON from {_filePath}");
        string content = File.ReadAllText(_filePath);
        return new List<string> { content };
    }

    protected override IReadOnlyList<string> ValidateData(IReadOnlyList<string> data)
    {
        Console.WriteLine("Validating JSON structure...");
        return data
            .Where(json => json.TrimStart().StartsWith("{")
                || json.TrimStart().StartsWith("["))
            .ToList();
    }

    protected override IReadOnlyList<string> TransformData(IReadOnlyList<string> data)
    {
        Console.WriteLine("Normalizing JSON whitespace...");
        return data
            .Select(json => json.Replace("\r\n", "\n"))
            .ToList();
    }

    protected override void ExportData(IReadOnlyList<string> data)
    {
        Console.WriteLine($"Exporting {data.Count} JSON document(s).");
    }

    // 覆写钩子:在导出前备份
    protected override void BeforeExport(IReadOnlyList<string> data)
    {
        Console.WriteLine("Backing up original JSON before export...");
    }
}

JsonDataProcessor 覆写了 BeforeExport 钩子来添加备份行为,CsvDataProcessor 完全不覆写它——依赖默认的空实现。两个类都遵循 ProcessData 定义的算法结构,但各步骤的实现完全不同。

客户端代码:

DataProcessor csvProcessor = new CsvDataProcessor("data/input.csv");
csvProcessor.ProcessData();
// Loading CSV from data/input.csv
// Validating CSV rows...
// Trimming whitespace from CSV fields...
// Exporting 42 cleaned CSV rows.
// Processing complete.

DataProcessor jsonProcessor = new JsonDataProcessor("data/input.json");
jsonProcessor.ProcessData();
// Loading JSON from data/input.json
// Validating JSON structure...
// Normalizing JSON whitespace...
// Backing up original JSON before export...
// Exporting 1 JSON document(s).
// Processing complete.

客户端通过 DataProcessor 抽象类工作,调用 ProcessData,模板方法负责编排。客户端不知道也不关心运行的是哪个具体子类——只知道算法会按正确顺序执行。

钩子和可选步骤

钩子(Hook)是模板方法模式的核心特性。钩子是抽象基类里带有默认实现(通常为空)的虚拟方法,子类可以选择性覆写。

abstract 方法与钩子的区别:

钩子还可以控制流程——常见模式是布尔型钩子,模板方法根据它决定是否执行可选步骤:

public abstract class ReportGenerator
{
    public void GenerateReport()
    {
        GatherData();
        FormatReport();

        if (ShouldIncludeCharts())
        {
            AddCharts();
        }

        PublishReport();
    }

    protected abstract void GatherData();
    protected abstract void FormatReport();
    protected abstract void PublishReport();

    // 布尔钩子:默认不包含图表
    protected virtual bool ShouldIncludeCharts() => false;

    // 行为钩子:默认不添加图表
    protected virtual void AddCharts()
    {
        // Default: no charts added
    }
}

需要图表的子类同时覆写 ShouldIncludeCharts(返回 true)和 AddCharts(提供图表逻辑);不需要图表的子类完全忽略这两个钩子。模板方法仍然是算法流程的唯一权威来源。

与依赖注入集成

在真实的 .NET 应用里,你会想把模板方法类注册到 DI 容器里。模板方法模式与 IServiceCollection 配合得很自然——注册具体子类,按抽象基类或共享接口解析:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

services.AddTransient<DataProcessor, CsvDataProcessor>(
    sp => new CsvDataProcessor("data/input.csv"));

var provider = services.BuildServiceProvider();

var processor = provider.GetRequiredService<DataProcessor>();
processor.ProcessData();

如果需要解析同一基类的多个实现,可以注册 IEnumerable<DataProcessor>

var services = new ServiceCollection();

services.AddTransient<DataProcessor>(
    sp => new CsvDataProcessor("data/input.csv"));
services.AddTransient<DataProcessor>(
    sp => new JsonDataProcessor("data/input.json"));

var provider = services.BuildServiceProvider();

IEnumerable<DataProcessor> processors = provider.GetServices<DataProcessor>();

foreach (DataProcessor processor in processors)
{
    processor.ProcessData();
}

这种方式可以按序处理所有注册的数据源,调用代码不需要知道有哪些具体处理器。新增数据源只需注册一个新子类——编排代码不变。

适合使用的场景

模板方法模式在以下场景里是很好的选择:

不适合使用的场景

如果算法结构本身在各实现之间发生变化——不只是步骤的实现不同,而是步骤的顺序和组合都不同——模板方法就变成了限制而不是帮助。这种情况下,策略模式通常更合适,因为它通过组合替换整个算法,而不是通过继承锁定固定序列。

当继承层次变得很深时也要小心。如果你发现自己在构建多层抽象基类链,每层都添加自己的模板方法钩子,这时应该考虑组合方案是否更好。

模板方法 vs 策略模式

两者都处理”变化的行为”,常被混淆,但解决的是不同的问题:

维度模板方法策略模式
机制继承组合
算法结构编译时由基类固定运行时可整体替换
自定义方式创建新子类覆写步骤注入不同策略对象
控制粒度高,基类控制整个序列低,上下文完全委托给策略

实用判断:

装饰器模式(Decorator)也值得一提:当你想在不修改类的情况下给对象添加行为时,装饰器更合适——它包装现有对象,而不是定义固定的算法骨架。

常见问题

怎么防止子类覆写模板方法?
在 C# 里,非虚拟方法默认就不能被覆写。如果需要更明确,可以把模板方法标记为 sealed(当它在父类里是 virtualoverride 时)。目标是确保子类只能自定义各个步骤,不能改变整体算法流程。

钩子是什么?
钩子是抽象基类里带有默认实现(通常为空)的虚拟方法,子类可以选择性覆写。常见用途包括前置处理、后置处理、日志记录,或控制可选步骤是否执行的布尔判断。

能用接口代替抽象类吗?
技术上可以——C# 8 起接口支持默认方法实现,因此可以在接口里定义模板方法。但抽象类是约定俗成的选择,因为它更清晰地表达了基于继承的设计意图,并为共享状态和辅助方法提供了自然的容身之所。

模板方法违反”组合优于继承”原则吗?
模板方法确实使用继承,但用法是受控的——基类定义固定结构,子类只自定义特定扩展点。只要继承层次保持浅层(抽象基类下只有一层具体子类),模式就工作良好。如果你发现自己在构建深层继承链,或者需要运行时灵活性,再考虑重构到策略模式等组合方案。

怎么支持开闭原则?
模板方法让你通过创建新子类来扩展行为,而不需要修改基类或已有子类。基类里的模板方法对修改是关闭的——顺序不变。新行为通过新的具体类来添加,不触碰已有的经过测试的代码。

小结

C# 的模板方法设计模式是一个务实的行为型模式:锁定算法结构,让各步骤的实现保持灵活。基类持有顺序,子类填入细节,钩子提供可选扩展点——调用代码不需要知道运行的是哪个具体实现。

识别场景的方法很简单:找代码库里多个类遵循同样工作流但步骤实现不同的地方,找那些在类之间重复出现的方法调用序列——那就是等待被提取到模板方法里的算法骨架。用依赖注入管理具体子类,在需要广播通知的场景里搭配观察者模式(Observer Pattern)。

参考


Tags


Previous

C# 封闭层次结构提案:用 closed 修饰符终结 switch 默认分支

Next

FIDES:用信息流控制让 Agent 不再被提示词注入劫持