• Go语言的100个错误使用场景(40-47)|字符串&函数&方法


    前言

    大家好,这里是白泽。《Go语言的100个错误以及如何避免》是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“Go: Simple to learn but hard to master”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。

    我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第五篇文章,对应书中第40-47个错误场景。

    🌟 当然,如果您是一位 Go 学习的新手,您可以在我开源的学习仓库中,找到针对《Go 程序设计语言》英文书籍的配套笔记,其他所有文章也会整理收集在其中。

    📺 B站:白泽talk,公众号【白泽talk】,聊天交流群:622383022,原书电子版可以加群获取。

    前文链接:

    5. 字符串

    🌟 章节概述:

    • 了解 rune 的概念
    • 避免常见的字符串遍历和截取造成的错误
    • 避免由于字符串拼接和转换造成的低效代码
    • 避免获取子字符串造成的内存泄漏

    5.5 无用的字符串转换(#40)

    错误示例:

    func getBytes(reader io.Reader) ([]byte, error) {
        b, err := io.ReadAll(reader)
        if err != nil {
            return nil, err
        }
        // 去除首尾空格
        return []byte(sanitize(string(b))), nil
    }
    
    func sanitize(s string) string {
        return strings.TrimSpace(s)
    }
    

    正确示例:

    func getBytes(reader io.Reader) ([]byte, error) {
        b, err := io.ReadAll(reader)
        if err != nil {
            return nil, err
        }
        // 去除首尾空格
        return sanitize(b), nil
    }
    
    func sanitize(b []byte) []byte {
        return bytes.TrimSpace(b)
    }
    

    通常来说 bytes 库提供了与 strings 库相同功能的方法,而且大多数 IO 相关的函数的输入输出都是 []byte,而不是 string,错误示例中,将字符切片转换成字符串,再转换成字符切片,需要额外承担两次内存分配的开销。

    5.6 获取子字符串操作和内存泄漏(#41)

    假设有许多个 string 类型的 log 需要存储(假设一个log有1000字节),但是只需要存放 log 的前36字节,不恰当的子字符串截取函数,会导致内存泄漏。

    示例代码:

    // 方式一
    func (s store) handleLog(log string) error {
        if len(log) < 36 {
            return errors.New("log is not correctly formatted")
        }
        uuid := log[:36]
        s.store(uuid)
        // Do something
    }
    // 方式二
    func (s store) handleLog(log string) error {
        if len(log) < 36 {
            return errors.New("log is not correctly formatted")
        }
        uuid := string([]byte(log[:36]))
        s.store(uuid)
        // Do something
    }
    // 方式三
    func (s store) handleLog(log string) error {
        if len(log) < 36 {
            return errors.New("log is not correctly formatted")
        }
        uuid := strings.Clone(log[:36])
        s.store(uuid)
        // Do something
    }
    
    1. 和(#26)提到的子切片获取造成的内存泄漏一样,获取子字符串操作执行后,其底层依旧依赖原来的整个字符数组,因此1000个字节内存依旧占用,不会只有36个。
    2. 通过将字符串转换为字节数组,再转换为字符串,虽然消耗了2次长度为36字节的内存分配,但是释放了底层1000字节的原字节数组的依赖。有些 IDE 如 Goland 会提示语法错误,因为本质来说,将 string 转 []byte 再转 string 是一个累赘的操作。
    3. go1.18之后,提供了一步到位的 strings.Clone 方法,可以避免内存泄漏。

    6. 函数和方法

    🌟 章节概述:

    • 什么时候使用值或者指针类型的接受者
    • 什么时候命名的返回值,以及其副作用
    • 避免返回 nil 接受者时的常见错误
    • 函数接受一个文件名,并不是最佳实践
    • 处理 defer 的参数

    6.1 不知道选择哪种类型的方法接受者(#42)

    值接受者:

    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("%.2f\n", c.balance) // 结果为 100.00
    }
    

    指针接受者:

    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("%.2f\n", c.balance) // 结果为 150.00
    }
    

    值接受者在方法内修改自身结构的值,不会对调用方造成实际影响。

    🌟 一些实践的建议:

    • 必须使用指针接受者的场景:
      • 如果方法需要修改原始的接受者。
      • 如果方法的接受者包含不可以被拷贝的字段。
    • 建议使用指针接受者的场景:
      • 如果接受者是一个巨大的对象,使用指针接受者可以更加高效,避免了拷贝内存。
    • 必须使用值接受者的场景:
      • 如果我们必须确保接受者是不变的。
      • 如果接受者是一个 map, function, channel,否则会出现编译错误。
    • 建议使用值接受者的场景:
      • 如果接受者是一个切片,且不会被修改。
      • 如果接受者是一个小的数组或者结构体,不含有易变的字段。
      • 如果接受者是基本类型如:int, float64, string。

    特殊情况:

    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.0
        }}
        c.add(50.0)
        fmt.Printf("%.2f\n", c.data.balance) // 150.00
    }
    

    在这种情况下,即使方法接受者 c 不是指针类型,但是修改依旧可以生效。

    但是为了清楚起见,通常还是将 c 声明成指针类型,如果它是可操作的。

    6.2 从来不使用命名的返回值(#43)

    如果使用命名返回值:

    func f(a int) (b int) {
        b = a
        return
    }
    

    推荐使用命名返回值的场景举例:

    // 场景一
    type locator interface {
        getCoordinates(address string) (lat, lng float32, err error)
    }
    // 场景二
    func ReadFull(r io.Reader, buf []byte) (n int, err error) {
        // 两个返回值被初始化为对应类型的零值:0和nil
        for len(buf) > 0 && err == nil {
            var nr int
            nr, err = r.Read(buf)
            n += nr
            buf = buf[nr:]
        }
        return
    }
    

    场景一:通过命名返回值提高接口的可读性

    场景二:通过命名返回值节省编码量

    🌟 最佳实践:需要权衡使用命名返回值是否能带来收益,如果可以就果断使用吧!

    6.3 使用命名返回值造成的意外副作用(#44)

    🌟 注意:使用命名返回值的方法,并不意味着必须返回单个 return,有时可以只为了函数签名清晰而使用命名返回值。

    错误场景:

    func (l loc) getCoordinates(ctx content.Content, 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
        }
        // Do something and return
    }
    

    此时,由于 ctx.Err() != nil 成立时,并没有为 err 赋值,因此返回的 err 永远都是 nil。

    修正方案:

    func (l loc) getCoordinates(ctx content.Content, address string) (lat, lng float32, err error) {
        isValid := l.validateAddress(address)
        if !isValid {
            return 0, 0, errors.New("invalid address")
        }
        if err = ctx.Err(); err != nil {
            // 这里原则上可以返回单个return,但是最好保持风格统一
            return 0, 0, err
        }
        // Do something and return
    }
    

    6.4 返回一个 nil 接受者(#45)

    🔔 提示:在 Go 语言当中,方法就像是函数的语法糖一样,相当于函数的第一个参数是方法的接受者,nil 可以作为参数,因此 nil 接受者可以触发方法,因此不同于纯粹的 nil interface。

    type Foo struct {}
    
    func (foo *Foo) Bar() string {
        return "bar"
    }
    
    func main() {
    	var foo *Foo
        fmt.Println(foo.Bar()) // 虽然 foo 动态值是 nil,但动态类型不是nil,是可以打印出 bar
    }
    

    错误示例:

    type MultiError struct {
        errs []string
    }
    
    func (m *MultiError) Add(err error) {
        m.errs = append(m.errs, err.Error())
    }
    
    func (m *MultiError) Error() string {
        return stirngs.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("age is nil"))
        }
        return m
    }
    
    func main() {
        // 传入的两个参数都不会触发 Validate 的 err 校验
        customer := Customer{Age: 33, Name: "John"}
        if err := customer.Validate(); err != nil {
            // 但是无论如何都会打印这行语句,err != nil 永远成立!
            log.Fatalf("customer is invalid: %v", err)
        }
    }
    

    🔔 提示:Go 语言的接口,有动态类型和动态值两个概念,

    image-20240214163507390

    上述错误示例中,即使通过了两个验证,Validate 返回了 m,此时这个接口承载的动态类型是 *MultiError,它的动态值是 nil,但是通过 == 判断一个 err 为 nil,或者说一个接口为 nil,要求其底层类型和值都是 nil 才会成立。

    正确方案:

    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("age is nil"))
        }
        if m != nil {
        	return m
        }
        return nil
    }
    

    此时返回的是一个 nil interface,是存粹的。而不是一个非 nil 动态类型的 interfere 返回值。

    6.5 使用文件名作为函数的输入(#46)

    编写一个从文件中按行读取内容的函数。

    错误示例:

    func countEmptyLinesInFile(filename string) (int, error) {
        file, err := os.Open(filename)
        if err != nil {
            return 0, err
        }
        
        scanner := bufio.NewScanner(file)
        for scanner.Scan() {
            // ...
        }
    }
    

    弊端:

    1. 每当需要做不同功能的单元测试,需要单独创建一个文件。
    2. 这个函数将无法被复用,因为它依赖于一个具体的文件名,如果是从其他输入源读取将需要重新编写函数。

    🌟 修正方案:

    func countEmptyLines(reader io.Reader) (int, error) {
        scanner := bufio.NewScanner(reader)
        for scanner.Scan() {
            // ...
        }
    }
    
    func TestCountEmptyLines(t *testing.T) {
        emptyLines, err := countEmptyLines(strings.NewReader(
        `foo
        	bar
        	baz
        	`))
        // 测试逻辑
    }
    

    通过这种方式,可以将输入源进行抽象,从而满足来自任何输入的读取(文件,字符串,HTTP Request,gRPC Request等),编写单元测试也十分便利。

    6.6 不理解 defer 参数和接收者是如何确定的(#47)

    • defer 声明的函数的参数值,在声明时确定:
    const (
    	StatusSuccess = "success"
        StatusErrorFoo = "error_foo"
        StatusErrorBar = "error_bar"
    )
    
    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
    }
    

    🌟 上述示例中,无论是否会在 foobar 函数的调用后返回 errstatus 的值传递给 notifyincrementCount 函数的都是空字符串,因为 defer 声明的函数的参数值,在声明时确定。

    修正方案1:

    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
    }
    

    因为地址一开始确定,所以无论后续如何为 status 赋值,都可以通过地址获取到最新的值。这种方式的缺点是需要修改 notify 和 incrementCounter 两个函数的传参形式。

    🌟 defer 声明一个闭包,则闭包内使用的外部变量的值,将在闭包执行的时候确定。

    func main() {
        i := 0
        j := 0
        defer func(i int) {
            fmt.Println(i, j)
        }(i)
        i++
        j++
    }
    

    因为 i 作为匿名函数的参数传入,因此值在一开始确定,而 j 是闭包内使用外部的变量,因此在 return 之前确定值。最后打印结果 i = 0, j = 1。

    修正方案2:

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

    通过使用闭包将 notify 和 incrementCounter 函数包裹,则 status 的值使用闭包外侧的变量 status,因此 status 的值会在闭包执行的时候确定,这种修改方式也无需修改两个函数的签名,更为推荐。

    • 指针和值接收者:

    值接收者:

    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,因为 defer 后声明的 s.print() 的接收者 s 将在一开始获得一个拷贝,foo 作为 id 已经固定。

    指针接收者:

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

    打印结果是 bar,defer 后声明的 s.print() 的接收者 s 将在一开始获得一份拷贝,因为是地址的拷贝,所以对 return 之前的改动有感知。

    小结

    已完成《Go语言的100个错误》全书学习进度47%,欢迎追更。

  • 相关阅读:
    常用建模工具
    Linux系统:进程控制
    canvas画图时的bug记录
    Java基础数组Arrays工具类
    快捷键整理
    【100天精通Python】Day61:Python 数据分析_Pandas可视化功能:绘制饼图,箱线图,散点图,散点图矩阵,热力图,面积图等(示例+代码)
    【软件设计师21天-考点整理】6)计算机系统构成及硬件基础知识
    数据结构 图 并查集 遍历方法 最短路径算法 最小生成树算法 简易代码实现
    【UBOOT】1-使用与烧写
    【零基础入门MyBatis系列】第十三篇——缓存机制
  • 原文地址:https://www.cnblogs.com/YLTFY1998/p/18016093