标题说用了三年,其实是夸张的说法。PHP 8.1还没正式发布(预计2021年11月发布),我从beta版本就开始试用了,用了几个月,感触很深,感觉像用了三年一样。

PHP 8.1引入了很多期待已久的新特性:枚举(Enums)、只读属性(Readonly Properties)、纤维(Fibers)、交叉类型(Intersection Types)、never返回类型、first-class可调用语法、数组解包支持字符串键名、new初始化器、final类常量等。这些新特性不只是语法糖,更改变了我们写PHP的方式,让PHP代码更现代、更安全、更有表达力。

我从PHP 8.1的alpha版本就开始关注,beta版本发布后就开始在测试环境和个人项目中试用。用了几个月,踩了一些坑,也有很多感悟。本文分享我试用PHP 8.1新特性的经验和感悟,聊聊这些新特性能解决什么问题,以及使用时需要注意什么。

一、枚举(Enums):终于等到你

枚举是PHP社区期待了很多年的特性,终于在PHP 8.1中实现了。

什么是枚举?

枚举是一种特殊的类,用来定义一组固定的常量值。比如订单状态(待支付、已支付、已发货、已完成、已取消)、用户角色(管理员、编辑、普通用户)、HTTP方法(GET、POST、PUT、DELETE)等。这些场景下,值是固定的几个,用枚举来表示比用类常量或者字符串更安全、更有表达力。

在PHP 8.1之前,我们通常用类常量来模拟枚举:

class OrderStatus
{
    const PENDING = 'pending';
    const PAID = 'paid';
    const SHIPPED = 'shipped';
    const COMPLETED = 'completed';
    const CANCELLED = 'cancelled';
}

但这种方式有很多问题:

  • 类型不安全,函数参数可以传任何字符串,不会报错
  • 没有统一的类型,OrderStatus::PENDING和一个普通字符串'pending'是一样的
  • 不能给枚举值加方法,逻辑只能写在别的地方
  • 不能枚举所有值,需要自己维护列表

PHP 8.1的枚举解决了这些问题:

enum OrderStatus: string
{
    case PENDING = 'pending';
    case PAID = 'paid';
    case SHIPPED = 'shipped';
    case COMPLETED = 'completed';
    case CANCELLED = 'cancelled';

    public function label(): string
    {
        return match($this) {
            self::PENDING => '待支付',
            self::PAID => '已支付',
            self::SHIPPED => '已发货',
            self::COMPLETED => '已完成',
            self::CANCELLED => '已取消',
        };
    }

    public function canCancel(): bool
    {
        return in_array($this, [self::PENDING, self::PAID]);
    }
}

用了枚举之后,代码更安全了:

function updateOrderStatus(int $orderId, OrderStatus $status)
{
    // $status只能是OrderStatus枚举的一个值,不能传其他值
    // 类型安全,不用担心传错
}

// 调用的时候必须传枚举值
updateOrderStatus(1, OrderStatus::PAID);

// 传字符串会报错
updateOrderStatus(1, 'paid'); // TypeError

枚举的分类。

PHP 8.1的枚举分为两种:

  1. 纯枚举(Pure Enums):没有标量值,只有case名。比如 enum Color { case Red; case Green; case Blue; }
  2. 背枚举(Backed Enums):有标量值(int或string),每个case对应一个标量值。比如上面的OrderStatus就是背枚举,每个状态对应一个字符串。

背枚举可以用value属性获取标量值,用from()tryFrom()方法从标量值转换为枚举值:

echo OrderStatus::PAID->value; // 'paid'

$status = OrderStatus::from('paid'); // OrderStatus::PAID
$status = OrderStatus::from('invalid'); // ValueError异常

$status = OrderStatus::tryFrom('invalid'); // null,不抛异常

枚举的方法。

枚举可以有方法,就像类一样。这是PHP枚举很强大的地方,可以把和枚举相关的逻辑都放在枚举类里,而不是散落在各处。比如上面的label()方法返回状态的中文名称,canCancel()方法判断订单是否可以取消。

枚举还可以实现接口,用trait,功能很强大。

使用枚举的感悟。

用了枚举之后,最大的感受是代码更安全、更清晰了。以前用字符串或者类常量表示状态,很容易传错值,而且IDE不能很好地提示。用了枚举之后,类型安全了,IDE可以自动补全,代码的可读性也提高了。

而且,把和枚举相关的逻辑(比如状态转换、显示名称、权限判断)都放在枚举类里,代码更内聚,更容易维护。以前这些逻辑散落在各个服务类里,现在都集中在枚举类里,找起来很方便。

