1、智能指针的模拟实现和使用
c++中new或者malloc出来的资源,是需要程序员手动去释放的,在这个过程中会发生两种问题,(1)忘记释放,(2)发生异常安全问题,new出资源后,还没delete掉就发生异常,跳出到外层捕获异常,像下面这样。最终都会导致资源的泄露。
void func()
{
int* p = new int(3);
{
if (*p == 3)
throw (string)"cuowu";
}
delete p;
}
int main()
{
try
{
func();
}
catch (string str)
{
cout << str << endl;
}
return 0;
}
这样衍生出了一种新的写法,叫做智能指针,在动态开辟(new/malloc)空间时使用智能指针对象来接收指针,智能指针生命周期到了之后会自动调用析构函数,自动delete空间,一旦发生上面所说的异常问题,在跳出到外层栈空间时,new出的资源就会自动释放。有了这种智能指针,我们就可以在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,我们实际上把管理一份资源的责任托管给了一个对象,这样我们就不需要显式地释放资源。通过重载operator*和operator->可以赋予智能指针正常指针的功能。
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
if (_ptr)
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
这种利用对象生命周期来控制程序资源的思想我们称为RAII(Resource Acquisition Is Initialization),lock_guard也应用了这种思想。
上面是最简单最早期的智能指针实现版本,有很多缺陷,最主要的是没有解决智能指针之间的赋值问题。赋值操作使得两个智能指针能对同一块资源做管理,但是在析构时,两个智能指针先后析构同一块空间,会导致下面这段程序崩溃。
int main()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2 = sp1;
return 0;
}
在c++的发展过程中,先后有三种对上述问题的解决方案。c++98的auto_ptr,解决思路是管理权转移。c++11的unique_ptr,思路是防拷贝。c++11的shared_ptr思路是引用计数共享拷贝。
auto_ptr采用的管理权转移,其实就是在发生拷贝构造或者赋值时,将原来的智能指针置空,这样对特定空间的管理权就由原对象转移到了新对象,这样原来的智能指针就不能进行解引用等指针操作了,这种管理权转移的做法是早期的设计缺陷,一般公司都明令禁止使用它。
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<T>& operator=(const auto_ptr<T>& ap)
{
if (this != &ap)
{
if (_ptr)
delete _ptr;
_ptr = ap._ptr;
ap._ptr = nullptr;
}
return *this;
}
~auto_ptr()
{
if (_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
private:
T* _ptr;
};
unique_ptr的策略也比较简单粗暴,直接将拷贝和赋值设置为delete,没有生成对应的拷贝和赋值,推荐使用。但是缺陷是,在某些特定的需要拷贝或者赋值的场景下无法使用。
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
unique_ptr(unique_ptr<T>& up) = delete;
unique_ptr<T>& operator=(unique_ptr<T>& up) = delete;
~unique_ptr()
{
if (_ptr)
{
delete _ptr;
_ptr = nullptr;
}
}
private:
T* _ptr;
};
shared_ptr的做法是在智能指针类中添加一个计数器,用于标定在发生赋值或者拷贝操作之后,一块空间由多少个智能指针来管理。发生一次拷贝或者赋值,计数器就+1,计数器不为1时,析构函数的操作是将计数器-1,如果为1则直接将所管理的空间直接析构。
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
:_ptr(ptr), _pcount(new int(1))
{}
shared_ptr(shared_ptr<T>& sp)
:_ptr(sp._ptr), _pcount(sp._pcount)
{
++(*_pcount);
}
shared_ptr<T>& operator=(shared_ptr<T>& sp)
{
if (this != &sp)
{
if (--(*_pcount) == 0)
{
delete _pcount;
delete _ptr;
}
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*pcount);
}
return *this;
}
~shared_ptr()
{
if (--(*_pcount) == 0 && _ptr)
{
delete _ptr;
_ptr = nullptr;
delete _pcount;
_pcount = nullptr;
}
}
private:
T* _ptr;
int* _pcount;
};
在拷贝构造和赋值时有对计数器的++操作,在析构时有对计数器的--操作,那么shared_ptr,是否是线程安全的呢?答案是的,在库中实现的shared_ptr还内置了一个互斥量mutex,用于保证只有一个线程访问shared_ptr的计数器。
shared_ptr也有其本身的缺陷,它无法应对循环引用的问题,观察下面的代码,ListNode节点内包含两个智能指针,当spn1的尾指向spn2,spn2的头指向spn1,这时两个节点的计数器都为2,因为”spn1->spnext = spn2;“以及后续的”spn2->spprev = spn1;“操作本质上就是赋值,发生了计数器的++,这样一来在spn1和spn2在生命周期结束之后都不能进行析构。解决方法是设计一个新的类叫做weak_ptr(弱指针),这个类没有默认构造函数,且需要用shared_ptr进行初始化。在struct ListNode内部使用weak_ptr<ListNode>就可以防止被多重引用的问题,节点链接时,计数器也不会++。
struct ListNode
{
int val;
shared_ptr<ListNode> _spnext;
shared_ptr<ListNode> _spprev;
};
int main()
{
shared_ptr<ListNode> spn1(new ListNode);
shared_ptr<ListNode> spn2(new ListNode);
spn1->spnext = spn2;
spn2->spprev = spn1;
return 0;
}
template<class T>
class weak_ptr
{
public:
weak_ptr() = default;
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get_ptr())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get_ptr();
return *this;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
库中实现的shared_ptr更加复杂,构造对象的参数还有删除器对象(缺省参数是默认析构函数),作为仿函数存在,在shared_ptr析构时定义一个删除器对象,调用其operator()函数对指向的空间进行析构。如果shared_ptr的模板不是一个普通的指针,而是一个文件FILE*,这时候就不能使用缺省参数,需要定制删除器对象并传参。具体如下。传入删除器相当于通过仿函数进行定制的析构,如果使用默认的delete这段代码会报错,因为FILE*不能通过delete来释放。
struct Fclose
{
void operator()(FILE* p)
{
fclose(p);
}
};
int main()
{
std::shared_ptr<FILE> sp(fopen("test.txt", "w"), Fclose());
return 0;
}
使用RAII思想还可以设计锁的管理守卫,使用互斥量来构造LockGuard,这样在访问临界资源的时候假如发生了异常,也可以利用LockGuard的生命周期来进行解锁,不用担心多线程的死锁问题。注意LockGuard里面的成员变量是引用,锁是不支持拷贝的,因此只能用引用。
template<class Lock>
class LockGuard
{
public:
LockGuard(lock& lock)
:_lk(lock)
{
_lk.lock();
}
~LockGuard()
{
_lk.unlock();
}
private:
Lock& _lk;
};
2、内存泄漏
一般我们申请了资源,这个资源不使用了,但是忘记释放,或者因为异常安全等问题没有释放,这时就造成了内存泄漏。如果我们申请的内存没有释放,但是进程正常结束,那么这个内存也会释放。一般程序碰到内存泄漏,重启后就可以,但是长期运行,不能随便重启的程序,碰到内存泄漏危害非常大,比如操作系统。
由于栈上的内存的分配和回收都是由编译器控制的,所以在栈上是不会发生内存泄露的,只会发生栈溢出(Stack Overflow),也就是分配的空间超过了规定的栈大小。而当在堆上申请内存后忘记使用 free/delete 来回收,就发生了内存泄露。开辟空间本质上是建立虚拟地空间和物理地址的映射关系,如果使用完资源不释放,物理地址的映射关系就会一直存在,那么别的进程就无法使用这块资源。内存泄漏会造成可用的物理内存越来越小。
在实际编程时,为了避免内存泄漏问题,我们需要小心谨慎一些,不好处理的地方多用智能指针去管理。