A Philosophy of Software Design 学习笔记

前言

高耦合,低内聚,降低复杂度:在软件迭代中,不关注软件系统结构,导致软件复杂度累加,软件缺乏系统设计,模块混乱,一旦需求增加、修改或者优化,改变的代价无法评估,甚至为了解决一个 bug,引入一个新的 bug。软件设计的核心在于降低复杂性。

一、介绍(关于复杂性)

所有编程都需要具有创造力的头脑和组织思想的能力。

编写软件的最大限制是我们了解所创建系统的能力。随着程序的发展和获得更多功能,它变得复杂,其组件之间具有微妙的依赖性。随着时间的流逝,复杂性不断累积,程序员在修改系统时将所有相关因素牢记在心中变得越来越难。这会减慢开发速度并导致错误,从而进一步延缓开发速度并增加成本。在任何程序的生命周期中,复杂性都会不可避免地增加。程序越大,工作的人越多,管理复杂性就越困难。

仅凭出色的工具,我们只能做些事情。如果我们想简化编写软件的过程,从而可以更便宜地构建功能更强大的系统,则必须找到简化软件的方法。尽管我们尽了最大努力,但复杂度仍会随着时间的推移而增加,但是更简单的设计使我们能够在复杂性压倒性优势之前构建更大,功能更强大的系统。

有两种解决复杂性的通用方法:1)通过使代码更简单、更明显来消除复杂性。例如,可以通过消除特殊情况或以一致的方式使用标识符来降低复杂性。;2)封装,以便程序员在系统上工作而不会立即暴露所有复杂性 ——模块化设计。

由于软件具有很好的延展性,因此软件设计是一个贯穿软件系统整个生命周期的连续过程。开发人员尝试在不改变整体设计的情况下解决问题,将导致复杂性爆炸式增长。因此增量方法适用于软件,可以在实施过程中进行重大的设计更改,但这也意味着永远不会完成软件设计。作为开发人员,应该始终在寻找机会来改进正在开发的系统的设计,并且应该计划将部分时间花费在设计改进上,软件开发人员应始终考虑复杂性。

阅读其他人的代码时,请考虑它是否符合此处讨论的概念,以及它与代码的复杂性之间的关系。在别人的代码中比在您的代码中更容易看到设计问题。阅读代码还将使您接触到新的设计方法和编程技术。

改善设计技能的最好方法之一就是学会识别危险信号:危险信号表明一段代码可能比需要的更加复杂

二、复杂性的本质

如果设计看起来很复杂,请尝试其他方法,看看是否更简单。某些技术往往会导致设计更简单。

1、复杂性的定义

复杂性与软件系统的结构有关,这使它很难理解和修改系统。在复杂的系统中,要实施甚至很小的改进都需要大量的工作。在一个简单的系统中,可以用更少的精力实现更大的改进。

复杂性是开发人员在尝试实现特定目标时在特定时间点所经历的。它不一定与系统的整体大小或功能有关。具有复杂功能的大型系统若易于使用,那就不复杂。小型而不复杂的系统也可能非常复杂。

复杂性取决于最常见的活动。如果系统中有一些非常复杂的部分,但是几乎不需要触摸这些部分,那么他们对于系统的整体复杂性不会有太大的影响。
C = ∑ p c p t p C=\sum_p c_p t_p C=pcptp
系统的总体复杂度(C)由每个部分的复杂度( c p c_p cp)乘以开发人员在该部分上花费的时间( t p t_p tp)加权。在一个永远不会被看到的地方隔离复杂性几乎和完全消除复杂性一样好

读者比作家更容易理解复杂性。如果编写了一段代码,对自己来说似乎很简单,但是其他人则认为它很复杂,那么它就是复杂的。

2、复杂性的表现
  • 变更放大:复杂性的第一个表现是,看似简单的变更需要在许多不同地方进行代码修改
  • 认知负荷:复杂性的第二个标表现,指开发人员需要多少知识才能完成一项任务。较高的认知负担意味着开发人员必须花更多的时间来学习所需的信息,并且由于错过了重要的东西而导致错误的风险也更大。例如一个功能分配了内存,需要开发者自己释放内存,这边增加了开发人员的认知负担。因为如果开发人员忘记释放内存,会造成内存泄漏或者其他不可预见的问题。而在同一个功能模块中增加内存释放,这将减少认知负荷。在考虑认知负荷的条件下,就不再是代码越短就与简单,在能够减少认知负荷的时候,使用更多的代码的实现会更加简单。
  • 未知的未知:复杂性的第三个症状是,必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。

在复杂性的三种表现中,未知的未知是最糟糕的。对于未知的未知,不清楚该做什么,或者提出的解决方案是否有效。唯一确定的方法是读取系统中的每一行代码,这对于任何大小的系统都是不可能的。甚至这可能还不够,因为更改可能依赖于一个从未记录的细微设计决策。

3、复杂性的原因

依赖性和模糊性

依赖性:当无法孤立地理解或者修改一段给定的代码的时候,便存在依赖关系。该代码以某种方式与其他代码相关,如果更改了给定代码,则必须考虑和/或修改其他代码。依赖关系是软件的基本组成部分,不能完全消除。

模糊性:复杂性的第二个原因是晦涩。当重要的信息不明显时,就会发生模糊。模糊性通常与依赖性相关联。不一致性也会是造成模糊性的一个主要原因:如果同一个变量名用于两个不同的目的,那么开发人员就无法清楚地知道某个特定变量的目的是什么。在许多情况下,由于文档不足而导致模糊不清。对大量文档的需求通常是一个警告,即设计不正确。减少模糊性的最佳方法是简化系统设计。

依赖性导致变更放大和高的认知负荷。模糊性会产生未知的未知,还会增加认知负荷。

4、复杂性是递增的

复杂性不是由单个灾难性错误造成的,而是许多小的复杂性堆积而成。

复杂性的增量性质使其难以控制。

复杂性来自于依赖性和模糊性的积累。随着复杂性的增加,它会导致变化放大,高认知负荷和未知的未知。从而导致每个新的修改,都需要更多的代码才能够实现。

复杂性使得修改现有代码库变得困难且冒险。

三、能跑的代码是不够的

战术思维:能跑就行,尽快跑起来

战略思维:花费时间来设计干净的设计并解决问题。

1、战术编程

战术编程的主要重点是使某些功能正常工作,例如新功能或者 bug 修复。战术编程几乎不可能产生良好的系统设计。

战术编程为了使当前任务尽快完成,会引入一些复杂性或者引入一两个小错误。这就是使系统变得复杂的原因,每个编程任务都会带来一些此类的复杂性。复杂性的累积是迅速的。

2、战略规划

仅仅是 working code 是不够的,为了更快地完成当前任务而引入不必要的复杂性是不被接受的。任何系统中的大部分代码都是通过扩展现有代码库编写来的,因此,最重要的是促进这些将来的扩展。因此,working code 是前提,但是 working code 不是主要目标,主要目标是制作出出色的设计,并且这种出色的设计也是能够解决当前问题的。战略规划在短期内让您放慢脚步,但从长远来看会加快您的速度。

00011

四、模块应该是深的

1、模块化设计

软件系统被分解为相对独立的模块集合。模块可以是多种形式的,类、子系统、服务等。理性系统中,每个模块都将完全独立于其他模块:开发人员可以在任何模块中工作,而无需了解任何其他模块。在这样的系统中,系统的复杂性就是最糟糕的模块的复杂性。实际上这样理想的系统是不存在的,模块必须通过调用彼此的函数或方法来协同工作。模块之间将存在依赖关系:如果一个模块发生更改,则可能需要更改其他模块以进行匹配。模块化设计的目标是最大程度地减少模块之间的依赖性。

模块通常分为两部分:接口和实现。接口包含使用其他模块的开发人员必须知道的所有内容,接口描述模块做什么,但是不描述模块如何做。实现由执行接口所承诺的功能的代码组成。模块内部可以包含复杂的代码,而这些复杂性对模块的使用者不可见。虑一个实现平衡树的模块。该模块可能包含复杂的代码,以确保树保持平衡。但是,此复杂性对于模块用户而言是不可见的。用户可以看到一个相对简单的接口,用于调用在树中插入,删除和获取节点的操作。要调用插入操作,调用者只需提供新节点的键和值即可。遍历树和拆分节点的机制在接口中不可见。

