go interface类型转换_详解GO接口类型

f564a098c54e9f8b389f192a1cadeecc.png

[TOC]

  • 1.接口概述
  • 2.接口的底层实现方式
  • 3.接口的nilnon-nil问题
  • 4.接口与接收者类型
  • 5.如何判断类型是否实现了某接口
  • 6.GO的类型转换和断言
  • 7.接口转换的原理
  • 8.如何使用接口实现多态
  • 9.GO与C++实现多态的异同
  • 10.总结

1. 接口概述

GO是一门静态语言,有着严格的静态语言的类型检查。同时GO又引入了动态语言的便利,通过“鸭子类型”的接口来实现动态多态非常的方便。

goroutinechannel支撑起了GO的高并发模型,而接口类型则是GO的整个类型系统的基石。通过接口,可以实现运行时多态,类型转换,类型断言,方法的动态分派等等功能。

GO有两种接口类型:空接口和非空接口。

两种接口的底层实现方式相似,但在使用场景上是不同的。

要理解接口,需要首先理解接口的底层数据结构。

2. 接口的底层实现方式

应该先了解接口的底层实现方式(即接口的数据结构),再去看接口的类型转换/反射/空接口等内容。因为接口的特征实际是是底层结构的反映,而且接口的底层实现也很简单。

上面提到GO根据接口类型是否含有方法集将接口分为了两类:

  • iface非空接口,含有一组方法集
  • * eface空接口,不含方法集

二者在底层数据结构的实现上略有不同。

(1). 非空接口

79e16dee5311bbc088d3be514173400d.png
type iface struct{ // 两个指针,16byte
    tab *itab             // 指向一个内部表
    data unsafe.Pointer   // 指向所持有的数据
}

上面就是iface的数据结构,只包含两个指针,大小为16byte,可以说是非常简单了。

  • itab指针指向一个itab结构体,该itab结构体记录了该接口值的一系列信息,包括接口静态类型信息,持有数据的动态类型信息,方法集等,用以进行 接口的类型转换,编译器类型检查,辅助反射等等;即:非空接口的itab既包含接口类型相关的信息,又包括所持数据的类型相关的信息
  • data是一个指向实际数据的指针

具体来看一下itab,这个结构体还是很重要的,是GO接口实现的基础。

type itab struct { // 32 bytes
    inter *interfacetype    // 类型的静态类型信息,比如io.Reader
    _type *_type            // 是一个结构体,记录所持有数据的类型相关的一系列信息
    hash  uint32            // 
    _     [4]byte
    fun   [1]uintptr        // 存储接口的方法集
}
  1. ifaceitab都可以看出,接口interface包含有两种类型:
  • 一种是接口自身的类型,称为接口的静态类型,比如io.Reader等,用于确定接口类型,直接存储在itab结构体中
  • 一种是接口所持有数据的类型,称为接口的动态类型,用于在反射或者接口转换时确认所持有数据的实际类型,存储在运行时runtime/_type结构体中。
  1. hash是一个uint32类型的值,实际上可以看作是类型的校验码,当将接口转换成具体的类型的时候,会通过比较二者的hash值确定是否相等,只有hash相等 才能进行转换。
注:GO中每种类型都有自己的类型信息,存储在运行时 runtime/_type结构体中,类型的 hash值即是 _type的字段之一。实际上, itab中的 hash 只是其所持有数据的类型的 _type结构体中 hash的一个拷贝。
  1. fun最终指向的是接口的方法集,即存储了接口所有方法的函数的指针。通过比较接口的方法集和类型的方法集,可以用来判断该类型是否实现了该接口。 把fun指向的方法集看作是一个虚函数表,也是很贴切的。

最后再简单看一下运行时runtime/_type结构体。该结构体包含了GO类型的所有类型信息,如类型大小/类别/哈希等等。 只需要有这个概念就好。

