返回首页

Golang入门:1.4 复合数据类型

在 Go 语言里,除了整数、浮点数、布尔值这些基础数据类型之外,我们还会频繁使用一类更适合组织数据的类型——复合数据类型。所谓“复合”,可以简单理解为:它们不是只存一个值,而是能把多个值按照某种规则组合起来。

这一章我们重点学习四个非常核心的内容:数组、切片、映射,以及字符串在底层的表示方式。它们几乎贯穿了所有 Go 项目的日常开发。

如果你已经有其他编程语言经验,可以把它们先粗略类比为:

类型 可以类比成什么 Go 中的特点
Array 定长数组 长度是类型的一部分,创建后不能改变长度
Slice 动态数组视图 最常用,基于底层数组实现,支持扩容
Map 哈希表 / 字典 键值对存储,查找效率高
String 字符序列 底层是字节序列,处理中文时要特别注意 rune

理解这些类型,不只是为了“会用”,更重要的是知道它们为什么这样设计,以及在什么场景下应该选哪一种。

一、数组(Array):定长容器

数组是 Go 中最基础的复合数据类型之一。它表示一组长度固定元素类型相同的数据。

数组的声明方式如下:

var arr [5]int

这表示定义了一个长度为 5、元素类型为 int 的数组。数组中的每个元素都会自动初始化为该类型的零值,这里就是 0。

1. 数组的特点

数组有几个关键点必须记住:

  1. 长度固定:一旦定义,长度不能改变。
  2. 元素类型一致:一个数组里所有元素必须是同一类型。
  3. 长度属于类型的一部分[3]int[5]int 是两种不同的类型。
  4. 数组是值类型:赋值或传参时会复制整个数组。

这和很多语言中的“数组”并不完全一样。在 Go 里,数组更偏向底层、稳定、明确的容器。

2. 数组的声明与初始化

下面的例子展示了数组的几种常见写法:

package main

import "fmt"

func main() {
	var nums [5]int
	fmt.Println("默认值数组:", nums)

	letters := [3]string{"A", "B", "C"}
	fmt.Println("完整初始化:", letters)

	scores := [5]int{0: 100, 2: 95, 4: 88}
	fmt.Println("按索引初始化:", scores)

	auto := [...]int{10, 20, 30, 40}
	fmt.Println("自动推导长度:", auto)
	fmt.Println("数组长度:", len(auto))
}

运行结果中,你会看到未赋值的位置自动补零,而 ... 可以让编译器根据初始化元素的个数推导数组长度。

3. 访问和遍历数组

数组支持通过下标访问元素,下标从 0 开始。也可以使用 forrange 遍历。

package main

import "fmt"

func main() {
	nums := [4]int{10, 20, 30, 40}

	fmt.Println("第一个元素:", nums[0])
	nums[1] = 99
	fmt.Println("修改后的数组:", nums)

	fmt.Println("使用普通 for 遍历:")
	for i := 0; i < len(nums); i++ {
		fmt.Printf("index=%d, value=%d\n", i, nums[i])
	}

	fmt.Println("使用 range 遍历:")
	for index, value := range nums {
		fmt.Printf("index=%d, value=%d\n", index, value)
	}
}

4. 数组是值类型

这是初学 Go 时非常容易忽略的一点。数组赋值时,不是“共用同一份数据”,而是完整复制一份。

package main

import "fmt"

func main() {
	a := [3]int{1, 2, 3}
	b := a

	b[0] = 100

	fmt.Println("a:", a)
	fmt.Println("b:", b)
}

输出结果会说明:修改 b 不会影响 a

这说明数组适合长度明确、数据规模较小、并且希望复制行为明确的场景。但在实际开发中,数组直接使用得并不算多,更多时候我们会使用切片。

5. 数组适合什么场景

数组虽然不如切片灵活,但并不是“没用”。它适合:

  • 长度固定且明确的数据
  • 需要编译期确定大小的数据结构
  • 作为切片底层存储的基础
  • 某些性能敏感、内存布局明确的场景

二、切片(Slice):动态数组的底层原理与常用操作

切片是 Go 中最常用的复合数据类型。你可以把它理解成对数组的一层灵活封装。它提供了类似“动态数组”的使用体验,但底层仍然依赖数组。

Go 日常开发里,大部分“数组式操作”其实都在用切片。

1. 切片是什么

切片本身并不直接存储所有元素,它更像一个“描述符”,内部记录了三部分信息:

  • 指向底层数组的指针
  • 当前长度 len
  • 当前容量 cap

其中:

  • len 表示当前切片中可直接访问的元素个数
  • cap 表示从切片起始位置到底层数组末尾,最多还能容纳多少元素

可以先看一张对比表:

对比项 数组 切片
长度是否固定 固定 不固定
是否可扩容 不可扩容 可通过 append 扩容
是否常用 相对较少 非常常用
底层存储 自己直接存数据 引用底层数组

2. 切片的创建方式

常见方式有三种:

方式一:基于数组切出一段

package main

import "fmt"

func main() {
	arr := [5]int{10, 20, 30, 40, 50}

	s1 := arr[1:4]
	fmt.Println("s1:", s1)
	fmt.Println("len(s1):", len(s1))
	fmt.Println("cap(s1):", cap(s1))
}

这里 s1 := arr[1:4] 表示取下标 1 到 3 的元素,也就是左闭右开区间。

len(s1) 为 3,因为切片中有 3 个元素。 cap(s1) 为 4,因为从下标 1 开始,到底层数组末尾一共有 4 个位置可用:20、30、40、50。

方式二:直接字面量创建

package main

import "fmt"

func main() {
	nums := []int{1, 2, 3, 4}
	fmt.Println(nums)
	fmt.Println("len:", len(nums))
	fmt.Println("cap:", cap(nums))
}

这种写法最常见,也是实际开发中最推荐的基础写法。

方式三:使用 make

package main

import "fmt"

func main() {
	s1 := make([]int, 3)
	s2 := make([]int, 3, 5)

	fmt.Println("s1:", s1, "len=", len(s1), "cap=", cap(s1))
	fmt.Println("s2:", s2, "len=", len(s2), "cap=", cap(s2))
}

make([]int, 3) 表示创建一个长度为 3、容量也为 3 的切片。 make([]int, 3, 5) 表示创建一个长度为 3、容量为 5 的切片。

当你预估后续会追加元素时,提前设置合适的容量,能减少扩容次数。

3. 切片与底层数组的关系

这是理解切片的关键。

多个切片可能引用同一个底层数组,因此修改一个切片中的元素,可能会影响另一个切片,甚至影响原数组。

package main

import "fmt"

func main() {
	arr := [5]int{10, 20, 30, 40, 50}
	s1 := arr[1:4]
	s2 := arr[2:5]

	s1[1] = 999

	fmt.Println("arr:", arr)
	fmt.Println("s1:", s1)
	fmt.Println("s2:", s2)
}

为什么会这样?

因为 s1[1] 实际对应的是 arr[2]。而 s2 也引用到了这个位置,所以它看到的值也会变化。

这就是切片“共享底层数组”的特性。它带来了高效,也带来了需要小心的地方。

4. len 和 cap 的直观理解

我们通过一个例子进一步观察:

package main

import "fmt"

func main() {
	data := [6]int{10, 20, 30, 40, 50, 60}
	s := data[2:4]

	fmt.Println("slice:", s)
	fmt.Println("len:", len(s))
	fmt.Println("cap:", cap(s))
}

这里:

  • s 的内容是 [30 40]
  • len(s) 是 2
  • cap(s) 是 4

原因是:切片从 data[2] 开始,所以容量从位置 2 一直算到底层数组末尾,一共有 4 个元素:30、40、50、60。

可以总结为:

  • len 看“当前能直接用多少个元素”
  • cap 看“在不换底层数组的前提下,最多还能扩展到多大”

5. append:向切片追加元素

切片最常见的操作就是 append

package main

import "fmt"

func main() {
	nums := []int{1, 2, 3}

	nums = append(nums, 4)
	nums = append(nums, 5, 6)

	fmt.Println(nums)
	fmt.Println("len:", len(nums))
	fmt.Println("cap:", cap(nums))
}

注意:append 返回的是新的切片,因此通常要写成:

nums = append(nums, 4)

而不是只写 append(nums, 4)

6. append 背后的扩容机制

这是切片部分最重要的知识点之一。

当切片追加元素时,如果当前容量够用,Go 会直接把新元素放到原底层数组后面;如果容量不够,就会申请一块更大的新数组,把旧数据复制过去,再返回指向新数组的切片。

也就是说,扩容前后,切片底层数组可能会变,也可能不会变

下面的代码可以帮助你观察这一点:

package main

import "fmt"

func main() {
	s := make([]int, 0, 2)
	fmt.Printf("初始: len=%d cap=%d %v\n", len(s), cap(s), s)

	s = append(s, 10)
	fmt.Printf("追加1个后: len=%d cap=%d %v\n", len(s), cap(s), s)

	s = append(s, 20)
	fmt.Printf("追加2个后: len=%d cap=%d %v\n", len(s), cap(s), s)

	s = append(s, 30)
	fmt.Printf("追加3个后: len=%d cap=%d %v\n", len(s), cap(s), s)

	s = append(s, 40, 50)
	fmt.Printf("继续追加后: len=%d cap=%d %v\n", len(s), cap(s), s)
}

在不同 Go 版本中,容量增长的具体细节可能会有差异,但对初学者来说,最重要的是理解下面这几点:

  1. 容量不足时会触发扩容。
  2. 扩容后通常会创建新的底层数组。
  3. 旧数据会被复制到新数组中。
  4. 因此,原切片和新切片可能不再共享同一块底层数据。

所以我们平时不应该依赖“扩容一定翻倍”这种具体实现细节,而应该抓住本质:切片扩容的目标是平衡性能与内存使用

7. 切片共享与扩容导致的差异

下面这个例子非常值得认真看,它能帮助你真正理解“共享底层数组”和“扩容后分离”之间的区别。

package main

import "fmt"

func main() {
	base := []int{1, 2, 3, 4}
	a := base[:2]
	b := base[:2]

	a = append(a, 100)
	fmt.Println("第一次 append 后")
	fmt.Println("base:", base)
	fmt.Println("a:", a)
	fmt.Println("b:", b)

	a = append(a, 200, 300)
	fmt.Println("第二次 append 后")
	fmt.Println("base:", base)
	fmt.Println("a:", a)
	fmt.Println("b:", b)
}

在第一次 append 时,如果容量还够,a 可能直接修改原底层数组,因此 base 也会受到影响。

而在后续继续追加、容量不足时,a 可能触发扩容,转而指向新的底层数组。此后再修改 a,就不一定会影响 baseb 了。

这也是为什么在团队开发里,如果多个切片共享底层数组却没有意识到,就很容易引发难排查的问题。

8. 切片的常用操作

追加元素

package main

import "fmt"

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

拷贝切片

如果你不希望多个切片共享同一个底层数组,可以使用 copy

package main

import "fmt"

func main() {
	src := []int{10, 20, 30}
	dst := make([]int, len(src))
	copy(dst, src)

	dst[0] = 999

	fmt.Println("src:", src)
	fmt.Println("dst:", dst)
}

删除元素(常见写法)

Go 没有内置的“删除切片某个元素”函数,通常用拼接来完成。

package main

import "fmt"

func main() {
	nums := []int{10, 20, 30, 40, 50}
	index := 2

	nums = append(nums[:index], nums[index+1:]...)
	fmt.Println(nums)
}

这里删除了下标为 2 的元素,也就是 30。

截取子切片

package main

import "fmt"

func main() {
	nums := []int{10, 20, 30, 40, 50}

	fmt.Println(nums[1:4])
	fmt.Println(nums[:3])
	fmt.Println(nums[2:])
}

9. nil 切片与空切片

这也是面试和实际开发中经常会遇到的点。

package main

import "fmt"

func main() {
	var s1 []int
	s2 := []int{}

	fmt.Println("s1 == nil:", s1 == nil)
	fmt.Println("s2 == nil:", s2 == nil)
	fmt.Println("len(s1):", len(s1))
	fmt.Println("len(s2):", len(s2))
}

它们看起来都“没有元素”,但并不完全一样:

类型 是否为 nil 长度 常见场景
var s []int 0 未初始化切片
[]int{} 0 明确表示空集合

在 JSON 编码、接口返回值等场景下,这种差异可能会产生实际影响。

三、映射(Map):哈希表的使用与注意事项

Map 是一种键值对结构,可以根据键快速找到对应的值。它的底层是哈希表,因此非常适合做查找、统计、去重、索引等操作。

1. Map 的基本概念

Map 的定义形式如下:

var m map[string]int

这表示:

  • 键类型是 string
  • 值类型是 int

可以把它理解成“从字符串映射到整数”的一张表。

2. Map 的创建方式

声明 map 后,如果没有初始化,它的零值是 nil,这时可以读取,但不能直接写入。

先看正确创建方式:

package main

import "fmt"

func main() {
	ages := make(map[string]int)
	ages["Alice"] = 18
	ages["Bob"] = 20

	fmt.Println(ages)

	scores := map[string]int{
		"Tom":   90,
		"Jerry": 95,
	}
	fmt.Println(scores)
}

3. nil map 不能直接写入

这是 map 最常见的坑之一。

package main

import "fmt"

func main() {
	var m map[string]int
	fmt.Println("m == nil:", m == nil)

	value := m["not_exist"]
	fmt.Println("读取 nil map 中不存在的键:", value)

	// m["test"] = 1
	// 上面这行如果取消注释,会发生 panic:assignment to entry in nil map
}

也就是说:

  • 读 nil map 可以,返回值类型的零值
  • 写 nil map 不可以,会直接 panic

4. Map 的增删改查

package main

import "fmt"

func main() {
	m := make(map[string]int)

	m["apple"] = 3
	m["banana"] = 5
	fmt.Println("初始 map:", m)

	m["apple"] = 10
	fmt.Println("修改后:", m)

	fmt.Println("banana 的值:", m["banana"])

	delete(m, "banana")
	fmt.Println("删除 banana 后:", m)
}

5. 判断键是否存在

直接通过 m[key] 取值时,如果键不存在,会返回值类型的零值。但有时你无法区分“键不存在”和“键存在但值刚好是零值”。

这时要使用 Go 的惯用写法:

package main

import "fmt"

func main() {
	scores := map[string]int{
		"Tom":  90,
		"Lucy": 0,
	}

	v, ok := scores["Tom"]
	fmt.Println("Tom:", v, ok)

	v, ok = scores["Lucy"]
	fmt.Println("Lucy:", v, ok)

	v, ok = scores["Jack"]
	fmt.Println("Jack:", v, ok)
}

这里:

  • v 是取到的值
  • ok 表示键是否真实存在

这是使用 map 时非常重要的习惯。

6. 遍历 Map

Go 支持使用 range 遍历 map。

package main

import "fmt"

func main() {
	m := map[string]int{
		"apple":  3,
		"banana": 5,
		"orange": 2,
	}

	for key, value := range m {
		fmt.Printf("key=%s, value=%d\n", key, value)
	}
}

需要注意的是:map 遍历结果是无序的。你不能依赖它的遍历顺序。

如果你确实需要稳定顺序,通常应该先把键取出来放进切片,再排序后遍历。

7. Map 的使用注意事项

注意一:键必须是可比较类型

Go 的 map 键必须支持 ==!= 比较,因此像切片、map、函数这类类型不能作为键。

可作为键的常见类型包括:

  • 整数
  • 字符串
  • 布尔值
  • 指针
  • 数组
  • 结构体(前提是其中字段都可比较)

注意二:Map 是引用语义

虽然 map 不是“引用类型”这个官方术语下的严格表述,但从使用效果上看,把一个 map 赋值给另一个变量,二者会指向同一份底层数据。

package main

import "fmt"

func main() {
	m1 := map[string]int{"a": 1, "b": 2}
	m2 := m1

	m2["a"] = 100

	fmt.Println("m1:", m1)
	fmt.Println("m2:", m2)
}

注意三:并发读写 map 需要额外处理

普通 map 在并发场景下不是线程安全的。如果多个 goroutine 同时读写 map,可能会导致运行时错误。

这部分我们在后续并发章节还会详细讲。当前阶段你只需要记住:

  • 单线程或受控访问场景下,普通 map 很方便
  • 并发场景下要加锁,或者使用专门的并发安全结构

8. Map 的典型应用:词频统计

下面是一个非常经典的例子:统计单词出现次数。

package main

import "fmt"

func main() {
	words := []string{"go", "java", "go", "python", "go", "java"}
	counter := make(map[string]int)

	for _, word := range words {
		counter[word]++
	}

	fmt.Println(counter)
}

这个例子很好地体现了 map 在“统计”和“分组”类问题中的优势。

四、字符串底层:byte vs rune

字符串是我们几乎每天都要使用的类型,但 Go 中的字符串有一个非常值得重视的特点:字符串底层存储的是字节序列

这会直接影响你对英文、数字、符号,尤其是中文的处理方式。

1. 字符串本质上是什么

在 Go 中,字符串可以理解为一段只读的字节序列。也就是说:

  • 字符串中的内容底层是字节
  • 字符串创建后不能被原地修改
  • 对字符串做下标访问,拿到的是某个字节,不一定是一个完整字符

例如:

package main

import "fmt"

func main() {
	s := "hello"
	fmt.Println("长度:", len(s))
	fmt.Println("s[1]:", s[1])
	fmt.Printf("s[1] 对应字符: %c\n", s[1])
}

这里 len(s) 返回的是字节数,而不是“字符个数”。对于纯英文字符串,这两者通常相同;但遇到中文时就不同了。

2. byte 和 rune 分别是什么

在 Go 中:

  • byteuint8 的别名,用来表示一个字节
  • runeint32 的别名,用来表示一个 Unicode 码点

可以简单理解为:

类型 本质 常见用途
byte 单个字节 处理二进制数据、ASCII、网络数据
rune 单个 Unicode 字符编码点 处理中文、多语言字符

3. 为什么中文不能按 byte 来理解

UTF-8 编码中,一个中文字符通常由多个字节组成。因此如果你按字节遍历字符串,就可能看到“字符被拆开”的情况。

package main

import "fmt"

func main() {
	s := "Go语言"

	fmt.Println("字节长度:", len(s))

	for i := 0; i < len(s); i++ {
		fmt.Printf("index=%d, byte=%d\n", i, s[i])
	}
}

这段代码能运行,但它遍历的是字节,不是字符。

4. 使用 range 按 rune 遍历字符串

Go 提供了非常方便的方式来按字符遍历字符串,那就是 range

package main

import "fmt"

func main() {
	s := "Go语言"

	for index, r := range s {
		fmt.Printf("index=%d, rune=%c, code=%d\n", index, r, r)
	}
}

这里:

  • index 是当前字符在原字符串中的字节起始位置
  • r 是当前解析出来的 rune

这也是 Go 处理 Unicode 字符串最常见、最安全的方式。

5. 字符串转为 []byte[]rune

根据不同需求,字符串可以转换成不同形式:

  • 转成 []byte:适合按字节处理
  • 转成 []rune:适合按字符处理
package main

import "fmt"

func main() {
	s := "Go语言"

	bs := []byte(s)
	rs := []rune(s)

	fmt.Println("[]byte:", bs)
	fmt.Println("[]rune:", rs)
	fmt.Println("字符个数:", len(rs))
}

如果你想正确统计“中文字符串有几个字符”,通常应该转成 []rune 后再看长度。

6. 修改字符串的正确方式

由于字符串是只读的,不能直接修改某个位置的内容。

下面这种写法是错误的:

// s[0] = 'H' // 编译错误:字符串不可变

正确做法是先转换成可修改的切片,再转换回字符串。

package main

import "fmt"

func main() {
	s := "golang"
	bs := []byte(s)
	bs[0] = 'G'
	s = string(bs)
	fmt.Println(s)

	name := "语言"
	rs := []rune(name)
	rs[0] = '术'
	name = string(rs)
	fmt.Println(name)
}

为什么英文示例可以用 []byte,而中文示例更适合 []rune

因为英文字符通常是单字节,直接按字节修改不会破坏编码;而中文通常由多个字节组成,按字节改很容易把 UTF-8 编码弄坏,所以更适合先转成 []rune 再处理。

7. 一个综合示例:正确统计中英文字符

package main

import "fmt"

func main() {
	s := "Hello, 世界"

	fmt.Println("按字节长度:", len(s))
	fmt.Println("按字符长度:", len([]rune(s)))

	fmt.Println("按 rune 遍历:")
	for i, r := range s {
		fmt.Printf("byteIndex=%d, char=%c\n", i, r)
	}
}

这个例子很典型:一个字符串既包含英文,也包含中文。你会看到“字节长度”和“字符长度”并不一致。

五、本章小结

这一章介绍了 Go 中几种最重要的复合数据类型,它们在真实开发里出现频率极高。

我们可以做一个整体回顾:

知识点 核心关键词 你需要重点掌握什么
数组 定长、值类型 理解长度属于类型的一部分,知道数组会整体复制
切片 底层数组、len、cap、append 重点掌握共享底层数组、扩容机制和常用操作
Map 键值对、哈希表 会创建、增删改查、判断键是否存在,理解 nil map 限制
字符串 byte、rune、UTF-8 明白字符串按字节存储,处理中文优先考虑 rune

如果说数组是基础,那么切片就是 Go 日常开发的主力;如果说 map 解决的是高效组织和查找数据的问题,那么字符串的 byterune 则直接决定了你能否正确处理中文和多语言文本。

学完这一章后,你应该至少能做到:

  • 知道数组和切片的本质区别
  • 理解切片为什么会“共享数据”以及为什么会“扩容”
  • 熟练使用 map 做查找和统计
  • 处理字符串时分清什么时候用 byte,什么时候用 rune

下一步继续深入 Go 时,这些内容会成为你理解函数参数传递、结构体设计、并发数据处理的重要基础。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Golang入门:1.3 流程控制

下一篇

Golang入门:1.5 函数与方法