Julia ---- 爲Julia做一下辯解

我寫這篇文章的主要目的就是爲了給我喜歡的Julia語言一辯,並且指出人們對Julia語言的幾個常見的誤區。

預警:文章非常長,所以需要希望入坑的人有耐心閱讀

文章內容

1.常見誤區

2.重新認識Julia

3.結尾

正文

即使我不說大家也知道,在知乎的Julia的這個話題下的相關話題和討論寥寥無幾,不僅如此,還充斥着很多大量的負面評論,除了少量非常客觀的分析是在談論語言本身以外(這些人在英文的julialang論壇上也非常活躍,所以我還是知道這幾個人的),其他的很多都是一些感嘆式的評論(有說好的也有說不好的)。更加糟糕的是,因爲本來了解的人就很少,所以一個回答下的答案往往跨越了數年之久,很多答案早就過時至極(譬如那些來自2012-2014年回答,那個時候Julia纔剛剛發行了2年),很多問題早就解決了(當然還有一些問題還沒有解決,但哪個編程語言是完全沒有問題呢?)。

每個人暢所欲言發表自己的感想,這本來也就沒有什麼問題,因爲編程語言就是一種工具,要是這個工具對自己當前的事情幫助不大,自然也就相性不好,而且Julia本來目標就很明確,主要用於做數值計算,一開始也就沒打算取悅所有的程序員。因此有的人說好有的人說不好也就很正常,畢竟這個世界很大,每個人領域不同,需求也就不同,也就喜好不同。但是沒有根據地diss一個語言,以偏概全,卻是不可以的(如果一個人不瞭解一個東西的話,就應該使用商討的語氣,而不是毫無根據地作出一個錯誤的斷言)。我收集了對Julia的幾種常見的觀點(有對的有不對的),然後一一進行分析。


一.Julia的名字

這個名字本來就毫無意義,但是Julia下的回答出現了大量毫無意義的這種灌水的回答,這種沒營養的東西我就不多說了。而且用人名命名的語言也不是沒有先例(例如Haskell,紀念數學家Curry Haskell)。

二.Julia的語法

我發現,當我們談論起編程語言的時候,編程語言的語法(譬如關於Python縮進,Go語言花括號這種爭論)。而且都是在這種細枝末節上,就和大頭端和小頭端的爭論一樣無止無休(還有類似的爭論是用花括號還是用縮進還是用end語句塊)。我不是說編程語言的語法不重要,我想說的是,編程語言的語法是要和它的語義以及編程範式相契合的(一定程度上是爲了方便),比如說Haskell高階函數用的多,所以大量使用currying與偏應用,可以節約很多括號使用。這個方法雖好,但是它在別的地方就會不方便了。同樣的是Lisp中的一大堆括號,寫起來很繁瑣,可是這也成就了lisp強大的宏的特性。

本質上,Julia的語法是和Ruby一致的,而不是Matlab(當然它們也很像)。Julia的語法實際上完全就是Ruby的框架,除了個別關鍵字不一樣(class->struct,define->function,還有匿名函數的lambda)。do-end,begin-end,一切表達式都是值...都是和Ruby一致的(知道Exlir語言的人也比較一下,其實Julia和Exlir有很多相似處)。所以Julia的語法的內核是Ruby,只不過表面上披着Matlab的外衣。

具體說來,很多人都說Julia的語法非常花裏胡哨。雖然Julia確實借鑑了很多編程語言,但是我個人認爲Julia的語法是很一致的,看起來花,其實是因爲用了很多符號的緣故,所以沒學過的人看不懂(或者說其實大家有符號恐懼症),例如我選了一段Julia中Base庫中的代碼:

function eltype(::Type{<:AbstractDict{K,V}}) where {K,V}
    if @isdefined(K)
        if @isdefined(V)
            return Pair{K,V}
        else
            return Pair{K}
        end
    elseif @isdefined(V)
        return Pair{k,V} where k
    else
        return Pair
    end
end

有一大堆奇怪的東西比如說@,{},<:,where(其實還有更多類似的符號),看起來好似天書,但其實我發現別的編程語言貌似也不會更加簡單,例如Java

class Unsound9 {
static class Type<A> {
class Constraint<B extends A> extends Type<B> {}
<B> Constraint<? super B> bad() { return null; }
<B> A coerce(B b) {
return pair(this.<B>bad(), b).value;
}
}
static class Sum<T> {
Type<T> type;
T value;
Sum(Type<T> t, T v) { type = t; value = v; }
}
static <T> Sum<T> pair(Type<T> type, T value) {
return new Sum<T>(type, value);
}
static <T,U> U coerce(T t) {
Type<U> type = new Type<U>();
return type.<T>coerce(t);
}
public static void main(String[] args) {
String zero = Unsound9.<Integer,String>coerce(0);
}
}

我覺得問題的根源在於Julia的編程範式很豐富,而且Julia的類型系統(往往一般寫程序的人不太注意到)和其他的主流編程語言有一些不一樣,在很多地方都用了非常學術的符號來表示其他語言用文字表達的東西(比如<:和上面的extend差不多一樣,{}就是Java中的<>表示泛型),但是一般人看起來就是一大堆符號堆起來的程序,我總結了一些“花裏胡哨”的語法放在這裏:

1.{}用來表示類型的參數化(泛型),比如說Vector{Int}表示一個整數的向量,在上面的Java(以及C++)中用<>表示,例如List<Int>

