Skip to content

第一部分-面向对象


继承

在写类的时候,每个类通常只描述一种具体事物。在实际开发中,不同类之间往往会出现一些重复的内容。

比如:

动物:都有 年龄、名字、吃东西
狼:会狩猎
狗:会看家

如果每个类都把这些内容重新写一遍,代码就会显得很重复。一旦后面要改公共逻辑(比如吃东西的方法),也得一个个类去改,维护起来很麻烦。

这时候就可以考虑继承。

继承本质上是让类和类之间建立父子关系,把多个类中的共性内容提取到父类中,子类直接使用。
这样做最直接的好处,就是提高代码复用性。

当多个类中存在相同内容,并且这些类之间又满足 is-a(是一种) 关系时,就可以考虑使用继承来优化代码结构。

例如刚刚的例子:

  • 狼 是一种 动物
  • 狗 是一种 动物

这就可以把“动物”写成父类,再让“狼”和“狗”作为它的子类。
这样一来,动物中那些通用的成员,子类就可以直接使用,自己只需要保留特有的部分。

因此,继承主要解决两个问题。

第一,是代码复用。
把多个类中重复的成员放到父类里,子类不用反复写。

第二,是让类之间的层次更清晰。
看到“狼继承动物”“狗继承动物”,就能很直观地看出它们之间的关系。

不过,继承也会带来一定代价。
继承虽然提高了代码复用性,但也会提高类之间的耦合度。父类变化时,子类往往也会受到影响。

extends 实现继承

在 Java 里,继承使用 extends 关键字表示,格式如下:

java
public class 子类名 extends 父类名 {
}

就拿刚刚的动物类来举例,想要实现继承,先提取多个类中共同的成员放到父类里:

java
class Animal {
    String name;
    int age;

    public void eat() {
        System.out.println("吃东西");
    }

    public void sleep() {
        System.out.println("睡觉");
    }
}

接着让具体的类去继承这个父类:

java
class Wolf extends Animal {
    public void hunt() {
        System.out.println("狩猎");
    }
}

class Dog extends Animal {
    public void lookHome() {
        System.out.println("看家");
    }
}
  • Animal 是父类,也叫基类、超类;
  • WolfDog 就是子类,也叫派生类。

Wolf extends Animal 这样写之后,就表示 WolfAnimal 之间建立了继承关系。

建立继承关系后,子类就可以直接使用父类中非私有的成员。

java
public class Test {
    public static void main(String[] args) {
        Wolf w = new Wolf();
        w.name = "灰狼";
        w.age = 3;

        w.eat();
        w.sleep();
        w.hunt();
    }
}

WolfDog 虽然没有重新声明 nameageeat()sleep(),但仍然可以直接使用它们。

父类负责保存共性,子类只需要补充自己的特有内容。
这就是继承最直观的好处:把重复代码收上去,让类的结构更清晰,写起来也更省。

super 访问父类成员

子类在继承父类之后,可以直接使用父类中的成员。

创建子类对象时,这个对象本身就包含了从父类继承来的那部分成员。子类对象理解成一整块完整的对象空间,其中包含两部分:

  • 从父类继承下来的成员
  • 子类自己新增的成员

比如:

java
class Animal {
    int age = 5;
}

class Wolf extends Animal {
    int speed = 10;
}

当你创建:

java
Wolf w = new Wolf();

这个 w 对象里,既有 age,也有 speed

super 这个关键字的作用就是,在子类中访问父类那部分成员。所以 super 更像是一种“访问父类成员的方式”,而不是一个独立存在的对象。

例如当变量同名的时候:

java
class Animal {
    String name = "动物";
}

class Wolf extends Animal {
    String name = "灰狼";

    public void showName() {
        String name = "局部名字";

        System.out.println(name);

        System.out.println(this.name);
        System.out.println(super.name);
    }
}

在没有用关键字指明的情况下,成员的查找会遵循就近原则:

  1. 先找当前方法中的局部变量,也就是showName() 方法中的局部变量 name
  2. 再找本类中的成员变量,是 Wolf 类中的成员变量 name
  3. 最后再找父类中的成员变量,是 Animal 类中的成员变量 name

为了把访问目标写得更明确,Java 提供了这两个关键字:

  • this:表示当前对象,用来访问本类成员
  • super:表示从父类继承下来的那部分,用来访问父类成员

所以,在上面那段代码中,如果调用 showName(),输出结果就是:

txt
局部名字
灰狼
动物

方法覆写

子类在继承父类之后,不仅可以继承父类的成员变量,也可以继承父类的方法。
但在实际开发中,父类提供的方法往往只是一个通用版本,子类有时需要根据自己的特点,写出更符合自身情况的实现。

例如,动物都会吃东西,所以父类中可以先定义一个 eat() 方法。
但具体到不同的动物,吃东西的方式又不一样:

  • 狼吃生肉
  • 狗吃狗粮

如果子类直接使用父类中的 eat(),显然就不够具体。
这时候,就需要子类对父类的方法进行重写。

所谓方法重写,就是:子类中定义一个与父类同名同参数同返回值的方法,对父类原有的方法实现进行重新定义。

先看父类中的方法:

java
class Animal {
    public void eat() {
        System.out.println("吃东西");
    }
}

如果 WolfDog 都直接继承这个方法,那么它们调用 eat() 时,输出的都会是“吃东西”。
但这样无法体现不同动物的特点,所以可以在子类中重写这个方法:

java
class Wolf extends Animal {
    @Override
    public void eat() {
        System.out.println("吃了生肉");
    }
}

class Dog extends Animal {
    @Override
    public void eat() {
        System.out.println("吃了狗粮");
    }
}

这样一来,虽然 Wolf 和 Dog 调用的都是 eat(),但实际执行的内容已经变成了各自重写后的版本。

java
public class Test {
    public static void main(String[] args) {
        Wolf w = new Wolf();
        Dog d = new Dog();

        w.eat();
        d.eat();
    }
}

输出结果为:

txt
吃了生肉
吃了狗粮

父类定义的是一种通用行为,而子类在继承之后,可以根据自己的实际情况对这个行为进行重新实现。

@Override 注解

在刚刚的重写过程中,方法上都被添加了 @Override 注解。

注解可以先简单理解成一种标记,用来给编译器或程序说明一些额外信息。这里的 @Override 注解就是在向编译器说明:当前方法是对父类方法的重写。

因此,@Override 不是必须写的,不写代码也能运行。
但一般都建议写上,因为它可以让编译器帮忙检查当前方法是不是真的在重写父类方法。

注意事项

在重写方法时,除了满足以下基本条件的前提下:

  • 方法名相同
  • 参数列表相同
  • 返回值类型相同

还有几个点需要特别注意。

  1. 父类中的私有方法不能被重写

如果父类中的方法是 private 修饰的,那么这个方法对子类来说是不可见的,自然也就不能被重写。

  1. 子类重写方法时,访问权限不能更低

子类在重写父类方法时,访问权限必须大于等于父类方法的访问权限,不能缩小。

例如,父类方法是 public,那么子类重写时也必须是 public
如果子类把它改成 protected 或者不写访问修饰符,权限就变小了,这种写法是不允许的。

原因也不难理解。
父类原本把这个方法对外开放到了某个程度,子类重写后,不能反而把它缩得更严,不然就破坏了原本的使用规则。

protected 访问修饰符

前面学习权限修饰符时,privatepublic 都比较好理解:

  • private:只能在本类中访问
  • public:哪里都可以访问

protected 会稍微特殊一点。
它既不像 private 那样完全封闭,也不像 public 那样完全开放,而是处在中间的一种权限范围。

protected 修饰的成员,既可以在本类中访问,也可以在子类中访问。

这也是 protected 最常见的使用场景:
某个成员不希望随便暴露给所有外部类,但希望子类能够继承并使用。

还是沿用前面的动物例子。

假设 Animal 类中有名字和年龄这两个属性。
这些属性对于动物本身来说很重要,对于子类 WolfDog 来说也同样要用到。

但如果直接用 public 修饰,又显得开放得太大;如果用 private 修饰,子类又不能直接访问。

这时候,protected 就比较合适了。

java
class Animal {
    protected String name;
    protected int age;

    protected void eat() {
        System.out.println("吃东西");
    }
}

这里的 nameageeat() 都使用了 protected 修饰。这样一来,子类在继承 Animal 之后,就可以直接使用这些成员。

java
class Wolf extends Animal {
    public void hunt() {
        System.out.println(name + "正在狩猎");
        eat();
    }
}

protected 的核心作用,是为了更好地配合继承。

当父类中的某些成员需要让子类直接使用,但又不适合完全对外公开时,就可以考虑使用 protected

构造方法与继承

Java 中,类与类之间只支持单继承,不支持多继承,但支持多层继承。

也就是说,一个子类只能有一个直接父类,不能同时继承多个父类。
这样做的目的,是为了避免多个父类中出现同名成员时产生歧义,也让类之间的关系更清晰。

java
class Animal {
}

class Wolf extends Animal {
}

Wolf 只能继承一个父类 Animal,不能再同时继承其他类。不过,Animal 还可以继续继承别的类,这就形成了多层继承。

还有几个细节需要理清的:

  1. 父类中的构造方法,能不能也被子类继承下来直接使用?

答案是不可以。

因为构造方法有一个特点:方法名必须和类名完全一致
而子类和父类本来就是两个不同的类,类名不同,所以父类的构造方法不可能直接继承给子类。

也就是说,子类可以继承父类中的普通成员,但构造方法不能被继承。

  1. 子类在创建对象时,父类中的成员要不要先完成初始化?

答案是要。

因为子类对象中,本身就包含从父类继承下来的那一部分成员。
例如 Wolf 继承了 Animal,那么 Wolf 对象中其实也包含父类的 nameage 这些成员。

如果父类中的成员都还没有初始化好,子类自然也没法正常使用它们。

而要初始化,就得使用构造方法。但父类构造方法不能继承,那父类是怎么初始化的?

  1. 父类构造方法不能继承,那父类是怎么初始化的

答案是:
子类在构造方法中,会先调用父类的构造方法。

Java 规定,子类构造方法的第一行,默认都会先调用父类的无参构造方法:

super();

子类对象在创建时,必须先把父类那一部分成员准备好。而完成初始化最直接的方式,就是调用父类构造方法。

所以,子类所有构造方法的第一行,默认都会先走父类的空参构造。这是 Java 在继承中的固定规则。

很多时候这句代码不会手动写出来,但编译器会自动补上。例如:

java
class Animal {
    public Animal() {
        System.out.println("Animal 空参构造执行了");
    }
}

class Wolf extends Animal {
    public Wolf() {
        System.out.println("Wolf 空参构造执行了");
    }
}

虽然 Wolf() 里没有写 super(),但它的第一行其实默认就有一句 super()
所以创建对象时:

java
public class Test {
    public static void main(String[] args) {
        Wolf w = new Wolf();
    }
}

输出结果为:

txt
Animal 空参构造执行了
Wolf 空参构造执行了

super(); 的实际意义

在实际开发中,super() 更重要的意义是:

当子类对象中包含父类定义的成员时,可以把这部分成员交给父类构造方法来初始化。

还是沿用前面的动物例子。假设父类 Animal 中定义了名字和年龄,子类 Wolf 中定义了自己特有的技能:

java
class Animal {
    String name;
    int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
java
class Wolf extends Animal {
    String skill;

    public Wolf(String name, int age, String skill) {
        super(name, age);
        this.skill = skill;
    }
}

在创建 Wolf 对象时,更合理的做法就是:

  • nameage 交给父类构造方法初始化
  • skill 留给子类自己初始化

super(name, age) 的实际意义是在主动调用父类构造方法,让父类完成属于自己的那部分初始化工作。

这样做有两个直接好处。

第一,职责更清楚。
父类负责初始化父类中的成员,子类负责初始化子类自己的成员。

第二,代码更合理。
如果父类中的成员都让子类手动一个个赋值,不但麻烦,也破坏了父类本身的封装性。

Object 类与继承

Object 类是所有类的父类。
任何一个类,不管有没有手动写 extends,最终都会直接或者间接地继承 Object 类。

例如:

java
class Animal {
}

class Wolf extends Animal {
}

这里 Wolf 直接继承的是 Animal
Animal 本身最终还是继承自 Object,所以 Wolf 也间接继承了 Object

这也就意味着:
Object 类中的方法,所有类都可以直接使用。

Object 众多方法中,最常见的一个,就是 toString() 方法。toString() 方法的作用,是返回对象的字符串表示形式。

当我们直接输出一个对象时,实际上底层会自动调用这个对象的 toString() 方法。
下面两种写法本质上是一个意思:

Java
System.out.println(w);
System.out.println(w.toString());

正因为如此,在 IDEA 里手动写 toString() 时,会发现它变灰,因为直接输出对象时,系统本身就会自动帮我们调用这个方法。

toString() 方法默认情况下的输出结果大致长这样:

java
Wolf@1b6d3586

这里的 Wolf 内容表示类名。
如果类写在包中,还可能看到更完整的形式:

java
com.demo.Wolf@1b6d3586

以上,如果带包名,就是这个类的全类名

全类名可以简单理解成:

包名 + 类名

例如:

  • Wolf 是类名
  • com.demo.Wolf 是全类名

后面的那一串内容,是对象相关的一段十六进制哈希值信息。

不过在实际开发中,我们更关心的,往往不是“它属于哪个类”,而是“这个对象里面到底有什么数据”。

还是拿狼这个例子来说:

java
class Wolf {
    String name;
    int age;
}
java
public class Test {
    public static void main(String[] args) {
        Wolf w = new Wolf();
        w.name = "灰狼";
        w.age = 3;

        System.out.println(w);
    }
}

如果直接输出对象,看到的大概率只是:

java
Wolf@1b6d3586

这个结果其实没法直观看出对象中的 nameage。所以在开发中,直接输出对象而得到默认结果,通常意义不大。

这时候,就很适合对 toString() 方法进行重写。

结合前面的内容,子类如果觉得父类中的方法实现不适合自己,就可以进行覆写。
这里也是一样。

例如:

java
class Wolf {
    String name;
    int age;

    @Override
    public String toString() {
        return "Wolf{name='" + name + "', age=" + age + "}";
    }
}

这时候再输出对象:

java
public class Test {
    public static void main(String[] args) {
        Wolf w = new Wolf();
        w.name = "灰狼";
        w.age = 3;

        System.out.println(w);
    }
}

输出结果就会变成:

java
Wolf{name='灰狼', age=3}

这样一来,对象中的关键信息就能直接看出来了。

final 关键字

final 表示“最终的、不可再改变的”。
它可以用来修饰类、方法、变量,而且修饰的对象不同,作用也不同。

final 可以修饰:

  • 方法
  • 变量

接下来分别来看它们的特点。

修饰类

当一个类被 final 修饰时,这个类就不能再被继承了:

java
final class Animal {
    // 这个类不能被继承
}

这在设计安全性要求高的类时非常有用,比如 Java 标准库中的 StringMath 类都是 final 的,防止被恶意继承和修改。

修饰方法

当一个方法被 final 修饰时,这个方法就不能被子类重写:

java
public class Parent {
    public final void eat() {
				System.out.println("吃东西");
        // 这个方法不能被子类重写
    }
}

这种设计适用于那些算法固定、不希望被子类修改的核心方法,既保证了安全性,也便于编译器优化。

修饰变量

当变量被 final 修饰后,这个变量就只能赋值一次,赋值之后不能再改。

java
final int a = 10;
a = 20;   // 报错

不过这里还要继续分清楚:
final 修饰基本类型变量和引用类型变量时,含义并不完全一样。

基本类型变量

如果 final 修饰的是基本类型变量,那么不能改变的是变量中保存的数据值

java
final int age = 3;
age = 4;   // 报错

final 修饰引用类型变量

如果 final 修饰的是引用类型变量,那么不能改变的是变量中保存的地址值

java
final Wolf w = new Wolf();
w = new Wolf();   // 报错

这里 w 里保存的不是对象本身,而是对象的地址。final 修饰之后,w 不能再指向新的对象了。

但是要注意:
地址值不能变,不代表对象内容不能变。

java
class Wolf {
    String name;
}
java
final Wolf w = new Wolf();
w.name = "灰狼";   // 可以
w.name = "白狼";   // 也可以

常量

如果一个变量使用 static final 一起修饰,那么它通常就表示一个常量。

java
public static final double PI = 3.14;

这种变量一般用大写字母命名,多个单词之间用下划线分开。

抽象类

在继承中提到过,可以把多个子类中的共性内容抽取到父类里。比如狼和狗都属于动物,所以可以把一些共同的属性和方法放到 Animal 类中。

在实际抽取时,并不是所有方法都适合直接写出具体实现。

例如,动物都有“吃东西”这个行为,这一点没有问题。可问题在于,不同动物吃的东西并不一样:

狼吃肉
狗吃狗粮

“吃”这个方法确实属于所有动物的共性行为,应该放到父类中;但父类里又没办法直接把这个方法写死, 每个具体的子类都有自己独特的方法。

抽象类的意义

这时候,我们只想定义一个"框架"或"骨架",而不关心具体实现细节。抽象类就是为这种需求而生的。

抽象类使用 abstract 关键字修饰,可以理解成一种特殊的父类。但它比普通父类更特殊的一点在于:

抽象类中可以编写抽象方法。抽象方法,就是只有方法声明,没有方法体的方法。

例如,动物都会吃东西,但父类中没办法明确写出到底怎么吃,就可以把这个方法定义为抽象方法:

java
public abstract void eat();

这里的 eat() 只有方法声明,没有具体的方法体。它想表达的是,这个方法是所有子类都应该有的,只是具体实现由各个子类自己决定。

这时候就可以把 Animal 定义为抽象类:

java
abstract class Animal {
    String name;
    int age;

    public abstract void eat();
}

然后让子类去继承它,并补全具体实现:

Java
class Wolf extends Animal {
    @Override
    public void eat() {
        System.out.println("狼吃肉");
    }
}

class Dog extends Animal {
    @Override
    public void eat() {
        System.out.println("狗吃狗粮");
    }
}

这样一来,父类负责规定“所有动物都应该有 eat() 这个行为”,而子类负责给出各自真正的实现。

一个抽象类可以包含:

  • 抽象方法(没有实现的方法)
  • 普通方法(有具体实现的方法)
  • 成员变量、构造器等普通类的所有元素
java
// 交通工具抽象类
public abstract class Vehicle {
    private String brand;     // 品牌
    private String color;     // 颜色
    protected int speed;      // 速度

    // 普通方法
    public String getInfo() {
        return brand + " " + color + " 交通工具";
    }

    // 抽象方法 - 没有方法体,必须由子类实现
    public abstract void move();
}

抽象类的特性

在此基础上,还需要进一步认识抽象类的几个特点。

  1. 抽象类不能创建对象:

抽象类不能直接使用 new 来实例化抽象类。

Java
abstract class Animal {
   public abstract void eat();
}

抽象类中可能包含抽象方法,而抽象方法只有方法声明,没有具体实现。

Java
Animal a = new Animal();   // 报错

如果一个类中还存在抽象方法,就说明这个类本身还不完整。
而 Java 是一种比较严格的语言,不允许直接创建不完整的对象,因此抽象类不能实例化。

因此,抽象类更适合作为父类存在,用来让子类继承,而不是自己直接拿来创建对象。

  1. 抽象类中可以有构造方法

虽然抽象类不能直接创建对象,但它仍然可以有构造方法。

前面提到,子类在创建对象时,会先通过 super(...) 调用父类构造方法,先完成父类部分的初始化。

抽象类虽然不能自己 new 对象,但它依然是父类。
只要它作为父类被继承,那么子类在创建对象时,照样会先调用抽象父类中的构造方法。

例如:

Java
abstract class Animal {
    String name;
    int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public abstract void eat();
}

Animal 虽然是抽象类,不能直接创建对象,但它的构造方法仍然可以被子类通过 super(name, age) 调用。

java
class Wolf extends Animal {
    public Wolf(String name, int age) {
        super(name, age);
    }

    @Override
    public void eat() {
        System.out.println("狼吃肉");
    }
}

总之,抽象类中的构造方法,主要是给子类初始化继承下来的那部分成员用的。

  1. 抽象类中可以存在普通方法

抽象类中不只是能写抽象方法,也可以写普通方法。

抽象方法用来规定子类必须具备哪些行为。
普通方法则可以把已经明确的共性实现直接写在父类里,供子类继承后直接使用。

java
abstract class Animal {
    String name;
    int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void sleep() {
        System.out.println("睡觉");
    }

    public abstract void eat();
}

这里的 sleep() 就是普通方法。
因为“睡觉”这个行为在父类中已经能够给出一个通用实现,所以可以直接写出来,让子类继承后直接使用。

eat() 则不同。
虽然所有动物都要吃东西,但具体吃什么并不明确,所以只能定义成抽象方法,交给子类自己实现。

抽象类既可以保留已经明确的共性,也可以声明暂时无法确定实现的方法。

  1. 抽象类的子类,要么全部重写,要么继续抽象
java
abstract class Animal {
    public abstract void eat();
    public abstract void drink();
}

抽象类中的抽象方法,最终一定要有具体实现。因此,抽象类的子类在继承之后,只有两种选择:

  • 要么重写抽象类中的所有抽象方法
  • 要么自己也定义成抽象类,等着后面谁继承接手

如果子类想成为一个普通类,就必须把所有抽象方法都重写完整:

java
class Dog extends Animal {
    @Override
    public void eat() {
        System.out.println("狗吃狗粮");
    }

    @Override
    public void drink() {
        System.out.println("狗喝水");
    }
}

但如果只重写一部分,而不是全部重写,就不行。这时候这个子类本身也必须继续定义为抽象类:

Java
abstract class Wolf extends Animal {
    @Override
    public void eat() {
        System.out.println("狼吃肉");
    }
}

因为 drink() 还没有实现,抽象方法仍然存在。
而抽象方法必须存在于抽象类中,所以这个类就不能作为普通类直接实例化。

总之,抽象类的子类,不能只重写一部分就当普通类来用。只要还有抽象方法没实现,这个子类就必须继续是抽象类。

模板方法设计模式

前面学习抽象类时提到过,父类可以先写出共性的内容,把暂时无法确定的部分定义为抽象方法,交给子类去实现。
模板方法设计模式,就是把这种思想进一步固定下来的一种写法。

先说什么是设计模式。

所谓设计模式,可以先简单理解成:
前人针对一些常见开发问题,总结出来的一套比较成熟、比较常用的解决思路。

这些思路因为反复被证明有效,所以被很多程序员长期使用,慢慢就形成了比较固定的写法。
这种被反复总结和使用的经验,就叫设计模式。

就像作文模板一样,开头和结尾往往比较固定,真正会变化的,通常是中间根据题目展开的部分。

放到代码里也是一样。

有些功能在执行时,整体流程是固定的,但流程中的某一步或者某几步,具体实现又可能不同。
这时候,就可以把整个流程先定义在父类中,作为一个“模板”;而模板中那些暂时不能确定的部分,再定义成抽象方法,让子类自己补全。

假设现在要描述“动物进食”这件事。
从整体上看,进食流程是有一定共性的,比如:

  1. 先准备食物
  2. 再吃东西
  3. 最后结束进食

这个流程本身是固定的。但真正变化的地方在于,不同动物吃的内容不一样:

  • 狼吃肉
  • 狗吃狗粮

这时候,就可以把“整个进食流程”写在父类中,再把其中变化的部分交给子类去完成。

Java
abstract class Animal {
    // 模板方法:定义整个流程
    public final void haveMeal() {
        prepareFood();
        eat();
        finishMeal();
    }

    // 变化部分:交给子类实现
    public abstract void prepareFood();
    public abstract void eat();

    // 固定部分:父类直接给出实现
    public void finishMeal() {
        System.out.println("进食结束");
    }
}

这里的 haveMeal() 就是模板方法,它把整个流程先固定了下来。
其中,prepareFood()eat() 没办法在父类中统一确定,所以定义成抽象方法,交给子类自己实现。

接着,让不同的子类补上自己需要的那部分逻辑就可以了。

Java
class Wolf extends Animal {
    @Override
    public void prepareFood() {
        System.out.println("准备肉");
    }

    @Override
    public void eat() {
        System.out.println("狼吃肉");
    }
}

这样一来,虽然狼和狗吃的内容不同,但它们整体执行的流程仍然是统一的。输出结果分别会按照父类中规定好的流程执行,只是中间的具体内容不同。

模板方法设计模式里,父类已经把整体执行顺序规定好了,子类只需要去补充变化的细节,而不应该把整个流程本身改掉。

因此,模板方法一般都会使用 final 修饰。

所以,模板方法设计模式里经常会看到这种搭配:

  • 用抽象类定义模板
  • 用抽象方法留出变化部分
  • 用 final 固定整体流程

接口

抽象类适合用来描述多个子类的共性。如果父类中已经有一些明确的成员变量、普通方法,那么用抽象类就很合适。

但如果一个抽象类里,几乎全部都是抽象方法,本质上只是在规定“子类必须具备哪些行为”,而没有太多具体实现,这时候它更像是在定义规则

对于这种场景,Java 专门提供了另一种结构:接口

接口使用 interface 关键字定义,格式如下:

java
public interface 接口名 {
}

例如:

java
public interface Swim {
    void swim();
}

这里的 Swim 就是一个接口。
它表示一种“会游泳”的规则,至于具体怎么游,由实现它的类自己决定。

接口和抽象类一样,也不能直接创建对象。因为接口本身只是规则,没有具体实现,所以不能直接实例化。

类和类之间是继承关系,用 extends 表示。而类和接口之间不是继承关系,而是实现关系,使用 implements 关键字表示。

格式如下:

java
public class 类名 implements 接口名 {
}

例如:

java
public interface Swim {
    void swim();
}

public class Dog implements Swim {
    @Override
    public void swim() {
        System.out.println("狗在游泳");
    }
}

这里 DogSwim 之间不是“狗是一种 Swim”,而是“狗实现了 Swim 这条规则”。

接口实现类

同抽象类的继承,一个类 implements (实现)接口之后,要么重写接口中的所有抽象方法,要么自己也变成抽象类。

例如:

java
public interface Swim {
    void swim();
    void dive();
}

如果一个类想作为普通类使用,就必须完整,也就是得把方法全部实现出来:

java
public class Dog implements Swim {
    @Override
    public void swim() {
        System.out.println("狗在游泳");
    }

    @Override
    public void dive() {
        System.out.println("狗在下潜");
    }
}

如果只实现了一部分,那么这个类就不能是普通类,而必须定义成抽象类:

java
public abstract class Animal implements Swim {
    @Override
    public void swim() {
        System.out.println("动物会游泳");
    }
}

因为 dive() 还没有实现,所以这个类仍然不完整,想继续就必须继续保持抽象。

总之,接口和类都是 Java 中定义类型的一种方式,是一种更纯粹的规则定义。

接口的成员特点

接口本质上是一种更纯粹的规则定义。正因为它主要负责“规定要做什么”,所以它内部的成员也有自己的特点。

  1. 成员变量只能是常量

接口中的成员变量,本质上只能是常量。接口中的成员变量默认会自动加上:

java
public static final

例如:

java
public interface Swim {
    int MAX_SPEED = 20;
}

上面的 MAX_SPEED 实际上等价于:

java
public static final int MAX_SPEED = 20;

接口中的成员变量必须有值,而且值不能再改。

  1. 成员方法在基础阶段可以先看成抽象方法

接口中的成员方法理解成抽象方法。因为接口中的成员方法默认会自动加上:

java
public abstract

例如:

java
public interface Swim {
    void swim();
}

上面的 swim() 实际上等价于:

java
public abstract void swim();

不过在 JDK8 和 JDK9 之后又增加了一些新特性,下节细谈。

  1. 接口中没有构造方法

接口不能创建对象,本身也不负责对象初始化,所以接口中没有构造方法

这一点和抽象类不同。
抽象类虽然也不能直接实例化,但它仍然是类,所以可以有构造方法,供子类通过 super(...) 调用。
而接口连“类”都不是,它只是规则定义,因此不存在构造方法。

类和接口之间的关系

类和接口之间一共会出现三种常见关系。

  1. 类能继承类

类和类之间是继承关系,使用 extends 表示。类和类之间只能单继承,但可以多层继承。

java
class Animal {
}

class Dog extends Animal {
}
  1. 类能实现接口

类和接口之间是实现关系,使用 implements 表示。类实现接口时:

  • 可以实现一个接口
  • 也可以实现多个接口
  • 还可以在继承一个类的同时实现多个接口

例如:

java
interface Swim {
    void swim();
}

interface Hunt {
    void hunt();
}

class Animal {
}

class Wolf extends Animal implements Swim, Hunt {
    @Override
    public void swim() {
        System.out.println("狼会游泳");
    }

    @Override
    public void hunt() {
        System.out.println("狼会狩猎");
    }
}

这也是接口很重要的一点:它在一定程度上弥补了类只能单继承的限制。

  1. 接口和接口可以多继承

接口和接口之间也是继承关系,同样使用 extends 表示。不过接口和接口之间,不仅可以单继承,也可以多继承。

例如:

java
interface Swim {
    void swim();
}

interface Hunt {
    void hunt();
}

interface WolfAbility extends Swim, Hunt {
}

这里 WolfAbility 同时继承了 SwimHunt 两个接口。

总结来看,接口和类的区别主要集中于:

成员上的区别

先看语法上最直接的区别。

