层次化
在确立一个系统的全面物理质量时,系统内的连接时依赖扮演主要角色。质量的更传统方面,例如可理解性、可维护性、易测试性和可重用性。都紧密地依赖于物理设计和质量。如果不小心加以防范,循环物理依赖将使系统失去这种质量,使得它缺乏灵活性和难以管理。
甚至对于可修正的设计,维护和改进起来也可能要付出不必要的昂贵代价。对大型的、低层次子系统的被迫依赖,回给更高层次的子系统造成显著的开发负担。最小化这种依赖的影响有助于提高系统的物理质量。
导致循环物理依赖的一些原因
接下来我们讨论循环物理依赖在实践中可能出现的两种方式。
增强
最初的设计通常都是经过精心策划,常常是可层次化的。此时,未预见到的客户需求可能会使我们在增强系统时考虑不周,从而导致不必要的循环依赖。例如,我们有时候会发现相似的对象会因为某些原因而共存于一个系统中,但他们本质上包含同样的信息。
允许链各个组件通过include指令彼此知道隐含了循环物理依赖
如果一个子系统可编译,并且单个组件的包含指令隐含的依赖图是非循环的,则称这个子系统是可层次化的
便利方法
开发人员在使一个系统可用的过程中,尝尝会试图创作一些在结构上不可靠的设计。比如我们有一个图形编辑器Shape.
Shape类能够潜在地定义大量的纯虚函数。Shape类的客户需要能够建立真实的Shape,但是他们不需要直接与派生类接口交互。为了把Shape的客户与派生自Shape的具体类绝缘,创建特定Shape的能力被直接合并进了Shape接口。
#program once
class Shape{
int x_coord_;
int y_coord_;
protected:
Shape(int x, int y);
Shape(const Shape& shape);
...
public:
static Shape *create(const char *typeName);
virtual Shape *clone() const = 0;
virtual void draw() const = 0;
};
为了更容易通过名称添加新的shape,实现了create函数。本质就是一个小的工厂函数
虽然这个设计从可用性角度来看似乎是吸引人的,但是它有一个设计缺陷,使得它维护起来比必要的代价会昂贵许多。Shape的create函数使用了派生自Shape的每一个类的构造函数,这就造成Shape与所有派生自Shape的类之间有一个相互依赖的关系。因此不可能独立于其他所有的类来测试一个特定的Shape,从而显著的加大了在增量式测试过程中所需要的连接时间和磁盘空间。这个shape子系统在其他方面是水平的因而是高度可重用的,但是它怎变成了一个要么全有要么全无得待解问题。
为了提高这个系统的可维护性,我们必须找到一种方法来重新打包shape子系统,使它变成非循环的,从而可层次化。
本质的相互依赖
对象的相互连接网络给软件系统体系结构设计者们提出了一种工程挑战。这种高度的内在耦合使得很难明显而直观地实现层次化。在这个最后的介绍性的例子中,我们将分析实现一个存在于最基本的对象网络中的图的困难。
相关抽象接口中的内在耦合使他们更抗拒层次分解
升级
如果组件y处在比组件x更高的层次上,并且y在物理上依赖x,则称组件y支配组件x
支配是一种组件之间的属性,它和一个单一派生对象内的虚基类之间的同名属性大致相同
如果同层次的组件是循环依赖的,那么就可能把互相依赖的功能从每一个组件升级为一个潜在的新的更高层次组件的静态成员
在庞大的低层子系统中的循环物理依赖将最大程度地增加维护系统的总开销
可以通过把互相依赖升级到更高的层次将循环依赖转换成受欢迎的向下依赖。通过避免子系统本身内部组件之间不必要的依赖,可以显著降低子系统和它的所有客户程序的维护开销。同时,子系统也可以变得更灵活从而更可重用。
降级
把公用功能移动到物理层次结构的更底层的技术叫降级
如果同一层次的组件是循环依赖的,那么就可能把互相依赖的功能从每一个组件下降到一个潜在的新的较低级的组件中,每一个原来的组件都依赖于这个新的组件
升级和降级相似,因为在这两种情况下,都是通过把循环依赖功能移到物理层次结构的另一层的方法来消除组件之间的循环依赖。
将共有代码降级可促成独立重用
可以将升级策略与基础设施降级结合起来,以增强独立重用
把一个具体的类分解为两个包含更高和更低层次功能的类可以促进层次化
将一个抽象的基类分解成两个类–一个定义一个纯粹的接口,另一个定义它的部分的实现,可以促进层次化
把一个系统分解成更小的组件,既可以使它更灵活,也可以使他更复杂,因为现在要与更多的物理部件一起工作了
升级和降级紧密相关。升级与降级本质区别只是功能移动的方向不同。
我们有时可以通过分解出普遍需要的功能,并将它迁移到物理层次结构的更低层来消除组件之间的相互依赖。降级不仅对改进循环相互依赖设计有用,而且也对减少非循环体系结构的CCD有用。将共有的子系统降级可以同时改进可维护性和可扩展性。一个分解适当的系统会更加灵活,因为它的内部物理依赖允许它的组件以更多种类的有用方式被独立测试和重用。
不透明指针
通常,我我们假设如果一个函数使用了T类型的对象,那么它以一种需要知道T的定义的方式使用T。也就是说,为了编译函数,编译器需要知道它所用的对象的大小和布局。在C++中,一个编译器获悉一个对象的大小和布局的方法就是让使用这个对象的组件包含含有该对象定义的组件的头文件。
如果编译函数f的函数体时要求提前看到类型T的定义,则称函数f实质使用了类型T
如果一个函数体在只是看到类型T的声明的情况下就可以编译,那么那个函数本身并不依赖于T的定义。实质使用一个类型的特点在于这样的用法会导致定义T的组件的一种直接编译时依赖。虽然函数f一般只在名称上使用而不是实质使用类型T,但是如果f调用了其他组件中的一个或者多个函数,这些函数依次的依赖T的定义,那么这种情况下仍然有一个f对T的连接时依赖。
如果编译函数f以及f可能依赖的任何组件时,不要求提前看到类型T的定义,则称函数f只在名称上使用了类型T
例如:
class SomeType;
struct Util
{
SomeType *f(SomeType *obj);
}
SomeType *Util::f(SomeType *obj)
{
static SomeType *last = 0;
return obj? last = obj : last;
}
说明函数f只在名称上使用了类型sometype。只在名称上使用一个类型的特点在于这样的用法没有隐含的物理依赖–即使是在连接时。没有物理依赖,耦合也就几乎全部消除了。
也可以对类建立相似的定义,即类实质或只在名称上使用了一个类型。更有用的是这些定义能够扩展应用于作为一个整体的组件。
如果编译组件c时候要求必须提前看到类型T的定义,则称组件c实质使用了类型T
如果编译组件c以及c可能依赖的任何组件时不要求提前看到类型T的定义,则称组件c只在名称上使用了类型T
只在名称上使用了对象的组件可以独立于被命名的对象被彻底测试
如果一个指针所指向的类型定义不包含在当前的编译单元中,这个指针就被称为是不透明的。
例如:
class Foo;
class Handle
{
Foo *opaque_p_;
public:
Foo* get() const;
};
在开发一个具体的应用程序时,一个较高层次的对象常常会将信息存储于定义在物理层次结构的较低层次的对象中。如果信息以一种用户自定义类型的形式存在,就有可能导致下级对象依赖那个类型。只要这个下级不需要主动的对那个类型进行任何是实质性的使用,这个下级就没有必要包含该类型的定义。
如果一个被包含的对象拥有一个指向它的容器的指针,并且要实现那些实质地依赖那个容器的功能,那么我们可以通过一下方法来消相互依赖:1.让被包含的类中的指针不透明;2.在被包含的类的公共接口上提供对容器指针的访问;3.将被包含类的受影响的方法升级为容器类的静态成员
使用不透明指针可以用来打破不需要的循环依赖组件。
哑数据
术语哑数据是对不透明指针概念的一种概括。哑数据是一个对象拥有但不知道如何解释的任何类型信息。这样的数据必须用在另一个对象的上下文中,通常用在一个更高的层次上。
哑数据可能比用于识别其他对象的不透明指针更方便并且偶尔会更简洁
哑数据可以用来打破in-name-only依赖,促进易测试性和减少实现的大小。但是,不透明指针可以同时保持类型安全和封装;而哑数据通常是不能的
哑数据不是不透明指针的一种泛化,它有助于子系统的实现,在这些子系统中低层次对象必须隐含地引用其他低层次的对象。这种技术在这样的地方尤其有用;某些引用在子系统的较低层次不必解释,而只2在某个较高层次的对象的上下文中解释。在这种受约束的上下文中,尽管会损害类型安全和封装,但实现可以更简洁。哑数据的使用是典型的低层次实现细节,通常不会暴露在较高层次子系统的接口中。
冗余
任何种类的重用都隐含着某种形式的耦合。在有些情况下耦合还很可能更严重。冗余指的是为了避免由重用到指定额不需要的物理依赖而故意重复代码或数据的技术
与一些重用形式相关的额外耦合可能会超过从该重用获得的利益
当功能存在于一个独立的物理单位中并且要重用的功能数量相对较小,而会导致耦合的数量却不是等比例的大,以至于超过了重用的好处,冗余是必要的。在重用的数量比较多的情况下,通常适合将共有代码降级到一个较低的层次,在那里可以共享它。
提供少量的冗余数据可以使对一个对象的使用只是在名称上,从而消除连接到那个对象类型的定义的开销
可以使用不同方式来有效地利用冗余,与其他技术结合在一起来减少物理依赖。尤其是选择只是在名称上使用对象不仅可以有效地打破一个子系统内部的循环依赖,而且可以有效地减少对其他子系统的物理依赖。但是有时候为了让某些对象不透明,有必要提供少量的冗余信息。
将子系统打包,使其连接到其他子系统的开销最小化,这是一个设计目标
回调
一个回调是一个函数,由一个客户提供给另一个子系统,它允许该子系统在该客户程序的上下文中执行了一个特定的操作。
不加选择地使用回调可能导致难以理解、调试和维护的设计
回调是强有力的消除耦合的工具,但是应该只是在必要的时候使用它们。由一对互相调用对方的成员函数的类引起相互依赖的拙劣设计的一个症状。回调有时候可以用来打破循环,但通常这个问题最好通过对功能重新打包来处理。
对回调的需求可能是不良的整体体系结构的一个症状
回调是一个函数,由客户提供,用以允许一个较低层次的组件利用一个行为,这个行为需要是一个较高层次的上下文。虚函数可以用来实现一个类型安全回调机制。回调是打破协同操作类之间的依赖的强有力的工具。回调对于图形学和基于事件程序设计是极其重要的。
如果不适当的使用,回调可能会模糊低层次的对象的职责并且导致不必要的概念上的耦合。通常,回调可能比传统的函数调用更难理解、维护和调试。他们的异步特性需要开发人员给予一种不同的类型的关注。作为一个规则,回调应该被当做是最后求助的避难所。
管理类
建立较低层次对象的分等级的所有权,可以使一个系统更容易理解和更可维护。
- 从耦合系统中分解出尽可能多的代码放到独立组件中,并且将其余的相互依赖的类放在一个单个组件中
- 若在某层上可以通过对整个子系统进行封装来消除对低层友元关系的需求,那么就升级该层
建立协同操作对象的清晰的所有权对好的设计来说是基本的。如果有两个或者是更多的对象共享共有的所有权,那个功能应该升级到一个管理类。
分解
分解的意思是提取小块的内聚功能,并把他们移动到一个较低的层次,以便他们可以被独立地测试和重用。分解是减轻由循环依赖类强加的负担的额一种非常普通而高效的技术。分解和降级类似,只是分解的行为不必然消除任何循环;取而代之的是它只减少参与到循环中的功能的数量。通过分解可以将循环依赖升级到一个更高的层次。
将独立可测试实现细节分解出来并降级,能够减少维护一个循环依赖类集合的开销
在循环物理依赖不可避免的地方,将其升级到尽可能高的层次以减少CCD,甚至可以使循环能够由一个单个的、大小便于管理的组件替代
授权友元关系不会产生依赖,但是为了保持封装可能会引起物理耦合
分解是一种通用技术,可用来减少着固有的循环依赖的设计和维护开销。通过将一些实现复杂事务重新安装到较低层次的组件中,可以独立于余下的循环相互依赖代码对该功能进行测试。一般的分解会得到更灵活的体系结构,又不会牺牲运行时的效率。然而,在分解一个子系统的接口时,客户可能被要求使用子系统层次结构中较低层次的组件接口。
升级封装
作为一个C++程序员,你无疑应该了解封装的概念。如果一个接口使其实现细节对客户是不可编程访问的,这个接口就是封装的。一个 常见的误解是,每个单独的类或者组件都有必要封装所有的实现细节,向整个世界展示一个健壮的接口。这样做将使大型的复杂的子系统变的更大、更慢、更复杂。这是不能容忍的。我们可以改为将大量的有用的低层次类藏在一个单个组件的接口后面。我们会经常称这样的组件称为包装器。
什么是和什么不是实现细节取决于物理层次结构内部的抽象级别
将封装所在的层次升级,能够消除对一个子系统内协同操作的组件授予私有访问权的需求
私有的肉文件不是适当的封装替代品,因为他们禁止并排重用
在层次系统中,封装一个类型意味着隐藏了它的使用而不是隐藏了类型的本身
包装器组件可以用来封装一个子系统内的实现类型的使用,但允许其他类型通过他的接口
包装器接口的使用在某些方面比一个分解实现还要简单,因为即使不是全部,大部分可用功能都展示在一个单一的,整体的头文件中。
包装也有缺点,它使接口更不灵活,使遍历它的通信更慢。一个被包装的子系统开始开发时也可能开销更大。但是包装可能是使包含有许多高度互相依赖组件的子系统完成层次化和封装的唯一真正有效的途径。
试图按照每个组件的原则来封装一个子系统的实现,可能会妨碍低层次的通信和破坏一个其他可行的设计。比限制单独类中的客户可访问功能更好的是,我们可以限制在总的子系统中接口中暴露给用户类的子集。通过使用一个包装器的组件,我们可以将封装的层次升级到子系统的最高层,这样做可以消除对低层次友元关系的需要,从而也消除了将紧密耦合的类合并成一个单个的超大型组件的必要。
小结
通过考虑逻辑设计的物理隐含和前摄地将我们的系统设计为一个可层次化的组件的集合,我们创建可可独立于设计的其他部分来理解、测试和重用的模块化抽象的一个层次结构
实现层次化的技术包括:
- 升级:将互相依赖的功能在物理层次结构中提高
- 降级:将共有的功能在物理层次结构中降低
- 不透明指针:让一个对象只在名称上使用另一个对象
- 哑数据:使用表示对同层对象的一个依赖的数据,但只在单独的较高层对象的上下文中
- 冗余:通过重复少量的代码或者数据避免耦合来故意避免重用
- 回调:使用客户提供的函数,这些函数可以使较低层次的子系统能够在更全局的上下文中执行特定的任务
- 管理类:建立一个拥有和协调较低层次对象的类
- 分解:将独立可测试子行为从涉及过渡物理依赖的复杂组件的实现中移出来
- 升级和封装:将实现细节对客户隐藏的地点移动到物理层次结构的更高层