对 王垠《对 Rust 语言的分析》的分析

admin 2022年5月16日09:32:10评论64 views字数 8392阅读27分58秒阅读模式

引言

王垠在2016年写下这篇文章:《对 Rust 语言的分析》[1]

当时觉得这篇文章对 Rust 语言的分析太偏颇,但是王垠说这篇文章会一直更新。这几年也有不少新手在群里引用王垠这篇文章对 Rust 的看法,或者直接问我,我原以为过去五年了,王垠应该对文章里对观点有所更新吧,然而并没有。

垠神粉丝众多(包括我自己),影响比较大。垠神自己对 Rust 怎么看,其实我并不关心。我只是针对这篇文章里对 Rust 的观点,和大家做一次探讨和交流。希望能对 Rust 新手提供另一种思考角度。

说明:

我发这篇文章,绝不是为了引战。如果有认为不对的地方,请反馈交流。

分析「变量声明语法」

let x: i32 = 8;

王垠认为,这样的语法是丑陋的。

本来语义是把变量 x 绑定到值 8,可是 x 和 8 之间却隔着一个“i32”,看起来像是把 8 赋值给了 i32。

首先,语法美丑是很主观的事情。至少我并不认为这语法丑陋,而且我也不反对有人说 Rust 语法丑。

如果要公正客观的评价一门语言的美丑,我认为至少要结合这门语言的设计哲学(动机)来评判。而不是你觉得,你认为。

Rust 美学之一:显式之美。

let x: i32 = 8;
let x = 8i32;
let x = 8;

代码中把 Rust 变量的类型看的清清楚楚,增加了可读性,可维护性。类型系统,是 Rust 要对 开发者 贯彻的理念之一。在 Rust 代码中,你会看到很多这样的代码,各种类型签名用于 函数签名、trait 等。

分析「变量可以重复绑定」

let mut x: i32 = 1;
x = 7;
let x = x; // 这两个 x 是两个不同的变量

let y = 4;
// 30 lines of code ...
let y = "I can also be bound to text!";
// 30 lines of code ...
println!("y is {}", y);      // 定义在第二个 let y 的地方

王垠 吐槽 Rust 的变量遮蔽 是多余的。

在 Yin 语言最初的设计里面,我也是允许这样的重复绑定的。第一个 y 和 第二个 y 是两个不同的变量,只不过它们碰巧叫同一个名字而已。你甚至可以在同一行出现两个 x,而它们其实是不同的变量!这难道不是一个很酷,很灵活,其他语言都没有的设计吗?后来我发现,虽然这实现起来没什么难度,可是这样做不但没有带来更大的方便性,反而可能引起程序的混淆不清。在同一个作用域里面,给两个不同的变量起同一个名字,这有什么用处呢?自找麻烦而已。

首先,Yin语言并不存在于真实世界,至少王垠没有公开这门语言。我觉得把 Rust 和 Yin 语言相比,有失偏颇。

其次,虽然我没有和王垠一样读过名校,也没有系统地学过计算机编程语言理论。但我对编程语言有一个基本的认知我觉得应该大家都比较赞同的,那就是,任何一门非玩具编程语言的出现,都是为了解决一些现实里的问题,对不对?而语言的语法设计,肯定是为了满足这门语言的设计目标,绝不是为了满足「很酷,其他语言都没有」这样的目标。

最后,Rust 语言的出现,是为了解决内存安全问题。变量默认不变,就是它为了解决此问题而采取的一种方案。但 Rust 也提供 mut 关键字来定义可变变量。那为什么需要「变量遮蔽」这种功能呢?

比如上面的例子,在下面我们看到一个对变量 y 的引用,它是在哪里定义的呢?你需要在头脑中对程序进行“数据流分析”,才能找到它定义的位置。

你找最近的那个定义就可以了,不需要做什么数据流分析。

比如上面的例子,在下面我们看到一个对变量 y 的引用,它是在哪里定义的呢?你需要在头脑中对程序进行“数据流分析”,才能找到它定义的位置。从上面读起,我们看到 let y = 4,然而这不一定是正确的定义,因为 y 可以被重新绑定,所以我们必须继续往下看。30 行代码之后,我们看到了第二个对 y 的绑定,可是我们仍然不能确定。继续往下扫,30行代码之后我们到了引用 y 的地方,没有再看到其它对 y 的绑定,所以我们才能确信第二个 let 是 y 的定义位置,它是一个字符串。

