C++ string 类详解
一、为什么学习 string 类?
C 语言中的字符串
在 C 语言中,字符串是以 \0 结尾的字符数组。使用 string 类可以简化字符串操作,避免手动管理内存和边界检查。
字符串面试题
常见的字符串处理问题包括字符串转整数、字符串相加等,掌握 string 类有助于高效解决此类算法题。
二、C++ 标准库中的 string 类
在使用 string 类时,必须包含 #include <string> 头文件以及 using namespace std;。
2.1)auto 和范围 for
- auto 关键字
- 核心功能:
auto的核心功能是让编译器通过初始值来推导变量的类型。这意味着使用auto时,变量必须初始化。
auto x = 10; // x 被推导为 int
auto y = 3.14; // y 被推导为 double
auto ptr = &x; // ptr 被推导为 int*
- 用
auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&。
int x = 10;
int* p = &x;
auto a = p; // a 被推导为 int*
auto* b = p; // b 被推导为 int*
// 结论:在这种情况下,a 和 b 的类型完全一致
int x = 10;
int& ref = x; // ref 是 x 的引用
auto c = ref; // c 的类型是 int (注意:引用被丢弃了!这里是值拷贝)
auto& d = ref; // d 的类型是 int& (显式声明为引用)
c = 20; // x 依然是 10,因为 c 是独立变量
d = 30; // x 变成了 30,因为 d 是 x 的别名
- 当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错。
/*正确用法:类型一致*/
auto a = 10, b = 20; // 正确:a 是 int,b 也是 int
auto c = 1.5, d = 3.14; // 正确:c 是 double,d 也是 double
int x = 0;
auto i = x, &j = x; // 正确:基础类型都是 int
/*错误用法:类型不一致*/
// 错误示例:编译器会报错
auto a = 10, b = 3.14; // 报错原因:编译器对 a 推导为 int,但对 b 推导为 double
auto m = 5, *p = &m; // 正确:基础类型都是 int
auto n = 5, q = 3.14; // 错误:基础类型不同
auto不能作为函数的参数,可以做返回值,但是建议谨慎使用。
// 错误示例 (C++17 及以前)
void func(auto x) { ... }
// 正确方案:模板
template<typename T>
void func(T x) { ... }
auto不能直接用来声明数组。
int arr[] = {1, 2, 3}; // 正确:传统的数组声明
auto a[] = {1, 2, 3}; // 错误!编译器无法推导出 a 是一个数组类型
auto b[3] = {1, 2, 3}; // 错误!即使指定了长度也不行
推导为指针
int nums[] = {10, 20, 30};
auto p = nums; // 正确:p 的类型被推导为 int*,而不是 int[3]
如果你希望保留数组的'大小信息'而不是让它退化成指针,可以使用引用:
int nums[] = {1, 2, 3};
auto& refArr = nums; // 正确:refArr 的类型是 int(&)[3]
- for 关键字
C++11 中引入了基于范围的 for 循环。for 循环后的括号由冒号':'分为两部分:第一部分是范围内用于迭代的变量,第二部分则表示被迭代的范围。
- 语法结构
for(declaration : range) {
// 循环体
}
- 代码示例
int arr[] = {1, 2, 3, 4, 5};
for(int x : arr) {
cout << x << " ";
}
-
范围
for可以作用到数组和容器对象上进行遍历。 -
使用示例
#include<iostream>
#include<string>
#include<map>
using namespace std;
int main() {
int array[] = {1, 2, 3, 4, 5};
// C++98 的遍历
for(int i = 0; i < sizeof(array)/sizeof(array[0]); ++i) {
array[i] *= 2;
}
for(int i = 0; i < sizeof(array)/sizeof(array[0]); ++i) {
cout << array[i] << endl;
}
// C++11 的遍历
for(auto& e : array) e *= 2;
for(auto e : array) cout << e << " " << endl;
string str("hello world");
for(auto ch : str) {
cout << ch << " ";
}
cout << endl;
return 0;
}
关键点说明:
auto& e:e是当前元素的引用。使用引用是因为我们需要修改array中的原始值。::分隔符,左边是变量,右边是范围(即数组array)。
范围 for 不仅能作用于数组,也能作用于容器对象(如 std::string)。循环会自动调用 str.begin() 和 str.end(),依次提取字符并打印,无需手动管理迭代器或下标。
2.2)string 类的常用接口
🚩1)string 类的常用构造
| 构造函数 | 功能说明 |
|---|---|
string()(重点) | 构造空的 string 类对象,即空字符串 |
string(const char* s)(重点) | 用 C-string 来构造 string 类对象 |
string(size_t n, char c) | string 类对象中包含 n 个字符 c |
string(const string& s)(重点) | 拷贝构造函数 |
#include<iostream>
#include<string>
using namespace std;
int main() {
// 1. string() (重点)
string s1;
cout << "s1 (空字符串): [" << s1 << "]" << endl;
// 2. string(const char* s) (重点)
string s2("Hello World");
cout << "s2 (由字符串常量构造): " << s2 << endl;
// 3. string(size_t n, char c)
string s3(10, '*');
cout << "s3 (10 个星号): " << s3 << endl;
// 4. string(const string& s) (重点)
string s4(s2);
cout << "s4 (拷贝自 s2): " << s4 << endl;
return 0;
}
🚩2)string 类对象的容量操作
| 函数名称 | 功能说明 |
|---|---|
size(重点) | 返回字符串有效字符长度 |
length | 返回字符串有效字符长度 |
capacity | 返回空间总大小 |
empty(重点) | 检测字符串是否为空串,是返回 true,否则返回 false |
clear(重点) | 清空有效字符 |
reserve(重点) | 为字符串预留空间 |
resize(重点) | 将有效字符的个数改成 n 个,多出的空间用字符 c 填充 |
❗注意事项
- size() 与 length() 底层原理相同
size()是为了与其他容器接口保持一致。
string s = "hello";
cout << "size: " << s.size() << endl; // 输出 5
cout << "length: " << s.length() << endl; // 输出 5
-
clear() 是清空有效字符,但不改变底层空间大小(capacity)
-
resize(size_t n, char c) 改变有效字符个数
s = "hello";
// 如果 n 大于当前大小,用字符 'x' 填充
s.resize(10, 'x');
cout << "resize(10, 'x'): " << s << endl; // helloxxxxx
// 如果 n 小于当前大小,会发生截断,但底层总空间不变
s.resize(3);
cout << "resize(3): " << s << endl; // hel
- reserve(size_t res_arg) 为 string 预留空间,不改变有效元素个数;只有当参数大于当前底层空间时才会扩容
s.reserve(100);
cout << "reserve(100) 后大小:" << s.size() << endl; // 依然是 3
🚩3)string 类对象的访问及遍历操作
| 函数名称 | 功能说明 |
|---|---|
operator[pos](重点) | operator 是字符串名,返回 pos 位置的字符,const string 类对象调用 |
begin + end | begin 获取一个字符的迭代器 + end 获取最后一个字符下一个位置的迭代器 |
rbegin + rend | rbegin 获取一个字符的迭代器 + rend 获取最后一个字符下一个位置的迭代器 |
范围 for | C++11 支持更简洁的范围 for 的新遍历方式 |
#include<iostream>
#include<string>
using namespace std;
int main() {
string s = "Hello";
// 1. operator[] (下标访问)
cout << "下标为 1 的字符:" << s[1] << endl; // 'e'
s[0] = 'h'; // 也可以修改
// 2. 使用迭代器遍历 (begin + end)
cout << "正向迭代器遍历:";
for(string::iterator it = s.begin(); it != s.end(); ++it) {
cout << *it << " ";
}
cout << endl;
// 3. 反向迭代器遍历 (rbegin + rend)
cout << "反向迭代器遍历:";
for(string::reverse_iterator rit = s.rbegin(); rit != s.rend(); ++rit) {
cout << *rit << " "; // 输出 o l l e h
}
cout << endl;
// 4. 范围 for (C++11 遍历新方式)
cout << "范围 for 遍历:";
for(char c : s) {
cout << c << "-";
}
cout << endl;
return 0;
}
迭代器说明:
string::iterator it = s.begin():string::是作用域限定符,iterator是类型名,it是变量名,s.begin()是初始赋值。string::reverse_iterator rit = s.rbegin():string::reverse_iterator是专门用于从后往前读字符串的类型。
对于 string 来说,最常用的四种组合:
iterator:能读能改,正着走。const_iterator:只能读,正着走。reverse_iterator:能读能改,倒着走。const_reverse_iterator:只能读,倒着走。
🚩4)string 类对象的修改操作
| 函数名称 | 功能说明 |
|---|---|
push_back | 在字符串后尾插字符 c |
append | 在字符串后追加一个字符串 |
operator+=(重点) | 在字符串后追加字符串 str |
c_str(重点) | 返回 C 格式字符串 |
find + npos(重点) | 从字符串 pos 位置开始往后找字符 c,返回该字符在字符串中的位置 |
rfind | 从字符串 pos 位置开始往前找字符 c,返回该字符在字符串中的位置 |
substr | 在 str 中从 pos 位置开始,截取 n 个字符,然后将其返回 |
#include<iostream>
using namespace std;
int main() {
// 0. 初始化
string s = "Hello";
// 1. push_back: 只能插入单个字符
s.push_back('!'); // s 变为 "Hello!"
// 2. append: 追加字符串
s.append(" Welcome"); // s 变为 "Hello! Welcome"
// 3. operator+= (重点): 最常用的追加方式
s += " to C++"; // s 变为 "Hello! Welcome to C++"
// 4. c_str (重点): 返回 C 风格字符串 (const char*)
const char* cStr = s.c_str();
cout << "C-Style: " << cStr << endl;
// 5. find + npos (重点): 查找子串
size_t pos = s.find("Welcome");
if(pos != string::npos) {
cout << "Found 'Welcome' at: " << pos << endl;
}
// 6. rfind: 从右向左找
size_t last_space = s.rfind(' ');
cout << "Last space at: " << last_space << endl;
// 7. substr: 截取子串
string sub = s.substr(7, 7); // 截取 "Welcome"
cout << "Sub-string: " << sub << endl;
return 0;
}
❗注意事项
- 追加字符的三种方式
char c = '!';
// 方式 A: 使用 push_back(c)
s.push_back(c);
// 方式 B: 使用 append(n, c)
s.append(1, c);
// 方式 C: 使用 += (最常用)
s += c; // 连接字符
s += " Hello"; // 连接字符串
- 使用 reserve 预留空间
string s;
// 如果预估要存 50 个字符,先预留空间可以减少频繁扩容带来的性能消耗
s.reserve(50);
🚩5)string 类非成员函数
| 函数 | 功能说明 |
|---|---|
operator+ | 尽量少用,因为传值返回,导致深拷贝效率低 |
operator>>(重点) | 输入运算符重载 |
operator<<(重点) | 输出运算符重载 |
getline(重点) | 获取一行字符串 |
relational operators(重点) | 大小比较 |
string full_name;
cout << "请输入你的全名:";
gline(cin, full_name);
cout << "你好," << full_name << endl;
string str1 = "apple";
string str2 = "banana";
if(str1 < str2) {
cout << "apple 在字典中排在 banana 前面" << endl;
}
if(str1 == "apple") {
cout << "字符串相等" << endl;
}
🚩6)vs 和 g++ 下 string 结构的说明
注意: 下述结构是在 32 位平台下进行验证,32 位平台下指针占 4 个字节。
VS 下 string 的结构
string 总共占 28 个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义 string 中字符串的存储空间:
- 当字符串长度小于 16 时,使用内部固定的字符数组来存放
- 当字符串长度大于等于 16 时,从堆上开辟空间
这种设计也是有一定道理的,大多数情况下字符串的长度都小于 16,那 string 对象创建好之后,内部已经有了 16 个字符数组的固定空间,不需要通过堆创建,效率高。
其次:还有一个 size_t 字段保存字符串长度,一个 size_t 字段保存从堆上开辟空间总的容量。
最后:还有一个指针做一些其他事情。
故总共占 16 + 4 + 4 + 4 = 28 个字节。
G++ 下 string 的结构
G++ 下,string 是通过写时拷贝实现的,string 对象总共占 4 个字节,内部只包含了一个指针,该指针将来指向一块堆空间,内部包含了如下字段:
- 空间总大小
- 字符串有效长度
- 引用计数
- 指向堆空间的指针,用来存储字符串
2.3)string 类的模拟实现
上面已经对 string 类进行了简单的介绍,大家只要能够正常使用即可。在面试中,面试官总喜欢让学生自己来模拟实现 string 类,最主要是实现 string 类的构造、拷贝构造、赋值运算符重载以及析构函数。
为了和标准库区分,此处使用 String。
class String {
public:
String(const char* str = "") {
if(nullptr == str) {
assert(false);
return;
}
_str = new char[strlen(str)+1];
strcpy(_str, str);
}
~String() {
if(_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
// 测试
void TestString() {
String s1("hello bit!!!");
String s2(s1);
}
这段代码展示了一个初学者在模拟实现 C++ String 类时经常会遇到的一个经典问题:浅拷贝导致的崩溃。
虽然你定义了构造函数和析构函数,但你漏掉了一个非常关键的角色:拷贝构造函数。
String s1("hello bit!!!");
String s2(s1); // 这里触发了隐式的拷贝构造
s1的创建:调用构造函数,在堆上申请了一块内存存储"hello bit!!!",_str指向这块地址。s2的创建:因为你没有自己写拷贝构造函数,编译器会为你生成一个默认的。默认拷贝构造函数执行的是浅拷贝,它只是简单地把s1._str的值(地址)赋值给了s2._str。- 析构时的灾难:
- 当
TestString结束时,s2先析构,调用delete[] _str,释放了内存。 - 随后
s1析构,它也尝试调用delete[] _str去释放同一块内存。 - 结果:同一块内存被释放了两次,程序直接崩溃。
- 当
解决方案 你需要手动实现拷贝构造函数,为新对象申请独立的内存空间。
String(const String& s) {
_str = new char[strlen(s._str)+1];
strcpy(_str, s._str);
}
🚩1)经典的 string 类问题
1)构造函数的缺省值设置非常关键
String(const char* str = "\0") // 为什么错?
- 逻辑冗余: 在 C/C++ 中,双引号括起来的字符串字面量(如 "")结尾自带一个隐式的
\0。如果你写 "\0",实际上这个字符串在内存中包含了两个\0。
String(const char* str = nullptr) // 为什么错?
- 解引用空指针:
string类的构造函数内部通常会调用strlen(str)来计算传入字符串的长度。 - 底层崩溃:
strlen函数的原理是持续向后读取内存直到遇到\0。如果str是nullptr(空指针),strlen试图访问地址为 0 的内存,这会导致非法访问(Segmentation Fault)。
🚩2)浅拷贝 VS 深拷贝
传统 vs 现代 的 string 类
1)传统
class String {
public:
String(const char* str = "") {
if(nullptr == str) {
assert(false);
return;
}
_str = new char[strlen(str)+1]; //分配空间,+1 用于存放'\0'
strcpy(_str, str);
}
String(const String& s) : _str(new char[strlen(s._str)+1]) {
strcpy(_str, s._str);
}
// 赋值运算符重载 /*避免 s1 = s2 出现浅拷贝问题*/
String& operator=(const String& s) {
if(this != &s) {
char* Pstr = new char[strlen(s._str)+1];
strcpy(Pstr, s._str);
delete[] _str;
_str = Pstr;
}
return *this; // 支持链式赋值
}
~String() {
if(_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
2)现代
class String {
public:
/*构造函数*/
String(const char* str = "") {
if(str != nullptr) {
_str = new char[strlen(str)+1];
strcpy(_str, str);
} else {
assert(false);
return;
}
}
/*拷贝构造函数*/
String(const String& s) : _str(nullptr) {
String strTmp(s._str);
swap(_str, strTmp._str);
}
/*赋值运算符重载*/
String& operator=(String s) {
std::swap(_str, s._str); // 直接交换形参和当前的资源
return *this; // s 是局部副本,函数结束时自动释放旧资源
}
/*另一种赋值实现 (传引用方式)*/
// String& operator=(const String& s) {
// if(this != &s) {
// String strTmp(s);
// std::swap(_str, strTmp._str);
// }
// return *this;
// }
/*析构函数*/
~String() {
if(_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
调用了 String 类的另一个构造函数(通常是 String(const char* str))。
strTmp 对象在被创建时,已经完美地做好了内存申请和数据拷贝工作(意味着你向编译器保证不会修改 s)。
交换指针:swap(_str, strTmp._str);
🚩3)写时拷贝
一、什么是写时拷贝(COW)?
在了解 COW 之前,我们先回顾一下传统的两种数据拷贝方式:
- 浅拷贝(Shallow Copy): 多个指针指向同一块内存。优点是速度快、省内存;缺点是一旦某个指针修改了数据,其他指针看到的数据也会跟着变,不安全。
- 深拷贝(Deep Copy): 重新分配一块同样大小的内存,把数据原封不动地搬过去。优点是绝对安全,互不影响;缺点是极其消耗时间和内存资源。
写时拷贝(Copy-on-Write)其实就是一种结合了浅拷贝和深拷贝优点的'懒汉(Lazy)'策略:
- 读操作时: 大家共享同一块内存(类似浅拷贝),不产生任何额外的开销。
- 写操作时: 当有人试图修改这块共享内存的数据时,系统才会真正分配一块新内存,把原来的数据复制过来,然后再进行修改(类似深拷贝)。
核心思想只有四个字:延迟执行。 只要你没去改它,大家就一直共享;只有在迫不得已(要修改)的时候,才去真正做拷贝的动作。
二、写时拷贝的工作机制
我们以内存页(Page)为例,看看 COW 在操作系统中是如何运转的:
- 初始状态: 进程 A 拥有一块内存页 Page 1。
- 发生拷贝(如 fork 进程): 此时需要拷贝出一个进程 B。系统不会真的去复制 Page 1,而是让进程 A 和 B 的页表都指向 Page 1,并将 Page 1 的权限设置为 只读(Read-Only)。
- 安全读取: 只要 A 和 B 只是读取数据,系统相安无事。
- 触发写入: 假设进程 B 要修改 Page 1 的数据。此时 CPU 发现 Page 1 是只读的,就会触发一个 缺页异常(Page Fault)。
- 内核介入(写时拷贝): 操作系统内核捕获到异常,发现这其实是一个 COW 页面。于是内核会默默分配一块新的物理内存(Page 2),把 Page 1 的数据复制到 Page 2 中,将进程 B 的页表映射到 Page 2,并将两者的权限都恢复为 可读写(Read-Write)。
- 继续执行: 进程 B 的写入操作在 Page 2 上顺利完成,进程 A 的数据依然在 Page 1 上保持原样。
三、写时拷贝的经典应用场景
COW 思想的应用极其广泛,以下是几个面试常考、工作中也常遇到的经典场景:
-
Linux 中的
fork()系统调用 在早期的 Unix 系统中,fork()创建子进程时会把父进程的所有内存空间全部深拷贝一份。但大多数情况下,子进程马上就会调用exec()去执行全新的程序,之前费时费力拷贝过来的内存完全浪费了。 引入 COW 后,fork()瞬间变得轻量级:父子进程初始共享全部物理内存。只有当某一方试图修改数据(如修改变量)时,才会针对那一小块特定的内存页进行拷贝。这极大地提升了进程创建的速度,并节省了内存。 -
Redis 的
bgsave持久化 Redis 是单线程的,如果在主线程里把庞大的内存数据写入磁盘(RDB 镜像),会严重阻塞正常的客户端请求。 Redis 的做法是:调用fork()创建一个子进程去专门负责写磁盘。得益于操作系统的 COW 机制,子进程在创建瞬间就拥有了和父进程一模一样的内存视图,而且速度极快。父进程依然可以毫无阻塞地处理客户端的新请求,只有被修改的数据页才会发生实际的内存拷贝。 -
Java 中的
CopyOnWriteArrayList这是 Java 并发包(java.util.concurrent)中提供的一个线程安全的 List。 它的内部实现原理是:任何读操作都不加锁,直接读取底层数组;而一旦有写操作(如add、set、remove),就会先将原数组复制一份出一个新数组,在新数组上进行修改,修改完成后,再把原数组的引用指向新数组。- 适用场景: 读多写少的并发场景(如黑白名单、系统配置缓存等)。
-
容器技术与存储(Docker / ZFS) Docker 镜像的分层存储(如 OverlayFS、AUFS)也深度依赖了 COW。当我们启动一个容器时,底层镜像层是只读的。只有当我们在容器内修改或新建文件时,系统才会把该文件从底层镜像层复制到最上面的可写层(Container Layer)进行修改。这使得成百上千个容器可以共享同一份底层镜像,极大地节省了磁盘空间。
四、写时拷贝的优缺点总结
🌟 优点:
- 极大地节省内存/磁盘资源: 只有在发生修改时才去分配资源,未修改的部分永远共享。
- 显著提升性能: 避免了不必要的、耗时的初始化全量拷贝操作。
- 并发读安全: 在某些语言级别实现中(如 Java),由于原始数据不可变,天然支持无锁的并发读取。
⚠️ 缺点:
- 不可预知的延迟: 拷贝动作被延迟到了写操作触发的瞬间。如果数据量大,突然触发的大量写操作会导致系统出现短暂的卡顿。
- 写放大开销: 在 Java 的
CopyOnWriteArrayList中,哪怕只修改数组中的一个元素,也要把整个数组复制一遍,写操作的开销极大。
2.4)面试中 string 的一种正确写法
C++ 的一个常见面试题是让你实现一个 String 类,限于时间,不可能要求具备 std::string 的功能,但至少要求能正确管理资源。具体来说:
- 能像
int类型那样定义变量,并且支持赋值、复制 - 能用作函数的参数类型及返回类型
- 能用作标准库容器的元素类型,即
vector/list/deque的value_type
2.5)STL 中的 string 类怎么了?
关于 STL 中 string 类的具体实现细节,建议参考官方文档或深入阅读源码分析,不同编译器和版本可能存在差异。


