2022-7-2 23:04 /
(John Carmack 在2007年给一个程序员邮件列表发了一封邮件,而开始这里是他在2014年对此添加的评论。)

写这篇文章后的几年里,我越来越看好纯函数式编程,甚至在 C/C++ 中合理的情况下也看好:链接

内联解决的真正敌人是意料之外的依赖和状态改变,而函数式编程更彻底地解决了这个问题。然而,如果你要改变大量的状态,把这些代码全部内联确实有好处;你应该一直意识到你所做之事的全部可怕之处。当内联太多不好处理的时候,想办法把代码块重构成纯函数(而且不要让它们变回非纯函数!)。

作为对那篇文章观点的证明,几年后我在做《毁灭战士3:BFG 版》时,和预测中完全相符的一帧输入延迟出现了,几乎就发行了。那是个冷汗直流的时刻:对延迟和响应性唠叨那么多之后,我差点发行了带不必要一帧延迟的游戏。

复杂的是,那个总是执行然后阻断或忽略的策略,虽然对高可靠系统来说很好,但对像移动设备这样有功耗和散热限制的环境不太合适。

John Carmack  
2014年9月26日

---

来自 John Carmack johnc@idsoftware.com  
日期: 2007年3月13日,星期二,下午4:17  
主题:内联代码

这会是封不寻常的邮件——我想谈谈代码风格。我不打算强制别人,但我希望每个人都能认真考虑这里面的一些问题。我不是在说次要的事情,比如运算符周围加空格、大括号风格、或指针是放到类型还是变量旁(尽管可能该把这个解决了),而是更大的组织代码的风格。虽说没有什么灵丹妙药,但只要提高几个百分点的生产力,就可以在一个项目中省去几个月的时间。

这封邮件已经太长了,所以我打算稍后再跟上一些其他的想法。我有很多整体上的事情要讨论,但有一个具体的方向是我想倡导的。

一年前,我参与了为航空航天编写极其可靠软件的一个讨论。几年来我多次参与这样的讨论。通常我会在那抨击那些讨论使用线程和 RTOS 的人,因为一个像原始电子游戏的简单轮询循环要清晰和有效得多。然而,下面这个特定的讨论给我带来了一个新的想法:
的确,如果没记错的话(离我读到这个已经过了好久了)...

Saab 的 Gripen(一种轻型战斗机)的飞控软件更进一步。它不允许子程序调用和逆向分支,在主循环底部的那个分支除外。控制流只往前走。有时,一段代码不得不为后面的代码留下说明,告诉它该怎么做,但这对测试来说很好。所有的数据都是静态分配的,监控这些变量可以清楚地了解软件在做的绝大部分事情。这个软件只做最基本的事,当然,他们认真做了彻底的陆地测试。  

该代码的“飞行用”版本中从未发现任何 bug。

Henry Spencer  
henry@spsystems.net
不过,航空航天工业里的很大一部分不应该被任何人模仿,这些部分往往是自我毁灭性的。关于航天飞机软件开发过程的各种流行文章,你们中不少人可能都读过。尽管有人可能认为如果所有软件开发者都那么“小心”,世界会变得更好,但事实是,如果一切都以这种蜗牛的速度开发,我们会比现在落后几十年,没有 PC 也没有公共互联网。这个特定的轶事似乎有些实际价值,所以我决定试试。Armadillo 火箭的飞控代码只有几千行,所以我选了主 tic 函数并开始内联所有的子程序。虽然不能说我发现了一个可能会导致(字面意义上...)崩溃(译注:crash / 坠毁)的隐藏 bug,但我确实发现了几个被多次设置的变量,几个看起来有点可疑的控制流,而最终的代码变得更小、更干净。

使用这种风格的代码一段时间后,我没发现什么缺点,而在 Id Software 的代码中我已经开始应用这种大致的方法。在很多地方可以选择几种组织代码的方式:

风格 A:

void MinorFunction1( void ) {
}

void MinorFunction2( void ) {
}

void MinorFunction3( void ) {
}

void MajorFunction( void ) {
        MinorFunction1();
        MinorFunction2();
        MinorFunction3();
}


风格 B:

void MajorFunction( void ) {
        MinorFunction1();
        MinorFunction2();
        MinorFunction3();
}

void MinorFunction1( void ) {
}

void MinorFunction2( void ) {
}

void MinorFunction3( void ) {
}


风格 C:

void MajorFunction( void ) {
        // MinorFunction1

        // MinorFunction2

        // MinorFunction3
}


我之前使用“风格 A”以便完全不写函数原型,不过有些人喜欢“风格B”。两者之间的区别不重要。Michael Abrash 曾用“风格 C”写代码,而我记得有把他的代码转成了“风格 A”,以提高可读性。现在,我认为“风格 C”有一些明确的优势,但它们是面向开发过程的,而不是离散的、可量化的东西,而且它们与很多公认的传统观点背道而驰,所以我要试着明确说明一下。这不是什么教条,但值得考虑一下它在哪里合适。

