经常看到一些帖子,关于开发者试图将他们各自的语言范式转换为 Rust,结果好坏参半,成功程度也各不相同。
在本文中,我将描述开发人员在将其他语言范式转换到 Rust 时遇到的一些问题,并提出一些替代解决方案,以帮助您克服 Rust 的局限性。
可以说,面向对象语言中被问到最多的缺失特性是继承。为什么 Rust 不让一个结构继承另一个结构呢?
你可以肯定地说,即使在 OO 世界中,继承的名声也不好,而且实践者通常尽可能地喜欢组合。但是你也可以认为,允许类型以不同的方式执行方法可能会提高性能,因此对于那些特定的实例来说是可取的。
下面是一个来自 Java 的经典例子:
interface Animal {
void tell();
void pet();
void feed(Food food);
}class Cat implements Animal {
public void tell() { System.out.println("Meow"); }
public void pet() { System.out.println("purr"); }
public void feed(Food food) { System.out.println("lick"); }
}
// this implementation is probably too optimistic...
class Lion extends Cat {
public void tell() { System.out.println("Roar"); }
}
对于 Rust,第一部分可以用 traits 实现:
trait Animal {
fn tell(&self);
fn pet(&mut self);
fn feed(&mut self, food: Food);
}struct Cat;
impl Animal for Cat {
fn tell(&self) { println!("Meow"); }
fn pet(&mut self) { println!("purr");
fn feed(&mut self, food: Food) { println!("lick"); }
}
但第二部分并不那么容易:
struct Lion;impl Animal for Lion {
fn tell(&self) { println!("Roar"); }
// Error: Missing methods pet and feed
}
显然,最简单的方法是复制这些方法。是的,复制是不好的。复杂性也是如此。创建一个独立的方法,如果需要重复代码,可以从 Cat 和 Lion impl 中调用它。
但是,你可能会说,等式中的多态性部分呢?这就是复杂的地方。面向对象语言通常给你提供动态转发,而 Rust 让你在静态语言和面向对象语言之间做出选择,这两者都有它们的成本和收益。
// static dispatch
let cat = Cat;
cat.tell();let lion = Lion;
lion.tell();
// dynamic dispatch via enum
enum AnyAnimal {
Cat(Cat),
Lion(Lion),
}
// `impl Animal for AnyAnimal` left as an exercise for the reader
let animals = [AnyAnimal::Cat(cat), AnyAnimal::Lion(lion)];
for animal in animals.iter() {
animal.tell();
}
// dynamic dispatch via "fat" pointer including vtable
let animals = [&cat as &dyn Animal, &lion as &dyn Animal];
for animal in animals.iter() {
animal.tell();
}
注意,与垃圾收集语言不同,每个变量在编译时必须有一个具体的类型。此外,对于 enum 的情况,委派 trait 的实现是冗长乏味的,但是像 ambassador[1] 这样的 crates 可以提供帮助。
将函数委托给成员的一种相当老套的方法是使用 Deref trait for polymorphism,这样在 Deref 目标上定义的函数就可以直接在 derefee 上调用。但是请注意,这通常被认为是一种反模式。
最后,可以为所有实现许多其他特性之一的类实现一个 trait,但它需要专门化,这是目前的一个 nightly 特性(尽管有一个可用的解决方案 workaround[2],如果您不想写出所需的所有样板文件,甚至可以打包在一个宏 crate 中)。trait 很可能是相互继承的,尽管它们只规定行为,而不是数据。
许多从 C++ 来到 Rust 的人一开始会想实现一个“简单的”双向链表,但很快就会发现它实际上并不简单。这是因为 Rust 想要明确所有权,因此双重链接列表需要对指针和引用进行相当复杂的处理。
一个新手可能会尝试写下面的结构:
struct MyLinkedList<T> {
value: T
previous_node: Option<Box<MyLinkedList<T>>>,
next_node: Option<Box<MyLinkedList<T>>>,
}
当他们注意到这个方法失败时,他们会添加 Option 和 Box。但是一旦他们尝试实现插入,他们就会感到很惊讶:
impl<T> MyLinkedList<T> {
fn insert(&mut self, value: T) {
let next_node = self.next_node.take();
self.next_node = Some(Box::new(MyLinkedList {
value,
previous_node: Some(Box::new(*self)), // Ouch
next_node,
}));
}
}
当然,borrow checker[3] 不会允许这样做。值的所有权是完全混乱的。Box 拥有它所包含的数据,因此列表中的每个节点都属于列表中的前一个节点和下一个节点。Rust 中的每个数据只允许有一个所有者,所以这将至少需要一个 Rc
或 Arc
才能工作。但是即使这样做也会很快变得麻烦,更不用说引用计数带来的开销了。
幸运的是,你不需要自己编写双向链表,因为标准库已经包含了一个(std::collections::LinkedList)。而且,与简单的 Vecs 相比,这种方法可能并不能给你带来好的性能,因此你可能需要相应地进行测试。
如果你真的想写一个双向链表列表,你可以参考《Learn Rust With Entirely Too Many Linked Lists》[4] ,这可以帮助你写链表,并在这个过程中学到很多关于不安全的 Rust。
同样的情况也适用于图结构,尽管你可能需要一个依赖项来处理图数据结构。Petgraph[5] 是目前最流行的,它提供了数据结构和一些图算法。
当面对自引用类型的概念时,我们可以会问: “谁拥有它?”同样,这也是 borrow checker 通常不喜欢的所有权中的一个小问题。
当你具有所有权关系并希望在一个结构中同时存储所有权对象和被所有的对象时,就会遇到这个问题。尝试一下这个方法,你会有一段艰难的时期去尝试生命周期。
我们只能猜测,许多 rustacean 已经转向不安全的代码,这是微妙的,真的很容易出错。当然,使用普通指针而不是引用会消除生命周期烦恼,因为指针不会有生命周期的烦恼。但是,这需要手动承担管理生命周期的责任。
幸运的是,有一些 crate 可以采用这种解决方案并提供一个安全的接口,比如 `ouroboros`[6], `self_cell`[7] 和 `one_self_cell`[8] 等 crates。
来自 C 或 C++ 亦或是来自动态语言的开发者,有时习惯于在他们的代码中创建和修改全局状态。例如,一位 reddit 用户说:“这是完全安全的,但 Rust 不让你这么做。”
下面是一个稍微简化的例子:
#include <iostream>
int i = 1;int main() {
std::cout << i;
i = 2;
std::cout << i;
}
在 Rust 中,这大致可以理解为:
static I: u32 = 1;fn main() {
print!("{}", I);
I = 2; // <- Error: Cannot mutate global state
print!("{}", I);
}
许多 Rustaceans 会告诉你,你并不需要这种全局的状态。当然,在这样一个简单的例子中,这是正确的。但是对于大量的用例,你确实需要全局可变状态,例如,在一些嵌入式应用程序中。
当然,有一种方法可以做到这一点,使用 unsafe。但是在这之前,根据场景的不同,你可能只想使用互斥对象(Mutex)来确保万无一失。或者,如果可变只需要在初始化时使用一次,那么 OnceCell 或 lazy_static 就可以巧妙地解决这个问题。或者,如果你真的只需要整数,那么 std::sync::Atomic* 类型也可以使用。
尽管如此,特别是在嵌入式环境中,每个字节的计数和资源通常都映射到内存中,拥有一个可变的静态变量通常是首选的解决方案。因此,如果你真的必须这么做,它看起来应该是这样的:
static mut DATA_RACE_COUNTER: u32 = 1;fn main() {
print!("{}", DATA_RACE_COUNTER);
// I solemny swear that I'm up to no good, and also single threaded.
unsafe {
DATA_RACE_COUNTER = 2;
}
print!("{}", DATA_RACE_COUNTER);
}
再次强调,除非真的需要,否则你不应该这样做。如果你想问这是不是一个好主意,答案是否定的。
新手可能会倾向于声明如下数组:
let array: [usize; 512];for i in 0..512 {
array[i] = i;
}
可是这将失败,因为数组从未初始化。然后我们尝试给它赋值,但是没有告诉编译器,它甚至不会为我们在堆栈上保留一个写入的位置。Rust 是这样挑剔,它从内容区分数组。此外,在我们读取它们之前,需要对它们进行初始化。
通过初始化 let array = [0usize; 512] ;
,我们以双重初始化为代价来解决这个问题,双重初始化可能会也可能不会得到优化ー或者,根据类型的不同,甚至可能是不可能的。参见“Unsafe Rust: How and when not to use it[9]”的解决方案。
Rust 确实不太容易。在 Rust 学习和使用过程中,你还遇到哪些「蛋疼」的地方呢?
ambassador: https://docs.rs/ambassador/0.2.1
[2]workaround: https://github.com/dtolnay/case-studies/blob/master/autoref-specialization/README.md
[3]borrow checker: https://blog.logrocket.com/introducing-the-rust-borrow-checker/
[4]《Learn Rust With Entirely Too Many Linked Lists》: https://rust-unofficial.github.io/too-many-lists/
[5]Petgraph: https://crates.io/crates/petgraph
[6]ouroboros
: https://docs.rs/ouroboros/0.9.2/ouroboros/
self_cell
: https://docs.rs/self_cell/0.8.0/self_cell/
one_self_cell
: https://docs.rs/once_self_cell/0.6.3/once_self_cell/
Unsafe Rust: How and when not to use it: https://blog.logrocket.com/unsafe-rust-how-and-when-not-to-use-it/
我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。
坚持输出技术(包括 Go、Rust 等技术)、职场心得和创业感悟!欢迎关注「polarisxu」一起成长!也欢迎加我微信好友交流:gopherstudio