14-8 数据隐藏(封装)的优势
在之前的课程(14.5节——公共与私有成员及访问限定符)中,我们提到类的成员变量通常会被设为私有。初次接触类的程序员往往难以理解这种做法的意义——毕竟将变量设为私有意味着它们无法被外部访问。轻则增加类编写工作量,重则显得毫无意义(尤其当我们为私有成员数据提供公共访问函数时)。
这个问题如此基础,我们将专门用一节课来探讨!
让我们从一个比喻开始。
现代生活中,我们接触着众多机械或电子设备。你用遥控器开关电视,踩油门使汽车前进,拨动开关点亮灯具。这些设备有个共同点:它们提供简易的用户界面(按钮、踏板、开关等),让你能执行关键操作。
这些设备内部的运作机制对你而言是隐藏的。按下遥控器按钮时,你无需了解遥控器如何与电视通信;踩下油门踏板时,你无需知道内燃机如何驱动车轮转动;拍摄照片时,你无需理解传感器如何收集光线并将其转化为像素图像。
这种界面与实现的分离极具价值——它让我们无需理解物体运作原理,只需掌握交互方式。这极大降低了使用复杂度,并拓展了我们能够交互的物体范围。
类类型的实现与接口
出于类似原因,在编程中分离接口与实现具有重要意义。但首先,让我们明确类型的接口与实现具体指什么。
类类型的接口interface(也称类接口class interface)定义了用户如何与该类型的对象交互。由于仅公共成员可被类型的外部访问,因此类型的公共成员共同构成其接口。因此,由公共成员组成的接口有时被称为公共接口public interface。
接口是类作者与类使用者之间隐含的契约。若现有接口发生变更,所有使用该接口的代码都可能失效。因此确保类型的接口设计合理且稳定(避免频繁变更)至关重要。
类类型的实现implementation包含使类按预期行为的代码,涵盖存储数据的成员变量,以及包含程序逻辑并操作成员变量的成员函数主体。
数据隐藏
在编程中,数据隐藏data hiding (也称为信息隐藏information hiding或数据抽象data abstraction)是一种通过隐藏(使不可访问)程序定义的数据类型的实现来强制分离接口与实现的技术。
在C++类类型中实现数据隐藏很简单。首先,我们确保类类型的数据成员为私有(因此用户无法直接访问它们)。成员函数体内的语句本就无法被用户直接访问,因此无需额外处理。其次,确保成员函数为public类型,以便用户调用。
遵循这些规则后,我们强制类型的用户通过公共接口操作对象,并阻止其直接访问实现细节。
C++中定义的类应采用数据封装。事实上,标准库提供的所有类都遵循此原则。而结构体则不应使用数据封装,因为非公开成员会妨碍其作为聚合体的处理。
此类定义方式对类作者要求更高。强制用户通过公共接口操作看似比直接访问成员变量更繁琐。但这种做法能带来诸多有益效果,有助于提升类的可复用性和可维护性。本节后续内容将详细探讨这些优势。
术语说明:
在编程领域,封装encapsulation通常指以下两种含义之一:
- 将一个或多个元素封装在某种容器中。
- 将数据及其操作函数捆绑为整体。
在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() 成员函数的返回值即可。
无需关注这些细节极大地降低了程序复杂度,从而减少了错误。这正是封装的核心优势,其重要性远超其他任何理由。
试想:若使用 C++ 时必须理解 std::string、std::vector 或 std::cout 的具体实现原理,语言复杂度将提升多少!
数据封装使我们能够保持不变量
在关于类的入门课程(14.2——类入门)中,我们介绍了类不变量的概念。这些是不变量,即对象在其生命周期内必须始终成立的条件,以确保对象保持有效状态。
请考虑以下程序:
#include <iostream>
#include <string>
struct Employee // members are public by default
{
std::string name{ "John" };
char firstInitial{ 'J' }; // should match first initial of name
void print() const
{
std::cout << "Employee " << name << " has first initial " << firstInitial << '\n';
}
};
int main()
{
Employee e{}; // defaults to "John" and 'J'
e.print();
e.name = "Mark"; // change employee's name to "Mark"
e.print(); // prints wrong initial
return 0;
}
该程序输出:

我们的 Employee 结构体有一个类不变量,即firstInitial 应始终等于名称的第一个字符。如果这是不正确的,则 print() 函数将发生故障。
因为 name 成员是公共的,所以 main() 中的代码能够将 e.name 设置为“Mark”,并且 firstInitial 成员不会更新。我们的不变量被破坏了,我们对 print() 的第二次调用无法按预期工作。
当我们让用户直接访问类的实现时,他们就负责维护所有不变量——他们可能不会这样做(要么正确,要么根本不这样做)。将这种负担加在用户身上会增加很多复杂性。
让我们重写这个程序,将成员变量设置为私有,并公开一个成员函数来设置 Employee 的名称:
#include <iostream>
#include <string>
#include <string_view>
class Employee // members are private by default
{
std::string m_name{};
char m_firstInitial{};
public:
void setName(std::string_view name)
{
m_name = name;
m_firstInitial = name.front(); // use std::string::front() to get first letter of `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;
}
该程序现已按预期运行:

从用户角度来看,唯一的改变在于:不再直接为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;
}
// use std::string::front() to get first letter of `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{}; // defaults to "John"
e.setName("Mark");
e.print();
return 0;
}

