- 开发工具
- 代码质量
- 质量保障
【免费下载链接】psalm
A PHP static analysis tool for finding errors and security vulnerabilities in PHP applications
本篇指南聚焦 Psalm 静态分析器中的UnsafeInstantiation问题,说明该检查的触发条件、底层判定逻辑,并完整给出两种官方修复方案:将构造函数标记为final,或通过@psalm-consistent-constructor注解约束子类构造函数签名。读完本文,你将能识别代码中不安全的晚期静态绑定实例化,并依据 Psalm 源码掌握其判定与修复的完整机制。
UnsafeInstantiation 是什么
Psalm 会在检测到通过new static或其他类似方式实例化类,而该类的构造函数又不是final时,报告UnsafeInstantiation问题。官方文档给出的最小触发示例:
<?php class A { public function getInstance() : self { return new static(); } }这里new static()属于 PHP 的晚期静态绑定(Late Static Binding)。它不是在编译期绑定到定义方法所在的类A,而是在运行时绑定到实际被调用的类。正因如此,Psalm 无法静态确认最终运行时的构造函数签名,从而判定该实例化是"不安全"的。
从源码看,该问题的定义位于 src/Psalm/Issue/UnsafeInstantiation.php,它继承自CodeIssue,其ERROR_LEVEL = 2(属于错误级,默认会被报告),SHORTCODE = 229(可用于--shortcodes输出或在配置中引用)。
问题根源:子类重定义构造函数导致致命错误
new static()之所以危险,是因为它在继承场景下会调用子类的构造函数。一旦子类为构造函数添加了父类不具备的必选参数,父类中编写好的new static()调用就会因缺少参数而抛出致命错误:
<?php class A { public function getInstance() : self { return new static(); } } class AChild extends A { public function __construct(string $some_required_param) {} } AChild::getInstance(); // fatal error当执行AChild::getInstance()时,new static()实际等价于new AChild(),而AChild::__construct需要一个string $some_required_param,调用处却没有传入任何参数,于是运行时直接 fatal error。即使子类构造函数的参数都是可选的、与父类签名完全兼容,这种"父类工厂方法 + 子类自定构造函数"的组合在类型层面依然是脆弱的——任何后续子类对构造函数的改动都可能破坏父类已有的调用点。
修复方案一:将构造函数标记为 final
最简单直接的方案是让构造函数不可被重写。一旦构造函数是final,子类就无法改变其签名,new static()的运行时行为因此变得可预测:
<?php class A { final public function __construct() {} public function getInstance() : self { return new static(); } }值得说明的是,Psalm 源码在实现层面对这一方案做了特殊处理:FunctionLikeNodeScanner.php 中,当扫描到某个类的构造函数为final时,会直接将preserve_constructor_signature置为true,注释明确写着"a bit of a hack, but makes sure thatnew staticworks for these classes"(一个小的技巧,用于确保这些类上的new static可用)。也就是说,final构造函数与@psalm-consistent-constructor注解最终会汇入同一个内部开关。
局限:不允许子类扩展构造逻辑
final方案的代价是彻底剥夺了子类自定义构造逻辑的能力。如果你的类库本身就是设计为可继承、且子类确实需要在构造时执行额外初始化,那么final方案并不合适。此时应改用下一种方案。
修复方案二:@psalm-consistent-constructor注解
第二种方案是在类级别添加@psalm-consistent-constructor注解。它不禁止子类声明构造函数,而是要求任何子类的构造函数必须与父类构造函数保持一致的签名:
<?php /** * @psalm-consistent-constructor */ class A { public function getInstance() : self { return new static(); } } class AChild extends A { public function __construct() { // this is fine } } class BadAChild extends A { public function __construct(string $s) { // this is reported as a violation } }AChild的构造函数与父类一致(均无必选参数),合法;BadAChild的构造函数新增了必选参数string $s,违反约束,会被 Psalm 报告为 ConstructorSignatureMismatch。
该注解在 Psalm 文档体系中属于受支持的类级注解,见 docs/annotating_code/supported_annotations.md,其中同样指向本文所讲的UnsafeInstantiation问题。
注解的解析链路
从源码追踪@psalm-consistent-constructor的完整处理链路,可以看到它最终落在一个名为preserve_constructor_signature的内部标志上:
- 解析 docblock:ClassLikeDocblockParser.php 识别类注释中的
psalm-consistent-constructor标签,写入$info->consistent_constructor; - 写入类存储:ClassLikeNodeScanner.php 将该值赋给
ClassLikeStorage::$preserve_constructor_signature;若类本身没有显式构造函数,还会自动注册一个空构造函数(registerEmptyConstructor),以便子类能以其为签名基准; - 沿继承链传播:Populator.php 在填充子类存储时,若父类
preserve_constructor_signature为真,子类同样置为真——这意味着约束会向下传递到整个继承分支; - 检查报告:该标志的默认值为
false,定义于 ClassLikeStorage.php。
注解的检查链路
preserve_constructor_signature在两处产生实际约束:
- 实例化点检查(NewAnalyzer.php):分析
new static时,若目标类的preserve_constructor_signature为假,立即报告UnsafeInstantiation,消息为 "Cannot safely instantiate class ... with 'new static' as its constructor might change in child classes"(构造函数可能在子类中改变,无法安全实例化); - 方法签名比对(MethodComparator.php):比对子类与父类构造函数时,只要父类开启了
preserve_constructor_signature(或父类为接口),子类构造函数必选参数数量多于父类就会被报告——子类新增必选参数时报告ConstructorSignatureMismatch,其余方法报告MethodSignatureMismatch。
此外,FunctionLikeAnalyzer.php 在检查被重写方法时,对__construct且父类未开启preserve_constructor_signature的情况会直接跳过签名比对,从反面印证:只有注解或final构造函数存在时,构造函数签名才会被纳入严格检查。
测试用例印证
仓库测试对上述两种修复方案均有覆盖,可作为行为契约参考:
- MethodSignatureTest.php 的
consistentConstructor用例验证:带注解的父类 + 签名一致的子类构造函数,不产生任何问题; - MethodSignatureTest.php 的
inconsistentConstructorExplicitParentConstructorArgCount用例验证:子类构造函数新增必选参数,报告ConstructorSignatureMismatch; - MethodSignatureTest.php 的
inconsistentConstructorExplicitParentConstructorType用例验证:子类构造函数改变参数类型(int变string),同样报告ConstructorSignatureMismatch; - AnnotationTest.php 则展示了在
final class上同时使用注解,并在new $class()场景中抑制相关问题的典型组合。
触发场景与检查范围
需要留意的是,UnsafeInstantiation的触发并不局限于new static()字面形式。从 NewAnalyzer.php 和同文件 L1230-L1258 的源码结构看,动态类名实例化同样会被纳入检查:
new $class_name()($class_name是模板参数类名TTemplateParamClass或class-string类型):若其约束的类没有开启preserve_constructor_signature,会报告UnsafeInstantiation,消息为 "Cannot safely instantiate class ... with 'new $class_name' as its constructor might change in child classes";- 结合 UnsafeGenericInstantiation 的相邻检查(NewAnalyzer.php):当类同时带模板类型且未强制模板继承时,还可能额外报告泛型实例化问题——这说明 Psalm 将"构造函数可变"与"模板参数可变"视为两种不同的实例化风险维度。
因此,如果代码库中存在"父类工厂方法返回new static"或"通过类名字符串动态实例化"的晚绑定模式,都应检查对应类是否满足上述任一修复条件。
如何配置与抑制
UnsafeInstantiation的ERROR_LEVEL为 2,意味着在默认错误级别下即会被报告。实际项目中可按需处理:
- 代码内抑制:在语句前使用
@psalm-suppress UnsafeInstantiation(仓库测试 MagicMethodAnnotationTest.php 与 MethodCallTest.php 中均有此用法示例); - 基线豁免:通过
psalm --set-baseline将存量问题写入 psalm-baseline.xml 统一管理,避免一次性大量报错阻塞迁移; - 注意:抑制仅能掩盖报告,并不能消除运行时 fatal error 的风险,建议仅在确实无法修改类结构时作为过渡手段。
小结
UnsafeInstantiation保护的是一种非常具体的运行时风险:晚期静态绑定下的构造函数签名漂移。理解它的关键在于把握 Psalm 内部那个统一的preserve_constructor_signature开关——final构造函数与@psalm-consistent-constructor注解最终都汇聚于此,并分别通过NewAnalyzer的实例化点检查和MethodComparator的签名比对来落地。实践中优先推荐注解方案:它保留了子类扩展构造逻辑的自由度,同时用静态检查把签名一致性约束住,兼顾灵活与安全。
- 开发工具
- 代码质量
- 质量保障
【免费下载链接】psalm
A PHP static analysis tool for finding errors and security vulnerabilities in PHP applications
相关推荐
Psalm UnsafeGenericInstantiation 问题详解:泛型类 `new static` 的安全隐患与修复方案
Psalm UnsafeGenericInstantiation 问题详解:泛型类 new static 的安全隐患与修复方案 导读 UnsafeGeneric
开发工具代码质量质量保障Psalm InterfaceInstantiation 详解:接口实例化错误的检测原理与修复方案
Psalm InterfaceInstantiation 详解:接口实例化错误的检测原理与修复方案 本篇文章深入讲解 Psalm 静态分析器中的 Interfa
开发工具代码质量质量保障Psalm 的 NonStaticSelfCall 检查:静态上下文误调用实例方法的检测原理与修复
Psalm 的 NonStaticSelfCall 检查:静态上下文误调用实例方法的检测原理与修复 本篇技术指南围绕 Psalm 静态分析器的 NonStati
开发工具代码质量质量保障
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考