模块是具有接口和实现的任何代码单元。面向对象编程语言中的每个类都是一个模块。类中的方法或非面向对象语言中的函数也可以视为模块。每个模块都有一个接口和一个实现,并且可以将模块化设计技术应用于它们。更高级别的子系统和服务也是模块。它们的接口可能采用不同的形式,例如内核调用或 HTTP 请求。如果以不更改其接口的方式修改了一个模块,则该修改不会影响其他模块。

2、接口中有什么

接口包含两种信息:形式信息、非形式信息。接口的形式部分在代码中明确指定,例如,方法的形式接口是其签名,其中包括其参数的名称和类型,其返回值的类型以及有关该方法引发的异常的信息。。类的形式接口包括其所有公共方法的签名以及任何公共变量的名称和类型。接口的非形式部分没有在d代码中指定,接口的非形式部分包含其高级行为。接口的非形式信息只能使用注释来描述,而编程语言不能确保描述是完整或准确的。非形式信息通常比形式信息更多、更复杂。

明确指定接口的好处之一是,它可以准确指示开发人员使用关联模块所需要知道的内容。可以有助于减小“未知未知数”问题。

3、抽象

抽象是实体的简化视图,其中省略了不重要的细节,抽象使我们更容易思考和操纵复杂的事物。在模块化编程中,每个模块以其接口的形式提供抽象,接口提供了该模块功能的简化描述。从模块抽象的角度来看,实现的细节并不重要,因此在接口中将其省略。

在抽象过程中,忽略的不重要的细节越多越好。抽象过程中通常会出现两种错误:1)抽象中包含了并非真正重要的细节,它会使抽象变得不必要的复杂,从而增加了使用抽象的开发人员的认知负担;2)抽象中忽略了真正重要的细节,这导致模糊不清:仅查看抽象的开发人员将不会获得正确使用抽象所需的全部信息。在抽象过程中,保存真正重要的细节和忽略不那么重要的细节都是非常重要的。抽象过程中,抽象的使用对象也是需要纳入考虑的,针对不同的使用对象,需要忽略和保留的信息是不同的。

4、深的模块

好的模块提供强大功能但具有简单接口的模块,它们允许通过简单的界面访问许多功能,简单的接口背后掩盖了大量的复杂性。浅层模块是具有相对复杂的接口的模块,但功能不多:它不会掩盖太多的复杂性。

00012

模块的深度是考虑成本与收益的一种方式。模块的成本是接口的复杂度,收益是功能的大小。接口的复杂度将直接类加在调用其的系统上。

Unix 系统(及其后代系统)提供的文件 IO 机制就是一个非常好的深层接口的例子。文件 IO 只有五个基本的系统调用接口,并且带有简单的标签名:

int open(const char* path, int flags, mode_t permissions);
ssize_t read(int fd, void* buffer, size_t count);
ssize_t write(int fd, const void* buffer, size_t count);
off_t lseek(int fd, off_t offset, int referencePosition);
int close(int fd);

开放系统调用采用分层文件名,例如 /a/b/c ,并返回一个整数文件描述符,该描述符用于引用打开文件。读写系统调用在应用程序内存和文件的缓冲区之间传输信息。close 结束对文件的访问。大多数文件是按顺序访问的,因此这是默认设置。但是,可以通过调用 lseek 系统调用来更改当前访问位置来实现随机访问。

Unix I/O 接口的现代实现需要成千上万行代码,多年来,Unix I/O 接口的实现已经发生了根本的发展,但是五个基本内核调用并没有改变。

深度模块的另一个示例是诸如 Go 或 Java 之类的语言中的垃圾收集器(内存回收)。这个模块根本没有接口。它在后台进行隐形操作以回收未使用的内存。易使用,但隐藏了巨大的实现复杂性。

5、浅的模块

浅的模块是指其接口与其提供的功能相比相对复杂的模块。例如操作链表的类很浅,操作链表不需要太多代码(插入或删除元素仅需几行),因此链表抽象不会隐藏很多细节。浅类有时是不可避免的,但是它们在管理复杂性方面没有提供太多帮助。

一个来自软件设计的浅层方法示例:

private void addNullValueForAttribute(String attribute) {
    data.put(attribute, null);
}

从管理复杂性的角度来看,此方法会使情况变得更糟,而不是更好。该方法不提供任何抽象,因为其所有功能都可以通过其接口看到。从管理复杂性的角度来看,此方法会使情况变得更糟,而不是更好。该方法不提供任何抽象,因为其所有功能都可以通过其接口看到。考虑接口并不比考虑完整实现简单。如果正确记录了该方法,则文档将比该方法的代码长。与调用方直接操作数据变量相比,调用该方法所花费的击键甚至更多。该方法增加了复杂性(以供开发人员学习的新接口的形式),但没有提供任何好处。小模块往往很浅。

6、常规情况

深度类的价值在今天并未得到广泛认可。编程中的传统观点是,类应该小而不是深。类的设计中通常是将较大的类分解为较小的类,对于方法也是如此。这种方法导致了大量的浅类和方法,这增加了整体系统的复杂性。

小类不会贡献太多的功能,因此需要很多的小类,每个小类都有自己的接口,这些接口会在系统层面产生巨大的复杂性,同时也会导致冗长的编码风格。

7、示例:Java 和 Unix I/O

在 Java 中,要打开文件以便从文件中读取序列化的对象,必须创建三个不同的对象:

FileInputStream fileStream = new FileInputStream(fileName);

BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);

ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);

FileInputStream 对象仅提供基本的 I/O:它不能执行缓冲的 I/O,也不能读取或写入序列化的对象。BufferedInputStream 对象将缓冲添加到 FileInputStream,而 ObjectInputStream 添加了读取和写入序列化对象的功能。一旦文件被打开,上面代码中的前两个对象 fileStream 和 bufferedStream 将永远不会被使用。以后的所有操作都使用 objectStream。

如果一个接口具有许多功能,但是大多数开发人员只需要了解其中的一些功能,那么该接口的有效复杂性就是常用功能的复杂性。

通过将模块的接口与其实现分开,我们可以将实现的复杂性从系统的其余部分中隐藏起来。模块的用户只需要了解其接口提供的抽象。设计类和其他模块时,最重要的问题是使它们更深,以使它们具有适用于常见用例的简单接口,但仍提供重要的功能。这使隐藏的复杂性最大化。

五、信息隐藏和泄露

本章和随后的章节将讨论创建深层模块的技术

1、信息隐藏

信息隐藏的基本思想是每个模块应该封装一些实质性的内容,这些实质性的内容嵌入在模块的实现中,不会出现在接口中,因此对其他模块不可见。隐藏在模块中的信息通常包含有关如何实现某种机制的详细信息。隐藏的信息包括与该机制有关的数据结构和算法。它还可以包含较低级别的详细信息(例如页面大小),还可以包含更抽象的较高级别的概念。

信息隐藏在两个方面降低了复杂性。首先,它将接口简化为模块。接口反映了模块功能的更简单、更抽象的视图,并隐藏了细节;这减少了使用该模块的开发人员的认知负担。其次,信息隐藏使系统更容易演化。如果隐藏了一段信息,那么在包含该信息的模块之外就不存在对该信息的依赖,因此与该信息相关的设计更改将只影响一个模块。

设计新模块时,应仔细考虑可以在该模块中隐藏哪些信息。如果可以隐藏更多的信息,则应该能够简化模块的接口,这会使模块更深。

信息隐藏的最佳形式是将信息完全隐藏在模块中,从而使该信息对模块的用户无关且不可见。但是,部分信息隐藏也具有价值。

2、信息泄露

当一个设计决策反映在多个模块中时,就会发生信息泄漏。这在模块之间创建了依赖关系:对该设计决策的任何更改都将要求对所有涉及的模块进行更改。如果一个信息反映在接口中,根据定义,该信息已经泄露。另外,即使信息未出现在模块的接口中,也可能存在信息泄露。假设两个类都具有特定文件格式的知识(也许一个类读取该格式的文件,而另一个类写入它们)。即使两个类都不在其接口中公开该信息,它们都取决于文件格式:如果格式更改,则两个类都将需要修改。

信息泄漏是软件设计中最重要的危险信号之一。如果受影响的类相对较小,并且与泄漏的信息紧密相关,那么将它们合并到一个类中是有意义的。另一种可能的方法是从所有受影响的类中提取信息,并创建一个只封装这些信息的新类。但是,这种方法只有在您能够找到一个从细节中抽象出来的简单接口时才有效。如果新类通过其接口公开了大部分知识,那么它就不会提供太多的价值(您只是用通过接口的泄漏替换了后门泄漏)。

3、时间分解

时间分解设计风格,系统的结构对应于操作将发生的时间顺序。考虑一个应用程序,该应用程序以特定格式读取文件,修改文件内容,然后再次将文件写出。通过时间分解,该应用程序可以分为三类:一类用于读取文件,另一类用于执行修改,第三类用于写出新版本。文件读取和文件写入步骤都具有有关文件格式的知识,这会导致信息泄漏。解决方案是将用于读写文件的核心机制结合到一个类中。该类将在应用程序的读取和写入阶段使用。很容易陷入时间分解的陷阱,因为在编写代码时通常会想到必须执行操作的顺序。但是,大多数设计决策会在应用程序的整个生命周期中的多个不同时刻表现出来。结果,时间分解常常导致信息泄漏。

在设计模块时,应该专注于执行每个任务所需的信息,而不是任务发生的顺序。在时间分解中,执行顺序反映在代码结构中:在不同时间发生的操作在不同的方法或类中。如果在执行的不同点使用相同的知识,则会在多个位置对其进行编码,从而导致信息泄漏。

4、示例:HTTP服务器、冗余的类、HTTP 参数处理、HTTP 响应中的默认值

HTTP 是 Web 浏览器用来与 Web 服务器通信的机制。当用户单击 Web 浏览器中的链接或提交表单时,浏览器使用 HTTP 通过网络将请求发送到 Web 服务器。服务器处理完请求后,会将响应发送回浏览器。该响应通常包含要显示的新网页。HTTP 协议指定了请求和响应的格式,两者均以文本形式表示。下图显示了描述表单提交的 HTTP 请求示例。

00014

对于共享大量信息的多个类,可以将他们合并为一个类。通常可以通过使类稍大一些来改善信息隐藏。

服务器收到 HTTP 请求后,服务器需要访问该请求中的某些信息。

HTTP 项目还必须提供对生成 HTTP 响应的支持。

每个 HTTP 响应必须指定一个 HTTP 协议版本。一个团队要求呼叫者在创建响应对象时明确指定此版本。但是,响应版本必须与请求对象中的版本相对应,并且在发送响应时必须已将请求作为参数传递(它指示将响应发送到何处)。因此,HTTP 类自动提供响应版本更为有意义。调用者不太可能知道要指定哪个版本,并且如果调用者确实指定了一个值,则可能导致 HTTP 库和调用者之间的信息泄漏。HTTP 响应还包括一个 Date 标头,用于指定发送响应的时间;HTTP 库也应该为此提供一个合理的默认值。默认值说明了应该设计接口以使常见情况尽可能简单的原则。最好的功能是您甚至不知道它们存在的功能。

5、信息隐藏在类中

仅当在其模块外部不需要隐藏信息时,隐藏信息才有意义。如果模块外部需要该信息,则不得隐藏它。假设模块的性能受某些配置参数的影响,并且模块的不同用途将需要对参数进行不同的设置。在这种情况下,将参数暴露在模块的接口中很重要。目标应该是最大程度地减少模块外部所需的信息量。

六、更深的通用模块

设计新模块的时候,是以通用还是专用方式实现它。

1、使类变得通用

最有效的方法是以一定程度上通用的方式实现新模块。即实现上可以针对特定的问题,而接口上应该足够通用。通用的接口可以更加简单,更深。

2、更通用的 API

通用方法总体上比专用方法具有更少的代码,因为它用较少数量的通用方法代替了大量的专用方法。

3、通用性可以更好地隐藏信息

**满足我当前所有需求的最简单的界面是什么?**如果减少 API 中的方法数量而不降低其整体功能,则可能有利于创建更加通用的方法。

例如,专用文本 API 至少具有三种删除文本的方法:退格,删除和 deleteSelection。通用性更强的 API 只有一种删除文本的方法,可同时满足所有三个目的。仅在每种方法的 API 保持简单的前提下,减少方法的数量才有意义。如果您必须引入许多其他参数以减少方法数量,那么您可能并没有真正简化事情。

**在多少情况下会使用此方法?**如果一种方法是为特定用途而设计的,那是一个危险信号,它可能太特殊了。看看是否可以用一个通用方法替换几种专用方法。

