From Java to C++ 第五篇之智能指针

931 阅读10分钟

前叙

From Java to C++ 第一篇
From Java to C++ 第二篇
From Java to C++ 第三篇
From Java to C++ 第四篇之内存管理篇

回顾

前面我们了解到RALL的基本用法,可以在方法执行完以后,主动将堆内存对象释放掉,从而简化了内存管理,解决内存泄漏的可能,这次我们学习下RALL,如果做一个完善的智能指针。

智能指针的本质

它的出现其实是为了解决由于动态内存分配而导致的一些内存问题,比如内存泄漏、生命周期管理、悬挂指针或空指针的问题。智能指针通过RALL管理对象的生命周期,提供少量异常类似普通指针的操作接口,在对象构造时候分配内存,在对象作用域之外释放掉内存,帮助我们管理动态内存。相信你跟我一样,看完这句话其实也不太明白,那大概率是对指针的概念不理解,我们来补充点指针的知识吧

到底指针是什么

要是上来就给指针下个定义,估计也没人看得懂,我们直接来看图。

先来看一个变量的内存模型

变量内存模型.png 上面一张图就表明了,一个变量p,它的内存模型就是上面那样,p就是0x11234564地址所对应的存储单元中的数据,这里一个框代表一个字节,由于int是四个字节,所以你看到的才是四个框组成的一个单元。这里需要说明的是c标准中并没规定哪个数据类型占多少字节,这个跟具体机器和编译器有关。你也可以看到,数值其实是按16进制存储的,0x14转10进制后就是20。 你是不是也发现了,其实每个字节都有一个自己的内存地址。对的,这就是跟我接下来讲的指针有关。请往下看。

简单指针

int p = 20;
int * a;
a = &p; //& 符号可以取得p的内存地址

我们来分析上面这段内存模型,你就明白指针是个什么了 指针变量模型.png 其实指针就是一个内存地址,而指针变量_ a 在内存中保存了指针指向的内存地址,那为什么我们不用_a来做下面的赋值操作呢?这就跟_号的用法有关,你可以简单这么理解,_ 在=号前面的时候代码指针变量,而在=号后面使用的时候,其实是在去指针对应的值。所以接着往下学习,仔细看指针变量a,它的四个字节合起来的值是 0x11234564,正好是变量p的内存地址,而指针a自己的内存地址是:0x11234568,所以你现在是不是明白了一个简单的指针是什么?它跟普通变量唯一的区别就是,它的值存储的是一个内存地址,如果没赋值的话,可能是0x00000000,也可能是其他随机数字。

空指针&野指针

我们再了解什么是空指针和野指针,所谓的空指针是指不指向任何东西的指针,需要注意的是,当我们定义一个指针变量时,如果没有赋值,那么它指针变量存储是一个随机值,如果这个随机值指向了内存中的代码区域,那么就很危险,所以这个时候一定不能写入操作,很有可能对实际的数据产生污染。建议对一个指针变量赋值为NULL。

int * p = NULL;

野指针是地址已经失效的指针,具体说就是当一个指针指向堆内存中的值时,如果这个堆内存被delete后,你还继续操作这个指针,那么就很危险了,所以不建议你这么操作。 好了言归正传,我们来继续研究智能指针。

为什么使用智能指针

在我们简单了解了内存管理和指针后,就很容易理解下面这三点

  • 智能指针能够帮助我们处理资源泄露问题
  • 它也能够帮我们处理空悬指针(野指针)的问题
  • 它还能够帮我们处理比较隐晦的由异常造成的资源泄露

常见的智能指针

智能指针在C11版本之后提供,包含在头文件中,shared_ptr、unique_ptr、weak_ptr。 其实还有auto_ptr,这个在C11中已经被抛弃,我们就不学习过时的技术了。 如果是讲unique_ptr,shared_ptr的用法,其实有大量的文章讲解,完全没必要继续看下去对吧,我们这次接着上篇的代码,来改造一下,实现一个通用的完整的智能指针。先来看下上次的代码


class TestRALL {
public:
    TestRALL() {
        std::cout << "TestRALL done" << std::endl;
    };

    ~TestRALL() {
        std::cout << "~TestRALL done" << std::endl;
    };

    void print() {
        std::cout << 1 << std::endl;
    }
};

TestRALL *createTest() {
    return new TestRALL();
}


class TRDelete {
public:
    explicit TRDelete(TestRALL *tr = nullptr) : tr_(tr) {}

    ~TRDelete() {
        delete tr_;
    }

