楔子
如果用了go一段时间之后,肯定会有人发现一个问题:那就是go对类型的检查太严格了。当然这是一件好事,可以避免我们犯错误,但是有些时候我们需要一个变量能够接收不同类型的值。比如在定义函数参数的时候,我们希望这个参数可以接收多种类型的值,那么这个时候该怎么做呢?很简单,go为我们提供了interface{}。
鸭子类型
先来看看鸭子类型的定义:
如果某个东西长得像鸭子,像鸭子一样游泳,像鸭子一样嘎嘎叫,那它就可以被看成是一只鸭子。
Duck Typing,鸭子类型,是动态编程语言的一种对象推断策略,它更关注对象能如何被使用,而不是对象的类型本身。Go 语言作为一门静态语言,它通过通过接口interface{}的方式完美支持鸭子类型。
笔者本人是主Python的,如果在Python中我们可以定义一个这样的函数:
def say_hi(obj):
return obj.hi()
在调用该函数的时候,你可以传入任意类型,那么Python解释器是如何做的呢?
1. 首先Python中的变量都是一个泛型指针PyObject *;
2. 当执行obj.hi的时候, 解释器会调用PyObject_GenericGetAttr(obj, "hi")在obj的属性字典中进行属性查找, 返回"hi"对应的value, 结果也是一个PyObject *; 如果没有找到的话, 那么会抛出AttributeError;
3. 找到之后再通过PyObject_CallObject进行调用;
所以我们看到给obj参数传递什么数据根本无关紧要,只要传递的变量可以调用hi即可,而且这一步是在运行时才发生的;换言之,如果报属性错误一定是在运行时报的错。
而对于静态语言而言,比如:C++,必须要显示地声明实现了某个接口之后,才能用在任何需要这个接口的地方。如果你在程序中调用 hello_world 函数,却传入了一个根本就没有实现 hello_world() 的类型,那在编译阶段就不会通过。这也是静态语言比动态语言更安全的原因。
动态语言和静态语言的差别在此就有所体现:静态语言在编译期间就能发现类型不匹配的错误,不像动态语言,必须要运行到那一行代码才会报错。当然,静态语言要求程序员在编码阶段就要按照规定来编写程序,为每个变量规定数据类型,这在某种程度上,加大了工作量,也加长了代码量。动态语言则没有这些要求,可以让人更专注在业务上,代码也更短,写起来更快。所以有优势就有劣势,鱼和熊掌往往是不可兼得的。
Go 语言作为一门现代静态语言,是有后发优势的。它引入了动态语言的便利,同时又会进行静态语言的类型检查,写起来是非常 Happy 的。Go 采用了折中的做法:不要求类型显示地声明实现了某个接口,只要实现了相关的方法,编译器就能检测到。
我们举个栗子:
package main
import "fmt"
// 定义一个接口people, 和接口中的函数:
type people interface {
say(word string)
}
// 定义两个结构体
type girl struct {name string}
type boy struct {name string}
// 我们并没有将girl显示地声明为借口people类型, 只要实现了接口内部的方法
// 那么编译器就会隐式地将其转成people类型
func (g girl) say(s string) {
fmt.Printf("%s say\n", g.name)
}
func (b boy) say(s string) {
fmt.Printf("%s say\n", b.name)
}
// 声明一个函数
func common_say(p people, word string) {
p.say(word)
}
func main() {
g := girl{"mashiro"}
b := boy{"sorata"}
g.say("hello") // mashiro say
b.say("hello") // sorata say
}
所以go里面如果想实现某个接口,不需要显示的声明,只需要实现对应接口中的方法即可。
顺带再提一下动态语言的特点:
变量指向的对象的类型是不确定的, 在运行期间才能确定;
函数和方法可以接收任何类型的参数, 且调用时不检查参数类型、不需要实现接口;
总结一下,鸭子类型是一种动态语言的风格,在这种风格中,一个对象有效的语义,不是由继承自特定的类或实现特定的接口决定,而是由它 "当前方法和属性的集合" 决定。Go 作为一种静态语言,通过接口实现了 鸭子类型,实际上是 Go 的编译器在其中作了隐匿的转换工作。
值接收者和指针接收者的区别
方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。
在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。
举个栗子:
package main
import "fmt"
type girl struct {
age int
}
func (g girl) ageIncr1() {
g.age++
}
func (g *girl) ageDecr1() {
g.age--
}
func main() {
g1 := girl{16}
//值类型调用者 调用 值类型接收者的方法
g1.ageIncr1()
fmt.Println(g1.age) // 16
g2 := girl{16}
//值类型调用者 调用 指针类型接收者的方法
g2.ageDecr1()
fmt.Println(g2.age) // 15
g3 := &girl{16}
//指针类型调用者 调用 值类型接收者的方法
g3.ageIncr1()
fmt.Println(g3.age) // 16
g4 := &girl{16}
//指针类型调用者 调用 值类型接收者的方法
g4.ageDecr1()
fmt.Println(g4.age) // 15
}
我们暂时先不看结果,总之我们目前可以得出:不管接收者是什么类型,该类型的值和指针都可以调用。
但是为什么一个变了,一个没变呢?这是因为go里面变量传递都是值传递,我们还是先来说说调用方法时背后都发生了些什么吧。
实际上,当 调用方法的类型 和 方法的接收者类型 不同时,其实是编译器在背后做了一些工作,用一个表格来呈现:
所以正如前面所说,不管接收者类型是值类型还是指针类型,都可以通过 值调用者 或 指针调用者 进行调用,这里面实际上通过语法糖起作用的。
如果是值接收者, 指针类型调用, 那么会通过 *指针 的方式调用, 并将值拷贝一份;
如果是指针接收者, 值类型调用, 那么会通过 &值 的方式调用, 并将指针拷贝一份;
因此在调用之后age是否改变,取决于接收者到底是值类型还是指针类型,与调用者无关,因为go编译器会进行转化。
但是问题来了,如果值接收者实现了一个方法,那么相同类型的指针接收者可不可以实现相同的方法呢?可以自己测试一下,答案是不行的。
因为不管是值还是指针,它们都是同一类型的值和指针。
在实现接口的时候,它们也是有区别的,举个栗子:
package main
import "fmt"
type people interface {
a()
b()
}
type girl struct {}
// 如果想实现某个接口, 那么只需要实现该接口中的方法即可
func (g girl) a() {
fmt.Println("girl -> a")
}
func (g *girl) b() {
fmt.Println("girl -> b")
}
// 但是我们看到方法a是值接收者实现的, 方法b是指针接收者实现的
func main() {
g := girl{}
var p people
// 但是此时将g赋值给p是报错的, 因为没有实现people中的所有方法
// 而指针可以
p = &g
p.a() //girl -> a
p.b() //girl -> b
}
所以区别如下:
实现了接收者是值类型的方法, 相当于自动实现了接口中接收者是指针类型的方法;
而实现了接收者是指针类型的方法, 不会自动生成接口中对应接收者是值类型的方法;
因此 girl 实现了 方法 a,会自动让 *girl 也实现了 方法 a;但是*girl实现了 方法 b,不代表 girl 也实现了 方法 b。
p = g
如果此时将g强行赋值给p,那么会出现如下编译错误。
# command-line-arguments
.\1.go:28:7: cannot use g (type girl) as type people in assignment:
girl does not implement people (b method has pointer receiver)
报错信息给的很详细,不能将 girl类型的 变量g 作为people类型进行赋值,因为它没有实现people这个接口,括号里面提示:方法b的接收者是指针,并不会让值接收也拥有方法b。因此girl只实现了接口中的一个方法,所以它没有实现该接口的全部方法,因此不能赋值。
至于为什么会有这么一个设计,原因很简单:
接收者是指针类型的方法,很可能在方法中会对接收者的属性进行更改操作,从而影响接收者;而对于接收者是值类型的方法,在方法中不会对接收者本身产生影响。
所以,当实现了一个接收者是值类型的方法,就可以自动生成一个接收者是对应指针类型的方法,因为两者都不会影响接收者。但是,当实现了一个接收者是指针类型的方法,如果此时自动生成一个接收者是值类型的方法,原本期望对接收者的改变(通过指针实现),现在却无法实现,因为值类型会产生一个拷贝,不会真正影响调用者。
因此,只要记住下面这点就可以了:
如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。
那么问题又来了,我实现方法的时候到底应该采用值接收者还是指针接收者呢?
如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。
使用指针作为方法的接收者的理由:
方法能够修改接收者指向的值;
避免在每次调用方法时复制该值, 在值的类型为大型结构体时, 这样做会更加高效;
但是判断使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质。
如果类型具备 "原始的本质",也就是说它的成员都是由 go 语言里内置的原始类型,如字符串,整型值等,那就定义值接收者类型的方法。像内置的符合类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的 header,而 header 本身就是为复制设计的。
如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份实体。
类型转换与断言
我们知道,Go 语言中不允许隐式类型转换,也就是说 = 两边,不允许出现类型不相同的变量。类型转换、类型断言本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。
类型转换
对于类型转换而言,转换前后的两个类型要相互兼容才行。类型转换的语法为:<结果类型> := <目标类型> ( <表达式> )
package main
import "fmt"
func main() {
var i int = 9
var j float64
// 这个时候直接把i赋值给j是非法的, 因为go的=两边不允许出现类型不同的变量
// 我们需要进行类型转化
j = float64(i)
fmt.Println(j)
// 但是注意: int(3.14) 则不行, 因为会涉及截断, 前后值发生了改变
// 将一个float64类型转化为int, 除非这个float64的小数点后面是0
// 那如果遇见小数点后面不是0的浮点数该咋办呢? 可以使用math.Floor(), 会返回一个小数点后面是0的浮点数, 此时再转成int即可
}
断言
前面说过,因为空接口 interface{} 没有定义任何函数,因此 go 中所有类型都实现了空接口。当一个函数的形参是 interface{},那么在函数中,需要对形参进行断言,从而得到它的真实类型。
断言的语法为:
<目标类型的值>,<布尔参数> := <表达式>.( 目标类型 ) // 安全类型断言
<目标类型的值> := <表达式>.( 目标类型 ) //非安全类型断言
类型转换和类型断言有些相似,不同之处,在于类型断言是对接口进行的操作。举个栗子:
package main
import "fmt"
func main() {
var i interface{} = 123
// 这个i是一个空接口, 任何类型都实现了空接口
// 这个时候我们赋了一个值, 虽然我们知道这个是123, 但如果是在函数中呢?
// 我们要如何得知这个变量的类型呢?我们可以使用断言的方式, 我们就认定它是整型, 那么就可以这么做
var num = i.(int)
fmt.Println(num) // 123
// 但如果断言是一个字符串的话, 显然是会报错的
// 这个时候可以使用安全断言, 也就是采用两个变量来接收
s, flag := i.(string)
fmt.Printf("%q %t\n", s, flag) // "" false
// 如果不能成功转换, 那么会得到 零值 和 false
// 成功转换会得到 对应的值 和 true
// 因此我们还可以使用switch语句
switch i.(type) {
case int:
fmt.Println("int", i) // int 123
case float64:
fmt.Println("float64", i)
default:
fmt.Println("Unknown type")
}
}
所以我们最开始说的如果函数需要接收不同类型的值,就可以使用interface{}, 然后进行断言。
如果不是空接口也可以这么做,举个栗子:
package main
import "fmt"
type people interface {
say() string
}
func f(p people) {
// 一旦断言成功, 就会将转换成对应类型的值,并赋值给t
switch t := p.(type) {
case girl:
fmt.Println("1.", t, t.say())
case *girl:
fmt.Println("2.", t, t.say())
case boy:
fmt.Println("3.", t, t.say())
case *boy:
fmt.Println("4.", t, t.say())
default:
panic("不支持的类型")
}
//fmt.Println(s.say())
}
type girl struct {age int}
type boy struct {age int}
func (g girl) say() string {
return "girl -> say"
}
func (b boy) say() string {
return "boy -> say"
}
func main() {
g := &girl{16}
f(g) // 2. &{16} girl -> say
}
因此我们便使用 interface 实现了多态,Go 语言并没有设计诸如虚函数、纯虚函数、继承、多重继承等概念,但它通过接口却非常优雅地支持了面向对象的特性。多态是一种运行期的行为,它有以下几个特点:
一种类型具有多种类型的能力;
允许不同的对象对同一消息做出灵活的反应;
以一种通用的方式对待使用的对象;
非动态语言必须通过继承和接口的方式来实现
小结
这次我们介绍了go中的接口,但这只是其中的一部分,我们再后续介绍反射的时候会继续聊。