Go語言核心36講(Go語言基礎知識六)--學習筆記

MingsonZheng 發表於 2021-10-17
Go

06 | 程式實體的那些事兒 (下)

在上一篇文章,我們一直都在圍繞著可重名變數,也就是不同程式碼塊中的重名變數,進行了討論。還記得嗎?

最後我強調,如果可重名變數的型別不同,那麼就需要引起我們的特別關注了,它們之間可能會存在“遮蔽”的現象。

必要時,我們需要嚴格地檢查它們的型別,但是怎樣檢查呢?我們們現在就說。

我今天的問題是:怎樣判斷一個變數的型別?

我們依然以在上一篇文章中展示過的 demo11.go 為基礎。

package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
  container := map[int]string{0: "zero", 1: "one", 2: "two"}
  fmt.Printf("The element is %q.\n", container[1])
}

那麼,怎樣在列印其中元素之前,正確判斷變數container的型別?

典型回答

答案是使用“型別斷言”表示式。具體怎麼寫呢?

value, ok := interface{}(container).([]string)

這裡有一條賦值語句。在賦值符號的右邊,是一個型別斷言表示式。

它包括了用來把container變數的值轉換為空介面值的interface{}(container)。

以及一個用於判斷前者的型別是否為切片型別 []string 的 .([]string)。

這個表示式的結果可以被賦給兩個變數,在這裡由value和ok代表。變數ok是布林(bool)型別的,它將代表型別判斷的結果,true或false。

如果是true,那麼被判斷的值將會被自動轉換為[]string型別的值,並賦給變數value,否則value將被賦予nil(即“空”)。

順便提一下,這裡的ok也可以沒有。也就是說,型別斷言表示式的結果,可以只被賦給一個變數,在這裡是value。

但是這樣的話,當判斷為否時就會引發異常。

這種異常在 Go 語言中被叫做panic,我把它翻譯為執行時恐慌。因為它是一種在 Go 程式執行期間才會被丟擲的異常,而“恐慌”二字是英文 Panic 的中文直譯。

除非顯式地“恢復”這種“恐慌”,否則它會使 Go 程式崩潰並停止。所以,在一般情況下,我們還是應該使用帶ok變數的寫法。

問題解析

正式說明一下,型別斷言表示式的語法形式是x.(T)。其中的x代表要被判斷型別的值。這個值當下的型別必須是介面型別的,不過具體是哪個介面型別其實是無所謂的。

所以,當這裡的container變數型別不是任何的介面型別時,我們就需要先把它轉成某個介面型別的值。

如果container是某個介面型別的,那麼這個型別斷言表示式就可以是container.([]string)。這樣看是不是清晰一些了?

在 Go 語言中,interface{}代表空介面,任何型別都是它的實現型別。

這裡的具體語法是interface{}(x),例如前面展示的interface{}(container)。

你可能會對這裡的{}產生疑惑,為什麼在關鍵字interface的右邊還要加上這個東西?

請記住,一對不包裹任何東西的花括號,除了可以代表空的程式碼塊之外,還可以用於表示不包含任何內容的資料結構(或者說資料型別)。

比如你今後肯定會遇到的struct{},它就代表了不包含任何欄位和方法的、空的結構體型別。

而空介面interface{}則代表了不包含任何方法定義的、空的介面型別。

當然了,對於一些集合類的資料型別來說,{}還可以用來表示其值不包含任何元素,比如空的切片值[]string{},以及空的字典值map[int]string{}。

image

我們再向答案的最右邊看。圓括號中[]string是一個型別字面量。所謂型別字面量,就是用來表示資料型別本身的若干個字元。

比如,string是表示字串型別的字面量,uint8是表示 8 位無符號整數型別的字面量。

再複雜一些的就是我們剛才提到的[]string,用來表示元素型別為string的切片型別,以及map[int]string,用來表示鍵型別為int、值型別為string的字典型別。

還有更復雜的結構體型別字面量、介面型別字面量,等等。

針對當前的這個問題,我寫了 demo12.go。它是 demo11.go 的修改版。我在其中分別使用了兩種方式來實施型別斷言,一種用的是我上面講到的方式,另一種用的是我們還沒討論過的switch語句,先供你參考。

package main

import (
	"fmt"
)

