【1-5 Golang】Go语言快速入门—结构体与接口

tomato01 · · 531 次点击 · · 开始浏览    

  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指向的是原始结构体变量。结合上述这些类型的定义,我们可以画出接口变量,结构体变量,接口类型,结构体类型等关系示意图: ![1-5.1.png](https://static.golangjob.cn/220919/f37b437aa3cf3cde37c4a6966a1ed4a5.png)   最后,不知道读者有没有遇到过这样的错误: ``` 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语言支持面向对象编程。接口的定义以及使用,接口继承,接口的定义等,需要我们重点理解。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

531 次点击  ∙  1 赞  
加入收藏 微博
1 回复  |  直到 2022-09-21 00:09:14
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传