Erlang 之父学习 Elixir 语言的一周

// 本文基于开源中国社区的译文稿: Elixir的一周

译序

作为Erlang之父Joe Armstrong,对Erlang VM上的新语言Elixir给出很精彩评论和思考。在『特定领域的专家的专业直觉』、『编程语言设计的三定律』、『管道运算符避免恶心代码』、『Elixirsigil引出的程度语言如何定义/解释字符串』等等这些讨论上,能强烈感受到Joe Armstrong老黑客风范。

自己理解粗浅,而本文讨论是语言设计,且作为老一代黑客的作者计算机领域中那被我们现在不再要去理解使用的主题和思想(如Prolog/DCGLisp/宏、sigil、不可变闭包、语言设计的兼容性)真是信手拈来,翻译中肯定会有不少不足和不对之处,译文源码在GitHub上《与Elixir相处的一周》,欢迎建议(提交Issue)和指正(Fork后提交代码)!
PS:为什么要整理和审校翻译 参见 译跋


与 Elixir 相处的一周

差不多一周前我开始看Elixir,关于Elixir之前只有些模糊的了解,没打算花时间去看细节。

但在得知 Dave Thomas 出版了 Programming Elixir 这本书的消息后,我的想法就彻底改变了。Dave Thomas 帮我修订过我的那本Erlang的书并且作为Ruby的倡导者做得非常出色,所以要是 Dave 对一样东西产生了兴趣,那说明这样东西的有趣性是毫无疑问的。

DaveElixir很感兴趣,在他的书里这样写道:

在1998年的时候,由于我是comp.lang.misc邮件组的忠实读者,机缘巧合得知了Ruby,然后下载、编译、与Ruby坠入爱河。 (没听过comp.lang.misc?那去问问你老爹吧。) 就像任何一次相爱经历一样,你很难解释原因是什么。 Ruby的工作方式和我心里想的灵犀默契,而且总是有足够的深度持续点燃着我的热情。

回首已经逝去15年的时光,而我无时无刻不在寻找下一个也能给出这样感觉的新『对象』。

很快我遇上了Elixir,由于一些原因,我没能一见钟情。 但在几个月前,和Corey Haines聊了一次,在如何不用那些学院派的书给大家介绍哪些有吸引力的函数式编程概念这个问题上诉了些苦。 他告诉我再去看看Elixir。我照做了,有了第一次看到Ruby时那样的感觉。

我能理解这种感觉,一种先行于逻辑的内心感性的感觉。就像我知道一件事是对的,我却并不知道我是如何和为什么知道这是对的。而对原因的解释常常在几周甚至几年后才显露出来。Malcolm Gladwell 在他的 Blink: The Power of Thinking Without Thinking 一书中曾探讨过这个问题。一个特定领域的专家常常能瞬间感知出一些事情是否正确,但却不能解释为什么。

但得知DaveElixir『看对眼』时,我很想知道为什么他会这样。

无独有偶,Simon St. Laurent也出了本Elixir的书。Simon的 Introducing Erlang 一书表现不俗,我和他还通过邮件沟通过几次,还有有些熟悉的。而且Pragmatic PressO’Reilly出版社都在争着要出版Elixir,我知道在Erlang VM上有事要发生,而我自己还没注意到。毫无疑问我Out了!

我发封邮件给DaveSimon,他们爽快地借给我了样书,现在可以开始阅读了……谢了二位……

上周我下载了Elixir然后开始学习……

没多久我觉得就上手了。确实是个好货!有趣的是ErlangElixir两者在在底层一样的,对于我来说『感觉』是一样的。事实上也确实这样,两者都会被编译成EVM(Erlang Virtual Machine)指令 —— 实际上EVM这个叫法之前没人用,都叫成Beam,但为了和JVM区分开,我觉得是时候开始用EVM这个叫法了。

ErlangElixir为什么有相同的『语意』?这得从虚拟机底层谈起。垃圾回收行为,不共享并发机制,底层的错误处理和代码加载机制都是一致的。当然这些肯定都是一致的:他们都运行在相同的VM里。这也是ScalaAkka区别于Erlang的原因。ScalaAkka都运行在JVM之上,垃圾回收和代码加载机制从根本上就不一样。

