qinghuan
← 回到知识探索

后端现场 / 可下载小项目

正则只检查中间一段,为什么锚点和前后文仍会改变结果?

固定 XabcY 中的 abc,用四种边界组合核对锚点、前后视与匹配范围,再检查 reset 和 find(start) 会重置什么。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先圈出 abc,但下标仍来自完整字符串
  4. 锚点开关:区域边缘能不能充当开头和结尾
  5. 透明边界:前后视能不能看见圈外的字符
  6. matches、lookingAt 和 find,问的不是同一件事
  7. reset 会恢复完整区域,find(start) 也会先做重置
  8. 运行完整矩阵,核对 39 项具体条件
  9. 把区域、规则和状态一起记录,才知道实际检查了什么
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
区域锚点
决定选定区域的边缘能否充当某些正则锚点的位置。
透明边界
让前后视等检查能够观察区域外的上下文。
打开完整术语小抄 ↗

输入是 XabcY,程序只想检查中间的 abc。给 Matcher 设置 region(1,4) 后,^abc$ 居然匹配成功;可要求前面有 X、后面有 Y 的规则却失败了。原字符串没变,为什么两种直觉同时落空?

region 限制匹配区域,另有两个开关分别决定区域边缘能否充当锚点、前后视能否看见外面。本页保持字符和规则很小,让每次变化都能与实际输出对照。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。真实Matcher,固定ASCII字符串;每JDK39检查,四种边界组合、三类匹配方法、reset/find(start)、空区域与非法区间;每版重复三次一致。未测任意正则、Unicode/多行、性能或并发。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

区域只圈住 abc,锚点和前后文分别由谁控制?

正在准备实验控件…

Pattern: ^abc$
Input:   XabcY
调用: matches()
原字符串绝对索引;高亮字符属于 region [1,4),右端不包含
绝对索引012345(末端)
字符XabcY无字符
这一步的布尔返回值
true
当前区域 region
[1,4)
anchoring / transparent
true / false
当前 group()
"abc"
成功匹配的绝对位置
[1,4)

锚定边界开启:^ 和 $ 可以把 region 两端作为边界,因此 abc 匹配成功。

两项开关各管一件事。region 不是截取后的新字符串;成功匹配的 start/end 仍是原字符串的绝对索引。这里固定使用 ASCII,不演示其他 Unicode 或复杂模式的边界行为。

固定 Java 实跑结果回放:JDK 17 与 21 各运行三次,结果一致。网页只选择已有记录,不启动 JVM,也不接受任意输入执行。下方完整 Java 项目可下载复验。

先圈出 abc,但下标仍来自完整字符串

XabcY 的五个字符下标依次为 0、1、2、3、4。region(1,4) 指定 [1,4) 这段,也就是 abc;4 是不包含的终点,不是最后一个参与匹配的字符下标。

区域设置没有创建一份从零编号的新字符串。匹配到 abc 后,start() 为 1、end() 为 4,group() 是 abc。把这些位置用于原输入的 substring,才对应同一段内容。

默认整个输入都是区域。程序另外核对空区域 [2,2) 可以存在,而负起点、终点超过长度、起点晚于终点都应拒绝;空区域与非法区间不能混成一种情况。

锚点开关:区域边缘能不能充当开头和结尾

Matcher 默认启用 anchoring bounds。在本例不含换行的字符串中,^abc$ 可以把区域起点当作 ^、区域终点当作 $,所以即使外面还有 X 和 Y,matches() 仍为 true。

保持 region(1,4) 不变,只调用 useAnchoringBounds(false),同一规则变为 false。区域边缘不再替这两个锚点提供匹配位置,而 abc 又不在完整输入的两端。

这不等于禁用正则里的所有锚点。控制组把完整输入直接设为 abc,即使关闭区域锚点,^abc$ 仍能匹配。本文不含多行标志与行终止符,没有把这个小控制组扩成任意文本的锚点规则大全。

