模板方法 + 策略模式

模板方法 + 策略模式:用一杯奶茶讲透(Python / C++ 双实现)

一句话分工:

模板方法管「什么时候做」,策略管「怎么做」。

很多人学设计模式时,背得出定义,却在真实代码里认不出来。这篇文章换个思路:先讲一家奶茶店怎么干活,再落到代码,最后给你 Python 和 C++ 两套完整可运行的实现。

读完你应该能做到三件事:

  1. 拿到一段陌生代码,能一眼指出哪里是模板、哪里是策略;
  2. 判断一个新需求该"加类"还是"改流程";
  3. 知道 Python 和 C++ 在实现这两个模式时的关键差异。

一、先说结论

两个模式经常一起出现,因为它们的职责正好互补:

模板方法(Template Method) 策略(Strategy)
回答什么问题 什么时候做、按什么顺序做 这一步具体怎么做
靠什么复用 继承(父类定骨架,子类填空) 组合(持有一个可替换的算法对象)
复用的是 顺序 算法
能否运行时替换 不能(类定义时就定死了) 能
GoF 分类 类行为型(Class behavioral) 对象行为型(Object behavioral)

什么时候两个一起用? 当同一块代码里存在两个相互独立的变化维度时:

  • 维度 A:场景 / 来源 / 类型(堂食店、外卖店)→ 用继承(这是对象的"身份")
  • 维度 B:某个环节的算法(红茶、绿茶、乌龙、咖啡)→ 用组合 / 策略(需要自由搭配)

一个维度用继承、一个用组合,类数量从 M × N 降到 M + N。

最实用的判据(可以直接对着代码用)

self.xxx() → 属于模板 (流程自己控制)

参数.xxx() → 属于策略(行为从外面传进来)

下文会反复用到这一条。


二、痛点:为什么不能一路 if-else 或一路继承

奶茶店的真实情况是这样的:

  • 流程永远是那几步(贴在墙上的 SOP):取杯 → 加茶底 → 加糖 → 摇匀 → 加小料 → 封盖 → 叫号
  • 但茶底有 4 种、小料有 4 种 → 菜单上就是 4 × 4 = 16 种组合

如果"每种组合写一个类":

复制代码
              | 珍珠 | 椰果 | 布丁 | 不加
--------------+------+------+------+------
红茶          |  ✔   |  ✔   |  ✔   |  ✔
绿茶          |  ✔   |  ✔   |  ✔   |  ✔
乌龙          |  ✔   |  ✔   |  ✔   |  ✔
咖啡          |  ✔   |  ✔   |  ✔   |  ✔      ← 16 个类

茶底加到 5 种就是 20 个类;再加一个"少冰/去冰"维度就是 40 个。这就是用继承表达两个变化维度的必然结果------类的笛卡尔积爆炸。

如果一路 if-else 呢?就是下面这样,每加一种格式/口味都要回来改这个函数,无法单独测试,流程也散落各处:

python 复制代码
def make(order, tea, topping):
    if tea == "红茶":   ...
    elif tea == "绿茶": ...
    elif tea == "乌龙": ...
    elif tea == "咖啡": ...
    if topping == "珍珠": ...
    # 每加一种,这里就长一节

正确的做法是把这个函数拆成两半 :顺序留在原地(模板),算法搬出去(策略)。


三、场景拆解:SOP 和菜单

把奶茶店的勾选项按"角色"分一分,你会发现它们其实是四类完全不同的东西:

勾选项 选项 角色 用什么表达
流程顺序 取杯→加茶底→加糖→摇匀→加小料→封盖→叫号 模板方法 继承(父类写死)
茶底 红茶 / 绿茶 / 乌龙 / 咖啡 策略 组合(传对象)
小料 珍珠 / 椰果 / 布丁 / 不加 策略 组合(传对象)
糖度 全糖 / 半糖 / 无糖 参数(不是策略!) 方法参数
门店类型 堂食 / 外卖 身份 继承

两个判断口诀:

  • 糖度为什么不做成策略? 因为它只是数据 ,没有"不同的做法"。差异能用"一个值"说完 → 参数;背后是"一套不同的算法" → 策略。把糖度做成 SugarStrategy 是新手最常见的过度设计。
  • 门店类型为什么用继承而不是策略? 因为"我是外卖店"是这个对象的身份(is-a),不会在同一杯奶茶的制作过程中从堂食店变成外卖店。

四、Python 实现

4.1 完整源码

python 复制代码
"""用「奶茶店」讲清 模板方法 + 策略 ------ 单文件可运行

运行:python python/naicha.py

奶茶店的真实样子:
    流程永远是那几步(贴在墙上的 SOP):
        取杯 -> 加茶底 -> 加糖 -> 摇匀 -> 加小料 -> 封盖 -> 叫号
    但茶底有 4 种、小料有 4 种 ------ 菜单上就是 4 x 4 = 16 种组合。

如果"每种组合写一个类"(用继承表达两个维度):16 个类起步,
菜单加一款茶底(4 种变 5 种)就变 20 个类。
本文件:流程 1 个类 + 茶底 4 个 + 小料 4 个 + 门店 2 个 = 11 个类,而且随便加。

四个角色的分工(这是整份代码的灵魂):
    模板方法(继承):管「先做什么、后做什么」 -> MilkTeaMachine.make()
    策略  (组合):管「这一步具体怎么做」     -> TeaBase(茶底)、Topping(小料)
    参数          :只是数据,别做成策略      -> 糖度 sugar
    钩子          :可有可无的额外动作        -> after_topping()
"""
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass


# ===========================================================================
# 顾客点的一杯。注意 size / sugar 是【数据】,不是算法
# ===========================================================================
@dataclass(frozen=True)
class Order:
    size: str = "大杯"
    sugar: str = "半糖"


# ===========================================================================
# 策略 A:茶底 ------ 4 种不同的"泡法",所以是策略,不是参数
# ===========================================================================
class TeaBase(ABC):
    name: str = "?"

    @abstractmethod
    def brew(self) -> str:
        """泡出这一份茶底。"""


class BlackTea(TeaBase):
    name = "红茶"

    def brew(self) -> str:
        return "红茶包泡 3 分钟"


class GreenTea(TeaBase):
    name = "绿茶"

    def brew(self) -> str:
        return "绿茶用 80 度水泡 2 分钟"


class Oolong(TeaBase):
    name = "乌龙"

    def brew(self) -> str:
        return "乌龙用 90 度水泡 4 分钟"


class Coffee(TeaBase):
    name = "咖啡"

    def brew(self) -> str:
        return "萃一份浓缩咖啡液 30ml"


# ===========================================================================
# 策略 B:小料 ------ 4 种不同的"加料动作",同样是策略
# ===========================================================================
class Topping(ABC):
    name: str = "?"

    @abstractmethod
    def add(self) -> str:
        """加小料这个动作。"""


class Pearl(Topping):
    name = "珍珠"

    def add(self) -> str:
        return "舀一勺珍珠"


class CoconutJelly(Topping):
    name = "椰果"

    def add(self) -> str:
        return "加一份椰果"


class Pudding(Topping):
    name = "布丁"

    def add(self) -> str:
        return "放一块布丁"


class NoTopping(Topping):
    """空对象:什么都不加。

    有了它,模板里就不用写 `if 小料 is None` ------ 把"什么都不做"也写成一个策略,
    流程代码就永远是平的。这是策略模式最常用的搭档技巧。
    """

    name = "不加料"

    def add(self) -> str:
        return "(不加小料)"


# ===========================================================================
# 模板方法:奶茶机 ------ 只负责"流程",对红茶绿茶珍珠椰果一无所知
# ===========================================================================
class MilkTeaMachine(ABC):
    """奶茶机上的 SOP。员工不许自己改流程。"""

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        if "make" in cls.__dict__:
            raise TypeError(f"{cls.__name__}: 禁止覆写 make(),流程由奶茶机统一规定")

    # ---------------------------------------------------------------- 模板方法
    def make(self, order: Order, base: TeaBase, topping: Topping) -> str:
        """步骤顺序只写这一遍。茶底/小料是按次传进来的零件(策略)。"""
        steps = [
            self.take_cup(order),          # 固定步骤
            f"茶底:{base.brew()}",         # 策略:怎么泡
            self.add_sugar(order),         # 固定步骤(糖度是参数)
            self.shake(),                  # 固定步骤
            f"小料:{topping.add()}",       # 策略:怎么加
            self.after_topping(),          # 钩子:默认动作,子类可加码
            self.seal(),                   # 固定步骤
            self.call(order, base, topping),   # 抽象步骤:各门店自己的事
        ]
        return "\n".join(f"  {i}. {s}" for i, s in enumerate(steps, 1))

    # ---------------------------------------------------------------- 固定步骤
    def take_cup(self, order: Order) -> str:
        return f"取一个{order.size}"

    def add_sugar(self, order: Order) -> str:
        return f"加糖:{order.sugar}"

    def shake(self) -> str:
        return "摇匀 10 下"

    def seal(self) -> str:
        return "封盖、插吸管"

    # ---------------------------------------------------------------- 钩子
    def after_topping(self) -> str:
        """默认动作:擦一下杯口。想加码的子类覆写它(记得 super())。"""
        return "擦一下杯口"

    # ---------------------------------------------------------------- 抽象步骤
    @abstractmethod
    def call(self, order: Order, base: TeaBase, topping: Topping) -> str:
        """叫号:堂食喊桌号,外卖投柜子。这是门店的固有差异,必须实现。"""


class DineInMachine(MilkTeaMachine):
    """堂食店:只实现必须实现的那一个方法。"""

    def call(self, order, base, topping) -> str:
        return f"叫号:{order.size}{base.name}{topping.name} 好了,请到吧台取"


class DeliveryMachine(MilkTeaMachine):
    """外卖店:叫号方式不同(抽象方法),另外自己加了一道防倒工序(钩子)。"""

    def after_topping(self) -> str:
        return "套上防倒杯套"

    def call(self, order, base, topping) -> str:
        return f"外卖:装袋放入 {order.size} 取餐柜"


class FlagshipMachine(DineInMachine):
    """旗舰店:它"仍然是一家堂食店",所以继承堂食店(is-a),只把钩子加码。

    注意区别:门店类型是"身份" -> 继承;茶底/小料是"算法" -> 策略。
    """

    def after_topping(self) -> str:
        return super().after_topping() + ",再套一个防倒杯套"   # 在默认动作之上加码


# ===========================================================================
# 菜单:名字 -> 具体类。有了它,点单的人不需要认识任何具体类
# ===========================================================================
TEAS: dict[str, type[TeaBase]] = {
    "红茶": BlackTea, "绿茶": GreenTea, "乌龙": Oolong, "咖啡": Coffee,
}
TOPPINGS: dict[str, type[Topping]] = {
    "珍珠": Pearl, "椰果": CoconutJelly, "布丁": Pudding, "不加": NoTopping,
}
MACHINES: dict[str, type[MilkTeaMachine]] = {
    "堂食": DineInMachine, "外卖": DeliveryMachine, "旗舰店": FlagshipMachine,
}


def order(size: str, sugar: str, tea: str, topping: str, where: str = "堂食") -> str:
    """点单入口:顾客只说名字,不关心背后是哪个类。"""
    return MACHINES[where]().make(Order(size, sugar), TEAS[tea](), TOPPINGS[topping]())


if __name__ == "__main__":
    print("=" * 68)
    print("1) 一杯「大杯 / 半糖 / 乌龙 / 珍珠」的完整流程")
    print("=" * 68)
    print(order("大杯", "半糖", "乌龙", "珍珠"))

    print("\n" + "=" * 68)
    print("2) 换茶底、换小料、换门店 ------ 奶茶机代码一行都不用改")
    print("=" * 68)
    print(order("中杯", "无糖", "绿茶", "椰果", "外卖"))
    print()
    print(order("大杯", "全糖", "咖啡", "布丁", "旗舰店"))

    print("\n" + "=" * 68)
    print("3) 为什么用组合而不是继承?看菜单")
    print("=" * 68)
    for tea_name in TEAS:
        print(f"  {tea_name}: " + " / ".join(TOPPINGS))
    print(f"\n  茶底 {len(TEAS)} 种 x 小料 {len(TOPPINGS)} 种 = {len(TEAS) * len(TOPPINGS)} 种组合,"
          f"但只用了 {len(TEAS)} + {len(TOPPINGS)} 个小类。")
    print("  用继承表达?那是 16 个类起步,而且茶底加到 5 种就是 20 个。")

    print("\n" + "=" * 68)
    print("4) 员工想自己改流程?拦住")
    print("=" * 68)
    try:
        class Sneaky(MilkTeaMachine):
            def make(self, order, base, topping):     # 绕过 SOP,自己乱来
                return "我不管流程,直接倒杯水"

    except TypeError as exc:
        print(f"  已拦住:{exc}")

4.2 逐段拆解

① 策略接口:只回答"怎么做"
python 复制代码
class TeaBase(ABC):
    @abstractmethod
    def brew(self) -> str:
        """泡出这一份茶底。"""
  • ABC + @abstractmethod 是一份强制契约 :谁做茶底,谁就必须自己写 brew,否则连对象都造不出来(TypeError: Can't instantiate abstract class ...)。
  • 接口极小------只有 brew() 一行。接口越小,组合越自由。

小料同理,是第二个策略维度:

python 复制代码
class Topping(ABC):
    @abstractmethod
    def add(self) -> str:
        """加小料这个动作。"""
② 模板方法:8 步顺序只写一遍
python 复制代码
def make(self, order: Order, base: TeaBase, topping: Topping) -> str:
    steps = [
        self.take_cup(order),          # 固定步骤
        f"茶底:{base.brew()}",         # 策略:怎么泡
        self.add_sugar(order),         # 固定步骤(糖度是参数)
        self.shake(),                  # 固定步骤
        f"小料:{topping.add()}",       # 策略:怎么加
        self.after_topping(),          # 钩子:默认动作,子类可加码
        self.seal(),                   # 固定步骤
        self.call(order, base, topping),   # 抽象步骤:各门店自己的事
    ]

请注意这里的分工:

  • self.take_cup(...)、self.add_sugar(...)、self.shake()、self.seal() ------ 模板的一部分,流程自己控制;
  • base.brew()、topping.add() ------ 策略 ,base 和 topping 是参数,行为从外面传进来;
  • self.after_topping() ------ 钩子(有默认实现,子类可选覆写);
  • self.call(...) ------ 抽象方法(子类必须实现)。

模板和策略其实在同一条语句里握手:

python 复制代码
f"茶底:{base.brew()}"
#  ↑         ↑
# 模板的活    策略的活
# "第2步要泡茶"  "具体怎么泡"
③ 三种"变化点"的区别(最容易搞混的地方)
写法 谁在变 属于 机制
base.brew() 传给 make() 的对象变 策略 组合
self.call() make() 所在类的子类变 抽象方法 继承
self.after_topping() 同上,但可选 钩子 继承

它们都会让"结果不同",但只有第一种是策略。这也是为什么很多人认不出策略模式------它可能就藏在一次普通的方法调用里。

④ 给模板方法上锁

只要有一个子类顺手覆写了 make(),顺序立刻散回各个子类,设计当场归零。Java 用 final,Python 没有 final,用 __init_subclass__ 做等效守卫:

python 复制代码
def __init_subclass__(cls, **kwargs):
    super().__init_subclass__(**kwargs)
    if "make" in cls.__dict__:
        raise TypeError(f"{cls.__name__}: 禁止覆写 make(),流程由奶茶机统一规定")
⑤ 空对象:把"什么都不加"也写成一个策略
python 复制代码
class NoTopping(Topping):
    name = "不加料"

    def add(self) -> str:
        return "(不加小料)"

有了它,模板里永远不用写 if topping is None,代码永远是平的。空对象是策略模式最实用的搭档技巧。

⑥ 菜单:注册表 + 工厂,别让 if-else 长回来

设计得再好,如果调用方这么写:

python 复制代码
if tea == "红茶":   base = BlackTea()
elif tea == "绿茶": base = GreenTea()

那前面的努力就白费了------变化点只是从"泡茶逻辑"搬到了"选择逻辑"。收口办法是注册表 (TEAS 字典)+ 工厂函数:

python 复制代码
def order(size, sugar, tea, topping, where="堂食"):
    return MACHINES[where]().make(Order(size, sugar), TEAS[tea](), TOPPINGS[topping]())

调用方只认识名字,不认识任何具体类。新增一款茶底,只需要加一个类 + 在字典里登记一行。

4.3 实跑输出

下面是本文生成时实际运行 python/naicha.py 抓下来的输出(不是手抄的):

text 复制代码
====================================================================
1) 一杯「大杯 / 半糖 / 乌龙 / 珍珠」的完整流程
====================================================================
  1. 取一个大杯
  2. 茶底:乌龙用 90 度水泡 4 分钟
  3. 加糖:半糖
  4. 摇匀 10 下
  5. 小料:舀一勺珍珠
  6. 擦一下杯口
  7. 封盖、插吸管
  8. 叫号:大杯乌龙珍珠 好了,请到吧台取

