写程序和写小说的区别

周末去上健身课,教练说我坐太多了,深层肌群在萎缩,虽然肌肉量够,但不平衡类的动作缺乏肌肉支撑,“最好每个小时起来做一下‘最伟大拉伸’(Forward Lunge Elbow to Instep)),才能避免这种情况恶化下去”。我说这个太难做到了,我写程序或者做设计的时候,有人靠近我我都会觉得受到威胁,想打人。更别说什么一个小时起来一下了。

教练就问,写程序到底在干什么?为什么要求这么高?

我想了一下,这样解释:写程序就像写小说,想好一个个情节怎么发生,然后把他们描述出来。但程序和小说的主要区别是:写程序的情节需要考虑墨菲定律——如果一件事情有可能发生,就一定会发生。

比如,你每天拿把刀在手上玩,刮伤自己的手的可能性是千分之一(每天)。那么一个月这个可能性就是3%,一年就是30%,十年就是97%,考虑100个人,这个事情发生的可能性就是100%。

写小说呢,对程序来,就是写test to pass。只写关键流程,比如你会这样写:

刻不容缓,金大侠飞身而起,腰间宝剑已到了手上,向前一挥,击落两枚金钱镖,顺势抢到悬崖边,一手抱住女婴,另一手挺剑急刺,堪堪挂在了崖边的迎客松上。

写程序呢,这样写就不行了。程序你得这样写:

刻不容缓,金大侠飞身而起,腰间宝剑已到了手上,向前一挥,此处有三种可能:
1. 击落两枚金钱镖
2. 只击落其中一枚金钱镖,但金大侠已经穿了金蚕衣,所以无妨
3. 其他未考虑之情形,金大侠卒,全书完
若为前述1、2之情形,则金大侠顺势抢到悬崖边,此处有两种情形:
1. 金大侠没站稳,掉下悬崖,卒,全书完
2. 金大侠站稳了,同时,本文设定,女婴被扔出到金大侠出发,有2秒的时延,金大侠出手时间根据经验应为1秒左右,有一秒的保险期,故女婴仍可被抱住,如果本条件不成立,全书完
承前,金大侠一手抱住女婴,另一手挺剑急刺,堪堪挂在了崖边的迎客松上。(为保证金大侠必然能挂住,本书场务已提前检查迎客松和使用之宝剑,并进行加固,按可承载500公斤进行过验收,所以,此处假定,金大侠必然可以挂于树上)

写程序大部分时候是写状态机,所谓“面面俱到”,每个状态都对所有可能输入有反馈,这才是正经程序的一般写法。(注1)

当然,读者应该可以看出,所谓面面俱到,是一个灰度问题,因为任何时候都有其他意外,我们这里只是在讨论一个“度”的问题。有一段时间,我看到有团队追求所谓“防御式编程”,他们定下类似这样的弟子规式的要求:所以变量都必须初始化。

他们觉得这样就“防御”了,所以,即使这样的代码:

def foo(v):
  int a       #这不是纯Python,我做个比喻而已
  a = get_a(v)
  ...

他们也觉得这个a必须初始化。或者v在foo中已经检查过范围了,到了get_a()还要再检查一次。我经常讽刺这些人:你们要不要这样写代码?:

def foo(v):
  int a = 0
  if a != 0:  #double check a 是不是初始化成功了
    raise Exception("太阳黑子运动过于强烈,请稍后再运行一次")
    raise Exception("可能硬件系统受到激烈电磁冲击,前一个raise流程没有起作用,请保存您的数据,然后念经求上帝保佑")
    print("Mayday, Mayday,we are under attack...")
    sys.exit(-1)
  a = get_a(v)
  ...

这它么神经病么,对不对?

说远了,回到主题:小说和程序的核心区别,程序需要运行在所有的变化上,要面对墨菲定律的挑战。你一个程序要在线运行几个月,乃至几年,可能有数百万的运行实例。任何一个小概率的事情,都可以发生,你写下的每个逻辑,都要推演它的状态机,把所有的异常流程都推演出来,然后基于这个来组织你的“文字”,这个就是程序的难处。所以,程序一点都不好看,这和小说没法比。但程序比小说实用。为此,他要付出额外的脑力。

国内自己建立的软件架构少,很多工程师只是使用别人定义好的架构,对这个问题感受不强烈。所以他们常常习惯这样一种模式:先把主流程写出来,然后开始测试,补漏洞,测出一个补一个,补着补着,大部分漏洞都看不见了。就觉得可以出厂了。

这种方法看着是种最佳实践,但实际上是靠向写小说,建不出架构来。

因为架构是无法测试的。你写一个程序,要在你的PC上运行,这很容易,但要在所有的PC系统上运行,这就难了。要在一种配置上运行容易,在各种配置组合上运行,这也更难了。架构情形无法穷举,不能靠测试覆盖,面对的组合是无穷的,我们只能用脑子去运行它。要用我们的脑子代替电脑,这种情况下,打断了,就是重新开始。我们在这个过程中用了大量的人脑的Cache,如果Reset一下,尽管外存还有这些数据,但要重新load回内部Cache里,要花大量的时间。

