04关键的设计概念

软件的首要技术使命:管理复杂度

1. 本质的难题

​ 因为从本质上说软件开发就是不断地去发掘错综复杂、相互连接的整套概念的所有细节。其本质性的困难来自很多方面。

  • 必须去面对复杂、无序的现实世界

  • 精确而完整的识别出各种依赖关系与例外情况

  • 设计出完全正确而不是大概正确的解决方案

    ...

2.管理复杂度的重要性

​ Dijkstra 指出,没有谁的大脑能容得下一个现代的计算机程序,也就是说,作为软件开发人员,我们不应该试着在同一时间把整个程序都塞进自己的大脑,而应该试着以某种方式去组织程序,以便能够在一个时刻可以专注于一个特定的部分。这么做的目的是尽量减少在任一时间所要思考的程序量。

​ 你可以把它想象成是一种心理上的杂耍(边抛边接:通过轮流抛接使两个或两个以上的物体同时保持于空中)——程序要求你在空中保持的(精神上的)球越多,你就越可能漏掉其中的某一个,从而导致设计或编码的错误。

​ 在软件架构的层次上,可以通过把整个系统分解为多个子系统来降低问题的复杂度。人类更易于理解许多项简单的信息,而不是一项复杂的信息。所有软件设计技术的目标都是把复杂问题分解成简单的部分。子系统间的相互依赖越少,你就越容易在同一时间里专注问题的一小部分。精心设计的对象关系使关注点相互分离,从而使你能在每个时刻只专注于一件事情。在更高汇聚的层次上,(与子程序类型)包提供了相同的好处。

​ 受人类固有限制影响的程序员的底线,就是要写出即让自己容易理解,也让能让别人容易看懂,而且很少有错误的程序代码。

3. 理想的设计特征
  • 最小复杂度。正如刚刚说过的,设计的首要目标就是要让复杂度最小。要避免做出“聪明的”设计,因为“聪明的”设计常常都是难以理解的。应该做出简单且易于理解的设计。如果你的设计方案不能让你在专注于程序的一部分时安心地忽视其他部分的话,这一设计就没有什么作用了。

  • 易于维护 。易于维护意味着在设计时为做维护工作的程序员着想。请时刻想着这些维护程序员可能会就你写的代码而提出问题。把这些程序员当成你的听众,进而设计出能自明的系统来。

  • 松散耦合。松散耦合意味着在设计时让程序的各个组成部分之间关联最小。通过应用类接口中的合理抽象、封装性及信息隐藏等原则,设计出相互关联尽可能最少的类。减少关联也就减少了集成、测试与维护时的工作量。

  • 可扩展性。可扩展性是说你能增强系统的功能而无需破坏其底层结构。你可以改动系统的某一部分而不会影响到其他部分。越是可能发生的改动,越不会给系统造成什么破坏。

  • 可重用性。可重用性意味着所设计的系统的组成部分能在其他系统中重复使用。

  • 高扇入。高扇入就是说让大量的类使用某个给定的类。这意味着设计出的系统很好地利用了在较低层次上的工具类。

  • 低扇出。低扇出就是说让一个类少量或始终地使用其他的类。高扇出(超过约7个)说明一个类实用了大量其他的类,因此可能变得过于复杂。

  • 可移植性。可移植性是说应该这样设计系统,使他能很方便地移植到其他环境中。

  • 精简性,精简性意味着设计出的系统没有多余的部分。伏尔泰曾说,一本书的完成,不在它能不能再加入任何内容的时候,而在不能删去任何内容的时候。在软件领域找那个,这一观点就更正确,因为任何多余的代码也需要开发、复审和测试,而且当修改了其他代码之后还要重新考虑它们。软件的后续版本也要和这些多余代码保持向后兼容。要问你这个关键的问题:“这虽然简单,但把他们加进来之后会损害什么呢?”

  • 层次性。层次性意味着尽量保持系统各个分解层的层次性,使你能在任意的层面上观察系统,并得到某种具有一致性的看法。设计出来的系统应该能在任何层次上观察而不需要进入其他层次。

  • 标准技术。一个系统 所依赖的外来的、古怪的东西越多,别人在第一次想要理解它的时候就越头疼。要尽量用标准化的、常用的方法,让整个系统给人一种熟悉的感觉。

