这一篇,我们来说说正则表达式它的底层原理,我们还是以 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() 要完成的事:
- 根据你写的匹配规则去定位满足规则的字符串,比如说"Java"。
- 从字符串索引为 0 的地方开始匹配,索引 0 是 J,满足规则,将子字符串的开始索引记录到 matcher 对象的属性 int[] groups 中,那么 groups[0] = 0,然后会把该子字符串的结束的索引+1的值记录到 groups[1],那么 groups[1] = 4。
- 同时记录 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() 方法,继续按照以上逻辑执行:
- 从记录的 oldLast 位置开始查找,匹配到"C"满足规则,将子字符串的开始索引记录到 matcher 对象的属性 int[] groups 中,那么 groups[0] = 21,然后会把该子字符串的结束的索引+1的值记录到 groups[1],那么 groups[1] = 22。
- 同时记录 oldLast 的值为该子字符串的结束的索引+1的值,也就是 22,下次执行 match.find() 方法时,就会从 oldLast 开始匹配,也就是从索引为 22 的地方开始匹配。
- 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"分成了两组,第一组括号括起来的叫第一组,以此类推。当用上了分组,上面分析的逻辑就会发生一些变化:
- 根据匹配规则去定位满足规则的字符串,比如说"1995"。
- 将找到的子字符串对应的索引记录到 matcher 对象的属性 int[] groups 中。
-
- groups[0] = 13,把该子字符串的结束索引+1的值记录到 groups[1] = 17。
- 记录第一组()匹配到的字符串索引的位置,比如说"1995"中的"1",记录为 groups[2] = 13,"9"记录为 groups[3] = 15。
- 记录第二组()匹配到的字符串索引的位置,比如说"1995"中的第二个"9",记录为 groups[4] = 15,"9"记录为 groups[3] = 17。
- 如果有更多的分组,以此类推。
我们再来分析它是怎么取到对应分组的值的,主要还是用到了刚才上面提到的那条重要语句:
getSubSequence(groups[group * 2], groups[group * 2 + 1]).toString()
- 当我们调用 matcher.group(0) 时,将 0 代入,算式截取的是 getSubSequence(groups[0], groups[1]).toString() 的字符串,也就是"1995"。
- 当我们调用 matcher.group(1) 时,将 1 代入,算式截取的是 getSubSequence(groups[2], groups[3]).toString() 的字符串,也就是"19"。
- 当我们调用 matcher.group(2) 时,将 2 代入,算式截取的是 getSubSequence(groups[4], groups[5]).toString() 的字符串,也就是"95"。
- 当我们调用 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 就没有输出。
全角和半角区别:一个汉字字符在电脑上要占两个英文字符的位置,当你输入符号的时候全角的字符也是要占两个英文字符的位置,半角的只占一个字符也就是半个汉字的位置。这就是全角和半角的区别。通常的英文字母、数字键、符号键都是半角的,半角的显示内码都是一个字节。半角全角主要是针对标点符号来说的,全角标点占两个字节,半角占一个字节。而不管是半角还是全角,汉字都还是要占两个字节,在编程序的源代码中只能使用半角标点(不包括字符串内部的数据)。
当我们在项目中需要使用到正则表达式的时候,需要注意全角空格的情况,因为文本中可能会出现全角空格,一不小你就会掉进这个坑里,而这个全角空格和半角空格,一眼过去就像是一样的,比较隐晦不容易发现。