# 数组函数

Source: https://codewiki.com/zh/php/array-functions/

> - **what**: PHP 数组函数把常见的转换、筛选、聚合、合并和排序操作封装成标准 API；选择函数时，先看它如何处理键以及是否修改输入。
> - **trap**: `array_filter()` 会保留旧键，`array_merge()` 会重排整数键，而 `usort()` 会原地修改数组并丢弃原键。
> - **fix**: 明确结果需要列表还是映射；为 `array_reduce()` 提供类型正确的初始值，并让排序比较函数返回负数、`0` 或正数。

## 是什么，为什么存在

PHP 的数组同时承担列表和有序映射两种角色。数组函数是在这种容器上执行常见操作的内置 API：`array_map()` 转换值，`array_filter()` 选择元素，`array_reduce()` 聚合状态，`array_merge()` 合并输入，`array_keys()` 提取键，`usort()` 按自定义顺序重排值。

这些函数解决的是重复遍历代码，而不是消除所有循环。函数名可以表达操作意图，PHP 也负责迭代细节；但键、空值和修改方式仍由各个 API 自己定义。只凭其他语言中 `map`、`filter`、`reduce` 的经验猜测 PHP 行为，很容易写出能运行却改变数据形状的代码。

`array_map()`、`array_filter()`、`array_reduce()` 和 `usort()` 接收 回调函数（callback）。回调是作为参数传入另一函数、由后者在处理数据时调用的函数。箭头函数适合一个表达式的回调，逻辑包含多步校验或状态更新时，普通匿名函数通常更清楚。

六个函数最重要的区别不是语法，而是结果契约：

| 函数 | 主要操作 | 键行为 | 是否修改输入 |
| --- | --- | --- | --- |
| `array_map()` | 把每个值转换为新值 | 单个输入数组时保留键 | 否 |
| `array_filter()` | 保留满足条件的元素 | 保留键，可能留下空档 | 否 |
| `array_reduce()` | 把所有值折叠为一个结果 | 不向回调传键 | 否 |
| `array_merge()` | 按顺序合并数组 | 字符串键覆盖，整数键重排 | 否 |
| `array_keys()` | 取得全部键或匹配值的键 | 返回从 `0` 开始的列表 | 否 |
| `usort()` | 使用比较函数排序值 | 丢弃原键并重新编号 | 是 |

当输入只在内存中、操作边界明确时，这些函数很合适。需要提前终止、同时维护多份状态、处理流式数据，或必须保留复杂键关系时，`foreach` 或生成器往往更直接。数组函数不是可读性的自动保证；嵌套三四层回调通常已经超过它们的舒适区。

## 工作原理

这些函数都遍历数组，但遍历结果不同。理解它们时，应分别追踪值、键、回调参数、返回值和输入是否被修改，而不是只看最终元素内容。

### `array_map()` 转换值

`array_map($callback, $array)` 为每个值调用一次回调，并用回调返回值组成新数组。只传一个输入数组时，新数组保留原键；传入两个或更多数组时，函数按位置组合对应值，并为结果创建连续整数键。

多个输入数组长度不一致时，较短数组缺少的位置会以 `null` 补齐。回调的参数数量应与输入数组数量匹配；如果回调要求的参数多于实际传入的数组，PHP 会抛出 `ArgumentCountError`。需要在回调中使用键时，可以显式组合 `array_keys()` 与 `array_values()`，也可以改用 `foreach`。

### `array_filter()` 选择元素

`array_filter($array, $callback)` 保留回调结果转换为 `true` 的元素。默认模式只传值；`ARRAY_FILTER_USE_KEY` 只传键；`ARRAY_FILTER_USE_BOTH` 依次传值和键。无论采用哪种模式，返回数组都保留输入键。

省略回调时，`array_filter()` 会删除 PHP 认为“空”的值，其中包括 `false`、`null`、整数 `0`、字符串 `"0"`、空字符串和空数组。这种快捷写法只适合这些值确实都无效的领域。订单数量、表单输入或状态码中，`0` 往往是合法数据，应写出明确谓词。

### `array_reduce()` 累积结果

`array_reduce($array, $callback, $initial)` 先把 `$initial` 作为累加器，再按数组迭代顺序把累加器和值传给回调。每次回调的返回值成为下一轮累加器，最后一次返回值就是整个函数的结果。键不会传入回调。

PHP 的行为与 JavaScript 不同：省略 `$initial` 并不会把首个元素当作初始值，而是从 `null` 开始处理第一个元素。空数组在没有初始值时也返回 `null`。求和使用 `0`，拼接字符串使用 `''`，构造映射使用 `[]`；初始值同时定义空输入结果和累加器类型。

### `array_merge()` 合并数组

`array_merge(...$arrays)` 从左到右处理输入。相同字符串键出现多次时，右侧值覆盖左侧值；整数键的值不会按键覆盖，而会追加到结果末尾，并从 `0` 开始重新编号。调用时不传参数会得到空数组。

数组联合运算符 `+` 使用另一套规则：它保留左侧已有的所有键，同键的右侧值被忽略，而且整数键不会重排。因此，`array_merge()` 适合拼接列表或让后方配置覆盖前方配置；`+` 适合保留左侧键和值。两者都不会自动执行符合所有领域需求的深层配置合并。

### `array_keys()` 提取键

`array_keys($array)` 按迭代顺序返回数组的所有整数键和字符串键。传入第二个参数后，它只返回值匹配 `$filterValue` 的键；第三个参数为 `true` 时使用 `===`，否则使用宽松比较。

检查某个键是否存在时，应直接使用 `array_key_exists()`，而不是先创建完整键列表再调用 `in_array()`。`isset($array[$key])` 的语义又不同：键存在但值为 `null` 时，它返回 `false`。选择哪个函数取决于你要检查“键存在”还是“值不为 `null`”。

### `usort()` 定义顺序

`usort(&$array, $callback)` 原地排列值，把所有键替换为连续整数。比较函数（comparator）在左值应排在右值之前、相等或之后时，分别返回负整数、`0` 或正整数；`<=>` 正好符合这个契约。

PHP 8 起，比较结果相等的元素保留原有相对顺序，因此 `usort()` 是 稳定排序（stable sort）。稳定性不会修复不一致的比较函数：如果回调对同一对值给出矛盾结果，或只返回布尔值，最终顺序仍不可信。PHP 8.2 起函数本身的返回类型是 `true`；有用结果在被修改的数组中。

## 示例

下面四个示例分别展示转换与筛选、归约、合并和排序。输出均由本地 PHP 8.3.33 运行得到。

### 筛选订单并转换显示值

订单以业务编号为键。筛选和映射都会保留这些键，所以第一次 JSON 编码得到对象；只有明确需要 JSON 列表时，才用 `array_values()` 重新编号。

<!-- quick -->

```php
// file: transform_orders.php
<?php

$orders = [
    104 => ['customer' => 'Mina', 'paid' => true, 'cents' => 1599],
    207 => ['customer' => 'Omar', 'paid' => false, 'cents' => 2400],
    310 => ['customer' => 'Liu', 'paid' => true, 'cents' => 800],
];

$paid = array_filter(
    $orders,
    fn(array $order): bool => $order['paid'],
);
$labels = array_map(
    fn(array $order): string => sprintf('%s:%.2f', $order['customer'], $order['cents'] / 100),
    $paid,
);

echo json_encode($labels, JSON_UNESCAPED_UNICODE), PHP_EOL;
echo json_encode(array_values($labels), JSON_UNESCAPED_UNICODE), PHP_EOL;
```

```text
{"104":"Mina:15.99","310":"Liu:8.00"}
["Mina:15.99","Liu:8.00"]
```

<!-- /quick -->

`$paid` 中只剩键 `104` 和 `310`，`$labels` 继续保留这两个键。PHP 的 `json_encode()` 只把从 `0` 开始连续编号的数组编码为 JSON 数组，因此第一行使用对象形状。

第二行在序列化边界主动调用 `array_values()`。这种位置比在每一步都重排更容易审查：业务处理阶段保留订单编号，只有外部格式要求列表时才丢弃键。

### 按地区归约订单金额

`array_reduce()` 可以返回数组，而不只是数字。这里的空数组初始值既处理了空订单列表，也声明累加器是一张地区到金额的映射。

