• 【Java编程思想】8.多态


    在面向对象的程序设计语言中,多态是继数据抽象继承之后的第三种基本特征。
    多态分离了“做什么”和“怎么做”,让接口和实现分离开,改善了代码的可读性和组织结构,创建了可拓展的程序。

    • 封装,通过合并特征和行为来创建新的数据类型。
    • 实现隐藏,通过将细节“私有化”把接口和实现分离开来。
    • 多态,消除类型之间的耦合联系。多态方法调用允许一种类型表现出与其他相似类型之间的区别,只要他们都是从同一基类导出来的。-->这种区别是根据方法行为的不同而表示出来的,虽然这种方法都可以通过同一个基类来调用。

    8.1 再论向上转型

    像第七章所说的那样,对象既可以作为他自己本身的类型使用,也可以作为他的基本类型使用,把这种对某个对象的引用视为对其基类类型的引用的做法称之为向上转型(因为在 UML 继承树的画法中,基类是在上方的)。

    在发生向上转型的时候,我们可以很清楚的感受到,系统和编写者都在刻意的去忽视传递对象的类型-->导出类的接口向上转型到基类,可能会“缩小”接口,但是一定不会比基类的全部接口更窄。
    这意味着在我们编写方法时,只接收基类作为参数,那么这样就可以不必为每一种导出类编写对应的接受参数方法。这就是多态的一种方式。


    8.2 转机

    下面我们开始更进一步的讨论。

    在向上转型的时候,方法接收基类的引用参数。那么编译器是如何得知引用是指向某一个特定导出类的呢。实际上编译器也无法得知,我们需要进一步了解绑定。

    • 将一个方法调用同一个方法主体关联起来被称为绑定
    • 若在程序执行前进行绑定(如果有的话,由编译器和连接程序实现),就叫做前期绑定。前期绑定是面向过程的语言中的默认的绑定方式
    • 而在运行时根据对象的类型进行绑定,被称为后期绑定,也叫做动态绑定运行时绑定。一种语言想实现后期绑定,就必须具有一种能在运行时能判断对象的类型从而调用恰当方法的机制。

    回到上面的问题,编译器是如何得知引用是指向某一个特定导出类的呢。我们之所以有疑问,就在于如果使用前期绑定,那么当编译器只有一个基类引用的时候,他是不应该知道应该调用哪个方法才对。但是在后期绑定中,编译器其实不知道,也不需要知道导出类对象的类型,方法调用机制能够找到正确的方法体并加以调用。


    Java 中,除了 static 方法和 final 方法(private 方法属于 final 方法),其他所有方法,都是后期绑定-->就是说,通常情况下不需要去判定是否应该进行后期绑定(他会自动发生)。
    final 方法其实是有效地关闭了动态绑定(后期绑定),或者是告诉了编译器不需要对一个方法做动态绑定。

    建立在上面的基础,我们可以很容易的理解:

    List<Integer> intList = new ArrayList();
    intList.get(0);
    

    看起来 get() 方法调用的是 List 的引用,其实由于后期绑定(多态),调用的是 ArrayList.get() 方法。

    编译器不需要获取任何特殊信息就能进行正确的调用,对方法的所有调用都是通过动态绑定进行的


    在一个设计良好的 OOP 程序中,大多数或者所有方法,都会遵循特定方法的模型,而且只与基类接口通信,这样的程序就是可拓展的。-->可以从通用的基类继承出新的数据类型,操纵基类接口的方法也不需要任何改动就可以应用于新类。
    多态最后的目的,就是希望能修改代码后,不会对程序中其他不受影响的部分受到破坏。


    关于多态的缺陷:

    1. ”覆盖“私有方法:非 private 方法才能被覆盖,如果覆盖了 private 方法,那这个方法并不会被在导出类中被重载。因此,导出类中,对基类中的 private 方法最好不要采用相同的名字。
    2. 域与静态方法:其实只有普通的方法调用是多态的。其他情况,例如直接访问某个域,这个访问在编译器就会被解析。
      不过通常我们会将所有的域设置为 private,让他们不能被直接访问。
      另外也不会对基类中的域和导出类中的域赋予相同的名字。
      静态方法的行为不会具有多态性。静态方法是与类关联的,而不是和单个对象关联的。

    8.3 构造器和多态

    基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接-->每个基类的构造器都能得到调用。
    这样做的意义在于:构造器能检查对象是否被正确构造,导出类只能访问自己的成员,基类的成员需要基类自己才能访问。只有在每个构造器都得到调用的情况下,才能保证无论在任何情况都能正确创造出完成的对象。

    复杂对象调用构造器的顺序(不严谨的):

    1. 调用基类构造器。这个步骤是递归的,首先是构造这种层次结构的根,然后是下一层导出类,最后知道最低层的导出类。
    2. 按声明顺序调用成员的初始化方法。
    3. 调用导出类构造器主体。

    如果不清楚可以按照下面的例子查看。

    class Meal {
        Meal() { print("Meal()"); // 1}
    }
    class Bread {
        Bread() { print("Bread()"); }
    }
    class Cheese {
        Cheese() { print("Cheese()"); }
    }
    class Lettuce {
        Lettuce() { print("Lettuce()"); }
    }
    class Lunch extends Meal {
        Lunch() { print("Lunch()"); // 2}
    }
    class PortableLunch extends Lunch {
        PortableLunch() { print("PortableLunch()"); // 3}
    }
    
    public class Sandwich extends PortableLunch {
        private Bread b = new Bread(); // 4
        private Cheese c = new Cheese(); // 5
        private Lettuce l = new Lettuce(); // 6
        public Sandwich() { print("Sandwich()"); }
        public static void main(String[] args) {
            new Sandwich(); // 7
        }
    

    输出如下:

    1 Meal()
    2 Lunch()
    3 PortableLunch()
    4 Bread()
    5 Cheese()
    6 Lettuce()
    7 Sandwich()
    

    通过组合和继承来创建新类,不用担心对象的清理问题。
    不过加入面临这方面问题时(自定义对象清理)一定要注意,

    • 万一某个子对象要依赖于其他对象,销毁的顺序应该和初始化的顺序相反。
    • 对于字段,则意味着和声明的顺序相反(因为字段的初始化是按照声明的顺序进行的)。
    • 对于基类,应该先对其导出类进行清理,然后才是基类(因为导出类的清理可能会调用积累中的某些方法,要使方法可用则基类不能提前被销毁)。

    当成员对象中存在于其他一个或者多个对象共享的情况,或许需要额外的引用计数来跟踪仍旧访问着的共享对象的对象数量。
    可以使用由 tatic long 修饰一个静态成员,帮助跟踪所创建的类的实例的数量,还可以为 id 提供数值。


    问题:如果在一个构造器内部调用正在构造的对象的某个动态绑定方法,那么对象无法知道自己是属于方法所在的那个类,还是属于那个类的导出类。
    举例

    class Glyph {
        void draw() { print("Glyph.draw()"); }
        Glyph() {
            print("Glyph() before draw()");
            draw();
            print("Glyph() after draw()");
        }
    }	
    class RoundGlyph extends Glyph {
        private int radius = 1;
        RoundGlyph(int r) {
            radius = r;
            print("RoundGlyph.RoundGlyph(), radius = " + radius);
        }
        void draw() {
            print("RoundGlyph.draw(), radius = " + radius);
        }
    }	
    
    public class PolyConstructors {
        public static void main(String[] args) {
            new RoundGlyph(5);
        }
    }
    

    输出

    Glyph() before draw()
    RoundGlyph.draw(), radius = 0
    Glyph() after draw()
    RoundGlyph.RoundGlyph(), radius = 5
    

    分析
    Glyph.draw() 方法被设计为将要被覆盖,这种覆盖在 RoundGlyph 中发生。但是 Glyph 中调用了这个方法,导致了对 RoundGlyph.draw() 的调用。但是这个调用是有问题的,RoundGlyph 根本没有开始初始化,所以 radius 的值是默认初始值0.
    结论
    构造器初始化的实际过程是:

    1. 在其他任何事物发生之前,将分配给对象的存储空间初始化成二进制零。
    2. 调用基类构造器。
    3. 按照声明的顺序调用成员的初始化方法。
    4. 调用导出类的构造器主体。

    为了避免发生上述这类不可控的风险,在编写构造器时,要用尽可能简单的方法使对象进入正常状态;如果可以的话尽量避免其他方法。在构造器中唯一能够安全调用的方法,是基类的 final 方法(也适用于 private 方法),这些方法不能被覆盖,也就不会出现上面的蛋疼问题。


    8.4 协变返回类型

    Java SE5中添加了协变返回类型,他表示在导出类中的被覆盖方法,可以返回基类方法的返回类型的某种导出类型。
    就是说协变返回类型允许重写方法返回更具体的导出类型(如果基类方法返回的是基类的话)。


    8.5 用继承进行设计

    与继承相比,组合其实要更灵活,因为他可以动态选择类型(也就是选择了行为)。
    而继承在编译时就要明确类型,不能再运行期间决定继承不同的对象。

    一条通用的准则就是:用继承表达行为间的差异,并用字段表达状态上的变化。


    如下图的继承方式,可以说是纯粹的继承层次关系-->只有在基类中已经建立的方法,才可以在导出类被覆盖。

    像这种纯继承模式,也被称作纯粹的“is-a”关系(是一个)-->这个类的接口已经确定了他应该是什么,导出类的接口绝对不会少于基类。
    这种设计,保证基类可以介绍发送给导出类的任何消息(因为二者有着完全相同的接口)。只需要进行向上转型,就不需要知道正在处理的对象的确切类型。

    但是实际使用中,我们更倾向于使用“is-like-a”关系(像一个),因为导出类就像一个基类-->有着相同的基本接口,还具有额外的其他方法。但是这么做的话,当使用向上转型时,拓展接口就不能被调用了。


    使用向上转型的时候会丢失具体的类型信息。
    使用向下转型可以获取到具体的类型信息。不过,向下转型是不安全的,因为基类接口会小于等于导出类的接口。

    为了确保安全,Java 中所有的转型都会得到检查。如果检查未通过,会返回 ClassCastException(类型转换异常)
    这种在运行期间对类型进行检查的行为被称为“运行时类型识别”,简称 RTTI(Run-time type information)。

  • 相关阅读:
    素数推断算法(高效率)
    Hibernate的fetch
    TRIZ系列-创新原理-22-变害为利原理
    Offer是否具有法律效力?
    浅谈C++多态性
    C语言scanf函数详解
    大话设计模式之原型模式
    Android源码文件夹结构
    简单的REST的框架实现
    android startActivityForResult的用法
  • 原文地址:https://www.cnblogs.com/chentnt/p/9791888.html
Copyright © 2020-2023  润新知