2

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

 1 year ago
source link: https://studygolang.com/articles/35874?fr=sidebar
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

tomato01 · 1天之前 · 295 次点击 · 预计阅读时间 13 分钟 · 大约8小时之前 开始浏览    

  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

  最后,不知道读者有没有遇到过这样的错误:

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语言支持面向对象编程。接口的定义以及使用,接口继承,接口的定义等,需要我们重点理解。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK