[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 更合适。
相关文章:
[C++游戏开发基础]:构造函数浅析,8000+字长文
构造函数 构造函数是一种特殊的成员函数,在创建非聚合类类型对象后会自动被调用。当定义一个非聚合类类型对象时,编译器会检查是否能找到一个可以访问的构造函数,该构造函数与调用者提供的初始化值(如果有的情况下)相匹配。 如果找到一个可访问的匹配构造函数,将为…...
【Go】切片
知识点关键概念切片声明var slice []int初始化切片slice : []int{1,2,3}make() 创建切片make([]int, len, cap)获取长度和容量len(slice), cap(slice)追加元素slice append(slice, value)切片截取slice[start:end](返回子切片)拷贝切片copy(dest, src)&…...
MySQL 设置允许远程连接完整指南:安全与效率并重
一、为什么需要远程连接MySQL? 在分布式系统架构中,应用程序与数据库往往部署在不同服务器。例如: Web服务器(如NginxPHP)需要连接独立的MySQL数据库数据分析师通过BI工具直连生产库多服务器集群间的数据同步 但直接…...
Cursor IDE 入门指南
什么是 Cursor? Cursor 是一款集成了 AI 功能的现代代码编辑器,基于 VSCode 开发,专为提高开发效率而设计。它内置强大的 AI 助手功能,能够理解代码、生成代码、解决问题,帮助开发者更快、更智能地完成编程任务。 基础功能 1.…...
32.[前端开发-JavaScript基础]Day09-元素操作-window滚动-事件处理-事件委托
JavasScript事件处理 1 认识事件处理 认识事件(Event) 常见的事件列表 认识事件流 2 事件冒泡捕获 事件冒泡和事件捕获 事件捕获和冒泡的过程 3 事件对象event 事件对象 event常见的属性和方法 事件处理中的this 4 EventTarget使用 EventTarget类 5 事件委托模式 事件委托&am…...
【工具变量】中国各地级市是否属于“信息惠民国家试点城市”匹配数据(2010-2024年)
数据来源:国家等12部门联合发布的《关于加快实施信息惠民工程有关工作的通知》 数据说明:内含原始文件和匹配结果,当试点城市在2014年及以后,赋值为1;试点城市在2014年之前或该城市从未实施信息惠民试点工程&#x…...
windows安装配置FFmpeg教程
1.先访问官网:https://www.gyan.dev/ffmpeg/builds/ 2.选择安装包Windows builds from gyan.dev 3. 下滑找到release bulids部分,选择ffmpeg-7.0.2-essentials_build.zip 4. 然后解压将bin目录添加path系统变量:\ffmpeg-7.0.2-essentials_bui…...
Wispr Flow,AI语言转文字工具
Wispr Flow是什么 Wispr Flow 是AI语音转文本工具,基于先进的AI技术,帮助用户在任何应用程序中实现快速语音转文字。 Wispr Flow支持100多种语言,具备自动编辑、上下文感知和低音量识别等功能,大幅提升写作和沟通效率。Wispr Fl…...
风暴潮、潮汐潮流模拟:ROMS模型如何精准预测海洋现象?
海洋数值模拟的崛起与 ROMS 的关键角色 🌊在海洋科学的浪潮中,海洋数值模拟正以迅猛之势崛起,成为科研与实际应用领域不可或缺的利器。ROMS(Regional Ocean Modeling System)作为其中的佼佼者,凭借其高效、…...
【Rust】集合的使用——Rust语言基础16
文章目录 1. 前言2. Vector2.1. 构建一个 vector2.2. 获取 vector 中的元素2.3. 遍历 vector2.4. 使用枚举来储存多种类型 3. String3.1. 新建字符串3.2. 更新字符串3.3. 字符串的内部结构3.3.1. 字符串如何访问内部元素?3.3.2. 字节、标量值和字形簇 3.4. 字符串 s…...
Kafka集成Debezium监听postgresql变更
下载postgres的插件:https://debezium.io/documentation/reference/2.7/install.html 2.7版本支持postgresql12数据库。 debezium-connector-postgres-2.7.4.Final-plugin.tar.gz 上传插件并解压 mkdir /usr/local/kafka/kafka_2.12-2.2.1/connector cd /usr/local…...
自动学习和优化过程,实现更加精准的预测和决策的智慧交通开源了
智慧交通视觉监控平台是一款功能强大且简单易用的实时算法视频监控系统。它的愿景是最底层打通各大芯片厂商相互间的壁垒,省去繁琐重复的适配流程,实现芯片、算法、应用的全流程组合,从而大大减少企业级应用约95%的开发成本。通过高效的实时视…...
第2.2节 Android Jacoco插件覆盖率采集
JaCoCo(Java Code Coverage)是一款开源的代码覆盖率分析工具,适用于Java和Android项目。它通过插桩技术统计测试过程中代码的执行情况,生成可视化报告,帮助开发者评估测试用例的有效性。在github上开源的项目ÿ…...
从零开始:使用 Cython + JNI 在 Android 上运行 Python 算法
1. 引言 在 Android 设备上运行 Python 代码通常面临性能、兼容性和封装等挑战。尤其是当你希望在 Android 应用中使用 Python 编写的计算密集型算法时,直接运行 Python 代码可能导致较高的 CPU 占用和较差的性能。为了解决这个问题,我们可以使用 Cytho…...
开源软件许可证冲突的原因和解决方法
1、什么是开源许可证以及许可证冲突产生的问题 开源软件许可证是一种法律文件,它规定了软件用户、分发者和修改者使用、复制、修改和分发开源软件的权利和义务。开源许可证是由软件的版权所有者(通常是开发者或开发团队)发布的,它…...
stratis,容器podman
一、stratis 1.stratis可以实现动态的在线扩容,lvm虽然也可以实现在线扩容,但是是需要人为的手动扩容。 2.stratis不需要手动格式化,自动会创建文件系统(默认是xfs) 1. 安装stratis软件包 yum list | grep stratis…...
解决用three.js展示n个叠加的stl模型文件错位的问题
加载stl时可以明显看到下面有一部分模型是错位的。 将stl文件格式转化为glb 使用免费将 STL 转换为 GLB - ImageToStl 模型就没有错位了 代码如下 <template><div ref"threeContainer" class"three-container"></div></template&…...
从零开始实现 C++ TinyWebServer 数据库连接池 SqlConnectPool详解
文章目录 数据库连接池是什么?Web Server 中为什么需要数据库连接池?SqlConnectPool 成员变量实现 Init() 函数实现 ClosePool() 函数SqlConnectRAII 类SqlConnectPool 代码SqlConnectPool 测试 从零开始实现 C TinyWebServer 项目总览 项目源码 数据库连…...
利用ffmpeg库实现音频AAC编解码
AAC(Advanced Audio Coding)是一种音频编码技术,出现于1997年,基于MPEG-2的音频编码技术。AAC具有高效的数据压缩能力和较高的音质,适用于各种音频应用场景。例如,在智能设备中,AAC技术被广泛…...
Vue + CSS实现渐变栅格进度条
进度条作为可视化大屏系统中展示数据状态的关键元素,其视觉效果直接影响用户的使用体验,而传统的进度条往往呈现出固定的样式,缺乏视觉吸引力。在这种场景下,一种基于Vue和CSS实现渐变栅格进度条的方法应运而生,该方法…...
算法模型从入门到起飞系列——背包问题(探索最大价值的掘金之旅)
文章目录 前言一、背包问题溯源(动态规划)1.1 动态规划的概念1.2 动态规划的基本步骤1.3 动态规划的实际应用 二、背包问题2.1 背包问题衍生2.2 0-1背包2.2.1 0-1背包描述2.2.2 0-1背包图解2.2.3 0-1背包代码刨析 2.3 完全背包2.3.1 完全背包描述2.3.2 完…...
蓝桥杯—迷宫(bfs)
一.题目 分析:最短路径问题,给定一个迷宫,从左上角走到右下角,要求路径最短,并且要求字典序最小,也就是按照D,L,R,U,的搜索顺序去搜索,否则路径不是唯一的&am…...
【Android】安卓 Java下载ZIP文件并解压(笔记)
写在前面的话 在这篇文章中,我们将详细讲解如何在 Android 中通过 Java 下载 ZIP 文件并解压,同时处理下载进度、错误处理以及优化方案。 以下正文 1.权限配置 在 AndroidManifest.xml 中,我们需要添加相应的权限来确保应用能够访问网络和设…...
清晰易懂的 PHP 安装与配置教程
初学者也能看懂的 PHP 安装与配置教程 本教程将手把手教你如何在 Windows 系统上安装 PHP,并配置 Composer(PHP 的依赖管理工具)的缓存位置,即使你是零基础小白,也能轻松完成! 一、准备工作 操作系统&…...
Ceph集群2025(Squid版)快速对接K8S cephFS文件存储
ceph的块存储太简单了。所以不做演示 查看集群 创建一个 CephFS 文件系统 # ceph fs volume create cephfs01 需要创建一个子卷# ceph fs subvolume create cephfs01 my-subvol -----------------#以下全部自动创建好 # ceph fs ls name: cephfs01, metadata pool: c…...
Linux进程控制(四)之进程程序替换
文章目录 进程程序替换单进程版程序替换替换原理多进程版程序替换替换函数函数解释小知识 命名理解 进程程序替换 如果要让子进程执行与父进程完全不同的代码,就要进行进程程序替换。 单进程版程序替换 执行一个可执行文件 makefile mycommand:mycommand.cgcc -…...
python-selenium 爬虫 由易到难
本质 python第三方库 selenium 空值 浏览器驱动 浏览器驱动控制浏览器 推荐 edge 浏览器驱动(不容易遇到版本或者兼容性的问题) 驱动下载网址:链接: link 1、实战1 (1)安装 selenium 库 pip install selenium&#…...
希尔排序
希尔排序是一种改进的插入排序算法,它通过将原始数据分成多个子序列来改善插入排序的性能,每个子序列的元素间隔为 d(增量)。随着算法的进行,d 逐渐减小,最终减为 1,此时整个序列就被排序好了。…...
Pydantic Mixin:构建可组合的验证系统体系
title: Pydantic Mixin:构建可组合的验证系统体系 date: 2025/3/22 updated: 2025/3/22 author: cmdragon excerpt: Pydantic的Mixin模式通过继承组合实现校验逻辑复用,遵循以Mixin后缀命名、不定义初始化方法等设计原则。支持基础校验模块化封装与多策略组合,如电话号码…...
策略模式 vs. 工厂模式:对比与分析
相同点 解耦思想 两者都通过接口/抽象类将实现与调用方解耦,降低模块间的直接依赖。 符合开闭原则 新增策略或产品时,只需扩展新类,无需修改已有代码。 封装变化 策略模式封装算法的变化,工厂模式封装对象创建的变化。 不同…...
RK3568 I2C底层驱动详解
前提须知:I2C协议不懂的话就去看之前的内容吧,这个文章需要读者一定的基础。 RK3568 I2C 简介 RK3568 支持 6 个独立 I2C: I2C0、I2C1、I2C2、I2C3、I2C4、I2C5。I2C 控制器支持以下特性: ① 兼容 i2c 总线 ② AMBA APB 从接口 ③ 支持 I2C 总线主模式…...
【大语言模型_8】vllm启动的模型通过fastapi封装增加api-key验证
背景: vllm推理框架启动模型不具备api-key验证。需借助fastapi可以实现该功能 代码实现: rom fastapi import FastAPI, Header, HTTPException, Request,Response import httpx import logging# 创建 FastAPI 应用 app FastAPI() logging.basicConfig(…...
hadoop-HDFS操作
1. 使用的是hadoop的用户登录到系统,那么 cd ~ 是跳转到/home/hadoop下。 2. 在操作hdfs时,需要在hadoop用户下的/usr/local/hadoop,此时是在根目录下。 cd /usr/local/hadoop或者cd / cd usr/local/hadoop 3. 回到Linux的操作目录 我们把…...
Mysql 安装教程和Workbench的安装教程以及workbench的菜单栏汉化
Mysql 安装教程和Workbench的安装教程 详细请参考我的文件 Mysql 安装教程和Workbench的安装教程 或者下载我的资源Mysql 安装教程和Workbench的安装教程 汉化菜单 英文版菜单文件:下载链接 汉化版菜单文件:下载链接 默认情况下,安…...
失物招领|校园失物招领系统|基于Springboot的校园失物招领系统设计与实现(源码+数据库+文档)
校园失物招领系统目录 目录 基于Springboot的校园失物招领系统设计与实现 一、前言 二、系统功能设计 三、系统实现 1、 管理员功能实现 (1) 失物招领管理 (2) 寻物启事管理 (3) 公告管理 (4) 公告类型管理 2、用户功能实现 (1) 失物招领 (2) 寻物启事 (3) 公告 …...
一条不太简单的TEX学习之路
目录 rule raisebox \includegraphics newenviro 、\vspace \stretch \setlength 解释: 总结: 、\linespread newcommand \par 小四 \small simple 、mutiput画网格 解释: 图案解释: xetex pdelatex etc index 报…...
如何为AI开发选择合适的服务器?
选择适合的服务器可以为您的AI项目带来更高的效率,确保最佳性能、可扩展性和可靠性,从而实现无缝的开发与部署。 选择适合的AI开发服务器可能并不容易。您需要一台能够处理大量计算和大型数据集的服务器,同时它还需要符合您的预算并易于管理…...
doris:审计日志
Doris 提供了对于数据库操作的审计能力,可以记录用户对数据库的登陆、查询、修改操作。在 Doris 中,可以直接通过内置系统表查询审计日志,也可以直接查看 Doris 的审计日志文件。 开启审计日志 通过全局变量 enable_audit_plugin 可以随时…...
CSS中的transition与渐变
目录 一、CSS transition 1. 核心属性 简写语法 2. 子属性详解 2.1 transition-property 2.2 transition-duration 2.3 transition-timing-function 2.4 transition-delay 3. 使用场景示例 3.1 悬停效果(Hover) 3.2 展开/收起动画 3.3 动态移…...
AI + 医疗 Qwq大模型离线本地应用
通义千问Qwq-32b-FP16可用于社区医院、乡镇卫生院、诊所等小型医疗机构,替代专业合理用药系统,作为药品知识库,实现以下功能: 药品信息智能查询:检索药品的详细说明书、适应症、禁忌症、不良反应及药物相互作用等关键信…...
大数据环境搭建
目录 一:虚拟机:VirtualBox 二:Shell工具:MobaXterm 三:安装脚本 四:JDK和Hadoop 4.1:安装 4.2:启动 4.3:Hadoop可视化访问 4.4:关机 一:虚拟机:VirtualBox Virt…...
七天免登录 为什么不能用seesion,客户端的http请求自动携带cookei的机制(比较重要)涉及HTTP规范
如果是七天免登录,和session肯定没关系,因为session不能持久化,主要是客户端一旦关闭,seesion就失效了/// 所以必须是能持久化的,这就清晰了,要莫在的服务器保存,要摸在客户端设置 cook机制 1. 使用Cookie实现七天免登录 前端(登…...
从PGC到AIGC:海螺AI多模态内容生成系统的技术革命
一、内容生产的范式迁移:从PGC到AIGC的进化之路 在数字内容生产的历史长河中,人类经历了三次重大范式转变:专业生成内容(PGC)的工业化生产、用户生成内容(UGC)的全民创作浪潮,以及当…...
常考计算机操作系统面试习题(三上)
目录 1. 为何要引入与设备的无关性?如何实现设备的独立性? 2. 页面置换先进先出算法 3. 页面置换先进先出算法,4个页框 4. 进程优先级调度算法 5. 短作业优先调度策略 6. 平均内存访问时间计算 7. 页式存储和段式存储的物理地址计算 …...
数据结构之双向链表-初始化链表-头插法-遍历链表-获取尾部结点-尾插法-指定位置插入-删除节点-释放链表——完整代码
数据结构之双向链表-初始化链表-头插法-遍历链表-获取尾部结点-尾插法-指定位置插入-删除节点-释放链表——完整代码 #include <stdio.h> #include <stdlib.h>typedef int ElemType;typedef struct node{ElemType data;struct node *next, *prev; }Node;//初化链表…...
一键部署 GPU Kind 集群,体验 vLLM 极速推理
随着 Kubernetes 在大模型训练和推理领域的广泛应用,越来越多的开发者需要在本地环境中搭建支持 GPU 的 Kubernetes 集群,以便进行测试和开发。大家都知道,本地搭建 Kubernetes 集群通常可以使用 Kind(Kubernetes IN Docker&#…...
C/C++蓝桥杯算法真题打卡(Day6)
一、P8615 [蓝桥杯 2014 国 C] 拼接平方数 - 洛谷 方法一:算法代码(字符串分割法) #include<bits/stdc.h> // 包含标准库中的所有头文件,方便编程 using namespace std; // 使用标准命名空间,避免每次调用…...
【C++】入门
1.命名空间 1.1 namespace的价值 在C/C中,变量,函数和后面要学到的类都是大量存在的,这些变量,函数和类的名称将存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,…...
CUDA 学习(2)——CUDA 介绍
GeForce 256 是英伟达 1999 年开发的第一个 GPU,最初用作显示器上渲染高端图形,只用于像素计算。 在早期,OpenGL 和 DirectX 等图形 API 是与 GPU 唯一的交互方式。后来,人们意识到 GPU 除了用于渲染图形图像外,还可以…...
构建自定义MCP天气服务器:集成Claude for Desktop与实时天气数据
构建自定义MCP天气服务器:集成Claude for Desktop与实时天气数据 概述 本文将指导开发者构建一个MCP(Model Control Protocol)天气服务器,通过暴露get-alerts和get-forecast工具,为Claude for Desktop等客户端提供实时天气数据支持。该方案解决了传统LLM无法直接获取天气…...