PHP 8.2的DNF类型怎么用才规范

前言

DNF 类型(Disjunctive Normal Form Types,析取范式类型)是"看文档觉得简单、一上手就报语法错误"的典型。典型症状有三种:

第一种,写 (A|B)&C 被 PHP 拒绝------你以为"或"和"与"可以随便组合,实际上联合类型不能出现在交叉类型里面 ;第二种,写 A&B|null 被拒绝,或者写 ?A&B 被拒绝,因为 null 必须写在交叉类型外面,而且不能用 ? 简写;第三种,写 (Stringable&JsonSerializable)|null 明明语法通过了,运行时却频繁抛 TypeError,因为调用方传进来的对象只实现了其中一个接口。

这些问题的根子在于:DNF 类型是三个特性的叠加,而不是一个独立的新语法。联合类型(union type,PHP 8.0)、交叉类型(intersection type,PHP 8.1)和 DNF(PHP 8.2)是逐步长出来的,每一层都有自己的约束。搞不清楚哪条约束属于哪一层,就会写出语法上不合法、或者语义上不达预期的类型声明。

本文按"三层演进 → 语法约束 → 什么时候真该用 → 可运行示例"的顺序讲清楚,示例最低要求 PHP 8.2(true 独立类型、null 独立类型、DNF 都是 8.2 引入的)。

一、三层演进:先分清每一层能做什么

特性 版本 语法 语义 例子

|------|---------|-----|-----|--------------|
| 联合类型 | PHP 8.0 | `A | B` | 满足任意一个即可 |

|------|---------|-------|------------|-------------------------|
| 交叉类型 | PHP 8.1 | A&B | 必须同时满足 | Countable&ArrayAccess |

|--------|---------|----------|-----|----------|
| DNF 类型 | PHP 8.2 | `(A&B) | C` | 先"与"再"或" |

|--------------|---------|--------|------------|--------|
| 独立 true 类型 | PHP 8.2 | true | 只接受 true | true |

|--------------|---------|--------|------------|--------|
| 独立 null 类型 | PHP 8.2 | null | 只接受 null | null |

理解这张表的关键是最后一行那一列的比喻:交叉类型是"与",联合类型是"或",DNF 就是"或"的连接项里塞进了"与"。在布尔代数里这叫析取范式------若干个"合取项"用"或"连起来的表达式。

顺带澄清一个常见误解:false 作为联合类型的成员从 PHP 8.0 就存在(int|false),但独立的 false 类型 和 true 类型都是 PHP 8.2 才有的。所以在 PHP 8.1 上写 function f(): false 会报错,写 function f(): int|false 则没问题。

二、语法约束:四条硬规则

2.1 联合不能嵌在交叉里

php 复制代码
// ❌ 语法错误:联合类型不能作为交叉类型的成员

function f((A|B)&C $x): void {}

DNF 只允许"交叉在联合里面",反过来不行。如果业务上真的需要"(A 或 B)且 C",只能把它等价改写成两个交叉项的并集:

php 复制代码
// ✅ 等价改写为析取范式

function f((A&C)|(B&C) $x): void {}

2.2 null 必须写在最外层

php 复制代码
// ❌ 语法错误:null 不是类/接口,不能参与交叉

function f(A&B|null $x): void {}
php 复制代码
// ✅ 加括号,让 null 成为联合的一个分支

function f((A&B)|null $x): void {}

2.3 不要用 ? 简写修饰交叉类型

? 只能修饰单个类型名 ,它表达的是"这个类型或 null"。对于交叉类型,请老老实实写 (A&B)|null:

php 复制代码
// ❌ 不要这样写:? 不能修饰交叉类型

function f(?A&B $x): void {}



// ✅

function f((A&B)|null $x): void {}

2.4 成员不能重复、不能与内建标量交叉

php 复制代码
// ❌ 重复成员:"Duplicate type A is redundant"

function f((A&B)|A $x): void {}



// ❌ 交叉类型只能由类/接口构成,int 不是类

function f(int&Countable $x): void {}



// ❌ 联合成员重复

function f(int|int $x): void {}

另外,void、never、mixed 都不能出现在联合或交叉里。mixed 本身就是"所有类型",再和其他类型联合属于语法错误------想表达"任意值或 null",直接写 mixed 就够了,因为 mixed 已经包含 null。

三、什么时候真的该用 DNF

DNF 不是炫技的东西,它的正当用途只有一类:你要表达"某个对象必须同时具备两种能力,或者干脆是 null(表示不提供)"。

