ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JavaSE面向对象核心:继承、多态、抽象类与接口实战解析

JavaSE面向对象核心:继承、多态、抽象类与接口实战解析 1. JavaSE第三阶段的定位从语法到面向对象思维的关键一跃很多人学JavaSE容易陷入一种误区——翻完一遍基础语法数据类型、运算符、流程控制、数组都过了一遍看着自己写的循环嵌套、字符串拼接也能跑起来就觉得自己基础已经掌握得差不多了。但是真正来到面向对象这一块尤其是继承、多态、抽象、接口这几个核心点的时候很多人会明显感到吃力甚至开始怀疑自己前面学的那些语法是不是白学了。其实这不是你的问题而是学习节奏本身就该如此。JavaSE三这阶段的核心我从一开始就想明确告诉你这一阶段的意义不在学会几个新关键字而在于让你的编程思维从面向过程切换到面向对象从用代码实现功能切换为用代码描述现实关系。继承让你学会抽取共性多态让你学会面向抽象编程接口让你学会约定行为规范。这种思维方式的转变才是JavaSE三真正要训练的东西。所以这篇内容我不打算再给你罗列一遍继承是什么、多态是什么这类教科书式的解释而是想结合我实际开发中高频用到的场景把这几个核心知识点拆开揉碎讲讲它们之间是怎么串起来的以及你在学习和实际写代码中特别容易踩的坑。等你看完这部分内容再回头看那些重载、重写、向上转型、动态绑定之类的术语你就不会觉得它们是一堆孤立的语法点而是一个可以互相配合的完整体系。2. 继承体系里的构造链与初始化顺序很多隐性Bug的源头2.1 继承的真正价值是抽取共性而不是复用方法很多初学者接触继承时第一反应是父类里写好的方法子类可以拿来直接用这功能太方便了。这个理解没错但它太浅了。如果你只是把继承当作省代码的工具那你在设计继承结构时很容易走偏——比如为了偷懒把几个不相干的类硬塞进一个继承体系里结果后面维护代码时痛苦不堪。我在实际写代码时更倾向于把继承理解为对现实世界分类关系的建模。比如狗、猫、鸟都属于动物它们都有名称、年龄、吃东西的行为这就是共性可以抽到Animal这个父类里但它们的叫声不一样走路方式不一样这就需要用方法重写来让子类表现自己的差异。class Animal { protected String name; protected int age; public Animal(String name, int age) { this.name name; this.age age; } public void eat() { System.out.println(name 正在吃东西); } public void sound() { System.out.println(动物发出声音); } } class Dog extends Animal { public Dog(String name, int age) { super(name, age); } // 重写父类的sound方法 Override public void sound() { System.out.println(name 汪汪叫); } } class Cat extends Animal { public Cat(String name, int age) { super(name, age); } Override public void sound() { System.out.println(name 喵喵叫); } }你看Animal里定义的eat方法是所有子类共享的行为Dog和Cat不需要重复写而sound方法虽然父类里有默认实现但每个子类都有自己的声音表现于是通过重写的方式各自实现。这个设计才是继承的正确打开方式——它帮你把是什么和做什么分离让代码结构更贴近真实世界的分类逻辑。2.2 子类构造器为什么第一行必须是super()这是继承中第一个容易让人困惑的点。我在教初学者时经常收到这样的问题我创建子类对象时父类没有无参构造器为什么编译都过不去这里的底层逻辑其实非常简单创建子类对象时JVM会先完成父类部分的初始化再初始化子类特有的部分。你可以把子类对象想象成一个套娃外层是子类自己定义的属性和方法内层是继承自父类的属性和方法。既然内层的父类部分还没创建好外层子类部分怎么能安全地使用它继承来的那些属性呢所以Java语言层面做了一个强制约束子类构造器的第一行必须调用父类构造器如果没写编译器会默认加上super()调用无参构造器。一旦父类没有无参构造器也就是你定义了带参构造器并且没写无参构造器那个默认的super()就会找不到目标而报错。解决方式有两种。第一种是在子类构造器第一行显式调用父类的带参构造器第二种是给父类补充一个无参构造器。我本人推荐第一种方式class Dog extends Animal { public Dog(String name, int age) { super(name, age); // 显式调用父类带参构造器 } }原因很简单显式传参意味着你明确知道自己给父类注入了什么数据整个对象的初始化链路是清晰可见的。如果随意加个无参构造器父类里的name、age就可能处于默认值null和0的状态后面用到这些属性时容易产生隐藏的空指针或逻辑错误。2.3 初始化顺序静态块、实例块、构造器的执行流程关于对象初始化顺序很多面试题爱考但实际开发中它的意义更加重要——它直接决定了你在哪里做初始化才能保证数据的正确性。Java里一个类从加载到创建对象的完整流程是父类静态成员变量初始化、静态代码块执行类加载阶段只执行一次子类静态成员变量初始化、静态代码块执行父类实例成员变量初始化、实例代码块执行父类构造器执行子类实例成员变量初始化、实例代码块执行子类构造器执行这里有个很容易忽略的细节实例代码块和实例成员变量初始化的顺序取决于它们在代码中出现的先后顺序而不是实例变量先执行、代码块后执行这种固定的规则。如果你在实例代码块里访问了后面才声明的变量会拿到默认值而不是你预设的值。class Parent { static { System.out.println(1. Parent静态代码块); } { System.out.println(3. Parent实例代码块); } public Parent() { System.out.println(4. Parent构造器); } } class Child extends Parent { static { System.out.println(2. Child静态代码块); } { System.out.println(5. Child实例代码块); } public Child() { System.out.println(6. Child构造器); } }执行new Child()时输出顺序就是你看到的1到6。理解这个顺序有什么用举个实际场景如果你的父类构造器里调用了某个实例方法而这个实例方法被子类重写了那么创建子类对象时父类构造器里调用的那个方法实际上会执行子类重写后的版本。此时子类自己的字段还没初始化完毕如果方法里访问了子类字段就会拿到默认值。这就是所谓的构造器里调用可重写方法的坑在实际开发里一定要避免。3. 多态背后的动态绑定机制为什么编译看左边运行看右边3.1 向上转型到底发生了什么多态是面向对象三大特性中最抽象也最迷人的一个但它也是很多人学了很久仍然似懂非懂的地方。在Java里多态的实现依赖三个条件继承或实现接口、方法重写、父类引用指向子类对象。先说说向上转型。所谓向上转型就是用一个父类类型的引用变量去指向一个子类对象Animal a new Dog(旺财, 3); a.sound(); // 实际调用的是Dog重写后的sound方法输出旺财 汪汪叫这里有个核心认知点编译阶段编译器看到的是a的声明类型是Animal所以它只允许你调用Animal类中定义过的方法如果你试图调用一个Dog类独有的方法比如fetchBall()编译器会直接报错即使a实际指向的对象确实是一个Dog。这就是那句顺口溜的由来——编译看左边运行看右边。在设计层面向上转型的核心意义在于你不再关心具体对象是Dog还是Cat你只需要把它当作一个Animal来对待调用Animal层面的行为即可。这一点在参数传递上体现得最明显。比如你写一个方法接收一个Animal类型的参数那Dog和Cat的实例都可以传进去如果哪天又新增了一个Bird类只要它继承Animal这个方法依然能处理它完全不用修改。3.2 动态绑定JVM是怎么找到真正要调用的方法的很多人知道多态的结果但不清楚内部机制。这里我尽量用通俗的方式讲明白。Java中方法的调用默认是动态绑定的也就是在运行时根据对象的实际类型来决定调用哪个方法。JVM的调用机制大致是这样的每个类在加载时都会生成一个方法表Method Table里面记录了该类所有可调用方法的实际入口地址。当代码执行到a.sound()时JVM先拿到a实际指向对象的类型——Dog类然后去Dog的方法表中查找sound方法对应的入口地址。这一查找过程对JVM来说成本是很低的因为它不需要在继承链上层层遍历方法表已经把重写后的方法入口记录好了。换句话说动态绑定是JVM为了支持多态而专门设计的一套高效机制。相对而言static方法和private方法属于静态绑定它们在编译阶段就确定了调用目标不存在运行时根据对象实际类型决定的问题。这也解释了为什么static方法不能被重写——它根本不属于动态绑定那一套体系你在子类里写一个与父类static方法签名完全相同的方法充其量叫隐藏而不是重写。Animal a new Dog(旺财, 3); a.sound(); // 动态绑定输出汪汪叫 // 如果sound方法是static上面这行调用的就是Animal里的版本因为编译期就绑定了3.3 向下转型与instanceof什么时候需要还原真实类型有了向上转型自然就有向下转型。向下转型就是把父类引用强制转回子类类型前提是这个父类引用实际指向的对象确实是那个子类类型。如果转错了运行时会抛出ClassCastException。我在实际工作中见过不少这种错误根本原因就是没搞清楚引用类型和对象真实类型的区别。举个典型场景Animal a new Dog(旺财, 3); Cat c (Cat) a; // 编译不报错但运行时抛出ClassCastException为什么编译不报错因为从Animal转型为Cat是父类类型转子类类型编译器无法确定a实际指向的对象是Dog还是Cat所以它不做拦截但运行时JVM一检查发现真实对象是Dog和Cat没有任何实际类型关系于是直接抛异常。正确的做法是先用instanceof做类型判断if (a instanceof Cat) { Cat c (Cat) a; c.sound(); } else if (a instanceof Dog) { Dog d (Dog) a; d.fetchBall(); }这里还有一个Java新版本的细节值得注意从Java 16开始你可以使用模式匹配的instanceof写法省去显式强转的步骤if (a instanceof Dog d) { d.fetchBall(); // d已经就是Dog类型直接使用 }这种写法既简洁又安全有条件的话我建议尽快用起来。4. 重写与重载同名方法的两种命运4.1 重写的约束条件远比你想象的严格方法重写是Java中高频使用的基础能力但偏偏有很多细节容易出错。我帮别人review代码时最常见的问题除了忘了加Override注解之外就是重写方法的访问权限和返回类型不匹配。先明确重写的核心约束方法签名必须一致方法名、参数列表完全相同返回值类型可以相同也可以是父类返回类型的子类型这叫协变返回类型访问权限不能比父类方法更严格。比如父类是public子类就不能改成protected或private不能重写被final修饰的方法构造器和static方法不能被重写关于第3点可能有些初学者不理解为什么访问权限只能放大不能缩小。你要换个角度想既然子类对象是一个父类对象那么所有通过父类引用能调用的public方法在子类对象上也必须能调用。如果子类把父类的public方法改成private那就意味着同一个对象你在父类类型下能调用这个方法换成子类类型反而不让调了这显然破坏了多态的规则。所以Java从语法层面直接禁掉了这种写法。4.2 重载编译期就决定好调用哪一个重载与重写最大的区别在于重载是在同一个类中定义多个同名但参数列表不同的方法重写是子类对父类方法的替换。重载在编译期就能确定调用哪一个静态绑定重写则在运行时通过动态绑定决定。举一个实际开发中最常见的重载场景——构造器重载class User { private String username; private String email; public User(String username) { this(username, ); } public User(String username, String email) { this.username username; this.email email; } }这个写法不仅体现了重载还展示了一个我特别推荐的习惯构造器之间用this()互相调用让参数最全的构造器成为唯一的数据初始化入口。这样既避免了重复代码也保证了无论走哪个构造器创建对象最终设置字段的逻辑是统一的。重载时有个很容易踩的坑是参数类型之间的自动类型提升。比如你定义了method(int a)和method(double a)两个重载方法调用method(3)时编译器会优先选择int版本而不是把3自动提升为double再调用double版本。如果只有一个method(double a)方法调用method(3)时编译器才会把int自动转成double来匹配。这种优先级机制可以帮助你理解为什么最精确匹配的规则在实际开发里这么重要。4.3 Override注解不是摆设是你的保护网很多初学者写重写方法时习惯性不写Override注解还振振有词不写也能实现重写功能。这个说法本身没错但不写注解意味着你失去了一道极其重要的编译期保护。举个例子你想重写父类的equals方法结果不小心把参数类型写成了String而不是Object。如果有Override注解编译器会立刻报错这个方法并没有重写父类的任何方法如果你没写注解这段代码会当作一个新定义的方法正常编译通过。你的本意是重写实际却变成了重载程序运行时的行为就会和你预期完全不一致而且这类Bug很难通过阅读代码发现。所以我一直强调凡是意图重写父类方法一律加上Override注解。这是最低成本的安全防护也是我们团队代码规范里明确要求的一条。5. 抽象类与接口从语法差异到设计哲学5.1 抽象类把必须有但没默认实现的行为说出来抽象类是用来描述一类对象的抽象模板的。它不能被实例化可以包含抽象方法和具体方法子类必须实现所有抽象方法除非子类自己也是抽象类。我常用的判断标准是如果父类里有一个方法无法给出合理的默认实现必须由子类根据自己的情况来实现那就把它定义成抽象方法对应的类就标记为abstract。拿之前Animal的例子来改所有动物都会发出声音但Animal这个类本身根本没法定义声音到底是什么——是汪汪、喵喵还是哼哼那sound()就应该定义为抽象方法abstract class Animal { protected String name; protected int age; public Animal(String name, int age) { this.name name; this.age age; } public void eat() { System.out.println(name 正在吃东西); } // 抽象方法子类必须实现 public abstract void sound(); }这样设计的好处是强制所有子类必须提供sound的实现如果某个子类忘了写编译器会直接报错而不是让这个错误在运行时才暴露出来。5.2 接口从是不是到能不能的思维转换如果说抽象类描述的是是什么is-a关系接口描述的则是能做什么can-do关系。一个经典的说法是接口定义的是一个能力契约任何一个类不管它继承自谁只要实现了这个接口就承诺了具备这些能力。以Java 8为分界接口的语法演进很明显。Java 8之前接口里只能有抽象方法和常量Java 8增加了default方法和static方法Java 9又引入了private方法。default方法的出现解决了接口演进兼容性的问题——当你给一个已经发布并被很多地方实现的接口新增方法时如果定义成抽象方法所有实现类都必须实现它如果定义成default方法则不影响已有实现类。但我要提醒一句default方法不是让你在接口里写业务逻辑的绿灯。它最大的应用场景是在不破坏现有实现的前提下扩展接口能力比如JDK里Iterable接口新增forEach就是典型的default方法应用。平时自己定义接口时如果多个实现类都要用到某段公共逻辑我更倾向于设计成抽象类或者提供工具类而不是塞进接口里。5.3 抽象类还是接口我的一套判断标准面对实际需求时很多初学者卡在不知道该用抽象类还是接口这个问题上。我这里给出一个可以直接套用的判断标准是我这些年用下来最顺手的一套逻辑如果多个类之间存在明显的父子血缘关系is-a且有共享的成员变量或通用方法考虑抽象类如果多个类之间没有血缘关系但都具备某种共同行为can-do考虑接口如果既需要共享代码又需要多能力组合就用抽象类 多个接口的组合方式优先考虑接口因为Java单继承多实现的特性决定了接口在组合上有天然优势关于优先考虑接口这一点我多说两句。接口让你在解耦上有更大的自由度。调用的代码依赖接口而不是某个具体实现类意味着将来替换实现比如把数据库从MySQL换成Oracle时调用方代码可以完全不动。这种面向接口编程的思想在很多框架中体现得淋漓尽致Spring的AOP就是建立在接口代理之上的。6. Object类与通用方法所有类的隐藏父类6.1 为什么所有类都能调用toString和equalsJava中所有类都直接或间接继承自Object类这是隐式的不需要你在类声明里写extends Object编译器会自动加上。这意味着Object里定义的方法比如toString()、equals()、hashCode()、getClass()等成为每个对象都具备的通用能力。这个设计的巧妙之处在于它定义了一套所有对象共通的行为标准。比如System.out.println(obj)里println方法接收一个Object类型的参数调用的是obj.toString()方法。你传入什么对象不重要只要它是Object的子类java里所有类都是就能调用toString()。这就是所有类都继承Object最大的工程意义——栈、集合、日志框架等通用组件可以统一处理任何类型的对象。6.2 重写equals必须同时重写hashCode的原因Object里的equals默认实现是基于引用的也就是两个引用指向同一个对象才返回true。很多场景下我们希望值相等就算同一个对象比如User类的两个实例只要username和email一样就认为是同一个用户。这种时候就必须重写equals方法。但只重写equals是不够的原因藏在HashSet、HashMap这类散列集合的工作机制里它们先通过hashCode方法算出对象的散列值定位到可能存储的桶再通过equals与桶内已有元素比较确定是否真的相等。如果两个对象equals返回true但hashCode不同它们会被散列到不同的桶里即使内容一样HashMap也可能把它们当作两个不同的键。这里有一个硬性约定你必须记住如果两个对象equals比较结果为true那么它们的hashCode返回值必须相同。反过来不成立——hashCode相同equals不一定为true因为不同对象可能产生相同散列值哈希冲突。因此重写equals几乎总是要同时重写hashCode。IDE生成的equalshashCode代码通常是把equals中用到的关键字段也作为hashCode计算的依据这是最稳妥的做法Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return Objects.equals(username, user.username) Objects.equals(email, user.email); } Override public int hashCode() { return Objects.hash(username, email); }6.3 equals用法上的一个细节陷阱有一个很小的细节特别容易踩坑就是equals和hashCode方法中getClass()与instanceof的选择。getClass() ! o.getClass()要求比较的两个对象运行时类型完全一致而instanceof则允许子类对象与父类对象相比较只要满足继承关系就返回true。如果你的业务规则是只要是User及其子类只要关键字段相同就算同一个用户那用instanceof更合适如果你的规则是必须是完全相同的类型才算同一个用户那应该用getClass()比较。实际项目里两者如何选择取决于业务语义没有绝对标准但有一点可以确定不明所以地混用会导致equals的不对称性风险。7. final关键字在类设计中的三种角色7.1 final修饰数据常量与不可变引用的区别final修饰基本类型变量时表示这个值一旦赋值就不能再修改final修饰引用类型变量时表示这个引用不能再指向其他对象但引用指向的对象本身的内容可以改变。很多人混淆了引用不可变和对象不可变这两个概念。看这个例子final ListString list new ArrayList(); list.add(A); // 这是允许的 list new ArrayList(); // 编译报错不能重新赋值你定了一个final的List引用不能让它重新指向另一个List对象但可以通过add方法往这个List里添加元素。如果需要真正不可变的列表应该使用Collections.unmodifiableList()或者Java 9之后的List.of()来创建。7.2 final修饰方法禁止重写但不是性能优化final方法不能被重写。关于final方法有一个很古老的提法叫性能优化认为final方法跳过了动态绑定性能更好。但在现代JVM中这个论点几乎没有意义了——JVM的运行时优化比如内联会自行判断哪些方法可以安全地内联与是否加final关系不大。因此对我来说final方法的最大价值是设计层面的当某个方法的逻辑是固定的、不希望子类改动时用final明确表达这个意图。比如模板方法模式中模板方法本身通常声明为final防止子类破坏整体流程。7.3 final修饰类不可继承的边界约束final类不能被继承。String就是典型的final类ArrayList也是。为什么这些类要设计成final核心原因是为了安全性和正确性。以String为例它被广泛用于类加载、网络协议、哈希表键等场景如果允许继承子类就可能override方法改变字符串的语义造成难以预料的后果。String是不可变类而final让不可变性从设计意图上升为语言强制。在实际开发中当你设计一个工具类全部由静态方法组成或者一个值对象且不希望别人通过继承扩展时可以考虑把它声明为final类。8. 综合实战一个完整练习打通继承、多态、抽象、接口前面对每个知识点的单点分析现在我们把它们全部串起来做一个综合练习。这个练习我建议你亲自动手敲一遍因为它的设计逻辑非常接近真实项目中的模块划分方式。需求很简单设计一个动物园系统能管理多种动物每种动物都会发出叫声同时具备清洗和喂食两种日常维护动作。先定义抽象基类把所有动物共有的属性和行为放进去abstract class Animal { private String name; public Animal(String name) { this.name name; } public String getName() { return name; } public abstract void makeSound(); public void feed() { System.out.println(name 正在进食); } }再定义两个能力接口把能清洗和能表演这些跨类的能力独立出来interface Washable { void wash(); } interface Performable { void perform(); }然后定义具体动物类。注意组合方式一个类既可以继承抽象类又可以同时实现多个接口class Lion extends Animal implements Washable { public Lion(String name) { super(name); } Override public void makeSound() { System.out.println(getName() 吼叫); } Override public void wash() { System.out.println(getName() 正在水洗); } } class Parrot extends Animal implements Washable, Performable { public Parrot(String name) { super(name); } Override public void makeSound() { System.out.println(getName() 模仿人声); } Override public void wash() { System.out.println(getName() 正在喷雾清洗); } Override public void perform() { System.out.println(getName() 正在表演说话); } }最后模拟一个动物园维护场景体现多态的解耦效果public class Zoo { public static void main(String[] args) { Animal lion new Lion(辛巴); Animal parrot new Parrot(波利); // 面向Animal父类编程 ListAnimal animals List.of(lion, parrot); for (Animal animal : animals) { animal.makeSound(); animal.feed(); } // 面向接口编程 ListWashable washables List.of((Washable) lion, (Washable) parrot); for (Washable washable : washables) { washable.wash(); } // 接口单独调用 Performable performable (Performable) parrot; performable.perform(); } }这个例子有几个值得仔细体会的点。第一List 里同时装了两个不同动物遍历时调用makeSound()展现的正是多态的动态绑定特性。第二Washable接口定义清洗能力Lion和Parrot虽然血缘关系不同但都实现了这个能力用接口就能统一处理。第三抽象类负责抽取公共属性name、feed方法和强制行为makeSound接口负责扩展附加能力可清洗、可表演彼此分工明确互不干扰。如果以后再增加一头新的动物比如老虎Tiger只要继承Animal并实现需要的接口即可Zoo类中的逻辑完全不用改动。这种扩展性正是面向对象设计追求的目标。9. JavaSE三学完后你应该具备的自我检查清单这一路知识点比较多我最后整理一份自查清单你可以拿它来检验自己的掌握程度。这里每一栏都不是知道概念就行而是要能当场手写出代码、能解释清楚原因的。继承设计能独立写出一个合理的继承结构清楚地区分哪些成员应该放到父类哪些应该由子类自己定义能解释super()在构造链中的作用。初始化顺序能从类加载开始完整说出一个子类对象创建过程中静态块、实例块、构造器的执行顺序并且能说明原因。多态原理能用编译看左边运行看右边解释向上转型后的方法调用行为能写出一个使用instanceof做安全向下转型的示例。重写与重载能列出重写的全部约束能说出重载与重写在绑定时机上的本质区别所有重写方法都养成了加Override注解的习惯。抽象类与接口能用自己的语言解释两者的定位差异is-a vs can-do能手写一个抽象类和一个接口并对三者父类、抽象类、接口做出正确设计决策。Object方法能重写equals/hashCode方法并正确解释为什么两者必须同时重写知道toString在日志输出中的重要性。final关键字能说清楚final修饰变量、方法、类的不同效果知道引用不可变与对象不可变的区别。综合运用能独立完成一个涉及多个类、继承体系、接口实现的小项目比如动物园管理、订单系统并通过多态简化代码结构。如果你能不打磕绊地完成以上项目的内容那么JavaSE三这部分就算真正掌握了后面的集合框架、泛型、异常处理、IO流等进阶内容学起来就会顺畅很多。反过来如果哪一项还需要翻笔记才能答出来我建议你先停下往前走回去把对应的知识点再看一遍这一步的基础没打牢后面的知识体系很容易出问题。
返回列表