《代码大全》读书笔记

太长不看版

软件构建中的设计

软件设计是一项明确的活动

设计中的挑战

软件设计一词意味着去构思、创造或发明一套方案,把一份计算机软件的规格说明书要求转变为可实际运行的软件。 设计就是把需求分析和编码调试连接在一起的活动。 好的高层词设计能够提供一个可以稳妥容纳多个较低层次设计的结构。

设计是一个险恶的问题

险恶(wicked)的问题就是那种只有通过解决或部分解决才能被明确的问题。

Tacoma Narrows 大桥是一个险恶问题的好例子,因为直到这座桥坍塌,工程师才知道不应该只考虑桥的负荷,还需要充分的考虑空气动力学因素(只有建造大桥,才能从中学到需要考虑额外的环节)。

设计是一个了无章法的过程(即使它能得处清爽的成果)

  • 是因为在设计的过程中可能会采用很多错误的步骤,多次出错
  • 因为设计的优劣差异往往非常微妙
  • 因为不能判断设计是否足够好

设计就是确定取舍和调整顺序的过程

现实世界中,设计者工作的一个关键内容就是衡量彼此冲突的各项设计特性,并尽力在其中寻求平衡。响应速度优先和开发时间短优先得出的设计结果可能是不同的。

设计受到诸多限制

设计的要点一部分是在创造可能发生的事情,另一部分是在限制可能发生的事情。

如果一个人有无限空间和资源来建造房子,可能会建造出无法控制的建筑。正是因为有了限制,才得出了简单的结果。软件设计也是一样。

设计是不确定的

每个人设计的结果可能是不同的,并且可能用起来都不错。设计没有标准答案。

设计是一个启发式的过程

设计过程中充满了不确定性,因此设计技术也趋于具有探索性–“经验法则”或者“试试没准能行”–而不是保证能产生预期结果的可重复的过程。

设计是自然而然形成的

设计不是在谁的头脑中直接跳出来的,它是在不断的设计评估、非正式讨论、写试经验以及修改试验代码中演化和完善的。

关键的设计概念

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

本质的难题和偶然的难题

偶然的难题可以理解为bug,编程语言笨拙的语法,等易于发现容易解决的问题。 本质的难题则比较复杂,本质上说,软件开发就是不断去发掘错综复杂,相互关连的整套概念的所有细节。本质困难就是:

  1. 要面对复杂、无序的现实世界;
  2. 精确而完成的识别出各种依赖关系和外部情况
  3. 设计出完全正确而不是大致正确的解决方案 。。。
管理复杂度的重要性

一个失败的项目如果是由于技术原因而失败,通常都是因为软件复杂度失控了。如果复杂度失控,那么软件就会变得极端复杂,没有人知道它能做什么,它出了问题如何解决。

管理复杂度是软件开发中最为重要的技术话题。

在软件架构层次上,可以通过把大的系统分解为多个子系统来降低问题的复杂度,多个简单的问题比一个复杂的大问题更容易理解。

子系统相互间应该减少依赖; 子系统的关注点应该是相互分离的。

如何应对复杂度

高代价、低效率的设计源于下面三种根源:

  1. 用复杂的方法解决简单的问题
  2. 用简单但错误的方法解决复杂的问题
  3. 用不恰当的复杂的方法解决复杂的问题

用下面的方法管理复杂度

  • 把任何人在同一时间需要处理的本质复杂度降到最低
  • 不要让偶然性的复杂度无谓的增长

