Skip to content

Day 3 · 继承、多态、抽象类与接口 ​

继承 ​

java
public class 子类名 extends 父类名 {
}
  • Python 里靠 MRO(方法解析顺序) 决定执行链,类和类可以多继承(能继承两个父类)
  • Java 里类和类是单继承(只能继承一个)

继承注意事项 ​

Java 中如果 C 想同时拥有 A 和 B 的能力,只能链式继承(先继承 B 再让 B 继承 A)。 不过 Java 里确实存在多继承,只是发生在接口和接口之间:

  • 类与类:单继承
  • 类与接口:多实现
  • 接口与接口:多继承

补充整理:链式继承不是「多继承的替代品」,硬凑 A → B → C 会把不相关的类绑死, 是典型的误用。Java 故意不支持类多继承,是为了避开菱形继承带来的字段 / 方法冲突。 正确思路是「组合优于继承」:把能力拆成接口或成员字段,需要什么就实现 / 持有哪个。 另外别为了复用几个方法就继承——先问一句「子类 is-a 父类吗」,答案不是就别用继承。

Object 类 ​

补充整理:原稿这里只有标题。补一句定位:java.lang.Object 是所有类的最终父类, 没写 extends 的类默认继承它,因此任何对象都能调用 toString() / equals() / hashCode() / getClass() 等方法,也都能被 Object 类型的变量接收(这正是多态的基础)。 它的五个常用方法、为什么要重写 equals 和 hashCode,放在 Day 4 · 常用核心类 里展开。

super ​

super 是当前对象里的父类引用区域,并不是一个实际存在的独立对象。 主要用来解决:

  • 变量的隐藏(子类有和父类同名的字段)
  • 方法的重写(子类覆盖父类方法后还想调父类版本)

super() ​

子类没有继承父类的构造器。但子类调用自己的构造器创建对象时,也会调用父类的无参构造器—— 因为无参构造器负责初始化成员变量,而子类继承了父类的成员变量,所以这部分必须由父类自己初始化。

  • super(); 调用父类的无参构造器(不写也默认有)
  • 必须写在构造器第一行
  • 若父类只手动写了有参构造器、没有无参构造器,就必须显式调用有参的,例如 super("张三");
  • this(); 调用本类的其他构造器(有参或无参)

this 与 super 的区别 ​

用法thissuper
访问成员变量this.变量名 优先找本类成员;本类没有,向上父类查找super.变量名 直接访问父类的成员变量,跳过子类同名变量(解决变量隐藏)
访问成员方法this.方法() 优先本类方法;找不到向上找父类super.方法() 直接调用父类版本方法,跳过子类重写方法
调用构造器this(...) 调用本类其他构造方法super(...) 调用父类构造方法
使用位置只能放在构造方法第一行只能放在构造方法第一行

补充整理:因为两者都要求「第一行」,所以 this(...) 和 super(...) 不能在同一个构造器里同时出现。 另外 this(...) 链式调用最终一定会走到某个含 super(...)(隐式或显式)的构造器, 保证父类初始化一定会执行且只执行一次。

补充两个原稿没写的用法:this 单独作为当前对象引用可以当参数传递、可以 return this 做链式调用;而 super 不能单独当对象用(return super; 非法),它只是访问父类成员的关键字。

方法重写 ​

方法重载 ≠ 方法重写:

  • 方法重载(overload):在同一个类中,方法名相同、参数列表不同
  • 方法重写(override):在子父类中出现相同的方法,因为父类功能不能满足子类需求了

@Override ​

标记当前方法是一个重写方法。注意事项:

  • 必须存在继承关系,有继承才能重写
  • 子类方法和父类方法方法名必须一样、参数列表一样(父类什么样,子类也得什么样)
  • 返回值类型:可以和父类一样,也可以是父类返回值的子类,但不能是父类的父类(协变返回)
  • 修饰符:子类的修饰符不能比父类更严格
  • 异常:子类抛出的异常不能比父类大

补充整理:@Override 不写也能重写,但强烈建议一律写上—— 编译器会帮你校验,一旦把方法名拼错、参数写错(一不小心变成重载),会直接报错, 否则这个 bug 能藏很久。

「修饰符不能更严格」指访问修饰符:父类 protected,子类不能改成 private 或包私有, 只能保持 protected 或放宽到 public。异常同理:父类声明抛 IOException, 子类不能抛 Exception,可以抛 FileNotFoundException 或不抛。

哪些方法不能被重写 ​

  1. 私有方法:私有方法没有被继承,所以不能重写
  2. 静态修饰的方法:属于类,不属于对象,静态方法是「隐藏」不是「重写」
  3. final 修饰的方法:final 表示最终功能,不能被重写

多态 ​

一个事物的多种表现形式。例:USB 接口都能存储数据,但 U 盘、移动硬盘各存各的。

