《Java 编程的逻辑》笔记——第4章 类的继承

声明:

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

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

开头语

上一章,我们谈到了如何将现实中的概念映射为程序中的概念,我们谈了类以及类之间的组合,现实中的概念间还有一种非常重要的关系,就是分类。分类有个根,然后向下不断细化,形成一个层次分类体系。这种例子是非常多的。

  1. 在自然世界中,生物有动物和植物,动物有不同的科目,食肉动物、食草动物、杂食动物等,食肉动物有狼、狗、虎等,这些又分为不同的品种。
  2. 打开电商网站,在显著位置一般都有分类列表,比如家用电器、服装,服装有女装、男装,男装有衬衫、牛仔裤等。

计算机程序经常使用类之间的继承关系来表示对象之间的分类关系。在继承关系中,有父类子类,比如动物类 Animal 和狗类 Dog,Animal 是父类,Dog 是子类。父类也叫基类,子类也叫派生类。父类子类是相对的,一个类 B 可能是类 A 的子类,是类 C 的父类。

之所以叫继承,是因为子类继承了父类的属性和行为,父类有的属性和行为,子类都有。但子类可以增加子类特有的属性和行为,某些父类有的行为,子类的实现方式可能与父类也不完全一样。

使用继承一方面可以复用代码,公共的属性和行为可以放到父类中,而子类只需要关注子类特有的就可以了;另一方面,不同子类的对象可以更为方便的被统一处理

4.1 基本概念

4.1.1 Object

在 Java 中,所有类都有一个父类,即使没有声明父类,也有一个隐含的父类,这个父类叫 Object。Object 没有定义属性,但定义了一些方法,如图 4-1 所示。

在这里插入图片描述

本节我们会介绍 toString() 方法,其他方法我们会在后续章节中逐步介绍。toString() 方法的目的是返回一个对象的文本描述,这个方法可以直接被所有类使用

比如说,对于我们之前介绍的 Point 类,可以这样使用 toString 方法:

Point p = new Point(2,3);
System.out.println(p.toString()); 

输出类似这样:

Point@76f9aa66

这是什么意思呢?@之前是类名,@之后的内容是什么呢?我们来看下 toString 的代码:

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

getClass().getName() 返回当前对象的类名,hashCode() 返回一个对象的哈希值,哈希我们会在后续章节中介绍,这里可以理解为是一个整数,这个整数默认情况下,通常是对象的内存地址值,Integer.toHexString(hashCode()) 返回这个哈希值的十六进制表示

为什么要这么写呢?写类名是可以理解的,表示对象的类型,而写哈希值则是不得已的,因为 Object 类并不知道具体对象的属性,不知道怎么用文本描述,但又需要区分不同对象,只能是写一个哈希值。

但子类是知道自己的属性的,子类可以重写父类的方法,以反映自己的不同实现。所谓重写,就是定义和父类一样的方法,并重新实现。

4.1.2 方法重写

我们再来看下 Point 类,这次我们重写了 toString() 方法。

public class Point {
    private int x;
    private int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public double distance(Point point){
        return Math.sqrt(Math.pow(this.x-point.getX(),2)
                +Math.pow(this.y-point.getY(), 2));
    }
    
    public int getX() {
        return x;
    }
    
    public int getY() {
        return y;
    }

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

toString 方法前面有一个 @Override,这表示 toString 这个方法是重写的父类的方法,重写后的方法返回 Point 的 x 和 y 坐标的值。重写后,将调用子类的实现。比如,如下代码的输出就变成了:(2,3)

Point p = new Point(2,3);
System.out.println(p.toString());

4.1.3 图形类继承体系

接下来,我们以一些图形处理中的例子来进一步解释。先来看一些图形的例子,如图 4-2 所示。

在这里插入图片描述

这都是一些基本的图形,图形有线、正方形、三角形、圆形等,图形有不同的颜色。接下来,我们定义以下类来说明关于继承的一些概念:

  • 父类 Shape,表示图形。
  • 类 Circle,表示圆。
  • 类 Line,表示直线。
  • 类 ArrowLine,表示带箭头的直线。

4.1.3.1 图形

所有图形都有一个表示颜色的属性,有一个表示绘制的方法,下面是代码:

public class Shape {
    private static final String DEFAULT_COLOR = "black";
    
    private String color;
    
    public Shape() {
        this(DEFAULT_COLOR);
    }

    public Shape(String color) {
        this.color = color;
    }
    
    public String getColor() {
        return color;
    }

    public void setColor(String color) {
        this.color = color;
    }
    
    public void draw(){
        System.out.println("draw shape");
    }
}

以上代码基本没什么可解释的,实例变量 color 表示颜色,draw 方法表示绘制,我们不会写实际的绘制代码,主要是演示继承关系。

4.1.3.2 圆

圆继承自 Shape,但包括了额外的属性,中心点和半径,以及额外的方法 area,用于计算面积,另外,重写了 draw 方法,代码如下:

public class Circle extends Shape {
    //中心点
    private Point center;
    
    //半径
    private double r; 

    public Circle(Point center, double r) {
        this.center = center;
        this.r = r;
    }

    @Override
    public void draw() {
        System.out.println("draw circle at "
                +center.toString()+" with r "+r
                +", using color : "+getColor());    
    }
    
