其實是受到 Julia 核心開發者之一 Lyndon White 的文章的啟發來撰寫本文的。

文章有 CSDNnews 簡中翻譯

本文並非以上文章的繁中版本,而是本人的一些心得及觀察。

載入套件

比較深入了解 Julia 的朋友,會了解到這個語言的設計與其他語言的不同,像是 using

當我們使用 using 來載入套件的時候,會發現有不少存在在 Base 中的函式是可以使用的。像是 length 可以用來取得陣列的長度,當你載入 DataStructures.jl 時,你同樣可以用 length 來取得 StackQueue 的長度。

或者是原文中的例子。使用者可以使用 NamedDims.jl 來為你的陣列的維度命名,而你的陣列需要使用 CuArrays.jl 送到 CUDA,這時候你不需要一個可以為 CUDA array 命名的套件來達成這件事。

你會發現在 Julia 語言中似乎不存在套件跟套件之間的差別,或是套件跟標準函式庫之間的區別。這是由於在 Julia 中對於命名空間(namespace)的概念較為薄弱,Julia 各模組之間仍然存在著命名空間,但是 Julia 在 using 時會將這些命名空間去除。這在工程上或許不是一個好的典範,因為會造成命名空間的汙染(namespace pollution)。反過來說,它促使人們相互溝通及協調,來提供更好的套件之間的可組合性。

在其他語言中,常常會告訴你,使用某個套件只需要載入你需要的部份,像是 using Foo: bar, baz,然而 Julia 並不去特別強調這點,using Foo 會載入套件開發者有導出的部份。如果有兩個套件都提供了 predict 來支援他們的模型,這邊借原文的例子再次說明:

1
2
3
4
5
6
7
using Foo
using Bar
training_data, test_data = ...
mbar = BarModel(training_data)
mfoo = FooModel(training_data)
evaluate(predict(mbar), test_data)
evaluate(predict(mfoo), test_data)

這兩個 predict 分別來自雙方套件的各自定義及實作。

1
2
Bar.predict(mbar)
Foo.predict(mfoo)

如此使用者便可以無縫地使用同樣一個介面 predict,而功能來自兩個不同的套件。

這樣的特性大概會有人聯想到介面(interface)。然而,Julia 並不明顯使用介面來規範開發者,Julia 隱含地使用鴨子定型(duck typing)。

在載入套件之後,Julia 允許使用者不冠上套件名稱來使用這些函式,像 Bar.predict,你可以自由地使用 predict 即可。總是會有套件之間的命名衝突,當衝突發生的時候,就是開發者需要負起責任彼此溝通及協調的時候。不過使用者仍然可以以 Bar.predict 的方式使用套件,只是每次載入套件的時候都會有警告。

1
2
3
4
5
6
7
julia> using Plots

julia> using Gadfly

julia> plot
WARNING: both Gadfly and Plots export "plot"; uses of it in module Main must be qualified
ERROR: UndefVarError: plot not defined

鴨子定型及多重分派

在原文中提到鴨子定型及多重分派是成就 Julia 成為一個可組合(composable)語言的基石。我個人也認為可組合是 Julia 提供最重要的特性之一,但卻鮮少被人提及。多數人仍然熱衷於語言效能及開發便利性。

鴨子定型的一個很好的比喻是,當一個東西會呱呱叫的時候,那麼他就是鴨子。當一個 bar(x) 可以呼叫時,我不需要去檢查 x 的型別為何,我就直接使用就是了。這樣會構成一種隱性的介面。

當我需要提供一個矩陣相乘的功能時,我會寫以下程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
function multiply(A, B)
C = zeros(size(A, 1), size(B, 2))
A = A'
for i = 1:size(C, 1)
for i = 1:size(C, 2)
for k = 1:size(A, 2)
C[i, j] = sum(A[:, k] .* B[:, k])
end
end
end
return C
end

這可以用在一般的矩陣,但如果今天我有自定義的一個新的矩陣呢?

1
2
3
struct NewArray
...
end

在上述的 multiply 中我只需要檢查 NewArray 是否提供相關的介面即可。像是裡頭用到了 sizeA[:, k]sum。我只需要支援這些介面的實作即可,如此,Julia 就可以以鴨子定型的方式,讓 multiply 接受 NewArray 了。

對於不同的型別需要有不同的行為,這在一般的物件導向語言中稱之為多型。多型在多數物件導向典範中使用的是單一分派(single dispatch),然而 Julia 也支援多型,但是以多重分派(multiple dispatch)的方式支援。因此,廣義而言,Julia 支援物件導向的方式是多重分派,卻不是典型的、語法上的封裝、繼承及(單一分派)多型。

多重分派,可以在需要更細緻行為定義時幫上忙。當 NewArray 支援 sum

1
sum(::NewArray) = ...

這是單一分派的方式,也可以有多重分派的方式。

1
sum(::NewArray, ::Array) = ...

不過這樣只支援 Array 這個特定型別。或是我們可以乾脆這樣做。

1
sum(::NewArray, ::AbstractArray) = ...

這樣只要是 AbstractArray 的子型別都可以接受。

總結

以上種種的特性成為了可組合性的基石。一個具有可組合性的語言能夠成為各種東西。Julia 的個別的套件都不俱備所有的定義,需要依賴 Julia 語言及標準函式庫的介面及實作。

例如當 Julia 中載入了深度學習框架,那它,連同語言本身,就是一個完整支援深度學習功能的引擎。當同時載入了資料庫與深度學習相關套件,那它就成為了支援深度學習功能的 DBMS。當載入了科學計算及機器學習套件,那它就會變成一個強大的數值計算引擎。以 Julia 的可組合性出發,來打造各式各樣不同的引擎,就像一個單純的編輯器搭配有豐富的外掛(plug-in)一樣。這樣衍生出的生態帶來了各式各樣不同的可能性,也讓套件的 reusability 提升到最高的境界。

留言與分享

Julia 有優異的效能,但是它的 JIT 讓套件的載入時間(using)過於冗長。

Julia JIT 會在套件載入時期以及第一次使用函式時期進行編譯,若是有編譯完成的版本,便可以直接使用,避免編譯時間。儘管 Julia 將程式碼進行編譯,得到了絕佳的執行時間,但是套件載入的第一次編譯時間成為了大家的痛點。

為了解決這樣的痛點,開發者社群做了不少努力,主要是 PackageCompiler.jl 套件,讓套件可以預先進行編譯,而後來也補充了不少 binary package 來支援套件編譯。

PackageCompiler.jl 套件可以將 Julia 程式碼編譯成可執行檔以及動態函式庫(.so),支援套件編譯成動態函式庫,以及增量編譯(incremental compilation)。

近期 PackageCompiler.jl 套件正式發佈 v1.0,正式成熟可以使用了!本文將會介紹如何將套件編譯成動態函式庫,並且在啟動時載入,來避免過長的載入及編譯時間。

繼續閱讀

今天來談談 Julia 的多重分派(multiple dispatch)。

很顯然地,多重分派有別於單一分派(single dispatch),而單一分派是在現今主流的物件導向程式設計中常見的方式。

在現今的物件導向程式設計當中,我們會將函式歸納到某個類別底下成為其類別的方法,而這個方法就屬於這個類別。

多重分派 v.s. 單一分派

這樣的觀點就是單一分派的精神,單一分派是指一個方法的呼叫,要如何決定呼叫的是哪一個方法實作呢?

1
2
3
4
5
6
7
8
9
10
class Foo:
def abc(self, x):
return str(x)

class Bar:
def abc(self, x):
return int(x)

foo = Foo()
bar = Bar()

像以上的 python 程式碼,foo.abc(x) 就是去呼叫 Foo 的方法,而 bar.abc(x) 則是去呼叫 Bar 的方法。

