安全公告 PHP 严重安全漏洞:请及时升级至修复版本 查看详情

别再只用数组了,这 10 种 PHP 数据结构值得了解 ​

PHP 的数组几乎无所不能,甚至包揽了本该由真正的栈、队列或堆更好地完成的工作。以下介绍 10 种常被忽视的内置数据结构。

PHP 的数组既是设计上的杰作,也容易成为开发者的依赖。它同时扮演着有序映射、列表、栈、队列、集合和查找表的角色。这种灵活性确实出色,直到它开始让代码变得难以阅读,并带来不必要的性能开销。正因为数组几乎什么都能做,而且做得还算不错,大多数 PHP 开发者遇到任何问题都会先想到它。然而,有些场景若采用专门的数据结构,代码意图会更清晰,更不容易被误用,有时性能也会显著提升。

PHP 的标准 PHP 库(Standard PHP Library,SPL)内置了一整套这样的专用结构,但许多开发者从未使用过。例如,不能随意通过索引访问的真正的栈、从代码上就能看出用途的队列、始终返回最紧急事项的优先队列,以及允许直接以对象为键的结构。它们早已存在于语言之中,无需安装,就能把某些“巧妙的数组技巧”变成直白、能够自我说明的代码。

本文将介绍 SPL 中的十种数据结构,说明各自的实际用途、使用代码,以及何时确实比数组更合适。以下所有示例均在 PHP 8.3 上运行过,所列输出也都是实际运行结果。目的并不是抛弃数组——大多数时候,它仍然是正确的工具——而是辨认出少数不适合使用数组的时刻,并知道应当用什么替代。

开始之前需要说明:这里几乎所有结构的价值,都既关乎实现机制,也关乎意图表达。用数组实现栈完全可行,但后续阅读代码的人必须从压入和弹出操作中推断它是一个栈。而 SplStack 直接通过类型说明了这一点。让代码明确表达自身用途,而不是让读者自行还原意图,正是贯穿这十项内容的核心价值。

1. 需要后进先出时,使用 SplStack ​

栈适合“撤销历史”这类场景:最后放进去的元素,最先取出来。开发者经常通过数组配合 array_push 和 array_pop 来模拟栈,这种方式可以正常工作,但无法阻止后来维护代码的人通过索引访问中间的元素,在不知不觉中破坏栈的使用约定。SplStack 能让意图更明确,也让误用变得不那么顺手:

php
$stack = new SplStack();
$stack->push('a');
$stack->push('b');
$stack->push('c');
$stack->top();   // 'c'  - 查看但不移除
$stack->pop();   // 'c'  - 移除并返回最新的元素
$stack->pop();   // 'b'

验证结果:top() 返回 c,但没有移除它;随后连续调用 pop(),依次返回 c 和 b,严格遵循后进先出的顺序。这里的优势不在于性能,而在于代码明确表明了自身用途。看到 new SplStack(),读者就能立即理解这个集合应该如何使用,而且它的 API 只提供栈操作,因此没有可供误用的索引。对于撤销栈、解析器状态、深度优先遍历,以及任何“最近的优先”逻辑,真正的栈能直接表达普通数组只能隐含的含义。

2. 需要先进先出时,使用 SplQueue ​

队列与栈恰好相反:元素按到达顺序返回,最早进入的最先取出。它天然适合任务列表、任务处理、广度优先遍历,以及任何强调“公平”、让等待最久的事项先得到处理的场景。用数组配合 array_push 和 array_shift 也能实现,但这种方式存在不易察觉的开销,而且同样无法直接体现意图。SplQueue 则通过名称明确了用途:

php
$queue = new SplQueue();
$queue->enqueue('first');
$queue->enqueue('second');
$queue->enqueue('third');
$queue->dequeue();   // 'first'  - 最早进入的元素最先取出
$queue->dequeue();   // 'second'

验证结果:dequeue() 先返回 first,再返回 second,保留了元素的到达顺序。这里还有一个容易忽略的性能因素。对大型数组反复调用 array_shift,开销比表面上更大:移除第一个元素会迫使其余所有元素重新建立索引,因此,通过循环不断从头部取出元素直至清空大型数组,实际执行的工作远多于看上去的操作量。SplQueue 没有这项开销。不过,选择它的主要理由仍然与栈相同。代码中的 enqueue 明确告诉读者这个集合的用途,而单独一个 array_shift 则需要读者稍作推断。对于需要处理数千个排队任务的工作进程,使用队列既能提升可读性,也能同时避开重新建立索引的成本。

