类、对象、访问控制与封装
本节目标
掌握 C++23 的类、对象、访问控制、const 成员函数和类不变量。
类把状态和能操作该状态的函数放在同一个接口边界内。封装不是把一切隐藏起来,而是只公开调用者需要的操作,并让对象在每次可观察时都保持合法。
类定义与对象
类定义描述一种类型的数据成员和成员函数,本身并不产生普通对象。写下 Account account{10}; 才定义了一个 Account 对象;由同一类创建的每个对象都是独立实例,各自保存自己的非静态成员状态。
常见的对象创建途径包括定义对象、动态创建对象、临时对象,以及作为成员对象或基类子对象;这不是穷举。进一步判断对象模型时,还要考虑隐式创建对象,以及 union 切换活跃成员;这些低层规则这里不展开。
因此,类名描述的是类型,不是一个已经存在的对象。也不能因为两个对象来自同一个类,就认为它们共享 balance_ 之类的非静态成员;需要由类表达的跨实例共享状态,可以明确建模为静态成员。
class 与 struct 的默认访问
写成 class 还是 struct,并不会选择两套能力不同的类型系统;两者在这里的语言差别只有默认成员访问和默认继承访问。class 默认成员访问为 private,struct 默认成员访问为 public;未写基类访问说明符时,class 派生类默认为私有继承,struct 派生类默认为公有继承,所以默认继承访问也不同。
class Account { int balance_; }; // balance_ 默认为 private
struct Snapshot { int balance; }; // balance 默认为 public
class PrivateDerived : Base {}; // Base 默认为 private 基类
struct PublicDerived : Base {}; // Base 默认为 public 基类
显式写出 public、protected 或 private 后,访问标签会覆盖相应默认值。两者都能拥有成员函数、构造函数和模板参数;认为 struct 只能装数据、class 才能写行为,混淆了接口约定与语言能力。
选择哪种写法应服务读者预期。需要明确维护不变量的类型通常直接写出 class 和访问标签,而不是依赖默认值掩盖接口。
访问控制与封装
访问标签控制成员名字可以从哪些上下文直接使用。它们适合承载的职责不同:
| 访问级别 | 谁可访问 | 适合承载的职责 |
|---|---|---|
public | 本类成员、友元和类外调用者 | 调用者可以依赖的最小稳定接口 |
protected | 本类成员与友元;派生类成员和友元还须满足受保护成员访问的对象表达式限制 | 只为经过设计的派生类协作保留的扩展边界 |
private | 本类成员与友元;派生类不会仅因继承而取得直接访问权 | 可变状态、验证辅助函数和其他实现细节 |
封装把可变状态放在 private,再让 bool deposit(int amount); 这样的公开操作执行验证。调用者请求一次存款,类可以拒绝负数或其他非法金额;若改为公开 int balance_;,任何调用点都能直接写入负值,接口便无法集中维护合法状态。
访问控制限制的是名字能否被直接使用,不会自动验证业务规则;验证仍须放入构造函数和公开操作中。
成员函数与 this
this 是否存在,取决于成员函数采用哪一种对象参数形式:
| 成员函数形式 | 对象参数 | 函数体中的 this | 访问某个对象的非静态成员 |
|---|---|---|---|
隐式对象成员函数 bool deposit(int amount); | 调用时由对象提供隐式对象参数 | 有 | 可直接写 balance_,也可写 this->balance_ |
C++23 显式对象成员函数 void inspect(this const Account& self); | self 是写在参数列表中的显式对象参数 | 没有 | 经具名参数访问,例如 self.balance() |
静态成员函数 static int instances(); | 没有对象参数 | 没有 | 不能直接读取某个对象的非静态成员;需要另行取得对象或引用 |
隐式对象成员函数具有隐式对象参数,this 是指向当前对象的隐式指针表达式。C++23 的显式对象成员函数使用显式对象参数,函数体中没有 this。因此,隐式对象成员函数中的 balance_ += amount; 操作当前 Account 的成员,不是在修改某个同名全局余额。
成员访问默认作用于当前对象;写出 this->balance_ 有时能消除名称遮蔽,但不应把它误解为可空的普通对象引用。隐式对象成员函数必须有对象来调用;静态成员函数没有隐式对象。显式对象参数的推导、重载与转发规则留给函数和模板专题。
const 成员函数
尾随 const 限定的是隐式对象成员函数的隐式对象参数。int balance() const; 因而可以通过 const Account& 调用;如果只声明不带 const 的版本,只读对象就不能调用这个 getter。需要同名的读写接口时,可以按成员函数是否带 const 进行重载。
语言级 const 限制经 const 访问路径修改非 mutable 子对象;它不自动保证没有可观察状态变化。逻辑只读是接口设计合同:若观察操作承诺不改变对调用者可见的语义,设计者还须在语言检查之外维护这一承诺。
mutable、经指针间接访问的对象、静态状态或外部状态仍可改变。因此,不能从 const 成员函数的语法形式直接推出“任何可观察状态都不会变”。mutable 通常只适合不属于逻辑状态的缓存或同步细节,不能作为绕过接口合同的常规手段。
静态数据与静态成员函数
实例计数必须跨对象共享,不能放进每个 Account 各自拥有的 balance_。代表程序把这项类级状态保留在 inline static int instanceCount_{0}; 中,并通过静态成员函数 Account::instances() 查询:
#include <iostream>
#include <limits>
#include <stdexcept>
class Account {
public:
explicit Account(int initialBalance) : balance_{checkedInitialBalance(initialBalance)} {
++instanceCount_;
}
Account(const Account& other) : balance_{other.balance_} { ++instanceCount_; }
Account(Account&& other) noexcept : balance_{other.balance_} { ++instanceCount_; }
~Account() { --instanceCount_; }
[[nodiscard]] int balance() const { return balance_; }
[[nodiscard]] bool deposit(int amount) {
if (amount < 0 || amount > std::numeric_limits<int>::max() - balance_) return false;
balance_ += amount;
return true;
}
[[nodiscard]] static int instances() { return instanceCount_; }
private:
[[nodiscard]] static int checkedInitialBalance(int initialBalance) {
if (initialBalance < 0) throw std::invalid_argument{"balance must be non-negative"};
return initialBalance;
}
int balance_;
inline static int instanceCount_{0};
};
int main() {
Account account{10};
std::cout << "initial=" << account.balance() << '\n';
static_cast<void>(account.deposit(2));
{
Account copy{account};
if (Account::instances() != 2) return 1;
}
const Account& readonly{account};
std::cout << "updated=" << account.balance() << '\n';
std::cout << "readonly=" << readonly.balance() << '\n';
std::cout << "instances=" << Account::instances() << '\n';
std::cout << "invariant=" << (account.balance() >= 0 ? "ok" : "broken") << '\n';
}
输出:
initial=10
updated=12
readonly=12
instances=1
invariant=ok
inline static 数据成员属于类而非某个对象,并可在类定义中完成定义。所有 Account 实例共享这一份计数;内部作用域中的副本构造后计数暂时成为 2,副本析构后,固定输出才显示 instances=1。静态成员函数没有隐式对象,因此可以直接使用类级静态成员,却不能直接读取某个实例的 balance_。
把计数放在无关的可变全局变量中,会让状态逃离类型边界。静态计数本身仍是可变状态,应由构造和析构等明确的类操作维护;它不能替代每个对象自己的 balance_。
friend 的受控访问边界
friend 是由类逐项写出的访问授权,可以让指定的非成员函数、类,或另一个类的成员函数访问私有与受保护实现。例如 friend void audit(const Account&); 只授权这个审计接口读取所需内部信息,并没有向所有非成员代码开放状态。
友元关系不是继承关系,友元身份不会被派生类继承,也不自动对称或传递;一个友元的友元不会因此获得访问权,授权方也不会自动成为对方的友元。friend 只应授予紧密协作的非成员接口,每次授权都应能说明为什么公开接口不足以表达合作。
为了少写 getter 就把许多无关函数声明为友元,会把依赖扩散到私有表示;friend 不是通用的封装替代品。
组合与成员对象
当较大的类型需要使用另一个类型的职责时,成员对象先给出直接的建模方式:
class Ledger { Account account_; };
这里 Ledger 包含一个 Account。组合表达 has-a 关系,外层对象负责协调成员生命周期,也能选择只暴露哪些行为。成员对象按照其声明与所属对象一起初始化和销毁;复杂构造顺序留给对象初始化专题。
继承表达的是 is-a 边界,并会带来替换关系等额外合同。若 Ledger 并不是一种可以替换 Account 使用的类型,仅仅为了复用实现就立即建立继承层级,会把关系建模错;此时应优先组合。
类不变量与合法状态
不变量描述每个已成功构造的对象都必须满足的条件。Account 要求余额非负:构造函数先拒绝负初值,deposit 则在修改前拒绝负数和会让余额溢出的金额。
Account account{10};
const bool accepted{account.deposit(2)}; // true,余额成为 12
const bool rejected{account.deposit(-1)}; // false,余额仍为 12
实现先检查 if (amount < 0) return false; 以及溢出边界,只有验证通过才执行加法。于是合法请求把余额从 10 更新为 12;随后的非法请求被拒绝,并让对象保持拒绝前的合法状态。先修改余额、再让调用者自己检查它是否仍合法,会在接口边界上短暂或永久暴露无效对象。
构造与每个公开操作都必须保持类不变量。这样调用者只需依赖接口合同,而不必在每次读取前重新猜测内部状态是否合法。
常见陷阱
设计类的边界时,可以把常见错误按它们破坏的合同分别排查:
- 公开
int balance_;之类的可变数据,会让任意调用点直接写入负值;应让经过验证的公开操作成为状态变化入口。 - 访问标签只限制名字的直接使用,不会自动维护不变量。公开操作若绕过验证,
private状态仍然可能失效。 - 为无关函数广泛授予
friend会扩大对私有表示的依赖;每个授权都应对应确实无法由公开接口表达的紧密合作。 const Account& readonly{account};形成只读访问路径,只能调用相应的const接口。不要用强制转换伪装可写接口;若底层对象本来就定义为const,经去除限定后的路径写入会产生未定义行为。- 把派生对象按值复制到基类对象,只保留基类子对象,这就是对象切片;它不是类定义语法可以忽略的细节。继承只作边界预览:替换关系、虚析构和运行时多态都应在继承专题单独判断。
当一个类型需要更多职责时,先检查能否通过小的公开操作或组合表达,再决定是否需要继承;保持接口最小,才能让不变量持续可审查。