面向对象:封装、继承与组合
更新时间:2026-08-25。本文是
languages/oop/主题第 ② 篇。封装让对象自治,继承让代码复用,组合让结构灵活——三者用对了,类体系才经得起需求变化。
本文要回答的问题
- 封装到底在保护什么?
private比public好在哪? - 什么时候该继承、什么时候该组合?
- 为什么"组合优先于继承"成了工程共识?
一、封装:隐藏内部,暴露契约
1.1 访问修饰符
java
class BankAccount {
private double balance; // 私有:外部不可直接访问
protected int accountNo; // 保护:子类可访问
public void deposit(double amt) { // 公有:对外接口
if (amt <= 0) throw new IllegalArgumentException();
balance += amt;
}
public double getBalance() { return balance; } // 只读访问器
}| 修饰符 | 同类 | 同包 | 子类 | 任意类 |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| 默认(包) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
1.2 封装的价值:约束"非法状态"
不封装的后果:account.balance = -1000; 直接破坏不变量。封装后,所有修改必经 deposit()/withdraw(),可校验、可加日志、可拦截——这是对象自治的根基。
封装 = 数据私有 + 公开行为,配套 "getter/setter" 时:只暴露必要的读(getter),避免无脑 setter(setter 多说明行为没抽象好)。
1.3 Go 的封装:首字母大小写
go
type Account struct {
balance float64 // 小写:包私有
Name string // 大写:导出
}
func (a *Account) Deposit(amt float64) { a.balance += amt }Go 没有 private/protected/public 关键字,用首字母大小写表达可见性——更简洁,但粒度较粗。
二、继承:建立 is-a 关系
2.1 基本用法
java
class Animal {
protected String name;
Animal(String n) { name = n; }
void speak() { System.out.println("..."); }
}
class Dog extends Animal {
Dog(String n) { super(n); } // 调父类构造
@Override void speak() { System.out.println(name + " 汪汪"); }
}
Dog d = new Dog("旺财");
d.speak(); // 旺财 汪汪| 继承要解决 | 机制 |
|---|---|
| 代码复用 | 子类直接继承父类方法/字段 |
| 统一类型 | 子类对象是父类类型(is-a) |
| 可扩展 | 子类覆写(override)父类方法 |
2.2 继承的代价(为什么要谨慎)
| 问题 | 说明 |
|---|---|
| 脆弱基类问题 | 父类一改,所有子类受影响 |
| 高耦合 | 子类强依赖父类实现细节 |
| 菱形问题 | 多继承的歧义(C++ 需虚继承) |
| 白盒复用 | 继承暴露内部,破坏封装 |
结论:继承解决的是"类型替换"(is-a),而不是"代码复用"——为了省代码而继承,多半是滥用。
三、组合:has-a 关系
java
class Engine { void start() { /* ... */ } }
class Car {
private Engine engine = new Engine(); // 组合:Car has-a Engine
void start() { engine.start(); } // 委托给内部对象
}| 对比 | 继承 | 组合 |
|---|---|---|
| 关系 | is-a(是) | has-a(有) |
| 耦合 | 编译期强耦合父类 | 仅依赖接口,弱耦合 |
| 复用 | 白盒(见内部) | 黑盒(只见接口) |
| 灵活 | 运行期难换 | 可动态替换组件 |
| 多继承 | Java 不支持 | 天然支持 |
3.1 组合优先于继承
- 继承适合:真正稳定的 is-a 关系(
Dog extends Animal)。 - 组合适合:行为可以委托/替换的场景(
Car换Engine)。
工程共识:"优先使用对象组合,而非类继承"(GoF 设计原则之一)。组合更灵活、耦合更低、更易测试——用委托(delegation)实现复用。
四、Liskov 替换原则(LSP)
继承必须满足:子类对象能替换父类对象而不改变程序正确性。
java
class Rectangle {
void setWidth(int w) { ... }
void setHeight(int h) { ... }
}
class Square extends Rectangle { // 违反 LSP!
// 正方形 setWidth 会把 height 也改了 → 替换后行为异常
}- 违反 LSP 的继承:用
instanceof判断类型、覆写后抛异常、静默改变语义。 - 修正:不要强行套 is-a;
Square不该继承Rectangle,二者都实现Shape。
五、多语言对照
| 特性 | Java | C++ | Go |
|---|---|---|---|
| 继承 | extends 单继承 | : 多继承 | 结构体嵌入(组合) |
| 覆写 | @Override | virtual + override | 方法覆盖(接口) |
| 父类构造 | super(...) | 初始化列表 | —(无继承) |
| 可见性 | 4 级 | 3 级(含 friend) | 首字母大小写 |
Go 刻意放弃继承、只用组合与接口——这是现代语言对"继承滥用"的纠偏。
六、与本站主线衔接
- 设计模式:组合优先是策略/装饰器/观察者等模式的前提。
- Java:
this/super、对象头与继承布局,见 Java 核心。 - C++:虚继承与菱形问题、
final,见 C++ 现代特性。 - 性能:继承层级深会拉长对象访问、虚函数有间接跳转,见 L3 缓存。
一句话总结
封装让对象自治、继承建立 is-a 复用、组合提供 has-a 灵活——"组合优先于继承",且继承必须满足 Liskov 替换;用对了,类体系才稳定可扩展。