吐槽几个 Rust 里写的不舒服的地方
最近一直在写 Rust 代码,发现里面有好些写起来不舒服,不 ergonomic 的地方……大概我需要一些语法糖……
一个简单的 Generics
让我们从一个小例子出发。假设我想写一个 generic 函数,输入一个数,输出它加一,在 C++ 里可以这么写:
1 | template<typename T> T add(T a) { |
简洁明了清晰易懂,当然它也没啥保证就是了……例如 T
可能没 overload + 呀,溢出了怎么处理呀,等等等等。
那么现在在 Rust 里面应该怎么写呢?Rust 自然也是支持 generics 的,所以可以写:
1 | fn add<T>(x: T) -> T { |
让我们来编译一下吧!
……编译失败了……这编译错误看起来很合理,因为编译器需要知道 T
怎么重载的 +
才能编译,所以这里我们指定 +
的输入和输出才能编译。这样,我们期望输入类型 T
,输出也是 T
,那么就改成
1 | fn add<T: std::ops::Add<i32, Output = T>>(x: T) -> T { |
编译通过,运行,完美!我不禁高呼:
我们再来测试一下其它的类型吧!例如我们试试 add(2i64)
。
……然后编译又失败了……这里我们认为这个 1 的类型是 i32
,而 i32
是不能和 i64
相加的!Rust 里面没有 implicit type cast,它只实现了 i64 + i64
这种同类型的加法……
既然这样,我们不如给 i64
实现一个和 i32
的加法吧!
1 | impl std::ops::Add<i32> for i64 { |
……编译又失败了……到这里我们就不得不介绍一下 Rust 的 orphan rule:Rust 里面,如果你想实现 impl Trait for Type
,那么 Trait
或者 Type
中间至少得有一个你是当前这个 crate
里定义的(crate 是 Rust 里的最小编译单元)……而我们的实现里面, Add
和 i64
都不是我们定义的,所以我们不能为 i64
添加一个 Add<i32>
的实现……
不慌,我们还有第二种方法:我们直接加一个 T
这个类型的 1 不就好了。于是我们给 T
再加一个限制:
1 | fn add<T: std::ops::Add<T, Output = T> + From<i32>>(x: T) -> T { |
编译通过,运行,完美!我不禁高呼:
我们再来测试一下其它的类型吧!我们再试试 add(2u64)
如何?
……然后编译又失败了……哦原来是 u64
没有实现 From<i32>
……这也难怪, i32
里面可能有负数,而 u64
是表示不出负数的,作为安全的语言,我怎么忍受这种这种情况的发生呢!但是不要慌,我们还有其它的解决方案:例如 num_traits
这个包里面提供了一个 trait 叫做 FromPrimitive
,就是用来做转换的。它里面有个函数叫做 from_i32
,而且 num_traits
已经给所有基础整数类型实现了 FromPrimitive
了,我们不妨直接拿过来用吧!
……等等这个函数的 signature 怎么有点蛋疼……这个 from_i32
的 signature 是:
1 | fn from_i32(n: i32) -> Option<Self>; |
也就是说,我们返回的是一个可能的值,因为负数表示不出来嘛……安全,安全!
于是我们把代码改成
1 | use num_traits::cast::FromPrimitive; |
编译通过,运行,完美!我不禁高呼:
于是我们每次需要加一个常数了,T::from_i32(1).unwrap()
,再次加一次常数,T::from_i32(2).unwrap()
,再加循环变量 i
,整一个 T::from_usize(i).unwrap()
。让我们来写一个阶乘吧!
1 | use num_traits::cast::{FromPrimitive, AsPrimitive}; |
对此,我只能说:
自定义 float
在解析方法数质数这个 project 中,我需要实现一个自定义的实数类型 f64x2
,因为默认的 f64
精度上满足不了我的需求。怎么实现 f64x2
暂且不谈,我们谈谈使用 f64x2
这个过程中遇到的问题。
由于我使用了 num_complex
里的 Complex<T>
,所以我不得不实现 impl Float for f64x2
,这里面包含了很多常用函数,例如 sin
, cos
, exp
, log
这些。但是如果你真的要实现 Float
这个 trait 的话,以下这些函数你全部得实现……
1 | pub trait Float: Num + Copy + NumCast + PartialOrd + Neg<Output = Self> { |
我比较懒,好多函数我用不到就没实现了,里面就写 todo!()
……我觉得 Float
这个 trait 包的东西太多了,GitHub 上也有一个 issue,但是从发言记录来看,短期间应该是看不到更细粒度的 Float
了……
第二个问题是无意中写出来的。我们看这个:
1 | 1.0 + Complex::<f64>::new(1.0, 2.0) |
这点小代码就计算 \(\frac{1}{1 + 2i}\),看起来很合理是吧。没错,它确实很合理,就是能编译通过。但是它合理的方式有点出乎我意料:我以为 num_complex
里面实现了impl<T> Add<Complex<T>> for T
,但是并不是这样的……原因在于这个 impl 里面 T
和 Add
都不是这个库义的,所以违反了 orphan rule……那么这段代码为啥能编译通过?我去看了一下,发现,库里面用宏实现了 impl<f64> Add<Complex<f64>> for f64
以及 f32
(这里),嗯也很合理。于是问题来了,我的 f64x2
怎么办呢……我就只能自己实现 impl<f64x2> Add<Complex<f64x2>>
了……人家 num_complex
不是已经用宏实现过一次了嘛,直接拿过来用不就好了……然而, num_complex
没有 export 这个 macro,所以我只能瞪着源代码着急……留给我的选项就只能……我把它的 macro 复制粘贴过来,改个参数直接用……
Composition vs. Inheritance
事情到这里还没有结束。假设某天,我把我自己的 f64x2
发布了出去,有人用着用着发现我没有实现另外某个库里的一个 trait(举个例子,假设我没有实现 num_traits::FromPrimitive
),而他需要这个 trait,那么这个时候咋整呢……由于 orphan rule,他不能帮我 impl FromPrimitive for f64x2
。而 Rust 里面没有 inheritance,所以他不能 inherit 我的 f64x2
。Rust 的解决方案叫做 new type pattern ,大概就是你自己造一个新的 type,姑且叫做 f64y2
吧,里面就一个成员,类型 f64x2
, f64y2
就像是一个 f64x2
的 wrapper。
这里谈两个概念,inheritance 和 composition。inheritance 就是继承,B 继承自 A,所以 B 可以做 A 做的所有事,B 的定义里面指定了 B 和 A 的不同;composition 是 B 里面包含 A,但是你需要指定 B 能做的事,自己去实现 method,并把其代理给 A,默认 B 啥都不能做。OOP 里面强调 inheritance 而不是 composition,而 Rust 则偏重 composition,或者更极端的说,Rust 不支持 inheritance。
很明显可以看出,这个 wrapper f64y2
做的事就是 composition 而不是 inheritance。composition 带来的问题是,当一个 object 能做很多事的时候(即实现了很多 trait ),每次新建一个 type 会显得非常繁琐。在我们这个例子中,我们需要一个一个把 f64y2
的加减乘除代理给 f64x2
,然后把 Float
/ FloatConst
这些 trait 也代理一下。你可以想象有个二分图,左边的所有的 type,右边是所有的 trait,type 实现了 trait 则连一条边。那么 composition 就是说左边新建一个点,然后一条一条慢慢连边,而 inheritance 则是左边 clone 一个点,把对应的边也 clone 过去,然后我再根据情况加边(加新的 method)或者改边(overload)。如果 trait 不多,那么 composition 也无妨,但我的情况则是 f64x2
这种 trait 贼多的 type,一个一个 trait 去 impl 实在有点啰嗦……而且一个一个代理说实话,全是一些 boilerplate,让人感觉这种工作毫无意义……
当然社区里也试图整了一些方案,有用 macro 来减小工作量的(例如 delegate 和 ambassador),也有 RFC(例如 这个)。delegate 还是得一个一个枚举所有的 trait 以及其中的 method,ambassador 需要在定义 trait 的时候就加上它的 proc macro,而我们通常没有权限在定义 trait 的地方修改代码。RFC 那边,很不幸,这个 18 年提交的 RFC 在 21 年被 close 了,原因是人手不够……所以还是没有舒服的解决方案……现在看起来,Rust roadmap 上更注重的是新 feature 的添加而不是 polish 这个语言,让我这种 developer 写得舒服的 priority 可能排的很低……
Efficient Code Reuse
当然,Rust 里面肯定早就有人注意到这些事情了,早在 14 年,Rust 还没有出 1.0 的时候,GitHub 上就有这么一个 issue,说是要 efficient code reuse,当中提了几条,我们一条一条来读读:
- cheap field access from internal methods; 这条对应的是 Niko 的这个 repo,不好意思,18 年之后就没有动静了……这条说的是,现在的 trait 只能说你必须有这个 method,没办法限制必须有这个 field,而这个 RFC 试图在 trait 加入 field 限制。我之前在写网络流的时候就遇到过这个问题。我定义了一个抽象的
Graph<E>
,最大流、费用流、最短路对不同的E
的实现。然而现在的写法只能用e.get_weight()
这种方法来实现,特别丑陋,搞得我心情不好,不想写了…… - cheap dynamic dispatch of methods; 这个应该指的是
dyn Trait
。我暂时没用到过,不予评价。 - cheap downcasting; 不懂。
- thin pointers; 不懂,但是可能已经有了。
- sharing of fields and methods between definitions; 这个没有。GitHub 上有讨论,但是没个所以然。
- safe, i.e., doesn't require a bunch of transmutes or other unsafe code to be usable; 这个看情况……我在写数据结构的时候
unsafe
还是满天飞,除此之外不多。 - syntactically lightweight or implicit upcasting; 没用到,没关注过。
- calling functions through smartpointers, e.g.
fn foo(JSRef<T>, ...)
; 不懂。没看出为啥这是个问题。 - static dispatch of methods. 这个有。
整体而言,我感觉 Rust 里的 code reuse 不太行……
upvote 数最高的几个 RFC
前几天在 Reddit 上看到一个帖子,有人总结了 Rust RFC repo 上 upvote 数最高的几个 RFC,我们也来依次看看:
- Wishlist: functions with keyword args, optional args, and/or variable-arity argument (varargs) lists 这个就是带名参数、可选参数,以及变长参数。一个都还没实现,甚至连个 RFC 都没出。对于这类情况,非官方建议是 builder pattern,又是又臭又长的 boilerplate,得新建一个 type ,每个可选参数都要写个 method,用的时候还得 import 进来……
- Destructuring assignment 就是能够
(a, b) = (b, a)
。应该前不久 merge 了,估计 v1.59 能出来。 - Transition to rust-analyzer as our official LSP (Language Server Protocol) implementation 这个是 IDE 相关的,不知道怎么样了,但是 vscode 里的 Rust Analyzer 用的挺舒服的。
- Higher kinded polymorphism 不懂,无法评论。
- implement a proper REPL 没研究过,从 GitHub issue 上来看没啥进展……
- Enum variant types 大概是对于
enum Foo<A, B> { L(A), R(B) }
让Foo::L
能成为一个独立的 type。因为人手不够鸽了。 - non-lexical lifetimes 没仔细看。根据 GitHub issue 来看,可能做的差不多了 ?
- RFC: Structural Records 大概就是匿名结构
{ a: 1, b: 2.0, c: "3"}
。有了这个,带名参数就可以实现了,在缺少带名参数的情况下,这个 RFC 可能是最方便的选择之一了……然而官方觉得这个 priority 不够,鸽了。 - Generic associated types (associated type constructors) 在做了在做了.jpg,发了 blogpost 了。
- standard lazy types 替代著名的
lazy_static
库。这个虽然没有被 merge ,但是代码都已经进 nightly 了,所以我也不知道官方态度是啥……
整体看下来,1/2/8 都是关于 ergonomics 的,6 或许勉强也算?有些 RFC 有些年头了,现在还没有一个满意的结果,可能这就是小心驶得万年船吧……毕竟……
我现在写代码都是写一下让自己快乐的代码,ergonomics 还挺重要的。下一次我可能得考虑一下 Zig, Crystal, Nim 这类语言了……C/C++?那是啥?