☰
手写C++ string类:从深拷贝到内存管理的完整实战解析
2026/10/3 17:20:11 网站建设 项目流程

大概每个认真学过《21天学通C++》的人,都会在某一章碰到那个经典的练习题:自己动手写一个string类。我在一个技术社群里看到有人晒出一个叫hstring的手写string类测试题,瞬间勾起了我当年被内存管理支配的日子。花了大半个晚上把思路重新捋了一遍,又把实现细节全写了一遍,干脆整理成这篇东西,给正在啃这本书、想把基础打扎实的朋友参考。

这个hstring看起来只是个阶段性成果验收,但背后考的其实是C++对象生命周期、深浅拷贝、运算符重载和内存管理这一连串核心硬功夫。说白了,能不能把string类写好,基本就是你C++基础牢不牢的试金石。这篇内容适合刚学完类和对象、准备做阶段性练习的初学者,也适合那些写了两年业务代码却从没自己实现过容器类、想回头补补底子的同学。我不打算只丢一份能跑通的代码,而是把设计思路、为什么这么选型、踩过的坑全说出来,让你写完一个class之后,是真的涨了功力而不是背了个答案。

1. 为什么21天教科书会安排这道题

《21天学通C++》系列的书国内读者很多,书名里的"21天"被吐槽过无数次,但不可否认的是它作为入门教材有一套比较完整的学习路径。等到类和对象讲完、运算符重载穿插进去,作者就会安排类似"实现一个简易字符串类"这样的综合练习。你可能会问:标准库明明有std::string,手写一个出来不是重复造轮子吗?

1.1 这道题真正考的是对象生命周期

C++里最容易出问题的不是函数怎么写,而是对象是怎么被创建、拷贝、赋值、销毁的。你写std::string a = b;的时候编译器帮你把一切做完了,代码看起来干干净净,但对内存的ownership、数据的复制过程完全没有感知。一道手写字符串类的题,逼着你直面最底层的三件事:

  • 构造器怎么申请内存
  • 析构器怎么归还内存
  • 拷贝发生时怎么保证两个对象互不干扰

这三种操作对应着C++对象模型里最核心的"三/五法则",学不学得懂,在小项目里一般体现不出来,但一旦牵涉到自定义资源管理类,翻不翻车全看这里趴没趴明白。hstring这个题的含金量,就是把这种"每个C++程序员都得过一遍"的核心内功,以一种可以检验的形式端到你面前。

1.2 两个关键维度:内存管理与字节语义

写string类的时候会遇到一个经典的两难:拷贝到底是复制指针还是复制数据。选复制指针,性能很好,但两个对象指向同一块内存,任谁析构都会让对方变成悬垂指针,程序直接崩给你看。选复制数据,安全但浪费内存和时间。

这就是浅拷贝和深拷贝的分岔路。教科书在这个阶段普遍要求你实现深拷贝,因为安全性优先;引用计数或写时复制那套优化,属于进阶玩法,通常放到后续章节。我在设计hstring时遵循的也是这个逻辑:先把字节语义做对——让每个字符串对象独立拥有自己的缓冲区,再去想什么共享内存的骚操作。

多提一句,写这道题遇到的另一个让你生理性头疼的点是\0结尾符。C风格字符串靠\0标记结束,但std::string本身是允许中间包含\0的。作为21天阶段的练习,hstring只需要遵循C风格约定就够了,但你要知道这个边界:一旦有朝一日你去做通讯协议解析,字符串里藏\0是家常便饭,那时的设计思路就得换一套了。

2. 动手前的设计思路与方案选型

动手敲代码之前,先把hstring的对外接口画出来。教科书里的练习题往往只要求"基本功能",但既然要写,我建议你多考虑一步:将来这段代码怎么被复用,怎么读起来舒服,怎么避免踩到那些坑到怀疑人生的隐蔽问题。

2.1 接口层:只暴露必要的部分

在很多习题代码里,大家习惯把所有成员都塞进public区,图省事。但一个合格的hstring类必须知道什么该外露、什么该藏好:

  • 构造与析构:默认构造、字符串构造、拷贝构造、析构
  • 赋值操作:拷贝赋值——这是大多数人第一次接触operator=重载的开始
  • 容量相关:返回字符串长度、判断是否为空
  • 内容访问:转C风格字符串、按索引取值、修改单个字符
  • 运算符重载:+=拼接、+拼接、==比较、<<输出
  • 内部实现:字符指针、字符串长度、缓冲区分配与回收

把data指针设成private是必须的,这个指针就是内存的命根子,一旦裸露在外面,什么野指针、悬垂指针的问题都来了。用户看到这个类,只需要碰几个干净的方法,内部的乱七八糟跟外部环境完全绝缘,这才是"封装"的落点。

2.2 内存策略:深拷贝先行,但留好扩展空间

我见过不少人在完成作业时顺手做了个"懒拷贝"——直接复制指针地址,注释写着"提高效率"。放到考试题里,这种取巧往往会被一眼看穿,因为它根本绕开了考察重点。

hstring我明确采用深拷贝策略,理由很朴素:在多数场景下,字符串对象不应该意外共享同一块缓冲区。独立数据内存意味着任何对象的修改不会被其他对象无感看到,这个语义和基础类型的直觉是一致的。

但实现时我会把"分配新内存"的逻辑抽成私有方法,比如reallocate或reserve,这样以后想升级成写时复制或者引用计数,只需要改内部实现,不用动对外接口。数据结构课本教我们的道理在这里又一次应验:接口和实现要分离,才配叫工程实践。

另外,存字符串长度时我建议显式维护一个len成员,而不是每次都调用strlen扫描一遍。每次get到长度都扫描一次,时间复杂度从O(1)变成O(n),看起来问题不大,但如果在一个循环里反复获取长度,性能就成了灾难。这个小决定看着平平无奇,恰恰是"有没有工程意识"的分水岭。

2.3 教科书之外的取舍:为什么不做写时复制

很多读者搜资料时会看到"COW(Copy-On-Write,写时复制)"这种炫酷的设计。老实讲,C++11之后这种手段基本被业界放弃。原因有这么几条:

  • 写时复制实现线程安全极其复杂,不加锁几乎不可能
  • char*裸指针拿出去之后,你根本不知道外部什么时候改了内容
  • C++11引入移动语义之后,COW能省下的拷贝已经变得无关紧要

手写string类的阶段,别碰这个深渊。把深拷贝的每条路走稳,把移动构造和移动赋值吃透,性价比高得多。你以后做项目会遇到无数类似的选择题,接受了"教科书练习以教学为目的"这个前提,你就不容易在无用优化上消耗太多热情。

3. 核心代码实现与关键细节解析

这段直接上实现思路。我尽量把每个关键步骤都拆开来解释,包括参数是怎么算出来的,为什么这样写,以及哪些地方最容易踩坑。

3.1 类的骨架设计

#include <iostream> #include <cstring> class hstring { public: // 构造与析构 hstring(); hstring(const char* str); hstring(const hstring& other); ~hstring(); // 赋值操作 hstring& operator=(const hstring& other); // 容量相关 size_t size() const; size_t length() const; bool empty() const; // 元素访问 const char* c_str() const; char& operator[](size_t index); const char& operator[](size_t index) const; // 拼接与比较 hstring& operator+=(const hstring& other); friend hstring operator+(const hstring& lhs, const hstring& rhs); bool operator==(const hstring& other) const; bool operator!=(const hstring& other) const; // 输出 friend std::ostream& operator<<(std::ostream& os, const hstring& str); private: char* data; size_t len; void init_from_cstr(const char* str); void destroy(); };

在这份代码里,我把data和len放在了private,只留该给的接口给用户碰。friend只给了外部的operator+和operator<<,因为这两个函数确实需要访问内部数据。这不是懒,是C++设计原则中"最小权限"的体现——能不改动类内部声明就不改动,尽量缩小暴露面积。

