C++与正则表达式入门
什么是正则表达式?
正则表达式是一组由字母和符号组成的特殊文本, 当你想要判断许多字符串是否符合某个特定格式;当你想在一大段文本中查找出所有的日期和时间;当你想要修改大量日志中所有的时间格式,在这些情况下,正则表达式都能帮上忙。
简单来说,正则表达式描述了一系列规则,通过这些规则,可以在字符串中找到相关的内容,规则使得搜索的能力更加强大。匹配的过程由正则表达式引擎完成。开发者通常不需要关心正则表达式引擎的实现细节,直接使用其提供的能力即可。
大家可以先想象你正在写一个应用, 然后你想设定一个用户命名的规则, 让用户名包含字符,数字,下划线和连字符,以及限制字符的个数,好让名字看起来没那么丑. 我们使用以下正则表达式来验证一个用户名:
以上的正则表达式可以接受 john_doe , john12_as . 但不匹配 Jo , 因为它包含了大写的字母而且太短了.
本文将以C++语言为例,介绍其中的正则表达式相关知识。
C++中正则表达式的API基本上都位于
部分代码为了简化书写,都已经默认做了以下操作:
#include
#include
using namespace std;
入门示例
为了使大家有一个直观的感受,文章的开头先通过一些入门示例给大家一个直观的感受。在这个基础之上,再详细讲解其中的细节。
使用正则表达式的大致流程如下:首先你有一段需要处理的文本。这可能是一个字符串对象,也可能是一个文本文件,或者是一大堆日志。接下来你会有特定的目标,例如:找出文本中所有的时间和日期。这个时候你就需要根据可能的格式写出具体的正则表达式,例如,日期的格式是:2020-01-01,那么你的正则表达式可能是这样:\d{4}-\d{2}-\d{2}
。(你现在不必纠结与这个正则表达式是什么意思,因为这是本文接下来要讲解的内容。)
有了正则表达式之后,你需要将你的文本和正则表达式交给正则表达式引擎 – 由C++语言(或者其他语言)提供。引擎会在文本中搜索到匹配的结果。这个结果的格式可能是包含了多个组,例如:你可能需要分离出年份和月份。有了引擎返回的结果之后,你就可以进一步处理了。
使用正则表达式的流程大体都是一致的,下面是最常见(其他形式大多为其变种)的三种使用方式。
匹配
匹配是判断给定的字符串是否符合某个正则表达式。例如:你想判断当前文本是否全部由数字构成。
下面是一段代码示例:
string s1 = "ab123cdef"; // ①
string s2 = "123456789"; // ②
regex ex("\\d+"); // ③
cout << s1 << " is all digit: " << regex_match(s1, ex) << endl; // ④
cout << s2 << " is all digit: " << regex_match(s2, ex) << endl; // ⑤
在这段代码中:
- 这是一个包含了数字和字母的字符串
- 这是一个只包含了数字的字符串
- 这是我们的正则表达式,它表示:有多个数字cpp
- 通过
regex_match
判断第一个字符串是否匹配,这里将返回false - 通过
regex_match
判断第二个字符串是否匹配,这里将返回true
这段代码输出如下:
ab123cdef is all digit: 0
123456789 is all digit: 1
请注意,正则表达式有它自身的语法。这与C++的语法是两回事。C++编译器只会检查C++代码的语法。因此,即便你的代码通过了C++编译器的语法检查,但在运行的时候,由于正则表达式的语义,还可能出现正则表达式的错误。正则表达式的错看起来类似这样:
terminating with uncaught exception of type std::__1::regex_error: The expression contained an invalid escaped character, or a trailing escape.
。
搜索
还有一些时候,我们要判断的并非是文本的全体是否匹配。而是在一大段文本中搜索匹配的目标。
下面是一段代码示例,这段示例演示了在一个字符串中查找数字:
string s = "ab123cdef"; // ①
regex ex("\\d+"); // ②
smatch match; // ③
regex_search(s, match, ex); // ④
cout << s << " contains digit: " << match[0] << endl; // ⑤
- 这是一个包含了数字和字母的字符串
- 和前面一样的正则表达式
- 通过
std::smatch
来保存匹配的结果。除了std::smatch
,还有std::cmatch
也很常用。前者是以std::string
的形式返回结果,后者是以const char*
的形式返回结果。 - 通过
regex_search
函数搜索结果 - 打印出匹配的结果
这段代码输出如下:
ab123cdef contains digit: 123
替换
最后,使用正则表达式的还有一个常见功能是文本替换。很多的编辑器都有这样的功能。
例如,下图是我的Visual Studio编译器,在搜索替换文本的时候,可以使用正则表达式,这时搜索的能力就更加强大了。“Find:”部分可以通过正则表达式来描述待替换的字符串,“Replace:”部分填写替换的字符串。
下面是在C++中使用正则表达式完成字符串替换的代码示例:
string s = "ab123cdef"; // ①
regex ex("\\d+"); // ②
string r = regex_replace(s, ex, "xxx"); // ③
cout << r << endl; // ④
- 仍然是前面这个字符串
- 仍然是同样的正则表达式
- 通过
regex_replace
完成替换 - 通过
cout
输出结果
最终输出的字符串如下:
abxxxcdef
通过上面的三个示例我们看到,regex_match
,regex_search
和regex_replace
三个函数是正则表达式的核心,它们会运行正则表达式引擎完成匹配,查找和替换任务。
正则表达式文法
文法
C++中内置了多种正则表达式文法,在创建正则表达式的时候可以通过参数来选择。
文法 | 说明 |
---|---|
ECMAScript | ECMAScript正则表达式语法,默认选项 |
basic | 基础POSIX正则表达式语法 |
extended | 扩展POSIX正则表达式语法 |
awk |
awk工具的正则表达式语法 |
grep |
grep工具的正则表达式语法 |
egrep |
grep工具的正则表达式语法 |
不同的文法在表达上有一些不同,如果你原先已经很熟悉awk
或者egrep
文法的正则表达式,你可以直接使用它们。对于其他人来说,我们直接使用默认的ECMAScript文法即可(Javascript
的正则表达式也是使用ECMAScript文法)。
grep的全称是Global Regular Expression Print。这个名字是在提示我们,它本身与正则表达式的历史有着特定的联系。
C++ 中的 ECMAScript 正则表达式文法是 ECMA-262 文法,你可以点击链接查看详细内容。
下文中,将会 引用 保罗的酒吧:关于“文法”相关的已经在上文介绍过了。
剩下的还有几个说明如下:
值 | 效果 |
---|---|
icase | 以不考虑大小写进行字符匹配。 |
nosubs | 进行匹配时,将所有被标记的子表达式 exprexpr 当做非标记的子表达式 ?:expr?:expr 。不将匹配存储于提供的 std::regex_match 结构中,且 mark_count() 为零 |
optimize | 指示正则表达式引擎进行更快的匹配,带有令构造变慢的潜在开销。例如这可能表示将非确定 FSA 转换为确定 FSA 。 |
collate | 形如 “[a-b]” 的字符范围将对本地环境敏感。 |
multiline(C++17) | 若选择 ECMAScript 引擎,则指定^ 匹配行首,$ 应该匹配行尾。 |
这其中,第一个是我们最常用的。
- 示例:匹配文本中“regular expression”所有的单复数,并且不区分大小写。
- 思路:单词的首字母有些会大写,我们可以通过
[Rr]
来匹配大写或者小写的R字母,但实际上,使用icase
无疑会更方便。
代码示例:
#include
#include
#include
using namespace std;
int main() {
regex word_regex("regular expressions?", regex::icase);
ifstream file("./content.txt");
string line;
while(getline(file, line)) {
auto iter_begin = sregex_iterator(line.begin(),
line.end(),
word_regex);
auto iter_end = sregex_iterator();
for (auto iter = iter_begin; iter != iter_end; iter++) {
cout << iter->str() << endl;
}
}
return 0;
}
这段代码与前面的结构是一样的,我们最需要关注的可能就是下面这一行:
regex word_regex("regular expressions?", regex::icase);
通过std::regex::icase
我们指定了这个正则表达式是不区分大小写的。
另外还有一个值得注意的就是正则表达式末尾的...s?
,它意味着单词可能是单数或者复数,因此结尾的“s”可以出现0次或者1次。
这段代码输出如下:
regular expression
regular expressions
Regular expressions
匹配结果与分组
std::match_results
用来存储匹配结果。与迭代器类似,匹配结果也有四种类型:
类型 | 定义 |
---|---|
std::cmatch | std::match_results |
std::wcmatch | std::match_results |
std::smatch | std::match_results |
std::wsmatch | std::match_results |
当我们使用正则表达式时,我们的目标常常不单单是判断或者查找完整匹配的内容。而是需要捕获匹配结果中的子串。例如:我们不仅要匹配出日期,还要捕获日期中的年份,月份等信息。这个时候就要使用分组功能。
我们在介绍正则表达式特殊字符的时候,提到过圆括号(
和)
。它们的作用就是分组。当你在正则表达式中配对的使用圆括号时,就会形成一个分组,一个正则表达式中可以包含多个分组。分组通过编号0, 1, 2, …来区分。编号0的分组是匹配的整体,其他编号根据括号的顺序来确定。
这些分组最终可以在匹配完成之后,可以通过std::match_results
的API来获取。这些API如下表所示:
API | 说明 |
---|---|
empty | 检查匹配是否成功 |
size | 返回完成建立的结果状态中的匹配数 |
max_size | 返回子匹配的最大可能数量 |
length | 返回特定分组的长度 |
position | 分会特定分组首字符的位置 |
str | 返回特定分组的字符序列 |
operation[] | 返回指定的分组 |
prefix | 返回目标序列起始和完整匹配起始之间的分组 |
suffix | 返回完整匹配结果和目标序列结尾之间的分组 |
在C++中,分组叫做子匹配(sub_match)。std::sub_match 这个类型只有一个默认构造函数,通常你不会主动创建它,而是使用std::match_results
的接口来获取它的对象。
示例:查找出文本中所有的年代,并分离出世纪的部分和年份的部分。 思路:年代的格式是四位数字加上“s”作为后缀。我们可以通过分组的形式分离出两个部分。图示如下:
代码示例:
#include
#include
#include
using namespace std;
int main() {
regex word_regex(R"((\d{2})(\d{2})s)"); // ①
ifstream file("./content.txt");
string line;
while(getline(file, line)) {
auto iter_begin = sregex_iterator(line.begin(),
line.end(),
word_regex);
auto iter_end = sregex_iterator();
for (auto iter = iter_begin; iter != iter_end; iter++) {
cout << "Match content: " << iter->str(0) << ", "; // ②
cout << "group Size: " << iter->size() << endl; // ③
cout << "Century: " << iter->str(1) << ", "; // ④
cout << "length: " << iter->length(1) << ", ";
cout << "position: " << iter->position(1) << endl;
auto year = (*iter)[2]; // ⑤
cout << "Year: " << year.str() << ", ";
cout << "length: " << year.length() << ", ";
cout << "position: " << iter->position(2) << endl;
cout << endl;
}
}
return 0;
}
这段代码说明如下:
- 这个正则表达式请注意其中的圆括号
- 先打印匹配的字符串整体
- 所有的分组数量,应该是 2 + 1 = 3
- 打印出世纪的部分
- 获取编号2的分组,其类型是
sub_match
这段代码输出如下:
Match content: 1950s, group Size: 3
Century: 19, length: 2, position: 25
Year: 50, length: 2, position: 27
Match content: 1980s, group Size: 3
Century: 19, length: 2, position: 277
Year: 80, length: 2, position: 279
稍微深入一点的内容
同一个符号的不同含义
前面的表格中,我们看到了正则表达式的特殊字符。但需要进一步说明的是,这些特殊字符在不同的环境可能有着不同的含义。
例如,特殊字符-
只有在字符组[...]
内部才是元字符,否则它只能匹配普通的连字符符号。并且,即便在字符组内部,如果连字符是在开头,它依然是一个普通字符而不是表示一个范围。
相反的,问号?
和点号.
不在字符组内部的时候才是特殊字符。因此[?.]
中的这两个符号仅仅代表这两个字符自身。
还有,字符^
出现在字符组中的时候表示的是否定,例如:[a-z]
和[^a-z]
表示的是正好相反的字符集。但是当字符^
不是用在字符组中的时候,它是一个锚点,具体内容下文会说到。
量词的占有欲
还是以content.txt
的内容为基础,现在假设我们的目标是:找出所有双引号中的内容。
根据之前的知识,你可能很轻松就写出了下面这个正则表达式:
regex content_regex("\"(.+)\"");
- 两边的双引号通过反斜杠转义
- 待捕获的内容通过圆括号形成分组
- 双引号中可以是任意内容,因此使用
.+
但是当你运行程序的时候却发现它可能有点问题。它捕获的结果是:
"find" or "find and replace"
为什么?其实很简单,因为双引号本身也可以与.
匹配。上面这个正则表达式的含义是:匹配一个两端是双引号,中间是任意文字的内容。
当然,你马上想到一个改进方法那就是:将正则表达式圆括号中的.+
改为[^"]+
,它的含义是:一个或多个非双引号字符。这么做是可以的。但其实我们还有更好的做法。
我们再回头看一下原先的正则表达式,不考虑分组和转义,它可以写成:".+"
。其实我们知道下面这三个字符串都是与其匹配的:
"find"
"find and replace"
"find" or "find and replace"
而将整个文本交给正则表达式的时候,它找出了最长的那个串。可见,原先的正则表达式太过“贪婪”(greedy)。是的,量词在默认情况都是贪婪的。即:它们会尽可能多的占有内容。
那我们能不能控制量词让其尽可能少的占有内容,只要满足匹配要求就可以呢?
答案是肯定的,而且做法很简单:在量词的后面加上一个?
。即,将圆括号中.+
修改为.+?
即可。量词的默认形式称之为“匹配优先量词”,现在这种写法称之为“忽略优先量词”。
现在它找到的是下面两个匹配:
"find"
"find and replace"
小结一下:
- 匹配优先量词:
*
,+
,?
,{num, num}
- 忽略优先量词:
*?
,+?
,??
,{num, num}?
锚点
锚点是一类特殊的标记,它们不会匹配任何文本内容,而是寻找特定的标记。你可以简单理解为它是原先表达式的基础上增加了新的匹配条件。如果条件不满足,则无法完成匹配。
锚点主要分为三种:
- 行/字符串的起始位置:
^
,行/字符串的结束位置:$
- 单词边界:
\b
- 环视 ,见下文
例如:
- 正则表达式
^\d+
在字符串"123abc"
中能找到匹配,在字符串"abc123"
却找不到。 - 正则表达式
some\b
在字符串"some birds"
中能找到匹配,在字符串"sometimes wonderful"
中却找不到。
下面是代码示例:
#include
#include
using namespace std;
void findIn(const char* content, const char* reg_ex) {
cout << "Search '" << reg_ex << "' in '" << content << "': ";
smatch match;
string s(content);
regex reg(reg_ex);
if(regex_search(s, match, reg)) {
cout << match[0] << endl;
} else {
cout << "NOTHING" << endl;
}
}
int main() {
findIn("123abc", "^\\d+");
findIn("abc123", "^\\d+");
cout << endl;
findIn("some birds", "some\\b");
findIn("sometimes wonderful", "some\\b");
return 0;
}
它的输出如下:
Search '^\d+' in '123abc': 123
Search '^\d+' in 'abc123': NOTHING
Search 'some\b' in 'some birds': some
Search 'some\b' in 'sometimes wonderful': NOTHING
环视
现在假设我们有下面两个需求:
- 匹配出所有sometimes中的前四个字符“some”
- 匹配出所有的单词some,但是要排除掉“some birds”中的“some”
对于第一个问题,我们可以分两步:先找出所有的单词sometimes,然后取前四个字符。对于第二个问题,我们可以先找出所有的单词“some”,然后把后面是“birds”的丢掉。
以上的解法都是分两步完成。但实际上,借助环视(lookaround)我们可以一步就完成任务。
环视是对匹配位置的附加条件,只有条件满足时才能完成匹配。环视有:顺序(向右),逆序(向左),肯定和否定一共四种:
类型 | 正则表达式 | 匹配条件 |
---|---|---|
肯定顺序环视 | (?=...) |
子表达式能够匹配右侧文本 |
否定顺序环视 | (?!...) |
子表达式不能匹配右侧文本 |
肯定逆序环视 | (?<=...) |
子表达式能够匹配左侧文本 |
否定逆序环视 | (? |
子表达式不能匹配左侧文本 |
C++中的环视只支持顺序环视,不支持逆序环视。
环视说起来有些拗口,但看具体的例子就容易理解了:
#include
#include
using namespace std;
void isMatch(const char* content, const char* reg_ex) {
cout << "Is '" << reg_ex << "' match '" << content << "': ";
smatch match;
string s(content);
regex reg(reg_ex);
if(regex_search(s, match, reg)) {
cout << "YES" << endl;
} else {
cout << "NO" << endl;
}
}
int main() {
isMatch("sometimes", "(?=sometimes)some");
isMatch("something", "(?=sometimes)some");
cout << endl;
isMatch("some eggs", "(?!some birds)some");
isMatch("some birds", "(?!some birds)some");
return 0;
}
这段代码并不复杂所以就不多做说明,它的输出结果如下:
Is 'sometimes' match '(?=sometimes)some': YES
Is 'something' match '(?=sometimes)some': NO
Is 'some eggs' match '(?!some birds)some': YES
Is 'some birds' match '(?!some birds)some': NO
对于包含环视的正则表达式来说,环视之外的内容是匹配的主体,环视本身只是一个附件条件。(?=sometimes)
这个肯定顺序环视要求从这个位置开始,接下来的字符串必须是"sometimes"
才能完成匹配。(?!some birds)
这个否定顺序环视要是接下来的字符串一定不能是"some birds"
才能完成匹配。
为了进一步帮助你理解,我们以图示的方式将(?=sometimes)some
匹配"something"
的过程描述出来。
图示中,虚线的上面是待匹配的文本,下面是正则表达式。对于环视,我们可以将其环视条件和主体分开来看。我们以一个下标三角箭头表示当前匹配的搜索位置。
刚开始的时候,搜索的位置是第一个字符的前面:
接下来,搜索位置往后走一个字符:
这个过程可以一直进行,直到匹配完"some"
:
虽然正则表达式的主体"some"
完成了匹配,但是接下来环视的条件却无法满足,于是匹配失败:
但是,如果要匹配内容正好是"sometimes"
,则条件是满足的,于是就完成了匹配。
结束语
因为是入门的文章,所以本文中所举例的正则表达式都很简单。但实际应用的时候,我们常常会写出非常复杂的正则表达式。你可以点击这里浏览一些示例:Regular Expression Library。
复杂的正则表达式常常很难理解,你可能需要借助工具来帮助分析,下面两个工具也许能帮上忙:
- https://regex101.com
- https://www.debuggex.com
想要很好的掌握正则表达式,多使用多练习是最好的方法。想要深入学习正则表达式,Jeffrey E.F. Friedl的《精通正则表达式》可能是最好的选择。
参考资料与推荐读物
保罗的酒吧:Mastering Regular Expressions
- Modified ECMAScript regular expression grammar
- 9. Regular Expressions - The Open Group Base Specifications Issue 7, 2018 edition
- awk Regular Expressions - The Open Group Base Specifications Issue 7, 2018 edition
C++ Regular expressions library
其它
文章开源在 Github - blog-articles,点击 Watch 即可订阅本博客。 若文章有错误,请在 Issues 中提出,我会及时回复,谢谢。
如果您觉得文章不错,或者在生活和工作中帮助到了您,不妨给个 Star,谢谢。
(文章完)