C++基础的三大特性之一:封装
封装是面向对象编程的第一块基石。它解决的核心问题是:如何把数据和对数据的操作绑在一起,并控制外部的访问权限。以下是我在职坐标的学习记录:
一、什么是封装
封装包含两层含义:
- 打包(Bundling):把一组相关的数据(属性)和操作这些数据的函数(方法)放在一起,形成一个「类(class)」。
- 隐藏(Hiding):把内部的实现细节藏起来,只对外暴露必要的接口。外部不需要知道、也不应该直接改动内部状态。
一个贴切的类比是电视机:
- 内部:电路板、显像管、电源模块(复杂且易损坏)→ 被外壳包住,用户看不到
- 外部:遥控器上的几个按键(开机、换台、调音量)→ 用户只需要知道这些
如果电视机不封装,用户必须直接操作电路板才能换台——既麻烦又容易把机器弄坏。类也一样:暴露过多内部细节,调用者就会写出依赖实现细节的代码,一旦内部改动,所有调用方全部崩溃。
二、访问控制:public / private / protected
C++ 通过三个访问说明符实现「隐藏」:
| 访问说明符 | 类内部 | 派生类 | 类外部(普通函数、main) |
|---|---|---|---|
public |
✅ 可访问 | ✅ 可访问 | ✅ 可访问 |
protected |
✅ 可访问 | ✅ 可访问 | ❌ 不可访问 |
private |
✅ 可访问 | ❌ 不可访问 | ❌ 不可访问 |
注意:
class的默认访问权限是private,struct的默认访问权限是public。这是class与struct在 C++ 中唯一的功能性区别。
2.1 一个反面例子:不封装
struct BankAccount_Bad {
std::string owner;
double balance; // 余额完全暴露
};
int main() {
BankAccount_Bad acc{"Alice", 1000.0};
acc.balance = -99999.0; // 余额被随意改成负数,没有任何约束
acc.balance *= 100; // 随意篡改
}
问题在于:数据的合法性完全依赖调用者的自觉。只要有一处代码写错,整个程序的状态就不可信了。
2.2 封装后的写法
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility> // std::move
class BankAccount {
private:
std::string owner_; // 下划线后缀是常见的成员命名约定
double balance_ = 0.0;
// 内部辅助函数,外界完全不需要知道它的存在
void validateAmount(double amount) const {
if (amount <= 0) {
throw std::invalid_argument("金额必须大于 0");
}
}
public:
// 构造函数:对象诞生时就必须处于合法状态
BankAccount(std::string owner, double initBalance)
: owner_(std::move(owner)), balance_(initBalance) {
if (initBalance < 0) throw std::invalid_argument("初始余额不能为负");
}
// 只读访问器(getter)—— const 修饰表示不修改对象
const std::string& owner() const { return owner_; }
double balance() const { return balance_; }
// 行为接口:每一步都做合法性校验
void deposit(double amount) {
validateAmount(amount);
balance_ += amount;
}
void withdraw(double amount) {
validateAmount(amount);
if (amount > balance_) {
throw std::runtime_error("余额不足");
}
balance_ -= amount;
}
};
int main() {
BankAccount acc("Alice", 1000.0);
acc.deposit(500);
acc.withdraw(200);
std::cout << acc.owner() << " 的余额: " << acc.balance() << '\n'; // Alice 的余额: 1300
// acc.balance_ = -1; // 编译错误:'balance_' is private
// acc.withdraw(1e9); // 运行时抛出 "余额不足"
}
现在余额只能通过与业务规则绑定的 deposit / withdraw 修改,非法状态在源头就被挡住了。
三、封装的关键机制
3.1 构造函数与初始化列表
封装要求「对象一出生就是合法的」,因此构造函数是封装的重要一环。
class Point {
private:
int x_, y_;
public:
// ✅ 推荐:成员初始化列表(直接构造,效率更高)
Point(int x, int y) : x_(x), y_(y) {}
// ❌ 不推荐:先默认初始化再赋值,多一次操作;const 成员和引用成员无法这样写
// Point(int x, int y) { x_ = x; y_ = y; }
};
初始化顺序陷阱:成员按声明顺序初始化,与初始化列表中的书写顺序无关。下面的写法会导致编译器告警(-Wreorder):
class Foo {
int a_; // 先声明,先初始化
int b_;
public:
Foo(int v) : b_(v), a_(b_) {} // ⚠️ 实际先初始化 a_,此时 b_ 还未初始化 → 未定义行为
};
3.2 const 成员函数
成员函数后面加 const,表示「这个函数承诺不修改对象状态」:
double balance() const; // const 对象也能调用
double balance(); // const 对象无法调用
规则很重要:只要函数不修改成员,就应该加 const。这不是洁癖——只有 const 成员函数才能被 const BankAccount& 类型的参数调用,漏写会导致接口不可用。
3.3 friend:有控制地开后门
有时确实需要让某个外部函数或类访问私有成员(例如运算符重载、单元测试)。用 friend 精确授权,而不是把成员改成 public:
class Vec2 {
private:
double x_, y_;
public:
Vec2(double x, double y) : x_(x), y_(y) {}
friend Vec2 operator+(const Vec2& a, const Vec2& b) {
return {a.x_ + b.x_, a.y_ + b.y_}; // 友元函数可直接访问私有成员
}
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << '(' << v.x_ << ", " << v.y_ << ')';
}
};
friend破坏了封装,应当克制使用。它适合运算符重载这类「逻辑上属于该类、但语法上必须在类外」的场景。
四、封装的价值:一个完整对比
假设需求变更——余额从 double 改为「以分为单位的整数」以避免浮点误差。
未封装版本:所有写 acc.balance 的地方(可能散落在几十个文件)全部要改,且 acc.balance = 1.5 这类代码会静默产生错误结果。
封装版本:
class BankAccount {
private:
long long cents_ = 0; // 内部表示变了
public:
// 对外接口完全不变,调用方零改动
double balance() const { return cents_ / 100.0; }
void deposit(double amount) { /* 转为分存储 */ }
};
这就是封装最实际的收益:内部实现可自由演化,外部契约保持稳定。
五、小结
| 要点 | 说明 |
|---|---|
| 本质 | 数据 + 操作绑定,隐藏实现细节,暴露最小接口 |
| 手段 | public / private / protected 访问控制 |
| 保障 | 构造函数建立类不变量;成员函数校验每一次状态变更 |
| 进阶 | const 成员函数、mutable、friend、Pimpl |
| 收益 | 内部实现可自由重构,调用方代码零改动;bug 被拦截在源头 |
下一步:封装解决了「单个类如何自我保护」,接下来看 继承 如何复用已有类的能力,以及 多态 如何让代码在运行时动态选择行为。
浙公网安备 33010602011771号