仓库源文

.. Kenneth Lee 版权所有 2017-2020

:Authors: Kenneth Lee :Version: 1.0

从学习assert的用法开始理解如何写“专业的程序”


和不理解什么是基于语义进行编程的人是说不清楚assert应该怎么用的,所以,在这这一 篇前,我先写了这一篇:基于“语义”编程。

assert()的语义是什么呢?它是“断言”。“我断定它是这样的”,就用断言。“Kenneth是个 好人”,这是断言,“a必然等于4”,这也是断言。

断言在解决问题上是无用的东西,因为它不进入决策链,比如“巧克力很好吃”,这是断言 ,不产生决策,“但我现在吃饭”,这才是决策,对程序来说,大部分时候我们关心这个, 而不是巧克力好不好吃。

但断言在交流上是有用的东西,它可以让我们很快知道双方是否有分歧,从而判断是否进 入细致逻辑。比如这个论文: https://pdos.csail.mit.edu/6.828/2017/readings/linux-lock.pdf,它断言成功获取锁 等待时间是c*k/2,如果我的硬件构架和这个断言不一致,这个论文的整个结论就对我不可 靠,我就可以不看下去。

从这里就可以看出,断言的主要作用是“声称什么东西在作者的考虑范围内,什么东西不在 作者的考虑范围内”。这就是为什么我要用断言来说明怎么写“专业的程序”,“专业的程序” 要求“算无遗着”,但严格来说,“算无遗着”是不可能的,太阳黑子变化,可以导致你某个 变量的一个bit发生跳变,你每次访问这个变量的时候都去校验一下吗?

所以,断言的作用在于在一个“代码结构”之内“算无遗着”,比如我有如下代码结构:::

    int a[SZ_T];
    for(i=0; i<data_num; i++)
            a[i] = 1/a[i];

这个代码目之所及,有很多不可靠的地方,如果我们加上断言,它就可靠了:::

    int a[SZ];
    assert(data_num<SZ);
    for(i=0; i<data_num; i++) {
            assert(a[i]!=0);
            a[i] = 1/a[i];
    }

有人会争辩说,这个地方应该用实际的代码去检查。如果真的需要,这个我不反对。但出 于性能的考虑,你不可能到处都进行多余的检查,在你做完所有那些检查后,我们回到这 个问题,你的每个“代码结构”是否无懈可击?——很可能,再加上断言之前,不是。那么, 什么地方应该加断言,这个就不用我说了。

理解断言,核心问题不在于断言的用途本身,而是“在语义上算无遗着”这个理念。

接着我们要谈断言带来的第二个编程理念:一切皆在预期之内。做可靠性的人都会很关心 两个概念,所谓的Failure和Error(不是每个语言空间都用同一个词表达我这里要表达的 意思,但概念都是存在的)。Failure是系统的输出不符合预期,Error是导致输出不符合 预期的那个设计。用户在乎前者,程序员在乎后者。因为多个Error综合,可以导致 Failure不成为Failure,但这个不是必然。用户关心他当时使用的Snapshot,程序关心的 所有有效时间和空间上的SnapShot。所以我们需要程序在可能的时候,永远都在计算的范 围之内。这是断言的第二个作用,大量多余的,在一个个独立的代码结构范围内保证计算 条件的断言,就可以有效控制,整个执行流一直在预期的计算范围内,程序出问题的可能 性就比不控制这个要小得多。

这个策略可以引申,我在写高性能程序的时候,不但大量使用断言,还大量使用统计,比 如一个网卡,发送接收的数据量,包数,不同长度包的分布,不同分段的分布,各种不同 类型的错误流程进入的次数。你要掌控一个系统,就要能保证它的全部运作在你的“设想之 内”,这样你所有的升级,优化,质量控制,才会有根本。

最后,我们还需要注意“语义”可以控制的度。有人觉得我这个代码写错了:::

    int main(int argc, char *argv[]) {
            assert(argc==2);
            printf("you give me %s", argv[1]);
            return EXIT_SUCCESS;
    }

他们会问,如果我调用这个程序的时候,没有给参数怎么办?

问这个问题,说明你还是不明白什么叫基于“基于语义”编程。这里的语义是:我认为你用 的时候要保证有且仅有一个参数,否则结果——无定义。这就是这段代码表述的语义——谁规 定一个程序实现必须满足同一个固定模式的?需求是要在一个整体构架中定义的啊。

这个问题看起来和我们这里讨论的主题关系不大,但很多具体策略用不起来,都是因为人 们过度引申它。所以我还是要强调,算无遗着,是在设计范围内的,没有人让你无限引申 。这就好比有些人要不完全不写设计文档,要不写得像代码一样详细(最后坚持不下去又 退化为不写设计文档),这都是不过脑子,死记形式导致的。如果你用Assert也是忘记目 的,追求形式,最终的结果也是一样的。

补充1:我知道很多人——正如在讨论中呈现的那样——把assert看作是“调试版本”的一种检查 工具。这是从结果,或者说“能跑”上来想这个问题的,而不是从语义上来想这个问题的。 首先,我们要从“意思”上理解,并没有两个版本,调试版本不是版本,调试版本成为一个 版本是因为你把中间过程转义了,你被当前事实带偏(把调试版本拿来交付)了。原始的 语义是:我将会交付正式的版本,而正式的版本中,Assert是无效的。如果作为一个自恰 的逻辑空间,所有我对产品的预期,都在正是版本中生效,如果我“意图”让程序的命令行 输入有检查,那么,就应该在没有Assert代码的情况下,这种检查就是有效的。然后我采 用Assert增加对代码逻辑和设计的“设计意图”限制,那个不是逻辑判断的一部分,而是“维 护工程师间交流”的一部分,是保证逻辑工程师明白前一个维护者在这一个“代码结构”范围 内,是如何设计这个逻辑的。这才是assert的本意。但当然“本意”这种东西,看谁解释, 你非要说你assert的本意如何如何,我无所谓,但基本上我不信。

其实,我还想问一句,各位强调“调试版本”如何如何的,是不是从来不做单元测试的?

补充2:我再深入一点谈所谓“语义”和“能跑”的区别问题:语义就是“我的期望”,是需求人 可以达至的最好描述形态。“能跑”是逻辑上的一个解决方案。“我想天天去玩,又想当学霸 ,每天在班花面前装霸道总裁”,这就是一种“需求”,是intention,是目的,逻辑上通不 通,事实上通不通,可不可行,别人喜不喜欢,这不重要,因为这确实是我的原始需求。 这就是那个需求的“语义”,程序要把语义变成能跑,但一旦变成能跑,它就会丢失原始需 求。我们解决问题是希望最大限度去贴近需求,而且在面对变化的时候还是能跑。所以, 我们最终希望达至的“最大限度接近需求”,这时我们对程序的组织就不仅仅受限于某个范 围的“能跑”,而还在于在程序表述上还接近我设计时的intention。所以,把我上面的表述 理解为Fast Die或者认为Assert作用于“调试版本”,这些想法在结果上是对的,但不符合 我描述的intention。在我的intension中,我首先关心的不是程序是否Fast Die,也不是 调试版本能打印出点什么东西来。我首先关心的是:我对你声称了,我在设计这个逻辑的 时候,是认为什么情况不会发生的。如果你认为那个不是你的intention,我们可以对齐, 但那个确实就是我在设计这段代码的时候的Intention,这个表述本身,才是重要的。

补充3:对补充2再说两句:Assert是“断言”,不是代码,我重视的是它的“断言”价值,不 是代码价值。它不产生流程。在调试版本中报错,退出程序,都是附加出来的,就算在调 试版本中,断言只是个空操作,它也具有价值。你认为它是个注释都行。中国文化讲究虚 心实腹,导致我们很多工程师在战术上都是“看实际情况”,而不是看“表述的语义”。所以 才导致各种潜规则倍增。我们的文化太习惯于“你说是这样的,实际是怎样的?”,我们过 度谈“实际”了,反而变成了不在乎“理想”了。但谈设计或者架构,就已经不是谈战术操作 的问题啦。