Bootstrap

[C++] 智能指针

文章目录


智能指针的使用原因及场景分析

在现代C++开发中,资源管理(包括内存、文件句柄、锁等)是一个至关重要的问题。特别是在异常安全性设计中,如何避免资源泄漏是开发者必须面对的挑战。


为什么需要智能指针?

在C++中,资源的申请与释放通常是手动管理的。比如new分配内存,delete释放内存。但是,以下场景会导致资源管理复杂化:

异常抛出导致的资源泄漏

当程序执行到一半抛出异常时,如果之前分配的资源没有及时释放,就会产生资源泄漏问题。例如:

double Divide(int a, int b)
{
    if (b == 0)
    {
        throw "Divide by zero condition!"; // 抛出异常
    }
    return static_cast<double>(a) / b;
}

void Func()
{
    int* array1 = new int[10]; // 申请资源1
    int* array2 = new int[10]; // 申请资源2

    try
    {
        int len, time;
        std::cin >> len >> time;
        std::cout << Divide(len, time) << std::endl; // 可能抛出异常
    }
    catch (...)
    {
        std::cout << "delete [] array1" << std::endl;
        delete[] array1; // 释放资源1
        std::cout << "delete [] array2" << std::endl;
        delete[] array2; // 释放资源2
        throw; // 重新抛出异常
    }

    // 手动释放资源
    delete[] array1;
    delete[] array2;
}

问题分析

上面的代码逻辑看似解决了资源释放问题,但:

  1. 代码复杂度高:需要频繁捕获异常,手动释放资源,极易出错。
  2. 异常嵌套问题:如果new array2本身抛出异常,array1也会因为没有被释放而造成资源泄漏。
  3. 资源管理分散:释放逻辑需要在多个地方手动维护。

因此,为了解决这些问题,C++引入了RAII(资源获取即初始化)思想,结合智能指针自动管理资源。


智能指针与RAII

**RAII(Resource Acquisition Is Initialization)**是一种设计原则:资源的申请和释放绑定到对象的生命周期中。通过智能指针,将资源管理从手动控制转变为自动化管理。

C++常用智能指针

C++11标准引入了三种常用智能指针:

智能指针包含在头文件<memory>

  • std::unique_ptr:独占式所有权,适用于单个对象。
  • std::shared_ptr:共享式所有权,适用于多个对象共享。
  • std::weak_ptr:弱引用,解决shared_ptr循环引用问题。

以下是使用智能指针优化上面代码的实现。


使用智能指针优化代码

优化后的代码

#include <iostream>
#include <memory>
#include <stdexcept>

double Divide(int a, int b)
{
    if (b == 0)
    {
        throw std::runtime_error("Divide by zero condition!");
    }
    return static_cast<double>(a) / b;
}

void Func()
{
    // 使用智能指针管理动态数组
    std::unique_ptr<int[]> array1(new int[10]);
    std::unique_ptr<int[]> array2(new int[10]);

    try
    {
        int len, time;
        std::cin >> len >> time;
        std::cout << Divide(len, time) << std::endl;
    }
    catch (...)
    {
        // 不需要手动delete,智能指针会自动释放资源
        throw; // 重新抛出异常 
    }
}

int main()
{
    try
    {
        Func();
    }
    catch (const std::exception& e)
    {
        std::cout << e.what() << std::endl;
    }
    catch (...)
    {
        std::cout << "未知异常" << std::endl;
    }

    return 0;
}

优化点分析

  1. 异常安全性提升
    • 智能指针的析构函数自动释放资源,避免了异常导致的资源泄漏。
  2. 代码简洁性提升
    • 不需要显式调用delete,降低了手动管理资源的复杂度。
  3. 避免重复代码
    • 资源释放逻辑不再需要多次书写,减少了人为错误的可能性。

析构函数中的异常问题

另一个常见问题是析构函数中抛出异常。例如:

class Resource
{
public:
    ~Resource()
    {
        // 假设要释放多个资源
        for (int i = 0; i < 10; ++i)
        {
            if (i == 5) // 假设释放到一半时出错
            {
                throw std::runtime_error("Error during destruction");
            }
        }
    }
};

如果析构函数抛出异常,会导致程序在堆栈展开过程中不知如何处理,可能直接导致程序崩溃。

解决方法

  1. 析构函数中不要抛出异常
    • 析构函数应该尽量捕获所有异常,确保不抛出。
  2. 智能指针辅助管理
    • 使用智能指针将资源的释放交给其析构函数处理。

RAII 和智能指针的设计思路详解

在C++开发中,资源管理(如内存、文件、网络连接等)是一个常见且关键的问题。如果资源没有被正确释放,可能会导致资源泄漏,进而引发性能问题甚至程序崩溃。为了高效且安全地管理资源,C++引入了RAII(资源获取即初始化)设计思想,而智能指针则是RAII思想的一种具体实现。


什么是 RAII?

RAIIResource Acquisition Is Initialization 的缩写,中文翻译为“资源获取即初始化”。其核心思想是将资源的管理与对象的生命周期绑定,通过对象的构造函数获取资源,并在析构函数中释放资源。RAII的优点包括:

  1. 避免资源泄漏:当对象生命周期结束时,资源会被自动释放。
  2. 异常安全性:RAII在异常发生时,仍然可以保证资源正确释放。
  3. 代码简洁性:减少了手动释放资源的复杂逻辑。

RAII 的工作原理

RAII 将资源的获取与对象的初始化绑定。例如:

  1. 在构造函数中获取资源。
  2. 在析构函数中释放资源。
  3. 在对象的生命周期内,确保资源始终处于有效状态。

RAII 管理的资源可以包括:

  • 动态内存(new/delete
  • 文件句柄
  • 网络连接
  • 互斥锁等。

智能指针的设计思路

智能指针是 RAII 的一个典型实现,它不仅符合 RAII 的设计理念,还通过重载运算符模拟指针的行为,使资源的访问更加方便。例如:

  • 通过重载 operator*operator->,可以像普通指针一样操作资源。
  • 通过重载 operator[],可以支持访问数组元素的操作。

以下是一个简单的智能指针类的实现:

template<class T>
class SmartPtr
{
public:
    // 构造函数:获取资源
    SmartPtr(T* ptr)
        : _ptr(ptr)
    {}

    // 析构函数:释放资源
    ~SmartPtr()
    {
        std::cout << "delete[] " << _ptr << std::endl;
        delete[] _ptr; // 确保资源释放
    }

    // 重载运算符,方便访问资源
    T& operator*()
    {
        return *_ptr;
    }

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

    T& operator[](size_t i)
    {
        return _ptr[i];
    }

private:
    T* _ptr; // 内部指针,管理资源
};

RAII 与智能指针的结合示例

下面的示例展示了如何使用自定义的智能指针类解决资源管理问题。

示例代码

#include <iostream>
using namespace std;

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

    ~SmartPtr()
    {
        cout << "delete[] " << _ptr << endl;
        delete[] _ptr;
    }

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

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

    T& operator[](size_t i)
    {
        return _ptr[i];
    }

private:
    T* _ptr;
};

double Divide(int a, int b)
{
    if (b == 0)
    {
        throw "Divide by zero condition!";
    }
    return static_cast<double>(a) / b;
}

void Func()
{
    // 使用智能指针管理动态数组
    SmartPtr<int> sp1 = new int[10];
    SmartPtr<int> sp2 = new int[10];

    for (size_t i = 0; i < 10; i++)
    {
        sp1[i] = sp2[i] = i;
    }

    int len, time;
    cin >> len >> time;
    cout << Divide(len, time) << endl;
}

int main()
{
    try
    {
        Func();
    }
    catch (const char* errmsg)
    {
        cout << errmsg << endl;
    }
    catch (const exception& e)
    {
        cout << e.what() << endl;
    }
    catch (...)
    {
        cout << "未知异常" << endl;
    }

    return 0;
}

示例说明

  1. RAII 的作用
    • SmartPtr 构造时获取资源(new int[10]),析构时释放资源(delete[])。
    • 即使发生异常,析构函数会在堆栈展开时自动调用,确保资源不会泄漏。
  2. 异常安全性
    • 如果Divide函数中抛出异常,SmartPtr对象会在函数退出时调用析构函数,自动释放资源。
  3. 简化资源管理逻辑
    • 不再需要显式调用delete,释放逻辑被封装到智能指针中。
  4. 访问资源的便利性
    • 通过重载运算符,支持数组下标操作(sp1[i])、指针操作(*sp1)等,使用体验接近普通指针。

智能指针 VS 原生指针

特性原生指针智能指针
资源释放手动调用 delete析构函数自动释放
异常安全性容易造成资源泄漏保证异常安全
使用复杂度需要手动管理资源自动化管理,降低复杂度
指针行为支持支持基本指针操作重载运算符,模拟指针行为
循环引用问题(shared_ptr存在通过weak_ptr解决

C++ 标准库的智能指针

C++ 标准库中的智能指针提供了一种安全、高效的资源管理方式,减少了资源泄漏和悬空指针的风险,同时显著提高了代码的异常安全性和可读性。以下是 C++ 标准库智能指针的全面介绍及使用示例。


智能指针概述

智能指针主要有以下几种类型,均定义在 <memory> 头文件中:

  1. std::auto_ptr (C++98,已废弃):
    • 拷贝时会转移资源管理权,但导致被拷贝对象悬空。
    • C++11 后被强烈建议弃用,C++17 已移除。
  2. std::unique_ptr(C++11 引入):
    • 独占式管理资源,不支持拷贝,只支持移动。
    • 非常适合无需共享资源的场景。
  3. std::shared_ptr(C++11 引入):
    • 共享式管理资源,底层通过引用计数实现。
    • 支持拷贝和移动,适用于需要共享资源的场景。
  4. std::weak_ptr(C++11 引入):
    • 弱引用指针,不管理资源,仅观察资源。
    • 用于解决 shared_ptr 循环引用问题。

std::unique_ptr

std::unique_ptr 是一个独占式智能指针,不能被拷贝,但可以通过移动语义转移资源管理权。

使用示例

#include <iostream>
#include <memory>

struct Date {
    int _year, _month, _day;
    Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}
    ~Date() { std::cout << "~Date()" << std::endl; }
};

int main() {
    std::unique_ptr<Date> up1(new Date(2024, 11, 16));
    
    // 不支持拷贝
    // std::unique_ptr<Date> up2 = up1; // 编译错误

    // 支持移动
    std::unique_ptr<Date> up3 = std::move(up1);
    
    if (!up1) {
        std::cout << "up1 is null after move." << std::endl;
    }
    if (up3) {
        std::cout << "up3 owns the resource." << std::endl;
    }
    return 0;
}

特性

  • 自动释放资源,无需手动调用 delete
  • 避免资源泄漏和悬空指针。
  • 使用场景:独占资源的管理。

std::shared_ptr

std::shared_ptr 是一个共享式智能指针,底层通过引用计数控制资源生命周期。

使用示例

#include <iostream>
#include <memory>

