14-10 构造函数成员初始化列表

本节课延续第14.9节——构造函数简介中对构造函数的介绍。


通过成员初始化列表初始化成员

要让构造函数初始化成员,我们使用成员初始化列表member initializer list(常称为“成员初始化列表member initialization list”)。请勿将其与同名的“初始化列表”混淆,后者用于通过值列表初始化聚合体。

成员初始化列表最好通过实例来学习。在下面的示例中,我们更新了 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 } // here's our member initialization list
    {
        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;
}

成员初始化列表定义在构造函数参数之后。它以冒号(:)开头,随后列出每个待初始化的成员及其初始化值,各变量之间以逗号分隔。此处必须使用直接初始化形式(建议使用大括号,但圆括号同样有效)——在此处使用复制初始化(含等号)将无法生效。另请注意成员初始化列表末尾不加分号。

该程序输出如下结果:

image


成员初始化列表格式化

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 }
{
}

否则(或根据个人偏好),每个成员与初始化器对可单独成行(以逗号开头以保持对齐):


成员初始化顺序

根据C++标准规定,成员初始化列表中的成员始终按其在类内部定义的顺序进行初始化(而非按成员初始化列表中的定义顺序)。

在上例中,由于 m_x 在类定义中先于 m_y 定义,因此 m_x 将被优先初始化(即使它在成员初始化列表中并非首位)。

由于人们通常期望变量按从左到右的顺序初始化,这种行为可能导致隐蔽错误。请看以下示例:

#include <algorithm> // for 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 } // issue on this line
    {
    }

    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。然而在作者的机器上,打印出的结果如下:

image

image

发生了什么?尽管m_y在成员初始化列表中排在首位,但由于m_x在类中定义在前,因此m_x会被优先初始化。而m_x的初始化值取自尚未初始化的m_y。最终,m_y会被初始化为两个初始化值中较大的那个。

为避免此类错误,成员初始化列表中的成员应按其在类中定义的顺序排列。部分编译器在成员初始化顺序错误时会发出警告。

最佳实践:
成员初始化列表中的成员变量应按其在类中定义的顺序排列。

同时建议避免使用其他成员的值来初始化成员(如果可行)。这样即使初始化顺序出错,也不会产生影响,因为初始化值之间不存在依赖关系。


成员初始化列表vs默认成员初始化器

成员可以通过几种不同的方式初始化:

  • 如果成员出现在成员初始化列表中,则使用该初始化值
  • 否则,如果成员具有默认成员初始化器,则使用该初始化值
  • 否则,成员将进行默认初始化。

这意味着,如果成员同时具有默认成员初始化器且出现在构造函数的成员初始化列表中,则成员初始化列表中的值具有优先级。

以下示例展示了三种初始化方法:

#include <iostream>

class Foo
{
private:
    int m_x {};    // default member initializer (will be ignored)
    int m_y { 2 }; // default member initializer (will be used)
    int m_z;      // no initializer

public:
    Foo(int x)
        : m_x { x } // member initializer list
    {
        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;
}

在作者的机器上,此输出:

image

情况如下:当构造 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; // incorrect: this is an assignment, not an initialization
        m_y = y; // incorrect: this is an assignment, not an initialization
    }

    void print() const
    {
        std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
    }
};

int main()
{
    Foo foo { 6, 7 };
    foo.print();

    return 0;
}

image

尽管在此简单情况下会产生预期结果,但在需要初始化成员(例如const数据成员或引用成员)时,赋值操作将无法生效。

image

关键要点:
成员初始化列表执行完毕后,对象即被视为初始化完成;函数主体执行完毕后,对象才被视为构造完成。

最佳实践:
建议优先使用成员初始化列表初始化成员,而非在构造函数主体中赋值。


检测并处理构造函数的无效参数

考虑以下 Fraction 类:

class Fraction
{
private:
    int m_numerator {};
    int m_denominator {};

public:
    Fraction(int numerator, int denominator):
        m_numerator { numerator }, m_denominator { denominator }
    {
    }
};

由于分数是由分子除以分母构成的,因此分数的分母不能为零(否则会导致除以零的情况,在数学上未定义)。换言之,该类具有一个不变量:m_denominator 不能为 0。

相关内容:
我们在第 14.2 课——类简介中讨论过类不变量。

那么当用户尝试创建分母为零的分数(例如 Fraction f { 1, 0 };)时该如何处理?

在成员初始化列表中,我们检测和处理错误的手段相当有限。虽然可使用条件运算符检测错误,但后续该如何处理?

class Fraction
{
private:
    int m_numerator {};
    int m_denominator {};

public:
    Fraction(int numerator, int denominator):
        m_numerator { numerator }, m_denominator { denominator != 0.0 ? denominator : ??? } // what do we do here?
    {
    }
};

我们可以将分母改为有效值,但这样用户得到的分数将不包含其要求的数值,且我们无法通知用户发生了意外操作。因此,我们通常不会在成员初始化列表中进行任何验证——仅用传入的值初始化成员,随后再处理该情况。

在构造函数主体中,我们可以使用语句,因此拥有更多检测和处理错误的选项。这里是使用assert或static_assert验证传入参数语义有效性的理想位置,但这并不能真正处理生产环境中的运行时错误。

