一、何为初始化列表

定义:一种便捷的初始化类内成员变量的方式。

初始化成员变量通常在构造函数里执行,如下例所示。成员变量的初始化可以通过调用有参的构造函数进行传参初始化,也可以通过调用无参的构造函数在函数体内部直接初始化(可以看我之前的博文)。下例展示了调用有参的构造函数始化成员变量的方式。

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age,int height)
	{
		m_age = age;
		m_height = height;
	}
};

int main()
{
	Person person(10,60);
	cout << person.m_age << endl; //10
	cout << person.m_height << endl; //60

	return 0;
}

既然初始化列表是一种便捷的初始化成员变量的方式,那具体的“相貌”如何呢?。下例展示了初始化列表初始化成员变量的方式:

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age, int height) : m_age(age), m_height(height) {}
};

int main()
{
	Person person(10,60);
	cout << person.m_age << endl;

	return 0;
}

我们可以轻而易举的看出两者的区别:构造函数的书写有些差异。初始化列表所在的构造函数没有函数体,在函数之后以“:”连接。

Person(int age, int height) : m_age(age), m_height(height) {}

m_age(age)中的m_age是即将被初始化的成员变量,括号内的age是构造函数的形参。当初始化多个成员变量时,用逗号分离。大括号还是有的,只是函数体内部没有代码。

二、初始化列表的本质

初始化列表的本质,就是生成函数体代码。这样写:

Person(int age, int height) : m_age(age),m_height(height){ }

实际上是执行的:

Person(int age, int height) {
	m_age = age;
	m_height = height;
}

初始化成员列表的写法,比普通的函数体内部赋值看起来更加好看,但是这两种写法的效率是一模一样的。读到这里就会有疑问,仅仅便捷到这种程度,没必要弄一个初始化列表出来吧?那我们下面介绍一下,初始化列表比普通函数体内部实现更便捷的应用场合。

三、初始化列表的优势

我们知道优势是相对的。初始化列表的问世就是为了便捷以往初始化成员变量的方式,那相对于普通的构造函数初始化有什么便捷之处呢?

  • 传入参数可以是表达式
Person(int age, int height) : m_age(age + 2), m_height(height) 

可以将表达式作为形式参数,初始化成员变量

  • 传入参数可以是函数
int function(int a)
{
	return a*a;
}

Person(int age, int height) : m_age(function(age)), m_height(height) { }

可以将形参经过函数,然后将函数的返回值作为初始化成员变量的值。这种情况还是比较常见的,我们在有些时候,需要对初始化的参数做一定的计算,然后作为成员变量的初始值。

四、初始化列表中列表顺序问题

我们初始化列表的时候,被初始化的成员变量之间用逗号分隔:

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age, int height) : m_age(age), m_height(height) {}
};

m_age(age) 然后再 m_height(height)。我们有没有想过是不是先初始化 m_age然后在初始化 m_height呢?那我们就来验证一下:

 Person(int age, int height) : m_age(m_height), m_height(height) { } 

我们初始化m_age选用m_height的值作为初始化值,然后运行main函数,打印两个成员变量的值,结果如下

int main()
{
	Person person(10,60);
	cout << person.m_age << endl; //-858993460
	cout << person.m_height<< endl; //60

	return 0;
}

发现m_age并没有被初始化。这样的结果也不奇怪,因为如果是先初始化m_age,传入的形参是m_height,而此时m_height还没有被赋值,自然m_age就得不到正常数值的初始化了。到这里其实并不能证明,初始化成员列表的书写顺序就是初始化的顺序。下面继续一个例子:

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age, int height) :  m_height(height) , m_age(m_height) { }
};

如果按照我们上面的分析,列表的书写顺序决定了初始化的顺序。这也的书写顺序,会先初始化m_height,然后将m_height的值作为形参,传递给m_age,进而m_age得到初始化。那事实是这样吗?打印结果如下:

int main()
{
	Person person(10,60);
	cout << person.m_age << endl; //-858993460
	cout << person.m_height<< endl; //60

	return 0;
}

咦?怎么回事?按理说应该都打印60才对。这个初始化的顺序难道不是列表的书写顺序吗?

答案是:初始化列表中,列表的顺序是没有意义的,初始化成员变量的顺序,只跟成员变量在内存中的地址值有关。即:在类中声明时,写在前面的成员变量被先初始化,写在后面的成员变量被后初始化,因此,对于本类来说,永远先初始化m_age,后初始化m_height。所以谜底揭开了。

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age, int height) :  m_height(height) , m_age(m_height) { }
};

也就是说,由于成员变量m_age写在前面,所以初始化列表中对于m_age的初始化操作先执行,也就是说先执行这句m_age(m_height),所以初始化列表中的书写顺序是意义的。
我们要做到成员变量的定义顺序和初始化列表顺序一致,增强程序的可读性。

五、初始化列表与默认参数的配合使用

首先回顾一下默认参数是啥(可以看我之前的博文)。默认参数的作用是:声明对象时,不传递参数也有默认参数传递参数。

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age = 10 , int height = 50) : m_age(age),m_height(height){ }
}

假如我的对象声明这样写:直接将 age = 10 和 height = 50 传递给成员变量进行初始化

Person person;

假如我的对象声明这样写:直接将 age = 12 和 height = 50 传递给成员变量进行初始化

Person person(12);

假如我的对象声明这样写:直接将 age = 12 和 height = 60 传递给成员变量进行初始化

Person person(12,60);

我们发现了,初始化列表与默认参数搭配使用最大的好处就是:我写一个构造函数,相当于写了三个构造函数。这种搭配要记住,要时常使用,可提高代码的精简

六、初始化列表的注意之处

  • 成员变量的初始化列表只能用在构造函数的后面,不能用在其他函数后面,比如下方这样的书写方式,这是不被允许的。run函数不是构造函数,不可以后接初始化列表。
class Person
{
	int m_age;
	int m_height;

public:
	Person(int age, int height) { }
	void run(int age, int height) : m_height(height), m_age(m_height) { }
};
  • 初始化列表和函数体内部的执行顺序问题

我们已经知道了,初始化列表其实就相当于把代码插入到构造函数的函数体内:(两种写法完全等价)

Person(int age, int height) : m_age(age), m_height(height) { }

Person(int age, int height) {
	m_age = age;
	m_height = height;
}

按照我们之前的逻辑,好像初始化列表就是为了替代函数体代码而出现的。所以认为有了初始化列表就不能在函数体内部写代码,但其实是可以在函数体内写代码的。比如下方代码:

Person(int age, int height) : m_age(age), m_height(height) { 
	m_age = 10;
}

我在初始化列表里通过传递形age,初始化了成员变量m_age,但我又有了另外一个需求,在函数体内部对m_age进行了额外的处理,也就是额外的初始化。那么问题来了:最终成员变量是按照初始化列表里的来呢,还是按照函数体里的来呢?

回答这个问题很简单,就看是先执行初始化列表的操作,还是先执行函数体里的操作呗!

Person(int age, int height) : m_age(age), m_height(height) {
	m_age = age;
	m_height = height;
	m_age = 10;
}

int main()
{
	Person person(20,60);
	cout << person.m_age << endl; //10
	cout << person.m_height<< endl; //60

	return 0;
}

答案是,先执行初始化列表里的操作,即把初始化列表里做的事插入到函数体的最顶端,先执行。既然是先执行,那最终的结果必然是依据后执行的结果了。所以,m_age最终被初始化为10。

  • 构造函数互相调用时,必须采用初始化列表

我在类中,写了两个构造函数(一个是有参的构造函数,另一个是无参的构造函数)。我们经常会在类中写多种构造函数,以适应不同的初始化成员变量需求。当声明无参的构造函数时,就赋值0给成员变量,当声明有参的构造函数时,就将参数传给成员变量,这很合理。如下方代码所示:

class Person
{
	int m_age;
	int m_height;

public:
	Person() {
		m_age = 0;
		m_height = 0;
	}

	Person(int age , int height) {
		m_age = age;
		m_height = height;
	}
};

但是,不觉得这样太麻烦了吗,因为两个构造函数体内部都是给成员变量赋值。两个成员变量还可以接受,如果是10个成员变量,就会很麻烦了。这时候或许你会想到一个解决这个问题的方法:在有参的构造函数赋予默认参数,这样不就解决了上面的两个构造函数的问题吗?为何要学习构造函数调用构造函数呢?

class Person
{
	int m_age;
	int m_height;

public:
	Person(int age = 0 , int height = 0) {
		m_age = age;
		m_height = height;
	}
};

那我说你的格局就小了!因为构造函数虽然最大的作用就是初始化成员变量,但是它还可以执行别的代码,函数体内还可以执行别的操作。因此构造函数的互相调用是有应用场景的。就像下方代码所示:

class Person
{
	int m_age;
	int m_height;

public:
	Person() {
		Person(0, 0) ;
	}

	Person(int age , int height) {
		m_age = age;
		m_height = height;
	}
};

然后我在main函数中,示例无参的对象,调用无参的构造函数。理论上会进入无参构造函数里,然后执行有参构造函数,然后将0赋值给成员变量。实际上这个过程也是这样运行的,但是为什么打印的乱码呢?