    TestRALL *get() const { return tr_; }

private:
    TestRALL *tr_;
};

void print() {
    TRDelete trDelete(createTest());
    trDelete.get()->print();
}

int main() {
    print();
    return 0;
}

我们发现,TRDelete只适用于TestRALL这一个类,在Java中,我可以通过泛型来解决通用性的问题,那么C++中有吗?肯定有,哈哈,那我们来改造下,代码如下

#include <iostream>


class TestRALL {
public:
    TestRALL() {
        std::cout << "TestRALL done" << std::endl;
    };

    ~TestRALL() {
        std::cout << "~TestRALL done" << std::endl;
    };

    void print() {
        std::cout << 1 << std::endl;
    }
};

TestRALL *createTest() {
    return new TestRALL();
}

template <typename T>
class HeapDel {
public:
    explicit HeapDel(T *tr = nullptr) : tr_(tr) {}

    ~HeapDel() {
        delete tr_;
    }

    T *get() const { return tr_; }

private:
    T *tr_;
};

void print() {
    HeapDel<TestRALL> heapDel(createTest());
    heapDel.get()->print();
}

int main() {
    print();
    return 0;
}

为了通用性和编码规范,我们重新取名HeapDel,意思是释放堆内存,用template 来定义个模板,它的标准格式如下

template <class identifier> function_declaration;
template <typename identifier> function_declaration;

示例

template <typename T> void swap(T& t1, T& t2);

感觉跟Java的泛型很像,有机会我们再深入讨论。我们改造后的代码运行结果如下:

TestRALL done
1
~TestRALL done

同样达到了预期。但是这样就是完整的智能指针了吗,肯定不是哈。再来优化一下

void print() {
    HeapDel<TestRALL> heapDel(createTest());
//    heapDel.get()->print();
    heapDel->print();
}

如果我想直接用heapDel来调用TestRALL的print方法可以吗?肯定是可以的,要不然每次都get岂不是很累。

template<typename T>
class HeapDel {
public:
    explicit HeapDel(T *tr = nullptr) : tr_(tr) {}
    T *operator->() const { return tr_; } //加入该行代码即可
    ~HeapDel() {
        delete tr_;
    }

    T *get() const { return tr_; }

private:
    T *tr_;
};

这里用 operator 重载标识,重载 -> 返回 tr_ 指针变量。具体详细用法请自己查阅哈,目前我对他也不是很理解,后续再学习中了解它。上面我们是想了通过->来方位tr_的成员,那*tr_获取指针的值,该怎么做呢?

    T &operator*() const { return *tr_; }  

注意: // & 取内存地址,* 取指针的值,因为指针的值就是指向的地址,所以赋值给&修饰的变量,其实就是地址与地址的赋值,没什么问题。 再加上面一行,你就可以实现 *heapDel 其实就是 *tr_ 一样的作用。 接下来我们看一个问题,如果我这样操作

void print() {
    HeapDel<TestRALL> heapDel(createTest());
    heapDel->print();
    HeapDel<TestRALL> heapDel2(heapDel);
    heapDel2->print();
}

运行后是这样的

untitled1(3107,0x102f4ae00) malloc: *** error for object 0x7fb06ac05a00: pointer being freed was not allocated
untitled1(3107,0x102f4ae00) malloc: *** set a breakpoint in malloc_error_break to debug
TestRALL done
1
1
~TestRALL done
~TestRALL done

~TestRALL析构函数被执行两次,意味着你释放了两次,着肯定是不允许的,程序已经崩溃报错了,那我们如何避免这个问题呢?

template<typename T>
class HeapDel {
public:
    HeapDel(const HeapDel &) = delete;
    HeapDel &operator=(const HeapDel &) = delete;
    explicit HeapDel(T *tr = nullptr) : tr_(tr) {}
    T *operator->() const { return tr_; }
    T &operator*() const { return *tr_; }
    ~HeapDel() {
        delete tr_;
    }
    T *get() const { return tr_; }
private:
    T *tr_;
};

我们把HeapDel的构造函数给禁掉了,这个时候你再次调用 heapDel2(heapDel) 的时候已经不允许了,会提示如下:

Call to deleted constructor of 'HeapDel<TestRALL>'

但这样做真的合理吗?如果我真需要用一个新的智能指针来获取这个所有权,怎么办呢?

template<typename T>
class HeapDel {
public:
    HeapDel(HeapDel &other) {
        //给当前对象的指针赋值
        tr_ = other.release();
    }

