细说智能指针

内存泄漏的产生

在C++中内存的分配与释放都是手工操作的(分配内存用new,释放内存用delete),这种方式本身就很容易产生内存泄漏。因为人们在开发过程中需要内存时很自然的就用new分配一块,但这块内存什么时候释放就说不好了,有可能用完马上就释放,也有可能要等待一个周期才能释放等等。而且随着时间的推移,代码越来越大,需要被释放的内存被遗忘的可能性也就更大。

  • 看一下具体的例子
void myfunc() {

    int *pVal = new int();
    *pVal = 10;
    ...
    if(*pVal == 10){
        return;      //这里会产生内存泄漏
    }
    ...
    delete pVal;
    return;

}

在上面的代码中,使用new在堆空间分配了一个整型大小的空间,在函数结束时通过delete将分配的内存释放。但当pVal==10时,函数没有释放内存就直接退出了,此时就产生了内存泄漏。

有的同学可能会说,谁会写出这么蠢的代码呢?实际上这样的代码在C++项目中经常出现,很多老手有时都犯这样的错误。你之所以可以一眼就看出上面代码的问题,是因为我将代码简化了。在真实的场景中,由于代码量比较大,你就没那么容易一眼看出问题了。


智能指针

提示:这里描述项目中遇到的问题:

上面我们已经看到了,通过new/delete这种方式申请/释放内存存在着很大弊端,有没有什么方法可以在使用时申请内存,在不需要的时自动释放它呢?当然有,这就是智能指针

下面我们来看看智能指针是怎么做到的吧。实际上,智能指针最朴素的想法是利用类的析构函数和函数栈的自动释放机制来自动管理指针,即用户只要按需分配堆空间,堆空间的释放由智能指针帮你完成。

在解释这个原理之前,我们先来补充两个基本知识,一是构造函数与析构函数;另一个是堆空间与栈空间。首先来看构造函数与析构函数。

类对象的构造与析构是C++最基本的概念了,当创建对象时其构造函数会被调用,销毁对象时其析构函数会被调用。我们来举个例子:

#include
class MyClass {
    public:
        MyClass(){
            std::cout << "construct func" << std::endl;
        }

        ~MyClass(){
            std::cout << "deconstruct func" << std::endl;
        }
};

int main(int argc, char *argv[]){
    std::cout << "create MyClass object ..." << std::endl;
    MyClass *myclass = new MyClass();
    std::cout << "release MyClass object ..." << std::endl;
    delete myclass;
}

细说智能指针_第1张图片

通过其结果就可以证明我们上面的结论了,即创建对象时其构造函数会被调用;销毁对像时其析构函数会被调用。

下面我们再来看看堆空间与栈空间。


堆空间与栈空间

我们以Linux为例,在Linux系统上每个进程都有自己的虚似地址空间,如果你的系统是32位的,那它可以访问的内存空间是:2^32,也就是4G大小。

在这么大的空间中,内存被分成了几块:内核块、代码块、BSS块、堆空间,栈空间等。

  • 内核块,由Linux内核使用,应用层不可以访问。
  • 代码块,用户的二进制应用程序,只读。
  • BSS块,全局量,全局常量等。
  • 堆空间,用new分配的动态空间,可以分配大块内存。
  • 栈空间,用于函数调用,分配临时变量等。其空间大小有限,当函数执行完成后其内存会自动回收。

其中栈空间有个特点,当函数执行完后,它所用到的栈空间会被自动释放,而这正是智能指针所需要的。当它与构造函数/析构函数结合到一起时就可以实现智能指针了。下面我们来看一个例子:



template <typename T>
class AutoPtr {
    public:
        explicit AutoPtr(T *ptr = nullptr){
            std::cout << "set new object" << ptr << std::endl;
            _ptr = ptr;

        }

        ~AutoPtr(){
            std::cout << "delete object" << _ptr << std::endl;
            if(_ptr != nulptr)
                delete _ptr;
        }

    private:
        T * _ptr;
};

class MyClass{
    public:
        MyClass(){
            std::cout << "construct MyClass func" << std::endl;
        }

        ~MyClass(){
            std::cout << "deconstruct MyClass func" << std::endl;
        }
};

int main(int argc, char *argv[]){
    AutoPtr<MyClass> myclass(new MyClass());
}

