目录
1.构造函数
1.1初始化列表
1.2 隐式类型转换
2.静态成员
2.1 静态成员变量
2.2静态成员函数
3.友元
3.1 友元函数
3.2 友元类
4.内部类
5.匿名对象
6.拷贝对象时的一些编译器优化
1.构造函数
1.1初始化列表
我们在将构造函数的时候讲过构造函数是对一个对象整体的初始化,但是我们可以看到,在进入构造函数之前,我们的成员变量其实是已经初始化了的。在构造函数内部的代码执行之前,成员变量就已经被初始化了,这说明构造函数体内的代码只是再对这些成员变量赋初值而已,而不是初始化这些成员变量,变量的初始化只有一次,就是在定义这个变量的时候。
那么成员变量的初始化是在什么时候完成的呢?
我们可以看到,在调用构造函数之前我们都还没有完成成员变量的初始化,而已进入构造函数,在执行构造函数的指令之前,成员变量就已经完成初始化了,这是什么原因呢?
一个对象的初始化分为两层,第一层是初始化列表,然后才是构造函数体内的赋值。
初始化列表就是完成成员变量初始化的工作的。
初始化列表的格式:
在构造函数体前面,以一个冒号开始,接着是一个以逗号分隔的成员列表,每一个成员变量后面跟一个放在括号中的初始值或者表达式。
Date(int year = 1, int month = 1, int day = 1):_year(2),_month(2),_day(2){_year = year;_month = month;_day = day;}
这就是一个日期类的初始化列表。
初始化列表的注意:
1.一个变量只能在初始化列表中出现一次。因为一个变量初始化只有一次
2.我们不一定要把所有的成员变量都写在初始化列表中。
3.所有的成员都会走初始化列表,如果我们自己写在初始化列表中就是显式,我们不写的话编译器也会隐式的写到初始化列表中进行初始化。
4.对于隐式的初始化列表的成员变量,如果是内置类型就初始化为随机值,如果是自定义类型就会去调用他的默认构造进行初始化。
首先要理解的一点就是,类中的所有成员都会走初始化列表,不是显式就是隐式,所有成员的变量的初始化都是在初始化列表中完成的。
这很容易理解,就拿我们上面的日期类来说,我们并没有写初始化列表,但是在进入构造函数体之前所有成员变量都已经初始化了。
对于自定义类型的成员变量,我们如果不显式写在初始化列表中对其初始化的话,就会调用它的默认构造进行初始化。
有了初始化列表,我们就能对构造函数进行优化了,就是能够在初始化列表中初始化的变量尽量都用初始化列表。因为就算我们不写在初始化列表中,他们也都会隐式在初始化列表初始化,而这样一来,相对于比直接在初始化列表定义还多了一次赋值操作。
在上面的日期类中我们也可以看出,构造函数的缺省参数是在函数体内起作用而不是在初始化列表中起作用
我们可以拿Stack 和MyQueue类来看一下是不是这样的。
我们能看到,就算我们自己实现的MyQueue构造函数没有去对两个栈类型成员初始化,他在进入到构造函数体之前也已经去调用了栈的默认构造来完成这两个栈类型成员的初始化。
那么了解到这里了,我们是不是能对MyQueue的构造函数实现一个优化呢?我们在创建对象时可以直接传参数来指定两个栈类型成员的容量,这就可以用到我们的初始化队列。 因为我们上面的栈的默认构造的参数给了缺省值是4,如果我们想要一个 MyQueue 对象,他的两个栈的初始容量就是6,我们就可以这么来写他的构造函数
MyQueue(int size = 4):pushST(size),popST(size){_size = size;}
我们联想前面讲过的成员声明的时候的缺省值,这时候我们就可以观察一下这个缺省值到底是在构造函数体内起作用还是在初始化列表起作用。
以我们的日期类来举例。假如我们这样定义日期类
class Date
{
public:Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}private:int _year=10;int _month=10;int _day=10;
};
我们可以观察一下函数参数的缺省值和成员声明的缺省值分别是在那里起作用的。
首先我们可以看到的是,当我们的程序进行到断点的时候,在按一下 f10 就跳到了函数声明中,这时候我们就能得出结论:成员声明的缺省值就是在初始化列表中起作用的。
接着调试走完构造函数
构造函数的缺省参数是在构造函数体内起作用的。
这里我们该将成员变量声明的缺省理解为显式成员列表还是隐式呢?
验证的方法也很简单,就是我们在此基础上在写显式的初始化列表,如果声明的缺省是显式的,那么这时候编译器就会报错,如果缺省是隐式的,那么编译器就会以显式的初始化列表为准,而不生成这些成员的隐式初始化列表。
class Date
{
public:Date(int year = 1, int month = 1, int day = 1): _year(20),_month(20),_day(20){_year = year;_month = month;_day = day;}private:int _year=10;int _month=10;int _day=10;
};
这里编译器执行的是我们显式写出来的初始化列表,这就说明了成员声明的缺省值是在我们没有显式的写在初始化列表时才会去使用的,或者 显式初始化列表的优先级大于声明缺省,大于随机值。当然,这是对于内置类型来说的,自定义类型声明时该怎么给出缺省呢?其实是一样的,简单的我们可以直接用匿名对象来给缺省,比如我们的Stack类
private:Stack pushST=Stack(10);Stack popST=Stack(10);int _size;
既然C++有初始化列表,那么肯定是尤其需求的,需求在哪里呢?或者说哪些类型的成员变量是必须初始化给初值的呢?
第一个必须在初始化列表中定义的就是const 修饰的成员变量
当我们类中定义了const 修饰的成员变量时,我们就比如在初始化列表中对其初始化,
class A
{
public:A(int a = 1, int b = 1){_a = a;_b = b;}
private:const int _a;int _b;
};
如果我们定义了这样的一个类,在构造函数我们没有对 _a 初始化,这时候用这个类创建对象就会出问题
这是为什么呢?我们在C语言就学过了const 的作用,const 修饰的叫做常变量,是具有常属性的变量,const 修饰的变量只有一次赋值的机会,就是在定义的时候进行初始化,而且是必须初始化,初始化之后它的值就不能修改了,因为它具有常属性。
那么他作为成员变量也是一样的,必须在定义它的时候初始化给一个值,成员变量的定义和初始化就发生在初始化列表,所以对于这种const修饰的成员变量,我们是必须显式的写在初始化列表的,要不就是声明变量的时候给一个缺省值,但是这种做法在实际中的应用并不多,因为缺省值是固定的,不像显式的初始化列表我们可以通过构造函数传参定义他的值。
两种方法:
1.声明给缺省值
class A
{
public:A(int a = 1, int b = 1){_b = b;}
private:const int _a=10;int _b;
};
2.显式的初始化列表定义初始化
class A
{
public:A(int a, int b = 1):_a(a),_b(b){}
private:const int _a;int _b;
};
更推荐第二种方法。
第二个必须在初始化列表中初始化定义的就是引用成员
与const修饰的变量相似,引用变量的性质也是只能在定义的时候引用一个实体,往后就不能在修改引用的对象了。
那么对于引用的成员变量怎么传参和初始化呢?
class A
{
public:A(int& ra, int b = 10):_ra(ra), _b(b){}private:int& _ra;int _b;
};int main()
{int c;A a(c, 5);return 0;
}
第三个必须在初始化列表中定义初始值的就是没有默认构造的类对象成员变量
我们前面说了类类型的成员变量如果没有显式写在初始化列表中,编译器会隐式地在初始化列表中调用他的默认构造函数进行初始化 ,那么如果这个类对象没有默认构造函数,那么编译器就会报错
class A
{
public:A(int a){_a = a;}
private:int _a;
};class B
{
public:B(int a)//A类没有默认构造,编译器会报错{_a = a;}
private:int _a;A aa;
};
这时候正确的写法就是在初始化列表中调用A类的构造函数对其初始化。
class B
{
public:
B(int a=10,int aa=10):_aa(A(aa)),_a(a)
{}
private:int _a;A _aa;
};
这里的过程成就是,首先创建一个匿名的A类对象,然后用A类自动生成地拷贝构造函数对成员变量_aa进行拷贝构造。
这里也应证了我们之前的一个建议,就是一个类最好要有默认构造
1.2 隐式类型转换
什么是隐式类型转换呢?我们来看下面的两行代码
double b = 1.23;int i = b;
为什么我们能用double类型的值对 int 类型的 i 初始化呢?就是因为这其中发生了隐式类型转换。编译器首先会生成一个 b 强制转换成的 const int 类型的的临时变量,然后用这个临时变量对 i 初始化,这里的临时变量是具有常性的,但是我们是可以用一个const int 类型的变量去给一个int类型的变量初始化的,大家千万不要被之前讲的指针和引用的权限放大缩小给绕进去了,权限的放大和缩小只存在于指针和引用,对于复制和初始化是没有任何影响的。
那么下面的一段代码是否也是隐式类型转换呢?
class Day
{
public:Day(int day):_day(day){}
private:int _day;
};int main()
{Day d1 = 5;return 0;
}
通过调试我们可以发现,这样赋值是没问题的,这就说明了这之间发生了隐式类型转换
这里的隐式类型转换的过程是什么样的呢?
首先编译器会去看一下Day类中有没有能传一个参数的构造函数,如果有,编译器就会用这个值通过构造函数生成一个 const Day 类型的临时变量,然后调用拷贝构造,用这个临时变量拷贝出一个d1对象出来。但是如果Day类中没有能传单参数调用的构造函数,这时候就无法进行类型转换,因为编译器无法用这个整形来构造一个Day类的临时对象。
但是这样一来,这个隐式类型转换的过程中就发生了一次构造和一次拷贝构造,这是不是有点降低效率了,因为这里的这个临时变量是没有很大的价值的,不像函数传值返回时的临时变量。对于这种连续的构造过程,以前的编译器执行起来就是一次构造加一次拷贝构造,而现在的编译器就会对这种行为进行优化,将这两次构造转换为一次直接构造,直接用这个整型去构造d1对象。
但是如果是引用的初始化,我们就只需要一次构造函数用这个整形来构造一个const Day的临时变量,所以必须是const Day类型的引用。
const Day& d1 = 5;
如果我们不想让这种隐式类型转换发生,我们可以用一个关键字 explicit 来修饰构造函数,这样就禁止了这种隐式类型转换,不能用被 explicit 修饰的这个构造函数进行隐式类型转换。
这种单参数的隐式构造在C++98就已经支持了
但是C++98只支持用单参数的构造函数来发生隐式类型转换,对其他的构造函数支不支持。
而在C++11标准中,支持了多参数的、半缺省、全缺省的构造函数发生隐式类型转换。
那么多参数的隐式类型转换要怎么实现呢?
那我们的日期类来举例,我们的日期类是自己写了一个全缺省的构造函数,这就意味着我们可以传一个参数、传两个参数或者传三个参数来进行隐式类型转换
Date d1 = 12;Date d2 = { 12,12 };Date d3 = { 12,12,12 };
对于这种多个参数的隐式类型转换,我们要用花括号括起来,表示它是一个整体。
同时,这种操作在原来的编译器中也是一次构造加一次拷贝构造,而现在的编译器就及逆行了优化,直接用参数来构造对象,省去了一次拷贝构造的消耗。
2.静态成员
静态成员顾名思义就是用static修饰声明的成员,分为静态成员变量和静态成员函数。
2.1 静态成员变量
静态成员变量有一个很常用的应用场景,就是统计一个类一共构造了多少个对象,创建对象无非就是构造或者拷贝构造,我们只要在这些构造函数中进行计数,就能够统计出该类创建的对象个数。其实这种计数功能我们用一个全局变量也能实现,但是全局变量最大的问题就是他的全局性,所有的函数都可以用它,这样一来误操作的概率就会很大,不安全。如果我们是定义在类中私有的话,那么就只能通过成员函数来访问和修改,这样一来就更加可控。
class A
{
public:static int GetN(){return N;}A(int a=0):_a(a){}
private:static int N;int _a;
};
如上,在A类中,我们定义了一个静态成员函数 GetN() 和静态成员变量 N ,那么对于静态变量的第一个疑问就是:是否每一个对象中都会存储一个 N ?我们首先要知道,静态变量是存在静态区的,而类对象一般是在函数中创建的局部变量,一般是存在栈中的,从这一点我们就能知道对象中是不可能会存一个静态变量N的,那么是不是对象中会拷贝一个N呢?这也没必要,因为这个类的所有的对象共享一个 N ,当我们想要通过某个对象去访问 公共的 N 时,编译器会去静态区找到N并且返回,编译器是知道 N 的地址的,所以同时,我们每个对象中是不会存储 N 的拷贝的,存了也没意义,因为难道某一个对象修改了N ,难道所有的对象都要对自己体内的N的拷贝修改一遍吗?这样的工作量太大了,而且是完全没有意义的,反正需要访问的时候编译器也能找到。
那么这样一来,这个类的大小或者说用这个类创建的对象的大小就是不包含静态成员变量的大小的,上面的A 类的大小就是4 个字节
同时我们可以先把N设置成共有的,然后用A类创建两个对象来看一下他们的N 是不是访问的同一个。
那么在类中的静态成员变量和在局部域或者全局域定义的静态变量有什么区别呢?首先,局部域中定义的静态变量它的作用域和生命周期都是局部范围,出了他的局部返回就销毁了。而对于全局的静态变量和类中的静态成员变量,他们的生命周期都是全局的,但是全局的静态变量它的作用域也是全局的,不受限制,而类中声明的静态变量的作用域则要受到类域的限制,只能在类域中对其进行操作。
搞清楚了他的生命周期和作用域,下一个问题就是静态变量的定义。首先肯定要排除在初始化列表中,因为初始化列表是在变量定义的时候进行初始化,而静态变量是所有的对象所共享的并不是说每创建一个对象调用构造函数的时候就要对他初始化一次,同时,编译器也不会将静态变量隐式地送上初始化列表。 那么我们要把静态变量的定义写在构造函数体内吗?构造函数体是对成员变量赋值,而不是定义和初始化,对于静态变量而言,我们可以在构造函数内对它进行修改计数,但是我们不能在构造函数内对其赋一个相同的值,为什么呢?如果每调用一次构造函数,静态变量就变回了一个固定的值了,那么就不能完成计数的功能了?
A(int a=0):_a(a){N++; //有意义的N = 2;//无意义的}
那么这时候就只有在全局进行静态成员变量的定义了,在全局定义的时候一定要指定类域。
int A::N = 0;
为什么不能在局部域中定义呢?前面说了,类域中的静态成员变量的生命周期是全局的,如果在局部域中定义的话,就相当于定义了一个局部的静态变量而不是全局的静态变量了,这不符合他的性质。
我们在全局定义了静态成员变量,那么是不是意味着,即使我们不通过类去创建对象,这个静态变量也是存在的,那么我们要怎么访问呢? 访问起来也很简单,我们只需要指定类域就可以了,对于静态成员变量,我们可以直接指定类域,也可以通过类对象或类类型的指针来间接指定类域,那么之前我们玩过的,类类型的空指针能否访问到静态成员变量呢?我们可以试一下(将N设置为共有,测试一下是否能通过类域和空指针来访问)
cout << A::N << endl;//直接通过类域来访问A* pa = nullptr;cout << pa->N << endl;//通过类类型的指针指定类域A a;cout << a.N << endl;//通过类对象来指定类域
我们发现这个的空指针也能访问到N,说明与成员函数的访问一样,编译器并不会真的去对这个空指针解引用,这个空指针由于是 A* 类型的,相当于指定类域了,然后编译器就会去相应的类域去访问这个静态变量,当然这种方法只适用于静态成员变量,对于其他类型的成员变量编译器是会对指针解引用的。
我们发现了打印信息中的第三行是1 ,这是因为我们在自己实现的构造函数中对 N++ 了,而我们这里也调用了这个构造函数,所以相当于计数了。计数除了要记录构造函数,拷贝构造是不是也要自己实现然后计数呢? 因为如果我们自己不写拷贝构造的话,编译器就会自动生成,这时候这些通过拷贝构造初始化的对象就没有计入 N 中了。所以我们自己实现一个拷贝构造函数出来。
A(const A& a){_a = a._a;N++;}
然后我们就可以测试一下计数是否准确,刚好可以测试一下隐式类型转换调用的构造次数,可以在构造函数和拷贝构造中打印一下信息。
A a1 = 2;A a2 = a1;A a3(a1);A a4(5);cout << A::N << endl;
对于 a2,a3,a4而言,我们是没有疑惑的,2 3是拷贝构造,4是直接构造,那么对于 a1 到底编译器有没有把一次隐式的直接构造和一次拷贝构造转换为直接构造呢?
这里就可以看出来,编译器确确实实是做了优化的。
2.2静态成员函数
静态成员变量我们差不多理解了,剩下静态函数,静态函数相对于普通的成员函数有什么区别吗?静态成员函数的特点就是它的参数没有 this 指针。这一点其实有利也有弊,好处就是,我们可以不用通过对象或者类的指针去调用静态成员函数,我们可以直接指定类域来调用,这是其他成员函数所不能的,因为普通的成员函数的第一个参数是 this 指针,这就注定了他们只能通过类对象或者类指针去调用 。 但是,没有this指针的弊端就是,我们无法去访问类中的非静态变量,而只能访问类中的静态变量。我们可以这样理解,就是静态成员函数其实是要和静态成员变量配合着使用的,它的优势就是不用创建对象就能调用,但是他受的私有的限制,他无法访问和修改非静态成员。
我们可以将A类的N改为私有来观察一下
cout << A::GetN() << endl;A a1(5);cout << a1.GetN() << endl;A* pa = nullptr;cout << pa->GetN() << endl;
3.友元
友元函数我们在前面已经使用过一次,就是在重载 <<和>> 的时候。那么友元到底重不重要呢?
首先,友元是一种突破封装的方式,有时候会提供便利,,就比如我们重载流插入和流提取操作符的时候,但是友元会增加耦合度,破坏了封装,使得在类外也能访问到本类的私有成员变量,这是一种很危险的行为,所以说友元在某些特定时刻很重要,但是在一般情况下,我们不到万不得已最好不要用友元。
友元分为 友元函数和友元类
3.1 友元函数
友元函数的说明:友元函数可以直接访问类的私有成员,他是定义在类外部的普通函数,不属于任何类,但需要在类的内部生命,声明时需要加 friend 关键字。
friend ostream& operator<<(ostream& out, const A& a);
友元函数不能用 const 修饰,因为友元函数是定义在类外的普通函数 ,他没有this指针。
友元函数可以在类定义的任何地方声明,不受类的访问限定符限制
一个函数可以是多个类的友元函数
友元函数跟普通函数的调用原理是一样的
所以说友元函数和普通的函数其实是差不多的,只是友元让他能够访问到类的私有成员而不受限制,这就是友元的优势也是他的弊端。
3.2 友元类
友元类的声明就是在一个类的内部 用friend 声明一个友元类,这个友元类的对象可以访问该类的私有成员。
友元类的声明也是可以放在这个类的任意位置的
class C
{
public:C(int c = 1):_c(c){}
private:friend class B;int _c;
};class B
{
public:B(int b):_b(b){}B(const C& c):_b(c._c){}
private:int _b;
};
在上面,我们声明B类是C类的友元类,那么B类就能直接访问到 C类的对象的私有成员,那么我们写了一个构造函数,用C类的对象的_c来初始化我们的_b。
友元关系是单向的,不具有交换性,比如A是B的友元类,但是不能说明B是A的友元类。
友元关系不能传递,比如A是B的友元,B是C的友元,但是不能说明A是C的友元
友元关系不能继承
4.内部类
内部类的概念:如果一个类定义在另一个类的内部,这个在内部定义的类就叫内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象访问内部类的成员
class B
{
public:B(int b):_b(b){}//A类是B类的内部类。class A{public:A(int a = 1):_a(a){}private:int _a;};
private:int _b;
};
如上,我们将A定义在了B的内部,定义为public,那么A就是B的内部类。这时候我们想要创建一个A类的对象,我们不能直接用 A 类名去直接定义,而是要指定类域
B::A a1(5);
我们不能通过B的对象或者B类指针来指定类域。外部类的对象无法访问到内部类的成员
b.A a1(5);//错误示范B* pb = nullptr;pb->A a2(5);//错误示范
同时,在B类的对象中是不会有A类的,我们可以用 sizeof 来求一下B的大小
外部类和内部类就相当于两个独立的类,唯一的联系就是 内部类受到外部类的访问限定符和类域的限制。我们可以把上面的A类定义在B类的私有中,这样一来,我们就只有B能定义A类的对象,其他人都用不了,相当于将A类藏了起来
class B
{
public:B(int b):_b(b){}private:int _b;//A类是B类的内部类。privateclass A{public:A(int a = 1):_a(a){}private:int _a;};
};
在这里我们将A类定义在了B类的私有域中,这时候我们就无法直接指定域来定义A类型的对象了
B::A a(4);//错误示范
在C++中有一个限制,就是内部类天生就是外部类的友元类,比如A是B的内部类,那么A 就是B的友元类,A可以访问B的私有,但是B不能访问A,在使用时一定要注意。C++是不鼓励使用内部类的。
这时候我们就可以像上面的友元类友元,可以用 B类型的对象来构造 A类型的对象
5.匿名对象
匿名对象就是在定义对象时不写对象名,这种没有名字的对象就是匿名对象。
A();A(3);
因为匿名对象没有名字,那么过了定义他的这一行代码,我们就找不到这个匿名对象了,所以可以说匿名对象的生命周期就只有定义他的这一行,这一行结束时编译器自动调用析构函数清理并销毁。
匿名对象在一些特殊场景下还是有用的,比如我们要实现一个函数来创建一个对象,用返回值来接收
A GetObj(int a)
{return A(a);
}
或者加入我们单纯要调用一个成员函数,而不会用到这个类的对象。
在上面的A类中,我们加入之前的静态成员变量,但是我们没有写静态的成员函数来访问它,而是写了一个普通的成员函数,这时候我们可以这样来调用这个函数访问静态成员变量
int Get(){return N;}
A().Get()
我们创建了一个匿名对象单纯只是为了调用一下这个类的成员函数,所以匿名对象就够了,还省去了取名字的烦恼。
6.拷贝对象时的一些编译器优化
第一种情况就是我们上面讲过的,
A a = 1;
在隐式类型转换的时候,我们分析的过程是,首先编译器会调用合适的构造函数用 1 去构造一个const A 类型的临时变量,然后用这个临时变量对 a 进行拷贝构造。 在一些很古老的编译器上确实是这样的,但是在编译器优化之后,如果你先构造临时变量,再去拷贝构造,这时候在不影响正确性的前提下,能优化就优化。比如在我们这里的场景,编译器就会优化成一次直接构造。
第二个场景就是传值传参的时候,
如果我们是这样传参,这里编译器是没有地方优化的,
void func(A a)
{}
A a(1);func(a);
而如果我们传的是这样的参数
func(1);
按照逻辑,首先会用这个 1 构造一个临时对象,然后临时对象传给形参,形参会用这个临时对象进行拷贝构造。而这种情况,因为 1 并不是一个我们后面还会用到的对象,所以编译器会直接把这个过程优化为用 1 去直接构造形参
这其实相当于第一种场景
那如果是匿名对象传参呢?
func(A(1));
正常的过程就是首先会构造一个匿名对象,然后传给形参,形参会用这个匿名对象进行拷贝。但是我们知道,匿名对象的生命周期就是这一行,所以这意味着我们后面就不会用到这个匿名对象了,于是编译器就不会构造这个匿名对象,而是直接传参数 1 过去,在形参直接构造
但是如果形参是引用接收的话,就不存在优化的情况了,因为形参要引用这个匿名对象的实体,或者上面的隐式类型转换构造出的临时变量(这种情况形参要用const 的引用)
第三种场景就是传值返回。
A func(int a)
{A aa(a);return aa;
}int main()
{A a = func(5);return 0;
}
这种情况下,在func函数中首先会构造出一个 aa 对象,然后传值返回的过程中要生成一个临时变量,最后 a 在用这个传回来的临时变量进行拷贝构造。那么在这个过程中这个临时变量似乎有点多余了,这里编译器会将生成临时变量的过程优化掉,再返回之前 , a 直接使用 aa进行拷贝构造,这样就不会有生成临时变量时的拷贝构造了
还有就是隐式转换加传值返回的结合,也就是第一种场景和第三种场景的结合
A func(A a)
{A aa(a);return aa;
}int main()
{A a = func(5);return 0;
}
最后就是一种极致优化的场景,
A func(int a)
{return A(a);
}int main()
{A a = func(5);return 0;
}
这样的代码按逻辑来说是:构造->拷贝构造->拷贝构造,而编译器会直接优化为一次直接构造
所以我们在使用类的时候,在不影响程序的正确性的前提下,我们要尽量减少拷贝构造的总次数,尤其是深拷贝,我们可以利用编译器的优化机制来优化掉某些不必要的消耗