《Java 编程的逻辑》笔记——第5章 类的扩展

声明:

本博客是本人在学习《Java 编程的逻辑》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

5.1 接口的本质

5.1.1 数据类型的局限

之前我们一直在说,程序主要就是数据以及对数据的操作,而为了方便操作数据,高级语言引入了数据类型的概念,Java 定义了八种基本数据类型,而类相当于是自定义数据类型,通过类的组合和继承可以表示和操作各种事物或者说对象。

但,这种只是将对象看做属于某种数据类型,并按该类型进行操作,在一些情况下,并不能反映对象以及对对象操作的本质

为什么这么说呢?很多时候,我们实际上关心的,并不是对象的类型,而是对象的能力,只要能提供这个能力,类型并不重要。我们来看一些生活中的例子。

要拍个照片,很多时候,只要能拍出符合需求的照片就行,至于是用手机拍,还是用 Pad 拍,或者是用单反相机拍,并不重要,关心的是对象是否有拍出照片的能力,而并不关心对象到底是什么类型,手机、Pad 或单反相机都可以。

要计算一组数字,只要能计算出正确结果即可,至于是由人心算,用算盘算,用计算器算,用电脑软件算,并不重要,关心的是对象是否有计算的能力,而并不关心对象到底是算盘还是计算器。

要将冷水加热,只要能得到热水即可,至于是用电磁炉加热,用燃气灶加热,还是用电热水壶,并不重要,重要的是对象是否有加热水的能力,而并不关心对象到底是什么类型。

在这些情况中,类型并不重要,重要的是能力。那如何表示能力呢?

5.1.2 接口概念

Java 使用接口这个概念来表示能力。

接口这个概念在生活中并不陌生,电子世界中一个常见的接口就是 USB 接口。电脑往往有多个 USB 接口,可以插各种 USB 设备,可以是键盘、鼠标、U盘、摄像头、手机等等。

接口声明了一组能力,但它自己并没有实现这个能力,它只是一个约定,它涉及交互两方对象,一方需要实现这个接口,另一方使用这个接口,但双方对象并不直接互相依赖,它们只是通过接口间接交互。如图 5-1 所示。

在这里插入图片描述

拿上面的 USB 接口来说,USB 协议约定了 USB 设备需要实现的能力,每个 USB 设备都需要实现这些能力,电脑使用 USB 协议与 USB 设备交互,电脑和 USB 设备互不依赖,但可以通过 USB 接口相互交互。

下面我们来看 Java 中的接口。

5.1.3 定义接口

我们通过一个例子来说明 Java 中接口的概念。

这个例子是"比较",很多对象都可以比较,对于求最大值、求最小值、排序的程序而言,它们其实并不关心对象的类型是什么,只要对象可以比较就可以了,或者说,它们关心的是对象有没有可比较的能力。Java API 中提供了 Comparable 接口,以表示可比较的能力,但它使用了泛型,而我们还没有介绍泛型,所以本节,我们自己定义一个 Comparable 接口,叫 MyComparable。

现在,首先,我们来定义这个接口,代码如下:

public interface MyComparable {
    
    
    int compareTo(Object other);
}

解释一下:

  • Java 使用 interface 这个关键字来声明接口,修饰符一般都是 public。
  • interface 后面就是接口的名字 MyComparable。
  • 接口定义里面,声明了一个方法 compareTo,但没有定义方法体,接口都不实现方法。接口方法不需要加修饰符,加与不加都是 public 的,不能是别的修饰符。

再来解释一下 compareTo 方法:

  • 方法的参数是一个 Object 类型的变量 other,表示另一个参与比较的对象。
  • 第一个参与比较的对象是自己
  • 返回结果是 int 类型,-1 表示自己小于参数对象,0 表示相同,1 表示大于参数对象

接口与类不同,它的方法没有实现代码。定义一个接口本身并没有做什么,也没有太大的用处,它还需要至少两个参与者,一个需要实现接口,另一个使用接口,我们先来实现接口。

5.1.4 实现接口

类可以实现接口,表示类的对象具有接口所表示的能力。我们来看一个例子,以前面介绍过的 Point 类来说明,我们让 Point 具备可以比较的能力,Point 之间怎么比较呢?我们假设按照与原点的距离进行比较,下面是 Point 类的代码:

public class Point implements MyComparable {
    
    
    private int x;
    private int y;
    
    public Point(int x, int y) {
    
    
        this.x = x;
        this.y = y;
    }
    
    public double distance(){
    
    
        return Math.sqrt(x*x+y*y);
    }

    @Override
    public int compareTo(Object other) {
    
    
        if(!(other instanceof Point)){
    
    
            throw new IllegalArgumentException();
        }
        Point otherPoint = (Point)other;
        double delta = distance() - otherPoint.distance();
        if(delta<0){
    
    
            return -1;
        }else if(delta>0){
    
    
            return 1;
        }else{
    
    
            return 0;
        }
    }

    @Override
    public String toString() {
    
    
        return "("+x+","+y+")";
    }
}

我们解释一下:

  • Java 使用 implements 这个关键字表示实现接口,前面是类名,后面是接口名。
  • 实现接口必须要实现接口中声明的方法,Point 实现了 compareTo 方法。

我们再来解释一下 Point 的 compareTo 实现:

  • Point 不能与其他类型的对象进行比较,它首先检查要比较的对象是否是 Point 类型,如果不是,使用 throw 抛出一个异常,异常我们还没提到,后续文章讲解,此处可以忽略。
  • 如果是 Point 类型,使用强制类型转换将 Object 类型的参数 other 转换为 Point 类型的参数 otherPoint。
  • 这种显式的类型检查和强制转换是可以使用泛型机制避免的,后续文章我们再介绍泛型。

一个类可以实现多个接口,表明类的对象具备多种能力,各个接口之间以逗号分隔,语法如下所示:

public class Test implements Interface1, Interface2 {
    
    

....

}

定义和实现了接口,接下来我们来看怎么使用接口。

5.1.5 使用接口

与类不同,接口不能 new,不能直接创建一个接口对象,对象只能通过类来创建。但可以声明接口类型的变量,引用实现了接口的类对象。比如说,可以这样:

MyComparable p1 = new Point(2,3);
MyComparable p2 = new Point(1,2);
System.out.println(p1.compareTo(p2));

p1 和 p2 是 MyComparable 类型的变量,但引用了 Point 类型的对象,之所以能赋值是因为 Point 实现了 MyComparable 接口。如果一个类型实现了多个接口,那这种类型的对象就可以被赋值给任一接口类型的变量。

p1 和 p2 可以调用 MyComparable 接口的方法,也只能调用 MyComparable 接口的方法,实际执行时,执行的是具体实现类的代码。

为什么 Point 类型的对象非要赋值给 MyComparable 类型的变量呢?在以上代码中,确实没必要。但在一些程序中,代码并不知道具体的类型,这才是接口发挥威力的地方,我们来看下面使用 MyComparable 接口的例子。

public class CompUtil {
    
    
    public static Object max(MyComparable[] objs){
    
    
        if(objs==null||objs.length==0){
    
    
            return null;
        }
        MyComparable max = objs[0];
        for(int i=1;i<objs.length;i++){
    
    
            if(max.compareTo(objs[i])<0){
    
    
                max = objs[i];
            }
        }
        return max;
    }
    
    public static void sort(MyComparable[] objs){
    
    
        for(int i=0;i<objs.length;i++){
    
    
            int min = i;
            for(int j=i+1;j<objs.length;j++){
    
    
                if(objs[j].compareTo(objs[min])<0){
    
    
                    min = j;
                }
            }
            if(min!=i){
    
    
                 MyComparable temp = objs[i];
                 objs[i] = objs[min];
                 objs[min] = temp;
            }
        }
    }
}

类 CompUtil 提供了两个方法,max 获取传入数组中的最大值,sort 对数组升序排序,参数都是 MyComparable 类型的数组。max 的代码是比较容易理解的,不再解释,sort 使用的是简单选择排序。

可以看出,这个类是针对 MyComparable 接口编程,它并不知道具体的类型是什么,也并不关心,但却可以对任意实现了 MyComparable 接口的类型进行操作。我们来看下对 Point 类型进行操作,代码如下:

Point[] points = new Point[]{
    
    
        new Point(2,3),
        new Point(3,4),
        new Point(1,2)
};
System.out.println("max: " + CompUtil.max(points));
CompUtil.sort(points);
System.out.println("sort: "+ Arrays.toString(points));

创建了一个 Point 类型的数组 points,然后使用 CompUtil 的 max 方法获取最大值,使用 sort 排序,并输出结果,输出如下:

max: (3,4)
sort: [(1,2), (2,3), (3,4)]

这里演示的是对 Point 数组操作,实际上可以针对任何实现了 MyComparable 接口的类型数组进行操作。

这就是接口的威力,可以说,针对接口而非具体类型进行编程,是计算机程序的一种重要思维方式。针对接口,很多时候反映了对象以及对对象操作的本质。它的优点有很多,首先是代码复用,同一套代码可以处理多种不同类型的对象,只要这些对象都有相同的能力,如 CompUtil。

更重要的是降低了耦合,提高了灵活性。使用接口的代码依赖的是接口本身,而非实现接口的具体类型,程序可以根据情况替换接口的实现,而不影响接口使用者。解决复杂问题的关键是分而治之,分解为小问题,但小问题之间不可能一点关系没有,分解的核心就是要降低耦合,提高灵活性,接口为恰当分解,提供了有力的工具。

5.1.6 接口的细节

上面我们介绍了接口的基本内容,接口还有一些细节,包括:

  • 接口中的变量
  • 接口的继承
  • 类的继承与接口
  • instanceof

我们逐个来介绍下。

5.1.6.1 接口中的变量

接口中可以定义变量,语法如下所示:

public interface Interface1 {
    
    
    public static final int a = 0;
}

这里定义了一个变量 int a,修饰符是 public static final,但这个修饰符是可选的,即使不写,也是 public static final。这个变量可以通过"接口名.变量名"的方式使用,如 Interface1.a。

5.1.6.2 接口的继承

接口也可以继承,一个接口可以继承别的接口,继承的基本概念与类一样,但与类不同,接口可以有多个父接口,代码如下所示:

public interface IBase1 {
    
    
    void method1();
}

public interface IBase2 {
    
    
    void method2();
}

public interface IChild extends IBase1, IBase2 {
    
    
}

接口的继承同样使用 extends 关键字,多个父接口之间以逗号分隔。

5.1.6.3 类的继承与接口

类的继承与接口可以共存,换句话说,类可以在继承基类的情况下,同时实现一个或多个接口,语法如下所示:

public class Child extends Base implements IChild {
    
    

 //...

}

extends 要放在 implements 之前。

5.1.6.4 instanceof

与类一样,接口也可以使用 instanceof 关键字,用来判断一个对象是否实现了某接口,例如:

Point p = new Point(2,3);
if(p instanceof MyComparable){
    
    
    System.out.println("comparable");
}

5.1.7 使用接口替代继承

上节我们提到,可以使用接口替代继承。怎么替代呢?

我们说继承至少有两个好处,一个是复用代码,另一个是利用多态和动态绑定统一处理多种不同子类的对象。

使用组合替代继承,可以复用代码,但不能统一处理。使用接口,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码。将组合和接口结合起来,就既可以统一处理,也可以复用代码了。我们还是以上节的例子来说明。

我们先增加一个接口 IAdd,代码如下:

public interface IAdd {
    
    
    void add(int number);
    void addAll(int[] numbers);
}

修改 Base 代码,让他实现 IAdd 接口,代码基本不变:

public class Base implements IAdd {
    
    

//...

}

修改 Child 代码,也是实现 IAdd 接口,代码基本不变:

public class Child implements IAdd {
    
    

 //...

}

Child 复用了 Base 的代码,又都实现了 IAdd 接口。这样,就既可以复用代码,也可以统一处理,而且不用担心破坏封装了。

5.1.8 小结

本节我们谈了数据类型思维的局限,提到了很多时候关心的是能力,而非类型,所以引入了接口。介绍了 Java 中接口的概念和细节,针对接口编程是一种重要的程序思维方式,这种方式不仅可以复用代码,还可以降低耦合,提高灵活性,是分解复杂问题的一种重要工具

接口没有任何实现代码,而之前介绍的类都有完整的实现,都可以创建对象,Java 中还有一个介于接口和类之间的概念,抽象类,它有什么用呢?

5.2 抽象类

顾名思义,抽象类就是抽象的类,抽象是相对于具体而言的,一般而言,具体类有直接对应的对象,而抽象类没有,它表达的是抽象概念,一般是具体类的比较上层的父类。

比如说,狗是具体对象,而动物则是抽象概念,樱桃是具体对象,而水果则是抽象概念,正方形是具体对象,而图形则是抽象概念。下面我们通过一些例子来说明 Java 中的抽象类。

5.2.1 抽象方法和抽象类

之前我们介绍过图形类 Shape,它有一个方法 draw(),Shape 其实是一个抽象概念,它的 draw 方法其实并不知道如何实现,只有子类才知道。这种只有子类才知道如何实现的方法,一般被定义为抽象方法

抽象方法是相对于具体方法而言的,具体方法有实现代码,而抽象方法只有声明,没有实现,上节介绍的接口中的方法就都是抽象方法。

抽象方法和抽象类都使用 abstract 这个关键字来声明,语法如下所示:

public abstract class Shape {
    
    
    // ... 其他代码
    public abstract void draw();
}

定义了抽象方法的类必须被声明为抽象类,不过,抽象类可以没有抽象方法。抽象类和具体类一样,可以定义具体方法、实例变量等,它和具体类的核心区别是,抽象类不能创建对象(比如,不能使用 new Shape()),而具体类可以。

抽象类不能创建对象,要创建对象,必须使用它的具体子类。一个类在继承抽象类后,必须实现抽象类中定义的所有抽象方法,除非它自己也声明为抽象类。圆类的实现代码,如下所示:

public class Circle extends Shape {
    
    
    //...其他代码
    
    @Override
    public void draw() {
    
    
        // ....
    }
}

圆实现了 draw() 方法。与接口类似,抽象类虽然不能使用 new,但可以声明抽象类的变量,引用抽象类具体子类的对象,如下所示:

Shape shape = new Circle();
shape.draw();

shape 是抽象类 Shape 类型的变量,引用了具体子类 Circle 的对象,调用 draw 方法将调用 Circle 的 draw 代码。

5.2.2 为什么需要抽象类

抽象方法和抽象类看上去是多余的,对于抽象方法,不知道如何实现,定义一个空方法体不就行了吗,而抽象类不让创建对象,看上去只是增加了一个不必要的限制。

引入抽象方法和抽象类,是 Java 提供的一种语法工具,对于一些类和方法,引导使用者正确使用它们,减少被误用。

使用抽象方法,而非空方法体,子类就知道他必须要实现该方法,而不可能忽略。

使用抽象类,类的使用者创建对象的时候,就知道他必须要使用某个具体子类,而不可能误用不完整的父类。

无论是写程序,还是平时做任何别的事情的时候,每个人都可能会犯错,减少错误不能只依赖人的优秀素质,还需要一些机制,使得一个普通人都容易把事情做对,而难以把事情做错。抽象类就是 Java 提供的这样一种机制

5.2.3 抽象类和接口

抽象类和接口有类似之处,都不能用于创建对象,接口中的方法其实都是抽象方法。如果抽象类中只定义了抽象方法,那抽象类和接口就更像了。但抽象类和接口根本上是不同的,接口不能定义实例变量,而抽象类可以,一个类可以实现多个接口,但只能继承一个类

抽象类和接口是配合而非替代关系,它们经常一起使用,接口声明能力,抽象类提供默认实现,实现全部或部分方法,一个接口经常有一个对应的抽象类

比如说,在 Java 类库中,有:

  • Collection 接口和对应的 AbstractCollection 抽象类
  • List 接口和对应的 AbstractList 抽象类
  • Map 接口和对应的 AbstractMap 抽象类

对于需要实现接口的具体类而言,有两个选择,一个是实现接口,自己实现全部方法,另一个则是继承抽象类,然后根据需要重写方法。

继承的好处是复用代码,只重写需要的即可,需要写的代码比较少,容易实现。不过,如果这个具体类已经有父类了,那就只能选择实现接口了。