透明边界:前后视能不能看见圈外的字符

规则 (?<=X)abc(?=Y) 的意思是:匹配 abc,同时要求它前面紧邻 X、后面紧邻 Y。前后视检查上下文,不把这两个外围字符加入本次匹配内容。

默认区域边界不透明,因此这两个检查不能越过 region 去看到 X 和 Y,结果为 false。设置 useTransparentBounds(true) 后,它们能看到圈外上下文,结果为 true;匹配内容仍只有 abc。

透明不等于放宽可消耗的匹配范围。程序另用完整规则 XabcY 对同一个区域做 matches(),即使透明也为 false。四行矩阵独立切换锚点和透明性:本组锚点结果只跟锚点开关走,前后视结果只跟透明开关走。

matches、lookingAt 和 find,问的不是同一件事

把区域换成 abcYY,规则仍是 abc。matches() 要整个区域符合,所以得到 false;lookingAt() 只要求从区域开头匹配一段,所以得到 true;find() 在区域内寻找下一段匹配,也得到 true。

再把区域起点向左挪一格,让它从 x 开始,lookingAt() 失败,find() 仍能在后面找到 abc。这解释了为什么“找到了一个合法片段”不能直接当作“整个字段都符合格式”。

这几项对照每次使用独立 Matcher,避免上一次查找位置影响下一项。业务代码如果复用同一对象,还需要额外记录它目前的区域、查找位置和最近一次匹配是否成功。

reset 会恢复完整区域,find(start) 也会先做重置

程序在 12abc34def56 中先设 region(2,5),只找到 abc。调用 reset() 后,区域恢复 [0,12),最近一次匹配状态被清除;此时直接调用 group() 会抛 IllegalStateException。

两个边界开关却会保留:我们先设为 anchoring=false、transparent=true,reset 后仍是这组值。不能把“重置了”理解成所有设置都回到最初默认值。

接着重新圈出 abc,再调用 find(7),仍能找到原区域之外的 def。原因是这个带起点的重载先 reset,再从完整输入的下标 7 查找;它不是在原区域内部简单改一下游标。region(...) 本身也会清除旧匹配状态,然后设置新区间。

运行完整矩阵,核对 39 项具体条件

保存为 Main.java,先编译再运行。程序只使用 Java 标准库和固定内存字符串,不读取文件,也不执行外部传来的正则。失败检查直接抛 AssertionError,不需要额外加 -ea。

2026-10-23,在 Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12 分别使用 --release 17 编译;每版运行三次,六份 stdout 统一换行后完全相同,每次通过 39 项检查。

观察四行 bounds 矩阵,再看 reset 与 find(7) 两行的 region。输出分别记录匹配布尔值、范围和内容,不能只保留“没有抛异常”这一项结果。