2.[]表示數組

3.()可以表示元組(和Haskell,Python一致),如(1,2,3),也可以表示函數調用,如sum([1,2,3])

3.A<:B表示類型子類化(A是B的子類,a::B表示a的類型是B)

4.where引導類型變量,例如Array{T,3} where T表示一個3維數組,數組元素的類型爲某個待定的T

5.一個點 . ,如A.a,表示A有個屬性爲a;. 還可以用來表示broadcast,可以理解爲就是map,例如說,你有一個矩陣A,sin.(A)表示將A中元素分別求正弦後得到的新矩陣,等價於map(sin,A)

6.@表示宏調用(Julia本來也可以不用@標記宏,但是爲了大家方便閱讀,最後還是強制要求用@標記)

對比一下上表,大多數人(我指的是那些做數據科學的,不是程序員)很多應該是完全沒有概念。大家也注意到有很多符號是和類型系統相關的,Julia作爲一個動態語言,其類型系統非常豐富(而偏偏很多人又不關心,或者沒有太在意這件事情),而且Julia的類型系統又有些獨特(雖然看起來和OOP一樣,但是和麪向對象的系統還是有不一樣的)。其實類型系統的相關概念上不難理解,很快也能上手,問題是很多人一開始沒有接觸到,所以一下子一大堆新概念上來就有些頭暈了。所以其實Java的那段代碼一樣很複雜,一樣用了泛型,繼承,wildtype,coerce,翻譯成上面的數學符號幾乎就是一樣的了。

只不過很多類型系統的這些特性,在傳統編程語言中,包括Python,是非常排斥這些東西的,所以這些東西一向就沒有深入人心,也有一些人儘量避免使用。但是這些在Julia中熟練運用類型系統完全是常態。我在後面也會提到爲什麼Julia和別的語言很不一樣,其中一個就是Julia的類型系統是很獨特的。

三.Julia的性能

這個時候官網上的Benchmark的圖片總是會被拿出來批鬥一番。其實人家都說的很清楚了,這個測試測的就是各個編譯器的優化能力,不是最優化的算法。在這個意義上這個Benchmark還是比較公正的。

我發現很多人一談論其性能來,就好像性能是完全不重要的東西。另外一方面,即使人們很關心性能,但是大家往往都覺得這件事情實在是一件非常簡單的事情我們只要能夠XX(舉例而言XX=線程,好的庫)就可以變快。

爲什麼會存在這些看法?很大一部分原因是因爲大家普遍使用的動態編程語言(Ruby,Python)好像都還很快,所以爲什麼要去追求性能?但真實情況是性能始終很重要,只不過那個來優化的人不是庫的使用者。所有這些動態編程語言,都在背地中做了大量的優化(往往是某個大公司在背後撐腰),例如Ruby換上了虛擬機,Javascript的V8引擎,都是很多編譯器設計者投入大量時間進行的優化,更加不要說爲各個庫寫C/C++擴展的開發者了。所以性能從來就不是不重要,只不過大部分人已經享受了這種好處罷了而已。

即使認識到了編程語言很重要,但是很多人沒有意識到編程語言優化提升性能的複雜性所在。程序優化是極其複雜的問題,不是簡單的多線程或者多進程就能解決的問題(而且很多語言都還沒有正確支持這些機制,爲什麼還有人覺得Python的多線程只不過是拿掉一個GIL這麼簡單,而且拿掉GIL也還沒有解決所有問題)。而且不同的領域有着不同優化需求,有的問題是IO繁重的任務,有的是計算密集的任務。像Julia是面向科學計算的,主要問題是計算密集,必須用真的多核多線程加速,而不是一個單核多線程(單核多線程可以加速IO繁重的程序,因爲在等待IO的時候可以切換線程做一些計算,但是對計算密集型幫助不大,甚至有反作用,在這裏我們先不考慮超線程)。編譯器優化程序要求編程語言的各個組成部分互相配合,一個看似不起眼的特性可能就會對優化造成阻礙。優化這件事情要編程語言和程序員共同參與,不是說一個人費心費力的到處標註了類型,程序自然就會變快了,要是編譯器不能自己推導出類型,加上的這些類型標註也毫無意義。

另外也很多人不相信Julia的性能有官網上說的這麼好,一個動態語言怎麼會這麼快(其實這毫無道理,C++還有一個基於LLVM的REPL,這還不一樣是動態了)。這都什麼年代了,怎麼還有人覺得C(或者C++)語言性能就一定天下第一(寫了Blas等線性代數庫的Fortan都沒有出來爭論呢)。OK,即使C確實是天下第一,Julia只要能趕上95%的速度就已經足夠了(其實性能這種東西非常取決於各個編譯器的優化,對於一個簡單的斐波那契數列,不同編譯器產生的優化也水平不同,所以說Julia Benchmark纔要這麼設計)。

性能也不是要無節制的追求,例如錯誤處理,像C語言裏面一般通過返回-1或者空指針等值表示出錯了,但Julia裏面有的地方拋出一個錯誤,要用try來catch,這種方法當然就不如C快(Julia有很多IO函數都是這麼做的),

最後,不要因爲自己的情況就說這個東西沒有”實際用途“,或者科學生態圈早就已經飽和這些話。現在還有researcher在想辦法寫各種高性能的數值計算的代碼,要是早就飽和了,那這些搞研究的人豈不是可以全部下崗了?

四.Julia生態圈

確實沒有Python和C++大,必須承認。但是就像是我之前已經說過的一樣,對某些人的領域生態圈匱乏,但是對別的人就不一定是這樣。例如JuMP.jl,DifferentialEquations.jl,Flux.jl等等。生態圈沒有Python成熟也沒有Python多(或者說Matlab),但是有這麼幾個庫苟活整個語言,起碼也就還不至於死掉。

而且說實在的,其實Julia整個生態圈也不算太小,很多功能已經有了,只不過都很難發現而已,有的時候要實現的功能要通過調用幾個很小的庫串聯在一起實現(Julia的庫很多都很小,所以star分散了以後也很少),很多時候庫的作者把庫寫的很通用,所以對於一個具體問題,很多時候很難想到要這麼寫罷了。。。

由於精力限制,現在社區的主要方向是探索有自己特色的庫,而不是原樣複製別的地方庫,相似的功能,如果沒有性能上的要求,完全就可以Pycall調用Python實現.(或者其它編程語言),例如說畫圖庫,我看有的人也還在用PyPlot畫圖。而很多人到Julia裏做的第一件事就是找原來自己用過的庫的代替品。。。當然就不怎麼找的到了。這其實對於目前的使用者而言也是一個門檻。但其實發展編程語言獨特生態圈這件事情是很重要的,Julia目前要做的事是設計更多嶄新的有特色的庫來證明自己,而非成爲Python或者Matlab的複製品。

五.Julia,R,Matlab,Python的關係

很多人都以爲Julia發明來的目的是爲了取代另外三個語言,當然其實也確實有這麼一點意味在其中,但是事情的真相是,Julia主要是爲了用來減輕Fortan/C++開發的負擔。

原因很簡單,Julia從設計之初就是爲了減輕那些做科學計算的人碼代碼的負擔(寫Fortan/C++說的輕鬆,實際上是頭大至極),所以主要目標就是面向那些做數值計算的開發者,直到現在也還是在大力吸引開發者(你完全可以理解爲簡化的C++,方便開發)。而大多數Python,Matlab,R語言用戶都不是開發者,吸引到Julia社區根本沒有任何實質的好處,又不能提供庫而且開源社區又不能收費,所以一開始假定Julia是挖別人的牆角是不對的,要挖牆角也是挖開發者的牆角,和一般人關係不大。Julia剛出吸引了很多數值計算者的注意,所以2012-2014短短兩年,即使整個語言都還要自己build的時候,就有開發者積極開發庫了(2015年就有了JuMP,這才3年),足以說明了Julia着實解決了當時某些人(起碼是某些researcher)的痛點。而且後來即使一些開發者離開了Julia,但是Julia的一些設計他們借鑑到了在別的數值計算開源項目(我記不清楚了是Pytorch還是Tensorflow了)。

所以Julia(目前爲止)都不曾想取代別的語言,相反人家還積極給R,Python做接口,這樣別的使用者也就能調用Julia的庫。當然如果在做相關research的時候,做了一個很好的庫(例如Flux.jl),有着不少神奇的功能,然後還吸引了不少用戶,那就不能怪別人碰瓷了。

六:文檔問題

Julia有很多庫是researcher做研究的代碼,研究者的代碼,自然就難用於生產環境,這些研究者自己用用,不寫文檔也就可以諒解。而且已經說了目前社區是以開發者爲主的,文檔寫的點到爲止,可以繼續開發就好了。要是期望像Python一樣手把手來教小白用戶,除非是一些常用的庫(例如微分方程庫,畫圖庫),目前是不可能的。就我現在看到的各種情況來說,很多庫的文檔有了很大的進步,很多包隨着不斷完善也都有越來越多的教程(也有的配有notebook)

七.啓動太慢

這個沒辦法,編譯器用的LLVM,所以每次要JIT都要花費時間(由於Julia採用方法特化機制,julia jit的量非常大),現在我們也只能忍耐,官方好歹出了precompile可以節約大家的一些時間。

總之我就寫了這麼幾點,主要是寫了一下我在知乎上看到了一些東西,然後我逐條反駁一下。

接下來我想要簡要的介紹一下Julia(重新認識Julia),我想說明的是爲什麼Julia非常獨特。爲什麼在二十一世紀的時候我們仍舊有必要發明一門新的編程語言。爲什麼在數值計算這一個古老的領域(要知道當時計算機發明出來很大一部分原因是用來做計算的)Julia仍然能夠佔有一席之地。


對於Julia最大的誤解在於認爲Julia只不過是簡單的剽竊了別的語言的創意,所以只不過是衆多語言的大雜燴罷了,這個認識是不準確的(誠然,官方自己也要背一些鍋,因爲那篇why we create Julia裏面的內容)。

任何初學者在用Julia寫了幾個簡單的程序以後,都很容易得出結論,這不過又是一個XX(XX=Matlab,Python,C++)等。例如:

if x < y
    println("x is less than y")
elseif x > y
    println("x is greater than y")
else
    println("x is equal to y")
end

看起來和Matlab完全就沒有區別(除了沒有分號以外)。

就這些常見的程序構造而言(控制流,數值計算),其實大部分語言除了表面的語法區別,沒有實質上的不同,而Julia真正有趣的地方,在於它有趣的地方是一般人無法注意到的地方,觀察這個式子:

1+1.5 #Int+Float
1+1 #Int+Int
1.5+1.5 #Float+Float

