第 8 章 · 面向对象(下)

第 3 章欠的账现在还:为什么 "hello" == "hello" 有时 true、new String("hello") == "hello" 却 false?答案在 Object 类的 equals 方法,而要讲清 equals 就必须先讲继承和多态。本章是面向对象的主菜:继承、抽象类与接口、多态(整个 Java 世界的运转核心)、异常体系、包与访问控制。学完它,你才算真的在写 Java。

8.1 继承:is-a 与代码复用

class Animal {
    protected String name;
    void eat() { System.out.println(name + " eating"); }
}

class Dog extends Animal {
    void bark() { System.out.println("wang!"); }
    @Override
    void eat() { super.eat(); System.out.println("(tail wagging)"); }   // 重写
}

Dog d = new Dog();
d.eat();       // 先走 Dog 的 eat:调 super.eat() 再补一句

Dog 拿到 Animal 的全部字段和方法(is-a 关系),又用**重写(@Override)**改造了 eat。三条纪律:@Override 注解必须写(拼错方法名时编译器立刻报错,没有它你重写了个寂寞);super.eat() 调父类版本(重写后想保留父类逻辑的标准姿势);子类构造必须先调父类构造(编译器自动补 super(),父类没有无参构造时你得显式写)。Java 单继承(只有一个直接父类),所有类的根是 Object,单继承 + 多接口的组合是它对 C++ 菱形继承问题的回应。

final 顺手清掉:类(不可被继承,如 String)、方法(不可被重写)、变量(不可再赋值;注意引用 final 只是门牌不变,房子里的家具照样能换,final int[] a 里的 a[0] 照改)。

8.2 抽象类与接口:两种「不完整」

抽象类:有抽象方法(只有签名没有实现)的类,不能实例化,子类要么补全要么继续抽象:

abstract class Shape {
    protected String name;
    abstract double area();                      // 强制子类给答案
    void describe() { System.out.println(name + " 面积 " + area()); }   // 模板方法
}

接口:纯规范,一撮方法的合同,JDK 8 后可以有 default 方法:

interface Comparable2 {
    int compareTo(Object o);
    default boolean biggerThan(Object o) { return compareTo(o) > 0; }   // 白送的方法
}

class Circle extends Shape implements Comparable2 { ... }   // 单继承 + 多接口

选型口诀:抽象类是「是什么」(共享的代码和状态,血缘),接口是「能做什么」(纯能力,合同)。Bird is-a Animal extends 它;Bird can-do Flyable implements 它。一个类只能 extends 一个父类,但能 implements 一把接口,Java 把「代码复用」放在继承、「行为合同」放在接口,各司其职。Comparable/Runnable 这些标准库接口,你后面章节天天用。

8.3 多态:同一句话,不同的戏

面向对象的灵魂,一例胜千言:

Shape[] shapes = { new Circle(2), new Rect(3, 4) };
for (Shape s : shapes)
    System.out.println(s.describe());   // 各自调各自的 area()!调用方完全不知道具体类

s.describe() 这一行代码,对 Circle 和 Rect 走了不同的 area 实现,这叫动态绑定:调用哪个方法由对象的实际类型在运行时决定,而不是变量声明类型。机器级的实现是方法表(vtable,计组课第 11 章函数指针表的 oo 版):每个类一张表,重写过的槽位换成子类版本,调用 = 查表 + 间接调用(第 11 章讲过的「间接调用税」)。多态的工程价值:新增三角形只需写 Tri extends Shape,describe 那段循环一个字不改(对扩展开放,对修改关闭)。

配套两件套:向上转型自动(Shape s = new Circle(),安全),向下转型要 instanceof 先问:

if (s instanceof Circle c) {     // JDK 16+ 的模式匹配写法,判断+转型一行
    c.radius = 5;
}

频繁 instanceof 通常是设计问题的信号(该用多态的地方没用上),偶发的类型收窄才合理。

8.4 Object 三兄弟:equals、hashCode、toString

所有类都继承 Object,三个方法几乎总该重写:

class Point {
    int x, y;
    @Override public boolean equals(Object o) {
        if (this == o) return true;                    // 自反速查
        if (!(o instanceof Point p)) return false;     // 类型不对直接 false
        return x == p.x && y == p.y;                   // 逐字段比内容
    }
    @Override public int hashCode() { return 31 * x + y; }   // 与 equals 一致
    @Override public String toString() { return "(" + x + "," + y + ")"; }
}

这就是第 3 章 == 之谜的谜底:String 重写了 equals(比内容),而 == 永远比引用;"hello" 是字面量常量(池化复用,同一对象,== 碰巧 true),new String 是新对象(必 false)。比较内容永远 equals 的纪律,根源在此。hashCode 的规则也钉一下:equals 相等则 hashCode 必须相等(反之不必),这条契约是第 9 章 HashMap 能正常工作的前提,重写了 equals 忘写 hashCode,HashMap 里存的对象会「找不到」。

8.5 异常:出事时的对象

Java 把运行期错误做成对象抛出,沿调用栈向上传播,中途可接住:

try {
    int r = 10 / 0;                                   // 抛 ArithmeticException
} catch (ArithmeticException e) {
    System.out.println("除零:" + e.getMessage());     // 接住,程序不死
} finally {
    System.out.println("无论如何都执行");              // 收尾(关资源)
}

异常分两族:受检异常(IOException 这类,编译器强制你 catch 或 throws 声明,「要么处理要么声明甩锅」)、非受检异常(RuntimeException 家族:NPE、数组越界、除零,编译器不管,代表「编程错误」)。设计哲学:受检管「外部世界的不确定」(文件不存在、网络断),非受检管「程序自身的 bug」(空指针不该 catch,该修代码)。自定义异常继承 Exception(受检)或 RuntimeException(非受检),带个 message 构造就够用。和 C 的返回码 + errno(指针课第 10 章)相比,异常把「正常逻辑」和「错误处理」分开了,代价是控制流变得不可见(一行代码可能突然跳到很远),try 里只包必要的范围是纪律。

8.6 包与访问控制:四位门卫

包(package)= 目录 = 命名空间,import java.util.Scanner 的 java.util 就是包名。访问修饰符四级,从窄到宽:private(本类)、默认(同包)、protected(同包加子类)、private(本类)< 默认(同包)< protected(同包 + 子类)< public(全世界)。工程惯例:字段 private,方法按需开,跨包要用的 public,同包协作的默认。第 1 章「一个文件一个 public 类」的规矩现在能解释了:public 类要被外部 import,同文件的非 public 类只服务同包,门卫等级决定的。

常见错误

  • 重写忘 @Override。参数写错(如 equals 的参数写成 Point 而非 Object)就成了重载,多态悄悄失效;@Override 让编译器替你把关。
  • equals 与 hashCode 只改一个。HashMap 里「存进去找不到」的经典事故,两个必须一致。
  • catch (Exception e) 一把梭。吞掉所有异常等于把错误埋进日志深处;catch 具体类型,兜底的才放 Exception,且必须记日志。
  • finally 里 return。会吞掉 try/catch 的返回值甚至异常,别写。

练习

  1. 实现:Shape 抽象类 + Circle/Rect/Triangle 三个子类,写一个「面积求和」方法(参数 Shape[]),再加一个新子类验证「不改求和代码」,亲历多态的扩展性。
  2. 动手:验证第 3 章的 == 之谜:String a = "he" + "llo"; String b = "hello"; 打印 a == b(编译期常量折叠,true!);换成 String c = new String("hello") 再比。用常量池解释三种结果。
  3. 实现:自定义异常 InsufficientBalanceException,在 BankAccount(第 7 章的)withdraw 余额不足时抛出,catch 它并打印信息。
  4. 思考:受检异常是 Java 特有的设计,Go 用返回值、Python 全用运行时异常。结合 6.2 的传参模型和 C 课第 10 章「返回码易被忽略」,评价三种错误处理哲学各自防住了什么、放过了什么。