type _type struct {
    size       uintptr  // 类型大小
    ptrdata    uintptr
    hash       uint32   // 前面阐述过,相当于类型的校验码
    tflag      tflag
    align      uint8    // 对齐方式
    fieldAlign uint8
    kind       uint8    // 类别
    equal      func(unsafe.Pointer, unsafe.Pointer) bool
    gcdata     *byte
    str        nameOff
    ptrToThis  typeOff
}

(2). 空接口

理解了非空接口的底层数据结构,再来看空接口的底层数据结构就更简单了。

空接口类型也是接口类型的一种,只不过它没有方法集,同时空接口也是GO实现多态的基础,因此将空接口进行单独定义,一来简化底层数据结构,二来更好的支持GO的运行时多态。

bdfca583adaaad6f9d6002ce61abce09.png
type eface struct{ // 两个指针,16byte
    _type *_type             // 指向一个内部表
    data unsafe.Pointer   // 指向所持有的数据
}
  1. 空接口是单独的唯一的一种接口类型,因此自然不需要itab中的接口类型字段了
  2. 空接口也没有任何的方法,因此自然也不存在itab中的方法集了

以上,空接口使用itab字段就有点多余了。因此,空接口中直接保存所持有数据的运行时类型信息_type,而不必使用itab

3. 接口的nil与non-nil

从上面接口的底层数据结构可以看出,接口总是存在两个指针类型的字段。因此,当且仅当该接口值的两个字段都是nil的时候,该接口==nil才成立。

更不能想当然的认为空接口==nil,因为判断接口是否等于nil只有一种条件,即其两个字段都是nil。

直接看几个示例代码:

func main() {
    var a interface{}  // 申明一个空接口,此时a的类型字段和数据字段都是nil

    var x *int             // x==nil,但x仍然有类型(*int)
    var b interface{} = x  // 将x赋给一个空接口,则该空接口保存了x的类型信息(*int)和x的值(nil)

    var y int = 5                          // y!=nil,y有值(5),有类型(int)
    var c interface{} = (int)(y)         // 将y赋给一个空接口,该空接口保存了y的类型信息(*int)和y的值(y的内存地址)

    // %T会输出接口所持有数据的类型,实际上底层会调用reflect.TypeOf(i)
    fmt.Printf("a type: %T, value: %v, a==nil? %vn", a, a, a==nil)  // 输出:a type: <nil>, value: <nil>, a==nil? true
    fmt.Printf("b type: %T, value: %v, b==nil? %vn", b, b, b==nil)  // 输出:b type: *int,  value: <nil>, b==nil? false
    fmt.Printf("c type: %T, value: %v, c==nil? %vn", c, c, c==nil)  // 输出:c type: int,   value: 5,     c==nil? false
}

总之,凡是给一个空接口赋值了,那么该赋值后的空接口就必然保留了值的类型,从而成为non-nil接口

4. 接口与接收者类型

接口与接收者类型之间的关系有两个注意点: 1. 值接收者和指针接收者代表了两种完全不同的类型,其对接口的实现是独立的。 2. 某类型对某接口的实现,或者是值接收者类型的对该接口的实现,或者是指针接收者类型对该接口的实现,二者只能选一个,不能共存。

对于第一点,还有个需要注意的地方是,当为值接收者实现某方法时,GO编译器会自动的为其指针接收者也实现该方法。

fbd798b2c5efd6fbf5206439ecfa3963.png

方法的值接收者与引用接收者的区别的详细解释在另一篇GO的类型系统和类型的方法中。

直接看代码示例:

type Coder interface{
    Coding()
    Debug()
}

type Worker struct{
    Name string
}

func (w Worker) Coding(){
    fmt.Printf("%s is codingn", w.Name)
}

func (w *Worker) Debug(){
    fmt.Println("Debug is panic.")
}

func main() {
    // var w Coder = Worker{"lito"}  // 报错:Worker没有实现Coder接口

    var t Coder = &Worker{"neo"}
    t.Debug()
}

代码示例中,Coder是一个接口类型,定义了两个方法Coding()Debug()。r

Worker的值接收者类型实现了Coding()方法,根据上面所述,此时指针接收者类型也实现了该方法;

Worker的指针接收者实现了Debug()方法,但值接收者类型并没有实现该方法。

综上:

  • Worker值类型只实现了Coding()方法 ----> Worker没有实现Coder接口
  • &Worker指针类型实现了Coding()方法和Debug()方法 ----> &Worker实现了Coder接口

对于第二点就很好理解了,Worker&Wroker有且只能有一个实现了Coder接口。两个都实现了的话,会直接通不过编译。

5. 如何判断类型是否实现了某接口

GO中接口的实现是“隐式”的,是一种“鸭子类型”,即某类型实现了某接口的所有方法,那么该类型就实现了该接口。

类型与接口之间没有显式的进行继承,也没有任何形式的关联。

判断某类型是否实现了某接口是编译器自动实现的,即在将类型显式或隐式的转换成某接口的过程中,编译器会自动对比其方法集,以确定该类型是否实现了接口的所有方法。

GO语言在实际执行的过程中,接口的方法集和类型的方法集都是按照统一的排序方式进行排序的。假设接口方法集的长度是m,类型方法集的长度是n,那么判断该类型方法集是否包含 接口的方法的时间复杂度是O(m+n)

实际上,很多开源的第三方库也是这么做的。一般在函数入口会有类似的这么一句:

var _ Coder = (*Worker)(nil) // 判断 Worker是否实现了Coder接口 // (Worker)(nil)表示一个指向Worker类型的空指针 var _ Coder = Worker{} // 判断Worker是否实现了Coder接口

上面两个例子分别用来判断对应类型的引用接收者类型和值接收者类型是否实现了Coder接口。

实现的方式也是一样的,第一步建立一个值为nil的空值,第二步将其赋给var _ Coder, 此时编译器就会自动根据给定的类型和接口信息进行判断,即通过显式的类型 转换,在转换的过程中进行判断。

关于类型转换的知识点将在下面介绍。

6. GO的类型转换和断言

Go语言不允许隐式类型转换,所有的类型转换都需要显式的进行。

类型转换和类型断言的本质都是把一个类型转换到另一个类型。

但是二者在使用场景和转换方式上还是有差别的:

  • 类型转换是用来在不同但相互兼容的类型之间的相互转换的方式,当类型不兼容的时候,是无法转换的。
  • 类型断言是用在接口上的

(1) 类型转换

至于什么是不同但相互兼容的类型,我没有找到准确的定义,目前来看可以大致按照如下方式区分:即基础类型相同但精度范围不同的两种类型。

比如intfloat都属于数值型,但是精度范围是不同的,这两种类型之间可以互转;

[]bytestring的基础类型是一样的,因此这两种类型也可以互转。

类型转换都必须显式的进行,示例如下:

func main(){
    var f float32 = 1.45
    var i int = int(f)
    //var s string = string(f)  // 错误,类型不兼容
    fmt.Printf("%T, %dn", i,i) // 输出:int, 1

    var bs []byte = []byte{40,41,42}
    var ss string = string(bs[:])  // 这里有个点要注意:string和byte数组不能直接转换,string和byte数组的切片可以进行转换
    fmt.Printf("%T, %sn", ss,ss)  // 输出:string, ()*
}

(2) 类型断言

类型断言用在接口转换上,即接口到其所持有数据类型的类型转换。

空接口是没有定义任何方法的,因此GO中所有类型都实现了空接口,从而所有类型都可以安全的转换到空接口上。也正因此,空接口名正言顺的成为了GO实现多态的基础。

当一个函数的形参是空接口interface{},函数内部在使用的时候需要显式的获得该空接口所持有数据的类型和所持有的数据才能进行后续的运算,即需要从空接口 断言,获得它的真实(动态)类型。

