Go 的函数为什么让人觉得朴素
Go 的函数没有默认参数,没有函数重载,也没有把异常藏在函数体里。一个函数接收什么,返回什么,基本都写在签名上。刚学时你可能觉得它少了一些方便功能,但写真实项目久了会发现,这种朴素让调用关系更容易推理。
后端代码最怕“看起来调用了一个函数,实际发生了很多猜不到的事”。Go 倾向于把事情摊开:要返回错误就写在返回值里,要修改外部对象就通过指针表达,要让调用者知道结果就显式返回。函数签名像一份小合同,告诉读者这段代码会拿什么、给什么,以及有没有失败可能。
这篇文章围绕函数和指针展开。你会看到 Go 的多返回值怎么用,为什么错误通常作为最后一个返回值,什么时候传值,什么时候传指针,以及如何避免把指针当成“性能优化万能药”。
基本函数签名
最简单的函数:
func add(a int, b int) int {
return a + b
}
相邻参数类型相同时,可以合并:
func add(a, b int) int {
return a + b
}
调用:
sum := add(3, 5)
fmt.Println(sum)
Go 把返回类型写在参数列表后面。没有返回值时,不写返回类型:
func printTitle(title string) {
fmt.Println("==", title, "==")
}
多个返回值是 Go 很常见的写法:
func splitName(fullName string) (string, string) {
if fullName == "" {
return "", ""
}
return fullName, ""
}
真实代码里,多返回值最典型的组合是“结果 + 错误”。
func parseAge(input string) (int, error) {
age, err := strconv.Atoi(input)
if err != nil {
return 0, err
}
if age < 0 {
return 0, fmt.Errorf("age must be positive")
}
return age, nil
}
调用方必须处理错误:
age, err := parseAge("28")
if err != nil {
fmt.Println("invalid age:", err)
return
}
fmt.Println(age)
这种写法初看有点重复,但好处是控制流明确。失败路径不会从某个深层函数突然跳出来,读者也不会错过失败可能。
多返回值不是用来塞满信息
多返回值很方便,但不要滥用。下面这种函数就不太舒服:
func loadUser(id int64) (int64, string, string, bool, error) {
return id, "小林", "active", true, nil
}
调用者看到一串返回值,很难记住每个位置代表什么:
id, name, status, verified, err := loadUser(1)
更好的方式是定义结构体:
type User struct {
ID int64
Name string
Status string
Verified bool
}
func loadUser(id int64) (User, error) {
return User{
ID: id,
Name: "小林",
Status: "active",
Verified: true,
}, nil
}
多返回值适合返回少量相关信息,尤其是 (value, error)、(value, ok)、(page, pageSize) 这类清楚组合。如果返回值超过三个,就应该认真想想是否需要结构体。
命名返回值要克制
Go 支持命名返回值:
func normalize(page, size int) (normalizedPage int, normalizedSize int) {
normalizedPage = page
normalizedSize = size
if normalizedPage <= 0 {
normalizedPage = 1
}
if normalizedSize <= 0 {
normalizedSize = 20
}
return
}
这里最后的 return 没有写返回值,因为返回变量已经命名。命名返回值适合短函数,或者返回值含义需要在签名里说明。但如果函数变长,裸 return 会让读者回头找变量最后在哪里被改过。
很多时候直接写返回值更清楚:
func normalize(page, size int) (int, int) {
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
return page, size
}
原则很简单:命名返回值不是为了少打字,而是为了提升表达。如果它让控制流变模糊,就不要用。
值传递:函数拿到的是一份拷贝
Go 函数参数默认按值传递。也就是说,函数拿到的是参数值的一份拷贝。
func changeCount(count int) {
count = 100
}
func main() {
count := 10
changeCount(count)
fmt.Println(count) // 10
}
changeCount 里的 count 改成了 100,但外面的 count 还是 10。这就是值传递。
如果想让函数修改外部变量,需要传指针:
func changeCount(count *int) {
*count = 100
}
func main() {
count := 10
changeCount(&count)
fmt.Println(count) // 100
}
&count 取得变量地址,*count 通过指针访问地址上的值。Go 有指针,但没有指针算术。你可以传地址、解引用,却不能像 C 那样移动指针位置。这降低了很多危险。
指针不只是为了修改值
指针最直观的用途是修改调用方的对象:
type User struct {
Name string
Active bool
}
func activate(user *User) {
user.Active = true
}
调用:
u := User{Name: "小林"}
activate(&u)
fmt.Println(u.Active) // true
另一个用途是避免复制较大的结构体。比如一个结构体包含很多字段,每次函数调用都复制一份可能没必要。
func printUser(user *User) {
fmt.Println(user.Name)
}
但不要过度使用指针。对于 int、bool、小字符串、小结构体,直接传值通常更简单。指针会引入“这个值会不会被改”的心理负担。Go 的工程审美不是“到处传指针显得专业”,而是“需要共享和修改时才传指针”。
nil 指针要小心
指针可以是 nil:
var user *User
fmt.Println(user == nil) // true
如果直接访问字段,会 panic:
fmt.Println(user.Name)
所以接收指针的函数要考虑是否允许 nil。
func displayName(user *User) string {
if user == nil {
return "匿名用户"
}
if user.Name == "" {
return "未命名用户"
}
return user.Name
}
真实项目里,不要随便让 nil 在系统里到处流动。能在边界处返回错误就返回错误,能用零值表达默认状态就用零值。只有当“没有这个对象”本身是正常业务状态时,nil 才是合适表达。
方法接收者也是函数参数
Go 的方法本质上也是函数,只是多了一个接收者。
type Counter struct {
Value int
}
func (c Counter) Current() int {
return c.Value
}
Current 使用值接收者。调用时:
c := Counter{Value: 10}
fmt.Println(c.Current())
如果方法要修改对象,使用指针接收者:
func (c *Counter) Increment() {
c.Value++
}
调用:
c := Counter{Value: 10}
c.Increment()
fmt.Println(c.Value) // 11
虽然调用处没有写 &c,Go 会在合适情况下自动取地址。但你在定义方法时仍然要明确选择值接收者还是指针接收者。
常见规则是:如果方法需要修改接收者,用指针;如果结构体较大,用指针;如果类型包含同步原语,如 sync.Mutex,用指针;如果只是小的不可变值,用值也可以。
一个真实一点的价格计算例子
下面写一个订单折扣函数:
package main
import "fmt"
type Order struct {
ID int64
TotalCents int64
Paid bool
}
func applyDiscount(order *Order, discountCents int64) error {
if order == nil {
return fmt.Errorf("order is nil")
}
if order.Paid {
return fmt.Errorf("paid order cannot be changed")
}
if discountCents < 0 {
return fmt.Errorf("discount cannot be negative")
}
if discountCents > order.TotalCents {
order.TotalCents = 0
return nil
}
order.TotalCents -= discountCents
return nil
}
func main() {
order := Order{ID: 1001, TotalCents: 12900}
if err := applyDiscount(&order, 2000); err != nil {
fmt.Println("apply discount failed:", err)
return
}
fmt.Println(order.TotalCents)
}
这个函数选择指针参数,因为它要修改订单金额。它返回 error,因为折扣失败是可能发生的业务情况。它也检查 nil,因为指针参数存在空值风险。
如果我们不希望函数修改原订单,而是返回一个新订单,可以使用值传递:
func discountedOrder(order Order, discountCents int64) (Order, error) {
if order.Paid {
return Order{}, fmt.Errorf("paid order cannot be changed")
}
if discountCents < 0 {
return Order{}, fmt.Errorf("discount cannot be negative")
}
if discountCents > order.TotalCents {
order.TotalCents = 0
return order, nil
}
order.TotalCents -= discountCents
return order, nil
}
调用者会得到一个新值:
newOrder, err := discountedOrder(order, 2000)
这两种设计没有绝对对错。关键是函数签名要表达意图:是原地修改,还是计算新结果。
函数作为值
Go 的函数可以作为值传递:
func filter(nums []int, keep func(int) bool) []int {
var result []int
for _, n := range nums {
if keep(n) {
result = append(result, n)
}
}
return result
}
调用:
even := filter([]int{1, 2, 3, 4}, func(n int) bool {
return n%2 == 0
})
fmt.Println(even)
这在回调、过滤、排序、自定义策略里很有用。不过 Go 不鼓励把所有业务都写成复杂函数式链条。函数作为值应当让代码更直接,而不是更难追踪。
比如排序:
users := []User{
{Name: "小林"},
{Name: "阿周"},
}
sort.Slice(users, func(i, j int) bool {
return users[i].Name < users[j].Name
})
这个匿名函数很短,贴在调用处很自然。如果逻辑变复杂,就应该提取成有名字的函数。
小结
Go 的函数设计强调明确。参数是输入,返回值是输出,错误也通过返回值进入控制流。多返回值好用,但不要把它当成一串松散数据;命名返回值能表达含义,但长函数里要谨慎;值传递让数据更安全,指针传递让修改和共享成为可能。
学习指针时,不要只记语法。更重要的是判断意图:这个函数是否要修改调用者传进来的对象?这个对象是否很大?nil 是否是一种合法状态?如果答案不明确,先选更简单的值传递通常更稳。
当你能从函数签名里读出设计意图,Go 代码就开始变得清楚。后面学习结构体、接口和错误处理时,这种能力会越来越重要。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。