一篇文章彻底弄懂go语言方法的本质

九路
• 阅读 2207

Go 语言不支持经典的面向对象语法元素,比如:类、对象、继承等。但 Go 语言也有方法(method)。和函数相比,Go 语言中的方法在声明形式上仅仅多了一个参数,Go 称之为 receiver 参数。而 receiver 参数正是方法与类型之间的纽带。

Go 方法的一般声明形式如下:

func (receiver T/*T) MethodName(参数列表) (返回值列表) {
    // 方法体
}

上面方法声明中的 T 称为 receiver 的基类型。通过 receiver,上述方法被绑定到类型 T 上。换句话说:上述方法是类型 T 的一个方法,我们可以通过类型 T 或*T 的实例调用该方法。

var t T
t.MethodName(参数列表)

var pt *T = &t
pt.MethodName(参数列表)

Go 方法具有如下特点:

  • 方法名的首字母是否大写决定了该方法是否是导出方法 ;
  • 方法定义要与类型定义放在同一个包内。

由于方法定义与类型定义必须放在同一个包下面,因此我们可以推论得到:我们不能为原生类型(诸如:int、float64、map 等)添加方法, 只能为自定义类型定义方法。

错误的作法:
func (i int) String() string { // cannot define new methods on non-local type int
        return fmt.Sprintf("%d", i) 
}

vs.

正确的作法:
type MyInt int

func (i MyInt) String() string {
        return fmt.Sprintf("%d", int(i))
}

同理,我们也可以推论得出:我们也不能横跨 Go 包为其他包内的自定义类型定义方法。

  • 每个方法只能有一个 receiver 参数,不支持多 receiver 参数列表或变长 receiver 参数。一个方法只能绑定一个基类型,Go 语言不支持同时绑定多个类型的方法。

  • receiver 参数的基类型本身不能是指针类型或接口类型。

    type MyInt *int
    func (r MyInt) String() string { // 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) { // invalid receiver type MyReader (MyReader is an interface type) return r.Read(p) }

和其他主流编程语言相比,Go 语言从函数到方法仅仅多出了一个 receiver,这大大降低了 Gopher 们学习方法的门槛。但即便如此,Gopher 们在把握方法本质以及如何选择 receiver 的类型时仍存在困惑,本节我就针对这些困惑做重点的说明。

## 1. 方法的本质
前面提到过:Go 语言没有类,方法与类型通过 receiver 联系在一起,我们可以为任何非内置原生类型定义方法,比如下面的类型 T:

```go
type T struct { 
        a int
}

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

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

C++的对象在调用方法时,编译器会自动传入指向对象自身的 this 指针作为方法的第一个参数。而对于 Go 来说,receiver 其实也是同样道理,我们将 receiver 作为第一个参数传入方法的参数列表,上面示例中的类型 T 的方法就可以等价转换为下面的普通函数:

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

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

这种转换后的函数就是方法的原型。只不过在 Go 语言中,这种等价转换是由 Go 编译器在编译和生成代码时自动完成的。Go 语言规范中提供了方法表达式(method expression) 的概念,可以让我们更充分地理解上面的等价转换。

Go 方法的一般使用方式如下:

var t T
t.Get()
t.Set(1)

我们可以将上面方法调用用下面的方式做等价替换:

var t T
T.Get(t)
(*T).Set(&t, 1)

这种直接以类型名 T 调用方法 M 的表达方式被称为Method Expression。类型 T 只能调用 T 的方法集合(Method Set)中的方法;同理T 只能调用T 的方法集合中的方法(关于方法集合,我们会在下一节中详细讲解)。我们看到:Method Expression有些类似于 C++中的 static 方法,static 方法在使用时以该 C++类的某个对象实例作为第一个参数,而 Go 语言的 Method Expression 在使用时,同样以 receiver 参数所代表的实例作为第一个参数。

这种通过 Method Expression 对方法进行调用的方式与我们之前所做的方法到函数的等价转换是如出一辙的。 这就是 Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数。

Method Expression 体现了 Go 方法的本质:其自身的类型就是一个普通函数。我们甚至可以将其作为右值赋值给一个函数类型的变量:

var t T
f1 := (*T).Set // f1的类型,也是T类型Set方法的原型:func (t *T, int)int
f2 := T.Get // f2的类型,也是T类型Get方法的原型:func(t T)int
f1(&t, 3)
fmt.Println(f2(t))

2. 正确选择 receiver 类型

有了上面对 Go 方法本质的分析,我们再来理解 receiver 并在定义方法时选择正确的 receiver 类型就简单多了。我们再来看一下方法和函数的”等价变换公式“:

func (t T) M1() <=> M1(t T)
func (t *T) M2() <=> M2(t *T)

我们看到:M1 方法的 receiver 参数类型为 T,而 M2 方法的 receiver 参数类型为*T。

  • 当 receiver 参数的类型为 T 时,即选择值类型的 receiver。

我们选择以 T 作为 receiver 参数类型时,T 的 M1 方法等价为 M1(t T)。我们知道 Go 函数的参数采用的是值拷贝传递,也就是说 M1 函数体中的 t 是 T 类型实例的一个副本,这样 M1 函数的实现中无论对参数 t 做任何修改都只会影响副本,而不会影响到原 T 类型实例。

  • 当 receiver 参数的类型为 T 时,即选择指针类型的 receiver。 我们选择以T 作为 receiver 参数类型时,T 的 M2 方法等价为 M2(t *T)。我们传递给 M2 函数的 t 是 T 类型实例的地址,这样 M2 函数体中对参数 t 做的任何修改都会反映到原 T 类型实例。

我们以下面的例子演示一下选择不同的 receiver 类型对原类型实例的影响:

package main

type T struct {
    a int
}

func (t T) M1() {
    t.a = 10
}

func (t *T) M2() {
    t.a = 11
}

func main() {
    var t T // t.a = 0
    println(t.a)

    t.M1()
    println(t.a)

    t.M2()
    println(t.a)
}

运行该程序:输出

0
0
11

在该示例中,M1 和 M2 方法体内都对字段 a 做了修改,但 M1(采用值类型 receiver)修改的只是实例的副本,对原实例并没有影响,因此 M1 调用后,输出 t.a 的值仍为 0;M2(采用指针类型 receiver)修改的是实例本身,因此 M2 调用后,t.a 的值变为了 11。

很多 Go 初学者还有这样的疑惑:是不是 T 类型实例只能调用 receiver 为 T 类型的方法,不能调用 receiver 为T 类型的方法呢?答案是否定的。无论是 T 类型实例,还是T 类型实例,都既可以调用 receiver 为 T 类型的方法,也可以调用 receiver 为*T 类型的方法。下面例子证明了这一点:

package main

type T struct {
    a int
}

func (t T) M1() {
}

func (t *T) M2() {
    t.a = 11
}

func main() {
    var t T
    t.M1() // ok
    t.M2() // <=> (&t).M2()

    var pt = &T{}
    pt.M1() // <=> (*pt).M1()
    pt.M2() // ok
}

通过例子我们看到 T 类型实例 t 调用 receiver 类型为T 的 M2 方法是没问题的,同样T 类型实例 pt 调用 receiver 类型为 T 的 M1 方法也是可以的。实际上这都是 Go 语法甜头(syntactic sugar),即 Go 编译器在编译和生成代码时为我们自动做的转换。

到这里,我们可以得出 receiver 类型选用的初步结论:

  • 如果要对类型实例进行修改,那么为 receiver 选择*T 类型;
  • 如果没有对类型实例修改的需求,那么为 receiver 选择 T 类型或T 类型均可;但考虑到 Go 方法调用时,receiver 是以值拷贝的形式传入方法中的。如果类型 size 较大,以值形式传入会导致较大损耗,这时选择T 作为 receiver 类型可能更好些。

对于 receiver 的类型的选择其实还有一个重要因素,那就是类型是否要实现某个 interface,这个考量因素在下一节中将有详细说明。

3. 利用对 Go 方法本质的理解巧解难题

下面的这个例子来自于笔者博客的一次真实的读者咨询,他的问题代码如下:

package main

import (
    "fmt"
    "time"
)

type field struct {
    name string
}

func (p *field) print() {
    fmt.Println(p.name)
}

func main() {
    data1 := []*field{{"one"}, {"two"}, {"three"}}
    for _, v := range data1 {
        go v.print()
    }

    data2 := []field{{"four"}, {"five"}, {"six"}}
    for _, v := range data2 {
        go v.print()
    }

    time.Sleep(3 * time.Second)
}

该示例在我的多核 MacOS 上运行结果如下(由于 goroutine 调度顺序不同,结果可能与下面的有差异):

one
two
three
six
six
six

这位读者的问题显然是:为什么对 data2 迭代输出的结果是三个"six",而不是 four、five、six?

好了,我们来分析一下。首先,我们根据Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数,对这个程序做个等价变换(这里我们利用 Method Expression),变换后的源码如下:

package main

import (
        "fmt"
        "time"
)

type field struct {
        name string
}

func (p *field) print() {
        fmt.Println(p.name)
}

func main() {
        data1 := []*field{{"one"}, {"two"}, {"three"}}
        for _, v := range data1 {
                go (*field).print(v)
        }

        data2 := []field{{"four"}, {"five"}, {"six"}}
        for _, v := range data2 {
                go (*field).print(&v)
        }

        time.Sleep(3 * time.Second)
}

这里我们把对 field 的方法 print 的调用替换为 Method Expression 形式,替换前后的程序输出结果是一致的。但变换后,问题是不是豁然开朗了,我们可以很清楚地看到使用 go 关键字启动一个新 goroutine 时是如何绑定参数的:

  • 迭代 data1 时,由于 data1 中的元素类型是 field 指针(*field),因此赋值后 v 就是元素地址,每次调用 print 时传入的参数(v)实际上也是各个 field 元素的地址;
  • 迭代 data2 时,由于 data2 中的元素类型是 field(非指针),需要将其取地址后再传入。这样每次传入的&v 实际上是变量 v 的地址,而不是切片 data2 中各元素的地址;

在 for range 语句中循环变量是复用的,这样一来这里的v在整个 for range 过程中只有一个,因此 data2 迭代完成之后,v是元素"six"的拷贝。

这样,一旦启动的各个子 goroutine 在 main goroutine 执行到 Sleep 时才被调度执行,那么最后的三个 goroutine 在打印&v 时,打印的也就都 v 中存放的值"six"了。而前三个子 goroutine 各自传入的是元素"one"、“two"和"three"的地址,打印的就是"one”、"two"和"three"了。

那么原程序如何修改一下才能让其按期望输出(“one”、“two”、“three”, “four”, “five”, “six”)呢?其实只需将 field 类型 print 方法的 receiver 类型由*field 改为 field 即可。


... ...

type field struct {
    name string
}

func (p field) print() {
    fmt.Println(p.name)
}

... ...

修改后的程序的输出结果为(因 goroutine 调度顺序不同,在你的机器上的结果输出顺序与这里可能会有不同):

one
two
three
four
five
six

至于其中的原因,大家可以参考我的分析思路自行分析一下

4. 小结

本节要点:

  • Go 方法的本质:一个以方法所绑定类型实例为第一个参数的普通函数;
  • Go 语法甜头使得我们通过类型实例调用类型方法时无需考虑实例类型与 receiver 参数类型是否一致,编译器会为我们做自动转换;
  • receiver 参数类型选择时要看是否要对类型实例进行修改;如有修改需求,则选择*T;如无修改需求,T 类型 receiver 传值的性能损耗也是考量因素之一。
点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
三十分钟入门基础Go(Java小子版)
本篇文章适用于学习过其他面向对象语言(Java、Php),但没有学过Go语言的初学者。文章主要从Go与Java功能上的对比来阐述Go语言的基础语法、面向对象编程、并发与错误四个方面。
Stella981 Stella981
2年前
Go语言中的方法(Method Sets)
物以类聚,人以群分。事物必以类分,以形命名。开始一个个学习Go语言中有哪些数据类型,以及这些类型的特点。Go语言中的方法是函数之外一个特别的概念。在Go语言的参考中说:Atypemayhaveamethodsetassociatedwithit(§Interfacetypes,§Methoddeclarations).
Wesley13 Wesley13
2年前
C++调用Go方法的字符串传递问题及解决方案
摘要:C调用Go方法时,字符串参数的内存管理需要由Go侧进行深度值拷贝。现象在一个APP技术项目中,子进程按请求加载Go的ServiceModule,将需要拉起的ServiceModule信息传递给Go的Loader,存在C调用Go方法,传递字符串的场景。方案验证时,发现有奇怪的将std::string对象的内容传递给G
Wesley13 Wesley13
2年前
Go接口
Go接口的定义Go语言不是一种 _“传统”_ 的面向对象编程语言:它里面没有类和继承的概念。但是Go语言里有非常灵活的 接口(Interfacesarenamedcollectionsofmethodsignatures) 概念,通过它可以实现很多面向对象的特性。接口提供了一种方式来 说明 对象的
Wesley13 Wesley13
2年前
Go 函数方法
   在Go语言中,函数和方法不太一样,有明确的概念区分。其他语言中,比如PHP函数就是方法,方法就是函数,但在Go语言中,函数是不属于任何结构体、类型的方法,也就是说函数是没有接收者的;而方法是有接收者的,我们所说的方法要么属于一个结构体的,要么属于一个新定义的类型的函数函数和方法,虽然概念不同,但是定义非常相似
小万哥 小万哥
3个月前
Go 语言学习指南:变量、循环、函数、数据类型、Web 框架等全面解析
学习基础知识掌握Go语言的常见概念,如变量、循环、条件语句、函数、数据类型等等。深入了解Go基础知识的好起点是查阅Go官方文档文章链接:基本语法了解Go语言的基本语法,包括Go程序的执行方式、包引入、主函数等Go中的变量变量是赋予内存位置的名称,用于存储特
何婆子 何婆子
2个月前
体系课-慕课Go开发工程师2023全新版|完结无密|独家首发
体系课慕课Go开发工程师2023全新版|完结无密|独家首发go语言和go开发工程师的背景介绍download》chaoxingit.com/3855/以下是关于Go语言和Go开发工程师的背景介绍:Go(Golang)语言:1.语言背景:Go是一门由Goog