Go語言 方法

2023-02-16 17:38 更新

Go支持一些面向?qū)ο缶幊烫匦?,方法是這些所支持的特性之一。 本篇文章將介紹在Go中和方法相關(guān)的各種概念。

方法聲明

在Go中,我們可以為類型T*T顯式地聲明一個方法,其中類型T必須滿足四個條件:

  1. T必須是一個定義類型;
  2. T必須和此方法聲明定義在同一個代碼包中;
  3. T不能是一個指針類型;
  4. T不能是一個接口類型。接口類型將在下一篇文章中講解。

類型T*T稱為它們各自的方法的屬主類型(receiver type)。 類型T被稱作為類型T*T聲明的所有方法的屬主基類型(receiver base type)。

注意:我們也可以為滿足上列條件的類型T*T別名聲明方法。 這樣做的效果和直接為類型T*T聲明方法是一樣的。

如果我們?yōu)槟硞€類型聲明了一個方法,以后我們可以說此類型擁有此方法。

從上面列出的條件,我們得知我們不能為下列類型(顯式地)聲明方法:

  • 內(nèi)置基本類型。比如intstring。 因為這些類型聲明在內(nèi)置builtin標準包中,而我們不能在標準包中聲明方法。
  • 接口類型。但是接口類型可以擁有方法。詳見下一篇文章。
  • 除了滿足上面條件的形如*T的指針類型之外的無名組合類型。

一個方法聲明和一個函數(shù)聲明很相似,但是比函數(shù)聲明多了一個額外的參數(shù)聲明部分。 此額外的參數(shù)聲明部分只能含有一個類型為此方法的屬主類型的參數(shù),此參數(shù)稱為此方法聲明的屬主參數(shù)(receiver parameter)。 此屬主參數(shù)聲明必須包裹在一對小括號()之中。 此屬主參數(shù)聲明部分必須處于func關(guān)鍵字和方法名之間。

下面是一個方法聲明的例子:

// Age和int是兩個不同的類型。我們不能為int和*int
// 類型聲明方法,但是可以為Age和*Age類型聲明方法。
type Age int
func (age Age) LargerThan(a Age) bool {
	return age > a
}
func (age *Age) Increase() {
	*age++
}

// 為自定義的函數(shù)類型FilterFunc聲明方法。
type FilterFunc func(in int) bool
func (ff FilterFunc) Filte(in int) bool {
	return ff(in)
}

// 為自定義的映射類型StringSet聲明方法。
type StringSet map[string]struct{}
func (ss StringSet) Has(key string) bool {
	_, present := ss[key]
	return present
}
func (ss StringSet) Add(key string) {
	ss[key] = struct{}{}
}
func (ss StringSet) Remove(key string) {
	delete(ss, key)
}

// 為自定義的結(jié)構(gòu)體類型Book和它的指針類型*Book聲明方法。

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

從上面的例子可以看出,我們可以為各種種類(kind)的類型聲明方法,而不僅僅是結(jié)構(gòu)體類型。

在很多其它面向?qū)ο蟮木幊陶Z言中,屬主參數(shù)名總是為隱式聲明的this或者self。這樣的名稱不推薦在Go編程中使用。

指針類型的屬主參數(shù)稱為指針類型屬主,非指針類型的屬主參數(shù)稱為值類型屬主。 在大多數(shù)情況下,我個人非常反對將指針這兩個術(shù)語用做對立面,但是在這里,我并不反對這么用,原因?qū)⒃谙旅嬲劶啊?

方法名可以是空標識符_。一個類型可以擁有若干名可以是空標識符的方法,但是這些方法無法被調(diào)用。 只有導出的方法才可以在其它代碼包中調(diào)用。 方法調(diào)用將在后面的一節(jié)中介紹。

每個方法對應(yīng)著一個隱式聲明的函數(shù)

對每個方法聲明,編譯器將自動隱式聲明一個相對應(yīng)的函數(shù)。 比如對于上一節(jié)的例子中為類型Book*Book聲明的兩個方法,編譯器將自動聲明下面的兩個函數(shù):

func Book.Pages(b Book) int {
	return b.pages // 此函數(shù)體和Book類型的Pages方法體一樣
}

func (*Book).SetPages(b *Book, pages int) {
	b.pages = pages // 此函數(shù)體和*Book類型的SetPages方法體一樣
}

在上面的兩個隱式函數(shù)聲明中,它們各自對應(yīng)的方法聲明的屬主參數(shù)聲明被插入到了普通參數(shù)聲明的第一位。 它們的函數(shù)體和各自對應(yīng)的顯式方法的方法體是一樣的。

兩個隱式函數(shù)名Book.Pages(*Book).SetPages都是aType.MethodName這種形式的。 我們不能顯式聲明名稱為這種形式的函數(shù),因為這種形式中的函數(shù)名不屬于合法標識符。這樣的函數(shù)只能由編譯器隱式聲明。 但是我們可以在代碼中調(diào)用這些隱式聲明的函數(shù):

package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var book Book
	// 調(diào)用這兩個隱式聲明的函數(shù)。
	(*Book).SetPages(&book, 123)
	fmt.Println(Book.Pages(book)) // 123
}

事實上,在隱式聲明上述兩個函數(shù)的同時,編譯器也將改寫這兩個函數(shù)對應(yīng)的顯式方法(至少,我們可以這樣認為),讓這兩個方法在體內(nèi)直接調(diào)用這兩個隱式函數(shù):

func (b Book) Pages() int {
	return Book.Pages(b)
}

func (b *Book) SetPages(pages int) {
	(*Book).SetPages(b, pages)
}

為指針類型屬主隱式聲明的方法

對每一個為值類型屬主T聲明的方法,一個相應(yīng)的同名方法將自動隱式地為其對應(yīng)的指針類型屬主*T而聲明。 以上面的為類型Book聲明的Pages方法為例,一個同名方法將自動為類型*Book而聲明:

// 注意:這不是合法的Go語法。這里這樣表示只是
// 為了解釋目的。它表明表達式(&aBook).Pages
// 將被估值為aBook.Pages(見隨后幾節(jié))。
func (b *Book) Pages = (*b).Pages

正因為如此,我并不排斥使用值類型屬主這個術(shù)語做為指針類型屬主這個術(shù)語的對立面。 畢竟,當我們?yōu)橐粋€非指針類型顯式聲明一個方法的時候,事實上兩個方法被聲明了。 一個方法是為非指針類型顯式聲明的,另一個是為指針類型隱式聲明的。

上一節(jié)已經(jīng)提到了,每一個方法對應(yīng)著一個編譯器隱式聲明的函數(shù)。 所以對于剛提到的隱式方法,編譯器也將隱式聲明一個相應(yīng)的函數(shù):

func (*Book).Pages(b *Book) int {
	return Book.Pages(*b)
}

換句話說,對于每一個為值類型屬主顯式聲明的方法,同時將有一個隱式方法和兩個隱式函數(shù)被自動聲明。

方法描述(method specification)和方法集(method set)

一個方法描述可以看作是一個不帶func關(guān)鍵字的函數(shù)原型。 我們可以把每個方法聲明看作是由一個func關(guān)鍵字、一個屬主參數(shù)聲明部分、一個方法描述和一個方法體組成。

比如,上面的例子中的PagesSetPages的描述如下:

Pages() int
SetPages(pages int)

每個類型都有個方法集。一個非接口類型的方法集由所有為它聲明的(不管是顯式的還是隱式的,但不包含方法名為空標識符的)方法的描述組成。 接口類型將在下一篇文章詳述。

比如,在上面的例子中,Book類型的方法集為:

Pages() int

*Book類型的方法集為:

Pages() int
SetPages(pages int)

方法集中的方法描述的次序并不重要。

對于一個方法集,如果其中的每個方法描述都處于另一個方法集中,則我們說前者方法集為后者(即另一個)方法集的子集,后者為前者的超集。 如果兩個方法集互為子集(或超集),則這兩個方法集必等價。

給定一個類型T,假設(shè)它既不是一個指針類型也不是一個接口類型,因為上一節(jié)中提到的原因,類型T的方法集總是類型*T的方法集的子集。 比如,在上面的例子中,Book類型的方法集為*Book類型的方法集的子集。

請注意:不同代碼包中的同名非導出方法將總被認為是不同名的。

方法集在Go中的多態(tài)特性中扮演著重要的角色。多態(tài)將在下一篇文章中講解。

下列類型的方法集總為空:

  • 內(nèi)置基本類型;
  • 定義的指針類型;
  • 基類型為指針類型或者接口類型的指針類型;
  • 無名數(shù)組/切片/映射/函數(shù)/通道類型。

方法值和方法調(diào)用

方法事實上是特殊的函數(shù)。方法也常被稱為成員函數(shù)。 當一個類型擁有一個方法,則此類型的每個值將擁有一個不可修改的函數(shù)類型的成員(類似于結(jié)構(gòu)體的字段)。 此成員的名稱為此方法名,它的類型和此方法的聲明中不包括屬主部分的函數(shù)聲明的類型一致。 一個值的成員函數(shù)也可以稱為此值的方法。

一個方法調(diào)用其實是調(diào)用了一個值的成員函數(shù)。假設(shè)一個值v有一個名為m的方法,則此方法可以用選擇器語法形式v.m來表示。