三要素 ​

  1. 要有继承关系
  2. 子类重写方法
  3. 父类引用指向子类对象:Father father = new Son()

多态访问成员方法 ​

流程:编译看左边,执行看右边。 先看等号左边(父类)有没有这个方法,没有就编译报错;运行时看右边(子类),右边没有再沿继承关系向上找。

多态访问成员变量 ​

成员变量没有方法重写,算「变量覆盖」。

不满足方法重写,编译看左边,运行也看左边。 子类有没有这个变量没有任何影响,运行时取的是 father 那一侧的字段。

多态访问静态成员方法 ​

静态方法没有重写,不满足三要素的第二个条件。

编译看左边,运行看左边。

补充整理:把这三种情况放一起记最清楚,唯一「运行看右边」的只有普通实例方法:

访问目标编译运行
实例方法看左边看右边(重写生效)
成员变量看左边看左边
静态方法看左边看左边
private / final 方法看左边看左边

这也解释了为什么字段一般不对外暴露、要 private + getter:字段没有多态, 一旦父类和子类定义了同名字段,读到的值会取决于变量的声明类型而不是真实对象,非常反直觉。

多态应用场景 ​

补充整理:原稿只有标题,补两个最常见的落地场景——

  1. 父类 / 接口作为参数,一个方法服务所有子类: public void pay(Payment p) { p.pay(); },以后加支付宝、微信支付都不用改这个方法。
  2. 集合统一存放:List<Animal> list 里同时放 Dog、Cat,遍历时统一调 a.eat(),各自执行各自的实现。

框架里的依赖注入、JDBC 的 Connection/Statement、Servlet 的 HttpServletRequest 全是这个套路:面向接口编程,运行时才决定具体是哪一种实现。

多态好处和弊端 ​

好处

  • 提高代码的维护性(继承保证)
  • 提高代码的扩展性(多态保证)

弊端

  • 用不了子类的特有成员
  • 解决办法:向下强制类型转换

补充整理:向下转型前一定要先判断,否则抛 ClassCastException。 这也是 instanceof 存在的意义(下一节)。JDK 16 起可以用模式匹配一步到位:

java
if (animal instanceof Dog d) {
    d.watchHouse();      // d 已是 Dog 类型,不用再强转
}

顺带一提:如果代码里到处都是 instanceof + 强转,通常说明抽象没设计好, 更该考虑把那个「特有行为」提到父类或接口里。

instanceof 关键字 ​

用来判断对象的类型是什么,主要是为了避免类型转换异常。

补充整理:obj instanceof Type 在 obj 为 null 时返回 false(不抛异常), 且要求两者在继承树上有关系,毫无关系的两个类型直接编译报错。 注意它判断的是运行时真实对象的类型,不是变量声明类型。

final 关键字 ​

最终的、不可变的,可以修饰变量、方法、类。

  • final 方法可以继承,但不能重写
  • final 修饰的类是最终形态,不会有子类;给类加 final 目的就是不让有子类

补充整理:三种用法的语义其实不完全一样——

修饰对象效果
变量值 / 引用只能赋值一次(引用不可变,但引用指向的对象内部仍可改)
方法不能被重写,但仍可继承、可重载
类不能被继承(如 String、Math、所有包装类)

「final 对象内容不可变」是常见误解:final List<String> list 只是不能再 list = ..., 照样能 list.add(...)。要真正不可变得用 List.of(...) 或 Collections.unmodifiableList。 另外 final 字段必须在声明时、实例块中或每个构造器里完成赋值,编译器会检查。

抽象类 ​

当父类里的某个方法,每个子类都用得上但实现各不相同,就把它定义成抽象方法; 而抽象方法必须定义在抽象类里。

抽象方法没有方法体;也就是说,定义类的时候无法确定方法的功能,就定义成抽象方法。

注意:

  • 继承抽象类的类,要么是普通子类(必须重写父类中的所有抽象方法)

  • 要么是抽象子类:不会强制要求实现父类的抽象方法,可以手动写

  • 抽象类和抽象方法都用 abstract 修饰

  • 抽象类不能创建对象(不能 new)

  • 抽象类中既可以有抽象方法,也可以有普通方法

抽象类的成员特点 ​

  • 抽象类有构造器,但构造器不能用 abstract 修饰(用来给子类初始化父类成员)

抽象类的引用场景:定义父类、抽取子类共性的时候。

补充整理:抽象类 vs 接口怎么选,一句话—— 抽象类回答「是什么」(is-a,共享状态和代码),接口回答「能做什么」(can-do,只定契约)。

维度抽象类接口
继承单继承多实现 / 接口间多继承
成员变量普通字段、静态字段都行只能是 public static final 常量
构造器有没有
方法抽象方法 + 普通方法抽象方法 + default + static + private