struct Date {
    int _year, _month, _day;
    Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}
    ~Date() { std::cout << "~Date()" << std::endl; }
};

int main() {
    std::shared_ptr<Date> sp1(new Date(2024, 11, 16));
    std::shared_ptr<Date> sp2 = sp1; // 拷贝,引用计数增加

    std::cout << "sp1 use_count: " << sp1.use_count() << std::endl;
    sp2->_year = 2025;

    std::cout << "Year: " << sp1->_year << std::endl; // sp1 和 sp2 共享资源
    return 0;
}

特性

  • 引用计数控制资源生命周期。
  • 当最后一个 shared_ptr 被销毁时,资源才会被释放。
  • 使用场景:多个对象共享同一资源。

std::weak_ptr

std::weak_ptr 是一种非 RAII 的弱引用智能指针,设计用于解决 shared_ptr 的循环引用问题。

使用示例

#include <iostream>
#include <memory>

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // 弱引用,防止循环引用
    ~Node() { std::cout << "~Node()" << std::endl; }
};

int main() {
    auto node1 = std::make_shared<Node>();
    auto node2 = std::make_shared<Node>();
    node1->next = node2;
    node2->prev = node1; // 弱引用避免循环引用

    return 0;
}

特性

  • 不增加引用计数。
  • 用于观察 shared_ptr 管理的资源。
  • 可通过 lock() 方法获取 shared_ptr

删除器的使用

默认情况下,智能指针使用 deletedelete[] 释放资源。如果资源不是通过 new 分配的,可以通过自定义删除器指定释放方式。

使用自定义删除器

#include <iostream>
#include <memory>
#include <cstdio>

void fileCloser(FILE* file) {
    std::cout << "Closing file." << std::endl;
    fclose(file);
}

int main() {
    std::shared_ptr<FILE> file(fopen("example.txt", "w"), fileCloser);
    if (file) {
        std::cout << "File opened successfully." << std::endl;
    }
    return 0;
}

make_shared 与资源初始化

使用 make_shared 可以直接构造 shared_ptr 对象,性能更高,异常安全性更强。

#include <iostream>
#include <memory>

struct Date {
    int _year, _month, _day;
    Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}
    ~Date() { std::cout << "~Date()" << std::endl; }
};

int main() {
    auto sp = std::make_shared<Date>(2024, 11, 16);
    std::cout << "Year: " << sp->_year << std::endl;
    return 0;
}

优势

  • 避免两次内存分配。
  • 构造时更安全。

智能指针的注意事项

  1. 避免重复释放资源
    • 智能指针不能管理非 new 分配的资源。
  2. 避免循环引用
    • 使用 std::weak_ptr 打破循环引用。
  3. 显式构造
    • 智能指针构造函数是 explicit 的,防止隐式类型转换。
  4. 移动语义的使用
    • 移动智能指针后,原指针会悬空,使用时需谨慎。

总结:

类型RAII拷贝支持移动支持适用场景
std::auto_ptr已废弃,不建议使用
std::unique_ptr独占资源管理
std::shared_ptr共享资源管理
std::weak_ptr配合 shared_ptr 防止循环引用

智能指针原理

智能指针是 C++ 提供的一种封装原生指针的类,其核心原理是通过 RAII(资源获取即初始化)设计模式,将资源的管理与智能指针对象的生命周期绑定,从而实现资源的自动管理和释放,避免资源泄漏、悬空指针等问题。


RAII 思想

RAII 是智能指针的核心设计思想,资源的获取和释放分别绑定到智能指针对象的构造函数和析构函数中:

  • 构造函数:获取资源(如内存、文件句柄等)。
  • 析构函数:释放资源。

RAII 确保了在异常抛出或正常退出作用域时,智能指针的析构函数能够被自动调用,从而释放资源,避免资源泄漏。


封装原生指针

智能指针通过内部成员变量封装原生指针(如 T* _ptr),并通过重载运算符(*->)实现类似原生指针的操作行为。例如:

  • operator*:访问资源本体。
  • operator->:访问资源的成员。
template <class T>
class SmartPtr {
private:
    T* _ptr; // 封装原生指针
public:
    SmartPtr(T* ptr) : _ptr(ptr) {}  // 资源获取
    ~SmartPtr() { delete _ptr; }     // 资源释放

    T& operator*() { return *_ptr; } // 解引用
    T* operator->() { return _ptr; } // 指针访问
};

资源管理方式

不同类型的智能指针采用不同的资源管理方式:

独占式资源管理:unique_ptr

  • 每个资源只能由一个 unique_ptr 对象管理。
  • 禁止拷贝,但允许通过移动语义转移管理权。
  • 原理:禁止拷贝构造和拷贝赋值,仅实现移动构造和移动赋值。

共享式资源管理:shared_ptr

  • 多个 shared_ptr 对象可以共享同一资源。
  • 使用引用计数来管理资源的生命周期:
    • 构造:引用计数初始化为 1。
    • 拷贝:引用计数增加。
    • 销毁:引用计数减少,减为 0 时释放资源。
class SharedPtr {
private:
    T* _ptr;         // 封装原生指针
    int* _ref_count; // 引用计数
public:
    SharedPtr(T* ptr) : _ptr(ptr), _ref_count(new int(1)) {}
    SharedPtr(const SharedPtr& other) : _ptr(other._ptr), _ref_count(other._ref_count) {
        ++(*_ref_count); // 引用计数增加
    }
    ~SharedPtr() {
        if (--(*_ref_count) == 0) {  // 引用计数为 0 时释放资源
            delete _ptr;
            delete _ref_count;
        }
    }
};

