方法

Go支持一些面向对象编程特性,方法是这些所支持的特性之一。 本篇文章将介绍在Go中和方法相关的各种概念。

方法声明

在Go中,我们可以为类型T*T显式地声明一个方法,其中类型T必须满足四个条件:
  1. T必须是一个定义类型
  2. T必须和此方法声明定义在同一个代码包中;
  3. T不能是一个指针类型;
  4. T不能是一个接口类型。接口类型将在下一篇文章中讲解。

类型T*T称为它们各自的方法的属主类型(receiver type)。 类型T被称作为类型T*T声明的所有方法的属主基类型(receiver base type)。

注意:我们也可以为满足上列条件的类型T*T别名声明方法。 这样做的效果和直接为类型T*T声明方法是一样的。

如果我们为某个类型声明了一个方法,以后我们可以说此类型拥有此方法。

从上面列出的条件,我们得知我们不能为下列类型(显式地)声明方法:

一个方法声明和一个函数声明很相似,但是比函数声明多了一个额外的参数声明部分。 此额外的参数声明部分只能含有一个类型为此方法的属主类型的参数,此参数称为此方法声明的属主参数(receiver parameter)。 此属主参数声明必须包裹在一对小括号()之中。 此属主参数声明部分必须处于func关键字和方法名之间。

下面是一个方法声明的例子:
// Age和int是两个不同的类型。我们不能为int和*int
// 类型声明方法,但是可以为Age和*Age类型声明方法。
type Age int
func (age Age) LargerThan(a Age) bool {
	return age > a
}
func (age *Age) Increase() {
	*age++
}

// 为自定义的函数类型FilterFunc声明方法。
type FilterFunc func(in int) bool
func (ff FilterFunc) Filte(in int) bool {
	return ff(in)
}

// 为自定义的映射类型StringSet声明方法。
type StringSet map[string]struct{}
func (ss StringSet) Has(key string) bool {
	_, present := ss[key]
	return present
}
func (ss StringSet) Add(key string) {
	ss[key] = struct{}{}
}
func (ss StringSet) Remove(key string) {
	delete(ss, key)
}

// 为自定义的结构体类型Book和它的指针类型*Book声明方法。

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

从上面的例子可以看出,我们可以为各种种类(kind)的类型声明方法,而不仅仅是结构体类型。

在很多其它面向对象的编程语言中,属主参数名总是为隐式声明的this或者self。这样的名称不推荐在Go编程中使用。

指针类型的属主参数称为指针类型属主,非指针类型的属主参数称为值类型属主。 在大多数情况下,我个人非常反对将指针这两个术语用做对立面,但是在这里,我并不反对这么用,原因将在下面谈及。

方法名可以是空标识符_。一个类型可以拥有若干名可以是空标识符的方法,但是这些方法无法被调用。 只有导出的方法才可以在其它代码包中调用。 方法调用将在后面的一节中介绍。

每个方法对应着一个隐式声明的函数

对每个方法声明,编译器将自动隐式声明一个相对应的函数。 比如对于上一节的例子中为类型Book*Book声明的两个方法,编译器将自动声明下面的两个函数:
func Book.Pages(b Book) int {
	return b.pages // 此函数体和Book类型的Pages方法体一样
}

func (*Book).SetPages(b *Book, pages int) {
	b.pages = pages // 此函数体和*Book类型的SetPages方法体一样
}

在上面的两个隐式函数声明中,它们各自对应的方法声明的属主参数声明被插入到了普通参数声明的第一位。 它们的函数体和各自对应的显式方法的方法体是一样的。

两个隐式函数名Book.Pages(*Book).SetPages都是aType.MethodName这种形式的。 我们不能显式声明名称为这种形式的函数,因为这种形式中的函数名不属于合法标识符。这样的函数只能由编译器隐式声明。 但是我们可以在代码中调用这些隐式声明的函数:
package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var book Book
	// 调用这两个隐式声明的函数。
	(*Book).SetPages(&book, 123)
	fmt.Println(Book.Pages(book)) // 123
}

事实上,在隐式声明上述两个函数的同时,编译器也将改写这两个函数对应的显式方法(至少,我们可以这样认为),让这两个方法在体内直接调用这两个隐式函数:
func (b Book) Pages() int {
	return Book.Pages(b)
}

func (b *Book) SetPages(pages int) {
	(*Book).SetPages(b, pages)
}

为指针类型属主隐式声明的方法

对每一个为值类型属主T声明的方法,一个相应的同名方法将自动隐式地为其对应的指针类型属主*T而声明。 以上面的为类型Book声明的Pages方法为例,一个同名方法将自动为类型*Book而声明:
// 注意:这不是合法的Go语法。这里这样表示只是
// 为了解释目的。它表明表达式(&aBook).Pages
// 将被估值为aBook.Pages(见随后几节)。
func (b *Book) Pages = (*b).Pages

正因为如此,我并不排斥使用值类型属主这个术语做为指针类型属主这个术语的对立面。 毕竟,当我们为一个非指针类型显式声明一个方法的时候,事实上两个方法被声明了。 一个方法是为非指针类型显式声明的,另一个是为指针类型隐式声明的。

上一节已经提到了,每一个方法对应着一个编译器隐式声明的函数。 所以对于刚提到的隐式方法,编译器也将隐式声明一个相应的函数:
func (*Book).Pages(b *Book) int {
	return Book.Pages(*b)
}

换句话说,对于每一个为值类型属主显式声明的方法,同时将有一个隐式方法和两个隐式函数被自动声明。

方法描述(method specification)和方法集(method set)

一个方法描述可以看作是一个不带func关键字的函数原型。 我们可以把每个方法声明看作是由一个func关键字、一个属主参数声明部分、一个方法描述和一个方法体组成。

比如,上面的例子中的PagesSetPages的描述如下:
Pages() int
SetPages(pages int)

每个类型都有个方法集。一个非接口类型的方法集由所有为它声明的(不管是显式的还是隐式的,但不包含方法名为空标识符的)方法的描述组成。 接口类型将在下一篇文章详述。

比如,在上面的例子中,Book类型的方法集为:
Pages() int
*Book类型的方法集为:
Pages() int
SetPages(pages int)

方法集中的方法描述的次序并不重要。

对于一个方法集,如果其中的每个方法描述都处于另一个方法集中,则我们说前者方法集为后者(即另一个)方法集的子集,后者为前者的超集。 如果两个方法集互为子集(或超集),则这两个方法集必等价。

给定一个类型T,假设它既不是一个指针类型也不是一个接口类型,因为上一节中提到的原因,类型T的方法集总是类型*T的方法集的子集。 比如,在上面的例子中,Book类型的方法集为*Book类型的方法集的子集。

请注意:不同代码包中的同名非导出方法将总被认为是不同名的。

方法集在Go中的多态特性中扮演着重要的角色。多态将在下一篇文章中讲解。

下列类型的方法集总为空:

方法值和方法调用

方法事实上是特殊的函数。方法也常被称为成员函数。 当一个类型拥有一个方法,则此类型的每个值将拥有一个不可修改的函数类型的成员(类似于结构体的字段)。 此成员的名称为此方法名,它的类型和此方法的声明中不包括属主部分的函数声明的类型一致。 一个值的成员函数也可以称为此值的方法。

一个方法调用其实是调用了一个值的成员函数。假设一个值v有一个名为m的方法,则此方法可以用选择器语法形式v.m来表示。

下面这个例子展示了如何调用为Book*Book类型声明的方法:
package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var book Book

	fmt.Printf("%T \n", book.Pages)       // func() int
	fmt.Printf("%T \n", (&book).SetPages) // func(int)
	// &book值有一个隐式方法Pages。
	fmt.Printf("%T \n", (&book).Pages)    // func() int

	// 调用这三个方法。
	(&book).SetPages(123)
	book.SetPages(123)           // 等价于上一行
	fmt.Println(book.Pages())    // 123
	fmt.Println((&book).Pages()) // 123
}

(和C语言不同,Go中没有->操作符用来通过指针属主值来调用方法。(&book)->SetPages(123)在Go中是非法的。)

等一下,上例中的(&book).SetPages(123)一行为什么可以被简化为book.SetPages(123)呢? 毕竟,类型Book并不拥有一个SetPages方法。 啊哈,这可以看作是Go中为了让代码看上去更简洁而特别设计的语法糖。此语法糖只对可寻址的值类型的属主有效。 编译器会隐式地将book.SetPages(123)改写为(&book).SetPages(123)。 但另一方面,我们应该总是认为aBookExpression.SetPages是一个合法的选择器(从语法层面讲),即使表达式aBookExpression被估值为一个不可寻址的Book值(在这种情况下,aBookExpression.SetPages是一个无效但合法的选择器)。

如上面刚提到的,当为一个类型声明了一个方法后,每个此类型的值将拥有一个和此方法同名的成员函数。 此类型的零值也不例外,不论此类型的零值是否用nil来表示。

一个例子:
package main

type StringSet map[string]struct{}
func (ss StringSet) Has(key string) bool {
	_, present := ss[key] // 永不会产生恐慌,即使ss为nil。
	return present
}

type Age int
func (age *Age) IsNil() bool {
	return age == nil
}
func (age *Age) Increase() {
	*age++ // 如果age是一个空指针,则此行将产生一个恐慌。
}