3.2 构造:内存从哪里来,到哪里去

默认构造和字符串构造:

hstring::hstring() : data(nullptr), len(0) { data = new char[1]; data[0] = '\0'; } hstring::hstring(const char* str) : data(nullptr), len(0) { if (str == nullptr) { data = new char[1]; data[0] = '\0'; return; } len = strlen(str); data = new char[len + 1]; strcpy(data, str); }

注意默认构造中,我仍然给data分配了一块能容纳'\0'的内存。为什么要这样?因为c_str()返回的指针必须永远合法,任何接口实现都不该让用户拿到悬垂指针。有些习题答案把data初始化为nullptr,等到c_str()调用时才判空,也算一种做法,但空字符串对象仍然拥有可访问的内存是更安全的方案。

len = strlen(str);这里先拿到长度,再按len + 1分配——加的那个1专门放字符串结尾符。如果漏掉这个+1,把'\0'写到缓冲区外面去,程序在运行期会踩到随机内存,轻则输出乱码,重则直接段错误。这一点被无数人忽略,是内存类练习里最常见也最经典的错误。

关于new char[len + 1]为什么不直接new char[strlen(str) + 1],主要是代码可读性和异常安全性的考虑。把strlen结果先存到len,后续size()这些方法就能直接复用,不用再次扫描字符串。

3.3 拷贝构造与拷贝赋值:深拷贝决战场

hstring::hstring(const hstring& other) : data(nullptr), len(other.len) { data = new char[len + 1]; strcpy(data, other.data); } hstring& hstring::operator=(const hstring& other) { if (this == &other) { return *this; } delete[] data; len = other.len; data = new char[len + 1]; strcpy(data, other.data); return *this; }

赋值操作符这块儿,有一道很难觉察的坎:自我赋值。str = str;这种代码如果出现在你的项目里,并且你没做自我判断,会先delete[]掉自己的数据,然后拿着一个被释放掉的地址去拷贝,直接把程序送走。

你可能会说"我怎么可能写str = str这种代码?"——确实不会直接写,但是当赋值和别名搅在一起时就危险了。比如存在一个hstring& ref = str;,某个分支里执行str = ref;,你在写代码时根本看不出这是自我赋值。所以教科书总是强调:写赋值操作符,第一行先判断是不是自己。

我还采用了"先释放后重新分配"的策略,这个策略在多数情况下是主流做法,因为它逻辑清晰、容易验证。但它在异常安全上存在隐患:如果new char[]抛异常,对象已经被摧毁了。这个问题很进阶,通常引入copy-and-swap惯用法来解决。在这阶段我建议你用最直接的先后顺序方案,但要清楚它的局限。

3.4 拼接操作与运算符重载的取舍

拼接这块,最容易出现的毛病是用完+=之后忘记返回引用。我把两个运算的差异摆出来:

hstring& hstring::operator+=(const hstring& other) { size_t new_len = len + other.len; char* new_data = new char[new_len + 1]; strcpy(new_data, data); strcat(new_data, other.data); delete[] data; data = new_data; len = new_len; return *this; } hstring operator+(const hstring& lhs, const hstring& rhs) { hstring result = lhs; result += rhs; return result; }

+=是在自己身上做增量修改,返回hstring&是链式调用a += b += c;的钥匙,不返回引用编译器会报错或者生成临时对象,事倍功半。+则不同,它是非成员函数,因为两侧参数都可能发生隐式类型转换,如果定义成成员函数,"hello" + str这种形式会编译失败。让operator+调用operator+=来复用逻辑,是我在实际开发中总结出的最不易出错的设计——你只把真正的数据搬运写一遍,其他接口全基于它扩展,代码的维护成本直线下降。

friend hstring operator+(...)是必须的吗?严格说,如果只访问了public接口,可以不设friend。但operator+访问+=并不需要friend,真正的friend需求来自operator<<以及未来可能的内部结构操作。在hstring这个练习中,cin和cout是标配,所以我把operator<<设计为friend,直接访问data和len来逐个输出。比较运算符我建议用strcmp,但前提是内部字符串约定以\0结尾——这正是我们前面坚持"末尾必有结束符"的回报。