理想的设计特征

  • 最小的复杂度
  • 易于维护
  • 松散耦合
  • 可扩展性
  • 可重用性
  • 高扇入:让大量的类使用某个给定的类。(意味着设计出的系统很好的利用了在较低层次上的工具类
  • 低扇出:让一个类少量或始终的使用其他类。(高扇出(7个)意味着一个类过多的使用了其他类,可能会变得过于复杂
  • 可移植性
  • 精简性:没有多余的部分
  • 层次性:比如一个新系统会用到很多设计不佳的旧系统,这时就应该为新系统编写一个负责同就代码交互的层(代理模式)

    • 层次性能把低劣的代码紧闭起来
    • 如果能最终抛弃或重构旧代码,旧不必修改处交互层之外的任何新代码。
  • 标准技术:用到的外来的、古怪的东西越多,也越难理解。

设计的层次

设计的层次

1. 软件系统(Software System)
2. 分解为子系统或包(Division into Subsystems or Packages)

这一层的主要目的是确定如何把程序分为主要的子系统,并定义清楚允许各子系统如何使用其他子系统。

对于任何需要几周时间才能完成的项目,在这一层上进行划分都是必须的。

这里有个特别重要的点,即不同子系统之间相互通信的原则。如果不同子系统间都可以相互通信,就失去了拆分子系统的意义。

  • 如果拿不准改如何设计,就应该先对子系统之间的通信加以限制,等以后需要时再放开。
  • 有一个很好的基本原则是,程序之间不应该有环形关系,比如A类调用B类,B类调用C类,C类又调用A类这种情况,系统设计也应遵守这个原则
一些常用的子系统
  • 业务规则:这个是指哪些再计算机系统中编入的规则、策略以及过程。比如开发一个薪资系统,可能就需要把税务局关于允许提扣的金额以及估算的税率编写到系统中。
  • 用户界面:这应该是一个子系统,把用户界面组件同其它组件隔离开。
  • 数据库访问:需要把数据库的访问细节隐藏起来,让程序的绝大部份不需要关心底层实现细节。
  • 对系统的依赖性:把对操作系统的依赖因素归类到一个子系统,就如同把对硬件的依赖因素封装起来一样。
    • 这里的操作系统也可以是外部系统,比如微信小程序开发中对微信的依赖。
3. 分解为类(Division into Classes)

这一层次上的设计包括识别出系统中所有的类。例如:数据库接口子系统可能会分为数据库访问类、持久化框架类以及数据库元数据类。

类与对象的比较

  1. 对象是指运行期间在程序中实际存在的具体实体
  2. 类是指在程序源码中存在的静态事物
4. 分解为子程序(Division into Routines)

完整的定义出类内部的子程序,常常会有助于更好的理解类的接口,反过来也有助于对类的接口进行进一步的修改。

这一层的分解和设计通常由程序员个人来完成,对于用时超过几个小时的项目就有做的必要了。

5. 子程序内部的设计(Internal Routine Design)

设计构造块:启发式方法

由于软件设计是非确定性的,因此灵活熟练的运用一组有效的启发式方法,就成了一件特别重要的工作

找出现实世界中的对象

在确定设计方案时,首选且最流行的一种做法便是“常规的”面向对象设计方法,此方法的要点是要鞭尸现实世界中的对象以及人造的对象。具体步骤为:

  1. 便是对象及其属性
  2. 确定可以对各个对象进行的操作
  3. 确定各个对象能对其他对象进行的操作
  4. 确定对象的哪些部分对其他对象可见–哪些部分可以是公用的,哪些部分应该是私用的。
  5. 定义每个对象的公开接口

经过上述步骤得到一个高层次的、面向对象的系统组织结构之后,你可以用这两种方式来迭代;在高层次的系统组织结构上进行迭代,以便更好的组织类的结构;或者在每一个已经定义好的类上进行迭代,把每个类的设计细化。

形成一致的抽象

抽象是一个能让你在关注某一概念的同时可以放心的忽略其中一些细节的能力—在不同的层次上处理不同的细节。

以复杂度的观点看,抽象的主要好处就在于它使你能忽略相关的细节。

好的设计会在子程序接口的层次上、在类接口的层次上以及包接口的层次上(在门把手上、门的层次上以及房屋的层次上)进行抽象。

封装实现细节

抽象是说“让你从高层的细节来看待一个对象”,而封装则说:“初次之外,你不能看到对象的任何其他细节层次”。

封装管理复杂度的方式是不让你看到那些复杂度。

当继承能简化设计时就继承

继承能简化编程工作

隐藏秘密(信息隐藏)

信息隐藏是结构化程序设计与面向对象设计的基础之一。

秘密和隐私权

在设计一个类的时候,一项关键性的决策就是确定类的那些特性应该对外可见,哪些应该被隐藏起来。 类的接口应该尽可能少的暴露其内部工作机制。

好的类接口就像是冰山的尖一样,让大部分内容都不暴露出来

信息隐藏的一个例子

比如有一个程序,没个对象都有一个名为id的成员变量来保存唯一的ID。

  1. 一种设计方法是用一个整数来表示ID,同时有一个名为 max_id 的全局变量来保存当前的最大值,新的id 使用 ++max_id 来生成。这种设计是不合适的设计
    • 不是线程安全
    • ++max_id 可能遍布代码的各个位置,修改id 的生成规则需要改动太大
  2. 一个好的设计方式是,使用一个NewId() 方法来生成id,具体的实现逻辑在 NewId() 方法中,id 的类型也使用自定义的 IdType 类型,而不是指定 int 类型。
两种秘密

信息隐藏中所说的秘密主要分为两大类:

  • 隐藏复杂度,这样你就不用再去应付它,除非你要特别关注的时候
  • 隐藏变化源,这样当变化发生时,其影响就能被限制在局部范围内。
信息隐藏的障碍

少数情况下,信息隐藏会变的不可能,通常这种情况是由以下障碍造成的:

  • 信息过度分散 比如一个变量 数字42被写入了代码中(写死了),这样就会造成对它的引用过于分散。最好是把这个信息隐藏起来,比如写入常量中:THE_ANSWER = 42,代码中使用的时候引用 THE_ANSWER 这个常量就可以了。
  • 循环依赖:比如 A 类的子程序引用了B 类中的子程序,而B类中的子程序又引用了A 类中的子程序。这样会造成难以测试,需要保证两个类都正常才可以。
  • 类内数据设置成了全局数据:使用全局数据通常会遇到两个问题:一种是子程序执行时可能有另一个子程序也对它进行了操作,另一种是子程序知道有其他子程序在使用但不知道具体是哪个。这时应该使用只有少数子类可访问的类内数据。
  • 可以察觉的性能损耗:有的开发者为了减少调用关系试图在系统架构层和编码层进行优化。认为额外的层次调用会影响性能(事实上这种担心可能太早了,等以后遇到问题再优化是更好的选择)。
信息隐藏的价值

信息隐藏是少数几个得到公认的、在实践中证明了其自身价值的理论技术、并且已经有很长一段时间了(Boehm 1987a)。

  1. 大型项目修改起来更容易
  2. 有助于公开接口的设计(使开发者更容易理解什么样的数据应该隐藏、什么样的数据应该公开

找出容易改变的区域

对优秀的设计师的一份研究表明,他们所共有的一项特质就是都有对变化的预期能力(Glass 1995) 看起来非常可信

以下是应对变动的措施:

  1. 找出看起来容易变化的项目
  2. 把容易变化的项目分离出来
  3. 把看起来容易变化的项目隔离出来。

以下是容易变化的区域:

  1. 业务规则
  2. 对硬件的依赖性:屏幕,打印机、键盘、鼠标等设备之间的接口
  3. 输入和输出
  4. 非标准的语言特性
  5. 困难的设计区域和构建区域
  6. 状态变量:可以在使用状态变量是增加至少两层的灵活性和可读性
    • 不要使用布尔变量作为状态变量
    • 使用防蚊器子程序取代对状态变量的直接检查
  7. 数据量的限制:当你定义了一个具有100个元素数据的时候,实际上也向外界透露了一些不必要的信息。用全局变量代替100 是一个好的选择。

预料不同程度的变化

当考虑系统的潜在变化时,你认为越有可能发生变化的区域,越要做好应对变化的准备。 找出容易变化的区域的一个好的办法是:受限找出程序中可能对用户有用的最小子集。这一子集构成了系统的核心,不容易变化,然后扩充系统。 > 通过首先定义清楚核心,来认清哪些组件是附属功能,这时就容易把它们提取出来,并且这些内容也容易改进优化。

保持松散耦合

模块之间好的耦合关系会松散到恰好能使一个模块能够很容易地被其他模块使用。 > 请尽量使你创建的模块不依赖或者很少依赖其他模块。 > 如果模块是微服务中的一个服务,那么一个好的耦合关系是一个服务可以在其它服务挂掉的情况下可以正常提供基础服务。

耦合标准
  • 规模:这里的规模指的是模块之间的连接数。对于耦合度来说,小就是美。
  • 可见性:可见性指的是两个模块之间的连接显著程度。通过参数传递数据是一种明显的连接,值得提倡,而通过修改全局数据而使另一模块能够使用该数据则是一种 鬼鬼祟祟的做法,不值得提倡。
  • 灵活性:灵活性指的是模块之间的连接是否容易改动。模块越灵活,越容易被其它模块调用(耦合越松散)越好。
耦合的种类
  • 简单数据参数耦合,两个模块之间通过参数传递数据,这种耦合关系正常,可以接受
  • 简单对象耦合,如果一个模块实例化一个对象,那么它们之间的耦合关系就是简单对象耦合。这种耦合关系也能接受
  • 对象耦合 如果object1 要求object2传递给他一个object3,那么这两个模块就是对象参数耦合。这种耦合更紧密,因为它要求object1 了解object3。
  • 语义耦合,如果一个模块不仅使用了另一模块的语法元素,而且还是用了那个模块内部工作细节的语义知识。这种耦合就非常危险,因为更改被调用模块,会影响调用者。

查阅常用的设计模式

设计模式其实是一些现成精炼的解决方案,可用于解决很多软件开发中常见的问题。

设计模式提供了如下好处:

  1. 设计模式通过提供现成的抽象来减少复杂度
  2. 设计模式通过把常见解决方案的细节予以制度化来减少出错
  3. 设计模式通过提供多种设计方案儿带来启发性的价值。
  4. 设计模式通过把设计对话提升到一个更高的层次上来简化交流。比如你和其它开发者讨论问题时说:我用来Factory Method。其实已经传递了很多有效信息。

常见的设计模式请参考百科介绍:设计模式 (设计模式概念)

常用设计模式Python实现

其它的启发式方法

  • 高内聚性
  • 构造分层结构
  • 严格描述类契约
    • 把没个类的接口看作是与程序的其余部分之间的一项契约会更有助于更好的洞察程序。这种契约类似于“如果你承诺提供数据x、y 和 z,并且答应让这些数据具有特征 a、b 和 c,我就承诺基于约束8、9和10来执行操作1、2和3
  • 分配职责
  • 为测试而设计
    • 如果为了便于测试儿设计这个系统,那么这个系统会是什么样子?
  • 避免失误
  • 有意识的绑定时间
    • 绑定时间是指的是吧特定的值绑定到某一变量的时间。早绑定会比较简单但不灵活
  • 创建中央控制点
    • 唯一一个正确位置的原则:为了找到某个事物,需要查找的地方越少,该起来就越容易越简单
  • 考虑使用蛮力突破
    • 一个可行的蛮力解决方案要好于一个优雅但不能用的解决方案
  • 画一个图
    • 一幅图顶的上一千句话–鲁迅说的。
  • 保持设计的模块化

使用启发方式的原则

  1. 理解问题
  2. 设计一个计划。找出现有数据和未知量之间的联系。
  3. 执行这一计划
  4. 回顾。检视整个解决方案。

设计实践

迭代(Iterate)

设计是个迭代的过程,并非只是从A点到B点,也可以从A点到B点,再从B点到A点。在设计方案中尝试不同的做法时,会同时从不同层次取审视问题。更有助于找出相关细节。

当你首次尝试得出一个看上去足够好的设计方案后,不要停下来,第二次尝试肯定会好于第一个。

分而治之(Divide and Conquer)

没有人的头脑能大到装下一个复杂程序的全部细节– Edsger Dijkstra

自上而下和自下而上的设计方法

自上而下和自下而上策略最关键的区别在于,前者是一种分解(decomposition)策略而后者是一种合成(composition)策略。

  • 自上而下的设计很简单,因为人们善于把一些大的事物分解成小的组件。
  • 自上而下另一个强项是可以推迟构建的细节
  • 自下而上的一个优点是通常能够焦躁的找出所需的功能,从而带来紧凑合理的设计
  • 自下而上的一个缺点是很难完全独立的使用它。大多数人擅长把大的概念分解成小概念,而不擅长从小概念中得出大的概念。
  • 自下而上设计的另一个缺点是,有时候会发现自己无法使用手头已有的零件来构建整个系统。
  • 自上而下和自下而上设计并不互斥,两者可以协作。

设计是一个启发式的过程,没有任何方案能保证万无一失,需要在设计的过程中需要不停的迭代,改进。需要多尝试来找出最佳方案。

建立试验性原型

有些时候,判断一种设计是否合适,只有用过才能知道。创建一个试验性原型(写出用于回答特定设计问题、量最少且能够随时扔掉的代码),来验证设计的可行性,通常可以找出设计中遇到的问题以及需要改进的方向。

合作设计

三个臭皮匠,顶个诸葛亮。

  1. 随便找个同事,向他征求意见
  2. 坐在会议室,在白板上画出可选设计方案
  3. 结对编程
  4. 和多名同事一起过设计想法

要做多少设计才够

对于正式编码前的设计工作量和设计文档的正规程度,很难有个准确的定论。下图总结了设计文档的正规化以及所需的设计层次:

设计的层次

如果在编码前判断不了应该做多深入的设计,那么详细的设计是一个好的选择。

记录设计成果

  • 把设计文档加入到代码中
    • 合适的注释非常关键,特别是某些特定的设计决策
  • 用Wiki 来记录设计讨论和决策
  • 写总结邮件
  • 保留设计图

总结

  • 软件的首要技术使命就是管理复杂度。以简单性为努力目标的设计方案对此最有帮助
  • 简单性可以通过两种方式来获取:轶事减少在同一时间所关注的本质性复杂度的量,二是避免生成不必要的偶然的复杂度
  • 设计是一个启发式的过程。固执于某一种单一方法会损害创新能力,从而损害程序
  • 好的设计都是迭代的,尝试的越多,最终方案会越好
  • 信息隐藏是一个非常有价值的概念。通过询问“我应该隐藏什么?”能够解决很多设计问题

参考链接


最后,感谢女朋友支持和包容,比❤️

也可以在公号输入以下关键字获取历史文章:公号&小程序 | 设计模式 | 并发&协程

扫码关注