《100 Go Mistakes and How to Avoid Them》之函数篇

2023-06-06 ⏳2.3分钟(0.9千字)

6.1 不知该用哪种类型的receiver

什么时候使用值类型接收,什么时候使用指针类型接收?

type customer struct {
    balance float64
}
func (c customer) add(v float64) {
    c.balance += v
}
func (c *customer) add(v float64) {
    c.balance += v
}

值类型接收相当于把receiver的值拷贝传递进去,在内部改变,不会影响原值。而指针类型接收相当于把指针的拷贝传递进去,会改变原值。什么时候一定要用 指针类型?

什么时候应该使用 指针类型?

什么时候一定要用 值类型?

什么时候应该使用 值类型?

6.2 不使用已命名的返回参数

已命名的返回参数可以提高我们代码的可读性,有时也能时函数更简短。比如

type locator interface {
    getCoordinates(address string) (float32, float32, error)
}

以上函数返回一个地址的经纬度,你如何确定第一第二个参数哪个是经度哪个是纬度?这种使用就需要用到提前命名。

type locator interface {
    getCoordinates(address string) (lat, lng float32, err error)
}

这样就能通过签名很清晰的使用这个interface。另外一个例子出自Effective Go,描述了io.ReadFull这个函数

func ReadFull(r io.Reader, buf []byte) (n int, err error) {
    for len(buf) > 0 && err == nil {
        var nr int
        nr, err = r.Read(buf)
        n += nr
        buf = buf[nr:]
    }
    return 
}

出参提前命名好,内部实现就会更简短,逻辑更清晰。

6.3 已命名的返回参数的意外边界情况

已命名的返回参数在某些情况下很有用,但如果不仔细有时候会导致一些细小的bug。

func (l loc) getCoordinates(ctx context.Context, address string) ( lat, lng float32, err error) {
    isValid := l.validateAddress(address)
    if !isValid {
        return 0, 0, errors.New("invalid address")
    }
    if ctx.Err() != nil {
        return 0, 0, err
    }
        // Get and return coordinates
}

比如上述例子,err根本没赋值,但因为已命名的返回方式,他就不会报编译错误。我们可以使用局部变量来赋值。

if err := ctx.Err(); err != nil {
    return 0, 0, err
}

6.4 返回了一个为nil的receiver

我们可以先看一个例子:

type MultiError struct {
    errs []string
}
func (m *MultiError) Add(err error) {
    m.errs = append(m.errs, err.Error())
}
func (m *MultiError) Error() string {
    return strings.Join(m.errs, ";")
}

func (c Customer) Validate() error {
    var m *MultiError
    if c.Age < 0 {
        m = &MultiError{}
        m.Add(errors.New("age is negative"))
    }
    if c.Name == "" {
        if m == nil {
            m = &MultiError{}
        }
        m.Add(errors.New("name is nil"))
    }
    return m 
}
customer := Customer{Age: 33, Name: "John"}
if err := customer.Validate(); err != nil {
    log.Fatalf("customer is invalid: %v", err)
}

我们会发现一个很神奇结果触发了err != nil,但是输出却是2021/05/08 13:47:28 customer is invalid: <nil> 其实在Go中,结构体函数只是一种语法糖(receiver作为函数的第一个入参),所以一个空指针作为receiver也是合法的。所以例子中return的结果是一个空指针而不是nil值。我们可以在最后判断下:

func (c Customer) Validate() error {
    var m *MultiError
    if c.Age < 0 {
        // ...
    }
    if c.Name == "" {
        // ... 
    }
    if m != nil {
        return m
    }
    return nil 
}

6.5 使用一个filename作为入参

当我们创建一个需要读文件的函数时,传递一个文件名作为入参并不合适,比如单元测试的时候就很难写。比如我们编写一个读取行数的函数,我们需要覆盖3种case:正常case/空文件/只有空行的文件。那我们就需要创建3个文件在单元测试中。后续也有可能需要读取request?读取socket?。所以比较好的方式是使用io.Reader作为参数。

6.6 忽略defer参数和receiver是如何计算的

func f() error {
    var status string
    defer notify(status)
    defer incrementCounter(status)

    if err := foo(); err != nil {
        status = StatusErrorFoo
        return err
    }
    if err := bar(); err != nil {
        status = StatusErrorBar
        return err
    }
    status = StatusSuccess
    return nil 
}

已上例子有什么问题?其实两个defer传递的status都是空字符串,因为defer的参数计算是立即计算的,而并不是在ruturn的那个时刻。

方案一我们可以改成:

    defer notify(status)
    defer incrementCounter(status)

传递指针,这样计算的是指针的地址,但是这样需要改变函数的签名,有时候不太合适。我们也可以采用另外的方式:使用闭包。

    defer func() {
        notify(status)
        incrementCounter(status)
    }()

另一种比较容易混淆的就是结构体函数的defer调用。

func main() {
    s := Struct{id: "foo"}
    defer s.print()
    s.id = "bar"
}
type Struct struct {
    id string
}

func (s Struct) print() { 
    fmt.Println(s.id)
}

值传递输出的结果是foo,指针传递输出的结果是bar.