资讯专栏INFORMATION COLUMN

再谈C++中的构造函数,深入理解构造函数(上篇)

fantix / 2935人阅读

摘要:注意这种只能发生在单参数构造函数中举个例子创建一个类,类该类如下,有个单参数的构造函数。默认构造函数当构造函数不带参数时,我们就把这个构造函数叫做默认构造函数。

前言

其实两个月前我写过一篇C++基础文章:关于构造函数的基本用法,文章链接传送门?:c++基础 面向对象:第五篇(构造,析构,拷贝函数),但是,这里仅仅是基础中的用法,并没有涉及太多的讲解,打算今天整理以下,我对构造函数的再度理解,分享给大家。


一. 构造函数的作用,存在意义,目的

构造函数的作用,存在意义,目的用来初始化类对象的成员变量的函数

如何理解呢?其实就是为了封装性(我的理解),当我们定义一个类对象时候,其实可以在类外直接使用对象.成员变量直接初始化类的成员变量,但是我们一般不这么做,这样初始化的方式不仅麻烦(假如每次创建多个对象时候,你就要多次使用对象.成员变量的方式初始化成员变量),而且破坏封装的原则,我们希望的是一个类创建好,里面就直接帮我初始化了一些成员变量,所以搞出一个构造函数,为的就是初始化类里的成员变量。

这个构造函数,不是自己调用的,当然C++也规定不可以自己调用,是通过创建一个对象,该对象就会自动调用该类对象的构造函数的(,这个操作是隐式操作,也就是说,是编译器干的事情,你看不见,但是却真实发生的事,相当于当你考试考了0分,怕爸妈看到不开心,自己偷偷的该为100分,这个改分的动作你爸妈也看不到;

关于自动调用构造函数这个事:前提是该类有构造函数或者没有构造函数的时候,当编译器觉得你需要构造函数,编译器就会给你一个默认构造函数。这是一个专门要讨论的话题,下面会讨论一下。


二. 隐式转化和explicit

在单参数构造函数中,有一种初始化的方式为隐式初始化的方式。注意这种只能发生在单参数构造函数中;
举个例子:创建一个类,Person 类,该类如下,有个单参数的构造函数

#include#includeusing namespace std;class Person{public:	int m_age;	Person(int age) //单参数构造函数	{		m_age = age;	}};int main(){   //这里发生了10隐式转化为Person的临时对象,   //从而调用了单参数的构造函数,构造一个临时对象初始化对象p1;   Person p1 = 10;   cout << p1.m_age << endl; //打印结果为10;	return 0;}

上面你发现没有,你可以用一个 为 int类型的 10 去初始化 类型为 Person 的 对象p1,明明这里不同的类型,却初始化成功了,那说明,编译器有能力偷偷的做了一些事情,把 10的int类型 转化为 Person类型的能力。前提是,你的类中,必须有一个构造函数,该构造函数可以使得编译器做隐式类型转换,这样才会发生隐式类型转化的初始化赋值

编译器是这样转化的:编译器看到 10 和 p1 的类型不一致,那么该 10 就会转化为 p1 类型,如何转换呢?是在转换的时候通过调用该类里面的单参数构造函数,产生了一个临时对象,该临时对象调用完单参数构造函数时候(调用完了也就说明初始化完了这个临时对象),声明周期也就结束了,那么也就自然而然的销毁了。该临时对象是由于调用了单参数构造函数产生的,所以类型是和p1一样,所以这样,就可以将临时对象复制给p1了,对象复制时候,会把临时对象的所有成员变量都一一的复制给p1

从狭义上讲,只要该类中有单参数的构造函数,就非常又可能在写代码时候,无意识的写上这种隐式类型转化的情况,比如在一个普通的函数中,该函数参数设计为类的形参,用一个非类的对象,即该变量可能和该类中的单参数构造函数的参数类型相同,这里就会发生单参数的隐式构造;

这种情况隐式构造的发生是可以通过编译的,但是我们并不推荐,因为这种方式,毕竟是不同类型的初始化方式,模糊不清,可读性不好。
所以基于上面情况,我们有时候想有一种办法,是强制编译器不做隐式构造,只能显示的构造(显示的初始化)那就有一个关键字,explicit出场了


这个explicit是C++的一个关键字,该关键字用来修饰构造函数,使得该构造函数不可以发生隐式类型转换,必须显示的初始化,或者显示的类型转换(即不是让编译器做偷偷转换,即不是隐式转换)
explicit 中文意思理解:明确的,清晰的;这样就很好理解了,使得这个构造函是明确的含义,清晰的表明它的作用,不要搞隐式构造这种模糊的代码。

举个例子,在上面的单参数构造函数前用explicit修饰,这就代表该构造函数不可以发生隐式类型转化构造临时变量。

#include#includeusing namespace std;class Person{public:	int m_age;	//单参数构造函数前用explicit修饰,	//这就代表该构造函数不可以发生隐式类型转化构造临时变量。	explicit Person(int age) 	{		m_age = age;	}};int main(){   Person p1 = 10; //尝试隐式调用构造函数,错误   cout << p1.m_age << endl; //错误,调用失败	return 0;}

上面的explicit很好的解决了单参数构造函数不发生隐式构造的方式,使得代码含义更加清晰可读;表现为:只要写了隐式构造的方式就会出错;


explicit 不仅仅可以修饰单参数构造函数,也可以修饰多参数的构造函数,只不过不常用而已。
只要明确explicit是修饰构造函数,使得该构造函数只能显示初始化,和显示类型转换就行,反正就不可以发生隐式类型 转换;

建议:单参数构造函数都用 explicit 修饰.

一般来说,对于对象的初始化方式
隐式初始化表现与代码的形式类类型 对象 = 变量(其他不与该类对象类型相同的变量,该变量与构造函数的参数相同)注意这里有个等号,一般有等号这种方式都会发生隐式构造,隐式初始化;
显示初始化表现与代码的形式:没有等号的方式 :如 类 对象 ();或者类型对象对应有等号的初始化方式: 类 对象 = 对象

还有一点,临时对象不一定是隐式类型转换发生的:
比如

Preson p2 = Perosn(10);这里就是构造临时对象显示初始化 p2,但这里Perosn(10)构建的临时对象并没有隐式转换啊;

三. 再谈对构造函数初始化列表的理解(1)

首先我们必须明确:构造函数初始化列表的执行顺序是先于函数体的代码滴。

举个例子:比如我创建一个新的类:Student类

class Student{public:	int m_age;	int m_id;	double m_score;	//构造函数初始化	Student(int age,int id,int score):m_age(age),m_id(id)	{		m_score = score;	}};

上面的student在创建对象的时候,该对象里面的初始化列表中,m_age,m_id的执行顺序是先于m_score的,也就是说,先执行的是初始化列表的代码,再执行函数体的代码。


上面的案例也说明:对于构造函数里面初始化成员变量有两种方式

第一种就是:初始化列表的方式初始化;第二种就是:在构造函数体内通过赋值语句初始化;这两种初始化的方式是由本质区别的,但是对于本案例中成员变量为内置数据类型来说是没多大关系,重点关注在函数体内的赋值语句,这个赋值语句和初始化列表初始化方式对于内置类型来说没什么印象,但是对于类的成员变量,即不是内置类型的成员变量,用初始化列表的方式会提升效率,这是因为在赋值语句初始化的时候,可能会调用一次或者多次类成员变量的构造函数拷贝构造函数等号赋值重载操作函数等,而初始化列表的方式却不会,

这个话题话还会继续讲:到第二篇文章讲它。


四. 再谈默认构造函数

1) 类内初始化

在C++11新标准中,我们是可以给类内的成员变量赋初始值滴,当我们在创建对象的时候,这个初始值就用来初始化成员变量。假如没有赋初始值的话,系统会提供默认初始化,比如:对于内置类型的in的成员变量,没有赋初始值,该成员变量的初始值是0xCCCCCC,即我们看到的随机值;

举个例子:在我们上面例子的Student类中,给 成员变量赋初始值:

int age = 10;//给成员变量age赋初始值;

如果给成员变量赋初始值的同时,还通过构造函数初始化了改成员变量,那么构造函数的成员变量就会覆盖改初始值。


2) 类内初始化的const成员变量

关于const成员变量,在构造函数初始化时候,只能在参数列表初始化,不可以在函数体初始化。
当然,你也是可以通过类内初始化的方式赋值初始化的,

