PHP 8.5 clone with 实战:用不可变配置对象替代满屏 setter

2026-10-12 0 842

去年重构一个 HTTP 客户端的时候,我把配置对象全改成了 readonly。改完之后 IDE 一片绿,类型系统很满意,只有我自己不满意——因为查错的时候调试器里全是 readonly 报错。

问题出在一句话上:我想改其中一项,其他保持原样。

配置对象只读之后,不可能再写 $config->timeout = 60,只能造一个新的。而在 clone with 出来之前,造一个新的有两种下场,两种都很难看。

先看老写法有多啰嗦

假设有这么个类:

<?php

final class RequestConfig
{
    public function __construct(
        public readonly string $url,
        public readonly string $method = 'GET',
        public readonly int $timeout = 30,
        public readonly array $headers = [],
        public readonly bool $followRedirects = true,
    ) {}
}

用起来很干净,$config->timeout 读者一看就知道是 30 秒。但如果某一处逻辑要换掉其中的 timeout,只能这样写:

$slowConfig = new RequestConfig(
    url: $config->url,
    method: $config->method,
    timeout: 120,
    headers: $config->headers,
    followRedirects: $config->followRedirects,
);

七个属性就得一行行抄。今天加了 $retries,全项目的这种手抄都要跟着改。少抄一个字段、或者顺序写错,编译器不会救你——它只看类型对不对,不看语义。

另一种写法是 clone 加赋值:

$slowConfig = clone $config;
// 这里到这就动不了了,readonly 属性不允许在 clone 之后改

readonly 属性只在 __construct 或者 __clone 内部可写,其他地方一律 Error。所以这种写法在 readonly 类里根本走不通。

也就是说过去这几年,”不可变对象“和”改一个字段”这两件事,在 PHP 里天然是拧巴的。clone with 是来解这个结的。

版本先确认一下

clone with 是 PHP 8.5 的新语法。8.4 及以下写了直接语法报错,不是运行时错误,是文件解析都过不去。先看版本:

php -v
# PHP 8.5.x (cli)

本地没升级就用 Docker 起一个:

docker run --rm -v "$PWD":/app -w /app php:8.5-cli php demo.php

不需要扩展,语法层面的东西。

语法长什么样

最小的一步:

<?php

class Point
{
    public function __construct(
        public int $x = 0,
        public int $y = 0,
    ) {}
}

$p1 = new Point(1, 2);
$p2 = clone $p1 with ['x' => 100];

var_dump($p2->x);  // int(100)
var_dump($p2->y);  // int(2),原值保留

就这一行。原来 $p1 不动,得到一个 $p2,只有 x 变成了 100。

语法上要注意两件事。

第一,with 后面必须跟一个数组字面量,不能是变量。下面这种写法不行:

$overrides = ['x' => 100];
$p2 = clone $p1 with $overrides;  // 语法错误

想动态拼属性名的话得换别的表达方式。这个限制是有意的,写死字面量能让静态分析工具更容易理解你改了什么。

第二,只能改本类定义的属性。父类的 private 属性改不了——因为字面上就不在你可见范围内。想改的话得父类自己提供方法。

readonly 属性可以被覆盖吗

可以。这是 clone with 里最容易被误解的一点。

class Point
{
    public function __construct(
        public readonly int $x = 0,
        public readonly int $y = 0,
    ) {}
}

$p1 = new Point(1, 2);
$p2 = clone $p1 with ['x' => 100];  // 正常

// 但下面这行不行
$p1->x = 100;
// Error: Cannot modify readonly property Point::$x

原因是 clone with 的写入发生在克隆过程之中,属于”对象还没有完全初始化完”的阶段。这个阶段 readonly 属性还是可写的,和 __clone 里能写 readonly 是一个道理。

一旦 clone 完成,读到的对象就是完全冻结的,任何直接赋值都会被挡住。

这个设计很对路——它让不可变对象变成了真正的、彻底冻结的”值”,而不是一堆只读属性拼出来的怪物。这个在以往是得靠别扭的构造参数传递才能实现的东西。

用起来是什么感觉

回到那个 RequestConfig。加几个 with 方法:

<?php

declare(strict_types=1);

final class RequestConfig
{
    public function __construct(
        public readonly string $url,
        public readonly string $method = 'GET',
        public readonly int $timeout = 30,
        public readonly array $headers = [],
        public readonly bool $followRedirects = true,
    ) {}

    public function withTimeout(int $seconds): static
    {
        if ($seconds <= 0 || $seconds > 600) {
            throw new InvalidArgumentException("超时时间必须在 1 到 600 秒之间");
        }
        return clone $this with ['timeout' => $seconds];
    }

    public function withHeaders(array $headers): static
    {
        $merged = array_merge($this->headers, $headers);
        return clone $this with ['headers' => $merged];
    }

    public function withMethod(string $method): static
    {
        return clone $this with ['method' => strtoupper($method)];
    }
}

调用方:

$base = new RequestConfig('https://api.example.com/v1/users');

$authed = $base
    ->withMethod('post')
    ->withHeaders(['Authorization' => 'Bearer xxx'])
    ->withTimeout(60);

链式调用写得顺,读起来也顺——从上往下就是逐步加持的过程。每一步都返回一个新对象,原始的 $base 一点没变。

和传统的 setter 链相比,最大的差别是不会污染共享状态。很多老代码里 $client->setTimeout(60) 一调,全局所有请求都变成 60 秒了,下一个调用方忘了改回来就出 bug。with* 系列天然不会——每次都是新对象。

让忘记接收返回值直接报错

这类方法有个隐形的坑:

$config->withTimeout(120);
// 少了赋值!

// 后面 $config 还是 30 秒的超时

不报错、不警告,就是这么安静地把你坑了。PHP 8.5 加的 #[NoDiscard] 属性就是干这个的。

use NoDiscard;

final class RequestConfig
{
    // ...

    #[NoDiscard("withTimeout 返回新实例,请接收返回值")]
    public function withTimeout(int $seconds): static
    {
        // ...
    }

    #[NoDiscard]
    public function withHeaders(array $headers): static
    {
        // ...
    }
}

加上之后:

$config->withTimeout(120);
// Warning: The return value of RequestConfig::withTimeout() is expected to be used, 参数提示

执行时会输出一条警告,包含你在属性里写的那个说明。默认是 E_WARNING 级别的,如果项目里挂了错误处理器,它会走 set_error_handler;如果希望它变成致命错误,可以在 php.ini 里调 no_discard.severity 这个配置项。

加不加属性里的那个字符串说明都行,不写也照样警告。但写了提示信息在现场排查时特别省事——尤其当你有十几个 with* 方法、告警一多容易数不清的时候。

我一般在所有返回新实例的”变换类”方法上都挂 #[NoDiscard],包括 with*、map*、to* 这种命名模式的。而返回 void 或者有副作用的方法就不挂,挂了反而误导调用方。

顺带提一句:这个属性在 PHP 8.5 之前的版本里会被当作未定义的属性报错。所以如果你的项目还同时支持多个 PHP 版本,得写得小心点——一般用条件编译或者干脆等全员升级完成之后再上。

嵌套对象怎么处理

如果配置里有嵌套对象,比如 $headers 是个 HeaderBag 对象,事情会稍微复杂一点。因为 clone with 只做浅拷贝,嵌套对象还是同一个引用。想改这个嵌套对象里的东西,得先给它写一个 with* 方法:

final class HeaderBag
{
    public function __construct(
        public readonly array $items = [],
    ) {}

    #[NoDiscard]
    public function with(string $name, string $value): static
    {
        $copy = $this->items;
        $copy[$name] = $value;
        return clone $this with ['items' => $copy];
    }
}

然后在 RequestConfig 里:

#[NoDiscard]
public function withHeaderBag(HeaderBag $bag): static
{
    return clone $this with ['headers' => $bag];
}

用法上可能有点别扭:

$new = $config->withHeaderBag(
    $config->headers->with('X-Trace-Id', 'abc123')
);

但至少每次改动都是显式的,不会出现”改一层,结果影响到别的引用持有的同一个对象”这种玄学问题。