  1. 成员变量
  • 抽象类:可以定义普通变量,也可以定义常量
  • 接口:只能定义常量
  1. 成员方法
  • 抽象类:可以有普通方法,也可以有抽象方法
  • 接口:在基础阶段,可以先看成只能有抽象方法
  1. 构造方法
  • 抽象类:有构造方法
  • 接口:没有构造方法

设计思想上的区别

抽象类更偏向于对一类事物进行抽象,重点是在描述共性。比如动物、交通工具、员工,这些都更适合用抽象类来表示。

接口更偏向于对行为、能力进行抽象,重点是在制定规则。比如会游泳、会飞、会狩猎,这些都更适合用接口来表示。

接口新特性

之前为了便于理解,先把接口中的方法看成了抽象方法。但这只是基础阶段的认识。随着 Java 版本的更新,接口中也开始允许定义一些非抽象方法。

这些新特性主要出现在 JDK8 和 JDK9 中。

默认方法

从 JDK8 开始,接口中允许定义默认方法。接口中可以写带方法体的方法,但必须使用 default 关键字修饰。

格式如下:

java
public default 返回值类型 方法名(参数列表) {
}

例如:

java
public interface TestA {
    default void show() {
        System.out.println("TestA...default...show...");
    }
}

默认方法出现,主要是为了解决接口升级的问题。

类一旦实现了接口,就要重写接口中的抽象方法。
那如果一个接口已经被很多类实现了,这时候突然给接口新增一个抽象方法,就会导致所有实现类全部报错,因为它们都得补这个新方法。

默认方法就是用来缓和这个问题的。

实现类可以直接继承接口中的默认方法,也可以根据需要进行重写。

java
public interface TestA {
    default void show() {
        System.out.println("TestA...default...show...");
    }
}

class TestAImpl implements TestA {
}

这里 TestAImpl 没有重写 show(),但仍然可以直接使用这个方法。如果实现类觉得接口中的默认实现不合适,也可以自己重写:

java
class TestAImpl implements TestA {
    @Override
    public void show() {
        System.out.println("实现类重写后的 show 方法");
    }
}

多个接口中有同名默认方法时

如果一个类实现了多个接口,而多个接口中又存在同名的默认方法,那么这个类就必须对该方法进行重写。

例如:

java
interface TestA {
    default void show() {
        System.out.println("TestA...show...");
    }
}

interface TestB {
    default void show() {
        System.out.println("TestB...show...");
    }
}
java
class TestABImpl implements TestA, TestB {
    @Override
    public void show() {
        TestA.super.show();
        TestB.super.show();
    }
}

因为此时 show() 到底该继承哪一个,已经不明确了。所以 Java 要求实现类必须自己重写,手动处理这个冲突。

这里还顺便出现了一种写法:

java
接口名.super.方法名()

它表示:调用指定接口中的默认方法。

静态方法

从 JDK8 开始,接口中还允许定义静态方法

格式如下:

java
public static 返回值类型 方法名(参数列表) {
}

例如:

java
public interface TestA {
    static void show() {
        System.out.println("TestA...static...show...");
    }
}

接口中的静态方法只能通过接口名调用,不能通过实现类名或者对象名调用。

正确写法:

java
TestA.show();

错误写法:

java
TestAImpl.show();
new TestAImpl().show();

另外还要注意,public 可以省略,而 static 不能省略

私有方法

从 JDK9 开始,接口中还允许定义私有方法

私有方法主要是为了给接口内部的方法复用服务,
也就是把多个默认方法或静态方法中的重复代码抽出来,供接口内部自己调用。

格式有两种:

java
private 返回值类型 方法名(参数列表) {
}
java
private static 返回值类型 方法名(参数列表) {
}

例如:

java
public interface TestA {
    default void show1() {
        log();
        System.out.println("show1");
    }

    default void show2() {
        log();
        System.out.println("show2");
    }

    private void log() {
        System.out.println("记录日志");
    }
}

这里的 log() 就是接口中的私有方法。
它不能被实现类直接调用,只能在当前接口内部使用。

总之,以上新特性都是对已有规则的补充。思想上跟抽象类的半成品还是有区别。

多态

多态,现阶段先抓住一句最实用的话:

同一个父类引用,指向不同的子类对象时,调用同名重写方法,会表现出不同的行为。

所以多态最核心的地方在于:
同样的方法调用,交给不同对象后,运行结果可以不同。

比如同样是 eat() 方法,狼有狼的吃法,狗有狗的吃法。调用语句看起来一样,但实际执行的逻辑不一样,这就是多态。

多态的成立条件

多态成立要满足三个条件,先留一个认知即可:

多态成立需要满足三个条件:

  1. 有继承或实现关系
  2. 有方法重写
  3. 有父类引用指向子类对象

典型写法如下:

java
Animal a = new Wolf();

左边是父类引用,右边是子类对象。对象实际还是子类对象,只是用父类类型的变量去接收它。

  1. 要有继承或实现关系

也就是类和类之间,或者类和接口之间,得先有关系。

java
class Animal {
    public void eat() {
        System.out.println("动物在吃东西");
    }
}

class Wolf extends Animal {
}

或者:

java
interface Swim {
    void swim();
}

class Dog implements Swim {
    @Override
    public void swim() {
        System.out.println("狗在游泳");
    }
}

没有这层父子关系,后面根本谈不上多态。

  1. 要有方法重写

如果子类没有重写父类方法,那就算父类引用指向子类对象,最后执行的还是父类原来的逻辑,多态效果就不明显了。

java
class Animal {
    public void eat() {
        System.out.println("动物在吃东西");
    }
}

class Wolf extends Animal {
    @Override
    public void eat() {
        System.out.println("狼在吃肉");
    }
}

这里 Wolf 重写了 eat(),后面才有“同样的调用,结果不同”这件事。

  1. 要有父类引用指向子类对象

这是多态最典型的写法。

java
Animal a = new Wolf();

左边是父类引用,右边是子类对象。这句一出来,多态的架子就搭起来了。

注意,这里不是说“子类对象变成父类对象了”。
而是说:

对象实际还是子类对象,只是用父类类型的变量去接它。

多态的成员访问特点

在之前我们最常写的是这种:

java
Wolf w = new Wolf();

这是“子类引用指向子类对象”,很直接。拿的是狼的引用,指向的也是狼对象,所以访问成员时基本没什么绕的。

而多态是这样:

java
Animal a = new Wolf();

这时候,对象还是那只狼,但你手里拿的是 Animal 类型的引用。从这里开始,就会出现一个很关键的现象:

访问成员时,要同时考虑“左边引用类型”和“右边实际对象类型”。

这也是多态这部分最容易乱掉的地方。这里一定要先记住一句话:

左边,看的是引用类型。右边,看的是实际对象类型。

  • 左边是 Animal ,是引用类型
  • 右边是 Wolf ,是实际对象类型

下面分三种情况访问。

  1. 访问成员变量:编译看左边,运行也看左边
java
class Animal {
    int num = 10;
}

class Wolf extends Animal {
    int num = 20;
}
java
Animal a = new Wolf();
System.out.println(a.num);

结果是:

java
10 // 访问的是父类的 `num`。

因为成员变量不具备多态性。变量访问本质上是在“按引用类型找变量”,不是在运行时动态分派。

所以对于成员变量来说:你左边写的是谁,就按谁来。

也就是说:

  • 编译时,先检查 Animal 里有没有 num
  • 运行时,也直接取 Animal 这部分的 num
  1. 访问成员方法:编译看左边,运行看右边
java
class Animal {
    public void eat() {
        System.out.println("动物在吃东西");
    }
}

class Wolf extends Animal {
    @Override
    public void eat() {
        System.out.println("狼在吃肉");
    }
}
java
Animal a = new Wolf();
a.eat();

结果是:

java
狼在吃肉

这里就是多态最核心的体现。

先说编译为什么看左边。
编译器要先确认:你写的 a.eat() 合不合法。而 a 的类型是 Animal,所以它会先检查 Animal 里有没有 eat()

  • 如果父类里没有这个方法,直接编译报错
  • 如果父类里有,哪怕是抽象方法,也能编译通过

再说运行为什么看右边。
真正执行时,JVM 会看这个对象实际是谁。这里实际对象是 Wolf,所以最后执行的是 Wolf 重写后的 eat()

所以访问成员方法的规律就是:

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

  1. 静态成员:编译看左边,运行也看左边

静态成员本来就不属于对象,而是属于类。所以它本质上就不参与多态。

java
class Animal {
    public static void show() {
        System.out.println("Animal show");
    }
}

class Wolf extends Animal {
    public static void show() {
        System.out.println("Wolf show");
    }
}
java
Animal a = new Wolf();
a.show();

结果是:

java
Animal show

虽然右边创建的是 Wolf 对象,但静态方法调用不看对象实际类型,它只看左边引用类型。

这里 a.show(); 字节码文件编译后 上等价于:

java
Animal.show();

因此静态成员的规律也是:编译看左边,运行也看左边。

不过更准确一点说,静态成员压根不是“运行再选择”,它在编译阶段基本就定下来了。

而且还是那句话,静态成员推荐直接用类名调用:

java
Animal.show();
Wolf.show();

这样最清楚,也不会让人误以为它有多态。

这一个完整例子,把三种情况放一起看:

java
class Animal {
    int num = 10;

    public void eat() {
        System.out.println("动物在吃东西");
    }

    public static void show() {
        System.out.println("Animal show");
    }
}

class Wolf extends Animal {
    int num = 20;

    @Override
    public void eat() {
        System.out.println("狼在吃肉");
    }

    public static void show() {
        System.out.println("Wolf show");
    }
}
java
Animal a = new Wolf();

System.out.println(a.num); // 10
a.eat();                   // 狼在吃肉
a.show();                  // Animal show

这三句刚好对应:

  • 成员变量:看左边
  • 成员方法:编译看左边,运行看右边
  • 静态成员:看左边

成员变量和静态成员都不体现多态,它们本质上都更偏向于按引用类型或类来访问。

当通过父类引用调用被子类重写的成员方法时,才会表现出多态。此时编译阶段看引用类型,检查这个方法能不能调;运行阶段看实际对象类型,决定真正执行哪个方法。

可以,我给你整理成一版更顺、更像笔记正文的。

多态的优点

多态最大的价值,是在写代码时不用把每一种子类情况都单独写一遍,我们称之为提高扩展性。
对象多态最常见的体现,就是方法形参写父类类型,实际传入子类对象

例如:

java
class Animal {
    public void eat() {
        System.out.println("动物在吃东西");
    }
}

class Wolf extends Animal {
    @Override
    public void eat() {
        System.out.println("狼在吃肉");
    }
}

class Dog extends Animal {
    @Override
    public void eat() {
        System.out.println("狗在啃骨头");
    }
}

如果不用多态,可能要这样写:

java
public void feedWolf(Wolf wolf) {
    wolf.eat();
}

public void feedDog(Dog dog) {
    dog.eat();
}

这样的问题很明显:以后每多一种动物,就得多写一个方法。而使用多态后,可以统一成一个方法:

java
public void feed(Animal animal) {
    animal.eat();
}

调用时直接传入具体对象:

java
feed(new Wolf());
feed(new Dog());

虽然形参写的是 Animal,但运行时会根据实际传入的对象类型,调用各自重写后的 eat() 方法。
这就是多态带来的效果。

多态的优点是提高程序的扩展性。
在方法参数中使用父类类型或接口类型,可以统一接收多个不同的子类对象。调用时传入具体对象,执行时再根据实际对象类型调用对应的方法。

这样做的好处是,后续新增子类时,原有方法通常不用改,直接传入新对象即可。

但多态也有局限:
父类引用只能调用父类中声明过的成员,子类自己特有的方法不能直接调用,这也是后面为什么需要学习类型转换。

向上转型
从子到父(父类引用指向子类对象)
Fu f =
new zi();
7ef20235
向下转型
从父到子(将父类引用所指向的对象,转交给子类类型)
zi z = f;
可以,我给你收成一版更贴前文风格的。主线就一条:

因为多态下父类引用有访问局限,所以才需要类型转换。

你可以直接改成这样:

类型转换

多态虽然提高了扩展性,但也带来了一个明显的限制:父类引用只能调用父类中声明过的成员,不能直接调用子类特有的成员。

例如:

java
Animal a = new Dog();
a.eat();   // 可以,eat 是父类中声明过的方法
// a.lookHome(); // 报错,父类 Animal 中没有这个方法

这时候,如果确实要调用子类自己特有的方法,就要用到类型转换。

向上转型

向上转型就是从子到父,也就是把子类对象交给父类引用来接收。

java
Animal a = new Dog();

这其实就是多态最常见的写法。
向上转型是自动完成的,一般不需要强制转换,也是安全的。

它的特点是:
父类引用可以统一接收不同的子类对象,但访问成员时会受到父类类型的限制。

向下转型

向下转型就是从父到子,也就是再把父类引用转回子类类型。

java
Dog d = (Dog) a;
d.lookHome();

这样做的目的,就是为了调用子类特有的方法。
不过向下转型不是自动的,必须手动写出目标类型,而且有风险。

如果父类引用实际指向的不是这个子类对象,运行时就会报错:

java
Animal a = new Cat();
Dog d = (Dog) a; // 运行时报 ClassCastException

所以在向下转型之前,通常要先判断类型:

java
if (a instanceof Dog) {
    Dog d = (Dog) a;
    d.lookHome();
}

类型转换是为了解决多态下父类引用访问受限的问题。

