C++ String类模拟实现:从深拷贝到移动语义的完整指南

发布时间:2026/7/30 9:36:57
C++ String类模拟实现:从深拷贝到移动语义的完整指南
1. 项目概述为什么要亲手实现一个String类在C的世界里std::string大概是每个开发者最早接触、使用最频繁的STL组件之一。从简单的“Hello World”到复杂的文本处理它无处不在。正因为它如此基础且“好用”很多人在面试或者日常开发中往往只是停留在调用的层面对其内部的运作机制一知半解。当面试官问起“String类的底层是如何实现的”或者“如何自己模拟实现一个String类”时不少人就开始语焉不详了。这就是我们今天要做的抛开标准库的“黑盒”从零开始模拟实现一个我们自己的MyString类。这绝不是一个简单的“造轮子”练习。通过这个过程你将彻底理解深拷贝与浅拷贝的生死抉择为什么直接拷贝指针会导致程序崩溃这是理解C资源管理的核心。动态内存管理的艺术new和delete如何与构造函数、析构函数配合实现资源的自动生命周期管理。C类设计的核心要素如何设计一个健壮的、符合直觉的接口如operatoroperator性能优化的关键点如何避免不必要的拷贝什么是“写时复制”Copy-On-Write虽然现代std::string的实现策略更为复杂如短字符串优化SSO但理解基础的内存模型是优化的前提。我见过太多项目里的诡异Bug追根溯源是字符串操作不当导致的内存泄漏或越界访问。亲手实现一遍这些概念将从书本上的名词变成你肌肉记忆的一部分。接下来我们就从最核心的设计思路开始拆解。2. 整体设计与核心思路拆解在动手写代码之前我们必须想清楚我们的MyString要长什么样以及它需要遵循哪些基本原则。2.1 数据成员与基础模型一个最简单的字符串类核心就是管理一段动态分配的字符数组C风格字符串。因此我们的类至少需要两个数据成员char* _str一个指针指向堆上分配的、存储字符串内容的字符数组。size_t _size记录字符串当前的实际长度不包含结尾的\0。size_t _capacity记录当前分配的内存空间总容量通常 _size 1为\0预留位置。为什么需要_capacity这是为了支持高效的append、等操作。如果每次添加字符都重新分配内存性能将无法接受。我们采用类似std::vector的策略当空间不足时按一定比率例如2倍进行扩容。注意现代std::string的实现如GCC的libstdc MSVC的STL普遍采用了短字符串优化SSO。对于很短的字符串例如15或22个字符以内会直接将其内容存储在对象内部的缓冲区中避免堆内存分配从而极大提升小字符串操作的性能。我们的模拟实现为了聚焦于核心的内存管理与接口设计暂不实现SSO但你需要知道这是工业级实现的一个重要优化方向。2.2 六大默认成员函数的考量这是模拟实现最核心、也最容易出错的部分。C类有六个特殊的默认成员函数构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符C11后。对于管理资源的类我们必须仔细处理它们。构造函数需要分配内存并初始化字符串。析构函数必须释放_str指向的堆内存否则内存泄漏。拷贝构造函数这是关键必须实现深拷贝。即为新对象分配一块新的内存并将原对象字符串内容复制过去。默认的浅拷贝只会复制指针导致两个对象指向同一块内存析构时会被释放两次双重释放程序崩溃。拷贝赋值运算符 (operator)同样需要深拷贝并且要处理好自赋值s1 s1;和原有资源的释放。移动构造函数/赋值运算符 (C11)为了支持现代C的高效语义应该实现移动操作。它们“窃取”右值临时对象的资源将自身指针置空避免不必要的深拷贝能显著提升性能。我们的实现会兼顾C98和C11的风格。2.3 接口设计哲学我们的接口设计应当尽量向std::string看齐保证直观易用。主要分为几类容量相关size,capacity,empty,reserve,resize。元素访问operator[](需提供const和非const版本)at,front,back。修改操作append,operator,c_str,clear,insert,erase,push_back,pop_back。字符串操作find,substr,operator(通常实现为全局函数以提高对称性)。非成员函数流插入提取 (operator,operator) 比较运算符 (,!,等)。设计时要特别注意异常安全和强异常保证例如在insert等可能失败的操作中要保证操作失败时对象状态不变。3. 核心细节解析与避坑指南理论说完了我们进入实战环节。我会先给出关键代码然后逐一解释其中的“坑”和设计考量。3.1 类的基本框架与构造函数、析构函数namespace my { class string { public: // 类型定义模仿STL typedef char* iterator; typedef const char* const_iterator; // 默认构造函数 - 构造空字符串 string() : _str(new char[1]) // 分配1个char用于存放\0 , _size(0) , _capacity(0) { _str[0] \0; } // 从C风格字符串构造 string(const char* str) : _size(strlen(str)) { _capacity _size; _str new char[_capacity 1]; // 1 for \0 strcpy(_str, str); // 拷贝内容包括\0 } // 拷贝构造函数 (深拷贝) string(const string s) : _size(s._size) , _capacity(s._capacity) { _str new char[_capacity 1]; strcpy(_str, s._str); // 深拷贝 } // 析构函数 ~string() { delete[] _str; // 释放数组 _str nullptr; _size _capacity 0; } // 其他成员函数声明... const char* c_str() const { return _str; } size_t size() const { return _size; } size_t capacity() const { return _capacity; } bool empty() const { return _size 0; } private: char* _str; size_t _size; size_t _capacity; // 静态常量表示npos static const size_t npos -1; }; }关键点与避坑指南new char[1]与new char[1]在默认构造函数中我们分配了1个字节来存储\0。虽然_capacity是0但内存是存在的。这是一种常见的实现方式确保c_str()始终返回一个有效的C风格字符串。也可以选择让_str初始化为nullptr并在c_str()中特殊处理但前者更简单。delete[]而非delete_str是通过new[]分配的数组必须用delete[]释放。用delete是未定义行为可能导致内存泄漏或崩溃。这是新手常犯的错误。strcpy的安全性与效率我们使用了strcpy它要求目标缓冲区足够大。在我们的构造中已经按需分配了_size1的空间所以是安全的。在更严格的实现中可能会使用memcpy或std::copy以获得更好的性能尤其是当字符串很长时。npos的定义npos是string类的一个静态常量成员表示“未找到”或“最大可能值”。通常定义为-1转换为size_t类型后是一个很大的正数。它用于find等函数的返回值。必须在类外单独定义这个静态成员在.cpp文件中const size_t my::string::npos -1;否则链接时会报错。这是静态成员变量使用的另一个坑。3.2 拷贝赋值运算符现代写法与经典写法拷贝赋值运算符是面试高频考点它比拷贝构造函数更复杂因为它需要处理已有资源。经典写法C98风格核心思想先创建一个临时副本再交换。string operator(const string s) { if (this ! s) { // 1. 防止自赋值 char* tmp new char[s._capacity 1]; // 2. 分配新资源 strcpy(tmp, s._str); delete[] _str; // 3. 释放旧资源 _str tmp; // 4. 接管新资源 _size s._size; _capacity s._capacity; } return *this; // 5. 返回自身引用以支持链式赋值 }为什么先分配新内存再释放旧内存这是为了提供强异常安全保证。如果new失败抛出std::bad_alloc异常对象的旧状态_str指向的原内容完全没有被改变。如果先delete再new一旦new失败对象就处于一个资源已释放但新资源未获取的无效状态。现代写法C11风格拷贝并交换这种写法异常安全且代码简洁利用了拷贝构造函数和析构函数。string operator(string s) { // 注意这里参数是传值会调用拷贝构造生成副本 swap(s); // 交换当前对象和副本s的资源 return *this; } // 函数结束副本s现在持有原对象的旧资源被析构释放你需要实现一个swap成员函数void swap(string s) { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); }这种写法的妙处在于1) 参数传值自动完成了深拷贝2)swap操作不会抛出异常3) 自动处理了自赋值自赋值时传参拷贝一个自己然后交换再销毁副本结果正确。这是目前公认的最佳实践之一。3.3 迭代器与元素访问为了让我们的MyString能用上范围for循环并与STL算法兼容需要提供迭代器。// 在类public部分 iterator begin() { return _str; } const_iterator begin() const { return _str; } iterator end() { return _str _size; // 指向\0字符 } const_iterator end() const { return _str _size; }非常简单因为我们的底层是连续的字符数组迭代器就是指针。注意const版本返回const_iterator。operator[]的实现char operator[](size_t pos) { assert(pos _size); // 调试期检查越界直接断言崩溃 return _str[pos]; } const char operator[](size_t pos) const { assert(pos _size); return _str[pos]; }at函数与operator[]功能类似但通常应该抛出std::out_of_range异常而不是使用assert。assert在发布版本定义了NDEBUG中会被移除而异常机制始终有效。char at(size_t pos) { if (pos _size) { throw std::out_of_range(string::at); } return _str[pos]; }3.4 容量操作reserve与resize这是理解string动态增长的关键。reserve: 请求改变容量。它只影响_capacity不改变_size和内容。void reserve(size_t new_capacity) { if (new_capacity _capacity) { char* new_str new char[new_capacity 1]; // 多分配1个给\0 strcpy(new_str, _str); // 拷贝原内容 delete[] _str; // 释放旧空间 _str new_str; _capacity new_capacity; // 注意_size 不变 new_str[_size] 已经是\0 } // 如果 new_capacity _capacity标准通常规定什么都不做不缩容 }关键点reserve通常只增不减。这是为了效率避免频繁申请释放内存。如果你确定需要缩容节省内存可以自己实现一个shrink_to_fit但标准不保证reserve会缩容。resize: 改变_size可能同时改变内容和容量。void resize(size_t new_size, char ch \0) { if (new_size _size) { // 缩小大小只需在 new_size 位置放置\0 _str[new_size] \0; _size new_size; } else { // 增大大小可能需要扩容 if (new_size _capacity) { reserve(new_size); // 或者按倍数扩容这里简单处理 } // 将新增的部分用字符ch填充 for (size_t i _size; i new_size; i) { _str[i] ch; } _str[new_size] \0; // 设置新结尾 _size new_size; } }resize和reserve的区别一定要分清reserve管“容量”resize管“大小/长度”和“内容”。4. 关键功能实现与内部逻辑有了上面的基础我们可以实现更丰富的修改操作了。4.1 append 与 push_backappend是和的基础。我们实现一个最通用的版本追加另一个string对象的部分内容。string append(const string s, size_t pos 0, size_t count npos) { // 1. 参数检查 if (pos s._size) { throw std::out_of_range(string::append); } size_t actual_count count; if (count npos || pos count s._size) { actual_count s._size - pos; } // 2. 检查容量是否足够 if (_size actual_count _capacity) { // 扩容策略至少扩大到 new_size 通常使用2倍或1.5倍增长 size_t new_capacity _capacity * 2; if (new_capacity _size actual_count) { new_capacity _size actual_count; } reserve(new_capacity); } // 3. 拷贝数据 strncpy(_str _size, s._str pos, actual_count); // 4. 更新大小和结尾 _size actual_count; _str[_size] \0; return *this; }push_back就是追加一个字符的appendvoid push_back(char ch) { if (_size _capacity) { // 容量为0时至少扩容到4或某个初始值避免频繁扩容 reserve(_capacity 0 ? 4 : _capacity * 2); } _str[_size] ch; _size; _str[_size] \0; }扩容策略的思考这里使用了简单的2倍扩容。为什么是2倍这是一个经验值在内存利用率和扩容次数之间取得平衡。1.5倍如MSVC也是常见选择。一次性扩到刚好够用_size count看似节省内存但会导致每次append都可能触发扩容O(n^2)时间复杂度。成倍扩容能将追加操作的均摊时间复杂度降到O(1)。4.2 insert 与 eraseinsert和erase因为涉及内存的移动是实现中最需要小心性能的部分。insert在指定位置插入字符串string insert(size_t pos, const char* str) { // 参数检查 if (pos _size) { // 允许在末尾插入(pos _size) throw std::out_of_range(string::insert); } size_t len strlen(str); if (len 0) return *this; // 检查并扩容 if (_size len _capacity) { reserve((_size len) * 2); // 使用扩容策略 } // 将pos之后的原有字符向后移动len位 // 必须从后向前移动避免覆盖 for (size_t i _size len; i pos len; --i) { _str[i] _str[i - len]; } // 或者使用 memmove它处理内存重叠 // memmove(_str pos len, _str pos, _size - pos 1); // 1 for \0 // 插入新字符串 strncpy(_str pos, str, len); // 更新大小 _size len; // 确保结尾是\0 (如果用的是memmove原\0已被移动这里需要显式设置) _str[_size] \0; return *this; }为什么用memmove而不用memcpy或循环memcpy不保证源内存和目标内存重叠时的正确性。而insert操作中移动的数据源和目标区域是重叠的。memmove会先检查重叠情况并选择正确的拷贝方向从后向前或从前向后因此是更安全的选择。上面的循环手动实现了从后向前移动也是正确的。erase删除从pos开始的count个字符string erase(size_t pos 0, size_t count npos) { if (pos _size) { throw std::out_of_range(string::erase); } size_t actual_count count; if (count npos || pos count _size) { actual_count _size - pos; } // 将poscount之后的字符向前移动 // 使用memmove处理重叠 memmove(_str pos, _str pos actual_count, _size - pos - actual_count 1); // 1 for \0 _size - actual_count; return *this; }注意memmove的第三个参数是要移动的字节数包括结尾的\0所以是_size - pos - actual_count 1。4.3 find 与 substrfind函数用于查找子串或字符我们实现最常见的从pos开始查找C风格字符串。size_t find(const char* str, size_t pos 0) const { if (pos _size) return npos; const char* result strstr(_str pos, str); // 使用标准库函数 if (result nullptr) { return npos; } return result - _str; // 指针相减得到下标 }strstr是C标准库函数用于在字符串中查找子串。自己实现strstr可以用朴素的暴力匹配O(n*m)或者KMP等高效算法但这里我们直接调用库函数以保持简洁。面试中可能会要求你自己实现查找逻辑。substr用于获取子串string substr(size_t pos 0, size_t count npos) const { if (pos _size) { throw std::out_of_range(string::substr); } size_t actual_count count; if (count npos || pos count _size) { actual_count _size - pos; } string sub; sub.reserve(actual_count); // 预分配空间避免多次扩容 for (size_t i 0; i actual_count; i) { sub.push_back(_str[pos i]); } // 或者更高效的方式直接构造 // return string(_str pos, actual_count); // 需要添加对应的构造函数 return sub; }这里我们采用了一种安全但可能低效的方式创建空字符串然后逐个push_back。更高效的做法是提供一个从指针和长度构造的私有或公开构造函数然后直接调用它return string(_str pos, actual_count);。这需要你实现这样一个构造函数string(const char* str, size_t count)。4.4 流操作与全局运算符为了让我们的string能像内置类型一样使用cout str和cin str需要重载流操作符。它们是非成员函数。// 输出 std::ostream operator(std::ostream os, const my::string str) { // 直接输出字符数组因为string内部保证以\0结尾 os str.c_str(); return os; } // 输入 (简化版不考虑复杂空白符处理) std::istream operator(std::istream is, my::string str) { str.clear(); // 先清空目标字符串 char ch; // 跳过前导空白符标准行为 while (is.get(ch) std::isspace(ch)) {} if (!is) return is; // 读取失败直接返回 do { str.push_back(ch); } while (is.get(ch) !std::isspace(ch)); // 如果因为遇到空白符而停止需要把空白符放回流中 if (is std::isspace(ch)) { is.putback(ch); } return is; }输入运算符的实现比输出复杂因为要处理缓冲、空白符等问题。上面的实现是一个简化版它读取直到遇到空白符。标准库的std::string的operator行为与此类似。全局operatormy::string operator(const my::string lhs, const my::string rhs) { my::string tmp(lhs); // 拷贝左值 tmp.append(rhs); // 追加右值 return tmp; // 返回值可能触发NRVO或移动语义 }这是一个经典的实现创建一个临时对象追加内容后返回。在C11以后由于返回值优化RVO/NRVO和移动语义这样的写法效率很高。注意它返回的是新对象而不是对原有对象的引用。5. 移动语义与现代C优化C11/14/17如果你实现的MyString要用于现代C项目实现移动构造函数和移动赋值运算符是必须的。它们能避免临时对象带来的不必要的深拷贝。5.1 移动构造函数与移动赋值运算符// 移动构造函数 string(string s) noexcept // noexcept 很重要告诉标准库这个操作不会抛出异常 : _str(s._str) , _size(s._size) , _capacity(s._capacity) { // 将源对象置于有效但可析构的状态空状态 s._str nullptr; s._size 0; s._capacity 0; } // 移动赋值运算符 string operator(string s) noexcept { if (this ! s) { // 先释放自己的资源 delete[] _str; // 窃取资源 _str s._str; _size s._size; _capacity s._capacity; // 置空源对象 s._str nullptr; s._size 0; s._capacity 0; } return *this; }关键点noexcept标记为noexcept非常重要。标准库中的许多操作如std::vector::resize在需要移动元素时会检查元素的移动构造函数是否noexcept。如果是则使用移动否则为了强异常安全可能会使用拷贝。对于像string这样移动操作就是交换几个指针的类移动构造函数肯定是noexcept的加上这个标记能提升容器操作的性能。将源对象置于有效状态移动操作“窃取”了源对象的资源后必须将源对象置于一个可析构、可赋值的有效状态。通常就是将其数据成员设为默认值如nullptr,0。这样当源对象临时对象被析构时delete[] nullptr是安全的。5.2 利用移动语义优化之前的函数有了移动语义我们可以优化一些返回string的函数。例如operator的实现已经可以受益了。但我们可以更进一步实现append的右值引用版本以支持链式操作中的移动string append(string s) { // 对于右值我们可以直接“交换”而不是拷贝 // 但这里为了接口统一我们选择移动其内容到当前字符串末尾 // 一种高效的做法是先预留空间然后移动字符 // 更简单的做法针对我们的简单设计复用const版本因为我们的string没有直接“接管”另一个string缓冲区的接口 // 所以这里我们暂时调用 const版本实际工程中可能有更优设计 return append(s); // 这会调用 const string 版本进行拷贝 }实际上对于string移动append的优势不如移动构造明显。一个更重要的优化是让我们的类支持移动迭代器和emplace_back等现代接口但这超出了基础模拟的范围。5.3 拷贝并交换赋值运算符的再认识在有了移动构造函数后我们之前提到的“现代写法”拷贝赋值运算符依然工作良好并且变得更加强大string operator(string s) { // 参数是传值 swap(s); // 交换 return *this; }当调用s1 s2;s2是左值时参数s通过拷贝构造初始化。 当调用s1 std::move(s2);或s1 get_temp_string();右值时参数s通过移动构造初始化。 然后swap交换资源。这个单一的赋值运算符同时完美处理了拷贝赋值和移动赋值两种情况代码极其简洁且异常安全。这是“拷贝-交换”惯用法的魅力所在。6. 测试、常见问题与性能思考实现完成后必须进行全面的测试。6.1 基础功能测试用例你应该编写测试代码覆盖以下场景void test_my_string() { // 1. 构造与基本功能 my::string s1; assert(s1.size() 0); assert(strcmp(s1.c_str(), ) 0); my::string s2(Hello); assert(s2.size() 5); assert(strcmp(s2.c_str(), Hello) 0); // 2. 拷贝构造与赋值 my::string s3 s2; // 拷贝构造 assert(s3.size() 5); s1 s3; // 拷贝赋值 assert(s1.size() 5); // 3. 自赋值 s1 s1; assert(s1.size() 5); // 4. 修改操作 s1.append( World); assert(s1.size() 11); assert(strcmp(s1.c_str(), Hello World) 0); s1.insert(5, ,); assert(strcmp(s1.c_str(), Hello, World) 0); s1.erase(5, 2); // 删除 , assert(strcmp(s1.c_str(), HelloWorld) 0); // 5. 查找与子串 size_t pos s2.find(ll); assert(pos 2); my::string sub s2.substr(1, 3); assert(strcmp(sub.c_str(), ell) 0); // 6. 迭代器与范围for for (auto ch : s2) { ch toupper(ch); } assert(strcmp(s2.c_str(), HELLO) 0); // 7. 流操作 my::string s4; std::istringstream iss(Test Input); iss s4; assert(s4 Test); // 需要实现 operator std::ostringstream oss; oss s4; assert(oss.str() Test); // 8. 移动语义 (C11) my::string s5 std::move(s2); // 移动构造 assert(s5.size() 5); assert(s2.size() 0); // s2 被移动应为空 assert(s2.c_str() ! nullptr s2.c_str()[0] \0); // 或 s2.c_str() 返回空字符串 s1 my::string(Temporary); // 移动赋值 assert(strcmp(s1.c_str(), Temporary) 0); std::cout All tests passed! std::endl; }6.2 常见问题与排查技巧在实现和使用过程中你可能会遇到以下问题问题现象可能原因排查与解决程序崩溃Segmentation fault1. 未初始化的指针被解引用。2. 浅拷贝导致双重释放。3. 访问越界operator[]未检查。4. 对nullptr调用strcpy或strlen。1. 检查所有构造函数是否正确初始化了_str即使是空字符串。2.确保实现了深拷贝的拷贝构造和拷贝赋值。3. 在operator[]、at、substr、find等函数中加入边界检查assert或throw。4. 在c_str()等返回内部指针的函数中确保即使字符串为空也返回一个有效的C风格字符串指向\0。内存泄漏1. 析构函数未正确释放_str。2. 赋值运算符中分配新内存前忘记释放旧内存。3. 在异常发生时资源未释放异常安全。1. 使用valgrind或 AddressSanitizer 等工具检测。2. 在赋值运算符中遵循“先分配新资源再释放旧资源”的顺序。3. 使用RAII思想或者像“拷贝-交换”技法那样让异常安全自动得到保证。字符串内容乱码或损坏1. 忘记在字符串末尾添加\0。2.memcpy/strcpy操作越界。3.resize或erase后未正确设置\0。1.在所有可能改变_size的操作的最后显式设置_str[_size] \0。这是一个铁律。2. 确保所有内存操作都在分配的空间内进行仔细计算memcpy的长度。3. 使用strncpy代替strcpy有时可以防止越界但注意strncpy不会自动添加\0如果长度不够。性能低下频繁扩容append或push_back时每次容量不足都只扩增1个字符。实现成倍扩容策略如reserve(_capacity * 2)。在默认构造时可以给予一个小的初始容量如16避免一开始就频繁扩容。自赋值导致错误在operator中没有检查if (this ! other)导致先释放了自己的内存然后又试图从已释放的内存拷贝。在经典写法中必须加入自赋值检查。在现代“拷贝-交换”写法中自赋值是安全的。6.3 性能思考与进阶优化方向我们实现的MyString是一个教学模型揭示了std::string的核心原理。但工业级的std::string实现要复杂和高效得多短字符串优化SSO这是最大的性能优化。对于短字符串长度通常15或22取决于实现直接将字符内容存储在对象内部的固定大小数组中避免堆内存分配。这极大地提升了小字符串的创建、拷贝和销毁速度。实现SSO需要更复杂的内存布局判断。引用计数与写时复制Copy-On-Write, COW一些旧的实现如GCC 4.x之前的std::string使用COW。多个string对象可以共享同一块内存只有在某个对象需要修改内容时写操作才进行实际的拷贝。这可以减少不必要的内存分配和拷贝。但COW在多线程环境下需要昂贵的原子操作来保证引用计数的安全并且与C11的移动语义有冲突因此现代实现C11后已逐渐弃用COW。更精细的扩容策略可能不是简单的2倍而是结合分配器allocator的行为进行优化。与分配器集成标准std::string实际上是一个模板类basic_stringchar, char_traitschar, allocatorchar的别名。我们的实现省略了分配器直接使用new/delete。完整实现需要支持用户自定义分配器。亲手实现这个基础的MyString后你再去看标准库的源码或者听到面试官问起“深拷贝浅拷贝”、“移动语义”、“SSO”这些概念时就会有完全不一样的理解。它不再是一堆抽象的术语而是你代码中一个个具体的new[]、delete[]、指针赋值和边界检查。这才是“模拟实现”最大的价值——将知识内化为本能。