自学内容网 自学内容网

【C++】指针与智慧的邂逅:C++内存管理的诗意

RAII

RAII(Resource Acquisition Is Initialization)是一种广泛应用于 C++ 等编程语言中的编程范式,它的核心思想是:资源的获取和释放与对象的生命周期绑定。在 RAII 中,资源(如内存、文件句柄、网络连接等)的获取通常发生在对象的构造函数中,而资源的释放则发生在对象的析构函数中。

这种设计模式确保了资源在不再需要时自动释放,从而避免了手动管理资源的复杂性和潜在的错误(如内存泄漏和资源泄露)。

核心思想

  • 资源获取: 当一个对象被创建时,它会立即获取某个资源。例如,分配内存、打开文件或创建数据库连接等。
  • 资源释放: 当该对象超出作用域或被销毁时,它的析构函数会自动释放相应的资源。这意味着开发者不需要显式地释放资源,降低了出错的概率。

实现方式

  • 构造函数:在对象创建时,负责分配所需的资源。例如,在构造函数中打开一个文件或分配一块内存。
  • 析构函数:在对象销毁时,负责释放该对象占用的资源。当对象的生命周期结束时,析构函数会自动执行,释放资源。

RAII 的优势

  • 自动资源管理: RAII 自动处理资源的释放,不需要显式调用资源释放代码,减少了出错的可能性(如忘记释放资源)。
  • 异常安全: RAII 能够保证即使程序中发生异常,资源也会被正确释放。例如,在 try 块中的对象被销毁时,析构函数会自动释放资源,从而避免资源泄漏。
  • 简洁性和易维护性: 使用 RAII 模式可以使资源管理代码更加简洁和模块化,减少了繁琐的手动管理。
  • 防止内存泄漏: 通过将资源与对象的生命周期绑定,可以有效防止内存泄漏、悬挂指针等问题。

RAII 的缺点

  • 不能自由控制资源释放的时机: 在 RAII 模式中,资源的释放依赖于对象的生命周期,无法显式控制资源的释放时机。如果需要在对象销毁之前释放资源,RAII 可能不适用。
  • 资源生命周期绑定问题: RAII 通过对象生命周期管理资源,这对于某些类型的资源可能不适用。例如,某些外部资源(如数据库连接)可能需要在特定时刻关闭,而不仅仅是在对象销毁时。

RAII 的应用场景

  • 内存管理:例如,unique_ptrshared_ptr 是 C++ 中的智能指针,它们的实现就是基于 RAII 模式,自动管理内存资源。
  • 文件操作:如上文所示,RAII 可以用于文件的打开和关闭,确保即使发生异常,文件资源也会被自动释放。
  • 数据库连接:RAII 可用于数据库连接的管理,确保连接在对象生命周期结束时被自动关闭。
  • 线程锁管理:通过 RAII 模式,锁的获取和释放可以自动管理,避免忘记释放锁导致死锁。

智能指针

智能指针(Smart Pointer 是现代 C++ 中用于自动管理动态内存的一种工具,它通过封装原始指针,提供对内存资源的自动管理,帮助避免常见的内存管理错误,如内存泄漏和悬挂指针。

智能指针实际上是一个类,它重载了指针操作符(如 *->),使得使用智能指针的代码和普通指针一样简便,但它能自动处理资源的释放。

C++标准库中的智能指针都在 <memory> 这个头文件下,智能指针主要有 auto_ptrunique_ptrshared_ptrweak_ptr 等。

auto_ptr

auto_ptr 是C++98标准中引入的一个智能指针类型,通过自动释放资源来避免内存泄漏和悬挂指针的问题。

1. auto_ptr 的缺陷

auto_ptr 的设计存在巨大缺陷,在涉及资源所有权转移时(拷贝或者赋值时)原auto_ptr 不再拥有资源,资源的所有权转移给 目标auto_ptr ,这导致了 原auto_ptr 变成一个悬挂指针(类似于空指针)。

代码示例:

//模拟一个日期类
struct Date
{
int _year;
int _month;
int _day;

Date(int year = 2000, int month = 1, int day = 1)
:_year(year)
,_month(month)
,_day(day)
{}

~Date()
{
cout << "~Date" << endl;
}

};

int main()
{
auto_ptr<Date> ap1(new Date);
//拷贝时,Date的管理权限从ap1转移ap2,ap1被置空
auto_ptr<Date> ap2(ap1);

//ap1相当于空指针了,再去访问会造成程序崩溃
//ap1->_day;

//赋值也是同样的道理,Date的管理权限从ap2转移ap3,ap2被置空
auto_ptr<Date> ap3;
ap3 = ap2;

//ap2被置空,访问会造成程序崩溃
ap2->_day;

return 0;
}

auto_ptr 的设计存在缺陷,在在涉及资源所有权转移时,其行为会造成意外的错误,auto_ptr在C++11中被废弃,不推荐使用

2. auto_ptr 的模拟实现
auto_ptr 的模拟实现比较简单,在涉及资源的转移时,将原指针置空即可。

template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr = nullptr)
:_ptr(ptr)
{}

