- 开发工具
- 代码质量
- 质量保障
【免费下载链接】psalm
A PHP static analysis tool for finding errors and security vulnerabilities in PHP applications
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 行)。核心机制可概括为四步:
- 登记赋值位置:每次变量赋值都会通过
registerVariableAssignment()把[$var_id, CodeLocation]记入$this->unused_var_locations(StatementsAnalyzer.php)。 - 构建变量使用图:Psalm 用
VariableUseGraph维护"赋值点 → 使用点"的数据流边(DataFlowNode::getForAssignment()创建赋值节点,引用时通过addPath连边)。 - 可达性判定:在语句收尾阶段,对每个登记的赋值点调用
$this->variable_use_graph->isVariableUsed($assignment_node);如果没有任何路径从该赋值节点到达使用节点,则该赋值被判定为"未使用"。 - 豁免检查与发射:在发射前依次检查——变量名以下划线开头(
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; }从源码可以提炼出三个关键条件:
- 必须是命名对象类型:变量类型需包含
TNamedObject(数组、标量、联合类型中的纯值类型都不满足); - 必须能解析到析构函数:通过
DestructorAnalyzer::getDestructorId()能定位到类的__destruct(若类根本没有定义析构函数,$destructor_id === null,直接跳过); - 析构函数必须显式标注为 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):
- 首先通过
foreach_var_locations判断当前赋值点是否为 foreach 值变量($is_foreach_var); - 若是 foreach 值变量,直接构造
UnusedForeachValueissue(而不是 UnusedVariable); - 只有非 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])视为使用$a | arrayOffset、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)视为使用了$v | eval |
| 析构函数豁免 | 见第三节 | 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 | 普通变量的赋值 | 变量赋值后无任何引用 |
UnusedForeachValue | foreach 值变量 | foreach 值在循环体内未被使用(见文档) |
UnusedParam | 函数/方法参数 | 参数在函数体内未被使用(文档) |
UnusedClosureParam | 闭包参数 | 闭包参数未被使用(文档) |
UnusedDocblockParam | docblock 声明的参数 | 文档块中声明的参数未被使用 |
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 类型不同。
九、实战要点小结
- 默认行为:Psalm 6 起
findUnusedCode默认开启,但未使用变量的完整检测默认关闭;--find-dead-code/--find-unused-code(auto 模式)只报告能够确定的场景,findUnusedVariablesAndParams="true"或--find-unused-variables才做完整变量检测。 - 合法抑制:优先用
$_前缀表达"有意忽略",其次用@psalm-suppress UnusedVariable做局部静默;不要用关闭整个特性来掩盖代码质量问题。 - 副作用豁免:依赖析构函数产生副作用的对象(timer、mutex、日志记录器等),必须给
__destruct()加@psalm-impure注解才会被豁免;不加注解照样报 UnusedVariable,且 foreach 值变量即使带 impure 析构函数也不豁免。 - 自动清理:
psalter --issues=UnusedVariable可以自动删除未使用赋值,且对函数调用副作用采取保守策略;建议在 CI 或提交前运行,并配合测试保证行为不变。 - 区分 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
相关推荐
Error Prone 未使用变量检查(UnusedVariable)实战指南:编译期拦截死代码与四种抑制方案
Error Prone 未使用变量检查(UnusedVariable)实战指南:编译期拦截死代码与四种抑制方案 未使用(Unused)的字段和参数往往是死代码的
静态分析代码质量开发工具JimuReport快速上手:15分钟做出你的第一张报表
JimuReport快速上手:15分钟做出你的第一张报表 老板临时要报表,你是不是又得花两天写SQL、一天调Excel格式?JimuReport积木报表是一款免
开发工具代码质量质量保障如何用 Repowise 检测死代码:5 步清理代码库僵尸代码的完整指南
如何用 Repowise 检测死代码:5 步清理代码库僵尸代码的完整指南 Repowise 是一款代码库智能分析工具,通过一条 repowise dead co
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考