https://zhuanlan.zhihu.com/p/43538597
https://my.oschina.net/taogang/blog/1940954
https://www.youtube.com/watch?v=bmSAYlu0NcY
https://platformlab.stanford.edu/Seminar%20Talks/retreat-2017/John%20Ousterhout.pdf
https://my.oschina.net/taogang/blog/1940597
http://www.ruanyifeng.com/blog/2018/09/complexity.html
https://my.oschina.net/taogang/blog/1940954
这里“设计两次”的意思是无论设计一个类,模块还是功能,在设计的时候仔细思考,除了当前的方案,还有那些其它的选择。在众多设计中比较,列出各自的优缺点,然后选出最佳方案。就是对于设计方案,都有两个或者两个以上的选择。
对于大牛而言,也许设计方案显而易见,于是觉得没有必要在不同方案中做遴选。然而这并不是一个好的习惯,这说明,你没有在处理更困难的问题,问题对于你而言太简单了。这不是一个好的现象,因为上坡路总是很难走。当你面对困难的问题的时候,通过对不同设计方案的学习和思考,你会成长到更高的一个层次。
我的解读:在管理理论上有一个叫彼得原理,就是“在一个等级制度中,每个人趋向于上升到他所不能胜任的地位”。程序员也面临同样的问题,当你的经验和资历不断的提高,你总会遇到你所不能胜任的问题,这个时候就需要通过不断的学习,提高自己。当然也有可能所处的环境无法给你更具挑战的问题。这个时候你就需要考虑,你的下一站在哪里?
为什么要写注释
困扰程序员的两大世界性难题:
- 别人的代码没有注释
- 别人让我给我的代码写注释
程序员通常有各种理由不写注释:
- 好的代码是自解释的
- 没时间写
- 注释很快就会和代码不一致,造成误解
- 我读的其他人的注释都毫无意义
我的解读:其实开发过软件的工程师都能理解写注释的重要性和意义,这并不需要很多的解释。但是“懒惰”是原罪之一,我就是不想写呀不想写。
注释应当用于描述代码中不易理解的部分
如果你一定要对于显而易见的部分增加注释,那么可能你是按代码行数收取工资吧,当然,注释也是算行数的。
选择命名
给变量,类,模块,文件起名字很难,真的很难。好的命名能使得软件设计更容易理解,差的命名更容易产生Bug。
我就被坑过。还是在某存储公司的时候,负责开发一个软件升级的规则模块,根据不同的规则决定能不能升级。当时我的代码release之后,发现客户不能升级了。于是我们在代码中找Bug,后来发现,原因是我的代码判断“hardware”字段来决定目标硬件类型是否匹配,而应该是另一个和“hardware”命名很像的另一个字段来决定要升级的硬件的类型。更糟糕的是,因为这个字段实在是比真正应该判断的字段看上去更合理,进行代码审查的人都没能看出这个问题。而当时没有测试环境能够实际匹配到这个硬件类型,这个问题也没能在测试环节中发现。
注释先行
在实现过程中,把接口和注释先准备好。
修改现有代码
对于修改代码,同样面临着“战术性编程”和“战略性编程”的挑战,是以最少的修改完成任务,还是以重新设计使得系统更合理的角度进行长线投资,需要仔细思考。
我的解读:随便改一些不相关的代码,你可能会发现Bug神奇的消失了,软件开发需要运气,祈祷有的时候真的管用。
一致性
一致性在软件设计里很重要,包括:
- 命名
- 代码风格
- 接口
- 设计模式
- 常量
可以使用以下的方法来保证一致性:
- 文档
- 利用工具/代码审查来强制
- 入乡随俗
- 不要随便改变命名约定
代码应当显而易见
怎么定义代码是不是显而易见,就是带代码审查的时候,如果有人认为这的代码不是容易理解,那么这个代码应该就是有问题的。也许这个代码对你来说很直观,但是代码不是写给自己看的。应该让团队里的其他成员也能读懂你的代码。
有一些使的代码不易理解的元素:
- 事件驱动模式 - 因为不知道事件流控制的顺序
- 范型 - 也许运行时才知道类型,造成阅读的困难
软件开发的趋势
John对软件开发重的一些趋势和问题做了总结:
- 面向对象,对于继承,基于接口的继承要优于基于实现的继承
- 敏捷,敏捷的一个潜在问题是导致“战术性编程”为主导,导致系统的复杂性增加
- 单元测试
- 测试驱动,测试驱动的问题是关注功能,而非找到最佳设计
- 设计模式,设计模式的问题可能导致过度应用
- Getter/Seeting, 这个模式可能是冗余的,也许不如直接暴露成员更简单
https://www.youtube.com/watch?v=bmSAYlu0NcY
https://platformlab.stanford.edu/Seminar%20Talks/retreat-2017/John%20Ousterhout.pdf
https://my.oschina.net/taogang/blog/1940597
软件设计应该简单,避免复杂,关于复杂性的定义,作者认为主要有两个量度
- 系统是不是难以理解
- 系统是不是难以修改
关于复杂性的症状:
- 当新增特性时,需要修改大量的代码
- 当需要完成一个功能时,开发人员需要了解许多知识
- 当新增/修改功能时,不能明显的知道要修改那些代码
引起复杂性的原因:依赖和晦涩。
最后,复杂性不是突然出现的,它是随着时间和系统的演进逐渐增加的。
我的解读:这本书讲的是软件设计的哲学,哲学要解决的是最根本的问题。作者认为软件设计要解决的最根本的问题就是避免复杂性,依赖和晦涩是造成软件负责的主要原因。依赖很多时候是无法避免的,但是应该尽可能的减少依赖,去除不必要的依赖。软件设计应该容易理解,晦涩是引起复杂性增加的另一个原因。这个核心观点是这本书的主旨,借用老爱的话“Simple,but not simpler!”
在第二章,作者提出了“战术性编程”和“战略性编程”的对立。
“战术性编程”最求以最快的速度完成可工作的功能。这看上去无可厚非。但是这种行为往往会增加系统的复杂性。引发大量的技术债。可以说这种做法以牺牲长远利益来获得眼前的利益。
“战略性编程”不仅仅要求可工作的代码,以好的设计为重,未来的功能投资,认为现阶段在设计上的投入会在将来获得回报。
好的设计是有代价的,问题是你愿意投入多少?
我的解读:很有趣的是,我司之前的产品的负责人在公司推行大规模的敏捷(LeSS),当时有一个顾问给我们上课,他也说设计要尽可能简单,但是不要为了未来做设计。以最小的代价实现可用的功能。以John的观点,这样做无疑会增加系统变复杂的可能性。我比较认同John这里的观点,好的设计是有价值的,投入在软件设计上的,对功能毫无影响的东西,是有价值的。但是如何取舍和权衡,投入多少是需要开发团队达成共识。 软件有它的生命周期,为了未来的投入也不是越多越好。
深度其实是对模块封装的度量,模块应该提供尽可能简单的接口和尽可能强大的功能。这样的模块称之为深度模块。
好的封装可以减少依赖,简单的接口可以避免晦涩。也就是减少了复杂性。
信息的隐藏和泄漏
关于信息的隐藏和泄漏,这一部分对于熟悉面向对象的猿们来说不是新东西。基于SOLID,这就是Open,软件应该是对于扩展开放的,但是对于修改封闭的。信息隐藏使得修改变的封闭。
具有通用功能的模块更具深度
更通用功能的接口意味着更高层级的抽象,隐藏更多的实现细节,按照John的观点,也就更具深度。那么如何在通用接口和特殊接口之间做权衡呢?
- 能够实现所需功能的最简单接口是什么?
- 该接口会被用于那些不同场景?
- 该接口对于我的当前是否容易使用?
我的解读:通用的接口和之前的“战略性编程”是一致的,更通用的接口在面对未来可能发生的需求变化的时候,更容易使用。这里的艺术在于能够找到需求到软件接口之间的最佳映射。抽象到哪一个层级,是主要问题。
不同的层,不同的抽象
软件系统通常有不同的层次组成,每一层都通过和它之上和之下的层的接口来交互。每一层都具有自己不同的抽象。例如典型的数据库,服务器和客户端模型中,数据库层的抽象是数据表和关系,服务器层是应用对象和应用逻辑而客户端的抽象是用户接口视图和交互。如果你发现不同的层具有相同的抽象,那也许你的分层有问题。
把复杂性向下移
在软件分层的鄙视链中,最高层是用户,接着的一层的UI工程师,然后是后台工程师,数据库工程师,等等。用户是上帝不能得罪,如果一定要在某个层次处理复杂性,那么这个层次越低越好,反正苦逼程序员也不会抱怨,对得,就是这个道理。
合并还是分离
“天下大事,分久必合,合久必分”。软件设计中经常要问的问题就是这两个功能模块是合并好,还是分开好?不论是合并还是分离,目标都是降低复杂性,那么把系统分离成更多的小的单元模块,每一个模块都更简单,系统的复杂性会降低么?答案是不一定:
- 复杂性可能来源于系统模块的数量
- 更多的模块也许意味着需要额外的代码来管理和协调
- 更多的模块可能带来许多依赖
- 更多的模块可能带来重复的代码,而重复的代码是恶魔
在以下的情况下,需要考虑合并:
- 模块之间共享信息
- 合并后的接口更简单
- 合并后减少了重复的代码
异常和错误处理是造成软件复杂的罪魁祸首之一。程序员往往错误的认为处理和上报越多的错误,就越好。这也就导致了过度防御性的编程。而很多时候,程序员捕获了异常并不知道该如何处理,干脆往上层扔,这就违背了封装原则。
降低复杂度的一个原则就是尽可能减少需要处理的异常可能性。而最佳实践就是确保错误终结,例如删除一个并不存在的文件,与其上报文件不存在的异常,不如什么都不做。确保文件不存在就好了,上层逻辑不但不会被影响,还会因为不需要处理额外的异常而变得简单。
Ousterhout 教授认为,软件设计的最大目标,就是降低复杂性(complexity)。 所谓复杂性,就是任何使得软件难于理解和修改的因素。
Complexity is anything that makes software hard to understand or to modify.
复杂性的来源主要有两个:代码的含义模糊和互相依赖。
Complexity is caused by obscurity and dependencies.
模糊指的是,代码里面的重要信息,看不出来。依赖指的是,某个模块的代码,不结合其他模块,就会无法理解。
Obscurity is when important information is not obvious.Dependency is when code can't be understood in isolation.
复杂性的危害在于,它会递增。你做错了一个决定,导致后面的代码都基于前面的错误实现,整个软件变得越来越复杂。"我们先把产品做出来,后面再改进",这根本做不到。
Complexity is incremental, the result of thousands of choices. Which makes it hard to prevent and even harder to fix.
二、复杂性的隔离
降低复杂性的基本方法,就是把复杂性隔离。"如果能把复杂性隔离在一个模块,不与其他模块互动,就达到了消除复杂性的目的。"
Isolating complexity in places that are rarely interacted with is roughly equivalent to eliminating complexity.
改变软件设计的时候,修改的代码越少,软件的复杂性越低。
Reduce the amount of code that is affected by each design decision, so design changes don't require very many code modifications.
复杂性尽量封装在模块里面,不要暴露出来。如果多个模块耦合,那就把这些模块合并成一个。
When a design decision is used across multiple modules, coupling them together.
三、接口和实现
模块分成接口和实现。接口要简单,实现可以复杂。
Modules are interface and implementation. The best modules are where interface is much simpler than implementation.It's more important for a module to have a simple interface than a simple implementation.
好的 class 应该是"小接口,大功能",糟糕的 class 是"大接口,小功能"。好的设计是,大量的功能隐藏在简单接口之下,对用户不可见,用户感觉不到这是一个复杂的 class。
最好的例子就是 Unix 的文件读写接口,只暴露了5个方法,就囊括了所有的读写行为。
四、减少抛错
有些软件设计者喜欢抛错,一遇到问题,就抛出一个 Exception。这也导致了复杂性,用户必须面对所有的 Exception。"反正我告诉你出错了,怎么解决是你的事。"
正确的做法是,除了那些必须告诉用户的错误,其他错误尽量在软件内部处理掉,不要抛出。
Tcl 语言的最初设计是,unset() 方法用来删除已经存在的变量,如果变量不存在,该方法抛错。Ousterhout 教授说,这个设计是一个错误,完全不应该抛错,只要把 unset() 定义成让一个变量不存在,就解决问题了。
另一个例子是,Windows 系统不能删除已经打开的文件,会有错误提醒。这也是一个设计错误,有些用户实在删不掉这些文件,不得不重启系统。Unix 的做法是,总是允许用户删除文件,但是不清理内存,已经打开的文件在内存里面继续存在,因此不会干扰其他程序的运行,那些程序退出保存文件的时候,发现文件不存在才会报错。这个设计比较好。