```php
// file: summarize_orders.php
<?php

$orders = [
    ['region' => 'east', 'cents' => 1200],
    ['region' => 'west', 'cents' => 750],
    ['region' => 'east', 'cents' => 750],
];

$totals = array_reduce(
    $orders,
    function (array $carry, array $order): array {
        $region = $order['region'];
        $carry[$region] = ($carry[$region] ?? 0) + $order['cents'];
        return $carry;
    },
    [],
);

foreach (array_keys($totals) as $region) {
    printf("%s=%d\n", $region, $totals[$region]);
}
```

```text
east=1950
west=750
```

回调每轮都返回更新后的 `$carry`。如果只修改局部变量却忘记返回，下一轮收到的累加器会变成 `null`，类型声明会很快暴露这个错误。

地区第一次出现的顺序决定了 `$totals` 的迭代顺序。`array_keys()` 取得地区名列表，但打印时仍通过原映射读取金额；它不会把金额复制进键列表。

### 区分合并与联合

用户设置放在 `array_merge()` 的右侧，因此覆盖默认设置。嵌套的 `features` 数组会整体替换；这个函数只处理顶层键。

```php
// file: merge_settings.php
<?php

$defaults = [
    'theme' => 'light',
    'page_size' => 20,
    'features' => ['search'],
];
$user = ['theme' => 'dark', 'features' => ['export']];

$settings = array_merge($defaults, $user);
$states = [10 => 'draft', 20 => 'review']
    + [20 => 'ignored', 30 => 'published'];

echo json_encode($settings), PHP_EOL;
echo json_encode($states), PHP_EOL;
```

```text
{"theme":"dark","page_size":20,"features":["export"]}
{"10":"draft","20":"review","30":"published"}
```

第二个表达式使用 `+`，所以左侧键 `20` 的 `review` 胜出，整数键 `10`、`20`、`30` 保持不变。因为这些键不是连续列表索引，JSON 结果仍是对象。

如果把两个操作互换，代码仍然合法，但数据政策完全改变。配置合并代码应通过变量名或测试说明覆盖方向，而不能只依赖参数位置让读者猜测。

### 复制后稳定排序

`usort()` 会修改参数，因此先复制 `$tasks`。两个优先级为 `1` 的任务被比较为相等，在 PHP 8.3 中保持输入顺序。

```php
// file: sort_tasks.php
<?php

$tasks = [
    ['name' => 'write', 'priority' => 2],
    ['name' => 'test', 'priority' => 1],
    ['name' => 'deploy', 'priority' => 1],
];

$sorted = $tasks;
usort(
    $sorted,
    fn(array $left, array $right): int =>
        $left['priority'] <=> $right['priority'],
);

echo implode(',', array_column($sorted, 'name')), PHP_EOL;
echo implode(',', array_column($tasks, 'name')), PHP_EOL;
```

```text
test,deploy,write
write,test,deploy
```

第一行证明排序结果按优先级升序排列，也证明相等项 `test` 和 `deploy` 的相对顺序没变。第二行证明原数组仍保持原顺序，因为传给 `usort()` 的是 PHP 数组的副本。

如果业务要求优先级相同时再按名称排序，就应把第二比较条件写进回调。稳定排序只保留相等项的输入顺序，不会凭空产生业务上的决胜规则。

## 陷阱

数组函数最危险的错误通常不会抛异常，而是悄悄改变键或把合法值当成空值。测试应同时断言值、键顺序和输入是否被修改。

### 过滤后把映射当列表

> **陷阱:** `array_filter()` 保留整数键；过滤 `[10, 20, 30]` 后可能得到 `[1 => 20, 2 => 30]`，JSON 编码时会变成对象。

**修复：** 先决定结果是业务映射还是列表。只有消费者要求连续索引时才调用 `array_values()`；如果键携带订单号或数据库 ID，就应保留它们，并让序列化格式明确表达映射。

### 用默认过滤误删 `0`

> **陷阱:** 省略回调会删除所有“空”值，不只是 `null` 和空字符串；整数 `0` 与字符串 `"0"` 也会消失。

**修复：** 用领域条件写回调，例如 `fn($value) => $value !== null`。不要用 `array_filter($values)` 代替输入校验，因为 PHP 的空值集合通常比业务上的无效值集合更大。

### 误以为首项是归约初始值

> **陷阱:** PHP 在未提供初始值时把 `null` 传给第一次回调，不会像 JavaScript 那样直接采用数组首项。

**修复：** 总是为可能为空的输入写出单位值，并让类型与结果一致。省略求积的初始值会让 `null` 参与运算，结果不再是你以为的首项起步；使用 `1` 才能表达乘法单位元。

### 把浅层合并当成配置策略

> **陷阱:** `array_merge()` 只在顶层处理覆盖；右侧嵌套数组会整体替换左侧同键数组，而 `array_merge_recursive()` 遇到重复标量时又可能把它们收集成数组。

**修复：** 先定义每个配置节点是覆盖、追加还是递归替换，再选择 API 或编写小型领域函数。至少测试重复字符串键、整数键和嵌套数组，不要把“recursive”理解为“符合配置覆盖需求”。

### 颠倒回调参数或返回类型

> **陷阱:** `ARRAY_FILTER_USE_BOTH` 的参数顺序是值、键；`usort()` 比较函数则接收两个值并必须返回整数。生成代码经常把前者写成键、值，或让后者返回布尔比较结果。

**修复：** 给回调参数写出领域名称和类型，并用一组小于、相等、大于的输入验证比较符号。布尔值转换为整数后只有 `0` 和 `1`，无法同时表达“排在前面”和“相等”。

### 忘记排序会修改输入

> **陷阱:** `usort()` 通过引用接收数组，修改原变量，并把字符串键和整数键都替换为连续索引。

**修复：** 需要同时保留原顺序时先赋值到新变量；需要保留键值关联时考虑 `uasort()`。不要把 `usort()` 的 `true` 返回值赋给 `$sorted`，真正的排序结果仍在传入变量里。

<!-- deep -->

## 边界语义

六个函数组合使用时，边界行为比单个函数的主路径更值得检查。尤其要关注输入数量、回调声明、PHP 键转换和排序相等项。

### 多数组映射改变键契约

单数组 `array_map()` 保留键，多数组形式却按位置对齐并返回连续整数键。即使两个输入都使用相同字符串键，函数也不会按键关联它们；它只使用各自的迭代位置。

较短输入会在尾部以 `null` 补齐，这会让声明为非空类型的回调在运行时抛出 `TypeError`。需要按业务键连接两张映射时，应显式遍历键并检查缺失项，而不是把多数组 `array_map()` 当作 join。

### 回调只看到 API 承诺的数据

`array_reduce()` 只传累加器和值，不传当前键。为了按键归约而先调用 `array_keys()` 是可行的，但回调随后需要通过闭包读取原数组；逻辑复杂时，`foreach ($array as $key => $value)` 更清楚。

`array_filter()` 的模式只改变回调参数，不改变返回数组的键保留规则。`ARRAY_FILTER_USE_KEY` 下回调看不到值，`ARRAY_FILTER_USE_BOTH` 下值在前、键在后；类型声明能让颠倒参数更早失败。

### PHP 键决定 JSON 形状

PHP 没有独立的列表运行时类型；列表只是键为 `0` 到 `n - 1` 的数组。筛选会留下空档，联合会保留非连续整数键，而 `array_merge()` 与 `usort()` 会重新编号。相同的值集合可能因此产生不同 JSON 形状。

不要为了得到 JSON 数组就盲目丢弃键。先确认键是否是业务标识；如果是，应把它写成对象字段，或有意输出 JSON 对象。序列化测试需要断言完整字符串或解码后的结构，而不只是值是否出现。

### 稳定排序仍需要完整规则

PHP 8 的稳定性只约束比较函数返回 `0` 的元素。输入本身来自不确定顺序的数据源时，保留输入相对顺序并不能得到跨运行稳定的业务结果；需要确定性输出时，应加入明确的第二排序字段。

比较函数还应满足一致性：比较同一对值时结果不能随外部可变状态变化，`compare(a, b)` 与 `compare(b, a)` 的符号应相反。不要在回调里执行网络请求、修改被比较元素或读取会变化的时钟。

### 值查找的严格性

