C++與正規表示式入門

RioTian發表於2020-07-23

什麼是正規表示式?

正規表示式是一組由字母和符號組成的特殊文字, 當你想要判斷許多字串是否符合某個特定格式;當你想在一大段文字中查詢出所有的日期和時間;當你想要修改大量日誌中所有的時間格式,在這些情況下,正規表示式都能幫上忙。

簡單來說,正規表示式描述了一系列規則,通過這些規則,可以在字串中找到相關的內容,規則使得搜尋的能力更加強大。匹配的過程由正規表示式引擎完成。開發者通常不需要關心正規表示式引擎的實現細節,直接使用其提供的能力即可。

大家可以先想象你正在寫一個應用, 然後你想設定一個使用者命名的規則, 讓使用者名稱包含字元,數字,下劃線和連字元,以及限制字元的個數,好讓名字看起來沒那麼醜. 我們使用以下正規表示式來驗證一個使用者名稱:

learn-regex

以上的正規表示式可以接受 john_doe , john12_as . 但不匹配 Jo , 因為它包含了大寫的字母而且太短了.

本文將以C++語言為例,介紹其中的正規表示式相關知識。


C++中正規表示式的API基本上都位於<regex>標頭檔案中。

部分程式碼為了簡化書寫,都已經預設做了以下操作:

#include <iostream>
#include <regex>

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 文法,你可以點選連結檢視詳細內容。

下文中,將會 引用 保羅的酒吧:關於正規表示式的解釋

Raw string literal (原始字串)

在程式碼中寫字串有時候是比較麻煩的,因為很多字元需要通過反斜槓轉義。當有多個反斜槓連在一起時,就很容易寫錯或者理解錯了。

當通過字串來寫正規表示式時,這個問題就更嚴重了。因為正規表示式本身也有一些字元需要轉義。例如,對於這樣一個字串 "('(?:[^\\\\']|\\\\.)*'|\"(?:[^\\\\\"]|\\\\.)*\")|" 大部分人恐怕很難一眼看出其含義了。

在正規表示式很複雜的時候,推薦大家使用 Raw string literal 來表達。這種表示式是告訴編譯器:這裡的內容是純字串,因此不再需要增加反斜槓來轉義特殊字元。

Raw string literal 的格式如下:

R"delimiter(raw_characters)delimiter"

這其中:

  • delimiter是可選的分隔符,通常不用寫
  • raw_characters是具體的字串

也就是說,R"(content)"中的content是你需要的字串本身。

下面是一個程式碼示例:

string s = R"("\w\\w\\\w)";
cout << s << endl;

它將輸出:

"\w\\w\\\w

可以看到,這裡的雙引號和反斜槓不會被解釋成轉義字元,而是當成字串內容本身,因此會原樣輸出。這樣就減少了轉義字元的複雜度,於是更容易理解了。

特殊字元

正規表示式本身定義了一些特殊的字元,這些字元有著特殊的含義。它們如下表所示。

字元 說明
. 匹配任意字元
[ 字元類的開始
] 字元類的結束
{ 量詞重複數開始
} 量詞重複數結束
( 分組開始
) 分組結束
\ 轉義字元
\ 轉義字元自身
* 量詞,0個或者多個
+ 量詞,1個或者多個
? 量詞,0個或者1個
|
^ 行開始;否定
$ 行結束
\n 換行
\t Tab符
\xhh hh表示兩位十六進展表示的Unicode字元
\xhhhh hhhh表示四位十六進位制表示的Unicode字串

這些字元並不少,剛開始接觸可能記不住,但隨著下文的講解,相信你會逐漸熟悉它們。

字元類

字元類,顧名思義:是對字元的分類。

例如:1234567890這些都屬於數字字元類。除此之外,還有其他的分類,它們如下表所示:

字元類 簡寫 說明
[[:alnum:]] 字母和數字
[_[:alnum:]] \w 字母,數字以及下劃線
[^_[:alnum:]] \W 非字母,數字以及下劃線
[[:digit:]] \d 數字
[[1]] \D 非數字
[[:space:]] \s 空白字元
[[2]] \S 非空白字元
[[:lower:]] 小寫字母
[[:upper:]] 大寫字母
[[:alpha:]] 任意字母
[[:blank:]] 非換行符的空白字元
[[:cntrl:]] 控制字元
[[:graph:]] 圖形字元
[[:print:]] 可列印字元
[[:punct:]] 標點字元
[[:xdigit:]] 十六進位制的數字字元

這裡我們可以看到:

  • 字元類通過[]作為標識,因此這兩個字元是正規表示式的中的特殊字元。如果是想使用這兩個字元本身,需要對它們進行轉義。
  • []內部,通過[:xxx:]來描述字元類的名稱。
  • []中可以通過^表示否定,即:字元類的反面。
  • 字母,數字和空白字元由於這些字元類非常常用,因此它們有簡寫的方法。簡寫使得正規表示式更加簡潔,但表達的含義是一樣的。

接下來我們看一個程式碼示例:

#include <iostream>
#include <regex>

using namespace std;

static void search_string(const string& str, const regex& reg_ex) { // ①
    for (string::size_type i = 0; i < str.size() - 1; i++) {
        auto substr = str.substr(i, 1);
        if (regex_match(substr, reg_ex)) {
            cout << substr;
        }
    }
}

static void search_by_regex(const char* regex_s,
    const string& s) { // ②
    regex reg_ex(regex_s);
    cout.width(12); // ③
    cout << regex_s << ": \""; // ④
    search_string(s, reg_ex);  // ⑤
    cout << "\"" << endl;
}

int main() {
    string s("_AaBbCcDdEeFfGg12345 \t\n!@#$%"); // ⑥

    search_by_regex("[[:alnum:]]", s);          // ⑦
    search_by_regex("\\w", s);                  // ⑧
    search_by_regex(R"(\W)", s);                // ⑨
    search_by_regex("[[:digit:]]", s);          // ⑩
    search_by_regex("[^[:digit:]]", s);         // ⑪
    search_by_regex("[[:space:]]", s);          // ⑫
    search_by_regex("\\S", s);                  // ⑬
    search_by_regex("[[:lower:]]", s);          // ⑭
    search_by_regex("[[:upper:]]", s);
    search_by_regex("[[:alpha:]]", s);          // ⑮
    search_by_regex("[[:blank:]]", s);          // ⑯
    search_by_regex("[[:graph:]]", s);          // ⑰
    search_by_regex("[[:print:]]", s);          // ⑱
    search_by_regex("[[:punct:]]", s);          // ⑲
    search_by_regex("[[:xdigit:]]", s);         // ⑳

    return 0;
}

這段程式碼稍微有些長,但還是比較好理解的。

  1. 這裡定義了一個函式,它接受一個字串和一個正規表示式作為輸入。該函式遍歷字串,每次取出一個字元然後用正規表示式進行匹配,如果匹配上,則輸出該字元。逐個遍歷字串的方式並不是非常好,在後文中我們將看到更好的方法。
  2. search_by_regex將呼叫search_string進行字元的匹配。
  3. cout.width(12); 是為了控制輸出格式的縮排。
  4. 先列印出正規表示式,然後列印冒號和雙引號。將匹配的內容放在雙引號中是為了更容易辨識。
  5. 呼叫search_string進行字元的匹配。
  6. 這是我們待匹配的字串,它其中包含了各種型別的字元。
  7. [[:alnum:]]匹配字母和數字類字元。
  8. \w[_[:alnum:]]的簡寫方式,它與字元數字的區別在與:它還包含了_。當通過字串定義正規表示式時,反斜槓需要轉義。
  9. R"(\W)"是一個Raw string literal,因此,這裡的反斜槓不再需要轉義。
  10. [[:digit:]]匹配數字類字元。
  11. [^[:digit:]]是非數字類正規表示式,它與⑩正好相反。
  12. [[:space:]]匹配空白類字元,該表示式將包含換行符。
  13. \S是非空白類字元類。
  14. [[:lower:]]小寫字母,[[:upper:]]下一行是大寫字母。
  15. [[:alpha:]]匹配所有字母字元。
  16. [[:blank:]]是空白字元類,它與[[:space:]]的區別是:它不包含換行符。
  17. [[:graph:]]是圖形類字元。
  18. [[:print:]]是可列印字元。
  19. [[:punct:]]是標點符號字元。
  20. [[:xdigit:]]是十六進位制的數字字元。

該程式的輸出如下:

 [[:alnum:]]: "AaBbCcDdEeFfGg12345"
          \w: "_AaBbCcDdEeFfGg12345"
          \W: "