下面這個例子展示了如何調(diào)用為Book*Book類型聲明的方法:

package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var book Book

	fmt.Printf("%T \n", book.Pages)       // func() int
	fmt.Printf("%T \n", (&book).SetPages) // func(int)
	// &book值有一個隱式方法Pages。
	fmt.Printf("%T \n", (&book).Pages)    // func() int

	// 調(diào)用這三個方法。
	(&book).SetPages(123)
	book.SetPages(123)           // 等價于上一行
	fmt.Println(book.Pages())    // 123
	fmt.Println((&book).Pages()) // 123
}

(和C語言不同,Go中沒有->操作符用來通過指針屬主值來調(diào)用方法。(&book)->SetPages(123)在Go中是非法的。)

等一下,上例中的(&book).SetPages(123)一行為什么可以被簡化為book.SetPages(123)呢? 畢竟,類型Book并不擁有一個SetPages方法。 啊哈,這可以看作是Go中為了讓代碼看上去更簡潔而特別設(shè)計的語法糖。此語法糖只對可尋址的值類型的屬主有效。 編譯器會隱式地將book.SetPages(123)改寫為(&book).SetPages(123)。 但另一方面,我們應(yīng)該總是認為aBookExpression.SetPages是一個合法的選擇器(從語法層面講),即使表達式aBookExpression被估值為一個不可尋址的Book值(在這種情況下,aBookExpression.SetPages是一個無效但合法的選擇器)。

如上面剛提到的,當為一個類型聲明了一個方法后,每個此類型的值將擁有一個和此方法同名的成員函數(shù)。 此類型的零值也不例外,不論此類型的零值是否用nil來表示。

一個例子:

package main

type StringSet map[string]struct{}
func (ss StringSet) Has(key string) bool {
	_, present := ss[key] // 永不會產(chǎn)生恐慌,即使ss為nil。
	return present
}

type Age int
func (age *Age) IsNil() bool {
	return age == nil
}
func (age *Age) Increase() {
	*age++ // 如果age是一個空指針,則此行將產(chǎn)生一個恐慌。
}

func main() {
	_ = (StringSet(nil)).Has   // 不會產(chǎn)生恐慌
	_ = ((*Age)(nil)).IsNil    // 不會產(chǎn)生恐慌
	_ = ((*Age)(nil)).Increase // 不會產(chǎn)生恐慌

	_ = (StringSet(nil)).Has("key") // 不會產(chǎn)生恐慌
	_ = ((*Age)(nil)).IsNil()       // 不會產(chǎn)生恐慌

	// 下面這行將產(chǎn)生一個恐慌,但是此恐慌不是在調(diào)用方法的時
	// 候產(chǎn)生的,而是在此方法體內(nèi)解引用空指針的時候產(chǎn)生的。
	((*Age)(nil)).Increase()
}

屬主參數(shù)的傳參是一個值復(fù)制過程

和普通參數(shù)傳參一樣,屬主參數(shù)的傳參也是一個值復(fù)制過程。 所以,在方法體內(nèi)對屬主參數(shù)的直接部分的修改將不會反映到方法體外。

一個例子:

package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var b Book
	b.SetPages(123)
	fmt.Println(b.pages) // 0
}

另一個例子:

package main

import "fmt"

type Book struct {
	pages int
}

type Books []Book

func (books Books) Modify() {
	// 對屬主參數(shù)的間接部分的修改將反映到方法之外。
	books[0].pages = 500
	// 對屬主參數(shù)的直接部分的修改不會反映到方法之外。
	books = append(books, Book{789})
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{500} {456}]
}

有點題外話,如果將上例中Modify方法中的兩行代碼次序調(diào)換,那么此方法中的兩處修改都不能反映到此方法之外。

func (books Books) Modify() {
	books = append(books, Book{789})
	books[0].pages = 500
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{123} {456}]
}

這兩處修改都不能反映到Modify方法之外的原因是append函數(shù)調(diào)用將開辟一塊新的內(nèi)存來存儲它返回的結(jié)果切片的元素。 而此結(jié)果切片的前兩個元素是屬主參數(shù)切片的元素的副本。對此副本所做的修改不會反映到Modify方法之外。

為了將此兩處修改反映到Modify方法之外,Modify方法的屬主類型應(yīng)該改為指針類型:

func (books *Books) Modify() {
	*books = append(*books, Book{789})
	(*books)[0].pages = 500
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{500} {456} {789}]
}

方法值的正規(guī)化

在編譯階段,編譯器將正規(guī)化各個方法值表達式。簡而言之,正規(guī)化就是將方法值表達式中的隱式取地址和解引用操作均轉(zhuǎn)換為顯式操作。

