4

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


李烁
156 声望92 粉丝