开篇:先别怕指针

先讲个大实话:很多人听到「指针」就头大,是被 C 语言的指针吓的——C 的指针能做运算(p+1 跳到下一个内存单元)、能乱指、能越界,出了名难搞。

Go 的指针简单得离谱,它砍掉了 C 指针所有危险的部分,只保留两件事:

  1. & 取地址:拿到一个变量「住在哪里」
  2. * 解引用:通过地址「去那个地方取东西」

就这两件事,没了。Go 的指针不能做运算,你没法像 C 那样写出 p++ 越界访问内存的代码。所以放心,我们慢慢来。

那 Go 为什么要有指针?因为 Go 默认全是值拷贝。你在 JS 里习惯了「对象传给函数自动是引用」,Go 偏不——它每次都老老实实复制一份。当你想「函数里改一改,外面能看见」时,就必须靠指针了。我们后面会一步步演示为什么。


5.1 为什么 Go 没有 class?

在 JS / PHP 里,你用 class 把「数据」和「行为」绑在一起:

// JavaScript
class User {
    constructor(name, age) {
        this.name = name
        this.age = age
    }
    greet() {
        return `Hi, I'm ${this.name}`
    }
}
const u = new User("张三", 28)
// PHP
class User {
    public $name;
    public $age;
    public function greet() { return "Hi, $this->name"; }
}

Go 故意没有 class 关键字。它的设计哲学是「组合优于继承」,所以把数据和 behaviour 拆开:

  • 数据 → 用 struct(结构体)组织
  • 行为 → 用方法绑定到 struct 上(下一章详讲)

为什么不搞 class?因为 class + 继承在大型项目里会变成「继承地狱」(祖父→父亲→儿子三层往上找方法)。Go 选择 struct + 组合,更扁平、更好维护。刚开始你会觉得别扭,用一段时间会爱上它的简单。


5.2 定义结构体

type User struct {
    Name  string    // 大写:导出(别的包能访问)
    Age   int
    email string    // 小写:未导出(仅本包可见)
}

注意:字段名首字母大小写决定可见性,这是 Go 用大小写代替 public/private 的核心机制(第 09 章详讲)。

创建实例的几种方式

// 1. 按字段名(推荐,最清晰)
u1 := User{Name: "张三", Age: 28}

// 2. 按顺序(字段多了易错,不推荐)
u2 := User{"李四", 30, "ls@x.com"}

// 3. new(返回指针,字段零值)
u3 := new(User)
u3.Name = "王五"

// 4. 取地址字面量(常用,返回指针)
u4 := &User{Name: "赵六"}

💡 小知识:Go 没有 new User() 这种构造语法。初始化结构体一律用「字面量」User{...},简洁统一。

Struct Tag

什么是 Struct Tag?

Struct Tag 是 Go 语言中附加在结构体字段上的元数据字符串,用反引号 包裹。它本身不会影响结构体的编译或运行逻辑,但可以通过reflect 包在运行时被读取和利用。Tag 由一个或多个键值对组成,格式为:

`key1:"value1" key2:"value2" key3:"value3" ...`

具体规则:

  • key:必须是非空字符串,不能包含控制字符、空格、引号、冒号
  • value:用双引号标记的字符串
  • 多个键值对:用空格分隔
  • 冒号前后不能有空格

示例:

type Person struct {
    Name string `json:"user_name" xml:"name" validate:"required"`
    Age  int    `json:"age,omitempty"`
}

如何读取 Tag?

Go 标准库的 reflect 包提供了读取 Tag 的方法:

package main

import (
    "fmt"
    "reflect"
)

type Person struct {
    Name string `json:"user_name" db:"name"`
    Age  int    `json:"age,omitempty"`
}

func main() {
    t := reflect.TypeOf(Person{})
    
    // 通过字段索引获取
    field := t.Field(0)
    fmt.Println(field.Tag.Get("json")) // 输出: user_name
    fmt.Println(field.Tag.Get("db"))   // 输出: name
    
    // 通过字段名获取
    field2, _ := t.FieldByName("Age")
    fmt.Println(field2.Tag.Get("json")) // 输出: age,omitempty
}

StructTag 提供了 Get(key string) string 方法,用于获取指定 key 对应的 value。

使用标准化的 Tag 名称

Tag 的 key 是任意非空字符串,开发者可以根据需要自定义。对于通用场景,优先使用社区公认的 Tag 名称:

  • jsonxmlyaml:序列化
  • db:数据库映射
  • gorm:GORM ORM
  • bson:MongoDB
  • form:表单绑定
  • bindingvalidate:参数校验

5.3 指针(重点详讲)

5.3.1 先建立直觉:变量住在哪?

想象内存是一栋公寓楼,每个变量住一个房间,房间有门牌号(地址)。

公寓楼(内存)
┌────────────────────────────────┐
│ 房间 0x1001: 住着 a = 5         │  ← 变量 a
│ 房间 0x1002: (空)             │
│ 房间 0x1003: 住着 b = "hi"      │  ← 变量 b
└────────────────────────────────┘

指针,就是一张写着「房间门牌号」的小纸条:

┌────────────────────┐
│  指针 p             │
│  纸条上写: 0x1001   │  ← 指向 a 的房间
└────────────────────┘

拿到这张纸条,你就能顺着门牌号找到 a,读它的值、改它的值。

5.3.2 两个操作符:&*

a := 5
p := &a          // & 取地址:p 现在是「指向 a 的指针」
fmt.Println(p)   // 0xc0000a2000(门牌号,每次运行不同)
fmt.Println(*p)  // 5  —— * 解引用:顺着地址去取值

*p = 10          // 通过指针改 a 的值
fmt.Println(a)   // 10 —— a 真的被改了!

用一句话记

  • & 是「问地址」:&a = 「a 住哪个房间?」
  • * 是「取东西」:*p = 「去 p 指向的房间,把里面的东西拿出来」

5.3.3 指针的类型

var p *int        // p 是「指向 int 的指针」
var u *User       // u 是「指向 User 的指针」

*T 读作「指向 T 的指针」。

5.3.4 结构体指针的语法糖

u := &User{Name: "zs"}

// 显式写法
(*u).Name = "李四"

// Go 自动解引用(推荐写法)
u.Name = "李四"      // ← Go 帮你自动 (*u).Name

Go 没有 -> 操作符(不像 C++)。指针访问字段直接用 .,编译器自动解引用。这是 Go 的贴心之处。


5.4 值拷贝 vs 指针传递(核心难点)

这是指针唯一的实际用途,也是新手最容易踩的坑。我们用代码一步步演示。

5.4.1 值拷贝:函数内修改,外面看不见

先看 JS 的习惯:

// JS:对象自动是引用,函数内改了外面能看到
function rename(u) { u.name = "改了" }
const u = { name: "原值" }
rename(u)
console.log(u.name)   // "改了"

现在看 Go:

// Go:默认全复制!
func rename(u User) {        // ← 注意:参数是 User(值),不是指针
    u.Name = "改了"
}

func main() {
    u := User{Name: "原值"}
    rename(u)
    fmt.Println(u.Name)      // "原值" —— 没变!
}

为什么没变? 因为 rename(u) 把 u 整份复制了一份给参数,函数里改的是那份「复印件」,原件没动。

画个内存图就一目了然:

调用 rename(u) 之前:
┌───────────────────┐
│ u = {Name:"原值"}  │  ← 原件
└───────────────────┘

进入函数后,复制了一份:
┌───────────────────┐     ┌─────────────────────────┐
│ u = {Name:"原值"}  │     │ 参数 u = {Name:"原值"}   │  ← 复印件
└───────────────────┘     └─────────────────────────┘
                            函数里改的是这个 ↓
                          │ 参数 u = {Name:"改了"}   │
                          └─────────────────────────┘

函数返回,复印件销毁,原件始终没动:
┌───────────────────┐
│ u = {Name:"原值"}  │  ← 还是原值
└───────────────────┘

5.4.2 指针传递:函数内修改,外面能看见

要「改外面」,就把「门牌号」传进去:

func rename(u *User) {       // ← 参数是 *User(指针)
    u.Name = "改了"           // 顺着地址去改原件
}

func main() {
    u := User{Name: "原值"}
    rename(&u)                // ← 传 u 的地址
    fmt.Println(u.Name)       // "改了" —— 改到了!
}

内存图:

调用 rename(&u),传的是门牌号:
┌───────────────────┐
│ u = {Name:"原值"}  │  ← 原件,住在 0x1001
│ 地址 0x1001        │
└───────────────────┘
       ▲
       │ 门牌号传给参数
