Go 方法介绍,理解“方法”的本质-CSDN博客

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

Go 方法介绍理解“方法”的本质

文章目录

一、认识 Go 方法

1.1 基本介绍

我们知道Go 语言从设计伊始就不支持经典的面向对象语法元素比如类、对象、继承等等但 Go 语言仍保留了名为“方法method”的语法元素。当然Go 语言中的方法和面向对象中的方法并不是一样的。Go 引入方法这一元素并不是要支持面向对象编程范式而是 Go 践行组合设计哲学的一种实现层面的需要。

在 Go 编程语言中方法是与特定类型相关联的函数。它们允许您在自定义类型上定义行为这个自定义类型可以是结构体struct或任何用户定义的类型。方法本质上是一种函数但它们具有一个特定的接收者receiver也就是方法所附加到的类型。这个接收者可以是指针类型或值类型。方法与函数的区别是函数不属于任何类型方法属于特定的类型。

1.2 声明

1.2.1 引入

首先我们这里以 Go 标准库 net/http 包中 *Server 类型的方法 ListenAndServeTLS 为例讲解一下 Go 方法的一般形式

img

和 Go 函数一样Go 的方法也是以 func 关键字修饰的并且和函数一样也包含方法名对应函数名、参数列表、返回值列表与方法体对应函数体。

而且方法中的这几个部分和函数声明中对应的部分在形式与语义方面都是一致的比如方法名字首字母大小写决定该方法是否是导出方法方法参数列表支持变长参数方法的返回值列表也支持具名返回值等。

不过它们也有不同的地方。从上面这张图我们可以看到和由五个部分组成的函数声明不同Go 方法的声明有六个组成部分多的一个就是图中的 receiver 部分。在 receiver 部分声明的参数Go 称之为 receiver 参数这个 receiver 参数也是方法与类型之间的纽带也是方法与函数的最大不同。

Go 中的方法必须是归属于一个类型的而 receiver 参数的类型就是这个方法归属的类型或者说这个方法就是这个类型的一个方法。以图中的 ListenAndServeTLS 为例这里的 receiver 参数 srv 的类型为 *Server那么我们可以说这个方法就是 *Server 类型的方法。

注意这里说的是 ListenAndServeTLS*Server 类型的方法而不是 Server 类型的方法。

1.2.2 一般声明形式

方法的声明形式如下

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

其中各部分的含义如下

  • (t *T或T)括号中的部分是方法的接收者用于指定方法将附加到的类型。t 是接收者的名称T 是接收者的类型。接收者可以是值类型T或指针类型*T。如果使用值类型作为接收者方法操作的是接收者的副本而指针类型允许方法修改接收者的原始值。无论 receiver 参数的类型为 *T 还是 T我们都把一般声明形式中的 T 叫做 receiver 参数 t 的基类型。如果 t 的类型为 T那么说这个方法是类型 T 的一个方法如果 t 的类型为 *T那么就说这个方法是类型 *T 的一个方法。而且要注意的是每个方法只能有一个 receiver 参数Go 不支持在方法的 receiver 部分放置包含多个 receiver 参数的参数列表或者变长 receiver 参数。
  • MethodName这是方法的名称用于在调用方法时引用它。
  • (参数列表)这是方法的参数列表定义了方法可以接受的参数。如果方法不需要参数此部分为空。
  • (返回值列表)这是方法的返回值列表定义了方法返回的结果。如果方法不返回任何值此部分为空。
  • 方法体方法体包含了方法的具体实现这里可以编写方法的功能代码。

1.2.3 receiver 参数作用域

方法接收器receiver参数、函数 / 方法参数以及返回值变量对应的作用域范围都是函数 / 方法体对应的显式代码块。

这就意味着receiver 部分的参数名不能与方法参数列表中的形参名以及具名返回值中的变量名存在冲突必须在这个方法的作用域中具有唯一性。如果不唯一比如下面的例子中那样Go 编译器就会报错

type T struct{}

func (t T) M(t string) { // 编译器报错duplicate argument t (重复声明参数t)
    ... ...
}

不过如果在方法体中没有使用 receiver 参数我们也可以省略 receiver 的参数名就像下面这样

type T struct{}

func (T) M(t string) { 
    ... ...
}

仅当方法体中的实现不需要 receiver 参数参与时我们才会省略 receiver 参数名不过这一情况很少使用了解一下即可。

1.2.4 receiver 参数的基类型约束

Go 语言对 receiver 参数的基类型也有约束那就是 receiver 参数的基类型本身不能为指针类型或接口类型。

下面的例子分别演示了基类型为指针类型和接口类型时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.2.5 方法声明的位置约束

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
}

1.2.6 如何使用方法

我们直接还是通过一个例子理解一下。如果 receiver 参数的基类型为 T那么我们说 receiver 参数绑定在 T 上我们可以通过 *T 或 T 的变量实例调用该方法

type T struct{}

func (t T) M(n int) {
}

func main() {
    var t T
    t.M(1) // 通过类型T的变量实例调用方法M

    p := &T{}
    p.M(2) // 通过类型*T的变量实例调用方法M
}

这段代码中方法 M 是类型 T 的方法通过 *T 类型变量也可以调用 M 方法。

二、方法的本质

通过以上我们知道了 Go 的方法与 Go 中的类型是通过 receiver 联系在一起我们可以为任何非内置原生类型定义方法比如下面的类型 T

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 
}

在Go 中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 编译器在编译和生成代码时自动完成的。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 的方法集合中的方法。

我们看到Method Expression 有些类似于 C++ 中的静态方法Static Method。在 C++ 中的静态方法使用时以该 C++ 类的某个对象实例作为第一个参数。而 Go 语言的 Method Expression 在使用时同样以 receiver 参数所代表的类型实例作为第一个参数。

这种通过 Method Expression 对方法进行调用的方式与我们之前所做的方法到函数的等价转换是如出一辙的。所以Go 语言中的方法的本质就是一个以方法的 receiver 参数作为第一个参数的普通函数。

而且Method Expression 就是 Go 方法本质的最好体现因为方法自身的类型就是一个普通函数的类型我们甚至可以将它作为右值赋值给一个函数类型的变量比如下面示例

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
}

三、巧解难题

我们来看一段代码

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 方法的本质也就是一个以方法的 receiver 参数作为第一个参数的普通函数对这个程序做个等价变换。这里我们利用 Method Expression 方式等价变换后的源码如下

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 时Method Expression 形式的 print 函数是如何绑定参数的

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

《Go 的 for 循环仅此一种》中我们学习过 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)
}

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)
}

修改后的程序的输出结果是这样的因 Goroutine 调度顺序不同在你的机器上的结果输出顺序可能会有不同

one
two
three
four
five
six
阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: go

“Go 方法介绍,理解“方法”的本质-CSDN博客” 的相关文章