在 Python、Go 和 Rust 的比较中,我得出了这个结论

发表时间: 2019-08-08 12:02



几年前,我负责重写一个图像处理服务。为了弄清楚对于给定的图像和一个或多个转换(调整大小、圆形裁剪、修改格式等),我的新服务创建的输出是否和旧服务一致,我必须自己检查图像。显然,我需要自动化,但我找不到一个现有的 Python 库可以告诉我,这两张图片在像素级上有什么不同,因此有了diffimg ,它可以给你一个差异比 / 百分比,或生成差异图像(检出 readme,里面有一个例子)。

我最初是用 Python 实现的(我最熟悉的语言),主要部分使用了 Pillow 。它可以用作库或命令行工具。程序的基本部分非常小,只有几十行,这要感谢Pillow。构建这个工具并不费力( xkcd 是对的,几乎所有东西都有一个 Python 模块),但它至少对于除我自己之外的几十人是有用的。

几个月前,我加入了一家公司,他们有几个服务是用 Go 编写的,我需要快速上手这门语言。编写diffimg-go 看起来很有趣,甚至可能是一种有用的方法。这里有一些来自经验的兴趣点,以及一些在工作中使用它的兴趣点。

一、对比 Python 和 Go


(代码: diffimg (Python)和 diffimg-go )

1、标准库:Go 有一个相当不错的 image 标准库模块,以及命令行 flag 解析库。我不需要寻找任何外部依赖;diffimg-go 实现没有依赖,而 Python 实现使用了相当重量级的第三方模块(讽刺的是)Pillow。Go 的标准库更有条理,而且经过深思熟虑,而 Python 的会逐步发展,它们是多年来由许多作者创建的,有许多不同的约定。Go 标准库的一致性使开发者更易于预测任何给定的模块将如何发挥作用,而且源代码有非常好的文档记录。

(1)使用标准 image 库的一个缺点是它不自动检测图像是否有一个 alpha 通道;所有图像类型的像素值都有四个通道(RGBA)。因此,diffimg-go 实现要求用户指明是否要使用 alpha 通道。这个小小的不便不值得找第三方库来修复。

(2)一个很大的好处是,标准库中有足够的内容,你不需要像 Django 这样的 Web 框架。用 Go 有可能在没有任何依赖关系的情况下建立一个真正可用的 Web 服务。Python 号称“自带电池(batteries-included)”,但在我看来,Go 做得更好。


2、静态类型系统:我过去使用静态类型语言,但我过去几年一直在使用 Python 编程。体验起初有点烦人,感觉它好像只是减慢了我的速度,它迫使我过度明确,即使我偶尔错了,Python 也会让我做我想做的。有点像你给人发指令而对方总是打断你让你阐明你是什么意思,而有的人则总是点头,看上去已经理解你,但是你并不确定他们是否已经全部了解。它将大大减少与类型相关的 Bug,但是我发现,我仍然需要花几乎相同的时间编写测试。

(1)Go 的一个常见缺点是它没有用户可实现的泛型类型。虽然这不是一个构建大型可扩展应用程序的必备特性,但它肯定会减缓开发速度。虽然已经有替代模式建议,但是它们中没有一个和真正的泛型类型一样有效。

(2)静态类型系统的一个优点是,可以更简单快速地阅读不熟悉的代码库。用好类型可以带来许多动态类型系统中会丢失的额外信息。


3、接口和结构:Go 使用接口和结构,而 Python 使用类。在我看来,这可能是最有趣的区别,因为它迫使我区分定义行为的类型和保存信息的类型这两个概念。Python 和其他传统的面向对象的语言都鼓励你将它们混在一起,但这两种范式各有利弊:

(1)Go 强烈建议组合而不是继承。虽然它通过嵌入继承,不用类,但其数据和方法不是那么容易传递。通常,我认为组合是更好的默认模式,但我不是一个绝对主义者,在某些情况下继承更合适,所以我不喜欢语言帮我作出这个决定。

