第22章:方法
方法
Go支持一些面向对象编程特性,方法是这些所支持的特性之一。 本篇文章将介绍在Go中和方法相关的各种概念。
方法声明
在Go中,我们可以为类型T
和*T
显式地声明一个方法,其中类型T
必须满足四个条件:
类型T
和*T
称为它们各自的方法的属主类型(receiver type)。 类型T
被称作为类型T
和*T
声明的所有方法的属主基类型(receiver base type)。
注意:我们也可以为满足上列条件的类型T
和*T
的别名(第14章)声明方法。 这样做的效果和直接为类型T
和*T
声明方法是一样的。
如果我们为某个类型声明了一个方法,以后我们可以说此类型拥有此方法。
从上面列出的条件,我们得知我们不能为下列类型(显式地)声明方法:
- 内置基本类型。比如
int
和string
。 因为这些类型声明在内置builtin
标准包中,而我们不能在标准包中声明方法。 - 接口类型。但是接口类型可以拥有方法。详见下一篇文章(第23章)。
- 除了满足上面条件的形如
*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
关键字的函数原型(第20章)。 我们可以把每个方法声明看作是由一个func
关键字、一个属主参数声明部分、一个方法描述和一个方法体组成。
比如,上面的例子中的Pages
和SetPages
的描述如下:
Pages() int
SetPages(pages int)
每个类型都有个方法集。一个非接口类型的方法集由所有为它声明的(不管是显式的还是隐式的,但不包含方法名为空标识符的)方法的描述组成。 接口类型将在下一篇文章(第23章)详述。
比如,在上面的例子中,Book
类型的方法集为:
Pages() int
而*Book
类型的方法集为:
Pages() int
SetPages(pages int)
方法集中的方法描述的次序并不重要。
对于一个方法集,如果其中的每个方法描述都处于另一个方法集中,则我们说前者方法集为后者(即另一个)方法集的子集,后者为前者的超集。 如果两个方法集互为子集(或超集),则这两个方法集必等价。
给定一个类型T
,假设它既不是一个指针类型也不是一个接口类型,因为上一节中提到的原因,类型T
的方法集总是类型*T
的方法集的子集。 比如,在上面的例子中,Book
类型的方法集为*Book
类型的方法集的子集。
请注意:不同代码包中的同名非导出方法将总被认为是不同名的。
方法集在Go中的多态特性中扮演着重要的角色。多态将在下一篇文章(第23章)中讲解。
下列类型的方法集总为空:
- 内置基本类型;
- 定义的指针类型;
- 基类型为指针类型或者接口类型的指针类型;
- 无名数组/切片/映射/函数/通道类型。
方法值和方法调用
方法事实上是特殊的函数。方法也常被称为成员函数。 当一个类型拥有一个方法,则此类型的每个值将拥有一个不可修改的函数类型的成员(类似于结构体的字段)。 此成员的名称为此方法名,它的类型和此方法的声明中不包括属主部分的函数声明的类型一致。 一个值的成员函数也可以称为此值的方法。
一个方法调用其实是调用了一个值的成员函数。假设一个值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()
}
属主参数的传参是一个值复制过程
和普通参数传参一样,属主参数的传参也是一个值复制过程。 所以,在方法体内对属主参数的直接部分(第17章)的修改将不会反映到方法体外。
一个例子:
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
是一个合法的方法值表达式,
- 如果
m
是一个为类型*T
显式声明的方法,那么编译器将把它正规化(&v).m
; - 如果
m
是一个为类型T
显式声明的方法,那么v.m
已经是一个正规化的方法值表达式。
假设值p
的类型为*T
,并且p.m
是一个合法的方法值表达式,
- 如果
m
是一个为类型T
显式声明的方法,那么编译器将把它正规化(*p).m
; - 如果
m
是一个为类型*T
显式声明的方法,那么p.m
已经是一个正规化的方法值表达式。
提升方法值的正规化将在随后的类型内嵌(第24章)一文中解释。
方法值的估值
假设v.m
是一个已经正规化的方法值表达式,在运行时刻,当v.m
被估值的时候,属主实参v
的估值结果的一个副本将被存储下来以供后面调用此方法值的时候使用。
以下面的代码为例:
b.Pages
是一个已经正规化的方法值表达式。 在运行时刻对其进行估值时,属主实参b
的一个副本将被存储下来。 此副本等于b
的当前值:Book{pages: 123}
,此后对b
值的修改不影响此副本值。 这就是为什么调用f1()
打印出123
。- 在编译时刻,方法值表达式
p.Pages
将被正规化为(*p).Pages
。 在运行时刻,属主实参*p
被估值为当前的b
值,也就是Book{pages: 123}
。 这就是为什么调用f2()
也打印出123
。 p.Pages2
是一个已经正规化的方法值表达式。 在运行时刻对其进行估值时,属主实参p
的一个副本将被存储下来,此副本的值为b
值的地址。 当b
被修改后,此修改可以通过对此地址值解引用而反映出来,这就是为什么调用g1()
打印出789
。- 在编译时刻,方法值表达式
b.Pages2
将被正规化为(&b).Pages2
。 在运行时刻,属主实参&b
的估值结果的一个副本将被存储下来,此副本的值为b
值的地址。 这就是为什么调用g2()
也打印出789
。
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
}
如何决定一个方法声明使用值类型属主还是指针类型属主?
首先,从上一节中的例子,我们可以得知有时候我们必须在某些方法声明中使用指针类型属主。
事实上,我们总可以在方法声明中使用指针类型属主而不会产生任何逻辑问题。 我们仅仅是为了程序效率考虑有时候才会在函数声明中使用值类型属主。
对于值类型属主还是指针类型属主都可以接受的方法声明,下面列出了一些考虑因素:
- 太多的指针可能会增加垃圾回收器的负担。
- 如果一个值类型的尺寸太大,那么属主参数在传参的时候的复制成本将不可忽略。 指针类型都是小尺寸类型。 关于各种不同类型的尺寸,请阅读值复制代价(第34章)一文。
- 在并发场合下,同时调用值类型属主和指针类型属主方法比较易于产生数据竞争。
sync
标准库包中的类型的值不应该被复制,所以如果一个结构体类型内嵌(第24章)了这些类型,则不应该为这个结构体类型声明值类型属主的方法。
如果实在拿不定主意在一个方法声明中应该使用值类型属主还是指针类型属主,那么请使用指针类型属主。
本书由老貘历时三年写成。目前本书仍在不断改进和增容中。你的赞赏是本书和Go101.org网站不断增容和维护的动力。
(请搜索关注微信公众号“Go 101”或者访问github.com/golang101/golang101获取本书最新版)