作者丨Erik Engheim
譯者 | 盧鑫旺
審校丨諾亞
Julia作為一門編程語言,雖然發展很快,但其生態系統仍有進步空間,加上Julia把重點放在了科學計算這一相對小眾的領域,因而關注度不如Python等熱門語言。但是,這些事實都無法掩蓋Julia在科學計算領域的巨大的優勢。
多重派發(multiple dispatch)是Julia編程語言的殺手級特性,不過卻幾乎沒有開發人員聽說過它, 更鮮有人知道它是什么以及是如何工作的。這不奇怪,因為很少有語言支持多重派發,而那些能支持多重派發的語言又往往很好地隱藏了它。因此,在我大談特談多重派發的厲害之前,我必須先解釋它到底是什么。
我先給你一個提示:它與函數的調用方式有關,讓我們來后退一步來詳細說明。
當程序運行并遇到函數調用時,它必須找出并跳轉到要執行的代碼。在一些過程編程語言(如C或者Pascal)中,這個過程很直接。每個函數都被實現為一個子例程,在內存中有唯一的位置,調用函數只需跳轉到子例程的內存地址,并執行每個指令即可,直到處理器遇到返回指令。
在處理函數指針時,事情變得有些棘手。我們跳轉到的子例程可以在運行時期間更改,因為代碼允許更改函數指針中存儲的子例程地址。我為什么要提到這些細節?因為我想表達的是,調用函數并決定執行什么代碼并不總是一件小事。
思考一下在面向對象編程中要調用一個方法的復雜性。
比如我們定義了一個叫“戰士”的類Warrior,Warrior類中的成員函數 attack 并不是對應到一個有指定內存地址的子例程。當attack方法被一個warrior對象調用時,決定跳轉到哪個子例程的復雜過程就會啟動。我們必須確定是哪一個Warrior類的實例化對象在調用attack方法。你可以想象不同類型層次結構的“戰士”類的實例化對象,比如弓箭手,槍手或者騎士。
上圖是具有不同屬性的“戰士”類的對象的類型結構
因為弓箭手的攻擊方式不同于長槍手或騎士,所以不同的“戰士”類的對象的攻擊方法都不一樣。通過一個稱為單一派發的過程,我們決定調用哪個方法。從低級的角度來看,我們試圖確定在執行warrior.attack(knight)這條語句時跳轉到哪個子例程。
單一派發如何工作取決于我們討論的是動態類型語言還是靜態類型語言。我們看一下它在動態類型語言中的工作原理,因為我們將把這個過程和Julia語言進行比較,同時后者也是一種動態類型的語言。
想象我們有兩個Warrior類的實例化 對象warrior a和 warrior b,a戰士正在攻擊b戰士。我們的第一步是要確定戰士a的類型是什么。在動態類型語言中,每個類對象都知道它的類型是什么。以Object-C語言為例,每個對象都有一個叫“isa”的屬性,這個屬性指向了一個類對象來描述當前對象是一個什么類型。在下圖中,我們模擬了這個過程,a戰士是Archer類的實例化對象,Archer類包含了每個實現方法的函數指針,為了找到正確的方法,我們對”attack”方法進行字典查找。
動態類型語言使用單一派發來定位要執行的代碼
上圖中方法名末尾的感嘆號可能看起來很奇怪。不用擔心,這只是一種命名約定,在Lisp語言和Julia語言中很流行,用于更改函數。它沒有語義。
嚴格地說,在大多數動態語言中談論函數指針是錯誤的。例如,在Ruby中,你實際上并沒有指向任何具有機器代碼的子例程,而是指向通過解析方法生成的抽象語法樹(AST)。Ruby解釋器解釋AST以運行方法中的代碼。
y=4*(2+x)的語法樹(AST)
我們剛才討論的稱為單一派發(single dispatch),因為由我們自己根據單個對象決定調用什么方法。對象b的類型不會以任何方式影響方法查找過程。相比之下,對于多重派發,函數調用中的每個參數都在決定選擇調用哪個方法中起了作用。我知道這聽起來很奇怪,所以讓我通過解釋單一派發的問題來給你一個使用多重派發的動機。
多重派發解決了什么問題?
我們用Julia編寫了一個battle!函數,它通過調用attack!函數來模擬兩個戰士a,b進行戰斗,并根據結果將信息打印出來。下面的大部分代碼是易懂的。在Julia中,我們用::來把變量名與變量類型分開。因此,在示例代碼中,a::Warrior是在告訴Julia battle!函數有一個名為a的Warrior類型的參數。
觀察上邊的代碼并問自己這樣一個簡單的問題:類似的代碼在C++或者Java中是否有效?乍一看,這似乎是可能的。這兩種語言都允許你定義具有相同名稱但不同參數的多個函數,你可以編寫類似下面的Julia代碼的代碼 :
代碼的細節并不重要。我想讓你從這個代碼示例中了解到的是,我們已經定義了三個attack!函數。每個定義接受不同類型的實參。在C++和Java中,我們稱這個函數為重載。在編譯時,編譯器將通過檢查調用站點上每個輸入實參的類型來選擇要調用的適當函數。
關鍵點是:C++編譯器不可能猜出battle!函數調用的是哪個attack!函數,因為它不知道實參a和b的具體類型。編譯器只知道這兩個實參都是Warrior類型的某個子類型。至于到底是哪個子類型只能在代碼實際運行時確定。這是一個遺憾,因為函數重載只在編譯時工作。
在這種情況下,多重派發可以做單一派發和函數重載都不能做的事情:它可以在運行時根據參數a和b的類型選擇正確的代碼。
多重派發是如何工作的?
還記得如何通過在運行時查找正確的方法來完成單一派發嗎?多重派發也是關于如何選擇正確的方法。你剛才看到的attack!定義實際上不是函數定義,而是方法定義。在定義attack!函數時,你可以這樣寫:
為什么沒有參數呢?因為在Julia中函數沒有參數,只有方法中有參數。與面向對象的語言不同,Julia中的方法是附加到函數而不是類上的。
因此,Julia中的函數調用首先通過查找被調用的函數來執行。Julia在每個函數上注冊一個方法表。從上到下搜索這個表,以找到一個方法,該方法接受與函數調用站點提供的輸入實參類型相匹配的實參類型。
函數被調用時Julia如何使用多重派發
來定位正確執行的代碼
Julia是一種即時(JIT)編譯語言,因此方法源代碼需要幾個步驟才能轉化為可執行的機器碼:
1.當Julia文件加載到內存中時,將解析每個方法的源代碼并將其轉換為抽象語法樹(AST)。
2.每個方法的AST都存儲在正確函數的正確方法表中。
3.在運行時,當一個方法被定位時,我們首先獲得AST, AST被JIT編譯器轉化為機器碼并緩存以供以后查找。
這個過程實際上比我在這里展示的要復雜得多。你可以看到,抽象語法樹可以非常通用。它可以是為數字參數定義的計算。無論參數是16位無符號整數還是32位有符號整數,執行的計算都是相同的。但是,這些情況的程序集代碼看起來不一樣。因此,同一個AST可以產生多個機器碼子例程。Julia將為方法表中的每個案例添加一個條目。因此,方法表并不局限于為其編寫源代碼的方法的數量。
什么讓Julia的多重派發獨一無二
每次調用Julia中的函數時,都會執行一個方法查找。或者更確切地說,從Julia開發人員的角度來看,情況就是這樣。代碼運行時就好像每次都是這樣。
在支持多重派發的其他語言中,情況并非如此。只有以特殊方式標記的函數才使用多重派發。否則,將執行常規函數調用。為什么其他語言限制了多重派發的使用?因為在Julia到來之前,多重派發非常慢。
不難想象為什么多重派發會比較慢。您可能需要通過一個大表進行線性搜索O(N)的時間復雜度,而不是在常數時間內進行單個字典查找O(1)。函數可以有一個巨大的方法表。
Julia是如何規避這個問題的?Julia的設計理念是盡可能保持類型的穩定。在Python或JavaScript等語言中,情況并非如此。可以在運行時添加或刪除字段和方法。單個字段的類型可以更改。在Julia身上,類型被設計得更加固定。定義復合類型時,需要固定字段及其類型的數量。
這種設計選擇是如何影響多重派發的?這意味著由Julia JIT編譯器完成的代碼分析變得容易得多。代碼的行為變得更加可預測,這使得有可能識別更多的情況,在調用函數時應該定位的方法變得完全確定和可預測。記住,如果函數調用的參數類型保持不變,那么Julia將始終查找相同的方法。如果代碼分析可以確定函數的哪些參數永遠不會改變,那么JIT編譯器就可以用直接的函數調用替換多分派查找。如果代碼很短,甚至可以內聯。
因此,Julia成功地將一開始的性能劣勢變成了性能優勢。因此,Julia函數調用通常比面向對象語言中的單一派發調用要快得多。
一旦你達到了閃電般的速度,那么在你的編碼風格發生變化的任何地方都可以使用多重派發。始終保持多重派發對Julia社區中的軟件工程實踐產生了深遠的影響。
通過多重派發重用代碼
面向對象語言的用戶通過繼承類和實現接口來重用代碼,這允許將新代碼插入到現有框架中。Julia方法是在函數級重用。不同的開發人員都可以向相同的函數添加方法。我們不擴展類,而是擴展函數。因為函數存在于較低的粒度級別,所以我們有更多的機會進行代碼重用。
這種靈活性的一個簡單例子是Julia標準庫中定義的show函數。Julia使用它在不同的上下文中顯示一個值。上下文可以是REPL(交互式命令行)、筆記本或IDE環境。匹配以下兩個簽名的方法可以添加到show函數中:
io對象表示用于顯示值x的目標。io可以是控制臺窗口、文件、文本字符串、套接字或圖形顯示。值x可以是簡單的數字、日期、文本字符串或更復雜的對象,如字典或數組。
與面向對象的編程語言不同,你可以沿著多個維度擴展顯示功能。你可以為全新的IO子類型添加show方法,以在新的上下文中顯示現有的值類型。假設我們創建了特殊類型來表示溫度單位攝氏度、華氏度和開爾文。可以添加方法來顯示,以便用正確的單位顯示代表溫度的數字。
注意,在Julia中可以用等號定義一行函數。
為了理解這個擴展機制為何如此強大,請允許我指出一些你試圖使用面向對象編程復制這個擴展機制時會遇到的問題。你設計一個系統,其中每個對象都必須實現一個顯示方法來顯示,但這種選擇會導致幾個問題:
- 所有的類都必須繼承一個帶有show方法的基類。
- 每個對象將在每個IO對象類型上獲得相同的表示。
也就是說:許多面向對象的系統最終都有過于復雜的基類。原因是你想為每個對象支持太多的功能:
- 在不同的上下文中可視化一個對象,比如在調試器中
- 用于打印或存儲到文件中的文本表示
- 為了允許使用集合中的對象使用哈希函數
例如,你可以在Java和Objective-C中找到這種模式。這種做法是僵化的。如果基類設計錯誤,將對所有相關代碼產生嚴重后果。
更不用說,如果語言設計者忘記添加show方法,那么就沒有簡單的方法來改進它。只有對標準庫進行更新才能修復它。作為第三方開發人員,你不能改造解決方案。相反,如果Julia標準庫沒有定義show函數,你可以很容易地自己定義它,并發布一個庫來實現公共對象的可視化,并且你可以將其分發給其他人。
u和v是向量,而A到F是點。向量表示點之間的差。u是點F和E的差。
讓我們多談談I/O系統的問題。假設你已經創建了一個名為Vector2D的2D向量類型。在控制臺中使用時,你可能希望將向量顯示為[4,8],而如果I/O對象表示圖形顯示,則希望顯示箭頭。這兩種選擇在Julia中都是可能的,因為你可以為io參數是一個圖形顯示而x參數是一個2D向量的情況編寫專門的方法。相比之下,面向對象語言只能根據io或x的類型選擇要執行的方法,而不能同時根據兩者。記住,對于單一派發,在運行時調用的方法是基于單個參數的類型選擇的,而不是基于多個參數的類型。
當然,你可以拋出一個switch-case語句來處理不同的類型,但這是不可擴展的。每次添加新類型時,都必須修改switch-case語句。這將阻止你將代碼作為可重用庫分發。庫用戶不應該修改第三方庫的源代碼來擴展它。
多重派發的效用
模擬不同類型的戰士之間的戰斗或者編寫I/O系統當然只是幾種情況,這些情況可以簡化編碼。當我在電子游戲中編寫碰撞檢測代碼時,它第一次發現我需要這樣的東西。不同的游戲對象會用不同的幾何形狀來表示。問題是計算兩個圓,兩個正方形或圓和正方形的交點是完全不同的。你不能只看一個參數就決定要使用的算法,你需要兩個參數。如果沒有多重派發,你的解決方案將變得混亂。
多重派發天然適合來組合不同的幾何對象
多重派發也很適合任何數值工作。對數字的運算通常是二進制的。只看第一個數的類型來決定如何組合兩個數是沒有什么意義的。
簡而言之,多重派發就像一把瑞士軍刀:它幫助程序運行得更快,允許你優雅地解決許多問題,并提供了代碼重用的高級方法。這聽起來可能有點夸張,但我真的相信,多重派發將定義未來的編程范式。
譯者簡介
盧鑫旺,51CTO社區編輯,編程語言愛好者,對數據庫,架構,云原生有濃厚興趣。
原文鏈接:?https://itnext.io/what-makes-julia-unique-f3ad184fa4a2??