Go 从 0 到精通 · 第 13 课:方法 method

学习定位:这是整套 Go 教程的第 13 课,也是阶段二(核心语法阶段)的最后一课。
前置要求:已经完成第 12 课,掌握了结构体的定义、初始化、字段访问、嵌套和值语义。
本课目标:理解 Go 中"数据 + 行为"的组织方式,掌握值接收者和指针接收者的区别,能为结构体定义相关行为。


1. 本课你要解决的核心问题

第 12 课你学会了用结构体把数据打包在一起。但数据本身没有行为——你只能在外面写函数来操作它:

1
2
3
4
5
6
7
8
9
type Student struct {
Name string
Age int
}

// 数据和操作是分离的
func birthday(s *Student) {
s.Age++
}

方法让你把操作"绑定"到类型上,实现"数据 + 行为"的统一:

1
2
3
4
5
func (s *Student) Birthday() {
s.Age++
}

// 使用:s.Birthday() —— 比 birthday(&s) 更自然

你需要搞明白以下问题:

  • 方法和普通函数有什么区别
  • 值接收者和指针接收者分别怎么用
  • 什么时候用值接收者,什么时候用指针接收者
  • 方法的本质是什么
  • 方法有哪些常见坑

学完这一课,你就完成了阶段二的全部内容,掌握了 Go 中组织程序的核心工具。


2. 方法的基本概念

2.1 什么是方法

方法就是绑定了接收者(receiver)的函数。接收者写在 func 关键字和方法名之间:

1
2
3
func (接收者变量 接收者类型) 方法名(参数列表) 返回值列表 {
方法体
}

对比普通函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 普通函数
func Add(a, b int) int {
return a + b
}

// 方法(绑定到 Point 类型)
type Point struct {
X, Y float64
}