举个例子:

//关于const成员变量,在构造函数初始化时候,只能在参数列表初始化,不可以在函数体初始化class A{public:	A() :i(20)	{		//i = 20;// 有误,不可以在构造函数体初始化	}	const int i = 10; //const int i;也是可以};A a; // 调用成功

为什么只能在构造函数初始化类比初始化呢,而不能在函数体内初始化呢cosnt成员变量只有只读的状态,你在函数体内赋值,相当于是直接给const成员变量修改值了,所以只能从初始化列表初始化const成员变量;这其实也侧面说明赋值和初始化确实是不同的


3)默认构造函数

当构造函数不带参数时,我们就把这个构造函数叫做默认构造函数。

比如对于这个Student类

class Student{public:	int m_age;	int m_id = 10010; //注意这里有初始值	double m_score;	Student() //默认构造函数,也是无参构造函数	{		m_age = 10;	}	};int main(){   Student s ; //成功创建对象,且调用了默认构造函数初始化对象	return 0;}

当我给 Student s ;打断点,执行该语句,看监视窗口,发现该对象成功创建,并且调用了构造函数初始化里面。

其实这是大家都懂这个道理,由于类有构造函数,创建对象肯定会调用构造函数。
那假如我把构造函数注释掉,即我注释掉上面Student 类的默认构造函数,当我以同样的方式执行上面的代码: Student s ;你发现,也成功啦。

看测试图:

所以有个结论:
即使一个类没有构造函数,那么也是可以成功创建对象的。
注意关键字眼,也是可以成功,我并没有说一定成功 。
这也是我在上面关于自动调用构造函数时候留下的话题,将在这里讨论。

有人可能会认为,当我们没有写构造函数时候,编译器会给我们自动生成一个所谓的“合成默认构造函数,”在我们没有给类写构造函数时候,创建对象能成功原因,是因为这个所谓的“合成默认构造函数.”
其实这种想法是错误的⚠⚠⚠
其实这种想法是错误的⚠⚠⚠
其实这种想法是错误的⚠⚠⚠

真正的理解是:
1. 即便一个类没有写构造函数,编译器也不一定(但有些情况会)会给该类合成默认构造函数,对象依旧能够成功创建。

2. 也就是说创建对象成功,不需要类中一定有构造函数,即使没有也可以。

3. 一个类中,假如没有写构造函数,编译器可能会合成默认构造函数,可能也不会合成默认构造函数,到底何不合成默认构造函数,这是取决于编译器觉得是否是需要给你合成默认构造函数,当然不管需不需要合成,对象都是可以创建成功的,这是不会错的。

但是有个问题就是:什么时候上面的对象才不会创建成功呢?

比如在该类,加了一个有参构造函数

class Student{public:	int m_age;	int m_id = 10010; //注意这里有初始值	double m_score;	//Student() //默认构造函数,也是无参构造函数	//{	//	m_age = 10;	//}		Student(int age)	{		m_age = age;	}};int main(){   Student s ; //创建失败,尝试调用无参的构造函数,但是没有	return 0;}

在这种情况下,即自己写了一个有参的构造函数,那么编译器就不可能会为你合成默认构造函数了,同时,假如你还是以Student s这种方式创建对象的话,那就会失败,因为你类提供了有参构造函数,没有提供无参的构造函数,这个时候就会创建对象失败。所以一个结论就是:当程序员自己书写了构造函数时候,那么你就要提供相应与书写构造函数相应形参的实参形式,才创建成功对象
所以由这个结论引出上诉的创建方式会失败,要想创建成功:
提供相应构造函数形参的实参,即书写这样的代码:

 Student s (10); //成功创建对象,并且调用了单参数的构造函数

1. 什么时候编译器才会合成默认构造函数

回到我们刚刚的话题,即便一个类没有书写自己的构造函数也是可以创建成功对象的

还是回到刚刚的Student类的代码,注释掉单参数的构造函数,回到有默认构造函数的时候。

class Student{public:	int m_age;	int m_id = 10010; //注意这里有初始值	double m_score;	Student() //默认构造函数,也是无参构造函数	{		m_age = 10;	}		//Student(int age)	//{	//	m_age = age;	//}};int main(){   Student s ; //在没有书写自己的构造函数时候,对象也是可以创建成功的	return 0;}

上诉的对象可以创建对象成功,我们也知道,但是我有个问题,就是上诉的代码到底有没有编译器合成默认构造函数呢?

这也是我们即将讨论的话题,编译器到底什么时候才会给我们合成默认构造函数

我们看看上面的代码情况,在没有写构造函数的前提下,编译器给这个Student类合成了一个默认构造函数,通过反汇编可以看到。

为什么这种情况会合成默认构造函数呢?

原因是该类中有成员变量是赋了初始值的,你观察Student类,有一句成员变量的int m_id = 10010;
这句代码使得编译器没有默认构造函数,给该Student类合成了一个默认构造函数,是如何合成的?是因为有这句int m_id = 10010;这个10010就会使得编译器需要合成默认构造函数,注意是编译器认为它需要滴,就在这个默认构造函数中初始化这个 m_id 的成员变量;


假如我把上面的代码int m_id = 10010;注释掉,即在Student类没有成员变量被赋初始值的了,同时也没有构造函数,此时,你在 Student s这个创建变量代码打一个断点,你发现,这句话直接跳过没有执行,当我门通过监视发现,虽然没执行但是却实实在在的把对象创建成功;

其实没执行这句话的原因是因为,编译器认为你没有构造构造函数, 也认为你不需要合成默认构造函数,所以给你直接跳过这个语句,不执行,但是,监视中,对象确实存在的,说明这个对象在没有构造函数的条件下,和编译器没有合成默认构造函数的条件下,也成功创建了。

验证一下代码:


第二种情况:
当类中有虚函数的时候,自己不写构造函数时候,编译器会合成默认构造函数。

如:

class Student{public:	int m_age;	//int m_id = 10010; //注意这里有初始值	double m_score;	virtual void fun() //类中有虚函数,自己类不写构造函数,编译器会合成一个默认构造函数	{	}	//Student() //默认构造函数,也是无参构造函数	//{	//	m_age = 10;	//}		//Student(int age)	//{	//	m_age = age;	//}};int main(){   Student s ;	return 0;}

我们给 Student s打一个断点,观察反汇编

为什么这种情况,即类中有虚函数,没有写构造函数,编译器会默认合成构造函数?

因为,编译器觉得需要合成默认构造函数,在我们创建对象时候,由于有虚函数,那么该对象就会多出4个字节,用于存放虚指针,该虚指针指向一个虚表,所以必须有该虚指针必须要有虚表的地址,那么该虚指针就必须初始化,那么对于编译器来说,他觉得需要一个默认构造函数来初始化该虚指针,即给该虚指针赋值虚表地址。

对于编译器来说,只要他觉得需要给你的类一个构造函数,他就会合成一个构造函数给你。


第三种情况:
当一个类有虚继承的情况,该类没有构造函数,则编译器会合成一个默认构造函数。

如:

class Person{	int m_price;	};//stdent 类虚继承了Peson类,该Student类假如没有构造函数,则编译器会合成一个默认的构造函数class Student:public virtual Person{public:	int m_age;	double m_score;	};int main(){   Student s ; 	return 0;}

我们给上面代码 Student s ;打一个断点,发现编译器合成了一个默认构造函数。
如图

为什么Students类虚继承了Person类,该Student类没有构造函数,编译器会合成一个默认构造函数呢?
原因:也是编译器觉得有必要合成一个默认构造函数,因为子类虚继承了父类,那么说明子类的对象中就多了4个字节,该4个字节用来保存虚指针,该虚指针指向一个虚表,所以必须有该虚指针必须要有虚表的地址,那么该虚指针就必须初始化,那么对于编译器来说,他觉得需要一个默认构造函数来初始化该虚指针,即给该虚指针赋值虚表地址。

总的来说,还是编译器觉得需要给你合成一个默认构造函数。


第四种情况:
该类1包含了类2类型的成员变量,并且该类2中有构造函数,在类1没有构造函数的情况下,编译器就会给类1合成默认构造函数。

代码如下:

class Person{public:	int m_price;	Person()	{		m_price = 10;	}};class Student {public:	int m_age;	double m_score;	Person p; //在Student 包含了Person 类的成员变量};int main(){	Student s; //成功创建对象,且调用了默认构造函数初始化对象	return 0;}

验证结果:

为什么在该类1包含了类2类型的成员变量,并且该类2中有构造函数,在类1没有构造函数的情况下,编译器就会给类1合成默认构造函数。

原因:因为编译器认为需要有一个合成的默认构造函数。对于该语句Student s;在创建一个Student类对象时候,该对象没有构造函数,但是编译器也给它合成了构造函数,是因为该类有Person 类的成员变量,并且该Person类是有构造函数的,所以编译器认为需要Student类一个构造函数,去初始化该Person p;中的成员变量,该构造函数里面是通过初始化列表调用Person 的构造函数滴。所以编译器会合成默认构函数。

当然有一种衍生情况,父类中的成员变量有赋初始值,但是没有构造函数,子类有该父类的成员变量,子类也没有构造函数,在创建子类对象时候,也会给子类合成一个默认构造函数滴。


总结:
什么时候编译器会给你合成默认构造函数呢?

当编译器觉得需要的时候就会给你合成默认构造函数,或者说当编译器认为有事情要做的时候,是会合成一个默认构造函数的。

但是总的来说,编译器合成的默认构造函数并不是什么好东西,即很多时候,并不能是你的意愿,该合成的构造函数并不能按你想要的要求去做事
我讲这个话题,只是为了告诉你,当你创建一个

对象成功时候,不一定编译器会给你合成默认构造函数滴。

并不是让你去使用编译器合成默认构造函数,这个我们一般不推荐。


构造函数还有很多话题没聊完,但是我觉得篇幅太长,所以分开两篇文章讲。
下篇继续来聊构造函数

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/118808.html

相关文章

