当前位置: 首页 > news >正文

【C++篇】string类的终章:深浅拷贝 + 模拟实现string类的深度解析(附源码)

💬 欢迎讨论:在阅读过程中有任何疑问,欢迎在评论区留言,我们一起交流学习!
👍 点赞、收藏与分享:如果你觉得这篇文章对你有帮助,记得点赞、收藏,并分享给更多对C++感兴趣的朋友


文章目录

  • 前言
  • 深浅拷贝
    • 一、浅拷贝
      • 浅拷贝的问题
    • 二、深拷贝
      • 深拷贝的实现
        • **拷贝构造函数**
        • **赋值运算符**
    • 三、 总结
    • 二、深拷贝
  • 模拟实现string类
    • 一、核心结构设计
      • 1. 成员变量
    • 二、关键功能实现
      • 1. 构造函数与拷贝控制
        • 1.1构造函数
        • 1.2拷贝构造函数与赋值运算符
        • 1.3析构函数
      • 2. 迭代器
      • 3. capacity相关接口
        • 3.1`reserve`扩容
        • 3.2`resize`调整大小
        • 3.3`size`获取大小
        • 3.4`capacity`获取容量
        • 3.5`empty`判空
      • 4. modify相关接口
        • 4.1`push_back`尾插字符
        • 4.2`append`在字符串结尾拼接字符串
        • 4.3`operator+=`
        • 4.4`clear` 清空有效字符
        • 4.5`c_str`获取C属性的字符串(字符串指针)
      • 5. assess接口
      • 6. 字符串操作
        • 6.1`find`查找字符或子串
        • 6.2`insert`插入
          • 6.2.1 插入字符
          • 6.2.2 插入字符串
        • 6.3`erase`删除
        • 6.4`substr`截取字符串
      • 7. 关系运算符重载
      • 8. 流插入与流提取运算符重载


前言

本文将通过一个自定义的字符串类实现(zhh::string zhh是一个我自定义的作用域),深入探讨string类的核心设计思路与实现细节,以及为什么在拷贝构造和赋值运算符重载的实现需要用深拷贝。该代码模拟了标准库std::string的一些核心功能,包括动态内存管理、迭代器、常用操作符重载等。同时也借此对深浅拷贝进行实际上的应用。

源码在文章末尾


深浅拷贝

一、浅拷贝

浅拷贝:也称位拷贝,仅仅这是将值拷贝过来。

我们平时C语言中使用的赋值,以及函数传值,都是浅拷贝。

如果在对象中使用,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生发生了访问错误。

浅拷贝的问题

浅拷贝 仅复制指针的值(地址),导致多个对象共享同一块内存:

class String {
public:char* data;// 假设有浅拷贝构造函数String(const String& other) : data(other.data) {}
};String s1("Hello");
String s2 = s1; // 浅拷贝:s2.data 和 s1.data 指向同一内存

问题

  • 修改冲突:修改 s1 会影响 s2
  • 重复释放:析构时 s1s2 会尝试释放同一块内存,导致崩溃。
  • 悬空指针:一个对象被析构后,另一个对象的指针失效。

那么,如何使得每个对象都有一份独立的资源,不要和其他对象共享


二、深拷贝

深拷贝 是一种内存管理技术,其核心是完整复制对象及其动态分配的资源,生成一个与原对象完全独立的新对象。在C++中,当类包含指针成员并指向堆内存时,必须通过深拷贝来避免以下问题:

深拷贝的实现

深拷贝通过分配新内存并复制内容,确保对象间的独立性。

在实现string类中的拷贝构造函数以及赋值运算符重载都需要用到深拷贝:

拷贝构造函数
string(const string& s) {_str = new char[s._capacity + 1]; // 分配新内存memcpy(_str, s._str, s._size + 1); // 复制内容(包含'\0')_size = s._size;_capacity = s._capacity;
}
  • 关键点:为新对象分配独立的内存,并复制原对象的所有数据。
赋值运算符

传统写法

