方法

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

方法声明

在Go中,我们可以为类型T*T显式地声明一个方法,其中类型T必须满足四个条件:

  • T必须是一个定义类型
  • T必须和此方法声明定义在同一个代码包中;
  • T不能是一个指针类型;
  • T不能是一个接口类型。接口类型将在下一篇文章中讲解。 类型TT称为它们各自的方法的属主类型(receiver type)。类型T被称作为类型TT声明的所有方法的属主基类型(receiver base type)。

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

如果我们为某个类型声明了一个方法,以后我们可以说此类型拥有此方法。 从上面列出的条件,我们得知我们不能为下列类型(显式地)声明方法:

  • 内置基本类型。比如intstring。因为这些类型声明在内置builtin标准包中,而我们不能在标准包中声明方法。
  • 接口类型。但是接口类型可以拥有方法。详见下一篇文章
  • 除了满足上面条件的形如*T的指针类型之外的非定义组合类型。 一个方法声明和一个函数声明很相似,但是比函数声明多了一个额外的参数声明部分。此额外的参数声明部分只能含有一个类型为此方法的属主类型的参数,此参数称为此方法声明的属主参数(receiver parameter)。此属主参数声明必须包裹在一对小括号()之中。此属主参数声明部分必须处于func关键字和方法名之间。 下面是一个方法声明的例子:
  1. // Age和int是两个不同的类型。我们不能为int和*int
  2. // 类型声明方法,但是可以为Age和*Age类型声明方法。
  3. type Age int
  4. func (age Age) LargerThan(a Age) bool {
  5. return age > a
  6. }
  7. func (age *Age) Increase() {
  8. *age++
  9. }
  10. // 为自定义的函数类型FilterFunc声明方法。
  11. type FilterFunc func(in int) bool
  12. func (ff FilterFunc) Filte(in int) bool {
  13. return ff(in)
  14. }
  15. // 为自定义的映射类型StringSet声明方法。
  16. type StringSet map[string]struct{}
  17. func (ss StringSet) Has(key string) bool {
  18. _, present := ss[key]
  19. return present
  20. }
  21. func (ss StringSet) Add(key string) {
  22. ss[key] = struct{}{}
  23. }
  24. func (ss StringSet) Remove(key string) {
  25. delete(ss, key)
  26. }
  27. // 为自定义的结构体类型Book和它的指针类型*Book声明方法。
  28. type Book struct {
  29. pages int
  30. }
  31. func (b Book) Pages() int {
  32. return b.pages
  33. }
  34. func (b *Book) SetPages(pages int) {
  35. b.pages = pages
  36. }

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

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

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

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

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

对每个方法声明,编译器将自动隐式声明一个相对应的函数。比如对于上一节的例子中为类型Book*Book声明的两个方法,编译器将自动声明下面的两个函数:

  1. func Book.Pages(b Book) int {
  2. return b.pages // 此函数体和Book类型的Pages方法体一样
  3. }
  4. func (*Book).SetPages(b *Book, pages int) {
  5. b.pages = pages // 此函数体和*Book类型的SetPages方法体一样
  6. }

在上面的两个隐式函数声明中,它们各自对应的方法声明的属主参数声明被插入到了普通参数声明的第一位。它们的函数体和各自对应的显式方法的方法体是一样的。 两个隐式函数名Book.Pages(*Book).SetPages都是aType.MethodName这种形式的。我们不能显式声明名称为这种形式的函数,因为这种形式不属于合法标识符。这样的函数只能由编译器隐式声明。但是我们可以在代码中调用这些隐式声明的函数:

  1. package main
  2. import "fmt"
  3. type Book struct {
  4. pages int
  5. }
  6. func (b Book) Pages() int {
  7. return b.pages
  8. }
  9. func (b *Book) SetPages(pages int) {
  10. b.pages = pages
  11. }
  12. func main() {
  13. var book Book
  14. // 调用这两个隐式声明的函数。
  15. (*Book).SetPages(&book, 123)
  16. fmt.Println(Book.Pages(book)) // 123
  17. }

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

  1. func (b Book) Pages() int {
  2. return Book.pages(b)
  3. }
  4. func (b *Book) SetPages(pages int) {
  5. (*Book).SetPages(b, pages)
  6. }

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

对每一个为值类型属主T声明的方法,编译器将自动隐式地为其对应的指针类型属主T声明一个相应的同名方法。以上面的为类型Book声明的Pages方法为例,编译器将自动为类型Book声明一个同名方法:

  1. func (b *Book) Pages() int {
  2. return Book.Pages(*b) // 调用上节中隐式声明的函数
  3. }

这是为什么我并不排斥使用值类型属主这个术语做为指针类型属主这个术语的对立面的原因。毕竟,当我们为一个非指针类型显式声明一个方法的时候,事实上两个方法被声明了。一个方法是为非指针类型显式声明的,另一个是为指针类型隐式声明的。 上一节已经提到了,每一个方法对应着一个编译器隐式声明的函数。所以对于刚提到的隐式方法,编译器也将隐式声明一个相应的函数:

  1. func (*Book).Pages(b *Book) int {
  2. return Book.Pages(*b)
  3. }

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

方法原型(method prototype)和方法集(method set)

一个方法原型可以看作是一个不带func关键字的函数原型。我们可以把每个方法声明看作是由一个func关键字、一个属主参数声明部分、一个方法原型和一个方法体组成。 比如,上面的例子中的PagesSetPages的原型如下:

  1. Pages() int
  2. SetPages(pages int)

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

  1. Pages() int