这个 API 是否易于使用以满足我当前的需求? 这个问题可以帮助您确定何时使 API 变得简单而通用。如果您必须编写许多其他代码才能将类用于当前用途,那么这是一个危险信号,即该接口未提供正确的功能。

通用接口比专用接口具有许多优点。它们往往更简单,使用的方法更少。它们还提供了类之间的更清晰的分隔,而专用接口则倾向于在类之间泄漏信息。使模块具有某种通用性是降低整体系统复杂性的最佳方法之一。

七、不同的层使用不同的抽象

软件系统由层组成,其中较高的层使用较低层提供的功能。在设计良好的系统中,每一层都提供与其上,下两层不同的抽象。如果系统包含具有相似抽象的相邻层,则这是一个危险的标记,表明类分解存在问题。

1、直通方法(传递方法)

当相邻的层具有相似的抽象时,问题通常以直通方法的形式表现出来。直通方法是一种很少执行的方法,除了调用另一个方法(其签名与调用方法的签名相似或相同)之外。

直通方法是一种不执行任何操作的方法,只是将其参数传递给另一个方法。这通常表明各类之间没有明确的功能划分。传递方法使类变浅:它们增加了类的接口复杂性,从而增加了复杂性,但是并没有增加系统的整体功能。直通方法还会在类之间创建依赖关系。

解决方案是重构类,以使每个类都有各自不同且连贯的功能。几种解决的方法:1)如图(b),将较低级别的类直接暴露给较高级别的类的调用者,而从较高级别的类中删除对该功能的所有功能;2)如图(c)所示,在类之间重新分配功能;3)如果无法妥善重构这些类,最好的解决方案是如图(d)所示,合并它们。

00015

2、何时重载方法(分派器)

具有相同签名的方法并不总是不好的。重要的是,每种新方法都应贡献重要的功能。只要每种方法都提供有用且独特的功能,几种方法都应具有相同的签名。当几种方法提供同一接口的不同实现时,它将减少认知负担。像这样的方法通常位于同一层,并且它们不会相互调用。

3、装饰器

装饰器设计模式(也称为“包装器”)是一种鼓励跨层复制 API 的模式。装饰对象接受现有对象并扩展其功能;它提供一个与底层对象相似或相同的 API,调用底层对象。装饰器的动机是将类的专用扩展与更通用的核心分开。但是,装饰器类往往很浅:它们引入了大量的样板,以实现少量的新功能。装饰器类通常包含许多传递方法。过度使用装饰器模式很容易,为每个小的新功能创建一个新类。这导致诸如 Java I/O 示例之类的浅层类激增。

创建装饰器之前,可以优先考虑以下替代方法:

  • 能否将新功能直接添加到基础类,而不是创建装饰器类。如果新功能是相对通用的,或者在逻辑上与基础类相关,或者如果基础类的大多数使用也将使用新功能,则这是有意义的。
  • 如果新功能专用于特定用例,将其与用例合并而不是创建单独的类是否有意义
  • 可以将新功能与现有的装饰器合并,而不是创建新的装饰器。这将导致一个更深的装饰器类,而不是多个浅的装饰器类。
  • 新功能是否真的需要包装现有功能。是否可以将其实现为独立于基类的独立类
4、接口与实现

类的接口通常应与其实现不同:内部使用的表示形式应与接口中出现的抽象形式不同。如果两者具有相似的抽象,则该类可能不是很深。

5、直通变量(传递变量)

1)跨层 API 复制的另一种形式是传递变量,该变量是通过一长串方法向下传递的变量。如图(a)所示,显示了数据中心服务的示例。命令行参数描述用于安全通信的证书。只有底层方法 m3 才需要此信息,该方法调用一个库方法来打开套接字,但是该信息会通过 main 和 m3 之间路径上的所有方法向下传递。cert 变量出现在每个中间方法的签名中。传递变量增加了复杂性,因为它们强制所有中间方法知道它们的存在,即使这些方法对变量没有用处。此外,如果存在一个新变量(例如,最初构建的系统不支持证书,但是您后来决定添加该支持),则可能必须修改大量的接口和方法才能将变量传递给所有相关路径。