    public double area(){
        return Math.PI*r*r;
    }
}

说明几点:

  • Java 使用 extends 关键字标明继承关系,一个类最多只能有一个父类
  • 子类不能直接访问父类的私有属性和方法,比如,在 Circle 中,不能直接访问 shape 的私有实例变量 color。
  • 除了私有的外,子类继承了父类的其他属性和方法,比如,在 Circle 的 draw 方法中,可以直接调用 getColor() 方法。

看下使用它的代码:

public static void main(String[] args) {
    Point center = new Point(2,3);
    //创建圆,赋值给circle
    Circle circle = new Circle(center,2);
    //调用draw方法,会执行Circle的draw方法
    circle.draw();
    //输出圆面积
    System.out.println(circle.area());
}

程序的输出为:

draw circle at (2,3) with r 2.0, using color : black
12.566370614359172

这里比较奇怪的是,color 是什么时候赋值的?在 new 的过程中,父类的构造方法也会执行,且会优先于子类先执行。在这个例子中,父类 Shape 的默认构造方法会在子类 Circle 的构造方法之前执行。关于 new 过程的细节,我们会在后续章节进一步介绍。

4.1.3.3 直线

线继承自 Shape,但有两个点,有一个获取长度的方法,另外,重写了 draw 方法,代码如下:

public class Line extends Shape {
    private Point start;
    private Point end;
    
    public Line(Point start, Point end, String color) {
        super(color);
        this.start = start;
        this.end = end;
    }

    public double length(){
        return start.distance(end);
    }
    
    public Point getStart() {
        return start;
    }

    public Point getEnd() {
        return end;
    }
    
    @Override
    public void draw() {
        System.out.println("draw line from "
                + start.toString()+" to "+end.toString()
                + ",using color "+super.getColor());
    }
}

这里我们要说明的是 super 这个关键字,super 用于指代父类,可用于调用父类构造方法,访问父类方法和变量:

  • 在 line 构造方法中,super(color) 表示调用父类的带 color 参数的构造方法,调用父类构造方法时,super(…) 必须放在第一行
  • 在 draw 方法中,super.getColor() 表示调用父类的 getColor 方法,当然不写 super. 也是可以的,因为这个方法子类没有同名的,没有歧义,当有歧义的时候,通过 super. 可以明确表示调用父类的。
  • super 同样可以引用父类非私有的变量。

可以看出,super 的使用与 this 有点像,但 super 和 this 是不同的,this 引用一个对象,是实实在在存在的,可以作为函数参数,可以作为返回值,但 super 只是一个关键字,不能作为参数和返回值,它只是用于告诉编译器访问父类的相关变量和方法。

4.1.3.4 带箭头的直线

带箭头直线继承自 Line,但多了两个属性,分别表示两端是否有箭头,也重写了 draw 方法,代码如下:

public class ArrowLine extends Line {
    
    private boolean startArrow;
    private boolean endArrow;
    
    public ArrowLine(Point start, Point end, String color, 
            boolean startArrow, boolean endArrow) {
        super(start, end, color);
        this.startArrow = startArrow;
        this.endArrow = endArrow;
    }

    @Override
    public void draw() {
        super.draw();
        if(startArrow){
            System.out.println("draw start arrow");
        }
        if(endArrow){
            System.out.println("draw end arrow");
        }
    }
}

ArrowLine 继承自 Line,而 Line 继承自 Shape,ArrowLine 的对象也有 Shape 的属性和方法。

注意 draw 方法的第一行,super.draw() 表示调用父类的 draw() 方法,这时候不带 super. 是不行的,因为当前的方法也叫 draw()。

需要说明的是,这里 ArrowLine 继承了 Line,也可以直接在类 Line 里加上属性,而不需要单独设计一个类 ArrowLine,这里主要是演示继承的层次性。

4.1.3.5 图形管理器

使用继承的一个好处是可以统一处理不同子类型的对象。比如说,我们来看一个图形管理者类,它负责管理画板上的所有图形对象并负责绘制,在绘制代码中,只需要将每个对象当做 Shape 并调用 draw 方法就可以了,系统会自动执行子类的 draw 方法。代码如下:

public class ShapeManager {
    private static final int MAX_NUM = 100;
    private Shape[] shapes = new Shape[MAX_NUM];
    private int shapeNum = 0;
    
    public void addShape(Shape shape){
        if(shapeNum<MAX_NUM){
            shapes[shapeNum++] = shape;    
        }
    }
    
