C++ 类与对象:封装、不变量和访问控制
01 类不是装字段的盒子
考虑一个银行账户,余额不能为负,取款不能超过当前余额,每次操作后余额必须保持前后一致,这些约束共同构成了"银行账户"这个概念的基本规则。如果用公开字段来实现,任何代码都可以写 account.balance = -5000 或者 account.balance += 100000,既不需要经过存款/取款逻辑,也不会触发任何验证。账户类型对此毫无防御能力,因为字段直接暴露在外部,类型本身对合法状态边界没有任何发言权。
这个问题并非"字段不够多"或者"缺一个 getter/setter",真正的原因在于类型的核心职责缺位了。类型应当定义合法状态的范围,并控制所有可能改变状态的操作入口。类(class)之所以成为 C++ 面向对象编程的起点,仅仅在语法上把字段和函数塞进一对花括号远远不够。其真正的价值在于提供了一套机制,允许我们把状态、操作和约束打包成一个自洽的契约,然后让编译器帮我们守住这个契约的边界。
类定义类型,对象是实例
class 关键字引入的是一个新的用户定义类型。类型描述了"这一类东西有什么状态、能做什么",而类型的实例(即对象)才是真正占据存储空间、持有具体值的东西。class BankAccount { double balance; }; 定义了一个类型,但直到你写 BankAccount myAccount; 或者 BankAccount yourAccount; 时,内存里才出现了具体的对象。同一个类型可以产生任意多个对象,每个对象持有自己的 balance 副本,但都共享同一套操作逻辑。这个区分看似浅显,却指向了一个经常被写反的事实:成员函数代码不随对象复制,只有数据成员进入对象布局。
当你写 account1.deposit(100) 和 account2.deposit(200) 时,两个对象调用了完全相同的 deposit 函数代码,区别在于这段代码执行时看到的"当前对象"不同。成员函数内部有一个隐式的 this 指针,它是一个指向调用对象的指针。准确地说,在非 const 成员函数中 this 的类型是 ClassName* const,指针本身不可变,但它指向的对象可以修改;在 const 成员函数中 this 的类型是 const ClassName* const,连指向的对象也不能修改。所有对数据成员的访问在函数体内都通过 this->member 完成,所以同一个函数逻辑作用在不同的对象状态上,却能正确地读各自对象的数据。
这意味着:非静态数据成员是对象的一部分,每个对象有自己独立的存储空间存放它们;非静态成员函数不是对象的一部分,它们属于类本身。编译器在翻译成员函数时,隐式地给每个非静态成员函数增加了一个指向类类型的 const 指针参数,调用方在调用点传入对象地址,函数内部通过这个指针定位数据成员。静态成员函数不属于任何特定对象,它没有隐式的 this 参数,因此不能访问非静态数据成员。这和法律上"不属于任何人"导致"不能打开任何人的保险箱"是一回事,这并非设计偏好,纯粹是机制上的限制。
关于成员函数在内存中的位置,有一个容易产生误解的点值得讲清。C++ 标准并不要求成员函数以特定方式存放在特定的内存区域。这种存放方式属于实现细节,而非标准需要关心的问题。标准只规定了语义:成员函数不属于对象状态,不通过对象复制,不贡献对象大小。在实际实现中,它们通常和普通函数一样位于代码段,但这不构成可移植保证。当你用 sizeof 测量一个包含若干成员函数的对象时,结果只反映数据成员的大小(加上可能的对齐填充),这已经充分说明了"成员函数代码不在对象内部"的事实。
对象内部有什么
进一步说,一个类对象在内存中的"形状"主要由它的非静态数据成员决定。如果你定义了一个包含三个 double 成员的类,每个对象的大小本质上就是三倍的 sizeof(double),加上编译器为了对齐插入的填充字节。成员函数的地址、静态数据成员的存储区域,都和具体的对象实例无关:静态数据成员存放在程序的静态数据区,所有对象共享同一份。
同时要说明的是,这种描述针对的是非多态类型。当一个类引入了虚函数时,多数实现会在对象中插入一个指向虚函数表的指针,从而增加对象大小。这是实现细节而非标准要求,但即便是这种场景,数据成员的大小和布局仍然遵循"函数本身不进对象"的基本规则。此时仅有虚表指针被额外放入对象,虚函数的实际代码依然在对象外部。虚函数的完整机制将在后续章节展开,这里提及它只是为了建立一个准确的直觉:正常情况下对象大小等于数据大小,特殊情况下对象会额外携带用于支持运行时行为的辅助指针。
访问控制的目标不是隐藏字段
public、private、protected 这三组关键词最常见的教学顺序是:类默认 private,结构体默认 public,然后把 private 解释成"防止外部访问"。这个说法在技术上是准确的,但如果理解止步于此,读者很容易把封装简化为"把字段藏起来,然后给每个字段写一对 getter/setter"。有不少人就是从这里得到了一个误解:只要字段是 private 的,这个类就算"封装好了"。实际情况完全不是这样。
封装(encapsulation)的真正目标是维护对象不变量(invariant)。对象不变量是对象在合法状态下必须满足的一组条件。这种条件并非 C++ 语法层面的强制约束(编译器不会替你检查"余额必须大于等于零"),它本质上是类型设计者承诺的契约:"只要我允许的操作都通过了类型自己的成员函数执行,那么任何成功构造的 BankAccount 对象,余额就永远不会是负数"。为了兑现这个承诺,类型需要控制所有可能改变状态的操作入口:存款操作在修改余额前检查溢出,取款操作在扣减前确认余额足够,利息计算要处理浮点精度边界。这些保护逻辑在所有写入路径上一致存在,外部代码不能绕过它们直接修改数据字段。这才是 private 的意义:它作为一种手段,服务于封装这个最终目的。
同理,public 成员函数构成了类型的对外接口(interface)。接口反映的是"这个类型能做什么",不涉及"它内部有什么字段"。一个好的接口设计会把操作命名得清晰、把参数约束得准确、把返回值设计得不会让调用方误读。举例来说,取款失败时返回 false 取代静默成功,余额查询返回 const 引用或值取代可修改的引用。接口的质量取决于调用方是否能仅通过公开成员函数的签名和行为就正确使用这个类型,而不需要了解内部实现。
理解了封装的目标之后,再审视 getter/setter 的角色就清楚了。写一个无条件返回内部字段值的 getter 和写一个无条件接受任意值并直接赋给字段的 setter,和公开字段在保护能力上没有本质区别。既然 setter 没有对输入做验证,负数余额依然可以通过 account.set_balance(-5000) 进入对象。封装的质量关键不在于 private 关键字的出现次数,关键在于类型是否在所有对外操作中一致地维护了它承诺的不变量。一个只有 getter/setter 却没有实际约束逻辑的类,不过是在公开字段上盖了一层语法糖纸,并没有改变"任何代码都能制造非法状态"的局面。
在工程实践中还需要注意:private 不仅仅保护不变量,它还限制了修改的影响范围。如果某个字段的名字、类型或存储方式需要在后续版本中变化,私有成员确保了只有类自己的成员函数(以及被声明为友元的函数)需要随之调整,外部代码因为从未直接接触过该字段而不受影响。这是封装在可维护性上的收益。如此一来,接口保持稳定,实现可以演进而不必波及使用者。反过来,如果一个字段真的只是一个无约束的数据槽,任意值都合法,那公开它也未尝不可。如果把所有字段无脑设成 private 再全配上 getter/setter,这无法带来任何实际保护收益,反而徒增代码行数。
struct 与 class:默认访问权限,仅此而已
关于 struct 和 class 在 C++ 中的差别,社区流传着很多说法:有的说"struct 是数据结构,class 是面向对象类型",有的说"struct 里不应该有函数",还有的说"struct 应该保持 C 的兼容性,class 才用于 C++ 新特性"。这些说法作为团队内部的风格约定有一定的价值,但当把它们当成语言层面的绝对规则来传播时,就成了误导。
C++ 标准中,struct 和 class 的唯一语言级差异是默认访问权限和默认继承权限:struct 的成员默认为 public,struct 的基类默认是 public 继承;class 的成员默认为 private,class 的基类默认是 private 继承。除此之外,两者在所有方面等价,它们都可以包含成员函数、构造函数、析构函数、虚函数、访问控制标签、继承关系、友元声明。你可以用 struct 写出一个带私有成员和构造函数的不变量保护类型,也可以用 class 写出一个纯公开数据聚合。编译器不关心你的设计意图,它只看你写的访问控制标签。
在实践中,"struct 用于无约束的数据聚合,class 用于有不变量的封装类型"是一个好的工程习惯,因为它用语法选择直接传达了设计意图。如果某个类型只是几个相关值的组合,且各字段的任意排列都是合法的(比如一个二维点 struct Point { double x; double y; }),那么公开字段就是最诚实的表达,强行套一层 getter/setter 只会让代码变得臃肿而无实际保护收益。如果某个类型内部有约束(如余额不能为负、温度不能低于绝对零度),那就应该用 class 并把状态字段设为 private,通过成员函数保证每一个状态变迁都经过校验。关键在于类型是否有不变量需要守护,关键字本身的名称无关紧要。
const 成员函数:把只读承诺写进类型系统
const 放在成员函数的参数列表后面不仅仅是一个标注,它向编译器和人类读者同时做出了可以被编译器强制检查的承诺:通过这个函数接口,不会改变对象的可观察状态。double balance() const 说的是"查余额不会修改账户",编译器会逐行检查这个承诺:凡是在 const 成员函数体内部修改非 mutable 的数据成员,或者调用非 const 的成员函数,皆会导致编译错误。
这个机制的价值在两个维度上体现。第一,防错。如果你在 balance() 的实现中不小心写了一句修改内部计数字段的代码,编译器会在你把它发布到生产环境之前就报错。第二,也是影响面更大的维度:接口可用性。如果你拿到一个 const BankAccount& 类型的引用(比如通过常量引用接收函数参数),那你只能调用 const 成员函数。withdraw 这样的修改操作在 const 引用上是不可调用的,编译器从类型系统层面就阻止了"在只读视图上做写操作"的错误。这和 private/public 形成互补:访问控制管的是"谁有权调用",const 限定管的是"在什么条件下可以调用"。
有一个需要特别指出的边界:const 成员函数的承诺是"不修改对象的逻辑状态",这并不等同于绝对"不修改任何物理字节"。对于一个持有原始指针 char* data 的类,const 成员函数不能修改 data 指针本身(指针存在对象内部,是对象状态的一部分),但完全可以通过 data 修改它所指向的堆上内容,因为那些字节并不在对象布局内部。这种行为有时是合理的(比如逻辑状态由指针指向的数据决定,而不是指针值本身),有时则是接口承诺与物理可变性之间的错位。标准库的做法是典型参考:std::vector 的 data() 提供了 const 和非 const 两个重载,分别返回 const T* 和 T*,在接口层面就区分了读写权限,不把"物理上能不能写"和"接口上允不允许写"混为一谈。
mutable 关键字在此处扮演了一个精确的例外角色。声明为 mutable 的数据成员即使在 const 成员函数中也可以被修改。这个机制适合的场景是那些"不影响对象逻辑状态但确实需要记录的辅助信息",比如缓存、访问计数器、互斥锁等。mutable 绝非设计漏洞,它对"逻辑状态"和"物理布局"之间的差异做出了诚实的标注:有些物理字节只是为了辅助实现,它们的修改不改变对象的逻辑不变量的真值。
另外,C++ 允许同一个成员函数有 const 和非 const 两个版本——它们构成重载。调用时编译器根据对象的 const 属性选择对应版本:非 const 对象优先调用非 const 版本,const 对象只能调用 const 版本。这一机制让类型可以为读写上下文分别提供不同的接口行为,比如返回可修改引用和只读引用。
protected 的角色:为继承预留的访问通道
protected 成员对外部代码的表现和 private 相同,均属不可见。区别在于,protected 成员对派生类可见。这就引出了一个需要讲清楚的问题:什么时候应该用 protected 取代 private,什么时候不应该?
如果你的基类中有一些数据或辅助函数,派生类确实需要直接访问它们才能正确实现自己的行为,那 protected 是合理的。比如说,一个基类的资源句柄被所有派生类共享,直接暴露给派生类比通过 public 接口间接访问更高效也更容易理解。但 protected 一经使用,就和 public 接口一样构成了一份隐式契约——派生类依赖了基类的内部细节,以后修改基类的内部结构可能破坏派生类。
一个常见的设计陷阱是把数据成员标记为 protected 以"方便未来的派生类",但这个"未来"往往不会来,或者来了以后发现字段的含义和名称已经完全对不上了。在不确定继承层次是否需要直接访问基类状态时,优先使用 private 并通过 protected 成员函数提供受控的读写接口,给了基类更多的修改自由度。这种建议基于现实的代价评估:每暴露一个 protected 数据成员,你就在基类和所有现有及未来的派生类之间增加了一条刚性耦合。如果这个耦合恰好表达了真实的模型关系,那没问题;如果它只是因为"先放着再说",那就应该再想一下。
一个最小不变量示例
下面的代码把本章的几个核心机制(包括 private 状态、公开的受控操作、const 只读接口、explicit 构造)压缩进一个可以独立编译的示例。两个类并排放置:DangerousAccount 暴露公开字段,随时可以被外部代码写入非法余额;BankAccount 通过访问控制和成员函数把余额的修改约束在存款和取款两条路径上。
#include <iostream>#include <stdexcept>// 反例:公开字段,无约束struct DangerousAccount {
double balance = 0.0; // 任何代码可直接写入负数
};
// 正例:封装,不变量由成员函数维护class BankAccount {
public:
explicit BankAccount(double initial)
: balance_{initial} {
if (initial < 0)
throw std::invalid_argument("初始余额不能为负");
}
void deposit(double amount) {
if (amount <= 0)
throw std::invalid_argument("存款金额必须为正数");
balance_ += amount;
}
bool withdraw(double amount) {
if (amount <= 0 || amount > balance_)
return false;
balance_ -= amount;
return true;
}
double balance() const { return balance_; }
private:
double balance_ = 0.0;
};
int main() {
// 反例:公开字段可以任意写入非法值
DangerousAccount da;
da.balance = -5000; // 编译通过,逻辑错误无人阻挡// 正例:只能通过 deposit/withdraw 改变余额
BankAccount account{1000.0};
account.deposit(500.0);
bool ok = account.withdraw(2000.0); // false,余额不足
std::cout << "取款" << (ok ? "成功" : "失败")
<< ",余额:" << account.balance() << '\n';
// account.balance_ = -5000; // 编译错误:balance_ 是 private
}
DangerousAccount 只有一行,却足够说明问题:编译器允许 da.balance = -5000,因为 balance 是公开的 double,赋值表达式在语法上完全合法。单纯从语言的角度来看,这并没有违反任何规则。问题出在设计层面:类型没有声明"余额不应为负"这条规则,编译器自然无从阻拦。BankAccount 把同样的字段放入 private,并在 deposit 和 withdraw 中分别验证参数合法性,余额的每一次变化都经过了类型自己控制的入口。外部代码无法绕过这些检查直接修改 balance_。编译器的访问控制在这里充当了"类型契约的物理防线"。
注意 withdraw 采用返回 bool 的方式,并未抛出异常。这是有意为之:取款失败(余额不足)属于可预期的业务结果,并非程序逻辑错误,此时采用返回值更为妥当。deposit 对非正数参数抛异常,因为给存款传一个负数或零属于调用方的编程错误,应当尽早暴露。哪种失败用返回值、哪种失败用异常,取决于错误的性质,和个人偏好无关。作为"接口设计"层次上的判断,本章只做简单提示,留给读者在后续章节和工程实践中持续校准。
合法状态的边界上,类型才有意义
回顾本章,类不仅是语法糖。它的核心机制(访问控制、成员函数、this 指针、const 限定)合在一起做了一件事:让类型有能力定义"什么是合法状态",并在编译器层面阻止外部代码随意越过这个边界。公开字段之所以无法做到这一点,根本原因在于它把状态修改权交给了任意代码,导致类型本身丧失了对自己状态的发言权,这跟"代码美观"毫无关联。
把"类型应当控制自身状态变迁"这个原则放到工程现实中,不同类型的封装深度自然不同。一个在单一函数内部使用的临时聚合体,可能根本不值得为它定义一个类,只需一个带公开字段的 struct 即可,因为它的生命周期短、调用方有限、出错容易定位。一个跨越多个模块、被不同开发者使用的核心领域类型(比如账户、订单、连接句柄),封装程度则应更高,因为一旦状态被意外破坏,追溯故障点会非常困难。封装是一个连续谱,绝非简单的非黑即白。具体要封装到何种程度,取决于这个类型的变更频率、调用方范围和出错代价。
如果你现在要回头检查自己代码中的类,可以拿三个问题来衡量:这个类型的不变量是什么(哪些值组合是合法的,哪些不合法)?所有可能修改状态的操作是否都经过了类型自己控制的入口?有没有哪个外部函数可以直接修改内部字段而不触发任何校验?是否存在某个值,把它直接赋给内部成员就能破坏不变量?换言之,类型的防线有没有漏洞?把这三个问题回答清楚,就完成了从"用 class 关键字定义类型"到"用类型来维护契约"的转变。
下一章要讨论的是对象生命周期的起点:构造函数怎样才能保证对象在出生的第一刻就处于合法状态,初始化列表为什么不是"写在构造函数体里更省事"的替代方案,以及初始化顺序为什么与书写顺序无关。
阅读导航