2)另一种方法是查看最顶层和最底层方法之间是否已共享对象。也许存在一个对象,其中包含有关网络通信的其他信息,这对于 main 和 m3 都是可用的。如果是这样,main 可以将证书信息存储在该对象中,因此不必通过通往 m3 的路径上的所有干预方法来传递证书。但是,如果存在这样的对象,则它本身可能是传递变量。

00016

3)另一种方法是将信息存储在全局变量中,如图(c)所示,这避免了将信息从一个方法传递到另一个方法的需要,但是全局变量几乎总是会产生其他问题。例如,全局变量使得不可能在同一过程中创建同一系统的两个独立实例,因为对全局变量的访问会发生冲突。

4)常用的方法是引入一个上下文对象,如图(d)所示,上下文存储应用程序的所有全局状态(否则将是传递变量或全局变量)。大多数应用程序在其全局状态下具有多个变量,这些变量表示诸如配置选项,共享子系统和性能计数器之类的内容。每个系统实例只有一个上下文对象。上下文允许系统的多个实例在单个进程中共存,每个实例都有自己的上下文。不幸的是,在许多地方可能都需要上下文,因此它有可能成为传递变量。为了减少必须意识到的方法数量,可以将上下文的引用保存在系统的大多数主要对象中。

上下文对象统一了所有系统全局信息的处理,并且不需要传递变量。如果需要添加新变量,则可以将其添加到上下文对象;除了上下文的构造函数和析构函数外,现有代码均不受影响。由于上下文全部存储在一个位置,因此上下文可以轻松识别和管理系统的全局状态。上下文远非理想的解决方案。存储在上下文中的变量具有全局变量的大多数缺点。避免问题的最佳方法是使上下文中的变量不可变。

八、降低复杂性

模块具有简单的接口比简单的实现更重要。

如果出现不确定如何处理的条件,最简单的方法是引发异常并让调用方处理它。如果不确定要实施什么策略,则可以定义一些配置参数来控制该策略,然后由系统管理员自行确定最佳策略。这样的方法短期内会使您的生活更轻松,但它们会加剧复杂性。例如,如果一个类抛出异常,则该类的每个调用者都必须处理该异常。如果一个类导出配置参数,则每个安装中的每个系统管理员都必须学习如何设置它们。

–> 引入异常和配置参数都会增加复杂度

理想情况下,每个模块都应完全解决问题。配置参数导致解决方案不完整,从而增加了系统复杂性。

降低复杂性时要谨慎处理;这个想法很容易被夸大。一种极端的方法是将整个应用程序的所有功能归为一个类,这显然没有意义。

如果(a)被降低的复杂度与该类的现有功能密切相关,(b)降低复杂度将导致应用程序中其他地方的许多简化,则降低复杂度最有意义。

简化接口的目标是最大程度地降低整体系统复杂性。

九、放在一起还是分开实现更好

在决定是合并还是分开时,目标是降最佳方法是将系统划分为大量的小组件:组件越小,每个单独的组件可能越简单。但是,细分的行为会带来额外的复杂性,而这在细分之前是不存在的:低整个系统的复杂性并改善其模块化。

  • 一些组件的复杂性仅来自组件的数量:组件越多,就越难以追踪所有组件,也就越难在大型集合中找到所需的组件。细分通常会导致更多接口,并且每个新接口都会增加复杂性。
  • 细分可能会导致附加代码来管理组件。
  • 细分产生分离:细分后的组件将比细分前的组件相距更远。
  • 细分可能导致重复:细分之前的单个实例中存在的代码可能需要存在于每个细分的组件中。

如果它们紧密相关,则将代码段组合在一起是最有益的。如果各部分无关,则最好分开:

  • 共享信息;例如,这两段代码都可能取决于特定类型文档的语法
  • 一起使用:任何使用其中一段代码的人都可能同时使用另一段代码
  • 在概念上重叠,因为存在一个简单的更高级别的类别,其中包括这两段代码。例如,搜索子字符串和大小写转换都属于字符串操作类别
  • 不看其中的一段代码就很难理解
1、如果信息共享,则合并
  • 当两个类共享信息时,合而为一时,代码变得更短,更简单。
2、合并在一起,是否可以简化接口