细说智能指针_第2张图片
在上面main函数中创建了一个智能指针AutoPtr myclass,其在堆空间分配了一个MyClass对象交由智能指针管理,即myclass(new MyClass())。当main函数结束时,它会调用智能指针的析构函数,析构函数中执行了delete操作,最终将之前new出来的myclass对象释放掉了。

通过这个例子我们可以知道,有了智能指针我们就不用再担心内存泄漏了。对于C++开发同学来说像不像中了大奖一样高兴?不过上面的AutoPtr还称不上真正的智能指针,因为它只实现了智能指针最基本的一部分功能,我们还需要对它不断完善才行。


AutoPtr智能指针

上面实现的智能指针有什么问题呢?最大的问题就是它不能像真正的指针一样操作,比如说不能执行xxx->xxx()、*xxx等操作。下面我们就为AutoPtr重载这两个操作符。

//修改AutoPtr,增加 -> 和 * 操作符
class AutoPtr {
    ...
    T* operator -> (){
        return this->_ptr;
    }
    ...
    T& operator * (){
        return &(this->_ptr);
    }
    ...
};

//修改MyClass类,增加print方法
class MyClass {
    ...
    void print(){
        std::cout << "Hello world!" << std::endl;
    }
    ...
}

//增加测试例子
int main(int argc, char *argv[]){
    AutoPtr<MyClass> myclass(new MyClass());
    myclass->print();
    (*myclass).print();

    return 0;
}

细说智能指针_第3张图片

AutoPtr缺陷

虽然上面的AutoPtr实现看着很不错,不过它有非常致命的问题。当两个AutoPtr指针指向同一块堆空间时,在释放资源时会引起crash。咱们看一个例子:

//增加测试例子
int main(int argc, char *argv[]){
    AutoPtr<MyClass> myclass(new MyClass());
    AutoPtr<MyClass> newPtr = myclass;

    return 0;
}

当你在main函数中让两个AutoPtr指向同一块堆空间时就会引起crash。之所以会出现这个问题,是因为堆空间被释放了两次。上面程序的执行结果就可以推出这个结论:

construct MyClass func
set new object,0x7fdecfc028c0

delete object,0x7fdecfc028c0     //释放第二个对象
deconstruct MyClass func

delete object,0x7fdecfc028c0     //释放第一个对象
deconstruct MyClass func
malloc: *** error for object 0x7fdecfc028c0: pointer being freed was not allocated //0x7fdecfc028c0这个空间已经被释放过一次了

通过上面的运行结果我们可以知道,创建myclass智能指针时它指向了new MyClass所分配的空间。紧接着,程序使用默认=运算符将myclass中的全部内容赋值给newPtr。此时newPtr的_ptr成员会与myclass的_ptr成员指向同一块堆空间(由于使用了默认=运算符,所以过程没有显示出来)。

当main函数结束时,它会按次序依次调用newPtr的析构函数和myclass的析构函数,所以我们可以看到有两次”delete object,0x7fdecfc028c0”。在C++中,如果对同一地址释放多次就会引起crash,所以我们在显示结果的最后一行看到了”pointer being freed was not allocated” 这条信息表示的就是重复释放了。

因此我们必须对 AutoPtr 继续改进,防止出现重复释放的情况。如何才能防止重复释放呢?

我们可以想到的最简单的办法是当有多个智能指针指向同一块堆空间时,只能有一个智能指针拥有所有权。什么意思呢?就是这块堆空间的释放只能由其中的一个来完成。


允许共享,独占所有权

怎么才能让众多智能指针中的一个拥有所有权呢?简单的办法是在AutoPtr上加个owner就好了。我们将上面的代码修改如下:

class AutoPtr {
public:
    explicit AutoPtr(T *ptr = nullptr):_ptr(ptr), _owner(true){
    }

    AutoPtr(AutoPtr<T> & autoptr):_ptr(autoptr._ptr), _owner(false){
    }

    ~AutoPtr(){
        if(_owner && _ptr != nullptr){
            delete _ptr;
        }
    }

    AutoPtr& operator=(AutoPtr<T> & autoptr){
        if(this != &autoptr){
            if(_ptr){
                delete _ptr;
            }
            this->_ptr = autoptr._ptr;
            _owner = false;
        }

        return *this;
    }