我们以一个例子来进一步说明这种配合关系,还是用前面两节中关于 add 的例子,上节引入了 IAdd 接口,代码如下:

public interface IAdd {
    
    
    void add(int number);
    void addAll(int[] numbers);
}

我们实现一个抽象类 AbstractAdder,代码如下:

public abstract class AbstractAdder implements IAdd {
    
    
    @Override
    public void addAll(int[] numbers) {
    
    
        for(int num : numbers){
    
    
            add(num);
        }
    }
}

这个抽象类提供了 addAll 方法的实现,它通过调用 add 方法来实现,而 add 方法是一个抽象方法。

这样,对于需要实现 IAdd 接口的类来说,它可以选择直接实现 IAdd 接口,或者从 AbstractAdder 类继承,如果继承,只需要实现 add 方法就可以了。这里,我们让原有的 Base 类继承 AbstractAdder,代码如下所示:

public class Base extends AbstractAdder {
    
    
    private static final int MAX_NUM = 1000;
    private int[] arr = new int[MAX_NUM];
    private int count;
    
    @Override
    public void add(int number){
    
    
        if(count<MAX_NUM){
    
    
            arr[count++] = number;    
        }
    }
}

5.2.4 小结

本节,我们谈了抽象类,相对于具体类,它用于表达抽象概念,虽然从语法上,抽象类不是必须的,但它能使程序更为清晰,减少误用,抽象类和接口经常相互配合,接口定义能力,而抽象类提供默认实现,方便子类实现接口。

在目前关于类的描述中,每个类都是独立的,都对应一个 Java 源代码文件,但在 Java 中,一个类还可以放在另一个类的内部,称之为内部类,为什么要将一个类放到别的类内部呢?

5.3 内部类的本质

5.3.1 内部类

之前我们所说的类都对应于一个独立的 Java 源文件,但一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类

为什么要放到别的类内部呢?一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁

不过,内部类只是 Java 编译器的概念,对于 Java 虚拟机而言,它是不知道内部类这回事的,每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件

也就是说,每个内部类其实都可以被替换为一个独立的类。当然,这是单纯就技术实现而言,内部类可以方便的访问外部类的私有变量,可以声明为 private 从而实现对外完全隐藏,相关代码写在一起,写法也更为简洁,这些都是内部类的好处

在 Java 中,根据定义的位置和方式不同,主要有四种内部类

  • 静态内部类
  • 成员内部类
  • 方法内部类
  • 匿名内部类

其中,方法内部类是在一个方法内定义和使用的,匿名内部类使用范围更小,它们都不能在外部使用,成员内部类和静态内部类可以被外部使用,不过它们都可以被声明为 private,这样,外部就使用不了了。

接下来,我们逐个介绍这些内部类的语法、实现原理以及使用场景。

5.3.1.1 静态内部类

语法

静态内部类与静态变量和静态方法定义的位置一样,也带有 static 关键字,只是它定义的是类,示例代码如下:

public class Outer {
    
    
    private static int shared = 100;
    
    public static class StaticInner {
    
    
        public void innerMethod(){
    
    
            System.out.println("inner " + shared);
        }
    }
    
    public void test(){
    
    
        StaticInner si = new StaticInner();
        si.innerMethod();
    }
}

外部类为 Outer,静态内部类为 StaticInner,带有 static 修饰符。语法上,静态内部类除了位置放在别的类内部外,它与一个独立的类差别不大,可以有静态变量、静态方法、成员方法、成员变量、构造方法等。

静态内部类与外部类的联系也不大(与后面其他内部类相比)。它可以访问外部类的静态变量和方法,如 innerMethod 直接访问 shared 变量,但不可以访问实例变量和方法。在类内部,可以直接使用内部静态类,如 test() 方法所示。

public 静态内部类可以被外部使用,只是需要通过 “外部类.静态内部类” 的方式使用,如下所示:

Outer.StaticInner si = new Outer.StaticInner();
si.innerMethod();

实现原理

以上代码实际上会生成两个类,一个是 Outer,另一个是 Outer$StaticInner,它们的代码大概如下所示:

public class Outer {
    
    
    private static int shared = 100;
    
    public void test(){
    
    
        Outer$StaticInner si = new Outer$StaticInner();
        si.innerMethod();
    }
    
    static int access$0(){
    
    
        return shared;
    }
}
public class Outer$StaticInner {
    
    
    public void innerMethod() {
    
    
        System.out.println("inner " + Outer.access$0());
    }
}

内部类访问了外部类的一个私有静态变量 shared,而我们知道私有变量是不能被类外部访问的,Java 的解决方法是,自动为 Outer 生成了一个非私有访问方法 access$0,它返回这个私有静态变量 shared

使用场景

静态内部类使用场景是很多的,如果它与外部类关系密切,且不依赖于外部类实例,则可以考虑定义为静态内部类。

比如说,一个类内部,如果既要计算最大值,也要计算最小值,可以在一次遍历中将最大值和最小值都计算出来,但怎么返回呢?可以定义一个类 Pair,包括最大值和最小值,但 Pair 这个名字太普遍,而且它主要是类内部使用的,就可以定义为一个静态内部类。

我们也可以看一些在 Java API 中使用静态内部类的例子:

  • Integer 类内部有一个私有静态内部类 IntegerCache,用于支持整数的自动装箱。
  • 表示链表的 LinkedList 类内部有一个私有静态内部类 Node,表示链表中的每个节点。
  • Character 类内部有一个 public 静态内部类 UnicodeBlock,用于表示一个 Unicode block。

