开篇:先别怕指针
先讲个大实话:很多人听到「指针」就头大,是被 C 语言的指针吓的——C 的指针能做运算(p+1 跳到下一个内存单元)、能乱指、能越界,出了名难搞。
但 Go 的指针简单得离谱,它砍掉了 C 指针所有危险的部分,只保留两件事:
&取地址:拿到一个变量「住在哪里」*解引用:通过地址「去那个地方取东西」
就这两件事,没了。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 名称:
json、xml、yaml:序列化db:数据库映射gorm:GORM ORMbson:MongoDBform:表单绑定binding、validate:参数校验
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 |