在新的 1.5 版是主要對記憶體的配置方式有改進,有最佳化記憶體布局(memory layout)。
繼續閱讀其實是受到 Julia 核心開發者之一 Lyndon White 的文章的啟發來撰寫本文的。
文章有 CSDNnews 簡中翻譯。
本文並非以上文章的繁中版本,而是本人的一些心得及觀察。
載入套件
比較深入了解 Julia 的朋友,會了解到這個語言的設計與其他語言的不同,像是 using
。
當我們使用 using
來載入套件的時候,會發現有不少存在在 Base
中的函式是可以使用的。像是 length
可以用來取得陣列的長度,當你載入 DataStructures.jl 時,你同樣可以用 length
來取得 Stack
及 Queue
的長度。
或者是原文中的例子。使用者可以使用 NamedDims.jl 來為你的陣列的維度命名,而你的陣列需要使用 CuArrays.jl 送到 CUDA,這時候你不需要一個可以為 CUDA array 命名的套件來達成這件事。
你會發現在 Julia 語言中似乎不存在套件跟套件之間的差別,或是套件跟標準函式庫之間的區別。這是由於在 Julia 中對於命名空間(namespace)的概念較為薄弱,Julia 各模組之間仍然存在著命名空間,但是 Julia 在 using
時會將這些命名空間去除。這在工程上或許不是一個好的典範,因為會造成命名空間的汙染(namespace pollution)。反過來說,它促使人們相互溝通及協調,來提供更好的套件之間的可組合性。
在其他語言中,常常會告訴你,使用某個套件只需要載入你需要的部份,像是 using Foo: bar, baz
,然而 Julia 並不去特別強調這點,using Foo
會載入套件開發者有導出的部份。如果有兩個套件都提供了 predict
來支援他們的模型,這邊借原文的例子再次說明:
1 | using Foo |
這兩個 predict
分別來自雙方套件的各自定義及實作。
1 | Bar.predict(mbar) |
如此使用者便可以無縫地使用同樣一個介面 predict
,而功能來自兩個不同的套件。
這樣的特性大概會有人聯想到介面(interface)。然而,Julia 並不明顯使用介面來規範開發者,Julia 隱含地使用鴨子定型(duck typing)。
在載入套件之後,Julia 允許使用者不冠上套件名稱來使用這些函式,像 Bar.predict
,你可以自由地使用 predict
即可。總是會有套件之間的命名衝突,當衝突發生的時候,就是開發者需要負起責任彼此溝通及協調的時候。不過使用者仍然可以以 Bar.predict
的方式使用套件,只是每次載入套件的時候都會有警告。
1 | julia> using Plots |
鴨子定型及多重分派
在原文中提到鴨子定型及多重分派是成就 Julia 成為一個可組合(composable)語言的基石。我個人也認為可組合是 Julia 提供最重要的特性之一,但卻鮮少被人提及。多數人仍然熱衷於語言效能及開發便利性。
鴨子定型的一個很好的比喻是,當一個東西會呱呱叫的時候,那麼他就是鴨子。當一個 bar(x)
可以呼叫時,我不需要去檢查 x
的型別為何,我就直接使用就是了。這樣會構成一種隱性的介面。
當我需要提供一個矩陣相乘的功能時,我會寫以下程式碼:
1 | function multiply(A, B) |
這可以用在一般的矩陣,但如果今天我有自定義的一個新的矩陣呢?
1 | struct NewArray |
在上述的 multiply
中我只需要檢查 NewArray
是否提供相關的介面即可。像是裡頭用到了 size
、A[:, 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 的物件都有屬性,但這需要寫在型別中事先定義。但如果要為沒有任何屬性的型別增加屬性要怎麼做?
Julia 的屬性存取是來自 Base.getproperty
及 Base.setproperty!
兩個方法。
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 | class Foo: |
像以上的 python 程式碼,foo.abc(x)
就是去呼叫 Foo
的方法,而 bar.abc(x)
則是去呼叫 Bar
的方法。
也就是說,方法的呼叫是由第一個參數所決定的,這邊要注意的是第一個參數並不是 x
,而是 self
喔!也就是物件本身!
相對於單一分派,多重分派是指他會參考所有的參數型別及其組合來決定到底要呼叫哪一個方法。
所以我們也有了更細緻的選擇。
1 | abc(foo::Foo, x::String) = x |
當第一個參數型別是 Foo
時,就回傳字串,而當第一個參數型別是 Bar
時,就回傳整數。
我們可以去區別,當 x
已經是整數或是字串時就不用去處理它,直接回傳即可。若是有不同型別時,個別處理。
這邊我們並沒有使用到第一個型別的參數,在 Julia 裡可以省略變數本身,只寫型別。
1 | abc(::Foo, x::String) = x |
多重分派的自由與限制
使用多重分派可以讓語言有更細緻的定義,也有更高的自由度。
我們可以看看以下的例子,這邊使用了常常被使用的 null pattern:
1 | push!(ls::List, obj::NullObject) = ls |
當我要去表示一類物件,我們會實作 Object
,但往往我們會想要表示一個為空的物件,這時候我們就會有一個 NullObject
來表達這件事。
當我們想要將一個 Object
放到 List
中的時候,我們會實作 push!
,然而當一個 NullObject
被放入 List
的時候,我們並不需要真的做什麼樣的動作。實作就會如同上面的程式碼。
相對在 python 或是一般物件導向語言當中,我們就會看到以下的程式碼:
1 | class List: |
我們需要自己去判斷接收進來的參數型別,然後進一步做處理。這樣的話會讓我們的程式碼充滿 if-else,當條件一多時,會變得相當難以閱讀及修改。
使用多重分派並不是只有優點,他也有缺點。多重分派會引入模糊性(ambiguity)。
考慮以下程式碼:
1 | foo(a::A, b) = a |
請問 foo(a, b)
該呼叫哪一個方法呢?
這樣的呼叫在 Julia 會產生錯誤,也就是編譯器不知道該呼叫哪一個方法。
這時候編譯器會建議修改的方式,就是定義一個更明確的方法:
1 | foo(a::A, b::B) |
應用情境
我們來寫個簡單的剪刀石頭布遊戲!
1 | struct Paper end |
我們分別有三個型別分別代表剪刀石頭布。
接下來我們定義運算,先定義贏的狀況:
1 | play(::Paper, ::Stone) = 1 |
當雙方是相同時就平手:
1 | play(::T, ::T) where {T} = 0 |
這邊我們利用了 Julia 的參數化方法,當兩個有相同的型別時,就回傳 0。
最後,是輸的狀況:
1 | play(a, b) = -1 |
也就是,如果沒有符合以上的狀況的方法呼叫,就會落到這個狀況來,所以這邊我們允許最廣義的 Any
型別。
希望大家可以經由這個簡單的例子來理解 Julia 的多重分派有多好用,甚至是搭配上參數化方法根本是逆天阿!
Julia v1.4 已經釋出啦!這邊來跟大家介紹一些新功能。
底下新功能後面會有括弧,附註相關的實作程式碼。
今天來跟大家介紹 iterator、generator 及 iterable 這三個東西。基本上,大家應該都有用過 for 迴圈,然而很多東西可以放在迴圈的 in
後方,這樣就可以將裡頭的元素一個一個取出來。但是這是怎做到的呢?
很多人在寫程式的時候會有些壞習慣,如:
1 | X = rand(3, 4) |
可能多數人看以上這段程式碼並沒有什麼特別的感覺,但是如果要維護的時候就會發現你突然不太理解這段程式碼。
有人知道這邊的 3
是什麼意思嗎?嗯…或許可以從上下文猜出來是陣列的列數的意思。
一旦要更改陣列的大小的時候勢必就要更改這些數字,甚至這些數字散落在程式碼的各個角落就會更加頭痛。
這些數字稱為魔術數字(magic numbers),因為沒有人知道他的意義是什麼!
解法一:使用常數
如果這些數字很常被使用到,而且不會在程式中被變更,請使用常數,像:
1 | const ROWS = 3 |
如此,以後要更改陣列大小只需要更改常數即可,也讓程式碼的可讀性上升。
如果你的程式會更改到這些數字,那麼就用變數。
1 | rows = 3 |
解法二:動態
如果陣列的大小不是事先知道的,或是需要動態取得,那麼可以用 size
:
1 | for i = 1:size(X, 1) |
如此可以用在未知大小的陣列上。
有在做套件開發的開發者們應該不陌生 @inbounds
這個 macro,在很多現代程式語言中也有。
在存取陣列時,為了安全性與正確性的考量,避免存取到陣列範圍以外的記憶體位置,很多語言都設置了邊界檢查(bounds check)。
邊界檢查會檢查所存取的索引值是否在陣列的範圍內,但是這樣的檢查會有些微的效能損耗,尤其在迴圈內的情況更有可能被累積而放大,關於 Julia 的 邊界檢查可以參考官方文件 Bounds checking。
如果可以確定所存取的索引值一定在範圍內,我們就可以把邊界檢查給移除,以加速陣列的存取。如以下範例:
1 | A = rand(3, 4) |
或是
1 | A = rand(3, 4) |
@inbounds
會將程式碼區塊中的邊界檢查給移除,可以參考 @inbounds
的官方文件。使用時必須注意存取的索引值,否則小則存取的值錯誤,大則可能導致程式崩潰。
先養成好的索引習慣,再考慮將效能提升,加入 @inbounds
。相關的資訊也紀錄在官方的效能建議中。