正则表达式Regex及Java相关使用

正则入门,有手就行

Posted by BlackDn on March 13, 2022

“来势汹汹,似懂非懂,风吹草动都让我心事重重。”

正则表达式 Regex 及 Java 相关使用

前言

累了=。=
最近在学 Linux,自然有grepsed的一些命令
于是自然接触到了正则表达式,还挺好用

正则表达式 Regex

什么是正则表达式

正则表达式居然还有百科,那就不多讲了,太官方了看着也头疼
要说我自己的理解,正则表达式就是一系列用于匹配字符的规则,而明白这些规则能让我们快速筛选字符,特别是一大串里的一小段字符。
正则表达式定义了一些语法符号来表示特殊的字符集和匹配方式,这也是正则表达式的主体。要是知道这些符号是什么用,那么正则你就会了 90%了。剩下 10%就是自己上手用一用正则。

语法符号

最好的情况是看一个语法符号然后到正则里去使用它,这样最能加深印象。不过那样就会导致文章篇幅很长。
所以我决定先给出一些基础的语法符号,然后用一些例子来匹配。

字符 说明
^ 匹配字符串开始的位置
$ 匹配字符串结束的位置
\b 匹配单词边界,即匹配字符和空格或开始/结束之间的位置
{n} 对前面的字符匹配 n 次
{n, } 对前面的字符匹配 n 次及以上
{n, m} 对前面的字符匹配 n 次到 m 次
* 对前面的字符匹配 0 次或多次(匹配任意次),相当于{0, }
+ 对前面的字符匹配 1 次或多次(至少匹配一次),相当于{1, 0}
对前面的字符匹配 0 次或 1 次,相当于{0, 1}
x|y 匹配 x 或 y。“c|dog”匹配“c”或“dog”,“(c|d)og”匹配”cog”或“dog”
[xyz] 字符集,匹配 x 或 y 或 z,相当于“x|y|z”
[^xyz] 反向字符集,匹配除了其中的字符
[a-z], [A-Z], [0-9] 分别表示小写字母集,大写字母集,数字集
\d 数字集,相当于[0-9]
\D 非数字集,相当于[\^0-9]
\w 字母集合+下划线,相当于[a-zA-Z0-9_]
\W 非(字母集合+下划线),相当于[\^a-zA-Z0-9]
\n,\r,\t 分别表示换行符,回车符,制表符(tab)
. 除了换行符以外的任意单个字符

简单例子

对上面一些语法的举例,要是看迷糊了可以往前翻一下。
为了方便,这里在 Linux 中进行举例,因为grep命令可以支持正则。

其实,严格来说单单一个不带任何语法的字符串也算是一个正则表达式,只不过它没有任何其他意义,仅仅是匹配和这个字符一模一样的字串罢了。
而一个比较有意思的表达式就是.*,它可以匹配任何字符串。.表示任意字符,*表示重复任意次嘛。

root # echo "doge is a cat" | grep -o "cat"
cat
root # echo "doge is a cat" | grep -o ".*"
doge is a cat

首先是^$,因为不能显示颜色,所以这里用文字阐述一下(-o表示只输出匹配到的内容,而非输出整行)

root # echo "doge is a dog" | grep -o "dog"
dog (匹配doge中的dog)
dog (匹配最后的dog)
root # echo "doge is a dog" | grep -o "^dog"
dog (匹配doge中的dog,因为它在起始位置)
root # echo "doge is a dog" | grep -o "dog$"
dog (匹配最后的dog,因为它在末尾)

比如我们想获取以b开头,以t结尾的单词,那么中间不管有什么,我们都需要进行匹配。
比如"the bat is the best"中,我们要匹配到“bat”“best
一开始我们想到了.*,但是……

root #echo "the bat is the best" | grep -o "b.*t"
bat is the best

