C++ String

计算机中的字符

ACSII码

起初,计算机使用ACSII码表示不同的英文字母以及符号。例如 32 代表空格,48 代表 ‘0’,65 代表 ‘A’,97 代表 ‘a’。

32~126 这些整数就用于是表示这些可显示字符(printable character)的。

0~31 和 127 这些整数,规定了一类特殊的控制字符(control character):

  • 0 表示空字符(‘\0’)
  • 9 表示 Tab 制表符(‘)
  • 10 表示换行(‘’)
  • 13 表示回车(‘)
  • 27 表示 ESC 键(‘1b’)
  • 127 表示 DEL 键(‘7f’)等

一些控制符号的使用,比较常见,如:Ctrl+C 来发送中断信号(SIGINT)强制终止程序;Ctrl+D 来关闭标准输入流,终止正在读取他的程序;Ctrl+I 的效果和 Tab 键一样, Ctrl+J 的效果和 Enter 键一样,Ctrl+H 的效果和退格键一样...

具体可以上wiki上查看。

UTF

ASCII 码表,建立了英文字母和标点符号到 0x00~0x7F 的一一映射。那么,中文、拉丁文字、俄文等等呢?

所以,各国开始推出自己用的编码格式规范。中国大陆推出了 GBK 编码格式表示简体中文的字符,同时简体和繁体的 GB18030 编码,包含了 27484 个汉字;日本推出了 Shift-JIS 编码格式表示日语的字符,等等。

再后来,就有了“万国码”的 Unicode。他给世界上所有的字符编码。全部字符都可以用一个 0x000000~0x10FFFF 的整数表示,也就是3个字节。

UTF-32

UTF-32 使用 wchar_t 这个 4 字节的数据类型,来表示 Unicode 编码,完全够用。

wchar_t 的问题在于,最高位字节是 0,导致 C 语言使用 \0 判断字符串结束符的方式不再适用。

C 语言为了适应这个变化,推出了专门针对 wchar_t 的 wcslen、wcscpy、wmemset 等函数。Windows 也推出 LoadLibraryA 和 LoadLibraryW 两个版本,分别适配 char 和 wchar_t。

显然,所有字符都占用4字节,是浪费的。即使是中文汉字,本来也只需要 2 字节就能表示了(27484 个汉字,在 65536 的范围内)。

UTF-16

Windows 采用了 UTF-16 编码格式,这种规范下的 wchar_t 是 2 字节的(实际上就是 unsigned short 的类型别名)。超过2字节的字符被分成两个 wchar_t表示,拆法很复杂。

Windows 为了更好地伺候中国客户,还专门把中文 Windows 系统的默认编码格式改成了 GBK,大大妨碍了程序员编程和国际交流的便利性。

UTF-8

UTF-16 没法兼容 ASCII,在网络上传输还需要考虑字节序等等各种问题。

UTF-8 则是在 1、2、3、4 字节之间变长的编码。

作为变长编码的代价,UTF-8 需要在二进制中浪费额外的空间来表示当前编码的长度。但是 1 个字节的ASCII码还是能使用一个字节表示。使用二进制位的标记方式,进行变长编码。

而此时,C 语言也可以使用 \0 判断字符串结束符了。节省了一些空间,也解决了C语言适配的问题。

解码编码格式对应

读写双方编码格式不同,会导致乱码。

所以 MSVC 编译器调试时,“烫烫烫”的问题,也可以解释了。

Windows 的 MSVC 在 Debug 模式下会默认把未初始化的栈内存填满 0xCC(x86 的 INT3 单步中断指令),未初始化的堆内存填满 0xCD。

而 0xCCCC 在 GBK 编码中就是“烫”,所以如果打印了栈上未初始化的字符串数组,就会看到“烫烫烫”。而 0xCDCD 在 GBK 编码中就是“屯”,所以如果打印了堆上未初始化的字符串数组,就会看到“屯屯屯”。

虽然现在普遍采用了 UTF-8 格式,但是中文版 Windows 还在用 UTF-16 和 GBK。Linux 也可根据环境变量 LANG 和 LC_ALL 的值来动态决定采用哪种编码格式和语言。

