抽象
我们可以理解“纯粹”的面向对象设计方法是什么样的:
- 万物皆对象。你可以将对象想象成一种特殊的变量。它存储数据,但可以在你对其“发出请求”时执行本身的操作。
- 程序是一组对象,通过消息传递来告知彼此该做什么。要请求调用一个对象的方法,你需要向该对象发送消息。
- 每个对象都有自己的存储空间,可容纳其他对象。或者说,通过封装现有对象,可制作出新型对象。所以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂度。
- 每个对象都有一种类型。根据语法,每个对象都是某个“类”的一个“实例”
同一类所有对象都能接收相同的消息。这实际是别有含义的一种说法。由于类型为Circle的一个对象也属于类型为Shape的一个对象,所以一个圆完全能接收发送给“形状”的消息。这意味着可让程序代码统一指挥“形状”,令其自动控制所有符合“形状”描述的对象,其中自然包括“圆”。这一特性称为对象的“可替换性”,是OOP最重要的概念之一。
封装
使用访问控制的原因有以下两点:
- 让应用程序员不要触摸他们不应该触摸的部分;
- 使类库的创建者在不影响后者使用的情况下完善更新工具库。
Java有三个显示关键字来设置类中的访问权限:public、private和protected。这些访问修饰符决定了谁能使用它们修饰的方法,变量或类
- public:表示任何人都可以访问和使用该元素;
- private:除了类本身和类内部的方法,外界无法直接访问该元素。private是类和调用者之间的屏障。任何试图访问私有成员的行为都会报编译错误;
- protected:类似于private,区别是子类可以访问protected的成员,但不能访问private成员
- default:如果你不使用前面三者,默认就是default访问权限,default被称为包访问,因为该权限下的资源可以被同一包中其类的成员访问。
复用
一个类经创建和测试后,理应是可复用的。
代码和设计方案的复用性是面向对象程序设计的优点之一。我们可以通过重复使用某个类的对象来达到这种复用。同时,我们也可以将一个类的对象作为另一个类的成员变量使用。新的类可以是由任意数量和任意类型的其他对象构成。这里涉及到“组合”和“聚合”的概念:
- 组合(Composition)经常用来表示“拥有关系”(has-a relationship)
- 聚合(Aggregation)动态的组合。
组合和聚合都属于关联关系的一种,只是额外具有整体-部分的意义。至于是聚合还是组合,需要根据实际业务需求来判断。可能相同超类和子类,在不同的业务场景,关联关系会发生变化。只看代码是无法区分聚合和组合的具体是哪一种关系,只能从语义级别来区分。聚合关系中:整体不会拥有部件的生命周期,所以整体删除时,部件不会被删除。再者,多个整体可以共享一个部件。组合关系:整体拥有部件的生命周期,所以整体删除时,部件也一定跟着删除。而且,多个整体不可以同时共享同一部件。这个区别可以用来区分某个关联关系到底是聚合还是组合。两个类生命周期不同步,则是聚合关系,生命周期同步就是组合关系。
使用“组合”关系给我们的程序带来极大的灵活性。通常新建的类中,成员变量会使用private访问权限,这样应用程序员则无法对其直接访问。我们可以在不影响客户代码的前提下,从容地修改那些成员。我们也可以在“运行时”改变成员对象从而动态地改变程序的行为,这进一步增大了灵活性。
在面向对象编程中经常重点强调“继承”。在新手程序员的印象里,或许先入为主地认为“继承应当随处可见”。沿着这种思路产生的程序设计通常拙劣又复杂。相反,在创建新类时首先要考虑“组合”,因为它更简单灵活,而且设计更加清晰。
继承
有两种方法可以区分新的派生类与原始基类。第一种方法很简单:在派生类中添加新的方法。这些新的方法不是基类接口的一部分。这意味着基类不能满足你的所有需求,所以你添加了更多的方法。继承的这种简单而原始的用途有时是解决问题的完美解决方案。然而,还是要仔细考虑是否在基类中也要有这些额外的方法。这种涉及的发现和迭代过程在面向对象程序设计中会经常发生。
尽管继承有时候意味着你要在接口中添加新方法(尤其是在以extends关键字表示继承的Java中),但并总需如此。第二种也更重要地区分派生类和基类的方法是改变现有基类方法的行为,这被称为覆盖(overriding)。要想覆盖一个方法,只需要在派生类中重新定义这个方法即可。
“是一个”与“像是一个”的关系
对于继承可能会发生争论:继承应该只覆盖基类的方法吗?如果这样的话,基类和派生类就是相同的类型了,因为它们具有相同的接口。这会造成,你可以用一个派生类对象完全替代基类对象,这叫做“纯粹替代”,也经常倍被称作“替代原则”。在某种意义上,这是一种继承的理想方式。我们经常把这种基类和派生类关系称为是一个(is - a)关系。
有时你在派生类添加了新的接口元素,从而扩展接口。虽然新类型仍然可以替代基类,但这种替代不完美,原因在于基类无法访问新添加的方法。这种关系称为像是一个(is-like-a)关系。新类型不但拥有旧类型的接口,而且包含其他方法,所以不能说新旧类型完全相同。
当你看到替代原则时,很容易会认为纯粹替代是唯一可行的方式,并且使用纯粹替代的设计很好。但有时候,你会发现必须得在派生类中添加新方法。只要仔细审视,你可以很明显地区分两种设计方式的使用场合。
多态
我们在处理类的层次结构时,通常把一个对象看成它是所属的基类,而不是把它看成具体类。通过这种方式,我们可以编写出不局限于特定类型的代码。
这样的代码不会受添加的新类型影响,并且添加新类型是扩展面向对象程序以处理新情况的常用方法。通过派生新的子类来扩展设计的这种能力是封装变化的基本方法之一。这种能力改善了我们的设计,且减少了软件的维护代价。
面向对象程序设计的妙诀:在传统意义上,编译器不能进行函数调用。由非OOP编译器产生的函数调用会引起所谓的早期绑定。这意味着编译器生成特定函数名的调用,该调用会被解析为执行的代码的绝对地址。
通过继承,程序直到运行时才能确定代码的地址,因此发送消息给对象时,还需要其他一些方案。为了解决这个问题,面向对象语言使用后期编译的概念。当面向对象发送消息时,被调用的代码直到运行时才确定。编译器确保方法存在,并对参数和返回值执行类型检查,但是它不知道要执行的确切代码。
为了执行后期绑定,Java使用一个特殊的代码位来代替绝对调用。这段代码使用对象中存储的信息来计算方法主体的地址。因此,每个对象的行为根据特定代码位的内容而不同。当你向对象发送消息时,对象知道该如何处理调用这条消息。
发送消息给对象时,如果程序不知道接收的具体类型是什么,最终执行是正确的,这就是对象的“多态性”(Polymorphism)。面向对象的程序设计语言是通过“动态绑定”的方式来实现对象的多态性。编译器和运行时系统会负责对所有细节的控制。
单继承结构
自从C++引入以来,一个OOP问题变得尤为突出:是否所有类都应该默认从一个基类继承呢?这个答案在Java中是肯定的。在Java中,这个最终基类的名字就是Object。
Java的单继承结构有很多好处。由于所有对象都具有一个公共接口,因此它们最终都属于同一个基类。
另外,单继承的结构使得垃圾收集的实现更为容易。
由于运行期的类型信息会存在于所有对象中,所以我们永远不会遇到判断不了对象类型的情况。这对于系统级操作尤为重要,例如异常处理。同时,这也让我们的编程具有更大的灵活性。
集合
通常,我们并不知道解决某个问题需要的对象数量和持续时间,以及对象的存储方式。那么我们如何知悉程序在运行时需要分配的内存空间呢?
“集合”这种类型的对象可以存储任意类型,数量的其他对象。它能根据需要自动扩容,我们不需要关心过程是如何实现的。
在一些库中,一两个泛型集合就能满足我们所有需求了,而在其他一些类库中,不同类型的集合对应不同的需求:常见的有List,常用于保存序列;Map,也称为关联数组,常用于将对象与其他对象关联;Set,只能保存非重复的值;其他还包括如队列(Queue)、树(Tree)、栈(Stack)、堆(Heap)等等。
之所以选择集合有以下两个原因:
- 集合可以提供不同类型的接口和外部行为。堆栈、队列的应用场景和集合、列表不同,它们中的一种提供解决方案可能比其他灵活得多。它们中的一种提供解决方案可能比其他灵活的多。
- 不同集合对某些操作有不同的效率。例如,List的两种基本类型:ArrayList和LinkedList。虽然两者具有相同的接口和外部行为,但是在某些操作中它们的效率差别很大。在ArrayList中随机查找元素是很高效的,而LinkedList随即查找效率低下。反之,在LinkedList中插入元素的效率要比ArrayList中高。由于底层数据结构的不同,每种集合类在执行相同的操作时会表现出效率的差别。
我们可以一开始使用LinkedList构建程序,在优化系统性能时改用ArrayList。通过List接口的抽象,我们可以很容易地将LinkedList改为ArrayList。
在Java5泛型出来之前,集合中保存的通用类型Object。Java单继承的结构意味着所有元素都基于Object类,所以在集合中可以保存任何类型的数据。要使用这样的集合,我们先要往集合添加元素。由于Java5版本前的集合只保存Object,当我们往集合中添加元素时,元素都向上转型成了Object,从而丢失自己原有的类型特性。这时我们再从集合中取出该元素时,元素变成了Object。那么,我们该怎么将其转回原先具体的类型呢?这里,我们使用了强制类型转换将其转为更为具体的类型,这个过程称为对象的“向下转型”。向上转型的过程是安全的,向下转型的过程是不安全的。无论如何,我们要寻找一种在取出集合元素时确定其具体类型的方法。另外,每次取出元素都要做额外的“向下转型”对程序和程序员都是一种开销。以某种方式创建集合,以确认保证元素的具体类型,减少集合元素“向下转型”的开销和可能出现的错误难道不好吗?这种解决方案就是:参数化类型机制(Parameterized Type Mechanism)。
参数化类型机制可以使得编译器能够自动识别某个class的具体类型并正确地执行。Java5版本支持了参数化类型机制,称之为“泛型”(Generic)。泛型是Java5的主要特性之一。
对象创建与声明周期
我们在使用对象时需要注意一个关键问题就是对象的创建和销毁。每个对象的生存都需要资源,尤其是内存。为了资源的重复利用,当对象不再被使用时,我们应该及时释放资源,清理内存。
在简单的编程场景下,对象的清理并不是问题。我们创建对象,按需使用,最后销毁它。然而,情况往往要比这更复杂。
Java使用动态内存分配。每次创建对象时,使用new关键字构建该对象的动态实例。这又带来了另一个问题:对象的生命周期。较之堆内存,在栈内存中创建对象,编译器能够确定该对象的生命周期并自动销毁它;然而如果你在堆内存创建对象的话,编译器是不知道它的生命周期的。在C++中你必须以编程方式确定何时销毁对象,否则可能导致内存泄漏。Java的内存管理是建立在垃圾收集器上的,它能自动发现对象不再被使用并释放内存。垃圾收集器的存在带来了极大的便利,它减少了我们之前必须要跟踪的问题和编写相关代码的数量。因此,垃圾收集器提供了更高级别的保险,以防止潜在的内存泄露问题。
Java的垃圾收集器被设计用来解决内存释放的问题。垃圾收集器知道对象什么时候不再被使用并且自动释放内存。结合单继承和仅可在堆中创建对象的机制,Java的编程过程中比用C++要简单得多。
异常处理
异常处理机制将程序错误直接交给编程语言甚至是操作系统。“异常”(Exception)是一个从错误点“抛出”(thrown)后能被特定类型的异常处理程序捕获(catch)的一个对象。它不会干扰程序的正常运行,仅当程序出错的时候才被执行。这让我们编程更加简单:不用再反复检查错误了。另外,异常不像方法返回的错误值和方法设置用来表示发生错误的标志位那样可以被忽略。异常的发生是不会被忽略的,它终究会在某一时刻被处理。
最后,“异常机制”提供了一种可靠地从错误状态中恢复的方法,使得我们可以编写出更健壮的程序。有时你只要处理好抛出的异常情况并且恢复程序的运行即可,无需退出。
Java的异常处理机制在编程语言中脱颖而出。Java从一开始就内置了异常处理,因此你不得不使用它。这是Java语言唯一接受的错误报告方法。如果没有编写适当的异常处理代码,你将会收到一条编译时错误消息。这种有保证的一致性有时会让程序的错误处理变得更容易。值得注意的是,异常处理并不是面向对象的特性。尽管在面向对象的语言中异常通常由对象表示,但在面向语言之前也存在异常处理。