string& operator=(const string& s)
{if (this != &s){char* tmp = new char[s._capacity + 1];memcpy(tmp, s._str, s._size+1);delete[] _str;_str = tmp;_size = s._size;_capacity = s._capacity;}return *this;
}
  • 深拷贝的经典写法

现代写法

//string类的swap接口:将每个成员变量交换即可
void swap(string& s)
{std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);
}string& operator=(const string& s)
{if (this != &s){string tmp(s);swap(tmp);}return *this;
}
  • 利用拷贝构造,用s构造tmp——代替new开辟空间与memcpy拷贝步骤
  • 再将*thistmp交换数据,利用局部对象tmp出函数作用域调用析构释放原*this的数据——代替delete释放原空间数据步骤

省略了大量代码,妙不可言

当今写法

string& operator=(string tmp) {swap(tmp); // 交换资源,tmp 析构时释放原内存return *this;
}

点睛之笔利用函数传值自动调用拷贝构造的原理——替代了手动调用拷贝构造

真是进了米奇妙妙屋了,妙的不能再妙了😏

  • 优势:利用临时对象 tmp 的深拷贝,通过 swap 安全交换资源,天然避免自赋值问题。

三、 总结

  • 深拷贝是管理动态资源的类的必备实现,确保对象间的独立性。
  • 浅拷贝仅适用于不涉及资源所有权的简单数据类型(如 int, double)。
  • 在C++中,默认拷贝构造函数和赋值运算符是浅拷贝。若类需要深拷贝,必须手动实现拷贝控制函数(拷贝构造、赋值运算符、析构函数)。

通过正确实现深拷贝,可以避免内存泄漏、悬空指针和不可预测的行为,从而编写出健壮的C++程序。

二、深拷贝

如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情况都是按照深拷贝方式提供。


模拟实现string类

一、核心结构设计

1. 成员变量

class string {
private:char* _str;       // 字符串内容size_t _capacity; // 总容量(不包含'\0')size_t _size;     // 当前长度const static size_t npos; // 特殊值,表示无效位置
};
  • _str:动态分配的字符数组,存储字符串内容(以'\0'结尾)。
  • _capacity:当前分配的内存容量(不包含'\0'的额外空间)。
  • _size:字符串实际长度(包含'\0'的额外空间)。
  • npos:静态常量,表示无效位置(定义为-1的无符号最大值)。

二、关键功能实现

1. 构造函数与拷贝控制

1.1构造函数
string(const char* str = "") {_size = strlen(str);_capacity = _size;_str = new char[_capacity + 1];memcpy(_str, str, _size);_str[_size] = '\0';
}
  • 支持从C风格字符串构造,默认构造空字符串。
  • 使用memcpy高效拷贝数据,但需确保输入字符串以'\0'结尾。
1.2拷贝构造函数与赋值运算符

前文在讲解深拷贝时已详细介绍了,这里不再赘述了。

string(const string& s) {_str = new char[s._capacity + 1]; // 需分配足够空间(原实现有误)memcpy(_str, s._str, s._size + 1); // 包含'\0'_size = s._size;_capacity = s._capacity;
}string& operator=(string tmp) {swap(tmp); // 通过交换资源实现赋值return *this;
}
1.3析构函数
~string() {delete[] _str;_str = nullptr;_size = _capacity = 0;
}
  • 释放动态内存,重置成员变量。

2. 迭代器

typedef char* iterator;
typedef const char* const_iterator;iterator begin() 
{ return _str; 
}iterator end() 
{ return _str + _size;
}const_iterator begin()const
{return _str;
}const_iterator end()const
{return _str + _size;
}
  • 提供类似标准库的迭代器接口,支持范围for循环。

测试代码:

void TestString1()
{zhh::string s1("hello world");zhh::string::iterator it = s1.begin();while (it != s1.end()){cout << *it << " ";it++;}cout << endl;{for (auto ch : s1)cout << ch << "6";}cout << endl;
}

测试结果:
在这里插入图片描述


3. capacity相关接口

