LeeYzero的博客

自强不息,厚德载物

0%

软件设计哲学

cover


最近本来想写一篇如何应对复杂系统的文章,偶然读到John Ousterhout软件设计哲学,读完后,发现我没有必要再写了。这篇文章对原文做一个概要性总结并谈谈自己的理解和思考。

对于一个软件系统,需求可以粗略的分为两类:

  • 功能性需求
  • 非功能性需求

功能性需求是面向用户的,非功能性需求是面向系统的,但非功能性需求最终是服务于功能性需求。我们通常会用非功能性需求去评估一个系统的质量,比如扩展性、维护性、安全性等。

那什么是软件设计呢?软件设计就是折衷(Trade Offs)。实际情况下,资源是受限的,需求面临着各种因素的制约,软件设计就是在有限资源条件下对影响因素的一种权衡。而这本书从另一个角度阐明了:软件设计即管理复杂性。在我看来,它更偏向于对非功能性需求的一种定义。

围绕着复杂性这个主题,这本书回答了以下三个问题:

  • 什么是复杂性?
  • 软件系统为什么会变得复杂?
  • 如何降低软件系统的复杂性?

什么是复杂性

复杂性与软件系统的结构有关,这使它很难理解和修改系统。系统的总体复杂度由每个部分的复杂度乘以开发人员在该部分上花费的时间加权。翻译成人话就是说:

如果一个软件系统难以理解和修改,那就很复杂。如果很容易理解和修改,那就很简单。

软件开发的一个事实是软件系统是在不断演进的。软件跟建筑是非常不一样的。建筑前期做好设计后,后续就可以按部就班实施,但软件的需求是变化的,需求的变化会影响系统的演进。比如软件系统的MVP版本是只是实现了核心功能,但随着用户规模增长以及市场环境的变化,之前的很多设计决策可能并不能满足用户的需求,这个时候软件系统需要增量式的迭代,以适应新的用户需求。

软件系统的复杂性具有三个特征:

  • 变更放大
  • 认知负荷
  • 变更的影响未知

变更放大是指在增加需求时,系统需要变更的多个地方,这其实是非内聚性的一个具体体现;认知负荷是指在我们在理解系统时所花的时间成本,认知负荷过重,不仅会导致对系统难以修改,也更容易导致系统BUG;变更的影响未知和第二点密切相连,但更强调整体性,主要体现在系统没有一个良好的结构和依赖关系,开发人员对系统全局没有一个清晰的认识,导致开发人员很难评估变更带来的影响,这也是最严重的一个问题,因为开发人员对未知的影响是没有预期的,一旦系统出现非预期行为,可能会导致无法挽回的问题。

软件系统为什么变得复杂

软件系统的复杂性来源于两个方面:

  • 依赖关系
  • 模糊性

依赖关系是软件的组成部分,是不可避免。人天生易于理解和掌握有结构的事物,软件系统也是强调结构的。明确的依赖关系带来良好的系统结构。比如单向依赖就比循环依赖更容易理解,显性依赖比隐性依赖更容易理解。

软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。

模糊性是指信息的表达比较晦涩。当信息不明显,缺乏结构时,就容易导致模糊。比如模块的职责不清晰、不一致的语义、不一致的命名、依赖关系混乱等。

依赖关系和模糊性共同构成了上述复杂性的三种特征。依赖关系导致变更放大和高认知负荷;模糊性会导致对变更的影响未知,同时也会增加认知负荷。

软件系统是迭代式演进的,也就是说我们无法避免对系统的修改。随着软件规模的增加,复杂性的增长也难以控制。在开发过程中,很容易说服自己,当前变更带来的一点复杂性没什么大不了。然而,如果每个开发人员对每次变更都采用这种方法,随时时间的推移,复杂性就会迅速积累。一旦复杂性积累起来,就很难消除,并开始逐渐拖慢开发节奏,导致BUG频出。

如果我们找到最小化依赖关系和模糊性的设计方法,那么我们就可以降低软件的复杂性。

如何降低软件系统的复杂性