更进阶的做法是把 withHeaders 里那个 array_merge 换成对 HeaderBag 的委托:

#[NoDiscard]
public function withHeaders(array $headers): static
{
    $bag = $this->headers;
    foreach ($headers as $k => $v) {
        $bag = $bag->with($k, $v);
    }
    return clone $this with ['headers' => $bag];
}

看起来有点绕,但这样 HeaderBag 自己的不变性约束就能统一维护在一处,不用在 RequestConfig 里再实现一遍。这个模式在配置对象比较深的时候省事很多。

四个真踩过的坑

__clone 照样会被调用

光看语法容易忘掉这一点。clone with 依然是 clone,如果你定义了 __clone 方法,它也会被调用。顺序上,属性的覆盖和 __clone 的调用具体谁先谁后,我建议别去纠结——写 __clone 的时候就把逻辑写得不要依赖覆盖顺序。

下面这种写法很危险:

public function __clone()
{
    // 假设这里处理 headers
    $this->headers = array_filter($this->headers, ...);
}

如果 clone with 已经把 headers 换成了外部传入的数组,这里的 filter 逻辑是不是还要再跑一遍?两条路径都可能,代码里看不出期望。更清晰的做法是——要么不用 __clone,要么 __clone 里只做与属性无关的初始化(比如重置某个内部的缓存)。

不能改父类 private 属性

这个前面提过一句。实际写的时候经常忘。比如父类定义了一个 private readonly ?LoggerInterface $logger,子类里想通过 clone with 换一个 logger,会直接抛 Error。绕过办法是在父类里加一个 withLogger 方法让子类调用,而不是让子类自己去改。

这个问题会随着继承层次变深而变严重。我现在的习惯是:只要一个类打算被继承,就尽量用 protected 而不是 private 存储属性。封装性稍微松一点点,换来的灵活性值得。

数组里的对象还是共享引用

$config1 = new RequestConfig('https://example.com', headers: ['a' => $obj]);
$config2 = clone $config1 with ['timeout' => 60];

// $config2->headers['a'] 和 $config1->headers['a'] 是同一个对象
$config2->headers['a']->setSomething();  // 会影响到 $config1

clone with 是浅拷贝。这条规则和普通 clone 完全一样,但因为 clone with 看起来更像是在”造一个新对象”,容易让人忘掉里面装的还是老引用。

真要完全隔离,得在 __clone 里手动深拷贝那几个引用字段。但一般来说配置对象里的嵌套项都应该是不可变的小对象,不太会踩到这个坑。如果真踩到了,反过来说明设计上还有该拆的地方。

静态分析工具的跟进程度

PHPStan 和 Psalm 对 clone with 的支持是逐步到位的。刚发的 8.5 版本那阵子,用旧版本的静态分析跑会看到莫名其妙的报错——比如 clone 后返回类型被推断成 object,或者 with 数组里的键被标成”未知属性”。

用之前把工具升到最新版。我记得比较早的几个大版本里,PHPStan 对 static 返回类型的推断就跟不上,把 withTimeout 的结果当作 RequestConfig 处理——链条一长,最后一步的返回类型可能被推断错。升级之后就正常了。

最后一点想法

clone with 解决的不是”能不能写”的问题。这些年 clone 加手抄构造参数也能跑,只是难受。

它解决的是”读代码时的心智负担”。一个 withTimeout(60) 返回新对象,读代码的人不用管它内部是怎么造的、有没有副作用、会不会影响别处——它就是个新对象,改了 timeout 一项,其余原样。

而这个”原样”是编译器保证的,不是靠人肉抄参数、靠代码审查时用眼睛比对。少了一个字段、顺序写反了,这些低级错误直接就从源头上没了。

不可变对象的价值被讨论了十几年的 8.5 终于把工具补齐了。如果你项目里还有大量靠 setter 链维护状态的代码,下次改到的时候,可以顺手考虑往这个方向挪一步。

PHP 8.5 clone with 实战:用不可变配置对象替代满屏 setter
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP 8.5 clone with 实战:用不可变配置对象替代满屏 setter https://www.taomawang.com/server/php/2927.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务