Go语言中常见100问题-#42 Not knowing which type of receiver to use

2022-08-15 15:12:29 浏览数 (2)

使用什么类型作为方法的接收者

什么时候我们应该选择值作为方法的接收者,什么时候应该选择指针作为方法的接收者,有时候并不是一件容易的事。下面结合具体的场景进行分析。

本节从性能角度做一个浅层次介绍,在许多情况下,使用指针还是值作为方法的接收者不应该由性能决定。而是下面讨论的其他因素决定的。在讨论开始之前,我们先来理清楚接收器的工作原理。

在Go语言中,可以将一个值或指针附加到一个方法上。对于值接收者,会将它拷贝一份传递给方法,所以方法内部的对值的修改,不会影响到外面原始值。

代码语言:javascript复制
type customer struct {
        balance float64
}

func (c customer) add(v float64) {
        c.balance  = v
}

func main() {
        c := customer{balance: 100.}
        c.add(50.)
        fmt.Printf("balance: %.2fn", c.balance)
}

上面是值接收者实例,c.balance = v不会影响原始c的值,所以程序的输出还是100

代码语言:javascript复制
100.00

上面的程序可以理解成下面的调用方式

代码语言:javascript复制
func add(c customer, v float64){
    c.balance  = v
}

与值接收者对应的是指针接收者,程序会将对象的指针传递给方法。本质上来看,它也是一种拷贝,只不过是地址的拷贝,而不是拷贝对象。在方法内的修改会影响到原始值,实例如下:

代码语言:javascript复制
type customer struct {
        balance float64
}

func (c *customer) add(operation float64) {
        c.balance  = operation
}

func main() {
        c := customer{balance: 100.0}
        c.add(50.0)
        fmt.Printf("balance: %.2fn", c.balance)
}

上述程序运行结果如下:

代码语言:javascript复制
150.00

是选择值还是指针作为方法的接收者并不是一件容易的事,下面讨论在各种场景下应该选择哪种类型。

  • 接收者必须是指针:
    • 当方法需要改变接收者的时候。如果接收者是slice,并且需要向slice中添加元素,必须选择指针作为接收者。
  • 当接收者包含不能拷贝的字段时,例如,对于sync包中的字段,像sync.Mutex,只能选择指针作为接收者。
  • 接收者应该是指针:如果接收者是一个大对象,使用指针相比值有更高的效率。如何评估一个对象是大对象呢?很难根据占用的大小进行判断,因为他取决于很多因素,解决办法是通过benchmark测试。
  • 接收者必须是值:
    • 不希望函数修改接收者情况
    • 当接收者的类型是map/funciton/channel时候,否则编译器会报错
  • 接收者应该是值:
    • 接收者的类型是一个slice,但不应该被修改
    • 当接收者是小的数组或者struct对象。并且不包含可以修改的字段,例如time.Time
    • 当接收者是基本类型,像 int,float64或者string等

下面的customer结构体中包含一个指针字段,对于这种情况,如何选择呢?

代码语言:javascript复制
type customer struct {
        data *data
}

type data struct {
        balance float64
}

func (c customer) add(operation float64) {
        c.data.balance  = operation
}

func main() {
        c := customer{data: &data{
                balance: 100,
        }}
        c.add(50.)
        fmt.Printf("balance: %.2fn", c.data.balance)
}

上述程序的输出结果为:

代码语言:javascript复制
150.00

尽管使用的是值对象,调用add方法之后,balance的值还是被修改了。在这种情况下,使用值对象就可以了,并不是只能使用指针对象才能修改balance的值。然而为了程序更清晰,大家可能倾向使用指针接收器来突出强调customer是可以修改的。

可以混合使用接收者类型吗?例如一个对象有多个方法,某些方法使用值作为接收者,另一些方法使用指针作为接收者。虽然倾向于避免这种情况出现,但实际是可以这么使用的。在标准库中,time.Time就是这样一个例子。设计者希望After/IsZero/UTC方法不要修改time.Time的值,所以采用的是值接收者。而方法UnmarshalBinary需要修改time.Time的值,所以采用指针作为接收者。

「一般情况下应该避免混合使用接收器类型,但不是必须的」

代码语言:javascript复制
func (t Time) After(u Time) bool {
 if t.wall&u.wall&hasMonotonic != 0 {
  return t.ext > u.ext
 }
 ts := t.sec()
 us := u.sec()
 return ts > us || ts == us && t.nsec() > u.nsec()
}

func (t Time) IsZero() bool {
 return t.sec() == 0 && t.nsec() == 0
}
代码语言:javascript复制
func (t *Time) UnmarshalBinary(data []byte) error {
 buf := data
 if len(buf) == 0 {
  return errors.New("Time.UnmarshalBinary: no data")
 }

 if buf[0] != timeBinaryVersion {
  return errors.New("Time.UnmarshalBinary: unsupported version")
 }

 if len(buf) != /*version*/ 1  /*sec*/ 8  /*nsec*/ 4  /*zone offset*/ 2 {
  return errors.New("Time.UnmarshalBinary: invalid length")
 }

 buf = buf[1:]
 sec := int64(buf[7]) | int64(buf[6])<<8 | int64(buf[5])<<16 | int64(buf[4])<<24 |
  int64(buf[3])<<32 | int64(buf[2])<<40 | int64(buf[1])<<48 | int64(buf[0])<<56

 buf = buf[8:]
 nsec := int32(buf[3]) | int32(buf[2])<<8 | int32(buf[1])<<16 | int32(buf[0])<<24

 buf = buf[4:]
 offset := int(int16(buf[1])|int16(buf[0])<<8) * 60

 *t = Time{}
 t.wall = uint64(nsec)
 t.ext = sec

 if offset == -1*60 {
  t.setLoc(&utcLoc)
 } else if _, localoff, _, _, _ := Local.lookup(t.unixSec()); offset == localoff {
  t.setLoc(Local)
 } else {
  t.setLoc(FixedZone("", offset))
 }

 return nil
}

经过上面的分析讨论,我们理清楚了什么情况下应该使用值类型什么情况下使用指针类型作为方法的接收者。有些边缘的场景上面没有分析到,有一个一般性方法,默认情况下,选择值类型作为接收者,除非有理由不能这样做,这时候我们再使用指针类型作为接收者。

0 人点赞