这难道不是很费事吗?更糟的是,这种人工扫描不是一次性的工作,每次看到这个变量,你都要疑惑一下它是什么东西,因为它可以被重新绑定,你必须重新确定一下它的定义。如果语言不允许在同一个作用域里面重复绑定同一个名字,你就根本不需要担心这个事情了。你只需要在作用域里面找到唯一的那个 let y = ...,那就是它的定义。

「变量遮蔽」在 Rust 社区还有一个名字,叫「继承式可变」。

我们学过 Rust 的都知道,Rust 以所有权机制著称。所有权机制是线性类型映射资源的一种机制。

比如,通过let变量绑定一块内存,那么这个变量就拥有这块内存区域的所有权。而当你把这个变量赋值给另外一个变量的时候,对于符合 Move 语义的类型,所有权是会转移的。


let foo = Some("42".to_string());
let foo = foo.unwrap(); 

看上面的示例, 第一行 foo 是一个拥有 move 语义的 Option 类型。第二行在unwrap 之后,重新赋值给另外一个同名 foo 变量,那么第一个 foo就会被 move,从而不能再被访问。

但是你再看下面的代码:

fn main(){
    let mut foo = Some("42".to_string());
    let bar = foo.unwrap();
    foo = Some("42".to_string());
}

我如果不用同名 foo 变量,而改为 bar 变量,看看会有什么问题。

因为在 Rust 里的 Move 行为,实际上让变量恢复了「未初始化」的状态,你其实还可以给它重新赋值。

所以,上面的代码逻辑,我本来是想把 foo 解包之后就不需要它了,也不想让它能重新被赋值。那么此时,就需要 变量遮蔽 了。我用 foo 变量重新绑定那块内存,赋与新值。内存的值改变了,变量也继承自之前的变量,也没有使用 mut关键字。

所以,把这种变量遮蔽特性叫做 继承式可变。

变量遮蔽,在用 Rust 编写图数据结构的时候,也非常有用,因为它可以返回不同的类型。

let c = Context::new();
let c = c.rot_deg(90.0);
let transform = c.transform;

let c = Context::new();
let c = c.scale(2.03.0); 
let transform = c.transform;

上面代码用同一个变量 c,持有上下文,利用变量遮蔽,调用不同的方法,返回不同的类型,在代码维护可读性上是非常有帮助的。

也许你会说,只有当有人滥用这个特性的时候,才会导致问题。然而语言设计的问题往往就在于,一旦你允许某种奇葩的用法,就一定会有人自作聪明去用。因为你无法确信别人是否会那样做,所以你随时都得提高警惕,而不能放松下心情来。

当你理解了 Rust 所有权机制的时候,你还会说这样的语法奇葩吗?同一个变量,不管重新绑定了几次,它总是对那个内存区域掌握所有权。

我还真发现一个滥用的示例:

static OVERRIDE: f32 = 300f32;
 
fn main() {
    let mut percentage = 100f32;
    percentage = percentage.round();
    let mut factor = 100f32 / percentage;
 
    if OVERRIDE > 200f32 {
 
        ///!! 这里滥用 !!///
        let mut percentage = OVERRIDE;
        //percentage = OVERRIDE; // 正确用法
 

        percentage = percentage.round();
        factor = 100f32 / percentage;
    }

    println!("Total is {}", percentage*factor);
 
}

不过,我问题就来了,这样的滥用,真的能怪「变量遮蔽」这个功能?能怪 Rust 语法设计?

分析「类型推导」

let x = 8;  // x 的类型推导为 i32

王垠说 建议少用类型推导,明确标识其类型。

他用 C# 代码举例:

var correct = ...;
var id = ...;
var slot = ...;
var user = ...;
var passwd = ...;

然而看过很多 C# 代码之后你发现,这看似方便,却让程序变得不好读。在看 C# 代码的时候,我经常看到一堆的变量定义,每一个的前面都是 var。我没法一眼就看出它们表示什么,是整数,bool,还是字符串,还是某个用户定义的类?

这点好像说的很有道理。然而,王垠高估 Rust 的类型推导了,Rust 类型推导可能并没有王垠认为的那么强,实际写代码的时候,确实需要明确标注一些类型。当然,写代码的时候,也很需要 IDE 的配合,一个好的 IDE 可以提升开发效率。

分析「动作的“返回值”」

let mut y = 5;
let x = (y = 6);  // x has the value `()`, not `6`

王垠吐槽,Rust 表达式从 OCmal 照搬了本来就是不好的设计

奇怪的是,这里变量 x 会得到一个值,空的 tuple,()。这种思路不大对,它是从像 OCaml 那样的语言照搬过来的,而 OCaml 本身就有问题。

可是在 Rust 里面,不但动作(比如 y = 6 )会返回一个值 (),你居然可以把这个值赋给一个变量。其实这是错误的作法。原因在于 y = 6 只是一个“动作”,它只是把 6 放进变量 y 里面,这个动作发生了就发生了,它根本不应该返回一个值,它不应该可以出现在 let x = (y = 6); 的右边。就算你牵强附会说 y = 6 的返回值是 (),这个值是没有任何用处的。更不要说使用空的 tuple 来表示这个值,会引起更大的类型混淆,因为 () 本身有另外的,更有用的含义。

你根本就不应该可以写 let x = (y = 6); 这样的代码。只有当你犯错误或者逻辑不清晰的时候,才有可能把 y = 6 当成一个值来用。Rust 允许你把这种毫无意义的返回值赋给一个变量,这种错误就没有被及时发现,反而能够通过变量传播到另外一个地方去。有时候这种错误会传播挺远,然后导致问题(运行时错误或者类型检查错误),可是当它出问题的时候,你就不大容易找到错误的起源了。

这一段,我承认王垠的观点有点道理。但其实,这一切都在 Rust 编译器的掌控之中。

fn main(){
    let mut y = 5;
    let x = (y = 6); 
    let z = x + y; // 当误以为 x 被赋值为 6 的时候,继续使用
}

编译器会报错,而且错误原因直接被定位。

error[E0369]: cannot add `{integer}` to `()`
 --> src/main.rs:4:15
  |
4 |     let z = x + y;
  |             - ^ - {integer}
  |             |
  |             ()

error: aborting due to previous error; 1 warning emitted

For more information about this error, try `rustc --explain E0369`.
error: could not compile `playground`

To learn more, run the command again with --verbose.

这种错误根本无法传播到运行时,编译期就消灭了。

再回到 Rust 里的 (),其实它叫单元类型,单元类型的值,也说它本身,它并不是空元组。

确实如王垠所说,单元类型是来自于 OCmal,还有生命周期参数语法'a这种形式,也是来自于 OCmal。但在 OCmal 里是不是错误设计,咱们不管,但在 Rust 里,它确实解决了一些实际问题。

Rust 的设计目标是内存安全,要达到这一目标,就要先做到类型安全。所以,Rust 里一切皆类型。包括上面的 (y=6),它本身也是一个表达式,表达式就要有返回值。而在这里,如果要把 (y=6) 返回值指定为 y 的值,就会打破 Rust 语言表达式求值一致性。一门好的语言,一致性很重要,它有关开发者掌握它的学习成本和使用体验(想想js)。

fn main(){
    let mut y = 5;
    let x = (y = 6); 
}

在 Rust 中,等号左右两侧代表不同的表达式:

  • 左边为位置表达式。它求值结果代表内存位置。
  • 右边为值表达式。并且右边为值上下文。在等号右边的就一定要求值。

你现在告诉我,(y=6) 这个表达式的求值结果是什么?谁告诉你一定要等于 6 ?它只是一个赋值过程。

看下面代码,如果你想把 y=6 的y值赋值给 x,就需要用「块表达式(花括号)」。

fn main(){
    let mut y = 5;
    let x = {y = 6; y}; 
    assert_eq!(12, x+y)
}

「块表达式(花括号)」的求值规则是返回最后一个表达式的值。当然,前提是不加分号。

Rust 为了统一语句与表达式,使用 分号。当 Rust 遇到 分号,会继续往后面求值,如果分号后面什么都没有,则返回分号的值,就是单元类型。

正是因为 Rust 这样的求值规则,你才可以这么写出这么有创意的 Rust 代码:

                         impl<
                 'four>For for
                &  'four  for<
               'fore>For where
             for<'fore>For:For
         {fn four(     self:&&
      'four for        <'fore>
      For){            print!(
    "four"             )}}  fn
   main
(){             four(&(
  four as for<'four>fn(&'four for<'fore>
  For)))}trait For{fn four(&self){}}fn
  four
(four:&for<'four>For){<&for<'four>
  For as For>::four(&{((&four).four(),four
                      .four());
                       four})}
                      impl For
                      for for<
                      'four>fn
                      
(&'four
                      for<'fore
                      >For){fn
                      four
(&self
                      ){print!(
                      "for")}}

分析 「return 语句」

fn add_one(x: i32) -> i32 {
    x + 1
}

王垠 吐槽 Rust 里面你不需要直接写“return”这个语句,应该都加 return 。

如果你隐性的返回函数里最后一个表达式,那么每一次看见这个函数,你都必须去搞清楚最后一个表达式是什么,这并不是每次都那么明显的。

首先,Rust 允许你在函数最后返回值加上 return。

其次,Rust 里「在函数里返回最后一个表达式」实际上只是「块表达式返回最后一个表达式」的特例而已。

很多时候,Rust 代码里使用 「块表达式」来求值,并不需要返回函数啊。比如下面代码:

fn sum(x: i32) -> i32 {
     let y = if x > 10 {5else {6};
     y + 42
}

fn main(){
    let x = 10;
    let y = sum(x);
}

请问,我为什么要在 if/else 表达式里加 return ?

分析「数组的可变性」

王垠吐槽:

Rust 里面,你只有一个地方可以放“mut”进去,所以要么数组指针和元素全部都可变,要么数组指针和元素都不可变。你没有办法制定一个不可变的数组指针,而它指向的数组的元素却是可变的。

fn main() {
    let m = [123];      // 指针和元素都不可变
    m[0] = 10;              // 出错
    m = [456];          // 也出错

    let mut m = [123];  // 指针和元素都可变
    m[0] = 10;              // 不出错
    m = [456];          // 也不出错
}

这其实是 Rust 新手经常懵的地方。可是,五年了,五年了,垠神你 。。。

我来解释一下:

fn main() {
    
    let m = [123];      // 默认绑定不可变
    m[0] = 10;              // 出错,因为不满足条件,在rust里修改元素,需要可变绑定,这里索引操作,实际上需要可变借用 `&mut m`
    m = [456];          // 也出错,因为绑定不可变

    let mut m = [123];  // 默认绑定可变
    m[0] = 10;              // 不出错,因为满足条件,在rust里修改元素,需要可变绑定,实际上需要可变借用 `&mut m`
    m = [456];          // 也不出错,因为绑定可变
}

分析 「内存管理」

王垠吐槽:

Rust 号称实现了非常先进的内存管理机制,不需要垃圾回收(GC)或者引用计数(RC)就可以“静态”的管理内存的分配和释放。然而仔细思考之后你就会发现,这很可能是不切实际的梦想(或者广告)。

当你真的去用 Rust 的时候,你会发现它是真的。

内存的分配和释放(如果要及时释放的话),本身是一个动态的过程,无法用静态分析来实现。现在你说可以通过一些特殊的构造,特殊的指针和传值方式,静态的决定内存的回收时间,真的有可能吗?

我认识 Rust 六年了,从来没有听说 Rust 能「静态决定内存回收时间」,出处是哪里呢?

Rust 那些炫酷的 move semantics, borrowing, lifetime 之类的概念加在一起,不但让语言变得复杂不堪,我感觉并不能从根本上解决内存管理问题。

所有权语义,并没有让 Rust 语言变的复杂不堪。borrowing 和 lifetime 学习曲线确实高了点,但它真的可以解决悬垂指针问题。

// 王垠:真够烦的,我感觉我的眼睛都没法 parse 这段代码了。
fn foo<'a'b>(x: &'a str, y: &'b str) -> &'a str {
}

现在 Rust 学过生命周期的新手都可以讲清楚这段代码是干嘛的。

当然我的意见也许不是完全准确,可我真是没有时间去琢磨这么多乱七八糟,不知道管不管用的概念(特别是 lifetime),更不要说真的用它来构建大型的系统程序了。

有用来理解这些概念,把程序改成奇葩样子的时间,我可能已经用 C 语言写出很好的手动内存管理代码了。如果你真的看进去理解了,发现这些东西可以用的话,告诉我一声!不过你必须说明原因,不要只告诉我“皇帝是穿了衣服的” 对 王垠《对 Rust 语言的分析》的分析

后面这两句应该不用我反驳了,六年过去了,Rust 到底是不是 王垠口中的 「皇帝的新衣」,大家有目共睹。

小结

评价一门语言的好坏,私以为应该结合起这门语言的设计原则和它要解决的问题领域。时间有限,就写这么多吧。欢迎反馈和交流。

(如果你喜欢这些内容,请帮忙转发给更多人看到,谢谢!)

参考资料

[1]

《对 Rust 语言的分析》: https://www.yinwang.org/blog-cn/2016/09/18/rust


原文始发于微信公众号(觉学社):对 王垠《对 Rust 语言的分析》的分析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年5月16日09:32:10
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   对 王垠《对 Rust 语言的分析》的分析https://cn-sec.com/archives/940290.html

发表评论

匿名网友 填写信息