字符串是C++程序员每天都在打交道的东西,但和其他基础语法相比,string中又有很多“陷阱”,往往会让初学者陷入迷惑。作为C++系列的开篇,本章节是笔者在学习过程中对string类进行的梳理总结。
和很多把字符串视作对象的语言不同,C语言中的字符串几乎就如同后加入的功能,它并没有真正用好string数据类型,只有简单的字节数组。而C++提供了string作为一等数据类型,并为其设计了一系列好用的工具。
本章将首先介绍C风格的字符串,然后会介绍C++中的string类,以及它的“只读版本”string_view。
尽管C++提供了更好的字符串抽象,但我们还是要从C语言中开始,因为在C++程序设计中仍然可能使用这些代码。
在C语言中,字符串被表示为字符的数组,字符串的最后一个字符被规定为 null(/0) 以告知编译器字符串在哪里结束。所以,程序员第一个常踩的坑是忘记为/0分配存储空间(例如“hello”看上去是五个字符,实际上需要六个字符来存储)
C++包含一些来自C语言的字符串操作函数,他们在< cstring >头文件中定义,通常这些函数不直接操作内存分配。比如strcpy()函数有两个字符串参数,它把第二个字符串复制到第一个字符串,而并不考虑它能否恰当的填入。
char* copyString(const char* str){
char* result{ new char[ strlen(str) + 1 ] }; // +1 for '/0'
strcpy(result , str);
return result;
}
要注意strlen()函数只返回字符串的实际长度,与之对应的是sizeof()函数。sizeof()返回给定数据类型或变量的大小
char text1[] {"abcdefg"};
size_t s1 { sizeof(text1) }; //is 8
size_t s2 { strlen(text1) }; //is 7
但如果C风格的字符串储存为char*,sizeof()函数返回的就会是这个指针的大小
const char* str {"abcdefg"};
size_t s3 { sizeof(str) }; //size of pointer
cout<<"hello"<<endl;
在上面的代码中,“hello”是一个字符串字面量,因为它以值的形式出现,而不是一个变量。它储存在内存的只读部分,编译器通过这种方式来重用相同的字符串字面量。也就是说,即使一个程序使用了1000次“hello”,编译器也只在内存中创建一个hello实例。这种技术称作字面量池(literal pooling)
因为它存储在内存的只读部分,C++标准将其定义为“n个const char的数组“。但在实际使用中,为了兼容不支持const的代码,编译器通常会允许把字符串字面量赋值给不带const的char*。但当我们尝试修改字面量时,会产生未定义行为。
char* ptr { "hello" };
ptr[1] = 'E'; // Undefined behavior;
还可以把字符串字面量作为字符数组char[]的初始值,这种情况下,编译器会创建一个合适大小的字符数组,然后把字符串赋值到数组中。此时,编译器不会把字面量放在只读区,也不会设计字面量池的操作。
char arr[] { "hello" };
arr[1] = 'E'; //is OK
原始字符串是指可以横跨多行代码的字符串字面量,而不需要借助转义字符。原始字符串形如R”( … )“。
const char* str1{ "hello "world"!"}; // Error!
const char* str2{ "hello \"world\"!"}; // is OK!
const char* str3{ R"(hello "world"!)" };// is OK!
const char* str4{ R"(hello
world!)"} // 换行
因为原始字符串使用 )" 作为标记,所以不能在字符中出现 )",此时,原始字符串支持:
R"d-char-sequence( )d-char-sequence"
d-char-sequence是一个长度为16个字符以内的分割符序列,放在 " 和 ( 中间作为新的标识符,同样,这个分隔符序列应该是原始字符串中未出现过的。
const char* str { R"-( "(Hello)" )-"}; // print "(Hello)"
C++极大的改善了字符串的功能,在C++中,字符串是一个类(作为basic_string模板类的一个实例),但C++又作了很多工作以让程序员们几乎总可将它作为简单的内建类型使用。
首先是运算符重载,string重载了运算符 + 以连接字符串:
string a{"12"};
string b{"34"};
string c;
c = a + b; //c is "1234"
a += b; //a is "1234"
C风格字符串不能通过==进行比较,因为它比较的是指针的值,而不是字符串的内容。而在C++的string类中,不光是 = =,!=,< 等都被重载了,可以直接操作真正的字符串字符进行比较。
string a {"12"};
string b {"34"};
if(a == b){ /*...*/ };
C++中还提供了compare()函数用于比较字符串的大小,它会返回-1,0,1分别表示小于,等于,大于。
string a {"12"};
string b {"34"};
auto result { a.compare(b) };
if(result < 0) { cout<<"less"<<endl; }
if(result > 0) { cout<<"great"<<endl; }
if(result == 0) { cout<<"equal"<<endl; }
当需要扩展string时,string类能够自动处理内存需求。所有的这些string对象都创建为栈中的变量,尽管string类肯定要完成大量分配内存和调整大小的工作,但string类的析构函数会在string对象离开作用域时清理内存。
string mystring {"hello"};
mystring += ",there";
string myotherstring { mystring };
if( mystring == myotherstring ){
myotherstring[0] = 'H';
}
cout<<myotherstring<<endl; // Hello,there
C++提供了c_str函数用来返回一个C风格的字符串const char指针,但一旦string执行任何内存重分配或者string对象被销毁,返回的这个const指针就永久失效了。
还有一个data()方法,在C++14以及之前和c_str一样返回const char*,但从C++17开始返回char*。
编译器通常把字符串字面量解释为const char*。如果要将其解释为string需要可以在后面加上s
auto string1 { "abcd" };
auto string2 { "abcd"s };
这一点在使用模板参数推导(CTAD)时尤其需要注意,
vector names { "john", "sam", "joe"}; // vector
vector names2 { "john"s, "sam"s, "joe"s}; // vector
是std::string中定义了很多辅助函数用于实现字符串与数值之间的相互转换
string to_string(T val);
long double d {3.14L};
string s { to_string(d) }; //long double to string
如上例所示,使用to_string函数把数值转换为字符串,T可以是int,long,long long等等类型,它们都会操作内存,创建一个新的string对象并返回。
若要把字符串转换为数值,可以使用下列函数:
int stoi(const string& str, size_t *idx = 0, int base = 10);
long stol(const string& str, size_t *idx = 0, int base = 10);
unsigned long stoul(const string& str, size_t *idx = 0, int base = 10);
long long stoll(const string& str, size_t *idx = 0, int base = 10);
unsigned long long stoull(const string& str, size_t *idx = 0, int base = 10);
float stof(const string& str, size_t *idx = 0);
double stod(const string& str, size_t *idx = 0);
这些函数接收一个string类型的值表示要转换的字符串,idx是一个指针指向第一个未转换的字符的索引,base表示转换的进制,默认为10,如果base被设置会0,编译器会自动推导给定数字的进制:
低级数值转换函数定义在< charconv >头文件中, 和高级数值转换不同的是,它们不操作内存,也不使用string类,而使用由调用者分配的缓存区。
这使低级数值转换可以比高级数值转换快几个数量级,因为这些函数存在的主要设计目的是实现”完美往返“。这要求将数值序列转化为字符串,再把结果字符串转换回数值,往返后的结果完全相同。
要把整数转为字符串可以使用:
to_chars_result to_chars(char* first, char* last,IntegerT value,int base = 10);
const size_t BufferSize{ 50 };
string out(BufferSize, ' ');
auto result{to_chars( out.data(), out.data()+out.size(),12345)};
相反,如果要把字符串转为数值:
from_chars_result from_chars(const char* first, const char* last, IntegerT& value, int base = 10);
from_chars_result from_chars(const char* first, const char* last, FloatT& value, chars_format format = chars_format::general);
在C++17之前,为接收只读字符串选择形参类型是一件进退两难的事,程序员们有两种选择但都各有不足:
如果使用const char*,那么用户就需要调用c_str()函数并失去string提供的一切便利。
如果使用const string&,当传递一个字符串字面量时,编译器会默认创建一个新的临时字符串对象传递给函数,会增加一些开销
不得已,程序员一般会编写同一函数的两个重载版本分别接收const char* 和const string&,但这并不是一个优雅的解决方案,直到C++17引入string_view。
string_view基本上是const string&的替代品,但它并不会复制字符串,因此不会产生开销。string_view()支持的接口与string()非常相似,唯一的例外是缺少了c_str()函数,同时string_view()提供了remove_prefix(size_t)来让起始指针前移给定值,提供remove_suffix(size_t)来让结尾指针后移。
string_view extractExtension(string_view filename){ // both const char* and string are OK!
return filename.substr()filename.rfind('.');
}
上例中,string_view可以接收所有类型的不同字符串。且string_view只包含指向字符串的指针和字符串的长度,所以在传递string_view时通常按值传递。你也可以使用指针和长度来构造一个string_view:
const char* raw {/*...*/};
size_t length {/*...*/};
auto res = extractExtension({raw, length});
无法通过string_view隐式构建一个string,下例中的handleExetension函数接收一个string值,调用1中extractExtension返回的string_view无法被隐式转换为string,你可以使用调用2和调用3所示的方法,显式狗仔一个string类,或者调用data()函数
void handleExtension(const string& str){/*...*/};
handleExtension(extractExtension("myfile.txt")); // Error!
handleExtension(string{extractExtension("myfile.txt")}); // is OK!
handleExtension(extractExtension("myfile.txt").data()); // is OK!
出于上述的原因,无法连接string和string_view(),除非你使用data()方法,或者append()
string str{"hello"};
string_view sv{"hello"};
auto result{str + sv}; // Error!
auto result{str + sv.data()}; // is OK!
string result{str};
result.append(sv.data(),sv.size()); // is OK!
string_view不应该用于保存临时字符串:
string s{"hello"};
string_view sv {s+"world"};
cout<<sv<<endl;
上例中,sv保存一个指向临时字符串的指针,但在访问该指针时,临时字符串已经被销毁了(离开了作用域),此时的访问是一种未定义状态。
可以使用sv把字符串解释为string_view:
auto sv{"mystring_view"sv};