前言
遍历数组是 PHP 里最日常的操作,foreach几乎成了条件反射。但foreach有两个截然不同的版本——按值遍历和按引用遍历,它们对「循环里修改数组」这件事的反应完全不同,性能特征也不一样。很多莫名其妙的 bug 就出在这里:循环体内改了一个元素,结果要么完全没生效,要么影响了后续的遍历。
第二类误解是把array_map()、array_walk()、array_filter()当成「foreach的简写」。它们看起来都是「对每个元素做点什么」,但返回值规则、键的处理、能否修改原数组,三者各不相同。用错了写出来的代码能跑,只是结果不是你想要的。
还有一段历史必须点明:早期 PHP 教程里常见的while (list($k, $v) = each($arr))这种写法,each()函数已在 PHP 8.0 中被彻底移除,不是废弃而是移除,网上很多老代码直接跑不起来。与之类似的还有mysql_*系列(PHP 7.0 移除)、ereg_*(PHP 7.0 移除)、split()(PHP 7.0 移除),这些都必须换成现行写法。
本文按「按值遍历」「按引用遍历」「函数式遍历」「手动指针遍历」四条线讲清遍历的机制与取舍,示例以 PHP 8.0 及以上为基准。
一、foreach 按值遍历:遍历的是快照
foreach按值遍历时,循环变量拿到的是元素的副本。PHP 7 起,foreach不再使用数组的内部指针,而是对数组做一份逻辑上的快照(物理上仍靠写时复制延迟复制)。这带来两个可预期的结论:
<?php // 适用于 PHP 8.0+
$nums = [1, 2, 3];
// 修改 $v 不影响原数组
foreach ($nums as $v) {
$v = $v * 10;
}
print_r($nums); // [1, 2, 3] —— 原数组没变
// 循环里往原数组追加元素,不会延长循环
foreach ($nums as $v) {
if ($v === 1) {
$nums[] = 100; // 追加发生在循环内部
}
echo $v, ' ';
}
echo PHP_EOL; // 1 2 3 —— 只遍历了最初的三个元素第二条是「快照」语义最直观的证据:按值遍历时,循环的次数在开始时就已经确定,中途往数组里塞东西不会让循环变长。这既是安全网(不会写出死循环),也是一个陷阱——如果你指望「边遍历边追加、边处理新元素」,foreach做不到,得改用while加array_shift(),或者用生成器(generator)。
嵌套遍历里用同一个变量名也要小心,内层会覆盖外层的循环变量:
<?php // 适用于 PHP 8.0+
$groups = [['a', 'b'], ['c']];
// ❌ 内外层都用 $v,内层结束后外层 $v 已被覆盖(这里外层直接结束所以看不出问题)
foreach ($groups as $v) {
foreach ($v as $v) {
echo $v, ' ';
}
}
// ✅ 内层换个名字,可读性也更好
foreach ($groups as $group) {
foreach ($group as $item) {
echo $item, ' ';
}
}二、foreach 按引用遍历:直接改原数组
如果目的是修改原数组,用引用遍历foreach ($arr as &$v)。此时循环变量是元素的别名,赋值直接落到原数组上:
<?php // 适用于 PHP 8.0+
$prices = [100, 200, 300];
foreach ($prices as &$p) {
$p = (int) round($p * 1.1); // 直接改原数组
}
unset($p); // ⚠️ 必须:断开引用
print_r($prices); // [110, 220, 330]unset($p)这一行不是可选项。循环结束后$p仍然指向数组的最后一个元素,如果你马上再用同名变量做按值遍历,就会把最后一个元素改掉:
<?php // 适用于 PHP 8.0+
$arr = [1, 2, 3];
foreach ($arr as &$v) { } // 忘记 unset($v)
foreach ($arr as $v) { } // $v 已经绑定到 $arr[2]
print_r($arr); // [1, 2, 2] —— 最后一个元素被改成了它自己数组元素本身是数组或对象时,按值遍历也能改到「里面」:
<?php // 适用于 PHP 8.0+
$rows = [['n' => 1], ['n' => 2]];
foreach ($rows as $row) {
$row['n'] = 99; // ❌ 只改了副本,$rows 不变
}
foreach ($rows as $i => $row) {
$rows[$i]['n'] = 99; // ✅ 按下标回写,改到原数组
}对象则不同,因为对象变量保存的是句柄,按值遍历时副本仍指向同一个对象,改属性会改到原对象。这个差异在「数组里放对象」时极易混淆,判断标准很简单:值是标量或数组,副本独立;值是对象,副本共享。
在按引用遍历的循环里修改数组结构(增删元素)是不可靠的。官方手册对此的表述是这种行为不保证可预期,因此不要写「边引用遍历边unset()或追加」的代码。需要过滤就在循环外收集待删键,循环结束后统一处理:
<?php // 适用于 PHP 8.0+
$data = ['a' => 1, 'b' => null, 'c' => 3];
// ✅ 先收集,循环结束后再删,避免遍历中改结构
$toRemove = [];
foreach ($data as $k => $v) {
if ($v === null) {
$toRemove[] = $k;
}
}
foreach ($toRemove as $k) {
unset($data[$k]);
}
// 不过这个场景其实用 array_filter() 一步就够了三、函数式遍历:map / walk / filter / reduce 的分工
这四个函数经常被混用,它们的分工其实很规整:
| 函数 | 返回值 | 键的处理 | 能否改原数组 | 用途 |
|---|
array_map() | 新数组 | 只传一个数组时保留键,传多个数组时键被重新索引 | 否 | 一对一映射 |
array_walk() | 布尔值(成功与否) | 原位处理 | 能(回调首参声明为引用时) | 就地修改 |
array_filter() | 新数组(保留键) | 保留原键 | 否 | 按条件筛选 |
array_reduce() | 单个值 | 不适用 | 否 | 累积成一个结果 |
array_merge()之类 | 新数组 | 见各自规则 | 否 | 合并 |
<?php // 适用于 PHP 8.0+
$nums = [1, 2, 3, 4, 5];
// array_map:一对一映射,返回新数组
$squares = array_map(fn(int $n): int => $n * $n, $nums);
print_r($squares); // [1, 4, 9, 16, 25]
// array_walk:就地修改,回调第一个参数必须是引用
array_walk($nums, function (int &$n, int $i): void {
$n = $n * 2;
});
print_r($nums); // [2, 4, 6, 8, 10]
// array_filter:筛选,注意保留原键!
$even = array_filter($nums, fn(int $n): bool => $n % 4 === 0);
print_r($even); // [1 => 4, 3 => 8] —— 键是 1 和 3,不是 0 和 1
// 要连续键就再补一次 array_values()
print_r(array_values($even)); // [4, 8]
// array_reduce:累积
$sum = array_reduce($nums, fn(int $carry, int $n): int => $carry + $n, 0);
echo $sum, PHP_EOL; // 30三个关键差异记牢:array_map()的回调不能通过引用改原数组(改的是副本);array_walk()的回调第一个参数不声明成&就改不到原数组;array_filter()会保留原键,结果不是「干净的列表」,直接json_encode()会变成 JSON 对象。
还有一个老读者熟悉的陷阱:array_map()在只传一个数组时保留键,传两个及以上数组时键会被重新编号(因为它做的事情是「拉链式并排」)。
四、手动指针与生成器
foreach之外,PHP 还保留了操作数组内部指针的函数:reset()、current()、key()、next()、prev()、end()。它们对同一数组共享同一个内部指针,因此在foreach内部调用会互相干扰——foreach在 PHP 7 起不再依赖内部指针,但这些函数自己仍是共享状态的。
<?php // 适用于 PHP 8.0+
$q = ['first', 'second', 'third'];
reset($q);
while (($k = key($q)) !== null) {
echo $k, ' => ', current($q), PHP_EOL;
next($q);
}
// 0 => first
// 1 => second
// 2 => third遍历完指针停在末尾,想再走一遍必须先reset()。这套写法在现代 PHP 里主要用于需要「中途停下、下次继续」的场景。
PHP 8.0 移除了each(),下面这段在 PHP 5 里到处都是的代码现在会直接报Call to undefined function each():
<?php // ❌ 已失效:each() 在 PHP 8.0 被移除,split()/ereg_* 在 PHP 7.0 被移除
// while (list($k, $v) = each($arr)) {
// echo "$k => $v\n";
// }现行写法就是foreach,list()本身还在(PHP 7.1 起支持短语法方括号解构):
<?php // 适用于 PHP 8.0+
$arr = ['a' => 1, 'b' => 2];
foreach ($arr as $k => $v) {
echo $k, ' => ', $v, PHP_EOL;
}数据量很大、或者元素需要「边算边取」时,用生成器(generator,PHP 5.5 起)避免一次性构造整个数组:
<?php // 适用于 PHP 8.0+
function readLines(string $path): Generator {
$fh = fopen($path, 'r');
if ($fh === false) {
return;
}
try {
while (($line = fgets($fh)) !== false) {
yield rtrim($line, "\r\n");
}
} finally {
fclose($fh);
}
}
// foreach 可以直接遍历生成器,内存里不会同时存在所有行
foreach (readLines(__DIR__ . '/data.txt') as $line) {
echo $line, PHP_EOL;
}生成器是一次性的:遍历完(或中途break)之后不能重来,需要重新调用函数生成一个新的。另外生成器里的return;表示结束,return $value取到的值只能通过getReturn()拿到,不会出现在foreach的元素里。
常见坑点
- 按引用遍历后忘记
unset()
❌foreach ($a as &$v) {}紧接着foreach ($a as $v) {},最后一个元素被改写成它自己的值 ✅ 引用循环结束立刻unset($v);
- 用
array_map()的回调去改原数组
❌array_map(function ($x) { $x++; }, $arr);以为改了$arr—— 回调拿到的是副本,且返回值被丢弃 ✅ 要改原数组用array_walk($arr, function (&$x) { $x++; }),或者接收array_map()的返回值
array_walk()回调参数忘了写引用
❌array_walk($a, function ($v) { $v = strtoupper($v); });结果原数组一点没变 ✅ 第一个参数声明为&$v:function (&$v) { $v = strtoupper($v); }
array_filter()之后直接交给前端
❌json_encode(array_filter($list))输出的是 JSON 对象,因为键 1、3 被保留下来 ✅ 需要列表就再套一层array_values()
- 在引用遍历里增删元素
❌foreach ($a as $k => &$v) { unset($a[$k]); }—— 遍历中改结构,结果不保证 ✅ 循环里只做标记,循环后用array_filter()或统一的删除步骤处理
- 还在用
each()或split()
❌while (list($k, $v) = each($arr))——each()在 PHP 8.0 被移除;split()在 PHP 7.0 被移除(改用explode()/preg_split()) ✅ 直接改用foreach ($arr as $k => $v)
- 以为
count()写在for条件里很慢、必须提到循环外
❌ 用「count()每轮都数一遍,提到外面性能翻倍」来解释,属于想当然 ✅ PHP 数组是哈希表,结构里存着元素个数,count()是 O(1);要不要提出去主要看可读性(循环里会改数组长度时就别提)
- 混用
foreach和current()/next()
❌ 在foreach循环体里调next($arr),指望推进循环 ✅foreach自己控制进度,指针函数只用于while手写遍历;两者不要混用
总结
| 遍历方式 | 循环变量是副本还是别名 | 循环中追加元素的影响 | 典型用途 |
|---|
foreach ($a as $v) | 副本(标量、数组) | 不影响遍历次数 | 只读遍历 |
foreach ($a as &$v) | 别名 | 行为不保证,应避免 | 就地修改元素 |
foreach ($a as $k => $v) | 副本 | 同上 | 需要键时 |
array_map() | 副本 | 不适用 | 生成新数组 |
array_walk() | 别名(参数加&时) | 不适用 | 就地修改 |
array_filter() | 副本 | 不适用 | 筛选(键会保留) |
while+next() | 内部指针 | 会受影响 | 可中断的遍历 |
foreach+ 生成器 | 一次一个 | 不适用 | 大数据流式处理 |
选遍历方式时先问自己两个问题:我要不要改原数组,我要不要一个新数组。要改原数组就用引用或array_walk(),要新数组就用array_map()/array_filter(),两者都不要就老实用foreach。至于each()这类老写法,记住它们在 PHP 7.0/8.0 已经被移除,不要再从旧代码里抄。