前言
先说一个必须澄清的事实:array_reduce()不是 PHP 8.5 的新函数,它从 PHP 4.0.5 起就存在了,在 PHP 8.5 里它的签名和行为都没有任何变化。标题里的版本号只是描述了运行环境,而不是这个函数的引入版本。本文按array_reduce()的真实行为来写,同时说明在 PHP 8.5 下它有什么新的写法(主要是管道操作符|>,PHP 8.5 引入)。
那为什么这个老函数这么容易写错?因为它有一条反直觉的默认行为,还有三个「看着像应该支持、其实并不支持」的能力:
Warning: Undefined array key "sku"或者更隐蔽的——结果少了一半数据,但一行错误都没有。这类问题的根源是三个认知偏差:
- 不传第三个参数时,PHP 会把数组的第一个元素当作起始累加值,回调不会对第一个元素执行。
- 回调只能拿到值,拿不到键。想按
sku做分组,得自己想办法把键带进去。 $carry是按值传递的,不是引用,所以「在回调里直接改 carry」的写法不成立。
本文先讲清这三个行为,再给出五种典型归约的写法与更合适的替代函数,最后是一个完整可运行、可自测的示例。
一、真实签名与版本
array_reduce(array $array, callable $callback, mixed $initial = null): mixed| 项目 | 内容 |
|---|---|
| 引入版本 | PHP 4.0.5 |
| PHP 8.5 的变化 | 无(本文标题里的 8.5 是运行环境,不是引入版本) |
| 返回值 | mixed,类型完全取决于回调返回什么 |
| 回调签名 | callback(mixed $carry, mixed $item): mixed |
| 第三个参数 | $initial,可选,默认null |
| 是否提供键 | 否 |
$carry是否引用传递 | 否 |
回调的两个参数含义:
$carry:上一轮回调的返回值;第一轮调用时它是$initial(有个重要例外,见下一节)。$item:当前元素的值。注意是值,不是键值对。
二、不传 $initial 时的默认行为
这是array_reduce()最容易踩的一条:
<?php declare(strict_types=1); // 任何 PHP 版本(array_reduce 自 4.0.5 起存在) $nums = [2, 3, 5, 7]; $sum = array_reduce($nums, static function ($carry, $item) { echo "carry=" . var_export($carry, true) . ", item=$item\n"; return $carry + $item; }); echo "结果:$sum\n";输出是:
carry=2, item=3 carry=5, item=5 carry=10, item=7 结果:17注意第一行:carry不是null,而是数组的第一个元素2。也就是说回调只被调用了 3 次(元素个数减 1),第一个元素被当作初始值直接跳过了。
行为归纳如下:
| 情况 | 返回值 |
|---|---|
数组为空、传了$initial | $initial |
数组为空、没传$initial | null |
数组非空、传了$initial | 从$initial开始归约,回调对每个元素都执行 |
数组非空、没传$initial | 用第一个元素当归约起点,回调对第 2 个元素起执行 |
这个行为导致的典型故障是空数组返回null:调用方拿到它去做算术或foreach,在 PHP 8 里就是TypeError。
<?php declare(strict_types=1); // ❌ 空数组时返回 null,后面的计算直接炸 $total = array_reduce($items, static fn ($c, $i) => $c + $i['amount']); // ✅ 永远显式传初始值,空数组时返回 0 而不是 null $total = array_reduce($items, static fn ($c, $i) => $c + $i['amount'], 0);结论很直接:写array_reduce()时永远传第三个参数。它同时解决了「空数组返回 null」和「第一个元素被跳过」两个问题。
三、五种典型归约,以及更该用的函数
array_reduce()能做的事情,有一大半有更专用、更快、更易读的写法。选择顺序应该是:专用函数 →foreach→array_reduce。
| 目标 | array_reduce()写法 | 更推荐 |
|---|---|---|
| 求和 | array_reduce($a, fn ($c, $i) => $c + $i, 0) | array_sum($a) |
| 求积 | array_reduce($a, fn ($c, $i) => $c * $i, 1) | array_product($a) |
| 求最值 | array_reduce($a, fn ($c, $i) => max($c, $i), PHP_INT_MIN) | max($a)/min($a) |
| 计数 | array_reduce($a, fn ($c, $i) => $c + 1, 0) | count($a) |
| 连接字符串 | array_reduce($a, fn ($c, $i) => $c . $i, '') | implode(',', $a) |
| 判断全部满足 | array_reduce($a, fn ($c, $i) => $c && $i > 0, true) | array_all($a, fn ($i) => $i > 0)(PHP 8.4+) |
| 判断存在满足 | 同上但用 `\ | \ |
| 找第一个匹配项 | 归约成「找到就返回」 | array_find($a, fn ($i) => ...)(PHP 8.4+) |
| 取第一个元素 | 折腾一圈 | array_first($a)(PHP 8.5+) |
| 分组(group by) | 适合 | foreach也行,看可读性 |
| 按 key 建索引 | 适合 | array_column($a, null, 'id')(仅限单层数组) |
| 展平嵌套数组 | 可以,但注意复杂度 | array_merge(...$a)或foreach |
真正留给array_reduce()的,是「把数组压成一个结构」这一类:分组、建索引、构建聚合对象。这些用foreach写反而更啰嗦(要先在循环外声明累加变量),而array_reduce()天然带一个「累加值」参数。
四、分组、建索引与展平该怎么写
分组(group by)
因为回调拿不到键,分组要靠元素自身携带的字段:
<?php declare(strict_types=1); // 需要 PHP 7.4+(箭头函数是 7.4 引入的) $orders = [ ['sku' => 'A-1', 'qty' => 2, 'price' => 19.9], ['sku' => 'B-2', 'qty' => 1, 'price' => 9.5], ['sku' => 'A-1', 'qty' => 3, 'price' => 19.9], ]; // 按 sku 分组 $grouped = array_reduce( $orders, static function (array $carry, array $order): array { $carry[$order['sku']][] = $order; return $carry; }, [] // 初始值必须是空数组,不能省 ); print_r(array_map('count', $grouped)); // [A-1 => 2, B-2 => 1]按 key 建索引
<?php declare(strict_types=1); $users = [ ['id' => 7, 'name' => 'alice'], ['id' => 9, 'name' => 'bob'], ]; $byId = array_reduce( $users, static function (array $carry, array $user): array { $carry[$user['id']] = $user; return $carry; }, [] ); // [7 => [...], 9 => [...]]同样的结果也能用array_column($users, null, 'id')一行搞定,而且更快——能用专用函数就别用归约。
展平:这里有个复杂度陷阱
<?php declare(strict_types=1); $chunks = [[1, 2], [3, 4], [5, 6]]; // ❌ 每一轮都 array_merge,累积数组被整体复制一次 $flat = array_reduce($chunks, static fn (array $c, array $i): array => array_merge($c, $i), []); // ✅ 直接展开参数,一次合并 $flat = array_merge(...$chunks); // ✅ 或者用 array_push 的变体逐段追加,总体是线性代价 $flat = array_reduce($chunks, static function (array $c, array $i): array { array_push($c, ...$i); return $c; }, []);第一行的写法在数据量大时会明显变慢:array_merge()每次都要把「已经累积的全部元素」重新拷贝一遍,总代价是元素总数的平方级(O(n²)),而后面两种是线性级(O(n))。这不是 PHP 的 bug,是算法选择问题。
短路:array_reduce 做不到
array_reduce()会遍历全部元素,没有break的余地。想「一找到就停」,得换写法:PHP 8.4+ 可以直接用array_find($nums, static fn (int $n): bool => $n < 0),低版本就用foreach加break。
五、PHP 8.5 下更顺手的写法:管道操作符
PHP 8.5 引入的管道操作符(pipe operator)|>不会改变array_reduce()的行为,但它让「一串数据变换」读起来是从左到右的,配合归约很合适:
<?php declare(strict_types=1); // 需要 PHP 8.5+ $orders = [ ['amount' => 120.5, 'paid' => true], ['amount' => 80.0, 'paid' => false], ['amount' => 199.9, 'paid' => true], ]; // 已支付订单金额合计:筛选 → 取列 → 归约 $total = $orders |> (fn (array $o): array => array_filter($o, static fn (array $r): bool => $r['paid'])) |> (fn (array $o): array => array_column($o, 'amount')) |> (fn (array $a): float => array_reduce($a, static fn ($c, $i) => $c + $i, 0.0)); printf("合计:%.2f\n", $total); // 320.40注意管道右侧的闭包都要用括号包起来——这是|>的语法要求(箭头函数会贪婪地吞掉后面的表达式)。另外,这一串的中间两步其实用array_sum()更直接,这里只是为了演示管道与归约的组合。
六、完整可运行示例
把下面这段存成reduce_demo.php直接运行(需要 PHP 8.0+,用到命名参数与match):
<?php declare(strict_types=1); // 需要 PHP 8.0+ $orders = [ ['sku' => 'A-1', 'qty' => 2, 'price' => 19.90, 'paid' => true], ['sku' => 'B-2', 'qty' => 1, 'price' => 9.50, 'paid' => false], ['sku' => 'A-1', 'qty' => 3, 'price' => 19.90, 'paid' => true], ['sku' => 'C-3', 'qty' => 5, 'price' => 3.00, 'paid' => true], ]; // 1) 空数组的两种返回:这是最容易踩的地方 var_dump(array_reduce([], static fn ($c, $i) => $c + 1)); // NULL var_dump(array_reduce([], static fn ($c, $i) => $c + 1, 0)); // int(0) // 2) 不传初始值时,第一个元素当起点,回调只跑 n-1 次 $calls = 0; $sum = array_reduce( [10, 20, 30], static function ($carry, $item) use (&$calls) { $calls++; return $carry + $item; } ); printf("不传初始值:sum=%d,回调执行 %d 次\n", $sum, $calls); // 60, 2 次 $calls = 0; $sum = array_reduce( [10, 20, 30], static function ($carry, $item) use (&$calls) { $calls++; return $carry + $item; }, 0 ); printf("传了初始值:sum=%d,回调执行 %d 次\n", $sum, $calls); // 60, 3 次 // 3) 按 sku 分组,并顺手算出每组金额 $bySku = array_reduce( $orders, static function (array $carry, array $o): array { $sku = $o['sku']; $carry[$sku]['count'] = ($carry[$sku]['count'] ?? 0) + 1; $carry[$sku]['amount'] = ($carry[$sku]['amount'] ?? 0.0) + $o['qty'] * $o['price']; $carry[$sku]['paid_qty'] = ($carry[$sku]['paid_qty'] ?? 0) + ($o['paid'] ? $o['qty'] : 0); return $carry; }, [] ); ksort($bySku); foreach ($bySku as $sku => $row) { printf("%-5s 订单数=%d 金额=%.2f 已付件数=%d\n", $sku, $row['count'], $row['amount'], $row['paid_qty']); } // 4) 用数组解构拿到「归约出的多个值」,避免多次遍历 [$totalAmount, $totalQty, $unpaidCount] = array_reduce( $orders, static function (array $carry, array $o): array { [$amount, $qty, $unpaid] = $carry; // PHP 7.1+ 的数组解构 $qty += $o['qty']; if ($o['paid']) { $amount += $o['qty'] * $o['price']; } else { $unpaid++; } return [$amount, $qty, $unpaid]; }, [0.0, 0, 0] ); printf("总金额=%.2f 总件数=%d 未付订单=%d\n", $totalAmount, $totalQty, $unpaidCount); // 5) 展平:对比两种写法的复杂度来源 $chunks = [[1, 2], [3, 4], [5, 6]]; $merge = array_reduce($chunks, static fn (array $c, array $i): array => array_merge($c, $i), []); $push = array_reduce($chunks, static function (array $c, array $i): array { array_push($c, ...$i); return $c; }, []); printf("array_merge 展平:%s\n", implode(',', $merge)); printf("array_push 展平:%s\n", implode(',', $push)); // 6) 性能自测:把这里跑一遍,用你自己机器上的数字说话 $big = range(1, 20000); $t0 = hrtime(true); $r1 = array_reduce($big, static fn ($c, $i) => $c + $i, 0); $t1 = hrtime(true); $r2 = array_sum($big); $t2 = hrtime(true); printf("array_reduce=%d 用时 %.3f ms;array_sum=%d 用时 %.3f ms\n", $r1, ($t1 - $t0) / 1e6, $r2, ($t2 - $t1) / 1e6);最后一段是刻意留的:关于性能,不要相信任何博客(包括本文)里的数字,用这段代码在自己机器上跑一遍。你会看到array_sum()这类 C 实现的专用函数比逐元素 PHP 回调快不少——原因不是array_reduce()写得烂,而是每轮回调都是一次 PHP 函数调用。
常见坑点
1. 不传第三个参数,空数组时拿到null
❌$total = array_reduce($rows, fn ($c, $r) => $c + $r['amount']);—— 当$rows为空时返回null,后面拿去做算术或格式化就报TypeError。 ✅ 永远传初始值:array_reduce($rows, $fn, 0)。初始值还决定了累加值的类型。
2. 以为回调会对第一个元素执行
❌ 在回调里printf("处理第 %d 条\n", ++$n)统计处理条数,结果总是比数组长度少一。因为不传初始值时,第一个元素直接成了$carry。 ✅ 统计条数用count();确实需要回调处理每个元素,就传第三个参数。
3. 想在回调里用键
❌array_reduce($map, fn ($c, $item) => $c . $item['key'])—— 回调第二个参数是值不是[key, value],数组的键根本传不进来。 ✅ 用foreach ($map as $k => $v);或者先把键塞进元素里:array_map(fn ($k, $v) => [$k, $v], array_keys($map), $map)。
4. 想让$carry按引用修改
❌array_reduce($a, function (array &$carry, $item) { $carry[] = $item; })——$carry不是引用传递,加&声明只会得到一个警告或无效行为。 ✅ 老老实实return新的$carry。这也是为什么回调里的return绝对不能漏。
5. 回调里漏了return
❌array_reduce($a, function ($c, $i) { $c[] = $i; }, [])—— 少了return,本轮返回null,下一轮$carry就成了null,报错位置落在很远的下一轮。 ✅ 每个分支都要有return;用static fn ($c, $i) => ...箭头函数从语法上避免漏写。
6. 用array_reduce做「找到就停」
❌ 归约到「已经找到就不再看」,但回调仍会被执行 N 次,等于白跑;而且逻辑绕。 ✅ 用array_find()(PHP 8.4+)或foreach+break,它们天然短路。
7. 用array_merge在回调里累积大数组
❌fn ($c, $i) => array_merge($c, $i)—— 每轮都整体复制累积结果,总代价是 O(n²),数据量一大就卡住。 ✅ 用array_push($c, ...$i)追加,或直接array_merge(...$chunks)一次合并;复杂度降回 O(n)。
8. 用归约替代专用函数
❌array_reduce($a, fn ($c, $i) => $c + $i, 0)求和、array_reduce($a, fn ($c, $i) => $c . $i, '')连接字符串 —— 又慢又难读。 ✅array_sum($a)、implode(',', $a)、max($a)、count($a)都是 C 层实现,更快也更清楚。归约只留给「压成一个结构」的场景。
总结
| 问题 | 结论 |
|---|---|
array_reduce()是 PHP 8.5 新函数吗 | 不是,PHP 4.0.5 起就有,8.5 未改动它 |
| 签名 | array_reduce(array $array, callable $callback, mixed $initial = null): mixed |
| 回调参数 | (mixed $carry, mixed $item),只有值没有键 |
| 不传第三个参数 | 第一个元素成为起始$carry,回调只跑 n-1 次 |
| 空数组 | 传了初始值返回初始值;没传返回null |
$carry是引用吗 | 不是,必须return新值 |
| 能短路吗 | 不能,需要短路就用array_find()或foreach |
| 什么时候该用 | 分组、按 key 建索引、压成聚合结构 |
| 什么时候不该用 | 求和/求积/最值/计数/连接字符串/取首元素,都有专用函数 |
| PHP 8.5 的新写法 | 用管道操作符 `\ |
array_reduce()的正确用法可以浓缩成一句话:永远显式传第三个参数,永远在回调里return。做到这两点,它就只是一个「带累加值的循环」;至于该不该用它,判断标准也很简单——如果 PHP 已经为你想做的事情提供了专用函数(array_sum、max、implode、array_column、array_find),那就用专用函数,把归约留给真正需要「压成一个结构」的场合。