从 Java 到 Go 这半年踩过的一些坑

296 阅读7分钟

我正在参与掘金创作者训练营第5期,点击了解活动详情

笔者因工作变动,从今年2月份开始在工作中使用 Go 语言,记得最早接触 Go 语言大概是在12年左右,那时候看到七牛云在大量使用 Go,自己也尝试去学习 Go 语言,以为能赶上一波技术红利,然而自己只写了几行 hello world 就继续去用 Java 了。

兜兜转转这些年,自己又回到了 Go 上,当时面试的岗位是后端开发,面试官也问了转 Go 的看法,自己当时就吹呗,没问题,语言只是工具,然后第二天就发 offer 了,看来是真缺人!

下面咱们进入正题,笔者从 Java 程序员的角度,总结下在使用 Go 开发过程中遇到的一些问题。

字符串的长度

在 Java 中,我们获取字符串的长度可以直接调用 length() 方法

public class StrSample {
    public static void main(String[] args) {
        String str = "hello,世界";
        System.out.println("len(hello,世界)= " + str.length());
    }
}

输出

len(hello,世界)= 8

在 Go 语言中也提供了一个len() 函数,但是它返回的是字节数

func main() {
   str := "hello,世界"
   fmt.Println("len(hello,世界)=", len(str))
}

输出

len(hello,世界)= 12

len() 是 Go 内置函数,在 builtin 包里面我们可以找到 len() 函数的声明如下

// The len built-in function returns the length of v, according to its type:
// Array: the number of elements in v.
// Pointer to array: the number of elements in *v (even if v is nil).
// Slice, or map: the number of elements in v; if v is nil, len(v) is zero.
// String: the number of bytes in v.
// Channel: the number of elements queued (unread) in the channel buffer;
//          if v is nil, len(v) is zero.
// For some arguments, such as a string literal or a simple array expression, the
// result can be a constant. See the Go language specification's "Length and
// capacity" section for details.
func len(v Type) int

从注释中我们可以了解到内置函数 len 返回参数 v 的长度,而这个 v 的类型可以是 Array、Pointer to array(指向数组的指针)、Slice、Map、String 还有 Channel,其中如果 v 是字符串,则返回 v 的字节数,是字节长度而不是字符长度,Go 语言中的字符串是不可变的字节序列, Go 语言中的 utf8 包提供了 RuneCountInString 函数可以获取字符长度

func main() {
   str := "hello,世界"
   fmt.Println("len(hello,世界)=", utf8.RuneCountInString(str))
}

for range 迭代变量重用

Go 语言中的 for range 循环,迭代变量 u 是重用,而不是重新声明,从下面的输出结果中也可以看出迭代变量 u 的地址内存地址是相同的。

type User struct {
   ID   int
   Name string
   Age  int
}

func main() {

   users := []User{
      {
         ID:   1,
         Name: "tom",
         Age:  20,
      },
      {
         ID:   2,
         Name: "jack",
         Age:  21,
      },
      {
         ID:   3,
         Name: "lily",
         Age:  22,
      },
   }

   for _, u := range users {
      fmt.Printf("%d, %p\n", u.ID, &u)
   }
}

输出

1, 0xc000118000
2, 0xc000118000
3, 0xc000118000

迭代变量重用,在使用延迟函数的时候,如果不注意变量的重用,就会出问题,举个例子

func sendMsg(u User) {
   fmt.Println("send msg to ", u.Name)
}

func main() {

   // 省略部分代码,同上
   var wg sync.WaitGroup
   for _, u := range users {
      wg.Add(1)
      go func() {
         defer wg.Done()
      // do something
         sendMsg(u)
      }()
   }

   // 等待所有 goroutine 执行完
   wg.Wait()
}

上面代码是循环迭代用户,调用 sendMsg 函数给每个用户发送一条消息,可是最终看到的结果是都发送给了最后一个用户。

send msg to  lily
send msg to  lily
send msg to  lily

怎么解决上面问题呢?有两种解决方式

方式一,通过参数的形式将变量传递给匿名函数,将迭代变量u传递给 goroutine 匿名函数。

go func(u User) {
  defer wg.Done()
  sendMsg(u)
}(u)

方式二,使用局部变量拷贝,就是下面看到的这种形式,在 for 循环内部声明一个变量 u,并将迭代变量 u 赋值给它。

u := u
go func() {
  defer wg.Done()
  sendMsg(u)
}()

以上2种解决方式的输出如下,这样就可以把消息发送给所有的用户了。

send msg to  jack
send msg to  lily
send msg to  tom

for range 表达式副本

下面的例子中我们在第1次循环(i = 0)的时候对数组s[1]、s[2] 进行修改,后面第2次、第3次会取出前面修改的值,但结果取出的依然是修改之前的值。

func main() {
   // 数组s
   s := [3]int{1, 1, 1}
   // 数组c
   c := [3]int{}
   for i, v := range s {
      if i == 0 {
         s[1] = 2
         s[2] = 2
      }
      c[i] = v
   }
   fmt.Println("s=", s)
   fmt.Println("c=", c)
}

我们期望的结果

s= [1 2 2]
c= [1 2 2]

实际运行的结果

s= [1 2 2]
c= [1 1 1]

上面例子中的代码,range 后面迭代变量s 是上面数组 s 的副本,也就是说参与迭代的是 range 后面表达式的副本,我们这里可以把 range 后面的迭代变量看成 s',对于数组的复制,复制出来的是一块新的内存空间,也就是说 s' 跟 s 完全不是一块内存空间。第1次循环中修改的是 s 中的s[1]、s[2],参与循环的 s' 根本没有改变。

下面我们做一点改动,就是把数组s前面中括号中数组的大小 3 去掉,将 s 换成一个切片 slice,其他代码不做修改,如下

func main() {
   // 切片s
   s := []int{1, 1, 1}
   // 其他代码不动
}

这样输出的结果就是我们期望的

s= [1 2 2]
c= [1 2 2]

这里换成了切片怎么就可以改遍了呢?range 后面迭代变量s 依然是上面 s 的副本,只是这里的 s 是切片,Go 语言中的切片内部是一个结构体,下面是 go1.16.15 的 runtime 包里 slice.go 中的代码

type slice struct {
   array unsafe.Pointer
   len   int
   cap   int
}

其中 array 是指向切片底层数组某个元素的指针,这个元素是切片的起始元素,如下切片 s1 指向底层数组 s,s1包含 2、3、4 三个元素。

func main() {
   s := [5]int{1, 2, 3, 4, 5}
   s1 := s[1:3]
   fmt.Println(s1)
}

slice.drawio.png

对于切片的复制,实际上复制的是 slice 结构体,复制后 s' 内部结构体中的 array 指针指向的和 s 是同一个底层数组,因此对切片副本的修改也会作用到底层数组上。

for range 修改值

还是之前迭代用户的代码,比如我们这里需要给用户的年龄加1,代码如下

for _, u := range users {
  u.Age += 1
}

for _, u := range users {
  fmt.Printf("%v, %d\n", u.Name, u.Age)
}

输出

tom, 20
jack, 21
lily, 22

用户的年龄并没有改变,我们可以通过索引的形式修改

for i, _ := range users {
  users[i].Age += 1
}

输出

tom, 21
jack, 22
lily, 23

当然也可以修改代码, users 里面存 User 指针,如下

users := []*User{
  {
    ID:   1,
    Name: "tom",
    Age:  20,
  },
  {
    ID:   2,
    Name: "jack",
    Age:  21,
  },
  {
    ID:   3,
    Name: "lily",
    Age:  22,
  },
}

map 初始化

Go 语言中 map 的初始值(零值)为 nil,从零值 map 中查找元素、删除元素、获取元素个数、执行 range 循环,这些都没有问题,但是当我们向零值 map 中放入元素的时候会导致 panic,所以向 map 中放入元素前一定要执行 make 初始化。

func main() {
   var ages map[string]int
   fmt.Println("ages", ages == nil)    // true
   fmt.Println("len(ages)", len(ages)) // 0

   // 向 map 添加元素
   ages["tom"] = 20 // panic
}

以上就是本篇文章的全部内容了,主要列举了使用 Go 语言的一些注意事项。

在这半年里,笔者除了把《Go 程序设计语言》这本书看了两三遍,还对这本书的内容录制了80多讲的视频,之前也发布到b站、公众号、头条里,每个地方都发布一遍,还是挺费时间的,最后决定只发布到 b站了,b站目前近 2w 的播放量,也获得了一些小伙伴的关注和点赞,大家感兴趣的话也可以到 b站搜索 geekymv,跟我一起学习 Go 语言吧。