auto_ptr(auto_ptr<T>& ap)
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}

auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
if (_ptr != ap._ptr)
{
if (_ptr)
delete _ptr;

_ptr = ap._ptr;
ap._ptr = nullptr
}
return *this;
}

~auto_ptr()
{
if (_ptr)
delete _ptr;
_ptr = nullptr;
}

T& operator*()
{
return *_ptr;
}

T* operator->()
{
return _ptr;
}

private:
T* _ptr;
};

unique_ptr

unique_ptr 是独占式的智能指针,表示指向一个动态分配的对象的唯一所有者。该指针不支持拷贝和赋值,但支持移动构造或者赋值

当一个资源只能有一个拥有者时,使用 unique_ptr 是最合适的选择。

代码示例:

unique_ptr<Date> up1(new Date);
//不支持拷贝或者赋值
//unique_ptr<Date> up2(up1);
//unique_ptr<Date> up3; up3 = up1;

//支持移动构造或者赋值,但是ap1置空了,谨慎使用
unique_ptr<Date> up2(move(up1));
unique_ptr<Date> up3;
up3 = move(up2);

1. make_unique
make_unique 是 C++11/14 标准库中引入的一个函数模板,用于创建动态分配的对象并返回一个 unique_ptr,从而安全高效地管理对象的生命周期。

template <typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args);

与直接使用 new 操作符相比的优势

  1. 避免手动调用 newdelete :使用 make_unique 能够简化动态内存分配,避免使用裸指针容易产生的内存泄漏或未定义行为。
  2. 性能优化 :它能够一次性分配对象和控制块所需的内存,减少额外开销。
  3. 强异常安全性:使用 make_unique 时,不会因为对象构造和分配的中间异常而泄漏内存。
//使用 make_unique 创建一个 int 类型的 unique_ptr(推荐)
auto up1 = make_unique<int>(20);

//直接使用 unique_ptr(容易出错),如果构造函数抛异常就会出现内存泄漏
unique_ptr<int> up2(new int(10));

2. 定制删除器

unique_ptr 在释放资源时,默认是 delete _ptr ,如果指向的资源是 new type[num] 而来的,默认释放资源的方式就不适合了,需要 delete[] 的方式是释放资源,这时我们需要定制删除器。

new [] 的方式经常使用,库里已经有了特化版本,而对于定制删除器,仿函数、函数指针、lamba表达式都可作为删除器。

不过要注意的是传定制删除器给 unique_ptr ,是传给模板参数,其构造参数也要传。
在这里插入图片描述
代码示例:

//new []特化
unique_ptr<Date[]> up1(new Date[5]);

// 仿函数作删除器,将其传到模板参数,仿函数构造的对象可以直接调用,不需要传给构造参数
unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);

//函数指针作删除器,既要传模板参数也要传构造参数
unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);

// lambda表达式作删除器,decltype获取delArr的类型
auto delArr = [](Date* ptr) {delete[] ptr; };
unique_ptr<Date, decltype(delArr)> up4(new Date[5], delArr);

简单说一下定制删除器的底层,将定制删除器的类型传过去利用其类型创建删除器对象并用传给构造参数的具体定制删除器对象来初始化,这样底层就有了外层传进来的定制删除器,然后利用删除器释放资源

3. unique_ptr 的模拟实现

unique_ptr 的模拟实现也比较简单,将其构造函数赋值重载函数 delete 即可。

template<class T>
class unique_ptr
{
public:
//防止隐式类型转换
explicit unique_ptr(T* ptr)
:_ptr(ptr)
{}

~unique_ptr()
{
if (_ptr)
delete _ptr;
_ptr = nullptr;
}

unique_ptr(const unique_ptr<T>& up) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& up) = delete;

unique_ptr(unique_ptr<T>&& up)
:_ptr(up._ptr)
{
up._ptr = nullptr;
}

unique_ptr<T>& operator=(unique_ptr<T>&& up)
{
delete _ptr;
_ptr = up._ptr;
up._ptr = nullptr;
}

private:
T* _ptr;
};

有关 explicit

关键字 explicit 的作用是修饰构造函数,防止隐式类型转换

Date* ptr = new Date;
//hz::unique_ptr<Date> up1 = ptr; 这种写法编译会出错,explicit不允许隐式类型转换,本质是构造+拷贝
unique_ptr<Date> up1(ptr);

使用 explicit 修饰单参数构造函数可以提高代码的可读性,减少维护负担。

shared_ptr

shared_ptr 是 C++11 标准引入的一种智能指针,用于管理动态分配的对象,并允许多个 shared_ptr 实例共享同一对象的所有权。shared_ptr 使用 引用计数 来追踪有多少个 shared_ptr 对象共享资源,并在最后一个 shared_ptr 被销毁时自动释放资源。这种机制确保了内存管理的安全性,避免了内存泄漏,同时允许多个对象共享相同的资源。

shared_ptr 是一种 共享所有权 的智能指针,而非独占所有权(像 unique_ptr )。多个 shared_ptr 对象可以共同管理一个动态分配的对象,而不必担心资源的重复释放或遗漏释放。

代码示例:

//创建一个 shared_ptr 管理 Date 对象
shared_ptr<Date> sp1(new Date);

//复制sp1,增加引用计数
shared_ptr<Date> sp2(sp1);
cout << "Reference count: " << sp1.use_count() << endl;

//当 sp1 和 sp2 超出作用域时,Date 对象会被自动销毁

代码解析:
1.shared_ptr<Date> sp1(new Date);

  • sp1 是一个 shared_ptr,它管理一个动态分配的 Date 对象。此时引用计数为 1。

2.shared_ptr<Date> sp2(sp1);

  • sp2sp1 的副本,意味着它也指向同一个 Date 对象,引用计数增加到 。

3.sp1.use_count() 返回当前有多少个 shared_ptr 管理相同的对象。此时返回 2。

4.当 sp1sp2 超出作用域时,它们的引用计数都会减少。当引用计数降到 0 时,Date 对象会自动销毁。

有关 make_shared

make_shared 也是一个函数模板,用于创建共享指针,可以接受任何类型的参数,并返回一个指向该类型对象的共享指针。

template <class T, class... Args>
  shared_ptr<T> make_shared (Args&&... args);
  • make_shared 与直接使用 shared_ptr 的对比
特性make_shared直接用shared_ptr
语法简洁性更简洁需要手动调用 new
内存分配次数1 次2 次(对象和引用计数分别分配)
异常安全性更安全容易出现内存泄漏

模拟实现

对于 shared_ptr 的模拟实现,我们首先要考虑的就是引用计数的设计。

引用计数用静态成员变量是无法实现的

因为静态成员变量是整个类共有的,每当指向一个资源,无论是不同的资源还是相同的资源,静态成员变量都会增加,不能做到对于不同的资源都有独立的一份引用计数

