深浅模式
继承
在写类的时候,每个类通常只描述一种具体事物。在实际开发中,不同类之间往往会出现一些重复的内容。
比如:
动物:都有 年龄、名字、吃东西
狼:会狩猎
狗:会看家如果每个类都把这些内容重新写一遍,代码就会显得很重复。一旦后面要改公共逻辑(比如吃东西的方法),也得一个个类去改,维护起来很麻烦。
这时候就可以考虑继承。
继承本质上是让类和类之间建立父子关系,把多个类中的共性内容提取到父类中,子类直接使用。
这样做最直接的好处,就是提高代码复用性。
当多个类中存在相同内容,并且这些类之间又满足 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是父类,也叫基类、超类;Wolf和Dog就是子类,也叫派生类。
当 Wolf extends Animal 这样写之后,就表示 Wolf 和 Animal 之间建立了继承关系。
建立继承关系后,子类就可以直接使用父类中非私有的成员。
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();
}
}Wolf 和 Dog 虽然没有重新声明 name、age、eat() 和 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);
}
}在没有用关键字指明的情况下,成员的查找会遵循就近原则:
- 先找当前方法中的局部变量,也就是
showName()方法中的局部变量name - 再找本类中的成员变量,是
Wolf类中的成员变量name - 最后再找父类中的成员变量,是
Animal类中的成员变量name
为了把访问目标写得更明确,Java 提供了这两个关键字:
- this:表示当前对象,用来访问本类成员
- super:表示从父类继承下来的那部分,用来访问父类成员
所以,在上面那段代码中,如果调用 showName(),输出结果就是:
txt
局部名字
灰狼
动物方法覆写
子类在继承父类之后,不仅可以继承父类的成员变量,也可以继承父类的方法。
但在实际开发中,父类提供的方法往往只是一个通用版本,子类有时需要根据自己的特点,写出更符合自身情况的实现。
例如,动物都会吃东西,所以父类中可以先定义一个 eat() 方法。
但具体到不同的动物,吃东西的方式又不一样:
- 狼吃生肉
- 狗吃狗粮
如果子类直接使用父类中的 eat(),显然就不够具体。
这时候,就需要子类对父类的方法进行重写。
所谓方法重写,就是:子类中定义一个与父类同名、同参数、同返回值的方法,对父类原有的方法实现进行重新定义。
先看父类中的方法:
java
class Animal {
public void eat() {
System.out.println("吃东西");
}
}如果 Wolf 和 Dog 都直接继承这个方法,那么它们调用 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 不是必须写的,不写代码也能运行。
但一般都建议写上,因为它可以让编译器帮忙检查当前方法是不是真的在重写父类方法。
注意事项
在重写方法时,除了满足以下基本条件的前提下:
- 方法名相同
- 参数列表相同
- 返回值类型相同
还有几个点需要特别注意。
- 父类中的私有方法不能被重写
如果父类中的方法是 private 修饰的,那么这个方法对子类来说是不可见的,自然也就不能被重写。
- 子类重写方法时,访问权限不能更低
子类在重写父类方法时,访问权限必须大于等于父类方法的访问权限,不能缩小。
例如,父类方法是 public,那么子类重写时也必须是 public。
如果子类把它改成 protected 或者不写访问修饰符,权限就变小了,这种写法是不允许的。
原因也不难理解。
父类原本把这个方法对外开放到了某个程度,子类重写后,不能反而把它缩得更严,不然就破坏了原本的使用规则。
protected 访问修饰符
前面学习权限修饰符时,private 和 public 都比较好理解:
private:只能在本类中访问public:哪里都可以访问
而 protected 会稍微特殊一点。
它既不像 private 那样完全封闭,也不像 public 那样完全开放,而是处在中间的一种权限范围。
protected修饰的成员,既可以在本类中访问,也可以在子类中访问。
这也是 protected 最常见的使用场景:
某个成员不希望随便暴露给所有外部类,但希望子类能够继承并使用。
还是沿用前面的动物例子。
假设 Animal 类中有名字和年龄这两个属性。
这些属性对于动物本身来说很重要,对于子类 Wolf、Dog 来说也同样要用到。
但如果直接用 public 修饰,又显得开放得太大;如果用 private 修饰,子类又不能直接访问。
这时候,protected 就比较合适了。
java
class Animal {
protected String name;
protected int age;
protected void eat() {
System.out.println("吃东西");
}
}这里的 name、age 和 eat() 都使用了 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 还可以继续继承别的类,这就形成了多层继承。
还有几个细节需要理清的:
- 父类中的构造方法,能不能也被子类继承下来直接使用?
答案是不可以。
因为构造方法有一个特点:方法名必须和类名完全一致。
而子类和父类本来就是两个不同的类,类名不同,所以父类的构造方法不可能直接继承给子类。
也就是说,子类可以继承父类中的普通成员,但构造方法不能被继承。
- 子类在创建对象时,父类中的成员要不要先完成初始化?
答案是要。
因为子类对象中,本身就包含从父类继承下来的那一部分成员。
例如 Wolf 继承了 Animal,那么 Wolf 对象中其实也包含父类的 name、age 这些成员。
如果父类中的成员都还没有初始化好,子类自然也没法正常使用它们。
而要初始化,就得使用构造方法。但父类构造方法不能继承,那父类是怎么初始化的?
- 父类构造方法不能继承,那父类是怎么初始化的
答案是:
子类在构造方法中,会先调用父类的构造方法。
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 对象时,更合理的做法就是:
- 把
name和age交给父类构造方法初始化 - 把
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这个结果其实没法直观看出对象中的 name 和 age。所以在开发中,直接输出对象而得到默认结果,通常意义不大。
这时候,就很适合对 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 标准库中的 String 和 Math 类都是 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();
}抽象类的特性
在此基础上,还需要进一步认识抽象类的几个特点。
- 抽象类不能创建对象:
抽象类不能直接使用 new 来实例化抽象类。
Java
abstract class Animal {
public abstract void eat();
}抽象类中可能包含抽象方法,而抽象方法只有方法声明,没有具体实现。
Java
Animal a = new Animal(); // 报错如果一个类中还存在抽象方法,就说明这个类本身还不完整。
而 Java 是一种比较严格的语言,不允许直接创建不完整的对象,因此抽象类不能实例化。
因此,抽象类更适合作为父类存在,用来让子类继承,而不是自己直接拿来创建对象。
- 抽象类中可以有构造方法
虽然抽象类不能直接创建对象,但它仍然可以有构造方法。
前面提到,子类在创建对象时,会先通过 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("狼吃肉");
}
}总之,抽象类中的构造方法,主要是给子类初始化继承下来的那部分成员用的。
- 抽象类中可以存在普通方法
抽象类中不只是能写抽象方法,也可以写普通方法。
抽象方法用来规定子类必须具备哪些行为。
普通方法则可以把已经明确的共性实现直接写在父类里,供子类继承后直接使用。
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() 则不同。
虽然所有动物都要吃东西,但具体吃什么并不明确,所以只能定义成抽象方法,交给子类自己实现。
抽象类既可以保留已经明确的共性,也可以声明暂时无法确定实现的方法。
- 抽象类的子类,要么全部重写,要么继续抽象
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() 还没有实现,抽象方法仍然存在。
而抽象方法必须存在于抽象类中,所以这个类就不能作为普通类直接实例化。
总之,抽象类的子类,不能只重写一部分就当普通类来用。只要还有抽象方法没实现,这个子类就必须继续是抽象类。
模板方法设计模式
前面学习抽象类时提到过,父类可以先写出共性的内容,把暂时无法确定的部分定义为抽象方法,交给子类去实现。
模板方法设计模式,就是把这种思想进一步固定下来的一种写法。
先说什么是设计模式。
所谓设计模式,可以先简单理解成:
前人针对一些常见开发问题,总结出来的一套比较成熟、比较常用的解决思路。
这些思路因为反复被证明有效,所以被很多程序员长期使用,慢慢就形成了比较固定的写法。
这种被反复总结和使用的经验,就叫设计模式。
就像作文模板一样,开头和结尾往往比较固定,真正会变化的,通常是中间根据题目展开的部分。
放到代码里也是一样。
有些功能在执行时,整体流程是固定的,但流程中的某一步或者某几步,具体实现又可能不同。
这时候,就可以把整个流程先定义在父类中,作为一个“模板”;而模板中那些暂时不能确定的部分,再定义成抽象方法,让子类自己补全。
假设现在要描述“动物进食”这件事。
从整体上看,进食流程是有一定共性的,比如:
- 先准备食物
- 再吃东西
- 最后结束进食
这个流程本身是固定的。但真正变化的地方在于,不同动物吃的内容不一样:
- 狼吃肉
- 狗吃狗粮
这时候,就可以把“整个进食流程”写在父类中,再把其中变化的部分交给子类去完成。
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("狗在游泳");
}
}这里 Dog 和 Swim 之间不是“狗是一种 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 中定义类型的一种方式,是一种更纯粹的规则定义。
接口的成员特点
接口本质上是一种更纯粹的规则定义。正因为它主要负责“规定要做什么”,所以它内部的成员也有自己的特点。
- 成员变量只能是常量
接口中的成员变量,本质上只能是常量。接口中的成员变量默认会自动加上:
java
public static final例如:
java
public interface Swim {
int MAX_SPEED = 20;
}上面的 MAX_SPEED 实际上等价于:
java
public static final int MAX_SPEED = 20;接口中的成员变量必须有值,而且值不能再改。
- 成员方法在基础阶段可以先看成抽象方法
接口中的成员方法理解成抽象方法。因为接口中的成员方法默认会自动加上:
java
public abstract例如:
java
public interface Swim {
void swim();
}上面的 swim() 实际上等价于:
java
public abstract void swim();不过在 JDK8 和 JDK9 之后又增加了一些新特性,下节细谈。
- 接口中没有构造方法
接口不能创建对象,本身也不负责对象初始化,所以接口中没有构造方法。
这一点和抽象类不同。
抽象类虽然也不能直接实例化,但它仍然是类,所以可以有构造方法,供子类通过 super(...) 调用。
而接口连“类”都不是,它只是规则定义,因此不存在构造方法。
类和接口之间的关系
类和接口之间一共会出现三种常见关系。
- 类能继承类
类和类之间是继承关系,使用 extends 表示。类和类之间只能单继承,但可以多层继承。
java
class Animal {
}
class Dog extends Animal {
}- 类能实现接口
类和接口之间是实现关系,使用 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("狼会狩猎");
}
}这也是接口很重要的一点:它在一定程度上弥补了类只能单继承的限制。
- 接口和接口可以多继承
接口和接口之间也是继承关系,同样使用 extends 表示。不过接口和接口之间,不仅可以单继承,也可以多继承。
例如:
java
interface Swim {
void swim();
}
interface Hunt {
void hunt();
}
interface WolfAbility extends Swim, Hunt {
}这里 WolfAbility 同时继承了 Swim 和 Hunt 两个接口。
总结来看,接口和类的区别主要集中于:
成员上的区别
先看语法上最直接的区别。
- 成员变量
- 抽象类:可以定义普通变量,也可以定义常量
- 接口:只能定义常量
- 成员方法
- 抽象类:可以有普通方法,也可以有抽象方法
- 接口:在基础阶段,可以先看成只能有抽象方法
- 构造方法
- 抽象类:有构造方法
- 接口:没有构造方法
设计思想上的区别
抽象类更偏向于对一类事物进行抽象,重点是在描述共性。比如动物、交通工具、员工,这些都更适合用抽象类来表示。
接口更偏向于对行为、能力进行抽象,重点是在制定规则。比如会游泳、会飞、会狩猎,这些都更适合用接口来表示。
接口新特性
之前为了便于理解,先把接口中的方法看成了抽象方法。但这只是基础阶段的认识。随着 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() 方法,狼有狼的吃法,狗有狗的吃法。调用语句看起来一样,但实际执行的逻辑不一样,这就是多态。
多态的成立条件
多态成立要满足三个条件,先留一个认知即可:
多态成立需要满足三个条件:
- 有继承或实现关系
- 有方法重写
- 有父类引用指向子类对象
典型写法如下:
java
Animal a = new Wolf();左边是父类引用,右边是子类对象。对象实际还是子类对象,只是用父类类型的变量去接收它。
- 要有继承或实现关系
也就是类和类之间,或者类和接口之间,得先有关系。
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("狗在游泳");
}
}没有这层父子关系,后面根本谈不上多态。
- 要有方法重写
如果子类没有重写父类方法,那就算父类引用指向子类对象,最后执行的还是父类原来的逻辑,多态效果就不明显了。
java
class Animal {
public void eat() {
System.out.println("动物在吃东西");
}
}
class Wolf extends Animal {
@Override
public void eat() {
System.out.println("狼在吃肉");
}
}这里 Wolf 重写了 eat(),后面才有“同样的调用,结果不同”这件事。
- 要有父类引用指向子类对象
这是多态最典型的写法。
java
Animal a = new Wolf();左边是父类引用,右边是子类对象。这句一出来,多态的架子就搭起来了。
注意,这里不是说“子类对象变成父类对象了”。
而是说:
对象实际还是子类对象,只是用父类类型的变量去接它。
多态的成员访问特点
在之前我们最常写的是这种:
java
Wolf w = new Wolf();这是“子类引用指向子类对象”,很直接。拿的是狼的引用,指向的也是狼对象,所以访问成员时基本没什么绕的。
而多态是这样:
java
Animal a = new Wolf();这时候,对象还是那只狼,但你手里拿的是 Animal 类型的引用。从这里开始,就会出现一个很关键的现象:
访问成员时,要同时考虑“左边引用类型”和“右边实际对象类型”。
这也是多态这部分最容易乱掉的地方。这里一定要先记住一句话:
左边,看的是引用类型。右边,看的是实际对象类型。
- 左边是
Animal,是引用类型 - 右边是
Wolf,是实际对象类型
下面分三种情况访问。
- 访问成员变量:编译看左边,运行也看左边
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
- 访问成员方法:编译看左边,运行看右边
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()。
所以访问成员方法的规律就是:
编译看左边,运行看右边。
- 静态成员:编译看左边,运行也看左边
静态成员本来就不属于对象,而是属于类。所以它本质上就不参与多态。
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("执行静态代码块");
}
}它会在类加载时执行,而且只执行一次。
因为一个类的字节码文件通常只会加载一次,所以静态代码块也只会跟着执行一次。
它的主要作用是:
在类加载时完成一些静态资源或静态变量的初始化。
静态代码块是代码块里最常见、也最需要重点掌握的一种。
执行顺序
代码块这部分执行顺序如下:
静态代码块 → 构造代码块 → 构造方法
再说完整一点:
- 类加载时,先执行静态成员变量初始化,再执行静态代码块
- 创建对象时,先执行普通成员变量初始化,再执行构造代码块,最后执行构造方法
也就是说:
- 静态代码块:随着类加载执行,只执行一次
- 构造代码块:每创建一个对象,就执行一次
- 构造方法:每创建一个对象,就执行一次,而且晚于构造代码块
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("喝咖啡");
}
}这种写法导致:
- 顾客类与基础咖啡机类强耦合,想替换为高级咖啡机必须修改代码
- 测试困难,无法注入测试专用的模拟对象
- 违反单一职责原则,顾客类不应该关心如何创建咖啡机
依赖注入通过解耦解决了这些问题,它让每个类专注于自己的职责,提高了代码的灵活性和可维护性。
有几种常见方式:
构造函数注入
通过构造方法将依赖对象传递给需要它的类:
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 中的内部类主要有四种:
- 成员内部类
- 静态内部类
- 局部内部类
- 匿名内部类(重点)
成员内部类
就像类里可以有成员变量、成员方法一样,类里也可以再放一个类。这就是成员内部类,定义在类中、方法外的内部类。
它和成员变量、成员方法一样,都属于外部类的成员。
内部类和外部类之间的关系会比较特殊:
- 内部类可以直接访问外部类中的成员
- 外部类如果想访问内部类中的成员,一般需要先创建内部类对象
这也是内部类最基础的访问特点。
- 内访外:直接访问
成员内部类访问外部类中的成员时,可以直接访问,就算被 private 修饰。
java
class Outer {
private int num = 10;
class Inner {
public void show() {
System.out.println(num);
}
}
}这里 Inner 中的 show() 方法可以直接访问外部类中的 num。
- 外访内:需要创建对象访问
外部类如果想访问成员内部类中的成员,通常需要先创建内部类对象,再进行访问。成员内部类创建对象的格式是:
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());这套写法没有问题。
但如果这个实现类只是为了这一次调用临时用一下,专门再写一个类,就会显得有些麻烦。
因为本来只是想传一个对象进去调用方法,结果却要额外经历下面几步:
- 写一个实现类
- 重写接口中的方法
- 创建实现类对象
- 再把对象传进去
如果这种对象只使用一次,这样写就有点啰嗦了。这时候,就可以使用匿名内部类。
匿名内部类,本质上是一种特殊的局部内部类。它没有单独的类名,而是把“定义实现类”和“创建对象”这两件事合在一起写。
可以先把它简单理解成:
不再单独写实现类,而是在
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 还可以继续简化。常见的简化规则有三条:
- 参数类型可以省略
因为函数式接口中抽象方法的参数类型,本来就已经确定了,编译器可以根据上下文推断出来,所以参数类型通常可以不写。
- 如果只有一个参数,小括号也可以省略
注意,这一条有前提:只能是一个参数。如果没有参数,还是要写 ();如果有多个参数,也不能省略括号。
- 如果方法体只有一行代码,可以省略大括号
这时候写法会更紧凑。但如果这一行代码本来是 return 语句,那么在省略大括号的同时,return 也必须一起省略,只保留返回的表达式。
也就是说:
- 保留大括号时,
return正常写 - 省略大括号时,
return也要一起去掉
- 无参、无返回值
先看前面“无参、无返回值”的例子。
java
Hunt h = () -> {
System.out.println("狼开始狩猎");
};这里的方法没有参数,所以左边必须写成 (),这一点不能省。但右边的方法体只有一行代码,因此可以继续简化成:
java
Hunt h = () -> System.out.println("狼开始狩猎");这个例子体现的是第 3 条规则:
- 方法体只有一行代码
- 可以省略大括号
- 有一个参数、无返回值
再看前面“一个参数、无返回值”的例子。
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);这个例子把三条规则都体现出来了:
- 参数类型可以省略
- 单参数时括号可以省略
- 方法体只有一行时,大括号可以省略
- 有参数、有返回值
最后看前面“有参数、有返回值”的例子。
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 和匿名内部类完全一样,它们之间还是有明显区别的。
- 使用限制不同
匿名内部类的使用范围更大。它既可以基于类来写,也可以基于接口来写,匿名内部类本质上做的事情有两种:
- 继承一个类,临时创建子类对象
- 实现一个接口,临时创建实现类对象
而 Lambda 表达式的使用范围更窄。它只能用于函数式接口,不能直接用于普通类、抽象类,也不能用于有多个抽象方法的接口。
所以这一点可以直接记成:
- 匿名内部类:可以操作类、抽象类、接口
- Lambda 表达式:只能操作函数式接口
也正因为这样,Lambda 并不是匿名内部类的“通用替代品”,它只能替代其中一部分场景。
- 实现方式不同
匿名内部类在编译之后,通常会生成一个独立的 .class 字节码文件。比如在某个类里写了一个匿名内部类,编译后常常会看到类似:
java
Outer$1.class这样的文件。
这说明匿名内部类本质上仍然是一个真实存在的类,只是这个类没有手动起名字,编译器帮它生成了名字。
而 Lambda 表达式就不是这样。
Lambda 虽然看起来也像是在“临时写实现”,但它编译后通常不会像匿名内部类那样直接对应一个独立的 .class 文件。
它更多是通过 Java 在底层提供的机制,在运行时把这段行为组织起来。所以从实现角度来说,Lambda 更像是在描述“一段要执行的逻辑”,而不是显式写出一个匿名类。
这一点可以简单记成:
- 匿名内部类:编译后通常会生成独立的内部类字节码文件
- Lambda 表达式:通常不会直接生成对应的独立匿名类字节码文件
- 本质侧重点不同
匿名内部类本质上仍然是“写一个类”。只不过这个类没有名字,而且通常只用一次。
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 其实什么新逻辑都没写,只是把 w1 和 w2 原样传给 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));这里本质上也没有额外逻辑,
只是把参数交给对象 wc 的 compare() 方法处理。
所以它同样可以继续简化成方法引用:
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->Integerdouble->Doublechar->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
表示只能接收 E 或 E 的子类作为泛型实参。
例如:
java
List<? extends Number> list;这表示 list 的泛型类型可以是:
NumberIntegerDouble
这些都可以,因为它们都是 Number 或其子类。
2. 下限:? super E
表示只能接收 E 或 E 的父类作为泛型实参。
例如:
java
List<? super Integer> list;这表示 list 的泛型类型可以是:
IntegerNumberObject
因为这些都是 Integer 或它的父类。
所以这一块可以简单记成:
? extends E:向下兼容,接收E及其子类? super E:向上兼容,接收E及其父类
现阶段先有这个印象就够了,后面结合集合再看会更清楚。
泛型只在编译阶段有效。
编译之后,字节码文件中并不会真正保留这些具体的泛型信息,这种现象叫作泛型擦除。
这一点现阶段不用展开太深,先知道就行。
你只要记住:
泛型的主要作用,是在编译阶段做类型检查。

评论