加架构级别的功能,基本上问题都不在功能,而在于怎么和过去的逻辑自恰。比如你做一个类似DPDK的框架,可以在用户态直接访问硬件,这直接看呢,可以提高效率。做这样的功能,你在Linux会面对什么挑战呢?

功能肯定会做出来的,但你如何解决这些问题呢?:

  1. 你需要让硬件访问DMA内存,这是Linux内存管理之外的行为,Linux通过rlimit已经限制进程可以允许的内存总量了,你这里开了一个口子,如何保证原来的承诺?
  2. Linux承诺用户进程不能访问内核内存,你如何保证你的用户程序不会通过网卡作为跳板直接访问内核的内存?
  3. Linux的cgroup和name space已经保证被加入对应组中的进程只能访问所分配的调度资源,如何保证你这个进程不会通过网络越过这个限制?
  4. Linux禁止没有CAP_NET_RAW权限的用户进程直接发RAW包,你把网口直接暴露出去,如何维持这个语义?
  5. 你的硬件在做DMA的流程的时候,对应的内存被Swap线程交换到磁盘上,你怎么保证两个流程的同步?
  6. ……

你看,加功能的难度已经完全不在那个基本功能上了,而在于这个新功能怎么加进去以后不破坏已经存在的那些旧功能,而且你怎么保证你知道那些旧功能?我肯定连Linus自己都搞不清楚内核已经对外做了多少承诺了。

我看过太多人在企业内部得意洋洋宣传自己的功能如何“核心竞争力”,如何比开源代码快,如何不能免费送给开源社区——你也不看看你那玩意儿人家收不收。

你只是破坏掉一堆的特性,取得一点点优势,这东西能在多大范围内用呢?你在消耗架构高度,解决一个具体的问题而已,离“写软件”还远得很呐。

这种问题,越往下层走越明显(因为依赖多)。比如老有人跟我说你们的芯片要加这个指令那个指令的,他们觉得加一个指令就是加这个指令了,最多就是把两个寄存器处理成第三个寄存器,或者把内存里面的某个数据修改修改。也不想想就算你就加一条简单的vector_load指令(下面简称vld),你都要考虑一下:这会放开st.ex的锁吗?这个vld的结果什么时候对所有的核可见?这个vld对IO Region的行为和内存有什么不同?它作用在两个Region的边界上会怎么样?目标地址不对齐又会怎么样,这个vld在什么权限下可用?你的向量加载和大部分应用的行列模型是否一致?为了做这个指令增加的ALU或者MEM单元的利用率有多高?……你觉得加条指令只是保证这条指令运行就行了?

但这也恰恰说明了,为什么国内很难做架构。架构这种东西啊,就和可靠性、安全性、可扩展性这些东西一样,缺乏真心做事的人很难做成的。你辛辛苦苦做一个安全特性出来,只要还没有发生安全事故,所有人都只会认为这是负担。当发生安全事故了,也不会说你好,他们会说:“问题都出来了,说这些风凉话有什么意思,赶紧一起解决问题啊。”问题是,留下漏洞是个已成的事实,产品已经卖得满天都是,说解决就可以解决的?

架构一样的,架构的所有目的,是为了加功能,加代码的可行性。到加不了的时候,他们就会说:“问题都出来了……巴拉巴拉”,构架乱都乱了,从一开始加功能的时候,逻辑就是互相冲突的,现在说要解决,除了重新开始,还能怎样?看看现在这个Cache侧信道问题,都被修成啥模样了,有人敢说都修掉了吗?这个是你做OoO的时候把Cache当做透明的天生制造的问题,OoO的好处你都宣传出去了,现在就没有什么办法好吧。只能有什么问题补什么问题喽。

更大的问题是,就算你重视架构,这东西又没有样子看,谁知道会不会白干了半天,一样架构不好呢?

这真是一件困难的事情,但无论如何,至少你得明白这是件什么事情,不要那么多“显然”好吧(参考:in nek:狂人日记读后感——名称空间囚笼

最后,总有人认为所有东西都需要有一个结尾,觉得我没有点题,我就写个结尾吧:世间安得两全法,不负如来不负卿?


[注1] 这个例子其实也说明了“设计文档”和“代码”有什么不同。

金大侠飞身而起,……,抢到悬崖边,……接住女婴。

这是代码,这是最终的呈现。设计文档是什么呢?设计文档是这样的:

令女婴被人抛出为时刻t1,抛出的初始速度范围为[n1..n2],然则其彻底离开悬崖之间的距离可算得为[m1..m2];
另金大侠预判女婴的时刻为t2,t2-t1=DeltaT,侧金大侠剩余时间为t3;
……
由此可得,金大侠的初始加速度只要达到a,即可满足情节要求。

这叫“设计文档”,设计文档不是代码的粘贴复制,设计文档是一个独立的分析逻辑,是代码之外的其他逻辑链,用于保护代码的可靠性的和演进性的。那些总要等写完代码才能写出设计文档的人,就没有搞清楚怎么写程序。他们以为写程序是写散文呢。

编辑于 2019-12-02

文章被以下专栏收录