Go:方法、接口和类型嵌套
概述
你是否考虑过如果一个结构体和它的某个字段对应的类型都实现了同一个接口会发生什么。这里我们可以问自己两个问题:
- 如果对同一个接口实现两次,编译器会不会报错?
- 如果编译器接受这种类型定义,编译器该怎么决定使用哪个实现?
下面通过代码来回答这个问题,然后我们再深入研究细节。你会发现非常有趣的事,值得与其他正在学习Go的人分享。一旦了解了方法、接口和嵌入类型背后的机制,答案就显而易见了。首先,让我们讨论一下Go中的方法。
方法
Go既有函数也有方法。在Go中,方法是含有接收者的函数。接收者可以是一个值或特定类型的指针。类型的所有方法都属于该类型的方法集。
下面我们定义一个struct类型和该类型的一个方法:
type User struct {
Name string
Email string
}
func (u User) Notify() error
首先,我们定义User结构体类型和Notify方法。要调用Notify方法的话,需要一个User类型的值或指针。
//User类型的值可以调接收者为值类型方法。
bill := User{"Bill", "bill@email.com"}
bill.Notify()
//User类型的指针也可以调接收者为值类型方法。
jill := &User{"Jill", "jill@email.com"}
jill.Notify()
在使用指针的情况下,Go会自动解析对指针的引用,这样就可以进行值类型的方法的调用。请注意,当接收者不是指针时,该方法是针对接收者值的一个副本进行操作。
我们可以将Notify方法的接收者改为指针类型:
func (u *User) Notify() error
同样,我们可以像之前那样调用Notify方法:
//User类型值同样可以调用接收者为指针类型的方法
bill := User{"Bill", "bill@email.com"}
bill.Notify()
//User类型指针也可以调用接收者为指针类型的方法
jill := &User{"Jill", "jill@email.com"}
jill.Notify()
如果你不确定什么时候使用值或指针接收者,Go wiki有一组很好的规则可以遵循。Go wiki还包含关于社区为接收者命名的约定。
接口
Go中的接口很特殊,为我们的程序提供了难以置信的灵活性和抽象性。从语言的角度来看,接口是一种指定方法集的类型,接口类型的所有方法都被认为是接口。
下面定一个接口:
type Notifier interface {
Notify() error
}
这里,我们声明了一个名为Notifier的接口和包含一个Notify方法。当接口只包含一个方法时,使用-er后缀命名接口是Go中的一种惯例。这不是一个硬性的规则,但我们应该遵守,特别是当接口和方法名称具有相同的签名和含义时。
实现接口
Go很特别当涉及到接口实现。Go并不要求我们显式地声明我们的类型实现了一个接口。如果接口方法集的每个方法都在定义的类型中实现了,那么该类型就实现了对应的接口。
继续我们的例子,创建一个函数,它接受任何实现了notiffier接口类型的值或指针:
func SendNotification(notify Notifier) error {
return notify.Notify()
}
SendNotification函数调用Notify方法,该方法由传递到该函数的值或指针实现。此函数可用于执行实现了该接口的任何类型的值或指针。
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: "janet jones",
Email: "janet@email.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)
上面的错误说明Notify方法的接受者是指针类型的,因此User类型的值并没有实现该方法,也就是没有实现Notifier接口,不能作为参数传入。
为什么编译器不认为值类型实现了接口呢?确定接口是否被实现的规则是基于这些方法的接收者以及如何进行接口调用。下面是关于编译器如何确定类型的值或指针是否实现接口的规则:
- 指针类型(*T)的方法集包含接受者是值类型(T)和指针类型(*T)的方法。
该规则表明,如果用于调用接口方法的变量是指针,那么不管方法的接受者是值类型还是指针类型都可以被调用。此规则不适用于我们的示例,因为我们向SendNotification函数传递一个值不是指针。
- 值类型(T)的方法集只包含所有接受者为值类型(T)的方法
该规则表明,如果我们用于调用接口的变量是值类型,那么只有接收者也是值类型的方法才能满足接口。此规则不适用于我们的示例,因为Notify方法的接收者是一个指针。
换句话说:
- 接收者为值类型(T)的方法集不包含接收者为指针类型(*T)的方法。
这就是我们的前面代码报错的情况。Notify方法的接收者是指针,我们使用值进行接口方法调用会报错。要解决这个问题,我们只需要将User值的地址传递给SendNotification函数即可:
func main() {
user := &User{
Name: "janet jones",
Email: "janet@email.com",
}
SendNotification(user)
}
// Output:
User: Sending User Email To janet jones<janet@email.com>
嵌套类型
结构类型能够包含匿名或嵌套字段。这也称为类型嵌套。当我们将类型嵌入到结构体中时,类型的名称充当嵌入字段的字段名。
定义一个新类型并嵌入我们的User类型:
type Admin struct {
User
Level string
}
这里定义了一个名为Admin的新类型,并将User类型嵌入到结构体定义中。这不是继承,而是组合。User和Admin类型之间没有关系。
我们修改main函数来创建一个Admin类型的值,并将这个值的地址传递给SendNotification函数:
func main() {
admin := &Admin{
User: User{
Name: "john smith",
Email: "john@email.com",
},
Level: "super",
}
SendNotification(admin)
}
// Output
User: Sending User Email To john smith<john@email.com>
当然,我们能够使用Admin类型的指针调用SendNotification函数。由于组合,Admin类型现在通过从嵌入的User类型,提升了方法来实现接口。
如果Admin类型现在包含User类型的字段和方法,那么它们与结构体的关系是什么?
当我们嵌入一个类型时,该类型的方法成为外部类型的方法,但当它们被调用时,方法的接收者是内部类型,而不是外部类型。- Effective Go
由于嵌入类型的名称充当字段名,而嵌入类型作为内部类型存在,因此可以调用以下方法:
admin.User.Notify()
// Output
User: Sending User Email To john smith<john@email.com>
这里,我们通过使用类型名来访问内部类型的字段和方法集。但是,这些字段和方法也被提升为外部类型:
admin.Notify()
// Output
User: Sending User Email To john smith<john@email.com>
回答开头的问题
现在我们可以完成示例,它将为我们在文章开始时提出的两个问题提供答案。为Admin类型实现Notifier接口:
func (a *Admin) Notify() error {
log.Printf("Admin: Sending Admin Email To %s<%s>\n",
a.Name,
a.Email)
return nil
}
Admin类型实现的接口显示了Admin的信息。这将帮助我们确定当我们使用Admin类型的指针对SendNotification进行调用时将调用哪个实现。
func main() {
admin := &Admin{
User: User{
Name: "john smith",
Email: "john@email.com",
},
Level: "super",
}
SendNotification(admin)
}
// Output
Admin: Sending Admin Email To john smith<john@email.com>
正如预期的那样,Admin实现的方法被SendNotification函数调用。那么我们使用外部类型调用Notify方法时会发生什么呢:
admin.Notify()
// Output
Admin: Sending Admin Email To john smith<john@email.com>
我们得到的是Admin类型实现的输出。User类型的实现不再提升为外部类型:
现在我们有了回答这些问题所需要的知识:
- 编译器会因为我们有两次接口实现而抛出错误吗?
不会,因为当我们使用嵌入类型时,它的名称充当字段名。这样做的效果是,内嵌类型的字段和方法具有唯一的名称作为结构体的内部类型。因此,我们可以拥有相同接口的内部和外部实现,并且每个实现都是惟一且可访问的。 - 如果编译器接受类型定义,编译器如何确定接口调用哪个实现?
如果外部类型满足接口的实现,则将使用外部类型的方法。否则,由于方法提升,任何实现接口的内部类型都可以通过外部类型使用。
总结
方法、接口和嵌套类型协同工作的方式使Go非常独特。这些特性帮助我们创建强大的结构体类型,以实现与面向对象编码相同的功能,而不具有复杂性。有了我们在这篇文章中谈到的语言特性,我们可以用最少的代码来构建抽象的和可伸缩的框架。
对Go语言和编译器的细节了解得越多,就越能理解该语言的正交性。一些小的特性协同工作,允许我们发挥创造性,以语言设计者都没想过的方式使用语言。建议您花时间学习语言特性,这样您就可以用更少的资源做更多的事情,同时具有创造性和生产力。