3.1reserve扩容
void reserve(size_t n) {if (n > _capacity) {char* tmp = new char[n + 1];memcpy(tmp, _str, _size + 1); // 包含'\0'delete[] _str;_str = tmp;_capacity = n;}
}
  • 当给定的目标扩容值小于已有容量,容量保持不变。
  • 使用memcpy提升性能,但需确保拷贝长度包含'\0'
3.2resize调整大小
void resize(size_t n, char c = '\0') {if (n > _size) {reserve(n);for (size_t i = _size; i < n; ++i)_str[i] = c;}_str[n] = '\0'; // 确保终止符_size = n;
}
  • n > _size,填充字符c;否则截断字符串。
3.3size获取大小
size_t size()const
{return _size;
}
3.4capacity获取容量
 size_t capacity()const{return _capacity;}
3.5empty判空
bool empty()const
{return _size == 0 ? true : false;
}

4. modify相关接口

4.1push_back尾插字符

尾插字符很简单,在字符串尾部赋值即可,需要考虑的问题有:

  • 容量是否足够?是否需要扩容?
  • 如何扩?扩多大?
  • 字符串结尾有没有附上\0结束标志?

解决问题:

  • 检查容量方法:判断_capacity是否大于_size +1
  • 扩容方式:调用reserve扩容。扩多大取决于自己,我这里是扩两倍
  • 记得改变_size的值,最后在_size的位置赋上\0

代码:

void push_back(char c)
{//检查容量,不足就扩if (_capacity < _size + 1){reserve(_capacity == 0 ? 4 : _capacity * 2);}_str[_size] = c;++_size;_str[_size] = '\0';
}
4.2append在字符串结尾拼接字符串

思路与尾插字符差不多:检查容量->不足就扩->拼接->改变size
需要注意的是:

  • 函数参数是指针,assert断言防止空指针传入
  • 拼接方法:使用memcpy从字符串尾部开始拷贝
  • size加上传入字符串长度即可
  • size位置无需赋值\0,因为memcpy时可以顺带完成

代码:

 void append(const char* str){assert(str);size_t len = strlen(str);reserve(_size + len);memcpy(_str + _size, str, len);_size += len;}
4.3operator+=

这里复用push_back和append即可:

string& operator+=(char c)
{push_back(c);return *this;
}string& operator+=(const char* str)
{append(str);return *this;
}

💡:我们平时需要尾插字符或字符串时一般都用这个接口,因为他完全可以代替push_back和append。但我们需要知道他的底层其实是由push_back和append实现的。

4.4clear 清空有效字符

_str[0]的位置赋值\0即可

 void clear(){_str[0] = '\0';_size = 0;}
4.5c_str获取C属性的字符串(字符串指针)

返回_str成员变量即可

const char* c_str()const
{return _str;
}

5. assess接口

这个其实我们前面已经用过了,是不是有种自来熟的感觉😂
返回字符串对应位置的字符即可

char& operator[](size_t index)
{assert(index < _size && index >= 0);return _str[index];
}const char& operator[](size_t index)const
{assert(index < _size && index >= 0);return _str[index];
}

6. 字符串操作

6.1find查找字符或子串
  • 查找字符:遍历查询即可
  • 查找子串:使用strstr库函数实现高效查找。

代码:

// 返回c在string中第一次出现的位置
size_t find(char c, size_t pos = 0) const
{assert(pos >= 0);for (size_t i = pos; i < _size; ++i){if (_str[i] == c){return i;}}return npos;
}// 返回子串s在string中第一次出现的位置
size_t find(const char* s, size_t pos = 0) const
{char* ptr = strstr(_str + pos, s);if (ptr){return ptr - _str;}else{return npos;}}
6.2insert插入
6.2.1 插入字符

大体与push_back类似,但多了一个步骤——挪动数据
从尾部开始,将字符赋值到后面的一个位置,到pos位置结束(pos位置也要挪动)。

代码(有bug):

 // 在pos位置上插入字符c/字符串str,并返回该字符的位置string& insert(size_t pos, char c){assert(pos < _size && pos >= 0);//检查容量,不足就扩if (_capacity < _size + 1){reserve(_capacity == 0 ? 4 : _capacity * 2);}size_t end = _size;while (pos <= end){_str[end + 1] = _str[end];--end;}_str[pos] = c;++_size;return *this;}

聪明的你可以看出问题出在哪吗?
可以试试用pos = 0运行一下代码,会发现超时了,那应该就是循环的问题,我们锁定while的位置
走读代码,当end = 0,进入循环,–end得end = -1,end的类型是size_t,而-1是最大的无符号整数,所以我们循环进入了死循环。

处理方法:添加循环条件end != npos

正确代码:

 // 在pos位置上插入字符c/字符串str,并返回该字符的位置string& insert(size_t pos, char c){assert(pos < _size && pos >= 0);//检查容量,不足就扩if (_capacity < _size + 1){reserve(_capacity == 0 ? 4 : _capacity * 2);}size_t end = _size;//当pos = 0 ,end会变为-1,成为最大无符号整数进入死循环//因此这里需补充条件end != nposwhile (pos <= end && end != npos){_str[end + 1] = _str[end];--end;}_str[pos] = c;++_size;return *this;}
6.2.2 插入字符串

插入字符串的挪动方式差不多:
方式是相同的,但挪动距离为len(插入的字符串长度)

代码:

string& insert(size_t pos, const char* str){assert(pos < _size && pos >= 0);size_t len = strlen(str);if (_size + len > _capacity){reserve(_size + len);}//挪动数据size_t end = _size;//当pos = 0 ,end会变为-1,成为最大无符号整数进入死循环//因此这里需补充条件end != nposwhile (end >= pos && end != npos){_str[end + len] = _str[end];--end;}for (size_t i = pos; i < pos + len; ++i){_str[i] = *str;++str;}_size += len;return *this;}
6.3erase删除
  1. 如果需要删除的长度大于_size - pos,直接在pos位置赋值\0即可
  2. 否则,将需要删除的部分挪到\0后面即可
// 删除pos位置上的元素,并返回该元素的下一个位置
string& erase(size_t pos, size_t len)
{if (len >= _size - pos){_str[pos] = '\0';}else{size_t end = pos + len;for (size_t i = end; i <= _size; ++i){_str[end - len] = _str[end];end++;}}return *this;
}
6.4substr截取字符串

核心思想:创建一个临时对象,将需的字符依次尾插到这个对象,最后返回这个对象的值即可
注意:这里只能传值返回

string substr(size_t pos = 0, size_t len = npos)
{string tmp;size_t n = len;if (n > _size - pos || n == npos){n = _size - pos;}tmp.reserve(n);for (size_t i = pos; i < pos + n; ++i){tmp += _str[i];}//返回值传值,调用拷贝构造return tmp;//返回值不可用引用,否则出作用域调用析构,非法访问
}

7. 关系运算符重载

一共有6个关系运算符:< > == <= >= !=
其实我们只需要实现两个即可(<>==

核心问题:如何比较两个字符串大小?

在编程中,两个字符串的大小比较通常是基于字典序进行的,类似于字典中单词的排列顺序。具体规则如下:

比较规则

  1. 逐字符比较

    • 从左到右依次比较两个字符串的每个字符的ASCII码值
    • 如果某位置的字符不同,直接根据这两个字符的ASCII码值大小决定字符串大小。
      示例
      "apple""apricot"
      第3个字符 'p'(ASCII 112) < 'r'(ASCII 114),所以 "apple" < "apricot"
  2. 长度比较

    • 如果所有对应字符均相同,则较长的字符串更大。
      示例
      "hello""hell":前4字符相同,但前者更长,所以 "hello" > "hell"
  3. 完全相等

    • 如果所有字符相同且长度相同,则两字符串相等。

实现思路:

  • 先比较短字符串长度较短的长度,如果谁大,那么谁就大;如果相等,那么字符串长度较长的字符串更大,如果字符串长度也相等,那么相等。
 bool operator<(const string& s){int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);return ret == 0 ? _size < s._size : ret < 0;}bool operator==(const string& s){return _size == s._size && memcmp(_str, s._str, _size) == 0;}bool operator<=(const string& s){return *this < s || *this == s;}bool operator>(const string& s){return !(*this <= s);}bool operator>=(const string& s){return !(*this < s);}bool operator!=(const string& s){return !(*this == s);}

8. 流插入与流提取运算符重载

我们上一期知道:这两个运算符重载不能作为成员函数(为了使得对象参数作为第二个参数),需要在类外实现,必要的话可以将它们声明为类的友元。

流插入比较简单,遍历流插入字符即可:

ostream& operator<<(ostream& out, const zhh::string& s)
{for (auto ch : s){out << ch;}return out;
}

而流提取就比较麻烦了。

  1. 每次输入都需要清空字符串(使用clear)。
  2. 清理缓存区的空格与换行(清除输入的字符串头部的空格与换行)。
  3. 实现时不能用流提取,否则会造成流堵塞(输入空格与换行无法结束输入),使用get()可以解决这个问题
  4. 如果我们一次输入较多数据会造成频繁扩容,效率低下
    我们可以用数组来解决这个问题:
    定义一个buff[128],我们将输入的数据存入数组,
    情况1:输入结束且数组未满,直接将数组中的字符尾插到目标字符串。
    情况2:输入未结束且数组已满,将数组中的字符尾插到目标字符串,继续从数组的起始位置重新存输入的数据,循环往复。
    注意:每次要尾插数据前,要在数组的有效数据后赋值\0,否则会造成数据错乱。
 istream& operator>>(istream& in, zhh::string& s){//清理原数据s.clear();char ch = '\0';//清理缓存区的空格与换行while (ch == ' ' || ch == '\n'){ch = in.get();}char buff[128];int i = 0;while (ch != ' ' && ch != '\n'){ch = in.get();buff[i++] = ch;if (i == 127)//留一个位置放\0{buff[i] = '\0';s += buff;i = 0;}}if (i != 0){buff[i] = '\0';s += buff;}return in;}
}

源码自取:模拟实现string类

万字文章,制作不易,留给赞再走吧~
在这里插入图片描述

相关文章:

  • uCOS3实时操作系统(系统初始化和任务启动)
  • 《Learning Langchain》阅读笔记5-RAG(1)
  • 7. 服务通信 ---- 使用自定义srv,服务方和客户方cpp,python文件编写
  • MATLAB 训练CNN模型 yolo v4
  • 强化学习框架verl源码学习-快速上手之如何跑通PPO算法
  • Linux学习笔记协议篇(六):SPI FLASH设备驱动
  • 嵌入式人工智能应用-第三章 opencv操作8 图像特征之HOG 特征
  • 网络原理 - 3(UDP 协议)
  • 读文献先读图:韦恩图怎么看?
  • 设备、管道绝热(保冷)设计计算
  • Flutter路由模块化管理方案
  • 文件包含漏洞,目录遍历漏洞,CSRF,SSRF
  • 深度解析云计算:概念、优势与分类全览
  • 爬虫获取sku信息需要哪些库
  • 用银河麒麟 LiveCD 快速查看原系统 IP 和打印机配置
  • 网页下载的m3u8格式文件使用FFmpeg转为MP4
  • three.js中的instancedMesh类优化渲染多个同网格材质的模型
  • 你的大模型服务如何压测:首 Token 延迟、并发与 QPS
  • JavaScript — 总结
  • 基于XC7V690T的在轨抗单粒子翻转系统设计
  • 新任遂宁市委副书记王忠诚已任市政府党组书记
  • 国家卫健委:无资质机构严禁开展产前筛查
  • 浙江桐乡征集涉企行政执法问题线索,含乱收费、乱罚款、乱检查等
  • 外媒:罗马教皇方济各去世
  • 商务部新闻发言人就美国以关税手段胁迫其他国家限制对华经贸合作事答记者问
  • 中共中央办公厅、国务院办公厅印发《农村基层干部廉洁履行职责规定》