概述

在 Go 语言中,如果一个结构体和一个嵌入字段同时实现了相同的接口会发生什么呢?我们猜一下,可能有两个问题:

  • 编译器会因为我们同时有两个接口实现而报错吗?
  • 如果编译器接受这样的定义,那么当接口调用时编译器要怎么确定该使用哪个实现?

在写了一些测试代码并认真深入的读了一下标准之后,我发现了一些有意思的东西,而且觉得很有必要分享出来,那么让我们先从 Go 语言中的方法开始说起。

方法

Go 语言中同时有函数和方法。一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的一个值或者是一个指针。所有给定类型的方法属于该类型的方法集。

下面定义一个结构体类型和该类型的一个方法:

type User struct {
  Name  string
  Email string
}

func (u User) Notify() error
UserNotifyUserNotifyUser
// User 类型的值可以调用接受者是值的方法
damon := User{"AriesDevil", "ariesdevil@xxoo.com"}
damon.Notify()

// User 类型的指针同样可以调用接受者是值的方法
alimon := &User{"A-limon", "alimon@ooxx.com"}
alimon.Notify()

在这个例子中当我们使用指针时,Go 调整和解引用指针使得调用可以被执行。注意,当接受者不是一个指针时,该方法操作对应接受者的值的副本(意思就是即使你使用了指针调用函数,但是函数的接受者是值类型,所以函数内部操作还是对副本的操作,而不是指针操作,参见:http://play.golang.org/p/DBhWU0p1Pv)。

Notify
func (u *User) Notify() error

再来一次之前的调用(注意:当接受者是指针时,即使用值类型调用那么函数内部也是对指针的操作,参见:http://play.golang.org/p/SYBb4xPfPh):

// User 类型的值可以调用接受者是指针的方法
damon := User{"AriesDevil", "ariesdevil@xxoo.com"}
damon.Notify()

// User 类型的指针同样可以调用接受者是指针的方法
alimon := &User{"A-limon", "alimon@ooxx.com"}
alimon.Notify()

如果你不清楚到底什么时候该使用值,什么时候该使用指针作为接受者,你可以去看一下这篇介绍。这篇文章同时还包含了社区约定的接受者该如何命名。

接口

Go 语言中的接口很特别,而且提供了难以置信的一系列灵活性和抽象性。它们指定一个特定类型的值和指针表现为特定的方式。从语言角度看,接口是一种类型,它指定一个方法集,所有方法为接口类型就被认为是该接口。

下面定义一个接口:

type Notifier interface {
  Notify() error
}
NotifierNotify-er

我们可以在接口中定义尽可能多的方法,不过在 Go 语言标准库中,你很难找到一个接口包含两个以上的方法。

实现接口

当涉及到我们该怎么让我们的类型实现接口时,Go 语言是特别的一个。Go 语言不需要我们显式的实现类型的接口。如果一个接口里的所有方法都被我们的类型实现了,那么我们就说该类型实现了该接口。

Notifier
func SendNotification(notify Notifier) error {
  return notify.Notify()
}
SendNotificationNotify
UserUserSendNotification
func (u *User) Notify() error {
  log.Printf("User: Sending User Email To %s<%s>\n",
      u.Name,
      u.Email)
  return nil
}

func main() {
  user := User{
    Name:  "AriesDevil",
    Email: "ariesdevil@xxoo.com",
  }

  SendNotification(user)
}

// Output:
cannot use user (type User) as type Notifier in function argument:
User does not implement Notifier (Notify method has pointer receiver)

为什么编译器不考虑我们的值是实现该接口的类型?接口的调用规则是建立在这些方法的接受者和接口如何被调用的基础上。下面的是语言规范里定义的规则,这些规则用来说明是否我们一个类型的值或者指针实现了该接口:

*T*TT
SendNotification
TT
Notify

语言规范里只有这两条规则,我通过这两条规则得出了符合我们例子的规则:

T*T
NotifyUserSendNotification
func main() {
  user := &User{
    Name:  "AriesDevil",
    Email: "ariesdevil@xxoo.com",
  }

  SendNotification(user)
}

// Output:
User: Sending User Email To AriesDevil<ariesdevil@xxoo.com>

嵌入类型

结构体类型可以包含匿名或者嵌入字段。也叫做嵌入一个类型。当我们嵌入一个类型到结构体中时,该类型的名字充当了嵌入字段的字段名。

User
type Admin struct {
  User
  Level  string
}
AdminUserUserAdmin
mainAdminSendNotification
func main() {
  admin := &Admin{
    User: User{
      Name:  "AriesDevil",
      Email: "ariesdevil@xxoo.com",
    },
    Level: "master",
  }

  SendNotification(admin)
}

// Output
User: Sending User Email To AriesDevil<ariesdevil@xxoo.com>
AdminSendNotificationAdminUser
AdminUser

当我们嵌入一个类型,这个类型的方法就变成了外部类型的方法,但是当它被调用时,方法的接受者是内部类型(嵌入类型),而非外部类型。– Effective Go

因此嵌入类型的名字充当着字段名,同时嵌入类型作为内部类型存在,我们可以使用下面的调用方法:

admin.User.Notify()

// Output
User: Sending User Email To AriesDevil<ariesdevil@xxoo.com>

这儿我们通过类型名称来访问内部类型的字段和方法。然而,这些字段和方法也同样被提升到了外部类型:

admin.Notify()

// Output
User: Sending User Email To AriesDevil<ariesdevil@xxoo.com>
Notify

下面是 Go 语言中内部类型方法集提升的规则:

ST
STS*ST

这条规则说的是当我们嵌入一个类型,嵌入类型的接受者为值类型的方法将被提升,可以被外部类型的值和指针调用。

*S*T

这条规则说的是当我们嵌入一个类型,可以被外部类型的指针调用的方法集只有嵌入类型的接受者为指针类型的方法集,也就是说,当外部类型使用指针调用内部类型的方法时,只有接受者为指针类型的内部类型方法集将被提升。

S*TS*ST*T

这条规则说的是当我们嵌入一个类型的指针,嵌入类型的接受者为值类型或指针类型的方法将被提升,可以被外部类型的值或者指针调用。

这就是语言规范里方法提升中仅有的三条规则,我根据这个推导出一条规则:

STS*T

这条规则说的是当我们嵌入一个类型,嵌入类型的接受者为指针的方法将不能被外部类型的值访问。这也是跟我们上面陈述的接口规则一致。

回答开头的问题

AdminNotifier
func (a *Admin) Notify() error {
  log.Printf("Admin: Sending Admin Email To %s<%s>\n",
      a.Name,
      a.Email)

  return nil
}
AdminAdminSendNotification
AdminSendNotification
func main() {
  admin := &Admin{
    User: User{
      Name:  "AriesDevil",
      Email: "ariesdevil@xxoo.com",
    },
    Level: "master",
  }

  SendNotification(admin)
}

// Output
Admin: Sending Admin Email To AriesDevil<ariesdevil@xxoo.com>
AdminSendNotificationNotify
admin.Notify()

// Output
Admin: Sending Admin Email To AriesDevil<ariesdevil@xxoo.com>
AdminUser

现在我们有了足够的依据来回答问题了:

  • 编译器会因为我们同时有两个接口实现而报错吗?

不会,因为当我们使用嵌入类型时,类型名充当了字段名。嵌入类型作为结构体的内部类型包含了自己的字段和方法,且具有唯一的名字。所以我们可以有同一接口的内部实现和外部实现。

  • 如果编译器接受这样的定义,那么当接口调用时编译器要怎么确定该使用哪个实现?

如果外部类型包含了符合要求的接口实现,它将会被使用。否则,通过方法提升,任何内部类型的接口实现可以直接被外部类型使用。

总结

在 Go 语言中,方法,接口和嵌入类型一起工作方式是独一无二的。这些特性可以帮助我们像面向对象那样组织结构然后达到同样的目的,并且没有其它复杂的东西。用本文中谈到的语言特色,我们可以以极少的代码来构建抽象和可伸缩性的框架。


参考