3. 需要优先处理最重要的事项时,使用 SplPriorityQueue ​

有些队列并不讲究先来后到,而是按紧急程度排序。对于任务调度器、告警系统或 A* 寻路算法,需要的不是最早进入的元素,而是最重要的元素,并且每个元素的重要程度可能不同。用数组模拟这种行为,意味着每次插入后都要排序,或者每次读取时都要扫描以寻找最大值。SplPriorityQueue 会自动维护所需的顺序:

php
$pq = new SplPriorityQueue();
$pq->insert('low task', 1);
$pq->insert('urgent task', 10);
$pq->insert('normal task', 5);
$pq->extract();   // 'urgent task'
$pq->extract();   // 'normal task'
$pq->extract();   // 'low task'

验证结果:虽然插入顺序是混杂的,但取出顺序始终为紧急任务 → 普通任务 → 低优先级任务,严格遵循优先级。可以随时插入元素,并为其指定一个优先级数值。每次取出时,得到的都是剩余元素中优先级最高的一个,无需手动排序,也无需扫描以查找最大值。对于通知、作业分级、调度等按重要程度而非到达顺序处理工作的场景,这个结构能将“始终优先处理最紧急的事项”变成数据结构本身的属性,而不是需要反复编写和维护的逻辑。

4. 需要自动维护取出顺序的集合时,使用 SplHeap ​

优先队列的底层是堆,PHP 也直接提供了堆结构,适用于希望元素在移除时按顺序返回、却不想显式调用 sort 的场景。SplMinHeap 总是取出剩余元素中的最小值,SplMaxHeap 则取出最大值:

php
$min = new SplMinHeap();
foreach ([5, 1, 8, 3] as $n) $min->insert($n);
// 取出顺序:1 3 5 8
$max = new SplMaxHeap();
foreach ([5, 1, 8, 3] as $n) $max->insert($n);
// 取出顺序:8 5 3 1

两者的验证结果如下:依次插入 5、1、8、3 后,最小堆按 1 3 5 8 的顺序取出全部元素,最大堆则按 8 5 3 1 的顺序取出。堆只维护足以确定下一个最小值或最大值的顺序。当需求只是逐个获取极值时,这比完整排序的成本更低,例如在数据流中维护“最大的 10 个元素”、合并多个有序数据源,或在调度器中始终选择截止时间最早的任务。若需要完全自定义排序规则,可以继承 SplHeap 并定义自己的比较方法,从而让集合在每次插入时自动按指定规则维护顺序,无需另行编写维护逻辑。

5. 需要以对象为键时,使用 SplObjectStorage ​

它解决了一个数组确实无法直接解决的问题。PHP 数组的键只能是字符串或整数,因此不能使用对象作为键。然而,“将一些数据与这个特定对象关联起来”的需求十分常见,例如记录哪些用户在线、哪些实体已被标记为脏状态,或为特定实例附加元数据。SplObjectStorage 正是为此而设计:它允许直接以对象为键,还可以为每个对象附带数据:

php
$storage = new SplObjectStorage();
$u1 = new User();
$u2 = new User();
$storage->attach($u1, ['role' => 'admin']);
$storage->attach($u2, ['role' => 'editor']);
$storage->contains($u1);   // true
$storage[$u1]['role'];     // 'admin'  - 与该对象关联的数据
$storage->detach($u1);
$storage->contains($u1);   // false

验证结果:它先确认包含 $u1,返回与该对象关联的 admin 数据;移除后,又正确地报告不再包含该对象。它依据对象本身的身份来识别对象,而不需要开发者自行构造并维护字符串键。它也可以用作对象集合,让每个对象只出现一次,从而简洁地回答“这个实例是否已经处理过”这样的问题。对于跟踪对象、去重或为特定对象附加元数据的需求,若仅用数组实现等价功能,就难免借助 spl_object_id 和额外的查找表,形成容易出错的变通方案。

6. 大小已知且固定时,使用 SplFixedArray ​