弱引用:weak_ptr

  • weak_ptr 不直接管理资源,而是观察由 shared_ptr 管理的资源。
  • 不增加引用计数,避免循环引用问题。

析构器的自动调用

智能指针的一个核心特点是:在智能指针对象的生命周期结束时,其析构函数会被自动调用,确保资源的正确释放。这种自动调用依赖于以下机制:

  • 栈上对象:离开作用域时,栈上对象会自动析构。
  • 异常处理:在异常抛出时,堆栈会展开,栈上的对象会按逆序自动析构。

引用计数的实现(shared_ptr

动态分配引用计数

  • shared_ptr 的引用计数需要动态分配:
    • 每次构造一个新的 shared_ptr,分配一份资源和一个引用计数。
    • 多个 shared_ptr 共享一个引用计数。

引用计数的增减

  • shared_ptr 被拷贝时,引用计数增加。
  • shared_ptr 被销毁时,引用计数减少。
  • 当引用计数减为 0 时,释放资源。

循环引用问题

当两个或多个 shared_ptr 互相引用时,会导致引用计数永不为 0,从而资源无法释放。这时需要 weak_ptr 来打破循环。


自定义删除器

智能指针允许通过删除器(deleter)定制资源释放方式。例如:

  • 默认:deletedelete[]
  • 自定义:文件关闭(fclose)、内存池释放等。
std::shared_ptr<FILE> file(fopen("test.txt", "r"), [](FILE* f) {
    fclose(f);
});

智能指针的关键实现点

  1. RAII 原则:资源绑定到对象生命周期,析构时自动释放。
  2. 引用计数shared_ptr):动态分配引用计数,管理资源生命周期。
  3. 操作符重载:提供与原生指针类似的访问接口。
  4. 自定义删除器:支持用户定制资源释放方式。
  5. 循环引用处理:通过 weak_ptr 解决 shared_ptr 的循环引用问题。

智能指针本质是通过封装原生指针和引用计数,实现安全、高效的资源管理,同时提升了代码的可维护性和异常安全性。

C++11中三种智能指针的模拟实现及原理理解

C++98 auto_ptr

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

	auto_ptr(auto_ptr<T>& sp)
		:_ptr(sp._ptr)
	{
		// 管理权转移
		sp._ptr = nullptr;
	}

	auto_ptr<T>& operator=(auto_ptr<T>& ap)
	{
		// 检测是否为⾃⼰给⾃⼰赋值
		if (_ptr !=ap._ptr)
		{
			// 释放当前对象中资源
			if (_ptr)
			{
				delete _ptr;
			}

			// 直接将资源转移,原来的智能指针处于悬浮状态,在使用时可能会出错
			// 转移ap中资源到当前对象中
			_ptr = ap._ptr;
			ap._ptr = NULL;
		}

		return *this;
	}

	~auto_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
		}
	}

	// 像指针⼀样使用
	T& operator*()
	{
		return *_ptr;
	}

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

private:
	T* _ptr;
};

C++11unique_ptr

template<class T>
class unique_ptr
{
public:
	explicit unique_ptr(T* ptr) // explicit : 不能通过隐式转换调用
		:_ptr(ptr)
	{}

	~unique_ptr()
	{
		if (_ptr)
		{
			cout << "delete:" << _ptr << endl;
			delete _ptr;
		}
	}

	// 像指针⼀样使⽤
	T& operator*()
	{
		return *_ptr;
	}

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

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

	// 如果一定要移动资源,那么通过move()即可强制转移
	unique_ptr(unique_ptr<T> && sp)
		:_ptr(sp._ptr)
	{
		sp._ptr = nullptr;
	}

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

private:
	T* _ptr;
};

C++11shared_ptr

template<class T>
class shared_ptr
{
public:
	explicit shared_ptr(T* ptr = nullptr) // 不能隐式类型转换
		: _ptr(ptr)
		, _pcount(new int(1)) // 构造函数,初始化引用计数为1
	{}

	template<class D>
	shared_ptr(T* ptr, D del) // 如果提供_del,则该构造函数
		: _ptr(ptr)
		, _pcount(new int(1))
		, _del(del)
	{}

	// 将当前指向资源所有权传递给新的智能指针,当前智能指针保留原所有权 增加引用计数
	shared_ptr(const shared_ptr<T>& sp) 
		:_ptr(sp._ptr)
		, _pcount(sp._pcount)
		, _del(sp._del)
	{
		++(*_pcount);
	}

	//void release()
	//{
	//	if (--(*_pcount) == 0)
	//	{
	//		// 最后⼀个管理的对象,释放资源
	//		_del(_ptr);
	//		delete _pcount;
	//		_ptr = nullptr;
	//		_pcount = nullptr;
	//	}
	//}

