【Go语言踩坑系列(六)】面向对象

声明

本系列文章并不会停留在Go语言的语法层面,更关注语言特性、学习和使用中出现的问题以及引起的一些思考。

问题思考

为什么有结构体?

首先,我们需要明确面向对象的思想是包含各种独立而又互相调用,这就需要一个承载的数据结构,那么这个结构是什么呢?很显然,在GO语言中就是结构体。

其次,结构体作为一种数据结构,无论是在C还是C++还是Go都发挥了极其重要的作用。另外,在Go语言中其实并没有明确的面向对象的说法,实在要扯上的话,我们可以将struct比作其它语言中的class。至于为什么不用class,可能是作者想要划清和其他语言不同的界限,毕竟Go在面向对象实现这方面是极其轻量的。我们简单看一下结构体的声明:

type Poem struct {
    Title  string //声明属性,开头大小写表示属性的访问权限
    Author string
    intro  string
}

func (poem *Poem) publish() { //和其它语言不一样,golang声明方法和普通方法一致,只是在func后增加了poem Poem这样的声明
    fmt.Println("poem publish")
}

结构体比较

如果结构体的全部成员都是可以比较的,那么结构体也是可以比较的,那样的话两个结构体将可以使用==或!=运算符进行比较。相等比较运算符==将比较两个结构体的每个成员,因此下面两个比较的表达式是等价的:

func main() {
    type Point struct{ X, Y int }

    p := Point{1, 2}
    q := Point{2, 1}
    fmt.Println(p.X == q.X && p.Y == q.Y) // "false"
    fmt.Println(p == q)                   // "false"
}

可比较的结构体类型和其他可比较的类型一样,可以用于map的key类型。

func main() {
    type address struct {
        name string
        age     int
    }

    hits := make(map[address]int)
    hits[address{"nosay", 8}]++
    fmt.Println(hits[address{"nosay", 8}]) // 1

}

结构体在使用时的一个技巧

在结构体传递过程中,如果考虑效率的话,较大的结构体通常会用指针的方式传入和返回。而且如果要在函数内部修改结构体成员的话,用指针传入是必须的;因为在Go语言中,所有的函数参数都是值拷贝传入的(结构体较大的话会重新分配空间,浪费资源),函数参数将不再是函数调用时的原始变量。

接口是什么?

Go 语言中的接口就是一组方法的签名,它是 Go 语言的重要组成部分。使用接口能够让我们更好地组织并写出易于测试的代码。但其实接口的本质就是引入一个新的中间层,调用方可以通过接口与具体实现分离,解除上下游的耦合,上层的模块不再需要依赖下层的具体模块,只需要依赖一个约定好的接口。我们日常使用的sql又何尝不是一个接口呢?例如下图:

GO语言接口是隐式的,一种鸭子模型很明确的体现,那么鸭子模型是什么?“当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。”在接口上体现就是当你实现了接口的所有方法的时候就会认为你实现了接口,而不用像其他语言一样去显示声明我实现了这个接口。例如下边这个例子Dog就实现了Pet接口:

type Pet interface {
    SetName(name string)
}

type Dog struct {
    Class string
}

func (dog *Dog) SetName(name string) {
    dog.Class = name
}

在Go语言中,只需要实现所有接口中定义的方法,我们就默认这个类型实现了接口。

接口的数据结构

Go 语言根据接口类型『是否包含一组方法』对类型做了不同的处理,也就是分为空接口和有方法的接口。我们使用 iface 结构体表示包含方法的接口;使用 eface 结构体表示不包含任何方法的 interface{} 类型。接下来我们来看看这两种数据结构。

eface:

type eface struct { // 16 bytes
    _type *_type
    data  unsafe.Pointer
}

由于 interface{} 类型不包含任何方法,所以它的结构也相对来说比较简单,只包含指向底层数据和类型的两个指针。从上述结构我们也能推断出 — Go 语言中的任意类型都可以转换成 interface{} 类型。

另一个用于表示接口的结构体就是 iface,iface 内部维护两个指针,tab 指向一个 itab 实体, 它表示接口的类型以及赋给这个接口的实体类型。data 则指向接口具体的值,一般而言是一个指向堆内存的指针。

iface:

type iface struct { // 16 bytes
    tab  *itab
    data unsafe.Pointer
}

接下来我们分别来看看type和tab里边又是什么内容:

type:

type _type struct {
    size       uintptr //字段存储了类型占用的内存空间,为内存空间的分配提供信息
    ptrdata    uintptr
    hash       uint32 //字段能够帮助我们快速确定类型是否相等
    tflag      tflag //类型的 flag,和反射相关
    align      uint8 // 内存对齐相关
    fieldAlign uint8
    kind       uint8 //类型的编号,有bool, slice, struct 等等等等
    equal      func(unsafe.Pointer, unsafe.Pointer) bool //字段用于判断当前类型的多个对象是否相等,该字段是为了减少 Go 语言二进制包大小从 typeAlg 结构体中迁移过来的
    gcdata     *byte //gc相关
    str        nameOff
    ptrToThis  typeOff
}

tab:

type itab struct {
    inter  *interfacetype //接口的类型
    _type  *_type //实体的类型
    link   *itab
    hash   uint32 // type.hash的拷贝,用于比较目标类型和接口类型
    bad    bool   // type does not implement interface
    inhash bool   // has this itab been added to hash?
    unused [2]byte
    fun    [1]uintptr // 放置和接口方法对应的具体数据类型的方法地址,这里存储的是第一个方法的函数指针,如果有更多的方法,在它之后的内存空间里继续存储
}

type interfacetype struct {
    typ     _type //实体类型
    pkgpath name //包名
    mhdr    []imethod //函数列表
}

因为eface和iface结构上有一定的共性,我们这里就只看一下iface数据结构的图解,eface只是稍微变化一下就可以:

在这里有个问题,一个iface结构体维护一个接口类型和实体类型的对应关系,我们在代码中常常会去多次实现接口,那怎么存呢?答案就是只要在代码中存在引用关系, go 就会在运行时为这一对具体的 生成 itab 信息。

值方法和指针方法的区别

我们都知道,方法的接收者类型必须是某个自定义的数据类型,而且不能是接口类型或接口的指针类型。所谓的值方法,就是接收者类型是非指针的自定义数据类型的方法。那么,值方法和指针方法体现在哪里呢?我们看下边的代码:

func (cat *Cat) SetName(name string) {
    cat.name = name
}

方法SetName的接收者类型是*Cat。Cat左边再加个*代表的就是Cat类型的指针类型,这时,Cat可以被叫做*Cat的基本类型。你可以认为这种指针类型的值表示的是指向某个基本类型值的指针。那么,这个SetName就是指针方法。那么什么是值方法呢?通俗的讲,把Cat前边的*去掉就是值方法。指针方法和值方法究竟有什么区别呢?请看下文。

  1. 值方法的接收者是该方法所属的那个类型值的一个副本。我们在该方法内对该副本的修改一般都不会体现在原值上,除非这个类型本身是某个引用类型(比如切片或字典)的别名类型。

而指针方法的接收者,是该方法所属的那个基本类型值的指针值的一个副本。我们在这样的方法内对该副本指向的值进行修改,却一定会体现在原值上。这块可能有点绕,但如果之前函数传切片那块理解的话这块也可以想明白,总之就是一个拷贝的是整个数据结构,一个拷贝的是指向数据结构的地址。

  1. 一个自定义数据类型的方法集合中仅会包含它的所有值方法,而该类型的指针类型的方法集合却囊括了前者的所有方法,包括所有值方法和所有指针方法。

严格来讲,我们在这样的基本类型的值上只能调用到它的值方法。但是,Go 语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用到它的指针方法。

例如下边这种也是可以调用的:

type Pet interface {
    Name() string
}

type Dog struct {
    Class string
}

func (dog Dog) Name() string{
    return dog.Class
}

func (dog *Dog) SetName(name string) {
    dog.Class = name
}

func main() {
    a := Dog{"grape"}
    a.SetName("nosay") //a会先取地址然后去调用指针方法
    //Dog{"grape"}.SetName("nosay") //因为是值类型,调用失败,cannot call pointer method       on Dog literal,cannot take the address of Dog literal
    (&Dog{"grape"}).SetName("nosay") //可以
}

在后边你会了解到,一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的。如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量就也会有差异,除非这两个数量都是零。

比如,一个指针类型实现了某某接口类型,但它的基本类型却不一定能够作为该接口的实现类型。例如:

type Pet interface {
   SetName(name string)
   Name()string
   Category()string
}

type Dog struct {
   name string
}

func (dog *Dog) SetName(name string) {
   dog.name = name
}

func(dog Dog) Name()string{
   return dog.name
}

func (dog Dog)Category()string{
   return "dog"
}

