java三大特性之多态

70 阅读14分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第19天,点击查看活动详情

4 多态

Java引用变量有两个类型:一个是编译时类型,一个是运行时类型。编译时类型由声明该变量时使用的类型决定,运行时类型由实际赋给该变量的对象决定。如果编译时类型和运行时类型不一致,就可能出现所谓的多态(Polymorphism)。

4.1 多态性

        class BaseClass
        {
            public int book=6;
            public void base()
            {
                  System.out.println("父类的普通方法");
            }
            public void test()
            {
                  System.out.println("父类的被覆盖的方法");
            }
        }
        public class SubClass extends BaseClass
        {
            //重新定义一个book实例Field隐藏父类的book实例Field
            public String book="轻量级Java EE企业应用实战";
            public void test()
            {
                  System.out.println("子类的覆盖父类的方法");
            }
            public void sub()
            {
                  System.out.println("子类的普通方法");
            }
            public static void main(String[] args)
            {
                  //下面编译时类型和运行时类型完全一样,因此不存在多态
                  BaseClass bc=new BaseClass();
                  //输出 6
                  System.out.println(bc.book);
                  //下面两次调用将执行BaseClass的方法
                  bc.base();
                  bc.test();
                  //下面编译时类型和运行时类型完全一样,因此不存在多态
                  SubClass sc=new SubClass();
                  //输出"轻量级J2EE企业应用实战"
                  System.out.println(sc.book);
                  //下面调用将执行从父类继承到的base方法
                  sc.base();
                  //下面调用将执行当前类的test方法
                  sc.test();
                  //下面编译时类型和运行时类型不一样,多态发生
                  BaseClass ploymophicBc=new SubClass();
                  //输出 6 —— 表明访问的是父类Field
                  System.out.println(ploymophicBc.book);
                  //下面调用将执行从父类继承到的base方法
                  ploymophicBc.base();
                  //下面调用将执行当前类的test方法
                  ploymophicBc.test();
                  //因为ploymophicBc的编译时类型是BaseClass
                  //BaseClass类没有提供sub方法,所以下面代码编译时会出现错误
                  //ploymophicBc.sub();
            }
        }

但第三个引用变量ploymophicBc则比较特殊,它的编译时类型是BaseClass,而运行时类型是SubClass,当调用该引用变量的test方法(BaseClass类中定义了该方法,子类SubClass覆盖了父类的该方法)时,实际执行的是SubClass类中覆盖后的test方法,这就可能出现多态了。

当把一个子类对象直接赋给父类引用变量时,例如上面的BaseClass ploymophicBc=new SubClass();这个ploymophicBc引用变量的编译时类型是BaseClass,而运行时类型是SubClass,当运行时调用该引用变量的方法时,其方法行为总是表现出子类方法的行为特征,而不是父类方法的行为特征,这就可能出现:相同类型的变量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。

上面的main的方法中注释了ploymophicBc.sub();,这行代码会在编译时引发错误。虽然ploymophicBc引用变量实际上确实包含sub()方法(例如,可以通过反射来执行该方法),但因为它的编译时类型为BaseClass,因此编译时无法调用sub()方法。

与方法不同的是,对象的Field则不具备多态性。比如上面的ploymophicBc引用变量,程序中输出它的book Field时,并不是输出SubClass类里定义的实例Field,而是输出BaseClass类的实例Field。

注意: 引用变量在编译阶段只能调用其编译时类型所具有的方法,但运行时则执行它运行时类型所具有的方法。因此,编写Java代码时,引用变量只能调用声明该变量时所用类里包含的方法。例如,通过Object p=new Person()代码定义一个变量p,则这个p只能调用Object类的方法,而不能调用Person类里定义的方法。

4.2 引用变量的强制类型转换

编写Java程序时,引用变量只能调用它编译时类型的方法,而不能调用它运行时类型的方法,即使它实际所引用的对象确实包含该方法。如果需要让这个引用变量调用它运行时类型的方法,则必须把它强制类型转换成运行时类型,强制类型转换需要借助于类型转换运算符。

引用类型之间的转换只能在具有继承关系的两个类型之间进行,如果是两个没有任何继承关系的类型,则无法进行类型转换,否则编译时就会出现错误如果试图把一个父类实例转换成子类类型,则这个对象必须实际上是子类实例才行(即编译时类型为父类类型,而运行时类型是子类类型)

考虑到进行强制类型转换时可能出现异常,因此进行类型转换之前应先通过instanceof运算符来判断是否可以成功转换。

        if (objPri instanceof String)
        {
            String str=(String)objPri;
        }

4.3 instanceof运算符