	shared_ptr<T>& operator=(const shared_ptr<T>& sp)
	{
		if (_ptr != sp._ptr)
		{
			// 如果该智能指针之前拥有的资源已经是该资源最后一个引用计数了,那么就释放资源
			// 如果不是,就对引用计数 -1 ,然后改变当前智能指针指向的资源,指向新资源
			if (--(*_pcount) == 0)
			{
				// 最后⼀个管理的对象,释放资源
				_del(_ptr);
				delete _pcount;
				_ptr = nullptr;
				_pcount = nullptr;
			}

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

	~shared_ptr()
	{
		if (--(*_pcount) == 0)
		{
			// 最后⼀个管理的对象,释放资源
			_del(_ptr);
			_ptr = nullptr;
			delete _pcount;
			_pcount = nullptr;
		}
	}

	T* get() const
	{
		return _ptr;
	}

	int use_count() const
	{
		return *_pcount;
	}

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

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

private:
	T* _ptr;
	int* _pcount;
	//atomic<int>* _pcount;
	function<void(T*)> _del = [](T* ptr) {delete ptr; };
};

C++11weak_ptr

template<class T>
class weak_ptr
{
public:
    weak_ptr()
    {}

    weak_ptr(const shared_ptr<T>& sp)
        :_ptr(sp.get())
    {}

    weak_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        _ptr = sp.get();
        return *this;
    }

private:
    T* _ptr = nullptr;
};

shared_ptr 循环引用引出 weak_ptr 的作用

在使用 shared_ptr 管理资源时,特殊情况下,如果多个 shared_ptr 通过成员变量相互引用,就会形成循环引用,导致引用计数永不为 0,资源无法释放,进而引发内存泄漏问题。weak_ptr 通过不增加引用计数的方式,帮助打破这种循环引用。


循环引用的形成

以下是循环引用问题的一个典型场景:

示例代码:
#include <iostream>
#include <memory>
using namespace std;

struct ListNode {
    int _data;
    std::shared_ptr<ListNode> _next; // 下一个节点
    std::shared_ptr<ListNode> _prev; // 上一个节点
    // 这里改成weak_ptr,当n1->_next = n2;绑定shared_ptr时
    // 不增加n2的引用计数,不参与资源释放的管理,就不会形成循环引用了
    /*std::weak_ptr<ListNode> _next;
     std::weak_ptr<ListNode> _prev;*/

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

int main() {
    // 创建两个共享指针
    std::shared_ptr<ListNode> n1(new ListNode);
    std::shared_ptr<ListNode> n2(new ListNode);

    cout << "n1 use_count: " << n1.use_count() << endl; // 输出 1
    cout << "n2 use_count: " << n2.use_count() << endl; // 输出 1

    // 相互引用
    n1->_next = n2;
    n2->_prev = n1;

    cout << "n1 use_count after linking: " << n1.use_count() << endl; // 输出 2
    cout << "n2 use_count after linking: " << n2.use_count() << endl; // 输出 2

    // 离开作用域,n1 和 n2 的析构函数不会被调用,导致内存泄漏
    return 0;
}
问题分析:
  1. n1_next 成员指向 n2,因此 n2 的引用计数增加。
  2. n2_prev 成员指向 n1,因此 n1 的引用计数增加。
  3. 形成循环引用时,n1n2 的引用计数都为 2。
  4. 即使 n1n2 离开作用域,它们的引用计数永远不会为 0,因此资源无法释放。

再次理解循环引用:

  1. 右边的节点什么时候释放呢,左边节点中的_next管着呢,_next析构后,右边的节点就释放了。
  2. _next什么时候析构呢,_next是左边节点的的成员,左边节点释放,_next就析构了。
  3. 左边节点什么时候释放呢,左边节点由右边节点中的_prev管着呢,_prev析构后,左边的节点就释
    放了。
  4. _prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。 至此逻辑上成功形成回旋镖似的循环引用,谁都不会释放就形成了循环引用,导致内存泄漏把ListNode结构体中的_next和_prev改成weak_ptr,weak_ptr绑定到shared_ptr时不会增加它的引用计数,_next和_prev不参与资源释放管理逻辑,就成功打破了循环引用,解决了这里的问题。

循环引用的理解

ListNode中的 std::shared_ptr<ListNode> _next; std::shared_ptr<ListNode> _prev;互相引用时:

// 相互引用
n1->_next = n2;
n2->_prev = n1;

调用成员函数:

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

由此导致离开作用域时,在n1 和 n2 的析构函数中因为引用是2而只是将_pcount进行了-1,没有进行内存释放,造成内存泄漏。

这并不是一个“循环”本身的问题,而是两个对象互相持有对方的 shared_ptr 导致引用计数形成了一个“闭环”,通过引用计数的次数阻止了资源的释放。这种“互相指向”实际上形成了一个引用计数上的死锁,使得两个对象的引用计数都无法降到零,最终导致内存泄漏。

循环引用的根本原因:shared_ptr 相互引用时,引用计数永不为 0,资源无法释放。


使用 weak_ptr 解决循环引用

template<class T>
class weak_ptr
{
public:
    weak_ptr()
    {}

    weak_ptr(const shared_ptr<T>& sp)
        :_ptr(sp.get())
    {}

    weak_ptr<T>& operator=(const shared_ptr<T>& sp)
    {
        _ptr = sp.get();
        return *this;
    }

private:
    T* _ptr = nullptr;
};
}

weak_ptr构造时不支持绑定到资源,只支持绑定到shared_ptr,绑定时不增加shared_ptr的引用计数,如此便可以解决循环引用的问题。

改进后的代码:
#include <iostream>
#include <memory>
using namespace std;

struct ListNode {
    int _data;
    std::weak_ptr<ListNode> _next;   // 下一个节点
    std::weak_ptr<ListNode> _prev;    // 使用 weak_ptr 打破循环引用

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

int main() {
    // 创建两个共享指针
    std::shared_ptr<ListNode> n1(new ListNode);
    std::shared_ptr<ListNode> n2(new ListNode);

    cout << "n1 use_count: " << n1.use_count() << endl; // 输出 1
    cout << "n2 use_count: " << n2.use_count() << endl; // 输出 1

    // 建立关系
    n1->_next = n2;
    n2->_prev = n1; // _prev 使用 weak_ptr,不增加引用计数

    cout << "n1 use_count after linking: " << n1.use_count() << endl; // 输出 1
    cout << "n2 use_count after linking: " << n2.use_count() << endl; // 输出 2

    // 离开作用域,资源正常释放
    return 0;
}
改进点:
  1. _prev 改为 weak_ptr,不增加 n1 的引用计数。
  2. 离开作用域时,n2_prev 并不阻止 n1 的释放,进而正常释放资源。

weak_ptr 的特性与用法

打破循环引用

weak_ptr 最常见的应用场景是配合 shared_ptr 解决循环引用问题。