int main()
{
	Person person;
	cout << person.m_age << endl; //-82939913
	cout << person.m_height<< endl; //-82939913
	return 0;
}

直接说答案:因为构造函数的互相调用必须在初始化列表里调用,即正确写法:

class Person
{
	int m_age;
	int m_height;

public:
	Person() : Person(0, 0)  {}

	Person(int age , int height) {
		m_age = age;
		m_height = height;
	}
};

下面解释,为什么构造函数必须放在初始化列表里,而不能放在函数体内部(为什么放在函数体内部不行?)

class Person
{
	int m_age;
	int m_height;

public:
	Person() {
		Person(0, 0);//这种调用方式,其实相当于声明了一个Person对象,干巴巴的声明
		// 我们通常通过 new Person来声明一个指向对象的指针,而这种相当于 Person person,
		// 但是,这个对象,是临时的对象。不是main函数中创建的对象,意思是这个构造函数不是main函数中
		// 对象调用的构造函数,而是一个临时对象调用的构造函数
	}

	Person(int age , int height) {
		m_age = age;
		m_height = height;
	}
};

这里需要回顾一个重点的知识。对象调用成员函数的时候,会有一个默认的this指针,出现在函数体内部。这个this指针指向这个对象

class Person
{
	int m_age;
	int m_height;

public:
	Person() {
		m_age = 10;     
		m_height = 20; 
	}

	Person() {
		// this = &person 把对象的地址默认的赋值给this指针,编译器做的事
		this->m_age = 10;     //等同于person.m_age = 10;
		this->m_height = 20;  //等同于person.m_height = 20;
	}
};

int main()
{
	Person person;
	
	return 0;
}

我们想起来了this指针的事,然后从本质解释一下:为什么调用构造函数,写在函数体内,main函数打印出来的是乱码呢?

class Person
{
	int m_age;
	int m_height;

public:
	Person() {
		Person(0, 0);//执行这句,相当于执行下面两句
		// Person temp;//这是存放在栈空间的临时对象,main函数中声明一个对象之后,这个临时对象的内存就转瞬即逝
		// temp.Person(0, 0)
	}

	Person(int age , int height) {
		this = &temp //然后临时变量的地址值,赋值给这个函数的this指针,而不是main函数中的person对象的地址值
		this->m_age = age;
		this->m_height = height;
	}
};

int main()
{
	Person person;
	cout << person.m_age << endl; //-82939913
	cout << person.m_height<< endl; //-82939913
	return 0;
}

所以,写在函数体内部的构造函数,会创建一个临时对象,然后临时对象的地址赋值给调用函数的this指针。这时,main函数中声明的对象,没有将地址传递给构造函数的this指针,那么person.m_age也就不会被赋值了。

而正确的调用:将构造函数写在初始化列表里,会将main函数中对象的person对象传给被调用的构造函数,进而赋值给this指针。

总结:构造函数调用构造函数是一种比较特殊的调用,被调用者必须放在初始化列表里面。构造函数调用普通的类内成员函数,可以写在函数体内部,但是构造函数必须写在初始化列表里。

七、构造函数的声明和实现分离时,初始化列表需写在实现里

假设构造函数的声明和实现是分离的(我们创建类时,基本上都是分离的),有一个非常关键的点,就是初始化列表的操作只能写在实现中,不可以写在声明中。

下面的类中,声明和实现是分开的。然而我将初始化列表写在了声明中,这也的书写方式是错误的。

class Person
{
	int m_age;
	int m_height;
	
public:
	Person(int age, int height) : m_age(age), m_height(height) { };
};

Person::Person(int age, int height)
{
}

而,这样的书写方式才是正确的:

class Person
{
	int m_age;
	int m_height;
	
public:
	Person(int age, int height);
};

Person::Person(int age, int height): m_age(age), m_height(height){ }

想没想过,为什么初始化列表要写在实现里呢?
答案:因为初始化列表的本质就是将这一段列表插入在函数体内部,所以要与实现绑在一起

假如,默认参数和初始化列表搭配起来使用,且恰好此时构造函数的声明和实现是分离的,那我们应该怎么做呢?下面的程序会报错,这是因为当声明和实现分离时,默认参数只能写在声明里。

class Person
{
	int m_age;
	int m_height;
	
public:
	Person(int age = 20, int height = 60);
};

Person::Person(int age = 20, int height = 60): m_age(age), m_height(height){ }

所以,必须改成下面的形式

class Person
{
	int m_age;
	int m_height;
	
public:
	Person(int age = 20, int height = 60);
};

Person::Person(int age , int height): m_age(age), m_height(height){ }

那想没想过,为什么默认参数又要写在构造函数的声明里呢?