  • 向上转型:从子到父,自动完成,是多态的基础
  • 向下转型:从父到子,必须强制转换,用来调用子类特有成员
  • 向下转型前,最好先用 instanceof 判断类型

Java 16 后的模式匹配(了解)

在传统写法中,通常要先用 instanceof 判断类型,再手动进行强制类型转换。

java
if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.fetchBone();
}

从 Java 16 开始,instanceof 支持模式匹配,可以在判断类型的同时,直接完成变量声明:

java
if (animal instanceof Dog dog) {
    dog.fetchBone();
}

这样写更简洁,也少了一步手动转型。这一点本质上还是先判断、再使用,只是语法更方便了。

代码块

在 Java 中,用 {} 括起来的一段代码,就叫代码块。

代码块本质上是一段会在特定时机自动执行的代码
它不像普通方法那样需要手动调用,而是在类加载或对象创建时执行一些初始化逻辑。

代码块主要分为三种:

  • 局部代码块
  • 构造代码块(实例代码块)
  • 静态代码块

同步代码块和多线程有关,后面学到再说。

局部代码块

局部代码块就是定义在方法中的代码块。

java
public void show() {
    {
        int a = 10;
        System.out.println(a);
    }
}

它的作用主要是限制变量的作用范围和生命周期
出了这个代码块,里面定义的变量就不能再访问了。

这个用法现在比较少,知道有这么个东西就行,不是重点。

构造代码块

构造代码块也叫实例代码块,写在类中、方法外,没有 static 修饰。

java
class Demo {
    {
        System.out.println("执行构造代码块");
    }

    public Demo() {
        System.out.println("执行构造方法");
    }
}

它会在每次创建对象时执行,并且先于构造方法执行

如果一个类中有多个构造方法,而这些构造方法里有一部分相同的初始化代码,就可以抽取到构造代码块中。

不过这种写法实际开发里不算常见,了解它的执行时机更重要。

静态代码块

静态代码块写在类中、方法外,并且要加 static

java
class Demo {
    static {
        System.out.println("执行静态代码块");
    }
}

它会在类加载时执行,而且只执行一次

因为一个类的字节码文件通常只会加载一次,所以静态代码块也只会跟着执行一次。

它的主要作用是:
在类加载时完成一些静态资源或静态变量的初始化。

静态代码块是代码块里最常见、也最需要重点掌握的一种。

执行顺序

代码块这部分执行顺序如下:

静态代码块 → 构造代码块 → 构造方法

再说完整一点:

  1. 类加载时,先执行静态成员变量初始化,再执行静态代码块
  2. 创建对象时,先执行普通成员变量初始化,再执行构造代码块,最后执行构造方法

也就是说:

  • 静态代码块:随着类加载执行,只执行一次
  • 构造代码块:每创建一个对象,就执行一次
  • 构造方法:每创建一个对象,就执行一次,而且晚于构造代码块
java
public class CodeBlock {
    public static int num = 100;
    public int num2 = 10;

    static {
        System.out.println("num 的初始值:" + num);
        num++;
        System.out.println("执行静态代码块");
    }

    {
        System.out.println("num2 的初始值:" + num2);
        num2 = 20;
        System.out.println("执行构造代码块");
    }

    public CodeBlock() {
        System.out.println("num2 = " + num2);
        System.out.println("执行构造方法");
    }
}

测试代码:

java
public class CodeBlockDemo {
    public static void main(String[] args) {
        System.out.println("num = " + CodeBlock.num);
        System.out.println("再次访问 num = " + CodeBlock.num);
        System.out.println("==============");

        CodeBlock cb1 = new CodeBlock();
        System.out.println("==============");

        CodeBlock cb2 = new CodeBlock();
    }
}

运行结果:

txt
num 的初始值:100
执行静态代码块
num = 101
再次访问 num = 101
==============
num2 的初始值:10
执行构造代码块
num2 = 20
执行构造方法
==============
num2 的初始值:10
执行构造代码块
num2 = 20
执行构造方法

从结果里可以看出:

  • 静态代码块只执行了一次
  • 构造代码块每创建一个对象都会执行
  • 构造方法也是每创建一个对象都会执行,并且在构造代码块之后执行

包(package)

包本质上是一个文件夹,用于组织和管理类文件。

包的语法格式

java
package 公司域名倒写.技术名称

包名建议全部使用小写字母,并具有意义,例如:
package com.wreckloud.pojo;
这里,com.wreckloud.pojo 是包的路径。

建包语句通常会放在文件的最前面,IDE(如 IntelliJ IDEA)会自动生成和帮助创建包。

可以使用关键字 import 导包

java
import 包名.类名;
  • 同一个包下的类,可以直接访问,无需导包。
  • 不同包下的类,必须使用 import 导入才能使用。
  • java.lang 包(核心包)下的类无需显式导入,Java 会自动导入这些类。

如果导入了多个类且它们有相同的类名,Java 默认只导入一个类,另一个类需要通过包名来访问。

例如:

java
import com.itheima.pojo.Student;
import com.another.package.Student;  // 这里会有冲突,需要使用完整包名

此时,可以通过包名指定使用哪个 Student 类:

java
com.itheima.pojo.Student student1 = new com.itheima.pojo.Student();
com.another.package.Student student2 = new com.another.package.Student();

依赖注入

依赖注入是一种设计模式,它解决了类与类之间过度耦合的问题。本质上,它让类不再自己创建依赖对象,而是接收外部传入的依赖

你需要什么工具,别人直接递给你,而不是自己 new 一个。

传统编程中,一个类往往在内部直接创建它需要的对象,这种方式产生了几个问题:

java
// 传统写法:自己造咖啡机
class Customer {
    private CoffeeMachine machine = new BasicCoffeeMachine(); // 自己new对象

    void drinkCoffee() {
        machine.brew();
        System.out.println("喝咖啡");
    }
}

这种写法导致:

  1. 顾客类与基础咖啡机类强耦合,想替换为高级咖啡机必须修改代码
  2. 测试困难,无法注入测试专用的模拟对象
  3. 违反单一职责原则,顾客类不应该关心如何创建咖啡机

依赖注入通过解耦解决了这些问题,它让每个类专注于自己的职责,提高了代码的灵活性和可维护性。

有几种常见方式:

构造函数注入

通过构造方法将依赖对象传递给需要它的类:

java
class Customer {
    private CoffeeMachine machine; // 不自己创建

    // 通过构造器传入依赖
    public Customer(CoffeeMachine machine) {
        this.machine = machine;
    }

    void drinkCoffee() {
        machine.brew();
        System.out.println("喝咖啡");
    }
}

// 使用场景
public static void main(String[] args) {
    // 可以灵活选择注入哪种咖啡机
    CoffeeMachine luxuryMachine = new LuxuryCoffeeMachine();

    // 将依赖注入到顾客对象
    Customer customer = new Customer(luxuryMachine);
    customer.drinkCoffee();
}

创建对象的那一刻,所有必需的依赖就到位了,对象状态完整。适用于依赖不会变化的场景。

构造函数注入的优势

  • 对象创建时依赖就绪,确保完整性
  • 依赖关系明确,一目了然
  • 适合必需的、不变的依赖

Setter 方法注入

通过 setter 方法在对象创建后动态注入依赖:

java
class Customer {
    private CoffeeMachine machine;

    // 通过setter方法注入依赖
    public void setCoffeeMachine(CoffeeMachine machine) {
        this.machine = machine;
    }

    void drinkCoffee() {
        if(machine != null) {
            machine.brew();
            System.out.println("喝咖啡");
        }
    }
}

// 使用场景
public static void main(String[] args) {
    Customer customer = new Customer();

    // 先使用基本咖啡机
    customer.setCoffeeMachine(new BasicCoffeeMachine());
    customer.drinkCoffee();

    // 可随时更换为高级咖啡机
    customer.setCoffeeMachine(new LuxuryCoffeeMachine());
    customer.drinkCoffee();
}

灵活性高,可以在对象创建后动态替换依赖。适用于依赖可能变化的场景。

Setter 注入的优势

  • 支持依赖的动态更换
  • 可处理可选依赖
  • 适合依赖可能变化的场景

依赖注入让类专注于使用依赖完成任务,而不是创建依赖。

依赖注入作为现代软件开发的重要设计模式,不仅提高了代码质量,也为许多框架(如 Spring)奠定了基础。掌握它让我们能够编写更加灵活、可维护的代码。

内部类

内部类,就是定义在另一个类里面的类

也就是说,一个类不仅可以定义成员变量、成员方法,还可以在自己的内部再定义一个类。
这种写在类内部的类,就叫内部类。

例如:

java
class Outer {
    class Inner {
    }
}

这里的 Inner 就是 Outer 的内部类,具体点可以称作成员内部类。

内部类本质上是一种特殊的封装方式。

如果一个类只是为另一个类服务,离开外部类之后几乎没有单独存在的意义,那么就可以考虑把它写成内部类。
这样做可以让代码上的归属关系更明确,封装性也会更强一些。

不过,内部类虽然在某些场景下封装性较好,但写法通常会更麻烦,理解成本也更高。所以在实际开发中,它并不是最常用的写法。