====================================================================
2) 换茶底、换小料、换门店 ------ 奶茶机代码一行都不用改
====================================================================
  1. 取一个中杯
  2. 茶底:绿茶用 80 度水泡 2 分钟
  3. 加糖:无糖
  4. 摇匀 10 下
  5. 小料:加一份椰果
  6. 套上防倒杯套
  7. 封盖、插吸管
  8. 外卖:装袋放入 中杯 取餐柜

  1. 取一个大杯
  2. 茶底:萃一份浓缩咖啡液 30ml
  3. 加糖:全糖
  4. 摇匀 10 下
  5. 小料:放一块布丁
  6. 擦一下杯口,再套一个防倒杯套
  7. 封盖、插吸管
  8. 叫号:大杯咖啡布丁 好了,请到吧台取

====================================================================
3) 为什么用组合而不是继承?看菜单
====================================================================
  红茶: 珍珠 / 椰果 / 布丁 / 不加
  绿茶: 珍珠 / 椰果 / 布丁 / 不加
  乌龙: 珍珠 / 椰果 / 布丁 / 不加
  咖啡: 珍珠 / 椰果 / 布丁 / 不加

  茶底 4 种 x 小料 4 种 = 16 种组合,但只用了 4 + 4 个小类。
  用继承表达?那是 16 个类起步,而且茶底加到 5 种就是 20 个。

====================================================================
4) 员工想自己改流程?拦住
====================================================================
  已拦住:Sneaky: 禁止覆写 make(),流程由奶茶机统一规定

重点看第 2 节:换了茶底、上了外卖、跑了旗舰店,那 8 步的顺序一个字没改。


五、验收:加功能该动哪里?

判断一个设计好不好,最直接的方法就是问"加需求时要改几个地方":

需求 要动的地方 模板改动
加一款"茉莉花茶" 加 1 个 TeaBase 子类 + 注册 1 行 0
加一款"芋泥"小料 加 1 个 Topping 子类 + 注册 1 行 0
加一种"自提柜"门店 加 1 个 MilkTeaMachine 子类 0(策略也不动)
改流程(比如封盖前先贴标签) 只改 make() 里加一行 1 处,全体生效

最后两行就是这两个模式各自的价值:

  • 加算法 → 不用碰流程 → 策略的功劳
  • 改流程 → 只改一处,所有组合一起变 → 模板方法的功劳

六、C++ 实现

C++ 里没有 ABC、没有 @abstractmethod、没有 GC,但这两个模式的骨架一模一样:还是"抽象基类 + 纯虚函数 + 组合"。

6.1 完整源码(C++17)

cpp 复制代码
// naicha.cpp ------ 模板方法 + 策略模式(奶茶店版),对应 Python 的 naicha.py
//
// 编译运行(任选其一):
//   g++     -std=c++17 -O2 -Wall -Wextra -o naicha naicha.cpp && ./naicha
//   clang++ -std=c++17 -O2 -Wall -Wextra -o naicha naicha.cpp && ./naicha
//   cl      /std:c++17 /EHsc /W4 /utf-8 naicha.cpp     (Windows + MSVC -> naicha.exe)
//           注意 /utf-8:本文件含中文字面量,不加它 MSVC 会按系统代码页解释(C4819 或乱码)
//
// 只依赖标准库。

#include <cstddef>
#include <functional>
#include <iostream>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>

// ===========================================================================
// 角色分工(与 Python 版一一对应)
//   模板方法(继承):MilkTeaMachine::make()  ------ 管"先做什么、后做什么"
//   策略  (组合):TeaBase / Topping       ------ 管"这一步具体怎么做"
//   参数          :Order                   ------ 只是数据(糖度别做成策略)
//   钩子          :afterTopping()          ------ 可选动作
// ===========================================================================

// ---------------------------------------------------------------------------
// 订单:纯数据。对应 Python 的 @dataclass(frozen=True)
// 这里用 struct + 默认成员初始值,C++17 下可以直接 Order{} 或 Order{"中杯","无糖"}
// ---------------------------------------------------------------------------
struct Order {
    std::string size  = "大杯";
    std::string sugar = "半糖";
};

// ---------------------------------------------------------------------------
// 策略 A:茶底
//   抽象基类           ≈ Python 的 class TeaBase(ABC)
//   纯虚函数 = 0       ≈ Python 的 @abstractmethod
//   override           ≈ 让编译器帮你检查"到底有没有覆写上"
// ---------------------------------------------------------------------------
class TeaBase {
public:
    virtual ~TeaBase() = default;             // 有虚函数就要有虚析构
    virtual std::string name() const = 0;
    virtual std::string brew() const = 0;     // 只声明"怎么泡"
};

class BlackTea : public TeaBase {
public:
    std::string name() const override { return "红茶"; }
    std::string brew() const override { return "红茶包泡 3 分钟"; }
};

