
前言递归recursion的规则很简单函数直接或间接地调用自己并且保证某一步之后不再调用自己。前者是「递归调用」后者是「终止条件」缺一不可。真正容易出问题的是结果怎么收集。递归展开之后会有一串同时活着的调用帧每一帧都有自己的局部变量那么「计算出来的中间结果往哪儿放」就成了分岔口也正是在这里分出了流传最广的三种写法返回值累加法函数把结果return给上一层由上一层汇总引用传参法所有调用共享同一个$result容器谁算出来谁往里塞静态变量或全局变量法把累加器写成static $x靠它的跨调用持久化保存状态。这三种写法都能得到正确结果但可靠性完全不同。第三种在教程里出现频率极高问题也最多static变量在整个进程生命周期内只初始化一次两次调用同一个函数第二次会接着上次的结果继续累加——这不是 bug是它的定义。本文把三种方式都给出可运行的完整示例并说明各自适合什么场合。一、递归的两个必要条件任何递归函数都必须满足有终止条件base case某一种输入下不再调用自己直接返回每次调用都向终止条件收敛参数在一次次的调用中单调靠近终止条件。违反第一条会无限递归违反第二条比如递归时参数没变等于变相的无限递归。PHP 没有内置的递归深度保护无限递归会耗尽调用栈进程崩溃退出。还有一件事必须说清楚PHP 不实现尾调用优化Tail Call Optimization。即使你把递归写成「最后一步就是 return 自己的调用」这种规范尾递归形式调用栈也照样一层层加深。所以递归深度必须自己控制。二、方式一返回值累加最推荐思路每一层只负责自己那一小块结果通过return往上汇总。?php // 适用于 PHP 7.0declare(strict_types1);/*** 统计一棵目录树里有多少个文件** param arraystring, mixed $node*/function countFiles(array $node): int{// 终止条件叶子节点文件if ($node[type] file) {return 1;}$total 0;foreach ($node[children] as $child) {$total countFiles($child); // 子树的结果汇总到本层}return $total;}$tree [type dir,children [[type file],[type dir,children [[type file],[type file],],],],];echo countFiles($tree), PHP_EOL; // 3优点非常明确函数是纯函数只依赖参数、只通过返回值输出没有隐藏状态。同一棵树的递归可以并行、可以重复调用、可以单独测试结果永远一致。代价是每一层的中间值都要等下层返回才能算调用栈上的帧全部要保留到最后。三、方式二与方式三引用传参、静态变量方式二引用传参思路把结果容器作为引用参数传进去所有调用共享同一个容器。?php // 适用于 PHP 5.0declare(strict_types1);/*** 收集目录树里所有文件的路径** param arraystring, mixed $node* param liststring $out*/function collectPaths(array $node, array $out): void{if ($node[type] file) {$out[] $node[name] ?? (unnamed);return;}foreach ($node[children] as $child) {collectPaths($child, $out);}}$tree [type dir,children [[type file, name a.txt],[type dir, children [[type file, name b.txt],]],],];$paths [];collectPaths($tree, $paths);print_r($paths); // Array ( [0] a.txt [1] b.txt )要点函数签名里必须写array $out在参数名前面调用时也必须传变量collectPaths($tree, [])会报错因为引用参数不接受字面量或表达式。累加容器$paths由调用方提供函数自己不需要返回值返回类型写void。这种写法在需要同时收集多种信息时特别方便——比如再加一个$size累加总字节数就不用把返回值改成一个复杂的结构。方式三静态变量 / 全局变量思路把累加器写成函数内的static变量或函数外的global变量靠它跨调用保存状态。?php // 适用于 PHP 5.0但写法有陷阱见下文function countFilesStatic(array $node): int{static $total 0; // 只初始化一次之后所有调用共享if ($node[type] file) {$total;} else {foreach ($node[children] as $child) {countFilesStatic($child);}}return $total;}echo countFilesStatic($tree), PHP_EOL; // 3echo countFilesStatic($tree), PHP_EOL; // 6 —— 不是 3第二次调用输出的是 6因为$total从未被重置。这就是这种写法的核心问题static $total的生存期是整个请求不是「一次递归调用」它在函数第一次执行时初始化之后每次进入函数都保留上次的值。要让它可重复调用只能显式加一个重置开关把「重置」和「统计」耦合在同一个参数上?php // 适用于 PHP 5.0function countFilesStatic2(array $node, bool $reset false): int{static $total 0;if ($reset) {$total 0; // 只有顶层调用会传 true}if ($node[type] file) {$total;} else {foreach ($node[children] as $child) {countFilesStatic2($child);}}return $total;}用global变量是同一个问题的另一种形态还额外污染了全局作用域更不可取?php // 适用于 PHP 5.0不推荐function countFilesGlobal(array $node): int{global $fileTotal; // 依赖一个外部约定存在的全局变量if ($node[type] file) {$fileTotal;} else {foreach ($node[children] as $child) {countFilesGlobal($child);}}return $fileTotal;}结论很直接能用方式一就用方式一需要收集多项结果时用方式二方式三只在阅读老代码时用来识别它的语义。静态变量写法唯一的优势是函数签名干净不需要额外的引用参数代价是函数变成了「有隐藏状态」的函数不可重入、难测试。四、补充匿名函数递归以及什么时候不该用递归匿名函数的自引用递归用闭包写递归时必须在use里按引用捕获自己否则闭包内部看不到这个变量?php // 适用于 PHP 5.3declare(strict_types1);$factorial function (int $n) use ($factorial): int {return $n 1 ? 1 : $n * $factorial($n - 1);};echo $factorial(5), PHP_EOL; // 120注意use ($factorial)里的值捕获会在闭包创建时把当时的值复制进去而那时变量还没赋值完。箭头函数fn() ...只能按值自动捕获因此很难用来自引用递归。什么时候不该用递归三种方式都掌握之后还要知道什么时候根本不该递归深度不可控的数据。递归深度可能撞上栈上限导致进程崩溃而 PHP 不会给你一个可捕获的异常。更稳妥的做法是改用显式栈加while循环——把待处理节点压入数组循环取出、处理、把子节点压回。存在大量重叠子问题。典型是朴素的 Fibonacci 递归同一子问题被反复展开复杂度 O(2^n)。改成迭代O(n)或加记忆化缓存。PHP 提供了内置的递归实现。遍历目录用RecursiveDirectoryIterator遍历嵌套数组用array_walk_recursive()深拷贝复杂结构可以考虑序列化。这些内置实现是 C 层的且不会占用 PHP 用户态的调用栈。顺带一提如果开发机上装了 Xdebug还会额外受xdebug.max_nesting_level的限制这是一个 Xdebug 的 ini 配置项默认值 256递归一旦超过就会被拦下并报错。它只在装了 Xdebug 的环境里生效别把它当成 PHP 自身的保护。常见坑点❌ 递归函数没有终止条件或参数不向终止条件收敛 —— ✅ 无限递归耗尽调用栈、进程崩溃PHP 没有内建的递归深度保护。❌ 用static $x在递归函数里做累加器然后连续调用两次 —— ✅ 静态变量跨调用共享第二次会接着上次的值继续累加要可重入就必须在顶层显式重置或改成方式一/方式二。❌ 把字面量传给引用参数collectPaths($tree, [])—— ✅ 引用参数只接受变量会直接报错必须先定义$out [];再传。❌ 在foreach里对正在遍历的数组做增删 —— ✅ 遍历行为不保证符合预期先把结果收集到独立数组循环结束后再合并。❌ 写完foreach ($arr as $v)忘了unset($v)—— ✅$v仍然是最后一个元素的引用之后再用$v或再跑一次foreach会写坏数组循环后立刻unset($v)。❌ 用朴素递归求 Fibonacci 或类似的重复子问题 —— ✅ 复杂度 O(2^n)参数稍大就跑不动改迭代或加记忆化。❌ 期待 PHP 做尾递归优化 —— ✅ PHP 不实现尾调用优化写成尾递归形式依然会加深调用栈。❌ 用递归处理深度由用户输入决定的结构深层嵌套 JSON、深层目录—— ✅ 深度不可控可能直接崩进程换成显式栈加while循环或改用内置的迭代实现。总结方式结果收集可重复调用可测试性适用场合返回值累加return向上汇总是好纯函数首选计算一个汇总值引用传参共享$result容器是需先建容器一般一次遍历收集多项结果静态变量 / 全局变量函数外部或函数内的持久状态否需手工重置差仅在阅读老代码时辨认匿名函数自引用use ($fn)捕获自身是一般一次性闭包、回调式递归结论递归的难点从来不是「函数调用自己」而是「中间结果放在哪里」。选方式一时函数是纯的行为可预测、可以随便调用多少次选方式二时注意引用参数必须是变量方式三的static累加器在第二次调用时会给出错误结果除非你手工重置。最后记住两条硬约束——PHP 没有尾调用优化递归深度必须自己控制超出承受范围就换成显式栈加循环。