
前面我们已经了解了 Go 程序的基本结构、变量声明以及常量的基本写法。接下来开始学习 Go 语言中非常重要的一部分:数据类型。
很多初学者会把数据类型理解成 int、string、bool 这一串需要背诵的名字。这个理解不完整。按照 Go 官方语言规范 的定义,一个类型不仅决定一组值,还决定这些值能够执行哪些操作、拥有哪些方法。
简单理解:数据类型同时规定了"变量里面能放什么"和"这个变量可以做什么"。
例如:
int可以参与加、减、乘、除等算术运算;bool只能表示true和false,不能拿来和整数相加;string可以拼接、比较和读取字节,但是不能直接修改其中某一个字节;slice可以追加元素,但只能和nil比较,不能直接比较两个切片的内容;map可以根据键查询值,但它的键类型必须支持比较。
Go 是一门静态类型、强类型 语言。**变量的类型在编译阶段就已经确定,不同类型之间通常不会自动完成隐式转换。**这样的规则看起来严格,却能把大量错误提前拦截在编译阶段。
本章不会只罗列类型名称。我们会按照"官方定义 → 实际代码 → 运行结果 → 原因解释"的顺序,把 Go 数据类型真正讲清楚。
Go 的数据类型可以先分成下面几组:
| 分类 | 主要类型 | 核心作用 |
|---|---|---|
| 布尔类型 | bool |
表示真假状态 |
| 数值类型 | 整数、浮点数、复数 | 完成数值计算 |
| 字符串类型 | string |
保存不可变的字节序列 |
| 复合类型 | 数组、结构体、指针、函数、接口、切片、映射、通道 | 由已有类型组合出更复杂的数据结构 |
本文代码在
go1.27.0 darwin/arm64环境中实际编译运行。涉及int、uint和uintptr大小的输出会受到操作系统与处理器架构影响。
变量的类型如何确定
在逐个学习类型之前,我们先解决一个基础问题: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,它只包含两个值:true 和 false。
布尔值主要用于条件判断、循环控制和状态记录。
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.1、0.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;内置函数 real 和 imag 分别获取实部与虚部。
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
这里 a 和 b 不能直接相乘,因为 complex64 与 complex128 是不同类型。代码先使用 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 个字节:G 和 o 各占 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 == nil 为 true,b == nil 和 c == nil 为 false。
在普通循环、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保存查询结果;ok为true代表键存在,为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 声明后没有显式初始化,所有字段自动获得各自类型的零值:ID 为 0,字符串为空,布尔值为 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
}
如果把摄氏温度、距离、金额、用户编号全部写成普通 float64 或 int64,编译器无法阻止它们被误用。定义独立类型后,错误混用通常会在编译阶段暴露。
类型别名则更适合兼容性重构,例如把公开类型移动到新包但暂时保留旧名称。
类型转换
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 的区别
学习引用类数据时,经常会同时看到 new 和 make。它们不是同一种初始化方式。
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 |
| 协议字段、文件格式 | uint16、int32 等固定宽度类型 |
使用位宽不固定的 int |
| 普通小数计算 | float64 |
没有内存压力却默认 float32 |
| 金额 | 最小货币单位的整数或十进制定点方案 | 直接使用 float32 / float64 |
| 原始二进制数据 | []byte |
把任意二进制数据当 UTF-8 字符串处理 |
| Unicode 码点处理 | rune、[]rune、for 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
这段输出集中说明了六件事:
- 当前平台的
int是 8 字节,但不能把这个结果当成跨平台保证; - 字符串长度统计字节,
range按 rune 解码并返回起始字节下标; - 浮点变量转整数会截断小数,缩窄整数转换可能改变数值;
- 数组赋值复制元素,切片赋值可能共享底层数组;
- 定义类型拥有独立身份,别名仍然代表原类型;
- Go 中每一种变量声明后都有确定的零值。
到这里,Go 数据类型的核心内容就全部讲解完成了。
官方资料
- The Go Programming Language Specification:Types
- The Go Programming Language Specification:Properties of types and values
- A Tour of Go:Basic types
- A Tour of Go:Zero values
- A Tour of Go:Type conversions
- Go Blog:Strings, bytes, runes and characters in Go
- Go Blog:Go Slices: usage and internals
- Effective Go:Data
- Package builtin