数组

理解 PHP 数组的有序映射模型、键规范化、列表形状、值语义与引用,并避开静默改变数据结构的常见错误。

难度 入门 时长 标准深度约 12分钟
版本 PHP 8.3.33
what

PHP 数组是一种把整数键或字符串键映射到任意值的有序映射;同一种运行时类型既能表示列表,也能表示字典式记录。

trap

键会被规范化,unset() 不会重排整数键,而 isset() 无法区分键缺失与值为 null;这些规则常让有效代码悄悄改变数据形状。

fix

明确数据需要列表还是映射,访问可空字段时使用 array_key_exists(),按引用 foreach 后立即 unset() 循环变量。

是什么,为什么存在

PHP 数组(PHP array)是一种 有序映射(ordered map) 。每个元素由一个整数键或字符串键与一个值组成,遍历顺序就是元素的插入顺序。值可以是标量、对象、资源或另一个数组,因此一个容器就能表达列表、查找表和嵌套记录。

PHP 没有把列表与关联映射做成两种独立的运行时类型。键恰好是从 0 开始的连续整数时,这个数组就是 PHP 列表(PHP list) ;键不连续或含有字符串时,它仍是数组,但不是列表。array_is_list() 用于检查这个形状,而不是检查元素类型。

这种设计适合请求参数、配置、数据库行和短生命周期的数据变换。你可以逐步加入字段,也可以按业务标识查找元素,不必先声明固定尺寸。数组的灵活性也意味着字段拼写、键类型和列表形状都需要由代码或静态分析器约束。

字段固定且需要不变量的领域数据通常更适合类、枚举或只读对象。大量队头删除、优先队列或流式数据也有更合适的 SPL 数据结构与生成器。数组能承担多种角色,不等于它在每个角色上都表达得最清楚。

工作原理

数组字面量使用 [],元素写成 key => value。省略键时,PHP 分配整数键;显式字符串键常用于记录,显式整数键则可表示业务编号或稀疏位置。

  • $colors = ['red', 'green'] 创建一个列表。
  • $profile = ['name' => 'Mina', 'email' => null] 创建使用字段名的映射。
  • $states = [10 => 'draft', 20 => 'review'] 创建使用非连续整数键的映射。

这三者的运行时类型都是 array。只有 $colors 满足列表条件;$profile 使用字段名,$states 的整数键也没有从 0 连续排列。

字面量语法本身不会声明数组的用途。调用方如何解释键,才决定它是位置序列、业务映射还是临时记录。

键与插入顺序

数组键最终只能是 intstring。部分其他类型会发生 数组键规范化(array-key normalization) :有效的十进制整数字符串可能变成整数,布尔值变成 01null 变成空字符串。规范化后的键相同,后写入的值就覆盖先前的值。

省略键的 $array[] = $value 会使用数组内部记录的下一个整数键。它不会为了填补中间空档而搜索最小可用位置。删除一个元素也不会移动其他元素,所以键集合与元素数量是两件不同的事。

数组保留插入顺序,而不是自动按键排序。覆盖现有键只替换该键的值,不会把元素移动到末尾。需要排序或重新编号时要显式调用相应数组函数,这些函数的键规则属于 php/array-functions 的范围。

访问与存在性

读取 $data[$key] 需要该键存在,否则 PHP 会产生 Undefined array key 警告并得到 null。外部输入经常缺字段,所以直接索引应该建立在校验或明确的数据契约之上。

$data[$key] ?? $fallback 不会为缺失键产生警告,很适合“缺失或 null 都使用默认值”的规则。这个运算符采用与 isset() 相同的存在性语义,因此不能保留“键存在但值为 null”这个区别。

array_key_exists($key, $data) 只检查键是否存在,即使对应值为 null 也返回 trueisset($data[$key]) 则要求键存在且值不是 null。先写清楚领域规则,再选择对应检查,不能把两者当作可互换写法。

修改与遍历

方括号赋值会新增或替换元素,$array[] 会追加元素,unset($array[$key]) 会移除一个键值对。count() 返回当前元素数量,不保证合法键是 0count() - 1。只有列表才可以安全地按这个范围做位置访问。

foreach ($array as $key => $value) 按插入顺序读取键和值。默认的 $value 是当前元素值的赋值结果,对它重新赋值不会改回数组。需要原地修改时可以写 &$value,但这会创建引用,并要求在循环后清除循环变量。