从更高一个维度来讲,管理复杂性的思想武器可以归结为:

  • 分治
  • 知识
  • 抽象

分治把问题分割成规模更小且易于处理的的若干子问题,这样就可以使用相似问题的知识来解决这些子问题,而使用抽象有助于对它们进行推理和判断。分治、知识、抽象的有效性在于它们能够帮助我们在智力不变的情况下解决不断增长的问题。

这本书提出了很多设计原则,是对上述思想武器的具体应用。这些设计原则比较多,本文将其分为三个方面:

  • 软件开发的基本事实
  • 通过结构化简化系统
  • 通过实现细节简化系统

软件开发的基本事实

软件开发经过几十年的发展,逐步形成了一些基本事实,了解这些基本事实有助于我们更好的做设计决策。这些基本事实是:

软件的复杂性随时间的推移而增长
上面提到,需求是不断变化的,软件无法避免修改。随着系统的迭代升级,如果不增加外力,系统的复杂性会越来越高。软件设计的艺术在于管理好系统的复杂性,以便新的需求能够更加容易的添加到系统中。

只满足业务需求的代码是远远不够的
面向需求编码更像是战术性编程,主要目标是完成需求功能。如果对于一次性的功能需求,比如编写一个跑数据的临时脚本,那确实不需要做什么设计,只要能在最短的时间完成功能即可。

开发人员有个错觉是认为ad-hoc方案效率高而没有考虑长期的影响。战术性编程的主要问题在于它会让系统复杂度变得越来越高,最终拖慢后续系统的迭代效率,而且让系统变得越来越不稳定,从长远来看,反而付出了更大的成本。

我们应该保持战略性编程。坚持战略性编程要求开发人员时刻关注并评估代码变更对系统复杂性的影响,这需要付出长期努力,对开发人员也有更高的要求。需要注意的是,警惕因为特殊情况而推迟好的设计,等待有时间了再回头解决,通常这些问题都不会得到有效解决,反而会随着时间的推移堆积更多问题。这个时候,由于团队成员通常都面临着业务压力,很难有时间去解决历史技术债务问题,进一步会选择ad-hoc方案。系统的复杂度与迭代效率进入一个恶性循环,最终严重拖慢业务迭代效率后,又花费更大的成本对系统进行重构。

好的设计不是一促而就的
软件设计并不简单,我们在第一次构建系统时可能并没有很好的理解需求,或者需求的变更导致系统的腐化。好的做法是设计两次。
每次设计都应该考虑多种方案,通过对多个方案的横向对比,最终将会得到更好的结果。即便只有一种选择时,也要想想其它方案,即使其它方案很糟糕,它也能够给你带来一些启发。

软件更多应该为可读性而设计,而不仅限于代码编写
我们应该意识到软件80%的成本不在于开发,而在于维护。根本原因还是在于开发人员需要不断迭代系统,这涉及三方面的时间成本:

  • 理解成本
  • 变更成本
  • 问题定位成本

好的设计应该降低这三方面的成本。

通过结构化简化系统

前面提到软件系统是结构化的。经过无数大师的经验积累,软件开发沉淀了一些对软件系统结构化的原则:

分层设计
分层设计是一种分治方法,它是降低系统复杂性的有效有段。通过对不同层次,提供不同的抽象,让每个层次完成特定的职责。各个层通过接口进行交互,而不用了解其实现细节,降低了理解复杂度。通常分层设计要求严格的依赖关系,比如只能上层依赖于下层,而不能反向依赖,这种依赖关系限制了系统中的数据流向,也就是约束了状态变更的有序性,也是为了让开发人员能够更容易维护系统。

模块化设计
模块化设计也是一种分治方法,它是对分层设计细化,本质上没有太大区别。最主要还是模块的职责定义与接口抽象。关于模块的设计,一个核心设计原则是深模块设计,即简单接口,深度实现

注: 这里指的模块并不仅仅是指一个独立部署的功能集合,它可以是一个类,也可以是一个函数。

接口是对实现的抽象,简单接口更容易让人理解,深度实现,是对高内聚的一种具体做法。它通过对信息的有效封装,让复杂性封装在模块内部。你可以能会质疑,那这样模块会不会太复杂了,这需要我们递归地运用其它原则降低模块的复杂度。

模块的拆分与合并

我们在做系统设计时,经常会面临一个问题:一个功能和另一个功能是拆分还是合并呢?

模块是拆分还是合并,根本的判断原则还是哪种方式能让系统复杂度更低。在实现时选择适合的数据结构对信息进行最好的隐藏,尽量少的依赖关系以及深接口设计。

关于合并,如果合并后,模块内出现不在一个抽象层面的事物,而且这些事物应对需求变更的频率出现较大差异时,那这是一个信号,它提示我们应该拆分。
关于拆分,如果拆分后,发现需要在父级和子级之间来回切换才能了解它们如何协同工作,那这是一个信号,它提示我们拆分不合理,或是否需要合并。

模块拆分原则:总体目标是让系统复杂度更低,模块内所有代码都应该在同一抽象层级上。

通过实现细节简化系统

对于实现细节,这本书中也提出了很多具体的设计原则,主要包括:

信息隐藏

这是实现深度模块最重要的技术。基本思想是每个模块应该封装一些知识,这些知识代表设计决策。这些知识嵌入在模块的实现中,但不会出现在其接口​​中,因此对其他模块不可见。
作为软件设计师,你的目标是尽量对外隐藏信息,但对于软件的透明性(可观察性),你需要暴露必要的信息给外部使用者,所以关键是要识别出哪些信息应该隐藏,哪些信息应该暴露,这是一种权衡(Trade Offs)。

命名

命名是被严重低估的用于改善系统复杂性的手段。命名应该清晰、一致、无歧义。

命名提供了一种简化的思考更复杂底层实体的方法。与其他形式的抽象一样,最好的名称是那些将注意力集中在底层实体最重要的部分上,同时忽略不太重要的细节的名称,它本质上是一种抽象。

如果你发现很难为特定变量想出一个精确、直观且不太长的名称,那么这就是一个危险信号,可能意味着存在设计上的问题。

注释

注释背后的总体思想是捕捉设计师心中但无法在代码中呈现的信息。

不写注释的4个理由

  • 代码即文档,代码能清晰表达设计意图
  • 没时间写注释
  • 文档和代码不同步
  • 注释无用

这本书会告诉你,以上4个理由都站不住脚,另外,写注释并没有想象的那么困难,也不会额外花费多少时间成本。

如何写注释

  • 注释应该描述代码中不明显的内容
  • 开发人员应该能够理解模块提供的抽象,而无需阅读除其外部可见声明之外的任何代码。
  • 好的注释通常会在与代码不同的详细程度上解释事物

异常处理

异常处理通常比正常逻辑处理更困难,开发者通常定义错误,但却没有仔细想过如何处理这些异常。减少异常情况可以减少系统复杂性,通常情况下可以通过修改语义,以便能正常处理异常行为。

如果下游知道怎么处理异常,且能够处理异常,就不要把异常暴露给上游。因为异常向上暴露,会导致所有上游调用都需要关注这些异常,且他们可能会做出相同的异常处理,相同的异常处理代表着重复工作。不同的异常处理可能意味着不一致,这种不一致可能会传导至用户,这会导致系统的复杂性。

一致性

一致性是减少系统复杂性的有力工具。一致性可以创造认识杠杆,一旦你知道系统中的一种处理方式,你就知道系统中其它也是相同的处理方式。

一致性降低错误的发生。如果相似的逻辑却有不一致的处理方法,除了造成认识负担外,还容易引入错误,因为修改的时候,你需要修改多处地方,而且修改的方式可能还不一致,开发者除了忘记不同地方的修改之外,但容易修改错误。

复杂性下层

离用户越近,用户越多。如果你负责一个模块,你将复杂性交给你的用户,会导致更多用户去面对这种复杂性,他们可能会对同样的问题进行重复处理。从人力成本来讲,你应该将复杂性下层至你负责的模块内部。但请记住:简单接口比简单实现更重要。