4. 设计的层次

​ 一个程序中的设计层次。系统首先被组织为子系统。子系统被进一步分解为类,然后类又被分解为子程序和数据。每个子程序的内部也需要进行设计。

第一层:软件系统
第二层:分解为子系统或包

​ 常用的子系统——有些种类的子系统会在不同的系统中反复出现,下面几种就较为常见。

  • 业务规则。业务规则是指那些在计算机系统中欧编入的法律、规则、政策以及过程。如果你在开发一套薪资系统,你可能要把美国国税局关于允许扣提的金额和估算的税率编到你的系统中。

  • 用户界面。应创建一个子系统,把用户界面组件同其他部分隔开,以便使用户界面的演化不会破坏程序的其余部分。在大多数情况下,用户界面子系统会使用多个附属的子系统或类来处理用户界面、命令行接口、菜单操作、窗体管理、帮助系统等等。

  • 数据库访问。你可以把对数据库进行访问的实现细节隐藏起来,让程序的绝大部分可以不必关心处理底层结构的繁琐细节,并能像在业务层一样处理数据。隐藏实现细节的子系统可以为系统提供有价值的抽象层,从而减少程序的复杂度。它把和数据库相关的操作集中起来,减少了在对数据进行操作时发生错误的几率。同时,它还能让数据库的设计结构更易于变化,做这种修改时无需膝盖程序的主要部分。

  • 对系统的依赖性。把对操作系统的依赖因素归到一个子系统里,就如同把对硬件的依赖因素封装起来一样。

第三层:分解为类
第四层:分解成子程序
第五层:子程序内部的设计
5. 启发式方法

(1)找出现实世界中的对象

(2)形成一致的抽象

​ 抽象是一种能让你在关注某一概念的同时可以放心地忽略其中一些细节的能力——在不同的层次出路不同的细节。任何时候当你在对一个聚合物品工作时,你就是在用抽象了。当你把一个东西称为“房子”而不是由玻璃、木材和钉子等构成的组合体时,你就是在用抽象了。当你把一组房屋称为“城镇”时,你还是在使用抽象。

​ 基类也是一种抽象,它使你能集中精力关注一组派生类所具有的共同特性,并在基类的层次上忽略各个具有派生类的细节。一个好的接口也是一种抽象,它能让你关注于接口本身而不是类的内部工作方式。一个设计良好的子程序接口也在较低层次上提供了同样的好处,而设计良好的包和子系统的接口则在更高的层次上提供了同样的好处。

​ 以复杂度的观点看,重选ing的主要好处就在于它使你能忽略无关的细节。大多数现实世界中的物体都已经是某种抽象了。正如上面 提到的,房屋是门、窗、墙、线路、管道、隔板等物体及其特定的组织方式所形成的抽象。同样,门是一块长方形材料加上合页和把手以及一些特定的组织方式的抽象。而门把手又是铜、镍、铁、钢等的一种特定形式的抽象。

​ 人们一直都在使用抽象。如果每天你开门的时候都要单独考虑那些木纤维、油漆分子以及铁原子的话,你就别想再出入房间了。抽象是我们用来得以处理现实世界中复杂度的一种重要手段。

​ 软件开发人员有时就是在木材纤维、油漆分子以及铁原子这一层来构建系统,系统因此变得异常复杂,难以通过任的智力去管理。当程序员没有给出足够高层的编程抽象时,系统有时就会被卡在门口了。

​ 优秀的程序员会在子程序接口的层次上、在类接口的层次上以及包接口的层次上——换句话说,在门把手的层次上、门的层次上以及房屋的层次上——进行抽象,这样才能更快、更稳妥地进行开发。

(3)封装实现细节

