PHP 8.0 新特性

通过联合类型、命名实参、特性、match、构造器属性提升与 nullsafe 运算符,掌握 PHP 8.0 的核心语义和升级风险。

难度 进阶 时长 标准深度约 15分钟
版本 PHP 8.3.33
what

PHP 8.0 把更多契约写进语言本身:类型可以表达多个合法分支,实参可以按名称传递,元数据可以成为可反射的特性,分支与可空调用也有了专门语法。

trap

新语法不会自动验证外部数据;命名实参还会让参数名成为兼容性表面,而 match 与 nullsafe 链的严格语义常被旧代码经验掩盖。

fix

在边界先验证原始值,逐项测试联合类型和 match 分支,把公开参数名当作 API,并用反射测试确认特性的目标和构造参数。

是什么,为什么存在

PHP 8.0 新特性不是一套必须全部采用的风格规则,而是一组更精确的语言能力。本主题聚焦 PHP 8.0 引入、且持续影响日常应用代码的六项能力:联合类型、命名实参、特性、match、构造器属性提升和 nullsafe 运算符。PHP 8.1 的枚举与 Fiber 分别由相关主题讲解。

联合类型(union type) 把多个允许的运行时类型写成 int|string。它能让引擎在函数调用、返回和属性赋值处执行已有类型规则,也能让静态分析器看见分支,但不会描述数组内部结构或自动解析请求字符串。

命名实参(named argument) 使用 parameter: value 把值交给指定形参。它适合跳过中间的可选参数,也能让布尔值等含义不明显的实参更易读;代价是调用方开始依赖参数名,而不只是参数顺序。

特性(attribute) 是附到类、方法、属性、参数等程序实体上的结构化元数据。框架或应用通过 Reflection API 读取它,再决定路由、校验或序列化行为。特性本身只描述信息,不会因为写在方法上就自动执行逻辑。

match、属性提升与 nullsafe 运算符解决三类常见样板代码:从输入到单一结果的严格分支、构造器参数到属性的重复声明,以及可空对象链上的逐层检查。它们压缩的是表达方式,不是业务决策;省掉的每一段代码仍有具体语义需要测试。

这些能力最常出现在 DTO、控制器、配置对象、领域值转换和框架集成中。迁移旧项目时,先用测试固定当前行为,再逐处采用新语法。大范围机械重写会同时改变类型错误、参数绑定、比较和空值传播,难以判断哪一项造成回归。

工作原理

六项能力作用在不同阶段。类型声明参与调用或赋值,命名实参在调用时绑定形参,特性由编译结果保存并等待消费者读取,match 和 nullsafe 运算符在表达式求值时工作,属性提升则在对象构造时建立并赋值属性。

能力主要时机语言保证不提供的保证
联合类型调用、返回、属性赋值值符合至少一个成员类型外部输入已通过领域校验
命名实参调用绑定按形参名选择目标参数名可以随意重命名
特性编译后保存、反射时读取元数据可由 Reflection API 发现元数据自动产生业务行为
match表达式求值严格比较、单分支、返回值编译期穷尽性证明
属性提升构造对象声明属性并从形参赋值自动校验领域不变量
nullsafe 运算符表达式求值遇到 null 时短路并返回 null把缺失值变成业务默认值

类型与调用边界

联合类型的成员以 | 分隔。一个值只需符合其中一个成员;重复类型、void 与其他成员的组合等无意义声明会被拒绝。可空的单一类型通常写作 ?Customer,复杂声明可以显式包含 null,但应选择一种团队风格并保持一致。

declare(strict_types=1); 会影响当前文件发起的用户自定义函数调用如何处理标量参数。它不会让 int|string 变成领域枚举,也不会检查 array 的键和值。来自 JSON、表单或数据库的值仍需在边界验证和转换,然后再进入带类型的内部接口。

命名实参先按名称找到形参,所以调用顺序可以不同,也可以跳过有默认值的形参。位置实参必须出现在命名实参之前;未知名称、同一形参重复赋值都会产生 Error。这不是关联数组配置语法,而是真正的函数调用绑定。

函数或方法一旦允许外部调用者使用命名实参,形参名就可能成为公开契约。把 $includeTax 重命名为 $withTax 不改变位置调用,却会让 includeTax: true 立即失败。库作者应把参数名纳入兼容性审查,应用内部也应避免仅为文案偏好频繁改名。

