ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Psalm 的 UnsafeInstantiation 检查:`new static` 安全实例化的原理与两种修复方案

Psalm 的 UnsafeInstantiation 检查:`new static` 安全实例化的原理与两种修复方案
  • 开发工具
  • 代码质量
  • 质量保障

【免费下载链接】psalm

A PHP static analysis tool for finding errors and security vulnerabilities in PHP applications

项目地址:https://gitcode.com/gh_mirrors/ps/psalm
点击查看免费下载

本篇指南聚焦 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的内部标志上:

  1. 解析 docblock:ClassLikeDocblockParser.php 识别类注释中的psalm-consistent-constructor标签,写入$info->consistent_constructor;
  2. 写入类存储:ClassLikeNodeScanner.php 将该值赋给ClassLikeStorage::$preserve_constructor_signature;若类本身没有显式构造函数,还会自动注册一个空构造函数(registerEmptyConstructor),以便子类能以其为签名基准;
  3. 沿继承链传播:Populator.php 在填充子类存储时,若父类preserve_constructor_signature为真,子类同样置为真——这意味着约束会向下传递到整个继承分支;
  4. 检查报告:该标志的默认值为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

项目地址:https://gitcode.com/gh_mirrors/ps/psalm
点击查看免费下载

相关推荐

上一篇:LizzieYzy:免费开源围棋AI分析工具,5分钟掌握职业级复盘技巧
下一篇:免费开源围棋AI分析助手LizzieYzy:从零到高手,你的终极棋力提升指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进