读书笔记:A Philosophy of Software Design

今天一位同事在斯坦福的博士生导师John Ousterhout (注,Tcl语言的设计者)来公司做了他的新书《A Philosophy of Software Design》的演讲,介绍了他对于软件设计的思考。这里我把本书的读书笔记和心得分享给大家,欢迎大家来和我交流探讨。

大家也可以去看作者在google演讲时的视频和他演讲的slides

复杂性的本质

软件设计应该简单,避免复杂,关于复杂性的定义,作者认为主要有两个量度

1. 系统是不是难以理解

2. 系统是不是难以修改

关于复杂性的症状:

1. 当新增特性时,需要修改大量的代码

2. 当需要完成一个功能时,开发人员需要了解许多知识

3. 当新增/修改功能时,不能明显的知道要修改那些代码

引起复杂性的原因:依赖和晦涩。

最后,复杂性不是突然出现的,它是随着时间和系统的演进逐渐增加的。

我的解读:这本书讲的是软件设计的哲学,哲学要解决的是最根本的问题。作者认为软件设计要解决的最根本的问题就是避免复杂性,依赖和晦涩是造成软件负责的主要原因。依赖很多时候是无法避免的,但是应该尽可能的减少依赖,去除不必要的依赖。软件设计应该容易理解,晦涩是引起复杂性增加的另一个原因。这个核心观点是这本书的主旨,借用老爱的话"Simple,but not simpler!"

我曾经就职某存储巨头,其中有一块代码因为是收购的产品,代码已经非常陈旧了,因为没有人能看懂,所以也就没有人敢修改。你看,这个产品不是也卖的挺好的。

仅仅可工作的代码还远远不够

在第二章,作者提出了"战术性编程""战略性编程"的对立。

"战术性编程"最求以最快的速度完成可工作的功能。这看上去无可厚非。但是这种行为往往会增加系统的复杂性。引发大量的技术债。可以说这种做法以牺牲长远利益来获得眼前的利益。

"战略性编程"不仅仅要求可工作的代码,以好的设计为重,未来的功能投资,认为现阶段在设计上的投入会在将来获得回报。

好的设计是有代价的,问题是你愿意投入多少?

我的解读:很有趣的是,我司之前的产品的负责人在公司推行大规模的敏捷(LeSS),当时有一个顾问给我们上课,他也说设计要尽可能简单,但是不要为了未来做设计。以最小的代价实现可用的功能。以John的观点,这样做无疑会增加系统变复杂的可能性。我比较认同John这里的观点,好的设计是有价值的,投入在软件设计上的,对功能毫无影响的东西,是有价值的。但是如何取舍和权衡,投入多少是需要开发团队达成共识。 软件有它的生命周期,为了未来的投入也不是越多越好。

模块要有深度

深度其实是对模块封装的度量,模块应该提供尽可能简单的接口和尽可能强大的功能。这样的模块称之为深度模块。

我的解读:这一部分没有什么新东西,传统的面向对象和如今的微服务架构都是对这一哲学的应用。好的封装可以减少依赖,简单的接口可以避免晦涩。也就是减少了复杂性。

信息的隐藏和泄漏

关于信息的隐藏和泄漏,这一部分对于熟悉面向对象的猿们来说不是新东西。基于SOLID,这就是Open,软件应该是对于扩展开放的,但是对于修改封闭的。信息隐藏使得修改变的封闭。

具有通用功能的模块更具深度

更通用功能的接口意味着更高层级的抽象,隐藏更多的实现细节,按照John的观点,也就更具深度。那么如何在通用接口和特殊接口之间做权衡呢?

1. 能够实现所需功能的最简单接口是什么?

2. 该接口会被用于那些不同场景?

3. 该接口对于我的当前是否容易使用?

我的解读:通用的接口和之前的"战略性编程"是一致的,更通用的接口在面对未来可能发生的需求变化的时候,更容易使用。这里的艺术在于能够找到需求到软件接口之间的最佳映射。抽象到哪一个层级,是主要问题。

不同的层,不同的抽象

软件系统通常有不同的层次组成,每一层都通过和它之上和之下的层的接口来交互。每一层都具有自己不同的抽象。例如典型的数据库,服务器和客户端模型中,数据库层的抽象是数据表和关系,服务器层是应用对象和应用逻辑而客户端的抽象是用户接口视图和交互。如果你发现不同的层具有相同的抽象,那也许你的分层有问题。

把复杂性向下移

在软件分层的鄙视链中,最高层是用户,接着的一层的UI工程师,然后是后台工程师,数据库工程师,等等。用户是上帝不能得罪,如果一定要在某个层次处理复杂性,那么这个层次越低越好,反正苦逼程序员也不会抱怨,对得,就是这个道理。

合并还是分离

"天下大事,分久必合,合久必分"。软件设计中经常要问的问题就是这两个功能模块是合并好,还是分开好?不论是合并还是分离,目标都是降低复杂性,那么把系统分离成更多的小的单元模块,每一个模块都更简单,系统的复杂性会降低么?答案是不一定:

· 复杂性可能来源于系统模块的数量

· 更多的模块也许意味着需要额外的代码来管理和协调

· 更多的模块可能带来许多依赖

· 更多的模块可能带来重复的代码,而重复的代码是恶魔

在以下的情况下,需要考虑合并:

· 模块之间共享信息

· 合并后的接口更简单

· 合并后减少了重复的代码

确保错误终结

异常和错误处理是造成软件复杂的罪魁祸首之一。程序员往往错误的认为处理和上报越多的错误,就越好。这也就导致了过度防御性的编程。而很多时候,程序员捕获了异常并不知道该如何处理,干脆往上层扔,这就违背了封装原则。

