Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

函数与方法:理解Go面向对象的不同方式,何时选择其一?

你好,我是 Tony Bai。

经过前面几讲对 Go 数据类型的强化学习,我们现在转向探讨类型的“行为”部分。在 Go 语言中,封装和复用代码的主要手段就是函数(function)和方法(method)。

你可能已经注意到, Go 中的函数和方法在形式上非常相似,甚至可以说,Go 在设计时有意模糊了两者的界限。这并非疏忽,而是一种深思熟虑的选择,旨在:

  • 简化类型系统。
  • 提供更大的灵活性。
  • 鼓励使用组合而非继承来实现代码复用和多态。

这与 Go 整体简洁、正交的设计哲学一脉相承。

但这种相似性也可能带来困惑:它们到底有什么本质区别?仅仅是语法上多了一个“接收者”吗?在实际开发中,我应该将一段逻辑实现为函数,还是某个类型的方法?错误的选择会带来什么后果?

不理解函数与方法的本质区别和适用场景,可能会让你写出不够“地道”的 Go 代码,甚至在面对接口实现、状态修改、代码组织等问题时做出次优的设计决策。

这节课,我们就来深入辨析 Go 中的函数与方法。我将带你:

  1. 理解函数作为“一等公民”的含义及其应用。
  2. 掌握方法的本质——为类型绑定行为,并重点区分“值接收者”和“指针接收者”的差异与影响。
  3. 明确在不同场景下,选择函数还是方法的判断依据。

下面先让我们一起走进函数的世界。

函数:Go 中的一等公民与代码复用

函数,简单来说,就是一段封装了特定功能的、可以被重复使用的代码块。它接收输入(参数),执行一系列操作,并可能返回输出(返回值)。函数是模块化编程的基础,能极大地提高代码的可读性和复用性。

在 Go 语言中,函数不仅仅是代码行为的组织单元之一,它还有着特殊的地位——函数是“一等公民(First-Class Citizen)”。这意味着什么呢?意味着函数和其他普通的数据类型(如 int、string、struct)一样,拥有同等的权利:

  • 可以被赋值给变量。
  • 可以作为参数传递给其他函数(高阶函数)。
  • 可以作为另一个函数的返回值。
  • 可以存储在数据结构中(如切片、map)。

函数的声明与调用

我们使用 func 关键字来声明一个函数:

func 函数名(参数列表) (返回值列表) {
    // 函数体:实现功能的代码
    return 返回值 // 如果有返回值
}

例如,一个简单的加法函数:

func add(a int, b int) int { // 参数a, b都是int类型,返回值是int类型
    result := a + b
    return result
}

调用函数很简单,使用函数名加上括号和实际参数:

sum := add(5, 3) // 调用add函数,将结果8赋值给sum
fmt.Println(sum)

函数的本质:函数类型

从 Go 的类型系统角度看,每个函数都有其特定的函数类型。这个类型由函数的参数类型列表和返回值类型列表共同决定(函数名本身不属于类型的一部分)。

上面的 add 函数,它的类型就是 func(int, int) int

我们可以像声明普通变量一样,用函数字面量(匿名函数)来声明一个函数类型的变量:

var myAdd func(int, int) int // 声明一个函数类型的变量myAdd

myAdd = func(x int, y int) int { // 将一个匿名函数赋值给myAdd
    return x + y
}

sum := myAdd(10, 20) // 通过变量调用函数
fmt.Println(sum)    // 输出 30

这清晰地展示了函数作为“值”的特性。 func myAdd(...) {...} 这种常见的函数声明,本质上就是声明了一个名为 myAdd 的变量,其类型是函数类型,其值是函数体定义的那个函数。

函数的参数与返回值

Go 函数的设计在参数和返回值方面相当灵活。

  • 类型简写:如果连续多个参数类型相同,可以只在最后一个参数后写类型。
func process(id string, count, limit int, verbose bool) { /* ... */ }
// count 和 limit 都是 int 类型

  • 多返回值:函数可以返回多个值,非常方便,例如函数可以同时返回结果和错误状态。
// 返回商和余数
func divide(dividend, divisor int) (int, int, error) {
    if divisor == 0 {
        return 0, 0, fmt.Errorf("division by zero")
    }
    quotient := dividend / divisor
    remainder := dividend % divisor
    return quotient, remainder, nil // 返回三个值
}

