Golang 开发中常犯的错误和语言特性陷阱

1. 不允许出现未被使用的变量

Go对代码整洁度有严格要求,如果在函数内部声明了局部变量但未使用,编译器会直接报错 (declared and not used)。全局变量未使用则不会报错。
代码

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func main() {
var a int = 10 // 报错:a declared but not used
// 解决方法 1:使用它
// fmt.Println(a)

// 解决方法 2:用空白标识符 _ 忽略(通常用于占位或忽略返回值)
_ = a
}

2. 不允许出现未被使用的 import

引入了未使用的包也会导致编译失败。但有时我们只想执行某个包内部的 init() 初始化函数(例如注册数据库驱动),而不需要直接调用该包的 API,这时可以使用下划线 _ 进行匿名导入。
代码

1
2
3
4
5
6
7
8
9
10
package main

import (
"fmt"
_ "net/http/pprof" // 仅执行该包的 init() 函数,不直接使用其内部变量/函数
)

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

3. 变量声明与零值

Go 中的变量声明后如果不赋值,会自动赋予“零值”。数值类型为 0,布尔类型为 false,字符串为 "",指针、切片、Map、Channel 和接口类型为 nil
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func main() {
var name string = "这里是名称"
var arr [10]int
var slice []string
var mp map[string]int

fmt.Println(name) // 这里是名称
fmt.Println(arr) //[0 0 0 0 0 0 0 0 0 0]

if slice == nil {
fmt.Println(slice) //[]
}
if mp == nil {
fmt.Println(mp) // map[]
}
}

4. 多行的 Slice 声明

Go 编译器在处理换行时会自动在语句末尾插入分号。所以当 Slice 或 Array、Map 多行初始化时,最后一个元素后必须加逗号,或者直接将右大括号放在元素同一行。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
list :=[]int{1, 2} // 单行,结尾不需要逗号

list1 :=[]int{
1,
2} // 多行,右大括号紧跟最后一个值,结尾可不需要逗号

list2 :=[]int{
1,
2, // 多行,右大括号另起一行,最后一个值后面必须添加逗号
}

fmt.Println(list, list1, list2)
}

5. 不需要的方法返回值可以通过下划线接收

Go 支持多返回值。如果只要部分返回值,必须用空白标识符 _ 来忽略不想要的返回值,不然会触发“变量已声明未使用”的编译错误。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func get() (res int, err error) {
fmt.Println("call get")
return 1, nil
}

func main() {
// 只需要 err,不需要 res 的情况
_, err := get()
fmt.Println(err)
}

6. :== 的区别

:= 是短变量声明符号,只能在函数内部使用,用于声明并初始化新变量。= 是赋值符号,用于更新已经声明过的变量。不能对已经声明的变量再次使用 :=(除非在多变量赋值中至少有一个是新变量)。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

// globalVar := 10 // 错误!全局变量只能用 var 声明
var globalVar = 10

func main() {
a := 1 // 声明新变量 a
a = 2 // 改变已存在的变量 a,使用 =
// a := 3 // 错误!a 已经声明过

b, a := 3, 4 // 正确!因为 b 是新变量,a 退化为赋值操作
fmt.Println(globalVar, a, b)
}

7. 作用域问题

Go 使用词法作用域(块作用域)。内层大括号可以直接访问外层变量;如果在内层大括号里使用 := 重新声明了同名变量,会发生变量遮蔽,此时内层操作不会影响外层。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

func main() {
x := 1
fmt.Println(x) // prints 1
{
// 大括号圈定了代码块中声明的变量的作用域
fmt.Println(x) // prints 1 (访问外层)
x := 2 // 遮蔽:声明了一个全新的局部变量 x,不会影响到外部 x
fmt.Println(x) // prints 2
}
fmt.Println(x) // prints 1 (外层 x 未被改变)
}

8. Nil 不能赋给未指定类型的变量

nil 本身没有默认类型,必须有一个具体的上下文(如指针、接口、切片)。如果你直接声明 var x = nil 或者 x := nil 编译器将无法推断类型。
代码

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
// x := nil // 编译错误:use of untyped nil

// 正确做法:显式指明为可以接收 nil 的类型,如 interface{}
var x interface{} = nil
fmt.Println(x)
}

