本文主要通過值傳遞和指針、字元串、數組、切片、集合、面向對象(封裝、繼承、抽象)和設計哲學7個方面來介紹GO語言的特性。
1.Go的前世今生
1.1Go語言誕生的過程
話說早在 2007 年 9 月的一天,Google 工程師 Rob Pike 和往常一樣啟動了一個 C++項目的建構,按照他之前的經驗,這個建構應該需要持續 1 個小時左右。這時他就和 Google公司的另外兩個同僚 Ken Thompson 以及 Robert Griesemer 開始吐槽并且說出了自己想搞一個新語言的想法。當時 Google 内部主要使用 C++建構各種系統,但 C++複雜性巨大并且原生缺少對并發的支援,使得這三位大佬苦惱不已。
第一天的閑聊初有成效,他們迅速構想了一門新語言:能夠給程式員帶來快樂,能夠比對未來的硬體發展趨勢以及滿足 Google 内部的大規模網絡服務。并且在第二天,他們又碰頭開始認真構思這門新語言。第二天會後,Robert Griesemer 發出了如下的一封郵件:
可以從郵件中看到,他們對這個新語言的期望是:在 C 語言的基礎上,修改一些錯誤,删除一些诟病的特性,增加一些缺失的功能。比如修複 Switch 語句,加入 import 語句,增加垃圾回收,支援接口等。而這封郵件,也成了 Go 的第一版設計初稿。
在這之後的幾天,Rob Pike 在一次開車回家的路上,為這門新語言想好了名字Go。在他心中,”Go”這個單詞短小,容易輸入并且可以很輕易地在其後組合其他字母,比如 Go 的工具鍊:goc 編譯器、goa 彙編器、gol 連接配接器等,并且這個單詞也正好符合他們對這門語言的設計初衷:簡單。
1.2逐漸成型
在統一了 Go 的設計思路之後,Go 語言就正式開啟了語言的設計疊代和實作。2008 年,C語言之父,大佬肯·湯普森實作了第一版的 Go 編譯器,這個版本的 Go 編譯器還是使用C語言開發的,其主要的工作原理是将Go編譯成C,之後再把C編譯成二進制檔案。到2008年中,Go的第一版設計就基本結束了。
這時,同樣在谷歌工作的伊恩·泰勒(Ian Lance Taylor)為Go語言實作了一個gcc的前端,這也是 Go 語言的第二個編譯器。伊恩·泰勒的這一成果不僅僅是一種鼓勵,也證明了 Go 這一新語言的可行性 。有了語言的第二個實作,對Go的語言規範和标準庫的建立也是很重要的。随後,伊恩·泰勒以團隊的第四位成員的身份正式加入 Go 語言開發團隊,後面也成為了 Go 語言設計和實作的核心人物之一。
羅斯·考克斯(Russ Cox)是Go核心開發團隊的第五位成員,也是在2008年加入的。進入團隊後,羅斯·考克斯利用函數類型是“一等公民”,而且它也可以擁有自己的方法這個特性巧妙設計出了 http 包的 HandlerFunc 類型。這樣,我們通過顯式轉型就可以讓一個普通函數成為滿足 http.Handler 接口的類型了。不僅如此,羅斯·考克斯還在當時設計的基礎上提出了一些更泛化的想法,比如 io.Reader 和 io.Writer 接口,這就奠定了 Go 語言的 I/O 結構模型。後來,羅斯·考克斯成為 Go 核心技術團隊的負責人,推動 Go 語言的持續演化。到這裡,Go 語言最初的核心團隊形成,Go 語言邁上了穩定演化的道路。
1.3正式釋出
2009年10月30日,羅伯·派克在Google Techtalk上做了一次有關 Go語言的演講,這也是Go語言第一次公之于衆。十天後,也就是 2009 年 11 月 10 日,谷歌官方宣布 Go 語言項目開源,之後這一天也被 Go 官方确定為 Go 語言的誕生日。
(Go語言吉祥物Gopher)
1.4.Go安裝指導
Go語言安裝包下載下傳
Go 官網可以進行下載下傳。
選擇對應的安裝版本即可(建議選擇.msi檔案)。
檢視是否安裝成功 + 環境是否配置成功
打開指令行:win + R 打開運作框,輸入 cmd 指令,打開指令行視窗。
指令行輸入 go version 檢視安裝版本,顯示下方内容即為安裝成功。
2.Go語言特殊的語言特性
2.1值傳遞和指針
Go中的函數參數和傳回值全都是按值傳遞的。什麼意思呢?比如下述的代碼:
type People struct {
name string
}
func ensureName(p People) {
p.name = "jeffery"
}
func main() {
p := People{
name: ""
}
ensurePeople(p)
fmt.Println(p.name) // 輸出:""
}
為啥上面這段代碼沒有把 p 的内容改成“jeffery”呢?因為 Go 語言的值傳遞特性,ensureName函數内收到的 p 已經是 main 函數中 p 的一個副本了。這就和 C#中把 p 改為一個 int 類型得到的結果一樣。
那怎麼解決呢?用指針。
不知道其他人怎麼樣,當我最開始學習 Go 的時候發現需要學指針的時候瞬間回想起了大學時期被 C 和 C++指針折磨的那段痛苦回憶,是以我本能的對指針就有一種排斥感,雖然 C#中也可以用指針,但是如果不寫底層代碼,可能寫 10 年代碼都用不到一次。
不過還好,Go 中對指針的使用進行了簡化,沒有複雜的指針計算邏輯,僅知道兩個操作就可以很輕松的用好指針:
- “*“: 取位址中的内容
- “&”: 取變量的位址
var p *People = &People{
name: "jeffery",
}
上述代碼中,我建立了一個新的 People 執行個體,并且通過”&”操作擷取了它的位址,把它的位址指派給了一個*People的指針類型變量 p。此時,p 就是一個指針類型,如果按照 C 或者 C++,我是無法直接操作 People 中的字段 name 的,但是 Go 對指針操作進行了簡化,我可以對一個指針類型變量直接操作其内的字段,比如:
func main() {
fmt.Println(p.name) // 輸出:jeffery
fmt.Println(*(p).name) // 輸出:jeffery
}
上述的兩個操作是等價的。
有了指針,我們就可以很輕松的模拟出 C#那種按引用傳遞參數的代碼了:
type People struct {
name string
}
func ensureName(p *People) {
p.name = "jeffery"
}
func main() {
p := &People{
name: ""
}
ensurePeople(p)
fmt.Println(p.name) // 輸出:jeffery
}
2.2字元串
在 C#中字元串其實是 char 類型的數組,是一個特殊的配置設定在棧空間的引用類型。
而在 Go 語言中,字元串是值類型,并且字元串是一個整體。也就是說我們不能修改字元串的内容,從下面的例子可以很清楚的看出這一概念:
var str = "jeffery";
str[0] = 'J';
Console.WriteLine(str); // 輸出:Jeffery
上述的文法在 C#中是成立的,因為我們修改的其實是字元串中的一個 char 類型,而 Go 中這樣的文法是會被編譯器報錯的:
str := "jeffery"
str[0] = 'J' // 編譯錯誤:Cannot assign to str[0]
但是我們可以用數組 index 讀取對應字元串的值:
s := str[0]
fmt.Printf("%T", s) // uint8
可以看到這個傳回值是uint8,這是為啥呢?其實,在 Go 中,string 類型是由一個名為rune的類型組成的,進入 Go 源碼看到rune的定義就是一個 int64 類型。這是因為 Go 中把 string 編譯成了一個一個的 UTF8 編碼,每一個 rune 其實就是對應 UTF8 編碼的值。
此外,string 類型還有一個坑:
str := "李正龍"
fmt.Printf("%d", len(str))
len()函數同樣也是 go 的内置函數,是用來求集合的長度的。
上面這個例子會傳回9,這是因為中文在 Go 中會編譯為 UTF-8 編碼,一個漢字的編碼長度就 3,是以三個漢字就成了 9,但是也不一定,因為一些特殊的漢字可能占 4 個長度,是以不能簡單用 len() / 3 來擷取文字長度。
是以,漢字求長度的方法應該這樣做:
fmt.Println(utf8.RuneCountInString("李正龍"))
2.3數組
Go 中的數組也是一個我覺得設計的有點過于底層的概念了。基礎的用法和 C#是相同的,但是細節差別還是很大的。
首先,Go 的數組也是一個值類型,除此之外,由于”嚴格地“遵循了數組是一段連續的記憶體的結合這個概念,數組的長度是數組的一部分。這個概念也很重要,因為這是直接差別于切片的一個特征。而且,Go 中的數組的長度隻能是一個常量。
a := [5]int{1,2,3,4,5}
b := [...]{1,2,3,4,5}
lena := len(a)
lenb := len(b)
上述是 Go 中數組的兩個比較正常的初始化文法,數組的長度和字元串一樣,都是通過len()内置函數擷取的。其餘的使用和 C#基本相同,比如可以通過索引取值指派,可以周遊,不可以插入值等。
2.4切片
與數組對應的一個概念,就是 Go 中獨有的切片Slice類型。在日常的開發中幾乎很少能用得到數組,因為數組沒有擴充能力,比如 C#中我們也幾乎用不到數組,能用數組的地方基本都用List<T>。Slice 就是 List 的一種 Go 語言實作,它是一個引用類型,主要的目的是為了解決數組無法插入資料的問題。其底層也是一個數組,隻不過它對數組進行了一些封裝,加入了兩個指針分别指向數組的左右邊界,就使得 Slice 有了可以增加資料的功能。
s1 := []int{1,2,3,4,5}
s2 := s1[1:3]
s3 := make([]int, 0, 5)
上面是 Slice 的三種常用的初始化方式。
- 可以看到切片和數組的唯一差別就是沒有了數組定義中的數量
- 可以基于一個去切片去建立另一個切片,其後面的數字的含義就是目前業界通用的左包含右封閉
- 可以通過**make()**函數建立一個切片
make()函數感覺可以伴随 Go 開發者的一生,Go 的三個引用類型都是通過 make 函數進行初始化建立的。對切片來說,第一個參數表示切片類型,比如上栗就是初始化一個 int 類型的切片,第二個參數表示切片的長度,第三個參數表示切片的容量。
想切片中插入資料需要使用到 append()函數,并且文法十分詭異,可以說是離譜到家了:
s := make([]int)
s = append(s, 12345) // 這種追加值還需要傳回給原集合的文法真不知道是哪個小天才想到的
這裡出現了一個新的概念,切片的容量。我們知道數組是沒有容量這個概念的(其實是有的,隻不過容量就是長度),而切片的容量其實就類似于 C#中List<T>的容量(我知道大部分 C#er 在使用 List 的時候根本不會去關心 Capacity 這個參數),容量表示的是底層數組的長度。
容量可以通過 cap()函數擷取
在 C#中,如果 List 的資料寫滿了底層數組,那會發生擴容操作,需要新開辟一個數組将原來的資料複制到新的數組中,這是很耗費性能的一個操作,Go 中也是一樣的。是以在日常開發使用 List 或者切片的時候,如果能提前确定容量,最好就是初始化的時候就定義好,避免擴充導緻的性能損耗。
2.5集合
Go 中除了把 List 内置為切片,同樣也把 Dictionary<TKey, TValue>内置為了 map 類型。map 是 Go 中三個引用類型的第二個,其建立的方式和切片相同,也需要通過 make 函數:
m := make(map[int]string, 10)
從字面意思我們就可以知道,這句話是建立了一個 key 為 int,value 為 string,初始容量是 10 的 map 類型。
對 map 的操作沒有像 C#那麼複雜,get,set 和 contains 操作都是通過[]來實作的:
m := make(map[string]string, 5)
// 判斷是否存在
v, ok := m["aab"]
if !ok {
//說明map中沒有對應的key
}
// set值,如果存在重複key則會直接替換
m["aab"] = "hello"
// 删除值
delete(m, "aab")
這裡要說個坑,雖然 Go 中的 map 也是可以周遊的,但是 Go 強制将結果亂序了,是以每次周遊不一定拿到的是相同順序的結果。
2.6面向對象
2.6.1封裝
終于說到面向對象了。細心的同學肯定已經看到了,Go裡面竟然沒有封裝控制關鍵字public,protected和private!那我這面向對象第一準則的封裝性怎麼搞啊?
Go 語言的封裝性是通過變量首字母大小寫控制的(對重度代碼潔癖患者的我來說,這簡直是天大的福音,我再也不用看到那些首字母小寫的屬性了)。
// struct類型的首字母大寫了,說明可以在包外通路
type People struct {
// Name字段首字母也大寫了,同理包外可通路
Name string
// age首字母小寫了,就是一個包内字段
age int
}
// New函數大寫了,包外可以調到
func NewPeople() People {
return People{
Name: "jeffery",
age: 28
}
}
2.6.2繼承
封裝搞定了,繼承怎麼搞呢?Go 裡好像也沒有繼承的關鍵字extends啊?Go 完全以設計模式中的優先組合而非繼承的設計思想設計了複用的邏輯,在 Go 中沒有繼承,隻有組合。
type Animal struct {
Age int
Name string
}
type Human struct {
Animal // 如果預設不定義字段的字段名,那Go會預設把組合的類型名定義為字段名
// 這樣寫等同于: Animal Animal
Name string
}
func do() {
h := &Human{
Animal: Animal{Age: 19, Name: "dog"},
Name: "jeffery",
}
h.Age = 20
fmt.Println(h.Age) // 輸出:20,可以看到如果自身沒有組合結構體相同的字段,那可以省略子結構體的調用直接擷取屬性
fmt.Println(h.Name) // 輸出:jeffery,對于有相同的屬性,優先輸出自身的,這也是多态的一種展現
fmt.Println(h.Animal.Name)// 輸出:dog,同時,所組合的結構體的屬性也不會被改變
}
這種組合的設計模式極大的降低了繼承帶來的耦合,單就這一點來說,我認為是完美的銀彈。
2.6.3抽象
在講解關鍵字的部分我們就已經看到了,Go 是有接口的,但是同樣沒有實作接口的implemented關鍵字,那是因為 Go 中的接口全部都是隐式實作的。
type IHello interface {
sayHello()
}
type People struct {}
func (p *People) sayHello() {
fmt.Println("hello")
}
func doSayHello(h IHello) {
h.sayHello()
}
func main() {
p := &People{}
doSayHello(p) // 輸出:hello
}
可以看到,上例中的結構體 p 并沒有和接口有任何關系,但是卻可以正常被doSayHello這個函數引用,主要就是因為 Go 中的所有接口都是隐式實作的。(是以我覺得真的有可能出現你寫着寫着突然就實作了某個依賴包的某個接口的情況)
此外,這裡看到了一個不一樣的文法,函數關鍵字 func 之後沒有直接定義函數名稱,而是加入了一個結構體 p 的一個指針。這樣的函數就是結構體的函數,或者更直白一點就是 C#中的方法。
在預設情況下,我們都是使用指針類型為結構體定義函數,當然也可以不用指針,但是在那種情況下,函數所更改的内容就和原結構體完全不相關了。是以一般也遵循一個無腦用指針的原則。
好了,封裝、繼承和抽象都有了,至于多态,在繼承那裡已經看到了,Go 也是優先比對自身的相同函數,如果沒有才回去調用父結構體的函數,是以預設情況下的函數都是被重寫之後的函數。
2.7設計哲學
Go 語言的設計哲學是less is more。這句話的意思是 Go 需要簡單的文法,其中簡單的文法也包括顯式大于隐式(接口類型真是滿頭問号)。這是什麼意思呢?
2.7.1. Go 沒有預設的類型轉換
var i int8 = 1
var j int
j = i // 編譯報錯:Cannot use 'i' (type int8) as the type int
還有一個例子就是 string 類型不能預設和 int 等其他類型拼接,比如輸入"n你好" + 1在 Go 中同樣會報編譯錯誤。原因就是 Go 的設計者覺得這種都是隐式的轉換,Go 需要簡單,不應該有這些。
2.7.2. Go 沒有預設參數,同樣也沒有方法重載
這也是一個很讓人惱火語言特性。因為不支援重載,寫代碼時就不得不寫大量可能重複但是名字不相同的函數。這個特性也是有開發者專門問過 Go 設計師的, 給出的回複就是 Go 的設計目标就是簡單,在簡單的大前提下,部分備援的代碼是可以接受的。
2.7.3.Go 不支援 Attribute
和目前沒有泛型不同,Go 的泛型是一個正在開發的功能,是還沒來得及做的。而特性 Attribute 也就是 Java 中的注解,在 Go 中是被明确說明不會支援的語言特性。
注解能在 Java 中帶來怎樣強大的功能呢?舉一個例子:
在大型網際網路都轉向微服務架構的時代,分布式的多段送出,分布式事務就是一個比較大的技術壁壘。以分布式事務為例,多個微服務很可能都不是一個團隊開發的,也可能部署在世界各地,而如果一個操作需要復原,其他所有的微服務都需要實作復原的機制。這裡不光涉及複雜的業務模型,還有更複雜的資料庫復原政策(什麼 2PC 啊,TCC 啊每一個政策都可以當一門單獨的課來講)。
這種東西如果要從頭開發那幾乎是很難考慮全面的。更别提這樣的複雜代碼再耦合到業務代碼中,那代碼會變得非常難看。都不說分布式事務了,簡單的一個記憶體緩存,我們用的都很混亂,在代碼中會經常看到先讀取緩存在讀取資料庫的代碼,和業務完全耦合在一起,完全無法維護。
而 Spring Cloud 中,代碼的使用者可以通過一個簡單的注解(也就是 C#的特性)@Transactional,那這個方法就是支援事務的,使這種複雜的技術級代碼完全和業務代碼解耦,開發者完全按照正常的業務邏輯寫業務代碼即可,完全不用管事務的一些問題。
然而, Go 的設計者同樣認為注解會嚴重影響代碼使用者對一個調用的使用心智,因為加了一個注解,就可以導緻一個函數的功能完全不一樣,這與 Go 顯式大于隐式的設計理念相違背,會嚴重增加使用者的心智負擔,不符合 Go 的設計哲學(哎,就離譜…)
2.7.4. Go 沒有 Exception
在 Go 中沒有異常的概念,相反地提供了一個 error 的機制。對 C#來說,如果一段代碼運作存在問題,那我們可以手動抛出一個 Exception,在調用方可以捕獲對應的異常進行之後的處理。而 Go 中沒有異常,替代的方案是 error 機制。什麼是 error 機制呢?還記得之前講過的 Go 的幾乎所有的函數都有多個傳回值嗎?為啥要那麼多的傳回值呢?對,就是為了接收 error 的。比如下述代碼:
func sayHello(name string) error {
if name == "" {
return errors.New("name can not be empty")
}
fmt.Printf("hello, %s\n", name)
return nil
}
// invoker
func main() {
if err := sayHello("jeffery"); err != nil {
// handle error
}
}
這樣的 error 機制需要保證所有的代碼運作過程中都不會異常崩潰,每個函數到底執行成功了沒有,需要通過函數的傳回錯誤資訊來判斷,如果一個函數調用的傳回結果的 error == nil,說明這段代碼沒問題。否則,就要手動處理這個 error。
這樣就有可能導緻一個嚴重的後果:所有的函數調用都需要寫成
if err := function(); err != nil
這樣的結構。這樣的後果幾乎是災難性的(這也是為啥 VS2022 支援了代碼 AI 補全功能後,網上的熱評都是利好 Gopher)這種 error 的機制也是 Go 被黑的最慘的地方。
那這時候肯定有小夥伴說了,那我就是不處理搞一個類似于1/0這樣的代碼會怎麼樣呢?
如果寫了類似于上述的代碼,那最終會引發一個 Go 的panic。在我目前淺顯的了解中,panic其實才是 C#中 Exception 的概念,因為程式運作遇到 panic 後就會徹底崩潰了,Go 的設計者在最開始的設計中估計是認為所有的錯誤都應該用 error 處理,如果引發了 panic 那說明這個程式無法使用了。是以 panic 其實是一個無法挽回的錯誤的概念。
然而,大型的項目中,并不是自己的代碼寫的萬無一失就沒有 panic 了,很可能我們引用的其他包幹了個什麼我們不知道的事就 panic 了,比如最典型的一個例子:Go 的 httpRequest 中的 Body 隻能讀取一次,讀完就沒有了。如果我們使用的 web 架構在處理請求時把 Body 讀了,我們再去讀取結果很有可能 panic。
是以,為了解決 panic,Go 還有一個 recover()的函數,一般的用法是:
func main() {
panic(1)
defer func() {
if err := recover(); err != nil {
fmt.Println("boom")
}
}
}
其實 Go 有一個強大的競争者——Rust,Rust 是 Mozilla 基金會在 2010 年研發的語言,和 Go 是以 C 語言為基礎開發的類似,Rust 是以 C++為基準進行開發的。是以現在社群中就有 Go 和 Rust 兩撥陣營在互相争論,吵得喋喋不休。
當然,萬物沒有銀彈,一切的事物都應該以辯證的思維去學習了解。