当前位置: 首页 > 文档资料 > 优质文章推荐 v3 >

写给 Python 程序员看的 Rust 介绍

优质
小牛编辑
123浏览
2023-12-01

这篇文章是 Flask 作者、资深 Python 开发者 Armin Ronacher 的作品,文章中比较了两者结构和行为的区别,这里擅自翻译以供大家学习 Rust 或者深入 Python,有些口语化的地方根据自己的理解做了一些意译。

原文:写给 Python 程序员看的 Rust 介绍(Rust for Python Programmers)

本文和原文都基于 CC-NC-SA 协议分享。

Rust 和 Python 截然不同,这不仅体现在 Rust 是编译型语言,而另一者是解释型,两者在设计理念上就已经完全不同。 但即使出发点不一样,并不妨碍他们在 API 设计上的存在相似性,Python 程序员可能会因此对 Rust 抱有好感。

语法

作为一个程序员,你首先注意到的不同点肯定在于语法。和 Python 不一样,Rust 中存在着大量的花括号和分号,不过这些使得 Rust 在 Python 实现得不好的地方,比如匿名函数、闭包等等,使用起来简单、清晰。这些特性使得编写非缩进式的代码更加方便。

举个例子,在 Python 中打印“Hello world”三次可以这样做:

def main():
    for count in range(3):
        print "{}. Hello World!".format(count)

Rust版本:

fn main() {
    for count in 0..3 {
        println!("{}. Hello World!", count);
    }
}

除了关键字、函数名和花括号外并没有明显的不同。 语法上另一大不同之处在于 Rust 需要指定函数的参数类型,而 Python 不需要。 当然, Python 3 中新增的类型注释(type annotations)使用起来和 Rust 的语法很像。

Rust 函数调用的结尾会有一个惊叹符,它其实是宏。在编译期间,宏会展开成对应的东西。 具体看情况,究竟是用于字符串格式化还是打印,这样,编译器就能在编译阶段强制字符串类型了。 也就不会在打印的时候出现函数参数、类型不匹配的情况。

Traits vs Protocols(特性 vs 协议)

最常见的不同之处在于对象的行为。 在 Python 中,类可以通过实现特定的魔术方法来支持某行为,通常称之为遵从 x 协议——比如 __iter__ 方法用于返回一个迭代器对象。 这些方法应该在类中实现,之后(实例化之后)就不能在修改了。(请无视 Monkey Patch)

Rust 的理念和 Python 类似,不过它选“特性”而非魔术方法。特性的不同之处在于它仅作用于本地,你可以在别的模块中实现更多特性。 如果你想要给整数特殊的功能,并不会影响到(全局的)整数类型。

以一个自调用的类型为例。Python:

class MyType(object):

    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        if not isinstance(other, MyType):
            return NotImplemented
        return self.__class__(self.value + other.value)

Rust:

use std::ops::Add;

struct MyType {
    value: i32,
}

impl MyType {
    fn new(value: i32) -> MyType {
        MyType { value: value }
    }
}

impl Add for MyType {
    type Output = MyType;

    fn add(self, other: MyType) -> MyType {
        MyType { value: self.value + other.value }
    }
}

Rust 的实现代码稍微长一些,但是它实现了 Python 代码中未处理的自动类型。 你可能还会注意到,Python 中的方法与类型声明在一起,而 Rust 中两者是分开声明的:struct 定义数据,impl MyType 定义类型所具备的方法,impl Add for MyType 是 Add 特性的实现。 在 Add 方法的实现中,我们还定义了方法返回结果的类型,但是避免了像 Python 中那样需要在运行时检查类型的复杂性。

另一点区别在于,Rust 构造器是明确的,而 Python 的更具迷惑性。 Python 初始化对象时会调用 __init__ 来初始化对象,在 Rust 中则需要手动定义一个功能类似的静态方法,通常是 new() 方法,来分配对象空间并构造。

错误处理/异常处理

Python 和 Rust 中的错误处理理念完全不同! Python 中错误会以异常的形式抛出,而 Rust 中则是返回值,这听起来很奇怪,但却是是一个不错的设计。

从函数的返回值定义中就可以确定它可能“抛出”的异常,是一种非常清晰的声明方式, 和 Python “显胜于隐”的设计哲学不谋而合。(Python 中也鼓励手动抛出异常而非过多的条件判断。)

