跳到主要内容

基本数据类型

Go 是一门静态强类型语言,变量在声明时类型就已经确定。本文聚焦于日常最常打交道的几类数据结构:数组、切片、结构体,以及字符串中容易混淆的 byterune

Go 的基本类型可以大致分为以下几类:

  • 布尔类型:bool
  • 整型:intint8/int16/int32/int64uint 系列
  • 浮点型:float32float64
  • 字符串:string
  • 别名类型:byte(即 uint8)、rune(即 int32

值得注意的是,int 的长度取决于平台,在 64 位系统上是 64 位。当你需要明确的位宽时,应当显式使用 int32int64 等类型,而不是依赖 int

数组 Array

数组是固定长度的同类型元素序列,长度是类型的一部分。也就是说 [3]int[4]int 是两个完全不同的类型。

var arr [3]int // 声明一个长度为 3 的 int 数组,默认零值 [0 0 0]
arr[0] = 1 // 通过下标赋值
nums := [3]int{1, 2, 3} // 声明并初始化
auto := [...]int{1, 2, 3, 4} // 使用 ... 由编译器推断长度为 4

数组最关键的特性是:它是值类型。将数组赋值给另一个变量或传入函数时,会完整复制一份。

a := [3]int{1, 2, 3}
b := a // b 是 a 的完整拷贝
b[0] = 100
// 此时 a 仍然是 [1 2 3],b 是 [100 2 3]

正因为数组是值类型、长度又固定,实际开发中很少直接使用数组,更多的是使用切片。

切片 Slice

切片是对底层数组的一个引用视图(切片变量本身是含指针的值类型三元组:ptr/len/cap),长度可变,是 Go 中最常用的序列结构。

切片底层由三部分构成:

  • 指针:指向底层数组的起始元素
  • 长度 len:当前切片包含的元素个数
  • 容量 cap:从切片起始位置到底层数组末尾的元素个数
s := []int{1, 2, 3} // 直接字面量创建
s2 := make([]int, 2, 5) // make(类型, len, cap),len=2 cap=5
fmt.Println(len(s2), cap(s2)) // 2 5

append 与扩容

append 用于向切片追加元素。当容量足够时,直接在底层数组上追加;当容量不足时,会分配一块更大的底层数组并复制原有数据,返回一个指向新数组的切片。

s := make([]int, 0, 2)
s = append(s, 1) // len=1 cap=2
s = append(s, 2) // len=2 cap=2
s = append(s, 3) // 容量不足,触发扩容,底层数组被替换,cap 通常翻倍

因此 append 的返回值必须重新赋值给原变量,否则可能拿到旧的、已失效的切片视图。

切片共享底层数组的陷阱

由于切片是引用视图,多个切片可能共享同一底层数组,对其中一个的修改会影响另一个。

arr := []int{1, 2, 3, 4, 5}
s1 := arr[1:3] // [2 3],与 arr 共享底层数组
s1[0] = 100
// 此时 arr 变为 [1 100 3 4 5]

如果需要一个完全独立的副本,应使用 copy

src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src) // dst 是独立副本,修改互不影响

小结:数组是值类型、长度固定;切片按值复制「头部」但共享底层数组,长度可变。传参开销小(只复制三元组),但要警惕底层数组共享与 append 可能换底层数组。

结构体 Struct

结构体用于将一组不同类型的字段聚合为一个自定义类型,是 Go 中组织数据的核心方式。

type Person struct {
Name string
Age int
}

p := Person{Name: "AsMuin", Age: 18} // 具名字段初始化,推荐
p2 := Person{"Tom", 20} // 按顺序初始化,不推荐(字段增减时易错)
fmt.Println(p.Name) // 通过 . 访问字段

结构体也是值类型

与数组一样,结构体赋值和传参都是值拷贝

a := Person{Name: "A", Age: 1}
b := a // 完整拷贝
b.Age = 100 // 不影响 a

如果希望在函数内修改原始结构体,或避免大结构体的复制开销,应使用指针。

func birthday(p *Person) {
p.Age++ // 通过指针修改,Go 允许直接用 . 访问指针指向的字段
}

p := Person{Name: "AsMuin", Age: 18}
birthday(&p) // 传入地址
// p.Age 变为 19

匿名字段与嵌入

结构体支持嵌入(embedding),被嵌入类型的字段和方法会被提升到外层,是 Go 实现"组合优于继承"的方式。

type Animal struct {
Name string
}

type Dog struct {
Animal // 匿名嵌入
Breed string
}

d := Dog{Animal: Animal{Name: "旺财"}, Breed: "柴犬"}
fmt.Println(d.Name) // 直接访问被提升的 Animal.Name

字符串中的 byte 与 rune

这是最容易踩坑的地方。要理解 byterune 的区别,先要明确一点:Go 的字符串本质上是一段只读的 UTF-8 编码字节序列。

  • byteuint8 的别名,表示一个字节
  • runeint32 的别名,表示一个 Unicode 码点(一个字符)

对于 ASCII 字符(如英文字母、数字),一个字符恰好占一个字节,byterune 看起来没什么区别。但对于中文等字符,一个字符在 UTF-8 下往往占 3 个字节,这时差异就显现出来了。

s := "你好Go"

// len() 返回的是字节数,不是字符数
fmt.Println(len(s)) // 8 (每个中文占 3 字节,共 6,加上 "Go" 2 字节)

// 直接用下标访问得到的是 byte(一个字节),而非完整字符
fmt.Println(s[0]) // 228("你" 的第一个字节,是个数字)

按字符正确遍历

如果直接用 for i := 0; i < len(s); i++ 遍历,每次拿到的是一个字节,中文会被拆坏。正确的做法是使用 for range,它会自动按 UTF-8 解码,每次返回一个 rune

s := "你好Go"

// for range 按字符遍历,i 是字节起始索引,r 是 rune
for i, r := range s {
fmt.Printf("索引 %d: 字符 %c (码点 %d)\n", i, r, r)
}
// 索引 0: 字符 你
// 索引 3: 字符 好
// 索引 6: 字符 G
// 索引 7: 字符 o

注意上面的索引是 0, 3, 6, 7 而非连续,因为每个中文占 3 个字节。

统计字符数量

要正确统计字符(而非字节)数量,可以将字符串转为 []rune

s := "你好Go"
fmt.Println(len(s)) // 8,字节数
fmt.Println(len([]rune(s))) // 4,字符数

三者之间的转换

s := "hello"

// string 转字节切片
bs := []byte(s) // [104 101 108 108 111]

// string 转字符(rune)切片
rs := []rune(s) // [104 101 108 108 111]

// 反向转换
s1 := string(bs) // "hello"
s2 := string(rs) // "hello"

// 单个 rune 转字符串
fmt.Println(string(rune(65))) // "A"

小结:len(string) 是字节数;下标 s[i] 取到的是 bytefor range[]rune 才是按字符处理。处理含中文/多语言文本时,务必用 rune 相关操作,避免把字符拆坏。