使用枚举需要注意的问题。

  1. 枚举值不能动态创建。 枚举的case是在定义时固定的,不能在运行时动态添加。如果你的值是动态的(比如存在数据库里的字典),就不适合用枚举。
  2. 背枚举的标量值必须唯一。 每个case的标量值不能重复,否则会报错。
  3. 枚举不能被实例化。 枚举不能用new创建实例,只能用预定义的case。
  4. 枚举的序列化。 枚举实现了Stringable接口(背枚举),可以直接echo。但JSON序列化的时候,默认会序列化成标量值(背枚举)或者一个对象(纯枚举),需要注意。
  5. 数据库存储。 枚举存在数据库里的时候,通常存标量值(int或string),读取的时候用from()转换为枚举对象。可以用ORM的类型转换功能自动转换。
  6. 不要滥用枚举。 枚举适合值固定的场景,如果值会变化或者很多,就不适合用枚举。比如用户等级,如果等级是固定的几个(普通、VIP、SVIP),可以用枚举;如果等级是动态的(存在数据库里可以随时增删),就不要用枚举。

二、只读属性(Readonly Properties):不可变对象的福音

只读属性是PHP 8.1另一个很实用的新特性。

什么是只读属性?

只读属性就是只能在初始化的时候赋值,之后不能修改的属性。用readonly关键字声明:

class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
    ) {}
}

$user = new User(1, '张三', 'zhangsan@example.com');
echo $user->name; // 张三
$user->name = '李四'; // Error:不能修改只读属性

在PHP 8.1之前,要实现只读属性,需要把属性声明为private,然后只提供getter,不提供setter:

class User
{
    private int $id;
    private string $name;

    public function __construct(int $id, string $name)
    {
        $this->id = $id;
        $this->name = $name;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }
}

这样写很啰嗦,每个属性都要写getter。用了只读属性之后,代码简洁了很多,而且属性可以直接访问,不需要getter。

只读属性的好处。

  1. 不可变对象更安全。 只读属性创建之后就不能修改,对象是不可变的。不可变对象更安全,不用担心被意外修改,也更容易调试和测试。
  2. 代码更简洁。 不需要写一堆getter,代码量减少了很多,可读性也提高了。
  3. IDE支持更好。 只读属性可以直接访问,IDE可以自动补全,不需要通过getter方法。
  4. 适合值对象(Value Object)。 值对象(比如Money、DateRange、Address)通常是不可变的,用只读属性来实现非常合适。

使用只读属性需要注意的问题。

  1. 只能在初始化的时候赋值。 只读属性只能在声明的时候或者构造函数里赋值,之后不能修改。如果需要在其他方法里赋值,就不能用只读属性。
  2. 只读属性不能有默认值。 只读属性不能有默认值,因为有默认值就意味着可以不初始化,和只读的语义冲突。
  3. 只读属性不能是static的。 只读属性只能是实例属性,不能是静态属性。
  4. 继承的时候,子类不能覆盖只读属性。 父类的只读属性,子类不能修改为可写,也不能重新声明。
  5. 只读属性和构造函数提升(Constructor Promotion)一起用很方便。 就像上面的例子,在构造函数参数前加readonly,直接提升为只读属性,非常简洁。
  6. 不要为了只读而只读。 如果属性确实需要修改,就不要用只读属性。只读属性适合不可变的对象,不是所有属性都适合只读。

不可变对象的实践。

用了只读属性之后,我开始更多地使用不可变对象。比如值对象、DTO(数据传输对象)、配置对象等,都用不可变对象来实现。

不可变对象的好处是:

  • 线程安全,多线程环境下不用担心被修改
  • 更容易测试,状态固定,测试结果可预测
  • 更容易调试,对象创建之后状态不变,出问题容易定位
  • 可以安全地共享,不用担心被其他代码修改

当然,不可变对象也有缺点,比如修改对象需要创建新对象,会有一定的性能开销。但在大多数场景下,这个开销可以忽略不计,而不可变带来的安全性和可维护性更重要。

三、纤维(Fibers):PHP的协程终于来了

纤维是PHP 8.1最令人兴奋的新特性之一,它给PHP带来了原生的协程支持。

什么是纤维?

纤维是一种轻量级的协程,可以在代码中创建、暂停、恢复。它和线程不同,线程是操作系统调度的,而纤维是在用户态调度的,由PHP代码自己控制什么时候暂停和恢复。

纤维的开销很小,可以创建大量的纤维(几万甚至几十万),而不会像线程那样有很大的开销。

