PHP-Parser 在 enterNode 中替换节点为什么会无限递归?怎么避免
【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser
在基于 nikic/php-parser(下文简称 PHP-Parser)编写自定义节点访问器(NodeVisitor)做 AST 转换时,"返回一个新节点来替换当前节点"是最常用的写法。但官方文档明确提醒:如果这个替换发生在enterNode()中,且新节点又包住了原节点,递归遍历会不断进入新节点的子节点,可能陷入无限递归,脚本不会结束,直到 PHP 达到内存上限。本文按文档给出的最小例子讲清楚原因,并给出把替换挪到正确阶段的修正写法,让遍历能正常结束。
适用环境:PHP-Parser 5.x 文档版本,运行环境要求 PHP >= 7.4(该版本可解析 PHP 7.0 至 8.4 的代码)。
背景:enterNode 与 leaveNode 的调用时机不同
PhpParser\NodeVisitor接口定义了四个回调:beforeTraverse()、enterNode()、leaveNode()、afterTraverse()。与这个问题直接相关的是后两个:
enterNode()在节点第一次被遇到、其子节点被处理之前调用("preorder");leaveNode()在一个节点的所有子节点都访问完之后调用("postorder")。
文档给出了一个具体的调用顺序。对下面这段 AST 摘录:
Expr_FuncCall( name: Name( name: printLine ) args: array( 0: Arg( name: null value: Scalar_String( value: Hello World!!! ) byRef: false unpack: false ) ) )enter/leave 方法的调用顺序为:
enterNode(Expr_FuncCall) enterNode(Name) leaveNode(Name) enterNode(Arg) enterNode(Scalar_String) leaveNode(Scalar_String) leaveNode(Arg) leaveNode(Expr_FuncCall)关键在于:当访问器在enterNode()里返回一个新节点替换当前节点后,traverser 会继续递归遍历新节点的子节点。这一点在 NodeTraverser.php 的traverseNode()实现里可以直接看到:enterNode 返回的替换节点被写回 AST 之后,代码立即对该替换节点调用traverseNode();而 leaveNode 的返回值替换发生在子节点遍历完成之后,新节点不会再进入子节点遍历阶段。
如果你不想自己实现全部四个方法,建议继承NodeVisitorAbstract,它为每个方法提供了空的默认实现。
为什么在 enterNode 中做包装式替换会无限递归
文档使用的例子是:把所有$a && $b(Node\Expr\BinaryOp\BooleanAnd)表达式转换成!($a && $b),即用Node\Expr\BooleanNot包一层。BooleanNot只有一个子节点expr(见 BooleanNot.php 的getSubNodeNames())。
如果替换发生在enterNode:
public function enterNode(Node $node) { if ($node instanceof Node\Expr\BinaryOp\BooleanAnd) { // Convert all $a && $b expressions into !($a && $b) return new Node\Expr\BooleanNot($node); } }执行过程是:
- traverser 遇到
$a && $b,enterNode返回!($a && $b)完成替换; - 由于替换发生在
enterNode,traverser 接着进入!($a && $b)的第一个(也是唯一一个)子节点,而它正是原来的$a && $b节点; - 该节点再次命中
BooleanAnd条件,再包一层,变成!!($a && $b); - 第 2 步重复,
!逐层叠加。
文档的原话是:"This will continue until PHP hits the memory limit." 也就是说,这个问题的现象是转换脚本无法正常返回:进程一直运行、内存持续增长,最终可能因达到内存上限而中止。文档对这一行为的表述是:替换在enterNode和leaveNode里都受支持,但必须清楚替换发生的位置——"If a node is replaced in enterNode, then the recursive traversal will also consider the children of the new node. If you aren't careful, this can lead to infinite recursion."
修复:把替换挪到 leaveNode
官方文档给出的修正做法是把同样的替换逻辑放到leaveNode()中:
public function leaveNode(Node $node) { if ($node instanceof Node\Expr\BinaryOp\BooleanAnd) { // Convert all $a && $b expressions into !($a && $b) return new Node\Expr\BooleanNot($node); } }关键在于时机:leaveNode()被调用时,当前节点内部的所有代码都已经被访问过,包出来的!($a && $b)不会被重新遍历,转换对原来的$a && $b只应用一次,遍历随即正常结束。
文档同时指出一个常见模式:用enterNode()收集信息,用leaveNode()基于这些信息做修改——"At the time when leaveNode is called, all the code inside the node will have already been visited and necessary information collected."
一个容易混淆的点:NodeVisitor::DONT_TRAVERSE_CHILDREN不能用来解决本问题。按 NodeVisitor.php 的定义,返回它的语义是跳过当前节点的子节点,且 "$node stays as-is"(节点本身不被替换),所以它无法与"返回新节点做替换"同时生效;而且它只能在enterNode()中使用,走到leaveNode()时已经太迟。它适合的是"找到节点后跳过其子树"的场景,例如收集所有类声明时(PHP 不允许嵌套类,找到Node\Stmt\Class_后不必再进入其子节点),而不是替换节点。
复现问题并验证修复效果
先用 composer 安装库(仓库 README 给出的命令):
php composer.phar require nikic/php-parser下面是一个完整脚本,使用修正后(替换在leaveNode中)的访问器。代码中的path/to/vendor/autoload.php需替换为你自己项目中 composer autoload 的真实路径(该占位符来自基本组件使用文档):
<?php require 'path/to/vendor/autoload.php'; use PhpParser\Node; use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter; $code = <<<'CODE' <?php echo $a && $b; CODE; $parser = (new ParserFactory())->createForHostVersion(); $stmts = $parser->parse($code); $traverser = new NodeTraverser(); $traverser->addVisitor(new class extends NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Expr\BinaryOp\BooleanAnd) { return new Node\Expr\BooleanNot($node); } } }); $stmts = $traverser->traverse($stmts); echo (new PrettyPrinter\Standard())->prettyPrintFile($stmts);验证方式:
- 修正版:
traverse()正常返回,输出的代码中$a && $b被包成单层!($a && $b)(文档示例的转换目标即 "Convert all $a && $b expressions into !($a && $b)")。 - 复现原问题:把
leaveNode改回enterNode(即上一节"为什么无限递归"中的包装式替换代码)再运行,脚本会持续运行不终止,!逐层叠加,直到 PHP 达到内存上限。
createForHostVersion()创建针对当前运行 PHP 版本的解析器;如果分析的是任意来源的代码,文档建议改用createForNewestSupportedVersion(),它接受范围通常最宽,见基本组件使用文档。
边界与已知限制
- 在
enterNode中替换节点是库支持的合法操作,问题只出在"新节点的子节点会再次命中转换条件"。凡是"包一层"式的替换(原节点成为新节点的子节点),都应优先放到leaveNode()中执行。 - 替换的节点种类有限制:
NodeTraverser的ensureReplacementReasonable()方法(见 NodeTraverser.php)会在你用表达式(Node\Expr)替换语句(Node\Stmt)时抛出LogicException,错误信息提示检查是否缺少Stmt_Expression包装。 - 遍历高度嵌套的节点树时,文档建议将
xdebug.max_nesting_levelini 选项调高(文档示例为ini_set('xdebug.max_nesting_level', 3000);),并更推荐完全禁用 Xdebug,因为它可能让该库慢五倍以上。 - 多个访问器交替执行的规则、
REMOVE_NODE、REPLACE_WITH_NULL、STOP_TRAVERSAL等返回值的完整语义,都在Walking the AST中有说明,遇到更复杂的替换需求时对照该文档处理。
【免费下载链接】PHP-ParserA PHP parser written in PHP项目地址: https://gitcode.com/GitHub_Trending/ph/PHP-Parser
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考