Go 基础语法(工程视角)

2022-12-02T14:11:02+08:00 | 19分钟阅读 | 更新于 2025-12-02T14:11:02+08:00

@

学习目标

  1. 理解 Go 程序的工程结构:packagemain 函数、go rungo build 的差异,建立"一个 Go 进程 = 一个 main 包"的心智模型。
  2. 掌握 Go 基本类型、变量、常量、方法声明的工程注意点(首字母大小写控制访问性、多返回值、defer、闭包),并能从工程角度解释为什么 Go 这么设计。
  3. 彻底搞懂切片与 map 的底层模型:容量、扩容、子切片的内存共享、map 的随机遍历,避免生产环境下的隐式 bug。
  4. 理解 Go 的接口、结构体、组合与泛型,能够用泛型封装出可复用的工具方法(如 SumFilterSet)。
  5. 掌握 defer 实现机制(堆上分配 / 栈上分配 / 开放编码)与切片扩容系数等高频面试要点,能在面试中主动深入。

前置知识(先看这部分,避免中途卡壳):

  • 已装好 Go 工具链(go version 能跑通),知道怎么新建一个目录。
  • 有任意一门编程语言基础(变量、函数、循环的概念能迁移即可)。
  • 不用会 Go 的并发、不用会 Web,本章只打语法地基。

本章你会动手做的事

  • 故意把 main.gopackage 写错,go run 亲眼看看报什么错。
  • 写一段切片子切片代码,验证"改一个、另一个也变"的共享内存现象。
  • 用泛型自己写一个 Map 工具方法(把每个元素平方),体会类型参数怎么用。

一、Hello Go:从第一个程序看 Go 工程结构

生活类比:写一个 Go 程序,就像开一家便利店。你得先挂店招(package main 声明这是"正经门店"),店里必须有个"开门营业"的开关(func main()),工商局(Go 工具链)只认这个开关才允许你对外营业。go run 是"试营业当天就开张",go build 是"先把门店打包成可运输的集装箱,之后随时能开"。

一张图先建立整体直觉——源码到运行有两条路:

flowchart LR
    S[源码 main.go] --> C{go run
还是 go build?} C -->|go run| R[当场编译并运行
开发期验证用] C -->|go build| B[生成二进制 webook] B --> D[部署到服务器运行
生产环境用] R --> M((main 函数入口)) D --> M

1.1 最小程序与 main 包

// 文件名: main.go
// 每一个 Go 文件都必须以 package 声明开头
// main 包是特殊的:Go 工具链会把 main 包里的 main 函数作为程序入口
package main

import "fmt"

// main 函数无参数、无返回值,这是 Go 程序的唯一入口
func main() {
    // 步骤 1:Go 程序入口,工具链启动时从这里执行
    // 步骤 2:调用 fmt.Println 输出内容并自动换行
    fmt.Println("Hello, Go!")
}

工程要点

  • package main 是程序入口的硬性要求。如果把 package 名写成别的(比如 hello),运行 go run main.go 会报错:package command-line-arguments is not a main package
  • 一个目录下的所有 .go 文件(不含 _test.go)package 名必须一致;但测试文件可以是 <pkg>_test,这是 Go 的一个特殊约定,方便在外部包做黑盒测试。
  • package 名可以和目录名不同,但工程实践中强烈建议保持一致,否则 import 后的标识符会让人困惑。

1.2 运行与构建

# 方式一:直接运行(适合开发期)
go run main.go            # 单文件
go run .                  # 整个目录
go build -o webook .      # 编译为可执行文件 webook
./webook                  # 运行

go run 适合开发期快速验证;go build 生成二进制后部署,生产环境一律用 go build。如果 main.go 引用了同包的其它文件(如 hello.go),必须用 go run .go run main.go hello.go

工程视角go run 本质是"编译到临时目录 + 立即运行",每次都重新编译,慢且不留产物;go build 产出一个独立二进制,它把 Go 运行时(goroutine 调度器、GC)一起打包进去,丢到任何同架构的机器上就能跑,不依赖那台机器装没装 Go。所以生产部署、Docker 镜像里跑的,都是 go build 出来的静态二进制。


二、基础语法:从工程角度审视

2.1 基本类型家族

类型族成员工程建议
int 家族int8/16/32/64、int默认用 int
uint 家族uint8/16/32/64、uint位运算、文件偏移用 uint
float 家族float32、float64默认用 float64
byteuint8 的别名表示原始字节
booltrue / false
string不可变的字节序列(UTF-8 默认编码)

关键点

  • Go 是强类型语言,没有自动类型转换intint64 相加会直接编译报错,必须显式转换:int64(a) + b
  • int 的字节数取决于 CPU(多数现代机器是 8 字节,等价于 int64)。但在结构体里如果你要精确控制内存布局(比如序列化、协议字段),必须用定长 int32/int64
  • 数字极值在 math 包中作为常量提供,如 math.MaxInt64math.MaxFloat64

工程视角:Go 的强类型没有隐式转换,初学觉得啰嗦,其实是帮你挡 bug。比如"用户 ID 是 int64、分页偏移是 int",两者相加若允许隐式转换,溢出时静默出错;Go 强制你写 int64(offset),转换意图明明白白。写要落库的结构体时,别用 int 图省事——跨 32 位 / 64 位平台 int 宽度不同,序列化后字段长度会变,老老实实用 int64

2.2 string 的中文陷阱

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "你好,Go"

    // len 返回的是字节长度,不是字符个数
    // "你好" 各占 3 字节(UTF-8),"," 占 3 字节,"Go" 各 1 字节
    fmt.Println(len(s)) // 11

    // 字符个数要用 utf8.RuneCountInString
    fmt.Println(utf8.RuneCountInString(s)) // 6

    // 遍历字符(rune),而不是字节
    for i, r := range s {
        fmt.Printf("%d: %c\n", i, r)
    }
}

⚠️ 新手必踩的坑:用字节下标截断中文。很多新手写截断逻辑 s[:5] 时,如果字符串里有中文,可能把一个汉字切成两半,得到乱码。因为 len 返回的是字节数,而一个汉字在 UTF-8 下占 3 字节,下标 5 可能正好切在汉字中间。处理用户输入(如昵称)时务必用 []rune(s) 转换成"字符切片"后,再按字符下标截断。

2.3 变量与常量

package main

// 包级变量:首字母大写 = 包外可访问(导出);小写 = 包内私有
var ServerPort int = 8080
var serverSecret = "xxx"

// const 同样遵循大小写控制访问性
const MaxRetry = 3

// iota:常量计数器,每个 const 块重置为 0,每新增一行 +1
type Weekday int

const (
    Sunday Weekday = iota // 0
    Monday                // 1
    Tuesday               // 2
    Wednesday             // 3
    Thursday              // 4
    Friday                // 5
    Saturday              // 6
)

func main() {
    // 局部变量两种声明方式
    var a int = 10   // 显式类型
    b := 20          // 短变量声明,只能用在函数内部;类型推断为 int

    // 注意::= 要求左侧至少有一个新变量
    b, c := 30, 40   // b 已存在但 c 是新的,所以可以用 :=

    _ = a
    _ = b
    _ = c
}

工程要点

  • 局部变量声明了必须使用,否则编译错误。这是 Go 强制的代码清洁性。
  • := 推断数字字面量时默认 int / float64。如果你要 int32,必须用 var x int32 = 10
  • iota 非常适合定义枚举、位掩码(如权限位 Read = 1 << iota; Write; Execute)。

2.4 方法:多返回值、defer、闭包

Go 方法的几个工程特性:多返回值有名返回值defer方法是一等公民

package main

import "fmt"

// 多返回值 + 有名返回值
// 有名返回值相当于在函数顶部声明了局部变量,return 时可以裸写
func divide(a, b int) (result int, err error) {
    defer func() {
        // defer 中可以修改有名返回值
        // 这里利用 recover 捕获 panic,让函数返回 err 而不是崩溃
        if r := recover(); r != nil {
            err = fmt.Errorf("panic: %v", r)
            result = 0
        }
    }()
    if b == 0 {
        // 命名返回值的好处:调用方一看签名就知道返回的是什么
        return 0, fmt.Errorf("divisor is zero")
    }
    return a / b, nil
}

// 闭包:方法 + 它绑定的运行时上下文
// 闭包引用的变量不会被 GC,使用不当可能内存泄露
func counter() func() int {
    count := 0
    // 返回的闭包"捕获"了 count,每次调用都修改同一个 count
    return func() int {
        count++
        return count
    }
}