!@#$"
 [[:digit:]]: "12345"
[^[:digit:]]: "_AaBbCcDdEeFfGg
!@#$"
 [[:space:]]: "
"
          \S: "_AaBbCcDdEeFfGg12345!@#$"
 [[:lower:]]: "abcdefg"
 [[:upper:]]: "ABCDEFG"
 [[:alpha:]]: "AaBbCcDdEeFfGg"
 [[:blank:]]: "
"
 [[:graph:]]: "_AaBbCcDdEeFfGg12345!@#$"
 [[:print:]]: "_AaBbCcDdEeFfGg12345 !@#$"
 [[:punct:]]: "_!@#$"
[[:xdigit:]]: "AaBbCcDdEeFf12345"

請仔細看一下這個輸出,並確認與你的認知是否一致。這裡的有些字元類包含了換行符,因此在輸出的結果中也是換行的。

重複

上面的示例中,我們一次只匹配了一個字元。這樣做效率是很低的。

在很多時候,我們當然是想一次性匹配出一個完整的字串。例如:一個手機號碼。這種情況下,其實是多個數字字元的重複。

下面就是在正規表示式中描述重複的方式。它們通常跟在字元類的後面,描述該字元出現多次。

字元 說明
{n} 重複n次
{n,} 重複n或更多次
{n,m} 重複[n ~ m]次
* 重複0次或多次,等同於{0,}
+ 重複1次或多次,等同於{1,}
? 重複0次或1次,等同於{0,1}

知道重複的方法之後,正規表示式的查詢能力就更強大了。看一下下面這個程式碼示例:

#include <iostream>
#include <regex>

using namespace std;

static void search_by_regex(const char* regex_s,
                            const string& s) { // ①
  regex reg_ex(regex_s);
  smatch match_result; // ②
  cout.width(14); // ③
  if (regex_search(s, match_result, reg_ex)) { // ④
    cout << regex_s << ": \"" << match_result[0] << "\"" << endl; // ⑤
  }
}

int main() {
  string s("_AaBbCcDdEeFfGg12345!@#$% \t"); // ⑥

  search_by_regex("[[:alnum:]]{5}", s);       // ⑦
  search_by_regex("\\w{5,}", s);              // ⑧
  search_by_regex(R"(\W{3,5})", s);           // ⑨
  search_by_regex("[[:digit:]]*", s);         // ⑩
  search_by_regex(".+", s);                   // ⑪
  search_by_regex("[[:lower:]]?", s);         // ⑫

  return 0;
}

在這段程式碼中:

  1. 這裡定義了一個函式,它接受一個正規表示式和字串。
  2. match_result用來儲存查詢的結果。
  3. 設定輸出格式,為了讓輸出對齊。
  4. 通過regex_search在字串中查詢匹配字元。
  5. 輸出匹配的結果。
  6. 待匹配的字串。
  7. [[:alnum:]]{5}是指:字元或者數字出現5次。
  8. \\w{5,}是指:字母,數字或者下劃線出現5次或更多次。
  9. R"(\W{3,5})"是指:非字母,數字或者下劃線出現3次到5次。
  10. [[:digit:]]*是指:數字出現任意多次。
  11. .+是指:任意字元出現至少1次。
  12. [[:lower:]]?是指:小寫字母出現0次或者1次。

該程式輸出如下:

[[:alnum:]]{5}: "AaBbC"
        \w{5,}: "_AaBbCcDdEeFfGg12345"
       \W{3,5}: "!@#$%"
  [[:digit:]]*: ""
            .+: "_AaBbCcDdEeFfGg12345!@#$% 	"
  [[:lower:]]?: ""

正規表示式程式設計

接下來我們會看到更多的示例。同時,也會看到C++正規表示式API的更多功能。

為了便於下文示例的講解,我們以維基百科上對於正規表示式的介紹文字為基礎。

A regular expression, regex or regexp ((sometimes called a rational expression)) is a sequence of characters that define a search pattern. Usually such patterns are used by string searching algorithms for “find” or “find and replace” operations on strings, or for input validation. It is a technique developed in theoretical computer science and formal language theory.

The concept arose in the 1950s when the American mathematician Stephen Cole Kleene formalized the description of a regular language. The concept came into common use with Unix text-processing utilities. Different syntaxes for writing regular expressions have existed since the 1980s, one being the POSIX standard and another, widely used, being the Perl syntax.

