目录
[一、为什么要学习 C++ string 类?](#一、为什么要学习 C++ string 类?)
[1.1 C 语言字符串的局限性](#1.1 C 语言字符串的局限性)
[1.2 string 类的优势](#1.2 string 类的优势)
[二、C++11 小语法:auto 与范围 for](#二、C++11 小语法:auto 与范围 for)
[2.1 auto 关键字:自动推导变量类型](#2.1 auto 关键字:自动推导变量类型)
[2.2 范围 for 循环:简化遍历](#2.2 范围 for 循环:简化遍历)
[三、标准库 string 类的常用接口](#三、标准库 string 类的常用接口)
[3.1 string 类的构造函数](#3.1 string 类的构造函数)
[3.2 string 类的容量操作](#3.2 string 类的容量操作)
[3.3 string 类的访问与遍历操作](#3.3 string 类的访问与遍历操作)
[3.4 string 类的修改操作](#3.4 string 类的修改操作)
[3.5 string 类的非成员函数](#3.5 string 类的非成员函数)
[四、不同编译器下 string 类的底层结构](#四、不同编译器下 string 类的底层结构)
[4.1 VS 下的 string 结构(32 位)](#4.1 VS 下的 string 结构(32 位))
[4.2 GCC 下的 string 结构(32 位)](#4.2 GCC 下的 string 结构(32 位))
[4.3 两种实现的对比](#4.3 两种实现的对比)
[五、string 类的模拟实现](#五、string 类的模拟实现)
[5.1 错误的 string 实现:浅拷贝问题](#5.1 错误的 string 实现:浅拷贝问题)
[5.2 解决方案 1:深拷贝(传统版实现)](#5.2 解决方案 1:深拷贝(传统版实现))
[5.3 解决方案 2:深拷贝(现代版实现)](#5.3 解决方案 2:深拷贝(现代版实现))
[5.4 拓展:写时拷贝(了解)](#5.4 拓展:写时拷贝(了解))
[六、string 类实战:经典 OJ 题目解析](#六、string 类实战:经典 OJ 题目解析)
[6.1 题目 1:仅仅反转字母(LeetCode 917)](#6.1 题目 1:仅仅反转字母(LeetCode 917))
[6.2 题目 2:找字符串中第一个只出现一次的字符(LeetCode 387)](#6.2 题目 2:找字符串中第一个只出现一次的字符(LeetCode 387))
[6.3 题目 3:字符串相加(LeetCode 415)](#6.3 题目 3:字符串相加(LeetCode 415))
[6.4 题目 4:验证回文串(LeetCode 125)](#6.4 题目 4:验证回文串(LeetCode 125))
前言
在 C++ 编程中,字符串是最常用的数据类型之一。C 语言中通过字符数组和
str
系列库函数处理字符串,却存在内存管理复杂、安全性低等问题。而 C++ 标准库中的string
类,以面向对象的设计思想封装了字符串操作,不仅简化了代码编写,还提升了程序的安全性和效率。本文将从string
类的学习意义出发,详细讲解其常用接口、底层实现差异,并深入剖析模拟实现过程中的核心问题(如浅拷贝、深拷贝),下面就让我们正式开始吧!
一、为什么要学习 C++ string 类?
在学习string
类之前,我们先回顾 C 语言中的字符串处理方式,通过对比凸显string
类的优势。
1.1 C 语言字符串的局限性
C 语言中,字符串本质是以'\0'
结尾的字符数组 ,例如char str[] = "hello";
。为了操作字符串,C 标准库提供了strlen(求长度)、strcpy(拷贝)、strcat(拼接)、strcmp(比较)等库函数,但这些函数存在明显缺陷:
- 与字符串分离 :库函数和字符数组是独立的,不符合面向对象(OOP)"数据与操作封装" 的思想。例如,调用
strcpy
时需要手动传入字符数组地址,无法直接通过 "对象。方法" 的形式操作。 - 内存管理繁琐 :字符数组的空间需要用户手动分配(如
malloc
)和释放(如free
),稍不注意就会导致内存泄漏。例如,动态扩容字符串时,需先计算新空间大小、申请内存、拷贝数据、释放旧空间,步骤复杂。 - 安全性低 :缺乏边界检查,容易引发越界访问。例如,
strcat
拼接字符串时,若目标数组空间不足,会覆盖后续内存,导致程序崩溃;strcpy
也可能因源字符串长度超过目标数组容量而越界。
正是这些局限性,使得 C 语言处理字符串时效率低、bug 率高。而 C++ 的string
类完美解决了这些问题。
1.2 string 类的优势
string
类是 C++ 标准库(STL)中的核心类之一,它将字符串的 "数据"(字符序列)和 "操作"(增删改查)封装在一起,具备以下优势:
- 无需手动管理内存 :
string
类内部自动处理内存分配与释放,用户无需调用malloc
/free
,避免内存泄漏和越界。 - 接口丰富易用 :提供了大量成员函数(如
size
求长度、append
拼接、find
查找)和运算符重载(如+=
、==
),直接通过string 对象.接口
即可操作,代码简洁。 - 兼容性强 :支持与 C 语言字符串互转(通过
c_str()
方法),同时兼容 C++11 后的新特性(如范围 for 循环、auto
关键字)。 - 工业界常用 :在算法题(OJ)和实际开发中,
string
类是处理字符串的首选。例如,LeetCode 中 "字符串相加""最长回文子串" 等题目,均以string
类作为输入输出类型;工作中处理配置文件、日志信息时,string
类能显著提升开发效率。
二、C++11 小语法:auto 与范围 for
在讲解string
类接口前,先补充两个 C++11 语法 ------auto
和范围 for,它们能简化string
的遍历与变量声明,后续示例会频繁用到。
2.1 auto 关键字:自动推导变量类型
C++11 中,auto
的含义从 "自动存储类型指示符"(局部变量默认属性)改为类型推导符:编译器会根据变量的初始化值,自动推导其类型。这在声明复杂类型(如迭代器)时尤为有用。
auto 的核心规则如下:
1. 必须初始化 :auto
声明的变量必须有初始值,否则编译器无法推导类型。例如:
cpp
auto a; // 错误:未初始化,无法推导类型
auto b = 10; // 正确:b推导为int
auto c = 'a'; // 正确:c推导为char
auto d = string("hello"); // 正确:d推导为string
2. 指针与引用的差异:
- 声明指针时,
auto
和auto*
效果相同(编译器会自动识别指针类型); - 声明引用时,必须显式加
&
,否则会推导为值类型。
cpp
int x = 10;
auto y = &x; // y是int*(指针)
auto* z = &x; // z也是int*(与auto等价)
auto& m = x; // m是int&(引用,修改m会改变x)
3. 同一行声明的变量类型必须一致:编译器仅推导第一个变量的类型,后续变量需与该类型兼容。例如:
cpp
auto a = 1, b = 2; // 正确:a和b均为int
auto c = 3, d = 4.0; // 错误:c是int,d是double,类型不一致
4. 不能用于函数参数和数组声明:
auto
无法作为函数参数类型(编译器无法在编译期确定实参类型);auto
不能直接声明数组(数组类型需明确大小和元素类型)。
cpp
// 错误:auto不能作为函数参数
void func(auto a) {}
// 错误:auto不能声明数组
auto arr[] = {1, 2, 3};
string
和 STL 容器的迭代器类型通常很长(如string::iterator
),如果我们使用auto九
可以大幅简化代码:
cpp
#include <iostream>
#include <string>
#include <map>
using namespace std;
int main() {
map<string, string> dict = {{"apple", "苹果"}, {"orange", "橙子"}};
// 传统写法:类型冗长
map<string, string>::iterator it1 = dict.begin();
// auto写法:简洁
auto it2 = dict.begin();
// 遍历map
while (it2 != dict.end()) {
cout << it2->first << ":" << it2->second << endl;
++it2;
}
return 0;
}
2.2 范围 for 循环:简化遍历
C++11 引入的范围 for 循环 ,专门用于遍历 "有范围的集合"(如数组、string
、STL 容器),无需手动控制索引或迭代器,语法格式为:
cpp
for (迭代变量 : 集合) {
// 循环体
}
范围 for 的核心规则如下:
- 自动迭代:循环会自动遍历集合中的每个元素,从第一个到最后一个,无需判断结束条件。
- 迭代变量的类型 :
- 若仅读取元素,可声明为值类型(如
auto e
);- 若需修改元素,需声明为引用类型(如
auto& e
),否则修改的是临时拷贝。- 适用范围 :支持数组、
string
、vector、list 等 STL 容器,不支持普通指针(无明确范围)。
下面我们以string的
遍历为例,对比范围for和传统for两种写法的差异:
cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string str = "hello world";
// 传统for循环:需手动控制索引
for (int i = 0; i < str.size(); ++i) {
cout << str[i] << " ";
}
cout << endl;
// 范围for循环:自动遍历
for (auto ch : str) { // 读取元素(值类型)
cout << ch << " ";
}
cout << endl;
// 范围for修改元素(引用类型)
for (auto& ch : str) {
ch = toupper(ch); // 转为大写
}
cout << str << endl; // 输出:HELLO WORLD
return 0;
}
范围 for 本质上是迭代器的 "语法糖",编译器会将其转换为 **"迭代器初始化→判断结束→访问元素→迭代器递增"**的逻辑,我们从汇编代码中可观察到这一转换。
三、标准库 string 类的常用接口
string
类的接口非常丰富,本文将聚焦最常用、最核心的接口,按 "构造→容量→访问→修改" 的逻辑分类讲解。
3.1 string 类的构造函数
构造函数用于创建string
对象,常用的 4 种构造方式如下表:
构造函数原型 | 功能说明 |
---|---|
string() |
无参构造,创建空字符串(长度为 0) |
string(const char* s) |
用 C 风格字符串(如"hello" )构造string |
string(size_t n, char c) |
创建包含 n 个字符 c 的string (如3, 'a' →"aaa" ) |
string(const string& s) |
拷贝构造,用已有string 对象 s 创建新对象 |
下面一段代码将为大家示范构造函数的使用:
cpp
#include <iostream>
#include <string>
using namespace std;
void TestStringConstructor() {
// 1. 无参构造:空字符串
string s1;
cout << "s1: " << s1 << " (size: " << s1.size() << ")" << endl; // 输出:s1: (size: 0)
// 2. C风格字符串构造
string s2("hello bit");
cout << "s2: " << s2 << " (size: " << s2.size() << ")" << endl; // 输出:s2: hello bit (size: 8)
// 3. n个字符c构造
string s3(5, 'x');
cout << "s3: " << s3 << " (size: " << s3.size() << ")" << endl; // 输出:s3: xxxxx (size: 5)
// 4. 拷贝构造
string s4(s2);
cout << "s4: " << s4 << " (size: " << s4.size() << ")" << endl; // 输出:s4: hello bit (size: 8)
}
int main() {
TestStringConstructor();
return 0;
}
3.2 string 类的容量操作
容量操作用于获取string
的长度、空间大小,或调整空间,常用接口如下表所示:
成员函数 | 功能说明 |
---|---|
size_t size() |
返回有效字符个数(不包含'\0' ),与length() 功能完全一致,推荐使用 |
size_t length() |
历史接口,与size() 等价(早期为兼容 C 语言设计) |
size_t capacity() |
返回当前分配的总空间大小(单位:字节),包含未使用的空间 |
bool empty() |
判断字符串是否为空(有效字符个数为 0),空则返回true ,否则false |
void clear() |
清空有效字符(将size 置为 0),但不释放底层空间(capacity 不变) |
void reserve(size_t n) |
为字符串预留 n 字节空间,仅扩容不缩容,不改变size |
void resize(size_t n, char c) |
将有效字符个数调整为 n:- 若 n > 当前size :用字符 c 填充新增空间(默认'\0' );- 若 n < 当前size :截断字符串,capacity 不变 |
注意事项
- size 与 capacity 的区别 :
size
:实际存储的有效字符个数(如"hello"
的size
是 5);capacity
:底层分配的总空间(如"hello"
可能分配 15 字节,capacity
是 15),预留空间是为了减少后续扩容的开销。- clear () 不释放空间 :例如,
string s("hello"); s.clear();
后,s.size()
为 0,但s.capacity()
仍为 5(或更大),底层字符数组并未被释放。- reserve 的扩容规则 :
- 若 n > 当前
capacity
:扩容到至少 n 字节(不同编译器可能扩容到更大值,如 VS 按 1.5 倍扩容,GCC 按 2 倍扩容);- 若 n ≤ 当前
capacity
:不做任何操作(reserve
不缩容)。- resize 与 reserve 的区别 :
resize
改变size
(有效字符个数),可能扩容;reserve
仅改变capacity
(空间大小),不改变size
。
对于容量操作的使用示例如下:
cpp
#include <iostream>
#include <string>
using namespace std;
void TestStringCapacity() {
string s("hello");
cout << "初始状态:" << endl;
cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 输出:size:5, capacity:15(VS下)
// 1. empty()判断空
cout << "是否为空:" << (s.empty() ? "是" : "否") << endl; // 输出:否
// 2. clear()清空有效字符
s.clear();
cout << "clear后:" << endl;
cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 输出:size:0, capacity:15(capacity不变)
// 3. reserve()预留空间
s.reserve(20);
cout << "reserve(20)后:" << endl;
cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 输出:size:0, capacity:20(扩容到20)
// 4. resize()调整有效字符个数
s.resize(10, 'a'); // 用'a'填充,size变为10
cout << "resize(10, 'a')后:" << endl;
cout << "s: " << s << ", size: " << s.size() << ", capacity: " << s.capacity() << endl; // 输出:s:aaaaaaaaaa, size:10, capacity:20
s.resize(5); // 截断到5个字符
cout << "resize(5)后:" << endl;
cout << "s: " << s << ", size: " << s.size() << ", capacity: " << s.capacity() << endl; // 输出:s:aaaaa, size:5, capacity:20
}
int main() {
TestStringCapacity();
return 0;
}
3.3 string 类的访问与遍历操作
string
提供了多种访问字符和遍历字符串的方式,常用接口如下表:
成员函数 / 运算符 | 功能说明 |
---|---|
char& operator[](size_t pos) |
访问 pos 位置的字符(支持读写),若 pos 越界,行为未定义(VS 下会断言报错) |
const char& operator[](size_t pos) const |
const 对象的访问接口(仅读) |
iterator begin() |
返回指向第一个字符的迭代器 |
iterator end() |
返回指向最后一个字符下一个位置的迭代器(标记遍历结束) |
reverse_iterator rbegin() |
返回指向最后一个字符的反向迭代器(用于反向遍历) |
reverse_iterator rend() |
返回指向第一个字符前一个位置的反向迭代器 |
范围 for 循环 | C++11 特性,自动遍历所有字符(底层是迭代器) |
注意事项
- 迭代器的使用 :
begin()
和end()
构成 "左闭右开" 区间,遍历条件为it != end()
;- 反向迭代器
rbegin()
对应最后一个字符,rend()
对应第一个字符前,遍历方向从后向前。- operator [] 的越界检查 :
operator[]
不做越界检查(效率优先),若 pos 超过size()-1
,会导致未定义行为;若需安全访问,可使用at(pos)
(越界会抛异常)。
代码示例如下:
cpp
#include <iostream>
#include <string>
using namespace std;
void TestStringAccess() {
string s("hello world");
// 1. operator[]访问单个字符
cout << "第3个字符(索引2):" << s[2] << endl; // 输出:l
s[2] = 'L'; // 修改字符
cout << "修改后:" << s << endl; // 输出:heLlo world
// 2. 迭代器遍历(正向)
cout << "正向迭代器遍历:";
string::iterator it = s.begin();
while (it != s.end()) {
cout << *it << " ";
++it;
}
cout << endl; // 输出:h e L l o w o r l d
// 3. 反向迭代器遍历(反向)
cout << "反向迭代器遍历:";
string::reverse_iterator rit = s.rbegin();
while (rit != s.rend()) {
cout << *rit << " ";
++rit;
}
cout << endl; // 输出:d l r o w o l L e h
// 4. 范围for遍历(C++11)
cout << "范围for遍历:";
for (auto ch : s) {
cout << ch << " ";
}
cout << endl; // 输出:h e L l o w o r l d
}
int main() {
TestStringAccess();
return 0;
}
3.4 string 类的修改操作
修改操作包括尾部插入、字符串拼接、查找、截取等,是string
类最核心的功能之一,常用接口如下表:
成员函数 / 运算符 | 功能说明 |
---|---|
void push_back(char c) |
在字符串尾部插入单个字符 c |
void append(const string& str) |
在尾部追加字符串 str(也支持 C 风格字符串、n 个字符 c) |
string& operator+=(const string& str) |
重载+= 运算符,尾部追加字符串 / 字符(推荐使用,最简洁) |
const char* c_str() const |
返回 C 风格字符串(以'\0' 结尾),用于兼容 C 语言接口(如printf ) |
size_t find(const string& str, size_t pos=0) const |
从 pos 位置开始向后查找 str,返回首次出现的起始索引;若未找到,返回string::npos (一个很大的无符号数,可视为 - 1) |
size_t rfind(const string& str, size_t pos=npos) const |
从 pos 位置开始向前查找 str,返回首次出现的起始索引;未找到返回string::npos |
string substr(size_t pos=0, size_t len=npos) const |
从 pos 位置开始截取 len 个字符,返回新的string ;若 len 省略,截取到末尾 |
注意事项
- 尾部插入的效率对比 :
push_back(c)
:仅插入单个字符,效率高;append(str)
:插入字符串,需计算长度并拷贝;operator+=
:支持插入单个字符(s += 'a'
)或字符串(s += "abc"
),语法简洁,推荐优先使用。- find 与 npos 的配合 :
string::npos
是size_t
类型的静态常量,表示 "未找到"。判断查找结果时,需用== string::npos
,不可用== -1
(size_t
是无符号类型,-1 会被解释为极大值)。- substr 的截取规则 :若 pos 超过
size()-1
,会抛异常;若 len 超过剩余字符数,仅截取到末尾。
代码示例如下所示:
cpp
#include <iostream>
#include <string>
using namespace std;
void TestStringModify() {
string s("hello");
// 1. 尾部插入/追加
s.push_back(' '); // 插入空格:"hello "
s.append("world"); // 追加字符串:"hello world"
s += "!!!"; // 重载+=:"hello world!!!"
cout << "追加后:" << s << endl; // 输出:hello world!!!
// 2. c_str():转为C风格字符串
printf("C风格输出:%s\n", s.c_str()); // 输出:hello world!!!(printf需C风格字符串)
// 3. find()查找
size_t pos1 = s.find("world");
if (pos1 != string::npos) {
cout << "\"world\"的起始索引:" << pos1 << endl; // 输出:6
}
size_t pos2 = s.find("test");
if (pos2 == string::npos) {
cout << "\"test\"未找到" << endl; // 输出:"test"未找到
}
// 4. rfind()反向查找
size_t pos3 = s.rfind('l');
cout << "最后一个'l'的索引:" << pos3 << endl; // 输出:9("hello world!!!"中最后一个'l'在索引9)
// 5. substr()截取
string sub1 = s.substr(6, 5); // 从索引6开始,截取5个字符
cout << "截取sub1:" << sub1 << endl; // 输出:world
string sub2 = s.substr(12); // 从索引12开始,截取到末尾
cout << "截取sub2:" << sub2 << endl; // 输出:!!
}
int main() {
TestStringModify();
return 0;
}
3.5 string 类的非成员函数
非成员函数是独立于string
类的全局函数,用于输入输出、比较等操作,常用接口如下表:
非成员函数 | 功能说明 |
---|---|
ostream& operator<<(ostream& os, const string& str) |
重载<< ,用于输出string (如cout << s ) |
istream& operator>>(istream& is, string& str) |
重载>> ,用于输入string ,但会忽略空格和换行(遇到空格 / 换行停止) |
istream& getline(istream& is, string& str) |
读取一行字符串(包括空格),直到遇到换行符(换行符不存入 str) |
bool operator==(const string& s1, const string& s2) |
重载比较运算符(==、!=、<、>、<=、>=),按字典序比较 |
注意事项
- cin >> 与 getline 的区别 :
cin >> s
:输入时跳过开头的空白字符(空格、换行),遇到空白字符停止,例如输入"hello world"
,s
仅存储"hello"
;getline(cin, s)
:读取整行内容(包括空格),直到换行符,例如输入"hello world"
,s
存储"hello world"
。- 字典序比较 :
operator<
按字符的 ASCII 码值逐位比较,例如"apple" < "banana"
('a' 的 ASCII 码小于 'b'),"abc" < "abd"
(前两位相同,第三位 'c' < 'd')。
代码示例如下:
cpp
#include <iostream>
#include <string>
using namespace std;
void TestStringNonMember() {
string s1, s2;
// 1. operator>>输入(忽略空格)
cout << "输入s1(空格分隔):";
cin >> s1; // 若输入"hello world",s1仅存"hello"
cout << "s1: " << s1 << endl;
// 注意:cin >> 后会残留换行符,需用cin.ignore()清除,否则getline会读取空行
cin.ignore(); // 清除缓冲区中的换行符
// 2. getline输入(读取整行)
cout << "输入s2(含空格):";
getline(cin, s2); // 输入"hello world",s2存"hello world"
cout << "s2: " << s2 << endl;
// 3. 比较运算符
string s3("apple"), s4("banana");
cout << "s3 == s4? " << (s3 == s4 ? "是" : "否") << endl; // 输出:否
cout << "s3 < s4? " << (s3 < s4 ? "是" : "否") << endl; // 输出:是('a' < 'b')
}
int main() {
TestStringNonMember();
return 0;
}
四、不同编译器下 string 类的底层结构
string
类的底层实现因编译器而异,最典型的是微软 VS 和 GCC(Linux 下)的差异,主要体现在内存布局和空间分配策略上。以下基于 32 位平台(指针占 4 字节)分析。
4.1 VS 下的 string 结构(32 位)
VS 的string
类(属于 MSVC 标准库)采用**"小字符串优化(SSO,Small String Optimization)"**策略,结构总大小为 28 字节,内部包含:
- 联合体(union)_Bx :用于存储字符串数据,占 16 字节(
_BUF_SIZE
为 16):
- 当字符串长度小于 16 时:使用内部固定数组
_Buf[16]
存储(无需堆内存,效率高);- 当字符串长度大于等于 16 时:使用指针
_Ptr
指向堆内存(存储字符串数据)。- 两个 size_t 字段 :各占 4 字节,共 8 字节:
_Mysize
:有效字符个数(即size()
的返回值);_Myres
:当前容量(即capacity()
的返回值,不包含'\0'
)。- 额外指针:占 4 字节,用于内部管理(如调试信息、内存分配器指针)。
总大小计算:16(联合体)+ 4(_Mysize)+ 4(_Myres)+ 4(额外指针)= 28 字节。

SSO 在大多数场景下,字符串长度较短(如文件名、配置项等),无需申请堆内存,减少内存分配开销和碎片。
4.2 GCC 下的 string 结构(32 位)
GCC 的string
类(属于 GNU libstdc++)采用**"写时拷贝(Copy-On-Write,COW)"**策略,结构非常简洁,仅占 4 字节:
- 内部仅包含一个指针
_M_p
,指向堆内存中的_Rep
结构体和字符串数据。
_Rep
结构体(堆内存中)包含:
- **
_M_length
:**有效字符个数(size_t,4 字节);_M_capacity
:容量(size_t,4 字节);- **
_M_refcount
:**引用计数(_Atomic_word,4 字节),用于实现写时拷贝;- 字符串数据: 紧跟
_Rep
结构体,以'\0'
结尾。
写时拷贝的核心思想如下:
- 拷贝字符串时,不立即复制数据,而是共享同一块堆内存,仅增加引用计数(
_M_refcount++
); - 当某个对象修改字符串时(比如
operator[]
写操作),先检查引用计数,若大于 1,则复制数据到新堆内存,降低引用计数,再修改新数据,避免影响其他对象。
COW 能减少不必要的拷贝,节省内存;但在多线程环境下,引用计数的原子操作会带来性能开销,且 C++11 后因线程安全问题,部分编译器已弃用 COW(如 GCC 5.0 + 默认关闭 COW)。
4.3 两种实现的对比
特性 | VS(SSO) | GCC(COW,旧版本) |
---|---|---|
对象大小(32 位) | 28 字节 | 4 字节 |
短字符串(<16)存储 | 栈上固定数组(无堆分配) | 堆内存(共享) |
长字符串存储 | 堆内存 | 堆内存(共享) |
拷贝开销 | 短字符串快,长字符串慢 | 拷贝时快(仅改引用计数),修改时可能慢 |
线程安全 | 较好(无共享) | 较差(引用计数需原子操作) |
五、string 类的模拟实现
掌握string
类的使用后,我们就要来学习模拟实现其核心功能了,这是面试高频的考点。
5.1 错误的 string 实现:浅拷贝问题
首先看一个简单的string
实现(命名为String
以区分标准库):
cpp
#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
class String {
public:
// 构造函数:用C风格字符串初始化
String(const char* str = "") {
if (str == nullptr) { // 防止传入nullptr
assert(false);
return;
}
// 分配空间:strlen(str) + 1(+1用于存储'\0')
_str = new char[strlen(str) + 1];
strcpy(_str, str); // 拷贝字符串
}
// 析构函数:释放空间
~String() {
if (_str) {
delete[] _str; // 释放堆内存
_str = nullptr; // 避免野指针
}
}
private:
char* _str; // 指向存储字符串的堆内存
};
// 测试
void TestString() {
String s1("hello");
String s2(s1); // 调用编译器合成的拷贝构造函数
}
int main() {
TestString();
return 0;
}
在上述代码中,String
类未显式定义拷贝构造函数 ,编译器会合成一个默认拷贝构造函数 。默认拷贝构造函数采用**"浅拷贝"(位拷贝):仅将_str
指针的值拷贝给新对象,而非复制指针指向的内容。**
这将会导致如下的问题:
s1
和s2
的_str
指向同一块堆内存;- 当
TestString
函数结束时,先销毁s2
:~String()
释放s2._str
指向的内存;- 再销毁
s1
:~String()
再次释放同一块内存,导致双重释放,程序崩溃。
实际上,浅拷贝的本质就是多个对象共享同一份资源(堆内存),资源释放时冲突。
5.2 解决方案 1:深拷贝(传统版实现)
深拷贝的核心思想就是为新对象独立分配资源 (堆内存),并复制原对象的内容,使多个对象的资源互不干扰。需显式实现拷贝构造函数 和赋值运算符重载。传统的实现代码如下:
cpp
#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
class String {
public:
// 1. 构造函数
String(const char* str = "") {
if (str == nullptr) {
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 2. 拷贝构造函数(深拷贝)
String(const String& s) {
// 为新对象分配独立空间
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str); // 复制内容
}
// 3. 赋值运算符重载(深拷贝)
// 返回值为String&:支持链式赋值(如s1 = s2 = s3)
// 参数为const String&:避免拷贝,且防止修改原对象
String& operator=(const String& s) {
// 防止自赋值(如s1 = s1)
if (this != &s) {
// 步骤1:先释放当前对象的旧空间
delete[] _str;
// 步骤2:分配新空间并拷贝内容
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}
return *this;
}
// 4. 析构函数
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
// 辅助接口:获取字符串(用于测试)
const char* c_str() const {
return _str;
}
private:
char* _str;
};
// 测试
void TestString() {
String s1("hello");
String s2(s1); // 调用深拷贝构造函数
cout << "s1: " << s1.c_str() << endl; // 输出:hello
cout << "s2: " << s2.c_str() << endl; // 输出:hello
String s3("world");
s3 = s1; // 调用深拷贝赋值运算符
cout << "s3: " << s3.c_str() << endl; // 输出:hello
}
int main() {
TestString();
return 0;
}
上述代码有以下两个关键改进:
- 拷贝构造函数 :为
s2
分配新的堆内存,复制s1._str
的内容,s1
和s2
的_str
指向不同空间,销毁时互不影响。- 赋值运算符重载 :
- 先判断自赋值(
this != &s
):若不判断,自赋值时会先释放_str
,导致后续拷贝时访问野指针;- 先释放旧空间,再分配新空间,避免内存泄漏。
5.3 解决方案 2:深拷贝(现代版实现)
传统版实现的赋值运算符重载存在一个潜在问题:若new char[]
失败(抛出异常),当前对象的_str
已被释放,会变成野指针。现代人们实现利用的是 "构造函数 + swap" 的方式,能够在避免异常安全问题的同时简化代码。如下所示:
cpp
#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
class String {
public:
// 1. 构造函数(同传统版)
String(const char* str = "") {
if (str == nullptr) {
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
// 2. 拷贝构造函数(现代版)
String(const String& s)
: _str(nullptr) { // 先初始化为nullptr,避免swap后野指针
String strTmp(s._str); // 用s._str构造临时对象strTmp(调用构造函数,分配新空间)
swap(_str, strTmp._str); // 交换当前对象和临时对象的_str
}
// 3. 赋值运算符重载(现代版1:参数为值传递)
String& operator=(String s) { // s是实参的拷贝(调用拷贝构造函数)
swap(_str, s._str); // 交换当前对象和s的_str
return *this;
}
/*
// 赋值运算符重载(现代版2:参数为const引用,内部构造临时对象)
String& operator=(const String& s) {
if (this != &s) {
String strTmp(s); // 构造临时对象
swap(_str, strTmp._str);
}
return *this;
}
*/
// 4. 析构函数(同传统版)
~String() {
if (_str) {
delete[] _str;
_str = nullptr;
}
}
// 辅助接口
const char* c_str() const {
return _str;
}
private:
char* _str;
};
// 测试
void TestStringModern() {
String s1("hello");
String s2(s1);
cout << "s1: " << s1.c_str() << endl; // 输出:hello
cout << "s2: " << s2.c_str() << endl; // 输出:hello
String s3("world");
s3 = s1;
cout << "s3: " << s3.c_str() << endl; // 输出:hello
}
int main() {
TestStringModern();
return 0;
}
现代版本的深拷贝具有如下的核心思想:
拷贝构造函数:
- 先将当前对象的
_str
初始化为nullptr
;- 构造临时对象
strTmp
(分配新空间,复制内容);- 交换
_str
和strTmp._str
:当前对象获得strTmp
的新空间,strTmp
获得原_str
(nullptr);- 函数结束时,
strTmp
析构:释放nullptr
(无操作),避免资源泄漏。赋值运算符重载(值传递参数):
- 参数
s
是实参的拷贝(调用拷贝构造函数,分配新空间);- 交换
_str
和s._str
:当前对象获得s
的新空间,s
获得当前对象的旧空间;- 函数结束时,
s
析构:释放旧空间,避免内存泄漏;- 无需判断自赋值:若自赋值,
s
是当前对象的拷贝,交换后s
析构释放旧空间,当前对象获得新空间(与旧空间相同),但无错误。
由此可见,现代版本的实现代码更简洁,且天然具备异常安全性(若new
失败,临时对象未构造,当前对象的_str
未被修改)。
5.4 拓展:写时拷贝(了解)
写时拷贝(COW)是一种 "延迟拷贝" 策略,结合了浅拷贝的高效和深拷贝的安全,核心是引用计数。其实现思路如下:
- 在堆内存中增加一个 "引用计数",记录当前共享该内存的对象个数;
- 拷贝对象时,仅增加引用计数(浅拷贝),不复制数据;
- 当对象修改数据时,先检查引用计数:
- 若引用计数 > 1:复制数据到新堆内存,降低原内存的引用计数,当前对象指向新内存(深拷贝);
- 若引用计数 = 1:直接修改数据(无需拷贝)。
代码实现如下:
cpp
#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;
class String {
private:
// 引用计数结构体:存储在堆内存,与字符串数据关联
struct RefCount {
size_t count; // 引用计数
char data[1]; // 柔性数组:存储字符串数据(实际大小动态分配)
};
RefCount* _pRef; // 指向RefCount结构体的指针
public:
// 构造函数:创建新的RefCount和字符串数据
String(const char* str = "") {
if (str == nullptr) str = "";
size_t len = strlen(str);
// 分配内存:RefCount大小 + 字符串长度 + 1('\0')
_pRef = (RefCount*)new char[sizeof(RefCount) + len + 1];
_pRef->count = 1; // 初始引用计数为1
strcpy(_pRef->data, str); // 拷贝字符串
}
// 拷贝构造函数:增加引用计数(浅拷贝)
String(const String& s) {
_pRef = s._pRef;
_pRef->count++; // 引用计数+1
}
// 赋值运算符重载:先减少当前引用计数,再共享新内存
String& operator=(const String& s) {
if (this != &s) {
// 减少当前内存的引用计数,若为0则释放
Release();
// 共享s的内存,引用计数+1
_pRef = s._pRef;
_pRef->count++;
}
return *this;
}
// 析构函数:减少引用计数,必要时释放内存
~String() {
Release();
}
// 重载operator[]:写操作时触发拷贝(COW核心)
char& operator[](size_t pos) {
assert(pos < strlen(_pRef->data));
// 若引用计数>1,触发深拷贝
if (_pRef->count > 1) {
size_t len = strlen(_pRef->data);
// 分配新内存
RefCount* newRef = (RefCount*)new char[sizeof(RefCount) + len + 1];
newRef->count = 1;
strcpy(newRef->data, _pRef->data);
// 减少原内存的引用计数,若为0则释放
_pRef->count--;
_pRef = newRef;
}
// 返回当前内存的数据(可修改)
return _pRef->data[pos];
}
// 辅助接口:获取字符串
const char* c_str() const {
return _pRef->data;
}
private:
// 释放内存:引用计数-1,若为0则删除
void Release() {
_pRef->count--;
if (_pRef->count == 0) {
delete[] (char*)_pRef; // 释放整个RefCount结构体
_pRef = nullptr;
}
}
};
// 测试写时拷贝
void TestCOW() {
String s1("hello");
String s2(s1); // 浅拷贝,引用计数=2
cout << "s1: " << s1.c_str() << ", s2: " << s2.c_str() << endl; // 输出:hello, hello
s2[0] = 'H'; // 修改s2,触发深拷贝,引用计数分别为1
cout << "s1: " << s1.c_str() << ", s2: " << s2.c_str() << endl; // 输出:hello, Hello
}
int main() {
TestCOW();
return 0;
}
写实拷贝实际上还是具有一定的局限性的:
- 线程安全问题 :引用计数的修改需原子操作(如
atomic_int
),否则多线程下可能出现计数错误; - 频繁修改场景低效 :若对象频繁修改(如循环调用
operator[]
),会多次触发深拷贝,效率低于普通深拷贝; - C++11 后的弃用:C++11 标准对容器的线程安全要求提高,COW 的实现复杂度增加,目前主流编译器(如 GCC 5.0+、VS 2015+)已默认不使用 COW,转而采用 SSO。
六、string 类实战:经典 OJ 题目解析
掌握了string
类的接口后,通过 OJ 题目巩固知识点。以下选取 4 道经典题目,涵盖字符串反转、查找、相加等核心操作。
6.1 题目 1:仅仅反转字母(LeetCode 917)
题目描述
给定一个字符串
s
,反转字符串中所有的字母,同时保持非字母字符的位置不变。例如:
- 输入:
"ab-cd"
,输出:"dc-ba"
;- 输入:
"a-bC-dEf-ghIj"
,输出:"j-Ih-gfE-dCba"
。解题思路
- 双指针法:用
begin
指向字符串开头,end
指向字符串末尾;- 循环移动
begin
找到字母,移动end
找到字母,交换两者;- 直到
begin >= end
。
代码实现如下:
cpp
#include <iostream>
#include <string>
using namespace std;
class Solution {
public:
// 判断是否为字母
bool isLetter(char ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
}
string reverseOnlyLetters(string s) {
if (s.empty()) return s;
size_t begin = 0;
size_t end = s.size() - 1;
while (begin < end) {
// 找到左侧第一个字母
while (begin < end && !isLetter(s[begin])) {
++begin;
}
// 找到右侧第一个字母
while (begin < end && !isLetter(s[end])) {
--end;
}
// 交换
swap(s[begin], s[end]);
++begin;
--end;
}
return s;
}
};
int main() {
Solution sol;
string s1 = "ab-cd";
cout << sol.reverseOnlyLetters(s1) << endl; // 输出:dc-ba
string s2 = "a-bC-dEf-ghIj";
cout << sol.reverseOnlyLetters(s2) << endl; // 输出:j-Ih-gfE-dCba
return 0;
}
6.2 题目 2:找字符串中第一个只出现一次的字符(LeetCode 387)
题目描述
给定一个字符串
s
,找到它的第一个不重复的字符,并返回它的索引。如果不存在,返回 - 1。例如:
- 输入:
"leetcode"
,输出:0('l' 是第一个不重复字符);- 输入:
"loveleetcode"
,输出:2('v' 是第一个不重复字符)。解题思路
- 计数法:用大小为 256 的数组(覆盖所有 ASCII 字符)记录每个字符的出现次数;
- 第一次遍历字符串,统计每个字符的出现次数;
- 第二次遍历字符串,找到第一个出现次数为 1 的字符,返回其索引。
cpp
#include <iostream>
#include <string>
using namespace std;
class Solution {
public:
int firstUniqChar(string s) {
// 初始化计数数组(256个ASCII字符)
int count[256] = {0};
// 第一次遍历:统计次数
for (char ch : s) {
count[ch]++;
}
// 第二次遍历:找第一个次数为1的字符
for (int i = 0; i < s.size(); ++i) {
if (count[s[i]] == 1) {
return i;
}
}
// 无重复字符
return -1;
}
};
int main() {
Solution sol;
string s1 = "leetcode";
cout << sol.firstUniqChar(s1) << endl; // 输出:0
string s2 = "loveleetcode";
cout << sol.firstUniqChar(s2) << endl; // 输出:2
return 0;
}
6.3 题目 3:字符串相加(LeetCode 415)
题目描述
给定两个非负整数
num1
和num2
,以字符串形式表示,返回它们的和(也以字符串形式表示)。例如:
- 输入:
num1 = "11", num2 = "123"
,输出:"134"
;- 输入:
num1 = "456", num2 = "77"
,输出:"533"
。解题思路
- 模拟手动加法:从字符串末尾(个位)开始相加,记录进位;
- 用
end1
和end2
分别指向num1
和num2
的末尾,next
记录进位(初始为 0);- 循环相加:
value1 + value2 + next
,计算当前位的值和新的进位;- 结果存入临时字符串,最后反转字符串(因是从后向前存储)。
cpp
#include <iostream>
#include <string>
#include <algorithm> // 用于reverse函数
using namespace std;
class Solution {
public:
string addStrings(string num1, string num2) {
int end1 = num1.size() - 1;
int end2 = num2.size() - 1;
int next = 0; // 进位
string result;
// 循环条件:任意一个字符串未遍历完,或有进位
while (end1 >= 0 || end2 >= 0 || next > 0) {
// 取当前位的值(若已遍历完,取0)
int value1 = (end1 >= 0) ? (num1[end1--] - '0') : 0;
int value2 = (end2 >= 0) ? (num2[end2--] - '0') : 0;
// 计算当前位总和
int sum = value1 + value2 + next;
next = sum / 10; // 新的进位
int current = sum % 10; // 当前位的值
// 存入结果(尾部插入,后续需反转)
result += (current + '0');
}
// 反转结果(从后向前存储→从前向后)
reverse(result.begin(), result.end());
return result;
}
};
int main() {
Solution sol;
string num1 = "11", num2 = "123";
cout << sol.addStrings(num1, num2) << endl; // 输出:134
num1 = "456", num2 = "77";
cout << sol.addStrings(num1, num2) << endl; // 输出:533
return 0;
}
6.4 题目 4:验证回文串(LeetCode 125)
题目描述
给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。例如:
- 输入:
"A man, a plan, a canal: Panama"
,输出:true
(忽略非字母数字,为"amanaplanacanalpanama"
,是回文);- 输入:
"race a car"
,输出:false
(忽略非字母数字,为"raceacar"
,不是回文)。解题思路
- 预处理:将所有小写字母转为大写(或反之),忽略非字母数字字符;
- 双指针法:
begin
指向开头,end
指向末尾,比较两者是否相等;- 若不相等,返回
false
;若遍历结束均相等,返回true
。
cpp
#include <iostream>
#include <string>
#include <cctype> // 用于toupper函数
using namespace std;
class Solution {
public:
// 判断是否为字母或数字
bool isLetterOrNumber(char ch) {
return isalnum(ch); // 库函数:判断是否为字母或数字
}
bool isPalindrome(string s) {
// 预处理:转为大写,忽略非字母数字
for (char& ch : s) {
if (islower(ch)) {
ch = toupper(ch); // 小写转大写
}
}
int begin = 0;
int end = s.size() - 1;
while (begin < end) {
// 找到左侧第一个字母/数字
while (begin < end && !isLetterOrNumber(s[begin])) {
++begin;
}
// 找到右侧第一个字母/数字
while (begin < end && !isLetterOrNumber(s[end])) {
--end;
}
// 比较
if (s[begin] != s[end]) {
return false;
}
++begin;
--end;
}
return true;
}
};
int main() {
Solution sol;
string s1 = "A man, a plan, a canal: Panama";
cout << (sol.isPalindrome(s1) ? "true" : "false") << endl; // 输出:true
string s2 = "race a car";
cout << (sol.isPalindrome(s2) ? "true" : "false") << endl; // 输出:false
return 0;
}
七、推荐拓展阅读
https://blog.csdn.net/haoel/article/details/1491219

https://coolshell.cn/articles/10478.html

总结
通过本文的学习,相信读者已能熟练使用
string
类,并理解其底层原理。后续可通过更多实战题目(如字符串相乘、最长公共前缀)进一步提升字符串处理能力,为 C++ 进阶打下坚实基础。