func main() {
	_ = (StringSet(nil)).Has   // 不会产生恐慌
	_ = ((*Age)(nil)).IsNil    // 不会产生恐慌
	_ = ((*Age)(nil)).Increase // 不会产生恐慌

	_ = (StringSet(nil)).Has("key") // 不会产生恐慌
	_ = ((*Age)(nil)).IsNil()       // 不会产生恐慌

	// 下面这行将产生一个恐慌,但是此恐慌不是在调用方法的时
	// 候产生的,而是在此方法体内解引用空指针的时候产生的。
	((*Age)(nil)).Increase()
}

属主参数的传参是一个值复制过程

和普通参数传参一样,属主参数的传参也是一个值复制过程。 所以,在方法体内对属主参数的直接部分的修改将不会反映到方法体外。

一个例子:
package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) SetPages(pages int) {
	b.pages = pages
}

func main() {
	var b Book
	b.SetPages(123)
	fmt.Println(b.pages) // 0
}

另一个例子:
package main

import "fmt"

type Book struct {
	pages int
}

type Books []Book

func (books Books) Modify() {
	// 对属主参数的间接部分的修改将反映到方法之外。
	books[0].pages = 500
	// 对属主参数的直接部分的修改不会反映到方法之外。
	books = append(books, Book{789})
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{500} {456}]
}

有点题外话,如果将上例中Modify方法中的两行代码次序调换,那么此方法中的两处修改都不能反映到此方法之外。
func (books Books) Modify() {
	books = append(books, Book{789})
	books[0].pages = 500
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{123} {456}]
}

这两处修改都不能反映到Modify方法之外的原因是append函数调用将开辟一块新的内存来存储它返回的结果切片的元素。 而此结果切片的前两个元素是属主参数切片的元素的副本。对此副本所做的修改不会反映到Modify方法之外。

为了将此两处修改反映到Modify方法之外,Modify方法的属主类型应该改为指针类型:
func (books *Books) Modify() {
	*books = append(*books, Book{789})
	(*books)[0].pages = 500
}

func main() {
	var books = Books{{123}, {456}}
	books.Modify()
	fmt.Println(books) // [{500} {456} {789}]
}

方法值的正规化

在编译阶段,编译器将正规化各个方法值表达式。简而言之,正规化就是将方法值表达式中的隐式取地址和解引用操作均转换为显式操作。

假设值v的类型为T,并且v.m是一个合法的方法值表达式, 假设值p的类型为*T,并且p.m是一个合法的方法值表达式,

提升方法值的正规化将在随后的类型内嵌一文中解释。

方法值的估值

假设v.m是一个已经正规化的方法值表达式,在运行时刻,当v.m被估值的时候,属主实参v的估值结果的一个副本将被存储下来以供后面调用此方法值的时候使用。

以下面的代码为例:
package main

import "fmt"

type Book struct {
	pages int
}

func (b Book) Pages() int {
	return b.pages
}

func (b *Book) Pages2() int {
	return (*b).Pages()
}

func main() {
	var b = Book{pages: 123}
	var p = &b
	var f1 = b.Pages
	var f2 = p.Pages
	var g1 = p.Pages2
	var g2 = b.Pages2
	b.pages = 789
	fmt.Println(f1()) // 123
	fmt.Println(f2()) // 123
	fmt.Println(g1()) // 789
	fmt.Println(g2()) // 789
}

一个定义类型不会获取为它的源类型显式声明的方法

举个例子,在下面的代码中,定义类型Age并不像它的源类型MyInt一样拥有一个IsOdd方法。
package main

type MyInt int
func (mi MyInt) IsOdd() bool {
	return mi%2 == 1
}

type Age MyInt

func main() {
	var x MyInt = 3
	_ = x.IsOdd() // okay
	
	var y Age = 36
	// _ = y.IsOdd() // error: y.IsOdd undefined
	_ = y
}

如何决定一个方法声明使用值类型属主还是指针类型属主?

首先,从上一节中的例子,我们可以得知有时候我们必须在某些方法声明中使用指针类型属主。

事实上,我们总可以在方法声明中使用指针类型属主而不会产生任何逻辑问题。 我们仅仅是为了程序效率考虑有时候才会在函数声明中使用值类型属主。

对于值类型属主还是指针类型属主都可以接受的方法声明,下面列出了一些考虑因素:

如果实在拿不定主意在一个方法声明中应该使用值类型属主还是指针类型属主,那么请使用指针类型属主。


目录↡

Go101.org网站内容包括Go编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。

本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .来浏览和确认各种改动。

本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。

你或许对本书作者老貘开发的一些App感兴趣。

The English version of this book is here.
赞赏
(《Go语言101》系列丛书由老貘从2016年7月开始编写。目前此系列丛书仍在不断改进和增容中。你的赞赏是本系列丛书和此Go101.org网站不断增容和维护的动力。)

目录: