Go 语言中的方法

158 阅读7分钟

认识 Go 方法

Go 的方法也是以 func 关键字修饰的,并且和函数一样,也包含方法名(对应函数名)、参数列表、返回值列表与方法体(对应函数体)。方法中的这几个部分和函数声明中对应的部分,在形式与语义方面都是一致的,比如:

  • 方法名字首字母大小写决定该方法是否是导出方法;
  • 方法参数列表支持变长参数;
  • 方法的返回值列表也支持具名返回值等。
img

Go 方法的声明由6部分组成:

  • func 关键字
  • receiver 是方法与类型之间的纽带,也是方法与函数的最大不同
  • 方法名
  • 参数列表
  • 返回值列表
  • 方法体

Tips: Go 方法必须是归属于一个类型的,而 receiver 参数的类型就是这个方法归属的类型,或者说这个方法就是这个类型的一个方法。

声明形式

func (t *T或T) MethodName(参数列表) (返回值列表) {
    // 方法体
}
  • 无论 receiver 参数的类型为 *T 还是 T,我们一般都把声明形式中的 T 叫做 receiver 参数 t 的基类型
  • 如果 t 的类型为 T,那么说这个方法是类型 T 的一个方法
  • 如果 t 的类型为 *T,那么就说这个方法是类型 *T 的一个方法
  • 每个方法只能有一个 receiver 参数,Go 不支持在方法的 receiver 部分放置包含多个 receiver 参数的参数列表,或者变长 receiver 参数

receiver 参数的作用域

  • 方法接收器(receiver)参数、函数 / 方法参数,以及返回值变量对应的作用域范围,都是函数 / 方法体对应的显式代码块 传送门

    package main
    
    import "fmt"
    
    type Person struct {
    	Name string
    	Age  int
    }
    
    func (p Person) SayHello() {
    	fmt.Printf("Hello, my name is %s. I am %d years old.\n", p.Name, p.Age)
    }
    
    func main() {
    	p := Person{Name: "Forest", Age: 24}
    	p.SayHello() // 调用方法
    
    	// 函数体开始
    	funcExample := func() {
    		// 函数参数作用域范围开始
    		message := "Hello, World!"
    		fmt.Println(message)
    		// 函数参数作用域范围结束
    	}
    	// 函数体结束
    
    	funcExample() // 调用函数
    
    	// 返回值变量作用域范围开始
    	age := getAge()
    	fmt.Println("Age:", age)
    	// 返回值变量作用域范围结束
    }
    
    // 函数开始
    func getAge() int {
    	// 函数体开始
    	return 30
    	// 函数体结束
    }
    
    // 函数结束
    
  • receiver 部分的参数名不能与方法参数列表中的形参名,以及具名返回值中的变量名存在冲突,必须在这个方法的作用域中具有唯一性;否则Go 编译器报错。

    type T struct{}
    
    func (t T) M(t string) { // 编译器报错:duplicate argument t (重复声明参数t)
        ... ...
    }
    
  • 如果在方法体中,没有用到 receiver 参数,可以省略 receiver 的参数名。

    type T struct{}
    
    func (T) M(t string) { 
        ... ...
    }
    
  • receiver 参数的基类型本身不能为指针类型或接口类型;否则报错。

    type MyInt *int
    func (r MyInt) String() string { // r的基类型为MyInt,编译器报错:invalid receiver type MyInt (MyInt is a pointer type)
        return fmt.Sprintf("%d", *(*int)(r))
    }
    
    type MyReader io.Reader
    func (r MyReader) Read(p []byte) (int, error) { // r的基类型为MyReader,编译器报错:invalid receiver type MyReader (MyReader is an interface type)
        return r.Read(p)
    }
    

方法声明与receiver的要求

方法声明要与 receiver 参数的基类型声明放在同一个包内;不能为原生类型(诸如 int、float64、map 等)添加方法;不能跨越 Go 包为其他包的类型声明新方法。Go 语言中的方法的本质就是:一个以方法的 receiver 参数作为第一个参数的普通函数。

receiver 参数类型对 Go 方法的影响

func (t T) M1() <=> F1(t T)

当 receiver 参数的类型为 T 时,方法 M1 使用类型为 T 的 receiver 参数时,传递的是 T 类型实例的副本。在方法体中对副本的任何修改操作都不会影响到原始的 T 类型实例。

func (t *T) M2() <=> F2(t *T)

当 receiver 参数的类型为 *T 时,选择以 *T 作为 receiver 参数类型时,M2 方法等价于 F2(t *T)。传递给 F2 函数的是 T 类型实例的地址,所以对参数 t 的任何修改都会反映到原始的 T 类型实例上。

选择 receiver 参数类型的原则