    public void draw(){
        for(int i=0;i<shapeNum;i++){
            shapes[i].draw();
        }
    }
}

ShapeManager 使用一个数组保存所有的 shape,在 draw 方法中调用每个 shape 的 draw 方法。ShapeManager 并不知道每个 shape 具体的类型,也不关心,但可以调用到子类的 draw 方法。

我们来看下使用 ShapeManager 的一个例子:

public static void main(String[] args) {
    ShapeManager manager = new ShapeManager();
    
    manager.addShape(new Circle(new Point(4,4),3));
    manager.addShape(new Line(new Point(2,3),
            new Point(3,4),"green"));
    manager.addShape(new ArrowLine(new Point(1,2), 
            new Point(5,5),"black",false,true));
    
    manager.draw();
}

新建了三个 shape,分别是一个圆、直线和带箭头的线,然后加到了 shape manager 中,然后调用 manager 的 draw 方法。

需要说明的是,在 addShape 方法中,参数 Shape shape,声明的类型是 Shape,而实际的类型则分别是 Circle,Line 和 ArrowLine。子类对象赋值给父类引用变量,这叫向上转型,转型就是转换类型,向上转型就是转换为父类类型。

变量 shape 可以引用任何 Shape 子类类型的对象,这叫多态,即一种类型的变量,可引用多种实际类型对象。这样,对于变量 shape,它就有两个类型,类型 Shape,我们称之为 shape 的静态类型,类型 Circle/Line/ArrowLine,我们称之为 shape 的动态类型。在 ShapeManager 的 draw 方法中,shapes[i].draw() 调用的是其对应动态类型的 draw 方法,这称之为方法的动态绑定

为什么要有多态和动态绑定呢?创建对象的代码 (ShapeManager 以外的代码)和操作对象的代码(ShapeManager 本身的代码),经常不在一起,操作对象的代码往往只知道对象是某种父类型,也往往只需要知道它是某种父类型就可以了。

可以说,多态和动态绑定是计算机程序的一种重要思维方式,使得操作对象的程序不需要关注对象的实际类型,从而可以统一处理不同对象,但又能实现每个对象的特有行为。后续章节我们会进一步介绍动态绑定的实现原理。

4.1.4 小结

本节介绍了继承和多态的基本概念:

  • 每个类有且只有一个父类,没有声明父类的其父类为 Object,子类继承了父类非 private 的属性和方法,可以增加自己的属性和方法,可以重写父类的方法实现。
  • new 过程中,父类先进行初始化,可通过 super 调用父类相应的构造方法,没有使用 super 的话,调用父类的默认构造方法。
  • 子类变量和方法与父类重名的情况下,可通过 super 强制访问父类的变量和方法。
  • 子类对象可以赋值给父类引用变量,这叫多态,实际执行调用的是子类实现,这叫动态绑定。

继承和多态的基本概念是比较简单的,子类继承父类,自动拥有父类的属性和行为,并可扩展属性和行为,同时,可重写父类的方法以修改行为。但关于继承还有很多细节,我们下节继续讨论。

4.2 继承的细节

继承和多态概念还有一些相关的细节,本节就来探讨这些细节,具体包括:

  • 构造方法
  • 重名与静态绑定
  • 重载和重写
  • 父子类型转换
  • 继承访问权限 (protected)
  • 可见性重写
  • 防止继承 (final)

下面我们逐个来解释。

4.2.1 构造方法

4.2.1.1 super

上节我们说过,子类可以通过 super(…) 调用父类的构造方法,如果子类没有通过 super(…) 调用,则会自动调动父类的默认构造方法,那如果父类没有默认构造方法呢?如下例所示:

public class Base {
    private String member;
    public Base(String member){
        this.member = member;
    }
}

这个类只有一个带参数的构造方法,没有默认构造方法。这个时候,它的任何子类都必须在构造方法中通过 super(…) 调用 Base 的带参数构造方法,如下所示,否则,Java 会提示编译错误。

public class Child extends Base {
    public Child(String member) {
        super(member);
    }
}

4.2.1.2 构造方法调用重写方法

如果在父类构造方法中调用了可被重写的方法,则可能会出现意想不到的结果,我们来看个例子:

这是基类代码:

public class Base {
    public Base(){
        test();
    }
    
    public void test(){
    }
}

构造方法调用了 test()。这是子类代码:

public class Child extends Base {
    private int a = 123;
    
    public Child(){
    }
    
    public void test(){
        System.out.println(a);
    }
}

子类有一个实例变量 a,初始赋值为 123,重写了 test 方法,输出 a 的值。看下使用的代码:

public static void main(String[] args){
    Child c = new Child();
    c.test();
}

输出结果是:

0
123

第一次输出为 0,第二次为 123。第一行为什么是 0 呢?第一次输出是在 new 过程中输出的,在 new 过程中,首先是初始化父类,父类构造方法调用 test(),test 被子类重写了,就会调用子类的 test() 方法,子类方法访问子类实例变量 a,而这个时候子类的实例变量的赋值语句和构造方法还没有执行,所以输出的是其默认值 0。

像这样,在父类构造方法中调用可被子类重写的方法,是一种不好的实践,容易引起混淆,应该只调用 private 的方法

4.2.2 重名与静态绑定

4.1 节我们说到,子类可以重写父类非 private 的方法,当调用的时候,会动态绑定,执行子类的方法。那实例变量、静态方法、和静态变量呢?它们可以重名吗?如果重名,访问的是哪一个呢

重名是可以的,重名后实际上有两个变量或方法。对于 private 变量和方法,它们只能在类内被访问,访问的也永远是当前类的,即在子类中,访问的是子类的,在父类中,访问的父类的,它们只是碰巧名字一样而已,没有任何关系。

但对于 public 变量和方法,则要看如何访问它,在类内访问的是当前类的,但子类可以通过 super. 明确指定访问父类的。在类外,则要看访问变量的静态类型,静态类型是父类,则访问父类的变量和方法,静态类型是子类,则访问的是子类的变量和方法。我们来看个例子,这是基类代码:

public class Base {
    public static String s = "static_base";
    public String m = "base";
    
