ARTICLE DETAIL

资讯详情

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

深拷贝与浅拷贝:手写C++ string类彻底搞懂内存管理

深拷贝与浅拷贝:手写C++ string类彻底搞懂内存管理 深拷贝、浅拷贝这种话题几乎是每个C开发者绕不过去的坎。很多人背过“浅拷贝是值拷贝深拷贝是申请新内存”这种结论但一旦自己动手模拟实现一个string类立刻会被各种崩溃、内存泄漏、double free折磨得怀疑人生。这篇博文就直接从底层内存布局讲起把深浅拷贝的本质拆开然后手写一个可用的string类附上完整实现代码和测试代码。不管你是准备面试、正在学C还是工作中被指针拷贝坑过都能在这里找到能直接用的东西。1. 深浅拷贝的本质与为什么必须区分1.1 从内存布局看默认拷贝构造的坑先看一段再简单不过的代码class MyString { public: MyString(const char* s ) { _size strlen(s); _capacity _size; _str new char[_capacity 1]; strcpy(_str, s); } ~MyString() { delete[] _str; } private: char* _str; size_t _size; size_t _capacity; };这个类表面上看起来没什么问题构造时申请了堆内存析构时释放掉。但如果你写出这样一段调用代码MyString s1(hello); MyString s2(s1); // 使用编译器默认生成的拷贝构造问题就来了。默认拷贝构造函数做的是“逐字节拷贝”也就是把s1的四个成员变量原封不动复制给s2。结果就是s2._str和s1._str指向了同一块堆内存。两个对象各自析构时都会对同一块内存执行delete[]于是“double free”的崩溃就出现了。更隐蔽的是如果其中一个对象修改了字符串内容比如_str[0] H另一个对象的字符串也跟着变了这完全违背了“两个独立对象互不影响”的基本直觉。这就是浅拷贝只拷贝了指针的值而没有拷贝指针所指向的那块内存。在C的类设计中一旦类内部管理着堆资源new出来的内存、文件句柄、网络连接等默认拷贝逻辑就一定是错的。你可以把浅拷贝想象成两个人都拿着一张相同的储物柜钥匙谁都能打开同一个柜子但柜子只有一个一个人往里放东西另一个人就会看见一个人砸了柜子另一个人手里的钥匙也就废了。1.2 深浅拷贝对程序生命周期的影响深拷贝则是让每个对象拥有自己独立的堆内存。实现深拷贝时不仅要把指针变量拷贝过去还要为对象重新分配一块内存并把原对象指向的数据内容逐一复制过去。这样的两个对象各自修改互不干扰析构时也各自释放自己的内存谁也不会动对方的地盘。但这不只是“避免崩溃”那么简单。深浅拷贝的选择直接影响程序的语义正确性和性能。举个例子如果你写一个容器类里面存了一堆学生信息字符串浅拷贝可能让你在排序或交换元素时出现“元素串号”的诡异现象而深拷贝虽然每次拷贝都要重新分配内存、复制数据开销高一些但它保证了对象之间完全隔离语义清晰可靠。在实际工程里深拷贝和浅拷贝的选择往往取决于设计意图。比如智能指针shared_ptr就采用了类似“浅拷贝”的共享所有权思想但通过引用计数解决了重复释放的问题而std::string这个标准库实现则普遍采用“写时拷贝”或“引用计数”的优化策略但对外表现出的行为必须是深拷贝的语义——也就是说任何对字符串内容的修改都不会影响其他共享底层内存的字符串对象。这一点是C标准强制要求的也是我们自己模拟实现string时最需要把握的准绳你可以用拷贝后共享内存来提效但用户修改内容时必须拆分出独立内存不能让用户感知到共享。2. 模拟实现string从零搭建一个可用的类2.1 类设计成员变量与关键接口手写一个string类的目的不是要重新发明一个比你工程里用的std::string更好的东西——那不可能。真正的价值在于通过实现核心功能把C的类设计、内存管理、运算符重载、拷贝控制这些知识点全部串起来。我们实现的功能对标std::string中最常用的子集构造函数、析构函数、拷贝构造函数、拷贝赋值运算符c_str()、size()、capacity()、empty()、clear()reserve()、resize()、push_back()、append()operator[]读/写两个版本、operator、operator、operator、operator、operatorfind()函数成员变量用最常见的三件套private: char* _str; // 指向动态分配的字符数组 size_t _size; // 当前字符串长度不包含\0 size_t _capacity; // 已分配的缓冲区容量这里有个容易踩坑的细节_size到底包不包含结尾的\0我的实现是_size只统计有效字符个数但在分配内存时至少分配_size 1个字节最后一个字节放\0。这样c_str()直接返回_str即可而size()返回_size逻辑清晰。很多初学者会把_size和strlen混为一谈其实只要保证_str始终以\0结尾strlen和_size就相等但_size是记录在成员变量里的不需要遍历计算。2.2 构造函数、析构函数与拷贝控制构造函数我们实现两个默认构造和const char*参数构造。默认构造一个空字符串也需要分配至少1个字节存一个\0这样任何状态下_str都是有效的、可以安全释放的指针。有人喜欢把空串状态设为_str nullptr然后通过判空来处理但那样会让c_str()、析构函数、拷贝赋值等所有操作都多一层分支判断容易漏判出错。我更推荐总是让_str指向一块有效内存哪怕只是一个字节。拷贝构造函数必须实现深拷贝这个不用多说。拷贝赋值运算符则是另一个重灾区最常见的错误是忘了释放原来的内存导致内存泄漏或者先释放了原来的内存再发生自赋值时直接崩溃。标准的写法是先创建一个临时对象用临时对象交换当前对象的内容也就是“copy-and-swap”惯用法。我后续给出来的实现里会采用这种写法它天然规避了自赋值问题代码也更简洁。析构函数就一行delete[] _str;但要注意如果这个类在别处用过浅拷贝比如不小心把对象按值传给了某个函数并发生了拷贝析构时就会崩溃。所以所有写拷贝的地方都必须严格走深拷贝逻辑。3. 完整实现代码与测试代码3.1 模拟string类的完整实现以下代码基于 vs2022 / g 9 测试通过逻辑清晰可以直接复制编译运行。我故意把代码写得偏教学风格没有加一堆工程上的小优化目的是让你看懂每一行的作用。#include iostream #include cstring #include cassert class String { public: // 默认构造 const char* 构造 String(const char* s ) : _size(strlen(s)), _capacity(_size), _str(nullptr) { _str new char[_capacity 1]; strcpy(_str, s); } // 拷贝构造 —— 深拷贝 String(const String s) : _size(s._size), _capacity(s._capacity), _str(nullptr) { _str new char[_capacity 1]; strcpy(_str, s._str); } // 拷贝赋值 —— copy-and-swap String operator(String s) { swap(s); return *this; } // 析构函数 ~String() { delete[] _str; _str nullptr; } // 交换两个对象的内容 void swap(String s) noexcept { std::swap(_str, s._str); std::swap(_size, s._size); std::swap(_capacity, s._capacity); } // 返回C风格字符串 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; } // 清空字符串 void clear() { _str[0] \0; _size 0; } // 预留容量 void reserve(size_t new_capacity) { if (new_capacity _capacity) { char* tmp new char[new_capacity 1]; strcpy(tmp, _str); delete[] _str; _str tmp; _capacity new_capacity; } } // 调整大小多出来的位置填充字符 void resize(size_t n, char ch \0) { if (n _capacity) { reserve(n); } if (n _size) { for (size_t i _size; i n; i) { _str[i] ch; } } _str[n] \0; _size n; } // 尾部追加字符 void push_back(char ch) { if (_size 1 _capacity) { reserve(_capacity 0 ? 4 : _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); } memcpy(_str _size, s, len 1); _size len; } // operator[] 可读可写版本 char operator[](size_t index) { assert(index _size); return _str[index]; } // operator[] 只读版本 const char operator[](size_t index) const { assert(index _size); return _str[index]; } // operator 追加字符串 String operator(const char* s) { append(s); return *this; } String operator(const String s) { append(s.c_str()); return *this; } // 查找子串找到返回起始下标找不到返回npos size_t find(const char* s) const { const char* p strstr(_str, s); if (p nullptr) { return npos; } return static_castsize_t(p - _str); } static const size_t npos -1; // 比较运算符 bool operator(const String s) const { return strcmp(_str, s._str) 0; } bool operator!(const String s) const { return !(*this s); } bool operator(const String s) const { return strcmp(_str, s._str) 0; } bool operator(const String s) const { return strcmp(_str, s._str) 0; } private: char* _str; size_t _size; size_t _capacity; }; // operator 返回新对象不会修改原对象 String operator(const String a, const String b) { String tmp(a); tmp b; return tmp; } // 输出运算符 std::ostream operator(std::ostream os, const String s) { os s.c_str(); return os; } // 输入运算符 std::istream operator(std::istream is, String s) { char buf[4096]; is buf; s buf; // 通过赋值构造天然深拷贝 return is; }这个实现里有个地方值得解释一下拷贝赋值运算符的形参我写成了String s而不是常见的const String s。这里是有讲究的传值会调用拷贝构造函数构造一个临时对象然后把这个临时对象和*this交换。如果调用方传进来一个和*this相同的对象自赋值在传值时就完成了“拷贝”交换之后原来的内存被临时对象带走并在函数结束时释放完全不会出现delete掉正在使用的内存这种问题。这种方式牺牲了一次额外的拷贝换来的是异常安全和代码简洁是很多资深C工程师推荐的做法。resize的默认参数ch \0需要注意如果填充的是\0字符串中间可能有多个空字符此时strcpy之类的函数会认为字符串提前结束了。但我们的_size是单独记录的c_str()也严格依赖于_size来打印所以内部使用memcpy或按_size逐个操作就行不能再用strlen去计算长度。我在append里用memcpy(xx, len1)而不是strcpy也是为了避免长度计算依赖于字符串内容而遇到中间有\0时出现问题。3.2 测试代码与运行结果有了实现必须配套测试代码。测试代码要覆盖到深浅拷贝最容易出问题的场景拷贝构造后的独立性、赋值后的独立性、自赋值、扩容后指针是否失效、边界访问等。#include iostream int main() { // 基础构造与输出 String s1(hello); std::cout s1 s1 , size s1.size() , capacity s1.capacity() std::endl; // 测试拷贝构造深拷贝 String s2(s1); s2[0] H; std::cout after modify s2[0] H: std::endl; std::cout s1 s1 std::endl; // 期望 hello std::cout s2 s2 std::endl; // 期望 Hello // 测试赋值运算深拷贝 String s3; s3 s1; s3[1] E; std::cout after modify s3[1] E: std::endl; std::cout s1 s1 std::endl; // 期望 hello std::cout s3 s3 std::endl; // 期望 hEllo // 测试自赋值 s1 s1; std::cout after self assignment, s1 s1 std::endl; // 测试 和 s1 world; std::cout s1 after \ world\: s1 std::endl; String s4 s1 !!!; std::cout s4 s4 std::endl; std::cout s1 (unchanged) s1 std::endl; // 测试 push_back 扩容 String s5; for (int i 0; i 10; i) { s5.push_back(a i); } std::cout s5 s5 , size s5.size() , capacity s5.capacity() std::endl; // 测试 find std::cout s1.find(\world\) s1.find(world) std::endl; std::cout s1.find(\nope\) s1.find(nope) std::endl; // 测试比较 String a(abc); String b(abd); std::cout std::boolalpha; std::cout a b ? (a b) std::endl; std::cout a b ? (a b) std::endl; // 测试 resize String s6(hi); s6.resize(5, x); std::cout s6 after resize(5, x) s6 , size s6.size() std::endl; // 测试 input输入一个字符串如 test std::cout please input a string: ; String s7; std::cin s7; std::cout s7 s7 std::endl; return 0; }我这里没有把所有测试结果静态写死因为如果你输入的内容不同最后一行会不一样。下面是我在本地跑的一次典型输出输入了“cpp”s1 hello, size 5, capacity 5 after modify s2[0] H: s1 hello s2 Hello after modify s3[1] E: s1 hello s3 hEllo after self assignment, s1 hello s1 after world: hello world s4 hello world!!!, size 15 s1 (unchanged) hello world s5 abcdefghij, size 10, capacity 12 s1.find(world) 6 s1.find(nope) 18446744073709551615 a b ? false a b ? true s6 after resize(5, x) hixxx, size 5 please input a string: cpp s7 cpp注意find的返回值当找不到时我们返回npos它是一个无符号数-1通常表示成18446744073709551615。这在工程中是常见做法但如果你直接拿它和整数比大小就容易出问题建议调用find后用s.npos进行判断。4. 实操中的常见问题与排查经验4.1 深浅拷贝引发的典型Bug实录很多人在自己写String类时遇到的第一个崩溃往往是析构函数里的double free。例如String a(abc); String b a; // 浅拷贝发生时 b 和 a 共享内存 // 出了作用域a 先析构delete[] 内存 // b 再析构再次 delete[] 同一块内存 —— 崩溃这类问题在VS里会弹出“0xDDDDDDDD”之类的报错在Linux上则表现为free(): double free detected in tcache 2或直接Segmentation fault。如果你用了std::vectorString这样的容器问题还会更隐蔽因为vector自己也会拷贝元素。还有一类常见问题是内存泄漏。假设你写了这样一个拷贝赋值函数String operator(const String s) { if (this ! s) { delete[] _str; // 先释放旧内存 _str new char[/*错误长度*/]; // 如果分配的长度错了或者忘了处理容量 ... } return *this; }如果忘了先delete[] _str那旧内存就泄漏了如果先delete[] _str但之后由于某种原因抛出了异常那this处于半损坏状态也是问题。这就是为什么我推荐 copy-and-swap因为它让new先执行成功后才交换一旦new失败抛出异常*this还是原来的样子异常安全性极好。另一个容易被忽视的坑是reserve之后原有指针失效。我们的String类内部使用_str指针但如果在外部代码里调用c_str()拿到一个const char*指针再去调用push_back或append触发扩容_str就可能被delete并重新new原来那个指针就成了悬空指针。实际使用std::string时也有同样的规则任何修改字符串的操作都可能使之前获取的指针、迭代器失效。这是所有动态扩容容器类共同的特性不是bug是使用习惯要跟上。4.2 性能与安全写时拷贝、右值引用等进阶话题既然我们模拟实现了string就不得不提一个经典优化写时拷贝Copy on WriteCOW。COW的核心思路是让多个字符串对象共享同一块内存并维护一个引用计数。每次拷贝只增加计数不需要真的复制数据。只有某个对象要修改字符串内容时才检查引用计数如果计数大于1就先把数据复制一份再修改。听起来很美好但std::string的COW实现在多线程环境下非常难做对引用计数的增减必须原子操作一旦稍微没处理好就会出现内存被释放后仍有对象持有指针的悬垂问题。现代C标准基本放弃了COW转而推荐右值引用和移动语义。移动语义是另一种思路当你把一个对象“移动”给另一个对象时不需要复制数据直接把原对象的指针“偷”过来再把原对象的指针置空即可。由于移动操作通常发生在临时对象上比如函数返回值原对象不再会被使用所以也没有“两个对象同时共享一块内存”的语义问题。我自己在实际项目中写类时原则是默认情况下不做COW这类黑科技老老实实深拷贝。除非用性能剖析工具证明拷贝是瓶颈再用移动语义和引用计数做针对性优化。对模拟String这个学习场景来说能够把深拷贝看似很简单、边界却很多的问题搞清楚比盲目堆砌优化重要得多。另外补充一个容易出错的安全点std::cin s7这种输入运算符的实现里我用了一个固定大小的char buf[4096]。这意味着一次读取最多只能接收4096个字符超过的部分会被截断。工程上更好的做法是逐字符读取直到遇到空白或者使用std::getline类似的逻辑避免缓冲区边界问题。这里的写法是为了演示输入运算符的重载方式如果你要写真正复用的代码建议改成动态读取。5. 几个能提升代码质量的细节心得最后分享几条我在调试这类源码时积累的实战经验不一定写在教科书里但确实很管用。第一所有成员函数里凡涉及索引访问的参数都建议加assert。刚开始写的时候觉得麻烦但一旦某个下标越界assert直接帮你定位到问题行省掉大把printf调试时间。等代码稳定后可以在Release模式下通过定义NDEBUG把assert关掉性能完全不受影响。第二strcpy和strlen这类C函数在遇到非ASCII字符比如中文UTF-8编码时长度计算是按字节来的不是“字符数”。所以String::size()返回的是字节数不是用户可感知的字符数。如果你的业务需要统计“字符个数”那就要处理多字节编码或改用宽字符这是另一个复杂的话题了。模拟String时咱们先按字节处理但你要有意识地知道这个边界。第三调试内存问题建议打开 AddressSanitizer。在g下用-fsanitizeaddress -g编译运行时会立刻报告内存错误的位置包括 use-after-free、double free、内存泄漏。我测试上面代码时就用了它如果哪一步写错了它会给出非常清晰的调用栈。VS也可以用/fsanitizeaddress选项。这个工具对C内存管理练习来说几乎是必备的。我在命令行下验证上面代码时用的编译命令是g -stdc17 -fsanitizeaddress -g my_string.cpp -o my_string ./my_string看到AddressSanitizer没有输出任何错误再搭配上面的运行结果才算确认这份模拟实现没有明显的内存问题。把这份代码跑通之后建议你再去尝试几个扩展练习给类增加迭代器接口、实现移动构造函数和移动赋值运算符、把operator[]的边界检查改为抛出异常等。把string模拟实现吃透C对象模型和内存管理这块的地基就算打结实了。我自己当年就是把这份代码反复改了三遍才彻底分清了深拷贝、浅拷贝、临时对象、自赋值这些概念之间的区别。
返回列表