GO [ 数据类型 ]

前面我们已经了解了 Go 程序的基本结构、变量声明以及常量的基本写法。接下来开始学习 Go 语言中非常重要的一部分:数据类型

很多初学者会把数据类型理解成 intstringbool 这一串需要背诵的名字。这个理解不完整。按照 Go 官方语言规范 的定义,一个类型不仅决定一组值,还决定这些值能够执行哪些操作、拥有哪些方法。

简单理解:数据类型同时规定了"变量里面能放什么"和"这个变量可以做什么"。

例如:

  • int 可以参与加、减、乘、除等算术运算;
  • bool 只能表示 truefalse,不能拿来和整数相加;
  • string 可以拼接、比较和读取字节,但是不能直接修改其中某一个字节;
  • slice 可以追加元素,但只能和 nil 比较,不能直接比较两个切片的内容;
  • map 可以根据键查询值,但它的键类型必须支持比较。

Go 是一门静态类型、强类型 语言。**变量的类型在编译阶段就已经确定,不同类型之间通常不会自动完成隐式转换。**这样的规则看起来严格,却能把大量错误提前拦截在编译阶段。

本章不会只罗列类型名称。我们会按照"官方定义 → 实际代码 → 运行结果 → 原因解释"的顺序,把 Go 数据类型真正讲清楚。

Go 的数据类型可以先分成下面几组:

分类 主要类型 核心作用
布尔类型 bool 表示真假状态
数值类型 整数、浮点数、复数 完成数值计算
字符串类型 string 保存不可变的字节序列
复合类型 数组、结构体、指针、函数、接口、切片、映射、通道 由已有类型组合出更复杂的数据结构

本文代码在 go1.27.0 darwin/arm64 环境中实际编译运行。涉及 intuintuintptr 大小的输出会受到操作系统与处理器架构影响。

变量的类型如何确定

在逐个学习类型之前,我们先解决一个基础问题:Go 到底在什么时候确定变量类型?

常见的变量声明方式一共有三种:

Go 复制代码
var age int = 18
var price = 19.9
name := "rose"

第一种写法显式指定类型;后两种写法没有写类型,编译器会根据右侧表达式推断类型。

我们编写代码查看结果。

type_inference.go

Go 复制代码
package main

import "fmt"

func main() {
	var age int = 18
	var price = 19.9
	name := "rose"
	passed := true

	fmt.Printf("age: %T\n", age)
	fmt.Printf("price: %T\n", price)
	fmt.Printf("name: %T\n", name)
	fmt.Printf("passed: %T\n", passed)
}

T == Type

执行代码:

Go 复制代码
age: int
price: float64
name: string
passed: bool

这里使用了 fmt.Printf%T 占位符,它可以打印变量的实际类型。

代码中 19.9 是一个没有显式类型的浮点常量。当它被赋值给变量 price 时,Go 使用浮点常量的默认类型 float64。同理,无类型整数常量默认变成 int,布尔常量默认变成 bool,字符串常量默认变成 string

Go 官方规范为无类型常量规定的默认类型如下:

常量类别 默认类型
布尔常量 bool
rune 常量 rune,也就是 int32
整数常量 int
浮点常量 float64
复数常量 complex128
字符串常量 string

这里必须区分两个概念:常量的值可以是无类型的,但变量一定有确定的静态类型。

布尔类型

布尔类型的关键字是 bool,它只包含两个值:truefalse

布尔值主要用于条件判断、循环控制和状态记录

Go 复制代码
package main

import "fmt"

func main() {
	age := 20
	isAdult := age >= 18
	hasTicket := true
	canEnter := isAdult && hasTicket

	fmt.Println("isAdult:", isAdult)
	fmt.Println("canEnter:", canEnter)
	fmt.Println("not canEnter:", !canEnter)
}

运行结果:

Go 复制代码
isAdult: true
canEnter: true
not canEnter: false

Go 不允许使用整数代替布尔值。下面这种在部分语言中可以工作的代码,在 Go 中会直接编译失败:

Go 复制代码
if 1 {
	fmt.Println("进入分支")
}

编译器会提示:

Go 复制代码
non-boolean condition in if statement

同样,bool 也不能强制转换成 int

Go 复制代码
var passed bool = true
// fmt.Println(int(passed)) // 编译错误:不能把 bool 转换成 int

这一点体现了 Go 的设计态度:真假状态就是真假状态,不使用 0 和 1 暗示业务含义。

整数类型

整数是实际项目中使用频率最高的数值类型。Go 同时提供有符号整数、无符号整数以及与机器架构相关的整数。

有符号整数

有符号整数可以保存负数、零和正数。

类型 位数 取值范围
int8 8 -128 ~ 127
int16 16 -32768 ~ 32767
int32 32 -2147483648 ~ 2147483647
int64 64 -9223372036854775808 ~ 9223372036854775807

Go 规范规定,有符号整数使用二进制补码表示。

无符号整数

无符号整数不能保存负数,因此可以把全部二进制位用于保存非负数。

类型 位数 取值范围
uint8 8 0 ~ 255
uint16 16 0 ~ 65535
uint32 32 0 ~ 4294967295
uint64 64 0 ~ 18446744073709551615