断言的两种形式:

  • 安全的断言方法: <目标类型值>, ok := <空接口值>.(目标类型)
  • 非安全的断言方法:<目标类型值> := <空接口值>.(目标类型)

安全的断言方法用的最多,这种形式我们应该非常熟悉了,即增加了bool类型的ok值,来捕获该次断言是否成功,从而避免断言失败造成panic进而引起程序的崩溃。

举例说明:

func GetWork(i interface{}){
    w, ok := i.(Worker)
    if !ok{
        log.Printf("input is not Worker type")
        return
    }
    w.Coding()
    w.Debug()
}

func main(){
    w := Worker{"Lito"}
    GetWork(w)
    i := "Neo"
    GetWork(i)  // 将产生类型断言错误
}

上面这种方法是在明确我们需要Worker类型的时候,对传入的空接口是否持有Worker类型进行判断。

还有一种方式,是使用switch对i可能的类型进行遍历判断,这种使用方式无论在自己平常编码还是标准库或第三方库中都是非常常见的。 直接看fmt.Println(a ...interface{})的源代码:

func (p *pp) printArg(arg interface{}, verb rune) {
    p.arg = arg
    p.value = reflect.Value{}

    if arg == nil {
        switch verb {
        case 'T', 'v':
            p.fmt.padString(nilAngleString)
        default:
            p.badVerb(verb)
        }
        return
    }

    // Special processing considerations.
    // %T (the value's type) and %p (its address) are special; we always do them first.
    switch verb {
    case 'T':
        p.fmt.fmtS(reflect.TypeOf(arg).String())
        return
    case 'p':
        p.fmtPointer(reflect.ValueOf(arg), 'p')
        return
    }

    // Some types can be done without reflection.
    switch f := arg.(type) {
    case bool:
        p.fmtBool(f, verb)
    case float32:
        p.fmtFloat(float64(f), 32, verb)
    case float64:
        p.fmtFloat(f, 64, verb)
    case complex64:
        p.fmtComplex(complex128(f), 64, verb)
    case complex128:
        p.fmtComplex(f, 128, verb)
    case int:
        p.fmtInteger(uint64(f), signed, verb)
    case int8:
        p.fmtInteger(uint64(f), signed, verb)
    case int16:
        p.fmtInteger(uint64(f), signed, verb)
    case int32:
        p.fmtInteger(uint64(f), signed, verb)
    case int64:
        p.fmtInteger(uint64(f), signed, verb)
    case uint:
        p.fmtInteger(uint64(f), unsigned, verb)
    case uint8:
        p.fmtInteger(uint64(f), unsigned, verb)
    case uint16:
        p.fmtInteger(uint64(f), unsigned, verb)
    case uint32:
        p.fmtInteger(uint64(f), unsigned, verb)
    case uint64:
        p.fmtInteger(f, unsigned, verb)
    case uintptr:
        p.fmtInteger(uint64(f), unsigned, verb)
    case string:
        p.fmtString(f, verb)
    case []byte:
        p.fmtBytes(f, verb, "[]byte")
    case reflect.Value:
        // Handle extractable values with special methods
        // since printValue does not handle them at depth 0.
        if f.IsValid() && f.CanInterface() {
            p.arg = f.Interface()
            if p.handleMethods(verb) {
                return
            }
        }
        p.printValue(f, verb, 0)
    default:
        // If the type is not simple, it might have methods.
        if !p.handleMethods(verb) {
            // Need to use reflection, since the type had no
            // interface methods that could be used for formatting.
            p.printValue(reflect.ValueOf(f), verb, 0)
        }
    }
}

7. 接口转换的原理

这里从实现的角度对接口转换的原理进行说明,感兴趣的可以进一步查看其源代码,源代码写的也很漂亮。

接口转换主要分为三类:

  • 具体类型转空接口
  • 具体类型转非空接口
  • 非空接口转空接口

以下一一说明。

1. 具体类型转空接口

空接口有两个字段:所持有对象和所持有对象的类型。

