欢迎光临
我们一直在努力

深入理解C++系列(07)——string类

⭐️博主: 此生决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 的区别

对比reserveresize
是否改变 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 内部 C 风格字符串地址;它主要用于和 C 语言接口交互;同时注意,string 修改后旧指针可能失效;并且禁止通过该指针修改字符串内容!
    • 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 指向同一块堆空间

    问题:

  • 两个对象共享同一资源。
  • 修改一个对象可能影响另一个对象。
  • 一个对象析构后,另一个对象留下悬空指针。
  • 两个对象析构时会对同一块空间执行两次 delete[]。
  • 程序可能崩溃。
  • 这就是浅拷贝。


    十四、深拷贝

    深拷贝要求:

    每个对象拥有自己独立的资源。

    如果一个类直接管理资源,一般应显式考虑:

    • 析构函数
    • 拷贝构造函数
    • 拷贝赋值运算符

    这就是经典的“三法则”思想。


    十五、传统版 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++,理解底层原理!需要快速复盘、精简学习内容的朋友,也别忘了订阅我的快速复习专栏哦,主打专注整合学习中的精华,我们一起加油吧!

    赞(0)
    未经允许不得转载:171主机测评 » 深入理解C++系列(07)——string类
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址