Java 面向对象概述

本文部分摘自 On Java 8

面向对象编程

在提及面向对象时,不得不提到另一个概念:抽象。编程的最终目的是为了解决某个问题,问题的复杂度直接取决于抽象的类型和质量。早期的汇编语言通过对底层机器作轻微抽象,到后来的 C 语言又是对汇编语言的抽象。尽管如此,它们的抽象原理依然要求我们着重考虑计算机的底层结构,而非问题本身

面向对象编程(Object-Oriented Programming OOP)是一种编程思维方式和编码架构。不同于传统的面向过程编程,面向对象编程把问题空间(实际要解决的问题)中的元素以及它们在解决方案空间中的表示以一种具有普遍性的方式表达出来,这种表示被称作对象(Object)。对于一些在问题空间无法对应的对象,我们还可以添加新的对象类型,以配合解决特定的问题。总而言之,OOP 允许我们根据问题来描述问题,而不是根据解决问题的方案。每个对象都有自己的状态,并且可以进行特定的操作,换言之,它们都有自己的特征和行为

根据面向对象编程的概念,可以总结出五大基本特征:

  • 万物皆对象
  • 程序是一组对象,可以互相传递消息以告知彼此应该做什么
  • 每个对象都有自己的存储空间,以容纳其他对象
  • 每个对象都有一种类型
  • 同一个类的所有对象能接收相同的消息

对对象更简洁的描述是:一个对象具有自己的状态、行为和标识,这意味着对象有自己的内部数据(状态)、方法(行为),并彼此区分(每个对象在内存中都有唯一的地址)

接口

所有对象都是唯一的,但同时也是具有相同的特征和行为的对象所归属的类的一部分。这种思想被应用于面向对象编程,并在程序中使用基本关键字 class 来表示类型,每个类都有自己的通用特征和行为。创建好一个类后,可生成许多对象,这些对象作为要解决问题中存在的元素进行处理。事实上,当我们在进行面向对象程序设计时,面临的最大的一项挑战就是:如何在问题空间与方案空间的元素之间建立理想的一对一映射关系?

如果无法建立有效的映射,对象也就无法做真正有用的工作,必须有一种方法能向对象发出请求,令其解决一些实际的问题,比如完成一次交易、打开一个开关等。每个对象仅能接收特定的请求,我们向对象发出的请求是通过它的接口定义的

比如有一个电灯类 Light,我们可以向 Light 对象发出的请求包括打开 on、关闭 off,因此在 Light 类我们需要定义两个方法 on() 和 off(),然后创建一个 Light 类型的对象,并调用其接口方法

也行你已经发现了,对象通过接受请求并反馈结果,因此我们可以将对象看成是某项服务的提供者,也就是说你的程序将为用户提供服务,并且它能还能通过调用其他对象提供的服务来实现这一点。我们的最终目标是开发或调用工具库中已有的一些对象,提供理想的服务来解决问题

更进一步,我们需要将问题进行分解,将其抽象成一组服务,并有组织地划分出每一个功能单一、作用明确且紧密的模块,避免将太多功能塞进一个对象里。这样的程序设计可以提高代码的复用性,同时也方便别人阅读和理解我们的代码

封装

我们把编程的侧重领域划分为研发和应用两块。应用程序员调用研发程序员构建的基础工具类做快速开发,研发程序员开发一个工具类,该工具类仅向应用程序员公开必要的内容,并隐藏内部实现的细节,这样可以有效避免工具类被错误使用和更改。显然,我们需要某些方法来保证工具类的正确使用,只有设定访问控制,才能从根本上解决这个问题

因此,使用访问控制的原因有以下两点:

  1. 让应用程序员不要触碰他们不应该触碰的部分
  2. 类库的创建者可以在不影响他人使用的情况下完善和更新工具库

Java 提供了三个显式关键字来设置类的访问权限,分别是 public(公开)、private(私有)和 protected(受保护),这些访问修饰符决定了谁能使用它们修饰的方法、变量或类

  1. public

    表示任何人都可以访问和使用该元素

  2. private

    除了类本身和类内部的方法,外界无法直接访问该元素

  3. protected

    被 protected 修饰的成员对于本包和其子类可见。这句话有点太笼统了,更具体的概括应该是:

    • 基类的 protected 是包内可见的
    • 若子类与基类不在同一包中,那么子类实例可以访问从基类继承而来的 protected 方法,而不能访问基类实例的 protected 方法
  4. default

    如果不使用前面三者,默认就是 default 权限,该权限下的资源可以被同一包中其他类的成员访问

复用

代码和设计方案的复用性是面向对象的优点之一,我们可以通过重复使用某个类的对象来实现复用性,例如,将一个类的对象的作为另一个类的成员变量使用。因此,新构成的类可以是由任意数量和任意类型的其他对象构成,这里涉及到了组合和聚合两个概念:

  • 组合(Composition)经常用来表示拥有关系(has-a relationship)例如,汽车拥有引擎。组合关系中,整件拥有部件的生命周期,所以整件删除时,部件一定会跟着删除
  • 聚合(Aggregation)表示动态的组合。聚合关系中,整件不会拥有部件的生命周期,所以整件删除时,部件不会删除

使用组合可以为我们的程序带来极大的灵活性。通常新建的类中,成员对象会使用 private 访问权限,这样应用程序员无法对其直接访问,我们就可以在不影响客户代码的前提下,从容地修改那些成员。我们也可以在运行时改变成员对象,从而动态地改变程序的行为。下面提到的继承并不具备这种灵活性,因为编译器对通过继承创建的类进行了限制