// 不定参数:必须是最后一个参数;方法内部当切片用
// Option 模式大量使用不定参数
func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

func main() {
    c := counter()
    fmt.Println(c(), c(), c()) // 1 2 3

    fmt.Println(sum(1, 2, 3, 4)) // 10

    // defer:后进先出,常用于资源释放
    // 一个函数不超过 8 个 defer(影响开放编码优化)
    defer fmt.Println("first defer")  // 后执行
    defer fmt.Println("second defer") // 先执行
    fmt.Println("main body")
}

defer 的值确定原则(高频面试题)

package main

import "fmt"

func main() {
    x := 1
    // 作为参数传入:定义 defer 时就确定了值
    defer fmt.Println("param x =", x) // 输出 1

    // 作为闭包引用:执行 defer 时才确定值
    defer func() {
        fmt.Println("closure x =", x) // 输出 3
    }()

    x = 2
    fmt.Println("before return, x =", x) // 输出 2
    x = 3
}

⚠️ 新手必踩的坑:defer 捕获的是变量,不是那一刻的值。上面例子里闭包引用 x,执行 deferx 已经被改成 3,所以输出 3——这不是 bug,是 Go 的语义。如果你期望它输出当时的值,必须把值作为参数传进去(即 defer fmt.Println(x) 形式)。参数传值"定义时定",闭包引用"执行时定",记反了面试就会翻车。

记忆口诀:参数传值定义时定,闭包引用执行时定。

defer 三种实现机制(面试亮点)

机制说明性能启用条件
堆上分配defer 整体分配到堆上,被 GC 管理最慢默认 fallback
栈上分配分配到 goroutine 栈上,无需 GC提升 30%编译期可证明 defer 不逃逸
开放编码(Open Code)内联优化,相当于把 defer 内容挪到函数末尾最快defer ≤ 8、不在循环里、return × defer ≤ 15

回答时主动引导到"内存逃逸"话题,是初级工程师面试刷亮点的好切入点。


三、控制结构

Go 的控制结构很简洁:只有 if-elseforswitchselect(select 后面并发章节讲)。

生活类比:Go 把控制结构"砍"到最少——没有 whilefor 兼职)、没有 do-while、没有 goto 式的花活,只有 if / for / switch / select。这是 Go “少即是多"的设计哲学:控制流越简单,代码越容易读、越不容易出 bug。你写业务时 99% 的场景,这三个就够用了。

package main

import "fmt"

func main() {
    // if 支持初始化语句,作用域仅限于 if-else 块
    if distance := 100; distance > 50 {
        fmt.Println("far")
    } else {
        fmt.Println("near")
    }
    // distance 在这里不可用

    // for 既是 for 也是 while
    for i := 0; i < 3; i++ {
        fmt.Println(i)
    }

    n := 0
    for n < 3 {
        fmt.Println("while-style:", n)
        n++
    }

    // for range:遍历切片/map/channel
    nums := []int{10, 20, 30}
    for idx, val := range nums {
        fmt.Printf("nums[%d]=%d\n", idx, val)
    }

    // !!!禁忌:不要对 for range 的迭代变量取地址!!!
    // 迭代变量在内存中是同一份地址,循环结束都指向最后一次的值
    bad := make([]*int, 0, len(nums))
    for _, v := range nums {
        bad = append(bad, &v) // 所有指针都指向同一个地址!
    }
    fmt.Println(*bad[0], *bad[1], *bad[2]) // 30 30 30

    // 正确做法:在循环体内创建局部变量
    good := make([]*int, 0, len(nums))
    for _, v := range nums {
        v := v // 每次循环创建新的局部变量
        good = append(good, &v)
    }
    fmt.Println(*good[0], *good[1], *good[2]) // 10 20 30

>  **新手必踩的坑 `for range` 的迭代变量取地址**上面 `bad` 例子里迭代变量 `v` 在内存中只有一份循环里每次 `&v` 拿到的都是同一个地址循环结束后所有指针都指向最后一次的值全是 30)。修复办法是在循环体里 `v := v` 创建局部副本再取地址`good` 的写法)。这是 Go 里最高频的隐蔽 bug 之一

    // switch 不需要 break(默认不穿透)
    switch day := "Mon"; day {
    case "Mon", "Tue", "Wed", "Thu", "Fri":
        fmt.Println("weekday")
    case "Sat", "Sun":
        fmt.Println("weekend")
    default:
        fmt.Println("unknown")
    }
}