发现它匹配了全部内容。这是因为.代表任意字符,包括了空格。为此,我们就改用字符集\w
而且我们可以保证bt不是一个单词,即bt中间一定有内容,所以我们改用+而不是*。(*可以匹配 0 次,即中间没有内容也会匹配,因此会匹配到bt
为了让grep识别\w,这里用了参数-E启用grep的扩展正则表达式

root #echo "the bat is the best" | grep -o -E "b\w+t"
bat
best

可以看到是成功匹配了,但是这里还不完善,因为\w字符集包括数字和下划线,如果原字符串变成了 "the bat is the best. but b4t is not b_t" ,就不能正常匹配出单词了。

root #echo "the bat is the best. the b4t is not b_t" | grep -E -o "b\w+t"
bat
best
b4t
b_t

不是单词的b4tb_t也被错误匹配到了。因此,我们改用字符集[a-z]来明确我们只要英文字母

root #echo "the bat is the best. the b4t is not b_t" | grep -E -o "b[a-z]+t"
bat
best

这样就成功匹配到了我们需要的单词。

还有一个比较难理解的是\b,它难以言喻,但是举个例子就很好表示了

root #echo "never is not a verb" | grep -o "er"
er (never中的er)
er (verb中的er)
root #echo "never is not a verb" | grep -o "er\b"
er (never中的er)

由于neververb都含有er,所以两个都会被匹配到。
但是如果用"er\b",这表示er之后该单词结束了,要么是空格,要么是空行。符合这个条件的就只有never中的er

获取邮箱

现在我们来假设一个情景,我有一串信息存在info.txt中,这个其中存储了我的邮箱(学校邮箱是我瞎编的):

root #cat info.txt
This is the text.
in the text i put in my email.
my email is blackdn233@outlook.com haha.
i wont tell you my email.
and i have school email
this is not mine 20180000000@gdufs.edu.cn, cool.
dont forget netease email
blackdawn233@163.com is mine.

咳咳,假装这串信息很长,我们一下子看不到邮箱在哪,现在呢我们就尝试用正则表达式来获取邮箱
(这里规定邮箱名(@前面)可以用数字、字母、下划线开头,同时也只能包含这三种东西。并且假定@之后的域名只有数字或字母)

首先我们直观感受一下邮箱的特点,无非就是xxx@xxx.xxx,其中末尾常见的有.com.cn,当但会有其他的形式。
那么第一步我们就通过@来获取信息:

root #cat info.txt | grep -o ".*@.*"
my email is blackdn233@outlook.com haha.
it is 20180000000@gdufs.edu.cn, cool.
blackdawn233@163.com is mine.

可以看到,我们用".*@.*"获取到了@前后所有的内容,即@所在的一整行。
我们进一步观察,由于规定了@前面只能有数字、字母、下划线,那么我们将这些作为一个字符集进行匹配,转头发现这些字符集不就是\w嘛:

root #cat info.txt | grep -o -E "(\w+)@.*"
blackdn233@outlook.com haha.
20180000000@gdufs.edu.cn, cool.
blackdawn233@163.com is mine.

反正@前面必须得有东西,所以我们顺便把*改成了+,变成"\w+@.*"
这样我们成功放弃了邮箱前面的空格,匹配到了前半段。
至于后半段,我们一步步来。普遍情况是在@后面是一段字母,我们就用字符集[0-9a-zA-Z]表示:

root #cat info.txt | grep -o -E "(\w+)@([0-9a-zA-Z]+)"
blackdn233@outlook
20180000000@gdufs
blackdawn233@163

这样我们成功匹配到了@后面第一部分的内容。但是还有后面的.com
不过由于.在正则中表示匹配任意一个字符,为了让他只匹配这个点,需要进行转移,变为“\\.”

root #cat info.txt | grep -o -E "(\w+)@([0-9a-zA-Z]+)\.com"
blackdn233@outlook.com
blackdawn233@163.com

这样我们成功匹配到了.com,不过遗憾的是并非所有邮箱都是.com,还有.cn结尾的呢,现在匹配不到学校邮箱了咋整
为了方便起见,我们在这认为邮箱的末尾仅由小写字母组成,并且这段长度在 2~6 个字符之间,这样就有了“([a-z]{2,6})”

root #cat info.txt | grep -o -E "(\w+)@([0-9a-zA-Z]+)\.([a-z]{2,6})"
blackdn233@outlook.com
20180000000@gdufs.edu
blackdawn233@163.com

这下我们匹配到了学校邮箱,但没完全匹配。原来学校邮箱存在子域名,也就是说中间可能存在很多个.
为此,我们将“.([a-z]{2,6})”认为是匹配顶级域名(邮箱末尾)的部分,这样,我们就需要在“([0-9a-zA-Z]+)”中进行修改
怎么修改呢?无非就是多了个.嘛,所以我们给他加到字符集中,变成“([0-9a-zA-Z\.]+)”

root #cat info.txt | grep -o -E "(\w+)@([0-9a-zA-Z\.]+)\.([a-z]{2,6})"
blackdn233@outlook.com
20180000000@gdufs.edu.cn
blackdawn233@163.com

这样我们就能正确提取出信息中的所有邮箱啦。

不过在实际应用中还需要经过情况讨论,毕竟我也没仔细琢磨邮箱的格式=。=

Java 中使用正则

Java 中的正则一般要用到两个类,分别为PatternMatcher
简单来说,Pattern就是将我们的字符串处理为正则表达式,让程序明白我这串字符是正则而不是字符串。Matcher就是将Pattern和待匹配的内容进行处理,得到最后的匹配内容。

String regex = "regex"; //正则,匹配‘regex’内容
String text = "I am a regex string.";   //待匹配内容

Pattern pattern = Pattern.compile(regex);   //Pattern将字符串变为正则表达式
Matcher matcher = pattern.matcher(text);    //Matcher进行匹配

然后,我们就可以从Matcher中对匹配结果进行进一步操作,比如是否匹配成功,匹配到了什么内容等。

是否匹配

我们想判断是否成功匹配,Mathcer给我们提供了两个方法find()matches()
find()是部分匹配,即待匹配内容中有一部分匹配到了我们的正则,就表示成功匹配;
matches()则是完全匹配,需要待匹配内容完全匹配正则内容才算成功匹配。

String regex = "regex"; //正则,匹配‘regex’内容
String text = "I am a regex string.";   //待匹配内容

Pattern pattern = Pattern.compile(regex);   //Pattern将字符串变为正则表达式
Matcher matcher = pattern.matcher(text);    //Matcher进行匹配

//判断是否匹配成功
boolean isFindMatched = matcher.find();	//用find()匹配
System.out.println("isFindMatched? : " + isFindMatched);
boolean isMatchesMatched = matcher.matches(); //用matches()匹配
System.out.println("isMatchesMatched? : " + isMatchesMatched);

//输出:
//isFindMatched? : true
//isMatchesMatched? : false

因为待匹配的text里只有“regex”的部分匹配我们的正则,所以find()显示成功匹配,matches()则显示失败。
我们可以用“.*”的表达式来测试一下,因为“.*”表示任何字符串,所以无论 find()还是matches()都会成功配对。

Pattern p = Pattern.compile(".*");
Matcher m = p.matcher("I am a regex string.");
System.out.println(m.matches());
//输出:
//true

由于find()matches() 返回的都是boolean,所以可以放在循环或判断里作为条件,方便进一步操作。

还有一个不怎么常用的是lookingAt(),它和find()类似,只要部分匹配即可,但是它的要求更严格,必须在待匹配内容的开头成功匹配才算成功。
比如当待匹配内容为String = "cat is not dog"的时候用m.lookingAt()regex = "cat"时匹配成功,但是regex = "dog"就匹配失败了。

之前我们判断是否匹配用的都是Matcher里的方法,因为Matcher本身就是设计来根据regex对文本进行操作的类。
不过如果想要偷懒不用MatcherPattern本身也提供了判断是否匹配的方法:Pattern.matches()
不过当看到源码的时候我们恍然大悟,实际上这个方法里仍然是使用Mathcermatches()方法进行判断:

//in Pattern.class
public static boolean matches(String regex, CharSequence input) {
    Pattern p = compile(regex);
    Matcher m = p.matcher(input);
    return m.matches();
}

所以说最好还是老老实实把PatternMatcher都一起实现
Pattern处理正则,Matcher处理文本,各司其职,偷懒反而还容易把自己搞晕了呢。

转义

这里要注意一点,如果我们单纯想匹配这个星号“*”,我们都知道需要进行转义,因为“*”是正则中的保留符号;而 Java 本身也有转义的机制,因此在进行转义的时候需要加2 个“\”
比如就匹配一个“*”,我们的正则表达式应该为“\*”,在这个表达式中,\*都是普通的字符,它们合起来表示正则中转义的*
而在 Java 中, 它会优先处理自己的转义机制,也就是说如果我们只写“\*”,会被 Java 理解成Java 中转义的*,而 Java 中转义的*没有任何意义,这就会导致报错。实际上由于\也是 Java 中的保留符号,所以我们要先对\转义,把它变成普通的\,然后和后面的*合起来:\\*
这样,\\*在 Java 字符串中经过转义处理后就表示单纯的两个字符“\*”,这个两个字符再经过Pattern处理成正则,就表示一个普通的*

Pattern p = Pattern.compile("\\*");
Matcher m = p.matcher("i have a *.");
System.out.println(m.find());
//输出:
//true

好像有点说复杂了,总之就是\在 Java 中是个保留符号,所以在正则里用的时候要先"\\"转义。

获取匹配内容

Matcher获取匹配内容的能力很强,不仅可以得到匹配的内容,还可以获取该内容在原字符串中的起始位置和结束位置。
利用find()进行匹配后,可以用matcher.group()方法,它返回的就是匹配到的全部内容。
start()end()方法可以获得匹配内容的开始位置和结束位置,不过要注意结束位置是不包括匹配内容的,即匹配的内容为text[start()]~text[end() - 1]

String regex = "regex"; //正则,匹配‘regex’内容
String text = "I am a regex string.";   //待匹配内容

Pattern pattern = Pattern.compile(regex);   //Pattern将字符串变为正则表达式
Matcher matcher = pattern.matcher(text);    //Matcher进行匹配
if (matcher.find()) {
    System.out.println("matched context: " + matcher.group());
    System.out.println(matcher.group(0));
    System.out.println("start: " + matcher.start() + ", end: " + matcher.end());
}
//输出:
//matched context: regex
//start: 7, end: 12

matcher.group()是可以对匹配内容进行分组的,就像这个方法的名字一样
首先在正则表达式里需要我们用括号来表示分组,比如有以下文本:

String textGroup = "I have 2 pans.";

我们打算匹配“数量+物品”,并让数字为一组,物品为一组。
首先我们用“任意长度的数字+空格+任意长度的字母”作为匹配格式,构造正则表达式:

String regexGroup = "\\d+ \\w+";

然后,把我们想要的分组用括号括起来

String regexGroup = "(\\d+) (\\w+)";

再然后我们就可以用group(1)group(2)来表示第一组(第一个括号里的东西)和第二组(第二个括号里的东西)
当然可能会有好奇宝宝会问那group(0)表示的是啥,它和group()是一样的,表示的匹配的全部内容

String textGroup = "I have 2 pans.";
String regexGroup = "(\\d+) (\\w+)";

Pattern p = Pattern.compile(regexGroup);
Matcher m = p.matcher(textGroup);
while (m.find()) {
    System.out.println("matched context: " + m.group());
    System.out.println("start: " + m.start() + ", end: " + m.end());
    System.out.println("group 1: " + m.group(1));
    System.out.println("group 2: " + m.group(2));
}
//输出
//matched context: 2 pans
//start: 7, end: 13
//group 1: 2
//group 2: pans

可以看到,我们成功将数字和物品进行了分组,可以分别获取了

循环匹配并获取内容

最后,当我们的待匹配内容中有多个符合表达式的内容时,我们需要通过循环调用find()来不断获取
每当调用find()后,Matcher会丢弃当前匹配到的内容,继续向下尝试匹配,直到匹配失败,返回false

比如我们把上面的字符串改一下

String textGroup = "I have 2 pans, 3 rulers and 4 books.";

在保持分组的情况下,除了“2 pans”,后面的“3 rulers”“4 books”我们都想要成功匹配,这就需要循环调用find()

String textGroup = "I have 2 pans, 3 rulers and 4 books.";
String regexGroup = "(\\d+) (\\w+)";

Pattern p = Pattern.compile(regexGroup);
Matcher m = p.matcher(textGroup);
while (m.find()) {
    System.out.println("matched context: " + m.group());
    System.out.println("start: " + m.start() + ", end: " + m.end());
    System.out.println("group 1: " + m.group(1));
    System.out.println("group 2: " + m.group(2));
}
//输出
//matched context: 2 pans
//start: 7, end: 13
//group 1: 2
//group 2: pans

//matched context: 3 rulers
//start: 15, end: 23
//group 1: 3
//group 2: rulers

//matched context: 4 books
//start: 28, end: 35
//group 1: 4
//group 2: books

替换

既然我们能够取到匹配内容的开始和结束位置,当然也就可以自己写替换了,但这样多少有点麻烦,所以 Java 很贴心地给我们提供了替换的方法。 比较简单的就是MatcherreplaceFirst()replaceAll()的方法,返回的就是替换后的字符串。

replaceFirst()就是替换第一个匹配的内容,而replaceAll()则替换所有匹配的内容。

String regex = "regex"; //正则,匹配‘regex’内容
String text = "I am a regex string. I like regex very much.";   //待匹配内容
String replaceText = "NEW";

Pattern pattern = Pattern.compile(regex);   //Pattern将字符串变为正则表达式
Matcher matcher = pattern.matcher(text);    //Matcher进行匹配

if (matcher.find()) {
    System.out.println("matched context: " + matcher.group());
    System.out.println("replace first: " + matcher.replaceFirst(replaceText));
    System.out.println("replace All: " + matcher.replaceAll(replaceText));
}

//输出:
//matched context: regex
//replace first: I am a NEW string. I like regex very much.
//replace All: I am a NEW string. I like NEW very much.

除了这两个简单的替换方法外,Matcher还提供了两个方法,appendReplacement()appendTail(),它们都是对StringBuffer的构建。
在源码中,appendReplacement()方法被描述为non-terminal step,而appendTail()则为terminal step

当我们生成一个 Matcher 后,我们传入了待匹配的字符串,而这两个方法用来将字符串匹配替换后变为StringBuffer之后再传出来。
appendReplacement(stringBuffer, replaceText)会进行一次匹配,匹配成功后它进行替换,替换完了将之前到当前成功位置的内容存入StringBuffer。如果我们想要多次匹配替换, 就需要循环调用appendReplacement()。相当于find()一次后replaceFirst()一次,然后存入StringBuffer
appendTail(stringBuffer)就比较简单,他把appendReplacement()结束后剩下下来没匹配上的东西加入StringBuffer

看起来有些难理解,我们举个例子:
待匹配内容为"I have a fat cat. I like fat cat. Fat cat is cute.",我们想把cat替换成DOG
执行第一次appendReplacement(),匹配到第一个catStringBuffer的内容为“I have a fat DOG”
执行第二次appendReplacement(),匹配到第二个catStringBuffer的内容为“I have a fat DOG. I like fat DOG”
执行第三次appendReplacement(),匹配到第三个catStringBuffer的内容为“I have a fat DOG. I like fat DOG. Fat DOG”
然后就不会再执行了,因此已经找不到cat了,匹配失败。
但是这时候StringBuffer里的内容和原文还是有点出入,最后部分没有加上,这时候调用,StringBuffer的内容就为“I have a fat DOG. I like fat DOG. Fat DOG is cute.”

String regex = "cat"; //正则,匹配‘regex’内容
String text = "I have a fat cat. I like fat cat. Fat cat is cute.";   //待匹配内容
String replaceText = "DOG";

Pattern pattern = Pattern.compile(regex);   //Pattern将字符串变为正则表达式
Matcher matcher = pattern.matcher(text);    //Matcher进行匹配

StringBuffer stringBuffer = new StringBuffer();
while (matcher.find()) {
    matcher.appendReplacement(stringBuffer, replaceText);
    System.out.println(stringBuffer.toString());
}
matcher.appendTail(stringBuffer);
System.out.println(stringBuffer.toString());
//输出
//I have a fat DOG
//I have a fat DOG. I like fat DOG
//I have a fat DOG. I like fat DOG. Fat DOG
//I have a fat DOG. I like fat DOG. Fat DOG is cute.

后话

到此其实 Java 正则基本使用就没啥问题了,主要是这两个类和一些方法的使用
正则的内容是独立于语言的,所以还是要靠自己多多学习动手看看
然后日常用的话其实一搜就会有很多正则表达式速查表,像是邮箱啊手机号啊啥的都有

参考

  1. 百科:正则表达式
  2. 菜鸟:Java 正则表达式
  3. 正则表达式速查表
  4. Oracle:Test Harness
  5. JAVA 正则表达式,matcher.find()和 matcher.matches()的区别