正则表达式怎么玩(二)

115 阅读7分钟

这一篇,我们来说说正则表达式它的底层原理,我们还是以 QuickStart 中的代码为例进行讲解:

/**
 * 分析 Java 正则表达式的底层原理
 */
public class RegxTheory01 {

    public static void main(String[] args) {
        // 内容
        String content = "Java是一门面向对象的编程语言,不仅吸收了C++语言的各种优点,还摒弃了C++里难以理解的多继承、指针等概念," +
                "因此Java语言具有功能强大和简单易用两个特征。Java语言作为静态面向对象编程语言的代表,极好地实现了面向对象理论," +
                "允许程序员以优雅的思维方式进行复杂的编程";
        // 匹配规则:找出所有大小写的英文字母
        String regStr = "[a-zA-Z]+";
        // 创建模式对象
        Pattern pattern = Pattern.compile(regStr);
        // 创建匹配器:按照正则表达式的规则去匹配 content 字符串
        Matcher matcher = pattern.matcher(content);
        // 开始匹配
        while (matcher.find()) {
            System.out.println(matcher.group(0));
        }
    }
}

要知道正则表达式的底层原理,我们可以提出这么两个疑问,match.find() 这个方法到底做了一些什么事情?match.group(0) 又是怎么把结果取出来的?这两个疑问知道了,底层原理我们也就清晰了。

匹配过程原理

这里我直接抛出大概的过程,match.find() 要完成的事:

  1. 根据你写的匹配规则去定位满足规则的字符串,比如说"Java"。
  2. 从字符串索引为 0 的地方开始匹配,索引 0 是 J,满足规则,将子字符串的开始索引记录到 matcher 对象的属性 int[] groups 中,那么 groups[0] = 0,然后会把该子字符串的结束的索引+1的值记录到 groups[1],那么 groups[1] = 4。
  3. 同时记录 oldLast 的值为该子字符串的结束的索引+1的值,也就是 4,下次执行 match.find() 方法时,就会从 oldLast 开始匹配,也就是从索引为 4 的地方开始匹配。

参照着下面的源码,match.group(0) 要完成的事:

// match.group(0) 源码分析
public String group(int group) {
    if (first < 0)
        throw new IllegalStateException("No match found");
    if (group < 0 || group > groupCount())
        throw new IndexOutOfBoundsException("No group " + group);
    if ((groups[group*2] == -1) || (groups[group*2+1] == -1))
        return null;
    return getSubSequence(groups[group * 2], groups[group * 2 + 1]).toString();
}

我们可以看到方法先做了一些校验,然后调用 getSubSequence() 方法进行处理,我们将 group = 0 代入,可以看到它根据 groups[0] = 0 和 groups[1] = 4 记录的位置,从 content 开始截取子字符串返回 [0,4),那么就获取到了"Java"。如果再次执行 find() 方法,继续按照以上逻辑执行:

  1. 从记录的 oldLast 位置开始查找,匹配到"C"满足规则,将子字符串的开始索引记录到 matcher 对象的属性 int[] groups 中,那么 groups[0] = 21,然后会把该子字符串的结束的索引+1的值记录到 groups[1],那么 groups[1] = 22。
  2. 同时记录 oldLast 的值为该子字符串的结束的索引+1的值,也就是 22,下次执行 match.find() 方法时,就会从 oldLast 开始匹配,也就是从索引为 22 的地方开始匹配。
  3. match.group(0) 方法从 content 开始截取子字符串返回 [21,22),获取到"C"。

分组原理

group() 方法可以传入0,那可不可以传入1呢,我们来试试将 matcher.group(0) 改成 matcher.group(1):

报错了,什么原因呢?其实啊,这个数字是给我们的分组的时候用的。接下来我们来举另外一个例子:

public class RegxTheory02 {

    public static void main(String[] args) {
        // 内容
        String content = "MySQL的起源可以追溯到1995年,当时瑞典开发者Michael Widenius和David Axmark开始创建一个名为MySQL的轻量级数据库系统。" +
                "最初,MySQL仅仅是一个小型的、仅支持少量数据类型和表的数据库,但它具有高度的可靠性和性能优势,很快就在Linux和其他UNIX操作系统上得到了广泛的应用。" +
                "2000年,MySQL AB公司成立,旨在为MySQL数据库提供商业支持和服务。2008年,MySQL AB公司被Sun Microsystems收购。这一举动使得MySQL得到了更加广泛的认可," +
                "并吸引了更多企业用户的关注。然而,在2010年,甲骨文(Oracle)收购了Sun Microsystems,MySQL随之被纳入甲骨文的管理体系之中。";
        // 匹配规则 匹配连续4个数字
        String regx = "(\d\d)(\d\d)";
        // 创建模式对象
        Pattern pattern = Pattern.compile(regx);
        // 创建匹配器:按照正则表达式的规则去匹配 content 字符串
        Matcher matcher = pattern.matcher(content);
        while (matcher.find()) {
            System.out.println(matcher.group(1));
        }
    }
}

"(\d\d)(\d\d)"在这个正则表达式中,我们使用了两组小括号分别把"\d\d"分成了两组,第一组括号括起来的叫第一组,以此类推。当用上了分组,上面分析的逻辑就会发生一些变化:

  1. 根据匹配规则去定位满足规则的字符串,比如说"1995"。
  2. 将找到的子字符串对应的索引记录到 matcher 对象的属性 int[] groups 中。
    1. groups[0] = 13,把该子字符串的结束索引+1的值记录到 groups[1] = 17。
    2. 记录第一组()匹配到的字符串索引的位置,比如说"1995"中的"1",记录为 groups[2] = 13,"9"记录为 groups[3] = 15。
    3. 记录第二组()匹配到的字符串索引的位置,比如说"1995"中的第二个"9",记录为 groups[4] = 15,"9"记录为 groups[3] = 17。
    4. 如果有更多的分组,以此类推。

我们再来分析它是怎么取到对应分组的值的,主要还是用到了刚才上面提到的那条重要语句:

getSubSequence(groups[group * 2], groups[group * 2 + 1]).toString()
  1. 当我们调用 matcher.group(0) 时,将 0 代入,算式截取的是 getSubSequence(groups[0], groups[1]).toString() 的字符串,也就是"1995"。
  2. 当我们调用 matcher.group(1) 时,将 1 代入,算式截取的是 getSubSequence(groups[2], groups[3]).toString() 的字符串,也就是"19"。
  3. 当我们调用 matcher.group(2) 时,将 2 代入,算式截取的是 getSubSequence(groups[4], groups[5]).toString() 的字符串,也就是"95"。
  4. 当我们调用 matcher.group(3) 时,将 3 代入,算式截取的是 getSubSequence(groups[6], groups[7]).toString() 的字符串,那我们知道其实 groups 数组里,groups[6] 和 groups[7] 的值都是-1,所以当我们调用 getSubSequence() 方法时,肯定会报异常,所以这也就解释了前面提到的为什么传1会报异常的疑惑了。

踩坑记录

我们还是直接来看一个例子:

public class BlankProblemRegx {

    public static void main(String[] args) {
        // 全角空格
        String str1 = "2023 ";
        // 半角空格
        String str2 = "2023  ";

        String regx = "\d\d\d\d\s";
        Pattern pattern = Pattern.compile(regx);
        Matcher matcher1 = pattern.matcher(str1);
        Matcher matcher2 = pattern.matcher(str2);
        while (matcher1.find()) {
            System.out.println("matcher1 find:" + matcher1.group(0));
        }
        while (matcher2.find()) {
            System.out.println("matcher2 find:" + matcher2.group(0));
        }
    }
}

// 运行结果
matcher2 find:2023 

Process finished with exit code 0

正则表达式的规则是匹配连续四个数字并且以任何空白字符,包括空格、制表符、换页符等等结尾,但是我们可以看到运行的结果里,我们只有 matcher2 才匹配出来了,这是因为全角空格和半角空格导致的,\s只能匹配出半角空格,所以 str1 使用了全角空格,\s 并不能匹配出来,所以 matcher1 就没有输出。

全角和半角区别:一个汉字字符在电脑上要占两个英文字符的位置,当你输入符号的时候全角的字符也是要占两个英文字符的位置,半角的只占一个字符也就是半个汉字的位置。这就是全角和半角的区别。通常的英文字母、数字键、符号键都是半角的,半角的显示内码都是一个字节。半角全角主要是针对标点符号来说的,全角标点占两个字节,半角占一个字节。而不管是半角还是全角,汉字都还是要占两个字节,在编程序的源代码中只能使用半角标点(不包括字符串内部的数据)。

当我们在项目中需要使用到正则表达式的时候,需要注意全角空格的情况,因为文本中可能会出现全角空格,一不小你就会掉进这个坑里,而这个全角空格和半角空格,一眼过去就像是一样的,比较隐晦不容易发现。