四、内置类型:切片与 map 的底层思维

4.1 数组 vs 切片

维度数组 [N]T切片 []T
长度固定,编译期确定运行时可变
cap= 长度≥ len,独立概念
make 初始化不支持支持
append不支持支持
内存模型一段连续内存一个头部结构 + 底层数组

工程结论:遇事不决用切片。数组只在极少场景用(如固定大小的协议头、SHA-256 摘要 [32]byte)。

4.2 切片的内存模型

切片本质上是一个头部结构(SliceHeader):

type SliceHeader struct {
    Data uintptr  // 指向底层数组的指针
    Len  int      // 当前元素个数
    Cap  int      // 底层数组的容量
}

这张图帮你把"切片头 + 底层数组"的关系一次看明白

```mermaid
flowchart TD
    H[SliceHeader] -->|Data 指针| A[["底层数组<br/>[1,2,3,_,_]"]]
    H -->|"Len=3"| L["当前有 3 个元素"]
    H -->|"Cap=5"| C["底层数组容量 5"]
    S[["子切片 s[:2]"]] -->|共享同一底层数组| A
    S -.->|"修改 s[0] 会影响原切片"| H

核心推论

  • 子切片 s[a:b] 和原切片共享底层数组,修改一个会影响另一个。
  • 只有发生扩容(append 超过 cap)时,才会重新分配底层数组,此时才不再共享。
package main

import "fmt"

func main() {
    s := make([]int, 3, 5) // len=3, cap=5
    s[0], s[1], s[2] = 1, 2, 3

    // 步骤 1:取子切片,与原切片共享同一底层数组
    sub := s[:2]
    sub[0] = 999
    fmt.Println(s[0]) // 999 —— s 被改了!

    // 步骤 2:append 没超过 cap,仍在共享的底层数组上写
    sub = append(sub, 100)
    fmt.Println(s[2]) // 100 —— 子切片 append 写到了原数组

    // 步骤 3:append 超过 cap,触发扩容,重新分配底层数组,不再共享
    big := append(s, 4, 5, 6) // 触发扩容
    big[0] = 0
    fmt.Println(s[0]) // 仍然 999 —— s 不受影响
}

⚠️ 新手必踩的坑:子切片导致内存泄露。函数返回切片的子切片时,因为子切片和原切片共享底层数组,整个大底层数组都无法被 GC,造成内存泄露。解决方法:用 copy 复制出独立切片,切断与原底层数组的引用。面试常问"为什么我的服务内存只涨不跌”,这往往是元凶之一。

工程坑场景:函数返回切片的子切片时,整个底层数组可能无法被 GC,造成内存泄露。解决方法:用 copy 复制出独立切片。

4.3 切片扩容系数(面试要点)

  • Go 1.18+:cap < 256 时两倍扩容;cap ≥ 256 时按 1.25 倍扩容。
  • Go 1.18 前:以 1024 为分界点,前两倍后 1.25 倍。

面试加分:能解释为什么前期 2 倍、后期 1.25 倍(前期小切片扩容代价小,倍数大减少频繁扩容;后期大切片倍数小避免浪费内存),并提到 Go 团队用 256 替代 1024 是基于实测数据的优化。

延伸思考:为什么是 1.25 而不是 1.5?因为后期切片已经很大,每次扩容 1.5 倍意味着"已用内存 + 预留内存"在最多时约占峰值的 2.5 倍,太浪费;1.25 倍把冗余压到更低水平,用"多扩几次容"换"少占内存"。这是典型的时间换空间权衡,面试能讲到这一层很加分。

最佳实践:初始化切片时预估容量 make([]T, 0, cap),避免多次扩容。

4.4 map

package main

import "fmt"

func main() {
    // 预估容量能提升性能
    m := make(map[string]int, 64)

    // 赋值
    m["a"] = 1
    m["b"] = 2

    // 读取:两返回值,第二个表示是否存在
    // 不存在的 key 返回零值,不会 panic
    v, ok := m["c"]
    fmt.Println(v, ok) // 0 false

    // 遍历:map 遍历顺序是随机的!每次运行结果可能不同
    // 这是 Go 故意的,防止开发者依赖遍历顺序
    for k, v := range m {
        fmt.Println(k, v)
    }

    // 删除
    delete(m, "a")
    fmt.Println(len(m)) // 1
}

>  **新手必踩的坑map 不是并发安全的且遍历顺序随机**下面这张图说明"为什么不能依赖遍历顺序"

```mermaid
flowchart LR
    M[map 内部哈希桶] --> K1[k1=苹果]
    M --> K2[k2=香蕉]
    M --> K3[k3=橙子]
    R1[ 1 次遍历] -->|顺序不固定| O1["香蕉, 苹果, 橙子"]
    R2[ 2 次遍历] -->|顺序不固定| O2["橙子, 香蕉, 苹果"]
  • 并发读写会直接 panic:多个 goroutine 同时读写同一个 map,Go 运行时会故意崩溃(concurrent map read and map write)。并发场景请用 sync.Map 或加 sync.RWMutex
  • 遍历顺序每次都不同:这是 Go 故意做的,防止你写出依赖顺序的代码。需要有序就先把 key 排序再遍历。

comparable 概念:map 的 key 必须可比较(基本类型、string、指针、channel、接口、固定大小数组、struct 全部由 comparable 字段组成)。切片、map、函数不能作为 key。

4.5 用 map 封装 Set(泛型实现)

package set

// 用泛型实现一个通用的 Set
// K 必须是 comparable,因为要作为 map 的 key
type Set[K comparable] struct {
    m map[K]struct{} // struct{} 不占内存
}

func New[K comparable]() *Set[K] {
    return &Set[K]{m: make(map[K]struct{})}
}

func (s *Set[K]) Add(k K) {
    s.m[k] = struct{}{}
}

func (s *Set[K]) Delete(k K) {
    delete(s.m, k)
}

func (s *Set[K]) Contains(k K) bool {
    _, ok := s.m[k]
    return ok
}

// Keys 返回所有元素,注意 map 遍历随机,结果顺序不固定
func (s *Set[K]) Keys() []K {
    keys := make([]K, 0, len(s.m))
    for k := range s.m {
        keys = append(keys, k)
    }
    return keys
}

工程视角:用 map[K]struct{} 而不是 map[K]bool 实现 Set,是因为 struct{} 占 0 字节——百万级元素时能省下可观内存,且 bool 多占 1 字节还不表达"存在即真"的语义。这是 Go 里"用空结构体省内存"的经典 idiom,面试常考。


五、接口、结构体与组合

5.1 接口:行为的抽象

package storage

// 接口只声明方法,不需要 func 关键字
// Go 的接口是隐式实现(鸭子类型):只要结构体实现了全部方法,就算实现了接口
type Repository interface {
    Save(key string, value []byte) error
    Load(key string) ([]byte, error)
}

工程建议:当你怀疑"这里要不要定义接口"时,加上去。接口让你在测试时可以 mock,在切换实现时(如 MySQL → MongoDB)不需要改业务代码。后面 Gin 的 Session Store、GORM 的各种 Driver 都是基于这种设计。

生活类比:Go 的接口是"鸭子类型"——“走路像鸭子、叫起来像鸭子,那它就是鸭子”。你不用显式写 UserRepository implements Repository,只要结构体把接口里的方法都实现了,编译器就认它实现了该接口。这就像招聘时不看简历写没写"会写代码",而是直接考你写不写得出代码。

