拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP8.5怎么实现策略模式切换不同算法

PHP8.5怎么实现策略模式切换不同算法

前言

需要先纠正一个概念:策略模式不是 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就是最优解。

返回列表