几乎每一本介绍Java语言的书中都会提到“面向对象”的这个概念,然而博主初学Java时看到这方面的内容一般都是草草地看一看,甚至是直接略过。原因很简单:考试基本不考,而且初学阶段写代码也很少用上。但事实上面向对象时Java中一个非常重要的内容,而且与代码这整体设计关系很大。越是具有丰富的编程经验,就越能体会到这个思想在实际代码结构设计中的重要性。在《Java编程思想》中,作者用了很大的篇幅来介绍面向对象的相关内容,其中不乏一些关于如何运用面向对象来优化程序结构,提高代码的可读性和可维护性的内容。这一章就来整理一下关于面向对象的相关内容。
什么是面向对象
《Java编程思想》一书,对于面向对象程序设计总结了一下五个特征:
- 万物皆为对象。将对象视为奇特的变量,它可以存储数据,除此之外,你还可以要求它在自身上执行操作。理论上讲,你可以出去带求解问题的任何概念化构件(狗、建筑物、服务等),将其表示为程序中的对象。
- 程序是对象的合集,它们通过发送消息来告知彼此所要做的。要想请求一个对象,就必须对该对象发送一条消息。更具体地说,可以把消息想象为对某个特定对象的方法调用请求。
- 每个对象都有自己的由其他对象所构成的存储。换句话说,可以通过创建包含现有对象的包的方式来创建新类型的对象。因此,可以在程序中构建复杂的体系,同时将其复杂性隐藏在对象的简单性背后。
- 每个对象都拥有其类型。按照通用的说法,“每个对象都是某个类(class)的一个实例(instance)”,这里“类”就是“类型”的同义词。每个类最重要的区别于其他类的特征就是“可以发送什么样的消息给它”。
- 某些特定类型的所有对象都可以接受相同的消息。“圆形”类型的对象同时也是“几何形”类型的对象,所以一个“圆形”对象必定能够接受发送给“几何形”对象的消息。这意味着可以编写与“几何形”交互并自动处理所有与几何形性质相关的事物的代码。
为什么要面向对象
讨论这个问题前要先说说“抽象”机制。所有的编程语言都提供抽象机制,所谓抽象就是用编程语言的元素来表达某些内容。不同的编程语言所能够抽象的对象是不一样的。汇编语言是对底层机器的轻微抽象,能够表达计算机底层结构以及操作。还有一些\"命令式\"的编程语言(例如C、FORTRAN等),这些语言是对汇编语言的抽象。这些语言能够更易于阅读,更方便地被人所理解。但这些语言依然是在基于计算机的底层结构进行抽象的,因此在解决问题时依然会受限制与计算机的机构。程序员在解决某个特定问题的时候,需要构建该问题与计算机机构模型之间的映射,这使得编写程序变得很困难。
如果编程语言能够直接对所需要解决的问题进行抽象,能够直接表达问题中的元素,那么就能够省去计算机结构和问题之间的映射的工作。面向对象就是能够为程序员提供面向问题空间元素的一个工具。如上文所述,面向对象的语言中,万物皆为对象,且每个对象都有其类型,也就是类(class)。因此,程序员就可以自定义适用于问题空间的类来解决问题。
面向对象程序设计能够降低各组件之间的耦合性,增加了代码的可维护性,复用性和可扩展性。
封装、继承和多态
封装、继承和多态是面向对象程序设计的三大特性。在具体介绍这三者前,先对Java中的类进行简单介绍。类是一组具有相同特性(数据元素)和行为(功能)的对象的集合,一个类可以有很多个对象。Java的类包含两部分元素:属性(也叫字段)和方法(也叫成员函数)。属性可以使任何类型的对象,包括基本类型和引用类型;方法是该类的对象能够进行的操作,用OOP中的说法,方法决定了一个对象能够接受什么样的消息。方法的组成包括名称,参数,返回值和方法体几部分。类的基本形式如下:
class ClassTypeA{ int dataA; double dataB; ClassTypeB classTypeB; ReturnType methodName(/* Argument list */) { /* Method body */ } ... }
封装
封装是指将类的内部实现隐藏起来,仅暴露出必要的接口给使用者。隐藏实现的第一个优点是可以避免类的使用者接触和操作到他们不应该接触的部分,避免由于使用者的粗心或错误操作产生程序bug。第二,隐藏具体实现,仅暴露接口给使用者,那么设计者在修改类的实现的时候就不用顾忌对使用者的影响,只需要保持对外的接口不变就可以。第三点,使用接口调用来连接设计者和使用者,可以降低系统的耦合性。
在具体的开发过程中,设计者对于类中的不同元素的可访问情况会有不同的要求:对于类内部的关键性字段,设计者会希望其完全被隐藏,不希望被任何使用者访问或修改;而对于提供给外部使用的接口,一定是希望其能够被所有使用者访问;还有一些元素,设计者希望该类的子类能够访问和使用,而不会被其他的使用者接触到。Java用访问权限关键字来区别这种不同的可访问性。
Java中一共有3中访问权限关键字:
- public表示紧随其后的元素对任何人都是可用的;
- private关键字表示除了类型的创建者和类型内部的方法以外,任何人都不能访问该元素。如果有人试图访问private成员,就会在编译时收到错误信息提示;
- protected关键字与private相比,区别在于继承的类可以访问protected修饰的元素,而不能访问private修饰的元素。
除了以上三种访问权限以外,Java还有一种默认的访问权限,在没有使用任何访问权限关键字的情况下,默认制定为这种控制权限,也被称为包控制权限,因为被其修饰的元素可以被同一个包中的其他类所访问。
访问权限关键字的使用方式如下:
class accessDemo { private int privateData; protected int protectedData; public int publicMethod() {...} int defaultMethod() {...} }
继承
继承是面向对象程序设计中必不可少的组成部分。在Java中,使用extends关键字来表示类的继承关系。继承关系中,将已有的类成为父类(基类),由父类生成的新类称为子类(导出类)。如下面一段代码中,Animal类为父类,Dog类为它的子类。
class Animal { public Animal(){ } } class Dog extends Animal{ public Dog(){ } }
事实上在Java中创建一个类时,总是在继承,如果新建类时没有用extends指定继承自那个类,则就隐式地从Java的标准根类Object类进行继承。也就是说所有的类都继承自Object类。让所有类都继承自Object可以使所有的类都具有一些相同的特性,或者可以都可以进行某些操作。例如Java中所有的类都具有hashcode()方法,可以计算该类对象的哈希值。这是Java中HashMap等重要数据结构实现的基础,也是判断对象间是否相同重要依据。
1、子类中的元素
- 子类继承父类的成员变量和方法。当一个子类继承一个父类时,子类便可以具有父类中的一些成员变量和方法,但子类只能继承父类中public和protected修饰的成员变量和方法。
- 子类可以定义自己的成员变量和方法。在定义自己的成员变量和父类的中的变量名一致时,就会发生隐藏的现象。即子类中的变量会掩盖父类的变量。同样的,在定义方法时,如果子类的方法和父类的方法的方法名、参数列表和返回值都相同,则子类的方法就会覆盖父类的方法。隐藏和覆盖是有差别的,简单来说。隐藏适用于成员变脸和静态方法,是指在子类中不显示父类的成员变量和方法,如果将子类转换为父类,调用的还是父类的成员变量和方法;覆盖针对的是普通方法,如果将子类转换成父类,访问的还是子类的具体方法。
2、构造器
子类不会继承父类的构造器,但是既然子类能够继承父类中的成员变量,那么自然也需要对其成员变量进行必要的初始化,初始化的方法就是调用父类的构造器。
- 无参数构造器。如果父类的构造器没有参数,那么在调用子类的构造器时,编译器会默认调用父类的构造器,完成相关初始化工作。如下面这段代码:
public class Animal { public Animal(){ System.out.println(\"animal constructor\"); } public static void main(String[] args) { Dog dog = new Dog(); } } class Dog extends Animal{ public Dog(){ System.out.println(\"dog constructor\"); } } /** * 运行结果: * animal constructor * dog constructor */
- 有参数构造器。如果父类只有有参数的构造器,那么在子类的构造器中必须显式地调用父类的构造器,并且要位于子类构造器的最开始。否则在编译时就会报错。原因是在没有调用父类构造器的情况下,编译器会默认调用父类的无参数构造器。但此时编译器会找不到父类的无参数构造器,从而报错。如下面代码所示:
public class Animal { public Animal(int i){ System.out.println(\"animal constructor \" + i); } public static void main(String[] args) { Dog dog = new Dog(0); } } class Dog extends Animal{ public Dog(int i){ super(i);//调用父类构造器 System.out.println(\"dog constructor \" + i); } } /** * 运行结果: * animal constructor 0 * dog constructor 0 */
多态
多态的定义是允许不同的对象对同一消息进行相应,即同一消息可以根据对象的不同而进行不同的行为,这里的行为就是指方法的效用。多态的意义在于分离“做什么”和“怎么做”,从而消除类型之间的耦合性,改善代码的组织结构和可读性,创造可扩展的程序。我们通过以下这个程序来具体说明:
1 public class Animal { 2 public static void takeFood(Animal animal){ 3 System.out.println(\"===start eat food===\"); 4 animal.eat(); 5 System.out.println(\"====end eat food====\"); 6 } 7 8 public void eat() { 9 } 10 11 public static void main(String[] args) { 12 Animal animal = new Dog(); 13 takeFood(animal); 14 animal = new Cat(); 15 takeFood(animal); 16 } 17 18 } 19 20 class Dog extends Animal{ 21 public void eat() { 22 System.out.println(\"dog eat food\"); 23 } 24 } 25 26 class Cat extends Animal{ 27 public void eat() { 28 System.out.println(\"cat eat food\"); 29 } 30 } 31 /** 32 * 运行结果: 33 * ===start eat food=== 34 * dog eat food 35 * ====end eat food==== 36 * ===start eat food=== 37 * cat eat food 38 * ====end eat food==== 39 */
在上述代码中,Dog类和Cat类都继承自Animal类,并且各自重写了eat()方法。在代码的12行中创建了一个Dog类对象,但是却把它付给了一个Dog类的父类引用,第14行同样这么做。takeFood()方法中的传入参数为一个父类对象,但是这并不影响第13行和15行中,将一个指向子类对象的Animal引用作为参数传入。并且在takeFood()方法中调用传入对象内部的方法时,实际调用的是子类中的方法。
通过以上代码我们可以总结,多态存在的三个必要条件是:1)继承;2)重写;3)父类引用指向子类对象。
在上述代码中,takeFood()方法体中的内容决定了“做什么”,具体“怎么做”却决定于参入对象的eat()方法。而传入的对象中如何定义eat()方法与takeFood()的内容并不相关,因此便实现了两者的解耦。当我们将传入takeFood()的参数由Dog类对象改为Cat类对象时,只需要修改引用的指向即可。这也就增强了代码的可替换性;当需要新增加一个新的动物Pig并进行相同的操作时,我们只需要新建一个Pig类,重写其中eat()方法,然后将Pig类对象传入takeFood()方法即可,这就是增强了代码的可扩展性。
动态绑定
你可能会想知道,在takeFood()方法中,调用的是Animal类的eat()方法,而具体执行的方法主体却是子类中的方法。那么编译器是怎么知道调用一个方法时具体应该执行哪个方法主体呢?
我们把方法调用和一个方法主体的关联起来成为绑定。如果方法调用和方法主体在程序执行前就能关联起来,则称为前期绑定(静态绑定),反之,如果必须到程序运行时才能知道方法调用具体应该执行哪一个方法,则称为后期绑定(动态绑定)。如果类B是类A的子类,A中定义了方法func(String s),B中重写了方法func(String s),那么此方法就需要使用动态绑定。如果x是B的一个实例,通过x.func(str)调用方法时,Java虚拟机会先在B中寻找此方法,如果B类中有对应方法,则直接调用它,否则就在B的父类A中寻找此方法。
在Java中,除了用static方法、final方法、private方法和构造方法以外,其他方法均采用动态绑定。这四种方法中:
- private方法无法被继承,那么自然无法被重写,所以在编译时就可以确定具体调用的方法
- static方法可以被继承,可以被子类隐藏,但是不能被子类重写。所以也可以在编译时确定
- final方法可以被继承,但是不能被子类重写
- 构造方法也不能被子类继承。子类的构造方法有两种:使用系统自动生成的无参数构造方法;调用父类的构造方法(包括自己定义构造方法并在其中调用父类的构造方法)
由以上分析我们可以看出,上述四种方法可以使用静态绑定的最终原因都是:不会出现方法重写,不会产生子类与父类具有信息(方法名,参数个数,参数类型,返回类型等)完全相同的方法。
参考文献
[1]. https://blog.csdn.net/u012340794/article/details/73194674 [2].Java编程思想 第四版