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

目录
相关文章
|
2月前
|
jenkins Shell 测试技术
|
3月前
|
缓存 安全 编译器
C++面试周刊(3):面试不慌,这样回答指针与引用,青铜秒变王者
《C++面试冲刺周刊》第三期聚焦指针与引用的区别,从青铜到王者级别面试回答解析,助你21天系统备战,直击高频考点,提升实战能力,轻松应对大厂C++面试。
434 131
C++面试周刊(3):面试不慌,这样回答指针与引用,青铜秒变王者
|
1月前
|
人工智能 Java 物联网
Java与边缘AI:构建离线智能的物联网与移动应用
随着边缘计算和终端设备算力的飞速发展,AI推理正从云端向边缘端迁移。本文深入探讨如何在资源受限的边缘设备上使用Java构建离线智能应用,涵盖从模型优化、推理加速到资源管理的全流程。我们将完整展示在Android设备、嵌入式系统和IoT网关中部署轻量级AI模型的技术方案,为构建真正实时、隐私安全的边缘智能应用提供完整实践指南。
285 3
|
1月前
|
人工智能 监控 Java
Java与AI智能体:构建自主决策与工具调用的智能系统
随着AI智能体技术的快速发展,构建能够自主理解任务、制定计划并执行复杂操作的智能系统已成为新的技术前沿。本文深入探讨如何在Java生态中构建具备工具调用、记忆管理和自主决策能力的AI智能体系统。我们将完整展示从智能体架构设计、工具生态系统、记忆机制到多智能体协作的全流程,为Java开发者提供构建下一代自主智能系统的完整技术方案。
371 4
|
2月前
|
人工智能 Java API
Java与大模型集成实战:构建智能Java应用的新范式
随着大型语言模型(LLM)的API化,将其强大的自然语言处理能力集成到现有Java应用中已成为提升应用智能水平的关键路径。本文旨在为Java开发者提供一份实用的集成指南。我们将深入探讨如何使用Spring Boot 3框架,通过HTTP客户端与OpenAI GPT(或兼容API)进行高效、安全的交互。内容涵盖项目依赖配置、异步非阻塞的API调用、请求与响应的结构化处理、异常管理以及一些面向生产环境的最佳实践,并附带完整的代码示例,助您快速将AI能力融入Java生态。
463 12
|
2月前
|
安全 jenkins Java
Java、Python、C++支持jenkins和SonarQube(一)
Jenkins 是一个开源的 持续集成(CI)和持续交付(CD) 工具,用于自动化构建、测试和部署软件项目。它基于 Java 开发,支持跨平台运行,并拥有丰富的插件生态系统,可以灵活地扩展功能
231 5
|
2月前
|
jenkins Java Shell
Java、Python、C++支持jenkins和SonarQube(全集)
Jenkins 是一个开源的持续集成(CI)和持续交付(CD)工具,用于自动化构建、测试和部署软件项目。它基于 Java 开发,支持跨平台运行,并拥有丰富的插件生态系统,可以灵活地扩展功能
314 1
|
2月前
|
jenkins Java 持续交付
|
3月前
|
存储 C++
C++语言中指针变量int和取值操作ptr详细说明。
总结起来,在 C++ 中正确理解和运用 int 类型地址及其相关取值、设定等操纵至关重要且基础性强:定义 int 类型 pointer 需加星号;初始化 pointer 需配合 & 取址;读写 pointer 执向之处需配合 * 解引用操纵进行。
374 12
|
2月前
|
jenkins Java 测试技术
下一篇
oss云网关配置