然而,这个程序还有另一个类不变量。请花点时间看看你能否找出它是什么。我们就在这里等着看这干涸的油漆……
答案是 m_name 不应为空字符串(因为每位员工都应有名字)。如果将 m_name 设为空字符串,不会立即发生什么坏事。但若随后调用firstInitial()方法,std::string的front()成员将试图获取空字符串的首字母,这将导致未定义行为。
理想情况下,我们希望彻底禁止m_name为空。
若用户能通过公共接口访问m_name成员,他们完全可以直接设置m_name = “”,而我们对此束手无策。
然而,由于我们强制用户通过公共接口函数 setName() 设置 m_name,因此可在 setName() 中验证用户传入的名称是否有效。若名称非空,则将其赋值给 m_name;若名称为空字符串,则可采取多种应对措施:
- 忽略将名称设为 “” 的请求并返回调用方。
- 触发断言错误。
- 抛出异常。
- 跳支滑稽舞。等等,这个不行。
关键在于我们能检测到滥用行为,并按认为最合适的方式处理。如何有效应对这类情况,则是另一个值得探讨的话题了。
数据隐藏使得在不破坏现有程序的情况下更改实现细节成为可能
请看这个简单示例:
#include <iostream>
struct Something
{
int value1 {};
int value2 {};
int value3 {};
};
int main()
{
Something something;
something.value1 = 5;
std::cout << something.value1 << '\n';
}

虽然这个程序运行正常,但如果我们决定像这样改变类的实现细节,会发生什么情况?
#include <iostream>
struct Something
{
int value[3] {}; // uses an array of 3 values
};
int main()
{
Something something;
something.value1 = 5;
std::cout << something.value1 << '\n';
}

我们尚未讲解数组,但不必担心。关键在于该程序无法编译,因为名为value1的成员已不存在,而main()中的语句仍在使用该标识符。
数据隐藏使我们能够在不破坏使用该类的程序的前提下,修改类的实现方式。
以下是该类的封装版本,通过函数访问m_value1:
#include <iostream>
class Something
{
private:
int m_value1 {};
int m_value2 {};
int m_value3 {};
public:
void setValue1(int value) { m_value1 = value; }
int getValue1() const { return m_value1; }
};
int main()
{
Something something;
something.setValue1(5);
std::cout << something.getValue1() << '\n';
}

现在,让我们将类的实现改回数组:
#include <iostream>
class Something
{
private:
int m_value[3]; // note: we changed the implementation of this class!
public:
// We have to update any member functions to reflect the new implementation
void setValue1(int value) { m_value[0] = value; }
int getValue1() const { return m_value[0]; }
};
int main()
{
// But our programs that use the class do not need to be updated!
Something something;
something.setValue1(5);
std::cout << something.getValue1() << '\n';
}

