C++11--智能指针

引入

为什么需要智能指针?

在介绍异常时,遇到以下场景,处理异常就会比较棘手:

void Func()
{
	int* arr1 = new int[10];
	int* arr2 = new int[20];
	int* arr3 = new int[30];
	
	// ...
	delete[] arr1;
	delete[] arr2;
	delete[] arr3;
}

这里 arr1 arr2 arr3 在使用 new 申请空间时可能会抛出异常(当空间不足时),这样就有四种情况:

  • arr1 抛出异常,不需要释放空间。
  • arr2 抛出异常,需要释放 arr1 申请的空间。
  • arr3 抛出异常,需要释放 arr1arr2 申请的空间。
  • 没有异常抛出,需要释放 arr1arr2arr3 申请的空间。

那我们在处理异常时,就需要像下面类似写法,才能保证异常安全:

void Func()
{
	int* arr1 = new int[10];
	int* arr2;
	int* arr3;
	try
	{
		arr2 = new int[20];
		try
		{
			arr3 = new int[30];
		}
		catch(...)
		{
			delete[] arr1;
			delete[] arr2;
			throw;
		}
	}
	catch(...)
	{
		delete[] arr1;
		throw;
	}

	// 没有异常
	delete[] arr1;
	delete[] arr2;
	delete[] arr3;
}

我们发现在这种场景下,要保证不产生内存泄漏,程序员在编写代码时要格外注意。

内存泄露

内存泄漏及其危害

内存泄漏 是指程序员因为疏忽错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为错误的设计,失去了对这段内存的控制(还要继续使用),因而造成了内存的浪费。

内存泄露的危害: 对于短时间运行的程序来说,内存泄漏并不会造成较大的危害,因为程序结束运行后,会自动回收申请的资源。但是,对于长期运行的程序出现内存泄漏,影响非常大(比如操作系统,后台服务等等),因为出现内存泄漏会导致响应越来越慢,最终卡死。

内存泄露的分类

C/C++ 程序中一般我们关心两种方面的内存泄漏:

  • 堆内存泄漏(Heap leak): 堆内存是指程序执行中通过malloc/calloc/realloc/new等从堆中分配的一块内存,该内存使用完后必须通过调用对应的free/delete释放该内存。假设程序因设计错误导致这部分内存没有被释放,那么这部分空间后续无法再被使用(程序运行时),就会产生Heap leak
  • 系统资源泄漏: 是指程序使用完系统分配的资源(如套接字、文件描述符、管道等等),没有使用对应的函数释放、归还给操作系统,导致系统资源的浪费,严重可导致系统能效减少,系统执行不稳定等后果。

经典场景

  1. 对象创建后却没有释放。
  2. 智能指针的循环引用,两者相互持有,导致引用计数永不为0,内存无法释放。
  3. 集合类容器中,删除元素后未释放内存。
  4. 在外面手动申请的内存,但进入了异常处理,手动分配的内存未释放。
  5. 静态成员或全局变量持有动态分配的对象。

如何避免内存泄漏

  1. 工程前期要有良好的设计规范,养成良好的编码规范。对于申请的内存空间,要调用匹配的函数去释放。
  2. 采用RAII思想或者智能指针来管理资源。
  3. 有些公司规定使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 使用内存检测工具。当程序出现问题后,可以使用一些工具检查。(ps:不过很多工具都不太靠谱,或者收费昂贵。)

总结: 内存泄漏的解决方案通常有两种:一是“事先预防”型(如编程规范和智能指针等);二是“事后查错”型(如使用泄漏检测工具等)

补充: 内存泄漏检测工具

  • 在linux下内存泄漏检测:linux下几款内存泄漏检测工具
  • 在windows下使用第三方工具:VLD工具说明
  • 其他工具:内存泄漏工具比较

智能指针的使用及原理

RAII

RAII(Resource Acquisition Is Initialization–资源获取即初始化)是一种利用对象的生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

对象构造时获取要管理的资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构时释放其管理的资源。借此,可以把我们管理资源的责任托管给一个对象。这种做法有两大好处:

  • 不需要显式地释放资源。
  • 采用这种方式,对象所需的资源在其生命周期内始终保持有效。
  • 异常安全: 即使程序在执行过程中抛出异常或多路径返回时,也能确保资源最终得到正确释放,特别是可以避免内存泄漏。
  • 简化资源管理: 将资源的获取和释放逻辑封装在类内,使代码更加简洁且方便维护。

示例

// SmartPtr.h
template<class T>
class SmartPtr
{
public:
	SmartPtr(T* ptr)
		:_ptr(ptr)
	{}

	~SmartPtr()
	{
		delete _ptr;
		std::cout << "delete: " << _ptr << std::endl;
	}
private:
	T* _ptr;
};
// SmartPtr.cpp
#include <iostream>
#include <exception>
#include "SmartPtr.h"

double Division(int a, int b)
{
	if (b == 0)
	{
		throw std::invalid_argument("除0错误");
	}

	return (double)a / (double)b;
}

void func()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2(new int);
	std::cout << Division(1, 0) << std::endl;
}

int main()
{
	try
	{
		func();
	}
	catch (const std::exception& e)
	{
		std::cout << e.what() << std::endl;
	}
	return 0;
}

运行结果:

delete: 0000022DDC2C7160
delete: 0000022DDC2C7520
除0错误

这里就算出现异常,并且我们没有手动释放资源,该资源也可以被正确地释放。原因是: 在触发异常后,该异常会被捕获,就会跳转到catch块中,对象sp1sp2的生命周期就结束了,就会调用该对象的析构函数,在析构函数中该对象管理的资源就会被释放。

注意: 这里RAII是一种思想,可以将其应用到其他的资源管理上,并不只是内存。下面给出利用RAII思想管理文件资源:

#include <iostream>
#include <fstream>

class FileHandler 
{
public:
    FileHandler(const std::string& filename) : file(filename) 
    { // 资源获取
        if (!file.is_open()) 
        {
            throw std::runtime_error("Unable to open file");
        }
    }

    ~FileHandler() 
    {
        file.close(); // 资源释放
    }

    void write(const std::string& data) 
    {
        if (file.is_open()) 
        {
            file << data << std::endl;
        }
    }

private:
    std::ofstream file;
};

int main() 
{
    try 
    {
        FileHandler fh("example.txt");
        fh.write("Hello, RAII!");
    }
    catch (const std::exception& e) 
    {
        std::cerr << e.what() << std::endl;
    }
    return 0;
}

应用场景:

  1. 内存管理: 标准库中的std::unique_ptrstd::shared_ptrRAII 的经典实现,用于智能管理动态内存。
  2. 文件管理: std::fstream类在打开文件时获取资源,在析构函数中关闭文件。
  3. 互斥锁: std::lock_guardstd::unique_lock 用于多线程编程中自动管理互斥锁的锁定和释放。

智能指针

智能指针通过使用RAII原则来实现资源的自动管理,即在对象的生命周期开始时获取资源,在对象生命周期结束时释放资源。之所以“指针”,是因为它具有指针的行为:比如可以解引用 (*) 或者通过箭头操作符 (->) 访问所指向的对象成员。

所以以下介绍的智能指针,其底层一定重载了这两个操作符(先以上述例子中的SmartPtr类为例,展示一下大致如何进行重载)。

为什么要重载操作符*->

智能指针的目的是提供一种自动管理资源(通常是动态分配的内存)的机制,同时尽可能保持与原生指针相似的接口。这样做的好处是,开发者可以在不改变现有代码风格的情况下,轻松地将原生指针替换为智能指针,从而获得更好的资源管理和异常安全性。

  1. 重载*操作符
    • 当你有一个指向内置类型对象的指针时,你通常会使用解引用操作符*获取该对象本身的值
    • 智能指针需要模拟这种行为,以便当你通过智能指针访问所管理的对象时,能够得到该对象的值。
    • 因此,智能指针需要重载*操作符,以便在解引用智能指针时返回所管理对象的值。
T& operator*() const
{
	return *_ptr;
}

int main()
{
	SmartPtr<int> sp1(new int(1));
	SmartPtr<int> sp2(new int(2));
	*sp1 += 10;
	
	return 0;
}

SmartPtr<int> 意味着 SmartPtr 类模板的一个实例,它管理的是 int 类型的对象。一个指针如果指向一个内置类型数据,可以通过解引用*访问到这个内置类型数据,所以智能指针类内部要重载操作符*,并且返回该内置类型数据(通过解引用类成员变量_ptr)。

  1. 重载->操作符
    • 当你有一个指向用户定义类型对象的指针时,你还可以使用箭头操作符->访问该对象的成员
    • 智能指针同样需要模拟这种行为,以便当你通过智能指针访问所管理对象的成员时,能够得到正确的结果。
    • 因此,智能指针需要重载->操作符,以便在通过智能指针访问成员时返回所管理对象的成员。

这里以std::pair<std::string, int> 类型为例:

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

int main()
{
	SmartPtr<std::pair<std::string, int>> sp3(new std::pair<std::string, int>("10", 10));
	std::cout << sp3->first << std::endl;
	return 0;
}

SmartPtr<std::pair<std::string, int>> 意味着 SmartPtr 类模板的一个实例,它管理的是 std::pair<std::string, int> 类型的对象。一个指针如果指向一个用户定义类型数据,可以通过操作符->访问到这个用户定义类型对象内部的数据,所以智能指针类内部要重载操作符->,这样我们就可以通过智能指针对象sp3直接访问到std::pair内部的std::stringint数据了。

如何重载操作符*->

在C++中,重载操作符是通过定义特殊形式的成员函数来实现的。对于智能指针来说,这些成员函数通常被定义为const成员函数,因为它们不会修改智能指针的状态。

  1. 重载*操作符
    • 返回值类型应该是所管理对象的引用(如果智能指针是非const的)或所管理对象的const引用(如果智能指针是const的)。
    • 函数体内部应该返回对所管理对象的解引用结果。
T& operator*() const
{
	return *_ptr;
}
  1. 重载->操作符
    • 返回值类型应该是所管理对象的指针类型
    • 函数体内部应该返回存储的内部指针(即指向所管理对象的指针)。

在C++中,->操作符的重载返回值通常是一个指向被管理对象的指针。这里被管理对象是std::pair<std::string, int>,所以要返回该对象的指针,也就是_ptr

关于->操作符重载的进一步说明

注意: 要访问到用户定义类型对象的内部数据,直接使用操作符->是不行的,还需要编译器做进一步优化,才能达到预期的使用效果:对象sp3通过->操作符返回其成员对象(std::pair* ),但是我们要访问的是std::stringint数据,因此还需要使用一个->操作符,才能访问到 std::pair*firstsecond,但是写两个->不方便(sp3.operator->()->first)。因此,编译器做了优化,只用写一个->即可访问到firstsecond

也就是: 当您重载->操作符时,返回的是一个指向被管理对象的指针。这意味着您可以连续使用->来访问对象的成员,如sp3->first。这里有一个重要的点:由于->操作符的返回值是一个指针,因此它可以继续被->操作符所使用,这允许我们链式访问成员。

例如,如果_ptr是一个指向std::pair<std::string, int>的指针,那么_ptr->first就是访问std::pair对象的first成员。当您重载->并返回_ptr时,sp3->first实际上等价于(_ptr)->first,编译器会自动处理这种嵌套访问。


补充: 迭代器中关于操作符->的重载

上面我们说,->操作符的重载返回值通常是一个指向被管理对象的指针。
在这里插入图片描述

  • 对于SmartPtr来说,其管理的对象为data,因此要返回其指针&data,而_ptr就是指向data的指针,所以返回_ptr
  • 对于迭代器来说,其管理的对象为_node->data,因此要返回其指针&_node->data

智能指针的拷贝问题

智能指针模拟的是原生指针的行为,指针在进行赋值时,两个指针会指向同一个数据。所以对于智能指针来说,当发生拷贝构造或赋值操作时,不进行深拷贝,拷贝与被拷贝的智能指针对象管理同一份资源(资源不是自己的,而是代为持有)。

int main()
{
	SmartPtr<int> sp1(new int(1));
	SmartPtr<int> sp2(sp1); // 拷贝构造
	SmartPtr<int> sp3(new int(3));
	sp3 = sp1; // 赋值
	return 0;
}

因此关于,这三个智能指针对象应该管理同一份资源,也就是1所在的堆空间。

