朱莉娅有什么不好?

2021-07-27 00:18:20

Julia 是我最喜欢的编程语言。更重要的是,也许我是一个狂热的粉丝。不过,有时候,像我这样的粉丝对 Julia 的不断庆祝可能有点过分了。它掩盖了语言中的合理问题,阻碍了进步。从局外人的角度来看,这不仅令人难以忍受(我猜),而且还混淆了该语言的真正优点和缺点。了解您可能不想选择使用工具的原因与了解您可能不想使用工具的原因同样重要。这篇文章是关于 Julia 的所有主要缺点。有些只是对我特别不喜欢的事情的咆哮 - 希望它们也能提供信息。像这样的帖子必然是主观的。例如,有些人认为 Julia 缺乏 Java 风格的 OOP 是一个设计错误。我不知道,所以这篇文章不会涉及。您了解 Julia 的第一件事就是它没有响应。你打开你最喜欢的 IDE,启动一个 Julia REPL,开始输入......并在任何文本出现之前看到明显的延迟。就第一印象而言,这并不是很好,尤其是对于一种因其速度而受到吹捧的语言。发生的事情是 Julia 正在编译其 REPL 所需的代码以及它与您的编辑器的集成。这种“运行时”编译会导致我们称之为编译时延迟的延迟。因此,如果我们从外部包中提取新代码,效果会更大:使用 BioSequences 和 FASTX 包的小脚本可能有 2 秒的延迟,即使计算本身需要微秒。它仍然可能变得更糟。在 Julian 中,延迟通常被称为 TTFP:首次绘图时间。图形绘图成为这个问题的典型代表,因为绘图涉及大量代码,但工作量相对较少。导入图并绘制最简单的线图需要 8 秒。然而,作为延迟的典型代表,Plots 已经得到了很多关注和工程努力来减少其延迟,所以它几乎不是最糟糕的包。像 Turing 或 ApproxFun 这样的软件包可能会增加半分钟的延迟——图灵在我的笔记本电脑上启动需要 40 秒。我听说过一些组织的代码库在 Julia 中,启动一个 Julia 进程并加载他们的包需要 5 分钟。好吧,这取决于您使用 Julia 的目的。请记住,每次启动 Julia 进程时,延迟都是一次性成本。如果您是一名在 Jupyter 笔记本上连续工作数小时的数据科学家,那么 10 秒甚至 40 秒的启动时间只是一个小烦恼。广义上,我属于这一类。当我启动 Julia 时,在我关闭之前很少需要不到几分钟的时间——而且我从命令行运行的 Julia 程序也需要几分钟才能完成。但是一些任务和用例依赖于运行大量的短 Julia 进程。这些都变得不可能了。例如,延迟使 Julia 完全无法启动: 延迟还强制 Julia 用户和开发人员使用特定的工作流程。使用 Python 或 Rust 时,您可能习惯于从命令行运行一些测试,在编辑器中修改源文件,然后重新运行测试直到它们工作。这个工作流程在 Julia 中是不可行的——相反,你基本上被迫进入 REPL 驱动的开发,在那里你有一个 Julia 会话,你在修改代码和观察结果时保持打开状态。

Julias 的延迟正在改善,您可以通过一些方法来缓解此问题。但是这个问题从根本上是无法解决的,因为它是在基本设计层面上内置到 Julia 中的。所以,在学习 Julia 之前,问问自己这对你来说是否是一个交易破坏者。是的,一个 hello-world 脚本大约需要 150 MB 的内存消耗。 Julia 的运行时间是巨大的——这些兆字节不仅被 Julias 编译器使用,而且它显然预先分配了 BLAS 缓冲区,以防万一用户想要在他们的 hello-world 脚本中乘以矩阵,你知道。忘记延迟吧,150 MB 的后台消耗完全排除了将 Julia 用于除了在 PC 或计算集群上运行的应用程序级程序之外的任何事情。对于其他任何东西,无论是移动、嵌入式、守护进程等,您都需要使用其他东西。事实上,即使对于桌面级应用程序,在 Julia 运行时上消耗 150 MB 也正在推动它。想想 Electron 因浪费资源而受到的所有仇恨。在这方面,每个 Julia 程序都与 Electron 处于同一范围内。用 Julia 编写的命令行计算器比 2003 年的视频游戏命令与征服:将军消耗更多的内存。 Julia 庞大的运行时间的另一个后果是,从其他语言调用 Julia 变得很烦人。如果您的 Python 脚本需要依赖 Julia,则您需要预先支付:延迟和 150 兆字节。将其与 C 之类的静态语言进行比较,您可以将 C 库编译为其他程序只需调用的二进制文件。 Julians 通常对 Julia 社区中大量的代码共享和代码重用感到非常自豪,但值得注意的是,这种共享在语言障碍上突然停止:我们可能能够在 Julia 中使用 Rust 库而不会产生摩擦,但是如果可以避免的话,没有人会使用 Julia 库。所以如果你想编写一些普遍使用的库,你最好使用静态语言。这是我在尝试编写 Rust 后改变观点的一点。在学习 Rust 之前,当我只知道 Python 和 Julia 时,我会这样说:当然,静态分析很有用。但是为了确保程序的正确性,无论如何您都需要测试,这些测试将捕获绝大多数编译时错误。你在动态语言中失去的小安全性不仅仅是由节省的时间弥补的,你可以用它来编写更好的测试。

多么愚蠢,过去我,如果你知道的话!看,我通过在 Rust 中完成 Code 2020 的到来自学了 Rust。作为一个新手,我在 Rust 方面非常糟糕,以至于我平均每行代码有一个以上的编译器错误。一切都很艰难。然而,对于大约三分之二的挑战,程序第一次编译时,它给出了正确的答案。这让我感到震惊。使用 Python 或 Julia 时,我预计程序会崩溃。程序总是一开始就崩溃,对吧?好吧,他们在 Julia 中一直这样做,直到您通过点击它们来发现错误,并一一修复它们。事实上,对我来说,它是开发工作流程的一部分,迭代地编写解决方案,运行它,观察它在哪里崩溃,修复它,重复。您可以在第一次尝试时编写正确的程序的想法是疯狂的。经验并不是我的程序变得更安全,因为我可以毫不费力地发布它。不,它只是工作,我可以完全跳过整个调试过程,这是 Julia 开发体验的核心,因为我在编译时遇到了所有错误。这是针对小脚本的。我只能想象,当您可以安全地重构时,静态分析为大型项目带来的生产力提升,因为如果您做错了什么,您会立即知道。回到 Julia:它在静态分析和安全方面介于 Python 和 Rust 之间。您可以为函数添加类型注释,但错误仍然只出现在运行时,并且通常认为使用太多类型注释是不习惯的,这是有充分理由的。 Julia 的 Linting 和静态分析正在慢慢出现和改进,但与 Rust 相比,它们只能捕获一小部分错误。在编写类型在运行前大部分时间都不确定的通用包代码时,他们不能做太多的类型分析。 Julia 中静态分析的另一个问题是,由于编写不可推断的代码是一种完全有效(如果效率低下)的编码风格,因此有很多代码根本无法进行静态分析。同样,根据静态分析器,您可以拥有一个 Julia 包,其动态样式会导致大量“问题”,但它仍然可以正常工作。如果您的包依赖于这样的包,您的静态分析将充斥着源自第三方代码的误报。我是这些工具的忠实粉丝,但老实说,在目前的状态下,您可以依靠 linter 来捕获拼写错误或错误的类型签名,并依靠静态分析器来分析您要求它执行的特定函数调用……但是就是这样。批评动态语言没有静态分析是否不公平?这不是隐含的吗?可能。但是这篇文章是关于 Julia 的弱点,无论你如何证明它,糟糕的静态分析绝对是一个弱点。

