Java面向对象三大特性:封装、继承、多态从原理到工程实战
2026/9/7 20:20:58 网站建设 项目流程

很多人在学习面向对象三大特性时都经历过这种状态:课上听懂了封装的private、继承的extends、多态的重写,考试也能及格,但到了真正做项目时,脑子里还是一团浆糊,不知道该在什么场景下用哪个特性。

我刚开始工作的头两年就是这样。直到后来维护了一个大型遗留系统,每天被各种滥用继承的代码折磨,又被几个设计得极为漂亮的多态模型惊艳过之后,才逐渐想明白这三个特性的真正价值——它们是代码组织和演化的三条底层策略,而不只是语法点。

这篇文章我就以一个实际开发者的视角,把封装、继承、多态从概念到实战整个讲透,包括源码层面的机制、项目里最常见的用法、以及那些教科书上不会写但是工程里一定会踩的坑。

1. 为什么说三大特性是代码组织与演化的底层策略

如果我们抛开教科书上的定义,单纯从代码演化的角度来审视这三个特性,思路会清晰很多。一个软件项目的生命周期里,需求一定在变,代码一定在改。怎么让代码在不停修改的过程中保持清晰、稳定、可扩展?答案就是利用这三大特性去控制代码之间的耦合方向。

1.1 从代码演化的角度重新审视封装

封装的核心逻辑不是“隐藏数据”这四个字,而是控制变更的影响范围。假如没有封装,所有字段直接暴露,那么任何一行代码都能随意读取和修改任何字段。一旦业务规则发生变化,比如用户名格式变了、余额不能为负数了,所有被读写过的地方都要跟着改,排查起来非常痛苦。

有了封装之后,字段一旦加上private,外部代码就无法直接触达,唯一能操作数据的路径变成了public方法。这意味着,业务规则集中到了几个方法内部,将来变更时只需要修改这几个方法内部逻辑,调用方不需要改动。

这里要特别注意:封装真正的价值是提供了一个稳定的接口,内部实现可以随时换掉。比如说我用一个类来表示一个文件配置,内部可能是从JSON读取,也可能是从数据库读取,但对外暴露的loadConfig()方法保持不变,调用方就感知不到底层变化。这就是封装的威力。

1.2 继承解决的是“类型抽象”而不是“代码复用”

很多人一提到继承,第一反应就是“父类里有现成的代码,子类可以直接拿来用”。这个理解本身没有错,但会把继承引向一个非常危险的使用方向——为了省代码而去继承。

继承本质上表达的是一种类型关系:子类是一种父类。有了这层关系,我们才能用父类引用去指向子类对象,才能实现多态。比如Manager是一种Employee,Circle是一种Shape,这种“is-a”语义带来的类型抽象能力才是继承存在的核心意义。

如果仅仅是为了复用代码,优先考虑的应该是组合:在一个类里持有另一个类的实例,通过委托来复用功能。组合更加灵活,不受单继承限制,耦合也更低。后面我会用一个具体例子详细对比这两种方式。

1.3 多态是实现“面向接口编程”的技术支撑

多态的运作机制从程序员视角来看就是“同一个方法调用语句,在实际运行时执行的是具体对象的那个版本”。这句话听起来简单,但它背后隐藏了一个巨大的架构优势:上层代码不必依赖具体实现类,只依赖抽象类型,就能驱动无限多个具体实现。

这正是依赖倒置原则的实现基础。当你写了一个接受List参数的方法,调用方可以传入ArrayList、LinkedList甚至任何List的实现,你的方法完全不需要知道也不关心具体是哪个。系统因此变得更加开放——未来新增一个实现类,已有的代码不用动,扩展能力大幅提升。

2. 封装的工程粒度:从字段私有到模块自治

聊完了底层逻辑,我们进入实操层面。我在项目里看过大量封装不当的代码,最常见的两种:一种是所有字段干脆不设限,直接public,图省事;另一种是每个字段都配上getter和setter,看似封装了其实啥也没封住。这两类都背离了封装的本意。

