// 本文基於開源中國社群的譯文稿: 用Elixir
的一週
譯序
作為Erlang
之父Joe Armstrong,對Erlang VM
上的新語言Elixir
給出很精彩評論和思考。在『特定領域的專家的專業直覺』、『程式語言設計的三定律』、『管道運算子避免噁心程式碼』、『Elixir
的sigil
引出的程度語言如何定義/解釋字串』等等這些討論上,能強烈感受到Joe Armstrong老黑客風範。
自己理解粗淺,而本文討論是語言設計,且作為老一代黑客的作者計算機領域中那被我們現在不再要去理解使用的主題和思想(如Prolog
/DCG
、Lisp
/巨集、sigil
、不可變閉包、語言設計的相容性)真是信手拈來,翻譯中肯定會有不少不足和不對之處,譯文原始碼在GitHub
上《與Elixir相處的一週》,歡迎建議(提交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
然後開始學習……
沒多久我覺得就上手了。確實是個好貨!有趣的是Erlang
和Elixir
兩者在在底層一樣的,對於我來說『感覺』是一樣的。事實上也確實這樣,兩者都會被編譯成EVM
(Erlang Virtual Machine
)指令 —— 實際上EVM
這個叫法之前沒人用,都叫成Beam
,但為了和JVM
區分開,我覺得是時候開始用EVM
這個叫法了。
Erlang
和Elixir
為什麼有相同的『語意』?這得從虛擬機器底層談起。垃圾回收行為,不共享併發機制,底層的錯誤處理和程式碼載入機制都是一致的。當然這些肯定都是一致的:他們都執行在相同的VM
裡。這也是Scala
和Akka
區別於Erlang
的原因。Scala
和Akka
都執行在JVM
之上,垃圾回收和程式碼載入機制從根本上就不一樣。
你直接看到的Elixir
是完全不同的上層語法,源自Ruby
。看起來不那麼『可怕』語法和很多附加的甜點。
Erlang
的語法源自Prolog
,並受到Smalltalk
、CSP
和函數語言程式設計的影響很大。Elixir
則受到Erlang
和Ruby
的影響很大。從Erlang
借鑑了模式匹配(pattern matching
)、高階函式(higher order function
)以及整個程式(process
)和任其崩潰的(let it crash
)錯誤處理(error handling
)機制。從Ruby
借鑑了sigil
和快捷語法(shortcut syntax
)。當然也有自創的甜點,像|>
管道操作符(|> pipe operator
),讓人想到Prolog
的DCG
和Haskell
的Monad
(儘管相比要簡單不少,更類似於Unix
的管道操作符),還有巨集的引用和反引用操作符(macro quote and unquote operator
,對應的是Lisp
的反引號和逗號操作符)。
【譯註】:
sigil
是指在變數名中包含符號來表達資料型別或作用域,通常作為字首,如$foo
,其中$
就是個sigil
。 像本文中說的例子,sigil
也可以能對常量加上字母符號,r"abc"
,其中r
是sigil
,把字串轉成正規表示式。 詳見wikipedia
詞條sigil
)
DCG
(definite clause grammar
),確定性子句語法,表達語法的一種方式,可以用於自然語言或是形式化語言,比如像Prolog
這樣邏輯程式語言。基本的DCG
用於描述『是什麼』和『有什麼特性』(簡單的可以認為邏輯程式設計程式設計師要做的就是給出這些描述;剩下的事是邏輯引擎會根據描述的規則生成演算法,然後得出解來)。像這樣:
12 sentence --> noun_phrase, verb_phrase.noun_phrase --> det, noun.不展開說明了,對於沒有了解過
Prolog
/邏輯程式設計的同學意會一下就好,不用糾結了。詳見wikipedia
詞條Definite clause grammar
譯文使用英文術語本身,不翻譯成中文,有更好的辨識度。
Elixir
還提供一個新的下層AST
,取代了每個form
都是獨有表示的Erlang AST
,Elixir AST
有一個統一得多的表示,這使得超程式設計(meta-programming
)要簡單得多。
Elixir
的實現出奇的可靠,儘管有幾個地方和我預想的不一樣。字串插值(string interpolation
)的工作方式有時候不好使(字串插值是個很棒的想法) :
1 |
IO.puts "...#{x}..." |
對x
求值後把x
友好格式化的表示(a pretty-printed representation
)插入到字串中。但是隻對簡單形式的x
可行。
這點可以通過從Elixir
呼叫Erlang
的方式很簡單就能解決掉。
IO.puts "...#{pp(x)}..."
這樣就總是可行的。我只是把pp(x)
定義成
1 2 3 4 5 |
def pp(x) do :io_lib.format("~p", [x]) |> :lists.flatten |> :erlang.list_to_binary end |
用Erlang
則表述成:
1 2 |
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-assignment
,SSA
)的形式。儘管在順序結構中這是可以的,但在迴圈結構中,一定肯定以及確定不要這麼做。但這不是個問題,因為Elixir
木有迴圈,只有遞迴。實際上Elixir
不可能在迴圈中包含可變的變數(mutable variables
),因為這樣編譯出來的東西在下層的EVM
是支援不了的。順序結構的SSA
變數挺好的,EVM
知道如何對其做優化。但在迴圈結構不行,所以Elixir
沒有這麼做。關於這方面的優化甚至可以更往下挖到LLVM
彙編器(LLVM assembler
) —— 但又是另一個很長的故事先就此打住吧。
0. 程式語言設計的三定律
- 你做對的,無人為你提。
- 你做錯的,有人跟你急。
- 難於理解的,你必須一而再再而三地去給人解釋。
一些語言有的設計做得太好,結果大家都懶得去提,這些好的設計是正確的、是優雅的,是易於理解的。
對於錯誤的設計,你完了。你成了2B,如果好設計比壞設計多,你可能被原諒。你想在以後幹掉這些壞設計,卻因為向後相容性或者是有些SB已經用上所有那些壞設計寫上了1T行程式碼,結果你是改不了了。
而難以理解的部分才是真正無賴。你必須一而再再而三地解釋,直到你吐血,可還是有些人永遠不懂,你必須寫上百郵件和數千文字來一遍又一遍地地解釋這是什麼意思以及為什麼會如此。對於一個語言的設計者或作者來說,這是個痛苦的深淵。
下面我要說到的幾件事,我認為也會落入這三類情況中。
在我開始前,我先要說,Elixir
做對了灰常灰常多對的事情,而且遠遠多於錯的。
關於Elixir
有利的是,要改正它的錯誤還不算晚。這隻能在無數程式碼行被寫下和眾多程式設計師開始使用它之前才能做到 —— 所以解決這些問題的時日並不多了。
1. 在原始檔中沒有版本
XML
檔案總是這樣開始的:
1 |
<?xml version="1.0"?> |
這點非常好。讀取XML
檔案的第一行就像是聽到拉赫瑪尼諾夫的第三鋼琴協奏曲的第一小節(【譯註】:指其富有辨識度)。這是一個令人讚歎的經驗。讚美XML
設計師,願他們的名字得到榮光,給這幫夥計頒圖靈獎吧。
所有原始檔中加上語言的版本是必要的。為什麼呢?
早期的Erlang
沒有列表推導(list comprehension
)。如果我們對一個新版的Erlang
模組用一箇舊的Erlang
編譯器去編譯。新版的程式碼含有列表推導,但舊編譯器並不知道列表推導,所以舊編譯器會認為這是一個語法錯誤。
如果 版本3 Erlang
編譯器處理這樣開始的檔案:
1 |
-version(5, 0). |
則可以給出這樣提示資訊:
啊~~~~咦~~~~
哦,煩炸了,我只是版本3的編譯器,看不懂未來。
你剛剛給我一個版本5的程式,這說明我在地球上的壽命已過。
你將不得不殺掉我,把我解除安裝,然後安個版本5的新編譯器。曾經玉樹臨風的我現在沒了價值,我將不再存在。
再見吧,老朋友。
我感覺頭痛。我要休息一下……
這是資料設計的第一法則:
所有未來可能會改變的資料應該標記上版本號。
而 模組 是 資料。
2. fun
和def
不同
在寫Programming Erlang一書時Dave Thomas問函式為什麼不能輸入到shell
裡。
如果模組裡有這樣的程式碼:
1 2 |
fac(0) -> 1; fac(N) when N > 0 -> N * fac(N-1). |
不能直接複製到shell
裡執行,得到相同的結果。Dave問這是為什麼,並說這樣很傻。
在Lisp
等其它語言主中這做是沒問題的。Dave說了『這很會讓人很迷惑』類似這樣的話 —— 他說的對並且這確實讓人迷惑了。在論壇裡關於此的問題肯定有成百上千條。
我解釋這個問題已經無數遍了,從黑髮解釋到白髮,我現在頭髮真白了就是因為這個原因。
原因是Erlang
的一個bug
。
Erlang
的模組是一系列的FORM
。Erlang
shell
解析的是一系列EXPRESSION
。- 但
Erlang
的FORM
不是EXPRESSION
。
1 2 3 |
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
模組可以這麼寫
1 2 3 |
def triple(x) do 3 * x; end |
估計很多人都會直接從編輯器複製到shell
裡直接執行,然後收到的是出錯資訊:
1 2 |
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
。
1 |
Var = def fac(0) -> 1; fac(N) -> N*fac(N-1) end. |
這就定義了一個有副作用的表示式。由於是個表示式,可以在shell
中求值了,記住在shell
中只能對錶達式求值。
副作用指的是需要建立一個shell:fac/1
功能(就像在模組中定義的一樣)。
1 2 3 |
iex> double = fn(x) -> 2 * x end; iex> def double(x) do 2*x end; |
上面兩者應該是一致的,並且都是定義一個名為Shell.double
的函式。
做了這樣的修改,媽媽再也不用擔心我會白頭了。
3. 函式名稱中有個額外的點號
1 2 3 4 |
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. 傳送操作符
1 |
Process <- Message |
這是啥玩意?你知道從occam-pi
轉成Elixir
有多難麼。
這點讓你現在在失去occam-pi
社群路上。傳送操作符就應該是!
,像這樣:
1 |
Process ! Message |
接下來的一週,我的大腦會變成漿糊,我的神經網路要被重程式設計,這樣我才能『看到』<-
時才能反應成!
—— 這點不是在說如何我思考,而是指要重程式設計我更深植在脊柱裡無意識反應。傳送操作符已經不在我大腦裡,而是在我的脊柱裡。我的大腦想著『傳送一個訊息給一個程式』併傳送訊號給我的手指,我的脊柱馬上加上!
,接著大腦要回退刪除這個字元改成<-
。
這是一個語法問題。讓人愛恨交織的語法。如果10分制的評級標準,10代表『非常非常爛』,1代表『好吧,我可以適應』的話,這個問題我給3分。
這點會使Occam-pi
的程式設計師很難轉到Elixir
,什麼,只需要簡單地使用!
就能完成<-
的功能?這可真是出人意料啊。相信會有很多人受到鼓舞的。
5. 管道運算子
這是一個很好很好的東西並且很簡單就能掌握,以至於沒人會給你稱讚。這就是生活。
這是來自Prolog
語言的隱性基因(recessive gene
):monad
。 在Prolog
中的基因是顯而易見的, 但是在Erlang
中確實不明顯的(Prolog
的兒子)但是又在Elixir
(Prolog
的兒子的兒子)中重新顯現了。(【譯註】:隔代遺傳)
x |> y
意味著呼叫了x
然後獲取了x
的輸出並且將它作為y
的另外一個引數(第一個引數)。
所以
1 |
x(1, 2) |> y(a, b, c) |
等價於下面的程式碼:
1 2 |
newvar = x(1, 2); y(newvar, a, b, c); |
這非常有用。假設我們要把握的是把一個變數abc
轉換為Abc
。在Elixir
中沒有利用的函式但是還有一個功能,就是去控制一個字串。所以我們需要現將這個變數轉換為string
,在Erlang
中,我們可以這樣寫:
1 2 |
capitalize_atom(X) -> list_to_atom(binary_to_list(capitalize_binary(list_to_binary(atom_to_list(X))))). |
這樣的寫法太驚悚了。我們還可以寫成這樣:
1 2 3 4 5 6 |
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). |
但是,這更糟 —— 好惡心的程式碼。像這樣德性的程式碼我都不知道寫過多少次了!浪費我大把的青蔥歲月。
於是|>
來了:
1 2 |
X |> atom_to_list |> list_to_binary |> capitalize_binary |> binary_to_list |> binary_to_atom |
為什麼我認為|>
是隱性基因?
Erlang
從Prolog
中演化而來,而且Elixir
也繼承了Erlang
。
Prolog
有DCG
,所以
1 |
foo --> a, b, c. |
擴充套件後的形式:
1 |
foo(In, Out) :- a(In, V1), b(V1, V2), c(V2, Out). |
這基本上是同樣的想法。我們通過新加一個額外的隱藏引數把函式呼叫序列的輸入輸出串接起來了。這類似Haskell
中monad
用法,但做得很隱祕。
Prolog
有DCG
,Erlang
沒有,Elixir
有管道操作符!
6. Elixir
有sigil
sigil
很棒 —— 愛之。我們應該加到Erlang
裡。
字串是一個程式設計抽象。程式語言都有字串常量,通常使用雙引號包著的一串字元。就像這樣的一行程式碼:
1 |
x = "a string" |
編譯器會轉換成字串的內部表示,關聯上對應的語義。
在Erlang
裡
1 |
X = "abc" |
表示『X
是字元a, b, c
的ASCII
碼值對應的整數的列表』。
但也可以選擇成任何其它我們想要的含義。在Elixir
裡,x = "abc"
代表x
是一個UTF8
編碼二進位制(binary
)(【譯註】:binary
是EVM
的內建型別)。通過在雙引號前面加上r
可以改變字串含義成和Erlang
一樣:
1 |
X = r"...." |
當然也可以被定義成代表編譯過的正規表示式,也就是說和等價於X = re:compile("...")
—— 基於我們確定字串的含義,可以以不同的方式去解釋(interpret
)內容。可以寫上這樣的程式碼:
1 2 |
A = "Joe", B = s"Hello #{A}". |
B
值可以是Hello Joe
—— 這裡sigil
s
改變字串常量解釋行為,『替換變數的值並插入』。
Elixir
在這方面做得很好,定義了很多不同的sigil
。
Elixir
的sigil
語法不太一樣,如下:
1 |
%C{.....} |
C
是單個字元(【譯註】:Erlang
中大寫開頭的是變數不是常量,C
是單個字元,表示可以是a
、b
、$
),後面跟著一對{}
或[]
。
sigil
很棒。Erlang
本可以在15年前就有這個功能,而現在也可以引入,並且不會帶來向後相容的問題。
7. docstring
大愛docstring
。
但有個小意見。請把docstring
放到函式定義裡面。
Elixir
是這樣:
1 2 3 4 5 6 7 |
@doc """ ... """ def foo do ... end |
放到函式裡面會是這樣:
1 2 3 4 5 |
def foo do @doc """ ... """ end |
否則成了『沒有歸屬的註釋』(detached comment
):當你編輯程式時,就可能出這樣的問題。註釋會與它要註釋函式脫離開。
在Erlang
裡,沒有辦法確定註釋的是下一個函式還是上一個函式,或是模組。如果註釋的物件是函式那就應該放到函式裡面而不是外面。
8. defmacro
引用
愛之。在解析轉換這個正確階段所做的正確的事。這讓可以讓人舒舒服服得不用去知道抽象語法了。引用(quote
)和反引用(unquote
)為你把魔法都做好了。
這就是那種是對的事 —— 非常棒卻真真兒難於解釋。就像Haskell
的monad
—— 啊哈,monad
真很容易解釋,難怪有上千篇文章來解釋它有多簡單。
Elixir
巨集真是簡單 —— 引用(quote
)對應Lisp
的反引號(quasiquote
),反引用(unquote
)對應Lisp
的列表逗號操作符(list comma operator
) —— 這就我說的簡單 :-)
9. 額外的符號
像這樣:
1 2 |
iex> lc x inlist [1, 2, 3], do: 2*x [2, 4, 6] |
而不是這樣:
1 2 |
iex> lc x inlist [1, 2, 3] do: 2*x ** (SyntaxError) iex:3: syntax error before: do |
列表後面額外的冒號讓人迷惑。
10. 奇怪的空白符
1 2 |
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
在這點上非常錯誤。給一個JavaScript
和Elixir
的例子,方便看到這點上的差異:
1 2 3 4 5 6 7 8 9 10 |
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
呢? 閉包的處理是對的:
1 2 3 4 5 6 7 8 9 10 |
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
中資料正是不可變的) —— 而不是可變資料的指標。如果閉包裡有指向可變資料的指標,後面修改了資料就會破壞閉包的一致性。這樣的結果就是不能把程式並行化,甚至順序執行的程式碼也會詭異的錯誤。
在傳統語言裡要建立合適的閉包的代價會很高,因為捕獲環境裡的所有變數都需要做深拷貝,但Erlang
和Elixir
不用這樣,資料都是不可變的。你所要做的就是引用需要的資料。內部實現是通過指標引用資料(指標對程式設計師是不可見的),並且不再有指標引用的資料會被垃圾回收掉。
在shell
中可以有閉包,但不能寫到模組裡。
在shell
裡,如果可以這樣寫
1 2 |
a = 10; f = fn(x) -> a + x end; |
為什麼不能在模組裡這樣寫呢?
1 2 3 4 |
a = 10; def f(x) do a + x end |
這個問題完全是可以解決的,我在erlang2
語言實驗並解決了。
最後
這就是我與Elixir
的相處一週,非常興奮的一週。
Elixir
沒有令人生畏的語法,融合了Ruby
和Erlang
優秀的特性。它不是Erlang
也不是Ruby
,有自己創新的想法。
這是門新興的語言,但在語言的開發的同時介紹的書也同步在寫了。第一本介紹Erlang
的書在Erlang
被發明後7年才出現,而暢銷書更是在14年後才出現。用21年的時間去等一本真正的介紹書籍實在是太長了。
Dave很喜歡Elixir
,我也覺得很酷,我想我們會在使用過程中找到更多樂趣的。
像是WhatsApp
這個應用和全世界一半手機網路的關鍵部分都是搭建在Erlang
之上。當技術變得更加親和,當新一批熱衷者進入陣營,讓我現在懷著非常欣喜的心情關注著後續要發生的變化。
這是篇即興的文章。也許會有些不妥之處,歡迎大家指正。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式