【井蛙语海】Rust

Posted by [Zenith John] on Saturday, December 21, 2024

为什么选择 Rust?

Rust 的官网提出了 Rust 的三个卖点,性能(Performance),可靠性(Reliability) 和生产力(Productivity)。其中最具独特性的是它的所有权(Ownership)系统,以及伴随这一整套系统而获得的内存安全性以及线程安全性。

学习经过

我主要是通过阅读 Crafting Interpreters(以下简称 CI),并将作者的 Java 实现改写为 Rust 实现来学习 Rust。目前完成了前十三章的学习,代码可见 lox-in-rust,暂时不打算继续开发了。

与 Java 实现的对比

抽象语法树

在 CI 中作者先编写了一个根据上下文无关文法生成{生成抽象语法树的程序}的程序。而在我的实现中,我利用了 Rust 强大的类型系统,直接使用递归类型将抽象语法树编码写了出来,事实上无论是可维护性和可读性,在我看来相当的直观简单,可以说是学习过程中最舒爽的时候。

解释器的实现

在 CI 中作者使用了复杂的设计模式来迎合 Java 的面向对象设计,而在 Rust 中使用模式匹配,就能够很容易地实现对于不同的语句进行分类处理。这一部分 Rust 的实现也比 Java 容易。

全局变量

在 CI 中作者充分地运用了 Class 的内部变量。而在 Rust 中,由于全局变量如果不进行 Hack,必须是不可变的,所以我必须在函数运行时,将运行环境作为参数在函数间进行传递,于是带来了重复的代码,隐藏的性能问题,以及时不时出现了所有权问题。

错误处理

在 CI 中作者使用了 Java 的异常处理来在代码遇到错误的时候进行回溯。而在 Rust 中,错误处理使用更柔和的 Option 和 Result 进行。但是使用 Option 和 Result 的代价就必须不断地在函数中处理返回值,所以也显得比较繁琐。

Object & Any

在 CI 中作者充分利用了 Java 的 Object 和运行时系统。而在 Rust 中使用 Any 就比较繁琐。尤其是由于 Any 的值不能够在编译时确定因此必须要进行包裹。尤其是 Rust 的安全性使得在实现各种变量的可变实现的时候就变得非常复杂。比如在进行变量赋值的时候,就需要使用 Rc 和 RefCell 的组合来得到一个可以克隆的可变对象。相比于 Java 的实现这一部分就变得过于复杂。

Return 语句

在 CI 中作者使用异常处理来实现函数的返回。同样,由于在 Rust 中没有类似于 Java 的类型处理,因此我在函数调用中单独进行 Return 的处理,而实现函数的返回。于是处理语句的 Rust 的代码就不得不分散在两个地方提高的维护的难度和阅读的难度。但是利用 Java 的异常来处理的方法在我看来也不够简洁。

Resolver

在 CI 中作者使用了 HashMap<Expr, Int> 这样的表来存储变量的作用域结构。但是在我的实现中,由于 Expr 的中存在 Rc 的元素,因此无法进行 Hash 化,所以不能作为 HashMap 的键来使用。所以我使用 AtomicU64 作为全局计数来给予变量唯一给定的 id 值来存储作用域信息。

代码的改进

由于最初没有掌握代码的全貌,以及对 Rust 还不了解,因此在设计上有很多可以改进的地方。

对于 Any 的使用

由于 CI 的书的 Java 实现上使用了 Object,所以我使用了 Any 来作为一些变量的值。但是 Rust 的从属权原则使得 Any 的使用非常不方便,完全不能够和 Java 相比。因此在很多的地方(比如在 Token.lexeme 的定义中),就应该将可能的情况用枚举类型写出来,避免进行运行时的处理。

Option/Result

由于对于 Option 和 Result 的语义不够了解,在很多应该使用 Result 的地方使用了 Option 的错误语义,导致代码的逻辑有些混乱。同时,由于对于 ? 语句缺少认识,在很多地方写了不必要的重复代码。

混乱的错误处理

在 CI 中作者使用 error 语句进行了统一的错误处理。但是为了减少在函数之间反复传递变量,所并没有始终使用 error 语句进行错误处理,于是造成了以下问题:一,输出的错误处理混乱;二,在不同的地方可能对错误进行反复处理,或者有些错误没有给出合理的信息。

使用感想

所有权

Rust 的所有权系统是 Rust 和其他编程语言之间最大的区别(可能没有之一)。通过编译器的工作来避免人肉眼难以发现的潜在内存安全/线程安全问题。但是作为初学着的感受其实非常不好。你必须要与编译器搏斗来使得代码能够通过编译。尤其在有些情况下你非常确信一段代码的所有权不会出现问题,但是 Rustc 依然拒绝了你的代码的时候真的是非常沮丧。即使是当年学习 C 被指针弄得头昏脑胀的时候,也没有被 Rustc 指出有所有权问题来得奔溃。每一次遇到所有权问题,几乎都要大改系统。你必须使用 Box,Rc,RefCell 等等工具才能够通过编译。但是,RefCell 又会把所有权的检查推迟到运行时,而在运行时,依然可能遭到失败。而使用 Rc 或者 Clone 来避免所有权的问题,又可能遇到潜在的性能问题。以我现在的水平,似乎还没有找到能够在保持安全性的同时不损失性能的方法。而所谓的生产力,更是难以实现。所以,Rust 官网的三大卖点,并未使我信服。在我看来 Rust 的卖点中可靠性 > 性能 > 生产力。事实上我现在也不太弄得明白生命周期标记。

Option/Result 系统

Option 和 Result 是 Result 的错误/异常处理的系统。在我看来,这一设计也是为了可靠性服务的。它不像异常一样会从乱七八糟地地方丢出来,而是能够在有限的范围内对于潜在可能的错误进行处理。通过这样的方式减少程序奔溃的可能,从而提高程序的可靠性。当然,其中的性能损失也是不可避免的。

类型与模式匹配

为了提高系统的可靠性,大量的检查在编译时完成。相比之下,Rust 的运行时系统就显得相对孱弱,并没有很好的反射等运行时的支持。虽然能够在运行时进行类型转换,但是实现相对繁琐。所以在 Rust 中可能更好的实践是充分利用 Rust 强大的类型系统和模式匹配,而不要将任务交到运行时去处理。Rust 的模式匹配用起来是非常直观而且方便的,编译器对于模式匹配未处理的可能也会报错,减少了模式匹配中出错的可能。模式匹配和迭代器,应该算是 Rust 对于过去几年编程语言经验的成功吸收。

总结

Rust 的语言设计以可靠性为首要目标,并兼顾性能。因此在系统级开发以及命令行程序的开发中得到了广泛的应用。就我个人的体验来看,Rust 在类型和模式匹配方面相当出色,Option/Result 的设计也很有趣,但是所有权系统只能说让人爱恨交加。不过,这次学习并没有涉及到 Rust 的并行/异步设计,通过所有权来实现线程安全是 Rust 的一大卖点,未免有些遗憾,或许通过并行编程的学习能够对于所有权的必要性有更深刻的理解。但是这个坑短期内是填不上了。