⭐️博主: 此生决int-@CSDN博客
速胜派就是最大的投降派!!!
🔥热门专栏🔥
深入理解 C++ 系列 | 算法系列
快速复习系列 | Java 速通系列
文章目录
-
- 上期回顾
- C++ string 类
-
- 一、为什么学习 string?
-
- 1. C 语言字符串的问题
- 2. string 的价值
- 二、auto 关键字
-
- 1. auto 的作用
- 2. auto 必须有初始化表达式
- 3. auto 推导指针和引用
- 4. 同一条声明中的类型必须一致
- 5. auto 的限制
- 6. auto 的典型价值
- 三、范围 for
-
- 1. 基本格式
- 2. 值拷贝和引用的区别
- 3. 范围 for 的底层
- 四、string 的常见构造
-
- 4. 拷贝构造
- 五、容量相关接口
-
- 1. size 和 length
- 2. empty
- 3. clear
- 4. reserve
- 5. resize
- 6. reserve 和 resize 的区别
- 六、字符串的访问⭐️⭐️⭐️⭐️
-
- 1. operator[](⭐️最常用)
- 2,at
- 3,front
- 4,back
- 七,字符串的遍历方式⭐️⭐️
-
- 1,下标(常用⭐️)
- 2. 正向迭代器
- 3. 反向迭代器
- 4. 范围 for(前面讲过,这里复习一下)(常用⭐️)
- 八、string 修改接口⭐️⭐️⭐️
-
- 1. 修改
- 1. 修改(Modifiers)
- 2,字符串操作
- 详细讲解:
-
- 2. c_str
- 3. find 和 npos
- 4. rfind
- 5. substr
- 全局函数
- 九、非成员函数和输入输出
-
- 1. operator<<
- 2. operator>>
- 3. getline⭐️⭐️⭐️
- 4. 关系运算符
- 5. operator+ 为什么不宜在循环里滥用?
- 十、string 的底层实现结构⭐️⭐️⭐️
-
- 1. 小字符串优化
- 2. 写时拷贝
- 本人在模拟实现string类时犯的最频繁的错误
- 十一、典型字符串题型
- 十二、string 模拟实现的核心
- 1. 基础构造函数
- 2. 为什么默认参数不能随便写成 nullptr?
- 3. 析构函数
- 十二、有关浅拷贝问题
- 十四、深拷贝
- 十五、传统版 String 实现
-
- 1. 深拷贝拷贝构造
- 2. 传统赋值运算符重载
- 十六、现代版:拷贝交换写法(以后基本都是这样写)
-
- 1. 拷贝构造
- 2. 赋值运算符
- 十七、写时拷贝(了解)
- 十八、易错点汇总
-
- 易错点 1:范围 for 修改元素却没加引用
- 易错点 2:混淆 size 和 capacity
- 易错点 3:混淆 reserve 和 resize
- 易错点 4:以为 clear 会释放底层空间
- 易错点 5:find 未找到时和 -1 比较(可以,但不推荐)
- 易错点 6:用 cin >> 读取带空格的一行
- 易错点 7:空字符串时执行 size() – 1
- 易错点 8:大数相加忘记最终进位
- 易错点 9:模拟 String 使用默认拷贝
- 易错点 10:new[] 和 delete 不匹配
- 易错点 11:死背 string 对象大小
- 二十、本节总结
- 下期预告
- string相关算法题
- 结语
上期回顾
上一篇我们主要学习了 C++中有关模版的一些知识,了解了STL的一些版本和历史,那么今天就让我们来看看C++里面的string,看看它与我们C语言的字符串数组有哪些不同?也让string类带领我们初探C++里面的STL的魅力!
C++ string 类
一、为什么学习 string?
1. C 语言字符串的问题
C 语言字符串本质是:
以 \\0 结尾的一组字符。
例如:
char str[] = "hello";
C 标准库提供了:
strlen
strcpy
strcat
strcmp
strstr
但 C 字符串存在几个问题:
- 字符串数据和操作函数相互分离,不符合面向对象封装思想。
- 底层空间需要程序员手动管理。
- 容量计算容易出错。
- 容易发生越界访问。
- 拼接、插入和扩容比较麻烦。
2. string 的价值
C++ 的 string 将字符串数据和操作封装到一个类中。
优势:
- 自动管理动态空间。
- 自动扩容。
- 支持拷贝、赋值和比较。
- 提供丰富的查找、追加、截取接口。
- 可以使用迭代器和范围 for。
- OJ、算法竞赛和实际开发中都很常用。
使用时需要:
#include <string>
推荐通过:
std::string
访问标准库类型;练习代码中也可以先使用:
using namespace std;
二、auto 关键字
1. auto 的作用
C++11 中,auto 用于让编译器根据初始化表达式推导变量类型。
int a = 10;
auto b = a; // b 是 int
auto c = 'a'; // c 是 char
auto d = 3.14; // d 是 double
重点:
auto 的类型推导发生在编译期,不是运行时动态类型。(注释1)
注释1: 所谓的“运行时动态类型”是Java和Python等语言具有的特性,他们有些类型是在运行时确定的,并且类型会随着运行的而改变,而auto 是编译器帮我们补全类型,类型是确定的,不能改变!
auto 本质上只是让编译器帮我们写出变量类型,变量的真实类型仍然在编译阶段就已经确定。
auto 并不会让变量变成运行时动态类型,也不会像 Python 或 Java 的 Object 一样在运行过程中改变类型。
2. auto 必须有初始化表达式
错误:
auto e;
编译器没有初始值,无法推导类型。
正确:
auto e = 10;
3. auto 推导指针和引用
int x = 10;
auto y = &x; // int*
auto* z = &x; // int*
auto& m = x; // int&
结论:
- 推导指针时,auto 和 auto* 都可以。
- 声明引用时,必须显式写 &。
4. 同一条声明中的类型必须一致
可以:
auto a = 1, b = 2;
不可以:
auto c = 3, d = 4.0;
原因:
同一条 auto 声明中的变量必须推导成相同类型。
5. auto 的限制
按本课 C++11 学习阶段掌握:
- 不能只写 auto 而不给初始值。
- 不能直接用作普通函数形参类型。
- 不建议为了省事滥用函数返回值 auto。//可以,但滥用会降低代码可读性。
- 不能按课件示例直接声明 auto 元素类型数组。//auto 用于变量类型推导,而不是数组元素类型推导,不能直接声明 auto 类型数组。
说明:更新的 C++ 标准对部分 auto 用法有所扩展,但当前阶段应先掌握课件中的 C++11 规则。
6. auto 的典型价值
当类型很长时,auto 可以显著简化代码。
std::map<std::string, std::string> dict;
// 完整类型很长
std::map<std::string, std::string>::iterator it1 = dict.begin();
// 使用 auto 后更简洁
auto it2 = dict.begin();
适合使用 auto 的场景:
- 迭代器类型很长。
- 类型从右侧表达式非常清楚。
- 避免重复书写复杂模板类型。
三、范围 for
1. 基本格式
for (元素变量 : 可迭代范围)
{
}
例如:
int array[] = { 1, 2, 3, 4, 5 };
for (auto e : array)
{
cout << e << " ";
}
范围 for 可以用于:
- 原生数组
- string
- 标准库容器
2. 值拷贝和引用的区别
只读取:
for (auto ch : str)
{
cout << ch;
}
这里 ch 是元素副本,修改 ch 不会影响原字符串。
需要修改原元素:
for (auto& ch : str)
{
ch = toupper(ch);
}
只读且避免拷贝:
for (const auto& ch : str)
{
cout << ch;
}
对 char 这种小类型,值拷贝成本很小;对复杂对象,常用 const auto&。
3. 范围 for 的底层
容器的范围 for 本质上会使用迭代器完成遍历,大致相当于:
for (auto it = str.begin(); it != str.end(); ++it)
{
auto ch = *it;
}
复习结论:
范围 for 是更简洁的遍历语法,底层仍然依赖 begin()、end() 和迭代器。
四、string 的常见构造
- string():创建空字符串。
string s1;//空字符串
- string(const string&):拷贝构造。
string s2(s1);
- string(const char*):由字符串创建。
string s3("abcde");
- string(size_t, char):创建由多个相同字符组成的字符串。
string s4(5,'a');
- 子串构造 string(const string&, size_t, size_t):
string s5(s3,2,3);//下标2(包括2)开始,复制3个
- 区间构造 string(InputIterator, InputIterator):
string s = "abcdef";
string t(s.begin(),
s.begin()+3);//s="abc"
4. 拷贝构造
string s4(s2);
string s5 = s2;
标准库 string 会正确管理自己的资源,不需要用户手动深拷贝。
或许你也想问:
1,string s5 = "12345"; 中,字符串常量明明存放在常量区,为什么又说 string 的数据存放在堆区?
"12345" 就是字符串常量,string是在堆区new一个空间,然后把它拷贝过来了!方便修改!
- "12345" 本身确实存放在只读数据区(常量区)。
- string 并不会直接指向这块内存,而是会将内容复制到自己管理的存储空间中。
- 这样做是因为 string 支持修改,而字符串常量不允许修改。
2, const char* p = "12345"; 中,"12345" 是不是一个数组?p 是不是这个数组的地址?以及什么时候 "12345" 是数组,什么时候又是字符串常量?
"12345"就是一个字符串数组,但是因为他的只读属性,所以也可以叫做字符串常量,然后他有些情况可以看作数组的首元素地址!(即在大多数表达式中会自动退化为 const char*,而不是本身就是指针。)
- "12345" 本质上是一个字符串字面量(字符串常量),其真实类型是 const char[6]。
- 因此,从类型的角度来看,它确实是一个包含 6 个元素(包括结尾 '\\0')的字符数组。
- 同时,由于它存放在只读数据区、生命周期贯穿整个程序运行期间、内容不可修改,因此又被称为字符串常量(字符串字面量)。
- 所以,“字符数组”和“字符串常量”描述的是它的不同属性,并不冲突:
- 从类型和内存布局来看,它是一个 const char[6] 数组;
- 从存储区域和只读属性来看,它是一个字符串常量。
- 在大多数表达式中,字符串字面量会发生数组退化,自动转换为首元素地址,即 const char*。
- 因此:
const char* p = "12345";
本质上等价
const char* p = &"12345"[0];
此时:
- p 保存的是数组首元素地址;
因此:
"12345" 本身始终都是一个 const char[6] 数组,它不会因为使用场景不同而变成别的类型。
只是当它参与大多数表达式运算时,会自动发生数组退化,从数组变成指向首元素的 const char*,
总结:
> `"12345"` 到底是数组、字符串常量还是指针?
一句话:
> **它一直都是数组,只不过经常自动变成指针;而“字符串常量”描述的是它的存储属性和只读属性,而不是它的类型。**
五、容量相关接口
常用接口:
| size() | 返回有效字符个数 |
| length() | 返回有效字符个数 |
| capacity() | 返回当前容量 |
| empty() | 判断字符串是否为空 |
| clear() | 清空有效字符 |
| reserve(n) | 预留至少能容纳一定字符的空间 |
| resize(n) | 把有效字符个数调整为 n |
| resize(n, c) | 调整到 n 个字符,扩展部分用 c 填充 |
1. size 和 length
string s = "hello";
cout << s.size();
cout << s.length();
两者底层意义相同,都返回有效字符长度。
通常优先使用:
size()
因为它与其他 STL 容器接口更统一。
2. empty
if (s.empty())
{
}
比下面写法更直接:
if (s.size() == 0)
{
}
3. clear
s.clear();
作用:
- 有效字符个数变为 0。
- 字符串变为空。
- 通常不会因此缩小当前容量。
重点:
clear() 清的是有效内容,不等于释放底层容量。
4. reserve
s.reserve(100);
作用:
提前预留空间,减少后续追加时的扩容次数。
特点:
- 改变容量。
- 不改变有效字符个数。
- 当参数小于或等于当前容量时,通常不会缩小容量。
适合场景:
string ret;
ret.reserve(预计字符数);
5. resize
缩短字符串:
string s = "abcdef";
s.resize(3); // "abc"
扩展字符串:
s.resize(6, 'x'); // "abcxxx"
若只写:
s.resize(6);
新增位置使用值初始化字符填充,即字符值 \\0。
注意:
- resize 会改变有效字符个数。
- 扩大 size 时,可能触发扩容。
- 缩小 size 时,容量通常不变。
6. reserve 和 resize 的区别
| 是否改变 size | 否 | 是 |
| 是否可能改变 capacity | 是 | 是 |
| 是否增加有效字符 | 否 | 扩大时会 |
| 主要用途 | 提前预留空间 | 改变字符串有效长度 |
口诀:
reserve 管容量,resize 管有效字符个数。 或许你也会有疑问?
1,resize() 扩大字符串时填充的不是 '\\0' 吗?为什么说它会增加有效字符? 2, reserve() 的预留空间到底是什么意思? 容量就是申请了多少空间,但是里面填了多少有效数据,就要看size了,所以,resize() 扩大字符串时填充 '\\0',就是在增加有效字符, reserve() 只是预留多少空间,空间里面的值都是未知的,还没有填充。
reserve() 的本质是提前申请内存空间,避免后续频繁扩容,提高性能。 std::string 内部允许存储 '\\0'(算有效字符),这也是它与 C 风格字符串的重要区别之一。
六、字符串的访问⭐️⭐️⭐️⭐️
1. operator[](⭐️最常用)
string s = "hello";
cout << s[0];
s[0] = 'H';
作用:
- 访问指定下标字符。
- 非 const string 可以通过它修改字符。
- const string 只能读取。
注意:
使用 operator[] 时要保证下标合法。
2,at
string s = "hello";
cout << s.at(0);
s.at(0) = 'H';
两种方法都要保证下标合法,[]报断言错误,at报抛异常! 所以,算法题一般用 [],工程开发中 at() 更安全。
3,front
返回第一个元素
4,back
返回最后一个元素
七,字符串的遍历方式⭐️⭐️
1,下标(常用⭐️)
for (int i=0;i<s.size();i++)
{
cout << s[i];
}
2. 正向迭代器
for (string::iterator it = s.begin(); it != s.end(); ++it)
{
cout << *it;
}
可以简化为:
for (auto it = s.begin(); it != s.end(); ++it)
{
cout << *it;
}
- begin():指向第一个字符。
- end():指向最后一个字符的下一个位置。
注意:
end() 不指向有效字符,不能直接解引用。
3. 反向迭代器
for (auto rit = s.rbegin(); rit != s.rend(); ++rit)
{
cout << *rit;
}
- rbegin():反向遍历的起点,即最后一个字符。
- rend():反向遍历结束位置。(可以理解为第一个字符的前一个位置)
4. 范围 for(前面讲过,这里复习一下)(常用⭐️)
for (auto ch : s)
{
cout << ch;
}
修改字符时:
for (auto& ch : s)
{
ch = 'x';
}
八、string 修改接口⭐️⭐️⭐️
常用接口:
1. 修改
| operator+=(str) | 在字符串末尾追加字符串 |
| operator+=(c) | 在字符串末尾追加一个字符 |
| append(str) | 在字符串末尾追加字符串 |
| push_back(c) | 在字符串末尾追加一个字符 |
| pop_back() | 删除最后一个字符 |
| insert(pos, str) | 在指定位置插入字符串 |
| replace(pos, len, str) | 替换指定范围内的字符 |
| erase(pos, len) | 删除指定范围内的字符 |
1. 修改(Modifiers)
| operator+= | s += str | 在字符串末尾追加字符或字符串 | ⭐ |
| append | append(str) | 在字符串末尾追加字符串 | |
| push_back | push_back(c) | 在字符串末尾追加一个字符 | ⭐ |
| pop_back | pop_back() | 删除最后一个字符 | ⭐ |
| insert | insert(pos, str) | 在指定位置插入字符串 | |
| replace | replace(pos, len, str) | 替换指定范围内的字符 | |
| erase | erase(pos, len) | 删除指定范围内的字符 | ⭐ |
| swap | swap(str) | 交换两个字符串的内容 | |
| assign | assign(str) | 用新的内容重新给字符串赋值 | |
| 示例: |
string s = "hello";
s.push_back('!'); // s = "hello!"
s.append(" world"); // s = "hello! world"
s += '?'; // s = "hello! world?"
s += " cpp"; // s = "hello! world? cpp"
s.pop_back(); // s = "hello! world? cp"
string s2 = "abcd";
s2.insert(2, "123");//在下标为2(字符c)前面插入“123”
//s2=ab123cd
string s3 = "abcdef";
s3.erase(2, 3);//从下标2开始删3个字符
//s3=abf
string s4 = "abcdef";
s4.replace(2, 3, "123");//从下标2开始替换3个字符,替换成“123”
//s4=ab123f
string s5="abcde";
s5.swap(s4);//交换两个字符串
//s5=ab123f,s4=abcde
2,字符串操作
| c_str() | ⭐ | 返回 C 风格字符串(const char*) |
| data() | 返回字符串底层字符数组 | |
| get_allocator() | 获取分配器对象 | |
| copy(char* s, len, pos) | 将字符串的一部分复制到字符数组 | |
| find(str) | ⭐ | 查找子串或字符第一次出现的位置 |
| rfind(str) | ⭐ | 从后往前查找,后面来看第一次出现的位置 |
| find_first_of(str) | 查找第一次出现任意指定字符的位置 | |
| find_last_of(str) | 从后往前查找,后面来看第一次出现任意指定字符的位置 | |
| find_first_not_of(str) | 查找第一个不属于指定字符集合的位置 | |
| find_last_not_of(str) | 从后往前查找后面来看第一个不属于指定字符集合的位置 | |
| substr(pos, len) | ⭐ | 截取子串 |
| compare(str) | 比较两个字符串 | |
| 示例: |
string s = "hello world";
cout << s.find("lo") << endl;// 3,从前向后查找,第一次出现字符串 "lo" 的下标为 3
cout << s.find('o') << endl;// 4,从前向后查找,第一次出现字符 'o' 的下标为 4
cout << s.rfind("o") << endl; // 7,从后向前查找,最后一次出现字符 'o' 的下标为 7
cout << s.rfind("lo") << endl;// 3,从后向前查找,最后一次出现字符串 "lo" 的下标为 3
cout << s.substr(6, 5) << endl;// "world",从下标 6 开始截取 5 个字符
cout << s.find_first_of("aeiou") << endl;// 1,从前向后查找,第一个属于 "aeiou" 中任意字符的是 'e',下标为 1
cout << s.find_last_of("aeiou") << endl;// 7,从后向前查找,最后一个属于 "aeiou" 中任意字符的是 'o',下标为 7
cout << s.find_first_not_of("hel") << endl;// 4,从前向后查找,第一个不属于 "hel" 中任意字符的是 'o',下标为 4
cout << s.find_last_not_of("orld") << endl;// 6,从后向前查找,最后一个不属于 "orld" 中任意字符的是 'w',下标为 6
cout << s.compare("hello world") << endl;// 0,两个字符串相等
cout << s.compare("abc") << endl;// >0,当前字符串大于 "abc"
详细讲解:
2. c_str
string s = "hello";
const char* p = s.c_str();
作用:
返回以 \\0 结尾的 C 风格字符串指针。
适合需要和 C 接口交互的场景:
printf("%s\\n", s.c_str());
注意:
- 返回指针指向 string 内部存储。
- 字符串发生修改、扩容等操作后,之前保存的指针可能失效。
- 不应通过该指针修改字符串内容。
注释1:
- c_str() 不会创建新的字符串,它只是返回 string 内部字符数组的地址。
- 因此,返回的指针实际上指向的是 string 自己管理的那块内存。
- 如果后续对 string 进行修改、插入、拼接、扩容等操作,string 可能会重新申请空间并释放旧空间,此时之前保存的指针就可能变成悬空指针。
- c_str() 返回的是 const char*,因此不能通过该指针修改字符串内容。
3. find 和 npos
size_t pos = s.find('a');
若找到,返回字符位置;若未找到,返回:
string::npos
推荐判断:
if (pos != string::npos)
{
}
npos 是一个特殊的 size_type 值(-1,但是它是size_类型,所以很大),表示“没有找到”。
4. rfind
size_t pos = s.rfind(' ');
从后向前查找,适合:
- 查找最后一个空格。
- 查找最后一个路径分隔符。
- 提取最后一段内容。
5. substr
string s = "hello world";
string sub = s.substr(6, 5); // "world"
格式:
substr(起始位置, 字符个数)
如果省略第二个参数,则截取到字符串末尾。
全局函数
| operater+ | ⭐ | 拼接两个字符串,生成新的字符串 |
| ==、!=、<、>、<=、>= | ⭐ | 比较两个字符串 |
| swap(str1, str2) | 交换两个字符串 | |
| cin >> s | ⭐ | 从输入流读取一个字符串(遇空格结束) |
| cout << s | ⭐ | 输出字符串 |
| getline(cin, s) | ⭐ | 读取一整行字符串(可包含空格) |
九、非成员函数和输入输出
1. operator<<
cout << s << endl;
输出整个字符串。
2. operator>>
cin >> s;
特点:
遇到空白字符就停止读取。
所以它不适合读取带空格的一整行。
3. getline⭐️⭐️⭐️
getline(cin, s);
作用:
读取一整行,包括中间的空格,直到遇到换行符。
4. 关系运算符
string 支持:
==
!=
<
<=
>
>=
比较规则通常是字典序比较。
5. operator+ 为什么不宜在循环里滥用?
string ret = s1 + s2;
operator+ 通常需要构造并返回新字符串,可能产生临时对象和额外拷贝。
大量循环拼接时更适合:
ret.reserve(预计长度);
ret += 部分内容;
结论:
偶尔拼接使用 + 很方便;高频、长字符串拼接优先考虑 reserve + +=。
十、string 的底层实现结构⭐️⭐️⭐️
本文展示的是特定编译器、特定版本和 32 位平台下的实现。
重点不要死背固定字节数,而要理解不同实现的优化思想。
1. 小字符串优化
VS 实现采用类似小字符串优化的设计:
- 字符串较短时,直接存放在对象内部字符数组中。
- 字符串较长时,再到堆上申请空间。
价值:
大多数短字符串不需要堆分配,可以提高效率。
这种思想通常称为小字符串优化,即 SSO。
2. 写时拷贝
本文介绍的旧版 g++ 实现使用写时拷贝:
- 多个字符串对象初始共享同一份资源。
- 使用引用计数记录共享者数量。
- 真正修改时再复制出独立资源。
注意:
这属于历史实现和优化思想,不应认为所有现代标准库 string 都固定采用写时拷贝,也不要死背“某编译器 string 永远占多少字节”。
对象大小和内部结构可能因以下因素变化:
- 编译器
- 标准库实现
- 版本
- 32 位或 64 位平台
- 编译选项
本人在模拟实现string类时犯的最频繁的错误
本人的Gitee链接:财哥
边界条件错误:如 < 与 <=、pos == size()、capacity == 0、循环是否需要处理最后一个元素等。
忘记维护 '\\0':每次修改字符串内容或长度后,都必须保证 _str[_size] == '\\0',否则 c_str() 等操作会出错。
动态内存管理错误:容易遗漏释放旧空间、误释放仍在使用的内存或忘记释放临时申请的空间,应养成“谁申请,谁释放”的习惯。
没有严格遵循 STL 接口规范:如 find、erase、insert 等接口的边界行为应与标准库保持一致,不能只保证“能运行”。
内存复制函数使用不当:区分 strcpy(字符串复制)、memcpy(普通内存复制)和 memmove(处理重叠内存)是实现字符串的重要基础。(提醒我们要常复习!!!)
size_t 无符号整数导致的溢出问题:涉及长度计算时,应尽量使用减法判断边界,避免 pos + len 等表达式发生无符号溢出。
学会复用已有成员函数:优先调用已经实现并验证正确的接口(如 reserve、push_back、append),可以显著减少重复代码和 Bug。
十一、典型字符串题型
十二、string 模拟实现的核心
面试中常让候选人模拟实现简化版 String,重点考查:
- 构造函数
- 拷贝构造
- 赋值运算符重载
- 析构函数
- 深拷贝
- 异常安全和自赋值
1. 基础构造函数
class String
{
public:
String(const char* str = "")//缺省参数
{
assert(str != nullptr);//一般不会是 nullptr,除非你写String s(nullptr);
_str = new char[strlen(str) + 1];//为什么不直接用str.size()呢?因为tmd这个str不是string类型啊!
strcpy(_str, str);
}
private:
char* _str;
};
默认参数推荐:
const char* str = ""
这样空字符串仍然会分配一个字符空间,用于存放:
'\\0'
注释1
const char* str = ""
这里的""他就是一个常量字符串,类似于数字1,2,3,它存放在代码区(数据区)
2. 为什么默认参数不能随便写成 nullptr?
如果写:
String(const char* str = nullptr)
随后调用:
strlen(str)
会对空指针操作,产生错误。
所以要么默认传 "",要么对 nullptr 做明确处理。
3. 析构函数
~String()
{
delete[] _str;
_str = nullptr;
}
因为构造时使用:
new char[...]
所以释放时必须使用:
delete[]
十二、有关浅拷贝问题
如果没有显式实现拷贝构造,编译器生成的默认拷贝构造会按成员值拷贝:
String s1("hello");
String s2(s1);
默认行为相当于:
s2._str = s1._str;
结果就是s1._str 和 s2._str 指向同一块堆空间
问题:
这就是浅拷贝。
十四、深拷贝
深拷贝要求:
每个对象拥有自己独立的资源。
如果一个类直接管理资源,一般应显式考虑:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
这就是经典的“三法则”思想。
十五、传统版 String 实现
1. 深拷贝拷贝构造
String(const String& s)
: _str(new char[strlen(s._str) + 1])//这里为什么也不用size呢?因为tmd这是你自己实现的,自己的string类里面没有size
{
strcpy(_str, s._str);
}
作用:
- 为新对象单独申请空间。
- 把源字符串内容复制过去。
- 两个对象资源相互独立。
2. 传统赋值运算符重载
String& operator=(const String& s)
{
if (this != &s)//一般情况都是要写的,注释1
{
char* newStr = new char[strlen(s._str) + 1];
strcpy(newStr, s._str);
delete[] _str;
_str = newStr;
}
return *this;
}
为什么先申请新空间,再释放旧空间?
如果先删除旧空间,再申请新空间,而申请失败,对象原有数据已经丢失。
为什么要判断自赋值?
if (this != &s)
防止:
s1 = s1;
时错误释放自己正在读取的资源。
注释1 这里的这个if (this != &s)可以不写,但是一般的赋值运算符重载都要写,防止自拷贝, 这里可以不写是因为先申请新空间,再释放旧空间,这就没有问题,但是依旧建议保留!
十六、现代版:拷贝交换写法(以后基本都是这样写)
1. 拷贝构造
String(const String& s)
: _str(nullptr)
{
String tmp(s._str);
swap(_str, tmp._str);
}
思路:
也许你也有这样的疑问: String tmp(s._str);是不是拷贝构造? 啊,不是!这里是普通的构造函数!拷贝构造是用一个对象去初始化另一个对象,这里传的是const char*,都不是string,那是不是隐式类型转换?也不是,因为,const char*完美匹配这里的参数。
2. 赋值运算符
String& operator=(String s)
{
swap(_str, s._str);//上面是值传递,不会改变传入的那个string.
return *this;
}
参数按值传递:
String s
进入函数前已经调用拷贝构造得到一个独立副本。
函数中交换:
swap(_str, s._str);
函数结束后,形参 s 析构,自动释放当前对象原来的旧资源。
这种方式通常称为:
copy-and-swap,拷贝并交换。
十七、写时拷贝(了解)
写时拷贝可以理解为:
先共享,真正修改时再复制。
核心机制是引用计数:
- 创建资源时,引用计数设为 1。
- 多一个对象共享,计数加 1。
- 对象销毁,计数减 1。
- 最后一个使用者销毁时,真正释放资源。
- 某个对象准备修改共享资源时,先复制出自己独立的一份。
它是在浅拷贝共享资源的基础上增加管理机制。
需要知道:
- 可以减少不必要的复制。
- 实现复杂。
- 多线程和并发修改会带来额外问题。
- 现代标准库 string 不应简单假定采用这种实现。
十八、易错点汇总
易错点 1:范围 for 修改元素却没加引用
错误:
for (auto ch : s)
{
ch = 'x';
}
正确:
for (auto& ch : s)
{
ch = 'x';
}
易错点 2:混淆 size 和 capacity
- size:当前有效字符数。
- capacity:当前底层最多可容纳的规模。
易错点 3:混淆 reserve 和 resize
- reserve:预留容量,不改变有效字符数。
- resize:改变有效字符数,必要时扩容。
易错点 4:以为 clear 会释放底层空间
clear() 主要清空有效字符,通常不缩小容量。
易错点 5:find 未找到时和 -1 比较(可以,但不推荐)
更清晰的写法:
if (pos == string::npos)
{
}
易错点 6:用 cin >> 读取带空格的一行
cin >> line;
遇到空格会停止。
应使用:
getline(cin, line);
易错点 7:空字符串时执行 size() – 1
size() 返回无符号类型,空字符串下:
s.size() – 1
会发生无符号下溢。
双指针前要先处理空字符串,或使用有符号下标。
易错点 8:大数相加忘记最终进位
循环条件可以写成:
while (i >= 0 || j >= 0 || carry != 0)
自然处理最终进位。
易错点 9:模拟 String 使用默认拷贝
资源管理类不能直接依赖默认浅拷贝,否则会共享资源和二次释放。
易错点 10:new[] 和 delete 不匹配
构造字符串缓冲区:
_str = new char[n];
析构必须:
delete[] _str;
易错点 11:死背 string 对象大小
string 内部结构和对象大小是实现细节,会随平台和标准库变化。
应理解:
- 动态空间管理
- 小字符串优化
- 容量与长度
- 可能的历史写时拷贝实现
不要死背固定字节数。
二十、本节总结
- string 自动管理字符数组和动态空间。
- 范围 for 如果要修改原元素必须使用引用:auto&。
- size() 表示有效字符数,capacity() 表示容量。
- clear() 清内容,通常不缩容量。
- reserve() 预留容量,不改 size。
- resize() 改有效字符数,必要时扩容。
- begin() 指向首字符,end() 指向末字符后一个位置。
- 追加常用 +=,高频追加先 reserve()。
- find() 失败返回 string::npos。
- 读取带空格整行使用 getline()。
- 双指针适合反转、回文等字符串问题。
- 大数相加从低位向高位处理,最后反转结果。
- 资源管理类默认浅拷贝会导致共享资源和二次释放。
- 深拷贝让每个对象拥有独立资源。
- 模拟 String 要重点实现构造、拷贝构造、赋值和析构。
- 现代赋值可使用 copy-and-swap。
下期预告
string相关算法题
结语
本文到此结束,欢迎各位在评论区探讨交流。欢迎订阅我的深入讲解C++系列专栏,带你从零开始深入的学习C++,理解底层原理!需要快速复盘、精简学习内容的朋友,也别忘了订阅我的快速复习专栏哦,主打专注整合学习中的精华,我们一起加油吧!


