计算机中的权衡(trade-off)

太极

计算机中一切都是权衡(trade-off)。所谓权衡,即在两个极端中取一个相对中间的位置,不是非黑即白,而是根据实际情况作出取舍, 也许偏向A多一点,从而偏向B少一点,也许是反过来。

A <------------------------> B
   \                      /
          trade-off

我们来聊几个常见的例子。

(关系型)数据库范式和反范式

而常见的两个极端,就是范式和反范式。所谓范式,就是一定的套路,是一种大家都遵循的行为准则,有时候业界也会称之为最佳实践。 反范式,就是不按套路出牌,反其道行之。如果你是工作了几年的人,细心回想一下,就会发现日常工作中到处都是范式和反范式, 同时到处都是权衡(trade-off)。

数据库设计中的范式和反范式我想大家都应该有所耳闻(可以参考此处)。

如果你仔细读一下 3NF 就会发现,它是冲着数据的低冗余去的,首先通过1NF把数据拆散成最小可拆分粒度,然后通过2NF确定唯一性, 通过3NF消除冗余。加入我们遵守3NF,实际设计中,就会发现为了消除冗余,数据库需要大量使用外键,而外键有这么一个特点,每次 修改数据时,都会去检查另一张表中的数据,从而确保外键的约束。对于传统的数据库使用来说,这没有什么太大的问题,然而放在 互联网中,一旦并发上上来(虽然大部分所谓的互联网公司仍然达不到这个量),外键将迅速成为数据库性能瓶颈。因此,常见的互联网 公司设计使用中,一般都会使用反范式,即主动冗余一份数据到所需要的,并发非常高的表中(更常见的做法是加缓存,但是不在此文 范畴内)。

而至于实际用途中,遵守多少范式,又遵守多少反范式,是一个度的问题,我们需要根据实际情况进行权衡取舍。

常量

经常在代码中看到这么两种设计:

return c.JSON(http.StatusOK, {})

或者是

return c.JSON(200, {})

我们大家都知道,RESTful中会使用HTTP状态码来表示一些返回状态,例如200就是成功,400就是参数错误等等,而我们几乎所有人都 听说过,代码需要高内聚,低耦合,不要使用魔数(magic number)等等,因此很多情况下我们会使用 http.StatusOK 来表示200, 仔细想想,其实这样做有两个坏处,那就是所有地方都要导入定义 StatusOK 这个常量的模块;阅读并理解 StatusOK 的速度没有 200快,而且实际情况中我们常用的80%状态码就那么几个:200, 301, 302, 400, 401, 404, 405等。

从另一方面,当你遇到不熟悉的状态码时,文字的含义和可读性比数字高,例如 402 到底是什么意思?如果使用 StatusPaymentRequired 就会好很多。不过由于团队中并不是所有人都能记住,所以综合下来使用 http.StatusOK 是一个更好的 选择,如果团队能够达成一致,那么无论哪个选择都是OK的。

再例如之前我读Go的源码时,发现源码中到处都是使用 “linux”, “windows”, “drawin” 三个字符串,而不是抽出来成为const, 我就去提了个 issue 问作者们为啥不抽成常量然后导入。

作者的回答很有道理,因为大家都懂这几个词的意思,他们其实也是常量。而导入一个新的模块更加麻烦,这也是一种取舍。

抽象泄漏(abstraction leaky)

抽象,这是CS的代名词。无论是语言,还是数据,这些年来,我们都是冲着抽象一步一步发展。正所谓,计算机中没有什么事情是加一个 中间层解决不了的,如果有,那就加两个。举个例子,我们从最开始的汇编语言,为了屏蔽各种汇编语言的细节,我们提供了高级语言 这层抽象。在高级语言中,为了屏蔽多个语句的实现细节,我们提供了函数这层抽象。

完全不抽象(暴露所有实现细节)和过度抽象正是我们最上面所画的A和B两个极端。完全不抽象的代码,会导致非常低的可读性,以为 它会把我们带入细节的深渊,试想,你无法了解一个系统的全局,怎么可能全面掌握它呢?

而过度抽象则是另外一个反面,每一次抽象都意味着细节的丢失,过度抽象之后,你只知道一个全貌,却无法了解细节,会导致自己 陷入一个似懂非懂的状态,例如当你阅读Go代码时,碰到一个接口(interface),但是你无法找到具体是哪一个实现,这里你就丢失了 很大一块细节,为了掌握整个系统,你需要了解实现细节,但是现在细节却丢失了。

从细节到抽象,和从抽象到细节,其实就是思维过程中,自底向上和自顶向下的两种方式,我们要掌握这两种方式,并且来回切换,才能 更快更好的了解一个东西,自顶向下帮助你掌握轮廓,自底向上帮助你掌握细节,同时运用两种方式才能让你真正的掌握一个系统。

说回正题,抽象泄漏,就是想要做到完美的抽象,却发现做不到,比如ORM,想要屏蔽所有数据库的实现细节,但却发现为了使用数据库 独有的功能,又必须在公有组件上加上每种数据库独有的功能支持。

函数到底拆多细

这是最近和群友们讨论的一个话题(进技术交流群见右侧二维码),函数到底拆多细?如果你感受过一个函数几百行,翻几屏都翻不完的 那种恐惧,大概率以后你自己再也不会写那样的代码了。

而当你见过另一种极端,几乎每一行代码都被拆散成一个函数时,你会了解到另一种痛苦。

人类的思维过程是比较适合一溜顺的,也就是说,你的思维能从头到尾不打断,而不是在各种环境中切换来切换去跳来跳去的。一个 函数中有很多代码时,符合这一点,你可以从头看到尾,然而它有一个很严重的问题,可维护性非常低,并且当长度达到一定程度时, 你需要记忆的上下文(例如前面定义的变量、内部函数等等)变得越来越多,会达到一个点,超过这个点之后,非常难理解这份代码。

拆的过细的函数会导致另一个问题,每次遇到一个函数时,相当于在你的思维里打一个断点,告诉自己,这里需要执行另一个分支。 同样当函数多到一定程度时,就会达到你的人脑上下文内存上限。

所以函数到底拆多细,怎么拆,这个问题是非常主观的一个问题,但是可以肯定的是,它是一种取舍。

这里得提一句,一个好的函数名可以减少这种痛苦。

总结

计算机中,到处都是权衡取舍。


更多文章
  • SQLite3 存储以及ACID原理
  • Redis源码阅读:pub/sub实现
  • Redis源码阅读:zset实现
  • Redis源码阅读:bitmap 位图的运算
  • Redis源码阅读:set是怎么做交并集运算的?
  • Redis源码阅读:list实现(ziplist, quicklist)
  • Redis源码阅读:RDB是怎么实现的
  • Redis源码阅读:AOF重写
  • Redis源码阅读:AOF持久化
  • Redis源码阅读:key是怎么过期的
  • Redis源码阅读:字典是怎么实现的
  • Redis源码阅读:执行命令
  • Redis源码阅读:启动过程
  • WAL(Write-ahead logging)的套路
  • 搞定CORS问题