数组赋值表现为值语义:$copy = $original 后,修改 $copy 的数组结构不会修改 $original。PHP 内部通过 写时复制(copy-on-write) 推迟实际复制,但这项优化不改变可观察到的独立值语义。显式的 & 才会让两个变量成为同一数组的别名。

操作键的结果是否修改原数组
$array[$key] = $value新增或覆盖指定键
$array[] = $value使用下一个整数键
unset($array[$key])删除键且不重排其他键
$copy = $array保留全部键与顺序
array_values($array)返回从 0 开始的新列表

示例

下面四个示例依次展示映射、可空字段、列表形状和值语义。所有输出都由本地 PHP 8.3.33 CLI 实际生成。

用业务键建立库存映射

商品编号是业务标识,不是位置。用编号作键后,查找和更新都能直接表达目标商品,foreach 仍按插入顺序输出。

inventory.php
<?php

$inventory = [
    'BK-101' => ['title' => 'PHP Patterns', 'stock' => 3],
    'BK-205' => ['title' => 'Web APIs', 'stock' => 0],
];

$inventory['BK-330'] = [
    'title' => 'Testing Services',
    'stock' => 5,
];
$inventory['BK-101']['stock'] -= 1;

foreach ($inventory as $sku => $book) {
    printf("%s | %s | %d\n", $sku, $book['title'], $book['stock']);
}
BK-101 | PHP Patterns | 2
BK-205 | Web APIs | 0
BK-330 | Testing Services | 5

更新 BK-101 不会改变它在遍历中的位置。库存为 0 也仍是正常元素;是否售罄是值的含义,与键是否存在无关。

如果这里使用普通列表,调用方必须先搜索编号再更新。映射把编号唯一性与直接查找放进了容器形状,但代码仍要验证外部数据是否真的提供了唯一编号。

区分缺失字段与空值

资料中的 email 字段被明确设置为 null,而 phone 根本不存在。array_key_exists() 可以保留这项差别,isset()?? 则把两者归到同一条分支。

optional_fields.php
<?php

$profile = [
    'name' => 'Mina',
    'email' => null,
];

$fields = ['email', 'phone'];
foreach ($fields as $field) {
    printf(
        "%s key=%s isset=%s value=%s\n",
        $field,
        array_key_exists($field, $profile) ? 'yes' : 'no',
        isset($profile[$field]) ? 'yes' : 'no',
        $profile[$field] ?? 'fallback',
    );
}
email key=yes isset=no value=fallback
phone key=no isset=no value=fallback

如果 null 表示用户主动隐藏邮箱,读取代码就应先检查 array_key_exists(),再处理这个值。若产品规则本来就把两种情况都视为“没有邮箱”,?? 才是简洁且准确的表达。

这里没有直接读取 $profile['phone'],所以不会产生 Undefined array key 警告。安全访问不是固定的一种写法,而是让检查方式匹配缺失值政策。

观察删除后的列表形状

从列表中 unset() 一个元素会留下键空档。之后追加使用下一个整数键,并不会回填被删除的 1

list_shape.php
<?php

$workflow = ['draft', 'review', 'publish'];
unset($workflow[1]);
$workflow[] = 'archive';

echo json_encode($workflow), PHP_EOL;
echo array_is_list($workflow) ? "list\n" : "map\n";

$steps = array_values($workflow);
echo json_encode($steps), PHP_EOL;
echo array_is_list($steps) ? "list\n" : "map\n";
{"0":"draft","2":"publish","3":"archive"}
map
["draft","publish","archive"]
list

第一行编码成 JSON 对象,因为整数键不连续。array_values() 明确丢弃旧键并生成新列表,所以第三行编码成 JSON 数组。

只有当键确实只是位置时才应该重排。若键是订单号或外部标识,调用 array_values() 会删除信息;更合适的 JSON 设计可能是保留对象形状,或把标识放进每个元素的字段。

复制后批量修改

普通赋值创建独立的数组值。按引用遍历可以修改副本中的每个金额,但循环变量必须在循环后解除引用。

discount_prices.php
<?php

$prices = [
    'BK-101' => 2500,
    'BK-205' => 1800,
];
$discounted = $prices;

foreach ($discounted as &$cents) {
    $cents -= 500;
}
unset($cents);

$cents = 9999;
echo json_encode($prices), PHP_EOL;
echo json_encode($discounted), PHP_EOL;
{"BK-101":2500,"BK-205":1800}
{"BK-101":2000,"BK-205":1300}

原数组没有变化,说明 $discounted 不是 $prices 的别名。unset($cents) 只解除变量与最后一个元素的引用,不会删除数组元素;之后给 $cents 赋值也不会污染 $discounted

若元素是对象,复制数组不会克隆对象本身,两个数组中的对应元素仍可指向同一个对象。需要独立对象图时必须定义显式复制政策,而不能把数组的值语义误解为递归深拷贝。

陷阱

数组错误经常不会立即抛出异常。键和值都可能看起来合理,直到序列化、可空字段或后续遍历暴露了形状变化。

isset() 当作键检查

**修复:**需要区分两种状态时使用 array_key_exists('email', $row),然后单独判断值。只有“存在且非 null”正是所需条件时才使用 isset()

认为 unset() 会压紧索引

**修复:**不需要位置时使用 foreach。确实需要新列表时调用 array_values(),并把这次丢弃旧键的转换放在清楚的边界上。

忽略键规范化造成覆盖

**修复:**在插入前验证并规范化标识格式。若数字字符串必须保留文本身份,使用不会被转成十进制整数的明确前缀,例如 user:1,并测试冲突输入。

遗留按引用循环变量

**修复:**按引用循环结束后立即写 unset($item),并让循环范围尽量短。若能通过键进行清楚的赋值,优先使用 $items[$key] = ...,以免引入长寿命引用。

把数组副本当成深拷贝

**修复:**先确定需要复制的是容器、嵌套数组还是对象图。对象需要独立身份时显式实现克隆或映射构造;不要用序列化往返充当未经设计的通用深拷贝。

深入 键规范化与列表形状

键规范化与列表形状

PHP 数组的灵活性集中在键规则上。理解写入时的规范化和后续的列表判定,可以解释覆盖、追加与 JSON 形状为何互相影响。

哪些键会被转换

整数和字符串可以直接作键。没有前导 + 的有效十进制整数字符串会转成整数,例如 '8'8 指向同一个键;'08' 不属于这种可转换形式,因此仍是字符串键。大小写不同的非数字字符串仍是不同的键。

布尔键转换为整数,所以 false0 冲突,true1 冲突。null 转换为空字符串,因此它可能覆盖显式的 '' 键。浮点键会转换为整数并丢弃小数部分;PHP 8.1 起,在这种转换损失精度时会产生弃用诊断,因此不应把浮点数设计成数组键。

数组与对象不能直接用作数组键,尝试这样做会触发 TypeError。从 JSON、表单或数据库读取动态标识时,先决定应用层允许的键格式,再统一验证;依赖隐式转换会让冲突政策藏在语言细节里。

覆盖不会改变位置

写入一个规范化后已存在的键,会替换值但保留该元素原来的遍历位置。写入新键才会把元素放到插入顺序末尾。键排序与插入顺序没有自动联系。

这种行为让配置覆盖保持稳定顺序,但也会隐藏重复输入。如果重复键本应报错,不能只看最终数组;解析阶段需要在覆盖发生前检测冲突,或保留原始条目列表进行验证。

列表是一项形状约束

array_is_list($array) 在空数组上返回 true,也会在键严格为 0, 1, ..., count($array) - 1 且顺序一致时返回 true。元素值及其类型不影响判断,字符串键 '0' 在写入时通常已规范化为整数 0

列表形状是许多边界协议的隐含要求。位置循环、元组式解构和 JSON 数组都依赖连续整数键;业务映射则常需要保留非连续编号或字符串键。变量名与函数返回说明应把这一点写清楚。

追加使用内部计数器

$array[] = $value 依靠下一个整数键计数器,而不是每次从头查找空档。删除键通常不会倒退这个计数器,所以删除后追加会继续产生更大的键。

显式写入很大的整数键也会影响之后的追加键。把业务编号和无键追加混在同一个数组里,会让新键依赖历史写入;对于记录映射,通常应始终显式提供业务键。

JSON 暴露形状差异

json_encode() 只把 PHP 列表编码成 JSON 数组。包含字符串键或不连续整数键的数组会编码成 JSON 对象,而且整数键在 JSON 对象中表现为字符串属性名。

这不是编码器随意改变数据,而是 JSON 只有数组与对象两种容器形状。API 测试应断言解码后的结构或完整 JSON,而不应只搜索其中的值。需要列表时,在边界显式调用 array_values() 并确认旧键没有业务含义。

值语义、引用与遍历

数组变量默认表现为独立值,但数组元素可能包含共享对象,显式引用也能改变默认关系。审查复制代码时,需要把外层容器、嵌套数组和对象身份分开讨论。

写时复制是实现策略

PHP 不必在每次数组赋值时立即复制全部存储。两个变量可以暂时共享内部表示,直到其中一个变量执行写操作;此时运行时再分离需要修改的数组。这就是写时复制。

应用代码应依赖值语义,而不是猜测共享存储持续多久。微优化不应以内部引用计数为接口契约,也不能据此声称复制“免费”。没有经过测量,就不对特定数组大小作性能结论。

嵌套值不等于独立对象图

数组中的标量与嵌套数组仍遵循值语义,因此修改副本中的嵌套数组不会自动改动原数组对应的嵌套数组。对象元素保存的是对象句柄;复制外层数组后,两边的句柄仍指向同一个对象,除非显式克隆。

资源也代表外部实体,而不是可以靠复制数组独立出来的纯值。文件句柄或连接被放进数组后,复制容器不会创建第二个独立资源。需要管理生命周期的数据应使用明确的所有者和关闭政策。

引用会改变所有权关系

$alias =& $array 让两个变量引用同一个数组变量,此后从任一名称写入都能从另一名称观察到。函数参数上的 & 也允许被调用方直接修改调用者的变量,这应是公开且必要的 API 契约。

数组还可以保存引用元素,使单个槽位与外部变量联动。这种结构很难通过类型声明看出,复制与遍历时也更难推理。除非接口明确需要别名关系,否则返回新值通常更容易测试。

foreach 的值变量与引用变量

按值形式把当前元素赋给循环变量。修改循环变量本身不会写回标量元素,但如果元素是对象,通过该对象句柄修改属性仍会作用于同一个对象。

按引用形式把循环变量绑定到当前槽位,因此赋值会直接修改数组。循环结束不会自动解除最后一次绑定,这正是遗留引用陷阱的来源。固定写法是在循环后立刻 unset($value),不要等到更远的位置再清理。

遍历期间新增或删除元素会增加控制流负担,按值与按引用形式的观察结果也可能不同。除非算法确实需要边遍历边改结构,更清楚的做法是构造一个结果数组,或先收集要修改的键再执行修改。

运行时类型与静态形状

函数签名中的 array 只说明值是数组,不能表达键和值的具体类型。array<string, int>list<string> 和数组形状语法通常属于 PHPDoc 与 PHPStan、Psalm 等静态分析工具,不能直接替代运行时参数类型。

生成代码常把文档类型误写到原生类型位置,或者写了 array 就假设字段一定存在。可靠的边界会组合原生 array 类型、输入校验、清楚的 PHPDoc 与测试;字段长期稳定时,值对象往往比不断扩张的数组形状更合适。

设计清晰的数组边界

数组最容易在系统边界失去约束。输入层需要确认键和值,返回层需要声明列表或映射形状,中间逻辑才可以依赖稳定的数据契约。

先验证再读取

对请求体或解码后的 JSON,仅有 is_array() 还不够。代码还要检查必需键是否存在、值类型是否正确,以及未知键是拒绝、忽略还是保留。直接使用多个 ?? 默认值可能把拼写错误伪装成正常缺失。

可空字段需要两阶段判断:先用 array_key_exists() 确认是否提供,再检查值是否为 null 或目标类型。这样才能分别报告“没有字段”和“字段明确为空”,也能避免直接索引产生警告。

声明返回形状

返回类型写成 array 时,调用方仍不知道应该按位置还是按键消费。函数名、PHPDoc 和测试应共同说明返回的是 list<Order>array<string, Order>,还是带固定字段的数组形状。

返回前随手调用 array_values() 会让类型看起来更整齐,却可能删除业务标识。相反,把本应是列表的空档键泄漏到 JSON,也会把数组变成对象。形状转换应放在负责该协议的边界函数中。

用反例测试契约

整齐的 ['a', 'b'] 无法暴露大多数键错误。列表测试至少应覆盖空数组、中间删除后的空档与非零起始键;映射测试应覆盖整数字符串冲突、null 值和覆盖后顺序。

复制与引用测试需要观察两个变量。修改副本后断言原值不变,再把对象作为元素重复测试,才能证明代码区分了容器独立与对象共享。按引用循环还应在循环外复用变量,以捕获遗留绑定。

在灵活性失控前换类型

数组形状拥有许多可选字段、嵌套分支和跨字段约束时,校验代码会散落到每个消费者。把数据转换成构造时校验的值对象,可以集中不变量并让方法签名表达用途。

转换不必发生在每个内部步骤。数组仍适合解析边界与短暂变换,但进入稳定领域模型时应缩小形状;输出时再显式映射为协议需要的列表或对象。

延伸阅读

检查点

5个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?