(2)接口实现的分离意味着,如果有许多类型彼此相似,你就需要多次编写类似的代码。由于缺少泛型类型,在 Go 中,有些情况下我无法重用代码,不过在 Python 中可以。

(3)然而,由于 Go 是静态类型的,当你编写的代码会导致运行时错误时,编译器 / 源码分析器(linter)会告诉你。Python 源码分析器也可以有一点这样的功能。但在 Python 中,当你试图访问一个可能不存在的方法或属性时,由于语言的动态性,Python 源码分析器无法确切地知道什么方法 / 属性存在,直到运行时。静态定义的接口和结构是唯一在编译时和开发过程中知道什么可用的方法,这使得编译时报错的 Go 比运行时报错的 Python 更可靠。


4、没有可选参数:Go 只有可变函数,类似于 Python 的关键字参数,但不那么有用,因为参数需要是相同的类型。我发现关键字参数是我真正怀念的特性,这主要是你可以把任何类型的一个 kwarg 扔给任何需要它的函数,而无需重写它的每一个调用,这让重构简单了许多。我在工作中经常使用这个特性,它为我节省了很多时间。由于没有该特性,这使得我在处理是否应该基于命令行标志创建差异图像时显得有些笨拙。

5、冗长:Go 有点冗长(尽管不是像 Java 那么冗长)。这部分是因为其类型系统没有泛型,但主要是因为语言本身很小,没有提供很多特性(你只有一种循环结构可以使用!)。我怀念Python 的列表推导式(list comprehensions)和其他函数式编程特性。如果你熟悉Python,你一两天就可以学完 Tour of Go ,然后你就了解了整个语言。

6、错误处理:Python 有异常,而 Go 在可能出错的地方通过从函数返回元组 value, error 来传播错误。Python 允许你在调用栈中的任何位置捕获错误,而不需要你一次又一次地手动将错误传递回去。这又使得代码简洁,而不像 Go 那样被其臭名昭著的 if err != nil 模式搞得杂乱无章,不过你需要弄清楚函数及其内部的所有调用可能抛出的异常(使用 except Exception: 通常是一种糟糕的变通方案)。良好的文档注释和测试可以提供帮助,你在使用其中任何一种语言编程时都应该添加。Go 的系统绝对更安全。如果你忽视了 err 值,你还是可以搬起石头砸自己的脚,但该系统使糟糕的主意变得更明显。


7、第三方模块:在 Go 模块出现之前,Go 的包管理器会把所有下载的包扔到 GOPATH/src目录下,而不是项目的目录(像大多数其他语言)。

GOPATH/src目录下,而不是项目的目录(像大多数其他语言)。GOPATH 下这些模块的路径也会从托管包的 URL 构建,所以你的 import 语句将是类似 import "
github.com/someuser/somepackage " 这个样子。在几乎所有 Go 代码库的源代码中嵌入 github.com 似乎是一个奇怪的选择。在任何情况下,Go 允许以传统的方式做事,但 Go 模块仍然是个很新的特性,所以在一段时间内,在缺少管理的 Go 代码库中,这种奇怪的行为仍将很常见。

8、异步性:Goroutines 是启动异步任务的一种非常方便的方法。在 async/await 之前,Python 的异步解决方案有点令人沮丧。遗憾的是,我没有用 Python 或 Go 编写很多真实的异步代码,而 diffimg 的简单性似乎并不适合说明异步性的额外开销,所以我没有太多要说的,虽然我确实喜欢使用 Go 的channels 来处理多个异步任务。我的理解是,对于性能,Go 仍然占了上风了,因为 goroutine 可以充分利用多处理器并发,而 Python 的基本 async/await 仍局限于一个处理器,所以主要用于 I / O 密集型任务。

