• Go 学习笔记(86)— Go 方法接受者参数类型选择


    Go 方法实质上是以方法的 receiver 参数作为第一个参数的普通函数

    我们直接来看下面例子中的两个 Go 方法,以及它们等价转换后的函数:

    
    func (t T) M1() <=> F1(t T)
    func (t *T) M2() <=> F2(t *T)
    
    • 1
    • 2
    • 3

    这个例子中 M1 方法是 receiver 参数类型为 T 的一类方法的代表,而 M2 方法则代表了 receiver 参数类型为 *T 的另一类。下面我们分别来看看不同的 receiver 参数类型对 M1M2 的影响。

    1. 当 receiver 参数的类型为 T 时

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

    据此我们可以得出结论:当我们的方法 M1 采用类型为 Treceiver 参数时,代表 T 类型实例的 receiver 参数以值传递方式传递到 M1 方法体中的,实际上是 T 类型实例的副本,M1 方法体中对副本的任何修改操作,都不会影响到原 T 类型实例。

    2. 当 receiver 参数的类型为 *T 时

    当我们选择以 *T 作为 receiver 参数类型时,M2 方法等价转换为 F2(t *T)。同上面分析,我们传递给 F2 函数的 tT 类型实例的地址,这样 F2 函数体中对参数 t 做的任何修改,都会反映到原 T 类型实例上。

    据此我们也可以得出结论:当我们的方法 M2 采用类型为 *Treceiver 参数时,代表 *T 类型实例的 receiver 参数以值传递方式传递到 M2 方法体中的,实际上是 T 类型实例的地址,M2 方法体通过该地址可以对原 T 类型实例进行任何修改操作。

    我们再通过一个更直观的例子,证明一下上面这个分析结果,看一下 Go 方法选择不同的 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
        println(t.a) // 0
    
        t.M1()
        println(t.a) // 0
    
        p := &t
        p.M2()
        println(t.a) // 11
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    我们看到,方法 M1 由于使用了 T 作为 receiver 参数类型,它在方法体中修改的仅仅是 T 类型实例 t 的副本,原实例并没有受到影响。因此 M1 调用后,输出 t.a 的值仍为 0。

    而方法 M2 呢,由于使用了 *T 作为 receiver 参数类型,它在方法体中通过 t 修改的是实例本身,因此 M2 调用后,t.a 的值变为了 11,这些输出结果与我们前面的分析是一致的。

    3. 选择 receiver 参数类型的原则

    1. 如果 Go 方法要把对 receiver 参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择 *T 作为 receiver 参数的类型。
    
      type T struct {
          a int
      }
      
      func (t T) M1() {
          t.a = 10
      }
     
     func (t *T) M2() {
         t.a = 11
     }
     
     func main() {
         var t1 T
         println(t1.a) // 0
         t1.M1()
         println(t1.a) // 0
         t1.M2()
         println(t1.a) // 11
     
         var t2 = &T{}
         println(t2.a) // 0
         t2.M1()
         println(t2.a) // 0
         t2.M2()
         println(t2.a) // 11
     }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    我们先来看看类型为 T 的实例 t1。我们看到它不仅可以调用 receiver 参数类型为 T 的方法 M1,它还可以直接调用 receiver 参数类型为 *T 的方法 M2,并且调用完 M2 方法后,t1.a 的值被修改为 11 了。

    其实,T 类型的实例 t1 之所以可以调用 receiver 参数类型为 *T 的方法 M2,都是 Go 编译器在背后自动进行转换的结果。或者说,t1.M2() 这种用法是 Go 提供的“语法糖”:Go 判断 t1 的类型为 T,也就是与方法 M2receiver 参数类型 *T 不一致后,会自动将 t1.M2() 转换为 (&t1).M2()

    同理,类型为 *T 的实例 t2,它不仅可以调用 receiver 参数类型为 *T 的方法 M2,还可以调用 receiver 参数类型为 T 的方法 M1,这同样是因为 Go 编译器在背后做了转换。也就是,Go 判断 t2 的类型为 *T,与方法 M1receiver 参数类型 T 不一致,就会自动将 t2.M1() 转换为(*t2).M1()

    通过这个实例,我们知道了这样一个结论:无论是 T 类型实例,还是 *T 类型实例,都既可以调用 receiverT 类型的方法,也可以调用 receiver*T 类型的方法。这样,我们在为方法选择 receiver 参数的类型的时候,就不需要担心这个方法不能被与 receiver 参数类型不一致的类型实例调用了。

    1. 考虑到 Go 方法调用时,receiver 参数是以值拷贝的形式传入方法中的。那么,如果 receiver 参数类型的 size 较大,以值拷贝形式传入就会导致较大的性能开销,这时我们选择 *T 作为 receiver 类型可能更好些。

    4. 方法集合

    我们先通过一个示例,直观了解一下为什么要有方法集合,它主要用来解决什么问题:

    
    type Interface interface {
        M1()
        M2()
    }
    
    type T struct{}
    
    func (t T) M1()  {}
    func (t *T) M2() {}
    
    func main() {
        var t T
        var pt *T
        var i Interface
    
        i = pt
        i = t // cannot use t (type T) as type Interface in assignment: T does not implement Interface (M2 method has pointer receiver)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    在这个例子中,我们定义了一个接口类型 Interface 以及一个自定义类型 TInterface 接口类型包含了两个方法 M1M2,代码中还定义了基类型为 T 的两个方法 M1M2,但它们的 receiver 参数类型不同,一个为 T,另一个为 *T。在 main 函数中,我们分别将 T 类型实例 t*T 类型实例 pt 赋值给 Interface 类型变量 i

    运行一下这个示例程序,我们在 i = t 这一行会得到 Go 编译器的错误提示:T 没有实现 Interface 类型方法列表中的 M2,因此类型 T 的实例 t 不能赋值给 Interface 变量。

    可是,为什么呀?为什么 *T 类型的 pt 可以被正常赋值给 Interface 类型变量 i,而 T 类型的 t 就不行呢?如果说 T 类型是因为只实现了 M1 方法,未实现 M2 方法而不满足 Interface 类型的要求,那么 *T 类型也只是实现了 M2 方法,并没有实现 M1 方法啊?

    有些事情并不是表面看起来这个样子的。了解方法集合后,这个问题就迎刃而解了。同时,方法集合也是用来判断一个类型是否实现了某接口类型的唯一手段,可以说,“方法集合决定了接口实现”。

    那么,什么是类型的方法集合呢?

    Go 中任何一个类型都有属于自己的方法集合,或者说方法集合是 Go 类型的一个“属性”。但不是所有类型都有自己的方法呀,比如 int 类型就没有。所以,对于没有定义方法的 Go 类型,我们称其拥有空方法集合。

    接口类型相对特殊,它只会列出代表接口的方法列表,不会具体定义某个方法,它的方法集合就是它的方法列表中的所有方法,我们可以一目了然地看到。因此,我们下面重点讲解的是非接口类型的方法集合。

    为了方便查看一个非接口类型的方法集合,我这里提供了一个函数 dumpMethodSet,用于输出一个非接口类型的方法集合:

    
    func dumpMethodSet(i interface{}) {
        dynTyp := reflect.TypeOf(i)
    
        if dynTyp == nil {
            fmt.Printf("there is no dynamic type\n")
            return
        }
    
        n := dynTyp.NumMethod()
        if n == 0 {
            fmt.Printf("%s's method set is empty!\n", dynTyp)
            return
        }
    
        fmt.Printf("%s's method set:\n", dynTyp)
        for j := 0; j < n; j++ {
            fmt.Println("-", dynTyp.Method(j).Name)
        }
        fmt.Printf("\n")
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    下面我们利用这个函数,试着输出一下 Go 原生类型以及自定义类型的方法集合,看下面代码:

    
    type T struct{}
    
    func (T) M1() {}
    func (T) M2() {}
    
    func (*T) M3() {}
    func (*T) M4() {}
    
    func main() {
        var n int
        dumpMethodSet(n)
        dumpMethodSet(&n)
    
        var t T
        dumpMethodSet(t)
        dumpMethodSet(&t)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    运行这段代码,我们得到如下结果:

    
    int's method set is empty!
    *int's method set is empty!
    main.T's method set:
    - M1
    - M2
    
    *main.T's method set:
    - M1
    - M2
    - M3
    - M4
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    我们看到以 int*int 为代表的 Go 原生类型由于没有定义方法,所以它们的方法集合都是空的。自定义类型 T 定义了方法 M1M2,因此它的方法集合包含了 M1M2,也符合我们预期。但 *T 的方法集合中除了预期的 M3M4 之外,居然还包含了类型 T 的方法 M1M2

    这是因为,Go 语言规定,*T 类型的方法集合包含所有以 *Treceiver 参数类型的方法,以及所有以 Treceiver 参数类型的方法。这就是这个示例中为何 *T 类型的方法集合包含四个方法的原因。

    这个时候,你是不是也找到了前面那个示例中为何 i = pt 没有报编译错误的原因了呢?我们同样可以使用 dumpMethodSet 工具函数,输出一下那个例子中 ptt 各自所属类型的方法集合:

    
    type Interface interface {
        M1()
        M2()
    }
    
    type T struct{}
    
    func (t T) M1()  {}
    func (t *T) M2() {}
    
    func main() {
        var t T
        var pt *T
        dumpMethodSet(t)
        dumpMethodSet(pt)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    运行上述代码,我们得到如下结果:

    
    main.T's method set:
    - M1
    
    *main.T's method set:
    - M1
    - M2
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    通过这个输出结果,我们可以一目了然地看到 T*T 各自的方法集合。

    我们看到,T 类型的方法集合中只包含 M1,没有 Interface 类型方法集合中的 M2 方法,这就是 Go 编译器认为变量 t 不能赋值给 Interface 类型变量的原因。

    在输出的结果中,我们还看到 *T 类型的方法集合除了包含它自身定义的 M2 方法外,还包含了 T 类型定义的 M1 方法,*T 的方法集合与 Interface 接口类型的方法集合是一样的,因此 pt 可以被赋值给 Interface 接口类型的变量 i

    到这里,我们已经知道了所谓的方法集合决定接口实现的含义就是:如果某类型 T 的方法集合与某接口类型的方法集合相同,或者类型 T 的方法集合是接口类型 I 方法集合的超集,那么我们就说这个类型 T 实现了接口 I。或者说,方法集合这个概念在 Go 语言中的主要用途,就是用来判断某个类型是否实现了某个接口。

    有了方法集合的概念做铺垫,选择 receiver 参数类型的第三个原则已经呼之欲出了,下面我们就来看看这条原则的具体内容。

    选择 receiver 参数类型的第三个原则

    理解了方法集合后,我们再理解第三个原则的内容就不难了。这个原则的选择依据就是 T 类型是否需要实现某个接口,也就是是否存在将 T 类型的变量赋值给某接口类型变量的情况。

    如果 T 类型需要实现某个接口,那我们就要使用 T 作为 receiver 参数的类型,来满足接口类型方法集合中的所有方法。

    如果 T 不需要实现某一接口,但 *T 需要实现该接口,那么根据方法集合概念,*T 的方法集合是包含 T 的方法集合的,这样我们在确定 Go 方法的 receiver 的类型时,参考原则一和原则二就可以了。

    参考: https://time.geekbang.org/column/article/466221

  • 相关阅读:
    UI设计中的图标的分类,功能性图标
    【uniapp】将微信小程序代码转换为 uniapp 小程序
    【go语言之timer实现】
    网络爬虫的实战项目:使用JavaScript和Axios爬取Reddit视频并进行数据分析
    每日shell脚本之mysql健康查询
    MindSponge分子动力学模拟——计算单点能(2023.08)
    前端程序员的职业规格
    内置属性-top栏切换
    在CARLA中手动开车,添加双目相机stereo camera,激光雷达Lidar
    训练营第二十九天贪心(简单题目)
  • 原文地址:https://blog.csdn.net/wohu1104/article/details/122967602