面向对象编程
面向对象编程,简称 OOP,是 Java 组织复杂业务代码的核心方式。它不是简单地把函数塞进类里,而是用“对象”承载业务状态,用“方法”约束状态变化,用“类型体系”表达业务关系。
如果只记住封装、继承、多态三个词,很容易写出看似面向对象、实际难以维护的代码。真正需要掌握的是:
- 如何从业务概念抽象出类。
- 对象在内存中如何存在。
- 继承和组合应该如何取舍。
- 多态为什么能让代码面向扩展。
static、final、this、super等关键字如何影响对象行为。- 抽象类、接口、内部类分别适合解决什么问题。
# 1. 类与对象
类是对象的模板,定义一类对象共同拥有的属性和行为。对象是类在运行时创建出来的具体实例。
public class User {
private Long id;
private String name;
public User(Long id, String name) {
this.id = id;
this.name = name;
}
public String displayName() {
return id + ":" + name;
}
}
创建对象:
User user = new User(1L, "Tom");
对象关系可以理解为:
栈帧局部变量表
┌──────────────┐
│ user 引用 │
└──────┬───────┘
│ 指向
▼
堆内存
┌──────────────────────┐
│ User 对象 │
│ id = 1 │
│ name = "Tom" │
└──────────┬───────────┘
│ 类型信息
▼
方法区 / 元空间
┌──────────────────────┐
│ User.class 元数据 │
│ 字段、方法、构造器信息 │
└──────────────────────┘
注意:变量 user 本身不是对象,它保存的是对象引用。对象真正位于堆中。
# 2. 面向对象建模
面向对象建模的重点是识别“谁拥有数据、谁负责行为”。
例如订单系统中常见对象:
| 业务概念 | 可能的类 | 核心职责 |
|---|---|---|
| 订单 | Order | 保存订单状态、计算金额、判断能否取消 |
| 订单项 | OrderItem | 保存商品、数量、单价 |
| 用户 | User | 保存用户身份和基础信息 |
| 支付单 | Payment | 表达支付状态和支付流水 |
| 库存服务 | InventoryService | 扣减、回滚库存 |
一个常见原则是:数据和与数据强相关的行为应尽量放在一起。
不推荐的贫血写法:
public class Order {
public BigDecimal amount;
public String status;
}
public class OrderService {
public boolean canCancel(Order order) {
return "CREATED".equals(order.status);
}
}
更好的写法:
public class Order {
private BigDecimal amount;
private OrderStatus status;
public boolean canCancel() {
return status == OrderStatus.CREATED;
}
public void cancel() {
if (!canCancel()) {
throw new IllegalStateException("当前订单状态不能取消");
}
this.status = OrderStatus.CANCELED;
}
}
这样做的好处是业务规则不容易散落到各个 Service 里,对象自身就能维护状态合法性。
# 3. 封装
封装不是简单地把字段改成 private,而是隐藏内部实现,并通过方法维持对象不变量。
public class Account {
private BigDecimal balance;
public Account(BigDecimal openingBalance) {
if (openingBalance.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("开户金额不能为负数");
}
this.balance = openingBalance;
}
public void withdraw(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("提现金额必须大于 0");
}
if (balance.compareTo(amount) < 0) {
throw new IllegalStateException("余额不足");
}
this.balance = this.balance.subtract(amount);
}
public BigDecimal getBalance() {
return balance;
}
}
封装带来的收益:
| 收益 | 说明 |
|---|---|
| 控制状态变化 | 不让外部代码随意修改字段 |
| 保持业务不变量 | 例如余额不能为负数 |
| 降低耦合 | 外部只依赖公开方法,不依赖内部字段结构 |
| 方便演进 | 内部实现可以调整,调用方不一定需要修改 |
封装设计时要避免两种极端:
- 所有字段都暴露
public,对象变成数据袋。 - 无脑生成所有 setter,让任何状态都能被外部任意改写。
# 4. 继承
继承用于表达“子类是父类的一种”。Java 使用 extends 表示类继承。
public class Animal {
public void eat() {
System.out.println("eat");
}
}
public class Dog extends Animal {
public void bark() {
System.out.println("bark");
}
}
继承关系:
Object
▲
│
Animal
▲
│
Dog
Java 类只支持单继承:
class Dog extends Animal {
}
不能这样写:
class Dog extends Animal, Pet {
}
单继承让对象布局和方法查找更简单,也避免多继承中常见的菱形继承问题。需要多种能力组合时,应优先使用接口或组合。
# 4.1 构造器调用顺序
创建子类对象时,一定会先初始化父类部分,再初始化子类部分。
class Parent {
Parent() {
System.out.println("Parent");
}
}
class Child extends Parent {
Child() {
System.out.println("Child");
}
}
执行:
new Child();
输出:
Parent
Child
初始化顺序可以简化理解为:
分配对象内存
│
▼
初始化 Object
│
▼
初始化父类字段和构造器
│
▼
初始化子类字段和构造器
│
▼
返回对象引用
# 4.2 继承的风险
继承会把父类实现细节暴露给子类。父类改动可能影响子类,因此不要为了复用几行代码就使用继承。
判断是否适合继承,可以问三个问题:
| 问题 | 如果答案是否定的 |
|---|---|
| 子类是否真的是父类的一种 | 不要继承 |
| 子类是否能遵守父类行为约定 | 不要继承 |
| 父类是否专门为继承设计 | 谨慎继承 |
很多业务系统中,组合比继承更稳:
public class OrderService {
private final PriceCalculator priceCalculator;
public OrderService(PriceCalculator priceCalculator) {
this.priceCalculator = priceCalculator;
}
}
组合关系表达“拥有一个能力”,继承关系表达“本身就是一种”。
# 5. 多态
多态指同一个父类型引用,在运行时可以指向不同子类对象,并调用不同实现。
interface PayChannel {
void pay(BigDecimal amount);
}
class WeChatPay implements PayChannel {
public void pay(BigDecimal amount) {
System.out.println("微信支付:" + amount);
}
}
class AliPay implements PayChannel {
public void pay(BigDecimal amount) {
System.out.println("支付宝支付:" + amount);
}
}
public class PaymentService {
public void pay(PayChannel channel, BigDecimal amount) {
channel.pay(amount);
}
}
调用链路:
PaymentService.pay(PayChannel channel)
│
│ 编译期只知道 channel 是 PayChannel
▼
运行时真实对象
┌──────────────┬──────────────┐
│ WeChatPay │ AliPay │
└──────┬───────┴──────┬───────┘
│ │
▼ ▼
调用微信支付实现 调用支付宝实现
多态的核心价值是让上层代码依赖抽象,而不是依赖具体实现。
# 5.1 编译期类型与运行期类型
Animal animal = new Dog();
这里有两个类型:
| 类型 | 值 |
|---|---|
| 编译期类型 | Animal |
| 运行期类型 | Dog |
编译期类型决定能调用哪些方法,运行期类型决定重写方法的实际执行版本。
animal.eat(); // 可以,Animal 定义了 eat
animal.bark(); // 编译失败,Animal 没有 bark
如果需要调用子类特有方法,必须向下转型:
if (animal instanceof Dog dog) {
dog.bark();
}
向下转型前应先判断类型,否则可能抛出 ClassCastException。
# 5.2 字段没有多态
方法重写具有多态,字段访问没有多态。
class Parent {
String name = "parent";
}
class Child extends Parent {
String name = "child";
}
Parent p = new Child();
System.out.println(p.name); // parent
字段访问取决于引用的编译期类型,方法调用才会动态分派。
# 6. 方法重载与重写
# 6.1 方法重载
重载发生在同一个类中,方法名相同,参数列表不同。
public class Printer {
public void print(String value) {
}
public void print(int value) {
}
public void print(String value, int count) {
}
}
重载判断依据:
| 能否区分重载 | 示例 |
|---|---|
| 参数个数不同 | print(String) 与 print(String, int) |
| 参数类型不同 | print(String) 与 print(int) |
| 参数顺序不同 | print(String, int) 与 print(int, String) |
返回值不同不能构成重载:
int value() { return 1; }
long value() { return 1L; } // 编译失败
# 6.2 方法重写
重写发生在父子类之间,子类重新实现父类方法。
class Animal {
public void sound() {
System.out.println("some sound");
}
}
class Dog extends Animal {
@Override
public void sound() {
System.out.println("bark");
}
}
重写规则:
| 规则 | 说明 |
|---|---|
| 方法名相同 | 必须与父类方法一致 |
| 参数列表相同 | 参数类型、数量、顺序一致 |
| 返回值兼容 | 可以是父类返回值的子类型 |
| 访问权限不能更严格 | public 不能重写成 protected |
| 异常不能更宽 | 不能抛出比父类更宽的受检异常 |
private 方法不能重写 | 子类只是定义了同名新方法 |
static 方法不能重写 | 只能隐藏 |
final 方法不能重写 | 编译失败 |
建议重写时始终加 @Override,让编译器帮你检查。
# 7. 抽象类
抽象类使用 abstract 修饰,不能直接实例化。
public abstract class AbstractMessageSender {
public void send(String message) {
check(message);
doSend(message);
}
private void check(String message) {
if (message == null || message.isBlank()) {
throw new IllegalArgumentException("message 不能为空");
}
}
protected abstract void doSend(String message);
}
抽象类适合表达“有共同状态或共同流程,但部分步骤由子类决定”的场景。
模板方法结构:
抽象父类定义流程
┌──────────────────────────┐
│ send │
│ 1. check │
│ 2. doSend 抽象步骤 │
│ 3. record │
└────────────┬─────────────┘
│
▼
子类提供差异实现
┌──────────────┬──────────────┐
│ SmsSender │ EmailSender │
└──────────────┴──────────────┘
抽象类可以包含:
- 成员变量。
- 构造器。
- 普通方法。
- 抽象方法。
- 静态方法。
抽象方法只能出现在抽象类或接口中。
# 8. 接口
接口用于定义能力或协议。
public interface Repository<T> {
T findById(Long id);
void save(T entity);
}
接口适合表达“某个对象具备某种能力”。例如:
| 接口 | 表达能力 |
|---|---|
Runnable | 可以被线程执行 |
Comparable | 可以比较大小 |
Serializable | 可以序列化 |
AutoCloseable | 可以自动关闭资源 |
Java 8 之后,接口可以包含默认方法和静态方法:
public interface Named {
String name();
default String displayName() {
return "[" + name() + "]";
}
static boolean isValid(String name) {
return name != null && !name.isBlank();
}
}
接口与抽象类对比:
| 对比项 | 接口 | 抽象类 |
|---|---|---|
| 关系含义 | 具备某种能力 | 是某类对象 |
| 多继承 | 一个类可实现多个接口 | 一个类只能继承一个抽象类 |
| 状态 | 不适合保存实例状态 | 可以保存实例状态 |
| 构造器 | 没有构造器 | 有构造器 |
| 使用场景 | 能力抽象、SPI、策略 | 模板方法、共享基础实现 |
一般经验:
- 优先面向接口编程。
- 需要共享状态和流程时再考虑抽象类。
- 接口默认方法适合做兼容演进,不适合塞入大量业务逻辑。
# 9. 访问修饰符
Java 有四种访问级别:
| 修饰符 | 当前类 | 同包 | 子类不同包 | 任意位置 |
|---|---|---|---|---|
private | 可以 | 不可以 | 不可以 | 不可以 |
| 默认 | 可以 | 可以 | 不可以 | 不可以 |
protected | 可以 | 可以 | 可以 | 不可以 |
public | 可以 | 可以 | 可以 | 可以 |
设计建议:
- 字段优先
private。 - 构造器根据创建方式选择
public、private或包级访问。 - 工具类可使用
private构造器避免实例化。 - 业务 API 不要随意
public,暴露出去就会成为兼容性负担。
# 10. this、super、static、final
# 10.1 this
this 表示当前对象。
常见用途:
public class User {
private String name;
public User(String name) {
this.name = name;
}
public User rename(String name) {
this.name = name;
return this;
}
}
this 可以区分成员变量和局部变量,也可以在链式调用中返回当前对象。
# 10.2 super
super 表示父类部分。
class Child extends Parent {
Child(String name) {
super(name);
}
@Override
public void run() {
super.run();
System.out.println("child run");
}
}
super() 必须作为构造器第一行语句。
# 10.3 static
static 表示属于类,而不是属于某个对象。
public class IdGenerator {
private static long sequence = 0;
public static long nextId() {
return ++sequence;
}
}
static 常用于:
- 工具方法。
- 常量。
- 类级共享状态。
- 静态工厂方法。
慎用可变静态字段,因为它是全局共享状态,容易带来并发和测试隔离问题。
# 10.4 final
final 可以修饰变量、方法和类:
| 位置 | 含义 |
|---|---|
final 变量 | 只能赋值一次 |
final 方法 | 不能被子类重写 |
final 类 | 不能被继承 |
public final class Money {
private final BigDecimal amount;
public Money(BigDecimal amount) {
this.amount = amount;
}
}
final 引用不能重新指向其他对象,但对象内部状态未必不可变:
final List<String> names = new ArrayList<>();
names.add("Tom"); // 可以
names = new ArrayList<>(); // 编译失败
# 11. 内部类
Java 支持在类内部定义类。
| 类型 | 定义位置 | 特点 |
|---|---|---|
| 成员内部类 | 类成员位置 | 依赖外部类实例 |
| 静态内部类 | static 修饰 | 不依赖外部类实例 |
| 局部内部类 | 方法内部 | 作用域仅在方法内 |
| 匿名内部类 | 表达式中 | 常用于一次性实现接口 |
成员内部类:
public class Outer {
private String name = "outer";
class Inner {
String value() {
return name;
}
}
}
静态内部类:
public class Response {
private String code;
public static class Builder {
public Response build() {
return new Response();
}
}
}
匿名内部类:
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("run");
}
};
现代 Java 中,函数式接口场景通常优先使用 Lambda:
Runnable task = () -> System.out.println("run");
# 12. 常见设计误区
# 12.1 把继承当复用工具
如果只是为了复用代码,优先考虑组合。继承会引入父子类强耦合。
# 12.2 在构造器中调用可重写方法
class Parent {
Parent() {
init();
}
void init() {
}
}
class Child extends Parent {
private String name = "child";
@Override
void init() {
System.out.println(name.length());
}
}
父类构造器执行时,子类字段可能还没初始化完,调用子类重写方法容易产生隐藏 bug。
# 12.3 滥用 static
把业务状态放进静态字段,会导致状态全局共享,测试互相污染,并发场景难以控制。
# 12.4 对象只有 getter 和 setter
如果一个类只有字段访问方法,没有业务行为,它可能只是数据结构,不是真正的领域对象。并不是所有类都必须富有行为,但核心业务对象应尽量承载规则。
# Tips 快问快答
Q:Java 是纯面向对象语言吗? A:严格来说不是。Java 有 8 种基本类型,它们不是对象。但 Java 的主体编程模型是面向对象。
Q:接口和抽象类怎么选? A:表达能力或协议时选接口;需要共享状态、构造过程、模板流程时选抽象类。
Q:为什么推荐面向接口编程? A:接口能隔离调用方和实现方,让实现可以替换,例如测试时替换为 Mock,生产中替换为不同策略。
Q:多态的本质是什么? A:编译期看引用类型,运行期根据真实对象类型进行方法分派。
Q:重载是多态吗? A:重载通常被称为编译期多态,编译器根据参数列表决定调用哪个方法;重写是运行期多态。
Q:为什么字段没有多态? A:字段访问在编译期根据引用类型确定,不参与运行期动态分派。
Q:private 方法能重写吗?
A:不能。父类 private 方法对子类不可见,子类同名方法只是一个新方法。
Q:static 方法能重写吗?
A:不能。static 方法属于类,只能被隐藏,调用哪个版本取决于引用的编译期类型。
Q:final 修饰引用变量后对象就不可变了吗?
A:不是。final 只限制引用不能重新赋值,不限制引用指向对象的内部状态变化。
Q:为什么不建议在构造器里调用可重写方法? A:因为子类对象尚未初始化完成,重写方法可能访问到未初始化字段,产生难排查的问题。