若父类里既有要复用的字段 / 方法实现,又有要求子类各自实现的行为,用抽象类; 只是想给一批不相关的类加同一种能力(可比较、可序列化、可关闭),用接口。

接口的子类 ​

语法上可以理解成更严格的抽象类,更倾向表达「你能做什么」。 用 interface 关键字定义,对外提供规则。

  • 普通子类实现接口 → implements,强制重写接口中所有抽象方法
  • 抽象子类实现接口 → 不必重写方法,可以留给它的子类去实现

接口注意事项 ​

要点说明
接口不能 new只能由实现类创建对象
多实现子类和接口可以多实现
多继承接口和接口可以多继承

接口的成员特点 ​

  • 成员变量:只能是静态常量,默认修饰符 public static final(就算不写,编译器自动补上)
  • 成员方法:只能是抽象方法,必须由 public abstract 修饰
  • 构造方法:没有。接口中的变量全部是 public static final 常量,属于接口、在方法区,不存在实例变量,不需要构造器去初始化实例成员

补充整理:原稿这三条是 JDK 7 及以前的规则,JDK 8 之后接口已经能放方法实现了; 另外「实现抽象类 / 继承接口」那两句也写反了,正确说法是 类 implements 接口、接口 extends 接口、类 extends 类。按现行 JDK 整理如下:

成员现在的规则
字段一律 public static final,必须初始化,属于接口本身
抽象方法默认 public abstract,实现类必须全部重写(除非是抽象类)
default 方法JDK 8 起可有方法体,实现类可直接用也可重写
static 方法JDK 8 起可有方法体,只能通过 接口名.方法名() 调用,不被实现类继承
private 方法JDK 9 起可用,给接口内部的 default 方法抽公共逻辑
构造器没有(因为无需初始化实例字段)

还有两处措辞更新:常量现在随类元数据存放在元空间(Metaspace), 「方法区」是 JDK 8 之前的概念;public static final 这些修饰符可以省略但不能改 (写成 private 会编译报错)。

default 方法 ​

JDK 1.8 新特性。子类可以直接使用默认方法,也可以重写它。

补充整理:default 方法最大的价值是接口演进——给老接口加新方法时, 所有实现类不必跟着改,不会一升级就全网编译报错(Collection.stream() 就是这么加进来的)。

冲突规则要记:一个类实现的两个接口有同名 default 方法时,必须在实现类里重写该方法来消除歧义; 若父类的方法和接口的 default 方法重名,类优先(class wins),走父类的实现。 想显式调用某个接口的默认实现,写 接口名.super.方法名()。

接口的静态方法 ​

补充整理:原稿只有标题,补一条。接口静态方法用 接口名.方法名() 调用, 不能通过实现类或实现类对象调用(这点与类的静态方法不同),也不能被重写。 典型用途是把和接口强相关的工厂 / 工具方法收在接口旁边,比如 Comparator.comparing(...)、List.of(...)、Stream.of(...), 这样就不用再单独造一个 XxxUtils 类。

内部类 ​

成员内部类 ​

定义在成员变量的位置(类中、方法外,非 static)。必须先创建外部类对象:

java
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();

对象名 .new 内部类(),类要用必须是对象。

静态成员内部类 ​

同样在成员变量的位置,但用 static 修饰,new 放前面:

java
Outer.Inner inner = new Outer.Inner();

局部内部类 ​

把类定义到方法中(局部变量的位置),在方法里创建类对象,比较少见。

匿名内部类 ​

没有类名,定义类和创建对象一步完成,只能用一次的局部内部类,用一次就会被标记为待回收。 常用来操作接口。

原来要操作接口得分两步:

  1. 定义子类实现接口
  2. 创建子类对象

用匿名内部类可以合成一步:

java
new 接口名() {
    @Override
    public void 重写的方法() {
        // 方法体
    }
};

这一整个就相当于一个对象,即接口的子类对象。接口不能创建对象,要通过类实现接口。

注意:这个「匿名」指的是没有类名,对象是可以有名字的:

java
父类引用 = new 子类() { ... };   // 多态:先看左边再看右边

这个匿名内部类可以是接口、抽象类,也可以是普通类(相应地把要重写的方法写上)。

补充整理:原稿说「用一次就会被标记为待回收」不太准确—— 匿名内部类对象就是个普通对象,是否回收取决于还有没有引用指向它。 把它赋给一个变量、或注册成监听器,它就会一直活着;只有创建后立刻用完、没人引用才会被回收。

另外两个实战要点:

  • JDK 8 之前,匿名内部类 / 局部内部类访问的外部局部变量必须显式写 final; JDK 8 起只要「事实上不变」(effectively final)即可,编译器自动补。原因是变量值被 拷贝了一份存进内部类,若外部还能改就会出现两份不一致。
  • 只有一个抽象方法的接口(函数式接口),优先用 Lambda 代替匿名内部类, 代码量差好几倍——这正是 Day 4 的内容。