由于我们没有改变类的公共接口,使用该接口的程序完全无需修改,功能依然完全相同。
同样地,如果小矮人深夜潜入你家,用另一种(但兼容的)技术替换了电视遥控器的内部组件,你可能根本不会察觉!
具有接口的类更易于调试
最后,封装能在程序出错时帮助调试。程序运行异常时,往往是因为某个成员变量被赋予了错误值。若所有人都能直接设置成员变量,则难以追踪究竟是哪段代码将其修改成了错误值。这可能需要对每个修改成员变量的语句设置断点——而这类语句可能数量庞大。
但若成员变量只能通过单一成员函数修改,则只需在该函数设置断点,即可观察每个调用者如何改变其值。这能大幅简化定位问题根源的过程。
优先使用非成员函数而非成员函数
在C++中,若函数可合理地作为非成员函数实现,则应优先采用非成员函数而非成员函数进行实现。
此举具有多重优势:
- 非成员函数不属于类接口的一部分。因此类接口将更精简直观,便于理解。
- 非成员函数强制封装机制——此类函数必须通过类的公共接口调用,避免因操作便捷而直接访问实现的诱惑。
- 修改类实现时无需考虑非成员函数(只要接口变更不破坏兼容性)。
- 非成员函数通常更易于调试。
- 包含应用程序特定数据和逻辑的非成员函数可与类中可复用部分分离。
若您曾接触现代面向对象语言(如Java或C#),此做法可能令人意外。这些语言采用截然不同的概念模型——类被视为宇宙中心,万物皆围绕其运转。因此这些语言将成员函数置于核心地位(事实上Java和C#甚至不支持非成员函数)。
最佳实践:
尽可能将函数实现为非成员函数(尤其包含应用程序特定数据或逻辑的函数)。
技巧:
以下是函数成员属性选择的简化指南:
- 必须使用成员函数时才使用。C++要求特定类型的函数必须定义为成员函数。下一课讨论构造函数时将看到一个示例,其他例子包括析构函数、虚函数和某些运算符。
- 当函数需要访问不应暴露的私有(或受保护)数据时,优先使用成员函数。
- 否则优先采用非成员函数(尤其适用于不修改对象状态的函数)。
后两条原则存在例外情况——相关主题讨论时将具体说明。
常见难题在于:当优先采用非成员函数需额外添加访问函数时,需权衡利弊。
- 添加访问函数意味着创建一两个新成员函数(获取器及可能的设置器),这会增加类接口的规模和复杂度。除非这些新访问函数能在多处复用,否则可能得不偿失。
- 切勿为不应直接访问的数据(例如内部状态)添加访问函数,也不应允许用户通过访问函数破坏类的不变性。
相关内容:
Scott Meyers 的文章《非成员函数如何增强封装性》更深入地探讨了优先使用非成员函数的理念。
让我们通过三个相似的示例来说明这一点,按从差到好的顺序排列:
#include <iostream>
#include <string>
class Yogurt
{
std::string m_flavor{ "vanilla" };
public:
void setFlavor(std::string_view flavor)
{
m_flavor = flavor;
}
const std::string& getFlavor() const { return m_flavor; }
// Worst: member function print() uses direct access to m_flavor when getter exists
void print() const
{
std::cout << "The yogurt has flavor " << m_flavor << '\n';
}
};
int main()
{
Yogurt y{};
y.setFlavor("cherry");
y.print();
return 0;
}

上述是最低效的版本。print()成员函数在已有flavor获取器的情况下仍直接访问m_flavor。若类实现后续更新,print()很可能也需修改。print()输出的字符串具有应用程序特异性(其他使用该类的应用程序可能需要输出不同内容,这将要求克隆或修改该类)。
#include <iostream>
#include <string>
class Yogurt
{
std::string m_flavor{ "vanilla" };
public:
void setFlavor(std::string_view flavor)
{
m_flavor = flavor;
}
const std::string& getFlavor() const { return m_flavor; }
// Better: member function print() has no direct access to members
void print(std::string_view prefix) const
{
std::cout << prefix << ' ' << getFlavor() << '\n';
}
};
int main()
{
Yogurt y{};
y.setFlavor("cherry");
y.print("The yogurt has flavor");
return 0;
}

上述版本有所改进,但仍不尽如人意。print() 函数虽仍是成员函数,但至少不再直接访问任何数据成员。若类实现未来更新,需评估 print() 是否需要同步更新(但实际无需修改)。print()函数的前缀现已参数化,这使我们能将前缀移至非成员函数main()中。但该函数仍对输出格式施加限制(例如始终按前缀-空格-味道‘flavor'-换行顺序输出)。若无法满足特定应用需求,则需添加其他函数。
#include <iostream>
#include <string>
class Yogurt
{
std::string m_flavor{ "vanilla" };
public:
void setFlavor(std::string_view flavor)
{
m_flavor = flavor;
}
const std::string& getFlavor() const { return m_flavor; }
};
// Best: non-member function print() is not part of the class interface
void print(const Yogurt& y)
{
std::cout << "The yogurt has flavor " << y.getFlavor() << '\n';
}
int main()
{
Yogurt y{};
y.setFlavor("cherry");
print(y);
return 0;
}

上述版本最为优越。print()现已成为非成员函数,不再直接访问任何成员变量。即使类实现发生变更,print()也无需重新评估。此外,每个应用程序均可提供专属的print()函数,实现完全符合自身需求的打印效果。
类成员声明的顺序
在类外部编写代码时,我们必须先声明变量和函数才能使用它们。但在类内部,这种限制并不存在。正如第14.3节——成员函数所述,我们可以按任意顺序排列成员。
那么该如何排序呢?
对此存在两种观点:
- 先列出私有成员,再列出公有成员函数。这遵循传统的“先声明后使用
declare-before-use”风格。查看类代码时,用户能先看到数据成员的定义再接触使用方式,有助于理解实现细节。 - 将公共成员置于首位,私有成员置于末尾。由于类使用者关注的是公共接口,这种排序能将所需信息置顶,并将实现细节(重要性最低的部分)置于末尾。
在现代C++中,第二种方法(公共成员优先)更受推崇,尤其适用于需与其他开发者共享的代码。
最佳实践:
先声明公共成员,其次声明受保护成员,最后声明私有成员。此举可突出公共接口,淡化实现细节。
作者注:
本站多数示例采用与推荐顺序相反的声明方式。这部分源于历史惯例,但我们发现该顺序在学习语言机制时更具直观性——此时我们专注于实现细节与解析工作原理。
进阶读者指南:
Google C++ 风格指南推荐以下声明顺序:
- 类型与类型别名(typedef、using、enum、嵌套结构体
nested structs/类classes及友元类型friend types) - 静态常量
Static constants - 工厂函数
Factory functions - 构造函数与赋值运算符
Constructors and assignment operators - 析构函数
Destructor - 其余函数(静态
static/非静态non-static成员函数member functions,及友元函数friend functions) - 数据成员
Data members(静态/非静态)

浙公网安备 33010602011771号