使用string和string_view(一)——C风格字符串、字符串字面量和std::string
目录
- 1. C风格字符串
- 2. 字符串字面量
- 2.1 简介
- 2.2 原始字符串字面量
- 3. std::string
- 3.1 C风格字符串的问题
- 3.2 使用string类
- 3.3 字符串比较
- 3.4 内存处理
- 3.5 与C风格字符串的兼容
- 3.6 std::string字面量
- 3.7 std::vector和字符串的CTAD
- 参考
1. C风格字符串
在C语言中,字符串表示为字符的数组。字符串中的最后一个字符是null字符(\0),这样,操作字符串的代码就知道字符串在哪里结束。官方将这个null字符定义为NUL,这个拼写中只有一个L而不是两个L。NUL和NULL指针是两回事。尽管C++提供了更好的字符串抽象,但理解C语言中使用的字符串技术非常重要,因为在C++程序设计中仍可能使用这些技术。最常见的一种情况是C++程序调用某个第三方库或与操作系统交互时调用基于C语言的接口。
目前,程序员使用C字符串时最常犯的错误是忘记为\0字符分配空间。例如,字符串"hello"看上去有5个字符长,但在内存中需要6个字符的空间才能保存这个字符串的值。
C++包含一些来自C语言的字符串操作函数,它们在<cstring>头文件中定义。通常,这些函数不直接操作内存分配。例如,strcpy()函数有两个字符串参数。这个函数将第二个字符串复制到第一个字符串,而不考虑第二个字符串能否恰当地填入第一个字符串。下面的代码试图在strcpy()函数之上构建一个包装器,这个包装器能够分配正确数量的内存并返回结果,而不是接收一个已经分配好的字符串。这个最初的尝试会被证明是错误的,函数通过strlen()函数获取字符串的长度。调用者负责释放由copyString()分配的内存。
char* copyString(const char* str) {
char* result { new char[strlen(str)] }; // bug! off by one!
strcpy(result, str);
return result;
}
copyString()函数的代码这样写是不正确的。strlen()函数返回字符串的长度,而不是保存这个字符串所需的内存量。对于字符串"hello",strlen()返回的是5,而不是6。为字符串分配内存的正确方式是在实际字符所需的空间加1>。一开始看到到处都需要加1可能会感到有点奇怪,但这是其工作方式,所以在使用C风格字符串时要记住这一点。正确实现的代码如下:
char* copyString(const char* str) {
char* result { new char[strlen(str) + 1] };
strcpy(result, str);
return result;
}
要记住strlen()只返回字符串中实际字符数的一种方式是:考虑如果为一个由几个其他字符串构成的字符串分配空间,应该怎么做。例如,如果函数接收3个字符串参数,并返回一个由这3个字符串串联而成的字符串,那么这个返回的字符串应该有多大?为精确分配足够空间,空间的大小应该是3个字符串的长度相加,然后加上1留给尾部的\0字符。如果strlen()的字符串长度包括\0,那么分配的内存就会过大。下面的代码通过strcpy()和strcat()函数执行这个操作:
char* appendStrings(const char* str1, const char* str2, const char* str3) {
char* result { new char[strlen(str1) + strlen(str2) + strlen(str3) + 1] };
strcpy(result, str1);
strcat(result, str2);
strcat(result, str3);
return result;
}
C和C++中的sizeof()操作符可用于获得给定数据类型或变量的大小。例如,sizeof(char)返回1,因为字符的大小是1字节。但在C风格字符串中,sizeof()和strlen()是不同的。绝对不要通过sizeof()获取字符串的大小。它根据C风格的字符串的存储方式来返回大小。如果C风格字符串存储为char[],则sizeof()返回字符串使用的实际内存,包括\0字符。例如:
char text1[] { "abcdef" };
size_t s1 { sizeof(text1) }; // is 7.
size_t s2 { strlen(text1) }; // is 6.
但是,如果C风格字符串存储为char*,sizeof()就返回指针的大小!例如:
const char* text2 { "abcdef" };
size_t s3 { sizeof(text2) }; // is platform-dependent.
size_t s4 { strlen(text2) }; // is 6.
在32位模式下编译期,s3的值为4;而在64位模式下编译期,s3的值为8,因为这返回的是指针const char*的大小。
警告:在Microsoft Visual Studio中使用C风格字符串函数时,编译器可能会给出安全相关的警告甚至错误,说明这些函数已经被弃用了。使用其他C标准库函数可以避免这些警告,例如strcpy_s()和strcat_s(),这些函数是安全C库(ISO/IEC TR 24731)标准的一部分。然而,最好的解决方案是切换到C++的string类。
2. 字符串字面量
2.1 简介
注意,C++程序编写的字符串要用引号包围。例如,下面的代码输出字符串hello,这段代码包含这个字符串本身,而不是一个包含这个字符串的变量。
std::cout << "hello" << std::endl;
在上面的代码中,"hello"是一个字符串字面值,因为这个字符串以值的形式写出,而不是一个变量。字符串字面量实际上存储在内存的只读部分。通过这种方式,编译器可重用相同字符串字面量的引用,从而优化内存的使用。也就是说,即使一个程序使用了500次"hello"字符串字面量,编译器也只在内存中创建一个hello实例。这种技术称为字面量池。
字符串字面值可赋值给变量,但因为字符串字面量位于内存的只读部分,且使用了字面量池,所以这样做会产生风险。C++标准正式指出:字符串字面量的类型为n个const char数组,然而为了向后兼容较老的不支持const的代码,大部分编译器不会强制程序将字符串字面量赋值给const char*类型的变量。这些编译器允许将字符串字面量赋值给不带有const的char*,而且整个程序可正常运行,除非试图修改字符串。一般情况下,试图修改字符串字面量的行为是没有定义的。可能会导致程序崩溃;可能使程序继续执行,看起来却有莫名其妙的副作用;可能不加通告地忽略修改行为;可能修改行为是有效的,这完全取决于编译器。例如,下面的代码展示了未定义的行为:
char* ptr { "hello" }; // assign the string literal to a variable.
ptr[1] = 'a'; // undefined behavior!
一种更安全的编码方式是在引用字符串常量时,使用指向const字符的指针。下面的代码包含同样的bug,但由于这段代码将字符串字面量赋值给const char*,因此编译器会捕获到任何写入只读内存的企图。
const char* ptr { "hello" }; // assign the string literal to a variable.
ptr[1] = 'a'; // error! attempts to write to read-only memory.
还可将字符串字面量用作字符数组char[]的初始值。这种情况下,编译器会创建一个足以放下这个字符串的数组,然后将字符串复制到这个数组。因此,编译器不会将字面量放在只读的内存中,也不会进行字面量池的操作。
char arr[] { "hello" }; // compiler takes care of creating appropriate sized
// character array arr.
arr[1] = 'a'; // the contents can be modified.
2.2 原始字符串字面量
原始字符串字面量是可横跨多行代码的字符串字面量,不需要转义嵌入的双引号,像\t和\n这种转义序列不按照转义序列的方式处理,而是按照普通文本的方式处理。如果像下面这样编写普通的字符串字面量,那么会收到一个编译器错误,因为字符串中包含了未转义的双引号。
const char* str { "Hello "World"!" }; / error!
对于普通字符串,必须转义双引号,如下所示。
const char* str { "Hello \"World\"!" };
对于原始字符串字面量,就不需要转义双引号了。原始字符串字面量以R“(开头,以)"结尾。
const char* str { R"(Hello "World"!)" };
如果需要一个包含多行的字符串,不使用原始字符串字面量的话,就需要在字符串中新行的开始位置嵌入\n转义序列。例如:
const char* str { "Line 1\nLine 2" };
如果将这个字符串输出到控制台,将看到以下结果。
Line 1
Line 2
而使用原始字符串字面量,不使用\n转义序列来开始一个新行,只需要在源代码中按下Enter键以开始一个真正的新行。这与前面使用嵌入的\n的代码片段的效果相同。
const char* str { R"(Line 1
Line 2)" };
在原始字符串字面量中忽略了转义序列。例如,在下面的原始字符串字面量中,\t转义序列没有替换为实际的制表符字符,而是按照字面形式保存。
const char* str { R"(Is the following a tab character? \t)" };
因此,如果将此字符串输出到控制台,将看到以下结果。
Is the following a tab character? \t
因为原始字符串字面量以)“结尾,所以使用这种语法时,不能在字符串中嵌入)”。例如,下面的字符串是不合法的,因为在这个字符串的中间包含)“。
const char* str { R"(Embedded )" characters)" }; // error!
如果需要嵌入)”,则需要使用扩展的原始字符串字面量语法,如下所示。
R"d-char-sequence(r-char-sequence)d-char-sequence"
r-char-sequence是实际的原始字符串。d-char-sequence是可选的分隔符序列,原始字符串首尾的分隔符序列应该一致。分隔符序列最多能有16个字符。应选择未出现在原始字符串字面量中的序列作为分隔符序列。
上例可改用唯一的分隔符序列。
const char* str { R"-((Embedded )" characters)-" };
在操作数据库查询字符串、正则表达式和文件路径时,原始字符串字面量可以令程序的编写更加方便。
3. std::string
C++提供了一个得到极大改善的字符串概念,并作为标准库的一部分提供了这个字符串的实现。在C++中,std::string是一个类(实际上是basic_string模板类的一个实例),这个类支持<cstring>中提供的许多功能,还能自动管理内存分配。string类在std名称空间的<string>头文件中定义。
3.1 C风格字符串的问题
为理解C++ String类的必要性,需要考虑C风格字符串的优势和劣势。
优势:
1. 很简单,底层使用了基本的字符类型和数组结构。
2. 轻量级,如果使用得当,只会占用所需的内存。
3. 可按操作原始内存的方式轻松操作和复制字符串。
4. 如果你是一名C语言程序员,为什么还要学习新事物?
劣势:
1. 为了模拟一等数据类型字符串,需要付出很多努力。
2. 很容易产生难以找到的内存bug,且难以解决。
3. 没有利用C++的面向对象特性。
4. 要求程序员了解底层的表示方式。
C++的String类解决了C风格字符串的所有问题,并且证明C风格字符串相比一等数据类型的那些优势实际上是无关紧要的。
3.2 使用string类
尽管string是一个类,但是几乎总可将string当成内建类型使用。事实上,把string想象成简单类型更容易发挥string的作用。通过运算符重载的神奇作用,C++的string使用起来比C字符串容易得多。例如,给string重新定义+运算符,以表示字符串串联。下面的例子会得到1234:
std::string a { "12" };
std::string b { "34" };
std::string c;
c = a + b; // c is "1234".
+=运算符也被重载了,通过这个运算符可以轻松地追加一个字符串。
a += b; // a is "1234".
3.3 字符串比较
C风格字符串的另一个问题是不能通过==运算符进行比较。假设有以下两个字符串。
char* a { "12" };
char b[] { "12" };
按照下述方式编写的比较操作始终返回false,因为它比较的是指针的值,而不是字符串的内容。
if ( a == b ) {
/* ... */
}
注意C数组和指针是相关的。可将C数组看成指向数组中第一个元素的指针。
要比较C字符串,需要编写这样的代码:
if ( strcmp(a, b) == 0 ) {
/* ... */
}
此外,C字符串也无法通过<、<=、>=或>进行比较,因此需要通过strcmp()根据字符串的字典顺序返回-1、0和1的值判断。这样会产生非常笨拙且可读性低的代码,还很容易出错。
在C++的String类中,操作符都被重载了,这些运算符可以操作真正的字符串字符。单独的字符可通过方括号运算符[]访问。
C++ string类另外提供了一个compare()方法,它的行为类似于strcmp()并且具有类似的返回类型。实例如下:
std::string a { "12" };
std::string b { "34" };
auto result { a.compare(b) };
if ( result < 0 ) {
std::cout << "less\n";
} else if ( result = 0 ) {
std::cout << "equal\n";
} else {
std::cout << "greater\n";
}
与strcmp()一样,这样使用起来很麻烦,你需要记住返回值的确切含义。此外,由于返回值只是一个整数,很容易忘记这个整数的含义,写出以下的错误代码来比较相等性。
if ( a.compare(b) ) {
std::cout << "equal\n";
}
compare()为相等返回0,为不相等返回任何其他值。所以这行代码与它的意图相反,也就是说,它对不相等的字符串输出"equal"!如果只想检查两个字符串是否相等,不要使用compare(),只需要使用==。
C++20通过三向比较运算符改进了这一切。字符串类完全支持这个运算符,示例如下:
auto result { a <=> b };
if ( std::is_lt(result) ) {
std::cout << "less\n";
}
if ( std::is_gt(result) ) {
std::cout << "greater\n";
}
if ( std::is_eq(result) ) {
std::cout << "equal\n";
}
3.4 内存处理
如下面的代码所示,当string操作需要扩展string时,string类能够自动处理内存需求,因此不会出现内存溢出的情况了。
std::string myString { "hello" };
myString += ", there";
std::string myOtherString { myString };
if ( myString == myOtherString ) {
myOtherString[0] = 'H';
}
std::cout << myString << "\n";
std::cout << myOtherString << "\n";
这段代码的输出如下所示:
hello, there
Hello, there
在这个例子中有几点需要注意。一是注意即使字符串被分配和调整大小,也不会出现内存泄漏的情况。所有这些string对象都创建为栈中的变量。尽管string类肯定需要完成大量分配内存和调整大小的工作,但是string类的析构函数会在string对象离开作用域时清理内存。
另外需要注意的是,运算符以预期的方式工作。例如,=运算符复制字符串,这是最有可能预期的操作。如果习惯使用基于数组的字符串,那么这种方式有可能带来全新的体验,也可能令你迷惑。不用担心,一旦学会信任string类能做出正确的行为,那么代码的编写会简单得多。
3.5 与C风格字符串的兼容
string类支持一系列其他的操作,以下是一部分:
1. substr(pos, len):返回从给定位置开始的给定长度的子字符串。
2. find(str):如果找到了字符串,返回它的位置;如果没有找到,返回string::npos。
3. replace(pos, len, str):将字符串的一部分(给定开始位置和长度)替换为另一个字符串。
4. starts_with(str)/ends_with(str):如果一个字符串以给定的子串开始或结尾,则返回true。
下面是一个小代码片段,展示了其中的一些操作。
std::string strHello { "Hello!!" };
std::string strWorld { "The World..." };
auto position { strHello.find("!!") };
if ( position != std::string::npos ) {
// found the "!!" substring, now replace it.
strHello.replace(position, 2, strWorld.substr(3, 6));
}
std::cout << strHello << "\n";
输出如下:
Hello World
注意:从C++20开始,std::string是constexpr类,这意味着string可用于在编译期执行操作,并可用于constexpr函数和类的实现。
3.6 std::string字面量
源代码中的字符串字面量通常解释为const char*。使用用户定义的标准字面量s可以把字符串字面量解释为std::string。例如:
auto string1 { "Hello World" }; // string1 is a const char*.
auto string2 { "Hello World"s }; // string2 is an std::string.
标准用户定义字面量s在std::literals::string_literals名称空间中定义。但是string_literals和literals名称空间都是所谓的内联名称空间。因此使用以下选项可以使这些字符串字面量可用于你的代码。
using namespace std;
using namespace std::literals;
using namespace std::string_literals;
using namespace std::literals::string_literals;
基本上,在内联名称空间中声明的所有内容都会自动在父名称空间可用。要自己定义内联名称空间,可以使用inline关键字。例如,string_literals内联名称空间定义如下:
namespace std {
inline namespace literals {
inline namespace string_literals {
// ...
}
}
}
3.7 std::vector和字符串的CTAD
std::vector支持类模板参数推导CTAD,允许编译器根据初始化列表自动推导vector的类型,对字符串vector使用CTAD时必须小心。以vector的以下声明为例:
std::vector names { "John", "Sam", "Joe" };
推导出的类型将是vector<const char*>,而不是vector<std::string>!这是一个很容易犯的错误,可能导致代码出现一些奇怪的行为,甚至崩溃。这取决于之后对vector的处理方式。
如果你需要一个vector<std::string>,可以使用上面一节提到的std::string字面量。注意下例中每个字符串字面量后面的s:
std::vector names { "John"s, "Sam"s, "Joe"s };
参考
[比] 马克·格雷戈勒著 程序喵大人 惠惠 墨梵 译 C++20高级编程(第五版)