18 KiB
PHPStan 设计分析:可引入 AOT 编译器的模式与模块
本文档分析了 PHPStan 项目 (projects/phpstan-src/) 的架构设计,识别出可引入 AOT 编译器的设计模式和模块。
概览:四大优先级的可迁移设计
| 优先级 | 设计/模块 | 实施量级 | 前置依赖 | 收益 |
|---|---|---|---|---|
| P1 | Type 对象层次 (#1) | ~1000 行 | 无 | 类型推导精度质的提升 |
| P1 | TrinaryLogic (#2) | ~80 行 | 无 | 类型查询不再给出错误答案 |
| P1 | TypeCombinator (#3) | ~300-400 行 | #1 | 消除散落的类型字符串拼接 |
| P2 | Rule 系统 (#4) | 接口+Registry ~80 行 | 基本独立 | 架构模块化,易测试易扩展 |
| P2 | Collector 双阶段分析 (#5) | ~200 行 | #4 | 跨文件全局优化 |
| P2 | Extension 注册机制 (#6) | ~100 行 | #4 | 框架级插件系统 |
| P3 | TypeSpecifier / 类型窄化 (#7) | ~200 行 | #1, #2 | if/else 分支类型精化 |
| P3 | Immutable Scope (#8) | ~300 行 | #1, #2 | 表达式级类型追踪 |
| P4 | PHPDoc Pipeline (#9) | 可复用 phpstan/phpdoc-parser | #1 | 利用现有成熟解析器 |
| P4 | NeverType (#10) | 含于 #1 | #1 | 死代码消除,矛盾类型检测 |
1. Object-Oriented Type 系统(替代基于字符串的类型表示)
现状
AOT 编译器用字符串常量表示类型:
const TYPE_INT = 'int';
const TYPE_FLOAT = 'float';
const TYPE_STR = 'string';
// 复合类型用字符串拼接
// 'int|string', 'string|null'
这导致类型运算(合并、比较、查询)必须手动解析和拼接字符串。
PHPStan 方案
PHPStan 有一个 Type 接口,定义约 100 个方法。每种类型是一个类:
Type (interface)
├── StringType
├── IntegerType
├── FloatType
├── BooleanType
├── NullType
├── MixedType (顶类型,带 subtracted type 支持)
├── NeverType (底类型,无可能值)
├── VoidType
├── ArrayType
├── ObjectType
├── CallableType
├── UnionType (A|B|C,持有 flat list<Type>)
├── IntersectionType (A&B&C)
├── ConstantStringType, ConstantIntegerType, ...
├── IntegerRangeType
└── Accessory*Type (用于 IntersectionType 中的精化)
核心设计原则:永远不要用 instanceof 判断类型身份。 必须使用 is*() 方法:
// 错误 —— 漏掉了 UnionType 和 IntersectionType
$type instanceof StringType
// 正确 —— UnionType 会委托给内部类型并组合结果
$type->isString()->yes()
关键子接口:
CompoundType:标记需要双向类型比较的类型(UnionType, IntersectionType)。添加isAcceptedBy(),isSubTypeOf()方法,实现双分派协议。SubtractableType:支持差集操作(如mixed~null)。
isSuperTypeOf / accepts 双分派协议
这是 PHPStan 类型系统最核心的设计模式:
简单类型 (StringType)::isSuperTypeOf(Type $otherType):
if $otherType 是 CompoundType:
return $otherType->isSubTypeOf($this) // 反向委托
// 自身逻辑 ...
return No
复合类型 (UnionType)::isSubTypeOf(Type $otherType):
foreach innerType in $this->types:
results[] = $otherType->isSuperTypeOf(innerType)
return extremeIdentity(results) // ALL 必须是 subtype
要点: 简单类型不需要理解复合类型的语义。新增复合类型时不需要修改任何简单类型的比较逻辑。
AOT 采用建议
AOT 编译器不需要 PHPStan 全部的复杂度。第一版只需这些具体类型:
IntegerType, FloatType, StringType, BoolType,
NullType, MixedType, NeverType, ArrayType, ObjectType, UnionType
不需要:IntersectionType, AccessoryType, TemplateType, Constant*Type, IntegerRangeType, EnumType。
2. TrinaryLogic(三值逻辑)
现状
AOT 编译器用 boolean 判断类型属性。但 mixed 类型意味着 "可能是 string,也可能是 int",boolean 无法表达这种不确定性。
PHPStan 方案
class TrinaryLogic {
public function yes(): bool;
public function no(): bool;
public function maybe(): bool;
public static function createYes(): self;
public static function createNo(): self;
public static function createMaybe(): self;
public function and(self ...$others): self;
public function or(self ...$others): self;
public function extremeIdentity(self ...$others): self; // ALL yes → yes; ALL no → no
public function maxMin(self ...$others): self; // ANY yes → yes; ALL no → no
}
使用示例:
// MixedType::isString() → maybe(mixed 可能是 string)
// IntegerType::isString() → no
// UnionType(int|string)::isString() → maybe
$type->isString()->yes() // 确定是 string
$type->isString()->no() // 确定不是 string
$type->isString()->maybe() // 不确定
AOT 采用建议
直接移植,约 80 行代码,无外部依赖。所有类型查询方法用 TrinaryLogic 替代 boolean。
3. TypeCombinator —— 类型规范化引擎
要解决的问题
禁止直接 new UnionType(...)。所有 union/intersect/remove 操作必须经过 TypeCombinator,确保类型表示始终是规范化的。
三个核心操作
union(Type ...$types): Type
算法流程:
- Fast path:0 个参数 →
NeverType;1 个参数 → 直接返回;2 个参数检查never/mixed/相同对象 - 扁平化:
union(A, union(B, C), D)→ 展开为[A, B, C, D] - 过滤 NeverType:
union(int, never, string)→[int, string] - 分类提取:将类型分为 scalar/array/enum/integerRange/generic 五类,分批处理
- 标量消解:
ConstantIntegerType(3) | IntegerType→IntegerType;true | false→BooleanType - Pairwise 比较:
IntegerRangeType相邻区间合并:int<0,5> | int<3,10>→int<0,10>- Subtype/supertype 消除:
Foo extends Bar⇒Foo | Bar=Bar int[] | string[]→(int|string)[]
- 收尾:0 个→NeverType;1 个→直接返回;否则
new UnionType(array_values($types), true)
intersect(Type ...$types): Type
算法流程:
- 有
NeverType→ 直接返回 never - 分配律展开:
A & (B|C)→(A&B) | (A&C),然后对每个子项递归 - 扁平化:
A & (B & C)→ 展开为[A, B, C] - Pairwise 双向比较:
IntegerType & ConstantIntegerType(5)→ConstantIntegerType(5)(Child & Parent = Child)int & string→NeverType(矛盾)- SubtractableType 差集交接
- 矛盾检测:
isSuperTypeOf返回no→NeverType
remove(Type $fromType, Type $typeToRemove): Type
remove(int|string, string) = int
remove(int|string|null, null) = int|string
remove(int, string) = int // 要移除的不在里面
remove(string, string) = never // 完全移除
remove(mixed, Foo) = mixed~Foo // 差集类型
AOT 采用建议
精简版 TypeCombinator(~300-400 行),覆盖 AOT 需要的基础类型的 union/intersect/remove。不需要 PHPStan 复杂的数组 shape 处理、accessory type 传播、IntegerRange 合并等逻辑。
收益
| 场景 | 当前 | TypeCombinator 后 |
|---|---|---|
int|int 的变量赋值 |
字符串 'int|int' |
自动简化为 IntegerType |
mixed|string 的返回值 |
不知道如何简化 | 自动简化为 MixedType |
| 两个分支类型合并 | 手动拼接 | union() 自动去重去 subtype |
int & string 交集 |
无法检测 | 返回 NeverType(编译错误) |
4. Rule-based 分析 Pass 系统
核心接口
/**
* @template TNodeType of Node
*/
interface Rule
{
/** @return class-string<TNodeType> */
public function getNodeType(): string;
/** @param TNodeType $node */
public function processNode(Node $node, Scope $scope): array;
}
每个 Rule 声明它关心哪种 AST 节点类型,以及在发现该节点时做什么。
注册机制
通过 PHP 8 Attribute 声明级别:
#[RegisteredRule(level: 0)]
final class CallMethodsRule implements Rule
{
public function getNodeType(): string { return MethodCall::class; }
public function processNode(Node $node, Scope $scope): array { ... }
}
Registry 实现
LazyRegistry 从 DI 容器收集所有带 phpstan.rules.rule 标签的服务,按 getNodeType() 返回值建立索引。
关键设计: getRules($nodeType) 不仅匹配精确的类名,还匹配所有父类和接口:
public function getRules(string $nodeType): array
{
// $nodeType = MethodCall::class
// parentNodeTypes = [MethodCall, Expr, NodeAbstract, Node, ...]
// 匹配所有注册在这些父类/接口上的 Rule
$parentNodeTypes = [$nodeType] + class_parents($nodeType) + class_implements($nodeType);
// ...
}
这意味着注册为 Node\Expr 的 rule 会匹配所有表达式类型。
运行时调度
在 AST 遍历的每个节点上:
$nodeType = get_class($node);
foreach ($this->ruleRegistry->getRules($nodeType) as $rule) {
$ruleErrors = $rule->processNode($node, $scope);
// 转换和收集错误 ...
}
极其简洁,没有 switch,没有 if-else 链。
Collector 双阶段分析
Collector 接口与 Rule 几乎相同,但返回收集的数据(而不是错误):
interface Collector
{
public function getNodeType(): string;
/** @return TValue|null */
public function processNode(Node $node, Scope $scope);
}
阶段 1(per-file): 在每个文件被分析时,collector 收集数据。
阶段 2(global): 所有文件分析完后,创建 CollectedDataNode 封装所有数据,运行注册在其上的规则:
$node = new CollectedDataNode($analyserResult->getCollectedData(), $onlyFiles);
foreach ($this->ruleRegistry->getRules(CollectedDataNode::class) as $rule) {
$ruleErrors = $rule->processNode($node, $scope);
}
CollectedDataNode::get(string $collectorType): array<string, list<TValue>> 按文件路径索引收集到的数据。
AOT 编译器具体应用
| Rule | 触发节点 | 作用 |
|---|---|---|
BinaryOpCodegenRule |
Expr\BinaryOp |
生成 C++ 运算代码 |
MethodCallCodegenRule |
Expr\MethodCall |
方法调用代码生成,虚调用/直调判断 |
TypeCheckInsertRule |
Param / Return_ |
函数入口/出口插入运行时类型检查 |
DeadCodeEliminateRule |
CollectedDataNode |
跨文件分析,移除未调用函数 |
DevirtualizeRule |
CollectedDataNode |
单实现虚方法 → 直接调用 |
InlineDecisionRule |
CollectedDataNode |
基于调用频率和函数大小决定内联 |
ConstantFoldRule |
Expr\BinaryOp |
编译期常量折叠 |
BoxOptimizationRule |
Expr\Assign |
std container 的 Box 逃逸分析 |
O0/O1/O2 分级:
#[RegisteredRule(level: 0)] // 基础代码生成,永远必须
class BinaryOpCodegenRule implements Rule { ... }
#[RegisteredRule(level: 1)] // O1 优化
class ConstantFoldRule implements Rule { ... }
#[RegisteredRule(level: 2)] // O2 激进优化
class InlineDecisionRule implements Rule { ... }
AOT 采用建议
渐进式迁移,不需要一次重写整个编译器:
- 定义
Rule接口和Registry(约 80 行代码) - 把
parseStmts()的一个独立功能提取为第一个 Rule - 逐步迁移剩余 switch 分支
- 引入
Collector+CollectedDataNode用于跨文件优化
Rule 系统可以与现有 switch 调度共存——先让 Rule 处理它能处理的节点,其他 fallback 到原有逻辑。
5. Extension 注册机制
PHPStan 方案
PHPStan 有数十个扩展接口,通过 DI 容器的 service tag 机制注册:
DynamicMethodReturnTypeExtension → tag: phpstan.broker.dynamicMethodReturnTypeExtension
FunctionTypeSpecifyingExtension → tag: phpstan.typeSpecifier.functionTypeSpecifyingExtension
TypeNodeResolverExtension → tag: phpstan.phpdoc.typeNodeResolverExtension
MethodsClassReflectionExtension → tag: phpstan.broker.methodsClassReflectionExtension
PropertiesClassReflectionExtension → tag: phpstan.broker.propertiesClassReflectionExtension
...
扩展在核心逻辑之前被调用,有机会覆盖默认行为:
public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
{
foreach ($this->extensions as $extension) {
$type = $extension->resolve($typeNode, $nameScope);
if ($type !== null) {
return $type; // 扩展处理了,短路核心逻辑
}
}
// 核心逻辑 ...
}
AOT 应用
框架特定的编译优化可通过扩展插件提供,而不是修改编译器核心:
interface MethodCallOptimizationExtension
{
/** 返回优化后的 C++ 代码,或 null 表示不处理 */
public function optimize(MethodCall $call, Scope $scope): ?string;
}
6. TypeSpecifier —— 类型窄化引擎
解决的问题
当 AOT 编译器遇到 if ($x instanceof Foo) 时,在分支内部 $x 的类型应该被精化为 Foo。这可以生成更高效的 C++ 代码(直接调用 Foo 的方法,不走虚表)。
PHPStan 方案
TypeSpecifier 分析条件表达式,决定如何在 truthy/falsy 分支中窄化类型:
| 条件 | Truthy 窄化为 | Falsey 窄化为 |
|---|---|---|
$x instanceof Foo |
Foo |
移除 Foo |
$x === null |
NullType |
移除 NullType |
is_array($x) |
ArrayType |
移除 ArrayType |
$x(truthy) |
移除 falsey 类型 | 仅保留 falsey 类型 |
$x > 0 |
int<1, max> |
int<min, 0> |
类型窄化流水线
条件表达式
→ TypeSpecifier::specifyTypesInCondition(scope, expr, context)
→ SpecifiedTypes { sureTypes[], sureNotTypes[] }
→ MutatingScope::filterBySpecifiedTypes(types)
→ 新的 scope(类型已窄化)
AOT 应用
简化版 TypeSpecifier(~200 行),处理:
instanceof窄化=== null/!== null窄化is_array()、is_string()、is_int()等函数窄化BooleanAnd/BooleanOr链式窄化
7. Immutable Scope(不可变作用域)
现状
AOT 的 FunctionContext 使用可变公共数组,仅按变量名追踪类型:
class FunctionContext {
public array $localVars = []; // 仅变量名 → 类型
public int $scopeLevel = 0; // 简单嵌套计数
public array $scopeLayouts = []; // ScopeContext 是空类
public bool $inLoop = false;
public bool $inClosure = false;
}
PHPStan 方案
MutatingScope 是不可变的持久化数据结构。每次变更返回新实例:
scope.assignVariable('x', stringType) → new scope
scope.filterByTruthyValue(instanceofExpr) → new scope (窄化后的类型)
scope.filterByFalseyValue(instanceofExpr) → new scope (移除后的类型)
scope.mergeWith(elseScope) → new scope (交集)
按表达式字符串追踪类型,而非仅按变量名:
'$a' → ExpressionTypeHolder($a, IntegerType, Yes)
'$a[0]' → ExpressionTypeHolder($a[0], StringType, Yes)
'$a->prop' → ExpressionTypeHolder($a->prop, FooType, Yes)
'strlen($a)' → ExpressionTypeHolder(strlen($a), IntegerType, Yes)
这意味着给 $a 赋值会使 $a[0] 和 $a->prop 的类型缓存失效。
AOT 采用建议
精简版(~300 行),核心能力:
- 不可变 scope,支持快照和合并
- 表达式键的类型追踪(至少支持变量和数组元素)
TrinaryLogic确定度追踪(分支合并后的 maybe 状态)
8. PHPDoc Pipeline
PHPStan 方案
三阶段流水线:
PHPDoc 注释字符串
→ Lexer + Parser (phpstan/phpdoc-parser)
→ PhpDocNode (原始 AST)
→ TypeNodeResolver::resolve(TypeNode, NameScope)
→ PHPStan Type 对象
TypeNodeResolver 用一个 switch 分发到 30+ 种标识符类型:
'int' / 'integer' → IntegerType
'positive-int' → IntegerRangeType(1, null)
'non-empty-string' → IntersectionType[StringType, AccessoryNonEmptyStringType]
'class-string' → ClassStringType
'array' → ArrayType(MixedType, MixedType)
'list' → IntersectionType[ArrayType(int), AccessoryArrayListType]
'mixed' → MixedType(true)
'never' → NonAcceptingNeverType
...
AOT 应用
AOT 可以复用 phpstan/phpdoc-parser 来解析 @param、@return、@var 注解,然后将类型 AST 节点映射到 AOT 自己的 Type 系统。NameScope(追踪 namespace + use imports)也是可直接复用的概念。
9. NeverType(底类型)
表示"不可能有任何值"的类型,即空集:
union(int, never) = int // never 是 union 的中性元
intersect(string, never) = never // never 是 intersect 的吸收元
AOT 应用
- 死代码消除:表达式窄化为 NeverType → 该路径不可达
- 错误传播:矛盾的类型组合产生 NeverType
void函数返回:本质上就是 NeverType 在 return 位置
推荐采用顺序
Phase 1 (基础层): Type 接口 + 具体类型 + TypeCombinator + TrinaryLogic
└── 替代字符串类型表示,预计减少散落的类型字符串操作
Phase 2 (模块化): Rule 接口 + Registry + Attribute 注册
└── 分解 parseStmts/parseExpr 的巨大 switch
Phase 3 (分析层): TypeSpecifier + 基础 Immutable Scope
└── 启用 if/else 分支类型窄化
Phase 4 (优化层): Collector + CollectedDataNode
└── 启用跨文件全局优化(去虚拟化、死代码消除、内联决策)
Phase 5 (生态层): Extension 注册 + PHPDoc Pipeline
└── 启用框架插件和注解驱动的类型信息
每一层都建立在上一层之上,且每一层都可以独立交付价值。
不应引入的 PHPStan 特性
| 特性 | 原因 |
|---|---|
Generics / @template T |
复杂度极高,AOT 当前不需要 |
| IntersectionType + Accessory types | non-empty-string = string & non-empty 这类设计过度工程化 |
| BetterReflection (静态反射) | AOT 已经加载文件,不需要避免运行时副作用 |
| 完整 MutatingScope (5000+ 行) | 不可变 scope 模式值得借鉴,但 300 行足够 |
| ConditionalExpressionHolder | 复合条件的惰性窄化,先做基础 TypeSpecifier |
| Enum / ConstantArrayType shape | 小众,初期不需要 |