C++ 中对各大编码格式的支持如下表:

字符类型 字符串类型 字符串常量语法 大小(字节) 编码格式
char string "字符" 1 随系统默认编码格式而变
wchar_t(Linux) wstring L"字符" 4 UTF-32
wchar_t(Windows) wstring L"字符" 2 UTF-16
char8_t(C++20) u8string u8"字符" 1 UTF-8
char16_t(C++11) u16string u"字符" 2 UTF-16
char32_t(C++11) u32string U"字符" 4 UTF-32

后面三个是不随系统而改变的(C++ 标准委员会定义)。

C 语言字符串

char

在C语言中,char类型就是整数,只不过被表示成对应的字符表示。是数字就可以比较大小等。

常用的帮手函数如下:

isupper(c) 判断是否为大写字母(‘A’ <= c && c <= ‘Z’)。 islower(c) 判断是否为小写字母(‘a’ <= c && c <= ‘z’)。 isalpha(c) 判断是否为字母(包括大写和小写)。 isdigit(c) 判断是否为数字(‘0’ <= c && c <= ‘9’)。 isalnum(c) 判断是否为字母或数字(包括字母和数字)。 isxdigit(c) 判断是否为十六进制数字(0~9 或 a-f 或 A-F)。 isspace(c) 判断是否为等价于空格的字符(‘ ’ 或 ‘ 或 ‘’ 或 ‘ 或 ‘)。 iscntrl(c) 判断是否为控制字符(0 <= c && c <= 31 或 c == 127)。 toupper(c) 把小写字母转换为大写字母,如果不是则原封不动返回。 tolower(c) 把大写字母转换为小写字母,如果不是则原封不动返回。

char 类型只需是 8 位即可,可以是有符号也可以是无符号,任凭编译器决定。在 x86 架构是有符号的 (char = signed char),而在 arm 架构上则认为是无符号的 (char = unsigned char)。

但是C 语言却规定 short,int,long,long long 必须是有符号的 (int = signed int),反而却没有规定他们的位宽,和 char 刚好相反。

C++ 标准保证 char,signed char,unsigned char 是三个完全不同的类型,std::is_same_v 分别判断他们总会得到 false,无论 x86 还是 arm。

1
2
3
4
5
6
7
8
9
// 判断系统
int main(int argc, char *argv[]) {
if (std::is_signed<char>::value) {
printf("signed, x86");
} else {
printf("unsigned, arm:");
}
return 0;
}

C string

字符串(string)就是由字符(character)组成的数组。

1
2
3
char c = ‘h’; // ‘h’ 是个语法糖,等价于 104 ASCII码
// “hello” 也是个语法糖,等价于数组 {‘h’, ‘e’, ‘l’, ‘l’, ‘o’, 0}
char s[] = “hello”;

C 语言的字符串因为只保留数组的首地址指针(指向第一个字符的指针),在以 char * 类型传递给其他函数时,其数组的长度无法知晓。

为了确切知道数组在什么地方结束,规定用 ASCII 码中的“空字符”也就是 0 来表示数组的结尾。这样只需要一个首地址指针就能表示一个动态长度的数组。

除了 \0 ,其他常见转义符号:

‘’ 换行符:另起一行(光标移到下一行行首) ‘ 回车符:光标移到行首(覆盖原来的字符) ‘ 缩进符:光标横坐标对齐到 8 的整数倍 ‘ 退格符:光标左移,删除上个字符 ‘\’ 反斜杠:表示这个是真的 ,不是转义符 ‘”’ 双引号:在字符串常量中使用,防止歧义 ‘’’ 单引号:在字符常量中使用,防止歧义 ‘\0’ 空字符:标记字符串结尾,等价于 0,注意和 '0' 不等价。

C++ 字符串

