面向对象的编程(OOP)-软件构造学习总结05


前言

这篇文章是我对软件构造课程的面向对象编程(OOP)章节的学习总结,以供未来使用。文章中的图片均来自课程教师的讲义。
主要内容包括:

  • OOP中的基本概念
  • 接口
  • 封装与信息隐藏
  • 继承与重写
  • 三种多态、子类型、重载
  • 绑定与分派

一、基本概念

对象(Object)

现实世界中的一个物体具有很多种性质,但我们可以将其抽象为两种——状态(state)和行为(behavior)。换句话说,只要我们对一个物体的状态和行为有了足够充分的描述,那我们就可以抽象的表示一个物体。
举个例子:一只狗所具有的状态包括毛色、名字、健康程度、是否饥饿等等。其具有的行为有吼叫、进食、跑等等。当对一只狗的状态和行为有了充分的描述后,我们就可以抽象的把这只狗表示出来。
这样将状态与行为捆绑在一起来描述一个现实事物的抽象表示就是对象(Object)。在Java中,对象的状态被描述为字段(fields) / 属性 / 成员变量;对象的行为被描述为方法(methods) / 函数


类(Class)

Class)是一种用来描述对象的模板或蓝图。
类定义了对象的状态与行为,它们都属于类的成员(members)。
类定义了对象的类型和实现。即对象在什么情况下可以被使用以及对象是如何做一些事情的。
宽松的来讲,类的方法就是它的APIApplication Programming Interface应用程序编程接口)。它定义了用户应该如何与其交互。
举个例子:我们可以统一的用状态:毛色、名字;行为,吼叫、跑、进食来作为一个模板用以描述狗。对于任意一只狗,我们可以对照着这个模板填写,比如一只褐色的叫做Poppy的狗,这样就形成了一个对象。而这个生成对象的模板就是关于狗的类。


类成员变量/方法 与 实例成员变量/方法

一个与类相关联而不是与类的实例相关联的变量就是类成员变量(class variables),换作方法就是类方法*(class methods*)。它们使用Static关键字来修饰。
与之对应的是实例成员变量(instance variables)和实例方法(instance methods)。它们不使用关键字修饰。
使用类成员变量和类方法时不需要创建类的实例,使用类名.变量/方法的形式即可。而使用实例成员变量和实例方法需要创建类的实例,使用对象名.变量/方法来指明。
它们在内存中的区别是:在这里插入图片描述
可见,实例方法是每一个实例对应一段栈内存空间;而类方法则是多个实例共用一段栈内存空间,也就是与整个类相关联。对于成员变量也是如此。
两者具有不同的使用场景:

类成员变量和方法

  • 适用于描述类本身的属性和行为,而不是类的每个实例的独特属性和行为。
  • 通常用于定义与整个类相关的共享数据或共享行为,例如工具类中的静态方法,记录类的实例数量等。
  • 当数据或行为与类的所有实例都密切相关时,使用类成员变量和方法可以提高内存效率,并减少重复定义。

实例成员变量和方法:

  • 适用于描述类的每个实例的独特属性和行为,每个实例都有自己的一份。
  • 通常用于处理与实例状态相关的操作,例如对象的初始化、状态修改、实例特定的计算等。
  • 当数据或行为需要与类的每个实例分开存储和操作时,使用实例成员变量和方法可以提供更好的封装性和灵活性。

二、接口(Interface)

基本概念

Java中的接口(Interface)就是一个不包含任何实现的方法声明的列表。
正如我们在上一篇文章抽象数据类型(ADT)中所讲,一个ADT就是一组抽象操作的集合。所以,定义了一组方法声明的接口便可以设计和表示一个ADT,而ADT的实现则需要交给实现接口的类来完成。所以实现某个接口的类必须实现接口中所有的方法。
接口与类的数量关系是多对多,也就是说一个接口可以由多个类实现,而一个类可以实现多个接口。


举例,下图就定义了一个接口:
在这里插入图片描述
可以看到这个接口就是一组方法的列表,但是这些方法都没有实现体。

下面定义一个类来实现这个接口:
在这里插入图片描述
可以看到,这个类对接口中所有的方法都做了实现。同时,这个类中也增加了成员变量。

假设在客户端中,client需要使用此ADT,那么就可以:
在这里插入图片描述
值得注意的是这行语句:

Complex c = new OrdinaryComplex(-1,0);

