1、抽象过程
Java 中的八大基本数据类型,char 、 boolean、byte、short、int、long、float、double 都是通过给二进制数字赋予定义得来的,例如 byte,是 8 位的、有符号的、以二进制补码表示的整数——计算机存储的“01”字符原本并没有特别的含义,通过人为的赋予定义,可以变成任意我们希望的东西。
可见,抽象过程是给二进制数字赋予定义的过程。
上述过程,主要是考虑计算机的结构进行的,如果更进一步,现实世界中的一切事物,都能够通过抽象后描述,这就是常听说的“万物皆对象”。
Java 的基本数据类型(type)是一种基础抽象,以整数为例:人所理解的每个整数都是一个对象,Java 则通过定义 byte、short、int、long 四种整数类型来描述这些对象。每个1、2、3之类数字都是一个对象,整数理论上有无穷个,但对应于上述四种整数类型时,则有相应的有限范围。
Java 的类(class)则是一种高层级的抽象,比如定义一个类时,我们给类赋予属性和方法,隐藏复杂性,使得这个类像基本数据类型一样,可以描述某一类型的对象,并可以基于该类型创建多个对象,这些对象各不相同但都基于同一种定义。就像上述的整数一样,每个1、2、3之类数字都是各不相同的对象,但它们都具备同一种定义:整数。
从基本数据类型的角度,可以这样理解:创建一个类,就是创建了一种新的数据类型。
2、封装
封装是将对象的属性和方法结合在一起,并对外界隐藏内部的实现细节。通过封装,外界只能通过方法来访问和修改内部的属性,从而保护对象的完整性。
封装与抽象的区别是:抽象强调对事物本质特征的提取,以此定义、描述和创建一个类;封装则强调对内部实现细节的隐藏和保护。
3、继承
继承是指子类从父类继承属性和方法,从而实现代码的重用和扩展。通过继承,子类拥有父类的所有属性和方法,并进行自定义的修改。
值得关注的是 Java 语言本身对继承的底层实现方式:它是通过让子类的对象创建一个隐藏的、父类的对象来获得父类属性和方法的。这听起来跟我们代码里 new 一个对象没什么区别,唯一的区别是:这个对象是隐藏起来的,我们不能直接操控。因此,定义子类的构造函数时,需要确保能调用到父类某个构造函数且只能调用一个,否则子类无法初始化这个隐藏的、父类的对象。
有一道与之相关题目:父类有个私有变量 num,子类能否使用父类的这个 num 变量?答案是不能。这是否违背了“继承是指子类从父类继承属性和方法”?基于这个概念,子类应该拥有父类的所有信息才对。但受限于 Java 语言本身对继承的底层实现方式,子类也无法访问父类的私有变量,也包括其他被 private 修饰的方法等。
既然 Java 的继承实现方式跟代码里 new 一个对象没什么区别,那么我在 A 类中 new 一个 B 类的对象b作为成员变量,是不是可以说 A 类继承了 B 类?甚至这种方式更加灵活方便,因为可以创建 N 个其他类的 N 个对象,相当于实现了多继承。这种实现方式叫做 “组合”,实战中,组合使用比继承要更加广泛。
相比之下,继承似乎要笨重多了,不能多继承却可以多组合。但是不要忘记了继承的实现是还有多态特性的,它们的对象还可以在父子类之间进行类型转换。
4、多态
多态是指同一个方法在不同的对象中具有不同的实现,允许对象在不同的上下文中表现出不同的行为,从而提高代码的灵活性和可扩展性。
多态是基于继承实现的,组合没有多态。
多态有个类型转换的问题:向上转型和向下转型。向上转型是永远不会出错的,同时意味着该对象丢失子类的信息,仅保留父类的信息。而向下转型原则上是不允许的或者是不建议的,随意的、直接的向下转型,在编译器就会报错。如果确实有需要,则需要强制转型:在 Java 里所有的向下转型都必须显式声明,当显式声明时,就意味着你已经了解这种风险并愿意承担其带来的问题等。
然而,这里面仍然有个问题:为什么向上转型是安全的,向下转型则是危险的?上文只是解释了如何做,并没有解释为什么,它背后的真正原因是什么呢?
回顾一下 Java 中的继承,子类继承父类,只是编译器帮忙 new 了一个父类的对象给子类使用而已。当对象需要向上转型时,通过当前对象中隐藏的父类对象就能直接转换了,转型自然是安全的。当对象想要向下转型时,当前对象的引用类型是父类,当前对象可能来自父类的任意子类,所以向下转型是有可能失败的,Java 便不允许随意向下转型的行为。所以你需要显式声明说:我要强制转型。编译器才能通过编译。
显式声明意味着:你清楚了当前对象来自于你要转换的类型。例如:父类 A 有 3 个子类 B1、C2、D3。如果对象 a 是 new A();创建的,该对象无法向下转型;如果对象 a 是 new B1();创建的,该对象向下转型时只能转为B1类型,不能转为C2、D3类型。
所以,显式声明意味着:你清楚了当前对象来自于你要转换的类型(重要的事情再强调一次),如果转型失败,后果由程序员承担。
转型失败时会抛出异常:ClassCastException,为了避免这种运行时异常,Java 为程序员提供了 instanceof 方法,用于判断当前对象是不是来自于需要强制向下转型的那个类型,判断通过后再进行转换,就能确保代码不会出现异常。
下面是一个可参考的例子:
第 25 行代码会在运行时抛出 ClassCastException,java.lang.Integer cannot be cast to java.lang.Double。这两个类都继承自 Number 抽象类,所以他们的向上转型都不会有任何问题并且不需要强转,但是向上转型后再向下转型时,抛出的异常会告诉你,Integer 类型是不能转为 Double 类型的,虽然它们都继承自 Number 类。
这就是为什么非要做类型判断后才能进行强转,所以注释掉的 28 到 33 行代码才是正确的做法。
顺便提一下,在 Java 里有个 Class 类,JVM 每加载一个类,Class 类都会新建一个对象,用于记录它的类型信息,Java 的反射机制也是基于它。
5、总结
本文讲述的主要是以下几点:
- 语言建立在抽象之上,基本数据类型是语言根据需要抽象出来的常用类型(type)
- 对象则是更高层次的抽象,任何人都可以根据需要,抽象后封装成一种新的数据类型(class)
- 在 Java 里,最终还是通过子类所拥有的“祖先”隐藏对象来获得继承、类型转换、多态等能力的
- 向上转型安全和向下转型不安全的背后原因,都是源于 Java 内部的继承机制所引发的问题