C++ 入门篇 (七)
文章目录
C++ 入门篇 (七)
1.对象拷贝时编译器的优化
编译器在进行编译的时候,会对一下需要拷贝的情况进行优化,像我们之前提到了构造+拷贝构造优化成直接构造。当然,这个优化是根据编译器的情况来决定的,一些老的编译器可能就没有,而一些新的编译器优化力度更大一些,或者说更激进一些,可能会存在跨行优化。在release版本下,优化力度会更大。
优化主要是在拷贝的时候发生,比如传值传参,或者传值返回。之前我们讲到的是隐式类型转换的时候,“1” 先去构造一个临时对象,然后再拷贝构造到初始化的对象里面,编译器会直接优化成用 1 去构造。
我们先讲传值传参,如果是内置类型的话,就不会那么麻烦,直接就拷贝了(类型转换,传值调用,传值返回都是)。类类型的对象才有优化拷贝这一说。
class A { public: A(int a = 0,int b = 2) : _a(a) { cout << A (int a) << endl; } A(const A& aa) : _a1(aa._a) { cout << A(const A& aa) << endl; } ~A() { cout << ~A << endl; } A& operator=(const A& a) { _a = a._a; cout << operator= << endl; return *this; } void Print() { cout << _a << endl; } private : int _a = 1; }; 我们定义好了这样一个类以后,我们来看这个例子
void func(A a1) {} int main () { func(1); func(A(1)); return 0; } 我们来看这两个例子,首先 1 先隐式类型转换,调一次构造,构造一个临时对象,然后临时对象拷贝构造给形参。和之前一样,vs2019 会直接优化成一次构造。
来看第二个,是一个匿名对象,匿名对象先调一次构造初始化,然后再拷贝构造给形参。也是先构造再拷贝构造,编译器同样会优化。
甚至,像刚才提到了激进的情况,
A aa1; func(aa1); 编译器会跨行优化,把这个构造和拷贝构造也优化掉。
我们再来看传值返回的情况,
A func2 () { A aa; return aa; } 我们之前提到过,传值返回也会先生成临时对象,然后再看情况要不要拷贝构造回来。
传值返回因为有临时对象,所以我们可以这样写,
int main () { A aa; func2().Print(); } 如果这样写的话,就是用了临时对象。临时对象和匿名类的生命周期都是只在这一行里面。
这样的写其实就是,先构造一个aa,然后aa在拷贝构造一个临时对象,编译器同样会优化成一个直接构造一个临时对象,会把aa省略掉。这是怎么看出来的呢?我们只要看一下析构函数在什么时候调用就行,这里一个很好的标杆。如果生成了aa的话,那么在func2结束的时候会析构一下。但是,从运行结果是来看并没有析构。所以跳过了aa。
我们还可以这样,
int main () { A a1 = func2(); } 如果这样写的话,在不开优化的情况下,会先构造一个 aa, 然后再拷贝构造一个临时变量,再用临时变量拷贝构造 a1 。在编译器优化的时候,连续构造会被优化为直接构造,直接用aa来拷贝构造a1。
在 vs2019 这个编译器里面的优化开的是很大的。
举个例子,我们来给A这个类来一个++的重载,
Date& operator++() { ++ _a; return *this; } 然后把func函数换一个写法,
A func2 () { A aa; ++aa; return aa; } 这样编译器会不会优化呢?也会优化的。会自己分析后面的语句,然后直接用aa拷贝构造。
当然,有的情况是不会进行优化的。比如:
int main() { A ret; ret = func2(); return 0; } 这里就不是拷贝构造了,是赋值重载,vs2019 debug 版本是不会优化的。
但是release版本还是会优化的,相当于是直接构造临时对象,然后直接赋值重载。可以理解为构造了aa把aa直接当临时对象。
编译器为什么会做优化,因为不是所有的情况都能用传引用,必须有地方要传值,所以在语义方面不能用引用优化了,那就只能在传值的地方优化。
还要注意的时,怎么优化没有标准,编译器自己来,能合并尽可能合并,前提要保持正确性。
2.C++里的内存管理
1.复习
C++里面的内存划分其实和C语言里面的是一样的,都分为栈,堆,数据段(静态区),代码段。
数据段时用来储存全局变量和静态变量,代码段是储存指令和常量值。
我们用下面这个题来复习一下:
int globalVar = 1; static int staticGlobalVar = 1; void Test() { static int staticVar = 1; int localVar = 1; int num1[10] = { 1, 2, 3, 4 }; char char2[] = "abcd"; const char* pChar3 = "abcd"; 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. 选择题: 选项: A.栈 B.堆 C.数据段(静态区) D.代码段(常量区) globalVar在哪里?____ staticGlobalVar在哪里?____ staticVar在哪里?____ localVar在哪里?____ num1 在哪里?____ char2在哪里?____ *char2在哪里?___ pChar3在哪里?____ *pChar3在哪里?____ ptr1在哪里?____ *ptr1在哪里?____ 这里都比较简单,我说几个容易记错的。 char2 在栈,是数组首元素的地址。数组里面是"abcd"。*char2 ,也在栈上。pChar3 在栈上。*pChar3 在代码段里面。
这是为什么呢?首先"abcd"这个字符串的字面量存在代码段里面,数组char2创建的时候,是把这个字符串拷贝到栈里面,然后数组名是首元素的地址。而pChar3是指向这个代码段里面字符串的指针,所以,它解引用就是访问代码段里面的字符串。
C语言里面的动态内存管理,用到的是malloc,calloc,realloc三个函数。要注意它们的不同点。这里要重点提一下realloc,realloc是扩容,可以原地扩,也可以异地扩,传空指针的话行为类似malloc。
还有一件事,free的时候不能从中间的部分开始free,要从头开始free。
2.C++里面的动态内存管理
C++里面的动态内存管理用的是 new 和 delete 。对于内置类型来说,它们的作用和C语言里面的开空间和释放差不多,区别就是语法的使用和可以随意初始化。
new的使用是这样,申请一个整形的空间,
int* pa = new int; 不需要类型转换,不需要自己算字节大小。
int* pa = new int(10); 这样的话就是把这个整形初始化为10。
delete pa; 这样就释放空间了。
如果我要开一个数组怎么办,也很简单,
int* pa = new int[10] = {0,1,2}; delete[] pa; 这样就好了,顺带着初始化一下。这里要注意,上面的例子里面是不完全初始化,十个整形剩下的都初始化成了0。但是如果不去初始化,来不完全初始化也不写,就直接结束的话,是不会初始化的为0的,也就是随机值。
这里注意一下,new 和 delete 是关键字,而不是函数。编译的时候遇到关键字,会转化成对应的一系列汇编指令。那么C++里面的内存管理和C语言里面的有什么不同呢?
当然是在处理类对象的时候。malloc 不会调用构造函数,free 也不会调用析构函数。但是new和delete会调用。这就是区别。
下面我们先来学语法,再来看底层。
如果我们要申请一个类类型对象的空间,我们这样写,
class A { public: A(int a = 0) : _a(a) { cout << A (int a) << endl; } A(const A& aa) : _a1(aa._a) { cout << A(const A& aa) << endl; } ~A() { cout << ~A << endl; } A& operator=(const A& a) { _a = a._a; cout << operator= << endl; return *this; } void Print() { cout << _a << endl; } private : int _a = 1; int _b = 1; }; 我们以这个A类为例,
A* pa = new A; delete pa; 这样是申请了一个A类类型的空间,并且调用这个A的默认构造函数。当然,我们也可以手动给构造函数传参
A* pa = new A(1); 我们还可以申请自定义类型的数组,
A* pa = new A[10]; delete[] pa; 对于这个数组来说,每一个自定义变量都要调用一次默认构造函数,释放之前都要调一次析构函数。
如果我们想去自己初始化,
A aa1; A aa2; A aa3; A* pa = new A[3] = { aa1, aa2 , aa3}; //这样就相当于是定义的时候调拷贝构造了 //按照这样思路,我们也可以使用匿名对象 A* pa2 = new A[3] = { A(1), A(2), A(3)}; //在C++11里面,或者对象C++98里面只有一个参数的构造函数,我们还可以通过类型转换的方式写 A* pa3 = new A[3] = { {1,2} , {3, 4}, {5 , 6}}; 像这样三种定义初始化的方式都是可以的。
如果申请失败了怎么办?这里new返回的就不再是空指针了。因为C++是面向对象的,面向对象的语言里面要用到 throw , try /catch 的方式。这个后面会细讲,这里简单提一下。(抛异常)
我们是这样写的,
try { char* ch = new char[1024*1024*1024] ; } catch(const exception& e) { cout << e.what << endl; } 抛异常,抛出异常出来我们要捕获,捕获一个叫exception的东西,这是库里面的一个类,然后 .what 来访问这个异常。这里的话报的异常是 bad alloction。就是申请失败。当然,我们也可以吧申请空间放一个函数里面
void func() { char* ch = new char[1024*1024*1024] ; } ……………… try { func(); } catch(const exception& e) { cout << e.what << endl; } 其实一般情况下都不会有申请失败的问题。一兆就是一百万字节,1G 就是十亿字节。当然我们这里申请空间都是虚拟内存。
在三十二位环境下,虚拟内存的大小是4G左右,内核占一个G,堆占了不到2G,其余的区域分剩下的空间,所以堆这个地方是非常大的,(linux里面的栈只有8M)。
如果是六十四位的环境下,虚拟内存的大小就是一百六十多亿G,当然这么大肯定是用不了的,堆的话占21G左右。
那么这里的虚拟内存是什么意思,计算机组成里面有地址总线,可以产生地址,一个内存单元一个字节,就是虚拟地址。所以,三十二位环境下,有2^32个地址,那虚拟内存就是这么大,四十九亿字节。六十四位环境下就是一百六十多亿G。
但是我们的电脑硬件上的内存肯定没有这么大,是因为虚拟内存要分块经过映射来对应实际的内存。具体的映射规则到操作系统讲。
所以,我们申请的空间基本是不会出问题的。
3.new 和 delete 的底层
operator new 和 operator delete
之前提到过 new 既开辟的空间,又调用了构造函数。因为 new 是一个关键词,到这里就完成这两个指令。
其中开空间就是 operator new 干的。operator new 这个函数的底层其实就是malloc,用这个函数再套一层就是为了上面提到的抛异常。
operator delete 的里面其实就是free。
当我们开多个数据和释放多个数据的时候,用的是operator new [] , operator delete []。这两个和上面两个区别就是,给它们传了大小,指定一次性开多少次空间,和调用多少次构造函数,或者析构函数。
如果 new 和 delete 的底层是 malloc 和 free 的话,那么我们用的时候可不可以混搭。
答案是最好不要,有的时候会有问题有的时候没问题,既然可以搭配使用不会错,那为什么要混搭呢?
如果混搭的话什么情况下会出错:
class A { public: A(int a = 0) : _a(a) { cout << A (int a) << endl; } A(const A& aa) : _a1(aa._a) { cout << A(const A& aa) << endl; } ~A() { cout << ~A << endl; } A& operator=(const A& a) { _a = a._a; cout << operator= << endl; return *this; } void Print() { cout << _a << endl; } private : int _a = 1; int _b = 1; }; 还是定义这个类,
int main () { A* pA = new A[3]; delete pA; int* pb = new int[3]; delete pb; } 这样使用的话就是pA会报错,pb没问题。那么是为什么呢?我们之前提到过,free 空间要从头开始,不能从半截拉快的地方开始。
这里的new A[3] 看着是开了三个A的空间,然后返回一个指针指向这三个A的空间的开头,其实在这个指针之前,还开了一个整形,也就是四个字节,来储存大小。储存这个大小是为了调用多少次析构函数用的。所以free 这里的pA其实是从中间开始free的所以会报错。但是,对于内置类型来说,不需要考虑调用析构函数的事,所以没问题。
定位new (placement)
这里还有一个点,就是定位new,这个是什么意思呢?我们之前提到过new这个关键词的指令就是调用一个operate new的一个全局函数。因为这个函数是全局的,所以我们也可以直接调用这个函数,
A* pA = (A*)operator new(sizof(A)); 这样单独使用这个的话是不会调用构造函数的,也就不会初始化,但是构造函数又不可以显示调用,那么我们怎么办呢?
这个时候就用到了我们的定位new,
new(pA)A(10); 这就是定位new的写法,先确定指针再确定要调用构造函数。
析构函数是可以在直接调用的,所以就可以,
pA->~A(); operator pA; 那么这个时候就有人要问了,这样不是多次一举吗?其实还是有用的,new 和 delete 可以解决%99的问题,这个地方就是来解决%1的。
这里我们可以提前了解一下池的概念,池就是把一些东西分割出来一部分,单独给某个东西使用。举个例子,我们可以有内存池,线程池,连接池。我们以我们最熟悉的内存池为例。如果我要申请空间的话,我是向内存里面的堆去申请,如果我的要求次数比较多,高频地去申请空间,操作系统在这个地方处理起来就可能会处理不过来,因为计算机里面很多进程共用一个堆,在申请的时候也会有一些检查机制,而且多个进程过来了也可能排队,那么我就可以单独从堆里拉一块空间出来,这一块空间单独给我使用,我申请空间就先从这里申请,这样会更加的高效。而且,不用了我就释放掉,再还给这块空间还能循环利用。这块空间就是内存池。要注意,这块内存池里面的空间本质还是堆里的。如果我要申请一块超级大的空间,这个内存池没这么大,还是要去堆里面申请的。
ew(pA)A(10);
这就是定位new的写法,先确定指针再确定要调用构造函数。 析构函数是可以在直接调用的,所以就可以, ```c++ pA->~A(); operator pA; 那么这个时候就有人要问了,这样不是多次一举吗?其实还是有用的,new 和 delete 可以解决%99的问题,这个地方就是来解决%1的。
这里我们可以提前了解一下池的概念,池就是把一些东西分割出来一部分,单独给某个东西使用。举个例子,我们可以有内存池,线程池,连接池。我们以我们最熟悉的内存池为例。如果我要申请空间的话,我是向内存里面的堆去申请,如果我的要求次数比较多,高频地去申请空间,操作系统在这个地方处理起来就可能会处理不过来,因为计算机里面很多进程共用一个堆,在申请的时候也会有一些检查机制,而且多个进程过来了也可能排队,那么我就可以单独从堆里拉一块空间出来,这一块空间单独给我使用,我申请空间就先从这里申请,这样会更加的高效。而且,不用了我就释放掉,再还给这块空间还能循环利用。这块空间就是内存池。要注意,这块内存池里面的空间本质还是堆里的。如果我要申请一块超级大的空间,这个内存池没这么大,还是要去堆里面申请的。
C++里面提供了这个new,我们可以从堆上new一个自定义类型的空间,然后初始化,但是我们在使用这个内存池的时候,只只开空间不给调用构造的,调了一个pool.Alloc(sizeof(Type)),所以这个定位new就排上用场了。