03 类型与零值

Go 类型体系全景:基本类型与取值范围、rune/byte/string 的关系、UTF-8 语义、零值总表、类型转换规则

03 类型与零值

本页回答:有哪些类型、各占多宽、默认值是什么、什么时候必须显式转换。复合类型的操作细节在 07 数组·切片·映射·结构体。

基线:Go 1.27.1 / darwin/arm64(64 位平台)。所有数值均为本机实跑结果。


类型体系全景

Go 的类型分四层:基本类型 → 复合类型 → 引用类型 → 接口类型。搞清这张图,nil 能赋给谁、什么能比较,就都不用背了。

flowchart TB
    T(["类型 Type"])

    T --> BASIC["基本类型"]
    T --> COMP["复合类型"]
    T --> REF["引用语义类型"]
    T --> IFACE["接口类型"]

    BASIC --> B1["布尔 bool"]
    BASIC --> B2["整数<br/>int int8..64<br/>uint uint8..64 uintptr"]
    BASIC --> B3["浮点<br/>float32 float64"]
    BASIC --> B4["复数<br/>complex64 complex128"]
    BASIC --> B5["字符串 string"]
    BASIC --> B6["字符别名<br/>byte=uint8<br/>rune=int32"]

    COMP --> C1["数组 [N]T<br/>值语义"]
    COMP --> C2["结构体 struct{}<br/>值语义"]
    COMP --> C3["指针 *T"]

    REF --> R1["切片 []T"]
    REF --> R2["映射 map[K]V"]
    REF --> R3["通道 chan T"]
    REF --> R4["函数 func()"]

    IFACE --> I1["接口 interface{}"]
    IFACE --> I2["any = interface{}"]

    REF -. "零值是 nil ⚠️" .-> NIL(["nil"])
    IFACE -. "零值是 nil ⚠️" .-> NIL

可以比较(==)的类型:布尔、数值、字符串、指针、通道、接口、数组(元素可比较时)、结构体(字段全可比较时)。 不能比较的类型:切片、映射、函数——只能与 nil 比较 🛑。

1
2
3
var s1, s2 []int
fmt.Println(s1 == nil)   // ✅ true
// fmt.Println(s1 == s2)  // 🛑 编译错误:slice can only be compared to nil

数值类型全表

类型宽度范围默认类型
int88 位−128 ~ 127
int1616 位−32768 ~ 32767
int3232 位−2147483648 ~ 2147483647
int6464 位−9223372036854775808 ~ 9223372036854775807
int平台相关(64 位平台 8 字节)同 int64✅ 整数默认
uint8 / byte8 位0 ~ 255
uint1616 位0 ~ 65535
uint3232 位0 ~ 4294967295
uint6464 位0 ~ 18446744073709551615
uint平台相关同 uint64
uintptr平台相关装指针的无符号整数 ⚠️
float3232 位±3.4028234663852886e+38
float6464 位±1.7976931348623157e+308✅ 浮点默认
complex6464 位float32 实部 + 虚部
complex128128 位float64 实部 + 虚部✅ 复数默认
1
2
fmt.Println("int bits:", unsafe.Sizeof(int(0))*8, "ptr bits:", unsafe.Sizeof(uintptr(0))*8)
fmt.Println(math.MaxInt64, math.MinInt64, math.MaxInt32, math.MaxUint8)
1
2
int bits: 64 ptr bits: 64
9223372036854775807 -9223372036854775808 2147483647 255

常用边界常量(都在 math 包)

常量值(本机实测)
math.MaxInt / math.MinInt9223372036854775807 / -9223372036854775808(平台相关,64 位下同 int64)
math.MaxInt8 / math.MinInt8127 / -128
math.MaxInt16 / math.MinInt1632767 / -32768
math.MaxInt32 / math.MinInt322147483647 / -2147483648
math.MaxInt64 / math.MinInt649223372036854775807 / -9223372036854775808
math.MaxUint8 / 16 / 32 / 64255 / 65535 / 4294967295 / 18446744073709551615
math.MaxUint18446744073709551615(平台相关)
math.MaxFloat323.4028234663852886e+38
math.MaxFloat641.7976931348623157e+308
math.SmallestNonzeroFloat321.401298464324817e-45
math.SmallestNonzeroFloat645e-324

⚠️ math 包里没有 MinUint* 系列——无符号整数下界恒为 0,不需要常量。也没有 MaxComplex,复数用两个 float64 表示。

⚠️ int 不是 int64。写库时如果 API 用了 int,在 32 位平台上就装不下大数;跨平台的持久化字段一律用定宽类型 int64。

该用哪个数值类型

场景用不用
计数、下标、长度int ✅int32/uint
小数、测量值float64 ✅float32(除非为省内存)
字符串的字节byteuint8(少用,语义差)
字符/码点runeint32(少用)
布尔boolint 当标志 ⚠️

💡 int 是平台相关的(64 位平台 8 字节),用它写循环与索引最自然。

场景用原因
数据库字段、API 结构体int64 🔥跨平台稳定,不受 32/64 位影响
二进制协议定宽类型 + encoding/binary字节序明确
金额整数最小单位(分)浮点会累积误差 🛑
IDstring 或 int64UUID 用 string / [16]byte
时间戳int64(Unix 秒/毫秒)跨语言通用

⚠️ int 不是 int64:32 位平台上装不下大数。对外契约一律定宽。

用 uint 的合理场景不合理的场景
位运算、掩码 🔥「这值不可能是负数」
哈希值、校验和循环计数(i-- 会回绕成天文数字 ⚠️)
协议规定的无符号字段长度(标准库都用 int)
1
2
3
4
5
6
// 🛑 无符号倒序循环会死循环
var i uint = 10
for ; i >= 0; i-- { }   // i=0 后 i-- 变成 18446744073709551615

// ✅
for i := 10; i > 0; i-- { }

各类型占多少字节

1
2
3
fmt.Println(unsafe.Sizeof(byte(0)), unsafe.Sizeof(rune(0)), unsafe.Sizeof(int16(0)),
	unsafe.Sizeof(int32(0)), unsafe.Sizeof(int64(0)), unsafe.Sizeof(float32(0)),
	unsafe.Sizeof(float64(0)), unsafe.Sizeof(complex64(0)), unsafe.Sizeof(complex128(0)))
1
1 4 2 4 8 4 8 8 16

string 的头部是 16 字节(1 个指针 + 1 个长度),所以字符串赋值是「拷贝头部、共享底层字节」。

1
string header: 16

rune、byte、string 三者的关系

这张图是 Go 文本处理的全部真相:string 是只读的字节序列,byte 是字节,rune 是码点。

flowchart LR
    S["string<br/>不可变 · UTF-8 字节序列"]
    S -->|"s[i]"| B["byte / uint8<br/>一个字节"]
    S -->|"[]rune(s)"| R["[]rune<br/>码点序列"]
    S -->|"for range"| RC["rune + 起始字节下标"]
    R -->|"string(rs)"| S
    B -->|"string(b)"| S
    S -->|"len(s)"| N1["字节数 ⚠️"]
    R -->|"len(rs)"| N2["字符数 ✅"]

    ALIAS["类型别名"] --> BA["byte = uint8"]
    ALIAS --> RA["rune = int32"]
表达式结果类型含义
s[i]byte第 i 个字节 ⚠️
s[a:b]string字节区间切片(可能切断字符)⚠️
len(s)int字节数 ⚠️
[]rune(s)[]rune码点切片,len 才是字符数
[]byte(s)[]byte字节切片(会发生拷贝)
for i, r := range sint, rune按码点迭代,i 是字节下标 🔥
utf8.RuneCountInString(s)int字符数,不分配内存
utf8.ValidString(s)bool是否为合法 UTF-8
1
2
3
s := "héllo, 世界"
fmt.Println("len(bytes) =", len(s), " len(runes) =", len([]rune(s)))
fmt.Println(len("中文"), utf8.RuneCountInString("中文"))
1
2
len(bytes) = 14  len(runes) = 9
6 2

UTF-8 编码长度规律(记住它就能心算字节数):

码点范围字节数例子
U+0000 ~ U+007F1ASCII、é 之外的拉丁字母
U+0080 ~ U+07FF2é ñ 希腊字母 西里尔字母
U+0800 ~ U+FFFF3中日韩汉字、大部分符号 🔥
U+10000 ~ U+10FFFF4emoji、部分生僻字

拿 "héllo, 世界" 逐字符验证一遍,长度就对上了:

字符héllo,␣世界合计
字节数12111113314
[]rune 个数1111111119

所以 len(s) == 14 而 len([]rune(s)) == 9——和实跑结果一致。💡 现实代码里不要心算,需要字符数就用 utf8.RuneCountInString。

字符串不可变

1
2
3
4
5
6
s := "hello"
// s[0] = 'H'        // 🛑 编译错误:cannot assign to s[0]
b := []byte(s)       // ✅ 转成可变切片(拷贝)
b[0] = 'H'
s2 := string(b)      // 再转回来
fmt.Println(s, s2)   // hello Hello

改字符串的正确姿势:[]byte(要处理字节)或 []rune(要处理字符),改完再转回 string。每次转换都是一次完整拷贝,热路径上要留意(见 14 字符串 的 strings.Builder)。


类型转换:什么时候必须写 T(v)

Go 没有隐式数值转换,这是它和 C/Java 最大的区别之一:不同类型之间运算必须先转换。

flowchart TB
    Q(["需要把 x 变成 T 类型"]) --> Q1{"可赋值(assignable)?<br/>底层类型相同 且 至少一侧不是具名类型"}
    Q1 -- 是 --> IMPL["直接赋值 ✅<br/>type MySlice []int<br/>var s []int; var m MySlice = s"]
    Q1 -- "否(如 int → MyInt,两侧都是具名类型)" --> CONV0["必须显式转换 T(x)"]
    Q1 -- 否 --> Q2{"都是数值类型?"}
    Q2 -- 是 --> CONV["T(x) 显式转换 ✅<br/>可能丢失精度 ⚠️"]
    Q2 -- 否 --> Q3{"string ↔ []byte/[]rune?"}
    Q3 -- 是 --> SCONV["转换即拷贝 ✅"]
    Q3 -- 否 --> Q4{"接口 ↔ 具体类型?"}
    Q4 -- "接口 → 具体" --> ASSERT["类型断言 x.(T)"]
    Q4 -- "具体 → 接口" --> AUTO["自动装箱,无需写 ✅"]
场景写法说明
int → int64int64(i)必须显式
int ↔ float64float64(i) / int(f)浮点转整截断小数
int → stringstrconv.Itoa(i)🛑 不是 string(i)
string → intstrconv.Atoi(s)返回 (int, error)
[]byte → stringstring(b)拷贝
string → []byte[]byte(s)拷贝
具名类型 → 底层类型int(myInt)必须显式
具体类型 → 接口直接赋值自动,无需写

精度丢失的方向

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var f float64 = 3.99
fmt.Println(int(f))       // 截断,不是四舍五入

var neg float64 = -2.7
fmt.Println(int(neg))     // 向零截断,不是 floor

var negInt int = -1
fmt.Println(uint8(negInt)) // 回绕

var n int = 300
fmt.Println(uint8(n))      // 只保留低 8 位

var big int64 = 1 << 40
fmt.Println(int32(big))    // 只保留低 32 位,高位静默丢弃 ⚠️
1
2
3
4
5
3
-2
255
44
0

⚠️ 编译期能查的会报错,运行期不能查的静默截断:

1
2
var _ = uint8(300)      // 🛑 编译错误:constant 300 overflows uint8
var _ = int32(1 << 62)  // 🛑 编译错误:constant 4611686018427387904 overflows int32

但一旦经过变量,编译器就不再检查,直接静默截断/回绕。go vet 也管不了这个,只能靠代码审查和显式范围校验。


零值总表

Go 没有未初始化变量:声明即得零值。这张表要背下来,因为它解释了「为什么 nil map 能读不能写」这类问题。

类型零值可安全使用?
boolfalse✅
所有整数/浮点/复数0 / 0.0 / 0+0i✅
string""✅ 长度 0,可拼接
*T 指针nil❌ 解引用 panic
[N]T 数组每元素各自零值✅
struct{}每字段各自零值✅
[]T 切片nil✅ len=0,可 append 🔥
map[K]Vnil⚠️ 可读、可 len、可 range、可 delete,但不能写
chan Tnil❌ 收发都永久阻塞
func()nil❌ 调用 panic
interface{} / anynil⚠️ nil 接口与 nil 指针不同(见 08)
errornil✅ 判 err != nil 是标准写法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type S struct {
	A int
	B string
	C []int
	D map[string]int
	E *int
	F error
	G any
	H [2]int
	I func()
	J chan int
	K bool
}

var s S
fmt.Printf("%+v\n", s)
1
{A:0 B: C:[] D:map[] E:<nil> F:<nil> G:<nil> H:[0 0] I:<nil> J:<nil> K:false}

nil 容器三兄弟的行为差异

这是初学者最容易翻车的地方,单列出来:

操作nil 切片nil 映射nil 通道
len(x)0 ✅0 ✅0 ✅
cap(x)0 ✅—0 ✅
range x迭代 0 次 ✅迭代 0 次 ✅永久阻塞 ❌
读 x[k]—返回零值 ✅永久阻塞 ❌
写 x[k]=v—panic ❌永久阻塞 ❌
append(x, v)可用 ✅——
delete(x, k)—无操作 ✅—
close(x)——panic ❌
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var c []int
func demo() {
	defer func() { fmt.Println("recover:", recover() != nil) }()

	var c []int
	c = append(c, 1)             // ✅ nil 切片可以直接 append
	fmt.Println(c, len(c), cap(c))

	var m map[string]int
	fmt.Println(m["x"], len(m))  // ✅ 读 nil map 是安全的

	m["x"] = 1                   // ❌ panic: assignment to entry in nil map
}
1
2
3
[1] 1 1
0 0
recover: true

⚠️ 最后一行是 defer 在 panic 展开时执行的输出,所以它出现在所有常规打印之后;m["x"] = 1 之后不会再有代码运行。

💡 实践结论:

  • 切片不需要「先 make 才能用」,直接 var s []int + append 是惯用法,也是返回空结果的推荐做法(见 07 切片)。
  • 映射必须 make 或字面量初始化后才能写。
  • 通道的 nil 阻塞特性其实是有用的:在 select 里把某个 case 的通道设为 nil,就等于永久禁用该分支 🔥(见 12 select)。

浮点数的三个真相

真相一:常量精度高于运行时精度

Go 的无类型常量在编译期用任意精度计算,一旦落到变量上就退化成 IEEE-754:

1
2
3
4
5
fmt.Println("const: 0.1+0.2 == 0.3 ->", 0.1+0.2 == 0.3)

a, b, c := 0.1, 0.2, 0.3
fmt.Println("vars:  0.1+0.2 == 0.3 ->", a+b == c)
fmt.Printf("vars:  %v\n", a+b)
1
2
3
const: 0.1+0.2 == 0.3 -> true
vars:  0.1+0.2 == 0.3 -> false
vars:  0.30000000000000004

⚠️ 这意味着同一段算术,写成常量还是变量,结果可能不同。跨语言对比浮点行为时这是常见的困惑源。

真相二:float32 只有约 7 位有效数字

1
2
x, y, z := float32(0.1), float32(0.2), float32(0.3)
fmt.Println(float64(x+y), x+y == z)
1
0.30000001192092896 true
类型有效十进制位典型误差量级
float32~7 位1e-7
float64~15–16 位1e-16

💡 默认一律用 float64。float32 只在明确为了省内存(大规模数组、图形/ML 数据)时使用。

真相三:浮点比较必须用误差容忍

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 🛑 不要这样比
if a+b == c { }

// ✅ 误差容忍
const eps = 1e-9
if math.Abs((a+b)-c) < eps { }

// ✅ 需要严格语义时用 math 的专用判断
math.IsNaN(f)      // NaN != NaN,必须用它
math.IsInf(f, 0)   // 判断 ±Inf

⚠️ NaN != NaN 恒为真。用 == 判 NaN 永远失败,用 math.IsNaN。同时 NaN 会让 map 的键行为异常(NaN 作键永远取不到),别拿浮点当 map 键。


类型别名 vs 类型定义

两者长得像,语义完全不同:

1
2
3
4
type MyInt int          // 类型定义:新类型,与 int 不通用 ⚠️
type AliasInt = int     // 类型别名:就是 int,完全等价 ✅
type Celsius float64    // 定义:可以给它挂方法
type ID = string       // 别名:ID 和 string 可以互相赋值
flowchart LR
    subgraph DEF["type MyInt int(定义)"]
        D1["MyInt 是独立类型"] --> D2["不能直接赋给 int<br/>需要 int(m)"]
        D2 --> D3["可以定义方法 ✅"]
    end
    subgraph ALI["type AliasInt = int(别名)"]
        A1["AliasInt 与 int 是同一个类型"] --> A2["互相赋值无需转换 ✅"]
        A2 --> A3["不能单独定义方法 ❌<br/>因为方法属于 int"]
    end
用途选择
想给基础类型加方法、加语义类型定义 type Celsius float64 🔥
想给长类型起短名字、渐进式重构类型别名 type ID = string
迁移期让两套名字并存类型别名
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Celsius float64
type Fahrenheit float64

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

c := Celsius(100)
fmt.Println(c.ToF())          // 212
fmt.Println(c + 1.0)          // ✅ 101:1.0 是无类型常量,自动取 Celsius 类型

var f float64 = 1.5
// fmt.Println(c + f)         // 🛑 编译错误:Celsius 与 float64 不通用(两个都是【变量】)
fmt.Println(c + Celsius(f))   // ✅ 显式转换后才能相加

⚠️ 关键是分清「无类型常量」与「变量」:

表达式结果
c + 1.0✅ 合法——1.0 是无类型常量,会取 Celsius 类型
c + f(f 是 float64 变量)🛑 编译错误:两个具名类型不通用
c + Celsius(f)✅ 显式转换后相加

本页陷阱速查

症状实际原因正确做法
m["k"] = 1 panic零值 nil map 不能写m := make(map[string]int)
len("中文") 是 6len 数字节utf8.RuneCountInString 或 len([]rune(s))
s[0] 拿到的字符乱码按字节索引切断了 UTF-8 序列用 for range 或 []rune
string(65) 得到 "A"是码点转换不是格式化strconv.Itoa(65)
int(3.99) 得到 3浮点转整是截断要四舍五入用 math.Round
uint8(negInt) 得到 255无符号回绕,静默发生转换前手动校验范围
a+b == c 浮点比较偶尔为 falseIEEE-754 表示误差误差容忍或 math.Abs
NaN != NaN 导致判断永远为真NaN 语义如此math.IsNaN(f)
type MyInt int 后赋给 int 报错类型定义产生新类型显式 int(v),或改用别名
切片不能 == 比较切片不可比较,只能比 nil用 slices.Equal 🆕 1.21
int 在 32 位平台截断int 宽度平台相关持久化/协议字段用 int64

📘 官方参考:Spec — Types、The Go Blog — Strings, bytes, runes and characters in Go、Go 1.21 builtin min/max/clear

➡️ 上一节:02 词法与语法骨架 | 下一节:04 变量·常量·iota

最后修改 September 21, 2026: 更新 (ac821931b)