函数是 Go 语言里最基础、也最常用的组织代码方式。前面我们已经接触过 main 函数,也在条件判断和循环中写过不少逻辑,但如果所有代码都堆在 main 里,程序很快就会变得臃肿、难读、难维护。
这一章我们系统学习 Go 中的函数与方法,包括:
- 函数定义
- 多返回值与命名返回值
- 可变参数函数
- 匿名函数与闭包
- 函数作为一等公民(高阶函数)
- 方法(Method)与接收者(值接收者 vs 指针接收者)
如果你已经有一些其他语言基础,那么可以把这一章理解为:Go 是怎样用尽量简洁的方式,让“行为”组织得更清楚、更安全、更适合工程实践的。
为什么函数和方法很重要
函数解决的是“把一段逻辑封装起来,重复使用”的问题;方法解决的是“让某个类型不仅有数据,还有与数据相关的行为”的问题。
简单来说:
- 函数 更像独立工具
- 方法 更像某个类型自带的能力
比如:
- “计算两个数的和”更适合写成函数
- “让用户打印自己的信息”更适合写成
User类型的方法
它们共同决定了代码是否清晰、模块是否合理、后续是否容易扩展。
一、函数定义、多返回值、命名返回值
1. 函数的基本定义
Go 使用 func 关键字定义函数,基本结构如下:
func 函数名(参数列表) 返回值类型 {
// 函数体
}
如果有多个参数,类型相同的相邻参数可以合并写。
下面是一个完整可运行的例子:
package main
import "fmt"
func add(a int, b int) int {
return a + b
}
func greet(name string) string {
return "你好," + name
}
func main() {
sum := add(10, 20)
message := greet("Go 开发者")
fmt.Println("10 + 20 =", sum)
fmt.Println(message)
}
这个例子里:
add接收两个int参数,返回一个intgreet接收一个字符串参数,返回一个字符串main中通过调用函数,把不同逻辑拆开管理
函数最大的好处就是把“做什么”抽象成一个名字。以后再次需要这段逻辑时,不必重复写代码,只需要调用函数即可。
2. 多返回值:Go 很有代表性的特性
Go 的函数可以返回多个值,这是它非常实用、也非常有辨识度的语言特性。
在很多语言里,一个函数只能返回一个结果。如果想同时返回“处理结果”和“错误信息”,通常要借助对象、数组、元组等结构。而 Go 允许直接返回多个值,因此它特别适合处理“结果 + 状态”这类场景。
下面的例子演示如何返回商和余数:
package main
import "fmt"
func divide(dividend int, divisor int) (int, int) {
quotient := dividend / divisor
remainder := dividend % divisor
return quotient, remainder
}
func main() {
q, r := divide(17, 5)
fmt.Println("商:", q)
fmt.Println("余数:", r)
}
这里的返回值类型写成 (int, int),表示函数会返回两个整数。
调用时可以这样接收:
q接收商r接收余数
这种写法在 Go 中极其常见,尤其是与错误处理结合时。
3. 多返回值与错误处理
Go 不鼓励把普通错误处理交给异常机制,而是倾向于通过返回值把错误显式传出来。最常见的函数签名形式是:
func xxx(...) (结果类型, error)
下面是一个更贴近真实开发的完整例子:
package main
import (
"errors"
"fmt"
)
func safeDivide(dividend int, divisor int) (int, error) {
if divisor == 0 {
return 0, errors.New("除数不能为 0")
}
return dividend / divisor, nil
}
func main() {
result, err := safeDivide(20, 4)
if err != nil {
fmt.Println("计算失败:", err)
return
}
fmt.Println("计算成功,结果为:", result)
result, err = safeDivide(20, 0)
if err != nil {
fmt.Println("计算失败:", err)
return
}
fmt.Println("计算成功,结果为:", result)
}
这个例子里:
- 当除数不为 0 时,返回正常结果和
nil - 当除数为 0 时,返回默认值
0和一个错误对象 - 调用方通过
if err != nil显式处理错误
你会发现,Go 的多返回值并不是“语法炫技”,而是和整个错误处理风格紧密绑定的。
4. 命名返回值
Go 允许在函数签名中,直接给返回值命名。这样函数内部就像拥有了几个默认局部变量,可以在函数体里直接使用,最后只写一个 return。
下面是一个示例:
package main
import "fmt"
func rectangleStats(width float64, height float64) (area float64, perimeter float64) {
area = width * height
perimeter = 2 * (width + height)
return
}
func main() {
area, perimeter := rectangleStats(5.5, 3.0)
fmt.Println("面积:", area)
fmt.Println("周长:", perimeter)
}
这里:
area和perimeter是命名返回值- 在函数体中可以直接给它们赋值
- 最后
return不需要再显式写变量名
这种写法在一些逻辑清晰、返回值含义明确的函数中很方便,但也不要滥用。因为一旦函数过长,读者可能不容易立刻知道最后到底返回了哪些值。
5. 什么时候使用命名返回值
可以用下面这张表快速判断:
| 场景 | 是否推荐 | 说明 |
|---|---|---|
返回值语义很明确,例如 width/height 计算得到 area/perimeter |
推荐 | 可读性较好 |
| 函数较短,返回逻辑简单 | 推荐 | 代码会比较自然 |
| 函数较长,分支很多 | 谨慎使用 | 容易让返回路径不够直观 |
| 只是为了少写几个变量名 | 不推荐 | 可读性比“省几行代码”更重要 |
对初学者来说,一个实用原则是:优先写清楚,再考虑写得巧。
二、可变参数函数
有时候,一个函数接收的参数数量并不固定。比如:
- 统计任意多个数字的总和
- 打印任意数量的日志标签
- 拼接多个字符串
Go 用 ...类型 表示可变参数。
1. 基本用法
下面是一个完整示例,用来计算任意多个整数的总和:
package main
import "fmt"
func sum(numbers ...int) int {
total := 0
for _, number := range numbers {
total += number
}
return total
}
func main() {
fmt.Println("sum() =", sum())
fmt.Println("sum(1, 2, 3) =", sum(1, 2, 3))
fmt.Println("sum(10, 20, 30, 40) =", sum(10, 20, 30, 40))
}
这里的 numbers ...int 表示:
- 可以传 0 个
int - 也可以传 1 个、多个
int - 在函数内部,
numbers的表现形式其实像一个[]int切片
所以你可以直接配合 range 来遍历。
2. 可变参数与切片展开
如果你手上已经有一个切片,想把它作为可变参数传进去,需要在切片后面加 ...。
package main
import "fmt"
func sum(numbers ...int) int {
total := 0
for _, number := range numbers {
total += number
}
return total
}
func main() {
scores := []int{88, 92, 95, 90}
total := sum(scores...)
fmt.Println("总分:", total)
}
注意这里的 scores... 不能少。否则编译器会认为你传入的是一个切片,而不是展开后的多个参数。
3. 可变参数的使用建议
| 建议 | 说明 |
|---|---|
| 参数数量天然不固定时使用 | 如日志、求和、拼接等场景 |
| 可变参数通常放在最后一个参数位置 | Go 语法也要求它必须是最后一个 |
| 参数过多、语义复杂时不要滥用 | 这种情况更适合结构体或普通参数 |
如果已有切片,记得用 slice... 展开 |
这是初学者常见易错点 |
三、匿名函数与闭包
Go 支持匿名函数,也就是没有名字的函数。这类函数通常用于:
- 临时封装一小段逻辑
- 作为参数传给其他函数
- 在函数内部构造“带状态”的行为
1. 匿名函数的基本写法
匿名函数可以定义后立即调用,也可以赋值给变量。
先看一个立即执行的例子:
package main
import "fmt"
func main() {
func(name string) {
fmt.Println("你好,", name)
}("Gopher")
}
上面这段代码的结构可以拆成两部分理解:
func(name string) { ... }定义了一个匿名函数- 后面的
("Gopher")表示立刻调用它
这种形式常用于:
- 初始化逻辑
- 一次性封装小段代码
- 配合 goroutine 启动并发任务(后面章节会学到)
再看一个赋值给变量的例子:
package main
import "fmt"
func main() {
multiply := func(a int, b int) int {
return a * b
}
fmt.Println("3 * 4 =", multiply(3, 4))
}
这说明函数本身也可以像普通值一样被保存和使用。
2. 什么是闭包
闭包可以理解为:函数“记住”了它定义时所处环境中的变量。
也就是说,一个函数不仅能执行代码,还能携带一些外部状态。
下面是一个经典示例:
package main
import "fmt"
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
next := counter()
fmt.Println(next())
fmt.Println(next())
fmt.Println(next())
another := counter()
fmt.Println(another())
}
运行这个程序时,你会看到:
next()第一次返回 1- 第二次返回 2
- 第三次返回 3
- 而
another()会从 1 重新开始
原因就在于:
counter()每调用一次,都会创建一个新的count- 返回的匿名函数捕获了这个
count - 后续每次调用这个匿名函数,都会继续使用同一个
count
这就是闭包的核心价值:把数据和行为临时绑定在一起。
3. 闭包适合什么场景
闭包常见于:
- 生成带状态的计数器
- 包装日志、缓存、权限检查等逻辑
- 给函数“预先绑定一部分参数”
不过对初学者来说,闭包虽然强大,但也要注意不要让状态来源太隐蔽。否则代码会变得不容易跟踪。
四、函数作为一等公民(高阶函数)
在 Go 中,函数是一等公民,意思是:
- 函数可以赋值给变量
- 函数可以作为参数传入另一个函数
- 函数可以作为返回值返回
当一个函数“接收函数”或“返回函数”时,通常就可以称为高阶函数。
1. 函数作为参数传递
下面我们写一个通用计算器,它接收一个“运算规则函数”:
package main
import "fmt"
func calculate(a int, b int, operation func(int, int) int) int {
return operation(a, b)
}
func add(a int, b int) int {
return a + b
}
func subtract(a int, b int) int {
return a - b
}
func main() {
fmt.Println("加法结果:", calculate(10, 5, add))
fmt.Println("减法结果:", calculate(10, 5, subtract))
multiply := func(a int, b int) int {
return a * b
}
fmt.Println("乘法结果:", calculate(10, 5, multiply))
}
这个例子很值得体会:
calculate并不关心“具体做什么运算”- 它只关心:传进来的函数符合
func(int, int) int这个签名 - 至于加法、减法、乘法,都可以动态切换
这会让代码更灵活,也更容易复用。
2. 函数作为返回值
除了把函数传进去,也可以把函数返回出来。
package main
import "fmt"
func makePrefixPrinter(prefix string) func(string) {
return func(message string) {
fmt.Println(prefix + message)
}
}
func main() {
infoPrinter := makePrefixPrinter("[INFO] ")
errorPrinter := makePrefixPrinter("[ERROR] ")
infoPrinter("服务启动成功")
errorPrinter("数据库连接失败")
}
这个例子同时也体现了闭包:
makePrefixPrinter返回一个函数- 返回的函数记住了
prefix - 所以我们可以得到不同风格的打印器
3. 高阶函数的价值
| 能力 | 说明 |
|---|---|
| 把变化点抽出来 | 让通用逻辑和具体策略分离 |
| 提高复用性 | 一个框架函数可搭配多个业务函数 |
| 让代码更灵活 | 运行时可以决定使用哪种行为 |
| 便于构建工具函数 | 如过滤、转换、包装、重试等 |
不过也要注意,过度函数式写法会让初学者不容易跟上。对于 Go 而言,实用和清晰永远比炫技巧更重要。
五、方法(Method)与接收者
学完函数之后,一个自然的问题是:既然函数已经能封装逻辑了,为什么还要方法?
答案是:方法让“行为”可以直接挂到某个类型上。
这意味着一个类型不只是保存数据,还能表达自己的操作方式。
1. 方法的基本定义
Go 中的方法本质上也是函数,只不过它在函数名前面多了一个接收者。
基本形式如下:
func (接收者 变量名 类型) 方法名(参数列表) 返回值类型 {
// 方法体
}
下面是一个完整示例:
package main
import "fmt"
type User struct {
Name string
Age int
}
func (u User) Introduce() string {
return fmt.Sprintf("大家好,我叫 %s,今年 %d 岁。", u.Name, u.Age)
}
func main() {
user := User{Name: "小明", Age: 25}
fmt.Println(user.Introduce())
}
这里的关键点是:
User是一个结构体类型Introduce是绑定在User上的方法u User就是接收者- 调用方式是
user.Introduce()
如果写成普通函数,也不是不行,比如:
func introduceUser(u User) string
但方法的优势在于:表达更自然,代码组织更贴近“谁拥有这个行为”。
2. 方法和函数的区别
| 对比项 | 函数 | 方法 |
|---|---|---|
| 是否绑定类型 | 否 | 是 |
| 调用方式 | fn(x) |
obj.method() |
| 适用场景 | 通用工具逻辑 | 与某个类型强相关的行为 |
| 可读性特点 | 强调“执行某个动作” | 强调“某个对象具备某种能力” |
一个简单判断标准是:
- 如果逻辑和某个类型密切相关,优先考虑方法
- 如果逻辑更通用,与具体类型关系不强,优先考虑函数
六、值接收者 vs 指针接收者
这是 Go 初学者学习方法时最重要的知识点之一。很多人会写方法,但不知道为什么有时写 (u User),有时写 (*User)。
先记住一句话:
- 值接收者:方法拿到的是对象的一个副本
- 指针接收者:方法拿到的是对象本身的地址,可以直接修改原对象
1. 值接收者示例
package main
import "fmt"
type Counter struct {
Value int
}
func (c Counter) Increase() {
c.Value++
fmt.Println("方法内部的值:", c.Value)
}
func main() {
counter := Counter{Value: 10}
counter.Increase()
fmt.Println("main 中的值:", counter.Value)
}
这个程序运行后你会发现:
- 方法内部打印的是 11
- 但
main中的值仍然是 10
因为值接收者拿到的是副本,方法里改的是副本,不是原始对象。
2. 指针接收者示例
package main
import "fmt"
type Counter struct {
Value int
}
func (c *Counter) Increase() {
c.Value++
fmt.Println("方法内部的值:", c.Value)
}
func main() {
counter := Counter{Value: 10}
counter.Increase()
fmt.Println("main 中的值:", counter.Value)
}
这一次输出会变成:
- 方法内部的值:11
main中的值:11
因为指针接收者操作的是原对象本身,所以修改会保留下来。
3. 值接收者和指针接收者怎么选
这一部分要重点理解,不只是“语法上能写出来”,更要知道工程上该怎么判断。
| 对比维度 | 值接收者 | 指针接收者 |
|---|---|---|
| 是否修改原对象 | 否 | 是 |
| 传递成本 | 会复制一份值 | 传递地址,通常更省拷贝成本 |
| 适合小型不可变数据 | 是 | 也可以,但不一定必要 |
| 适合大型结构体 | 不太推荐 | 更推荐 |
| 是否表达“这个操作会改变对象状态” | 不明显 | 很明显 |
4. 什么时候优先使用值接收者
通常在下面这些场景中,值接收者比较合适:
- 方法不需要修改接收者状态
- 结构体比较小,复制成本低
- 你希望这个方法更像“读取”而不是“修改”
- 类型本身语义上接近不可变值
看一个典型例子:
package main
import "fmt"
type Point struct {
X int
Y int
}
func (p Point) DistanceSquared() int {
return p.X*p.X + p.Y*p.Y
}
func main() {
point := Point{X: 3, Y: 4}
fmt.Println("距离平方:", point.DistanceSquared())
}
这里的 DistanceSquared 只是读取 Point 的数据,并不修改它,所以用值接收者很自然。
5. 什么时候优先使用指针接收者
以下场景通常更推荐指针接收者:
- 方法需要修改对象状态
- 结构体较大,复制代价高
- 结构体内部包含切片、映射、互斥锁等需要谨慎处理的字段
- 你希望整个类型的方法风格保持一致
下面是一个更贴近业务的例子:
package main
import "fmt"
type BankAccount struct {
Owner string
Balance float64
}
func (b *BankAccount) Deposit(amount float64) {
if amount <= 0 {
fmt.Println("存款金额必须大于 0")
return
}
b.Balance += amount
}
func (b *BankAccount) Withdraw(amount float64) bool {
if amount <= 0 {
fmt.Println("取款金额必须大于 0")
return false
}
if amount > b.Balance {
fmt.Println("余额不足")
return false
}
b.Balance -= amount
return true
}
func (b BankAccount) Display() {
fmt.Printf("账户:%s,余额:%.2f\n", b.Owner, b.Balance)
}
func main() {
account := BankAccount{Owner: "小明", Balance: 1000}
account.Display()
account.Deposit(500)
account.Display()
ok := account.Withdraw(300)
fmt.Println("取款是否成功:", ok)
account.Display()
}
这个例子里:
Deposit和Withdraw会修改余额,所以使用指针接收者Display只是展示信息,不修改状态,所以可以使用值接收者
不过在真实项目中,如果一个类型的大部分方法都需要指针接收者,那么通常建议统一使用指针接收者风格,避免 API 风格混杂,减少理解成本。
6. 一个非常实用的选择原则
如果你还在犹豫接收者怎么选,可以记住下面这个经验法则:
| 问题 | 建议 |
|---|---|
| 这个方法会不会修改对象状态? | 会,就优先指针接收者 |
| 这个结构体大不大,复制值划算吗? | 不划算,就优先指针接收者 |
| 这个方法只是读取数据、做展示、做计算吗? | 可以考虑值接收者 |
| 同一个类型里其他方法大多已经是指针接收者了吗? | 那就尽量统一用指针接收者 |
对于初学者来说,最稳妥的理解方式是:
- 读数据、不改状态:可以优先考虑值接收者
- 改状态、避免拷贝、保持一致:优先考虑指针接收者
七、函数与方法的整体对比
学到这里,可以把本章知识点串起来看:
| 主题 | 核心作用 | 典型使用场景 |
|---|---|---|
| 普通函数 | 封装独立逻辑 | 工具函数、公共计算逻辑 |
| 多返回值 | 一次返回多个结果 | 结果 + 错误、商和余数 |
| 命名返回值 | 提升返回值语义表达 | 短小且含义明确的函数 |
| 可变参数函数 | 处理数量不固定的输入 | 求和、日志、拼接 |
| 匿名函数 | 临时定义一段逻辑 | 回调、立即执行、局部封装 |
| 闭包 | 让函数携带状态 | 计数器、包装器、配置绑定 |
| 高阶函数 | 让函数接收或返回函数 | 策略切换、通用框架封装 |
| 方法 | 给类型绑定行为 | 面向数据建模的业务逻辑 |
| 值接收者 | 基于副本工作 | 只读操作、小型结构体 |
| 指针接收者 | 基于原对象工作 | 修改状态、大型结构体 |
八、本章小结
这一章的重点,不只是记住几个语法形式,而是建立下面这些认知:
- 函数是组织逻辑的基础单位,让代码从“堆在一起”变成“按职责拆分”
- 多返回值是 Go 很重要的设计特征,尤其适合显式错误处理
- 可变参数、匿名函数、闭包、高阶函数 让 Go 的函数能力既简洁又灵活
- 方法让类型拥有行为,让代码更贴近真实业务模型
- 值接收者和指针接收者的选择,本质上是在权衡:是否修改状态、是否需要避免拷贝、是否保持风格一致
如果你刚学到这里,建议你在写练习代码时多问自己两个问题:
- 这段逻辑更适合写成函数,还是某个类型的方法?
- 这个方法到底是“读取对象”,还是“修改对象”?
把这两个问题想清楚,后面学习结构体、接口、并发和工程项目时,会顺畅很多。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!