這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
概覽
本文涉及到下面的幾個方面:
- 聲明新的使用者自訂類型
- 為類型添加行為
- 何時用實值型別何時用指標類型
- 使用介面實現多態
- 通過組合擴充和改變類型
- 標識符的暴露與不暴露
Go語言是一種靜態類型的程式設計語言。編譯器總是需要知道程式中的每個值的類型是什麼。編譯器提前知道值的類型資訊,可以協助程式安全的處理這些值。 這樣可以減少潛在的bug或記憶體的破壞,同時還有機會讓編譯器產生更有效代碼。
變數的實值型別為編譯器提供兩條資訊:
- 值的尺寸: 需要為值分配多少記憶體。
- 記憶體代表什麼。
很多內建類型中,類型名同時包含了值尺寸和所代表的東西。 比如int64類型表示需要8個位元組記憶體(64位), 代表的是整數值。 float32類型表示需要4個位元組記憶體(32位), 代表的是IEEE-754浮點數。bool類型需要一個位元組記憶體(8位), 代表的是布爾值true或false。
而有些類型具體代表什麼,是和機器的代碼架構相關的。 例如, int類型的值,尺寸可能是64位,也有可能是32位,具體要看所在機器的架構情況了。 還有一些和架構相關的其他類型, 例如Go語言中的所有參考型別都是和架構相關的。比較幸運的是,這些類型的值,在建立和處理的時候,你無需知道這些資訊。但是編譯器不知道這些資訊的話,它就不能防止你做一些可能引起傷害程式本身或者運行機器的事情了。
自訂類型
Go語言支援自訂類型的聲明。在聲明新類型的時候,構建的聲明為編譯器提供值的尺寸和記憶體所代表資訊, 這點和內建類型的工作方式類似。Go語言中有兩種聲明用於自訂類型的方法。 最常用的就是用關鍵詞struct來建立組合類別型。
struct(結構體)是由固定的獨立欄位組合起來聲明的。 結構體中的每個欄位都由已知類型來聲明的,這些已知類型可以是內建類型,也可以是使用者自訂類型。
type user struct { name string email string exp int privileged bool}
上面就聲明了一個結構體類型。聲明以type關鍵詞開頭,然後是新類型的名字,最後是關鍵詞struct。 這個結構體包含四個欄位,它們都是內建類型。 你可以看這些欄位如何組合在一起形成新的類型。 類型一旦聲明好,就可以使用它建立值。
var bill user
上面我們通過關鍵詞var建立一個名為bill的user類型變數。當聲明變數的時候,代表變數的值總是被初始化的。 可以使用特定值或對應類型的零值(變數類型的預設值)來初始化它們的值。
數字類型的零值是0。字串的零值是Null 字元串。布爾值的零值是false。
上面的結構體中,零值需要應用到結構體中的每個欄位。
每當變數被建立並初始化為它的零值時,我們習慣使用var關鍵詞。保留對關鍵詞var的使用,表示變數被設定為零值的一種方式。如果我們需要將變數初始化為零值意外的值,我們可以使用短變數聲明符後面帶一個結構體字面量。
lisa := user{ name: "Lisa", email: "lisa@email.com", ext: 124, privileged: true,}
注意短變數聲明符(:=)前面的變數不能是已經聲明的變數。 結構體字面量和類型後面跟上打括弧構成,結構體中的每個欄位名和欄位值以冒號分割,值後面必須跟一個逗號。
短變數聲明符(:=)提供兩個目的,聲明變數和初始設定變數。 根據操作符右邊的類型資訊,短變數聲明符可以確定變數的類型。
既然我們建立並初始化了一個結構體類型,那麼我們就可以使用結構體字面量來執行初始化。
結構體類型的結構體字面量可以接受兩種格式的內容。 上面展示的是第一種,括弧裡邊列出每個欄位名和欄位值,中間用冒號分割,然後在值後面加上逗號。 欄位順序可以隨意排放。
第二種形式可以省略欄位名,只用值來聲明。 如下所示:
lisa := user{"Lisa", "lisa@email.com", 123, true}
這種形式的值也可以分成多行列出, 但是上面這種形式的傳統值都是放一行裡邊的,結尾沒有逗號。這種情況下, 值的順序就非常重要了,需要匹配結構體聲明中的欄位順序。
當聲明結構體類型是,不限制僅使用內建類型。你還可以使用一些使用其他自訂類型的欄位。
type admin struct { person user level string}
上面我們定義了一個新的admin結構體類型。這個結構體類型有一個名字為person的欄位,類型為user, 另外還有一個string類型的level欄位。建立這樣的一個變數,初始化該類型時結構體字面量稍有變化。
// Declare a variable of type admin.fred := admin{ person: user{ name: "Lisa", email: "lisa@email.com", ext: 123, privileged: true, }, level: "super",}
要初始化person欄位,需要建立一個user類型的值。這就是上面我們的lisa變數的字面量。 使用結構體字面量形式,user類型的值被建立並賦值給person欄位。
另外一種聲明自訂類型的方式是使用現有類型,讓現有類型作為類型的類型規範。在新類型可以用現有類型表示的情況中,這種申明方式非常有用。標準庫中就有很多使用這種聲明方式從內建類型建立進階類別功能的例子。
type Duration int64
上面就是標準庫time中聲明Duration類型的代碼。Duration代表的是持續的納秒時間。這個類型代表的是內建類型int64。 Duration和int64是兩個有區別的、不同的類型。
為了更好的闡明這一點,我們可以看看下面這個不能編譯的小程式。
package maintype Duration int64func main() { var dur Duration dur = int64(1000)}// prog.go:7: cannot use int64(1000) (type int64) as type Duration in assignment
編譯器清楚的知道問題是什麼。 int64類型的值不能用於類型Duration. 換句話說,即便類型int64是Duration的基礎類型, Duration仍然屬於它自己的唯一類型。不同類型的值不能互相賦值, 即便它們能相容。編譯器不能隱式轉換不同類型的值。
方法
方法提供了一種為使用者自訂類型添加行為的方式。方法實際上就是函數,在關鍵詞func和函數名之間包含了一個額外參數。
// Sample program to show how to declare methods and how the Go// compiler supports them.package mainimport "fmt"// user defines a user in the program.type user struct { name string email string}// notify implements a method with a value receiver.func (u user) notify() { fmt.Printf("Sending User Email to %s<%s>\n", u.name, u.email)}// changeEmail implements a method with a pointer receiver.func (u *user) changeEmail(email) { u.email = email}// main is the entry point for the application.func main() { // Values of type user can be used to call methods // declared with a value receiver. bill := user{"Bill", "bill@email.com"} bill.notify() // Pointers of type user can also be used to call methods // declared with a value receiver. lisa := &user{"Lisa", "lisa@email.com"} lisa.notify() // Values of type user can be used to call methods // declared with a pointer receiver. bill.changeEmail("bill@newdomain.com") bill.notify() // Pointers of type user can be used to call methods // declared with a pointer receiver. lisa.changeEmail("lisa@comcast.com") lisa.notify()}
上面展示了兩個不同的方法。在關鍵詞func和函數名之間的參數叫做接受者(receiver), 函數被綁定給這個特定的類型。 當函數有接受者時, 函數就被叫做方法。 當你運行上面的代碼,會有下面的輸出:
Sending User Email To Bill<bill@email.com>
Sending User Email To Lisa<lisa@email.com>
Sending User Email To Bill<bill@newdomain.com>
Sending User Email To Lisa<lisa@comcast.com>
讓我們檢查下程式做了些什麼。程式聲明了結構體user, 然後聲明了一個名為notify的方法。
type user struct { name string email string}func (u user) notify() { // ...}
在Go語言中有兩種類型的接收者: 值接受者和指標接受者。notify方法以值接受者的方式聲明的。
notify方法的接收者被聲明為類型user的值。 當以值接受者聲明方法時,這個方法是種能與用於調用該方法的值副本進行操作。
bill := user{"Bill", "bill@email.com"}bill.notify()
上面使用user類型的值bill對方法notify進行調用。
這個文法看起來類似於調用包的函數。然而這個例子中,bill不是包名,而是一個變數名。 這種情況下我們調用notify方法, bill的值對於調用來說是接受者值, notify方法是對這個值的副本進行操作的。
你也可以使用指標來調用使用值接受者聲明的方法。
lisa := &user{"Lisa", "lisa@email.com"}lisa.notify()
上面我們使用user類型的指標lisa對方法notify()進行調用。為了支援方法調用,Go語言調整了指標以滿足方法的接受者。你可以想象Go語言執行了下面的操作:
(*lisa).notify()
上面就展示了Go編譯器所作的支援方法調用的等價。 指標值會被取值 (Dereference),以便方法調用和值接受者相容。 再來一次,notify是操作副本的, 但是這次值的副本是lisa指標指向的。
同樣可以使用指標接受者聲明方法:
func (u *user) changeEmail(email string) { u.email = email}
上面聲明了changeEmail方法,使用的是指標接受者。這次,接受者不是user類型的值,而是指標。 當調用以指標接受者聲明的方法時,用於調用方法的值是方法共用的。
lisa := &user{"Lisa", "lisa@email.com"}lisa.changeEmail("lisa@newdomain.com")
上面你看到lisa指標的聲明,後面跟著changeEmail的方法調用。一旦changeEmail方法調用返回,對lisa指向的值的改變在調用後會受影響。這多虧了指標接受者。 值接受者操作的是用於方法調用的值的副本。而指標接受者操作的是實際的資料。
同樣可以使用實值型別來調用使用指標接受者聲明的方法。
bill := user{"Bill", "bill@email.com"}bill.changeEmail("bill@newdomain.com")
上面你可以看到,bill變數的聲明以及對changeEmail方法的調用, changeEmail方法以指標接受者的方式聲明的。Go語言再次調整值來讓它滿足方法的接受者, 以支援方法調用。
(&bill).notify()
上面展示了Go編譯器支援方法調用所作的事情本質。 該情況下,值是引用的,因此方法調用時和接受者類型相容的。 這是Go語言提供的極大便利, 允許方法調用使用值和指標,而不用天生匹配方法的接受者類型。(Go編譯器會幫你進行適當的轉換。)
決定是否使用值或指標接受者有時候會感覺到困惑。 有一些來自標準庫中的基本規則你可以直接遵循。
類型性質(Nature of types)
聲明新類型之後,在為這個型別宣告方法之前先回答一個問題。 你是否需要在這個類型上添加或刪除一些東西來建立新的值或改變現有值? 如果是建立新值,那麼方法就使用值接收者(value receiver)。如果答案是改變值,那麼方法使用指標接收者。
這種原則同樣適用於這些值如何傳遞給程式的其他部分。
保持一致非常重要。這樣做的目的不是為了關注使用值做什麼,而是關注值的本質是什麼。
內建類型
內建類型是Go語言提供的類型集合。也就是我們知道的數字、字串、布爾類型的集合。 這些類型具有原始性質(primitive nature)。
所謂原始性質,可以理解為機器指令和翻譯的最小的或最基本的單元, 具有原子性。
正因為如此,向這樣的值添加或刪除一個值,就會建立新的值。鑒於這個原因,傳遞這些類型的值給函數和方法,應該使用這些值的副本,也就是值傳遞。 下面我們看看標準庫中的函數是如何處理這些內建類型值的。
func Trim(s string, cutset string) string { if s == "" || cutset == "" { return s } return TrimFunc(s, makeCutsetFunc(cutset))}
上面代碼是Trim函數的實現,來自標準庫strings包。Trim函數傳入要操作的字串值和要尋找的字元值。然後返回新的字串,也就是操作的結果。函數操作調用者使用原始字串的副本, 然後返回的新字串的值。字串,就像整數、浮點數和布爾類型,都是未經處理資料類型,傳入傳出函數或方法的時候都應拷貝。
下面我們看另外一個例子,內建類型如何被視為原始屬性。
func isShellSpecialVar(c uint8) bool { switch c { case '*', '$', '@', '!', '#', '?', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': return true } return false}
上面展示了env包中的isShellSpecialVar函數。這個函數傳入一個uint8類型的值,返回一個bool類型的值。注意這裡指標為什麼不能用於共用參數和傳回值。調用者將傳入的uint8值進行拷貝,接收到的卻是一個true或false的值。
參考型別
Go語言中的參考型別是分區、映射、通道以及函數類型。
當聲明這樣類型的變數時,被建立的值被稱為頭值(header value). 技術上, 字串也是一種參考型別值。
所有來自不同參考型別的不同頭值都包含一個指向底層資料結構的指標。每個參考型別也包含唯一欄位集合,用於管理底層資料結構。不能共用參考型別值,因為頭值設計是用來拷貝的。頭值包含一個指標,因此你可以傳遞任意參考型別值副本,本質上共用底層資料結構。
讓我們看看來自net包的一個類型:
type IP []byte
上面展示了一個叫IP的類型,它被聲明為位元組分區。 當你需要為內建或參考型別聲明行為時,聲明這樣的類型就非常有用了,因為編譯器只允許對你聲明的命名型別宣告方法。
func (ip IP) MarshalText() ([]byte, error) { if len(ip) == 0 { return []byte(""), nil } if len(ip) != IPv4len && len(ip) != IPv6len { return nil, errors.New("invalid IP address") } return []byte(ip.String()), nil}
MarshalText方法是用類型IP的值接受者聲明的。 值接受者完全是你所期望看到的,既然不需要共用參考型別值。這同樣適用於傳遞參考型別值作為函數和方法的參數。
// ipEmptyString is like ip.String except that it returns// an empty string when ip is unset.func ipEmptyString(ip IP) string { if len(ip) == 0 { return "" } return ip.String()}
ipEmptyString函數傳入一個IP類型的值。 再一次,你可以看到調用者的對這個參數的參考型別不在函數間共用。 函數被傳入調用者的參考型別值副本。 這對於傳回值同樣適用。 結尾處,參考型別值被視為類似基本資料值。
結構體
結構體類型可以代表既包含基本類型或非基本類型值的資料。 當我們決定建立那樣的一個結構體類型,我們對哪些應加入或哪些應去掉的值表現易變, 應該遵循內建類型和參考型別的指導。 下面我們開始看看通過標準庫實現的結構體, 有一個基本特性。
type Time struct { // sec gives the number of seconds elapsed since // January 1, year 1 00:00:00 UTC. sec int64 // nsec specifies a non-negative nanosecond // offset within the second named by Seconds. // It must be in the range [0, 999999999]. nsec int32 // loc specifies the Location that should be used to // determine the minute, hour, month, day, and year // that correspond to this Time. // Only the zero Time has a nil Location. // In that case it is interpreted to mean UTC. loc *Location}
Time結構體來自time包。 當你考慮time的時候,你會意識到任何給定的時間點都是不能改變的。這正是標準庫實現Time類型的方式。 下面我們看看建立時間類型值的Now函數。
func Now() Time { sec, nsec := now() return Time{sec + unixToInternal, nsec, Local}}
上面展示了Now函數的實現。 這個函數建立了一個Time類型的值,並返回那個Time值的副本給調用者。 指標不用來共用這個函數建立的Time值。下一步,我們看一下Time型別宣告的另一個方法。
func (t Time) Add(d Duration) Time { t.sec += int64(d / 1e9) nsec := int32(t.nsec) + int32(d%1e9) if nsec >= 1e9 { t.sec++ nsec -= 1e9 } else if nsec < 0 { t.sec-- nsec += 1e9 } t.nsec = nsec return t}
上面代碼很好的展示了標準庫如何對待具有原始屬性的Time類型的。方法Add使用一個值接收器聲明,返回一個新的Time值。方法操作調用者的Time值的副本, 返回它局部的Time值給調用者。 調用者不管是使用返回的Time替換它們的Time值, 或者聲明一個新變數來儲存這個值都行。
在大多數情況中,結構體類型沒有呈現出原始性質,而是非原始屬性。在這些例子中,從值中添加或刪除都會讓值變化。在這種情況下,你想使用指標和程式的其他需要它的部分共用這個值. 讓我們看看標準庫實現的具有非原始性質的結構體類型。
// File represents an open file descriptor.type File struct { *file}// file is the real representation of *File.// The extra level of indirection ensures that no clients of os can overwrite this data, which could cause the finalizer to close the wrong file descriptor.// 額外的間接層,取保作業系統的用戶端不能覆蓋這些資料,如果覆蓋可能會導致定稿人(finalizer)關閉錯誤的檔案描述符。type file struct { fd int name string dirinfo *dirinfo // nil unless directory being read nepipe int32 // number of consecutive EPIPE in Write}
上面你看到標準庫中File類型的聲明。這個類型的性質是非原始的。這個類型的值實際上是拷貝不安全的。不暴露類型的注釋說的很清楚了。 既然沒有辦法防止程式拷貝, File類型的實現使用了嵌入的指向不希望暴露類型的指標。 本章後面會討論嵌入類型,但是這個額外的間接層提供了複製的保護。 並不是所有結構體類型都需要或應該實現這種額外的保護。程式員應該關心每種類型的性質,並對應的使用它們。
下面我們看看Open函數的實現。
func Open(name string) (file *File ,err error) { return OpenFile(name, O_RDONLY, 0)}
Open函數的實現展示了如何使用指標共用調用函數帶的File類型值。Open建立一個File類型值,並返回指向那個值的指標。 當工廠函數返回指標, 就是很好的指示,傳回值的屬性是非原始的。即便函數或方法沒有打算直接改變非原始值的狀態, 它也應該被共用起來。
func (f *File) Chdir() error { if f == nil { return ErrInvalid } if e := syscall.Fchdir(f.fd); e != nil { return &PathError{"chdir", f.name, e} } return nil}
Chdir方法展示了如何使用指標接受者來聲明一個即便不對接受值進行改變的情況。既然File類型值具有非原始屬性,它們應該總是共用的,並且不要複製的。
要使用值接受者還是指標接受者不應該基於方法是否需要改變接受者的值。 決定必須基於類型的屬性。 這個指導原則的一個例外情況就是,當你需要實值型別接受者操作介面時提供的靈活性。在這些情況中,你應該選擇使用值接受者,即便類型的性質是非原始的。 完全基於介面值如何使用儲存在它裡邊值來調用方法的機制。下一節,你將瞭解到介面值是什麼,使用它們調用方法的背後機制。
原文地址