ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java继承从入门到实战:extends、super、方法重写与多态详解

Java继承从入门到实战:extends、super、方法重写与多态详解 1. 从重复代码说起继承到底解决了什么问题先聊一个我见过无数次的场景。很多零基础的朋友学完类和对象之后兴致勃勃地开始写代码结果写着写着就发现不对劲。比如要写一个动物管理系统先写一个Animal类里面有name、age字段有eat()、sleep()方法。接着要写Dog类发现狗也是一种动物于是又复制了一遍name、age字段和eat()、sleep()方法再加一个wang()方法。再写Cat类又是一顿复制粘贴加上miao()方法。代码如下// 没有继承的时候Dog和Cat各自复制了一套Animal的字段和方法 public class Animal { String name; int age; public void eat() { System.out.println(动物在吃东西); } public void sleep() { System.out.println(动物在睡觉); } } public class Dog { String name; int age; public void eat() { System.out.println(动物在吃东西); } public void sleep() { System.out.println(动物在睡觉); } public void wang() { System.out.println(狗在汪汪叫); } } public class Cat { String name; int age; public void eat() { System.out.println(动物在吃东西); } public void sleep() { System.out.println(动物在睡觉); } public void miao() { System.out.println(猫在喵喵叫); } }三个类里有大段一模一样的代码。这时候你想改点什么比如把eat()的输出从动物在吃东西改成动物正在进食就得跑到三个类里改三遍。类一多改到怀疑人生而且特别容易漏掉一两处导致程序行为不一致。这就是继承要解决的核心问题把多个类共有的字段和方法抽取出来放到一个公共的父类里然后让子类通过继承自动获得这些成员。在Java里用extends关键字就可以实现。继承同时也是面向对象三大特性——封装、继承、多态——里承上启下的那一环没有继承多态基本无从谈起。继承解决的第二个问题是建模层面的它让代码能够表达现实世界里的分类关系。狗是动物的一种猫也是动物的一种经理是员工的一种圆是图形的一种。这种关系在面向对象里叫is-a关系即子类是父类的一种。有了继承你写代码时的思维方式就可以跟现实世界的分类体系对齐而不是把所有类都拍扁成一个个孤立的、互不相干的模板。这篇文章就是给0基础的朋友准备的。我会从最朴素的需求出发讲清楚继承怎么写、底层怎么跑、有哪些坑最后用一个完整的实战例子带你从头到尾写一遍。看完你能理解继承的语法和设计思想也能避开新手最常犯的错误。2. 继承的语法骨架extends、方法重写与super2.1 最基本的继承写法继承的语法非常直观就是子类extends父类。看下面这个例子把前面Animal、Dog、Cat的重复代码用继承重构一下// 父类把公共字段和方法放这里 public class Animal { String name; int age; public void eat() { System.out.println(动物在吃东西); } public void sleep() { System.out.println(动物在睡觉); } } // 子类只写自己特有的东西 public class Dog extends Animal { public void wang() { System.out.println(狗在汪汪叫); } } public class Cat extends Animal { public void miao() { System.out.println(猫在喵喵叫); } }Dog类里一个字都没写name和age但创建Dog对象后可以直接dog.name 旺财、dog.eat()。这些成员从哪来的从父类Animal继承来的。继承的本质是子类自动拥有父类中允许被继承的成员也就是非private的字段和方法。用这种方式公共代码只维护一份。以后你想改eat()的逻辑只改Animal就够了所有子类自动生效。这就是继承最直接的收益——消除重复集中管理。一个类想要继承另一个类Java语法要求只能写一个extends比如class Dog extends Animal。注意这里没有逗号分隔多个父类的写法因为Java的类继承是单继承。后面我会专门讲为什么这么设计。2.2 方法重写子类想改父类的行为怎么办继承不是让你原封不动地照搬父类。很多时候子类需要一些定制化的行为。比如狗吃东西和猫吃东西发出的声音、表现都不一样。如果所有动物都用同一个eat()那多态就失去了意义。这时可以用方法重写Override在子类里定义和父类方法签名完全一样方法名、参数列表相同的方法运行时调用子类自己的版本。public class Dog extends Animal { Override public void eat() { System.out.println(狗在啃骨头); } public void wang() { System.out.println(狗在汪汪叫); } }测试一下Dog dog new Dog(); dog.eat(); // 输出狗在啃骨头这个Override注解不是可有可无的装饰。写上它编译器会帮你检查如果你的方法名拼错了、参数类型对不上它根本就不是重写而是一个普通的新方法编译器会直接报错提示你。这能在编译阶段就拦住很多我以为我重写了其实没有的尴尬情况。重写和后面要讲的重载Overload千万别混。重写是子类把父类的方法替换掉发生在有继承关系的不同类之间方法签名完全一致重载是同一个类里方法名相同、参数列表不同跟继承没有必然关系。很多面试题就爱拿这两个概念做文章。2.3 super关键字怎么调用父类的版本有时候你并不想把父类方法整个换掉而是想在父类逻辑的基础上加点东西。比如父类的eat()已经做了记录日志、检查状态这些事子类只想再加一行汪汪叫。这时候用super关键字调用父类的方法public class Dog extends Animal { Override public void eat() { super.eat(); // 先执行父类的eat()逻辑 System.out.println(狗吃完后又汪了一声); } }super可以理解为父类对象的引用有三个主要用途super.方法名()调用父类的方法。super.字段名访问父类的字段。super()调用父类的构造方法必须写在子类构造方法的第一行。第3点比较关键也很容易踩坑我单独拿出来讲。2.4 构造方法的执行链子类默认先调父类构造器Java在创建子类对象时并不仅仅是执行子类的构造方法。它会先执行父类的构造方法再执行子类的构造方法一层一层往上推就像盖楼先打地基一样。看这段代码public class Animal { public Animal() { System.out.println(Animal构造方法执行了); } } public class Dog extends Animal { public Dog() { super(); // 这行就算不写编译器也会自动加上 System.out.println(Dog构造方法执行了); } } public class Main { public static void main(String[] args) { Dog dog new Dog(); } }运行结果Animal构造方法执行了 Dog构造方法执行了注意一个细节就算你在Dog()里不写super()Java编译器也会自动在子类构造方法的第一行插入一个无参的super()调用。如果父类没有无参构造方法那编译器就报错了。比如父类只定义了带参数的构造方法没写无参构造方法子类构造器第一行就必须显式写super(参数)否则编译不过。public class Animal { String name; public Animal(String name) { // 只定义了带参构造无参构造不存在 this.name name; } } public class Dog extends Animal { public Dog(String name) { super(name); // 必须显式调用父类的带参构造 } }这条规则背后的道理是子类对象在逻辑上是一个父类对象再加上子类的扩展部分所以必须先保证父类部分被正确初始化。理解了这条执行链后面很多奇怪的报错都能想明白。3. 继承底层那些事初始化顺序、访问权限与多态3.1 继承体系下的对象初始化顺序先看一个稍微复杂点的例子把静态代码块、实例代码块、构造方法都加进来看看创建子类对象时的真正执行顺序public class Animal { static { System.out.println(Animal静态代码块); } { System.out.println(Animal实例代码块); } public Animal() { System.out.println(Animal构造方法); } } public class Dog extends Animal { static { System.out.println(Dog静态代码块); } { System.out.println(Dog实例代码块); } public Dog() { System.out.println(Dog构造方法); } }创建一个Dog对象输出顺序是Animal静态代码块 Dog静态代码块 Animal实例代码块 Animal构造方法 Dog实例代码块 Dog构造方法这个顺序可以总结成一句话先父后子先静态后实例先代码块后构造方法。但注意static代码块只会在类第一次被加载时执行一次之后再new Dog()静态代码块不会再输出。这个知识点面试经常考实际开发中也有真实意义。比如你在子类构造方法里用到了继承自父类的字段如果父类构造还没执行完这些字段可能还是默认值null、0、false一用就出问题。理解了初始化顺序你就知道什么时候数据是安全的。3.2 访问修饰符在继承里的差异Java有4种访问级别public、protected、默认不加修饰符、private。它们对继承的影响差异很大很多新手搞不清protected和默认的区别。修饰符同类同包子类不同包任意类public可以可以可以可以protected可以可以可以不可以默认无修饰符可以可以不可以不可以private可以不可以不可以不可以在继承场景里最容易忽略的是父类的private成员子类虽然拥有它对象内存里确实有这些字段但子类代码不能直接访问。比如public class Animal { private String secret 动物的小秘密; public void showSecret() { System.out.println(secret); // 父类自己可以访问 } } public class Dog extends Animal { public void tryAccess() { // System.out.println(secret); // 编译错误Dog不能直接访问父类的private字段 // 只能通过父类的public方法来间接访问 } }这里有个很经典的误解新手以为继承就意味着子类能拿父类的private成员。实际上private成员不会被子类继承可见性它只属于父类自己。要暴露给子类就把成员改成protected。protected的设计初衷就是给子类用的子类在任意包都能访问但外界其他类无法访问。3.3 多态与向上转型父类引用指向子类对象这是继承最精彩的部分也是很多人学到这里开始懵的地方。Java允许把子类对象赋值给父类类型的引用这叫向上转型Animal animal new Dog();此时animal变量的编译类型是Animal实际指向的对象是Dog。调用animal.eat()时如果Dog重写了eat()运行时会执行Dog的版本。这种编译看左边运行看右边的行为就是动态绑定也是多态的核心机制。Animal animal new Dog(); animal.eat(); // 输出狗在啃骨头Dog重写后的版本但是注意animal不能调用wang()因为Animal类型里根本没有这个方法。编译器的规则是调用哪个方法取决于引用变量的声明类型。多态最大的好处是面向父类编程。假设你有一个zoo数组里面可以放Dog、Cat、Bird等各种动物统一用Animal[]来管理然后遍历每个元素调用eat()各个子类会执行各自的实现Animal[] animals {new Dog(), new Cat(), new Bird()}; for (Animal a : animals) { a.eat(); // 分别输出各自的吃法 }如果没有继承和多态你得写一堆if (x instanceof Dog)这样的判断代码又长又难维护。有了多态新增一种动物时只需要让它继承Animal并重写eat()老代码一行都不用改。这种对扩展开放对修改关闭的设计正是面向对象追求的。3.4 单继承设计Java为什么不允许一个类继承多个父类C支持一个类继承多个父类比如class FlyingFish extends Fish, Bird。Java却只允许单继承。为什么核心原因是多继承会带来菱形问题。假设有两个类A和B都定义了一个run()方法类C同时继承A和B那C到底该用谁的run()各语言处理方式不同处理不好就会产生混乱和歧义。Java的选择是类只能单继承但一个类可以实现多个接口。接口里只声明方法签名不包含具体实现Java 8之后接口可以写默认方法但这不是核心用法所以接口之间的冲突也远没有多继承那么严重。实际开发中的经验是能用接口表达的能力就优先用接口把继承留给真正有is-a关系的类体系。4. 新手最容易踩的继承陷阱重写、super与构造方法4.1 重写时的权限、返回值与异常规则方法重写不是方法名一样就行Java对它有严格约束违反就编译报错。访问权限不能更严格父类方法是public子类重写时不能改成protected或private。反过来可以把权限放宽比如父类protected子类重写成public。返回值类型可以相同也可以是父类方法返回类型的子类型这叫协变返回类型。比如父类Animal get()子类可以Dog get()。抛出的异常不能比父类更宽泛父类方法不抛异常子类重写时不能抛出受检异常父类抛IOException子类可以抛FileNotFoundException更具体的异常但不能抛Exception更宽泛的异常。这些规则背后的本质是重写后的方法必须能安全地替换父类方法的位置。如果父类方法答应不抛异常子类重写后却抛了个Exception调用方就措手不及了。4.2 父类的private方法和static方法不会被子类重写这是另一个高发误区。public class Animal { private void secret() { System.out.println(Animal私有方法); } public static void info() { System.out.println(Animal静态方法); } } public class Dog extends Animal { private void secret() { // 这不是重写Animal的secret对Dog不可见 System.out.println(Dog私有方法); } public static void info() { // 这不是重写这叫静态方法隐藏 System.out.println(Dog静态方法); } }private方法对子类不可见子类里写一个同名方法本质上就是一个全新方法跟父类的那个没有任何覆盖关系。static方法也不参与重写它属于类本身子类写同名的static方法叫做隐藏hide。调用时看变量的声明类型而不是实际对象类型Animal animal new Dog(); animal.info(); // 输出Animal静态方法静态方法看左边类型只有实例方法才参与多态重写。这个知识点面试问得非常多很多工作两三年的人都会在这里翻车。4.3 在构造方法里调用可重写方法的隐患这是Java里一个非常有名的坑也是高频面试题。看代码public class Animal { public Animal() { init(); // 调用了一个实例方法 } public void init() { System.out.println(Animal初始化); } } public class Dog extends Animal { private String name 旺财; public Dog() { super(); } Override public void init() { System.out.println(Dog初始化name name); } }执行new Dog()输出Dog初始化namenull为什么会null因为子类字段name的赋值发生在子类构造方法执行阶段而父类构造方法执行时动态绑定已经让init()调用到了子类版本此时子类的name还没来得及赋值所以输出null。实际项目里如果init()里还做了别的事比如初始化一个集合、连接资源就很可能在这个阶段抛NullPointerException。经验规则是在构造方法里尽量不要调用可以被重写的方法。要么把方法设成private或final要么等构造完成后再通过外部调用初始化逻辑。4.4 super调用链的一些边界情况有一个很容易被忽略的细节super()只能调用直接父类的构造方法不能跨级调用爷爷类的构造方法。而且super(...)和this(...)都必须放在构造方法第一行所以两者不能同时出现。你要是试图在构造方法里写两行编译器直接报错。另外如果父类构造方法里调用了某个方法而子类又重写了它就会出现上面4.3说的问题。所以在设计父类时要格外谨慎要么明确标注哪些方法是专供子类重写的并且文档说明调用时机要么就干脆把父类设计得更保守一点。5. 继承之外什么样的代码不该用继承5.1 继承不是为了复用代码就要用很多新手看到一个类里有自己需要的方法第一反应就是那我继承它吧。这其实是个很危险的思维。继承是一种强耦合关系子类和父类的生命周期、初始化顺序、内部实现都绑在一起父类改一个方法签名所有子类全部受影响。用继承之前先问自己一个问题这两个类之间真的有is-a关系吗Dog是Animal没问题。Manager是Employee也没问题。但是某个工具类里有个parseJson()方法我的类想用这不是is-a关系这是has-a关系应该用组合。组合就是在一个类里持有另一个类的对象// 用组合替代继承 public class ReportService { private JsonUtil jsonUtil new JsonUtil(); // 组合ReportService有一个JsonUtil public String buildReport() { String data jsonUtil.parseJson(...); // ... } }组合的耦合度低得多你只依赖JsonUtil对外公开的接口内部怎么改都不影响你。5.2 设计父类时要做受保护的判断如果确定要用继承父类的设计直接决定子类的体验。几个关键设计点允许子类重写的方法用protected或public修饰不希望被重写的方法用final修饰。构造方法里调用实例方法尽量调用final或private方法避免调用可重写方法。字段能设private就设private通过protected的getter/setter暴露给子类。直接暴露protected字段虽然省事但会让子类代码随意修改状态后期维护容易出问题。final关键字在继承里的作用常被忽略。final class表示这个类不能被继承比如String类就是final的Java禁止你继承String。final方法表示子类不能重写它。这些设计不是故意限制你而是为了保护不变性和安全性——比如String若是能被继承字符串的一些安全保证就形同虚设了。5.3 继承层次别设计太深有的项目里能看到五六层继承A extends B extends C extends D...每层加一点东西。这种代码非常难读你看到一个对象要搞清楚它到底有哪些方法得一层一层往上翻。改代码更痛苦改顶层父类下面所有类都可能受影响。业界经验是继承层次尽量控制在两三层以内。如果发现继承链太长说明你的设计可能过度分类了该考虑用组合、接口或者直接拆分更细的类来化解。我自己在实际开发中还坚持一个原则子类只重写父类的方法不要轻易在子类里新增太多父类不知道的能力。如果子类有大量父类完全不知道的公共方法说明父类的抽象边界画错了继续堆下去会越堆越乱。6. 0基础实战从Employee到Manager的完整继承示例前面原理讲了不少这一节我们动手写一个完整的、能跑的小项目把前面的知识点全部串起来。场景是员工管理系统有普通员工也有经理。经理本身也是员工但经理有额外的津贴计算工资的方式和普通员工不一样。6.1 父类Employeepublic class Employee { private String name; private double salary; public Employee(String name, double salary) { this.name name; this.salary salary; } public String getName() { return name; } public double getSalary() { return salary; } /** * 计算年度收入普通员工就是12个月工资。 * 这个方法设计为可重写方便子类定制。 */ public double getAnnualIncome() { return salary * 12; } Override public String toString() { return 员工 name 月薪 salary; } }这里有几个细节值得注意。字段用了private子类不能直接访问只能通过getName()、getSalary()这些方法间接使用。构造方法接收名称和月薪把必要的初始化都做完了子类构造时可以放心调用super(name, salary)。6.2 子类Managerpublic class Manager extends Employee { private double bonus; // 经理额外有的津贴 public Manager(String name, double salary, double bonus) { super(name, salary); // 调用父类构造方法先初始化员工共有的部分 this.bonus bonus; } public double getBonus() { return bonus; } Override public double getAnnualIncome() { // 经理的年收入 12个月工资 津贴 return super.getAnnualIncome() bonus; } Override public String toString() { return 经理 getName() 月薪 getSalary() 津贴 bonus; } }注意三点第一子类构造方法第一行用super(name, salary)调用了父类的带参构造因为父类没有无参构造不写就编译不过。第二getAnnualIncome()用了Override重写而且借助super.getAnnualIncome()复用了父类12个月工资的计算逻辑再加津贴。第三toString()里调用了getName()和getSalary()而不是直接访问私有字段。6.3 测试类验证继承、重写和多态public class CompanyTest { public static void main(String[] args) { // 1. 直接创建对象验证继承与重写 Employee emp new Employee(张三, 8000); Manager mgr new Manager(李四, 15000, 30000); System.out.println(emp); System.out.println(mgr); System.out.println(张三的年收入 emp.getAnnualIncome()); System.out.println(李四的年收入 mgr.getAnnualIncome()); // 2. 验证向上转型和多态 Employee employeeRef new Manager(王五, 12000, 20000); System.out.println(employeeRef); System.out.println(王五的年收入 employeeRef.getAnnualIncome()); // 3. 批量处理员工验证面向父类编程 Employee[] employees { new Employee(赵六, 9000), new Manager(孙七, 18000, 50000) }; System.out.println( 公司员工名单 ); for (Employee e : employees) { System.out.println(e); System.out.println(年收入 e.getAnnualIncome()); } } }运行结果员工张三月薪8000 经理李四月薪15000津贴30000 张三的年收入96000.0 李四的年收入210000.0 经理王五月薪12000津贴20000 王五的年收入164000.0 公司员工名单 员工赵六月薪9000 年收入108000.0 经理孙七月薪18000津贴50000 年收入266000.0仔细看这段输出你能找到几个关键点Manager对象调用toString()时执行的是子类重写后的版本。employeeRef的类型是Employee但实际指向Manager打印出来是经理格式年收入计算也用了经理的算法。数组遍历时同一个getAnnualIncome()调用在不同对象上执行了不同行为——这就是多态的样子。6.4 自己动手改一改只看不练过两天就会忘。我建议你在这个例子上做几个小改动每个改动都能验证一个知识点给Employee增加一个protected字段department部门子类Manager中直接访问它验证protected的访问权限。在Employee构造方法里调用一个可重写的方法观察创建Manager对象时的输出顺序亲自感受4.3节说的坑。给Manager增加一个teamSize团队人数并在toString()里打印出来顺便练习子类如何新增自己独有的字段和方法。我自己带新人的时候发现一个规律那些能把继承的初始化顺序、重写规则讲明白的人往往不是背概念背得多熟而是亲手写过这种小例子、踩过几个坑的人。Java的继承并不复杂复杂的是藏在细节里的执行规则和设计取舍。把这一章的例子跑通、改明白你对继承的理解就能超过相当一部分工作一两年的程序员。
返回列表