跳转到内容

字符串本地化与正则表达式

本章首先讨论本地化(localization)。借助它,你可以编写能够针对全球不同地区进行本地化的软件。一个正确完成本地化的应用,会按照某个国家或地区的规则,以合适的格式显示数字、日期、货币等信息。

本章后半部分介绍正则表达式库。它让你能够轻松地在字符串上执行模式匹配。它不仅能搜索满足给定模式的子串,还能验证、解析和变换字符串。正则表达式非常强大。我建议你使用它们,因为与手写自己的字符串处理代码相比,它们更不容易出错。

当你在学习如何使用 C 或 C++ 编程时,把一个字符看成一个 byte,并把所有字符都当成 American Standard Code for Information Interchange(ASCII)字符集成员,这样想是有帮助的。ASCII 是一个 7-bit 集合,通常存放在 8-bit 的 char 类型中。但在现实中,有经验的 C++ 程序员都知道,成功的程序会在世界各地被使用。即使你一开始并没有面向国际用户来编写程序,你也不应该让自己在将来失去对软件进行本地化,或者让软件具备 locale 感知能力的可能性。

把字符看成一个 byte 的问题在于,并不是所有语言,或者说所有字符集,都能用 8 bit、也就是 1 byte 完整表示。C++ 内建了一个名为 wchar_t 的类型,用来保存宽字符。像日语、阿拉伯语这类包含非 ASCII(US)字符的语言,都可以在 C++ 中借助 wchar_t 表示。不过,C++ 标准并没有规定 wchar_t 的大小。有些编译器使用 16 bit,而有些使用 32 bit。多数时候,它会和底层操作系统原生 Unicode 字符类型的大小保持一致。要编写跨平台代码,就不能安全地假定 wchar_t 一定是某个特定大小。

如果你的程序有 任何 可能被用在非西方字符集环境中(提示:确实有!),那你就应该从一开始使用宽字符。处理 wchar_t 时,字符串字面量和字符字面量都要加上前缀字母 L,以表明应使用宽字符编码。例如,要把一个 wchar_t 字符初始化为字母 m,写法如下:

wchar_t myWideCharacter { L'm' };

你喜欢的大多数类型与类几乎都有宽字符版本。宽字符版的 string 类叫做 wstring。这种“前缀字母 w”模式同样适用于 stream。宽字符文件输出 stream 用 wofstream,输入则用 wifstream。这些类名的发音乐趣(woof-stream? whiff-stream?)本身,就足以成为让程序具备 locale 感知能力的理由!关于 stream,本书会在第 13 章“揭开 C++ I/O 的面纱”中详细讨论。

coutcincerrclog 也都有宽字符版本,分别叫做 wcoutwcinwcerrwclog。使用方式与非宽字符版本没有区别:

wcout << L"I am a wide-character string literal." << endl;

print()println() 不支持 wchar_t 字符串字面量,不过它们支持本章后面将讨论的 UTF-8 字符串字面量。另一方面,format() 确实支持宽字符串:

wcout << format(L"myWideCharacter is {}", myWideCharacter) << endl;

宽字符是一个很大的进步,因为它们为表示单个字符提供了更大的空间。下一步,就是搞清楚这块空间到底如何使用。在宽字符字符集中,和 ASCII 一样,字符也用数字表示,只不过这些数字现在叫 code point。唯一的区别在于,这些数字不再需要局限于 8 bit。字符到 code point 的映射也大得多,因为它除了覆盖英语程序员熟悉的字符之外,还能处理许多其他字符集。

Universal Character Set(UCS,由国际标准 ISO 10646 定义)和 Unicode 都是标准化的字符集合。它们都用一个明确无歧义的名字和一个 code point 来标识字符。两套标准中,字符和对应数字是相同的。在本书写作时,Unicode 的最新版本是 15,共定义了 149,186 个字符。UCS 与 Unicode 都有各自具体的 encoding,可用于表示特定 code point。这一点非常重要:code point 只是一个数字;而 encoding 则规定如何把这个数字表示为一个或多个 byte。例如,UTF-8 就是一种 Unicode encoding,它用 1 到 4 个 8-bit byte 编码 Unicode 字符。UTF-16 把 Unicode 字符编码为 1 个或 2 个 16-bit 值,而 UTF-32 则总是使用整整 32 bit。

不同应用可以使用不同的 encoding。不幸的是,正如本章前面提到的,C++ 标准并没有规定宽字符(wchar_t)的大小。在 Windows 上它是 16 bit,而在其他平台上它可能是 32 bit。在跨平台代码中使用宽字符做字符编码时,你必须意识到这一点。为帮助解决这个问题,C++ 还提供了其他字符类型:char8_tchar16_tchar32_t。下面的列表概述了可用字符类型:

  • char 存储 8 bit。它可以用来保存 ASCII 字符,也可以作为保存 UTF-8 编码 Unicode 字符的基本构件;在 UTF-8 中,一个 Unicode 字符最多由 4 个 char 编码。
  • charx_t 至少存储 x bit,其中 x 可以是 8、16 或 32。它可以作为 UTF-x 编码 Unicode 字符的基本构件:一个 Unicode 字符最多由 4 个 char8_t、最多 2 个 char16_t,或者 1 个 char32_t 来编码。
  • wchar_t 存储一个宽字符,其大小与编码都由编译器决定。

wchar_t 相比,使用 charx_t 类型的好处在于,标准为 charx_t 类型提供了与编译器无关的最小大小保证;而 wchar_t 没有这样的最小大小保证。

字符串字面量可以通过前缀变成特定类型。完整支持的字符串前缀如下:

  • u8 使用 UTF-8 编码的 char8_t 字符串字面量
  • u 使用 UTF-16 编码的 char16_t 字符串字面量
  • U 使用 UTF-32 编码的 char32_t 字符串字面量
  • L 使用编译器决定编码的 wchar_t 字符串字面量

这些字符串字面量都可以与原始字符串字面量前缀 R 组合使用;R 已在第 2 章“使用字符串与字符串视图”中讨论过。示例如下:

const char8_t* s1 { u8R"(Raw UTF-8 string literal)" };
const wchar_t* s2 { LR"(Raw wide string literal)" };
const char16_t* s3 { uR"(Raw UTF-16 string literal)" };
const char32_t* s4 { UR"(Raw UTF-32 string literal)" };

在非原始字符串字面量中,你可以用几种不同的转义序列插入特定的 Unicode code point。下表概述了可用选项。最后一列展示的是上标字符 ² 的编码方式。

转义序列说明示例:2
\nnn1 到 3 位八进制数字\262
\o{n…}任意数量的八进制数字\o{262}
\xn…任意数量的十六进制数字\xB2\x00B2
\x{n…}任意数量的十六进制数字\x{B2}\x{00B2}
\unnnn4 位十六进制数字\u00B2
\u{n…}任意数量的十六进制数字\u{B2}\u{00B2}
\Unnnnnnnn8 位十六进制数字\U000000B2
\N{name}Universal character name\N{SUPERSCRIPT TWO}

C++23 引入的 \o{n…}\x{n…}\u{n…} 写法,在字符串字面量的下一个字符恰好又是合法八进制或十六进制数字时尤其有用,可以避免歧义。对于 \N{name} 这种写法,其中的名字必须是该字符的官方 Unicode 名称;你可以在任意 Unicode 字符参考资料中查到。

下面给出更多示例,它们都表示公式 π r²。字符 π 的编码是 3C0,上标字符 ² 的编码是 B2

