C++对象模型与内存管理深度解析:从构造、友元到拷贝优化

一、类和对象

1. 再探构造函数

之前我们实现构造函数 时,初始化成员变量主要在函数体内赋值构造函数初始化还有一种方式,初始化列表初始化列表的使用方式是以一个冒号开始,接着是以一个逗号分隔的数据成员列表,每个成员变量后面跟着一个放在括号中的初始值或表达式

每个成员变量在初始化列表中只能出现一次


成员变量只是在类里面进行了声明,并没有进行定义 。那么,是在哪里对成员变量进行定义的呢?定义类对象的时候,是开辟了空间的但是这是对成员变量整体进行了开辟空间,单个成员变量是在哪里定义的呢对成员变量的定义就是在初始化列表中

. 引用成员变量,const成员变量,没有默认构造的类类型变量,必须放在初始化列表中进行初始化否则会编译报错


. C++11支持 在成员变量声明的位置给缺省值这个缺省值主要是给没有显示在初始化列表初始化的成员使用的

. 尽量使用初始化列表初始化因为那些你不在初始化列表初始化的成员也会走初始化列表如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化如果你没有给缺省值,对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定对于没有显示在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数。如果没有默认构造会编译错误

. 初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表中出现的先后顺序无关建议声明顺序和初始化列表顺序保持一致

c 复制代码
class A
{
public:
	A(int a)
		:_a1(a)
		,_a2(_a1)
	{}
	void Print() {
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a2 = 2;
	int _a1 = 2;
};
int main()
{
	A aa(1);
	aa.Print();
}

这道题的关键就在于成员变量的初始化顺序是按照声明的顺序初始化的

所以,先用_a1初始化_a2,此时_a1还没有初始化,所以是随机值,_a2也就被初始化为了随机值,接着用 a 初始化_a1,_a1就被初始化为了1

2. 类型转换

C++支持内置类型隐式转换为类类型对象,需要有相关内置类型为参数的构造函数

若没有适当的构造函数,编译器就会报错


. 构造函数前面加 explicit 就不再支持隐式类型转换


虽然加了 explicit 就不再支持隐式类型转换 ,但是我们可以显示的类型转换

.`` 类类型的对象之间也可以隐式转换,需要相应的构造函数支持


3. static 成员

. 用 static 修饰的成员变量,称之为静态成员变量,静态成员变量一定要在类外初始化


. 静态成员变量为所有类对象所共享不属于某个具体的对象。不存在对象中,存放在静态区


. 用 static 修饰的成员函数,称之为静态成员函数,静态成员函数没有 this指针


. 静态成员函数中可以访问其它的静态成员,但是不能访问非静态的,因为没有 this 指针

. 非静态的成员函数,可以访问任意的静态成员变量和静态成员函数

. 突破类域就可以访问静态成员,可以通过类名::静态成员 或者 对象.静态成员来访问静态成员变量和静态成员函数

. 静态成员也是类的成员,受 public,protected,private访问限定符的限制


. 静态成员变量不能在声明位置给缺省值初始化因为缺省值是给构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表


4. 友元

. 友元提供了一种突破类访问限定符封装的方式,友元分为友元函数和友元类,在函数声明或类声明的前面加上friend,并且把友元声明放到一个类的里面

. 外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,它不是类的成员函数

. 友元函数可以在类定义的任何地方声明,不受类访问限定符限制

. 一个函数可以是多个类的友元函数

. 友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员

. 友元类的关系是单向的,不具有交换性比如A类是B类的友元,但B类不是A类的友元

. 友元类关系不能传递比如A是B的友元,B是C的友元,但A不是C的友元

友元会增加耦合度,破坏封装,不宜多用

5. 内部类

. 如果一个类定义在另一个类的内部,这个类叫做 内部类内部类是一个独立的类,跟定义在全局相比,它只是受外部类类域限制和访问限定符限制所以外部类定义的对象中不包含内部类

. 内部类默认是外部类的友元类

6. 匿名对象

用类型(实参)定义出来的对象就叫做匿名对象

匿名对象生命周期只在当前一行一般临时定义一个对象当前用一下即可,就可以定义匿名对象

匿名对象的使用场景

第一点方便调用成员函数

第二点可以给函数参数做缺省值。(这个模板时候在看)

7. 对象拷贝时的编译器优化

现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下 会尽可能减少一些传参和传返回值的过程中可以省略的拷贝

如何优化C++标准并没有规定,各个编译器会根据情况自行处理。

编译时采用 g++ test.cpp -fno-elide-constructors方式可以关闭编译器优化

. 类型转换


. 传参


. 传返回值

二、内存管理

用一张图来表示内存区域划分。

下面我们直接用一道题目来加深对于内存区域的划分。

c 复制代码
//全局变量存在于数据段
int globalVar = 1;
//静态变量存在于数据段(静态数据)
static int staticGlobalVar = 1;
int main()
{
	//静态变量存在于数据段(静态数据)
	static int staticVar = 1;
	//localVar属于main函数栈帧中的局部变量,存放于栈区
	int localVar = 1;
	//num1数组在栈上开辟空间,属于栈区
	int num1[10] = { 1, 2, 3, 4 };
	//char2数组也是属于栈区,"abcd"是常量字符串,存放于代码段
	//本质是把代码段的内容拷贝到了数组空间里,所以,访问数组空间里的内容就是在栈区上访问的
	char char2[] = "abcd";
	//pChar3是一个字符指针,指向代码段的地址,所以解引用访问的就是代码段的内容
	const char* pChar3 = "abcd";
	//ptr1是一个指针,局部变量,属于栈区,但是该指针指向的空间是堆区的,指针存储的是堆区的地址
	//通过解引用就可以找到堆区
	int* ptr1 = (int*)malloc(sizeof(int) * 4);
	int* ptr2 = (int*)calloc(4, sizeof(int));
	int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4);
	free(ptr1);
	free(ptr3);
}