9、调试:Python 胜出。pdb(以及像 ipdb 这样更复杂的选项)非常灵活,一旦你进入 REPL,你可以编写任何你想要的代码。 Delve 是一个很好的调试器,但和直接放入解释器不一样,语言的全部功能都唾手可得。

Go 总结

我对 Go 最初的印象是,由于它的抽象能力(有意为之)有限,所以它不像 Python 那样有趣。Python 有更多的特性,因此有更多的方法来做一些事情,找到最快、最易读或“最聪明”的解决方案可能会很有趣。Go 会积极地阻止你变得“聪明”。我认为,Go 的优势在于它并不聪明。

它的极简主义和缺乏自由限制了单个开发人员实现一个想法。然而,当项目扩展到几十个或几百个开发人员时,这个弱点变成了它的力量——因为每个人都使用同样的语言特性小工具集,更容易统一,因此可以被他人理解。使用 Go 仍有可能编写糟糕的代码,但与那些更“强大”的语言相比,它让你更难创造怪物。

使用一段时间后,我理解了为什么像谷歌这样的公司想要一门这样的语言。新工程师不断地加入到大量代码库的开发,在更复杂 / 更强大的语言中,在最后期限的压力下,引入复杂性的速度比它被消除的速度更快。防止这种情况发生的最好方法是使用一种更不容易产生复杂性的语言。

所以说,我很高兴工作在一个大型应用程序上下文中的 Go 代码库上,有一个多样化和不断增长的团队。事实上,我认为我更喜欢它。我只是不想把它用于我自己的个人项目。


二、进入 Rust

几周前,我决定尝试学习 Rust。我之前曾试图这样做,但发现类型系统和借用检查(borrow checker)让人困惑,没有足够的背景信息,我不知道为什么要把所有这些限制强加给我,对于我想做的任务来说大而笨重。然而,自那时以来,我对程序执行时内存中发生了什么有了更多的了解。我从这本书开始,而不是试图一头扎进去。这有很大的帮助,这份介绍比我见过的任何编程语言的介绍都要好。

在我读过这本书的前十几章之后,我觉得自己有足够的信心尝试 diffimg 的另一个实现(这时,我觉得我的 Rust 经验与我写 diffimg-go 时的 Go 经验一样多)。这比我用 Go 实现的时间要长,而后者比用 Python 需要的时间更长。我认为,即使考虑到我对 Python 更加熟悉,这也是对的——两种语言都有更多的东西要写。

在编写 diffimg-rs 时,我注意到一些事情。

1、类型系统:现在,我已经习惯 Go 中更基本的静态类型系统了,但 Rust 更强大(也更复杂)。有了 Go 中接口和结构的基础(它们都简单得多),泛型类型、枚举类型、traits、引用类型、生命周期就是全部我要额外学习的概念了。此外,Rust 使用其类型系统实现其它语言不使用类型系统实现的特性(如 Result ,我很快会介绍)。幸运的是,编译器 / 源码解析器非常有帮助,它可以告诉你你做错了什么,甚至经常告诉你如何解决这个问题。尽管如此,这比我学习 Go 的类型系统所用的时间要多得多,我还没有适应它的所有特性。

(1)有一个地方,因为类型系统,我正在使用的图像库的实现会导致令人不快的代码重复量。我最终只要匹配两个最重要的枚举类型,但匹配其他类型会导致另外半打左右几乎相同的代码行。在这个规模上,这不是一个问题,但它使我生气。也许这里使用宏是个不错的主意,我仍然需要试验。



2、手动内存管理:Python 和 Go 会帮你捡垃圾。C 允许你乱丢垃圾,但当它踩到你丢的香蕉皮时,它会大发脾气。Rust 会拍你一下,并要求你自己清理干净。这会刺痛我,甚至超过了从动态类型语言迁移到静态类型语言,因为我被宠坏了,通常都是由语言跟在我后面捡。此外,编译器会设法尽可能地帮助你,但你仍然需要大量的学习才能理解到底发生了什么。

(1)直接访问内存(以及 Rust 的函数式编程特性)的一个好处是,它简化了差异比的计算,因为我可以简单地映射原始字节数组,而不必按坐标索引每个像素。

3、函数式特性:Rust 强烈鼓励函数式的方法:它有 FP 友好的类型系统(类似 Haskell)、不可变类型、闭包,迭代器,模式匹配等,但也允许命令式代码。它类似于编写 OCaml(有趣的是,最初的 Rust 编译器是用 OCaml 编写的)。因此,对于这门与 C 竞争的语言,代码比你想象得更简洁。

4、错误处理:不同于 Python 使用的异常模型,也不同于 Go 异常处理时返回的元组,Rust 利用枚举类型:Result 返回 Ok(value) 或 Err(error)。这和 Go 的方式更接近,但更明确一些,而且利用了类型系统。还有一个语法糖用于检查语句中的 Err 并提前返回:? 操作符(在我看来,Go 可以用类似这样的东西)。

5、异步性:Rust 的 async/await 还没有完全准备好,但最终语法最近已经达成一致。Rust 标准库中也有一些基本的线程特性,看上去比 Python 的更容易使用,但我没有花太多的时间了解它。Go 似乎仍然有最好的特性。

6、工具:rustup 和 cargo 分别是非常优秀的语言版本管理器和包 / 模块管理器实现。一切“正常”。我特别喜欢自动生成的文档。对于这些工具,Python 提供的选项有点太简单,需要小心对待,正如我之前提到的,Go 有一种奇怪的管理模块方式,但除此之外,它的工具比 Python 的更好。

7、编辑器插件:我的. vimrc 文件大到令人尴尬,至少有三十多个插件。我有一些用于代码检查、自动补全以及格式化 Python 和 Go 的插件,但相比于其他两种语言,Rust 插件更容易设置,更有用、更一致。 rust.vim 和 vim-lsp 插件(以及 Rust 语言服务器)是我获得一个非常强大的配置所需要的全部。我还没有测试其他编辑器,但是,借助 Rust 提供的编辑器无关的优秀工具,我认为它们一样有帮助。这个设置提供了我使用过的最好的“转到定义”。它可以很好地适用于本地、标准库和开箱即用的第三方代码。

8、调试:我还没有尝试过 Rust 的调试器(因为其类型系统和 println! 已经让我走得很远),但是你可以使用 rust-gdb 和 rust-lldb,以及 rustup 初始安装时带有的 gdb 和 lldb 调试器的封装器。如果你之前在编写 C 语言代码时使用过那些调试器,那么其使用体验将是意料之中的。正如前面提到的,编译器的错误消息非常有帮助。


Rust 总结

你至少要读过这本书的前几章,否则我绝对不建议你尝试编写 Rust 代码,即使你已经熟悉 C 和内存管理。对于 Go 和 Python,只要你有一些另一种现代命令式编程语言的经验,它们就不是很难入手,必要时可以参考文档。Rust 是一门很大的语言。Python 也有很多特性,但是它们大部分是可选的。只要理解一些基本的数据结构和一些内置函数,你就可以完成很多工作。对于 Rust,你需要真正理解类型系统的固有复杂性和借用检查,否则你会搞得十分复杂。

就我写 Rust 时的感觉而言,它非常有趣,就像 Python 一样。它广泛的特性使它很有表现力。虽然编译器会经常让你停下,但它也非常有用,而且它对于如何解决你的借用问题 / 输入问题的建议通常是有效的。正如我所提到的,这些工具是我遇到的所有语言中最好的,并且不像我使用的其他一些语言那样给我带来很多麻烦。我非常喜欢使用这种语言,并将在 Python 的性能还不够好的地方继续寻找使用 Rust 的机会。

三、代码示例

我提取了每个 diffimg 中计算差异比的代码块。为了概括介绍 Python 的做法,这需要 Pillow 生成的差异图像,对所有通道的所有像素值求和,并返回最大可能值(相同大小的纯白图像)除以总和的比值。

Python


对于 Go 和 Rust,方法有点不同:我们不用创建一个差异图像,我们只要遍历两幅输入图像,并对每个像素的差异求和。在 Go 中,我们用坐标索引每幅图像……

Go


……但在 Rust 中,我们将图像视为它们真的是在内存中,是一系列可以压缩到一起并消费的字节。

Rust


对于这些例子,有一些事情需要注意:

  1. Python 的代码最少。显然,这在很大程度上取决于使用的图像库,但这说明了使用 Python 的一般体验。在许多情况下,那些库为你做了很多工作,因为生态系统非常发达,任何东西都有成熟的解决方案。
  2. 在 Go 和 Rust 示例中有类型转换。每个代码块中都使用了三个数值类型:用于像素通道值的 uint8/u8(Go 和 Rust 有类型推断,所以你看不到任何明确提及的类型)、用于总和的 uint64/u64 和用于最终比值的 float64/f64。对于 Go 和 Rust,需要花时间统一类型,而 Python 将隐式地完成这一切。
  3. Go 实现的风格是命令式的,但也是明确和可以理解的(使其稍显欠缺的是我前面提到的 ignoreAlpha),甚至是对那些不习惯该语言的人也是如此。Python 示例相当清晰,一旦你理解了 ImageStat 在做什么。对于那些不熟悉这种语言的人来说,Rust 肯定更加难懂:
  • .raw_pixels() 获取图像,生成 8 位无符号整数向量;
  • .iter() 为该向量创建一个迭代器。默认情况下,向量是不可遍历的;
  • .zip() 你可能了解,它接受两个迭代器,然后生成一个迭代器,每个元素是一个元组:(来自第一个向量的元素,来自第二个向量的元素);
  • 在 diffsum 声明中,我们需要一个 mut,因为变量默认是不可变的;
  • 如果你熟悉 C,你就会明白为什么 for (&p1, &p2) 中有 &:迭代器生成像素值的引用,但 abs_diff() 自己取得它们的值。Go 支持指针(和引用不太一样),但它们不同于 Rust 中常用的引用。
  • 函数中的最后一个语句用于在没有行结束符 ; 的情况作为返回值。其他一些函数式语言也是这样做的。

这段是为了让你了解需要掌握多少特定于语言的知识才能有效地使用 Rust。

四、性能

现在来做一个科学的比较。我首先生成三张不同尺寸的随机图像:1x1、2000x2000、10000x10000。然后我测量每个(语言、图像大小)组合的性能,每个 diffimg 计算 10 次,然后取平均值,使用 time 命令的 real 值给出的值。diffimg-rs 使用–release 构建,diffimg-go 使用 go build,而 Python diffimg 通过 python3 - m diffimg 调用。以下是在 2015 年的 Macbook Pro 上获得的结果:

我损失了很多精度,因为 time 只精确到 10ms(因为计算平均值的缘故,这里多显示了一个数字)。该任务只需要一个非常特定类型的计算,所以不同的或更复杂的任务得出的数值可能差别很大。话虽如此,我们还是可以从这些数据中了解到一些东西。

对于 1x1 的图像,几乎所有的时间都花费在设置中,没有比例计算。Rust 获胜,尽管它使用了两个第三方库( clap 和 image ),Go 只使用了标准库。我并不惊讶 Python 的启动那么缓慢,因为导入大库 Pillow 是它的一个步骤,time python -c ’ '其实只用了 0.030 秒。

对于 2000x2000 的图像,Go 和 Python 与 Rust 的差距就缩小了,这大概是因为与计算相比,用于设置的时间更少。然而,对于 10000 年 x10000 的图像,Rust 相比较之下性能更好,我想这是由于其编译器优化所生成的机器代码最小(循环 1 亿次),设置时间相对就比较少了。从不需要暂停进行垃圾收集也是一个因素。

