Java继承从入门到实战:extends、super、方法重写与多态详解
2026/9/10 9:02:10 网站建设 项目流程

1. 从重复代码说起:继承到底解决了什么问题

先聊一个我见过无数次的场景。很多零基础的朋友学完类和对象之后,兴致勃勃地开始写代码,结果写着写着就发现不对劲。比如要写一个动物管理系统,先写一个Animal类,里面有nameage字段,有eat()sleep()方法。接着要写Dog类,发现狗也是一种动物,于是又复制了一遍nameage字段和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、方法重写与super

2.1 最基本的继承写法

继承的语法非常直观,就是子类extends父类。看下面这个例子,把前面AnimalDogCat的重复代码用继承重构一下:

// 父类:把公共字段和方法放这里 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类里一个字都没写nameage,但创建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(),静态代码块不会再输出。

这个知识点面试经常考,实际开发中也有真实意义。比如你在子类构造方法里用到了继承自父类的字段,如果父类构造还没执行完,这些字段可能还是默认值(null0false),一用就出问题。理解了初始化顺序,你就知道什么时候数据是安全的。

3.2 访问修饰符在继承里的差异

Java有4种访问级别:publicprotected、默认(不加修饰符)、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成员不会被子类继承可见性,它只属于父类自己。要暴露给子类,就把成员改成protectedprotected的设计初衷就是"给子类用的",子类在任意包都能访问,但外界其他类无法访问。

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数组,里面可以放DogCatBird等各种动物,统一用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却只允许单继承。为什么?

核心原因是多继承会带来"菱形问题"。假设有两个类AB都定义了一个run()方法,类C同时继承AB,那C到底该用谁的run()?各语言处理方式不同,处理不好就会产生混乱和歧义。

Java的选择是:类只能单继承,但一个类可以实现多个接口。接口里只声明方法签名,不包含具体实现(Java 8之后接口可以写默认方法,但这不是核心用法),所以接口之间的冲突也远没有多继承那么严重。实际开发中的经验是:能用接口表达的能力就优先用接口,把继承留给真正有is-a关系的类体系

4. 新手最容易踩的继承陷阱:重写、super与构造方法

4.1 重写时的权限、返回值与异常规则

方法重写不是"方法名一样就行",Java对它有严格约束,违反就编译报错。

  • 访问权限不能更严格:父类方法是public,子类重写时不能改成protectedprivate。反过来可以把权限放宽,比如父类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初始化,name=null

为什么会null?因为子类字段name的赋值发生在子类构造方法执行阶段,而父类构造方法执行时,动态绑定已经让init()调用到了子类版本,此时子类的name还没来得及赋值,所以输出null

实际项目里,如果init()里还做了别的事,比如初始化一个集合、连接资源,就很可能在这个阶段抛NullPointerException。经验规则是:在构造方法里,尽量不要调用可以被重写的方法。要么把方法设成privatefinal,要么等构造完成后再通过外部调用初始化逻辑。

4.4 super调用链的一些边界情况

有一个很容易被忽略的细节:super()只能调用直接父类的构造方法,不能跨级调用爷爷类的构造方法。而且super(...)this(...)都必须放在构造方法第一行,所以两者不能同时出现。你要是试图在构造方法里写两行,编译器直接报错。

另外,如果父类构造方法里调用了某个方法,而子类又重写了它,就会出现上面4.3说的问题。所以在设计父类时,要格外谨慎:要么明确标注哪些方法是"专供子类重写"的,并且文档说明调用时机;要么就干脆把父类设计得更保守一点。

5. 继承之外:什么样的代码不该用继承

5.1 继承不是"为了复用代码就要用"

很多新手看到一个类里有自己需要的方法,第一反应就是"那我继承它吧"。这其实是个很危险的思维。继承是一种强耦合关系:子类和父类的生命周期、初始化顺序、内部实现都绑在一起,父类改一个方法签名,所有子类全部受影响。

用继承之前,先问自己一个问题:这两个类之间真的有is-a关系吗?DogAnimal,没问题。ManagerEmployee,也没问题。但是"某个工具类里有个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 设计父类时要做"受保护"的判断

如果确定要用继承,父类的设计直接决定子类的体验。几个关键设计点:

  • 允许子类重写的方法,用protectedpublic修饰;不希望被重写的方法,用final修饰。
  • 构造方法里调用实例方法,尽量调用finalprivate方法,避免调用可重写方法。
  • 字段能设private就设private,通过protected的getter/setter暴露给子类。直接暴露protected字段虽然省事,但会让子类代码随意修改状态,后期维护容易出问题。

final关键字在继承里的作用常被忽略。final class表示这个类不能被继承,比如String类就是final的,Java禁止你继承Stringfinal方法表示子类不能重写它。这些设计不是故意限制你,而是为了保护不变性和安全性——比如String若是能被继承,字符串的一些安全保证就形同虚设了。

5.3 继承层次别设计太深

有的项目里能看到五六层继承:A extends B extends C extends D...,每层加一点东西。这种代码非常难读,你看到一个对象要搞清楚它到底有哪些方法,得一层一层往上翻。改代码更痛苦,改顶层父类,下面所有类都可能受影响。

业界经验是:继承层次尽量控制在两三层以内。如果发现继承链太长,说明你的设计可能过度分类了,该考虑用组合、接口或者直接拆分更细的类来化解。

我自己在实际开发中还坚持一个原则:子类只重写父类的方法,不要轻易在子类里新增太多父类不知道的能力。如果子类有大量父类完全不知道的公共方法,说明父类的抽象边界画错了,继续堆下去会越堆越乱。

6. 0基础实战:从Employee到Manager的完整继承示例

前面原理讲了不少,这一节我们动手写一个完整的、能跑的小项目,把前面的知识点全部串起来。场景是员工管理系统:有普通员工,也有经理。经理本身也是员工,但经理有额外的津贴,计算工资的方式和普通员工不一样。

6.1 父类:Employee

public 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 子类:Manager

public 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 自己动手改一改

只看不练,过两天就会忘。我建议你在这个例子上做几个小改动,每个改动都能验证一个知识点:

  1. Employee增加一个protected字段department(部门),子类Manager中直接访问它,验证protected的访问权限。
  2. Employee构造方法里调用一个可重写的方法,观察创建Manager对象时的输出顺序,亲自感受4.3节说的坑。
  3. Manager增加一个teamSize(团队人数),并在toString()里打印出来,顺便练习子类如何新增自己独有的字段和方法。

我自己带新人的时候,发现一个规律:那些能把继承的初始化顺序、重写规则讲明白的人,往往不是背概念背得多熟,而是亲手写过这种小例子、踩过几个坑的人。Java的继承并不复杂,复杂的是藏在细节里的执行规则和设计取舍。把这一章的例子跑通、改明白,你对继承的理解就能超过相当一部分工作一两年的程序员。

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

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

立即咨询