要让构造函数初始化成员,我们使用成员初始化列表(通常称为“成员初始化列表”,不要将其与用于聚合初始化的“初始化列表”混淆)。
成员初始化列表最好通过示例来学习。在以下示例中,我们的Foo(int, int)
构造函数已更新为使用成员初始化列表来初始化m_x
和m_y
:
#include <iostream>
class Foo
{
private:
int m_x {};
int m_y {};
public:
Foo(int x, int y)
: m_x { x }, m_y { y } // 这是我们的成员初始化列表
{
std::cout << "Foo(" << x << ", " << y << ") constructed\n";
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};
int main()
{
Foo foo{ 6, 7 };
foo.print();
return 0;
}
成员初始化列表定义在构造函数参数之后。它以冒号(:
)开头,然后列出每个要初始化的成员及其初始化值,用逗号分隔。你必须在这里使用直接初始化形式(最好使用花括号,但圆括号也可以)——使用拷贝初始化(带有等号)在这里不起作用。另外请注意,成员初始化列表不以分号结尾。
该程序产生以下输出:
Foo(6, 7) constructed
Foo(6, 7)
当foo
被实例化时,初始化列表中的成员将被指定的初始化值初始化。在这种情况下,成员初始化列表将m_x
初始化为x
的值(即6),将m_y
初始化为y
的值(即7)。然后执行构造函数体。
当调用print()
成员函数时,你可以看到m_x
仍然保持值6,m_y
仍然保持值7。
成员初始化列表的格式化
C++提供了很大的灵活性来按照你喜欢的方式格式化成员初始化列表,因为它不在乎你把冒号、逗号或空白放在哪里。
以下几种风格都是有效的(你可能会在实践中看到所有这三种风格):
Foo(int x, int y) : m_x { x }, m_y { y }
{
}
Foo(int x, int y) :
m_x { x },
m_y { y }
{
}
Foo(int x, int y)
: m_x { x }
, m_y { y }
{
}
我们推荐使用第三种风格:
- 将冒号放在构造函数名称的下一行,这样可以清晰地将成员初始化列表与函数原型分开。
- 缩进你的成员初始化列表,以便更容易看到函数名称。
- 如果成员初始化列表较短/简单,所有初始化器可以放在一行:
Foo(int x, int y)
: m_x { x }, m_y { y }
{
}
否则(或者如果你更喜欢),每个成员和初始化器对可以放在单独的一行(以逗号开头以保持对齐):
Foo(int x, int y)
: m_x { x }
, m_y { y }
{
}
成员初始化顺序
由于C++标准的规定,成员初始化列表中的成员总是按照它们在类中被定义的顺序进行初始化(而不是按照它们在成员初始化列表中被定义的顺序)。
在上面的示例中,因为m_x
在类定义中被定义在m_y
之前,所以m_x
将首先被初始化(即使它在成员初始化列表中没有被首先列出)。
由于我们直观上期望变量从左到右初始化,这可能会导致一些微妙的错误。考虑以下示例:
#include <algorithm> // 为了使用std::max
#include <iostream>
class Foo
{
private:
int m_x{};
int m_y{};
public:
Foo(int x, int y)
: m_y { std::max(x, y) }, m_x { m_y } // 这一行有问题
{
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};
int main()
{
Foo foo { 6, 7 };
foo.print();
return 0;
}
在上述示例中,我们的意图是计算传递进来的初始化值中较大的一个(通过std::max(x, y)
),然后使用这个值来初始化m_x
和m_y
。然而,在作者的机器上,打印出以下结果:
Foo(-858993460, 7)
发生了什么?尽管m_y
在成员初始化列表中被首先列出,但由于m_x
在类中被首先定义,因此m_x
首先被初始化。m_x
被初始化为m_y
的值,但此时m_y
尚未被初始化。最后,m_y
被初始化为初始化值中较大的一个。
为了帮助防止此类错误,成员初始化列表中的成员应该按照它们在类中被定义的顺序列出。一些编译器会在成员初始化顺序不正确时发出警告。
最佳实践:成员初始化列表中的成员变量应按照它们在类中被定义的顺序列出。
如果可能的话,避免使用其他成员的值来初始化成员也是一个好主意。这样,即使你在初始化顺序上犯了错误,也不会有问题,因为初始化值之间没有依赖关系。
成员初始化列表与默认成员初始化器
成员可以通过几种不同的方式初始化:
- 如果成员出现在成员初始化列表中,则使用该初始化值。
- 否则,如果成员有默认成员初始化器,则使用该初始化值。
- 否则,成员将被默认初始化。
这意味着,如果成员既有默认成员初始化器,又出现在构造函数的成员初始化列表中,那么成员初始化列表中的值将优先。
以下示例展示了所有三种初始化方法:
#include <iostream>
class Foo
{
private:
int m_x {}; // 默认成员初始化器(将被忽略)
int m_y { 2 }; // 默认成员初始化器(将被使用)
int m_z; // 没有初始化器
public:
Foo(int x)
: m_x { x } // 成员初始化列表
{
std::cout << "Foo constructed\n";
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ", " << m_z << ")\n";
}
};
int main()
{
Foo foo { 6 };
foo.print();
return 0;
}
在作者的机器上,输出如下:
Foo constructed
Foo(6, 2, -858993460)
以下是发生的情况。当foo
被构造时,只有m_x
出现在成员初始化列表中,因此m_x
首先被初始化为6。m_y
没有出现在成员初始化列表中,但它有一个默认成员初始化器,因此它被初始化为2。m_z
既没有出现在成员初始化列表中,也没有默认成员初始化器,因此它被默认初始化(对于基本类型来说,这意味着它保持未初始化)。因此,当我们打印m_z
的值时,我们得到未定义行为。
构造函数体
构造函数体通常被留空。这是因为我们主要使用构造函数进行初始化,而初始化是通过成员初始化列表完成的。如果这就是我们需要做的全部,那么我们就不需要在构造函数体中添加任何语句。
然而,由于构造函数体中的语句在成员初始化列表执行后执行,因此我们可以添加语句来完成任何其他所需的设置任务。在上面的示例中,我们在控制台上打印了一些内容以表明构造函数已经执行,但我们也可以做其他事情,比如打开文件或数据库、分配内存等。
新手程序员有时会在构造函数体中为成员赋值:
#include <iostream>
class Foo
{
private:
int m_x { 0 };
int m_y { 1 };
public:
Foo(int x, int y)
{
m_x = x; // 错误:这是一个赋值操作,而不是初始化
m_y = y; // 错误:这是一个赋值操作,而不是初始化
}
void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};
int main()
{
Foo foo { 6, 7 };
foo.print();
return 0;
}
尽管在这个简单的情况下,这会产生预期的结果,但如果成员需要被初始化(例如,对于const
或引用类型的成员变量),赋值将不起作用。
关键洞察:一旦成员初始化列表执行完毕,对象就被认为是初始化完成的。一旦函数体执行完毕,对象