┌──────┴────────────┐
│ 参数 u = 0x1001    │  ← 参数只是张纸条
└───────────────────┘
   顺着 0x1001 找到原件,改它:
┌───────────────────┐
│ u = {Name:"改了"}  │  ← 原件被改了
└───────────────────┘

5.4.3 什么时候用指针?(决策表)

场景 用指针 用值
函数要修改参数
struct 很大(字段多) ✅(省复制)
要表示「可能为空」 ✅(指针可为 nil)
struct 很小且只读
map / slice / channel ✅(它们本身是引用)

经验:struct 默认用指针接收者;基本类型(int/string)用值。


5.5 嵌套(组合代替继承)

Go 没有 extends,用嵌套实现代码复用。

5.5.1 命名嵌套

type Address struct {
    City    string
    ZipCode string
}

type User struct {
    Name string
    Addr Address    // 命名嵌套
}

u := User{Name: "zs", Addr: Address{City: "北京"}}
u.Addr.City        // 访问要带字段名

5.5.2 匿名嵌套(字段提升)

省略字段名,外层可直接访问内层字段——这就是 Go 的「继承替代品」:

type User struct {
    Name string
    Address          // 匿名嵌套(注意没字段名)
}

u := User{Name: "zs", Address: Address{City: "北京"}}
u.City             // ✅ 直接访问(字段提升),等价于 u.Address.City
u.Address.City     // 也可以显式访问

方法也会提升:嵌入类型的方法,外层能直接调用。下一章详讲。


5.6 new vs make(易混淆)

new(T) make(T, ...)
返回 *T(指针) T(值)
用于 任意类型 slice/map/channel
初始化 零值 非零(已分配)
u := new(User)            // *User,字段零值
s := make([]int, 5)       // []int

99% 用字面量或 make,new 很少用。


5.7 nil 指针与报错诊断

5.7.1 nil 指针

指针的零值是 nil(空指针,指向「不存在的地方」):

var p *User       // p == nil
// p.Name         // ❌ 运行时 panic

5.7.2 ⚠️ 报错诊断小节

学指针时你一定会撞上这些报错,对着这里查,省得瞎搜

报错 1:panic: runtime error: invalid memory address or nil pointer dereference

var p *User
p.Name = "zs"        // ← 报错在这里

原因:p 是 nil,没有指向任何 User,却去访问它的字段。
修复:先让 p 指向真实的 User:

p := &User{}
p.Name = "zs"        // ✅

报错 2:cannot use u (type User) as type *User

func f(u *User) {}
f(User{Name:"zs"})   // ← 报错

原因:函数要指针,你传了值,类型不匹配。
修复:传地址 f(&User{Name:"zs"})

报错 3:函数内修改了 struct,外面没生效

func birthday(u User) { u.Age++ }
u := User{Age: 28}
birthday(u)
fmt.Println(u.Age)   // 28,没变

原因:值传递,改的是复印件。
修复:改用指针 func birthday(u *User),调用 birthday(&u)

报错 4:cannot assign to struct field ... in map

m := map[string]User{"a": {Age: 1}}
m["a"].Age = 2       // ← 报错

原因:map 的值不可寻址(map 可能 rehash,地址不稳定)。
修复:先取出改完再放回,或用 map[string]*User

u := m["a"]; u.Age = 2; m["a"] = u   // ✅
// 或
m := map[string]*User{"a": {Age: 1}}
m["a"].Age = 2       // ✅ 指针可改

5.8 逃逸分析(了解即可)

Go 编译器自动决定变量放栈还是堆:

func f() *int {
    x := 5
    return &x     // x 的地址逃出函数 → 分配在堆
}

规则:变量地址「逃出」函数(被返回/存全局/传 goroutine)就放堆,否则放栈。Go 不需要你手动管理,GC 自动回收堆。用 go build -gcflags="-m" 可查看逃逸分析。


Node.js / PHP ↔ Go 对照表

概念 Node.js / PHP Go 备注
组织数据 class struct Go 无 class
创建实例 new Class() Struct{} / new(Struct)
字段可见性 public/private 首字母大小写 Go 用大小写
继承 extends 嵌套组合 Go 无继承
对象引用 自动引用 必须用指针 Go 默认值拷贝
取地址 无显式概念 &a
解引用 *p
空对象 null nil 指针才可为 nil