因为我在调用函数的时候,编译器是先看声明,再看实现,如果声明中没有默认参数,那么编译器就不会把默认参数值push出来(看我的博文),而是直接调用函数,直接执行函数体代码,这样的话就会报错。

搭配使用时,我们要记住两件事:当构造函数的声明和实现是分离的时候,默认参数只能写在声明里,而初始化列表只能写在实现处,切记切记。

八、子类调用父类的构造函数

子类的构造函数会默认调用父类的无参构造函数(前提是父类中写了无参的构造函数)

class Person
{
public:
	Person(){
		cout << "Person::Person()" << endl;
	}
};

class Student : public Person
{
public:
	Student(){
		cout << "Student::Student()" << endl;
	}
};

int main()
{
	Student student;
	// 打印这两句
	//Person::Person()
	//Student::Student()
	return 0;
}

如果子类的构造函数显式的调用了父类的有参构造函数,就不会默认的调用父类的无参构造函数了

class Person
{
	int m_age;
public:
	Person(){
		cout << "Person::Person()" << endl;
	}
	
	Person(int age){
		cout << "Person::Person(int)" << endl;
	}
};

class Student : public Person
{
public:
	Student() : Person(10) {
		cout << "Student::Student()" << endl;
	}
};

int main()
{
	Student student;
	// 打印这两句
	//Person::Person(int)
	//Student::Student()
	return 0;
}

如果父类中没有无参的构造函数,只有有参的构造函数。那么子类必须主动调用有参的构造函数,否则编译器会报错。

class Person
{
	int m_age;
public:
	Person(int age){
		cout << "Person::Person(int)" << endl;
	}
};

class Student : public Person
{
public:
	// 会报错
	Student() {
		cout << "Student::Student()" << endl;
	}
	// 不会报错
	Student() : Person(10) {
		cout << "Student::Student()" << endl;
	}
};

如果父类没有写构造函数,那么子类如何调用父类的构造函数呢?(那我就不调用了)。网上有的博客说,如果没有写构造函数,就会默认生成一个,然后调用。这句话是错的。根本不会默认生成。

思考:为什么子类会在没有进行任何操作的情况下,默认调用父类的构造函数?

因为子类就是要继承父类的东西。如果子类对象想要初始化父类成员变量怎么办,那就必须得调用父类的构造函数。特别的就是父类中如果有private成员,子类根本无法初始化,只能通过父类初始化,也就是只能通过调用父类的构造函数来初始化。就像下面的例子一样。

class Person
{
	int m_age;//是私有的
public:
	Person(int age){
		m_age = age;
		cout << "Person::Person(int)" << endl;
	}
};

class Student : public Person
{
public:
	Student() : Person(10) {
		// m_age = 10 如果直接赋值给m_age会报错,因为m_age是私有的,子类不可以直接访问,必须通过父类的构造函数访问
		cout << "Student::Student()" << endl;
	}
};


所以说,子类调用父类的构造函数最大的价值其实是,初始化父类中的private成员。

总结一下子类调用父类构造函数的规则:

  • 如果父类缺少无参的构造函数,那么子类的构造函数‘必须’显式的调用父类的有参构造函数(在子类中有构造函数的前提下)
  • 如果子类写出来了构造函数,而父类还没有无参的构造函数,同时子类的构造函数还没有指定调用父类的有参构造函数,这是程序就会报错!
  • 如果父类中压根没有构造函数,那么子类中的构造函数即使不调用也不会出错。什么时候会出错呢:父类存在有参构造函数,但是没有无参构造函数,而子类存在构造函数!
  • 网上一个扯淡的说法:父类没有无参构造函数,子类有构造函数时,会给父类默认生成一个,根本就不会生成,没有就是没有
  • 如果父类没有写构造函数,那么子类就不调用了,不会默认生成一个无参构造函数再调用。

九、初始化列表总结

  • 初始化成员变需要调用有参构造函数时,才有必要使用初始化列表。如果只需要创建对象时,初始化成员变量为0,那完全没必要使用初始化列表,只需在无参构造函数种为成员变量赋值即可;
  • 初始化的顺序只跟成员变量在类中的声明顺序有关,跟初始化列表的书写顺序无关;
  • 构造函数调用构造函数时,一定要将被调用的构造函数写在初始化列表里;
  • 子类调用父类的构造函数,最明显的作用是初始化父类中的private成员;
  • 声明和实现分离时,初始化列表要写在实现处;
  • 初始化列表只能个构造函数搭配使用,其他函数不可以;
  • 初始化列表的本质就是在构造函数的函数体内插入代码,只不过看起来更高效且参数可以多样化。
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