通用能力下沉,专用能力上升,其背后隐藏的根本原因是策略和机制分离,换个更直观的说法是分离易变部分和稳定部分。分离的好处,减轻了各个层的复杂度,系统变更缩小到了一个更小的范围内,更容易理解和控制。

编写可读代码

好的代码应该是可读的,如何编写可读代码,同样可以运用上面提到的大部分原则。在此推荐另一本书编写可读代码的艺术,这本书中非常详细的介绍了一些编写可读代码的具体实践。

软件开发趋势

前面我们了解了系统复杂性产生的根源以及如何管理系统复杂性,现在我们再从软件发展历史,来分析当前一些主流开发方式的本质。

面向对象

面向对象的三个特征:

  • 封装
  • 继承
  • 多态

封装是对信息的隐藏,是内聚性的一种具体实现方法;继承分为接口继承和实现继承,接口继承是深接口的一种体现,实现承继是为了代码复用。

需要注意的是面向对象编程和面向对象语言是两个概念,并不是说C语言就不能做面向对象编程。也不意味你使用C++开发,就是面向对象了。面向对象语句只是在语言层面上提供了面向对象机制可以帮助实现干净的设计,但它们本身并不能保证良好的设计。

在面向对象设计中有一个很著名的设计原则SOLID,这些原则其实都是管理依赖关系的技术。总体目标是将软件系统划分为一组组件,这些组件的相互依赖关系被组织得如此之好,以至于变化不会从一个组件传播到另一个组件。这其实是解决复杂性根因的依赖关系问题。

敏捷开发

敏捷开发主要涉及软件开发过程,而不是软件设计。敏捷开发最重要的元素之一是软件开发是渐进式、演进的。这是软件区别于建筑等行业的一个明显特征。敏捷开发的一个主要风险是容易导致战术性编程(面向需求编程)。敏捷开发主要关注功能(feature)而不是抽象。它鼓励开发人员推迟设计决策,以便尽快生产出可用的软件。

单元测试

单元测试是开发人员最常编写的测试,它的规模小且重点突出:每个测试通常验证单个方法中的一小段代码。单元测试可以独立运行,而无需为系统设置生产环境。单元测试通常与测试覆盖率工具一起运行,以确保应用程序中的每一行代码都经过测试。每当开发人员编写新代码或修改现有代码时,他们都有责任更新单元测试以保持适当的测试覆盖率。
单元测试,在软件设计中起着重要作用,因为它们有助于重构。在做较大代码变更时,没有单元测试是很难保证变更是否达到预期的。
系统测试(有时称为集成测试),用于确保应用程序的各个部分能够正常协同工作。它们通常涉及在生产环境中运行整个应用程序。系统测试一般由QA 和RD共同编写。

测试驱动开发

本书的作者认为单元测试是必要的,但测试驱动开发并不能给出良好的设计。作者认为测试驱动开发更容易导致战略性编程,因为编程单元测试是面向需求的,这导致代码的实现主要以完成需求为目标。

在修复bug时,先写单元测试是合理的。原因是可以先用测试覆盖bug,然后再变更代码让测试过程,这样就能验证功能符合预期了。

设计模式

设计模式针对提供一套可复用的通用方案,如果具体场景适用,那就使用。这容易在开发人员之前形成共识,易于理解。但注意别将具体业务场景硬套入设计模式中。使用设计模式不会改善软件系统,只有当设计模式合适时才会这样做。

AI编程

需求的变化性决定了软件系统的复杂性,人类几十年的软件开发历史可以看作是一条对抗复杂性的探索之路。软件开发技术和工具日新月异,但软件开发的本质没有什么变化。AI时代,软件开发是否会有所改变呢?我认为它只能提高编程效率,能够进一步释放人的创新能力,但无法解决根本性问题,我们能够做的就是积极拥抱新技术,善用工具,提升自身的生产力。

参考资料