Regular expressions are used in search engines, search and replace dialogs of word processors and text editors, in text processing utilities such as sed and AWK and in lexical analysis. Many programming languages provide regex capabilities either built-in or via libraries.

我們將這段文字儲存在名稱為content.txt的文字檔案中。下面幾個示例會在這個文字上操作。

迭代器

在上文中,為了從字串中查詢出所有匹配的字元,我們的做法是遍歷原始字串的每一個子字串來進行查詢,這樣做很明顯效率很低。更好的做法當然是使用迭代器。

正規表示式迭代器一共有四種,分別對應了是否是寬字元,是否是字串型別:

型別 定義
cregex_iterator regex_iterator<const char*>
wcregex_iterator regex_iterator<const wchar_t*>
sregex_iterator regex_iteratorstd::string::const_iterator
wsregex_iterator regex_iteratorstd::wstring::const_iterator

在一大段文字中查詢所有匹配的目標,這是一個非常常見的需求。而迭代器正好滿足這一需求,它會依次返回它從文字中找到的匹配內容。

  • 示例:統計出文字中一共出現了多個單詞。
  • 思路:組成單詞的字母可以使用[[:alpha:]]字元類來表達,一個單詞至少有一個字母,因此這個正規表示式可以寫成:[[:alpha:]]+。然後藉助迭代器便可以統計出總數量。

程式碼示例如下:

#include <fstream>
#include <iostream>
#include <regex>

using namespace std;

int main() {
  regex word_regex("[[:alpha:]]+"); // ①

  ifstream file("./content.txt"); // ②
  string line;
  int word_count = 0;
  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++) { // ⑥
      word_count++;  // ⑦
      // cout << iter->str() << endl; // ⑧
    }
  }
  cout << "It contains " << word_count << " words" << endl; // ⑨

  return 0;
}

這段程式碼的說明如下:

  1. 匹配單詞的正規表示式
  2. 通過ifstream讀取文字檔案
  3. 依次讀取文字檔案中的每一行
  4. 通過正規表示式迭代器從文字行的逐個匹配
  5. 迭代器的末尾
  6. 迭代器遍歷
  7. 每遇到一個匹配進行一次計數
  8. 如果需要,可以輸出匹配的內容

這段程式碼輸出如下:

It contains 153 words

接下來的幾個程式碼示例的主體結構和這裡會很相似,我們總是先開啟文字檔案,然後讀取每一行來進行處理。

正規表示式選項

前面的示例中我們已經看到,通過std::regex並傳遞字串就可以構造正規表示式物件。實際上,除了std::regex,還有寬字元版本的std::wregex。它們都源自std::basic_regex

型別 定義
regex basic_regex
wregex basic_regex<wchar_t>

在建立正規表示式物件的時候,除了描述規則本身的字串之外,還可以傳遞一個flag_type型別的引數,該引數的值定義在std::regex_constants::syntax_option_type中。它們中與“文法”相關的已經在上文介紹過了。

剩下的還有幾個說明如下:

效果
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 <fstream>
#include <iostream>
#include <regex>

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<const char*>
std::wcmatch std::match_results<const wchar_t*>
std::smatch std::match_resultsstd::string::const_iterator
std::wsmatch std::match_resultsstd::wstring::const_iterator

當我們使用正規表示式時,我們的目標常常不單單是判斷或者查詢完整匹配的內容。而是需要捕獲匹配結果中的子串。例如:我們不僅要匹配出日期,還要捕獲日期中的年份,月份等資訊。這個時候就要使用分組功能。

我們在介紹正規表示式特殊字元的時候,提到過圓括號()。它們的作用就是分組。當你在正規表示式中配對的使用圓括號時,就會形成一個分組,一個正規表示式中可以包含多個分組。分組通過編號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 <fstream>
#include <iostream>
#include <regex>

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 <iostream>
#include <regex>

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 <iostream>
#include <regex>

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

複雜的正規表示式常常很難理解,你可能需要藉助工具來幫助分析,下面兩個工具也許能幫上忙:

想要很好的掌握正規表示式,多使用多練習是最好的方法。想要深入學習正規表示式,Jeffrey E.F. Friedl的《精通正規表示式》可能是最好的選擇。

參考資料與推薦讀物



  1. :digit: ↩︎

  2. :space: ↩︎

相關文章