第 3 章

基础类型与控制流

基础类型与控制流

编程语言的类型系统是其世界观的体现。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 的字符串有三个核心属性:

  1. 不可变(immutable) — 一旦创建就不能修改其内容
  2. UTF-8 编码 — 默认就是 UTF-8,不需要额外编码转换
  3. 本质是只读字节切片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 类型

runeint32 的别名,用于表示一个 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 的控制流语句非常精简——只有 ifforswitchselect(加上 gotobreakcontinue)。

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 这么"严格"?

  1. 可读性。 当你看到 float64(x) 时,你立即知道这里发生了类型转换,可能有精度损失。隐式转换则隐藏了这些信息。

  2. 安全性。 许多 C 的安全漏洞来自隐式类型转换——有符号与无符号之间的隐式转换可能导致整数溢出漏洞。

  3. 明确性。 intint64 在语义上是不同的——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 的 float32float64 严格遵循 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
}

这个设计带来几个重要推论:

  1. 字符串赋值是 O(1) 操作 — 只复制指针和长度,不复制底层数据
s1 := "a very long string..."
s2 := s1  // O(1),s1 和 s2 共享同一块底层内存
  1. 子字符串操作也是 O(1) — 新字符串指向原字符串内存的某个偏移
s := "Hello, World"
sub := s[7:]  // O(1),sub 的 Data 指向 s 的 Data+7
// 但 sub 会阻止 s 的底层数组被 GC 回收!
  1. 字符串比较是 O(n) — 需要逐字节比较
// 先比较长度(O(1)),不同则直接返回 false
// 然后比较指针(O(1)),相同则直接返回 true(同一块内存)
// 最后逐字节比较(O(n))
s1 == s2
  1. 长字符串的子串会阻止 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 别名?

常量的编译期求值

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" 实际上要:

  1. 分配一块新内存(大小 = len(s) + 5)
  2. 把旧 s 的内容复制到新内存
  3. 把 "hello" 复制到新内存的末尾
  4. 旧 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 包或手动检查

本章要点总结:

  1. Go 有丰富的数值类型,但日常使用 intfloat64 即可
  2. 零值设计使得变量声明即可用——"Make the zero value useful"
  3. 所有类型转换必须显式,没有隐式转换
  4. 字符串是不可变 UTF-8 字节序列,len() 返回字节数不是字符数
  5. iota 是编译期枚举生成器,支持位运算和复杂表达式
  6. 浮点数遵循 IEEE 754,不能精确表示十进制小数——不要用 == 比较
  7. 字符串底层是 {指针, 长度} 结构,赋值是 O(1),子串可能导致内存泄漏
  8. 整数溢出是静默的——必须手动防御
  9. 循环中拼接字符串用 strings.Builder,不要用 +
  10. Go 1.22 修复了 for-range 循环变量捕获的经典陷阱
本章评分
4.8  / 5  (102 评分)

💬 留言讨论