现阶段学习它,目的是:以后在别人代码里看到内部类时,至少知道它是什么就行。

Java 中的内部类主要有四种:

  • 成员内部类
  • 静态内部类
  • 局部内部类
  • 匿名内部类(重点)

成员内部类

就像类里可以有成员变量、成员方法一样,类里也可以再放一个类。这就是成员内部类,定义在类中、方法外的内部类。
它和成员变量、成员方法一样,都属于外部类的成员。

内部类和外部类之间的关系会比较特殊:

  • 内部类可以直接访问外部类中的成员
  • 外部类如果想访问内部类中的成员,一般需要先创建内部类对象

这也是内部类最基础的访问特点。

  1. 内访外:直接访问

成员内部类访问外部类中的成员时,可以直接访问,就算被 private 修饰。

java
class Outer {
    private int num = 10;

    class Inner {
        public void show() {
            System.out.println(num);
        }
    }
}

这里 Inner 中的 show() 方法可以直接访问外部类中的 num

  1. 外访内:需要创建对象访问

外部类如果想访问成员内部类中的成员,通常需要先创建内部类对象,再进行访问。成员内部类创建对象的格式是:

java
外部类名.内部类名 对象名 = new 外部类对象().new 内部类对象();

例如:

java
Outer.Inner oi = new Outer().new Inner();
  • 先有一个外部类对象
  • 再基于这个外部类对象去创建内部类对象

因为成员内部类本来就是“依附”在外部类对象上的。

来一个内部类经典的案例:

java
class Outer {
    int num = 10;

    class Inner {
        int num = 20;

        public void show() {
            int num = 30;
            System.out.println(num);            // 30
            System.out.println(this.num);       // 20
            System.out.println(Outer.this.num); // 10
        }
    }
}

这里要分清楚三层:

  • num:先找当前方法中的局部变量
  • this.num:表示当前内部类对象中的成员变量
  • Outer.this.num:表示外部类对象中的成员变量

super 是用来访问父类成员的。
而内部类访问的是外部类成员,所以这里对应的写法是:

java
外部类名.this

这个点是内部类里最容易搞混的地方之一。

静态内部类

如果在内部类前面加上 static 修饰,这个内部类就叫静态内部类

java
class Outer {
    static class Inner {
    }
}

静态内部类和成员内部类最大的区别在于:

  • 成员内部类属于外部类对象
  • 静态内部类属于外部类本身

也正因为如此,静态内部类在创建对象时,不需要先创建外部类对象。创建静态内部类对象的格式是:

java
外部类名.内部类名 对象名 = new 外部类名.内部类名();

例如:

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

和成员内部类相比:

java
new Outer().new Inner()

这里可以少写一个 new,直接 new Outer.Inner();

因为静态内部类本身不依附于外部类对象,而是跟着外部类一起存在。在访问特点上,静态内部类也会遵循静态成员的规则:

  • 可以直接访问外部类中的静态成员
  • 不能直接访问外部类中的非静态成员

例如:

java
class Outer {
    static int a = 10;
    int b = 20;

    static class Inner {
        public void show() {
            System.out.println(a);
            // System.out.println(b); // 报错
        }
    }
}

这里 a 是静态成员,所以可以直接访问。而 b 是外部类对象的成员,静态内部类不能直接访问。

如果确实想访问外部类中的非静态成员,也不是不行,只是不能像成员内部类那样直接访问,而是要先创建外部类对象,再通过对象访问。

局部内部类

如果把一个类定义在方法、代码块或者构造方法中,这个类就叫局部内部类。他只出现在某个局部范围里。

例如:

java
class A {
    public void show() {
        class B {
            public void print() {
                System.out.println("print...");
            }
        }

        B b = new B();
        b.print();
    }
}

这里的 B 就是局部内部类。
它只在 show() 方法内部有效,出了这个方法,就不能再直接使用了。

也正因为这样,它的使用场景非常有限。

一般来说,局部内部类只是为了在某个方法里临时定义一个辅助类。但这种写法本身比较麻烦,可读性也不算好,所以实际开发中几乎不常用,了解即可。

匿名内部类(重点)

在实际开发中,有一种情况:某个方法的参数是接口类型。这时候就会遇到一个问题:调用这个方法时,到底该传什么?

例如:

java
interface Swim {
    void swim();
}
java
class Test {
    public static void go(Swim s) {
        s.swim();
    }
}

这里 go() 方法的参数类型是 Swim。这说明,调用 go() 时,传进去的对象必须“符合 Swim 这条规则”,也就是必须有 swim() 这个方法。

因为接口不能直接创建对象,所以这里只能传这个接口的实现类对象。按照普通写法,如果想调用这个方法,一般要先专门写一个实现类:

java
class Dog implements Swim {
    @Override
    public void swim() {
        System.out.println("狗在游泳");
    }
}

然后再创建对象,把它作为参数传进去:

java
Test.go(new Dog());

这套写法没有问题。
但如果这个实现类只是为了这一次调用临时用一下,专门再写一个类,就会显得有些麻烦。

因为本来只是想传一个对象进去调用方法,结果却要额外经历下面几步:

  1. 写一个实现类
  2. 重写接口中的方法
  3. 创建实现类对象
  4. 再把对象传进去

如果这种对象只使用一次,这样写就有点啰嗦了。这时候,就可以使用匿名内部类

匿名内部类,本质上是一种特殊的局部内部类。它没有单独的类名,而是把“定义实现类”和“创建对象”这两件事合在一起写。
可以先把它简单理解成:

不再单独写实现类,而是在 new 的时候,直接把这个实现类临时写出来。

它的基本格式是:

java
new 类名或者接口名() {
    重写方法;
}

如果 new 后面写的是类,就表示临时创建一个子类对象;
如果写的是接口,表示临时创建一个实现类对象。

还是用刚刚的 Swim 接口来写,就可以变成这样:

java
Test.go(new Swim() {
    @Override
    public void swim() {
        System.out.println("狗在游泳");
    }
});

这段代码虽然看起来一下子挤在了一起,但实际上做的事情还是前面的那几步,只不过被压缩到了一行位置上。

可以拆开理解:

new Swim() { ... } 临时写一个实现了 Swim 接口的类,并立刻创建这个类的对象。而花括号里面写的内容,就是这个“临时实现类”对接口方法的重写。

所以这段代码本质上等价于前面的普通写法:

  • 原来先写 Dog implements Swim
  • 再重写 swim()
  • new Dog()
  • 最后传参

现在则变成:

  • 直接在 new Swim() { ... } 里把实现类写出来
  • 然后立刻把这个对象传给 go()

也就是说,匿名内部类本质上仍然是在创建对象,只不过这个对象对应的类没有单独起名字,而是临时写在当前位置。

匿名内部类最常见的使用场景,就是刚刚这种作为方法参数传递
因为这种场景下,对象往往只用一次,单独写一个实现类不太划算,而匿名内部类刚好可以把代码收在一起。

例如:

java
interface Swim {
    void swim();
}

class Test {
    public static void go(Swim s) {
        s.swim();
    }

    public static void main(String[] args) {
        go(new Swim() {
            @Override
            public void swim() {
                System.out.println("狼在游泳");
            }
        });
    }
}

这里的匿名内部类只为了这一次 go() 调用服务。用完之后就结束了,不需要额外再定义一个实现类名字。

总的来说,匿名内部类的核心作用就是:

把“写实现类(或子类)”和“创建对象”这两步合成一步。

这样在“一次性使用”的场景下会比较方便。不过它的缺点也很明显,就是写法比较挤,可读性一般,复用性也差。

所以匿名内部类更适合:

  • 临时使用一次
  • 主要用于方法传参
  • 不值得专门单独建类的场景

如果一个逻辑后面还要反复使用,那通常还是老老实实单独写类更合适。

Lambda 表达式

在匿名内部类中,如果某个方法的参数是接口类型,那么调用这个方法时,通常需要传入这个接口的实现类对象。

例如:

java
@FunctionalInterface
interface Swim {
    void swim();
}
java
class Test {
    public static void go(Swim s) {
        s.swim();
    }
}

如果按匿名内部类的方式来写,可以这样调用:

java
Test.go(new Swim() {
    @Override
    public void swim() {
        System.out.println("狼在游泳");
    }
});

JDK8 开始提供了一种新的语法形式:Lambda 表达式。它的作用就是:简化函数式接口匿名内部类的写法。
还是刚刚的例子,用 Lambda 改写之后就是:

java
Test.go(() -> {
    System.out.println("狼在游泳");
});

对比一下,匿名内部类里那些固定结构被省掉了,只保留了最关键的部分:参数和方法体。
所以 Lambda 表达式的基本格式是:

java
(参数列表) -> {
    方法体
}

其中:

  • () 中写的是被重写方法的参数列表
  • -> 是固定的语法格式,本身没有实际含义
  • {} 中写的是被重写方法的方法体

因为 go() 方法的参数类型是 Swim,而 Swim 接口中只有一个抽象方法 swim()。所以编译器能够根据上下文判断出:

  • 这里是在给 swim() 写实现
  • 没有参数,所以左边是 ()
  • 方法体就是右边的大括号内容

这里要特别注意:Lambda 只能简化函数式接口的匿名内部类写法,不是所有匿名内部类都能随便改成 Lambda。

所谓函数式接口,要满足两个条件:

  • 必须是接口
  • 接口中有且仅有一个抽象方法

例如:

java
@FunctionalInterface
interface Swim {
    void swim();
}

这里的 Swim 就是函数式接口。因为它本身是接口,并且里面只有一个抽象方法。通常还会在接口上加上 @FunctionalInterface 注解,用来明确说明:这个接口必须是函数式接口。

如果后来不小心又往里面加了第二个抽象方法,编译器就会直接报错提示。

为什么 Lambda 只能用于函数式接口?因为 Lambda 写法里并没有方法名,它只写了“参数列表”和“方法体”。

所以编译器必须先明确知道:你到底是在实现接口中的哪一个方法。如果接口里有多个抽象方法,编译器就没法判断这段 Lambda 到底对应谁,写法自然就不成立了。

案例

无参、无返回值

先看最简单的一种情况。

java
@FunctionalInterface
interface Hunt {
    void hunt();
}

匿名内部类写法:

java
Hunt h1 = new Hunt() {
    @Override
    public void hunt() {
        System.out.println("狼开始狩猎");
    }
};
h1.hunt();

Lambda 写法:

java
Hunt h2 = () -> {
    System.out.println("狼开始狩猎");
};
h2.hunt();