你直接看到的Elixir是完全不同的上层语法,源自Ruby。看起来不那么『可怕』语法和很多附加的甜点。

Erlang的语法源自Prolog,并受到SmalltalkCSP和函数式编程的影响很大。Elixir则受到ErlangRuby的影响很大。从Erlang借鉴了模式匹配(pattern matching)、高阶函数(higher order function)以及整个进程(process)和任其崩溃的(let it crash)错误处理(error handling)机制。从Ruby借鉴了sigil和快捷语法(shortcut syntax)。当然也有自创的甜点,像|>管道操作符(|> pipe operator),让人想到PrologDCGHaskellMonad(尽管相比要简单不少,更类似于Unix的管道操作符),还有宏的引用和反引用操作符(macro quote and unquote operator,对应的是Lisp的反引号和逗号操作符)。

【译注】:

sigil是指在变量名中包含符号来表达数据类型或作用域,通常作为前缀,如$foo,其中$就是个sigil。 像本文中说的例子,sigil也可以能对常量加上字母符号,r"abc",其中rsigil,把字符串转成正则表达式。 详见wikipedia词条sigil)


DCGdefinite clause grammar),确定性子句语法,表达语法的一种方式,可以用于自然语言或是形式化语言,比如像Prolog这样逻辑编程语言。基本的DCG用于描述『是什么』和『有什么特性』(简单的可以认为逻辑编程程序员要做的就是给出这些描述;剩下的事是逻辑引擎会根据描述的规则生成算法,然后得出解来)。像这样:

不展开说明了,对于没有了解过Prolog/逻辑编程的同学意会一下就好,不用纠结了。详见wikipedia词条Definite clause grammar


译文使用英文术语本身,不翻译成中文,有更好的辨识度。

Elixir还提供一个新的下层AST,取代了每个form都是独有表示的Erlang ASTElixir AST有一个统一得多的表示,这使得元编程(meta-programming)要简单得多。

Elixir的实现出奇的可靠,尽管有几个地方和我预想的不一样。字符串插值(string interpolation)的工作方式有时候不好使(字符串插值是个很棒的想法) :

x求值后把x友好格式化的表示(a pretty-printed representation)插入到字符串中。但是只对简单形式的x可行。

这点可以通过从Elixir调用Erlang的方式很简单就能解决掉。

IO.puts "...#{pp(x)}..."这样就总是可行的。我只是把pp(x)定义成

Erlang则表述成:

很『显然』这和Elixir的版本表述是一样。当然Elixir的写法要更容易阅读。上面用到的|>操作符意思是把io_lib:format的结果输入到lists:flatten,然后再到list_to_binary。就像好用的老家伙Unix的管道符|

Elixir打破了一些Erlang神圣信条 —— 在顺序结构中变量可重绑定(re-bound)。实际上这也是可以做到的,因为最终结果还是可以规范化成静态单赋值(static-single-assignmentSSA)的形式。尽管在顺序结构中这是可以的,但在循环结构中,一定肯定以及确定不要这么做。但这不是个问题,因为Elixir木有循环,只有递归。实际上Elixir不可能在循环中包含可变的变量(mutable variables),因为这样编译出来的东西在下层的EVM是支持不了的。顺序结构的SSA变量挺好的,EVM知道如何对其做优化。但在循环结构不行,所以Elixir没有这么做。关于这方面的优化甚至可以更往下挖到LLVM汇编器(LLVM assembler) —— 但又是另一个很长的故事先就此打住吧。

0. 编程语言设计的三定律

  1. 你做对的,无人为你提。
  2. 你做错的,有人跟你急。
  3. 难于理解的,你必须一而再再而三地去给人解释。

一些语言有的设计做得太好,结果大家都懒得去提,这些好的设计是正确的、是优雅的,是易于理解的。

