一篇文章带你详细了解C++智能指针
一篇文章带你详细了解C++智能指针
- 为什么要有智能指针
- 内存泄漏
- 1.什么是内存泄漏,它的危害是什么
- 2.内存泄漏的分类
- 3.如何避免内存泄漏
- 智能指针的使用及原理
- 1.RAII
- 2.智能指针的原理
- 3.auto_ptr
- 4.unique_ptr
- 5.shared_ptr
- 6.weak_ptr
为什么要有智能指针
C++引入智能指针的主要目的是为了解决手动管理内存的问题,提高程序的健壮性和可维护性。在C++中,内存管理由程序员手动完成,包括内存的分配和释放。手动管理内存可能导致一些常见的问题,如内存泄漏、释放已经释放的内存(二次释放)、野指针等。
智能指针是一种封装了指针的类,它可以自动管理内存的生命周期,使得内存的分配和释放更加安全和方便。
我们考虑一个简单的情景,展示为什么需要智能指针以及它是如何解决问题的。
假设有一个
Person
类表示一个人,该类有一个成员变量是name
,并且我们在动态内存中为其分配内存:
#include <iostream>
#include <cstring>
class Person {
public:
Person(const char* n) {
name = new char[strlen(n) + 1];
strcpy(name, n);
}
~Person() {
delete[] name;
}
void printName() const {
std::cout << name << std::endl;
}
private:
char* name;
};
int main() {
Person* personPtr = new Person("John");
personPtr->printName();
delete personPtr; // 忘记释放内存
return 0;
}
在这个例子中,我们通过 new
在堆上创建了一个 Person
对象,但在程序结束前忘记了调用 delete
来释放内存。这会导致内存泄漏,因为自定义对象的析构函数不会被调用,从而无法释放 name
的内存。
这里我们可以使用智能指针中的unique_ptr
来管理Person
对象:
#include <iostream>
#include <memory>
#include <cstring>
class Person {
public:
Person(const char* n) {
name = new char[strlen(n) + 1];
strcpy(name, n);
}
~Person() {
delete[] name;
}
void printName() const {
std::cout << name << std::endl;
}
private:
char* name;
};
int main() {
std::unique_ptr<Person> personPtr = std::make_unique<Person>("John");
personPtr->printName(); // 在作用域结束时自动释放内存
return 0;
}
当然这里只是常规的情况,普通的内存泄漏问题,很多人觉得只要多注意一些就可以了,但是如果是下面的情况呢?
考虑以下场景,其中
Person
类有一个成员变量bestFriend
表示另一个Person
对象,两个人互为最好的朋友:
#include <iostream>
class Person {
public:
Person(const char* n) : name(n), bestFriend(nullptr) {}
~Person() {
std::cout << name << " destroyed." << std::endl;
}
void setBestFriend(Person* friendPtr) {
bestFriend = friendPtr;
}
private:
const char* name;
Person* bestFriend;
};
int main() {
Person* john = new Person("John");
Person* mary = new Person("Mary");
john->setBestFriend(mary);
mary->setBestFriend(john);
// delete john;
// delete mary;
return 0;
}
在这个例子中,John
和 Mary
形成了循环引用,因为它们彼此引用对方作为最好的朋友。如果我们尝试使用原始指针进行 delete
,它们的析构函数将永远不会被调用,导致内存泄漏。这里我们就可以使用智能指针中的shared_ptr
就可以解决这个问题,因为 std::shared_ptr
使用引用计数来跟踪对象的引用数量,当引用计数为零时,对象会被正确地销毁。然而,使用原始指针来管理这种情况会导致无法释放的内存。
**如果两个或多个对象相互引用,形成循环引用,而使用原始指针管理内存,可能导致内存泄漏,因为循环引用会导致引用计数无法归零,从而无法释放对象。**所以在对对象的管理中我们会遇到很多原始指针解决不了的问题,所以才有了智能指针的由来。
内存泄漏
1.什么是内存泄漏,它的危害是什么
内存泄漏是指在程序运行过程中,分配的内存空间在不再需要时没有被释放,导致系统中的可用内存减少。内存泄漏可能发生在各种编程语言中,包括C++、Java、C#等。内存泄漏的危害主要包括:
- 资源浪费: 内存泄漏导致未释放的内存无法被重新使用,导致系统中的可用内存逐渐减小。这可能导致系统性能下降、程序变得缓慢,甚至在极端情况下导致系统崩溃。
- 程序性能下降: 随着时间的推移,内存泄漏会导致程序使用的内存越来越多,从而增加了垃圾回收的负担,使得程序运行变得更加缓慢。这对于长时间运行的服务或应用程序来说尤其是一个严重的问题。
- 系统稳定性下降: 内存泄漏可能导致系统内存耗尽,最终导致系统不稳定,甚至崩溃。这对于一些关键系统、服务器或嵌入式系统来说可能是致命的。
- 难以调试: 内存泄漏通常不容易被发现,因为程序在运行时没有显著的错误提示。随着内存泄漏的累积,程序可能会在某一刻因为内存不足而崩溃,而这个问题的根本原因可能很难追踪。
- 安全隐患: 内存泄漏也可能导致安全问题。恶意攻击者可以利用未释放的内存来执行缓冲区溢出等攻击,从而破坏程序的正常执行,甚至入侵系统。
2.内存泄漏的分类
内存泄漏可以分为几种常见的类型,每种类型都有不同的原因和表现。以下是一些常见的内存泄漏分类:
- 堆内存泄漏(Heap Memory Leak): 堆内存泄漏是指在动态分配内存时,没有正确释放这些内存导致的泄漏。这通常发生在使用
new
或malloc
分配内存后忘记使用delete
或free
进行释放。 - 栈内存泄漏(Stack Memory Leak): 栈内存泄漏通常是由于在函数或代码块中分配的局部变量没有在该函数或代码块结束时被正确释放。栈内存泄漏通常较为轻微,因为在函数结束时,栈上的局部变量会自动被销毁。
- 全局/静态内存泄漏(Global/Static Memory Leak): 全局变量和静态变量在程序的整个生命周期内存在,如果没有在程序结束时释放相关内存,就会导致全局或静态内存泄漏。
- 循环引用(Circular Reference): 循环引用是指两个或多个对象相互引用,形成一个循环结构,并且它们的引用计数无法归零。这种情况下,即使没有其他引用,这些对象也无法被垃圾回收。
- 虚拟机泄漏(Memory Leak in Managed Runtimes): 在使用托管运行时(如Java虚拟机、.NET运行时等)的环境中,有时会出现虚拟机泄漏,即运行时本身没有正确释放的内存。
- 资源泄漏(Resource Leak): 除了内存之外,资源泄漏还可以包括其他类型的资源,例如文件句柄、网络连接等。如果这些资源在使用完毕后没有被释放,就会导致资源泄漏。
3.如何避免内存泄漏
避免内存泄漏是编程中非常重要的任务之一。以下是一些常见的方法和最佳实践,有助于减少或避免内存泄漏:
-
使用智能指针: 使用C++的智能指针,如
std::shared_ptr
、std::unique_ptr
等,可以自动管理内存的释放。这样可以减少手动释放内存的机会,防止忘记释放或重复释放的问题。// 使用 std::shared_ptr std::shared_ptr<int> smartPtr = std::make_shared<int>(42);
-
RAII(资源获取即初始化)原则: 使用对象生命周期管理资源。确保在对象创建时分配资源,在对象销毁时释放资源。智能指针正是基于这个原则设计的。
-
避免手动管理内存: 尽量避免使用
new
和delete
进行手动内存管理。使用标准容器和智能指针等抽象层级更高的工具,它们能够更安全地管理内存。 -
使用析构函数: 在类的析构函数中释放在构造函数中分配的资源。确保资源的释放操作被正确实现。
class ResourceHolder { public: ResourceHolder() { // 分配资源 resource = new Resource; } ~ResourceHolder() { // 释放资源 delete resource; } private: Resource* resource; };
-
避免循环引用: 当存在循环引用时,使用弱引用(
std::weak_ptr
)来打破循环引用关系,防止引用计数无法归零。#include <iostream> #include <memory> class Person; class Car { public: void setOwner(std::shared_ptr<Person> person) { owner = person; } private: std::shared_ptr<Person> owner; }; class Person { public: void buyCar() { car = std::make_shared<Car>(); car->setOwner(shared_from_this()); } private: std::shared_ptr<Car> car; };
-
使用工具进行静态和动态分析: 使用工具如静态分析器(如
Clang Static Analyzer
、Cppcheck
)、动态分析器(如Valgrind
)等,帮助发现潜在的内存泄漏问题。 -
使用现代C++特性: C++11及其后续版本引入了许多现代C++特性,如移动语义、智能指针、Lambda表达式等,这些特性有助于更安全和高效地管理内存。
-
良好的编程习惯: 养成良好的编程习惯,注重代码的规范性和清晰性,有助于及早发现潜在的问题,并减少内存泄漏的发生。
智能指针的使用及原理
1.RAII
RAII(Resource Acquisition Is Initialization)
是一种C++编程范式,是一种基于对象生命周期管理资源的策略。RAII的核心思想是,在对象的构造函数中获取资源(如内存、文件句柄、网络连接等),而在对象的析构函数中释放这些资源。这样,资源的生命周期与对象的生命周期绑定在一起,从而确保资源在适当的时候被正确释放。
下面我们看一个使用RAII
思想设计的SmartPtr
类
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
private:
T* _ptr;
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(new int);
cout << div() << endl;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
在这里我们可以看到,通过SmartPtr
类对div
类新对象的建立,我们可以在其创建新对象时自动将指针进行托管,最终不用我们手动去调用析构函数,而是在作用域将结束时,通过SmartPtr
指向的div
对象指针自动调用析构,从而防止了内存泄漏的问题。
2.智能指针的原理
上述的SmartPtr
还不能将其称为智能指针,因为它还不具有指针的行为。指针可以解引用,也可以通过->去访问所指空间中的内容,因此:此处模板类中还得需要将*
、->
进行重载,才可让其像指针一样去使用 。
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;
}
private:
T* _ptr;
};
struct Date
{
int _year;
int _month;
int _day;
};
int main()
{
SmartPtr<int> sp1(new int);
*sp1 = 10;
cout << *sp1 << endl;
SmartPtr<int> sparray(new Date);
sparray->_year = 2023;
sparray->_month = 1;
sparray->_day = 1;
}
需要注意的是这里应该是sparray.operator->()->_year = 2018
;应该是sparray->->_year
这里语法上为了可读性,省略了一个->
3.auto_ptr
auto_ptr
是 C++98 标准中引入的一种智能指针,用于管理动态分配的内存。它是第一个尝试提供自动内存管理的 C++ 标准库智能指针,然而,由于其独特的拥有权转移语义,导致了一些问题,因此在后续的 C++ 标准中被更现代的智能指针(如 std::unique_ptr
和 std::shared_ptr
)所取代。
以下是 auto_ptr
的一些关键特点和历史:
- 独占所有权:
auto_ptr
具有独占所有权的特性,即当一个auto_ptr
拥有某块动态分配的内存时,其他任何auto_ptr
都不能指向同一块内存。这种独占性导致了一些潜在的问题,特别是在涉及到复制和拷贝构造函数时。- 拥有权的转移:
auto_ptr
支持拥有权的转移,即一个auto_ptr
对象的所有权可以转移到另一个对象,而被转移的对象会变成空指针。这样的语义在某些情况下可能导致程序员不经意间的错误。- 不适用于容器: 由于拥有权的转移语义,
auto_ptr
不能安全地用于标准库容器,因为容器的操作可能导致拷贝或复制auto_ptr
对象,从而引发悬空指针和内存泄漏问题。- 被现代智能指针替代: 由于
auto_ptr
存在的问题,C++11 引入了更为安全和灵活的智能指针,如std::unique_ptr
和std::shared_ptr
。这些智能指针提供了更好的内存管理语义和更丰富的功能,取代了auto_ptr
。- 被标记为废弃: C++11 标准中将
auto_ptr
标记为废弃(deprecated),并建议使用更为安全的替代方案。在 C++17 标准中,auto_ptr
被完全移除。
简化模拟实现auto_ptr
namespace yulao
{
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 (this != &ap)
{
if (_ptr)
{
cout << "Delete:" << _ptr << endl;
delete _ptr;
}
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
~auto_ptr()
{
if (_ptr)
{
cout << "Delete:" << _ptr << endl;
delete _ptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
C++98中 auto_ptr
管理权转移,被拷贝对象的出现悬空问题,很多公司是明确的要求了不能使用它
比如下面的情况
class A
{
public:
~A()
{
cout << "~A()" << endl;
}
int _a1 = 0;
int _a2 = 0;
};
int main()
{
auto_ptr<A> ap1(new A);
ap1->_a1++;
ap1->_a2++;
auto_ptr<A> ap2(ap1);
ap1->_a1++;
ap1->_a2++;
return 0;
}
这里使用了 auto_ptr
来管理动态分配的对象 A
的内存。然而,需要注意的是,auto_ptr
具有拥有权转移的语义,因此在将一个 auto_ptr
赋值给另一个后,原始的 auto_ptr
将变为 nullptr
指针。这会导致后续对原始指针成员 _a1
和 _a2
的访问可能导致未定义行为。auto_ptr
在现代C++中已经被废弃,有了更为安全的 unique_ptr
来管理动态分配的内存。unique_ptr
没有拥有权转移的问题,并提供了更好的所有权管理。
4.unique_ptr
在C++11出现之前其实已经有了智能指针(不是指auto_ptr
),是在第三方的一个C++库中,名叫boost库。
Boost库是一个由C++社区开发和维护的开源库集合,提供了许多高质量、可移植且通用的C++工具和组件。在C++11引入标准智能指针之前,Boost库已经提供了类似的功能。
Boost库中的智能指针主要是boost::shared_ptr
和boost::scoped_ptr
。以下是它们在C++98到C++11之间的历史发展:
-
boost::scoped_ptr
: 在C++98时代,Boost库引入了boost::scoped_ptr
。这是一个独占所有权的智能指针,其目的是在其生命周期结束时自动释放所管理的资源。然而,由于它的独占性质,boost::scoped_ptr
不能共享资源。#include <boost/scoped_ptr.hpp> int main() { boost::scoped_ptr<int> myInt(new int); // myInt 的生命周期结束时,所管理的内存将被释放 return 0; }
-
boost::shared_ptr
: 随着C++98的发展,Boost库还引入了boost::shared_ptr
。这是一个引用计数智能指针,它允许多个shared_ptr
对象共享对同一对象的所有权,并在最后一个shared_ptr
离开作用域时释放资源。#include <boost/shared_ptr.hpp> int main() { boost::shared_ptr<int> sharedInt(new int); // 多个 sharedInt 对象共享同一块内存资源 return 0; }
-
C++11引入标准智能指针: 随着C++11标准的发布,标准库中引入了
std::unique_ptr
、std::shared_ptr
和std::unique_ptr
,提供了更为强大和灵活的智能指针实现。C++11的智能指针取代了Boost库中的对应版本,并成为标准语言特性。
unique_ptr的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份unique_Ptr来了解它的原理
template<class T>
class unique_ptr
{
private:
// 防拷贝 C++98
// 只声明不实现
//unique_ptr(unique_ptr<T>& ap);
//unique_ptr<T>& operator=(unique_ptr<T>& ap);
public:
unique_ptr(T* ptr = nullptr)
: _ptr(ptr)
{}
// 防拷贝 C++11
unique_ptr(unique_ptr<T>& ap) = delete;
unique_ptr<T>& operator=(unique_ptr<T>& ap) = delete;
~unique_ptr()
{
if (_ptr)
{
cout << "Delete:" << _ptr << endl;
delete _ptr;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
这里我们可以看到为了防止出现auto_ptr
的拷贝空指针的情况,我们可以直接将拷贝构造和赋值重载直接delete
,或者设为私有声明后不实现两种方式。
int main()
{
yulao::unique_ptr<int> sp1(new int);
yulao::unique_ptr<int> sp2(sp1);//此时拷贝会报错
return 0;
}
5.shared_ptr
shared_ptr与unique_ptr最大的区别就是支持拷贝
shared_ptr的原理:是通过引用计数的方式来实现多个shared_ptr对象之间共享资源。
- shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
- 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减一。
- 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
- 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
==引用计数支持多个拷贝管理同一个资源,最后一个析构对象释放资源 ==
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pCount(new int(1))
{}
void Release()
{
if (--(*_pCount) == 0)
{
cout << "Delete:" << _ptr << endl;
delete _ptr;
delete _pCount;
}
}
~shared_ptr()
{
Release();
}
// sp1(sp2)
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)
{
return *this;
}
Release();
// 共管新资源,++计数
_ptr = sp._ptr;
_pCount = sp._pCount;
(*_pCount)++;
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
// 引用计数
int* _pCount;
};
- 引用计数管理: 使用
_pCount
来追踪对象的引用计数,每个shared_ptr
指向同一个对象时,它们共享同一个_pCount
。 - 构造函数和析构函数: 在构造函数中,你初始化
_pCount
为1,表示当前有一个shared_ptr
指向这个对象。在析构函数中,你通过Release()
函数来释放资源,如果引用计数为0,则删除对象和引用计数。 - 拷贝构造函数和拷贝赋值运算符: 当一个
shared_ptr
被拷贝构造或赋值给另一个shared_ptr
时,引用计数会增加。这样,多个shared_ptr
可以共享同一块内存,当最后一个shared_ptr
被销毁时,对象才会被释放。 - 重载
operator *
和operator->
: 使得shared_ptr
的使用方式类似于原始指针。 - 删除资源的时机: 在你的代码中,资源的释放是在
Release()
函数中进行的,该函数在析构函数和赋值运算符中被调用。这确保了资源在最后一个引用被释放时被正确删除。
int main(){
shared_ptr<A> sp1(new A);
shared_ptr<A> sp2(sp1);
shared_ptr<A> sp3(sp1);
sp1->_a1++;
sp1->_a2++;
std::cout << sp2->_a1 << ":" << sp2->_a2 << std::endl;
sp2->_a1++;
sp2->_a2++;
std::cout << sp1->_a1 << ":" << sp1->_a2 << std::endl;
}
我们通过这段代码可以看到shared_ptr
可以很好的支持对对象指针的拷贝并进行管理,实现多指针管理同一个对象,但不会造成多次调用析构的情况。
但是在C++中,使用 shared_ptr
来管理资源,当形成循环引用时,可能导致对象无法正确释放。下面是一个简化的双向链表的例子:
#include <memory>
#include <iostream>
template<typename T>
class Node {
public:
T data;
std::shared_ptr<Node<T>> next;
std::shared_ptr<Node<T>> prev;
Node(const T& val) : data(val), next(nullptr), prev(nullptr) {
std::cout << "Node constructed with value: " << val << std::endl;
}
~Node() {
std::cout << "Node destructed with value: " << data << std::endl;
}
};
int main() {
// 创建一个双向链表节点1
auto node1 = std::make_shared<Node<int>>(1);
// 创建一个双向链表节点2
auto node2 = std::make_shared<Node<int>>(2);
// 形成循环引用
node1->next = node2;
node2->prev = node1;
// 输出每个节点的引用计数
std::cout << "Reference counts: node1=" << node1.use_count() << ", node2=" << node2.use_count() << std::endl;
// 节点1和节点2的引用计数不为零,它们不会被释放
return 0;
}
在上面的例子中,node1
和 node2
形成了双向链表的循环引用。node1
持有 node2
的 shared_ptr
,而 node2
同时持有 node1
的 shared_ptr
。这导致两个节点的引用计数不会变为零,它们的析构函数也不会被调用。这就是循环引用的问题。
为了解决这个问题,可以使用 weak_ptr
来打破循环引用。在上面的例子中,可以将 prev
和 next
成员改为 std::weak_ptr
类型。这样,即使形成了循环引用,weak_ptr
不会增加引用计数,也不会影响节点的析构。
6.weak_ptr
首先我们要知道weak_ptr
不是常规智能指针,没有RAII
,不支持直接管理资源,weak_ptr
主要用shared_ptr
构造,用来解决shared_ptr
循环引用问题,这里将它单独列出,只是为了更好的讲清楚这个问题
简单的模拟实现weak_ptr
// 辅助型智能指针,使命配合解决shared_ptr循环引用问题
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr(const weak_ptr<T>& wp)
:_ptr(wp._ptr)
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
public:
T* _ptr;
};
- 构造函数: 默认构造函数,以及从
shared_ptr
和另一个weak_ptr
构造的构造函数。这是weak_ptr
常见的构造方式。 - 赋值运算符: 从
shared_ptr
赋值的运算符。这允许将shared_ptr
赋值给weak_ptr
,使得weak_ptr
可以观察shared_ptr
所指向的对象,但并不影响引用计数。 - 重载
operator *
和operator->
: 这使得weak_ptr
的使用方式类似于原始指针。 - 注意事项: 在实际使用中,
weak_ptr
通常用于解决循环引用问题,而不是直接与裸指针交互。weak_ptr
不会增加对象的引用计数,因此不会影响对象的生命周期。
以下是一个简单的示例,演示了如何使用 weak_ptr
和 shared_ptr
避免循环引用:
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
shared_ptr<B> b_ptr;
A() { std::cout << "A constructed" << std::endl; }
~A() { std::cout << "A destructed" << std::endl; }
};
class B {
public:
weak_ptr<A> a_ptr; // 使用 weak_ptr 避免循环引用
B() { std::cout << "B constructed" << std::endl; }
~B() { std::cout << "B destructed" << std::endl; }
};
int main() {
shared_ptr<A> a = make_shared<A>();
shared_ptr<B> b = make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
return 0;
}
在这个例子中,A
和 B
类相互引用,但通过使用 weak_ptr
避免了循环引用。
最后再提一句shared_ptr
的线程安全问题,需要等到后面作者将Linux文章更新到多线程再进行讲解,希望大家能够持续关注我!!!