面向对象编程¶
这是华为可信认证的一个科目,本版块梳理面向对象的核心概念、Java 中的落地机制,以及从语法到设计的进阶方向。
面向对象编程(Object-Oriented Programming,OOP)不是"把每个名词都写成一个类",而是一种组织复杂度的方法:把状态和行为封装在对象里,通过接口约定对象之间的协作,让变化被限制在局部。
学习 OOP 容易停留在语法:会写 class、会 extends、会用 interface,但遇到需求变化时仍然写出牵一发动全身的代码。本页先从概念地图入手,再给出 Java 中的关键机制和常见误区,最后给出学习方向。
概念地图¶
| 概念 | 一句话解释 | 真正要解决的问题 |
|---|---|---|
| 类 | 对象的模板,描述状态与行为 | 职责如何划分 |
| 对象 | 运行时拥有身份、状态和行为的实体 | 谁和谁协作 |
| 封装 | 隐藏实现,暴露稳定契约 | 变化如何被隔离 |
| 抽象 | 提取共性,忽略无关细节 | 哪些是稳定接口 |
| 继承 | 子类复用并扩展父类 | 是否真的是 is-a |
| 多态 | 同一接口,不同实现 | 调用方如何与实现解耦 |
| 组合 | 对象持有其他对象完成职责 | 如何复用而不破坏封装 |
一个常见的误区是把"面向对象"理解为"把过程代码拆进多个类"。判断是否真正面向对象,可以看两点:对象是否封装了不变式,以及调用方是否依赖抽象而不是具体实现。
四大特性¶
封装¶
封装不是给每个字段加 getter/setter,而是保护对象的不变式:对象对外只暴露必要的操作,调用方不能把对象改成非法状态。
public final class Money {
private final long cents;
public Money(long cents) {
if (cents < 0) {
throw new IllegalArgumentException("金额不能为负");
}
this.cents = cents;
}
public Money plus(Money other) {
return new Money(this.cents + other.cents);
}
public long cents() {
return cents;
}
}
Money 用 final 字段和构造器校验保证"金额非负且不可变"。没有暴露 setCents,调用方就无法绕过校验。封装的关键问题是:这个对象允许处于哪些状态?哪些入口可能破坏它?
继承¶
继承表达 is-a 关系:子类对象在父类出现的地方应当可以替换父类,并且不破坏调用方的预期(里氏替换原则)。
继承的收益是复用和统一类型,但代价也很明显:
- 子类和父类的实现细节耦合,父类改动可能破坏子类(脆弱基类问题);
- 继承层次过深时,行为来源难以追踪;
- 为了复用代码而继承,往往会把本来不是 is-a 的关系硬凑成继承。
因此有一条常用经验:优先使用组合,只有在确实存在 is-a 且父类为继承设计过时,才使用继承。
多态¶
多态让调用方面向接口编程,运行时再决定执行哪个实现:
interface Notifier {
void send(String message);
}
final class EmailNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("邮件: " + message);
}
}
final class SmsNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("短信: " + message);
}
}
Notifier notifier = new EmailNotifier();
notifier.send("订单已发货"); // 运行时调用 EmailNotifier 的实现
两个容易混淆的概念:
| 概念 | 发生时间 | 依据 | 例子 |
|---|---|---|---|
| 重载(Overload) | 编译期 | 方法名相同、参数列表不同 | print(int) / print(String) |
| 重写(Override) | 运行时 | 子类重新实现父类/接口方法 | EmailNotifier.send |
多态的价值在于新增实现时不必修改调用方。如果代码里出现"根据类型写一长串 if-else / switch",通常说明多态没有被用起来。
抽象¶
抽象是提取稳定契约、隐藏实现差异的过程。Java 提供两种主要表达方式:
| 维度 | 抽象类 | 接口 |
|---|---|---|
| 关系语义 | is-a,强调共享实现 | can-do / 契约,强调能力 |
| 成员 | 可有字段、构造器、具体方法 | 常量、抽象方法、default/static/private 方法 |
| 继承数量 | 单继承 | 可实现多个接口 |
| 适用场景 | 多个子类共享骨架和状态 | 定义能力、解耦调用方与实现 |
| 演进 | 加方法通常影响子类 | 可用 default 方法平滑演进 |
选择时可以问:我是在表达"是什么",还是在表达"能做什么"? 前者倾向抽象类,后者倾向接口;如果只是需要一份稳定契约,接口通常更灵活。
类之间的关系¶
UML 把对象之间的关系分为依赖、关联、聚合、组合、继承和实现。它们体现的耦合强度不同:
| 关系 | 语义 | 生命周期 | 例子 |
|---|---|---|---|
| 依赖 | 临时使用 | 不负责对方生命周期 | 方法参数、局部变量 |
| 关联 | 长期知道对方 | 各自独立 | 订单关联客户 |
| 聚合 | 整体与部分,部分可独立 | 部分不随整体销毁 | 部门与员工 |
| 组合 | 整体与部分,部分不可独立 | 部分随整体销毁 | 订单与订单项 |
| 继承 | is-a | 类型层级 | 子类与父类 |
| 实现 | can-do | 契约实现 | 类与接口 |
classDiagram
class Order
class OrderItem
class Customer
class Payment
class AlipayPayment
Order "1" *-- "many" OrderItem : 组合
Order --> Customer : 关联
Order ..> Payment : 依赖
Payment <|.. AlipayPayment : 实现 关系选择会直接影响耦合程度:依赖最轻,组合通常比继承更容易替换和测试。设计时不必一开始就画出完整 UML,但应当能说清楚谁拥有谁、谁在什么时候创建谁。
Java 中的关键机制¶
OOP 的概念最终要落到语言机制上。可以按下面的方向建立知识框架:
| 方向 | 关键问题 | Java 机制 |
|---|---|---|
| 对象创建 | 对象如何初始化,顺序是什么 | 构造器、实例初始化块、静态初始化块、工厂方法 |
| 状态与身份 | 两个对象何时"相等" | ==、equals、hashCode、record |
| 复用 | 继承还是组合 | extends、委托、组合 |
| 多态 | 调用哪个实现 | 重写、接口、动态绑定、向上转型 |
| 抽象 | 契约如何表达 | 抽象类、接口、sealed |
| 类型安全 | 通用代码如何复用 | 泛型、通配符、类型擦除 |
| 不可变性 | 状态如何安全共享 | final、不可变类、record |
| 行为参数化 | 如何把逻辑当参数传递 | 函数式接口、lambda、方法引用 |
几个需要特别注意的契约:
equals与hashCode必须一致:a.equals(b)为真时,两者的hashCode必须相等;否则对象放入HashMap/HashSet后可能"找不到自己"。- 可变对象不适合作为哈希键:作为键之后修改状态,会破坏哈希表的定位。
==比较的是引用(或基本类型的值),对象内容比较要用equals。- 构造器不是唯一的创建入口:静态工厂方法可以缓存实例、返回子类型、表达更清晰的语义。
- Java 只有值传递:传递对象时复制的是引用值,方法内不能改变调用方的引用指向,但可以修改对象状态(除非对象不可变)。
设计原则:从语法走向设计¶
会写继承和多态,不等于会做面向对象设计。设计原则解决的是"类和对象应该如何划分与协作":
| 常见症状 | 相关原则 | 调整方向 |
|---|---|---|
| 一个类承担业务、持久化、格式化 | 单一职责(SRP) | 按变化原因拆分职责 |
每加一种类型就改 switch | 开闭原则(OCP) | 用多态建立扩展点 |
| 子类无法替代父类的行为 | 里氏替换(LSP) | 重新审视继承关系 |
| 接口很大,实现类被迫写空方法 | 接口隔离(ISP) | 拆分小接口 |
| 业务层直接依赖具体实现 | 依赖倒置(DIP) | 依赖抽象,注入实现 |
更完整的内容见软件设计和其中的 SOLID 原则页面。
常见误区¶
| 误区 | 后果 | 改进 |
|---|---|---|
| 把 OOP 等同于"为每个名词建类" | 类很多但职责不清 | 先按职责和变化原因划分 |
| 为了复用代码而继承 | 继承层次脆弱,父类改动牵连子类 | 优先组合/委托 |
| 所有字段都配 getter/setter | 封装退化为数据容器,不变式无人保护 | 暴露行为,不暴露可变状态 |
用 switch 判断类型 | 新增类型要改所有分支 | 用多态或策略模式 |
| 过度使用设计模式 | 结构复杂、可读性下降 | 先解决具体问题,再引入模式 |
equals/hashCode 实现不一致 | 集合行为异常 | 成对重写并遵守契约 |
| 到处使用静态方法和全局状态 | 依赖隐藏、难以替换和测试 | 通过参数/构造器注入依赖 |
学习方向¶
可以按"语法 → 类型 → 协作 → 设计 → 实践"的顺序建立知识体系:
| 阶段 | 目标 | 检验方式 |
|---|---|---|
| 语法基础 | 会定义类、创建对象、使用访问控制 | 能写出封装良好的值对象 |
| 类型机制 | 理解继承、接口、抽象类、重写与重载 | 能解释一段多态调用的实际执行路径 |
| 对象协作 | 能判断依赖、关联、组合等关系 | 能画出核心类的 UML 关系图 |
| 语言特性 | 熟悉泛型、record、sealed、lambda | 能用现代 Java 简化样板代码 |
| 设计原则 | 用 SOLID 和组合优于继承指导拆分 | 能识别并重构常见的坏味道 |
| 工程实践 | 让设计可测试、可扩展、可维护 | 能为关键接口编写替换实现的单元测试 |
面向对象设计不是一次成型的。更现实的路径是:先写出能工作的代码,再通过重构和测试逐步收敛职责;每引入一层抽象,都要能回答它解决了什么变化。
继承和组合应该怎么选?
先问关系是不是真正的 is-a,以及父类是否明确为继承设计过。如果只是想复用一段实现,优先把这段实现抽成独立对象,通过组合和委托使用;如果子类确实是一种父类,并且能遵守父类的契约,继承才是合适的。组合通常耦合更低、更容易替换和测试。
抽象类和接口怎么选?
需要共享字段、构造逻辑或方法骨架时,用抽象类;只需要定义一组能力、希望调用方依赖契约、或者需要多实现时,用接口。很多场景可以两者结合:接口定义契约,抽象类提供骨架实现。
重载和重写有什么区别?
重载发生在同一个类(或父子类)中,方法名相同、参数列表不同,编译期根据参数的静态类型选择;重写发生在子类与父类/接口之间,方法签名相同,运行时根据对象的实际类型动态绑定。重载看"声明类型",重写看"真实类型"。
为什么说 Java 只有值传递?
Java 调用方法时,实参的值会被复制给形参。基本类型复制的是数值,引用类型复制的是对象引用的值。因此方法内可以让形参指向另一个对象而不影响调用方,但可以通过这个引用修改对象状态。这正是"传引用值",不是"传引用"。
小结¶
- OOP 的核心不是语法,而是用对象封装状态、用接口组织协作;
- 封装的本质是保护不变式,而不是给字段加 getter/setter;
- 继承表达 is-a,组合表达 has-a,多数情况下组合更灵活;
- 多态让调用方依赖抽象,新增实现时不必修改调用方;
- 抽象类和接口分别偏向"共享实现"和"定义契约";
- 能写继承不等于会设计,SOLID 原则和重构实践才是从语法走向设计的关键。
创建日期: 2026-10-23 01:34:52
广告
人要恰饭的嘛🤑🤑