从零实现C++ String类:深入理解RAII、拷贝控制与内存管理

📅 2026/7/27 22:13:40
从零实现C++ String类:深入理解RAII、拷贝控制与内存管理
1. 项目概述为什么我们要亲手实现一个String在C的世界里std::string大概是每个开发者最熟悉、使用频率最高的类之一了。从简单的文本拼接、日志输出到复杂的解析算法它无处不在。正因为如此很多面试官也喜欢把它作为考察候选人C基本功的“试金石”。你可能已经熟练使用了它的find、substr、等接口但你是否思考过当你写下std::string s “hello”;时内存里究竟发生了什么s “ world”;这个操作背后又隐藏着怎样的内存分配与拷贝逻辑这就是我们今天要做的抛开标准库这层“黑盒”从零开始模拟实现一个我们自己的String类。这绝不是一个简单的玩具练习。通过这个过程你将深刻理解RAII资源获取即初始化原则如何安全地管理动态内存防止内存泄漏。拷贝控制成员三/五法则拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数它们是如何协同工作保证类在拷贝、赋值时行为正确的。这是C面向对象编程的核心难点之一。运算符重载如何让自定义类型像内置类型一样支持、、等直观操作。迭代器设计如何为你的容器提供类似指针的访问方式使其能与标准算法库无缝衔接。我见过太多简历上写着“精通C”的候选人在被要求在白板上实现一个简单的String类时漏洞百出。内存泄漏、浅拷贝导致的重复释放、自我赋值问题、异常安全性……每一个坑都可能让程序崩溃。所以这篇“保姆级”教程我会带你一步步走过这些雷区不仅给出代码更会解释每一行代码背后的设计意图和潜在风险。当你真正吃透了这个实现你对C面向对象和资源管理的理解会上一个大台阶。2. 核心设计思路与类框架在动手写代码之前我们必须先想清楚我们的String类需要什么样的数据成员以及它应该提供哪些最基本的接口。一个最基础的、模仿std::string的类其核心就是管理一段动态分配的、以\0结尾的字符数组。2.1 数据成员与基础接口设计我们的String类至少需要两个数据成员char* m_data: 一个指针指向在堆上分配的、存储字符串内容的字符数组。size_t m_size: 记录当前字符串的实际长度不包括末尾的\0。可选size_t m_capacity: 记录当前已分配内存的总容量。引入它可以优化频繁追加操作避免每次push_back都重新分配内存。为了更贴近std::string的常见实现我们这里选择加入容量概念。基于此我们可以规划出类的骨架class String { public: // 构造函数们 String(); // 默认构造空字符串 String(const char* cstr); // 从C风格字符串构造 String(const String other); // 拷贝构造 String(String other) noexcept; // 移动构造 (C11) // 析构函数 ~String(); // 赋值运算符们 String operator(const String other); // 拷贝赋值 String operator(String other) noexcept; // 移动赋值 (C11) String operator(const char* cstr); // 从C字符串赋值 // 容量相关 size_t size() const { return m_size; } size_t capacity() const { return m_capacity; } bool empty() const { return m_size 0; } void reserve(size_t new_cap); // 预留空间 void shrink_to_fit(); // 收缩到合适大小 // 元素访问 char operator[](size_t pos); const char operator[](size_t pos) const; char at(size_t pos); // 带边界检查 const char at(size_t pos) const; const char* c_str() const { return m_data ? m_data : ; } const char* data() const { return m_data ? m_data : ; } // 修改操作 void push_back(char ch); void append(const char* str, size_t count); String operator(const String rhs); String operator(const char* str); void clear(); // ... 其他如 insert, erase, find 等 // 迭代器支持 (简化版原生指针) using iterator char*; using const_iterator const char*; iterator begin() { return m_data; } const_iterator begin() const { return m_data; } iterator end() { return m_data m_size; } const_iterator end() const { return m_data m_size; } // 非成员函数友元声明 friend std::ostream operator(std::ostream os, const String str); friend bool operator(const String lhs, const String rhs); friend String operator(const String lhs, const String rhs); private: char* m_data nullptr; // 指向堆内存的指针 size_t m_size 0; // 当前字符串长度 size_t m_capacity 0; // 当前分配的内存容量可容纳字符数不含\0 };设计要点解析m_data初始化为nullptr这很重要。它使得默认构造函数可以简单地设置指针为空并在析构时安全地delete[]delete[] nullptr是安全的。提供const和非const版本像operator[]、at、begin/end这些接口必须提供const版本以保证const String对象也能被安全访问。迭代器简化对于String这种连续内存容器原生指针完全满足随机访问迭代器的所有要求所以直接使用char*作为迭代器类型是最简单高效的。c_str()和data()在C11之后data()返回的指针不一定以\0结尾但c_str()保证返回一个以\0结尾的数组。在我们的简单实现中两者可以返回相同的内容。2.2 内存管理策略容量Capacity与大小Size这是实现中的一个关键设计。m_size是字符串的实际长度而m_capacity是我们一次性向操作系统申请的内存大小。当我们执行append或push_back时如果m_size 1需要为新字符和末尾的\0留空间小于等于m_capacity我们就可以直接写入。否则就需要执行一次昂贵的“重分配Reallocation”操作。重分配通常遵循一个增长因子策略比如每次扩容为当前容量的1.5倍或2倍。这是为了在内存使用效率和减少分配次数之间取得平衡。我们选择常见的2倍扩容策略。void String::reserve(size_t new_cap) { if (new_cap m_capacity) return; // 如果请求的容量不大于当前容量什么都不做 // 注意new_cap 是期望的容量我们需要分配 new_cap 1 的空间来存放末尾的\0 size_t new_total new_cap 1; char* new_data new char[new_total]; // 拷贝现有数据 if (m_data) { // 使用 memcpy 比循环拷贝字符效率更高。注意拷贝 m_size1 个字符包含\0 std::memcpy(new_data, m_data, m_size 1); delete[] m_data; // 释放旧内存 } else { // 如果原本是空字符串确保新内存的最后一个字节是\0 new_data[0] \0; } m_data new_data; m_capacity new_cap; // m_size 保持不变 }注意事项reserve只会增加容量不会减少。这是为了保持接口行为与std::string一致避免不必要的性能开销。内存分配 (new char[]) 可能失败并抛出std::bad_alloc异常。在我们的实现中我们让异常自然传播这符合RAII原则如果构造/赋值失败对象应保持原有状态。拷贝数据时使用std::memcpy是安全的因为char是平凡可拷贝类型。如果我们的String未来要支持更复杂的字符类型如wchar_t且涉及非平凡拷贝则需要调整。3. 核心成员函数的实现与陷阱规避有了清晰的框架和内存策略我们现在来实现最关键的几个成员函数构造、析构、拷贝和移动。这部分是C类设计的精髓也是面试中最常被深挖的地方。3.1 构造函数与析构函数默认构造函数应该创建一个有效的空字符串。String::String() : m_data(nullptr), m_size(0), m_capacity(0) { // 实际上我们可以选择分配一个小的初始缓冲区比如 capacity15。 // 许多标准库实现如MSVC的短字符串优化SSO除外会这样做以减少小字符串的分配开销。 // 这里为了简单初始化为空。后续第一次 push_back 时会触发分配。 }从C字符串构造这是最常用的构造函数之一。String::String(const char* cstr) : m_data(nullptr), m_size(0), m_capacity(0) { if (cstr) { m_size std::strlen(cstr); // 计算需要的容量。我们可以直接分配刚好够用的空间也可以多分配一些。 m_capacity m_size; // 初始容量等于大小 if (m_capacity 0) { // 分配 m_capacity 1 的空间 m_data new char[m_capacity 1]; std::memcpy(m_data, cstr, m_size 1); // 拷贝内容及末尾\0 } else { // 如果传入的是空字符串m_size0, 我们可以选择让 m_data 为 nullptr // 或者分配一个字节存放\0。这里选择前者与默认构造函数行为一致。 // m_data 保持 nullptr } } // 如果 cstr 是 nullptr我们选择构造一个空字符串而不是引发未定义行为。 // 这与 std::string 的行为可能不同std::string(nullptr) 是未定义的 // 但作为一个健壮的实现我们可以防御性地处理。 }析构函数释放动态分配的内存。String::~String() { delete[] m_data; // delete[] nullptr 是安全的无需检查 m_data nullptr; // 良好的习惯防止悬空指针但非必须 m_size m_capacity 0; }关键点析构函数必须使用delete[]来匹配构造函数中使用的new char[]。使用delete而非delete[]是未定义行为。3.2 拷贝构造函数与拷贝赋值运算符深拷贝这是实现中最容易出错的部分。默认的拷贝行为是“浅拷贝”即只复制指针的值。如果两个String对象的m_data指向同一块内存那么析构时这块内存会被释放两次导致程序崩溃。拷贝构造函数创建一个新对象其内容与原对象相同。String::String(const String other) : m_data(nullptr), m_size(other.m_size), m_capacity(other.m_size) { // 注意我们使用 other.m_size 作为初始容量刚好够用。 if (m_size 0) { m_data new char[m_capacity 1]; std::memcpy(m_data, other.m_data, m_size 1); } // 如果 other 是空的m_data 保持 nullptr }拷贝赋值运算符比拷贝构造更复杂因为它需要处理目标对象原来可能持有的资源。String String::operator(const String other) { // 1. 防止自我赋值a a; if (this other) { return *this; } // 2. 分配新内存前先保存旧数据为了异常安全 size_t new_size other.m_size; size_t new_cap new_size; // 简单策略容量等于新大小 char* new_data nullptr; if (new_size 0) { new_data new char[new_cap 1]; std::memcpy(new_data, other.m_data, new_size 1); } // 3. 释放旧资源 delete[] m_data; // 4. 接管新资源 m_data new_data; m_size new_size; m_capacity new_cap; return *this; }这就是著名的“拷贝并交换copy-and-swap” idiom 的简化版。更健壮、异常安全的写法是创建一个临时副本然后与当前对象交换。这里展示的是直接版本其关键在于“先分配新资源成功后再释放旧资源”。如果new抛出异常旧资源依然完好对象状态不变满足了强异常安全保证。3.3 移动构造函数与移动赋值运算符C11移动语义是C11引入的重大优化用于避免不必要的深拷贝。它将资源从一个临时对象右值“移动”到新对象代价极低。移动构造函数String::String(String other) noexcept // noexcept 很重要标准库容器在重分配时会使用它 : m_data(other.m_data), m_size(other.m_size), m_capacity(other.m_capacity) { // pilfer others resources other.m_data nullptr; // 关键将源对象置于有效但空的状态 other.m_size 0; other.m_capacity 0; }移动赋值运算符String String::operator(String other) noexcept { // 同样要检查自我赋值虽然移动一个对象到自身不常见但可能发生如 std::swap 的实现 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; }移动操作的核心不分配新内存不拷贝数据只是“窃取”指针并将源对象置为空状态使其析构是安全的。标记为noexcept可以允许标准库容器在扩容时使用移动而非拷贝提升性能。3.4 常用成员函数的实现示例让我们实现几个最常用的修改操作看看它们如何与我们的内存管理策略配合。push_backvoid String::push_back(char ch) { // 检查是否需要扩容。我们需要空间存放新字符和末尾的\0所以需要 m_size 1 1 的空间 // 不对。m_capacity 表示能存储的有效字符数不含\0。 // 当前已用 m_size 个字符末尾还有一个\0占了一个位置。 // 所以剩余空间是 m_capacity - m_size。 // 我们需要至少1个位置来放新字符并且还要在它后面放一个\0。 // 实际上当我们写入新字符到 m_data[m_size] 后需要在 m_data[m_size1] 处写入\0。 // 因此条件应该是 (m_size 1) m_capacity。因为我们需要 m_size1 个有效字符位置。 if (m_size 1 m_capacity) { // 扩容策略如果当前容量为0则分配至少1个空间否则翻倍。 size_t new_cap (m_capacity 0) ? 1 : m_capacity * 2; // 但翻倍后可能仍然不够比如 m_capacity1, m_size1需要2翻倍后是2刚好。 // 确保 new_cap 至少为 m_size 1 if (new_cap m_size 1) { new_cap m_size 1; } reserve(new_cap); } // 现在一定有足够空间 m_data[m_size] ch; m_size; m_data[m_size] \0; // 添加新的结尾符 }appendvoid String::append(const char* str, size_t count) { if (!str || count 0) return; // 计算追加后的新大小 size_t new_size m_size count; if (new_size m_capacity) { // 需要扩容。新容量至少为 new_size size_t new_cap new_size; // 也可以采用更积极的策略比如翻倍后再比较 size_t doubled (m_capacity 0) ? 1 : m_capacity * 2; if (doubled new_cap) { new_cap doubled; } reserve(new_cap); } // 拷贝数据 std::memcpy(m_data m_size, str, count); m_size new_size; m_data[m_size] \0; }operatorString String::operator(const String rhs) { append(rhs.m_data, rhs.m_size); return *this; } String String::operator(const char* str) { if (str) { append(str, std::strlen(str)); } return *this; }4. 运算符重载与友元函数为了让我们的String用起来更自然我们需要重载一些运算符。输出运算符std::ostream operator(std::ostream os, const String str) { if (str.m_data) { os str.m_data; } else { os ; // 输出空字符串 } return os; }比较运算符bool operator(const String lhs, const String rhs) { // 先比较长度长度不同肯定不相等 if (lhs.m_size ! rhs.m_size) return false; // 如果都为空则相等 if (lhs.m_size 0) return true; // 此时两者长度均为0 // 长度相同且不为空比较内容 return std::strcmp(lhs.m_data, rhs.m_data) 0; } // 类似地可以实现 !, , 等运算符加法运算符String operator(const String lhs, const String rhs) { String result; // 创建一个空字符串 result.reserve(lhs.m_size rhs.m_size); // 预分配足够空间 result.append(lhs.m_data, lhs.m_size); result.append(rhs.m_data, rhs.m_size); return result; // 注意这里会触发返回值优化RVO或移动语义避免拷贝 }实现技巧operator通常被实现为非成员函数因为它是对称的。它内部利用了我们已经实现的reserve和append代码复用度高。返回的临时String对象在C11以后如果编译器不能进行RVO也会因为定义了移动构造函数而被移动出去效率很高。5. 迭代器、at()与边界检查迭代器由于我们使用原生指针实现非常简单。// 已经在类定义中声明了别名 // using iterator char*; // using const_iterator const char*; // iterator begin() { return m_data; } // const_iterator begin() const { return m_data; } // iterator end() { return m_data m_size; } // const_iterator end() const { return m_data m_size; }现在你可以像使用标准容器一样使用范围for循环String s hello; for (char ch : s) { std::cout ch; }带边界检查的at()char String::at(size_t pos) { if (pos m_size) { throw std::out_of_range(String::at: pos (which is std::to_string(pos) ) this-size() (which is std::to_string(m_size) )); } return m_data[pos]; } const char String::at(size_t pos) const { // const版本返回const引用 if (pos m_size) { throw std::out_of_range(String::at const); } return m_data[pos]; }operator[]vsat()operator[]通常不进行边界检查以追求最大性能类似数组访问。at()则进行边界检查并在越界时抛出std::out_of_range异常。这是模仿std::string和std::vector的行为。6. 常见问题、调试技巧与性能考量在实现和使用这样一个底层数据结构时会遇到许多典型问题。6.1 内存相关问题排查内存泄漏最可能的原因是析构函数未正确编写如写成了delete而非delete[]或者在拷贝赋值运算符中分配新内存失败抛出异常前释放了旧内存导致旧内存泄漏。使用 Valgrind (Linux/macOS) 或 Visual Studio 的内存诊断工具 (Windows) 可以轻松检测。重复释放Double Free通常由浅拷贝引起。两个对象持有同一个m_data指针析构时各自delete[]一次。确保拷贝构造函数和拷贝赋值运算符执行的是深拷贝。访问越界使用operator[]访问时如果索引 m_size就会读到非法内存或破坏末尾的\0。在调试时可以在operator[]的实现中加入断言assert(pos m_size)来快速发现问题。悬空指针Dangling Pointer移动操作后源对象的m_data被置为nullptr这是正确的。但如果用户之后还去使用这个被移动过的对象例如调用c_str()将得到空指针或空字符串。这是移动语义的约定行为使用者应注意。6.2 自我赋值与异常安全这是拷贝赋值运算符必须处理的两个经典问题。自我赋值a a;。如果不检查代码可能会先delete[] m_data然后试图从other.m_data其实就是刚被释放的同一块内存拷贝数据导致未定义行为。我们的实现通过if (this other)进行了防护。异常安全我们的拷贝赋值实现提供了“基本异常安全保证”如果new失败抛出异常对象的状态旧字符串保持不变。但它不提供“强异常安全保证”即发生异常后对象状态完全不变。使用“拷贝并交换”惯用法可以实现强异常安全String String::operator(const String other) { String temp(other); // 拷贝构造可能抛异常 swap(*this, temp); // 交换不会抛异常 return *this; } // 需要实现一个 swap 成员函数或友元函数 void swap(String a, String b) noexcept { using std::swap; swap(a.m_data, b.m_data); swap(a.m_size, b.m_size); swap(a.m_capacity, b.m_capacity); }6.3 性能优化点短字符串优化SSO这是现代std::string实现如GCC、Clang的libc MSVC普遍采用的技术。对于很短的字符串例如15或22个字符以内直接将其存储在对象内部的缓冲区中避免堆内存分配。这能极大提升小字符串操作的性能。实现SSO会显著增加代码复杂度因为它需要将“本地缓冲区”和“堆指针”两种表示融合在一个联合体union中并根据字符串长度动态切换。扩容因子我们使用了2倍扩容。1.5倍是另一个常见选择例如MSVC的vector。理论上1.5倍扩容在多次重用之前释放的内存块方面更有优势。你可以通过一个成员常量如static constexpr size_t kGrowthFactor 2;来配置它。reserve的明智使用如果你事先知道字符串最终的大致长度提前调用reserve可以避免多次重分配这是提升性能最有效的手段之一。移动语义确保你的移动操作被标记为noexcept。这会让标准库容器如std::vectorString在扩容时使用移动而非拷贝性能差异巨大。6.4 测试你的String类编写全面的测试用例至关重要。你应该测试所有构造函数默认、C字符串、拷贝、移动。所有赋值运算符。边界情况空字符串、nullptr输入、自我赋值。修改操作append,push_back,operator特别是触发扩容的情况。容量操作reserve,shrink_to_fit。迭代器和范围for循环。异常安全在内存分配失败时可以通过自定义operator new模拟程序行为是否合理。一个简单的测试框架示例void test_basic() { String s1; // 默认构造 assert(s1.size() 0); assert(s1.empty()); String s2 hello; // 从C字符串构造 assert(s2.size() 5); assert(std::strcmp(s2.c_str(), hello) 0); String s3 s2; // 拷贝构造 assert(s3 s2); assert(s3.c_str() ! s2.c_str()); // 必须是深拷贝指针不同 s1 s3; // 拷贝赋值 assert(s1 hello); String s4 std::move(s2); // 移动构造 assert(s4 hello); assert(s2.size() 0); // s2 被移空 s1 world; // operator assert(s1 hello world); for (char ch : s1) { ch toupper(ch); } // 迭代器 assert(s1 HELLO WORLD); std::cout All basic tests passed!\n; }亲手实现一个完整的String类是一次绝佳的C学习旅程。它强迫你去思考内存管理、对象生命周期、异常安全和接口设计这些核心议题。虽然我们的实现相比std::string还缺少很多功能如find,replace,substr, 比较运算符全集等但骨架已经搭好你可以以此为蓝图逐步添加更多功能。最终当你看到自己写的String类能和标准库算法、容器流畅配合时那种成就感会让你觉得所有的调试和踩坑都是值得的。