instanceof运算符的前一个操作数通常是一个引用类型变量,后一个操作数通常是一个(也可以是接口,可以把接口理解成一种特殊的类),它用于判断前面的对象是否是后面的类,或者其子类、实现类的实例。如果是,则返回true,否则返回false。 instanceof运算符的作用是:在进行强制类型转换之前,首先判断前一个对象是否是后一个类的实例,是否可以成功转换,从而保证代码更加健壮。 instanceof(type)是Java提供的两个相关的运算符,通常先用instanceof判断一个对象是否可以强制类型转换,然后再使用(type)运算符进行强制类型转换,从而保证程序不会出现错误。

5 继承与组合

继承是实现类重用的重要手段,但继承带来了一个最大的坏处:破坏封装。相比之下,组合也是实现类重用的重要方式,而采用组合方式来实现类重用则能提供更好的封装性。

5.1 使用继承的注意点

子类扩展父类时,子类可以从父类继承得到Field和方法,如果访问权限允许,子类可以直接访问父类的Field和方法,相当于子类可以直接复用父类的Field和方法,确实非常方便。

继承带来了高度复用的同时,也带来了一个严重的问题:继承严重地破坏了父类的封装性。前面介绍封装时提到:每个类都应该封装它内部信息和实现细节,而只暴露必要的方法给其他类使用。但在继承关系中,子类可以直接访问父类的Field(内部信息)和方法,从而造成子类和父类的严重耦合。

为了保证父类有良好的封装性,不会被子类随意改变,设计父类通常应该遵循如下规则。

  • 尽量隐藏父类的内部数据。尽量把父类的所有Field都设置成private访问类型,不要让子类直接访问父类的Field。
  • 不要让子类可以随意访问、修改父类的方法。父类中那些仅为辅助其他的工具方法,应该使用private访问控制符修饰,让子类无法访问该方法;如果父类中的方法需要被外部类调用,则必须以public修饰,但又不希望子类重写该方法,可以使用final修饰符(该修饰符后面会有更详细的介绍)来修饰该方法;如果希望父类的某个方法被子类重写,但不希望被其他类自由访问,则可以使用protected来修饰该方法。
  • 尽量不要在父类构造器中调用将要被子类重写的方法
        class Base
        {
            public Base()
            {
                  test();
            }
            public void test()          //①号test方法
            {
                  System.out.println("将被子类重写的方法");
            }
        }
        public class Sub extends Base
        {
            private String name;
            public void test()       //②号test方法
            {
                  System.out.println("子类重写父类的方法,"
                        + "其name字符串长度" + name.length());
            }
            public static void main(String[] args)
            {
                  //下面代码会引发空指针异常
                  Sub s=new Sub();
            }
        }

当系统试图创建Sub对象时,同样会先执行其父类构造器,如果父类构造器调用了被其子类重写的方法,则变成调用被子类重写后的方法。当创建Sub对象时,会先执行Base类中的Base构造器,而Base构造器中调用了test方法——并不是调用①号test方法,而是调用②号test方法,此时Sub对象的name Field是null,因此将引发空指针异常。

到底何时需要从父类派生新的子类呢?不仅需要保证子类是一种特殊的父类,而且需要具备以下两个条件之一。

  • 子类需要额外增加属性,而不仅仅是属性值的改变。例如从Person类派生出Student子类,Person类里没有提供grade(年级)属性,而Student类需要grade属性来保存Student对象就读的年级,这种父类到子类的派生,就符合Java继承的前提。
  • 子类需要增加自己独有的行为方式(包括增加新的方法或重写父类的方法)。例如从Person类派生出Teacher类,其中Teacher类需要增加一个teaching方法,该方法用于描述Teacher对象独有的行为方式:教学。

5.2 利用组合实现复用

对于继承而言,子类可以直接获得父类的public方法,程序使用子类时,将可以直接访问该子类从父类那里继承到的方法;而组合则是把旧类对象作为新类的Field嵌入,用以实现新类的功能,用户看到的是新类的方法,而不能看到被嵌入对象的方法。因此,通常需要在新类里使用private修饰被嵌入的旧类对象

使用组合关系来实现复用时,需要创建两个Animal对象,是不是意味着使用组合关系系统开销更大?

答:不会。回忆前面介绍继承时所讲的内容,当创建一个子类对象时,系统不仅需要为该子类定义的Field分配内存空间,而且需要为它的父类所定义的Field分配内存空间。如果采用继承的设计方式,假设父类定义了2个Field,子类定义了3个Field,当创建子类实例时,系统需要为子类实例分配5块内存空间;如果采用组合的设计方式,先创建被嵌入类实例,此时需要分配 2 块内存空间,再创建整体类实例,也需要分配 3 块内存空间,只是需要多一个引用变量来引用被嵌入的对象。通过这个分析来看,继承设计与组合设计的系统开销不会有本质的差别。

到底该用继承?还是该用组合呢?继承是对已有的类做一番改造,以此获得一个特殊的版本。简而言之,就是将一个较为抽象的类改造成能适用于某些特定需求的类。因此,对于上面的Wolf和Animal的关系,使用继承更能表达其现实意义。用一个动物来合成一匹狼毫无意义:狼并不是由动物组成的。反之,如果两个类之间有明确的整体、部分的关系,例如Person类需要复用Arm类的方法(Person对象由Arm对象组合而成),此时就应该采用组合关系来实现复用,把Arm作为Person类的嵌入Field,借助于Arm的方法来实现Person的方法,这是一个不错的选择。

