用 array_walk 配合递归去”顺手”修改嵌套数组,是 PHP 里一个特别容易踩雷的写法。表面上看,回调函数里写了 $value = ...,跑完 print_r 却毫无变化;或者小心翼翼加上 &$value,结果顶层改了、深层没改;再或者想用 array_walk_recursive 一把梭,却发现它拿不到父级键名,也没法直接替换整个子树。这些现象不是 PHP 的 bug,而是 array_walk 的引用语义、数组内部指针、递归传参方式三者叠加后的必然结果。
这篇文章围绕一个目标展开:在多层嵌套数组上做批量改写,搞清楚什么时候该用 array_walk、什么时候必须用 array_walk_recursive、什么时候该老老实实写 foreach 递归函数。文中所有代码都可以直接复制到 php -a 或一个 .php 文件里跑,示例基于 PHP 7.4 与 PHP 8.x 两个常用版本线验证,其中会点出两个版本之间的行为差异。
一、先看清 array_walk 的签名与引用语义
官方签名是 array_walk(array &$array, callable $callback, mixed $arg = null): bool。注意第一个参数是引用,它决定了”数组本身可以被改”,但并不意味着”回调里的 $value 是引用”。回调最常见的形态有两种:
<?php
$arr = ['a' => 1, 'b' => 2];
// 形态一:$value 传值。改 $value 不会回写数组
array_walk($arr, function ($value, $key) {
$value = $value * 10;
});
var_dump($arr); // ['a' => 1, 'b' => 2] 没有任何变化
// 形态二:$value 声明为引用,才真正回写
array_walk($arr, function (&$value, $key) {
$value = $value * 10;
});
var_dump($arr); // ['a' => 10, 'b' => 20]
?>
这是第一层坑:array_walk 的数组参数是引用,回调的 value 参数默认不是引用。很多同学把这两件事混为一谈,只记住”array_walk 能改数组”,结果回调里改了个寂寞。
第二层坑更隐蔽。假如数组元素本身就是数组,你写成 function (&$value, $key),此时 $value 是该子数组的引用,你可以整体替换它,但如果你在里面又调用一次 array_walk($value, ...),那么这次调用的回调不会自动继承引用语义,因为 array_walk 从来不会把引用属性”传递”给回调的形参,它只按回调声明去绑定。
<?php
$config = [
'db' => ['host' => 'localhost', 'port' => 3306],
'cache' => ['host' => '127.0.0.1', 'port' => 6379],
];
// 想递归把字符串值加个前缀
function prefixNaive(array &$data, string $prefix): void
{
array_walk($data, function (&$value, $key) use ($prefix) {
if (is_array($value)) {
// 这里 $value 是引用,传给下一层函数时能保持引用
prefixNaive($value, $prefix);
} else {
$value = $prefix . $value;
}
});
}
prefixNaive($config, 'dev_');
print_r($config);
?>
上面这段能正常工作,原因是递归调用的是自己写的函数 prefixNaive(array &$data),形参显式声明了 &。真正的坑出现在”直接在回调里调用 array_walk”这种写法上:
<?php
$config = [
'db' => ['host' => 'localhost', 'port' => 3306],
'cache' => ['host' => '127.0.0.1'],
];
array_walk($config, function (&$value, $key) {
if (is_array($value)) {
// 注意:这里没有再写 &$value,第二层的修改就丢了
array_walk($value, function ($v, $k) {
$v = strtoupper($v);
});
}
});
print_r($config); // 完全没变
?>
这段代码不报错、不警告,静默失败。要让它生效,内层回调同样得写成 function (&$v, $k)。而一旦层级不确定,你就得写任意深度的嵌套闭包,显然不现实——这就是为什么递归必须交给”自带递归能力”的函数,或者自己封装带引用形参的递归函数。
二、array_walk_recursive 的真实能力边界
PHP 提供了 array_walk_recursive(array &$array, callable $callback, mixed $arg = null): bool,它会自动递归到最深层的叶子节点。它解决了”层级不确定”的问题,但带来两个必须记住的限制:
- 回调只对非数组元素(叶子)触发。子数组本身不会进入回调,所以你不能通过它替换整个子树,也不能删除某个中间层级。
- 回调第二个参数 $key 只给最内层键名。你拿不到父级路径,多层嵌套里出现同名 key 时无法区分。
<?php
$data = [
'user' => ['name' => 'tom', 'tags' => ['a', 'b']],
'admin' => ['name' => 'jerry'],
];
array_walk_recursive($data, function (&$value, $key) {
echo $key, ' => ', var_export($value, true), PHP_EOL;
});
// 输出:
// name => 'tom'
// 0 => 'a'
// 1 => 'b'
// name => 'jerry'
array_walk_recursive($data, function (&$value, $key) {
if (is_string($value)) {
$value = strtoupper($value);
}
});
print_r($data);
// user.name -> TOM,admin.name -> JERRY,tags 里的 a/b -> A/B
?>
这里有个容易被忽略的细节:回调第一个参数必须声明为引用,否则还是不会回写。上例如果写成 function ($value, $key),输出同样是原样。很多教程只演示 array_walk_recursive 的遍历输出,不提醒这一点,读者拿去做改写就踩空。
另一个高频报错来自参数顺序写错。PHP 8 对回调签名检查更严格,如果你把回调写成 function ($key, $value),在 PHP 7.x 下通常”能跑但语义错乱”(字符串键和值互换,看起来像数据被篡改),PHP 8 里则更容易因为类型或 is_array 判断爆出 TypeError。标准顺序永远是 (value, key, arg)。
还有一种常见需求:替换某个键名下所有叶子,但键名在多处重复。例如把所有 name 改成大写,但 user.name 想保留小写。用 array_walk_recursive 做不到(没有路径),必须自己维护路径栈。
三、自己写递归函数:引用形参 + 路径栈
当需求超出”逐叶子改写”,就不要再硬凑 array_walk 系列了。最稳的写法是自己封装一个带引用形参的递归函数,用额外参数收集路径。下面这个函数支持按键名或路径做条件改写,直接可用:
<?php
/**
* 递归改写嵌套数组,回调返回新值;返回 null 表示保持原值。
*
* @param array $data 待处理数组(引用)
* @param callable $callback function (string $path, mixed $value, mixed $key): mixed
* @param string $prefix 路径前缀,递归内部使用
*/
function walk_path(array &$data, callable $callback, string $prefix = ''): void
{
foreach ($data as $key => &$value) {
$path = $prefix === '' ? (string)$key : $prefix . '.' . $key;
if (is_array($value)) {
walk_path($value, $callback, $path);
continue;
}
$new = $callback($path, $value, $key);
if ($new !== null) {
$value = $new;
}
}
unset($value); // 关键:切断最后一个引用,防止后续 foreach 污染
}
$config = [
'db' => ['host' => 'localhost', 'name' => 'shop'],
'admin' => ['name' => 'root'],
'interval' => 30,
];
walk_path($config, function (string $path, $value) {
if ($path === 'admin.name') {
return null; // 保留原值
}
if (is_string($value)) {
return strtoupper($value);
}
if (is_int($value)) {
return $value * 2;
}
return null;
});
print_r($config);
// db.host => LOCALHOST,db.name => SHOP,admin.name => root,interval => 60
?>
两个要点值得单独说:
foreach ($data as $key => &$value)之后必须unset($value)。这是 PHP 引用循环的经典坑:循环结束后$value仍然指向数组最后一个元素的引用,如果函数外层还有同名的$value变量,后续赋值可能把最后一个元素改掉。在递归场景里这个 bug 会以”数组莫名其妙多了一个元素 / 最后一个元素被覆盖”的形式出现,极难定位。- 回调返回
null表示”不改”,因此不能通过该接口把值改成 null。若业务里 null 是合法值,请改用哨兵常量,比如定义一个const KEEP = "\0__keep__";作为”保持不变”的标记。
如果你更倾向于沿用 array_walk 家族的性能特征,也可以写成这样,等价且更短:
<?php
function walk_path2(array &$data, callable $callback, string $prefix = ''): void
{
array_walk($data, function (&$value, $key) use ($callback, $prefix) {
$path = $prefix === '' ? (string)$key : $prefix . '.' . $key;
if (is_array($value)) {
walk_path2($value, $callback, $path);
return;
}
$new = $callback($path, $value);
if ($new !== null) {
$value = $new;
}
});
}
?>
注意这里外层 array_walk 的回调形参写了 &$value,内层递归把 $value(已经是引用)传给 walk_path2(array &$data),引用链完整,修改能逐层回写。这是”用 array_walk 做递归”能成立的正确姿势——前提是递归调用的是声明了引用形参的自定义函数,而不是又一次把 $value 按值传进 array_walk。
四、几个真实会遇到的报错与诡异现象
1. “Only variables should be passed by reference”
常见于把函数返回值直接当 array_walk 的第一个参数:
<?php
// 报错:Only variables should be passed by reference
array_walk(get_config(), function (&$v) { $v = trim($v); });
?>
原因:第一个参数要求变量引用,函数返回的临时值不是变量。解决:先赋值给变量再传。
<?php
$data = get_config();
array_walk($data, function (&$v) { $v = trim($v); });
?>
这类逻辑错误在 PHP 7.4 常以 Notice 级别提示(因为改动的是临时值,实际不生效),PHP 8 起会直接抛 Error,升级时容易集中爆发。排查时优先看 error_reporting(E_ALL) 有没有被关掉——很多”改了没反应”的现场,就是因为 Notice 被屏蔽,实际一直没改成功。
2. 回调里 unset 导致遍历错位
在 array_walk 回调里 unset($value),删的是”引用指向的元素”还是”引用本身”,结论并不直观。经实测,在 function (&$value) { unset($value); } 里 unset 只会解除当前引用绑定,数组元素不会被删除,容易造成”代码写了删除但数据还在”。要真正删除,直接用 unset($data[$key]) 或改用 foreach 更清晰。
3. 嵌套数组同时被 array_walk_recursive 和手动遍历改动,导致双重处理
下面的错误模式很常见:先 array_walk_recursive 把所有叶子转成大写,又在外面包一层 foreach 对 key 做替换,结果字符串被转了两次或者顺序错乱。规避方法是一次遍历只做一件与数据结构相关的事:要么逐叶子改值(用 recursive),要么按路径改结构(用自定义递归),不要混用两套遍历。
4. 大数组上的性能差异
对 10 万级元素的扁平数组,array_walk 因为要逐次回调,通常比手写 foreach 慢一截(函数调用开销),差距量级在数十毫秒。嵌套场景里,array_walk_recursive 与自写递归函数性能接近,瓶颈更多来自回调内部逻辑与数组复制。真正需要避免的是在回调里按值复制整棵子树——例如 $value = array_map(...),每层都 copy 一次,内存随深度线性放大。
五、写一套可复用的安全改写方案
把前面的结论固化成三条工程约定,可以直接用在项目里:
- 只改叶子值:用
array_walk_recursive,回调一律写function (&$value, $key),并用is_scalar或is_string显式判定类型。 - 要按路径改,或要改结构:用第三节的
walk_path,不要试图用嵌套闭包硬拼。 - 任何 foreach 引用循环后加
unset($value),包括递归函数内部。
再加一个兜底思路:当数组来自 JSON 解码或配置文件,改写前先用 json_encode($data) 与改写后结果做 diff 日志,能在联调阶段立刻发现”引用了但没生效”的静默失败。生产环境里,一次静默失败比一次报错代价大得多。
<?php
$before = $config;
array_walk_recursive($config, function (&$value, $key) {
if (is_string($value)) {
$value = trim($value);
}
});
if (json_encode($before) === json_encode($config)) {
error_log('[array_walk] no-op detected: check reference on $value');
}
?>
FAQ
Q1:为什么 array_walk 回调里不加 & 也”偶尔”能改数组?
通常是因为数组元素本身是对象。对象在 PHP 里默认按引用语义传递(handle),所以 function ($value) 里改 $value->prop 会生效,但给 $value 整体重新赋值仍然不生效。判断标准是:改的是对象内部状态,还是替换了变量指向。后者永远需要 &。同理,PHP 8.1 之后的 readonly 属性对象、ArrayObject 等 SPL 容器也遵循类似规律。
Q2:array_walk_recursive 能不能拿到父级路径?
不能。它不暴露任何父级信息,回调只有 value、key 和第三个固定参数。需要路径就用第三节的自定义递归,用参数累积 prefix。如果你的数组层级只有两层,也可以先 foreach 第一层,再对每个子数组调 array_walk(回调带引用),这样路径由外层键名自己拼。
Q3:多层嵌套时用 array_walk 递归,什么时候会触发 “Maximum function nesting level”?
那是 Xdebug 的防护,默认 xdebug.max_nesting_level=256。当递归函数写错(例如把 &$data 写成了值参数、或者条件判断错误导致无限自调用),就会抛出 Maximum function nesting level of '256' reached, aborting!。先确认递归的终止条件依赖的是 is_array($value) 而非 isset($value) 之类不可靠判断;生产环境建议把 Xdebug 关掉,用 max_execution_time 兜底。
Q4:PHP 8 里同样的代码还能跑吗?
引用绑定规则没变,但两处差异需要留意:PHP 8 对回调签名不匹配更容易抛 TypeError(尤其是回调声明了强类型返回值或参数类型时);另外 array_walk 的第一个参数如果不是变量,报错等级从 Notice 提升为 Error。升级前用 E_ALL 跑一遍测试套件,几乎所有”静默不改”的点都会暴露成显式异常。
Q5:有没有比递归更省事的替代方案?
如果只是按键名扁平化提取,array_filter + iterator_to_array(new RecursiveIteratorIterator(new RecursiveArrayIterator($data))) 可以一次性拍平,但会丢失路径信息;如果要做结构转换,推荐先用 json_encode / json_decode 转成对象树再处理,或者直接上 League\Config 这类带路径访问的库,避免自己维护递归引用。
小结一句:array_walk 的数组参数是引用,回调参数默认不是;递归改嵌套数组,要么用 array_walk_recursive 只改叶子,要么自写带 & 形参的递归函数并记得 unset($value)。把这两条记牢,绝大多数”改了没生效”的坑都能绕开。