前言
递归(recursion)的规则很简单:函数直接或间接地调用自己,并且保证某一步之后不再调用自己。前者是「递归调用」,后者是「终止条件」,缺一不可。
真正容易出问题的是结果怎么收集。递归展开之后会有一串同时活着的调用帧,每一帧都有自己的局部变量,那么「计算出来的中间结果往哪儿放」就成了分岔口,也正是在这里分出了流传最广的三种写法:
- 返回值累加法:函数把结果
return给上一层,由上一层汇总; - 引用传参法:所有调用共享同一个
&$result容器,谁算出来谁往里塞; - 静态变量(或全局变量)法:把累加器写成
static $x,靠它的跨调用持久化保存状态。
这三种写法都能得到正确结果,但可靠性完全不同。第三种在教程里出现频率极高,问题也最多:static变量在整个进程生命周期内只初始化一次,两次调用同一个函数,第二次会接着上次的结果继续累加——这不是 bug,是它的定义。本文把三种方式都给出可运行的完整示例,并说明各自适合什么场合。
一、递归的两个必要条件
任何递归函数都必须满足:
- 有终止条件(base case):某一种输入下不再调用自己,直接返回;
- 每次调用都向终止条件收敛:参数在一次次的调用中单调靠近终止条件。
违反第一条会无限递归;违反第二条(比如递归时参数没变)等于变相的无限递归。PHP 没有内置的递归深度保护,无限递归会耗尽调用栈,进程崩溃退出。
还有一件事必须说清楚:PHP 不实现尾调用优化(Tail Call Optimization)。即使你把递归写成「最后一步就是 return 自己的调用」这种规范尾递归形式,调用栈也照样一层层加深。所以递归深度必须自己控制。
二、方式一:返回值累加(最推荐)
思路:每一层只负责自己那一小块,结果通过return往上汇总。
<?php // 适用于 PHP 7.0+
declare(strict_types=1);
/**
* 统计一棵目录树里有多少个文件
*
* @param array<string, 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.0+
declare(strict_types=1);
/**
* 收集目录树里所有文件的路径
*
* @param array<string, mixed> $node
* @param list<string> $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; // 3
echo countFilesStatic($tree), PHP_EOL; // 6 —— 不是 3!第二次调用输出的是 6,因为$total从未被重置。这就是这种写法的核心问题:static $total的生存期是整个请求,不是「一次递归调用」;它在函数第一次执行时初始化,之后每次进入函数都保留上次的值。
要让它可重复调用,只能显式加一个重置开关,把「重置」和「统计」耦合在同一个参数上:
<?php // 适用于 PHP 5.0+
function 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.3+
declare(strict_types=1);
$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 没有尾调用优化,递归深度必须自己控制,超出承受范围就换成显式栈加循环。