当构造函数无法构造出语义上有效的对象时,我们称其构造失败。


当构造函数失败时(序曲)

在第9.4课——检测与处理错误中,我们引入了错误处理的主题,并探讨了当函数因错误发生而无法继续执行时的一些处理方案。由于构造函数也是函数,它们同样会遇到相同的问题。

该章节提出了四种应对策略:

  • 在函数内部解决错误
  • 将错误传递给调用方处理
  • 终止程序运行
  • 抛出异常

多数情况下,构造函数内部缺乏充分信息来彻底解决问题,因此修复错误通常不可行。

对于非成员函数和非特殊成员函数,可将错误传递给调用方处理。但构造函数没有返回值,因此缺乏有效实现方式。某些情况下可添加 isValid() 成员函数(或重载为 bool 的转换函数),用于返回对象当前是否处于有效状态。例如,Fraction类的isValid()函数可在m_denominator != 0.0时返回true。但这要求调用方每次创建新Fraction对象时都必须主动调用该函数。而允许访问语义上无效的对象极易引发错误。因此尽管优于无解决方案,这仍非理想选择。

在特定类型的程序中,我们可以直接终止整个程序,让用户重新输入正确参数……但在多数情况下这不可行。所以这个方案也不太可能采用。

最终选择是抛出异常。异常会完全终止构造过程,这意味着用户永远无法访问语义上无效的对象。因此在大多数情况下,抛出异常是此类场景的最佳处理方式。

核心要点:
当构造函数失败且无法恢复时,抛出异常通常是最佳选择。我们将在第27.5节 异常、类与继承 和第27.7节 函数try代码块 中进一步探讨此内容。

作者注:
目前,我们通常假设类对象的构造成功创建了语义上有效的对象。

对于高级读者
如果异常不可行或不被期望(无论是因为你决定不使用它们,还是因为你尚未学习相关知识),还有另一种合理的选择。与其让用户直接创建类,不如提供一个函数,该函数要么返回类的实例,要么返回失败指示符。

在下面的示例中,createFraction()函数返回一个std::optional类型对象,其中可选地包含一个有效的Fraction实例。若存在有效实例,则可直接使用;若不存在,调用方可检测到异常并进行处理。std::optional将在第12.15节(std::optional)中讲解,其友元函数机制则在第15.8节(非成员友元函数)中阐述。

#include <iostream>
#include <optional>

class Fraction
{
private:
    int m_numerator { 0 };
    int m_denominator { 1 };

    // private constructor can't be called by public
    Fraction(int numerator, int denominator):
        m_numerator { numerator }, m_denominator { denominator }
    {
    }

public:
    // Allow this function to access private members
    friend std::optional<Fraction> createFraction(int numerator, int denominator);
};

std::optional<Fraction> createFraction(int numerator, int denominator)
{
    if (denominator == 0)
        return {};

    return Fraction{numerator, denominator};
}

int main()
{
    auto f1 { createFraction(0, 1) };
    if (f1)
    {
        std::cout << "Fraction created\n";
    }

    auto f2 { createFraction(0, 0) };
    if (!f2)
    {
        std::cout << "Bad fraction\n";
    }
}

image


测验时间

问题 #1

编写一个名为 Ball 的类。该类应包含两个私有成员变量:一个用于存储颜色color,另一个用于存储半径radius。同时编写一个函数,用于打印出球的颜色和半径。

以下示例程序应能编译通过:

int main()
{
	Ball blue { "blue", 10.0 };
	print(blue);

	Ball red { "red", 12.0 };
	print(red);

	return 0;
}

并输出结果:

Ball(blue, 10)
Ball(red, 12)

image

显示解答

#include <iostream>
#include <string>
#include <string_view>

class Ball
{
private:
	std::string m_color { "none" };
	double m_radius { 0.0 };

public:
	Ball(std::string_view color, double radius)
		: m_color { color }
		, m_radius { radius }
	{
	}

	const std::string& getColor() const { return m_color; }
	double getRadius() const { return m_radius; }
};

void print(const Ball& ball)
{
    std::cout << "Ball(" << ball.getColor() << ", " << ball.getRadius() << ")\n";
}

int main()
{
	Ball blue { "blue", 10.0 };
	print(blue);

	Ball red { "red", 12.0 };
	print(red);

	return 0;
}

问题 #2

为什么我们将 print() 定义为非成员函数而非成员函数?

显示解答

其原理详见第14.8节——数据隐藏(封装)的优势

问题 #3

为什么我们将 m_color 定义为 std::string 而非 std::string_view?

显示解答

在这个特定示例中,这并不重要(因为我们的颜色参数是C风格字符串常量,不会脱离作用域)。

但从概念上讲,我们希望Ball类成为传入颜色的拥有者。若m_color采用std::string_view类型,当颜色参数为临时对象(如函数返回的std::string)时,该临时对象被销毁后将导致m_color成为悬空引用。

我们在第13.11节 结构体杂项 中对此案例进行了更详细的讨论(并给出示例)。

posted @ 2025-12-28 23:30  游翔  阅读(30)  评论(0)    收藏  举报