2.1 创建可工作的类
成为高校程序员的一个关键就在于,当你开发程序任一部分的代码时,都能安全地忽视程序中尽可能多的其余部分。而类就是实现这一目标的首要工具。
类的基础:抽象数据类型
抽象数据类型(ADT,abstract data type)是指一些数据以及对这些数据所进行的操作的集合。要想理解面向对象编程,首先要理解ADT。不懂ADT的程序员开发出来的类只是名义上的“类”而已——实际上这种“类”只不过就是把一些稍有点儿关系的数据和子程序堆在一起。然而在理解ADT之后,程序员就能写出在一开始很容易实现、日后也易于修改的类来。
使用ADT的益处
- 可以隐藏实现的细节。
- 改动不会影响到整个程序。
- 让接口能提供更多的信息。
- 更容易提高性能。
- 让程序的正确性更显而易见。
- 程序更具有自我说明性。
- 无须在程序内到处传递数据。
- 你可以像在现实世界中那样操作实体。
平时我们在编程的时候可以借鉴下面这些指导性的建议:
- 把常见的底层数据类型创建为ADT并使用这些ADT,而不再使用底层数据类型。
- 把像文件这样的常用对象当成ADT。
- 简单的事物也可当做ADT。
- 不要让ADT依赖于其存储介质。
2.2 良好的类接口
创建高质量的类,第一步,可能也是最重要的一步,就是创建一个好的接口。这也包括了创建一个可以通过接口来展现的合理的抽象,并确保细节仍被隐藏在抽象背后。
这里给出一些创建类的接口的指导建议:
- 类的接口应该展现一致的抽象层次。
- 一定要理解类所实现的抽象是什么。
- 提供成对的服务
- 把不相关的信息转移到其他类中;
- 尽可能让接口可编程,而不是表达语义。
- 谨防在修改时破坏接口的抽象。
- 不要添加与接口抽象不一致的公用成员。
- 同时考虑抽象性和内聚性。
良好的封装
封装是一个比抽象更强的概念,这两个概念之所以相关,是因为没有封装时,抽象往往很容易被打破。要么就封装与抽象两者皆有,要么就是两者皆失。
- 尽可能地限制类和成员的可访问性。
- 不要公开暴露成员数据。
- 避免把私用的实现细节放入类的接口中。
- 不要对类的使用者做出任何假设。
- 避免使用友元类。
- 不要因为一个子程序里仅使用公用子程序。
- 让阅读代码比编写代码更方便。
- 要额外警惕从语义上破坏封装性。
2.3 有关设计和实现的问题
给类定义合理的接口,对于创建高质量程序起到了关键作用。然而,类内部的设计和实现也同样重要。这一节就来论述关于包含、继承、成员函数和数据成员、类之间的耦合性、构造函数、值对象与引用对象等的问题。
包含(“有一个...”的关系)
包含是一个非常简单的概念,它表示一个类含有一个基本数据元素或对象。与包含相比,关于继承的论述要多得多,这是因为继承需要更多的技巧,而且更容易出错,而不是因为继承要比包含更好。包含才是面向对象编程中的主力技术。
- 通过包含来实现“有一个/has a 的关系”;
- 在万不得已时通过private继承来实现“有一个”的关系;
- 警惕有超过约7个数据成员的类;如果一个类包含有7个数据成员,请考虑要不要把它分解为几个更小的类。如果数据成员都是整型或字符串这种简单数据类型,你可以按7+2或7-2的上限来考虑;反之如果数据成员都是复杂对象的话,就应该考虑下限。
继承(“是一个...”的关系)
继承的概念是说一个类是另一个类的一种特化。继承的目的在于,通过“定义能为两个或更多个派生类提供公有元素的基类”的方式写出更精简的代码。其中的共有元素可以是子程序接口、内部实现、数据成员或数据类型等。继承能把这些共有的元素集中在一个基类中,从而有助于避免在多处出现重复的代码和数据。
使用继承必须注意:
. 对于每一个成员函数而言,它应该对派生类可见吗?它应该有默认的实现吗?这一默认的实现能被覆盖吗?
. 对于每一个数据成员而言(包括变量、具名常量、枚举等),它应该对派生类可见吗?
下面就来详细解释如何考虑这些事项:
- 用public继承来实现“是一个...”的关系。当程序员决定通过继承一个现有类的方式创建一个新类时,他是在表明这个新的类是现有类的一个更为特殊的版本。基类既对派生类将会做什么设定了预期,也对派生类能怎么运作提出了限制。
如果派生类不准备完全遵守由基类定义的同一个接口契约,继承就不是正确的实现技术了。请考虑换用包含的方式,或者对继承体系的上层做修改。
- 要么使用继承并进行详细说明,要么就不要用它。继承给程序增加了复杂度,因此它是一种危险的技术。
- 遵循liskov替换原则。派生类必须能通过基类的接口而被使用,且使用者无须了解两者之间的差异。换句话说,对于基类中定义的所有子程序,用在它的任何一个派生类中时的含义都应该是相同的。
- 确保只继承需要继承的部分。派生类可以继承成员函数的接口和/或实现。
继承来的子程序有三种基本情况: 1. 抽象且可覆盖的子程序是指派生类只继承了该子程序的接口,但不继承其实现。2.可覆盖的子程序是指派生类继承了该子程序的接口及其默认实现,并且可以覆盖该默认实现。3.不可覆盖的子程序是指派生类继承了该子程序的接口及其默认实现,但不能覆盖默认实现。
当你选择通过继承的方式来实现一个新的类时,请针对每一个子程序仔细考虑你所希望的继承方式。仅仅是因为要继承接口所以才继承实现,或仅仅是因为要继承实现所以才继承接口,这两类情况都值得注意。如果你只是想使用一个类的实现而不是接口,那么就应该采用包含方式,而不该用继承。
- 不要“覆盖”一个不可覆盖的成员函数。派生类中的成员函数不要与基类中不可覆盖的成员函数重名。
- 把共用的接口、数据及操作放到继承树中尽可能高的位置。接口、数据和操作在继承体系中的位置越高,派生类使用它们的时候就越容易。多高合适呢?如果你发现把一个子程序移到更高的层次后会破坏该层对象的抽象性,就该停手了。
- 只有一个实例的类是值得怀疑的。这可能表明设计中把对象和类混为一谈了。
- 派生后覆盖了某个子程序,但在其中没做任何操作,这种情况也值得怀疑。这通常表明基类的设计中有错误。
- 避免让继承体系过深。
为什么有这么多关于继承的原则
这一节给出了许多规则,它们能帮你远离与继承相关的麻烦。下面来总结一下何时可以使用继承,何时又该使用包含:
- 如果多个类共享数据而非行为,应该创建这些类可以包含的共用对象。
- 如果多个类共享行为而非数据,应该让它们从共同的基类继承而来,并在基类里定义共用的子程序。
- 如果多个类既共享数据也共享行为,应该让它们从一个共同的基类继承而来,并在基类里定义共用的数据和子程序。
- 当你想由基类控制接口时,使用继承;当你想自己控制接口时,只用包含。
成员函数和数据成员
下面就有效地实现成员函数和数据成员给出一些指导建议
- 让类中子程序的数量尽可能少。
- 禁止隐士地产生你不需要的成员函数和运算符。
- 减少类所调用的不同子程序的数量。
- 对其他类的子程序的间接调用要尽可能少。
- 一般来说,应尽量减少类和类之间相互合作的范围。
构造函数
- 如果有可能,应该在所有的构造函数中初始化所有的数据成员。这是一个防御式编程实践。
- 用私用构造函数来强制实现单件属性
2.4 创建类的原因
下面就列出创建类的合理原因:
- 为现实世界中的对象建模;
- 为抽象的对象建模;
- 降低复杂度;
- 隔离复杂度;
- 隐藏实现细节;
- 隐藏全局数据;
- 让参数传递更顺畅;
- 建立中心控制点;
- 让代码更易于重用;
- 为程序族做计划;
- 实现某种特定的重构。
应该避免的类
- 避免创建万能类。要避免创建什么都知道、什么都能干的万能类。如果一个类把功夫都花在用get()方法和set()方法想其他类索要数据(也就是说,深入到其他类的工作中并告诉它们该如何去做)的话,请考虑是否应该把这些功能组织到其他那些类中去,而不要放到万能类里。
- 清除无关紧要的类。
- 避免用动词命名的类。 只有行为而没有数据的类往往不是一个真正的类。
核对表: 类的质量
抽象数据类型
- 你是否把程序中的类都看做是抽象数据类型了?是否从这个角度评估它们的接口了?
抽象
- 类是否有一个中心目的?
- 类的命名是否恰当?其名字是否表达了其中心目的?
- 类的接口是否展现了一致的抽象?
- 类的接口是否能让人清楚明白地知道该如何用它?
- 类的接口是否足够抽象,使你能不必顾虑它是如何实现其服务的?你能把类看做黑盒子吗?
- 类提供的服务是否足够完整,能让其他类无须动用其内部数据?
- 是否已从类中出去无关信息?
- 是否考虑过把类进一步分解为组件类?是否已尽可能将其分解?
- 在修改类时是否维持了其接口的完整性?
封装
- 是否把类的成员的可访问性降到最小?
- 是否避免暴露类中的数据成员?
- 在编程语言所许可的范围内,类是否已尽可能地对其他的类隐藏了自己的实现细节?
- 类是否避免对其使用者,包括其派生类会如何使用它做了假设?
- 类是否不依赖于其他类?它是松散耦合的吗?
继承
- 继承是否用来建立“是一个/is a”的关系?也就是说,派生类是否遵循了Liskov?
- 类的文档中是否是记述了其继承策略?
- 派生类是否避免了“覆盖”不可覆盖的方法?
- 是否把公用的接口、数据和行为都放到尽可能高的继承层次中了?
- 继承层次是否很浅?
- 基类中所有的数据成员是否都被定义为private而非protected的了?
跟实现相关的其他问题
- 类中是否只有大约七个或更少的数据成员?
- 是否把类直接或间接调用
- 类是否只在绝对必要时才与其他的类相互协作?
- 是否在构造函数中初始化了所有的数据成员?
- 除非拥有经过测量的、创建浅层复本的理由,类是否都被设计为当作深层复本使用?
与语言相关的问题
- 你是否研究过所用编程语言里和类相关的各种特有问题?