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

九路 等级 563 0 0

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 传值的性能损耗也是考量因素之一。
收藏
评论区

相关推荐

【读vue 源码】溯源 import Vue from 到底做了什么?
阅读资源 vue.js源码托管地址(https://links.jianshu.com/go?tohttps%3A%2F%2Fgithub.com%2Fvuejs%2Fvue) flow 静态检查工具地址(https://links.jianshu.com/go?tohttps%3A%2F%2Fflow.org%2Fen%2Fdoc
【Golang】Goland使用介绍
goland介绍 Goland官方地址:http://www.jetbrains.com/go/(http://www.jetbrains.com/go/) goland安装 下载 Windows下载地址:https://download.jetbrains.com/go/goland2018.2.1.exe(https://download
【程序人生】毕业入职后,C++转Go语言工作半年感受
我在大学期间就听说了Go并学习了一段时间,坦白的说,那时候对Go是比较无感的,因为并 没有看到Go特别亮眼的地方,可能和我使用C、C、Java有关,这三
[Go] GO语言中的md5和sha256加密
项目中经常使用的md5和sha256加密函数 //md5加密 func Md5(src string) string { m : md5.New() m.Write(byte(src)) res : hex.EncodeToString(m.Sum(nil)) return res } //Sha256加密
Go语言开发的利与弊
Go 语言有多火爆?国外如 Google、AWS、Cloudflare、CoreOS 等,国内如七牛、阿里等都已经开始大规模使用 Go 语言开发其云计算相关产品。在 Go 语言的使用过程中,需要注意哪些 Yes 和 But? 最近,我们使用 Go 语言编写了一个 API,Go 语言是一种开源编程语言,2009 年由 Google 推出。在使用 Go 进行开
go-map源码简单分析(map遍历为什么时随机的)
GO 中map的底层是如何实现的 首先Go 语言采用的是哈希查找表,并且使用链表解决哈希冲突。 GO的内存模型 先看这一张map原理图 (https://imghelloworld.osscnbeijing.aliyuncs.com/49dfa7b81e19fbab143ddc0a7b3b7fa0.png) map 再来看
go 语言资源整理
Awesome GitHub Topic for Go(https://links.jianshu.com/go?tohttps%3A%2F%2Fgithub.com%2Ftopics%2Fgolang) Awesome Go(https://links.jianshu.com/go?tohttps%3A%2F%2F
知乎从Python转为Go,是不是代表Go比Python好?
众所周知,知乎早在几年前就将推荐系统从 Python 转为了 Go。于是乎,一部分人就说 Go 比 Python 好,Go 和 Python 两大社区的相关开发人员为此也争论过不少,似乎,谁也没完全说服谁。 知乎从Python转为Go,是不是代表Go比Python好?我认为,各有优点,谁也取代不了谁,会长期共存! “由 Python 语言转向 Go 语言
go的三个运行基本命令的区别,go run ,go build 和 go install
最近在自学go,遇到点基础的问题,通过自己实际操作之后得出结论在实际操作之前,我们需要知道go有三种源码文件:      1,命令源码文件;声明自己属于main包,并且包含main函数的文件,每个项目只能有一个这样的文件,即程序的入口文件      2,库源码文件;不能直接被执行的源码文件      3,测试源码文件本次操作不涉及测试源码文件。go run
go语言开发入门:GO 开发者对 GO 初学者的建议
以促进 India 的 go 编程作为 GopherConIndia 承诺的一部分。我们采访了 40 位 Gophers(一个 Gopher 代表一个 GO 项目或是任何地方的 GO 程序员),得到了他们关于 GO 的意见。如果你正好刚刚开始 go 编程,他们对于我们一些问题的答案可能会对你有非常有用。看看这些。应该做:通读 the Go standard
Linux环境部署go运行环境并启动项目
第一步、搭建Go生产环境1.下载包 https://golang.org/dl/2.解压(有1.14.4版本了,tar zxvf后回有个go文件夹) cd /usr/local/ wget https://dl.google.com/go/go1.13.6.linuxamd64.tar.gz tar xf go1.13.
[go-linq]-Go的.NET LINQ式查询方法
关于我 开发者的福音,go也支持linq了 坑爹的集合go在进行集合操作时,有很不舒服的地方,起初我真的是无力吐槽,又苦于找不到一个好的第三方库,只能每次写着重复代码。举个栗子类 学生{姓名 年龄性别}1、现在有10个学生的数组,如果我要统计所有年龄大于20岁的人,那我需要一、遍历二、自定义条件三、再append数组添加。2、接着我又
[concurrent-map]-并发map在go中的使用
关于我 通过学习和分享的过程,将自己工作中的问题和技术总结输出,希望菜鸟和老鸟都能通过自己的文章收获新的知识,并付诸实施。 引言Go语言原生的map类型并不支持并发读写。在Go 1.9之前,go语言标准库中并没有实现并发map。在Go 1.9中,引入了sync.Map。 concurrentmap的优势concurrentm
GO的执行原理以及GO命令
一、Go的源码文件 Go 的源码文件分类: yuanmawenjian1(https://imghelloworld.osscnbeijing.aliyuncs.com/b50e58692d24232e7d6437
一篇文章彻底弄懂go语言方法的本质
Go 语言不支持经典的面向对象语法元素,比如:类、对象、继承等。但 Go 语言也有方法(method)。和函数相比,Go 语言中的方法在声明形式上仅仅多了一个参数,Go 称之为 receiver 参数。而 receiver 参数正是方法与类型之间的纽带。Go 方法的一般声明形式如下:gofunc (receiver T/T) MethodName(参数列表)