3. Go复合类型:数组、切片、映射、结构体与JSON序列化

好,咱们今天聊聊Go语言的复合类型。说实话,这部分内容是我在实际项目中打交道最多的。数组、切片、映射、结构体,再加上JSON序列化,几乎每个Go服务都离不开它们。

我个人习惯把复合类型理解为「基础类型的组合拳」。你想想看,光靠int、string这些基础类型,根本没法描述复杂的数据结构。比如一个用户信息,既有名字又有年龄,还得有地址——这时候就得靠结构体了。

3.1 数组:固定长度的序列

数组在Go里是个「老实人」。长度一旦定义,就不能变了。我记得刚学Go时,总想着像Python那样动态扩展数组,结果编译器直接报错。

// 声明一个长度为5的int数组
var arr [5]int
arr[0] = 1
arr[1] = 2

// 字面量初始化
arr2 := [3]string{"a", "b", "c"}

// 让编译器数长度
arr3 := [...]int{1, 2, 3, 4, 5}

注意:数组是值类型。赋值或传参时会复制整个数组。如果数组很大,性能会受影响。我曾经在项目中因为没注意这点,导致内存占用飙升——排查了半天才发现是数组拷贝的问题。

3.2 切片:动态数组的利器

切片才是Go里的「主角」。说白了,切片就是对数组的引用,但它更灵活。长度可以动态变化,这也是我日常用得最多的复合类型。

// 创建切片
s1 := []int{1, 2, 3}
s2 := make([]int, 5, 10)  // 长度5,容量10

// 追加元素
s1 = append(s1, 4, 5)

// 切片操作
sub := s1[1:3]  // [2, 3]

这里有个坑,我必须要说。切片操作共享底层数组,修改子切片会影响原切片。举个例子:

arr := []int{1, 2, 3, 4, 5}
sub := arr[1:3]  // [2, 3]
sub[0] = 99
fmt.Println(arr)  // [1, 99, 3, 4, 5]  — 原数组也被改了!

避坑指南:我曾经在微服务中传递切片,结果一个协程修改了切片内容,导致另一个协程读到脏数据。解决方案是使用copy函数创建独立副本。

// 安全复制
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
// 现在修改dst不会影响src

3.3 映射:键值对存储

映射(map)就是Go里的字典。用起来很方便,但有几个细节要注意。

// 声明并初始化
m := map[string]int{
    "apple": 5,
    "banana": 3,
}

// 使用make创建
m2 := make(map[string]int)
m2["orange"] = 7

// 安全取值
value, exists := m["grape"]
if exists {
    fmt.Println("找到了:", value)
}

重要提醒:map不是并发安全的。多个goroutine同时读写map会导致fatal error。我在生产环境中遇到过这个问题,服务直接崩溃。解决方案是用sync.RWMutex或者sync.Map。

为什么会这样?因为map的内部结构复杂,并发读写时可能破坏内部数据结构。嗯,这里要记住:永远不要在并发场景下直接读写同一个map

3.4 结构体:自定义数据类型

结构体是Go里组织数据的核心方式。说白了,就是把多个字段打包成一个整体。

type User struct {
    Name    string
    Age     int
    Email   string
    Address string
}

// 创建实例
u1 := User{
    Name:  "张三",
    Age:   28,
    Email: "zhangsan@example.com",
}

// 访问字段
fmt.Println(u1.Name)

我个人习惯把结构体定义放在文件顶部,方便阅读。另外,结构体字段的命名要规范,首字母大写表示公开字段,小写表示私有。

小技巧:结构体可以嵌套。比如User里嵌套一个Address结构体,这样数据组织更清晰。

3.5 JSON序列化与反序列化

这部分在实际项目中太常用了。微服务之间通信、配置文件解析、API响应处理,都离不开JSON。

import "encoding/json"

type Product struct {
    ID    int     `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price"`
    // 忽略空值字段
    Desc  string  `json:"desc,omitempty"`
}

// 序列化:结构体 -> JSON
p := Product{ID: 1, Name: "手机", Price: 2999.0}
jsonData, _ := json.Marshal(p)
fmt.Println(string(jsonData))  // {"id":1,"name":"手机","price":2999}

// 反序列化:JSON -> 结构体
jsonStr := `{"id":2,"name":"电脑","price":5999}`
var p2 Product
json.Unmarshal([]byte(jsonStr), &p2)
fmt.Println(p2.Name)  // 电脑

关键点:结构体字段后的`json:"name"`叫做标签(tag),它告诉JSON编码器字段名怎么映射。omitempty表示如果字段是零值就忽略。我曾经因为忘记加标签,导致前端收到的字段名是大写的——嗯,那场面挺尴尬的。

还有一个常见问题:时间格式的处理。Go的time.Time默认序列化是RFC3339格式,但很多系统要求其他格式。这时候可以自定义类型:

type CustomTime time.Time

func (ct CustomTime) MarshalJSON() ([]byte, error) {
    t := time.Time(ct)
    return json.Marshal(t.Format("2006-01-02 15:04:05"))
}

3.6 实战建议

最后,分享几个我在项目中的经验:

  • 切片预分配容量:如果知道最终大小,用make指定容量,避免多次扩容
  • map取值要检查:永远用「comma ok」模式,避免拿到零值误判
  • 结构体尽量用指针:特别是作为函数参数时,避免大结构体拷贝
  • JSON字段名用蛇形:前端和后端约定好命名风格,我一般用snake_case

好了,这部分内容就到这里。记住一点:复合类型是Go程序的骨架,多写多练才能掌握。下一章咱们聊聊函数和方法,到时候见。