元数据与对象构造

特性使用 #[Name(arguments)] 语法,参数必须能作为常量表达式求值。一个特性类自身用内置的 #[Attribute] 标记,并可声明允许的目标和是否可重复。ReflectionClassReflectionMethod 等对象的 getAttributes() 返回 ReflectionAttribute 描述符。

取得描述符不等于构造特性对象。只有调用 newInstance() 才会运行特性类构造器,并在此时暴露错误参数、非法目标或未标记为特性类等问题。消费元数据的代码应决定何时实例化,也应为错误配置提供测试和清楚的启动失败。

构造器属性提升把可见性修饰符写在构造器形参前,例如 public string $id。该形参同时声明属性;构造时,传入值会在构造器主体执行前赋给属性,所以主体可以用 $this->id 校验组合不变量。没有可见性修饰符的形参仍只是普通形参。

提升属性不是不同种类的属性。反射、继承和类型规则仍按普通属性处理,也可以把特性直接附在提升形参上。不要同时再显式声明同名属性;重复声明会造成错误,而不是覆盖或补充提升声明。

严格分支与空值传播

match 表达式(match expression) 把一个主题值与各分支条件按 === 比较。第一个匹配分支的右侧表达式成为整个 match 的值;没有贯穿执行,也不需要 break。多个条件可用逗号共享一个结果。

没有分支匹配且没有 default 时,PHP 会在执行到该路径时抛出 UnhandledMatchError。这让遗漏不能静默变成 null,但它不是编译期穷尽性检查。为封闭输入列出所有分支并测试每个值,通常比宽泛的 default 更能暴露需求变化。

match (true) 可以表达按顺序判断的条件区间,因为每个分支条件也按严格比较与 true 匹配。顺序因此成为业务规则的一部分:更窄或优先级更高的条件必须放在前面。若只是在映射离散值,直接匹配主题值通常更清楚。

nullsafe 运算符(nullsafe operator) 写作 ?->。左侧为对象时,它像 -> 一样继续读取属性或调用方法;左侧为 null 时,剩余链短路,整个表达式得到 null,连当前方法的实参表达式也不会求值。

每个可能返回 null 的步骤都要使用 ?->。链中出现普通 -> 后,后一步再遇到 null 仍会报错。nullsafe 运算符只用于读取,不能放在赋值左侧,也不能取得引用;需要更新时,应先显式检查对象是否存在。

示例

下面四个示例以订单处理为同一背景,从类型与分支开始,再加入对象建模、特性元数据和 nullsafe 短路。所有输出都由本地 PHP 8.3.33 CLI 实际执行对应文件得到。

联合类型、命名实参与 match

状态转换函数接受内部整数码或外部字符串引用。match 的严格比较让整数 1 与字符串 '1' 保持不同含义,命名实参则说明第二个布尔值控制优先级。

status_label.php
<?php
declare(strict_types=1);

function statusLabel(int|string $status, bool $priority = false): string
{
    return match ($status) {
        1 => 'queued',
        '1' => 'external-reference',
        2 => $priority ? 'priority' : 'processing',
        default => throw new InvalidArgumentException("unknown status: {$status}"),
    };
}

echo statusLabel(1), PHP_EOL;
echo statusLabel('1'), PHP_EOL;
echo statusLabel(status: 2, priority: true), PHP_EOL;

try {
    echo statusLabel(status: 9), PHP_EOL;
} catch (InvalidArgumentException $error) {
    echo $error::class, ': ', $error->getMessage(), PHP_EOL;
}
queued
external-reference
priority
InvalidArgumentException: unknown status: 9

联合类型说明两种技术表示都合法,但函数仍明确拒绝未知状态。若使用 switch 或先把输入统一转换成字符串,整数码与外部引用可能被意外合并。测试应覆盖每个类型成员,以及看起来相等但类型不同的边界值。

priority: true 让调用含义比裸布尔值更直接。不过,调用现在依赖 $priority 这个名称;重命名形参需要同步修改所有命名调用,或作为公开 API 的破坏性变更处理。

用属性提升建立对象