使用C++的特性,简化对字符串数据的处理方式。其特点有:

  1. string 可以从 const char * 隐式构造:string s = “hello”;
  2. string 具有 +、+=、== 等直观的运算符重载:string(“hello”) + string(“world”) == string(“helloworld”)
  3. string 符合 vector 的接口,例如 begin/end/size/resize……
  4. string 有一系列成员函数,例如 find/replace/substr……
  5. string 可以通过 s.c_str() 重新转换回古板的 const char *。
  6. string 在离开作用域时自动释放内存 (RAII),不用手动 free。

C 语言字符串是单独一个 char *ptr,自动以 ‘\0’ 结尾。C++ 字符串是 string 类,其成员有两个:

char *ptr; size_t len;

有了len,就不需要 ‘\0’ 标记结尾。

string 类从 C 字符串构造时,可以额外指定一个长度,string(“hello”, 3) 会得到 “hel”。len 超出范围会出现越界读取内存的错误。

c_str() 和 data()

s.c_str() 保证返回的是以 0 结尾的字符串首地址指针,总长度为 s.size() + 1。

s.data() 只保证返回长度为 s.size() 的连续内存的首地址指针,不保证 0 结尾。

把 C++ 的 string 作为参数传入像 printf 这种 C 语言函数时,需要用 s.c_str()。如果只是在 C++ 函数之间传参数,直接用 string 或 string const & 即可。考虑传参效率,可以使用 string_view。

1
2
3
4
void legacy_c(const char *name); // 古老的 C 语言遗产
void modern_cpp(std::string name); // 这个函数是现代 C++
void performance_geek(std::string const &name); // 追求性能
void performance_nerd(std::string_view name); // 超级追求性能

自定义字面量后缀

1
2
3
4
5
6
7
8
9
string operator""_s(const char *s, size_t len) {
return string(s, len);
}

int main() {
// 使用
string s3 = "hello"_s + "world"_s;
cout << s3 << endl;
}

写 “hello”_s 就相当于写 operator“”_s(“hello”, 5),就相当于 string(“hello”, 5) 了。

如果你 using namespace std; 那么标准库已经自动帮你定义好了 “”s 后缀。这里 “hello”s 就等价于原本繁琐的 string(“hello”) 了。或者更安全一点 using namespace std::literials;

std::to_string()

std::to_string 是标准库定义的全局函数,他具有9个重载,将数字转为字符串。

把 to_string 作为全局函数,而不是 string 类的构造函数,可以让数字转字符串这个特定的需求,和字符串本身的实现不会有太多耦合。

相关函数,字符串转数字:std::stoi/stof/stod 是标准库定义的一系列全局函数。

stoi 有第二参数 &pos,用来保存数字部分结束的那个字符在原字符串中所在的index。

stoi 的第三参数 base 表示,当前字符串表示的数字的进制。

另外 stof 支持科学计数法。

stringstream

想要完整字符串格式化功能(指定多少进制,左右对齐等),可以用专业的做法:

  • C 语言的 sprintf
  • C++ 的 stringstream
  • C++20 新增的 std::format

stringstream 是相对较早的处理方法,比如设置数字进制:

1
2
3
4
5
6
7
8
9
10
11
#include <sstream>
#include <iostream>
#include <string>
#include <iomanip>

int main() {
stringstream ss;
ss << hex << 66;
string s = ss.str();
std::cout << s << std::endl; // 只是为了显示结果
}

官方推荐用 stringstream 取代 to_string。stringstream 也可以取代 stoi。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sstream>
#include <iostream>
#include <string>
#include <iomanip>

int main() {
string s = "233word";
stringstream ss(s);
int num;
ss >> num;
string unit;
ss >> unit;

std::cout << num << " " << unit << std::endl;
}

at()

s.at(i) 和 s[i] 都可以获取字符串中的第 i 个字符。区别在于 at 如果遇到 i 越界的情况,也就是检测到 i ≥ s.size() 时,会抛出 std::out_of_range 异常终止程序。at 做越界检测需要额外的开销,[] 不需要。

其他方法

s.length() 和 s.size()

获取字符串长度有两种写法 s.length() 和 s.size() 等价。

substr()

函数原型为:

1
string substr(size_t pos = 0, size_t len = -1) const;

