C++与正则表达式入门


什么是正则表达式?

正则表达式是一组由字母和符号组成的特殊文本, 当你想要判断许多字符串是否符合某个特定格式;当你想在一大段文本中查找出所有的日期和时间;当你想要修改大量日志中所有的时间格式,在这些情况下,正则表达式都能帮上忙。

简单来说,正则表达式描述了一系列规则,通过这些规则,可以在字符串中找到相关的内容,规则使得搜索的能力更加强大。匹配的过程由正则表达式引擎完成。开发者通常不需要关心正则表达式引擎的实现细节,直接使用其提供的能力即可。

大家可以先想象你正在写一个应用, 然后你想设定一个用户命名的规则, 让用户名包含字符,数字,下划线和连字符,以及限制字符的个数,好让名字看起来没那么丑. 我们使用以下正则表达式来验证一个用户名:

learn-regex

以上的正则表达式可以接受 john_doe , john12_as . 但不匹配 Jo , 因为它包含了大写的字母而且太短了.

本文将以C++语言为例,介绍其中的正则表达式相关知识。



C++中正则表达式的API基本上都位于头文件中。

部分代码为了简化书写,都已经默认做了以下操作:

#include 
#include 

using namespace std;

入门示例

为了使大家有一个直观的感受,文章的开头先通过一些入门示例给大家一个直观的感受。在这个基础之上,再详细讲解其中的细节。

使用正则表达式的大致流程如下:首先你有一段需要处理的文本。这可能是一个字符串对象,也可能是一个文本文件,或者是一大堆日志。接下来你会有特定的目标,例如:找出文本中所有的时间和日期。这个时候你就需要根据可能的格式写出具体的正则表达式,例如,日期的格式是:2020-01-01,那么你的正则表达式可能是这样:\d{4}-\d{2}-\d{2}。(你现在不必纠结与这个正则表达式是什么意思,因为这是本文接下来要讲解的内容。)

有了正则表达式之后,你需要将你的文本和正则表达式交给正则表达式引擎 – 由C++语言(或者其他语言)提供。引擎会在文本中搜索到匹配的结果。这个结果的格式可能是包含了多个组,例如:你可能需要分离出年份和月份。有了引擎返回的结果之后,你就可以进一步处理了。

img

使用正则表达式的流程大体都是一致的,下面是最常见(其他形式大多为其变种)的三种使用方式。

匹配

匹配是判断给定的字符串是否符合某个正则表达式。例如:你想判断当前文本是否全部由数字构成。

下面是一段代码示例:

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; // ⑤

在这段代码中:

  1. 这是一个包含了数字和字母的字符串
  2. 这是一个只包含了数字的字符串
  3. 这是我们的正则表达式,它表示:有多个数字cpp
  4. 通过regex_match判断第一个字符串是否匹配,这里将返回false
  5. 通过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; // ⑤
  1. 这是一个包含了数字和字母的字符串
  2. 和前面一样的正则表达式
  3. 通过std::smatch来保存匹配的结果。除了std::smatch,还有std::cmatch也很常用。前者是以std::string的形式返回结果,后者是以const char*的形式返回结果。
  4. 通过regex_search函数搜索结果
  5. 打印出匹配的结果

这段代码输出如下:

ab123cdef contains digit: 123

替换

最后,使用正则表达式的还有一个常见功能是文本替换。很多的编辑器都有这样的功能。

例如,下图是我的Visual Studio编译器,在搜索替换文本的时候,可以使用正则表达式,这时搜索的能力就更加强大了。“Find:”部分可以通过正则表达式来描述待替换的字符串,“Replace:”部分填写替换的字符串。

image-20200723135813276

下面是在C++中使用正则表达式完成字符串替换的代码示例:

string s = "ab123cdef"; // ①
regex ex("\\d+");    // ②

string r = regex_replace(s, ex, "xxx"); // ③

cout << r << endl; // ④
  1. 仍然是前面这个字符串
  2. 仍然是同样的正则表达式
  3. 通过regex_replace完成替换
  4. 通过cout输出结果

最终输出的字符串如下:

abxxxcdef

通过上面的三个示例我们看到,regex_matchregex_searchregex_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;
}

这段代码说明如下:

  1. 这个正则表达式请注意其中的圆括号
  2. 先打印匹配的字符串整体
  3. 所有的分组数量,应该是 2 + 1 = 3
  4. 打印出世纪的部分
  5. 获取编号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

环视

现在假设我们有下面两个需求:

  1. 匹配出所有sometimes中的前四个字符“some”
  2. 匹配出所有的单词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"的过程描述出来。

图示中,虚线的上面是待匹配的文本,下面是正则表达式。对于环视,我们可以将其环视条件和主体分开来看。我们以一个下标三角箭头表示当前匹配的搜索位置。

刚开始的时候,搜索的位置是第一个字符的前面:

接下来,搜索位置往后走一个字符:

img

这个过程可以一直进行,直到匹配完"some"

img

虽然正则表达式的主体"some"完成了匹配,但是接下来环视的条件却无法满足,于是匹配失败:

img

但是,如果要匹配内容正好是"sometimes",则条件是满足的,于是就完成了匹配。

img

结束语

因为是入门的文章,所以本文中所举例的正则表达式都很简单。但实际应用的时候,我们常常会写出非常复杂的正则表达式。你可以点击这里浏览一些示例: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,谢谢。

(文章完)