拿到这套《21天学通C++》的阶段性测试题时,我原本以为就是一次普通的课后小练习,直到看到题目要求“手写一个 hstring(自定义 string 类)”,我才意识到这道题的分量。它几乎把前21天学过的最核心的C++知识——类与对象、构造与析构、运算符重载、深拷贝与浅拷贝、内存管理、甚至右值引用与移动语义——全部串在了一个小小的字符串类里。可以说,这道题不是一个“作业”,而是一面镜子,能照出你对C++的理解到底是“看着懂了”还是“真会写了”。
如果你也正在自学C++,或者正刷到类似的“手写string类”题目,这篇文章就是我以hstring为切入点,把整套思考过程、设计路线、代码实现以及踩坑记录完整梳理出来的实战笔记。内容会尽量照顾到刚学完基础语法、但对“如何组织一个类”还不太有感觉的读者,也会对已经能写出大致框架、但想进一步打磨鲁棒性和规范性的读者有帮助。读完你不仅能复现一个可用的 hstring,更能理解每一行代码背后的设计理由。
1. 为什么是 hstring:一道测试题背后的知识版图
在动手写代码之前,我先把这道题放在整个C++学习路径里想了一遍。为什么教材会在这一阶段安排一道“手写string类”的题目?因为string类几乎天然覆盖了C++中“值语义”和“资源管理”的全部核心矛盾,而这两点恰恰是C++区别于其他语言的分水岭。
一个字符串,从表象看就是一串字符,但放到C++里它就变得“麻烦”起来了:字符串的底层内存谁分配、谁释放?字符串被复制的时候,到底是复制指针还是复制内容?字符串对象作为函数参数和返回值时,如何避免不必要的拷贝开销?这些问题不解决,string就只能是“用起来很爽但一旦深究就漏洞百出”的黑盒。而hstring这个任务,就是逼着你亲手打开黑盒,把每一个环节都暴露到阳光下。
从知识点覆盖上看,这道题至少串起了这样一张表:
| 知识点 | 在hstring中的落点 | 容易考察的能力 |
|---|---|---|
| 构造函数 | 默认构造、带参构造、拷贝构造 | 掌握初始化列表、参数设计 |
| 析构函数 | 释放动态字符数组 | 理解RAII资源管理思想 |
| 深拷贝 | 拷贝构造、拷贝赋值运算符 | 区分深浅拷贝及其危害 |
| 运算符重载 | operator=、operator+=、operator[]、operator<<、operator>>、operator==等 | 理解运算符重载的规则与惯例 |
| 内存管理 | new[]/delete[],动态扩容 | 掌握堆内存生命周期 |
| 边界处理 | 空字符串、越界访问、自赋值 | 提升代码健壮性意识 |
| 现代C++特性(进阶) | 移动构造、移动赋值、右值引用 | 理解C++11的性能优化理念 |
可以说,hstring里每一行代码都不白给。哪怕你写出的版本只是“能用”,只要你是完整地实现了上述大部分功能点,你对C++类机制的理解就已经比只翻教材、只做题、只调STL的学习者深了一个量级。这也是我把这篇博文定位为“阶段性成果”的原因——它不是终点,但一定是一座绕不开的里程碑。
2. 动笔前的三张草图:hstring应该长什么样、扛什么事
很多人一上来就噼里啪啦开始敲代码,结果写着写着发现自己忘记处理空指针、忘记运算符重载的参数、忘记 const 限定符……回头改得一团糟。我建议你先在纸上回答清楚三个问题,相当于把hstring的蓝图画出来。
2.1 内部结构:用什么装字符串
C风格字符串其实就是一个以\0结尾的字符数组。那么在类内部,最直接的方案就是维护一个char*指针,指向堆上动态分配的内存块。为什么不能直接用一个固定大小的char数组?因为那将无法处理任意长度的字符串,根本不符合string类的语义。
于是我们的核心成员变量有两个:
char* m_data; // 指向堆内存中的字符缓冲区 size_t m_size; // 字符串有效长度(不含结尾的'\0')这里注意一个细节:我单独用m_size记录字符串长度,而不是每次都用strlen(m_data)去数。strlen 的时间复杂度是 O(n),字符串越长开销越大;而用成员变量记录长度,取长度就是 O(1) 操作。这也是标准库 string 的做法之一,属于用空间换时间、用维护换效率的典型设计。
2.2 接口范围:做到哪一档才算合格
hstring 到底需要哪些接口?我把它分成了三档,你可以根据自己的时间和水平对号入座:
- 基础档(必须):默认构造函数、带const char*参数的构造函数、拷贝构造函数、析构函数、拷贝赋值运算符、c_str()方法、size()/length()方法、operator[]下标访问。
- 进阶档(强烈推荐):operator+=(追加字符串)、operator+(连接字符串)、operator==/!=/</>/<=/>=(比较运算)、operator<<(输出到流)、operator>>(从流读取)、operator=支持直接赋值const char*。
- 挑战档(加分项):移动构造函数、移动赋值运算符、迭代器begin()/end()、find查找函数、substr截取函数、空串判断empty()。
我的建议是,如果你是在跟着《21天学通C++》的进度测试自己,至少要做到“进阶档”,因为运算符重载那一章的内容如果不通过这类题来练手,基本等于没学扎实。挑战档则可以触碰C++11的移动语义,对后续理解现代C++的“零拷贝”理念很有帮助。
2.3 生命周期:谁负责分配、谁负责释放
这个问题的核心就是RAII(资源获取即初始化)。hstring的析构函数必须负责释放构造期间申请的内存,否则每次创建hstring对象就会泄漏一块堆内存。内存泄漏在简单小程序里不一定立刻暴露,但一旦对象多起来或程序长时间运行,就会导致内存暴涨,甚至崩溃。
在C++里,一块堆内存最好只属于一个“所有者”,这个所有者负责释放。hstring内部的 m_data 就是这样的所有权指针。拷贝的时候,我们必须为副本申请一份独立的内存并复制内容——这就叫深拷贝。偷懒只复制指针(浅拷贝)会带来双重释放、悬空指针等严重的未定义行为。这一点无论如何都要想透彻,它是整个hstring的核心考点。
3. 第一版实现:从“能用”到“好用”的必经之路
确定好蓝图后,就可以开始写代码了。我先把第一版的完整代码放出来,这版覆盖了“基础档+进阶档”,我会在代码之后逐块解释设计逻辑。
#include <iostream> #include <cstring> #include <algorithm> class hstring { public: // 1. 默认构造函数 hstring() : m_data(nullptr), m_size(0) { m_data = new char[1]; m_data[0] = '\0'; } // 2. 带参构造函数 hstring(const char* str) { if (str == nullptr) { m_size = 0; m_data = new char[1]; m_data[0] = '\0'; } else { m_size = strlen(str); m_data = new char[m_size + 1]; strcpy(m_data, str); } } // 3. 拷贝构造函数(深拷贝) hstring(const hstring& other) { m_size = other.m_size; m_data = new char[m_size + 1]; strcpy(m_data, other.m_data); } // 4. 析构函数 ~hstring() { delete[] m_data; } // 5. 拷贝赋值运算符 hstring& operator=(const hstring& other) { if (this != &other) { // 自赋值检查 delete[] m_data; // 释放旧资源 m_size = other.m_size; m_data = new char[m_size + 1]; strcpy(m_data, other.m_data); } return *this; } // 6. 返回C风格字符串 const char* c_str() const { return m_data; } // 7. 返回长度 size_t size() const { return m_size; } // 8. 下标访问(非const版本) char& operator[](size_t index) { return m_data[index]; } // 8. 下标访问(const版本) const char& operator[](size_t index) const { return m_data[index]; } // 9. 追加字符串 hstring& operator+=(const hstring& other) { char* new_data = new char[m_size + other.m_size + 1]; strcpy(new_data, m_data); strcat(new_data, other.m_data); delete[] m_data; m_data = new_data; m_size += other.m_size; return *this; } // 10. 字符串连接(友元函数) friend hstring operator+(const hstring& lhs, const hstring& rhs) { hstring result(lhs); result += rhs; return result; } // 11. 比较运算符(以==为例,其余类似) friend bool operator==(const hstring& lhs, const hstring& rhs) { return strcmp(lhs.m_data, rhs.m_data) == 0; } friend bool operator!=(const hstring& lhs, const hstring& rhs) { return !(lhs == rhs); } friend bool operator<(const hstring& lhs, const hstring& rhs) { return strcmp(lhs.m_data, rhs.m_data) < 0; } friend bool operator>(const hstring& lhs, const hstring& rhs) { return rhs < lhs; } friend bool operator<=(const hstring& lhs, const hstring& rhs) { return !(rhs < lhs); } friend bool operator>=(const hstring& lhs, const hstring& rhs) { return !(lhs < rhs); } // 12. 输出到流 friend std::ostream& operator<<(std::ostream& os, const hstring& str) { os << str.m_data; return os; } // 13. 从流输入 friend std::istream& operator>>(std::istream& is, hstring& str) { char buffer[4096]; is >> buffer; str = hstring(buffer); return is; } private: char* m_data; size_t m_size; };初看这份代码,你可能觉得它“差不多就是照标准string抄”。但请注意,这里每一个选择背后都有坑,我在下面重点讲几处。
3.1 为什么默认构造也要分配内存
我的默认构造里,给 m_data 分配了一个new char[1]并存入\0。为什么不把 m_data 直接设为 nullptr?
因为这样能让 hstring 的任意一个合法对象始终持有一个可用的C风格字符串,后续所有成员函数在处理字符串时都不必额外判断 m_data 是否为空。比如c_str()可以直接返回 m_data 而不必担心返回空指针让下游代码崩溃。这在工程里是一个非常重要的思想:用不变式简化逻辑。你规定“hstring 的 m_data 永远指向一块有效的以 \0 结尾的堆内存”,那么整个类的代码里就不用到处写空指针保护。
当然,你可以在操作前统一判断,但那样代码会又啰嗦又容易漏。我更喜欢这种“从构造起就保证合法性”的做法。
3.2 拷贝赋值里的门道:自赋值检查与异常安全
在拷贝赋值运算符里,第一步是检查this != &other。这一步叫自赋值检查,防止出现“自己给自己赋值”时先把 m_data 释放掉,再用一块已经释放的内存去做 strcpy 的未定义行为。
但是只看自赋值检查还不够。我的写法是先 delete 旧内存、再 new 新内存,这有一个隐患:万一 new 失败抛异常(内存不足),当前对象的 m_data 已经被释放了,对象就处于一种“半死”状态。更健壮的做法是采用“copy-and-swap”(拷贝并交换)惯用法,我后面会单独讲。
如果你是第一版先追求“能跑通”,上面的写法问题不大。但如果你希望这段代码经得起面试官追问,或者想养成更好的工程习惯,请继续看第4节。
3.3 深拷贝的三个环节缺一不可
深拷贝的逻辑其实就三句话:测长度、按长度分配新内存、把内容复制过去。在拷贝构造函数中我用other.m_size而不是自己再调一遍 strlen,一方面性能更好,另一方面语义上更准确——我们要复制的是对象的完整状态,包括它内部记录的 size,而不是重新去扫描它所指向的C字符串。
千万注意,拷贝构造和拷贝赋值里都必须是“深拷贝”,绝不能用简单的m_data = other.m_data这种指针复制。一旦用了浅拷贝,两个对象的 m_data 指向同一块堆内存,析构的时候谁都认为自己有权释放这块内存,导致双重释放,轻则程序崩溃,重则被攻击者利用造成内存破坏。学习阶段一定要亲手写一遍浅拷贝的错误代码,亲眼看到崩溃场景,才能真正理解深拷贝的必要性。
3.4 运算符重载里“隐藏”的坑
operator[] 我写了两个版本:非const版本返回char&,const版本返回const char&。这一对重载可以让 hstring 对象在常量环境下也只能读不能写,这是const正确性的基本要求。很多人只写一个版本,结果发现const hstring s; s[0]无法编译,就是因为缺少const版本。
operator+ 我设计成友元函数,返回一个新对象。这意味着实现方式可以写成“先拷贝一份左操作数,再追加右操作数”,非常简单。
operator<< 和 operator>> 也都是友元函数。这里有个容易忽略的点:为什么输出/输入运算符必须是友元函数?因为它们需要访问对象的私有成员 m_data,而且它们的第一个参数是std::ostream&/std::istream&,不能是 hstring 自身。所以只能通过友元的方式突破封装边界。这也算是一个小小的设计模式知识点。
4. 第二版升级:拷贝并交换与移动语义
第一版虽然能跑,但我建议你在完成了基本测试之后,不要急着收工,而是进行第二轮改造。这一轮会引入两个非常重要的C++工程实践,它们会把你和“只会写作业的学生”区分开。
4.1 copy-and-swap:一种优雅而安全的赋值写法
“拷贝并交换”的核心思路是:不直接修改当前对象,而是先用参数构造一个临时对象(拷贝),再把这个临时对象与当前对象交换(swap),临时对象在函数结束时自动析构,顺带把旧资源带走。
class hstring { public: // 交换两个对象的内部指针和长度 void swap(hstring& other) noexcept { std::swap(m_data, other.m_data); std::swap(m_size, other.m_size); } // 拷贝赋值运算符(copy-and-swap版本) hstring& operator=(const hstring& other) { hstring temp(other); // 拷贝构造一个临时对象 swap(temp); // 将临时对象的内容与当前对象交换 return *this; // 函数结束,temp析构,释放旧资源 } };这个写法有一个极其显著的优点:异常安全。如果拷贝构造过程中 new 分配内存抛出了异常,temp 构造失败,此时当前对象完全不受影响,仍然是原来的状态。而第一版的写法在“先delete再new”的模式下,new失败就会让当前对象处于坏状态。同时,这个写法天然处理了自赋值问题——就算a = a,也只是经历一次拷贝和交换,不会崩。
很多人会问:万一自赋值,temp 拷贝的是自己,然后swap回去,那不是白折腾?其实这个问题不用担心,自赋值本来就是极少数情况,为了代码的鲁棒性多付出这一点微不足道的开销,非常划算。你甚至可以认为,copy-and-swap 已经把“自赋值检查”这个分支给消灭掉了,逻辑上更统一。
4.2 移动构造与移动赋值:从拷贝到搬运
有了拷贝构造和拷贝赋值,hstring 已经能正确处理所有“复制字符串”的场景了。但C++11引入右值引用后,对于“临时对象”我们其实还有更高效的玩法:直接把临时对象的资源“搬”过来,而无需逐个字符拷贝。这就是移动语义。
class hstring { public: // 移动构造函数 hstring(hstring&& other) noexcept : m_data(other.m_data), m_size(other.m_size) { other.m_data = nullptr; other.m_size = 0; } // 移动赋值运算符 hstring& operator=(hstring&& other) noexcept { if (this != &other) { delete[] m_data; m_data = other.m_data; m_size = other.m_size; other.m_data = nullptr; other.m_size = 0; } return *this; } };移动构造里,我把 other.m_data 直接“据为己有”,然后把 other 的指针置空。这里为什么把 other.m_data 置为 nullptr 而不是让它继续指向那块内存?因为 other 随后会被析构,析构函数会执行delete[] m_data。如果还与当前对象共享同一块内存,又会出现双重释放。所以移动的本质不是“复制资源”,而是“转移所有权”,同时把源头“掏空”。
再强调一下 noexcept 关键字。为什么移动构造/移动赋值必须标记 noexcept?因为标准库容器(比如 vector )在扩容时,如果候选的移动构造函数不是 noexcept,为了强异常安全保证,它会退回去使用拷贝构造而非移动构造,于是你辛辛苦苦实现的移动语义根本不会被触发,性能优化等于白做。这是一个非常实际的细节,在面试和性能调优场景中经常被问到。
5. 踩坑实录:从“能编译”到“能正确运行”的真实排错过程
说实话,第一次写完 hstring,我有很大的把握它能通过常规测试,结果在较真一些边界条件时还是翻车了。这里把三个最典型的bug以及完整的排查链路写下来,你可以对照自己的实现排查。
5.1 边界条件1:空字符串的追加和比较
第一个问题出现在追加操作上。当我执行下面这段测试代码时:
hstring s1("hello"); hstring s2(""); s1 += s2; std::cout << s1 << std::endl; // 预期输出 hello第一版代码里,我在 operator+= 中用了strcat,而strcat要求目标字符串以 \0 结尾。如果 s2 是一个空字符串,它的 m_data 是指向{'\\0'}的,strcat 空字符串本身没问题。但问题在于如果 s1 也是空字符串呢?比如hstring s3; s3 += s2;,此时 s3.m_data 指向{'\\0'},strcat 也能正常工作。看起来没问题?
真正的问题出现在s1 += s1这种自我追加。我用 strcpy 先把 s1.m_data 覆盖到 new_data 里,这没问题;但接着我用 strcat 把 other.m_data 追加到 new_data 尾部时,other 指向的正是 s1.m_data,而 s1.m_data 在第一步 strcpy 之前就已经被我们读取,但关键是 new_data 和 s1.m_data 不是同一块内存,所以我在 new_data 上操作不影响 s1.m_data……那到底哪里出错了?
让我再仔细走一遍流程:s1 += s1,先把 s1.m_data 的内容复制到 new_data,然后 strcat(new_data, other.m_data),此时 other.m_data 和 s1.m_data 是同一块内存,内容还没有被修改。strcat 是从 new_data 的末尾开始追加,把 s1.m_data 的内容追加进去,逻辑上没问题。真正的问题藏在第一步的delete[] m_data之前,我其实已经用strcpy复制了 m_data 到 new_data,所以 old m_data 即使被 delete,new_data 也持有了一份副本,后续 strcat 读取 other.m_data 已经指向被 delete 的内存,这就成了悬垂指针。如果在 delete 之后又被其他分配覆盖,strcat 读到的数据就不是原来的了,结果就完全不可控。
所以自我追加的正确实现要在重新分配内存之前,先把 other 的长度和内容备份,或者统一采用“先复制到临时、再交换”的思想。最简单直接的方式是:
hstring& operator+=(const hstring& other) { size_t new_size = m_size + other.m_size; char* new_data = new char[new_size + 1]; strcpy(new_data, m_data); strcat(new_data, other.m_data); delete[] m_data; m_data = new_data; m_size = new_size; return *this; }这段代码在s1 += s1时,other.m_data 等于 m_data,但这一步,我们先把 m_data 的内容复制到 new_data,然后又把 other.m_data 也追加进 new_data。问题在于,other.m_data 和 m_data 是同一块内存,但我们在复制的时候并没有修改它,所以理论上还是能读到原始内容。但是!如果中间编译器做了某些优化,或者 delete[] 把内存内容破坏了,那就是未定义行为了。因此最稳妥的方式是:先把 other 的内容单独复制出来再处理,或者使用临时变量保护。
我最终采用了这个思路:
hstring& operator+=(const hstring& other) { if (other.m_size == 0) return *this; size_t new_size = m_size + other.m_size; char* new_data = new char[new_size + 1]; strcpy(new_data, m_data); strcat(new_data, other.m_data); delete[] m_data; m_data = new_data; m_size = new_size; return *this; }实际上对大多数实现,上面的逻辑也能跑通,但为了绝对不发生悬垂读取,更严谨的方式是记录other.m_data的长度和值引用,或者像C++标准库那样走临时对象方案。我在这里专门把这个 bug 拉出来,就是提醒你:任何在函数内部同时读取“当前对象”和“参数对象”的操作,都值得做一次“参数是否可能别名到 this”的自问。
5.2 边界条件2:非const的operator[]破坏内部不变式
第二个bug非常隐蔽。我实现了char& operator[](size_t index),然后测试了这样的代码:
hstring s("abc"); s[0] = 'X'; std::cout << s << std::endl; // 预期输出 Xbc单看这段完全正常。可如果用户直接写s[3] = 'Y';呢?我这里没有做越界检查,m_data[3] 其实已经访问到了\0的位置,把结束符覆盖掉,等输出时 m_data 就再也找不到结尾了,轻则乱码,重则越界读取。虽然标准库的 string 的 operator[] 也不提供越界检查,但那是为了性能刻意为之,并且它的文档明确指出越界是未定义行为。所以我在教学版 hstring 里做了一个折中:保留 operator[] 不带检查的快速版本,另外提供 at() 方法,带边界检查并抛出 std::out_of_range 异常。这样既贴近标准库的习惯,又适合学习阶段做防御性编程。
5.3 边界条件3:从流读取输入时的分隔符问题
第三个坑出现在 operator>> 上。我最初的实现是:
friend std::istream& operator>>(std::istream& is, hstring& str) { char buffer[4096]; is >> buffer; str = hstring(buffer); return is; }测试普通单词没问题,但当我输入hello world时,只能读到hello。这是因为我用的是格式化输入operator>>,它天然以空白字符为分隔符。而 string 的 getline 才用于读取一整行。如果你希望 hstring 支持整行读取,要么让用户自己用std::getline(is, buffer)然后赋值给 hstring,要么提供另一个成员函数。
这个问题的教训是:运算符重载要遵循语言习惯,不能凭空发明行为。operator>>默认读到空白符是标准惯例,不用刻意改。但你可以提供一个hstring::readLine这样的辅助方法,或者直接在示例代码里告诉用户如何配合 std::getline 使用。
6. 验收测试:怎么严谨地证明 hstring 真的接近 std::string
写完代码并不代表任务结束。一套好的测试用例,能帮你把很多隐藏问题提前揪出来。下面这组测试代码是我在验收 hstring 时实际运行的,你可以把它存下来,作为你完成这道题之后的“体检清单”。
6.1 生命周期与构造测试
#include <iostream> #include <cassert> void test_constructors() { hstring s0; // 默认构造 assert(s0.size() == 0); assert(strcmp(s0.c_str(), "") == 0); hstring s1("hello"); // 带const char*构造 assert(s1.size() == 5); assert(strcmp(s1.c_str(), "hello") == 0); hstring s2(s1); // 拷贝构造 assert(strcmp(s2.c_str(), "hello") == 0); assert(s1.c_str() != s2.c_str()); // 关键:内存地址不能相同 hstring s3 = s2; // 依然是拷贝构造 s3[0] = 'H'; assert(s2[0] == 'h'); // 修改s3不能影响s2 } void test_assignment() { hstring s1("hello"); hstring s2; s2 = s1; // 拷贝赋值 assert(s1.c_str() != s2.c_str()); s1 = s1; // 自赋值(不许崩) assert(strcmp(s1.c_str(), "hello") == 0); } void test_concat() { hstring s1("Hello, "); hstring s2("C++"); hstring s3 = s1 + s2; // 连接 assert(strcmp(s3.c_str(), "Hello, C++") == 0); s1 += s2; assert(strcmp(s1.c_str(), "Hello, C++") == 0); hstring s4("loop"); s4 += s4; // 自我追加(重点考验) assert(strcmp(s4.c_str(), "looploop") == 0); }这里尤其注意s1.c_str() != s2.c_str()这条断言。如果不同对象的 c_str() 返回了相同的地址,说明发生了浅拷贝,那是绝对不允许的。
6.2 比较与流测试
void test_compare_and_io() { hstring s1("abc"); hstring s2("def"); assert(s1 < s2); assert(s2 > s1); assert(s1 != s2); assert(s1 == hstring("abc")); std::cout << "s1 = " << s1 << std::endl; // 测试 operator<< hstring input; std::cin >> input; // 测试 operator>> std::cout << "input = " << input << std::endl; }一个合格的测试,不仅要跑出正确结果,还要故意制造错误场景。你可以把拷贝构造改成浅拷贝,看看哪些断言会失败;也可以把 operator[] 的越界写故意放出来,观察程序会有多野。这种“故障注入”试验能极大加深你对内存管理模型的理解。
6.3 valgrind 内存检查
如果是在 Linux 环境,强烈建议安装 valgrind 跑一遍:
g++ -std=c++11 -g -o test_hstring test_hstring.cpp valgrind --leak-check=full ./test_hstring如果输出里有definitely lost或者invalid read/write的提示,说明你的 hstring 还不过关。这个工具能精准定位到具体是哪一行出了内存问题,配合 gdb 调试,你能学到平时根本不可能碰到的底层细节。
7. 还有哪些值得折腾的扩展方向
当你把 hstring 基本调通,稳定性测试也过了,先别急着把这份代码扔进“完成”文件夹。这道测试题之所以叫“阶段性成果”,就是因为它的潜力远不止一份作业。以下扩展方向我按性价比从高到低排列,你可以按兴趣挑一两个玩玩。
7.1 实现一个迭代器
给 hstring 加上 begin() 和 end(),并让它支持范围 for 循环,这在C++11以后非常实用。你甚至不用引入迭代器类,直接用char*指针就能充当随机访问迭代器:
class hstring { public: typedef char* iterator; typedef const char* const_iterator; iterator begin() { return m_data; } iterator end() { return m_data + m_size; } const_iterator begin() const { return m_data; } const_iterator end() const { return m_data + m_size; } };加上之后你就能写出for (char& c : hs) c = toupper(c);这样的代码。此时你会发现,原来 STL 算法库里的 sort、find、count 等方法也都能直接用在 hstring 上了,体验会非常丝滑。
7.2 实现小对象优化(SSO)
标准库 string 里有一个著名的优化叫 SSO(Small String Optimization),当字符串很短(比如小于15个字符)时,直接存到对象内部的一个固定数组中,避免堆分配。这能极大提升短字符串场景下的性能。为 hstring 加上小巧的 SSO 是一个非常有挑战性的练习,它会让你开始思考对齐、联合体、对象内存布局等平时接触不到的底层问题。我建议等你对 hstring 的常规实现非常熟了之后,再花一整个下午去研究这个方向。
7.3 与 C 接口互操作时的安全性
在很多实际工程里,hstring 可能要跨 DLL 边界或者与 C 代码交互。此时你可能需要避免使用new[]/delete[],改用 malloc/free,或者更跨平台的分配器。你可以考考自己:如果类的内存由 malloc 分配,析构函数该用什么释放?如果别人传入一块你并不拥有的内存,你如何处理所有权?这些问题的答案会让你对“谁拥有这块内存”这个终极问题有更深的体会。
8. 关于这道题我最后想说的话
手写一个 string 类这件事,表面上看只是完成一个课程练习,但实际上它像是一次小型的“架构设计 + 资源管理 + 接口打磨”综合演练。我在做这个 hstring 的时候,最大的收获不是把语法用得更熟练了,而是第一次真正意识到:一个类的接口设计会直接决定它好不好用、容不容易被误用,一个资源的管理策略会直接决定程序的稳定性和安全性。这种意识,是看再多的教学视频、刷再多的选择题都换不来的。
如果你正在看这篇文章,并且也处于学习C++的“21天”左右这个阶段,我真心建议你不止于抄代码,而是合上书,自己从头推演一遍 hstring 的构造、拷贝、赋值、析构的全部代码流程。你可以找几个刁钻的测试样例,比如空字符串赋值、自我追加、自我复制、越界访问,把程序跑出问题,再追踪问题根源。这个过程里产生的困惑和顿悟,才是这道测试题真正想送给你的礼物。等你亲手把这个类写到能通过 valgrind 的严格检测,再回头去看标准库 string 的文档,你会发现自己读懂的已经不仅仅是语法,而是设计者的良苦用心了。