在PHP 8.1之前,PHP的异步编程主要靠扩展(比如Swoole、ReactPHP),用回调或者生成器来实现异步,代码写起来很绕,不容易理解。有了纤维之后,PHP有了原生的协程,可以用同步的写法写异步代码,代码更清晰、更容易理解。

纤维的基本用法。

$fiber = new Fiber(function (): void {
    echo "Fiber开始\n";
    Fiber::suspend(); // 暂停纤维
    echo "Fiber恢复\n";
});

echo "主程序1\n";
$fiber->start(); // 启动纤维,执行到suspend暂停
echo "主程序2\n";
$fiber->resume(); // 恢复纤维,继续执行
echo "主程序3\n";

// 输出:
// 主程序1
// Fiber开始
// 主程序2
// Fiber恢复
// 主程序3

纤维可以传值和返回值:

$fiber = new Fiber(function (string $name): string {
    echo "收到:$name\n";
    $value = Fiber::suspend('中间结果'); // 暂停并返回值
    echo "恢复收到:$value\n";
    return '最终结果';
});

$result1 = $fiber->start('张三'); // 启动纤维,传参'张三',suspend返回'中间结果'
echo $result1 . "\n"; // 中间结果

$result2 = $fiber->resume('继续'); // 恢复纤维,传参'继续',函数返回'最终结果'
echo $result2 . "\n"; // 最终结果

纤维的意义。

纤维本身只是一个底层的协程原语,它提供了创建、暂停、恢复协程的能力,但它本身不提供调度器。也就是说,PHP 8.1的纤维只是基础,真正的异步编程框架(比如基于纤维的事件循环、协程调度器)需要在纤维的基础上构建。

但纤维的意义在于,它给PHP带来了原生的协程支持,以后的异步框架可以基于纤维来构建,而不需要用扩展或者hack的方式。而且,纤维可以和现有的代码很好地共存,不需要大规模重构。

未来,基于纤维的异步框架会越来越多,PHP的异步编程会变得更简单、更主流。

使用纤维需要注意的问题。

  1. 纤维不是多线程。 纤维是协程,不是线程,它是在单个线程里运行的,同一时间只有一个纤维在执行。纤维的暂停和恢复是代码控制的,不是操作系统抢占的。所以纤维不能利用多核CPU,不能用来做并行计算,只能用来做IO密集型的异步编程。
  2. 纤维需要调度器。 纤维本身不提供调度,需要自己写或者用第三方的调度器来管理多个纤维的执行。如果只是用单个纤维,和普通函数没什么区别。
  3. 纤维不能跨线程。 纤维是在单个PHP进程里的,不能跨进程或者跨线程传递。
  4. 纤维的异常处理。 纤维里抛出的异常,会在调用start()或者resume()的时候抛出,需要在主程序里捕获。
  5. 不要在生产环境滥用。 纤维是PHP 8.1的新特性,生态还不成熟,基于纤维的框架还在发展中。生产环境使用要谨慎,建议先在测试环境和个人项目中试用,等生态成熟了再大规模使用。
  6. 和Swoole等扩展的关系。 纤维和Swoole不冲突,Swoole也在适配纤维,未来Swoole的协程可能会基于纤维来实现。纤维是PHP原生的,而Swoole是扩展,提供了更完整的异步编程能力(网络、数据库、定时器等)。两者可以共存,互相补充。

四、交叉类型(Intersection Types):类型系统更完善了

交叉类型是PHP 8.1对类型系统的又一次增强。

什么是交叉类型?

交叉类型用&符号连接多个类型,表示参数或返回值必须同时满足所有类型。比如A&B表示对象必须同时实现了A和B两个接口。

interface Logger
{
    public function log(string $message): void;
}

interface Formatter
{
    public function format(string $message): string;
}

// 参数必须同时实现Logger和Formatter接口
function process(Logger&Formatter $obj): void
{
    $formatted = $obj->format('hello');
    $obj->log($formatted);
}

在PHP 8.1之前,如果需要参数同时实现多个接口,只能定义一个新接口继承多个接口,或者在函数里用instanceof检查。有了交叉类型之后,可以直接在类型声明里表示,更简洁、更安全。

交叉类型和联合类型(PHP 8.0引入,用``连接)是对应的:
  • 交叉类型A&B表示类型同时是A和B,必须同时满足所有类型

交叉类型的使用场景。

交叉类型适合用在需要对象同时具有多个能力的场景。比如:

  • 需要对象同时实现了Logger和Cache接口
  • 需要对象同时是ArrayAccess和Countable
  • 需要对象同时实现了多个接口,才能完成某个操作

