摘要:
作为一名程序员,很少有人在从业生涯中只接触过一种编程语言,本文的作者就是如此。最初,身为图形程序员的他喜欢 Python 的快速易用,后来他发现 Common Lisp 更加高效,但用了 7 年后,
他最终换成了与 Common Lisp 非常相似但更有优势的 Julia。
原文链接
:https://mfiano.net/posts/2022-09-04-from-common-lisp-to-julia/
声明:本文为 CSDN 翻译,未经授权,禁止转载。
作者 | Michael Fiano
译者 | 弯月
出品 | CSDN(ID:CSDNnews)
我想通过本文讲述我选择的主要编程语言从Common Lisp过渡到Julia的经过及原因。本文仅代表我个人的经验和看法。
这两种语言的设计都非常优秀,而且运行良好,所以我鼓励你亲身研究一下,看看哪种编程语言最适合你。
从 Python 到 Common Lisp
我第一次接触Common Lisp是在 2008 年 1 月。在此之前,大部分时候我都在使用Python,有一位同事偶尔会笑话我的代码,然后用Common Lisp重新实现部分代码,希望能改变我对Common Lisp的看法,然而他的努力都白费了。我清楚地告诉自己,永远也不会使用带有如此多括号和结构的语言,这种语言太不正常了。我使用Python就是因为这种语言可以轻松快速地将我的想法转化为代码。有一天,纯粹因为太无聊,我随手翻开了《Practical Common Lisp》,这是一本经常被人推荐的Common Lisp入门参考书。几天之后,虽然我还没有读完这本书,但我发现自己已经爱上了这些括号。我是如何从一个极端走向另一个极端的?答案很简单:虽然我只是一个Lisp初学者,但我用Common Lisp编写代码的效率竟然比Python还高,尽管我拥有多年的Python开发经验。在我看来,这太不可思议了,因为Python经常自诩非常适合快速开发应用程序。
从2008年~2022年,我每天都在使用Common Lisp编写代码,而且只用这一种语言。我能够快速有效地将自己的想法转化成代码。这都要归功于Common Lisp是围绕交互性构建的。我可以一边运行Lisp镜像,一边修改代码,不断完善程序,也可以做任何我想做的事情,而且每次修改代码后可以立即得到反馈。鉴于我是一名图形程序员,这种交互然后迭代的开发工作流程对我来说尤其重要。我的工作是编写游戏引擎、游戏、图形算法、过程生成图像、模拟以及其他相关的应用程序。
从 Common Lisp 到 Julia
在使用Common Lisp做了7年的软件开发后,2015年,我开始对这门语言的各个方面都感到越来越失望,所以我开始寻找第二种语言,毕竟我们都知道不要把所有鸡蛋都放到一个篮子里。就在这个时期,我听说了Julia。于是,我通读了整本手册,这门语言留给我的印象很深刻。然而,当时Julia还处于起步阶段,所以我也没有做进一步的探索。于是,我继续使用 Common Lisp,直到2017年,一位有名的Lisp程序员宣布改用Julia。虽然我不是数据科学家,但他的文章(https://tamaspapp.eu/post/common-lisp-to-julia/)重燃了我对Julia的兴趣,这次我决定亲身尝试一下。至于后面的故事嘛,长话短说,我经历了一段非常艰难的时期,由于当时Julia还不稳定(距离1.0版本发布还有一年的时间),有很多出乎意料的事情。所以,我只好暂时放弃,只在REPL实验中偶尔使用Julia,但我一直在留意后续版本的发展。
2020年,我的一个长期项目遭遇滑铁卢,以此次事件为契机,我决定向其他领域过渡,但仍然是与图形相关的工作。就这样,我开始使用Julia编写了一个简单的图形数学库,但没过多久我就开始怀念 Common Lisp。然而,这是我第一次尝试编写 Julia 代码,而且这门语言给我留下了深刻的印象。最终,我还是回到了 Common Lisp,编写了各种小型实用程序和图形算法。
时至2022年,我意识到,如果因为其他语言缺乏Common Lisp的一些特性而将它们拒之门外,恐怕自己就要永远“困”在Common Lisp中了。我不敢奢望某种语言能够聚集所有这些特性。于是,我开始学习一些编程语言,包括但不限于 Scheme、Racket、Raku、Go 和 Rust。在这当中,Rust是唯一一个我之前就研究了很多年的语言,只不过在当时它还没有这么流行。我决定重新探索Rust,看看自己能否接受它。然而,最终的答案是否定的,而且我尝试过的所有其他语言也都是否定的。
2022 年 6 月,我强迫自己停止使用 Common Lisp,并利用闲暇时间,花了几周使用Julia编写了一个小型数学库。在编写这个库的过程中,我看到了 Common Lisp 和 Julia 之间的相似之处。我发现我更加欣赏Julia,于是我又编写了一个库,这个库收到了社区的很多积极的反馈。一直到了9月,我都没有再碰过Common Lisp,也没有回头的打算。似乎Julia可以满足我所有的需求。
为什么我不再使用 Common Lisp
Common Lisp 是一门很棒的语言,但它不适合我。这门语言的问题大多是社会问题,而不是技术问题。随着时间的推移,我遇到的以下问题导致我对该语言越来越失望。编辑器支持
Common Lisp的设计决定了它不适合在任何旧的文本编辑器或 IDE 中编辑。它的设计完全基于交互性和迭代式开发,这远比Julia等其他语言的增量式编译或热重载要激进得多。CLOS 和 Common Lisp等条件系统天然具备高度的动态和交互性。例如,当出现异常情况时(不仅仅是出错时),编辑器中会“弹出”调试器。你可以检查任何堆栈帧中的局部变量的状态,查看任何堆栈帧的上下文中的任意代码等等。在调试器处于活动状态期间,程序基本上处于“暂停”状态,你可以自由地编辑函数和其他定义,然后告诉调试器在你的修改基础之上继续执行程序。你甚至可以通过编程的方式控制该行为,而且还可以控制是否展开堆栈,并自定义异常状况的处理。大多数其他语言中的“异常系统”只相当于Common Lisp条件系统的一小部分,而且关键是,在处理异常之前,调用堆栈就展开了,所以大多数情况下你没办法正确地处理异常。
你可以通过Common Lisp 检查器直观地遍历对象层次结构,探索有关程序状态的每一个细节,甚至修改它们的值,所有这些都是编辑器内置的功能。
编译函数和其他定义也非常简单,只需要将光标悬停在编辑器中相应的定义上,然后按下一个快捷键。而且编译结果是实时的,运行中的程序会立即看到变更。
所有这些交互性都是通过一个编辑器插件实现的,它远远超出了语言服务器协议服务器的范围。因此,任何非 Emacs 的编辑器对它的支持都非常有限。虽然其他编辑器也有支持,但远不如 Emacs方便和完整。
我不喜欢被迫使用特定的编辑环境来用一种语言编程。我是一个 Vim 用户,虽然Vim也有许多类似的插件,但使用起来非常别扭,并且提供的功能也只有Emacs插件的一部分。虽然我们可以通过设置,让Emacs表现出与Vim类似的行为,但 Emacs 的复杂性和蹩脚的功能依然会时不时冒出来。虽然多年来我一直被迫使用Emacs,但我一点都不喜欢这种编辑器。
语言发展
Common Lisp 不只是一门语言,它还是描述如何实现语言的文档。因此,这个ANSI标准有多种实现方式,每种实现方式都有自己的一组特性。有时编写可移植的 Common Lisp 难度非常大,甚至根本不可能。我们经常需要使用可移植性库来统一特定功能在不同实现中的接口。
Common Lisp是一个标准,这一事实既是福也是祸。许多开发人员认为这是好事,因为编写的代码无论过多久都不会出问题。但对于某些人来说,这意味着该语言不会再发展了。例如,
Common Lisp的诞生早于Unicode;没有线程的概念;不强制要求 IEEE-754 浮点编码;没有描述外部功能接口(FFI);没有定义垃圾收集接口;没有网络支持;等等。所有这些特性都需要第三方库来实现,甚至还需要可移植性库来统一不同实现之间的接口。
此外,这个标准非常难以学习,尤其是对于尝试学习该语言的初学者。标准的许多地方都很模糊,甚至是错误的,并且经常在 Common Lisp 交流论坛中引发长时间的争论。
软件的版本与部署
Common Lisp没有内置的包管理器。安装新软件需要通过第三方库进行,通常包含在每一种Common Lisp的实现中,但并不是语言标准的一部分。许多实现经常会包含非常古老的包管理器,所以经常需要自行替换掉。而且,这个软件(ASDF,https://asdf.common-lisp.dev/asdf.html)不支持软件下载,只能安装本地已经下载好的软件。还有另一个第三方软件Quicklisp,它是ASDF的一个封装,支持从互联网上下载新版本软件,然后转给底层的ASDF工具进行安装。
虽然Quicklisp能很好地处理小型的开源Common Lisp社区版,但远不理想。它本身包含了一个发行版——“quicklisp”发行版。这是一堆用来描述软件包的元数据,描述了每个软件包的最新版本是什么、可以从哪里下载,以及依赖项是什么。软件需要通过官方Quicklisp的发行版库安装,而不是从上游库进行安装。
Quicklisp由其维护者负责管理,他们负责保证所有软件都能正确构建(只是软件本身能构建而已,不会检查软件是否与同一发行版中的其他软件兼容)。Quicklisp的维护者每隔一两个月从上游下载软件包,并放到一个发行版中。这个过程不会考虑兼容性信息,用户只能收到最新版的软件,而这个版本并不一定能与其他软件兼容。
实际上,Common Lisp的开发者们甚至都不会给软件添加版本号,因为他们把版本管理的责任完全推给了Quicklisp的维护者,而自己什么都不管。在我看来,这个模型远远没有达到正规软件开发的标准。对于用户会收到哪个版本的软件,开发者完全不知情,除非开发者在Quicklisp的代码库上创建一个分支来跟踪,而不是像现在这样只更新自己的代码库的主分支。但是,就算你这样做,你的用户在安装“稳定版”软件时,其依赖项也不一定会采用这种方法,因此除非开发者能自己维护一份特殊的Quicklisp发行版,否则根本没办法保证软件与依赖项之间的兼容性。不过,大多数用户不会安装内置的Quicklisp之外的发行版。Common Lisp的开发者似乎并不关心能否产生可重复的构建,也不愿意担负起软件部署的责任。
由于Quicklisp的官方发行版每一两个月发布一次(对于软件界来说,这个时间间隔太长了),开发者没办法即时推送补丁,也不能解决用户报告的问题,除非自己维护并说服用户使用自己的发行版,或者说服用户直接从上游库中下载源代码,并放在硬盘的特定位置上,让Quicklisp覆盖发行版自带的软件版本。
我始终认为,软件开发者应当负起部署和维护自己的软件的责任,而不是依赖于Quicklisp。
文档
Common Lisp库几乎没有文档。“docstring”和代码中的注释并不是用户文档。被广泛接纳的成功开源项目必须有真正的离线用户文档,里面有很多的使用范例、教程、图片,以及能帮助用户熟悉软件的一切。这是显而易见的,尤其是Common Lisp面向的对象主要是其他开发者,他们最喜欢阅读代码并随意修改。软件质量
Common Lisp的软件通常质量很高——考虑到许多软件都是由一个人负责开发,然后很快就被遗弃的现状。Common Lisp的程序员的思维特别活跃,也非常善于写代码,但通常都是单打独斗,所以常常无法考虑到所有边界条件、找到重大bug、编写文档,兴趣也不会在同一件事情上停留太久。这揭示了Common Lisp的一个非常现实的问题:它更容易吸引那些“非我所创综合征”(指倾向于自己创造解决方案,而非采用已有方案)且拒绝合作的人们。许多软件的功能都大幅度重叠,而且对于新人来说,有许多唾手可得的成果诱惑着他们去创造。再加上语言本身很小,所以这种现象对语言的伤害非常大。
在我看来,这要归罪于语言强大的可塑性。重新实现一个想法,远比使用或修改其他人的实现要容易得多。这是因为Common Lisp的代码非常灵活,能够完全反映我们的思维过程。毕竟,代码只不过是一个人的思维过程的投影。
这个问题甚至有递归的效果,我们已经有了许多“半吊子”解决方案,而下一轮人们又会在其上制造出更多的半成品。
Common Lisp的灵活性养育了更多个人开发者,这个现象在库生态系统中尤为明显。
编程范式
Common Lisp是一门多范式编程语言。尽管如此,它最强大的功能还是面向对象范式。虽然你可以混合使用不同的范式,但几乎无法避开面向对象,而面向对象正是Common Lisp深入骨髓的范式。虽然众说纷纭,但CLOS(Common Lisp对象系统)是Common Lisp最好的特性之一。语言本身就建立在面向对象之上,尽管许多现代程序员并不认识它的这种极其特殊的面向对象。CLOS与其他对象系统的主要区别是,它将方法从类中分离出来,从而解决了一些长期困扰其他面向对象实现的问题,比如多重继承。此外,CLOS还可以通过MOP(Meta-Object Protocol,元对象协议)进行完全定制,因此人们可以在任意层面上改变对象系统的行为。
尽管我认为CLOS非常优秀,而且必不可少,但我也相信,从通常意义上来看,面向对象并不适合所有问题。实际上,对于大多数应用,我更希望采用参数多态,而不是使用Common Lisp的泛型函数临时创造的多态。
Common Lisp中的泛型函数不支持参数个数重载,这一点Julia和其他支持泛型函数的语言都支持。在Common Lisp中泛型函数的参数个数是固定的,并且规定了一种协议。许多人喜欢这一点,但我不赞成。
此外,Common Lisp的大部分特性都不支持泛型。例如,你无法重新定义序列的特性,无法将序列扩展成其他集合类型或迭代器。一些实现支持一种名为“可扩展序列”的扩展,部分解决了这个问题,但由于只有少数几个实现支持这一特性,因此并不具备可移植性。原因是,在Common Lisp中调用泛型代码会导致性能略微下降,因此在注重性能的应用中,人们不得不使用非泛型的代码。
Common Lisp中的用户自定义类型有三种形式:类,结构体,类型。defclass定义类,defstruct定义结构体,deftype定义类型。
结构体很少用到,除非在注重性能的代码中,因为它的工作方式无法与Common Lisp的交互式开发流程完美结合,重新定义结构体会导致未定义的行为。此外,它们只支持单继承。
泛型函数对参数进行特化只能在类名上进行,无法在类型上进行。尽管每个类都有同名的类型,但反过来并不成立。例如,你无法定义一个方法,对某个范围 [0, 10) 进行操作。但是,可以针对一个值进行操作,前提是这个值可以通过相等操作进行比较,也就是说,只能用于标量上,而不能用于集合值上。
尽管CLOS非常强大,而且可以实现各种技巧,但我几乎每天都会遇到这些涉及根本的问题,我真心希望它能支持带有泛型类型参数的参数化多态。
社区
开源Common Lisp社区是我见过的最小的编程语言社区。获得帮助很困难,找人合作项目更困难,因为大多数开发者都是单独作战。获得帮助也很困难。有经验的Common Lisp开发者会假设你对语言有基本的了解,知道风格上的最佳实践,并且熟悉Emacs,安装了相关的Common Lisp工具。作为初学者,寻求帮助并贴一段代码,得到的回答往往是要求先修正代码风格问题才能提供帮助。这非常打击初学者,让他们丧失学习的信心。在极端情况下(而且并不罕见),社区对于新手问题的容忍度非常低,因为人们经常会因为术语错误或不恰当的代码格式而怒不可遏。
即使作为有经验的Common Lisp开发者,我也不喜欢参与讨论,因为这有损健康。社区应该有更友好的氛围。
性能
如果你的目标是极致的性能,那么Common Lisp绝不是最佳选择。尽管你可以编写出速度可与C语言媲美的代码,但这很大取决于Common Lisp的实现,以及与交互性的取舍。要想获得运行时性能提升,可以不使用泛型函数的动态分发特性,这就意味着不能使用CLOS的类编程。相反,必须使用结构体来创建集合类型,因为其字段访问器不是泛型函数,而是正常函数,而且重新定义会导致未定义的行为。
类型注释通常要分散到整个代码中,还要使用宏和编译器宏,在编译时将代码变成效率更高的形式。
任何立即值都要特化并放到内存中,而不能采用指向堆内存的指针。可以特化的元素类型完全依赖于具体实现,而且几乎只能使用标量。结构体数组、数组的数组以及许多其他数据类型基本上无法在不影响性能的前提下使用。
并不是每个人都能写出高性能的Common Lisp代码,而且这几乎是一件不可能完成的任务。此外,有些代码在一种实现上运行的速度很快,但换到另一种实现上可能就会变得非常缓慢。在我看来,只要开始编写依赖于具体实现的代码,就意味着该换一种编程语言了。
为什么我选择了Julia作为主编程语言
Julia是一种非常强大的语言。至于我个人喜欢这门语言的原因,我总结了一下,大致如下。简单来说,Julia与Common Lisp非常相似,此外Julia还有许多优势。编辑器支持
我认为Julia的编辑器支持是最棒的。你不需要使用特定的编辑器就能获得Julia的完整体验。各种编辑器都有十分优秀的插件,甚至有专门的组织负责改进Julia的编辑器工具。我个人喜欢使用Vim和LaugnageServer.jl,体验非常好。
语言发展
Julia与Common Lisp不同,它没有语言标准,可以自由发展,因此可以解决更多的问题。我认为这是一件好事。我不希望一门语言故步自封。我希望看到进步,而在过去几年,Julia取得了很多进步,采用了许多新特性,新软件包的出现速度也越来越快。
软件版本和部署
与Common Lisp不同,Julia的开发者可以全权负责软件的发布。不过,有了内置的包管理器Pkg.jl,以及软件包仓库和相关工具,这一切都非常容易。我将我的软件托管在GitHub上,而将软件发布给Julia社区的过程也非常容易。我只需要在希望发布的提交上添加一个注释,一个GitHub机器人就会负责分析软件,如果符合要求,就将其合并到官方软件仓库中。使用其他托管平台甚至自行托管时的流程也非常相似。
在软件包进入官方仓库后,任何人都可以使用内置的包管理器进行安装。
构建的可重复性也非常好。所有Julia项目环境都带有一个清单文件,记录了所有依赖项的精确版本,如果项目名称在多个仓库中出现,那么还会记录一个唯一的标识符,以解决歧义问题。只需把这个清单发给其他人,他们就能复现出完全相同的开发环境。非常容易。
Pkg.jl是一个设计得非常好的包管理器,提供许多有用的功能。为强烈建议你阅读一下文档,了解一下它的基本功能。它是迄今为止我用过的最好的包管理器。
编程范式
Julia不是一门面向对象语言。实际上,它根本没有类。它有一个特别的类型系统,初看之下似乎功能非常有限,只能实例化类型的树形结构的叶节点。中间节点是抽象类型,不能被实例化,但可以用来定义所有类的行为,甚至可以用trait对类进行解构。只能实例化叶节点这一点看起来似乎有点受限,但实际上大幅度简化了泛型函数的可应用性,因此阅读程序变得非常简单,而且可以鼓励程序员采用组合结构来代替继承结构。
类型参数化和零成本泛型函数是我切换到Julia的主要原因之一。编写高效率的代码非常容易,不需要牺牲代码的清晰度。实际上,除了需要很好地理解数据结构和算法(这是每个程序员的基本功)之外,我根本不需要考虑任何性能有关的问题。
关于Common Lisp与Julia的泛型函数,另一个需要指出的区别是,Julia的泛型函数中的可选参数可以进行特化,这样就可以针对不同数量的参数生成多个方法。而Common Lisp禁止签名不同的泛型函数,所以Common Lisp中无法实现这一点,除非采用元对象协议扩展。
社区
Julia社区是最好的社区之一,而且非常大。它分成多个子社区,比如Slack、zulip、discourse、discord和IRC等。我是Julia zulip社区的活跃用户之一,在那里我提出的问题几乎能得到及时的答复。此外,这些人还非常欢迎新用户,让我感觉自己也是他们的一员。Julia社区是高质量社区最好的榜样。
性能
Julia代码的运行速度非常快。即使不理解常见的陷阱(如抽象类型字段、类型不稳定性)等,代码的运行速度也远超Common Lisp,尽管后者经过了大量手工优化、且采用了能生成高效机器码的实现。实际上,我的第一个项目从Common Lisp移植到Julia,性能就得到了数量级的提升,而我完全没有采用任何优化措施。这对我非常有吸引力,因为我在Common Lisp上花费的大量时间都用来优化代码了。
Julia的性能非常好,而且可读性非常高。
在最新的一个移植项目中,我花了一些心思来优化最终稳定版,结果性能提高了大约两到三个数量级。以前执行时间为毫秒级的函数,在Julia中降到了纳秒级别,而且并没有影响到代码质量。
这要归功于Julia强大的JIT编译器,以及丰富的类型系统。Julia中的泛型函数没有任何性能开销,甚至还支持类型参数化,因此我们可以针对一大类的类型定义功能,而不会影响到运行时的性能。
总结
我关注Julia已经很多年了,如今它已成为我的日常编程语言。Julia在类型和泛型编程方面投入了很大精力,因此它可以非常优雅地解决复杂的问题,而不需要使用任何类型注释。Julia的类型推断引擎非常优秀,经常会出乎意料地向我证明它的强大之处。
另一个理由是,你可以通过任何编辑器编辑Julia。我非常不喜欢Emacs。
此外,Julia的社区非常友好,知识积累丰富,也非常有包容心。
总的来说,在熟悉Julia之后,你就能感觉出Julia的设计者非常精通Common Lisp,他们在尝试通过更自然的方式来表述Common Lisp的思想。我认为Julia也是一种Lisp语言,只不过没有大量括号。仔细观察就会发现,Julia和Common Lisp真的很相似,因此我的过渡非常轻松。