  • 循环引用问题:当两个或多个 shared_ptr 互相引用时,引用计数永不为 0,导致资源无法释放。
  • weak_ptr** 的作用**:
    • 使用 weak_ptr 代替某些 shared_ptr,让引用关系不增加引用计数,从而打破循环引用。
    • 它依赖于 shared_ptr 的资源管理,弱引用不参与资源的生命周期控制。

提供非强引用(非拥有型引用)

weak_ptr 的另一个重要作用是提供一种非强引用,在某些场景下,允许观察资源而不拥有资源。

场景示例:
  1. 观察者模式
    • 一个对象(被观察者)由多个观察者引用,观察者引用被观察者但不负责管理它的生命周期。
    • 通过 weak_ptr,可以在观察者中引用被观察者,同时不影响被观察者的生命周期。
  2. 缓存机制
    • 使用 weak_ptr 引用缓存中的资源,资源被 shared_ptr 管理。
    • 如果缓存中的资源已经被释放,weak_ptr 会变为过期状态,避免无效访问。

检查资源是否过期

weak_ptr 提供了一些独特的功能,可以用来检查资源的状态

  • expired()
    • 检查 shared_ptr 是否已经释放资源。
  • use_count()
    • 获取 shared_ptr 的引用计数。
  • lock()
    • 尝试获取一个有效的 shared_ptr 来访问资源。
    • 如果资源已释放,返回的 shared_ptr 是空对象,确保访问安全。
示例代码:
#include <iostream>
#include <memory>
using namespace std;

int main() {
    std::shared_ptr<string> sp1(new string("Hello"));
    std::weak_ptr<string> wp = sp1;

    cout << "sp1 use_count: " << sp1.use_count() << endl; // 输出 1
    cout << "wp expired: " << wp.expired() << endl;       // 输出 0(未过期)

    // 改变 sp1 的管理对象
    sp1 = std::make_shared<string>("World");

    cout << "sp1 use_count: " << sp1.use_count() << endl; // 输出 1
    cout << "wp expired: " << wp.expired() << endl;       // 输出 1(已过期)

    // 尝试通过 weak_ptr 访问资源
    if (auto sp2 = wp.lock()) {
        cout << "Accessed resource: " << *sp2 << endl;
    } else {
        cout << "Resource expired, cannot access." << endl;
    }

    return 0;
}
输出结果:
sp1 use_count: 1
wp expired: 0
sp1 use_count: 1
wp expired: 1
Resource expired, cannot access.

安全访问资源

**weak_ptr**** 不支持 RAII,**仅观察资源,不参与资源管理。

相比直接使用裸指针(如 raw pointer)观察资源,weak_ptr 提供了更安全的方式:

  • 在资源被释放时,weak_ptr 会自动检测到过期状态,避免了悬空指针问题。
  • 通过 lock() 获取 shared_ptr,在访问前确保资源有效。

总结

weak_ptr 的作用不仅仅是解决引用计数的问题,它的主要功能包括:

  1. 打破循环引用:解决 shared_ptr 循环引用导致的内存泄漏。
  2. 非强引用:提供观察资源的能力,而不影响资源的生命周期。
  3. 安全访问:在访问资源前,检查其是否仍然有效。

weak_ptr 是一种辅助工具,用于在某些需要非强引用的场景中增强程序的健壮性和资源管理的灵活性。

shared_ptr 的线程安全问题与解决方案

shared_ptr 的引用计数是共享资源管理的重要机制,当多个 shared_ptr 实例同时管理同一资源时,引用计数的增减需要是线程安全的,否则可能导致资源重复释放或资源未释放等问题。


引用计数的线程安全

问题描述:
  • shared_ptr 的引用计数(use_count)在堆上存储。
  • 多个线程同时对 shared_ptr 的引用计数进行增减操作时,如果操作不是线程安全的,可能导致以下问题:
    1. 计数错误:引用计数被破坏,导致资源未释放或重复释放。
    2. 程序崩溃:多个线程竞争访问引用计数,可能导致未定义行为。
默认的 shared_ptr
  • 标准库实现的 shared_ptr 使用了原子操作(如 std::atomic)来保证引用计数的线程安全,允许多个线程同时拷贝或销毁 shared_ptr
  • 然而,shared_ptr 仅保证其自身的引用计数是线程安全的,并不保证管理的资源是线程安全的。

被管理资源的线程安全

  • shared_ptr 管理的对象(即资源本体)并不是线程安全的。
  • 如果多个线程同时访问和修改同一资源,需要外部通过互斥锁(std::mutex)等机制实现资源的同步。

自定义 shared_ptr 引用计数的线程安全

以下代码模拟了自定义 shared_ptr 的实现,并展示如何通过原子操作(std::atomic)或互斥锁(std::mutex)解决线程安全问题。

原始问题示例(线程不安全):
#include <iostream>
#include <thread>
#include <memory>
#include <mutex>
using namespace std;

struct AA {
    int _a1 = 0;
    int _a2 = 0;
    ~AA() {
        cout << "~AA()" << endl;
    }
};

template <typename T>
class SharedPtr {
public:
    SharedPtr(T* ptr = nullptr) : _ptr(ptr), _ref_count(new int(1)) {}
    SharedPtr(const SharedPtr& other) : _ptr(other._ptr), _ref_count(other._ref_count) {
        ++(*_ref_count); // 非线程安全
    }
    ~SharedPtr() {
        if (--(*_ref_count) == 0) { // 非线程安全
            delete _ptr;
            delete _ref_count;
        }
    }