以上这些类我们在后续文章再介绍。

5.3.1.2 成员内部类

语法

成员内部类没有 static 修饰符,少了一个 static 修饰符,但含义却有很大不同,示例代码如下:

public class Outer {
    
    
    private int a = 100;
    
    public class Inner {
    
    
        public void innerMethod(){
    
    
            System.out.println("outer a " +a);
            Outer.this.action();
        }
    }
    
    private void action(){
    
    
        System.out.println("action");
    }
    
    public void test(){
    
    
        Inner inner = new Inner();
        inner.innerMethod();
    }
}

Inner 就是成员内部类,与静态内部类不同,除了静态变量和方法,成员内部类还可以直接访问外部类的实例变量和方法,如 innerMethod 直接访问外部类私有实例变量 a。成员内部类还可以通过 “外部类.this.xxx” 的方式引用外部类的实例变量和方法,如 Outer.this.action(),这种写法一般在重名的情况下使用,没有重名的话,“外部类.this.” 是多余的。

在外部类内,使用成员内部类与静态内部类是一样的,直接使用即可,如 test() 方法所示。与静态内部类不同,成员内部类对象总是与一个外部类对象相连的,在外部使用时,它不能直接通过 new Outer.Inner() 的方式创建对象,而是要先将创建一个 Outer 类对象,代码如下所示:

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

创建内部类对象的语法是 “外部类对象.new 内部类()”,如 outer.new Inner()。

与静态内部类不同,成员内部类中不可以定义静态变量和方法 (final 变量例外,它等同于常量),下面介绍的方法内部类和匿名内部类也都不可以。Java 为什么要有这个规定呢?具体原因不得而知,个人认为这个规定不是必须的,Java 这个规定大概是因为这些内部类是与外部实例相连的,不应独立使用,而静态变量和方法作为类型的属性和方法,一般是独立使用的,在内部类中意义不大吧,而如果内部类确实需要静态变量和方法,也可以挪到外部类中。

实现原理

以上代码也会生成两个类,一个是 Outer,另一个是 Outer$Inner,它们的代码大概如下所示:

public class Outer {
    
    
    private int a = 100;

    private void action() {
    
    
        System.out.println("action");
    }

    public void test() {
    
    
        Outer$Inner inner = new Outer$Inner(this);
        inner.innerMethod();
    }

    static int access$0(Outer outer) {
    
    
        return outer.a;
    }

    static void access$1(Outer outer) {
    
    
        outer.action();
    }
}
public class Outer$Inner {
    
    

    final Outer outer;
    
    public Outer$Inner(Outer outer){
    
    
        ths.outer = outer;
    }
    
    public void innerMethod() {
    
    
        System.out.println("outer a "
                + Outer.access$0(outer));
        Outer.access$1(outer);
    }
}

Outer $ Inner 类有个实例变量 outer 指向外部类的对象,它在构造方法中被初始化,Outer 在新建 Outer$Inner 对象时传递当前对象给它,由于内部类访问了外部类的私有变量和方法,外部类 Outer 生成了两个非私有静态方法,access$0 用于访问变量 a,access$1 用于访问方法 action。

使用场景

如果内部类与外部类关系密切,且操作或依赖外部类实例变量和方法,则可以考虑定义为成员内部类。

外部类的一些方法的返回值可能是某个接口,为了返回这个接口,外部类方法可能使用内部类实现这个接口,这个内部类可以被设为 private,对外完全隐藏。

比如说,在 Java API 类 LinkedList 中,它的两个方法 listIterator 和 descendingIterator 的返回值都是接口 Iterator,调用者可以通过 Iterator 接口对链表遍历,listIterator 和 descendingIterator 内部分别使用了成员内部类 ListItr 和 DescendingIterator,这两个内部类都实现了接口 Iterator。关于 LinkedList,后续文章我们还会介绍。

5.3.1.3 方法内部类

语法

内部类还可以定义在一个方法体中,示例代码如下所示:

public class Outer {
    
    
    private int a = 100;
    
    public void test(final int param){
    
    
        final String str = "hello";
        class Inner {
    
    
            public void innerMethod(){
    
    
                System.out.println("outer a " +a);
                System.out.println("param " +param);
                System.out.println("local var " +str);
            }
        }
        Inner inner = new Inner();
        inner.innerMethod();
    }
}

类 Inner 定义在外部类方法 test 中,方法内部类只能在定义的方法内被使用。如果方法是实例方法,则除了静态变量和方法,内部类还可以直接访问外部类的实例变量和方法,如 innerMethod 直接访问了外部私有实例变量 a。如果方法是静态方法,则方法内部类只能访问外部类的静态变量和方法。

方法内部类还可以直接访问方法的参数和方法中的局部变量,不过,这些变量必须被声明为 final,如 innerMethod 直接访问了方法参数 param 和局部变量 str。

实现原理

系统生成的两个类代码大概如下所示:

public class Outer {
    
    
    private int a = 100;

    public void test(final int param) {
    
    
        final String str = "hello";
        OuterInner inner = new OuterInner(this, param);
        inner.innerMethod();
    }
    
    static int access$0(Outer outer){
    
    
        return outer.a;
    }
}
public class OuterInner {
    
    
    Outer outer;
    int param;
    
    OuterInner(Outer outer, int param){
    
    
        this.outer = outer;
        this.param = param;
    }
    
    public void innerMethod() {
    
    
        System.out.println("outer a "
                + Outer.access$0(this.outer));
        System.out.println("param " + param);
        System.out.println("local var " + "hello");
    }
}