Python 实现肯定还有很大的改进余地,因为像 Pillow 那么高效,我们仍然是在内存中创建一张差异图像(遍历输入图像),然后累加每个像素的通道值。更直接的方法,比如 Go 和 Rust 实现,可能会稍微快一些。然而,纯 Python 实现会非常慢,因为 Pillow 主要是用 C 完成其工作。因为另外两个是纯粹用一种语言实现的,这不是一个真正公平的比较,虽然在某些方面是,因为得益于 C 扩展(Python 和 C 的关系一般都非常紧密 ),Python 有一大堆高性能库可以使用。

我还应该提下二进制文件的大小:Rust 的 2.1MB,使用–release 构建,Go 的大小差不多,为 2.5 MB。Python 不创建二进制文件,但是.pyc 文件有一定的可比性,和 diffimg 的.pyc 文件总共约 3 KB。它的源代码也只有 3KB,但是包括 Pillow 依赖的话,它将达 24MB。再说一次,这不是一个公平的比较,因为我使用了一个第三方图像库,但应该提一下。

五、结论

显然,这三种截然不同的语言实现满足不同的细分市场需求。我经常听到 Go 和 Rust 被一起提及,但我认为,Go 和 Python 是两种类似 / 存在竞争关系的语言。它们都很适合编写服务器端应用程序逻辑(我在工作中大部分时间都在做这项工作)。仅比较原生代码的性能,Go 完胜 Python,但许多有速度要求的 Python 库是对速度更快的 C 实现的封装——实际情况比这种天真的比较更复杂。编写一个用于 Python 的 C 扩展不能完全算是 Python 了(你需要了解 C),但这个选项是对你开放的。

对于你的后端服务器需求,Python 已被证明它对于大多数应用程序都“足够快”,但是如果你需要更好的性能,Go 可以,Rust 更是如此,但是你要付出更多的开发时间。Go 在这方面并没有超出 Python 很多,虽然开发肯定是慢一些,这主要是由于其较小的特性集。Rust 的特性非常齐全,但管理内存总是比由语言自己管理会花费更多的时间,这好过处理 Go 的极简性。

还应该提一下,世界上有很多很多 Python 开发人员,有些有几十年的经验。如果你选择 Python,那么找到更多有语言经验的人加入到你的后端团队中可能并不难。然而,Go 开发人员并不是特别少,而且很容易发展,因为这门语言很容易学习。由于 Rust 这种语言需要更长的时间内化,所以开发人员更少,也更难发展。

至于系统类型:静态类型系统更容易编写更多正确的代码,但它不是万能的。无论使用何种语言,你仍然需要编写综合测试。它需要更多的训练,但是我发现,我使用 Python 编写的代码并不一定比 Go 更容易出错,只要我能够编写一个好的测试套件。尽管如此,相比于 Go,我更喜欢 Rust 的类型系统:它支持泛型、模式匹配、错误处理,它通常为你做得更多。

最后,这种比较有点愚蠢,因为尽管这些语言的用例重叠,但它们占领着不同的细分市场。Python 开发速度快、性能低,而 Rust 恰恰相反,Go 则介于两者之间。我喜欢 Python 和 Rust 超过 Go(这可能令人奇怪),不过我会继续在工作中愉快地使用 Go(以及 Python),因为它真的是一种构建稳定、可维护的应用程序的伟大语言,它有许多来自不同背景的贡献者。它的僵硬和极简主义使它使用起来不那么令人愉快(对我来说),但这也正是它的力量所在。如果我要为一个新的 Web 应用程序选择后端语言的话,那将是 Go。

我对这三种语言所涵盖的编程任务范围相当满意——实际上,没有哪个项目不能把它们中的一种视为很好的选择。

查看英文原文:One Program Written in Python, Go, and Rust