Julia 于 2018 年发布了 1.0,并且从那时起就一直致力于不破坏。那么我怎么能说语言不稳定呢?不稳定不仅仅是破坏变化。它还与错误和不正确的文档有关。在这里,朱莉娅很糟糕。在 1.0 之前就使用了 Julia,我经常遇到核心语言中的错误。不经常,但也许每两个月一次。我不记得曾经在 Python 中遇到过错误。如果您对此表示怀疑,请查看标记为错误的未解决问题。其中一些是 master 上的暂时性错误,但是在稳定的 Julia 版本上,您仍然可以进入并从 REPL 触发很多很多旧的错误。这是我大约一年前报告的一个问题,它仍然没有得到修复:我认为这不是因为 Julia 开发人员粗心,或者 Julia 没有经过很好的测试。这只是不断发现错误的问题,因为 Julia 是相对年轻的软件。随着 1.0 之后的成熟和稳定,错误的数量已经下降,并且在未来还会继续下降。但在此之前,不要指望使用 Julia 时会有成熟、稳定的软件。然而,也存在性能不稳定的问题,Julia 是一个独特的尴尬境地。其他动态语言很慢,使用它们的人编写代码期望它们很慢。静态语言很快,因为编译器在编译过程中拥有完整的类型信息。如果编译器无法推断某事物的类型,则程序将无法编译。重要的是,因为静态语言中的推理失败会导致编译失败,所以编译器的推理是 API 的一部分,必须保持稳定。朱莉娅不是这样。在 Julia 中,编译器了解您的代码及其所做的优化是一个纯粹的实现细节——只要它产生正确的结果。即使在无法推断出 Julia 将运行的类型并产生正确结果的情况下,也只是慢了数百倍。这意味着导致推理失败和 100 倍性能回归的编译器更改不是破坏性更改。所以,这些都会发生。我的意思是,不要误会我的意思,它们不经常发生,而且它们通常只影响您的程序的一部分,因此回归很少如此引人注目。 Julia 团队真的试图避免这样的回归,并且通常在它们发布到任何版本之前都会在 Julia 的 master 分支上找到并修复它们。尽管如此,如果您维护了一些 Julia 包,我敢打赌它已经不止一次发生在您身上。

Julia 作为一种年轻的、不成熟的语言的一个更重要的后果是包生态系统同样不成熟。与拥有大量用户和更多开发人员的核心语言相比,生态系统的建立速度更慢。这对 Julia 有几个后果:首先,与已建立的语言相比,缺少很多包。特别是如果你在一个小众学科工作,就像大多数科学家所做的那样,你更有可能找到一个 Python 或 R 包来满足你的需求,而不是 Julia 包。随着时间的推移,这种情况显然会有所改善,但现在,朱莉娅还远远落后。您也更有可能在 Julia 中找到过时或未维护的软件包。我认为,这并不是因为 Julia 包比其他语言更容易失修,而是因为已经存在 20 年的包比已经存在两年的包更有可能再使用 5 年。 Julia 1.0 发布仅三年,因此如果您找到 2015 年的博客文章,任何发布的 Julia 代码都不太可能有效,并且从那时起,这些软件包可能已经发布了一些重大更改。相比之下,Python 包 Numpy 的长度大约是 Julia 1.0 的五倍!在软件生态系统中,整合为知名软件包也需要一段时间。例如,在 Python 中,每个人都知道在处理数据帧时使用 Pandas。它已成为事实上的标准。如果它要被废黜,任何竞争者都必须与 Pandas 相媲美,这意味着它本身必须是一个可靠的、使用良好的包。也许最关键的是,围绕 Julia 的开发工具也不成熟,缺少许多基本功能。这也是生态系统不够成熟的结果,其背后的开发工作太少(值得注意的是,与我所知道的所有其他语言不同,没有大公司对 Julia 做出了巨大贡献)。以下是一些随意选择的示例: Julia 的内置测试包是准系统,不提供测试的设置和拆卸,也不提供仅运行完整测试套件子集的功能。 Julia 的编辑体验并不好。它变得越来越好,但是由于少数人在业余时间开发的最重要的 Julia IDE,它具有您所期望的所有崩溃、缓慢和不稳定。

静态分析是全新的,感觉还没有进入最终形式。它也没有 IDE 集成。没有用于对 Julia 代码进行基准测试和分析的通用框架。在单个会话中,您可以使用 BenchmarkTools、@allocated、Profile、JET、JETTest、@code_native 和 Cthulhu 分析相同的功能,每个都必须单独加载和启动。当新用户面临性能问题并在 Julia 论坛上询问“我应该怎么做”并得到 10 个不同的答案时,这个问题尤其值得注意,每个答案都涉及一个特定的子分析,这些子分析可能会揭示性能问题的一个特定原因。这是一个巨大的时间槽,而不是一个很好的用户体验。应该可以在单个分析包中收集多个这些工具,但尚未完成。这是我与 Julia 之间最有争议的问题。不了解 Julia 的人不知道我说子类型系统不好是什么意思,而了解 Julia 的人不太可能同意我的观点。对于不熟悉的人,我将简要回顾一下该系统的工作原理:在 Julia 中,类型可以是抽象的,也可以是具体的。抽象类型被认为是“不完整的”。它们可以有子类型,但它们不能保存任何数据字段或被实例化 - 毕竟它们是不完整的。具体类型可以被实例化并且可能有数据,但不能被子类型化,因为它们是最终的。这是一个虚构的例子: # Abstract type subtyping BioSequence (itself abstract) abstract type NucleotideSequence <: BioSequence end # 具有子类型 NucleotideSequence 字段的具体类型 # 不能被子类型化! struct DNASequence <: NucleotideSequence x::Vector{DNA} end 可以为抽象类型定义方法,它的所有子类型都继承了这些方法(即行为可以继承,但数据不能继承)。但是如果一个具体的类型定义了相同的方法,那将覆盖抽象的: # 泛型函数,慢函数 print(io::IO, seq::NucleotideSequence) for i in seq print(io, i) end end # Specialized function, overwrites generic function print(io::IO, seq::DNASequence) write(io, seq.x) #优化写实现结束

因此,您可以创建类型层次结构,实现通用回退方法,并在需要时覆盖它们。整洁的!不喜欢什么?嗯...假设你实现了一些有用的 MyType。另一个包认为它真的很整洁,想要扩展类型。太糟糕了,这是不可能的 - MyType 是最终的,不能扩展。如果原作者没有为 MyType 添加抽象超类型,那你就不走运了。很可能,作者没有。毕竟,优秀的程序员通常遵循 YAGNI 原则:不要先发制人地实现你不需要的东西。例如,在 Python 中,您不会遇到想要子类化但不能子类化的类型。你可以子类化任何你该死的东西。在 Rust 中,问题甚至无法识别:您编写的任何类型都可以自由派生 trait,并且完全不受它在类型层次结构中的位置的限制,因为没有类型层次结构。另一方面,假设您发现作者确实添加了 AbstractMyType。然后你可以对它进行子类型化:......然后呢?你需要实施什么?抽象类型需要什么?它保证什么? Julia 绝对没有办法找出抽象接口是什么,或者你如何遵守它。事实上,即使在 Base Julia 中,也没有记录基本类型,如 AbstractSet、AbstractChannel、Number 和 AbstractFloat。 Julia 中的数字究竟是什么?我的意思是,我们知道数字在概念上是什么,但是当您子输入 Number 时,您选择加入什么?你承诺什么?谁知道?连核心开发者都知道吗?我对此表示怀疑。 Julia 中的一些抽象类型有很好的文档记录,最显着的是 AbstractArray 及其抽象子类型,并且 Julia 的数组生态系统如此之好可能并非巧合。但这是一个很好的例子,而不是一般的模式。具有讽刺意味的是,这个异常经常被当作 Julia 类型系统运行良好的一个例子。对于任何认为“它不可能那么糟糕”的人来说,这是一个有趣的挑战:尝试实现一个 TwoWayDict,一个 AbstractDict,如果 d[a] = b,那么 d[b] = a。在具有继承性的 Python 中,这是微不足道的。您只需将 dict 子类化,覆盖它的一些方法,其他一切都有效。在 Julia 中,您必须首先定义其数据布局 - 相当麻烦,因为字典具有复杂的结构(请记住,您不能继承数据!)。数据布局可以通过创建一个简单地包装一个 Dict 的类型来解决,但是当你必须以某种方式弄清楚 AbstractDict 承诺的所有内容(祝你好运!)并实现它时,实现的真正痛苦就来了。

依赖子类型进行行为的另一个问题是每种类型只能有一个超类型,并且它继承了它的所有方法。通常,事实证明这不是您想要的:新类型通常具有多个接口的属性:也许它们是类似集合的、可迭代的、可调用的、可打印的等。但不,朱莉娅说,选择一件事。公平地说,“可迭代”、“可调用”和“可打印”是如此通用和广泛有用,它们在 Julia 中没有使用子类型实现 - 但这不是说明了什么吗?在 Rust 中,这些属性是通过 trait 实现的。因为每个特征都是独立定义的,所以每种类型都面临着各种可能性。它可以选择它可以支持的内容,仅此而已。它还导致更多的代码重用,因为您可以例如简单地派生 Copy 并获取它而无需实现它。这也意味着有创造“较小”特征的动机。在 Julia 中,如果您对 AbstractFoo 进行子类型化,则您选择加入潜在的大量方法。相比之下,创建仅涉及少数或一种方法的非常具体的特征是没有问题的。 Julia 确实有特征,但它们是半生不熟的,不受语言支持......