订单与客户对象用提升属性去掉重复声明。构造器主体仍负责总额必须为正这一领域不变量;属性提升本身只完成声明和赋值。

promoted_order.php
<?php
declare(strict_types=1);

final class Address
{
    public function __construct(public string $city) {}
}

final class Customer
{
    public function __construct(
        public string $name,
        public ?Address $address = null,
    ) {}
}

final class Order
{
    public function __construct(
        public string $id,
        public Customer $customer,
        public int|float $total,
    ) {
        if ($this->total <= 0) {
            throw new InvalidArgumentException('total must be positive');
        }
    }

    public function summary(): string
    {
        $city = $this->customer->address?->city ?? 'pickup';
        return sprintf('%s|%s|%.2f', $this->id, $city, $this->total);
    }
}

$delivery = new Order(
    id: 'A-17',
    customer: new Customer('Mina', new Address('Paris')),
    total: 42.5,
);
$pickup = new Order(id: 'A-18', customer: new Customer('Noah'), total: 19);

echo $delivery->summary(), PHP_EOL;
echo $pickup->summary(), PHP_EOL;
A-17|Paris|42.50
A-18|pickup|19.00

命名实参允许第二次构造在一行中仍保持可读。total 的联合类型保留整数和浮点数两种输入,但 sprintf() 在输出边界将它们规范为同一种金额格式。真实金额通常还需要明确的精度和货币策略,不能由联合类型替代。

address 是可空属性,因此访问 city 的那一步使用 ?->?? 'pickup' 是业务默认策略,与 nullsafe 运算符的传播语义分开;如果缺少地址应阻止下单,就应在构造器或服务边界拒绝,而不是显示默认值。

定义并读取可重复特性

RequiresRole 只允许用于方法,而且可以重复。读取器先筛选目标特性,再调用 newInstance() 得到带类型的配置对象。

route_attributes.php
<?php
declare(strict_types=1);

#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final class RequiresRole
{
    public function __construct(public string $role) {}
}

final class OrderController
{
    #[RequiresRole('support')]
    #[RequiresRole('admin')]
    public function refund(): void {}
}

$method = new ReflectionMethod(OrderController::class, 'refund');
$attributes = $method->getAttributes(RequiresRole::class);

foreach ($attributes as $attribute) {
    $rule = $attribute->newInstance();
    echo $method->getName(), ':', $rule->role, PHP_EOL;
}
refund:support
refund:admin

程序能输出角色,是因为消费代码显式读取、实例化并解释特性。若没有这一段 Reflection 逻辑,#[RequiresRole] 不会执行授权。生产授权还必须从可信身份取得角色,并定义多个特性是「满足任意一个」还是「必须全部满足」。

IS_REPEATABLETARGET_METHOD 描述结构约束,不描述上面的组合政策。新增特性消费者时,应测试无特性、单个特性、重复特性、错误参数和错误目标,而不只测试成功路径。

观察 nullsafe 短路

最后一个示例让方法实参带有可见副作用。资料不存在时,auditLabel() 不会求值;对象链存在时,它才会在 format() 调用之前运行。

nullsafe_profile.php
<?php
declare(strict_types=1);

final class Profile
{
    public function __construct(public ?DeliveryAddress $address = null) {}
}

final class DeliveryAddress
{
    public function __construct(public string $city) {}

    public function format(string $audit): string
    {
        return "{$audit}:{$this->city}";
    }
}

function auditLabel(): string
{
    echo "audit evaluated\n";
    return 'ship';
}

function destination(?Profile $profile): string
{
    return $profile?->address?->format(auditLabel()) ?? 'pickup';
}

echo destination(new Profile()), PHP_EOL;
echo destination(new Profile(new DeliveryAddress('Paris'))), PHP_EOL;
pickup
audit evaluated
ship:Paris

短路可以避免无用工作,但把有必要的审计、计数或校验放进方法实参会造成条件性执行。副作用如果无论对象是否存在都必须发生,应在 nullsafe 链之前单独执行,并用清楚的控制流处理缺失对象。

这里两个可空步骤都使用 ?->$profile 可能为空,address 也可能为空。只在第一个位置使用 nullsafe,然后写 ->address->format(),无法保护第二个空值。

陷阱

把联合类型当作输入校验