flowchart LR
    I["Repository 接口
Save / Load"] -. 只要方法集满足 .-> S["MySQLRepository
实现了 Save+Load"] I -. 只要方法集满足 .-> M["MongoRepository
实现了 Save+Load"] B[业务代码] -->|只依赖接口| I

5.2 结构体与指针接收器

package user

import "time"

type User struct {
    ID        int64
    Name      string
    Email     string
    CreatedAt time.Time
}

// 指针接收器:方法内修改字段会生效
// 工程口诀:遇事不决用指针接收器
func (u *User) Rename(newName string) {
    u.Name = newName
}

// 值接收器:方法内拿到的是副本,修改不会生效
func (u User) NameLength() int {
    return len(u.Name)
}

// Go 没有构造函数!通常用 NewXXX 函数代替
func NewUser(name, email string) *User {
    return &User{
        Name:      name,
        Email:     email,
        CreatedAt: time.Now(),
    }
}

生活类比:值接收器像"复印件"——方法里改的是副本,原件不动;指针接收器像"原件"——方法里改的就是那一份。所以凡是方法里要改结构体字段(如 Rename),或者结构体很大不想每次调用都拷贝一份,都用指针接收器。

值接收器 vs 指针接收器

  • 值接收器:方法调用是值传递,结构体被复制一份。适合只读方法。
  • 指针接收器:复制的是指针,指向同一份结构体。适合会修改字段的方法。
  • 混用风险:如果一个类型同时有值接收器和指针接收器的方法,只有指针接收器的方法集在接口判定时被认可。所以工程上一个类型的方法接收器应保持一致

5.3 组合优于继承

Go 没有 extends,只有组合。组合不是继承,没有多态

package main

import "fmt"

// Base
type Logger struct{}

func (l Logger) Log(msg string) { fmt.Println("log:", msg) }

// Service 组合了 Logger,可以直接调用 Log 方法
type Service struct {
    Logger // 匿名字段:组合
}

func (s *Service) DoWork() {
    // 可以直接在 s 上调用 Logger 的方法
    s.Log("starting work")
    fmt.Println("working...")
}

func main() {
    s := &Service{}
    s.DoWork()
    // 输出:
    // log: starting work
    // working...
}

Java 转来的同学特别小心:组合不是继承,Service 不会因为重写 Log 而改变 DoWork 里调用的 Log 行为(因为 Go 没有动态分派)。如果你需要这种效果,要用接口字段。

5.4 衍生类型 vs 类型别名

type MyInt int     // 衍生类型:全新类型,有独立方法集
type MyInt2 = int  // 类型别名:只是换名字,本质还是 int

衍生类型常用于给第三方库类型扩展方法(不能改源码时)。

工程视角type MyInt inttype MyInt = int 只差一个 =,语义天差地别。前者是"新类型",有独立方法集,不能和 int 直接运算(要显式转换),适合给第三方类型"加方法"又不污染原类型;后者是"别名",和原类型完全等价,只是换了个名字,主要用于大重构时逐步迁移。分不清这俩,是面试和实战里的常见扣分项。


六、泛型

Go 1.18 引入泛型。语法非常简洁。

6.1 泛型接口、结构体、方法

package slice

// 泛型约束:用 ~ 表示底层类型
type Number interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
        ~float32 | ~float64
}

这张图说明"约束"就是给类型参数 T 划一个允许的集合

```mermaid
flowchart LR
    T["类型参数 T"] -->|必须满足| N["Number 约束"]
    N --> A["int / int64 / float64 ..."]
    N -.  ~ 包含底层类型 .-> B["哪怕衍生类型<br/>MyInt 也满足"]

// 泛型方法:求任意数字类型切片的和 func Sum[T Number](nums []T) T { var total T for _, n := range nums { total += n } return total }

// 泛型结构体 type Stack[T any] struct { data []T }

func (s *Stack[T]) Push(v T) { s.data = append(s.data, v) } func (s *Stack[T]) Pop() (T, bool) { var zero T if len(s.data) == 0 { return zero, false } v := s.data[len(s.data)-1] s.data = s.data[:len(s.data)-1] return v, true }


### 6.2 实战:泛型工具方法

```go
package slice

// Filter 过滤切片
func Filter[T any](src []T, f func(T) bool) []T {
    dst := make([]T, 0, len(src))
    for _, v := range src {
        if f(v) {
            dst = append(dst, v)
        }
    }
    return dst
}

// Max 求最大值
func Max[T Number](nums []T) (T, bool) {
    var zero T
    if len(nums) == 0 {
        return zero, false
    }
    max := nums[0]
    for _, n := range nums[1:] {
        if n > max {
            max = n
        }
    }
    return max, true
}

// Insert 在指定位置插入元素
func Insert[T any](src []T, idx int, v T) []T {
    if idx < 0 || idx > len(src) {
        panic("index out of range")
    }
    // 预留一个位置
    dst := append(src, v)
    // 把 v 复制到 idx,后面的元素后移
    copy(dst[idx+1:], dst[idx:])
    dst[idx] = v
    return dst
}

// Delete 删除指定下标元素(不支持缩容,可自行扩展)
func Delete[T any](src []T, idx int) []T {
    if idx < 0 || idx >= len(src) {
        panic("index out of range")
    }
    // 用后续元素前移覆盖 idx
    return append(src[:idx], src[idx+1:]...)
}

七、工程实践要点

7.1 切片使用禁忌

  1. 不要依赖子切片的独立性:子切片与原切片共享内存,修改会互相影响。需要独立副本时用 copy
  2. 不要对 for range 迭代变量取地址:会指向同一个地址。
  3. 大切片返回子切片要警惕内存泄露:底层数组无法被 GC。用 copy 截取需要的部分。
  4. 预估容量make([]T, 0, cap) 比反复 append 扩容性能好得多。

7.2 map 使用注意

  1. map 不是并发安全的:并发读写会 panic。并发场景用 sync.Map 或加锁。
  2. 遍历顺序随机:不要依赖遍历顺序,需要有序就先取 key 排序再遍历。
  3. 删除大 map 元素后内存不会立即释放:必要时重建 map。

7.3 defer 注意事项

  1. 一个函数内 defer 不超过 8 个:否则失去开放编码优化。
  2. defer 不要放在循环里:循环里 defer 会堆积,且开放编码优化失效。
  3. defer 适合资源释放Close()Unlock()ResponseWriter.Write() 等。
  4. 闭包引用要小心:闭包引用的变量不会被 GC,长生命周期闭包要避免引用大对象。

7.4 命名与可见性

  • Go 用首字母大小写控制可见性,这是 Go 区别于其它语言的关键设计。
  • 大写 = 包外可访问(导出),小写 = 包内私有。
  • 包名应该简洁、小写、单数(不要 users,要 user)。

八、自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 一个 Go 程序必须有几个 main 包和几个 main 函数?把 package main 写成 package hellogo run 会报什么错?
  2. stringlen() 返回的是什么长度?要正确截断一个含中文的字符串应该怎么做?为什么 s[:5] 可能得到乱码?
  3. 切片 s[a:b] 和原切片是什么关系?为什么说"函数返回子切片可能导致内存泄露",怎么解决?
  4. defer 后进先出,那么 defer fmt.Println(x)(参数传值)和 defer func(){ fmt.Println(x) }()(闭包引用),在 x 后续被修改时分别打印什么?为什么?
  5. map 的 key 必须满足什么约束?为什么 Go 的 map 遍历顺序每次都不同?并发读写 map 会发生什么?

动手练习(建议真做一遍)

  1. 故意写错 package:把 main.gopackage main 改成别的名字,go run 看报错,再改回来,体会"入口包"的硬性要求。
  2. 验证切片共享:写代码创建 s := make([]int, 3, 5),取子切片 sub := s[:2],修改 sub[0] 打印 s[0] 验证被改;再 append 触发扩容后验证不再共享。
  3. 写个泛型工具:用泛型实现 Map 函数,把 []int{1,2,3} 每个元素平方成 []int{1,4,9},并试着用 Number 约束复用 Sum

九、本章小结

  • 工程结构:一个 Go 进程 = 一个 main 包 + main 函数;package 名与目录名一致;go run 开发用,go build 部署用。
  • 类型:默认用 int/float64;string 是字节序列,中文处理要用 []rune;强类型,没有自动转换。
  • 方法:多返回值是 Go 的特色;defer 后进先出;闭包引用的变量不会被 GC;defer 实现有三种机制(堆/栈/开放编码),开放编码最快但有限制。
  • 切片:底层数组 + 头部结构;子切片共享内存,扩容才分离;cap < 256 两倍扩容,否则 1.25 倍;预分配容量是最佳实践。
  • map:key 必须 comparable;遍历顺序随机;非并发安全。
  • 接口与组合:鸭子类型隐式实现;组合优于继承,没有多态;遇事不决定义接口。
  • 泛型:Go 1.18+ 支持;可用于接口、结构体、方法;适合封装通用工具库。

下一章我们将进入 Gin + GORM 的世界,把这些语法基础用到真实的 Web 工程里。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!