本文轉載自《》,作者許式偉(@xushiwei)是七牛雲創始人兼 CEO,創造了 Go+ 語言。
去年(2021年)Go+ 的 slogan 從 「面向資料科學」 的語言升級到了 「面向工程、STEM 教育與資料科學」 三位一體的語言。也就是說,我們希望 Go+ 可以同時被軟體工程師、中小學生、資料分析師這三個截然不同的人群所廣泛使用。
對 Go+ 來說,「面向資料科學」 這個目標註定有非常長遠的路要走。所以去年 Go+ 的版本迭代主要精力都花在了 「低門檻」 上。我們努力讓 Go+ 的使用門檻低到和 Python 相當的水平。這是從 Go+ 作為 「面向 STEM 教育」,作為一種中小學生就能夠學習和掌握的教學語言而做出的努力。我認為這些努力對 Go+ 的發展來說非常重要。2022 年我們仍然會繼續去加強 Go+ 在低門檻方向上的工作。
但是在去年年底的時候,我們將 Go+ 下一個里程碑(v1.2)的版本計劃做了非常巨大的調整:我們從之前的一籮筐的 Go+ 生態發展的計劃,調整成為就幹一個非常硬的硬骨頭:實現對 C 語言的完美支援。
為什麼需要做這個改變?
因為到目前為止,Go+ 從 「面向工程」 這個目標來說,它只是 「更好的 Go」。客觀來講,這個 「更好」 並不足以讓軟體工程師們心動到改變自己的習慣,採用 Go+ 來進行日常的開發工作。這個 Why Go+ 如果回答不好,那麼 Go+ 的面向工程這個目標就僅僅停留於口號。
Go 語言足夠好,在它擅長的伺服器端開發領域上,你甚至幾乎可以肯定地說它是最好的。但是在其他所有領域,無論是 PC、Mobile、Web、小程式、嵌入式、區塊鏈與 Web3、巨量資料與 AI、程式設計教學領域等等,Go 都顯得不夠好。如果用學生來打比方,Go 像是一個偏科偏的非常嚴重的學生,有一門學科特別擅長考 99 分,但是其它學科都只是勉強的及格線。
是什麼制約了 Go 語言在其他領域的發展?
我們以 Web3 為例。這很可能是與伺服器端開發最為接近,Go 最容易取得壓倒性優勢的領域。但是實際的戰績如何呢?我花時間對幾十個頭部的 Web3 專案進行了分析,最後的統計結果很意外:在這個領域 Go 語言的確採用率很高的確沒錯,但是有另一個語言 Rust 的採用率也很高,兩者的採用率基本上接近 1:1,甚至一些專案同時採用 Go 和 Rust。
為什麼會這樣?在正統的伺服器端開發中 Rust 的採用率可能連 Go 的 1/10 都沒有,為什麼一個看起來完全類似的同樣都是網路應用類的場景,其採用率居然有這麼大的差別?
我知道一些人把這個事情歸因到 Go 是 GC 語言,Rust 效能更好這一點上。但我認為這並不成立。如果對網路服務效能是重要的,那麼在伺服器端開發(尤其是雲端計算)這樣一個成本很敏感的領域,Rust 應該比 Go 更有市場競爭力才對。
我認為最有可能的關鍵因素是兩個:
-
語言帶給人的安全感(更少的安全漏洞);
-
C 語言的相容性。
Rust 的確能夠帶來更好的安全感(至少表面上看是這樣),但這個因素能夠影響到底有多少不得而知。但我認為 C 語言的相容性是更為重要的因素。Web3 是一個快賽道,發展日新月異,大家都會搶時間。從搶時間這個角度來看,只對比語言角度 Go 比 Rust 有優勢得多。是什麼原因讓大家覺得用 Rust 開發起來更快?
因為 Rust 能夠讓軟體工程師們複用大量的 C 語言的社群資源。程式語言史發展到今天,哪門語言資源最多我想不用我在這裡多費口舌,對於 Web3 這樣一個日新月異的新領域來說,能夠複用既有 C 語言的資源,可以輕易打敗 Go 僅僅靠語言特性本身形成的便捷性優勢。
簡單一句話,cgo 太雞肋,與 C 語言的相容上,Go 也就是做到了聊勝於無而已。
這裡 Web3 只是一個例子。無論進入到任何伺服器端之外的新領域,對 Go 來說,相容 C 都是至關重要,沒有之一。
想清楚了這一點,Go+ 面向工程領域的第一個執行目標就出來了:實現對 C 語言的完美相容。要麼讓 cgo 變好,要麼提供一個遠超 cgo 的新的相容 C 語言的方案。
這就是去年年底,為什麼我們調整 Go+ v1.2 版本的迭代目標的原因。
目標有了,但是怎麼做到呢?剛剛過去的這個春節裡我幾乎每天一有空就在思考這個問題。為此我查閱了大量的 github 上的程式碼,基本上圍繞著 c2go、c2goasm、cgo、binary/asm2go、bytecode/vm2go 這些話題。
最終,我沒有選擇優化 cgo,而是選擇了:c2go。簡單說,就是把 C 程式碼轉換為 Go 程式碼,然後重新用 Go 編譯器進行編譯。
當我把要做 c2go 這個想法發到 Go+ 貢獻者群的時候,大家第一反應都是覺得不可能,這太難了。
這條路並非沒有人走過。
大家都知道,Go 團隊自己就做過這事(參考:),當然他們的目標並不是做一個通用版本的 c2go,而是要實現 Go 的自舉,把 Go 編譯器(也包括執行時等)所有的 C 程式碼都轉成 Go。所以它的程式碼可以針對 Go 編譯器寫特殊的轉換規則,只要滿足不別人肉去改編譯器就好。
第三方做得最好的是 這個專案。它從 2017 年 2 月開始做,到今天有 5 個年頭了,最初作者雄心勃勃,給自己定了一個小目標:在沒有任何人工干預的情況下實現 sqlite3 到 Go 程式碼的自動轉換。然而這個目標至今沒有實現。
但是我認為它的選擇的大方向思路是對的。這個 c2go 專案把整個轉換過程分為這樣幾個步驟:
首先,用 C 語言的預處理程式(preprocessor)解決掉宏和各種預編譯指令。這樣,我們就只需要處理最純粹的 C 程式碼。C 語言的 spec 非常的精簡,比 Go 語言的 spec 還要短小很多,並且每個語法的功能與 Go 有足夠強的相似性,這意味著這一步下來後,我們工作量相對可控許多。
這一步是重要的,它意味著平臺相關的工作都在這一步幹了,我們後續的步驟不用考慮跨平臺。每個平臺預處理結果產生的 C 程式碼可以不同,從而轉換生成的 Go 程式碼也不同,這是可接受的。
其次,生成的 C 程式碼再通過 llvm project 中的 clang 命令列進行解析(parser):
clang -Xclang -ast-dump=json -fsyntax-only [C原始檔...]
這樣就得到 C 語言的抽象語法樹(C AST)。這一步自己做也可以,但是既然有現成的,能夠先省心就先省心吧,以後有空了再改寫不遲(注意 github.com/elliotchance/c2go 這個專案用的是 -ast-dump 開關,而不是 -ast-dump=json,這樣就不得不在再寫一個額外的文字解析程式碼來解析 clang 的輸出)。
接著,就是將 C AST 轉為 Go AST。這一步最為核心,我們的大部分工作都集中在這裡。類似的工作我們在 Go+ 中也已經做過了,只不過我們之前做的是 Go+ AST 轉為 Go AST 而已。考慮 C 語言的 spec 比 Go+ 語言 spec 要小很多,我們心裡對工作量就有了大體的估計。
這一步會有少量的技術難點,比如 C 的指標是可以移動的,比如 C 語言有 union 資料型別。但總體來說這些語法都不是特別複雜,工作量是可控的。
C 是手工管理記憶體的,這點在轉為 Go 後可以仍然保持不變。C 語言的 malloc 函數可以從 Go 程序裡面劃出一片記憶體來進行手工管理。
還有一些 corner case,比如在 C 程式碼裡面插入組合指令的。我們可以考慮引入一個通用的過濾機制來解決:在 c2go 轉換過程的組態檔中,指定哪些函數或 C 原始檔應該被忽略,然後在該工程中加入一個手工實現的 Go 原始檔來自己實現這些被忽略的函數即可。
在介紹 Go+ 編譯器實現原理(請移步 bilibili 搜尋 "Go+ 公開課 · 第1期|Go+ v1.x 的設計與實現" 進行檢視)的時候,我提到過 github.com/goplus/gox 這個專案,它是用來輔助生成 Go AST 的模組。在 C AST 轉為 Go AST 中,我們也會藉助它來大幅減少開發工作量。
生成了 Go AST,剩下來的工作就和我們在 Go+ 一樣了,通過 Go AST 呼叫 go/format 這個標準庫來生成 Go 原始檔,最後用 Go 編譯器編譯它。
在我解釋以上 c2go 的全過程後,小夥伴們開始相信這條路走得通。
當然,在我內心中,還有一個更為重要的理由:我們有機會幹得比 cgo 更好。如果選擇優化 cgo 的話,是達不到這個目標的,我們唯一能夠做的只是讓 cgo 更快。
如何讓 Go+ 對 C 語言的支援更好?
在 Go+ 與 C 語言的互操作性上,我有以下這些核心的考量準則:
其一,C 程式碼不需要經過額外包裝,可以直接由 Go+ 來呼叫。以 Hello world 這個經典程式為例,Go+ 呼叫 C 標準庫的 printf 程式碼如下:
import "C"
C.printf(C"Hello, world\n")
它挺像 cgo,但是它並不是。你可以拿它和cgo 的程式碼對比一下:
package main
/*
#include <stdio.h>
#include <stdlib.h>
*/
import "C"
import "unsafe"
func main() {
cstr := C.CString("Hello World\n")
defer C.free(unsafe.Pointer(cstr))
C.printf(cstr)
}
可以看到,在 Go 語言中 import "C" 並不是真匯入一個包,而只是說這裡有 C 程式碼被插入。相比 Go 語言,Go+ 對 C 的支援更像是把 C 看做 Go+ 原生程式碼的一部分,而不是外來者。具體體現在:
-
不用寫任何 C 程式碼就可以參照。C 實現的模組如同 Go 實現的模組一樣,就是正常的一個 package 而已。import "C" 表示匯入 C 語言的標準庫(當然不同平臺不同 C 編譯器的 C 標準庫並不完全相同,這個考慮在 gop.mod 中對 C 庫這個模組進行版本系結)。
-
引入了 C 的字串常數語法 C"...",以增強 C 函數使用上的便捷性。程式碼中的 C"Hello, world\n" 等價於 []int8{'H', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd', '\n', '\0'},是一個以 '\0' 為結尾的 C 字串。
那麼對於非 C 標準庫,比如類似 sqlite3 這樣的知名 C 庫,怎麼匯入?我們設想是這樣的:
import "C/sqlite3"
sqlite3.XXX(...)
那麼如果是使用者自己實現的 C 庫呢?嗯,最通用的形式是這樣的:
import "C/github.com/foo/bar"
bar.XXX(...)
很簡潔,和 Go 語言寫的庫看起來沒啥大區別,對吧?
其二,C 與 Go 的型別系統儘可能一致,以降低互操作的成本。在 C 語言中的 void (*)() 到 Go 裡面就是 func(),兩者屬於同一個 type。其他還有 C 語言的 int 型別也是如此。不考慮引入 C.int 這類資料結構,它就等同於 Go 語言的 int。為此 Go+ 還會引入內建的 char 型別,實際上它只是 int8 的別名。
至於要不要引入 union,我估計大概率 Go+ 不支援 union 概念。但是 C 程式碼中定義的 union 型別,在 Go+ 中可以識別。比如 sqlite3.XXX 如果是一個 union 型別,那就會有正常的 union 行為,包括可以正確存取其中的成員變數。但是我們不允許在 Go+ 原始檔中顯式定義一個 union 型別。
在 C 語言中,函數會有呼叫約定(Call Convention)的概念。最常見的呼叫約定有 cdecl 和 stdcall。其中,cdecl 是 C 函數預設的呼叫約定,它的好處是支援類似 printf 這種允許有不定引數的函數。而 stdcall 是 Windows API 的呼叫約定,這個呼叫方式有時候會被叫做 pascal,這當然是因為它與 Pascal 語言的函數呼叫約定一致的原因。
這種函數的呼叫約定的差異在翻譯成 Go 程式碼後會被取消。也就是說,void (cdecl *)() 和 void (stdcall *)() 這兩種函數指標型別在 C 語言裡面是兩個完全不同的型別並且無法相互轉換(強制轉換後進行函數呼叫會導致程式崩潰),但是翻譯成 Go 後就都是 func(),沒有區別。
其三,翻譯成 Go 程式碼後的程式語意,也就是 C 程式設計師對 C 形成的常規的語意理解都仍然正確。包括:C 字串以 '\0' 為結尾,C 是手工管理記憶體的等等,這些都仍然不變。如上面已經提到過的那樣,在實現上 C.malloc 會從 Go 程序中劃出一片記憶體來進行手工管理。
最後總結就一句話:我對這個 c2go 專案相當認真。目前它已經放到 github 上 goplus 這個組織裡面了,連結如下:
我覺得這事對 Go 未來十年的生態繁榮是至關重要的一項工作,否則 Go 就真的只能偏安在伺服器端開發領域了。
當然從 Go+ 的角度來說,它是我們在 「面向工程」 領域的目標下,給軟體工程師們交出的最重要的一份答卷。