C++相关概念和易错语法(31)(特殊类的设计、new和delete底层调用分析)

本文主要是介绍C++相关概念和易错语法(31)(特殊类的设计、new和delete底层调用分析),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

特殊类的设计

在实践过程中,我们难免会接触到一些需要实现特定功能的类。像之前提过的unique_ptr就是直接delete拷贝构造和赋值函数。下面会分享一些常见的特殊类的实现

1、防拷贝和防赋值

通过封死拷贝构造和赋值函数来保护对象里面内容不被复制。如果对象里面的内容是指针,对析构次数有严格要求的话(如unique_ptr)就通常采用这种处理方法。

注意拷贝构造和移动拷贝为一体,赋值重载和移动赋值为一体,两两为一组,若其中之一被delete掉了,另一个就算满足自动生成条件(析构、移动、拷贝赋值未手动生成)也没有办法自动生成。

注意封析构不会影响拷贝和赋值的自动生成

所以我们实现防拷贝时,只需要封死拷贝和赋值那一块,而移动拷贝和赋值就不需要我们过多担心了,它们也不会自动生成。因为我们是想要防止拷贝,所以直接delete比起私有化函数更干净利落。

2、只能在栈区创建对象(new和delete底层分析)

如果我们想要只允许在栈区创建对象的话,就要想办法封死堆区和静态区创建的方式。

我先说静态区,静态区无法被封死,因为它的创建、拷贝模式和栈区的几乎没有区别,你可以封死构造,自己写一个构造(栈区值返回,调用构造或移动),但是拦不住静态区也利用构造或移动来拷贝构造。

想要封死堆区创建的方式从操作来讲非常简单,因为我们知道new会去调用operator new,delete会去调用operator delete,所以我们在中间拦截就能实现这个禁用new和delete

但是对new和delete有一定了解的人马上就能找到漏洞,直接跳过中间的拦截,用malloc、calloc、realloc都可以实现在堆区开辟空间

malloc、calloc、realloc是无法拦截的,为什么?以及删除operator new和operator delete为什么会禁止掉new?我们需要深入new和delete的调用规则才能一探究竟。

我们在new和delete混用分析就说过,new主要分为以下阶段:new -> operator new -> malloc,delete阶段分为delete -> operator delete -> free,其中operator new和operator delete都是全局函数,但其实更准确的是在operator new里malloc,在operator delete里面free,operator new之后调用构造函数,而析构函数是在operator delete之前就调用了的。

对于大多数情况,operator new和operator delete都是在全局进行调用的,调用operator new之前就会计算好应该开辟空间的大小

如在这里num接收的就是应当malloc的字节大小num。

关键点来了,C++规定operator new和operator delete可以在类里面自己实现。虽然operator这个标志词让人一下就联想到了重载这个概念,但全局函数和类里面的成员函数首先在作用域上就不相同,其次operator new和operator delete对函数名、参数、返回值都有严格要求,并不能被定义为重载,一般我们可以理解为重定义或者替换。

当在类里面重定义这两个函数时,new这个类或者delete这个类实例化的对象时,当进行到调用operator new或调用operator delete这一步时,都会直接去调用类里面的替换的函数,而不会去调用全局的。

看看下面的代码会如何打印

class A
{
public:A(){cout << "A()" << endl;}~A(){cout << "~A()" << endl;}void* operator new(size_t num){cout << "void* operator new(size_t num)" << endl;return malloc(sizeof(A));}void operator delete(void* p){cout << "void operator delete(void* p)" << endl;free(p);}	};int main()
{A* a = new A;delete a;return 0;
}

结果是

这个结果也可以进一步验证我所说的new和delete的执行步骤,注意malloc(num)中num的具体含义是指开辟的空间大小以及重定义的函数的形式必须完全统一。

如果我们显式删除了operator new和operator delete,就算我们没有显式定义这两个函数,编译器也会解读为我们不希望new这个类的时候调用operator new这个函数,也不会去调用全局的函数,所以在开辟空间这里就卡死了。

这里可以理解为一层特殊处理,但也很符合我们的逻辑,如果你想要调用全局的operator new那就什么都不写,想自己实现就自己写,编译器也会调用(注意格式功能正确),不想自己写也不想调用全局的就直接delete掉这个函数。

还有人知道new[ ]和delete[ ],这两个也是从new和delete中衍生出来的

new[ ] -> operator new[ ] (malloc包含在内),delete[ ]阶段分为delete[ ] -> operator delete[ ](free包含在内)

我们一定要注意operator new[ ]和operator new,operator delete和operator delete[ ]完全独立,没有任何关系

operator new[ ]专门处理开辟数组的情况, 不会去调用operator new。注意size_t num依然意味着要开辟空间的大小,编译器会提前计算好,将真真实实需要开辟的字节数传过来作为num。

在混用分析那里我特地强调了operator new和operator new[ ]的区别,operator new[ ]调用前就会计算要开辟空间的大小(包括多开辟的),会多开辟空间用于存放数组元素个数的信息,返回的时候会将返回的地址二次处理,通过检测new[ ]的元素个数,记录并进行地址的错位返回。只有delete[ ]会在调用operator delete[ ]前进行矫正,将矫正的地址赋给p。

根据上面的规则,结合下面的代码,仔细体会并试图回答为什么不能用operator new[ ]替代new[ ]?为什么不能用operator delete[ ]替代delete[ ]?


class A
{
public:A(){cout << "A()" << endl;}~A(){cout << "~A()" << endl;}void* operator new(size_t num){cout << "void* operator new(size_t num) : num == " << num << endl;return malloc(num);}void operator delete(void* p){cout << "void operator delete(void* p)" << endl;free(p);}void* operator new[](size_t num){cout << "void* operator new[](size_t num) : num == " << num << endl;return malloc(num);}void operator delete[](void* p){cout << "void operator delete[](void* p)" << endl;free(p);}};int main()
{cout << "sizeof(A) == " << sizeof(A) << endl << endl;A* a1 = new A[1];delete[] a1;	cout << endl;A* a2 = new A;delete a2;return 0;
}

结果是

这个大小9是编译器自己的处理方式,不用纠结数字如何来的。

由于new和new[ ]、delete和delete[ ]调用的函数不一样,所以当我们删除operator new时,operator new[ ]并不会受到任何影响,依然遵循优先重定义函数其次全局函数的规则,所以我们如果要封死的话,要注意4个函数都delete,不要只写两个

我们只能尽最大努力,控制new、new[ ]、delete、delete[ ]的行为,但malloc、calloc、realloc不支持重定义这套规则,也没办法delete掉这些函数(显式delete的会被认为是成员函数,仍会调用全局的,编译器不会像operator new那样解释)。因此,从某种意义上说,只能在栈区定义的类难以实现,但我们可以在很多层面作出限制,毕竟使用C++一般都不会使用malloc这种C语言语法了,一定程度上起到了规范作用。

3、只能在堆区创建对象

(1)私有化构造函数

只有在堆区创建对象的话,我们要先私有化构造函数,只能以我们的规定的方式来定义函数,这也需要借助静态成员函数来实现。注意我们要分清什么使用该私有化函数,什么时候该delete函数。私有化函数是防止外部调用,但内部可以调用,delete就是完全不可调用。在这里只能私有化,即只能用规定方式创建对象,创建时内部调用构造。

这里我们需要仔细体会,静态成员函数属于整个类而不是某个对象,因此静态成员函数只需要我们指定类域即可,它再调用构造函数就能实现对象的初始化。而如果我们写的是非静态成员函数,那么这就陷入了先有鸡还是先有蛋的问题。非静态成员函数本来就需要先实例化出对象才能调用的,但这个函数的功能又是实例化出对象。

我们上面的实现有个漏洞,即拷贝和移动可以轻松绕过限制,我们在实现特殊类时一定不能忽略拷贝、赋值这两个函数可能带来的漏洞。

因此我们需要针对我们的需求delete拷贝构造或是私有化,对外提供接口

赋值其实在这里是没有必要封的,因为赋值的本质是进行值的覆盖,是对该空间的值的重写。当我们把构造、拷贝函数私有化了之后,我们就只能按照对外的接口来创建空间,显然赋值重载只是将掌管堆区空间的指针进行转移,并不会导致在其他区域开辟了新空间。

(2)私有化析构函数

这是一个很巧妙的办法,利用了非堆区对象出生命周期自动调用析构函数这个特征来禁止调用。

堆区对象的特点就是不主动释放就不析构,最后程序结束时不调用析构直接回收空间。

但上面这个操作明显导致了内存泄漏,因此当我们不用堆区空间,就利用接口来释放空间,这比栈区静态区灵活多了。

很多人这个时候回想:我可以先在栈区构造,用了之后显式析构,这样析构私有化就失效了啊,但事实真的如此吗?

