Go 学习笔记(34)— Go 方法声明、方法调用、方法值、方法表达式、切片对象方法、指针对象方法
1. 方法聲明
Go 語言的方法非常純粹, 可以看作特殊類型的函數, 其顯式地將對象實例或指針作為函數的第一個參數, 并且參數名可以自己指定, 而不強制要求一定是 this 或 self 。這個對象實例或指針稱為方法的接收者(reciever)。
為命名類型定義方法的語法格式如下:
// 類型方法接收者是值類型
func (t TypeName) MethodName (ParamList ) (Returnlist) {//method body
}// 類型方法接收者是指針
func (t *TypeName) MethodName (ParamList) (Returnlist) {//method body
}
說明:
t是接收者或者叫接收器變量,官方建議使用接收器類型名TypeName的 第一個小寫字母,而不是self、this之類的命名。例如,Socket類型的接收器變量應該命名為s,Connector類型的接收器變量應該命名為c等;TypeName為命名類型的類型名;MethodName為方法名,是一個自定義標識符;ParamList是形參列表;ReturnList是返回值列表;
接收者的定義和普通變量、函數參數等一樣,前面是變量名,后面是接收者類型。
Go 方法實質上是以方法的 receiver 參數作為第一個參數的普通函數,沒有使用隱式的指針,我們可以將類型的方法改寫為常規的函數。示例如下:
//類型方法接收者是值類型
func TypName_MethodName(t TypeName , otherParamList) (Returnlist) {//method body
}
//類型方法接收者是指針
func TypName_MethodName (t *TypeName , otherParamList) (Returnlist) {//method body
}
2. 創建方法和使用
2.1 切片方法
package mainimport "fmt"type SliceInt []int// 面向對象
func (s SliceInt) Sum() int {sum := 0for _, i := range s {sum += i}return sum
}// 面向過程 這個函數和上面方法等價
func SliceIntSum(s SliceInt) int {sum := 0for _, i := range s {sum += i}return sum
}func main() {var s SliceInt = []int{1, 2, 3, 4, 5}fmt.Println(s.Sum()) // 面向對象的方法fmt.Println(SliceIntSum(s)) // 面向過程的方法
}
2.2 結構體方法
處理球體時,假設您要計算其表面積和體積。在這種情況下,非常適合使用結構體和方法集。通過使用方法集,您只需創建一次計算代碼,就可將其重用于任何球體。要創建這個方法集,可聲明結構體 Sphere 巳再聲明兩個將結構體 Sphere 作為接收者的方法。
package mainimport ("fmt""math"
)type Sphere struct {Radius float64
}/* 這里聲明了計算球體表面積和體積的方法,并像通常那樣定義函數簽名。唯一不同的
是添加了一個表示接收者的參數,這里是一個指向 Sphere 實例的指針
*/
func (s *Sphere) SurfaceArea() float64 {return float64(4) * math.Pi * (s.Radius * s.Radius)
}func (s *Sphere) Volume() float64 {radiusCubed := s.Radius * s.Radius * s.Radiusreturn (float64(4) / float64(3)) * math.Pi * radiusCubed
}// 方法接收者參數類型為值引用
func (s Sphere) ChageRadiusValue(r float64) float64 {s.Radius = rreturn r
}// 方法接收者參數類型為指針
func (s *Sphere) ChageRadiusPoint(r float64) float64 {s.Radius = rreturn r
}func main() {s := &Sphere{Radius: 5,}fmt.Println(s.SurfaceArea())fmt.Println(s.Volume())r := 1.0s.ChageRadiusValue(r) // 方法接收者參數類型為值引用時不會改變原始值fmt.Println(s.Radius) // 5s.ChageRadiusPoint(r) // 方法接收者參數類型為指針時會改變原始值fmt.Println(s.Radius) // 1
}
指針和值之間的差別很微妙,但選擇使用指針還是值這一點很簡單:
- 如果需要修改原始結構體,就使用指針;
- 如果需要操作結構體,但不想修改原始結構體,就使用值;
3. 方法特點
除了 receiver 參數名字要保證唯一外,Go 語言對 receiver參數的基類型也有約束,那就是 receiver 參數的基類型本身不能為指針類型或接口類型。
類型方法有如下特點:
- 可以為命名類型增加方法(除了接口),非命名類型不能自定義方法。
比如不能為 []int 類型增加方法,因為[]int是非命名類型。命名接口類型本身就是一個方法的簽名集合,所以不能為其增加具體的實現方法。
下面的例子分別演示了基類型為指針類型和接口類型時,Go 編譯器報錯的情況:
type MyInt *int
func (r MyInt) String() string { // r的基類型為MyInt,編譯器報錯:invalid receiver type MyInt (MyInt is a pointer type)return fmt.Sprintf("%d", *(*int)(r))
}type MyReader io.Reader
func (r MyReader) Read(p []byte) (int, error) { // r的基類型為MyReader,編譯器報錯:invalid receiver type MyReader (MyReader is an interface type)return r.Read(p)
}
- 為類型增加方法有一個限制,就是方法的定義必須和類型的定義在同一個包中。
Go 要求,方法聲明要與 receiver 參數的基類型聲明放在同一個包內?;谶@個約束,我們還可以得到兩個推論。
第一個推論:我們不能為原生類型(諸如 int、float64、map 等)添加方法。比如,下面的代碼試圖為 Go 原生類型 int 增加新方法 Foo,這樣做,Go編譯器會報錯:
func (i int) Foo() string { // 編譯器報錯:cannot define new methods on non-local type intreturn fmt.Sprintf("%d", i)
}
第二個推論:不能跨越 Go 包為其他包的類型聲明新方法。
比如,下面的代碼試圖跨越包邊界,為 Go 標準庫中的 http.Server 類型添加新方法 Foo,這樣做,Go 編譯器同樣會報錯:
import "net/http"func (s http.Server) Foo() { // 編譯器報錯:cannot define new methods on non-local type http.Server
}
不能再為 int 、 bool 等預聲明類型增加方法,因為它們是命名類型,但它們是 Go 語言內置的預聲明類型,作用域是全局的,為這些類型新增的方法是在某個包中,這與第2 條規則沖突,所以 Go 編譯器拒絕為 int 增加方法。
-
方法的命名空間的可見性和變量一樣,大寫開頭的方法可以在包外被訪問,否則只能在包內可見。
-
使用
type定義的自定義類型是一個新類型,新類型不能調用原有類型的方法,但是底層類型支持的運算可以被新類型繼承。
type Map map[string]stringfunc (m Map) Print() {// 底層類型支持的 range 運算,新類型同樣支持for _, v := range m {fmt.Println(v)}
}type MyInt intfunc main() {var a MyInt = 10var b MyInt = 20// int 類型支持的加減乘除運算, 新類型同樣可用c := a + bd := a * bfmt.Println(c)fmt.Println(d)
}
4. 方法調用
類型方法本質上是函數,只是采用了一種特殊的語法書寫。
4.1 一般調用
類型方法的一般調用方式:
TypeinstanceName.MethodName(ParamList)
TypeinstanceName:類型實例名或指向實例的指針變量名;MethodName: 類型方法名;ParamList: 方法實參。
package mainimport "fmt"type T struct {a int
}func (t T) Get() int {return t.a
}func (t *T) Set(i int) int {t.a = ireturn t.a
}func main() {var t = &T{}fmt.Println(t.Set(2)) // 普通方法調用fmt.Println(t.Get())
}
提示:在調用方法的時候,傳遞的接收者本質上都是副本,只不過一個是這個值副本,一是指向這個值指針的副本。
指針具有指向原有值的特性,所以修改了指針指向的值,也就修改了原有的值。我們可以簡單地理解為值接收者使用的是值的副本來調用方法,而指針接收者使用實際的值來調用方法。
C++ 中的對象在調用方法時,編譯器會自動傳入指向對象自身的 this 指針作為方法的第一個參數。而 Go 方法中的原理也是相似的,只不過我們是將 receiver 參數以第一個參數的身份并入到方法的參數列表中。按照這個原理,我們示例中的類型 T 和 *T 的方法,就可以分別等價轉換為下面的普通函數:
// 類型T的方法Get的等價函數
func Get(t T) int { return t.a
}// 類型*T的方法Set的等價函數
func Set(t *T, a int) int { t.a = a return t.a
}
這種等價轉換后的函數的類型就是方法的類型。只不過在 Go 語言中,這種等價轉換是由 Go 編譯器在編譯和生成代碼時自動完成的。
4.2 方法值
變量 x 的靜態類型是 T , M 是類型 T 的一個方法, x.M 被稱為方法值( method value ) 。x.M是一個函數類型變量, 可以賦值給其他變量,并像普通的函數名一樣使用。例如:
f := x.M
f(args...)
// 等價于
x.M(args...)
方法值( method value )其實是一個帶有閉包的函數變量,其底層實現原理和帶有閉包的匿名函數類似, 接收值被隱式地綁定到方法值( method value )的閉包環境中。后續調用不需要再顯式地傳遞接收者。例如:
package mainimport "fmt"type T struct {a int
}func (t T) Get() int {return t.a
}func (t *T) Set(i int) int {t.a = ireturn t.a
}func (t *T) Print() {fmt.Printf("%p, %v, %d\n", t, t, t.a)
}func main() {var t = &T{}// method valuef := t.Set// 方法值調用f(3)t.Print()
}
4.3 方法表達式
方法表達式相當于提供一種語法將類型方法調用顯式地轉換為函數調用,接收者( receiver )必須顯式地傳遞進去。下面定義一個類型 T ,增加兩個方法,方法 Get 的接收者為 T ,方法 Set 的接收者類型為 *T 。
package mainimport "fmt"type T struct {a int
}func (t T) Get() int {return t.a
}func (t *T) Set(i int) int {t.a = ireturn t.a
}func (t *T) Print() {fmt.Printf("%p, %v, %d\n", t, t, t.a)
}
表達式 T.Get 和 (*T).Set 被稱為方法表達式(method expression),方法表達式可以看作函數名,只不過這個函數的首個參數是接收者的實例或指針。T.Get 的函數簽名是 func (t T) int ,(*T).Set 的函數簽名是 func( t *T, i int) 。
Go 語言規范中還提供了方法表達式(Method Expression)的概念,可以讓我們更充分地理解上面的等價轉換,我們還以上面類型 T 以及它的方法為例,結合前面說過的 Go 方法的調用方式,我們可以得到下面代碼:
var t T
t.Get()
(&t).Set(1)
我們可以用另一種方式,把上面的方法調用做一個等價替換:
var t T
T.Get(t)
(*T).Set(&t, 1)
這種直接以類型名 T 調用方法的表達方式,被稱為 Method Expression。通過 Method Expression 這種形式,類型 T 只能調用 T 的方法集合(Method Set)中的方法,同理類型 *T 也只能調用 *T 的方法集合中的方法。
Go 語言中的方法的本質就是,一個以方法的 receiver 參數作為第一個參數的普通函數。
我們甚至可以將它作為右值,賦值給一個函數類型的變量,比如下面示例:
func main() {var t Tf1 := (*T).Set // f1的類型,也是*T類型Set方法的類型:func (t *T, int)intf2 := T.Get // f2的類型,也是T類型Get方法的類型:func(t T)intfmt.Printf("the type of f1 is %T\n", f1) // the type of f1 is func(*main.T, int) intfmt.Printf("the type of f2 is %T\n", f2) // the type of f2 is func(main.T) intf1(&t, 3)fmt.Println(f2(t)) // 3
}
注意: 這里的 T.Get不能寫成 (*T).Get ,(*T).Set也不能寫成 T.Set ,在方法表達式中編譯器不會做自動轉換。例如:
func main() {// 以下方法表達式調用都是等價的t := T{a: 1}// 普通方法調用t.Get(t)// 方法表達式調用(T).Get(t)// 方法表達式調用f1 := T.Get()f1(t)// 方法表達式調用f2 := (T).Get()f2(t)// 以下方法表達式調用都是等價的(*T).Set(&t, 3)f3 := (*T).Setf3(&t, 1)}
通過方法值和方法表達式可以看到: Go 的方法底層是基于函數實現的,只是語法格式不同,本質是一樣的。
5. 基于指針對象的方法
基于指針對象的聲明方法:
type T struct {a int
}func (t *T) Set(i int) int {t.a = ireturn t.a
}
這個方法的名字是 (*T).Set 這里的括號是必須的;沒有括號的話這個表達式可能會被理解為 *(T.Set) 。
只有類型( T )和指向他們的指針(*T),才可能是出現在接收器聲明里的兩種接收器。此外,為了避免歧義,在聲明方法時,如果一個類型名本身是一個指針的話,是不允許其出現在接收器中的,比如下面這個例子:
type P *int
func (P) f() { /* ... */ } // compile error: invalid receiver type
想要調用指針類型方法(*T).Set,只要提供一個 T 類型的指針即可,像下面這樣。
r := &T{1}
r.Set(2)
fmt.Println(*r) // {2}
或者這樣:
p := T{1}
pptr := &p
pptr.Set(2)
fmt.Println(p) // {2}
或者這樣:
p := T{1}
(&p).Set(2)
fmt.Println(p) // {2}
不過后面兩種方法有些笨拙。幸運的是, Go 語言本身在這種地方會幫到我們。如果接收器 p 是一個 T 類型的變量,并且其方法需要一個 T 指針作為接收器,我們可以用下面這種簡短的寫法:
p.Set(2)
編譯器會隱式地幫我們用 &p 去調用 Set 這個方法。這種簡寫方法只適用于“變量”,包括 Set 里的字段比如 p.a ,以及 array 和 slice 內的元素比如 a[0] 。我們不能通過一個無法取到地址的接收器來調用指針方法,比如臨時變量的內存地址就無法獲取得到:
Point{1, 2}.ScaleBy(2) // compile error: can't take address of Point literal
但是我們可以用一個*T這樣的接收器來調用 T 的方法,因為我們可以通過地址來找到這個變量,只要用解引用符號*來取到該變量即可。編譯器在這里也會給我們隱式地插入*這個操作符,所以下面這兩種寫法等價的:
pptr.Set(2)
(*pptr).Set(2)
這里的幾個例子可能讓你有些困惑,所以我們總結一下:在每一個合法的方法調用表達式中,也就是下面三種情況里的任意一種情況都是可以的:
- 要么接收器的實際參數和其形式參數是相同的類型,比如兩者都是類型T或者都是類型
*T:
T{1}.Set(2) // Point
pptr.Set(2) // *Point
- 或者接收器實參是類型T,但接收器形參是類型
*T,這種情況下編譯器會隱式地為我們取變量的地址:
p.Set(2) // implicit (&p)
- 或者接收器實參是類型
*T,形參是類型T。編譯器會隱式地為我們解引用,取到指針指向的實際變量:
pptr.Set(2) // implicit (*pptr)
如果命名類型 T (譯注:用 type xxx 定義的類型)的所有方法都是用T類型自己來做接收器(而不是*T),那么拷貝這種類型的實例就是安全的;調用他的任何一個方法也就會產生一個值的拷貝。比如 time.Duration 的這個類型,在調用其方法時就會被全部拷貝一份,包括在作為參數傳入函數的時候。
但是如果一個方法使用指針作為接收器,你需要避免對其進行拷貝,因為這樣可能會破壞掉該類型內部的不變性。比如你對 bytes.Buffer 對象進行了拷貝,那么可能會引起原始對象和拷貝對象只是別名而已,實際上它們指向的對象是一樣的。緊接著對拷貝后的變量進行修改可能會有讓你有意外的結果。
package mainimport "fmt"type T struct {a int
}func (t T) Get() int {return t.a
}func (t *T) Set(i int) int {t.a = ireturn t.a
}func main() {t1 := T{a: 1}fmt.Println(t1.Set(3))fmt.Println(t1.Get())fmt.Println((&t1).Set(4))fmt.Println((&t1).Get())t2 := &T{a: 1}fmt.Println(t2.Set(3))fmt.Println(t2.Get())fmt.Println((*t2).Set(4))fmt.Println((*t2).Get())}
譯注: 作者這里說的比較繞,其實有兩點:
- 不管你的
method的receiver是指針類型還是非指針類型,都是可以通過指針/非指針類型進行調用的,編譯器會幫你做類型轉換。 - 在聲明一個
method的receiver該是指針還是非指針類型時,你需要考慮兩方面的因素,第一方面是這個對象本身是不是特別大,如果聲明為非指針變量時,調用會產生一次拷貝;第二方面是如果你用指針類型作為receiver,那么你一定要注意,這種指針類型指向的始終是一塊內存地址,就算你對其進行了拷貝。熟悉C或者C++的人這里應該很快能明白。
如果使用一個值類型變量調用指針類型接收者的方法,Go 語言編譯器會自動幫我們取指針調用,以滿足指針接收者的要求。
同樣的原理,如果使用一個指針類型變量調用值類型接收者的方法,Go 語言編譯器會自動幫我們解引用調用,以滿足值類型接收者的要求。
總之,方法的調用者,既可以是值也可以是指針,不用太關注這些,Go 語言會幫我們自動轉義,大大提高開發效率,同時避免因不小心造成的 Bug。
不管是使用值類型接收者,還是指針類型接收者,要先確定你的需求:在對類型進行操作的時候是要改變當前接收者的值,還是要創建一個新值進行返回?這些就可以決定使用哪種接收者。
參考書籍:
- Go 語言核心編程
- Go 語言圣經
總結
以上是生活随笔為你收集整理的Go 学习笔记(34)— Go 方法声明、方法调用、方法值、方法表达式、切片对象方法、指针对象方法的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Go 学习笔记(33)— Go 自定义类
- 下一篇: Go 学习笔记(35)— Go 接口 i