14-12 委托构造函数

在可能的情况下,我们希望减少冗余代码(遵循DRY原则——不要重复自己)。

请考虑以下函数:

void A()
{
    // statements that do task A
}

void B()
{
    // statements that do task A
    // statements that do task B
}

这两个函数都包含一组执行完全相同操作(任务A)的语句。在这种情况下,我们可以进行如下重构:

通过这种方式,我们消除了函数A()和B()中存在的冗余代码。这使得代码更易于维护,因为修改只需在单一位置进行。

当类包含多个构造函数时,各构造函数中的代码往往高度相似甚至完全相同,存在大量重复。我们同样希望尽可能消除构造函数的冗余。

请看以下示例:

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

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };
    bool m_isManager { false };

public:
    Employee(std::string_view name, int id) // Employees must have a name and an id
        : m_name{ name }, m_id { id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }

    Employee(std::string_view name, int id, bool isManager) // They can optionally be a manager
        : m_name{ name }, m_id{ id }, m_isManager { isManager }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James", 7 };
    Employee e2{ "Dave", 42, true };
}

image

每个构造函数的本体都包含完全相同的打印语句。

作者注:
构造函数通常不应包含打印语句(调试目的除外),因为这意味着当不需要打印内容时,无法通过该构造函数创建对象。本例中保留打印语句是为了帮助说明实现原理。

构造函数允许调用其他函数,包括类中的其他成员函数。因此我们可以进行如下重构:

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

class Employee
{
private:
    std::string m_name { "???" };
    int m_id{ 0 };
    bool m_isManager { false };

    void printCreated() const // our new helper function
    {
        std::cout << "Employee " << m_name << " created\n";
    }

public:
    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id }
    {
        printCreated(); // we call it here
    }

    Employee(std::string_view name, int id, bool isManager)
        : m_name{ name }, m_id{ id }, m_isManager { isManager }
    {
        printCreated(); // and here
    }
};

int main()
{
    Employee e1{ "James", 7 };
    Employee e2{ "Dave", 42, true };
}

image

虽然这比之前的版本有所改进(冗余语句已被冗余函数调用取代),但需要引入新函数。而且两个构造函数都在初始化 m_name 和 m_id。理想情况下我们也应消除这种冗余。

能否做得更好?当然可以。但这正是许多新手程序员容易陷入困境之处。

在函数体内调用构造函数会创建临时对象

类似于前例中函数B()调用函数A()的逻辑,显而易见的解决方案似乎是在Employee(std::string_view, int, bool)构造函数体内调用Employee(std::string_view, int)构造函数,以初始化m_name、m_id并打印语句。具体实现如下:

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

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };
    bool m_isManager { false };

public:
    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id } // this constructor initializes name and id
    {
        std::cout << "Employee " << m_name << " created\n"; // our print statement is back here
    }

    Employee(std::string_view name, int id, bool isManager)
        : m_isManager { isManager } // this constructor initializes m_isManager
    {
        // Call Employee(std::string_view, int) to initialize m_name and m_id
        Employee(name, id); // this doesn't work as expected!
    }

    const std::string& getName() const { return m_name; }
};

int main()
{
    Employee e2{ "Dave", 42, true };
    std::cout << "e2 has name: " << e2.getName() << "\n"; // print e2.m_name
}

但这无法正常工作,因为程序输出如下内容:

image

尽管员工戴夫创建了打印输出,但在e2完成构造后,e2.m_name似乎仍被设置为初始值“???”。这怎么可能?

我们原本期望Employee(name, id)会调用构造函数以继续初始化当前隐式对象(e2)。但类对象的初始化在成员初始化列表执行完毕后即告完成。当构造函数主体开始执行时,已错过进一步初始化的时机。

当从函数体内调用构造函数时,看似函数调用的操作通常会创建并直接初始化一个临时对象(另一种情况则会引发编译错误)。在上例中,Employee(name, id); 创建了一个临时(无名)Employee对象。该临时对象的m_name被设为Dave,并输出“Employee Dave created”,随后被销毁。e2的m_name和m_id始终保持默认值不变。

最佳实践:
构造函数不应在其他函数体内直接调用。此操作将导致编译错误,或直接初始化临时对象。
若需创建临时对象,请优先采用列表初始化(明确表明创建对象的意图)。

既然无法在构造函数体内调用构造函数,该如何解决此问题?


委托构造函数

构造函数可将初始化责任委托(转移)给同类型的另一个构造函数。此过程有时称为构造函数链式constructor chaining调用,此类构造函数称为委托构造函数delegating constructors

要使一个构造函数将初始化委托给另一个构造函数,只需在成员初始化列表中调用该构造函数即可:

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

class Employee
{
private:
    std::string m_name { "???" };
    int m_id { 0 };

public:
    Employee(std::string_view name)
        : Employee{ name, 0 } // delegate initialization to Employee(std::string_view, int) constructor
    {
    }

    Employee(std::string_view name, int id)
        : m_name{ name }, m_id { id } // actually initializes the members
    {
        std::cout << "Employee " << m_name << " created\n";
    }

};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

image

当初始化 e1 { “James” } 时,会调用匹配构造函数 Employee(std::string_view),并将参数 name 设为 ‘James’。该构造函数的成员初始化列表将初始化委托给其他构造函数,因此随后调用 Employee(std::string_view, int)。其中 name 的值 (“James”) 作为第一个参数传递,字面量 0 作为第二个参数传递。被委托构造函数的成员初始化列表随后初始化成员变量。接着执行被委托构造函数的主体代码。控制权随后返回初始构造函数,其(空)主体代码执行完毕。最终控制权返回调用方。

此方法的缺点在于有时需要重复初始化值。在委托调用 Employee(std::string_view, int) 构造函数时,我们需要为 int 参数提供初始化值。由于无法引用默认成员初始化器,我们不得不硬编码常量0。

关于构造函数委托的补充说明:首先,委托给其他构造函数的构造函数本身不得进行任何成员初始化。因此构造函数只能选择委托或初始化,不可兼得。

顺带一提...:
请注意,我们让参数较少的构造函数 Employee(std::string_view) 委托给参数较多的构造函数 Employee(std::string_view name, int id)。这种由参数较少的构造函数委托给参数较多的构造函数的做法很常见。

若改为让 Employee(std::string_view name, int id) 委托给 Employee(std::string_view),则将无法使用 id 初始化 m_id,因为构造函数只能委托或初始化,不能同时执行两者。

其次,构造函数可能出现向另一个构造函数委托,而该构造函数又回传至原构造函数的情况。这将形成无限循环,导致程序栈空间耗尽而崩溃。可通过确保所有构造函数最终都指向非委托构造函数来规避此问题。

最佳实践:
当存在多个构造函数时,应考虑是否可通过委托构造函数减少代码重复。


使用默认参数减少构造函数数量

默认值有时也可用于将多个构造函数精简为更少的构造函数。例如,通过为id参数设置默认值,我们可以创建一个仅要求name参数的单一Employee构造函数,该构造函数将可选地接受id参数:

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

class Employee
{
private:
    std::string m_name{};
    int m_id{ 0 }; // default member initializer

public:

    Employee(std::string_view name, int id = 0) // default argument for id
        : m_name{ name }, m_id{ id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1{ "James" };
    Employee e2{ "Dave", 42 };
}

image

由于函数调用中默认值必须附加在最右侧参数上,定义类时应遵循以下良好实践:首先定义用户必须提供初始化值的成员(并将这些成员设为构造函数最左侧参数);其次定义用户可选择性提供初始化值的成员(因默认值已满足需求),并将这些成员设为构造函数最右侧参数。

最佳实践:
用户必须提供初始化值的成员应优先定义(并作为构造函数最左侧参数)。用户可选择性提供初始化值的成员(因默认值可接受)应其次定义(并作为构造函数最右侧参数)。

需注意此方法还要求重复定义 m_id 的默认初始化值(‘0’):既作为默认成员初始化器,又作为默认参数。


一个难题:冗余构造函数与冗余默认值

在上述示例中,我们通过委托构造函数和默认参数来减少构造函数冗余。但这两种方法都需要在不同位置重复初始化成员变量的值。遗憾的是,目前尚无法指定委托构造函数或默认参数应使用默认成员初始化值。

关于“减少构造函数数量(伴随初始化值重复)”与“增加构造函数数量(避免初始化值重复)”的优劣,存在多种观点。我们认为,即使导致初始化值重复,通常采用较少构造函数的方式更为简洁明了。

进阶读者指南:
当某个初始化值需在多处使用(例如同时作为默认成员初始化器和构造函数参数的默认值)时,可定义命名常量并在所有需要处引用该常量。这能实现初始化值的集中定义。

虽然可使用 constexpr 全局变量实现此目的,但更优方案是在类内部使用 static constexpr 成员:

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

class Employee
{
private:
    static constexpr int default_id { 0 }; // define a named constant with our desired initialization value

