第一章 面向对象
- “我们没有意识到惯用语言的结构有多大的力量。可以毫不夸张地说,它通过语义反应机制奴役我们。 语言表现出来并在无意识中给我们留下深刻印象的结构会自动投射到我们周围的世界。” – Alfred Korzybski (1930) >>
- 当我们进行面向对象的程序设计时,面临的最大一项挑战是:如何在 “问题空间”(问题实际存在的地方)的元素与 “方案空间”(对实际问题进行建模的地方,如计算机)的元素之间建立理想的 “一对一” 的映射关系。 >>
a) 所有编程语言都提供抽象机制 >>
问题的复杂度直接取决于抽象的类型和质量
“类型” 意思是:抽象的内容是什么?
b) 一个对象具有自己的状态, 行为和标识。 这意味着对象有自己的内部数据 (提供状态)、方法 (产生行为), 并彼此区分(每个对象在内存中都有唯一的地址) 。 >>
(1) “纯粹” 的面向对象程序设计方法是什么样的 >>
Smalltalk 作为第一个成功的面向对象并影响了 Java 的程序设计语言,Alan Kay 总结了其五大基本特征。
i) 万物皆对象 >>
万物皆对象。你可以将对象想象成一种特殊的变量。它存储数据, 但可以在你对其 “发出请求” 时执行本身的操作。理论上讲,你总是可以从要解决的问题身上抽象出概念性的组件,然后在程序中将其表示为一个对象。
ii) 程序是一组对象, 通过消息传递来告知彼此该做什么 >>
要请求调用一个对象的方法,你需要向该对象发送消息。
iii) 每个对象都有自己的存储空间, 可容纳其他对象 >>
或者说, 通过封装现有对象,可制作出新型对象。所以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂程度。
iv) 每个对象都有一种类型 >>
根据语法, 每个对象都是某个 “类” 的一个 “实例”。其中,“类”(Class)是 “类型”(Type)的同义词。一个类最重要的特征就是 “能将什么消息发给它?”
v) 同一类所有对象都能接收相同的消息 >>
这实际是别有含义的一种说法, 大家不久便能理解。由于类型为 “圆”(Circle)的一个对象也属于类型为 “形状”(Shape)的一个对象,所以一个圆完全能接收发送给 “形状”的消息。这意味着可让程序代码统一指挥 “形状”,令其自动控制所有符合 “形状” 描述的对象,其中自然包括 “圆”。这一特性称为对象的“可替换性”,是 OOP 最重要的概念之一。
(2) 所有对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分。 >>
c) 程序员可利用一些工具表达 “问题空间” 内的元素。由于这种表达非常具有普遍性,所以不必受限于特定类型的问题。我们将问题空间中的元素以及它们在解决方案空间的表示称作 “对象”(Object) >>
a) 每个对象仅能接受特定的请求。我们向对象发出的请求是通过它的 “接口”(Interface)定义的,对象的 “类型” 或 “类” 则规定了它的接口形式。“类型” 与 “接口” 的对应关系是面向对象程序设计的基础 >>
a) 开发或理解程序设计时,我们可以将对象看成是 “服务提供者”。你的程序本身将为用户提供服务,并且它能通过调用其他对象提供的服务来实现这一点。我们的最终目标是开发或调用工具库中已有的一些对象,提供理想的服务来解决问题 >>
b) 将这些问题一一分解,抽象成一组服务。软件设计的基本原则是高内聚: 每个组件的内部作用明确,功能紧密相关 >>
(1) 每个对象都提供了一组紧密的服务。在良好的面向对象设计中,每个对象功能单一且高效。这样的程序设计可以提高我们代码的复用性,同时也方便别人阅读和理解我们的代码。只有让人知道你提供什么服务,别人才能更好地将其应用到其他模块或程序中。 >>
(2) 使类库的创建者(研发程序员)在不影响后者使用的情况下完善更新工具库。例如,我们开发了一个功能简单的工具类,后来发现可以通过优化代码来提高执行速度。假如工具类的接口和实现部分明确分开并受到保护,那我们就可以轻松地完成改造。 >>
(1) public(公开)表示任何人都可以访问和使用该元素 >>
(2) private (私有) 除了类本身和类内部的方法, 外界无法直接访问该元素。 private 是类和调用者之间的屏障。任何试图访问私有成员的行为都会报编译时错误; >>
(3) protected (受保护) 类似于 private, 区别是子类 (下一节就会引入继承的概念) 可以访问 protected 的成员,但不能访问 private 成员; >>
(4) default (默认) 如果你不使用前面的三者, 默认就是 default 访问权限。 default 被称为包访问,因为该权限下的资源可以被同一包(库组件)中其他类的成员访问。 >>
a) 两个类生命周期不同步,则是聚合关系,生命周期同步就是组合关系 >>
(1) 聚合(Aggregation)动态的组合 >>
空心三角形。
i) 聚合关系中,整件不会拥有部件的生命周期,所以整件删除时,部件不会被删除 >>
(2) 组合(Composition) >>
实心三角形
i) 组合关系中,整件拥有部件的生命周期,所以整件删除时,部件一定会跟着删除。而且,多个整件不可以同时共享同一个部件 >>
ii) 组合(Composition)经常用来表示 “拥有” 关系(has-a relationship) 。例如,“汽车拥有引擎”。 >>
a) 使用继承,你将构建一个类型层次结构,来表示你试图解决的某种类型的问题 >>
b) 以相同的术语将解决方案转换成问题是有用的, 因为你不需要在问题描述和解决方案描述之间建立许多中间模型。通过使用对象,类型层次结构成为了主要模型,因此你可以直接从真实世界中对系统的描述过渡到用代码对系统进行描述 >>
c) 若原始类(正式名称叫作基类、超类或父类)发生了变化,修改过的 “克隆” 类(正式名称叫作继承类或者子类)也会反映出这种变化 >>
(1) 这种新类型不仅包含现有类型的所有成员(尽管私有成员被隐藏起来并且不可访问) ,而且更重要的是它复制了基类的接口。也就是说,基类对象接收的所有消息也能被派生类对象接收 >>
i) 添加新方法(尤其是在以 extends 关键字表示继承的 Java 中) >>
ii) 改变现有基类方法的行为,这被称为覆盖 (overriding) >>
只需要在派生类中重新定义这个方法即可
d) “是一个” 与 “像是一个” 的关系 >>
继承应该只覆盖基类的方法 (不应该添加基类中没有的方法) 吗?
“替代原则”
是一个(is-a)关系
基类和派生类就是相同的类型了,因为它们具有相同的接口
(2) 像是一个 (is-like-a) 关系 >>
有时你在派生类添加了新的接口元素,从而扩展接口。虽然新类型仍然可以替代基类,但是这种替代不完美,原因在于基类无法访问新添加的方法
a) 我们在处理类的层次结构时,通常把一个对象看成是它所属的基类,而不是把它当成具体类 >>
(1) 代码不会受添加的新类型影响, 并且添加新类型是扩展面向对象程序以处理新情况的常用方法 >>
b) 当程序接收这种消息时, 程序员并不想知道哪段代码会被执行。“绘图” 的方法可以平等地应用到每种可能的 “形状”上,形状会依据自身的具体类型执行恰当的代码。 >>
(1) 如果不需要知道执行了哪部分代码,那我们就能添加一个新的不同执行方式的子类而不需要更改调用它的方法 >>
i) 在传统意义上,编译器不能进行函数调用。由非 OOP 编译器产生的函数调用会引起所谓的早期绑定 >>
(a) 这意味着编译器生成对特定函数名的调用, 该调用会被解析为将执行的代码的绝对地址。 >>
ii) 通过继承,程序直到运行时才能确定代码的地址,因此发送消息给对象时,还需要 >>
其他一些方案。为了解决这个问题,面向对象语言使用后期绑定的概念。当向对象发送信息时,被调用的代码直到运行时才确定。编译器确保方法存在,并对参数和返回值执行类型检查,但是它不知道要执行的确切代码。
(a) 为了执行后期绑定,Java 使用一个特殊的代码位来代替绝对调用。这段代码使用对象中存储的信息来计算方法主体的地址 >>
因此, 每个对象的行为根据特定代码位的内容而不同
i) 在某些语言中,必须显式地授予方法后期绑定属性的灵活性。例如, C++ 使用 virtual 关键字。在这些语言中,默认情况下方法不是动态绑定的。在 Java 中,动态绑定是默认行为,不需要额外的关键字来实现多态性 >>
(2) 向上转型”(upcasting) >>
这种把子类当成其基类来处理的过程叫做 “向上转型”(upcasting) 。
i) 由于 Java 5 版本前的集合只保存 Object, 当我们往集合中添加元素时,元素便向上转型成了 Object,从而丢失自己原有的类型特性。这时我们再从集合中取出该元素时,元素的类型变成了 Object。 >>
我们使用了强制类型转换将其转为更具体的类型,这个过程称为对象的 “向下转型”
ii) 除非我们能确定元素的具体类型信息,否则 “向下转型” 就是不安全的 >>
每次取出元素都要做额外的 “向下转型” 对程序和程序员都是一种开销
(4) 参数化类型机制(Parameterized Type Mechanism) >>
以某种方式创建集合,以确认保存元素的具体类型,减少集合元素 “向下转型” 的开销和可能出现的错误
i) Java 5 版本支持了参数化类型机制, 称之为 “泛型” (Generic) 。 泛型是 Java 5 的主要特性之一 >>
c) 发送消息给对象时, 如果程序不知道接收的具体类型是什么, 但最终执行是正确的, 这就是对象的 “多态性”(Polymorphism) 。面向对象的程序设计语言是通过 “动态绑定”的方式来实现对象的多态性的。编译器和运行时系统会负责对所有细节的控制; >>
a) Java中只支持单继承 >>
在 Java 中,最终基类的名字就是 Object。
b) 单继承的结构使得垃圾收集器的实现更为容易。这也是 Java 在 C++ 基础上的根本改进之一 >>
对象的数据在哪?它的生命周期是怎么被控制的?
a) C++ 设计中采用的观点是效率第一,因此它将选择权交给了程序员。为了获得最大的运行时速度,程序员可以在编写程序时, 通过将对象放在栈(Stack, 有时称为自动变量或作用域变量)或静态存储区域(static storage area)中来确定内存占用和生存时间。这些区域的对象会被优先分配内存和释放 >>
b) 第二种方法是在堆内存(Heap)中动态地创建对象 >>
。在这种方式下,直到程序运行我们才能确定需要创建的对象数量、生存时间和类型。什么时候需要,什么时候在堆内存中创建。因为内存的占用是动态管理的,所以在运行时,在堆内存上开辟空间所需的时间可能比在栈内存上要长(但也不一定)
(1) 动态方法有这样一个合理假设:对象通常是复杂的,相比于对象创建的整体开销, 寻找和释放内存空间的开销微不足道 >>
(2) Java 使用动态内存分配。每次创建对象时,使用 new 关键字构建该对象的动态实例 >>
i) 这又带来另一个问题:对象的生命周期。 >>
较之堆内存,在栈内存中创建对象,编译器能够确定该对象的生命周期并自动销毁它;然而如果你在堆内存创建对象的话,编译器是不知道它的生命周期的
(a) 在 C++ 中你必须以编程方式确定何时销毁对象,否则可能导致内存泄漏 >>
(b) Java 的内存管理是建立在垃圾收集器上的,它能自动发现对象不再被使用并释放内存 >>
a) “异常”(Exception) 是一个从出错点 “抛出”(thrown)后能被特定类型的异常处理程序捕获 (catch) 的一个对象。它不会干扰程序的正常运行,仅当程序出错的时候才被执行。这让我们的编码更简单:不用再反复检查错误了。另外,异常不像方法返回的错误值和方法设置用来表示发生错误的标志位那样可以被忽略。异常的发生是不会被忽略的,它终究会在某一时刻被处理。 >>
(1) Java 从一开始就内置了异常处理, 因此你不得不使用它。这是 Java 语言唯一接受的错误报告方法 >>
(2) 异常处理并不是面向对象的特性。尽管在面向对象的语言中异常通常由对象表示,但是在面向对象语言之前也存在异常处理 >>