【C++】危险根源堆内存
拷贝构造函数
拷贝构造函数(Copy Constructor)是 C++ 中的一种特殊构造函数,用于通过一个已存在的对象来初始化新对象。它主要用于对象的值拷贝,确保新对象拥有与源对象相同的数据内容。
作用
默认的拷贝构造函数是浅拷贝,即只复制对象的成员变量的值。如果对象中包含动态分配的内存(如 char*),浅拷贝会导致多个对象共享同一块内存,从而引发双重释放等问题。
通过实现深拷贝构造函数,可以确保每个对象都有自己独立的动态内存,避免这些问题。
浅拷贝
仅复制对象的成员变量值(对于指针,仅复制指针地址,不复制指针指向的内容)。
多个对象共享同一块动态分配的内存。
深拷贝
不仅复制对象的成员变量值,还为动态分配的资源(如指针指向的内存)分配新的空间,并复制内容。
每个对象拥有独立的资源,互不影响。
代码示例
定义一个string类
成员变量
char* str_{ nullptr };
一个指向动态分配的字符数组的指针,用于存储字符串内容。
int size_{};
一个整数,用于存储字符串的长度(不包括末尾的 \0)。

构造函数
接受一个 C 风格字符串(const char*)。
使用 strlen 计算字符串长度,并分配足够大小的动态内存(size_ + 1,包括末尾的 \0)。
使用 memcpy 将字符串内容复制到动态分配的内存中。
输出 "create String" 和传入的字符串内容。

析构函数
输出 "Drop String" 和字符串长度。
释放动态分配的内存(delete str_),并将指针置为 nullptr。
将 size_ 置为 0。

成员函数 C_str
返回内部存储的字符串(str_)。
如果 str_ 为 nullptr,返回空字符串 ""。

测试函数 TestString
接受一个 String 对象作为参数(按值传递)。
按值传递会触发 String 的拷贝构造函数(尽管这里没有显式定义拷贝构造函数,C++ 会生成一个默认的浅拷贝构造函数)。
输出 "TestString:" 和传入的字符串内容。

main 函数
创建一个 String 对象 str1,传入字符串 "test my string str1"。
调用构造函数,输出 "create Stringtest my string str1"。
调用 TestString(str1),将 str1 按值传递给 TestString。
按值传递会生成 str1 的副本,触发默认的浅拷贝构造函数。
在 TestString 中,输出 "TestString:test my string str1"。
当 TestString 函数返回时,其局部参数 s 被销毁,触发析构函数,输出 "Drop String..."。
当 main 函数结束时,str1 被销毁,再次触发析构函数,输出 "Drop String..."。

预期的编译如下
create Stringtest my string str1
调用 String 的构造函数,初始化 str1。
TestString:test my string str1
调用 TestString,输出传入的字符串内容。
Drop String22
TestString 的局部参数 s 被销毁,触发析构函数。
字符串长度为 22("test my string str1" 的长度)。
Drop String22
main 中的 str1 被销毁,触发析构函数。

因为在 TestString(str1) 中,str1 被按值传递,会调用默认的浅拷贝构造函数。
浅拷贝会导致 str1 和 s 共享同一个动态分配的内存(str_)。
当 s 被销毁时,会释放这块内存。
当 str1 被销毁时,会再次尝试释放同一块内存,
导致双重释放(double free)错误。
为了避免报错,我们需要创建拷贝构造函数
参数
const String& s:接受一个对 String 类型对象的常量引用,避免不必要的拷贝,同时const保证不会修改传入的对象。
输出
cout << "Copy String:" << s.str_ << endl;
输出提示信息,表明拷贝构造函数被调用,并显示被复制的字符串内容(s.str_)。
复制 size_
size_ = s.size_;
将传入对象 s 的字符串长度赋值给新对象的 size_。
分配新内存
str_ = new char[size_ + 1];
为新对象分配动态内存,大小为 size_ + 1(包括末尾的 \0)。
复制字符串内容
memcpy(str_, s.str_, size_ + 1);
使用 memcpy 将传入对象 s 的字符串内容复制到新对象的动态内存中。

移动语义
移动语义(Move Semantics)是C++11引入的一项重要特性,旨在通过减少不必要的对象拷贝来提高程序性能,特别是在处理大型或复杂对象时。
移动语义通过移动构造函数(Move Constructor)和移动赋值运算符(Move Assignment Operator)实现,它们允许对象“接管”另一个对象的资源,而不是复制它们。
使用原因
在C++11之前,当需要将一个对象传递给函数、从函数返回对象,或者将一个对象赋值给另一个对象时,通常会进行深拷贝。深拷贝会分配新的内存并复制数据,这在处理大型对象(如std::vector、std::string等)时可能导致性能瓶颈。
移动语义通过“移动”资源而不是复制资源来解决这个问题。移动后的源对象通常处于有效但未定义的状态(例如,指针被置为nullptr,或大小被置为0),而目标对象则接管了这些资源。
右值引用
在C++98/03中,当对象需要被复制时,通常会调用拷贝构造函数,这可能导致性能开销(如动态内存分配、深拷贝等)。
某些临时对象(如函数返回值)的生命周期很短,复制它们的资源没有意义,反而浪费性能。
右值引用允许将临时对象(右值)的资源“移动”到新对象,而不是复制。
通过移动构造函数和移动赋值运算符,可以“接管”临时对象的资源(如动态内存、文件句柄等),避免深拷贝。
左值
左值(Lvalue,Locator value)是C++中可以出现在赋值操作符左侧的表达式。它表示一个有明确存储位置的对象,可以被引用或修改。
左值的核心特征
有内存地址:左值通常对应一个具体的内存位置。
可修改:左值可以被赋值或修改(除非是const修饰的左值)。
生命周期:左值通常有明确的生命周期(如局部变量、全局变量、对象成员等)。
示例如下
int x = 10; // x 是左值
x = 20; // 左值出现在赋值操作符左侧
int arr[5] = {0};
arr[0] = 42; // arr[0] 是左值
int y = 30;
int* p = &y;
*p = 50; // *p 是左值
右值
右值(Rvalue,Read value)是C++中不能出现在赋值操作符左侧的表达式。它通常是一个临时值,没有明确的存储位置,且在表达式结束后会被销毁。
右值的核心特征
无持久存储:右值通常是临时对象或字面量,没有明确的内存地址。
不可修改:右值不能被直接赋值或修改(除非通过右值引用)。
生命周期短暂:右值在表达式求值完成后立即销毁。
示例如下
int x = 10; // 10 是右值
double y = 3.14; // 3.14 是右值
std::string s = std::string("Hello") + " World";
// "Hello" + " World" 返回的临时对象是右值
int a = 5, b = 10;
int c = a + b; // a + b 的结果是右值
int foo() { return 42; }
int d = foo(); // foo() 的返回值是右值
左值右值比较
| 特性 | 左值 | 右值 |
|---|---|---|
| 存储位置 | 有明确的内存地址 | 通常是临时值,没有持久存储位置 |
| 能否赋值 | 可以 | 不能(除非是右值引用) |
| 常见例子 | 变量、数组元素、解引用指针 | 字面量、临时变量、表达式结果 |
| C++11 扩展 | 左值引用(T&) | 右值引用(T&&) |
Move函数
主要作用是将一个对象显式地转换为右值引用,从而允许调用移动构造函数或移动赋值运算符,实现资源的“移动”而非“拷贝”。
作用
显式转换:
将一个左值或右值转换为右值引用(T&&)。
这意味着调用者明确表示不再需要该对象的原有资源,资源可以被“移动”到另一个对象。启用移动语义:
通过将对象转换为右值引用,触发移动构造函数或移动赋值运算符,从而避免深拷贝,提高性能。
与左值/右值无关:
std::move 本身并不移动对象,它只是将对象标记为右值引用,具体的移动操作由移动构造函数或移动赋值运算符完成。
Vector移动语义
创建一个类,并用vector容器创建三个类对象

创建一个函数,其参数是装着类对象的vector数组

将创建的数组传给函数
这里会触发 vector 的
默认拷贝构造,也就是说,TestData 函数中的参数d 是 datas 的一个副本。

运行程序可以发现,每个类对象销毁了两次,即三个对象一共六次
当 TestData 函数结束时,d 的生命周期结束,会调用 vector 的析构函数,从而销毁其内部的 Data 对象。接着,在 main 函数中,datas 的生命周期结束时,又会再次销毁 datas 中的 Data 对象。这会导致 Data 的析构函数被调用两次(每个对象两次),从而可能引发重复释放资源的问题(尽管在这个例子中不会崩溃,因为 Data 没有管理动态分配的资源)。

监听以下拷贝构造的过程

如果将TestData 函数中的参数不进行复制,而是移动语义进行右值引用
相当于直接把栈空间中的Data对象移动到了函数中

打印以下移动前后对象的大小
移动后原先的对象就失效了

自定义移动构造
创建一个String对象如图

用string直接构造一个str1,拷贝构造一个str2

用移动语义构造str3
此时再打印str2就没有了

因此移动构造函数的逻辑就是将str2的内容,移动到str3中,并将str2为空
在创建的String自定义的移动构造函数如下,功能和string的相同

运行代码,可以得到和string一样的效果

注意!移动构造函数的参数没有const,因为需要对参数进行修改

赋值运算符
如果我们定义一个str4

如果想让str4等于str3,会出现错误
operator=是赋值运算符,该函数控制赋值的行为,把(operator=)整体当作一个函数名

基于拷贝构造和移动构造,在赋值时也分为拷贝赋值和移动赋值
他们构造函数的方式有所不同

拷贝赋值 vs. 移动赋值
| 特性 | 拷贝赋值 | 移动赋值 |
|---|---|---|
| 资源管理 | 复制资源 | 转移资源 |
| 性能 | 较慢(涉及内存分配和数据复制) | 较快(避免不必要的复制) |
| 适用对象 | 左值(已存在的对象) | 右值(临时对象或通过 std::move 转换的对象) |
| 默认实现 | 编译器生成浅拷贝版本 | 编译器不会自动生成,需手动实现 |
| 安全性 | 安全(深拷贝避免资源冲突) | 需小心处理,确保原对象处于有效状态 |
拷贝赋值
拷贝赋值函数的代码大纲如下
operator=函数返回String的引用,参数是String的引用
函数体中直接返回了 *this,即当前对象。

在赋值前先进行清理

然后进行赋值

如果自己给自己赋值也会出现错误

容错处理

错误消失

移动赋值
移动赋值大纲如下

容错处理

赋值前清空内存

将内容移动过来

清空原来的数据

测试成功

智能指针
智能指针(Smart Pointer) 是 C++ 中用于管理动态分配内存的一种工具,主要目的是避免传统指针可能导致的 内存泄漏 和 悬空指针 问题。智能指针在对象生命周期结束时自动释放资源。
智能指针头文件

简单创建一个Data类

创建一个智能指针
创建方法一

使用new 运算符动态分配一个 Data 类型的对象,并调用其构造函数。然后将Data交给智能指针来管理

创建方法二,(C++14之后的方法,优先使用)

为什么推荐使用 std::make_unique(C++14 起)
代码更简洁
编译器产生更小更快的代码
异常安全:
std::make_unique 是一个函数调用,可以保证内存分配和对象构造的原子性。
如果 Data 的构造函数抛出异常,std::make_unique 不会导致内存泄漏,而直接使用 new 的方式在某些复杂场景下可能存在潜在风险(例如同时分配多个资源时)。
避免手动使用 new:
减少直接使用 new 和 delete,降低内存泄漏和悬垂指针的风险。
在类中创建一个函数

用智能指针使用该函数

对象的访问
(*指针)是对象

通过智能指针拿到普通指针

智能指针释放原有空间

释放空间
将 unique_ptr 当前管理的对象释放,并接管一个新分配的 Data 对象的所有权。

释放控制权
控制权转给了普通指针,要自行释放内存(为了兼容旧系统)

智能指针的移动构造和移动赋值

智能指针的实现
需求
构造存储对对象指针
析构删除空间
限制拷贝构造和赋值函数,保证指针使用的唯一性
支持移动构造和移动赋值
代码实现
第一步:创建一个指针,将参数指针构造进来,析构时释放掉

验证

第二步:当指针拷贝构造时,会因为两个指针双重释放报错

禁止拷贝构造

验证

第三步:有了带参数的构造函数后,默认的构造函数没有了

重写默认构造函数

或者

验证

第四步:限制拷贝赋值函数

验证

第五步:Get方法实现返回普通指针

验证

第六步:Reset方法,接管当前对象,释放当前空间

验证


浙公网安备 33010602011771号