`array_keys($array, $value)` 默认采用宽松比较，因此不同类型的值可能被视为匹配。数据经过表单、JSON 或数据库驱动后，数字与数字字符串混在一起很常见；如果类型本身有意义，第三个参数应传 `true`。

严格查找不会把键也变成严格类型。PHP 数组键本来就只能是整数或字符串，并且某些合法十进制整数字符串在存入数组时会转换为整数键。检查值的比较方式与理解键的规范化是两件不同的事。

### 数组函数与显式循环的取舍

一次转换或筛选通常适合标准函数，聚合状态也可以自然地写成归约。若管道需要多次遍历同一大数组、在找到结果后立刻停止，或同时依赖键和值并处理错误，显式循环常常更短，也更容易逐步调试。

可读性的判断标准是数据政策是否显而易见。一个命名良好的 `foreach` 不比嵌套的 `array_map(array_filter(...))` 低级；选择能让键、空输入和修改边界最容易审查的形式。

## 组合操作的审查方法

数组管道把多个正确的函数连起来后，仍可能产生错误结果。审查时不要只验证最后几个值，还要逐步写下中间数组的键、类型和所有权。

一个实用办法是给每一步准备最小反例，而不是只用整齐的连续列表。加入缺失键、合法零值和重复键后，错误的默认假设通常会立刻显现。

### 尽量晚重排键

`array_values()` 会永久丢掉原键，后续步骤无法恢复订单编号或来源位置。把重新编号留到确实要求列表的输出边界，前面的诊断信息会更多。

如果后续必须用位置访问元素，应在变量名中写明形状，例如 `$paidOrderList`。这个名称让读者知道键已经不再承载业务意义，也能阻止后续代码误把位置当作 ID。

### 明确定义空输入

每个聚合都应有可解释的空结果：金额总和是 `0`，分组结果是 `[]`，拼接文本可能是 `''`。把它作为 `array_reduce()` 的初始值，正常输入和空输入就共享同一返回类型。

如果不存在合理的空结果，先检查输入并抛出领域异常通常比返回 `null` 更清楚。不要让省略初始值替你偷偷选择 `null`；那只是 API 默认值，不是业务决策。

### 区分转换与校验

`array_map()` 适合把每个有效输入转换为输出，`array_filter()` 适合按已定义的条件选择输入。把解析失败、缺字段和授权失败都折叠成 `null`，再用无回调过滤，会丢失错误原因，也可能误删合法零值。

需要报告每个失败时，可以用显式循环收集结果和错误，或让回调抛出有上下文的异常。调用方应能区分“没有匹配项”与“输入无法处理”。

### 先命名复杂步骤

连续嵌套的数组函数按照从内到外的顺序执行，而代码阅读通常从外向内开始。把重要的中间结果命名为 `$eligibleOrders`、`$totalsByRegion` 之类的变量，可以显露数据形状和政策。

命名步骤也方便在调试器中检查键以及为单步行为写测试。若一个回调需要捕获多个变量并包含几个分支，把它提取成命名函数或普通循环通常更省事。

### 用形状断言保护边界

测试列表结果时，既断言 `array_is_list($result)`，也断言元素顺序。测试映射时则断言确切键及其类型，避免 `array_values()` 或 `array_merge()` 的重排在重构中悄悄通过。

排序测试至少包含一对相等项，并保留排序前数组用于比较。合并测试至少包含一次字符串键冲突、一次整数键冲突和一个嵌套数组；这几类输入能直接暴露函数选错的问题。

<!-- /deep -->

[检查点: php/array-functions](https://codewiki.com/zh/php/array-functions/#checkpoint)

## 延伸阅读

- [PHP 手册：数组函数](https://www.php.net/manual/en/ref.array.php)
- [PHP 手册：`array_map()`](https://www.php.net/manual/en/function.array-map.php)
- [PHP 手册：`array_filter()`](https://www.php.net/manual/en/function.array-filter.php)
- [PHP 手册：`array_reduce()`](https://www.php.net/manual/en/function.array-reduce.php)
- [PHP 手册：`array_merge()`](https://www.php.net/manual/en/function.array-merge.php)
- [PHP 手册：`usort()`](https://www.php.net/manual/en/function.usort.php)