这就陷入了编译时逻辑和运行时逻辑的漏洞,编译器在编译的时候可不会管Destroy()什么意思,只要在栈区实例化出对象,它就会去找析构函数,访问不了就报错,所以虽然运行时逻辑没有问题,但编译都报错了,运行时逻辑还有意义吗?

这里只是特殊类的一些例子,用到的知识已经综合化了,这也可以帮助我们加深语法的印象,拔高我们的思维。

这篇关于C++相关概念和易错语法(31)(特殊类的设计、new和delete底层调用分析)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1135161

相关文章

C++统计函数执行时间的最佳实践

《C++统计函数执行时间的最佳实践》在软件开发过程中,性能分析是优化程序的重要环节,了解函数的执行时间分布对于识别性能瓶颈至关重要,本文将分享一个C++函数执行时间统计工具,希望对大家有所帮助... 目录前言工具特性核心设计1. 数据结构设计2. 单例模式管理器3. RAII自动计时使用方法基本用法高级用法

redis-sentinel基础概念及部署流程

《redis-sentinel基础概念及部署流程》RedisSentinel是Redis的高可用解决方案,通过监控主从节点、自动故障转移、通知机制及配置提供,实现集群故障恢复与服务持续可用,核心组件包... 目录一. 引言二. 核心功能三. 核心组件四. 故障转移流程五. 服务部署六. sentinel部署

深入解析C++ 中std::map内存管理

《深入解析C++中std::map内存管理》文章详解C++std::map内存管理,指出clear()仅删除元素可能不释放底层内存,建议用swap()与空map交换以彻底释放,针对指针类型需手动de... 目录1️、基本清空std::map2️、使用 swap 彻底释放内存3️、map 中存储指针类型的对象

Go之errors.New和fmt.Errorf 的区别小结

《Go之errors.New和fmt.Errorf的区别小结》本文主要介绍了Go之errors.New和fmt.Errorf的区别,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考... 目录error的基本用法1. 获取错误信息2. 在条件判断中使用基本区别1.函数签名2.使用场景详细对

C++ STL-string类底层实现过程

《C++STL-string类底层实现过程》本文实现了一个简易的string类,涵盖动态数组存储、深拷贝机制、迭代器支持、容量调整、字符串修改、运算符重载等功能,模拟标准string核心特性,重点强... 目录实现框架一、默认成员函数1.默认构造函数2.构造函数3.拷贝构造函数(重点)4.赋值运算符重载函数

Java调用Python脚本实现HelloWorld的示例详解

《Java调用Python脚本实现HelloWorld的示例详解》作为程序员,我们经常会遇到需要在Java项目中调用Python脚本的场景,下面我们来看看如何从基础到进阶,一步步实现Java与Pyth... 目录一、环境准备二、基础调用:使用 Runtime.exec()2.1 实现步骤2.2 代码解析三、

C++ vector越界问题的完整解决方案

《C++vector越界问题的完整解决方案》在C++开发中,std::vector作为最常用的动态数组容器,其便捷性与性能优势使其成为处理可变长度数据的首选,然而,数组越界访问始终是威胁程序稳定性的... 目录引言一、vector越界的底层原理与危害1.1 越界访问的本质原因1.2 越界访问的实际危害二、基

Python如何调用另一个类的方法和属性

《Python如何调用另一个类的方法和属性》在Python面向对象编程中,类与类之间的交互是非常常见的场景,本文将详细介绍在Python中一个类如何调用另一个类的方法和属性,大家可以根据需要进行选择... 目录一、前言二、基本调用方式通过实例化调用通过类继承调用三、高级调用方式通过组合方式调用通过类方法/静

C#控制台程序同步调用WebApi实现方式

《C#控制台程序同步调用WebApi实现方式》控制台程序作为Job时,需同步调用WebApi以确保获取返回结果后执行后续操作,否则会引发TaskCanceledException异常,同步处理可避免异... 目录同步调用WebApi方法Cls001类里面的写法总结控制台程序一般当作Job使用,有时候需要控制

一文带你迅速搞懂路由器/交换机/光猫三者概念区别

《一文带你迅速搞懂路由器/交换机/光猫三者概念区别》讨论网络设备时,常提及路由器、交换机及光猫等词汇,日常生活、工作中,这些设备至关重要,居家上网、企业内部沟通乃至互联网冲浪皆无法脱离其影响力,本文将... 当谈论网络设备时,我们常常会听到路由器、交换机和光猫这几个名词。它们是构建现代网络基础设施的关键组成