关于这个拷贝问题,标准库中的智能指针实现的策略是不同的,下面我们来详细看看( •̀ ω •́ )✧(标准库实现的智能指针在memory头文件中,参考文档

智能指针的介绍

先来介绍一下独占所有权的模型。

使用该模型的智能指针,意味着在一个时刻只有一个实例拥有对该对象的所有权。当实例被销毁或者被移动时,它会自动释放所管理的对象。

auto_ptr(C++98)

在 C++98 中,std::auto_ptr 是一种智能指针,它旨在自动管理动态分配的对象的生命周期。std::auto_ptr 提供了一种独占所有权的模型。

一、auto_ptr的基本特性

  • 独占所有权模型auto_ptr采用独占所有权模型,即任何时候只能有一个auto_ptr实例管理某个资源(动态分配的对象)。
  • 自动释放资源:当auto_ptr实例被销毁时,它会自动释放所管理的资源。

二、管理权转移

  • 拷贝构造函数与赋值运算符:在C++98中,auto_ptr的拷贝构造函数和赋值运算符会将管理权从被拷贝的auto_ptr实例转移到拷贝后的实例。这意味着,被拷贝的auto_ptr将不再管理该资源,而是将其所有权转移给新的auto_ptr实例。
  • 管理权转移的后果:由于管理权的转移,被拷贝的auto_ptr实例将变为空(即其内部指针将被设置为nullptr),而新的auto_ptr实例将接管该资源的管理权。这可能导致一些意外的行为,特别是当程序员不了解这种管理权转移的特性时。

三、auto_ptr的缺陷

  • 潜在的悬空指针问题:由于管理权的转移,被拷贝的auto_ptr实例在拷贝后可能变为悬空指针(即指向已被释放的内存)。这可能导致未定义的行为,因为悬空指针无法安全地解引用。
  • 不安全的拷贝语义auto_ptr的拷贝语义可能导致资源被意外地释放。

四、使用及原理

#include <memory>

int main()
{
	std::auto_ptr<int> sp1(new int(1));
	std::auto_ptr<int> sp2(sp1);
	return 0;
}

在这里插入图片描述
管理权转移:
在这里插入图片描述

此时 sp1 的管理权就转移给 sp2 对象,此时就不能再通过 sp1 访问其原先管理的资源。


底层类似于:

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

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

	~auto_ptr()
	{
		delete _ptr;
		std::cout << "delete: " << _ptr << std::endl;
	}
private:
	T* _ptr;
};

五、auto_ptr的替代方案

  • unique_ptr:在C++11及更高版本中,引入了unique_ptr作为auto_ptr的替代方案。unique_ptr同样采用独占所有权模型,但它不允许拷贝构造函数和赋值运算符,从而避免了管理权转移的问题。相反,unique_ptr支持移动语义,允许通过移动构造函数和移动赋值运算符来转移资源的所有权。
  • shared_ptr:另一个替代方案是shared_ptr,它采用共享所有权模型。shared_ptr使用引用计数来跟踪有多少个shared_ptr实例共享同一个资源。当最后一个shared_ptr实例被销毁时,资源才会被释放。这避免了auto_ptr中的管理权转移问题和潜在的悬空指针问题。

综上所述,C++98中的auto_ptr虽然提供了一种自动管理动态分配内存的机制,但其设计存在缺陷,特别是在管理权转移方面。因此,在C++11及更高版本中,建议使用unique_ptrshared_ptr作为替代方案来更安全地管理动态分配的内存。

unique_ptr(C++11)

unique_ptr同样采用独占所有权模型,但它不允许拷贝构造函数和赋值运算符,从而避免了管理权转移的问题。相反,unique_ptr支持移动语义,允许通过移动构造函数和移动赋值运算符来转移资源的所有权。(适用于不需要拷贝的场景。)

template<class T>
class unique_ptr
{
public:
	unique_ptr(T* ptr)
		:_ptr(ptr)
	{}

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

	~unique_ptr()
	{
		delete _ptr;
		std::cout << "delete: " << _ptr << std::endl;
	}
private:
	T* _ptr;
};

为什么std::unique_ptr可以做到不可复制,只可移动?

不可复制,因为把拷贝构造函数和赋值运算符标记为了delete,见源码:

template <typename _Tp, typename _Tp_Deleter = default_delete<_Tp> > 
class unique_ptr {
	// Disable copy from lvalue.
	unique_ptr(const unique_ptr&) = delete;
	
	template<typename _Up, typename _Up_Deleter> 
	unique_ptr(const unique_ptr<_Up, _Up_Deleter>&) = delete;
 
	unique_ptr& operator=(const unique_ptr&) = delete;
    
    template<typename _Up, typename _Up_Deleter> 
    unique_ptr& operator=(const unique_ptr<_Up, _Up_Deleter>&) = delete;
};

只可移动:

在C++中,“只可移动”(movable-only)意味着一个对象不能被复制(即不能使用拷贝构造函数或拷贝赋值运算符进行复制),但是它可以被移动(即可以使用移动构造函数或移动赋值运算符进行移动)。移动操作通常涉及将资源(如动态分配的内存、文件句柄等)的所有权从一个对象转移到另一个对象,而不是像复制操作那样创建资源的副本。

对于std::unique_ptr来说,它代表对某个对象的唯一所有权。这意味着在任意时刻,只有一个std::unique_ptr实例可以拥有某个资源(例如一个动态分配的对象)。因此,如果允许std::unique_ptr被复制,那么就会违反它的唯一所有权原则,因为复制后的两个std::unique_ptr实例都会声称拥有同一个资源,这会导致资源管理上的混乱和潜在的错误(如双重释放)。