这里 hunt() 方法没有参数,也没有返回值,所以 Lambda 左边写成 (),右边直接写方法体即可。

有参数、无返回值

再看带参数的情况。

java
@FunctionalInterface
interface Feed {
    void eat(String food);
}

匿名内部类写法:

java
Feed f1 = new Feed() {
    @Override
    public void eat(String food) {
        System.out.println("狼吃" + food);
    }
};
f1.eat("生肉");

Lambda 写法:

java
Feed f2 = (String food) -> {
    System.out.println("狼吃" + food);
};
f2.eat("生肉");

这里 eat() 有一个参数 food,所以 Lambda 左边就要把这个参数写出来,右边仍然是方法体。接口中那个抽象方法有什么参数,Lambda 左边就写什么参数。

有参数、有返回值

再看带返回值的情况。

java
@FunctionalInterface
interface CompareAge {
    int compare(int a, int b);
}

匿名内部类写法:

java
CompareAge c1 = new CompareAge() {
    @Override
    public int compare(int a, int b) {
        return a - b;
    }
};
System.out.println(c1.compare(5, 3));

Lambda 写法:

java
CompareAge c2 = (int a, int b) -> {
    return a - b;
};
System.out.println(c2.compare(5, 3));

compare() 有两个参数,并且有返回值,所以 Lambda 左边写两个参数,右边的方法体中再写 return

进阶写法

前面的 Lambda 写法,已经比匿名内部类简洁了不少。不过在很多情况下,Lambda 还可以继续简化。常见的简化规则有三条:

  1. 参数类型可以省略

因为函数式接口中抽象方法的参数类型,本来就已经确定了,编译器可以根据上下文推断出来,所以参数类型通常可以不写。

  1. 如果只有一个参数,小括号也可以省略

注意,这一条有前提:只能是一个参数。如果没有参数,还是要写 ();如果有多个参数,也不能省略括号。

  1. 如果方法体只有一行代码,可以省略大括号

这时候写法会更紧凑。但如果这一行代码本来是 return 语句,那么在省略大括号的同时,return 也必须一起省略,只保留返回的表达式。

也就是说:

  • 保留大括号时,return 正常写
  • 省略大括号时,return 也要一起去掉
  1. 无参、无返回值

先看前面“无参、无返回值”的例子。

java
Hunt h = () -> {
    System.out.println("狼开始狩猎");
};

这里的方法没有参数,所以左边必须写成 (),这一点不能省。但右边的方法体只有一行代码,因此可以继续简化成:

java
Hunt h = () -> System.out.println("狼开始狩猎");

这个例子体现的是第 3 条规则:

  • 方法体只有一行代码
  • 可以省略大括号
  1. 有一个参数、无返回值

再看前面“一个参数、无返回值”的例子。

java
Feed f = (String food) -> {
    System.out.println("狼吃" + food);
};

第一步,可以先省略参数类型:

java
Feed f = (food) -> {
    System.out.println("狼吃" + food);
};

因为接口中抽象方法的参数类型已经确定是 String,所以这里不写,编译器也能推断出来。

第二步,因为现在只剩一个参数,所以括号也可以省略:

java
Feed f = food -> {
    System.out.println("狼吃" + food);
};

第三步,方法体只有一行代码,所以还可以继续省略大括号:

java
Feed f = food -> System.out.println("狼吃" + food);

这个例子把三条规则都体现出来了:

  • 参数类型可以省略
  • 单参数时括号可以省略
  • 方法体只有一行时,大括号可以省略
  1. 有参数、有返回值

最后看前面“有参数、有返回值”的例子。

java
CompareAge c = (int a, int b) -> {
    return a - b;
};

第一步,可以先省略参数类型:

java
CompareAge c = (a, b) -> {
    return a - b;
};

这里虽然有两个参数,但参数类型同样可以由编译器推断出来,所以可以省略。

第二步,因为方法体只有一行 return 语句,所以可以继续省略大括号。但这里要特别注意:省略大括号时,return 也必须一起省略。

所以最终可以写成:

java
CompareAge c = (a, b) -> a - b;

这个例子主要体现两点:

  • 参数类型可以省略
  • 单行 return 语句在省略大括号时,要连同 return 一起省略

Lambda 表达式与匿名内部类的区别

Lambda 表达式可以看成是对函数式接口匿名内部类写法的简化。但这并不意味着 Lambda 和匿名内部类完全一样,它们之间还是有明显区别的。

  1. 使用限制不同

匿名内部类的使用范围更大。它既可以基于来写,也可以基于接口来写,匿名内部类本质上做的事情有两种:

  • 继承一个类,临时创建子类对象
  • 实现一个接口,临时创建实现类对象

而 Lambda 表达式的使用范围更窄。它只能用于函数式接口,不能直接用于普通类、抽象类,也不能用于有多个抽象方法的接口。

所以这一点可以直接记成:

  • 匿名内部类:可以操作类、抽象类、接口
  • Lambda 表达式:只能操作函数式接口

也正因为这样,Lambda 并不是匿名内部类的“通用替代品”,它只能替代其中一部分场景。

  1. 实现方式不同

匿名内部类在编译之后,通常会生成一个独立的 .class 字节码文件。比如在某个类里写了一个匿名内部类,编译后常常会看到类似:

java
Outer$1.class

这样的文件。

这说明匿名内部类本质上仍然是一个真实存在的类,只是这个类没有手动起名字,编译器帮它生成了名字。

而 Lambda 表达式就不是这样。
Lambda 虽然看起来也像是在“临时写实现”,但它编译后通常不会像匿名内部类那样直接对应一个独立的 .class 文件

它更多是通过 Java 在底层提供的机制,在运行时把这段行为组织起来。所以从实现角度来说,Lambda 更像是在描述“一段要执行的逻辑”,而不是显式写出一个匿名类。

这一点可以简单记成:

  • 匿名内部类:编译后通常会生成独立的内部类字节码文件
  • Lambda 表达式:通常不会直接生成对应的独立匿名类字节码文件
  1. 本质侧重点不同

匿名内部类本质上仍然是“写一个类”。只不过这个类没有名字,而且通常只用一次。

Lambda 表达式则更偏向于“传递一段行为”。它关心的重点不是“我创建了一个什么类”,而是“我要把什么逻辑传进去”。

所以两者虽然都能完成“临时实现接口”的效果,但思路并不完全一样:

  • 匿名内部类:重点在“临时定义一个类”
  • Lambda 表达式:重点在“直接提供方法实现”

方法引用

前面已经知道,Lambda 表达式可以用来简化函数式接口的匿名内部类写法。但有些时候,Lambda 本身还可以继续简化。

比如,Lambda 里并没有写新的逻辑,只是把参数原样传给一个已经存在的方法去处理。如果只是这种“代为调用”的情况,其实连 Lambda 都可以再省一层,这就引出了方法引用

所以,方法引用可以先简单理解成:

如果 Lambda 表达式只是调用一个现成的方法,就可以考虑改成方法引用。

静态方法引用

先看最容易理解的一种情况。假设现在要给狼对象数组按年龄排序:

java
class Wolf {
    private String name;
    private int age;

    public Wolf(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public int getAge() {
        return age;
    }

    public static int compareByAge(Wolf w1, Wolf w2) {
        return w1.getAge() - w2.getAge();
    }
}

如果用 Lambda 来写排序规则,可以这样写:

java
Arrays.sort(arr, (w1, w2) -> Wolf.compareByAge(w1, w2));

这里的 Lambda 其实什么新逻辑都没写,只是把 w1w2 原样传给 Wolf.compareByAge()

既然只是单纯调用静态方法,就可以继续简化成方法引用:

java
Arrays.sort(arr, Wolf::compareByAge);

这种写法就叫静态方法引用

格式是:

java
类名::静态方法

所以这一类写法的判断关键就是:

Lambda 里是不是只是“把参数传给某个静态方法”。

如果是,就可以考虑改成 类名::静态方法

实例方法引用

再看另一种情况。
有时候,Lambda 调用的不是静态方法,而是某个对象的实例方法。

例如:

java
class WolfComparator {
    public int compare(Wolf w1, Wolf w2) {
        return w1.getAge() - w2.getAge();
    }
}

如果先创建一个比较器对象:

java
WolfComparator wc = new WolfComparator();

那么 Lambda 可以这样写:

java
Arrays.sort(arr, (w1, w2) -> wc.compare(w1, w2));

这里本质上也没有额外逻辑,
只是把参数交给对象 wccompare() 方法处理。

所以它同样可以继续简化成方法引用:

java
Arrays.sort(arr, wc::compare);

这种写法就叫实例方法引用

格式是:

java
对象名::实例方法

和前面的静态方法引用相比,区别只是:

  • 前者调用的是类方法
  • 这里调用的是对象方法

但它们的共同点都是一样的:

Lambda 只是把参数原样转交给一个现成的方法。

构造器引用

除了调用现成的方法,还有一种很常见的情况:
Lambda 里只是根据参数去创建对象。

例如:

java
@FunctionalInterface
interface CreateWolf {
    Wolf create(String name, int age);
}

如果用 Lambda 来写:

java
CreateWolf cw = (name, age) -> new Wolf(name, age);

这里 Lambda 做的事情也很单纯,
就是把参数传给构造方法,创建一个 Wolf 对象。

既然如此,就可以继续简化成:

java
CreateWolf cw = Wolf::new;

这种写法就叫构造器引用

格式是:

java
类名::new

所以它的判断方式也很直接:

如果 Lambda 只是“接收参数,然后 new 对象”,就可以考虑改成构造器引用。

特定类型的方法引用

这一种相对绕一点,现阶段有个印象就够了。

例如,对字符串数组按忽略大小写的方式排序:

java
Arrays.sort(names, (s1, s2) -> s1.compareToIgnoreCase(s2));

这里也可以写成:

java
Arrays.sort(names, String::compareToIgnoreCase);

这种写法的关键在于:

  • Lambda 的第一个参数,正好作为方法调用者
  • 后面的参数,正好作为这个方法的参数

所以它可以简化成:

java
类型名::方法

第一次看这种写法容易懵,因为表面上看不到调用者。
但你只要先记住一点就够了:

在这种写法里,Lambda 的第一个参数会自动当作方法调用者。

这一类用得没前面三种直观,先能看懂即可。

方法引用什么时候能用

方法引用虽然更短,但不是为了短而短。
它能不能用,关键不是“我想不想省”,而是下面这个前提是否成立:

Lambda 里是不是只做了一件事:调用一个已经存在的方法。

如果答案是“是”,那就可以考虑方法引用。
如果 Lambda 里还有自己的处理逻辑,比如:

  • 先判断再调用
  • 先拼接再返回
  • 先计算再传参

那通常就不能直接改成方法引用。

所以方法引用最核心的一句话就是:

它只适合简化那种“代为调用现成方法”的 Lambda。

小结

方法引用可以看成是 Lambda 表达式的进一步简化

常见的几种写法可以先记成:

  • 类名::静态方法
  • 对象名::实例方法
  • 类名::new

另外还有一种:

  • 类型名::方法

这一种相对绕一些,现阶段能看懂即可。

学习方法引用时,不用刻意追求把所有 Lambda 都改掉。
只要先抓住判断标准就够了:

如果 Lambda 只是调用现成方法,就可以考虑改成方法引用。

如果改完之后反而更绕,那保留 Lambda 写法通常更合适。

枚举

枚举(enum)其实就是一种特殊的类,用来表示一组有限且固定的常量。比如星期、月份、操作类型等。

以前我们经常用一堆 public static final int 常量来表示状态,比如:

java
public class Constant {
    public static final int DOWN = 1;
    public static final int UP = 2;
    public static final int HALF_UP = 3;
    public static final int DELETE_LEFT = 4;
}

这样写虽然能用,但有两个问题:

  • 参数值不受约束,随便传个 5、6 也能进来,容易出错
  • 可读性一般,维护起来麻烦

用枚举就很优雅了,直接把所有可能的取值都列出来:

java
public enum RoundingMode {
    DOWN, UP, HALF_UP, DELETE_LEFT;
}

每个名称其实就是一个常量对象,类型就是 RoundingMode

比如我们要写一个方法,支持不同的取整方式:

java
public static double handleData(double number, RoundingMode mode) {
    switch (mode) {
        case DOWN:
            return Math.floor(number);
        case UP:
            return Math.ceil(number);
        case HALF_UP:
            return Math.round(number);
        case DELETE_LEFT:
            return (int) number;
        default:
            throw new IllegalArgumentException("未知取整方式");
    }
}

调用的时候,参数只能是枚举里的四种,写错编译器直接报错:

java
System.out.println(handleData(3.9991, RoundingMode.DOWN));     // 3.0
System.out.println(handleData(5.9991, RoundingMode.HALF_UP));  // 6.0

枚举的本质和特点

java
public enum RoundingMode {
    DOWN, UP, HALF_UP, DELETE_LEFT;
}

其实 Java 编译器在背后会帮你生成一堆"看不见的"源代码。大致等价于下面这样(省略了部分细节,但核心结构是这样的):

java
public final class RoundingMode extends java.lang.Enum<RoundingMode> {
    // 1. 四个 public static final 的对象,分别代表每个枚举值
    public static final RoundingMode DOWN = new RoundingMode("DOWN", 0);
    public static final RoundingMode UP = new RoundingMode("UP", 1);
    public static final RoundingMode HALF_UP = new RoundingMode("HALF_UP", 2);
    public static final RoundingMode DELETE_LEFT = new RoundingMode("DELETE_LEFT", 3);

    // 2. 用于存放所有枚举值的数组
    private static final RoundingMode[] VALUES = {DOWN, UP, HALF_UP, DELETE_LEFT};

    // 3. 构造器是私有的,外部不能 new
    private RoundingMode(String name, int ordinal) {
        super(name, ordinal);
    }

    // 4. 返回所有枚举值
    public static RoundingMode[] values() {
        return VALUES.clone();
    }

    // 5. 通过名字查找枚举对象
    public static RoundingMode valueOf(String name) {
        for (RoundingMode mode : VALUES) {
            if (mode.name().equals(name)) {
                return mode;
            }
        }
        throw new IllegalArgumentException("No enum constant: " + name);
    }
}
  • values() 方法返回所有枚举对象的数组。
  • valueOf(String) 可以通过名字查找对应的枚举对象。

每个枚举值(DOWN、UP、HALF_UP、DELETE_LEFT)其实就是 public static final 的对象,系统帮你 new 好了。

构造器是私有的,外部 new 不出来。

这个类还自动继承了 java.lang.Enum,所以有很多和枚举相关的内置方法,比如 name()ordinal() 等。

可以,这一块原始信息其实是够的,主要问题就是:

  • 概念、好处、分类混在一起
  • 泛型方法里静态和非静态的逻辑没收好
  • 泛型接口的课上补充是碎片
  • 通配符那部分有一句容易误导:List<? extends Number> list1; // 只能放 Number 及其子类 这句不严谨,应该说“只能接收 Number 或其子类作为泛型实参”,不是“只能放”

泛型

泛型是 JDK5 引入的一种新特性。
它的作用是:在编译阶段约束操作的数据类型,并提前检查类型问题。

泛型就是在定义类、接口、方法时,先不把类型写死,而是用一个类型变量先占住位置,等真正使用时再指定具体类型。

例如:

java
ArrayList<String> list = new ArrayList<>();
list.add("张三");
// list.add(123); // 编译报错

ArrayList<String> 已经把元素类型限定成了 String,所以后面如果再放 Integer,编译阶段就会直接报错。

泛型最直接的好处有两个。

第一,统一数据类型
容器中保存什么类型的数据,可以提前约束清楚,代码会更规范。

第二,把运行期错误提前到编译期
以前很多类型转换问题,要等运行时才报错;有了泛型之后,很多问题在编译时就已经拦下来了。

泛型如果没有指定具体类型,默认按 Object 处理。

例如:

java
ArrayList list = new ArrayList();
list.add("abc");
list.add(123);

这种写法本质上就相当于在操作 Object 类型,
什么都能放,但类型约束也就没了。

因此,实际开发中一般都建议把泛型写完整,不要省略。

另外还要注意一点:

泛型只能支持引用类型,不能直接使用基本数据类型。

例如不能写:

java
ArrayList<int> list = new ArrayList<>(); // 报错

如果要使用基本类型,就要写对应的包装类:

  • int -> Integer
  • double -> Double
  • char -> Character

泛型类

如果一个类在定义时就把类型设计成可变的,这种类就叫泛型类。

定义格式:

java
public class MyArrayList<E> {
    public void add(E e) {
    }

    public void remove(E e) {
    }
}

这里的 E 就是类型变量,表示当前类操作的数据类型暂时不确定,等创建对象时再指定。

使用时:

java
MyArrayList<String> list = new MyArrayList<>();
list.add("张无忌");
list.add("赵敏");
list.remove("张无忌");

这时 E 就会被替换成 String
也就是说,这个对象后续操作的数据类型就固定成了 String

常见的泛型标识符有:

  • E:Element,元素
  • T:Type,类型
  • K:Key,键
  • V:Value,值

这些字母本身没有强制要求,只是一种习惯写法。

泛型方法

有时候,不是整个类都需要泛型,而只是某个方法想写得更通用。
这时候就可以使用泛型方法。

例如:

java
public static <T> void print(T t) {
    System.out.println(t);
}

使用时:

java
print("hello");
print(123);
print(3.14);

这里的 T 不是类上的泛型,而是这个方法自己声明的泛型。
也就是说,这个方法每次调用时,都会根据传入的实际参数来匹配具体类型。

1. 非静态方法

如果类本身已经定义了泛型,那么非静态方法一般直接跟着类的泛型走。

因为类的泛型,是在创建对象的时候就已经确定好的。

例如:

java
class Box<T> {
    public void show(T t) {
        System.out.println(t);
    }
}

这里 show() 方法里的 T,跟的就是类上的 T

2. 静态方法

静态方法不能直接使用类上的泛型。
因为类上的泛型,要等创建对象时才能确定,而静态方法是跟类一起加载的,这时对象还没创建出来。

所以如果静态方法想使用泛型,就必须声明自己的独立泛型。

例如:

java
class Box<T> {
    public static <E> void print(E e) {
        System.out.println(e);
    }
}

这里的 <E> 是静态方法自己声明的泛型,
不再依赖类上的 T

这一点可以直接记成一句:

静态方法如果要用泛型,必须单独声明自己的泛型。

泛型接口

如果一个接口中的数据类型也不确定,就可以把接口定义成泛型接口。

定义格式:

java
public interface Inter<E> {
    void show(E e);
}

对于泛型接口,实现类一般有两种处理方式。

1. 实现接口时,直接确定具体类型

java
class InterAImpl implements Inter<String> {
    @Override
    public void show(String s) {
        System.out.println(s);
    }
}

这时候接口中的 E 就直接固定成了 String

2. 实现接口时,不确定类型,让泛型继续跟着类走

java
class InterBImpl<E> implements Inter<E> {
    @Override
    public void show(E e) {
        System.out.println(e);
    }
}

这里实现类自己也带了泛型,
所以接口中的 E 不会立刻确定,而是继续跟着实现类的泛型一起变化。

使用时:

java
InterBImpl<String> i1 = new InterBImpl<>();
i1.show("abc");

InterBImpl<Integer> i2 = new InterBImpl<>();
i2.show(123);

所以泛型接口的处理思路可以直接记成:

  • 要么实现时直接把类型定死
  • 要么让泛型继续跟着实现类一起变化

通配符

有时候,泛型的具体类型不确定,但我们又希望它能接收一类对象。
这时候就可以使用通配符。

最基本的通配符是:

java
?

它表示任意类型

例如:

java
public void printList(List<?> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}

这里 List<?> 表示:
只要是 List,至于里面具体装的是什么类型,这里不关心。

泛型的上下限

在通配符的基础上,还可以进一步限制范围。

1. 上限:? extends E

表示只能接收 EE 的子类作为泛型实参。

例如:

java
List<? extends Number> list;

这表示 list 的泛型类型可以是:

  • Number
  • Integer
  • Double

这些都可以,因为它们都是 Number 或其子类。

2. 下限:? super E

表示只能接收 EE 的父类作为泛型实参。

例如:

java
List<? super Integer> list;

这表示 list 的泛型类型可以是:

  • Integer
  • Number
  • Object

因为这些都是 Integer 或它的父类。

所以这一块可以简单记成:

  • ? extends E:向下兼容,接收 E 及其子类
  • ? super E:向上兼容,接收 E 及其父类

现阶段先有这个印象就够了,后面结合集合再看会更清楚。

泛型只在编译阶段有效。
编译之后,字节码文件中并不会真正保留这些具体的泛型信息,这种现象叫作泛型擦除

这一点现阶段不用展开太深,先知道就行。
你只要记住:

泛型的主要作用,是在编译阶段做类型检查。

评论