此外还有三个特殊整数类型:

  • int:位数由实现决定,可能是 32 位,也可能是 64 位;
  • uint:与当前平台上的 int 宽度相同;
  • uintptr:能够保存一个指针值中未经解释的二进制位,主要配合 unsafe 包使用。【用来做指针算术(地址偏移),Go 正常语法不允许指针加减。】

很多人会把 int 直接理解成 int64,这是一个常见误区。它们在 64 位机器上通常占用相同空间,但仍然是两个不同的命名类型。

我们通过 unsafe.Sizeof 查看当前环境中整数类型占用的字节数。

integer_size.go

Go 复制代码
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	fmt.Printf("int     占用 %d 字节\n", unsafe.Sizeof(int(0)))
	fmt.Printf("int32   占用 %d 字节\n", unsafe.Sizeof(int32(0)))
	fmt.Printf("int64   占用 %d 字节\n", unsafe.Sizeof(int64(0)))
	fmt.Printf("uintptr 占用 %d 字节\n", unsafe.Sizeof(uintptr(0)))
}

当前 arm64 环境的运行结果:

bash 复制代码
int     占用 8 字节
int32   占用 4 字节
int64   占用 8 字节
uintptr 占用 8 字节

这里再次强调:输出 8 字节是当前环境的结果,不是 Go 语言承诺 int 永远为 64 位。

整数字面量

Go 支持二进制、八进制、十进制和十六进制整数。较长的数字还可以使用下划线分组,下划线不会改变数值。

Go 复制代码
package main

import "fmt"

func main() {
	decimal := 100
	binary := 0b1100100
	octal := 0o144
	hexadecimal := 0x64
	population := 1_400_000_000

	fmt.Println(decimal, binary, octal, hexadecimal)
	fmt.Println(population)
}

运行结果:

bash 复制代码
100 100 100 100
1400000000

四个变量的书写形式不同,但它们表示的数值完全相同。

什么时候使用 int,什么时候使用固定宽度整数

普通计数、循环索引、切片长度一般优先使用 int。原因是 Go 内置的 len 和 cap 返回 int,数组与切片下标也天然适合使用 int。

下面这些场景更适合固定宽度整数:

  • 网络协议明确规定字段为 16 位或 32 位;
  • 二进制文件格式规定字段宽度;
  • 与数据库字段或外部系统严格对齐;
  • 需要位运算并关心溢出边界;
  • 对内存布局有明确要求。

uintptr 不应该拿来做普通整数运算。它的主要用途是底层编程,并且把指针转换成 uintptr 后,垃圾回收器不会把这个整数当作有效指针继续追踪。

浮点类型

Go 提供两种浮点类型:

类型 标准 常见有效精度
float32 IEEE 754 32 位浮点数 大约 7 位十进制有效数字
float64 IEEE 754 64 位浮点数 大约 15~16 位十进制有效数字

在没有特殊内存或外部格式要求时,通常优先使用 float64。Go 中无类型浮点常量的默认类型也是 float64,标准库 math 包的大部分函数同样接收和返回 float64

Go 复制代码
package main

import "fmt"

func main() {
	var f32 float32 = 1.23456789
	var f64 float64 = 1.23456789
	a, b := 0.1, 0.2

	fmt.Printf("float32: %.10f\n", f32)
	fmt.Printf("float64: %.10f\n", f64)
	fmt.Printf("0.1 + 0.2 = %.20f\n", a+b)
}

运行结果:

bash 复制代码
float32: 1.2345678806
float64: 1.2345678900
0.1 + 0.2 = 0.30000000000000004441

float32 能保存的有效位数更少,因此赋值时已经发生舍入。0.10.2 这类十进制小数无法用有限长度的二进制浮点数精确表示,所以计算结果可能出现非常小的误差。

这会带来一个实战结论:金额不要直接使用浮点数保存。

例如 19.99 元可以保存成 1999 分,使用整数完成计算;需要任意精度十进制时,应使用经过验证的十进制定点方案或专用库。

浮点数也不适合直接使用 == 判断经过多次计算后的结果。常见做法是比较二者差值是否小于允许误差:

Go 复制代码
package main

import (
	"fmt"
	"math"
)

func almostEqual(a, b, epsilon float64) bool {
	return math.Abs(a-b) < epsilon
}

func main() {
	x, y := 0.1, 0.2
	a := x + y
	b := 0.3
	fmt.Println(almostEqual(a, b, 1e-9))
}

运行结果:

bash 复制代码
true

复数类型

Go 在语言层面直接提供复数类型:

  • complex64:实部和虚部都是 float32
  • complex128:实部和虚部都是 float64

可以直接使用 1 + 2i 创建复数,也可以调用内置函数 complex;内置函数 realimag 分别获取实部与虚部。

Go 复制代码
package main

import "fmt"

func main() {
	var a complex64 = 1 + 2i
	b := complex(3.0, 4.0)
	c := a * complex64(b)

	fmt.Printf("a=%v, 类型=%T\n", a, a)
	fmt.Printf("b=%v, 类型=%T\n", b, b)
	fmt.Printf("c=%v, 实部=%.1f, 虚部=%.1f\n", c, real(c), imag(c))
}

运行结果:

Go 复制代码
a=(1+2i), 类型=complex64
b=(3+4i), 类型=complex128
c=(-5+10i), 实部=-5.0, 虚部=10.0

这里 ab 不能直接相乘,因为 complex64complex128 是不同类型。代码先使用 complex64(b) 完成显式转换,再进行运算。

复数在普通 Web 业务中并不常见,但在信号处理、傅里叶变换、科学计算等场景中非常有用。

byte、rune 与字符串

字符串是 Go 数据类型中最容易产生误解的部分。

按照 Go 字符串类型规范,一个字符串值是一个可能为空的字节序列字符串创建以后内容不可修改len 返回的是字节数,而不是人眼看到的字符数量。

byte 和 rune 到底是什么

Go 预声明了两个常用别名:

Go 复制代码
type byte = uint8
type rune = int32

因此:

  • byte 不是一种全新的类型,它就是 uint8 的别名,通常用于强调当前数值代表原始字节;
  • rune 也不是全新的类型,它就是 int32 的别名,通常用于强调当前数值代表 Unicode 码点。

一个英文字符在 UTF-8 中通常占用 1 个字节,一个中文字符通常占用 3 个字节。我们用代码验证。

string.go

Go 复制代码
package main

import (
	"fmt"
	"unicode/utf8"
)

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

	fmt.Printf("字符串:%q\n", s)
	fmt.Printf("字节数:%d\n", len(s))
	fmt.Printf("Unicode 码点数:%d\n", utf8.RuneCountInString(s))

	for index, r := range s {
		fmt.Printf("字节下标=%d rune=%q Unicode=%U\n", index, r, r)
	}
}

运行结果:

bash 复制代码
字符串:"Go语言"
字节数:8
Unicode 码点数:4
字节下标=0 rune='G' Unicode=U+0047
字节下标=1 rune='o' Unicode=U+006F
字节下标=2 rune='语' Unicode=U+8BED
字节下标=5 rune='言' Unicode=U+8A00

字符串一共包含 4 个 Unicode 码点,却占用 8 个字节:Go 各占 1 字节, 各占 3 字节。

还要注意 range 返回的 index 是每个 UTF-8 编码序列的起始字节下标 ,因此结果是 0、1、2、5,并不是连续的 0、1、2、3

Go 官方博客 Strings, bytes, runes and characters in Go 对这套关系做了完整解释:字符串保存字节;UTF-8 是一种编码方式;rune 表示 Unicode 码点。这三个概念不能混在一起。

使用下标访问字符串

通过下标访问字符串,得到的是一个 byte,不是完整的 Unicode 字符。

Go 复制代码
package main

import "fmt"

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

	fmt.Printf("s[0]=%d, 十六进制=%x, 类型=%T\n", s[0], s[0], s[0])
	fmt.Printf("[]byte(s)=% x\n", []byte(s))
	fmt.Printf("[]rune(s)=%U\n", []rune(s))
}

运行结果:

bash 复制代码
s[0]=232, 十六进制=e8, 类型=uint8
[]byte(s)=e8 af ad e8 a8 80
[]rune(s)=[U+8BED U+8A00]

s[0] 只是"语"这个汉字 UTF-8 编码的第一个字节 0xe8,并不是完整字符。

因此处理二进制协议、文件原始内容、哈希数据时使用 []byte;按 Unicode 码点处理自然语言文本时使用 []rune 或直接使用 for range。【重点】

字符串不可变

下面的代码无法通过编译:

Go 复制代码
s := "hello"
// s[0] = 'H' // 编译错误:cannot assign to s[0]

如果确实需要修改,可以转换成字节切片或 rune 切片,修改完成后再转换回字符串。

Go 复制代码
package main

import "fmt"

func main() {
	s := "Go语言"
	runes := []rune(s)
	runes[2] = '编'
	s = string(runes)

	fmt.Println(s)
}

运行结果:

bash 复制代码
Go编言

数组

数组是固定长度、元素类型相同的连续序列。

数组类型的语法如下:

Go 复制代码
[长度]元素类型

例如 [3]int 表示长度为 3、元素类型为 int 的数组。

Go 复制代码
package main

import "fmt"

func main() {
	var scores [3]int
	scores[0] = 90
	scores[1] = 85
	scores[2] = 100

	colors := [3]string{"red", "green", "blue"}
	primes := [...]int{2, 3, 5, 7}

	fmt.Println(scores)
	fmt.Println(colors)
	fmt.Printf("primes=%v, 类型=%T, 长度=%d\n", primes, primes, len(primes))
}

运行结果:

Go 复制代码
[90 85 100]
[red green blue]
primes=[2 3 5 7], 类型=[4]int, 长度=4

[...]int 让编译器根据初始化元素数量推导数组长度,最终类型仍然是 [4]int。

这里有一个非常重要的规则:数组长度属于类型的一部分。

[3]int[4]int 是两个不同类型,不能直接赋值:

Go 复制代码
var a [3]int
var b [4]int
// a = b // 编译错误:不能把 [4]int 赋值给 [3]int

数组赋值会复制全部元素。把数组传给函数时,默认也会复制整个数组。【这里要注意,不要直接一遍过, 这里也是可以考量性能的细节】

Go 复制代码
a1 := [3]int{1, 2, 3}
a2 := a1
a2[0] = 99

fmt.Println(a1)
fmt.Println(a2)

运行结果:

Go 复制代码
[1 2 3]
[99 2 3]

修改 a2 不会影响 a1。数组具有明确固定的长度,适合协议字段、哈希值、矩阵等长度本身具有业务含义的场景;普通动态列表更常使用切片。

切片

切片写作 []T。它不是没有长度的数组,而是对某个底层数组连续区间的描述。【动态数组】

按照 Go 切片规范,切片包含长度和容量;未初始化切片的值为 nil。Go 官方文章 Go Slices: usage and internals 进一步解释了切片与底层数组之间的关系。

Go 复制代码
package main

import "fmt"

func main() {
	var nilSlice []int
	numbers := []int{10, 20, 30}
	numbers = append(numbers, 40)

	fmt.Printf("nilSlice=%v len=%d cap=%d nil=%t\n",
		nilSlice, len(nilSlice), cap(nilSlice), nilSlice == nil)
	fmt.Printf("numbers=%v len=%d cap=%d\n",
		numbers, len(numbers), cap(numbers))
}

一次实际运行结果:

Go 复制代码
nilSlice=[] len=0 cap=0 nil=true
numbers=[10 20 30 40] len=4 cap=6

append 可能复用原底层数组,也可能分配一个更大的新数组,因此容量增长结果属于实现细节,不应该在业务代码中假设每次一定翻倍。

切片赋值不会复制元素

数组赋值复制全部元素,而切片赋值只复制切片描述信息。两个切片可能指向同一个底层数组。

Go 复制代码
package main

import "fmt"

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

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

运行结果:

Go 复制代码
a: [99 2 3]
b: [99 2 3]

修改 b[0] 后,a[0] 也发生改变,因为两个切片共享底层数组。

需要独立副本时,可以使用 copy

Go 复制代码
a := []int{1, 2, 3}
b := make([]int, len(a))
copy(b, a)
b[0] = 99

fmt.Println(a) // [1 2 3]
fmt.Println(b) // [99 2 3]

nil 切片与空切片

Go 复制代码
var a []int
b := []int{}
c := make([]int, 0)

三者的长度都是 0,并且都可以直接使用 append。但是 a == niltrueb == nilc == nilfalse

在普通循环、append 和 len 操作中,它们通常没有区别;序列化成 JSON 或与外部接口交互时,却可能分别表现为 null 和 [],需要根据接口约定选择。【细节需要注意】可以看成就是底层分布分配一个空的空间而已,有分配当然就有具体的实际地址了!

map 映射

映射使用键快速查找值,类型写作:

Go 复制代码
map[键类型]值类型
Go 复制代码
package main

import "fmt"

func main() {
	scores := map[string]int{
		"Alice": 95,
		"Bob":   88,
	}

	scores["Rose"] = 100
	value, ok := scores["Tom"]

	fmt.Println(scores["Alice"])
	fmt.Printf("Tom: value=%d exists=%t\n", value, ok)

	delete(scores, "Bob")
	fmt.Println("Bob exists:", scores["Bob"] != 0)
}

运行结果:

Go 复制代码
95
Tom: value=0 exists=false
Bob exists: false

查询不存在的键不会抛出异常,而是返回值类型的零值 。这里 scores["Tom"] 返回 0,但无法只凭 0 判断这个键不存在,因为字典中完全可能真的保存一个 0 分。

因此 Go 提供了"逗号 ok"写法:

Go 复制代码
value, ok := scores["Tom"]
  • value 保存查询结果;
  • oktrue 代表键存在,为 false 代表键不存在。

上面通过 scores["Bob"] != 0 判断键是否存在,只是特意展示一个错误思路。删除后应该这样检查:

Go 复制代码
_, ok = scores["Bob"]
fmt.Println("Bob exists:", ok)

map 的键为什么有限制

map 的键必须支持 ==!= 比较 。布尔、数值、字符串、指针、通道、接口,以及元素或字段都可比较的数组和结构体,都可以作为 map 的

切片、map 和函数不能作为 map 键:

Go 复制代码
// invalid := map[[]int]string{} // 编译错误:invalid map key type []int

nil map 可以读,不能写

Go 复制代码
var m map[string]int
fmt.Println(m["missing"]) // 读取正常,得到 0
// m["answer"] = 42      // 运行时 panic:assignment to entry in nil map

需要写入 map 时,必须先使用字面量或 make 初始化:

Go 复制代码
m := make(map[string]int)
m["answer"] = 42

此外,map 的遍历顺序没有保证。不要根据一次运行中看到的顺序编写业务逻辑;需要稳定顺序时,应先取出键并排序。

结构体

结构体把多个具名字段组合成一个整体,各字段可以拥有不同类型。

Go 复制代码
package main

import "fmt"

type User struct {
	ID       int
	Name     string
	Email    string
	IsActive bool
}

func main() {
	u1 := User{
		ID:       1,
		Name:     "Rose",
		Email:    "rose@example.com",
		IsActive: true,
	}

	var u2 User
	u2.Name = "Tom"

	fmt.Printf("u1=%+v\n", u1)
	fmt.Printf("u2=%+v\n", u2)
}

运行结果:

Go 复制代码
u1={ID:1 Name:Rose Email:rose@example.com IsActive:true}
u2={ID:0 Name:Tom Email: IsActive:false}

u2 声明后没有显式初始化,所有字段自动获得各自类型的零值:ID0,字符串为空,布尔值为 false

结构体是值类型,赋值时会复制字段:

Go 复制代码
u3 := u1
u3.Name = "Jack"

fmt.Println(u1.Name) // Rose
fmt.Println(u3.Name) // Jack

如果结构体所有字段都支持比较,那么整个结构体也支持 == 和 !=。一旦包含切片、map 或函数字段,结构体就不能直接比较。

结构体字面量推荐使用字段名初始化。相比 User{1, "Rose", "rose@example.com", true},带字段名的写法不依赖字段顺序,新增字段时也更安全。【类似上面的示例】

指针

指针保存某个变量的内存地址。 *T 表示指向 T 类型值的指针,&x 获取变量 x 的地址,*p 访问指针 p 指向的值。

Go 复制代码
package main

import "fmt"

func addOne(n *int) {
	*n++
}

func main() {
	value := 10
	p := &value

	fmt.Println("修改前:", value, *p)
	addOne(p)
	fmt.Println("修改后:", value, *p)
}

运行结果:

Go 复制代码
修改前: 10 10
修改后: 11 11

addOne 接收 *int,通过解引用修改原变量。

表达式 *n++ 按 Go 的语法解析为 (*n)++,表示让指向的整数自增,不是让指针移动。Go 不支持像 C 那样进行普通指针算术,这减少了越界访问与悬空指针问题。

指针的零值是 nil。解引用 nil 指针会触发运行时 panic:

Go 复制代码
var p *int
// fmt.Println(*p) // panic: invalid memory address or nil pointer dereference

使用指针通常有两个目的:

  • 让函数或方法能够修改调用方持有的值;【业务】
  • 避免复制较大的结构体。【性能】

不要因为"指针看起来更高效"就让所有参数都变成指针。小型只读值直接传值往往更清晰,而且是否逃逸到堆上由编译器分析决定,并不是用了 new 或指针就必然更快。

函数类型

函数本身也是一种类型。只要参数列表和返回值列表一致,函数就拥有相同的函数类型。

Go 复制代码
package main

import "fmt"

type Operation func(int, int) int

func calculate(a, b int, op Operation) int {
	return op(a, b)
}

func main() {
	add := func(a, b int) int {
		return a + b
	}

	multiply := func(a, b int) int {
		return a * b
	}

	fmt.Println(calculate(3, 4, add))
	fmt.Println(calculate(3, 4, multiply))
}

运行结果:

bash 复制代码
7
12

Operation 是一个命名函数类型。calculate 不关心具体运算逻辑,只要求传入的函数符合 func(int, int) int 签名。

函数值可以赋值、作为参数传递,也可以作为返回值。函数只能和 nil 比较,不能比较两个函数是否相等。

函数的零值同样是 nil,调用 nil 函数会引发 panic,因此调用前可以进行判断:

Go 复制代码
var op Operation
if op != nil {
	op(1, 2)
}

接口类型

接口定义一组方法。一个具体类型只要实现接口要求的全部方法,就自动实现该接口,不需要显式写出 implements

Go 复制代码
package main

import "fmt"

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + ": 汪汪"
}

func say(s Speaker) {
	fmt.Println(s.Speak())
}

func main() {
	dog := Dog{Name: "旺财"}
	say(dog)
}

运行结果:

Go 复制代码
旺财: 汪汪

接口变量需要从两个层面理解:

  • 静态类型:代码中声明的接口类型,例如 Speaker
  • 动态类型与动态值:运行时真正装入接口中的具体类型和值,例如 Dog 和对应对象。

nil 接口与装有 nil 指针的接口

这是 Go 接口最经典的坑之一。

Go 复制代码
package main

import "fmt"

type MyError struct{}

func (*MyError) Error() string {
	return "something went wrong"
}

func main() {
	var p *MyError = nil
	var err error = p

	fmt.Println("p == nil:", p == nil)
	fmt.Println("err == nil:", err == nil)
	fmt.Printf("err 的动态类型:%T\n", err)
}

运行结果:

Go 复制代码
p == nil: true
err == nil: false
err 的动态类型:*main.MyError

err 不等于 nil,因为它已经保存了动态类型 *main.MyError,只是这个动态类型对应的值为 nil。

可以把接口想成"动态类型 + 动态值"这一对信息。只有二者都不存在时,接口本身才等于 nil。实际项目中,返回 error 时不要把一个具体的 nil 指针装进接口后再返回。

any 是 interface{} 的别名,可以保存任何类型的值,但取出具体值时通常需要类型断言或类型 switch。不要为了省事把所有字段都写成 any,否则编译期类型检查会被推迟到运行阶段

通道类型

通道 chan 用于在 goroutine 之间 传递指定类型的值,并完成同步。

Go 复制代码
package main

import "fmt"

func worker(result chan<- int) {
	result <- 21 * 2
}

func main() {
	result := make(chan int)
	go worker(result)

	value := <-result
	fmt.Println(value)
}

运行结果:

Go 复制代码
42

通道方向可以写入类型:

  • chan T:既能发送,也能接收;
  • chan<- T:只能发送;
  • <-chan T:只能接收。

示例中的 worker 接收 chan<- int,从函数签名就能看出它只负责发送结果。

未初始化通道的零值是 nil。向 nil 通道发送或从 nil 通道接收都会永久阻塞;关闭 nil 通道会 panic。因此通道通常使用 make 创建。

通道适合表达 goroutine 之间的数据流和同步关系,不应该仅仅为了"存几个元素"代替切片或队列。

类型定义与类型别名

Go 中下面两种写法只差一个等号,含义却完全不同:

Go 复制代码
type Celsius float64
type Temperature = float64

第一行创建一个新的定义类型 Celsius;第二行只是给 float64 增加别名 Temperature

type_definition.go

Go 复制代码
package main

import "fmt"

type Celsius float64
type Temperature = float64

func main() {
	var c Celsius = 36.5
	var t Temperature = 36.5

	fmt.Printf("c=%v, 类型=%T\n", c, c)
	fmt.Printf("t=%v, 类型=%T\n", t, t)

	var raw float64 = t
	fmt.Printf("raw=%v, 类型=%T\n", raw, raw)

	var converted float64 = float64(c)
	fmt.Printf("converted=%v, 类型=%T\n", converted, converted)
}

运行结果:

Go 复制代码
c=36.5, 类型=main.Celsius
t=36.5, 类型=float64
raw=36.5, 类型=float64
converted=36.5, 类型=float64

Temperature 与 float64 是同一个类型,因此可以直接赋值;Celsius 是独立的命名类型,必须显式转换成 float64。

新定义类型的价值在于表达业务语义,并且可以绑定方法:

Go 复制代码
type Celsius float64

func (c Celsius) Fahrenheit() float64 {
	return float64(c)*9/5 + 32
}

如果把摄氏温度、距离、金额、用户编号全部写成普通 float64int64,编译器无法阻止它们被误用。定义独立类型后,错误混用通常会在编译阶段暴露。

类型别名则更适合兼容性重构,例如把公开类型移动到新包但暂时保留旧名称。

类型转换

Go 不提供普通数值类型之间的隐式转换。 即使 int32 与当前机器上的 int 恰好占用相同空间,它们仍然是不同类型。

Go 复制代码
var a int = 10
var b int32 = 20

// fmt.Println(a + b) // 编译错误:mismatched types int and int32
fmt.Println(a + int(b))

类型转换的基本语法是:

Go 复制代码
目标类型(表达式)

常量转换与变量转换并不完全相同

我们先写一段看起来很自然的代码:

Go 复制代码
fmt.Println(int(9.8))

在 Go 1.27 中编译会得到:

Go 复制代码
cannot convert 9.8 (untyped float constant) to type int

原因是 9.8 是无类型常量。 Go 规范要求把常量转换到目标类型时,该值必须能够由目标类型表示,而整数类型无法精确表示 9.8。

如果先把它保存到浮点变量中,再执行转换,代码可以运行:

Go 复制代码
package main

import "fmt"

func main() {
	x := 9.8
	fmt.Println(int(x))
}

运行结果:

bash 复制代码
9

浮点变量转换为整数时会丢弃小数部分,不是四舍五入。

缩窄转换可能改变数值

Go 复制代码
package main

import "fmt"

func main() {
	n := 130
	fmt.Println(int8(n))
}

运行结果:

bash 复制代码
-126

int8 的最大值是 127。把运行时整数变量 130 转换成 int8 时,高位被截断,最终得到 -126

如果直接写成常量转换:

Go 复制代码
// fmt.Println(int8(130))

编译器会报错,因为常量 130 超出了 int8 的可表示范围。

这说明显式转换只代表"程序员要求执行转换",不代表结果一定安全。处理外部数据时,转换前应该检查边界。

Go 复制代码
if n >= -128 && n <= 127 {
	value := int8(n)
	fmt.Println(value)
}

整数转字符串不是格式化

Go 复制代码
fmt.Println(string(65)) // A

string(65) 把整数 65 当成 Unicode 码点 U+0041,因此结果是 "A",并不是字符串 "65"

把数字格式化成十进制文本,应使用 strconv

Go 复制代码
text := strconv.Itoa(65)
fmt.Println(text) // 65

零值

Go 变量声明后即使没有显式初始化,也一定拥有一个合法值,这个值叫作零值。

类型 零值
布尔类型 false
整数类型 0
浮点类型 0.0
复数类型 0+0i
字符串 ""
数组 每个元素都是零值
结构体 每个字段都是零值
指针、切片、map、函数、接口、通道 nil

zero_value.go

Go 复制代码
package main

import "fmt"

func main() {
	var i int
	var f float64
	var b bool
	var s string
	var p *int
	var list []int
	var dict map[string]int
	var ch chan int
	var fn func()

	fmt.Printf("int=%d float=%.1f bool=%t string=%q\n", i, f, b, s)
	fmt.Printf("pointer=%t slice=%t map=%t chan=%t func=%t\n",
		p == nil, list == nil, dict == nil, ch == nil, fn == nil)
}

运行结果:

Go 复制代码
int=0 float=0.0 bool=false string=""
pointer=true slice=true map=true chan=true func=true

零值不是"没有值",而是该类型在未显式初始化时自动获得的确定值。

好的 Go 类型通常会让零值直接可用。例如 sync.Mutex 的零值就是未加锁状态,可以直接调用;bytes.Buffer 的零值就是一个空缓冲区,也可以立即写入。

但是"拥有零值"不代表"所有零值操作都安全":nil 切片可以读取长度和追加;nil map 可以读取但不能写入;nil 函数不能调用;nil 指针不能解引用;nil 通道上的发送和接收会永久阻塞。

new 与 make 的区别

学习引用类数据时,经常会同时看到 newmake。它们不是同一种初始化方式。

new 分配一个零值变量并返回指针

Go 复制代码
p := new(int)
fmt.Printf("值=%d 类型=%T\n", *p, p)

输出:

Go 复制代码
值=0 类型=*int

new(int) 的结果是 *int,指向一个值为 0 的整数。

make 初始化切片、map 或通道并返回该类型本身

Go 复制代码
s := make([]int, 3)
m := make(map[string]int)
ch := make(chan int, 1)

fmt.Printf("%T %T %T\n", s, m, ch)

输出:

Go 复制代码
[]int map[string]int chan int

make 只用于切片、map 和通道,它返回初始化后的值,不返回指针。

对比 new([]int)make([]int, 0)

Go 复制代码
p := new([]int)       // p 的类型是 *[]int,*p 是 nil 切片
s := make([]int, 0)   // s 的类型是 []int,s 是已初始化的空切片

实际业务中很少需要 new([]int)。对于切片、map、通道,应优先根据所需长度、容量或缓冲大小使用 make

哪些类型可以比较

可比较性会直接影响 ==!= 以及 map 键的使用。

类型 能否使用 == / != 说明
bool、数值、字符串 可以 同类型值之间比较
指针 可以 比较是否指向同一变量,或与 nil 比较
通道 可以 比较是否由同一次 make 创建,或与 nil 比较
数组 有条件可以 元素类型必须可比较
结构体 有条件可以 所有字段必须可比较
接口 可以,但可能 panic 动态类型必须可比较
切片 只能与 nil 比较 不能直接比较两个切片
map 只能与 nil 比较 不能直接比较两个 map
函数 只能与 nil 比较 不能比较两个函数

例如:

Go 复制代码
a := [2]int{1, 2}
b := [2]int{1, 2}
fmt.Println(a == b) // true

// s1 := []int{1, 2}
// s2 := []int{1, 2}
// fmt.Println(s1 == s2) // 编译错误:slice can only be compared to nil

需要比较切片内容 时,可以手动循环,或根据元素类型使用标准库提供的工具,例如 Go 1.21 及之后版本中的 slices.Equal

Go 复制代码
fmt.Println(slices.Equal([]int{1, 2}, []int{1, 2})) // true

接口比较还有额外风险: 如果两个接口的动态类型是不可比较的切片或 map,执行比较会在运行时 panic。接口表面上能写 ==,并不代表里面装入任何值都能安全比较。

数据类型选择建议

学完所有类型之后,我们把项目中常见的选择规则整理成一张速查表。

需求 推荐类型 不建议的做法
普通计数、循环下标 int 没有协议要求却统一使用 int64
协议字段、文件格式 uint16int32 等固定宽度类型 使用位宽不固定的 int
普通小数计算 float64 没有内存压力却默认 float32
金额 最小货币单位的整数或十进制定点方案 直接使用 float32 / float64
原始二进制数据 []byte 把任意二进制数据当 UTF-8 字符串处理
Unicode 码点处理 rune[]runefor range s[i] 当作完整中文字符
固定长度且长度有含义 数组 [N]T 为了方便一律改成切片
普通动态序列 切片 []T 使用数组后频繁复制
键值查询 map[K]V 依赖 map 遍历顺序
组合业务字段 结构体 struct 大量使用 map[string]any 丢失类型检查
修改调用方数据 指针 *T 所有小值都无理由传指针
抽象行为 小接口 预先设计包含大量方法的"大接口"
goroutine 数据流与同步 通道 chan T 把通道当普通容器使用

核心原则可以总结成一句话:优先选择最能表达业务约束的类型,而不是看起来容量最大的类型。

例如年龄不能为负数,是否就应该使用 uint8?不一定。uint8 的算术行为、与 len 返回值交互时的转换成本,以及输入校验需求都要考虑。业务合法性仍然应该通过校验表达,不能只依赖底层类型范围。

常见错误总结

错误一:认为 int 永远等于 int64

int 的大小由实现决定,规范只保证它是 32 位或 64 位。二者即使在当前机器上同为 64 位,也仍然是不同类型。

错误二:认为字符串长度就是字符数量

len(string) 返回字节数。按 Unicode 码点统计可以使用 utf8.RuneCountInString,按用户看到的"字形簇"统计还需要更高层的 Unicode 文本处理逻辑。

错误三:认为数组和切片只是写法不同

数组是包含全部元素的值,长度属于类型;切片是对底层数组区间的描述,长度可以变化。数组赋值复制元素,切片赋值可能共享底层数组。

错误四:认为显式转换一定安全

缩窄整数转换可能截断高位,浮点转整数会丢弃小数部分。显式转换不会自动替你检查业务边界。

错误五:认为所有 nil 都一样

nil 切片能 append,nil map 不能写,nil 通道收发会阻塞,装有 nil 指针的接口可能不等于 nil。必须结合静态类型理解 nil 的行为。

错误六:认为 make 返回指针

make 返回初始化后的切片、map 或通道本身;new(T) 返回 *T,指向一个 T 类型的零值。

完整综合示例

最后编写一段综合代码,把本章最容易混淆的规则放在一起验证。

data_types.go

Go 复制代码
package main

import (
	"fmt"
	"unicode/utf8"
	"unsafe"
)

type Celsius float64
type Temperature = float64

func printZeroValues() {
	var i int
	var f float64
	var b bool
	var s string
	var p *int
	var list []int
	var dict map[string]int
	var ch chan int
	var fn func()

	fmt.Printf("int=%d float=%.1f bool=%t string=%q\n", i, f, b, s)
	fmt.Printf("pointer=%t slice=%t map=%t chan=%t func=%t\n",
		p == nil, list == nil, dict == nil, ch == nil, fn == nil)
}

func main() {
	// 1. 平台相关整数宽度
	fmt.Printf("int=%dB int64=%dB uintptr=%dB\n",
		unsafe.Sizeof(int(0)),
		unsafe.Sizeof(int64(0)),
		unsafe.Sizeof(uintptr(0)))

	// 2. 字节数与 Unicode 码点数
	s := "Go语言"
	fmt.Printf("%q: bytes=%d runes=%d\n",
		s, len(s), utf8.RuneCountInString(s))
	for index, r := range s {
		fmt.Printf("index=%d rune=%q code=%U\n", index, r, r)
	}

	// 3. 运行时数值转换
	x := 9.8
	n := 130
	fmt.Printf("float64 variable 9.8 -> int = %d\n", int(x))
	fmt.Printf("int(130) -> int8 = %d\n", int8(n))

	// 4. 数组复制与切片共享
	a1 := [3]int{1, 2, 3}
	a2 := a1
	a2[0] = 99

	s1 := []int{1, 2, 3}
	s2 := s1
	s2[0] = 99

	fmt.Printf("arrays: %v %v\n", a1, a2)
	fmt.Printf("slices: %v %v\n", s1, s2)

	// 5. 定义类型与别名
	var c Celsius = 36.5
	var t Temperature = 36.5
	fmt.Printf("defined=%T alias=%T\n", c, t)

	// 6. 零值
	printZeroValues()
}

在本文环境中执行:

go run data_types.go

运行结果:

Go 复制代码
int=8B int64=8B uintptr=8B
"Go语言": bytes=8 runes=4
index=0 rune='G' code=U+0047
index=1 rune='o' code=U+006F
index=2 rune='语' code=U+8BED
index=5 rune='言' code=U+8A00
float64 variable 9.8 -> int = 9
int(130) -> int8 = -126
arrays: [1 2 3] [99 2 3]
slices: [99 2 3] [99 2 3]
defined=main.Celsius alias=float64
int=0 float=0.0 bool=false string=""
pointer=true slice=true map=true chan=true func=true

这段输出集中说明了六件事:

  1. 当前平台的 int 是 8 字节,但不能把这个结果当成跨平台保证;
  2. 字符串长度统计字节,range 按 rune 解码并返回起始字节下标;
  3. 浮点变量转整数会截断小数,缩窄整数转换可能改变数值;
  4. 数组赋值复制元素,切片赋值可能共享底层数组;
  5. 定义类型拥有独立身份,别名仍然代表原类型;
  6. Go 中每一种变量声明后都有确定的零值。

到这里,Go 数据类型的核心内容就全部讲解完成了。

官方资料

相关推荐
云浪21 小时前
Go Context 到底是什么?
后端·go
newerp1 天前
系统调用与 M/P 解绑
后端·程序员·go
newerp1 天前
抢占式调度实现细节
后端·程序员·go
小小龙学IT1 天前
Go 语言 net/http 网络编程深度解析:从 Handler 到生产级 HTTP 服务
go
名字还没想好☜1 天前
Go 实现指数退避重试:context 取消、抖动 jitter 与什么时候别重试
后端·golang·go
我不会起名字3221 天前
一天一道力扣Hot100(37):深度优先算法--括号生成
java·数据结构·c++·后端·python·算法·go
ITKEY_1 天前
macOS搭建Go的开发环境
go
我不会起名字3222 天前
一天一道力扣Hot100(36):深度优先算法---组合总和
数据结构·c++·后端·python·算法·go
名字还没想好☜2 天前
Go context.AfterFunc 实战(Go 1.21):context 一取消就自动跑清理,告别手写 goroutine 监听 Done
后端·golang·go