用户一脸懵逼,"你叫我干啥?"

降低复杂度的一个原则就是尽可能减少需要处理的异常可能性。而最佳实践就是确保错误终结,例如删除一个并不存在的文件,与其上报文件不存在的异常,不如什么都不做。确保文件不存在就好了,上层逻辑不但不会被影响,还会因为不需要处理额外的异常而变得简单。

设计两次

这里"设计两次"的意思是无论设计一个类,模块还是功能,在设计的时候仔细思考,除了当前的方案,还有那些其它的选择。在众多设计中比较,列出各自的优缺点,然后选出最佳方案。就是对于设计方案,都有两个或者两个以上的选择。

对于大牛而言,也许设计方案显而易见,于是觉得没有必要在不同方案中做遴选。然而这并不是一个好的习惯,这说明,你没有在处理更困难的问题,问题对于你而言太简单了。这不是一个好的现象,因为上坡路总是很难走。当你面对困难的问题的时候,通过对不同设计方案的学习和思考,你会成长到更高的一个层次。

我的解读:在管理理论上有一个叫彼得原理,就是"在一个等级制度中,每个人趋向于上升到他所不能胜任的地位"。程序员也面临同样的问题,当你的经验和资历不断的提高,你总会遇到你所不能胜任的问题,这个时候就需要通过不断的学习,提高自己。当然也有可能所处的环境无法给你更具挑战的问题。这个时候你就需要考虑,你的下一站在哪里?

为什么要写注释

困扰程序员的两大世界性难题:

1. 别人的代码没有注释

2. 别人让我给我的代码写注释

程序员通常有各种理由不写注释:

1. 好的代码是自解释的

2. 没时间写

3. 注释很快就会和代码不一致,造成误解

4. 我读的其他人的注释都毫无意义

我的解读:其实开发过软件的工程师都能理解写注释的重要性和意义,这并不需要很多的解释。但是"懒惰"是原罪之一,我就是不想写呀不想写。

关于软件开发的七宗罪,请阅读AntiPatterns

注释应当用于描述代码中不易理解的部分

如果你一定要对于显而易见的部分增加注释,那么可能你是按代码行数收取工资吧,当然,注释也是算行数的。

选择命名

给变量,类,模块,文件起名字很难,真的很难。好的命名能使得软件设计更容易理解,差的命名更容易产生Bug。

我就被坑过。还是在某存储公司的时候,负责开发一个软件升级的规则模块,根据不同的规则决定能不能升级。当时我的代码release之后,发现客户不能升级了。于是我们在代码中找Bug,后来发现,原因是我的代码判断"hardware"字段来决定目标硬件类型是否匹配,而应该是另一个和"hardware"命名很像的另一个字段来决定要升级的硬件的类型。更糟糕的是,因为这个字段实在是比真正应该判断的字段看上去更合理,进行代码审查的人都没能看出这个问题。而当时没有测试环境能够实际匹配到这个硬件类型,这个问题也没能在测试环节中发现。

注释先行

在实现过程中,把接口和注释先准备好。

修改现有代码

对于修改代码,同样面临着"战术性编程"和"战略性编程"的挑战,是以最少的修改完成任务,还是以重新设计使得系统更合理的角度进行长线投资,需要仔细思考。

我的解读:随便改一些不相关的代码,你可能会发现Bug神奇的消失了,软件开发需要运气,祈祷有的时候真的管用。

一致性

一致性在软件设计里很重要,包括:

1. 命名

2. 代码风格

3. 接口

4. 设计模式

5. 常量

可以使用以下的方法来保证一致性:

1. 文档

2. 利用工具/代码审查来强制

3. 入乡随俗

4. 不要随便改变命名约定

代码应当显而易见

怎么定义代码是不是显而易见,就是带代码审查的时候,如果有人认为这的代码不是容易理解,那么这个代码应该就是有问题的。也许这个代码对你来说很直观,但是代码不是写给自己看的。应该让团队里的其他成员也能读懂你的代码。

有一些使的代码不易理解的元素:

1. 事件驱动模式 - 因为不知道事件流控制的顺序

2. 范型 - 也许运行时才知道类型,造成阅读的困难

我的解读:最早曾在一家通信企业做管理软件开发,几年后被要求修改自己多年前写的代码,读了好久,愣是没看懂。

软件开发的趋势

John对软件开发重的一些趋势和问题做了总结:

1. 面向对象,对于继承,基于接口的继承要优于基于实现的继承

2. 敏捷,敏捷的一个潜在问题是导致"战术性编程"为主导,导致系统的复杂性增加

3. 单元测试

4. 测试驱动,测试驱动的问题是关注功能,而非找到最佳设计

5. 设计模式,设计模式的问题可能导致过度应用

6. Getter/Seeting, 这个模式可能是冗余的,也许不如直接暴露成员更简单

为性能做设计

关于如何在复杂性和性能之间的权衡,通常更简单的代码运行的更快。当然很有可能更复杂和晦涩的代码性能更高,例如汇编对比Python。设计的时候需要考虑的是为了获得性能的提升,代价是什么?这样的代价是不是值得?

在为了性能做出修改之前,先进行测量。针对关键路径,找到影响性能的核心单元,做出性能改进的设计。

这本书的核心是关于"复杂性"的,软件无疑是一个非常复杂的领域。对于导致复杂的原因,我觉得John的观点没有问题,但是实际上还有很多更深层的原因。软件开发和人息息相关,离开人来讲纯软件的东西,其实并不复杂,软件开发中引起复杂性的更多原因是更为复杂的人,团队,组织,和组织关系。这并不是对该书的否定,这本书对于程序员来说还是很好的一本书,值得一读。

举报
评论 0