​ 封装填补了抽象留下的空白。抽象是说:“可以让你从高层的细节来看待一个对象。”而封装则说:“除此之外,你不能看到对象的任何其他细节层次。”

​ 继续刚才关于房屋材质的比喻:封装是说,你可以从房屋的外面看,但是病呢靠的太近去把门的细节都看清楚。可以让你知道哪里有门,让你知道门是开着的还是关着的,但不能让你知道门是木制的、纤维玻璃的、钢质的还是其他什么材质的,当然就更不能让你看到每一个木纤维。

(4)信息隐藏

信息隐藏的一个例子

​ 在设计一个类的时候,一项关键性的决策就是确定类的哪些特性应该对外可见,而哪些特性应该隐藏起来。一个类可能有25个子程序,但只暴露了其中的5个,其余20个则仅限于在类的内部使用。一个类在内部可能用到多种数据类型,缺不对外暴露有关它们的任何信息。在类的设计中,这一方面也称为“可见性”,因为他要确定的就是类的哪些特性对外界是“可见的”或能“暴露”给外界。

​ 类的接口应该尽可能少地暴露其内部工作机制。类很像是冰山:八分之七都位于水面以下,而你只能看到水面上的八分之一。

​ 假设你有一个程序,其中每个对象都是通过一个名为id个成员变量来保存一种唯一的ID。一种设计方法是用一个整数来表示ID,同时用一个名为 g_maxId的全局变量来保存目前已分配的ID的最大值。每当创建新的对象时,你只要在该对象的构造函数中简单地使用 id = ++g_maxId这条语句,就肯定能获得一个唯一的ID值,这种做法会让对象在创建时只想的代码量最少。可这样设计可能有问题吗?

​ 好多地方都可能会出错。如果你想把某些范围的ID留作它用该怎么办?如果你想要使用非连续的ID来提高安全性又该怎么办?如果你想重新使用已销毁对象的Id呢?如果你想增加一个断言来确保所分配的ID值不会超过预期的最大范围呢?如果程序中到处都是 id = ++g_maxId 这种语句的话,一旦上面说的任何一种情况出现,你就需要修改所有这些语句。另外,如果你的程序时多线程的话,这种方法也不是线程安全的。

​ 创建新ID的方法就是一种你应该隐藏起来的设计决策。如果你在程序中带出使用++g_maxId的话,你就暴露了常见新ID的方法。相反,如果你在程序中都使用语句 id = NewId(),那就把创建新ID的方法隐藏起来了。你可以在 NewId() 子程序中人阿然只用一行代码,return (++g_maxId),。但如果日后你想把某些范围的ID留作它用,或者重用旧的ID时,值要对NewId() 子程序内部加以改动即可,无需改动几十个甚至成百个 id = NewId() 语句。无论NewId() 内部做了多么复杂的改动,这些改动都不会影响到程序的其他部分。

​ 现在假设你发现需要将ID的类型用整数改为字符串。如果你已经在程序内部大量使用了 int id这样的变量声明的话,那么即使改用NewId() 也无济于事。你还得深入到程序内部,进行几十次甚至几百次的修改。

​ 因此,另一个需要隐藏的秘密就是ID的类型。在C++里,你可以简单地使用typedef来吧ID定义成IdType——一个可以解释为int的用户自定义类型。

两种秘密

  • 隐藏复杂度,这样你就不用再去应付它,除非你要特别关注的时候。

  • 隐藏变化源,这样当变化发生时,其影响就能被限制在局部范围内。复杂度的根源包括复杂的数据类型、文件结构、布尔判断以及晦涩的算法等。

信息隐藏的障碍

​ 在少数情况下,信息隐藏式根本不可能的。不过大多数让信息无法隐藏的障碍都是由于管用某些结束而导致的心理障碍。

  • 信息过度分散
  • 循环依赖
  • 把类内数据误认为全局数据
  • 可以觉察的性能消耗。你可能会认为,由于有了额外层次的对象实例化和子程序调用等,间接访问对象会带来性能上的损耗。事实上,这种担心卫视尚早,因为你能够衡量系统的性能,并且找出妨碍性能的瓶颈所在之前,在编码层能为性能所做的最好准备,便是做出高度模块化的设计来。等你日后找出了性能瓶颈,你就可以针对个别的类后者子程序进行优化而不会影响系统的剩余部分了。
