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)

错误示例:

go 复制代码
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)
}

正确示例:

go 复制代码
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字节,不恰当的子字符串截取函数,会导致内存泄漏。

示例代码:

go 复制代码
// 方式一
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)

值接受者:

go 复制代码
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
}

指针接受者:

go 复制代码
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。

特殊情况:

go 复制代码
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)

如果使用命名返回值:

go 复制代码
func f(a int) (b int) {
    b = a
    return
}

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

go 复制代码
// 场景一
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,有时可以只为了函数签名清晰而使用命名返回值。

错误场景:

go 复制代码
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。

修正方案:

go 复制代码
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。

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

错误示例:

go 复制代码
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 语言的接口,有动态类型和动态值两个概念,

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

正确方案:

go 复制代码
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)

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

错误示例:

go 复制代码
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. 这个函数将无法被复用,因为它依赖于一个具体的文件名,如果是从其他输入源读取将需要重新编写函数。

🌟 修正方案:

go 复制代码
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 声明的函数的参数值,在声明时确定:
go 复制代码
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:

go 复制代码
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 声明一个闭包,则闭包内使用的外部变量的值,将在闭包执行的时候确定。

go 复制代码
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:

go 复制代码
func f() error {
    var status string
    defer func() {
        notify(status)
        incrementCounter(status)
    }()
}

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

  • 指针和值接收者:

值接收者:

go 复制代码
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 已经固定。

指针接收者:

go 复制代码
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%,欢迎追更。

相关推荐
ningqw4 小时前
SpringBoot 常用跨域处理方案
java·后端·springboot
你的人类朋友4 小时前
vi编辑器命令常用操作整理(持续更新)
后端
胡gh4 小时前
简单又复杂,难道只能说一个有箭头一个没箭头?这种问题该怎么回答?
javascript·后端·面试
一只叫煤球的猫5 小时前
看到同事设计的表结构我人麻了!聊聊怎么更好去设计数据库表
后端·mysql·面试
uzong5 小时前
技术人如何对客做好沟通(上篇)
后端
颜如玉6 小时前
Redis scan高位进位加法机制浅析
redis·后端·开源
ReedFoley6 小时前
【笔记】动手学Ollama 第七章 应用案例1 搭建本地AI Copilot编程助手
人工智能·笔记·copilot
Moment6 小时前
毕业一年了,分享一下我的四个开源项目!😊😊😊
前端·后端·开源
why技术7 小时前
在我眼里,这就是天才般的算法!
后端·面试
绝无仅有7 小时前
Jenkins+docker 微服务实现自动化部署安装和部署过程
后端·面试·github