news 2026/10/13 1:57:06

Psalm UnusedVariable 死代码检测指南:原理、抑制与自动清理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Psalm UnusedVariable 死代码检测指南:原理、抑制与自动清理
  • 开发工具
  • 代码质量
  • 质量保障

【免费下载链接】psalm

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

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

Psalm 是 PHP 生态中基于类型推断的静态分析工具,其死代码检测体系在开启后会报告一类高频问题——UnusedVariable(未使用变量)。本篇指南以 docs/running_psalm/issues/UnusedVariable.md 为骨架,结合 Psalm 仓库源码(src/Psalm/Internal/Analyzer/StatementsAnalyzer.php、src/Psalm/Internal/Analyzer/Statements/UnusedAssignmentRemover.php)、配置解析(src/Psalm/Config.php)与测试用例(tests/UnusedVariableTest.php、tests/FileManipulation/UnusedVariableManipulationTest.php)展开,带你掌握该 issue 的触发条件、两种合法抑制方式、不可抑制的边界情形,以及借助psalter一键自动清理未使用赋值的工作流。

一、UnusedVariable 是什么

UnusedVariable是 Psalm 在**开启死代码检测(--find-dead-code/--find-unused-code)或变量检测(--find-unused-variables)**后才会发射的 issue 类型,官方定义如下:

Emitted when--find-dead-codeis turned on and Psalm cannot find any references to a variable, once instantiated

即:当一个变量被赋值后,Psalm 在后续代码中找不到对该变量的任何引用时,就会报告 UnusedVariable。注意措辞中的 "once instantiated"(一旦被实例化/赋值之后)——它关注的是赋值语句之后是否还有任何读取行为,而不仅仅是变量名是否存在。

在源码层面,该 issue 的实现位于 src/Psalm/Issue/UnusedVariable.php,它继承自CodeIssue,并定义了:

final class UnusedVariable extends CodeIssue { public const ERROR_LEVEL = -2; public const SHORTCODE = 77; }
  • ERROR_LEVEL = -2:负值表示这是一个特性型 issue,只在对应检测特性开启时才发射,不能靠调整 errorLevel 关闭(对应 docs/running_psalm/error_levels.md 中的 "Feature-specific errors" 分类);
  • SHORTCODE = 77:该 issue 的唯一短代码,用于基线(baseline)、报告输出等场景。

触发示例

原文档给出的最小触发样例:

