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()什么意思,只要在栈区实例化出对象,它就会去找析构函数,访问不了就报错,所以虽然运行时逻辑没有问题,但编译都报错了,运行时逻辑还有意义吗?

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

  • 11
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值