讀古今文學網 > Go語言程序設計 > 第5章 過程式編程 >

第5章 過程式編程

本章的目的是完全覆蓋自本書開始處所提及的Go過程式編程。Go語言可以用於寫純過程式程序,用於寫純面向對像程序,也可以用於寫過程式和面向對像相結合的程序。學習 Go語言的過程式編程至關重要,因為與 Go語言的並發編程一樣,面向對像編程也是建立在面向過程的基礎之上的。

前面幾章描述並闡明了Go語言內置的數據類型,在這一過程中,我們接觸了Go語言的表達式語句和控制結構,以及許多輕量的自定義函數。本章中我們將更詳細地講解 Go語言的表達式語句和控制結構,同時更加詳細地講解創建和使用自定義的函數。表5-1提供了一個Go語言的內置類型函數的列表,其中大部分已在本章內容中覆蓋[1]。

本章有些知識點在前面的章節中已經提及過,而有些知識點涉及 Go語言編程的其他方面則在接下來的章節中講解,讀者需要根據實際情況參閱前後章節的相關內容。

5.1 語句基礎

形式上講,Go語言的語法需要使用分號(;)來作為上下文中語句的分隔結束符。然而,如我們所見,在實際的Go 程序中,很少使用到分號。那是因為編譯器會自動在以標識符、數字字面量、字母字面量、字符串字面量、特定的關鍵字(break、continue、fallthrough和return)、增減操作符(++或者--)或者是一個右括號、右方括號和右大括號(即)、]、})結束的非空行的末尾自動加上分號。

有兩個地方必須使用分號,即當我們需要在一行中放入一條或者多條語句時,或者是使用原始的for循環時(參見5.3節)。

自動插入分號的一個重要結果是一個右大括號無法自成一行。

// 正確代碼                // 錯誤代碼 (不能通過編譯)

for i := 0; i < 5; i++ {        for i := 0; i < 5; i++

fmt.Println(i)           {

}                      fmt.Println(i)

}

上面右邊的代碼不能編譯,因為編譯器會往 ++ 後面插入一個分號。類似地,如果我們有一個無限循環(for),其左括號從下一行開始,那麼編譯器就會在for後面加上一個分號,代碼同樣不能編譯。

表5-1 內置函數

括號放置的美學,通常引來無限多的討論,但Go語言中不會。這部分是因為自動插入的分號限制了左括號的放置,部分是因為許多Go語言的用戶使用gofmt程序將Go代碼標準格式化。事實上,Go標準庫中的所有源代碼都使用了gofmt,這就是為什麼這些代碼有一個如此緊湊而一致的結構的原因,雖然這些是許多不同程序員的工作[2]。

Go語言支持表2-4中所列的++(遞增)和--(遞減)操作符。它們都是後置操作符,也就是說,它們必須跟隨在一個它們所作用的操作數後面,並且它們沒有返回值。這些限制使得該操作符不能用於表達式,也意味著不能用於語意不明的上下文中。例如,我們不能將該操作符用於一個函數的語句中,或者在Go語言中寫出類似i=i++這樣的代碼(雖然我們能夠在C和C++中這樣做,其中其結果是未定義的)。

賦值通過使用 = 賦值操作符來完成。變量可以使用 =和一個var連接起來創建和賦值。例如, var x int = 5創建了一個int型的變量x,並將其賦值為5。(使用var x int = 5或者x :=5所達到的目的完全一樣。)被賦值變量的類型必須與其所賦的值的類型相兼容。如果使用了=而沒有使用var關鍵字,那麼其左邊的變量必須是已存在的。可以為多個逗號分隔的變量賦值,我們也可以使用空標識符(_)來接受賦值,它與任意類型兼容,並且會將賦給它的值忽略。多重賦值使得交換兩個變量之間的數據變得更加簡單,它不需要顯式地指明一個臨時變量,例如a, b = b, a。

快速聲明操作符(:=)用於同時在一個語句中聲明和賦值一個變量。多個逗號分隔的變量用法大多數情況下跟 = 賦值操作符一樣,除了必須至少有一個非空變量為新的。如果有一個變量已經存在了,它就會直接被賦值,而不會新建一個變量,除非該:= 操作符位於作用域的起始處,如if或者for語句中的初始化語句(參見5.2.1節和5.3節)。

a, b, c := 2, 3, 5

for a := 7; a < 8; a++{  // a無意間覆蓋了外部a的值

b := 11        // b無意間覆蓋了外部b的值

c = 13         // c為外部的c值 √

fmt.Printf(〞inner: a→%d b→%d c→%d\n〞, a, b, c)

}

fmt.Printf(〞outer: a→%d b→%d c→%d\n〞, a, b, c)

inner: a→7 b→11 c→13

outer: a→2 b→3 c→13

這個代碼片段展示了:= 操作符是如何創建「影子」變量的。在上面的代碼中,for 循環裡面,變量a和b覆蓋了外部作用域中的變量,雖然合法,但基本上可確定是一個失誤。另一方面,上面代碼只創建了一個變量c(在外部作用域中),因此它的使用是正確的,並且也是所預期的。我們馬上會看到,覆蓋其他變量的變量可以很方便,但是粗心地使用可能會引起問題。

正如我們將在後面章節所討論的,我們可以在有一到多個命名返回值的函數中寫無需聲明返回值的return語句。這種情況下,返回值將是命名的返回值,它們在函數入口處被初始化為其類型的零值,並且可以在函數體中通過賦值語句來改變它們。

func shadow (err error) { // 該函數不能編譯

x, err := check1 // 創建x,並對err進行賦值

if err != nil {

return // 正確地返回err

}

if y, err := check2(x); err != nil { // 創建了變量y和一個內部err變量

return // 內部err變量覆蓋了外部err變量,因此錯誤地返回了nil

} else {

fmt.Println(y)

}

return // 返回nil

}

在shadow函數的第一個語句中,創建了變量x並將其賦值。但是err變量只是簡單地將其賦值,因為它已經被聲明為 shadow函數的返回值了。這之所以能夠工作,是因為:=操作符必須至少創建一個非空的變量,而該條件在這裡能夠滿足。因此,如果err變量非空,就會正確地返回。

一個if語句的簡單語句(即跟在if後面且在條件之前的可選語句)創建了一個新的作用域(參見5.2.1節)。因此,變量y和err都被重新創建了,後者是一個影子變量。如果err為非空,則返回外部作用域中的err(即聲明為shadow函數返回值的err變量),其值為nil,因為調用check1函數的時候它被賦值了,而調用check2的時候,賦值的是err的影子變量。

所幸的是,函數的影子變量問題只是個幻影,因為在我們使用裸的return語句而此時又有任一返回值被影子變量覆蓋時,Go編譯器會給出一個錯誤消息並中止編譯。因此,該函數無法通過編譯。

一個簡單的辦法是在函數開始處聲明變量(例如var x, y int或者x, y := 0, 0),然後把調用 check1和調用 check2函數時的:= 換成 =。(關於該方法的一個例子,請看自定義的americanise函數。)

另一個解決方法是使用一個非命名的返回值。這迫使我們返回一個顯式的值,因此在本例中,前兩個語句的返回值都是return err(每一個語句返回一個不同的但都是正確的err值),同時最後一個返回語句為return nil。

5.1.1 類型轉換

Go語言提供了一種在不同但相互兼容的類型之間相互轉換的方式,並且這種轉換非常有用並且安全。非數值類型之間的轉換不會丟失精度。但是對於數值類型之間的轉換,可能會發生丟失精度或者其他問題。例如,如果我們有一個x := uint16(65000),然後使用轉換y := int16(x),由於x超出了int16的範圍,y的值被毫無懸念地設置成-536,這也可能不是我們所想要的。

下面是類型轉換的語法:

resultOfType := Type(expression)

對於數字,本質上講我們可以將任意的整型或者浮點型數據轉換成別的整型或者浮點型(如果目標類型比源類型小,則可能丟失精度)。同樣的規則也適用於 complex128和complex64類型之間的轉換。我們已經在2.3節講解了數字轉換的內容。

一個字符串可以轉換成一個byte(其底層為 UTF-8的字節)或者一個rune(它的Unicode碼點),並且byte和rune都可以轉換成一個字符串類型。單個字符是一個rune類型數據(即 int32),可以轉換成一個單字符的字符串。字符串和字符的類型轉換的內容已在第3章中闡述過(參見表3-2、表3-8和表3-9)。

讓我們看一個更加直觀的小例子,它從一個簡單的自定義類型開始。

type StringSlice string

該類型也有一個自定義的StringSlice.String 函數(沒給出),它返回一個表示一個字符串切片的字符串,該字符串切片以組合字面量語法的形式創建了自定義的StringSlice類型。

fancy := StringSlice(〞Lithium〞, 〞Sodium〞, 〞Potassium〞, 〞Rubidium〞)

fmt.Println(fancy)

plain := string(fancy)

fmt.Println(plain)

StringSlice{〞Lithium〞, 〞Sodium〞, 〞Potassium〞, 〞Rubidium〞}

[Lithium Sodium Potassium Rubidium]

StringSlice變量fancy使用它自身的StringSlice.String函數打印。但一旦我們將其轉換成一個普通的string切片,那就像任何其他string一樣被打印了。(創建帶自身方法的自定義類型的內容將在第6章提到。)

如果表達式與類型Type的底層類型一樣,或者如果表達式是一個可以用類型Type表達的無類型常量,或者如果Type是一個接口類型並且該表達式實現了Type接口,那麼將一種類型的數據轉換成其他類型也是可以的[3]。

5.1.2 類型斷言

一種類型的方法集是一個可以被該類型的值調用的所有方法的集合。如果該類型沒有方法,則該集合為空。Go語言的interface{}類型用於表示空接口,即一個方法集為空集的類型的值。由於每一種類型都有一個方法集包含空的集合(無論它包含多少方法),一個interface{}的值可以用於表示任意Go類型的值。此外,我們可以使用類型開關、類型斷言或者Go語言的reflect包的類型檢查(參見9.4.9節)將一個interface{}類型的值轉換成實際數據的值(參見5.2.2.2節)[4]。

在處理從外部源接收到的數據、想創建一個通用函數及在進行面向對像編程時,我們會需要使用interface{}類型(或自定義接口類型)。為了訪問底層值,有一種方法是使用下面中提到的一種語法來進行類型斷言:

resultOfType, boolean := expression.(Type) // 安全類型斷言

resultOfType := expression.(Type) // 非安全類型斷言,失敗時panic

成功的安全類型斷言將返回目標類型的值和標識成功的true。如果安全類型斷言失敗(即表達式的類型與聲明的類型不兼容),將返回目標類型的零值和false。非安全類型斷言要麼返回一個目標類型的值,要麼調用內置的panic函數拋出一個異常。如果異常沒有被恢復,那麼該函數會導致程序終止。(異常的拋出和恢復的內容將在後面闡述,參見5.5節。)

這裡有個小程序用來解釋用到的語法。

var i interface{} = 99

var s interface{} = string{〞left〞, 〞right〞}

j := i.(int) // j是int類型的數據(或者發生了一個panic)

fmt.Printf(〞%T→%d\n〞, j, j)

if i, ok := i.(int); ok {

fmt.Printf(〞%T→%d\n〞, i, j) // i是一個int類型的影子變量

}

if s, ok := s.(string); ok {

fmt.Printf(〞%T→%q\n〞, s, s) // s是一個string類型的影子變量

}

int→99

int→99

string→[〞left〞 〞right〞]

做類型斷言的時候將結果賦值給與原始變量同名的變量是很常見的事情,即使用影子變量。同時,只有在我們希望表達式是某種特定類型的值時才使用類型斷言。(如果目標類型可以是許多類型之一,我們可以使用類型開關,參見5.2.2.2節。)

注意,如果我們輸出原始的i和s變量(兩者都是interface{}類型),它們可以以int和string類型的形式輸出。這是因為當fmt包的打印函數遇到interface{}類型時,它們會足夠智能地打印實際類型的值。

5.2 分支

Go語言提供了3種分支語,即if、switch和select,後者將在後面深入討論(參見5.4 節)。分支效果也可以通過使用一個映射來達到,它的鍵可以用於選擇分支,而它的值是對應的要調用的函數,我們會在本章末尾看到更多細節(參見5.6.5節)。

5.2.1 if語句

Go語言的if語句語法如下:

if optionalStatement1; booleanExpression1 {

block1

} else if optionalStatement2; booleanExpression2 {

block2

} else {

block3

}

一個if語句中可能包含零到多個else if子句,以及零到多個else子句。每一個代碼塊都由零到多個語句組成。

語句中的大括號是強制性的,但條件判斷中的分號只有在可選的聲明語句optionalStatement1 出現的情況下才需要。該可選的聲明語句用 Go語言的術語來說叫做「簡單語句」。這意味著它只能是一個表達式、發送到通道(使用<-操作符)、增減值語句、賦值語句或者短變量聲明語句。如果變量是在一個可選的聲明語句中創建的(即使用:=操作符創建的),它們的作用域會從聲明處擴展到if語句的完成處,因此它們在聲明它們的if或者else if語句以及相應的分支中一直存在著,直到該if語句的末尾。

布爾表達式必須是bool型的。Go語言不會自動轉換非布爾值,因此我們必須使用比較操作符。例如,if i == 0。(布爾類型和比較操作符參見表2-3。)

我們已經看過了使用if語句的大量例子,在本書的後續章節中將看到更多。不過,讓我們再看兩個小例子,第一個演示了可選簡單語句的用處,第二個解釋了Go語言中if語句的習慣用法。

// 經典用法                // 囉嗦用法

if α := compute; α < 0 {       {

fmt.Printf(〞(%d)\n〞, -α)        α := compute

} else {                  if α < 0 {

fmt.Println(α)               fmt.Printf(〞(%d)\n〞, -α)

}                      } else {

fmt.Println(α)

}

}

這兩段代碼的輸出一模一樣。右邊的代碼必須使用額外的大括號來限制變量 α的作用域,然而左邊的代碼中的if語句自動地限制了變量的作用域。

第二個關於if語句的例子是ArchiveFileList函數,它來自於archive_file_list示例(在文件archive_file_list/archive_file_ list.go中)。隨後,我們會使用該函數的實現來對比if和switch語句。

func ArchiveFileList(file string) (string, error) {

if suffix := Suffix(file); suffix == 〞.gz〞 {

return GzipFileList(file)

} else if suffix == 〞.tar〞 || suffix == 〞.tar.gz〞 || suffix == 〞.tgz〞 {

return TarFileList(file)

} else if suffix == 〞.zip〞 {

return ZipFileList(file)

}

return nil, errors.New(〞unrecognized archive〞)

}

該函數讀取一個從命令行指定的文件,對於那些可以處理的壓縮文件(.gz、.tar、.tar.gz、.zip),它會打印壓縮文件的文件名,並以縮進格式打印該壓縮文件所包含文件的列表。

第一個if語句中聲明的suffix變量的作用域擴展到了整個if…else if …語句中,因此它在每一個分支中都是可見的,就像前例中的α 變量一樣。

該函數本可以在末尾添加一個else語句,但在Go語言中使用這裡所給的結構是非常常用的:一個if語句帶零到多個else if語句,其中每一個分支都帶有一個return語句,隨後緊接的是一個return語句而非一個包含return語句的else分支。

func Suffix(file string) string {

file = strings.ToLower(filepath.Base(file))

if i := strings.LastIndex(file, 〞.〞); i > -1 {

if file[i:] == 〞.bz2〞 || file[i:] == 〞.gz〞 || file[i:] == 〞.xz〞 {

if j := strings.LastIndex(file[:i], 〞.〞);

j > -1 && strings.HasPrefix(file[j:], 〞.tar〞) {

return file[j:]

}

}

return file[i:]

}

return file

}

為了完整性考慮,這裡也提供了Suffix函數的實現。它接受一個文件名(可能包含路徑),返回其小寫的後綴名(也叫擴展名),即文件名中從點號開始的最後部分。如果一個文件名沒有點號,則將文件名返回(路徑除外)。如果文件名以.tar.bz2、.tar.gz 或者.tar.xz結尾,則這些就是返回的後綴。

5.2.2 switch語句

Go語言中有兩種類型的switch語句:表達式開關(expression switch)和類型開關(type switch)。表達式開關語句對於C、C++和Java程序員來說比較熟悉,然而類型開關語句是Go語言專有的。兩者在語法上非常相似,但不同於C、C++和Java的是,Go語言的switch語句不會自動地向下貫穿(因此不必在每一個case子句的末尾都添加一個break語句)。相反,我們可以在需要的時候通過顯式地調用fallthrough語句來這樣做。

5.2.2.1 表達式開關

Go語言的表達式開關(switch)語法如下:

switch optionalStatement; optionalExpression {

case expressionList1: block1

case expressionListN: blockN

default: blockD

}

如果有可選的聲明語句,那麼其中的分號是必要的,無論後面可選的表達式語句是否出現。每一個塊由零到多個語句組成。

如果switch語句未包含可選的表達式語句,那麼編譯器會假設其表達式值為true。可選的聲明語句與if語句中使用的簡單語句是相同類型的。如果變量都是在可選的聲明語句中創建的(例如,使用:=操作符),它們的作用域將會從其聲明處擴展到整個switch語句的末尾處。因此它們在每個case語句和default語句中都存在,並在switch語句的末尾處消失。

將case語句排序最有效的辦法是,從頭至尾按最有可能到最沒可能的順序列出來,雖然這只有在有很多case子句並且該switch語句重複執行的情況下才顯得重要。由於case子句不會自動地向下貫穿,因此沒必要在每一個case語句塊的末尾都加上一個break語句。如果需要case語句向下貫穿,我們只需簡單地使用一個fallthrough語句。default語句是可選的,並且如果出現了,可以放在任意地方。如果沒有一個 case 表達式匹配,則執行給出的default語句;否則程序將從switch語句之後的語句繼續往下執行。

每一個case語句必須有一個表達式列表,其中包含一個或者多個分號分隔的表達式,其類型與switch語句中的可選表達式類型相匹配。如果沒有給出可選的表達式,編譯器會自動將其設置為true,即一個布爾類型,這樣每一個case子句中的表達式的值就必須是一個布爾類型。

如果一個case或者default語句有一個break語句,switch語句的執行會被立即跳出,其控制權被交給switch語句後面的語句,或者如果break語句聲明了一個標籤,控制權就會交給聲明標籤處的最裡層for、switch或者select語句。

這裡有個關於switch語句的非常簡單的例子,它沒有可選的聲明和可選表達式。

func BoundedInt(minimum, value, maximum int) int {

switch {

case value < minimum:

return minimum

case value > maximum:

return maximum

}

return value

}

由於沒有可選的表達式語句,編譯器會將表達式語句的值設為 true。這意味著 case 語句中的每一個表達式都必須計算為布爾類型。這裡兩個表達式語句都使用了布爾比較操作符。

switch {

case value < minimum:

return minimum

case value > maximum

return maximum

default:

return value

}

panic(〞unreachable〞)

這是上面 BoundedInt函數的一種替代實現。其 switch 語句現在包含了每一種可能的情況,因此控制權永遠不會到達switch語句的末尾。然而,Go語言希望在函數的末尾出現一個return語句或者panic,因此我們使用了後者來更好地表達函數的語意。

前面節中的ArchiveFileList函數使用了一個if語句來決定調用哪個函數。這裡有一個原始的基於switch語句的版本。

switch suffix := Suffix(file); suffix { ∥ 原始的非經典用法

case 〞.gz〞:

return GzipFileList(file)

case 〞.tar〞:

fallthrough

case 〞.tar.gz〞:

fallthrough

case 〞.tgz〞:

return TarFileList(file)

case 〞.zip〞:

return ZipFileList(file)

}

switch語句同時有一個聲明語句和一個表達式語句。本例中表達式語句是string類型,因此每一個 case 語句的表達式列表必須包含一個或者多個以逗號分隔的字符串才能匹配。我們使用了fallthrough語句來保證所有的tar類型文件都使用同一個函數來執行。

變量suffix的作用域從聲明處擴展至每一個case子句(如果有default,其作用域也會擴展至default子句)中,同時在switch語句的末尾處結束,因為從那之後suffix變量就不再存在了。

switch Suffix(file) { // 經典用法

case 〞.gz〞:

return GzipFileList(file)

case 〞.tar〞, 〞.tar.gz〞, 〞.tgz〞:

return TarFileList(file)

case 〞.zip〞:

return ZipFileList(file)

}

這裡有個更加緊湊也更加實用的使用switch的版本。與使用一個聲明和一個表達式語句不同的是,我們只是簡單地使用一個表達式:一個返回字符串的Suffix的函數。同時,我們也不使用 fallthrough 語句來處理所有的tar 文件,而是使用逗號分隔的所有能夠匹配的文件後綴來作為case語句的表達式列表。

Go語言的表達式switch語句比C、C++以及Java中的類似語句都更有用,很多情況下可以用於代替if語句,並且還更緊湊。

5.2.2.2 類型開關

注意,我們之前提到過類型斷言(參見5.1.2節),當我們使用interface{}類型的變量時,我們常常需要訪問其底層值。如果我們知道其類型,就可以使用類型斷言,但如果其類型可能是許多可能類型中的一種,那我們就可以使用類型開關語句。

Go語言的類型開關語法如下:

switch optionalStatement; typeSwitchGuard {

case typeLis1: block1

...

case typeListN: blockN

default: blockD

}

可選的聲明語句與表達式開關語句和if語句中的一樣。同時這裡的case語句與表達式切換語句中的case語句工作方式也一樣,不同的是這裡列出一個或者以多個逗號分隔的類型。可選的default子句和fallthrough語句與表達式切換語句中的工作方式也一樣,通常,每一個塊也包含零到多條語句。

類型開關守護(guard)是一個結果為類型的表達式。如果表達式是使用:=操作符賦值的,那麼創建的變量的值為類型開關守護表達式中的值,但其類型則決定於 case 子句。在一個列表只有一個類型的case子句中,該變量的類型即為該類型;在一個列表包含兩個或者更多個類型的case子句中,其變量的類型則為類型開關守護表達式的類型。

這種類型開關語句所支持的類型測試對於面向對像程序員來說可能比較困惑,因為他們更依賴於多態。Go語言在一定程度上可以通過鴨子類型支持多態(將在第6章看到),但儘管如此,有時使用顯式的類型測試是更為明智的選擇。

這裡有個例子,顯示了我們如何調用一個簡單的類型分類函數以及它的輸出。

classifier(5, -17.9, 〞ZIP〞, nil, true, complex(1, 1))

param #0 is an int

param #1 is a float64

param #2 is a string

param #3 is nil

param #4 is a bool

param #5's type is unknown

classifier函數使用了一個簡單的類型開關。它是一個可變參函數,也就是說,它可以接受不定數量的參數。並且由於其參數類型為 interface{},所傳的參數可以是任意類型的。(本章稍後將講解可變參函數以及帶省略符函數,參見5.6節。)