我绝不是在说单纯避免函数调用可以直接提高性能。

我隔一段时间会做的一个练习是在游戏中“步进一帧”,从一些主要的位置开始,比如 common->Frame(),game->Frame(),或者 renderer->EndFrame(),然后步进到每个函数,尝试走完完全的代码覆盖。通常远远在走到帧尾之前,这个过程就会变得令人抑郁地长。对所有实际在执行的代码都有认知很重要,你在调试时很容易总是跳过某些很大的代码块,尽管它们对性能和稳定性有影响。

C++ 对这个目的不怎么友好,它有运算符重载、隐式构造器等等。很多以灵活性为名做的事都有些误入歧途,是很多开发问题的根源。与内容创作应用或交易处理相比,游戏因为其不断循环的实时 tic 结构,也有一些目标和限制,鼓励采用不一样的编程风格。

如果某件事情每帧要做一次,那么让它发生在帧循环的最外层是有些价值的,而不是让它深埋在一些可能因为某些原因被跳过的函数链中。例如,我们的 usercmd_t 生成代码被埋在 asyncServer 之内,而它真的应该在主循环里。与此相关的是一个硬件设计对比软件设计的话题——通常情况下,先进行一个操作,然后选择阻断或者忽略部分或全部结果,比尝试条件性执行操作要好。usercmd_t 的生成代码和这个话题有关,其中 usercmd_t 只在“需要”的时候生成。游戏中 bindset 交互的一些麻烦问题也和这个有关。

我们测量性能和优化游戏的传统方式鼓励大量的条件性操作——认识到一个特定的操作不需要在运行状态的某些子集中完成,并跳过它。这可以提供更好的帧时间数字,但会产生大量的 bug,因为跳过昂贵的操作通常也跳过了一些其他地方需要的状态更新。

显然我们还是有一些性能密集型任务需要优化,但这种优化风格被理所当然地应用,很多情况下性能上的好处可以忽略不计,我们还是要吃这些 bug 的苦果。现在我们已经决定了做一个 60Hz 的游戏,最坏性能比平均性能更重要,所以变化大的性能更应该遭鄙夷。

当操作被深深地嵌套在各种子系统中时,很容易让几帧延迟溜进来,而且这些事情会随着时间的推移发展。这可以藏在几乎无法察觉的输入质量下降中,也可以非常明显地表现为模型在移动过程中拖着个连接点。如果所有的东西都在两千行的函数里跑完了,那么哪个部分先发生是很明显的,而你可以非常确定后面的部分会在帧渲染之前被执行。

除了可以对实际执行的代码有认知外,把函数内联还有一个好处,就是这样就不可能从其他地方调用了。这听起来很荒唐,但是是有其道理的。随着代码库在多年使用中不断增长,会有很多机会走捷径直接调用一个函数,只做你认为需要做的工作。可能有一个 FullUpdate() 函数调用 PartialUpdateA() 和 PartialUpdateB(),但在某些特殊情况下,你可能意识到(或觉得)你只需要做 PartialUpdateB(),而避免了其他工作提高了性能。很多很多的 bug 都源于此。大多数 bug 都是运行状态与你所想的不完全一样所造成的。

严格意义上的纯函数只读取输入参数返回一个值,而不检查或修改任何永久的状态。这样的函数对于这类错误是安全的,而能形式化地谈论它们使它们成为一个很好的象牙塔话题,但我们真正的代码很少属于这个类别。我不认为用纯函数式编程写大型项目很实际,因为它使代码非常晦涩难懂且效率相当低下,但如果一个函数只引用了一两个全局状态,考虑将其作为变量传入大概是明智的。如果 C 语言有一个“functional”关键字来强制不能做全局引用就好了。

const 参数和 const 函数有助于避免与副作用有关的错误,但这些函数仍然容易受到全局运行环境变化的影响。试着让更多的参数和函数使用 const 是一个很好的练习,但最后往往因为沮丧感而把他们转型了。这种沮丧感通常是由于发现了各种不明显的可以修改状态的地方——正是 bug 滋生的地方。

C++ 对象方法可以认为是几乎函数式的,在返回时有一个隐含的覆盖赋值,但是对于包含大量变量的大型对象,你并不清楚方法修改了什么,同样,也不能保证那个函数没有跑出去做一些全局的可怕事情,比如解析一个声明。

最不可能引起问题的函数是不存在的函数,这就是内联的好处。如果一个函数只在一个地方被调用,这个决定就相当简单了。

几乎所有的情况下,代码重复都比函数在不同情况下被调用所产生的第二阶问题更坏,所以我很少会主张通过重复代码来避免一个函数,但在很多情况下,你仍然可以通过 flag 一个操作只在恰当控制的时间点执行来避免这个函数。例如,在玩家思考代码中对 health <= 0 && !killed 进行一个检查,几乎可以肯定比在20个不同的地方调用 KillPlayer() 产生的 bug 少。

