Erlang之父学习Elixir语言的一周

Posted on October 14, 2016

原文链接:A Week with ElixirJoe Armstrong,2013-05-31
基于开源中国社区的译文稿: Elixir的一周
译文链接:A Week with Elixir

Erlang之父学习Elixir语言的一周

译序

作为Erlang之父_Joe Armstrong_,对Erlang VM上的新语言Elixir做了很精彩的评论和思考。『特定领域专家的专业直觉』、『编程语言设计的三定律』、『管道运算符避免恶心代码』、『Elixirsigil引出的程序语言如何定义/解释字符串』等等问题的讨论,个性鲜明又幽默诙谐的行文风格,都能强烈感受到_Joe Armstrong_深入广博的老黑客风范。

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


Elixir相处的一周

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

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

_Dave_对Elixir很感兴趣,在他的书里这样写道:

在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一书中曾探讨过这个问题。一个特定领域的专家常常能瞬间感知出一些事情是否正确,但却不能解释为什么。

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

无独有偶,_Simon St. Laurent_也出了本Elixir的书。_Simon_的Introducing Erlang一书表现不俗,我和他还邮件沟通过几次,所以有些事已经在酝酿了。而_Pragmatic Press_和_O’Reilly_出版社都在争着要出版Elixir,我知道在Erlang VM上的事已经在发生了,而我自己还没注意到。毫无疑问我Out了!

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

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

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

ErlangElixir为什么有相同的『语义』(semantics)?这得从虚拟机底层谈起。垃圾回收行为,不共享并发模型,底层的错误处理和代码加载机制都是一致的。当然这是肯定的:他们都运行在相同的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用于描述『是什么』和『有什么特性』(简单的可以认为逻辑编程程序员要做的就是给出这些描述;剩下的事是逻辑引擎会根据描述的规则生成算法,然后得出解来)。像这样:

sentence --> noun_phrase, verb_phrase.
noun_phrase --> det, noun.

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


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

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

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

IO.puts "...#{x}..."

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

因为可以通过从Elixir调用Erlang的函数,这点很简单就能解决。

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

def pp(x) do :io_lib.format("~p", [x]) |> :lists.flatten |> :erlang.list_to_binary end

Erlang则写成:

pp(X) ->
  list_to_binary(lists_flatten(li_lib:format("~p"), [X])))

很『显然』这和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 version="1.0"?>

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

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

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

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

-version(5, 0).

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

啊~~~~咦~~~~

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

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

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

再见吧,老朋友。

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

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

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

而 模块 数据。

2. fundef不同

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

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

fac(0)            -> 1;
fac(N) when N > 0 -> N * fac(N-1).

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

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

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

之所以这样是因为Erlang的一个bug

  • Erlang的模块是一系列的 FORM
  • Erlang shell解析的是一系列 EXPRESSION
  • ErlangFORM 不是 EXPRESSION
double(X) -> 2*X.            in an Erlang module is a FORM

Double = fun(X) -> 2*X end.  in the shell is an EXPRESSION

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

Elixir模块可以这么写

def triple(x) do
   3 * x;
end

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

ex> def triple(x) do 3*x; end
** (SyntaxError) iex:66: cannot invoke def outside module

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

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

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

Var = def fac(0) -> 1; fac(N) -> N*fac(N-1) end.

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

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

iex> double = fn(x) -> 2 * x end;

iex> def double(x) do 2*x end;

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

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

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

iex> f = fn(x) -> 2 * x end
#Function<erl_eval.6.17052888>
iex> f.(10)
20

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

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

4. 发送操作符

Process <- Message

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

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

Process ! Message

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

这是一个语法问题,而我们都喜欢对语法说长道短的。如果10分制的评级标准,10代表『非常非常烂』,1代表『好吧,我可以适应』的话,这个问题我给3分。

这点会使occam-pi程序员很难转到Elixir。什么?只需要简单地用!而不是<-,就可以让一波occam-pi的程序员喜大普奔地哭喊着『药 !药!切克闹!!美好生活现在到!!!』然后立马就转到Elixir。以后老司机告诉你真是这样的,这个改变会让人开心得像过节一样。

5. 管道运算符

这就是之前我说的一个非常好非常好的想法,并且非常非常简单就能掌握,以至于没人会给你称赞。这就是生活。

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

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

所以

x(1, 2) |> y(a, b, c)

等价于下面的代码:

newvar = x(1, 2);
y(newvar, a, b, c);

非常有用。假设我们要把一个原子(atom)转成首字母大写,即abc转换为Abc。在Elixir中没有对应的函数,但有把字符串转成首字母大写的函数。所以我们需要先将原子转换为字符串。用Erlang的实现代码如下:

capitalize_atom(X) ->
    list_to_atom(binary_to_list(capitalize_binary(list_to_binary(atom_to_list(X))))).

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

capitalize_atom(X) ->
    V1 = atom_to_list(X),
    V2 = list_to_binary(V1),
    V3 = capitalize_binary(V2),
    V4 = binary_to_list(V3),
    binary_to_atom(V4).

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

通过|>操作符,代码变更成了这样:

X |> atom_to_list |> list_to_binary |> capitalize_binary
  |> binary_to_list |> binary_to_atom

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

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

PrologDCG,所以

foo --> a, b, c.

扩展后的形式:

foo(In, Out) :- a(In, V1), b(V1, V2), c(V2, Out).

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

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

6. Elixirsigil

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

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

x = "a string"

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

Erlang

X = "abc"

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

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

X = r"...."

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

A = "Joe",
B = s"Hello #{A}".

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

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

Elixirsigil语法不太一样,如下:

%C{.....}

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

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

7. docstring

大爱docstring

但有个小建议,请把docstring放到函数定义里面

Elixir是这样:

@doc """
    ...
"""

def foo do
    ...
end

放到函数里面会是这样:

def foo do
  @doc """
  ...
  """
end

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

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

8. defmacro的引用和反引用

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

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

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

9. 额外的符号

像这样:

iex> lc x inlist [1, 2, 3], do: 2*x
[2, 4, 6]

而不是这样:

iex> lc x inlist [1, 2, 3] do: 2*x
** (SyntaxError) iex:3: syntax error before: do

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

10. 奇怪的空白符

iex> lc x inlist [1, 2, 3], do : 2*x
** (SyntaxError) iex:2: invalid token: : 2*x

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

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

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

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

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

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

js> a = 5;
5
js> f = function(x) { return x+a }; 
function (x){return x+a}
js> f(10)
15
js> a = 100
100
js> f(10)
110

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

Elixir呢?正确地处理了闭包:

iex> a = 5
5
iex> f = fn(x) -> x + a end
#Function<erl_eval.6.17052888>
iex> f.(10)
15
iex> a = 100
100
iex> f.(10)
15

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

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

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

shell里,如果可以这样写

a = 10;
f = fn(x) -> a + x end;

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

a = 10;
def f(x) do
   a + x
end

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

结束语

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

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

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

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

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

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