也就是說,方法的呼叫是由第一個參數所決定的,這邊要注意的是第一個參數並不是 x,而是 self 喔!也就是物件本身!

相對於單一分派,多重分派是指他會參考所有的參數型別及其組合來決定到底要呼叫哪一個方法。

所以我們也有了更細緻的選擇。

1
2
3
4
abc(foo::Foo, x::String) = x
abc(foo::Foo, x::Int64) = String(x)
abc(bar::Bar, x::String) = Meta.parse(x)
abc(bar::Bar, x::Int64) = x

當第一個參數型別是 Foo 時,就回傳字串,而當第一個參數型別是 Bar 時,就回傳整數。

我們可以去區別,當 x 已經是整數或是字串時就不用去處理它,直接回傳即可。若是有不同型別時,個別處理。

這邊我們並沒有使用到第一個型別的參數,在 Julia 裡可以省略變數本身,只寫型別。

1
2
3
4
abc(::Foo, x::String) = x
abc(::Foo, x::Int64) = String(x)
abc(::Bar, x::String) = Meta.parse(x)
abc(::Bar, x::Int64) = x

多重分派的自由與限制

使用多重分派可以讓語言有更細緻的定義,也有更高的自由度。

我們可以看看以下的例子,這邊使用了常常被使用的 null pattern:

1
2
3
push!(ls::List, obj::NullObject) = ls

psuh!(ls::List, obj::Object) = push!(ls.array, obj)

當我要去表示一類物件,我們會實作 Object,但往往我們會想要表示一個為空的物件,這時候我們就會有一個 NullObject 來表達這件事。

當我們想要將一個 Object 放到 List 中的時候,我們會實作 push!,然而當一個 NullObject 被放入 List 的時候,我們並不需要真的做什麼樣的動作。實作就會如同上面的程式碼。

相對在 python 或是一般物件導向語言當中,我們就會看到以下的程式碼:

1
2
3
4
5
6
class List:
def append(self, obj):
if type(obj) == NullObject:
return self
elif type(obj) == Object:
self.array.append(obj)

我們需要自己去判斷接收進來的參數型別,然後進一步做處理。這樣的話會讓我們的程式碼充滿 if-else,當條件一多時,會變得相當難以閱讀及修改。

使用多重分派並不是只有優點,他也有缺點。多重分派會引入模糊性(ambiguity)。

考慮以下程式碼:

1
2
3
4
5
foo(a::A, b) = a
foo(a, b::B) = b

a = A()
b = B()

請問 foo(a, b) 該呼叫哪一個方法呢?

這樣的呼叫在 Julia 會產生錯誤,也就是編譯器不知道該呼叫哪一個方法。

這時候編譯器會建議修改的方式,就是定義一個更明確的方法:

1
foo(a::A, b::B)

應用情境

我們來寫個簡單的剪刀石頭布遊戲!

1
2
3
struct Paper end
struct Scissor end
struct Stone end

我們分別有三個型別分別代表剪刀石頭布。

接下來我們定義運算,先定義贏的狀況:

1
2
3
play(::Paper, ::Stone) = 1
play(::Scissor, ::Paper) = 1
play(::Stone, ::Scissor) = 1

當雙方是相同時就平手:

1
play(::T, ::T) where {T} = 0

這邊我們利用了 Julia 的參數化方法,當兩個有相同的型別時,就回傳 0。

最後,是輸的狀況:

1
play(a, b) = -1

也就是,如果沒有符合以上的狀況的方法呼叫,就會落到這個狀況來,所以這邊我們允許最廣義的 Any 型別。

希望大家可以經由這個簡單的例子來理解 Julia 的多重分派有多好用,甚至是搭配上參數化方法根本是逆天阿!

留言與分享

  • 第 1 頁 共 1 頁

Yueh-Hua Tu

目標是計算生物學家!
Systems Biology, Computational Biology, Machine Learning
Julia Taiwan 發起人


研發替代役研究助理


Taiwan