关于代码重复的问题,我跟踪了我一段时间内修复的所有(在认为一切工作正常后出现的) bug,我对复制-粘贴-修改的操作造成不明显 bug 的频率感到相当惊讶。对于三或四个东西上的向量小运算,我经常这样直接粘贴然后修改几个字符:


v[0] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+0));
v[1] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+1));
v[2] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+2));
v[3] = HF_MANTISSA(*(halfFloat_t *)((byte *)data + i*bytePitch+j*8+3));


我现在强烈建议所有事情都采用显式的循环,并希望编译器能正确地展开它。因为相当多的 bug 与这种事情有关,我现在甚至在重新考虑二维的情况,我通常使用离散的 _X、_Y 或 _WIDTH、_HEIGHT 变量。我发现这比两个元素的数组更容易阅读,但很难用我的数据来反驳它让我搞砸的频率。一些实际建议——

在主要函数内使用大的注释块来分开次要函数是一个好主意,可以快速概览。而将次要函数包在一对大括号内通常会很有用,这样可以限定局部变量的范围,并让编辑器可以对这部分进行折叠。我知道有些经验法则说不要让函数大于一两页,但我现在不同意这一点——如果很多操作应该按顺序发生,它们的代码就应该遵循同样的顺序。

在条件语句或循环语句中的几页代码确实有可读性和可认知性上的缺陷,所以把这些代码留在一个单独的函数中可能仍然是合理的,但在某些情况下,仍然可以把代码移到另一个执行不会有条件的地方,或者一直执行它,用一个非常小的条件代码块以某种方式把结果阻断掉。执行-阻断的风格通常需要更多的绝对时间,但它减少了帧时间的变化,并消除了一类 bug。

内联代码很快就会与模块化和 OOP 保护相冲突,而我们必须做良好的判断。模块化的全部意义在于隐藏细节,而我主张增加对细节的认识。确实需要权衡一下一些实际因素,比如增加源文件的多次签出,在主预编译头文件中包括更多的本地数据,因此要完全编译更多次等。目前,我倾向于使用大型对象作为组合代码的合理点,并试着减少使用中等大小的辅助对象,同时使非常轻量级的对象尽可能地纯函数式,如果它们真有必要存在的话。

总结一下:

如果一个函数只被一个地方调用,考虑内联它。

如果一个函数从多个地方被调用,看看是否有可能把工作安排到一个地方完成,可能带着一些 flag,并内联它。

如果一个函数有多个版本,考虑写单个有更多参数的函数。这些参数可以是默认参数。

如果要做的事情接近纯函数,对全局状态的引用很少,试着让它完全纯函数化。

当函数真的必须在多个地方使用时,尽量在参数和函数上都使用 const。

尽量减少控制流的复杂性和“if 下的区域”,倾向于一致的执行路径和时间,而不是“最佳”地避免不必要的工作。

来讨论一下?

John Carmack
Tags: 编程
#1 - 2022-9-3 17:01
#2 - 2022-9-3 17:02
译者的一些补充:

Carmack 提到的
而将次要函数包在一对大括号内通常会很有用
或“打开一个 scope”,是这种代码风格几乎必需的语言功能:

(伪代码)

void major_function() {

    // minor function 1
    {
        int a;
        int b;
        int c;
   
        ...
    }


    // minor function 2
    {
        float a;
        float b;
        float c;

        ...
    }


    // minor function 3
    {
        string a;
        string b;
        string c;
   
        ...
    }
}


没有这个功能的话长函数的命名空间里很快就会出现冲突。有些语言虽然支持这个,但和 C/C++ 里的语义并不太一样。(例如,在 JavaScript 里单开一个 scope,用 let 声明的变量不会泄漏出去,但用 var 声明的会泄漏。)

另外,很多现代语言都支持函数里的函数:

void foo() {

    Stuff bar(Thing t) {
        ...
    }

    Thing a = ...;
    Thing b = ...;
   
    Stuff c = bar(a);
    Stuff d = bar(b);

    ...
}


因此,如果你发现有些重复的代码可以重构成函数,在确定其他地方也需要这个函数之前可以把它写在大函数里。虽然这并不是内联,文章中提到的不可能从其他地方调用的优势还是存在。

如果你的语言不支持单独开 scope 但是支持函数里的函数,你也可以用这个来近似内联的效果:

void major_function() {

    void f1() {
        int a;
        int b;
        int c;
   
        ...
    }

    void f2() {
        float a;
        float b;
        float c;

        ...
    }

    void f3() {
        string a;
        string b;
        string c;
   
        ...
    }

    f1();
    f2();
    f3();
}
#3 - 2022-9-22 00:18
有趣的东西。
最近c++用写在函数内的大量lambda表达式组织代码,似乎跟这种风格有点像。
不过函数嵌套过多gdb貌似有点乱跳的问题。
#3-1 - 2022-9-22 06:43
Letheward
可惜的是 C++ 的 lambda 有很多缺陷,C 和 C++ 真正需要的是函数里的普通函数(GCC 有这个扩展)。

不过如果只用一遍的话,个人还是倾向于直接内联写在一个 Block 里。