9
字符串和正则表达式
使用规范的语言。
—— 斯特伦克 和 怀特1
9.1 导言
文本操占据了多数程序的大部分工作。
C++标准库提供了一个 string
类型以解救大多数用户,
不必再通过指针进行字符串数组的 C 风格字符串操作。
string_view
类型可以操作字符序列,无论其存储方式如何
(比如:在std::string
或char[]
中)。
此外还提供了正则表达式匹配,以便在文本中寻找模式。
正则表达式的形式与大多数现代语言中呈现的方式类似。
无论string
还是regex
对象,都可以使用多种字符类型(例如:Unicode)。
9.2 字符串
标准库提供了string
类型,用以弥补字符串文本(§1.2.1)的不足;
string
是个Regulae
类型(§7.2, §12.7),
用于持有并操作一个某种类型字符的序列。
string
提供了丰富有用的字符串操作,比方说连接字符串。例如:
string compose(const string& name, const string& domain)
{
return name + '@' + domain;
}
auto addr = compose("dmr","bell−labs.com");
此处,addr
被初始化为字符序列dmr@bell−labs.com
。
string
“加法”的意思是连接操作。
你可以把一个string
、一个字符串文本、C-风格字符串或者一个字符连接到string
上。
标准string
有个转移构造函数,所以就算是传值返回长的string
也很高效(§5.2.2)。
在大量应用中,最常见的字符串连接形式是把什么东西添加到某个string
的末尾。
此功能可以直接使用+=
操作。例如:
void m2(string& s1, string& s2)
{
s1 = s1 + '\n'; // 追加换行
s2 += '\n'; // 追加换行
}
这两种附加到string
末尾的方式语意等价,但我更青睐后者,
对于所执行的内容来说,它更明确、简练并可能更高效。
string
是可变的,除了=
、+=
,还支持取下标(使用 []
)、取自字符串操作。
例如:
string name = "Niels Stroustrup";
void m3()
{
string s = name.substr(6,10); // s = "Stroustrup"
name.replace(0,5,"nicholas"); // name 变成 "nicholas Stroustrup"
name[0] = toupper(name[0]); // name 变成 "Nicholas Stroustrup"
}
substr()
操作返回一个string
,该string
是其参数标示出的子字符串的副本。
第一个参数是指向string
的下标(一个位置),第二个参数是所需子字符串的长度。
由于下标从0
开始,s
的值便是Stroustrup
。
replace()
操作以某个值替换一个子字符串。
本例中,子字符串是始自0
,长度5
的Niels
;它被替换为nicholas
。
最后,我将首字符替换为对应的大写字符。
因此,name
最终的值便是Nicholas Stroustrup
。
请留意,替代品字符串无需与被替换的子字符串长度相同。
string
有诸多便利操作,诸如赋值(使用=
),
取下标(使用[]
或像vecor
那样使用at()
;§11.2.2),
相等性比较(使用==
和!=
),以及字典序比较(使用<
、<=
、>
和>=
),
遍历(像vector
那样使用迭代器;§12.2),输入(§10.3)和流(§10.8)。
显然,string
可以相互之间比较,与C-风格字符串比较(§1。7.1),
与字符串文本比较,例如:
string incantation;
void respond(const string& answer)
{
if (answer == incantation) {
// 施放魔法
}
else if (answer == "yes") {
// ...
}
// ...
}
如果你需要一个C-风格字符串(零结尾的char
数组),
string
为其持有的字符提供一个只读访问。例如:
void print(const string& s)
{
printf("For people who like printf: %s\n",s.c_str()); // s.c_str() 返回一个指针,指向 s 持有的那些字符
cout << "For people who like streams: " << s << '\n';
}
从定义方面讲,字符串文本就是一个 const char*
。
要获取一个std::string
类型的文本,请用s
后缀。例如:
auto s = "Cat"s; // 一个 std::string
auto p = "Dog"; // 一个C-风格字符串,也就是: const char*
要启用s
后缀,
你需要使用命名空间std::literals::string_literals
(§5.4.4)。
9.2.1 string
的实现
实现一个字符串类,是个很受欢迎并有益的练习。
不过,对于广泛的用途来说,就算费尽心力打磨的处女作,
也罕能与标准库的sting
便利及性能匹敌。
如今,string
通常使用 短字符串优化(short-string optimization)来实现。
就是说,较短的字符串值保留在string
对象内部,只有较长的字符串会置于自由存储区。
考虑此例:
string s1 {"Annemarie"}; // 短字符串
string s2 {"Annemarie Stroustrup"}; // 长字符串
其内存配置将类似于这样:
当某个string
的值从短字符串变成长字符串(或相反),其内存配置也将相应调整。
一个“短”字符串应该有多少个字符呢?这由实现定义,但是“14个字符左右”当相去不远。
string
的具体性能严重依赖运行时环境。
尤其在多线程实现中,内存分配的代价相对高昂。
并且在使用大量长度参差不齐的字符串时将产生内存碎片。
这些因素就是短字符串优化如此普遍应用的原因。
为处理多种字符集,string
实际上是采用字符类型char
的通用模板basic_string
的别名:
template<typename Char>
class basic_string
{
// ... string of Char ...
};
using string = basic_string<char>;
用户可定义任意字符类型的字符串。
例如:假设我们有个日文字符类型Jchar
,就可以这么写:
using Jstring = basic_string<Jchar>;
现在,就可以针对Jstring
——日文字符的字符串——进行所有常规操作了。
9.3 字符串视图
针对字符串序列,最常见的用途是将其传给某个函数去读取。
此操作可以有多种方式达成,将string
传值,传字符串的引用,或者是C-风格字符串。
在许多系统中,还有进一步的替代方案,例如标准之外的字符串类型。
所有这些情形中,当我们想传递一个子字符串,就要涉及额外的复杂度。
为了解决这个问题,标准库提供了string_view
;
string_view
基本上是个(指针,长度)对,以表示一个字符序列:
string_view
为一段连续的字符序列提供了访问方式。
这些字符可采用多种方式储存,包括在string
中以及C-风格字符串中。
string_view
像是一个指针或引用,就是说,它不拥有其指向的那些字符。
在这一点上,它与一个由迭代器(§12.3)构成的STL pair相似。
考虑以下这个简单的函数,它连接两个字符串:
string cat(string_view sv1, string_view sv2)
{
string res(sv1.length()+sv2.length());
char* p = &res[0];
for (char c : sv1) // 一种复制方式
*p++ = c;
copy(sv2.begin(),sv2.end(),p); // 另一种方式
return res;
}
可以这样调用cat()
:
string king = "Harold";
auto s1 = cat(king,"William"); // 字符串和 const char*
auto s2 = cat(king,king); // 字符串和字符串
auto s3 = cat("Edward","Stephen"sv); // const char * 和 string_view
auto s4 = cat("Canute"sv,king);
auto s5 = cat({&king[0],2},"Henry"sv); // HaHenry
auto s6 = cat({&king[0],2},{&king[2],4}); // Harold
跟接收const string&
参数的compose()
(§9.2)相比,
这个cat()
具有三个优势:
- 它可被用于多种不同方式管理的字符序列。
- 不会为C-风格字符串参数创建临时的
string
参数。 - 可以轻松的传入子字符串。
请注意sv
(“string_view”)后缀,要启用它,需要:
using namespace std::literals::string_view_literals; // § 5.4.4
何必要多此一举?原因是,当我们传入Edward
时,
需要从const char*
构建出string_view
,因而就需要给这些字符串计数。
而对于"Stephen"sv
,其长度在编译期就计算。
当返回string_view
时,请谨记这与指针非常相像;它需要指向某个东西:
string_view bad()
{
string s = "Once upon a time";
return {&s[5],4}; // 糟糕:返回了指向局部变量的指针
}
此处返回了一个指针,指向一些位于某个string
内的字符,
在我们用到这些字符之前,这个string
就会被销毁。
string_view
有个显著的限制,它是其中那些字符的一个只读视图。
例如,对于一个将其参数修改为小写字符的函数,你无法使用string_view
。
这种情况下,请考虑采用gsl::span
或gsl::string_span
(§13.3)。
string_view
越界访问的行为是 未指明的(unspecified)。
如果你需要一个确定性的越界检查,请使用at()
,
它为越界访问抛出out_of_range
异常,
也可以使用gsl::string_span
(§13.3),或者“加点儿小心”就是了。
9.4 正则表达式
正则表达式是文本处理的强大工具。
它提供一个容易而简洁方式来描述文本中的模式
(例如:诸如TX 77845
的美国邮编,或者诸如2009-06-07
的ISO-风格日期)
并能够高效地发现这些模式。
在regex
中,标准库为正则表达式提供了支持,其形式是std::regex
类和配套函数。
作为regex
风格的浅尝,我们定义并打印出一个模式:
regex pat {R"(\w{2}\s*\d{5}(-\d{4})?)"}; // 美国邮编模式: XXddddd-dddd 及变体
在任何语言中用过正则表达式的人都能发现\w{2}\s*\d{5}(-\d{4})?
很眼熟。
它指定了一个模式,以两个字母\w{2}
开头,其后紧跟的一些空格\s*
是可选的,
接下来是五位数字\d{5}
以及可选的连接号加四位数字-\d{4}
。
如果你不熟悉正则表达式,这应该是个学习它的好时机
([Stroustrup,2009], [Maddock,2009], [Friedl,1997])。
为了表示这个模式,我用了个以R"(
开头和)
结尾的
原始字符串文本(raw string literal)。
这使得反斜杠和引号可直接用在字符串中(无需转义——译注)。
原始字符串特别适用于正则表达式,因为正则表达式往往包含大量的反斜杠。
如果我用了传统字符串,该模式的定义就会是:
regex pat {"\\w{2}\\s*\\d{5}(-\\d{4})?"}; // 美国邮编模式
在<regex>
中,标准库为正则表达式提供了如下支持:
regex_match()
:针对一个(长度已知的)字符串进行匹配(§9.4.2)。regex_search()
: 在一个(任意长度的)数据流中查找匹配某个正则表达式的一个字符串(§9.4.1)。regex_replace()
: 在一个(任意长度的)数据流中查找匹配某个正则表达式的那些字符串并替换之。regex_iterator()
:在匹配和子匹配中进行遍历(§9.4.3)。regex_token_iterator()
:在未匹配中进行遍历。
9.4.1 查找
对一个模式最简单的应用就是在某个流中进行查找:
int lineno = 0;
for (string line; getline(cin,line); ) { // 读进行缓冲区
++lineno;
smatch matches; // 保存匹配到的字符串
if (regex_search(line,matches,pat)) // 在 line 中查找 pat
cout << lineno << ": " << matches[0] << '\n';
}
regex_search(line,matches,pat)
在line
中进行查找,
查找任何能够匹配存储于正则表达式pat
中的内容,
并将匹配到的任何内容保存在matches
里。
如果未发现匹配,regex_search(line,matches,pat)
返回false
。
matches
变量的类型是smatch
。
其中的“s”代表“子(sub)”或者“字符串(string)”,
一个smatch
是个承载string
类型子匹配的vector
。
第一个元素,此处为matches[0]
,是完整的匹配。
regex_match()
的结果是一个匹配内容的集合,通常以smatch
表示:
void use()
{
ifstream in("file.txt"); // 输入文件
if (!in) // 检查文件是否打开
cerr << "no file\n";
regex pat {R"(\w{2}\s*\d{5}(-\d{4})?)"}; // 美国邮编模式
int lineno = 0;
for (string line; getline(in,line); ) {
++lineno;
smatch matches; // 保存匹配到的字符串
if (regex_search(line, matches, pat)) {
cout << lineno << ": " << matches[0] << '\n'; // 完整匹配
if (1<matches.size() && matches[1].matched) // 如果有子模式
// 并且匹配成功
cout << "\t: " << matches[1] << '\n'; // 子匹配
}
}
}
此函数读取一个文件并寻找美国邮编,例如TX77845
和DC 20500-0001
。
smatch
类型是个正则表达式匹配结果的容器。
此处,matches[0]
是整个匹配,而matches[1]
是可选的四位数字子模式。
换行符\n
可以是模式的组成部分,因此可以查找多行模式。
显而易见,如果要查找多行模式,就不该每次只读取一行。
正则表达式的语法和语意是有意这样设计的,目的是编译成状态机以便高效执行[Cox,2007]。
regex
类型在运行时执行该编译过程。
9.4.2 正则表达式表示法
regex
库可以识别正则表达式表示法的多个变体。
此处,我使用默认的表示法,ECMA标准用于ECMAScript(俗称JavaScript)的一个变体。
正则表达式的语法基于具备特殊意义的字符们:
正则表达式特殊字符 | |
---|---|
. 任意单个字符(“通配符”) |
\ 下一个字符具有特殊意义 |
[ 字符类起始 |
* 零个或更多(后缀操作) |
] 字符类终止 |
+ 一个或更多(后缀操作) |
{ 计数起始 |
? 可选的(零或一个)(后缀操作) |
} 计数终止 |
| 可替换的(或) |
( 分组起始 |
^ 行首;取反 |
) 分组终止 |
$ 行尾 |
例如,可以指定一行文本以零或多个A
开头,后跟一或多个B
,
再跟一个可选的C
,就像这样:
ˆA*B+C?$
可匹配的范例如下:
AAAAAAAAAAAABBBBBBBBBC
BC
B
不可匹配的范例如下:
AAAAA // 没有 B
AAAABC // 以空格开头
AABBCC // C 太多了
模式的一部分若被括在小括号中,则被当作子模式(可单独从smatch
中提取)。例如:
\d+-\d+ // 无子模式
\d+(-\d+) // 一个子模式
(\d+)(-\d+) // 两个子模式
模式可借助后缀设置为可选的或者重复多次(默认有且只能有一次):
重复 |
---|
{n} 恰好n 次 |
{n,} n 或更多次 |
{n,m} 至少n 次并且不超过m 次 |
* 零或多次,即{0,} |
+ 一或多次,即{1,} |
? 可选的(零或一次),即{0,1} |
例如:
A{3}B{2,4}C*
可匹配范例如:
AAABBC
AAABBB
不可匹配范例如:
AABBC // A 不够
AAABC // B 不够
AAABBBBBCCC // B 太多了
在任意的重复符号(?
、*
、+
和{}
)后添加后缀?
,
可令该模式匹配行为“懒惰”或者“不贪婪”。
就是说,在寻找模式的时候,它将寻找最短而非最长的匹配。
默认情况下,模式匹配总是寻找最长匹配;这被称为最长匹配规则(Max Munch rule),
考虑:
ababab
模式(ab)+
匹配整个ababab
,而(ab)+?
仅匹配第一个ab
。
最常见的字符分类如下:
字符分类 |
---|
alnum 任意字母和数字字符 |
alpha 任意字母字符 |
blank 除了行分割符以外的任意空白字符 |
cntrl 任意控制字符 |
d 任意十进制数字字符 |
digit 任意十进制数字字符 |
graph 任意绘图字符 |
lower 任意小写字符 |
print 任意可打印字符 |
punct 任意标点符号 |
s 任意空白字符 |
space 任意空白字符 |
upper 任意大写字符 |
w 任意成词字符(字母数字字符外加下划线) |
xdigit 任意十六进制数字字符 |
在正则表达式中,字符分类的类名必须用中括号[:
:]
括起来。
例如[:digit:]
匹配一个十进制数字字符。
另外,要代表一个字符分类,它们必须被置于一对中括号[]
中。
有多个字符串分类支持速记表示法:
字符分类简写 | ||
---|---|---|
\d |
一个十进制数字字符 | [[:digit:]] |
\s |
一个空白字符(空格、制表符等等) | [[:space:]] |
\w |
一个字母(a-z )或数字字符(0-9 )或下划线(_ ) |
[_[:alnum:]] |
\D |
非\d |
[^[:digit:]] |
\S |
非\s |
[^[:space:]] |
\W |
非\w |
[^_[:alnum:]] |
此外,支持正则表达式的语言常常会提供:
字符分类简写 | ||
---|---|---|
\l |
一个小写字符 | [[:lower:]] |
\u |
一个大写字符 | [[:upper:]] |
\L |
非\l |
[^[:lower:]] |
\U |
非\u |
[^[:upper:]] |
为了最优的可移植性,请使用字符分类名而非这些简写。
作为一个例子,请考虑写一个模式以描述C++的标志符: 一个下划线或字母,后跟一个可能为空的序列,该序列由字母、数字字符或下划线组成。 为阐明细微的差异,我列出了几个错误的范例:
[:alpha:][:alnum:]* // 错误: ":alpha:"集合中的字符后跟...
[[:alpha:]][[:alnum:]]* // 错误: 不接受下划线 ('_' 不是字母)
([[:alpha:]]|_)[[:alnum:]]* // 错误: 下划线也不属于 alnum
([[:alpha:]]|_)([[:alnum:]]|_)* // OK:但略显笨拙
[[:alpha:]_][[:alnum:]_]* // OK:在字符分类里包括了下划线
[_[:alpha:]][_[:alnum:]]* // 也 OK
[_[:alpha:]]\w* // \w 等价于 [_[:alnum:]]
最后,这里有个函数使用regex_match()
(§9.4.1)的最简形式测试某字符串是否标志符:
bool is_identifier(const string& s)
{
regex pat {"[_[:alpha:]]\\w*"}; // 下划线或字母
// 后跟零或多个下划线、字母、数字字符
return regex_match(s,pat);
}
注意,使用在常规字符串文本里的双反斜杠用于引入一个反斜杠。 可用原始字符串文本以缓解特殊字符带来的麻烦。例如:
bool is_identifier(const string& s)
{
regex pat {R"([_[:alpha:]]\w*)"};
return regex_match(s,pat);
}
以下是一些模式的范例:
Ax* // A, Ax, Axxxx
Ax+ // Ax, Axxx 而非 A
\d-?\d // 1-2, 12 而非 1--2
\w{2}-\d{4,5} // Ab-1234, XX-54321, 22-5432 数字字符包含在\w中
(\d*:)?(\d+) // 12:3, 1:23, 123, :123 而非 123:
(bs|BS) // bs, BS 而非 bS
[aeiouy] // a, o, u 英文元音字母, 而非 x
[ˆaeiouy] // x, k 不是英文元音字母, 而非 e
[aˆeiouy] // a, ˆ, o, u 英文元音字母或者 ˆ
潜在的以sub_match
形式表示的group
(子模式)由小括号界定。
如果你需要一对小括号但却不想定义一个子模式,要使用(?:
而非常规的(
。例如:
(\s|:|,)*(\d*) // 可选的空白字符、冒号、和/或逗号,其后跟随一个可选的数字
假设我们不关心数字之前那些字符(比方说是分隔符之类的),就可以这么写:
(?:\s|:|,)*(\d*) // 可选的空白字符、冒号、和/或逗号,其后跟随一个可选的数字
这样可以避免正则表达式引擎存储第一个字符:采用(?:
的这个版本只有一个子模式。
正则表达式群组示例 | |
---|---|
\d*\s\w+ |
无群组(子模式) |
(\d*)\s(\w+) |
两个群组 |
(\d*)(\s(\w+))+ |
两个群组(群组不能嵌套) |
(\s*\w*)+ |
一个群组;一个或更多子模式; 只有最后一个子模式保存为 sub_match |
<(.*?)>(.*?)</\1> |
三个群组;\1 的意思是(同群组1) |
表中最后一个模式在解析XML的时候非常好用。它查找 标签/尾标签 的记号。
注意,我为标签和尾标签间的子模式用了一个不贪婪匹配(懒惰匹配),.*?
。
如果我用了常规的.*
,以下输入将引发一个问题:
Always look on the <b>bright</b> side of <b>life</b>.
针对第一个子模式的贪婪匹配将匹配到第一个<
和最后一个>
。
那也是正确的行为,但却不太可能是程序员想要的。
有关正则表达式更详尽的阐述,请见 [Friedl,1997]。
9.4.3 迭代器
可以定义一个regex_iterator
去遍历一个字符序列,寻找符合某个模式的匹配。
例如,可使用sregex_iterator
(即regex_iterator<string>
),
去输出某个string
中所有空白字符分割的单词:
void test()
{
string input = "aa as; asd ++eˆasdf asdfg";
regex pat {R"(\s+(\w+))"};
for (sregex_iterator p(input.begin(),input.end(),pat); p!=sregex_iterator{}; ++p)
cout << (*p)[1] << '\n';
}
输出是:
as
asd
asdfg
我们丢掉了第一个单词,aa
,因为它前面没有空白字符。
如果把模式简化成R"((\w+))"
,就会得到:
aa
as
asd
e
asdf
asdfg
regex_iterator
是双向迭代器,
故无法将其在(仅提供输入迭代器的)istream
上直接遍历。
另外,也无法借助regex_iterator
执行写操作,
regex_iterator
默认值(regex_iterator{}
)是唯一可能的序列末尾。
9.5 忠告
- [1] 用
std::string
去持有字符序列;§9.2;[CG: SL.str.1]。 - [2] 多用
string
,而非C-风格的字符串函数;§9.1。 - [3] 用
string
去声明变量和成员,而非用作基类;§9.2。 - [4] 以传值方式返回
string
(依赖转移语意);§9.2,§9.2.1。 - [5] 以直接或间接的方式,通过
substr()
读子字符串,replace()
写子字符串;§9.2。 - [6]
string
可按需变长或缩短;§9.2。 - [7] 当你需要越界检查的时候,用
at()
而非迭代器或[]
;§9.2。 - [8] 当你需要优化速度,用迭代器和
[]
,而非at()
;§9.2。 - [9]
string
输入不会溢出;§9.2,§10.3。 - [10] (仅)在你迫不得已的时候,
使用
c_str()
为string
生成一个C风格字符串的形式;§9.2。 - [11] 用
stringstream
或者通用的值提取函数(如to<X>
) 做字符串的数值转换;§10.8。 - [12]
basic_string
可用于生成任意类型字符的字符串;§9.2.1。 - [13] 把
s
后缀用于字符串文本的意思是使之成为标准库的string
; §9.3 [CG: SL.str.12]。 - [14] 在待读取的字符序列存储方式多种多样的时候,
以
string_view
作为函数参数;§9.3 [CG: SL.str.2]。 - [15] 在待写入的字符序列存储方式多种多样的时候,以
gsl::string_span
作为函数参数;§9.3 [CG: SL.str.2] [CG: SL.str.2]。 - [16] 把
string_view
看作附有长度的指针;它并不拥有那些字符;§9.3。 - [17] 把
sv
后缀用于字符串文本的意思是使之成为标准库的string_view
;§9.3。 - [18] 对于绝大多数常规的正则表达式,请使用
regex
;§9.4。 - [19] 除了最简单的模式,请采用原始字符串文本;§9.4。
- [20] 用
regex_match()
去匹配完整的输入;§9.4,§9.4.2。 - [21] 用
regex_search()
在输入流中查找模式;§9.4.1。 - [22] 正则表达式的写法可以针对多种标准进行调整;§9.4.2。
- [23] 默认的正则表达式写法遵循 ECMAScript;§9.4.2。
- [24] 请保持克制,正则表达式很容易沦为只写语言;§9.4.2。
- [25] 记住,
\i
可以表示同前面某个子模式;§9.4.2。 - [26] 可以用
?
把模式变得“懒惰”;§9.4.2。 - [27] 可以用
regex_iterator
在流中遍历查找模式;§9.4.3。
1. 出自 上海译文出版社 1992年12月 出版的《英文写作指南》(陈一鸣 译),引用内容出现在该书第157页的“提示二十一”。 —— 译者注 ↩