类的介绍
在上一章中,我们介绍了结构体(结构体、成员和成员选择的介绍),并讨论了它们如何将多个成员变量捆绑到一个对象中,以便作为一个整体进行初始化和传递。换句话说,结构体为存储和移动相关数据值提供了一个方便的包装。
考虑以下结构体:
#include <iostream>
struct Date
{
int day{}; // 日
int month{}; // 月
int year{}; // 年
};
void printDate(const Date& date)
{
std::cout << date.day << '/' << date.month << '/' << date.year; // 假设日期格式为日/月/年
}
int main()
{
Date date{ 4, 10, 21 }; // 使用聚合初始化
printDate(date); // 可以将整个结构体传递给函数
return 0;
}
在上述示例中,我们创建了一个Date
对象,并将其传递给一个打印日期的函数。该程序输出:
4/10/21
提醒:在这些教程中,我们所有的结构体都是聚合体。我们在第13.8课——结构体聚合初始化中讨论了聚合体。
尽管结构体非常有用,但在尝试构建大型复杂程序(尤其是由多个开发人员共同开发的程序)时,结构体存在一些不足,可能会带来挑战。
类不变量问题
或许结构体最大的问题是它们没有提供一种有效的方式来记录和强制执行类不变量。在第9.6课——断言和静态断言中,我们将不变量定义为“在某个组件执行期间必须为真的条件”。
在类类型(包括结构体、类和联合体)的上下文中,类不变量是一个条件,它必须在整个对象生命周期内为真,以便对象保持在有效状态。如果对象的类不变量被违反,则该对象处于无效状态,进一步使用该对象可能会导致意外或未定义的行为。
关键洞察:使用类不变量被违反的对象可能会导致意外或未定义的行为。
首先,考虑以下结构体:
struct Pair
{
int first {}; // 第一个值
int second {}; // 第二个值
};
first
和second
成员可以独立地设置为任何值,因此Pair
结构体没有不变量。
现在考虑以下几乎相同的结构体:
struct Fraction
{
int numerator { 0 }; // 分子
int denominator { 1 }; // 分母
};
从数学上我们知道,分母为0的分数在数学上是未定义的(因为分数的值是分子除以分母,而除以0在数学上是未定义的)。因此,我们希望确保Fraction
对象的denominator
成员永远不会被设置为0。如果它被设置为0,则该Fraction
对象处于无效状态,进一步使用该对象可能会导致未定义的行为。
例如:
#include <iostream>
struct Fraction
{
int numerator { 0 }; // 分子
int denominator { 1 }; // 分母(类不变量:不应为0)
};
void printFractionValue(const Fraction& f)
{
std::cout << f.numerator / f.denominator << '\n';
}
int main()
{
Fraction f { 5, 0 }; // 创建一个分母为0的Fraction对象
printFractionValue(f); // 导致除以零错误
return 0;
}
在上述示例中,我们使用注释来记录Fraction
的类不变量。我们还提供了一个默认成员初始化器,以确保如果用户没有提供初始化值,则denominator
被设置为1。这确保了如果用户决定值初始化一个Fraction
对象,我们的Fraction
对象将是有效的。这是一个不错的开始。
但没有什么能阻止我们明确违反这个类不变量:当我们创建Fraction f
时,我们使用聚合初始化将denominator
显式初始化为0。虽然这不会立即引发问题,但我们的对象现在处于无效状态,进一步使用该对象可能会导致意外或未定义的行为。
正如我们在后续调用printFractionValue(f)
时所看到的:程序因除以零错误而终止。
顺便说一下:
一个小的改进是在printFractionValue
函数体的开头添加assert(f.denominator != 0);
。这为代码增加了文档价值,并使违反的前置条件更加明显。然而,从行为上来说,这并没有真正改变什么。我们真正希望在问题的源头(成员被初始化或分配了一个坏值时)捕捉这些问题,而不是在下游的某个地方(当坏值被使用时)。
鉴于Fraction
示例的相对简单性,避免创建无效的Fraction
对象应该不会太难。然而,在一个使用许多结构体、具有许多成员的结构体,或者成员之间具有复杂关系的结构体的更复杂的代码库中,理解哪些值的组合可能会违反某些类不变量可能并不那么明显。
更复杂的类不变量
Fraction
的类不变量是一个简单的例子——分母成员不能为0。这在概念上很容易理解,也不太难避免。
当结构体的成员必须具有相关值时,类不变量变得更加具有挑战性。
#include <string>
struct Employee
{
std::string name { }; // 员工姓名
char firstInitial { }; // 应始终保存`name`的第一个字符(或`0`)
};
在上述(设计不佳的)结构体中,成员firstInitial
中存储的字符值应始终与name
的第一个字符匹配。
当初始化一个Employee
对象时,用户有责任确保类不变量得以维护。如果name
被赋予了一个新值,用户也有责任确保firstInitial
得到更新。这种关联对于使用Employee
对象的开发人员来说可能并不明显,即使他们意识到了,他们也可能忘记去做。
即使我们编写函数来帮助我们创建和更新Employee
对象(确保firstInitial
始终从name
的第一个字符设置),我们仍然依赖用户意识到并使用这些函数。
简而言之,依赖对象的用户来维护类不变量很可能会导致问题代码。
关键洞察:依赖对象的用户来维护类不变量很可能会导致问题。
理想情况下,我们希望使我们的类类型更加坚固,以便对象要么不能被置于无效状态,要么能够立即发出信号(而不是让未定义行为在未来某个随机时刻发生)。
作为聚合体的结构体,它们并没有解决这个问题的优雅机制。
类的介绍
在开发C++时,Bjarne Stroustrup希望引入一些功能,使开发人员能够创建更直观的程序定义类型。他对找到一些优雅的解决方案也很感兴趣,这些方案可以解决大型复杂程序中常见的陷阱和维护挑战(例如前面提到的类不变量问题)。
凭借他在其他编程语言(尤其是Simula,第一个面向对象的编程语言)的经验,Bjarne确信可以开发出一种通用且功能强大的程序定义类型,几乎可以用于任何事情。为了向Simula致敬,他将这种类型称为类。
与结构体一样,类是一种程序定义的复合类型,可以有多种类型的多个成员变量。
关键洞察:从技术角度来看,结构体和类几乎完全相同——因此,任何使用结构体实现的示例都可以使用类来实现,反之亦然。然而,从实际角度来看,我们使用结构体和类的方式不同。
我们在第14.5课——公共和私有成员以及访问说明符中讨论了结构体和类的技术和实际差异。
相关内容:我们在第14.8课——数据隐藏(封装)的好处中讨论了类如何解决不变量问题。
定义类
由于类是一种程序定义的数据类型,因此在使用之前必须定义它。类的定义方式与结构体类似,只是我们使用class
关键字而不是struct
。例如,这是一个简单员工类的定义:
class Employee
{
int m_id {}; // 员工ID
int m_age {}; // 员工年龄
double m_wage {}; // 员工工资
};
相关内容:我们在即将发布的第14.5课——公共和私有成员以及访问说明符中讨论了为什么类的成员变量通常以“m_”为前缀。
为了展示类和结构体可以多么相似,以下程序与我们在本课开头展示的程序等价,但Date
现在是一个类而不是一个结构体:
#include <iostream>
class Date // 我们将struct改为class
{
public: // 并添加了这一行,这被称为访问说明符
int m_day{}; // 并为每个成员名称添加了“m_”前缀
int m_month{};
int m_year{};
};
void printDate(const Date& date)
{
std::cout << date.m_day << '/' << date.m_month << '/' << date.m_year;
}
int main()
{
Date date{ 4, 1