从“照着敲都会,一运行就报错”到真正理解面向对象,是每个Java零基础学习者必须跨过的一道坎。这个系列的牛客刷题指南走到第21~26题,正好切入Java里最核心也最容易让人绕晕的一组概念:继承、多态、接口和抽象类。这篇文章就把这6道题背后真正需要掌握的东西拆开揉碎讲清楚,不背概念、不念定义,用实际代码和踩坑经历带你一步步把这块硬骨头啃下来。不管你是刚学完类和对象、正在被各种术语折磨的新手,还是学了一遍但总觉得哪里没通透、想在刷题中查漏补缺的初学者,这篇都适合你。
1. 零基础学Java,为什么总在“类”这里栽跟头
先聊一个现象。我见过很多自学Java的人,前面变量、循环、数组、方法都学得挺顺,一到类和对象就开始发懵,再往后学到继承多态,基本就是“每个字都认识,连起来不知道在说什么”。这不怪你,因为从这一章开始,你要切换的不只是语法,而是一整套思维模型。
1.1 从“复制粘贴代码”到“面向对象思维”的坎
在学类和对象之前,你写程序的方式是这样的:需要什么功能,就写一个方法,把数据传进去,得到结果。这种方式叫面向过程,适合小程序,但一旦项目体量变大,代码之间会越来越纠缠,改一个功能常常牵连一大片。
面向对象不一样,它把数据和操作数据的方法打包在一起,成为一个“对象”。你可以把对象理解成一个微型机器人,里面有它自己的属性(变量),也有它自己的行为(方法)。你不需要关心它内部怎么干活,只需要告诉它“你去做饭”或者“你把成绩单打出来”,它自己会处理。这种组织方式的好处,代码量一大就体现出来了:每个对象各管各的一摊事,互相之间通过明确的方式协作,出了问题也容易定位。
继承、多态、接口、抽象类这四个概念,本质上都是在解决“对象之间怎么协作、怎么组织”的问题。它们不是四个独立的知识点,而是一条链路:继承是父类和子类之间的纵向关系,多态是利用这个关系让代码更灵活,接口和抽象类则是从另一个维度定义“类应该长什么样、应该遵守什么规则”。
1.2 牛客这6道题,到底在考什么
21~26题这个区间,题目设计是有讲究的。它不会一上来就让你写一个完整的项目,而是通过一组精心设计的小题,逼你把以下能力点逐个过一遍:
- 你能不能准确判断两个类之间是不是继承关系
- 你能不能看出一段代码在运行时调用的到底是哪个方法
- 你能不能区分重载和重写,并且知道重写有哪些约束
- 你能不能理解接口和抽象类各自的定位,并在设计中做出选择
说白了,这6道题就是在检验你有没有建立起“面向对象”的直觉。刷题之前,这个直觉得有,刷题之后,这个直觉会被强化。所以这篇文章先讲透概念,再逐题拆解,顺序不能反。
2. 继承:先搞懂它到底是“省代码”还是“认关系”
很多新手理解继承,第一个想到的就是“子类可以复用父类的代码”,然后就把继承当成了一个省代码的工具。这个方向其实已经偏了。继承首先是一种关系,代码复用只是这个关系的副产品。
2.1 is-a关系才是继承的灵魂
继承关系用一句话概括:子类是一个父类。猫是一种动物,学生是一个人,正方形是一种形状。这种关系在英文里写作is-a,是判断能不能用继承的第一标准。
如果你说“我想让Student类复用Person类里的name和age字段,所以让Student继承Person”,这个理由不够充分。正确的理由是“学生是一个人,所以Student天然拥有人的所有共性”。代码复用是结果,不是原因。
那么问题来了,怎么在代码里体现这种关系?看下面这个例子:
class Animal { protected String name; public Animal(String name) { this.name = name; } public void eat() { System.out.println(name + "正在吃东西"); } } class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name + "汪汪叫"); } }Dog类通过extends关键字继承Animal类,Dog对象就自动拥有了name属性和eat()方法。你在测试类里可以这样写:
Dog dog = new Dog("旺财"); dog.eat(); // 旺财正在吃东西 dog.bark(); // 旺财汪汪叫Dog类自己只写了bark()方法,但它能调用eat(),这就是继承带来的能力。
2.2 super()这一行,新手最容易漏写
看上面Dog的构造器,第一行是super(name)。这行代码的意思是调用父类的构造器,把name传上去。
很多新手会问:我不写super()行不行?答案是行,但要分情况。如果你不写,Java编译器会自动在子类构造器第一行插入一个无参的super()调用,也就是调用父类的无参构造器。如果父类没有无参构造器,编译就会报错,提示你something like“constructor Animal in class Animal cannot be applied to given types”。
我见过不少新手在这个地方卡住,怎么检查都没发现语法问题,就是编译不过。原因就是父类只写了有参构造器,没有写无参构造器。
所以这里有个实操建议:如果你定义了带参构造器,编译器就不再提供默认的无参构造器了。这时候你有两个选择,一是显式写出无参构造器,二是让子类构造器第一行调用父类的带参构造器。从设计角度我更推荐后者,因为name这种必填信息,走构造器传进去更安全,能避免创建出名字为null的对象。
2.3 访问修饰符的“收”与“放”
继承里还有一个细节需要特别留意:父类的private成员,子类能不能直接访问?答案是不能。
看个例子:
class Animal { private String secret = "动物的小秘密"; } class Dog extends Animal { public void reveal() { // System.out.println(secret); // 编译错误!secret是private的 } }这是因为private成员的可见范围仅限于本类内部,子类也看不到。那怎么办?两种方案:一种是改成protected,protected的语义是“同包下的类可见,还有子类可见,哪怕是不同包的子类”;另一种是提供public的getter方法,这也是更符合封装思想的做法。
说到protected,有个细节值得展开:即使Dog是Animal的子类,Dog类内部也只能通过“Dog对象自己的继承来的成员”访问protected成员,不能随便拿一个Animal对象去访问它的protected字段。这点在刷题时偶尔会以判断题形式出现,建议留意。
2.4 为什么说继承是“强关系”,不能乱用
继承的耦合度非常高。子类绑定了父类,父类改了,子类跟着受影响。这就是为什么业界一直有“组合优于继承”的说法:能用组合表达的关联关系,尽量别用继承硬套。
比如“汽车”和“发动机”,它们之间是has-a关系(汽车拥有一台发动机),不是is-a关系,所以不该让Car去继承Engine,而应该在Car里声明一个Engine类型的字段。明白了这个边界,你再去判断牛客里那些“下列哪组类适合用继承”的题,就不会光凭直觉乱选了。
3. 多态:同一个方法调用,凭什么走不同实现
继承是基础,多态是继承的进阶运用。多态这个词听起来玄乎,其实用一句话就能说透:同一个类型的引用,指向不同的对象,调用同一个方法,会有不同的表现。
3.1 多态成立的三个条件,少一个都不行
要在Java里实现多态,必须同时满足三个条件:
- 有继承关系(或者接口实现关系)
- 子类重写了父类的方法
- 父类引用指向子类对象
看这段代码:
Animal a1 = new Animal("动物"); Animal a2 = new Dog("旺财"); a1.eat(); // 动物正在吃东西 a2.eat(); // 旺财正在吃东西(如果Dog重写了eat方法)这里a2的编译类型是Animal,运行类型是Dog。调用eat()时,Java虚拟机做的是动态绑定:它不看a2声明成什么类型,而是看它实际指向的对象是哪个类,然后调用那个类里真正的方法。
用大白话说就是:代码写的是a2.eat(),但a2到底是谁,要等程序运行到这一行才知道。这就叫“运行期才确定”,也就是多态的底层机制。
3.2 编译看左边,运行看右边
“编译看左边,运行看右边”这句话,是刷多态题的金钥匙。它说的是:调用一个方法时,编译阶段检查的是左边引用类型里有没有这个方法;运行阶段调用的却是右边实际对象所属类里的方法。
所以如果父类里没有某个方法,就算子类里面有,你也调不了:
Animal a = new Dog("旺财"); // a.bark(); // 编译错误!Animal类型里没有bark方法这个例子新手特别容易踩。明明a指向的是Dog对象,Dog类里有bark()方法,凭什么不能调?就是因为编译阶段只看Animal这个引用类型,Dog里那套东西它不认。要是想让子类特有的方法被调用,你得先把引用类型转回Dog:
Dog d = (Dog) a; d.bark(); // 现在可以了这个强制类型转换也有风险,如果a实际上不是Dog,运行时会抛ClassCastException。用之前最好用instanceof先判断一下。
3.3 重载和重写,牛客最爱考的一对概念
多态的题,十道里有七八道是围绕“重写”展开的。跟它对应的还有“重载”,这两个词名字像,但含义完全不同。
| 对比项 | 重写(Override) | 重载(Overload) |
|---|---|---|
| 发生位置 | 子类和父类之间 | 同一个类里 |
| 方法名 | 必须相同 | 必须相同 |
| 参数列表 | 必须相同 | 必须不同 |
| 返回值类型 | 可以相同,也可以是父类返回值的子类型 | 可以不同,Java不要求 |
| 访问修饰符 | 子类不能比父类更严格 | 随意 |
| 绑定方式 | 运行期动态绑定 | 编译期静态绑定 |
| 常见标记 | 建议加@Override注解 | 不需要注解 |
重写是父子类之间的一场“改造”,重载是同一个类里“同名不同参”的多个版本。我见过不少人把这两者搞混,一看到方法名相同就说是重写,完全没看两个方法到底在哪个类里。刷题时只要先把“这两个方法在不同类还是同类”判断清楚,再去看参数列表,基本就不会错。
另外有个小细节容易被漏掉:重写时方法的返回值可以是原来返回类型的子类型,这叫协变返回类型。父类返回Animal,子类重写时可以返回Dog。这在JDK里很常见,但作为零基础,你只需要有个印象就行。
3.4 多态到底有什么实际价值
可能有人会觉得,多态不就是绕来绕去的嘛,直接new一个Dog再调用方法不就行了,干嘛非要声明成Animal类型?问得好。
多态的核心价值在于“面向抽象编程”。你可以写这样一段代码,完全不知道具体的实现类是谁:
public void feed(Animal a) { a.eat(); }feed方法接收一个Animal类型的参数,但它能接收Animal的任何一个子类对象。传入Dog它就让狗吃,传入Cat它就让猫吃。如果将来你新写了一个Bird类继承Animal,feed方法一行都不用改,直接就能传Bird对象进来。
这就是设计原则里说的“开闭原则”:对扩展开放,对修改关闭。新增功能靠添加新类,而不是改动既有代码。多态是支撑这个原则的基石,也是你以后学框架时到处都能见到“依赖注入”“策略模式”这些概念的思想源头。
4. 接口和抽象类:撕开它们最容易被混淆的那层纸
继承和多态算是父子关系这条纵轴,接口和抽象类则是另一条横轴。很多人学到这里崩溃,是因为把两个概念放在了同一个维度里去死记硬背区别。其实它们的定位完全是两回事。
4.1 抽象类:一个不完整的类,用来当模板
抽象类用abstract关键字修饰。它的特点是:类本身不能被new,但可以拥有抽象方法(没有方法体,只有声明),也可以拥有普通方法、字段、构造器。
抽象类的本质是“模板”。它是让你去继承的,不是让你直接使用的。打个比方,你开了一家快餐店,菜单上有一道“招牌套餐”,但具体是什么菜没定,可能今天是咖喱鸡饭套餐,明天是红烧牛肉面套餐。这个“招牌套餐”就是抽象方法,它一定存在,但具体内容由各家店自己决定。
来看一个例子:
abstract class Shape { protected String color; public Shape(String color) { this.color = color; } public abstract double getArea(); public void showColor() { System.out.println("颜色是" + color); } }Shape是个抽象类,里面有一个抽象方法getArea()。任何继承Shape的具体类,都必须实现getArea(),否则这个子类也得是抽象类。但Shape里也可以有showColor()这种完整实现的方法,子类可以直接继承使用。
为什么抽象类要有构造器?既然不能new,构造器拿来干嘛?答案是给子类用的。子类在构造时会先调用父类构造器,把color这种公共字段初始化好。这是抽象类一个比较隐蔽但也比较重要的小细节,牛客里偶尔会考。
4.2 接口:一份纯契约,规定你必须能干哪些事
接口用interface关键字修饰,它比抽象类更彻底。Java 8之前,接口里只能有抽象方法和常量;Java 8之后加了default方法和static方法;Java 9之后还允许private方法。但对零基础来说,你只需要把接口理解成“一份合同”就够了。
合同上写着:凡是实现我这个接口的类,必须提供这些能力。至于你怎么实现,我不管,那是你的事。
interface Flyable { void fly(); } class Bird implements Flyable { @Override public void fly() { System.out.println("扇动翅膀飞行"); } } class Plane implements Flyable { @Override public void fly() { System.out.println("喷气引擎推进"); } }Flyable接口规定了fly()这个能力,Bird和Plane各自去实现。注意这里用implements关键字,一个类可以implements多个接口,这就绕开了Java单继承的限制。
接口能多实现,是接口和抽象类最大的区别之一。一个类只能继承一个抽象类,但可以实现多个接口。这也是设计上的一种折中:Java为了保证类关系的简单性,不允许多继承,但现实世界中确实存在“某种东西拥有多方面的能力”的需求,接口就是为此准备的。
4.3 到底什么时候用接口,什么时候用抽象类
这个问题可以说是面试八股文里的常青树,牛客这个阶段的题里也一定会碰到。我的回答是:看你想要“契约”还是“模板”。
如果你需要定义一组能力、行为规范,让完全不相干的类也能统一表现,用接口。比如dragon能飞,飞机能飞,鸟能飞,这三个类没什么血缘关系,但它们都需要fly(),那就定义一个Flyable接口,谁想能飞就implement它。
如果你需要抽取多个类的公共代码,并且它们之间确实有is-a关系,用抽象类。比如所有形状都有颜色,都有计算面积的行为,但每个形状算面积的方式不一样,那Shape设计成抽象类就很合适:颜色和显示颜色的代码全部复用,面积计算留给子类各自实现。
在实际工程里,还有个更常见的组合姿势:先定义接口作为对外契约,再写一个抽象类作为“骨架子”,把一些通用逻辑实现掉,然后让具体业务类去继承这个抽象类并实现剩余的方法。这是很多框架(像Spring里的一些模板类)都采用的模式。零基础阶段不需要写这么复杂,但刷题时能在选择题里认出这种设计意图,很有帮助。
5. 牛客21~26题逐题拆解:看到题目先想到哪一层
概念铺垫到这里,来拆题。这一组题我没有办法把原题逐字复现(每期牛客的题面会有调整),但根据系列进度和考察范围,这类题目围绕的考点分布是很稳定的。我按照“题型+破题思路+易错点”的方式带你把这一组题过一遍,你自己做题时对号入座就行。
5.1 第21、22题:判断继承关系与访问控制
这个阶段的开头两道题,通常会给几个类,让你判断“哪两个类之间适合用继承”或者直接给你一段代码,问你能不能编译通过、输出是什么。
破题顺序很重要。我一般建议按三步走:
第一步,判断两个类之间是否是is-a关系。不是is-a,直接排除继承选项,别被“能复用代码”这种选项迷惑。
第二步,看访问修饰符。注意父类private成员,子类能不能看到。题目如果给了一段在子类里访问父类private字段的代码,大概率编译报错。
第三步,看构造器。子类构造器有没有通过super调用父类合适的构造器。如果父类只有带参构造器,而子类构造器第一行没有调用super,和父类有没有默认构造器,这往往就是隐藏的坑。
举个例子,题目给你:
class A { protected int x; public A(int x) { this.x = x; } } class B extends A { public B() { // 此处没有写super } }问能否编译通过。答案是编译失败,因为父类A定义了带参构造器后,默认无参构造器消失,而B的构造器第一行隐式调用super()时找不到无参构造器。这个考点出现频率不低,请务必记住。
5.2 第23题:多态输出,写运行结果
多态类的题,一般会甩给你一段类继承关系代码,里面有几个类、几个重写方法,然后让你写出main方法执行后的输出。做这种题最忌讳的就是“目测”,一定要在草稿纸上画出继承链条。
以这样一段代码为例:
class Parent { public String show() { return "Parent"; } } class Child extends Parent { public String show() { return "Child"; } } public class Test { public static void main(String[] args) { Parent p = new Child(); System.out.println(p.show()); } }输出是Child,因为p的编译类型是Parent,但运行类型是Child,动态绑定让实际执行的方法来自Child。
如果题目里加一个重载方法,比如Parent里再加一个show(String s),难度瞬间提升。这种“重写+重载混合”的题目,破题方法就一句话:先把重载区分开(看参数列表),确定调用的是哪个签名;再按重写规则(编译看左边,运行看右边)确认最终执行的是哪个类的方法。
我刷题的经验是:多态题里最容易错的不是知识本身,而是粗心。很多人看到p是Parent类型,就直接认定调用Parent的方法,忘了看右边new的是谁。所以做题的时候,建议养成一个习惯,每个对象引用都先把这两个类型写出来:左边是什么类型、右边是什么类型,再开始分析。
5.3 第24题:重写考点的各种变体
这题通常围绕重写规则展开。常见的出题方式包括:
- 以下哪个方法可以正确重写父类方法
- 以下关于重写的说法,正确的是哪项
- 给出一个子类重写方法,问哪里有问题
重写规则需要背牢的点,其实就五个:方法名相同、参数列表相同、返回值类型兼容、访问权限不能更严格、不能抛出比父类更宽泛的受检异常。
前四个还好理解,第五个“异常”零基础阶段可能还没接触到,但牛客这个阶段偶尔会提前出判断题。你不用深入掌握异常体系,只需要记住:子类重写方法时,抛出的受检异常范围不能比父类更大,可以更小或者不抛。打个比方,父类说“我想办法做到”,子类可以更牛,直接做到并且不出错,但如果子类说“我做不到,要出大错”,那就不行。
这里还有个考前特别容易忽略的细节:static方法不存在重写。子类里写一个和父类static方法签名一样的方法,不叫重写,叫隐藏。调用时看引用类型:用父类引用调,执行父类的;用子类引用调,执行子类的。私有方法也不存在重写,那纯粹是子类自己定义了一个新方法。
5.4 第25题:接口与抽象类的辨析
到了这一题,牛客基本是把接口和抽象类放一起横向比较。出题形式常常是给你一堆关于两者的描述,让你选出正确的那项。
我把比较维度列成一张表,刷题前对照着过一遍:
| 对比维度 | 抽象类 | 接口 |
|---|---|---|
| 关键字 | abstract class | interface |
| 继承/实现 | 单继承extends | 多实现implements |
| 构造器 | 有,子类super调用 | 没有 |
| 字段 | 可以是普通字段,也可以有常量 | 默认public static final,只能放常量 |
| 抽象方法 | 可以有 | 可以有(Java 8前都是) |
| 普通方法 | 有 | Java 8后default方法 |
| 设计语义 | is-a,模板 | can-do,契约 |
我个人的记忆方法是:抽象类里可以藏隐私(普通字段)、可以有家底(已实现方法),像一个传家宝,只能传给一个后代;接口则是一张能力清单,什么东西都能来认领,而且可以认领多张清单。
做题时还有一个很实用的技巧:看到选项里说“接口里可以定义普通字段”,直接排除。接口字段默认是public static final的,也就是说它本质上是常量,不是能随便改的普通属性。这个知识点老手可能觉得简单,但对零基础来说,确实是最容易模糊的地方。
5.5 第26题:综合设计与代码填空
最后一题往往是综合应用,让你补全代码或者设计类结构。常见的场景有:给一个形状类层次结构,Shape是抽象类,里面有抽象方法getArea(),让你定义一个实现类;或者定义一个接口,要求某个类实现它的方法。
这种综合题,核心考察的是“把概念落成代码”的能力。我建议拿到题先做三件事:
第一,读清楚要求实现的是“继承”还是“实现”。关键词看extends还是implements,别写反。
第二,看抽象方法/接口方法有几个,一个都不能漏。漏一个,子类没全部实现,要么报错,要么子类也必须是抽象类。
第三,写实现方法时,必须加上@Override注解。这个注解不是强制要求(不写编译也能过),但它能在你写错方法签名时立刻给出编译错误提示。写对方法名但参数列表写错,不亚于挖坑埋自己。
给你一个典型的设计题模板,类似这样:
abstract class Shape { protected double area; public abstract double getArea(); } interface Describable { String getDescription(); } class Circle extends Shape implements Describable { private double radius; public Circle(double radius) { this.radius = radius; } @Override public double getArea() { return Math.PI * radius * radius; } @Override public String getDescription() { return "一个半径为" + radius + "的圆"; } }这段代码演示了“同时继承抽象类并实现接口”的写法。Circle既是Shape,所以用extends;又具备“可描述”的能力,所以用implements Describable。两个关键字可以同时使用,extends要写在implements前面,顺序错了编译也会报错。
6. 刷完这组题,你大概率会踩的坑
这一节是经验总结。我陪着不少新人刷过这个区间,几乎每个人都会踩到下面几个坑。提前知道了,你就能少浪费几个小时在无意义的报错里。
6.1 构造器继承这个伪命题
有人以为子类会“继承”父类的构造器,所以new子类时可以直接传父类构造器的参数。这完全是误解。构造器不参与继承,子类必须自己定义构造器,并在第一行(显式或隐式)调用父类的某个构造器。
记住:父类构造器带参、子类构造器不写super,编译必挂。这条规则属于“踩一次就再也不忘”的经典坑。
6.2 重写时把访问权限越收越紧
父类方法是public,子类重写时一不留神写成默认访问权限(不加修饰符)或者改成private,编译直接报错“attempting to assign weaker access privileges”。
原因很好理解:父类的public方法承诺了“谁都能来调”,子类如果把它改成private,等于单方面撕毁承诺,那些通过父类引用调用这个方法的代码就全崩了。所以重写规则里有这么一条:访问权限只能一样或者更开放,不能更严格。
反过来说,你想从private改成public之类扩大可见性,允许。
6.3 返回值类型的问题
重写时方法名、参数列表都对了,但返回值写错类型,编译也能报错。
Java 5之后允许协变返回类型,子类重写方法的返回值,可以是父类方法返回值类型的子类型。比如父类返回值是Shape,子类返回值写成Circle,可以;但如果父类返回值是Shape,子类写成String,绝对不行,两者没有任何继承关系,编译不通过。
做题和写代码时,如果看到返回值类型不匹配,别慌,先判断两者有没有父子类关系。
6.4 接口字段被当普通字段用
接口里的字段默认是public static final,这意味着它必须被初始化,而且初始化之后不可变。有的题会故意写出这样的代码:
interface Test { int num; // 编译错误!常量必须赋值 }这行代码在Java里编译不过,因为num没赋值。接口不会像类那样给字段一个默认值,常量必须显式初始化。刷到这个考点时,我想你已经记住了。
6.5 忽略@Override注解的预警作用
@ Override这个注解,平时写不写都能运行,但它最大的价值是“白送的编译期检查”。如果方法签名写错了(比如少传一个参数),编译器会立刻告诉你“这个方法并没有覆盖父类的方法”。这比运行时报错好找得多。
我的习惯是:凡是重写,一律标@Override。这是零成本、高收益的防御性写法,刷题时要是输出代码片段让你挑错,看到该标却漏标的方法,也要留个心眼:先检查它的签名到底对不对。
7. 刷题之后的进阶思路:别停在“会做题”
最后说点实在的个人体会。这一组题刷完,你可能会有一个错觉:概念都懂了,选择判断题基本能对。
但这离真正的会用,还有一段距离。检验标准很简单:不给你题目,让你自己从头设计一个稍微复杂一点的类结构,比如“一个动物园系统,有动物、饲养员、喂食行为,孔雀能飞,大象不能飞”,你能否独立写出来?如果能,说明你是真的掌握了;如果还是无从下手,建议回头再把4.3那一节看两遍,然后把第26题的模板题多练几遍。
我在实际带新人的过程中,总会让他们在刷完这一组后自己建一个小项目练手,什么系统都行(学生成绩管理、图书借阅、宠物商店),唯一的要求是:必须用到继承、多态、接口或抽象类中的至少三项。这么做不是为了完成任务,而是让这些抽象概念真正和代码产生连接。等他们做完,再回头看牛客这套题,普遍反映是“简单了很多”。
还有一个经验想分享:这一组题学完,你对后续的集合框架、异常处理、泛型理解会顺畅很多。ArrayList为什么能存任何对象?Comparator为什么能用匿名内部类实现?这些你以后会遇到的“神奇语法”,底层都是这组题里的概念在支撑。所以这一步值得慢一点、稳一点,宁可多花几天把它吃透,也别赶进度。基础打牢了,后面的路会越走越快。