前言

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

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

Go语言的100个错误使用场景(40-47)|字符串&函数&方法的更多相关文章

  1. c语言使用指针实现模拟java/c# string.concat字符串串联方法

    #include <stdio.h> void _strcat(char *, const char *); int main(void) { char source[] ="V ...

  2. 黑马程序员——经典C语言程序设计100例

    1.数字排列 2.奖金分配问题 3.已知条件求解整数 4.输入日期判断第几天 5.输入整数进行排序 6.用*号显示字母C的图案 7.显示特殊图案 8.打印九九口诀 9.输出国际象棋棋盘 10.打印楼梯 ...

  3. C 语言经典100例

    C 语言经典100例 C 语言练习实例1 C 语言练习实例2 C 语言练习实例3 C 语言练习实例4 C 语言练习实例5 C 语言练习实例6 C 语言练习实例7 C 语言练习实例8 C 语言练习实例9 ...

  4. C语言打印100以内的质数

    C语言打印100以内的质数 #include <stdio.h> int main() { int number; int divisor; for( number = 3; number ...

  5. C语言经典100例-ex002

    系列文章<C语言经典100例>持续创作中,欢迎大家的关注和支持. 喜欢的同学记得点赞.转发.收藏哦- 后续C语言经典100例将会以pdf和代码的形式发放到公众号 欢迎关注:计算广告生态 即 ...

  6. C语言经典100例-ex001

    系列文章<C语言经典100例>持续创作中,欢迎大家的关注和支持. 喜欢的同学记得点赞.转发.收藏哦- 后续C语言经典100例将会以pdf和代码的形式发放到公众号 欢迎关注:计算广告生态 即 ...

  7. C语言入门100题,考算法的居多

    入门题,考算法的居多,共同学习! 1. 编程,统计在所输入的50个实数中有多少个正数.多少个负数.多少个零. 2. 编程,计算并输出方程X2+Y2=1989的所有整数解. 3. 编程,输入一个10进制 ...

  8. oc语言常用的字符串函数

    #import <Foundation/Foundation.h> int main(int argc, const char * argv[]) { @autoreleasepool { ...

  9. C语言-字符串函数的实现(一)之strlen

    C语言中的字符串函数有如下这些 获取字符串长度 strlen 长度不受限制的字符串函数 strcpy strcat strcmp 长度受限制的字符串函数 strncpy strncat strncmp ...

  10. SQL2008代理作业出现错误: c001f011维护计划创建失败的解决方法

    SQL2008数据库总会出现从 IClassFactory 为 CLSID 为 {17BCA6E8-A95D-497E-B2F9-AF6AA475916F} 的 COM 组件创建实例失败,原因是出现以 ...

随机推荐

  1. C#排序算法3:插入排序

    插入排序是一种最简单的排序方法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而一个新的.记录数增1的有序表. 原理: ⒈ 从第一个元素开始,该元素可以认为已经被排序 ⒉ 取出下一个元素,在已 ...

  2. Go socket 编程源码解析(上)

    0. socket 介绍 Liunx 中一切皆文件.通过文件描述符和系统调用号可以实现对任何设备的访问.同样的,socket 也是一种文件描述符.通过 socket 可以建立网络传输.对于 TCP 和 ...

  3. linux 对子用户配置java 环境变量

    转载请注明出处: 若服务器安装 jdk 时用的是root 用户,则root 用户登录服务器可以直接获取Java环境. 当切换到其他子用户时,则会发现环境不存在,命令不存在等. 解决方案: 1. 先切换 ...

  4. [转帖]Oracle如何重启mmon/mmnl进程(AWR自动采集)

    https://www.cnblogs.com/jyzhao/p/10119854.html 学习一下 环境:Oracle 11.2.0.4 RAC现象:sysaux空间满导致无法正常生成快照,清理空 ...

  5. [转帖]Python基础之文件处理(二)

    https://www.jianshu.com/p/7dd08066f499 Python基础文件处理 python系列文档都是基于python3 一.字符编码 在python2默认编码是ASCII, ...

  6. [转帖]APIServer dry-run and kubectl diff

    https://kubernetes.io/blog/2019/01/14/apiserver-dry-run-and-kubectl-diff/ Monday, January 14, 2019 A ...

  7. 初识VUE响应式原理

    作者:京东零售 吴静 自从Vue发布以来,就受到了广大开发人员的青睐,提到Vue,我们首先想到的就是Vue的响应式系统,那响应式系统到底是怎么回事呢?接下来我就给大家简单介绍一下Vue中的响应式原理. ...

  8. Vue3中shallowReactive和shallowRef对数据进行非深度监听

    1.Vue3 中 ref 和 reactive 都是深度监听 默认情况下, 无论是通过 ref 还是 reactive 都是深度监听. 深度监听存在的问题: 如果数据量比较大,非常消耗性能. 有些时候 ...

  9. fasthttp 中如何使用`Transfer-Encoding: chunked` 方式的流式内容输出

    作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢! cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 具体的思路是这样:通过 RequestCtx 的 Conn ...

  10. Windows 堆管理机制 [2] Windows 2000 – Windows XP SP1版本

    2.Windows 2000 – Windows XP SP1 2.1 环境准备 环境 环境准备 虚拟机 32位Windows 2000 SP4 调试器 OllyDbg.WinDbg 编译器 VC6. ...