從軟件工程的角度比較 Swift、Go 和 Julia,我有了這些發現-

作者 | Erik Engheim

譯者 | 彎月

出品 | CSDN(ID:CSDNnews)

從已有代碼的擴展和重用方面考慮,這幾種語言的類擴展、duck type(鴨子類型)和多分發孰優孰劣?

面向對象編程(OOP)是組織大型程序的方式之一,但並不是唯一的方式。本文將從代碼重用的角度比較 Swift、Go 和 Julia。Swift 採用了 OOP 方式,還支持接口和類擴展。Go 嘗試從新的角度考慮代碼重用問題,在靜態類型語言中引入了 duck 類型。而 Julia 拒絕使用 OOP 範式,而是發明了自己的範式:多分發。 

下面,我們就來看看每種方式的優缺點。

1、Swift 協議和類擴展

如果你想要傳統的面向對象編程,那麼很少有主流語言能打敗 Swift。沒錯,我的意思是,雖然 Java 和 C# 等語言與 Swift 差不多,但都不如 Swift。那麼 Python 如何?Python 是一門優秀的面嚮對象語言。但這種比較不太容易,因爲 Python 是動態類型。而 Swift 是靜態類型,因此與 Java 或 C# 比較更容易。

但究竟是什麼讓 Swift 成爲了一個強力的面嚮對象語言?答案是類擴展和協議。下面是最近的一段代碼中的例子。我需要在有序數組中支持二分查找。這樣就能在 O(log N) 的時間內找到數組中的某個元素,例如 8,同時不需要檢查每個元素。下面是 Swift 代碼:

let xs = [2, 4, 8, 10]
let i = xs.binarySearch { x in x < 8 }

但 Swift 中的數組並不支持 binarySearch。一些習慣了傳統 OOP 語言的人可能會選擇創建數組的子類。但是這會導致混亂的繼承結構。繼承應當用於表示新概念,而不是用來添加新功能。

但是在 Swift 中,你可以擴展接口(Swift 稱之爲協議)來添加新方法。Swift 的 Array 類實現了 RandomAccessCollection 接口,它屬於標準庫。但我們可以向這個接口添加任何新方法。我們甚至可以提供默認實現:

extension RandomAccessCollection {
    func binarySearch(predicate: (Iterator.Element) -> Bool) -> Index {
        var low = startIndex
        var high = endIndex
        while low != high {
            let mid = index(low, offsetBy: distance(
                    from: low, 
                      to: high)/2)
            if predicate(self[mid]) {
                low = index(after: mid)
            } else {
                high = mid
            }
        }
        return low
    }
}

這意味着 Swift 中任何實現了 RandomAccessCollection 接口的集合類型都可以使用 binarySearch 方法。

你幾乎可以擴展任何東西,而不僅僅是接口。你也可以擴展類、枚舉和結構。NSRange 是一個結構(值語義的類),表示範圍。它可以表示諸如文本視圖中選中的文本,或字體和顏色均相同的一段字符。我編寫了自己的 WrittenDoc 類,用於容納可以打標籤的文本。我希望創建一個範圍,表示從一個標籤到下一個標籤。因此只需要擴展 NSRange 結構,給它一個額外的初始化器:

extension NSRange {
    init(doc: WrittenDoc, tag: Tag) {
        self.init(doc.charIndex(ofTag: tag)..<doc.scriptText.count)
    }
}

因此,代碼可以這樣寫:

let nextTag : Tag = tags[i+1]                
let range : NSRange = NSRange(doc: self.writtenDoc, 
                              tag: nextTag)

這個特性可以實現許多想都不敢想的事情。例如,在 Swift 中,一個字符串可以自己在屏幕上描繪自己。聽起來這不像是正確的設計,因爲這樣需要把 GUI 和圖形代碼放到核心庫中。但是,Swift 並不是這樣做的。如果只導入 Foundation 庫,描繪功能是無法使用的。

但是,如果還導入了 Cocoa 庫,就會包含一個針對 String 的類擴展,允許字符串描繪自己。這樣,擴展就可以應用到不同的庫中。圖形相關的代碼可以專門放到圖形庫中,但依然可以擴展基本類型。

這有什麼用呢?它能解決訪問者模式之類貌似有點彆扭的模式。

2、Go 中的 duck 類型

當然,面向對象編程並不是解決問題的唯一方法。Go 使用了面向對象編程的元素,但儘可能保持簡單。

在 Go 中,你不需要明確標示出對象實現了接口;只要它包含接口中列出的所有方法,就自動實現了該接口。因此,只要擁有類似 Write 方法的類都實現了 Write 接口。

type Writer interface {
        Write(p []byte) (n int, err error)
}

因此在 Go 中,你可以從已有的庫中發明新接口,它就會自動實現,而不需要專門設計接口。這一點非常像 Swift。

但與 Swift 不同,你不能擴展已有類型。相反,Go 的方法是使用自由函數爲簡單接口添加功能。舉個例子:

func main() {
    filename := "rocket-engine.txt"
    file, err := os.Create(filename)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Could not create %s because: %v\n", filename, err)
        os.Exit(1)
    }
    defer file.Close()
    engine := "RD-180"
    var thrust float64 = 3830
    fmt.Fprintf(file, "%s has thrust %0.1f\n", engine, thrust)
}

這裏可以看到,fmt.Fprintf 函數用來向 stderr 和打開的文件寫入格式化後的文本。fmt.Fprintf 關心的只是第一個參數有沒有遵循 Write 接口。你還可以在此基礎上構建更多功能,從而爲所有實現了 Write 接口的類型創建更復雜的功能。

這種方法的侷限性是,你無法爲不同的類型創建不同版本的 Fprintf。在 Swift 中這樣做是可能的。在 Swift 中,只需添加一個支持 Fprintf 的接口擴展,就能提供一個默認的實現,就像 Go 中的自由函數一樣,但是可以爲特定的類型提供一個特殊的 Fprintf。但 Go 沒有辦法解決這個問題,除非使用並不太優雅的類型切換語句。

3、Julia 中的多分發

雖然 Swift 中的面向協議的編程和擴展非常強大,但我仍然要說,Julia 的多分發在組織和重用代碼方面有更強大的範式。

下面來解釋一下它的工作原理。我們假設 Go 和 Swift 都有類似於 Julia 的類型和接口,這樣方便我們看到其中的區別。假設 Swift 和 Go 都有一個 show 函數或方法、IO 接口和 Any 接口。

在 Julia 中對錶達式求值,即可獲得一個對象,該對象會在 REPL(交互式命令行環境)中顯示如下:

julia> xs = [3, 4, 8]
3-element Vector{Int64}:
 3
 4
 8

在 Julia 的提示符下,我創建了一個擁有 3 個元素的數組,存儲在變量 xs 中。按回車鍵之後,Julia 輸出了該數組的描述。在 Julia 中,這是通過調用 show 方法完成的。當命令行需要顯示一個對象時,就會調用其 show 方法。上例中調用方式大致如下:

show(output, xs)

它有兩個參數。output 表示控制檯,xs 是要顯示的對象。Julia 提供的默認實現大致如下:

function show(io::IO, obj::Any)
    # implementation code goes here
end

該實現使用內省來確定類型擁有的字段並輸出。因此,如果在 Julia 中創建自定義類型並初始化,就能得到一個合理的默認顯示結果,如下面的構造函數調用:

julia> struct Point
           x::Int
           y::Int
       end
julia> p = Point(3, 4)
Point(3, 4)

但是,我可以通過重載 show 方法來改變 Julia 中的顯示:

import Base:show
function show(io::IO, p::Point)
    print(io, "<", p.x, ", ", p.y, ">")
end

現在,在 REPL 中顯示 p,就會得到不同結果:

julia> p
<3, 4>

到這裏一切都還好。Swift 也可以做到這一切。假設它也支持 Any 接口,那麼可以定義 show 方法如下:

extension Any {
   func show(io: IO) {
     // implementation code goes here
   }
}

然後 Point 類可以這樣寫:

extension Point {
   func show(io: IO) {
      io.print("<", self.x, ", ", self.y, ">")
   }
}

但接下來 Swift 就力不從心了。假設我們需要通過 UDP 套接字發送一個 Point 的文本表示,需要發送一個特殊的表示。在 Julia 中可以這樣寫:

function show(io::UDPSocket, p::Point)
    print(io, "(", p.x, ", ", p.y, ")")
end

這樣就能通過 UDP 套接字發送 (3, 4) 而不是 < 3,4>。這個例子的確不太恰當,但可以演示爲何多分發要更強大。

另一個更明顯的例子就是處理不同幾何形狀的交點。在遊戲編程中經常會用到這一功能,來判斷不同幾何體之間的碰撞情況。幾何體可能是正方形、圓形或多邊形。Julia 可以定義一個方法來處理所有情況:

collide(a::Circle, b::Triangle)
collide(a::Square, b::Circle)
collide(a::Polygon, b::Square)

實際上這正是 Julia 中數值提升系統的工作原理。它會找到多個數值類型中的最小公倍數。

4、多分發的缺點

多分發看起來很不錯,但它有什麼缺點呢?OOP 的優點是方法永遠屬於對象。只要有對象,就能查找其方法。但在 Julia 中,方法屬於函數,而不是對象(準確地說是類型)。

如果很熟悉 OOP 的話,“方法” 這個術語可能會讓你感到迷惑。看一下 collide 的例子,它演示了 collide 函數的三個不同的方法。方法是函數的具體實現。一個函數可以有多個實現,每個實現都可以有不同的參數個數和類型。

因此,如果有一個 Circle,我無法得知它有 collide 方法,因爲方法屬於所有的參數。

這也意味着 Julia 中很難獲知一個對象是什麼。Julia 並不像 OOP 語言那樣,對每個對象及其所有方法有集中的定義。但是可以認爲,Julia 的思想更函數式一些。你不需要關心對象,只需要關心函數及函數的行爲。OOP 關注的是名詞,而 Julia 關注的是動詞。

參考鏈接:

https://erik-engheim.scribe.rip/software-engineering-in-swift-go-and-julia-compared-5937bcb63143

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/U_J_YNxOEMiLWWp1SsZxVQ