比如 sp1 和 sp2 指向着资源1,引用计数是2,在创建一个 sp3 指向资源2,由于引用计数是静态成员变量,引用计数就变成3了,这显然是错误的,sp3 的引用计数应该是1.

  • 引用计数的设计应该采用动态开辟的方式,做到每一个不同的资源都有一份独立的引用计数。
    在这里插入图片描述
    以下为 shared_ptr 的实现:
template<class T>
class shared_ptr
{
public:
//explicit防止隐式类型转换
explicit shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{}

~shared_ptr()
{
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
}

shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
{
++(*_pcount);
}

shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}

_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}

T& operator*()
{
return *_ptr;
}

T* operator->()
{
return _ptr;
}

 T* get() const
 {
  return _ptr;
 }
 
 int use_count() const
     {
         return *_pcount;
     }

private:
T* _ptr;
int* _pcount;
};

shared_ptr 的成员函数的实现都比较简单,但是赋值重载函数有比较多细节要注意:

  1. 赋值操作要保证不是一个指针自己给自己赋值this != &sp 不能完全处理所有情况,因为不同的 shared_ptr 对象的 _ptr 可能是一样的,得用 _ptr != sp._ptr 才可以完全覆盖所有情况。

  2. 被赋值的指针的引用计数要先要减1判断该指针是否是最后一个指向对应资源的指针若是则要释放原来的资源

  3. 进行赋值操作,完成后引用计数要+1,最后返回 *this
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
if (--(_pcount) == 0)
{
delete _ptr;
delete _pcount;
}

_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}

定制删除器

shared_ptr 也可以传定制删除器,不过相比 unique_ptr 的方式,shared_ptr 传递删除器的方式只需传到构造函数的参数即可

//其构造函数的声明
template <class U, class D> shared_ptr (U* p, D del);
template <class D> shared_ptr (nullptr_t p, D del);

使用示例:

template<class T>
struct DeleteArray
{
void operator()(T* ptr)
{
delete[] ptr;
}
};

template<class T>
void DeleteArrayFunc(T* ptr)
{
delete[] ptr;
}

//[]特化版本
shared_ptr<int[]> sp1(new int[10]);

//仿函数作删除器
shared_ptr<Date> sp2(new Date[10], DeleteArray<Date>());

//函数指针作删除器
shared_ptr<Date> sp3(new Date[10], DeleteArrayFunc<Date>);

//lambda作删除器
auto  delArr = [](Date* ptr) {delete[] ptr; };
shared_ptr<Date> sp4(new Date[10], delArr);

增加定制删除器的模拟实现:

template<class T>
class shared_ptr
{
public:
//explicit防止隐式类型转换
explicit shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
, _pcount(new int(1))
{
}

template<class D>
shared_ptr(T* ptr, D del)
:_ptr(ptr)
, _pcount(new int(1))
,_del(del)
{}

~shared_ptr()
{
if (--(*_pcount) == 0)
{
_del(_ptr);
delete _pcount;
}
}

shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _del(sp._del)
{
++(*_pcount);
}

shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
if (--(*_pcount) == 0)
{
_del(_ptr);
delete _pcount;
}

_ptr = sp._ptr;
_pcount = sp._pcount;
_del = sp._del;
++(*_pcount);
}
return *this;
}

T& operator*()
{
return *_ptr;
}

T* operator->()
{
return _ptr;
}

private:
T* _ptr;
int* _pcount;
//利用function来包装 _del,默认是不带[] 的delete
function<void(T*)> _del = [](T* ptr) {delete ptr; };
};

代码细节:

  • 写多一个构造函数并套一层模板,当传递删除器的时候,调用此函数。
template<class D>
shared_ptr(T* ptr, D del)
:_ptr(ptr)
, _pcount(new int(1))
,_del(del)
{}
  • 删除器可以是仿函数、函数指针和 lambda 表达式等,我们是没有办法用具体的某个类型去创建 _del 变量,但是C++11中有一个类模板 function ,它是通用的函数包装器,可以包装仿函数、函数指针和 lambda 表达式,而删除器的函数签名都是 void(T* ptr)(返回类型和参数类型)。我们就可以用 function 来创建 _del 变量,并给上 lambda 缺省值 [](T* ptr) {delete ptr; }
