C++ string类模拟实现:从深拷贝到RAII的实战指南

C++ string类模拟实现:从深拷贝到RAII的实战指南 1. 项目概述为什么我们要手撕一个string类如果你正在学习C尤其是刚刚从C语言过渡过来或者正在准备面试那么“手撕string类”几乎是一个绕不开的经典练习。这个项目标题“【C】string类模拟实现适合新手的手撕string”精准地指向了C学习路径上的一个关键里程碑。它不是一个简单的语法练习而是一次对C核心思想——面向对象、资源管理、运算符重载——的综合性实战。很多新手对C标准库里的std::string既爱又怕。爱的是它用起来太方便了再也不用像C语言那样操心字符数组的长度和\0结尾怕的是它内部像个黑盒一旦涉及到深拷贝、动态内存这些底层概念就容易出问题比如浅拷贝导致的“双重释放”double free或者内存泄漏。通过自己动手模拟实现一个简化版的MyString类你才能真正理解std::string是如何优雅地管理一块动态内存的理解拷贝构造、赋值运算符这些“特殊成员函数”为什么如此重要以及“RAII”资源获取即初始化这个听起来高大上的概念在实践中到底是怎么一回事。我当年学C的时候也是通过实现自己的字符串类才彻底搞明白了什么时候该用new什么时候该delete以及为什么需要自己写拷贝构造函数。这个过程会让你对指针、引用、const关键字有全新的认识。接下来我将带你从零开始一步步构建一个功能完整、健壮的MyString类我会重点解释每一步“为什么”要这么做并分享我在实现过程中踩过的坑和总结的技巧。2. 整体设计与核心思路拆解在动手写代码之前我们必须先想清楚我们的MyString类要长什么样以及它需要遵循哪些设计原则。我们不能简单地照搬std::string的所有接口那太复杂了。我们的目标是实现一个教学版的、能体现核心机制的字符串类。2.1 类的数据成员设计一个字符串类最核心的任务就是管理一段动态分配的、以\0结尾的字符数组C风格字符串。因此我们的类至少需要两个数据成员char* _str: 一个指针指向堆上分配的字符数组的首地址。这是我们字符串数据的实际存放地。size_t _size: 一个无符号整数记录字符串的实际长度不包括结尾的\0。这能让我们以O(1)的时间复杂度获取长度而不是每次都去遍历字符串计算。size_t _capacity: 一个无符号整数记录当前分配的内存空间能容纳多少字符不包括结尾的\0。这是为了支持高效的追加操作如避免每次追加都重新分配内存。为什么需要_capacity想象一下如果你每次调用或者push_back都在堆上重新申请一块刚好够大的新内存然后把旧数据拷贝过去再释放旧内存这个过程的性能开销是巨大的。有了_capacity我们就可以实现一个简单的“容量翻倍”策略当_size即将等于_capacity时我们一次性申请一块更大的内存比如原容量的2倍从而摊平多次插入的平均时间成本。这是几乎所有动态数组如std::vector的基础策略。2.2 六大默认成员函数的考量C类有六个特殊的默认成员函数构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。对于管理资源的类我们的MyString管理着堆内存我们必须慎重对待它们。构造函数我们需要实现多种构造函数比如默认构造一个空字符串、用C风格字符串构造、用单个字符构造等。析构函数这是最重要的它必须负责释放构造函数中申请的堆内存delete[] _str否则必然导致内存泄漏。拷贝构造函数当用一个MyString对象去初始化另一个对象时如MyString s2(s1);它会被调用。这里我们必须进行“深拷贝”即为新对象申请一块独立的内存并把原对象的数据拷贝过来。如果使用编译器生成的默认拷贝构造它只会进行“浅拷贝”复制指针值导致两个对象的_str指向同一块内存析构时这块内存会被释放两次程序崩溃。拷贝赋值运算符当两个已存在的对象进行赋值时如s2 s1;它会被调用。它比拷贝构造更复杂因为目标对象s2可能已经持有资源。我们必须遵循“先释放旧资源再分配新资源最后拷贝数据”的原则并且要处理好“自赋值”s1 s1;的情况。移动构造与移动赋值这是C11引入的用于优化临时对象右值的资源转移。对于新手项目我们可以选择先不实现专注于理解拷贝语义。但我会在高级技巧部分简要介绍其思路。我们的核心战斗就是围绕如何正确实现这前四个函数展开的。2.3 接口设计模拟std::string的常用功能为了让我们的类有用我们需要实现一些常用接口容量相关size(),capacity(),empty(),reserve(),clear()。元素访问operator[]重载下标运算符分常量和非常量版本c_str()返回底层的C风格字符串指针。修改操作push_back(char c),append(const char* str),operator,insert(),erase()。字符串操作find(),substr()。我们会挑选最核心、最能体现设计思想的接口来实现而不是追求大而全。3. 核心细节解析与实操要点3.1 深拷贝与浅拷贝生死攸关的区别这是模拟实现string类最核心、也最容易出错的概念。我必须要用最直白的方式讲清楚。浅拷贝Shallow Copy只复制指针的值。结果是两个对象的指针成员指向同一块堆内存。// 假设这是编译器为我们生成的默认拷贝构造函数 MyString(const MyString s) : _str(s._str), _size(s._size), _capacity(s._capacity) {}这行代码非常危险。当s1和s2的_str都指向同一块内存时如果s1被析构它释放了那块内存。那么s2的_str就变成了一个“悬空指针”dangling pointer指向一块已经被释放的无效内存。随后当s2也被析构时它会尝试再次释放同一块内存这就是“双重释放”会导致程序崩溃。更糟糕的是在这期间如果通过s2修改了字符串内容也会影响到s1如果s1还存在的话这违背了对象的独立性。深拷贝Deep Copy为新对象申请一块全新的、大小足够的内存然后把原对象内存中的数据包括结尾的\0逐个字节地拷贝过来。// 正确的拷贝构造函数 MyString(const MyString s) { _str new char[s._capacity 1]; // 多申请1个字节放\0 strcpy(_str, s._str); // 拷贝数据包括\0 _size s._size; _capacity s._capacity; }这样s1和s2就拥有了各自独立的数据副本互不干扰析构时也各自释放自己的内存安全无误。实操心得在C中但凡你的类有指针成员指向动态分配的资源堆内存、文件句柄、网络连接等你几乎总是需要自己编写拷贝构造函数和拷贝赋值运算符来实现深拷贝或者使用C11的“ delete”语法明确禁止拷贝。绝对不能依赖编译器生成的默认版本。3.2 拷贝赋值运算符的现代写法拷贝赋值运算符operator比拷贝构造函数更难写因为它要处理一个已经存在的对象。一个健壮的实现需要解决三个问题防止自赋值a a。安全地释放旧资源。正确地拷贝新资源。传统的写法是这样的常被称为“拷贝并交换” idiom 的基础MyString operator(const MyString s) { if (this ! s) { // 1. 检查自赋值 char* tmp new char[s._capacity 1]; // 2. 先分配新资源 strcpy(tmp, s._str); delete[] _str; // 3. 再释放旧资源顺序很重要 _str tmp; _size s._size; _capacity s._capacity; } return *this; // 4. 返回自身引用以支持链式赋值 abc }这里的关键是先分配新内存成功后再释放旧内存。如果先释放旧内存然后新内存分配失败new可能抛出std::bad_alloc异常对象就会处于一个资源已释放但新资源未获取的无效状态非常危险。先分配新资源保证了“强异常安全性”即使分配失败抛出异常旧资源依然完好。3.3 关于reserve和resize的设计reserve(n)用于增加容量它保证容量至少为n。如果当前_capacity已经大于等于n则什么都不做。它不改变字符串的内容和_size。resize(n, char ch)用于改变字符串的_size。如果n _size则用字符ch填充多出的部分如果n _size则截断字符串相当于erase。它可能会触发容量的重新分配。在实现时reserve的逻辑相对独立而resize的实现内部可能会调用reserve来保证有足够的空间。清晰地区分这两个函数的目的能让代码更易读和维护。4. 实操过程与核心环节实现下面我将分步骤实现我们的MyString类并附上详细的注释。4.1 类的框架与基本成员函数首先我们定义类的基本结构和构造函数、析构函数。#include iostream #include cstring // 用于strcpy, strlen等 #include cassert // 用于断言检查 namespace my { // 放在自己的命名空间里避免污染全局 class string { private: char* _str; size_t _size; size_t _capacity; static const size_t npos -1; // 模仿std::string表示未找到的位置 public: // 默认构造函数构造空字符串 string() : _str(new char[1]), _size(0), _capacity(0) { _str[0] \0; } // 用C风格字符串构造 string(const char* str) { assert(str ! nullptr); // 防御性编程防止传入空指针 _size strlen(str); _capacity _size; _str new char[_capacity 1]; // 1 给\0 strcpy(_str, str); } // 拷贝构造函数深拷贝 string(const string s) { _size s._size; _capacity s._capacity; _str new char[_capacity 1]; strcpy(_str, s._str); } // 析构函数 ~string() { delete[] _str; _str nullptr; _size _capacity 0; } // 获取C风格字符串常量版本 const char* c_str() const { return _str; } // 获取大小 size_t size() const { return _size; } // 获取容量 size_t capacity() const { return _capacity; } // 判断是否为空 bool empty() const { return _size 0; } }; } // namespace my要点解析我们在堆上分配内存时总是分配_capacity 1个字节多出的那个字节专门用于存放字符串结束符\0。这保证了c_str()总能返回一个合法的C风格字符串。析构函数中在delete[]之后将指针置为nullptr是一个好习惯可以防止后续误用成为悬空指针。虽然这里对象即将销毁但养成这个习惯很重要。所有不修改对象状态的成员函数如size(),c_str()都声明为const这既是良好的设计也使得常量对象能调用这些函数。4.2 实现reserve和push_back接下来实现动态扩容的核心reserve和基础的添加字符操作push_back。// 在类内部继续添加成员函数 void reserve(size_t n) { if (n _capacity) { char* tmp new char[n 1]; // 申请新空间 strcpy(tmp, _str); // 拷贝旧数据 delete[] _str; // 释放旧空间 _str tmp; _capacity n; } // 如果 n _capacity什么都不做 } void push_back(char ch) { if (_size _capacity) { // 如果容量已满需要扩容。如果当前容量为0则扩容到4或1否则翻倍。 // 这是常见的策略避免频繁扩容。 size_t new_capacity (_capacity 0) ? 4 : _capacity * 2; reserve(new_capacity); } _str[_size] ch; _size; _str[_size] \0; // 别忘了添加新的结束符 }要点解析reserve的实现体现了“先申请新再释放旧”的安全原则。push_back中的扩容策略从0到4之后翻倍是std::vector等容器的典型策略。你可以调整初始值和增长因子比如1.5倍但翻倍是一个简单有效的选择能在空间和时间效率之间取得平衡。每次修改字符串内容后都必须手动维护结尾的\0这是C风格字符串的规则我们的MyString在底层依赖它。4.3 实现append和operator有了push_back实现追加字符串就简单了。append是基础operator可以复用append。// 追加一个C风格字符串 string append(const char* str) { size_t len strlen(str); if (_size len _capacity) { // 确保容量足够通常也是按需扩容比如至少扩大到 _sizelen reserve(_size len); } strcpy(_str _size, str); // 从原字符串结尾开始拷贝 _size len; // strcpy已经拷贝了str的结束符所以这里不需要再添加\0 return *this; // 返回自身引用支持链式调用 } // 追加一个MyString对象 string append(const string s) { return append(s.c_str()); // 复用上面的版本 } // 重载 运算符字符版本 string operator(char ch) { push_back(ch); return *this; } // 重载 运算符C字符串版本 string operator(const char* str) { append(str); return *this; } // 重载 运算符MyString版本 string operator(const string s) { append(s); return *this; }要点解析append和operator都返回string这是为了支持链式编程例如s1.append(s2).append(s3)或s1 s2 s3。strcpy(_str _size, str)这行代码很关键。_str _size是一个指针运算它指向原字符串的末尾即\0的位置。strcpy会从这里开始覆盖将str的内容包括其自身的\0拷贝过来从而完成拼接。4.4 实现拷贝赋值运算符现在我们来攻克最复杂的拷贝赋值运算符。我们将采用一种更现代、更安全、更简洁的写法它巧妙地利用了拷贝构造函数。// 拷贝赋值运算符的“现代写法” string operator(string s) { // 注意这里参数是传值而不是常引用 swap(s); // 交换当前对象和临时对象s的资源 return *this; } // 需要一个swap成员函数来交换两个对象的所有成员 void swap(string s) { // 使用标准库的std::swap来交换每个成员 std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); }为什么这种写法更优秀自动处理自赋值参数s是传值如果发生自赋值a a那么会调用拷贝构造函数生成一个a的副本临时对象s。然后swap(*this, s)交换了当前对象和这个副本的资源。最后临时对象s在函数结束时被析构释放掉的是当前对象原来的旧资源。完美强异常安全保证所有的资源分配new都发生在拷贝构造函数中。如果分配失败拷贝构造函数会抛出异常这个异常会直接传播到operator的调用者而当前对象*this的状态完全没有被改变。代码简洁避免了手动检查自赋值、手动分配和释放内存的繁琐逻辑。这种写法是C中“拷贝-交换”copy-and-swap惯用法的体现是编写安全赋值运算符的推荐方法。你需要做的就是实现一个不抛异常的swap函数和一个正确的拷贝构造函数。4.5 实现operator[]和insert/erase访问和修改特定位置的字符以及插入和删除操作。// 重载下标运算符非常量版本允许修改 char operator[](size_t pos) { assert(pos _size); // 检查下标越界 return _str[pos]; } // 重载下标运算符常量版本用于常量对象不允许修改 const char operator[](size_t pos) const { assert(pos _size); return _str[pos]; } // 在pos位置插入一个字符 string insert(size_t pos, char ch) { assert(pos _size); // 允许在末尾插入(pos _size) if (_size _capacity) { size_t new_capacity (_capacity 0) ? 4 : _capacity * 2; reserve(new_capacity); } // 将pos及之后的字符向后移动一位 // 注意要从后往前移动避免覆盖数据 for (size_t i _size; i pos; --i) { _str[i] _str[i - 1]; } _str[pos] ch; _size; _str[_size] \0; return *this; } // 在pos位置插入一个C风格字符串 string insert(size_t pos, const char* str) { assert(pos _size); size_t len strlen(str); if (_size len _capacity) { reserve(_size len); } // 将原字符串从pos开始的部分向后移动len位 for (size_t i _size len; i pos len - 1; --i) { // 注意循环条件 _str[i] _str[i - len]; } // 拷贝插入的字符串 for (size_t i 0; i len; i) { _str[pos i] str[i]; } _size len; // 移动操作覆盖了原结尾符新结尾符在移动时已经处理好 return *this; } // 从pos位置开始删除len个字符 string erase(size_t pos, size_t len npos) { assert(pos _size); if (len npos || pos len _size) { // 如果len是npos或者要删除到末尾直接截断 _str[pos] \0; _size pos; } else { // 否则将后面的字符向前移动覆盖 for (size_t i pos; i _size - len; i) { _str[i] _str[i len]; } _size - len; _str[_size] \0; } return *this; }要点解析我们提供了operator[]的两个版本以同时支持非常量对象可修改和常量对象只读的下标访问。这是C标准库容器的常见做法。insert和erase涉及元素的移动。移动时必须注意方向向后移动元素时要从后往前遍历防止数据被覆盖向前移动时可以从前往后。画个图能帮助你理清下标关系。erase的默认参数npos表示删除到字符串末尾这是一个常用的设计。4.6 实现find和substr最后实现两个常用的字符串查找和子串操作。// 从pos位置开始查找字符ch size_t find(char ch, size_t pos 0) const { assert(pos _size); for (size_t i pos; i _size; i) { if (_str[i] ch) { return i; } } return npos; } // 从pos位置开始查找C风格字符串str size_t find(const char* str, size_t pos 0) const { assert(pos _size str ! nullptr); // 这里我们简单实现可以使用strstr库函数但为了教学手动实现一个 // 实际中更推荐用KMP等高效算法这里用朴素匹配 size_t len strlen(str); if (len 0) return pos _size ? pos : npos; // 空串总是被“找到” if (pos len _size) return npos; for (size_t i pos; i _size - len; i) { size_t j 0; for (; j len; j) { if (_str[i j] ! str[j]) { break; } } if (j len) { return i; // 匹配成功 } } return npos; } // 返回从pos开始长度为len的子串 string substr(size_t pos 0, size_t len npos) const { assert(pos _size); size_t real_len len; if (len npos || pos len _size) { real_len _size - pos; } string sub; sub.reserve(real_len); // 预分配空间提高效率 for (size_t i 0; i real_len; i) { sub.push_back(_str[pos i]); } return sub; }要点解析find函数我们实现了最简单的朴素匹配算法。在实际的std::string中查找算法可能更优化。对于学习目的理解其工作原理即可。substr函数中我们创建了一个新的string对象sub并利用reserve预分配了足够空间然后逐个字符push_back。最后返回这个局部对象。这里涉及到一个重要的C优化返回值优化RVO。编译器通常会优化掉这里的拷贝直接在新对象sub的位置构造返回值效率很高。5. 常见问题与排查技巧实录自己实现一个类调试是必不可少的环节。下面是我在实现和教学过程中学生们最常遇到的几个问题。5.1 程序崩溃访问违规或双重释放症状程序运行中突然崩溃调试器提示“Access Violation”或“Segmentation fault”或者在析构时崩溃。可能原因及排查浅拷贝问题这是头号杀手。检查你的拷贝构造函数和赋值运算符是否实现了深拷贝。一个快速的测试方法是创建一个MyString对象s1然后用它初始化s2MyString s2(s1);接着修改s2的内容最后观察s1是否被意外修改。如果被修改就是浅拷贝。指针未初始化或野指针在构造函数中确保_str被正确初始化即使是空字符串也应指向一个包含\0的内存块。在析构函数delete[]之后将_str置为nullptr是个好习惯。下标越界在operator[]、insert、erase等函数中务必使用assert检查pos参数是否在有效范围[0, _size)内对于insert允许pos _size。自赋值处理不当在传统的operator实现中忘记检查if(this ! s)会导致在释放自身内存后又试图访问它。使用我们介绍的“现代写法”可以天然避免这个问题。5.2 字符串内容乱码或丢失症状字符串打印出来是乱码或者末尾多了奇怪的字符或者内容不全。可能原因及排查忘记维护结尾的\0这是最常见的原因。任何修改字符串长度的操作push_back,append,insert,erase,等之后都必须确保_str[_size] \0。一个检查方法是在每次修改_size的地方都跟上一句设置结束符的代码。内存分配大小错误记住分配的大小是_capacity 1。在reserve、拷贝构造等地方检查你的new char[...]表达式是否正确。strcpy使用不当strcpy会一直拷贝直到遇到源字符串的\0。确保目标缓冲区足够大并且源字符串是合法的以\0结尾。在我们的实现中源字符串来自我们自己的_str或合法的C字符串所以重点是保证目标缓冲区大小。5.3 性能问题频繁重新分配内存症状当大量使用或push_back时程序运行速度很慢。可能原因及排查扩容策略过于保守如果你的push_back在每次_size _capacity时只扩容1个字符那么连续插入n个字符的时间复杂度是O(n²)。检查并实现我们提到的翻倍或1.5倍扩容策略。未使用reserve预分配如果你事先知道最终字符串的大致长度应该在操作前调用reserve一次性分配足够内存避免中间多次扩容。例如my::string s; s.reserve(1000); for(int i0; i1000; i) s.push_back(a);5.4 关于const正确性问题定义了一个const my::string对象却无法调用size()或c_str()。解决确保所有不修改对象状态的成员函数都声明为const例如size_t size() const;。这样常量对象和非常量对象都能调用它们。5.5 高级话题移动语义C11虽然我们的主要目标是理解拷贝语义但了解移动语义能让你的类在现代C中效率更高。移动语义的核心是“资源转移”而非“资源拷贝”。移动构造函数// 移动构造函数 string(string s) noexcept // 表示右值引用noexcept 声明不抛异常 : _str(s._str), _size(s._size), _capacity(s._capacity) { s._str nullptr; // 关键将源对象置于有效但空的状态 s._size s._capacity 0; }移动赋值运算符// 移动赋值运算符 string operator(string s) noexcept { if (this ! s) { delete[] _str; // 释放自己的旧资源 _str s._str; _size s._size; _capacity s._capacity; s._str nullptr; s._size s._capacity 0; } return *this; }当发生my::string s2 std::move(s1);s1是即将消亡的临时对象时移动构造函数会被调用它直接“窃取”了s1内部的指针资源然后将s1的指针置空。这个过程没有深拷贝效率极高。实现了移动语义后我们的“现代写法”operator会变得更加高效因为传参时如果实参是右值会优先匹配移动构造函数而不是拷贝构造函数。手撕一个完整的string类是一项艰巨但收获巨大的工程。它强迫你去思考C中关于对象生命周期、资源管理、接口设计的最基本问题。当你调试通最后一个bug看着自己的MyString像std::string一样工作时那种成就感是无与伦比的。我建议你在实现过程中多写测试用例覆盖边界情况空串、自赋值、大量数据等并使用调试器一步步跟踪观察内存和变量的变化这比读十遍理论都管用。最后不妨对比一下你的实现和标准库的实现如果用的是GCC或Clang可以查看其源码看看工业级的代码在异常安全、算法优化、内存分配器等方面做了哪些更精细的处理这会是学习的下一个台阶。