<?php function foo() : void { $a = 5; // 赋值后从未被引用 → UnusedVariable $b = 4; echo $b; }

$a = 5之后函数体内没有任何地方读取$a,Psalm 因而报告 UnusedVariable;而$b被echo $b引用,属于正常使用。

检测的底层原理

UnusedVariable 的检测逻辑集中在 src/Psalm/Internal/Analyzer/StatementsAnalyzer.php 的语句分析收尾阶段(unused_var_locations遍历循环,约第 986-1061 行)。核心机制可概括为四步:

  1. 登记赋值位置:每次变量赋值都会通过registerVariableAssignment()把[$var_id, CodeLocation]记入$this->unused_var_locations(StatementsAnalyzer.php)。
  2. 构建变量使用图:Psalm 用VariableUseGraph维护"赋值点 → 使用点"的数据流边(DataFlowNode::getForAssignment()创建赋值节点,引用时通过addPath连边)。
  3. 可达性判定:在语句收尾阶段,对每个登记的赋值点调用$this->variable_use_graph->isVariableUsed($assignment_node);如果没有任何路径从该赋值节点到达使用节点,则该赋值被判定为"未使用"。
  4. 豁免检查与发射:在发射前依次检查——变量名以下划线开头(str_starts_with($var_id, '$_'))则跳过(StatementsAnalyzer.php);按引用传入的函数参数、全局引用(referenced_globals)、超级全局变量(如$_SESSION)不算;foreach 值变量单独归类为UnusedForeachValue(StatementsAnalyzer.php);含 impure 析构函数的对象豁免(下一节详述);最后通过IssueBuffer::maybeAdd()发射。

也就是说,Psalm 并不是简单地数"变量名出现过几次",而是基于数据流分析判断某次赋值产出的值是否真正被消费。这也是它能区分$a = 5(未消费)与$b = 4; echo $b(已消费)的原因。

二、两种合法抑制方式

方式一:下划线前缀($_约定)

官方推荐的做法是用下划线前缀命名变量,Psalm 会直接跳过检测:

<?php $_a = 22;

对应源码中的豁免判断正是前文提到的str_starts_with($var_id, '$_')(StatementsAnalyzer.php),即只要变量标识符以$_开头,无论其是否被使用,都不会报告 UnusedVariable。

这一约定与 PHP 生态中"$_表示有意忽略"的惯用法一致,例如占位变量、仅为触发副作用而保留的赋值、或未来可能用到的临时值。需要说明的是:

  • 该规则对普通变量赋值、闭包use引入的变量同样生效(测试callableReferencesItself中通过@psalm-suppress UnusedParam配合使用,见 tests/UnusedVariableTest.php);
  • 它只豁免 UnusedVariable,不豁免其它 issue 类型,例如下划线变量如果类型错误仍会触发相应问题。

方式二:@psalm-suppress注释

与其它 Psalm issue 相同,UnusedVariable 也支持在语句或作用域上使用抑制注释:

<?php /** @psalm-suppress UnusedVariable */ $a = 5;

IssueBuffer::maybeAdd($issue, $this->getSuppressedIssues())的第二个参数会合并上下文中的抑制列表,从而实现按需静默。相比全局配置,@psalm-suppress粒度更细,适合"仅此一处确需保留"的场景。

三、impure 析构函数豁免:合法的副作用模式

这是原文档重点讲解的一条特殊豁免规则:

If the variable contains an object with an impure destructor, the issue is not emitted, to allow for patterns used for example in timers or mutexes.

即:如果变量持有的对象带有被标记为 impure(非纯)的析构函数(__destruct),则即使该变量从未被引用,也不会发射 UnusedVariable。这样做的目的是支持一类依赖"对象销毁时机"产生副作用的编程模式,例如:

  • 计时器(timer):对象销毁时打印耗时统计;
  • 互斥锁(mutex):对象销毁时自动释放锁,保证异常路径下也能解锁。

原文档示例:

<?php class Timer { private readonly float $start; public function __construct(private readonly string $op) { $this->start = microtime(true); } /** * Must be explicitly marked as @psalm-impure. * * @psalm-impure */ public function __destruct() { $took = microtime(true) - $this->start; // Send to influx, or similar echo "{$this->op} took $took seconds!\n"; } } function foo() : void { // OK! $timer = new Timer('file_get_contents'); echo file_get_contents('https://httpbin.io/delay/3'); } foo();

这里$timer变量在创建后并未被显式读取,但因其__destruct()被@psalm-impure标注(并且析构函数内部有echo等副作用),Psalm 判定销毁动作本身是有价值的,因此不报告 UnusedVariable。

源码层面的判定逻辑

豁免判定实现在StatementsAnalyzer::hasImpureDestructor()(StatementsAnalyzer.php):

private function hasImpureDestructor(?Union $type, Codebase $codebase): bool { if ($type === null) { return false; } foreach ($type->getAtomicTypes() as $atomic_type) { if (!$atomic_type instanceof TNamedObject) { continue; } $destructor_id = DestructorAnalyzer::getDestructorId($codebase, $atomic_type->value); if ($destructor_id === null) { continue; } $destructor = $codebase->methods->getStorage($destructor_id); if ($destructor->has_mutations_annotation && $destructor->capabilities === Capabilities::ALL) { return true; } } return false; }

从源码可以提炼出三个关键条件:

  1. 必须是命名对象类型:变量类型需包含TNamedObject(数组、标量、联合类型中的纯值类型都不满足);
  2. 必须能解析到析构函数:通过DestructorAnalyzer::getDestructorId()能定位到类的__destruct(若类根本没有定义析构函数,$destructor_id === null,直接跳过);
  3. 析构函数必须显式标注为 impure:$destructor->has_mutations_annotation && $destructor->capabilities === Capabilities::ALL—— 即必须在析构函数 docblock 中写@psalm-impure(或等价注解)。

第 3 点正是原文档注释 "Must be explicitly marked as@psalm-impure" 的来源。测试用例impureDestructor(tests/UnusedVariableTest.php)验证了显式标注@psalm-impure后不再报错;而测试用例impureDestructorImplicit(tests/UnusedVariableTest.php)则验证了只写了echo但没有@psalm-impure标注的析构函数不算数,仍会报告 UnusedVariable——这印证了"必须显式标注"这一约束。

注意:测试中的impureDestructorImplicit案例同时说明,Psalm 并不试图通过函数体内容来推断析构函数是否为 impure,注解是必须的。

四、不可豁免的边界:foreach 值变量

原文档特别强调了一条边界规则:

$var = [new Timer('bad')]; // UnusedVariable: The issue will still be emitted even if the destructor is impure for variables used as foreach variables. foreach ($var as $timer) { }

即:即使对象带 impure 析构函数,只要变量是作为foreach的值变量使用,UnusedVariable 依旧会被发射。换句话说,impure 析构函数豁免不适用于 foreach 值变量。

从源码看,这一行为源于检测分支的顺序(StatementsAnalyzer.php):

  1. 首先通过foreach_var_locations判断当前赋值点是否为 foreach 值变量($is_foreach_var);
  2. 若是 foreach 值变量,直接构造UnusedForeachValueissue(而不是 UnusedVariable);
  3. 只有非 foreach 值变量,才会走到hasImpureDestructor()的豁免分支。

这意味着对 foreach 值变量而言,impure 析构函数检查被完全跳过。同时注意:本例中 foreach 循环体内为空,$timer在循环中既未读取也未使用,Psalm 报告 UnusedVariable(原文档示例代码的注释也明确这一点)。

与 UnusedVariable 同族的UnusedForeachValue问题(issue 文档见 docs/running_psalm/issues/UnusedForeachValue.md)专门负责 foreach 值未使用的情形,其报告条件等价于:foreach 值变量在循环体内从未被引用。测试用例detectUnusedVariableInsideLoopAfterAssignment(tests/UnusedVariableTest.php)展示了foreach ([1, 2, 3] as $i) { $i = $i; }这种"仅自我赋值"的循环仍会被判定为未使用值。

五、如何开启 UnusedVariable 检测

UnusedVariable 属于特性型 issue,必须开启对应特性才会报告。有三种开启途径:

1. 命令行参数(临时开启)

# 开启未使用变量检测(最直接) vendor/bin/psalm --find-unused-variables # 通过死代码检测一并开启(auto:只在能确定时报告) vendor/bin/psalm --find-dead-code vendor/bin/psalm --find-unused-code # 强制开启死代码检测(always:宁可误报也要全覆盖) vendor/bin/psalm --find-unused-code=always

命令行参数的解析逻辑见 src/Psalm/Internal/Cli/Psalm.php:

  • --find-unused-variables为无值开关,命中后$find_unused_variables = true(第 344 行);
  • --find-dead-code与--find-unused-code互为别名关系:--find-dead-code会被映射为--find-unused-code,值为always或auto(shouldFindUnusedCode(),第 1312-1317 行);
  • 最终在 Psalm 主流程中,满足任一条件即调用$project_analyzer->getCodebase()->reportUnusedVariables()(第 1375-1377 行);
  • reportUnusedVariables()(src/Psalm/Codebase.php)会同时打开collect_references = true与find_unused_variables = true,后者是 StatementsAnalyzer 进入未使用变量分析分支的总开关。

2. psalm.xml 配置(持久化)

在psalm.xml的根元素上配置:

<psalm findUnusedVariablesAndParams="true" > </psalm>

配置项说明(对应 docs/running_psalm/configuration.md):

配置属性含义默认值
findUnusedVariablesAndParams是否查找所有未使用变量(等价于命令行--find-unused-variables)false
findUnusedCode是否查找所有未使用代码(含未使用变量,等价于--find-unused-code)true(Psalm 6 起默认开启)

配置解析位于 src/Psalm/Config.php:

  • findUnusedCode为true或1时,会连带把find_unused_variables一并置为 true(第 1151-1155 行);
  • findUnusedVariablesAndParams独立控制find_unused_variables(第 1177-1180 行);
  • 额外规则:当errorLevel="1"且未显式设置上述两个属性、showMixedIssues未被禁用时,Psalm 会自动开启未使用变量检测(第 1196-1203 行)——即在 level 1 下 UnusedVariable 默认开启。

3. Language Server(编辑器内实时报告)

Psalm 的 Language Server 也会遵循配置:当find_unused_variables为 true 时调用reportUnusedVariables()(src/Psalm/Internal/LanguageServer/LanguageServer.php),使编辑器内实时显示未使用变量的诊断信息。

关于 errorLevel 的说明

正如前文所述,UnusedVariable 属于 Feature-specific errors:只有在对应特性开启时才会被报告,且一旦开启就按错误级别处理,无法通过调高 errorLevel 来降级或静默(参见 docs/running_psalm/error_levels.md 的 "Feature-specific errors" 分类)。要关闭它,只能关闭对应特性(移除命令行参数 / 设置findUnusedVariablesAndParams="false"、findUnusedCode="false")或使用逐处抑制手段。

六、哪些"看似未使用"其实不会误报

Psalm 的变量使用分析经过了大量边界打磨,以下情况不会报告 UnusedVariable(多数可在 tests/UnusedVariableTest.php 的providerValidCodeParse中找到对应测试):

场景说明测试用例示例
数组/偏移量读取echo $arr[$a]、unset($arr[$a])视为使用$aarrayOffset、unset
条件分支中的使用if/else、switch、while、do-while 各分支中的引用varDefinedInIfWithReference、loopReassigned
foreach值变量在循环体使用循环体内读取(含&引用)byrefInForeachLoopWithReference
引用传递&$a按引用传参、use (&$var)闭包引用捕获dummyByRefVar、byRefVariableUsedInAddition
超级全局变量$_SESSION、$_GET等不受此检测影响superGlobalInFunction
全局变量 / 静态变量global $a、static $x的跨作用域使用globalVariableUsage、staticVar
动态方法/类名调用$a->$methodName()、new $d($e)中的变量使用usedMethodCallVariable、newVariableConstructor
eval/ 字符串动态执行eval($v)视为使用了$veval
析构函数豁免见第三节impureDestructor
try/catch/finally 交叉使用try 赋值、catch/finally 读取useVariableInsideTry、useTryAssignedVariableInsideFinally
循环中break/continue后的使用分支汇合后仍被读取loopSetIfNullWithBreakAndReference等
闭包捕获function () use ($i)视为使用regularVariableClosureUseInAddition

反过来,以下情形会报告 UnusedVariable(对应providerInvalidCodeParse):仅自增自减不读取($a++、$a += 1)、所有分支都赋值但最终不读取(ifInBothBranchesWithoutReference)、循环内赋值但循环外无引用(loopTypeChangedInIfWithoutReference)等。测试文件 tests/UnusedVariableTest.php 全文件约 3800 行,覆盖了 200+ 个正反场景,是理解检测边界的最佳参照物。

七、进阶:用 psalter 自动移除未使用赋值

除了报告问题,Psalm 还提供了自动修复能力:配合psalter工具,可以在安全前提下自动删除被判定为未使用的赋值语句。

工作流

# 1. 开启变量检测并指定需要修复的 issue vendor/bin/psalter --issues=UnusedVariable --find-unused-variables # 2. 或指定全部可修复 issue vendor/bin/psalter --issues=all --find-unused-variables

执行后 psalter 会直接改写源文件,删除可安全移除的未使用赋值。

底层实现

自动移除逻辑由 src/Psalm/Internal/Analyzer/Statements/UnusedAssignmentRemover.php 承担。触发点在 StatementsAnalyzer 发射 UnusedVariable 之前(StatementsAnalyzer.php):

if ($codebase->alter_code && $issue instanceof UnusedVariable && !$unused_var_remover->checkIfVarRemoved($var_id, $original_location) && isset($project_analyzer->getIssuesToFix()['UnusedVariable']) && !IssueBuffer::isSuppressed($issue, $this->getSuppressedIssues()) ) { $unused_var_remover->findUnusedAssignment( $this->getCodebase(), $stmts, array_combine($var_list, $loc_list), $var_id, $original_location, ); }

findUnusedAssignment()会基于 AST 定位赋值语句(findAssignStmt),并区分几种处理策略:

  • 纯赋值:整条$a = 5;直接删除;
  • 右侧是函数调用:$a = substr(...)会退化为裸调用substr(...),保留副作用(测试removeUnusedVariableAndFunctionCall,tests/FileManipulation/UnusedVariableManipulationTest.php);
  • 链式赋值:$a = $b = ...场景会通过checkRemovableChainAssignment()判断能否整链移除;
  • 副作用保留:$a = "hello".bar()保留"hello".bar()表达式,只去掉赋值(测试removeUnusedVariableFunctionCallAndStrLiteral);
  • 保守场景不删:可能未定义(PossiblyUndefined)、for/while循环条件中出现的变量、被抑制的 issue 均不删除(对应测试dontRemovePossiblyUndefined、dontremoveUnusedVariableFor、dontremoveUnusedVariableWhile)。

测试佐证

FileManipulation/UnusedVariableManipulationTest.php 以input → output成对用例验证自动修复行为,例如:

// input class A { public function foo() : void { $a = 5; $b = "hello"; echo $b; } } // output($a = 5 被安全删除) class A { public function foo() : void { $b = "hello"; echo $b; } }

从测试还可以看到,自动修复是保守的:例如$a = substr("wonderful", 2)这类带函数调用的赋值,删除赋值后函数调用本身会被保留,避免意外移除副作用;而dontRemovePossiblyUndefined用例证明,当删除赋值可能导致PossiblyUndefinedVariable时,Psalm 会放弃修复。

八、与其他相关 issue 的区分

UnusedVariable 常与同族 issue 混淆,放在一起对比更有助于准确理解:

Issue关注对象与 UnusedVariable 的区别
UnusedVariable普通变量的赋值变量赋值后无任何引用
UnusedForeachValueforeach 值变量foreach 值在循环体内未被使用(见文档)
UnusedParam函数/方法参数参数在函数体内未被使用(文档)
UnusedClosureParam闭包参数闭包参数未被使用(文档)
UnusedDocblockParamdocblock 声明的参数文档块中声明的参数未被使用
PossiblyUnusedMethod/PossiblyUnusedProperty等类成员公开但可能未使用的成员(--find-unused-code下)

从源码实现看,foreach 值变量与普通变量在检测入口处共用同一套unused_var_locations登记机制,只是通过foreach_var_locations(StatementsAnalyzer.php)区分归属——这解释了为何二者的消息文本完全相同(都是$var is never referenced or the value is not used),但 issue 类型不同。

九、实战要点小结

  1. 默认行为:Psalm 6 起findUnusedCode默认开启,但未使用变量的完整检测默认关闭;--find-dead-code/--find-unused-code(auto 模式)只报告能够确定的场景,findUnusedVariablesAndParams="true"或--find-unused-variables才做完整变量检测。
  2. 合法抑制:优先用$_前缀表达"有意忽略",其次用@psalm-suppress UnusedVariable做局部静默;不要用关闭整个特性来掩盖代码质量问题。
  3. 副作用豁免:依赖析构函数产生副作用的对象(timer、mutex、日志记录器等),必须给__destruct()加@psalm-impure注解才会被豁免;不加注解照样报 UnusedVariable,且 foreach 值变量即使带 impure 析构函数也不豁免。
  4. 自动清理:psalter --issues=UnusedVariable可以自动删除未使用赋值,且对函数调用副作用采取保守策略;建议在 CI 或提交前运行,并配合测试保证行为不变。
  5. 区分 issue 类型:foreach 值未使用报UnusedForeachValue,参数未使用报UnusedParam,别把它们当成 UnusedVariable 的误报。

如需深入验证某个边界行为,最直接的方式是阅读 tests/UnusedVariableTest.php 中对应命名的测试用例,或在本地运行vendor/bin/psalm --find-unused-variables复现。

  • 开发工具
  • 代码质量
  • 质量保障

【免费下载链接】psalm

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

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

相关推荐

上一篇:Wand-Enhancer 完整教程:十分钟解除 2 小时修改器限制,把 Wand 变成手机可控
下一篇:告别页面卡顿:用Immutable.js和Web Workers构建流畅数据处理体验

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:54:37

Jetson Nano MIPI CSI摄像头全栈调试指南:从设备树到Argus API

1. 为什么非得用板载MIPI CSI接口——从“能跑通”到“真可用”的分水岭Jetson Nano上接摄像头&#xff0c;新手第一反应往往是USB免驱即插即用&#xff1a;插上一个罗技C920&#xff0c;cv2.VideoCapture(0)一调&#xff0c;画面就出来了。看起来很美&#xff0c;但只要项目往…

作者头像 李华
网站建设 2026/10/13 1:54:20

从零到第一个网页跑通:Scrapling 网页抓取环境配置指南

从零到第一个网页跑通&#xff1a;Scrapling 网页抓取环境配置指南 【免费下载链接】Scrapling &#x1f577;️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl! Dont be shy, join here: https://discord.gg/EMgG…

作者头像 李华
网站建设 2026/10/13 1:52:16

在 ASUS 主板上让 FanControl 拿回 CPU 风扇控制权,四步搞定

在 ASUS 主板上让 FanControl 拿回 CPU 风扇控制权&#xff0c;四步搞定 【免费下载链接】FanControl.Releases This is the release repository for Fan Control, a highly customizable fan controlling software for Windows. 项目地址: https://gitcode.com/GitHub_Trend…

作者头像 李华
网站建设 2026/10/13 1:51:05

koa2-note 实战:使用 koa-static 中间件为 Koa2 应用托管静态资源

文档教程后端 【免费下载链接】koa2-note 《Koa2进阶学习笔记》已完结&#x1f384;&#x1f384;&#x1f384; 项目地址&#xff1a; https://gitcode.com/gh_mirrors/ko/koa2-note 点击查看 免费下载 本文基于《Koa2进阶学习笔记》开源仓库中的 note/static/middleware.md …

作者头像 李华
网站建设 2026/10/13 1:49:46

JMeter CSV参数化核心原理与实战避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华