前言

在我刚接触编程时入门的是 C#,一款经典的编译语言,又接触了 JS,总的来说,这两款语言与目前多数主流语言有多种相同的特点,Object Oriented,GC 等。在编写了几年后,我也很是习惯它们的写法,后来在我接触到 Rust 之后,由于 Rust 独特的设计,我以往的编程习惯在 Rust 上遭遇了很大的挑战,会时常感到非常不便和束手束脚。

在我渐渐习惯 Rust 后它就成了我最喜欢的语言。这里我会根据我以往的一点经验,将 Rust 和 C# 做一点写作习惯上的比较。如果读者有入手 Rust 的打算,这篇文章应该对你有所帮助。

语言没有优劣之分,使用 C# 作比较是因为我写了几年 C#,相对熟悉一点。

引用对象以及所有权

各种语言都有自己的一套内存管理方式,在 C# 里,使用 class 关键字定义的对象被托管在堆上所赋值的变量是一个引用,它指向了堆上的地址。虽然在我看来,它与 C 中的指针,Rust 中的借用大致原理差不多,但在实际使用中,我可以随心所欲地传递这个引用。

// C#
var p = new ClassD();
fun1(p);
fun2(p);

就算是在多线程中,也可以随意使用这个引用,尽管很可能在运行时造成错误。

上面三行非常简单的写法,在 Rust 中是编译不通过的:

// Rust

fn foo(st: String) {}

let st = String::new();

foo(st);
foo(st);
~~~~~
// use of moved value: `st`
// value used here after move

这涉及到 Rust 中一个重要概念:所有权。

  1. 变量持有它的值的所有权
  2. 变量赋值给别的变量会转移所有权
  3. 变量不能使用它没有持有所有权的值

在上面的代码中:

let st = String::new();

构建了一个字符串对象,将它赋值给了变量 st,那么 st 拥有了这个对象的所有权,st 可以使用这个对象。(概念 1)

随后将 st 传递给函数 foo,它的形参是 String,此时把 st 传递给 foo 后,st 就不再持有了这个对象的所有权。(概念 2)

由于 st 失去了所有权,st 不能再使用这个对象,所以将 st 传递给 foo 编译失败。(概念 3)

你看,不了解所有权的概念,连赋值都做不到。

在进阶用法中,有多种方式来处理和绕过这种看起来有点烦人的所有权检查,但也同样的,它又要涉及到一些额外的概念。总是不能像 C# 一样随意使用。所有权带来的好处也是明显的:它严格限制了值的生命周期,为编译器知道在何处释放它提供了基础。

抽象

面向对象的编程思想鼓励人们将关联的数据和行为聚合在一起,在 C# 中的 struct 和 class 都支持定义属性和方法,方式是写在同一个 class 大括号内:

class D {
	 string Id { get; set; }
	 string GetId() {}
}

这是一种不错的方式,class 关键字起到了很好的聚合作用。有时候如果我只需要定义属性或者只需要定义方法,就会得到一个有点干巴巴的类。这也是允许的,你可以在 Rust 里用同样的方式来写一个 struct,就像 C# 的 class 一样,在 Rust 里可以用这样的方式来定义一个没有属性只有方法的 struct:

struct D { }

impl D {
	 // ....
}

上面定义的在括号里没有任何属性,在 Rust 里它支持更加紧凑的写法:

struct D;

如果不需要属性,那么连括号都不需要,这种叫单元结构体。

一开始我并没有使用这种单元结构体,而是直接在模块下定义一个方法。这种写法是我多年写 C# 和 JS 的习惯,在 C# 里, VS(Visual Studio)会自动帮我往新建的 .cs 文件里定义一个与文件名同名的类,我可以不费力的直接在类里定义方法,不会注意到我已经定义了一个 class;而在 JS 里定义一个类的方式,不管是 function 还是 class,如果只定义方法的话都麻烦得令人抓耳挠腮,所以更倾向直接在文件下定义方法。

尽管如此,C# 也提供了令人满意的抽象,它可以用 类名.方法 的写法调用静态函数,也因为 VS 对 C# 的支持,帮我写了类名和大括号;在 JS 中,我将方法直接写在文件中,提供的抽象不大够,我可能会直接这样调用 toSomething(),如果一个文件中有多个不是很相关的方法,那无疑增加了难度。

这当然是一种取舍,我宁愿少写一个 class。

在 Rust 里我遇到了和 JS 类似的处境,我直接定义了方法,调用时也类似 to_something(),这也不能提供很好的抽象。

直到我发现了单元结构体,它不定义任何的属性,struct D;,可以用 impl 块给他定义方法:

impl D {
  // ...
}

调用的时候单元结构体的名字提供了抽象,D::do_something()

在 Rust 里,如果有多个属于同一模块下的方法,定义在一个单元结构体下是不错的方式。

Wrap! Wrap! Wrap!

Rust 中你大概率会见到这么一种代码:

let t = Arc<Mute<Box<dyn D>>>;

噢老天这层层嵌套的泛型是什么鬼玩意儿!

每次写到这堆玩意儿之前我都很怀念 C# 的写法,如果一个方法要返回接口,可以直接将接口作为返回值来定义:

IInterface Foo() {}

然后就可以直接返回实现了这个接口的类,很合理对吧。

但是在 Rust 里这种写法不行的,因为编译器需要一个已知的大小,如果在 Rust 里方法直接返回一个 trait,却不知道这个 trait 实际有多大,那么编译器就无法编译。

Rust 提供了一种写法:动态分配。返回一个指向堆上的指针,指针的大小是已知的。写法为:

fn foo() -> Box<dyn trait> {}

返回值 Box<dyn trait>,Box 是一个智能指针,它将对象分配到堆上,并持有指向这个地址的指针,Box 的大小是已知的。dyn 表示动态分配,后跟着一个 trait,意思是只要实现了这个 trait 的结构都满足条件,所以,如果有两个结构:A、B 都实现了这个 trait,都可以被返回:

Box::new(A)
// 或者
Box::new(B)

这样可以实现类似接口的用法,看上面的原理,很合理对吧。但是也太~麻烦了。所以后来 Rust 推出了新的写法:impl trait 以简化。但是这种写法也只能用于方法的返回值和入参,在其他方面还是只能用 Box<dyn trait> 这种写法。必须在外面 wrap 一层 Box

如果是在 C# 里的话根本不用理会这种事儿是吧,就算我对什么堆栈指针不熟悉也能咔咔一顿返回一个接口实现对吧。

在 Rust 里每个 wrap 都有必要的理由,一开始的代码:

let t = Arc<Mute<Box<dyn D>>>;

Box<dyn D> 就像我们上面介绍的,为了实现动态分配,而其他的,Mute<T> 是为了在多线程中共享数据,Mute<T> 是一个锁;Arc<T> 是为了可以将对象移动到移动其他线程中。

因为 Rust 的线程安全机制,我们又 wrap 了两层东西。

在 Rust 里必须要习惯这种 wrap 写法。好在 Rust 提供了用 type 关键字定义类型的功能,可以用:

type NewType = Arc<Mute<Box<dyn D>>>;

来取一个更加方便一点的类型定义。

Conclusion

在一开始写 Rust 的时候上面的概念困扰了我挺久,但是现在回过头来看又觉得上面是很基础的东西了,写完之后又觉得太简单没有什么写的必要。

但算了,可能别人需要吧。