不喜欢 D 和 C++,程序员将 58000 行代码移植到 Jai 语言?
摘要:将已有的上万行代码迁移至另一种编程语言,从来就不是一件容易决定的事情,而本文作者却信心满满地要将 5.8 万行代码全部用另一种不那么主流的语言重写,这是为什么呢?
链接:https://www.yet-another-blog.com/porting_the_game_to_jai_part0/
声明:本文为 CSDN 翻译,未经允许禁止转载。
在这篇文章中,我将分享把一款正在开发的游戏移植到 Jai 语言的经过。游戏本身主要是用 D 和 C++ 编写的,总共有 58,620 行代码(不包括库)。
原因
你可能想问,为什么要将如此大规模的程序移植到另一种编程语言?你完全可以等到新项目再用新语言嘛!
我之所以移植这些代码,原因主要有以下几个:
目前这些语言给我的日常工作带来了无尽的痛苦。
我有合适的系统来帮助我移植代码,所以我认为此次移植能够顺利进行。
对于我来说,Jai 似乎比 C++ 或 D 更具吸引力。
最重要的原因是:我喜欢 Jai!
为什么放弃 C++
网上有很多文章诉说了 C++ 的缺点,所以我不打算在此赘述。简单来说,C++ 几十年的发展积累了很多错误决定,我们没有任何方法去摆脱它们。标准库是一场灾难,使用其他人的代码也非常困难,而且不知何故,C++ 每次添加的新功能都有陷阱。
虽然 C++ 的这些缺点也不至于糟糕到让人避之不及,但它确实给我带来了很多痛苦。此外,这些年来 C++ 的发展似乎不尽如人意,我不觉得这门语言会越来越好,所以我还是想逃离 C++ 生态系统。
为什么放弃 D 语言
2019 年,当我开始开发这款游戏时,已决定放弃 C++,但我不确定应该选用何种语言。最终,我选择了 D 语言,因为这门语言与 C++ 类似,但没有 C++ 的那些缺点。然而不幸的是,事实证明 D 语言也有一些 C++ 中不存在的缺点。
虽然 D 语言有一些优点,比如更强大的元编程、不需要头文件、没有未初始化的值等,但相较于缺点而言,这些优点不值一提。
我在 Windows 的两款 D 编译器(dmd 和 ldc2)之间来回折腾了 4 年之久,到头来却发现在 Windows 上编写 D 代码,只适合个人的业余项目或早期不成熟的软件,该语言完全不像有 20 多年的发展经历。
就目前的情况来看,我不建议任何人使用 D 语言在 Windows 开发正式项目。相较而言,继续使用 C++ 才是更好的选择。根据我多年的经验来看,在 Windows 编写 D 代码所面临的最大问题在于,其调试信息千疮百孔:
90% 的情况下不会给出 this 指针,或给出错误的 this 指针;
函数堆栈上的变量经常不完整、丢失或有误;
变量的值有时会报告错误,却看不到任何其他问题;
静态 foreach 扩展的处理不当,甚至会导致调试器紊乱;
mixins(相当于 D 语言的宏)生成的调试信息会导致调试器找不到正确的文件(因此你需要逐步反汇编);
在 visual studio 中,将指令指针移到上一行常常会导致程序在下一条指令上崩溃。
网上有人告诉我,一直以来 DMD 的调试信息就存在很多质量上的问题,但不幸的是,上述大部分问题不仅限于 DMD,Windows 的两个编译器都有这些问题。除了调试之外,元编程的核心部分还存在其他问题和缺点:
不同的编译阶段以奇怪的方式交互,常常导致元编程出现意外,同时还会产生具有误导性的错误;
ldc2 的编译速度非常慢,但有时这款编译器是唯一的选择,因为 DMD 有 bug;
D 提供了一种 betterC 模式,其中包括禁用垃圾收集。然而,在使用这种模式时,标准库不会被编译,而且元编程也会遇到重大问题;
缺少文档;
此外,还有一堆小问题。
总之,虽然从某些方面来看,D 确实比 C++ 略胜一筹,但其他方面的小问题非常多,累积起来导致使用 D 语言编程也非常痛苦。糟糕的调试信息和垃圾收集的需求很致命,我的整体感受是,D 的创始人对 C++ 的看法似乎与我截然不同。我只是希望改进 C++,而不是用一些 C++ 的问题来换取 D 的其他问题。现在我对调试器有严重的信任问题。
为什么选择 Jai
Jai 是 Jonathan Blow 于 2014 年开发的一款编程语言,而编译器一直到 2019 年 12 月才开始内测。如今封闭测试仍在进行中,大约两个月前,我应邀参加了这项测试。
Jai 的设计初衷也是希望改进 C++,但与 D 语言不同,Jai 正在对 C++ 做出有意义的改进。在我看来,最重要的改进包括编译速度更快,以及允许通过无限制的编译时执行来实现元编程。
请注意,这里我所说的编译速度提升可不止 20%,而是 10~100 倍;而且你能在编译期间执行任何操作,不仅限于元编程。尤其是,元编程与编译时编译器 API 的结合使用具有深远的影响,例如你不再需要构建系统,或启用复杂的自定义检查。除此之外,Jai 还对 C++ 进行了其他方面的改进,比如更好的默认值、更简单的语法、更实用的标准库、命名函数参数、上下文、using 等等。我希望我的游戏从 D 移植到 Jai,能够获得以下提升:
编译时间从现在的 60 秒减少到 5 秒以下,争取能缩短至 1 秒左右;
调试器能够正常工作;
用 Jai 代码替换构建脚本;
使用元编程引入自定义编译检查,以抓取更多错误;
用更简单的命令式代码替换复杂的元编程代码;
各种语法改进,减少代码中的繁琐部分;
删除使用多种语言时不可避免的重复。
我希望通过这篇文章记录我的期望,将来可以回过头来检查有多少期望真的实现了。
为什么不是其他语言
如上所述,我不喜欢 C++ 和 D 语言,而 Jai 看起来很不错。那么,其他编程语言呢?似乎 Rust 也是一个很好的备选。这门语言风头正盛,而且还有一个热心的社区,但我个人认为 Rust 并没有做出正确的权衡。
它的支持者都是唯“内存安全”是论者,考虑到如此多的漏洞都是内存安全引发的,我可以理解这种心态,但他们忽略了其他高安全性、高质量的方法。例如,我相信如果 C 和 C++ 没有零终止字符串、默认初始化值,那么大部分漏洞都不会存在;再加上合适的指针+长度类型,就可以用边界检查代码取代 90% 的指针计算;然后再建立一种文化,不鼓励大家自己想办法自行管理内存。
除此之外,我认为,我们在寻找“安全的”代码时完全忽视了我们拥有如此多漏洞的最主要的因是,我们的文化对复杂性的容忍,甚至是鼓励。总之,忍受 Rust 慢吞吞的编译并接受借用检查器是一种极端的解决方案,并没有解决最重要的问题,这是一个文化上的问题。另一方面,Jai 非常在意复杂性,并努力建立正确的文化。
对于其他不太受欢迎的语言,例如 Zig,我只能说虽然它们可能具有巨大的潜力,但我并无法相信它们是正确的选择。我不是说这些语言不好,只是它们不适合我。
移植方法
在本文开头,我曾说过我认为此次移植能够顺利进行,原因是我的游戏中有两个系统,对此次移植有很大的帮助:
我的游戏可以将游戏过程中的输入(HID、加载的文件、网络等)记录到一个文件中,之后进行回放。在回放的过程中,系统可以将记录下来的输入传递给游戏循环,从而重现一模一样的游戏状态。
玩游戏期间,系统可以在各个时间点,对游戏状态进行哈希处理,并将这些哈希值保存到不同的文件中。在回放整个游戏过程时,系统可以利用这些文件检查游戏的状态是否与原来匹配。
实际的功能和上面的描述有一些细微的差别,但不会影响整体的逻辑。根据这些特性,我的移植计划如下:
将一小段代码从 D 或 C++ 复制到 Jai,然后编译;
调用这段 Jai 代码;
回放录制的游戏会话;
如果回放出现分歧,则说明移植引入了 bug,修复 bug;
如果回放没有出现分歧,则说明移植成功;
重复以上操作。
关于该方法是否有效,我需要考察两个关键问题:
移植引入的 bug 是否会导致游戏状态出现明显的分歧?
能否以少量、渐进式的方式移植代码,这样在得知存在 bug 时,更容易找到bug?
第一个问题取决于状态哈希覆盖了多少代码。一部分代码需要判断游戏是否正在回放,这部分代码在回放时有不同的行为,因此无法完成真正的哈希处理。例如,写入文件的功能在回放时会直接丢弃所有数据,因此如果移植在写入文件的代码时引入 bug,则不会被哈希处理注意到。幸运的是,大多数代码不属于这一类。
最初,只有很小一部分代码使用了哈希处理,例如物理模拟,但最近我设法进行了扩展,在向动态数组插入数据时,用哈希来记录其大小和容量。这意味着,插入动态数组的代码中的 bug 很快就会被发现。由于动态数组的使用在我的代码中随处可见,所以对于庞大且复杂的数学算法来说,即便是一个很小的变化,也能带来立竿见影的效果。
第二个问题是一个经典的编程问题:代码的解耦性如何?这个问题非常有趣,因为在移植的过程中,我将亲眼目睹我的代码库中究竟封存了多少不为我所知的复杂性。一个明显的问题是模板函数,这些代码无法直接移植,因为函数的定义和调用必须在同一个编译器中,模板才能发挥作用,除非你手动实例化模板。我的代码库中有大量的模板化代码,但大多不依赖于容器或序列化,所以我希望不会引起太大的麻烦。
移植过程
下面这张图是移植前的代码库状态:
整个代码库有 45,701 行 D 代码和 12,919 行 C++ 代码,总共 58,620 行。 编译时间如下:
在调试模式下,ldc2 需要 3 分钟才能完成编译,内存使用量峰值约为 8GB,如果打开浏览器,我笔记本电脑的 16GB 内存很容易就饱和了。发布模式更糟糕,约为 11.5GB。
为了记录移植进度,我绘制了如下代码库的示意图:
如果一切按计划进行,上面两张图中的颜色都会改变,我非常期待!
最后,我来说一下我期待的效果:
整个移植的过程需要 160 小时,每周工作 40 小时,一共需要一个月。
编译时间从 1 分钟缩减到 5 秒以下,理想值为 1 秒左右。