    HeapDel &operator=(HeapDel &hd) {
        HeapDel(hd).swap(*this);
        return *this;
    }

    explicit HeapDel(T *tr = nullptr) : tr_(tr) {}

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

    T &operator*() const { return *tr_; }

    ~HeapDel() {
        delete tr_;
    }

    /**
     * 创建新的指针变量返回
     * 将老的指针变量赋值空指针
     * @return
     */
    T *release() {
        T *tr = tr_;
        tr_ = nullptr;
        return tr;
    }

    void swap(HeapDel &hd) {
        using std::swap;
        //用std 的swap,来交换tr_
        swap(tr_, hd.tr_);
    }

    T *get() const { return tr_; }

private:
    T *tr_;
};

新增了俩函数都是为了交换 tr_ 的所有权,再运行刚的代码,你会发现正常了

void print() {
    HeapDel<TestRALL> heapDel(createTest());
    heapDel->print();
    HeapDel<TestRALL> heapDel2(heapDel);
    heapDel2->print();
}
//运行结果如下:

TestRALL done
1
1
~TestRALL done

到目前为止你就实现了被 C++ 11 抛弃的版本 auto_ptr 它的核心逻辑。为什么会抛弃呢?你也看到了在swap中,其实是用HeapDel(hd).swap(*this); 这相当于构造了一个临时对象,再调用swap,如果再赋值过程中发生了异常,this对象可能会部分破坏,就不是一个完整的状态了。而且它最大的问题在于,如果用了新的HeapDel,那之前的HeapDel就不再拥有tr_。下面来看下unique_ptr 智能指针是如何解决上面问题的

template<typename T>
class HeapDel {
public:
    HeapDel(HeapDel &&other)  noexcept {
        //给当前对象的指针赋值
        tr_ = other.release();
    }

    HeapDel &operator=(HeapDel hd) {
        hd.swap(*this);
        return *this;
    }

    explicit HeapDel(T *tr = nullptr) : tr_(tr) {}

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

    T &operator*() const { return *tr_; }

    ~HeapDel() {
        delete tr_;
    }

    /**
     * 创建新的指针变量返回
     * 将老的指针变量赋值空指针
     * @return
     */
    T *release() {
        T *tr = tr_;
        tr_ = nullptr;
        return tr;
    }

    void swap(HeapDel &hd) {
        using std::swap;
        //用std 的swap,来交换tr_
        swap(tr_, hd.tr_);
    }

    T *get() const { return tr_; }

private:
    T *tr_;
};

我们将HeapDel重载的构造函数,参数other,改为了&&修饰,原本的实现其实是叫拷贝构造函数,在 C++ 11 标准之前(C++ 98/03 标准中),如果想用其它对象初始化一个同类的新对象,只能借助类中的复制(拷贝)构造函数,也就是HeapDel(HeapDel &other) 的写法。现在用两个&&,则变成了移动构造函数,你是不是又多了一个问号,它是干嘛用的呢? 它的来源其实就是由于拷贝构造函数在做对象初始化过程中,底层是进行了两次深拷贝,如果申请的堆空间较小也无伤大雅,可谁能保证呢?随着业务的增多,肯定会需要申请大的空间,从而影响拷贝的执行效率。 移动构造函数,指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。想要深入了解的,可以自查哦,嘿嘿。 再一个就是将重载的 = 改为了hd.swap(*this)的实现,并且参数hd去掉了&,我们知道这种方式叫值传递,hd的任何修改对实参无影响。下面来看下改造后如何用

void print() {
    HeapDel<TestRALL> heapDel(createTest());
    heapDel->print();
    HeapDel<TestRALL> heapDel2;
    heapDel2 = std::move(heapDel);
    heapDel2->print();
}

std::move() 函数就是强制调用HeapDel的移动构造函数,如果还有拷贝构造或者其他构造函数。 现在unique_ptr的实现,你已经知道了。还有更复杂的shared_ptr,如果想把它搞明白,估计需要不少的知识,我们后续有机会再讨论,后面继续学习基础知识。

总结

本期,对智能指针两种实现unique_ptr、auto_ptr,有了深刻的理解,也明白了指针到底是什么,收获很多,确实感觉到C++的复杂性,一个构造函数就有这么多的变数。想要学明白就要理解它背后的动机以及设计的规范,很多设计的背后其实在原理上还是有共通的点,这里分享一个简单且高效的学习过程:先知道是什么,且一定要弄明白为什么,然后才是怎么用。这次分享就到这里,感谢跟我一起学习。加油。