class GreenTea : public TeaBase {
public:
    std::string name() const override { return "绿茶"; }
    std::string brew() const override { return "绿茶用 80 度水泡 2 分钟"; }
};

class Oolong : public TeaBase {
public:
    std::string name() const override { return "乌龙"; }
    std::string brew() const override { return "乌龙用 90 度水泡 4 分钟"; }
};

class Coffee : public TeaBase {
public:
    std::string name() const override { return "咖啡"; }
    std::string brew() const override { return "萃一份浓缩咖啡液 30ml"; }
};

// ---------------------------------------------------------------------------
// 策略 B:小料
// ---------------------------------------------------------------------------
class Topping {
public:
    virtual ~Topping() = default;
    virtual std::string name() const = 0;
    virtual std::string add() const = 0;
};

class Pearl : public Topping {
public:
    std::string name() const override { return "珍珠"; }
    std::string add() const override { return "舀一勺珍珠"; }
};

class CoconutJelly : public Topping {
public:
    std::string name() const override { return "椰果"; }
    std::string add() const override { return "加一份椰果"; }
};

class Pudding : public Topping {
public:
    std::string name() const override { return "布丁"; }
    std::string add() const override { return "放一块布丁"; }
};

// 空对象模式:把"什么都不加"也写成一个策略,流程里就永远不用写 if
class NoTopping : public Topping {
public:
    std::string name() const override { return "不加料"; }
    std::string add() const override { return "(不加小料)"; }
};

// ---------------------------------------------------------------------------
// 模板方法:奶茶机
// ---------------------------------------------------------------------------
class MilkTeaMachine {
public:
    virtual ~MilkTeaMachine() = default;

    // ------------------------------------------------------------------
    // 模板方法:8 步顺序只写这一遍
    //
    // final 的作用:子类一旦想覆写 make(),【编译期】直接报错。
    // 这相当于 Python 里那句 __init_subclass__ 守卫,只是:
    //     Python -> 运行时抛 TypeError
    //     C++    -> 编译期就过不去(更早、更硬)
    //
    // 参数用 const 引用传策略:不拷贝、不持有、不负责生命周期。
    // ------------------------------------------------------------------
    virtual std::string make(const Order& order,
                             const TeaBase& base,
                             const Topping& topping) const final {
        std::vector<std::string> steps;
        steps.push_back(takeCup(order));                    // 固定步骤
        steps.push_back("茶底:" + base.brew());             // 策略:怎么泡
        steps.push_back(addSugar(order));                   // 固定步骤(糖度是数据)
        steps.push_back(shake());                           // 固定步骤
        steps.push_back("小料:" + topping.add());           // 策略:怎么加
        steps.push_back(afterTopping());                    // 钩子
        steps.push_back(seal());                            // 固定步骤
        steps.push_back(call(order, base, topping));         // 抽象步骤

        std::string out;
        for (std::size_t i = 0; i < steps.size(); ++i) {
            out += "  " + std::to_string(i + 1) + ". " + steps[i] + "\n";
        }
        return out;
    }

protected:
    // ---------------- 固定步骤:刻意不写 virtual ----------------
    // 不写 virtual,子类即使定义同名函数也只是"隐藏",多态调用时不会生效。
    // 比 Python 版更严格(Python 里这些方法其实可以被覆写)。
    std::string takeCup(const Order& order) const { return "取一个" + order.size; }
    std::string addSugar(const Order& order) const { return "加糖:" + order.sugar; }
    std::string shake() const { return "摇匀 10 下"; }
    std::string seal() const { return "封盖、插吸管"; }

    // ---------------- 钩子:有自己的默认实现 ----------------
    virtual std::string afterTopping() const { return "擦一下杯口"; }

    // ---------------- 抽象步骤:子类必须实现 ----------------
    virtual std::string call(const Order& order,
                             const TeaBase& base,
                             const Topping& topping) const = 0;
};

// 堂食店:只实现必须实现的那一个
class DineInMachine : public MilkTeaMachine {
protected:
    std::string call(const Order& order,
                     const TeaBase& base,
                     const Topping& topping) const override {
        return "叫号:" + order.size + base.name() + topping.name() + " 好了,请到吧台取";
    }
};

// 外卖店:叫号方式不同(抽象方法),另外加了防倒工序(钩子)
class DeliveryMachine : public MilkTeaMachine {
protected:
    std::string afterTopping() const override { return "套上防倒杯套"; }

    std::string call(const Order& order, const TeaBase&, const Topping&) const override {
        return "外卖:装袋放入 " + order.size + " 取餐柜";
    }
};

// 旗舰店:它"仍然是一家堂食店",所以继承堂食店,只在钩子上加码
// 对应 Python 的 super().after_topping()
class FlagshipMachine : public DineInMachine {
protected:
    std::string afterTopping() const override {
        return DineInMachine::afterTopping() + ",再套一个防倒杯套";
    }
};

// ---------------------------------------------------------------------------
// 菜单:名字 -> 造对象的工厂函数
//   对应 Python 里 dict[str, type] + 工厂函数 的那套注册表
//   用 unique_ptr 表达"这块内存归我管"
// ---------------------------------------------------------------------------
using TeaFactory     = std::function<std::unique_ptr<TeaBase>()>;
using ToppingFactory = std::function<std::unique_ptr<Topping>()>;