    std::string m_name {};
    int m_id { default_id }; // we can use it here

public:

    Employee(std::string_view name, int id = default_id) // and we can use it here
        : m_name { name }, m_id { id }
    {
        std::cout << "Employee " << m_name << " created\n";
    }
};

int main()
{
    Employee e1 { "James" };
    Employee e2 { "Dave", 42 };
}

image

在此上下文中使用 static 关键字,使我们能够拥有一个由所有 Employee 对象共享的 default_id 成员。若不使用 static,每个 Employee 对象将拥有独立的 default_id 成员(虽然可行,但会浪费内存)。

此方法的缺点在于:每增加一个命名常量,就需理解一个新名称,导致类结构略显冗杂。其价值取决于所需常量的数量,以及初始化值在多少处被调用。

静态数据成员将在第15.6节——静态成员变量中详细讲解。


测验时间

问题 #1

编写一个名为 Ball 的类。该类应包含两个私有成员变量:一个用于存储颜色(默认值:黑色),另一个用于存储半径(默认值:10.0)。添加 4 个构造函数,分别处理以下情况:

int main()
{
    Ball def{};
    Ball blue{ "blue" };
    Ball twenty{ 20.0 };
    Ball blueTwenty{ "blue", 20.0 };

    return 0;
}

该程序应产生以下结果:

Ball(black, 10)
Ball(blue, 10)
Ball(black, 20)
Ball(blue, 20)

image

显示解决方案

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

class Ball
{
private:
	std::string m_color{ "black" };
	double m_radius{ 10.0 };

public:
	// Default constructor (color and radius will use default value)
	Ball()
	{
            print();
	}

	// Constructor with only radius parameter (color will use default value)
	Ball(double radius)
		: m_radius{ radius }
	{
            print();
	}

	// Constructor with only color parameter (radius will use default value)
	Ball(std::string_view color)
		: m_color{ color }
	{
            print();
	}

	// Constructor with both color and radius parameters
	Ball(std::string_view color, double radius)
		: m_color{ color }
		, m_radius{ radius }
	{
            print();
	}

    void print() const
    {
        std::cout << "Ball(" << m_color << ", " << m_radius << ")\n";
    }
};

int main()
{
    Ball def{};
    Ball blue{ "blue" };
    Ball twenty{ 20.0 };
    Ball blueTwenty{ "blue", 20.0 };

    return 0;
}

问题 #2

通过使用默认参数和委托构造函数,减少上述程序中的构造函数数量。

显示解决方案

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

class Ball
{
private:
	std::string m_color{ "black" };
	double m_radius{ 10.0 };

public:
	// Handles Ball(radius)
	Ball(double radius)
		: Ball{ "black", radius } // delegate to the other constructor
	{
		// We don't need to call print() here since it will be called by
		// the constructor we delegate to
	}

	// Handles Ball(color, radius), Ball(color), and Ball()
	Ball(std::string_view color="black", double radius=10.0)
		: m_color{ color }
		, m_radius{ radius }
	{
		print();
	}

	void print() const
	{
		std::cout << "Ball(" << m_color << ", " << m_radius << ")\n";
	}
};

int main()
{
    Ball def{};
    Ball blue{ "blue" };
    Ball twenty{ 20.0 };
    Ball blueTwenty{ "blue", 20.0 };

    return 0;
}

image

posted @ 2025-12-29 21:49  游翔  阅读(16)  评论(0)    收藏  举报