吐槽几个 Rust 里写的不舒服的地方

最近一直在写 Rust 代码,发现里面有好些写起来不舒服,不 ergonomic 的地方……大概我需要一些语法糖……

一个简单的 Generics

让我们从一个小例子出发。假设我想写一个 generic 函数,输入一个数,输出它加一,在 C++ 里可以这么写:

1
2
3
template<typename T> T add(T a) {
return a + 1;
}

简洁明了清晰易懂,当然它也没啥保证就是了……例如 T 可能没 overload + 呀,溢出了怎么处理呀,等等等等。

那么现在在 Rust 里面应该怎么写呢?Rust 自然也是支持 generics 的,所以可以写:

1
2
3
fn add<T>(x: T) -> T {
x + 1
}

让我们来编译一下吧!

……编译失败了……这编译错误看起来很合理,因为编译器需要知道 T 怎么重载的 + 才能编译,所以这里我们指定 + 的输入和输出才能编译。这样,我们期望输入类型 T,输出也是 T,那么就改成

1
2
3
fn add<T: std::ops::Add<i32, Output = T>>(x: T) -> T {
x + 1
}

编译通过,运行,完美!我不禁高呼:

我们再来测试一下其它的类型吧!例如我们试试 add(2i64)

……然后编译又失败了……这里我们认为这个 1 的类型是 i32,而 i32 是不能和 i64 相加的!Rust 里面没有 implicit type cast,它只实现了 i64 + i64 这种同类型的加法……

既然这样,我们不如给 i64 实现一个和 i32 的加法吧!

1
2
3
4
5
6
7
impl std::ops::Add<i32> for i64 {
type Output = i64;

fn add(self, rhs: i32) -> i64 {
self + rhs as i64
}
}

……编译又失败了……到这里我们就不得不介绍一下 Rust 的 orphan rule:Rust 里面,如果你想实现 impl Trait for Type,那么 Trait 或者 Type 中间至少得有一个你是当前这个 crate 里定义的(crate 是 Rust 里的最小编译单元)……而我们的实现里面, Addi64 都不是我们定义的,所以我们不能为 i64 添加一个 Add<i32> 的实现……

不慌,我们还有第二种方法:我们直接加一个 T 这个类型的 1 不就好了。于是我们给 T 再加一个限制:

1
2
3
fn add<T: std::ops::Add<T, Output = T> + From<i32>>(x: T) -> T {
x + T::from(1)
}

编译通过,运行,完美!我不禁高呼:

我们再来测试一下其它的类型吧!我们再试试 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
2
3
4
5
use num_traits::cast::FromPrimitive;

fn add<T: std::ops::Add<T, Output = T> + FromPrimitive>(x: T) -> T {
x + T::from_i32(1).unwrap()
}

编译通过,运行,完美!我不禁高呼:

于是我们每次需要加一个常数了,T::from_i32(1).unwrap() ,再次加一次常数,T::from_i32(2).unwrap(),再加循环变量 i,整一个 T::from_usize(i).unwrap() 。让我们来写一个阶乘吧!

1
2
3
4
5
6
7
8
9
10
use num_traits::cast::{FromPrimitive, AsPrimitive};
use std::ops::Mul;

fn factorial<T: Mul<T, Output = T> + FromPrimitive + AsPrimitive<usize>>(x: T) -> T {
let mut ret = T::from_usize(1).unwrap();
for i in 2..=x.as_() {
ret = ret * T::from_usize(i).unwrap();
}
ret
}

对此,我只能说:

自定义 float

在解析方法数质数这个 project 中,我需要实现一个自定义的实数类型 f64x2,因为默认的 f64 精度上满足不了我的需求。怎么实现 f64x2 暂且不谈,我们谈谈使用 f64x2 这个过程中遇到的问题。

由于我使用了 num_complex 里的 Complex<T> ,所以我不得不实现 impl Float for f64x2 ,这里面包含了很多常用函数,例如 sin, cos, exp, log 这些。但是如果你真的要实现 Float 这个 trait 的话,以下这些函数你全部得实现……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
pub trait Float: Num + Copy + NumCast + PartialOrd + Neg<Output = Self> {
fn nan() -> Self;
fn infinity() -> Self;
fn neg_infinity() -> Self;
fn neg_zero() -> Self;
fn min_value() -> Self;
fn min_positive_value() -> Self;
fn max_value() -> Self;
fn is_nan(self) -> bool;
fn is_infinite(self) -> bool;
fn is_finite(self) -> bool;
fn is_normal(self) -> bool;
fn classify(self) -> FpCategory;
fn floor(self) -> Self;
fn ceil(self) -> Self;
fn round(self) -> Self;
fn trunc(self) -> Self;
fn fract(self) -> Self;
fn abs(self) -> Self;
fn signum(self) -> Self;
fn is_sign_positive(self) -> bool;
fn is_sign_negative(self) -> bool;
fn mul_add(self, a: Self, b: Self) -> Self;
fn recip(self) -> Self;
fn powi(self, n: i32) -> Self;
fn powf(self, n: Self) -> Self;
fn sqrt(self) -> Self;
fn exp(self) -> Self;
fn exp2(self) -> Self;
fn ln(self) -> Self;
fn log(self, base: Self) -> Self;
fn log2(self) -> Self;
fn log10(self) -> Self;
fn max(self, other: Self) -> Self;
fn min(self, other: Self) -> Self;
fn abs_sub(self, other: Self) -> Self;
fn cbrt(self) -> Self;
fn hypot(self, other: Self) -> Self;
fn sin(self) -> Self;
fn cos(self) -> Self;
fn tan(self) -> Self;
fn asin(self) -> Self;
fn acos(self) -> Self;
fn atan(self) -> Self;
fn atan2(self, other: Self) -> Self;
fn sin_cos(self) -> (Self, Self);
fn exp_m1(self) -> Self;
fn ln_1p(self) -> Self;
fn sinh(self) -> Self;
fn cosh(self) -> Self;
fn tanh(self) -> Self;
fn asinh(self) -> Self;
fn acosh(self) -> Self;
fn atanh(self) -> Self;
fn integer_decode(self) -> (u64, i16, i8);
}

我比较懒,好多函数我用不到就没实现了,里面就写 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 里面 TAdd 都不是这个库义的,所以违反了 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 吧,里面就一个成员,类型 f64x2f64y2 就像是一个 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 来减小工作量的(例如 delegateambassador),也有 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,当中提了几条,我们一条一条来读读:

  1. cheap field access from internal methods; 这条对应的是 Niko 的这个 repo,不好意思,18 年之后就没有动静了……这条说的是,现在的 trait 只能说你必须有这个 method,没办法限制必须有这个 field,而这个 RFC 试图在 trait 加入 field 限制。我之前在写网络流的时候就遇到过这个问题。我定义了一个抽象的 Graph<E>,最大流、费用流、最短路对不同的 E 的实现。然而现在的写法只能用 e.get_weight() 这种方法来实现,特别丑陋,搞得我心情不好,不想写了……
  2. cheap dynamic dispatch of methods; 这个应该指的是 dyn Trait。我暂时没用到过,不予评价。
  3. cheap downcasting; 不懂。
  4. thin pointers; 不懂,但是可能已经有了
  5. sharing of fields and methods between definitions; 这个没有。GitHub 上有讨论,但是没个所以然。
  6. safe, i.e., doesn't require a bunch of transmutes or other unsafe code to be usable; 这个看情况……我在写数据结构的时候 unsafe 还是满天飞,除此之外不多。
  7. syntactically lightweight or implicit upcasting; 没用到,没关注过。
  8. calling functions through smartpointers, e.g. fn foo(JSRef<T>, ...); 不懂。没看出为啥这是个问题。
  9. static dispatch of methods. 这个有。

整体而言,我感觉 Rust 里的 code reuse 不太行……

upvote 数最高的几个 RFC

前几天在 Reddit 上看到一个帖子,有人总结了 Rust RFC repo 上 upvote 数最高的几个 RFC,我们也来依次看看:

  1. Wishlist: functions with keyword args, optional args, and/or variable-arity argument (varargs) lists 这个就是带名参数、可选参数,以及变长参数。一个都还没实现,甚至连个 RFC 都没出。对于这类情况,非官方建议是 builder pattern,又是又臭又长的 boilerplate,得新建一个 type ,每个可选参数都要写个 method,用的时候还得 import 进来……
  2. Destructuring assignment 就是能够 (a, b) = (b, a)应该前不久 merge 了,估计 v1.59 能出来。
  3. Transition to rust-analyzer as our official LSP (Language Server Protocol) implementation 这个是 IDE 相关的,不知道怎么样了,但是 vscode 里的 Rust Analyzer 用的挺舒服的。
  4. Higher kinded polymorphism 不懂,无法评论。
  5. implement a proper REPL 没研究过,从 GitHub issue 上来看没啥进展……
  6. Enum variant types 大概是对于 enum Foo<A, B> { L(A), R(B) }Foo::L 能成为一个独立的 type。因为人手不够鸽了
  7. non-lexical lifetimes 没仔细看。根据 GitHub issue 来看,可能做的差不多了 ?
  8. RFC: Structural Records 大概就是匿名结构 { a: 1, b: 2.0, c: "3"} 。有了这个,带名参数就可以实现了,在缺少带名参数的情况下,这个 RFC 可能是最方便的选择之一了……然而官方觉得这个 priority 不够,鸽了
  9. Generic associated types (associated type constructors) 在做了在做了.jpg,发了 blogpost 了
  10. standard lazy types 替代著名的 lazy_static 库。这个虽然没有被 merge ,但是代码都已经进 nightly 了,所以我也不知道官方态度是啥……

整体看下来,1/2/8 都是关于 ergonomics 的,6 或许勉强也算?有些 RFC 有些年头了,现在还没有一个满意的结果,可能这就是小心驶得万年船吧……毕竟……

我现在写代码都是写一下让自己快乐的代码,ergonomics 还挺重要的。下一次我可能得考虑一下 Zig, Crystal, Nim 这类语言了……C/C++?那是啥?