这句话的含义是:创建了一个名为 c 的 Complex 接口类型的变量,并将其赋值为一个 OrdinaryComplex 类型的对象。可以理解为接口变量c交给类OrdinaryComplex来实现。于是c便可以调用接口中定义的任意方法,而这些方法的执行逻辑是按照OrdinaryComplex中的定义来完成的。值得注意的是,对于OrdinaryComplex中定义了,但在接口中没有定义的方法,c是不可以调用的。这实际上是一种静态类型检查的表现,可以参考数据类型与类型检验这篇文章。


当然,正如之前所言,一个接口可以交给多个类来进行实现:
在这里插入图片描述
在这里插入图片描述
这里由于在定义接口变量c的时候使用的是PolarComplex的实现,所以c进行方法调用使用的是PolarComplex中的执行逻辑。当然,根据ADT的规则,这两种实现都应该符合接口中方法的规约Spec。当然,这并非意味着使用任意实现的效果都是相同的,在符合接口中同一方法规约的基础上,不同的实现类可能在运行效率、空间占用等性能方面有所不同,也可以在异常处理、执行逻辑等设计方面有所差别
我们经常使用的List接口的两个实现类ArrayList和LinkedList就是如此。ArrayList 内部使用动态数组来存储元素,它提供了随机访问元素的能力,并且在大多数情况下具有较快的访问和修改速度。LinkedList 内部使用双向链表来存储元素,它提供了快速的插入和删除操作,并且适用于需要频繁的插入和删除操作的场景。
我们可以根据使用场景的不同灵活的选择这两个实现类。


利用接口和类实现ADT

总之,接口和类对于ADT具有不同作用:

  • 接口:确定ADT的规约Spec
  • 类:对ADT进行具体实现

它们之间的关系如下图:
在这里插入图片描述
在实际编程过程中,我们可以选择直接使用类来实现ADT,这时类中需要同时具有ADT的定义和实现,这是我们在上一篇文章中所使用的方法。
假如既有接口又有其实现类,那么我们倾向于使用接口名来定义变量,如:
在这里插入图片描述
这样做可以随时替换接口的实现类而不用修改其他代码,同时客户端也只需要使用接口而不用时刻关心具体的实现类,增加了代码的灵活性,符合ADT的抽象原则。
但是,从这个例子中我们可以看出,client要使用接口,那么就必须知道接口实现类的名字才能进行构造:因为接口中没有构造方法,也无法保证其多个实现类具有同名的构造方法。这样就打破了抽象边界,使用户的操作可能直接依赖于实现类


接口的default和static方法

从java8开始,接口中可以实现default和static方法。这为我们的编程增添了便利。

接口中的static方法可以作为静态工厂方法来实现,也就是说可以在接口中构造实现类,这样就可以避免抽象边界的打破——用户只需要使用接口中的构造方法,而无需知道实现类的名字。

接口中的default可以统一实现所有实现类的某一功能,而不需要在每个实现类中一一实现。同时,default方法还可以作为一种增量式的方法为接口添加新的功能而不破坏已经实现的类。


三、封装和信息隐藏

封装性如何是评价代码好坏的重要标准。一段好的代码应该隐藏所有的实现细节:

  • 将API与实现进行干净的分离
  • 模块间的沟通只能通过API进行
  • 对象之间对对方的内部工作方式一无所知

为了实现封装性,我们可以遵循以下标准:
在这里插入图片描述
当然,这些是远远不够的。封装性的实现与代码的各种细节有关,需要我们在编程过程中时刻注意,积累经验。


针对封装性原则,java对成员的可见性提供了四种修饰符:

  • public:最广泛的访问级别,在任何地方都可以访问。被声明为 public 的类、接口、成员变量和方法可以被任何其他类访问。
  • protected:受保护的访问级别,只能在同一包内或者子类中访问。被声明为 protected 的成员变量和方法只能被同一包内的其他类或者该类的子类访问。
  • default(默认):在没有显式指定访问修饰符时使用的访问级别,只能在同一包内访问。被声明为 default 的成员变量和方法只能被同一包内的其他类访问,不能被其他包中的类访问。
  • private:最私密的访问级别,只能在同一类内部访问。被声明为 private 的成员变量和方法只能在声明它们的类内部访问,不能被任何其他类访问。

在ADT的设计过程中,应该只提供client需要的功能,而将其他的成员全部设置为private。
所有private的成员可以随时修改为public,但反之则不可。所以对ADT的可见性做出修改时一定要谨慎。