const char8_t* formula1 { u8"\x3C0 r\xB2" };
const char8_t* formula2 { u8"\u03C0 r\u00B2" };
const char8_t* formula3 { u8"\N{GREEK SMALL LETTER PI} r\N{SUPERSCRIPT TWO}" };

除了字符串字面量之外,字符字面量也可以通过前缀变成特定类型。支持的前缀有 u8uUL,例如:u'a'U'a'L'a'u8'a'

除了 std::string 类,标准还支持 wstringu8stringu16stringu32string。它们定义如下:

  • using string = basic_string<char>;
  • using wstring = basic_string<wchar_t>;
  • using u8string = basic_string<char8_t>;
  • using u16string = basic_string<char16_t>;
  • using u32string = basic_string<char32_t>;

类似地,标准库还提供 std::string_viewwstring_viewu8string_viewu16string_viewu32string_view,它们都建立在 basic_string_view 之上。

多字节字符串(multibyte string)是指使用与 locale 相关的 encoding,把一个字符表示成一个或多个 byte 的字符串。locale 会在本章稍后讨论。多字节字符串可能使用 Unicode encoding,也可能使用其他编码,例如 Shift-JIS、EUC-JP 等。标准库提供了一些转换函数,可在 char8_t/char16_t/char32_t 与 multibyte string 之间互相转换:mbrtoc8()c8rtomb(),以及 mbrtoc16()c16rtomb()mbrtoc32()c32rtomb()

遗憾的是,char8_tchar16_tchar32_t 的支持并没有进一步扩展太多。确实存在一些转换类(本章后面会提到),但例如并不存在类似 coutcinprintln()format() 等直接支持这些字符类型的版本;这使得把这类字符串打印到控制台,或者从用户输入中读取它们,都变得很困难。如果你想对这类字符串做更多事情,就得借助第三方库。International Components for Unicode(ICU)就是一个著名的库,它为应用提供 Unicode 与 globalization 支持。(见 icu-project.org。)

C++23 在这方面稍微改进了一点。它允许用 u8 UTF-8 字符串字面量来初始化 const charconst unsigned char 类型的数组,而像 std::format()print() 这样的函数也支持 const char[]。例如,下面这段代码用一个 UTF-8 字符串字面量初始化 const char[] 数组,然后使用 println() 输出它。如果你的环境已正确配置以处理日语字符,那么输出将会是日语版的 “Hello world”。

const char hello[] { u8"こんにちは世界" };
println("{}", hello);

如果你像下面这样把 char[] 换成 char8_t[],则会得到编译错误,因为 println() 不理解 char8_t 类型。

const char8_t hello[] { u8"こんにちは世界" };
println("{}", hello); // 错误:无法编译!

本地化的一个关键方面是:你永远不应把任何原生语言的字符串字面量直接写进源代码中,除非那只是给开发者看的调试字符串。在 Microsoft Windows 应用中,这通常通过把应用所需的全部字符串放进 STRINGTABLE 资源来完成。其他大多数平台也都有类似能力。如果你需要把应用翻译成另一种语言,那么理论上只需要翻译这些资源,而不需要改动源代码。本质上,也已经有现成工具可以帮助你完成这一翻译流程。

为了让你的源代码真正可本地化,你还不应该用多个字符串字面量去拼句子,哪怕这些单独字面量本身都可以被本地化。下面就是一个例子:

unsigned n { 5 };
wstring filename { L"file1.txt" };
wcout << n << L" bytes read from " << filename << endl;

这条语句无法被本地化到例如德语,因为那需要重新排列词序。书中的德语版本示例如下:

wcout << n << L" Bytes aus " << filename << L" gelesen" << endl;

要确保这类字符串能够被正确本地化,你可以把它写成下面这种形式:

vprint_unicode(loadResource(IDS_TRANSFERRED), make_format_args(n, filename));

IDS_TRANSFERRED 是字符串资源表中的一个条目名。对于英语版本,IDS_TRANSFERRED 可以被定义为 “{0} bytes read from {1}”;而德语版本则可以被定义为 “{0} Bytes aus {1} gelesen”。loadResource() 函数负责加载给定名称的字符串资源,而 vprint_unicode()(见第 2 章)会把 {0} 替换成 n 的值,把 {1} 替换成 filename 的值。

字符集只是不同国家在数据表示方式上的差异之一。即便是字符集相近的国家,比如 Great Britain 与 United States,在表示日期、货币等某些数据时,仍然存在差异。

在标准 C++ 中,用来描述某一整套文化参数信息的机制叫做 locale。locale 中的一个独立组成部分,例如日期格式、时间格式、数字格式等,被称为 facet。US English 就是一个 locale 的例子;显示日期所用的格式则是一个 facet 的例子。有些内建 facet 是所有 locale 都共有的。C++ 也提供了自定义或新增 facet 的方式。

有一些第三方库可以让 locale 的使用变得更容易。一个例子是 boost.localeboost.org),它可以使用 ICU 作为后端,从而支持 collations、conversions、整串字符串转大写(而不是逐字符转大写)等等。

在使用 I/O stream 时,数据会按照某个特定 locale 的规则进行格式化。locale 是可以附加到 stream 上的对象,它们定义在 <locale> 中。locale 名称由实现决定。POSIX 标准的写法,是把语言和地区写成两个由下划线连接的两字母片段,并可选地附带编码。例如,美国英语的 locale 是 en_US,而英国英语的 locale 是 en_GB。使用 Japanese Industrial Standard 编码、在日本使用的日语 locale 则是 ja_JP.jis

Windows 上的 locale 名称可以有两种格式。推荐格式与 POSIX 类似,只不过使用的是连字符而不是下划线。另一种较旧格式如下,其中方括号中的部分都是可选的:

lang[_country_region[.code_page]]

下面这张表展示了一些 POSIX、推荐的 Windows 格式,以及旧版 Windows locale 格式的例子:

语言POSIXWindowsWindows 旧格式
美国英语en_USen-USEnglish_United States
英国英语en_GBen-GBEnglish_Great Britain

大多数操作系统都提供一种机制,用来确定用户所定义的 locale。在 C++ 中,你可以向 std::locale 构造函数传入空字符串,从用户环境创建一个 locale。一旦这个对象创建出来,你就可以用它查询当前 locale 信息,并在必要时据此做出程序上的决策。

std::locale::global() 函数可以用给定 locale 替换应用中的全局 C++ locale。std::locale 的默认构造函数则会返回这个全局 locale 的一个副本。不过需要记住,使用 locale 的标准库对象——例如 cout 这样的 stream——会在构造时保存当时全局 locale 的一个副本。之后再改变全局 locale,并不会影响那些早在此前就已经创建好的对象。如果有需要,你可以在 stream 上调用 imbue() 成员函数(下一节会讲)来在构造之后改变它们所使用的 locale。

下面是一个例子。它先用默认 locale 输出一个数字,再把全局 locale 改成美国英语,然后再次输出同一个数字:

void print()
{
stringstream stream;
stream << 32767;
println("{}", stream.str());
}
int main()
{
print();
locale::global(locale { "en-US" }); // POSIX 下为 "en_US"
print();
}

输出如下:

32767
32,767

下面的代码演示了如何通过在 stream 上调用 imbue() 成员函数,让该 stream 使用用户 locale。结果就是:所有输出到 cout 的数据,都会按照用户环境的格式化规则来格式化:

cout.imbue(locale { "" });
cout << "User's locale: " << 32767 << endl;

这意味着,如果你的系统 locale 是 English United States,那么输出数字 32767 时会显示成 32,767;但如果你的系统 locale 是 Dutch Belgium,那么同一个数字则会显示为 32.767。