继承

对象的概念为编程带来便利,它允许我们将各式各样的数据和功能封装到一起,这样可以恰当表达问题空间的概念,而不用受制于必须使用底层机器语言

通过 class 关键字,可以形成编程语言中的基本单元。遗憾的是,这样做还是有很多问题:在创建一个类之后,即使另一个新类与其具有相似的功能,你还是不得不重新创建一个新类。如果我们能利用现有的数据类型,对其进行克隆,再根据情况进行添加和修改,那就方便许多了。继承正是为此而设计,但继承并不等价于克隆。在继承过程中,如原始类(基类、父类)发生了变化,修改过的克隆类(子类、派生类)也会反映出这种变化

基类一般会有多个派生类,并包含派生自它的类型之间共享的所有特征和行为。后者可能比前者包含更多的特征,并可以处理更多消息(或者以不同的方式处理它们)

使用继承,你将构建一个类型层次结构,来表示你试图解决的某种类型的问题。常见的例子是形状,每个形状都有大小、颜色、位置等等,每个形状可以绘制、擦除、移动等,还可以派生出具体类型的形状,如圆形、正方形、三角形等等。派生出的每个形状都可以具有附加的特征和行为,例如,某些形状可以翻转,计算形状面积的公式互不相同等等

类型层次结构体现了形状之间的相似性和差异性,你不需要在问题描述和解决方案描述之间建立许多中间模型。从现有类型继承并创建新类型,新类型不仅包含现有类型的所有成员(尽管私有成员被隐藏起来并不可访问),更重要的是它复制了基类的接口。也就是说,基类对象能接收的消息派生类对象也能接收。如果基类不能满足你的需求,你可以在派生类添加更多的方法,甚至改变现有基类方法的行为(覆盖),只需在派生类重新定义这个方法即可

多态

在处理类的层次结构时,通常把一个对象看成是它所属的基类,而不是把它当成具体类,通过这种方式,我们可以编写出不局限于特定类型的代码。例如上述形状的例子,方法操纵的是通用的形状,而不关心具体是圆还是三角形什么的。所有形状都可以被绘制、擦除和移动。因此方法向其中任何代表形状的对象发送消息都不必担心对象如何处理信息

这种能力改善了我们的设计,减少了软件的维护代价。如果我们把派生对象类型统一看成是它本身的基类,编译器在编译时就无法准确地获知具体是哪个形状被绘制,那一种车正在行驶,这正是关键所在:当程序接受这种消息时,程序员并不关心哪段代码会被执行,绘图方法可以平等地应用到每种可能的形状上,形状会依据自身的具体类型执行恰当的代码

因此,我们就能添加一个新的不同执行方式的子类而不需要更改调用它的方法,更利于程序扩展。那么编译器如何确定该执行哪部分的代码呢?一般来说,编译器不能进行函数调用,对于非 OOP 编译器产生的函数调用会引起所谓的早期绑定,这意味着编译器生成对特定函数名的调用,该调用会被解析为将执行的代码的绝对地址。而面向对象语言使用了一种后期绑定的概念,当向对象发送信息时,被调用的代码直到运行时才确定,编译器要做的只是确保方法存在,并对参数和返回值执行类型检查,但并不知道要执行的确切代码

为了执行后期绑定,Java 使用一个特殊的代码位来代替绝对调用,这段代码使用对象中存储的信息来计算方法主体的地址。因此,每个对象的行为根据特定代码位的内容而不同。当你向对象发送消息时,对象知道该如何处理这条消息。在某些语言如 C++ 必须显式地授予方法后期绑定属性的灵活性,而在 Java 中,动态绑定是默认行为,不需要额外的关键字来实现多态性

发送消息给对象时,如果程序不知道接收的具体类型是什么,但最终执行是正确的,这就是对象的多态性。面向对象的程序设计语言是通过动态绑定的方式来实现对象的多态性的,编译器和运行时系统会负责控制所有的细节。我们只需要知道要做什么,以及如何利用多态性更好地设计程序

对象创建与生命周期

在使用对象时要注意的一个关键问题就是对象的创建和销毁方式。每个对象的生存都需要资源,尤其是内存。当对象不再被使用时,我们应该及时释放资源,清理内存

然而,实际的情形往往要复杂许多。我们怎么知道何时清理这些对象呢?也许一个对象在某一系统中处理完成,但在其他系统可能还没处理完成。另外,对象的数据在哪?如何控制它的生命周期?在 C++ 设计中采用的观点是效率第一,它将这些问题的选择权交给了程序员。程序员在编写程序时,通过将对象放在栈或静态存储区域中来确定内存占用和生存空间。相对的,我们也牺牲了程序的灵活性

Java 使用动态内存分配,在堆内存中动态地创建对象。在这种方式下,直到程序运行我们才能知道创建的对象数量、生存类型和时间。在堆内存上开辟空间所需的时间可能比栈内存要长(但并不一定),但动态分配论认为:对象通常是复杂的,相比于对象创建的整体开销,寻找和释放内存空间的开销微不足道。对于对象的生命周期问题,在 C++ 中你必须以编程方式确定何时销毁对象,而 Java 的垃圾收集机制能自动发现不再被使用的对象并释放相应的内存空间,使得 Java 的编码过程比用 C++ 要简单许多

发表评论

评论已关闭。

相关文章