    ...

private:
    T* _ptr;
    bool _owner;
};

经上面修改后,new MyClass分配的空间就有了具体的owner,所以再执行之前的测试程序就不会crash了。结果如下:
细说智能指针_第4张图片
通过上面的修改问题似乎已经得到了解决,但实际的情况是后创建的智能指针更应该是owner,所以我们再做下微调:

class AutoPtr {

    explicit AutoPtr(T *ptr = nullptr):_ptr(ptr), _owner(true){
    }

    AutoPtr(AutoPtr<T> & autoptr):_ptr(autoptr._ptr), _owner(true){
        autoptr._owner = false;
    }

    AutoPtr& operator=(AutoPtr<T> & autoptr){
        if(this != &autoptr){
            if(_ptr){
                delete _ptr;
            }
            this->_ptr = autoptr._ptr;
            _owner = true;
            autoptr._owner = false;
        }

        return *this;
    }
    ...

};

细说智能指针_第5张图片

通过上面最后三行的输出结果我们可以看出,释放空间的顺序发生了变化,说明owner已经变为最近创建的智能指针newPtr了。

调整后的AutoPtr还有没有问题呢?当然还有,我们再来看一下例子:

class AutoPtr {
    ...
    public:
        T* get(){
            return this->_ptr;
        }
    ...
};

int main(int argc, char *argv[])
{
        AutoPtr<int> oldPtr(new int(100));
        {
            AutoPtr<int> newPtr(oldPtr);
        }

        //这里出现了野指针
        *(oldPtr.get())= -100;
        std::cout << "the value is " << *(oldPtr.get()) << std::endl;
}

在上面的代码中,将newPtr放到一个花括号里,这样它就有了自己的栈空间。当跳出花括号后,newPtr就完成了它的使命,然后它会将持有的资源全部释放掉。由于newPtr从oldPtr获得了new int(100)这块堆空间的控制权,所以当newPtr生命周期结束后,堆空间也被回收了。

但在newPtr被释放掉之后,oldPtr却还能通过get方法访问原来的堆空间,它还能将-100写入了被释放的堆空间。这是非常可怕的事情,因为oldPtr通过get方法拿到的已经是野指针了。

因此,多智能指针共享堆空间并用owner控制最终资源释放的方法并不是特别好的智能指针方案。


shared_ptr

有时候我们还是需要多个智能指针管理同一块堆内存空间。之前在讲AutoPtr时我们已经介绍了多个智能指针管理同一块内存空间会引起很多问题,有没有更好的方式来解决这些问题呢?

其中引用计数法是个不错的解决方案,实现起来也比较简单。其基本原理是当有多个智能指针指对同一块堆空间进行管理时,每增加一个智能指针引用计数就增1,每减少一个智能指针引用计数就减少。当引用计数减为0时,就将管理的堆空间释放掉。

我们还是看一个具体例子吧,其实现是在unique_ptr的基础之上实现的,代码如下:

class ScopedPtr {

    public:
        ...
        ScopedPtr(T *ptr = nullptr): _ptr(ptr), _ref_count(new int(1)){
        }

        ScopedPtr(ScopedPtr<T> & scopedptr): _ptr(scopedptr._ptr), _ref_count(scopedptr._ref_count){
            ++*_ref_count);
        }

        ScopedPtr & operator=(ScopedPtr<T> & scopedptr){
            if(this != &scopedptr){
                _release();
                _ptr(scopedptr._ptr);
                _ref_conut(scopedptr._ptr);
                ++(*_ref_count);
            }

            return *this;
        }

        ~ScopedPtr(){
            _release();
        }

        int* getCount(){
            return *_ref_count; 
        }
    protected:
        void _release() {
            std::cout << "deconstruct...: count=" << ((*_ref_count) -1)  << std::endl;
            if(--(*_ref_count) == 0){
                delete _ptr;
                delete _ref_count;
            }
        }

    private:
        ...
        int *_ref_count;   //引用计数
};

int main(int argc, char *argv[]){
    ScopedPtr<int> myPtr(new int(100));
    ScopedPtr<int> pT2 = myPtr;
}

细说智能指针_第6张图片

你可能感兴趣的:(C++,c++)