组件
组件与类
逻辑设计强调系统内部定义的类和函数的交互作用。从纯粹的逻辑角度看,一个设计可以看做是类和函数的海洋,哪里没有物理分区存在–每一个类和自由函数都驻留在一个单一的无缝空间里面。
但是罗技设计只考虑了设计过程的一个方面。逻辑设计不考虑像文件和库这样的物理实体。编译时耦合、连接时依赖以及独立重用等都不仅仅是通过逻辑设计就能结局的。例如:一个函数无论是否声明成内联都不会影响他要完成的任务,但是可能会影响到它的一些显示测量的特性,如运行时间、编译时间、连接时间和可执行程序的大小。如果不从物理角度来考虑设计,就不可能考虑到在设计超大型系统时变的很重要的组织问题
逻辑设计只研究体系结构问题;物理设计研究组织问题
物理设计几种研究系统中的物理实体以及他们如何相互关联的问题。在大多数传统的C++程序设计环境中,系统中每一个逻辑实体的源代码都必须驻留在一个物理实体中,一般称为一个文件。基本上每一个C++程序都可以描述为一个文件的集合。这些文件中有一些是头文件,而有一些则是实现文件。对于小型程序,这种描述足够了。但是对于大一些程序,我们需要利用额外的结构来生成可维护、可测试和可重用的子系统
一个组件就是物理设计的最小单位
组件不是类,反之亦然。从概念上来讲,一个组件包含一组逻辑设计的子集,这些逻辑设计使得组件爱你作为一个独立的、内聚的单位存在时是有意义的。类、函数、枚举等等都是构成这些组件的逻辑实体。特别地,每个类定义都严格地只驻留在一个组件中。
在结构上,组件是一个不可分割的物理单位,没有哪一个部分可以独立地用在其他的组件中。一个组件的物理形态是标准的,并且独立于它的内容。一个组件爱你严格地由一个头文件和一个源文件构成。
一个组件一般会定义一个或者多个紧密相关的类和被认定适合于所支持的抽象的任何自由的运算符。
原则:一个组件就是设计的适当的基本单位
一个组件爱你(不是一个类)是逻辑和物理设计的适当的基本单位,至少有三个理由:
- 一个组件把便于管理的一定数量的内聚功能塞进一个单位的物理单位中
- 一个组件不仅把捕捉的整个抽象作为单一的实体,而且允许不通过类级设计来考虑物理问题
- 一个设计适当的组件可以作为一个单一的单位从系统中提出来,不必重写任何代码就可以在另一个系统中有效的重用。
一个组件的逻辑接口就是可被客户通过编程访问或者检测到的东西
组件的逻辑接口是定义在头文件中的类型和功能的集合,他们可以被该组件的客户通过编程访问。因为组织原因而驻留在头文件中的私有实现细节将被封装,不被认为是逻辑接口的一部分
一个组件的头文件的文件作用域中定义的任何类或者声明的任何自由函数,其公共接口里面如果使用了一个类型,则称在这个组件接口中使用了这个类型
一个组件的公共接口由那个类的公共成员的接口的并集构成,同样的,一个组件的公共接口也是由组件的头文件中所声明的公共成员函数、typedef、枚举类型和自由函数的集合构成
一个组件的物理接口就是他头文件中的所有东西
一个组件的物理接口由头文件中可利用的所有信息构成。组件的头文件中包含的信息越多,对组件爱你实现的修改就越有可能影响组件的客户程序而导致它们要重编译。
如果一个组件的任何地方通过名称引用了一个类型,则称在这个组件的实现中使用了该类型
从逻辑角度看,在组件的实现中有或者没有使用什么都是封装细节,并不重要。从物理角度看,这样的使用可能隐含着对其他组件的物理依赖。在大型系统中正是这些物理依赖会影响可维护性和可重用性。
优秀的设计要求开发者既懂得逻辑设计只是也要懂物理设计只是。逻辑设计师天然的出发点。我们必须考虑那些逻辑实体应该天然地在一起或者是充分的互相依赖,因此不能被合理地分割。我们也必须考虑在物理接口上需要暴露多少实现细节。而且我们还必须决定我们饿组件会依赖于哪些其他的鹅组还能,在这些组件中有哪些变化会对我们自己的组件以及其客户程序产生影响。所以这些问题都已经研究结局之后才正确地设计了一个组件。
物理设计规则
如果要使我们其他的规程和技术生效,这些规则是必要的。若一个大型设计从一开始就没有从本质上遵循这些规程,那么我们事实上不可能改正它
在一个组件内部声明的逻辑实体不应该在该组件之外定义
对一个可重用的组件来说,它必须合理地自我包含。一个组件可以有对其他组件的依赖,但是,一个组件在它的头文件中声明的任何逻辑结构。-- 如果定义了则应该全部定义在该组件内部
组成一个组件的源文件和头文件名称应该严格匹配
严格匹配一个组件的根名称,对于可维护性来说是重要的。例如,知道了stack.cpp和stack.h组成一个组件,不仅方便手工维护,而且为简单的面向对象设计自动工具打开了大门
每个组件的源文件都应该包含它自己的头文件语句作为其代码的第一行有效语句
通过确保一个组件自己分析自己的头文件–不要外部提供的声明和定义,可以避免潜在的使用错误
客户程序应该包含直接提供了所需类型定义的头文件;除非私有继承,应避免依赖一个头文件去包含另一个头文件
一个头文件是否应该包含另一个头文件时一个物理问题,不是逻辑问题。为了编译,头文件本身需要另一个头文件中的定义,这种情况下,把适当的include指令挡在头文件中是正确的。
但是除了公共的和保护的继承之外,包含一个类型的定义而不是预先在头文件中声明它,这种需求几乎总是由封装的逻辑实现细节来决定。
继承是一个例外,因为继承总是隐含一个编译时的依赖,并且是派生类的逻辑接口的一部分,改变继承层次结构,也将改变逻辑接口,同时将迫使客户程序被再次访问。因此,客户程序只包含一个派生类的定义,并依赖这个派生类的头文件来包含基类的定义是合理的。
因为类似原因,客户程序依赖某个组件的头文件来事先声明一个只用在该组件的逻辑实现中的类是不明智的
在一个组件的源文件中,避免使用有外部连接并且没有在相应的头文件中明确声明的定义
为了分析、维护、尤其是测试,确保某人只看到一个组件的物理接口节能了解那个组件的全部逻辑接口是很重要的。要求一个组件在它的头文件中声明它的完整逻辑接口有助于提高:
- 可用性:使客户程序只从接口就可以全面了解一个组件所支持的整个抽象
- 可重用性:确保组件提供的所有支持功能对所有客户都是同样的可访问的
- 可维护性:避免不支持的后门接口
避免后门用法对于好的物理设计和有效的重用来说是关键的。只要求组件作者这样做是不够的。要堵上所有的漏洞,我们必须指定一种对大家都有好处的要求:客户程序不能视图通过局部声明来使用任何带有外部连接的结构。相反,客户程序要包含一个组件的头文件以便访问组件所提供的任何定义
避免通过一个局部声明来访问另一个组件中带有外部连接的定义,而是要包含那个组件的头文件
遵循上述规则的理由主要是要让对其他组件中的外部定义的依赖显性化
用包含头文件来取代提供一个局部函数声明,对客户也有好处。有时候文件头会改变。你的局部声明讲如何改变来反应这些头文件的变化?–一个声明错误的有C++连接的函数至少在连接时就可以被发现,但是来自标准C库的不正确的函数局部声明(带有C连接),可能直到运行时候才能发现
依赖关系
一个系统组件之间的物理依赖,将影响系统的开发、维护、测试和独立重用。类和自由函数之间的逻辑关系隐含了他们所驻留组件之间的物理依赖。如果编译和链接一个函数体时需要一个组件,那么通过说那个函数依赖于那个组件,我们可以宽松地位函数定义实现依赖。我们也可以用相似的方法为类定义实现依赖。更普遍的,我们可以精确地定义组件之间的重要而纯粹的物理关系。
如果编译或连接组件y时需要组件x,则组件y依赖组件x
如果编译y.c时候需要x.h,那么组件y展示了对组件x的编译时依赖
一个组件可能在编译时候不需要依赖另一个组件,但是在连接的时候依赖它。
如果对象文件y.o包含未定义的符号,因此可能在连接时候直接或者间接调用x.o来辅助解析这些符号,那么就说组件y展示了对组件x的一种连接时依赖。一个编译时依赖几乎总隐含一个连接时依赖,组件的依赖关系具有传递性
例如,x y z组件,组件如果x依赖y,y依赖z那么x依赖z
依赖关系对物理设计来说是很重要的,因为它指明了在维护、测试和重用时有一个给定的组件所提供的功能所需要的所有组件
隐含依赖
逻辑设计隐含一定的物理特性。在我们的逻辑设计实现之前,我们期望能够充分利用已知的逻辑关系来预测它的物理隐含。读者可能进场因为逻辑设计导致了不合需要的物理特性而被迫修改甚至完全重做逻辑设计。
定义了某个函数的组件,通常会物理依赖于定义了某个类型的任何组件
如果一个组件定义了某个类,且该类ISA或者HasA用户自定义类型,那么那个定义了类的组件爱你总是在编译时依赖那个定义了类型的组件
如果一个类拥有A的一个类型或者这个类型被实质地用在一个非内联函数结构体中,那么就不一定隐含这种强烈的物理耦合。这种用法是不正当的,因为它迫使用户程序在编译时依赖它的实现类型,正如把include指令嵌入组件爱你的头文件所导致的解结果一样。
重要概述:逻辑关系隐含物理依赖。像isA和HasA这样的逻辑实体之间的关系,在跨越组件边界实现时总是隐含编译时依赖。像holdsA和Users这样的关系可能隐含跨越组件的连接时依赖。通过在设计阶段考虑隐含依赖,我们可以远在编写任何代码之前就评价处我们的体系结构的物理质量。
提取实际依赖
现在假设我们根据隐含依赖设计一个大型项目。在大部分的设计工作完成之后,开发正在进行,我们很想有一种能够提取我们组件之间的实际物理依赖工具,以便我们可能跟踪实际的组件依赖,并且将他们和我们最初的设计期望来进行比较
虽然通过语法分析整个C++程序来确定准确的组件依赖图是可能的,但这样做既困难又相对较慢。加入我们已经遵守了物理设计规则,就可能通过只分析C++预处理器include指令直接从组件的源文件中提取依赖关系图。这个过程相对较快,因为有许多标准的、公共领域的依赖分析工具可以做这个。
假如系统编译成功的话,仅凭由C++预处理器#include指令产生的包含图,就足以推断出系统内部的所有物理依赖
想要了解为什么这个论断是正确的,考虑下面的推理。如果组件x直接实质地使用了组件y,那么为了编译x,编译器就必须查看y.h提供的定义。唯一能做到这一点的方法就是i组就爱你x直接或者间接的包含y.h。
只有当组件x直接实质使用了定义在y中的一个类或者自由运算符函数的时候,x才应该包含y.h
友元关系
友元关系和物理设计之间的交互程度强烈的令人惊讶。虽然表面上是一个逻辑设计问题,友元关系却影响我们把逻辑结构收集进组件的方式。避免友元关系穿越组件边界的愿望,甚至可能导致我们要重建我们的逻辑设计。
避免把友元关系授权给定义在另一个组件中的一个逻辑实体
一个组件内部的友元关系是该组件的实现细节
若通过一个组件爱你的逻辑接口不能通过程序访问或探测到该组件包含的一个实现细节,则称这些实现细节被该组件封装了
为定义在同一个组件内的类授予友元关系不会破坏封装
授予局部的友元关系不会威胁到暴露一个对象的私有细节 给未经授权的用户。因为被声明为友元的类被定义在同一组件的头文件内,任何视图使用该对象的授权友元关系的人都将拥有信任他们的所有友元类的有效的定义。重定义这些友元类的任何企图都将被编译器制止,它会迅速发出错误警告
在组件内定义一个容器类的同时定义一个迭代器类,可以保持封装的同时,使组件爱你具有用户可扩展性,改进可维护性和加强重用性
对一个定义在系统的单独物理部分的逻辑实体,授权友元关系会破坏该友元关系的那个类的封装
除了是封装的破坏者,远距离友元关系也是糟糕结构设计的一个症状。让物理上独立的逻辑实体相互紧密依赖,极易降低可维护性。特别是远距离友元关系允许对私有实现细节进行局部修改,影响系统中物理上比较远的额部分,因而会降低模块化。甚至局部友元也会影响可维护性。授权友元关系会扩大一个类本身的接口。有权访问对象实现的封装细节的函数越多,当修改实现时需要回访的代码也就越多。直接访问私有信息的代码行越少,就越容易实验可选择的实现。
远距离友元关系与隐含依赖
尽管我们不鼓励使用远距离友元关系,但是在一个组件外授予友元关系的可能性却导致我们必须明确,在决定涉及某个类的Uses关系时,是否应该考虑与该类的一个友元声明相匹配的函数。对这个问题的回答是重要的,但仅限于在友元关系超过了单个组件并且要基于Users关系来推导出物理依赖的情况下。
友元关系影响访问特权但不影响隐含依赖
一个类是不可分割的逻辑单位,一个自由函数是一个不同的逻辑单位。自由函数是否是一个类的友元决不会影响系统中的任何隐含物理依赖。
虽然友元关系在本质上绝不会直接影响隐含依赖,但是友元关系可能间接影响物理耦合。在视图避免这些与远距离友元关系有关的问题时,我们可能发现自己正在把若干紧密依赖的逻辑实体聚合进一个单个的组件内,从而在物理上使他们建立了耦合关系。
友元关系与骗局
对于大型系统来说,保护某一实现不被未经授权地使用是很重要的。
一个不谨慎的开发者可以简单地通过局部地定义友元类来获得访问私有细节的权利。于是这个开发者可以经由内联函数来使用这些细节,内联函数没有外部连接,因而不会与合法的函数定义发生冲突。同样的原因,把一个单独的非内联自由函数声明为一个友元–即使是局部的–也不能避免经由内联置换的骗局,人们实际上在产品代码中已经这么做了。你已经被警告了!
小结
开发可维护、易测试和可重用的软件需要全面的物理设计和逻辑设计的知识。物理设计研究组织的问题,超过了逻辑领域的范畴,物理设计很容易影响可测量的特性,例如运行时间、编译时间、链接时间以及可执行文件大小
一个组件是由一个源文件和一个头文件组成的物理实体,它具有表达了一个逻辑抽象的具体实现,一个组件一般包含一个、两个甚至多个类,以及需要用来支持全部抽象的适当的自由运算符。一个组件是逻辑设计和物理设计的适当单位,因为它能够:
- 让若干逻辑实体把一个单一的抽象表现为一个内聚单位
- 考虑到物理问题和组织问题
- 在其他程序中选择性地重用编译单元
一个组件的逻辑接口仅限于指能够被客户程序编译通过编程访问的部分,而物理接口则包括它的整个头文件。如果在一个组件的物理接口中使用了一个用户自定义类型T,即使T是一个封装的逻辑细节,也可能迫使那个组件爱你的客户程序在编译时依赖T的定义。
组件是自我包含的、内聚的和潜在可重用的设计单位。在一个组件内部声明的逻辑结构不应该定义在那个组件之外。一个组件的源文件应该直接包含它的头文件以确保头文件可以用于自己进行语法分析。对于每一个需要的类型定义,都始终包含其头文件,而不是依赖一个头文件去包含另一个,这样,当有一个组件允许一个include指令从起头文件中被删除时不会出现问题。想要改进可用性、可重用性和可维护性,如果某个带有外部连接的结构没有在一个组件的头文件中声明,那么我们应该避免把该结构放在这个组件的源文件中,同样的我们应该避免使用局部声明却访问有外部连接的定义。
依赖关系标识组件之间的物理依赖。一个编译时依赖几乎总是隐含一个连接时依赖,而组件间的依赖关系具有传递性
我们可以从一个跨越组件边界的逻辑IsA或者HasA关系推断出一个确定的编译时依赖。在这样的情形下,逻辑HoldsA和Uses关系隐含了一个可能的连接时依赖。通过利用抽象逻辑关系来推断我们的设计决策和物理衍生物,我们可以在编写任何代码之前预知和修正物理设计缺陷
最后,友元关系虽然表面上是一个逻辑问题,却会影响物理设计。在一个组件内部,友元关系是那个组件的一个封装的实现细节。为了改进可用性和用户扩展性,一个容器类常常会把同一个组件内的一个迭代看做友元,不会破坏封装
因为跨越了组件边界,友元关系变成了一个组件的接口的一部分,并且会导致该组件爱你的封装被破坏。远距离友元关系还会通过允许对一个系统的物理上较远的部分进行密切访问二进一步影响可维护性