对于错误的设计,你完了。你成了2B,如果好设计比坏设计多,你可能被原谅。你想在以后干掉这些坏设计,却因为向后兼容性或者是有些SB已经用上所有那些坏设计写上了1T行代码,结果你是改不了了。

而难以理解的部分才是真正无赖。你必须一而再再而三地解释,直到你吐血,可还是有些人永远不懂,你必须写上百邮件和数千文字来一遍又一遍地地解释这是什么意思以及为什么会如此。对于一个语言的设计者或作者来说,这是个痛苦的深渊。

下面我要说到的几件事,我认为也会落入这三类情况中。

在我开始前,我先要说,Elixir做对了灰常灰常多对的事情,而且远远多于错的。

关于Elixir有利的是,要改正它的错误还不算晚。这只能在无数代码行被写下和众多程序员开始使用它之前才能做到 —— 所以解决这些问题的时日并不多了。

1. 在源文件中没有版本

XML文件总是这样开始的:

这点非常好。读取XML文件的第一行就像是听到拉赫玛尼诺夫的第三钢琴协奏曲的第一小节(【译注】:指其富有辨识度)。这是一个令人赞叹的经验。赞美XML设计师,愿他们的名字得到荣光,给这帮伙计颁图灵奖吧。

所有源文件中加上语言的版本是必要的。为什么呢?

早期的Erlang没有列表推导(list comprehension)。如果我们对一个新版的Erlang模块用一个旧的Erlang编译器去编译。新版的代码含有列表推导,但旧编译器并不知道列表推导,所以旧编译器会认为这是一个语法错误。

如果 版本3 Erlang编译器处理这样开始的文件:

则可以给出这样提示信息:

啊~~~~咦~~~~

哦,烦炸了,我只是版本3的编译器,看不懂未来。

你刚刚给我一个版本5的程序,这说明我在地球上的寿命已过。

你将不得不杀掉我,把我卸载,然后安个版本5的新编译器。曾经玉树临风的我现在没了价值,我将不再存在。

再见吧,老朋友。

我感觉头痛。我要休息一下……

这是数据设计的第一法则:

所有未来可能会改变的数据应该标记上版本号。

而 模块 数据。

2. fundef不同

在写Programming Erlang一书时Dave Thomas问函数为什么不能输入到shell里。

如果模块里有这样的代码:

不能直接复制到shell里运行,得到相同的结果。Dave问这是为什么,并说这样很傻。

Lisp等其它语言主中这做是没问题的。Dave说了『这很会让人很迷惑』类似这样的话 —— 他说的对并且这确实让人迷惑了。在论坛里关于此的问题肯定有成百上千条。

我解释这个问题已经无数遍了,从黑发解释到白发,我现在头发真白了就是因为这个原因。

原因是Erlang的一个bug

  • Erlang的模块是一系列的 FORM
  • Erlang shell解析的是一系列 EXPRESSION
  • ErlangFORM 不是 EXPRESSION

上面两个是同的。这小点愚蠢成了Erlang一个永远的痛,当时我们没有注意到,到了现在我们就只能学会和它相处。

Elixir模块可以这么写

估计很多人都会直接从编辑器复制到shell里直接运行,然后收到的是出错信息:

如果你不解决这个问题就要花后面20年的时间去解决为什么 —— 就像Erlang曾经所做的。

顺便说一下,修复这个问题真的真的很简单。我在erl2作为了尝试就解决了。Erlang中没法修复这个问题 (版本兼容问题),所以我就在erl2解决。只需要erl_eval的小改和解析器的几个微调。

主要原因是FORM不是EXPRESSION,所以加了个关键字def

这就定义了一个有副作用的表达式。由于是个表达式,可以在shell中求值了,记住在shell中只能对表达式求值。

副作用指的是需要创建一个shell:fac/1功能(就像在模块中定义的一样)。

上面两者应该是一致的,并且都是定义一个名为Shell.double的函数。

做了这样的修改,妈妈再也不用担心我会白头了。

3. 函数名称中有个额外的点号

在学校里我学会了写f(10)来调用函数而不是f.(10) —— 这是个『真正』的函数,函数名是Shell.f(10)(一个在shell中定义的函数)。shell部分是隐式的,所以可以只用f(10)来调用。