const std::map<std::string, TeaFactory>& teaMenu() {
    static const std::map<std::string, TeaFactory> menu{
        {"红茶", [] { return std::make_unique<BlackTea>(); }},
        {"绿茶", [] { return std::make_unique<GreenTea>(); }},
        {"乌龙", [] { return std::make_unique<Oolong>(); }},
        {"咖啡", [] { return std::make_unique<Coffee>(); }},
    };
    return menu;
}

const std::map<std::string, ToppingFactory>& toppingMenu() {
    static const std::map<std::string, ToppingFactory> menu{
        {"珍珠", [] { return std::make_unique<Pearl>(); }},
        {"椰果", [] { return std::make_unique<CoconutJelly>(); }},
        {"布丁", [] { return std::make_unique<Pudding>(); }},
        {"不加", [] { return std::make_unique<NoTopping>(); }},
    };
    return menu;
}

std::unique_ptr<TeaBase> orderTea(const std::string& name) {
    const auto& menu = teaMenu();
    const auto it = menu.find(name);
    if (it == menu.end()) {
        throw std::invalid_argument("不支持的茶底: " + name);
    }
    return it->second();
}

std::unique_ptr<Topping> orderTopping(const std::string& name) {
    const auto& menu = toppingMenu();
    const auto it = menu.find(name);
    if (it == menu.end()) {
        throw std::invalid_argument("不支持的小料: " + name);
    }
    return it->second();
}

int main() {
    try {
        const Order big{"大杯", "半糖"};
        const Order small{"中杯", "无糖"};

        std::cout << "1) 一杯「大杯 / 半糖 / 乌龙 / 珍珠」的完整流程\n";
        DineInMachine dineIn;
        std::cout << dineIn.make(big, Oolong{}, Pearl{});

        std::cout << "\n2) 换茶底、换小料、换门店 ------ 奶茶机代码一行都不用改\n";
        DeliveryMachine delivery;
        std::cout << delivery.make(small, GreenTea{}, CoconutJelly{});

        FlagshipMachine flagship;
        std::cout << flagship.make(big, Coffee{}, Pudding{});

        std::cout << "\n3) 走菜单(注册表 + 工厂):点单的人不需要认识任何具体类\n";
        const auto tea = orderTea("乌龙");
        const auto topping = orderTopping("椰果");
        std::cout << dineIn.make(small, *tea, *topping);

        std::cout << "\n4) 菜单内容\n";
        std::cout << "  茶底:";
        for (const auto& kv : teaMenu()) std::cout << " " << kv.first;
        std::cout << "\n  小料:";
        for (const auto& kv : toppingMenu()) std::cout << " " << kv.first;
        std::cout << "\n  组合数: " << teaMenu().size() << " x " << toppingMenu().size()
                  << " = " << teaMenu().size() * toppingMenu().size() << " 种,"
                  << "但只用了 " << teaMenu().size() << " + " << toppingMenu().size()
                  << " 个策略小类\n";

        std::cout << "\n5) 点一个菜单上没有的?抛异常拦住\n";
        try {
            const auto bad = orderTea("白开水");
            std::cout << "  居然做出来了: " << bad->name() << "\n";
        } catch (const std::exception& e) {
            std::cout << "  已拦住: " << e.what() << "\n";
        }
    } catch (const std::exception& e) {
        std::cerr << "出错了: " << e.what() << "\n";
        return 1;
    }
    return 0;
}

// ---------------------------------------------------------------------------
// 想试试"子类偷改流程"会怎样?把下面这段贴到 main() 之前再编译,
// 编译器会直接拒绝。实测 GCC 13.1.0 的原话:
//   error: virtual function 'virtual std::string Sneaky::make(const Order&, const TeaBase&, const Topping&) const' overriding final function
//   note: overridden function is 'virtual std::string Machine::make(const Order&, const TeaBase&, const Topping&) const'
//
//   class Sneaky : public MilkTeaMachine {
//   public:
//       std::string make(const Order&, const TeaBase&, const Topping&) const override {
//           return "我不管流程,直接倒杯水";
//       }
//       // 纯虚的 call() 也必须实现,否则 Sneaky 自己也是抽象类
//       std::string call(const Order&, const TeaBase&, const Topping&) const override {
//           return "";
//       }
//   };
//
// 这就是模板方法的"锁":Python 靠 __init_subclass__ 在运行时报错,
// C++ 靠 final 在编译期报错 ------ 更早、更硬。
// ---------------------------------------------------------------------------

6.2 编译运行

bash 复制代码
# GCC / MinGW
g++ -std=c++17 -O2 -Wall -Wextra -o naicha naicha.cpp && ./naicha

# Clang
clang++ -std=c++17 -O2 -Wall -Wextra -o naicha naicha.cpp && ./naicha

# Windows + MSVC(生成 naicha.exe)
# 注意 /utf-8:本文件含中文字面量,不加它 MSVC 会按系统代码页解释(C4819 或乱码)
cl /std:c++17 /EHsc /W4 /utf-8 naicha.cpp

Windows 上如果控制台显示中文乱码,先执行 chcp 65001 切到 UTF-8 代码页(只影响显示,不影响程序逻辑)。GCC / Clang 没有这个问题。

实测环境:GCC 13.1.0(MinGW-w64, x86_64-posix-seh) ,-Wall -Wextra 下零警告。

踩坑提示(实测) :Windows 上如果按全路径 调用 g++.exe,却没有把它的 bin 目录加进 PATH,会遇到一个很迷惑的现象------g++ --version 能正常打印版本号,但一编译就静默失败 :退出码 1、stdout / stderr 全是空的、也不生成目标文件。

真实原因是编译器子进程 cc1plus.exe 找不到同目录的 DLL(进程退出码 0xC0000135 = STATUS_DLL_NOT_FOUND)。把 ...\mingw64\bin 加进 PATH 就好了。

6.3 实跑输出

下面是本文生成时实际编译并运行 cpp/naicha.cpp 抓下来的输出(不是手抄的):

text 复制代码
1) 一杯「大杯 / 半糖 / 乌龙 / 珍珠」的完整流程
  1. 取一个大杯
  2. 茶底:乌龙用 90 度水泡 4 分钟
  3. 加糖:半糖
  4. 摇匀 10 下
  5. 小料:舀一勺珍珠
  6. 擦一下杯口
  7. 封盖、插吸管
  8. 叫号:大杯乌龙珍珠 好了,请到吧台取

2) 换茶底、换小料、换门店 ------ 奶茶机代码一行都不用改
  1. 取一个中杯
  2. 茶底:绿茶用 80 度水泡 2 分钟
  3. 加糖:无糖
  4. 摇匀 10 下
  5. 小料:加一份椰果
  6. 套上防倒杯套
  7. 封盖、插吸管
  8. 外卖:装袋放入 中杯 取餐柜
  1. 取一个大杯
  2. 茶底:萃一份浓缩咖啡液 30ml
  3. 加糖:半糖
  4. 摇匀 10 下
  5. 小料:放一块布丁
  6. 擦一下杯口,再套一个防倒杯套
  7. 封盖、插吸管
  8. 叫号:大杯咖啡布丁 好了,请到吧台取

3) 走菜单(注册表 + 工厂):点单的人不需要认识任何具体类
  1. 取一个中杯
  2. 茶底:乌龙用 90 度水泡 4 分钟
  3. 加糖:无糖
  4. 摇匀 10 下
  5. 小料:加一份椰果
  6. 擦一下杯口
  7. 封盖、插吸管
  8. 叫号:中杯乌龙椰果 好了,请到吧台取

4) 菜单内容
  茶底: 乌龙 咖啡 红茶 绿茶
  小料: 不加 布丁 椰果 珍珠
  组合数: 4 x 4 = 16 种,但只用了 4 + 4 个策略小类

5) 点一个菜单上没有的?抛异常拦住
  已拦住: 不支持的茶底: 白开水

可以看到输出和 Python 版逐行对应:同一套 8 步流程、同样的步骤编号,只是第 4 节多了一段菜单信息。

6.4 逐点对照

概念 Python C++
策略接口 class TeaBase(ABC) 抽象基类 class TeaBase
"必须实现" @abstractmethod(运行时拦截) virtual ... = 0(编译期强制)
子类实现 def brew(self) std::string brew() const override
覆写检查 无(签名写错也照跑) override 让编译器帮你查
禁止改模板方法 __init_subclass__ 运行时抛 TypeError virtual ... final 编译期报错
传策略 直接传对象(鸭子类型) const TeaBase& 引用(不拷贝、不持有)
策略的生命周期 GC 管 谁 new 谁负责 → std::unique_ptr
注册表 dict[str, type] std::map<std::string, std::function<std::unique_ptr<TeaBase>()>>
抽象方法能有实现吗 能,子类可 super() 纯虚函数也能有定义(类外定义后可用 Base::f() 调),但一般不这么写

6.5 三个 C++ 特有的注意点

① final vs 运行时守卫:错误发现得更早

cpp 复制代码
virtual std::string make(const Order& order,
                         const TeaBase& base,
                         const Topping& topping) const final
  • Python:子类覆写 make() → 运行时 (定义类那一刻)抛 TypeError;
  • C++:子类覆写被标 final 的 make() → 编译期直接报错。实测 GCC 13.1.0 的原话是:
text 复制代码
error: virtual function 'virtual std::string Sneaky::make(const Order&, const TeaBase&, const Topping&) const' overriding final function
note: overridden function is 'virtual std::string Machine::make(const Order&, const TeaBase&, const Topping&) const'

一个在运行时、一个在编译期,但都达到了"流程不许被子类改"的目的。

值得一提的是:final 只能用在虚函数上。如果你干脆不写 virtual ,子类就算定义了同名函数也只是"名字隐藏",多态调用时根本不会生效------这是另一种(更隐蔽的)锁。本文的做法是显式 virtual ... final,让编译器把错误喊出来。

② 所有权必须自己说清楚

Python 里 machine.make(order, Oolong(), Pearl()) 用完就扔,剩下的交给 GC。C++ 里必须回答"这块内存谁负责":

  • 模板方法只是"用一下"策略 → 传 const TeaBase&(引用),不拷贝、不持有、不负责生命周期。这是最清晰的表达。
  • 策略需要活得更久、需要从菜单里造出来 → 用 std::unique_ptr 明确"独享所有权",orderTea() 返回工厂造出的对象,调用方拿 *tea 使用。

③ 有虚函数就要有虚析构

cpp 复制代码
class TeaBase {
public:
    virtual ~TeaBase() = default;   // 少这一行,delete 基类指针就是未定义行为

Python 里不存在这个问题(GC 按真实类型回收),C++ 里是硬性规定。

6.6 进阶:C++ 还能用"静态多态"做策略

上面的写法是动态多态(虚函数):运行时可替换,代价是一次间接调用(vtable 查表)。

C++ 还有另一种选择------编译期绑定:

cpp 复制代码
// 策略作为模板参数:编译期就确定,可内联,零虚函数开销
template <class Tea, class Top>
class Machine {
public:
    std::string make(const Order& order) const {
        return takeCup(order) + Tea{}.brew() + Top{}.add();
    }
};
// 用法:Machine<Oolong, Pearl> m;

取舍很清楚:

动态多态(虚函数 / 本文实现) 静态多态(模板参数)
何时绑定 运行时 编译期
能否运行时替换 能(菜单、配置、插件必须用它) 不能
开销 一次间接调用,无法内联 可内联,零开销
编译时间 / 报错可读性 好 差一些,代码膨胀

判断标准还是那句老话:策略需要在运行时按名字/配置挑选 → 用虚函数;所有组合在编译期就已知且追求极致性能 → 用模板。Python 里没有这个选择(只有动态派发),这也是 C++ 实现同一个模式时最值得多想一步的地方。


七、什么时候不要用

