用Spring Resource源码彻底搞懂Java继承与多态
2026/9/23 4:30:04 网站建设 项目流程

先说一个我经常遇到的现象:很多人学 Java 基础的时候,继承和多态能倒背如流,AnimalDogCat的代码写得飞起,但只要一打开 Spring 这类框架的源码,立刻就懵了——明明每个类都认识,连起来不知道在干嘛。

其实这不是你基础差,而是教材里的例子太“干净”了,它只教你语法,不教你设计。所以这篇文章我想换一个讲法:直接用 Spring 的Resource体系当主线,把继承、多态、重写、重载、静态绑定、动态绑定这些知识点全部串起来讲。Resource这个体系非常合适,类不多、层次清楚、几乎每个继承关系背后都有一个真实需求。把这套东西吃透,你不但能真正读懂框架源码,Java 面试里关于继承和多态的高频追问也能应对得更从容。

下面是详细拆解。

1. 为什么我建议用 Spring Resource 学继承与多态

1.1 三层继承结构:接口、抽象类、实现类

Spring 的Resource体系做的是一件很简单的事:统一抽象“外部资源”。一个配置文件可以在 classpath 下,可以在文件系统里,也可以在一个远程 URL 上,甚至在内存字节数组里。从使用者的角度看,它们的共同点是“都能给我一个InputStream让我读数据”。

为了表达这种统一抽象,Spring 设计了典型的三层结构:

  • 顶层接口Resource,继承自InputStreamSource,定义了一个资源应该具有的所有行为;
  • 中间层抽象类AbstractResource,把一些公共逻辑先实现掉,留给子类一个更轻量的实现起点;
  • 底层一堆具体实现类,比如ClassPathResourceFileSystemResourceUrlResourceByteArrayResource等,各自对接一种真实资源形态。

这三层结构正好对应 Java 继承体系里最重要的三种角色:接口定义契约,抽象类提供骨架,具体类完成实现。

1.2 和教科书 Animal 例子差在哪

AnimalDogCat的例子你肯定写过:

class Animal { void speak() {} } class Dog extends Animal { void speak() { System.out.println("汪汪"); } } class Cat extends Animal { void speak() { System.out.println("喵喵"); } }

这个例子能说明语法,但说明不了设计。因为没人会在真实项目里为了让动物叫而写一个继承体系。真实项目里的继承,每一个层级都有它存在的理由:接口是为了让调用方不依赖具体类型,抽象类是为了让多个实现类共享逻辑,具体类是为了响应不同场景的需求。

Resource体系正好把这三层理由展示得清清楚楚。你不需要去背“继承是什么”,看一遍AbstractResource里那些默认方法,自然就理解了“抽象类到底抽象了什么”。

1.3 这篇你能带走什么

这篇文章接下来会做几件事:先拆Resource继承体系的结构,然后从调用方ResourceLoader看多态如何生效,再扎到 JVM 层面讲清楚构造器执行顺序、方法重写、静态绑定和动态绑定,最后带你手写一个自定义Resource,把整套思路平移到业务代码里。

如果你正在准备 Java 面试,这篇文章可以直接当复习材料;如果你是想提升读源码能力的中级开发者,这篇文章给你一个切入框架源码的锚点。

2. Resource 接口与 AbstractResource:继承的价值不在代码复用

2.1 接口只做一件事:定义资源的行为契约

Resource接口继承自InputStreamSource,而InputStreamSource只有一个方法:

public interface InputStreamSource { InputStream getInputStream() throws IOException; }

这是整个体系的基石——任何资源,最终都要能变成流。Resource在此基础上又扩展了一批方法,大致可以分成几组:

  • 判断能力:exists()isReadable()isOpen()isFile()
  • 定位能力:getURL()getURI()getFile()createRelative()
  • 元信息:contentLength()lastModified()getFilename()getDescription()

注意这些方法并不关心资源“从哪里来”,只关心“你能不能提供这些信息”。这就是接口的意义:它站在使用方的角度定义契约。Spring 容器加载配置时,它的诉求是“给我一个InputStream,我要读配置”,至于流来自 classpath 还是文件系统,容器根本不在意。