看起來是一個很普通的式子,其實不是,因爲實際上涉及到了多重派發機制(很多人應該都知道),加法是普通的函數(只不過是箇中綴的),在三組不同的類型上各自調用了不同的加法(所以對應着不同的機器碼)。可是,在大多數編程語言中我們也能觀察到類似的現象,因爲往往基本數字類型和加號被編譯器特殊處理,所以這些基本運算也能表現出多態性,另外一個例子就是Julia的線性代數的庫:

A = [1,2,3]
B = [3,4,5]
A*B'

只不過是兩個數組的點乘,看起來平淡無奇,但是有幾點值得我們注意

  1. A,B是真的一維數組,而不是一個n*1的或者1*n的二維數組
  2. B的轉置是lazy的,而且返回一個新的類型,這個類型不和B相同,因此B'!=B
  3. B必須轉置,A*B是錯的(因爲維數不匹配)
julia> B'
1×3 LinearAlgebra.Adjoint{Int64,Array{Int64,1}}:
 3  4  5

julia> B==B'
false

用過Matlab的人應該看的出區別,爲什麼要大費周章的設計這些複雜的類型?做出的答案反正不也都一樣嗎(而且從數學上說,一維的向量同構於一個1*n或者n*1矩陣)

答案很簡單,一致性與優化。