  • 只有两个分支、而且长期不变:if-else 更便宜也更好读。
  • 只有一个变化维度:要么只用模板方法(差异是"这类东西的身份"),要么只用策略(没有固定流程可复用)。硬凑两个模式 = 过度设计。
  • 团队里没人说得清"哪一步会变":先把需求问清楚,比套模式重要。
  • 需要的是横切关注点(日志、重试、事务):那是装饰器 / AOP 的活,不是策略。

八、常见坑

  1. 模板方法被子类覆写 ------顺序又散回各处。Java 用 final,Python 用 __init_subclass__ 守卫,C++ 用 final。
  2. 在构造期调用可覆写方法 (__init__ 里调 self.render()):此时子类字段还没初始化。骨架要由外部显式调用(本文的 make() 就是显式调用,安全;Java 构造器里调抽象方法是经典事故)。
  3. 继承层级超过 2 层 :CsvExporter → ApiCsvExporter → AuditedApiCsvExporter 一旦出现,说明你在用继承表达变化维度,该换策略 / 装饰器了。
  4. 策略有状态却被复用:同一个策略实例并发跑两次,数据串台。策略尽量无状态;有状态就每次新建。
  5. 策略反向"拉"上下文 (策略里直接访问调用方内部)→ 双向依赖。正确做法是把需要的数据当参数推进去。
  6. 调用方还在 if-elif 里 new 策略:变化点只是搬了个家。用注册表 + 工厂收口。
  7. 提前抽象:只有一个实现就抽策略 = 凭空多一个文件和一层跳转。等第三个分支出现再抽(Rule of Three)。
  8. 命名混乱 :模板方法的名字里不该出现具体算法(叫 exportCsv 就说明还没抽象干净);钩子用 before_* / after_* / on_*,一眼看出是可选的。

九、如何复现本文所有内容

目录结构:

复制代码
template-strategy/
├── python/naicha.py            # 本文的 Python 实现(第 4 节源码)
├── cpp/naicha.cpp              # 本文的 C++ 实现(第 6 节源码)
├── check_env.py                # 一键自检:Python 版本 + 所有示例能否运行
└── blog/
    ├── blog.template.md        # 本文正文:源码和输出都直接写在里面,能直接读
    ├── build_blog.py           # 同步脚本:原地刷新代码块 + 产出发布文件
    └── 模板方法+策略-奶茶店版.md  # ← 生成出来的这篇博客

跑一遍:

bash 复制代码
python python/naicha.py          # 单独看 Python 版
python check_env.py              # 自检环境与全部示例
python blog/build_blog.py        # 重新生成本文(代码与输出自动同步)

为什么还要一个同步脚本? 因为文章里的源码和输出都不是手抄的:build_blog.py 会读真实文件、真的运行 Python、真的编译并运行 C++ ,然后把结果原地刷新回 blog.template.md。

所以 blog.template.md 本身就是一篇可以直接阅读、直接发布的完整文章(代码就写在正文里,不是链接);而改完代码重跑一次脚本,文章里的代码和输出又会自动跟上------不会出现"文章里的代码和仓库不一致"这种经典尴尬。


十、结语

把这两个模式记成一句话就够了:

模板方法负责"安排顺序",策略负责"填内容"。

看代码时:self.xxx() 是模板,参数.xxx() 是策略。

它们的价值不在于"用了什么模式",而在于把变化的边界画清楚了:

  • 想知道"有哪些奶茶" → 看策略类;
  • 想知道"奶茶是怎么做出来的" → 看 make() 那 8 行;
  • 想知道"哪些东西可以换" → 看 make() 的参数表。

一个类的参数表 ,就是它允许你改变的东西;一个类的内部顺序,就是它不允许你改变的东西。设计的好坏,往往就差在这条线的位置。

相关推荐
EatFan1 小时前
2026 Rust 后端技术栈选型:Axum 0.8 + Tokio + SQLx 全链路怎么搭
开发语言·后端·rust·tokio·serde·axum·sqlx
外收内放1 小时前
Python基础语法练习题(59-原始版本与优化版本)
开发语言·python
2401_888859711 小时前
STM32H733 MPU、AXI、FMC学习
java·开发语言·stm32·spring
泡海椒2 小时前
JQuick-Excel 实战:用 STYLE 配置单元格与区域样式
开发语言·python·excel
Sarvartha10 小时前
final 关键字
java·开发语言
fpcc10 小时前
c++编程实践—堆和栈越界调试
c++
程序员Sunday10 小时前
JavaScript 事件循环面试题,宏任务与微任务怎么执行|Sunday面试指南
开发语言·javascript·面试·校招·事件循环·程序员sunday
zhangzeyuaaa11 小时前
Ruby 多线程、GVL(GIL)与 Mutex 完全指南
开发语言·前端·ruby
weiabc11 小时前
MSYS2 UCRT64 + g++ C++ 中文输出乱码 完整全过程
c++