前言
需要先纠正一个概念:策略模式不是 PHP 8.5 的特性。它是《设计模式》里提出的对象行为型模式,与语言版本无关,用 PHP 5 也能写,用 PHP 8.5 也能写。之所以标题里带着"PHP8.5",更合理的理解是"在 PHP 8.5 环境下怎么写策略模式"——本文就按这个角度来写,并且在实现里用上 PHP 8.0 到 8.5 这一路的语言特性(构造器属性提升、枚举、readonly、#[\Override]、clone with、管道操作符),让代码比十年前那版教科书实现短得多、也安全得多。
策略模式要解决的问题非常具体:同一件事有多种算法,且需要在运行时决定用哪一种。运费计算、支付渠道、加密方式、图片压缩、折扣规则,都属于这一类。常见的坏味道是把它们写成一长串if / else if / else或switch,函数随业务增长越来越长,每加一种算法都要改动它、重测它。
本文给出一个能直接落地的实现:策略接口 + 枚举标识 + 工厂分派 + 上下文持有,并配一份可运行的完整示例,最后说明什么情况下不该用它。
一、先看反面教材:switch是怎么烂掉的
<?php // ❌ 反面教材:新增一种渠道就要动这个函数,分支只会越来越长 function calcShipping(string $channel, float $weightKg, float $amount): float { switch ($channel) { case 'standard': return 8.0 + $weightKg * 2.0; case 'express': return 20.0 + $weightKg * 6.0; case 'free': return 0.0; default: throw new InvalidArgumentException("未知渠道: {$channel}"); } }它能跑,问题在于三点:对扩展不封闭(每加一种渠道都要改这个函数,改 A 可能碰坏 B)、测试无法隔离(想单独测"顺丰特快"得跑完整个函数路径)、依赖无法注入(要调运价接口、读配置时只能靠全局变量,没法替换成测试替身)。
策略模式的做法是把"选择算法"和"实现算法"分开:算法各自成为一个实现同一接口的类,选择交给工厂,使用方只持有接口。
二、策略模式的三块积木
| 角色 | 职责 | 本文中的实现 |
|---|---|---|
| Strategy(策略接口) | 定义所有算法共同的方法签名 | interface ShippingStrategy |
| ConcreteStrategy(具体策略) | 各自实现一种算法 | StandardShipping、ExpressShipping、FreeShipping |
| Context(上下文) | 持有策略,对调用方暴露统一入口 | final class ShippingCalculator |
| 额外的一块:选择器 | 把"用户选择的渠道"翻译成策略对象 | enum ShippingChannel+ShippingStrategyFactory |
教科书版本只有前三块,把选择逻辑留给调用方去new。实践里真正容易出问题的是第四块——选择器。写成new $class或者字符串拼接类名,是最常见的事故来源。用枚举 +match表达式(match是 PHP 8.0 引入的)就稳得多:新增渠道时match的分支写不全,运行时会抛UnhandledMatchError,而不是静默地走到某个default里算错钱。
三、完整可运行示例
下面这份代码需要PHP 8.3 及以上(用到了#[\Override]属性,它是 PHP 8.3 引入的)。把两处#[\Override]注释掉就可以在 PHP 8.1 上运行。
<?php // shipping-strategy.php —— 需要 PHP 8.3+(去掉 #[\Override] 后 PHP 8.1 亦可) // 用法:php shipping-strategy.php declare(strict_types=1); /** 订单:策略所需的输入数据由调用方传入,策略本身不读全局状态 */ final readonly class Order // readonly 类是 PHP 8.2 引入的 { public function __construct( public string $id, public float $weightKg, public float $amount, ) {} } /** 策略接口:所有算法必须提供名称与运费计算两个能力 */ interface ShippingStrategy { public function name(): string; /** 返回运费(元) */ public function cost(Order $order): float; } final class StandardShipping implements ShippingStrategy { private const BASE = 8.0; private const PER_KG = 2.0; #[\Override] // PHP 8.3:编译期校验确实实现了接口方法 public function name(): string { return '标准快递'; } #[\Override] public function cost(Order $order): float { return self::BASE + $order->weightKg * self::PER_KG; } } final class ExpressShipping implements ShippingStrategy { private const BASE = 20.0; private const PER_KG = 6.0; public function name(): string { return '顺丰特快'; } public function cost(Order $order): float { return self::BASE + $order->weightKg * self::PER_KG; } } final class FreeShipping implements ShippingStrategy { public function name(): string { return '满额包邮'; } public function cost(Order $order): float { return 0.0; } } /** 渠道枚举:对外的"选项",也是策略的唯一入口(枚举是 PHP 8.1 引入的) */ enum ShippingChannel: string { case Standard = 'standard'; case Express = 'express'; case Free = 'free'; public function label(): string { return match ($this) { self::Standard => '标准快递(3-5 天)', self::Express => '顺丰特快(次日达)', self::Free => '包邮', }; } /** 把用户输入的字符串转成渠道,非法值返回 null 由调用方处理 */ public static function parse(?string $raw): ?self { return self::tryFrom((string) $raw); } } /** 工厂:负责"渠道 -> 策略"的映射,并缓存无状态策略实例 */ final class ShippingStrategyFactory { /** @var array<string, ShippingStrategy> */ private static array $cache = []; public static function fromChannel(ShippingChannel $channel): ShippingStrategy { // 无状态策略可以安全复用,避免每个请求都 new 一遍 return self::$cache[$channel->value] ??= match ($channel) { ShippingChannel::Standard => new StandardShipping(), ShippingChannel::Express => new ExpressShipping(), ShippingChannel::Free => new FreeShipping(), }; } } /** 上下文:调用方只跟它打交道,不关心用的是哪种算法 */ final class ShippingCalculator { public function __construct( private ShippingStrategy $strategy = new StandardShipping(), ) {} /** 运行时切换算法 */ public function useChannel(ShippingChannel $channel): void { $this->strategy = ShippingStrategyFactory::fromChannel($channel); } /** 统一出口:报价 */ public function quote(Order $order): array { $cost = $this->strategy->cost($order); return [ 'order' => $order->id, 'strategy' => $this->strategy->name(), 'cost' => round($cost, 2), ]; } } // —————————— 使用 —————————— $order = new Order('SO20260929001', weightKg: 3.5, amount: 299.0); // 命名参数是 PHP 8.0 的 $calculator = new ShippingCalculator(); // 默认标准快递 foreach (ShippingChannel::cases() as $channel) { $calculator->useChannel($channel); $result = $calculator->quote($order); printf("%-12s %-24s 运费 %.2f 元\n", $result['order'], $result['strategy'], $result['cost']); } // 用户提交的渠道:非法值必须显式处理,不能默认成某个算法 $input = $_GET['channel'] ?? 'express'; $channel = ShippingChannel::parse($input); if ($channel === null) { fwrite(STDERR, "非法渠道: {$input}\n"); exit(1); } $calculator->useChannel($channel); echo json_encode($calculator->quote($order), JSON_UNESCAPED_UNICODE), PHP_EOL;输出:
SO20260929001 标准快递 运费 15.00 元 SO20260929001 顺丰特快 运费 41.00 元 SO20260929001 满额包邮 运费 0.00 元 {"order":"SO20260929001","strategy":"顺丰特快","cost":41}这段代码有三个值得说的设计决定:
1. 策略只接收数据参数,不读全局状态。cost(Order $order)的入参就是全部输入,测试一个策略只需要构造一个Order,不需要起 HTTP 环境。
2. 有状态的依赖走构造函数注入。若某个策略要调运价接口,就写成new ExpressShipping($rateClient),工厂里从容器取依赖;策略内部绝不new,否则无法替换成测试替身。
3. 无状态策略缓存复用,枚举是唯一的"渠道"来源。工厂里的??=缓存让同一渠道始终拿到同一实例;前端下拉框用ShippingChannel::cases()生成,后端解析用tryFrom(),两边不可能对不上。
想让上下文不可变?用 PHP 8.5 的clone with
上面的useChannel()是可变写法。若希望上下文不可变(如在常驻内存的框架里避免状态串味),PHP 8.5 提供了clone with语法,可在复制对象的同时替换属性:
<?php // 需要 PHP 8.5+ // 概念示例:不改动原对象,返回一个换了策略的新上下文 final class ShippingCalculator { public function __construct( public readonly ShippingStrategy $strategy = new StandardShipping(), ) {} public function withChannel(ShippingChannel $channel): self { return clone $this with { strategy: ShippingStrategyFactory::fromChannel($channel) }; } } $base = new ShippingCalculator(); $express = $base->withChannel(ShippingChannel::Express); // 原对象不受影响clone with省掉了为每个属性手写withXxx()的样板代码。在 PHP 8.5 之前的版本里,等价写法是自己写构造函数复制:return new self(ShippingStrategyFactory::fromChannel($channel));。
策略也可以就是一个可调用对象
如果算法短到不值得为它建一个类,可以用 PHP 8.1 引入的一等可调用语法(first-class callable syntax,形如strtolower(...))把函数包成策略:写一个构造函数接收Closure $costFn的包装类,cost()里调用($this->costFn)($order)即可。它是策略模式的退化形态,只在算法只有一两行时才划算。
顺带一提 PHP 8.5 的管道操作符(|>),它能让"数据依次经过多个函数"读起来更顺($slug = $s |> strtolower(...) |> trim(...);),但它不替代策略模式——管道解决的是"同一份数据的处理流水线",策略解决的是"同一件事的不同实现,运行时二选一"。
四、什么时候不该用策略模式
策略模式是有成本的:多一个接口、多 N 个类、多一层间接。满足下面任意一条时,直接用match就够了,别过度设计:
- 算法只有一种,或者未来可预见的只有两种,而且都不会有独立依赖。
- 算法短到一行就能写完,且不需要单独测试(比如
max(0, $x)这种)。 - 分支之间共享大量状态,拆开之后反而要在多个类之间来回传参。
下表是三种写法的选择依据:
| 写法 | 适用场景 | 扩展方式 | 可测试性 |
|---|---|---|---|
if / else链 | 2~3 个简单分支,无依赖 | 改原函数 | 差,分支耦合在一起 |
match直接分派 | 分支是纯计算、无外部依赖、不需要单独替换 | 加一个分支 | 中,可整函数测 |
| 策略模式 | 算法有独立依赖、需运行时切换、需独立测试与单独发布 | 加一个类 + 一个枚举 case | 好,每个策略单独测 |
常见坑点
1. 上下文里写if判断具体类型,等于没解耦
// ❌ 上下文知道了所有具体策略,新增一种就要改这里 public function quote(Order $o): float { if ($this->strategy instanceof ExpressShipping) { return $this->strategy->cost($o) * 1.1; } return $this->strategy->cost($o); }// ✅ 把差异放进策略内部,上下文只调接口方法 public function quote(Order $o): float { return $this->strategy->cost($o); }2. 用字符串拼类名来创建策略
// ❌ 拼错了要到运行时才炸,而且允许外部输入构造任意类 $class = 'App\\Shipping\\' . ucfirst($_GET['channel']) . 'Shipping'; $strategy = new $class();// ✅ 用枚举白名单 + match,非法输入在解析阶段就被拦掉 $channel = ShippingChannel::tryFrom((string) $_GET['channel']) ?? throw new InvalidArgumentException('非法渠道'); $strategy = ShippingStrategyFactory::fromChannel($channel);3. 策略方法签名不统一,导致无法互换
// ❌ 一个返回 float,一个返回 ['cost' => ...],换进去就报错 class A implements ShippingStrategy { public function cost(Order $o): float {} } class B implements ShippingStrategy { public function cost(Order $o): array {} }// ✅ 接口是唯一契约,签名不一致在编译期就会被拒绝(返回类型不兼容是致命错误) interface ShippingStrategy { public function cost(Order $o): float; }4. 策略内部直接读超全局变量
// ❌ 策略不可测、不可复用,换一个入口(CLI/队列)就废 public function cost(Order $order): float { return $order->weightKg * (float) ($_POST['rate'] ?? 1); }// ✅ 依赖通过构造函数注入,数据通过方法参数传入 public function __construct(private float $ratePerKg = 2.0) {} public function cost(Order $order): float { return $order->weightKg * $this->ratePerKg; }5. 把数据塞进上下文,导致策略无法独立复用
// ❌ 策略必须依赖上下文才能工作,等于把两者焊死了 final class ShippingCalculator { public array $orderData = []; public function quote(): float { return $this->strategy->cost($this); } }// ✅ 数据用参数传递,策略只依赖自己声明的输入类型 public function quote(Order $order): float { return $this->strategy->cost($order); }6. 上下文暴露了 setter/getter,外部随意改动算法
// ❌ 任何拿到上下文的地方都能换掉算法,出问题极难定位 $calc->setStrategy(new WhateverStrategy());// ✅ 切换只能通过受控入口(枚举)完成;或干脆用不可变的 withChannel() $calc = $calc->withChannel(ShippingChannel::Express);7. 枚举渠道新增了 case,match忘了补分支
// ❌ 新增 case 后运行时抛 UnhandledMatchError,报错点离真正的改动很远 $strategy = match ($channel) { ShippingChannel::Standard => new StandardShipping(), ShippingChannel::Express => new ExpressShipping(), }; // ✅ 要么穷举所有 case,要么在 default 里显式抛出带上下文的异常 default => throw new LogicException("未实现的渠道: {$channel->value}"),总结
| 关注点 | 结论 |
|---|---|
| 策略模式是否 8.5 新特性 | 不是,它是与语言版本无关的设计模式 |
| 核心结构 | 策略接口 + 具体策略 + 上下文 + 选择器(枚举/工厂) |
| 选择器怎么写 | 枚举做白名单,match做分派,工厂做缓存 |
| 依赖与数据怎么进来 | 依赖走构造函数注入,数据走方法参数,都不读全局状态 |
| 8.x 语言特性收益 | 枚举保证选项合法、match保证穷举、readonly保证不可变 |
| 8.5 相关语法 | clone with做不可变切换,`\ |
| 何时不用 | 分支少、纯计算、无独立依赖,直接match即可 |
结论:策略模式的本质是把"选择"和"实现"分开,让新增算法变成"加一个类",而不是"改一个越来越长的函数"。在 PHP 8.5 上写它,代码量比教科书版本小得多——枚举管住合法选项,match保证穷举,readonly与clone with守住不可变。但它是有成本的:只有当算法真的会独立演进、独立测试、独立注入依赖时,这层间接才有价值;否则一个match就是最优解。