前言
PHP 8.0 引入联合类型(Union Types)之后,很多人产生了一个误会:以为 PHP 终于有泛型(Generics)了,于是写出function wrap(T $item): T这样的签名,等着运行时给个明确报错,结果要么类型检查永远通不过,要么报错信息里冒出一个莫名其妙的类名。
误会的原因在于,"联合类型"和"泛型"解决的是两个完全不同的问题。联合类型回答的是"这个位置可以是哪几种类型中的一种",比如int|string;泛型回答的是"这个容器里的元素是什么类型",比如"一个装User的列表"。PHP 8.0 给的是前者,PHP 至今没有语言级的泛型——T这类类型参数在 PHP 里不是关键字,运行时不认识它。
那"结合使用"到底指什么?指的是这套组合拳:用 PHP 的语言特性(联合类型、mixed、交叉类型、DNF 类型)把能在运行时检查的部分表达出来,用 PHPDoc 把运行时表达不出来的泛型信息标注出来,再交给静态分析工具(PHPStan 或 Psalm)在分析期检查。前者是硬的,后者是软的,两者拼起来才接近"有泛型"的效果。
本文从概念区分讲起,说明联合类型的语义边界,然后给出把两者接起来的实际写法,最后是一份能直接运行的完整示例和几个常踩的坑。
一、把两类东西分清楚
这张表是全文的基础,先把它记住,后面很多困惑会自动消失。
| 写法 | 属于什么 | 引入版本 | 谁来检查 |
|---|---|---|---|
| `int\ | string` | 语言级联合类型 | PHP 8.0 |
?int(即 `int\ | null`) | 语言级可空类型 | PHP 7.1 |
mixed | 语言级类型 | PHP 8.0 | 运行时(几乎不做限制) |
A&B交叉类型 | 语言级交叉类型 | PHP 8.1 | 运行时 |
| `(A&B)\ | C` DNF 类型 | 语言级组合类型 | PHP 8.2 |
@template T、T[] | PHPDoc 文档标注 | 工具约定,不是语言特性 | PHPStan / Psalm 静态分析 |
array{id: int} | PHPDoc 数组形状 | 工具约定 | 静态分析 |
关键差别只有一句:上表后两行删掉,代码照样能跑;前三行删掉,运行时的类型检查就没了。所以泛型注解的价值完全建立在"你确实跑了静态分析"这个前提上——不跑静态分析工具,@template T只是一句注释。
二、联合类型的语义边界
写联合类型时有几条硬规则,违反它们得到的是编译期致命错误,脚本一行都不会执行。
<?php // 需要 PHP 8.0+ // ❌ 重复类型:编译期致命错误,提示 Duplicate type int is redundant // function a(int|int $v): void {} // ❌ 与 mixed 组合:mixed 已经包含所有类型,不能并列书写 // function b(mixed|int $v): void {} // ❌ void 不能出现在联合类型里 // function c(): void|false {} // ✅ 正确的几种写法 function d(int|string $v): string { return is_int($v) ? (string) $v : $v; } function e(int|false $v): bool // false 从 PHP 8.0 起可以作为独立类型使用 { return $v !== false; } function f(?string $v): string // ?string 就是 string|null { return $v ?? ''; } var_dump(d(1), d('a'), e(false), f(null));另外要记住一个等价关系:iterable等价于array|Traversable,所以array|iterable这种写法会因为重复而被判为冗余。同理bool等价于true|false,在 PHP 8.2 之后true才成为可独立书写的类型,如果同时写bool|false也会报冗余。
三、把泛型信息接上去:T 不能出现在真实类型里
这是全文最重要的一条实践结论:@template声明的T只能出现在 PHPDoc 里,绝对不能直接写进 PHP 的参数或返回类型声明。
原因很直接:PHP 解析类型声明时,看到T会把它当成一个类名。类名在运行时才解析,所以你不会在编译期得到"这不是个类型参数"的提示,而是会在某个时刻碰到两种结果之一:类型检查永远匹配不上,或者报错说找不到这个类。两种情况都很难排查。
正确做法是:真实类型退到mixed或联合类型,精确类型写进 PHPDoc。
<?php // 需要 PHP 8.0+ declare(strict_types=1); /** * 一个带类型标注的列表容器 * * @template T */ final class TypedList { /** @var T[] */ private array $items = []; /** * @param T $item */ public function add(mixed $item): void // 真实类型只能是 mixed { $this->items[] = $item; } /** * @return T|null */ public function first(): mixed // 同样退到 mixed { return $this->items[0] ?? null; } /** * @return T[] */ public function all(): array { return $this->items; } public function count(): int { return count($this->items); } } $users = new TypedList(); $users->add('tom'); $users->add('jerry'); var_dump($users->count()); // int(2) var_dump($users->first()); // string(3) "tom"关于"某个具体类的通用容器"这类注解,PHPDoc 的标准写法是在类型名后面用尖括号标注类型参数,本文的示例统一改用T[]这种形式,静态分析器对两者的理解是一致的。
3.1 用 T 约束联合类型的语义
泛型真正发力的地方,是当你需要表达"输入和输出的元素类型相同"时。联合类型本身表达不了这种关联,泛型可以:
<?php // 需要 PHP 8.0+ declare(strict_types=1); /** * 把输入统一成数组:传入单个值得到单元素数组,传入数组原样返回 * * @template T * @param T|T[] $input * @return T[] */ function normalize(mixed $input): array { return is_array($input) ? array_values($input) : [$input]; } var_dump(normalize(5)); // [5] var_dump(normalize([1, 2, 3])); // [1, 2, 3]T|T[]这个注解是联合类型和泛型配合的最典型形态:联合类型负责"可以是单个也可以是多个"这个形状,泛型负责"这两处的元素是同一个类型"这个约束。静态分析器据此能同时发现两类错误——传了不该传的类型,或者接收方按错误的元素类型使用结果。
3.2 给类型参数加约束
泛型不加约束会退化成mixed,失去检查意义。PHPDoc 用of关键字给类型参数限定范围:
<?php // 需要 PHP 8.0+ declare(strict_types=1); /** * @template T of \DateTimeInterface * @param T $a * @param T $b * @return T */ function later(mixed $a, mixed $b): mixed { return $a->getTimestamp() >= $b->getTimestamp() ? $a : $b; } var_dump(later(new DateTime('2026-01-01'), new DateTime('2026-06-01'))->format('Y-m-d'));有了of \DateTimeInterface这个约束,静态分析器就知道函数体里调用getTimestamp()是安全的,同时会拒绝传入字符串。
3.3 PHP 8.2 的 DNF 类型让联合与交叉结合
如果你的项目能用到 PHP 8.2,DNF 类型(Disjunctive Normal Form Types)可以让"联合"里嵌套"交叉",表达能力更进一步:
<?php // 需要 PHP 8.2+ declare(strict_types=1); /** * 接受"既可计数又能当数组访问"的对象,或者直接给 null */ function describe((Countable&ArrayAccess)|null $box): string { if ($box === null) { return 'empty'; } return 'count=' . count($box); } var_dump(describe(null)); // emptyDNF 类型是运行时真实生效的检查,不需要静态分析器参与——这是它和泛型注解的本质区别:能用语言表达的,就不要只写在注释里。所以判断标准很简单:如果某个约束能用联合类型、交叉类型、DNF 类型表达,就用语言特性;只有当约束涉及"元素类型的一致性"这种运行时无法表达的语义时,才退到 PHPDoc 泛型。
代码实战:一份完整可运行的示例
把下面这段保存成generics_demo.php直接运行,它把联合类型、mixed、PHPDoc 泛型、数组形状注解放在一起使用,输出的是真实结果。
<?php // 需要 PHP 8.0+;保存后执行:php generics_demo.php declare(strict_types=1); /** * 通用结果包装:成功时携带数据,失败时携带错误信息 * * @template T */ final class Result { /** * @param T|null $value */ private function __construct( public bool $ok, private mixed $value, private string $error = '' ) { } /** * 创建成功结果 * * @template U * @param U $value * @return self */ public static function ok(mixed $value): self { return new self(true, $value); } /** * 创建失败结果 * * @return self */ public static function fail(string $error): self { return new self(false, null, $error); } /** * 联合类型在这里派上用场:取值可能成功,也可能拿到错误信息 * * @return T|null */ public function value(): mixed { return $this->ok ? $this->value : null; } public function error(): string { return $this->error; } } /** * @param array{id: int, name: string} $row * @return Result */ function greet(array $row): Result { if ($row['name'] === '') { return Result::fail('缺少 name 字段'); } return Result::ok('hello, ' . $row['name']); } $r1 = greet(['id' => 1, 'name' => 'tom']); $r2 = greet(['id' => 2, 'name' => '']); foreach ([$r1, $r2] as $i => $r) { if ($r->ok) { printf("#%d ok, value=%s\n", $i, var_export($r->value(), true)); } else { printf("#%d 失败, error=%s\n", $i, $r->error()); } }运行结果:
#0 ok, value='hello, tom' #1 失败, error=缺少 name 字段要让泛型注解真正产生价值,需要装上静态分析工具并让它跑起来:
# 在项目里安装 PHPStan(开发依赖) composer require --dev phpstan/phpstan # 分析源码,级别越高检查越严格 vendor/bin/phpstan analyse src --level=6分析器会把Result::ok(123)之类的调用与上一节声明的返回类型注解逐项比对,在不运行代码的情况下指出类型不一致。这也是泛型注解唯一的收益来源:它不能防止运行时错误,只能提前把不一致暴露在开发阶段。
常见坑点
1. 以为联合类型就是泛型
- ❌
function wrap(int|string $v)之后宣称"PHP 8.0 支持泛型了" - ✅ 联合类型表达"几种类型之一",泛型表达"容器元素的类型"。PHP 至今没有语言级泛型,泛型只能靠 PHPDoc 加静态分析工具
2. 把 T 写进真实的类型声明
- ❌
public function add(T $item): T {}—— PHP 会把T当成类名解析 - ✅ 真实类型写
mixed(PHP 8.0 起可用)或联合类型,把@param T写进 PHPDoc
3. 写了泛型注解却从不跑静态分析
- ❌ 项目里
@template写得满满当当,CI 里没有任何分析步骤 - ✅ 注解与工具是一对,缺一半等于没有。把
phpstan analyse加进 CI,并给一个明确的级别目标
4. 联合类型里写重复类型
- ❌
int|int、mixed|string、array|iterable—— 编译期致命错误 - ✅ 记住等价关系:
iterable就是array|Traversable,mixed已经包含一切,bool包含true与false
5. 可空写法叠用
- ❌
?int|null这类写法会被判定为类型重复 - ✅ 二选一:用
?int,或者写int|null
6. 用泛型注解代替输入校验
- ❌ 认为
@param int $id能挡住外部传进来的字符串 - ✅ PHPDoc 不参与运行时。来自请求的参数仍然要显式校验和类型转换
7. 忘了never的语义
- ❌ 在联合类型里写
never,或者在返回never的函数里写了return; - ✅
never是 PHP 8.1 引入的返回类型,表示函数永不正常返回(总是抛异常或退出),它不能出现在联合类型里
8. 类型注解与实现各写一套
- ❌ PHPDoc 写
@return string[],实际函数在某些分支返回null - ✅ 注解要和实现一致;不一致时静态分析器会报错,别把报错当成"工具太严"关掉
总结
| 需求 | 用什么表达 | 生效时机 |
|---|---|---|
| 一个参数可以是几种类型之一 | 联合类型 `int\ | string`(PHP 8.0) |
| 一个对象必须同时满足多个接口 | 交叉类型A&B(PHP 8.1) | 运行时 |
| 联合里嵌套交叉 | DNF 类型 `(A&B)\ | C`(PHP 8.2) |
| 容器里的元素类型 | PHPDoc@template T与T[] | 静态分析期 |
| 输入与输出的元素类型一致 | `@param T\ | T[]与@return T[]` |
| 限制类型参数的范围 | @template T of SomeInterface | 静态分析期 |
"联合类型和泛型结合使用"在实践中就是一条分界线:能用语言特性表达的约束,全部用联合类型、交叉类型、DNF 类型写出来,让运行时把关;只有"元素类型一致性"这类运行时表达不了的语义,才退到 PHPDoc 泛型注解,由静态分析器把关。记住T不能出现在真实类型声明里、泛型注解必须配静态分析工具这两点,就不会在这条路上走弯路。