Julia 語言可重用性高竟源於缺陷和不完美?

【編者按】關於Julia編程語言,最值得注意的最大優勢之一就是程序包的編寫方式。你幾乎總是可以在自己的軟件中重用他人的類型或方法,而不會出現問題。


通常來說,從高層角度來說,對於所有編程語言而言這是正確的,因為這就是庫該有的樣子。但是,經驗豐富的軟件工程師通常會指出,在實踐中很難把從一個項目中獲取東西在不進行任何改動的情況下完全照搬到另一個項目中,做到這一點很難。但是,在Julia生態系統中,似乎可以做到這一點。

Julia 语言可重用性高竟源于缺陷和不完美?

作者 | Lyndon White

出品 | CSDN(CSDNnews)

這篇文章將探討一下其中原因的理論,以及對未來語言設計者的一些建議。本文基於作者受邀在2020 F(by)會議上發表的演講,且部分內容受到了Stefan Karpinski在JuliaCon 2019上發表的《多重調度的不合理有效性》的啟發。

Julia 语言可重用性高竟源于缺陷和不完美?

我說的可組合是什麼意思?

例子:

  • 如果要將跟蹤測量誤差添加到標量數字,則無需贅述新類型與數組的交互方式(Measurements.jl)

  • 如果你有一個微分方程求解器和一個神經網絡庫,那麼你應該只能夠得到神經ODE(DifferentialEquations.jl / Flux.jl)

  • 如果你有一個程序包可以為數組的尺寸添加名稱,並且可以在GPU上添加名稱,那麼你不必編寫代碼即可在GPU上命名數組(NamedDims.jl / CUArrays.jl)

Julia 语言可重用性高竟源于缺陷和不完美?

Julia為什麼是這樣的?

我的理論是,Julia代碼之所以可重用性高,這不僅是因為該語言具有一些強大的功能,而且還因為其弱點或缺失的特定特徵。

它缺少以下功能:

  • 有關名稱空間干擾的規則不完善

  • 從來沒有嘗試使其易於使用包外部的本地模塊

  • 類型系統無法用於檢查正確性

但是這些缺陷被其他功能抵消或放大了其他功能:

  • 與他人交流的習慣

  • 非常容易創建包

  • 結合鴨子類型和多次調度

以有漏洞的方式使用Julia命名空間

在大多數語言社區中,從另一個模塊加載代碼時,常見的建議是:僅導入所需的內容。例如使用Foo:a,b c

而在Julia中,通常的做法是:使用Foo,它將導入Foo作者標記為要導出的所有內容。

你不必這樣做,但這很普遍。

但是,如果有一對軟件包會發生什麼:

  • Foo導出預測(:: FooModel,數據)

  • Bar導出預測(::BarModel,數據),

一個會:

<code>using Foo/<code><code>using Bar/<code><code>training_data, test_data = .../<code><code>mbar = BarModel(training_data)/<code><code>mfoo = FooModel(training_data)/<code><code>evaluate(predict(mbar), test_data)/<code><code>evaluate(predict(mfoo), test_data)/<code>


如果你有多次嘗試將using同一個名稱納入範圍,那麼Julia會拋出錯誤,因為它無法確定使用哪個名稱。

作為用戶,你可以告訴它使用什麼。


<code>evaluate(Bar.predict(mbar), test_data)/<code><code>evaluate(Foo.predict(mfoo), test_data)/<code>


但是軟件包作者可以解決此問題:

如果兩個重載的名稱都來自同一名稱空間,則不會發生名稱衝突。

如果Foo和Bar都在重載StatsBase.predict,則一切正常。


<code>using StatsBase # exports predict/<code><code>using Foo # overloads `StatsBase.predict(::FooModel)/<code><code>using Bar # overloads `StatsBase.predict(::BarModel)/<code><code>training_data, test_data = .../<code><code>mbar = BarModel(training_data)/<code><code>mfoo = FooModel(training_data)/<code><code>evaluate(predict(mbar), test_data)/<code><code>evaluate(predict(mfoo), test_data)/<code>


這鼓勵人們協同工作。

名稱衝突促使程序包作者聚在一起,創建基本程序包(如StatsBase),並就功能的含義達成一致。

他們不是必須這樣做,因為用戶仍然可以解決它,但這鼓勵了實踐。因此,我們讓軟件包作者思考如何將其他軟件包與他們的軟件包一起使用。

包作者甚至可以根據需要從多個名稱空間重載函數。例如MLJBase.predict,StatsBase.predict,SkLearn.predict的全部。針對不同用例的接口可能都略有不同。

