1. 起因

项目从 PHP 7.4 升级到 8.1 之后,把之前压着没用的几个新语法都实际用了一遍,记一下体验和几个不算坑但容易搞混的地方。

2. 命名参数,可选参数一多就顺手

1
2
3
4
5
6
7
8
9
function createUser(string $name, string $email, bool $isAdmin = false, bool $sendWelcomeEmail = true) {
// ...
}

// PHP 7 只能这样,中间的默认值想跳过跳不过
createUser('Tom', 'tom@example.com', false, false);

// PHP 8 可以按名字传,顺序随意,还能只传想改的那个
createUser(name: 'Tom', email: 'tom@example.com', sendWelcomeEmail: false);

以前遇到函数签名里有一串 bool 类型的可选参数,调用的地方经常得写一堆没意义的 false, false, true 去占位,命名参数之后这类调用可读性提升明显。唯一要注意的是参数名相当于变成了公开 API 的一部分,如果一个函数是给外部调用的公共方法,改参数名就是 breaking change,这点以前不用太在意,现在得留心。

3. match 表达式,比 switch 严格也更简洁

1
2
3
4
5
6
$status = match ($code) {
200, 201 => 'success',
400, 422 => 'client_error',
500 => 'server_error',
default => 'unknown',
};

switch 相比有两个实际区别:一是 match 内部比较用的是 ===(严格比较),不会出现前面提到的弱类型比较坑;二是 match 是表达式,有返回值,不需要每个分支都手写 break,也不会有忘记写 break 导致 fall-through 的经典错误。唯一要注意的是如果没有命中任何分支、又没写 default,会直接抛 UnhandledMatchError,跟 switch 默认啥都不干的行为不一样,迁移老代码的时候这点容易漏。

4. 构造函数属性提升,DTO 类瘦身明显

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// PHP 7.4 写法
class Point {
private float $x;
private float $y;

public function __construct(float $x, float $y) {
$this->x = $x;
$this->y = $y;
}
}

// PHP 8 写法
class Point {
public function __construct(
private float $x,
private float $y,
) {}
}

项目里一大批 DTO / Value Object 类,之前光是属性声明加构造函数赋值就占了一半篇幅,用属性提升之后代码量直接砍掉不少。需要注意的是属性提升只在构造函数里生效,如果同一个属性还想在其他方法里重新赋值做校验,还是得老老实实写完整的属性声明。

5. readonly 属性,不是深度不可变

PHP 8.1 加的 readonly,用起来体验很好,但有一个容易理解错的地方:

1
2
3
4
5
6
7
8
class Order {
public function __construct(
public readonly array $items,
) {}
}

$order = new Order(['A', 'B']);
$order->items[] = 'C'; // 报错:Cannot modify readonly property

这一层保护是有效的,items 属性本身不能被重新赋值。但如果 items 存的不是标量数组而是对象数组,readonly 保护的只是这个属性”指向哪个对象”,不保护对象内部的状态:

1
2
3
4
5
6
7
8
class Order {
public function __construct(
public readonly OrderItem $mainItem,
) {}
}

$order = new Order(new OrderItem('A'));
$order->mainItem->name = 'B'; // 这行不会报错,OrderItem 内部属性照样能改

跟前面 C# 的 record 踩过的坑其实是同一个道理——readonly/record 保证的都是”顶层引用不能变”,不是”整个对象图不可变”,属性本身如果是可变对象,内部状态还是能被外部改掉。真要不可变,OrderItem 自己的属性也得声明成 readonly

6. 小结

这几个新特性单独看都不复杂,组合起来用(readonly + 构造函数属性提升 + 命名参数)写不可变 DTO 的体验已经很接近 C# 的 record 或者 TypeScript 的 interface。唯一要提醒自己的是 readonly 别想当然地当成”深度不可变”,属性是对象类型的时候该做防御性拷贝还是得做。