前言
array_slice()大概是「看一眼就会、用起来就错」的典型函数。它只有四个参数,但每一个都有正负号、每一个都有边界情况,叠在一起就成了一个小型的状态机。你很可能遇到过下面这些现象:
- 分页列表第一页少了第一条,或者第二页重复了上一页的最后一条;
array_slice($arr, 1, -1)拿到的结果和「去掉首尾」的预期不一致;- 偏移量明明超出了数组长度,函数不报错也不警告,直接返回空数组,于是页面上只是「这一页空白」,排查半天找不到原因;
- 用
array_slice()截出一段之后丢给json_encode(),前端收到的居然是{"1":"b","2":"c"}这样一个对象,而不是数组; - 传了个
'2.5'或者null进去,PHP 8 下冒出一条「隐式转换丢失精度」的废弃提示。
这些问题的共同点是:它们都不是 bug,而是对参数的语义理解有偏差。array_slice()的行为被精确定义过,只是那些定义散落在文档的各个角落。
本文以PHP 8.5为环境,把offset(偏移量)和length(长度)的全部分支一次讲清,给出可直接照抄的边界表格、一个分页器实现,以及九个真会踩的坑。示例代码最低要求PHP 8.0(用到了构造函数属性提升与命名参数),array_slice()本身的语义从 PHP 7 到 8.5 没有任何变化。
一、函数签名与四条基本规则
array_slice( array $array, int $offset, ?int $length = null, bool $preserve_keys = false ): array不用记「偏移量怎么算」这种模糊的说法,直接记四条规则,任何组合都能推出来:
$offset >= 0:从下标$offset开始(数组下标从 0 起)。$offset < 0:从「倒数第|$offset|个元素」开始。注意-0就是0,不是「从末尾数零个」。$offset超出数组范围:返回空数组,不报错、不警告。这是最容易被忽略的一条,也是「沉默的 bug」的来源。$length:省略或传null表示「一直到末尾」;正数表示取这么多个;负数表示「到距离末尾|$length|个元素为止」,也就是把最后|$length|个丢掉。
用$a = ['a', 'b', 'c', 'd', 'e'];(下标 0~4)把常用组合全部列出来:
| offset | length | 结果 | 说明 |
|---|---|---|---|
0 | 省略 | a b c d e | 全量拷贝 |
1 | 省略 | b c d e | 跳过第一个,取到末尾 |
1 | 2 | b c | 从下标 1 起,取 2 个 |
1 | -1 | b c d | 去掉首尾(末尾丢掉 1 个) |
0 | -2 | a b c | 去掉最后 2 个 |
-2 | 省略 | d e | 最后 2 个 |
-2 | 1 | d | 倒数第 2 个 |
-100 | 省略 | a b c d e | 负数超出范围会从 0 开始,不会返回空 |
10 | 省略 | 空数组 | 正向越界返回空,静默 |
1 | -10 | 空数组 | length把区间吃完了 |
请特别注意-100和10这两行:负数偏移量越界会被「夹」到 0,正向偏移量越界直接返回空数组。两者行为不对称,这正是「有时候不报错、有时候拿到全部数据」这类诡异现象的来源。
至于length = 0,结果永远是空数组——它是一个合法的「取零个」。
二、偏移量的计算:和后端分页公式完全一致
业务代码里 90% 的array_slice()都用在分页上。分页的偏移量公式只有一条,和 SQL 的LIMIT是同一套:
offset = (page - 1) * perPage这里最容易错的是「页码从 1 开始、下标从 0 开始」这个错位。写成page * perPage就会永远跳过第一页,而页面不会报任何错——你只会看到「第一页的数据不见了」,然后怀疑数据源。
| 页码(从 1 起) | 每页条数 | 偏移量 | 等价 SQL |
|---|---|---|---|
| 1 | 10 | (1-1)*10 = 0 | LIMIT 0, 10或LIMIT 10 OFFSET 0 |
| 2 | 10 | (2-1)*10 = 10 | LIMIT 10 OFFSET 10 |
| 3 | 10 | (3-1)*10 = 20 | LIMIT 10 OFFSET 20 |
| 5 | 15 | (5-1)*15 = 60 | LIMIT 15 OFFSET 60 |
页码是从 1 开始的(人类习惯),偏移量是从 0 开始的(机器习惯),中间减掉的这个 1 就是全部玄机。把公式封装进函数、永远不手写那个减法,是唯一可靠的防御手段。
顺便把容易和它混淆的array_splice()放在一起对比,因为两者名字只差一个字母:
| 对比项 | array_slice() | array_splice() |
|---|---|---|
| 是否修改原数组 | 否,返回新数组 | 是,按引用修改原数组 |
| 返回值 | 截取出来的那一段 | 被移除的那一段 |
length省略 | 取到末尾 | 删除到末尾 |
| 键的处理 | 可选择保留或重排 | 整数键总是重排 |
| 典型用途 | 分页、取子集 | 删除中间一段、插入替换 |
一句话记忆:slice 是「拿一段出来」,splice 是「剪掉一段」。想删除元素却用了array_slice(),会发现原数组纹丝不动,返回值里反而是你以为删掉的内容。
三、键的命运:字符串键永远保留
$preserve_keys这个参数的名字有误导性。它真正控制的是整数键:
$preserve_keys = false(默认):整数键会被重新编号为0, 1, 2, ...;$preserve_keys = true:整数键保持原样;- 字符串键在任何情况下都被保留,无论这个参数传什么。
<?php declare(strict_types=1); $a = ['x' => 1, 'y' => 2, 'z' => 3]; var_dump(array_slice($a, 1)); // ['y' => 2, 'z' => 3] —— 字符串键被保留了 $b = [0 => 'a', 1 => 'b', 2 => 'c']; print_r(array_slice($b, 1)); // [0 => 'b', 1 => 'c'] —— 整数键被重排为 0,1 print_r(array_slice($b, 1, null, true)); // [1 => 'b', 2 => 'c'] —— 整数键保持原样由此引出前面提到的那个高频坑:preserve_keys = true加上json_encode(),数组会变成对象。
<?php $rows = array_slice(['alice', 'bob', 'carol'], 1, 2, true); echo json_encode($rows), PHP_EOL; // {"1":"bob","2":"carol"} ← 是 JSON 对象! echo json_encode(array_values($rows)), PHP_EOL; // ["bob","carol"] ← 才是 JSON 数组原因是 PHP 的数组在序列化为 JSON 时,会检查「键是否是从 0 开始、连续、整数」。只要有一个键不是,整个数组就退化成 JSON 对象。前端拿到对象之后.map()就报错了,而服务端日志里什么错都没有。
四、完整可运行的分页器
把上面的规则全部封装进函数,业务代码就再也不会碰到这些边界。
<?php declare(strict_types=1); // 最低 PHP 8.0 /** * 一个只读的分页视图:对内存中的数组做切片。 * 适用于「数据量不大、已经在内存里」的场景(读配置、读缓存、合并后的列表)。 */ final class ArrayPaginator { /** @var list<mixed> */ private array $items; public function __construct( array $items, private int $perPage = 10, private int $page = 1, ) { $this->items = array_values($items); // 先正规化,键的问题一次性解决 $this->perPage = max(1, $perPage); // 每页至少 1 条,避免除零 $this->page = max(1, $this->page); // 页码从 1 起 } /** 核心:偏移量只在这里算一次 */ public function offset(): int { return ($this->page - 1) * $this->perPage; } /** @return list<mixed> */ public function items(): array { // 用 array_values 兜底:即使以后有人改了 preserve_keys,输出也是标准列表 return array_values( array_slice($this->items, $this->offset(), $this->perPage) ); } public function total(): int { return count($this->items); } public function totalPages(): int { return (int)ceil($this->total() / $this->perPage); } public function hasPrev(): bool { return $this->page > 1; } public function hasNext(): bool { return $this->page < $this->totalPages(); } /** 把越界页码夹回合法范围,避免出现「空白页」 */ public function clamp(): self { $max = max(1, $this->totalPages()); return new self($this->items, $this->perPage, min($this->page, $max)); } } // ----------------------------------------------------------- 验证 $data = []; foreach (range(1, 23) as $i) { $data[] = ['id' => $i, 'name' => sprintf('user-%02d', $i)]; } $perPage = 10; $totalPages = (new ArrayPaginator($data, $perPage))->totalPages(); foreach (range(1, $totalPages) as $page) { $p = new ArrayPaginator($data, $perPage, $page); $ids = array_column($p->items(), 'id'); printf( "第 %d/%d 页 偏移=%2d 条数=%2d id: %s\n", $p->page, $totalPages, $p->offset(), count($ids), implode(',', $ids) ); } // 边界:页码越界 $over = (new ArrayPaginator($data, $perPage, 99))->clamp(); printf("\n越界页码 99 夹回后: 第 %d 页, 条数 %d\n", $over->page, count($over->items())); // 边界:空数组 $empty = new ArrayPaginator([], $perPage, 1); printf("空数据: 总页数 %d, 当前条数 %d\n", $empty->totalPages(), count($empty->items()));运行输出:
第 1/3 页 偏移= 0 条数=10 id: 1,2,3,4,5,6,7,8,9,10 第 2/3 页 偏移=10 条数=10 id: 11,12,13,14,15,16,17,18,19,20 第 3/3 页 偏移=20 条数= 3 id: 21,22,23 越界页码 99 夹回后: 第 3 页, 条数 3 空数据: 总页数 0, 当前条数 0注意最后一页只有 3 条:array_slice($data, 20, 10)在只剩 3 个元素时不会报错,也不会补齐,它老老实实返回 3 条。这正是我们想要的行为,但如果你按「每页必须 10 条」去写校验逻辑,就会误判。
再看几个只跟「负数切片」有关的例子,它们在与count()配合时最容易算错:
<?php declare(strict_types=1); $log = ['boot', 'connect', 'query', 'cache', 'shutdown']; // 最后 2 条 print_r(array_slice($log, -2)); // 去掉最后 1 条(length 传 -1 比传 count()-1 更安全) print_r(array_slice($log, 0, -1)); // 去掉第一条和最后一条 print_r(array_slice($log, 1, -1)); // 倒数第 2 条,只要 1 条 print_r(array_slice($log, -2, 1));输出:
Array ( [0] => cache [1] => shutdown ) Array ( [0] => boot [1] => connect [2] => query [3] => cache ) Array ( [0] => connect [1] => query [2] => cache ) Array ( [0] => cache )常见坑点
- ❌ 分页偏移量写成
$page * $perPage,第一页被跳过
✅ 永远是($page - 1) * $perPage;把计算收进一个offset()方法,业务代码不手写减法
- ❌ 传
null、''或者'2.5'、1.5这类值当偏移量
✅ PHP 8.1 起「隐式转换丢失精度」会触发Deprecated,null传给int参数同样被废弃;一律在调用前(int)强转,或者先校验
- ❌
array_slice($arr, 1, 2, true)之后直接json_encode(),前端收到对象
✅ 输出给前端的列表一律套一层array_values(),保证键从 0 开始连续
- ❌ 以为
$preserve_keys = false会把字符串键也重排
✅ 字符串键永远保留;重排的只有整数键,想统一成列表就用array_values()
- ❌ 用
array_slice()想「删掉原数组中间一段」
✅ 它不改原数组;要改就用array_splice(),并且记住它的返回值是被删掉的部分
- ❌ 用
array_slice($a, $offset, count($a) - $offset)算长度
✅$offset为负时count() - $offset会算出偏大的值;直接传null或省略length让函数自己算到末尾
- ❌ 不校验偏移量,正向越界时拿到空数组却以为「数据为空」
✅ 正向越界静默返回空数组(负数越界则夹到 0);页码要先clamp到[1, totalPages]
- ❌ 用
array_slice()实现队列的pop或shift
✅ 每次都全量拷贝,复杂度是 O(n);队列场景用array_pop()/array_shift(),或者干脆用SplQueue
- ❌ 用
array_slice()切一段后期待它「重新从 0 开始编号」,但原数组里有字符串键
✅ 混合键的数组切完键是不连续的,需要连续下标就array_values(array_slice(...)),一步到位
总结
| 场景 | 正确写法 | 关键点 |
|---|---|---|
| 分页取第 N 页 | array_slice($a, ($page - 1) * $perPage, $perPage) | 页码 1 基、偏移 0 基 |
| 取末尾 N 个 | array_slice($a, -N) | 负数偏移从末尾数 |
| 去掉末尾 N 个 | array_slice($a, 0, -N) | 负数长度表示「丢末尾」 |
| 去掉首尾 | array_slice($a, 1, -1) | 组合使用 |
| 保证输出是 JSON 数组 | array_values(array_slice(...)) | 键必须从 0 连续 |
| 关联数组保序取子集 | array_slice($a, 1) | 字符串键天然保留 |
| 修改原数组 | array_splice($a, $offset, $length) | 返回值是被移除的元素 |
| 越界保护 | min($page, $totalPages) | 正向越界静默返回空 |
偏移量的计算其实只有一条公式:(页码 - 1) × 每页条数。真正需要额外留意的不是这行算术,而是它周围的三个陷阱——正向越界静默返回空数组、字符串键永远保留、preserve_keys会让 JSON 数组退化成对象。把这个公式连同array_values()一起封进一个小类,让业务代码只需要传页码,这三类问题就都从源头消失了。