与成员内部类类似,OuterInner 类也有一个实例变量 outer 指向外部对象,在构造方法中被初始化,对外部私有实例变量的访问也是通过 Outer 添加的方法 access$0 来进行的。

方法内部类可以访问方法中的参数和局部变量,这是通过在构造方法中传递参数来实现的,如 OuterInner 构造方法中有参数 int param,在新建 OuterInner 对象时,Outer 类将方法中的参数传递给了内部类,如 OuterInner inner = new OuterInner(this, param);。在上面代码中,String str 并没有被作为参数传递,这是因为它被定义为了常量,在生成的代码中,可以直接使用它的值。

这也解释了,为什么方法内部类访问外部方法中的参数和局部变量时,这些变量必须被声明为 final,因为实际上,方法内部类操作的并不是外部的变量,而是它自己的实例变量,只是这些变量的值和外部一样,对这些变量赋值,并不会改变外部的值,为避免混淆,所以干脆强制规定必须声明为 final。

如果的确需要修改外部的变量,可以将变量改为只含该变量的数组,修改数组中的值,如下所示:

public class Outer {
    
    
    public void test(){
    
    
        final String[] str = new String[]{
    
    "hello"};
        class Inner {
    
    
            public void innerMethod(){
    
    
                str[0] = "hello world";
            }
        }
        Inner inner = new Inner();
        inner.innerMethod();
        System.out.println(str[0]);
    }
}

str 是一个只含一个元素的数组。

使用场景

方法内部类都可以用成员内部类代替,至于方法参数,也可以作为参数传递给成员内部类。不过,如果类只在某个方法内被使用,使用方法内部类,可以实现更好的封装

5.3.1.4 匿名内部类

语法

匿名内部类没有名字,在创建对象的同时定义类,语法如下:

new 父类(参数列表) {
    
    
   //匿名内部类实现部分
}

或者

new 父接口() {
    
    
   //匿名内部类实现部分
}

匿名内部类是与 new 关联的,在创建对象的时候定义类,new 后面是父类或者父接口,然后是圆括号 (),里面可以是传递给父类构造方法的参数,最后是大括号 {},里面是类的定义。

看个具体的代码:

public class Outer {
    
    
    public void test(final int x, final int y){
    
    
        Point p = new Point(2,3){
    
                    
                                               
            @Override                              
            public double distance() {
    
                 
                return distance(new Point(x,y));     
            }                                      
        };                                       
                                                 
        System.out.println(p.distance());        
    }
}

创建 Point 对象的时候,定义了一个匿名内部类,这个类的父类是 Point,创建对象的时候,给父类构造方法传递了参数 2 和 3,重写了 distance() 方法,在方法中访问了外部方法 final 参数 x 和 y。

匿名内部类只能被使用一次,用来创建一个对象。它没有名字,没有构造方法,但可以根据参数列表,调用对应的父类构造方法。它可以定义实例变量和方法,可以有初始化代码块,初始化代码块可以起到构造方法的作用,只是构造方法可以有多个,而初始化代码块只能有一份。

因为没有构造方法,它自己无法接受参数,如果必须要参数,则应该使用其他内部类。

与方法内部类一样,匿名内部类也可以访问外部类的所有变量和方法,可以访问方法中的 final 参数和局部变量

实现原理

每个匿名内部类也都被生成为了一个独立的类,只是类的名字以外部类加数字编号,没有有意义的名字。上例中,产生了两个类 Outer 和 Outer$1,代码大概如下所示:

public class Outer {
    
    
    public void test(final int x, final int y){
    
    
        Point p = new Outer$1(this,2,3,x,y);                                            
        System.out.println(p.distance());        
    }
}
public class Outer$1 extends Point {
    
    
    int x2;
    int y2;
    Outer outer;
    
    Outer$1(Outer outer, int x1, int y1, int x2, int y2){
    
    
        super(x1,y1);
        this.outer = outer;
        this.x2 = x2;
        this.y2 = y2;
    }
    
    @Override                              
    public double distance() {
    
                 
        return distance(new Point(this.x2,y2));     
    }   
}

与方法内部类类似,外部实例 this,方法参数 x 和 y 都作为参数传递给了内部类构造方法。此外,new 时的参数 2 和 3 也传递给了构造方法,内部类构造方法又将它们传递给了父类构造方法。

使用场景

匿名内部类能做的,方法内部类都能做。但如果对象只会创建一次,且不需要构造方法来接受参数,则可以使用匿名内部类,代码书写上更为简洁

在调用方法时,很多方法需要一个接口参数,比如说 Arrays.sort 方法,它可以接受一个数组,以及一个 Comparator 接口参数,Comparator 有一个方法 compare 用于比较两个对象。

比如说,我们要对一个字符串数组不区分大小写排序,可以使用 Arrays.sort 方法,但需要传递一个实现了 Comparator 接口的对象,这时就可以使用匿名内部类,代码如下所示:

public void sortIgnoreCase(String[] strs){
    
    
    Arrays.sort(strs, new Comparator<String>() {
    
    

        @Override
        public int compare(String o1, String o2) {
    
    
            return o1.compareToIgnoreCase(o2);
        }
    });
}

Comparator 后面的 <Stirng> 与泛型有关,表示比较的对象是字符串类型,后续文章会讲解泛型。

匿名内部类还经常用于事件处理程序中,用于响应某个事件,比如说一个 Button,处理点击事件的代码可能类似如下:

Button bt = new Button();
bt.addActionListener(new ActionListener(){
    
    
    @Override
    public void actionPerformed(ActionEvent e) {
    
    
        //处理事件
    }
});