为了避免这种情况,std::unique_ptr的拷贝构造函数和拷贝赋值运算符被删除(= delete),从而禁止了复制操作。然而,在某些情况下,我们仍然需要将std::unique_ptr的所有权从一个实例转移到另一个实例(例如,将资源从一个函数返回给调用者,或者将资源从一个容器移动到另一个容器)。这就是移动操作的目的。

移动操作不会创建资源的副本,而是将资源的所有权从一个std::unique_ptr实例转移到另一个实例。这通常是通过将源std::unique_ptr的内部指针(指向资源的指针)设置为nullptr(表示放弃所有权),并将该指针的值赋给目标std::unique_ptr来实现的。这样,目标std::unique_ptr就获得了资源的唯一所有权,而源std::unique_ptr则不再拥有任何资源。

如何移动?

std::unique_ptr做到移动的关键在于其移动构造函数和移动赋值运算符的实现。这些移动操作不会创建资源(如动态分配的内存)的副本,而是将资源的所有权从一个std::unique_ptr实例转移到另一个实例。

  1. 移动构造函数
    当使用移动构造函数创建一个新的std::unique_ptr实例时,源std::unique_ptr(即要移动的实例)会将其内部指针(指向所管理资源的指针)的值赋给新创建的std::unique_ptr实例。然后,源std::unique_ptr会将其内部指针设置为nullptr,表示它不再拥有该资源。

  2. 移动赋值运算符
    类似地,当使用移动赋值运算符将一个std::unique_ptr实例的值赋给另一个std::unique_ptr实例时,源std::unique_ptr会将其内部指针的值赋给目标std::unique_ptr,并将自己的内部指针设置为nullptr。这样,目标std::unique_ptr就获得了资源的所有权,而源std::unique_ptr则放弃了所有权。

由于移动操作不会创建资源的副本,因此它们通常比复制操作更高效。在移动之后,源std::unique_ptr不再拥有任何资源,因此可以安全地被销毁或用于其他目的(但需要注意的是,此时它不再指向任何有效的资源)。

std::unique_ptr的移动构造函数和移动赋值运算符是由标准库自动提供的,你不需要手动实现它们。当你使用C++11或更高版本的编译器时,这些移动操作会自动启用,允许你以高效的方式转移std::unique_ptr的所有权。

例如,以下是一个简单的使用std::unique_ptr移动操作的示例:

#include <memory>
#include <iostream>

int main() {
    std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
    std::cout << "ptr1: " << *ptr1 << std::endl; // 输出: ptr1: 42

    // 使用移动构造函数创建ptr2
    std::unique_ptr<int> ptr2 = std::move(ptr1);
    // 此时ptr1不再拥有资源,ptr2拥有资源
    std::cout << "ptr1 (after move): " << (ptr1 ? *ptr1 : "nullptr") << std::endl; // 输出: ptr1 (after move): nullptr
    std::cout << "ptr2: " << *ptr2 << std::endl; // 输出: ptr2: 42

    // 使用移动赋值运算符将ptr2的值赋给ptr3
    std::unique_ptr<int> ptr3;
    ptr3 = std::move(ptr2);
    // 此时ptr2不再拥有资源,ptr3拥有资源
    std::cout << "ptr2 (after move assignment): " << (ptr2 ? *ptr2 : "nullptr") << std::endl; // 输出: ptr2 (after move assignment): nullptr
    std::cout << "ptr3: " << *ptr3 << std::endl; // 输出: ptr3: 42

    return 0;
}

在这个示例中,std::move函数用于将std::unique_ptr实例转换为右值引用,从而启用移动构造函数或移动赋值运算符。然后,资源的所有权被从源std::unique_ptr转移到目标std::unique_ptr

shared_ptr(C++11)

std::shared_ptr 是一种共享所有权的智能指针,多个shared_ptr可以指向同一个对象。内部使用引用计数来确保只有当最后一个指向对象的shared_ptr被销毁时,对象才会被销毁。

使用场景:

  • 当你需要多个所有者之间共享对象时。
  • 当你需要通过复制构造函数或赋值操作符来复制智能指针时。

shared_ptr 的使用

shared_ptr 定义在头文件 <memory> 中,可以通过 std::shared_ptr 来使用。以下是一些基本的用法示例:

#include <iostream>
#include <memory>

int main() {
    // 创建一个 shared_ptr 管理一个动态分配的 int
    std::shared_ptr<int> ptr1 = std::shared_ptr<int>(
上一篇:win11/win10/windows下快安装并使用git-一、Git 的特点?


下一篇:蓝桥杯:求平均年龄