前语

大家好,这里是白泽。 《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("%.2fn", 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("%.2fn", 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("%.2fn", 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 言语的接口,有动态类型和动态值两个概念,

Go言语的100个过错运用场景(40-47)|字符串&函数&办法

上述过错示例中,即便经过了两个验证,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%,欢迎追更。