Go 零基础教程

Go 复合类型:数组和切片

数组 (Arrays) 和切片 (Slices) 是 Go 语言中最基础的复合数据类型,它们允许你将多个相同类型的值组合在一个变量名下。

简单来说,数组提供了一块固定大小、连续的内存空间来存储元素;而切片则提供了一种更灵活、动态的视角,用于访问和操作底层的数组。

本章将深入探讨数组和切片的底层逻辑,涵盖它们的声明、初始化、操作技巧,以及在 Go 语言中特有的注意事项。

1. Go 语言中的数组 (Arrays)

数组是具有固定长度的相同类型元素的序列。在 Go 语言中,数组的长度是其类型的一部分。这意味着 [3]int[4]int 是两种完全不同的类型,不能直接混用。

1.1 声明数组

声明数组的基本语法如下:

var 数组名 [数组长度]数据类型

例如,声明一个可以容纳 5 个整数的数组:

var numbers [5]int

这行代码声明了一个名为 numbers 的数组。它的元素会自动被初始化为该类型的零值(对于整数,零值就是 0)。

1.2 初始化数组

你可以通过多种方式在声明时直接初始化数组:

1. 使用字面量直接赋值:

numbers := [5]int{1, 2, 3, 4, 5}

这会将 numbers 数组的元素依次初始化为 1 到 5。

2. 仅指定特定索引位置的元素:

numbers := [5]int{0: 10, 4: 50}

这会将第一个元素(索引 0)设为 10,最后一个元素(索引 4)设为 50。未指定的剩余元素将保持零值 (0)。

3. 使用 ... 让编译器自动推断长度:

numbers := [...]int{1, 2, 3, 4, 5}

这里的 ... 告诉编译器:“请根据大括号里元素的个数,自动帮我计算数组的长度。”

1.3 访问与修改数组元素

你可以通过索引 (Index) 来访问数组元素,索引从 0 开始计算:

numbers := [5]int{10, 20, 30, 40, 50}
fmt.Println(numbers[0]) // 输出: 10
fmt.Println(numbers[3]) // 输出: 40

同样,你可以通过索引来修改对应位置的值:

numbers[1] = 25
fmt.Println(numbers[1]) // 输出: 25

1.4 数组的长度

数组的长度是固定的,你可以使用内置的 len() 函数来获取它:

numbers := [5]int{1, 2, 3, 4, 5}
length := len(numbers)
fmt.Println(length) // 输出: 5

注意:在 Go 语言中,数组没有独立于长度的“容量”概念。

1.5 核心重点:数组是“值类型” (Values)

这是 Go 语言数组的一个极其关键的特性:数组是值类型。当你把一个数组赋值给另一个数组变量,或者把它作为参数传递给函数时,Go 会完整地复制整个数组的内容。对副本的任何修改,都绝对不会影响到原始数组。

numbers1 := [3]int{1, 2, 3}
numbers2 := numbers1 // numbers2 获得了 numbers1 的完整拷贝
numbers2[0] = 100

fmt.Println(numbers1) // 输出: [1 2 3] (原数组毫发无损)
fmt.Println(numbers2) // 输出: [100 2 3]
这与许多其他编程语言(如 Java 或 Python,它们通常传递的是引用/指针)的逻辑完全不同,千万要牢记!

2. Go 语言中的切片 (Slices)

由于数组的长度固定且传递时会复制全量数据,这在实际开发中往往不够灵活。因此,Go 引入了切片 (Slices)。切片提供了一种更强大、更灵活的方式来处理数据序列。

切片本质上是对底层数组一个连续片段的“描述符” (Descriptor)。它内部包含了三个关键信息:

  1. 指向底层数组的指针。
  2. 切片的长度 (Length)。
  3. 切片的容量 (Capacity)。

2.1 声明切片

声明切片的语法与数组类似,但不需要指定长度

var sliceName []dataType

例如,声明一个整数切片:

var numbers []int

2.2 创建切片

你可以通过以下几种主要方式创建切片:

1. 从现有数组中切取 (Slicing):

array := [5]int{1, 2, 3, 4, 5}
slice := array[1:4] // 从索引 1 (包含) 切割到索引 4 (不包含)
fmt.Println(slice)   // 输出: [2 3 4]

这创建了一个切片,它“看向”原始 array 的一部分。

2. 使用 make() 函数动态创建:

slice := make([]int, 5)       // 创建一个长度为 5,容量为 5 的切片
slice2 := make([]int, 5, 10)  // 创建一个长度为 5,容量为 10 的切片

make() 函数会自动在内存里分配一个隐藏的底层数组,并返回一个指向它的切片。第一个参数是切片类型,第二个是长度,第三个(可选)是容量。如果不传第三个参数,容量默认等于长度。

3. 使用切片字面量 (最常用):

slice := []int{1, 2, 3, 4, 5}

这种写法看起来像数组字面量,但括号里没有数字或 ...。Go 会自动为你创建一个底层数组,并返回一个包含这些值的切片。

2.3 长度 (Length) 与容量 (Capacity)

  • 长度 (Length): 切片当前实际包含的元素个数。
  • 容量 (Capacity): 从切片的第一个元素开始,到底层数组末尾的总元素个数。

你可以分别使用 len()cap() 函数来获取它们:

slice := make([]int, 3, 5)
fmt.Println(len(slice)) // 输出: 3
fmt.Println(cap(slice)) // 输出: 5

2.4 动态追加元素 (Append)

切片是动态的,你可以随时向其中添加新元素。Go 提供了内置的 append() 函数来实现这一点:

slice := []int{1, 2, 3}
slice = append(slice, 4, 5)
fmt.Println(slice) // 输出: [1 2 3 4 5]

扩容机制: 如果切片当前的容量 (Capacity) 不足以容纳新追加的元素,Go 会自动在内存中申请一块更大的新数组,把老数据拷贝过去,然后追加新元素,最后让切片指向这个全新的底层数组。

2.5 对切片进行再切割 (Slicing Slices)

你可以基于一个现有的切片,再切出一个新的切片:

slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1[1:4] // 从索引 1 到 4
fmt.Println(slice2)   // 输出: [2 3 4]
极其危险的陷阱: 因为多个切片可能共享同一个底层数组,修改其中一个切片的元素,往往会影响到其他切片!
slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1[1:4]

slice2[0] = 100 // 修改了 slice2 的第一个元素

fmt.Println(slice1) // 输出: [1 100 3 4 5] (slice1 也被改变了!)
fmt.Println(slice2) // 输出: [100 3 4]

但请注意,如果对其中一个切片使用 append() 触发了扩容操作(生成了新的底层数组),它们就会“分道扬镳”,后续的修改就不再互相影响了。

2.6 独立拷贝切片 (Copying Slices)

如果你想要创建一个与原切片完全独立、互不干扰的副本,必须使用内置的 copy() 函数:

slice1 := []int{1, 2, 3, 4, 5}
slice2 := make([]int, len(slice1)) // 必须先分配足够长度的内存

copy(slice2, slice1) // 把 slice1 的内容拷贝到 slice2 中

slice2[0] = 100 // 修改副本
fmt.Println(slice1) // 输出: [1 2 3 4 5] (原切片不受影响)
fmt.Println(slice2) // 输出: [100 2 3 4 5]

copy() 函数拷贝的元素数量,取决于两个切片中较短的那个的长度。

2.7 切片的零值 (Zero Value)

切片的零值是 nil。一个 nil 切片的长度和容量都是 0,并且它没有指向任何底层数组。

var slice []int
fmt.Println(slice == nil) // 输出: true
fmt.Println(len(slice))   // 输出: 0
fmt.Println(cap(slice))   // 输出: 0

不过不用担心,Go 非常智能。你可以直接对一个 nil 切片使用 append(),Go 会自动为你分配底层数组。

3. 如何选择:数组 vs 切片?

特性数组 (Array)切片 (Slice)
长度固定不变 (编译时确定)动态可变 (运行时扩缩容)
内存表现值类型 (赋值时拷贝全量数据)引用类型 (底层是指针结构)
适用场景明确知道数据大小且绝不会变;对内存分配有极致性能要求的底层场景绝大多数日常开发场景;需要动态增删数据的列表

结论: 在 Go 语言中,99% 的情况下你都应该使用切片

4. 实战代码示例

来看看切片在实际编程中的威力。

示例 1:计算一组数字的平均值

package main
import "fmt"

func main() {
	numbers := []float64{10.5, 20.7, 30.2, 40.9, 50.1}
	sum := 0.0
	
    // 使用 range 遍历切片,'_' 表示忽略索引
	for _, number := range numbers {
		sum += number
	}
	
	average := sum / float64(len(numbers))
	fmt.Printf("平均值: %.2f\n", average) // 输出: 平均值: 30.50
}

示例 2:过滤出所有的偶数

package main
import "fmt"

func main() {
	numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
	evenNumbers := []int{} // 初始化一个空切片
	
	for _, number := range numbers {
		if number%2 == 0 {
            // 利用 append 动态添加元素
			evenNumbers = append(evenNumbers, number)
		}
	}
	fmt.Println("所有的偶数:", evenNumbers) // 输出: 所有的偶数: [2 4 6 8 10]
}

示例 3:利用切片实现“栈” (Stack) 数据结构

package main
import "fmt"

type Stack struct {
	data []int
}

// Push: 入栈,将元素追加到切片末尾
func (s *Stack) Push(value int) {
	s.data = append(s.data, value)
}

// Pop: 出栈,弹出切片末尾的元素
func (s *Stack) Pop() (int, bool) {
	if len(s.data) == 0 {
		return 0, false // 栈为空
	}
	index := len(s.data) - 1
	value := s.data[index]
	s.data = s.data[:index] // 切割切片,移除最后一个元素
	return value, true
}

func main() {
	stack := Stack{data: []int{}}
	stack.Push(10)
	stack.Push(20)
	stack.Push(30)
	
	value, ok := stack.Pop()
	if ok {
		fmt.Println("出栈元素:", value) // 输出: 出栈元素: 30
	}
	
	value, ok = stack.Pop()
	if ok {
		fmt.Println("出栈元素:", value) // 输出: 出栈元素: 20
	}
}