q, r, err := divide(10, 3)
if err == nil {
    fmt.Printf("Quotient: %d, Remainder: %d\n", q, r) // 输出 3, 1
}
// 如果只关心部分返回值,可以用空白标识符 _ 忽略
q, _, err := divide(10, 0)
if err != nil {
    fmt.Println("Error:", err)
}

  • 具名返回值(Named Return Values):可以给返回值命名。它们就像在函数顶部预先声明的变量,默认值为其类型的零值。在函数体内可以直接给它们赋值,最后的 return 语句可以省略操作数,它会自动返回这些命名变量的当前值,这也称为“裸 return”。
func divideNamed(dividend, divisor int) (quotient int, remainder int, err error) {
    if divisor == 0 {
        err = fmt.Errorf("division by zero")
        // 此时 quotient 和 remainder 保持它们的零值 0
        return // 裸 return,返回 quotient(0), remainder(0), err(非nil)
    }
    quotient = dividend / divisor
    remainder = dividend % divisor
    // 此时 err 保持其零值 nil
    return // 裸 return,返回计算后的 quotient, remainder, 和 nil error
}

注意:具名返回值虽然有时能让代码更简洁(尤其在涉及 defer 修改返回值时),但也可能降低可读性。过度使用 return 可能让函数实际返回的值不够清晰。建议谨慎使用,明确的 return value1,value2 通常更易理解。

  • 可变参数(Variadic Parameters):函数可以接受不定数量的同类型参数。可变参数必须是函数签名中的最后一个参数,类型前加 ...。在函数内部,可变参数表现为一个该类型的切片。
func sumAll(numbers ...int) int { // numbers 是一个 []int 切片
    total := 0
    for _, num := range numbers {
        total += num
    }
    return total
}

fmt.Println(sumAll(1, 2, 3))      // 输出 6
fmt.Println(sumAll(5, 10, 15, 20)) // 输出 50

// 也可以将一个切片展开传入可变参数
nums := []int{10, 20, 30}
fmt.Println(sumAll(nums...))     // 输出 60 (注意切片后的 ...)

匿名函数与闭包

Go 支持匿名函数,即没有名字的函数。它们可以直接定义并赋值给变量,或者直接作为参数传递。

// 直接调用匿名函数
result := func(a, b int) int {
    return a + b
}(5, 3) // 定义后立即调用
fmt.Println(result) // 输出 8

匿名函数最强大的能力在于它可以形成 闭包(closure)。闭包是指一个函数值,它引用了其函数体之外的变量。简单来说,闭包 = 函数 + 其引用的外部环境。

func sequenceGenerator() func() int {
    i := 0 // 这个 i 被下面的匿名函数引用了
    return func() int { // 返回一个匿名函数(闭包)
        i += 1
        return i // 每次调用都访问并修改同一个 i
    }
}

func main() {
    nextInt := sequenceGenerator() // nextInt 现在是一个闭包

    fmt.Println(nextInt()) // 输出 1
    fmt.Println(nextInt()) // 输出 2
    fmt.Println(nextInt()) // 输出 3

    newInts := sequenceGenerator() // newInts 是另一个闭包,有自己的 i
    fmt.Println(newInts()) // 输出 1
}

在这个例子中, sequenceGenerator 返回的匿名函数“捕获”了它外部的变量 i。即使 sequenceGenerator 函数执行完毕,只要返回的闭包 nextIntnewInts 还存在,它们所引用的那个 i 变量也会一直存在。每个闭包实例都有自己独立的环境。

闭包在 Go 中非常常用,例如实现计数器、生成器、事件处理回调、goroutine 传参等。理解闭包对于掌握 Go 的函数式编程特性和并发编程至关重要。

方法:为类型附加行为

看完了独立的函数,我们再来看与类型紧密关联的 方法(method)

方法是与特定类型关联的函数。在 Go 语言中,我们可以为自定义类型定义方法。这里的自定义类型包括:

  • 使用 type 关键字定义的类型(如结构体、自定义的整型、自定义的字符串类型等)。
  • 基于已存在类型创建的类型别名(type alias)( 注意:类型别名和原类型共享底层类型和方法集)。

但需要注意的是:

  • 我们不能直接为内置类型(如 intstringfloat64 等)定义方法。
  • 我们不能跨越包的边界为外部包中定义的类型声明方法。

需要强调的是,Go 语言虽然支持面向对象编程的某些特性(如通过方法实现数据和行为的封装), 但它并不是一种典型的面向对象(OO)语言。 Go 并没有类、继承、虚函数等传统面向对象语言的典型特征。

相比于传统的基于继承的类型层次结构,Go 更倾向于使用组合(Composition)来组织代码。Go 鼓励通过将小的、独立的类型组合成更大的、更复杂的类型,而不是通过继承来构建类型体系。

这种设计哲学使得 Go 代码更灵活、更易于维护和扩展。因此,过度的、传统的 OOP 风格在 Go 语言中往往不是最佳实践。

方法的声明与调用

方法的声明与函数类似,但在 func 关键字和方法名之间,多了一个 接收者(receiver) 的声明:

func (接收者变量 接收者类型) 方法名(参数列表) (返回值列表) {
    // 方法体,可以使用接收者变量访问类型的数据
}

  • 接收者类型:必须是当前包内定义的某个类型 T 或其指针 *T(不能是接口、指针本身或内置类型)。
  • 接收者变量:方法体内用来指代调用该方法的那个类型实例。

下面我们看一个为 Point 结构体定义一个计算距离的方法的示例:

import "math"

type Point struct {
    X, Y float64
}

// Distance 是 Point 类型的一个方法
// p 是接收者变量,类型是 Point (值接收者)
func (p Point) Distance(q Point) float64 {
    // 方法体内可以通过 p 访问 Point 实例的 X 和 Y 字段
    dx := p.X - q.X
    dy := p.Y - q.Y
    return math.Sqrt(dx*dx + dy*dy)
}

从本质上讲,Go 语言中的方法就是一种特殊的函数,它的特别之处在于:

  1. 隐式的第一个参数: 方法的接收者实际上是作为隐式的第一个参数传递给方法的。在上面的 Distance 方法中, p 就是隐式的第一个参数。
  2. 类型绑定: 方法与特定的类型绑定。这意味着,方法只能通过该类型或该类型的指针来调用。
  3. 方法名作用域: 虽然 Go 规范中并没有显式说明,但方法名和函数名一样,都是包级作用域,不同的是函数可以被直接调用,而方法则需要通过其关联的接收者类型的实例或指针进行间接调用。这意味着不同的类型可以拥有相同名称的方法而不会冲突。例如,类型 TypeA 和类型 TypeB 可以各自拥有名为 String 的方法。只有通过具体的类型实例(或类型指针的实例)才能确定调用的是哪个 String 方法。

方法的调用是通过接收者来实现的:

p := Point{3, 4}
q := Point{0, 0}
distance := p.Distance(q) // 调用 Point 类型的方法 Distance
fmt.Println(distance) // 输出 5

在这个例子中,我们创建了两个 Point 类型的变量 pq,并通过 p 来调用 Distance 方法。

如前所述,方法的接收者是作为第一个参数隐式传递给方法的。在上面的例子中, p.Distance(q) 实际上等价于 Point.Distance(p, q)(注意:这种写法也叫方法表达式,在本节课后面会详细讲解)。

此外,在包外,导出的方法名不会受限于其关联的类型是否为导出的,即便其关联类型并非导出的,我们也可以调用其导出的方法,比如下面示例:

u := mypkg.NewUnexportedType() // 通过工厂函数获得实例(但无法显式声明u的类型)
result := u.ExportedMethod()   // 调用未导出类型的导出方法

通过将方法与类型绑定,Go 语言实现了数据和行为的封装。 这使得我们可以像操作对象一样操作自定义类型的值,使代码更具表达力和可读性。

值接收者 vs 指针接收者

在声明方法时,接收者的类型可以设置为值类型 T 或指针类型 *T。这两类接收者在使用上有一些区别。但无论选择哪种方式,接收者的基类型 T(Base Type)都必须满足以下条件:

  1. 必须是自定义类型: 接收者的基类型不能是内置类型(如 intstring 等),不能是指针类型,也不能是接口类型( interface)。
  2. 不能是(多层)指针类型: 接收者类型可以直接为类型 T 的指针,比如 *T。但不能是多层指针,例如 **T
  3. 类型定义必须在同一个包内: 定义方法的类型和方法的声明必须在同一个包内。

在此基础上,我们可以选择使用值接收者或指针接收者。

当接收者是一个值类型时,方法内部会对接收者的副本进行操作,而不是直接操作原始的接收者。

type Point struct {
    X, Y float64
}

func (p Point) MoveTo(x, y float64) {
    p.X = x
    p.Y = y
}

func main() {
    p := Point{3, 4}
    p.MoveTo(0, 0)
    fmt.Println(p) // 输出 {3, 4},p 的值没有改变
}

在这个例子中, MoveTo 方法的接收者是一个值类型 Point。在方法内部,我们修改了 pXY 字段,但这个修改并不会影响到原始的 p 变量,因为方法内部操作的是 p 的副本。

当接收者是一个指针类型时,方法内部可以通过指针直接操作原始的接收者。

type Point struct {
    X, Y float64
}

func (p *Point) MoveTo(x, y float64) {
    p.X = x
    p.Y = y
}