四、继承与重写

继承(Inheritance)

现实世界的事物既有差异,又有共性之处。从模板到实例可以抽象为从类到对象,那么从泛化到细化这一过程就可称为继承(inheritance),它允许一个类(称为子类或者派生类)基于另一个类(父类或者基类)的定义进行创建。通过继承,子类可以继承父类的属性和方法,并且可以添加自己特有的属性和方法,从而实现代码的重用和扩展。
继承既可以发生在接口与接口之间,也可以发生在类与类之间。
与类实现接口不同,一个类只能直接继承一个父类。
在这里插入图片描述


重写(Override)

在面向对象编程中,重写Override)指的是子类(或子接口)重新实现(覆盖)了父类(或父接口)中的某个方法。通过重写,子类可以提供自己的实现来替代父类中的方法实现,从而改变方法的行为,但要注意不能改变方法的本意。
对方法进行重写时,必须满足子类重写的方法要与父类的方法签名(Signature)符合LSP的某些原则

方法签名包括:

  • 方法名:方法的名称,用来唯一标识方法。
  • 参数列表:方法的参数类型、顺序和数量。参数列表是方法签名的重要组成部分,因为它定义了方法接受的输入。
  • 返回类型:方法执行后返回的数据类型。返回类型描述了方法返回值的类型,对于无返回值的方法,返回类型为 void。

具体来说,LSP要求子类型中重写的方法:
方法名一致,参数逆变,返回类型协变,抛出异常协变。
但实际上,Java中的重写需要满足方法名不变,参数列表不变,返回类型可协变,访问修饰符可扩大,异常可更具体但不可添加
也就是说,方法名和参数列表必须完全相同,但返回类型可以变为原来的子类型,访问修饰符可以扩大访问限制(比如protected到public),异常可以变得更具体(原异常的子类型)但不能添加新的异常。
如果参数列表发生了变化,则一律视为重载

当在实际运行时,才可确定调用哪个方法。这是一种动态绑定Dynamic Binding)或者运行时多态性Runtime Polymorphism)。规则如下:

  • 当使用父类的对象调用一个被子类重写的方法时,将会执行父类中的版本。
  • 当使用子类的对象调用一个被子类重写的方法时,将会执行子类中的版本。
  • 当使用父类的引用指向子类的对象并调用一个被子类重写的方法时,将会执行子类中的版本。

不管如何,在运行时,Java 会根据对象的实际类型来确定要调用的方法版本,而不是引用的类型。当然,如果子类没有对父类的方法进行重写,那么子类可以直接调用父类的方法。
举例:

class Animal {
    void makeSound() {
        System.out.println("Animal makes a sound");
    }
}

class Dog extends Animal {
    void makeSound() {
        System.out.println("Dog barks");
    }

    void fetch() {
        System.out.println("Dog fetches a stick");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal animal = new Dog(); // 向上转型
        animal.makeSound(); // 输出 "Dog barks"
        // animal.fetch(); // 编译错误,Animal 类型没有 fetch 方法
    }
}

这里animal的引用类型是Animal,但实际类型是Dog。
运行时会调用实际类型中的方法。

几种应用场景

  • 父类中被重写的方法不为空,说明对于大多数子类而言该方法是可以复用的。对于某些子类而言,该功能具有特殊性,则需要重写。
  • 父类中的某个方法实现体为空(模板方法),那么就说明所有子类就该方法而言缺乏共性之处,需要各自重写,但并不强制。
  • 子类需要对父类的某个方法进行拓展,那么可以使用 super 复用父类的功能。

举例:
在这里插入图片描述
特别的,虽然子类与父类的构造方法具有不同的方法名,称不上是重写。但是与重写类似,往往需要在子类的构造方法中复用父类的构造方法进行拓展。此时需要使用 super() 来调用父类的构造方法,然后再根据接收的参数进行拓展。
举例:
在这里插入图片描述
这里的子类相对于父类拓展了一个成员变量fee。在定义构造方法时,为了方便我们可以先复用父类的构造方法,然后再对接收的参数fee完成赋值,从而实现子类的构造方法。


无法重写的情况

当然,父类中的方法并非一定可以被子类重写。如果某个方法不想被子类重写,那么就可以用final关键字进行修饰。如果父类中所有的方法都被定义为final,那么称这种继承情况为严格继承(Strict inheritance):子类只能添加新方法,但无法重写父类的方法。
举例:
在这里插入图片描述


抽象类(Abstract Class)

对于同一父类,其子类的某个方法可能缺乏共性之处,需要每个子类自行实现。此时,父类可以像上面所讲提供一个模板方法,由每个子类重写进行实现。但这就无法强行约束子类必须重写这个方法。这时如果将父类的该方法改为抽象方法,父类改为抽象类,就可以强制子类对其进行实现。
给出定义:

  • 抽象方法(abstract method):一个具有方法签名但却没有实现体的方法。使用abstract修饰。
  • 抽象类(abstract class):一个具有0个或多个抽象方法的类。也使用abstract修饰。

于是,回顾“接口”的定义,我们就可以说:接口实际上可以理解为一个只含抽象方法的抽象类。接口,抽象类,具体类的关系可以总结为:
在这里插入图片描述


继承关系下方法的设计

根据以上的概念,我们就可以分为下面几种情况对继承关系中的方法进行具体设计:

  • 所有子类中完全相同的操作:在父类中实现,子类无需重写,直接调用父类的实现。
  • 各子类都有但都有所差异的操作:在父类中设计抽象方法,强制要求在所有子类中重写。此时父类变为抽象类
  • 有些子类有但有些子类没有的操作:无需在父类中定义,在需要的子类中各自实现即可。

五、多态、子类型、重载

在Java中,多态(Polymorphism)是一个内涵丰富的概念,具有三种类型:

  • 特殊多态(Ad hoc polymorphism):当一个函数根据一组特定类型和组合来表示不同且可能是异构的实现时,称为特殊多态。特殊多态通常通过函数重载(Overload)来实现,即在同一个作用域中有多个同名的函数,但它们的参数类型或数量不同。通过调用这些同名函数并传递不同类型的参数,可以实现不同的行为。
  • 参数化多态 (Parametric polymorphism):当代码编写时不涉及任何特定类型,因此可以透明地与任意数量的新类型一起使用时,称为参数化多态。在面向对象编程中,这通常被称为泛型generics)。泛型允许编写一次代码,以适用于多种不同类型的数据,从而提高了代码的重用性和灵活性。
  • 子类型多态(Subtyping):当一个名称表示许多不同类的实例,这些类由一些共同的超类(或接口)相关联时,称为子类型多态。子类型多态也称为子类型多态或包含多态。在这种情况下,子类继承了超类的行为和属性,并可以通过父类的引用来访问,允许我们使用统一的接口来处理不同类型的对象,实现了多态。比如使用List接口中定义的方法操作列表,而不必关系其实现是ArrayList还是LinkedList。

下面着重对这三种多态进行分析。


特设多态与重载(Overload)

特设多态通常使用重载来实现。从直观上来看,重载(Overload)就是指多个方法具有相同的方法名,但是具有不同的参数列表
值得注意的是,方法的重载要求方法的参数列表必须不同,但不要求方法的返回值类型。也就是说,即使两个方法具有不同的返回值类型,只要它们的参数列表不同,仍然可以构成方法重载。
类似的,重载对于方法的访问修饰符、异常抛出也没有要求。重载可以在同一类中进行,也可以在子类中进行。
总之,方法可重载的唯一且必要的条件就是:方法名相同,参数列表不同。

class Example {
    void method(int x) {
        System.out.println("int method: " + x);
    }

    String method(double x) {
        return "double method: " + x;
    }
}

以上代码就是一个合法的重载示例。


重载的好处

为什么Java中有重载呢?其实重载这一特性是为client考虑的,对于ADT的同一个操作,可能会有不同的参数。当然可以将这同一种操作分解为不同的方法(名),对每个方法设计不同的参数即可。但这增加了client调用的难度——可能会要求client记住许多繁琐的方法名。这时如果利用重载,就可以通过参数列表 自动识别 client想调用哪一种具体的方法。

举个例子:
我们可以为某个类定义一个print(Object O)方法,用以在屏幕上显示对象O的有关信息。但是参数O有可能是文本,也有可能是图片,而这两种情况的输出方式是完全不同的。
如果设计成printText(text_objetc T)和printImage(image_object P)两个函数,就需要client对两个函数名进行区分。
如果此时利用重载,则可以使两个方法共用一个函数名print,而在类内部设计print(text_objetc T)和print(image_object P)两个方法。这样client想要使用该功能,就无需根据参数类型对方法名进行区分,直接调用print()并给予参数即可。这样的设计提高了代码的灵活性和可维护性。


