正则表达式是一串字符,描述了一个文本模式,可以方便地处理文本,包括查找、替换、切分等。
正则表达式中的字符有两类:一类是普通字符,匹配字符本身;另一类是元字符,有特殊含义,元字符及其特殊含义构成了正则表达式的语法。
单个字符
大部分的单个字符是用字符本身表示的,比如字符 '0'、'3'、'a'、'马'等,但有一些单个字符使用多个字符表示,这些字符都以斜杠 \
开头。
- 特殊字符
比如 tab 字符 '\t'、换行符 '\n'、回车符 '\r' 等。
- 八进制表示的字符
\0 开头,后跟1~3位数字,比如 \0141,对应的是 ASCII 编码为97的字符,即字符 'a'。
- 十六进制表示的字符
\x 开头,后跟两位字符,比如 \x6A,对应的是 ASCII 编码为 106 的字符,即字符 'j'。
- Unicode 编号表示的字符
\u 开头,后跟 4 位字符,比如 \u9A6C,表示的是中文字符'马',只能表示编号在 0xFFFF 以下的字符,如果超出 0xFFFF,使用 \x{...} 形式,比如 \x{1f48e}。5
- 元字符
如 '\'、'.'、'?' 等,要匹配元字符本身,需要使用转义字符。
字符组
字符组有多种,包括任意字符、多个指定字符之一、字符区间等。
任意字符
'.'
默认匹配换行符以外的任意字符。比如:正则表达式 a.f
既匹配字符串 "abf",也匹配 "acf"。
可以指定另外一种匹配模式,一般称为单行匹配模式或者点号匹配模式,此模式下,'.'
匹配任意字符,包括换行符。
有两种方式指定匹配模式:
一种是在正则表达式中,以 (? s)
开头,s 表示 single line,即单行匹配模式,比如:(? s)a.f
一种是在程序中指定,在 Java 中,单行匹配模式对应的模式常量是 Pattern.DOTALL 。
字符区间
用中括号 [] 表示组,匹配组中的任意一个字符。比如:[abcd],匹配 a、b、c、d 中的任意一个字符;
字符组中可以使用连字符 '-' 表示连续的多个字符,比如:[0-9]、[a-z];
可以有多个连续空间,可以有其他普通字符,比如:[0-9a-zA-Z];
'-' 是一个元字符,如果要匹配它自身,可以使用转义,即 '-',或者把它放在字符组的最前面
字符组支持排除的概念,在 [ 后紧跟一个字符 ^,比如:[^abcd]
,匹配除了a, b, c, d以外的任意一个字符;
^ 只有在字符组的开头才是元字符,如果不在开头,就是普通字符,匹配它自身;
在字符组中,除了 [ ]、\ 、- 、^ 外,其他在字符组外的元字符不再具备特殊含义。
有一些特殊的以 \ 开头的字符,表示一些预定义的字符组:
❑ \d: d 表示 digit,匹配一个数字字符,等同于 [0-9]。
❑ \w: w 表示 word,匹配一个单词字符,等同于 [a-zA-Z_0-9]。
❑ \s: s 表示 space,匹配一个空白字符,等同于 [ \t\n\x0B\f\r]。
❑ \D:匹配一个非数字字符,即 [^\d]
。
❑ \W:匹配一个非单词字符,即 [^\w]
。
❑ \S:匹配一个非空白字符,即 [^\s]
。
量词
量词指的是指定出现次数的元字符,有三个常见的元字符:+、*、? 。
表示前面字符的一次或多次出现,比如 ab+c
,既能匹配 abc,也能匹配 abbc,或 abbbc。
表示前面字符的零次或多次出现,比如 ab*c
,既能匹配 abc,也能匹配 ac,或 abbbc。
- ?
表示前面字符可能出现,也可能不出现,比如 ab? c
,既能匹配 abc,也能匹配 ac,但不能匹配 abbc。
- {m,n}
更通用的语法是 {m,n},出现次数从m 到 n,包括 m 和 n,如果 n 没有限制,可以省略,如果 m 和 n 一样,可以写为 {m}。
语法必须是严格的 {m,n} 形式,逗号左右不能有空格。
量词的默认匹配是贪婪的,如果希望在碰到第一个匹配时就停止,应该使用懒惰量词,在量词的后面加一个符号 '? '。
分组
表达式可以用括号 () 括起来,表示一个分组,比如 a(bc)d, bc 就是一个分组,分组可以嵌套,比如 a(de(fg))。
分组默认都有一个编号,按照括号的出现顺序,从 1 开始,从左到右依次递增。分组 0 是一个特殊分组,内容是整个匹配的字符串。
a(bc)((de)(fg))
字符串 abcdefg 匹配这个表达式,第 1 个分组为 bc,第 2 个为 defg,第 3 个为 de,第 4 个为 fg,分组 0 是 abcdefg 。
分组匹配的子字符串可以在后续访问,好像被捕获了一样,所以默认分组称为捕获分组。
小括号 () 和元字符 '|' 一起,可以表示匹配其中的一个子表达式,如:(http|ftp|file)。
可以使用斜杠 \ 加分组编号引用之前匹配的分组,这称为回溯引用。比如:<(\w+)>(.*)</\1>,\1匹配之前的第一个分组 (\w+)。
使用数字引用分组,容易出现混乱,可以对分组进行命名,通过名字引用,对分组命名的语法是 (?<name>X)
,引用分组的语法是 \k<name>
。
比如:<(? <tag>\w+)>(.*)</\k<tag>>
。
默认分组都称为捕获分组,即分组匹配的内容被捕获了,可以在后续被引用。
实现捕获分组有一定的成本,为了提高性能,如果分组后续不需要被引用,可以改为非捕获分组,语法是 (? :...),比如:(? :abc|def)
。
特殊边界匹配
边界匹配不同于字符匹配,可以认为,在字符串中,每个字符的两边都是边界。比如:"a cat\n"。
在正则表达式中,除了可以指定字符需满足什么条件,还可以指定字符的边界,常用的表示特殊边界的元字符有 ^、$、\A、\Z、\z 和 \b。
- ^
匹配整个字符串的开始,^abc 表示整个字符串必须以 abc 开始。注意在字符组中 ^ 表示排除,但在字符组外,它匹配开始。
- $
默认匹配整个字符串的结束,如果整个字符串以换行符结束,匹配的是换行符之前的边界,比如表达式 abc$,表示整个表达式以 abc 结束,或者以 abc\r\n 或 abc\n 结束。
以上 ^ 和 匹配的是行结束,比如表达式是 ^abc$,字符串是 "abc\nabc\r\n",就会有两个匹配。
可以有两种方式指定匹配模式。一种是在正则表达式中,以(? m)开头,m表示multi-line,即多行匹配模式。另外一种是在程序中指定,在Java中,对应的模式常量是 Pattern.MULTILINE。
单行模式影响的是字符 '.' 的匹配规则,使得 '.' 可以匹配换行符;多行模式影响的是 ^ 和 $ 的匹配规则,使得可以匹配行的开始和结束,两个模式可以一起使用。
- 开始边界
\A 与 ^ 类似,不管什么模式,匹配的总是整个字符串的开始边界。
- 结束边界
\Z 和 \z 与 $ 一样,匹配的是换行符之前的边界,而 \z 匹配的总是结束边界。
- 单词边界
\b 匹配的是单词边界,比如 \bcat\b,匹配的是完整的单词c at,不能匹配 category。\b 匹配的不是一个具体的字符,而是一种边界,这种边界满足一个要求,一边是单词字符,另一边不是单词字符。在Java中,\b 识别的单词字符除了 \w,还包括中文字符。
环视边界匹配
环视匹配的是一个边界,表达式是对这个边界左边或右边字符串的要求,对同一个边界,可以指定多个要求,即写多个环视。
- 肯定顺序环视
语法是(? =...),要求右边的字符串匹配指定的表达式。比如表达式 abc(? =def),(? =def) 在字符 c 右面,即匹配 c 右边的边界。对该边界的要求是:它的右边有def,比如 abcdef,如果没有则不匹配,比如 abcd。
- 否定顺序环视
语法是(? ! ...),要求右边的字符串不能匹配指定的表达式。比如表达式 s(? ! ing),匹配一般的 s,但不匹配后面有 ing 的 s。注意:避免与排除型字符组混淆,比如 s[^ing]
,匹配的是两个字符,第一个是 s,第二个是 i、n、g 以外的任意一个字符。
- 肯定逆序环视
语法是(? <=...),要求左边的字符串匹配指定的表达式。比如表达式 (? <=\s)abc,(? <=\s) 在字符 a 左边,即匹配 a 左边的边界。对该边界的要求是:它的左边必须是空白字符。
- 否定逆序环视
语法是(? <! ...),要求左边的字符串不能匹配指定的表达式。比如表达式 (? <! \w)cat,(? <! \w) 在字符 c 左边,即匹配 c 左边的边界。对该边界的要求是:它的左边不能是单词字符。
环视结构也被称为断言,断言的对象是边界,边界不占用字符,没有宽度,所以也被称为零宽度断言。
顺序环视也可以出现在左边,逆序环视也可以出现在右边。
比如:(? =.*[A-Z])\w+
,\w+ 匹配多个单词字符,(? =.*[A-Z])
匹配单词字符的左边界,这是一个肯定顺序环视,对这个左边界的要求是,它右边的字符串匹配表达式 .*[A-Z]
,就是说,它右边至少要有一个大写字母。
匹配模式
在正则表达式中,可以指定多个模式。
- 单行匹配模式
又称点号匹配模式,此模式下,'.'
匹配任意字符,包括换行符。有两种方式指定匹配模式:
一种是在正则表达式中,以 (? s)
开头,s 表示 single line,即单行匹配模式,比如:(? s)a.f
;
一种是在程序中指定,Java 中对应的模式常量是 Pattern.DOTALL 。
- 多行匹配模式
在此模式下,会以行为单位进行匹配,^ 匹配的是行开始,$ 匹配的是行结束,比如表达式是 ^abc$,字符串是 "abc\nabc\r\n",就会有两个匹配。
有两种方式指定匹配模式:
一种是在正则表达式中,以(? m)开头,m表示multi-line,即多行匹配模式;
一种是在程序中指定,Java 中对应的模式常量是 Pattern.MULTILINE。
- 不区分大小写的模式
一种是在正则表达式开头使用 (? i)
, i 为 ignore,比如:(? i)the
;
一种在程序中指定,Java 中对应的模式常量是 Pattern.CASE_INSENSITIVE。
Java API
正则表达式相关的类位于包 java.util.regex
下,有两个主要的类,一个是 Pattern,另一个是 Matcher。
Pattern
Pattern 表示正则表达式对象,它与要处理的具体字符串无关。
- 表示正则表达式
正则表达式由元字符和普通字符组成,在正则表达式中,字符 '\' 是一个元字符,要表示 '\' 本身,需要使用它转义,即 '\\'。
在 Java 中,需要用字符串表示正则表达式,而在字符串中,''也是一个元字符,要表示 '\' 本身,需要使用它转义,即 '\\'。
为了在字符串中表示正则表达式的 '\' 本身,就需要使用四个 '\' 。字符串表示的正则表达式可以被编译为一个 Pattern 对象。
String regex = "<(\\w+)>(.*)</\\1>";
Pattern pattern = Pattern.compile(regex);
编译有一定的成本,Pattern 对象只与正则表达式有关,与要处理的具体文本无关,可以安全地被多线程共享,应该尽量重用 Pattern 对象,避免重复编译。
Pattern 的 compile 方法接受一个额外参数,可以指定匹配模式,多个模式可以一起使用,通过 '|' 连起来即可。
Pattern.DOTALL 单行模式(点号模式)
Pattern.MULTILINE 多行模式
Pattern.CASE_INSENSI-TIVE 大小写无关模式
Pattern.LITERAL 正则表达式的元字符将失去特殊含义,被看作普通字符,和 Pattern 的 静态 quote() 方法作用一样。
- 切分
String 的 split 将参数 regex 看作正则表达式,而不是普通的字符串,如果分隔符包含元字符,比如 .$ | ( ) [ { ^ ? * + \
,就需要转义。
如果分隔符是用户指定的,程序事先不知道,可以通过 Pattern.quote() 将其看作普通字符串。
分隔符就不一定是一个字符,比如,可以将一个或多个空白字符或点号作为分隔符。
String str = " abc def hello.\n world";
String[] fields = str.split("[\\s.]+");
System.out.println(Arrays.toString(fields));
需要说明的是,尾部的空白字符串不会包含在返回的结果数组中,但头部和中间的空白字符串会被包含在内。
String str = ", abc, , def, , ";
String[] fields = str.split(", ");
System.out.println("field num: "+fields.length); // field num: 4
System.out.println(Arrays.toString(fields)); // [, abc, , def]
如果字符串中找不到匹配 regex 的分隔符,返回数组长度为1,元素为原字符串。
Pattern 也有 split 方法,与 String 方法的定义类似:
public String[] split(CharSequence input)
1)Pattern接受的参数是CharSequence,更为通用,String、StringBuilder、StringBuffer、CharBuffer 等都实现了该接口。
2)如果 regex 长度大于 1 或包含元字符,String 的 split 方法必须先将 regex 编译为 Pattern 对象,再调用 Pattern 的 split 方法,为避免重复编译,应该优先采用Pattern 的方法。
3)如果 regex 就是一个字符且不是元字符,String 的 split 方法会采用更为简单高效的实现,这时应该优先采用 String 的 split 方法。
- 验证
检验输入文本是否完整匹配预定义的正则表达式,经常用于检验用户的输入是否合法。
String 的 matches:
public boolean matches(String regex) {
return Pattern.matches(regex, this);
}
实际调用的是 Pattern 的 matches :
public static boolean matches(String regex, CharSequence input) {
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(input);
return m.matches();
}
Matcher
Matcher 表示一个匹配,它将正则表达式应用于具体字符串,通过它对字符串进行处理。
- 查找
public static void find(){
String regex = "\\d{4}-\\d{2}-\\d{2}";
Pattern pattern = Pattern.compile(regex);
String str = "today is 2017-06-02, yesterday is 2017-06-01";
Matcher matcher = pattern.matcher(str);
while(matcher.find()){
System.out.println("find " + matcher.group()
+" position: " + matcher.start() + "-" + matcher.end());
}
}
Matcher 的内部记录有一个位置,起始为 0,find 方法从这个位置查找匹配正则表达式的子字符串,找到后,返回 true,并更新这个内部位置。
匹配到的子字符串信息可以通过如下方法获取:
// 匹配到的完整子字符串,group() 其实调用的是 group(0),表示获取匹配的第0个分组的内容,分组0是一个特殊分组,表示匹配的整个子字符串
public String group()
// 子字符串的起始位置
public int start()
// 子字符串的结束位置加 1
public int end()
// 分组编号为 group 的内容
public String group(int group)
// 分组编号为 group 的起始位置
public int start(int group)
// 分组编号为 group 的结束位置加 1
public int end(int group)
// group(0) 不统计
public int groupCount()
// 分组命名为 name 的内容
public String group(String name)
public static void findGroup() {
String regex = "(\\d{4})-(\\d{2})-(\\d{2})";
Pattern pattern = Pattern.compile(regex);
String str = "today is 2017-06-02, yesterday is 2017-06-01";
Matcher matcher = pattern.matcher(str);
while (matcher.find()) {
System.out.println("year:" + matcher.group(1)
+ ", month:" + matcher.group(2) + ", day:" + matcher.group(3));
}
}
- 替换
String的 replaceAll 和 replaceFirst 调用的其实是 Pattern 和 Matcher 中的方法。
public String replaceAll(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}
为避免元字符的干扰,可以使用 Matcher 的 quoteReplacement 静态方法将其视为普通字符串。
public static String quoteReplacement(String s)
除了一次性的替换操作外,Matcher 还定义了边查找、边替换的方法。
public Matcher appendReplacement(StringBuffer sb, String replacement)
public StringBuffer appendTail(StringBuffer sb)
Pattern pattern = Pattern.compile("cat");
Matcher matcher = pattern.matcher("one cat,two cat,three cat");
StringBuffer sb = new StringBuffer(); // sb 存放最终的替换结果
int foundNum = 0;
while(matcher.find()){
matcher.appendReplacement(sb,"dog");
foundNum++;
if(foundNum == 2) break;
}
matcher.appendTail(sb);
System.out.println(sb.toString());
Matcher 内部除了查找位置,还有一个 append 位置,初始为0,当找到一个匹配的子字符串后,appendReplacement() 做了三件事情:
1)将 append 位置到当前匹配之前的子字符串 append 到 sb 中,在第一次操作中,为 "one ",第二次为 ", two "(注意空格)。
2)将替换字符串 append 到 sb 中。
3)更新 append 位置为当前匹配之后的位置。
appendTail 将 append 位置之后所有的字符 append 到 sb 中。
模板引擎
模板是一个字符串,中间有一些变量,以 {name} 表示。
String template = "Hi {name}, your code is {code}.";
上述模板字符串中有两个变量:一个是 name,另一个是 code。
变量的实际值通过 Map 提供,变量名称对应 Map 中的键,模板引擎的任务就是接受模板和 Map 作为参数,返回替换变量后的字符串。
public class PatternTemplate {
private static Pattern templatePattern = Pattern.compile("\\{(\\w+)\\}");
public static String templateEngine(String template, Map<String,Object> params) {
StringBuffer sb = new StringBuffer();
// 寻找所有的模板变量,正则表达式为 \{(\w+)\}
Matcher matcher = templatePattern.matcher(template);
while(matcher.find()){
String key = matcher.group(1);
Object value = params.get(key);
matcher.appendReplacement(sb,value != null ? Matcher.quoteReplacement(value.toString()) : "");
}
matcher.appendTail(sb);
return sb.toString();
}
public static void main(String[] args) {
String template = "Hi {name},your code is {code}";
Map<String,Object> params = new HashMap<>();
params.put("name","JOJO");
params.put("code","6789");
System.out.println(templateEngine(template, params));
}
}
参考:《Java 编程的逻辑》马俊昌