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

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

前叙


From Java to C++ 第一篇

From Java to C++ 第二篇

From Java to C++ 第三篇

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

回顾


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

智能指针的本质


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

到底指针是什么


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

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


04f62e8fd5824c0da8b70ca4a3814828_tplv-k3u1fbpfcp-zoom-in-crop-mark_4536_0_0_0.jpg

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

简单指针


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

我们来分析上面这段内存模型,你就明白指针是个什么了

42876bcb605c49069190eca42348b82f_tplv-k3u1fbpfcp-zoom-in-crop-mark_4536_0_0_0.jpg

其实指针就是一个内存地址,而指针变量_ 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++的复杂性,一个构造函数就有这么多的变数。想要学明白就要理解它背后的动机以及设计的规范,很多设计的背后其实在原理上还是有共通的点,这里分享一个简单且高效的学习过程:先知道是什么,且一定要弄明白为什么,然后才是怎么用。这次分享就到这里,感谢跟我一起学习。加油。

目录
相关文章
|
5天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
18 4
|
14天前
|
SQL 监控 Java
Java连接池技术的最新发展,包括高性能与低延迟、智能化管理与监控、扩展性与兼容性等方面
本文探讨了Java连接池技术的最新发展,包括高性能与低延迟、智能化管理与监控、扩展性与兼容性等方面。同时,结合最佳实践,介绍了如何选择合适的连接池库、合理配置参数、使用监控工具及优化数据库操作,以实现高效稳定的数据库访问。示例代码展示了如何使用HikariCP连接池。
9 2
WK
|
14天前
|
安全 Java 编译器
C++和Java哪个更好用
C++和Java各具优势,选择取决于项目需求、开发者偏好及目标平台特性。C++性能出色,适合游戏、实时系统等;Java平台独立性强,适合跨平台、安全敏感应用。C++提供硬件访问和灵活编程范式,Java有自动内存管理和丰富库支持。两者各有千秋,需根据具体需求选择。
WK
10 1
|
19天前
|
IDE Java 程序员
C++ 程序员的 Java 指南
一个 C++ 程序员自己总结的 Java 学习中应该注意的点。
20 5
|
21天前
|
存储 安全 编译器
在 C++中,引用和指针的区别
在C++中,引用和指针都是用于间接访问对象的工具,但它们有显著区别。引用是对象的别名,必须在定义时初始化且不可重新绑定;指针是一个变量,可以指向不同对象,也可为空。引用更安全,指针更灵活。
WK
|
13天前
|
开发框架 移动开发 Java
C++和Java哪个更适合开发移动应用
本文对比了C++和Java在移动应用开发中的优劣,从市场需求、学习难度、开发效率、跨平台性和应用领域等方面进行了详细分析。Java在Android开发中占据优势,而C++则适合对性能要求较高的场景。选择应根据具体需求和个人偏好综合考虑。
WK
27 0
|
1月前
|
人工智能 缓存 Java
深入解析Spring AI框架:在Java应用中实现智能化交互的关键
【10月更文挑战第12天】Spring AI 是 Spring 框架家族的新成员,旨在满足 Java 应用程序对人工智能集成的需求。它支持自然语言处理、图像识别等多种 AI 技术,并提供与云服务(如 OpenAI、Azure Cognitive Services)及本地模型的无缝集成。通过简单的配置和编码,开发者可轻松实现 AI 功能,同时应对模型切换、数据安全及性能优化等挑战。
WK
|
14天前
|
安全 Java 编译器
C++和Java哪个更适合开发web网站
在Web开发领域,C++和Java各具优势。C++以其高性能、低级控制和跨平台性著称,适用于需要高吞吐量和低延迟的场景,如实时交易系统和在线游戏服务器。Java则凭借其跨平台性、丰富的生态系统和强大的安全性,广泛应用于企业级Web开发,如企业管理系统和电子商务平台。选择时需根据项目需求和技术储备综合考虑。
WK
16 0
|
1月前
|
存储 C++
c++的指针完整教程
本文提供了一个全面的C++指针教程,包括指针的声明与初始化、访问指针指向的值、指针运算、指针与函数的关系、动态内存分配,以及不同类型指针(如一级指针、二级指针、整型指针、字符指针、数组指针、函数指针、成员指针、void指针)的介绍,还提到了不同位数机器上指针大小的差异。
35 1
|
1月前
|
存储 编译器 C语言
C++入门2——类与对象1(类的定义和this指针)
C++入门2——类与对象1(类的定义和this指针)
28 2