ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++深浅拷贝详解:用MyString模拟深拷贝实现与内存管理

C++深浅拷贝详解:用MyString模拟深拷贝实现与内存管理 深浅拷贝在C里是个老话题可每次聊到它总能在代码评审里抓出一两个新问题。最常见的就是类里面带了裸指针默认拷贝构造把两个对象的指针变成同一个地址改一个跟着动析构的时候还要double free。我最早动手写string的模拟实现就是因为线上有一个模块在字符串赋值时偶发崩溃调查到最后发现是拷贝语义没写对。这篇文章就用一个MyString模拟类把深拷贝和浅拷贝的运作机制、每个成员函数怎么处理内存、测试怎么设计从头到尾捋一遍完整实现代码和测试代码都放在下面可以直接跑。1. 拷贝背后的本质问题为什么string会翻车1.1 浅拷贝的共享内存陷阱先看最朴素的情况。如果一个类里只有一个char*成员你不写任何拷贝构造和赋值运算符编译器会帮我们生成默认版本。这个默认版本的拷贝行为是什么呢它是逐字节复制成员变量也就是所谓的位拷贝。指针成员复制过来之后新旧两个对象里的指针值完全一样指向同一块堆内存。我把这个最容易踩雷的版本写出来class BadString { public: BadString(const char* s) { int len strlen(s) 1; _ptr new char[len]; strcpy(_ptr, s); } ~BadString() { delete[] _ptr; } private: char* _ptr; };这段代码的问题非常大。当执行BadString a(hello); BadString b(a);的时候a和b的_ptr指向同一块地址。程序运行到作用域结束时先析构b把这块堆内存释放掉再析构a的时候又对同一块内存执行delete[]直接double free。有些人会说那我析构的时候不释放就没事了吧不行那会变成内存泄漏。你每构造一个对象就new一次但永远不释放跑久了内存就涨上去了。真正的问题不在析构函数怎么写而在于拷贝语义本身是错的两个对象不应该共享同一份内存它们应当各自拥有一份属于自己的数据。这就是浅拷贝的陷阱指针成员被原样复制资源的所有权被一份内存被两个对象同时管理结果必然是冲突。1.2 深拷贝的原理与成本深拷贝做的事情很简单拷贝的时候不只是复制指针值而是为目标对象开辟一块新的堆空间然后把原对象的字符串内容完整地复制过去。这样每个对象都持有自己独立的内存生命周期各自管理互不干扰。深拷贝的实现有三个关键点为目标对象new一块新空间把源对象的字符串内容原样拷贝到新空间里保证内容以\0结尾让c_str()这类接口能安全工作代价也很直观每次拷贝都伴随一次堆内存分配和字符串复制。如果一个字符串有几MB你在循环里频繁按值传递它性能就会非常难看。所以标准库std::string内部做了很多优化最常见的是短字符串优化SSO小字符串直接存在对象内部不触发堆分配。我的模拟实现为了把深拷贝讲清楚先不做这些优化但会在后面提到它们的思路。1.3 深浅拷贝的影响范围绝不止string很多初学者以为深浅拷贝是string特有的问题其实是错的。所有带了指针或引用成员的类默认拷贝都有同样的隐患。vector、map这些容器内部管理着堆内存如果它们的拷贝语义出了问题一样会崩。只不过标准库容器已经把拷贝构造和赋值运算符写好了你直接用就好但一旦你写自己的类这个问题就躲不掉了。还有一个场景特别容易忽略函数传参。如果你写了一个void func(MyString s)调用的时候会触发拷贝构造。如果MyString没有正确的深拷贝语义传进去的对象和外面的对象就会共享内存函数内部一改外面的值也会变函数结束析构时更是对同一块内存重复释放。这也是为什么C社区长期强调传参尽量用const引用既省了一次拷贝也避免在无意中触发浅拷贝风险。2. 动手前先拆解模拟string类的整体设计与选型2.1 模拟范围哪些要实现哪些刻意做减法我定的目标很明确不是把std::string三百多个接口全部平替一遍而是聚焦于「内存管理」和「深浅拷贝」相关的最小闭环。能体现深拷贝特性的核心接口必须齐全其余业务方法能省则省。我的MyString要实现这些接口构造默认构造、带参构造、拷贝构造、移动构造析构赋值拷贝赋值、移动赋值c_str()拿到C风格字符串size()和capacity()查询长度与容量operator[]下标访问支持普通对象和const对象reserve()预留容量push_back()尾部追加单个字符append()追加字符串operator追加字符串比较运算符、、非成员函数operator字符串拼接刻意不做的有迭代器、find、substr、insert、erase、replace以及各种allocator配置。它们和深浅拷贝的关系不大硬塞进来反而会把文章主题冲淡。2.2 成员变量设计一个指针加两个size_t我把成员变量设计为三个char* _str; // 指向堆上的字符串数据 size_t _size; // 当前字符串长度不含\0 size_t _capacity; // 当前可容纳的字符数不含\0为什么要单独维护_size和_capacity两个概念这跟扩容策略直接相关。你可以把_capacity理解成会场容量_size是已经坐进来的观众数。每次追加字符前先检查_size是否小于_capacity如果满了就扩容不必每次追加都重新分配内存。这个设计也是std::string和std::vector共同的核心思路。_str指向的内存块永远比_capacity大1多出的那一位用来放\0。这样c_str()就能直接返回_str完美兼容C接口。字符串以\0结尾是刻在C/C基因里的约定我们自己实现字符串类时也必须遵守。2.3 扩容策略与真实string的差距扩容的时候我用最简单直接的策略容量不够就倍增。比如当前容量是15追加字符发现满了就扩容到30。倍增的好处是均摊时间复杂度为O(1)这是教科书级别的结论。也有用1.5倍的1.5倍在内存碎片控制上更友好但倍增代码写起来更直观教学场景足够。真实std::string和我的模拟实现差距主要在这几点短字符串优化SSO小字符串直接存对象内部不分配堆内存完整的迭代器支持和STL算法适配线程安全相关的一些细节保证针对不同平台的内存分配器优化这些不影响深浅拷贝的本质所以我的模拟类不实现它们。3. 核心实现从构造到赋值的深拷贝落地3.1 构造函数与析构函数内存从哪里来、到哪里去默认构造函数分配一块初始容量为15的内存并把第一个字符置为\0。容量不设为0是因为接下来任何一次push_back都要触发扩容白白多一次分配初始给一个合理的值比如15在很多场景下能直接容纳短字符串避免过早扩容。MyString() : _size(0), _capacity(15) { _str new char[_capacity 1]; _str[0] \0; }带参构造函数接收一个C风格字符串先算出长度再设置容量最后拷贝内容。MyString(const char* s) : _size(strlen(s)), _capacity(_size 15 ? _size : 15) { _str new char[_capacity 1]; strcpy(_str, s); }这里有一个约定需要说清楚传入的s不能是nullptr标准库std::string对空指针也是未定义行为。代码里没必要写防空指针判断调用方保证传非空字符串是本分。析构函数要做的就是释放_str指向的堆内存。养成好习惯释放后把指针置空防止调试时出现悬空指针误用。~MyString() { delete[] _str; _str nullptr; _size 0; _capacity 0; }3.2 拷贝构造与拷贝赋值深拷贝的两种关键路径拷贝构造函数是最典型的深拷贝场景。它的参数是另一个MyString我们要为新对象开辟独立内存再复制内容。MyString(const MyString s) : _size(s._size), _capacity(s._capacity) { _str new char[_capacity 1]; strcpy(_str, s._str); }注意不能写成_str(s._str)这样直接把指针复制过去那就退化成浅拷贝了。必须先new、再strcpy。_size和_capacity是整型成员直接复制没问题因为它们描述的是值不是资源。拷贝赋值运算符更考验细节它必须处理三件事先判断是不是自赋值释放掉自己原来的旧内存分配新内存并拷贝内容MyString operator(const MyString s) { if (this ! s) { delete[] _str; _size s._size; _capacity s._capacity; _str new char[_capacity 1]; strcpy(_str, s._str); } return *this; }自赋值检查不能省。如果执行m m;第一步就把_str释放了第二步再用m._str拷贝时用的是一块已经释放的内存轻则未定义行为重则崩溃。这个坑我在实际项目里见人踩过场景往往是对象被传入某个函数函数内部又把它赋值给自己看起来不起眼但跑起来很致命。这种写法有一个更现代的替代方案叫copy-and-swap。它先构造一个临时对象再交换成员异常发生时能保证对象处于有效状态也更简洁。它的核心代码如下MyString operator(const MyString s) { if (this ! s) { MyString tmp(s); std::swap(_str, tmp._str); std::swap(_size, tmp._size); std::swap(_capacity, tmp._capacity); } return *this; }我在文章里给出的是经典写法容易理解实际生产代码里我更推荐copy-and-swap因为异常安全是硬道理。3.3 常用成员函数让MyString真正像个字符串实现了核心拷贝语义之后其他成员函数就是围绕_str、_size、_capacity三个成员做常规操作。c_str()直接返回_strsize()返回_sizecapacity()返回_capacity。它们都是const成员函数保证调用时不会修改对象。operator[]要提供普通对象和const对象两个版本char operator[](size_t pos) { return _str[pos]; } const char operator[](size_t pos) const { return _str[pos]; }普通版本返回char支持修改const版本返回const char给const对象用。注意为了效率我没有做越界检查这一点和标准库行为一致。你需要下标合法性由调用方自己保证。reserve()负责扩容核心思路是分配新内存、拷贝旧内容、释放旧内存、更新_capacity。这里有个细节如果n小于等于当前容量什么都不做避免无意义的内存分配。void reserve(size_t n) { if (n _capacity) { char* tmp new char[n 1]; strcpy(tmp, _str); delete[] _str; _str tmp; _capacity n; } }push_back()追加单个字符。先检查容量如果满了就扩容然后把新字符放到_size位置_size自增最后补上\0。void push_back(char ch) { if (_size _capacity) { reserve(_capacity 0 ? 15 : _capacity * 2); } _str[_size] ch; _str[_size] \0; }append()追加一段字符串。这里要计算目标总长度判断容量是否足够不够就扩容到正好容纳新字符串的大小。注意用strcpy(_str _size, s)从原有字符串的末尾开始追加它会自动把新的\0放到最后。void append(const char* s) { size_t len strlen(s); if (_size len _capacity) { reserve(_size len); } strcpy(_str _size, s); _size len; }有了append之后operator就是一行转发MyString operator(const char* s) { append(s); return *this; }比较运算符直接用strcmpbool operator(const MyString s) const { return strcmp(_str, s._str) 0; } bool operator(const MyString s) const { return strcmp(_str, s._str) 0; } bool operator(const MyString s) const { return strcmp(_str, s._str) 0; }非成员operator用来实现a b这种写法。它先拷贝a再追加b返回临时对象。很多初学者担心这里会有大字符串深拷贝导致的性能问题实际上编译器普遍会做RVO/NRVO优化C17更是从语言层面规范了复制省略返回值临时对象不会被当成额外拷贝。3.4 完整实现代码把上面的设计拼在一起就是下面这个完整的MyString类。我也把移动构造和移动赋值加进去了它们的作用是把资源直接转移给新对象避免不必要的深拷贝这在现代C里非常重要。#include iostream #include cstring #include utility #include cassert class MyString { public: // 默认构造 MyString() : _size(0), _capacity(15) { _str new char[_capacity 1]; _str[0] \0; } // 带参构造 MyString(const char* s) : _size(strlen(s)), _capacity(_size 15 ? _size : 15) { _str new char[_capacity 1]; strcpy(_str, s); } // 拷贝构造深拷贝 MyString(const MyString s) : _size(s._size), _capacity(s._capacity) { std::cout [日志] 拷贝构造: s._str std::endl; _str new char[_capacity 1]; strcpy(_str, s._str); } // 移动构造转移资源不深拷贝 MyString(MyString s) noexcept : _str(s._str), _size(s._size), _capacity(s._capacity) { s._str nullptr; s._size 0; s._capacity 0; } // 析构 ~MyString() { delete[] _str; _str nullptr; _size 0; _capacity 0; } // 拷贝赋值深拷贝 MyString operator(const MyString s) { if (this ! s) { delete[] _str; _size s._size; _capacity s._capacity; _str new char[_capacity 1]; strcpy(_str, s._str); std::cout [日志] 拷贝赋值: s._str std::endl; } return *this; } // 移动赋值转移资源 MyString operator(MyString 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; } // 常用访问接口 const char* c_str() const { return _str; } size_t size() const { return _size; } size_t capacity() const { return _capacity; } // 下标访问 char operator[](size_t pos) { return _str[pos]; } const char operator[](size_t pos) const { return _str[pos]; } // 预留容量 void reserve(size_t n) { if (n _capacity) { char* tmp new char[n 1]; strcpy(tmp, _str); delete[] _str; _str tmp; _capacity n; } } // 尾部追加字符 void push_back(char ch) { if (_size _capacity) { reserve(_capacity 0 ? 15 : _capacity * 2); } _str[_size] ch; _str[_size] \0; } // 追加字符串 void append(const char* s) { size_t len strlen(s); if (_size len _capacity) { reserve(_size len); } strcpy(_str _size, s); _size len; } // operator MyString operator(const char* s) { append(s); return *this; } // 比较运算符 bool operator(const MyString s) const { return strcmp(_str, s._str) 0; } bool operator(const MyString s) const { return strcmp(_str, s._str) 0; } bool operator(const MyString s) const { return strcmp(_str, s._str) 0; } private: char* _str; size_t _size; size_t _capacity; }; // 非成员 operator MyString operator(const MyString a, const MyString b) { MyString tmp(a); tmp b.c_str(); return tmp; }4. 测试代码与验证确保实现真的靠谱4.1 测试用例设计思路测试不是随便跑几下就完事要围绕深拷贝这个核心目标设计用例。我这里的测试覆盖了六个关键场景默认构造和空字符串处理带参构造的正确性拷贝构造的深拷贝独立性拷贝赋值后的深拷贝独立性自赋值安全追加、扩容、预留容量的正确性深拷贝独立性是最重要的测试点。如何验证一个拷贝是深拷贝很简单修改副本看原对象是否受影响。如果原对象变了说明它们共享内存那就是浅拷贝如果原对象没变说明各自独立深拷贝确认无误。4.2 测试代码与运行结果void TestDefault() { MyString s; assert(strcmp(s.c_str(), ) 0); assert(s.size() 0); std::cout 默认构造测试通过 std::endl; } void TestDeepCopy() { MyString s1(hello); MyString s2(s1); // 修改s2验证s1不受影响 s2[0] H; assert(s1[0] h); assert(s2[0] H); assert(strcmp(s1.c_str(), hello) 0); assert(strcmp(s2.c_str(), Hello) 0); std::cout 深拷贝独立性测试通过 std::endl; } void TestAssignment() { MyString s1(world); MyString s2(cpp); s2 s1; assert(strcmp(s2.c_str(), world) 0); // 修改s2验证s1不受影响 s2[0] W; assert(s1[0] w); assert(strcmp(s1.c_str(), world) 0); assert(strcmp(s2.c_str(), World) 0); std::cout 拷贝赋值测试通过 std::endl; } void TestSelfAssign() { MyString s(self); s s; assert(strcmp(s.c_str(), self) 0); std::cout 自赋值测试通过 std::endl; } void TestAppendAndReserve() { MyString s(hello); s world; assert(strcmp(s.c_str(), hello world) 0); s.reserve(50); assert(s.capacity() 50); for (int i 0; i 10; i) { s.push_back(x); } assert(s.size() 21); assert(strcmp(s.c_str(), hello worldxxxxxxxxxx) 0); std::cout append/reserve测试通过 std::endl; } int main() { std::cout MyString 测试开始 std::endl; TestDefault(); TestDeepCopy(); TestAssignment(); TestSelfAssign(); TestAppendAndReserve(); std::cout 所有测试通过 std::endl; return 0; }我保留了拷贝构造和拷贝赋值里的教学日志这样运行时可以直接看到深拷贝在什么时机发生。在我的机器上用g编译并执行后输出如下 MyString 测试开始 默认构造测试通过 [日志] 拷贝构造: hello 深拷贝独立性测试通过 [日志] 拷贝赋值: world 拷贝赋值测试通过 自赋值测试通过 append/reserve测试通过 所有测试通过 注意TestSelfAssign()里没有打印任何拷贝赋值的日志说明自赋值检查生效了this ! s条件为假直接跳过了整个释放和分配流程。4.3 编译运行环境与注意事项我实测用的命令是这样的g -stdc11 -Wall -g main.cpp -o test_my_string ./test_my_string用-stdc11是因为类里用到了移动语义用-g是为了后面如果写崩了能配合调试器或AddressSanitizer定位。如果你想增加编译告警级别可以再加上-Wextra -Wpedantic。如果你用Visual Studio直接把源文件拖进空项目编译也能跑MSVC对这套代码的兼容性没有问题。5. 实操中踩过的坑与排查技巧实录5.1 高频崩溃点双析构、野指针、越界访问我在实际调代码过程中见过的最典型的崩溃有三种。第一种就是双析构。类里带了指针又没有写拷贝构造两个对象共享同一块内存析构时对同一地址执行两次delete[]。表现形式程序退出的瞬间报double free or corruption有时候还伴随sigabrt。遇到这种崩溃第一反应就应该是检查有没有浅拷贝。第二种是野指针。把源对象的指针直接赋值给目标对象后如果中间某个对象被提前释放另一个对象手里拿着的就是一块已经归还给操作系统的内存。后续访问它轻则读到脏数据重则直接段错误。这种问题在大型项目里极难发现因为它可能只在特定对象生命周期交叉时触发。第三种是越界写。自己实现字符串类的时候最容易犯的错是忘记给\0留位置。我在早期版本里默认构造时分配了_capacity个字节而不是_capacity 1结果push_back把最后一个字符写进去后再写\0就越界了。这种越界通常不会马上崩但会在莫名其妙的地方炸出来。所以现在写代码我坚持一个原则分配空间永远比_capacity多一个字节这个字节属于\0。5.2 排查工具ASan、valgrind和日志打印在真实项目中排查内存类问题我强烈建议优先用AddressSanitizer。它比valgrind快得多而且能精确报告出错的代码行。用g编译时加这几个参数g -fsanitizeaddress -fno-omit-frame-pointer -g main.cpp -o test_asan ./test_asan如果代码里有堆内存相关的错误ASan会输出类似这样的信息ERROR: AddressSanitizer: heap-use-after-free它能直接告诉你哪一行访问了已经被释放的内存这对定位深浅拷贝问题几乎是降维打击。valgrind也能做类似的事但速度慢很多适合在本地做一次深度检查时用。我的习惯是日常开发用ASan发布前再跑一遍valgrind确认。还有一种最朴素也很有用的方法在拷贝构造、拷贝赋值、析构函数里打印日志。这个方法没有技术含量但效果极好。你把构造、赋值、析构的调用点都打印出来就能看到对象什么时候被拷贝、什么时候被释放配合调用栈能很快还原整个生命周期。5.3 经验总结开发中如何规避深浅拷贝的坑规避深浅拷贝问题我总结了三条实操建议。第一条自定义类里带了裸指针优先遵循三法则或五法则。三法则指的是如果一个类需要自定义析构函数、拷贝构造函数、拷贝赋值运算符那么这三个通常都要一起写。五法则在C11之后加入移动构造和移动赋值一共五个函数。只要有一类资源需要手动释放就必须把这套接口补齐。第二条函数传参尽量用const引用返回尽量依赖返回值优化。深拷贝不是免费的每次拷贝都伴随着堆分配和字符串复制。能用const MyString就不用MyString这既能避免拷贝开销也能降低浅拷贝风险。第三条如果不想自己管理裸指针可以把char*换成std::unique_ptr这类智能指针。智能指针天生禁用了拷贝只能移动从根源上杜绝了浅拷贝问题。当然使用智能指针后需要重新设计字符串类的拷贝语义因为unique_ptr不能拷贝你必须明确要么提供深拷贝要么把类改成只允许移动。这比裸指针安全得多也是我推荐的方向。这里还应该提一个容易被忽略的点我写的operator深拷贝版本有一个潜在的异常安全问题。如果new char[_capacity 1]抛异常了对象已经处于一个被删除旧内存的混乱状态。copy-and-swap写法能规避这个问题。你在自己的项目中实现赋值运算符时建议优先用copy-and-swap而不是像我教学版本这样先释放后分配。最后再分享一个小技巧。排查深浅拷贝问题时除了看代码你还可以把对象的地址打出来对比std::cout static_castconst void*(s1.c_str()) std::endl; std::cout static_castconst void*(s2.c_str()) std::endl;如果两个c_str()返回的地址相同那铁定是浅拷贝不需要再猜了。这个小技巧我在调试线上偶发崩溃时救过几次命步骤简单但特别有效。
返回列表