    public static void staticTest(){
        System.out.println("base static: "+s);
    }
}

定义了一个 public 静态变量 s、一个 public 实例变量 m、一个静态方法 staticTest。这是子类代码:

public class Child extends Base {
    public static String s = "child_base";
    public String m = "child";
    
    public static void staticTest(){
        System.out.println("child static: "+s);
    }
}

子类定义了和父类重名的变量和方法。对于一个子类对象,它就有了两份变量和方法,在子类内部访问的时候,访问的是子类的,或者说,子类变量和方法隐藏了父类对应的变量和方法,下面看一下外部访问的代码:

public static void main(String[] args) {
    Child c = new Child();
    Base b = c;
    
    System.out.println(b.s);
    System.out.println(b.m);
    b.staticTest();
    
    System.out.println(c.s);
    System.out.println(c.m);
    c.staticTest();
}

以上代码创建了一个子类对象,然后将对象分别赋值给了子类引用变量 c 和父类引用变量 b,然后通过 b 和 c 分别引用变量和方法。这里需要说明的是,静态变量和静态方法一般通过类名直接访问,但也可以通过类的对象访问。程序输出为:

static_base
base
base static: static_base
child_base
child
child static: child_base

当通过 b (静态类型Base) 访问时,访问的是 Base 的变量和方法,当通过 c (静态类型Child)访问时,访问的是 Child 的变量和方法,这称之为静态绑定,即访问绑定到变量的静态类型,静态绑定在程序编译阶段即可决定,而动态绑定则要等到程序运行时。实例变量、静态变量、静态方法、private 方法,都是静态绑定的

4.2.3 重载与重写

重载是指方法名称相同但参数签名不同(参数个数或类型或顺序不同),重写是指子类重写父类相同参数签名的方法。对一个函数调用而言,可能有多个匹配的方法,有时候选择哪一个并不是那么明显,我们来看个例子,这里基类代码:

public class Base {
    public int sum(int a, int b){
        System.out.println("base_int_int");
        return a+b;
    }
}

它定义了方法 sum,下面是子类代码:

public class Child extends Base {
    public long sum(long a, long b){
        System.out.println("child_long_long");
        return a+b;
    }
}

以下是调用的代码:

public static void main(String[] args){
    Child c = new Child();
    int a = 2;
    int b = 3;
    c.sum(a, b);
}

这个调用的是哪个 sum 方法呢?每个 sum 方法都是兼容的,int 类型可以自动转型为 long,当只有一个方法的时候,那个方法就会被调用。但现在有多个方法可用,子类的 sum 方法参数类型虽然不完全匹配但是是兼容的,父类的 sum 方法参数类型是完全匹配的。程序输出为:

base_int_int

父类类型完全匹配的方法被调用了。如果父类代码改成下面这样呢?

public class Base {
    public long sum(int a, long b){
        System.out.println("base_int_long");
        return a+b;
    }
}

父类方法类型也不完全匹配了。程序输出为:

base_int_long

调用的还是父类的方法。父类和子类的两个方法的类型都不完全匹配,为什么调用父类的呢?因为父类的更匹配一些。现在修改一下子类代码,更改为:

public class Child extends Base {
    public long sum(int a, long b){
        System.out.println("child_int_long");
        return a+b;
    }
}

程序输出变为了:

child_int_long

终于调用了子类的方法。可以看出,当有多个重名函数的时候,在决定要调用哪个函数的过程中,首先是按照参数类型进行匹配的,换句话说,寻找在所有重载版本中最匹配的,然后才看变量的动态类型,进行动态绑定

4.2.4 父子类型转换

之前我们说过,子类型的对象可以赋值给父类型的引用变量,这叫向上转型,那父类型的变量可以赋值给子类型的变量吗?或者说可以向下转型吗语法上可以进行强制类型转换,但不一定能转换成功。我们以上面的例子来示例:

Base b = new Child();
Child c = (Child)b;

Child c = (Child)b 就是将变量 b 的类型强制转换为 Child 并赋值为 c,这是没有问题的,因为 b 的动态类型就是 Child,但下面代码是不行的:

Base b = new Base();
Child c = (Child)b;

语法上 Java 不会报错,但运行时会抛出错误,错误为类型转换异常。

一个父类的变量,能不能转换为一个子类的变量,取决于这个父类变量的动态类型(即引用的对象类型)是不是这个子类或这个子类的子类

给定一个父类的变量,能不能知道它到底是不是某个子类的对象,从而安全的进行类型转换呢?答案是可以,通过 instanceof 关键字,看下面代码:

public boolean canCast(Base b){
    return b instanceof Child;
}

这个函数返回 Base 类型变量是否可以转换为 Child 类型,instanceof 前面是变量,后面是类,返回值是 boolean 值,表示变量引用的对象是不是该类或其子类的对象

4.2.5 继承访问权限 protected

变量和函数有 public/private 修饰符,public 表示外部可以访问,private 表示只能内部使用,还有一种可见性介于中间的修饰符 protected,表示虽然不能被外部任意访问,但可被子类访问。另外,在 Java 中,protected 还表示可被同一个包中的其他类访问,不管其他类是不是该类的子类

我们来看个例子,这是基类代码:

public class Base {
    protected  int currentStep;
    
