计算机程序的思维逻辑 (16) - 继承的细节【转】

上节我们介绍了继承和多态的基本概念,基本概念是比较简单的,子类继承父类,自动拥有父类的属性和行为,并可扩展属性和行为,同时,可重写父类的方法以修改行为。

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

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

下面我们逐个来解释。

构造方法

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);
    }
}

构造方法调用重写方法

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

这是基类代码:

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的方法。

重名与静态绑定

上节我们说到,子类可以重写父类非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方法,都是静态绑定的。

重载和重写

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

这里基类代码:

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

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

父子类型转换

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

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值,表示变量引用的对象是不是该类或其子类的对象。

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的一个常用场景。关于更多设计模式的内容我们暂不介绍。

可见性重写

重写方法时,一般并不会修改方法的可见性。但我们还是要说明一点,重写时,子类方法不能降低父类方法的可见性,不能降低是指,父类如果是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"的关系,但子类可以增加父类的行为,所以提升可见性是没有问题的。

防止继承 (final)

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

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

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

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

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

小结

本节我们讨论了Java继承概念引入的一些细节,有些细节可能平时遇到的比较少,但我们还是需要对它们有一个比较好的了解,包括构造方法的一些细节,变量和方法的重名,父子类型转换,protected,可见性重写,final等。

但还有些重要的地方我们没有讨论,比如,创建子类对象的具体过程?动态绑定是如何实现的?让我们下节来探索继承实现的基本原理。

----------------

时间: 2024-11-17 22:27:45

计算机程序的思维逻辑 (16) - 继承的细节【转】的相关文章

计算机程序的思维逻辑 (16) - 继承的细节

上节我们介绍了继承和多态的基本概念,基本概念是比较简单的,子类继承父类,自动拥有父类的属性和行为,并可扩展属性和行为,同时,可重写父类的方法以修改行为. 但继承和多态概念还有一些相关的细节,本节就来探讨这些细节,具体包括: 构造方法 重名与静态绑定 重载和重写 父子类型转换 继承访问权限 (protected) 可见性重写 防止继承 (final) 下面我们逐个来解释. 构造方法 super 上节我们说过,子类可以通过super(...)调用父类的构造方法,如果子类没有通过super(...)调

计算机程序的思维逻辑 (37) - 泛型 (下) - 细节和局限性

35节介绍了泛型的基本概念和原理,上节介绍了泛型中的通配符,本节来介绍泛型中的一些细节和局限性. 这些局限性主要与Java的实现机制有关,Java中,泛型是通过类型擦除来实现的,类型参数在编译时会被替换为Object,运行时Java虚拟机不知道泛型这回事,这带来了很多局限性,其中有的部分是比较容易理解的,有的则是非常违反直觉的. 一项技术,往往只有理解了其局限性,我们才算是真正理解了它,才能更好的应用它. 下面,我们将从以下几个方面来介绍这些细节和局限性: 使用泛型类.方法和接口 定义泛型类.方

计算机程序的思维逻辑 (17) - 继承实现的基本原理

第15节我们介绍了继承和多态的基本概念,而上节我们进一步介绍了继承的一些细节,本节我们通过一个例子,来介绍继承实现的基本原理.需要说明的是,本节主要从概念上来介绍原理,实际实现细节可能与此不同. 例子 这是基类代码: public class Base { public static int s; private int a; static { System.out.println("基类静态代码块, s: "+s); s = 1; } { System.out.println(&qu

计算机程序的思维逻辑 (22) - 代码的组织机制

使用任何语言进行编程都有一个类似的问题,那就是如何组织代码,具体来说,如何避免命名冲突?如何合理组织各种源文件?如何使用第三方库?各种代码和依赖库如何编译连接为一个完整的程序? 本节就来讨论Java中的解决机制,具体包括包.jar包.程序的编译与连接,从包开始. 包的概念 使用任何语言进行编程都有一个相同的问题,就是命名冲突,程序一般不全是一个人写的,会调用系统提供的代码.第三方库中的代码.项目中其他人写的代码等,不同的人就不同的目的可能定义同样的类名/接口名,Java中解决这个问题的方法就是包

计算机程序的思维逻辑 (23) - 枚举的本质

前面系列,我们介绍了Java中表示和操作数据的基本数据类型.类和接口,本节探讨Java中的枚举类型. 所谓枚举,是一种特殊的数据,它的取值是有限的,可以枚举出来的,比如说一年就是有四季.一周有七天,虽然使用类也可以处理这种数据,但枚举类型更为简洁.安全和方便. 下面我们就来介绍枚举的使用,同时介绍其实现原理. 基础 基本用法 定义和使用基本的枚举是比较简单的,我们来看个例子,为表示衣服的尺寸,我们定义一个枚举类型Size,包括三个尺寸,小/中/大,代码如下: public enum Size {

计算机程序的思维逻辑 (21) - 内部类的本质

内部类 之前我们所说的类都对应于一个独立的Java源文件,但一个类还可以放在另一个类的内部,称之为内部类,相对而言,包含它的类称之为外部类. 为什么要放到别的类内部呢?一般而言,内部类与包含它的外部类有比较密切的关系,而与其他类关系不大,定义在类内部,可以实现对外部完全隐藏,可以有更好的封装性,代码实现上也往往更为简洁. 不过,内部类只是Java编译器的概念,对于Java虚拟机而言,它是不知道内部类这回事的, 每个内部类最后都会被编译为一个独立的类,生成一个独立的字节码文件. 也就是说,每个内部

计算机程序的思维逻辑 (25) - 异常 (下)

上节我们介绍了异常的基本概念和异常类,本节我们进一步介绍对异常的处理,我们先来看Java语言对异常处理的支持,然后探讨在实际中到底应该如何处理异常. 异常处理 catch匹配 上节简单介绍了使用try/catch捕获异常,其中catch只有一条,其实,catch还可以有多条,每条对应一个异常类型,比如说: try{ //可能触发异常的代码 }catch(NumberFormatException e){ System.out.println("not valid number"); }

计算机程序的思维逻辑 (28) - 剖析包装类 (下)

本节探讨Character类,它的基本用法我们在包装类第一节已经介绍了,本节不再赘述.Character类除了封装了一个char外,还有什么可介绍的呢?它有很多静态方法,封装了Unicode字符级别的各种操作,是Java文本处理的基础,注意不是char级别,Unicode字符并不等同于char,本节详细介绍这些方法以及相关的Unicode知识. 在介绍这些方法之前,我们需要回顾一下字符在Java中的表示方法,我们在第六节.第七节.第八节介绍过编码.Unicode.char等知识,我们先简要回顾一

计算机程序的思维逻辑 (29) - 剖析String

上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比较简单直接的,我们来看下. 基本用法 可以通过常量定义String变量 String name = "老马说编程"; 也可以通过new创建String String name = new String("老马说编程"); String可以直接使用+和+=运算符,如: S