抽象过程
所有编程语言都提供抽象机制。
汇编语言是对机器语言的抽象,“命令式语言”(C、BASIC等)是对汇编语言的抽象,虽然有了大幅度的改进,但是他们所作的主要抽象仍要求在解决问题时要基于计算机的结构,而不是基于所要解决的问题的结构来考虑。程序员必须建立起在机器模型(位于“解空间”内,对问题建模的地方,如计算机)和实际待解问题的模型(位于“问题空间”内,这是问题存在的地方,例如一项业务)之间的关联。建立这种映射是费力的,而且这不属于编程语言所固有的功能,使程序难以编写,维护代价高昂。
另一种对机器建模的方式就是只针对待解问题建模,这些方式对于要解决的特定类型的问题是不错的解决方案,但是一旦超出其特定领域,它们就力不从心了。
面向对象方式通过向程序员提供表示问题空间中的元素而更进一步,我们将问题空间中的元素及其在解空间的表示称为对象。(还需要一些无法类比为问题空间元素的对象)。这种思想的实质是:程序可以通过添加新类型的对象使自身适用于某个特定问题。因此当你在阅读描述解决方案的代码的同时,也是在阅读问题的表述。
OOP允许根据问题来描述问题,而不是根据运行解决方案的计算机描述问题。
Alan Key总结了面向对象语言的五个基本特性:
- 万物皆为对象 可以存储数据也可以在自身上执行操作
- 程序是对象的集合,他们通过发送消息来告知彼此所要做的
- 每个对象都有自己的由其他对象所构成的存储
-
每个对象都拥有其类型 “每个对象都是某个
类(class)
的一个实例(instance)
”,这里的“类”和“类型”的同义词。每个类最重要的区别于其他类的特性就是“可以发送什么样的消息给它”。 -
某一特定类型的所有对象都可以接收同样的消息 因为“圆形”类型的对象同时也是“几何形”类型的对象,所以一个“圆形”对象必定能够接受发送给“几何形”对象的消息。这种
可替代性(substitutablitiy)
是OOP中最强有力的概念之一
Booch对对象提出了更加简洁的描述:对象具有状态、行为和标识 这意味着每一个对象拥有内部数据(状态)和方法(行为),并且每个对象都可以唯一地与其他对象区分开来,例如每个对象在内存中都有一个唯一的地址(标识)
每个对象都有一个接口
所有对象都是唯一的,但同时也是具有相同的特性和行为的对象所归属的类的一部分。尽管我们在面向对象程序设计中实际上进行的是创建新的数据类型,但事实上所有的面向对象程序设计语言都是用class
这个关键词来表示数据类型。当看到类型一词时,可将其作为类来考虑,反之亦然。二者的差异在于,程序员通过定义类来适应问题,而不在被迫只能使用现有的用来表示机器中的存储单元的数据类型。
每个对象都只能满足某些请求,这些请求由对象的接口(interface)
所定义,决定接口的便是类型。接口确定了对某以特定对象所能发出的请求。但是,在程序中必须有满足这些请求的代码。这些代码与隐藏的数据一起构成了实现。从过程型编程的观点来看,这并不太复杂,此过程通常被概括为:向某个对象“发送消息”(产生请求),这个对象便知道此消息的目的,然后执行对应的程序代码。
类可以用UML(Unified Modelling Language,统一建模语言)形式的图表示。类名在方框的顶部,数据成员描述在方框的中间部分,方法在方框的底部。通常,只有类名和公共方法被示于UML设计图中。
每个对象都提供服务
将对象想象为“服务提供者”。“如果可以将问题从表象中抽取出来,那么什么样的对象可以马上解决问题呢?”。也许对象中的某些已经存在了,但是对于哪些并不存在的对象,它们能够提供哪些服务?需要哪些对象才能履行它们的义务?这是将问题分解为对象集合的一种合理的方式。
将对象看作服务提供者还有一个好处:有助于提高对象的内聚性。高内聚是软件设计的基本质量要求之一:意味着一个软件构件的各个方面“组合”得很好。在良好的面向对象设计中,每个对象都可以很好地完成一项任务,一个它所能提供服务的内聚的集合,但并不试图做更多的事,避免将过多的功能都塞在一个对象中。
被隐藏的具体实现
将程序开发人员按照角色分为类创建者和客户端程序员是大有裨益的。类创建者的目标是构建类,只向客户端程序员暴露必须的部分,而隐藏其他的部分。被隐藏的部分通常代表对象内部脆弱的部分,它们很容易被粗心的或不知内情的客户端程序员所毁坏,因此将实现隐藏起来可以减少程序bug。
因此,访问控制的第一个存在原因就是让客户端程序员无法触及他们不应该触及的部分——这些部分对数据类型的内部操作来说是必要的,但并不是用户解决特定问题所需的接口的一部分。
访问控制的第二个存在的原因就是允许库设计者可以改变类内部的工作方式而不用担心会影响到客户端程序员。
复用具体实现
代码复用是面向对象程序设计语言提供的最了不起的优点之一。
最简单的复用某个类的方式就是直接使用该类的对象,此外也可以将那个类置于某个新的类中。我们称其为“创建一个成员对象”。这种概念被称为组合(composition)
,如果组合是动态发生的,那么它通常被称为聚合(aggregation)
。组合经常被视为“has-a”(拥有)关系。(UML图用实心菱形表明组合关系)。
组合带来了极大的灵活性,新类的成员对象通常被声明为private
,这可以在不干扰现有客户端代码的情况下进行修改,也可以在运行时修改这些成员对象。继承并不具备这样的灵活性,因为编译器必须对通过继承而创建的类施加编译时的限制。
实际上在设计新类时,应该首先考虑组合,因为它更加简单灵活,有一些场合才适合继承。
继承
通过概念将数据和功能封装到一起,对问题空间的观念给出恰当的表示,而不用受制于必须使用底层机器语言,这些概念可以用关键字class
表示,它们形成了编程语言中的基本单位。
通过继承可以添加和修改这个副本来创建新类,当源类(被称为基类
、超类
或父类
)发生变动时,被修改的“副本”(被称为导出类
、继承类
或子类
)也会反映这些变动。(UML图中的箭头从导出类指向基类)。
类型不仅仅只是描述了作用于一个对象集合上的约束条件,同时还有与其他类型之间的关系。一个基类型包含其所有导出类型所共享的特性和行为。可以创建一个基类型来表示系统中某些对象的核心概念,从基类型中导出其它类型,来表示此核心可以被实现的各种不同方式。
当继承现有类型时,也就创造了新的类型,这个新的类型不仅包括现有类型的所有成员(尽管private成员被隐藏),而且更重要的是它复制了基类的接口。也就是说,所有可以发送给基类对象的消息同时也可以发送给导出类的对象。由于通过发送给类的消息的类型可知类的类型,所以这也就意味着导出类与基类具有相同的类型。
如果只是简单的继承一个类而不做其他任何事,这意味着导出类的对象不仅与基类拥有相同的类型,而且还拥有相同的行为。有两种方法可以使基类与导出类产生差异。第一种:直接在导出类中添加新方法。第二种也是更重要的一种方法,是改变现有基类的方法的行为,被称为覆盖(overriding)
。
“是一个”与“像是一个”关系
继承应该只覆盖基类的方法(而并不添加在基类中没有的新方法)吗?如果这样做,就意味着导出类和基类是完全相同的类型,因为它们具有完全相同的接口,结果可以用一个导出类对象来完全替代一个基类对象。这种被视为纯粹替代
,通常称之为替代原则
。在某种意义上,这是一种处理继承的理想方式,我们经常将这种情况下的基类与导出类之间的关系称为is-a(是一个)关系。判断是否继承,就要确定是否可以用is-a来描述类之间的关系。
有时导出类必须要添加新的接口元素,也就扩展了接口。这个新的类型仍然可以替代基类,但是这种替代并不完美,因为基类无法访问新添加的方法。这种情况我们可以描述为is-like-a(像是一个)关系。
伴随多态的可互换对象
在处理类型的层次结构时,经常想把一个对象不当作它所属的特定类型来对待,而是将其当作其基类的对象来对待,这使得人们可以编写出不依赖于特定类型的代码。例如基类“几何形”的方法操作的都是泛化(generic)
的形状,而不关心它们具体的是什么。这样的代码不会受添加新类型影响的,而且添加新类型是扩展一个面向对象程序以便处理新情况的最常用的方式。通过导出新的子类型而轻松扩展设计的能力是对改动进行封装的基本方式之一。这种能力可以极大地改善我们的设计,同时也降低软件维护的代价。
但是,在试图将导出类型的对象当作其泛化基类型对象来看待时,仍会存在一个问题。那就是编译器在编译时是不可能知道应该执行哪一段代码。这就是关键所在:当发送这样的消息时,程序员并不想知道哪一段代码被执行,而对象会依据自身的具体类型来执行恰当的代码。
问题的答案也是面向对象程序设计的最重要的绝妙:编译器不可能产生传统意义上的函数调用。一个非面向对象编程的编译器产生的函数调用会引起所谓的前期绑定,这么做意味着编译器将产生对一个具体函数名字的调用,而运行时将这个调用解析到将要被执行代码的绝对地址。为了解决这个问题,面向对象程序设计语言使用了后期绑定的概念。当向对象发送消息时,被调用的代码知道运行时才能确定。编译器确保被调用方法的存在,并对调用参数和返回值执行类型检查(无法提供此类保证的语言被称为是弱类型的),但是并不知道将被执行的确切代码。
为了执行后期绑定,Java使用一小段特殊的代码来替代绝对地址调用。这段代码使用在对象中存储的信息来计算方法体的地址。这样,根据这一小段代码的内容,每一个对象都可以具有不同的行为表现。当向对象发送消息时,该对象就能够知道对这条消息应该做些什么。
在某些语言中,必须明确地声明希望某个方法具备后期绑定属性所带来的灵活性(C++使用virtual
关键字来实现的),在这些语言中,方法在默认情况下不是动态绑定的,而Java动态绑定是默认行为。
把将导出类看作是它的基类的过程称为向上转型(upcasting)
。转型(cast)
来自于模型铸造的塑模动作;而向上(up)
这个词来源于继承图的典型布局方式。一个面向对象程序肯定会在某处包含向上转型,这正是将自己从必须知道确切类型中解放出来的关键。Java编译器在编译方法时,并不能确切知道要处理的类型,通常会期望它的编译结果是调用基类的方法,而不是具体导出类的相应版本。正是因为多态才使得事情总能被正确处理。
单根集成结构
Java所有的类最终都继承与单一的基类Object。在单根继承结构中的所有对象都具有一个共用的接口,所以它们归根到底都是相同的基本类型。C++所提供的结构无法确保所有对象都属于同一个基本类型。从向后兼容的角度看,这么做能更好地适应C模型,但是进行完全的面向对象程序设计时,在所获得的任何新类库中,总会用到一些不兼容的接口,需要花力气(可能多重继承)来使新接口融入设计之中。
单根继承结构保证所有对象具备某些功能,因此在你的系统中你可以在每个对象上执行某些基本操作。所有对象都很容易地在堆上创建,而参数传递也得到了极大的简化。
单根继承结构使垃圾回收器的实现变得容易很多。由于所有对象都保证具有其类型信息,因此不会因为确定对象的类型而陷入僵局。这对于系统级操作(如异常处理)显得尤其重要,并且给编程带来了更大的灵活性。
容器
创建另一种对象类型,新的对象类型持有对其他对象的引用。这个通常被称为“容器”(也被称为集合)的新对象,只需要创建一个容器对象,然后让它处理所有细节。
从设计的观点看,真正需要的只是一个可以被操作,从而解决问题的序列。需要对容器有所选择,第一,不同容器提供了不同类型的接口和外部行为。堆栈相比于队列就具备不同的接口和行为,也不同于集合和列表的接口和行为。某种容器提供的解决方案可能比其他容器要灵活的多。第二,不同容器对于某些操作具有不同的效率。例如List:ArrayList和LinkedList。它们都是具有相同接口和外部行为的简单序列,但是它们对某些操作所花费的代价却有天壤之别。在Arraylist中,随机访问元素是一个花费固定时间的操作;但是相对于LinkedList来说,随机选取元素需要在列表中移动,越靠近表尾的元素花费时间越长。而另一方面,如果想在序列中间插入一个元素,LinkedList的开销却比ArrayList小。上述操作以及其他操作的效率,依序列底层结构的不同存在很大差异。接口List所带来的抽象,把在容器之间进行转换时对代码产生的影响降到最小限度。
参数化类型
单根继承意味着所有东西都是Object类型,所以可以存储Object的容器可以存储任何东西。由于容器只存储Object,所以当将对象引用置入容器时,它必须被向上转型为Object,因此它会丢失其身份。当把它取回时,就获得了一个对Object对象的引用,而不是对置入时的那个类型的对象的引用。这里向下转型为更具体的类型。这种转型的方式称为向下转型。向上转型是安全的,除非确切知道索要处理的对象的类型,否则向下转型几乎是不安全的。如果向下转型为错误的类型,就会得到被称为异常的运行时错误。
向下转型和运行时的检查需要额外的程序运行时间,也需要程序员付出更多的心血。那么创建这样的容器,它知道自己保存的对象的类型,从而不需要向下转型以及消除犯错误的可能,这种解决方案被称为参数化类型机制
。参数化类型就是一个编译器可以自动定制作用于特定类型上的类。Java中称为泛型
。一对尖括号,中间包含类型信息,通过这些特征就可以识别对泛型的使用。
对象的创建和生命周期
在使用对象时,最关键的问题之一便是它们的生成和销毁方式。当我们不再需要一个对象时,它必须被清理掉,使其占用的资源可以被清理掉。那怎样才能知道何时销毁这些对象呢?在必须明确删除对象的编程系统中(例如C++),此问题会变得十分复杂。
对象的数据位于何处?怎样控制对象的生命周期?C++认为效率控制是最重要的议题,所以给程序员提供了选择的权力。为了追求最大的执行速度,对象的存储空间和生命周期可以在编写程序时确定,可以通过将内存置入堆栈(有时被称为自动变量(automatic variable)
或限域变量(scoped variable)
)或静态存储区域内来实现。这种方式将存储空间分配和释放置于优先考虑的位置,某些情况下这样控制非常有价值,但也牺牲了灵活性,因为在编写程序的时知道对象确切的数量、生命周期和类型。如果试图解决更一般化的问题,如计算机辅助设计、仓库管理或者空中交通控制,这种方式就显得过于受限了。
第二种方式是在被称为堆(heap)的内存池中动态地创建对象。在这种方式中,直到运行时才知道需要多少对象,它们的生命周期如何,以及它们的具体类型是什么。因为存储空间是在运行时被动态管理的,所以需要大量的时间在堆中分配存储空间,这可能要远远大于在堆栈中创建存储空间的时间。在堆栈中创建存储空间和释放存储空间通常各需要一条汇编指令即可,分别对应将栈顶指针向下移动和将栈顶指针向上移动。创建堆存储空间的时间依赖于存储机制的设计。
动态方式有这样一个一般性的逻辑假设:对象趋向于变得复杂,所以查找和释放存储空间的开销不会对对象的创建造成重大冲击。动态方式所带来的更大的灵活性正是解决一般化编程问题的要点所在。
Java完全采用了动态内存分配方式,每当想要创建新对象时,就要使用new关键字来构建此对象的动态实例。
对于允许在堆栈上创建对象的语言,编译器可以确定对象的存活时间,并自动销毁它。然而如果是在堆上创建对象,编译器就会对它的生命周期一无所知。在像C++这样的语言中,必须通过编程方式来确定何时销毁对象,这可能会因为不能正确处理而导致内存泄漏。Java提供了“垃圾回收器”的机制,它可以自动发现对象何时不再被使用,并继而销毁他。
Java的垃圾回收器被设计用来处理内存释放的问题(景观它不包括清理对象的其他方面)。垃圾回收器“知道”对象何时不再被使用,并自动释放对象占用的内存。这一点同所有对象继承自单根基类Object以及只能以一种方式创建对象(在堆上创建)这两个特性结合起来,使Java编程的过程较之用C++要简单的多。
异常处理
Java的异常处理在众多的编程语言中格外引人注目,因为Java一开始就内置了异常处理,而且强制你必须使用它。它是唯一可接受的错误报告方式。如果没有编写正确的处理异常的代码,那么就会得到一条编译时的出错消息。这种有保障的一致性有时会使得错误处理非常容易。
异常处理不是面向对象的特征——尽管在面向对象语言中异常常被表示成一个对象。异常处理在面向对象语言出现之前就已经存在了。
并发编程
在同一时刻处理多个任务的思想。最初,使用中断服务程序,主进程的挂起是通过硬件中断来触发的。有时中断对于处理时间性强的任务是必须的,但是对于大量的其他问题,我们只是想把问题切分成多个可独立运行的部分(任务),从而提供程序的响应能力。在程序中,这些彼此独立运行的部分称之为线程,上述概念被称为“并发”。
网友评论