基础类型与控制流
基础类型与控制流
编程语言的类型系统是其世界观的体现。C 对类型的态度是"相信程序员"——允许几乎任何隐式转换;Python 的态度是"别让类型碍事"——运行时才检查类型;Haskell 的态度是"类型即证明"——用类型系统表达程序的正确性约束。
Go 的态度是:类型是交流工具。 类型首先不是给编译器看的,而是给读代码的人看的。这就是为什么 Go 要求所有类型转换必须显式进行、为什么 Go 有零值设计、为什么 Go 的整数类型那么多——这些设计让你在阅读代码时,不需要运行程序就能理解数据的精确语义。
Level 1 · 你需要知道的
整数类型
Go 提供了两类整数类型:确定大小的 和 平台相关的。
确定大小的整数类型:
| 类型 | 大小 | 范围 | 用途 |
|---|---|---|---|
int8 |
1字节 | -128 ~ 127 | 很少直接使用 |
int16 |
2字节 | -32768 ~ 32767 | 很少直接使用 |
int32 |
4字节 | -2^31 ~ 2^31-1 | 约 ±21.4 亿 |
int64 |
8字节 | -2^63 ~ 2^63-1 | 约 ±9.2 × 10^18 |
uint8 |
1字节 | 0 ~ 255 | 等同于 byte |
uint16 |
2字节 | 0 ~ 65535 | |
uint32 |
4字节 | 0 ~ 2^32-1 | 约 42.9 亿 |
uint64 |
8字节 | 0 ~ 2^64-1 | 约 1.8 × 10^19 |
平台相关的整数类型:
| 类型 | 32位系统 | 64位系统 | 用途 |
|---|---|---|---|
int |
4字节 | 8字节 | 最常用的整数类型 |
uint |
4字节 | 8字节 | 无符号计数 |
uintptr |
4字节 | 8字节 | 存储指针值(与 unsafe 包配合) |
选择哪个整数类型?
// 默认选择:用 int
count := 42 // 类型推断为 int
for i := 0; i < n; i++ { } // 循环变量是 int
// 需要精确大小时(如协议解析、文件格式)
type PacketHeader struct {
Version uint8
Type uint8
Length uint16
Sequence uint32
}
// 处理时间戳
timestamp := time.Now().Unix() // 返回 int64
// 切片长度和索引
length := len(slice) // 返回 int
经验法则:除非有明确理由选择特定大小,否则用 int。 只在与外部系统交互(网络协议、文件格式、数据库字段)时才使用固定大小的类型。
浮点类型
Go 有两种浮点类型,都遵循 IEEE 754 标准:
| 类型 | 大小 | 精度 | 范围 |
|---|---|---|---|
float32 |
4字节 | ~7位有效数字 | ±3.4 × 10^38 |
float64 |
8字节 | ~15位有效数字 | ±1.8 × 10^308 |
// 默认使用 float64
price := 19.99 // float64
pi := 3.141592653589 // float64
// 只在内存敏感场景使用 float32(如大量 3D 坐标)
type Vector3 struct {
X, Y, Z float32
}
// 科学计数法
avogadro := 6.022e23 // 6.022 × 10^23
planck := 6.626e-34 // 6.626 × 10^-34
// 特殊值
inf := math.Inf(1) // +∞
negInf := math.Inf(-1) // -∞
nan := math.NaN() // Not a Number
// NaN 的特殊性:NaN != NaN
fmt.Println(nan == nan) // false!
fmt.Println(math.IsNaN(nan)) // true
重要:不要用浮点数表示金额! 浮点数无法精确表示十进制小数。
// 错误做法
price := 0.1 + 0.2
fmt.Println(price == 0.3) // false! price 实际上是 0.30000000000000004
// 正确做法:用整数表示最小单位(分)
priceCents := 199 // ¥1.99 = 199分
字符串
Go 的字符串有三个核心属性:
- 不可变(immutable) — 一旦创建就不能修改其内容
- UTF-8 编码 — 默认就是 UTF-8,不需要额外编码转换
- 本质是只读字节切片 —
string的底层是[]byte,但不允许修改
// 字符串字面量
s1 := "Hello, 世界" // 双引号,支持转义字符
s2 := `Hello, 世界` // 反引号,原始字符串(不转义)
// 字符串长度
fmt.Println(len(s1)) // 13 字节("Hello, " 7字节 + "世界" 6字节)
fmt.Println(utf8.RuneCountInString(s1)) // 9 个字符
// 字符串拼接
greeting := "Hello" + ", " + "World" // 简单拼接
// 多行字符串
sql := `
SELECT id, name, email
FROM users
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 100
`
// 字符串遍历
for i, ch := range "Hello, 世界" {
fmt.Printf("index=%d char=%c unicode=%U\n", i, ch, ch)
}
// index=0 char=H unicode=U+0048
// index=1 char=e unicode=U+0065
// ...
// index=7 char=世 unicode=U+4E16 (注意 index 跳了!)
// index=10 char=界 unicode=U+754C
布尔类型
var b bool // 零值是 false
isReady := true
isEmpty := len(s) == 0
// Go 没有隐式的 truthy/falsy 值
// if 1 { } // 编译错误!不能用整数做条件
// if "" { } // 编译错误!不能用字符串做条件
if n != 0 { } // 必须显式比较
if s != "" { } // 必须显式比较
rune 类型
rune 是 int32 的别名,用于表示一个 Unicode 码点。
// rune 字面量用单引号
var ch rune = '中' // Unicode 码点 U+4E2D,值为 20013
var a rune = 'A' // Unicode 码点 U+0041,值为 65
// rune 就是 int32
fmt.Printf("%T\n", ch) // int32
fmt.Printf("%d\n", ch) // 20013
fmt.Printf("%c\n", ch) // 中
fmt.Printf("%U\n", ch) // U+4E2D
// byte vs rune
var b byte = 'A' // byte 是 uint8 的别名,只能表示 ASCII
var r rune = '中' // rune 是 int32 的别名,能表示所有 Unicode
为什么需要 rune?因为 Go 字符串是 UTF-8 编码的字节序列。一个中文字符占 3 个字节,直接按字节索引会得到错误结果:
s := "Go语言"
fmt.Println(s[0]) // 71 ('G' 的 ASCII)
fmt.Println(s[2]) // 232 (不是 '语'!是 '语' 的 UTF-8 编码第一个字节)
// 正确方式:转换为 rune 切片
runes := []rune(s)
fmt.Println(string(runes[2])) // "语"
控制流
Go 的控制流语句非常精简——只有 if、for、switch、select(加上 goto、break、continue)。
if 语句:
// 基本 if
if x > 0 {
fmt.Println("positive")
} else if x == 0 {
fmt.Println("zero")
} else {
fmt.Println("negative")
}
// if 带初始化语句(Go 特有!非常常用)
if err := doSomething(); err != nil {
return err
}
// err 的作用域仅限于 if 块内
// 这种模式在 Go 中非常常见
if n, err := fmt.Scanf("%d", &x); err != nil {
log.Fatal(err)
} else if n != 1 {
log.Fatal("expected 1 value")
}
for 循环(Go 唯一的循环语句):
// 传统 for
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// while 等价(Go 没有 while 关键字)
for condition {
// ...
}
// 无限循环
for {
// break 退出
}
// range 遍历
for index, value := range slice { }
for key, value := range myMap { }
for index, char := range "string" { } // char 是 rune 类型
for line := range channel { } // Go 1.22+: range over func
// 只要索引
for i := range slice { }
// 只要值(忽略索引)
for _, v := range slice { }
switch 语句:
// 基本 switch(不需要 break!自动 break)
switch day {
case "Monday":
fmt.Println("周一")
case "Tuesday", "Wednesday": // 多值匹配
fmt.Println("周二或周三")
default:
fmt.Println("其他")
}
// 无条件 switch(替代长 if-else 链)
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
case score >= 70:
grade = "C"
default:
grade = "F"
}
// type switch(用于接口类型判断)
switch v := i.(type) {
case int:
fmt.Println("int:", v)
case string:
fmt.Println("string:", v)
case nil:
fmt.Println("nil")
default:
fmt.Printf("unexpected type %T\n", v)
}
// fallthrough(显式继续到下一个 case)
switch n {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("at most two")
}
defer 语句:
// defer 在函数返回前执行,后进先出(LIFO)
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // 保证文件关闭,无论后面是否出错
// 处理文件...
return nil
}
// defer 的执行顺序
func example() {
defer fmt.Println("first") // 最后执行
defer fmt.Println("second") // 倒数第二
defer fmt.Println("third") // 第一个执行
// 输出:third, second, first
}
// defer 常见用途
mutex.Lock()
defer mutex.Unlock()
resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()
Level 2 · 它是怎么运行的
零值设计哲学
Go 的每个类型都有一个 零值(zero value)——变量声明后如果不显式初始化,就自动拥有零值。
| 类型 | 零值 |
|---|---|
| 数值类型(int, float, etc.) | 0 |
| bool | false |
| string | ""(空字符串) |
| pointer | nil |
| slice | nil(但 len=0, cap=0) |
| map | nil |
| channel | nil |
| interface | nil |
| struct | 所有字段递归取零值 |
| array | 所有元素递归取零值 |
| function | nil |
零值设计的哲学意义:声明即可用。
在 C 中,未初始化的局部变量包含垃圾值(未定义行为),这是无数 bug 的温床。在 Java 中,使用未初始化的局部变量会导致编译错误(但实例字段有默认值)。Go 的零值设计统一了所有场景——每个变量从诞生那一刻起就有确定的值。
这个设计使得很多类型可以"开箱即用",不需要构造函数:
// sync.Mutex 的零值是未锁定的 mutex——直接可用
var mu sync.Mutex
mu.Lock()
// ...
mu.Unlock()
// bytes.Buffer 的零值是空缓冲区——直接可用
var buf bytes.Buffer
buf.WriteString("hello")
fmt.Println(buf.String()) // "hello"
// sync.WaitGroup 的零值是计数为 0——直接可用
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// ...
}()
wg.Wait()
Go 标准库的设计充分利用了零值可用性。Rob Pike 提出的 Go 箴言之一就是:
"Make the zero value useful."
设计你自己的类型时也应该遵循这个原则——尽可能让零值成为有意义的默认状态。
类型转换规则
Go 没有隐式类型转换。所有类型转换必须显式进行。
var i int = 42
var f float64 = float64(i) // int → float64,必须显式转换
var u uint = uint(f) // float64 → uint,必须显式转换
// 以下全部是编译错误:
// var f float64 = i // 错误:不能隐式转换
// var x int = 3.14 // 错误:浮点字面量不能赋给 int
// var b byte = 256 // 错误:溢出
// 不同大小的整数之间也需要转换
var i32 int32 = 100
var i64 int64 = int64(i32) // 必须显式
var i16 int16 = int16(i32) // 必须显式(可能丢失数据!)
// int 和 int64 是不同类型!(即使在 64 位系统上大小相同)
var a int = 42
var b int64 = int64(a) // 必须显式
为什么 Go 这么"严格"?
-
可读性。 当你看到
float64(x)时,你立即知道这里发生了类型转换,可能有精度损失。隐式转换则隐藏了这些信息。 -
安全性。 许多 C 的安全漏洞来自隐式类型转换——有符号与无符号之间的隐式转换可能导致整数溢出漏洞。
-
明确性。
int和int64在语义上是不同的——int表示"我不关心具体大小",int64表示"我需要精确 8 字节"。即使它们在 64 位系统上大小相同,语义不同。
常量与 iota
Go 的常量系统有一个独特的特性——无类型常量(untyped constants)。
const pi = 3.14159265358979323846 // 无类型浮点常量
const e = 2.71828182845904523536 // 无类型浮点常量
const maxInt = 9223372036854775807 // 无类型整数常量
// 无类型常量的精度远高于任何浮点类型
// Go 规范要求至少 256 位精度
const (
huge = 1 << 100 // 无法存入任何整数变量,但作为常量是合法的
half = huge / 2 // 仍然是合法的常量运算
)
// 无类型常量可以用于任何兼容类型
var f32 float32 = pi // OK,编译器在赋值时截断精度
var f64 float64 = pi // OK,精度更高
iota — 枚举生成器:
iota 是 Go 的枚举工具。它在每个 const 块中从 0 开始,每行递增 1:
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
// 跳过某些值
type Permission uint8
const (
Read Permission = 1 << iota // 1 (001)
Write // 2 (010)
Execute // 4 (100)
)
// 可以组合
readWrite := Read | Write // 3 (011)
// 使用 iota 定义字节单位
const (
_ = iota // 跳过 0
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20 = 1048576
GB // 1 << 30
TB // 1 << 40
PB // 1 << 50
)
// iota 的作用域是 const 块
const (
a = iota // 0
b // 1
)
const (
c = iota // 0(新的 const 块,重新从 0 开始)
d // 1
)
字符串与 []byte/[]rune 的关系
理解 Go 字符串的关键是理解三层抽象:
层次 1: string → 不可变的 UTF-8 字节序列
层次 2: []byte → 可变的字节切片
层次 3: []rune → 可变的 Unicode 码点切片
s := "Hello, 世界"
// string → []byte(复制!O(n))
bytes := []byte(s)
// [72 101 108 108 111 44 32 228 184 150 231 149 140]
// H e l l o , SP |----世----| |----界----|
// string → []rune(复制!O(n))
runes := []rune(s)
// [72 101 108 108 111 44 32 19990 30028]
// H e l l o , SP 世 界
// []byte → string(复制!O(n))
s2 := string(bytes)
// []rune → string(复制!O(n))
s3 := string(runes)
为什么 string ↔ []byte 转换需要复制?
因为 string 是不可变的,[]byte 是可变的。如果不复制,修改 []byte 就会违反字符串不可变性。
但 Go 编译器在某些情况下会优化掉这个复制:
// 这些场景编译器不会实际复制
for _, b := range []byte(s) { } // 优化:直接遍历字符串的字节
if string(byteSlice) == "hello" { } // 优化:直接比较,不分配内存
m[string(byteSlice)] // 优化:map 查找时不分配
字符串拼接的多种方式
// 方式 1: + 运算符(每次分配新内存)
s := "Hello" + " " + "World"
// 适合:少量固定字符串拼接
// 方式 2: fmt.Sprintf(反射开销,较慢)
s := fmt.Sprintf("Name: %s, Age: %d", name, age)
// 适合:需要格式化的场景
// 方式 3: strings.Builder(推荐用于循环拼接!)
var builder strings.Builder
for i := 0; i < 1000; i++ {
builder.WriteString("hello")
}
result := builder.String()
// 适合:循环中的大量拼接
// 方式 4: strings.Join
parts := []string{"Hello", "World", "Go"}
s := strings.Join(parts, ", ")
// 适合:已有字符串切片需要合并
// 方式 5: []byte 手动管理
buf := make([]byte, 0, 1024)
buf = append(buf, "hello"...)
buf = append(buf, ' ')
buf = append(buf, "world"...)
result := string(buf)
// 适合:性能极致要求的场景
性能对比(1000 次拼接 "hello"):
BenchmarkPlusOperator-8 1000 15420 ns/op 530000 B/op 999 allocs/op
BenchmarkSprintf-8 1000 52300 ns/op 531000 B/op 2001 allocs/op
BenchmarkBuilder-8 1000 4120 ns/op 5376 B/op 1 allocs/op
BenchmarkBytesAppend-8 1000 3890 ns/op 5376 B/op 1 allocs/op
strings.Builder 之所以快,是因为它内部维护一个动态增长的 []byte,只在最后调用 String() 时转换为字符串——而且由于 Builder 保证不会再修改底层字节,String() 方法使用了 unsafe.String 避免了最后的复制。
Level 3 · 规范怎么定义的
IEEE 754 浮点精度问题
Go 的 float32 和 float64 严格遵循 IEEE 754-2008 标准。理解浮点数的工作原理对于避免精度陷阱至关重要。
IEEE 754 双精度浮点数(float64)的内部结构:
|1 bit| 11 bits | 52 bits |
|sign | exponent | mantissa/fraction |
一个 float64 的值等于:(-1)^sign × 2^(exponent-1023) × 1.mantissa
这意味着 float64 能精确表示的最大连续整数是 2^53 = 9007199254740992。超过这个值后,相邻可表示的浮点数之间的间距大于 1:
// float64 的整数精度边界
a := float64(9007199254740992) // 2^53,能精确表示
b := float64(9007199254740993) // 2^53 + 1,不能精确表示!
fmt.Println(a == b) // true! 两个不同的数被表示为同一个值
// 这在处理数据库 ID 时是一个真实问题
// JSON 中的大整数可能丢失精度
type Response struct {
ID int64 `json:"id,string"` // 用 string 标签避免 JS 丢失精度
}
十进制无法精确表示的问题:
IEEE 754 使用二进制分数。很多十进制小数无法用有限位二进制精确表示:
// 0.1 在二进制中是无限循环小数
// 0.1₁₀ = 0.0001100110011001100...₂(循环)
fmt.Printf("%.20f\n", 0.1) // 0.10000000000000000555
fmt.Printf("%.20f\n", 0.2) // 0.20000000000000001110
fmt.Printf("%.20f\n", 0.3) // 0.29999999999999998890
// 所以 0.1 + 0.2 ≠ 0.3
fmt.Println(0.1 + 0.2 == 0.3) // false
// 正确的浮点比较方式
func almostEqual(a, b, epsilon float64) bool {
return math.Abs(a-b) < epsilon
}
fmt.Println(almostEqual(0.1+0.2, 0.3, 1e-10)) // true
特殊值的行为(Go 语言规范 § Floating-point operators):
// 正无穷 / 负无穷
fmt.Println(1.0 / 0.0) // +Inf(不是 panic!)
fmt.Println(-1.0 / 0.0) // -Inf
fmt.Println(math.IsInf(1.0/0.0, 1)) // true
// NaN (Not a Number)
fmt.Println(0.0 / 0.0) // NaN
fmt.Println(math.Log(-1)) // NaN
// NaN 的诡异性质
nan := math.NaN()
fmt.Println(nan == nan) // false(唯一一个不等于自身的值!)
fmt.Println(nan < 0) // false
fmt.Println(nan > 0) // false
fmt.Println(nan != nan) // true
// NaN 会"污染"所有运算
fmt.Println(nan + 1) // NaN
fmt.Println(nan * 0) // NaN
fmt.Println(math.Max(nan, 100)) // NaN
字符串底层:StringHeader
Go 字符串的运行时表示在 reflect 包中暴露为 StringHeader:
// reflect.StringHeader(已废弃,但概念不变)
type StringHeader struct {
Data uintptr // 指向底层字节数组的指针
Len int // 字符串的字节长度
}
实际上在 Go 运行时中(runtime/string.go),字符串定义为:
// 运行时内部表示
type stringStruct struct {
str unsafe.Pointer
len int
}
这个设计带来几个重要推论:
- 字符串赋值是 O(1) 操作 — 只复制指针和长度,不复制底层数据
s1 := "a very long string..."
s2 := s1 // O(1),s1 和 s2 共享同一块底层内存
- 子字符串操作也是 O(1) — 新字符串指向原字符串内存的某个偏移
s := "Hello, World"
sub := s[7:] // O(1),sub 的 Data 指向 s 的 Data+7
// 但 sub 会阻止 s 的底层数组被 GC 回收!
- 字符串比较是 O(n) — 需要逐字节比较
// 先比较长度(O(1)),不同则直接返回 false
// 然后比较指针(O(1)),相同则直接返回 true(同一块内存)
// 最后逐字节比较(O(n))
s1 == s2
- 长字符串的子串会阻止 GC
// 一个常见的内存泄漏模式
var cache string
func processLargeData(data string) {
// data 可能是几 MB 的字符串
cache = data[:10] // cache 只需要 10 字节
// 但由于 cache 的底层指针指向 data 的内存
// 整个几 MB 的 data 都无法被 GC 回收!
// 正确做法:复制需要的部分
cache = strings.Clone(data[:10]) // Go 1.20+
// 或
cache = string([]byte(data[:10])) // 强制复制
}
类型别名 vs 类型定义
Go 有两种创建新类型名的方式,语义完全不同:
// 类型定义(Type Definition)— 创建全新类型
type UserID int64
type Celsius float64
type Handler func(http.ResponseWriter, *http.Request)
// UserID 和 int64 是不同类型,不能直接赋值
var id UserID = 42
var n int64 = id // 编译错误!需要 int64(id)
// 可以为新类型定义方法
func (c Celsius) ToFahrenheit() float64 {
return float64(c)*9/5 + 32
}
// ============================================
// 类型别名(Type Alias)— 只是另一个名字,完全等同
type byte = uint8 // Go 内建的别名
type rune = int32 // Go 内建的别名
type any = interface{} // Go 1.18 引入
// 自定义别名
type MyInt = int
var a MyInt = 42
var b int = a // OK! MyInt 和 int 是同一个类型
// 不能为别名定义方法(因为它不是新类型)
// func (m MyInt) String() string { } // 编译错误
何时用类型定义 vs 别名?
- 类型定义:当你想创建一个有不同语义的类型(如 UserID vs OrderID 都是 int64 但语义不同)
- 类型别名:主要用于大型重构时的渐进式迁移(在两个包中使用同一个类型名)
常量的编译期求值
Go 的常量在编译期求值,不占用运行时内存:
const (
maxSize = 1024 * 1024 * 100 // 编译期计算为 104857600
message = "hello" + " " + "world" // 编译期拼接
isDebug = false
)
// 编译器会优化掉不可达代码
if isDebug {
// 如果 isDebug 是 const false,这整个块会被编译器移除
log.Println("debug info")
}
// 常量表达式的溢出在编译期检测
// const tooBig = 1 << 1000 // 如果赋给变量会编译错误
// 但作为中间值参与常量运算是允许的
const (
big = 1 << 100
small = big >> 99 // = 2,可以赋给 int 变量
)
Level 4 · 边界与陷阱
陷阱一:整数溢出
Go 的整数溢出是 静默的 — 不会 panic,而是回绕(wrap around):
var u uint8 = 255
u++ // u 变成 0,不是 256!没有任何警告!
var i int8 = 127
i++ // i 变成 -128!
// 这在真实系统中造成过严重 bug
// 例如:计数器溢出、时间戳计算错误、缓冲区大小计算错误
// 防御性编程
func safeAdd(a, b int64) (int64, error) {
if (b > 0 && a > math.MaxInt64-b) || (b < 0 && a < math.MinInt64-b) {
return 0, errors.New("integer overflow")
}
return a + b, nil
}
// 或使用 math/bits 包(Go 1.12+)
hi, lo := bits.Add64(a, b, 0)
if hi != 0 {
// 溢出了
}
整数除法的陷阱:
// 整数除法向零截断(不是向下取整!)
fmt.Println(7 / 2) // 3(正数)
fmt.Println(-7 / 2) // -3(不是 -4!向零截断)
// 求余的符号跟随被除数
fmt.Println(7 % 2) // 1
fmt.Println(-7 % 2) // -1(不是 1!)
fmt.Println(7 % -2) // 1
// 如果你需要数学意义上的模运算(结果总是非负)
func mod(a, b int) int {
return ((a % b) + b) % b
}
陷阱二:浮点比较
// 永远不要直接比较浮点数
if 0.1+0.2 == 0.3 { // 这个条件永远为 false!
fmt.Println("equal")
}
// 正确方式:使用 epsilon 比较
const epsilon = 1e-9
func floatEqual(a, b float64) bool {
if a == b {
return true // 处理 ±Inf 和完全相同的值
}
diff := math.Abs(a - b)
if a == 0 || b == 0 || diff < math.SmallestNonzeroFloat64 {
return diff < epsilon*math.SmallestNonzeroFloat64
}
return diff/(math.Abs(a)+math.Abs(b)) < epsilon
}
// 或者用更简单但足够的版本
func approxEqual(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
// float64 → int 的截断
fmt.Println(int(2.9)) // 2(向零截断,不是四舍五入!)
fmt.Println(int(-2.9)) // -2
fmt.Println(int(math.Round(2.5))) // 2(Go 的 Round 使用银行家舍入!)
fmt.Println(int(math.Round(3.5))) // 4
// 如果需要传统四舍五入
func roundTraditional(x float64) int {
return int(math.Floor(x + 0.5))
}
陷阱三:字符串拼接性能
// 反模式:在循环中用 + 拼接
func badConcat(n int) string {
s := ""
for i := 0; i < n; i++ {
s += "hello" // 每次都分配新字符串!O(n²) 时间复杂度!
}
return s
}
// 当 n=10000 时,这个函数会分配约 250MB 临时内存
// 正确做法:使用 strings.Builder
func goodConcat(n int) string {
var builder strings.Builder
builder.Grow(n * 5) // 预分配容量(可选但推荐)
for i := 0; i < n; i++ {
builder.WriteString("hello")
}
return builder.String()
}
// 当 n=10000 时,只分配一次约 50KB 内存
为什么 + 拼接在循环中这么慢?
因为字符串不可变。每次 s += "hello" 实际上要:
- 分配一块新内存(大小 = len(s) + 5)
- 把旧 s 的内容复制到新内存
- 把 "hello" 复制到新内存的末尾
- 旧 s 的内存等待 GC 回收
当 s 越来越长时,每次复制的量也越来越大。1000 次拼接的总复制量是 5+10+15+...+5000 = O(n²)。
陷阱四:nil slice vs empty slice
// nil slice
var s1 []int // s1 == nil, len=0, cap=0
// empty slice
s2 := []int{} // s2 != nil, len=0, cap=0
s3 := make([]int, 0) // s3 != nil, len=0, cap=0
// 在大多数操作中它们等价
fmt.Println(len(s1), len(s2)) // 0 0
s1 = append(s1, 1) // OK
s2 = append(s2, 1) // OK
for range s1 { } // OK(不会 panic)
// 但在序列化时不同!
data1, _ := json.Marshal(s1) // null
data2, _ := json.Marshal(s2) // []
// API 设计中这个区别很重要
type Response struct {
Items []Item `json:"items"`
}
// 如果 Items 是 nil → {"items": null}
// 如果 Items 是 []Item{} → {"items": []}
// 前端通常期望后者,所以初始化空切片:
resp := Response{Items: []Item{}}
陷阱五:map 的无序性
// map 遍历顺序是随机的!
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v)
}
// 每次运行结果可能不同!Go 故意随机化遍历顺序
// 防止程序员依赖特定顺序
// 如果需要有序遍历,手动排序键
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
陷阱六:for range 的变量捕获
// Go 1.21 及之前的经典陷阱
values := []int{1, 2, 3, 4, 5}
var funcs []func()
for _, v := range values {
funcs = append(funcs, func() {
fmt.Println(v) // 捕获的是变量 v 的地址,不是值!
})
}
for _, f := range funcs {
f() // 输出 5 5 5 5 5(全部是最后一个值!)
}
// 修复方式 1:创建局部变量
for _, v := range values {
v := v // 创建新变量
funcs = append(funcs, func() {
fmt.Println(v) // 捕获新变量
})
}
// 修复方式 2:作为参数传入
for _, v := range values {
funcs = append(funcs, func(val int) func() {
return func() { fmt.Println(val) }
}(v))
}
// Go 1.22+ 修复了这个问题!
// 从 Go 1.22 开始,每次循环迭代都创建新的循环变量
// 上面的代码在 Go 1.22+ 中会正确输出 1 2 3 4 5
陷阱七:数值转换的精度丢失
// int64 → float64 的精度丢失
n := int64(9007199254740993) // 2^53 + 1
f := float64(n)
fmt.Println(int64(f)) // 9007199254740992(丢失了 1!)
// float64 → int 的截断
fmt.Println(int(1.9)) // 1(不是 2!)
fmt.Println(int(-1.9)) // -1(不是 -2!)
// uint → int 的溢出
var u uint64 = math.MaxUint64 // 18446744073709551615
var i int64 = int64(u)
fmt.Println(i) // -1(二进制位模式相同,但解释不同)
// 大 int 转小 int 的截断
var big int64 = 256
var small int8 = int8(big)
fmt.Println(small) // 0(256 的低 8 位全是 0)
var big2 int64 = 300
var small2 int8 = int8(big2)
fmt.Println(small2) // 44(300 = 256 + 44,只保留低 8 位)
陷阱八:字符串索引返回字节,不是字符
s := "café"
fmt.Println(len(s)) // 5(不是 4!é 占 2 字节)
fmt.Println(s[3]) // 169(é 的第一个字节的值,不是整个字符)
fmt.Println(string(s[3])) // 乱码(不完整的 UTF-8 序列)
// 正确获取第 n 个字符
runes := []rune(s)
fmt.Println(string(runes[3])) // é
// 或使用 utf8 包
r, size := utf8.DecodeRuneInString(s[3:])
fmt.Printf("%c (%d bytes)\n", r, size) // é (2 bytes)
// 字符串切片也是按字节的
fmt.Println(s[:3]) // "caf"(前 3 个字节)
fmt.Println(s[:4]) // "caf\xc3"(不完整的 UTF-8!)
fmt.Println(s[:5]) // "café"(完整)
实战建议总结
| 场景 | 推荐做法 |
|---|---|
| 一般整数 | int |
| 精确大小(协议/文件格式) | int32/int64/uint16 等 |
| 金额 | 整数(分为单位)或 shopspring/decimal 库 |
| 浮点比较 | epsilon 比较,不用 == |
| 循环拼接字符串 | strings.Builder |
| 遍历字符串字符 | for _, r := range s(r 是 rune) |
| 获取字符串第 n 个字符 | []rune(s)[n] |
| 获取字符串字符数 | utf8.RuneCountInString(s) |
| JSON 中的大整数 ID | 使用 json:"...,string" tag |
| 检查整数溢出 | math/bits 包或手动检查 |
本章要点总结:
- Go 有丰富的数值类型,但日常使用
int和float64即可 - 零值设计使得变量声明即可用——"Make the zero value useful"
- 所有类型转换必须显式,没有隐式转换
- 字符串是不可变 UTF-8 字节序列,
len()返回字节数不是字符数 iota是编译期枚举生成器,支持位运算和复杂表达式- 浮点数遵循 IEEE 754,不能精确表示十进制小数——不要用
==比较 - 字符串底层是
{指针, 长度}结构,赋值是 O(1),子串可能导致内存泄漏 - 整数溢出是静默的——必须手动防御
- 循环中拼接字符串用
strings.Builder,不要用+ - Go 1.22 修复了 for-range 循环变量捕获的经典陷阱