【仓颉语言入门 · 第二篇 第 1 课】

【仓颉语言入门 · 第二篇 第 1 课】正则表达式:一行规则,管住千行文本(std.regex)

第一篇 30 课收官时,我们的工具已经能读写文件、处理 JSON、走网络、开线程了。但从这一课起要面对一个更"脏"的现实:文本不会乖乖排好队等你。日志一行几百字,你只要时间戳;网页一页几千字,你只要链接;用户表单里混着中文英文数字,你要挑出手机号。靠 indexOf 和 split 一层层切,代码会越写越长还容易错——这一课的正则表达式,就是"用一行规则描述一类文本"的能力。仓颉把完整的正则引擎(PCRE2)内置在标准库 std.regex 里,开箱即用,不需要 stdx。本课带你从语法速成一路写到实战:解析日志、提取 IP、给手机号脱敏。

本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(第二篇导航)

第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 17 课、4 个模块:

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~10 std.database.sql 入门与 CRUD、预编译与事务、手写 DAO、综合小实战
三、进阶硬核篇 11~14 反射、宏与 deriving、C 互操作 FFI、并发容器与对象池
  1. 正则表达式(本文)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:std.database.sql 与 CRUD
  8. 预编译、事务与防注入
  9. 手写 DAO 数据访问层
  10. 数据库综合小实战
  11. 反射:在运行时打量类型
  12. 宏与 deriving:让编译器帮你写代码
  13. 与 C 互操作:FFI
  14. 并发容器与对象池
  15. 实战三:HTTP 服务端
  16. 实战四:多线程日志分析器
  17. 实战五:简易爬虫

一、为什么需要正则

先感受一下"没有正则"的窘境。假设日志里有一行:

2026-10-09 08:31:02 [ERROR] db - 连接超时: 192.168.1.10:5432

现在要提取其中的 IPv4 地址。用第一篇的字符串工具,你得先找 : 再切、处理后面跟着端口的情况、还要判断"点开的每一段是不是数字、是不是 1 到 3 位"……写十几行,还浑身是特例。

用正则,规则只有一行:

let ipRe = Regex("\\d{1,3}(\\.\\d{1,3}){3}")

它读作:"1 到 3 位数字,后面跟着 1 到 3 组『一个点 + 1 到 3 位数字』"——这正是一个 IPv4 的形状。引擎拿着这条规则去文本里扫,凡是长得像 IP 的片段都会被交还给你。

一句话理解正则:普通字符串 "abc" 只能匹配文本里的 abc 三个字符;正则字符串描述的是一类文本的形状,比如"四个数字"、"字母后跟数字"。学正则就是学这套"形状描述语言"。


二、准备:直接 import 就能用

std.regex 属于核心标准库 std(和 std.fs、std.collection 一样),随 SDK 安装、开箱即用,不需要像第 23 课的 stdx 那样下载解压、配置 path-option。任何 cjpm 工程里写上:

import std.regex.*

就可以开始。本课的所有示例都在下面这个最普通的工程里完成:

s2probe/            ← 工程根目录(cjpm init --name s2probe)
├── cjpm.toml
├── data/
│   └── app.log     ← 第九节实战用的数据文件
└── src/
    └── main.cj     ← 所有代码都在这里

小提示:正则写在仓颉字符串里要过两道转义。正则里的 \d(表示数字),在仓颉双引号字符串里要写成 "\\d"——第一道转义发生在字符串字面量,第二道才轮到正则引擎。本课所有示例都遵守这个写法,你照抄即可。


三、第一行正则代码:Regex 与 find

仓颉的正则 API 围绕三个类型展开:Regex(编译好的规则)、MatchData(一次匹配的结果)、RegexFlag(匹配选项)。先看最小可用例子:

import std.regex.*

main(): Int64 {
    let r = Regex("([a-z]+)([0-9]+)")
    let md = r.find("abc123 def456")
    match (md) {
        case Some(d) => println("找到:${d.matchString()}")
        case None => println("没找到")
    }
    0
}
找到:abc123