默认 locale 是 classic/neutral locale,而不是用户 locale。classic locale 使用 ANSI C 约定,其名称为 C。classic C locale 与 US English 类似,但仍存在一些细微差异。例如,数字不会带任何标点分隔。

cout.imbue(locale { "C" });
cout << "C locale: " << 32767 << endl;

这段代码的输出如下:

C locale: 32767

下面这段代码手动设定为美国英语 locale,因此数字 32767 会按照美国英语的标点规则格式化,而与系统 locale 无关:

cout.imbue(locale { "en-US" }); // POSIX 下为 "en_US"
cout << "en-US locale: " << 32767 << endl;

输出如下:

en-US locale: 32,767

默认情况下,std::print()println() 使用 C locale。例如,下面这段代码会输出 32767:

println("println(): {}", 32767);

你可以指定 L 格式说明符,这样就会使用全局 locale。

println("println() using global locale: {:L}", 32767);

std::format() 同样支持 locale。方法是使用 L 格式说明符,并且可选地将一个 locale 作为第一个参数传给 format()。当使用 L 格式说明符且向 format() 传入了 locale 时,就会使用该 locale 来格式化;如果使用了 L 格式说明符但没有向 format() 传入 locale,则会使用全局 locale。例如,下面这段代码会按照英语格式规则打印 32,767:

cout << format(locale { "en-US" }, "format() with en-US locale: {:L}", 32767);

locale 对象允许你查询该 locale 的信息。例如,下面的程序创建了一个与用户环境匹配的 localename() 成员函数用于得到一个描述该 locale 的 C++ string;随后,代码对这个 string 对象调用 find() 成员函数来查找给定子串,若未找到则返回 string::npos。代码会同时检查 Windows 名称与 POSIX 名称,并根据 locale 是否看起来像 US English,打印两条消息中的其中一条。

locale loc { "" };
if (loc.name().find("en_US") == string::npos &&
loc.name().find("en-US") == string::npos) {
println("Welcome non-US English speaker!");
} else {
println("Welcome US English speaker!");
}

<locale> 包含以下字符分类函数:std::isspace()isblank()iscntrl()isupper()islower()isalpha()isdigit()ispunct()isxdigit()isalnum()isprint()isgraph()。它们都接收两个参数:待分类字符,以及用于分类的 locale。不同字符类的精确含义,会在本章后面的正则表达式部分继续讨论。下面是一个使用法语 locale 调用 isupper() 来判断字母是否为大写的例子:

println("É {}", isupper(L'É', locale{ "fr-FR" }));
println("é {}", isupper(L'é', locale{ "fr-FR" }));

输出如下:

É true
é false

<locale> 还定义了两个字符转换函数:std::toupper()tolower()。它们接收两个参数:待转换字符,以及执行转换所使用的 locale。示例如下:

auto upper { toupper(L'é', locale { "fr-FR" }) }; // É

你可以使用 std::use_facet() 函数模板来取得某个 locale 中的特定 facet。模板类型参数指定要取回的 facet,而函数参数指定从哪个 locale 中取得它。例如,下面的表达式使用 POSIX locale 名称,取得 British English locale 的标准货币标点 facet:

use_facet<moneypunct<wchar_t>>(locale { "en_GB" })

注意,最内层的模板类型决定了要使用的字符类型。表达式的结果是一个对象,它包含了你想了解的 British monetary punctuation 的全部信息。标准 facet 中可用的数据定义在 <locale> 中。下表列出了标准所定义的 facet 分类。至于每一种 facet 的具体细节,请参考标准库资料(见附录 B“注释书目”)。

facet说明
ctype字符分类 facet
codecvt转换 facet;见下一节
collate按字典序比较字符串
time_get解析日期与时间
time_put格式化日期与时间
num_get解析数值
num_put格式化数值
numpunct定义数值的格式化规则
money_get解析货币值
money_put格式化货币值
moneypunct定义货币值的格式化规则

下面这段代码把 locale 和 facet 组合到一起,分别打印 US English 和 British English 中的货币符号。注意,取决于你的环境,英国货币符号可能显示为问号、方框,甚至完全不显示。如果你的环境配置正确,你就能看到真正的英镑符号。

locale locUSEng { "en-US" }; // POSIX 下为 "en_US"
locale locBritEng { "en-GB" }; // POSIX 下为 "en_GB"
wstring dollars { use_facet<moneypunct<wchar_t>>(locUSEng).curr_symbol() };
wstring pounds { use_facet<moneypunct<wchar_t>>(locBritEng).curr_symbol() };
wcout << L"In the US, the currency symbol is " << dollars << endl;
wcout << L"In Great Britain, the currency symbol is " << pounds << endl;

C++ 标准提供 codecvt 类模板,帮助在不同字符编码之间进行转换。<locale> 中定义了以下四类编码转换类:

说明
codecvt<char,char,mbstate_t>恒等转换,也就是不做转换
codecvt<char16_t,char,mbstate_t> / codecvt<char16_t,char8_t,mbstate_t>UTF-16 与 UTF-8 之间的转换
codecvt<char32_t,char,mbstate_t> / codecvt<char32_t,char8_t,mbstate_t>UTF-32 与 UTF-8 之间的转换
codecvt<wchar_t,char,mbstate_t>宽字符(实现相关)与窄字符编码之间的转换

遗憾的是,这些 facet 用起来相当复杂。比如,下面这段代码把一个窄字符串转换成宽字符串:

auto& facet { use_facet<codecvt<wchar_t, char, mbstate_t>>(locale { }) };
string narrowString { "Hello" };
mbstate_t mb { };
wstring wideString(narrowString.size(), '\0');
const char* fromNext { nullptr };
wchar_t* toNext { nullptr };
facet.in(mb,
narrowString.data(), narrowString.data() + narrowString.size(), fromNext,
wideString.data(), wideString.data() + wideString.size(), toNext);
wideString.resize(toNext - wideString.data());
wcout << wideString << endl;

在 C++17 之前,<codecvt> 中还定义了三个代码转换 facet:codecvt_utf8codecvt_utf16codecvt_utf8_utf16。它们可以与两个便捷转换接口 wstring_convertwbuffer_convert 一起使用。不过,C++17 已经弃用了这三个转换 facet(也就是整个 <codecvt>)以及这两个便捷接口,因此本书不再进一步讨论它们。C++ 标准委员会之所以决定弃用这部分功能,是因为它们在错误处理上做得并不好。格式错误的 Unicode 字符串会带来安全风险,事实上它们确实已经被用作攻击向量来破坏系统安全。此外,这套 API 也过于晦涩、难以理解。我建议在标准委员会给出一个合适、安全且更易使用的替代方案之前,使用 ICU 这样的第三方库来正确处理 Unicode 字符串。

定义在 <regex> 中的正则表达式,是标准库里一个非常强大的字符串相关特性。它支持一套专门用于字符串处理的小型语言。初看起来它可能显得复杂,但一旦熟悉之后,就会让字符串处理轻松很多。正则表达式可以用于多种字符串操作:

  • 验证(Validation): 检查输入字符串是否格式正确。例如,输入字符串是否是一个合法的电话号码?
  • 判别(Decision): 判断一个输入字符串属于哪一类。例如,这个输入字符串是 JPEG 文件名还是 PNG 文件名?
  • 解析(Parsing): 从输入字符串中提取信息。例如,从日期中提取 year、month 和 day。
  • 变换(Transformation): 搜索子串,并用新格式的子串替换它们。例如,搜索所有 “C++23”,并把它们替换成 “C++”。
  • 迭代(Iteration): 搜索某个子串的全部出现位置。例如,从输入字符串中提取所有电话号码。
  • 分词(Tokenization): 依据一组分隔符把字符串拆成多个子串。例如,按空白、逗号、句号等分割一个字符串,从中提取单词。