Julia 语言可重用性高竟源于缺陷和不完美?

創建軟件包比本地模塊更容易

許多語言每個文件都有對應的一個模塊,你可以加載該模塊,例如通過從當前目錄導入文件名。

你也可以在Julia進行這項工作,但這對精準度的要求出奇的高。

但是,還有一個更簡單的方法,就是創建和使用程序包。

製作本地模塊通常會給你帶來什麼?

  • 命名空間

  • 你做了一個很棒的軟件工程,很有成就感

  • 以後更容易過渡到軟件包

做一個Julia包裝會給你帶來什麼?

  • 以上所有並加上

  • 標準目錄結構

  • 託管依賴項,最新和以往的版本

  • 易於重新分配——難以獲得本地狀態

  • 可使用套件管理員的pkg> test MyPackage測試

推薦的創建包的方法還可以確保:

  • 持續集成設置

  • 代碼覆蓋率

  • 文檔設置

  • 許可證集

測試Julia代碼很重要。

Julia使用的是JIT編譯器,因此即使編譯錯誤也要等到運行時才能出現。作為一種動態語言,類型系統很少指出正確性如何。

測試Julia代碼很重要。如果測試中未涵蓋代碼路徑,那麼Julia語言本身幾乎沒有任何措施可以保護它們免受任何類型錯誤的拖累。

因此,設置持續集成和其他此類工具非常重要。

瑣碎的包裝創建很重要

許多創建Julia軟件包的人都不是傳統的軟件開發人員。例如很大一部分是學術研究人員。那些不認為自己是“開發人員”的人不太願意採取措施將其代碼打包。

說起來,許多Julia軟件包的作者不乏忙著完成下一篇論文的研究生。許多科學代碼永遠不會被髮布,而其中許多代碼根本不會被其他人使用。但是,如果他們開始編寫一個程序包(而不是隻能在他們的腳本中運行的本地模塊),那麼離發佈已經更近好幾步了。一旦成為軟件包,人們便開始像軟件包作者一樣思考,並開始考慮如何使用它。

這不是靈丹妙藥,但它可以把你向正確的方向推一把。

多次分派+鴨式輸入

假設它走路像鴨子,說話像鴨子,但不能解決這個問題。

Julia鴨式輸入與多次發送相結合的方式非常簡潔。它使我們能夠支持任何滿足函數所期望的隱式接口的對象(鴨式輸入);同時也有機會將其作為特殊情況處理(多次分派)。以完全可擴展的方式。

這與Julia缺乏靜態類型系統有關。靜態類型系統的好處來自確保在編譯時滿足接口。這在很大程度上與鴨式輸入不兼容。(不過,在該空間中還有其他有趣的選項,例如結構化鍵入。)

本節中的示例將用來說明如何進行鴨式輸入和多次發送,使表達具有可組合性。

我們想使用庫中的一些代碼

假如我可能有一個來自Ducks庫的類型。

輸入:


<code>struct Duck end/<code><code>walk(self) = println(" Waddle")/<code><code>talk(self) = println(" Quack")/<code>

<code>raise_young(self, child) = println(" ➡️ Lead to water")/<code>

我想要運行一些代碼並寫入:


<code>function simulate_farm(adult_animals, baby_animals)/<code><code> for animal in adult_animals/<code><code> walk(animal)/<code><code> talk(animal)/<code><code> end/<code>

<code># choose the first adult and make it the parent for all the baby_animals/<code><code> parent = first(adult_animals)/<code><code> for child in baby_animals/<code><code> raise_young(parent, child)/<code><code> end/<code><code>end/<code>


試試:3只發育成熟的鴨子,2個小鴨子:輸入:

<code>simulate_farm([Duck(), Duck(), Duck()], [Duck(), Duck()])/<code>

輸出:


<code> Waddle/<code><code> Quack/<code><code> Waddle/<code><code> Quack/<code><code> Waddle/<code><code> Quack/<code><code> ➡️ Lead to water/<code><code> ➡️ Lead to water/<code>


很好,成功運行了。

好了現在我想用它拓展自己的類型。一隻天鵝

輸入:

<code>struct Swan end/<code>

首先用1只來測試:

<code>simulate_farm([Swan()], )/<code>


輸出:


<code> Waddle/<code><code> Quack/<code>


天鵝是可以蹣跚而行了,但是卻沒叫。

我們做了一些鴨式輸入——天鵝走路像鴨子,但它們卻不像鴨子叫喚。

我們可以通過單分派解決。

<code>talk(self::Swan) = println(" Hiss")/<code>

輸入:

<code>simulate_farm([Swan()], )/<code>

輸出:


<code> Waddle/<code><code> Hiss/<code>


好了,現在我們試著用一整個農場的天鵝來寫入:

輸入:

<code>simulate_farm([Swan(), Swan(), Swan()], [Swan(), Swan()])/<code>

輸出:


<code> 蹣跚而行/<code><code> 嘶鳴/<code><code> 蹣跚而行/<code><code> 嘶鳴/<code><code> 蹣跚而行/<code><code> 嘶鳴/<code><code> ➡️ 領著下水/<code><code> ➡️ 領著下水/<code>


有點不對勁。天鵝不會領著它們的孩子入水,而是馱著它們。

Julia 语言可重用性高竟源于缺陷和不完美?

我們依然可以通過單分派解決這個問題。

<code>raise_young(self::Swan, child::Swan) = println(" ↗️ Carry on back")/<code>

再試一次:

輸入:

<code>simulate_farm([Swan(), Swan(), Swan()], [Swan(), Swan()])/<code>

輸出:


<code> 蹣跚而行/<code><code> 嘶鳴/<code><code> 蹣跚而行/<code><code> 嘶鳴/<code><code> 蹣跚而行/<code><code> 嘶鳴/<code><code> ↗️ 馱在背上/<code><code> ↗️ 馱在背上/<code>


現在,我想得到農場中有多種家禽的結果。

2只鴨子,1只天鵝和2只小天鵝

輸入:

<code>simulate_farm([Duck(), Duck(), Swan()], [Swan(), Swan()])/<code>

輸出:


<code> Waddle/<code><code> Quack/<code><code> Waddle/<code><code> Quack/<code><code> Waddle/<code><code> Hiss/<code><code> ➡️ Lead to water/<code><code> ➡️ Lead to water/<code>


又不對了。

<code> ➡️ Lead to water/<code>

發生了什麼?

我們有一隻鴨子在撫養一隻小天鵝,它把小天鵝引到水裡。

如果你瞭解飼養家禽的知識,那麼就會知道:給小天鵝餵鴨子的鴨子將放棄小鴨子。

但是我們將如何編碼呢?

選擇1:重寫鴨子


<code>function raise_young(self::Duck, child::Any)/<code><code> if child isa Swan/<code><code> println(" Abandon")/<code><code> else/<code><code> println(" ➡️ Lead to water")/<code><code> end/<code><code>end/<code>


但是重寫鴨子還有問題

  • 必須編輯其他的資料庫,以添加對我的類型的支持。

  • 這可能意味著要添加許多代碼以供他們維護。

  • 不能拓展,如果其他人想要添加雞、鵝等,該怎麼辦?

變體:猴子補丁

  • 如果該語言支持猴子補丁,則可以這樣做。

  • 但這意味著將他們的代碼複製到我的庫中會遇到無法更新的問題。

  • 由於不再是要複製的主要規範來源,因此與其他人擴展時添加新類型的情況更糟。

變體:可以分叉他們的代碼

  • 那就是放棄代碼重用。

設計模式

設計模式允許人們模仿一種語言所沒有的功能。例如,鴨子可能允許一個人為給定的小動物記錄行為,這基本上是即席運行時多重調度。但這將需要以這種方式重寫Duck。

選項2:從鴨子繼承

(注意:此示例是無效的Julia代碼)

<code>struct DuckWithSwanSupport <: duck="" end=""/>/<code>

<code>function raise_young(self::DuckWithSwanSupport, child::Any)/<code><code> if child isa Swan/<code><code> println(" Abandon")/<code><code> else/<code><code> raise_young(upcast(Duck, self), child)/<code><code> end/<code><code>end/<code>


從鴨子繼承也有問題:

  • 必須用DuckWithSwanSupport替換我代碼庫中的每個Duck

  • 如果我正在使用其他可能返回Duck的庫,我也必須處理

  • 有一些設計模式可以提供幫助,例如使用“依賴注入”來控制如何創建所有Duck。但現在必須重寫所有庫才能使用它。

仍然無法擴展:

如果其他人實現了DuckWithChickenSupport,並且我想同時使用他們的代碼和我的代碼,該怎麼辦?

  • 兩者都繼承?DuckWithChickenAndSwan支持

  • 這是經典的多繼承鑽石問題。

  • 這個很難(即使在支持多重繼承的語言中,如果我沒有為許多事情寫特殊案例,他們也可能無法以一種有用的方式來支持它。

選項3:多次派送

這很簡單:

嘗試一下:

<code>raise_young(parent::Duck, child::Swan) = println(" Abandon")/<code>

輸入:

<code>simulate_farm([Duck(), Duck(), Swan()], [Swan(), Swan()])/<code>

輸出:

<code>蹣跚而行/<code><code>嘎嘎/<code><code>蹣跚/<code><code>嘎嘎/<code><code>蹣跚/<code><code>嘶嘶聲/<code><code>放棄/<code><code>放棄/<code>

是否有現實世界中的多次派送用例?

事實證明是有的。

在科學計算中,一直需要擴展操作以對新的類型組合進行操作。我懷疑它很常見,但是我們已經學會了忽略它。

如果查看BLAS方法列表,你將僅看到此編碼在函數名稱中,例如:

  • SGEMM-矩陣矩陣乘法

  • SSYMM-對稱矩陣矩陣乘法

  • ZHBMV-複雜的Hermitian帶狀矩陣向量乘法

事實證明,人們一直希望發明越來越多的矩陣類型。

  • 塊矩陣

  • 帶狀矩陣

  • 塊帶狀矩陣(其中帶由塊組成)

  • 帶狀塊帶狀矩陣(頻帶由自身帶狀的塊組成)。

在此之前,你可能想對矩陣進行其他操作,並希望對其進行編碼:

  • 在GPU上運行

  • AutoDiff的跟蹤操作

  • 命名尺寸,便於查找

  • 在群集上分佈

這些都很重要,並在關鍵應用程序中使用。當你開始進行跨學科應用時,它們的出現頻率會更高。就像神經微分方程的進步一樣,需要:

  • 機器學習研究已經發明瞭所有類型

  • 所有類型的微分方程求解研究都已具備

並希望將它們一起使用。

因此,對於數字語言來說,列舉你可能需要的所有矩陣類型並不合理。

手動干預JIT

跟蹤JIT的基本功能:

  • 通過跟蹤發現重要案例

  • 為它們編譯定製的方法

這稱為定製化。

Julia的JIT的基本功能:

  • 在調用它們的所有類型上定製化所有方法

這非常好:合理地假設類型將成為重要案例。

在Julia的JIT之上,多重調度又增加了什麼?

它讓人們分辨應該如何進行定製化,裡面可以添加很多信息。

思考下矩陣乘法。

我們有

Julia 语言可重用性高竟源于缺陷和不完美?

將問題扔給BLAS或GPU,任何人都可以進行基本的快速陣列處理。

但是並不是每個人都有標量類型參數化的數組類型,以及在兩者中都具有同樣速度的能力。

沒有這個,就無法解開數組代碼和標量代碼。

例如BLAS就沒有此功能,它對標量和矩陣的每種組合都有唯一的代碼。

通過這種分離,可以添加新的標量類型:

  • 雙數

  • 測量誤差跟蹤編號

  • 符號代數

無需改變數組代碼,除非是後期優化。

否則,就需要在標量中實現數組支持,以實現合理的性能。

發明新的語言真的很棒!

人們需要發明新的語言。現在是發明新語言的好時機。這對世界有益,也對我有益,因為我喜歡很棒的新鮮事物。

我真的很喜歡那些新語言能夠有以下特徵:

  • 多次派發至:

    • 允許通過單獨包中需要的任何特殊情況進行擴展。(例如,鴨子會把小鴨子帶到水裡,但會放棄小天鵝)

    • 包括允許添加領域知識(如矩陣乘法示例)

  • 公開類型:

    • 因此你可以在包中為在另一個包中聲明的類型/函數創建新方法

  • 在類型級別按標量類型參數化的數組類型

    • 這樣就不必為提高性能而搗鼓數組代碼和標量代碼。

  • 每個人都使用的內置包管理解決方案。

    • 因為這可以提供一致的工具,並對軟件標準產生乘法效應。

    • 像Julia社區中的每個人一樣,編寫測試並使用CI。

  • 不要直接跳到每個文件1個命名空間,要隔離所有東西。

    • 命名空間衝突不要太糟糕

    • 名稱空間的價值是什麼這一點值得思考:超出“名稱空間是一個很棒的主意——讓我們做更多的事情!”

原文鏈接:

https://white.ucc.asn.au/2020/02/09/whycompositionalJulia.html

本文為CSDN翻譯文章,轉載請註明出處。

☞字節跳動 5 萬人遠程辦公的背後,飛書的演進之路

☞雷軍親曝小米 10 四大猛料!

☞中文版開源!這或許是最經典的Python編程教材

☞升級到架構師,程序員無需過度關注哪些技能?| 程序員有話說

☞數據分析如何幫助揭示冠狀病毒的真相?

☞2020年區塊鏈領域最具影響力人物Top 20


分享到:


相關文章: