《100 Go Mistakes and How to Avoid Them》之数据类型篇

2023-05-26 ⏳3.9分钟(1.6千字)

3.1 创建令人困惑的八进制字面量

八进制字面量很容易会产生误解。文章举了个例子:

sum := 100 + 010
fmt.Println(sum)

第一眼看去结果应该是110,但其实结果是108,因为字面量的int以0开头表示了八进制。更多的是用在一些特定场景,比如在file, err := os.OpenFile("foo", os.O_RDONLY, 0644)中可以代表权限。如果想要区分这种误解,可以用0o644去代替,效果一样,可以看起来更清晰。二进制(0b / 0B),十六进制(0x / 0X)也类似.另外我们也可以使用_去做分隔符增加可读性。比如1亿:1_000_000_000.

3.2 忽略整形溢出

关于int的溢出问题,比较常见,不再赘述。

3.3 不理解浮点数

浮点数储存的都是一个近似值,float32(单精度)和float64(双精度)遵循了IEEE-754标准,分为了3块:

1.标记(sign) 表示正负

2.指数(exponent) float32(8位) float64(11位)

3.尾数(mantissa) float32(23位) float64(52位)

公式为:sign * 2^exponent * mantissa

所以 直接用 ==来比较两个浮点数是不准的.可取的一个方案是类似testify中的InDelta函数比较差值。另外 浮点数中 有几个特殊的值。

1.Positive infinite (math.IsInf) 正无穷

2.Negative infinite (math.IsInf) 负无穷

3.NaN (Not-a-Number) (math.IsNaN) 表示无法表示的结果.

// IsNaN reports whether f is an IEEE 754 “not-a-number” value.
func IsNaN(f float64) (is bool) {
    // IEEE 754 says that only NaNs satisfy f != f.
    // To avoid the floating-point hardware, could use:
    //      x := Float64bits(f);
    //      return uint32(x>>shift)&mask == mask && x != uvinf && x != uvneginf
    return f != f
}

关于如何近可能的保持精度,提供了两个例子

func f1(n int) float64 {
    result := 10_000.
    for i := 0; i < n; i++ {
        result += 1.0001
    }
    return result
}
func f2(n int) float64 {
    result := 0.
    for i := 0; i < n; i++ {
        result += 1.0001
}
    return result + 10_000.
}
n Exact result f1 f2
10 10010.001 10010.099999999993 10010.001
1k 11000.1 11000.099999999293 11000.099999999982
1m 1.0101e+06 1.0100999999761417e+06 1.0100999999766762e+06

精度的丢失是会叠加的,当我们有一系列的加减法时,我们可以把数据级相近的操作放一起。

另一个例子就是比较a × (b + c)a×b+a×c

a := 100000.001
b := 1.0001
c := 1.0002
fmt.Println(a * (b + c))
fmt.Println(a*b + a*c)
200030.00200030004
200030.0020003

关于浮点数的计算我们可以总结三个点:

  1. 比较两个浮点数,我们可以检查他们的差值在一个可控范围内。

  2. 当执行批量加减法时,量级相近的操作分组可以让结果更精确。

  3. 当执行加减乘除时,先乘除在加减可以让结果更精确。

3.4 不理解切片的length以及capacity

关于slice length&capacity如何使用,以及扩容方式。 length表示当前使用的长度,capacity表示备用的长度。如果两者相等再append就会触发扩容。

3.5 无效的切片初始化

make([]Bar, 0, n)提前分配len和cap可以避免内存申请

func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, 0, n)
    for _, foo := range foos {
        bars = append(bars, fooToBar(foo))
    }
    return bars 
}
func convert(foos []Foo) []Bar {
    n := len(foos)
    bars := make([]Bar, n)
    for i, foo := range foos {
        bars[i] = fooToBar(foo)
    }
    return bars 
}

以上两个方式区别是 方法二 会快4%,因为没有重复调用内置函数append,而第一种方式更有可读性。

3.6 不清楚 nil vs empty slices

nil slice 没有任何的内存分配。在 encoding/json 包中有做区分,nil slice 的 marshal结果是null,empty slice的是[].并且两者无法相等。关于什么时机使用:

var s []string不确定最终长度并且slice可以为空时

s = []string(nil)作为语法糖创建一个nil slice

s = []string{}在有初始化元素的时候使用。

s = make([]string, 0)长度已知

3.7 不恰当的判断empty slices

直接判断len == 0 覆盖了nil和empty两种场景

3.8 错误使用切片copy函数

关于copy内置函数常见的错误.

src := []int{0, 1, 2}
var dst []int
copy(dst, src)
fmt.Println(dst)

上面的打印会是[],因为dst的长度为0.正确的方式是dst := make([]int, len(src)). 另外一种常见的写法是dst := append([]int(nil), src...).

3.9 切片append边界问题

s1 := []int{1, 2, 3}
s2 := s1[1:2]
s3 := append(s2, 10)

s3的append会影响s1的第三个值,因为他们底层的data指向的同一块连续内存,可以使用copy函数去创建一个副本再去编辑。

3.10 切片内存泄漏

func consumeMessages() {
    for {
        msg := receiveMessage()
        // Do something with msg
        storeMessageType(getMessageType(msg))
    }
}

func getMessageType(msg []byte) []byte {
    return msg[:5]
}

以上的这个case会出现内存泄漏,msg无法被GC掉因为切片的引用。

有种方式Full slice expressions

func getMessageType(msg []byte) []byte {
    return msg[:5:5]
}

三参数切片,最后一个参数表示limited capacity append的时候如果遇到full capacity就会重新分配,但是并不影响他内存泄漏。所以最好的方式还是copy创建副本。

3.11 无效的Map初始化

初始化的时候指定大小,关于map内部结构,可以查看之前关于map内部实现的文章.

3.12 Map的内存泄露

n := 1_000_000
m := make(map[int][128]byte)
printAlloc()
for i := 0; i < n; i++ {
    m[i] = randBytes()
}
printAlloc()
for i := 0; i < n; i++ {
    delete(m, i)
}
runtime.GC()
printAlloc()
runtime.KeepAlive(m)

结果是 :

0 MB

461 MB

293 MB

map的bucket是无法收缩的,移除map里的元素并不能影响已经存在的bucket。可行的方案一是创建一个新的map,复制所有元素然后覆盖map。另一种方案是改成map[int]*[128]byte,这样就能用一个指针去替代原来的值。不过这样的方式应该会使GC的时候扫描map,影响GC效率。

3.13 错误的值比较

slices & maps 是无法通过==比较的。我们可以看下哪些是可比较的:

  1. Booleans 比较值是否相等

  2. Numerics (int, float, and complex types) 比较值是否相等

  3. Strings 比较内容

  4. Channels 比较是否是同一个make创建的,或者是否都是nil

  5. Interfaces 比较是否有相同的类型和值 或者是否都是nil

  6. Pointers 比较是否都指向同一个值 或者是否都是nil

  7. Structs and arrays 比较组成的简单类型

如果我们想要比较slices & maps 我么可以借助reflect.DeepEqual.该函数回去递归遍历values。

NOTE reflect.DeepEqual has a specific behavior depending on the type we provide. Before using it, read the documentation carefully.

reflect.DeepEqual==慢100倍。最好不要在runtime的时候使用,在测试用例中使用。如果追求性能,我们可以自己实现比较函数,通过遍历比较,也会比reflect.DeepEqual快很多。或者我们也可以看看三方库如何实现go-cmp or testify. 另外我们必须记住一些标准库中的比较函数 比如bytes.Compare