Java 面向对象编程用类定义对象的状态、行为和边界,再通过引用操作对象。对象不是字段的容器,而是负责维护自身契约的协作单元。
private 字段不会自动带来封装,extends 也不会自动带来可替换性。公开可变数据或让子类破坏父类契约,仍会得到脆弱的模型。
在构造器中建立不变量,通过行为方法修改状态,面向最小抽象调用对象;只在子类型确实保持基类型契约时继承,否则使用组合。
是什么,为什么存在
面向对象编程(object-oriented programming,OOP)把相关状态和操作这些状态的行为放进对象。类声明对象允许保存哪些字段、如何构造,以及调用方可以执行哪些方法;对象则是运行时具有自身标识和状态的实例。Java 也有基本类型和静态成员,因此把它称为“纯面向对象语言”并不准确。
对象边界解决的是状态由谁负责的问题。若余额可以由任意调用方直接改写,每个调用点都必须重复检查规则;若账户只公开 deposit() 和 withdraw(),校验就集中在拥有余额的对象内。这种把表示隐藏在 API 后面的做法叫作 封装(encapsulation) 。
一个 类不变量(class invariant) 是在对象完成构造后,并在每次公开操作结束时都必须成立的规则。例如,账户余额不得为负,订单必须至少有一行,日期范围的结束时间不得早于开始时间。构造器和所有修改状态的方法共同负责保持这些规则。
继承声明一种名义上的“是一个”关系,运行时分派则让基类型调用抵达实际对象的覆盖实现。这形成 子类型多态(subtype polymorphism) :调用方依赖稳定的基类型契约,而不同子类型提供不同实现。多态的价值是删除调用方对具体类的条件分支,不是单纯减少重复代码。
当一个对象通过字段持有协作者,并把部分工作委托给它们时,使用的是 组合(composition) 。组合可以在构造时替换策略,不会把实现细节暴露成子类契约。你会在领域模型、服务对象、策略、适配器和依赖注入边界中同时遇到封装、多态与组合。
工作原理
类是声明,new 表达式创建对象并返回引用值。引用变量不包含对象本身;给另一个变量赋值只会复制引用,因此两个变量可以指向同一个可变对象。null 表示没有对象,调用它的实例方法或访问实例字段会抛出 NullPointerException。
每次构造都先为实例字段取得默认值,再执行构造链和字段初始化逻辑。构造器没有返回类型,也不会被继承;它的任务是让新对象在泄漏给调用方之前进入有效状态。必需数据应作为构造参数传入,非法组合应立即拒绝,而不是创建一个等待后续 setter 修补的半成品。
访问控制决定源码中哪些位置可以命名成员。private 限制在顶层类及其嵌套成员内,包访问限制在同一包,protected 还向符合规则的子类代码开放,public 则向可访问该类的代码开放。访问修饰符只是一层语法边界;真正的封装还要求公开方法不泄漏可变内部表示。
实例方法调用同时涉及静态选择和运行时选择。编译器依据引用的静态类型、方法名与参数选择签名;若目标是可覆盖的实例方法,JVM 再依据实际对象的类选择最终覆盖实现。static 方法按引用处可见的声明解析,private 方法不可被子类覆盖,final 方法明确禁止覆盖。
方法重载与方法覆盖不能混为一谈。重载是在同一名称下提供不同参数列表,调用目标由编译期参数类型决定;覆盖是在子类型中提供兼容实例方法,目标由运行时对象决定。给覆盖方法加 @Override,可以让编译器捕获拼写错误、参数变化和意外重载。
调用边界的行为契约
方法签名只说明名称、参数类型、返回类型和声明的受检异常,无法完整描述行为。调用方还需要知道哪些输入有效、null 是否允许、结果单位是什么,以及失败时状态是否改变。这些承诺决定两个实现能否互相替换。
异常也是对象 API 的可观察结果。若一个实现对余额不足返回 false,另一个实现却扣成负数或抛出无关异常,它们虽然拥有相同 Java 签名,却没有同一行为契约。对失败路径的测试应同时断言异常或返回值,以及对象是否保持原状态。
副作用需要明确边界。一个看似只计算价格的方法如果还写数据库、发送邮件或更新缓存,调用方就无法安全重试或排序调用。把命令与查询区分开,并不要求套用固定架构,但方法名和文档应让状态变化可预测。
并发安全同样不会从 private 或 final 自动产生。多个线程共享一个可变对象时,读取、校验与写入可能交错,单线程下成立的不变量就可能被破坏。对象应声明它是不可变、线程受限,还是通过同步保护共享状态。
一份可替换的对象契约至少要回答:
- 哪些参数值和对象状态允许调用?
- 正常结果、单位与舍入规则是什么?
- 失败通过返回值还是异常表达,失败后状态如何?
- 方法会修改哪些对象、执行 I/O,或要求怎样的线程模型?
接口、抽象类和普通基类都只能表达部分答案。文档说明语义,构造器强制初始条件,方法实现保护状态,契约测试则让不同实现接受同一组可观察断言。
抽象、final 与扩展边界
抽象类可以保存实例状态、定义构造规则、提供具体方法,并把选定操作留作抽象方法。它适合一组确实共享生命周期和不变量的相关类型。若调用方只需要行为契约,而实现不应共享状态,可优先考虑接口。
final 类禁止创建子类,final 方法禁止覆盖,final 字段禁止重新赋值。三者保护的是不同边界;尤其是 final List<String> 仍可能指向内容可变的列表。不要把 final 当作深度不可变的同义词。
super(...) 选择直接父类构造器。Java 25 允许在调用前放置受限的构造器前序代码,但这段早期构造上下文不能使用正在构造的对象;旧源码级别仍要求调用位于最前。super.method() 可以调用当前覆盖所替代的父类实现,但这会把子类与父类实现顺序耦合起来。只有当父类文档把这条调用链列入扩展契约时,子类才应依赖它。
protected 字段让子类可以绕过父类行为直接修改表示,扩大了必须共同维护不变量的代码范围。通常应保留私有字段,并提供窄小的受保护操作或查询。这样父类仍能改变内部表示,而不迫使所有子类一起修改。
每个开放覆盖点都是供未来未知代码调用的 API。若无法写清允许子类假设什么、必须保持什么,就应先把方法设为 final 或把类设为 final,待出现真实扩展需求后再开放边界。
对象模型可以按以下顺序检查:
- 写出对象在构造完成后必须成立的不变量。
- 指定哪个对象拥有每份可变状态,以及哪些方法可以修改它。
- 从调用方角度定义最小的可观察契约,包括结果、异常与副作用。
- 判断变化点应由子类型多态还是对象组合承载。
- 用无效构造、边界输入和替代实现验证契约。
| 设计机制 | 绑定时机 | 适合表达 | 主要风险 |
|---|---|---|---|
| 重载 | 编译期 | 同一操作的不同参数形态 | 误以为按运行时参数类型选择 |
| 覆盖 | 运行时 | 子类型对共同契约的不同实现 | 子类改变前置条件或结果含义 |
| 组合 | 构造或配置时 | 可替换协作者与策略 | 所有权和可变性边界不清 |
示例
以下三个程序依次展示状态封装、运行时分派和对象组合。输出来自本地 OpenJDK 21.0.12,使用 javac --release 21 -Xlint:all 编译并执行;示例只使用在目标 Java 25 中仍有效的语言与标准库特性。
用行为保护余额不变量
BankAccount 不公开余额字段,也不提供任意设置余额的 setter。构造器拒绝无效初始状态,两个业务方法则在改变余额之前检查各自的前置条件。
public class BankAccountDemo {
static final class BankAccount {
private final String number;
private long balanceInCents;
BankAccount(String number, long openingBalance) {
if (number == null || number.isBlank()) {
throw new IllegalArgumentException("number is required");
}
if (openingBalance < 0) {
throw new IllegalArgumentException("negative opening balance");
}
this.number = number;
this.balanceInCents = openingBalance;
}
void deposit(long amount) {
requirePositive(amount);
balanceInCents += amount;
}
boolean withdraw(long amount) {
requirePositive(amount);
if (amount > balanceInCents) {
return false;
}
balanceInCents -= amount;
return true;
}
String summary() {
return number + " balance=" + balanceInCents;
}
private static void requirePositive(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
}
}
public static void main(String[] args) {
var account = new BankAccount("A-17", 10_000);
account.deposit(2_500);
System.out.println(account.withdraw(4_000));
System.out.println(account.withdraw(9_000));
System.out.println(account.summary());
}
}true
false
A-17 balance=8500第一次取款成功并修改状态,第二次取款失败且不修改状态,因此余额仍为 8,500 分。boolean 返回值把“余额不足”建模为预期业务结果,而非法金额会抛出异常。实际金额模型还要定义币种、溢出策略和并发语义,这些都不能从 long 类型自动得到。
private 的价值不是隐藏数字本身,而是保证每条写路径都经过同一规则。若 getBalance() 返回一个可变账本对象,或者另一个方法绕过校验直接改字段,语法上的私有性仍然无法保护不变量。
通过基类型触发运行时分派
ShippingMethod 在 quoteFor() 中集中校验和输出格式,把费用计算留给子类。循环只认识基类型,却会根据每个实际对象执行对应的 costInCents()。
import java.util.List;
public class DispatchDemo {
abstract static class ShippingMethod {
private final String label;
ShippingMethod(String label) {
this.label = label;
}
final String quoteFor(int kilograms) {
if (kilograms <= 0) {
throw new IllegalArgumentException("weight must be positive");
}
return label + ": " + costInCents(kilograms);
}
protected abstract int costInCents(int kilograms);
}
static final class Pickup extends ShippingMethod {
Pickup() {
super("pickup");
}
@Override
protected int costInCents(int kilograms) {
return 0;
}
}
static final class Courier extends ShippingMethod {
Courier() {
super("courier");
}
@Override
protected int costInCents(int kilograms) {
return 300 + kilograms * 80;
}
}
public static void main(String[] args) {
List<ShippingMethod> methods = List.of(new Pickup(), new Courier());
for (ShippingMethod method : methods) {
System.out.println(method.quoteFor(3));
}
}
}pickup: 0
courier: 540quoteFor() 是 final,所以所有子类都保留正重量检查;变化点只有受保护的费用计算。这个模板方法设计只有在所有运输方式都能遵守同一输入和输出契约时才成立。若某种方式接受零重量或返回其他单位,它就不是可安全替换的子类型。
把列表元素声明为 ShippingMethod 不会复制或包装实际对象。引用的静态类型限制调用方直接可见的成员,实际对象类型决定覆盖方法。新增一个守约的子类时,循环无需增加 instanceof 分支。
用组合替换计算策略
Checkout 拥有总价流程,但把税额计算委托给 TaxPolicy。同一个类可以组合不同策略,不需要为每个税制创建一个 Checkout 子类。
public class CompositionDemo {
interface TaxPolicy {
long taxFor(long subtotalInCents);
}
static final class FixedRateTax implements TaxPolicy {
private final int basisPoints;
FixedRateTax(int basisPoints) {
if (basisPoints < 0) {
throw new IllegalArgumentException("negative tax rate");
}
this.basisPoints = basisPoints;
}
@Override
public long taxFor(long subtotalInCents) {
return Math.multiplyExact(subtotalInCents, basisPoints) / 10_000;
}
}
static final class Checkout {
private final TaxPolicy taxPolicy;
Checkout(TaxPolicy taxPolicy) {
this.taxPolicy = java.util.Objects.requireNonNull(taxPolicy);
}
long totalFor(long subtotalInCents) {
if (subtotalInCents < 0) {
throw new IllegalArgumentException("negative subtotal");
}
return Math.addExact(subtotalInCents, taxPolicy.taxFor(subtotalInCents));
}
}
public static void main(String[] args) {
var standard = new Checkout(new FixedRateTax(2_000));
var exempt = new Checkout(subtotal -> 0);
System.out.println(standard.totalFor(12_500));
System.out.println(exempt.totalFor(12_500));
}
}15000
12500固定税率对象维护税率非负的不变量,结账对象维护小计非负且策略非空的边界。Math.multiplyExact() 与 Math.addExact() 让整数溢出显式失败,而不是静默回绕;除法的截断规则仍属于必须由业务定义的金额舍入契约。
组合不会自动产生好设计。TaxPolicy 仍需说明是否允许负税额、怎样舍入、能否访问外部状态,以及多线程调用是否安全。构造器注入只使依赖关系可见,不会补齐缺失的行为契约。
陷阱
把私有字段等同于封装
**修复:**从业务操作命名公开方法,并让一次操作原子地检查和更新相关字段。只公开调用方真正需要的查询结果;返回集合或数组时,明确所有权并在需要时使用防御性复制。
为复用几行代码建立继承
**修复:**先写基类型调用方依赖的前置条件、后置条件和不变量,再对每个子类型运行同一组契约测试。仅需要复用实现或替换策略时,优先把协作者组合进对象。
在构造器中调用可覆盖方法
**修复:**构造期间只调用 private、static 或 final 的受控逻辑。需要扩展构建过程时,使用验证完成后再返回对象的工厂、显式初始化步骤,或把变化行为作为已构造的协作者传入。
混淆重载与覆盖
**修复:**不要用一组容易混淆的重载模拟按运行时类型分支。对覆盖方法使用 @Override,对有歧义的重载写最小编译示例,并检查调用点的静态参数类型。
同时破坏 equals() 与 hashCode() 契约
**修复:**成对实现并测试 equals() 与 hashCode(),只使用能在对象作为键期间保持稳定的字段。若领域需要实体标识与值相等两种语义,使用不同类型或明确命名的方法表达它们。
对象标识、相等性与生命周期
引用不等于对象
每个对象都有 对象标识(object identity) ,用于区分它与其他对象。两个引用可以指向同一对象,复制引用不会复制对象;两个独立对象也可能包含完全相同的字段值。修改别名所指的可变对象时,其他别名随后会观察到同一变化。
对引用使用 == 比较的是它们是否指向同一对象,或是否都为 null。Object.equals() 的默认实现也使用标识语义,但类可以覆盖它来定义值相等。调用方应根据类型契约选择 equals() 或 Objects.equals(),不能把包装类型缓存、字符串驻留等实现现象当成 == 可比较值的理由。
相等关系必须满足自反、对称、传递和一致性,并且任何非空引用都不等于 null。相等对象必须产生相同哈希码,反向则不要求成立。继承层次若让父类与子类选择不同的相等字段,很容易破坏对称或传递,因此值对象通常更适合不可变的 final 类或记录类。
| 操作 | 比较内容 | 常见用途 |
|---|---|---|
left == right | 引用标识 | 判断是否为同一个对象 |
left.equals(right) | 接收者定义的相等关系 | 比较类型契约中的值 |
Objects.equals(left, right) | 空安全地调用 equals() | 任一操作数可能为 null |
System.identityHashCode(value) | 尽量按标识产生哈希码 | 诊断,不用于业务相等性 |
构造与动态分派的边界
对象初始化按继承链进行,父类构造逻辑会在子类初始化完成前运行。此时对象的实际类型已经是子类,所以从构造器调用可覆盖方法仍会动态分派到子类实现。子类看到的是部分初始化状态,这也是构造器不应发布 this、注册回调或启动线程的原因。
静态方法属于声明它的类,不参与实例动态分派。若父类和子类各声明同签名静态方法,发生的是隐藏;由调用表达式的编译期类型决定选择哪个声明。字段访问同样不是虚分派,给父类字段和子类同名字段只会制造两份容易混淆的状态。
向下转型不会改变对象,只是要求运行时验证引用是否兼容目标类型。错误假设会抛出 ClassCastException。如果调用方频繁先接收基类型,再用 instanceof 恢复每个具体类型,通常说明共同契约缺少操作,或者这组类型本来就不适合一个开放的多态层次。
所有权决定可变性风险
final 字段只能保证引用不会被重新赋值,不能冻结所指对象。若构造器直接保存调用方传入的可变列表,调用方以后仍能越过对象方法修改内容;若 getter 直接返回内部列表,也会发生反向泄漏。两端的别名都会绕开拥有者的不变量。
防御性复制要根据契约选择深度。List.copyOf() 返回不可修改的列表,不会反映可变输入之后的结构变化,但它不会深复制元素,也可能复用合适的不可修改输入;数组需要显式 clone() 或 Arrays.copyOf()。若元素本身可变,必须决定共享元素、复制元素,还是改用不可变值类型,并把决定写进 API 契约。
对象的生命周期也跟随强引用。监听器、缓存、线程池任务或静态集合持有对象时,对象及其可达图都可能继续存活。封装设计因此不仅关心谁能修改状态,也要关心谁持有引用、何时注销,以及资源是否通过 AutoCloseable 等显式协议释放。
4个问题 · 1 道输出预测题 · 1 道找错题