总之,继承要表达的是一种“是(is-a)”的关系,而组合表达的是“有(has-a)”的关系。

6 初始化块

与构造器作用非常类似的是初始化块,它也可以对Java对象进行初始化操作。

6.1 使用初始化块

        [修饰符] {
            /   始化块的可执行性代码
            ...
        }

初始化块的修饰符只能是static,使用static修饰的初始化块被称为静态初始化块

从运行结果可以看出,当创建Java对象时,系统总是先调用该类里定义的初始化块,如果一个类里定义了2个普通初始化块,则前面定义的初始化块先执行,后面定义的初始化块后执行。

初始化块只在创建Java对象时隐式执行,而且在执行构造器之前执行

普通初始化块、声明实例Field指定的默认值都可认为是对象的初始化代码,它们的执行顺序与源程序中的排列顺序相同。

        public class InstanceInitTest
        {
            //先执行初始化块将a Field赋值为6
            {
                  a=6;
            }
            //再执行将a Field赋值为9
            int a=9;
            public static void main(String[] args)
            {
                  //下面代码将输出9
                  System.out.println(new InstanceInitTest().a);
            }
        }

提示: 当Java创建一个对象时,系统先为该对象的所有实例Field分配内存(前提是该类已经被加载过了),接着程序开始对这些实例变量执行初始化,其初始化顺序是:先执行初始化块或声明Field时指定的初始值,再执行构造器里指定的初始值。

6.2 初始化块和构造器

在这里插入图片描述

从图中可以看出,如果两个构造器中有相同的初始化代码,这些初始化代码无须接收参数,就可以把它们放在初始化块中定义。通过把多个构造器中的相同代码提取到初始化块中定义,能更好地提高初始化代码的复用,提高整个应用的可维护性。

与构造器类似,创建一个Java对象时,不仅会执行该类的普通初始化块和构造器,而且系统会一直上溯到java.lang.Object类,先执行java.lang.Object类的初始化块,开始执行java.lang.Object的构造器,依次向下执行其父类的初始化块,开始执行其父类的构造器……最后才执行该类的初始化块和构造器,返回该类的对象。

6.3 静态初始化块

如果定义初始化块时使用了static修饰符,则这个初始化块就变成了静态初始化块,也被称为类初始化块。静态初始化块是类相关的,系统将在类初始化阶段执行静态初始化块,而不是在创建对象时才执行。因此静态初始化块总是比普通初始化块先执行

静态初始化块不能对实例Field进行初始化处理。

静态初始化块也被称为类初始化块,也属于类的静态成员,同样需要遵循静态成员不能访问非静态成员的规则,因此静态初始化块不能访问非静态成员,包括不能访问实例Field和实例方法。

        class Root
        {
            static{
                  System.out.println("Root的静态初始化块");
            }
            {
                  System.out.println("Root的普通初始化块");
            }
            public Root()
            {
                  System.out.println("Root的无参数的构造器");
            }
        }
        class Mid extends Root
        {
            static{
                  System.out.println("Mid的静态初始化块");
            }
            {
                  System.out.println("Mid的普通初始化块");
            }
            public Mid()
            {
                  System.out.println("Mid的无参数的构造器");
            }
            public Mid(String msg)
            {
                  //通过this调用同一类中重载的构造器
                  this();
                  System.out.println("Mid的带参数构造器,其参数值:"
                        + msg);
            }
        }
        class Leaf extends Mid
        {
            static{
                  System.out.println("Leaf的静态初始化块");
            }
            {
                  System.out.println("Leaf的普通初始化块");
            }
            public Leaf()
            {
                  //通过super调用父类中有一个字符串参数的构造器
                  super("疯狂Java讲义");
                  System.out.println("执行Leaf的构造器");
            }
        }
        public class Test
        {
            public static void main(String[] args)
            {
                  new Leaf();
                  new Leaf();
            }
        }

在这里插入图片描述 一旦Leaf类初始化成功后,Leaf类在该虚拟机里将一直存在,因此当第二次创建Leaf实例时无须再次对Leaf类进行初始化。

注意: Java系统加载并初始化某个类时,总是保证该类的所有父类(包括直接父类和间接父类)全部加载并初始化

静态初始化块和声明静态Field时所指定的初始值都是该类的初始化代码,它们的执行顺序与源程序中的排列顺序相同

提示:当JVM第一次主动使用某个类时,系统会在类准备阶段为该类的所有静态Field分配内存;在初始化阶段则负责初始化这些静态Field,初始化静态Field就是执行类初始化代码或者声明类Field时指定的初始值,它们的执行顺序与源代码中的排列顺序相同。