如果用一個二維矩陣來表示一維向量(行向量與列向量),那麼做點乘的時候就要返回1*1的數組(它僅僅同構於一個數,但是它還是數組),從計算機角度看,他們的類型是不一樣的,要是我們返回一個數,那麼函數就會返回多個類型,這對性能會產生影響(除非我們在編譯器中hack,然後用一些奇妙的優化,但是這種方法是不可持續的),同樣的,向量的轉置應該區分於向量本身(特別要注意的情況是1*1的數組,因爲如果我們用二維矩陣來表示就會得到a'==a,這再次表明不能簡單地用二維矩陣來表示一維向量),所以我們才需要一個Adjoint type。

對於使用者而已,似乎完全感受不到在Julia中做線性代數有什麼本質區別,就是一些加減乘除罷了(對於Matlab使用者應該很熟悉),可是Julia在背後做了很多的工作(花了幾年時間討論整個線性代數系統的語義)確保整個系統是一致的符合直覺的,同樣的還有對IEEE浮點數的支持,考慮了各種corner case(例如sin(Inf)應該是多少,支持浮點數標準不是一件簡單的事情)。

所以僅僅從表面上觀察這個語言平淡無奇,因爲很多類似功能別的語言都有呀,要是說優化,很多語言不也可以做嗎(當然不方便而已)?可是這些功能很多是用原生庫自己實現的(也有部分線性代數的庫函數包裝了Blas),而且它們用到了很多高級的語言特性,只不過被巧妙地隱藏起來了。實質上內核裏Julia是很不一樣的。

 


 

首先讓我們來看看Julialang的文檔中對Julia的簡介:

  • Free and open source (MIT licensed)
  • User-defined types are as fast and compact as built-ins
  • No need to vectorize code for performance; devectorized code is fast
  • Designed for parallelism and distributed computation
  • Lightweight "green" threading (coroutines)
  • Unobtrusive yet powerful type system
  • Elegant and extensible conversions and promotions for numeric and other types
  • Efficient support for Unicode, including but not limited to UTF-8
  • Call C functions directly (no wrappers or special APIs needed)
  • Powerful shell-like capabilities for managing other processes
  • Lisp-like macros and other metaprogramming facilities

雖然這裏的特性很多,實際上部分特性其實還沒有完全實現(比如說線程,一直都是experiment的),不管怎麼樣,讓我們逐條分析一下。

不是CS的人看了以後覺得有很多花裏胡哨的術語(看起來就像是推銷的人說了一大堆名詞),先說說簡單的:

一.Efficient support for Unicode, including but not limited to UTF-8

對Unicode的支持,注意這不僅僅是簡單的可以用Unicode字符串,變量和函數名都是可以用Unicode的,而且很多專有的數學符號,例如克羅內克積都是可以中置使用的,還支持一定的Latex符號。也有另外一些字符串庫來實現類似的功能(要是不喜歡你可以直接調用perl,因爲Julia底層裏面包括了perl的一個字符串處理庫。。)

二.Powerful shell-like capabilities for managing other processes

這個就是一個方便用的shell,可以直接用run(process) 來執行命令行中的命令(用julia call linux的一些命令還是很不錯的)

三.Lisp-like macros and other metaprogramming facilities

繼承於Lisp的宏,宏是生成代碼的函數,Julia有end的重大原因就是爲了支持宏(很多動態語言中沒有宏,這是讓Julia創始人困惑的事情),有了宏,Julia可以直接用@time exp測時,以及用@assert做斷言(有的東西總是需要宏來做的,例如說對表達式測時間,如果用time(exp)是沒有用的,因爲exp在調用前就已經求值完了,不用宏就只能用包裝在函數裏面或者特別提供關鍵字)。

另外Julia對於宏也有自己的創造,Julia中有生成函數(一種特殊函數),本質上是一個返回表達式的函數,然後表達式會插入函數所在處執行了。這個東西創造出來是爲了利用LLVM的多層次編譯優勢(運行時動態生成優化代碼),主要用於優化(而且被大量使用)例如在文檔中的例子:

julia> @generated function bar(x)
           if x <: Integer
               return :(x ^ 2)
           else
               return :(x)
           end
       end
bar (generic function with 1 method)

julia> bar(4)
16

julia> bar("baz")
"baz"

這個bar函數根據輸入的類型不同會產生不同的函數(都是編譯的),如果是整數,就會求其平方,否則直接返回x(實際的例子比這個複雜很多,所以就不好舉例子了)

簡而言之,宏是一個很方便的東西,正確使用可以幫助用戶少寫很多代碼(也可以做一些優化,不過貌似不多)

四.Unobtrusive yet powerful type system

Elegant and extensible conversions and promotions for numeric and other types

User-defined types are as fast and compact as built-ins

這三點都是在說類型系統,我很想強調一下這兩點,因爲Julia的類型系統實在是很有趣。

Julia的類型系統是導致很多人困惑的根源(因爲我也曾經困惑過)

首先我們要說一下什麼是類型系統,這件事情沒有這麼簡單。靜態類型系統和動態類型系統的區別完全沒有我們想的這麼簡單。

一個靜態類型系統是按照一定規則,給一個程序中的表達式及其子表達式賦予類型的規則。注意這裏的要點是,我們必須給表達式賦予類型,例如:

1+1

1+1,作爲一個表達式的類型是Int,從靜態類型系統的角度看,這不是因爲1+1等於2,而是因爲1是Int,+是Int,Int->Int的函數,所以1+1整個表達式的類型是2.

那麼什麼是類型呢,嚴格地說類型可以是任何東西,完全不限,類型系統也只要我們用某種方式賦予表達式類型,這個類型和方式可以非常的荒謬,例如說,把所有的表達式都標記爲一個叫做Any的類型--這就是動態類型語言,又叫Uni-type(單類型)系統。當然出於實用考慮,類型系統要設計的好一些。

那麼等等,動態類型語言明明就有很多類型,1不就是整數類型嗎?"aaa"不就是字符串類型嗎?這個類型不是我們說的靜態類型語言中的類型,嚴格的說這個類型要叫做標記(tag),他們是標記在值上的,只有值有類型,而表達式沒有類型。

舉幾個例子,衆所周知C語言中有類型,這個類型就是靜態類型,所以我們纔會說變量有類型,因爲變量也要算表達式一部分,而C語言沒有tag(或者說只有一種tag,叫做Any),因爲在運行時C語言擦除了類型(你不能在運行時問一個東西的標記是多少,因爲編譯器早就把這個信息丟棄了),其他編程語言:

Java:有靜態類型也有標記(因爲有一些動態行爲需要JVM)

Python:沒有靜態類型而只有標記

那麼很多人會問這個標記和靜態類型有什麼關係。沒有必然聯繫,但是很多類型系統設計的時候都設計成了相關的。靜態類型推出的類型可以與實際值的類型不符合(但是這樣是不好的)。最好的例子是面向對象中的繼承,A是B的子類,所以一個類型爲B的變量,實際上可以在運行時標記爲B(A!=B)。

所有不能被賦予類型的程序都被判定爲類型錯誤的。靜態類型系統和編譯與否無關,也和是否有靜態類型錯誤關係不大,因爲這個錯誤是可以動態拋出的,插入一個assert就好。重點在於,如果一個程序被靜態類型系統判定爲類型錯誤的話,那麼它就一定不能執行(與我們想的相反,一個靜態類型系統滿足的最低要求是不能阻止那些本可以運行的程序被判定爲類型錯誤,也就是說不要錯判可以執行的程序)。例如:

function f(x::Int,y::Float)
  if rand()>0
     return x
  else
     return y
  end
end

這個函數會根據不同的隨機數返回不同類型的x,y,在C語言中,如果沒有用一個聯合體包裝起來,那麼等價語義的程序是非法的。但是大家很容易看出,這個程序在動態語言中可以執行,因此,假設我們給動態語言強行配上一個C語言的類型系統,那麼上述程序就會被判定爲非法的(但是實際可以執行),不過你也可能會說,我們完全可以用更好的類型系統,比C語言的會更加強力,這樣就可以允許這樣的程序存在了。實際上Java就是一個很好的例子,正如前面所說,Java同時有靜態類型系統和tag系統,但是類型系統推導出的類型往往比程序的tag更加粗(比如說繼承),這樣的系統是不準確的,但並不違反靜態類型系統的定義。例如這個代碼(來自於Java is unsound)

List<Integer> ints = Arrays.asList(1);
List raw = ints;
List<String> strs = raw;
String one = strs.get(0);

(這個程序現在應該已經不合法了)把一個整數賦值給了一個字符串,注意,這個程序是靜態類型正確但是tag錯誤,因爲根據類型系統的規則,類型系統允許這樣的程序寫出,只不過實際執行的時候,一個整數無法給一字符串賦值(內存等原因),所以產生了一個運行錯誤。具體的問題往往很複雜,很多靜態語言的語義都依賴於類型系統,而運行時是無類型的,所以談論什麼程序 "實際上"可執行是很難的。

這和Julia有什麼關係?

很多人不理解爲什麼Julia要設計爲一個動態類型語言,這一點相對好理解,就是方便,而且不是一邊的方便,數值計算往往有個做測試用的REPL,這個時候支持動態類型是很有必要的(包括測試各種代碼,做一些簡單的計算,用過Matlab和Python的人都知道)。但是更多人應該不明白的是爲什麼一個高性能的編程語言要設計成動態類型的,而且據傳言還能match C語言的速度。

看了上面的討論我們就知道,靜態類型其實沒有我們想的這麼好(但不是不好,很多安全的程序還是要用一個靜態類型系統來check的),本來這個東西就是發明來證明數學公理系統的正確性的,它不一定和程序性能有直接關聯。只不過是大多數採用了靜態類型系統的(C家族)語言恰好又支持了很多接近機器底層的操作(內存分配,內存佈局可控制聲明,類型與這些東西配合的很好),但是還有別的編程語言也採用了靜態類型系統,性能不一定就見的好(例如Haskell,雖然優化很棒,但是就平均比起C來,寫出性能好的Haskell還是不簡單的,還有Java,Java有很多動態行爲實際上和優化是背道而馳的)。

Julia是動態語言,因爲Julia沒有一套規則來給每個表達式一個類型(注意,是每個表達式,僅僅標記部分表達式是不算的),而且Julia有一個tag系統。但是這並不能說明任何絕對事情。實際上,Julia爲了類型系統更加強力的表達能力(或者說tag系統),放棄了成爲一個靜態類型系統(我要說一下,一個靜態類型系統可表達的類型越豐富,那麼做起類型檢查就越困難,特別是如果不強制大家每個變量都聲明類型的話,編譯器就要求解一些類型限制來確定類型,例如說Haskell的類型系統就是很巧妙的一個可以任意推導類型的系統)。取而代之的是,Julia可以用啓發式算法給部分表達式(起碼所有寫的像C語言的簡單類型的表達式)推出一個類型(這叫做type inference),所以很多人會奇怪爲什麼Julia不是一個靜態類型系統,因爲好像:

julia> @code_typed sum([1,2,3])
CodeInfo(
1 ── %1  = Base.identity::typeof(identity)
│    %2  = Base.add_sum::typeof(Base.add_sum)
│    %3  = Base.arraysize(a, 1)::Int64
│    %4  = Base.slt_int(%3, 0)::Bool
│    %5  = Base.ifelse(%4, 0, %3)::Int64
│    %6  = Base.sub_int(%5, 0)::Int64
│    %7  = (%6 === 0)::Bool
└───       goto #3 if not %7
2 ──       goto #11
3 ── %10 = (%6 === 1)::Bool
└───       goto #5 if not %10
4 ── %12 = Base.arrayref(false, a, 1)::Int64
└───       goto #11
5 ── %14 = Base.slt_int(%6, 16)::Bool
└───       goto #10 if not %14
6 ── %16 = Base.arrayref(false, a, 1)::Int64
│    %17 = Base.arrayref(false, a, 2)::Int64
└─── %18 = Base.add_int(%16, %17)::Int64
7 ┄─ %19 = φ (#6 => 2, #8 => %25)::Int64
│    %20 = φ (#6 => %18, #8 => %27)::Int64
│    %21 = Base.slt_int(%5, 0)::Bool
│    %22 = Base.ifelse(%21, 0, %5)::Int64
│    %23 = Base.slt_int(%19, %22)::Bool
└───       goto #9 if not %23
8 ── %25 = Base.add_int(%19, 1)::Int64
│    %26 = Base.arrayref(false, a, %25)::Int64
│    %27 = Base.add_int(%20, %26)::Int64
#,,,我截斷了輸出

編譯器確實給了一些類型在表達式後面,編譯器不做類型檢查,而僅僅用類型信息進行優化,即使編譯器知道類型肯定是錯誤的,它也不會拋出錯誤,而會推到運行時拋錯,例如:

#f(x) = 1::Float64 把一個整數斷言爲浮點數,這是錯的
code_typed f(2)
CodeInfo(
1 ─     Core.typeassert(1, Main.Float64)::Union{}
│       π (1, Union{})
└──     $(Expr(:unreachable))::Union{}
) => Union{}

編譯器推出了整個程序是絕對錯誤的,因此是Unreachable的,但是它沒有出一個編譯錯誤,而是運行時纔出錯。

Julia類型系統的能力完全就不弱於別的編程語言,這個類型系統的光芒被其動態類型的表象掩蓋,正如Julia創始人所描述的一樣,要讓用戶感覺到整個語言還是動態的,但在內部又可以做一些靜態的優化。Julia是動態類型中的異類,因爲它支持一套非常獨特的類型系統,以適合數值計算中的多層次的抽象,具體說來有:

1.用struct和primitive自定義類型(如果結構體的每個域都是具體類型,那麼這個結構體可以和C的一一對應)

2.基於子類化和抽象類型的類型樹(和1相關)

3.泛型(參數化類型),除了類型以外,部分值是可以用來參數化類型的(比如說數組的維數是用整數參數化的,這是很不同尋常的)

4.Union聯合體,聯合體僅僅爲類型,不能用來構造,編譯器在推導類型的時候可以產生一個Union,比如說返回不同類型的值的時候(你肯定會奇怪問什麼不像C語言一樣弄成一個可以構造出來的,這是有原因的,後面再說)

5.多重派發——實現ad hoc多態

6.類型本身也爲值,可以用於做計算

所有不能用於直接構造的值都叫做抽象類型,反之叫做具體類型。和麪向對象系統不一樣,面向對象的抽象類型是可以構造的,例:Wolf是Animal的子類,兩者都是可以構造出來的(也有一些特殊情況,但是理論上是可以的),因此在Julia中,值只有具體類型,不可能有抽象類型,只有抽象類型能被子類化,具體類型不可以,具體說來,就是上述類型樹中,只有葉子節點才能被構造出來,其他的節點是不可構造的。

這是很有趣的一個地方,這表明,Julia的tag系統全都是具體類型組成的,那抽象類型有什麼用呢?抽象類型用來確定子類化關係,以做多重派發以實現ad hoc多態(並且只有這個用處)

儘管和Haskell一樣用a::B表示a類型是B,儘管Julia好像也寫類型,例如

function f(x::AbstractFloat,y::AbstractFloat)
  if x>0
    y
  else
    2*y
  end
end

看上去這個函數接受兩個抽象的浮點數!難道這樣不會影響效率嗎?現在我們知道,抽象的浮點數是抽象類型,是根本不能構造的,實際情況在調用f時,總是用具體類型來調用f(例如f(1.0,2.0) ),然後f會被specialize(特化),也就是說,f會對每一個類型組合產生一個編譯後的函數,因此f實際上是很多函數的集合!在f上標記的類型只不過是用來聲明不同的行爲(若用不同的類型調用,則我們可以有不同函數體的f),例如:

function f(x)
  x+1
end
function f(x::Int64)
  x*2
end

f(1)會調用第二個函數,f("rfs")會調用第一個函數,注意儘管第一個f沒有標記類型,f一樣會對每一個類型產生一個編譯後函數。

區分出具體類型和抽象類型是Julia優化中很重要的一個因素,因爲只有這樣才能做到一類型一內存佈局,實際上像C語言(簡單lambda類型)就是符合這個設計的。子類化只能繼承行爲,而不能繼承數據。整個系統都是面向方法設計的(或者說接口),而非對象。所以編程範式是很不一樣的,很多人不習慣的原因大多來源於此。

實際上這些特性並不是高高懸掛在上用來吹着好聽的,實際上Julia自身以及其衆多庫都大量運用了這些特性,所以一般人看起來這些程序都非常抽象,這是一個Base庫中函數(用了帶變量的與參數子類化的參數化類型):

function getindex(r::StepRangeLen{T}, s::OrdinalRange{<:Integer}) where {T}
    @_inline_meta
    @boundscheck checkbounds(r, s)
    # Find closest approach to offset by s
    ind = LinearIndices(s)
    offset = max(min(1 + round(Int, (r.offset - first(s))/step(s)), last(ind)), first(ind))
    ref = _getindex_hiprec(r, first(s) + (offset-1)*step(s))
    return StepRangeLen{T}(ref, r.step*step(s), length(s), offset)
end

一般的Julia程序都寫得非常的泛用,往往充滿着大量的函數調用(多重派發),每個函數都是自動多態的,例如最常提到的,索引數組的時候,不用:

for i in 1:length(A)
  A[i]
end  #不推薦

#而是
for i in eachindex(A)
 A[i]
end

用eachindex獲得索引,這樣對於使用任意索引數組也能夠成功迭代(例如0開始的數組)

所以Julia類型系統不是爲了安全設計的,標記類型與安全性關係不大。Julia放棄靜態類型系統的很大一個原因就是爲了”不安全“。考慮這樣一個程序:

function f(x)
  if x>0
    x+1
  else
    x*2
  end
end

這個函數是不可能檢查類型的,因爲我們根本不可能知道x的類型是什麼(準確的說,可以是任何值),它等價於Haskell的這樣一個泛型程序:

f::a->?
f x = if x>0 then x+1 else x*2 

但是這不太對,我們不知道+和*是怎麼回事(這些是要定義的)我們需要type class來限定這個a,使得a可以加和乘

f::(Addable a,Multable a)=>a->a

這個辦法在複雜的函數上很快就不work了,因爲會用很多函數,這樣我們就會用一大堆類型類。更何況同名函數不一定要返回相同的類型,所以在Haskell中做類似的事情是要打開一些擴展的(這不僅僅是一個擴展,這會破壞原有類型系統,實質上變得和Julia的類型系統沒什麼區別)

總之Julia的類型系統確實很不起眼,但是作爲一個動態類型的語言完全就不輸給Rust之類的語言。所以能和C語言一樣快有什麼奇怪的(更何況所有基本類型和部分結構體都和C兼容)?

五.Designed for parallelism and distributed computation

Lightweight "green" threading (coroutines)

做併發並行的支持Julia一開始設計的時候就內化於心了,即使如此,很多設計還是在不停的改來改去,現在在1.3中才剛剛確定了最後的對線程的支持。

我在這裏要解釋一下,以免大家誤解。Julia很早就支持了Task(綠色線程),但是Task是單線程的,用來實現Julia中的IO,還有一些異步程序也可以用Task實現的(用@async宏),也支持了分佈式計算,用Distributed庫。但是對線程的支持卻始終被標記爲experimental的(而且部分時候還有一些性能問題),這裏的線程的編程模型是共享內存編程。

問題不在於不支持線程,而在於這個支持始終讓Julia設計者很不滿意(別忘了Julia是動態語言,支持線程涉及到很多靜態語言中沒有的問題,所以支持線程是不簡單的事情)。首先是線程和IO不能在一起工(Julia IO 調用了一個C庫,所以Julia只有有限的操控權),其次是這個線程支持實在是太low level,原來也有high level的構造,但是幾乎都刪除了(實驗性的),因爲發現設計的不好,不可組合。而且Task一直都是單線程的,讓大家很不高興。所以一直等了很久(好幾年)來把線程整合到IO和Task中(這還涉及到重新設計Base庫中函數,使之線程安全,以及編譯器的支持)。見這篇文章:

https://julialang.org/blog/2019/07/multithreading-zh-cn​julialang.org

 

支持共享內存編程(線程)比用進程以及異步IO困難的多,Julia還可以做得更好。

六.優化

我覺得這個東西本來就沒有必要多說,因爲已經有很多開發者說了N遍,爲什麼Julia能這麼快,我之前也分析過,類型系統在Julia中起到了很大的幫助,基於LLVM的類型特化的JIT能夠產生高效的代碼,生成函數和宏可以起到錦上添花的作用,另外整個程序的優化流程(左邊),有多層的優化,在進入到LLVM優化以前Julia就已經做了不少高級優化減輕後端負擔,而這些高級優化的可能性來源於語言本身良好的設計。

而且Julia並不只是追求快,正確性和通用性也是重要的組成部分(不準確的浮點數運算可以很快,通過重排計算),我想再說一個很重要的事情,Julia編譯器團隊始終堅持的一點,就是優化的時候要有Compiler's freedom

什麼意思呢?直譯就是”編譯器的自由“,寫程序的人不應該根據編譯器的具體實現來編程,而是應該僅僅根據所提供的程序的構造語義編程,說簡單些,寫程序的人不能夠通過破壞程序的抽象來寫程序(有一些例外情況,比如說要和別的語言交互的時候)。

比如說不能把一個結構體struct轉換成一個bit string(位字符串)。乍一看這根本就不合理,結構體底層不就是一大堆字節排列在一片內存中嗎?可是從集合論的角度看,結構體本來就只不過是一個乘積類型,例如:

struct A
  x::Int
  y::Float
end

A = Int * Float(笛卡爾積),沒有任何規定Int必須和Float放在一起(當然確實放在一起更好),程序員不能利用這個特性(更加不能用一個指針加一來取後面的Float)。編寫程序的人可以知道這些實現,但不應該依靠他們寫程序。編譯器有它的自由,所以對於上面這個結構體,編譯器完全可以隨意在內存移動(注意struct聲明的結構體都是不可變的結構體,域不能改動),可以放在棧或堆上,只要怎麼好就怎麼放,甚至可以有多個拷貝。

保持編譯器的自由可以導致編譯器做更加激進的優化。只要保持語義不變,編譯器怎麼優化都可以(類似的就是javascript的V8優化)

七.flexible

Julia倒騰了這麼久,就是爲了結合flexible和性能。

爲什麼要折騰來折騰去,就是爲了使函數能夠泛用同時還保持高性能。爲什麼Julia的函數要寫的泛用?很多人很容易想到的是可以節約一些代碼,但是更加重要的是很多情況下泛用的函數可以給我們一些意外的驚喜,例如說Julia的微分方程的庫Differential.jl和一個物理學的不確定量的庫Uncertainty.jl結合在一起,可以求解初始條件有不確定量的微分方程。

Julia的庫之所以要寫的通用,是爲了激發未來不同庫(這些庫可能是來自不同領域的不同researcher的作品)互相組合的可能性,每個庫都保持開放,對代碼做最小的可能性限制,提供開箱即用的可能性(out of box)

可組合性也是很重要的一點,我舉個例子好了,以隨機數生成爲例:

julia> rand(Int64,3)  
3-element Array{Int64,1}:
 -2649612475503480631
 -1339104474732605748
  7365259649972224143

生成3個整數隨機數,讓我們來看看,怎麼生成三個浮點數:

julia> rand(Float64,3)
3-element Array{Float64,1}:
 0.3861987959240578
 0.20948171556616169
 0.6257081229187069

我們只要變換類型就好了。。。

類似的我們可以一一寫出:

rand(1:10,3) #在1,2,3,4,5,6,7,8,9,10中有放回隨機取數3個
rand(1.0:0.5:3.0,3) #1.0,1.5,2.0,2.5,3.0中有放回取數3個
rand(Complex{Float64},3) #三個複數

我們還可以導入Distributions.jl獲得更多分佈:

julia> using Distributions
julia> rand(Gamma(1,2),4)
4-element Array{Float64,1}:
 9.026842772260245
 0.8963993164954357
 1.3921690234327555
 0.9756503782568936

也是一模一樣的用rand+類型。根本不用多餘的函數(randfloat,randcomplex,randgamma等等)

Julia有很多非常小的庫都採用了這種設計,所以很多情況下大家都沒有發現自己要找的東西其實別人都已經解決了,只不過有些隱蔽而已。。。


還有很多可以說的,以後可能可以補上。

總而言之,Julia已經是一門成熟的語言了,但是Julia給大家的總是不成熟的印象,因爲比起別的語言,Julia還有很多不確定的地方,Julia走的這條動態語言優化之路本來就非常崎嶇,這條路不同於那些用於服務器語言或者腳本的語言,可以用併發代替並行提高性能,或者用一些非常特殊的優化,僅僅優化語言的某個方面也能有可觀的性能提升。更何況大家對Julia的期望不是一般的高(我想要Matlab+Python+Javascript+C+...)人們想要做非常多的事情,有太多的地方還可以提高,比如說編譯到Webassembly上,編譯到GPU上,在HPC上執行,Debugger...可是人手卻相對少。即便如此,自Julia1.0發佈一年以來,Julia已經做出了很多成就,但是前方道路還很遙遠。正如Julia創始人說的一樣:

We are greedy.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章