    protected void step1(){
    }
    
    protected void step2(){        
    }
    
    public void action(){
        this.currentStep = 1;
        step1();
        this.currentStep = 2;
        step2();
    }
}

action() 表示对外提供的行为,内部有两个步骤 step1() 和 step2(),使用 currentStep 变量表示当前进行到了哪个步骤,step1、step2 和 currentStep 是 protected 的,子类一般不重写 action,而只重写 step1 和 step2,同时,子类可以直接访问 currentStep 查看进行到了哪一步。子类的代码是:

public class Child extends Base {
    protected void step1(){
        System.out.println("child step "
                +this.currentStep);
    }
    
    protected void step2(){    
        System.out.println("child step "
                +this.currentStep);
    }
}

使用 Child 的代码是:

public static void main(String[] args){
    Child c = new Child();
    c.action();
}

输出为:

child step 1
child step 2

基类定义了表示对外行为的方法 action,并定义了可以被子类重写的两个步骤 step1 和 step2,以及被子类查看的变量 currentStep,子类通过重写 protected 方法 step1 和 step2 来修改对外的行为

这种思路和设计在设计模式中被称之为模板方法,action 方法就是一个模板方法,它定义了实现的模板,而具体实现则由子类提供。模板方法在很多框架中有广泛的应用,这是使用 protected 的一个常用场景。关于更多设计模式的内容我们暂不介绍。

4.2.6 可见性重写

重写方法时,一般并不会修改方法的可见性。但我们还是要说明一点,重写时,子类方法不能降低父类方法的可见性,不能降低是指,父类如果是 public,则子类也必须是 public,父类如果是 protected,子类可以是 protected,也可以是 public,即子类可以升级父类方法的可见性但不能降低。看个例子,基类代码为:

public class Base {
    protected void protect(){
    }
    
    public void open(){        
    }
}

子类代码为:

public class Child extends Base {
    //以下是不允许的的,会有编译错误
//    private void protect(){
//    }
    
    //以下是不允许的,会有编译错误
//    protected void open(){        
//    }
    
    public void protect(){        
    }
}

为什么要这样规定呢?继承反映的是 “is-a” 的关系,即子类对象也属于父类,子类必须支持父类所有对外的行为,将可见性降低就会减少子类对外的行为,从而破坏 “is-a” 的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。

4.2.7 防止继承 (final)

4.3 节我们提到继承是把双刃剑,带来的影响就是,有的时候我们不希望父类方法被子类重写,有的时候甚至不希望类被继承,实现这个的方法就是 final 关键字。之前我们提过 final 可以修饰变量,这是 final 的另一个用法。

一个 Java 类,默认情况下都是可以被继承的,但加了 final 关键字之后就不能被继承了,如下所示:

public final class Base {
   //....
}

一个非 final 的类,其中的 public/protected 实例方法默认情况下都是可以被重写的,但加了 final 关键字后就不能被重写了,如下所示:

public class Base {
    public final void test(){
        System.out.println("不能被重写");
    }
} 

4.3 继承实现的基本原理

本节我们通过一个例子,来介绍继承实现的基本原理。需要说明的是,本节主要从概念上来介绍原理,实际实现细节可能与此不同。

4.3.1 示例

这是基类代码:

public class Base {
    public static int s;
    private int a;
    
    static {
        System.out.println("基类静态代码块, s: "+s);
        s = 1;
    }
    
    {
        System.out.println("基类实例代码块, a: "+a);
        a = 1;
    }
    
    public Base(){
        System.out.println("基类构造方法, a: "+a);
        a = 2;
    }
    
    protected void step(){
        System.out.println("base s: " + s +", a: "+a);
    }
    
    public void action(){
        System.out.println("start");
        step();
        System.out.println("end");
    }
}

Base 包括一个静态变量 s,一个实例变量 a,一段静态初始化代码块,一段实例初始化代码块,一个构造方法,两个方法 step 和 action。

这是子类代码:

public class Child extends Base {
    public static int s;
    private int a;
    
    static {
        System.out.println("子类静态代码块, s: "+s);
        s = 10;
    }
    
    {
        System.out.println("子类实例代码块, a: "+a);
        a = 10;
    }
    
    public Child(){
        System.out.println("子类构造方法, a: "+a);
        a = 20;
    }
    
    protected void step(){
        System.out.println("child s: " + s +", a: "+a);
    }
}

Child 继承了 Base,也定义了和基类同名的静态变量 s 和实例变量 a,静态初始化代码块,实例初始化代码块,构造方法,重写了方法 step。

这是使用的代码:

public static void main(String[] args) {
    System.out.println("---- new Child()");
    Child c = new Child();
    
    System.out.println("\n---- c.action()");
    c.action();
    
    Base b = c;
    System.out.println("\n---- b.action()");
    b.action();
    
    
    System.out.println("\n---- b.s: " + b.s); 
    System.out.println("\n---- c.s: " + c.s); 
}

创建了 Child 类型的对象,赋值给了 Child 类型的引用变量 c,通过 c 调用 action 方法,又赋值给了 Base 类型的引用变量 b,通过 b 也调用了 action,最后通过 b 和 c 访问静态变量 s 并输出。这是屏幕的输出结果:

---- new Child()
基类静态代码块, s: 0
子类静态代码块, s: 0
基类实例代码块, a: 0
基类构造方法, a: 1
子类实例代码块, a: 0
子类构造方法, a: 10

---- c.action()
start
child s: 10, a: 20
end

---- b.action()
start
child s: 10, a: 20
end

---- b.s: 1

---- c.s: 10

下面我们来解释一下背后都发生了一些什么事情,从类的加载开始。

4.3.2 类加载过程

在 Java 中,所谓类的加载是指将类的相关信息加载到内存。在 Java 中,类是动态加载的,当第一次使用这个类的时候才会加载,加载一个类时,会查看其父类是否已加载,如果没有,则会加载其父类

一个类的信息主要包括以下部分:

  • 类变量(静态变量)
  • 类初始化代码
  • 类方法(静态方法)
  • 实例变量
  • 实例初始化代码
  • 实例方法
  • 父类信息引用

类初始化代码包括:

  1. 定义静态变量时的赋值语句
  2. 静态初始化代码块

实例初始化代码包括:

  1. 定义实例变量时的赋值语句
  2. 实例初始化代码块
  3. 构造方法

类加载过程包括

  1. 分配内存保存类的信息
  2. 给类变量赋默认值
  3. 加载父类
  4. 设置父子关系
  5. 执行类初始化代码

需要说明的是,关于类初始化代码,是先执行父类的,再执行子类的。不过,父类执行时,子类静态变量的值也是有的,是默认值。对于默认值,我们之前说过,数字型变量都是 0,boolean 是 false,char 是 ‘\u0000’,引用型变量是 null。

之前我们说过,内存分为栈和堆,栈存放函数的局部变量,而堆存放动态分配的对象,还有一个内存区,存放类的信息,这个区在 Java 中称之为方法区

加载后,对于每一个类,在 Java 方法区就有了一份这个类的信息。以我们的例子来说,有三份类信息,分别是 Child,Base,Object,内存布局如图 4-3 所示。

在这里插入图片描述

我们用 class_init() 来表示类初始化代码,用 instance_init() 表示实例初始化代码,实例初始化代码包括了实例初始化代码块和构造方法。例子中只有一个构造方法,实际中可能有多个实例初始化方法。

本例中,类的加载大概就是在内存中形成了类似上面的布局,然后分别执行了 Base 和 Child 的类初始化代码。接下来,我们看对象创建的过程。

4.3.3 对象的创建过程

在类加载之后,new Child() 就是创建 Child 对象,创建对象过程包括

  1. 分配内存
  2. 对所有实例变量赋默认值
  3. 执行实例初始化代码

分配的内存包括本类和所有父类的实例变量,但不包括任何静态变量

实例初始化代码的执行从父类开始,先执行父类的,再执行子类的。但在任何类执行初始化代码之前,所有实例变量都已设置完默认值。

每个对象除了保存类的实例变量之外,还保存着实际类信息的引用

Child c = new Child(); 会将新创建的 Child 对象引用赋给变量 c,而 Base b = c; 会让 b 也引用这个 Child 对象。创建和赋值后,内存布局大概如图 4-4 所示。

在这里插入图片描述

引用型变量 c 和 b 分配在栈中,它们指向相同的堆中的 Child 对象,Child 对象存储着方法区中 Child 类型的地址,还有 Base 中的实例变量 a 和 Child 中的实例变量 a。创建了对象,接下来,来看方法调用的过程。

4.3.4 方法调用的过程

我们先来看 c.action(); 这句代码的执行过程是:

  1. 查看 c 的对象类型,找到 Child 类型,在 Child 类型中找 action 方法,发现没有,到父类中寻找
  2. 在父类 Base 中找到了方法 action,开始执行 action 方法
  3. action 先输出了 start,然后发现需要调用 step() 方法,就从 Child 类型开始寻找 step 方法
  4. 在 Child 类型中找到了 step() 方法,执行 Child 中的 step() 方法,执行完后返回 action 方法
  5. 继续执行 action 方法,输出 end

寻找要执行的实例方法的时候,是从对象的实际类型信息开始查找的,找不到的时候,再查找父类类型信息

我们来看 b.action();,这句代码的输出和 c.action 是一样的,这称之为动态绑定,而动态绑定实现的机制,就是根据对象的实际类型查找要执行的方法,子类型中找不到的时候再查找父类。这里,因为 b 和 c 指向相同的对象,所以执行结果是一样的。

如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要进行很多次查找。大多数系统使用一种称为虚方法表的方法来优化调用的效率。

虚方法表

所谓虚方法表,就是在类加载的时候,为每个类创建一个表,这个表包括该类的对象所有动态绑定的方法及其地址,包括父类的方法,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的

对于本例来说,Child 和 Base 的虚方法表如图 4-5 所示。

在这里插入图片描述

对 Child 类型来说,action 方法指向 Base 中的代码,toString 方法指向 Object 中的代码,而 step() 指向本类中的代码。

这个表在类加载的时候生成,当通过对象动态绑定方法的时候,只需要查找这个表就可以了,而不需要挨个查找每个父类。

接下来,我们看对变量的访问。

4.3.5 变量访问的过程

对变量的访问是静态绑定的,无论是类变量还是实例变量。代码中演示的是类变量:b.s 和 c.s,通过对象访问类变量,系统会转换为直接访问类变量 Base.s 和 Child.s。

例子中的实例变量都是 private 的,不能直接访问,如果是 public 的,则 b.a 访问的是对象中 Base 类定义的实例变量 a,而 c.a 访问的是对象中 Child 类定义的实例变量 a。

4.4 为什么说继承是把双刃剑

继承被广泛应用于各种 Java API、框架和类库之中,一方面它们内部大量使用继承,另一方面,它们设计了良好的框架结构,提供了大量基类和基础公共代码。使用者可以使用继承,重写适当方法进行定制,就可以简单方便的实现强大的功能。

但,继承为什么会有破坏力呢?主要是因为继承可能破坏封装,而封装可以说是程序设计的第一原则;另一方面,继承可能没有反映出 “is-a” 关系。下面我们详细来说明。

4.4.1 继承破坏封装

什么是封装呢?封装就是隐藏实现细节,提供简化接口。使用者只需要关注怎么用,而不需要关注内部是怎么实现的。实现细节可以随时修改,而不影响使用者。函数是封装,类也是封装。通过封装,才能在更高的层次上考虑和解决问题。可以说,封装是程序设计的第一原则,没有封装,代码之间到处存在着实现细节的依赖,则构建和维护复杂的程序是难以想象的。

继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。子类在继承父类的时候,往往不得不关注父类的实现细节,而父类在修改其内部实现的时候,如果不考虑子类,也往往会影响到子类。

我们通过一些例子来说明。这些例子主要用于演示,可以基本忽略其实际意义。

4.4.2 封装是如何被破坏的

我们来看一个简单的例子,这是基类代码:

public class Base {
    private static final int MAX_NUM = 1000;
    private int[] arr = new int[MAX_NUM];
    private int count;
    
    public void add(int number){
        if(count<MAX_NUM){
            arr[count++] = number;    
        }
    }
    
    public void addAll(int[] numbers){
        for(int num : numbers){
            add(num);
        }
    }
}

Base 提供了两个方法 add 和 addAll,将输入数字添加到内部数组中。对使用者来说,add 和 addAll 就是能够添加数字,具体是怎么添加的,应该不用关心。

下面是子类代码:

public class Child extends Base {
    
    private long sum;

    @Override
    public void add(int number) {
        super.add(number);
        sum+=number;
    }

    @Override
    public void addAll(int[] numbers) {
        super.addAll(numbers);
        for(int i=0;i<numbers.length;i++){
            sum+=numbers[i];
        }
    }
    
    public long getSum() {
        return sum;
    }
}

子类重写了基类的 add 和 addAll 方法,在添加数字的同时汇总数字,存储数字的和到实例变量 sum 中,并提供了方法 getSum 获取 sum 的值。

使用 Child 的代码如下所示:

public static void main(String[] args) {
    Child c = new Child();
    c.addAll(new int[]{1,2,3});
    System.out.println(c.getSum());
}

使用 addAll 添加 1,2,3,期望的输出是 1+2+3=6,实际输出呢?

12

实际输出是 12。为什么呢?查看代码不难看出,同一个数字被汇总了两次。子类的 addAll 方法首先调用了父类的 addAll 方法,而父类的 addAll 方法通过 add 方法添加,由于动态绑定,子类的 add 方法会执行,子类的 add 也会做汇总操作。

可以看出,如果子类不知道基类方法的实现细节,它就不能正确的进行扩展。知道了错误,现在我们修改子类实现,修改 addAll 方法为:

@Override
public void addAll(int[] numbers) {
    super.addAll(numbers);
}

也就是说,addAll 方法不再进行重复汇总。这下,程序就可以输出正确结果 6 了。

但是,基类 Base 决定修改 addAll 方法的实现,改为下面代码:

public void addAll(int[] numbers){
    for(int num : numbers){
        if(count<MAX_NUM){
            arr[count++] = num;    
        }
    }
}

也就是说,它不再通过调用 add 方法添加,这是 Base 类的实现细节。但是,修改了基类的内部细节后,上面使用子类的程序却错了,输出由正确值 6 变为了 0。

从这个例子,可以看出,子类和父类之间是细节依赖,子类扩展父类,仅仅知道父类能做什么是不够的,还需要知道父类是怎么做的,而父类的实现细节也不能随意修改,否则可能影响子类

更具体的说,子类需要知道父类的可重写方法之间的依赖关系,上例中,就是 add 和 addAll 方法之间的关系,而且这个依赖关系,父类不能随意改变

但即使这个依赖关系不变,封装还是可能被破坏

还是以上面的例子,我们先将 addAll 方法改回去,这次,我们在基类 Base 中添加一个方法 clear,这个方法的作用是将所有添加的数字清空,代码如下:

public void clear(){
    for(int i=0;i<count;i++){
        arr[i]=0;
    }
    count = 0;
}

基类添加一个方法不需要告诉子类,Child 类不知道 Base 类添加了这么一个方法,但因为继承关系,Child 类却自动拥有了这么一个方法。因此,Child 类的使用者可能会这么使用 Child 类:

public static void main(String[] args) {
    Child c = new Child();
    c.addAll(new int[]{1,2,3});
    c.clear();
    c.addAll(new int[]{1,2,3});
    System.out.println(c.getSum());
}

先添加一次,之后调用 clear 清空,又添加一次,最后输出 sum,期望结果是 6,但实际输出呢?是 12。为什么呢?因为 Child 没有重写 clear 方法,它需要增加如下代码,重置其内部的 sum 值:

@Override
public void clear() {
    super.clear();
    this.sum = 0;
}

以上,可以看出,父类不能随意增加公开方法,因为给父类增加就是给所有子类增加,而子类可能必须要重写该方法才能确保方法的正确性

总结一下:对于子类而言,通过继承实现,是没有安全保障的,父类修改内部实现细节,它的功能就可能会被破坏,而对于基类而言,让子类继承和重写方法,就可能丧失随意修改内部实现的自由

4.4.3 继承没有反映 “is-a” 关系

继承关系是被设计用来反映"is-a"关系的,子类是父类的一种,子类对象也属于父类,父类的属性和行为也一定适用于子类。就像橙子是水果一样,水果有的属性和行为,橙子也必然都有。

但现实中,设计完全符合"is-a"关系的继承关系是困难的。比如说,绝大部分鸟都会飞,可能就想给鸟类增加一个方法 fly() 表示飞,但有一些鸟就不会飞,比如说企鹅。

在"is-a"关系中,重写方法时,子类不应该改变父类预期的行为,但是,这是没有办法约束的。比如说,还是以鸟为例,你可能给父类增加了 fly() 方法,对企鹅,你可能想,企鹅不会飞,但可以走和游泳,就在企鹅的 fly() 方法中,实现了有关走或游泳的逻辑。

继承是应该被当做"is-a"关系使用的,但是,Java 并没有办法约束,父类有的属性和行为,子类并不一定都适用,子类还可以重写方法,实现与父类预期完全不一样的行为。

但通过父类引用操作子类对象的程序而言,它是把对象当做父类对象来看待的,期望对象符合父类中声明的属性和行为。如果不符合,结果是什么呢?混乱。

4.4.4 如何应对继承的双面性?

继承既强大又有破坏性,那怎么办呢?

  1. 避免使用继承
  2. 正确使用继承

我们先来看怎么避免继承,有三种方法:

  • 使用 final 关键字
  • 优先使用组合而非继承
  • 使用接口

4.4.4.1 使用 final 避免继承

在 4.2 节,我们提到过 final 类和 final 方法,final 方法不能被重写,final 类不能被继承,我们没有解释为什么需要它们。通过上面的介绍,我们就应该能够理解其中的一些原因了。

给方法加 final 修饰符,父类就保留了随意修改这个方法内部实现的自由,使用这个方法的程序也可以确保其行为是符合父类声明的。

给类加 final 修饰符,父类就保留了随意修改这个类实现的自由,使用者也可以放心的使用它,而不用担心一个父类引用的变量,实际指向的却是一个完全不符合预期行为的子类对象。

4.4.4.2 优先使用组合而非继承

使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该被优先使用。还是上面的例子,我们使用组合来重写一下子类,代码如下:

public class Child {
    private Base base;
    private long sum;

    public Child(){
        base = new Base();
    }
    
    public void add(int number) {
        base.add(number);
        sum+=number;
    }

    public void addAll(int[] numbers) {
        base.addAll(numbers);
        for(int i=0;i<numbers.length;i++){
            sum+=numbers[i];
        }
    }
    
    public long getSum() {
        return sum;
    }
}

这样,子类就不需要关注基类是如何实现的了,基类修改实现细节,增加公开方法,也不会影响到子类了。

但,组合的问题是,子类对象不能被当做基类对象,被统一处理了。解决方法是使用接口。关于接口我们暂不介绍,留待下节。

4.4.4.3 正确使用继承

如果要使用继承,怎么正确使用呢?使用继承大概主要有三种场景

  1. 基类是别人写的,我们写子类。
  2. 我们写基类,别人可能写子类。
  3. 基类、子类都是我们写的。

第一种场景中,基类主要是 Java API,其他框架或类库中的类,在这种情况下,我们主要通过扩展基类,实现自定义行为,这种情况下需要注意的是:

  1. 重写方法不要改变预期的行为。
  2. 阅读文档说明,理解可重写方法的实现机制,尤其是方法之间的调用关系。
  3. 在基类修改的情况下,阅读其修改说明,相应修改子类。

第二种场景中,我们写基类给别人用,在这种情况下,需要注意的是:

  1. 使用继承反映真正的"is-a"关系,只将真正公共的部分放到基类。
  2. 对不希望被重写的公开方法添加 final 修饰符。
  3. 写文档,说明可重写方法的实现机制,为子类提供指导,告诉子类应该如何重写。
  4. 在基类修改可能影响子类时,写修改说明。

第三种场景,我们既写基类、也写子类,关于基类,注意事项和第二种场景类似,关于子类,注意事项和第一种场景类似,不过程序都由我们控制,要求可以适当放松一些。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

bm1998

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值