go语言的一些陷阱和技巧

253 阅读5分钟

持续更新...

for下标循环和range

对于slice,使用for-range循环有两种形式

s := []{1, 2, 3, 4, 5}

// 第一种方法,手动使用下标访问
for idx := 0; i < len(s); i++ {
    // 使用下标访问切片数组
}

// 第二种方法,只迭代了下标
for idx := range s {
    //循环只使用了下标
}

// 第三种,同时迭代了下标和元素
// val是拷贝的形式
for idx, val := range s{
}

前两种方式几乎没有区别,需要注意的是:for-range迭代出的值是通过值拷贝的形式,对它进行修改无法改变原切片,而通过下标访问则是引用,可以正常修改切片的元素

对于map来说也是类似的。

m := map[int]string{1: "a", 2: "b", 3: "c"}

for k, v := range m {
    fmt.Println(k, v)
}

// 无效修改
for _, v := range m {
    v = v + v
}

for k, v := range m {
    fmt.Println(k, v)
}

// 通过键的方式才可以修改
for k := range m {
    m[k] = m[k] + m[k]
}

for k, v := range m {
    fmt.Println(k, v)
}

同时这也启示我们若是对于复杂的数据结构,尽量采用下标访问,避免拷贝产生的开销,或者直接在slice/map里存储指针

init函数的生效时机

我们常用init函数来初始化一些配置,它会在main函数之前执行。但实际上如果有全局变量,实际的执行顺序是:全局变量->init函数->main函数

package main

import "fmt"

var beforeMain = func() int {
    fmt.Println("before main")
    return 0
}()

func init() {
    fmt.Println("init")
}

func main() {
    fmt.Println("main")
}

//输出:
before main
init
main

函数选项模式

例如对于一个学生的结构体,我们希望给它传值来生成一个实例,我们会定义以下函数:

// 定义结构体
type Student struct {
    Name   string
    Age    int
    ID     uint64
}

// 根据传递的参数返回一个实例
func NewStudent(name string, age int, id uint64) *Student {
    return &Student{
       Name:   name,
       Age:    age,
       ID:     id,
    }
}

这种方式简单直观,但也有明显的缺点:

  1. 代码耦合度高,如果我们希望给该结构体添加字段,所有用到该函数的地方都需要修改
  2. 灵活度低,不能只给部分参数设值,无法实现默认参数

通过闭包,我们可以很方便的实现动态传递参数和参数默认值、参数校验等功能。

package main

import "fmt"

type Student struct {
    Name string
    Age  int
    ID   uint64
}

type OptionFunc func(*Student)

// 闭包,返回实际赋值结构体的函数
func WithName(name string) OptionFunc {
    return func(s *Student) { s.Name = name }
}

func WithAge(age int) OptionFunc {
    return func(s *Student) { s.Age = age }
}

func WithID(id uint64) OptionFunc {
    return func(s *Student) { s.ID = id }
}

func NewStudent(name string, opts ...OptionFunc) *Student {
    // 可以在这里实现默认参数和特定的参数传递/参数校验等
    s := &Student{Name: name}
    // 遍历传入的闭包函数
    for _, opt := range opts {
       opt(s)
    }
    return s
}

func main() {
    s := NewStudent("张三", WithAge(18), WithID(12345))
    fmt.Println(s)
}

gc压舱物

go语言是自带gc的语言,后台会有goroutine定时清理用不上的内存。那么何时清理呢?其中一个时机就是每次堆内存翻倍的时候。在后端程序中,我们可以在主函数中在堆上分配一个较大的变量来增大程序的初始变量,避免后台goroutine过多的进行gc,并且只要我们不在代码中使用/访问该变量导致触发缺页中断去申请实际的物理内存,就不会占用太多的物理空间

注意slice截断的行为

我们知道slcie底层包括:保存数据的指针、len和cap。当我们使用赋值或者截断新建一个slice的时候,实际内部会共用一个数组。此时对一个slice进行append直到超过它的cap就会导致底层分配内存分离两个slice

package main

import "fmt"

func log(s []int) {
	fmt.Printf("len = %v, cap = %v \n", len(s), cap(s))
}

func main() {
	s1 := make([]int, 2, 3)
	s1[0] = 1
	s1[1] = 2
	log(s1)

	s2 := s1[1:3]
	fmt.Println("s1 is s2: ", &s1[1] == &s2[0])  //true

	s2 = append(s2, 5)
	fmt.Println("s1 is s2 ", &s1[1] == &s2[0])   //false
}

struct{}的作用

  1. 定义仅用来通知的chan 如果一个通道仅仅用来通知,而不在意具体的消息,可以使用空结构体类型的通道。通道内部实际对消息是有拷贝的,如果是空结构体,可以避免拷贝
	fmt.Println(unsafe.Sizeof(struct{}{})) // 0
	ch := make(chan struct{})
	ch <- struct{}{}
  1. 定义set go语言并没有内置set,使用map[string]struct{}实际只保存了键,节省了内存。
type set map[string]struct{}

func(s set) has(key string) bool {
	_, ok := s[key]
	return ok
}

func(s set) insert(key string) {
	s[key] = struct{}{}
}

func(s set) delete(key string) {
	delete(s, key)
}	
  1. 内嵌noCopy空结构体,避免类型被复制 go语言并没有禁止拷贝的方式,但通过noCopy内嵌,可以使用go vet帮我们检查
type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

自定义类型的输出方式

我们可以为某个类型定义String()方法,当我们使用fmt.Print、fmt.Println和使用%v输出的时候都会自动调用该方法。

type Student struct {
	age  int
	name string
}

func (s Student) String() string {
	return fmt.Sprintf("name = %s, age = %d", s.name, s.age)
}

func main() {
	s := Student{name: "rsf", age: 12}
	fmt.Printf("%v", s)
}

对于%v格式输出符号,也可以更加精细的控制:

type Student struct {
	age  int
	name string
}

func main() {
	s := Student{name: "rsf", age: 12}
	fmt.Printf("%v %+v %#v", s, s, s)
}

image.png