1. 继承:零基础理解 Java 代码复用第一课
1.1 为什么需要继承 —— 先看一个改代码改到崩溃的场景
我记得当年带一个学弟入门 Java 的时候,他写过一段特别“原始”的代码:先写了一个 Dog 类,里面有 name、age 两个字段,还有 eat() 和 sleep() 两个方法。后来需求加了,要一个 Cat 类,他直接把 Dog 类复制了一份,把“狗”改成“猫”。再后来要一个 Bird 类,又复制一份。三个类加起来 200 多行,其中 180 行是一模一样的。
真正让他崩溃的是第四次改需求:所有动物都要加一个 weight 字段。于是他花了半小时,改了三个文件,改了九处地方,还漏改了一处,程序直接跑不通。我告诉他:你缺的不是方法,是思维。这种“复制粘贴再改名”的写法,在 Java 里有一个非常明确的反对方案——继承。
继承解决的问题很简单:把多个类共有的字段和方法抽到一个“父类”里,子类用 extends 关键字“继承”这些内容,然后只写自己独有的部分。用继承重写上面的动物场景,核心逻辑就变成了这样:
// Animal.java public 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 sleep() { System.out.println(name + " 在睡觉"); } }// Dog.java public class Dog extends Animal { public Dog(String name, int age) { super(name, age); } public void bark() { System.out.println(name + " 汪汪叫"); } }子类 Dog 只写了 bark() 这一个自己独有的方法,name、age、eat()、sleep() 全部来自父类 Animal。以后需求再变,加 weight 字段只需要改 Animal 一个文件,所有子类自动生效。这就是继承最直接的价值:复用共性,保留个性。
1.2 extends 语法与构造方法调用顺序
继承的语法非常简单,子类声明时加 extends 加父类名就行。但简单背后有几个必须搞清楚的规则,这是牛客刷题和面试里反复出现的点。
第一个规则:子类构造方法第一行必须调用父类构造方法。如果你不写,编译器会自动加一个 super() 调用父类的无参构造方法。如果父类只有带参构造方法,而子类构造方法里没有显式调用带参的 super(...),编译就会直接报错。所以刚才的 Dog 类构造方法里写 super(name, age) 不是可写可不写,而是父类没有无参构造时,这一行就是必须的。
public class Cat extends Animal { // 错误示范:父类没有无参构造,这里会编译失败 public Cat() { } }第二个规则:继承的关系是单向的,Java 只支持单继承。一个类只能有一个直接父类,这是刻意的设计——多继承会带来菱形继承问题,也就是两个父类都有同一个方法时,子类到底继承哪一个?Java 用“接口多实现 + 类单继承”的组合拳绕开了这个坑。你可以在牛客上看到很多关于菱形继承的题,本质都是在考这个语法限制。
第三个规则:private 成员不会被继承。父类里如果用 private 修饰字段,子类代码里直接用这个字段名会编译失败。你需要把字段改成 protected 或者提供 public 的 getter/setter。这也引出一个很常见的初学者误区:继承和访问权限是强相关的,不是“父类所有东西子类都能用”。protected 这个权限位就是专门为继承设计的,它允许子类直接访问,但外部其他类不行。
1.3 牛客题 21/22 的解题思路拆解
牛客题库里 21、22 这两题,围绕的核心就是“定义父类、创建子类、重写方法”这个套路。典型题型是:给一个 Person 父类,有 name 和 age 字段以及一个 speak() 方法,要求定义 Student 子类继承 Person,新增 school 字段,重写 speak() 方法输出学生的完整信息。
拿到这种题,我建议你按下面四步走:
- 先写父类。字段私有化,提供构造方法和一个 speak() 方法,方法体输出通用的信息。
- 写子类,用 extends 继承父类。构造方法用 super(name, age) 先把父类的字段初始化掉,再给自己的新增字段赋值。
- 重写 speak() 方法。方法签名必须和父类完全一致:返回类型、方法名、参数列表都不能变。输出内容加上子类特有的字段。
- 在主方法里测试。用父类引用指向子类对象的方式创建对象,也就是 Animal a = new Dog(),后面讲多态时会重点展开。
重写方法时有个细节:可以在方法上加上 @Override 注解。这个注解不加代码也能跑,但加了之后,如果你把方法签名写错了,编译器会直接报错提醒你。我从学 Java 第一天起就习惯加 @Override,这个习惯帮我避掉了无数的坑。
提示:刷题时如果看到“子类继承父类,但无法访问父类的私有成员”这类说法,判断题答案是“错误”。子类确实不能直接访问,但可以通过父类提供的 public 方法间接访问。题目经常在这里挖坑。
2. 多态:让同一个方法调用出不同行为
2.1 多态的三个必要条件
多态这个词听起来高大上,其实在 Java 里就是指一句话:同一个父类引用,指向不同的子类对象,调用同一个方法,表现出来的是不同子类各自的行为。
最经典的例子就是:
Animal a1 = new Dog("旺财", 2); Animal a2 = new Cat("咪咪", 1); a1.eat(); // 输出:旺财 在吃东西 a2.eat(); // 输出:咪咪 在吃东西看起来 a1 和 a2 都声明成 Animal 类型,调用同一个 eat() 方法,但因为实际指向的对象不同,执行的结果也不同。这就是“一种接口,多种实现”的直观体现。
要触发多态,需要满足三个条件,缺一不可:
- 有继承关系。父类引用可以指向子类对象,这是前提。
- 子类重写了父类的方法。如果子类没有重写,调用到的还是父类的方法,体现不出差异。
- 通过父类引用调用被重写的方法。注意,如果你用子类引用调用子类独有的方法,那不算多态;多态的核心是“声明类型”和“实际类型”不一致。
我在教学中见过一个常见的困惑:为什么 a1 明明指向的是 Dog 对象,却不能调用 bark() 方法?答案很实在——编译期间,编译器看的是声明类型 Animal,Animal 类里没有 bark() 方法,所以编译都过不了。想在运行时调 bark(),只能先强制类型转换:
if (a1 instanceof Dog) { Dog d = (Dog) a1; d.bark(); }instanceof 关键字用来判断对象的实际类型,做向下转型前一定要先判断,否则很容易抛 ClassCastException。
2.2 重载与重写的区分,以及动态绑定原理
重载(Overload)和重写(Override)是牛客高频考点,也是零基础最容易混的一对概念。我总结了一个最简单的记法:
- 重写:发生在继承关系中,子类把父类的方法“换皮”,方法签名完全一样。
- 重载:发生在同一个类中,方法名一样,参数列表不一样,本质是多个不同的方法。
重载的例子:
public class Calculator { public int add(int a, int b) { return a + b; } public double add(double a, double b) { return a + b; } }这两个 add 方法方法名相同,但参数列表不同,调用时根据传入参数的个数和类型来决定走哪一个。注意,只改返回类型不算重载,比如 add(int, int) 返回 int,再写个 add(int, int) 返回 double,编译器直接报错——方法签名只由方法名和参数列表决定。
理解重写背后的“动态绑定”,是真正吃透多态的关键。我打个比方:你给朋友发消息说“周末一起吃饭”,发给程序员朋友时他会回“好,帮我带杯咖啡”,发给健身朋友时他会回“可以,顺便练个背”。消息内容一样,但不同的人收到后做出的回应不同。“绑定”就是在运行时决定到底执行哪个方法版本的过程。Java 默认的非静态方法调用都是动态绑定,也就是运行时才根据对象的实际类型来决定。
为什么非静态方法要动态绑定而 static 方法不用?因为静态方法属于类本身,不依赖对象的存在,所以它是静态绑定,编译期就定死了。这意味着:通过父类引用调用一个 static 方法,执行的一定是父类的方法,哪怕实际对象是子类。这个坑在牛客题里出现过不止一次,记住这句话能帮你拿分。
2.3 牛客题 23 的解题思路拆解
23 这题,通常是给你一个父类 Shape,里面定义一个计算面积的方法 calculateArea(),子类 Circle 和 Rectangle 分别重写这个方法,最后用父类引用的数组存储多个子类对象,循环调用计算总面积。
这道题的考察点非常集中,就是你有没有真正理解多态。我看到不少零基础同学卡在这里,原因不是不会写 Circle 的面积公式,而是不知道怎么组织代码。最佳写法是:
public class Main { public static void main(String[] args) { Shape[] shapes = new Shape[2]; shapes[0] = new Circle(3.0); shapes[1] = new Rectangle(4.0, 5.0); double totalArea = 0; for (Shape s : shapes) { totalArea += s.calculateArea(); } System.out.printf("总面积: %.2f%n", totalArea); } }这个写法的妙处在于:Shape[] 数组的声明类型是 Shape,但你放进去的是 Circle 和 Rectangle。循环里每调用一次 calculateArea(),JVM 都会根据当前对象的实际类型,去执行对应子类的面积计算方法。以后你要加一个 Triangle 子类,只需要创建一个 Triangle 对象放进数组,主循环一行代码都不用改。这就是多态从“能跑”到“好用”的分水岭。
注意:写这道题时,很多人会忘记在 Circle 和 Rectangle 类里加 @Override。如果你想快速确认自己是不是真的重写了,就看注解有没有生效。@Override 提示“找不到要重写的方法”,说明要么方法签名写错,要么父类根本没有这个方法。
3. 接口与抽象类:设计层面的两个利器
3.1 抽象类:把“共性”和“差异”分开
讲完了继承和多态,接下来是接口和抽象类。这两个概念在零基础阶段特别容易混淆,因为它们看起来都“不能直接实例化”,都“要被子类实现”。但它们的定位和使用场景完全不同。
抽象类用 abstract 关键字声明。它最核心的特点是:可以包含抽象方法,也可以包含普通方法。抽象方法只有方法签名,没有方法体,需要子类强制实现。换句话说,抽象类是“半成品”类——它把一部分共性逻辑写成具体方法,把一部分差异逻辑留成抽象方法,让不同的子类各自补全。
我用一个现实场景来类比:你想开一家餐厅,需要招聘厨师,但“厨师”这个概念太抽象了,你不能直接雇佣一个“厨师”对象,你必须雇“川菜厨师”或者“粤菜厨师”。但是,所有厨师应该知道“如何颠勺”这个动作。这里的“厨师”就是抽象类,颠勺是抽象方法,每个菜系厨师根据自己的方法实现。
public abstract class Chef { public void prepareIngredients() { System.out.println("准备食材,清洗、切配"); } public abstract void cook(); }子类川菜厨师必须实现 cook() 方法,而 prepareIngredients() 可以直接继承使用。这样设计的好处是:共性逻辑写一次,差异逻辑强制每个子类自己实现,不会出现某个子类“忘记实现核心方法”的情况。
抽象类还有一个细节:构造方法存在,但不能用 new 直接创建对象。有个经常被问到的面试题是“抽象类有构造方法吗?”答案是有。它存在的意义是给子类调用,子类创建对象时,会先通过 super() 链调用抽象父类的构造方法,初始化父类的字段。
3.2 接口:能力契约与默认方法
接口用 interface 关键字定义。它比抽象类更“纯粹”——在 Java 8 之前,接口里只能有抽象方法和常量。接口本质是一份能力契约,它规定“实现这个接口的类,必须拥有哪些能力”,但不关心你怎么实现。
我给学生举过一个很直观的例子:USB 接口。一台电脑上不需要知道 U 盘、键盘、打印机各自内部怎么运作,只要它们都遵守 USB 接口标准,插上就能用。代码里也是一样——一个类可以实现多个接口,这弥补了 Java 单继承的不足。
public interface Flyable { void fly(); } public interface Singable { void sing(); } public class Bird implements Flyable, Singable { @Override public void fly() { System.out.println("鸟儿飞翔"); } @Override public void sing() { System.out.println("鸟儿歌唱"); } }一个类可以同时实现 Flyable 和 Singable 两个接口,这种能力在类的单继承下是不可能实现的。接口之间也可以用 extends 互相继承,形成接口的层次。
Java 8 以后,接口里新增了 default 方法和 static 方法。default 方法允许你在接口里写出带方法体的方法,实现类可以不用强制重写。这个设计主要是为了兼容性——在不破坏已有实现类的前提下,给接口增加新方法。但我要提醒你:刷题时如果看到接口里有 default 方法,别觉得奇怪,这是合法语法。同时,default 方法也带来一个经典面试题:当一个类实现的两个接口里有同名的 default 方法,类必须重写这个方法来消除冲突。
3.3 抽象类和接口该如何选择
抽象类和接口功能上有重叠,也有很多细节区别。我给你整理了一个对比表,刷题和面试都照这个记:
| 比较项 | 抽象类 | 接口 |
|---|---|---|
| 关键字 | abstract class | interface |
| 继承/实现数量 | 单继承,一个类只能继承一个抽象类 | 多实现,一个类可以实现多个接口 |
| 字段 | 可以有实例字段,普通变量 | 只能定义常量(public static final) |
| 构造方法 | 有 | 没有 |
| 方法 | 可以有抽象方法和普通方法 | 主要是抽象方法,Java 8 后有 default 和 static 方法 |
| 设计定位 | “是什么”的关系(is-a) | “具备什么能力”的关系(like-a / has-a) |
选择逻辑其实很简单:如果你要表达的是“这个类是那个类的一种”,比如 Student 是一种 Person,用继承抽象类。如果你要表达的是“这个类具备某种能力”,比如一个类能飞、能游泳,用接口。
零基础阶段我看到的最多问题,就是纠结“我该用抽象类还是接口”。我一般的建议是:默认优先考虑接口。这是因为接口更灵活,一个类可以实现多个接口,而且接口天然适合定义行为规范。只有当你确实需要共享字段和具体方法、且这些字段方法有明确的父子层级关系时,才考虑抽象类。
3.4 牛客题 24~26 的解题思路拆解
第 24~26 题是接口和抽象类的直接应用。常见题型有:定义抽象类、定义接口、创建一个类同时继承抽象类并实现接口,然后按要求输出。
我拿一个典型的综合题来说明。题目要求:定义一个抽象类 Animal2,包含 name 字段和一个抽象方法 eat();定义一个接口 Pet,包含一个普通方法 play();定义 Dog2 类继承 Animal2 并实现 Pet,重写所有必须实现的方法。
标准写法的解题顺序是这样的:
public abstract class Animal2 { protected String name; public Animal2(String name) { this.name = name; } public abstract void eat(); } public interface Pet { void play(); } public class Dog2 extends Animal2 implements Pet { public Dog2(String name) { super(name); } @Override public void eat() { System.out.println(name + " 在吃狗粮"); } @Override public void play() { System.out.println(name + " 在玩飞盘"); } }这类题的注意点是:类继承抽象类并实现接口时,先写 extends,再写 implements,顺序不能反过来。这是语法规定。此外,凡是抽象父类里的抽象方法,以及接口里的抽象方法,子类必须全部实现,否则子类自己也要声明成 abstract,而一旦声明成抽象类,就不能 new 对象了。
第 24~26 题之间可能还存在递进关系,比如第 24 题只是让你创建抽象类和子类,第 25 题加入接口,第 26 题要求用父类引用和接口引用同时指向同一个对象来验证多态。最后这种考法很经典:
Animal2 a = new Dog2("旺财"); Pet p = new Dog2("旺财"); a.eat(); // 通过父类引用调用 eat() p.play(); // 通过接口引用调用 play()同一个对象 Dog2,既可以被 Animal2 引用指向,也可以被 Pet 引用指向。通过不同的引用类型,能调用到的方法也不同——Animal2 引用只能调用 eat() 和继承下来的方法,Pet 引用只能调用 play()。这个设计把今天讲的所有核心概念(继承、接口、多态、抽象类)全部串起来了,理解透这里,你的 Java 面向对象基础就算真正立住了。
4. 刷题过程中常见的坑和排查心得
4.1 编译报错的三大高频原因
我在带学生刷这六道题的过程里,发现零基础同学报错高度集中在三个地方。
第一个是构造方法问题。报错信息里常见的是“There is no default constructor available in class Animal”之类。原因就是我前面说的:父类定义了带参构造方法后,就不再有默认的无参构造,子类构造方法第一行必须显式调用 super(...)。这个错看懂了就是一行代码的事,看不懂就反复编译失败。我的建议是:写子类构造方法时,第一句话永远是 super(...),形成肌肉记忆。
第二个是访问权限问题。报“Cannot find symbol”或者“x has private access in y”,基本就是访问权限用错了。记住:父类的 private 成员子类不可直接访问,需要 protected 或者 getter/setter。实践里我喜欢把需要被子类访问的字段设为 protected,这既保证了封装的封闭性,又给继承留了口子。
第三个是类型转换问题。前面提过,父类引用不能直接调用子类特有方法。如果你强转时不写 instanceof 判断,代码运行到一半极可能抛 ClassCastException,这是运行时异常,编译期根本发现不了。稳妥写法是:先判断,再转换。
这里我分享一个排查思路。收到报错后,先看报错发生在编译期还是运行期。编译期的错,编译器会指出具体行号,直接定位到那一行,对照我在上面列的三类问题逐一排查。运行期的错,往往和对象的实际类型有关,这时候优先检查 instanceof 和类型转换相关的代码。把这两条思路理清楚,牛客上的报错你能解决掉百分之八十。
4.2 面试更容易追问的三个隐藏考点
刷完这几道题,知识部分已经覆盖了面向对象最核心的几个概念。但如果目标是准备面试,我建议你额外把这三个隐藏考点也挖一遍。
第一个是 equals 和 hashCode。很多牛客题里都会涉及对象比较,但初学者往往只用 == 比较基本类型。对于引用类型,== 比较的是地址,equals 默认也是地址比较,除非重写了。实践中重写 equals 时必须同时重写 hashCode,否则基于哈希的集合(HashSet、HashMap)会出现逻辑错误。这两个方法在面试中被问到的概率极大。
第二个是 static 方法不参与多态。前面提到过这个点,面试官经常考:用父类引用调用被重写的 static 方法,输出的是谁的?答案是父类的。因为 static 方法属于类,在编译期就完成了绑定。
第三个是接口中变量的隐含修饰符。接口里声明的变量,默认就是 public static final,也就是常量。你写 int x = 5; 其实等同于 public static final int x = 5;。面试时如果问接口能不能有实例变量,回答不能,因为接口里的所有变量都是常量。
这三个点不需要你现在立刻做深度研究,但做完继承、多态、接口、抽象类这六道题之后,顺着去了解一下会非常有帮助。它们属于同一个知识网络里的延伸节点。
4.3 一个值得刻意养成的刷题习惯
最后说一个我特别想安利给零基础同学的刷题习惯:每做完一道题,强制自己用一句话说出“这道题考了什么抽象概念”。
比如做完第 21 题,你告诉自己:这道题考的是继承,子类通过 extends 复用父类字段和方法。做完第 23 题,你告诉自己:这道题考的是多态,父类引用指向子类对象,调用方法时执行子类重写版本。做完第 26 题,你告诉自己:这道题考的是抽象类和接口的组合使用,一个类可以同时继承抽象类并实现接口。
这个方法看起来简单,但效果出奇地好。因为刷题的核心目的不是背题,而是建立“题目现象”和“概念本质”之间的映射。你每做一道题,就强迫自己把这个映射说一遍,一个月后你会发现,很多题目在你眼里不再是冷冰冰的代码,而是几个熟悉概念的不同组合方式。
我在带学生的过程中反复验证过这件事:能坚持做这个动作的同学,学完继承多态这部分之后,再看后面的集合、异常、IO相关内容,接受速度明显快一截。因为面向对象的思想一旦内化,Java 的一切都会变得顺理成章。