实际上,这两个部分都是一个具体类型的值所天然具有的,实际对象的类型记录在runtime/_type中,实际对象的值记录在堆或栈上。 空接口只不过把这两部分放到了一起,作为一个整体。

因此,具体类型转空接口是很简单的,就是把具体类型的值放到空接口的data字段,把具体类型的类型信息放到空接口的_type字段。

2. 具体类型转非空接口

与空接口相比,非空接口除了记录具体类型的值和其类型之外,还记录了该非空接口自身的类型,该非空接口的方法集等各种信息,所有除所持有数据的值 之外的其他信息都放到非空接口的itab结构体中。

因此具体类型转非空接口的实现如下:

  • 初始化一个非空接口值(姑且称之为对象),该对象包含一个空的data字段,一个itab字段,该itab字段包含接口所有的信息和所持有类型的 _type信息,只不过此时_type是空的;
  • 比较具体类型所持有的方法集和非空接口的方法集,确定该具体类型实现了该接口;
  • 把具体类型的对象的值拷贝到非空接口的data字段中,将具体类型的对象的类型信息拷贝到非空接口的itab._type字段中。

3. 非空接口转空接口

非空接口同样的持有所持有数据的类型信息_type,因此非空接口转空接口的时候,会把dataitab._type拷贝到空接口中。此时丢失了原接口类型信息。

type Person interface{
    RUN()
}

type Man struct{
    Name string
}

func (m Man) RUN(){
    return
}

func tets(){
    var m Person = Man{"Neo"}
    var e interface{} = m
    fmt.Println(reflect.TypeOf(e))  // 会得到main.Man类型,即数据m的原有类型
    fmt.Println(reflect.ValueOf(e)) // {Neo}
}

以上只是接口转换的一个示意性的说明,实际执行上要比这个复杂的多,感兴趣的可以直接看源码。

8.如何使用接口实现多态

一方面,GO没有继承等特性,GO不是面向对象的语言。

另一方面,接口是GO中的一种“类型”,可以如其他类型一样进行各种操作,也有自己的方法集。

而不同类型都可以通过实现接口的所有方法集来实现接口,从而能够被显式的替换成该接口类型。

因此GO使用接口实现多态就很简单了。以下仅作示例。

type Coder interface {
    Coding()
}

type Student struct{
    Name string
}

func (s Student) Coding(){
    fmt.Printf("Student %s is coding...n", s.Name)
}

type Worker struct{
    Name string
}

func (w Worker) Coding(){
    fmt.Printf("Worker %s is coding...n", w.Name)
}

// go_to_work接收Coder接口类型作为参数
func go_to_work(c Coder){
    c.Coding()
}

func main(){
    xiaoming := Student{"xiaoming"}
    xiaohong := Worker{"xiaohong"}

    go_to_work(xiaoming)
    go_to_work(xiaohong)
}

9. GO与C++实现多态的异同

1. 接口实现方式的不同

接口定义了一种规范,描述了类的行为和功能,而不做具体实现。

C++和Java等这类面向对象语言都有专门的接口定义与实现方式。C++ 的接口是使用抽象类来实现的,如果类中至少有一个函数被声明为纯虚函数(即在虚函数后加=0),则这个类就是抽象类。 抽象类中的纯虚函数只做声明,不做实现,具体的实现由子类继承后进行实现。可以看出这类接口实现方式是“侵入式”的,即子类需要显式的声明继承自基类(接口),并进行实现。

而GO语言中的接口实现方式是“非侵入式”的或说松耦合的。接口声明了一系列的方法集,对于某个类型来说,只要其实现了该方法集中的所有方法,那么该类型就实现了该接口。 “某个类型是否实现了某个接口”这个工作是编译器自动进行的(在运行期进行),从而类型不必显式的声明自己要实现哪个接口。

GO与C++实现接口的方式的不同,使得二者在接口的底层数据结构及其内存分布上也有着较大的不同。

