9

软件设计的哲学

 3 years ago
source link: https://limboy.me/2021/06/30/software-design-philosophy/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

今天要推荐的书是「A Philosophy of Software Design」,作者是 Tcl 语言的设计者,也是斯坦福大学的教授:John Ousterhou。他也在 Google 做了一个相关的分享。作为一个还在从事编程的大佬,看看他是怎么理解软件开发的哲学的。

一切都是关于复杂度

在作者看来,「复杂度」是核心,如果无法处理好复杂度,就很难构建大型/复杂的系统。

复杂度的定义

复杂度有没有简单的定义呢,作者认为复杂度就是理解和修改系统的成本。比如是否容易理解某一段代码是怎么工作的,如何跟上下游衔接的,处于架构中的哪个部分,改动它会对那些模块产生影响。如果这些都比较模糊,那就是复杂的。

还有一点是「Unknown unknowns」,比如为了完成某个 Feature,不知道哪些地方的代码需要做调整,或者需要知道哪些上下文,这种情况是最糟糕的。所以好的设计一定是「显式」的。

复杂度的来源

依赖和模糊。依赖是指一段代码无法被独立理解和修改,必须参照/修改相关代码。我们无法摆脱依赖,但可以让依赖尽量简单和显式。模糊就是一些重要的信息不够突出,比如用了一个通用的变量名,或者时间单位没有说明等。

复杂度也不是孤立的,平时如果不注意,日积月累之后想要再降下去就会比较难了。

处理复杂度的心态:战术编程与战略编程

战术编程就是只关注眼前需求,没有太考虑需求的本质和将来的演进。这样可能会让需求完成地更快,但也会给系统增加复杂度,进而带来更大的维护成本,于是就产生了「技术债」。

战略编程要意识到「Working code isn’t enough」,「a great design, which happens to work」才是目标,这需要投入时间去思考去雕琢。

处理复杂度的手段:分解与封装

分解就是将一个复杂系统拆分为多个相对独立的子系统,子系统之间也会产生依赖,处理依赖的方式是将子系统拆分为 interface 和 implementation。interface 里的内容是供消费方使用的,也就是 what,implementation 对应的是 how。interface 尽量简洁,把复杂度包在 implementation 里内部消化,形成 Deep Module。

这个 interface 的提炼就涉及到抽象能力了,抽象就是去掉不重要的细节,留下最核心的本质。比如一个文件系统,不需要在 interface 里把文件存储的 block 等细节暴露出来,这样会增加使用者的负担,也会增加复杂度(将来如果换了一种实现,就要修改接口了)。

作者在视频中举了一个 Unix file I/O 的例子

封装就是信息隐藏,将具体怎么实现的都放到 implementation 里,使用方不需要关心,甚至可以随时更换实现,这样即使内部很复杂,因为没有上游依赖,所以不会将复杂度扩散出去。

其他降低复杂度的方式:优雅地设计错误

只要不符合预期就抛一个 Exception,这是最简单的处理,但对使用方可能不太友好,比如取数组的 Range 时,Index 超过长度抛一个 Exception;文件在使用时请求删除抛一个 Exception;参数校验不通过抛一个 Exception。如果多为使用方想一下,这些 Exception 都是必须要抛的么?有没有可能通过改变语义或设计来避免 Error?

作者举了一个 unset 的例子,一个开始他把 unset 定义为:移除一个变量,这样如果传入的变量不存在就要抛异常,这样导致外面使用方要通过 try catch 的方式去使用这个方法,后来他把 unset 定义为:让一个变量不可用,如果传入的变量已经不可用了,那就不需要做处理。对比 Windows 和 Unix 对文件删除的不同处理方式(后者在运行时删除不抛出 error),也可以达到减少 Error 的效果。


写一个程序,输出从 1 到 n 数字的字符串表示。

  1. 如果 n 是3的倍数,输出“Fizz”;
  2. 如果 n 是5的倍数,输出“Buzz”;
  3. 如果 n 同时是3和5的倍数,输出 “FizzBuzz”。

如果采用「战术编程」的话,很快就能写出一段可以 work 的代码

fizzbuzz = (n) => {
  for (let i = 1; i < n; i++) {
    if (i % 5 == 0 && i % 3 == 0) {
      console.log("FizzBuzz")
    }
    else if (i % 3 == 0) {
      console.log("Fizz")
    }
    else if (i % 5 == 0) {
      console.log("Buzz")
    } else {
      console.log(i)
    }
  }
}

fizzbuzz(100)

完全符合题意,也能正确运行,但调整空间太小了

  • 如果条件变了,不是 5 而是 7 怎么办
  • 如果不是输出 Fizz,Buzz 怎么办

考虑到这两点,我们再来调整下代码:

fizzbuzz_v2 = (n, triggers) => {
  for (let i = 1; i < n; i++) {
    let result = ""
    triggers.forEach(trigger => {
      result += i % trigger.divisor == 0 ? trigger.text : ""
    });
    console.log(result ? result : i)
  }
}

fizzbuzz_v2(100, [{ text: 'Fizz', divisor: 3 }, { text: 'Buzz', divisor: 5 }])

将变量抽了出来,这样将来调整时,不需要动实现,只需改参数即可。

如果又来了个新需求:小于 10 的都要在前面补0。如果求快,采用战术编程的话,直接在方法内部加入这个判断分支即可。但可以有更好的解法,比如将计算逻辑放到外面,方法内部只需要判断计算结果即可。

fizzbuzz_v3 = (n, triggers) => {
  for (let i = 1; i < n; i++) {
    let result = ""
    triggers.forEach(trigger => {
      result += trigger.predicate(i) ? trigger.text : ""
    });
    console.log(result ? result : i)
  }
}

fizzbuzz_v3(100, [
  { text: '0', predicate: (i) => i < 10 },
  { text: 'Fizz', predicate: (i) => i % 3 == 0 },
  { text: 'Buzz', predicate: (i) => i % 5 == 0 },
])

接下来还可以有更多的演变,比如要输出到文件而不是 console,要具备可测性等等。可以看到一个简单的需求,战术编程和战略编程会带来很大的差异。这种思维和能力上的转变对于写出优雅的代码也会有帮助。

Leave a Comment?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK