一、什么是初始化列表
初始化列表是一种在构造函数中初始化成员变量的语法。它
允许你在对象创建时直接初始化成员变量,而不是在构造函数体内进行赋值操作
。这种方式对于初始化常量成员(const)、引用成员以及调用基类或成员对象的构造函数尤为重要。
使用:MyClass(int a, double b) : x(a), y(b) {}
,这里假设有一个MyClass类,x和y为成员函数
在了解初始化列表之前,我们需要先了解对象构造的过程
是怎么样的
对象的构造过程分为两个阶段:
- 成员初始化列表阶段:在这个阶段,成员变量直接通过它们的构造函数初始化。这是在进入构造函数体之前发生的。
- 构造函数体执行阶段:在这个阶段,可以对成员变量进行进一步的操作或赋值。
知道了对象构造函数的过程,我们就可以进行已下讨论了
二、区分初始化和赋值
在使用初始化列表之前,我觉得很有必要解释一下什么情况下成员是初始化的,什么情况下是给成员进行赋值,这一点很有必要,因为初始化和赋值是两个不同的概念,它们在构造函数中的执行顺序和语义也是不同的。
类成员变量的初始化
是在进入构造函数函数体之前
完成的。而在构造函数体内进行赋值的操作并不是初始化,而是赋值,不要问为什么,人家就是这么规定的。
class MyClass {
int x;
public:
MyClass(int value) : x(value) {} // 这里 x 被初始化
};
class MyClass {
int x;
public:
MyClass(int value) {
x = value; // 这里 x 被赋值
}
};
可以看见,在
花括号之前的朝左是初始化,在花括号里面的操作是赋值
,其中,这两个构造函数执行的步骤是不一样的,后面我们会详细讨论这两种构造函数的区别,其中的区别也是我们使用初始化列表的原因
三、使用场景
- 初始化常量成员: 常量成员变量一旦声明,就
必须立即初始化
,不能在构造函数体内赋值,只能使用初始化列表进行初始化。
class Example {
const int const_member;
public:
Example(int value) : const_member(value) {}
};
- 初始化引用成员:
引用必须在声明时初始化
,并且之后不能更改其指向的对象,只能使用初始化列表进行初始化。
class Example {
int &ref_member;
public:
Example(int &ref) : ref_member(ref) {}
};
- 初始化父类成员: 当你的类继承自其他类时,可以使用初始化列表来调用父类的构造函数。
class Base {
public:
Base(int n) {}
};
class Derived : public Base {
public:
Derived(int n) : Base(n) {}
};
- 成员类对象: 如果类的成员是其他类的对象,并且这些成员类没有默认构造函数,那么必须在初始化列表中初始化这些成员。
class Member {
public:
Member(int n) {}
};
class Composite {
Member m;
public:
Composite(int n) : m(n) {}
};
- 效率提高: 对于非内置类型的成员,使用初始化列表可以提高效率,因为它直接初始化成员,而不是先默认初始化然后再赋值
四、 为什么常量成员(const) 和 引用成员只能使用初始化列表进行初始化?
因为常量成员必须在
声明时立即初始化
,如果在构造函数体内在进行赋值就已经太迟了,此时的常量必须已经是一个确定的值,而初始化列表给我们提供了一个机制,即-使用成员初始化列表可以在对象构造过程中立即给常量成员赋予初始值。同样的,引用成员也必须在声明时立即初始化,所以在函数体内在赋值已经太晚了,而初始化列表允许在对象构造期间立即将引用成员绑定到特定对象上.所以我们在用常量成员和引用成员时只能使用初始化列表的原因,也是初始化列表相较于普通构造函数的优势之一。
五、为什么初始化列表能够提高效率
让我们以下面的例子进行分析:
#include <iostream>
class B {
public:
B() { std::cout << "B default constructor" << std::endl; }
B(const B& other) { std::cout << "B copy constructor" << std::endl; }
B& operator=(const B& other) {
std::cout << "B copy assignment operator" << std::endl;
return *this;
}
};
class A {
public:
A() {
std::cout << "A constructor" << std::endl;
b = B(); // 在构造函数内部进行赋值操作
}
private:
B b;//这里添加一个B类的成员
};
int main() {
A a;
return 0;
}
运行结果:
B default constructor
A constructor
B default constructor
B copy assignment operator
我们惊奇的发现,B类的构造函数居然被调用了三次,两次默认够着函数,一次拷贝赋值操作符,让我们来解释以下为什么会这样:
第一次:在我们创建
a
对象时,需要执行A的构造函数,但是在进入构造函数之前,因为成员函数b
并没有使用初始化列表,所以我们需要对先对b
进行一次默认构造函数的调用
第二次:在A
的默认构造函数体内,进行B()
调用了一次默认构造函数
第三次:b=B()
调用了一次拷贝拷贝赋值运算符
假设说默认构造函数和赋值运算符重载函数的实现很相似,那么我们近乎对一个函数重复调用了三次,如果考虑到其中的内存资源的分配和释放等,那么这样肯定是增加了事件的损耗且容易出错,这肯定不是我们想要的。前面两次的默认构造的作用完全是没有必要
让我们来看看使用初始化列表时是什么情况
#include <iostream>
class B {
public:
B() { std::cout << "B default constructor" << std::endl; }
B(const B& other) { std::cout << "B copy constructor" << std::endl; }
B& operator=(const B& other) {
std::cout << "B copy assignment operator" << std::endl;
return *this;
}
};
class A {
public:
A():b(){
std::cout << "A constructor" << std::endl;
}
private:
B b;
};
int main() {
A a;
return 0;
}
输出结果:
B default constructor
A constructor
显然,B的构造函数只在进入构造函数体之前被调用了一次,这正是我们所希望的!避免了不必要的构造和销毁过程,从而提高效率。特别是对于有构造函数的类类型成员或者包含动态内存分配的对象,这个效率差异更加显著
六、需要注意的点
防止未定义行为:为了避免未定义的行为,特别是对于那些没有默认构造函数的类类型成员,强烈建议在成员初始化列表中显式地进行初始化。这确保了这些成员在进入构造函数体之前已经被正确初始化。即使成员有默认构造函数,出于代码清晰性和一致性的考虑,最好还是在初始化列表中显式初始化每个成员。
内置类型的初始化:对于内置类型(如int, double),使用初始化列表与在构造函数体内进行赋值的效率基本相同。但是,为了保持代码的一致性和可读性,推荐即使对于这些类型也使用初始化列表。当然,如果有特定的理由需要在构造函数体内初始化它们(例如基于某些条件或计算),这样做也是可接受的
具有默认构造函数的类类型成员:对于具有默认构造函数的类类型成员,如果不在初始化列表中显式初始化,将自动调用其默认构造函数。虽然这在技术上是可行的,但显式初始化提供了更好的控制和清晰度,特别是在涉及复杂类或资源管理时。
七、总结
初始化列表的优点
1. 直接构造:当使用初始化列表时,成员变量会通过它们的构造函数直接构造。这意味着如果成员是一个类类型,它会直接使用提供的参数进行构造。如果这个类涉及内存分配(比如std::string或者包含动态数组的自定义类),那么这些资源会在成员的构造函数中
一次性正确分配
。
2. 避免不必要的默认构造:如果不使用初始化列表,对于类类型的成员
,首先会进行默认构造,可能涉及资源分配或其他设置。随后在构造函数体内通过赋值操作对其进行再次初始化,这可能涉及额外的资源分配或释放。这种情况下,先进行的默认构造实际上是不必要的,因为最终的值是在构造函数体内设置的。
3. 优化内存操作:对于涉及内存分配的类,直接使用初始化列表通常更高效,因为它避免了先进行默认构造(可能伴随着默认资源分配),然后再释放这些资源以便进行赋值初始化的过程。
4. 对于const和引用成员的初始化:初始化列表能够对const成员和引用成员进行正确的初始化
5. 避免子类构造函数影响:当创建派生类对象时,基类的构造函数会在派生类的构造函数体执行之前被调用。如果基类使用初始化列表,可以确保在派生类构造函数体执行前,基类的成员已经正确初始化。
6. 更清晰的初始化顺序:成员初始化的顺序与它们在类定义中的声明顺序一致,而不是初始化列表中的顺序。这使得初始化过程更加清晰和可预测。