3.5 大小接口与索引边界

size_t hstring::size() const { return len; } bool hstring::empty() const { return len == 0; } char& hstring::operator[](size_t index) { return data[index]; } const char& hstring::operator[](size_t index) const { return data[index]; }

关于operator[]要不要检查越界,业界分歧很大。标准库的std::string在operator[]中不保证越界检查(那是at()的职责),目的就是极致性能。hstring作为教学练习,我同样选择不检查,但你要清楚地知道这是个性能和安全的权衡设计。如果用户拿越界下标去访问,会直接从data[index]跑到缓冲区外,读出来的是垃圾,写进去就是内存破坏。

如果这是用来上课交作业的代码,建议你在注释里写明"未定义行为"四个大字,至少让阅卷老师看见你明白这个语义。如果想加检查,就额外写个at(size_t)方法,内部用if (index >= len) throw std::out_of_range(...)把异常抛出来。这种方式既照顾了安全性,也保留了[]操作符的高速通道。

4. 实际测试与常见问题排查实录

代码写完了不是终点,真正的挑战从你开始跑测试的那一刻才降临。我把自己实测踩过的坑和一些经典问题整理成速查表,帮你省掉大量查资料的功夫。

4.1 三种一跑就崩的经典错误

第一类错误是空指针解引用。很多同学在默认构造里偷懒不初始化data,或者初始化为nullptr,后面一旦调用strcpy(data, other.data)试图往空指针地址拷贝数据,程序当场崩溃。对这个问题的排查思路很直接:把类里所有用到data的地方全部列出来,检查每一个分支执行时data是否一定非空。最好的解决方案就是我前文写的,默认构造分配一块new char[1],从源头杜绝悬垂。

第二类错误是内存泄漏。new了之后忘了delete[],运行一万次就能把内存耗尽。排查方法是用工具,Linux下的valgrind是神器,Windows的Visual Studio也有_CrtDumpMemoryLeaks()。测试完代码跑一次内存检测,比自己瞪大眼睛盯代码强得多。

第三类错误是自赋值崩溃,刚才提过,表现特征很奇怪:不是每次都会崩,而是偶尔崩。因为字符串内容相同时,delete[]之后的数据在内存里还是原样的概率很高,strcpy居然能自欺欺人地复制成功。哪天缓冲区被回收重用了,才露出狰狞面目。这就是为什么开头的if (this == &other)判断绝对不能省。

错误类型现象根治方案
空指针解引用构造后立刻崩溃默认构造也分配内存,保证data非空
内存泄漏长时间运行内存暴涨new/delete成对检查,配合valgrind
自赋值崩溃偶尔崩溃、随机崩溃operator=开头判断this地址
深拷贝缺失两个对象互相影响拷贝时new新内存,逐字节拷贝
缓冲区溢出字符串末尾乱码new时长度必须len + 1

4.2 功能测试:哪一种测试值得我们写

写hstring的主要目的是学习,不是交付商业代码,所以不用重型测试框架,写几段性感的自测就够了。我的习惯是用一个main函数把所有功能一次性检验一遍:

int main() { hstring s1; std::cout << "s1为空: " << s1.empty() << ", 输出: [" << s1 << "]" << std::endl; hstring s2("hello"); hstring s3(s2); std::cout << "s2: " << s2 << ", s3拷贝构造: " << s3 << std::endl; hstring s4; s4 = s2; std::cout << "s4赋值: " << s4 << std::endl; s4 += " world"; std::cout << "s4拼接后: " << s4 << std::endl; hstring s5 = s2 + " cpp"; std::cout << "s5用operator+: " << s5 << std::endl; if (s2 == s3) { std::cout << "s2与s3相等" << std::endl; } s2[0] = 'H'; std::cout << "s2修改后: " << s2 << ", s3不变: " << s3 << std::endl; return 0; }

这段测试有个容易被忽略的价值:它验证了深拷贝最重要的场景——修改s2的某个字符,s3的内容不应该跟着变。如果你偷懒用了浅拷贝,或者operator=只是指针赋值,那么s2[0] = 'H'这行之后,s3打印出来也会变成"Hello",测试会立刻暴露问题。

测试时我发现一个关于size()返回类型的细节:应该用size_t而不是int。从纯数学上看,字符串长度是非负数,int有符号特性是多余的;从兼容性看,标准库字符串的长度类型就是size_t,如果你的类将来要和标准库容器互操作,类型对不上就要做一堆强制转换,非常别扭。

4.3 与标准库string的差异,你心里要有数

写完hstring,你可能会不由自主拿它和std::string对比,然后陷入自我怀疑。别这样,hstring的目标从来不是再造一个标准库,而是帮你理解底层。因此两者之间的差异正是学习价值所在:

  • std::string支持移动语义,hstring在这个阶段未必支持
  • std::string支持find、substr等大量算法,hstring只实现了基础接口
  • std::string在不同编译器和标准库中可能有小字符串优化,hstring用的是裸堆内存
  • std::string的c_str()在原字符串被修改后可能失效,hstring同样如此——这个隐藏坑连有经验的人也会在长生命周期项目中偶遇

这些差异说明了两件事:标准库实现者在工程上做了大量取舍来兼顾性能、安全与易用性;而这些取舍恰恰是你可以通过手写string去逐步领悟的。先完成hstring的功能,再去看std::string源码(或者网上那些分析std::string实现的文章),你会获得豁然开朗的体验。

5. 从手写string到真实工程的进阶路线

hstring写完了、测试通过了、21天阶段性目标达成了,然后呢?把这段代码丢进回收站就太浪费了。从手写string到真实工程能力之间还有几级台阶,你可以趁热打铁爬上去。

5.1 第一个进阶:无异常版本的思考

我在3.3节提到"先释放后分配"在异常安全上的隐患。想让hstring真正达到工业级,就要引入copy-and-swap惯用法:

void swap(hstring& other) noexcept { std::swap(data, other.data); std::swap(len, other.len); } hstring& operator=(const hstring& other) { hstring temp(other); swap(temp); return *this; }

原理很好理解:先用other拷贝构造一个临时变量,如果拷贝过程中内存分配失败,抛出异常,此时的*this完好无损。如果拷贝成功,交换临时变量和当前对象的内容,临时变量析构时把旧内存释放。这个方法把"赋值"变成"构造+交换+析构"的组合操作,从结构上消灭了异常安全问题。

我非常建议你把这段代码亲手写进hstring,再用异常注入的方式模拟new失败场景,感受一下跟旧版本的区别。这个习惯对你以后写任何资源管理类都有不可估量的帮助。

5.2 第二个进阶:移动构造与移动语义

C++11之后,移动语义是新标准的重要内容。给hstring加上移动构造和移动赋值,代码不复杂:

hstring(hstring&& other) noexcept : data(other.data), len(other.len) { other.data = nullptr; other.len = 0; } hstring& operator=(hstring&& other) noexcept { if (this == &other) { return *this; } delete[] data; data = other.data; len = other.len; other.data = nullptr; other.len = 0; return *this; }

移动构造的本质是"偷走"别人的资源。other.data = nullptr这行尤其重要:置空之后,临时对象的析构函数释放一块空指针是安全的,不会释放掉刚被偷走的数据。注意析构对nullptr做delete[]是合法的,这算C++的一条语言福利,但要记得在析构里判空或者其他方式保证安全。

有了移动构造和移动赋值,"三法则"这个要求就升级成"五法则"——五法则的含义是:如果需要自定义析构函数、拷贝构造或拷贝赋值三者中的任何一个,通常意味着这三个都需要你自己写,而一旦涉及移动操作,移动构造和移动赋值也要一起考虑。这也是为什么很多面试官喜欢拿string类做考点:它完美覆盖了五法则的所有内容。

5.3 第三个进阶:在真实项目里用hstring积累经验

自己手写的hstring可以怎么用而不显得无聊?你可以把它作为缓存容器来存储命令行的历史记录,或者把它嵌到一个简单的文本统计小工具里。我自己做的实验是写了一个小型的词频统计功能,用hstring存储每个单词,然后把它们塞进一个std::vector<hstring>里,观察拷贝带来的性能开销。

这个实验的收获很大。你会在亲身体会中懂得为什么标准库提供那么多优化——当你统计一本几百页的英文书,几千个单词在vector里反复扩容、拷贝、再扩容时,肉眼可见的性能损耗会让你重新审视"深浅拷贝"这个看似基础的概念。你也马上会想了解reserve和shrink_to_fit的机制,这又触发了对vector内存模型的进一步学习。

在这个过程里,别忘了用工具检测问题。valgrind、AddressSanitizer、_CrtDbg都行,关键是养成习惯。写过一次带内存问题的类并亲眼看到检测工具定位到那一行代码之后,你就再也回不到"只管实现不管内存"的舒适区了。

6. 给练习题答卷人的实操建议

如果这篇博客触发的场景是你正准备交一份hstring的阶段性测试题,那么下面这些建议是我结合自己看作业和写代码的双重经验总结出来的,照着做,效果至少能上一个台阶。

6.1 写代码时保持注释与实现同步

习题代码不要写注释或者写太多花哨注释,而是要在关键处用注释说明设计动机。比如在operator=开头的自赋值判断旁,写一行"// 防止自我赋值导致野指针的问题",阅卷老师一瞬间就能捕捉到你的工程素养。反过来,在new char[len + 1]旁边写一句"// 末尾的+1给'\0'留位置",就证明你踩过或者认真看过这个经典坑。

6.2 必要的方法也考虑const正确性

很多初学者写方法时漏掉const修饰,比如:

size_t size() { return len; }

这会导致一个尴尬的场景:一个const的hstring对象无法调用size()方法。比较运算符operator==如果你写的不够const,那两边有const引用时照样编译不过。且不说工程实践,单单老师布置的测试代码里如果有一个const hstring a("hello"); a.size();的调用,你就要吃编译错误。

你只需要记住一条标准:不修改任何类成员的方法,都加const。这个习惯一旦养成,你的代码跟别人之间的差距会肉眼可见地拉开。

6.3 亲手把代码再抄一遍

网上关于hstring或者手写string类的代码一搜一大把,但单纯复制粘贴不会有任何长进。我强烈建议你照着思路自己敲一遍,再故意制造几个bug去验证你的理解。

比如你可以改动一行,把深拷贝改成浅拷贝,跑一遍之前的测试聊聊会发生什么;或者删掉默认构造中的data = new char[1],看看哪些操作会崩。这种"故意搞坏代码再修复"的方式,被很多人戏称为"负向教学法",我实测下来是巩固C++底层概念最有效的方法之一。

7. 总结与个人实践体会

写到这里,hstring这道题的核心内容其实已经全讲完了。六年前我第一次接触这个练习题时,写了整整一个晚上,期间无数次刷新编译错误、谷歌解决方案,最终在第二天凌晨把代码调通。现在回头看,那一个晚上的收获比之后连续刷两个星期的教程都大。

如果你让我说说对"《21天学通C++》阶段性成果测试题——hstring(手写string类)"这个标题的整体评价,我会说:这是一个被严重低估的教学设计。它不像很多练习题那样只考某个孤立语法,而是用一种"你必须亲自从零构建一个基础工具"的方式,把C++中最容易蒙混过关的对象生命周期、资源管理、运算符重载等核心知识全部串联起来。

交作业或者自我练习时,别满足于跑通,再多走一步:加上移动语义、验证const正确性、跑一遍内存检测、想想copy-and-swap比"先释放后分配"好在哪。当你把这些都做完了,你会发现你不在是那个只会调用std::string的初学者,而是开始用package designer的视角审视C++这门语言。这,才是hstring真正想教会你的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询