Rust 中的函数可以返回 Result,Result 是一种规范化的类型,分为成功和失败两种。 Result<i32, MyError> 表示这个方法成功时会返回 32位的整数类型,失败时返回 MyError。 如果你需要返回多种错误怎么办?

Python 函数可能会抛出任何错误,但并不会做出任何处理。比如,你在使用 requests 库时可能会遇到 SSL error 或者其它错误,并且只有当它发生时你才知道出错了,但如果文档中没有明确的说明,你永远都不知道它会返回什么样的错误。

Rust 不一样,函数的声明中就包含了会遇到什么样的错误。 如果需要返回两种以上的错误,通常是建议定义一个内部错误来整合。 以一个 HTTP 库为例,它可能会抛出 Unicode error、IO error、SSL error 等等。 你可以把它们都定义为一个只在你的库中使用的错误类型,用户也只需要知道它即可。

Rust 的错误链机制能够在你需要的时候回溯到产生错误的地方。 你可以在任意时候是使用 Box<Error> 这个所有错误的根来代替自定义错误。 相比之下,Rust 的错误更透明,而 Python 中会比较绕。

Rust 的错误处理机制是由 try! 宏提供的,下面是一个例子:

use std::fs::File;

fn read_file(path: &Path) -> Result<String, io::Error> {
    let mut f = try!(File::open(path));
    let mut rv = String::new();
    try!(f.read_to_string(&mut rv));
    Ok(rv)
}

上面代码中的 File::openread_to_string 都会失败并返回 IO error, 而 try! 宏会将错误向上传递,并且会立即返回。返回信息是 success 还是 failure 由包裹函数是 Ok 还是 Err 确定。

try! 宏引用了 From 特性以运行错误转换。例如你可以通过修改返回值 io::ErrorMyError,并且通过实现 From 特性来实现 io::ErrorMyError 的转换,它也会被自动引用。

或者,你也可以使用 Box<Error> 作为返回值类型代替 io::Error,这样就可以传递任何类型的错误了。 这样做的坏处是,原本编译期就能确定错误的程序必须正式运行的时候才能确定。

如果你不打算处理异常而直接退出执行,可以 unwrap() 结果,这样成果返回的结果会是一个错误,程序会因此退出。

可变性和所有权

可变性(mutability)和所有权(ownership)是 Python 和 Rust 最本质区别的地方! Python 会自动 GC ,因此运行的时候会有很多意想不到的事情发生,你可以随意地传递对象,虽然可能产生一些内存泄漏问题,不过大部分问题都会它都能在运行时自动解决。

Rust 不支持 GC,但仍然能够自动管理内存,这得益于其所有权跟踪机制,你创建的所有对象都被另一个对象所拥有。

对比 Python,你可以认为 Python 中的所有对象的所有权都是 Python 解释器。

Rust 中的所有权存在范围设定,一个调用对象列表的函数拥有这个列表的所有权,而列表拥有其中所有对象的所有权,而这一切都发生在函数的作用域中。

下面通过一个关于生命周期注释(lifetime annotation)和函数签名的更复杂的例子来帮助你理解什么是“所有权”。以实现上面的“加法”为例,接收者(receiver)和 Python 中一样命名为 self,不同的是值会被“移动到”函数中,而不像 Python 那样是通过可变引用来实现。也就是说,你可能是用 Python 这样实现:

leaks = []

class MyType(object):
    def __add__(self, other):
        leaks.append(self)
        return self

a = MyType() + MyType()

当你将 MyType 的实例加入另一个对象时,就会将 self 泄漏到 global 列表中, 也就是说,运行上面的代码将获得

当你将一个 MyType 类型的实例和另一个对象相加时都将会使得自身泄漏到全局列表中, 也就是说,当你运行上面的代码时,第一个 MyType 将会被引用两次:被第二个实例引用, 以及被 global 所引用。

而 Rust 中不存在这样的问题,每个对象都只能有一个所有者。你在引用 self 时, 编译器将会将值“移动”过去,这时函数将无法找到原来的 self 也就无法将它 return 回去。 想要 return self 则必须向将它移动回去(将它从引用中删除)。 如果你想要把 self 泄露出去,编译器会负责“移动”值,但是这样函数就无法返回 self,因为它已经被移动了。想要返回它,你首先还是需要把它再移动回去(比如从列表中将它删除)。

如果你需要多次引用同一个对象该怎么办呢?Rust 提供的解决方案是“借用(borrow)”,“借用”这个变量的值。 借用的数量可以没有限制,但是不允许对借用的对象进行修改,或者可以修改但只允许存在一个借用。

操作不可变“借用”的函数被标记为 &self,使用可变借用的函数被标记为 &mut self。作为拥有者你只能“借出”引用。如果想要将值移出函数(比如返回),则不能有额外的借出,并且将所有权移动后不能再借出。

这可能会颠覆你思考程序的方式,但习惯它能帮你更好地理解程序。

运行时的“借用”和可变的所有者

目前为止,都能在运行时验证所有的所有权并没有问题,但编译时无法验证所有权怎么办?

你有以下可选方案:第一种方案你可以使用 mutex,mutex 保证只有一个用户可以可变地借用对象,但对象的所有者是 mutex。这样你就可以在视线获取对象时,永远只有一个线程能够取到它。

这也意味着,你不得不使用 mutex 锁,否则会导致数据竞争,无法通过编译。

如果你想像 Python 一样编程,不用找出内存的所有者?

在那种情况下你可以将一个对象封装在引用计数器中,并在运行时将其借出。

这种方式非常类似 Python,同时也可能导致循环。Python 会在 GC 的时候解除循环,但 Rust 不会。

不妨用个比较复杂的例子来比较一下:

from threading import Lock, Thread

def fib(num):
    if num < 2:
        return 1
    return fib(num - 2) + fib(num - 1)

def thread_prog(mutex, results, i):
    rv = fib(i)
    with mutex:
        results[i] = rv

def main():
    mutex = Lock()
    results = {}

    threads = []
    for i in xrange(35):
        thread = Thread(target=thread_prog, args=(mutex, results, i))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    for i, rv in sorted(results.items()):
        print "fib({}) = {}".format(i, rv)

上面的代码会生成 35 个线程,都用于计算斐波那契数,最后将所有结果聚合并排序。 你可能会注意到 mutex(锁)和结果数组直接并无关系。

use std::sync::{Arc, Mutex};
use std::collections::BTreeMap;
use std::thread;

fn fib(num: u64) -> u64 {
    if num < 2 { 1 } else { fib(num - 2) + fib(num - 1) }
}

fn main() {
    let locked_results = Arc::new(Mutex::new(BTreeMap::new()));
    let threads : Vec<_> = (0..35).map(|i| {
        let locked_results = locked_results.clone();
        thread::spawn(move || {
            let rv = fib(i);
            locked_results.lock().unwrap().insert(i, rv);
        })
    }).collect();
    for thread in threads { thread.join().unwrap(); }
    for (i, rv) in locked_results.lock().unwrap().iter() {
        println!("fib({}) = {}", i, rv);
    }
}

Rust 版本和 Python 版本最大的不同之处在于使用了 B 树 map 而不是 hash 表,结果存放于 Arc mutex 中。

这是为什么?这里使用 B 树是因为它能够自动排序,而这正是我们所需要的。

将值存放在 mutex 中是因为这样可以在运行时将其锁住。

关系创建后,我们将其置入 Arc,因为 Arc 会管理它所封装的事物的引用计数,本例中即 mutex。也就是说,直到最后一个线程运行结束时 mutex 才会被删除。非常简洁!

下面解释下这段代码是如何工作的:首先数 20 次,和在 Python 中一样,每次都运行一个本地函数。和 Python 中不同的是,我们在这里可以使用闭包。之后将 Arc 复制到本地线程中,也就是说每个线程都会有自己的 Arc(这会 Arc 的增加引用计数,但会在线程结束的时候释放)。之后我们使用本地函数 spawn 一个新线程,这会将闭包移动到线程中。

之后每个线程都会执行 Fibonacci 函数,When we lock our Arc we get back a result we can unwrap and the insert into. 先不要管解包过程,这只是将明确的结果转换为 panic。重点在于,你只有在解除 mutex 锁之后才能获得结果映射,所以千万不能忘记解锁。

之后我们将所有线程集中到 vector 中,最后迭代所有线程,合并并打印结果。

这里有两点需要注意的地方:可见类型非常少。当然,Arc 和 Fibonacci 函数接受 64 位 unsigned 整数,除此之外,没有任何类型是可见的。在我们也可以使用 B-tree 映射来代替哈希表,因为 Rust 内置了这个类型。

迭代的运行方式和 Python 几乎完全一致,不同之处在于,这上面这个 Rust 例子中我们需要引入 mutex,因为编译器不知道线程会怎样结束,而 mutex 是不必要的。当然 Rust 也有不需要引入 mutex 的 API,只不过在 Rust 1.0 中还不是稳定版本。

性能优化会如你所期望地那样进行。(这里的优化情况很糟糕,因为只是未来提供一个线程执行的例子。)

Unicode

我最喜欢的话题是 Unicode :) ,这也是 Python 和 Rust 相差最大的地方。

Python (2 和 3)都使用着相似的 Unicode 模型,将 Unicode 数据映射至字符数组。

在 Rust 中,Unicode 都是 UTF-8 格式存储,我之前也提到过为什么这比 Python 或者 C# 的解决方案要好得多(参见 UCS vs UTF-8 as Internal String Encoding)。 非常有趣的是 Rust 是如何处理丑陋的编码问题的。

首先 Rust 一开始就意识到操作系统(不论是 Windows Unicode 还是 Linux 非 Unicode)的 API 都非常糟糕,它没有像 Python 一样强制使用 Unicode,但是实现了一套低廉的字符转化系统,这在现实使用中非常出色,也使得 Rust 拥有高效的字符处理能力。

对于大多数支持 UTF-8 的程序来说,编码、解码并不需要,只需要简单地验证编码的正确性,并不需要的对 UTF-8 字符编码后再输出。如果需要集成 Windows Unicode API,只需要在内部使用 WTF-8 进行编码,可以很高效地和 UTF-16 这样的 UCS2 编码之间进行转换。

无论何时,你都可以将 Unicode 和字节码互相转换,之后检验以确保所有操作都按预期进行了。这使得编写协议既快速又高效。和 Python 那种不断编码、解码的方式相比,只需要支持 O(1) 的字符索引。

得益于 Unicode 优秀的存储模型,Rust 还自带或者太 crates.io 上提供了很多 Unicode 处理的 API,包括 case folding、分类、Unicode 正则表达式、Unicode 正常化、标准的 URI/IRI/URL API、分割操作以及命名映射等等。

缺点呢?"föo"[1] 的结果不是 'ö',但你本来就不应该这样做。

下面有一个用于示范如何和 OS 集成的例子,执行它会打印出当前目录的信息和文件名:

use std::env;
use std::fs;

fn example() -> Result<(), Box<Error>> {
    let here = try!(env::current_dir());
    println!("Contents in: {}", here.display());
    for entry in try!(fs::read_dir(&here)) {
        let path = try!(entry).path();
        let md = try!(fs::metadata(&path));
        println!("  {} ({} bytes)", path.display(), md.len());
    }
    Ok(())
}

fn main() {
    example().unwrap();
}

所有 IO 操作都使用了上面用过的 Path 对象,包含了 OS 内部的路径属性。根据系统使用的编码,它可能是字节、Unicode 或者 OS .display() 格式化(这会返回一个能将自身格式化为字符串的对象)前的调用格式。这很方便,因为你不会像在 Python 3 中那样无意中漏掉错误编码的字符串,它提供了清晰的区分。

库以及应用发布

Rust 提供了一个称为“cargo”的工具,作用基本相当于 Python 中的 virtualenv+pip+setuptools , 不过默认情况下它尽能保证一个版本的 Rust 正常工作。 “cargo” 能够同时支持库的不同版本,并且可以直接从 git 仓库或者 crates.io 源中安装程序,通常安装 Rust 的时候就已经自带了 cargo。

Rust 会代替 Python 吗?

Python 和 Rust 之间并没有直接关系,

  • 对于科学计算等领域,丰富的库和文档的作用非常之大,Rust 不可能在短期内影响到 Python 在其中的地位。
  • 对于脚本这样的领域,只要 Python 能解决,我不认为你会考虑选择 Rust。
  • 虽然肯定会有很多 Python 程序员去学习 Rust,但就和很多 Python 学习 Go 一样,并不是以替代 Python 为目的。

Rust 是一门非常强大的语言,拥有稳定的基金会、友好的社区、人性化的许可证,可能会在编程语言的民主制度掀起一场革命。

Rust 几乎不需要运行时支持,因此通过 ctypes 或 CFFI 来和 Python 交互并不难,直接使用 Python 封装的 Rust 二进制库并非不可能。