最典型的场景是"可选的可序列化对象"。比如一个导出函数,接受:一个既能转字符串、又能 JSON 序列化的对象;或者一个已经拼好的字符串;或者什么都不给(用默认值)。

如果只用交叉类型 Stringable&JsonSerializable,那"什么都不给"就没法表达(除非给参数默认值 null,但那要求参数可空,仍然需要 (A&B)|null);如果只用联合 Stringable|JsonSerializable,那"两个接口都实现"这个约束就松掉了------传进来一个只实现 Stringable 的对象也能通过,函数体里调用 jsonSerialize() 时才炸。

DNF 的价值就在于它能同时守住"与"和"或"两种约束。 反过来说,如果你的类型声明里出现了三层以上的组合,或者出现 (A&B)|(C&D)|(E&F) 这种,那基本可以判断是设计过度了------调用方会很难满足你的类型,最后只能到处传 null 绕过。这时候应该做的是抽一个小接口 ,比如把 Stringable&JsonSerializable 抽成 Exportable,让 DNF 退化成 Exportable|null。

四、实战:可运行的类型约束示例

下面这个脚本把 DNF 用在参数、返回值和属性三个位置,并用反射(Reflection)把类型结构打出来:

php 复制代码
<?php

declare(strict_types=1);

// 最低要求:PHP 8.2

// 运行:php dnf_demo.php



final class Money implements Stringable, JsonSerializable

{

    public function __construct(private int $cents = 0) {}



    public function __toString(): string

    {

        return number_format($this->cents / 100, 2, '.', '');

    }



    public function jsonSerialize(): mixed

    {

        return ['cents' => $this->cents];

    }

}



// 只实现了 Stringable,不满足交叉类型

final class Label implements Stringable

{

    public function __construct(private string $text = '') {}



    public function __toString(): string

    {

        return $this->text;

    }

}



/**

 * DNF 参数类型:(Stringable&JsonSerializable)|string|null

 * 含义:要么是"两个接口都实现"的对象,要么是字符串,要么不提供。

 */

function export((Stringable&JsonSerializable)|string|null $payload = null): string

{

    return match (true) {

        $payload === null => '{}',

        is_string($payload) => $payload,

        default => json_encode($payload, JSON_THROW_ON_ERROR),

    };

}



// 1) 参数位置的 DNF

echo export(new Money(1999)), PHP_EOL;   // {"cents":1999}

echo export('raw-json'), PHP_EOL;        // raw-json

echo export(null), PHP_EOL;              // {}



try {

    export(new Label('只实现了 Stringable'));   // 不满足交叉,抛 TypeError

} catch (TypeError $e) {

    echo 'TypeError: ', $e->getMessage(), PHP_EOL;

}



// 2) 属性位置的 DNF

final class Basket

{

    // 既可以持有"可数且可数组访问"的容器,也可以什么都不持有

    public (Countable&ArrayAccess)|null $items = null;



    public function count(): int

    {

        return $this->items?->count() ?? 0;

    }

}



$basket = new Basket();

$basket->items = new ArrayObject([1, 2, 3]);   // ArrayObject 同时实现两个接口

echo 'count = ', $basket->count(), PHP_EOL;    // 3



$basket->items = null;

echo 'count = ', $basket->count(), PHP_EOL;    // 0



// 3) 用反射把 DNF 结构还原出来

$type = (new ReflectionFunction('export'))->getParameters()[0]->getType();

echo get_class($type), PHP_EOL;               // ReflectionUnionType



if ($type instanceof ReflectionUnionType) {

    foreach ($type->getTypes() as $inner) {

        if ($inner instanceof ReflectionIntersectionType) {

            $names = array_map(

                static fn (ReflectionNamedType $t): string => $t->getName(),

                $inner->getTypes(),

            );

            echo '交叉分支: ', implode(' & ', $names), PHP_EOL;

        } else {

            echo '普通分支: ', $inner->getName(), PHP_EOL;

        }

    }

}

反射这一段的输出值得记住:DNF 类型在反射层面是"联合类型里嵌着交叉类型" ,最外层永远是 ReflectionUnionType,交叉分支则是 ReflectionIntersectionType。写泛型的类型解析器时,必须处理这两层嵌套,只处理一层会漏掉交叉分支。

常见坑点

1. 把联合写进交叉里

php 复制代码
// ❌ Parse error: 语法层面就不接受

function handle((CreateAction|UpdateAction)&Auditable $action): void {}
php 复制代码
// ✅ 展开成析取范式

function handle((CreateAction&Auditable)|(UpdateAction&Auditable) $action): void {}

2. null 没加括号

php 复制代码
// ❌ 报错:null 不是类,不能参与交叉

function set(Session&Countable|null $s): void {}
php 复制代码
// ✅ 交叉项整体加括号,再与 null 取并

function set((Session&Countable)|null $s): void {}

3. 以为 DNF 是"两个都要满足"

php 复制代码
// ❌ 误读 (Stringable&JsonSerializable)|string 为"必须同时是对象和字符串"

//    实际语义是"满足交叉分支"或"是字符串",二选一
php 复制代码
// ✅ 记住读法:(A&B)|C 读作"(A 且 B) 或 C"

4. 交叉类型里塞了标量

php 复制代码
// ❌ Fatal error: int cannot be part of an intersection type

function f(int&Countable $x): void {}
php 复制代码
// ✅ 交叉类型只接受类/接口名;要限制标量请用联合

function f(int|Countable $x): void {}

5. 用 ? 简写修饰交叉

php 复制代码
// ❌ 语法错误

function f(?Countable&ArrayAccess $x): void {}
php 复制代码
// ✅

function f((Countable&ArrayAccess)|null $x): void {}

6. 联合成员重复

php 复制代码
// ❌ Fatal error: Duplicate type int is redundant

function f(int|string|int $v): void {}
php 复制代码
// ✅ 去掉冗余成员;注意 bool 与 true/false 也不能共存

function f(int|string $v): void {}

7. 忘记接口组合后仍需实现全部方法

php 复制代码
// ❌ 以为声明了 implements Stringable, JsonSerializable 就自动有了 __toString

final class Money implements Stringable, JsonSerializable

{

    // 少了 __toString() 与 jsonSerialize(),直接 Fatal error

}
php 复制代码
// ✅ 抽象成中间接口,但实现类仍然要把方法写全(或使用 trait)

interface Exportable extends Stringable, JsonSerializable {}

final class Money implements Exportable

{

    public function __toString(): string { return '0.00'; }

    public function jsonSerialize(): mixed { return []; }

}

8. 类型声明过严,调用方只能传 null

php 复制代码
// ❌ 四个接口叠加,团队里没人能构造出满足条件的对象,

//    结果所有调用点都传 null,类型约束形同虚设

function render((Stringable&JsonSerializable&Countable&ArrayAccess)|null $data): string {}
php 复制代码
// ✅ 抽一个语义清晰的小接口,让 DNF 退化成"可空单类型"

interface Renderable extends Stringable, JsonSerializable {}

function render(?Renderable $data): string {}

总结

你想表达 该用的写法 版本

|-------|-----|-----|
| A 或 B | `A | B` |

|-----------|-------|---------|
| 同时是 A 和 B | A&B | PHP 8.1 |

|----------------|----------|-----|
| (同时是 A 和 B)或 C | `(A&B) | C` |

|----------------|----------|--------|
| (同时是 A 和 B)或为空 | `(A&B) | null` |

|---------------------|-----------------|---------|
| 只接受 true / 只接受 null | true / null | PHP 8.2 |

|---------------|-------|---------|
| 联合成员中允许 false | `int | false` |

一句话结论:DNF 类型的规范用法就四条------交叉项一律加括号、null 一律写在最外层、? 一律不用于交叉、成员一律不重复 ;而在设计层面记住一条判断标准:当 DNF 超过两个分支时,通常说明该抽接口了。DNF 是给"可选的一对能力组合"准备的语法糖,不是给类型体操准备的。

相关推荐
_Evan_Yao1 小时前
前后端联调怎么排查问题?从 Network 到后端日志,一步步定位错误
开发语言·后端·团队开发·需求分析
hudd6681 小时前
JavaEE 初阶 - 多线程(进阶)
java·开发语言·java-ee
小白快快跑哦2 小时前
python-字符串全解(六):正则表达式-前后相断言
开发语言·python·正则表达式·前后相断言
kimnoic2 小时前
Python数据分析中正则表达式匹配字符串与常用函数示例详解
开发语言·python
中屹指纹浏览器svip2 小时前
打开账号环境网页报错,常见原因有哪些?
服务器·开发语言·php·中屹指纹浏览器
朝朝辞暮i2 小时前
第 01 讲:Python 变量、对象、数据类型与内存引用
开发语言·python
外收内放2 小时前
Python基础语法练习题(69)
开发语言·python
lojox2 小时前
深入理解 C++ STL:vector 底层实现、迭代器失效与 list 特性
开发语言·c++
iotxiaohu2 小时前
用 cflow 查看 C 函数调用关系
c语言·开发语言·cflow