4、Go复合类型:数组、切片与map
好,咱们今天聊聊Go的复合类型。说实话,这部分是Go语言里最常用的数据结构了。我刚开始从Java转Go时,最不适应的就是数组和切片的设计。但用久了你会发现,Go的设计哲学真的很巧妙——简单、高效、直白。
4.1 数组:声明、遍历与值类型特性
数组在Go里是个"老实人"。它长度固定,类型统一,而且——它是值类型。这一点特别重要,我待会儿会细说。
声明与初始化
// 基本声明
var arr1 [5]int // [0 0 0 0 0]
var arr2 [3]string // ["" "" ""]
// 字面量初始化
arr3 := [3]int{1, 2, 3}
arr4 := [...]int{4, 5, 6} // 编译器自动计算长度
// 指定索引初始化
arr5 := [5]int{0: 10, 3: 20} // [10, 0, 0, 20, 0]
我个人习惯用 [...] 这种写法,省心。但要注意,数组长度是类型的一部分——[3]int 和 [5]int 是两种不同的类型,不能互相赋值。
遍历数组
arr := [3]string{"Go", "Python", "Java"}
// 方式一:经典for循环
for i := 0; i < len(arr); i++ {
fmt.Println(arr[i])
}
// 方式二:range遍历(推荐)
for index, value := range arr {
fmt.Printf("索引%d: %s\n", index, value)
}
// 只遍历索引
for i := range arr {
fmt.Println(i)
}
range遍历时,value是元素的副本。想修改原数组?用索引操作。
值类型特性——避坑指南
赋值或传参时,会复制整个数组。数组越大,开销越大。
func modify(arr [3]int) {
arr[0] = 100 // 只修改副本,不影响原数组
}
func main() {
original := [3]int{1, 2, 3}
modify(original)
fmt.Println(original) // 输出 [1 2 3],没变!
}
我曾经在项目中犯过这个错。一个同事传了个大数组给函数,结果函数里改了数据,外面没生效。排查了半天才发现是值拷贝的问题。后来我们统一改用切片了。
想传引用?用指针:
func modifyPtr(arr *[3]int) {
arr[0] = 100
}
// 调用:modifyPtr(&original)
4.2 切片:动态数组的利器
切片才是Go里真正的主角。它是对数组的抽象,提供了动态扩容的能力。你想想看,实际开发中哪有那么多固定长度的数组?
创建切片
// 方式一:从数组切取
arr := [5]int{1, 2, 3, 4, 5}
slice := arr[1:4] // [2, 3, 4]
// 方式二:直接声明
var s1 []int // nil切片,长度0
s2 := []int{1, 2, 3} // 字面量
// 方式三:make创建(最常用)
s3 := make([]int, 5) // 长度5,容量5
s4 := make([]int, 3, 5) // 长度3,容量5
我个人强烈推荐用 make。为什么?因为你可以预分配容量,减少扩容次数。这在性能敏感的场景下特别有用。
append与扩容机制
s := make([]int, 0, 3)
s = append(s, 1, 2, 3) // 容量够,直接追加
s = append(s, 4) // 容量不够,触发扩容
扩容规则:
| 原容量 | 扩容策略 |
|---|---|
| < 256 | 翻倍扩容 |
| ≥ 256 | 增加约 1.25 倍 |
扩容会分配新数组,复制旧数据。频繁扩容影响性能。所以——预估好容量,用make预分配。
我记得有一次优化一个日志处理服务,发现大量时间花在切片扩容上。后来改成预分配容量,性能提升了30%。
切片底层结构
切片本质上是一个结构体:
type slice struct {
ptr unsafe.Pointer // 指向底层数组的指针
len int // 长度
cap int // 容量
}
多个切片可以共享同一个底层数组。修改一个,其他也会受影响。嗯,这里要注意:
arr := [5]int{1, 2, 3, 4, 5}
s1 := arr[0:3] // [1, 2, 3]
s2 := arr[2:5] // [3, 4, 5]
s1[2] = 100 // 修改了底层数组索引2
fmt.Println(s2[0]) // 输出 100,s2也变了!
想完全独立?用 copy 函数创建副本:s2 := make([]int, len(s1)); copy(s2, s1)
4.3 Map:键值对存储
Map就是哈希表,Go里用起来很顺手。但有几个坑,我踩过,得跟你说说。
声明与初始化
// 方式一:make创建
m1 := make(map[string]int)
m1["age"] = 25
// 方式二:字面量
m2 := map[string]int{
"Alice": 25,
"Bob": 30,
}
// 方式三:var声明(nil map,不能直接赋值)
var m3 map[string]int
// m3["key"] = 1 // 会panic!
nil map不能直接写,但可以读(返回零值)。我刚开始就吃过这个亏,一运行就panic。
增删改查
m := make(map[string]int)
// 增/改
m["Alice"] = 25
m["Bob"] = 30
m["Alice"] = 26 // 覆盖
// 查
age, ok := m["Alice"]
if ok {
fmt.Println("Alice的年龄:", age)
}
// 删
delete(m, "Bob")
// 遍历
for key, value := range m {
fmt.Printf("%s: %d\n", key, value)
}
查的时候用 value, ok := map[key] 这种写法。ok为false表示key不存在。别直接用 value := map[key],因为不存在的key会返回零值,你分不清是"不存在"还是"值就是零"。
并发安全——重中之重
多个goroutine同时读写map,会引发fatal error: concurrent map writes
我曾经在生产环境遇到过这个问题。一个缓存服务,多个goroutine同时写map,服务直接挂了。排查起来特别痛苦,因为不是每次都会触发。
解决方案:
// 方案一:sync.RWMutex(推荐)
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(key string) int {
s.mu.RLock()
defer s.mu.RUnlock()
return s.m[key]
}
func (s *SafeMap) Set(key string, value int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = value
}
// 方案二:sync.Map(适合读多写少场景)
var sm sync.Map
sm.Store("key", "value")
val, ok := sm.Load("key")
读多写少用 sync.Map,读写均衡用 sync.RWMutex + 普通map。别自己造轮子,标准库已经够用了。
好了,数组、切片、map就聊到这儿。说白了,数组是基础,切片是主力,map是神器。掌握好它们,Go开发就稳了一半。下一章咱们聊聊函数和方法的那些事儿。