如果这点你置之不理,那就等着用你生命的接下来的二十年去解释为什么吧。等着在数百论坛里的数千封邮件吧。

4. 发送操作符

这是啥玩意?你知道从occam-pi转成Elixir有多难么。

这点让你现在在失去occam-pi社区路上。发送操作符就应该是!,像这样:

接下来的一周,我的大脑会变成浆糊,我的神经网络要被重编程,这样我才能『看到』<-时才能反应成! —— 这点不是在说如何我思考,而是指要重编程我更深植在脊柱里无意识反应。发送操作符已经不在我大脑里,而是在我的脊柱里。我的大脑想着『发送一个消息给一个进程』并发送信号给我的手指,我的脊柱马上加上!,接着大脑要回退删除这个字符改成<-

这是一个语法问题。让人爱恨交织的语法。如果10分制的评级标准,10代表『非常非常烂』,1代表『好吧,我可以适应』的话,这个问题我给3分。

这点会使Occam-pi的程序员很难转到Elixir,什么,只需要简单地使用!就能完成<-的功能?这可真是出人意料啊。相信会有很多人受到鼓舞的。

5. 管道运算符

这是一个很好很好的东西并且很简单就能掌握,以至于没人会给你称赞。这就是生活。

这是来自Prolog语言的隐性基因(recessive gene):monad。 在Prolog中的基因是显而易见的, 但是在Erlang中确实不明显的(Prolog的儿子)但是又在ElixirProlog的儿子的儿子)中重新显现了。(【译注】:隔代遗传)

x |> y意味着调用了x然后获取了x的输出并且将它作为y的另外一个参数(第一个参数)。

所以

等价于下面的代码:

非常有用。假设我们要把握的是把一个变量abc转换为Abc。在Elixir中没有利用的函数但是还有一个功能,就是去控制一个字符串。所以我们需要现将这个变量转换为string,在Erlang中,我们可以这样写:

这样的写法太惊悚了。我们还可以写成这样:

但是,这更糟 —— 好恶心的代码。像这样德性的代码我都不知道写过多少次了!浪费我大把的青葱岁月。

于是|>来了:

为什么我认为|>是隐性基因?

ErlangProlog中演化而来,而且Elixir也继承了Erlang

PrologDCG,所以

扩展后的形式:

这基本上是同样的想法。我们通过新加一个额外的隐藏参数把函数调用序列的输入输出串接起来了。这类似Haskellmonad用法,但做得很隐秘。

PrologDCGErlang没有,Elixir有管道操作符!

6. Elixirsigil

sigil很棒 —— 爱之。我们应该加到Erlang里。

字符串是一个编程抽象。编程语言都有字符串常量,通常使用双引号包着的一串字符。就像这样的一行代码:

编译器会转换成字符串的内部表示,关联上对应的语义。

Erlang

表示『X是字符a, b, cASCII码值对应的整数的列表』。

但也可以选择成任何其它我们想要的含义。在Elixir里,x = "abc"代表x是一个UTF8编码二进制(binary)(【译注】:binaryEVM的内置类型)。通过在双引号前面加上r可以改变字符串含义成和Erlang一样:

当然也可以被定义成代表编译过的正则表达式,也就是说和等价于X = re:compile("...") —— 基于我们确定字符串的含义,可以以不同的方式去解释(interpret)内容。可以写上这样的代码:

B值可以是Hello Joe —— 这里sigil s改变字符串常量解释行为,『替换变量的值并插入』。

Elixir在这方面做得很好,定义了很多不同的sigil

Elixirsigil语法不太一样,如下:

C是单个字符(【译注】:Erlang中大写开头的是变量不是常量,C是单个字符,表示可以是ab$),后面跟着一对{}[]

sigil很棒。Erlang本可以在15年前就有这个功能,而现在也可以引入,并且不会带来向后兼容的问题。

7. docstring

大爱docstring

但有个小意见。请把docstring放到函数定义里面

Elixir是这样:

放到函数里面会是这样:

否则成了『没有归属的注释』(detached comment):当你编辑程序时,就可能出这样的问题。注释会与它要注释函数脱离开。

Erlang里,没有办法确定注释的是下一个函数还是上一个函数,或是模块。如果注释的对象是函数那就应该放到函数里面而不是外面。

8. defmacro引用

爱之。在解析转换这个正确阶段所做的正确的事。这让可以让人舒舒服服得不用去知道抽象语法了。引用(quote)和反引用(unquote)为你把魔法都做好了。

这就是那种是对的事 —— 非常棒却真真儿难于解释。就像Haskellmonad —— 啊哈,monad真很容易解释,难怪有上千篇文章来解释它有多简单。

Elixir宏真是简单 —— 引用(quote)对应Lisp的反引号(quasiquote),反引用(unquote)对应Lisp的列表逗号操作符(list comma operator) —— 这就我说的简单 :-)

9. 额外的符号

像这样:

而不是这样:

列表后面额外的冒号让人迷惑。

10. 奇怪的空白符

哎呦~ 一定要是do:do :不行。

个人认为,空白符(whitespace)就是空白符。在字符串里面不能随便添加。在字符串外面,为了格式化代码我可以按自己喜好添加空白,这样可以让代码更美观。

Elixir不能这么做 —— 不讨我喜欢。

11. 闭包行为完全正确 —— 哦耶

Elixir的闭包(closure)(即fn表达式)和Erlang完全一样。

fn表达式有一个很好的特性:能捕获所在作用域的任何变量的当前值(换句话说:能创建不可变的闭包(immutable closure)),这点令人难以置信的有用。需要说一下,JavaScript在这点上非常错误。给一个JavaScriptElixir的例子,方便看到这点上的差异:

啥!函数f被打破了。定义的f,开始使用;修改了变量a有副作用打破了函数f。函数式编程的好处之一就是使程序变得容易推理。如果f(10)的值是15,那么就应该一直是15,不应该能在其它的地方打破。

Elixir呢? 闭包的处理是对的:

正确的闭包只应该包含不可变数据的指针 (Erlang中数据正是不可变的) —— 而不是可变数据的指针。如果闭包里有指向可变数据的指针,后面修改了数据就会破坏闭包的一致性。这样的结果就是不能把程序并行化,甚至顺序执行的代码也会诡异的错误。

在传统语言里要创建合适的闭包的代价会很高,因为捕获环境里的所有变量都需要做深拷贝,但ErlangElixir不用这样,数据都是不可变的。你所要做的就是引用需要的数据。内部实现是通过指针引用数据(指针对程序员是不可见的),并且不再有指针引用的数据会被垃圾回收掉。

shell中可以有闭包,但不能写到模块里。

shell里,如果可以这样写

为什么不能在模块里这样写呢?

这个问题完全是可以解决的,我在erlang2语言实验并解决了。

最后

这就是我与Elixir的相处一周,非常兴奋的一周。

Elixir没有令人生畏的语法,融合了RubyErlang优秀的特性。它不是Erlang也不是Ruby,有自己创新的想法。

这是门新兴的语言,但在语言的开发的同时介绍的书也同步在写了。第一本介绍Erlang 的书在Erlang被发明后7年才出现,而畅销书更是在14年后才出现。用21年的时间去等一本真正的介绍书籍实在是太长了。

Dave很喜欢Elixir,我也觉得很酷,我想我们会在使用过程中找到更多乐趣的。

像是WhatsApp这个应用和全世界一半手机网络的关键部分都是搭建在Erlang之上。当技术变得更加亲和,当新一批热衷者进入阵营,让我现在怀着非常欣喜的心情关注着后续要发生的变化。

这是篇即兴的文章。也许会有些不妥之处,欢迎大家指正。

打赏支持我写出更多好文章,谢谢!

打赏作者

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

1 1 收藏 评论

关于作者:李鼎

花名哲良 PAAS平台@阿里云/分布式服务&服务治理框架dubbo。 个人主页 · 我的文章 · 11 ·     

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部