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会返回零值,你分不清是"不存在"还是"值就是零"。

并发安全——重中之重

🚨 Go的map不是并发安全的!

多个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开发就稳了一半。下一章咱们聊聊函数和方法的那些事儿。