调用 addActionListener 将事件处理程序注册到了 Button 对象 bt 中,当事件发生时,会调用 actionPerformed 方法,并传递事件详情 ActionEvent 作为参数。

以上 Arrays.sort 和 Button 都是上节提到的一种针对接口编程的例子,另外,它们也都是一种回调的例子。所谓回调是相对于一般的正向调用而言,平时一般都是正向调用,但 Arrays.sort 中传递的 Comparator 对象,它的 compare 方法并不是在写代码的时候被调用的,而是在 Arrays.sort 的内部某个地方回过头来调用的。Button 中的传递的 ActionListener 对象,它的 actionPerformed 方法也一样,是在事件发生的时候回过头来调用的

将程序分为保持不变的主体框架,和针对具体情况的可变逻辑,通过回调的方式进行协作,是计算机程序的一种常用实践。匿名内部类是实现回调接口的一种简便方式

5.3.2 小结

本节,我们谈了各种内部类的语法、实现原理、以及使用场景,内部类本质上都会被转换为独立的类,但一般而言,它们可以实现更好的封装,代码上也更为简洁。

5.4 枚举的本质

本节探讨 Java 中的枚举类型。

枚举是一种特殊的数据,它的取值是有限的,可以枚举出来的,比如说一年就是有四季、一周有七天,虽然使用类也可以处理这种数据,但枚举类型更为简洁、安全和方便。

下面我们就来介绍枚举的使用,同时介绍其实现原理。

5.4.1 基础

5.4.1.1 基本用法

定义和使用基本的枚举是比较简单的,我们来看个例子,为表示衣服的尺寸,我们定义一个枚举类型 Size,包括三个尺寸,小/中/大,代码如下:

public enum Size {
    
    
    SMALL, MEDIUM, LARGE
}

枚举使用 enum 这个关键字来定义,Size 包括三个值,分别表示小、中、大,值一般是大写的字母,多个值之间以逗号分隔。枚举类型可以定义为一个单独的文件,也可以定义在其他类内部。

可以这样使用 Size:

Size size = Size.MEDIUM

Size size 声明了一个变量 size,它的类型是 Size,size=Size.MEDIUM 将枚举值 MEDIUM 赋值给 size 变量。

枚举变量的 toString 方法返回其字面值,所有枚举类型也都有一个 name() 方法,返回值与 toString() 一样,例如:

Size size = Size.SMALL;
System.out.println(size.toString());
System.out.println(size.name());

输出都是 SMALL。

枚举变量可以使用 equals 和 == 进行比较,结果是一样的,例如:

Size size = Size.SMALL;
System.out.println(size==Size.SMALL);
System.out.println(size.equals(Size.SMALL));
System.out.println(size==Size.MEDIUM);

上面代码的输出结果为三行,分别是 true, true, false。

枚举值是有顺序的,可以比较大小。枚举类型都有一个方法 int ordinal(),表示枚举值在声明时的顺序,从 0 开始。例如,如下代码输出为 1:

Size size = Size.MEDIUM;
System.out.println(size.ordinal());

另外,枚举类型都实现了 Java API 中的 Comparable 接口,都可以通过方法 compareTo 与其他枚举值进行比较,比较其实就是比较 ordinal 的大小。例如,如下代码输出为 -1,表示 SMALL 小于 MEDIUM:

Size size = Size.SMALL;
System.out.println(size.compareTo(Size.MEDIUM));

枚举变量可以用于和其他类型变量一样的地方,如方法参数、类变量、实例变量等,枚举还可以用于 switch 语句,代码如下所示:

static void onChosen(Size size){
    
    
    switch(size){
    
    
    case SMALL:
        System.out.println("chosen small"); break;
    case MEDIUM:
        System.out.println("chosen medium"); break;
    case LARGE:
        System.out.println("chosen large"); break;
    }
}

在 switch 语句内部,枚举值不能带枚举类型前缀,例如,直接使用 SMALL,不能使用 Size.SMALL。

枚举类型都有一个静态的 valueOf(String) 方法,可以返回字符串对应的枚举值,例如,以下代码输出为 true:

System.out.println(Size.SMALL==Size.valueOf("SMALL"));

枚举类型也都有一个静态的 values 方法,返回一个包括所有枚举值的数组,顺序与声明时的顺序一致,例如:

for(Size size : Size.values()){
    
    
    System.out.println(size);
}

屏幕输出为三行,分别是 SMALL, MEDIUM, LARGE。

5.4.1.2 枚举的好处

Java 是从 JDK 5 才开始支持枚举的,在此之前,一般是在类中定义静态整形变量来实现类似功能,代码如下所示:

class Size {
    
    
    public static final int SMALL = 0;
    public static final int MEDIUM = 1;
    public static final int LARGE = 2;
}

枚举的好处是比较明显的:

  • 定义枚举的语法更为简洁。
  • 枚举更为安全,一个枚举类型的变量,它的值要么为 null,要么为枚举值之一,不可能为其他值,但使用整形变量,它的值就没有办法强制,值可能就是无效的。
  • 枚举类型自带很多便利方法(如 values, valueOf, toString 等),易于使用。

5.4.1.3 基本实现原理

枚举类型实际上会被 Java 编译器转换为一个对应的类,这个类继承了 Java API 中的 java.lang.Enum 类

Enum 类有两个实例变量 name 和 ordinal,在构造方法中需要传递,name(), toString(), ordinal(), compareTo(), equals() 方法都是由 Enum 类根据其实例变量 name 和 ordinal 实现的。