静态多态

从编译器的角度来看,重载实际上是一种对方法调用的选择机制,它是一种静态多态(static polymorphism)。当我们调用一个方法时,编译器会根据参数列表进行静态类型检查,选择符合该参数列表的具体实现来进行绑定。
由此可见,重载是在编译时确定的。与之不同的是,由于重写方法的方法签名完全一致,所以在编译时不作区分,而是在运行时进行动态检查,来确定哪一种具体的实现以供执行。


需要注意的情况

  • 重载中所检查的参数列表是指参数的类型、顺序、数量。与参数名没有关系。如果两个方法的参数列表完全相同,但参数名不同,依然无法构成重载。
public void changeSize(int size,  String name,   float pattern) { }
public void changeSize(int length,String pattern,float size){ }
两个方法的参数列表相同,但参数名不同,无法重载。
  • 重载可以发生在父类与子类之间。如果子类中的某个方法与父类的某个方法具有相同的方法名但参数列表不同,则可以构成重载。在这种情况下,我们可以认为父类的这个方法和子类中被重载的方法都被加入了子类,但是父类中并没有被重载的方法。
class Parent {
    void method(int x) {
        System.out.println("Parent's method with int: " + x);
    }
}

class Child extends Parent {
    void method(double x) {
        System.out.println("Child's method with double: " + x);
    }
}

class Main {
    public static void main(String[] args) {
        Child object = new Child();
        object.method(10);       // 调用子类从父类继承而来的 method(int x)
        object.method(10.5);     // 调用子类中被重载的method(double x)
    }
}

上面就是一个合法的重载使用。
        Parent object = new Child();
        object.method(10);       // 调用父类的 method(int x)
        object.method(10.5);     // 编译不通过
改成这样就是非法的。
因为编译时检查引用类型,而父类中实际上没有这个被重载的方法。

另外一个类似的例子:
在这里插入图片描述

  • 由于重载属于静态多态,所以检查参数列表时,检查的是对象的引用类型,而不是对象的实际类型。
    在这里插入图片描述
    这里animalRefToHorse的引用类型是Animal,实际类型是Horse。由于doStuff发生了重载,检查引用类型,所以会调用第一个doStuff方法而不是第二个。

重载与重写的区别

在这里插入图片描述
这里非常容易出错的地方就是Invocation(方法调用)这一栏。二者在编译和运行时调用选择的区别将在之后的部分详细解释。


参数化多态与泛型编程

参数化多态(Parametric polymorphism)是在函数对一系列类型一致工作时获得的,这些类型通常具有一些共同的结构。
它能够以通用的方式定义函数和类型,使其根据运行时传递的参数工作,即允许在不完全指定类型的情况下进行静态类型检查
这就是Java中所谓的“泛型”。
泛型编程(Generic programming)是一种编程风格,其中数据类型和函数根据待指定的类型编写,然后在需要时实例化为特定类型的参数。
泛型编程的核心思想是从具体的、高效的算法中抽象出通用的算法,以获得可以与不同的数据表示结合使用以产生各种有用软件的通用算法。

泛型相关概念

  • 类型变量(type variable):类型变量是在泛型类、泛型接口、泛型方法或泛型构造函数声明中引入的未限定标识符。它们允许在声明中使用未知的具体类型,以便在实例化时提供。
  • 值得注意的是,类型变量可以是对象类型,也可以是基本数据类型的包装类(如Integer、Double等),但不能是基本数据类型
  • 而且类型变量也无法用于创建数组。因为无法确定该类型的字长。
public class Box<T>{...}
比如在上面这行代码中,T就是一个类型变量。
  • 泛型类:如果一个类声明了一个或多个类型变量,则该类是泛型的。这些类型变量称为类的类型参数,它们充当参数来定义类中的字段、方法或构造函数的类型。泛型类声明定义了一组参数化类型,其中每个类型参数的实例化都会产生一个具体的参数化类型,但它们在运行时都属于同一个类的实例。这意味着尽管参数化类型之间可能具有不同的类型实例,但它们都共享相同的类定义和行为
    Box<Integer> integerBox = new Box<>(10);
    Box<String> stringBox = new Box<>("Hello");
比如这里就定义了两个参数化类型:Box<Integer>和Box<String>。
它们的类型参数分别被实例化为Integer和String
  • 泛型方法:如果一个方法声明了类型变量,则该方法是泛型的。
public class GenericMethodExample {
    // 泛型方法,T 是类型参数
    public <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }
上面例子中,printArray就是一个泛型方法,
用以打印任意类型数组的元素。
  • 泛型接口:如果一个接口声明了类型变量,则该接口是泛型的。
  • 泛型接口既可以由泛型类实现,也可以由非泛型类实现。当使用非泛型类实现时,要在implements后面声明类型变量是的实例,即指定确定的数据类型。
// 定义一个泛型接口
interface Pair<K, V> {
    K getKey();
    V getValue();
}

// 实现泛型接口的泛型类
class OrderedPair<K, V> implements Pair<K, V> {
    private K key;
    private V value;

    public OrderedPair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

// 实现泛型接口的非泛型类
class StringPair implements Pair<String, String> {
    private String key;
    private String value;

    public StringPair(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public String getKey() {
        return key;
    }

    public String getValue() {
        return value;
    }
}

这里Pair就是一个泛型接口,
它由泛型类OrderedPair和非泛型类StringPair实现。

如果在类级别声明了类型变量,那么在类的方法就无需重复声明了。比如

public class Box<T> {
    private T t;

    public void set(T t) {
        this.t = t;
    }

    public T get() {
        return t;
    }
}

反之,如果参数中出现了类型变量,就必须在方法前声明。

public class Example {
    // 声明泛型方法,包含多个类型参数
    public static <T, U> void printPair(T first, U second) {
        System.out.println("First: " + first + ", Second: " + second);
    }

    public static void main(String[] args) {
        printPair("Hello", 10); // 输出: First: Hello, Second: 10
        printPair(3.14, true); // 输出: First: 3.14, Second: true
    }
}


子类型多态

相关概念

Java提供了名为List的接口,但是所有List的实现中,没有一个是List类型的对象——因为List作为接口是不可实例化的。然而,它们却是ArrayList或者LinkedList的对象。在这里,ArrayList和LinkedList实现了List接口,拥有List接口所提供的所有功能,那么它们就是List的子类型。
子类型(subtypes):在Java中,子类型是指一个类或接口,它继承或实现了另一个类或接口,并且具有该类或接口的所有成员(字段和方法),并且可以添加自己的成员或重新实现继承的方法。

由此可见,子类型来源于接口的实现以及类之间的继承。当我们说“B是A的子类型”时,就意味着“所有的B都能满足A的规约”。然而,Java的编译器并不能对这一规定进行检查:比如在B中对A的某个方法进行重写,以弱化其规约。所以在编程的过程中一定要加以注意。


几个需要注意的地方

instanceof
Java提供了instanceof用于检查对象是否是某个类的实例或其子类的实例。当我们无法确定参数的具体数据类型时(往往出现在参数的数据类型为一个具有子类型的父类),可能会使用instanceof判断接收的类型然后再进行处理。比如:
在这里插入图片描述

然而这是不恰当的,因为在良好的面向对象设计中,应该尽量避免根据对象的具体类型执行不同的操作,而是应该让每个类负责自己的行为
相反,我们应该通过合理的设计和使用多态来处理不同类型的对象,比如将超类设计为泛型类,然后将子类设计为类型变量被实例化的类。


向下转型与向上转型
在Java中,向下转型downcast)是指将一个父类引用转换为一个子类引用的过程。这种转换只能在编译时确定为父类对象引用的实际类型是子类类型时才能进行,否则会导致运行时的ClassCastException异常。
比如下面的代码就使用了向下转型
与之对应的,是向上转型(upcast):向上转型是指将一个子类的对象引用赋值给一个父类的引用变量的过程。在Java中,这是自动进行的,不需要显式的类型转换。这个过程是安全的,因为子类对象具有父类对象的所有属性和方法,所以父类引用可以访问子类对象的成员,但不能访问子类特有的成员。

class Animal {
    public void eat() {
        System.out.println("Animal is eating");
    }
}

class Dog extends Animal {
    public void bark() {
        System.out.println("Dog is barking");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog(); // 创建 Dog 对象
        Animal animal = dog; // 向上转型,将 Dog 对象赋值给 Animal 类型的引用变量

        animal.eat(); // 可以访问父类的方法
        // animal.bark(); // 编译错误,无法访问子类特有的方法
    }
}

这段代码使用了向上转型:
animal的引用类型是父类,但其指向对象的实际类型是子类型。
animal可以调用父类的方法,但不可调用子类特有的方法

六、绑定与分派

上面我们提到了override和overload的不同之处,二者在方法的具体执行方面有很大的不同。为了避免混乱,需要从Java方法调用的内部机制上理解二者的工作原理。这就用到了绑定和分派的概念。
直观上来讲:
绑定就是将调用的名字与实际的方法名字联系起来(可能很多个)。
分派则是决定具体执行哪个方法。

绑定与分派都有静态与动态之分,具体定义如下:

  • 动态分派Dynamic Dispatch):
    动态分派是选择调用哪个方法的过程,在运行时根据对象的实际类型来确定调用哪个方法的实现。
    动态分派涉及到方法调用的语义,即选择执行哪个方法的决策。
  • 动态绑定Dynamic Binding):
    动态绑定是在运行时将对象和方法实现关联起来的过程。它确保在调用方法时根据对象的实际类型来选择正确的方法实现。
    动态绑定涉及到方法调用的实现机制,即确保在运行时调用适当的方法实现(多个备选方法)。
  • 静态分派Static Dispatch):
    静态分派是在编译时确定方法调用的过程,根据方法参数的静态类型(即声明时的类型)来选择具体调用哪个方法。静态分派发生在编译时,编译器根据方法参数的静态类型就能够确定要调用哪个方法。
  • 静态绑定Static Binding):
    静态绑定是在编译时确定方法实现的过程,在方法调用时根据引用变量的静态类型来选择方法的实现。静态绑定确保在编译时就确定了方法的具体实现。

Java中,方法调用的机制可以概括为:
在编译过程中,编译器会根据引用类型检查方法签名。如果在引用类型中找到了完全对应的方法签名,那么编译才会通过。此步骤与变量的实际类型无关。此时无论是重载还是重写,只要是合法的,其引用类型中一定会有符合该方法的方法签名。
编译过程中,如果该方法是静态的,就会发生静态绑定,进而在运行中发生静态分派。此时该方法只与引用类型有关,与实际类型无关。
相反,如果不是静态的,那么在编译过程中通过了方法签名检查后,则会根据情况来进行绑定。重载是静态绑定,而重写是动态绑定。
到运行时,对于重载和重写均会发生动态分派方法的实际调用与对象的实际类型有关,而与引用类型无关。也就是说,动态分派的结果确定了方法到底调用哪一个,而这个结果与编译时进行的检查是不相关的。
在这里插入图片描述
上面的两个方法均为静态方法,所以在运行时根据引用类型来调用,结果均是Human walks。
在这里插入图片描述
在这个重写的示例中,虽然obj和obj2的引用类型都是Human,但我们知道重写使用动态绑定,在运行过程中方法的实际调用由实际类型决定。根据赋值语句我们知道obj的实际类型是Boy,所以obj将调用Boy中被重写的walk函数。结果是Boy walks和Human walks。

class Parent {
    void method(int x) {
        System.out.println("Parent's method with int: " + x);
    }
}

class Child extends Parent {
    void method(double x) {
        System.out.println("Child's method with double: " + x);
    }
}

class Main {
    public static void main(String[] args) {
        Parent object = new Child();
        object.method(10);       // 调用父类的 method(int x)
        object.method(10.5);     // 编译不通过
    }
}

在这个重载的示例中,object的引用类型是Parent。在编译时,根据引用类型来进行检查,发现object.method(10.5);这行语句,object的引用类型Parent中没有参数为double类型的方法(虽然object的实际类型是Child,但这需要运行时才可确定),所以编译不通过。

class Parent {
    void method(int x) {
        System.out.println("Parent's method with int: " + x);
    }
}

class Child extends Parent {
    void method(int x) {
        System.out.println("Child's method with int: " + x);
    }
}

class Main {
    public static void main(String[] args) {
        Parent object = new Child();
        object.method(10);       // 调用子类中被重写的method(int x)
    }
}

相应的,这里使用重写。object的引用类型是Parent但实际类型是Child,在运行时的动态分派是根据实际类型进行的,所以这里的方法调用的是子类被重写的方法。
值得注意的是,这里在编译的时候检查的却是父类Parent中的方法签名,所以对这里的重写而言,编译时检查的方法与实际运行的方法并不是一个。

  • 13
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值