[C++游戏开发基础]:构造函数浅析,8000+字长文
构造函数
构造函数是一种特殊的成员函数,在创建非聚合类类型对象后会自动被调用。当定义一个非聚合类类型对象时,编译器会检查是否能找到一个可以访问的构造函数,该构造函数与调用者提供的初始化值(如果有的情况下)相匹配。
- 如果找到一个可访问的匹配构造函数,将为该对象分配内存,然后调用构造函数。
- 如果找不到合适的构造函数,则会生成编译错误。
许多新手程序员可能不太清楚构造函数是否创建对象。实际上,它们不会创建对象,编译器在调用构造函数之前为对象分配内存,然后在未初始化的对象上调用构造函数。
然后,如果一组初始化参数找不到匹配的构造函数,则会出现编译错误。因此,虽然构造函数不创建对象,但是缺少匹配的构造函数将阻止对象的创建。
除了确定对象如何创建之外,构造函数通常还执行下面两个功能:
- 它们通常通过成员初始化列表初始化任何成员。
- 可能执行其他操作,比如检查初始化值,打开文件或数据库等。 这些都是构造函数可以实现的。
构造函数执行完毕之后,我们说该对象已经被“构造”完成,并且对象现在处于一致可用的状态。
构造函数的命名
与普通函数不同,构造函数必须遵循严格的命名规则:
- 构造函数必须与类同名,这里的同名是严格意义上的,比如大小写一致。这个名称不包括模版参数。
- 构造函数没有返回类型,甚至没有
void
。
由于构造函数通常是类接口的一部分,因此它们通常是公共的。
下面演示为一个程序添加一个基本的构造函数:
#include<iostream>class Foo
{int m_x {};int m_y {};public:Foo(int x, int y){std::cout <<"Foo(" << x << "," <<y <<")constructed\n";}void print() const{std::cout << "x: " << m_x << ", y: " << m_y << '\n';}
};int main()
{Foo foo {6,7};foo.print();return 0;
}
当编译器看到定义Foo foo{6,7}
时,它会寻找一个匹配的Foo
构造函数,该构造函数可以接受两个int
参数,在运行时,当 foo
被实例化时,会为 foo
分配内存,并调用 Foo(int, int)
构造函数,其中参数 x
被初始化为 6
,参数 y
被初始化为 7
。然后构造函数的主体执行并打印 Foo(6, 7) constructed
。
当我们调用 print()
成员函数时,你会发现成员 m_x
和 m_y
的值为 0。这是因为虽然我们的 Foo(int, int)
构造函数被调用了,但它实际上并没有初始化成员。别急,后文会逐步体现。
构造函数不能是const
构造函数需要初始化正在构造的对象,因此,构造函数不能是const
。
#include <iostream>class Something
{
private:int m_x{}; // 私有成员变量 m_x,默认初始化为 0public:Something() // 构造函数必须是非常量(non-const)的{m_x = 5; // 在非常量构造函数中可以修改成员变量}int getX() const { return m_x; } // 常成员函数,不能修改成员变量
};int main()
{const Something s{}; // 定义常量对象 s,并隐式调用(非常量的)构造函数std::cout << s.getX(); // 输出 5return 0;
}
const
对象仍然可以调用非 const
构造函数,因为 const
限制只影响对象创建后,不影响初始化。
通过成员初始化列表进行成员初始化
为了让构造函数初始化成员,我们使用成员初始化列表(通常称为“成员初始化列表”)来完成。不要将这个与用于用值列表初始化聚合体的同名“初始化列表”混淆。
成员初始化列表最好通过示例来学习。在下面的例子中,我们的 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;
}
成员初始化列表定义在构造函数参数之后。它以冒号(:)开始,然后列出每个要初始化的成员及其对应的初始化值,用逗号分隔。
这里必须使用直接初始化形式(最好使用花括号,但圆括号也可以)——使用拷贝初始化(带有等号)在这里不起作用。另外请注意,成员初始化列表不以分号结尾。
当 foo
被实例化时,初始化列表中的成员将使用指定的初始化值进行初始化。在这种情况下,成员初始化列表将 m_x
初始化为 x
的值( x
的值是 6
),并将 m_y
初始化为 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 <iostream>class Foo
{
private:int m_x {}; // 默认成员初始化(将被构造函数初始化列表覆盖)int m_y { 2 }; // 默认成员初始化(如果未在构造函数中显式初始化,将使用此值)int m_z; // 没有初始化,值不确定(未定义行为)public:Foo(int x): m_x { 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 对象,m_x 被初始化为 6,m_y 仍然是 2,m_z 未初始化(值不确定)foo.print(); // 调用 print() 打印成员变量的值return 0;
}
构造函数的函数体
构造函数的函数体通常留空。这是因为我们主要使用构造函数进行初始化,这是通过成员初始化列表完成的。如果仅需要进行这些初始化操作,那么构造函数函数体中就不需要任何语句。
然而,因为构造函数体内语句的执行是在成员初始化列表之后,所以我们可以在其中添加语句来完成任何其他初始化任务。
在上述示例中,我们向控制台打印一些内容以显示构造函数已执行,但我们也可以执行其他操作,例如打开文件或数据库、分配内存等…
优先在构造函数成员初始化列表中初始化成员,而不是在构造函数体中赋值。
检测和处理构造函数中的无效参数
考虑下面的程序:
class Fraction
{
private:int m_numerator {};int m_denominator {};public:Fraction(int numerator, int denominator):m_numerator { numerator }, m_denominator { denominator }{}
};
因为分数是由分子除以分母得到的,所以分数的分母不能为零(否则会得到除以零,这是数学上未定义的)。换句话说,这个类中 m_denominator
不能为 0
。
当用户尝试创建一个分母为零的分数(例如 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 : ??? } // 然后呢,接下来怎么做?{}
};
你可能会想到,我们可以将分母改为一个有效的值,但是这样用户得到的结果就不会包含它们要求的值了,而且我们也没有办法通知他们做了非法操作。
因此,我们通常不会在成员初始化列表中尝试进行任何类型的验证,在大多数情况下,我们没有足够的信息支持我们完全在狗仔函数内部解决这些问题,因此在狗仔构造函数内部修复这些问题显然不是什么好主意。
对于非成员函数和非特殊成员函数,我们可以将错误传递给调用者处理。但是构造函数没有返回值,所以我们没有好的方法来做这一点。在某些情况下,我们可以添加一个
isValid()
成员函数(或重载转换为bool
),返回对象当前是否处于有效状态。例如,一个isValid()
函数对于Fraction
会返回true
当m_denominator != 0.0
。但这意味着调用者必须记住每次创建新的 Fraction 对象时都调用该函数。并且使语义上无效的对象可访问可能会导致错误。
- 在某些类型的程序中,我们可以直接停止整个程序,并让用户重新运行程序并输入正确的数据……但在大多数情况下,这根本不可接受。
- 异常会完全终止构造的过程,这意味着用户永远不会获得一个语义上无效的对象。因此,大多数情况下,抛出异常是最好的做法。
当然,如果无法或者不想使用异常抛出的方式,我们还有一个合理的选择:
那就是不让用户直接创建类,可以提供一个函数,该函数要么返回一个实例,要么返回一个表示失败的值。
在下面的例子中,我们的 createFraction()
函数返回一个 std::optional<Fraction>
,该 std::optional<Fraction>
可能包含一个有效的 Fraction
。如果包含,则我们可以使用该 Fraction
。如果不包含,则调用者可以检测到并处理这种情况。
#include <iostream>
#include <optional>class Fraction
{
private:int m_numerator { 0 }; // 分子,默认为 0int m_denominator { 1 }; // 分母,默认为 1// 私有构造函数,外部无法直接调用Fraction(int numerator, int denominator):m_numerator { numerator }, m_denominator { denominator }{}public:// 允许该友元函数访问私有成员friend std::optional<Fraction> createFraction(int numerator, int denominator);
};// 负责创建 Fraction 实例的函数,返回 std::optional<Fraction>
std::optional<Fraction> createFraction(int numerator, int denominator)
{if (denominator == 0) // 分母不能为 0,否则返回空 optionalreturn {};return Fraction{numerator, denominator}; // 否则返回合法的 Fraction
}int main()
{auto f1 { createFraction(0, 1) }; // 创建合法分数 0/1if (f1) // 检查是否成功创建{std::cout << "Fraction created\n"; // 输出 "Fraction created"}auto f2 { createFraction(0, 0) }; // 试图创建非法分数 0/0if (!f2) // 检查创建是否失败{std::cout << "Bad fraction\n"; // 输出 "Bad fraction"}
}
默认构造函数以及参数
默认构造函数是一个不需要参数的构造函数,通常,这是一个没有参数定义的构造函数。
看个示例:
#include <iostream>class Foo
{
public:Foo() // 默认构造函数{std::cout << "Foo default constructed\n";}
};int main()
{Foo foo{}; // 没有初始化值,调用foo的默认构造函数return 0;
}
如果一个类类型有默认构造函数,那么值初始化(value initialization) 和 默认初始化(default initialization) 都会调用默认构造函数。因此,对于这样的类(比如示例中的 Foo 类),以下两种写法本质上是等价的:
Foo foo{}; // 值初始化,调用 Foo() 默认构造函数
Foo foo2; // 默认初始化,调用 Foo() 默认构造函数
对于所有类类型,优先使用值初始化而不是默认初始化。
带有默认参数的构造函数
与所有函数一样,构造函数的最右侧参数可以有默认参数。
#include<iostream>class Foo
{private:int m_x {};int m_y {};public:Foo(int x=0,int y=0) // 带有默认参数的构造函数: m_x {x}, m_y {y}{std::cout <<"Foo("<<m_x<<","<<m_y<<") constructed\n";}
};int main()
{Foo foo1{}; // 调用Foo(int,int)构造函数并使用默认参数初始化Foo foo2{6,7}; // 调用Foo(int,int) 构造函数return 0;
}
如果一个构造函数所有参数都有默认值,那么它就可以像默认构造函数一样工作,可以在不传递任何参数的情况下调用,因此它就是一个默认构造函数。
构造函数重载
由于构造函数也是函数,因此也可以被重载。也就是说,我们可以有多个构造函数,以便以不同的方式创建对象。
#include<iostream>class Foo{private:int m_x {};int m_y {};public:Foo() // 默认构造函数{std::cout <<"Foo() constructed\n";}Foo(int x,int y) // 非默认构造函数: m_x {x}, m_y {y}{std::cout <<"Foo("<<m_x<<","<<m_y<<") constructed\n";}};int main(){Foo foo1{}; // 调用Foo()构造函数并使用默认参数初始化Foo foo2{6,7}; // 调用Foo(int,int) 构造函数return 0;}
以上结论的一个推论是,一个类应该只有一个默认构造函数。如果提供了多个默认构造函数,编译器将无法区分应该选择使用哪个构造函数而报错。
#include <iostream>class Foo
{
private:int m_x {};int m_y {};public:Foo() // default constructor{std::cout << "Foo constructed\n";}Foo(int x=1, int y=2) // default constructor: m_x { x }, m_y { y }{std::cout << "Foo(" << m_x << ", " << m_y << ") constructed\n";}
};int main()
{Foo foo{}; // 编译错误:不知道选用哪个默认构造函数return 0;
}
在上述示例中,我们使用无参数的方式实例化 foo
,因此编译器将查找默认构造函数。它会找到两个,并且无法区分应该使用哪个构造函数。这将导致编译错误。
隐式默认构造函数
如果非聚合类类型的对象没有用户声明的构造函数,编译器会生成一个公共的默认构造函数,这样类可以进行值初始化或默认初始化。这个构造函数就是隐式的默认构造函数。
#include <iostream>class Foo
{
private:int m_x{};int m_y{};// 没有声明的构造函数
};int main()
{Foo foo{};return 0;
}
-
这个类没有用户声明的构造函数,所以编译器将为我们生成一个隐式默认构造函数。这个构造函数将用于实例化
foo{}
。 -
隐式默认构造函数等同于一个没有参数、没有成员初始化列表且构造函数体内没有语句的构造函数。换句话说,对于上述
Foo
类,编译器生成如下内容:
public:Foo() // 隐式生成默认构造函数{}
隐式默认构造函数(implicit default constructor)在类没有数据成员的情况下通常比较有用。但如果一个类有数据成员,我们通常希望它们可以用用户提供的值进行初始化,而隐式默认构造函数无法满足这个需求。
在某些情况下,我们可能会手动编写一个默认构造函数,但它的行为实际上和编译器隐式生成的默认构造函数完全一样。
在这种情况下,我们可以使用 = default 告诉编译器生成默认构造函数,而不必自己写一个。这种构造函数被称为显式默认化的默认构造函数(explicitly defaulted default constructor)。
#include <iostream>class Foo
{
private:int m_x {};int m_y {};public:Foo() = default; // 生成一个显式默认构造函数Foo(int x, int y): m_x { x }, m_y { y }{std::cout << "Foo(" << m_x << ", " << m_y << ") constructed\n";}
};int main()
{Foo foo{}; // 调用 Foo() 默认构造函数return 0;
}
在上述示例中,由于我们声明了一个用户自定义构造函数( Foo(int, int)
),通常不会生成隐式默认构造函数。然而,因为我们告诉编译器需要为我们生成这样的构造函数,那么它将会生成。这个构造函数随后将被我们对 foo{}
的实例化使用。
优先使用显式默认构造函数(
=default)
,而不是空主体的默认构造函数。
显式默认化的默认构造函数与空的用户定义构造函数区别
-
当使用值初始化一个类时,如果该类具有用户定义的默认构造函数,对象将会进行默认初始化。但是如果该类有一个未由用户提供的默认构造函数即,一个隐式定义的默认构造函数,或者使用
= default
定义的默认构造函数),那么在默认初始化之前,该对象将被进行零初始化。#include <iostream>class User { private:int m_a; // 注意:没有默认初始化值int m_b {}; // 默认初始化为 0public:User() {} // 用户定义的空构造函数int a() const { return m_a; }int b() const { return m_b; } };class Default { private:int m_a; // 注意:没有默认初始化值int m_b {}; // 默认初始化为 0public:Default() = default; // 显式默认化的默认构造函数int a() const { return m_a; }int b() const { return m_b; } };class Implicit { private:int m_a; // 注意:没有默认初始化值int m_b {}; // 默认初始化为 0public:// 隐式默认构造函数(编译器自动生成)int a() const { return m_a; }int b() const { return m_b; } };int main() {User user{}; // 默认初始化(m_a 未初始化,m_b 初始化为 0)std::cout << user.a() << ' ' << user.b() << '\n';Default def{}; // 先零初始化(m_a、m_b 设为 0),然后默认初始化std::cout << def.a() << ' ' << def.b() << '\n';Implicit imp{}; // 先零初始化(m_a、m_b 设为 0),然后默认初始化std::cout << imp.a() << ' ' << imp.b() << '\n';return 0; }
上面程序在我的电脑上的打印结果:
-
在 C++20 之前,如果一个类具有用户定义的默认构造函数(即使它的函数体为空),那么该类就不再被视为聚合类型(aggregate)。然而,如果使用 = default 语法显式地默认化默认构造函数,则不会影响该类仍然被视为聚合类型。
假设该类在其他方面符合聚合类型的要求,前者(用户定义的默认构造函数)会导致类使用列表初始化(list initialization),而不是聚合初始化(aggregate initialization)。
从 C++20 开始,这个不一致性被修正了,使得无论是用户定义的空默认构造函数,还是显式默认化的默认构造函数,都会使类变为非聚合类型。
创建默认构造函数的时机
默认构造函数允许我们在没有提供初始化值的情况下创建非聚合类类型的对象。因此,只有当一个类的对象在默认情况下可以合理地被创建时,才应该提供默认构造函数。
换句话说,如果一个类的所有成员变量都可以有一个合理的默认值(例如 0、nullptr、空字符串等),那么提供默认构造函数是合适的。否则,类应该要求用户提供必要的初始化值,以确保对象在创建时处于有效的状态。
#include <iostream>class Fraction { private:int m_numerator{ 0 }; // 分子,默认初始化为 0int m_denominator{ 1 }; // 分母,默认初始化为 1public:Fraction() = default; // 显式声明默认构造函数Fraction(int numerator, int denominator): m_numerator{ numerator }, m_denominator{ denominator }{}void print() const{std::cout << "Fraction(" << m_numerator << ", " << m_denominator << ")\n";} };int main() {Fraction f1 {3, 5}; // 使用带参数的构造函数f1.print();Fraction f2 {}; // 由于 `= default`,使用默认构造函数f2.print();return 0; }
对于表示分数(Fraction)的类来说,允许用户不提供初始化值来创建 Fraction 对象是合理的。在这种情况下,用户会得到默认的分数 0/1。
现在考虑下面这个类:
#include <iostream> #include <string> #include <string_view>class Employee { private:std::string m_name{ };int m_id{ };public:Employee(std::string_view name, int id): m_name{ name }, m_id{ id }{}void print() const{std::cout << "Employee(" << m_name << ", " << m_id << ")\n";} };int main() {Employee e1 { "Joe", 1 };e1.print();Employee e2 {}; //编译错误:无匹配的构造函数e2.print();return 0; }
现实中,一个员工对象必须有名字,否则不合理。因此,我们不应该提供默认构造函数,这样如果用户尝试创建无名员工,就会导致编译错误,提醒用户必须提供参数。
委托构造函数
在 C++ 中,委托构造函数(Delegating Constructors)允许一个构造函数调用同一个类中的另一个构造函数,以减少代码重复,提高可维护性。
当一个类包含多个构造函数时,每个构造函数中的代码通常非常相似,甚至完全相同,有大量的重复。我们同样希望尽可能去除构造函数中的冗余代码。
看这个例子:
#include <iostream> #include <string> #include <string_view>class Employee { private:std::string m_name { "???" }; // 默认名称为 "???"int m_id { 0 }; // 默认 ID 为 0bool m_isManager { false }; // 默认不是经理public:Employee(std::string_view name, int id) // 员工必须要有姓名和 ID: m_name{ name }, m_id { id }{std::cout << "Employee " << m_name << " created\n"; // 输出员工创建信息}Employee(std::string_view name, int id, bool isManager) // 员工可以选择是否是经理: m_name{ name }, m_id{ id }, m_isManager { isManager }{std::cout << "Employee " << m_name << " created\n"; // 输出员工创建信息} };int main() {Employee e1{ "James", 7 }; // 创建普通员工 "James"Employee e2{ "Dave", 42, true }; // 创建经理 "Dave" }
你会发现,两个构造函数主体中都打印了完全相同的语句。
通常来说,让构造函数打印内容(除了用于调试目的外)并不是一个好的做法,我们的文章中经常这样做,目的是为了更好的阐述观点,实际开发中不建议这样做,望悉知!
由于构造函数允许调用其他函数,包括类的其他成员函数,那么我们可以这样重构:
#include <iostream>
#include <string>
#include <string_view>class Employee
{
private:std::string m_name { "???" }; // 默认名称为 "???"int m_id{ 0 }; // 默认 ID 为 0bool m_isManager { false }; // 默认不是经理void printCreated() const // 辅助函数:打印员工创建信息{std::cout << "Employee " << m_name << " created\n";}public:Employee(std::string_view name, int id) // 构造函数:指定姓名和 ID: m_name{ name }, m_id { id }{printCreated(); // 调用辅助函数}Employee(std::string_view name, int id, bool isManager) // 构造函数:指定姓名、ID 以及是否为经理: m_name{ name }, m_id{ id }, m_isManager { isManager }{printCreated(); // 调用辅助函数}
};int main()
{Employee e1{ "James", 7 }; // 创建普通员工 "James"Employee e2{ "Dave", 42, true }; // 创建经理 "Dave"
}
虽然这比之前的版本好(冗余语句被冗余函数调用所取代),但它需要引入一个新的函数。而且,我们的两个构造函数也在初始化 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 } // 此构造函数用于初始化 m_name 和 m_id{std::cout << "Employee " << m_name << " created\n"; // 这里重新加入了打印语句}Employee(std::string_view name, int id, bool isManager): m_isManager { isManager } // 此构造函数仅初始化 m_isManager{// 试图调用 Employee(std::string_view, int) 来初始化 m_name 和 m_idEmployee(name, id); // 这段代码不会按预期工作!}const std::string& getName() const { return m_name; }
};int main()
{Employee e2{ "Dave", 42, true };std::cout << "e2 has name: " << e2.getName() << "\n"; // 打印 e2.m_name
}
遗憾的是,类似这样的调用不会正常运行,你可以自己运行看看。
不应在另一个函数的主体中直接调用构造函数。这样做要么会导致编译错误,要么会直接初始化一个临时对象。
那么如果不能在另一个构造函数的主体中调用构造函数,我们该如何解决这个问题?
这就引出了 委托构造函数的概念。
构造函数允许将初始化责任(委托)转移给同一个类类型的另一个构造函数。这个过程有时候也称为构造函数链式调用,这样的构造函数称为委托构造函数。
要使一个构造函数委托初始化给另一个构造函数,只需要在成员初始化列表中调用构造函数即可:
#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 } // 将初始化委托给 Employee(std::string_view, int) 构造函数{}Employee(std::string_view name, int id): m_name{ name }, m_id { id } // 实际上初始化成员变量{std::cout << "Employee " << m_name << " created\n";}};int main()
{Employee e1{ "James" };Employee e2{ "Dave", 42 };
}
针对中这个示例,简单看一下初始化的流程:
- 当
e1 { "James" }
被初始化时,匹配的构造函数Employee(std::string_view)
将被调用,其中参数name
设置为"James"
。 - 这个构造函数的成员初始化列表委托初始化给另一个构造函数,因此
Employee(std::string_view, int)
随后被调用。 name
("James"
)的值作为第一个参数传递,字面量0
作为第二个参数传递。被委托构造函数的成员初始化列表初始化成员,然后被委托构造函数的主体运行。- 然后控制权返回到初始构造函数,其(空)主体运行。
- 最后,控制权返回给调用者。
这种方法的缺点是有时候需要重复初始化值。在委托给mployee(std::string_view, int)
构造函数时,我们需要为 int
参数提供一个初始化值。我们不得不硬编码字面量 0
,因为没有方法可以引用默认成员初始化器。
记住,硬编码不是什么好习惯!
关于委托构造函数的几点额外说明。首先,委托给另一个构造函数的构造函数不允许自己进行任何成员初始化。所以你的构造函数可以委托或初始化,但不能两者都做。
换句话说就是,你既然委托了别人进行初始化的操作,那么你自己就别再做同样的初始化操作了。
请注意,我们让
Employee(std::string_view)
(参数较少的构造函数)委托(delegate)给Employee(std::string_view name, int id)
(参数较多的构造函数)。通常,参数较少的构造函数会委托给参数较多的构造函数。
如果反过来,让
Employee(std::string_view name, int id)
委托给Employee(std::string_view)
,那么我们将无法使用id
来初始化m_id
,因为构造函数只能要么委托给另一个构造函数,要么自己进行初始化,但不能同时执行这两种操作。
警告⚠️
如果一个构造函数委托给另一个构造函数,而那个被委托的构造函数又委托回第一个构造函数。这样会形成一个无限循环,从而导致程序耗尽栈空间而崩溃。
使用默认参数来减少构造函数
默认值有时也可以将多个构造函数减少到一定数量。例如,就上面的例子来说,通过在id
参数上设置一个默认值,我们可以创建一个单个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) // 为 id 提供默认参数(default argument): m_name{ name }, m_id{ id }{std::cout << "Employee " << m_name << " created\n";}
};int main()
{Employee e1{ "James" }; // 由于 id 没有提供,使用默认值 0Employee e2{ "Dave", 42 }; // 提供了 id,使用 42 进行初始化
}
最佳实践
用户必须提供初始化值的成员应该首先定义(并且作为构造函数的左侧参数)。
用户可以提供初始化值的成员应该第二定义(且作为构造函数的右侧参数)。
class Employee
{
private:std::string m_name; // 必须提供int m_id; // 必须提供bool m_isManager; // 可选(有默认值)public:Employee(std::string_view name, int id, bool isManager = false) // isManager 在最右侧: m_name{ name }, m_id{ id }, m_isManager{ isManager }{}
};
当某个初始化值(例如默认成员初始化值和构造函数参数的默认值)在多个地方被使用时,建议定义一个命名常量,并在需要的地方使用它。
这样做的好处是:
-
统一管理初始化值,只需在一个地方修改,就能影响所有使用该值的地方。
-
避免魔法数字(magic numbers),提高代码的可读性和可维护性。
尽管可以使用 constexpr 全局变量 来存储这些默认值,但更好的做法是在类中使用 static constexpr 成员变量。
#include <iostream>
#include <string>
#include <string_view>class Employee
{
private:static constexpr int default_id { 0 }; // 定义一个命名常量,表示默认的 ID 值std::string m_name {};int m_id { default_id }; // 在这里使用命名常量进行默认初始化public:Employee(std::string_view name, int id = default_id) // 在构造函数的默认参数中也使用该命名常量: m_name { name }, m_id { id }{std::cout << "Employee " << m_name << " created\n";}
};int main()
{Employee e1 { "James" }; // ID 默认使用 default_id(即 0)Employee e2 { "Dave", 42 }; // ID 显式指定为 42
}
为什么 static constexpr 更优?
使用 static 关键字,使 default_id 成为所有 Employee 对象共享的静态成员。如果不使用static
,每个 Employee 对象都会有自己独立的 default_id 成员,这虽然不会影响功能,但会浪费内存,因为所有 default_id 变量的值都是相同的。
使用这种方式,default_id
存储在类的静态区域,而不是每个对象都存一份。这样所有 Employee
对象都能共享一个 default_id,提高效率并减少内存浪费
这种方式的缺点
-
增加类的复杂度:每增加一个命名常量,都会给类添加一个额外的名称,可能会使类变得稍微复杂。
是否值得使用取决于场景:
- 如果默认值只在一个地方使用,直接写死即可(比如 m_id { 0 })。
- 如果默认值在多个地方使用,则使用 static constexpr 更合适。