信息隐藏的价值

​ 问题“这个类需要隐藏些什么?”正切中了接口设计的核心。如果你能在给类的公开接口中增加函数或者数据而不牺牲该类的隐秘性,那么就做下去。

找出容易改变的区域

​ 好的程序设计所面临的最重要挑战之一就是适应变化。目标应该是把不稳定的区域隔离出来,从而把变化所带来的影响限制在一个子程序、类或者包的内部。下面给出你应该采取的应对各种变动的措施:

  • 找出看起来容易变化的项目

  • 把容易变化的项目分离出来

  • 把看起来容易变化的项目隔离开来

    下面是一些容易发生变化的区域:

  • 业务规则。业务规则很容易成为软件频繁变化的根源。国会改变了税率结构,工会重新谈判合同的内容...如果你遵循信息隐藏的原则,那么基于这些业务规则的逻辑就不应该遍布于整个程序,而是仅仅隐藏于系统的某个角落,直到需要对它进行改动,才会把它拎出来。

  • 对硬件的依赖

  • 输入和输出

  • 非标准的语言特性

  • 困难的设计区域和构建区域

  • 状态变量。不要使用布尔变量作为状态变量,请换用枚举类型。或者使用访问器子程序取代对状态变量的直接检查。

  • 数据量的限制

预料不同程度的变化

(5)保持松散耦合

耦合标准
  • 规模。这里的规模指的是模块之间的连接数。对于耦合度来说,小就是美,因为只要做很少的事情,就可以把其他模块与一个有着很小的接口的模块连接起来。只有一个参数的子程序与调用它的子程序之间的耦合关系比有6个参数的子程序与他的调用方法之间的耦合关系更为松散。包含4个定义明确的公用方法的类与它的调用方的耦合关系,比包含37个公用方法的类与它的调用方法的耦合关系更为松散。

  • 可见性。可见性指的是两个模块之间的连接的显著程度。开发程序与在中央情报局里工作不一样,你不能靠鬼鬼祟祟来获得信任。而是应该像登广告一样,通过把模块之间的连接关系变得广为人知而获得信任。通过参数表传递数据便是一种明显的连接,因而值得提倡。通过修改全局数据而使另一个模块能够使用该数据则是一种“鬼鬼祟祟”的做法,因此是很不好的设计。

  • 灵活性。

    简而言之,一个模块越容易不饿其他模块所调用,那么它们之间的耦合关系就会越松散。

耦合的种类
  • 简单数据参数耦合。使用简单数据类型作为参数在两个模块之间进行参数传递,这种耦合关系是正常的。

  • 简单对象耦合

  • 对象参数耦合

  • 语义上的耦合

(6)查阅常用的设计模式

  • 设计模式通过提供线程的抽象来减少复杂度
  • 设计模式通过把常见解决方案的细节予以制度化来减少出错
  • 设计模式通过提供多种设计方案二带来启发性的价值
  • 设计模式通过把设计对话提升到一个更高的层次上来简化交流
  • 应用设计模式的一个潜在的陷阱就是强迫让代码适用于某个模式。有时候,对代码进行一些微小的更改以便符合某个广为人知的模式,回事这段代码更容易理解。但是,如果一段代码做出巨大改动,迫使它去符合某个标准模式,有时反而会把问题复杂化。
其他的启发式方法
  • 高内聚性
  • 构造分层结构
  • 严格描述类契约
  • 分配职责
  • 为测试而设计
  • 避免失误
  • 有意识的选择绑定时间
  • 创建中央控制点
  • 考虑使用蛮力突破
  • 画一个图
  • 保持设计的模块化
posted @ 2018-10-23 21:01  洛克十年  阅读(298)  评论(0编辑  收藏  举报