*Book类型的方法集为:

  1. Pages() int
  2. SetPages(pages int)

方法集中的方法原型的次序并不重要。

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

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

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

方法集在Go中的多态特性中扮演着重要的角色。多态将在下一篇文章中讲解。 下列类型的方法集总为空:

  • 内置基本类型;
  • 定义的指针类型;
  • 基类型为指针类型或者接口类型的指针类型;
  • 非定义的数组/切片/映射/函数/通道类型。

方法值和方法调用

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

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

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

  1. package main
  2. import "fmt"
  3. type Book struct {
  4. pages int
  5. }
  6. func (b Book) Pages() int {
  7. return b.pages
  8. }
  9. func (b *Book) SetPages(pages int) {
  10. b.pages = pages
  11. }
  12. func main() {
  13. var book Book
  14. fmt.Printf("%T \n", book.Pages) // func() int
  15. fmt.Printf("%T \n", (&book).SetPages) // func(int)
  16. // &book值有一个隐式方法Pages。
  17. fmt.Printf("%T \n", (&book).Pages) // func() int
  18. // 调用这三个方法。
  19. (&book).SetPages(123)
  20. book.SetPages(123) // 等价于上一行
  21. fmt.Println(book.Pages()) // 123
  22. fmt.Println((&book).Pages()) // 123
  23. }

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

等一下,上例中的(&book).SetPages(123)一行为什么可以被简化为book.SetPages(123)呢?毕竟,类型Book并不拥有一个SetPages方法。啊哈,这是Go中为了让代码看上去更简洁而特别设计的的语法糖。此语法糖只对可寻址的值类型的属主有效。编译器会自动将book.SetPages(123)改写为(&book).SetPages(123) 如上面刚提到的,当为一个类型声明了一个方法后,每个此类型的值将拥有一个和此方法同名的成员函数。此类型的零值也不例外,不论此类型的零值是否用nil来表示。 一个例子:

  1. package main
  2. type StringSet map[string]struct{}
  3. func (ss StringSet) Has(key string) bool {
  4. _, present := ss[key] // 永不会产生恐慌,即使ss为nil。
  5. return present
  6. }
  7. type Age int
  8. func (age *Age) IsNil() bool {
  9. return age == nil
  10. }
  11. func (age *Age) Increase() {
  12. *age++ // 如果age是一个空指针,则此行将产生一个恐慌。
  13. }
  14. func main() {
  15. _ = (StringSet(nil)).Has // 不会产生恐慌
  16. _ = ((*Age)(nil)).IsNil // 不会产生恐慌
  17. _ = ((*Age)(nil)).Increase // 不会产生恐慌
  18. _ = (StringSet(nil)).Has("key") // 不会产生恐慌
  19. _ = ((*Age)(nil)).IsNil() // 不会产生恐慌
  20. // 下面这行将产生一个恐慌,但是此恐慌不是在调用方法的时
  21. // 候产生的,而是在此方法体内解引用空指针的时候产生的。
  22. ((*Age)(nil)).Increase()
  23. }

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

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

一个例子:

  1. package main
  2. import "fmt"
  3. type Book struct {
  4. pages int
  5. }
  6. func (b Book) SetPages(pages int) {
  7. b.pages = pages
  8. }
  9. func main() {
  10. var b Book
  11. b.SetPages(123)
  12. fmt.Println(b.pages) // 0
  13. }

另一个例子:

  1. package main
  2. import "fmt"
  3. type Book struct {
  4. pages int
  5. }
  6. type Books []Book
  7. func (books Books) Modify() {
  8. // 对属主参数的间接部分的修改将反映到方法之外。
  9. books[0].pages = 500
  10. // 对属主参数的直接部分的修改不会反映到方法之外。
  11. books = append(books, Book{789})
  12. }
  13. func main() {
  14. var books = Books{{123}, {456}}
  15. books.Modify()
  16. fmt.Println(books) // [{500} {456}]
  17. }

有点题外话,如果将上例中Modify方法中的两行代码次序调换,那么此方法中的两处修改都不能反映到此方法之外。

  1. func (books Books) Modify() {
  2. books = append(books, Book{789})
  3. books[0].pages = 500
  4. }
  5. func main() {
  6. var books = Books{{123}, {456}}
  7. books.Modify()
  8. fmt.Println(books) // [{123} {456}]
  9. }

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

  1. func (books *Books) Modify() {
  2. *books = append(*books, Book{789})
  3. (*books)[0].pages = 500
  4. }
  5. func main() {
  6. var books = Books{{123}, {456}}
  7. books.Modify()
  8. fmt.Println(books) // [{500} {456} {789}]
  9. }

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

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

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

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

  • 太多的指针可能会增加垃圾回收器的负担。
  • 如果一个值类型的尺寸太大,那么属主参数在传参的时候的复制成本将不可忽略。指针类型都是小尺寸类型。关于各种不同类型的尺寸,请阅读值复制代价一文。
  • 在并发场合下,同时调用为值类型属主和指针类型属主方法比较易于产生数据竞争。
  • sync标准库包中的类型的值不应该被复制,所以如果一个结构体类型内嵌了这些类型,则不应该为这个结构体类型声明值类型属主的方法。

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

Go语言101项目目前同时托管在GithubGitlab上。欢迎各位在这两个项目中通过提交bug和PR的方式来改进完善Go语言101中的各篇文章。

本书微信公众号名称为"Go 101"。每个工作日此公众号将尽量发表一篇和Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

赞赏