如果 Go 方法要把对 receiver 参数代表的类型实例的修改,反映到原类型实例上,那么我们应该选择 *T 作为 receiver 参数的类型,是不是只能通过 *T 类型变量调用该方法,而不能通过 T 类型变量调用? 传送门

package main

import "fmt"

type Person struct {
	Name string
	Age  int
}

// 使用 *Person 作为 receiver 参数的方法
func (p *Person) UpdateAge(newAge int) {
	p.Age = newAge
}

func main() {
	p := Person{Name: "Forest", Age: 24}

	// 使用 *Person 类型变量调用 UpdateAge 方法
	p.UpdateAge(30)
	fmt.Println("Age after update:", p.Age) // 输出: Age after update: 30

	// 使用 Person 类型变量调用 UpdateAge 方法会导致编译错误
	//p.UpdateAge(30) // 编译错误: cannot use p (type Person) as type *Person in function argument
}

一般情况下,我们通常会为 receiver 参数选择 T 类型,因为这样可以缩窄外部修改类型实例内部状态的“接触面”,也就是尽量少暴露可以修改类型内部状态的方法。

考虑到 Go 方法调用时,receiver 参数是以值拷贝的形式传入方法中的。那么,如果 receiver 参数类型的 size 较大,以值拷贝形式传入就会导致较大的性能开销,这时我们选择 *T 作为 receiver 类型可能更好些。 传送门

package main

import "fmt"

type BigData struct {
	// 假设这里有很多很大的数据字段
	Data [1000000]int
}

// 使用 *BigData 作为 receiver 参数的方法
func (bd *BigData) ProcessData() {
	// 对大数据进行处理
	fmt.Println("process data")
}

func main() {
	bd := &BigData{} // 创建 BigData 类型的指针

	// 使用 *BigData 类型的变量调用 ProcessData 方法
	bd.ProcessData()
}

如果 T 类型需要实现某个接口,那我们就要使用 T 作为 receiver 参数的类型,来满足接口类型方法集合中的所有方法。

方法集合

如果某类型 T 的方法集合与某接口类型的方法集合相同,或者类型 T 的方法集合是接口类型 I 方法集合的超集,那么我们就说这个类型 T 实现了接口 I。或者说,方法集合这个概念在 Go 语言中的主要用途,就是用来判断某个类型是否实现了某个接口。 传送门

package main

import "fmt"

// 定义一个接口
type Speaker interface {
	Speak()
}

// 定义一个类型
type Person struct {
	Name string
}

// 为类型 Person 添加一个方法
func (p Person) Speak() {
	fmt.Println("Hello, my name is", p.Name)
}

func main() {
	p := Person{Name: "Forest"}

	// 检查类型 Person 是否实现了接口 Speaker
	var s Speaker = p

	s.Speak() // 调用 Speak 方法,输出: Hello, my name is Forest
}

在上面的示例中,我们定义了一个接口 Speaker,其中只有一个方法 Speak。然后我们定义了一个类型 Person,并为其添加了一个方法 Speak。接下来,我们将类型 Person 的实例 p 赋值给接口 Speaker 类型的变量 s。这是合法的,因为类型 Person 的方法集合与接口 Speaker 的方法集合相同,即 Person 类型实现了接口 Speaker。

最后,我们通过调用接口变量 s 的 Speak 方法来验证类型 Person 是否实现了接口 Speaker。由于 p 是 Person 类型,而 Person 类型实现了 Speak 方法,因此调用 s.Speak() 将触发 Person 类型的 Speak 方法,输出 "Hello, my name is Forest"。

结构体类型的方法集合,包含嵌入的接口类型的方法集合。

结构体的方法集合不允许存在交集,但是接口的方法集合可以。因为接口只是方法定义,存在交集的方法的定义还是相同的;而结构体的方法涉及到实现,存在交集时编译器无法决定要用哪个实现。

package main

import "fmt"

type A interface {
	Method1()
}

type B interface {
	Method1()
}

type C struct{}

func (c C) Method1() {
	fmt.Println("Method1 from C")
}

func main() {
	var a A
	var b B
	var c C

	a = c
	b = c

	a.Method1() // Method1 from C
	b.Method1() // Method1 from C
}

Go 就会优先使用结构体自己实现的方法。如果没有实现,那么 Go 就会查找结构体中的嵌入字段的方法集合中,是否包含了这个方法。如果多个嵌入字段的方法集合中都包含这个方法,那么我们就说方法集合存在交集。

package main

import "fmt"

type A struct{}

func (a A) Method1() {
	fmt.Println("Method1 from A")
}

type B struct{}

func (b B) Method2() {
	fmt.Println("Method2 from B")
}

type C struct {
	A
	B
}

func main() {
	c := C{}

	c.Method1() // Method1 from A
	c.Method2() // Method2 from B
}