func main() {
   dog:=Dog{"little pig"}

   _,ok:=interface{}(dog).(Pet)
   fmt.Printf("Dog implements interface Pet: %v\n", ok) //false
   _, ok = interface{}(&dog).(Pet)
   fmt.Printf("*Dog implements interface Pet: %v\n", ok)
   fmt.Println() //true
}

对于编译器在什么情况下调用这些方法会调用失败有以下几种情况:

值方法 指针方法
结构体初始化变量 通过 不通过
结构体指针初始化变量 通过 通过

说完基础知识的疑惑,接下来我们具体举例看看GO如何实现面向对象的三把斧(继承,封装,多态的);

面向对象的三把斧

“继承”

首先,我们需要明确一个概念,Go语言中是没有继承的概念的,具体原因在官网上是明确作出声明的(参见 为什么没有继承? ,简单的说,面向对象编程中的继承,其实是通过牺牲一定的代码简洁性来换取可扩展性,而且这种可扩展性是通过侵入的方式来实现的,而Go因为类型和接口之间没有明确的关系,所以不需要管理或讨论类型层次结构。

那么,我们通过下边一个例子来看一下Go是怎么通过嵌入组合来实现继承的:

type Animal struct {
  name string
  subject string
}

// 动物的公共方法
func (a *Animal) Eat(food string) {
  fmt.Println("动物")
}
type Cat struct {
  // 继承动物的属性和方法
  Animal
  // 猫自己的属性
  age int
}

// 猫类独有的方法
func (c Cat) Sleep() {
  fmt.Println("睡觉")
}

func main() {
  // 创建一个动物类
  animal := Animal{name:"动物", subject:"动物科"}
  animal.Eat("肉")

  // 创建一个猫类
  cat := Cat{Animal: Animal{name:"猫", subject:"猫科"},age:1}
  cat.Eat("鱼") //调用的Animal的Eat方法,“继承”的体现
  cat.Sleep()
}

封装

Go语言在包的级别进行封装。 以小写字母开头的名称只在该程序包中可见。 你可以隐藏私有包中的任何内容,只暴露特定的类型,接口和工厂函数。

例如,在这里要隐藏上面的Foo类型,只暴露接口,你可以将其重命名为小写的foo,并提供一个NewFoo()函数,返回公共Fooer接口:

type foo struct {
}
 
func (f foo) Foo1() {
    fmt.Println("Foo1() here")
}
 
func (f foo) Foo2() {
    fmt.Println("Foo2() here")
}
 
func (f foo) Foo3() {
    fmt.Println("Foo3() here")
}
 
func NewFoo() Fooer {
    return &Foo{}
}

然后来自另一个包的代码可以使用NewFoo()并访问由内部foo类型实现的Footer接口,当然要记得引入包名:

f := NewFoo()
f.Foo1()
f.Foo2()
f.Foo3()

多态

多态性是面向对象编程的本质:只要对象坚持实现同样的接口,Go语言就能处理不同类型的那些对象。 Go接口以非常直接和直观的方式提供这种能力。

这里有一个精心准备的例子,实现Ihello接口的多个实现被创建并存储在一个slice中,然后轮询调用Hello方法。 你会注意到不同实例化对象的风格。

type IHello interface {
    Hello(name string)
}

func Hello(hello IHello) {
    hello.Hello("hello")
}

type People struct {
    Name string
}

func (people *People) Hello(say string) {
    fmt.Printf("the people is %v, say %v\n", people.Name, say)
}

type Man struct {
    People
}

func (man *Man) Hello(say string) {
    fmt.Printf("the people is %v, say %v\n", man.Name, say)
}

type Women struct {
    People
}

func (women *Women) Hello(say string) {
    fmt.Printf("the people is %v, say %v\n", women.Name, say)
}

func Echo(hello []IHello) {
    for _,val := range hello {
        val.Hello("hello world")
    }
}

func main() {
    hello1 := &People{"people"}
    hello2 := &Man{People{Name: "xiaoming"}}
    hello3 := &Women{People{Name: "xiaohong"}}
    
    sli := []IHello{hello1, hello2, hello3}
    //the people is people, say hello world
    //the people is xiaoming, say hello world
    //the people is xiaohong, say hello world

    Echo(sli)
}

下期预告

【Go语言踩坑系列(七)】Goroutine

关注我们

欢迎对本系列文章感兴趣的读者订阅我们的公众号,关注博主下次不迷路~