第1章 抽象
C++编程惯用法——高级程序员常用方法和技巧
数据抽象(data abstraction)是面向对象设计的一个重要概念。数据抽象要优先于面向对象的设计;然而,随着C++这样直接支持数据抽象的语言变得流行起来,它的应用范围也变得越来越广泛。
抽象数据类型(abstract data type,也称为ADT)是一种由用户定义、拥有明显不同的两部分的类型:
一个公用的接口(public interface),用于指定用户使用该类型的方式,而一个私用的实现(private implementation),在类型内部使用,以提供公用接口所给定的功能。
在C++中,private和public关键字用于指定类声明中哪部分是实现,哪部分又是接口。通过这种方法,编译器就可以确保类的使用者不会绕过类的接口而直接访问其私用成员。然而,另一个事实就是,类有私用成员也不表示它的设计就很好。
对于private和public来说,它们本身还存在着一个远比低层次语言规则更重要的思想。这个思想还从未被C++编译器显式地检测过,但它对于设计模块化、易维护的程序来说却是至关重要的。它表述的是一种概念,那就是——每个编程问题的解决方案都可以被划分为两部分:一个抽象模型(abstract model),它是在程序员和程序用户之间都能取得一致意见的、用于描述该问题以及其解决方案的智力模型;对于该模型的实现(implementation),就是程序员使用特定的方法使计算机可以表述出这种抽象。在本章中,我们将会通过对一个类的设计、细化以及记录过程,来逐步体现出抽象的过程。如我们将看到的那样,“简单地书写一页手册,然后让代码来适合它”这种方式在程序设计中是行不通的。
下面是一些理由,用于说明为什么一个经过详细考虑并记录下来的抽象模型对于程序设计来说很重要:
它有助于帮助其他人来理解如何使用你所设计的类。如果你正试图使用一个链表类,你的主要的(也是最初的)考虑应该不是包含这个类的头文件的名字,也不是该类中成员函数的名字以及其参数类型等。相对来说,你会更注重那些有关该类抽象模型的基础问题,如:它提供了什么操作?能不能在链表上进行回退?能不能在常数时间内访问到链表的头和尾?同一对象能不能在一个链表中出现多次?链表中包含的是对象本身呢,还是对对象的引用?如果链表包含的是对象的引用,那么由谁来负责创建及销毁这些对象?当链表中的某个对象被销毁时,链表应该有什么样的调整?等等。
对这些问题的回答将会直接影响到那些使用该链表类的应用程序的设计。(例如,如果你的应用程序需要能够在链表上双向移动,那么一个单向链表类在此处就不再适合了。)
如果你不能理解并记录下你的类所支持的抽象模型,那么用户就可能会选择不使用你所提供的类(更糟的是,用户决定使用你的类,然后却发现他们做了一个错误的选择)。
抽象模型是你与你的用户之间的一个协议。由于抽象模型对用户的程序设计有着很重要的影响,你将发现对它进行不向上兼容的改动会有多困难,有时那样的改动甚至就是不可能做到的。例如,一个去除掉链表中后退功能的决议,对公开使用的List类来说将会是一场灾难。为什么这样说呢?因为某些List的用户可能会将其应用程序的设计完全围绕着它能够支持后退这种抽象来进行。这种在抽象模型中的不兼容改动肯定会使某些已有的用户陷入困境。如果一个类已经被广泛使用,那么它的每一个特性都将至少被一个用户所使用,如果你轻率地决定去除掉某个特性,必将在特定的用户群中引起骚乱。
这就意味着,在你决定将你的库发布给客户时,对库的抽象模型进行细化和完善尤为重要。在实现中产生的错误通常都可以很容易地在下一个版本中得到更正;但在抽象模型中产生的问题(除去冗余错误之外)则将持续存在于类的整个生命周期中。
在记录抽象模型的过程中,我们常常可以发现设计中的重要缺陷。在软件项目设计的早期,我们通常会过高地估计我们对于问题以及我们所提出的解决方案的理解程度。在将早期的模糊想法用精确的言语表示清楚的过程(即抽象模型的记录过程)中,我们就能更关注于那些我们以前从未考虑到的方面。毕竟,在认识不清晰的时候,我们是无法提出合理的解决方案的。
清晰的抽象模型文档有助于其他人重新构造出你的类的新版本(这包括继承或是重新实现两种方式)。要想实现出一个能够与现有代码共同工作的新版本的类,你所需做的不止是在成员函数的名字以及类型签名上做到和最初的代码完全匹配;更重要的是,新版本的类必须还能够符合旧版本的抽象模型的要求。
一旦理解了抽象模型,我们就可以避免“以实现来驱动设计”的情况出现。不管其承认与否,许多的软件设计者在设计一个新类的接口时,他们的脑海中都已经有了一个“明显”的实现方案。这就会使得他们在设计中不自觉地将抽象模型向这种实现方式靠拢。这种做法不但不能提供一个用户易于理解的、并且认为就应该如此的接口,它还会使实现的细节遍布于接口之中,导致以后对实现进行改动变得异常困难。
当然,其他极端的做法也同样会导致麻烦的出现(如:设计出一个完全不顾及实现方案的可能性的接口)。一个接口,不管它有多么优雅,如果它不可能被实现出来,或是实现它需要一些让人无法接受的性能上的损失,那么对用户来说它就起不到任何帮助作用。在这些考量之间取得一个合理的平衡是类设计中最具有挑战性的部分。最后说明一点,设计抽象模型和设计实现方案应该是两个独立的行为。但尽管如此,这并不意味着我们需要用不同的人手来分开处理这两部分,重要的是,开发人员必须知道自己在某个特定的时间时,自己到底是在负责抽象呢,还是负责实现?
仅仅考虑抽象(而不是实现),我们有几种常用的方法。在抽象的过程中决定“什么应该有,什么不应该有”是面向对象设计者的一个关键技巧。在下一小节中,我们将开始构造出一个简单的抽象模型。