1000字范文,内容丰富有趣,学习的好帮手!
1000字范文 > Go 学习笔记(34)— Go 方法声明 方法调用 方法值 方法表达式 切片对象方法 指针对象方法

Go 学习笔记(34)— Go 方法声明 方法调用 方法值 方法表达式 切片对象方法 指针对象方法

时间:2022-04-12 08:22:46

相关推荐

Go 学习笔记(34)— Go 方法声明 方法调用 方法值 方法表达式 切片对象方法 指针对象方法

1. 方法声明

Go语言的方法非常纯粹, 可以看作特殊类型的函数, 其显式地将对象实例或指针作为函数的第一个参数, 并且参数名可以自己指定, 而不强制要求一定是thisself。这个对象实例或指针称为方法的接收者(reciever)。

为命名类型定义方法的语法格式如下:

// 类型方法接收者是值类型func (t TypeName) MethodName (ParamList ) (Returnlist) {//method body}// 类型方法接收者是指针func (t *TypeName) MethodName (ParamList) (Returnlist) {//method body}

说明:

t是接收者或者叫接收器变量,官方建议使用接收器类型名TypeName的 第一个小写字母,而不是selfthis之类的命名。例如,Socket类型的接收器变量应该命名为sConnector类型的接收器变量应该命名为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 *intfunc (r MyInt) String() string {// r的基类型为MyInt,编译器报错:invalid receiver type MyInt (MyInt is a pointer type)return fmt.Sprintf("%d", *(*int)(r))}type MyReader io.Readerfunc (r MyReader) Read(p []byte) (int, error) {// r的基类型为MyReader,编译器报错:invalid receiver type MyReader (MyReader is an interface type)return r.Read(p)}

为类型增加方法有一个限制,就是方法的定义必须和类型的定义在同一个包中。

Go要求,方法声明要与receiver参数的基类型声明放在同一个包内。基于这个约束,我们还可以得到两个推论。

第一个推论:我们不能为原生类型(诸如intfloat64map等)添加方法。比如,下面的代码试图为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}

不能再为intbool等预声明类型增加方法,因为它们是命名类型,但它们是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的静态类型是TM是类型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 Tt.Get()(&t).Set(1)

我们可以用另一种方式,把上面的方法调用做一个等价替换:

var t TT.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 *intfunc (P) f() {/* ... */ } // compile error: invalid receiver type

想要调用指针类型方法(*T).Set,只要提供一个T类型的指针即可,像下面这样。

r := &T{1}r.Set(2)fmt.Println(*r) // {2}

或者这样:

p := T{1}pptr := &ppptr.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,以及arrayslice内的元素比如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) // Pointpptr.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())}

译注:作者这里说的比较绕,其实有两点:

不管你的methodreceiver是指针类型还是非指针类型,都是可以通过指针/非指针类型进行调用的,编译器会帮你做类型转换。在声明一个methodreceiver该是指针还是非指针类型时,你需要考虑两方面的因素,第一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷贝;第二方面是如果你用指针类型作为receiver,那么你一定要注意,这种指针类型指向的始终是一块内存地址,就算你对其进行了拷贝。熟悉C或者C++的人这里应该很快能明白。

如果使用一个值类型变量调用指针类型接收者的方法,Go 语言编译器会自动帮我们取指针调用,以满足指针接收者的要求。

同样的原理,如果使用一个指针类型变量调用值类型接收者的方法,Go 语言编译器会自动帮我们解引用调用,以满足值类型接收者的要求。

总之,方法的调用者,既可以是值也可以是指针,不用太关注这些,Go 语言会帮我们自动转义,大大提高开发效率,同时避免因不小心造成的 Bug。

不管是使用值类型接收者,还是指针类型接收者,要先确定你的需求:在对类型进行操作的时候是要改变当前接收者的值,还是要创建一个新值进行返回?这些就可以决定使用哪种接收者。

参考书籍:

Go 语言核心编程Go 语言圣经

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。