方法

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

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

    1. T必须是一个;
    2. T必须和此方法声明定义在同一个代码包中;
    3. T不能是一个指针类型;
    4. T不能是一个接口类型。接口类型将在下一篇文章中讲解。

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

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

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

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

    • 内置基本类型。比如intstring。 因为这些类型声明在内置builtin标准包中,而我们不能在标准包中声明方法。
    • 接口类型。但是接口类型可以拥有方法。详见下一篇文章
    • 除了满足上面条件的形如*T的指针类型之外的非定义组合类型。

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

    下面是一个方法声明的例子:

    从上面的例子可以看出,我们可以为各种种类(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. // 注意:这不是合法的Go语法。这里这样表示只是
    2. // 为了解释目的。它表明表达式(&aBook).Pages
    3. func (b *Book) Pages = (*b).Pages

    上一节已经提到了,每一个方法对应着一个编译器隐式声明的函数。 所以对于刚提到的隐式方法,编译器也将隐式声明一个相应的函数:

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

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

    比如,上面的例子中的PagesSetPages的原型如下:

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

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

    比如,在上面的例子中,Book类型的方法集为:

    1. Pages() int

    *Book类型的方法集为:

    1. 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中是非法的。)

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

    一个例子:

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

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

    一个例子:

    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. fmt.Println(b.pages) // 0
    12. }

    另一个例子:

    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. // 对属主参数的直接部分的修改不会反映到方法之外。
    10. books = append(books, Book{789})
    11. }
    12. func main() {
    13. var books = Books{{123}, {456}}
    14. books.Modify()
    15. fmt.Println(books) // [{500} {456}]
    16. }

    有点题外话,如果将上例中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. }

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

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

    • 如果m是一个为类型*T显式声明的方法,那么编译器将把它正规化(&v).m
    • 如果m是一个为类型T显式声明的方法,那么v.m已经是一个正规化的方法值表达式。

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

    • 如果m是一个为类型T显式声明的方法,那么编译器将把它正规化(*p).m
    • 如果m是一个为类型*T显式声明的方法,那么p.m已经是一个正规化的方法值表达式。

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

    方法值的估值

    假设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

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

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

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

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

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