    T* operator->() { return _ptr; }
    T& operator*() { return *_ptr; }
    int use_count() const { return *_ref_count; }

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

此代码中的引用计数 (_ref_count) 不是线程安全的,当多个线程同时对 SharedPtr 进行拷贝或销毁时,可能导致未定义行为。


使用 std::atomic 确保引用计数线程安全

int* _ref_count 替换为 std::atomic<int>* _ref_count

template <typename T>
class SharedPtr {
public:
    SharedPtr(T* ptr = nullptr) : _ptr(ptr), _ref_count(new std::atomic<int>(1)) {}
    SharedPtr(const SharedPtr& other) : _ptr(other._ptr), _ref_count(other._ref_count) {
        _ref_count->fetch_add(1); // 线程安全的 ++
    }
    ~SharedPtr() {
        if (_ref_count->fetch_sub(1) == 1) { // 线程安全的 --
            delete _ptr;
            delete _ref_count;
        }
    }

    T* operator->() { return _ptr; }
    T& operator*() { return *_ptr; }
    int use_count() const { return _ref_count->load(); }

private:
    T* _ptr;
    std::atomic<int>* _ref_count;
};
  • fetch_add(1)fetch_sub(1) 是原子操作,确保多线程环境下引用计数的正确性。
  • 即使多个线程同时对 SharedPtr 进行拷贝或销毁,也不会破坏引用计数。

使用互斥锁确保线程安全

在一些特殊场景下,可以使用 std::mutex 来保护引用计数:

template <typename T>
class SharedPtr {
public:
    SharedPtr(T* ptr = nullptr) : _ptr(ptr), _ref_count(new int(1)), _mtx(new std::mutex) {}
    SharedPtr(const SharedPtr& other) : _ptr(other._ptr), _ref_count(other._ref_count), _mtx(other._mtx) {
        std::lock_guard<std::mutex> lock(*_mtx);
        ++(*_ref_count);
    }
    ~SharedPtr() {
        std::lock_guard<std::mutex> lock(*_mtx);
        if (--(*_ref_count) == 0) {
            delete _ptr;
            delete _ref_count;
            delete _mtx;
        }
    }

    T* operator->() { return _ptr; }
    T& operator*() { return *_ptr; }
    int use_count() const {
        std::lock_guard<std::mutex> lock(*_mtx);
        return *_ref_count;
    }

private:
    T* _ptr;
    int* _ref_count;
    std::mutex* _mtx; // 保护引用计数的互斥锁
};
  • 使用 std::mutex 确保引用计数的增减操作是线程安全的。
  • 但性能可能不如 std::atomic,因为互斥锁的开销更大。

资源访问的线程安全

即使 shared_ptr 的引用计数是线程安全的,资源本体的线程安全性需要外部保证。例如:

示例代码:
#include <iostream>
#include <memory>
#include <thread>
#include <mutex>
using namespace std;

struct AA {
    int _a1 = 0;
    int _a2 = 0;
    ~AA() {
        cout << "~AA()" << endl;
    }
};

int main() {
    shared_ptr<AA> p(new AA);
    const size_t n = 100000;
    mutex mtx;

    auto func = [&]() {
        for (size_t i = 0; i < n; ++i) {
            // 拷贝 shared_ptr,线程安全
            shared_ptr<AA> copy(p);

            // 使用锁保护资源访问
            {
                unique_lock<mutex> lk(mtx);
                copy->_a1++;
                copy->_a2++;
            }
        }
    };

    thread t1(func);
    thread t2(func);
    t1.join();
    t2.join();

    cout << p->_a1 << endl;
    cout << p->_a2 << endl;
    cout << p.use_count() << endl;

    return 0;
}
输出结果:
200000
200000
1
分析:
  • 使用 std::mutex 确保资源的访问是线程安全的。
  • shared_ptr 的引用计数(拷贝和销毁)由标准库自动保证线程安全。

总结

  1. 引用计数的线程安全
    • 标准库的 shared_ptr 使用 std::atomic 保证引用计数的线程安全。
    • 自定义实现时可以使用 std::atomicstd::mutex
  2. 资源的线程安全
    • shared_ptr 不保证管理的资源本体是线程安全的,外部需要通过同步机制(如 std::mutex)保护资源的访问。
  3. 推荐方案
    • 在大多数情况下,直接使用标准库的 std::shared_ptr 即可,它已经处理了引用计数的线程安全问题。
    • 如果需要访问共享资源,外部需要提供额外的同步机制来确保资源的线程安全。

内存泄漏

内存泄漏指的是程序在运行过程中申请了内存资源,但由于程序设计疏忽或异常导致这些资源未被释放,从而使这部分内存资源无法再被使用或回收。

  • 本质
    • 内存泄漏并不意味着物理内存消失,而是程序分配了一块内存,却因为失去了指针或引用的控制权,无法再访问和释放这块内存。
  • 常见原因
    1. 忘记释放:动态分配的内存没有在适当时机调用 deletefree 释放。
    2. 异常中断:程序发生异常时,未能正确执行释放逻辑。
    3. 循环引用:使用智能指针(如 shared_ptr)时,两个或多个对象间形成循环引用,导致资源无法释放。
    4. 悬空指针:内存被释放后,仍然保留指针指向这块内存,之后又尝试使用它。

内存泄漏的危害

