為什么 Go 不是一款好的編程語言
我喜歡 Go. 常用它實現各種功能(包括在寫本文時的這個博客). Go 很實用,但不夠好。 不是說它有多差, 只是沒那么好而已。
一門編程語言, 也許會用上一輩子, 所以選擇的時候要注意。
本文專注于 Go 的各種吐槽。 老生常談的有之,鮮為人知的也有。
我用 Rust 和Haskell 作為參照 (至少, 我以為, 這倆都很不錯)。 本文列出的所有問題, 都有解決方案。
常規編程
那么問題來了
我們寫代碼可以用于許多不同的事情。假如我寫了一個函數用來對一列數字求和,如果我可以用該函數對浮點數、整數以及其他任何類型進行求和那該多棒。如果這些代碼包含了類型安全并且可以快速的寫出用于整型相加、浮點型相加等的獨立函數就更完美了。
好的解決方案:基于限制的泛型和基于參數的多態
到目前為止,我遇到的最好的泛型編程系統是rust和haskell所共用的那個。它一般被稱作”被限制的類型“。在haskell中,這個系統被稱作”type class“。而在Rust中,它被稱作”traits“。像這樣:
(Rust, version 0.11)
fn id<T>(item: T) -> T { item }
(Haskell)
id :: t -> t id a = a
在上面這個簡單了例子中,我們定義了一個泛型函數id。id函數將它的參數原封不動傳回來。很重要的一點是這個函數可以接受任何類型的參數,而不是某個特定的類型。在Rust和haskell中,id函數保留了它參數的類型信息,使得靜態類型檢查可以順利工作,并且沒有為次在運行期付出任何代價。你可以使用這個函數來寫一個克隆函數。
同樣,我們可以應用這種方式來定義泛型數據結構。例如:
(Rust)
struct Stack<T>{ items: Vec<T> }
(Haskell)
data Stack t = Stack [t]
跟上面一樣,我們在沒有運行期額外消耗的情況下得到完全的靜態類型安全。
現在,如果我們想寫一個通用的函數,我們必須告訴編譯器“這個函數只有在它的所有參數支持這個函數中所用用到的操作時,才有意義”。舉個例子,如果我們想定義一個將它的三個參數相加,并返回其和的函數,我們必須告訴編譯器:這三個參數必須支持加法運算。就象這樣:
(Rust)
fn add3<T:Num>(a:T, b:T, c:T)->T{ a + b + c }
(Haskell)
add3 :: Num t => t -> t -> t -> t add3 a b c = a + b + c
在上面這個例子中,我們告訴haskell的編譯器:“add3這個函數的參數必須是一個Num(算數數類型)“。因為編譯器知道一個Num類型的參數支持加法,所以這個函數的表達式可以通過類型檢查。在haskell中,這些限制也可應用于data關鍵字所做的定義中。這是一個可以優雅地定義百分之百類型安全的靈活泛型函數的方式。
go的解決方案:interface{}
Go的普通類型系統的結果是,Go對通用編程的支持很差。
你可以非常輕松的寫通用方程。假如你想寫一個可以打印被哈希的對象的哈希值。你可以定義一個擁有靜態類型安全保證的interface,像這樣:
(Go)
type Hashable interface { Hash() []byte }func printHash(item Hashable) { fmt.Println(item.Hash()) }</pre>
現在,你可以提供給printHash任何Hashable的對象,你也得到靜態類型檢查。這很好。
但如果你想寫一個通用的數據結構呢?讓我們寫一個簡單的鏈表。在Go里寫通用數據結構的慣用方法是:
(Go)
type LinkedList struct { value interface{} next *LinkedList }func (oldNode LinkedList) prepend(value interface{}) LinkedList { return &LinkedList{value, oldNode} }
func tail(value interface{}) *LinkedList { return &LinkedList{value, nil} }
func traverse(ll *LinkedList) { if ll == nil { return } fmt.Println(ll.value) traverse(ll.next) }
func main() { node := tail(5).prepend(6).prepend(7) traverse(node) }</pre>
發現什么了嗎?value的類型是interface{}。interface{}就是所謂的“最高類型”,意味著所有其他的類型都是interface{}的子類型。這大致相當于Java中的Object。呀!(注意:對于Go中是否有最高類型還有爭議,因為Go宣稱沒有子類型。不管這些,保留類比的情況。
在Go里面“正確”構建通用數據結構的方法是將對象設置為最高類,然后把它們放入到數據結構中。大約在2004年,Java就是這么做的。后來人們發現這完全違背了類型系統的本意。當你有這樣的數據結構時,你完全消除了一個類型系統能提供的所有好處。比如,下面這個是完全有效的代碼:
node := tail(5).prepend("Hello").prepend([]byte{1,2,3,4})而這在一個良好結構化的程序里完全沒有意義。你可能期望的時一個整數鏈表,但在某個情況下,一些疲憊、靠咖啡清醒的程序員在截止日期前偶然在某處加入了一個字符串。因為Go里面的 通用數據結構不知道它們值的類型,Go的編譯器也不會改正,你的程序在你失去從interface{}里面捕獲時將崩潰。
相同的問題在任何通用數據結構里都存在,無論是list、map、graph、tree、queue等。
語言可擴展性
問題
高級語言通常有復雜任務的關鍵字和符號簡寫。比如,在很多語言中,迭代一個如數組一樣的數據集合中所有元素的簡寫:
(Java)
for (String name : names) { ... }(Python)
for name in names: ...如果我們能在任何集合類型上操作,不僅僅是那些語言內建類型(如數組),會很美好。
如果我們可以定義類型的相加也會很美好,那么我們可以這么做
(Python)
point3 = point1 + point2
好的解決方案:把運算符視作函數
將內建的運算符和某個特別命名的函數對應起來,亦或將關鍵字視作特定函數的別名,這樣做可以很好的解決該問題。
某些編程語言,像Python,Rust和Haskell允許我們重載運算符。我們只需要給我們自定義的類添加一個函數,自此,當我們使用某個運算符的時候(例如”+“),解釋器(編譯器)就會直接調用我們所添加的函數。在Python中,運算符”+“對應于__add__()函數。在Rust中,”+“運算符在Add這個trait中定義為add()函數。在Haskell中,”+“對應于Num這個type class中的(+)。
許多語言都有擴展關鍵字的方法,例如for-each循環。Haskell沒有循環,但是像Rust,Java和Python這樣的語言中都有”迭代器“這樣的概念使得for-each循環可以應用于任何種類的數據集合結構。
某些人可能會用這個特性做一些很操蛋的事情,這是一個潛在的缺點。例如,某些瘋狂的家伙使用”-“來代表兩個向量之間的點乘。但這并不完全是運算符重載的問題。無論使用何種語言,都可以寫出胡亂命名的函數。
Go的解決方案:沒有
Go語言不支持操作符重載或者關鍵字擴展。
那么如果我們想給其他的東西(例如樹,鏈表)實現range關鍵字的操作怎么辦?太糟糕了。這不是語言的一部分。你這能在內建對象上使用range關鍵字。對于關鍵字make也一樣,它不能給非內建數據結構申請內存和初始化。
最接近這個可以使用迭代器的關鍵字的方式是寫一個包裝函數,這個函數以目標數據結構為參數并返回一個可迭代的對象,我們通過使用這個對象在目標數據結構上迭代(譯者注:參見設計模式中的迭代器模式或C++中的迭代器實現)。但是這樣做可能會很慢并且復雜,而且無法保證不引入其他的bug。
對于這樣一個問題,有人辯解道,“這樣更容易讓人理解代碼,并且我看到的代碼就是真正被執行的代碼。”也就是說,如果Go語言允許我們擴展像range這樣的東西,那么range本身的機制和實現就會變得復雜難以理解。我認為這樣的說法沒有什么營養,因為不管Go是否通過這種方式讓其變得更簡單,更易懂,人們總要進行這種在某些數據結構上進行迭代操作。如果我們不想把實現細節隱藏在range()函數里,我們就要把它隱藏在其他的工具函數里,沒什么改進。所有的好代碼都是易讀的,大多數糟糕代碼讓人很難懂,很顯然Go不能改變這個事實。
基礎案例與失敗條件
那么問題來了
當遇到遞歸的數據結構(如鏈表和樹)時,我們希望找到一個途徑來指出我們到達數據結構的末端。
當遇到可能會執行失敗的函數或包含缺失數據片的數據結構時,我們希望找到一個途徑明示我們遇到的幾種失敗情況。
Go 的方解決案: Nil (和多個返回值)
這回我先說 Go 的, 才好引出其他更好解決方案的討論.
Go 支持 null 指針(nil). 每次看到新的編程語言(如:tabula rasa), 實現這個導致 bug 滿天飛的功能, 我替他們可惜.
null 指針的歷史, 滿滿的都是 bug. 無論是歷史, 還是現實, 我都看不出來, 數據存在內存地址為 0x0 的地方有什么意義. 指向 0x0 的指針通常都有特定的含義. 比如, 返回類型是指針的函數出錯, 會返回 0x0 . 遞歸數據結構把 0x0 當作基底(base case), 如: 樹結構的頁節點, 或鏈表的結尾. 這也是 null 指針在 Go 中的用法.
然而,這樣使用null指針也是不安全的。事實上,null指針是類型系統的后門,它讓你能夠創造某個根本不是所屬類型的實例。程序員有時候會忘記某個指針的值可能是null這個事實,這是一個很常見的情況。在最好的情況下,你的程序會掛掉,而在最壞的情況下,這會產生一個可以被人利用的漏洞。編譯器無法輕易地阻止這種情況的發生,因為null指針破壞了語言的類型系統。
對于Go來說,使用多重返回值這個機制,利用它第二個返回值來返回一個代表“失敗”的值是一個正確也被鼓勵的做法。然而,這種機制很容易被忽略或者誤用,并且在表示遞歸數據結構的時候沒有什么用用處。
好的解決方案:代數數據類型和類型安全的錯誤模式
我們可以使用類型系統來包裝錯誤狀況,基底,而不是試圖打破類型系統。
現在我們想要構建一個表示鏈表的類型。我們想表示兩種情況:我們是否已經到達了鏈表的末尾,某個鏈表的節點上到底有沒有被存放在那里的數據。一種類型安全的方式是分別使用不同的類型來表示這些情況,最后將它們組合成一個單獨的類型(使用代數數據類型)。現在我們有一個叫做Cons的類型來表示一個存放有某些數據的鏈表,一個叫做End的類型來表示鏈表的末尾。我們可以這樣寫:
(Rust)
enum List<T> { Cons(T, Box<List<T>>), End } let my_list = Cons(1, box Cons(2, box Cons(3, box End)));(Haskell)
data List t = End | Cons t (List t) let my_list = Cons 1 (Cons 2 (Cons 3 End))每個類型都為遞歸操作這個數據結構的算法聲明了一個基底(End)。。Rust和Haskell都不允許null指針的出現,所以我們永遠都不會碰到null指針解引用所造成的bug(除非我們做一些很大膽的底層操作)。
這些代數數據結構通過像模式匹配(后面講它)這樣的技術,允許我們寫出非常明了的代碼。
那么,我們如何得到一個可能返回或者不返回給定類型的數據的函數,或是一個可能內部包含或者沒有包含一個給定類型的數據的數據結構呢?也就是說,我們如何將錯誤狀況(failure condition)封裝到我們的類型系統中來呢?Rust使用Option,Haskell使用一個叫Maybe的類型來解決這個問題。
我們想象這樣一個函數,它所作的事情是搜索一個非空字符串的數組,尋找一個以這‘H’開頭的字符串,返回第一個找到的這樣的字符串,如果沒有找到,就返回某種錯誤狀況。在Go語言中,我們可以通過返回nil來表示“沒找到”這個錯誤。但是在Haskell和Rust中,不使用危險的指針,我們就可以安全地完成這個任務。
(Rust)
fn search<'a>(strings: &'a[String]) -> Option<&'a str>{ for string in strings.iter() { if string.as_slice()[0] == 'H' as u8 { return Some(string.as_slice()); } } None }(Haskell)
search [] = Nothing search (x:xs) = if (head x) == 'H' then Just x else search xs我們可以返回一個包含或者沒有包含一個字符串的對象來代替返回一個字符串或者null指針的做法。使用search()函數的程序員也會很清楚地知道這個函數可能會失敗(因為它返回的對象的類型已經這么說了),而且程序員必須處理這兩種狀況,否則報錯。這樣我們就跟null指針解引用所造成的bug說再見了。
類型推導(Type Inference)
問題
給程序中的每個值都指定類型, 有時看起來點過老土。 某些場合, 值的類型顯而易見,如
int x = 5
y = x*2這里的 y 明顯就是整形。更復雜點的,我們甚至可以根據函數的參數類型推斷出它的返回類型(反之亦然)。
出色的解決方案: 通用類型推導(General Type Inference)
Rust 和 Haskell 都基于 Hindley-Milner 類型系統, 他們都很擅長類型推導, 你可以實現像下面這樣好玩的功能:
(Haskell)
map :: (a -> b) -> [a] -> [b] let doubleNums nums = map (*2) nums doubleNums :: Num t => [t] -> [t]函數 (*2) 有一個 Num 類型參數, 返回也是一個Num 類型, Haskell 由此推斷 a 和 b 也是 Num 類型. 最后推斷出, 該函數有若干個 Num 類型參數, 返回若個 Num 類型的值. 這種方式比 Go 和 C++ 的簡單類型推導強大多了. 有了它, 哪怕是結構復雜的程序, 就算我們不聲明這么多顯性類型, 編譯器也能正確處理.
Go 的解決方案 : :=
Go 支持 := 賦值操作符, 用法如下:
(Go)
foo := bar()
它的原理是: 查找 bar() 的返回類型, 然后賦給 foo. 下列代碼的道理也一樣:
(C++)
auto foo = bar();
沒什么稀奇的, 無非省去了人工查找函數 bar() 的返回類型, 在鍵盤上多敲幾個字聲明 foo 的類型那點時間而已.
不變性(Immutability)
問題
不變性是指,在程序生成的時候,設好的值,以后不會再變。 它的優勢很明顯, 能減少因程序某個地方的數據結構改變,導致另一個地方出現問題的概率。
此外對程序優化也有利。
出色的解決方案: 默認使用不變性
程序員應當盡可能使用不可變數據結構。 不變性使得判斷負面影響和安全性變得更簡單。同時也能減少各種 Bug 。
Haskell 默認情況下, 所有的值都是不可變的。改變數據結構就意味著, 在保證正確性的前提下, 重新創建一個新的數據結構。由于 Haskell 采用的是惰性求值(lazy evaluation)和永久性數據結構(persistent data structures), 所以運行的速度還是粉快的。Rust 屬于系統級編程語言。不可能使用惰性求值,也就不能像 Haskell 那樣始終使用不變性。 因此,雖然 Rust 默認情況下,變量的值是不可變的。 但是,在需要的時候, 還是可以將變量設置成可變的。這樣挺好,因為它迫使程序員問自己, 底需不需要將這個變量設成可變的。 這是很好的變成習慣, 對編譯器優化代碼也有好處。
Go 的方案: 無
Go 不支持這項功能。
控制流結構(Control Flow Structures)
問題
控制流結構是高級編程語言有別于匯編的原因之一. 它允許我們在抽象層面, 有條理地控制程序流程. 毫無疑問, 所有高級語言都支持控制流結構, 否則, 我還說個毛啊. 可惜, 有那么幾種相當不錯的控制流結構 Go 不支持.
出色的解決方案:模式匹配和復合表達式
模式匹配配合數據結構或值使用的時候, 效果相當好. 簡直就是 case/switch 的加強版. 我們可以像這樣對值進行匹配:
(Rust)
match x { 0 | 1 => action_1(), 2 .. 9 => action_2(), _ => action_3() };或者像這樣解構數據結構(deconstruct data structures):
(Rust)
deg_kelvin = match temperature { Celsius(t) => t + 273.15, Fahrenheit(t) => (t - 32)/1.8 + 273.15 };上面的例子, 有時也稱作復合表達式. C 和 Go 中的 if 和 case/switch 語句只用來控制程序流程, 不會返回值; 而 Rust 和 Haskell 的 if 和 模式匹配語句則可以. 既然有值返回, 當然也能用來賦給其他東東. 這里給出一個 if 語句的例子:
(Haskell)
x = if (y == "foo") then 1 else 2Go 的方案: C語言風格的無值語句( Valueless Statements)
不是我故意找 Go 的茬; 它確實有幾個不錯的的控制流元素, 如, 用于并行計算的 select. 可惜沒有我鐘愛的復合表達式和模式匹配. Go 唯一支持賦值的語句, 是像這樣的原子表達式 x := 5 或 x := foo().
嵌入式編程
給嵌入式系統編寫程序與在一個有完整操作系統的計算機上編寫程序有很大不同。某些語言相比而言更適合嵌入式編程的需要。
對于不少人贊成Go語言可以給機器人編程這件事我很疑惑。基于一些原因,Go語言并不適合用來為嵌入式系統編寫程序。這一節并不是對Go語言的指責,Go語言并不是被設計用來編寫嵌入式程序的語言。這一章節針對那些吹捧Go語言可以勝任嵌入式編程的人。
子問題 #1:堆和動態內存分配
堆是一塊在運行期創建的可以存儲任意數量對象的內存區域。我們將對堆的使用稱作”動態內存分配“。
通常,在嵌入式系統中使用堆存儲空間是不明智的。較大的內存開銷和需要管理復雜的數據結構是主要的原因,尤其是當你在一塊主頻只有8MHz,RAM只有2KB的MCU上寫程序的時候。
在實時系統(因為某一操作耗時過長就可能會跪的系統)中使用堆也是不明智的,因為對堆上空間的申請和釋放所消耗的時間有很大的不確定性。舉個例子,如果你的MCU正在控制一個火箭的引擎,就在這時,如果一個對棧空間的申請比平常多消耗了幾百毫秒,導致對閥門的錯誤計時,就會發生大爆炸。
還有一些原因致使動態內存分配對嵌入式編程沒有多大用。例如,許多使用堆的語言同時也擁有垃圾收集機制。垃圾收集機制經常會暫停整個程序一會兒,在堆上尋找垃圾(不再被程序使用的內存)并清除它們。這比單純的堆空間申請更加具有不確定性。
好的解決方案:讓動態內存分配成為可選項
Rust語言的標準庫中有很多特性依賴于堆。然而,Rust語言的編譯器支持完全關閉這些有關堆的語言特性,并且能夠靜態地確保這些特性在程序中不被使用。寫出完全不使用堆的Rust程序是完全可行的。
Go語言的解決方案:沒有
Go語言嚴重依賴于對堆的運用。沒有可行的方式讓Go程序完全不使用堆。這不是Go語言的問題。這在Go語言的目的應用領域完全沒有問題。
Go并不是一門實時的語言,通常我們不能擔保合理復雜的Go程序的執行時間。這可能有點費解,我來解釋一下:Go相對而言很快,但不是實時的,這兩個概念非常不同。執行速度快對嵌入式程序來說很重要,但是真正重要的是能否擔保某些操作的最大執行時間,而這恰恰是Go不能預測的。這個問題有很大一部分是Go語言對于堆空間和垃圾收集機制的使用造成的。
Haskell也有相似的問題。Haskell同樣由于對堆的大量使用而不能勝任嵌入式或者實時編程。然而,我沒有看見任何人推薦使用Haskell對機器人編程,所以我不用指出這點。
子問題#2:不安全的底層代碼
當我們寫嵌入式程序的時候,寫一些不安全的代碼(不安全的的類型轉換,或者指針運算)是不可避免的。在C或C++中,做這樣的事情是很簡單的。如果我需要向0x1234這個內存地址寫入0xff這個值來點亮一個LED,我可以這樣寫:
(C/C++)
*(uint8_t*)0x1234 = 0xFF;
這樣做很危險,只有當我們寫非常底層的系統代碼的時候才有意義。這就是Go和Haskell沒有簡單的方式來做這樣的事的原因:它們不是系統編程語言。
好的解決方案:將不安全的代碼孤立開來
注重安全和系統編程的Rust語言有一個非常好的解決方案:unsafe代碼塊。unsafe代碼塊是一種顯示地將不安全的代碼分離出來的方式。我們通過如下的方式在Rust語言中向0x1234地址寫入0xff:
(Rust)
unsafe{
*(0x1234 as *mut u8) = 0xFF;
}如果我們在unsafe代碼塊外面做這樣的事情,Rust的編譯器會警告我們。這樣允許我們在滿足嵌入式編程需要的同時,保持了程序的安全和穩定。
Go的解決方案:沒有
Go語言本來就不是為了做這樣的事而出現的,所以沒有任何內建的支持。
總結
現在你可能會說,“那么為什么你說Go語言不好?這只是一大堆你的抱怨而已。你可以針對任何語言發牢騷。“沒有語言是完美的,這很正確。然而,我希望我的抱怨能在某種程度上說明:
Go語言本質上沒有干了什么新的事情
Go語言本身沒有被良好地設計
Go語言是其他現代編程語言的退化
</ul>