数据类型
# 第 3 章 数据类型
Go 的基础类型:固定宽度整数、浮点、字符串、
rune/byte、零值、类型转换的"严格"。 关键词:int/int8/16/32/64、uint、float32/64、string、rune、byte、零值、显式转换
# 目录介绍
- 3.1 本章学习目标
- 3.2 整数类型
- 3.3 浮点数类型
- 3.4 字符串类型
- 3.5 布尔类型
- 3.6 复数类型
- 3.7 类型零值
- 3.8 类型转换的严格性
- 3.9 自定义类型与类型别名
- 3.10 综合示例:UTF-8 字节切片转码
- 3.11 本章底层原理(简介)
- 3.12 Go 新手陷阱 Top 5
- 3.13 思考题
- 3.14 推荐阅读
# 3.1 本章学习目标
- ✅ 区分
int/int32/int64的使用场景,知道哪个宽度依赖平台 - ✅ 理解
string的本质是只读字节切片,不是字符序列 - ✅ 默写 8 大基础类型(
bool/int/uint/float/complex/string/byte/rune)的零值 - ✅ 理解为什么 Go 不允许
int32隐式转int64,要写显式int64(x) - ✅ 区分
byte与rune、UTF-8 字节数与字符数 - ✅ 写得出
string ↔ []byte ↔ []rune三向互转代码并说明拷贝语义 - ✅ 区分自定义类型
type X int与类型别名type X = int的差异
# 3.2 整数类型
Go 的整数类型分两大组:平台依赖(int、uint、uintptr)与固定宽度(int8/16/32/64、uint8/16/32/64)。
# 3.2.1 int 与平台依赖性
int 与 uint 的宽度由 CPU 架构决定:
| 平台 | int 宽度 | uint 宽度 | 取值范围 |
|---|---|---|---|
| 32 位(armv7、386) | 4 字节(32 bit) | 4 字节 | -2³¹ ~ 2³¹-1 |
| 64 位(amd64、arm64) | 8 字节(64 bit) | 8 字节 | -2⁶³ ~ 2⁶³-1 |
package main
import (
"fmt"
"math"
"unsafe"
)
func main() {
var x int
fmt.Printf("int 占 %d 字节,最大值 = %d\n",
unsafe.Sizeof(x), math.MaxInt)
}
2
3
4
5
6
7
8
9
10
11
12
13
在 64 位 macOS / Linux 上输出:
int 占 8 字节,最大值 = 9223372036854775807
💡 何时该用
int、何时不该用?
- 该用:循环计数器、
len()/cap()的返回值(标准库就这么定义的)、本地业务计数。- 不该用:跨网络/磁盘传输的协议字段、需要保证跨平台一致的哈希值。这些场景必须用
int64/uint64,否则同一份数据在 32/64 位机上读出不同结果。
# 3.2.2 固定宽度 int8/16/32/64
无论什么平台,宽度都固定:
| 类型 | 字节 | 位 | 范围 |
|---|---|---|---|
int8 | 1 | 8 | -128 ~ 127 |
int16 | 2 | 16 | -32 768 ~ 32 767 |
int32 | 4 | 32 | -2 147 483 648 ~ 2 147 483 647 |
int64 | 8 | 64 | -9.22e18 ~ 9.22e18 |
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println("int8 :", math.MinInt8, math.MaxInt8)
fmt.Println("int16:", math.MinInt16, math.MaxInt16)
fmt.Println("int32:", math.MinInt32, math.MaxInt32)
fmt.Println("int64:", math.MinInt64, math.MaxInt64)
}
2
3
4
5
6
7
8
9
10
11
12
13
溢出是"环绕"而不是 panic——这一点和 C 一致,与 Python 的"无限大整数"不同:
var x int8 = 127
x++
fmt.Println(x) // -128(环绕)
2
3
如果你需要"溢出即报错",要么自己加判断,要么用 math/bits (opens new window) 的 Add64 / Mul64 等带溢出位的版本。
# 3.2.3 无符号 uint 与 uintptr
无符号整数:uint8/16/32/64、uint、uintptr。
uint8=byte(别名,完全等价)uintptr:能容纳一个指针的整数,用于unsafe包做地址运算。业务代码 99% 用不到。
var b byte = 0xFF // 等价于 uint8
var p uintptr = uintptr(unsafe.Pointer(&b)) // 仅在 unsafe 场景用
2
⚠️ 不要随便用
uint。Go 官方建议除非真的需要"非负"语义(如位运算、哈希、计数器),否则一律用int。原因:
uint之间相减容易"下溢"为巨大正数:var a, b uint = 1, 2; fmt.Println(a-b)输出18446744073709551615。- 与标准库
len()/cap()(返回int)混用要频繁转换。
# 3.2.4 整数字面量与下划线分组
Go 1.13 起字面量支持下划线分组(仅作可读性,不影响值):
const (
Million = 1_000_000 // 一百万
MaxU64 = 0xFFFF_FFFF_FFFF_FFFF
BinMask = 0b1010_1010
Octal = 0o755 // 0o 前缀(建议替代旧的 0755)
)
2
3
4
5
6
四种进制前缀:
| 前缀 | 进制 | 例子 |
|---|---|---|
| 无 | 十进制 | 42 |
0b / 0B | 二进制 | 0b1010 = 10 |
0o / 0O | 八进制 | 0o755 = 493(推荐写法) |
0x / 0X | 十六进制 | 0xFF = 255 |
⚠️ Go 仍接受
0755这种"裸 0"八进制,但gofmt与 lint 推荐0o755,更醒目。
# 3.3 浮点数类型
Go 只提供两种浮点:float32(IEEE 754 单精度,~7 位十进制有效数字)、float64(双精度,~15-17 位有效数字)。
# 3.3.1 float32 vs float64
默认就用 float64,除非内存或带宽极度敏感(图形、音频、嵌入式)。
package main
import "fmt"
func main() {
var a float32 = 0.1 + 0.2
var b float64 = 0.1 + 0.2
fmt.Printf("float32: %.20f\n", a)
fmt.Printf("float64: %.20f\n", b)
}
2
3
4
5
6
7
8
9
10
11
输出:
float32: 0.30000001192092895508
float64: 0.30000000000000004441
2
两个都不是精确的 0.3——这是 IEEE 754 的本质,不是 Go 的 bug。
# 3.3.2 浮点比较的陷阱
❌ 千万别这样写:
if 0.1+0.2 == 0.3 { // false!
fmt.Println("equal")
}
2
3
✅ 正确做法是引入"容差":
import "math"
func almostEqual(a, b, eps float64) bool {
return math.Abs(a-b) <= eps
}
func main() {
fmt.Println(almostEqual(0.1+0.2, 0.3, 1e-9)) // true
}
2
3
4
5
6
7
8
9
容差选多大要看场景:
| 场景 | 推荐 eps |
|---|---|
| 一般业务计算 | 1e-9 |
| 图形 / 物理仿真 | 1e-6 |
| 金融计算 | 不要用浮点,用整数("分"为单位)或 math/big (opens new window) |
# 3.3.3 math.NaN/Inf 与 IEEE 754
浮点的三种"特殊值",Go 都遵循 IEEE 754:
package main
import (
"fmt"
"math"
)
func main() {
posInf := math.Inf(+1) // +∞
negInf := math.Inf(-1) // -∞
nan := math.NaN() // NaN
fmt.Println(1.0/0.0 == posInf) // 编译错:除零是常量除零
var zero float64 // = 0
fmt.Println(1.0/zero == posInf) // true,运行期除零 = +Inf
fmt.Println(nan == nan) // false!NaN 不等于自己
fmt.Println(math.IsNaN(nan)) // true,必须用 IsNaN 判断
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关键事实:
NaN != NaN——意味着用map[float64]X存浮点 key 时,NaN 永远查不到。- 浮点除零不 panic,得到
±Inf或NaN;整数除零会 panic。
# 3.4 字符串类型
Go 的 string 是把"字符串"和"字节流"统一起来的类型,深刻理解它是写好 Go 的基石。
# 3.4.1 字符串本质:只读字节序列
string 在底层是一个只读、不可变的字节切片——可以用一个二元组表达:
string = (ptr *byte, len int) // 16 字节(64 位平台)
package main
import (
"fmt"
"unsafe"
)
func main() {
s := "hello"
fmt.Println(unsafe.Sizeof(s)) // 16(在 64 位机器上)
fmt.Println(len(s)) // 5
fmt.Println(s[0]) // 104,即字节 'h'
// s[0] = 'H' // ❌ 编译错:字符串不可变
}
2
3
4
5
6
7
8
9
10
11
12
13
14
💡 字符串
len()返回的是字节数,不是字符数:s := "你好" fmt.Println(len(s)) // 6(每个汉字 UTF-8 占 3 字节)1
2
# 3.4.2 byte vs rune
| 类型 | 别名 | 含义 | 用途 |
|---|---|---|---|
byte | uint8 | 一个字节 | 处理二进制 / ASCII |
rune | int32 | 一个 Unicode 码点 | 处理"一个字符" |
Go 源码默认 UTF-8 编码,字符串字面量也是 UTF-8 字节流:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "Go语言"
fmt.Println(len(s)) // 8 = 2(Go) + 3(语) + 3(言)
fmt.Println(utf8.RuneCountInString(s)) // 4
// 按字节遍历
for i := 0; i < len(s); i++ {
fmt.Printf("%d ", s[i])
}
fmt.Println()
// 按 rune 遍历(推荐处理 Unicode 文本时用)
for i, r := range s {
fmt.Printf("byte 偏移 %d: %c (%U)\n", i, r, r)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
输出:
8
71 111 232 175 173 232 168 128
byte 偏移 0: G (U+0047)
byte 偏移 1: o (U+006F)
byte 偏移 2: 语 (U+8BED)
byte 偏移 5: 言 (U+8A00)
2
3
4
5
6
注意:for i, r := range s 中 i 是字节偏移而不是字符序号——这是 Go 字符串的核心规则。
# 3.4.3 字符串拼接与 strings.Builder
字符串不可变意味着每次 += 都要分配新内存并拷贝。少量拼接没事,大量循环里就会成为性能杀手。
// ❌ 慢:N 次拼接 = O(N²) 字节拷贝
s := ""
for i := 0; i < 10000; i++ {
s += "a"
}
// ✅ 推荐:strings.Builder(内部维护 []byte 缓冲)
var b strings.Builder
b.Grow(10000) // 可选:预分配避免扩容
for i := 0; i < 10000; i++ {
b.WriteByte('a')
}
s := b.String()
2
3
4
5
6
7
8
9
10
11
12
13
不同方式的性能对照(10 000 次拼接,单核 amd64 实测量级):
| 方式 | 耗时数量级 | 何时用 |
|---|---|---|
s += t | 几十毫秒 | 极少量、固定次数 |
strings.Join | 微秒级 | 已有 []string,一次性合并 |
strings.Builder | 微秒级 | 循环拼接(最常用) |
bytes.Buffer | 微秒级 | 需要 io.Writer 兼容场景 |
fmt.Sprintf | 较慢 | 需要格式化时再用 |
# 3.4.4 原始字符串 ` 与转义
Go 提供两种字符串字面量:
// 1. 解释字符串(双引号):转义字符生效
s1 := "line1\nline2\t\"quoted\""
// 2. 原始字符串(反引号):所见即所得,不解析转义
s2 := `line1\nline2\t"quoted"`
fmt.Println(s2) // line1\nline2\t"quoted"
2
3
4
5
6
原始字符串特别适合:
- 正则表达式:
regexp.MustCompile(\d+.\d+),省去\\d+\\.\\d+的双反斜杠 - 多行 SQL / JSON 模板
- Windows 路径:
`C:\Users\foo`
唯一不能放进原始字符串的就是 ` 自身,需要时只能拆开拼接。
# 3.5 布尔类型
bool 只有 true / false 两个取值,零值是 false。Go 的布尔与整数完全不可互转:
var b bool = true
var i int = int(b) // ❌ 编译错:cannot convert b (type bool) to type int
if 1 { } // ❌ 编译错:non-bool 1 used as if condition
2
3
要在 bool 与 int 之间转换,写函数:
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
2
3
4
5
6
这种"明确大于隐式"的设计避免了 C 里 if (x = 0) 这种"等于打成赋值"的经典 bug——Go 编译器会因为 x = 0 不是 bool 直接拒绝。
# 3.6 复数类型
Go 内置两种复数:complex64(实部虚部各 float32)、complex128(实部虚部各 float64)。
package main
import (
"fmt"
"math/cmplx"
)
func main() {
c := complex(3, 4) // 3 + 4i,类型 complex128
fmt.Println(real(c), imag(c)) // 3 4
fmt.Println(cmplx.Abs(c)) // 5
}
2
3
4
5
6
7
8
9
10
11
12
实际工程里很少用——只在数字信号处理、傅里叶变换、特定数学题里出现。一般业务代码可以忽略这一节。
# 3.7 类型零值
Go 的所有变量声明后自动初始化为零值——这是它"安全"的核心特征之一,避免了 C 里 int x; printf("%d", x); 打印随机内存的灾难。
| 类型 | 零值 |
|---|---|
bool | false |
数值类型(int / float / complex 全部) | 0 |
string | ""(空串,非 nil) |
| 指针 / 接口 / 函数 / channel / map / slice | nil |
| 数组 | 每个元素的零值 |
| 结构体 | 每个字段的零值 |
type Person struct {
Name string
Age int
}
var p Person
fmt.Printf("%+v\n", p) // {Name: Age:0}
2
3
4
5
6
7
💡 零值即可用是 Go 的设计风格——
var b strings.Builder、var m sync.Mutex、var wg sync.WaitGroup都不需要额外初始化就能直接用。这是 Go 与 Java(必须new)的关键区别。
# 3.8 类型转换的严格性
Go 没有隐式数值转换——任意两种不同类型间都要写显式 T(x)。这是 Go 与 C / Java 最大的语法差异之一。
# 3.8.1 int32 不能直接给 int64
var a int32 = 100
var b int64 = a // ❌ 编译错:cannot use a (type int32) as type int64
var c int64 = int64(a) // ✅ 显式转换
2
3
即使是 int 与 int64:
var x int = 1
var y int64 = x // ❌ 也不行(哪怕 64 位机上 int 实际就是 int64)
var y int64 = int64(x) // ✅
2
3
设计哲学:宁可让程序员多敲键盘,也不让"自动扩展"或"自动截断"埋藏 bug。
截断与符号扩展:
var big int32 = 0x12345678
var small int8 = int8(big) // = 0x78 = 120(直接砍掉高位字节)
var neg int8 = -1 // 0xFF(补码)
var u uint16 = uint16(neg) // = 0xFFFF(符号扩展)
2
3
4
5
转换规则与 C 一致:缩小取低位,扩大按符号扩展。
# 3.8.2 字符串与字节切片的互转
s := "hello"
// string -> []byte:拷贝一份,得到的 []byte 可改
b := []byte(s)
b[0] = 'H'
fmt.Println(string(b)) // Hello
fmt.Println(s) // hello(原字符串没受影响)
// []byte -> string:同样拷贝
s2 := string(b)
b[0] = 'X'
fmt.Println(s2) // 还是 Hello
2
3
4
5
6
7
8
9
10
11
12
关键:两种转换都会拷贝底层字节,不是零拷贝。这保证了 string 的不可变性。
💡 想避免拷贝?用
unsafe可以,但属于"违反约定"——只在性能极敏感场景下,且必须 100% 确定使用范围。卷四会专门讲这种黑魔法。
# 3.8.3 字符串与 rune 切片的互转
如果要操作"一个字符"而不是"一个字节",转 []rune:
s := "Go语言"
rs := []rune(s)
fmt.Println(len(s)) // 8(字节数)
fmt.Println(len(rs)) // 4(字符数)
rs[2] = '编'
rs[3] = '程'
fmt.Println(string(rs)) // Go编程
2
3
4
5
6
7
8
9
转换代价:[]rune(s) 要遍历整个字符串做 UTF-8 解码,不是 O(1)——长字符串频繁转换有性能损失。
三种切片表示对照:
字符串:"Go语言"
字节切片:[0x47 0x6F 0xE8 0xAF 0xAD 0xE8 0xA8 0x80] 8 个 byte
rune切片:[0x47 0x6F 0x8BED 0x8A00] 4 个 rune
2
3
# 3.9 自定义类型与类型别名
Go 提供两种"取新名字"的语法,含义完全不同:
// 1. 自定义类型(type definition):产生新类型,与原类型不可隐式互换
type UserID int
// 2. 类型别名(type alias,Go 1.9+):仅是同一类型的另一个名字
type MyInt = int
2
3
4
5
对照测试:
package main
import "fmt"
type UserID int // 新类型
type MyInt = int // 别名
func main() {
var u UserID = 1
var i int = 2
var m MyInt = 3
// u + i // ❌ 编译错:mismatched types UserID and int
_ = int(u) + i // ✅ 显式转换可以
_ = i + m // ✅ 别名就是同一个类型,自由相加
fmt.Println(u, i, m)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| 语法 | 是否新类型 | 能否定义方法 | 主要用途 |
|---|---|---|---|
type X int(无 =) | 新类型 | ✅ 能 | 业务建模、类型安全(区分 UserID / OrderID) |
type X = int(有 =) | 同一类型 | ❌ 不行(方法在原类型上) | 重构 / 迁移过渡(byte = uint8、any = interface{}) |
经典应用:自定义类型 + 方法 = 类型安全枚举
type Status int
const (
StatusPending Status = iota
StatusRunning
StatusDone
)
func (s Status) String() string {
return [...]string{"Pending", "Running", "Done"}[s]
}
func main() {
s := StatusRunning
fmt.Println(s) // Running(fmt 自动调用 String)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
经典应用:类型别名做大重构
Go 1.9 引入别名的直接动机就是支持 Go 标准库内部把 bytes.Buffer 之类的类型搬家时不破坏外部代码。byte = uint8、rune = int32、any = interface{}(Go 1.18+)都是别名。
# 3.10 综合示例:UTF-8 字节切片转码
把本章知识串起来,写一个小工具:从命令行读入一段文本,输出每个字符的 UTF-8 字节序、码点、字节数。
// utf8inspector/main.go
package main
import (
"fmt"
"os"
"strings"
"unicode/utf8"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("用法: utf8inspector <文本>")
os.Exit(1)
}
s := strings.Join(os.Args[1:], " ")
fmt.Printf("原文: %q\n", s)
fmt.Printf("字节数: %d\n", len(s))
fmt.Printf("字符数: %d\n", utf8.RuneCountInString(s))
fmt.Println(strings.Repeat("-", 50))
fmt.Printf("%-6s %-6s %-10s %-12s %s\n",
"序号", "偏移", "码点", "字节序列", "字符")
idx := 0
for byteOffset, r := range s {
size := utf8.RuneLen(r)
bytes := []byte(string(r))
fmt.Printf("%-6d %-6d U+%04X % -12X %c\n",
idx, byteOffset, r, bytes, r)
idx++
_ = size
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
跑一下:
$ go mod init example.com/utf8inspector
$ go run . "Go 语言 🚀"
原文: "Go 语言 🚀"
字节数: 14
字符数: 7
--------------------------------------------------
序号 偏移 码点 字节序列 字符
0 0 U+0047 47 G
1 1 U+006F 6F o
2 2 U+0020 20
3 3 U+8BED E8 AF AD 语
4 6 U+8A00 E8 A8 80 言
5 9 U+0020 20
6 10 U+1F680 F0 9F 9A 80 🚀
2
3
4
5
6
7
8
9
10
11
12
13
14
这个例子用上了:
| 章节知识点 | 在哪一行体现 |
|---|---|
| 字符串本质 = 字节序列 | len(s) |
rune = Unicode 码点 | for _, r := range s |
| 字节偏移而非字符序号 | byteOffset |
string ↔ []byte 互转 | []byte(string(r)) |
utf8 标准库 | RuneCountInString / RuneLen |
# 3.11 本章底层原理(简介)
string 的内存结构(runtime/string.go: type stringStruct)
┌──────────────┬──────────────┐
│ *byte │ int │ 16 字节(64 位平台)
│ 数据指针 │ 字节长度 │
└──────────────┴──────────────┘
│
▼
┌──┬──┬──┬──┬──┬──┬──┬──┐
│ G│ o│ │ 语 (3B) │ 言 (3B) │ 只读区(编译期常量)或堆(运行期构造)
└──┴──┴──┴──┴──┴──┴──┴──┘
2
3
4
5
6
7
8
9
10
11
更深的内容留到卷三第 4 章「字符串与切片底层」详讲:
- 字符串字面量在 ELF / Mach-O 的只读段(
.rodata),多个 goroutine 共享同一份 string([]byte)编译器在某些场景能消除拷贝(如m[string(b)]查 map)unsafe.String/unsafe.StringData(Go 1.20+)允许零拷贝重解释
本章你只需记住:字符串是不可变的字节序列,拷贝便宜,遍历用 rune。
# 3.12 Go 新手陷阱 Top 5
# ❌ 陷阱 1:len(s) 当字符数
s := "你好"
fmt.Println(len(s)) // 6,不是 2
2
修复:用 utf8.RuneCountInString(s) 或 len([]rune(s))。
# ❌ 陷阱 2:浮点 == 比较
if 0.1+0.2 == 0.3 { ... } // 永远 false
修复:用 math.Abs(a-b) < eps;金融场景根本不要用 float,用整数(分)或 math/big。
# ❌ 陷阱 3:int 跨平台传输
// 32 位机产出
var hash int = 0x80000000 // 在 64 位机上是正数,32 位机上溢出为负
binary.Write(w, binary.BigEndian, hash) // 跨机器读出宽度不同!
2
3
修复:协议字段、文件格式、哈希值一律用 int32/int64 等固定宽度。
# ❌ 陷阱 4:string([]byte{0xFF, 0xFE}) 不是 UTF-8
b := []byte{0xFF, 0xFE}
s := string(b) // 不是 UTF-8 序列,但不会报错
fmt.Println(utf8.ValidString(s)) // false
2
3
string(b) 不做 UTF-8 校验,"任何字节"都能塞进去。处理外部输入时记得 utf8.Valid() 检查。
# ❌ 陷阱 5:NaN 永远不等于自己
nan := math.NaN()
fmt.Println(nan == nan) // false
m := map[float64]int{nan: 1}
fmt.Println(m[nan]) // 0(查不到)
2
3
4
修复:用 math.IsNaN(x) 判断;不要用 float 做 map key。
# 3.13 思考题
- 为什么 Go 设计成
len(s)返回字节数而不是字符数?这种设计在性能与易用性上的取舍是什么? - 写一段代码:输入一个字符串,输出它在 UTF-8、UTF-16(big-endian)、UTF-32 下分别占几字节。
- 为什么
var i int; var j int64 = i编译不过,但const i = 1; var j int64 = i能过?请用 Go 规范的"无类型常量"概念解释。 - 自己实现一个安全相加
func AddInt32(a, b int32) (int32, error),溢出时返回 error。提示:用math/bits.Add32或自己判符号位。 type UserID int与type UserID = int的差别?请写两个版本的Save(u UserID)函数,演示前者能阻止误传普通 int、后者不能。- 为什么 Go 字符串可以做 map 的 key 而 slice 不行?请联系第 5 章「复合类型」预先思考。
# 3.14 推荐阅读
# 卷内
- 第 2 章 基础语法与 Hello World
- 第 4 章 运算符(位运算、为什么没有指针算术)
- 第 5 章 复合类型(slice/map/struct 与字符串的呼应)