1. new/delete操作

new/delete操作符是C++用来进行动态内存管理的

new和delete操作内置类型

申请和释放单个元素的空间,使用new和delete操作符,申请和释放连续的空间,使用new[]和delete[],需要匹配起来使用

new和delete操作自定义类型

看到这里,大家应该可以看到C内存管理与C++内存管理之间的区别了吧。

new和delete操作符对于 自定义类型来说,除了会开辟空间 还会调用构造函数和析构函数

2. operator new 与 operator delete函数

new 和 delete 是用户进行动态内存申请和释放的操作符,operator new 和 operator delete是系统提供的全局函数,new在底层调用operator new全局函数来申请空间,delete在底层通过调用 operator delete全局函数来释放空间

接下来就看看operator newoperator delete函数是如何实现的。

c 复制代码
/*operator new:该函数实际通过malloc来申请空间,当
malloc申请空间成功时直接返回;申请空间失败,尝试执行空间不
足应对措施,如果该应对措施用户设置了,则继续申请,否则抛异
常。*/
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{
// try to allocate size bytes
void *p;
while ((p = malloc(size)) == 0)
    if (_callnewh(size) == 0)
    {
        // report no memory
        // 如果申请内存失败了,这里会抛出bad_alloc 类型异常
        static const std::bad_alloc nomem;
        _RAISE(nomem);
    }
    return (p);
}

可以看到,operator new函数是对于malloc函数的封装申请空间失败会抛出异常,而不是向C一样返回空指针

c 复制代码
/*operator delete: 该函数最终是通过free来释放空间的*/
void operator delete(void *pUserData)
{
    _CrtMemBlockHeader * pHead;
    RTCCALLBACK(_RTC_Free_hook, (pUserData, 0));
    if (pUserData == NULL)
        return;
    _mlock(_HEAP_LOCK);  /* block other threads */
    __TRY
        /* get a pointer to memory block header */
        pHead = pHdr(pUserData);
         /* verify block type */
        _ASSERTE(_BLOCK_TYPE_IS_VALID(pHead->nBlockUse));
        _free_dbg( pUserData, pHead->nBlockUse );
    __FINALLY
        _munlock(_HEAP_LOCK);  /* release other threads */
    __END_TRY_FINALLY
    return;
}

operator delete函数最终是调用了_free_dbg释放空间的,其实就是free函数

c 复制代码
/*free的实现*/
#define free(p) _free_dbg(p, _NORMAL_BLOCK)

free函数就是_free_dbg定义的一个宏

3. new 和 delete的实现原理

内置类型

如果申请的是内置类型的空间,new 和 malloc,delete和free基本类似不同的地方是:new、delete申请和释放的是单个元素的空间,new[]和delete[]申请和释放的是连续的空间,而且new在申请空间失败时会抛异常,malloc会返回NULL

