Go语言支持面向对象编程,但是又和传统的面向对象语言如C++,Java等略有不同:Go语言没有类class的概念,只有结构体strcut,其可以拥有属性,可以拥有方法,我们可以通过结构体实现面向对象编程。Go语言也有接口interface的概念,其定义一组方法集合,结构体只要实现接口的所有方法,就认为其实现了该接口,结构体类型变量就能赋值给接口类型变量,这相当于面向对象中的多态。另外,Go语言也可以有继承的概念,不过是通过结构体的"组合"实现的。
结构体
Go语言基于结构体实现面向对象编程,与类class的概念比较类似,结构体可以拥有属性,也可以拥有方法;我们通过点号访问结构体任意属性或者方法。一般定义方式如下所示:
package main
import "fmt"
//type关键字用于定义类型;Student结构体拥有两个属性/字段
type Student struct {
Name string
Score int
}
//结构体方法,方法中可以使用结构体变量;
func (s Student) Study() {
s.Score += 10
}
//结构体指针方法,方法中可以使用结构体指针变量
func (s *Student) Study1() {
s.Score += 10
}
func main() {
stu := Student{
Name: "张三",
Score: 60,
}
stu1 := &stu
fmt.Println(stu.Score) //60
//stu与stu1变量,分别执行Study与Study1方法
stu.Study()
fmt.Println(stu.Score) //60
stu.Study1()
fmt.Println(stu.Score) //70
stu1.Study()
fmt.Println(stu1.Score) //70
stu1.Study1()
fmt.Println(stu1.Score) //80
}
注意方法Study与方法Study1的声明,Study归属结构体类型变量,Study1归属结构体指针类型变量;两个方法中都修改了Score属性。main方法中相应的定义了结构体变量stu,结构体指针变量stu1;分别执行Study & Study1方法,变量stu与stu1的Score属性会发生变化吗?执行结果如上所示,在解释之前读者可以思考下为什么是这样的结果。另外,方法Study属于结构体类型,为什么stu1变量可以调用呢?而方法Study1属于结构体指针类型,stu也可以调用。
在回答上面问题之前,我们先思考下,Study/Study1方法中为什么能直接使用stu/sut1变量呢?其实是编译过程中做了一些处理,声明的结构体方法,以及结构体方法的调用,都和目前看到的不太一样。底层编译生成的函数如下:
//输入参数类型为Student
Student.Study
//输入类型为*Student,函数定义:
(*Student).Study {
//Ax寄存器第一个参数,就是*Student指针;拷贝结构体数据
MOVQ (AX), DX
MOVQ 8(AX), BX
MOVQ 16(AX), CX
//传递结构体参数
//最终还是调用Student.Study函数
CALL Student.Study
}
//输入参数类型为*Student
(*Student).Study1
可以看到,Study方法底层编译生成了两个函数;而Study1只编译生成一个函数。编译生成的函数,第一个参数都是结构体变量,或者结构体指针变量,这下明白了,原来是通过第一个参数传递过去的。而4种调用方式编译过程也做了一些修改:
//stu.Study方法调用,拷贝stu变量作为输入参数
CALL Student.Study(SB)
//stu.Study1,stu变量地址作为输入参数
CALL (*Student).Study1(SB)
//stu1.Study,stu1是指针,拷贝指针指向的结构体作为输入参数
CALL Student.Study(SB)
//stu1.Study1,stu1指针变量作为输入参数
CALL (*Student).Study1(SB)
再强调一次Go语言是按值传递参数的。结合上面的描述我们说明下4种调用方式下Score属性最终结果:1)stu.Study,stu变量作为输入参数,按值传递,传递的是数据副本,所以Score不会改变;2)stu.Study1,以stu变量地址作为输入参数,传递的是地址,函数内的数据修改,stu变量肯定会同步修改;3)stu1.Study,stu1变量虽然是指针,但是调用Student.Study函数时,仍然传递的是stu1指向结构体的数据副本,所以Score不会改变;4)stu1.Study1,以stu1指针变量作为输入参数,函数内的数据修改,stu1指向的数据肯定会同步修改。
最后再思考一个问题,结构体变量占多少字节内存呢?这就看结构体的属性定义了,结构体占用的内存大小等于所有字段占用内存大小之和,当然还要考虑内存对齐。比如结构体Student,包含一个字符串16字节(字符串长度8字节+字符串指针8字节),包含一个整型8字节,所以Student类型变量需要24字节内存。而访问Student类型变量的属性,其实只需要简单的变量首地址加属性偏移量就行了。那结构体的方法呢?只存储属性不需要存储方法吗?当然是不需要了,因为结构体方法的调用,在编译阶段就确定了具体的函数。
结构体-继承
面向对象有一个很重要的概念叫继承,子类可以继承父类的某些属性或者方法,Go语言结构体也支持继承;不过语法与传统面向对象语言有些不同,更像是通过组合来实现的继承。如下面程序所示:
package main
import "fmt"
type Human struct {
Name string
Age int
}
func (h Human)Say() {
say := fmt.Sprintf("I am %s, my age is %d", h.Name, h.Age)
fmt.Println(say)
}
type Student struct {
Human
Score int
}
func (s Student)Study() {
say := fmt.Sprintf("I am %s, my age is %d, my score is %d", s.Name, s.Age, s.Score)
fmt.Println(say)
}
func main() {
var stu Student
stu.Name = "zhangsan"
stu.Age = 18
stu.Score = 90
stu.Say()
stu.Study()
}
结构体Student包含结构体Human,可以看到stu变量类型为结构体Student,但是我们可以直接操作属性Name/Age,以及方法Say,而这些都是结构体Human的属性和方法。那么,Go语言是如何维护这类继承关系呢?再进一步,我们操作结构体属性或者方法时,Go语言如何判断该结构体是否包含这些属性以及方法呢?
其实,Go语言所有类型,都有其对应的类型定义,可以在文件runtime/type.go查看。如结构体类型structtype,structfield定义了结构体属性,method定义了结构体方法;如指针类型ptrtype;如函数类型functype等。我们通过"type xxx struct"方式定义的结构体,其所有信息都在structtype;通过"go tool compile"也可以看到我们自定义的所有类型。
type."".Student SRODATA
rel 96+8 t=1 type..namedata.Human.+0 //属性1
rel 104+8 t=1 type."".Human+0
rel 120+8 t=1 type..namedata.Score.+0 //属性2
rel 128+8 t=1 type.int+0
rel 144+4 t=5 type..namedata.Say.+0 //方法1
rel 148+4 t=26 type.func()+0
rel 152+4 t=26 "".(*Student).Say+0
rel 156+4 t=26 "".Student.Say+0
rel 160+4 t=5 type..namedata.Study.+0 //方法2
rel 164+4 t=26 type.func()+0
rel 168+4 t=26 "".(*Student).Study+0
rel 172+4 t=26 "".Student.Study+0
type."".Human SRODATA
rel 96+8 t=1 type..namedata.Name.+0 //属性1
rel 104+8 t=1 type.string+0
rel 120+8 t=1 type..namedata.Age.+0 //属性2
rel 128+8 t=1 type.int+0
rel 144+4 t=5 type..namedata.Say.+0 //方法1
rel 148+4 t=26 type.func()+0
rel 152+4 t=26 "".(*Human).Say+0
rel 156+4 t=26 "".Human.Say+0
可以看到,自定义类型属于SRODATA,只读。暂时不需要一行一行去理解,我们先简单看看能不能获取一些有用信息。type."".Student类型定义,包含了属性type..namedata.Human(类型type."".Human),以及属性type..namedata.Score(类型type.int);包含方法"".Student.Say,以及方法"".Student.Study。基于这些信息,也就相当于结构体Student拥有了属性Name/Age,以及方法Say。
最后,结构体类型structtype定义如下:
type structtype struct {
typ _type //公共type类型,所有类型首先包含该公共字段
fields []structfield //属性
//结构体后面还跟有方法定义method
}
type _type struct {
size uintptr //该类型占多少字节内存
hash uint32
kind uint8 //类型,如kindStruct,kindString,kindSlice等
//等等
}
接口
Go语言也有接口interface的概念,其定义一组方法集合,结构体并不需要声明实现某借口,其只要实现接口的所有方法,就认为其实现了该接口,结构体类型变量就能赋值给接口类型变量。根据这些描述我们可以知道,只有当结构体类型变量赋值给接口类型变量时,Go语言才会校验结构体是否实现了该接口,在这之前是不会校验也完全没有必要校验的。
Go语言接口使用方式通常如下:
package main
import "fmt"
type Animal interface {
Eat()
Move()
}
type Human struct {
Name string
Age int
}
func (h Human)Eat() {
say := fmt.Sprintf("I am %s, I can eat", h.Name)
fmt.Println(say)
}
func (h Human)Move() {
say := fmt.Sprintf("I am %s, I can move", h.Name)
fmt.Println(say)
}
func main() {
var animal Animal
animal = Human{Name: "zhangsan", Age: 20}
animal.Eat()
animal.Move()
}
变量animal的类型为接口Animal,我们将结构体Human类型赋值给变量animal,而结构体Human实现了方法Eat/Move;方法调用animal.Eat以及animal.Move,其实执行的是结构体Human的方法。再扩展一下,变量animal类型是Animal接口,其赋值的是什么结构体,最终访问的就是什么结构体的方法,这是不是可以理解为面向对象常说的多态呢?
变量animal在内存是如何维护存储呢?变量animal占多大字节内存呢?通过变量animal,又是如何找到其对应其对应结构体类型的属性呢?以及方法呢?貌似变量animal会比较复杂,需要存储结构体Human的所有属性,还需要存储所有方法的地址。确实是这样,接口类型变量的定义在runtime/runtime2.go文件:
type iface struct {
tab *itab
data unsafe.Pointer //指向结构体变量,为了获取结构体变量的属性
}
type itab struct {
inter *interfacetype //interfacetype即接口类型定义,其包含接口声明的所有方法;
_type *_type //结构体类型定义
fun [1]uintptr //柔性数组,长度是可变的,存储了所有方法地址(从结构体类型中拷贝过来的)
}
itab也相当于自定义类型(结构体赋值给接口,自动生成的),其定义当然也可以通过"go tool compile"查看:
//结构体(指针)类型变量赋值给接口类型变量,自动创建对应itab类型
go.itab."".Human,"".Animal SRODATA
rel 0+8 t=1 type."".Animal+0 //interfacetype
rel 8+8 t=1 type."".Human+0 //结构体type定义
rel 24+8 t=-32767 "".(*Human).Eat+0 //方法1
rel 32+8 t=-32767 "".(*Human).Move+0 //方法2
type."".Animal SRODATA
rel 96+4 t=5 type..namedata.Eat.+0 //方法1
rel 100+4 t=5 type.func()+0
rel 104+4 t=5 type..namedata.Move.+0 //方法2
rel 108+4 t=5 type.func()+0
type."".Human SRODATA
rel 96+8 t=1 type..namedata.Name.+0 //属性1
rel 104+8 t=1 type.string+0
rel 120+8 t=1 type..namedata.Age.+0 //属性2
rel 128+8 t=1 type.int+0
rel 144+4 t=5 type..namedata.Eat.+0 //方法1
rel 148+4 t=26 type.func()+0
rel 152+4 t=26 "".(*Human).Eat+0
rel 156+4 t=26 "".Human.Eat+0
rel 160+4 t=5 type..namedata.Move.+0 //方法2
rel 164+4 t=26 type.func()+0
rel 168+4 t=26 "".(*Human).Move+0
rel 172+4 t=26 "".Human.Move+0
另外注意,animal = Human{}方式赋值时,会将原始结构体变量拷贝一份副本,iface.data指向的是该副本数据;animal = &Human{}方式赋值时,iface.data指向的是原始结构体变量。结合上述这些类型的定义,我们可以画出接口变量,结构体变量,接口类型,结构体类型等关系示意图:
最后,不知道读者有没有遇到过这样的错误:
package main
import "fmt"
type Animal interface {
Eat()
Move()
}
type Human struct {
}
func (h *Human)Eat() {
fmt.Println("Eat")
}
func (h Human)Move() {
fmt.Println("Move")
}
func main() {
var animal1 Animal
animal1 = &Human{}
animal1.Move()
animal1.Eat()
//这样却能调用
h := Human{}
h.Eat()
h.Move()
//这样却语法错误
/**
var animal Animal
animal = Human{}
animal.Move()
animal.Eat()
//cannot use Human{…} (value of type Human) as type Animal in assignment:
//Human does not implement Animal (Eat method has pointer receiver)
*/
}
初学Go语言可能会比较迷惑,方法接受者可以是结构体或者结构体指针,接口变量可以赋值为结构体或者结构体指针。但是当遇到上面程序:animal赋值为结构体变量,Eat方法接收者为结构体指针,竟然编译错误,提示结构体Human没有实现接口Animal的方法,并且说明Eat方法接受者为结构体指针。而animal1变量赋值为结构体指针,却既能调用Eat方法,也能调用Move方法。为什么呢?
其实我们在定义了结构体Human后,Go语言不止定义了type."".Human一种类型,还定义了结构体指针类型,我们通过通过"go tool compile"看一下:
//结构体(指针)类型变量赋值给接口类型变量,自动创建对应itab类型
go.itab.*"".Human,"".Animal
type.*"".Human SRODATA
rel 72+4 t=5 type..namedata.Eat.+0 //方法1
rel 76+4 t=26 type.func()+0
rel 80+4 t=26 "".(*Human).Eat+0
rel 84+4 t=26 "".(*Human).Eat+0
rel 88+4 t=5 type..namedata.Move.+0 //方法2
rel 92+4 t=26 type.func()+0
rel 96+4 t=26 "".(*Human).Move+0
rel 100+4 t=26 "".(*Human).Move+0
type."".Human SRODATA
rel 96+4 t=5 type..namedata.Move.+0 //方法1
rel 100+4 t=26 type.func()+0
rel 104+4 t=26 "".(*Human).Move+0
rel 108+4 t=26 "".Human.Move+0
这下明确了,结构体Human类型只有Move方法,而结构体Human指针类型有Eat以及Move方法;所以在向接口Animal类型赋值时,结构体变量无法编译通过。然而我们又发现,结构体变量h,却可以调用Eat以及Move方法,不是说结构体Human类型只有Move方法吗?其实这是编译阶段做了处理,将变量h的地址(也就是结构体Human指针类型)作为参数传递给Eat方法了。
这一点要特别注意,方法接收者不管是结构体还是结构体指针,通过结构体变量或者结构体指针变量调用,都是没有问题的。但是,一旦赋值给接口类型变量,编译时会做类型检查,发现结构体类型没有实现某些方法,可是会导致语法错误的。
再扩展思考一下为什么要这么设计呢?结构体变量赋值给接口类型变量,不是一样可以获取到该结构体地址呢?不同样可以调用Eat方法。为什么不设计成这样呢?原因其实上面已经解释过了,animal = Human{}方式赋值时,会将原始结构体变量拷贝一份副本,iface.data指向的是该副本数据,这时候获取到的地址,还是原始结构体变量的地址吗?
空接口
Go语言将接口分为两种:带方法的接口,一般比较复杂,用iface表示;不带方法的接口也就是空接口,一般当我们不知道变量类型时,会声明变量类型为空接口(interface{}),其余类型可以转化为空接口类型。将某一类型变量转化为空接口时,依然需要维护原始变量类型,以及数据,Go语言用eface表示空接口变量,定义如下:
type eface struct {
_type *_type //变量的实际类型
data unsafe.Pointer //数据指针
}
我们经常使用fmt.Println函数向控制台输出变量,其输入参数类型为空接口,在调用该函数时,一定会触发类型转化,将原始变量转化为eface变量:
a := 111
fmt.Println(a)
//构造eface变量
eface.type = type.int
eface.data = runtime.convT64(a)
fmt.Println(eface)
说到这里还有一个比较有意思的现象,由于任何类型都能转化为interface{},nil转化之后还等于nil吗?刚开始写Go语言,老是搞不清楚,明明最初值是nil,作为interface{}类型传递到函数之后,再判断竟然不等于nil了!现在知道了,空接口interface{}对应的变量用eface表示,肯定是不会等于nil的。
package main
import "fmt"
func main() {
var a map[string]int = nil
fmt.Println(a == nil) //true
test(a)
}
func test(v interface{}) {
fmt.Println(v == nil) //false
}
最后,任意类型转化为interface{}之后,还能转化回来吗?当然是可以的,Go语言可以使用类型断言将接口转化为其他类型,使用方式如下:
package main
import "fmt"
type Human struct {
Name string
}
func main() {
h := Human{Name: "zhangsan"}
var v interface{} = h //结构体类型转化为interface{}
human := v.(Human) //类型断言,转化为结构体Human
fmt.Println(human.Name)
}
是不是很简单?但是使用类型断言的时候一定要注意,如果类型不匹配,可是会出现panic异常的!其实v.(Human)可以返回两个值,第一个转化的类型变量,第二个bool值代表是否是该类型,这时候就不会有panic了。
//类型断言,转化为结构体Human
human := v.(Human)
//伪代码:
if eface.type != type."".Human {
runtime.panicdottypeE()
}
human = *eface.data
//类型断言,转化为结构体Human
human, ok := v.(Human)
if eface.type == type."".Human {
ok = true
human = *eface.data
}
对于interface{}类型变量,其实我们也可以很方便获取到其类型,这样就能根据不同类型执行不同业务逻辑了。如将变量转化为字符串函数可以通过如下方式:
func ToStringE(i interface{}) (string, error) {
switch s := i.(type) {
case string:
return s, nil
case bool:
return strconv.FormatBool(s), nil
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), nil
//等等
}
总结
结构体以及接口是Go语言非常重要的两个概念;与传统面向对象语言的类class以及接口非常类似;正因为结构体与接口的存在,我们才说Go语言支持面向对象编程。接口的定义以及使用,接口继承,接口的定义等,需要我们重点理解。