
读《Java编程思想》:面向对象编程该从哪些概念入手
读《Java编程思想》,很容易在类、对象、继承、接口等术语之间来回切换,却不确定该先掌握什么。理解面向对象,不是把这些名词背下来,也不是把所有代码都改写成一组类。更实用的入门路径是:先弄清对象是什么,再用封装管理状态,通过组合组织协作;了解继承与多态的条件,最后把接口、异常和类型系统放进真实设计中检验。
本文面向已有少量 Java 基础的读者,重点回答三个问题:这些概念分别解决什么问题?什么场景适合使用?它们的边界和容易犯的错是什么?文中的完整示例可保存为 OopGuide.java,使用 Java 8 或更高版本编译运行。
一、先把“对象”理解成有边界的协作单位
对象可以看作程序运行时的一种实体:它有自己的状态,也能通过方法参与行为。类是描述这类对象的定义,创建对象时,程序会得到一个具体实例。比如 Account 类描述账户具有余额并能存款、取款;某个余额为 100 的账户,则是一个对象。
初学者常把对象简化为“数据加函数”。这个说法有帮助,但还不够。一个好的对象还应当有清晰的责任和边界:它知道什么、能做什么,以及哪些事情不应由它负责。假如一个账户对象同时负责账户余额、打印报表、发送邮件和连接数据库,它就承担了太多互不相关的职责,修改其中一项功能也可能牵动其他部分。
因此,面向对象设计首先不是“看到名词就建类”,而是识别程序中的状态、行为和责任。对象也不是现实事物的完整数字复制品。业务程序只需要表达当前问题所需的属性和规则。
二、封装:让状态通过规则变化
封装常被解释为“把字段设为 private,再生成 getter 和 setter”。访问控制确实是封装的一部分,但封装的重点是控制状态变化,维护对象必须遵守的规则。
比如账户余额不能随意被外部代码改成负数。若余额是公开字段,任何调用方都可以直接赋值;若余额是私有字段,对外提供存款和取款操作,对象就有机会检查操作是否合法。
封装并不等于“所有字段都加 setter”。无条件提供修改方法,仍可能把状态控制权交给所有调用方。需要问的是:这个状态是否应该改变?由谁改变?改变时需要满足什么条件?对于只在对象创建时确定、此后不应变化的数据,可以考虑不提供 setter;对于可以变化的数据,则应让方法表达有意义的操作,而不只是暴露字段。
什么时候使用封装
- 对象有必须始终成立的条件,例如余额不能小于零、订单创建后必须有明细。
- 字段的内部表示可能变化,不希望调用方依赖具体实现。
- 修改状态需要同步更新多个字段,或需要在操作前后执行检查。
private 限制的是 Java 访问权限,并不自动提供并发安全、数据加密或持久化保护。封装也不能替代对外部输入的校验:输入是否可信,应在系统边界按业务规则检查。
三、组合:优先考虑“拥有”与“使用”关系
当一个对象需要借助另一个对象完成工作,通常可以通过组合表达。例如,订单包含多条订单项;报告生成器使用格式化器;服务对象依赖仓储对象。组合关注的是“一个对象由哪些对象协作而成”,而不是“一个对象是不是另一种对象”。
在代码中,组合通常表现为把依赖对象保存为字段,并在方法中调用它。这样做能让职责分开:报告生成器负责组织报告,格式化器负责格式转换。替换格式化器时,不必把报告生成器改写成一串子类。
组合的边界也要考虑清楚:依赖由谁创建、谁负责其生命周期、是否允许替换,取决于程序规模和框架设计。小程序可以直接在构造方法中传入依赖;复杂应用可能由依赖注入框架管理。不要为了“面向对象”而把每个简单值都包装成一个类,也不要在没有变化需求时堆叠大量抽象层。
四、继承与多态:复用代码之前,先确认替换关系
继承用来建立父类与子类之间的关系。子类可以继承父类可访问的成员,也可以增加或重写行为。Java 的类只支持单继承:一个类只能直接继承一个类,但可以实现多个接口。
多态指的是:通过父类类型或接口类型引用对象时,调用被重写的方法,实际执行的是运行时对象对应的实现。它使调用方能够依赖稳定的抽象,而不必在每个位置判断具体类型。
例如,程序通过 PaymentMethod 使用付款方式。付款可以有不同实现,但调用方只关心它们都能执行付款操作。新增实现时,原有调用流程可能不需要修改。这种扩展能力是多态的重要价值。
不过,“把共有代码提取到父类”并不是继承的充分理由。两个类即便有重复字段或方法,也未必具有合理的子类型关系。关键问题是:子类能否在需要父类的地方出现,并继续符合父类所承诺的行为?如果父类表示“可保存的付款方式”,子类就不应在调用付款时突然拒绝所有合法请求,除非这种限制已被抽象契约清楚表达。
继承适合什么场景
- 概念上确实存在稳定的“是一个”关系。
- 父类定义的行为契约对所有子类都成立。
- 希望通过父类或接口统一调用不同实现。
如果目的主要是复用实现、拼装功能,组合通常更灵活。继承会让子类依赖父类的实现细节;父类修改方法行为或状态结构,可能影响多个子类。还要注意,构造过程会先执行父类构造,再执行子类构造;不要在构造期间调用可能被子类重写的方法,因为此时子类字段可能尚未初始化。
五、接口:表达能力与协作约定
接口描述一组对外能力或协作约定。实现接口的类需要提供相应实现,调用方则可以依赖接口,而不需要知道具体类。接口适合表达“能做什么”,例如可关闭、可比较、可付款;类继承更适合表达受控的实现与类型层次。
在 Java 中,一个类可以实现多个接口,所以接口常用于弥补类单继承的限制。接口也可以包含常量、静态方法,以及 Java 8 引入的默认方法;但不要因为接口能写默认实现,就把它当成可以无限扩展的父类。接口的设计应围绕调用方所需的能力,避免做成包含大量无关方法的“大接口”。
接口同样不是越多越好。只有一个实现、也没有清晰替换点时,先用具体类往往更直接。若为了“方便测试”就给每个类机械地配一个同名接口,可能会增加跳转和维护成本,却没有带来真正的解耦。
六、一个可运行的例子:账户封装、接口和组合
下面的示例把账户余额藏在对象内部,用 deposit 和 withdraw 管理状态;通过 PaymentMethod 表达付款能力;用组合让 Checkout 调用不同付款实现。示例刻意保持简单,便于看清概念之间的关系。
import java.math.BigDecimal;
import java.util.Objects;
interface PaymentMethod {
void pay(BigDecimal amount);
}
final class Account {
private BigDecimal balance;
Account(BigDecimal openingBalance) {
Objects.requireNonNull(openingBalance, "openingBalance");
if (openingBalance.signum() < 0) {
throw new IllegalArgumentException("开户余额不能为负数");
}
this.balance = openingBalance;
}
BigDecimal getBalance() {
return balance;
}
void deposit(BigDecimal amount) {
requirePositive(amount);
balance = balance.add(amount);
}
void withdraw(BigDecimal amount) {
requirePositive(amount);
if (balance.compareTo(amount) < 0) {
throw new IllegalArgumentException("余额不足");
}
balance = balance.subtract(amount);
}
private static void requirePositive(BigDecimal amount) {
Objects.requireNonNull(amount, "amount");
if (amount.signum() <= 0) {
throw new IllegalArgumentException("金额必须大于零");
}
}
}
final class AccountPayment implements PaymentMethod {
private final Account account;
AccountPayment(Account account) {
this.account = Objects.requireNonNull(account, "account");
}
@Override
public void pay(BigDecimal amount) {
account.withdraw(amount);
}
}
final class Checkout {
private final PaymentMethod paymentMethod;
Checkout(PaymentMethod paymentMethod) {
this.paymentMethod =
Objects.requireNonNull(paymentMethod, "paymentMethod");
}
void complete(BigDecimal amount) {
paymentMethod.pay(amount);
}
}
public class OopGuide {
public static void main(String[] args) {
Account account = new Account(new BigDecimal("100.00"));
PaymentMethod payment = new AccountPayment(account);
Checkout checkout = new Checkout(payment);
checkout.complete(new BigDecimal("35.50"));
System.out.println("剩余余额:" + account.getBalance());
}
}
将以上内容保存为 OopGuide.java,在安装了 JDK 的环境中执行:
javac OopGuide.java
java OopGuide
输出为:
剩余余额:64.50
这里的 Checkout 不需要知道付款对象内部如何完成扣款,只依赖 PaymentMethod。如果后来增加另一种付款实现,只要它符合接口约定,就可以传给 Checkout。这就是面向接口编程与多态协作的具体表现。示例中的类没有声明为 public,它们都放在同一个文件中,因此可以直接按文件名编译运行。
七、异常与参数边界:失败要清晰,但不要夸大保证
示例用 Objects.requireNonNull 拒绝空参数,用 IllegalArgumentException 表示金额不满足方法要求。参数校验应与方法契约一致:存款和取款金额必须大于零,取款金额不能超过余额。校验不应只是为了“看起来安全”,而应准确描述程序不接受什么输入。
异常用于表达无法按当前调用继续完成的情况。何时使用受检异常或非受检异常,要结合 API 契约和调用方是否能够合理恢复来判断。不要捕获异常后什么也不做,也不要把所有错误都包装成含糊的“操作失败”。面向用户的错误提示与内部诊断日志也应区分,日志中不要泄露密码、令牌等敏感信息。
本例使用 BigDecimal 表示金额,避免用二进制浮点数直接计算十进制金额时出现精度问题。它仍不代表完整的金融系统方案:实际业务还需定义币种、精度与舍入规则,并考虑并发更新、持久化事务、权限校验和审计要求。示例账户不是线程安全的,多线程同时修改余额时不能仅凭封装就保证结果正确。
还要理解参数约束的范围。这里的判断只约束本地 Java 方法收到的值,不会自动保护 HTTP 接口、数据库或其他服务。外部请求需要在相应边界验证;数据库也可能需要约束、事务和访问控制。安全性来自整个系统的设计,不能把某个类中的参数检查宣传成对所有攻击的防护。
八、常见错误与改进方式
- 把所有字段都公开,或给每个字段无条件生成 setter。这会让调用方随意改变对象状态。改进方式是先定义不变量,再只开放符合业务语义的操作。
- 为了复用代码而建立过深的继承层次。子类会承受父类契约和实现变化。先判断是否真有“是一个”的关系;只需要协作或复用时优先考虑组合。
- 把多态理解为“有很多子类”。多态的关键是调用方可以依赖共同类型,通过统一契约使用不同实现。子类数量多并不自动意味着设计良好。
- 过早为每个类创建接口。抽象的价值在于隔离变化、定义协作边界,而不是增加文件数量。只有存在替换需求、稳定边界或明确的多实现可能时,接口才更有意义。
- 把 getter 和 setter 当作封装本身。访问器只是方法形式。若 setter 允许对象进入无效状态,仍然没有维护好封装。
- 假设对象状态天然线程安全。私有字段只能限制访问路径,不会自动加锁。并发共享状态需要结合锁、不可变对象、原子类或其他并发设计处理。
- 用异常代替所有业务分支。明确且常见的业务结果,有时适合用返回值或领域类型表达;参数违约、不可恢复失败等才应按契约选择异常。重要的是让失败方式可理解、可处理。
九、把概念连起来,而不是孤立背诵
读这部分内容时,可以用一条线索串起概念:先识别对象的责任,再决定对象需要维护哪些状态;用封装规定状态如何变化;用组合安排对象之间的协作;当多个实现确实能满足同一契约时,用接口和多态解耦调用方;只有子类型关系稳定、行为契约一致时,再考虑继承。
实际设计没有一套适用于所有项目的固定类图。小型脚本可以采用简单函数和数据结构;复杂业务系统则更需要清晰的领域边界、可替换的依赖和明确的失败处理。面向对象不是把程序复杂化,而是把变化放到合适的位置,让一个对象的修改尽可能不迫使无关对象跟着修改。
从入门到熟练,最有效的方式不是一次性建立庞大的抽象模型,而是从具体需求出发:写出对象的责任,列出它必须保持的规则,再观察哪些协作关系会变化。这样再回看《Java编程思想》中的类、对象、继承、接口与多态,概念就不再是孤立术语,而是解决具体设计问题的工具。









