作为Rust子论坛的版主,我经常看到一些关于开发者尝试将他们各自的语言范式移植到Rust的帖子,结果喜忧参半,成功程度不一。
在本指南中,我将描述开发人员在将其他语言范式移植到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"); }
}
第一部分可以用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中调用。
但是等等,你可能会说,等式中的多态性部分呢?这就是它变得复杂的地方。OO语言通常给你动态调度,而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();
}
请注意,与垃圾回收语言不同,每个变量在编译时都必须有一个具体的类型。另外,对于枚举的情况,委托实现特质是很繁琐的,但是像ambassador这样的板块可以提供帮助。
将函数委托给成员的一个相当黑的方法是使用多态性的Deref trait,这样在deref 目标上定义的函数就可以直接在derefee上调用。然而,请注意,这通常被认为是一种反模式。
最后,我们可以为所有实现了其他一些特质之一的类实现一个特质,但这需要专业化,目前是一个夜间功能(尽管有一个变通的办法,如果你不想写出所有需要的模板,甚至可以打包在一个宏箱里)。特质可以很好地相互继承,尽管它们只规定了行为而不是数据。
链接列表和其他基于指针的数据结构
许多从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,
}));
}
}
当然,借贷检查器不会允许这样做。值的所有权是完全混乱的。Box 拥有它所包含的数据,因此列表中的每个节点都会被列表中的前一个和后一个节点所拥有。Rust只允许每个数据有一个所有者,所以这至少需要一个Rc 或Arc 才能工作。但即使这样也会很快变得很麻烦,更不用说引用计数带来的开销了。
幸运的是,你不需要写一个双链表,因为标准库已经包含了一个双链表(std::collections::LinkedList )。另外,与简单的Vecs相比,这将给你带来良好的性能,这是相当罕见的,所以你可能要进行相应的测量。
如果你真的想写一个双链表,你可以参考《用完全的太多的链表学习Rust》,这可能会帮助你既写出链表,又在这个过程中学习到很多不安全的Rust。
(题外话。单链列表是绝对可以用盒子链来构建的。事实上,Rust编译器包含一个实现)。
同样的情况也适用于图结构,尽管你可能需要一个处理图数据结构的依赖性。petgraph是目前最流行的,它同时提供了数据结构和一些图算法。
自引用类型
当面对自我引用类型的概念时,我们可以问:"谁拥有这个?"同样,这是所有权故事中的一个皱褶,借贷检查者通常不满意。
当你有一个所有权关系,并且想在一个结构中同时存储拥有者和拥有者的对象时,你会遇到这个问题。天真地尝试一下,你会在试图让生命期工作时遇到麻烦。
我们只能猜测,许多Rustaceans已经转向了不安全的代码,这很微妙,而且真的很容易出错。当然,使用普通的指针而不是引用可以消除你对寿命的担心,因为指针没有寿命。然而,这就承担了手动管理寿命的责任。
幸运的是,有一些板块采取了解决方案,并提供了一个安全的接口,如 ouroboros, self_cell和 one_self_cell箱子。
全局可改变的状态
来自C和/或C++的人--或者更少的时候,来自动态语言的人--有时习惯于在他们的代码中创建和修改全局状态。例如,一位Redditor咆哮道:"这完全是安全的,但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]; ,我们解决了这个问题,但代价是双重初始化,这可能会也可能不会被优化掉--或者,取决于类型,甚至可能不可能。参见 "不安全的Rust。如何以及何时(不)使用它"的解决方案。
至此,我们结束了在Rust中不能(轻易)做的事情的简短游览。虽然Rust肯定还有其他难以做到的事情,但对我和你,亲爱的读者来说,把它们全部列出来会占用太多的时间。
帖子《Rust中不能做的事情(以及该怎么做)》首先出现在LogRocket博客上。