当然,你完全可以自己编写代码来对字符串执行这些操作,但我仍然建议使用正则表达式功能,因为手写既正确又安全的字符串处理代码,是一件很棘手的事。

在进一步深入正则表达式之前,有一些重要术语需要先了解。后续讨论都会使用下列术语:

  • Pattern: 真正的正则表达式本身,它是由一个字符串表示的模式。
  • Match: 判断给定正则表达式,是否与给定序列 [first, last)全部字符 匹配。
  • Search: 判断给定序列 [first, last) 中,是否存在某个子串与给定正则表达式匹配。
  • Replace: 在给定序列中识别出匹配的子串,并把它们替换为依据另一种模式计算出来的新子串,这种模式称为 替换模式

正则表达式有多种不同语法。C++ 支持以下几种:

  • ECMAScript: 基于 ECMAScript 标准的语法。ECMAScript 是由 ECMA-262 标准化的一种脚本语言。JavaScript、ActionScript、Jscript 等语言的核心都使用 ECMAScript 标准。
  • basic: 基础 POSIX 语法。
  • extended: 扩展 POSIX 语法。
  • awk: POSIX awk 工具使用的语法。
  • grep: POSIX grep 工具使用的语法。
  • egrep: POSIX grep 工具在带 -E 参数时使用的语法。

如果你已经熟悉这些正则语法中的任何一种,那么你可以直接在 C++ 中使用它,只要告诉正则表达式库采用对应语法(syntax_option_type)即可。C++ 中的默认语法是 ECMAScript,其语法会在下一节中详细解释。它也是最强大的语法。至于其他正则语法的讲解,则超出了本书范围。

正则表达式模式是一个字符序列,用于描述你想要匹配的内容。正则表达式中的任何字符默认都匹配其自身,只有下面这些特殊字符例外:

^ $ \ . * + ? ( ) [ ] { } |

这些特殊字符会在后续讨论中逐步解释。如果你需要匹配这些特殊字符中的某一个,就必须使用 \ 对它进行转义,例如:

\[ or \. or \* or \\

特殊字符 ^$ 被称为 anchor(锚点)。^ 匹配紧跟在某个行结束符之后的位置,而 $ 匹配某个行结束符所在的位置。默认情况下,^$ 也分别匹配字符串的开头和结尾,不过这种行为可以被关闭。

例如,^test$ 只匹配字符串 test,而不会匹配那些在某一行中某处包含 test 的字符串,例如 1testtest2test abc 等。

wildcard(通配符)字符 . 可以匹配除换行符之外的任意单个字符。例如,正则表达式 a.c 会匹配 abca5c,但不会匹配 ab5cac 等。

字符 | 可用于表示“或”的关系。例如,a|b 匹配 ab

括号 () 用于标记 子表达式,也叫 捕获组。捕获组有多种用途:

  • 它可以用来识别原始字符串中的各个子序列;每一个被标记的子表达式(捕获组)都会出现在结果中。例如,正则表达式 (.)(ab|cd)(.) 有三个被标记的子表达式。对字符串 1cd4 执行一次 search 操作时,会得到一个包含四项的匹配结果:第一项是整个匹配,即 1cd4;后面三项分别对应三个捕获组,也就是 1cd4
  • 捕获组可以在匹配阶段用于所谓 back references(后面会解释)。
  • 捕获组还可以在 replace operations 中用来识别不同组成部分(后面也会解释)。

正则表达式的某些部分可以通过四种 quantifier(量词)中的一种来重复:

  • * 匹配前一个部分 零次或多次。例如,a*b 匹配 babaabaaaab 等。
  • + 匹配前一个部分 一次或多次。例如,a+b 匹配 abaabaaaab 等,但不匹配 b
  • ? 匹配前一个部分 零次或一次。例如,a?b 匹配 bab,除此之外不匹配其他内容。
  • {…} 表示 bounded quantifier(有界量词)。b{n} 匹配恰好重复 n 次的 bb{n,} 匹配重复 n 次及以上bb{n,m} 匹配重复 n 到 m 次(含端点)b。例如,b{3,4} 匹配 bbbbbbb,但不匹配 bbbbbbbb 等。

这些量词被称为 greedy(贪婪)的,因为它们在仍能匹配正则表达式其余部分的前提下,会尽可能匹配最长结果。要把它们变成 non-greedy(非贪婪),可以在量词后面再加一个 ?,例如 *?+???{…}?。非贪婪量词会在仍能匹配剩余部分的前提下,尽可能少地重复自己的模式。

例如,下表展示了 greedy 与 non-greedy 正则表达式的差异,以及把它们作用在输入序列 aaabbb 上时得到的子匹配结果:

正则表达式子匹配结果
Greedy: (a+)(ab)*(b+)"aaa" "" "bbb"
Non-greedy: (a+?)(ab)*(b+)"aa" "ab" "bb"

和数学公式一样,了解正则表达式元素的优先级也很重要。其优先级如下:

  • Elements,例如 b,是正则表达式的基本构件。
  • Quantifiers,例如 +*?{…},会紧紧绑定在左侧元素上;例如 b+.
  • Concatenation,例如 ab+c,优先级低于量词。
  • Alternation,例如 |,最后结合。

例如,正则表达式 ab+c|d 可以匹配 abcabbcabbbc 等,也能匹配 d。括号可以用来改变这些优先级规则。例如,ab+(c|d) 能匹配 abcabbcabbbcabdabbdabbbd 等。不过,使用括号也意味着你创建了一个子表达式,也就是捕获组。如果你想改变优先级,又不希望产生新的捕获组,可以使用 (?:)。例如,ab+(?:c|d) 与前面的 ab+(c|d) 匹配相同内容,但不会创建额外的捕获组。

与其写成 (a|b|c||z) 这种既笨重又会引入捕获组的形式,不如使用一套专门的语法来指定字符集合或字符范围。除此之外,还提供了“非匹配”的形式。字符集写在方括号内,你可以写出 [c1c2…cn],表示匹配字符 c1c2、…、cn 中的任意一个。例如,[abc] 会匹配 abc。如果第一个字符是 ^,则表示“除了这些之外任意字符”:

  • ab[cde] 匹配 abcabdabe
  • ab[^cde] 匹配 abfabp 等,但不匹配 abcabdabe

如果你需要匹配字符 ^[] 本身,就必须对它们转义;例如,[\[\^\]] 可以匹配字符 [^]

如果你想指定所有字母,当然可以使用 [abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ] 这样的字符集;但这既笨重,又很容易在多次书写时漏掉某个字母。对此有两个解决方案。

其一,是使用方括号中的 range specification(范围写法),例如 [a-zA-Z],它表示范围 azAZ 内的全部字母。如果你需要匹配连字符,就必须对它转义;例如,[a-zA-Z\-]+ 可以匹配任意单词,包括带连字符的单词。

其二,是使用 character class(字符类)。字符类用于表示某类特定字符,写法是 [:name:]。可用的字符类取决于 locale,但下表列出的这些名字始终被识别。字符类的精确含义也与 locale 相关。下表假定使用标准 C locale:

字符类名说明
digit数字,即 0、1、2、3、4、5、6、7、8、9。
ddigit 相同。
xdigit数字(digit)以及十六进制数字中会出现的字母:a、b、c、d、e、f、A、B、C、D、E、F。
alpha字母字符。对于 C locale,即所有小写和大写字母。
alnumalpha 类与 digit 类的组合。
walnum 相同。
lower小写字母(若该 locale 适用)。
upper大写字母(若该 locale 适用)。
blank空白字符,用于在一行文本中分隔单词。对 C locale 来说,它们是空格和 \t(tab)。
space空白字符。对 C locale 来说,它们是空格、\t\n\r\v\f
sspace 相同。
print可打印字符。它们会占据一个打印位置,例如显示器上的一个位置,是控制字符(cntrl)的相反集合。示例包括小写字母、大写字母、数字、标点字符和空格字符。
cntrl控制字符。它们与可打印字符(print)相对,不占据实际打印位置,例如显示器上的位置。C locale 中的例子包括 \f\n\r
graph具有图形表示的字符。也就是所有可打印字符(print)中,除了空格字符 ' ' 之外的那些。
punct标点字符。对 C locale 来说,它们是所有具图形表示的字符(graph)中,去掉字母数字字符(alnum)后剩下的部分。例子包括 !#@} 等。

字符类要写在字符集内部使用;例如,在英语环境中,[[:alpha:]]*[a-zA-Z]* 等价。

由于某些字符类非常常用,例如数字,因此标准还为它们提供了简写模式。例如,[:digit:][:d:][0-9] 含义相同。有些类还提供了更短的转义写法。例如,\d 就表示 [:digit:]。因此,如果你想识别一个或多个数字组成的序列,可以使用以下任意一种模式:

  • [0-9]+
  • [[:digit:]]+
  • [[:d:]]+
  • \d+

下表列出了字符类可用的转义写法:

转义写法等价于
\d[[:d:]]
\D[^[:d:]]
\s[[:s:]]
\S[^[:s:]]
\w[\_[:w:]]
\W[^_[:w:]]

例如:

  • Test[5-8] 匹配 Test5Test6Test7Test8
  • [[:lower:]] 匹配 ab 等,但不匹配 AB 等。
  • [^[:lower:]] 匹配除小写字母 ab 等之外的任何字符。
  • [[:lower:]5-7] 匹配任意小写字母,以及数字 567

word boundary(单词边界)可以意味着以下几种情况:

  • 单词的第一个字符,也就是一个单词字符,而它前面的字符不是单词字符。单词字符指字母、数字或下划线。对标准 C locale 来说,它等价于 [A-Za-z0-9_]
  • 单词的末尾,即当前位置是一个非单词字符,而前一个字符是单词字符。
  • 源字符串的开头,前提是源字符串的第一个字符是单词字符。默认情况下,会匹配源字符串的开头;但你可以通过 regex_constants::match_not_bow 来禁用它,其中 bow 表示 beginning-of-word。
  • 源字符串的结尾,前提是源字符串的最后一个字符是单词字符。默认情况下,会匹配源字符串的结尾;但你可以通过 regex_constants::match_not_eow 来禁用它,其中 eow 表示 end-of-word。

你可以使用 \b 来匹配单词边界,也可以使用 \B 来匹配“除了单词边界之外的任何位置”。

back reference(反向引用)允许你在正则表达式自身内部引用某个已捕获分组:\n 指向第 n 个捕获组,其中 n > 0。例如,正则表达式 (\d+)-.*-\1 用来匹配如下格式的字符串:

  • 一个或多个数字,并由捕获组 (\d+) 捕获
  • 接着是一个连字符 -
  • 接着是零个或多个字符 .*
  • 接着又是一个连字符 -
  • 最后是与第一个捕获组所捕获数字完全相同的那串数字,即 \1

这个正则表达式可以匹配 123-abc-1231234-a-1234 等,但不能匹配 123-abc-1234123-abc-321 等。

正则表达式支持 positive lookahead(写作 ?=pattern)和 negative lookahead(写作 ?!pattern)。lookahead 后面的字符必须分别满足“匹配”(positive)或“不匹配”(negative)lookahead 模式,但这些字符本身不会被消耗。

例如,模式 a(?!b) 中包含一个 negative lookahead,用于匹配“后面不是 b 的字母 a”。模式 a(?=b) 则包含一个 positive lookahead,用于匹配“后面跟着 b 的字母 a”,但其中的 b 不会被消耗,因此它不会成为匹配结果的一部分。

下面是一个更贴近实际的例子。这个正则表达式匹配的输入序列必须满足:至少包含一个小写字母、至少包含一个大写字母、至少包含一个标点字符,并且总长度至少为 8 个字符。例如,这样的正则表达式可以用来强制密码满足特定条件。

(?=.*[[:lower:]])(?=.*[[:upper:]])(?=.*[[:punct:]]).{8,}

在本章最后的一道练习中,你会亲自试验这个密码校验正则表达式。

正则表达式与原始字符串字面量

Section titled “正则表达式与原始字符串字面量”

正如前面几节所示,正则表达式经常使用一些特殊字符,而这些字符在普通 C++ 字符串字面量中又必须转义。例如,在正则表达式中写 \d,表示匹配任意数字。但由于 \ 在 C++ 中也是特殊字符,因此在正则表达式字符串字面量里,你必须把它写成 \\d;否则 C++ 编译器会试图解释其中的 \d。如果你想让正则表达式匹配单个反斜杠字符 \,情况会更复杂。因为 \ 在正则表达式语法本身中又是特殊字符,所以你需要先把它写成 \\。而 \ 在 C++ 字符串字面量中同样是特殊字符,因此还要再做一次转义,最终变成 \\\\

你可以使用原始字符串字面量,让复杂的正则表达式在 C++ 源代码中更易阅读。(原始字符串字面量在第 2 章中介绍过。)例如,考虑下面这个正则表达式:

"( |\\n|\\r|\\\\)"

这个正则表达式匹配空格、换行符、回车符和反斜杠。它需要写很多转义字符。而使用原始字符串字面量后,就可以改写为下面这个更容易读懂的正则表达式:

R"(( |\n|\r|\\))"

这个原始字符串字面量以 R"( 开始,以 )" 结束,中间的内容就是正则表达式。当然,在末尾你仍然需要写双反斜杠,因为这里的反斜杠需要在正则表达式语法本身中被转义。

编写正确的正则表达式并不总是容易。对于诸如密码、电话号码、Social Security number、IP 地址、email 地址、信用卡号、日期等常见模式,你其实不必每次都从头写起。只要用你喜欢的互联网搜索引擎搜索 regular expressions online,就能找到许多收集好现成模式的网站,比如 regexr.comregex101.comregextester.com 等等。许多这类网站还允许你直接在线测试模式,因此你可以在把它们放进代码之前,先轻松验证其正确性。

到这里,我们对 ECMAScript 语法的简要介绍就结束了。下面几节将解释如何在 C++ 代码中真正使用正则表达式。

正则表达式库的全部内容都定义在 <regex> 中,并位于 std 命名空间。这个库定义的基础模板类型包括:

  • basic_regex 表示一个具体正则表达式的对象。
  • match_results 一个与正则表达式匹配到的子串结果,包含全部捕获组;它本质上是若干 sub_match 的集合。
  • sub_match 一个对象,内部包含一对指向输入序列的 iterator。这对 iterator 表示某个被匹配到的捕获组:前一个 iterator 指向该捕获组匹配到的第一个字符,后一个 iterator 指向该捕获组最后一个字符之后的位置。它还有一个 str() 成员函数,可以把这个匹配到的捕获组转成字符串返回。

这个库提供了三个核心算法:regex_match()regex_search()regex_replace()。这三个算法都具有多个重载,允许你把源字符串指定为 string、C 风格字符串,或者 begin/end iterator 对。可用 iterator 可以是:

  • const char*const wchar_t*
  • string::const_iteratorwstring::const_iterator

实际上,任何表现得像 bidirectional iterator 的 iterator 都可以使用。关于 iterator 的详细内容,请参见第 17 章“理解迭代器与 Ranges 库”。

库中还定义了以下两个 正则表达式迭代器,它们在从源字符串中找出某个模式的所有出现位置时非常重要:

  • regex_iterator 遍历源字符串中某个模式的全部匹配项。
  • regex_token_iterator 遍历源字符串中某个模式的全部匹配项所对应的全部捕获组。

为了让库更易使用,标准为前述模板定义了一组类型别名:

using regex = basic_regex<char>;
using wregex = basic_regex<wchar_t>;
using csub_match = sub_match<const char*>;
using wcsub_match = sub_match<const wchar_t*>;
using ssub_match = sub_match<string::const_iterator>;
using wssub_match = sub_match<wstring::const_iterator>;
using cmatch = match_results<const char*>;
using wcmatch = match_results<const wchar_t*>;
using smatch = match_results<string::const_iterator>;
using wsmatch = match_results<wstring::const_iterator>;
using cregex_iterator = regex_iterator<const char*>;
using wcregex_iterator = regex_iterator<const wchar_t*>;
using sregex_iterator = regex_iterator<string::const_iterator>;
using wsregex_iterator = regex_iterator<wstring::const_iterator>;
using cregex_token_iterator = regex_token_iterator<const char*>;
using wcregex_token_iterator = regex_token_iterator<const wchar_t*>;
using sregex_token_iterator = regex_token_iterator<string::const_iterator>;
using wsregex_token_iterator = regex_token_iterator<wstring::const_iterator>;

接下来几节会解释 regex_match()regex_search()regex_replace() 这几个算法,以及 regex_iteratorregex_token_iterator 这两个类。

regex_match() 算法可用于把某个源字符串与某个正则表达式模式进行比较。如果该模式与整个源字符串完全匹配,它就返回 true;否则返回 falseregex_match() 一共有七个重载,可接受不同种类的参数。它们都具有如下形式:

template<>
bool regex_match(InputSequence[, MatchResults], RegEx[, Flags]);

其中,InputSequence 可用以下几种形式表示:

  • 源字符串上的 begin/end iterator 对
  • std::string
  • C 风格字符串

可选参数 MatchResults 是一个对 match_results 的引用,用来接收匹配结果。如果 regex_match() 返回 false,那么你只能调用 match_results::empty()match_results::size();除此之外的任何操作都是未定义行为。如果 regex_match() 返回 true,说明匹配成功,此时你就可以查看 match_results 对象,了解究竟匹配到了什么。后面的小节会通过例子来说明。

RegEx 参数就是要匹配的正则表达式。可选的 Flags 参数用于指定匹配算法的选项。大多数情况下,保持默认值即可。如需更多细节,请查阅标准库资料。

下面这个程序会要求用户输入一个按 year/month/day 格式写出的日期,其中 year 必须是四位数字,month 是 1 到 12 的数字,day 是 1 到 31 的数字。程序使用正则表达式配合 regex_match() 算法来验证用户输入。关于正则表达式的细节,会在代码后解释。

regex r { "\\d{4}/(?:0?[1-9]|1[0-2])/(?:0?[1-9]|[1-2][0-9]|3[0-1])" };
while (true) {
print("Enter a date (year/month/day) (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
if (regex_match(str, r)) { println(" Valid date."); }
else { println(" Invalid date!"); }
}

第一行创建正则表达式。该表达式由三部分组成,它们之间用斜杠字符 / 分隔:一部分用于匹配 year,一部分匹配 month,一部分匹配 day。下面逐项解释:

  • \d{4}:匹配任意四位数字组合,例如 1234、2024 等。
  • (?:0?[1-9]|1[0-2]) 这个子表达式被括号包裹,以确保优先级正确。我们不需要捕获组,因此使用的是 (?:)。内部表达式由两部分择一分支构成,中间通过 | 分隔。
    • 0?[1-9] 匹配 1 到 9 之间任意数字,并允许前面可选地有一个 0。例如,它可以匹配 1、2、9、03、04 等,但不会匹配 0、10、11 等。
    • 1[0-2]:匹配 10、11 或 12,除此之外不匹配别的值。
  • (?:0?[1-9]|[1-2][0-9]|3[0-1]) 这一部分同样被包在非捕获组中,内部由三个择一分支构成。
    • 0?[1-9] 同样表示带可选前导 0 的 1 到 9。
    • [1-2][0-9] 匹配 10 到 29 之间的任意数字,且不匹配其他内容。
    • 3[0-1] 匹配 30 或 31,且不匹配其他内容。

随后,示例会进入一个无限循环,不断要求用户输入日期。每次输入的日期都会传给 regex_match() 算法。当 regex_match() 返回 true 时,就意味着用户输入的日期满足该日期正则表达式模式。

这个例子还可以进一步扩展,让 regex_match() 把捕获到的子表达式返回到结果对象中。要做到这一点,你首先得理解捕获组的作用。当你在调用 regex_match() 时传入一个像 smatch 这样的 match_results 对象,那么当正则表达式匹配成功时,match_results 对象的各个元素就会被填入相应内容。要把这些子串提取出来,你必须使用括号创建捕获组。

match_results 对象中的第一个元素 [0] 保存的是匹配 整个模式 的字符串。在 regex_match() 中,如果匹配成功,它就是整个源序列;而在下一节将介绍的 regex_search() 中,它则可能只是源序列中的某个匹配子串。元素 [1] 是第一个捕获组所匹配到的子串,[2] 是第二个捕获组的结果,以此类推。要从 match_results 对象 m 中取得第 i 个捕获组的字符串形式,你可以像下面示例一样直接使用 m[i],或者使用 m[i].str()

下面这段代码把 year、month 和 day 的数字提取到三个独立的整数变量中。修订后的正则表达式做了几处小改动:用于匹配 year 的第一部分被包进了一个捕获组,而 month 和 day 部分现在也使用捕获组,而不再是非捕获组。对 regex_match() 的调用新增了一个 smatch 参数,用于接收匹配到的捕获组。代码如下:

regex r { "(\\d{4})/(0?[1-9]|1[0-2])/(0?[1-9]|[1-2][0-9]|3[0-1])" };
while (true) {
print("Enter a date (year/month/day) (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
if (smatch m; regex_match(str, m, r)) {
int year { stoi(m[1]) };
int month { stoi(m[2]) };
int day { stoi(m[3]) };
println(" Valid date: Year={}, month={}, day={}", year, month, day);
} else {
println(" Invalid date!");
}
}

在这个例子中,smatch 结果对象中有四个元素:

  • [0] 匹配整个正则表达式的字符串,也就是本例中的完整日期
  • [1] year
  • [2] month
  • [3] day

运行该示例时,你可能会得到如下输出:

Enter a date (year/month/day) (q=quit): 2024/12/01
Valid date: Year=2024, month=12, day=1
Enter a date (year/month/day) (q=quit): 24/12/01
Invalid date!

前一节讨论的 regex_match() 算法,会在整个源字符串与正则表达式完全匹配时返回 true,否则返回 false。如果你想搜索一个 匹配的子串,就需要使用 regex_search()regex_search() 同样有七个重载,并且都具有以下形式:

template<>
bool regex_search(InputSequence[, MatchResults], RegEx[, Flags]);

这些重载在输入序列中 某处 找到匹配时返回 true,否则返回 false。参数含义与 regex_match() 的参数类似。

regex_search() 的其中两个重载接受 begin 和 end iterator 作为待处理输入序列。你可能会很想在循环中使用这种版本,通过不断调整 begin/end iterator,来从源字符串中找出某个模式的全部匹配项。千万别这么做!当你的正则表达式里使用了 anchor(^$)、word boundary 等特性时,这种做法会出问题;而且空匹配还可能导致无限循环。要从源字符串中提取某个模式的全部出现位置,应该使用本章后面会介绍的 regex_iteratorregex_token_iterator

永远不要在循环中使用 regex_search() 来查找源字符串中某个模式的全部出现位置。请改用 regex_iteratorregex_token_iterator

regex_search() 算法可以用来从输入序列中提取一个匹配到的子串。例如,下面这个程序会从字符串中提取代码注释。正则表达式会搜索一个以 // 开头、后面跟着可选空白字符 \s*,再跟着一个或多个字符 (.+) 的子串。这里的捕获组只捕获注释本身。smatch 对象 m 会接收搜索结果;如果搜索成功,m[1] 就包含找到的注释。你还可以通过 m[1].firstm[1].second 这两个 iterator,精确知道注释在源字符串中的位置。

regex r { "//\\s*(.+)$" };
while (true) {
print("Enter a string with optional code comments (q=quit):\n > ");
string str;
if (!getline(cin, str) || str == "q") { break; }
if (smatch m; regex_search(str, m, r)) {
println(" Found comment '{}'", m[1].str());
} else {
println(" No comment found!");
}
}

该程序的输出可能如下:

Enter a string with optional code comments (q=quit):
> std::string str; // 源字符串
Found comment 'Our source string'
Enter a string with optional code comments (q=quit):
> int a; // 中间带有 // 的注释
Found comment 'A comment with // in the middle'
Enter a string with optional code comments (q=quit):
> std::vector values { 1, 2, 3 };
No comment found!

match_results 对象还提供 prefix()suffix() 成员函数,分别返回匹配前面的部分与匹配后面的部分。

正如前一节所说,你不应在循环中使用 regex_search() 来从源序列中提取某个模式的全部出现位置。正确的做法是使用 regex_iteratorregex_token_iterator。它们的工作方式与标准库容器上的 iterator 很相似。

下面这个例子会让用户输入一个源字符串,然后从中提取每个单词,并把所有单词用引号括起来逐个打印。这里使用的正则表达式是 [\w]+,表示搜索一个或多个单词字符。由于这个例子使用 std::string 作为 source,因此使用的是 sregex_iterator。程序采用标准 iterator 循环,不过这里的 end iterator 与标准库容器的 end iterator 略有不同。通常情况下,你会从某个容器中取得自己的 end iterator;但对 regex_iterator 来说,只有一个统一的“end” iterator。你只需要默认构造一个 regex_iterator,就能得到它。

for 循环创建了一个名为 iter 的起始 iterator,它接收源字符串的 begin 和 end iterator,以及一个正则表达式。循环体会针对每个匹配项执行一次,也就是本例中的每个单词。sregex_iterator 会遍历所有匹配项。对 sregex_iterator 解引用后,你得到的是一个 smatch 对象。访问这个 smatch 对象的第一个元素 [0],就能得到匹配到的子串:

regex reg { "[\\w]+" };
while (true) {
print("Enter a string to split (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
const sregex_iterator end;
for (sregex_iterator iter { cbegin(str), cend(str), reg };
iter != end; ++iter) {
println("\"{}\"", (*iter)[0].str());
}
}

该程序的输出可能如下:

Enter a string to split (q=quit): This, is a test.
"This"
"is"
"a"
"test"

正如这个例子所展示的那样,即便是非常简单的正则表达式,也能完成相当强大的字符串操作!

还要注意,regex_iterator 以及下一节讨论的 regex_token_iterator,在内部都会保存一个指向给定正则表达式的指针。因此,它们都显式删除了那些接收 rvalue reference 正则表达式的构造函数,以防你用临时 regex 对象来构造它们。例如,下面这段代码不能编译:

for (sregex_iterator iter { cbegin(str), cend(str), regex { "[\\w]+" } };
iter != end; ++iter) {}

上一节介绍的 regex_iterator 会遍历每一次完整匹配。在每次迭代中,你得到的是一个 match_results 对象,并可用它来提取某次匹配中由捕获组捕获的各个子表达式。

regex_token_iterator 则可以用来自动遍历“所有匹配中的全部或选定捕获组”。它有四个构造函数,形式如下:

regex_token_iterator(BidirectionalIterator a,
BidirectionalIterator b,
const regex_type& re
[, SubMatches
[, Flags]]);

这些构造函数都要求传入 begin/end iterator 作为输入序列,以及一个正则表达式。可选参数 SubMatches 用来指定要遍历哪些捕获组。SubMatches 可以通过四种方式指定:

  • 单个整数,表示你想遍历的捕获组索引
  • 一个保存捕获组索引的 vector<int>
  • 一个包含捕获组索引的 initializer_list
  • 一个保存捕获组索引的 C 风格数组

如果你省略 SubMatches,或者显式把它指定为 0,那么得到的 iterator 会遍历所有索引为 0 的捕获组,也就是每次匹配到 整个正则表达式 的那部分子串。可选参数 Flags 用于指定匹配算法的选项。大多数情况下,保持默认值即可。更多细节请查阅标准库资料。

前面 regex_iterator 的例子可以改写成如下 regex_token_iterator 版本。在循环体中,你不再需要写 (*iter)[0].str(),而只要直接使用 iter->str() 即可,因为当 token iterator 的 submatch 索引为 0(也就是默认值)时,它会自动遍历所有索引为 0 的捕获组。该代码的输出与前面 regex_iterator 示例完全相同。

regex reg { "[\\w]+" };
while (true) {
print("Enter a string to split (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
const sregex_token_iterator end;
for (sregex_token_iterator iter { cbegin(str), cend(str), reg };
iter != end; ++iter) {
println("\"{}\"", iter->str());
}
}

下面这个例子会要求用户输入一个日期,然后使用 regex_token_iterator 去遍历第二和第三个捕获组(month 和 day)。这两个索引通过一个整数 vector 来指定。日期正则表达式在本章前面已经解释过,这里唯一的不同是加入了 ^$ anchor,因为我们想要匹配整个输入序列。前面不需要写 anchor,是因为 regex_match() 本来就会自动匹配整个输入字符串。

regex reg { "^(\\d{4})/(0?[1-9]|1[0-2])/(0?[1-9]|[1-2][0-9]|3[0-1])$" };
while (true) {
print("Enter a date (year/month/day) (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
vector indices { 2, 3 };
const sregex_token_iterator end;
for (sregex_token_iterator iter { cbegin(str), cend(str), reg, indices };
iter != end; ++iter) {
println("\"{}\"", iter->str());
}
}

这段代码只会打印合法日期中的 month 和 day。它的输出可能如下:

Enter a date (year/month/day) (q=quit): 2024/1/13
"1"
"13"
Enter a date (year/month/day) (q=quit): 2024/1/32
Enter a date (year/month/day) (q=quit): 2024/12/5
"12"
"5"

regex_token_iterator 还可以用来执行 field splittingtokenization。与 C 语言里那个老旧且本书不再讨论的 strtok() 函数相比,它是一种更安全、也更灵活的替代方案。要在 regex_token_iterator 构造函数中开启 tokenization 模式,只需把捕获组索引指定为 -1。在 tokenization 模式下,iterator 会遍历输入序列中所有 不匹配 正则表达式的子串。下面的代码通过分隔符 ,;,并允许分隔符前后出现零个或多个空白字符,对字符串进行 tokenization。代码用两种方式演示了这一点:先直接遍历 token,然后再创建一个新的 vector 存放所有 token,并输出这个 vector 的内容:

regex reg { R"(\s*[,;]\s*)" };
while (true) {
print("Enter a string to split on ',' and ';' (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
// 直接遍历所有 token。
const sregex_token_iterator end;
for (sregex_token_iterator iter { cbegin(str), cend(str), reg, -1 };
iter != end; ++iter) {
print("\"{}\", ", iter->str());
}
println("");
// 把所有 token 存入一个 vector。
vector<string> tokens {
sregex_token_iterator { cbegin(str), cend(str), reg, -1 },
sregex_token_iterator {} };
// 输出 tokens vector 的内容。
println("{:n}", tokens);
}

该例中的正则表达式使用原始字符串字面量来表示,它匹配如下内容:

  • 零个或多个空白字符
  • 后面跟着一个 ,;
  • 再后面跟着零个或多个空白字符

输出可能如下:

Enter a string to split on ',' and ';' (q=quit): This is, a; test string.
"This is", "a", "test string.",
"This is", "a", "test string."

从输出可以看出,字符串是按 ,; 被拆开的。,; 两侧的所有空白字符都被去掉了,因为 tokenization iterator 遍历的是所有 不匹配 正则表达式的子串,而该正则表达式恰好会把带有周围空白的 ,; 一并匹配进去。

regex_replace() 算法需要一个正则表达式以及一个格式字符串,用来替换匹配到的子串。这个格式字符串可以使用下表中的转义序列,来引用匹配结果的不同部分。

转义序列会被替换成
$n匹配到的第 n 个捕获组的字符串;例如,第一个捕获组对应 $1,第二个对应 $2,以此类推。n 必须大于 0。
$&匹配整个正则表达式的字符串。
$` 输入序列中,位于匹配子串左边的那一部分。
输入序列中,位于匹配子串右边的那一部分。
$$单个美元符号。

regex_replace() 有六个重载。其中四个重载的形式如下:

template<>
string regex_replace(InputSequence, RegEx, FormatString[, Flags]);

这四个重载会在完成替换后返回结果字符串。InputSequenceFormatString 都可以是 std::string 或 C 风格字符串。RegEx 参数表示要匹配的正则表达式。可选参数 Flags 指定 replace 算法的选项。

另外两个重载具有如下形式:

OutputIterator regex_replace(OutputIterator,
BidirectionalIterator first,
BidirectionalIterator last,
RegEx, FormatString[, Flags]);

这两个重载会把替换后的结果写入给定输出迭代器,并返回该输出迭代器。输入序列通过 begin/end iterator 给出,其余参数与前四个重载相同。

先看下面这个 HTML source 字符串:

<body><h1>Header</h1><p>Some text</p>
</body>

以及下面这个正则表达式:

<h1>(.*)</h1><p>(.*)</p>

下表展示了不同转义序列会被替换成什么:

转义序列替换结果
$1Header
$2Some text
$&<h1>Header</h1><p>Some text</p>
$` <body>
</body>

下面这段代码演示了 regex_replace() 的用法:

const string str { "
<body><h1>Header</h1><p>Some text</p>
</body>" };
regex r { "<h1>(.*)</h1><p>(.*)</p>" };
const string replacement { "H1=$1 and P=$2" }; // 见上表。
string result { regex_replace(str, r, replacement) };
println("Original string: '{}'", str);
println("New string : '{}'", result);

程序输出如下:

Original string: '
<body><h1>Header</h1><p>Some text</p>
</body>'
New string : '
<body>H1=Header and P=Some text
</body>'

regex_replace() 算法支持若干 flag 来改变其行为。最重要的几个如下表所示:

flag说明
format_default默认行为:替换模式的所有出现位置,并把所有未匹配模式的内容也复制到输出中。
format_no_copy替换模式的所有出现位置,但不把任何未匹配模式的内容复制到输出中。
format_first_only只替换模式的第一次出现。

前面代码片段中的 regex_replace() 调用,可以改写为使用 format_no_copy flag:

string result { regex_replace(str, r, replacement,
regex_constants::format_no_copy) };

此时输出变为:

Original string: '
<body><h1>Header</h1><p>Some text</p>
</body>'
New string : 'H1=Header and P=Some text'

regex_replace() 的另一个例子,是把一个字符串中的每个单词边界替换成换行符,从而让输出变成“每行只包含一个单词”。下面的代码片段演示了如何在 不写任何循环 的前提下处理给定输入字符串。代码首先创建一个用来匹配单词的正则表达式。当 regex_replace() 找到匹配项后,它会用 $1\n 来替换它,其中 $1 会替换成匹配到的单词。还要注意,这里使用了 format_no_copy flag,以防把源字符串中的空白和其他非单词字符一起复制到输出中。

regex reg { "([\\w]+)" };
const string replacement { "$1\n" };
while (true) {
print("Enter a string to split over multiple lines (q=quit): ");
string str;
if (!getline(cin, str) || str == "q") { break; }
println("{}", regex_replace(str, reg, replacement,
regex_constants::format_no_copy));
}

程序输出可能如下:

Enter a string to split over multiple lines (q=quit): This is a test.
This
is
a
test

本章让你体会到了“在编写代码时就把本地化考虑进去”有多重要。任何经历过本地化项目的人都会告诉你:如果你事先做好规划,例如使用 Unicode 字符、并留意 locale,那么为程序新增一种语言或 locale 的支持,会容易得多。

本章后半部分则解释了正则表达式库。一旦掌握了正则表达式的语法,处理字符串就会容易得多。正则表达式允许你验证字符串、在输入序列中搜索子串、执行查找并替换操作,等等。强烈建议你熟悉并开始使用正则表达式,而不是自己去写字符串操作例程。它们会让你的工作轻松许多。

通过完成下面这些练习,你可以动手巩固本章讨论过的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 提供的代码下载包中。不过,如果你在某道题上卡住了,建议先重读本章相关部分,尽量自己找到答案,再去查看网站上的解法。

  1. 练习 21-1: 使用一个合适的 facet,找出按用户环境格式化数字时所使用的小数点分隔符。关于所选 facet 可用的具体成员函数,请查阅标准库参考资料。

  2. 练习 21-2: 编写一个应用,让用户输入一个按美国格式书写的电话号码。例如:202-555-0108。使用正则表达式验证电话号码格式是否正确,也就是三位数字、一个连字符、再三位数字、再一个连字符、最后四位数字。如果电话号码有效,就把这三部分分别单独打印出来。例如,对前述号码,结果必须如下:

    202
    555
    0108
  3. 练习 21-3: 编写一个应用,让用户输入一段源代码。它可以跨越多行,也可以包含 // 风格注释。为了标记输入结束,可以使用一个 sentinel 字符,例如 @。你可以调用 std::getline(),并把 '@' 作为分隔符,从标准输入控制台中读取多行文本。最后,使用正则表达式从代码片段的所有行中去掉注释。确保你的代码能正确处理如下输入:

    string str; // 一条注释 // 更多注释。
    str = "Hello"; // 你好。

    对上述输入,结果必须如下:

    string str;
    str = "Hello";
  4. 练习 21-4: 本章前面 “Lookahead” 一节提到过一个用于校验密码的正则表达式。编写一个程序来测试这个正则表达式。让用户输入一个密码,并验证它。一旦确认这个正则表达式能工作,再给它新增一条规则:密码还必须至少包含两个数字。