使用交叉类型需要注意的问题。

  1. 交叉类型只能用接口和类。 交叉类型的组成部分只能是接口和类,不能用标量类型(int、string等),也不能用void、mixed、never等特殊类型。
  2. 不能有重复的类型。 交叉类型里不能有重复的类型,比如A&A是不允许的。
  3. 不能有冲突的方法。 如果交叉类型的多个接口里有同名但签名不同的方法,会导致冲突,这样的交叉类型不能用。
  4. 交叉类型的参数可以传同时实现了所有接口的对象。 只要对象同时实现了交叉类型里的所有接口,就可以传进去。
  5. 不要滥用交叉类型。 如果交叉类型用得很多,说明可能需要定义一个新接口继承多个接口,或者重构代码。交叉类型适合偶尔使用的场景,如果经常需要同时用某几个接口,还是定义组合接口更好。

五、never返回类型:明确表示函数不会返回

never是PHP 8.1新增的返回类型,表示函数永远不会返回。

什么是never返回类型?

如果一个函数用never作为返回类型,表示这个函数要么抛出异常,要么调用exit/die终止程序,永远不会正常返回。

function abort(string $message): never
{
    throw new RuntimeException($message);
}

function shutdown(): never
{
    echo "程序结束\n";
    exit;
}

在PHP 8.1之前,这种函数通常用void作为返回类型,但void表示函数没有返回值,还是会返回的。而never更准确,表示函数根本不会返回。

never返回类型的好处。

  1. 语义更准确。 对于抛出异常或者终止程序的函数,用never比用void更准确,静态分析工具和IDE可以更好地理解代码。
  2. 帮助静态分析。 静态分析工具可以根据never类型判断代码是否可达,比如调用了never函数之后的代码是不可达的,可以检测出来。
  3. 代码更清晰。 看到never返回类型,就知道这个函数不会正常返回,不需要看函数体就知道。

使用never需要注意的问题。

  1. never函数必须不返回。 如果函数声明了never返回类型,但实际上正常返回了(比如有return语句,或者执行到函数末尾),会报错。
  2. never是所有类型的子类型。 never是PHP类型系统里的底部类型,它是所有类型的子类型。也就是说,never类型的函数可以用在任何需要返回值的地方(虽然它永远不会返回)。
  3. never不能和其他类型联合。 never不能和其他类型组成联合类型,比如never|int是不允许的,因为never已经包含在所有类型里了。
  4. never适合用在抛出异常、终止程序、无限循环的函数。 比如abort()、throwexception()、shutdown()、runforever()等函数。

六、其他实用的新特性

除了上面几个重要的新特性,PHP 8.1还有很多实用的小特性。

1. First-class可调用语法。

以前获取函数的可调用对象,需要用字符串或者数组,比如'strlen'[$obj, 'method'],写法不直观,IDE也不好支持。PHP 8.1引入了first-class可调用语法,可以直接用函数名(...)获取可调用对象:

$callable = strlen(...); // 获取strlen函数的可调用对象
echo $callable('hello'); // 5

$methodCallable = $obj->method(...); // 获取对象方法的可调用对象
$staticCallable = MyClass::staticMethod(...); // 获取静态方法的可调用对象

这种写法更直观,IDE可以更好地支持,也更容易重构。

2. 数组解包支持字符串键名。

PHP 7.4引入了数组解包(spread operator),但只支持整数键名的数组。PHP 8.1开始,数组解包也支持字符串键名了:

$array1 = ['a' => 1, 'b' => 2];
$array2 = ['c' => 3, 'd' => 4];

$result = [...$array1, ...$array2];
// ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]

如果有重复的键名,后面的会覆盖前面的,和array_merge的行为一致。

3. new初始化器。

PHP 8.1允许在参数默认值、属性默认值、常量等地方使用new关键字创建对象:

class Logger
{
    // 属性默认值可以用new
    private Formatter $formatter = new DefaultFormatter();

    // 参数默认值可以用new
    public function log(string $message, Formatter $formatter = new DefaultFormatter()): void
    {
        // ...
    }
}

// 常量可以用new
const DEFAULT_LOGGER = new FileLogger();

以前,默认值只能用标量值,不能用new创建对象,需要在构造函数里初始化。有了new初始化器之后,代码更简洁了。

4. final类常量。

PHP 8.1允许在类常量前加final关键字,表示这个常量不能被子类覆盖:

class ParentClass
{
    final public const VERSION = '1.0';
}

class ChildClass extends ParentClass
{
    const VERSION = '2.0'; // Fatal error:不能覆盖final常量
}

以前,类常量是可以被子类覆盖的,这在某些场景下会导致问题。有了final类常量,可以保护重要的常量不被修改。

5. 八进制整数的显式表示。

以前,PHP的八进制整数用0前缀表示,比如0123,但这种写法容易和十进制混淆,而且如果数字里有8或9,会被静默忽略(比如0128等于10,因为8被忽略了),容易出bug。

PHP 8.1引入了显式的八进制前缀0o(或者0O),和二进制的0b、十六进制的0x一致:

$octal = 0o123; // 八进制,等于十进制的83
$oldOctal = 0123; // 旧写法,仍然支持,但不推荐

新写法更清晰,不容易出错,推荐使用。

七、使用PHP 8.1新特性的感悟和建议

用了几个月PHP 8.1的新特性,有很多感悟,也总结了一些建议。

1. 新特性不是银弹,不要为了用而用。

PHP 8.1的新特性很吸引人,但不是所有项目都适合立刻升级,也不是所有新特性都要用。比如纤维,目前生态还不成熟,生产环境使用要谨慎。比如枚举,如果你的项目里值都是动态的,就不需要用枚举。

建议先在测试环境和个人项目中试用,熟悉新特性,等生态成熟了,再在生产环境中逐步使用。

2. 升级要循序渐进。

从PHP 7.x或者8.0升级到8.1,不要一下子全量升级。建议先在测试环境升级,跑测试,修复不兼容的地方,然后灰度发布,先在部分服务器上升级,观察没问题了再全量升级。

PHP 8.1有一些不兼容的变更,比如:

  • 弃用了Serializable接口的隐式序列化
  • 弃用了autoloader里的requireinclude返回值
  • 弃用了htmlspecialchars()htmlentities()的默认编码
  • 弃用了filtervar()FILTERSANITIZE_STRING过滤器
  • 弃用了mysqli的一些函数

升级之前要仔细看官方的迁移指南,检查代码里有没有用到弃用的功能,提前修改。

3. 新特性能提高代码质量,但也要注意学习成本。

PHP 8.1的新特性能让代码更安全、更清晰、更有表达力,比如枚举、只读属性、交叉类型,都能提高代码质量。但新特性也有学习成本,团队里的每个人都需要学习和适应,才能用好。

建议团队一起学习新特性,制定编码规范,明确哪些新特性可以用,哪些暂时不用,怎么用。避免每个人用的风格不一样,导致代码不统一。

4. 关注生态的发展。

PHP 8.1刚发布的时候,很多框架和库还不支持,升级之后可能会遇到兼容性问题。要关注主流框架(Laravel、Symfony等)和常用库的升级情况,等它们都支持PHP 8.1了,再升级会更顺利。

目前,Laravel 9、Symfony 6等主流框架都已经支持PHP 8.1,生态越来越成熟了,升级的时机也越来越好了。

5. PHP在进步,我们也要进步。

从PHP 7.0的性能革命,到PHP 7.4的类型属性,到PHP 8.0的联合类型、命名参数、match表达式,再到PHP 8.1的枚举、只读属性、纤维,PHP一直在进步,越来越现代、越来越安全、越来越有表达力。

作为PHP开发者,我们也要跟着进步,学习新特性,用新特性写出更好的代码。不要固步自封,总是用老版本的写法。当然,也不要盲目追新,要根据项目的实际情况,合理地使用新特性。

八、写在最后

PHP 8.1是一个很重要的版本,它引入了很多期待已久的新特性,让PHP变得更现代、更安全、更有表达力。枚举、只读属性、纤维、交叉类型、never返回类型,这些新特性不只是语法糖,更改变了我们写PHP的方式。

虽然标题说"用了三年"是夸张的说法,但我从beta版本就开始试用,用了几个月,确实感触很深。这些新特性能解决很多以前PHP开发中的痛点,比如类型不安全、不可变对象写起来麻烦、异步编程不友好等。

当然,PHP 8.1也不是完美的,纤维还需要生态的支持,一些小特性还需要时间来验证。但整体来说,PHP 8.1是一个值得升级的版本,它代表了PHP的发展方向:更现代、更安全、更有表达力。

如果你还在使用PHP 7.x或者8.0,建议关注PHP 8.1,在合适的时候升级。升级之后,你会发现写PHP代码变得更愉快了。

最后用一句话结束本文:"PHP在进步,我们也要进步。"愿我们都能跟上PHP的发展,用新特性写出更好的代码,成为更好的开发者。