自定义类型

. new的原理

1.调用operator new函数申请空间

2.在申请的空间上执行构造函数,完成对象的构造

. delete的原理

1.在空间上执行析构函数,完成对象中资源的清理工作

2.调用operator delete函数释放对象的空间

. new T[N]的原理

1.调用operator new[]函数,在operator new[]中实际调用operator new函数完成N个对象空间的申请

2.在申请的空间上执行N次构造函数

. delete[]的原理

1.在释放的对象空间上执行N次析构函数,完成N个对象中资源的清理

2.调用operator delete[]释放空间,实际在operator delete[]中调用operator delete来释放空间

前面说过,new、delete操作符使用的时候需要匹配使用,如果不匹配使用的话,会造成内存泄漏的问题。下面就来看看吧。

对于内置类型来说,free和delete没有明显的区别,delete只是对free进行了封装,因为内置类型不需要调用析构函数

定义单个自定义类型的对象,交叉释放空间,也是没有问题的




可以看到,这两种情况都会导致程序崩溃,那么这是为什么呢

对于自定义类型数组来说(一次性开辟出多个自定义类型对象)编译器是会多开出4个字节的空间的,这是因为new开辟空间的时候,是知道开辟了几个对象的,但是析构函数不清楚啊!所以多开出的这4个字节的空间就是用来存储开辟对象的个数的

我们可以验证一下。



这样delete[]析构时就会根据前面多开的4个字节空间就知道应该析构的次数了

那为什么创建一个自定义类型的对象,交叉释放空间就没有问题呢

这是因为,定义一个自定义类型的对象,编译器不会多开4个字节的空间,所以用户拿到的地址就是该对象空间的起始地址

申请和释放空间一定要匹配操作,否则可能会导致内存泄漏

4. 定位new表达式(placement-new)

什么是定位new呢

定位new表达式是在已分配的原始内存空间中调用构造函数初始化一个对象

使用格式new(place_address)type 或者new(place_address)type(initializer_list)

place_address必须是一个指针,initializer_list是类型的初始化列表

一般情况下,我们是不会使用定位new的。但是在一些特殊的场景下,需要使用定位new

定位new表达式在实际中一般是配合内存池使用,因为内存池分配出的内存没有初始化所以如果是自定义类型的对象需要使用new的定义表达式进行显示调用构造函数进行初始化

5. malloc/free和new/delete的区别

malloc/free和new/delete的共同点是都是从堆上申请空间不同的地方是

1.malloc/free是函数,new/delete是操作符

2.malloc申请的空间不会初始化,new申请的空间可以初始化

3.malloc申请空间时,需要手动计算空间大小并传递,new只需在其后跟上空间的类型即可,如果是多个对象,[]中指定对象的个数即可

4.malloc返回值的类型是void*,在使用时必须强转,new不需要,因为new后面跟的是空间的类型

5.malloc申请空间失败时,返回NULL,因此使用时必须判空,new不需要,但是new需要捕获异常

6.申请自定义类型对象时,malloc/free只会开辟/释放空间,而new在申请空间后会调用构造函数完成对象的初始化,delete在释放空间前会调用析构函数完成空间中资源的清理释放

今天的文章分享到此结束,觉得不错的小伙伴给个一键三连吧。

相关推荐
Zhu_S W2 小时前
Java图论基础:有向图与无向图详解
开发语言·php
@PHARAOH2 小时前
WHAT - SWC Rust-based platform for the Web
开发语言·前端·rust
遥望九龙湖2 小时前
在一个单独的类或者模块中调用动态库
开发语言·c++
宫瑾2 小时前
VSCode使用C/C++ extensions开发STM32,添加头文件路径
c语言·c++·vscode
froginwe112 小时前
JavaScript 类型转换
开发语言
王老师青少年编程2 小时前
csp信奥赛C++之摩尔投票算法详解
数据结构·c++·算法·题解·csp·信奥赛·摩尔投票算法
Drifter_yh2 小时前
「JVM」 并发编程基石:Java 内存模型(JMM)与 Synchronized 锁升级原理
java·开发语言·jvm
码界筑梦坊2 小时前
220-基于Python的诺贝尔奖数据可视化分析系统
开发语言·python·信息可视化·数据分析·毕业设计·fastapi