func main() {
    p := Point{3, 4}
    p.MoveTo(0, 0)
    fmt.Println(p) // 输出 {0, 0},p 的值被修改了
}

在这个例子中, MoveTo 方法的接收者是一个指针类型 *Point。在方法内部,我们通过指针 p 修改了原始的 Point 变量的值。

需要注意的是,无论方法的接收者是一个指针类型,还是值类型,我们既可以使用指针类型的变量来调用方法,也可以使用值类型的变量来调用方法。Go 语言的编译器会自动进行取地址或解引用的操作:

p := Point{3, 4}
p.MoveTo(0, 0) // 值类型的变量可以直接调用指针接收者的方法

q := &Point{3, 4}
q.MoveTo(0, 0) // 指针类型的变量可以调用指针接收者的方法

那么,我们该如何选择接收者类型呢?

在定义方法时,我们需要根据实际情况选择合适的接收者类型。通常情况下,可以遵循以下几个原则:

  1. 如果方法需要修改接收者的值,应该使用指针接收者。值接收者操作的是接收者的副本,无法修改原始值。

  2. 如果接收者是一个大型的对象(如大型结构体),使用指针接收者可以避免每次方法调用时都进行对象拷贝,提高性能。

  3. 如果接收者类型中包含接口类型字段,且当值接收者的方法内部调用了该接口类型字段的方法时,编译器无法在编译时完全确定该方法的具体实现是否会导致值接收者副本或其内部字段的地址以某种方式“逃逸”出去,即在方法返回后仍被引用。

    为了内存安全,编译器在这种不确定的情况下,可能会采取保守策略,在堆上分配这个副本,而不是在通常更快的栈上。堆分配会带来额外的性能开销(分配本身和后续的垃圾回收)。

    使用指针接收者通常可以避免接收者本身因为这种接口方法调用而逃逸到堆上,因为传递的是指针,编译器更容易追踪其生命周期。

  4. 如果类型需要实现某个接口,要注意该类型的方法集。

    a. 类型 T 的方法集仅包含所有 receiver 为 T 的方法。

    b. 类型 *T 的方法集包含了所有 receiver 为 T*T 的方法。

    c. 因此如果接口中定义的方法,只有 receiver 为 *T 的方法才实现了该接口,那么类型 T 的实例将不能赋值给该接口变量,而类型 *T 的实例则可以。

  5. 如果你不确定使用哪种类型的接收者,建议使用指针接收者,因为指针接收者更通用,可以覆盖更多的场景。

这里提到了影响接口实现与否的一个重要概念:方法集(method set),到底什么是方法集呢?下面我们来详细说明一下。

方法集

每个类型都有一个与之关联的方法集,方法集定义了该类型可以调用的所有方法的集合。

对于类型 T,它的方法集包含所有接收者类型为 T 的方法。而对于类型 *T,它的方法集既包含所有接收者类型为 *T 的方法,也包含所有接收者类型为 T 的方法。

下面是一个示例:

type T struct{}

func (t T) M1() {}
func (t *T) M2() {}

func main() {
    var t T
    var pt *T = &t

    t.M1() // ok,T 类型的方法集中包含 M1
    t.M2() // ok,*T 类型的方法集中包含 M2,Go 自动将 t 转换为 &t

    pt.M1() // ok,*T 类型的方法集中包含 M1
    pt.M2() // ok,*T 类型的方法集中包含 M2
}

在这个例子中,类型 T 的方法集只包含 M1 方法,类型 *T 的方法集包含 M1M2 两个方法。

方法集在 Go 语言的接口和类型断言中起着重要的作用。一个类型只有实现了接口中定义的所有方法,才能认为该类型实现了该接口。在进行类型断言时,也需要考虑方法集的规则。

在 Go 语言中,除了通过类型变量或指针调用方法外,方法也可以像函数一样被赋值给变量,或者作为参数传递给其他函数。这种特性称为方法值(method value),我们也可以直接通过类型调用方法,这种称为方法表达式(method expression)。下面我们来详细看看这两个特性的用法。

方法值与方法表达式

方法值是一个绑定了特定接收者实例的函数值。当我们通过一个接收者调用方法时,可以不立即传入参数,而是先将方法赋值给一个变量,稍后再通过这个变量来调用方法。

type Point struct {
    X, Y float64
}

func (p Point) Add(q Point) Point {
    return Point{p.X + q.X, p.Y + q.Y}
}

func main() {
    p := Point{1, 2}
    add := p.Add        // 方法值,将 p.Add 赋值给 add 变量
    fmt.Println(add(Point{3, 4})) // 通过 add 变量调用方法,输出 {4, 6}
}

在这个例子中, p.Add 就是一个方法值,它将 Add 方法绑定到了接收者 p 上。 add 变量的类型是 func(Point) Point,它是一个函数类型。

方法值实际上是一个闭包,它保存了接收者的值和方法的地址。当我们通过方法值调用方法时,Go 语言内部会将接收者作为第一个参数传递给方法。

方法表达式(method expression)是一种通过类型来调用方法的语法。它可以将方法转换为一个普通的函数,该函数的第一个参数是方法的接收者。

type Point struct {
    X, Y float64
}

func (p Point) Add(q Point) Point {
    return Point{p.X + q.X, p.Y + q.Y}
}

func main() {
    p := Point{1, 2}
    add := Point.Add    // 方法表达式,将 Point 类型的 Add 方法赋值给 add 变量
    fmt.Println(add(p, Point{3, 4})) // 通过 add 变量调用方法,输出 {4, 6}
}

在这个例子中, Point.Add 就是一个方法表达式,它表示 Point 类型的 Add 方法。 add 变量的类型是 func(Point, Point) Point,它是一个函数类型。

设计抉择:何时定义函数,何时定义方法?

理解了函数和方法的区别后,我们回到最初的问题:何时用函数,何时用方法?

核心判断依据是: 这个操作是与某个特定类型的数据紧密相关,还是一个更通用的、独立的操作。

  1. 优先考虑方法,如果:

    a. 操作需要访问或修改该特定类型实例的内部状态(字段)。这是最主要的原因。

    b. 操作逻辑上属于该类型的核心职责或行为。例如, File 类型的 Read, Write, Close 方法。

    c. 希望该类型实现某个接口,接口定义了该操作。

    d. 你想利用面向对象的封装特性,将数据和操作绑定在一起。

  2. 优先选择函数,如果:

    a. 操作是通用的,可以应用于多种类型,或者不依赖于任何特定类型的状态。例如,标准库中的 sort.Stringsfmt.Printlnmath.Abs 等。

    b. 操作逻辑上不属于任何单一类型的职责,更像是一个工具函数或辅助函数。

    c. 你想实现纯粹的函数式编程风格。

    d. 操作需要处理多个不同类型的主要对象,将其定义为某个单一类型的方法会显得不自然。例如, diff(obj1, obj2) 可能比 obj1.Diff(obj2) 更合适。

抉择前,你可以做一个简单的自问:这个功能是 T 类型“应该能做”的事情,还是一个“可以对 T 类型做”的事情? 前者倾向于方法,后者倾向于函数。

例如,“计算一个 Rectangle 的面积”显然是 Rectangle 应该能做的事,适合用方法 rect.Area()。而“将两个 Rectangle 按面积排序”是一个可以对 Rectangle 做的通用操作,更适合用函数 sortRectanglesByArea(rects []Rectangle)

小结

这一讲,我们辨析了 Go 语言中函数和方法的异同与选择:

  1. 函数是“一等公民”:可以赋值、传递、返回,是灵活的代码组织单元。理解其类型、参数(含可变)、返回值(含具名)、匿名函数和闭包是基础。
  2. 方法是与类型绑定的函数:通过接收者将行为附加到特定类型上,实现了类似面向对象的封装。理解其本质(带隐式接收者参数的函数)很重要。
  3. 值接收者 vs 指针接收者:核心区别在于方法内部操作的是副本还是原始实例。选择依据包括是否修改状态、性能开销(大对象拷贝)、潜在的堆分配以及接口实现(方法集规则)。通常指针接收者更常用和推荐。
  4. 方法集:决定了类型 T*T 分别能调用哪些方法,以及它们能满足哪些接口。 *T 的方法集包含 T 的方法集。
  5. 方法值与方法表达式:提供了将方法作为函数值使用的两种方式。
  6. 设计抉择:根据操作与类型的关联紧密程度、是否需要访问内部状态、是否属于核心职责、以及是否满足接口来决定使用函数还是方法。

Go 语言通过这种函数与方法的灵活设计,鼓励开发者编写清晰、可复用、易于组合的代码。理解它们的差异和适用场景,是写出地道、高质量 Go 代码的关键一步。

思考题

假设你正在为一个图形库编写代码,定义了一个 Circle 结构体:

type Circle struct {
    Radius float64
}

现在你需要实现两个功能:

  1. 计算圆的面积。
  2. 比较两个圆的大小(基于半径)。

你会将这两个功能分别实现为函数还是 Circle 类型的方法?为什么?请给出你的函数/方法签名。

欢迎在留言区分享你的设计和理由!我是 Tony Bai,我们下节课见。