  • 短期程序
    • 对于短时间运行的程序(如命令行工具等),内存泄漏的危害较小。
    • 程序结束时,进程的页表会被操作系统清理,内存也随之释放。
    • 示例
int main() {
    char* ptr = new char[1024 * 1024 * 1024]; // 申请 1GB 内存
    cout << (void*)ptr << endl;
    return 0; // 程序结束后操作系统回收内存
}
  • 长期运行的程序
    • 对于长期运行的程序(如后台服务、操作系统、客户端软件等),内存泄漏会严重影响程序的稳定性和性能。
    • 危害
      1. 内存耗尽:内存不断泄漏会导致可用内存减少,系统或程序最终因无法分配内存而崩溃。
      2. 性能下降:内存泄漏会导致内存碎片化,降低程序和系统的内存分配效率,响应速度变慢。
      3. 资源浪费:泄漏的内存无法回收,可能影响其他进程正常运行。

如何检测内存泄漏

Linux 下内存泄漏检测工具

  • Valgrind
    • 功能强大,能检测内存泄漏、非法内存访问等。
    • 使用示例:
valgrind --leak-check=full ./your_program
  • AddressSanitizer (ASan)
    • GCC 和 Clang 提供的内存检测工具。
    • 编译时添加标志:
g++ -fsanitize=address -g your_program.cpp -o your_program
./your_program

Windows 下内存泄漏检测工具

  • Visual Leak Detector (VLD)
    • 一个简单易用的内存泄漏检测工具。
    • 在 Visual Studio 中集成后,编译程序时会自动检测内存泄漏。

跨平台工具

  • Valgrind:支持 Linux 和部分 Unix 系统。
  • Dr. Memory:支持 Windows 和 Linux。

如何避免内存泄漏

良好的编码规范

  • 在代码中动态分配内存时,明确对应的释放逻辑。
    • 如:newdelete 配对,mallocfree 配对。
  • 避免出现多重分配、重复释放或忘记释放的问题。

使用智能指针

  • 使用智能指针(如 std::unique_ptrstd::shared_ptr)自动管理内存资源。
  • 智能指针利用 RAII 原则,绑定资源的生命周期到智能指针的作用域,程序离开作用域时自动释放资源。
  • 避免使用 raw pointer(裸指针)。

采用 RAII 思想

  • RAII(资源获取即初始化)
    • 将资源的获取和释放绑定到对象生命周期中。
    • 例如,将动态分配的资源封装到一个类中,在类的析构函数中释放资源。
    • 示例
class ResourceGuard 
{
    char* _data;
public:
    ResourceGuard(size_t size) : _data(new char[size]) {}
    ~ResourceGuard() { delete[] _data; } // 确保资源释放
};
定期检查内存泄漏
  • 开发过程中,使用内存检测工具进行测试。
  • 在上线前通过专业的工具进行内存检查和性能测试。
防止循环引用
  • 在使用 std::shared_ptr 时,避免循环引用。
  • 将循环引用中的一个 shared_ptr 替换为 std::weak_ptr,打破循环。

示例:常见内存泄漏场景及解决

场景 1:忘记释放内存
void leak() {
    char* data = new char[1024];
    // 未调用 delete,导致内存泄漏
}

解决方案

  • 使用智能指针自动管理内存:
void no_leak() {
    std::unique_ptr<char[]> data(new char[1024]);
    // 离开作用域时,智能指针会自动释放内存
}

场景 2:循环引用导致内存泄漏
#include <memory>
struct Node {
    std::shared_ptr<Node> next;
    ~Node() { std::cout << "Node destroyed" << std::endl; }
};

void cyclic_leak() {
    auto n1 = std::make_shared<Node>();
    auto n2 = std::make_shared<Node>();
    n1->next = n2;
    n2->next = n1; // 循环引用
}

解决方案

  • 使用 std::weak_ptr 打破循环:
struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // 使用 weak_ptr 避免循环引用
    ~Node() { std::cout << "Node destroyed" << std::endl; }
};

void no_cyclic_leak() {
    auto n1 = std::make_shared<Node>();
    auto n2 = std::make_shared<Node>();
    n1->next = n2;
    n2->prev = n1; // 打破循环引用
}

总结

  1. 什么是内存泄漏
    • 程序分配内存后未能释放,导致内存资源无法回收。
  2. 内存泄漏的危害
    • 对短期程序影响较小,但对长期运行的程序可能导致系统性能下降甚至崩溃。
  3. 如何检测内存泄漏
    • 使用工具如 Valgrind、AddressSanitizer、VLD 等。
  4. 如何避免内存泄漏
    • 良好的编码习惯。
    • 使用智能指针和 RAII 管理资源。
    • 使用工具进行定期检测。
    • 通过 weak_ptr 避免循环引用。

通过规范编码和正确使用工具,可以有效预防和解决内存泄漏问题,保障程序的稳定性和高效运行。

C++11及之前的语法与特性到此已经大概讲解完成,内容可能会跳跃或者不全面,如果有问题可以私信笔者或者在评论区留言。
总之,在撰写代码的时候一定要注意语法的规范以及优美的代码风格,代码风格不仅利于自己调试和理解,也会使他人阅读更加便捷。如果有需要可以阅读《高质量C/C++编程》一书。
感谢阅读我的博客!!!

;