//利用function来包装 _del,默认是不带[] 的delete
function<void(T*)> _del = [](T* ptr) {delete ptr; };

循环引用 和 weak_ptr

智能指针是用来管理动态分配的内存,以避免内存泄漏的问题。然而,如果使用不当,智能指针也会引入一些新的问题,例如循环引用

循环引用(Cyclic Reference)是指两个或多个对象互相持有对方的引用形成一个环导致它们无法被释放,即使它们已经不再被其他部分使用。

代码示例

class Node {
public:
//指向下一个节点的智能指针
shared_ptr<Node> next;
~Node() { cout << "Node destroyed" << endl; }
};

int main() {
auto node1 = make_shared<Node>();
auto node2 = make_shared<Node>();

// 相互引用
node1->next = node2;
node2->next = node1;

// 程序结束时,node1和node2不会被释放
return 0;
}

在这里插入图片描述

为什么会出现循环引用?

  1. shared_ptr 的原理:

    • shared_ptr 通过引用计数来管理对象的生命周期。
    • 当引用计数变为0时,shared_ptr 会自动释放内存。
  2. 循环引用的本质:

    • 在上述例子中,node1node2 互相持有对方的 shared_ptrnode1 需要 node2的shared_ptr 析构时释放,而 node2的shared_ptrnode2 的成员变量,需要让 node2 释放才会析构,node2 需要 node1的shared_ptr 析构时释放, node1的shared_ptr 需要让 node1 释放才会析构。这样就形成一个环了,两个节点的 shared_ptr 的引用计数始终不为0;
    • 即使它们超出了作用域,也不会被销毁,从而引发内存泄漏
      在这里插入图片描述

如何解决循环引用问题?

可以通过将其中一个 shared_ptr 替换为 weak_ptr来打破循环引用。

class Node {
public:
// 用weak_ptr打破循环引用
 weak_ptr<Node> next;
~Node() { cout << "Node destroyed" << endl; }
};

int main() {
auto node1 = make_shared<Node>();
auto node2 = make_shared<Node>();

// weak_ptr不会增加引用计数
node1->next = node2;
node2->next = node1;

// 程序结束时,node1和node2会被正确释放
return 0;
}

程序结束时,先析构 node2 ,引用计数减到0,释放 node2 ,而不会像循环引用那般由于 node2 和 node1->next 指向同一个对象,析构 node2 时其引用计数从2减到1,导致引用计数永远不为0导致 node2 无法释放。node2 释放后 node1 也能正常释放了

weak_ptr 是一种辅助智能指针,它与 shared_ptr 配合使用,用于解决循环引用问题实现对象的非强拥有关系

  • weak_ptr 是一种不参与引用计数的智能指针。
  • 它不会改变所指向对象的生命周期,仅仅是一个“弱引用”。
  • 常用于观察由 shared_ptr 管理的对象,而不会影响其销毁时机。

基本用法:

  • weak_ptr 必须从 shared_ptr 初始化,不能直接管理动态分配的内存。
  • 通过 weak_ptr 无法直接访问对象,需要调用 lock() 方法将其转换为 shared_ptr
    lock() 方法返回一个指向相同对象的 shared_ptr,如果对象已被释放,则返回一个空指针。

shared_ptr 的区别

特性shared_ptrweak_ptr
是否参与引用计数
是否影响生命周期
访问对象方式直接使用 *->需调用 lock() 转换为 shared_ptr
应用场景强拥有关系,负责对象生命周期弱引用,避免循环引用或临时访问

总结

  • weak_ptr 是一种轻量级的智能指针,用于观察对象,不参与对象生命周期管理。
  • 在设计需要临时引用或防止循环引用的场景中,weak_ptr 是一个非常重要的工具。
  • 配合 shared_ptr 使用,能够更好地管理复杂对象间的依赖关系。

拜拜,下期再见😏

摸鱼ing😴✨🎞
请添加图片描述


原文地址:https://blog.csdn.net/2301_80373479/article/details/144029980

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!