10 字符串和正则表达式

10.1 引言

在大多数程序中,文本处理都是其重要的组成部分。C++标准库提供了string类型,使程序员不必再使用C风格的文本处理方式——通过指针操作字符数组。C++标准库还提供了string_view类型,允许程序员以容器的方式访问字符序列,而无论它们存储在哪里,是string对象还是字符数组。此外,C++标准库还提供了正则表达式,用于在文本中匹配特定的模式。标准库中的正则表达式与大多数编程语言所提供的正则表达式类似。string和regex都支持包括Unicode等在内的多种字符编码。

10.2 字符串

标准库中的string类型,具有比简单的字符串字面量,更完整的字符串处理能力。string是用于管理不同字符序列的regular类型。string类型提供了很多有用的字符串处理功能,如字符串拼接等。例如:

在本例中,mbox被初始化为“minwei@tedu.cn”。函数compose中的字符串“加法”表示拼接处理。它可以将string对象、字符串字面量、C风格字符串,或者一个单个字符,拼接在一个string对象所表示的字符串末尾。标准库为string类型定义了一个转移构造函数,因此即使以传值而非传引用方式返回一个很长的字符串,也会很高效。

在很多应用中,字符串拼接常被用于在一个字符串的末尾追加一些内容。这可以通过“+=”操作来实现。例如:

这两种在字符串末尾追加内容的方式,在语义上时完全等价的。后者显然比前者更加简洁、明确地表达了代码编写者的意图,而且可能还更高效。

string对象是可修改的。除了“+”和“+=”,string还支持下标([])和提取子字符串等操作。例如:

substr函数返回一个string对象,保存其参数指定的子字符串的拷贝。其第一个参数表示子字符串第一个字符在原字符串中的下标,第二个参数表示子字符串的长度。由于下标从0开始,因此s4的值为“World”。

0123456789101112
Hello, World!

replace函数替换字符串中的内容,从指定位置(第一个参数)开始,将指定长度(第二个参数)的子字符串,替换为指定的字符串(第三个参数)。替换的内容和被替换的子字符串不一定一样长。替换后的s3为“Hello, c++!”

0123456789101112
Hello, World!
Hello, c++!  

toupper函数返回其参数字符的大写字符。s3最后变为“Hello, C++!”

0123456789101112
Hello, World!
Hello, c++!  
Hello, C++!  

在string支持的所有字符串操作中,最常用的无外乎赋值操作(=)、下标操作([]或at)、比较操作(<、<=、>、>=、==、!=)、迭代操作(begin和end)、I/O流操作(<<和>>)等。

字符串的比较操作(<、<=、>、>=、==、!=),可以在string对象之间,也可以在string对象和C风格字符串或字符串字面值之间。例如:

C风格字符串的本质,就是一个以空字符('\0')结尾的字符数组。

012345678
C StringNUL

空字符(NUL)不是0字符,也不是空格,而是一个值为0的单个字节,其字面值表示为'\0'。它并不是字符串有效内容的一部分,仅作为字符串结束的标志。string提供两个只读访问接口c_str和data,以获得指向C风格字符串的指针。例如:

根据定义,字符串字面量的数据类型是“const char*”。要想获得string类型的字面量,需为其添加s后缀。例如下面的代码是无法通过编译的:

但这样可以:

后缀s定义在std::literals::string_literals命名空间中。

10.2.1 string的实现

在当前的string实现版本中,通常会使用短字符串优化(SSO)技术,即将较短的字符串直接保存在string对象内部,而将较长的字符串保存在自由存储中。例如下面的代码:

它们的内存布局可能会象下面这样:

s1
6
short\0
自由存储
s2
long long long long long\0
25
*

当一个string对象的值从短字符串变为长字符串时,它的存储方式会发生相应地改变,反之亦然。那么多长的字符串算短字符串,多长的字符串又算长字符串呢?这由具体实现决定,14个字符可能是一个分水岭。