values 和 valueOf 方法是编译器给每个枚举类型自动添加的,上面的枚举类型 Size 转换后的普通类的代码大概如下所示:

public final class Size extends Enum<Size> {
    
    
    public static final Size SMALL = new Size("SMALL",0);
    public static final Size MEDIUM = new Size("MEDIUM",1);
    public static final Size LARGE = new Size("LARGE",2);
    
    private static Size[] VALUES =
            new Size[]{
    
    SMALL,MEDIUM,LARGE};
    
    private Size(String name, int ordinal){
    
    
        super(name, ordinal);
    }
    
    public static Size[] values(){
    
    
        Size[] values = new Size[VALUES.length];
        System.arraycopy(VALUES, 0,
                values, 0, VALUES.length);
        return values;
    }
    
    public static Size valueOf(String name){
    
    
        return Enum.valueOf(Size.class, name);
    }
}

解释几点:

  • Size 是 final 的,不能被继承,Enum<Size> 表示父类,<Size> 是泛型写法,我们后续文章介绍,此处可以忽略。
  • Size 有一个私有的构造方法,接受 name 和 ordinal,传递给父类,私有表示不能在外部创建新的实例。
  • 三个枚举值实际上是三个静态变量,也是 final 的,不能被修改。
  • values 方法是编译器添加的,内部有一个 values 数组保持所有枚举值。
  • valueOf 方法调用的是父类的方法,额外传递了参数 Size.class,表示类的类型信息,类型信息我们后续文章介绍,父类实际上是回过头来调用 values 方法,根据 name 对比得到对应的枚举值的。

一般枚举变量会被转换为对应的类变量,在 switch 语句中,枚举值会被转换为其对应的 ordinal 值。

可以看出,枚举类型本质上也是类,但由于编译器自动做了很多事情,它的使用也就更为简洁、安全和方便

5.4.2 典型场景

5.4.2.1 用法

以上枚举用法是最简单的,实际中枚举经常会有关联的实例变量和方法,比如说,上面的 Size 例子,每个枚举值可能有关联的缩写和中文名称,可能需要静态方法根据缩写返回对应的枚举值,修改后的 Size 代码如下所示:

public enum Size {
    
    
    SMALL("S","小号"),
    MEDIUM("M","中号"),
    LARGE("L","大号");
    
    private String abbr;
    private String title;
    
    private Size(String abbr, String title){
    
    
        this.abbr = abbr;
        this.title = title;
    }

    public String getAbbr() {
    
    
        return abbr;
    }

    public String getTitle() {
    
    
        return title;
    }
    
    public static Size fromAbbr(String abbr){
    
    
        for(Size size : Size.values()){
    
    
            if(size.getAbbr().equals(abbr)){
    
    
                return size;
            }
        }
        return null;
    }
}

以上代码定义了两个实例变量 abbr 和 title,以及对应的 get 方法,分别表示缩写和中文名称,定义了一个私有构造方法,接受缩写和中文名称,每个枚举值在定义的时候都传递了对应的值,同时定义了一个静态方法 fromAbbr 根据缩写返回对应的枚举值。

需要说明的是,枚举值的定义需要放在最上面,枚举值写完之后,要以分号(;)结尾,然后才能写其他代码。

这个枚举定义的使用与其他类类似,比如说:

Size s = Size.MEDIUM;
System.out.println(s.getAbbr());

s = Size.fromAbbr("L");
System.out.println(s.getTitle());

以上代码分别输出: M, 大号

5.4.2.2 实现原理

加了实例变量和方法后,枚举转换后的类与上面的类似,只是增加了对应的变量和方法,修改了构造方法,代码不同之处大概如下所示:

public final class Size extends Enum<Size> {
    
    
  public static final Size SMALL =        
          new Size("SMALL",0, "S", "小号");     
  public static final Size MEDIUM =       
          new Size("MEDIUM",1,"M","中号");      
  public static final Size LARGE =        
          new Size("LARGE",2,"L","大号");       
                                          
  private String abbr;                    
  private String title;                   
                                          
  private Size(String name, int ordinal,  
          String abbr, String title){
    
             
      super(name, ordinal);               
      this.abbr = abbr;                   
      this.title = title;                 
  }
  //... 其他代码
}

5.4.2.3 说明

每个枚举值经常有一个关联的标示(id),通常用 int 整数表示,使用整数可以节约存储空间,减少网络传输。一个自然的想法是使用枚举中自带的 ordinal 值,但 ordinal 并不是一个好的选择。

为什么呢?因为 ordinal 的值会随着枚举值在定义中的位置变化而变化,但一般来说,我们希望 id 值和枚举值的关系保持不变,尤其是表示枚举值的 id 已经保存在了很多地方的时候。

比如说,上面的 Size 例子,Size.SMALL 的 ordinal 的值为 0,我们希望 0 表示的就是 Size.SMALL 的,但如果我们增加一个表示超小的值 XSMALL 呢?

public enum Size {
    
    
    XSMALL, SMALL, MEDIUM, LARGE
}

这时,0 就表示 XSMALL 了。

所以,一般是增加一个实例变量表示 id,使用实例变量的另一个好处是,id 可以自己定义。比如说,Size 例子可以写为:

public enum Size {
    
    
    XSMALL(10), SMALL(20), MEDIUM(30), LARGE(40);
    
    private int id;
    private Size(int id){
    
    
        this.id = id;
    }
    public int getId() {
    
    
        return id;
    }
}

猜你喜欢

转载自blog.csdn.net/bm1998/article/details/107815176