当两个或者更多的类组合成一个类的时候,如果能够定义更简单、更易于使用的接口,就应该合并在一起。通常在原始模块各自实现解决问题的一部分方案的时候,会出现这种情况。另外,当多个模块合并在一起的时候,某些大多数用户不需要了解的功能会自动执行。

3、消除重复

当代码中出现重复相同的代码模式段落的时候:

  • 一种解决方法是将重复的代码封装成单独的方法。通过对方法的调用来代替重复的代码片段。如果重复的代码片段比较长,并且能够封装得到简单的方法接口签名,这种方法是非常有效的;但是如果重复的代码片段只有几行,这种方法是不划算的。

  • 另一种方法是重构代码。通过重构代码,使相关的重复代码只需要在一个地方执行。这个时候可以尝试使用 goto语句,但是不建议随便使用goto语句。如下图,使用 goto语句重构前后的代码:

    00017

    00018

4、单独的挺通用代码和专用代码

如果木块提供可用于多种不同目的的机制,那么应该为它提供一种通用机制。通用机制的代码不应该包含针对专门用途机制的代码,也不应该包含其他通用机制的代码。与通用机制关联的专用代码通常应放在不同的模块中(通常是与特定用途关联的模块)。通常,系统的下层倾向于更通用,而上层则更专用。

将专用代码与通用代码分开的方法是将专用代码向上拉到较高的层,而将较低的层保留为通用。当遇到同时包含通用功能和专用功能的同一类的类时,请查看该类是否可以分为两个类,一个包含通用功能,另一个在其上分层以提供特殊功能。然而,将一种机制的专用代码与另一种机制的通用代码组合起来通常是有意义的。

5、拆分和合并方法

只有在拆分之后,能够使系统更加简单,才能够拆分方法。方法的长度不是一个拆分方法的好的理由。

方法(a)可以通过提取子任务(b)或将其功能划分为两个单独的方法(c)进行拆分。如果方法导致浅层方法,则不应拆分该方法,如(d)所示。

00019

设计方法时,最重要的目标是提供简洁的抽象。每种方法都应该做一件事并且完全做到这一点。总体而言,拆分方法只有在其导致更抽象的抽象时才有意义。有两种方法可以做到这一点:

  • 最佳方法是将子任务分解为单独的方法,如图(b)所示。该细分产生一个包含该子任务的子方法和一个包含原始方法其余部分的父方法;父级调用子级。
  • 第二种方法是将其拆分为两个单独的方法,每个方法对原始方法的调用者可见,如图(c)所示。如果原始方法的接口过于复杂,这是有道理的,因为该接口试图执行不密切相关的多项操作。在这种情况下,可以将方法的功能划分为两个或更多个较小的方法,每个方法仅具有原始方法功能的一部分。理想情况下,大多数调用者只需要调用两个新方法之一即可;如果调用者必须同时调用这两个新方法,则将增加复杂性,从而降低拆分是个好主意的可能性。如果新方法比原始方法更具通用性,那么这是一个好兆头。(c)所示形式的拆分并不是很有意义,因为它们导致调用者不得不处理多个方法而不是一个方法。(d)所示。如果调用者必须调用每个单独的方法,并在它们之间来回传递状态,则拆分不是一个好主意。

在某些情况下,通过将方法结合在一起可以简化系统。它可以消除重复的代码;它可以消除原始方法或中间数据结构之间的依赖关系;它可能导致更好的封装,从而使以前在多个位置存在的知识现在被隔离在一个位置。

如果两段代码在物理上是分开的,但是只有通过查看另一段代码才能理解它们,这就是危险信号。拆分或加入模块的决定应基于复杂性。选择一种结构,它可以隐藏最佳的信息,最少的依赖关系和最深的接口。

十、定义不存在的错误

异常处理是软件系统中最糟糕的复杂性来源之一。减少必须处理异常的地方的数量。在许多情况下,可以修改操作的语义,以便正常行为可以处理所有情况,并且没有要进行异常处理。

1、为什么异常处理会增加复杂性

代码遇到异常的方式:

  • 调用方法可能会提供错误的参数或者配置信息
  • 调用的方法可能无法完成请求的操作
  • 在分布式系统中,网络数据包可能会丢失或延迟,服务器可能无法及时响应,或者对等方可能会以意想不到的方式进行通信
  • 该代码可能会检测到错误,内部不一致或未准备处理的情况