文章由On Java8网上翻译版本而来,具体翻译可查看:https://lingcoder.github.io/OnJava8/#/,个人结合Java编程思想第四版进行细小修改
Smalltalk 作为第一个成功的面向对象并影响了 Java 的程序设计语言 ,Alan Kay 总结了其五大基本特征。通过这些特征,我们可理解“纯粹”的面向对象程序设计方法是什么样的:
Grady Booch 提供了对对象更简洁的描述:一个对象具有自己的状态,行为和标识。这意味着对象有自己的内部数据(提供状态)、方法 (产生行为),并彼此区分(每个对象在内存中都有唯一的地址)。
所有对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分。
Simula 是一个很好的例子。在这个例子里,我们有一系列出纳员、客户、帐号、交易和货币单位等许多"对象”。每类成员(元素)都具有一些通用的特征:每个帐号都有一定的余额;每名出纳都能接收客户的存款;等等。与此同时,每个成员都有自己的状态;每个帐号都有不同的余额;每名出纳都有一个名字。所以在计算机程序中,能用独一无二的实体分别表示出纳员、客户、帐号以及交易。这个实体便是“对象”,而且每个对象都隶属一个特定的“类”,那个类具有自己的通用特征与行为。
因此,在面向对象的程序设计中,尽管我们真正要做的是新建各种各样的数据“类型”(Type),但几乎所有面向对象的程序设计语言都采用了 class 关键字。当你看到 “type” 这个词的时候,请同时想到 class;反之亦然。
创建好一个类后,可根据情况生成许多对象。随后,可将那些对象作为要解决问题中存在的元素进行处理。事实上,当我们进行面向对象的程序设计时,面临的最大一项挑战性就是:如何在“问题空间”(问题实际存在的地方)的元素与“方案空间”(对实际问题进行建模的地方,如计算机)的元素之间建立理想的“一对一”的映射关系。
Light lt = new Light()
lt.on()
在这个例子中,类型/类的名称是 Light,可向 Light 对象发出的请求包括包括打开 on
、关闭 off
、变得更明亮 brighten
或者变得更暗淡dim
。通过声明一个引用,如 lt
和 new
关键字,我们创建了一个 Light 类型的对象,再用等号将其赋给引用。
为了向对象发送消息,我们使用句点符号 . 将lt
和消息名称 on
连接起来。可以看出,使用一些预先定义好的类时,我们在程序里采用的代码是非常简单直观的。
上图遵循 UML(Unified Modeling Language,统一建模语言)的格式。每个类由一个框表示,框的顶部有类型名称,框中间部分是要描述的任何数据成员,方法(属于此对象的方法,它们接收任何发送到该对象的消息)在框的底部。通常,只有类的名称和公共方法在 UML 设计图中显示,因此中间部分未显示,如本例所示。如果你只对类名感兴趣,则也不需要显示方法信息。
在开发或理解程序设计时,我们可以将对象看成是“服务提供者”。你的程序本身将为用户提供服务,并且它能通过调用其他对象提供的服务来实现这一点。我们的最终目标是开发或调用工具库中已有的一些对象,提供理想的服务来解决问题。
软件设计的基本原则是高内聚:每个组件的内部作用明确,功能紧密相关。
在良好的面向对象设计中,每个对象功能单一且高效。
使用访问控制的原因有以下两点:
Java 有三个显式关键字来设置类中的访问权限:public
(公开),private
(私有)和protected
(受保护)。这些访问修饰符决定了谁能使用它们修饰的方法、变量或类。
public
(公开)表示任何人都可以访问和使用该元素;private
(私有)除了类本身和类内部的方法,外界无法直接访问该元素。private
是类和调用者之间的屏障。任何试图访问私有成员的行为都会报编译时错误;protected
(受保护)类似于private
,区别是子类(下一节就会引入继承的概念)可以访问 protected
的成员,但不能访问 private
成员;default
(默认)如果你不使用前面的三者,默认就是 default
访问权限。default
被称为包访问,因为该权限下的资源可以被同一包(库组件)中其他类的成员访问。一个类经创建和测试后,理应是可复用的。
我们可以通过重复使用某个类的对象来达到这种复用性。同时,我们也可以将一个类的对象作为另一个类的成员变量使用。新的类可以是由任意数量和任意类型的其他对象构成。这里涉及到“组合”和“聚合”的概念:
(译者注:组合和聚合都属于关联关系的一种,只是额外具有整体-部分的意义。至于是聚合还是组合,需要根据实际的业务需求来判断。可能相同超类和子类,在不同的业务场景,关联关系会发生变化。只看代码是无法区分聚合和组合的,具体是哪一种关系,只能从语义级别来区分。聚合关系中,整件不会拥有部件的生命周期,所以整件删除时,部件不会被删除。再者,多个整件可以共享同一个部件。组合关系中,整件拥有部件的生命周期,所以整件删除时,部件一定会跟着删除。而且,多个整件不可以同时共享同一个部件。这个区别可以用来区分某个关联关系到底是组合还是聚合。两个类生命周期不同步,则是聚合关系,生命周期同步就是组合关系。)
问题:在创建了一个类之后,即使另一个新类与其具有相似的功能,还是要重新创建一个新类。
继承是对某一个类进行“克隆”,再根据情况进行添加和修改。但继承并不完全等价于克隆。在继承过程中,若原始类(正式名称叫作基类、超类或父类)发生了变化,修改过的“克隆”类(正式名称叫作继承类或者子类)也会反映出这种变化。
这个图中的箭头从派生类指向基类。正如你将看到的,通常有多个派生类。类型不仅仅描述一组对象的约束,它还涉及其他类型。两种类型可以具有共同的特征和行为,但是一种类型可能包含比另一种类型更多的特征,并且还可以处理更多的消息(或者以不同的方式处理它们)。继承通过基类和派生类的概念来表达这种相似性。基类包含派生自它的类型之间共享的所有特征和行为。创建基类以表示思想的核心。从基类中派生出其他类型来表示实现该核心的不同方式
基类是“形状”,每个形状都有大小、颜色、位置等等。每个形状可以绘制、擦除、移动、着色等。由此,可以派生出(继承出)具体类型的形状——圆形、正方形、三角形等等——每个形状可以具有附加的特征和行为。
ps:派生类与基类是相同的类型。因为基类和派生类都具有相同的基本接口,所以伴随此接口的必定有某些具体实现。也就是说,当对象接收到特定消息时,必须有可执行代码。如果继承一个类而不做其他任何事,则来自基类接口的方法直接进入派生类。这意味着派生类和基类不仅具有相同的类型,而且具有相同的行为。
有两种方法可以区分新的派生类与原始的基类。第一种方法很简单:在派生类中添加新方法。这些新方法不是基类接口的一部分。这意味着基类不能满足你的所有需求,所以你添加了更多的方法。继承的这种简单而原始的用途有时是解决问题的完美解决方案。然而,还是要仔细考虑是否在基类中也要有这些额外的方法。这种设计的发现与迭代过程在面向对象程序设计中会经常发生。
尽管继承有时意味着你要在接口中添加新方法(尤其是在以 extends 关键字表示继承的 Java 中),但并非总需如此。第二种也是更重要地区分派生类和基类的方法是改变现有基类方法的行为,这被称为覆盖 (overriding)。要想覆盖一个方法,只需要在派生类中重新定义这个方法即可。
问题:继承应该只覆盖基类的方法(不应该添加基类中没有的方法)
如果使用一个派生类对象完全替代基类对象,则称作"纯粹替代",也曾经被称作"替代原则"。在某种意义上,这是一种处理继承的理想方式。我们经常把这种基类和派生类的关系称为是一个(is-a)关系,因为可以说"圆是一个形状"。判断是否继承,就看在你的类之间有无这种 is-a 关系。
如果在派生类添加了新的接口元素,从而拓展接口。虽然新类型仍然可以替代基类,但是这种替代不完美,原因在于基类无法访问新添加的方法。这种关系称为像是一个(is-like-a)关系。新类型不但拥有旧类型的接口,而且包含其他方法,所以不能说新旧类型完全相同。
例:以空调为例,假设房间里已经安装好了制冷设备的控制器,即你有了控制制冷设备的接口。想象一下,现在空调坏了,你重新安装了一个既制冷又制热的热力泵。热力泵就像是一个(is-like-a)空调,但它可以做更多。因为当初房间的控制系统被设计成只能控制制冷设备,所以它只能与新对象(热力泵)的制冷部分通信。新对象的接口已经扩展了,现有控制系统却只知道原来的接口,一旦看到这个设计,你就会发现,作为基类的制冷系统不够一般化,应该被重新命名为"温度控制系统",也应该包含制热功能,这样的话,我们就可以使用替代原则了。上图反映了在现实世界中进行设计时可能会发生的事情。
当你看到替代原则时,很容易会认为纯粹替代是唯一可行的方式,并且使用纯粹替代的设计是很好的。但有些时候,你会发现必须得在派生(扩展)类中添加新方法(提供新的接口)。只要仔细审视,你可以很明显地区分两种设计方式的使用场合。
代码示例:
void doSomething(Shape shape) {
shape.erase();
// ...
shape.draw();
}
此方法与任何 Shape 对话,因此它与所绘制和擦除的对象的具体类型无关。如果程序的其他部分使用 doSomething()
方法:
Circle circle = new Circle();
Triangle triangle = new Triangle();
Line line = new Line();
doSomething(circle);
doSomething(triangle);
doSomething(line);
可以看到无论传入的“形状”是什么,程序都正确的执行了。
分析:
doSomething(circle);
当预期接收 Shape 的方法被传入了 Circle,会发生什么。由于 Circle 也是一种 Shape,所 以 doSomething(circle)
能正确地执行。也就是说,doSomething()
能接收任意发送给 Shape 的消息。这是完全安全和合乎逻辑的事情。
这种把子类当成其基类来处理的过程叫做“向上转型”(upcasting)。在面向对象的编程里,经常利用这种方法来给程序解耦。再看下面的 doSomething()
代码示例:
shape.erase();
// ...
shape.draw();
我们可以看到程序并未这样表达:“如果你是一个 Circle ,就这样做;如果你是一个 Square,就那样做…”。若那样编写代码,就需检查 Shape 所有可能的类型,如圆、矩形等等。这显然是非常麻烦的,而且每次添加了一种新的 Shape 类型后,都要相应地进行修改。在这里,我们只需说:“你是一种几何形状,我知道你能删掉erase()
和绘制 draw()
,你自己去做吧,注意细节。”
尽管我们没作出任何特殊指示,程序的操作也是完全正确和恰当的。我们知道,为 Circle 调用draw()
时执行的代码与为一个 Square 或 Line 调用draw()
时执行的代码是不同的。但在将 draw()
信息发给一个匿名 Shape 时,根据 Shape 句柄当时连接的实际类型,会相应地采取正确的操作。这非常神奇,因为当 Java 编译器为 doSomething()
编译代码时,它并不知道自己要操作的准确类型是什么。
尽管我们确实可以保证最终会为 Shape 调用erase()
和 draw()
,但并不能确定特定的 Circle,Square 或者 Line 调用什么。最后,程序执行的操作却依然是正确的,这是怎么做到的呢?
发送消息给对象时,如果程序不知道接收的具体类型是什么,但最终执行是正确的,这就是对象的“多态性”(Polymorphism)。面向对象的程序设计语言是通过“动态绑定”的方式来实现对象的多态性的。编译器和运行时系统会负责对所有细节的控制;我们只需知道要做什么,以及如何利用多态性来更好地设计程序。
所有的类都应该默认从一个基类继承,在java中,这个最终基类的名字为object
;
好处:
问题:通常,我们并不知道解决某个具体问题需要的对象数量和持续时间,以及对象的存储方式。那么我们如何知悉程序在运行时需要分配的内存空间呢?
解决方法:创建一个新类型的对象来引用,容纳其他的对象。多数编程语言都支持“数组”,在java中“集合”的使用率更高。
“集合”这种类型的对象可以存储任意类型、数量的其他对象。能根据需要自动扩容。
不同类型的集合对应不同的需求:常见的有 List,常用于保存序列;Map,也称为关联数组,常用于将对象与其他对象关联);Set,只能保存非重复的值;其他还包括如队列(Queue)、树(Tree)、栈(Stack)、堆(Heap)等等。
集合可以提供不同类型的接口和外部行为。
不同的集合对某些操作有不同的效率。例如,List 的两种基本类型:ArrayList 和 LinkedList。虽然两者具有相同接口和外部行为,但是在某些操作中它们的效率差别很大。在 ArrayList 中随机查找元素是很高效的,而 LinkedList 随机查找效率低下。反之,在 LinkedList 中插入元素的效率要比在 ArrayList 中高。由于底层数据结构的不同,每种集合类型在执行相同的操作时会表现出效率上的差异。
Java单继承结构意味着所有元素都基于Object
类,所以在集合中可以保存任何类型的数据,由于Java5版本前集合只保存Object
,当我们往集合中添加元素时,元素便向上转型成了Objecct
,元素丢失原有的类型特性。当我们从集合中取出该元素时,元素类型变成了Object
。此时,我们可以使用强制类型转换将其转为更具体的类型,这个过程称为对象的“向下转型”。
问题:如果我们无法确定元素的具体类型信息,那么“向下转型“不安全,一旦我们转化率错误的类型,程序就会运行出错。而且我们每次取出元素都要做额外的"向下转型",这无疑是一种重复的劳动。
解决方法:Java5版本支持参数化类型机制(Parameterized Type Mechanism),并称之为"泛型":该机制能够自动识别某个class
的具体类型并正确地执行。
例子:对集合的参数化类型机制可以让集合仅接受“形状”这种类型的元素,并以“形状”类型取出元素。
//Shape为泛型,自定义。
ArrayList <Shape> shapes = new ArrayList<>();
每个对象的生存都需要资源,尤其是内存。为了资源的重复利用,当对象不再被使用时,我们应该及时释放资源,清理内存。
问题:对象的数据在哪?对象的生命周期如何被控制?
解决方法:
Java使用动态内存分配。每次创建对象时,使用new
关键字构建该对象的动态实例。但同时带来另一个问题:对象的生命周期,Java的内存管理是建立在垃圾收集器上,它能自动发现对象不再被使用并释放内存。垃圾收集器提供了更高级别的保险,以防止潜在的内存泄漏问题。
“异常”
(Exception)是一个从出错点“抛出”(thrown)后能被特定类型的异常处理程序捕获(catch)的一个对象。它不会干扰程序的正常运行,仅当程序出错的时候才被执行。
文章来源
:https://lingcoder.github.io/OnJava8/#/book/01-What-is-an-Object?id=第一章-对象的概念