PHP 8.1预计今年(2021年)11月正式发布,我从beta版本开始就在测试环境试用了,把公司项目的部分模块升级到PHP 8.1,体验了各种新特性。用了两个多月,新特性确实很香,枚举(Enums)、只读属性(Readonly Properties)、纤程(Fibers)、交叉类型(Intersection Types)、First-class Callable Syntax这些,能让代码更简洁、更安全、性能更好。
但升级过程中也踩了不少坑,有些是兼容性问题(旧代码在PHP 8.1下报错或行为变化),有些是新特性本身的坑(用的时候发现和预期不一样),有些是文档没说清楚的地方,还有些是扩展兼容性问题。本文分享我在PHP 8.1新特性实战中踩过的坑,以及对应的解决方案,给打算升级PHP 8.1的朋友一些参考。
先说明一下:我用的是PHP 8.1 beta2版本,正式版发布后有些问题可能已经修复,行为也可能有变化。本文基于我当时的使用体验,具体以正式版为准。我的项目是一个中型的Web应用,用的是Laravel 8框架,主要是业务代码,也有一些底层库和扩展。
一、PHP 8.1有哪些新特性
在说踩坑之前,先简单介绍一下PHP 8.1的主要新特性,让大家有个整体了解。
1. 枚举(Enums)
这是PHP 8.1最重要的新特性之一。终于支持原生枚举了,不用再用类常量或者第三方库了。
enum Status {
case Active;
case Inactive;
case Banned;
}枚举还可以有值(Backed Enums):
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
case Banned = 'banned';
}2. 只读属性(Readonly Properties)
类属性可以声明为readonly,只能在构造函数中赋值,之后不能修改:
class User {
public function __construct(
public readonly string $name,
public readonly int $age,
) {}
}3. 纤程(Fibers)
纤程是一种轻量级的协程,可以在PHP代码中实现异步编程,不需要扩展支持:
$fiber = new Fiber(function (): void {
$value = Fiber::suspend('fiber');
echo "Value used to resume fiber: ", $value, "\n";
});
$value = $fiber->start();
echo "Value from fiber suspending: ", $value, "\n";
$fiber->resume('test');4. 交叉类型(Intersection Types)
和联合类型(Union Types)相对,交叉类型要求参数同时满足多个类型:
function foo(A&B $value) {
// $value 同时是 A 和 B
}5. First-class Callable Syntax
可以用更简洁的语法创建可调用对象:
$callable = $obj->method(...);
$callable = strlen(...);6. 新的初始化器(New in Initializers)
可以在参数默认值、属性默认值、常量等地方使用new:
function foo($bar = new DefaultBar()) {}7. 纯交集类型(Never Type)
新增了never类型,表示函数永远不会返回(抛异常或exit):
function redirect(string $url): never {
header('Location: ' . $url);
exit;
}8. 数组解包支持字符串键(Array Unpacking with String Keys)
PHP 7.4支持了数组解包,但只支持整数键,PHP 8.1支持字符串键了:
$array1 = ['a' => 1];
$array2 = ['b' => 2, 'a' => 3];
$result = [...$array1, ...$array2];
// ['a' => 3, 'b' => 2]9. 其他改进
还有一些其他改进,比如性能优化(JIT改进、内置函数类型声明完善)、弃用一些旧特性(弃用向内部函数参数传递null、弃用自动转换为int的浮点键等)、新增一些内置函数(arrayislist等)。
这些新特性看起来都很好,但实际用起来,坑也不少。下面就说说我踩过的那些坑。
二、枚举(Enums)的坑
枚举是我用得最多的新特性,也是踩坑最多的。原生枚举确实好用,但有很多细节需要注意。
坑1:枚举不能继承,也不能被继承
我一开始想定义一个基础枚举类,然后让其他枚举继承它,复用一些方法。结果发现,枚举类是final的,不能继承,也不能被继承。
// 这样是不行的
enum BaseEnum {
public function label() {
return '...';
}
}
enum Status extends BaseEnum { // 报错:Enums cannot extend
case Active;
}解决方案: 用trait来复用代码。把公共方法放到trait里,然后在枚举里use这个trait:
trait EnumLabel {
public function label() {
return match($this) {
// ...
};
}
}
enum Status {
use EnumLabel;
case Active;
case Inactive;
}虽然不如继承方便,但至少能复用代码。
坑2:枚举的比较要用===,不能用==
枚举的比较,用==和===结果可能不一样。我一开始用==比较两个枚举值,发现有时候结果不对。
enum Status {
case Active;
case Inactive;
}
$a = Status::Active;
$b = Status::Active;
var_dump($a == $b); // bool(true)
var_dump($a === $b); // bool(true)看起来没问题?但如果是不同枚举的同名case,用==比较可能会出问题:
enum Status1 {
case Active;
}
enum Status2 {
case Active;
}
var_dump(Status1::Active == Status2::Active); // bool(true)!这不对
var_dump(Status1::Active === Status2::Active); // bool(false),这才对用==比较的时候,PHP会把枚举转换成标量值(case的名字),所以不同枚举的同名case用==比较会返回true,这显然是不对的。
解决方案: 比较枚举的时候,一定要用===,不要用==。养成习惯,所有枚举比较都用===。
坑3:枚举不能直接当数组键用
我一开始想把枚举当数组键用,结果发现不行:
enum Status {
case Active;
case Inactive;
}
$array = [
Status::Active => '激活', // 报错:Illegal offset type
Status::Inactive => '未激活',
];枚举对象不能直接当数组键,因为数组键只能是int或string。
解决方案: 如果是Backed Enum(有值的枚举),用它的值当键:
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
}
$array = [
Status::Active->value => '激活',
Status::Inactive->value => '未激活',
];如果是普通枚举(没有值),可以用name属性:
$array = [
Status::Active->name => '激活',
Status::Inactive->name => '未激活',
];或者用SplObjectStorage,但那样就麻烦了,不如直接用value或name。
坑4:枚举的序列化和反序列化
枚举可以序列化,但反序列化的时候要注意:
enum Status: string {
case Active = 'active';
case Inactive = 'inactive';
}
$serialized = serialize(Status::Active);
$unserialized = unserialize($serialized);
var_dump($unserialized === Status::Active); // bool(true)看起来没问题,但如果反序列化的是一个不存在的枚举值,就会报错:
// 假设数据库里存了一个已经被删除的枚举值
$unserialized = unserialize('O:6:"Status":1:{s:5:"value";s:7:"deleted";}');
// 报错而且,枚举在JSON序列化的时候,默认会序列化成对象,不是标量值:
echo json_encode(Status::Active);
// {"name":"Active","value":"active"}这在前后端交互的时候可能不是你想要的,你可能只想传一个字符串"active"。
解决方案:
- 数据库存储枚举的时候,存Backed Enum的value(字符串或整数),不要存序列化后的对象。
- JSON序列化的时候,手动取value:
json_encode(Status::Active->value),或者实现JsonSerializable接口。 - 反序列化的时候,用
Status::from($value)或Status::tryFrom($value),tryFrom在值不存在的时候返回null,不会报错。
$status = Status::tryFrom($valueFromDb);
if ($status === null) {
// 处理无效值
}坑5:枚举的方法不能修改枚举值
枚举是不可变的,不能在方法里修改枚举的值,也不能给枚举添加属性(除非是常量)。
enum Status {
case Active;
case Inactive;
private string $label; // 报错:Enums cannot have properties
public function setLabel(string $label) {
$this->label = $label; // 当然也不行
}
}解决方案: 枚举就是用来表示固定的一组值的,不要试图把它当成普通类用。如果需要可变的状态,用普通类。枚举里可以有常量、方法(只能读,不能写)、trait、实现接口。
三、只读属性(Readonly Properties)的坑
只读属性也是一个很实用的新特性,可以让不可变对象的写法更简洁。但用的时候也有一些坑。
坑1:只读属性只能在构造函数中赋值
只读属性只能在声明时或构造函数中赋值,之后不能修改,包括在类的其他方法里也不能修改:
class User {
public readonly string $name;
public function __construct(string $name) {
$this->name = $name; // 可以
}
public function setName(string $name) {
$this->name = $name; // 报错:Cannot modify readonly property
}
}这个是设计如此,不算坑,但要注意,如果你需要在其他方法里修改,就不能用readonly。
坑2:只读属性不能有默认值(除了构造函数提升)
我一开始想给只读属性一个默认值,结果发现不行:
class User {
public readonly string $name = 'default'; // 报错:Readonly property cannot have default value
}只读属性不能有默认值,因为如果有默认值,那它就不是真正的"只读"了(你可以不赋值,用默认值,那默认值就相当于一个固定值,不是构造时传入的)。
解决方案: 在构造函数中给默认值:
class User {
public function __construct(
public readonly string $name = 'default', // 可以,构造函数提升中的默认值是允许的
) {}
}注意,只有构造函数属性提升中的默认值是允许的,普通声明的只读属性不能有默认值。
坑3:只读属性和继承的问题
子类不能重写父类的只读属性,也不能把父类的普通属性改成只读,反过来也不行:
class ParentClass {
public readonly string $name;
}
class ChildClass extends ParentClass {
public string $name; // 报错:Cannot override readonly property
}这个也是设计如此,属性的只读性是不能在继承中改变的。
坑4:克隆对象时只读属性的问题
克隆对象的时候,只读属性会被复制,但是在__clone方法里不能修改只读属性:
class User {
public function __construct(
public readonly string $name,
) {}
public function __clone() {
$this->name = 'clone'; // 报错:Cannot modify readonly property
}
}如果你需要在克隆的时候修改某些属性,那这些属性就不能是readonly的。
解决方案: 如果需要不可变对象的克隆并修改,可以用"with"方法,返回一个新的对象:
class User {
public function __construct(
public readonly string $name,
public readonly int $age,
) {}
public function withName(string $name): self {
return new self($name, $this->age);
}
}这样虽然不是修改原对象,但能达到类似的效果,而且保持了不可变性。
四、纤程(Fibers)的坑
纤程是PHP 8.1最令人兴奋的新特性之一,它让PHP原生支持了协程,不需要Swoole或ReactPHP等扩展。但纤程是比较底层的特性,用起来也有不少坑。
坑1:纤程不是异步,不会自动并行
很多人以为有了纤程,PHP就可以像Node.js一样自动异步并行IO了,其实不是。纤程只是提供了协程的原语,它本身不会自动并行,也不会自动把阻塞IO变成非阻塞IO。
$fiber1 = new Fiber(function() {
sleep(2); // 这是阻塞的,不会并行
Fiber::suspend();
});
$fiber2 = new Fiber(function() {
sleep(2); // 这也是阻塞的
Fiber::suspend();
});
// 这样执行还是需要4秒,不是2秒
$fiber1->start();
$fiber2->start();上面的代码,两个纤程里的sleep还是阻塞执行的,总时间还是4秒,不是2秒。因为sleep是阻塞函数,它会阻塞整个进程,不是只阻塞当前纤程。
解决方案: 纤程需要配合非阻塞IO才能实现真正的异步并行。你需要用streamsetblocking设置非阻塞,然后用stream_select等待IO就绪,在等待的时候切换纤程。或者用基于纤程的异步框架(比如ReactPHP的纤程版本、AMPHP等),它们已经帮你封装好了非阻塞IO的调度。
简单来说,纤程只是工具,它提供了暂停和恢复的能力,但具体的调度和非阻塞IO还是需要你自己(或框架)来实现。不要以为用了纤程就自动异步了。
坑2:纤程的异常处理
纤程里抛出的异常,会在调用start()或resume()的地方抛出:
$fiber = new Fiber(function() {
throw new Exception('fiber error');
});
try {
$fiber->start();
} catch (Exception $e) {
echo $e->getMessage(); // fiber error
}这个看起来没问题,但要注意,如果纤程已经suspend了,你再调用resume的时候抛出异常,异常的栈追踪可能不太好调试,因为它跨越了纤程的边界。
而且,如果纤程执行完了(没有suspend,直接return了),你再调用resume,会抛出FiberError:
$fiber = new Fiber(function() {
// 没有suspend,直接执行完
});
$fiber->start();
$fiber->resume(); // 报错:Fiber is not suspended解决方案: 调用resume之前,用$fiber->isSuspended()检查纤程是否处于suspended状态:
if ($fiber->isSuspended()) {
$fiber->resume();
}异常处理的时候,要注意捕获跨纤程的异常,并且在调试的时候注意栈追踪的问题。
坑3:纤程不能嵌套启动
一个纤程不能在另一个纤程的执行过程中启动新的纤程?不对,是可以的,但要注意,纤程是可以嵌套的,但是Fiber::suspend()只会暂停当前纤程,不会暂停外层纤程。
这个不算大问题,但要理解纤程的执行模型:纤程是协作式的,只有当前纤程主动调用suspend,才会切换回外层,外层才能执行其他代码。如果当前纤程不suspend,它会一直执行完,外层代码是得不到执行机会的。
坑4:纤程和全局状态的问题
因为纤程可以暂停和恢复,所以在纤程执行过程中修改的全局状态,在suspend之后、resume之前,可能被其他代码修改,resume之后全局状态已经变了,这可能导致意想不到的bug。
$global = 1;
$fiber = new Fiber(function() use (&$global) {
$global = 2;
Fiber::suspend();
echo $global; // 这里可能不是2了,因为suspend期间其他代码可能修改了$global
});
$fiber->start();
$global = 3; // suspend期间修改了全局变量
$fiber->resume(); // 输出3,不是2这个在多协程环境下尤其要注意,全局状态、静态变量、单例对象等,都可能在协程切换期间被修改,导致竞态条件。
解决方案: 尽量避免在纤程中使用全局状态,或者在suspend之前把需要的状态保存到局部变量中,resume之后用局部变量,不要依赖全局状态。如果必须共享状态,要加锁或者用原子操作。
五、交叉类型(Intersection Types)的坑
交叉类型是PHP 8.1新增的类型系统特性,和联合类型相对。用的时候也有一些需要注意的地方。
坑1:交叉类型不能和联合类型混用
交叉类型和联合类型不能同时用在一个类型声明里:
function foo(A&B|C $value) {} // 报错:Cannot mix intersection and union types这个是设计限制,PHP 8.1还不支持混合的交集联合类型(DNF types),可能未来的版本会支持。
坑2:交叉类型的两个类型不能有冲突
如果两个接口有同名的方法,但签名不一样,就不能用交叉类型:
interface A {
public function foo(int $x): int;
}
interface B {
public function foo(string $x): string;
}
function foo(A&B $value) {} // 报错:Cannot use intersection type with conflicting methods因为如果一个对象同时实现A和B,它的foo方法既要接受int又要接受string,返回值既要int又要string,这是不可能的(除非用联合类型,但PHP的方法签名不支持协变/逆变到这种程度)。
坑3:交叉类型只能用类和接口,不能用基本类型
交叉类型只能用于类名和接口名,不能用于int、string、array等基本类型:
function foo(int&string $value) {} // 报错:Intersection types must be class types这个也合理,因为一个值不可能同时是int和string。
六、兼容性问题:旧代码在PHP 8.1下的坑
除了新特性本身的坑,升级PHP 8.1的时候,旧代码也可能出现兼容性问题,有些是因为PHP 8.1弃用了一些旧特性,有些是因为行为变化。
坑1:弃用向内部函数参数传递null
PHP 8.1弃用了向内部函数的非nullable参数传递null的做法。比如,strlen的参数是string,如果你传null,在PHP 8.0及以前会自动转换成空字符串,在PHP 8.1会抛出Deprecated警告,未来的版本会变成TypeError。
strlen(null); // PHP 8.1: Deprecated: strlen(): Passing null to parameter #1 ($string) of type string is deprecated这个改动影响很大,因为很多老代码里都有这种写法,比如从数据库或数组里取一个值,可能是null,直接传给内部函数。
解决方案:
- 给参数加默认值或类型转换:
strlen($value ?? '')或strlen((string)$value)。 - 在传参之前检查是否为null:
if ($value !== null) { strlen($value); } - 可以用静态分析工具(比如PHPStan、Psalm)扫描代码,找出所有可能传null给内部函数的地方,批量修复。
这个改动虽然麻烦,但确实是好事,能让代码更安全,避免隐式类型转换带来的bug。
坑2:弃用自动转换为int的浮点键
PHP 8.1弃用了数组中使用浮点数作为键,浮点数会自动转换成int,现在会抛出Deprecated警告:
$array = [1.5 => 'a']; // PHP 8.1: Deprecated: Implicit conversion from float 1.5 to int loses precision因为浮点数1.5会被转换成int 1,丢失精度,所以现在会警告。
解决方案: 不要用浮点数当数组键,如果需要,手动转换成int或string:
$array = [(int)1.5 => 'a']; // 显式转换,不会警告
// 或者
$array = [(string)1.5 => 'a']; // 用字符串键坑3:$GLOBALS的变化
PHP 8.1对$GLOBALS的访问做了优化,现在访问$GLOBALS['var']不再是引用访问了,这可能导致一些依赖引用行为的代码出问题。
比如:
$a = 1;
$globals = $GLOBALS; // 现在是值拷贝,不是引用
$globals['a'] = 2;
echo $a; // PHP 8.0及以前:2;PHP 8.1:1这个改动是为了性能优化,因为$GLOBALS的引用访问很慢。但如果你的代码依赖$GLOBALS的引用行为,就会出问题。
解决方案: 不要依赖$GLOBALS的引用行为,如果需要修改全局变量,直接用$GLOBALS['var'] = value,不要先拷贝到局部变量再修改。或者用global关键字。
坑4:扩展兼容性问题
升级PHP 8.1的时候,很多第三方扩展可能还不支持PHP 8.1,需要等扩展更新。我升级的时候,就遇到了几个扩展不兼容的问题:
- 某个加密扩展,在PHP 8.1下编译失败,因为用了已经移除的API。
- 某个数据库扩展,加载的时候警告,虽然能用,但有兼容性问题。
- Xdebug、Redis、Swoole等常用扩展,需要升级到支持PHP 8.1的版本。
解决方案:
- 升级之前,先检查所有用到的扩展是否支持PHP 8.1,去官方网站或GitHub看一下。
- 常用扩展(Redis、Xdebug、Imagick等)一般很快就会支持PHP 8.1,升级到最新版本即可。
- 不常用的、年久失修的扩展,可能需要自己修改代码适配,或者找替代方案。
- 先在测试环境升级,测试所有功能,确认没问题再上生产环境。
七、性能相关的坑
PHP 8.1有一些性能优化,但也有一些需要注意的地方。
坑1:JIT的配置变化
PHP 8.0引入了JIT,PHP 8.1对JIT做了改进,但配置方式也有一些变化。我升级的时候,原来的php.ini里的JIT配置在PHP 8.1下行为不一样了,性能反而下降了。
解决方案: 升级后重新测试JIT的配置,根据你的应用场景(Web应用还是CLI脚本、CPU密集还是IO密集)选择合适的JIT模式(function还是trace)和缓冲区大小。对于大部分Web应用,JIT的提升不大,甚至可能因为编译开销而变慢,需要实际测试才能确定要不要开JIT。
坑2:新特性的性能开销
有些新特性是有性能开销的,比如枚举、只读属性、纤程等,虽然开销不大,但在性能敏感的场景下要注意。
比如,枚举的比较和转换,比纯字符串或整数要慢一些;纤程的创建和切换也有一定的开销。
解决方案: 在性能敏感的地方(比如热点代码、循环里),评估新特性的性能开销,必要的时候用传统写法。但大部分情况下,新特性的性能开销很小,不值得为了这点性能牺牲代码的可读性和安全性。
八、升级建议和最佳实践
说了这么多坑,最后给打算升级PHP 8.1的朋友一些建议。
1. 先在测试环境升级,充分测试
不要直接在生产环境升级,先在测试环境升级,把所有功能都测试一遍,包括单元测试、集成测试、手动测试。重点关注:
- 兼容性问题(弃用警告、行为变化)。
- 扩展兼容性。
- 新特性的使用是否正确。
- 性能是否有变化。
测试没问题了,再灰度上生产环境,先上一台机器,观察一段时间,没问题再全量升级。
2. 用静态分析工具扫描代码
升级之前,用PHPStan、Psalm等静态分析工具扫描代码,把级别调高,找出潜在的类型问题、兼容性问题,提前修复。尤其是"向内部函数传null"这种问题,静态分析工具能很好地找出来。
3. 逐步使用新特性,不要一次性全改
升级到PHP 8.1之后,不要一次性把所有代码都改成用新特性,先在新代码里用,然后逐步重构旧代码。这样可以减少引入bug的风险,也能让团队慢慢适应新特性的写法。
4. 关注正式版的发布说明
本文是基于beta版本的体验,正式版发布后,有些问题可能已经修复,行为也可能有变化。升级之前,一定要仔细阅读正式版的发布说明(UPGRADING文件),了解所有的变化和弃用项。
5. 做好回滚准备
升级之前,做好回滚准备,比如保留旧版本的PHP二进制文件和配置,万一升级后出问题,可以快速回滚。数据库、代码等也要做好备份。
九、写在最后
PHP 8.1是一个非常重要的版本,枚举、只读属性、纤程、交叉类型这些新特性,能让PHP代码更现代、更安全、更简洁。我用了两个多月,整体体验还是很好的,新特性确实能提升开发效率和代码质量。
但升级过程中也确实踩了不少坑,有些是新特性本身的细节,有些是兼容性问题,有些是扩展问题。本文把我踩过的坑都记录下来,希望能给打算升级PHP 8.1的朋友一些参考,让大家少走弯路。
总的来说,PHP 8.1值得升级,尤其是对于新项目,可以直接上PHP 8.1,用新特性写代码。对于旧项目,建议在测试环境充分测试后,逐步升级,不要着急。
PHP这几年的发展很快,从PHP 7.0到PHP 8.0再到PHP 8.1,语言特性越来越完善,性能越来越好,已经不是以前那个"草根语言"了。作为PHPer,看到PHP的发展,还是很欣慰的。希望PHP未来能越来越好,也希望大家都能顺利升级到PHP 8.1,享受新特性带来的便利。
最后,用一句话结束本文:"新特性很香,但升级需谨慎,测试充分再上生产。"祝大家升级顺利,少踩坑。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录