前言
explode()和preg_split()默认都会把分隔符丢掉。这在大多数场景下没问题,但有几类需求偏偏要留着它:给搜索结果里的关键词做高亮、写一个简单的表达式求值器需要拿到运算符、统计用哪种分隔符分隔了多少次、把切开的片段重新无损拼回去。这时候explode()完全无能为力,而preg_split()可以,前提是你得加上那个经常被忘记的常量。
最常见的症状是这样的:开发者知道「正则里加括号就是捕获」,于是写了preg_split('/(,)/', $s),跑出来一看,逗号还是没了。或者更曲折一点:模式里写的是非捕获组(?:,),即使加了PREG_SPLIT_DELIM_CAPTURE也没有效果,因为非捕获组根本不产生捕获结果。还有一种「看起来是对的但结果错位」的情况:多个候选分隔符各带一个捕获组,数组里突然多出几个空字符串元素,遍历时索引全乱。
本文讲清preg_split()保留分隔符的完整条件、三种实现思路的差异,以及那些会让人查半天的细节:字节偏移、limit参数取 0 的含义、正则写错时返回false而不是抛异常。
一、先看签名和三个常量
preg_split()的签名是:
preg_split(string $pattern, string $subject, int $limit = -1, int $flags = 0): array|false其中$flags是三个常量按位或的组合。三者都和「保不保留分隔符」这件事有关,但作用完全不同。
| 常量 | 作用 | 关键注意点 |
|---|---|---|
PREG_SPLIT_NO_EMPTY | 丢弃切出来的空字符串元素 | 只对切分结果生效,不影响分隔符捕获 |
PREG_SPLIT_DELIM_CAPTURE | 把分隔模式中捕获组匹配到的内容也作为独立元素返回 | 必须配合捕获组,单个条件满足没用 |
PREG_SPLIT_OFFSET_CAPTURE | 每个元素变成[子串, 字节偏移]的两元素数组 | 偏移是字节偏移,不是字符偏移 |
关于$limit,文档里写得很清楚但很容易记反:-1、0和null都表示不限制。也就是说limit = 0不是「返回 0 个元素」,而是「全都要」。如果想要「只切一刀」,正确的值是1。
二、保留分隔符的两种正则思路
思路 A:捕获组 +PREG_SPLIT_DELIM_CAPTURE。分隔符成为独立元素,元素总数是「片段数 × 2 - 1」这个规律在只有一个捕获组时成立。
<?php $s = 'name=php;age=13'; // 逗号、分号、等号都当作分隔符,且要保留 $parts = preg_split('/([=;])/', $s, -1, PREG_SPLIT_DELIM_CAPTURE); // ['name', '=', 'php', ';', 'age', '=', '13'] var_dump($parts); var_dump(implode('', $parts) === $s); // true,可无损还原关键点是两个条件同时成立:模式里必须有捕获组,$flags里必须有PREG_SPLIT_DELIM_CAPTURE。少任何一个,分隔符都不出现。
思路 B:零宽断言,让分隔符黏在相邻片段上。不用常量,用前瞻或后顾把一个「空位置」变成切分点,分隔符就留在前一段或后一段里。
<?php // 前瞻:在分隔符之前切,分隔符黏在右边 var_dump(preg_split('/(?=,)/', 'a,b,c')); // ['a', ',b', ',c'] // 后顾:在分隔符之后切,分隔符黏在左边 var_dump(preg_split('/(?<=,)/', 'a,b,c')); // ['a,', 'b,', 'c']这两种断言的尖括号形式属于 PCRE 的固定语法,写起来比捕获组长,但好处是不需要额外常量,而且零宽匹配不会往结果里塞空元素。
思路 C:preg_match_all()拿全部片段和偏移。当你既要知道「切出了什么」又要知道「它在原文的哪个位置」时,preg_match_all()比preg_split()更直接。
| 方案 | 结果结构 | 需不需要标志位 | 适合场景 |
|---|---|---|---|
| A 捕获组 + DELIM_CAPTURE | 片段与分隔符交替出现 | 需要 | 高亮、还原、表达式分词 |
| B 零宽断言 | 分隔符附着在相邻片段 | 不需要 | 只想让分隔符不丢,不关心它独立成项 |
Cpreg_match_all | 每次匹配的完整信息 | 不需要 | 需要偏移、需要保留原始下标 |
三、偏移、组合标志与还原
三种标志可以叠加。最常用的是「保留分隔符 + 记录偏移」:
<?php $s = 'a,b'; $parts = preg_split('/(,)/', $s, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_OFFSET_CAPTURE); // [['a', 0], [',', 1], ['b', 2]] var_dump($parts);这里的偏移是字节偏移。'中文,文'这种字符串里,逗号的字节偏移是 6,而它的字符序号是 2。拿字节偏移去调mb_substr()会直接切错位置,必须用substr()(它也是字节语义),或者先用mb_strlen()做一次换算。这是PREG_SPLIT_OFFSET_CAPTURE最典型的翻车点。
还原也很简单:只要保留的分隔符是完整且按原顺序排列的,implode('', $parts)就等价于原串。这也是一句很强的自检——在写分词器时,先把结果拼回去和原文比一次,能立刻暴露正则写漏的情况。
四、完整可运行示例:一个微型表达式分词器
下面这个脚本需要PHP 8.0 或更高(用到str_contains、构造器属性提升)。它把算术表达式切成 token 序列,保留运算符和括号,并用「拼回去等于原文」来自检正确性。
<?php declare(strict_types=1); final class Tokenizer { /** 捕获组里是全部需要保留的分隔符:四则运算符与括号 */ private const PATTERN = '/([+\-*\/()])/'; /** * @return string[] 分词结果,按原顺序排列 */ public function tokenize(string $expr): array { $parts = preg_split( self::PATTERN, $expr, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY ); if ($parts === false) { throw new RuntimeException('正则表达式编译失败'); } // 去掉片段两侧的空白,再丢弃纯空白项 $tokens = []; foreach ($parts as $part) { $part = trim($part); if ($part !== '') { $tokens[] = $part; } } return $tokens; } } $expr = '12 + 3 * (num - 4) / 2'; $tokenizer = new Tokenizer(); $tokens = $tokenizer->tokenize($expr); echo "原文: {$expr}\n"; echo "token 数: ", count($tokens), "\n"; foreach ($tokens as $i => $token) { printf(" [%d] %s\n", $i, $token); } // 自检:把 token 之间的空白按原样补回,观察能否还原 $compact = preg_replace('/\s+/', '', $expr); $rebuilt = implode('', $tokens); echo "\n去空白后是否与拼接结果一致: ", $rebuilt === $compact ? "是" : "否", "\n"; if ($rebuilt !== $compact) { throw new RuntimeException("分词结果无法还原: {$rebuilt}"); }运行后可以看到+、-、*、/、(、)全部作为独立 token 出现在数组里,而不是被吞掉。这正是做表达式解析、语法高亮、模板引擎时想要的结果。
再补一个「统计分隔符用量」的小例子,说明捕获组方式在统计场景下也顺手:
<?php $csv = "php,python;ruby,go;rust"; $parts = preg_split('/([,;])/', $csv, -1, PREG_SPLIT_DELIM_CAPTURE); $counts = [',' => 0, ';' => 0]; foreach ($parts as $part) { if (isset($counts[$part])) { $counts[$part]++; } } print_r($counts); // [',' => 2, ';' => 2]常见坑点
- ❌
preg_split('/(,)/', $s)以为加了括号就会保留 ✅ 补上PREG_SPLIT_DELIM_CAPTURE
括号只负责「捕获」,要不要把捕获结果放进返回值,由标志位决定。
- ❌ 模式里写非捕获组
(?:,),同时加了PREG_SPLIT_DELIM_CAPTURE✅ 换成捕获组(,)
非捕获组不产生捕获结果,标志位再对也没东西可放。
- ❌ 以为
limit = 0表示「返回 0 个元素」 ✅ 想要只切一刀就用limit = 1
-1、0、null三种值都表示不限制。
- ❌ 用
PREG_SPLIT_OFFSET_CAPTURE拿到的偏移去调mb_substr()✅ 那是字节偏移,配substr()或先换算
中文内容上这个错误会稳定出现,且偏移看起来「差不多对」,很难一眼看穿。
- ❌ 直接
foreach遍历preg_split()的返回值 ✅ 先判断是否=== false
正则写错或语法不支持时返回false并发出警告,对false做foreach会在 PHP 8 里直接报错。
- ❌ 分隔符忘了转义,写成
'/./'✅ 写'/\./'
点号是通配符,结果是每个字符都成了分隔符,输出一堆空串。
- ❌ 多个候选分隔符各写一个捕获组,如把逗号和分号分成两个分支各带括号 ✅ 合并成一个字符类
([,;])
多捕获组会产生空字符串元素,破坏「片段与分隔符交替」的规律,遍历时索引全乱。
- ❌ 在循环里反复对同一长字符串调
preg_split()只为取第一段 ✅ 用explode()配limit,或加PREG_SPLIT_NO_EMPTY
切分产生的数组是整个字符串的副本,长文本上代价不小。
总结
| 需求 | 写法 | 结果 |
|---|---|---|
| 分隔符独立成元素 | 捕获组 +PREG_SPLIT_DELIM_CAPTURE | 片段与分隔符交替出现 |
| 分隔符黏在左片段 | PREG_SPLIT配后顾断言 | a,/b,/c |
| 分隔符黏在右片段 | PREG_SPLIT配前瞻断言 | a/,b/,c |
| 需要位置信息 | 叠加PREG_SPLIT_OFFSET_CAPTURE | 每项为子串与字节偏移 |
| 丢弃空片段 | 叠加PREG_SPLIT_NO_EMPTY | 空项被过滤 |
| 只想切一刀 | limit = 1 | 后半段整体保留 |
保留分隔符的核心就一句话:捕获组负责「抓到」,PREG_SPLIT_DELIM_CAPTURE负责「放进结果」,两者缺一不可。理解这一点之后,剩下要小心的就是偏移的字节语义、limit取值的反直觉规则,以及正则编译失败时返回false这个必须显式判断的返回值。