2. 多态实现方式和底层实现的不同

1. C++动态多态的实现方式与内存分布

首先看C++动态多态的实现。C++实现动态多态是通过虚函数继承为基础来实现的。

基类定义了虚函数(或者说纯虚函数),编译器则会为基类创建一个虚函数表,用以存放该基类所实现的所有虚函数的指针,该基类维护一个虚基类指针指向该虚函数表。

当子类继承该基类时,也同时继承了基类的属性和虚函数表;当子类增加虚函数或者对重写某个虚函数时,都会在子类的虚函数表中进行增加和修改;同样的,子类也有自己的 虚函数表指针指向自己的虚函数表。

由此可见,在动态多态下,类内的内存分布为:

--------------
  | 虚函数表指针 |
  --------------
  | 父类属性    |
  --------------
  | 子类属性    |
  --------------

(注:以上仅为示意,实际要比这个复杂的多,尤其是在多重继承模式下。)

以上这些都是发生在编译期。

在运行期,当使用基类指针指向派生类对象时,该指针调用的虚函数将是派生类的虚函数表中的函数,从而实现了动态多态。

因为在编译期就已经生成了所有的虚函数表,因此运行时只需要链接过去就好了。

2. GO动态多态的实现方式与内存分布

GO的动态多态是通过接口和反射来实现的。

从上面接口的底层实现我们看到接口的itab *itab字段指向了内部表itabitab中持有接口的类型,接口的方法集,所持有数据的类型等内容, 即接口类型持有两种类型: * 静态类型:即接口的类型,是唯一固定的 * 动态类型:即所持有数据的实际类型,是实现多态和反射的实现基础

接口中持有该接口的方法集,注意:该方法集只是接口所有函数的签名

当某个类型实现了某个接口的时候,GO编译器会不会为该类型生成一个该接口的方法表(即虚表)以使得运行时可以直接动态的调用该虚表呢,或者说,在编译期就明确了 类型都实现了哪些接口以及某个接口都有哪些类型实现了呢?答案是不会的。

因为GO不是基于继承来实现的,只要某个类型实现了某个接口的所有的方法,就实现了这个接口。

如果在编译期要确定接口都实现了哪些接口,是不现实的,原因如下:

  • 假设有m个类型各自实现了x个方法,有n个接口各自有y个方法,则逐一确认类型实现了哪些接口的时间复杂度是O(mn*log(x+y))
  • 由于没有明显的继承,因此我们可能实现了不需要的接口

因此GO的解决方案是,在运行时,在需要使用接口类型的时候去对比一下方法集就好了,然后根据接口所持有的itab._type字段获取到所持有的具体类型,最后去调用该类型的方法。

**GO的动态派发**
这里就涉及到GO的动态派发了。动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是一种在面向对象语言中常见的特性。
Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go 语言会在运行期间决定具体调用该方法的哪个实现。
举例说明:

type Coder interface {
   Coding()
}

type Student struct{
   Name string
}

func (s Student) Coding(){
   fmt.Printf("%s is coding.", s.Name)
}

func main(){
   var xiaoming Coder = Student{"xiaoming"}
   xiaoming.Coding()
   xiaoming.(Student).Coding()
}

调用xiaoming.Coding()时,xiaoming是接口类型,编译期只知道xiaoming(所属接口)有Coding()方法,但不知道其具体实现,只有在运行期才能确定。

调用xiaoming.(Student).Coding(),实际上获取到了xiaoming的实际类型(Student),在编译期就会确定所要调用函数的具体实现。

总结

GO的接口类型在GO的整个类型系统中起到非常重要的作用,通过接口可以实现动态多态,同时接口还是反射/类型转换/类型断言/方法动态分派等的基础。

我们从接口底层数据结构是接口实现所有功能的基础,理解了接口的底层数据结构,再来理解接口的功能实现就很简单了,同时也能够 避免GO使用过程中的很多问题,比如接口的nilnon-nil问题等。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值