在之前的课程中,我们提到类的成员变量通常被声明为私有。对于第一次学习类的程序员来说,很难理解为什么要这么做。毕竟,将变量声明为私有意味着它们不能被公共代码访问。在最好的情况下,这会增加编写类的工作量。在最坏的情况下,这可能看起来毫无意义(尤其是当我们为私有成员数据提供公共访问函数时)。
这个问题的答案如此基础,以至于我们将用整整一课来讨论这个话题!
让我们先从一个类比开始。
在现代生活中,我们使用许多机械或电子设备。你用遥控器打开/关闭电视。你通过踩油门踏板让汽车前进。你通过拨动开关打开灯。所有这些设备都有一个共同点:它们提供了一个简单的用户界面(一组按钮、踏板、开关等……),让你能够执行关键操作。
这些设备的实际工作方式对你来说是隐藏的。当你按下遥控器上的按钮时,你不需要知道遥控器是如何与电视通信的。当你踩下汽车的油门踏板时,你不需要知道内燃机是如何使车轮转动的。当你拍照时,你不需要知道传感器是如何收集光线并将光线转化为像素图像的。
这种界面与实现的分离极其有用,因为它允许我们在不了解它们的工作原理的情况下使用对象——相反,我们只需要了解如何与它们交互。这大大减少了使用这些对象的复杂性,并增加了我们能够交互的对象数量。
类类型的实现与接口
出于类似的原因,编程中界面与实现的分离也很有用。但首先,让我们定义一下关于类类型的接口和实现的含义。
类类型的接口(也称为类接口)定义了类类型的用户将如何与该类类型的对象交互。由于只有公共成员可以从类类型外部访问,因此类类型的公共成员构成了它的接口。因此,由公共成员组成的接口有时也被称为公共接口。
接口是类作者和类用户之间的一种隐式契约。如果现有的接口被更改,任何使用它的代码都可能出错。因此,确保我们类类型的接口设计良好且稳定(不经常变化)是很重要的。
类类型的实现包括实际使类按预期工作所需的代码。这包括存储数据的成员变量,以及包含程序逻辑并操作成员变量的成员函数的主体。
数据隐藏
在编程中,数据隐藏(也称为信息隐藏或数据抽象)是一种用于通过隐藏(使不可访问)程序定义的数据类型的实现来强制分离界面和实现的技术。
在C++类类型中实现数据隐藏很简单。首先,我们确保类类型的成员变量是私有的(这样用户就不能直接访问它们)。成员函数主体内的语句已经对用户不可直接访问,因此我们那里不需要做任何其他事情。接下来,我们确保成员函数是公共的,这样用户就可以调用它们。
通过遵循这些规则,我们迫使类类型的用户通过公共接口操作对象,并阻止他们直接访问实现细节。
在C++中定义的类应该使用数据隐藏。实际上,标准库提供的所有类都是这么做的。另一方面,结构体不应该使用数据隐藏,因为拥有非公共成员会阻止它们被视为聚合体。
以这种方式定义类需要类作者付出一些额外的工作。并且要求类的用户使用公共接口,而不是直接提供对成员变量的公共访问,这可能看起来比直接提供对成员变量的公共访问更麻烦。但这样做提供了许多有助于鼓励类的可重用性和可维护性的有用好处。我们将在本课的其余部分讨论这些好处。
术语
在编程中,封装一词通常指以下两种情况之一:
- 将一个或多个项目封装在某种容器内。
- 将数据和操作这些数据的函数捆绑在一起。
在C++中,具有数据和公共接口以创建和操作该类对象的类类型是封装的。因为封装是数据隐藏的先决条件,而数据隐藏是一种如此重要的技术,所以按照惯例,封装一词通常也包括数据隐藏。
在本教程系列中,我们将假设所有封装的类都实现了数据隐藏。
数据隐藏使类更易于使用,减少复杂性
要使用封装的类,你不需要知道它的实现方式。你只需要理解它的接口:哪些成员函数是公开可用的,它们接受哪些参数,以及它们返回什么值。
例如:
#include <iostream>
#include <string_view>
int main()
{
std::string_view sv{ "Hello, world!" };
std::cout << sv.length();
return 0;
}
在这个简短的程序中,std::string_view
的实现细节并没有暴露给我们。我们看不到std::string_view
有多少成员变量,它们叫什么名字,或者它们是什么类型。我们不知道length()
成员函数是如何返回被查看字符串的长度的。
而且最好的部分是,我们不需要知道!程序仍然可以正常工作。我们只需要知道如何初始化一个std::string_view
类型的对象,以及length()
成员函数返回什么。
不需要关心这些细节大大减少了程序的复杂性,从而减少了错误。比其他任何原因更重要的是,这是封装的关键优势。
想象一下,如果要使用std::string
、std::vector
或std::cout
,就必须了解它们的实现细节,C++会变得多么复杂!
数据隐藏允许我们维护不变量
在关于类的入门课程(14.2——类的介绍)中,我们介绍了类不变量的概念,这些是在对象的整个生命周期中必须为真的条件,以便对象保持在有效状态。
考虑以下程序:
#include <iostream>
#include <string>
struct Employee // 成员默认为公共
{
std::string name{ "John" };
char firstInitial{ 'J' }; // 应与name的第一个字母匹配
void print() const
{
std::cout << "Employee " << name << " has first initial " << firstInitial << '\n';
}
};
int main()
{
Employee e{}; // 默认为"John"和'J'
e.print();
e.name = "Mark"; // 将员工的名字改为"Mark"
e.print(); // 打印错误的首字母
return 0;
}
该程序输出:
John has first initial J
Mark has first initial J
我们的Employee
结构体有一个类不变量,即firstInitial
应该始终等于name
的第一个字符。如果这个条件不成立,那么print()
函数将无法正常工作。
因为name
成员是公共的,main()
中的代码能够将e.name
设置为"Mark",而firstInitial
成员没有更新。我们的不变量被破坏了,第二次调用print()
的结果并不如预期。
当我们允许用户直接访问类的实现时,他们就负责维护所有不变量——他们可能不会这么做(或者根本不会做)。将这个负担加在用户身上会增加很多复杂性。
让我们重写这个程序,将成员变量声明为私有,并提供一个成员函数来设置Employee
的名字:
#include <iostream>
#include <string>
#include <string_view>
class Employee // 成员默认为私有
{
std::string m_name{};
char m_firstInitial{};
public:
void setName(std::string_view name)
{
m_name = name;
m_firstInitial = name.front(); // 使用std::string::front()获取`name`的第一个字母
}
void print() const
{
std::cout << "Employee " << m_name << " has first initial " << m_firstInitial << '\n';
}
};
int main()
{
Employee e{};
e.setName("John");
e.print();
e.setName("Mark");
e.print();
return 0;
}
该程序现在按预期工作:
John has first initial J
Mark has first initial M
从用户的角度来看,唯一的改变是他们不再直接为name
赋值,而是调用成员函数setName()
,它负责同时设置m_name
和m_firstInitial
。用户摆脱了维护这个不变量的负担!
数据隐藏允许我们更好地检测(和处理)错误
在上面的程序中,m_firstInitial
必须与m_name
的第一个字符匹配的不变量存在是因为m_firstInitial
独立于m_name
存在。我们可以通过将数据成员m_firstInitial
替换为一个返回首字母的成员函数来消除这个特定的不变量:
#include <iostream>
#include <string>
class Employee
{
std::string m_name{ "John" };
public:
void setName(std::string_view name)
{
m_name = name;
}
// 使用std::string::front()获取`m_name`的第一个字母
char firstInitial() const { return m_name.front(); }
void print() const
{
std::cout << "Employee " << m_name << " has first initial " << firstInitial() << '\n';
}
};
int main()
{
Employee e{}; // 默认为"John"
e.setName("Mark");
e.print();
return 0;
}
然而,这个程序还有另一个类不变量。花一点时间看看你能否确定