  • JS程序

    摘要:设计模式是以面向对象编程为基础的,的面向对象编程和传统的的面向对象编程有些差别,这让我一开始接触的时候感到十分痛苦,但是这只能靠自己慢慢积累慢慢思考。想继续了解设计模式必须要先搞懂面向对象编程,否则只会让你自己更痛苦。 JavaScript 中的构造函数 学习总结。知识只有分享才有存在的意义。 是时候替换你的 for 循环大法了~ 《小分享》JavaScript中数组的那些迭代方法~ ...

    melody_lql 评论0 收藏0
  • 深入javascript——原型链和继承

    摘要:在使用原型链实现继承时有一些需要我们注意的地方注意继承后的变化。在了解原型链时,不要忽略掉在末端还有默认的对象,这也是我们能在所有对象中使用等对象内置方法的原因。 在上一篇post中,介绍了原型的概念,了解到在javascript中构造函数、原型对象、实例三个好基友之间的关系:每一个构造函数都有一个守护神——原型对象,原型对象心里面也存着一个构造函数的位置,两情相悦,而实例呢却又...

    UCloud 评论0 收藏0
  • 再谈Javascript原型继承

    摘要:原型继承基本模式这种是最简单实现原型继承的方法,直接把父类的对象赋值给子类构造函数的原型,这样子类的对象就可以访问到父类以及父类构造函数的中的属性。 真正意义上来说Javascript并不是一门面向对象的语言,没有提供传统的继承方式,但是它提供了一种原型继承的方式,利用自身提供的原型属性来实现继承。Javascript原型继承是一个被说烂掉了的话题,但是自己对于这个问题一直没有彻底理解...

    ThinkSNS 评论0 收藏0
  • 再谈Promise

    摘要:方法完成回调注册模式下,对象通过方法调用,注册完成态和失败态的回调函数。这些回调函数组成一个回调队列,处理的值。调用实例的方法,能使注册的回调队列中的回调函数依次执行。 之前写了一篇关于ES6原生Promise的文章。近期又读朴灵的《深入浅出Node》,里面介绍了一个Promise/Deferred模式。 Promise是解决异步问题的利器。它其实是一种模式。Promise有三种状态,...

    chenjiang3 评论0 收藏0
  • C++类和对象(万字总结)(建议收藏!!!)

    摘要:当你用该日期类创建一个对象时,编译器会自动调用该构造函数对新创建的变量进行初始化。注意构造函数的主要任务并不是开空间创建对象,而是初始化对象。编译器对内置类型使用默认构造函数时,对其成员赋的是随机值。 ...

    masturbator 评论0 收藏0

发表评论

0条评论

fantix

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<