func classifier(items…interface{}) {

for i, x := range items {

switch x.(type) {

case bool:

fmt.Printf(〞param #%d is a bool\n〞, i)

case float64:

fmt.Printf(〞param #%d is a float64\n〞, i)

case int, int8, int16, int32, int64:

fmt.Printf(〞param #%d is an int\n〞, i)

case uint, uint8, uint16, uint32, uint64:

fmt.Printf(〞param #%d is an unsigned int\n〞, i)

case nil:

fmt.Printf(〞param #%d is nil\n〞, i)

case string:

fmt.Printf(〞param #%d is a string\n〞, i)

default:

fmt.Printf(〞param #%d's type is unknow\n〞, i)

}

}

}

這裡使用的類型開關守護與類型斷言裡的格式一樣,即 variable.(Type),但是使用type關鍵字而非一個實際類型,以用於表示任意類型。

有時我們可能想在訪問一個 interface{}的底層值的同時也訪問它的類型。我們馬上會看到,這可以通過將類型開關守護進行賦值(使用:= 操作符)來達到這個目的。

類型測試的一個常用案例是處理外部數據。例如,如果我們解析JSON格式的數據,我們必須將數據轉換成相對應的Go語言數據類型。這可以通過使用Go語言的json.Unmarshal函數來實現。如果我們向該函數傳入一個指向結構體的指針,該結構體又與該JSON數據相匹配,那麼該函數就會將JSON數據中對應的數據項填充到結構體的每一個字段。但是如果我們事先並不知道JSON數據的結構,那麼就不能給json.Unmarshal函數傳入一個結構體。這種情況下,我們可以給該函數傳入一個指向interface{}的指針,這樣json.Unmarshal函數就會將其設置成引用一個map[string]interface{}類型值,其鍵為JSON字段的名字,而值為對應的保存為interface{}的值。

這裡有個例子,給出了如何反序列化一個其內部結構未知的原始JSON對象,如何創建和打印JSON對象的字符串表示。

MA := byte('{〞name〞: 〞Massachusetts〞, 〞area〞: 27336, 〞water〞: 25.7, 〞senators〞:

[〞John Kerry〞, 〞Scott Brown〞]}')

var object interface{}

if err := json.Unmarshal(MA, &object); err != nil {

fmt.Println(err)

} else {

jsonObject := object.(map[string]interface{}) 1

fmt.Println(jsonObjectAsString(jsonObject))

}

{〞senators〞: [〞John Kerry〞, 〞Scott Brown〞], 〞name〞: 〞Massachusetts〞,

〞water〞: 25.700000, 〞area〞: 27336.000000}

如果反序列化時未發生錯誤,則 interface{}類型的object 變量就會指向一個map[string]interface{}類型的變量,其鍵為 JSON 對像中字段的名字。jsonObject AsString函數接收一個該類型的映射,同時返回一個對應的JSON 字符串。我們使用一個未檢查的類型斷言語句(標識1)來將一個interface{}類型的對象轉換成map[string] interface{}類型的jsonObject變量。(注意,為了適應書頁的寬度,這裡給出的輸出切分成了兩行。)

func jsonObjectAsString(jsonObject map[string]interface{}) string{

var buffer bytes.Buffer

buffer.WriteString(〞{〞)

comma := 〞〞

for key, value := range jsonObject{

buffer.WriteString(comma)

switch value := value.(type){ // 影子變量 1

case nil: 2

fmt.Fprintf(&buffer, 〞%q: null〞, key)

case bool:

fmt.Fprintf(&buffer, 〞%q: %t〞, key, value)

case float64:

fmt.Fprintf(&buffer, 〞%q: %f〞, key, value)

case string:

fmt.Fprintf(&buffer, 〞%q: %q〞, key, value)

case interface{}:

fmt.Fprintf(&buffer, 〞%q: [〞, key)

innerComma := 〞〞

for _, s := range value{

if s, ok := s.(string); ok { ∥影子變量3

fmt.Fprintf(&buffer, 〞%s%q〞, innerComma, s)

innerComma = 〞, 〞

}

}

buffer.WriteString(〞]〞)

}

comma = 〞, 〞

}

buffer.WriteString(〞}〞)

return buffer.String

}

該函數將一個JSON對像轉換成用一個映射來表示,同時返回一個對應的JSON格式中對象的數據的字符串表示。表示JSON對象的映射裡的JSON數組使用interface{}類型來表示。關於JSON數組,該函數做了一個簡化的假設:它假設數組中只包含字符串類型的項。

為了訪問數據,我們在for...range(參見5.3節)循環來訪問映射的鍵和值,同時使用類型開關來獲得和處理每種不同類型的值。類型開關守護(1)將其值(interface{}類型)賦值給一個新的value變量,其類型與其相匹配的case子句的類型相同。在這種情況下使用影子變量是個明智的選擇(雖然我們可以輕鬆地創建一個新的變量)。因此,如果 interface{}值的類型是布爾型,其內部值為布爾值,那麼將匹配第二個case子句,其他case子句的情況也類似。

為了將數據寫回緩衝區,我們使用了 fmt.Fprintf函數,因為這個函數比buffer.WriteString(fmt.Sprintf(...))(2)函數來得方便。fmt.Fprintf函數將數據寫入到其第一個 io.Writer 類型的參數。雖然 bytes.Buffer 不是 io.Writer,但*bytes.Buffer卻是一個io.Writer,因此我們傳入buffer的地址。這些內容將在第6章詳細闡述。簡而言之,io.Writer是一個接口,任何提供了Write方法的值都可以滿足該接口。bytes.Buffer.Write方法需要一個指針類型的接收器(即一個*bytes.Buffer 而非一個bytes.Buffer 值),因此只有*bytes.Buffer 才能夠滿足該接口,這也意味著我們必須將buffer的地址傳入fmt.Fprintf函數,而非buffer本身。

如果該JSON對像包含JSON數組,我們使用for...range循環來迭代interface{}數組的每一個項,同時也使用已檢查的類型斷言來判斷(3),這樣就能保證只有在數據為字符串類型時我們才將其添加到輸出結果中。我們再一次使用了影子變量(這次是字符串類型的s),因為我們需要的不是接口,而是該接口所引用的值。(類型斷言的內容我們已經講過,參見5.1.2節。)當然,如果我們事先知道原始JSON對象的結構,我們可以很大程度上簡化代碼。我們可以使用一個結構體來保存數據,然後使用一個方法以字符串的形式將其輸出。下面是在這種情況下反序列化並將其數據輸出的例子。

var state State

if err := json.Unmarshal(MA, &state); err != nil {

fmt.Println(err)

}

fmt.Println(state)

{〞name〞: 〞Massachusetts〞, 〞area〞: 27336, 〞water〞: 25.700000,

〞senators〞: [〞John Kerry〞, 〞Scott Brown〞]}

這段代碼看起來跟之前的代碼很像。然而,這裡不需要jsonObjectAsString函數,相反我們需要定義一個 State 類型和一個對應的State.String方法。(同樣地,我們將其輸出結果分行以適應書頁的寬度。)

type State struct{

Name   string

Senators string

Water  float64

Area   int

}

該結構體與我們之前所看到的近似。然而請注意,這裡每個字段的起始字符必須以大寫字母開頭,這樣就能夠將其導出(公開),因為json.Unmarshal函數只能填充可導出的字段。同時,雖然Go語言的encoding/json包並不區分不同的數據類型(它會把所有JSON的數字當成float64類型),但json.Unmarshal函數足夠聰明,會自動填充其他數據類型的字段。

func (state State) String string{

var senators string

for _, senator := range state.Senators{

senators := append(senators, fmt.Sprintf(〞%q〞, senator))

}

return fmt.Sprintf(

'{〞name〞: %q, 〞area〞: %d, 〞water〞: %f, 〞senators〞: [%s]}',

state.Name, state.Area, state.Water, strings.Join(senators, 〞, 〞))

}

該方法返回一個表示State值的JSON字符串。

大部分 Go 程序應該都不需要類型斷言和類型開關,即使需要,應該也很少用到。其中一個使用案例是,我們傳入一個滿足某個接口的值,同時想檢查下它是否滿足另外一個接口。(該主題將在第6章闡述,例如6.5.2節。)另一個使用案例是,數據來自於外部源但必須轉換成Go語言的數據類型。為了簡化維護,最好總是將這些代碼與其他程序分開。這樣就使得程序完全地工作於 Go語言的數據類型之上,也意味著任何外部源數據的格式或類型改變所導致的代碼維護工作可以控制在小範圍內。

5.3 for循環語句

Go語言使用兩種類型的for 語句來進行循環,一種是無格式的for 語句,另一種是for...range語句。下面是它們的語法:

for { //無限循環

block

}

for booleanExpression { // while循環

block

}

for optionalPreStatement; booleanExpress; optionalPostStatement{ // 1

block

}

for index, char := range aString{ //一個字符一個字符地迭代一個字符串 2

block

}

for index := range aString{ // 一個字符一個字符地迭代一個字符串 3

block // char, size := utf8.DecodeRuneInString(aString[index:])

}

for index, item := range anArrayOrSlice { // 數組或者切片迭代 4)

block

}

for index := range anArrayOrSlice { // 數組或者切片迭代 5

block // item := anArrayOrSlice[index]

}

for key, value := range aMap{ // 映射迭代 // 6

block

}

for key := range aMap { // 映射迭代 // 7

block // value := aMap[key]

}

for item := range aChannel { // 通道迭代

block

}

for循環中的大括號是必須的,但分號只在可選的前置或者後置聲明語句都存在的時候才需要(1),兩個聲明語句都必須是簡短的聲明語句。如果變量是在一個可選的聲明語句中創建的,或者用來保存一個 range 子句中產生的值(例如,使用:= 操作符),那麼它們的作用域就會從其聲明處擴展到for語句的末尾。

在無格式的for循環語法(1)中,布爾表達式的值必須是bool類型的,因為Go語言不會自動轉換非bool型的值。(布爾表達式和比較操作符的內容已在之前的表2-3中列出。)第二個for...range循環迭代一個字符串的語法(3)給出了字節偏移的索引。對於一個7位的ASCII字符串s,如其值為「XabYcZ」,該語句產生的輸出為0、1、2、3、4和5。但是對於一個UTF-8的字符串類型s,例如其值為「XαβYγZ」,則產生的索引值為0、1、3、5、6、8。第一個迭代字符串的for...range循環語法(2)在大多數情況下都比第二種語法(3)方便。

對於非空切片或者索引而言,第二個迭代數組或者切片的for...range循環語法(5)獲取索引從0到len(slice) - 1的項。該語法與第一個迭代數組或者切片的語法(4)都非常有用。這兩個語法能夠解釋為什麼Go程序中更少使用普通的for循環(1)。

迭代映射的鍵-值對(6)或鍵(7)的for...range循環以任意順序的形式得到映射中的項或者鍵。如果需要有序的映射,解決方案之一是使用第二種語法(7)創建一個由鍵組成的切片,然後將切片排序。我們已經在前面章節中看過一個相關的例子(參見4.3.4節)。另一種解決方案是優先使用一個有序數據結構。例如,一個有序映射。我們將在下章看一個類似的例子(參見6.5.3節)。

如果以上語法(2~7)作用於一個空字符串、數組、切片或映射,那麼for循環就什麼也不做,控制流程將從下一條語句繼續。

一個for循環可以隨時使用一個break語句來終止,這樣控制權將傳送給for循環語句的下一條語句。如果 break 語句聲明了一個標籤,那麼控制權就會進入包含該標籤的最內層for、switch或者select語句中。也可以通過使用一個continue語句來使得程序的控制權回到for循環的條件或者範圍子句,以進行下一次迭代(或者結束循環)。

我們已經在看到過很多for語句的使用案例,其中包含for...range循環、無限循環以及在Go語言中使用得不是很多的普通for循環(因為其他循環更為方便)。當然,在本書的後續章節以及本章的後面節中,我們也會看到很多使用for循環的例子,因此這裡我們就只看一個小例子。

假設我們有一個二維切片(即其類型為int),想要從中搜索看看是否包含某個特定的值。這裡有兩種搜索的方法。兩者都使用第二種遍歷數組或切片的for...range循環語法(5)。

標籤是一個後面帶一個冒號的標識符。這兩個代碼段的功能一樣,但是右邊的代碼比左邊的代碼更加簡短和清晰,因為一旦成功搜索到目標值(x),它就會使用一個聲明了一個標籤的break 子句跳轉到外層循環。如果我們的循環嵌套得很深(例如,迭代一個三維的數據),使用帶標籤的中斷語句的優勢就更加明顯。

標籤可以作用於for、switch以及select語句。break和continue語句都可以聲明標籤,並且都可用於for循環裡面。同時,也可以在switch和select語句裡面使用break語句,無論是裸的break語句還是聲明了一個標籤的break語句。

標籤也可以獨立出現在程序中,它們可能用做goto語句的目標(使用goto label語法)。如果一個 goto 語句跳過了任何創建變量的語句,則程序的行為是未定義的。幸運的話程序會崩潰,但它也可能繼續運行並輸出錯誤的結果。一個使用 goto 語句的案例是用於自動生成代碼,因為在這種情況下goto語句非常方便,並且無需顧慮意大利面式代碼問題(spaghetti code,指代碼的控制結構特別複雜難懂)。雖然在寫本書時有超過30個Go語言的源代碼文件中使用了goto語句,但本書的例子中不會出現goto語句,我們提倡避免它[5]。

5.4 通信和並發語句

Go語言的通信與並發特性將在第7章講解,但是為了過程式編程講解的完整性,我們在這裡描述下它的基本語法。

goroutine 是程序中與其他goroutine 完全相互獨立而並發執行的函數或者方法調用。每一個Go 程序都至少有一個goroutine,即會執行main 包中的main函數的主goroutine。goroutine非常像輕量級的線程或者協程,它們可以被大批量地創建(相比之下,即使是少量的線程也會消耗大量的機器資源)。所有的goroutine共享相同的地址空間,同時Go語言提供了鎖原語來保證數據能夠安全地跨goroutine共享。然而,Go語言推薦的並發編程方式是通信,而非共享數據。

Go語言的通道是一個雙向或者單向的通信管道,它們可用於在兩個或者多個goroutine之間通信(即發送和接收)數據。

在goroutine和通道之間,它們提供了一種輕量級(即可擴展的)並發方式,該方式不需要共享內存,因此也不需要鎖。但是,與所有其他的並發方式一樣,創建並發程序時務必要小心,同時與非並發程序相比,對並發程序的維護也更有挑戰。大多數操作系統都能夠很好地同時運行多個程序,因此利用好這點可以降低維護的難度。例如,將多份程序(或者相同程序的多份副本)的每一個操作作用於不同的數據上。優秀的程序員只有在其帶來的優點明顯超過其所帶來的負擔時才會編寫並發程序。

goroutine使用以下的go語句創建:

go function(arguments)

go func(parameters) { block } (arguments)

我們必須要麼調用一個已有的函數,要麼調用一個臨時創建的匿名函數。與其他函數一樣,該函數可能包含零到多個參數,並且如果它包含參數,那麼必須像其他函數調用一樣傳入對應的參數。

被調用函數的執行會立即進行,但它是在另一個goroutine上執行,並且當前goroutine的執行(即包含該go語句的goroutine)會從下一條語句中立即恢復。因此,執行一個go語句之後,當前程序中至少有兩個goroutine在運行,其中包括原始的goroutine(初始的主goroutine)和新創建的goroutine。

少數情況下需要開啟一串的goroutine,並等待它們完成,同時也不需要通信。然而,在大多數情況下,goroutine之間需要相互協作,這最好通過讓它們相互通信來完成。下面是用於發送和接收數據的語法:

channel <- value      // 阻塞發送

<-channel         // 接收並將其丟棄

x := <-channel       // 接收並將其保存

x, ok := <-channel     // 功能同上,同時檢查通道是否已關閉或者是否為空

非阻塞的發送可以使用select語句來達到,或者在一些情況下使用帶緩衝的通道。通道可以使用內置的make函數通過以下語法來創建:

make(chan Type)

make(chan Type, capacity)

如果沒有聲明緩衝區容量,那麼該通道就是同步的,因此會阻塞直到發送者準備好發送和接收者準備好接收。如果給定了一個緩衝區容量,通道就是異步的。只要緩衝區有未使用空間用於發送數據,或還包含可以接收的數據,那麼其通信就會無阻塞地進行。

通道默認是雙向的,但如果需要我們可以使得它們是單向的。例如,為了以編譯器強制的方式更好地表達我們的語義。在第7章中我們將看到如何創建單向的通道,然後在任何適當的時候都使用單向通道。

讓我們結合一個小例子理解上文中討論的語法[6]。我們將創建返回一個通道的createCounter函數。當我們從中接收數據時,該通道會發送一個int類型數據。通道返回的第一個值是我們傳送給createCounter函數的值,往後返回的每一個值都比前面一個大1。下面展示了我們如何創建兩個獨立的counter 通道(每個都在它們自己的goroutine 裡執行)以及它們產生的結果。

counterA := createCounter(2)    // counterA是chan int類型的

counterB := createCounter(102)   // counterB是chan int類型的

for i := 0; i < 5; i++ {

a := <-counterA

fmt.Printf(〞(A→%d, B→%d)〞, a, <-counterB)

}

fmt.Println

(A→2, B→102) (A→3, B→103) (A→4, B→104) (A→5, B→105) (A→6, B→106)

我們用兩種方式展示了如何從通道獲取數據。第一種接收方式將獲取的數據保存到一個變量裡,第二種接收方式將接收的值直接以參數的形式傳遞給一個函數。

這兩個 createCounter函數的調用是在主 goroutine 中進行的,而另外兩個由createCounter函數創建的goroutine 初始時都被阻塞。在主 goroutine 中,只要我們一從這兩個通道中接收數據,就會發生一次數據發送,然後我們就能接收其值。然後,發送數據的goroutine再次阻塞,等待一個新的接收請求。這兩個通道是無限的,即它們可以無限地發送數據。(當然,如果我們達到了int型數據的極限,下一個值就會從頭開始。)一旦我們想要接收的五個值都從通道中接收完成,通道將繼續阻塞以備後續使用。

如果不再需要了,我們如何清理用於計數器通道的goroutine 呢?這需要讓它跳出無限循環,以終止發送數據,然後關閉它們使用的通道。我們將在下一節提供一種方法。當然,第 7章中我們將深入討論更多關於並發的內容。

func createCounter(start int) chan int{

next := make(chan int)

go func(i int) {

for {

next <- i

i++

}

}(start)

return next

}

該函數接收一個初始值,然後創建一個通道用於發送和接收int型數據。然後,它將該初始值傳入在一個新的goroutine中執行的匿名函數。該匿名函數有一個無限循環,它簡單地發送一個int型數據,並在每次迭代中將該int型數據加1。由於通道創建時其容量為0,因此該發送會阻塞直到收到一個從通道中接收數據的請求。該阻塞只會影響匿名函數所在的goroutine,因此程序中剩下的其他goroutine對此一無所知,並且將繼續運行。一旦該goroutine被設置為運行狀態(當然,從這點來看它會立即阻塞),緊接著該函數的下一條語句會立即執行,將通道返回給其調用者。

有些情況下我們可能有多個goroutine並發執行,每一個goroutine都有其自身通道。我們可以使用select語句來監控它們的通信。

select語句

Go語言的select語句語法如下[7]:

select {

case sendOrReceive1: block1

...

case sendOrReceiveN: blockN

default: blockD

}

在一個 select 語句中,Go語言會按順序從頭至尾評估每一個發送和接收語句。如果其中的任意一語句可以繼續執行(即沒有被阻塞),那麼就從那些可以執行的語句中任意選擇一條來使用。如果沒有任意一條語句可以執行(即所有的通道都被阻塞),那麼有兩種可能的情況。如果給出了default語句,那麼就會執行default語句,同時程序的執行會從select語句後的語句中恢復。但是如果沒有default語句,那麼select語句將被阻塞,直到至少有一個通信可以繼續進行下去。

一個select語句的邏輯結果如下所示。一個沒有default語句的select語句會阻塞,只有當至少有一個通信(接收或者發送)到達時才完成阻塞。一個包含 default 語句的select 語句是非阻塞的,並且會立即執行,這種情況下可能是因為有通信發生,或者如果沒有通信發生就會執行default語句。

為了瞭解和掌握該語法,讓我們來看兩個簡短的例子。第一個例子有些刻意為之,但能夠讓我們很好地理解select語句是如何工作的。第二個例子給出了更為符合實際的用法。

channels := make(chan bool, 6)

for i := range channels {

channels[i] = make(chan bool)

}

go func {

for {

channels[rand.Intn(6)] <- true

}

}

在上面的代碼片段中,我們創建了6個用於發送和接收布爾數據的通道。然後我們創建了一個goroutine,其中有一個無限循環語句,在循環中每次迭代都隨機選擇一個通道並發送一個true值。當然,該goroutine會立即阻塞,因為這些通道不帶緩衝且我們還沒從這些通道中接收數據。

for i := 0; i < 36; i++ {

var x int

select {

case <-channels[0]:

x = 1

case <-channels[1]:

x = 2

case <-channels[2]:

x = 3

case <-channels[3]:

x = 4

case <-channels[4]:

x = 5

case <-channels[5]:

x = 6

}

fmt.Printf(〞%d〞, x)

}

fmt.Println

6 4 6 5 4 1 2 1 2 1 5 5 4 6 2 3 6 5 1 5 4 4 3 2 3 3 3 5 3 6 5 2 2 3 6 2

上面代碼片段中,我們使用 6 個通道來模擬一個公平骰子的滾動(嚴格地講,是一個偽隨機的骰子)。其中的select語句等待通道發送數據,由於我們沒有提供一個default語句,該select語句會阻塞。一旦有一個或者更多個通道準備好了發送數據,那麼程序會以偽隨機的形式選擇一個case語句來執行。由於該select語句在一個普通for循環內部,它會執行固定數量的次數。

接下來讓我們看一個更加實際的例子。假設我們要對兩個獨立的數據集進行同樣的昂貴計算,並產生一系列結果。下面是執行該計算的函數框架。

func expensiveComputation(data Data, answer chan int, done chan bool) {

// 設置……

finished := false

for !finished {

// 計算……

answer <- result

}

done <- true

}

該函數接收需要計算的數據和兩個通道。answer 通道用於將每個結果發送回監控代碼中,而done通道則用於通知監控代碼計算已經完成。

// 設置 ……

const allDone = 2

doneCount := 0

answerα := make(chan int)

answerβ := make(chan int)

defer func {

close(answerα)

close(answerβ)

}

done := make(chan bool)

defer func { close(done) }

go expensiveComputation(data1, answerα, done)

go expensiveComputation(data2, answerβ, done)

for doneCount != allDone {

var which, result int

select {

case result = <-answerα:

which = 'α'

case result = <-answerβ:

which = 'β'

case <-done:

doneCount++

}

if which != 0 {

fmt.Printf(〞%c→%d 〞, which, result)

}

}

fmt.Println

α→3 β→3 α→0 β→9 α→0 β→2 α→9 β→3 α→6 β→1 α→0 β→8 α→8 β→5 α→0 β→0 α→3

上面這些代碼設置了通道,並開始執行計算,監控進度,然後在程序的末尾進行清理。以上代碼沒出現一個鎖。

開始時我們創建兩個通道answerα和answerβ 來接收結果,以及另一個通道done來跟蹤計算是否完成。我們創建一個匿名函數來關閉這些通道,並使用defer 語句來保證它們在不再需要用到時才被關閉,即外層函數返回時。接下來,我們進行昂貴的計算(分別在它們自己的goroutine裡進行),每一個計算使用的都是獨立分配的數據、獨立的結果通道以及共享的done通道。

我們本可以讓每一個計算都使用相同的answer通道,但如果真那樣做的話我們就不知道哪個計算返回的是哪個結果了(當然這可能也沒關係)。如果我們想讓每個計算共享相同的通道,同時又想為不同的結果標記其源頭,我們可以使用一個操作一個結構體的通道,例如,type Answer struct{id, answer int}。

這兩個計算開始於各自的goroutine中(但是是阻塞的,因為它們的通道是非緩衝的)之後,我們就可以從它們那裡獲取結果。每次迭代時,for循環中的which和result值都是全新的,而其阻塞的select語句會任意選擇一個已準備好的case語句執行。如果一個結果已經準備好了,我們會設置which來標記它的源頭,並將該源頭與結果打印出來。如果done通道準備好了,我們將 doneCount 計數器加 1。當其值達到我們預設的需要計算的個數時,就表示所有計算都完成了,for循環結束。

一旦跳出for循環後,我們就知道兩個進行計算的goroutine都不會再發送數據到通道裡去(因為它們完成時會自動跳出它們自身的無限循環,參見5.4節)。當函數返回時,defer語句中會自動將通道關閉,而其所使用的資源也會被釋放。這樣,垃圾回收器就會清理這幾個goroutine,因為它們不再需要執行,並且所使用的通道也已被關閉。

Go語言的通信和並發特性非常靈活而功能強大,第7章將專門闡述該主題。

5.5 defer、panic和recover

defer語句用於延遲一個函數或者方法(或者當前所創建的匿名函數)的執行,它會在外圍函數或者方法返回之前但是其返回值(如果有的話)計算之後執行。這樣就有可能在一個被延遲執行的函數內部修改函數的命名返回值(例如,使用賦值操作符給它們賦新值)。如果一個函數或者方法中有多個defer語句,它們會以LIFO(Last In Firs Out,後進先出)的順序執行。

defer語句最常用的用法是,保證使用完一個文件後將其成功關閉,或者將一個不再使用的通道關閉,或者捕獲異常。

var file *os.File

var err error

if file, err = os.Open(filename); err != nil {

log.Println(〞failed to open the file〞, err)

return

}

defer file.Close

這段代碼摘自wordfrequency程序的updateFrequencies函數,我們在之前的章節中討論過它。這裡展示了一個典型的模式,即在打開文件並在文件打開成功後用延遲執行的方式保證將其關閉。

該模式創建了一個值,並在該值被垃圾收集之前延遲執行一些關閉函數來清理該值(例如,釋放一些該值所使用的資源)。這個模式在 Go語言中是一個標準做法[8]。雖然很少用到,我們當然也可以將該模式應用於自定義類型,為類型定義Close或者Cleanup方法,並將該方法用defer語法調用。

panic和recover

通過內置的panic和recover函數,Go語言提供了一套異常處理機制。類似於其他語言(例如,C++、Java和Python)中所提供的異常機制,這些函數也可以用於實現通用的異常處理機制,,但是這樣做在Go語言中是不好的風格。

Go語言將錯誤和異常兩者區分對待。錯誤是指可能出錯的東西,程序需以優雅的方式將其處理(例如,文件不能被打開)。而異常是指「不可能」發生的事情(例如,一個應該永遠為true的條件在實際環境中卻是false的)。

Go語言中處理錯誤的慣用法是將錯誤以函數或者方法最後一個返回值的形式將其返回,並總是在調用它的地方檢查返回的錯誤值(不過通常在將值打印到終端的時候會忽略錯誤值。)

對於「不可能發生」的情況,我們可以調用內置的panic函數,該函數可以傳入任何想要的值(例如,一個字符串用於解釋為什麼那些不變的東西被破壞了)。在其他語言中,這種情況下我們可能使用一個斷言,但在Go語言中我們使用panic。在早期開發以及任何發佈階段之前,最簡單同時也可能是最好的方法是調用 panic函數來中斷程序的執行以強制發生錯誤,使得該錯誤不會被忽略因而能夠被盡快修復。一旦開始部署程序時,任何情況下可能發生錯誤都應該盡一切可能避免中斷程序。我們可以保留所有 panic函數但在包中添加一個延遲執行的recover調用來達到這個目的。在恢復過程中,我們可以捕捉並記錄任何異常(以便這些問題保留可見),同時向調用者返回非nil的錯誤值,而調用者則會試圖讓程序恢復到健康狀態並繼續安全運行。

當內置的panic函數被調用時,外圍函數或者方法的執行會立即中止。然後,任何延遲執行的函數或者方法都會被調用,就像其外圍函數正常返回一樣。最後,調用返回到該外圍函數的調用者,就像該外圍調用函數或者方法調用了 panic一樣,因此該過程一直在調用棧中重複發生:函數停止執行,調用延遲執行函數等。當到達main函數時不再有可以返回的調用者,因此這時程序會終止,並將包含傳入原始panic函數中的值的調用棧信息輸出到os.Stderr。

上面所描述的只是一個異常發生時正常情況下所展開的。然而,如果其中有個延遲執行的函數或者方法包含一個對內置的recover函數(可能只在一個延遲執行的函數或者方法中調用)的調用,該異常展開過程就會終止。這種情況下,我們就能夠以任何我們想要的方式響應該異常。有種解決方案是忽略該異常,這樣控制權就會交給包含了延遲執行的recover調用的函數,該函數然後會繼續正常執行。我們通常不推薦這種方法,但如果使用了,至少需要將該異常記錄到日誌中以不完全隱藏該問題。另一種解決方案是,我們完成必要的清理工作,然後手動調用 panic函數來讓該異常繼續傳播。一個通用的解決方案是,創建一個 error值,並將其設置成包含了recover調用的函數的返回值(或返回值之一),這樣就可以將一個異常(即一個panic)轉換成錯誤(即一個error)。

絕大多數情況下,Go語言標準庫使用error值而非異常。對於我們自己定義的包,最好別使用 panic。或者,如果要使用 panic,也要避免異常離開這個自定義包邊界,可以通過使用recover來捕捉異常並返回一個相應的錯誤值,就像標準庫中所做的那樣。

一個說明性的例子是 Go語言中最基本的正則表達式包 regexp。該包中有一些函數用於創建正則表達式,包括regexp.Compile和regexp.MustCompile。第一個函數返回一個編譯好的正則表達式和nil,或者如果所傳入的字符串不是個合法的正則表達式,則返回nil和一個error值。第二個函數返回一個編譯好的正則表達式,或者在出問題時拋出異常。第一個函數非常適合於當正則表達式來自於外部源時(例如,當來自於用戶輸入或者從文件讀取時)。第二個函數非常適合於當正則表達式是硬編碼在程序中時,這樣可以保證如果我們不小心對正則表達式犯了個錯誤,程序會因為異常而立即退出。

什麼時候應該允許異常終止程序,什麼時候又應該使用recover來捕捉異常?有兩點相互衝突的利益需要考慮。作為一個程序員,如果程序中有邏輯錯誤,我們希望程序能夠立馬崩潰,以便我們可以發現並修改該問題。但一旦程序部署好了,我們就不想讓我們的程序崩潰。

對於那些只需通過執行程序(例如,一個非法的正則表達式)就能夠捕捉的問題,我們應該使用panic(或者能夠發生異常的函數,如regexp.MustCompile)、因為我們永遠不會部署一個一運行就崩潰的程序。我們要小心只在程序運行時一定會被調用到的函數中才這樣做,例如main包中的init函數(如果有的話)、main包中的main函數,以及任何我們的程序所導入的自定義包中的init函數,當然也包括這些函數所調用的任何函數或者方法。如果我們在使用測試套件,我們當然可以把異常的使用擴展至測試套件會調用到的任何函數或者方法。自然地,我們必須保證無論程序的控制流程如何進行,潛在的異常的情況總是能夠被適當地處理。

對於任何特殊情況下可能運行也可能不運行的函數或者方法,如果調用了panic函數或者調用了發生異常的函數或者方法,我們應該使用 recover以保證將異常轉換成錯誤。理想情況下,recover函數應該在盡可能接近於相應 panic的地方被調用,並在設置其外圍函數的error返回值之前盡可能合理的將程序恢復到健康狀態。對於main包的main函數,我們可以放入一個「捕獲一切」的recover函數,用於記錄任何捕獲的異常。但不幸的是,延遲執行的recover函數被調用後程序會終止。稍後我們會看到,我們可以繞過這個問題。

接下來讓我們看兩個例子,第一個演示了如何將異常轉換成錯誤,第二個例子展示了如何讓程序變得更健壯。

假設我們有如下函數,它在我們所使用的某個包的深處。但我們沒法更改這個包,因為它來自於一個我們無法控制的第三方。

func ConvertInt64ToInt(x int64) int {

if math.MinInt32 <= x && x <= math.MaxInt32{

return int(x)

}

panic(fmt.Sprintf(〞%d is out of the int32 range〞, x))

}

該函數安全地將一個int64類型的值轉換成一個int類型的值,如果該轉換產生的結果非法,則報告發生異常。

為什麼一個這樣的函數優先使用 panic呢?我們可能希望一旦有錯就強制崩潰,以便盡早弄清楚程序錯誤。另一種使用案例是,我們有一個函數調用了一個或者多個其他函數,一旦出錯我們希望盡快返回到原始調用函數,因此我們讓被調用的函數碰到問題時拋出異常,並在調用處使用recover捕獲該異常(無論異常來自哪裡)。正常情況下,我們希望包報告錯誤而非拋出異常,因此常用的做法是在一個包內部使用panic,同時使用recover來保證產生的異常不會洩露出去,而只是報告錯誤。另一種使用案例是,將類似panic(〞unreachable〞)這樣的調用放在一個我們從邏輯上判斷不可能到達的地方(例如函數的末尾,而該函數總是會在到達末尾之前通過return語句返回),或者在一個前置或者後置條件被破壞時才調用panic函數。這樣做可以保證,如果我們破壞了函數的邏輯,立馬就能夠知道。

如果以上理由沒有一個成立,那麼當問題發生時我們就應該避免崩潰,而只是返回一個非空的error值。因此,在本例中,如果轉換成功,我們希望返回一個int型值和一個nil,如果失敗則返回一個int值和一個非空的錯誤值。下面是一個包裝函數,能夠實現我們想要的功能。

func IntFromInt64(x int64) (i int, err error){

defer func{

if e := recover; e != nil{

err = fmt.Errorf(〞%v〞, e)

}

}

i = ConvertInt64ToInt(x)

return i, nil

}

該函數被調用時,Go語言會自動地將其返回值設置成其對應類型的零值,如在這裡是0和nil。如果對自定義的ConvertInt64ToInt函數正常返回,我們將其值賦值給i返回值,並返回i和一個表示沒錯誤發生的nil值。但是如果ConvertInt64ToInt函數拋出異常,我們可以在延遲執行的匿名函數中捕獲該異常,並將err設置成一個錯誤值,其文本為所捕獲錯誤的文本表示。

如IntFromInt64函數所示,可以非常容易將異常轉換成錯誤值。

對於我們第二個例子,我們考慮如何讓一個 Web 服務器在遇到異常時仍能夠健壯地運行。我們回顧下第2章中的statistics例子(參見2.4節)。如果我們在那個服務器端犯了個程序錯誤,例如,我們意外地傳入了一個nil值作為image.Image值,並調用它的一個方法,我們可能得到一個如果不調用 recover函數就會導致程序中止的異常。如果網站對我們來說非常重要,特別是我們希望在無人值守的情況下持續運行時,這當然是讓人非常不滿意的場景。我們期望的是即使出現異常服務器也能繼續運行,同時將任何異常都以日誌的形式記錄下來,以便將我們進行跟蹤並在有時間時將其修復。

我們創建了一個statistics例子的修改版(事實上,是statistics_ans解決方案的修改版),保存在文件statistics_nonstop/statistics.go中。為了測試需要,我們所做的修改是在網頁上添加一個額外的「Panic!」按鈕,點擊後可產生一個異常。其中所做的最重要的修改是,我們讓服務器可以從異常恢復。為了更好地查看發生了什麼,每當成功響應一個客戶端,或者當我們得到一個錯誤的請求時,或者如果服務器重啟了,我們都以日誌的形式將其記錄下來。下面是一個常規日誌的小樣本。

[127.0.0.1:41373] served OK

[127.0.0.1:41373] served OK

[127.0.0.1:41373] bad request: '6y' is invalid

[127.0.0.1:41373] served OK

[127.0.0.1:41373] caught panic: user clicked panic button!

[127.0.0.1:41373] served OK

為了讓輸出結果更適合於閱讀,我們告訴log包不要打印時間戳。

在瞭解我們對代碼做了什麼更改之前,讓我們簡單地回顧下原始代碼。

func main{

http.HandleFunc(〞/〞, homePage)

if err := http.ListenAndServe(〞:9001〞, nil); err != nil {

log.Fatal(〞failed to start server〞, err)

}

}

func homePage(writer http.ResponseWriter, request *http.Request) {

// …

}

雖然我們所要展示的技術可應用於創建有多個網頁的網站,但這裡這個網站只有一個網頁。如果發生了異常而沒有被recover捕獲,即該異常被傳播到了main函數,服務器就會終止,這就是我們所要阻止的。

func homePage(writer http.ResponseWriter, request *http.Request) {

defer func { // 每一個頁面都需要

if x := recover; x != nil {

log.Printf(〞[%v] caught panic: %v〞, request.RemoteAddr, x)

}

}

// …

}

對於能夠健壯地應對異常的Web服務器而言,我們必須保證每一個頁面響應函數都有一個調用 recover的匿名函數。這可以阻止異常的蔓延。然而,這不會阻止頁面響應函數返回(因為延遲執行的語句只是在函數的返回語句之前執行),但這不重要,因為每次頁面被請求時,http.ListenAndServer函數會重新調用頁面響應函數。

當然,對於一個含有大量頁面處理函數的網站,添加一個延遲執行的函數來捕獲和記錄異常會產生大量重複的代碼,並且容易被遺漏。我們可以通過將每個頁面處理函數都需要的代碼包裝為一個函數來解決這個問題。使用包裝函數,只要改變下http.HandleFunc函數的調用,我們可以從頁面處理函數中移除恢復代碼。

http.HandleFunc(〞/〞, logPanics(homePage))

這裡我們使用原始的homePage函數(即未調用延遲執行recover的版本),它依賴於logPanics包裝函數來處理異常。

func logPanics(function func(http.ResponseWriter,

*http.Request)) func(http.ResponseWriter, *http.Request) {

return func(writer http.ResponseWriter, request *http.Request) {

defer func {

if x := recover; x != nil {

log.Printf(〞[%v] caught panic: %v〞, request.RemoteAddr, x)

}

}

function(writer, request)

}

}

該函數接收一個 HTTP 處理函數作為其唯一參數,創建並返回一個匿名函數。該匿名函數包含一個延遲執行的(同時也是)匿名函數以捕獲並記錄異常,然後調用所傳入的處理函數。這跟我們在上面修改過的homePage函數中所看到的效果一樣,它添加了一個延遲執行的異常捕獲器和日誌記錄器,但是更為方便,因為我們無需為每一個頁面處理函數添加一個延遲執行函數。相反,我們使用logPanics包裝器將每個頁面處理函數傳入http.HandleFucn。

文件statistics_nonstop2/statistics.go中有使用該技術的statistics程序的版本。匿名函數的內容將在下一節中關於閉包的節中詳細闡述(參見5.6.3節)。

5.6 自定義函數

函數是面向過程編程的根本,Go語言原生支持函數。Go語言的方法(在第6章描述)和函數是很相似的,所以本章的主題和過程編程以及面向對像編程都相關。下面是函數定義的基本語法。

func functionName(optionalParameters) optionalReturnType {

body

}

func functionName(optionalParameters) (optionalReturnValues) {

body

}

函數可以有任意多個參數,如果沒有參數那麼圓括號是空的,否則要寫成這樣:params1 type1,..., paramsN typeN,其中params1是參數,type1是參數類型,多個參數之間要用逗號分隔開。參數必須按照給定的順序來傳遞,沒有和Python的命名參數相同的功能。不過Go語言裡也可以實現一種類似的效果,後面就可以看到(5.6.1.3節)。

如果要實現可變參數,可以將最後一個參數的類型之前寫上省略號,也就是說,函數可以接收任意多個那個類型的值,在函數里,實際上這個參數的類型是type。

函數的返回值也可以是任意個,如果沒有,那麼返回值列表的右括號後面是緊接著左大括號的。如果只有一個返回值可以直接寫返回的類型,如果有兩個或者多個沒有命名的返回值,必須使用括號而且得這樣寫(type1,..., typeN)。如果有一個或者多個命名的返回值,也必須使用括號,要寫成這樣(values1 type1,..., valuesN typeN),其中values1是一個返回值的名稱,多個返回值之間必須使用逗號分隔開。函數的返回值可以全部命名或者全都不命名,但不能只是部分命名的。

如果函數有返回值,則函數必須至少有一個return語句或者最後執行panic調用。如果返回值不是命名的,則return語句必須指定和返回值列表一樣多的值。如果返回值是命名的,則return語句可以像沒有命名的返回值方式一樣或者是一個空的return語句。注意儘管空的return語句是合法的,但它被認為是一種拙劣的寫法,我們這本書所有的例子都沒有這樣寫。

如果函數有返回值,則函數的最後一個語句必須是一個return語句或者panic調用。如果函數是以拋出異常結束,Go 編譯器會認為這個函數不需要正常返回,所以也就不需要這個return語句。但是如果函數是以if語句或switch語句結束,且這個if語句的else分支以return語句結尾或者switch語句的default分支以return語句結尾的話,Go編譯器還無法意識到它們後面已經不需要return語句。對於這種情況的解決方法有幾種,要麼不給if語句和switch語句添加對應的else語句和default分支,要麼將return語句放到if或者switch後面,或者在最後簡單地加上一句panic(〞unreachable〞)語句,我們前面看到過這種做法(5.2.2.1節)。

5.6.1 函數參數

我們之前見過的函數都是固定參數和指定類型的,但是如果參數的類型是interface{},我們就可以傳遞任何類型的數據。通過使用接口類型參數(無論是自定義接口類型還是標準庫裡定義的接口類型),我們可以讓所創建的函數接受任何實現特定方法集合的類型作為參數,我們在6.3節會繼續討論這個問題。

這一節我們來瞭解關於函數參數的其他內容。第一個小節關於如何將函數的返回值作為其他函數的參數,第二小節討論可變參數,最後我們討論如何實現可選參數。

5.6.1.1 將函數調用作為函數的參數

如果我們有一個函數或者方法,接收一個或者多個參數,我們可以理所當然地直接調用它並給它相應的參數。另外,我們可以將其他函數或者方法調用作為一個函數的參數,只要該作為參數的函數或者方法的返回值個數和類型與調用函數的參數列表匹配即可。

下面是一個例子,一個函數要求傳入三角形的邊長(以 3 個整型數的方式),然後使用海倫公式計算出三角形的面積。

for i := 1; i <= 4; i++ {

a, b, c := PythagoreanTriple(i, i+1)

∆1 := Heron(a, b, c)

∆2 := Heron(PythagoreanTriple(i, i+1))

fmt.Printf(〞∆1 == %10f == ∆2 == %10f\n〞, ∆1, ∆2)

}

∆1 == 6.000000 == ∆2 == 6.000000

∆1 == 30.000000 == ∆2 == 30.000000

∆1 == 84.000000 == ∆2 == 84.000000

∆1 == 180.000000 == ∆2 == 180.000000

首先我們使用歐幾里德的勾股函數來獲得邊長,然後將這3個邊長作為Heron的參數,應用海倫公式來計算面積。我們重複一次這個計算過程,不過這次我們是直接將PythagoreanTriple函數作為Heron函數的參數,交由Go語言將PythagoreanTriple函數的3個返回值轉換成Heron函數的參數。

func Heron(a, b, c int) float64 {

α, β, γ := float64(a), float64(b), float64(c)

s := (α + β + γ) / 2

return math.Sqrt(s * (s - α) * (s - β) * (s - γ))

}

func PythagoreanTriple(m, n int) (a, b, c int) {

if m < n {

m, n = n, m

}

return (m * m) - (n * n), (2 * m * n), (m * m) + (n * n)

}

為了閱讀完整性,這裡給出了 Heron和PythagoreanTriple函數的實現。這裡PythagoreanTriple函數使用了命名返回值(算是對該函數文檔的一些補充)。

5.6.1.2 可變參數函數

所謂可變參數函數就是指函數的最後一個參數可以接受任意個參數。這類函數在最後一個參數的類型前面添加有一個省略號。在函數里面這個參數實質上變成了一個對應參數類型的切片。例如,我們有一個簽名是Join(xs...string)的函數,xs的類型其實是string。

下面是一個使用可變參數的例子,它返回輸入的整數里最小的一個。我們將分析它的調用過程以及輸出的結果。

fmt.Println(MinimumInt1(5, 3), MinimumInt1(7, 3, -2, 4, 0, -8, -5))

3 –8

MinimumInt1函數可以傳入一個或者多個整型數,然後返回其中最小的一個。

func MinimumInt1(first int, rest...int) int {

for _, x := range rest {

if x < first {

first = x

}

}

return first

}

我們可以很容易地實現一個任意參數(即使不傳參數也可以)的函數,例如MinimumInt0 (ints...int),或者至少是兩個整型數的函數,例如,MinimunInt2(forst, second, int, rest...int)。

假如我們有一個int類型的切片,我們可以這樣使用MinimunInt1函數。

numbers := int{7, 6, 2, -1, 7, -3, 9}

fmt.Println(MinimumInt1(numbers[0], numbers[1:]...))

-3

函數MinimunInt1至少需要一個int型的參數,當調用一個可變參數函數或者方法時,我們可以在一個slice後面放一個省略號,這樣就把切片變成了一系列參數,每個參數對應切片裡的一項。(我們之前在4.2.3節討論Go語言內置的append函數時討論過。)所以我們這裡實際上就是將numbers[1:]...展開成獨立的每一個參數6,-2,-1,7,-3,9了,而這些都會被保存在rest這個切片裡面。如果我們使用剛才提到過的MinimunInt0函數,我們簡單地調用MinimumInt0(numbers...)即可。

5.6.1.3 可選參數的函數

Go語言並沒有直接支持可選參數。但是,要實現它也不難,只需增加一個額外的結構體即可,而且Go語言能保證所有的值都會被初始化為零值。

假設我們有一個函數用來處理一些自定義的數據,默認就是簡單地處理所有的數據,但有些時候我們希望可以指定處理第一個或者最後一個項,還有是否記錄函數的行為,或者對於非法的項做錯誤處理,等等。

一個辦法就是創建一個簽名為 ProcessItems(items Items, first, last int, audit bool, errorHandler func(item Item))的函數。在這個設計裡,如果last的值為0的話意味著需要取到最後一個item而不用管這個索引值,而errorHandler函數只有在不為nil時才會被調用。也就是說,不管在哪調用它,如果希望是默認行為的話,只需要寫ProcessItems(items, 0, 0, false, nil)就可以了。

一個比較優雅的做法就是這樣定義函數 ProcessItems(items Items, options Options),其中Options結構體保存了所有其他參數的值,初始值均為零值。這樣大部分調用都可以被簡化為 ProcessItems(items, Options{})。然後在我們需要指定一個或者多個額外參數的場合,我們可以為 Options 結構指定一到多個字段的值(我們會在 6.4節詳細描述結構體)。讓我們來看看如何用代碼實現,先從Options結構開始。

type Options struct {

First   int   // 要處理的第一項

Last   int   // 要處理的最後一項(O意味著要從第一項開始處理所有項)

Audit   bool  // 如果為trne,所有動作都被記錄

ErrorHandler func(item Item) // 如果不是nil,對每一個壞項周用一次

}

一個結構體能夠聚合或者嵌入一個或者多個任何類型的字段(關於聚合和嵌入的區別將在第6章詳細描述)。這裡,Options結構體聚合了兩個int型字段、一個bool型字段以及一個簽名為func(Item)的函數,其中Item是某自定義類型。

ProcessItems(items, Options{})

errorHandler := func(item Item) { log.Println(〞Invalid:〞, item) }

ProcessItems(items, Options{Audit: true, ErrorHandler: errorHandler})

這塊代碼調用了兩次自定義函數ProcessItems,第一次調用使用默認的選項(例如,處理所有的項,但是不記錄任何的動作,對於非法的記錄也不調用錯誤處理函數來處理),第二次調用時創建了一個Options值,其中Options的First字段和Last字段是0(也就是告訴這個函數要處理所有的項),但設置了Audit和ErrorHandler字段這樣函數就能記錄它的行為而且當發現非法的項時能夠做一些相應的處理。

這種利用結構體來傳遞可選參數的技術在標準庫裡也有用到,例如,image.jpeg.Encode函數,我們在後面的6.5.2節還會看到這種技術。

5.6.2 init函數和main函數

Go語言為特定目的保留了兩個函數名: init函數(可以出現在任何的包裡)和main函數(只在main包裡)。這兩個函數既不可接收任何參數,也不返回任何結果,一個包裡可以有很多init函數。但是我寫這本書的時候,Go編譯器只支持每個包最多一個init函數,所以我們推薦你在一個包裡最多只用一個init函數。

init函數和main函數是自動執行的,所以我們不應該顯式調用它們。對程序或者包來說init是可選的,但是每一個程序必須在main包裡包含一個main函數。

Go程序的初始化和執行總是從main包開始,如果main包裡導入了其他的包,則會按順序將它們包含進 main 包裡。如果一個包被其他的包多次導入的話,這個包實際上只會被導入一次(例如,有好些包都會導入 fmt 這個包,一旦導入之後再遇到就不會再次導入)。當一個包被導入時,如果它自己還導入了其他的包,則還是先將其他的包導入進來,然後再創建這個包的一些常量和變量。再接著就是調用init函數了(如果有多個就調用多次),最終所有的包都會被導入到main包裡(包括這些包所導入的包等),這時候main這個包的常量和變量也會被創建,init函數會被執行(如果有或者多個的話)。最後,main包裡的main函數會被執行,程序開始運行。這些事件的過程如圖5-1所示。

圖5-1 程序的啟動順序

我們可以在init函數里寫一些go語句,但是要注意的是init函數會在main函數之前執行,所以init中不應該依賴任何在main函數里創建的東西。

讓我們來看一個例子(從第1章的americanise/americanise.go文件裡截取),看看實際會發生什麼事情。

package main

import (

〞bufio〞

〞fmt〞

//...

〞strings〞

)

var britishAmerican = 〞british-american.txt〞

func init {

dir, _ := filepath.Split(os.Args[0])

britishAmerican = filepath.Join(dir, britishAmerican)

}

func main {

//...

}

Go程序從main包開始,因為main包裡導入了其他的包,所以它先按順序從bufio包開始把其他的包導進來。bufio包自身也導入了一些其他的包,所以這些導入會先完成。在導入每一個包時總是先會去將這個包的所有依賴包導入,然後才創建包級別的常量和變量,再接著執行這個包的init函數。bufio包導入完成後fmt包會被導入。fmt包裡它自己也導入了strings包,所以當Go語言會忽略main包導入strings包的語句,因為strings包之前已被導入。