修复方法: 在 HTTP、CLI、数据库或消息边界先验证原始形状和值,再转换成窄小的内部表示。测试联合类型的每个成员、边界值和被拒绝的类型;当分支拥有不同业务行为时,考虑值对象或枚举,而不是继续增加成员。

忘记命名实参绑定的是参数名

修复方法: 对公开方法记录稳定参数名,并在集成测试中保留至少一个命名调用。只在跳过可选参数或显著提升可读性时使用命名实参;重命名公开形参时按兼容性变更迁移调用方。

认为特性会自动执行策略

修复方法: 明确哪个启动或请求组件读取特性、何时调用 newInstance()、怎样处理重复项。用反射集成测试遍历已注册处理器,使错误配置在部署或启动阶段失败;安全策略还要测试缺少元数据时的默认行为。

default 隐藏新增分支

修复方法: 对封闭集合显式列出分支,并让数据提供器遍历所有合法值。只有所有未来值确实共享一个安全后备策略时才使用 default;外部未知输入应在进入 match 前得到验证和拒绝。

让 nullsafe 吞掉领域缺失

修复方法: 先定义每个 null 的领域含义,再决定传播、默认或抛出异常。测试链中每个可空位置,而不只测试第一个;必须执行的日志、授权和校验不要藏在可能被短路的方法实参中。

迁移时顺带改变行为

修复方法: 每次只改变一种语义,并在修改前用特征测试记录旧输入与输出。先处理 PHP 8.0 的不兼容变更和依赖,再采用新语法;对每个 switch 检查比较类型、break、贯穿路径和默认分支。

深入 特性交互与兼容性

特性交互与兼容性

PHP 8.0 的语法常在同一个构造器或控制器中组合出现。组合并不会取消各自的边界:提升属性仍受类型规则约束,命名实参仍依赖形参名,附在提升形参上的特性仍需消费者解释,nullsafe 链产生的 null 仍需业务代码决定如何处理。

参数绑定的顺序

调用发生时,位置实参先按顺序占用形参,命名实参再按名称选择尚未赋值的形参。把位置实参放到命名实参之后是语法错误;两次指定同一形参,或使用不存在的名称,则产生 Error。参数默认值只在该形参没有被任何实参绑定时使用。

数组展开会保留字符串键,并把它们当作命名实参处理。这使 function(...$payload) 看起来方便,却把不可信数组直接变成调用协议:未知键、重复绑定与类型错误都可能在调用处出现。外部载荷应先通过允许列表投影到已知字段,而不是原样展开。

可变参数会收集没有匹配到已声明形参的命名实参,并在数组中保留字符串键。这是有意支持扩展选项时可用的能力,但也可能掩盖拼写错误。公开 API 应明确是否接受任意命名选项,并在需要封闭集合时自行拒绝未知键。

特性的惰性实例化

Reflection API 可以按特性类名筛选描述符,而不先运行特性构造器。这让扫描器能低成本收集元数据,也意味着只扫描不实例化的测试不足以验证配置。需要特性对象的消费者应在受控边界调用 newInstance(),并把失败报告到具体类、方法和特性位置。

特性参数保存的是编译期可表示的值,而不是任意运行时代码。对象真正创建时,构造器仍可验证字符串格式、数值范围和参数组合。目标掩码限制「可以附在哪里」,构造器限制「参数是否有意义」,消费器则负责「这些元数据怎样影响行为」。

重复特性的合并规则不属于 PHP 语言。授权可以解释为任一角色满足即可,校验规则可能要求全部通过,路由特性也可能注册多条路径。特性类使用 IS_REPEATABLE 只允许重复出现,应用仍必须写明顺序、组合和冲突政策。

match 的失败时间

match 对主题和分支条件使用严格比较,因此从旧 switch 迁移时,字符串数字、布尔值和 null 都要重新检查。右侧只允许一个表达式;需要多步工作时,应调用一个命名函数,而不是把副作用压进复杂表达式。

分支条件按顺序求值,并只执行选中的结果表达式。使用 match (true) 时,重排条件可能改变结果;范围边界相交时尤其如此。测试应覆盖每个分界点的前一个值、边界值和后一个值。

