跳到主要内容
C++ string 类详解:常用接口、内存管理与模拟实现 | 极客日志
C++ 算法
C++ string 类详解:常用接口、内存管理与模拟实现 C++ string 类详解涵盖常用构造方法、容量操作、访问遍历及修改接口。文章对比了 size 与 length 原理,解析了 reserve 与 resize 区别,并深入探讨了浅拷贝与深拷贝在资源管理中的差异。通过模拟实现 String 类,阐述了写时拷贝(COW)机制及其在 Linux fork、Redis bgsave 等场景的应用。内容包含 auto 关键字用法、范围 for 循环、迭代器操作以及 VS 与 G++ 下 string 结构差异分析,适合希望深入理解 C++ 标准库字符串实现的开发者参考。
修罗 发布于 2026/3/21 更新于 2026/9/10 50 浏览C++ string 类详解
一、为什么学习 string 类?
C 语言中的字符串
在 C 语言中,字符串是以 \0 结尾的字符数组。使用 string 类可以简化字符串操作,避免手动管理内存和边界检查。
字符串面试题
常见的字符串处理问题包括字符串转整数、字符串相加等,掌握 string 类有助于高效解决此类算法题。
二、C++ 标准库中的 string 类
在使用 string 类时,必须包含 #include <string> 头文件以及 using namespace std;。
2.1)auto 和范围 for
核心功能: auto 的核心功能是让编译器通过初始值来推导变量的类型。这意味着使用 auto 时,变量必须初始化。
auto x = 10 ;
auto y = 3.14 ;
auto ptr = &x;
用 auto 声明指针类型时,用 auto 和 auto* 没有任何区别,但用 auto 声明引用类型时则必须加 &。
int x = 10 ;
int * p = &x;
auto a = p;
auto * b = p;
int x = 10 ;
int & ref = x;
auto c = ref;
auto & d = ref;
c = 20 ;
d = 30 ;
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错。
auto a = 10 , b = 20 ;
auto c = 1.5 , d = 3.14 ;
int x = 0 ;
auto i = x, &j = x;
auto a = 10 , b = 3.14 ;
auto m = 5 , *p = &m;
auto n = 5 , q = 3.14 ;
auto 不能作为函数的参数,可以做返回值,但是建议谨慎使用。
void func (auto x) { ... }
template <typename T>
void func (T x) { ... }
int arr[] = {1 , 2 , 3 };
auto a[] = {1 , 2 , 3 };
auto b[3 ] = {1 , 2 , 3 };
int nums[] = {10 , 20 , 30 };
auto p = nums;
如果你希望保留数组的'大小信息'而不是让它退化成指针,可以使用引用:
int nums[] = {1 , 2 , 3 };
auto & refArr = nums;
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 };
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;
}
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 个字符 cstring(const string& s)(重点)拷贝构造函数
#include <iostream>
#include <string>
using namespace std;
int main () {
string s1;
cout << "s1 (空字符串): [" << s1 << "]" << endl;
string s2 ("Hello World" ) ;
cout << "s2 (由字符串常量构造): " << s2 << endl;
string s3 (10 , '*' ) ;
cout << "s3 (10 个星号): " << s3 << endl;
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;
cout << "length: " << s.length () << endl;
clear() 是清空有效字符,但不改变底层空间大小(capacity)
resize(size_t n, char c) 改变有效字符个数
s = "hello" ;
s.resize (10 , 'x' );
cout << "resize(10, 'x'): " << s << endl;
s.resize (3 );
cout << "resize(3): " << s << endl;
reserve(size_t res_arg) 为 string 预留空间,不改变有效元素个数;只有当参数大于当前底层空间时才会扩容
s.reserve (100 );
cout << "reserve(100) 后大小:" << s.size () << endl;
🚩3)string 类对象的访问及遍历操作 函数名称 功能说明 operator[pos](重点)operator 是字符串名,返回 pos 位置的字符,const string 类对象调用begin + endbegin 获取一个字符的迭代器 + end 获取最后一个字符下一个位置的迭代器rbegin + rendrbegin 获取一个字符的迭代器 + rend 获取最后一个字符下一个位置的迭代器范围 for C++11 支持更简洁的范围 for 的新遍历方式
#include <iostream>
#include <string>
using namespace std;
int main () {
string s = "Hello" ;
cout << "下标为 1 的字符:" << s[1 ] << endl;
s[0 ] = 'h' ;
cout << "正向迭代器遍历:" ;
for (string::iterator it = s.begin (); it != s.end (); ++it) {
cout << *it << " " ;
}
cout << endl;
cout << "反向迭代器遍历:" ;
for (string::reverse_iterator rit = s.rbegin (); rit != s.rend (); ++rit) {
cout << *rit << " " ;
}
cout << endl;
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 是专门用于从后往前读字符串的类型。
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 () {
string s = "Hello" ;
s.push_back ('!' );
s.append (" Welcome" );
s += " to C++" ;
const char * cStr = s.c_str ();
cout << "C-Style: " << cStr << endl;
size_t pos = s.find ("Welcome" );
if (pos != string::npos) {
cout << "Found 'Welcome' at: " << pos << endl;
}
size_t last_space = s.rfind (' ' );
cout << "Last space at: " << last_space << endl;
string sub = s.substr (7 , 7 );
cout << "Sub-string: " << sub << endl;
return 0 ;
}
❗注意事项 char c = '!' ;
s.push_back (c);
s.append (1 , c);
s += c;
s += " Hello" ;
🚩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 类的构造、拷贝构造、赋值运算符重载以及析构函数。
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 ];
strcpy (_str, str);
}
String (const String& s) : _str(new char [strlen (s._str)+1 ]) {
strcpy (_str, s._str);
}
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 ;
}
~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 类的具体实现细节,建议参考官方文档或深入阅读源码分析,不同编译器和版本可能存在差异。
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Gemini 图片去水印 基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online