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 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
参数的基类型本身不能为指针类型或接口类型。
类型方法有如下特点:
- 可以为命名类型增加方法(除了接口),非命名类型不能自定义方法。
比如不能为 []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 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
}
不能再为 int
、 bool
等预声明类型增加方法,因为它们是命名类型,但它们是 Go
语言内置的预声明类型,作用域是全局的,为这些类型新增的方法是在某个包中,这与第2 条规则冲突,所以 Go
编译器拒绝为 int
增加方法。
-
方法的命名空间的可见性和变量一样,大写开头的方法可以在包外被访问,否则只能在包内可见。
-
使用
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
的静态类型是 T
, M
是类型 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
,以及 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 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())
}
译注: 作者这里说的比较绕,其实有两点:
- 不管你的
method
的receiver
是指针类型还是非指针类型,都是可以通过指针/非指针类型进行调用的,编译器会帮你做类型转换。 - 在声明一个
method
的receiver
该是指针还是非指针类型时,你需要考虑两方面的因素,第一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷贝;第二方面是如果你用指针类型作为receiver
,那么你一定要注意,这种指针类型指向的始终是一块内存地址,就算你对其进行了拷贝。熟悉C
或者C++
的人这里应该很快能明白。
如果使用一个值类型变量调用指针类型接收者的方法,Go 语言编译器会自动帮我们取指针调用,以满足指针接收者的要求。
同样的原理,如果使用一个指针类型变量调用值类型接收者的方法,Go 语言编译器会自动帮我们解引用调用,以满足值类型接收者的要求。
总之,方法的调用者,既可以是值也可以是指针,不用太关注这些,Go 语言会帮我们自动转义,大大提高开发效率,同时避免因不小心造成的 Bug。
不管是使用值类型接收者,还是指针类型接收者,要先确定你的需求:在对类型进行操作的时候是要改变当前接收者的值,还是要创建一个新值进行返回?这些就可以决定使用哪种接收者。
可以参考这篇文章:Go 方法参数类型选择
参考书籍: