去年重构一个 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 链维护状态的代码,下次改到的时候,可以顺手考虑往这个方向挪一步。