substr(pos, len) 会截取从第 pos 个字符开始,长度为 len 的子字符串,原字符串不会改变。

如果 pos 超出了原字符串的范围,则抛出 std::out_of_range 异常。

可以指定 len 为 -1(即 string::npos),此时会截取从 pos 开始直到原字符串末尾的子字符串。

len为什么可以是 -1 ?因为 -1 类型转为无符号 size_t 类型之后,是很大的数,0xffffffffffffffff。

find()

find 拥有众多重载,返回这个字符第一次出现所在的位置。如果找不到,返回 -1。

1
2
3
4
5
size_t find(char c, size_t pos = 0) const noexcept;
size_t find(string_view svt, size_t pos = 0) const noexcept;
size_t find(string const &str, size_t pos = 0) const noexcept;
size_t find(const char *s, size_t pos = 0) const;
size_t find(const char *s, size_t pos, size_t count) const;

为什么最后两个重载没有标记 noexcept?只是历史原因。

实际上 find 函数都是不会抛出异常的,他找不到只会返回 -1。可以用 std::string::npos 代替 -1。

1
2
3
4
5
// 都是等价的
s.find(c) != string::npos
s.find(c) != s.npos
s.find(c) != (size_t)-1
s.find(c) != -1

下面是 std::string::npos 的定义。

1
static const size_type  npos = static_cast<size_type>(-1);

find(“str”, pos) 是从第 pos 个字符开始查找子字符串 “str”。

还有 find(“str”, pos, len) 和 find(“str”.substr(0, len), pos) 等价,用于查询确定长度字符串,或者要查询的字符串是个切片(string_view)的情况。

若不指定这个长度 len,则默认是 C 语言的 0 结尾字符串,此时 find 还要去求 len = strlen(“str”),相对低效。

rfind 则是从尾部开始查找,返回最后一次出现的地方。

find_first_of()

1
2
3
size_t find_first_of(string const &s, size_t pos = 0) const noexcept;
size_t find_first_of(const char *s, size_t pos = 0) const noexcept;
size_t find_first_of(const char *s, size_t pos, size_t n) const noexcept;

“str”.find_first_of(“chset”, pos) 会从第 pos 个字符开始,在 “str” 中找到第一个出现的 ‘c’ 或 ‘h’ 或 ‘s’ 或 ‘e’ 或 ‘t’ 字符,并返回他所在的位置。如果都找不到,则会返回 -1(string::npos)。

其实 s.find_first_of(“chset”) 等价于 min(s.find(‘c’), s.find(‘h’), s.find(‘s’), s.find(‘e’), s.find(‘t’))。

按空格分割字符串就可以使用下面这个方法:s.find_first_of(“ )。空格类字符是一个集合 {‘ ’, ‘, ‘, ‘, ‘’, ‘}。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vector<string> split(string s) {
vector<string> ret;
size_t pos = 0;
while (true) {
size_t newpos = s.find_first_of(" \t\v\f\n\r", pos);
if (newpos == s.npos) {
ret.push_back(s.substr(pos, newpos));
break;
}
ret.push_back(s.substr(pos, newpos - pos));
pos = newpos + 1;
}
return ret;
}

同时也有find_first_not_of 方法寻找不在集合内的字符。

replace()

replace() 替换一段子字符串。replace(pos, len, “str”) 会把从 pos 开始的 len 个字符替换为 “str”。

string 的本质和 vector 一样,是内存中连续的数组。所以当 str 长度不等于 len,就会发生数据的复制移动,从而变成 O(n) 时间复杂度,丢失性能。

append()

s.append(“world”) 和 s += “world” 等价。区别在于 append 还可以指定第二个参数,限定字符串长度,用于要追加的字符串已经确定长度,或者是个切片的情况(string_view)。 例如 s.append(“world”, 3) 和 s += string(“world”, 3) 和 s += “wor” 等价。

1
2
3
4
5
6
string &append(string const &str);   // str 是 C++ 字符串类 string 的对象
string &append(const char *s); // s 是长度为 strlen(s) 的 0 结尾字符串
string &append(string const &str, size_t len); // 只保留后 str.size() - len 个字符
string &append(const char *s, size_t len); // 只保留前 len 个字符