var container = []string{"zero", "one", "two"}

func main() {
	container := map[int]string{0: "zero", 1: "one", 2: "two"}

	// 方式1。
	_, ok1 := interface{}(container).([]string)
	_, ok2 := interface{}(container).(map[int]string)
	if !(ok1 || ok2) {
		fmt.Printf("Error: unsupported container type: %T\n", container)
		return
	}
	fmt.Printf("The element is %q. (container type: %T)\n",
		container[1], container)

	// 方式2。
	elem, err := getElement(container)
	if err != nil {
		fmt.Printf("Error: %s\n", err)
		return
	}
	fmt.Printf("The element is %q. (container type: %T)\n",
		elem, container)
}

func getElement(containerI interface{}) (elem string, err error) {
	switch t := containerI.(type) {
	case []string:
		elem = t[1]
	case map[int]string:
		elem = t[1]
	default:
		err = fmt.Errorf("unsupported container type: %T", containerI)
		return
	}
	return
}

可以看到,當前問題的答案可以只有一行程式碼。你可能會想,這一行程式碼解釋起來也太複雜了吧?

千萬不要為此煩惱,這其中很大一部分都是一些基本語法和概念,你只要記住它們就好了。但這也正是我要告訴你的,一小段程式碼可以隱藏很多細節。面試官可以由此延伸到幾個方向繼續提問。這有點兒像潑墨,可以迅速由點及面。

知識擴充套件

問題 1. 你認為型別轉換規則中有哪些值得注意的地方?

型別轉換表示式的基本寫法我已經在前面展示過了。它的語法形式是T(x)。

其中的x可以是一個變數,也可以是一個代表值的字面量(比如1.23和struct{}{}),還可以是一個表示式。

注意,如果是表示式,那麼該表示式的結果只能是一個值,而不能是多個值。在這個上下文中,x可以被叫做源值,它的型別就是源型別,而那個T代表的型別就是目標型別。

如果從源型別到目標型別的轉換是不合法的,那麼就會引發一個編譯錯誤。那怎樣才算合法?具體的規則可參見 Go 語言規範中的轉換 https://golang.google.cn/ref/spec#Conversions 部分。

我們在這裡要關心的,並不是那些 Go 語言編譯器可以檢測出的問題。恰恰相反,那些在程式語言層面很難檢測的東西才是我們應該關注的。

很多初學者所說的陷阱(或者說坑),大都源於他們需要了解但卻不瞭解的那些知識和技巧。因此,在這些規則中,我想丟擲三個我認為很常用並且非常值得注意的知識點,提前幫你標出一些“陷阱”。

首先,對於整數型別值、整數常量之間的型別轉換,原則上只要源值在目標型別的可表示範圍內就是合法的。

比如,之所以uint8(255)可以把無型別的常量255轉換為uint8型別的值,是因為255在[0, 255]的範圍內。

但需要特別注意的是,源整數型別的可表示範圍較大,而目標型別的可表示範圍較小的情況,比如把值的型別從int16轉換為int8。請看下面這段程式碼:

var srcInt = int16(-255)
dstInt := int8(srcInt)

變數srcInt的值是int16型別的-255,而變數dstInt的值是由前者轉換而來的,型別是int8。int16型別的可表示範圍可比int8型別大了不少。問題是,dstInt的值是多少?

首先你要知道,整數在 Go 語言以及計算機中都是以補碼的形式儲存的。這主要是為了簡化計算機對整數的運算過程。(負數的)補碼其實就是原碼各位求反再加 1。

比如,int16型別的值-255的補碼是1111111100000001。如果我們把該值轉換為int8型別的值,那麼 Go 語言會把在較高位置(或者說最左邊位置)上的 8 位二進位制數直接截掉,從而得到00000001。

又由於其最左邊一位是0,表示它是個正整數,以及正整數的補碼就等於其原碼,所以dstInt的值就是1。

一定要記住,當整數值的型別的有效範圍由寬變窄時,只需在補碼形式下截掉一定數量的高位二進位制數即可。

類似的快刀斬亂麻規則還有:當把一個浮點數型別的值轉換為整數型別值時,前者的小數部分會被全部截掉。

