为什么学习 string 类
在 C 语言中,字符串是以字符数组的形式存在的,处理起来往往需要手动管理内存和边界,容易引发缓冲区溢出等安全问题。C++ 引入的 std::string 类封装了这些底层细节,提供了更安全、便捷的字符串操作方式。
C++ 标准库中的 string 类
使用 string 类时,必须包含 <string> 头文件以及 using namespace std;。
auto 和范围 for
auto 关键字
auto 的核心功能是让编译器通过初始值来推导变量的类型。这意味着使用 auto 时,变量必须初始化。
auto x = 10; // x 被推导为 int
auto y = 3.14; // y 被推导为 double
auto ptr = &x; // ptr 被推导为 int*
用 auto 声明指针类型时,auto 和 auto* 没有区别;但声明引用类型时必须加 &,否则会发生值拷贝。
int x = 10;
int& ref = x;
auto c = ref; // c 的类型是 int(引用被丢弃)
auto& d = ref; // d 的类型是 int&(显式声明为引用)
d = 30; // x 变成了 30
注意,当在同一行声明多个变量时,它们必须是相同的类型。auto 不能作为函数参数,建议谨慎用作返回值。
for 循环
C++11 引入了基于范围的 for 循环,语法结构如下:
for(declaration : range) {
// 循环体
}
- 第一部分 (
declaration): 用于存放当前迭代到的元素。 - 第二部分 (
range): 要遍历的范围。
示例:
#include <iostream>
#include <string>
using namespace std;
int main() {
int array[] = {1, 2, 3, 4, 5};
// C++11 的遍历
for(auto& e : array) e *= 2; // 使用引用修改数据
for(auto e : array) cout << e << " ";
string str("hello world");
for(auto ch : str) {
cout << ch << " ";
}
return 0;
}
范围 for 不仅能作用于数组,也能作用于容器对象如 std::string。循环会自动调用 begin() 和 end(),无需手动管理迭代器或下标。
string 类的常用接口
1)string 类的常用构造
| 构造函数 | 功能说明 |
|---|---|
string() | 构造空的 string 类对象 |
string(const char* s) | 用 C-string 来构造 string 类对象 |
string(size_t n, char c) | 构造包含 n 个字符 c 的对象 |
string(const string& s) | 拷贝构造函数 |
#include <iostream>
#include <string>
using namespace std;
int main() {
string s1; // 空字符串
string s2("Hello World"); // 由字符串常量构造
string s3(10, '*'); // 10 个星号
string s4(s2); // 拷贝构造
return 0;
}
2)string 类对象的容量操作
| 函数名称 | 功能说明 |
|---|---|
size / length | 返回字符串有效字符长度 |
capacity | 返回空间总大小 |
empty | 检测字符串是否为空串 |
clear | 清空有效字符 |
reserve | 为字符串预留空间 |
resize | 将有效字符的个数改成 n 个 |
注意事项:
size()与length()底层原理相同,主要是为了与其他容器接口保持一致。clear()是清空有效字符,但不改变底层空间大小(capacity)。resize(n, c)改变有效字符个数。如果n大于当前大小,用字符c填充;如果小于,则截断。reserve(n)为string预留空间,不改变有效元素个数。只有当参数大于当前底层空间时才会扩容。
3)string 类对象的访问及遍历操作
| 函数名称 | 功能说明 |
|---|---|
operator[pos] | 返回 pos 位置的字符 |
begin + end | 获取正向迭代器 |
rbegin + rend | 获取反向迭代器 |
范围 for | C++11 新遍历方式 |
#include <iostream>
#include <string>
using namespace std;
int main() {
string s = "Hello";
// 下标访问
cout << s[1] << endl; // 'e'
s[0] = 'h'; // 可以修改
// 迭代器遍历
for(string::iterator it = s.begin(); it != s.end(); ++it) {
cout << *it << " ";
}
// 反向迭代器遍历
for(string::reverse_iterator rit = s.rbegin(); rit != s.rend(); ++rit) {
cout << *rit << " ";
}
// 范围 for
for(char c : s) {
cout << c << "-";
}
return 0;
}
对于 string 来说,常用的四种组合包括:iterator(能读能改,正着走)、const_iterator(只能读,正着走)、reverse_iterator(能读能改,倒着走)、const_reverse_iterator(只能读,倒着走)。
4)string 类对象的修改操作
| 函数名称 | 功能说明 |
|---|---|
push_back | 在字符串后尾插字符 |
append | 在字符串后追加一个字符串 |
operator+= | 在字符串后追加字符串 |
c_str | 返回 C 格式字符串 |
find + npos | 查找子串位置 |
rfind | 从右向左找字符 |
substr | 截取子串 |
#include <iostream>
using namespace std;
int main() {
string s = "Hello";
s.push_back('!'); // "Hello!"
s.append(" Welcome"); // "Hello! Welcome"
s += " to C++"; // "Hello! Welcome to C++"
const char* cStr = s.c_str();
cout << cStr << endl;
size_t pos = s.find("Welcome");
if(pos != string::npos) {
cout << "Found at: " << pos << endl;
}
string sub = s.substr(7, 7);
cout << "Sub-string: " << sub << endl;
return 0;
}
追加字符的三种方式:
push_back(c):只能插入单个字符。append(n, c):表示追加n个字符c。+=:最常用,既可以连接单个字符,也可以连接字符串。
使用 reserve 预留空间: 如果预估要存较多字符,先预留空间可以减少频繁扩容带来的性能消耗。
string s;
s.reserve(50); // 预留 50 字节
5)string 类非成员函数
| 函数 | 功能说明 |
|---|---|
operator+ | 尽量少用,传值返回效率低 |
operator>> | 输入运算符重载 |
operator<< | 输出运算符重载 |
getline | 获取一行字符串 |
relational operators | 大小比较 |
string full_name;
getline(cin, full_name);
cout << "你好," << full_name << endl;
string str1 = "apple";
string str2 = "banana";
if(str1 < str2) {
cout << "apple 在字典中排在 banana 前面" << endl;
}
6)VS 和 G++ 下 string 结构的说明
VS 下 string 的结构
string 总共占 28 个字节。内部有一个联合体,当字符串长度小于 16 时,使用内部固定的字符数组存放;大于等于 16 时,从堆上开辟空间。此外还有保存长度的字段和保存容量的字段。
G++ 下 string 的结构
G++ 下,string 是通过写时拷贝实现的,对象总共占 4 个字节,内部只包含了一个指针,指向堆空间。堆空间中包含了空间总大小、字符串有效长度、引用计数等信息。
string 类的模拟实现
在面试中,面试官常要求模拟实现 string 类,重点在于构造、拷贝构造、赋值运算符重载以及析构函数。
浅拷贝问题 初学者容易忽略拷贝构造函数,导致浅拷贝。默认拷贝构造函数只是简单复制指针地址,析构时会重复释放同一块内存,导致程序崩溃。
class String {
public:
String(const char* str = nullptr) {
if (str == nullptr) {
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); // 触发隐式拷贝构造,若未自定义则为浅拷贝
}
解决方案 手动实现拷贝构造函数,为新对象申请独立内存空间。
String(const String& s) {
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
传统 vs 现代的 string 类
传统写法 直接分配内存并拷贝,赋值运算符需检查自赋值并释放旧资源。
现代写法 利用移动语义或交换法优化。例如赋值运算符采用传值语义,形参是局部副本,函数结束时自动释放旧资源,只需交换指针即可。
class String {
public:
String(const char* str = nullptr) {
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;
}
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
写时拷贝(COW)
什么是写时拷贝? 写时拷贝是一种结合了浅拷贝和深拷贝优点的策略。读操作时共享内存,写操作时才真正分配新内存进行拷贝。核心思想是延迟执行。
工作机制 以操作系统内存页为例:进程 A 拥有 Page 1。发生拷贝(如 fork)时,系统不复制数据,而是让 A 和 B 都指向 Page 1,并将权限设为只读。当 B 试图写入时触发缺页异常,内核分配新页面 Page 2 并复制数据,B 映射到 Page 2,A 保持 Page 1。
应用场景
- Linux fork():父子进程初始共享内存,仅修改时拷贝,提升创建速度。
- Redis bgsave:子进程负责持久化,父进程继续处理请求,避免阻塞。
- Java CopyOnWriteArrayList:读多写少场景,写时复制数组。
- Docker/ZFS:镜像分层存储,容器层可写,底层镜像只读。
优缺点总结
- 优点:节省资源,提升性能,并发读安全。
- 缺点:不可预知的延迟,写放大开销。
面试中 string 的正确写法
C++ 面试题通常要求实现一个 String 类,至少满足:
- 支持定义变量、赋值、复制。
- 能用作函数参数及返回类型。
- 能作为标准库容器的元素类型。
实现时需确保资源管理正确,遵循三法则(Rule of Three)或五法则(Rule of Five),处理好深拷贝与移动语义。