string的实际性能严重依赖运行时环境。特别是在多线程场景下,内存分配的时间开销可能会非常大。另外,当存在众多长度不同的字符串时,大量的内存碎片会进一步降低程序的运行性能。这些都是短字符串优化被普遍采用的原因。

string中的每个字符都是char类型的,这对于ASCII字符集而言显然已经足够了。为了支持更多的字符集,特别那些需要用char以外的数据类型表示一个字符的字符集,标准库定义了一个通用的字符串模板basic_string,其中的字符类型是参数化的。string实际上就是basic_string<char>的别名而已。

用户可以定义任意字符类型的字符串。例如用zchar类型表示一个汉字字符,则表示汉字字符串的类型可被定义为:

这样,就可以在zstring——汉字字符串上执行常见的字符串操作了。

10.3 字符串视图

字符序列最常见的用途是将其作为参数传递给一个函数以供读取。这可以通过string类型以传值的方式实现,也可以传递string类型的引用,甚至C风格的字符串。在很多系统中,还存在着其它替代方案,比如使用标准库以外的字符串类型。在所有这些情况下,传递子字符串(字符串的一部分)都会增加额外的复杂度。为了解决这个问题,标准库提供了string_view类,即字符串视图,用一个指针——长度对,表示字符串中的字符序列。

string_view类允许以只读方式访问基于字符的连续序列。这里的字符可以很多方式存储,包括标准库的string对象和C风格的字符串。string_view是一个很轻量级的对象,只维护一个指针和一个长度,并不负责任何与所表示字符序列有关的内存分配、释放与复制。例如下面的函数用于拼接两个字符串:

而对它的调用可能会象下面这样:

对于函数参数而言,使用string_view对象接受实参,相比于用const string&,至少有三个优点:

表示string_view型字面量的sv后缀,定义在std::literals::string_view_literals命名空间中。使用sv后缀比不使用该后缀的好处是,有关字符串长度的计算在编译期完成,不消耗运行时间。

string_view类型同时还可以作为一个范围定义,因此可以用它来遍历所表示字符序列中的每个字符。例如:

string_view类型的显著限制在于它是只读的。例如,将参数字符串中的字符修改为小写的函数,就不能通过string_view传递参数。下面的代码将无法通过编译:

这种情况下,span也许是个值得考虑的选择。任何时候,一个string_view对象都相当于一个指针,所有与指针有关的限制,对string_view对象同样成立。例如下面的代码虽然可以通过编译:

但使用bad函数的返回值将导致无法预期的后果:

在bad函数中,"Once upon a time"s,实际上是一个定义在局部作用域中的匿名string对象。该函数的返回值是一个string_view对象,其中的指针指向此局部string对象的字符序列。bad函数一旦返回,该局部string对象即被销毁,存放字符序列的内存空间即被释放,所返回string_view对象中的指针即成为悬空指针。对悬空指针的访问结果,在任何时候都是未定义的。

如果在使用string_view的过程中发生下标越界,结果将是未定义的。如果需要范围检查支持,可以使用at函数,它会在下标越界时抛出out_of_range异常。例如:

或者使用gsl::string_span作为替代。

C++17:string_view

10.4 正则表达式

正则表达式是一种很强大的文本处理工具。它通过一种简单、精炼的方法,描述文本中的模式,如“192.168.0.11”形式的IPv4地址,或形如“2024/10/21”的日期等。同时,它还提供了在文本中高效地查找模式的方法。C++标准库在<regex>头文件中定义了std::regex类及一系列函数,以支持对正则表达式的编程。下面是一个描述类似“TX 77845-4467”的美国邮政编码的正则表达式:

它描述了一种文本模式:以两个字母(\w{2})开始,后跟任意个空白字符(\s*),然后是五个数字(\d{5}),可能(?)还带有一个连字符引导的四位数字(-\d{4})。可以用该正则表达式,构造一个类型为regex的对象。例如:

其中的“R"(...)"”表示原始字符串字面量。原始字符串中反斜线连同后面的字符,不会被当做转义字符看待。“\n”就是一个“\”字符和一个“n”字符,不转义为换行符。正则表达式中包含大量的反斜线,使用原始字符串字面量会非常方便。如果换成常规字符串字面量,需要写成如下等价形式:

<regex>头文件中,标准库为正则表达式提供如下支持:

函数功能
regex_math将正则表达式与一个(已知长度的)字符串进行匹配
regex_search在一个(任意长的)数据流中,搜索与正则表达式匹配的字符串
regex_replace在一个(任意长的)数据流中,替换与正则表达式匹配的字符串
regex_iterator遍历匹配的结果和子匹配
regex_token_iterator遍历未匹配的部分

C++11:原始字符串字面量

C++11:正则表达式(reg)

10.4.1 搜索

使用模式的最简单的方式,就是在流中搜索它。例如:

regex_search函数在line字符串中搜索任何与正则表达式pat匹配的子字符串,并将其保存到matches中,返回true。若未找到任何匹配,则返回false。matches其实就是一个vector<string>。matches[0]对应整个匹配,其余元素为子匹配。例如:

输入:

输出:

matches[0]对应整个模式“\w{2}\s*\d{5}(-\d{4})?”,而matches[1]则对应其中的子模式“-\d{4}”。

换行符“\n”也可以作为模式的一部分,因此可以对包含换行符的多行模式进行搜索。

正则表达式的编译,由regex在运行时完成。

10.4.2 正则表达式的符号表示

regex可以识别几种不同的正则表达式符号集,如ECMA等。符号集中的一些字符具有特殊意义,如下表所示:

正则表达式中的特殊字符含义
.任意单个字符
(分组开始
)分组结束
[字符集开始
]字符集结束
{重复开始
}重复结束
\下一个字符有特殊含义
*后缀,零次或多次
+后缀,一次或多次
?后缀,零次或一次
|二选一(或)
^行开始、非
$行结束

例如下面的正则表达式:

描述了这样一种模式:以零或多个A开始,后跟一或多个B,最后是一个可选的C。以下文本均与该模式匹配:

而下面的文本则不与该模式匹配:

正则表达式中被圆括号括起来分组,构成了一个子模式,可以从smatch中单独抽取出来。例如:

下表中的后缀表示一个模式是可选的还是重复多次的,如无这些后缀,则只能出现一次:

可选/重复后缀含义
{n}严格重复n次
{n,}至少重复n次
{n,m}至少重复n次,至多重复m次
*零次或多次,等价于{0,}
+一次或多次,等价于{1,}
?零次或一次,等价于{0,1}

例如下面的正则表达式:

描述了这样一种模式:3个A,后跟2到4个B,最后零或多个C。以下文本均与该模式匹配:

而下面的文本则不与该模式匹配:

默认情况下,模式匹配器总是试图查找最长的匹配,即所谓最长匹配(贪心匹配)法则。例如用正则表达式“(ab)+”在文本“cccabababddd”中查找匹配,得到的结果是“ababab”。而如果在任何表示重复的符号({}、*、+、?)之后再放一个“?”后缀,则会采用最短匹配(懒惰匹配)法则。例如用正则表达式“(ab)+?”在文本“cccabababddd”中查找匹配,得到的结果是“ab”。

在正则表达式中,用类似“[ ... ]”的形式表示一个特定的字符集。例如用“[[:digit:]]”表示十进制数字字符集,用“[^[:digit:]]”表示非十进制数字字符集。下表列出了一些常用的字符集关键字:

关键字字符集
alnum字母、数字字符
alpha字母字符
blank除行分隔符以外的空白字符
cntrl控制字符
d十进制数字字符
digit十进制数字字符
graph图形字符
lower小写字符
print可打印字符
punct标点字符
s空白字符
space空白字符
upper大写字符
w字母、数字、下划线字符
xdigit十六进制数字字符

