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 main

import "fmt"

type SliceInt []int

// 面向对象
func (s SliceInt) Sum() int {
	sum := 0
	for _, i := range s {
		sum += i
	}

	return sum
}

// 面向过程   这个函数和上面方法等价
func SliceIntSum(s SliceInt) int {
	sum := 0
	for _, 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 main

import (
	"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.Radius
	return (float64(4) / float64(3)) * math.Pi * radiusCubed
}

// 方法接收者参数类型为值引用
func (s Sphere) ChageRadiusValue(r float64) float64 {
	s.Radius = r
	return r
}

// 方法接收者参数类型为指针
func (s *Sphere) ChageRadiusPoint(r float64) float64 {
	s.Radius = r
	return r
}

func main() {

	s := &Sphere{
		Radius: 5,
	}
	fmt.Println(s.SurfaceArea())
	fmt.Println(s.Volume())

	r := 1.0
	s.ChageRadiusValue(r) // 方法接收者参数类型为值引用时不会改变原始值
	fmt.Println(s.Radius) // 5
	s.ChageRadiusPoint(r) // 方法接收者参数类型为指针时会改变原始值
	fmt.Println(s.Radius) // 1
}

指针和值之间的差别很微妙,但选择使用指针还是值这一点很简单:

  • 如果需要修改原始结构体,就使用指针;
  • 如果需要操作结构体,但不想修改原始结构体,就使用值;

3. 方法特点

除了 receiver 参数名字要保证唯一外,Go 语言对 receiver 参数的基类型也有约束,那就是 receiver 参数的基类型本身不能为指针类型或接口类型。

类型方法有如下特点:

  1. 可以为命名类型增加方法(除了接口),非命名类型不能自定义方法。

比如不能为 []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)
}
  1. 为类型增加方法有一个限制,就是方法的定义必须和类型的定义在同一个包中。

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

第一个推论:我们不能为原生类型(诸如 intfloat64map 等)添加方法。比如,下面的代码试图为 Go 原生类型 int 增加新方法 Foo,这样做,Go 编译器会报错:

func (i int) Foo() string { // 编译器报错:cannot define new methods on non-local type int
    return 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 增加方法。

  1. 方法的命名空间的可见性和变量一样,大写开头的方法可以在包外被访问,否则只能在包内可见。

  2. 使用 type 定义的自定义类型是一个新类型,新类型不能调用原有类型的方法,但是底层类型支持的运算可以被新类型继承。

type Map map[string]string

func (m Map) Print() {
	// 底层类型支持的 range 运算,新类型同样支持
	for _, v := range m {
		fmt.Println(v)
	}
}

type MyInt int

func main() {
	var a MyInt = 10
	var b MyInt = 20

	// int 类型支持的加减乘除运算, 新类型同样可用
	c := a + b
	d := a * b
	fmt.Println(c)
	fmt.Println(d)
}

4. 方法调用

类型方法本质上是函数,只是采用了一种特殊的语法书写。

4.1 一般调用

类型方法的一般调用方式:

TypeinstanceName.MethodName(ParamList)
  • TypeinstanceName :类型实例名或指向实例的指针变量名;
  • MethodName : 类型方法名;
  • ParamList : 方法实参。
package main

import "fmt"

type T struct {
	a int
}

func (t T) Get() int {
	return t.a
}

func (t *T) Set(i int) int {
	t.a = i
	return 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 main

import "fmt"

type T struct {
	a int
}

func (t T) Get() int {
	return t.a
}

func (t *T) Set(i int) int {
	t.a = i
	return t.a
}

func (t *T) Print() {
	fmt.Printf("%p, %v, %d\n", t, t, t.a)
}

func main() {
	var t = &T{}
	// method value
	f := t.Set
	// 方法值调用
	f(3)
	t.Print()
}

4.3 方法表达式

方法表达式相当于提供一种语法将类型方法调用显式地转换为函数调用,接收者( receiver )必须显式地传递进去。下面定义一个类型 T ,增加两个方法,方法 Get 的接收者为 T ,方法 Set 的接收者类型为 *T

package main

import "fmt"

type T struct {
	a int
}

func (t T) Get() int {
	return t.a
}

func (t *T) Set(i int) int {
	t.a = i
	return 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 T
    f1 := (*T).Set // f1的类型,也是*T类型Set方法的类型:func (t *T, int)int
    f2 := T.Get    // f2的类型,也是T类型Get方法的类型:func(t T)int
    fmt.Printf("the type of f1 is %T\n", f1) // the type of f1 is func(*main.T, int) int
    fmt.Printf("the type of f2 is %T\n", f2) // the type of f2 is func(main.T) int
    f1(&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).Set
	f3(&t, 1)

}

通过方法值和方法表达式可以看到: Go 的方法底层是基于函数实现的,只是语法格式不同,本质是一样的。

5. 基于指针对象的方法

基于指针对象的声明方法:

type T struct {
	a int
}

func (t *T) Set(i int) int {
	t.a = i
	return 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 ,以及 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)

这里的几个例子可能让你有些困惑,所以我们总结一下:在每一个合法的方法调用表达式中,也就是下面三种情况里的任意一种情况都是可以的:

  1. 要么接收器的实际参数和其形式参数是相同的类型,比如两者都是类型T或者都是类型*T
T{1}.Set(2) //  Point
pptr.Set(2)         // *Point
  1. 或者接收器实参是类型T,但接收器形参是类型*T,这种情况下编译器会隐式地为我们取变量的地址:
p.Set(2) // implicit (&p)
  1. 或者接收器实参是类型*T,形参是类型T。编译器会隐式地为我们解引用,取到指针指向的实际变量:
pptr.Set(2) // implicit (*pptr)

如果命名类型 T (译注:用 type xxx 定义的类型)的所有方法都是用T类型自己来做接收器(而不是*T),那么拷贝这种类型的实例就是安全的;调用他的任何一个方法也就会产生一个值的拷贝。比如 time.Duration 的这个类型,在调用其方法时就会被全部拷贝一份,包括在作为参数传入函数的时候。

但是如果一个方法使用指针作为接收器,你需要避免对其进行拷贝,因为这样可能会破坏掉该类型内部的不变性。比如你对 bytes.Buffer 对象进行了拷贝,那么可能会引起原始对象和拷贝对象只是别名而已,实际上它们指向的对象是一样的。紧接着对拷贝后的变量进行修改可能会有让你有意外的结果。

package main

import "fmt"

type T struct {
	a int
}

func (t T) Get() int {
	return t.a
}

func (t *T) Set(i int) int {
	t.a = i
	return 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())

}

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

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

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

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

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

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

可以参考这篇文章:Go 方法参数类型选择

参考书籍:

  1. Go 语言核心编程
  2. Go 语言圣经
  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值