展开收起代码 · 小实验java · 93 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Main {
    private static int checks;

    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }

    private static void rejects(Class<? extends Throwable> expected, Runnable action) {
        try {
            action.run();
        } catch (Throwable error) {
            check(expected.isInstance(error), "unexpected exception: " + error);
            return;
        }
        throw new AssertionError("expected " + expected.getSimpleName());
    }

    private static Matcher middle(String pattern, boolean anchoring, boolean transparent) {
        return Pattern.compile(pattern).matcher("XabcY").region(1, 4)
                .useAnchoringBounds(anchoring).useTransparentBounds(transparent);
    }

    public static void main(String[] args) {
        Matcher defaults = Pattern.compile("^abc$").matcher("XabcY").region(1, 4);
        check(defaults.hasAnchoringBounds(), "default anchoring");
        check(!defaults.hasTransparentBounds(), "default opaque bounds");
        check(defaults.matches(), "anchors at region edges");
        check(defaults.start() == 1 && defaults.end() == 4 && defaults.group().equals("abc"),
                "indices remain relative to the complete input");
        System.out.println("default: input=XabcY region=[1,4) group=abc span=[1,4)");

        for (boolean anchoring : new boolean[]{false, true}) {
            for (boolean transparent : new boolean[]{false, true}) {
                boolean anchors = middle("^abc$", anchoring, transparent).matches();
                boolean context = middle("(?<=X)abc(?=Y)", anchoring, transparent).matches();
                check(anchors == anchoring, "anchoring matrix");
                check(context == transparent, "lookaround matrix");
                check(middle("abc", anchoring, transparent).matches(), "plain region match");
                check(!middle("XabcY", anchoring, transparent).matches(),
                        "transparent lookaround does not consume outside the region");
                System.out.println("bounds anchoring=" + anchoring + " transparent=" + transparent
                        + ": anchors=" + anchors + " lookaround=" + context);
            }
        }
        check(Pattern.compile("^abc$").matcher("abc").useAnchoringBounds(false).matches(),
                "real input edges still work without regional anchors");

        Pattern literal = Pattern.compile("abc");
        String larger = "xxabcYY";
        boolean all = literal.matcher(larger).region(2, 7).matches();
        boolean prefix = literal.matcher(larger).region(2, 7).lookingAt();
        boolean found = literal.matcher(larger).region(2, 7).find();
        check(!all && prefix && found, "whole region versus its prefix or a subsequence");
        check(!literal.matcher(larger).region(1, 7).lookingAt(), "prefix starts at region start");
        check(literal.matcher(larger).region(1, 7).find(), "find may scan within region");
        System.out.println("region=abcYY: matches=" + all + " lookingAt=" + prefix + " find=" + found);

        String text = "12abc34def56";
        Matcher state = Pattern.compile("[a-z]+").matcher(text).region(2, 5)
                .useAnchoringBounds(false).useTransparentBounds(true);
        check(state.find() && state.group().equals("abc"), "first regional match");
        state.reset();
        check(state.regionStart() == 0 && state.regionEnd() == text.length(), "reset restores full region");
        check(!state.hasAnchoringBounds() && state.hasTransparentBounds(), "reset keeps bound flags");
        rejects(IllegalStateException.class, () -> state.group());
        System.out.println("reset: region=[0,12) anchoring=false transparent=true group=undefined");

        state.region(2, 5);
        check(state.find() && state.group().equals("abc"), "narrow again");
        check(state.find(7) && state.group().equals("def"), "find(start) resets before searching");
        check(state.regionStart() == 0 && state.regionEnd() == 12
                && state.start() == 7 && state.end() == 10, "find(start) uses full-input indices");
        System.out.println("find(7): region=[0,12) group=def span=[7,10)");

        state.region(7, 10);
        rejects(IllegalStateException.class, () -> state.start());
        check(state.matches() && state.group().equals("def"), "region resets previous match state");
        check(!state.hasAnchoringBounds() && state.hasTransparentBounds(), "region keeps bound flags");

        Matcher empty = Pattern.compile("^$").matcher("abc").region(2, 2);
        check(empty.matches() && empty.start() == 2 && empty.end() == 2, "empty region is legal");
        rejects(IndexOutOfBoundsException.class, () -> Pattern.compile(".").matcher("abc").region(-1, 2));
        rejects(IndexOutOfBoundsException.class, () -> Pattern.compile(".").matcher("abc").region(0, 4));
        rejects(IndexOutOfBoundsException.class, () -> Pattern.compile(".").matcher("abc").region(2, 1));
        rejects(IndexOutOfBoundsException.class, () -> state.find(13));
        System.out.println("empty-region=matched; invalid-regions=3 rejected; invalid-find-start=1 rejected");
        System.out.println("PASS " + checks + " checks; fixed ASCII input, no regex performance claim");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。真实Matcher,固定ASCII字符串;每JDK39检查,四种边界组合、三类匹配方法、reset/find(start)、空区域与非法区间;每版重复三次一致。未测任意正则、Unicode/多行、性能或并发。

运行后对照结果
default: input=XabcY region=[1,4) group=abc span=[1,4)
bounds anchoring=false transparent=false: anchors=false lookaround=false
bounds anchoring=false transparent=true: anchors=false lookaround=true
bounds anchoring=true transparent=false: anchors=true lookaround=false
bounds anchoring=true transparent=true: anchors=true lookaround=true
region=abcYY: matches=false lookingAt=true find=true
reset: region=[0,12) anchoring=false transparent=true group=undefined
find(7): region=[0,12) group=def span=[7,10)
empty-region=matched; invalid-regions=3 rejected; invalid-find-start=1 rejected
PASS 39 checks; fixed ASCII input, no regex performance claim

把区域、规则和状态一起记录,才知道实际检查了什么

若一个校验步骤应该只检查原来的区间,调用任何会 reset 的方法后都要重新核对区域;若规则依赖左右上下文,还要明确透明边界是否符合这项需求。可见上下文与实际匹配内容是两个不同范围。

本实验只含固定 ASCII 字符串和少量规则,验证区域边界及状态转换。没有测 Unicode 字符簇、多行文本、复杂回溯、吞吐或任意用户规则的资源消耗,不能把通过这 39 项写成通用解析器已经安全或完备。

Matcher 是带状态的对象,官方文档不保证多个线程同时使用同一实例安全。这里所有调用在单线程按顺序执行,没有进行并发验证;需要并发使用时,应单独处理每次匹配状态的归属。

记住这三点

  • region 指定匹配区域,start/end 仍对应完整输入的位置。
  • 锚点边界和透明边界分别控制区域边缘与圈外上下文。
  • reset 恢复完整区域但保留边界开关;find(start) 会先重置。

别把例子用过头

  • 把 region 等同于先 substring 后创建全新的 Matcher。
  • 以为透明边界允许规则把区域外字符一起消耗。
  • 用 find 成功代替整个字段符合 matches。
  • 调用 find(start) 后仍以为旧区域限制保持不变。

自己试一小步

在 XabcY 中只允许匹配 abc,同时要求左边是 X、右边是 Y,你怎样把区域与状态约定写清楚?

  1. 先画出每个字符下标,列出要匹配的内容和只用于观察的上下文。
  2. 分别预测两个边界开关四种组合下的锚点规则与前后视规则结果。
  3. 在一次成功匹配后调用 reset、region 和 find(start),每一步记录区域及 group 是否可读。
  4. 换成空区域、非法区间和前后字符不符合要求的输入,自写断言并保留完整输出。

做到这些,就算完成

  • 观察范围和匹配内容分别记录。
  • 下标始终明确对应原输入还是新字符串。
  • 不把有限规则实验扩成任意正则性能或安全保证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Java SE 17 Matcher:区域、边界开关与重置 (新标签页)↗Oracle Java 官方文档 · 英文

    区域设置、默认边界、matches/lookingAt/find 差别,以及 reset 和 find(start) 对状态的影响。

    2026-10-23 已实际搜索并打开正文,核读region、useAnchoringBounds、useTransparentBounds、reset、matches、lookingAt、find 两个重载及类级线程说明;仅声明这些阅读范围。

  • Java SE 17 Pattern:锚点与前后视 (新标签页)↗Oracle Java 官方文档 · 英文

    本实验所用 ^、$ 和零宽正向前后视的语法含义。

    2026-10-23 已实际搜索并打开正文,核读Boundary matchers、Special constructs 以及 Pattern/Matcher 状态归属说明;仅声明这些阅读范围。

  • Java SE 17 MatchResult:位置与匹配内容 (新标签页)↗Oracle Java 官方文档 · 英文

    start/end 的位置含义、group 与原输入 substring 的对应,以及没有有效匹配时的异常。

    2026-10-23 已实际搜索并打开正文,核读start()、end()、group() 的正文和异常约定;仅声明这些阅读范围。