类简介
1. 面向对象编程简介
到目前为止,我们一直在做一种称为过程编程的编程。在 过程式编程(procedural programming) 中,重点是创建实现程序逻辑的“过程”(在 C++ 中称为函数)。我们将数据对象传递给这些函数,这些函数对数据执行操作,然后可能返回一个结果供调用者使用。
在面向对象的编程(object-oriented programming)(通常缩写为 OOP)中,重点是创建包含属性和一组明确定义的行为的程序定义的数据类型。OOP 中的术语 “object” 指的是我们可以从此类类型实例化的对象。示例:
1 |
|
2. 类简介
也许结构体(struct)最大的难点在于它们没有提供有效的方法来记录和强制执行类不变性(class invariants)。在类类型(包括结构体、类和联合体)的上下文中,类不变性是指在对象的整个生命周期中必须为真的条件,以确保对象保持在有效状态中。当一个对象的类不变性被破坏时,我们称该对象处于无效状态,进一步使用该对象可能会导致意外或未定义行为。考虑以下内容:
1 | struct Fraction |
我们从数学中知道,分母为 0
的分数在数学上是未定义的(因为分数的值是其分子除以分母 – 而除以 0
在数学上是未定义的)。因此,我们希望确保 Fraction 对象的denominator
成员永远不会设置为 0
。如果是,则该 Fraction 对象处于无效状态,并且进一步使用该对象可能会导致未定义行为。例如:
1 |
|
当我们创建 Fraction f
时,我们使用聚合初始化来显式地将分母初始化为 0
。虽然这不会立即导致问题,但我们的对象现在处于无效状态,进一步使用该对象可能会导致意外或未定义行为。
在开发 C++ 时,Bjarne Stroustrup 希望引入一些功能,使开发人员能够创建可以更直观地使用的程序定义类型。他还对寻找优雅的解决方案来解决困扰大型复杂程序的一些常见陷阱和维护挑战(例如前面提到的类不变问题)感兴趣。借鉴他在其他编程语言(特别是 Simula,第一种面向对象的编程语言)方面的经验,Bjarne 确信可以开发一种程序定义的类型,这种类型既通用又强大,几乎可以用于任何用途。为了向 Simula 致敬,他将这种类型称为 Class。与结构一样,类是程序定义的复合类型,可以具有许多不同类型的成员变量。
由于类是程序定义的数据类型,因此必须先定义该类,然后才能使用该类。类的定义与结构体类似,不同之处在于我们使用 class
关键字而不是 struct
。例如,下面是一个简单 employee 类的定义:
1 | class Employee |
大多数 C++ 标准库都是类
3. 成员函数
除了具有成员变量之外,类类型(包括结构体、类和联合)还可以有自己的函数!属于类类型的函数称为成员函数。不是成员函数的函数称为非成员函数(或偶尔称为自由函数)。
在其他面向对象的语言(如 Java 和 C#)中,这些称为方法。尽管 C++ 中没有使用术语“方法”,但首先学习其他语言之一的程序员仍然可以使用该术语。
成员函数必须在类类型定义中声明,并且可以在类类型定义内部或外部定义。提醒一下,定义也是一个声明,所以如果我们在类中定义一个成员函数,那也算作一个声明。
一个成员函数的示例:
1 | // Member function version |
在类类型定义中定义的成员函数是隐式内联的,因此,如果类类型定义包含在多个代码文件中,则不会导致违反单定义规则。
在上述示例中,我们调用了 today.print()
。这种语法使用了成员选择运算符 (.
) 来选择要调用的成员函数,这与我们访问成员变量的方式保持一致(例如 today.day = 16;
)。所有(非静态的)成员函数都必须通过该类类型的对象来调用。在这个例子中,today
就是调用 print()
的对象。需要注意的是,在成员函数的情况下,我们不需要将 today
作为参数传递。调用成员函数的对象会被隐式地传递给成员函数。正因为如此,调用成员函数的对象通常被称为隐式对象。换句话说,当我们调用 today.print()
时,today
是隐式对象,它会被隐式传递给 print()
成员函数。
下面是一个成员函数稍微复杂一些的示例:
1 |
|
输出:
1 | Joe kisses Kate |
首先,我们定义两个 Person
结构体 joe
和 kate
。接下来,我们调用 joe.kisses(kate)
。Joe
是这里的隐式对象,而 Kate
作为显式参数传递。当 kisses()
成员函数执行时,标识符name
不使用成员选择运算符 (.),因此它引用隐式对象,即 joe
。所以这解析为 joe.name
。person.name
使用成员选择运算符,因此它不引用隐式对象。由于 person
是 kate
的引用,因此解析为 kate.name
。
非成员必须在使用之前声明。但是,在类定义中,此限制不适用:您可以在声明成员变量和成员函数之前访问它们。这意味着您可以按自己喜欢的任何顺序定义成员变量和成员函数!例如:
1 | struct Foo |
数据成员按声明顺序初始化。如果数据成员的初始化访问另一个数据成员,而该数据成员直到稍后才声明(因此尚未初始化),则初始化将导致未定义行为。示例:
1 | struct Bad |
为了允许以任何顺序定义数据成员和成员函数,编译器采用了一种巧妙的技巧。当编译器遇到类定义中定义的成员函数时:
-
成员函数是隐式前向声明的。
-
成员函数定义在类定义结束后立即移动。
这样,当编译器实际编译成员函数定义时,它已经看到了完整的类定义(包含所有成员的声明)!示例当编译器遇到以下情况时:
1 | struct Foo |
它将编译等效的以下内容:
1 | struct Foo |
就像非成员函数一样,成员函数可以重载,只要每个成员函数都可以区分即可。示例:
1 |
|
这将打印出:
1 | 2020/10/14 |
在 C 语言中,结构体只有数据成员,没有成员函数。在 C++ 中,在设计类时,Bjarne Stroustrup 花费了一些时间考虑是否应授予结构体(从 C 继承)具有成员函数的能力。经过考虑,他决定他们应该这样做。
在现代 C++ 中,结构体具有成员函数是可以的。这不包括构造函数,构造函数是一种特殊类型的成员函数。具有构造函数的类类型不再是聚合,我们希望结构保持聚合。
可以创建没有数据成员的类类型(例如,只有成员函数的类类型)。也可以实例化此类类型的对象:
1 |
|
但是,如果类类型没有任何数据成员,则使用类类型可能有点大材小用。在这种情况下,请考虑改用命名空间。
4. 常量类对象和常量成员函数
类类型对象(结构体、类和联合体)也可以通过使用 const
关键字来设为常量。此类对象也必须在创建时进行初始化。示例:
1 | struct Date |
初始化常量类类型对象后,不允许任何修改对象数据成员的尝试,因为这会违反对象的常量性(const-ness)。这包括直接更改成员变量(如果它们是公共的),或调用设置成员变量值的成员函数。示例:
1 | struct Date |
常量对象不能调用非常量成员函数,以下示例会发生编译错误:
1 |
|
即使 print()
不尝试修改成员变量,我们对 today.print()
的调用仍然是常量冲突。发生这种情况是因为 print()
成员函数本身未声明为常量。编译器不允许我们在常量对象上调用非常量成员函数。
为了解决上述问题,我们需要将 print()
设为常量成员函数。常量成员函数是一个成员函数,它保证它不会修改对象或调用任何非常量成员函数(因为它们可能会修改对象)。将 print()
设为常量成员函数很容易 – 我们只需将 const
关键字附加到函数原型中,在参数列表之后,但在函数体之前:
1 |
|
常量成员函数如果尝试更改数据成员或调用非常量成员函数会发生编译错误,示例:
1 | struct Date |
常量成员函数 (const
member functions) 可以像平常一样修改非成员(例如局部变量和函数参数),并调用非成员函数。const
仅对类的成员生效。
可以在非常量对象上调用常量成员函数:
1 |
|
最后,尽管这种做法并不常见,但可以通过函数重载为同一个成员函数提供一个 const
和一个非 const
版本。这种方法可行的原因在于,const
修饰符被视为函数签名的一部分,因此仅在 const
性质上有所不同的两个函数会被视为不同的函数。
1 |
|
输出:
1 | non-const |
5. 公共和私有员及访问说明符
类类型的每个成员都有一个称为访问级别的属性,用于确定谁可以访问该成员。C++ 有三种不同的访问级别:public、private 和 protected。这里我们将介绍两个常用的访问级别:public 和 private。每当访问成员时,编译器都会检查该成员的访问级别是否允许访问该成员。如果不允许访问,编译器将生成编译错误。此访问级别系统有时非正式地称为访问控制。
具有 public 访问级别的成员称为公共成员。公共成员是类类型的成员,对它们的访问方式没有任何限制。默认情况下,结构体的所有成员都是公共成员。
具有 private 访问级别的成员称为私有成员。私有成员是类类型的成员,只能由同一类的其他成员访问。默认情况下,类的成员是私有成员。
在 C++ 中,以“m_”前缀开头命名私有数据成员是一种常见约定。
默认情况下,结构体(和联合体)的成员是公共的,而类的成员是私有的。但是,我们可以使用访问说明符显式设置成员的访问级别。访问说明符设置说明符后面的所有成员的访问级别。C++ 提供了三个访问说明符:public:
、private:
和 protected:
。示例:
1 | class Date |
以下是不同访问权限级别的快速摘要表:
访问级别 | 访问说明符 | 成员访问 | 派生类访问 | 公共访问 |
---|---|---|---|---|
公共(Public) | public: |
是 | 是 | 是 |
受保护(Protected) | protected: |
是 | 是 | 否 |
私有(Private) | private: |
是 | 否 | 否 |
一个类类型可以按任何顺序使用任意数量的访问说明符,并且可以重复使用(例如,你可以有一些公共成员,然后是一些私有成员,然后是更多的公共成员)。
结构和类的访问级别最佳实践:
-
结构体应完全避免使用访问说明符,这意味着默认情况下,所有结构体成员都将是公共的。我们希望我们的结构是聚合,而聚合只能具有公共成员。使用
public:
访问说明符与默认值是多余的,使用private:
或protected:
会使结构成为非聚合。 -
类通常应仅具有私有(或受保护)数据成员(通过使用默认的私有访问级别或
private:
(或protected:
)访问说明符) -
类通常具有公共成员函数(因此在创建对象后,公共空间可以使用这些成员函数)。但是,如果成员函数不打算供公众使用,则有时它们会设为私有(或受保护)。
C++ 访问级别的一个细微差别经常被忽视或误解,即对成员的访问是基于每个类定义的,而不是基于每个对象定义的。你已经知道成员函数可以直接访问私有成员(隐式对象的)。但是,由于访问级别是按类而不是按对象进行的,因此成员函数还可以直接访问范围内相同类类型的任何其他对象的私有成员。让我们用一个例子来说明这一点:
1 |
|
这打印出:
1 | Joe kisses Kate |
首先,m_name
被设为私有(private),因此它只能被 Person
类的成员访问(而不能被公众访问)。其次,因为我们的类具有私有成员,所以它不是一个聚合,我们不能使用聚合初始化来初始化我们的 Person 对象。作为解决方法,我们创建了一个名为 setName()
的公共成员函数,它允许我们为 Person 对象分配名称。第三,因为 kisses()
是一个成员函数,所以它可以直接访问私有成员m_name
。但是,您可能会惊讶地发现它也可以直接访问 p.m_name
!这之所以有效,是因为 p
是一个 Person
对象,而 kisses()
可以访问范围内任何 Person
对象的私有成员!
6. 访问函数
访问函数是一个普通的公共成员函数,其工作是检索或更改私有成员变量的值。访问函数有两种形式:
-
Getter(有时也称为访问器,accessor)是公共成员函数,用于返回私有成员变量的值。
-
Setter(有时也称为修改器,mutator)是公共成员函数,用于设置私有成员变量的值。
Getter 通常是常量,因此可以在常量和非常量对象上调用它们。setter 应该是非常量的,以便它们可以修改数据成员。示例:
1 |
|
输出:
1 | The year is: 2021 |
命名访问函数没有通用约定。但是,有一些命名约定比其他约定更受欢迎:
-
以 “get” 和 “set” 为前缀:
1
2int getDay() const { return m_day; } // getter
void setDay(int day) { m_day = day; } // setter -
无前缀:
1
2int day() const { return m_day; } // getter
void day(int day) { m_day = day; } // setter -
“set” 前缀:
1
2int day() const { return m_day; } // getter
void setDay(int day) { m_day = day; } // setter
你选择以上哪一项是个人喜好问题。但是,强烈建议对 setter 使用 “set” 前缀。getter 可以使用 “get” 前缀或不使用前缀。
getter 应提供对数据的 “只读” 访问权限。因此,最佳做法是,它们应按值(如果复制成员的成本较低)或 const 左值引用(如果创建成员的开销较高)返回。
7. 返回对数据成员的引用的成员函数
之前介绍了按引用返回。特别是,我们注意到,“通过引用返回的对象必须在函数返回后存在”。这意味着我们不应该通过引用返回局部变量,因为在局部变量被销毁后,引用将悬空。但是,通常可以通过引用返回通过引用传递的函数参数或具有静态持续时间的变量(静态局部变量或全局变量),因为它们通常在函数返回后不会被销毁。成员函数也可以按引用返回,并且它们遵循相同的规则。
请考虑以下示例:
1 |
|
在此示例中,getName()
访问函数按值返回 std::string m_name
。虽然这是最安全的做法,但这也意味着每次调用 getName()
时都会制作一个昂贵的 m_name
副本。由于访问函数往往被大量调用,因此这通常不是最佳选择。
成员函数还可以通过 (const) 左值引用返回数据成员。数据成员的生存期与包含它们的对象相同。由于成员函数始终在对象上调用,并且该对象必须存在于调用方的范围内,因此成员函数通过 (const) 左值引用返回数据成员通常是安全的(因为当函数返回时,通过引用返回的成员仍将存在于调用方的范围内)。让我们更新上面的示例,以便 getName()
通过 const 左值引用返回 m_name
:
1 |
|
现在,当调用 joe.getName()
时,joe.m_name
通过引用调用者返回,从而避免了创建副本。然后,调用方使用此引用将joe.m_name
打印到控制台。因为 joe
一直存在于调用方的范围内,直到 main()
函数结束,所以对 joe.m_name
的引用在相同的持续时间内也有效。
对于 getter,使用 auto
让编译器从返回的成员中推断返回类型是确保不发生转换的有用方法:
1 |
|
但是,从文档的角度来看,使用 auto
返回类型会掩盖 getter 的返回类型。例如:
1 | const auto& getName() const { return m_name; } // uses `auto` to deduce return type from m_name |
因此,我们通常更喜欢显式返回类型。
有一种情况我们需要小心。在上面的示例中,joe
是一个左值对象,它一直存在到函数结束。因此,joe.getName()
返回的引用在函数结束之前也有效。但是,如果我们的隐式对象是右值(例如某个按值返回的函数的返回值),该怎么办?右值对象在创建它们的完整表达式结束时销毁。销毁右值对象时,对该右值成员的任何引用都将失效并悬空,并且使用此类引用将产生未定义行为。因此,对右值对象成员的引用只能在创建右值对象的完整表达式中安全地使用。让我们探讨一些与此相关的案例:
1 |
|
-
在情况 1 中,我们调用
createEmployee("Frank")
,它返回一个右值Employee
对象。然后,我们在此右值对象上调用getName()
,这将返回对m_name
的引用。然后,立即使用此引用将名称打印到控制台。此时,包含对createEmployee(“Frank")
的调用的完整表达式结束,右值对象及其成员被销毁。由于在此点之后,右值对象或其成员均未使用,因此这种情况很好。 -
在情况 2 中,我们遇到了问题。首先,
createEmployee("Garbo")
返回一个右值对象。然后,我们调用getName()
来获取对此右值的m_name
成员的引用。然后,此m_name
成员用于初始化ref
。此时,包含对createEmployee("Garbo")
的调用的完整表达式结束,右值对象及其成员将被销毁。这会让ref
悬空。因此,当我们在后续语句中使用ref
时,我们将访问一个悬空引用,并导致未定义行为。 -
在情况 3 中,我们使用返回的引用来初始化非引用局部变量
val
。这将导致被引用的成员被复制到val
中。初始化后,val
独立于引用存在。因此,当右值对象随后被销毁时,val
不会受到此影响。因此,val
可以在将来的语句中输出而不会出现问题。
最好立即使用返回引用的成员函数的返回值,以避免当隐式对象是右值时出现悬垂引用问题。
由于引用的行为与被引用的对象类似,因此返回非常量引用的成员函数提供对该成员的直接访问(即使该成员是私有的)。例如:
1 |
|
因为 value()
返回对 m_value
的非常量引用,所以调用者能够使用该引用直接访问(并更改m_value
的值)。
8. 数据隐藏(封装)的好处
类类型的接口(也称为类接口)定义类类型的用户如何与类类型的对象交互。由于只能从类类型外部访问公共成员,因此类类型的公共成员构成其接口。因此,由公共成员组成的接口有时称为公共接口。类类型的实现由实际使类按预期运行的代码组成。这包括存储数据的成员变量,以及包含程序逻辑并作成员变量的成员函数的主体。
在编程中,数据隐藏(也称为信息隐藏或数据抽象)是一种技术,用于通过对用户隐藏(使其无法访问)程序定义的数据类型的实现来强制接口和实现的分离。在 C++ 类类型中实现数据隐藏很简单。首先,我们确保类类型的数据成员是私有的(这样用户就不能直接访问它们)。用户已经无法直接访问成员函数主体中的语句,因此我们不需要在那里做任何其他事情。接下来,我们确保成员函数是公共的,以便用户可以调用它们。
8.1 数据隐藏使类更易于使用,并降低了复杂性
要使用封装类,你无需知道它是如何实现的。你只需要了解它的接口:哪些成员函数是公开可用的,它们采用哪些参数,以及它们返回哪些值。 例如:
1 |
|
在这个简短的程序中,我们没有透露如何实现 std::string_view
的细节。我们无法看到 std::string_view
有多少数据成员,它们的名称是什么,或者它们是什么类型。我们不知道 length()
成员函数是如何返回正在查看的字符串的长度的。我们需要知道的是如何初始化 std::string_view
类型的对象,以及 length()
成员函数返回什么。
8.2 数据隐藏允许我们保持不变性
考虑以下程序:
1 |
|
该程序打印:
1 | John has first initial J |
我们的 Employee
结构体具有一个类不变式(class invariant),即 firstInitial
始终应等于 name
的第一个字符。如果这一点被破坏,那么 print()
函数将会发生故障。由于 name
成员是公共的,因此 main()
中的代码能够将 e.name
设置为 “Mark”
,并且不会更新 firstInitial
成员。我们的不变量被打破了,我们对 print()
的第二次调用没有按预期工作。让我们重写这个程序,将我们的成员变量设为私有,并公开一个成员函数来设置 Employee 的名称:
1 |
|
该程序现在按预期工作:
1 | John has first initial J |
8.3 数据隐藏使我们能够更好地进行错误检测(和处理)
在上面的程序中,m_firstInitial
必须与 m_name
的第一个字符匹配的不变量存在,因为m_firstInitial
独立于 m_name
存在。我们可以通过将数据成员 m_firstInitial
替换为返回第一个首字母的成员函数来删除这个特定的不变量:
1 |
|
此程序还有一个类不变式,m_name
不应该是一个空字符串(因为每个 Employee
都应该有一个 name)。如果 m_name
设置为空字符串,则不会立即发生任何错误。但是,如果随后调用 firstInitial()
,则 std::string
的 front()
成员将尝试获取空字符串的第一个字母,这会导致未定义行为。理想情况下,我们希望防止 m_name
永远为空。如果用户对 m_name
成员具有公共访问权限,则他们只需设置 m_name = :""
,而我们无法阻止这种情况发生。但是,由于我们强制用户通过公共接口函数 setName()
设置 m_name
,因此我们可以让 setName()
验证用户是否传入了有效名称。如果 name
非空,那么我们可以将其分配给 m_name
。如果 name
是一个空字符串,我们可以处理这个错误。
8.4 数据隐藏可以在不破坏现有程序的情况下更改实施细节
以下是使用函数访问m_value1
的此类的原始版本的封装版本:
1 |
|
现在,让我们将类的实现改回数组:
1 |
|
因为我们没有更改类的公共接口,所以使用该接口的程序根本不需要更改,并且仍然运行相同。
8.5 非成员函数优于成员函数
在 C++ 中,如果一个函数可以合理地实现为非成员函数,优先将其实现为非成员函数,而不是成员函数。这样做有许多好处:
-
非成员函数不属于类的接口的一部分。因此,类的接口会更小、更简洁,使类更容易理解。
-
非成员函数可以强化封装性,因为这些函数必须通过类的公共接口来工作。这样可以避免因为方便而直接访问类的内部实现的诱惑。
-
在更改类的实现时,无需考虑非成员函数(只要接口没有以不兼容的方式更改)。
-
非成员函数通常更容易调试。
-
包含应用程序特定数据和逻辑的非成员函数可以与类的可重用部分分离。
让我们用三个类似的例子来说明这一点,从最差到最好的顺序是:
1 |
|
以上是最糟糕的版本。print()
成员函数在已存在 flavor 的 getter 时直接访问 m_flavor
。如果类实现曾经更新,则可能还需要修改 print()
。print()
打印的字符串是特定于应用程序的(使用此类的另一个应用程序可能想要打印其他内容,这将需要克隆或修改该类)。
1 |
|
上面的版本更好,但仍然不是很好。print()
仍然是一个成员函数,但至少它现在不直接访问任何数据成员。如果类实现曾经被更新,则需要评估 print()
以确定它是否需要更新(但不需要)。但是该函数仍然对事物的打印方式施加了约束。如果这不能满足给定应用程序的需求,则需要添加另一个函数。
1 |
|
上面的版本是最好的。print()
现在是非成员函数。它不直接访问任何成员。如果类实现发生变化,则根本不需要计算 print()
。此外,每个应用程序都可以提供自己的 print()
函数,该函数可以准确地打印该应用程序想要的方式。
8.6 类声明的顺序
有两派思想:
-
首先列出你的私有成员,然后列出你的公有成员函数。这遵循了使用前声明的传统风格。任何查看你的类代码的人都会看到你在使用数据成员之前是如何定义数据的,这可以更轻松地阅读和理解实现细节。
-
首先列出你的公共成员,然后将你的私人成员放在底部。因为使用你的类的人对公共接口感兴趣,所以把你的公共成员放在最前面会让他们需要的信息放在最前面,而把实现细节(最不重要)放在最后。
在现代 C++ 中,更常见的是推荐使用第二种方法(公共成员优先),尤其是对于将与其他开发人员共享的代码。
9. 构造函数简介
当一个类类型是一个聚合时,我们可以使用聚合初始化来直接初始化类类型:
1 | struct Foo // Foo is an aggregate |
但是,一旦我们将任何成员变量设为私有变量(以隐藏我们的数据),我们的类类型就不再是聚合(因为聚合不能有私有成员)。这意味着我们不能再使用聚合初始化:
1 | class Foo // Foo is not an aggregate (has private members) |
构造函数是在创建非聚合类类型对象后自动调用的特殊成员函数。定义非聚合类类型对象时,编译器会查看它是否可以找到与调用方提供的初始化值(如果有)匹配的可访问构造函数。
-
如果找到可访问的匹配构造函数,则分配对象的内存,然后调用构造函数。
-
如果找不到可访问的匹配构造函数,将生成编译错误。
除了确定如何创建对象之外,构造函数通常执行两个功能:
-
它们通常执行任何成员变量的初始化(通过成员初始化列表)
-
它们可以执行其他 setup 函数 (通过构造函数主体中的语句)。这可能包括错误检查初始化值、打开文件或数据库等。
在构造函数完成执行后,我们说对象已经 “构造” 了,对象现在应该处于一致、可用的状态。
请注意,聚合不允许有构造函数 —— 因此,如果向聚合添加构造函数,它将不再是聚合。
与普通成员函数不同,构造函数对它们必须如何命名有特定的规则:
-
构造函数必须与类同名(大小写相同)。对于模板类,此名称不包括模板参数。
-
构造函数没有返回类型(甚至没有
void
)。
由于构造函数通常是类接口的一部分,因此它们通常是公共的。
基本构造函数示例:
1 |
|
此程序现在将编译并生成结果:
1 | Foo(6, 7) constructed |
构造函数的参数也支持隐式转换。
构造函数需要能够初始化正在构造的对象 – 因此,构造函数不能是常量。
1 |
|
通常,不能在常量对象上调用非常量成员函数。但是,C++ 标准明确规定(根据 class.ctor.general#5)常量不适用于正在构造的对象,并且仅在构造函数结束后生效。
10. 构造函数成员初始化列表
要让构造函数初始化成员,我们使用成员初始化列表来实现。示例:
1 |
|
成员初始化列表定义在构造函数参数之后。它以一个冒号 (:
) 开始,然后列出每个需要初始化的成员,以及该变量的初始化值,各成员之间用逗号分隔。在这里必须使用直接形式的初始化(推荐使用大括号 {}
,但圆括号 ()
也可以)——在此处使用复制初始化(带等号的形式)是行不通的。另外需要注意的是,成员初始化列表不以分号结尾。
此程序将生成以下输出:
1 | Foo(6, 7) constructed |
C++ 提供了很大的自由度,可以根据自己的喜好设置成员初始值设定项列表的格式,因为它不在乎你把冒号、逗号或空格放在哪里。以下样式都是有效的:
1 | Foo(int x, int y) : m_x { x }, m_y { y } |
1 | Foo(int x, int y) : |
1 | Foo(int x, int y) |
建议使用第三种,如果成员初始化列表较短或较简单,所有初始化器可以放在同一行:
1 | Foo(int x, int y) |
成员初始化列表中的成员始终按照它们在类中定义的顺序进行初始化(而不是按照它们在成员初始化列表中的顺序)。为了避免错误,成员初始化列表中的成员变量应按照它们在类中定义的顺序列出。示例:
1 |
|
在这个示例中,即使 m_y
在成员初始化列表中列在第一位,但由于 m_x
首先在类中定义,因此 m_x
首先初始化。m_x
被初始化为 m_y
的值,该值尚未初始化。最后,m_y
初始化为较大的初始化值。
成员可以通过几种不同的方式进行初始化:
-
如果成员在成员初始化列表中列出,则使用该初始化值。
-
如果成员有默认的成员初始化器,则使用该初始化值。
-
否则,成员将进行默认初始化。
这意味着,如果一个成员既有默认的成员初始化器,又在构造函数的成员初始化列表中列出,则成员初始化列表中的值优先。下面是一个显示所有三种初始化方法的示例:
1 |
|
首选使用成员初始化列表来初始化成员,而不是在构造函数的主体中分配值。
当构造函数失败(并且无法恢复)时,引发异常通常是最好的做法
11. 默认构造函数和默认参数
默认构造函数是不接受任何参数的构造函数。通常,这是一个定义的构造函数,没有参数。示例:
1 |
|
输出:
1 | Foo default constructed |
如果类类型具有默认构造函数,则值初始化和默认初始化都将调用默认构造函数。因此,对于这样的类,例如上面示例中的 Foo
类,以下内容本质上是等效的:
1 | Foo foo{}; // value initialization, calls Foo() default constructor |
与所有函数一样,构造函数的最右边的参数可以具有默认参数。示例:
1 |
|
输出:
1 | Foo(0, 0) constructed |
如果构造函数中的所有参数都有默认参数,则该构造函数是默认构造函数(因为它可以在没有参数的情况下调用)。
由于构造函数是函数,因此它们可以重载。也就是说,我们可以有多个构造函数,以便我们可以以不同的方式构造对象:
1 |
|
一个类应该只有一个默认构造函数。如果提供了多个默认构造函数,编译器将无法消除应使用哪个构造函数的歧义:
1 |
|
在上面的例子中,我们实例化没有参数的 foo
,因此编译器将寻找默认构造函数。它将找到两个,并且无法消除应该使用哪个构造函数的歧义。这将导致编译错误。
如果非聚合类类型对象没有用户声明的构造函数,则编译器将生成一个公共默认构造函数(以便该类可以进行值或默认初始化)。此构造函数称为隐式默认构造函数。隐式默认构造函数等效于构造函数主体中没有参数、没有成员初始值设定项列表且没有语句的构造函数。示例:
1 | public: |
如果我们编写的默认构造函数等同于隐式生成的默认构造函数,我们可以改为告诉编译器为我们生成默认构造函数。此构造函数称为显式默认默认构造函数,可以使用 = default
语法生成:
1 |
|
首选显式默认的默认构造函数 (
= default
),而不是具有空正文的默认构造函数。
至少在两种情况下,显式默认的默认构造函数的行为与空的用户定义构造函数不同。
-
当值初始化一个类时,如果该类具有用户定义的默认构造函数,则该对象将被默认初始化。但是,如果该类具有非用户提供的默认构造函数(即隐式定义或使用
= default
定义的默认构造函数),则对象将在默认初始化之前进行零初始化。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class User
{
private:
int m_a; // note: no default initialization value
int m_b {};
public:
User() {} // user-defined empty constructor
int a() const { return m_a; }
int b() const { return m_b; }
};
class Default
{
private:
int m_a; // note: no default initialization value
int m_b {};
public:
Default() = default; // explicitly defaulted default constructor
int a() const { return m_a; }
int b() const { return m_b; }
};
class Implicit
{
private:
int m_a; // note: no default initialization value
int m_b {};
public:
// implicit default constructor
int a() const { return m_a; }
int b() const { return m_b; }
};
int main()
{
User user{}; // default initialized
std::cout << user.a() << ' ' << user.b() << '\n';
Default def{}; // zero initialized, then default initialized
std::cout << def.a() << ' ' << def.b() << '\n';
Implicit imp{}; // zero initialized, then default initialized
std::cout << imp.a() << ' ' << imp.b() << '\n';
return 0;
}输出:
1
2
3782510864 0
0 0
0 0 -
在 C++20 之前,具有用户定义的默认构造函数的类(即使它有一个空的主体)使该类成为非聚合类,而显式默认的默认构造函数则不会。假设该类不是聚合类,则前者将导致该类使用列表初始化而不是聚合初始化。在 C++20 及更高版本中,这种不一致得到了解决,因此两者都使类成为非聚合类。
12. 委托构造函数
构造函数允许将初始化的责任委托给同一类类型的另一个构造函数。这个过程有时被称为构造函数链(constructor chaining),这样的构造函数被称为委托构造函数(delegating constructors)。
要使一个构造函数将初始化委托给另一个构造函数,只需在成员初始化列表中调用该构造函数:
1 |
|
几点说明:
-
委托给另一个构造函数的构造函数不能自己进行任何成员初始化。因此,构造函数要么委托,要么初始化,而不能同时进行。
-
一个构造函数可以委托给另一个构造函数,而后者又委托回第一个构造函数。这会形成一个无限循环,导致程序耗尽栈空间并崩溃。你可以通过确保所有构造函数都解析到一个非委托构造函数来避免这种情况。
默认值有时还可用于将多个构造函数减少为更少的构造函数。例如,通过在 id
参数上放置默认值,我们可以创建一个需要 name 参数但可以选择接受 id 参数的 Employee
构造函数:
1 |
|
13. 临时类对象
临时对象(有时称为匿名对象或未命名对象)是没有名称且仅在单个表达式的持续时间内存在的对象。有两种常见的方法可以创建临时类类型对象:
1 |
|
在情况 2 中,我们告诉编译器构造一个 IntPair
对象,并使用 { 5, 6 }
初始化它。由于此对象没有名称,因此它是临时对象。然后将临时对象传递给函数 print()
的参数 p
。当函数调用返回时,临时对象将被销毁。
在情况 3 中,我们还将创建一个临时 IntPair
对象以传递给函数 print()
。但是,由于我们没有明确指定要构造的类型,编译器将从函数参数中推断出必要的类型 (IntPair
),然后将 { 7, 8 }
隐式转换为 IntPair
对象。
更多示例:
1 | std::string { "Hello" }; // create a temporary std::string initialized with "Hello" |
当函数按值返回时,返回的对象是临时对象。
14. 复制构造函数简介
复制构造函数是一种用于使用同一类型的现有对象来初始化新对象的构造函数。在复制构造函数执行后,新创建的对象应当是作为初始化参数传入的对象的副本。如果你没有为你的类提供复制构造函数,C++ 将为你创建一个公共的隐式复制构造函数。默认情况下,隐式复制构造函数将执行成员逐一初始化。这意味着每个成员都将使用作为初始化参数传入的类中的对应成员进行初始化。我们还可以显式定义自己的复制构造函数。示例:
1 |
|
程序打印:
1 | Copy constructor called |
复制构造函数不应执行除复制对象之外的任何操作。这是因为在某些情况下,编译器可能会对复制构造函数进行优化,从而省略其执行。因此,在大多数情况下,使用隐式复制构造函数。
当一个对象以值传递时,实参会被复制到形参中。当实参和形参是相同的类类型时,复制操作是通过隐式调用复制构造函数完成的。示例:
1 |
|
输出:
1 | Copy constructor called |
在上面的示例中,对 printFraction(f)
的调用是按值传递 f
。调用复制构造函数以将 f
从 main
复制到函数 printFraction()
的 f
参数中。
如果一个类没有复制构造函数,编译器将隐式地为我们生成一个。如果我们愿意,可以使用 = default
语法显式请求编译器为我们创建一个默认的复制构造函数:
1 |
|
偶尔我们会遇到不希望某个类的对象是可复制的。我们可以通过使用 = delete
语法将复制构造函数标记为已删除来防止这种情况:
1 |
|
15. 类的初始化和复制省略
我们讨论了具有基本类型的对象的 6 种基本初始化类型:
1 | int a; // no initializer (default initialization) |
所有这些初始化类型都对具有类类型的对象有效:
1 |
|
复制省略是一种编译器优化技术,它允许编译器删除不必要的对象复制。换句话说,在编译器通常调用复制构造函数的情况下,编译器可以自由重写代码以完全避免调用复制构造函数。当编译器优化了对复制构造函数的调用时,我们说构造函数已被省略。示例:
1 | Something s { Something { 5 } }; |
这通常会被优化为:
1 | Something s { 5 }; // only invokes Something(int), no copy constructor |
16. 转换构造函数和 explicit 关键字
考虑以下示例:
1 |
|
上述示例中,,printFoo
有一个 Foo
参数,但我们传入了一个 int
类型的参数。由于这些类型不匹配,编译器将尝试将 int 值 5
隐式转换为 Foo
对象,以便可以调用该函数。C++ 标准没有具体的规则告诉编译器如何将值转换为(或从)用户定义的类型。相反,编译器会查看我们是否定义了某个函数,可以用来执行这样的转换。这样的函数称为用户定义的转换。在上面的示例中,编译器将找到一个函数,该函数允许它将 int 值 5
转换为 Foo
对象。该函数是 Foo(int)
构造函数。,当调用 printFoo(5)
时,参数 f
是使用 Foo(int)
构造函数(以 5
作为参数)进行复制初始化的!
只能应用一次用户定义的转换。考虑以下示例:
1 |
|
你可能会惊讶地发现此版本无法编译。原因很简单:只能应用一个用户定义的转换来执行隐式转换,而此示例需要两个。首先,我们的 C 样式字符串文字必须转换为 std::string_view
(使用 std::string_view
转换构造函数),然后我们的 std::string_view
必须转换为 Employee
(使用 Employee(std::string_view)
转换构造函数)。有两种方法可以使此示例正常工作:
-
使用
std::string_view
文本:1
2
3
4
5
6
7int main()
{
using namespace std::literals;
printEmployee( "Joe"sv); // now a std::string_view literal
return 0;
} -
显式构造一个
Employee
,而不是隐式创建一个:1
2
3
4
5
6int main()
{
printEmployee(Employee{ "Joe" });
return 0;
}
我们可以使用 explicit 关键字告诉编译器不应将构造函数用作转换构造函数。使构造函数explicit
有两个明显的后果:
-
显式构造函数不能用于执行复制初始化或复制列表初始化。
-
显式构造函数不能用于执行隐式转换(因为它使用复制初始化或复制列表初始化)。
示例:
1 |
|
因为编译器不能再使用 Dollars(int)
作为转换构造函数,所以它找不到将 5
转换为 Dollars
的方法。因此,它将生成编译错误。
显式构造函数仍可用于直接和直接列表初始化:
1 | // Assume Dollars(int) is explicit |
当我们从函数返回一个值时,如果该值与函数的返回类型不匹配,则会发生隐式转换。就像按值传递一样,此类转换不能使用显式构造函数。示例:
1 |
|
也许令人惊讶的是,return { 5 }
被视为转换。
默认情况下,将接受单个参数的任何构造函数设为
explicit
。
17. Constexpr 聚合和类
就像非成员函数一样,成员函数可以通过使用 constexpr
关键字来设为 constexpr。可以在编译时或运行时计算 Constexpr 成员函数。示例:
1 |
|
当使用 p.greater()
初始化 constexpr 变量 g
时,我们会收到编译器错误。尽管 greater()
现在是 constexpr,但 p
仍然不是 constexpr,因此 p.greater()
不是常量表达式。
如果我们需要 p
是 constexpr,让我们把它设为 constexpr:
1 |
|
现在让我们将 Pair
设为非聚合:
1 |
|
此示例与前一个示例几乎相同,只是 Pair
不再是聚合(由于具有私有数据成员和构造函数)。当我们编译这个程序时,我们得到一个编译器错误。在 C++ 中,字面量类型(literal type)是指可以在常量表达式中创建对象的类型。换句话说,除非类型符合字面量类型的要求,否则对象不能是 constexpr。而我们的非聚合类型 Pair
并不符合这个要求。
字面量类型的定义相对复杂,详细总结可以在 cppreference 上找到。然而,值得注意的是,字面量类型包括:
-
标量类型(scalar types)(那些持有单一值的类型,例如基本类型和指针)
-
引用类型(reference types)
-
大多数聚合类型(aggregates)
-
具有 constexpr 构造函数的类
现在我们可以理解为什么 Pair
不是一个字面量类型。当一个类对象被实例化时,编译器会调用构造函数来初始化该对象。而在我们的 Pair
类中,构造函数并不是 constexpr
,因此它无法在编译时被调用。因此,Pair
对象不能是 constexpr
。
解决这个问题很简单:我们也只是将构造函数设为 constexpr
:
1 |
|
如果希望类能够在编译时求值,请将成员函数和构造函数设为 constexpr。