2.1 不只是private:访问控制符的正确使用姿势

Java提供的访问控制符有四种(从宽到窄):public、protected、默认(包访问)、private。很多人只会用public和private,这是不够的。

我分享一个实际场景。假设你开发一个内部工具包,里面有个类专门处理字符串脱敏,这个类放在com.company.utils包中。你希望包内的其他工具类能直接调用它的某个辅助方法,但不希望外部项目的代码碰到它。这时,把方法声明为默认访问控制(不加任何修饰符),就能实现“包内可见、包外不可见”的效果。

类似地,protected的语义是“子类可见,但无关代码不可见”。这在设计框架、模板类时非常有用:允许子类重写或访问某些受保护的方法,但对外部调用者隐藏。

下面用一个表格整理访问控制符的可见性范围:

访问控制符同类内包内子类(跨包)全局
public
protected
默认(无)
private

实际开发中我的默认选择是:字段一律private,方法能收缩就收缩,只有在明确需要扩大可见性时,才逐步放宽。

2.2 可变对象与不可变对象的封装差异

封装时一个很容易忽略的细节是可变对象的安全暴露。比如:

public class Order { private List<String> items; public List<String> getItems() { return items; } }

这个getter看起来标准,实际上有一个大坑:因为List是引用类型,外部拿到items这个引用后,可以直接调用add方法往里面加数据,完全不经过Order类提供的任何校验逻辑。你的封装在引用传递面前被击穿了一半。

正确的做法是返回一个不可变视图,或者在不需要修改操作时返回副本:

public List<String> getItems() { return Collections.unmodifiableList(items); }

或者用Java 9+的List.copyOf方法返回一个不可修改的副本:

public List<String> getItems() { return List.copyOf(items); }

这个细节在构建API、DTO、领域模型时尤其重要。很多时候我们以为用了private就算封装好了,其实封装不仅仅是修饰符的问题,还得考虑返回类型本身是否会让内部状态泄露。

2.3 DTO、VO、Entity的分层本质是封装的放大版

从更宏观的视角看,分层架构中DTO(数据传输对象)、VO(视图对象)、Entity(实体)这些概念,本质上是封装思想在类与类之间的延伸。Entity负责承载业务数据与规则,不应该直接暴露给前端;DTO用于跨进程传递数据,把Entity的内部结构隔离起来。

比如在一个Spring Boot项目中,Controller层接收前端请求参数,通常不能直接用Entity接收,因为前端传参格式与数据库结构很可能不一致。我们要定义一个专门用于接收参数的DTO类,把参数校验逻辑封装在其中,再在Service层转换为Entity。

这看似多写了几个类,但实际上降低了层与层之间的耦合。某个字段Entity内部改了,只要DTO接口没变,Controller层体验不到;反过来,前端参数变了,也只是改DTO和Controller的映射关系,不影响底层数据结构。

3. 继承的正确姿势与那些年我们踩过的坑

继承是个好工具,但也是最容易用错的语法。我维护的老系统里就嵌套了四五层继承关系,改一个基类方法,几十个子类的行为都可能受影响,测试都要跑半天。下面我把继承的关键点和坑逐个讲清楚。

3.1 构造器调用顺序:子类初始化之前,父类先初始化

一个很基础但经常被忽略的知识点:子类的构造器第一条语句,一定是调用父类的构造器(显式使用super(...)或隐式调用无参父类构造器)。所以创建子类对象时,对象的初始化顺序是:父类静态块 → 子类静态块 → 父类实例变量初始化 → 父类构造器 → 子类实例变量初始化 → 子类构造器。

这个顺序在项目里的实际意义是:如果父类构造器调用了被子类重写的方法,那么子类的实例字段可能还没有初始化完毕,就会读到null或默认值。这是一个非常隐蔽的坑,看个例子:

class Parent { Parent() { init(); } void init() { System.out.println("Parent init"); } } class Child extends Parent { private String name = "child"; @Override void init() { System.out.println("Child init, name=" + name); } }

创建Child对象时,输出的结果是“Child init, name=null”,而不是预期中的“child”。原因是父类构造器执行时,子类的字段初始化语句还没有跑完。虽然子类字段在字节码层面已经在准备阶段分配了内存,但赋值在构造器链之后才开始。

所以经验法则是:在构造器中调用可被重写的方法是高危行为,尽量避免;如果确实需要,优先调用非虚方法或static方法。

3.2 super关键字的多层传递与菱形继承的破解

super在Java中代表父类引用,但多层继承之后,super并不能直接跳到任意祖先层。例如GrandParent → Parent → Child,在Child中使用super只能调用Parent的方法,不能直接super到GrandParent的方法。如果需要,必须在Parent中显式定义一个透传方法。

至于C++中著名的菱形继承问题(一个类继承两个有共同祖先的类),Java用“单继承类 + 多实现接口”的模型从语言层面消灭了。但接口也可以继承多个其他接口,如果几个父接口里有相同签名的default方法,子接口或实现类会强制要求自己重写并指定用谁的:

interface A { default void hello() { System.out.println("A"); } } interface B { default void hello() { System.out.println("B"); } } class C implements A, B { @Override public void hello() { A.super.hello(); // 显式指定使用 A 的实现 } }

这种处理方式确实麻烦,但也强制开发者想清楚“我这个类到底应该怎么表现”,而不是让编译器模棱两可。

3.3 重写的规则:不是你想改就能改

子类重写父类方法时有几个硬性约束:

  • 方法签名必须一致(方法名、参数列表都相同)。
  • 返回类型可以相同,也可以是原返回类型的子类型(协变返回类型)。比如父类返回Object,子类可以返回String。
  • 访问权限不能比父类更严格。比如父类是public,子类就必须是public。
  • 不能抛出比父类更宽泛的受检异常。父类方法没声明抛IOException,子类就不能抛IOException。

日常开发中,我强烈建议在重写方法上加上@Override注解。这个注解不仅是给阅读者看的,更重要的是编译器会帮你校验上面那些规则,一旦不满足会直接报编译错误。如果一个方法你以为在重写,实际因为参数类型写错变成了重载,没有@Override注解的话,编译器是不会提示的,运行时父类的逻辑就不会被替换掉——这类bug排查起来特别恼火。

3.4 组合优先于继承的经典案例对比

前面说到复用代码优先考虑组合,这里用一个具体例子来对比。假设我们要给OrderService增加一个日志导出功能。继承方式可能这样写:

class OrderService extends FileExporter { // 继承了FileExporter的导出方法,直接使用 }

如果OrderService将来还需要导出到Excel呢?A类无法同时继承两个类,所以还得继续加父类层级,或者改FileExporter的支持范围,非常被动。

组合方式则是:

class OrderService { private FileExporter exporter; public OrderService(FileExporter exporter) { this.exporter = exporter; } public void export(Order order) { exporter.export(order); } }

OrderService内部持有FileExporter,并通过构造函数注入。将来要支持Excel,只需要再实现一个ExcelExporter传入即可,OrderService本身不用变化。这就是组合的最大优势:行为在运行时可以自由切换,而继承在编译期就把行为焊死了。

所以我现在的编码准则很简单:凡是“是”的关系,用继承;凡是“有”的关系,用组合。Manager is an Employee,用继承;OrderService has a FileExporter,用组合。

4. 多态的实现机制与实战应用场景

如果说封装和继承是代码内容的组织方式,那么多态就是系统扩展性的灵魂。我先从JVM层面拆解一下多态到底是怎么做到的,这能解答很多人心中“为什么运行时会走子类方法”的疑惑。

4.1 虚方法表:多态的底层真相

Java中非静态、非private、非final方法默认就是虚方法,调用时会走动态分派。JVM在类加载阶段为每个类维护一张虚方法表,里面记录了类中所有虚方法的入口地址。子类继承了父类方法后,如果重写了某个方法,虚方法表中该方法的入口地址就会指向子类自己的实现;如果没有重写,则继续指向父类的实现。

当程序执行到一个方法调用指令时,JVM根据调用者对象的实际类型,去对应的虚方法表中查找目标方法的入口地址。因为对象在创建时,其内部的数据结构中已经包含了指向自身所属类的虚方法表引用,所以运行时能精确找到正确的方法版本。

这个过程也解释了为什么向上转型后,调用被重写方法时会执行子类版本:

Animal animal = new Dog(); animal.sound(); // 实际执行 Dog 覆盖后的 sound()

animal变量的编译时类型是Animal,但运行时实际对象是Dog,JVM会沿着Dog的虚方法表寻找sound(),因此执行Dog的版本。而如果Dog没有重写sound(),虚方法表里该条目仍然指向Animal的版本,那就执行Animal的逻辑。

4.2 重载与重写的本质区别

多态分为编译时多态和运行时多态。重载是编译时多态,发生在编译器确定调用哪个方法的阶段,依据是方法名字相同但参数列表不同;重写是运行时多态,发生在JVM动态分发阶段,依据是对象的实际类型。

我遇到过一个有意思的笔试常考陷阱:

class Parent { public void print(String s) { System.out.println("Parent String"); } } class Child extends Parent { public void print(Object o) { System.out.println("Child Object"); } } public class Test { public static void main(String[] args) { Parent p = new Child(); p.print("hello"); } }

这个输出结果是“Parent String”。原因是Child类中的print(Object)并没有重写Parent的print(String)(参数列表不同,是重载),所以Parent的print(String)对Child来说仍然存在。调用p.print("hello")时,编译阶段根据p的静态类型Parent确定选择signature为print(String)的方法,于是走Parent的实现。虽然运行时对象是Child,但Child没有重写print(String),虚方法表中该方法的入口仍然是Parent的版本。

这类案例特别能检验开发者对“编译时绑定”和“运行时绑定”的理解程度,我建议好好消化一下。

4.3 各种语言多态的写法对比

多态的实现方式在不同语言里有明显的差异,看一眼对比能加深理解:

语言多态实现方式备注
Java接口 + 继承 + 重写运行时分派基于虚方法表
C++虚函数 + virtual关键字通过虚函数表(vftable)实现
Python鸭子类型不要求继承关系,只要对象有同名方法即可
Dart继承 + Mixinmixin可以混入多个行为对象
Rusttrait + 泛型(静态分发)+ trait对象(动态分发)没有传统继承

对比之后会发现,静态语言大多数靠“继承体系 + 虚方法表”来完成动态分派,而动态语言往往靠运行时类型检查来调用任何对象上的同名方法。Rust用trait对象完成类似多态的目标,但采用显式对象安全约束,保证调用成本预知。

对于Java开发者来说,理解虚方法表是理解多态的关键;对于Python开发者来说,理解鸭子类型意味着你甚至可以不需要继承,只要对象实现了约定的行为即可,灵活但缺少类型约束,所以出现了协议(Protocol)来补齐。

4.4 策略模式与模板方法:多态在项目里最经典的两个落点

模式是工具,不是教条。但了解两个最常用的多态应用场景,能让你的代码优雅许多。

策略模式解决的是“同一功能的不同算法实现”问题。比如订单金额的折扣策略,可能是满减、立减、VIP折扣。每个策略一个实现类,公共接口定义统一的计算方法:

public interface DiscountStrategy { BigDecimal calculate(BigDecimal amount); } public class FullReductionStrategy implements DiscountStrategy { private BigDecimal threshold; private BigDecimal reduction; // 构造器、getter、setter 省略 @Override public BigDecimal calculate(BigDecimal amount) { if (amount.compareTo(threshold) >= 0) { return amount.subtract(reduction); } return amount; } } public class VipDiscountStrategy implements DiscountStrategy { private double rate; // 构造器、getter、setter 省略 @Override public BigDecimal calculate(BigDecimal amount) { return amount.multiply(BigDecimal.valueOf(rate)); } }

调用方OrderService只需要持有DiscountStrategy引用,在运行时注入具体策略即可。新增折扣方案时,新增一个实现类,OrderService一行不用改,这就是开放封闭原则的实践。

模板方法模式则是利用继承来实现“算法骨架不变、具体步骤由子类实现”的需求。它的关键是把通用流程写在父类模板方法里,把可变步骤抽成protected方法,子类选择重写哪些步骤。

比如一个数据导入器,通用流程一定是:打开文件 → 解析数据 → 校验数据 → 写入数据库。打开/解析/写入的逻辑在不同文件格式下是不同的,校验逻辑可能部分相同。父类可以定义模板方法:

public abstract class AbstractDataImporter { public final void importData(String filePath) { Data data = parseFile(filePath); validateData(data); writeToDatabase(data); } protected abstract Data parseFile(String filePath); protected void validateData(Data data) { // 默认校验逻辑:非空、长度等 } protected abstract void writeToDatabase(Data data); }

导入JSON、Excel各自的子类,只需要关心自己怎么解析、怎么写入。校验逻辑作为钩子方法,想要加强校验的子类就重写,不需要变化的不动。

我实际用模板方法重构过一个报表导出模块,把从查询数据→填充模板→生成文件的流程统一到了父类,子类只关心各种报表的数据填充逻辑,代码量减少了将近一半。

5. 一个贯穿案例:支付模块重构中的三大特性协同设计

理论讲了这么多,真正能体现三大特性协作价值的,往往是某个完整的业务模块。我挑一个非常经典的支付模块重构来串一遍,因为支付扩展场景特别清晰,多支付渠道几乎是标配需求。

5.1 需求背景与最原始的糟糕版本

项目起初只有支付宝支付,当时的代码可能是这样的:

public class PaymentService { public void pay(String channel, BigDecimal amount) { if ("alipay".equals(channel)) { // 调用支付宝SDK的支付逻辑 System.out.println("使用支付宝支付:" + amount); } else if ("wechat".equals(channel)) { // 后来加微信支付,直接在原方法里加分支 System.out.println("使用微信支付:" + amount); // 再后来加银联支付,又来一个else if } } }

这种写法的问题是显而易见的:每次新增支付渠道,都要修改pay方法,相当于对支付金额逻辑的所有调用方都暴露了变异风险。而且随着分支增多,方法越来越长,各种支付渠道的不同参数、不同签名处理都堆在一个方法里,迟早变成一锅粥。

5.2 用封装、继承、多态重构后的设计

重构分三步走,正好用上三大特性。

第一步,封装支付参数。建立PaymentRequest类,把原始金额、订单号、支付渠道类型、可选的附加参数都收拢到一个类中,并提供校验逻辑:

public class PaymentRequest { private final String orderId; private final BigDecimal amount; private final PayChannel channel; private final Map<String, String> extraParams; public PaymentRequest(String orderId, BigDecimal amount, PayChannel channel, Map<String, String> extraParams) { if (orderId == null || orderId.isBlank()) { throw new IllegalArgumentException("订单号不能为空"); } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("支付金额必须大于零"); } this.orderId = orderId; this.amount = amount; this.channel = channel; this.extraParams = extraParams == null ? Map.of() : Map.copyOf(extraParams); } public String getOrderId() { return orderId; } public BigDecimal getAmount() { return amount; } public PayChannel getChannel() { return channel; } public Map<String, String> getExtraParams() { return extraParams; } }

这里字段设为final,构造器内完成校验,extraParams通过Map.copyOf防止外部修改内部集合。这就是封装的完整落地。

第二步,用继承建立支付渠道的抽象体系。定义一个抽象父类AbstractPayChannel,包含所有渠道都需要做的通用逻辑(记录日志、处理重复通知等),同时定义两个抽象方法,要求子类实现各自的签名和下单逻辑:

public abstract class AbstractPayChannel { protected abstract String sign(Map<String, String> params); protected abstract boolean requestPayment(PaymentRequest request); public final boolean pay(PaymentRequest request) { // 1. 统一埋点日志 System.out.println("开始支付,订单号=" + request.getOrderId() + ",金额=" + request.getAmount()); // 2. 渠道特有签名 String sign = sign(request.getExtraParams()); if (sign == null || sign.isBlank()) { return false; } // 3. 每个渠道自己的下单请求 return requestPayment(request); } }

这里pay被声明为final,子类不需要也不应该重写整个算法流程,只需要关注签名和请求这两个变化点。

第三步,各种支付渠道的类继承父类,填充各自实现:

public class AlipayChannel extends AbstractPayChannel { @Override protected String sign(Map<String, String> params) { // 支付宝的RSA签名逻辑 return "alipay-sign"; } @Override protected boolean requestPayment(PaymentRequest request) { // 调用支付宝SDK下单 System.out.println("调用支付宝下单接口"); return true; } } public class WechatPayChannel extends AbstractPayChannel { @Override protected String sign(Map<String, String> params) { // 微信的MD5签名逻辑 return "wechat-sign"; } @Override protected boolean requestPayment(PaymentRequest request) { // 调用微信支付下单接口 System.out.println("调用微信支付下单接口"); return true; } }

最后在PaymentService里,维护一个Map,key是渠道枚举,value是通道实现类:

public class PaymentService { private final Map<PayChannel, AbstractPayChannel> channelMap; public PaymentService() { channelMap = new HashMap<>(); channelMap.put(PayChannel.ALIPAY, new AlipayChannel()); channelMap.put(PayChannel.WECHAT, new WechatPayChannel()); } public boolean pay(PaymentRequest request) { AbstractPayChannel channel = channelMap.get(request.getChannel()); if (channel == null) { throw new UnsupportedOperationException("不支持的支付渠道:" + request.getChannel()); } return channel.pay(request); } }

此时pay方法已经没有if-else了,完全基于多态完成分发。将来要接入银联,只需要新增一个UnionPayChannel类,在初始化的map里加一行,业务调用方的代码一行都不用动。

5.3 重构带来的具体收益评价

这个重构价值不是体现在语法上的“用了三大特性”,而是体现在几个可量化的维度上:新增支付渠道的代码改动量从原来的“改方法体加分支”变为“新增类 + 注册一行”;各渠道之间彻底隔离,不会出现支付宝改坏微信的情况;测试也可以针对具体Channel做单元测试,不再需要构造全路径分支覆盖。

我在另外的项目中也见过类似用switch分发支付渠道的写法,逻辑稍复杂一点就出现渠道参数泄漏、幂等处理重复、日志混乱等问题。与其等代码腐化了再重构,不如在一开始就按多态模型设计。

6. 面试与笔试题里关于三大特性的高频误区

把三大特性学扎实,能直接体现在面试表现上。下面这几个点是我经常看到候选人和笔试者搞混的,单独拎出来列一下。

6.1 静态方法为什么不能重写

静态方法属于类本身,不属于对象实例。所以子类定义一个与父类静态方法签名相同的方法,它不是重写,而是“隐藏”。调用哪个方法,取决于你的引用类型:

class Parent { public static void show() { System.out.println("Parent static show"); } } class Child extends Parent { public static void show() { System.out.println("Child static show"); } } Parent p = new Child(); p.show(); // 输出 Parent static show Child c = new Child(); c.show(); // 输出 Child static show

由于静态方法调用在编译期就决定了,所以没有运行时多态。这也是为什么我建议不要通过对象引用来调用静态方法,直接用类名调用,能避免歧义。

6.2 向上转型后能访问哪些成员

父类引用指向子类对象时,只能访问父类中定义的成员(包括被重写后仍由子类实现的虚方法),无法访问子类新增的成员。比如:

class Parent { public void parentMethod() {} } class Child extends Parent { public void childMethod() {} } Parent p = new Child(); p.parentMethod(); // 没问题 p.childMethod(); // 编译错误,Parent类型中没有childMethod

这一点在设计接口时很有用:尽量把调用方需要的方法定义在父类或接口中,否则上层拿不到向下转型的资格就在抛ClassCastException的边缘试探了。

6.3 多态与类型转换的常见陷阱

向下转型(强转)不是随便用的,前提是对象确实是目标类型。错误的强转会在运行时抛ClassCastException。更稳妥的写法是用instanceof判断或者用Java 16+的instanceof模式匹配:

if (animal instanceof Dog dog) { dog.fetch(); }

这个新特性不仅简化了代码,还自动帮你完成了强转,很舒服。

我见到的另一个陷阱是集合泛型缺乏多态性。List 并不是List 的子类型,因此不能把一个List 直接赋给List 。泛型默认是不变的(invariance),想实现协变需要使用通配符:

List<Dog> dogs = new ArrayList<>(); List<? extends Animal> animals = dogs; // 正确,协变

但是通过这个通配符声明的List,只能读不能添加新元素,因为编译器不确定里面的具体类型。这也是面试里爱问的“泛型为什么不支持多态”,本质是类型安全权衡的结果。

7. 日常开发中的设计建议与个人经验

7.1 一定不要为了设计而设计

三大特性是工具,不是终极目标。如果一个类未来几乎不可能有第二个实现,硬加接口去“面向接口编程”反而增加理解成本。我见过一个demo项目恨不得给每个service都配接口+两个实现类,最后维护的人根本分不清该用哪个。

正确的判断标准是看变化的方向。如果你确实能看到未来的扩展点,比如支付渠道、消息推送渠道、多种存储方案,那么现在就抽象出一个稳定的接口,让依赖这个接口的代码不跟随实现变化。如果只是CRUD一个用户表,真的没必要套一堆设计模式进去。

7.2 package-info.java也是封装边界

在我工作的Java项目中,还会利用package-info.java编写包级别的文档,标注这个包对外暴露的入口类、内部约定。这虽不是语法层面的强约束,但在团队协作里能帮助其他人理解包设计的边界,不会轻易在一个工具包里随意加public类。

另外,模块化(Java 9+的module-info.java)更进一步,可以对外隐藏包内实现,只开放指定的导出包。这算是封装思想在模块层面的终极形态。

7.3 测试驱动你更好地理解继承与多态

写单元测试能帮你发现继承设计的问题。如果你为了测试某个类,不得不mock掉很多父类行为,说明父类对子类的侵入太强了。一个好的多态设计里,各个实现类应该像插件一样,可以独立替换、独立测试。

我在重构支付模块时给每个Channel都单独写了测试类,互相之间完全隔离,测试稳定性大幅提升。反过来,如果某个实现类测试里频繁依赖另一个类的内部数据,就要检查封装是否被破坏了。

7.4 学三大特性最有效的方式:读优秀源码

框架源码是学习封装、继承、多态的最佳教科书。比如Spring的BeanFactory体系,顶层是接口,中间有抽象类实现一些通用逻辑,底层有各种具体容器。你在看它的类图时,能直观地看到抽象、扩展、分工是如何安排的。

再比如Java集合框架的Collection接口 → AbstractList抽象类 → ArrayList实现类,这套三层结构就是把稳定接口、公共逻辑、具体实现分开的范本。建议大家挑一个自己常用的框架,把核心类之间的继承关系画出来,远比背十遍“什么是多态”有效。

我入行前几年踩过不少继承的坑,后来靠大量读源码、反复重构小案例,才慢慢形成自己的设计手感。希望这篇文章能帮你少走一点弯路,把封装、继承、多态真正变成你写代码时自然流露的能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询