
1. 为什么要手写string类《21天学通C》第14天的自我拷问如果你正在啃《21天学通C》大概在第10天到第14天之间会碰上一道绕不开的坎——类的构造函数、析构函数、拷贝控制、运算符重载全都学完了接下来呢书上的练习题顶多让你写个Date类、Stack类做完总觉得心里没底。说到底那些练习没触及C内存管理的核心痛点。这时候自己动手写一个hstring手写string类等于把前面十几天学的零散知识点全部串成一条线是一次真正意义上的阶段性成果验收。标题里“阶段性成果测试题”这六个字我特别有共鸣。当年我学C到第14天正处在“感觉啥都会了写起来啥都错”的尴尬期。类能写重载能用但一旦涉及动态内存、深拷贝、自赋值代码就各种崩溃。后来逼自己完整写了一个string类才真正把知识焊死在脑子里。所以这篇文章不是教你怎么抄一个std::string的简化版而是把你手写时必然会踩的坑、必然要想通的点、必然要用的技巧全部摆到台面上说清楚。适合正在学C入门但卡在类与对象章节的读者也适合准备C面试想拿“手写string”当加分项的同学。2. hstring的整体设计思路拆解2.1 从std::string反推一个string类必须管好的三件事动笔之前先别急着敲代码。你把std::string在脑内拆一遍就会发现它本质上是三件事的集合字符缓冲区管理、长度与容量维护、面向用户的字符串操作接口。这三件事正是手写hstring的全部工作量。第一件事是字符缓冲区管理也是最容易翻车的地方。std::string内部维护一块连续的内存这块内存的地址会随着字符串变长而改变。你写str hello和str , world时底层可能发生了重新分配内存、拷贝旧数据、释放旧内存。如果不理解这套机制写出来的hstring要么溢出要么内存泄漏要么两个对象共享同一块内存然后互相踩踏。第二件事是长度与容量维护。初学阶段最容易忽略capacity和size的区别觉得字符串嘛存多长就该分配多长。但频繁append时每次都重新分配是灾难级的性能问题。所以设计hstring时我会把m_size当前长度和m_capacity已分配容量分开记录预留冗余空间这一点完全模仿std::string的策略。第三件事是字符串操作接口。包括构造、析构、赋值、拼接、比较、查找、截取等常用方法。接口设计不需要一次到位但要保证基础的可读、可写、可比较、可拼接。2.2 为什么成员变量一定要用“指针 长度 容量”三元组很多初学版本写string类成员变量只敢用char* m_data加一个int m_size甚至有人直接写char m_data[100]这种定长数组。定长数组的写法必须第一个否决掉它把string退化成了固定长度字符数组完全丧失动态扩容能力。正确的设计是class hstring { private: char* m_data; size_t m_size; size_t m_capacity; };m_data指向堆上分配的字符数组m_size记录当前字符串的逻辑长度不包含结尾的\0m_capacity记录m_data实际可容纳的字符数不包含结尾的\0。这里有个细节值得注意size_t是无符号类型用来表示长度天然合理不会出现负数长度这种逻辑错误。新手写int m_size也能跑但size_t更贴近工程实践能少踩很多隐式类型转换的坑。m_capacity的价值在不扩容场景下体现不出来但只要你在循环里连续拼接上百次字符串差别就出来了。没有容量预留的版本每次拼接都要new[]一次时间复杂度是O(n^2)有容量预留的版本只有容量不足时才重新分配均摊下来接近O(n)。这也是std::string性能优秀的核心原因之一。2.3 三种构造函数的写法与底层逻辑构造函数是hstring的门面写不好后面全崩。我按从简单到复杂的顺序拆解。默认构造函数。逻辑上创建一个空字符串。为了统一后续的c_str()、operator[]等操作我倾向于让空字符串也有一块合法的内存里面存一个\0。这样m_data永远不会是nullptr遍历和打印时不容易出问题。hstring() { m_data new char[1]; m_data[0] \0; m_size 0; m_capacity 0; }带const char*参数的构造函数。这是最常用的构造函数写法是先strlen算出源字符串长度再分配len 1的内存拷贝数据最后在结尾补\0。这里三个步骤的顺序不能乱。有些新手会先strcpy再strlen逻辑上也能跑但万一ptr不是以\0结尾的非法指针strlen读完整个进程的地址空间程序瞬间崩溃。拷贝构造函数。这是深拷贝思想的核心体现。当用一个已有对象初始化一个新对象时必须为新对象分配独立的内存逐字符拷贝内容。如果只是把m_data指针的值直接复制过去两个对象就指向了同一块堆内存析构时双删修改时互相干扰。hstring(const hstring other) { m_size other.m_size; m_capacity other.m_capacity; m_data new char[m_capacity 1]; memcpy(m_data, other.m_data, m_size); m_data[m_size] \0; }注意这里不能直接把other.m_data赋值给m_data那叫浅拷贝。稍后析构时两个对象的delete[]会作用于同一地址直接触发double free崩溃。3. 核心细节解析与实操要点3.1 析构函数与内存释放的黄金准则析构函数是所有内存管理问题的收口。有人写完构造函数忘了写析构结果类对象每次离开作用域都泄漏一块堆内存有人写了析构但漏了[]变成delete m_data而不是delete[] m_data这是未定义行为后果可能是内存泄漏、堆损坏甚至在某些编译器下直接崩溃。正确的析构函数只有三行~hstring() { delete[] m_data; m_data nullptr; }delete[]之后把指针置空能有效避免“悬空指针”带来的二次释放和非法访问。虽然对象析构后成员变量已经不可访问但这是一个好习惯调试程序时看到指针是nullptr而不是野地址能省下大量排查时间。3.2 拷贝赋值运算符最能暴露水平的4个陷阱拷贝赋值运算符operator是整张测试卷里最阴险的一题。正常写法分四步检查自赋值 → 释放旧资源 → 分配新内存 → 拷贝数据。这四步顺序错一步要么泄漏内存要么自赋值崩溃要么异常安全出问题。先说不做自赋值检查的下场。hstr hstr这种代码在业务逻辑里很少手写但出现在容器排序、算法复制等场景时很隐蔽。如果不检查第一步就会把m_data释放掉接着再把这个已经释放掉的指针当作源数据去拷贝读到的全是垃圾甚至直接段错误。hstring operator(const hstring other) { if (this other) { return *this; } delete[] m_data; m_size other.m_size; m_capacity other.m_capacity; m_data new char[m_capacity 1]; memcpy(m_data, other.m_data, m_size); m_data[m_size] \0; return *this; }这个版本能跑但异常安全问题没解决。如果new char[]抛出异常内存不足m_data已经被释放了对象处于一个既没有数据也没有合法指针的“半死”状态。更稳妥的做法是用copy-and-swap技术先基于other创建一个临时对象交换地址后再由临时对象负责释放旧内存。这个方案对新手稍微难理解但面试时能写出来是妥妥的加分项。3.3 深拷贝 vs 浅拷贝用现实类比彻底讲透深拷贝和浅拷贝的概念我习惯用“租房子”来类比。如果你有一套房子堆内存把钥匙指针直接给别人一把两人一起住任何一个人改动了房间布置修改字符串内容另一个人看到的样子也变了一个人退租析构时把房子炸了另一个回来发现房子没了。这就是浅拷贝共享同一块内存生命周期不受控。深拷贝则是你重新租了一套一模一样的房子给另一把独立的钥匙。两套房子互不干扰各自退租谁也不影响谁。hstring的拷贝构造函数和拷贝赋值运算符本质上就是在“重新租房子”为每个对象分配独立的内存并把数据抄过去。4. 实操过程与核心环节实现4.1 基础框架搭建头文件、成员变量与构造析构一步到位下面给出一个可以直接编译运行的hstring基础框架我会在注释里标注每个关键决策的原因。#include iostream #include cstring class hstring { private: char* m_data; size_t m_size; size_t m_capacity; public: // 默认构造确保 m_data 永远不是 nullptr hstring() : m_data(new char[1]), m_size(0), m_capacity(0) { m_data[0] \0; } // 从 C 字符串构造 hstring(const char* ptr) { if (ptr nullptr) { m_data new char[1]; m_data[0] \0; m_size 0; m_capacity 0; return; } m_size strlen(ptr); m_capacity m_size; m_data new char[m_capacity 1]; strcpy(m_data, ptr); } // 拷贝构造深拷贝 hstring(const hstring other) : m_size(other.m_size), m_capacity(other.m_capacity) { m_data new char[m_capacity 1]; strcpy(m_data, other.m_data); } // 析构释放堆内存 ~hstring() { delete[] m_data; m_data nullptr; } // 获取长度 size_t size() const { return m_size; } size_t length() const { return m_size; } size_t capacity() const { return m_capacity; } // C 风格字符串 const char* c_str() const { return m_data; } // 清空字符串 void clear() { m_size 0; m_data[0] \0; } // 判断是否为空 bool empty() const { return m_size 0; } };这个框架已经能完成基本的构造、析构、长度查询、清空操作。你可以在main函数里创建一个对象打印size()和c_str()验证结果。接下来逐步添加运算符重载和常用方法。4.2 运算符重载operator、operator[]、operator、operator运算符重载是《21天学通C》里比较靠后的章节但手写string类时它们是“让hstring用起来像内置类型”的关键。输出运算符operator。需要声明为友元函数因为它要访问m_data私有成员friend std::ostream operator(std::ostream os, const hstring str) { os str.m_data; return os; }下标运算符operator[]。这是最常见的字符串访问方式。注意要同时提供const和非const两个版本非const版本返回引用允许修改单个字符const版本返回const引用防止通过常量对象修改内容char operator[](size_t index) { return m_data[index]; } const char operator[](size_t index) const { return m_data[index]; }下标运算符的边界检查可以不做因为std::string的operator[]也不做检查性能优先。如果希望安全访问可以额外提供at()方法越界时抛出异常。拼接运算符operator。注意它必须返回新对象不修改任何一方hstring operator(const hstring lhs, const hstring rhs) { hstring result; result.m_size lhs.m_size rhs.m_size; result.m_capacity result.m_size; result.m_data new char[result.m_capacity 1]; strcpy(result.m_data, lhs.m_data); strcat(result.m_data, rhs.m_data); return result; }比较运算符operator和operator!。底层直接调strcmpbool operator(const hstring lhs, const hstring rhs) { return strcmp(lhs.m_data, rhs.m_data) 0; } bool operator!(const hstring lhs, const hstring rhs) { return !(lhs rhs); }提示在main里用std::cout hstr时如果不重载operator编译器会报“没有匹配的运算符”。这是C不支持自定义类型直接输出的必然结果也是为什么要费劲写运算符重载的原因。4.3 常用方法实现append、find、insert、substr一个只有构造和析构的string类充其量算个字符数组包装器。要真正靠近std::string高频方法还必须补上。我不建议一次实现几十个方法但append、find、substr这三个是核心。append追加字符串核心是容量管理。追加前先判断容量是否足够不够就扩容。扩容倍数我选用2倍增长和常见的std::vector扩容策略一致均摊时间复杂度为O(1)void append(const char* ptr) { if (ptr nullptr) return; size_t addLen strlen(ptr); size_t newSize m_size addLen; if (newSize m_capacity) { reserve(newSize * 2); } memcpy(m_data m_size, ptr, addLen); m_size newSize; m_data[m_size] \0; } void reserve(size_t newCapacity) { if (newCapacity m_capacity) return; char* newData new char[newCapacity 1]; if (m_data ! nullptr) { memcpy(newData, m_data, m_size); delete[] m_data; } m_data newData; m_capacity newCapacity; }reserve单独封装是一个好习惯后续insert、operator都要复用这套扩容逻辑避免到处重复分配内存。find查找子串第一次出现的位置。朴素实现用两层循环第一层遍历主串的起始位置第二层比对子串。时间复杂度O(n*m)性能一般但作为学习目的完全够用。面试官通常不会在意这个查找的效率更在意边界条件的处理size_t find(const char* ptr, size_t pos 0) const { if (ptr nullptr || pos m_size) return npos; size_t needleLen strlen(ptr); if (needleLen 0) return pos; if (pos needleLen m_size) return npos; for (size_t i pos; i m_size - needleLen; i) { size_t j 0; while (j needleLen m_data[i j] ptr[j]) { j; } if (j needleLen) return i; } return npos; } static const size_t npos -1;注意npos定义为static const size_t npos -1利用无符号类型的回绕得到size_t的最大值表示“未找到”。这是C标准库的惯例面试时能解释清楚这行代码的意图会显得你不是背代码而是懂原理。substr截取子串。注意边界控制越界时要么抛出异常要么截断到末尾。简单起见先做越界截断处理hstring substr(size_t pos, size_t count npos) const { if (pos m_size) return hstring(); size_t realCount count; if (realCount npos || pos realCount m_size) { realCount m_size - pos; } hstring result; result.m_size realCount; result.m_capacity realCount; result.m_data new char[result.m_capacity 1]; memcpy(result.m_data, m_data pos, realCount); result.m_data[result.m_capacity] \0; return result; }substr的返回值是值类型会触发拷贝构造或移动构造。如果只写了拷贝构造编译器会用拷贝构造完成返回值优化之外的部分。这一步是理解“什么时候调用拷贝构造”的绝佳示例。5. 常见问题与排查技巧实录5.1 崩溃场景double free、越界写入与悬空指针手写string类最常见的崩溃是double free。现象是程序退出时调用析构函数报出free(): double free detected in tcache 2。排查思路很清晰先查所有复制操作看看哪些对象共享了同一块m_data内存。最容易出问题的是拷贝赋值运算符没写编译器生成的默认版本就是浅拷贝两个对象的m_data指向同一块地址析构时二连杀。第二个常见崩溃是越界写入。char m_data[100]是很多人最初的选择一旦字符串长度超过99strcpy会写穿缓冲区轻则覆盖相邻对象的数据重则段错误。就算换成动态内存如果忘记给结尾的\0预留空间strcpy时也会多写一个字节。所有涉及分配的代码都要检查“是否分配了m_capacity 1的空间”。第三个是悬空指针。m_data被delete[]后没有置空后续代码再访问它读到的可能是任意值。排查这类问题建议写一个简单的printState()调试方法在每个构造、析构、赋值后打印m_data、m_size、m_capacityvoid printState(const char* tag) const { std::cout tag : data (m_data ? m_data : (null)) , size m_size , cap m_capacity std::endl; }每次操作后调一下哪个环节出了问题一眼就能定位。5.2 逻辑诡异自赋值时字符全没了自赋值的bug极其隐蔽。假设你写了这样的代码hstring operator(const hstring other) { delete[] m_data; m_data new char[other.m_capacity 1]; strcpy(m_data, other.m_data); return *this; }这版本没检查自赋值。当执行hstr hstr时第一步就把m_data释放了而other.m_data和this-m_data是同一块内存。第二步strcpy时other.m_data已经是悬空指针读到的内容全是垃圾。最终hstr变成了乱码。排查这类问题在赋值运算符第一行加if (this other) return *this;就能根治。5.3 性能陷阱频繁拼接时反复分配内存很多人的第一个hstring版本append每次都delete[]旧内存再new[]新内存。测试时拼接1000次短字符串程序肉眼可见地卡。这不是C慢是分配策略写得差。堆内存分配的开销远超字符拷贝减少分配次数就能显著提升性能。解决方案就是前面写的reserve函数。拼接前预估最终长度提前分配好容量hstring s; s.reserve(10000); // 提前预留 1 万字符的空间 for (int i 0; i 1000; i) { s.append(hello); }实测下来reserve之后拼接1000次的时间比不预留快了接近一个数量级。这个优化思路在面试中聊到“为什么std::string性能好”时是很好的加分论据。5.4 速查表问题、原因与解决方案症状根本原因解决方案程序退出时double free多个对象共享同一块堆内存浅拷贝实现深拷贝构造和赋值运算符打印乱码或带多余字符忘记在末尾写\0越界读取所有操作后手动补\0赋值后字符串变短或变空自赋值时先释放了源数据赋值运算符开头检查this other拼接大量字符时越来越慢每次append都重新分配内存实现reserve和2倍容量增长策略传入nullptr给构造函数直接崩溃没有处理空指针分支构造函数中增加ptr nullptr判断编译报错没有匹配的operator未重载输出运算符实现友元函数operator6. 从hstring到面试加分项再到真正理解C内存模型6.1 手写string类在面试中的考点分布手写string算得上C面试的经典题目常出现在二面或三面的手撕代码环节。面试官让你写“一个简化版string类”表面考的是代码能力实际想从三个维度试探你的水平第一个维度是基本功。构造函数、析构函数、拷贝构造、赋值运算符这四大件能不能独立写对。很多候选人能背出“深拷贝”三个字但动手写时忘记自赋值检查、忘记delete[]、忘记返回*this任何一处都是送命题。第二个维度是性能意识。写完基础版本后面试官大概率会追问“如何优化”。这就需要你主动提到容量预留、移动语义、reserve扩容策略、const引用传参。这个追问环节是拉开差距的地方也是前面几个H2里强调的细节起作用的时刻。第三个维度是C11/14/17新特性。如果面试官要求用现代C重写就涉及移动构造、移动赋值、noexcept声明。下面是移动构造的参考实现// 移动构造直接接管 right 的资源不再复制 hstring(hstring other) noexcept : m_data(other.m_data), m_size(other.m_size), m_capacity(other.m_capacity) { other.m_data nullptr; other.m_size 0; other.m_capacity 0; } // 移动赋值 hstring operator(hstring other) noexcept { if (this other) return *this; delete[] m_data; m_data other.m_data; m_size other.m_size; m_capacity other.m_capacity; other.m_data nullptr; other.m_size 0; other.m_capacity 0; return *this; }移动语义的精髓在于“偷”而不是“拷贝”。当右值对象马上要销毁时直接拿走它的指针和长度再把源对象置空成本几乎为零。用C11标准编译时返回临时对象、表达式求值等场景会自动挑中移动构造而不是更昂贵的拷贝构造。6.2 hstring能跑通之后下一步练什么写完hstring并且各种边界测试都过了你就把《21天学通C》中类的核心语法吃掉了八成。但学习不能停在这里我给三个明确的进阶方向。第一个进阶是给hstring添加迭代器支持。不用完全仿照标准库实现一套完整的迭代器体系只需要定义begin()和end()方法返回char*和const char*就能让hstring被基于范围的for循环使用char* begin() { return m_data; } char* end() { return m_data m_size; } const char* begin() const { return m_data; } const char* end() const { return m_data m_size; }第二个进阶是用std::vectorchar替换裸指针作为底层存储。这样内存管理交给标准库hstring反而更容易写对还能顺便练习“如何封装一个底层结构”。第三个进阶是参考std::pmr::string实现一个支持自定义分配器的hstring版本。这一步比较深但对理解C分配器模型极有帮助。不想一步到位的话至少可以在reserve里用std::make_uniquechar[]替代裸new至少在异常安全上比裸指针强不少。6.3 关于《21天学通C》的学习建议与节奏把控这本书的名字很容易让人产生焦虑以为21天就能精通C。实际学下来你会发现到第14天能完整写出hstring并跑通所有自测就已经达到预期了。C语法庞杂模板、STL、并发、网络编程这些内容远不是21天能覆盖的所以阶段性测试题的意义就是让你在一道综合题里检验旧知识再带着问题去学新知识。我个人体会最深的是手写hstring这一周效率远高于之前机械地敲示例代码。因为你知道自己要实现哪些功能才会主动去查阅std::string的文档、对比接口设计、思考为什么标准库要这么设计。这种“为了解决问题而学习”的路径比按部就班翻书扎实得多。最后再分享一个小技巧写完hstring之后把代码里的运算符重载逐个改成不重载的写法看看调用处的代码会变成什么鬼样子你会瞬间理解运算符重载存在的意义。这个体验比背十遍“运算符重载可以让自定义类型像内置类型一样使用”都有用。