getDescription()这个方法很少被单独提到,但它其实很重要。看到这个名字,你可能会觉得它只是用来打印日志的。确实如此,但它的价值远超想象:所有Resource实现都会重写这个方法,当资源找不到、流打开失败时,异常信息里带着的就是这个描述,比如class path resource [application.yml]file [/etc/config/app.properties]。排查问题的时候,一眼就能定位是哪个资源出了问题。

2.2 AbstractResource 如何用默认实现减轻子类负担

AbstractResource是理解“抽象类到底有什么用”的最佳样本。它实现了Resource接口的大部分方法,子类只需要关心自己真正特殊的地方。

最典型的是exists()的默认实现逻辑。它没有规定死“存在”的判定方式,而是先尝试getFile(),拿不到文件就尝试getURL(),再不行就认为资源不存在。这个试探链路非常聪明,因为不同子类能力不同:FileSystemResource能直接返回文件,UrlResource只能返回 URL,父类不硬性规定路线,而是给了一个通用策略。

isOpen()的默认实现也很有意思,它默认返回false。为什么?因为大部分资源——比如 classpath 下的文件、文件系统里的文件——都是可以重复读取的,你打开流读一次,再打开还能读一次。但InputStreamResource这种包装了“一次性流”的资源不一样,流读完了就没了,所以它必须把isOpen()重写成true。调用方可以根据isOpen()决定要不要对资源做缓存。

这就是抽象类典型的“模板方法”思路:把公共流程固化下来,把变化点留给子类去覆盖。

2.3 抽象类没有抽象方法,也可能是“抽象”的

这里有一个容易被忽视的 Java 语法细节。AbstractResource类声明为抽象类,但它内部并没有显式声明任何abstract方法。它实现Resource接口时,唯独没有实现getInputStream()getDescription(),于是这两个从接口继承来的方法依然是抽象的。

这意味着什么?意味着只要一个类实现了接口但没把接口方法全部实现,即使它自己不写abstract关键字,这个类也天然是抽象的,不能new。子类必须至少把getInputStream()getDescription()实现了,才能实例化。

换句话说,抽象类的“抽象性”不只来自显式的abstract方法,还可以来自“未实现的接口方法”。搞清楚这一点,很多源码里的抽象类你都不会再看错。

2.4 为什么不用接口 default 方法一撸到底

Java 8 之后接口也能写方法体了,那AbstractResource还有存在的必要吗?这个问题面试里经常被问到,答案可以从三个角度拆:

第一,时间角度。AbstractResource在 Java 8 之前就存在了,接口默认方法是后来的补充,不能拿新语法去否定老设计。

第二,能力角度。抽象类能持有共享状态,比如保存一个ClassLoader、保存一个Path;抽象类的方法访问权限更灵活,可以用protected暴露给子类;抽象类还可以定义final方法禁止子类覆盖。这些接口都做不到,接口的成员默认是public

第三,兼容角度。接口加default方法主要是为了“给接口加方法时不破坏已有实现类”。比如 Spring 后来给Resource加了新方法,就用默认方式补上,避免所有实现类立刻报错。这和抽象类解决“复用骨架代码”是完全不同的两个问题。

所以结论是:在需要“骨架复用”的场景,抽象类仍然不可替代;接口默认方法侧重“演进兼容”。两者是配合关系,不是替代关系。

3. 具体资源实现类:继承后的差异化玩法

3.1 ClassPathResource:类路径资源背后的路径清洗逻辑

ClassPathResource是处理 classpath 下资源的实现类,用法很简单:你给它一个classpath:application.xml这样的路径,它负责通过类加载器去找到这个资源。

但它有一个隐藏的细节很多人不知道——路径清洗。你传入的路径可能以/开头,比如/config/app.properties,但在ClassLoader.getResourceAsStream()的语义里,类路径下的资源路径是不以/开头的。所以ClassPathResource在构造器里会做一次normalizePath():去掉开头的斜杠、把反斜杠转成正斜杠、清理路径中的冗余部分。这个细节很容易被忽略,但如果你自己写代码加载类路径资源时遇到路径对不上,八成就是没做清洗。

它的getInputStream()实现也很直白:

@Override public InputStream getInputStream() throws IOException { if (this.clazz != null) { return this.clazz.getResourceAsStream(this.path); } else { return this.classLoader.getResourceAsStream(this.path); } }

这里有两个可选的加载入口:如果有Class,就用Class.getResourceAsStream();如果没有,就用ClassLoader.getResourceAsStream()。拿到流还好说,拿不到就抛FileNotFoundException,异常信息里带上getDescription()的返回值。

还有一个值得注意的点:它重写了equals()hashCode(),比较的是pathclassLoader。为什么要重写?因为在 Spring IoC 容器里,经常要判断“这个资源是不是已经被处理过了”,比如配置去重。如果两个ClassPathResource指向同一个路径、同一个类加载器,它们就是同一个资源,这个判断离不开equals()

3.2 FileSystemResource:文件系统的直接映射

FileSystemResourceClassPathResource是同一层的“兄弟类”,都直接继承AbstractResource,但底层数据源完全不同。FileSystemResource包装的是FilePathgetInputStream()本质就是Files.newInputStream()getFile()直接返回底层文件对象,isFile()永远是true

这两个类的差异恰好展示了“同一个父类,不同实现策略”的多态价值。我用表格对比一下:

方法ClassPathResourceFileSystemResource
getInputStream()通过 ClassLoader 加载Files.newInputStream()
getFile()需将 URL 转 File直接返回 File
isFile()依赖 URL 协议判断恒为 true
getDescription()class path resource [xxx]file [xxx]

调用方拿到一个Resource引用时,根本不需要知道它内部是走类加载器还是文件流,统一调getInputStream()就行。这就是多态最直观的体现:同一个方法调用,在不同实现类上执行了完全不同的逻辑。

3.3 重写父类方法的两个真实案例:FileUrlResource 与 ClassPathContextResource

如果说上面的“兄弟类”是继承的常规操作,那FileUrlResource就是“子类重写父类方法”的典范。它继承自UrlResource,专门处理file:协议的 URL,而它重写的最核心方法就是getFile()

为什么要重写?因为直接new File(url.getFile())在很多场景下不靠谱。URL 里的文件路径可能包含 URL 编码后的空格,比如/home/my%20file.txt,直接转换成文件路径会得到错误的文件名。FileUrlResource换了一种更稳妥的解析方式,让file:URL 能正确地转成File对象。你看,父类UrlResource的实现不是错的,只是不够好,于是子类在特定分支上覆盖了父类的行为。这个“因为场景不满足而重写”的动机,比教科书里“为了演示重写而重写”有说服力得多。

ClassPathContextResource也是一个很好的例子。它是DefaultResourceLoader内部定义的一个ClassPathResource子类,重写了createRelative()方法。正常情况下,ClassPathResource.createRelative()返回一个普通的ClassPathResource,而ClassPathContextResource重写后返回的是ClassPathContextResource本身,目的是保持“上下文资源”的类型一致性,避免相对路径创建时退化成普通资源。这个改动很小,但能让你看到重写在真实代码里的另一个用途:修正或增强父类方法的行为。

3.4 这些差异怎么体现多态

把上面三个小节串起来看,你会发现整个Resource继承体系里,方法调用分成了两条路:

  • AbstractResource继承下来的默认实现,比如exists()isOpen(),子类不重写就直接复用;
  • 子类按需重写的方法,比如getInputStream()getDescription()getFile(),每个实现类都有自己的逻辑。

调用方持有一个Resource引用时,它调用getInputStream()到底走哪个实现,取决于这个引用实际指向的对象类型。这,就是多态的运行期行为。在编译期你根本确定不了,只有运行到那一行,JVM 才知道真正的对象是ClassPathResource还是FileSystemResource

4. ResourceLoader:从调用方视角再看多态

4.1 DefaultResourceLoader 的分发逻辑

继承体系的生产方看完了,现在我们切换视角,站在调用方看多态为什么好用。Spring 里负责把“资源字符串”转换成Resource对象的接口叫ResourceLoader,最常用的实现是DefaultResourceLoader

它的核心方法getResource(String location)内部逻辑大致是这样:

public Resource getResource(String location) { // 先让自定义 ProtocolResolver 处理 Resource resource = getResourceByProtocol(location); if (resource != null) { return resource; } if (location.startsWith("/")) { // 以 / 开头,按路径资源处理 return getResourceByPath(location); } else if (location.startsWith(CLASSPATH_URL_PREFIX)) { // 以 classpath: 开头,创建 ClassPathResource return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader()); } else { try { URL url = new URL(location); // 可解析成 URL:file 协议走 FileUrlResource,其余走 UrlResource return (ResourceUtils.isFileURL(url) ? new FileUrlResource(url) : new UrlResource(url)); } catch (MalformedURLException ex) { // 无法解析成 URL,按路径资源处理 return getResourceByPath(location); } } }

注意看这段代码的模式:它没有去判断“这个路径对应的文件存不存在”“这个 URL 指向什么内容”,只根据字符串前缀做了一件事——返回一个合适的Resource实现。真正的资源读取行为,全部被延迟到了Resource内部。调用方拿到Resource之后,只会调接口方法,完全不关心背后具体是哪个类。

这就是面向接口编程的核心价值:ResourceLoader负责“选择合适的实现类”,上层业务负责“面向接口消费资源”,生产方和消费方被成功解耦。

4.2 ProtocolResolver:不改源码的扩展点

DefaultResourceLoader还支持一个高端玩法:ProtocolResolver。如果你自定义了一个协议,比如oss://bucket/object,默认的ResourceLoader根本不知道该怎么处理,因为oss不是标准 URL 协议,new URL()会抛异常。

但你可以注册一个ProtocolResolver,在getResourceByProtocol()阶段提前拦截:

public interface ProtocolResolver { Resource resolve(String location, ResourceLoader resourceLoader); }

DefaultResourceLoader会遍历所有注册的ProtocolResolver,只要有一个能返回非空Resource就用它。这意味着你可以不修改 Spring 的任何源码,就为框架增加一种全新的资源类型。

从多态的角度看,这其实是策略模式和多态的叠加:把“字符串如何映射到 Resource”开放成策略接口,每种策略是一个独立的实现,运行时动态路由。Spring 后面能衍生出ServletContextResource等一堆资源类,靠的就是这套可扩展能力。

4.3 多态好用的前提和容易踩的坑

多态很好用,但它不是没有约束。有三件事我建议你牢牢记住:

第一,编译期以静态类型为准。Resource r = new ClassPathResource(...),那r只能调用Resource接口里定义的方法。如果你想调用ClassPathResource独有的方法,必须强制类型转换,转换前最好用instanceof判断一下。

第二,运行期按实际类型分派。接口方法被调用时,JVM 会根据对象的实际类型找到最合适的重写版本,这就是动态绑定。

第三,字段和静态方法不参与多态。这个坑很多人第一次遇到都懵了,下一节我会专门展开讲。

5. 继承与多态底层机制:构造器、重写、绑定,一次说清楚

5.1 new 一个子类时到底发生了什么

继承体系里,对象的创建顺序是一条主线。看这段代码:

public class Parent { static { System.out.println("1 Parent static"); } { System.out.println("3 Parent instance"); } public Parent() { System.out.println("4 Parent constructor"); } } public class Child extends Parent { static { System.out.println("2 Child static"); } { System.out.println("5 Child instance"); } public Child() { System.out.println("6 Child constructor"); } }

执行new Child()时,输出顺序是:

1 Parent static 2 Child static 3 Parent instance 4 Parent constructor 5 Child instance 6 Child constructor

这个顺序背后的原因不复杂:

  • 类加载阶段执行静态块,父类先于子类,所以先打印 1 再打印 2;
  • 创建实例阶段,JVM 必须先把父类的实例空间初始化好,再初始化子类部分,所以父类的实例块和构造器会先执行;
  • 父类构造器执行时,子类的实例变量还没有被赋值,还停留在默认值阶段。

super()这条隐式调用链是关键:子类构造器的第一行(你没写也会默认有)会调用父类构造器。如果父类没有无参构造器,子类必须在构造器第一行显式调用super(参数)

5.2 构造器调用可重写方法的经典陷阱

理解了上面的执行顺序,你就可以理解一个经典的坑:父类构造器里调用了一个可被重写的方法。

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

执行new Child(),输出是什么?如果你认为是Parent,那就错了。父类构造器执行时,JVM 调用show()会动态绑定到子类的重写版本上,但此时子类的name字段还没有被赋值为"child",它的默认值是null。所以输出是null

这个例子说明了一个非常重要的设计原则:不要在构造器里调用可重写的方法。如果父类构造器一定要做一些初始化操作,最好提供一个protectedinit()钩子方法,并用final修饰,明确禁止子类重写,或者干脆等子类构造完成后由框架回调。Spring 里的InitializingBean#afterPropertiesSet()@PostConstruct其实都在做同一件事:避免“构造期间调用虚方法”的陷阱。

5.3 方法重写规则清单与 @Override 的真实作用

方法重写的规则,面试几乎必考,这里列一个完整清单:

  • 方法名和参数列表必须完全相同;
  • 返回类型可以相同,也可以是父类返回类型的子类型,这叫协变返回类型;
  • 访问权限不能比父类方法更严格,可以更宽松;
  • private方法不能被重写,只能被隐藏;
  • static方法不能被重写,只能被隐藏;
  • final方法不能被重写;
  • 受检异常不能扩大范围,可以缩小或保持;
  • 构造器不能重写;
  • 接口方法在实现类里必须用public修饰。

@Override注解最大的价值是编译期校验。如果你在子类方法上标注了它,但父类方法签名改了,编译会直接报错,提示你这里“不是重写”。没有这个注解,你以为是重写,实际可能变成了重载,或者干脆是子类的一个普通新方法,这种 bug 特别隐蔽。

重载和重写的区别也可以顺便总结一下:

维度重写重载
方法签名必须一致参数列表必须不同
返回类型支持协变不要求也不建议依赖
修饰符不能更严格无限制
绑定时机运行期动态绑定编译期静态绑定
目的替换父类行为提供同名的多种入口

5.4 静态绑定与动态绑定:重载和重写分别在什么时机确定

Java 的方法调用分为两种绑定方式。

重载在编译期就已经确定调用哪个方法了,它看的是变量的静态类型。比如:

class Printer { void print(Parent p) { System.out.println("parent"); } void print(Child c) { System.out.println("child"); } } Parent p = new Child(); Printer printer = new Printer(); printer.print(p); // 输出 parent,编译期按 Parent 类型选了 print(Parent)

这里虽然p的实际类型是Child,但重载选择参数时看的是声明类型Parent,所以走的是print(Parent)方法。

重写则不同。普通实例方法调用在字节码层面使用的是invokevirtual指令,JVM 会根据调用者的实际类型去方法表里查找最合适的实现,从实际类型开始,逐层向上查找,找到就先命中。这就是动态绑定。

有一个比较容易混淆的场景:重载加组合:

public void handle(Resource resource) { System.out.println(resource.getDescription()); }

这里的handle方法参数类型是Resource,编译期确定;但方法体里的resource.getDescription()是动态绑定,运行期根据实际对象是ClassPathResource还是FileSystemResource去执行不同实现。Java 是一门单分派语言,方法调用的接收者——也就是this——会参与动态绑定,而方法参数的重载选择是静态的。理解这一点,很多“看似多态却没有多态”的问题都会豁然开朗。

5.5 字段隐藏与静态方法隐藏:看起来像多态,其实不是

很多人不知道,Java 里字段访问和静态方法调用都不参与多态。看这段代码:

class Parent { String name = "parent"; static void hi() { System.out.println("parent hi"); } } class Child extends Parent { String name = "child"; static void hi() { System.out.println("child hi"); } }

执行:

Parent p = new Child(); System.out.println(p.name); // 输出 parent p.hi(); // 输出 parent hi

是不是有点反直觉?实际类型明明是Child,为什么访问的字段和静态方法都是父类的?因为字段和静态方法在 Java 里的绑定方式是“静态绑定”,编译器直接根据变量的静态类型来确定用哪个成员。子类的同名字段和同签名静态方法被称为“隐藏”了父类的成员,而不是“重写”。

所以我对你的建议是:设计类的时候,尽量避免子类和父类使用同名字段,接口里不要定义可变的业务字段。否则代码读起来会有严重的误导性,你以为在多态,其实在访问父类隐藏成员。

6. 实战:自写一个 StringResource 并迁移设计思路

6.1 最小实现类需要哪些代码

前面讲了一大堆,最有效的验证方式是自己动手写一个Resource实现。只继承AbstractResource的话,最少只需要实现两个方法:getInputStream()getDescription()

我写了一个超简单的StringResource,把内存里的字符串当成一个可读取的资源:

public class StringResource extends AbstractResource { private final String content; private final String description; public StringResource(String content) { this(content, "string resource"); } public StringResource(String content, String description) { this.content = content; this.description = description; } @Override public String getDescription() { return this.description; } @Override public InputStream getInputStream() { return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); } @Override public long contentLength() { return content.getBytes(StandardCharsets.UTF_8).length; } }

