原文地址:blog.shipreq.com/post/compil…
原文作者:github.com/japgolly
发布时间:2021年1月18日
我经常听说编写编译器很难,和编写其他种类的软件不同。最近的一些经验为我提供了洞察力,让我知道为什么会这样,而且证明是相当有趣的!我最近完成了ShipReq中新的大功能的工作。
我最近完成了ShipReq中一个新的大功能的工作,我已经工作了大约2个月,结果它是我一生中写过的最难的代码。我做了几十年的编码,参与过很多不同的项目,也为一些非常大的组织工作过;我告诉你这些是为了了解背景。当我说这是我写过的最难的代码时,它有很多竞争。并发系统通常有非常困难的名声,而我在OO时代就已经从头开始设计和编写了一个大规模的并发项目,包括编写所有的调度和并发基元,自己管理所有的线程安全(那是不同的时代)。我发现这一块的工作比这要难一个数量级。
我在 ShipReq 关于页面中说,ShipReq 就像一个编译器。我最近完成的大功能是迄今为止最像编译器的功能,让我惊讶的是,具体来说就是这个属性让它变得如此具有欺骗性的难度。这个功能是什么呢?基本上当你有一堆需求/问题/设计文档/什么的时候,这个功能就会增加自动化的功能,根据某些字段的关系和相关字段来填充,这样用户就不用自己手动编辑或维护了,既繁琐又容易出错。一个例子是,如果有人提出一个bug,需要一些开发任务来修复,人们可以设置自己的项目,当所有这些开发标记被标记为 "完成 "时,bug本身就会自动变为 "准备测试"。这个功能没有任何 "完成 "或 "测试 "的知识,但允许用户定义任何他们想要的状态和规则。这听起来可能和编写编译器没有关系。(听起来可能都不难。)需求、bug报告、项目文档似乎也与代码没有什么关系。但也有惊人的相似之处。让我们来看看...
相似性1:关系
ShipReq中几乎所有的数据关系都是多对多/DAG。ShipReq中的一些视图将数据显示为平面列表,但在本质上,很少有任何东西以平面列表的形式存储,几乎总有一种方法可以将相同的数据以图形或树的形式查看。
如果你仔细观察一下,源代码也是这样的。包有多个类/对象,这些类有多个方法/字段/内类有多个语句,这些语句有多个子句等等。在声明方面,更多的是一对多而不是多对多,但还是都是一个大DAG。当我们开始考虑导入和方法调用其他方法等关系时,那么它也变成了多对多,我们得到了一个循环图。
相似性#2:交互
其次,ShipReq中的这个新功能,用户可配置性很强。这是ShipReq的一个主要目标,避免硬编码的隐性假设,找到合适的、可配置的抽象。显然让用户需要一直重新配置所有的东西将是一个可怕的负担,所以我们提供了预配置的完整设置与最佳实践,用户可以(选择性地)使用,并且可以不受限制地重新配置,以最好地满足他们的需求。从编码的角度来看,这意味着我们永远不知道编译时的自动化规则。代码需要在运行时理解和应用所有的规则,并且需要能够识别和处理使不变量失效的数据和逻辑组合。这听起来可能不是什么大问题,但在一个复杂的系统中,有许多不变量,而且不变量不会停留在其直接的领域边界上--它们会通过所有相关的图传播,并以不同的复杂程度与其他不变量组成。
例如,你可能在10个孤立的域中有10个简单的不变量,但当它们全部组合在一起时,需要考虑的状态数量就变成了10,000,000,000,而且这些状态中会有相当大的比例被你认为是非法的(即bug)。
这也就像写编译器一样。例如,Java编译器不会硬编码你的类的隐私级别,它允许你自己设置。如ShipReq可能有一个UI页面用于配置,而在Java中,你有修饰关键字,如public class Hello和private class Hello是不同的。从编写编译器的角度来看,这些都是带有运行时规则的运行时值。当我们考虑额外的关键字,如final和abstract时,我们开始进入我所说的组合爆炸的意思。Java编译器在运行时,必须确保所有可能的组合都是有效的。例如,它将拒绝以下代码。
final public class Hello {
public abstract void doSomething(); // abstract method in final class not allowed
}
和
abstract class Hello {
private abstract void doSomething(); // private + abstract not allowed
}
想象一下,你正在编写自己的Java编译器。完全由你来思考那些非法的组合,然后写出代码来抓住它们。我想,我们都会把一些事情视为理所当然,比如把一个方法标记为私有,然后知道它不可能被外部调用,但冥冥之中,它并不是完全不可能。你真的是在正确的地方依赖别人的if语句。就像我的ShipReq例子一样,都是在运行时的值检查,没有保证所有的功能如何交互。类/方法隐私特性与final和abstract特性的交互在心理上已经很简单了,但是每一个特性都有能力与其他的东西进行不良交互,而且要识别这些组合并不总是那么容易。
我最近看到一个PR到Scala,正是一个很好的示范。private和sealed的关键字在Scala中一直存在,最近才有人意识到私有类是有效密封的。Scala已经存在了16年了! 当你有数百个特征时,手动考虑每一个可能的组合似乎并不可行。100个特征会产生4950个唯一的对。不过对子并没有什么特别的,有些问题可能只有在三个特定特征相交时才会体现出来。对于100个特征有。
- 4950个独特的2组
- 161,700套独特的3
- 3,921,225套独特的4件套
- …
- 17,310,309,456,440套唯一的10套设备
在可能的100个功能中,有10个功能之间的交互不好也不是不可能。只是无法手动考虑。
相似性#3。类型不安全
类型安全是我一直依赖的支柱。不,当你想到类型安全时,请不要想到Java的类型系统,那不是我真正的意思,虽然它是一个开始。我更多的意思是Scala和Haskell等语言中的类型系统,想想存在型和依赖型等特性。像Java的类型系统和Typescript的类型系统是伟大的!它们增加了价值。它们增加了价值! 但是,你可以用更高级的类型系统来实现一两个数量级的安全,而且当你学会如何好好利用它们时,这对你是非常有益的。
不幸的是,在开发我的大功能时,类型安全对我帮助不大。假设你有一个比可以接受一组值的数据位置。在编译时,每个运行时的值都是可以接受的,因为总是存在一个配置,可以让每个可能的值都是合法的。这就像有一个类型为String的'Name'字段,然后由一个运行时规则来规定名字的必要首字母。(不现实,但这是我强调的概念。)配置可能是firstNameLetter: Option[Char]。在这样的情况下,你必须让名字字段是一些包容所有可能性的东西,比如name: String。你可以很狡猾地创建一个类型,对名称的证明进行编码,使其符合规则,但这只是 (name: String, firstNameLetter: Option[Char]) 的衍生。除非名称和配置和可原子地修改为一个单一的复合值,否则在这样的推导之前,你仍然需要独立地存储这两个值。名字限制的第一个字母是一个玩具例子,但它表明了这样一个事实:你的运行时配置在一个位置,运行时数据在另一个位置,而在编译时没有类型安全来强制执行正确的事情在运行时发生。
编译器也有同样的问题。他们维护着一个庞大的运行时数据库,完全由编译器作者来应用所有的规则,根据其他运行时条件将数据移入和移出范围,等等等等。而不像我们编译器用户,他们没有能够依赖(很多)类型系统的好处。他们只能靠自己,只能用类型安全来保护他们非常复杂的领域的底层。
相似性#4:反馈
这是一个必要的过程,事情会出错,用户需要得到有意义的反馈。
在ShipReq的情况下,它将...
- 检测那些总是错误的更改,并防止用户进行这些更改。这些都是错误。
- 检测已经变得错误的数据/配置,相应地修改派生,并以完全可理解的方式呈现给用户。这些是警告。
- 收集所有必要的信息,向用户解释派生数据是如何、为什么和从哪里来的。自动填充/修改用户的数据是一回事,但我相信用户应该能够检查和理解诸如执行了哪些步骤,哪些数据是因素。这就是出处。听起来并不难,但在实践中,实现起来却出奇的有挑战性。
编译器也在做同样的事情。他们...
- 在运行时检测非法数据和数据组合,并向用户解释。这些都是错误。
- 检测到可以接受或有可能出错的数据,处理它们,也许是通过忽略语句,或以某种方式修改生成的代码,最后,他们将此标记给用户。这些都是警告。
- 有些编译器计算出某件事情是如何产生的,并将其呈现出来,通常是在编译错误中。有些编译器的错误信息会运行10-30行,因为它们提供了这么多关于上下文和计算的信息,直到这一点。这就是一种出处的形式。
和上面的观点类似,这都是专门的人写了四万亿个if条件(不是字面意思),在非常有战略意义的位置收集各种运行时数据,并做了大量的条件数据转换,才能够简明扼要地解释一个更大的底层数据集。
相似性#5: 性能
编译速度慢让大众失望。每隔两年左右,Scala编译速度就会有非常明显的提高,而且还会继续提高,然而速度慢的坏名声仍然存在于很多人的心中。我认为Rust社区现在就开始累积这个名声了。Go的支持者自豪地宣称超快的编译速度是该语言的主要卖点之一。当你在写编译器时,速度是非常重要的。
我写的这个功能也有同样的要求。它有可能在用户的每一次变化上都需要全面重新计算! 每一次变化上发生的任何事情都需要快。
我是FP(函数式编程)的坚定拥护者,已经有9年左右的时间了,但是对于这个功能,我不得不使用大量的变量和可变性。FP的奇妙之处在于,你可以用一种不可变的、引用透明的方式封装可变的代码,这样工作的 "原子 "本身就是FP的,尽管所有的可变性都在外壳之下。这正是我所做的,我能够吃到我的蛋糕:代码速度超快,整个 "编译 "步骤都是原子的、纯粹的,这意味着我没有牺牲FP。换句话说,同样是不可变的数据进来;同样是不可变的结果,但我只是碰巧使用了大量的突变性来得出这个不可变的结果。顺便说一下,这是Scala的一个奇妙的属性,也是它的多范式性质。我相信,Haskell(严格来说是一种只用FP的语言)的传道者会认为,你可以在Haskell中拥有同样的速度,但我不确定这种说法的真实性,也不确定通过什么手段来实现它。代码会不会只是做完全相同的事情,但使用IORefs或巨大的StateT堆栈来做更多的boilerplate?Haskell的优化效率会不会很高,以至于更直接的FP方法会足够快?其实我也不知道,但很想知道。我猜想,看看 Haskell 编译器的内部结构将是一个深刻的令人敬畏的洞察力的宝库。但我已经离题了。
除了Haskell可能的例外,编写一个快速编译器意味着要有很多可突变的状态;而突变状态的推理和保持正确的难度是成倍的。我眼泪汪汪,这是我必须要达到的东西,才能有闪电般的性能(这是在大量非常有策略的缓存之后),据我所知,几乎所有的编译器中都有这个功能。
解决方案(1/3)
我们已经说了很多困难,但是有什么样的解决方案呢?到底如何确保这种复杂的代码能够真正发挥作用?这是我的方法。
首先要做的就是让你的测试,特别是你的测试数据表示,尽可能的易读易写。
例如,说你的期望值是这样的。
final case class Result(accounts: Set[Account])
final case class Account(id : Long,
name : String,
roles : Set[Role],
aliases: Set[String])
sealed trait Role
object Role {
case object Admin extends Role
case object User extends Role
case object Offline extends Role
}
你可能会倾向于开始写这样的测试。
val result = doStuff(…)
val accounts = result.accounts.toVector.sortBy(_.id)
assertEqual(accounts.length, 3)
assertEqual(accounts(0),
Account(2, "David",
Set(Role.Admin, Role.User),
Set("Baz", "Bazza", "Dave", "Davo")))
assertEqual(accounts(1),
Account(7, "Bob",
Set(Role.User), Set.empty))
assertEqual(accounts(2),
Account(13, "John",
Set(Role.User, Role.Offline),
Set("Jono", "Johnny")))
不要这样做!
- 写的时候很痛苦
- 你每次只能得到一个失败的子集。也许是 "4不是3",也许是一个账户失败,当你有多个
- 如果缺少了 "达沃",就很难从结果中判断出
Account到底出了什么问题,对于大数据来说更是如此。 - 如果你改变了数据结构,你必须改变每一个测试。想象一下,当你写第51个测试用例时,你需要把
Set[Role]改成一个叫Roles的新类。这就是50个测试,谁知道有多少次发生,你需要手动更新。
相反,要宠爱自己和你的团队。花一两个小时对自己好一点。你将会编写大量的测试,所以尽可能的让自己轻松。
- 编写新的测试
- 修改现有测试
- 理解测试失败
- 阅读和理解测试案例
在上面的例子中,我们将写一个函数,通过执行以下操作将Result转换为String。
- 按id排序
- 将一个
Set[Role]表示为Admin,User和Offline的auo标志;就像在Linux中你有rwx权限一样。 - 当出现别名时,要像YAML一样进行排序并打印出来
另外,如果你的测试库不能很好地处理多行字符串,请找一个能处理多行字符串的库。理想情况下,当两个多行字符串不匹配时,你会希望看到彩色的并排比较。
我们的新测试会是这样的。
def assertResult(actual: Result, expect: String): Unit =
assertEqual(formatForTests(actual), expect.trim)
val result = doStuff(…)
def expect =
"""#2) David [au-]
| Aliases:
| - Baz
| - Bazza
| - Dave
| - Davo
|
|#7) Bob [-u-]
|
|#13) John [-uo]
| Aliases:
| - Jono
| - Johnny
""".stripMargin
assertResult(result, expect)
好多了!
- 写作速度更快
- 修改速度更快
- 故障很容易理解(尤其是有颜色的并列对比)。
- 我们可以在不改变所有测试的情况下改变数据结构。
- 当测试失败时,我们会一下子看到所有的东西,简洁明了。
现在去进行单元测试吧! 编写所有你通常会做的测试。如果你需要的话,也同样努力让生成测试输入变得尽可能简单。为你能想到的每个有问题的数据组合写一个测试。确保添加注释,描述为什么组合是有问题的,因为它并不总是那么明显。
解决方案 (2/3)
当你历经千辛万苦让所有的单元测试都通过后,就可以进行下一步了:属性测试。
如果你不知道什么是 "属性测试",请搜索一下它,因为它是一个无价的工具。它是改变生活的。
想一想结果数据应该满足的所有属性(即不变量)。我发现,为类似编译器的代码思考属性要比通常的运行代码容易得多,这可能是因为这个领域的数据和数据关系非常重要。
即使是我们上面的玩具账户例子,你也可以想出这样的属性。
- ids应该是唯一的
- 名字要唯一
- 别名绝对不能与另一个用户的名字相同(也许代码应该是为了过滤掉这些名字)。
如果你处理的是图而不是列表,通常每个节点都应该遵守一些属性,与它的子节点或父节点有关。
确保你的属性测试库能够重现其测试。每次它发现失败时,提取数据并将其添加为单元测试。我的OSS Nyaya库有一个叫做bug-hunt的功能,允许我告诉它 "嘿,从种子x开始在m个线程上运行n个测试,每个种子一个测试"。当我在研究上述ShipReq功能时,它发现了很多问题,通常在30秒内就能发现,直到最后运行了一两个小时也没有发现故障。我不能保证我的代码中不会还存在一些疯狂的bug,但通过我所有的单元测试和超过几个小时的系统测试,我至少可以放心,这在数学上是极不可能的。这是你付出合理的努力所能得到的最好结果。
解决方案(3/3)
如果你愿意,你也可以投入不合理的精力,走得更远!你可以尝试写出正式的、可机器验证的证明。你可以尝试写一个正式的、可由机器验证的证明。一旦你有了一个可验证的证明,你就可以保证你的逻辑在所有情况下都能正常工作,不会出现任何意外的错误(假设你正确地实现了证明的逻辑)。虽然这是我们都喜欢的结果,但它很少可行,因为它可能需要多年的工作才能完成。
一个有趣的例子是Scala。它已经存在了很多年,这些年来,社区遇到了很多bug。如今,很少会遇到编译器的bug,因为已经有很多持续的努力去修复它们。但即将到来的Scala 3,最让人兴奋的是,经过多个博士生近10年的努力,Scala语言的基础已经被正式证明。这还不是完整的语言。比如说,高种类型和子类型之间的交互还是一个开放的研究问题。顺便说一句,这并不是Scala特有的,任何试图包含这两个特性的语言都会有一个问题,那就是我们永远不会真正知道是否有剩余的bug,在得到证明之前,我们只能知道添加到语言测试套件中的所有东西都能用。我喜欢Scala有一个所谓的 "社区构建",它可以编译和测试数百个(或更多)最流行的OSS Scala库和框架。这是一个捕捉回归的好方法。但我现在真的离题了。重点是:形式化证明=非常困难。
我还应该提到的是,你可以使用TLA+来获得证明,但要省力得多。你基本上建立了一个非常抽象的状态机,它会通过测试所有可能的组合来强行证明。在非常特殊的情况下,这是一个神器! 无论多么罕见的边缘情况,TLA+都能找到它;通常在几秒钟内就能找到。我曾用它与ShipReq一起保证最终的一致性等事情,并确保用户永远不会看到陈旧的数据。你可以花几天时间,最后保证你的逻辑在所有可能的情况下都成立。不过对于验证类似于编译器的代码,我还没有尝试过,你很可能要坐下来,非常非常非常认真地思考如何以一种兼容和高效的方式对你的逻辑进行建模。状态空间很可能会太大。根据你具体在做什么,它可能是可行的,不过,我至少建议你考虑一下。
我还漏掉了什么吗?如果我介绍的解决方案有点反常,对不起。如果你能想到其他的解决方案,我真的很想知道,所以请在Reddit或HackerNews评论中让我和其他读者知道!
结束语
我想写这个有几个原因。我听编译器作者说过,写编译器和写其他东西很不一样,但我没有好好理解为什么。遗憾的是,我也看到很多人因为取舍或bug而对某些编译器作者和语言进行无礼和嘲笑的例子。一些比较温和的人说过这样的话:"FP不是很容易就能解决X问题吗?","为什么要有这么多的可变性?这显然是个灾难性的配方 "等等,虽然他们看起来像是合理的立场,但我经常发现自己在想,这些编译器作者足够聪明,能制作出如此复杂的系统,肯定已经知道这些策略及其好处。那么到底发生了什么?
我想我现在已经有了答案,我希望这篇文章和我的思考也能给你提供一些启示。我所做的工作与一个完整的语言编译器的复杂性相比是微不足道的,然而这简直是我写过的最难的代码。(而我从小到大已经编码了30多年了!)。
我希望你和我一样觉得这些见解很有趣。我想我现在进一步理解了写编译器和写其他东西很不一样的说法。不过其实我还想再往前抽象一步,说写一个高配置、高灵活的系统是非常难的。
谢谢巨匠!
我们都站在巨人的肩膀上。记得停下脚步,欣赏一下我们每天都在依赖的工具中所付出的工作和努力。想一想你的语言编译器中必须有所有的if语句,让它反映出我们都认为是如此明显的东西。我们所依赖的所有复杂的东西都是建立在层层原始乏味的基础上的。所以,如果可以的话,也许请你的日常语言的作者喝杯咖啡,或者只用一条漂亮的感谢信息来表示你的感谢。
我非常感谢所有我以他们的作品为基础的人。驱动我个人的部分原因是,我希望我能在ShipReq中产生新一代的效益和质量,让我的用户觉得理所当然,并对自己说 "是啊,科技又进化了,这个2021年的科技是我现在的基线",然后继续打造他们认为是革命性的东西。这是一个美丽的循环。