普通 PHP 数组具有很高的灵活性,也为此付出了代价:其底层是一个完整的哈希映射,需要承担动态增长、容纳任意键以及保留插入顺序所需的机制。当需求只是恰好 N 个以整数索引访问的槽位,不需要其他复杂能力时,SplFixedArray 是一种更精简、约束更严格的选择:

php
$fa = new SplFixedArray(3);
$fa[0] = 'x';
$fa[1] = 'y';
$fa[2] = 'z';
$fa->getSize();   // 3
$fa[5] = 'oops';  // RuntimeException: Index invalid or out of range

验证结果:它支持按索引存取数据,报告大小为 3,并在越界写入时抛出异常,而不会像普通数组那样悄然扩展。这种严格约束本身就是一种优势。差一错误在普通数组中可能只是意外增加一个元素,直到很久以后才被发现;而在这里,它会立即成为一个明确的错误。它也具有一定的内存优势,不过需要客观看待:测试中,存储一百万个整数时,固定数组占用约 16.0 MB,普通数组则约为 16.8 MB,节省约 5%,确有收益,但幅度有限。因此,选择它的主要理由应当是固定大小、整数索引数据的正确性与意图表达;内存节省只是额外收益,并非质的飞跃。对于真正庞大、每一个百分点都值得争取的数值数据集,这种收益会逐渐累积;在日常代码中,如果希望数据结构明确约束“大小固定,而且这一点很重要”,它就值得使用。

7. 需要双向遍历时,使用 SplDoublyLinkedList ​

栈和队列实际上都是双向链表的特化形式,PHP 也提供了通用的双向链表结构,适用于需要从任意一端遍历序列,或切换遍历方向的场景。通过设置迭代模式,同一个链表既可以从前往后读取,也可以从后往前读取,无需反转链表:

php
$list = new SplDoublyLinkedList();
$list->push('a');
$list->push('b');
$list->push('c');
$list->setIteratorMode(SplDoublyLinkedList::IT_MODE_FIFO);
// 迭代结果:a b c
$list->setIteratorMode(SplDoublyLinkedList::IT_MODE_LIFO);
// 迭代结果:c b a

验证结果:只需切换迭代模式,同一个链表在 FIFO 模式下的遍历结果为 a b c,在 LIFO 模式下则为 c b a。链表擅长在两端高效插入和移除元素,而可切换的遍历方向,有时恰好符合需求,例如可双向浏览的历史记录,或从两端消费的缓冲区。这是本文中适用范围最窄的一项,但当访问模式涉及“两端操作”或“双向遍历”时,它能直接表达这种需求,而不必费力处理数组偏移量。

8. 需要具有对象语义的数组时,使用 ArrayObject ​

PHP 数组是值类型:将一个数组赋给另一个变量,或将其传入函数时,会发生复制。通常,这种复制是一种安全保障。但有时确实需要共享可变状态,让代码的多个部分通过同一个句柄更新数据,同时又不希望到处使用 & 引用。ArrayObject 提供了类似数组的访问方式,同时具备对象的句柄语义:

php
$data = new ArrayObject(['x' => 1]);
$addY = function (ArrayObject $a) {
    $a['y'] = 2;        // 修改调用方的对象,无需 &
};
$addY($data);
$data->getArrayCopy();   // ['x' => 1, 'y' => 2]

验证结果:函数添加了一个键,原始对象上也能看到这一变化,整个过程无需按引用传参,因为 ArrayObject 与其他对象一样通过句柄传递。它支持 [] 访问、迭代和计数,并内置了类似引用的共享行为。当集合确实需要跨函数边界共享和修改时,这是一个清晰的解决方案,无需采用容易出错的替代方式:以引用传递数组,并寄希望于所有人都记得使用 &。

9. 使用 iterator_to_array 转回数组 ​

这里介绍的每一种结构都可以迭代,因此,一旦需要普通数组,例如传给要求数组参数的函数、进行序列化,或调用只支持数组的内置函数,只需一行代码就能完成转换:

php
$queue = new SplQueue();
$queue->enqueue(1);
$queue->enqueue(2);
$queue->enqueue(3);
iterator_to_array($queue);   // [1, 2, 3]