func (p Point) Distance() float64 {
return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

2.2 调用方式

1
2
3
4
5
6
7
8
func main() {
// 函数调用
result := Add(3, 5)

// 方法调用
p := Point{3, 4}
d := p.Distance() // 通过变量调用
}

p.Distance() 表示"在 p 上执行 Distance 操作"——这比 Distance(p) 更有表达力。


3. 值接收者

3.1 基本用法

值接收者的方法,接收的是结构体的副本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

type Counter struct {
Value int
}

func (c Counter) Show() {
fmt.Println("当前值:", c.Value)
}

func main() {
ct := Counter{Value: 10}
ct.Show() // 当前值: 10
}

(c Counter) 表示接收者是 Counter 类型的值(副本)。

3.2 值接收者不能修改原结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

type Counter struct {
Value int
}

func (c Counter) Increment() {
c.Value++ // 修改的是副本
}

func main() {
ct := Counter{Value: 10}
ct.Increment()
ct.Increment()
fmt.Println(ct.Value) // 10(没变)
}

Increment 拿到的是 ct 的副本,c.Value++ 修改的是副本,ct 本身没有变。

这和第 12 课的值传递问题是一样的——值接收者就是值传递。


4. 指针接收者

4.1 基本用法

指针接收者的方法,接收的是结构体的指针,可以修改原结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

type Counter struct {
Value int
}

func (c *Counter) Increment() {
c.Value++ // 通过指针修改原结构体
}

func main() {
ct := Counter{Value: 10}
ct.Increment()
ct.Increment()
fmt.Println(ct.Value) // 12
}

(*Counter) 表示接收者是指针。Go 的语法糖让你不需要写 (*c).Value++,直接用 c.Value++

4.2 指针接收者 vs 值接收者

特性 值接收者 指针接收者
语法 (c Counter) (c *Counter)
拿到的是 副本 指针(共享)
能否修改原结构体 不能
调用时 ct.Show() ct.Increment()

5. 调用时的自动转换

Go 编译器在方法调用时会做一些自动转换,让你不需要手动取址或解引用。

5.1 值变量可以调用指针接收者的方法

1
2
3
4
5
6
7
8
9
10
11
12
type Counter struct {
Value int
}

func (c *Counter) Increment() {
c.Value++
}

func main() {
ct := Counter{Value: 10} // ct 是值
ct.Increment() // Go 自动转为 (&ct).Increment()
}

Go 自动帮你取了地址。

5.2 指针变量可以调用值接收者的方法

1
2
3
4
5
6
7
8
func (c Counter) Show() {
fmt.Println("值:", c.Value)
}

func main() {
ct := &Counter{Value: 10} // ct 是指针
ct.Show() // Go 自动转为 (*ct).Show()
}

Go 自动帮你解了引用。

5.3 总结

不管接收者是指针还是值,调用方式都一样:变量.方法名()。Go 编译器帮你搞定转换。


6. 什么时候用指针接收者

这是实践中最重要的判断之一。

6.1 必须用指针接收者的情况

  • 方法需要修改接收者:比如 IncrementSetName
  • 结构体很大:值接收者每次调用都复制整个结构体
  • 保持一致性:如果一个类型的某个方法用了指针接收者,其他方法也建议用指针接收者

6.2 可以用值接收者的情况

  • 方法不需要修改接收者:比如 ShowString、只读查询
  • 结构体很小:复制成本低
  • 并发安全考虑:值接收者天然不共享数据

6.3 实践建议

如果不确定,用指针接收者。大多数实际项目中的方法都使用指针接收者,因为结构体通常需要被修改,而且避免复制。

Go 标准库中的常见类型(bytes.Bufferstrings.Builderhttp.Request)的方法基本都是指针接收者。


7. 方法的本质

方法本质上就是语法糖。编译器会把方法调用转换成普通函数调用:

1
2
3
ct.Increment()
// 编译器转为:
Counter.Increment(ct) // ct 作为第一个参数传入

接收者就是第一个参数。值接收者是值传递,指针接收者是指针传递。

这和你第 6 课学的"Go 的参数传递"完全一致。方法只是让代码看起来更面向对象。


8. 给非结构体类型定义方法

方法不仅可以绑定到结构体,还可以绑定到任何自定义类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import "fmt"

type MyInt int

func (m MyInt) IsPositive() bool {
return m > 0
}

func main() {
var n MyInt = 42
fmt.Println(n.IsPositive()) // true
}

但你不能给其他包的类型定义方法——只能给当前包中定义的类型。

也不能给内置类型(如 intstring)直接定义方法,必须先用 type 创建新类型。


9. String() 方法:自定义打印格式

Go 中有一个特殊的约定:如果你的类型实现了 String() string 方法,fmt.Printlnfmt.Printf 会自动调用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

type Student struct {
Name string
Age int
Score float64
}

func (s Student) String() string {
return fmt.Sprintf("%s (年龄: %d, 分数: %.1f)", s.Name, s.Age, s.Score)
}

func main() {
s := Student{Name: "小明", Age: 20, Score: 92.5}
fmt.Println(s) // 小明 (年龄: 20, 分数: 92.5)
fmt.Printf("%s\n", s) // 同上
}

fmt.Println 会检查类型是否实现了 String() 方法,如果有就调用它来获取字符串表示。

这是 Go 中最常用的"行为定制"模式之一。


10. 一段综合示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main

import "fmt"

type BankAccount struct {
Owner string
Balance float64
}

// 存款(指针接收者:需要修改余额)
func (a *BankAccount) Deposit(amount float64) error {
if amount <= 0 {
return fmt.Errorf("存款金额必须为正数")
}
a.Balance += amount
return nil
}

// 取款(指针接收者:需要修改余额)
func (a *BankAccount) Withdraw(amount float64) error {
if amount <= 0 {
return fmt.Errorf("取款金额必须为正数")
}
if amount > a.Balance {
return fmt.Errorf("余额不足:当前余额 %.2f", a.Balance)
}
a.Balance -= amount
return nil
}

// 查询余额(值接收者:不需要修改)
func (a BankAccount) Info() string {
return fmt.Sprintf("%s 的账户,余额:%.2f", a.Owner, a.Balance)
}

// 转账
func (a *BankAccount) Transfer(to *BankAccount, amount float64) error {
if err := a.Withdraw(amount); err != nil {
return err
}
to.Deposit(amount)
return nil
}

func main() {
alice := BankAccount{Owner: "Alice", Balance: 1000}
bob := BankAccount{Owner: "Bob", Balance: 500}

fmt.Println(alice.Info())
fmt.Println(bob.Info())

// 存款
alice.Deposit(200)
fmt.Println("\n存款后:", alice.Info())

// 取款
if err := bob.Withdraw(100); err != nil {
fmt.Println("取款失败:", err)
}
fmt.Println("取款后:", bob.Info())

// 转账
fmt.Println("\n=== 转账 ===")
if err := alice.Transfer(&bob, 300); err != nil {
fmt.Println("转账失败:", err)
}
fmt.Println(alice.Info())
fmt.Println(bob.Info())

// 异常取款
fmt.Println("\n=== 异常测试 ===")
if err := bob.Withdraw(10000); err != nil {
fmt.Println(err)
}
if err := alice.Deposit(-100); err != nil {
fmt.Println(err)
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
Alice 的账户,余额:1000.00
Bob 的账户,余额:500.00

存款后: Alice 的账户,余额:1200.00
取款后: Bob 的账户,余额:400.00

=== 转账 ===
Alice 的账户,余额:900.00
Bob 的账户,余额:700.00

=== 异常测试 ===
余额不足:当前余额 700.00
存款金额必须为正数

11. 常见坑总结

11.1 值接收者的方法无法修改接收者

1
func (c Counter) Increment() { c.Value++ }  // 改的是副本

需要修改就用指针接收者。

11.2 混用值接收者和指针接收者

1
2
3
4
type Counter struct { Value int }

func (c Counter) Show() {} // 值接收者
func (c *Counter) Increment() {} // 指针接收者

虽然 Go 允许这样做,但混用会让代码风格不一致。建议:一个类型的全部方法要么都用值接收者,要么都用指针接收者。如果需要修改就统一用指针接收者。

11.3 在 nil 接收者上调用方法

1
2
3
4
5
6
func (a *BankAccount) Info() string {
return fmt.Sprintf("%s: %.2f", a.Owner, a.Balance)
}

var a *BankAccount
a.Info() // panic: nil pointer

如果接收者可能是 nil,方法内部要做检查。但通常情况下,不需要主动处理 nil 接收者——确保不传 nil 就行。

11.4 以为方法需要定义在同一个文件里

Go 允许同一个类型的方法分散在多个文件中,只要它们在同一个包里。但为了可读性,通常把一个类型的所有方法放在一起。

11.5 不能给其他包的类型定义方法

1
2
// 不能这样做:
func (s strings.Builder) MyMethod() {} // 编译错误

只能给当前包中定义的类型或自定义类型定义方法。


12. 阶段二完成回顾

到这里,你已经完成了阶段二:核心语法阶段的全部 7 课内容。

回顾一下你现在的能力:

课次 主题 你获得的能力
第 07 课 指针入门 理解指针、&*,能在函数里修改外部变量
第 08 课 数组 理解固定长度集合,知道数组是值类型
第 09 课 切片 掌握动态数组,append、截取、容量管理
第 10 课 映射 map 掌握键值对查找、增删改查、遍历
第 11 课 字符串与编码 理解 byte/rune、正确处理中文
第 12 课 结构体 能用结构体建模业务对象
第 13 课 方法 能为类型定义行为,理解值接收者与指针接收者

你现在已经具备了用 Go 组织中等复杂度程序的完整能力。接下来我们进入阶段三:核心抽象阶段,开始学习接口、组合、错误处理这些更高层次的设计能力。


13. 本课练习

练习 1:为结构体添加方法

要求:

  • 定义 Circle 结构体(Radius float64
  • 添加 Area() 方法(值接收者,返回面积)
  • 添加 Scale(factor float64) 方法(指针接收者,缩放半径)
  • 测试调用

练习 2:值接收者 vs 指针接收者

要求:

  • 定义 Score 结构体(Value int
  • 定义值接收者方法 Display()
  • 定义指针接收者方法 Add(n int)
  • 验证 Display 不修改原值,Add 修改原值

练习 3:自定义 String() 方法

要求:

  • 定义 Color 结构体(R, G, B uint8
  • 实现 String() string 方法,返回 "rgb(R, G, B)"
  • fmt.Println 验证自动调用

练习 4:链式调用

要求:

  • 定义 StringBuilder 结构体(内部用 []byte 存储)
  • 实现 Append(s string) *StringBuilder 方法
  • 实现 String() string 方法
  • 支持链式调用:sb.Append("Hello").Append(" ").Append("Go").String()

提示:Append 返回 *StringBuilder(接收者本身)。


练习 5:综合项目——简单银行系统

要求:

  • 定义 Account 结构体(OwnerBalance
  • 实现 DepositWithdrawTransfer 方法
  • Withdraw 余额不足时返回错误
  • Transfer 从一个账户转到另一个
  • 所有修改操作用指针接收者

练习 6:给自定义类型定义方法

要求:

  • type Celsius float64type Fahrenheit float64
  • Celsius 添加 ToFahrenheit() 方法
  • Fahrenheit 添加 ToCelsius() 方法
  • 给两个类型都实现 String() 方法,分别显示 "XX°C""XX°F"
  • 测试 fmt.Println(celsius值)fmt.Println(fahrenheit值)

14. 自测题

14.1 概念题

  1. 方法和普通函数的区别是什么?
  2. 接收者写在函数签名的什么位置?
  3. 值接收者和指针接收者的核心区别是什么?
  4. 值接收者的方法能修改原结构体吗?
  5. 值变量能调用指针接收者的方法吗?Go 怎么处理的?
  6. 什么时候应该用指针接收者?
  7. String() 方法有什么特殊之处?
  8. 方法本质上是什么?
  9. 能给其他包的类型定义方法吗?
  10. 一个类型的方法可以混用值接收者和指针接收者吗?推荐吗?

15. 本课总结

这一课你学到了 Go 中"数据 + 行为"的组织方式——方法。

你现在应该已经理解:

  • 方法是绑定了接收者的函数
  • 值接收者拿到副本,不能修改原结构体
  • 指针接收者拿到指针,可以修改原结构体
  • 调用时 Go 自动做取址/解引用转换
  • 需要修改接收者或结构体较大时用指针接收者
  • 实现 String() string 可以自定义打印格式
  • 不能给其他包的类型定义方法

16. 下一课预告

下一课我们进入阶段三:核心抽象阶段,首先学习:接口 interface 入门

会重点讲:

  • 接口是什么,Go 的接口和其他语言有什么不同
  • 什么是"鸭子类型"
  • 怎么定义接口、怎么实现接口
  • 空接口 interface{} 有什么用
  • 接口在实际开发中的价值

学完下一课,你就能理解 Go 的抽象能力来自接口而非继承,开始用更高级的方式组织代码。