解析KString源码:CString引用计数与写时复制实现
发布时间:2026/9/15 22:17:22 作者:尧图编辑部 阅读量:1,286

简介KString 类源码包是一份面向 C 开发者的非 MFC 字符串类实现适合在无法依赖 MFC 的项目中仍需要类似 CString 那样丰富字符串操作能力的场景。它提供构造、复制、拼接、查找、替换、比较、取子串等常见方法并兼顾动态内存分配、深拷贝保证与各类边界情况的处理整体设计注重可移植性和易用性。压缩包体积仅 4KB共包含 3 个文件KString.h 用于类声明与接口定义KString.cpp 包含全部功能实现另有 txt 文本对源码或出处进行补充说明结构十分精简。当前已有 229 人学习特别适合希望透过完整代码理解字符串类封装、内存管理、运算符重载和异常机制的 C 学习者。研读这份源码既能掌握仿 CString 字符串类的设计脉络也能将这些技巧直接迁移到自己的工程中是很实用的参考素材。1. 字符串类 KString 和 CString 之间是什么关系为什么这个源码包值得拆一遍搜到KString_class_src.zip的读者一般是从课程设计、面试手写题或老代码迁移路过这里。CString 是 MFC 里最值得单独实现的一个组件它把动态内存、引用计数和大量文本 API 收在同一个类型里。KString 是不依赖 MFC 的自实现字符串类的常见名字其目标通常就是“在纯 C 项目里复刻 CString 的语义”。要读懂这个类先要回答三个问题内存怎么分配、对象怎么拷贝、接口怎么对齐。下面按 CString 的模型拆出一个最小可用的 KString最后一章再聊调试器怎么验证内存正确性。2. KString 的内存模型CString 的引用计数和深拷贝怎么定夺2.1 CString 的缓冲区布局一个指针如何同时表示内容和持有方式实际 MFC 的 CString 对象只有一个成员const char* m_pszData宽字符版本就是wchar_t*。真正分配出来的内存块是“头部 字符区 结尾\0”三合一。头部记录三个字段引用计数nRefs、当前长度nDataLength、分配容量nAllocLength。nRefs是 1 时表示独占大于 1 时说明多个字符串对象正在共享同一块缓冲区。这种布局最大的好处是读操作零成本整个字符串内容可以通过一个指针直接传给 C 接口写操作通过“先看 nRefs再决定复制还是共享”统一起来。很多新写的字符串类为了简单直接放弃共享采用std::string的 SSO 或者深拷贝。要说清楚怎么选得把堆分配的成本算明白。[ nRefs ][ nDataLength ][ nAllocLength ][ H e l l o \0 ] ^ 头部结构 ^ 字符区起点也就是 m_pszData短字符串用 MFC 风格布局一串超过 64 字节的字符串就是一次堆分配如果业务里大多是长度 16 字节以内的短串会更适合std::string的 SSO。可这个权衡在KString_class_src.zip这类源码包里设计者的思路通常已经被 CString 带走类里只存字符指针头部和数据一体连坐分配。2.2 先定义最小可编译的 KString 结构往下写代码前把类的声明先定下来struct KStringData { long nRefs; // 共享计数 size_t nDataLength; // 字符串有效字符数不含结尾 \0 size_t nAllocLength; // 缓冲区可容纳的字符数不含结尾 \0 }; class KString { public: KString(); explicit KString(const char* ps); KString(const KString other); ~KString(); KString operator(const KString other); size_t GetLength() const { return DataOf(m_pszData)-nDataLength; } const char* GetString() const { return m_pszData; } private: char* m_pszData; // 永远指向字符区起点 void Init(const char* ps, size_t len); static KStringData* DataOf(char* p) { return reinterpret_castKStringData*(p) - 1; } };DataOf是所有“从内容反查头部”操作的公共入口。我把它写成静态函数放在类里各成员函数反复调用后面写调试断言、写 Natvis 可视化逻辑时也能复用。注意GetString()不加const_cast它永远只读。Init实现如下void KString::Init(const char* ps, size_t len) { char* block static_castchar*( ::operator new(sizeof(KStringData) len 1)); KStringData* d reinterpret_castKStringData*(block); d-nRefs 1; d-nDataLength len; d-nAllocLength len; m_pszData block sizeof(KStringData); if (ps len) ::memcpy(m_pszData, ps, len); m_pszData[len] \0; }len必须提前由调用方算好构造函数里传strlen(ps)即可不要在Init里重复量长度否则构造链上两次扫描字符串长文本开销翻倍。::operator new不触发构造函数避免在原始内存上多放无用的对象释放时要对应::operator delete(d)。这里最关键的是m_pszData[len] \0它保证外部 C 接口按%s读取时不会越界。2.3 深拷贝、引用计数 COW 怎么选这不是性能洁癖深拷贝的赋值是 O(n) 的整段memcpy引用计数的赋值是 O(1) 的“指针加一”。CString 选的是引用计数也把这个选择带进了 KString 这类源码包。用表格把三条路线摆在一起候选方案赋值成本写共享块时多线程实现代价深拷贝O(n)不需要特殊处理安全低引用计数 COWO(1)先复制再写nRefs需原子化中移动语义 深拷贝移动 O(1)同深拷贝安全中从源码阅读角度看KString 选 COW 是在向 CString 对齐选深拷贝则说明作者只要 CString 的 API不要 CString 的内存模型。实际项目里我一般不盲目抄 COW字符串以读取为主时 COW 收益明显到处 Append 拼接时 COW 反而每次写都多一次分离开销不如深拷贝加reserve。COW 实现里写前第一步永远是检查DataOf(m_pszData)-nRefs。这个动作我习惯单独抽成一个成员函数char* KString::GetBufferForWrite() { KStringData* d DataOf(m_pszData); if (d-nRefs 1) { KString copy(m_pszData); // 用拷贝构造得到独立副本 DetachBySwap(copy); // 把独立副本换进 this旧引用减一 } return m_pszData; }判断写成nRefs 1而不是! 1是为了防止对象处于计数为 0 的悬空态时误判。要让这函数在多线程下安全nRefs应换成std::atomiclong自增自减对应改成fetch_add和fetch_sub。DetachBySwap内部一般就是用swap或「先建后拆」完成所有权交接下一章展开。3. KString 的拷贝控制与字符串比较构造、赋值、 放在同一张桌上3.1 构造与析构引用计数从 1 到 0 的完整回路空串也必须有一个有效缓冲区否则GetString()返回空指针会让printf(%s)直接崩溃。构造函数统一走Init空串长度 0但字符区有结尾\0。KString::KString() { Init(nullptr, 0); // 空串也有独立缓冲 } KString::KString(const KString other) : m_pszData(other.m_pszData) { DataOf(m_pszData)-nRefs; // 共享只加引用不复制内容 } KString::~KString() { KStringData* d DataOf(m_pszData); if (--d-nRefs 0) { ::operator delete(d); // 最后一个持有者释放整块内存 } m_pszData nullptr; }拷贝构造只搬指针再对引用计数加一。析构把计数减一减到 0 才释放整块头部和字符区。把m_pszData置空是防御性写法避免析构后指针被误用。提示另一种做法是把所有空串合并成同一个全局空串块引用计数用特殊值标记减少空串反复分配。教学代码里不推荐因为会引入全局变量初始化顺序问题。3.2 赋值运算符先建新块再拆旧块赋值运算符的难点是两个对象可能指向同一块以及异常安全。下面用“先递增新引用后递减旧引用”的顺序从根上避开自赋值判断造成的数据不一致KString KString::operator(const KString other) { if (this other) return *this; KStringData* nd DataOf(other.m_pszData); KStringData* od DataOf(m_pszData); nd-nRefs; // 先持有新块 if (--od-nRefs 0) ::operator delete(od); // 旧块无人持有再释放 m_pszData other.m_pszData; return *this; }即使this和other指向同一个缓冲区先加后减的结果也正确计数先到 2 再到 1而不是先减到 0 再踩空指针。如果旧块和新块不是同一块减到 0 才释放符合预期。从const char*赋值也可以复用这个逻辑KString KString::operator(const char* ps) { size_t len strlen(ps ? ps : ); KString temp(ps, len); // 构造临时对象 *this temp; // 复用共享赋值 return *this; }temp的生命周期只存在这个函数里赋值后立刻析构不会多占引用。如果传入的ps正好指向m_pszData内部比如s s.GetString() 1这种表达式临时对象先构造原对象还没被改行为稳定。3.3 比较是否相同把相等、排序、大小写的边界划清网上搜“c cstring 比较是否相同”要找的其实是operator。但字符串类的比较要拆开看才能写好相等比较、顺序比较、忽略大小写比较语义完全不同。bool operator(const KString a, const KString b) { if (a.m_pszData b.m_pszData) return true; // 同一块缓冲区内容必然相同 if (a.GetLength() ! b.GetLength()) return false; return memcmp(a.m_pszData, b.m_pszData, a.GetLength()) 0; } int KString::Compare(const KString other) const { size_t minLen (GetLength() other.GetLength()) ? GetLength() : other.GetLength(); int r memcmp(m_pszData, other.m_pszData, minLen); if (r ! 0) return r; if (GetLength() other.GetLength()) return -1; if (GetLength() other.GetLength()) return 1; return 0; }Compare先按共同长度做memcmp相等再用长度分大小。这避免strcmp遇到中部\0就停止的问题也避免两个只有前缀相同的字符串被误判成相等。CString 的底层不比较引用计数只比较内容和长度。接口大小写长度参与返回使用场景operator区分是bool判断两个字符串内容是否相同Compare区分是0 / 0 / 0排序、字典序CompareNoCase不区分是0 / 0 / 0用户名匹配、配置项比较CompareNoCase的循环实现如下注意只对 ASCII 字母转大小写扩展字符要用查表法int KString::CompareNoCase(const KString other) const { size_t minLen (GetLength() other.GetLength()) ? GetLength() : other.GetLength(); for (size_t i 0; i minLen; i) { int a ToLower(m_pszData[i]); int b ToLower(other.m_pszData[i]); if (a ! b) return a b ? -1 : 1; } return GetLength() other.GetLength() ? 0 : GetLength() other.GetLength() ? -1 : 1; }ToLower只处理A到Z的区间其余原样返回。需要本地化比较时Windows 上可以直接换成CompareStringOrdinal或 C 库的_stricmp但那种写法会把控制权交给操作系统跨平台项目要再封装一层。4. 把 CString 的常用接口迁移到 KStringFormat、Find、Left、Mid 逐个落地4.1 Format 实现两段式测量与容量预留CString 的Format和sprintf家族最大的区别是它自己管理输出缓冲。KString 不能假设缓冲区够大只能先测量再写入否则格式化长数字串时必炸。void KString::Format(const char* fmt, ...) { va_list args; va_start(args, fmt); int need _vscprintf(fmt, args); // 第一次测量返回需要的字符数 if (need 0) { va_end(args); *this KString(); return; } char* buf GetBufferForWrite((size_t)need); vsnprintf(buf, (size_t)need 1, fmt, args); va_end(args); SetLength((size_t)need); // 只更新长度不再扫描 }逻辑说明_vscprintf是 Windows CRT 提供的函数只计算需要的字符数不写缓冲。need是格式化结果长度vsnprintf的第二个参数必须传need 1给结尾\0留位差一位结果就会被截断。参数说明GetBufferForWrite内部要同时处理“引用计数大于 1 时先复制”和“容量不足时扩容”两条分支。跨平台代码里可以把_vscprintf换成vsnprintf(nullptr, 0, ...)两者算出同一结果我一般把这一层再包一个MeasureFormatLength(fmt, args)函数方便按平台切换。4.2 子串截取Left、Right、Mid 的边界条件不能靠 if 堆子串函数是字符串类的门面最容易翻车的不是功能而是size_t无符号溢出。C 里first count可能越界必须先做前置判断。KString KString::Mid(size_t first, size_t count) const { if (first GetLength()) return KString(); size_t remain GetLength() - first; size_t n (count remain) ? remain : count; return KString(m_pszData first, n); } KString KString::Left(size_t count) const { size_t n (count GetLength()) ? count : GetLength(); return KString(m_pszData, n); } KString KString::Right(size_t count) const { size_t n (count GetLength()) ? count : GetLength(); return KString(m_pszData GetLength() - n, n); }参数说明Mid先判first GetLength()再用remain收口保证m_pszData first不会越过结尾。Right的顺序更不能反先确定有效长度n再做GetLength() - n此时减法不会下溢。提示有人会对照《Java 程序设计基础教程》里对字符串、数组、日期类的讲法来理解 C 字符串类。Java 有 JVM 兜底越界直接抛异常C 里size_t溢出是静默的所以子串边界必须自己用判断包住。4.3 查找接口Find、ReverseFind 的入参和返回值int KString::Find(char ch, int start) const { if (start 0) start 0; if ((size_t)start GetLength()) return -1; const char* p (const char*)memchr( m_pszData start, ch, GetLength() - start); return p ? (int)(p - m_pszData) : -1; } int KString::Find(const KString sub, int start) const { if (sub.GetLength() 0) return start 0 ? start : 0; if (sub.GetLength() GetLength() - (start 0 ? start : 0)) return -1; for (size_t i (start 0 ? (size_t)start : 0); i sub.GetLength() GetLength(); i) { if (memcmp(m_pszData i, sub.m_pszData, sub.GetLength()) 0) return (int)i; } return -1; } int KString::ReverseFind(char ch) const { for (size_t i GetLength(); i 0; --i) { if (m_pszData[i - 1] ch) return (int)(i - 1); } return -1; }逻辑说明单字符查找用memchr是标准做法编译器会把它优化成 SIMD 指令子串查找这里用朴素匹配是为了把边界条件说清楚长文本项目建议换成 KMP 或std::search。函数签名start0start 越界空子串找到时找不到Find(char, int)从 0 开始返回 -1—返回 0 起下标-1Find(KString, int)从 0 开始返回 -1返回 start 或 0返回 0 起下标-1ReverseFind(char)———返回 0 起下标-1注意Find的子串空串语义是 CString 对齐的空串永远匹配返回起始位置。ReverseFind只支持单个字符这和std::string::rfind的子串重载不是一回事别搞混。4.4 与 C API 和 std::string 互操作GetBuffer/ReleaseBuffer 的收尾细节在需要直接写缓冲区的场景比如文件读取、socket recv、注册表查询只读的GetString()不够用。KString 按 CString 惯例提供GetBuffer和ReleaseBuffer。char* KString::GetBuffer(size_t minLen) { if (minLen 16) minLen 16; KStringData* d DataOf(m_pszData); if (d-nAllocLength minLen || d-nRefs 1) Reserve(minLen); return m_pszData; } void KString::ReleaseBuffer(size_t newLen) { KStringData* d DataOf(m_pszData); if (newLen (size_t)-1) newLen strlen(m_pszData); d-nDataLength newLen; m_pszData[newLen] \0; }参数说明minLen不包含结尾\0。ReleaseBuffer的newLen传 -1 时函数自己用strlen量长度如果外部 API 写入了中间带\0的二进制内容必须手传实际长度否则长度会被截断。和std::string互操作其实只要两个函数std::string KString::ToStdString() const { return std::string(m_pszData, GetLength()); } KString::KString(const std::string s) : m_pszData(nullptr) { Init(s.data(), s.size()); }转回std::string时用“指针 长度”的双参构造函数避免第二次strlen扫描。5. KString 调试体检Natvis 可视化、引用计数检查和“无效的类字符串”排查把 KString 接进一个真实服务后调试器里看到的往往不是Hello而是m_pszData的裸地址。字符串类没有可视化排查问题等于半盲。先做三件事给对象挂上可视化验证引用计数再确认结尾字符。提示Visual Studio 监视窗口里直接输入m_pszData,su可以作为 Unicode 字符串查看输入m_pszData,s可以作为 ANSI 字符串查看。这是零配置的临时方案要长期好用就写一个 Natvis 文件。Natvis 项目文件通常命名为KString.natvis加入项目后随编译生效?xml version1.0 encodingutf-8? AutoVisualizer xmlnshttp://schemas.microsoft.com/vstudio/debugger/natvis/2010 Type NameKString DisplayString{(char*)m_pszData}/DisplayString Expand Item Name[length]((KStringData*)(m_pszData - sizeof(KStringData)))-nDataLength/Item Item Name[refs]((KStringData*)(m_pszData - sizeof(KStringData)))-nRefs/Item /Expand /Type /AutoVisualizer注意 Natvis 的表达式不能调用DataOf这种自定义函数必须把m_pszData - sizeof(KStringData)这个指针回退原样写进表达式。调试器不加载你写的代码它只解析 XML 表达式。排查“无效的类字符串”这类日志时核心步骤是判断“坏的是数据还是类指针本身”。这种报错通常是某个组件拿到一个被错误强转的对象指针然后当字符串类调用字符串数据本身可能没问题。对应到 KString 上先确认m_pszData是不是合法堆地址再看DataOf(m_pszData)-nRefs是不是合理范围内的 1 或 2最后看nDataLength是否等于strlen(GetString())。三步下来绝大部分问题都出在第二步引用计数被写坏说明越界写发生在头部附近不是字符串尾部。验证写时复制是否生效可以在断点处做两个断言KString a shared; KString b a; // 此刻 nRefs 2 b !; // 写操作应该先分离 assert(((KStringData*)(b.m_pszData - sizeof(KStringData)))-nRefs 1); assert(((KStringData*)(a.m_pszData - sizeof(KStringData)))-nRefs 1);这两个断言能拦住大多数“改了 b 结果 a 也跟着变”的回归。最后一处检查放在ReleaseBuffer之后监视nDataLength和m_pszData[nDataLength]两个表达式后者必须是\0。把这一条写进每次ReleaseBuffer后的断言比跑两百个用例都更能保住字符串类的二进制安全底线。本文还有配套的精品资源点击获取