【C++】手撕 string 类:我踩过的坑和学到的东西(避坑指南)
目录一、为什么要自己实现 string二、基本结构三、构造函数与析构函数四、拷贝构造我踩的第一个坑五、赋值运算符自赋值判断的细节六、swap 为什么是 O(1)七、扩容与常用操作八、insert 里的无符号整数陷阱九、operator 里的数据重复写入十、深拷贝 vs 浅拷贝十一、总结一、为什么要自己实现 string用了一段时间std::string之后我发现自己其实不太清楚它在内部做了什么。拷贝构造、赋值运算符、深浅拷贝……这些概念书上都有但只有自己写一遍才能真正理解。所以我决定从头实现一个string类逼自己把这些东西搞清楚。二、基本结构我选择用三个成员变量来描述字符串的状态char* _str; // 指向堆上的字符数组 size_t _size; // 当前字符串长度 size_t _capacity; // 当前分配的容量不含 \0这和std::string的基本思路是一致的。额外还定义了一个静态常量static const size_t npos -1;npos本质是size_t类型的最大值用于表示未找到或到末尾的语义和标准库保持一致。三、构造函数与析构函数构造函数的核心是根据传入的字符串分配内存并拷贝数据string(const char* str ) { assert(str); _size strlen(str); _capacity _size; _str new char[_capacity 1]; strcpy(_str, str); }这里有一个容易忽略的细节new char[_capacity 1]多分配的那一个字节是给\0用的。字符串的长度和容量都不算这个终止符但底层数组必须有它否则c_str()等函数的行为都会出问题。析构函数负责释放资源~string() { delete[] _str; _str nullptr; _size _capacity 0; }delete[]对应new[]这是固定搭配。之所以还要把指针置为nullptr是为了防止析构后仍然能通过旧指针访问这在调试多次析构的场景下很有用。四、拷贝构造我踩的第一个坑拷贝构造的目的是用一个已有对象构造出一个新对象两者资源独立。我一开始写出了这个版本// 错误写法 string(const string s) { string tmp(s._str); swap(s); // 问题在这里 }表面上看逻辑清晰先用s._str构造一个临时对象tmp然后 swap。但仔细一想问题有两个。第一swap的对象是s不是tmp。tmp创建出来之后完全没有被使用这段逻辑根本不是我以为的那样。第二s是const string不能被修改所以这里的swap(s)本身就是错误的设计——swap 必须修改双方。正确的拷贝构造应该是直接分配内存、拷贝数据string(const string s) { _str new char[s._capacity 1]; strcpy(_str, s._str); _size s._size; _capacity s._capacity; }这才是真正的深拷贝每个对象管理自己独立的一份堆内存互不干扰。不过在我最终的头文件里我用了另一种写法string(const string s) { string tmp(s._str); // 用 char* 构造函数创建临时对象 swap(tmp); // 把 this 和 tmp 互换 } // tmp 析构自动释放 this 原来的资源这个版本的关键是swap 的对象是tmp一个局部变量不是s。互换之后this持有新分配的资源tmp持有this原来的那块这里是未初始化状态析构时也能安全处理。这是一种常见的copy-and-swap惯用法。五、赋值运算符自赋值判断的细节赋值运算符和拷贝构造的区别在于被赋值对象已经存在它已经持有一份资源需要先处理掉。我一开始这样判断自赋值// 错误写法 if (_str ! s._str)这个判断有个隐患两个不同的对象在某些情况下可能指向同一块内存比如浅拷贝场景下。正确的做法是比较对象地址string operator(const string s) { if (this ! s) { string tmp(s._str); swap(tmp); } return *this; }逻辑是先用s._str构造一个临时对象tmp然后swap(tmp)this拿到新资源tmp拿走旧资源并在函数结束时析构。整个过程不需要手动delete资源释放由tmp的析构函数完成。六、swap 为什么是 O(1)void swap(string s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); }swap 快的原因很简单它交换的是指针和两个整数不涉及任何数据的拷贝。不管字符串有多长swap 的时间复杂度都是 O(1)。这也是为什么copy-and-swap惯用法在实现赋值运算符时性能不差——真正的代价在构造tmp时而不在 swap 本身。七、扩容与常用操作void string::reserve(size_t n) { if (n _capacity) { char* temp new char[n 1]; strcpy(temp, _str); delete[] _str; _str temp; _capacity n; } }PushBack 和 append 在写入前都要检查容量void string::PushBack(char ch) { if (_size _capacity) reserve(_capacity 0 ? 4 : 2 * _capacity); _str[_size] ch; _size; _str[_size] \0; }扩容策略是翻倍避免频繁 realloc。初始容量为 0 时直接给 4这是个经验值。八、insert 里的无符号整数陷阱insert 的核心逻辑是把 pos 之后的数据整体后移void string::insert(size_t pos, char ch) { assert(pos _size); if (_size _capacity) reserve(_capacity 0 ? 4 : 2 * _capacity); size_t end _size 1; while (end pos) { _str[end] _str[end - 1]; --end; } _str[pos] ch; _size 1; }我一开始把循环条件写成了end pos结果死循环。原因是end和pos都是size_t无符号类型当end减到 0 之后继续--end它不会变成 -1而是会绕回到size_t的最大值条件永远成立。改成end pos之后循环在end pos时就停了问题解决。这是 C 里用无符号类型做循环变量时的经典坑。九、operator 里的数据重复写入在实现operator时我最初的版本有个隐蔽的 bug// 错误写法节选 buff[i] ch; s buff; // 把 buff 里的内容追加到 s s ch; // 又把 ch 单独追加一次ch同时出现在buff和单独的 ch里每个字符都被写入两次。修复方式是只走一条路径字符统一先放入buff满了就 flush 到s读完之后把剩余部分再 flush 一次istream operator(istream in, string s) { s.clear(); const int N 256; char buff[N]; int i 0; char ch in.get(); while (ch ! ch ! \n ch ! EOF) { buff[i] ch; if (i N - 1) { buff[i] \0; s buff; i 0; } ch in.get(); } if (i 0) { buff[i] \0; s buff; } return in; }用缓冲区的好处是减少调用次数避免每次追加单个字符都可能触发扩容。十、深拷贝 vs 浅拷贝如果不自己实现拷贝构造和赋值运算符编译器默认生成的版本做的是浅拷贝直接复制_str指针的值两个对象指向同一块内存。这在析构时会导致 double free是典型的未定义行为。深拷贝则是每个对象独立分配自己的内存互不干扰。代价是每次拷贝都要new一次但安全性有保证。还有一种折中方案叫引用计数多个对象共享同一块内存用一个计数器追踪引用数量只有最后一个对象析构时才真正释放。早期的std::string实现有用过这个方案但多线程下引用计数的同步开销抵消了共享内存的收益现代实现基本不再使用。十一、总结实现这个 string 类之前我以为自己理解了深拷贝、赋值运算符这些概念实现完之后我才意识到知道和能写对之间的差距有多大。最有收获的几个点size_t做循环变量的无符号陷阱、copy-and-swap 惯用法的真实意图、以及swap为什么能把赋值运算符的资源管理变得这么干净。如果你也在学 C 的资源管理部分建议动手写一遍。光看书是不够的bug 才是最好的老师。