标准中规定,一些字符集还可以采用简写形式。例如:

简写等价字符集
\d[[:digit]]十进制数字字符
\s[[:space:]]空白字符
\w[_[:alnum:]]字母、数字、下划线字符
\D[^[:digit:]]非十进制数字字符
\S[^[:space:]]非空白字符
\W[^_[:alnum:]]非字母、数字、下划线字符

此外,还有一些非标准的字符集简写形式。例如:

简写等价字符集
\l[[:lower:]]小写字符
\u[[:upper:]]大写字符
\L[^[:lower:]]非小写字符
\U[^[:upper:]]非大写字符

为了保证程序代码的可移植性,建议使用完整的字符集名称,如“[[:digit:]]”,而非其简写形式,如“\d”。

合法的C++标识符(变量名、函数名、类型名等),必须以字母或下划线开头,后跟一个由字母、数字或下划线组成的字符序列,该序列可以为空。为了描述这样的模式,考虑如下正则表达式:

下面的函数用于判断一个字符串是否是合法的C++标识符:

注意,要在一个普通字符串字面量中包含一个反斜线,必须使用两个反斜线。使用原始字符串字面量可以避免这种麻烦。例如:

下表罗列了一些正则表达式,及与之匹配和不匹配的文本示例:

正则表达式匹配示例不匹配示例
Ax*A
Axx
Axxxxx
B
Ax+Ax
Axxx
Axxxxxx
A
\d-?\d1-2
12
1--2
\w{2}-\d{4,5}Ab-1234
XX-54321
22-2222
22_2222
(\d*:)?(\d+)12:3
1:23
123
:123
123:
(bs|BS)bs
BS
bS
[aeiouy]仅由元音字母组成的字符串x
[^aeiouy]仅有非元音字母组成的字符串e
[a^eiouy]仅由元音字母和“^”组成的字符串_

在一个正则表达式中,被圆括号括起来的部分形成了一个分组,同时也是一个子模式,用sub_match表示。如果只想表示一个分组,而无意将其作为子模式,则应使用“(?: ... )”,而非单纯的“( ... )”。例如:

假设对数字之前的字符不感兴趣,则可写成:

这里的“?:”使得只有数字部分才是子模式。

下表罗列了一些正则表达式分组的例子:

正则表达式分组
\d*\s\w+无分组
(\d*)\s(\w+)两个分组
(\d*)(\s(\w+))+两个分组(分组没有嵌套)
(\s*\w*)+一个分组,但有一到多个子模式,只有最
后一个子模式被保存为一个sub_match
<(.*?)>(.*?)</\1>三个分组,“\1”表示与分组1一样

最后一个模式对解析XML文件非常有用。它可以查找标签开始(如“<b>”)和结束(如“</b>”)标记。注意,这里对标签开始和结束间的子模式,使用了懒惰匹配法则(“.*?”)。如果使用默认的贪心匹配法则(“.*”),对于下面的文本会发生问题:

如果第二个子模式采用贪心匹配法则,即“<(.*?)>(.*)</\1>”,则会将第一个“<b>”和最后一个“</b>”配对。这是正确的匹配行为,但或许并非代码编写者所期望的。

10.4.3 迭代器

可以借助regex_iterator类型的对象,遍历一个流中所有与给定模式匹配的对象。为了遍历一个字符串中所有与给定模式匹配的子字符串,可以使用sregex_iterator(regex_iterator<string>)类型的迭代器。例如下面的代码将从一个完整的句子中拆分出每个单词,滤除单词间的空白字符和标点符号:

regex_iterator的默认构造函数用于创建一个表示序列终止的迭代器。regex_iterator是一种双向迭代器,因此不能用于遍历istream流对象。另外,只能通过regex_iterator读取数据,而不能通过它写入数据。

10.5 建议