PHP 里处理多层嵌套数组的场景很常见,解析 JSON 得到的响应、加载配置文件、遍历目录树都可能碰到不确定层级的数据结构。常规的做法是写一个递归函数,把非数组的叶子值收集到一个结果数组里统一返回。这种方式在小数据量下足够直观,但返回数组必须在内存中完整构建,所有叶子节点和中间数组都会同时存在。数据规模达到几万甚至几十万条时,PHP 进程的内存占用会迅速增加,可能直接触发 memory_limit。借助生成器可以把递归过程改造成惰性产出,每遍历到一个叶子就立即让调用方处理,不需要一次性保存整个结果集。

一、传统递归遍历的局限
先看一种最常见的递归展开写法:函数接收一个数组,遇到数组元素就继续递归,遇到标量值就追加到结果数组中,最后返回完整的扁平数组。代码结构简洁,问题在于每次递归调用都会创建一个局部数组,返回给上一级时还需要通过 array_merge 合并。以一棵五层深、每层五个子节点的树为例,叶子数量会快速增长,所有中间数组和最终结果数组会同时存在于内存中,空间占用远大于原始数据本身。
function flatten(array $items): array
{
$result = [];
foreach ($items as $item) {
if (is_array($item)) {
$result = array_merge($result, flatten($item));
} else {
$result[] = $item;
}
}
return $result;
}
这种实现并不只是在峰值内存上吃亏。array_merge 每次都会复制一份已累积的元素,导致时间复杂度也会因为反复复制而上升。PHP 的数组本身内存开销比标量值大得多,每个元素都带有哈希表节点、键和值的结构信息。当原始数据本身已经占用可观内存时,再复制一份扁平结果很容易让进程逼近 memory_limit。另一个容易忽略的点是递归深度,PHP 没有尾递归优化,层级过深时调用栈也会持续增长,Xdebug 默认的最大嵌套级别甚至可能直接抛出异常。
二、用生成器改写递归展开
生成器的核心价值在于函数不再一次性返回结果,而是返回一个 Generator 对象。函数体在你真正迭代这个对象时才会执行,每次遇到 yield 关键字就产出一个值并暂停,等待下一次迭代请求。将递归逻辑和 yield 结合,就可以让函数在遍历到叶子节点时立即把值交给调用方,而不是把值塞进某个大数组里等待最终返回。
function flattenGenerator(iterable $items): Generator
{
foreach ($items as $value) {
if (is_array($value)) {
yield from flattenGenerator($value);
} else {
yield $value;
}
}
}
这里用到了 PHP 7 引入的 yield from 语法,它可以把子生成器的所有输出直接委托给外层生成器。对于数组中的某个子数组,函数会进入递归调用,递归调用内部继续按相同逻辑逐层向下,直到遇到标量值后产出。yield from 会自动处理 Generator 对象之间的数据传递,不需要你在递归返回后再手动遍历合并子结果。整个过程只维护当前的生成器栈和当前值,不会创建保存所有叶子节点的大数组。
调用方式和普通遍历一样简单,直接使用 foreach 即可。值得注意的是,生成器的执行是交替进行的:外层循环执行到某个数组元素时进入递归生成器,递归生成器在下一次迭代时继续执行,产出叶子后又返回到上一级继续。这种协程式执行避免了完整中间结果的累积,也让代码读起来仍然保持递归的清晰结构。
三、内存对比与实验结果
为了更直观地观察差异,可以构造一个规模较大的嵌套数组,分别用传统递归和生成器方案处理,并记录内存峰值。下面的测试代码生成一个四层结构,每层包含五个子节点,叶子总数会达到数千个。传统递归会返回一个包含全部叶子值的大数组,而生成器方案只是在 foreach 循环中逐个接收值。
function buildTree(int $depth, int $breadth): array
{
if ($depth === 0) {
return range(1, $breadth);
}
$children = [];
for ($i = 0; $i < $breadth; $i++) {
$children[] = buildTree($depth - 1, $breadth);
}
return $children;
}
$tree = buildTree(4, 5);
$start = memory_get_peak_usage();
$flat = flatten($tree);
$peak1 = memory_get_peak_usage() - $start;
unset($flat);
$start = memory_get_peak_usage();
$sum = 0;
foreach (flattenGenerator($tree) as $value) {
$sum += $value;
}
$peak2 = memory_get_peak_usage() - $start;
实际运行中,传统递归的峰值内存会随着叶子数量线性增长,而且包含大量中间数组的额外开销。生成器方案的峰值内存则基本保持在一个较低水平,因为 foreach 循环内部只保存当前值、累加结果以及生成器调用栈。数据规模越大,两者差距越明显。对于需要从大文件或大接口响应中边读取边处理的场景,生成器几乎是更务实的选择。
这种内存优势并不是没有代价的。生成器只能前进,无法回退,同一个 Generator 对象在遍历结束后不能再次从头迭代。如果需要多次遍历或者随机访问某个位置,要么重新调用生成器函数创建新实例,要么把结果一次性收集到数组里。生成器适合流式处理、单次消费型任务,例如批量写库、导出 CSV、数据清洗、逐条发送消息等。
四、使用限制与工具选型
虽然生成器减少了内存峰值,但它并没有消除递归本身的栈深度问题。yield from 让递归调用仍然占用函数调用栈,层级过深时依然可能触发 Xdebug 的 max nesting level 限制,甚至在极端情况下导致栈溢出。对于宽而浅的树形结构,生成器优势最明显;对于极深的链表式嵌套,可能需要考虑改用循环加显式栈来遍历。
PHP 标准库还提供了 RecursiveIteratorIterator,它可以遍历实现了 RecursiveIterator 接口的对象。这套接口体系更面向对象,好处是迭代器对象可以被复用,也能和其他 SPL 组件组合。缺点是使用前必须为数据结构实现 hasChildren 和 getChildren 等方法,代码量比一个生成器函数多不少。如果你的数据源天然是对象而不是数组,或者需要把遍历逻辑交给统一的迭代器接口,RecursiveIteratorIterator 更合适;如果只是想快速扁平化一个数组并逐个处理,生成器方案更轻便。
还需要注意键名保留问题。上面的生成器示例直接 yield $value,丢弃了原始键名。如果你需要在处理时知道叶子来自哪个顶层键,可以在递归调用时增加一个路径参数,把每一层键拼接起来,或者只保留顶层键。不要直接在递归中 yield $key => $value,因为不同层级的索引键可能重复,在迭代时容易出现令人困惑的覆盖和重名。明确需求后再决定是否保留键名,能让代码行为更可预期。
综合来看,把嵌套数组遍历从立即返回数组改为生成器惰性产出,是一种投入小、收益明显的内存优化手段。尤其在处理外部输入、日志聚合、批量任务等数据量不可控的场景中,这种写法可以有效降低 PHP 进程的峰值内存,让程序更稳定、更可扩展。