Go語言核心36講(Go語言進階技術十二)--學習筆記

MingsonZheng發表於2021-11-02

18 | if語句、for語句和switch語句

現在,讓我們暫時走下神壇,迴歸民間。我今天要講的if語句、for語句和switch語句都屬於 Go 語言的基本流程控制語句。它們的語法看起來很樸素,但實際上也會有一些使用技巧和注意事項。我在本篇文章中會以一系列面試題為線索,為你講述它們的用法。

那麼,今天的問題是:使用攜帶range子句的for語句時需要注意哪些細節? 這是一個比較籠統的問題。我還是通過程式設計題來講解吧。

本問題中的程式碼都被放在了命令原始碼檔案 demo41.go 的main函式中的。為了專注問題本身,本篇文章中展示的程式設計題會省略掉一部分程式碼包宣告語句、程式碼包匯入語句和main函式本身的宣告部分。

numbers1 := []int{1, 2, 3, 4, 5, 6}
for i := range numbers1 {
  if i == 3 {
    numbers1[i] |= i
  }
}
fmt.Println(numbers1)

我先宣告瞭一個元素型別為int的切片型別的變數numbers1,在該切片中有 6 個元素值,分別是從1到6的整數。我用一條攜帶range子句的for語句去迭代numbers1變數中的所有元素值。

在這條for語句中,只有一個迭代變數i。我在每次迭代時,都會先去判斷i的值是否等於3,如果結果為true,那麼就讓numbers1的第i個元素值與i本身做按位或的操作,再把操作結果作為numbers1的新的第i個元素值。最後我會列印出numbers1的值。

所以具體的問題就是,這段程式碼執行後會列印出什麼內容?

這裡的典型回答是:列印的內容會是[1 2 3 7 5 6]。

問題解析

你心算得到的答案是這樣嗎?讓我們一起來複現一下這個計算過程。

當for語句被執行的時候,在range關鍵字右邊的numbers1會先被求值。

這個位置上的程式碼被稱為range表示式。range表示式的結果值可以是陣列、陣列的指標、切片、字串、字典或者允許接收操作的通道中的某一個,並且結果值只能有一個。

對於不同種類的range表示式結果值,for語句的迭代變數的數量可以有所不同。

就拿我們這裡的numbers1來說,它是一個切片,那麼迭代變數就可以有兩個,右邊的迭代變數代表當次迭代對應的某一個元素值,而左邊的迭代變數則代表該元素值在切片中的索引值。

那麼,如果像本題程式碼中的for語句那樣,只有一個迭代變數的情況意味著什麼呢?這意味著,該迭代變數只會代表當次迭代對應的元素值的索引值。

更寬泛地講,當只有一個迭代變數的時候,陣列、陣列的指標、切片和字串的元素值都是無處安放的,我們只能拿到按照從小到大順序給出的一個個索引值。

因此,這裡的迭代變數i的值會依次是從0到5的整數。當i的值等於3的時候,與之對應的是切片中的第 4 個元素值4。對4和3進行按位或操作得到的結果是7。這就是答案中的第 4 個整數是7的原因了。

現在,我稍稍修改一下上面的程式碼。我們再來估算一下列印內容。

numbers2 := [...]int{1, 2, 3, 4, 5, 6}
maxIndex2 := len(numbers2) - 1
for i, e := range numbers2 {
  if i == maxIndex2 {
    numbers2[0] += e
  } else {
    numbers2[i+1] += e
  }
}
fmt.Println(numbers2)

注意,我把迭代的物件換成了numbers2。numbers2中的元素值同樣是從1到6的 6 個整數,並且元素型別同樣是int,但它是一個陣列而不是一個切片。

在for語句中,我總是會對緊挨在當次迭代對應的元素後邊的那個元素,進行重新賦值,新的值會是這兩個元素的值之和。當迭代到最後一個元素時,我會把此range表示式結果值中的第一個元素值,替換為它的原值與最後一個元素值的和,最後,我會列印出numbers2的值。

對於這段程式碼,我的問題依舊是:列印的內容會是什麼?你可以先思考一下。

好了,我要公佈答案了。列印的內容會是[7 3 5 7 9 11]。我先來重現一下計算過程。當for語句被執行的時候,在range關鍵字右邊的numbers2會先被求值。

這裡需要注意兩點:

1、range表示式只會在for語句開始執行時被求值一次,無論後邊會有多少次迭代;

2、range表示式的求值結果會被複制,也就是說,被迭代的物件是range表示式結果值的副本而不是原值。

基於這兩個規則,我們接著往下看。在第一次迭代時,我改變的是numbers2的第二個元素的值,新值為3,也就是1和2之和。

但是,被迭代的物件的第二個元素卻沒有任何改變,畢竟它與numbers2已經是毫不相關的兩個陣列了。因此,在第二次迭代時,我會把numbers2的第三個元素的值修改為5,即被迭代物件的第二個元素值2和第三個元素值3的和。

以此類推,之後的numbers2的元素值依次會是7、9和11。當迭代到最後一個元素時,我會把numbers2的第一個元素的值修改為1和6之和。

好了,現在該你操刀了。你需要把numbers2的值由一個陣列改成一個切片,其中的元素值都不要變。為了避免混淆,你還要把這個切片值賦給變數numbers3,並且把後邊程式碼中所有的numbers2都改為numbers3。

問題是不變的,執行這段修改版的程式碼後列印的內容會是什麼呢?如果你實在估算不出來,可以先實際執行一下,然後再嘗試解釋看到的答案。提示一下,切片與陣列是不同的,前者是引用型別的,而後者是值型別的。

我們可以先接著討論後邊的內容,但是我強烈建議你一定要回來,再看看我留給你的這個問題,認真地思考和計算一下。

知識擴充套件

問題 1:switch語句中的switch表示式和case表示式之間有著怎樣的聯絡?

先來看一段程式碼。

value1 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch 1 + 3 {
case value1[0], value1[1]:
  fmt.Println("0 or 1")
case value1[2], value1[3]:
  fmt.Println("2 or 3")
case value1[4], value1[5], value1[6]:
  fmt.Println("4 or 5 or 6")
}

我先宣告瞭一個陣列型別的變數value1,該變數的元素型別是int8。在後邊的switch語句中,被夾在switch關鍵字和左花括號{之間的是1 + 3,這個位置上的程式碼被稱為switch表示式。這個switch語句還包含了三個case子句,而每個case子句又各包含了一個case表示式和一條列印語句。

所謂的case表示式一般由case關鍵字和一個表示式列表組成,表示式列表中的多個表示式之間需要有英文逗號,分割,比如,上面程式碼中的case value1[0], value1[1]就是一個case表示式,其中的兩個子表示式都是由索引表示式表示的。

另外的兩個case表示式分別是case value1[2], value1[3]和case value1[4], value1[5], value1[6]。

此外,在這裡的每個case子句中的那些列印語句,會分別列印出不同的內容,這些內容用於表示case子句被選中的原因,比如,列印內容0 or 1表示當前case子句被選中是因為switch表示式的結果值等於0或1中的某一個。另外兩條列印語句會分別列印出2 or 3和4 or 5 or 6。

現在問題來了,擁有這樣三個case表示式的switch語句可以成功通過編譯嗎?如果不可以,原因是什麼?如果可以,那麼該switch語句被執行後會列印出什麼內容。

我剛才說過,只要switch表示式的結果值與某個case表示式中的任意一個子表示式的結果值相等,該case表示式所屬的case子句就會被選中。

並且,一旦某個case子句被選中,其中的附帶在case表示式後邊的那些語句就會被執行。與此同時,其他的所有case子句都會被忽略。

當然了,如果被選中的case子句附帶的語句列表中包含了fallthrough語句,那麼緊挨在它下邊的那個case子句附帶的語句也會被執行。

正因為存在上述判斷相等的操作(以下簡稱判等操作),switch語句對switch表示式的結果型別,以及各個case表示式中子表示式的結果型別都是有要求的。畢竟,在 Go 語言中,只有型別相同的值之間才有可能被允許進行判等操作。

如果switch表示式的結果值是無型別的常量,比如1 + 3的求值結果就是無型別的常量4,那麼這個常量會被自動地轉換為此種常量的預設型別的值,比如整數4的預設型別是int,又比如浮點數3.14的預設型別是float64。

因此,由於上述程式碼中的switch表示式的結果型別是int,而那些case表示式中子表示式的結果型別卻是int8,它們的型別並不相同,所以這條switch語句是無法通過編譯的。

再來看一段很類似的程式碼:

value2 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch value2[4] {
case 0, 1:
  fmt.Println("0 or 1")
case 2, 3:
  fmt.Println("2 or 3")
case 4, 5, 6:
  fmt.Println("4 or 5 or 6")
}

其中的變數value2與value1的值是完全相同的。但不同的是,我把switch表示式換成了value2[4],並把下邊那三個case表示式分別換為了case 0, 1、case 2, 3和case 4, 5, 6。

如此一來,switch表示式的結果值是int8型別的,而那些case表示式中子表示式的結果值卻是無型別的常量了。這與之前的情況恰恰相反。那麼,這樣的switch語句可以通過編譯嗎?

答案是肯定的。因為,如果case表示式中子表示式的結果值是無型別的常量,那麼它的型別會被自動地轉換為switch表示式的結果型別,又由於上述那幾個整數都可以被轉換為int8型別的值,所以對這些表示式的結果值進行判等操作是沒有問題的。

當然了,如果這裡說的自動轉換沒能成功,那麼switch語句照樣通不過編譯。

image

通過上面這兩道題,你應該可以搞清楚switch表示式和case表示式之間的聯絡了。由於需要進行判等操作,所以前者和後者中的子表示式的結果型別需要相同。

switch語句會進行有限的型別轉換,但肯定不能保證這種轉換可以統一它們的型別。還要注意,如果這些表示式的結果型別有某個介面型別,那麼一定要小心檢查它們的動態值是否都具有可比性(或者說是否允許判等操作)。

因為,如果答案是否定的,雖然不會造成編譯錯誤,但是後果會更加嚴重:引發 panic(也就是執行時恐慌)。

問題 2:switch語句對它的case表示式有哪些約束?

我在上一個問題的闡述中還重點表達了一點,不知你注意到了沒有,那就是:switch語句在case子句的選擇上是具有唯一性的。

正因為如此,switch語句不允許case表示式中的子表示式結果值存在相等的情況,不論這些結果值相等的子表示式,是否存在於不同的case表示式中,都會是這樣的結果。具體請看這段程式碼:

value3 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch value3[4] {
case 0, 1, 2:
  fmt.Println("0 or 1 or 2")
case 2, 3, 4:
  fmt.Println("2 or 3 or 4")
case 4, 5, 6:
  fmt.Println("4 or 5 or 6")
}

變數value3的值同value1,依然是由從0到6的 7 個整陣列成的陣列,元素型別是int8。switch表示式是value3[4],三個case表示式分別是case 0, 1, 2、case 2, 3, 4和case 4, 5, 6。

由於在這三個case表示式中存在結果值相等的子表示式,所以這個switch語句無法通過編譯。不過,好在這個約束本身還有個約束,那就是隻針對結果值為常量的子表示式。

比如,子表示式1+1和2不能同時出現,1+3和4也不能同時出現。有了這個約束的約束,我們就可以想辦法繞過這個對子表示式的限制了。再看一段程式碼:

value5 := [...]int8{0, 1, 2, 3, 4, 5, 6}
switch value5[4] {
case value5[0], value5[1], value5[2]:
  fmt.Println("0 or 1 or 2")
case value5[2], value5[3], value5[4]:
  fmt.Println("2 or 3 or 4")
case value5[4], value5[5], value5[6]:
  fmt.Println("4 or 5 or 6")
}

變數名換成了value5,但這不是重點。重點是,我把case表示式中的常量都換成了諸如value5[0]這樣的索引表示式。

雖然第一個case表示式和第二個case表示式都包含了value5[2],並且第二個case表示式和第三個case表示式都包含了value5[4],但這已經不是問題了。這條switch語句可以成功通過編譯。

不過,這種繞過方式對用於型別判斷的switch語句(以下簡稱為型別switch語句)就無效了。因為型別switch語句中的case表示式的子表示式,都必須直接由型別字面量表示,而無法通過間接的方式表示。程式碼如下:

value6 := interface{}(byte(127))
switch t := value6.(type) {
case uint8, uint16:
  fmt.Println("uint8 or uint16")
case byte:
  fmt.Printf("byte")
default:
  fmt.Printf("unsupported type: %T", t)
}

變數value6的值是空介面型別的。該值包裝了一個byte型別的值127。我在後面使用型別switch語句來判斷value6的實際型別,並列印相應的內容。

這裡有兩個普通的case子句,還有一個default case子句。前者的case表示式分別是case uint8, uint16和case byte。你還記得嗎?byte型別是uint8型別的別名型別。

因此,它們兩個本質上是同一個型別,只是型別名稱不同罷了。在這種情況下,這個型別switch語句是無法通過編譯的,因為子表示式byte和uint8重複了。好了,以上說的就是case表示式的約束以及繞過方式,你學會了嗎。

總結

我們今天主要討論了for語句和switch語句,不過我並沒有說明那些語法規則,因為它們太簡單了。我們需要多加註意的往往是那些隱藏在 Go 語言規範和最佳實踐裡的細節。

這些細節其實就是我們很多技術初學者所謂的“坑”。比如,我在講for語句的時候交代了攜帶range子句時只有一個迭代變數意味著什麼。你必須知道在迭代陣列或切片時只有一個迭代變數的話是無法迭代出其中的元素值的,否則你的程式可能就不會像你預期的那樣執行了。

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章