// 注意第三个重载函数,是保留 str 的后 str.size() - len 个字符

前面两个是最常用的版本,和 += 也是等价的。后面两个带 len 的版本很奇怪,他们居然不一样:对于 str 是 string 类型时,会变成保留后半部分。对于 str 是 const char * 类型时,会保留前半部分。

C++17 中有更为直观的 string_view,要切片只需 substr,例如:

1
2
3
4
5
6
7
8
9
// string_view(“world”) 也可以简写作 “world”sv

s.append(“world”, 3)
// 改成
s += string_view(“world”).substr(0, 3)

s.append(“world”s, 3)
// 改成
s += string_view(“world”).substr(3)

又高效,又直观易懂,且 substr 附带了自动检查越界的能力,安全。 string_view(“world”) 也可以简写作 “world”sv。

insert()

s.insert(pos, str) 会把子字符串 pos 插入到原字符串中第 pos 个字符和第 pos+1 个字符之间。函数原型:

1
2
3
4
string &insert(size_t pos, string const &str);                 // str 是 C++ 字符串类 string 的对象
string &insert(size_t pos, const char *s); // s 是长度为 strlen(s) 的 0 结尾字符串
string &insert(size_t pos, string const &str, size_t len); // 只保留 str 后 str.size() - len 个字符
string &insert(size_t pos, const char *s, size_t len); // 只保留 s 前 len 个字符

compare()

C 语言的 strcmp(a, b) 不仅可以判断相等,也可以用于字典序比较,返回 -1 代表 a < b,返回 1 代表 a > b,返回 0 代表 a == b。

string 也有一个成员函数 compare,他也是返回 -1、1、0 表示大小关系。

a == b 和 !a.compare(b) 等价。

[Not found] starts_with 和 ends_with

以下内容没有找到,成疑:

s.starts_with(str) 等价于 s.substr(0, str.size()) == str s.ends_with(str) 等价于 s.substr(str.size()) == str

他们不会抛出异常,只会返回 true 或 false,表示 s 是否以 str 开头。

和 vector 类似的其他接口方法

at, [], data, size, resize, empty, clear, capacity, reserve, shrink_to_fit, insert, erase, assign, push_back, pop_back, front, back, begin, end, rbegin, rend, swap, move string 在这些函数上都和 vector<char> 一样。

basic_string

string 被 c++filt 解析为 basic_string<char, char_traits<char>, allocator<char>>。std::string 就是他的类型别名(typedef)。

string_view 也是 basic_string_view<char, char_traits<char>> 的类型别名。

可自定义 char_traits ,使用比标准库更高效的字符串比较、赋值等方法。或者 allocator 自定义内存管理。

string 空基类优化 [ TODO ]

string 类中,将首地址指针成员属性包装在一个继承了空基类 allocator_type 的类 _Alloc_hider 中。

因为,如果只是把 allocator(空的)直接作为成员变量放在 basic_string 里的,至少要占1个字节,再考虑字节对齐。比如,这1个字节扩展到 8 个字节。而这 8 字节没有任何数据,只是浪费空间。

如果一个类(_Alloc_hider)的基类是空类(allocator),则这个基类不占据任何空间,如果这个派生类(_Alloc_hider)如果定义了大小为 n 字节的成员变量(_M_p),则这个派生类(_Alloc_hider)的大小也是 n。

_M_allocator 从原来被迫从1字节开始对齐,到不占空间。

字符串胖指针

要描述一个动态长度的数组(此处为字符串),需要首地址指针和数组长度两个参数。C 语言假定字符串中的字符不可能出现 ‘\0’,那么可以用 ‘\0’ 作为结尾的标记符。

可以把这描述同一个东西的两个参数首地址指针和数组长度,打包进一个结构体(struct)里:

1
2
3
4
struct FatPtr {
char *ptr;
size_t len;
};

这就是 rust 炫耀已久的数组胖指针。C++20 中的 span 也是这个思想。提倡把 ptr 和 len 这两个逻辑上相关的参数绑在一起,避免程序员犯错。

C++ 中的 vector 和 string 其实都是胖指针。string 和 vector 内部都有三个成员变量:ptr, len, capacity。

前两个 [ptr, len] 其实就是表示实际有效范围(存储了字符的)的胖指针。

而 [ptr, capacity] 就是表示实际已分配内存(操作系统认为的)的胖指针。

在 GCC 的实现中,被换成了三个指针 [ptr, ptr + len, ptr + capacity] 来表示。

强引用、弱引用 string

string 容器,是掌握着字符串生命周期(lifespan)的胖指针。这种掌管了所指向对象生命周期的指针称为强引用(strong reference)。

当 string 容器被拷贝时,其指向的字符串也会被拷贝(深拷贝)。 当 string 容器被销毁时,其指向的字符串也会被销毁(内存释放)。

一个强引用的 string 到处拷贝来拷贝去,则其指向的字符串也会被多次拷贝,比较低效。人们常用 string const & 来避免不必要拷贝,但仍比较麻烦。

C++17 引入了弱引用胖指针 string_view,这种弱引用(weak reference)不影响原对象的生命周期,原对象的销毁仍然由强引用控制。

当 string_view 被拷贝时,其指向的字符串仍然是同一个(浅拷贝)。 当 string_view 被销毁时,其指向的字符串仍存在(弱引用不影响生命周期)。

使用原则:

  • 强引用和弱引用都可以用来访问对象。
  • 每个存活的对象,强引用有且只有一个。
  • 弱引用可以同时存在多个,也可以没有。
  • 强引用销毁时,所有弱引用都会失效。如果强引用销毁以后,仍存在其他指向该对象的弱引用,访问他会导致程序奔溃(野指针)。

建议创建 string_view 以后,不要改写原字符串。

常见强弱引用:

强引用 弱引用
string string_view
wstring wstring_view
vector span
unique_ptr T *
shared_ptr weak_ptr

小字符串优化

注意当 string 长度在15 以内,会保存在一个共享栈空间地址(_M_local_buf ),此时修改原字符串会在此地址上覆盖,还可以通过弱引用查看内容。

但是当长度大于 15 ,string 字符串保存在堆空间,一旦修改原字符串,使用弱引用会导致访存失效。

字符串切片

string(“hello”).substr(1, 3) 会得到 “ell”。 这样其实不是最高效的,因为 string.substr 并不是就地修改字符串,他是返回一个全新的 string 对象,然后把原字符串里的 1 到 3 这部分子字符串拷贝到这个新的 string 对象里去。

C++17 只需要保证原来的字符串存在于内存中,让 substr 只是返回切片后的胖指针 [ptr, len],不就让新字符串和原字符串共享一片内存,实现了零拷贝零分配。于是就有了接口和 string 很相似,但是只保留胖指针,而不掌管他所指向内存生命周期的 string_view 类。

不论子字符串多大,真正改变的只有两个变量。

remove_prefix、remove_suffix

sv.remove_prefix(n) 等价于 sv = sv.substr(n) sv.remove_suffix(n) 等价于 sv = sv.substr(0, n)

他们都是就地修改的,这个就地修改的是 string_view 对象本身,而不是修改他指向的字符串,原 string 还是不会变的。

不同之处在于,substr(pos, len) 遇到 pos > sv.size() 的情况会抛出 out_of_range 异常。而 remove_prefix/suffix 就不会,如果他的 n > sv.size(),则属于未定义行为,可能崩溃。 remove_prefix/suffix 更高效,substr 更安全。

类型转换规则

1
2
3
4
5
6
7
8
9
10
11
// 隐式:string s = “hello”;
// 显式:string s(“hello”); 或 auto s = string(“hello”);
// c_str:const char *cs = s.c_str();

const char * ===隐式==O(n)==> string_view
const char * ===隐式==O(n)==> string

string ===隐式==O(1)==> string_view
string_view ===显式==O(n)==> string

string ===c_str==O(1)==> const char *

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!