9. 类型转换

Go 是强类型语言,不存在隐式类型转换,哪怕是 intint64 之间也需要显式转换。字符串与数字互转必须借助 strconv 包。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
"fmt"
"strconv"
)

func main() {
// 1. 数字类型互转
var num1 int = 100
fmt.Println(int64(num1)) // 100
var num2 int64 = 100
fmt.Println(int(num2)) // 100

// 2. 字符串与数字互转
var num3 int = 100
fmt.Println(strconv.Itoa(num3) + "abc") // 100abc

var str1 string = "100"
val1, err1 := strconv.Atoi(str1)
fmt.Println(val1, err1) // 100 <nil>

var num4 int64 = 1010
fmt.Println(strconv.FormatInt(num4, 10)) // 1010

var str2 string = "1010"
val2, err2 := strconv.ParseInt(str2, 10, 64)
fmt.Println(val2, err2) // 1010 <nil>

// 3. 字符串与 []byte /[]rune 互转
var str3 string = "今天天气很好"
fmt.Println([]byte(str3)) // 输出底层 UTF-8 字节数组

var bytes1 =[]byte{228, 187, 138, 229, 164, 169, 229, 164, 169, 230, 176, 148, 229, 190, 136, 229, 165, 189}
fmt.Println(string(bytes1)) // 今天天气很好

fmt.Println([]rune(str3)) // rune 是 int32 的别名,存储 Unicode 码点
var runeList =[]rune{20170, 22825, 22825, 27668, 24456, 22909}
fmt.Println(string(runeList[3])) // 气
fmt.Println(string(runeList)) // 今天天气很好

// 4. 接口类型转具体类型(类型断言)
var inf interface{} = 100
i, ok := inf.(int)
fmt.Println(i, ok) // 100, true
}

10. 包的错误使用

在 Go 语言中,一个文件夹(目录)对应一个 package。同一个文件夹下的所有 .go 文件必须声明相同的包名。如果存在子文件夹,则属于完全独立的不同包,外部需要重新 import 子目录路径。
代码(目录结构)

1
2
3
4
5
myproject/
├── main.go (package main)
└── utils/
├── str.go (package utils)
└── math.go (package utils)

11. 字符串转译与校验

Go 里的字符串底层是不可变的 UTF-8 字节序列。如果强行注入非法的十六进制字节(例如 \xfc),会导致其变成无效的 UTF-8 字符串,可以通过 utf8.ValidString 进行校验。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"unicode/utf8"
)

func main() {
str1 := "ABC"
fmt.Println(utf8.ValidString(str1)) // true

// \xfc 不是一个有效的 UTF-8 编码字节
str2 := "A\xfcC"
fmt.Println(utf8.ValidString(str2)) // false
}

12, 13, 14. new 与 make 的区别及 Slice 切片操作

  • new(T):为任意类型分配内存,返回该类型的指针 *T。内存全被置为零值,因此指针本身不为 nil,但里面的结构可能无法直接使用(如 map / slice 需要进一步初始化)。
  • make(T, args...):专门用于创建切片(slice)、字典(map)、通道(chan)三种引用类型,并返回类型本身(非指针)。分配的不仅仅是内存,还包括内部数据结构的初始化。
    代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

func main() {
// new 返回地址,值已被清零但如果是 slice,仍然是个未被分配底层的 slice(nil)
var i *int = new(int)
fmt.Println(*i) // 0

var listPtr = new([]int)
fmt.Println(listPtr) // &[] (地址)
fmt.Println(*listPtr) //[] (但其实际内部为 nil,长度和容量为 0)

// make 返回对象,并且可以直接使用
var list []int = make([]int, 5, 10) // len=5, cap=10
var mp map[string]int = make(map[string]int, 5) // 初始容量5
var ch chan int64 = make(chan int64, 0) // 0 代表无缓冲通道

fmt.Println(len(list), cap(list)) // 5 10

// 改变切片长度重切,不重新分配内存数组,节省资源
list1 := list[:10]
fmt.Println(len(list1), cap(list1)) // 10 10
}

15. 值类型不能通过 nil 判断为空

nil 只适用于指针、函数、接口、切片、通道和字典。其他基本数据类型(整数、浮点数、布尔、字符串、结构体等)只能通过对比它们的“零值”来判断是否为空。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

func main() {
var i int
var b bool
var s string

// if i == nil {} // 编译报错
if i == 0 { fmt.Println("int 零值为 0") }
if b == false { fmt.Println("bool 零值为 false") }
if s == "" { fmt.Println("string 零值为空字符串") }
}

16. 错误的将数组当成引用类型使用

在 Java/C 中数组名就是指针,但在 Go 中数组是值类型! 当你将 [5]int 作为参数传入函数时,传递的是整个数组的副本。如果想在函数内部修改它,应该使用切片 []int 或数组指针 *[5]int
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import "fmt"

// 错误示范:修改的是副本
func setArrValErr(arr [5]int) {
arr[2] = 1
}

// 正确做法 1:使用切片 (推荐)
func setArrValSlice(arr []int) {
arr[2] = 1
}

// 正确做法 2:使用数组指针
func setArrValPtr(arr *[5]int) {
arr[2] = 1
}

func main() {
arr1 := [5]int{0, 0, 0, 0, 0}
setArrValErr(arr1)
fmt.Println(arr1) // [0 0 0 0 0] 没有改变

arr2 :=[]int{0, 0, 0, 0, 0}
setArrValSlice(arr2)
fmt.Println(arr2) //[0 0 1 0 0] 已改变

arr3 := [5]int{0, 0, 0, 0, 0}
setArrValPtr(&arr3)
fmt.Println(arr3) //[0 0 1 0 0] 已改变
}

17. 错误的使用引用类型 / 结构体被坑

由于 map 是引用类型,当你把一个 map 作为值放进另一个 map 中,它们指向同一块底层数据。修改一处会导致所有引用的地方发生改变。对于结构体(值类型)作为 map 的 value 时,取出的是一个副本,直接修改其属性是不生效的,甚至会直接编译报错。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import "fmt"

type st struct {
A, B, C int
}

func main() {
// Map 引用陷阱
tmp := map[string]int{"A": 1, "B": 2, "C": 3}
mp1 := map[int]map[string]int{
1: tmp,
2: tmp,
}
m := mp1[1]
m["A"] = 10
// tmp 和 mp1[1], mp1[2] 的 "A" 都会变成 10
fmt.Println(mp1)

// 正确做法:使用循环和 make 重新构建并复制副本...

// 结构体作为 Map 值的陷阱
stObj := st{A: 1, B: 2, C: 3}
mp2 := map[int]st{1: stObj}

// mp2[1].A = 10 // 编译报错:cannot assign to struct field mp2[1].A in map

s := mp2[1]
s.A = 10 // 仅仅修改了副本 s
fmt.Println(mp2[1].A) // 输出仍是 1 (未改变)

// 正确的做法是修改后重新赋给 map,或者 Map 值使用结构体指针:
mp2[1] = s // 放回 map 中
}

18. Go 包依赖与 Module 管理

Go 1.11 后引入了 Go Module,抛弃了原来繁琐的 GOPATH 机制。使用外部依赖前,必须进行模块初始化和下载。
命令

1
2
3
4
go mod init my_project     # 初始化项目,生成 go.mod
go get github.com/gin-gonic/gin # 下载依赖包,并更新 go.mod 和 go.sum
go install xxx # 编译并安装二进制可执行文件到 GOPATH/bin
go mod tidy # 自动整理依赖,清理没用到的包,下载缺失的包

19. Go 语言的 Map 是无序的

为了防止开发者过度依赖 Map 元素的遍历顺序,Go 官方在 range 遍历 Map 时,会引入随机种子,导致每次遍历同一 Map 打印出的 Key 顺序大概率不一样。
代码

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
m := map[int]string{1: "A", 2: "B", 3: "C", 4: "D"}
// 多次运行此段代码,会发现打印顺序不固定
for k, v := range m {
fmt.Printf("%d:%s ", k, v)
}
}

20. Range 返回的是键值对

range 关键字极其强大:遍历 Slice/Array 时返回 (index, value);遍历 Map 时返回 (key, value);遍历 String 时返回的是 (byte索引, rune字符)。如果只想要 Value,记得用占位符忽略 Key。
代码

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
s := "Go"
for i, v := range s {
// i 是字符起始字节所在的索引,v 是该 rune (int32)
fmt.Printf("Index: %d, Char: %c\n", i, v)
}
}

21. 获取 Map 中不存在的 Key

直接从 Map 获取不存在的 Key 不会报错(不像其他语言抛出 Null Pointer 或 Key Error),而是会返回 Value 对应类型的零值。为了区分是“值本来就是 0”还是“Key 根本不存在”,Go 支持多返回值(Comma Ok idiom)。
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
m := map[string]int{"A": 100}

val := m["B"]
fmt.Println(val) // 直接输出零值 0

val2, ok := m["B"]
if !ok {
fmt.Println("键 B 不存在")
} else {
fmt.Println("键 B 存在,值为:", val2)
}
}

22. 多线程陷阱与 WaitGroup

主 Go 协程 (main) 执行完毕后会直接退出进程,不会等待子协程运行完。因此必须使用 sync.WaitGroup 或者 Channel 阻塞主协程等待。特别注意WaitGroup 是结构体值类型,跨函数传递时必须传指针 *sync.WaitGroup
代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
"fmt"
"sync"
)

func doIt(workerID int, ch <-chan interface{}, done <-chan struct{}, wg *sync.WaitGroup) {
fmt.Printf("[%v] is running\n", workerID)
defer wg.Done() // 通知执行完毕
for {
select {
case m, ok := <-ch:
if ok {
fmt.Printf("[%v] m => %v\n", workerID, m)
}
case <-done: // 只要接收到关闭信号
fmt.Printf("[%v] is done\n", workerID)
return
}
}
}

func main() {
var wg sync.WaitGroup
done := make(chan struct{})
ch := make(chan interface{}, 10)
workerCount := 10

for i := 0; i < workerCount; i++ {
wg.Add(1)
// wg 必须传指针,否则 doIt 内部调用 Done 只会改变副本,导致死锁
go doIt(i, ch, done, &wg)
}

// 主进程充当生产者,向通道中发送消息
for i := 0; i < workerCount; i++ {
ch <- i
}

close(ch) // 关闭数据通道
close(done) // 关闭通知信号通道,利用广播特性结束所有协程

wg.Wait() // 阻塞,直到所有的 wg.Done() 被执行完毕
fmt.Println("all done!")
}

23. Printf 格式化输出占位符汇总

fmt.Printf 提供了非常丰富的占位符,熟记可以极大便利调试。

  • %v:默认格式的值 / %+v:含字段名 / %#v:含 Go 语言类型的表示。
  • %T:打印该变量的类型。
  • %b / %o / %d / %x:二进制 / 八进制 / 十进制 / 十六进制输出。
  • %f / %e / %g:浮点数格式输出。
    代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import "fmt"

type simple struct {
value int
}

func main() {
a := simple{value: 10}

// 【通用占位符】
fmt.Printf("默认格式的值: %v \n", a) // {10}
fmt.Printf("包含字段名的值: %+v \n", a) // {value:10}
fmt.Printf("go语法表示的值: %#v \n", a) // main.simple{value:10}
fmt.Printf("go语法表示的类型: %T \n", a) // main.simple
fmt.Printf("输出字面上的百分号: %%10 \n") // %10

// 【整数 / 进制占位符】
v1 := 10
v2 := 20170 // 码点 '今'
fmt.Printf("二进制: %b \n", v1) // 1010
fmt.Printf("Unicode码点转字符: %c \n", v2)// 今
fmt.Printf("十进制: %d \n", v1) // 10
fmt.Printf("八进制: %o \n", v1) // 12
fmt.Printf("十六进制: %x \n", v1) // a
fmt.Printf("十六进制大写: %X \n", v1) // A
fmt.Printf("Unicode格式: %U \n", v2) // U+4ECA

// 【浮点数与字符串占位符】
f1 := 123.789
fmt.Printf("浮点数默认: %f \n", f1) // 123.789000
fmt.Printf("浮点数保留2位小数: %.2f \n", f1) // 123.79

str := "今天是个好日子"
fmt.Printf("描述一下今天: %s \n", str) // 今天是个好日子
fmt.Printf("十六进制字符串: %x \n", str) // 16进制表示的 byte
fmt.Printf("单引号包裹的字面值: %q \n", str) // "今天是个好日子"
}