验证结果:它将队列转换成了普通数组 [1, 2, 3]。这也是采用 SPL 结构风险较低的原因:使用之后,并不会被束缚在这些结构之中。可以在业务逻辑中使用适合的数据结构,再在需要数组的接口边界将其转换回普通数组。它同样适用于生成器和任何实现 Traversable 的对象,因此,当明确需要数组形式时,它也是将可迭代数据实体化为数组的通用工具。

10. 需要自己的排序规则时,自定义 SplHeap ​

最后一项与其说是一种单独的数据结构,不如说是 SPL 提供的扩展入口。继承 SplHeap 并定义 compare 方法,就能获得一个按自定义规则维护顺序的集合:例如综合截止时间和优先级安排新任务、按某个字段确定对象顺序,或维护任何自定义的“下一个处理谁”的逻辑。每次插入时,结构都会自动遵循规则,无需调用 sort:

php
class DeadlineHeap extends SplHeap {
    protected function compare($a, $b): int {
        // 截止时间最早的任务先取出
        return $b['deadline'] <=> $a['deadline'];
    }
}
$heap = new DeadlineHeap();
$heap->insert(['task' => 'A', 'deadline' => 300]);
$heap->insert(['task' => 'B', 'deadline' => 100]);
// 取出时,B(截止时间 100)先于 A(截止时间 300)

当需求是“需要一个有序集合,但排序规则比较特殊”时,这就是解决途径。无需每添加一个元素就重新排序数组,只需定义一次顺序规则,数据结构就会自动维护。它是本文中最进阶的一项,也最能体现了解 SPL 的价值:如果改用数组,排序调用往往会散落在代码各处,反复执行,而且很容易恰好在关键位置漏掉一次。

快速判断该选择哪一种结构 ​

如果上面的结构容易混淆,通常只需围绕数据的访问方式作出判断:

  • 元素只从端点加入和移除?如果加入和移除发生在同一端,就是栈(SplStack);如果从一端加入、从另一端取出,就是队列(SplQueue)。
  • 始终需要优先取得最重要的元素?使用优先队列(SplPriorityQueue);如果只是希望元素按顺序逐个取出,则使用堆(SplHeap、SplMinHeap、SplMaxHeap)。
  • 需要以对象为键关联数据,或维护一个对象集合?使用 SplObjectStorage,它没有真正等价的数组实现。
  • 大小固定、使用整数索引,并希望数据结构对此施加约束?使用 SplFixedArray。
  • 需要跨函数边界共享和修改集合?使用 ArrayObject,它与其他对象一样通过句柄传递。
  • 需要自定义“下一个处理谁”的顺序?继承 SplHeap 并实现自己的 compare 方法。

如果这些情况都不符合,普通数组仍然是正确的选择,而大多数时候正是如此。真正需要掌握的是辨认出数组不适合的那些场景。

贯穿这十项内容的共同价值 ​

回顾这些内容,会发现它们几乎都不以速度为核心。SplFixedArray 可以节省少量内存,SplQueue 可以避免重新建立索引的开销,堆可以省去排序操作。但反复出现的主要收益,是清晰性和正确性。真正的栈不会被意外地通过索引访问,真正的队列一看就知道是队列,优先队列将“最紧急的优先”变成了保障,而不再是需要手动维护的逻辑,对象存储则实现了数组本身无法做到的事情。数据结构承载了意图,代码也就不再要求读者自行还原设计。

值得养成的习惯,不是背下 SPL 的类名列表,而是在合适的时刻意识到它们的存在。下次使用数组实现栈、队列、集合或优先级列表,并且需要添加注释或辅助函数才能说清用途时,不妨留意一下:PHP 或许早已提供了正在手工构建的结构。使用这些结构,通常意味着更少的代码和更明确的意图,也意味着后来维护代码的人少了一种误用的可能。对于确实适合数组的那 90% 的场景,数组始终可用。这里介绍的十项内容,是为另外那 10% 的场景准备的;它们一直存在于语言之中,只待开发者不再勉强让数组承担专用结构的工作。

在知道它们存在之前,你曾用数组手工实现过其中哪些结构?优先队列通常最让人惊讶:原来 PHP 早就内置了它。

本作品采用《CC 协议》,转载必须注明作者和本文链接