UnhandledMatchError 属于 Error 分支,可由 Throwable 边界捕获,但不应在局部转换成无信息的默认结果。它通常表示代码没有处理一个实际到达的状态。记录主题值和调用上下文后,让请求或任务边界按既定失败政策结束,通常比继续执行更安全。

nullsafe 链的求值

nullsafe 链从左到右求值。某一步得到 null 后,该链剩余的属性访问、方法调用和对应实参都不会发生。括号中的独立表达式可能形成自己的求值范围,因此不要只凭视觉位置猜测副作用是否执行。

?-> 的结果不能作为赋值目标,例如 $customer?->address = $address 不合法。更新需要明确的所有者和失败策略:先检查 $customer,缺失时抛出、创建对象或返回失败,再执行普通赋值。显式分支也让静态分析和审查更容易看到状态变化。

长链会隐藏哪个关系允许为空。超过两三个步骤时,把中间结果赋给有业务名称的变量,或在模型上提供返回明确结果的方法。这样可以分别记录「客户不存在」「地址缺失」和「城市缺失」,而不是全部压成同一个 null

PHP 8.0 的其他能力

PHP 8.0 还引入了多项适合按需采用的能力。下面只列出可准确界定的语义,不给出没有基准环境的性能结论。

能力核心语义使用边界
mixed 类型显式表示可接受任意值,也包含 null优先放在真正开放的边界,不要替代可表达的窄类型
static 返回类型返回类型随运行时调用类变化适合返回 $this 的继承式流畅接口
throw 表达式可在箭头函数、??、三元和 match 中抛出复杂失败处理仍应使用清楚的语句块
str_contains()提供包含、前缀和后缀判断仍需决定大小写、编码和空字符串政策
WeakMap对象键不阻止该对象被回收适合附加元数据,不替代拥有明确生命周期的缓存
JITOPcache 可把部分执行路径编译为机器码收益取决于工作负载,必须在自己的部署环境测量

mixed 是信息最少的类型,不是「兼容性最好」的默认选择。内部函数若只处理 string|array,写出联合类型能让调用者、引擎和工具共同维护这个边界。只有容器、序列化入口或通用转发层确实接收任意值时,mixed 才准确。

str_contains($haystack, '')str_starts_with($haystack, '')str_ends_with($haystack, '') 都会返回 true。搜索词来自用户时,空字符串是否表示「匹配全部」必须由产品规则决定。新函数消除了 strpos()false 混淆,却不会替你定义领域含义。

WeakMap 的键必须是对象。当外部没有其他强引用时,条目可随键对象回收,因此不能把它当作持久缓存或唯一数据存储。它适合给对象附加派生信息,同时不因为缓存本身延长对象生命周期。

JIT 是 PHP 8.0 的重要运行时能力,但 Web 应用常受数据库、网络和序列化等其他成本限制。没有固定硬件、配置、数据和重复次数,就不能声称某段应用会获得具体倍数。先用代表性请求和 CPU 密集任务分别建立基线,再决定复杂度是否值得。

迁移验证

采用新特性之前,应先确认运行时、扩展、Composer 依赖和部署镜像都满足目标版本。只在开发机运行 PHP 8 不足以证明队列工作进程、计划任务与生产 FPM 使用相同二进制和配置。

第一轮测试应保持旧代码,专门发现 PHP 8.0 不兼容变化。重点包括内部函数参数错误现在更常抛出 TypeError、字符串与数字的非严格比较规则变化,以及错误抑制和资源到对象的迁移。具体清单应以官方迁移指南和项目实际 API 为准。

第二轮再逐项引入语法。每次把一个 switch 改成 match,都比较原主题值类型和所有贯穿路径;每次加入联合类型,都运行来自真实边界的样本;每次引入命名实参,都搜索公开形参的调用方与继承覆盖。

静态分析能发现一部分类型、不可达分支和调用问题,但不能替代运行时反射与集成测试。特性消费者、动态调用、框架容器和序列化边界依赖运行时数据。迁移完成的证据应同时包含静态检查、单元测试和代表性入口的执行结果。

把最低支持版本写入 composer.json,让 CI 在该版本上执行测试,同时可在当前目标版本上增加一个任务。最低版本暴露误用新语法,目标版本暴露弃用与未来兼容性问题。两者通过后,才能把本地语法成功当作可部署证据。

延伸阅读

检查点

5个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?