假設(shè)值v的類型為T,并且v.m是一個合法的方法值表達式,

  • 如果m是一個為類型*T顯式聲明的方法,那么編譯器將把它正規(guī)化(&v).m;
  • 如果m是一個為類型T顯式聲明的方法,那么v.m已經(jīng)是一個正規(guī)化的方法值表達式。

假設(shè)值p的類型為*T,并且p.m是一個合法的方法值表達式,

  • 如果m是一個為類型T顯式聲明的方法,那么編譯器將把它正規(guī)化(*p).m;
  • 如果m是一個為類型*T顯式聲明的方法,那么p.m已經(jīng)是一個正規(guī)化的方法值表達式。

提升方法值的正規(guī)化將在隨后的類型內(nèi)嵌一文中解釋。

方法值的估值

假設(shè)v.m是一個已經(jīng)正規(guī)化的方法值表達式,在運行時刻,當v.m被估值的時候,屬主實參v的估值結(jié)果的一個副本將被存儲下來以供后面調(diào)用此方法值的時候使用。

以下面的代碼為例:

  • b.Pages是一個已經(jīng)正規(guī)化的方法值表達式。 在運行時刻對其進行估值時,屬主實參b的一個副本將被存儲下來。 此副本等于b的當前值:Book{pages: 123},此后對b值的修改不影響此副本值。 這就是為什么調(diào)用f1()打印出123。
  • 在編譯時刻,方法值表達式p.Pages將被正規(guī)化為(*p).Pages。 在運行時刻,屬主實參*p被估值為當前的b值,也就是Book{pages: 123}。 這就是為什么調(diào)用f2()也打印出123。
  • p.Pages2是一個已經(jīng)正規(guī)化的方法值表達式。 在運行時刻對其進行估值時,屬主實參p的一個副本將被存儲下來,此副本的值為b值的地址。 當b被修改后,此修改可以通過對此地址值解引用而反映出來,這就是為什么調(diào)用g1()打印出789。
  • 在編譯時刻,方法值表達式b.Pages2將被正規(guī)化為(&b).Pages2。 在運行時刻,屬主實參&b的估值結(jié)果的一個副本將被存儲下來,此副本的值為b值的地址。 這就是為什么調(diào)用g2()也打印出789。
package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) Pages2() int {
	return (*b).Pages()
}

func main() {
	var b = Book{pages: 123}
	var p = &b
	var f1 = b.Pages
	var f2 = p.Pages
	var g1 = p.Pages2
	var g2 = b.Pages2
	b.pages = 789
	fmt.Println(f1()) // 123
	fmt.Println(f2()) // 123
	fmt.Println(g1()) // 789
	fmt.Println(g2()) // 789
}

一個定義類型不會獲取為它的源類型顯式聲明的方法

舉個例子,在下面的代碼中,定義類型Age并不像它的源類型MyInt一樣擁有一個IsOdd方法。

package main

type MyInt int
func (mi MyInt) IsOdd() bool {
	return mi%2 == 1
}

type Age MyInt

func main() {
	var x MyInt = 3
	_ = x.IsOdd() // okay
	
	var y Age = 36
	// _ = y.IsOdd() // error: y.IsOdd undefined
	_ = y
}

如何決定一個方法聲明使用值類型屬主還是指針類型屬主?

首先,從上一節(jié)中的例子,我們可以得知有時候我們必須在某些方法聲明中使用指針類型屬主。

事實上,我們總可以在方法聲明中使用指針類型屬主而不會產(chǎn)生任何邏輯問題。 我們僅僅是為了程序效率考慮有時候才會在函數(shù)聲明中使用值類型屬主。

對于值類型屬主還是指針類型屬主都可以接受的方法聲明,下面列出了一些考慮因素:

  • 太多的指針可能會增加垃圾回收器的負擔。
  • 如果一個值類型的尺寸太大,那么屬主參數(shù)在傳參的時候的復(fù)制成本將不可忽略。 指針類型都是小尺寸類型。 關(guān)于各種不同類型的尺寸,請閱讀值復(fù)制代價一文。
  • 在并發(fā)場合下,同時調(diào)用值類型屬主和指針類型屬主方法比較易于產(chǎn)生數(shù)據(jù)競爭。
  • sync標準庫包中的類型的值不應(yīng)該被復(fù)制,所以如果一個結(jié)構(gòu)體類型內(nèi)嵌了這些類型,則不應(yīng)該為這個結(jié)構(gòu)體類型聲明值類型屬主的方法。

如果實在拿不定主意在一個方法聲明中應(yīng)該使用值類型屬主還是指針類型屬主,那么請使用指針類型屬主。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號