C++ string类模拟实现:从深拷贝到内存管理的完整实践
1. 项目概述为什么我们要亲手“撕”一个string类在C的日常开发里std::string大概是除了int之外我们打交道最多的类了。它封装了字符数组提供了size()、append()、find()等一系列便捷操作让我们能像操作基本类型一样处理字符串。但不知道你有没有过这样的疑惑为什么我的字符串拼接操作在循环里慢得离谱string s1 s2;这行简单的赋值背后到底发生了什么当面试官问你“深拷贝和浅拷贝的区别”时除了背概念能不能用代码实实在在地讲清楚这就是我们这次要干的事从零开始模拟实现一个我们自己的MyString类。这绝不是一个“玩具”练习。我干了十多年C可以很负责任地说能把string类从头到尾、考虑周全地实现一遍你对C面向对象、内存管理、运算符重载乃至标准库设计的理解会直接上一个台阶。它像一面镜子能照出你对构造函数/析构函数、拷贝控制、异常安全、迭代器等核心概念的掌握程度。网上很多八股文都在空谈而我们今天要做的是把这些概念全部落到一行行可编译、可运行的代码里。我们会从最原始的char*和new/delete开始一步步构建出一个具备基本功能、行为接近标准库std::string的类。过程中你会清晰地看到“深拷贝”是如何避免内存错误的“移动语义”是如何提升性能的以及为什么有些操作要设计成现在这个样子。准备好了吗我们开始动刀。2. 核心设计思路像标准库一样思考在动手写第一行代码之前我们必须先想清楚一个string类的“灵魂”是什么它需要管理哪些数据提供哪些接口行为上要和标准库保持多大程度的一致2.1 数据成员与内存布局标准库的std::string实现非常复杂通常采用诸如 SSO短字符串优化等高级技术。为了聚焦核心原理我们的MyString采用最经典、最易于理解的模型一个char*指针 (_str)指向在堆上动态分配的、以\0结尾的字符数组。这是字符串数据的本体。一个size_t类型的长度 (_size)记录字符串中有效字符的数量不包括结尾的\0。一个size_t类型的容量 (_capacity)记录当前堆上分配的内存空间总共能容纳多少字符不包括结尾的\0。_capacity _size是恒成立的。为什么需要_capacity这是实现高效内存管理的关键。想象一下如果你每次append一个字符都new一块新内存然后拷贝所有数据性能将是灾难性的。有了_capacity我们就可以预分配一块更大的内存只有在空间不足时才进行昂贵的“重新分配-拷贝”操作这就是reserve()和resize()方法存在的意义。2.2 六大默认成员函数的考量这是类的基石也是面试的重灾区。构造函数我们需要默认构造、C风格字符串构造、拷贝构造可能还需要计数构造如MyString(5, A)。默认构造不能简单地将_str设为nullptr因为很多操作如c_str()期望返回一个合法的C风格字符串。更安全的做法是让其指向一个只包含\0的空字符串堆内存块。析构函数必须释放_str指向的堆内存。这是防止内存泄漏的底线。拷贝构造函数这是“深拷贝”的典型场景。必须为新对象分配独立的内存并将源对象的数据完整拷贝过来。如果只是简单拷贝指针浅拷贝两个对象的_str将指向同一块内存析构时会被释放两次导致未定义行为通常是程序崩溃。拷贝赋值运算符 (operator)这是比拷贝构造更复杂的地方因为它需要处理“自赋值”如s1 s1;和原有的资源释放。经典的“拷贝并交换”copy-and-swap idiom 是编写异常安全代码的优雅方式。移动构造函数与移动赋值运算符 (C11及以上)为了支持现代C的高效语义我们需要实现移动操作。它们“窃取”右值临时对象的资源直接接管其_str指针然后将源对象置于有效但可析构的状态如将其_str设为nullptr。这是push_back等操作性能提升的关键。注意在模拟实现中为了简化我们可能先不实现移动语义但在完整的、生产可用的版本中移动语义是必须的。我会在关键部分指出哪些地方可以/应该应用移动语义。2.3 接口设计向std::string看齐我们的目标是模拟所以接口命名和行为应尽量贴近std::string。这包括容量操作size(),capacity(),empty(),clear(),reserve(size_t),resize(size_t, char)。元素访问operator[](size_t)重载const和非const版本at(size_t)带边界检查front(),back(),c_str(),data()。修改操作append(),push_back(char),operator,assign(),insert(),erase(),replace(),swap()。字符串操作find(),substr(),compare()。非成员函数输入输出流运算符重载 (operator,operator) 关系运算符 (,!,等)。3. 基础骨架与资源管理实现让我们先把类的“壳”和最核心的资源管理部分搭起来。我假设我们使用 C11 标准进行实现。// MyString.h #ifndef MYSTRING_H #define MYSTRING_H #include iostream #include cstring // for strlen, strcpy, etc. #include algorithm // for std::swap (C11后) class MyString { public: // 类型别名模仿标准库 using size_type size_t; using iterator char*; using const_iterator const char*; // 1. 构造函数族 MyString(); // 默认构造 MyString(const char* str); // C风格字符串构造 MyString(const MyString other); // 拷贝构造 MyString(MyString other) noexcept; // 移动构造 (C11) // 计数构造 MyString(size_t count, char ch); 可以后续添加 // 2. 析构函数 ~MyString(); // 3. 赋值运算符重载 MyString operator(MyString other); // 注意这里使用“按值传递”是copy-and-swap的关键 MyString operator(const char* str); // MyString operator(MyString other) noexcept; // 移动赋值可由上面的operator(MyString)处理 // 4. 容量相关 size_type size() const { return _size; } size_type capacity() const { return _capacity; } bool empty() const { return _size 0; } void clear(); void reserve(size_type new_cap); void resize(size_type new_size, char ch \0); // 5. 元素访问 char operator[](size_type pos); const char operator[](size_type pos) const; const char* c_str() const { return _str; } char* data() { return _str; } // C17后data()返回非const这里简化 const char* data() const { return _str; } // ... 其他接口声明将在后续扩展 private: char* _str; // 指向堆上字符数组的指针 size_t _size; // 字符串长度不含\0 size_t _capacity; // 当前分配的总容量不含\0 // 一个私有的辅助函数用于分配内存并处理可能的异常 void _reallocate_and_copy(size_type new_cap); }; #endif // MYSTRING_H现在我们来逐一实现这些关键函数并解释其中的“坑”。3.1 构造与析构一切的开始与结束// MyString.cpp #include MyString.h #include stdexcept // for std::out_of_range // 默认构造函数构造一个空字符串 MyString::MyString() : _str(new char[1]) // 分配1个char的空间 , _size(0) , _capacity(0) { _str[0] \0; // 确保是合法的C风格字符串 }这里为什么new char[1]而不是nullptr因为c_str()要求返回一个指向空终止字节数组的指针。返回nullptr不符合约定可能导致调用方如printf(%s, s.c_str())崩溃。// 从C风格字符串构造 MyString::MyString(const char* str) : _str(nullptr) , _size(0) , _capacity(0) { if (str nullptr) { // 处理空指针输入构造一个空字符串 _str new char[1]; _str[0] \0; } else { _size strlen(str); _capacity _size; // 初始容量刚好够用 _str new char[_capacity 1]; // 1 给 \0 strcpy(_str, str); // 拷贝内容包括\0 } }实操心得永远不要相信外部传入的指针。这里检查str是否为nullptr是一种防御性编程。更健壮的做法可能是抛出异常但为了简化我们选择构造一个空字符串。// 拷贝构造函数深拷贝 MyString::MyString(const MyString other) : _str(nullptr) , _size(other._size) , _capacity(other._capacity) { // 即使other是空字符串_size0我们也需要分配内存 _str new char[_capacity 1]; strcpy(_str, other._str); // 拷贝数据 }这就是“深拷贝”的经典实现为新对象分配独立的内存然后复制数据。如果这里写成_str other._str;那就成了灾难性的浅拷贝。// 移动构造函数 (C11) MyString::MyString(MyString other) noexcept : _str(other._str) // “窃取”资源 , _size(other._size) , _capacity(other._capacity) { // 将源对象置于有效但可析构的状态 other._str nullptr; other._size 0; other._capacity 0; }移动构造的效率极高因为它只拷贝了三个基本类型两个指针两个size_t没有分配新内存和拷贝字符串内容。noexcept关键字很重要它告诉标准库这个操作不会抛出异常使得标准库在特定场合如vector::push_back扩容时能选择更高效的移动而非拷贝。// 析构函数 MyString::~MyString() { delete[] _str; // 释放数组内存 // 将成员置为初始状态是一个好习惯尽管对象即将销毁 _str nullptr; _size _capacity 0; }delete[]必须与new[]配对使用。即使_str是nullptrdelete[] nullptr也是安全的C标准规定其为空操作。3.2 赋值运算符与 copy-and-swap 技法赋值运算符是难点因为它需要处理自赋值和原有资源的释放。我们先看一个直观但容易出错的写法// 有缺陷的赋值运算符实现 MyString MyString::operator(const MyString other) { if (this ! other) { // 1. 检查自赋值 delete[] _str; // 2. 释放原有资源 _size other._size; _capacity other._capacity; _str new char[_capacity 1]; // 3. 分配新资源 strcpy(_str, other._str); // 4. 拷贝数据 } return *this; }这个版本逻辑正确但存在异常安全问题如果第3步new分配内存失败抛出了std::bad_alloc异常那么*this对象的状态已经被破坏_str被释放但新内存没分配成功。对象处于无效状态后续使用或析构都会导致未定义行为。更优雅、异常安全的方法是copy-and-swap// 改进使用按值传参和swap的赋值运算符 MyString MyString::operator(MyString other) { // 注意参数是值传递 // 交换当前对象和临时对象other的内容 swap(*this, other); // 需要实现一个swap成员函数或友元函数 return *this; } // 同时我们需要一个高效的swap函数 void swap(MyString lhs, MyString rhs) noexcept { using std::swap; // 启用ADL (Argument-Dependent Lookup) swap(lhs._str, rhs._str); swap(lhs._size, rhs._size); swap(lhs._capacity, rhs._capacity); }这个版本的妙处在于参数MyString other是值传递。调用s1 s2;时会调用拷贝构造函数或移动构造函数如果s2是右值来初始化形参other。这个初始化过程如果失败new抛异常发生在修改s1之前s1的状态完好无损。swap交换资源。交换两个对象的内部指针和大小、容量值效率极高仅交换几个基本类型且绝不抛出异常noexcept。利用析构函数自动清理。函数结束时形参other现在持有s1原来的资源被析构自动释放内存。这个写法同时正确处理了拷贝赋值和移动赋值当实参是右值时会调用移动构造初始化other并且是异常安全的。这是现代C中非常推崇的写法。3.3 容量管理reserve与resize的玄机reserve和resize是string类性能的关键但它们的语义不同容易混淆。void MyString::reserve(size_type new_cap) { if (new_cap _capacity) { return; // 请求的容量不大于当前容量什么都不做 } _reallocate_and_copy(new_cap); // 重新分配内存 } void MyString::resize(size_type new_size, char ch) { if (new_size _size) { // 新大小小于等于当前大小只需截断并添加\0 _size new_size; _str[_size] \0; } else { // 新大小大于当前大小可能需要扩容 if (new_size _capacity) { reserve(new_size); // reserve会处理扩容 } // 填充新增的部分 for (size_type i _size; i new_size; i) { _str[i] ch; } _size new_size; _str[_size] \0; // 确保以\0结尾 } } // 私有的内存重分配辅助函数 void MyString::_reallocate_and_copy(size_type new_cap) { // 1. 分配新内存 char* new_str new char[new_cap 1]; // 1 for \0 // 2. 拷贝原有数据如果存在 if (_str) { strcpy(new_str, _str); // 拷贝包括\0在内的所有内容 } else { new_str[0] \0; } // 3. 释放旧内存 delete[] _str; // 4. 更新成员变量 _str new_str; _capacity new_cap; // _size 保持不变 }关键区别reserve(n)只保证容量至少为n不改变_size和字符串内容。它只影响底层内存。resize(n, ch)改变_size为n并可能改变字符串内容。如果n _size多出的部分用字符ch填充如果n _size则截断。常见问题很多人误以为reserve之后就可以直接用operator[]访问[0, new_cap)的范围这是错误的。operator[]的有效索引范围是[0, _size)。reserve不改变_size。4. 核心功能实现与迭代器基础打好后我们来实现最常用的修改和访问操作。4.1 元素访问与迭代器// 元素访问 char MyString::operator[](size_type pos) { // 通常标准库的operator[]不进行边界检查为了性能。 // 但我们可以在调试版本中加入断言(assert)。 // assert(pos _size); return _str[pos]; } const char MyString::operator[](size_type pos) const { // assert(pos _size); return _str[pos]; } // 带边界检查的at char MyString::at(size_type pos) { if (pos _size) { throw std::out_of_range(MyString::at index out of range); } return _str[pos]; } const char MyString::at(size_type pos) const { if (pos _size) { throw std::out_of_range(MyString::at index out of range); } return _str[pos]; }提供const和非const版本是为了同时支持普通对象和常量对象的访问。迭代器实现非常简单因为我们的底层是连续的数组// 在类定义中已声明 using iterator char*; MyString::iterator MyString::begin() { return _str; } MyString::const_iterator MyString::begin() const { return _str; } MyString::const_iterator MyString::cbegin() const { return _str; } MyString::iterator MyString::end() { return _str _size; } // 指向\0的位置 MyString::const_iterator MyString::end() const { return _str _size; } MyString::const_iterator MyString::cend() const { return _str _size; }有了迭代器我们就可以使用基于范围的for循环for (char ch : myStr) { ... }。4.2 添加与拼接push_back, append, operator这是string最常用的操作也是性能敏感区。void MyString::push_back(char ch) { if (_size _capacity) { // 空间已满需要扩容。常见的策略是倍增amortized O(1) time reserve(_capacity 0 ? 4 : _capacity * 2); } _str[_size] ch; _size; _str[_size] \0; // 别忘了维护结尾的\0 } MyString MyString::append(const char* str) { if (str nullptr) return *this; size_type len strlen(str); if (len 0) return *this; if (_size len _capacity) { // 需要扩容。扩容策略很重要直接追加可能导致多次扩容。 // 这里选择至少扩容到 _size len reserve(_size len); } strcpy(_str _size, str); // 从末尾开始拷贝 _size len; // strcpy已经拷贝了\0所以_str[_size]已经是\0 return *this; } MyString MyString::append(const MyString str) { return append(str._str); // 复用上面的实现 } // operator 通常通过append实现 MyString MyString::operator(const char* str) { return append(str); } MyString MyString::operator(const MyString str) { return append(str); } MyString MyString::operator(char ch) { push_back(ch); return *this; }性能要点push_back的扩容策略这里用了简单的倍增决定了连续push_back操作的平均时间复杂度。如果每次只扩1个那么插入n个字符的时间是O(n²)如果每次倍增摊还分析下是O(n)。append在可能的情况下一次性计算所需总容量并扩容避免多次扩容。4.3 插入、删除与替换这些操作涉及内存的移动实现时需要小心。// 在pos位置插入字符串str MyString MyString::insert(size_type pos, const char* str) { if (pos _size) { // pos可以等于_size表示在末尾插入 throw std::out_of_range(MyString::insert position out of range); } if (str nullptr) return *this; size_type len strlen(str); if (len 0) return *this; // 检查并扩容 if (_size len _capacity) { reserve(_size len); } // 将pos之后的字符向后移动len个位置 // 注意必须从后向前移动避免覆盖 for (size_type i _size; i pos; --i) { // 包括\0也要移动 _str[i len] _str[i]; } // 或者使用 memmove它处理内存重叠 // memmove(_str pos len, _str pos, _size - pos 1); // 1 for \0 // 拷贝插入的字符串 for (size_type i 0; i len; i) { _str[pos i] str[i]; } // 或者使用 memcpy (因为目标区域现在是空的没有重叠) // memcpy(_str pos, str, len); _size len; return *this; } // 从pos位置开始删除len个字符 MyString MyString::erase(size_type pos, size_type len) { if (pos _size) return *this; // 标准库规定possize时无效果 // 计算实际要删除的长度 size_type actual_len len; if (pos len _size || len npos) { // 假设npos是静态常量值为-1的最大值 actual_len _size - pos; } // 将posactual_len之后的字符向前移动 // 使用memmove处理内存重叠 memmove(_str pos, _str pos actual_len, _size - pos - actual_len 1); // 1 for \0 _size - actual_len; return *this; } // 将[pos, poslen)范围内的字符替换为str MyString MyString::replace(size_type pos, size_type len, const char* str) { // 可以组合erase和insert实现但直接实现可能更高效 // 这里展示一个简单实现先删除再插入 erase(pos, len); insert(pos, str); return *this; }踩坑记录在insert实现中移动内存时必须从后向前移动或者使用memmove它保证能正确处理源内存和目标内存重叠的情况。如果从前向后移动并用memcpy当源和目标内存重叠时结果是未定义的。这是一个经典的错误。5. 字符串操作与运算符重载5.1 查找与子串// 从pos开始查找字符ch MyString::size_type MyString::find(char ch, size_type pos) const { if (pos _size) return npos; const char* result static_castconst char*(memchr(_str pos, ch, _size - pos)); return result ? result - _str : npos; } // 从pos开始查找子串str MyString::size_type MyString::find(const char* str, size_type pos) const { if (pos _size || str nullptr) return npos; const char* result strstr(_str pos, str); return result ? result - _str : npos; } // 获取子串 MyString MyString::substr(size_type pos, size_type len) const { if (pos _size) { throw std::out_of_range(MyString::substr position out of range); } size_type actual_len len; if (pos len _size || len npos) { actual_len _size - pos; } // 构造一个新的MyString对象 MyString result; result.reserve(actual_len); for (size_type i 0; i actual_len; i) { result.push_back(_str[pos i]); } return result; // 注意这里会触发返回值优化(RVO)或移动语义效率很高 }这里使用了C标准库函数memchr和strstr来简化实现。substr返回的是一个新对象这里利用了编译器的返回值优化RVO或C11的移动语义避免了不必要的拷贝。5.2 关系运算符与流操作符关系运算符如,通常实现为非成员函数以支持左侧操作数为其他类型如const char*的混合运算。// 在类外定义通常放在头文件里inline bool operator(const MyString lhs, const MyString rhs) { if (lhs.size() ! rhs.size()) return false; return strcmp(lhs.c_str(), rhs.c_str()) 0; } bool operator(const MyString lhs, const char* rhs) { if (rhs nullptr) return lhs.empty(); return strcmp(lhs.c_str(), rhs) 0; } bool operator(const char* lhs, const MyString rhs) { return operator(rhs, lhs); // 复用上面的实现 } bool operator(const MyString lhs, const MyString rhs) { return strcmp(lhs.c_str(), rhs.c_str()) 0; } // 其他关系运算符 !, , , 可以类似实现或通过 和 组合实现输入输出流运算符重载std::ostream operator(std::ostream os, const MyString str) { // 直接输出字符数组直到遇到\0 return os str.c_str(); } std::istream operator(std::istream is, MyString str) { // 简单实现读取一个单词遇到空白停止 str.clear(); // 先清空原有内容 char ch; // 跳过开头的空白字符 while (is.get(ch) std::isspace(ch)) {} if (!is) return is; // 读取失败或EOF // 将第一个非空白字符放回去 is.unget(); // 读取直到遇到空白 while (is.get(ch) !std::isspace(ch)) { str.push_back(ch); } return is; }注意这个operator实现是简化的标准库的std::string的流提取操作符行为更复杂并且可以设置宽度等。这里仅展示基本原理。6. 常见问题、调试技巧与进阶思考实现完基本功能后我们来看看实际编码和面试中常遇到的问题。6.1 内存问题排查自己管理内存最常见的就是内存错误。以下是一些工具和技巧重载new和delete以跟踪分配可以在全局或类级别重载operator new/operator delete打印日志帮助发现内存泄漏或重复释放。void* operator new(size_t size) { std::cout Allocating size bytes\n; return malloc(size); } void operator delete(void* ptr) noexcept { std::cout Deallocating memory\n; free(ptr); }注意这会影响全局谨慎使用最好只在调试时开启。使用valgrind(Linux/Mac)这是神器。用valgrind --leak-checkfull ./your_program运行你的测试程序它能精准报告内存泄漏、非法读写、使用未初始化内存等问题。AddressSanitizer (ASan)编译时加上-fsanitizeaddress标志GCC/Clang运行时能检测出很多内存错误比valgrind更快但对某些泄漏检测不如valgrind全面。6.2 面试高频问题实录Q深拷贝和浅拷贝的区别你的MyString是如何实现的A浅拷贝只复制指针导致多个对象共享同一块堆内存析构时多次释放程序崩溃。深拷贝为每个对象分配独立的内存并复制数据。我的实现中拷贝构造函数和拷贝赋值运算符通过copy-and-swap都进行了深拷贝。Qreserve和resize有什么区别Areserve(n)只改变容量(_capacity)保证至少能存n个字符不改变字符串内容(_size)和实际数据。resize(n, ch)改变_size为n并可能改变内容如果n_size多出的部分用ch填充如果n_size则截断。resize可能会隐式调用reserve来扩容。Qstring的operator和append性能如何在循环中拼接字符串有什么更好的方法A如果每次追加都可能导致扩容性能是O(n²)。更好的方法是先预估最终大小用reserve一次性分配足够内存或者在循环外使用std::ostringstream。QMyString s; s[0] a;会导致什么问题A如果s是默认构造的空字符串_size0_capacity0但_str指向一个包含\0的堆内存。operator[]不检查边界所以s[0]会访问到那个\0并修改它破坏了C风格字符串的约定。虽然程序可能不会立即崩溃但后续调用c_str()或strlen(s.c_str())会得到错误结果。更安全的做法是在operator[]中对于pos _size的情况抛出异常像at()那样或者至少加入断言。6.3 进阶优化方向我们的MyString是一个教学模型离生产级别的std::string还有很大距离。你可以尝试以下优化短字符串优化 (SSO)对于很短的字符串例如15字节以内直接将其存储在对象内部的缓冲区中避免堆内存分配。这是现代std::string实现如GCC的libstdc Clang的libc的标配能极大提升小字符串操作的性能。写时复制 (Copy-On-Write, COW)多个string对象共享同一块内存只有在某个对象需要修改内容时写操作才真正进行拷贝。这可以节省内存和拷贝时间但在多线程环境下需要复杂的同步机制现代C标准库实现已较少使用。更精细的异常安全保证我们的实现基本提供了强异常安全保证操作要么成功要么对象状态不变。可以进一步研究不同操作提供的异常安全等级nothrow, basic, strong。实现完整的迭代器类别我们只提供了最简单的指针迭代器。完整的迭代器需要定义iterator_category,value_type,difference_type,pointer,reference等关联类型以支持所有标准算法。添加noexcept规范为所有明确不会抛出异常的函数如移动操作、swap、size()等加上noexcept帮助编译器优化并让使用者了解其异常行为。亲手实现一遍string类就像完成了一次C核心机制的深度体检。每一个函数每一行代码都在逼问你关于对象生命周期、资源所有权和接口设计的理解。下次当你再使用std::string时你看到的将不再是一个黑盒而是一个由构造函数、析构函数、拷贝控制和内存管理策略精心构筑的精密实体。这种从使用者到创造者视角的转变正是进阶路上最扎实的一步。