为什么要重写contentLength()?因为父类AbstractResource的默认实现是尝试通过getFile()getURL()拿到长度,而内存字符串资源既没有文件也没有 URL,父类那套逻辑会抛异常。这个例子恰好演示了一个道理:继承抽象类之后,哪些方法要重写,取决于你的资源形态能不能满足父类默认实现的假设条件。

6.2 运行结果与分析

写一个简单的测试:

Resource resource = new StringResource("hello, resource", "custom string resource"); System.out.println(resource.getDescription()); System.out.println(resource.isOpen()); byte[] bytes = resource.getInputStream().readAllBytes(); System.out.println(new String(bytes, StandardCharsets.UTF_8));

输出:

custom string resource false hello, resource

getDescription()返回了我自定义的描述,isOpen()返回了父类默认的false,表示这是一个可重复读取的资源,getInputStream()能正常拿到流。这个StringResource已经可以传给任何只依赖Resource接口的代码,比如你自己封装的一个配置文件读取工具。你不需要它和ClassPathResourceFileSystemResource有任何直接关系,它们只是实现了同一个接口,调用方就能一视同仁。

6.3 把同一套设计思路搬进业务代码

最后一步,我们把“接口 + 抽象类 + 具体实现 + 策略选择器”这套组合拳迁移到日常业务里。举一个订单导出的例子。

先定义一个接口:

public interface OrderExporter { byte[] export(OrderQuery query); String formatName(); }

再定义抽象类,把公共流程固化成模板方法:

public abstract class AbstractExcelExporter implements OrderExporter { protected final OrderRepository orderRepository; protected AbstractExcelExporter(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Override public byte[] export(OrderQuery query) { List<Order> orders = orderRepository.find(query); validate(orders); Workbook workbook = buildWorkbook(orders); afterBuild(workbook); return writeToBytes(workbook); } protected abstract Workbook buildWorkbook(List<Order> orders); protected void afterBuild(Workbook workbook) { // 默认空的钩子方法,子类按需覆盖 } protected void validate(List<Order> orders) { if (orders == null || orders.isEmpty()) { throw new IllegalArgumentException("没有可导出的订单数据"); } } }

然后写两个具体实现:

public class V1ExcelExporter extends AbstractExcelExporter { // 用 HSSF 实现 buildWorkbook } public class V2ExcelExporter extends AbstractExcelExporter { // 用 SXSSF 实现 buildWorkbook,支持大数据量 }

上层调用方只依赖OrderExporter,通过一个选择器按需获取实例:

OrderExporter exporter = exporterFactory.getExporter("v2"); byte[] data = exporter.export(query);

以后新增V3版本,只需要新增一个类,上层代码一行都不用改。这个模式不是 Spring 专属,它就是我们刚分析的ResourceLoader思路的业务复刻版:调用方面向接口,生产方按需替换。

6.4 什么时候该继承,什么时候该组合

讲了这么多继承的好处,最后必须泼一盆冷水:继承是手段,不是目的。能用组合优先用组合,这条原则叫“合成复用原则”。

你判断要不要继承,先问自己一个问题:两个类之间真的有“is-a”关系吗?ClassPathResource是一个ResourceFileSystemResource也是一个Resource,语义成立,继承才合理。如果你只是想让某个类复用父类的工具方法,但两者没有清晰的从属关系,不要硬继承。比如为了用HashMap的方法就去继承HashMap,这是典型的错误用法,应该用组合:内部持有一个HashMap实例,再暴露自己需要的方法。

Resource体系之所以经典,就是因为它严格遵守了这个原则:接口表达语义,抽象类提供复用,具体类是真实的“资源”,每一步都没有越界。

我个人带新人时有个习惯,会让他们把DefaultResourceLoader.getResource()那几十行代码对着源码抄一遍,然后自己写两个不同的Resource实现跑一遍测试。这个过程比背十遍“多态的三个必要条件”有用得多。你在实际开发里再遇到“接口和抽象类怎么选”“重写和重载到底怎么用”,回头看这篇文章里的Resource体系,应该就有自己的答案了。剩下的,就是打开 IDE,动手验证。

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

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

立即咨询