面向对象编程(Object-Oriented Programming)
面向对象编程使用对象组织状态与行为。本文先说明封装如何保护状态,再解释继承和多态如何决定方法调用。随后比较接口、抽象类与组合,最后用同一个调用串起重载选择和动态分派。
1. 面试核心
先记住这几条,再读正文- 封装。 封装通过受控方法修改状态,并在修改前校验条件,让对象持续满足自身约束。卡 · 封装
- 继承与替换。 继承建立子类型关系,子类应满足父类型的约定,才能替代父类型使用。
- 动态分派。 动态分派根据实际对象的类型,选择可重写实例方法的实现。
- 重载与重写。 重载在编译期根据实参的静态类型选择签名,重写在运行时根据对象的实际类型选择实现。卡 · 重载与重写
- 接口、抽象类与组合。 接口定义能力,抽象类共享实例状态与构造过程,组合让对象使用可替换的协作者。卡 · 接口、抽象类与组合
2. 封装
封装把状态变化集中到对象提供的方法中,让对象在修改前检查输入。这样才能持续维护对象不变量,也就是对象必须始终满足的约束。
例如,账户余额必须保持非负。构造账户和取款时,都需要检查这个条件:
final class Account {
private long balance;
Account(long openingBalance) {
if (openingBalance < 0) {
throw new IllegalArgumentException("negative balance");
}
this.balance = openingBalance;
}
void withdraw(long amount) {
if (amount <= 0 || amount > balance) {
throw new IllegalArgumentException("invalid amount");
}
balance -= amount;
}
long balance() {
return balance;
}
}
上例在开户时检查初始余额,取款时再检查金额是否为正、是否超过余额。调用方只能通过这些方法修改余额,所以负余额会被拒绝。公开字段或提供无校验的 setter,则会让调用方绕过检查。
返回内部的可变集合也会暴露修改入口。防御性副本是另建一份集合,可以隔离集合结构的修改。不可修改视图则只限制通过该视图写入,底层集合的修改仍会反映出来。
3. 继承与多态
继承声明子类属于父类型,并让子类获得父类可见的状态和行为。父类型的契约是它对输入、结果和行为的约定。子类满足这些约定后,调用方才能把它当作父类型使用。
接口实现也建立子类型关系。例如,调用方依赖计价接口,把具体算法交给实现类:
interface PricePolicy {
long calculate(long originalPrice);
}
final class DiscountPolicy implements PricePolicy {
@Override
public long calculate(long originalPrice) {
return originalPrice * 80 / 100;
}
}
PricePolicy policy = new DiscountPolicy();
long price = policy.calculate(1_000); // 800
变量声明为 PricePolicy,这就是它的编译时类型,也称静态类型。实际创建的对象是 DiscountPolicy。编译器根据接口确认 calculate 可以调用,运行时再选择这个对象的重写实现。
这个过程称为动态分派,也叫动态绑定。上例计算得到 800,调用方只需依赖接口,无需直接依赖具体折扣类。
3.1 静态绑定与成员隐藏
判断调用结果时,要先区分实例方法与其他成员。以下四类成员各有自己的规则:
- 字段访问由引用的编译时类型决定。
- 静态方法属于类,子类声明同名方法属于隐藏。
private方法不会被子类继承。final方法保留父类实现,子类不能重写。
动态分派适用于可重写的实例方法。字段和静态成员按各自绑定规则解析。
3.2 重载与重写
重载用于给同名方法提供不同的参数列表,重写则为继承的实例方法提供新实现。先看一个重载示例:
void print(String value) {}
void print(Object value) {} // 重载
两个 print 的参数类型不同,因此构成重载。编译器根据实参的静态类型选择方法签名,也就是方法名和参数类型等信息。单独改变返回类型无法形成重载。
重写发生在继承或接口实现关系中,子类为同签名实例方法提供实现。它需要满足以下兼容要求:
- 引用返回类型支持协变,即子类方法可以返回更具体的子类型。
- 访问权限保持或扩大,原来能调用父类方法的代码仍能调用子类方法。
- 受检异常保持或缩小范围,让调用方原有的异常处理仍然有效。
4. 接口、抽象类与组合
设计对象关系时,要区分调用方需要的能力与多个类共享的实现。接口、抽象类与组合分别适合下面这些情况:
| 方式 | 适合表达 | 主要限制 |
|---|---|---|
| 接口 | 调用方依赖的能力或协议 | 实例状态由实现类保存 |
| 抽象类 | 同一类对象共享的状态、构造过程或模板流程 | Java 只有单类继承,子类与基类耦合较强 |
| 组合 | 一个对象使用可替换的策略或协作者 | 需要显式转发调用和管理依赖 |
一个类可以实现多个接口,但只能直接继承一个类。接口支持以下四类方法:
- 抽象方法由实现类提供具体实现。
default方法提供默认实现,可让现有实现类继续使用扩展后的接口。- 静态方法属于接口本身,通过接口名调用。
- 私有辅助方法供接口内部复用代码。
默认方法会带来实现选择问题。两个无继承关系的接口提供相同默认方法时,实现类必须重写以消除歧义。
多个子类共享状态和稳定流程时,可以使用抽象类。只需替换计价、存储或通知策略时,可以用接口配合组合。组合就是把另一个对象作为协作者保存下来,再委托它完成工作。这样可以独立替换协作者,而无需调整整个继承层次。
final class OrderService {
private final PricePolicy policy;
OrderService(PricePolicy policy) {
this.policy = policy;
}
long quote(long originalPrice) {
return policy.calculate(originalPrice);
}
}
上例的 OrderService 保存一个 PricePolicy,报价时把计算交给它。传入另一种计价实现,就能改变报价规则。
5. 重载选择与动态分派
重载与重写同时出现时,需要分两步判断调用结果。编译期先根据实参的静态类型选择签名,运行时再根据接收者的实际类型选择实现。接收者就是执行实例方法调用的那个对象。
class Parent {
String show(Object value) { return "parent-object"; }
String show(String value) { return "parent-string"; }
}
class Child extends Parent {
@Override
String show(Object value) { return "child-object"; }
}
Parent service = new Child();
Object value = "java";
System.out.println(service.show(value)); // child-object
value 的静态类型是 Object,所以选中 show(Object)。接收者是 Child,因此调用子类重写实现。
接口默认方法也需要确定最终实现。同签名方法遵循以下优先规则:
- 父类的具体实例方法优先于接口默认方法。
- 子接口的默认方法优先于父接口的默认方法。
- 同级接口发生冲突时,实现类必须显式重写,确定最终行为。
6. 常见问题
6.1 有 getter 和 setter 就完成封装了吗?
封装还需要让对象持续维护自己的约束。getter 和 setter 只改变了字段访问方式,如果 setter 无条件接受所有值,调用方仍能写入负余额。所以判断封装是否有效,要看修改入口是否维护了业务约束。
6.2 什么时候选择组合,什么时候选择继承?
行为来自可替换的协作者时,组合更灵活,例如更换计价策略。存在稳定的子类型关系时,可以采用继承。如果这些子类还共享状态和稳定流程,就可以把它们放到抽象基类中。
6.3 为什么频繁向下转型会增加调用方的修改成本?
向下转型把父类型引用转成具体子类型。如果调用方必须反复判断子类,才能调用各自的方法,父类型就没有覆盖调用方的完整需求。这样每新增一种子类,调用方也要增加判断分支,修改范围会随子类数量扩大。
7 · 面试题卡片
7.1 如何理解 Java 的封装、继承和多态
出现公司:中软国际、菜鸟直管、成睿科技
考察重点
- 说明封装、继承和多态各自解决什么问题。
- 解释编译时类型和运行时类型怎样影响方法调用。
- 根据子类型关系和行为变化选择继承或组合。
参考回答
封装保护对象的状态约束,继承建立子类型关系,多态让同一接口调用执行不同对象的实现。
封装通过受控方法校验修改条件,让对象保持有效状态。继承要求子类满足父类型的约定,调用方才能依赖父类型。对于可重写的实例方法,运行时会根据实际对象选择实现。
继承表达子类型关系,代码复用只是附带结果。变化来自可替换策略或协作者时,我会使用接口和组合;多个子类确实共享稳定状态与流程时,再考虑抽象基类。
7.2 接口和抽象类有什么区别,应该怎样选择
出现公司:网易、美团
考察重点
- 比较接口与抽象类在状态、方法和继承数量上的差异。
- 根据哪些行为需要替换、哪些状态需要共享,选择合适的方式。
相关内容:接口、抽象类与组合。
参考回答
接口描述可供调用的能力,抽象类则能共享实例状态、构造过程和实现。
一个类可以实现多个接口,但只能直接继承一个类。
调用方需要可替换的能力时,可以通过组合传入接口实现。如果一组同类对象共享状态与稳定的模板流程,则可使用抽象类统一这些实现。
7.3 重载和重写有什么区别
出现公司:招银网络科技、慧策、有赞
考察重点
- 说明参数列表怎样影响重载与重写的判断。
- 区分编译期选择方法签名和运行时选择具体实现。
相关内容:重载与重写。
参考回答
重载为同名方法提供不同参数列表,重写为继承的同签名实例方法提供新实现。
调用时,编译器先根据实参静态类型选择签名。签名确定后,运行时再根据接收者的实际类型选择重写实现。因此,实参恰好保存某个子类对象,也不会让运行时重新选择重载。
7.4 Object 变量保存字符串时,重载和重写怎样共同决定调用?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分实参的静态类型与接收者的实际类型,并据此推导调用结果。
相关内容:重载选择与动态分派。
参考回答
实参声明为 Object 时,编译期选择 show(Object);若接收对象实际为 Child,运行时再分派到它覆写的 Child.show(Object)。
实参中保存的是字符串,也不会让运行时重新选择 show(String)。重载由编译期的静态类型确定签名,重写只在已选签名内根据接收对象的实际类型选择实现。