逐行拆解:

  • Regex("([a-z]+)([0-9]+)"):把规则编译成 Regex 对象。规则本身是"字母串后跟数字串",两对小括号是两个捕获组(第五节细讲)。编译是有成本的,所以和 JsonValue 一样——构造一次,反复使用,别在循环里反复 Regex(...)。
  • r.find(文本):拿着规则去文本里找第一处匹配。返回值是 Option<MatchData>——找到包成 Some,找不到就是 None。这和第 23 课 JsonObject.get() 的设计哲学一模一样:"可能没有"用 Option 显式表达,逼你处理。
  • d.matchString():这次匹配整体匹配到的文本。

find 只找第一处。想找全部,用 findAll(返回 Array<MatchData>):

import std.regex.*

main(): Int64 {
    let r = Regex("([a-z]+)([0-9]+)")
    for (d in r.findAll("abc123 def456 x9")) {
        println("${d.matchString()}")
    }
    0
}
abc123
def456
x9

到这里,正则的最小闭环已经通了:写规则 → 编译 → find/findAll → 拿 MatchData。接下来两节,一节学"规则怎么写",一节学"结果怎么取"。


四、正则语法速成

正则的语法元素不多,分四类记:字符写法、重复次数、位置锚点、分组逻辑。下面每条都有实测。

4.1 字符写法:描述"一个字符"

写法 含义 示例
abc 字面量,只匹配 a、b、c 依次出现 "仓颉" 匹配文本中的"仓颉"二字
[abc] 字符组:a、b、c 中任意一个 [aeiou] 匹配任一元音字母
[a-z0-9] 范围:小写字母或数字
[^abc] 反向组:除了 a、b、c 以外的字符
. 任意一个字符(换行符除外)
\d 一个数字,等价 [0-9]
\w 一个"单词字符"(字母数字下划线)
\s 一个空白字符(空格、制表符等)

中文怎么办?直接写在规则里就行——正则引擎按 Unicode 处理文本:

import std.regex.*

main(): Int64 {
    match (Regex("仓颉").find("你好仓颉语言")) {
        case Some(d) => println("匹配到:${d.matchString()}")
        case None => println("没找到")
    }
    0
}
匹配到:仓颉

如果想用"Unicode 码点范围"描述一段汉字(如所有基本区汉字 U+4E00~U+9FA5),要写成 \u{4e00} 的花括号形式——注意这是仓颉字符串字面量的转义规则,\u4e00(不带花括号)会直接编译报错:

import std.regex.*

main(): Int64 {
    let han = Regex("[\u{4e00}-\u{9fa5}]+")
    match (han.find("电话:138")) {
        case Some(d) => println("汉字串:${d.matchString()}")
        case None => println("没找到")
    }
    0
}
汉字串:电话

4.2 重复次数:量词

写法 含义
* 出现 0 次或多次
+ 出现 1 次或多次
? 出现 0 次或 1 次
{n} 恰好 n 次
{n,m} 至少 n 次、至多 m 次
{n,} 至少 n 次

量词默认贪婪:能吃多长吃多长。在量词后加 ? 变非贪婪:能吃多短吃多短。经典对比——从 "<a><b>" 里取标签:

import std.regex.*

main(): Int64 {
    let greedy = Regex("<.*>")
    let lazy = Regex("<.*?>")
    match (greedy.find("<a><b>")) {
        case Some(d) => println("贪婪:${d.matchString()}")
        case None => println("没找到")
    }
    match (lazy.find("<a><b>")) {
        case Some(d) => println("非贪婪:${d.matchString()}")
        case None => println("没找到")
    }
    0
}
贪婪:<a><b>
非贪婪:<a>

贪婪版从第一个 < 一直吃到串尾最后一个 >;非贪婪版碰到第一个 > 就收手。提取 HTML/JSON 片段类任务,九成要用非贪婪。

4.3 位置锚点:不"吃"字符,只卡位置

写法 含义
^ 串的开头
$ 串的结尾
\b 单词边界(字母数字与其它字符的分界)

锚点只声明"必须在哪",本身不消耗字符。^ 和 $ 组合是全串校验的标准姿势:

import std.regex.*

// 校验 s 是否为一个合法形状的手机号:1 开头 + 恰好 10 个数字
func isPhone(s: String): Bool {
    Regex("^1\\d{10}$").matches(s)
}

main(): Int64 {
    println(isPhone("13812345678"))
    println(isPhone("23812345678"))   // 2 开头,不是 1
    println(isPhone("1381234567"))    // 少一位
    0
}
true
false
false

\b 解决"别把单词的一部分也算进来"的问题——找独立的 cat,别要 catalog 里的:

import std.regex.*

main(): Int64 {
    let r = Regex("\\bcat\\b")
    println("边界版命中:${r.findAll("cat catalog concat cat").size}")
    0
}
边界版命中:2

4.4 分组与逻辑:括号的三个用法

① 捕获组 ( ):把一段规则圈起来,匹配后可以单独取出来(第五节)。

② 非捕获组 (?: ):只圈逻辑、不占编号。比如"ab 出现一两次,后跟数字":

import std.regex.*

main(): Int64 {
    let r = Regex("(?:ab)+(\\d+)")
    match (r.find("ababab12", group: true)) {
        case Some(d) => println("数字部分:${d.matchString(1)}")
        case None => println("没找到")
    }
    0
}
数字部分:12

(?:ab)+ 圈了重复逻辑但不占编号,所以"数字部分"是 1 号组。把 (?: 写成 (,数字就会变成 2 号组——不需要取出来的部分,尽量用非捕获组,编号稳。

③ 或 |:两者取一。cat|dog 匹配 cat 或 dog;配合分组 (cat|dog) food 匹配"cat food"和"dog food"。

4.5 进阶一笔带过

本课是入门,还有两类常用语法只提个脸:命名捕获组 (?<名字>...)(第五节实战);前瞻断言 (?=...)——"后面必须跟着某物,但别把它吃进来",例如 Regex("\\d+(?= 元)") 在 "100 元" 里只取 100 不带"元"。二者底层是 PCRE2 引擎,能力完整,需要时再深入。


五、查找三入口与 group 开关

Regex 的查找方法一共三个半,先列清单再讲那个最容易踩的坑。

5.1 三个入口

方法 返回 语义
matches(input) Bool 文本里存在匹配吗?
find(input) Option<MatchData> 找第一处
findAll(input) Array<MatchData> 找所有处(一次性)
lazyFindAll(input) Iterator<MatchData> 找所有处(惰性,边找边给)

先说一个容易误会的地方:matches 不是"整串必须完全匹配"——只要文本里任何位置存在匹配就返回 true:

import std.regex.*

main(): Int64 {
    let r = Regex("\\d{2}")
    println("存在两位数字吗(ab12cd):${r.matches("ab12cd")}")
    println("存在两位数字吗(abcd):${r.matches("abcd")}")
    0
}
存在两位数字吗(ab12cd):true
存在两位数字吗(abcd):false

Java 背景的同学注意:仓颉的 matches 语义相当于 Java 的 find().isSome(),不是 matches()。想表达"整串必须如此",用第 4.3 节的 ^...$ 锚点把规则钉死在两端。

lazyFindAll 返回 Iterator<MatchData>,for-in 直接用。文本巨大、匹配项很多时,它不用一次性把所有结果装进内存:

import std.regex.*

main(): Int64 {
    let r = Regex("\\d+")
    for (d in r.lazyFindAll("a1 bb22 ccc333")) {
        println("惰性拿到:${d.matchString()}")
    }
    0
}
惰性拿到:1
惰性拿到:22
惰性拿到:333

5.2 大坑预警:捕获组默认是关着的

find / findAll 都有一个可选命名参数 group,默认 false——引擎只找整体匹配,不解析捕获组。这时你去 matchString(1),编译能过、运行才炸:

import std.regex.*

main(): Int64 {
    let r = Regex("([a-z]+)([0-9]+)")
    let md = r.find("abc123")            // ← 没写 group: true
    match (md) {
        case Some(d) =>
            println("整体:${d.matchString()}")
            println("1号组:${d.matchString(1)}")   // ← 运行时在这里抛异常
        case None => println("没找到")
    }
    0
}
An exception has occurred:
IllegalArgumentException: Capture group not enabled.

修法:要用捕获组,一律加 group: true:

let md = r.find("abc123", group: true)

这是本课第一坑,后面第六节的所有取组代码都带着它。

顺带一提:你在 SDK 文档或旧资料里可能看到 r.matcher(输入) 这种老式写法(类似 Java 的 Matcher)。在 1.2.0 里它已被标记废弃,编译时会有 warning: function 'matcher' is deprecated 警告,而且新 API(find/findAll)用起来更直接——直接用新 API,别碰它。


六、捕获组:编号组与命名组

6.1 编号组:按 1 开始数

( 从左到右按开括号出现的顺序编号:1 号、2 号……整体匹配相当于 0 号。MatchData 提供三组方法:

方法 含义
matchString() / matchString(0) 整体匹配文本
matchString(i) 第 i 号组匹配的文本;该组未参与匹配时是空串
matchPosition() / matchPosition(i) 匹配发生的位置,返回 Position(有 start、end 字段,end 不含在范围内)
groupCount() 捕获组个数(不含整体)
import std.regex.*

main(): Int64 {
    let r = Regex("([a-z]+)([0-9]+)")
    let md = r.find("abc123 def456", group: true)
    match (md) {
        case Some(d) =>
            println("整体:${d.matchString()}")
            println("字母组:${d.matchString(1)}")
            println("数字组:${d.matchString(2)}")
            println("组数:${d.groupCount()}")
            let p = d.matchPosition()
            println("整体位置:start=${p.start} end=${p.end}")
            let p1 = d.matchPosition(1)
            println("字母组位置:start=${p1.start} end=${p1.end}")
        case None => println("没找到")
    }
    0
}
整体:abc123
字母组:abc
数字组:123
组数:2
整体位置:start=0 end=6
字母组位置:start=0 end=3

位置是左闭右开:整体 0..6 恰好是 "abc123" 这 6 个字符;字母组 0..3 是 "abc"。要用位置从原文本里"抠"内容时,end 不用再减一。

还有一个实测的细节:可选组没参与匹配时,取出来是空串(不是异常)。规则 "(a)|(b)" 去匹配 "xbx",命中的是 b,1 号组(a)没参与:

import std.regex.*

main(): Int64 {
    let r = Regex("(a)|(b)")
    match (r.find("xbx", group: true)) {
        case Some(d) =>
            println("整体:${d.matchString()}")
            println("a组:[${d.matchString(1)}]")
            println("b组:[${d.matchString(2)}]")
        case None => println("没找到")
    }
    0
}
整体:b
a组:[]
b组:[b]

6.2 命名组:给组起名字,别靠数括号

组一多,matchString(3) 这种代码自己都读不懂。给组起名字:(?<名字>规则),然后用名字取值:

import std.regex.*

main(): Int64 {
    let r = Regex("(?<year>\\d{4})-(?<month>\\d{2})")
    match (r.find("date: 2026-10-09", group: true)) {
        case Some(d) =>
            let y = d.matchString("year")
            let mo = d.matchString("month")
            println("年:${y}  月:${mo}")
        case None => println("没找到")
    }
    0
}
年:2026  月:10

小提示:插值字符串里不能写 \"("${d.matchString(\"year\")}" 会报 unterminated string interpolation)。像上面这样先把值取到 let 里,再插值——这也是更通用的写法。

Regex 对象上还有个 getNamedGroups(),返回"组名 → 编号"的映射:

import std.regex.*

main(): Int64 {
    let r = Regex("(?<year>\\d{4})-(?<month>\\d{2})")
    for ((name, idx) in r.getNamedGroups()) {
        println("${name} 是 ${idx} 号组")
    }
    0
}
month 是 2 号组
year 是 1 号组

注意打印顺序不保证(底层是哈希结构),别依赖它按定义顺序输出。


七、替换与拆分:replace、replaceAll、split

7.1 替换:先泼一盆冷水

replace 换第一处,replaceAll 换所有处,都返回新字符串(原串不动):

import std.regex.*

main(): Int64 {
    let r = Regex("o")
    println("replace:${r.replace("foo boo", "0")}")
    println("replaceAll:${r.replaceAll("foo boo", "0")}")
    0
}
replace:f0o boo
replaceAll:f00 b00

两家的第三参 limit 是"最多替换几次"——而且要小心 limit: 0 表示一次都不换(不是"不限次数"):

import std.regex.*

main(): Int64 {
    let r = Regex("o")
    println("换 1 次:${r.replaceAll("foo boo", "0", 1)}")
    println("换 0 次:${r.replaceAll("foo boo", "0", 0)}")
    0
}
换 1 次:f0o boo
换 0 次:foo boo

冷水在这儿:很多语言的替换串里写 $1 能引用 1 号捕获组的内容,仓颉 1.2.0 不支持——替换串就是纯字面量,写什么原样输出什么(实测 $1、\1、${1}、$& 全部原样):

import std.regex.*

main(): Int64 {
    let r = Regex("([a-z]+)([0-9]+)")
    println("${r.replace("abc123", "$2-$1")}")
    println("${r.replace("abc123", "\\2-\\1")}")
    0
}
$2-$1
\2-\1

想要"引用捕获组"的替换效果怎么办? 自己组装:先 find(..., group: true) 把组取出来,拼好新串,再用普通 replace 换掉——第九节实战的手机号脱敏就是这个套路,记住它,几乎所有"带引用的替换"都能这么写。

7.2 拆分:split

用规则当分隔符拆字符串,比第 6 课的 split(只认固定子串)灵活得多:

import std.regex.*

main(): Int64 {
    let parts = Regex("[:,;]").split("a:b,c;d")
    for (p in parts) {
        println("[${p}]")
    }
    0
}
[a]
[b]
[c]
[d]

第二个参数 limit 是"最多拆成几段"(其余内容原样留给最后一段):

import std.regex.*

main(): Int64 {
    let parts = Regex(",").split("a,b,c,d", 2)
    println("段数:${parts.size}")
    println("最后一段:${parts[1]}")
    0
}
段数:2
最后一段:b,c,d

八、匹配选项:RegexFlag

Regex 构造的第二个参数是选项数组,常用的两个:

选项 作用
RegexFlag.IgnoreCase 忽略大小写
RegexFlag.MultiLine 多行模式:^ 和 $ 从"整串的开头/结尾"变成"每一行的开头/结尾"
import std.regex.*

main(): Int64 {
    let strict = Regex("hello")
    println("区分大小写,能找到吗:${strict.find("HELLO world").isSome()}")

    let loose = Regex("hello", [RegexFlag.IgnoreCase])
    match (loose.find("HELLO world")) {
        case Some(d) => println("忽略大小写,找到:${d.matchString()}")
        case None => println("没找到")
    }
    0
}
区分大小写,能找到吗:false
忽略大小写,找到:HELLO

多行模式用一个真实场景感受:三行文本里找"每行行首的单词"。默认 ^ 只钉在串首,只命中 1 处;加上 MultiLine,三行行首各命中一处:

import std.regex.*

main(): Int64 {
    let text = "first line\nsecond line\nthird line"
    let plain = Regex("^\\w+")
    let multi = Regex("^\\w+", [RegexFlag.MultiLine])
    println("默认模式命中:${plain.findAll(text).size} 处")
    println("多行模式命中:${multi.findAll(text).size} 处")
    0
}
默认模式命中:1 处
多行模式命中:3 处

小提示:RegexFlag 数组可以放多个:Regex(pattern, [RegexFlag.IgnoreCase, RegexFlag.MultiLine])。另有一个 RegexFlag.Unicode(启用 Unicode 感知),默认的 Unicode 行为对中文已经够用,本课不展开。


九、CIDE 实操:日志巡检小工具

学完语法和 API,把它们串成一个真实的小工具。需求:

  1. 读入 data/app.log(六行服务日志);
  2. 逐行解析出 时间 [级别] 模块 - 消息,把所有 ERROR 行单独摘出来;
  3. 提取日志里出现过的全部 IPv4 地址(去重);
  4. 日志里有用户的手机号,发布日志前要脱敏(保留前 3 后 4,中间 4 位变 *)。

9.1 数据文件

在工程根目录建 data/app.log:

2026-10-09 08:30:15 [INFO] auth - 用户 user01 从 192.168.1.23 登录
2026-10-09 08:31:02 [ERROR] db - 连接超时: 192.168.1.10:5432
2026-10-09 08:31:45 [WARN] auth - 密码重试过多: user07
2026-10-09 08:32:20 [INFO] api - 请求 /api/orders 耗时 42ms
2026-10-09 08:33:05 [ERROR] api - 调用支付网关失败: 10.0.0.8
2026-10-09 08:34:10 [INFO] auth - 用户 13812345678 修改了手机号 13998887766

9.2 完整代码

src/main.cj 全文(文件读取是第 22 课的 File.readFrom 老三样):

package s2probe

import std.regex.*
import std.fs.*
import std.collection.*
import std.unicode.*

main(): Int64 {
    // 1. 读入日志文本(Windows 文件是 \r\n 换行,逐行后 trim 掉行尾的 \r)
    let raw = String.fromUtf8(File.readFrom(Path("data/app.log")))
    let lines = raw.split("\n")
        .map({ l: String => l.trim() })
        .filter({ l: String => l != "" })
        .toArray()

    println("共读到 ${lines.size} 条日志")
    println("")

    // 2. 逐行解析:时间 级别 模块 - 消息
    let lineRe = Regex("^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) \\[(\\w+)\\] (\\w+) - (.+)$")
    let errorRe = Regex("\\[(\\w+)\\] (\\w+)")

    var errorCount = 0
    for (line in lines) {
        match (errorRe.find(line, group: true)) {
            case Some(d) =>
                let level = d.matchString(1)
                let module = d.matchString(2)
                if (level == "ERROR") {
                    errorCount++
                    let msg = lineRe.find(line, group: true)
                    match (msg) {
                        case Some(m) =>
                            let t = m.matchString(1)
                            let body = m.matchString(4)
                            println("ERROR #{${errorCount}} [${t}] ${module}: ${body}")
                        case None => println("ERROR #{${errorCount}}(无法解析)")
                    }
                }
            case None => println("格式不认识的行:${line}")
        }
    }
    println("")
    println("ERROR 总数:${errorCount}")
    println("")

    // 3. 提取全部 IPv4 地址并去重(ArrayList 保持出现顺序)
    let ipRe = Regex("\\d{1,3}(\\.\\d{1,3}){3}")
    let ips = ArrayList<String>()
    for (d in ipRe.findAll(raw)) {
        let ip = d.matchString()
        if (!ips.contains(ip)) {
            ips.add(ip)
        }
    }
    print("出现过的 IP(${ips.size} 个):")
    for (ip in ips) { print("${ip} ") }
    println("")

    // 4. 手机号脱敏:保留前 3 后 4,中间 4 位换成 ****
    let phoneRe = Regex("(\\d{3})(\\d{4})(\\d{4})")
    for (line in lines) {
        let phones = phoneRe.findAll(line, group: true)
        if (phones.size > 0) {
            var masked = line
            for (p in phones) {
                let full = p.matchString(0)
                let head = p.matchString(1)
                let tail = p.matchString(3)
                masked = masked.replace(full, "${head}****${tail}")
            }
            println("脱敏后:${masked}")
        }
    }

    0
}

9.3 逐段看懂

第 1 步:File.readFrom 读回 UInt8 数组 → String.fromUtf8 转文本(第 22 课原样)。Windows 的换行是 \r\n,按 \n 拆行后每行尾巴挂着一个 \r,所以链上 .map(trim).filter(非空)——这也是本课第一次在链式调用里用 lambda,注意参数要写类型标注 { l: String => ... }(不写会报 parameters of this lambda expression must have type annotations),trim() 来自 std.unicode,import 别漏。

第 2 步:lineRe 是本课规则的集大成者——^ 钉串首、四个捕获组(时间、级别、模块、消息)、\\[\\] 转义方括号、(.+)$ 一路吃到行尾。先用 errorRe 快速判断级别(小正则干小活),确定是 ERROR 才用 lineRe 精拆——先用便宜规则筛,再上贵规则拆,处理大文件时的惯用手法。

第 3 步:IP 正则 \\d{1,3}(\\.\\d{1,3}){3} 就是第一节那条。去重用 ArrayList.contains 而不是 HashSet——HashSet 遍历顺序不保证,我们想让输出保持"日志里出现的顺序"。

第 4 步:手机号规则 "(\\d{3})(\\d{4})(\\d{4})" 把 11 位切成三段,1、3 号组是"要保留的头和尾",2 号组是"要抹掉的中间"。7.1 节说过替换串里不能引用捕获组,所以手动组装:head + "****" + tail 拼出新片段,再用普通 replace 换掉原号——这就是"引用替换"的仓颉标准写法。

9.4 运行与验证

共读到 6 条日志

ERROR #{1} [2026-10-09 08:31:02] db: 连接超时: 192.168.1.10:5432
ERROR #{2} [2026-10-09 08:33:05] api: 调用支付网关失败: 10.0.0.8

ERROR 总数:2

出现过的 IP(3 个):192.168.1.23 192.168.1.10 10.0.0.8
脱敏后:2026-10-09 08:34:10 [INFO] auth - 用户 138****5678 修改了手机号 139****7766

六行日志,四个需求,全部命中。回看这段代码你会发现:一个正则替换掉了过去几十行的 indexOf/split 手工解析——这就是这一课的价值。


十、常用 API 速查

类别 API 说明
构造 Regex(pattern) 编译规则;抛 RegexException
构造 Regex(pattern, [RegexFlag.IgnoreCase, ...]) 带选项
存在 matches(input): Bool 存在匹配即为 true(非全串)
单个 find(input, group: Bool = false): Option<MatchData> 第一处
全部 findAll(input, group: Bool = false): Array<MatchData> 所有处(一次性)
全部 lazyFindAll(input): Iterator<MatchData> 所有处(惰性)
替换 replace(input, repl) / replace(input, repl, limit) 换第一处 / 最多 limit 次
替换 replaceAll(input, repl) / replaceAll(input, repl, limit) 换全部 / 最多 limit 次(limit=0 不换)
拆分 split(input) / split(input, limit) 全拆 / 最多 limit 段
组名 getNamedGroups(): Map<String, Int64> 组名 → 编号
结果 MatchData.matchString() / (i) / ("名") 整体 / 编号组 / 命名组
结果 MatchData.matchPosition() / (i) → Position start/end(end 不含)
结果 MatchData.groupCount(): Int64 组数(不含整体)
选项 RegexFlag.IgnoreCase / MultiLine / Unicode 忽略大小写 / 多行 / Unicode

Regex 还有 matcher(input) 等旧式 API,1.2.0 已废弃,请一律使用上表中的新 API。


十一、常见问题 FAQ

Q1:matches 为什么不是"整串完全匹配"?我想校验手机号怎么办?
matches 的语义是"文本中存在匹配",是存在性判断。全串校验用锚点把规则钉死两端:Regex("^1\\d{10}$").matches(s)(见 4.3)。这也是本课练习 2 的写法。

Q2:matchString(1) 抛 IllegalArgumentException: Capture group not enabled?
find/findAll 默认不开捕获组(group 参数默认 false)。要用组,调用时加 group: true。注意这个错运行时才炸,编译器不拦——写组操作前先检查自己的 find 调用。

Q3:替换串里的 $1 为什么原样输出了?
仓颉 1.2.0 的替换串是纯字面量,不支持 $1/\1 这类捕获组引用。替代写法:find(..., group: true) 取出各组,拼好新串再普通 replace(见 7.1 与实战第 4 步)。

Q4:正则写错了报什么错?怎么看?
编译期抛 RegexException,消息里带出错位置和原因,如实转贴一条实测报错——规则 ([a-z 少了右括号:

RegexException: Compilation for pattern `([a-z` failed at offset 5: missing terminating ] for character class.

offset 5 直接指向出错的位置,后半句告诉你缺什么:规则 ([a-z 写到 z 就断了,还差一个 ]。构造 Regex 放在 try 里能捕获它。

Q5:中文字符怎么写进正则?
两种都实测可用:直接写字面量 "仓颉";或者用码点范围 "[\\u{4e00}-\\u{9fa5}]"(注意仓颉字符串的 Unicode 转义必须带花括号,\\u4e00 会报 expected '{' in unicode escape)。

Q6:matcher() 方法上有废弃警告,还能用吗?
能编译能跑,但 1.2.0 已标记 deprecated(编译警告 function 'matcher' is deprecated),且新 API 更简洁。新代码一律用 find/findAll/matches。

Q7:大文件全量 findAll 会很占内存吗?
结果多时改用 lazyFindAll,它返回 Iterator<MatchData>,边扫边给,配合 for-in 逐个处理(见 5.1)。

Q8:matchPosition 的 end 算不算在匹配范围内?
不算。区间是左闭右开:整体匹配 "abc123" 在原串中的位置是 start=0 end=6,6 是"下一个字符"的下标。按位置截取时直接 [start..end],不用减一。


十二、课后练习

练习原则照旧:每题只考本课知识点,题面给全提示,先在纸上写出规则再动手。前 4 题必做,第 5 题选做。

1.(必做)提取所有整数。 给定文本 "买了3个苹果花了12元,又买了2斤香蕉花8元",用 findAll 提取其中所有整数并空格分隔打印。提示:规则用 \\d+,import std.regex.*。

func ex1(): Unit {
    let s = "买了3个苹果花了12元,又买了2斤香蕉花8元"
    let r = Regex("\\d+")
    for (d in r.findAll(s)) {
        print("${d.matchString()} ")
    }
    println("")
}

期望输出:

3 12 2 8

2.(必做)手机号校验函数。 实现 func isPhone(s: String): Bool:s 是"1 开头、共 11 位数字"时返回 true,否则 false。用 matches + ^...$ 锚点(参考 4.3 节)。依次校验 "13812345678"、"23812345678"、"1381234567"。

期望输出(三行):

true
false
false

3.(必做)命名组解析日期。 文本 "今天是 2026-10-09,天气不错"。写出带三个命名组 y、m、d 的日期规则,find(..., group: true) 后按名取值,打印 年=2026 月=10 日=09。提示:取值先放 let 再插值;没匹配到时打印"没找到日期"。

期望输出:

年=2026 月=10 日=09

4.(必做)身份证号脱敏。 文本 "张三的身份证号是 110101199001011234,请妥善保管"。用分组把 18 位号码拆成"前 6 位 + 中间 8 位 + 后 4 位",中间 8 位替换成 8 个 *(回忆 7.1:替换串不能引用组,要手动拼接后用普通 replace 换掉)。

期望输出:

张三的身份证号是 110101********1234,请妥善保管

5.(选做)多行模式数行首单词。 文本 "apple ball\ncat dog\negg fish"(三行)。开启 RegexFlag.MultiLine,用 findAll 找出每一行行首的单词,逐行打印。提示:规则 ^\\w+,参考第八节。

期望输出(三行):

apple
cat
egg

下节预告

正则解决"从文本里找结构",但时间本身就是一个最难缠的文本问题:时区、闰年、格式五花八门。下一课 时间日期处理(std.time) 将带你看仓颉标准库怎么表达一个"时刻":DateTime 的构造与字段、格式化输出、时间戳互转、以及"两个时刻差多少"的计算——最后把本课日志里的时间字符串真正解析成时间对象,让日志巡检工具再进化一步。


系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
  • 打开页面后进入 发行版(Releases),两种包任选其一:
    • 安装版:下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable):下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
posted @ 2026-10-10 00:11  upala  阅读(11)  评论(0)    收藏  举报