第二,雖然直接把一個整數值轉換為一個string型別的值是可行的,但值得關注的是,被轉換的整數值應該可以代表一個有效的 Unicode 程式碼點,否則轉換的結果將會是"�"(僅由高亮的問號組成的字串值)。

字元'�'的 Unicode 程式碼點是U+FFFD。它是 Unicode 標準中定義的 Replacement Character,專用於替換那些未知的、不被認可的以及無法展示的字元。

我肯定不會去問“哪個整數值轉換後會得到哪個字串”,這太變態了!但是我會寫下:

string(-1)

並詢問會得到什麼?這可是完全不同的問題啊。由於-1肯定無法代表一個有效的 Unicode 程式碼點,所以得到的總會是"�"。在實際工作中,我們在排查問題時可能會遇到�,你需要知道這可能是由於什麼引起的。

第三個知識點是關於string型別與各種切片型別之間的互轉的。

你先要理解的是,一個值在從string型別向[]byte型別轉換時代表著以 UTF-8 編碼的字串會被拆分成零散、獨立的位元組。

除了與 ASCII 編碼相容的那部分字符集,以 UTF-8 編碼的某個單一位元組是無法代表一個字元的。

string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好

比如,UTF-8 編碼的三個位元組\xe4、\xbd和\xa0合在一起才能代表字元'你',而\xe5、\xa5和\xbd合在一起才能代表字元'好'。

其次,一個值在從string型別向[]rune型別轉換時代表著字串會被拆分成一個個 Unicode 字元。

string([]rune{'\u4F60', '\u597D'}) // 你好

當你真正理解了 Unicode 標準及其字符集和編碼方案之後,上面這些內容就會顯得很容易了。什麼是 Unicode 標準?我會首先推薦你去它的官方網站 https://home.unicode.org/ 一探究竟。

問題 2. 什麼是別名型別?什麼是潛在型別?

我們可以用關鍵字type宣告自定義的各種型別。當然了,這些型別必須在 Go 語言基本型別和高階型別的範疇之內。在它們當中,有一種被叫做“別名型別”的型別。我們可以像下面這樣宣告它:

type MyString = string

這條宣告語句表示,MyString是string型別的別名型別。顧名思義,別名型別與其源型別的區別恐怕只是在名稱上,它們是完全相同的。

源型別與別名型別是一對概念,是兩個對立的稱呼。別名型別主要是為了程式碼重構而存在的。更詳細的資訊可參見 Go 語言官方的文件Proposal: Type Aliases https://go.googlesource.com/proposal/+/master/design/18130-type-alias.md

Go 語言內建的基本型別中就存在兩個別名型別。byte是uint8的別名型別,而rune是int32的別名型別。

一定要注意,如果我這樣宣告:

type MyString2 string // 注意,這裡沒有等號。

MyString2和string就是兩個不同的型別了。這裡的MyString2是一個新的型別,不同於其他任何型別。

這種方式也可以被叫做對型別的再定義。我們剛剛把string型別再定義成了另外一個型別MyString2。

image

對於這裡的型別再定義來說,string可以被稱為MyString2的潛在型別。潛在型別的含義是,某個型別在本質上是哪個型別。

潛在型別相同的不同型別的值之間是可以進行型別轉換的。因此,MyString2型別的值與string型別的值可以使用型別轉換表示式進行互轉。

但對於集合類的型別[]MyString2與[]string來說這樣做卻是不合法的,因為[]MyString2與[]string的潛在型別不同,分別是[]MyString2和[]string。另外,即使兩個不同型別的潛在型別相同,它們的值之間也不能進行判等或比較,它們的變數之間也不能賦值。

總結

Go 語言中的每個變數都是有型別的,我們可以使用型別斷言表示式判斷變數是哪個型別的。

正確使用該表示式需要一些小技巧,比如總是應該把結果賦給兩個變數。另外還要保證被判斷的變數是介面型別的,這可能會用到型別轉換表示式。

此外,你還應該搞清楚別名型別宣告與型別再定義之間的區別,以及由此帶來的它們的值在型別轉換、判等、比較和賦值操作方面的不同。

思考題

  • 除了上述提及的那些,你還認為型別轉換規則中有哪些值得注意的地方?
  • 你能具體說說別名型別在程式碼重構過程中可以起到哪些作用嗎?

知識共享許可協議

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

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