结构体和接口

struct

go/types包中,struct的底层实现。

type Struct struct {
    fields []*Var   // 变量类型的指针
    tags   []string // 字段的标签;如果没有标签,则为nil
}

type Var struct {
    object  
    embedded bool // 如果设置,则变量为嵌入式结构字段,名称为类型名称
    isField  bool // 如果设置,则变量是结构体的字段
    used     bool // 如果设置,则表示变量被使用了
}

一个结构体类型包含若干个字段、每个字段都需要有确切的名字和类型。结构体类型也可以不包含任何字段,这并不是没有意义,可以给结构体类型关联上一些方法,把方法看做是函数的特殊版本。

函数是独立的程序实体,可以声明有名字的函数或者匿名函数,还可以把函数当做值传递。可以把具有相同签名的函数抽象成独立的函数类型,作为一组输入、输出的代表

方法与函数不同,它需要有名字,不能被当做值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者声明体现出来。接收者声明就是在关键字func和方法名之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。

// AnimalCategory 代表动物分类学中的基本分类法
type AnimalCategory struct {
    kingdom string  // 界
    phylum string   // 门
    class  string   // 纲
    order  string   // 目
    family string   // 科
    genus  string   // 属
    species string  // 种
}

// 隶属于AnimalCategory类型的方法String(),方法的接收者是ac
func (ac AnimalCategory) String() string {
    return fmt.Sprintf("%s%s%s%s%s%s%s",
        ac.kingdom, ac.phylum, ac.class, ac.order,
        ac.family, ac.genus, ac.species)
}
// 通过方法的接收者ac,可以在其中引用到当前值的任何一个字段,
// 或者调用当前值的任何一个方法(包括string方法自己)

func main(){
    category := AnimalCategory{species: "cat"}
    fmt.Printf("The animal category: %s\n",category)
    // 无需显示调用String方法,即可打印出该类型的字符串表示形式
}

在Go语言中,通过为一个类型编写名为String()的方法,来自定义该类型的字符串表示形式。这个String()方法不需要任何参数声明,但是需要有一个string类型的结果输出。

方法隶属的类型不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。

  • 一个数据类型关联的所有方法,共同组成了该类型的方法集合

  • 同一个方法集合中的方法不能重名,且不能与该类型中任何字段的名称重复

嵌入字段

面向对象编程的主要原则:将数据及其操作封装在一起。在Go语言中,把结构体类型中的一个字段看作是一项数据,把隶属它的方法看作是附加在其中数据之上的操作。

type Animal struct {
    scientificName string   // 学名
    AnimalCategory          // 动物基本分类
}

// 在结构体类型的某个字段声明中只有一个类型名,则将原类型嵌入到新的类型中

Go语言规范规定,如果一个字段的声明中只有字段的类型名,没有字段的名称,那么就是一个嵌入字段(也称为匿名字段)。通过此类型变量的名称后跟着“.”,在跟着嵌入字段类型的方式引用到该字段,即嵌入字段的类型既是类型也会名称。例如,下面的a.AnimalCategory

func (a Animal) Category() string {
    return a.AnimalCategory.String()
}
// Category方法的接收者类型是Animal,接收者名称a
// 通过a.AnimalCategory选择到a的嵌入字段

选择表达式:在某个代表变量的标识符的右边加上“.”,在加上字段名或方法名,表示选择了某个字段或方法。嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中

animal := Animal{
    scientificName: "American Shorthair",
    AnimalCategory: category,
}
fmt.Printf("The animal: %s\n", animal)
// 此时会直接调用AnimalCategory的String方法

如果Animal也编写自己的String()方法,那么嵌入字段的String()会被屏蔽。只要名称相同,无论方法的签名是否一致,嵌入字段的方法都会被屏蔽

因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,屏蔽现象依然存在。通过链式选择表达式选择被屏蔽的嵌入字段的字段或方法。

多层嵌入

type Cat struct {
    name string
    Animal  // 嵌入字段本身也是嵌入字段
}

func (cat Cat) String() string {
    return fmt.Sprintf("%s (category: %s, name: %q)",
        cat.scientificName, cat.Animal.AnimalCategory, cat.name)
}

屏蔽现象会以嵌入层级为依据,嵌入的层级越深的字段或方法越可能被屏蔽。如,调用Cat的String()方法:

状态

Cat

Animal

AnimalCategory

1

存在,调用

屏蔽

屏蔽

2

不存在

调用

屏蔽

3

不存在

不存在

调用

根据是否存在String()方法,来判断嵌入字段的String()方法是否会被调用或屏蔽。

如果处于同一层级的多个嵌入字段有用同名的字段或方法,那么从被嵌入类型的值那里选择此名称时,会引发编译错,编译器无法确定被选择的成员到底是哪一个

类型组合

Go语言中不存在继承的概念,它通过嵌入字段的方式实现了类型之间的组合。面向对象编程中的继承,通过牺牲一定的代码简洁性来换取可扩展性,这种可扩展性是通过侵入的方式来实现的

类型之间的组合采用的是非声明的方式,是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。我们只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。同时可以通过“包装”或“屏蔽”的方式来调整或优化嵌入字段。

类型组合非常灵活的通过嵌入字段把一个类型的属性和方法“嫁接”给另一个类型,被嵌入类型自然的实现了嵌入字段所实现的接口。组合比继承更加简洁和清晰,不会有多重继承那样复杂的层次结构和可观的管理成本。接口类型之间也可以组合,以此来扩展接口定义的型号或者标记接口的特征。

值方法与指针方法

方法的接收者类型必须是某个自定义的数据类型,不能是接口类型或者接口的指针类型。

  • 值方法:接收者类型是非指针的自定义数据类型的方法

  • 指针方法:接收者类型是指针类型的方法

// 指针方法
func (cat *Cat) SetName(name string) {
    cat.name = name
}
// SetName方法的接收者类型是*Cat,所以是一个指针方法
  • 取值表达式:*放在一个指针值的左边,来获取该指针指向的基本类型值

  • 取地址表达式:&放在一个可寻址的基本类型值的左边,来获取该基本类型的指针值

值方法与指针方法的区别:

差别

值方法

指针方法

方法接收者

该方法所属的那个类型值的一个副本,值方法内对该副本的修改不会体现在原值上,除非这个类型本身是某个引用类型的别名类型

该方法所属的那个基本类型值的指针的一个副本,指针方法内对该副本的修改会直接体现在原值上

方法集合

自定义数据类型的方法集合中仅包含所有值方法

该类型的指针类型的方法集合包括所有的值方法和指针方法

方法集合的解释:严格来说,基本类型的值上之只能调用它的值方法,但,Go语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用它的指针方法。例如,cat.SetName("monster")会自动转译为(&cat).SetName("monster"),即先取cat的指针值,然后在指针值上调用指针方法SetName。

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

interface

// go/types包中的接口类型是一个结构体
type Interface struct {
    methods   []*Func // 显示声明的方法的有序列表
    embeddeds []Type  // 显式嵌入类型的有序列表

    allMethods []*Func // 在此接口中声明或嵌入的方法的有序列表(TODO(gri):用mset替换)
}

接口类型与其他数据类型不同,它没法被实例化。既不能通过调用new()make()函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。

对于某一个接口类型,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在

接口类型的类型字面量与结构体类型看起来相似,用花括号包裹一些核心信息:

  • 结构体类型:包裹字段声明

  • 接口类型:包裹方法定义

接口类型声明中的方法代表的就是该接口的方法集合,一个接口的方法集合就是它的全部特征。对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征,那么它就一定是这个接口的实现类型,如下所示:

type Pet interface {
    SetName(name string)
    Name() string
    Category() string
}
// 只要一个数据类型的方法集合中有上述3个方法,那它就是Pet接口的实现类型
// 这是一种无侵入式的接口实现方式

判断一个数据类型的某个方法实现的是某个接口类型中的方法:

  1. 两个方法的签名要完全一致

  2. 两个方法的名称要完全一致

// 动态值是赋给接口类型变量的值
// 动态类型是被赋予给接口类型变量的值的类型
dog := Dog{"little pig"}
var pet Pet = &dog
// &dog是动态值,*dog是动态类型
// pet的类型是静态类型

pet的静态类型永远不变,而动态类型会随着赋值的变化而变化。在给一个接口类型的变量赋予动态值之前,它的动态类型是不存在的。

接口变量

如果使用一个变量给另外一个变量赋值,那么真正赋值给后者的,并不是前者持有的那个值,而是该值的一个副本

接口类型本身是无法被值化的,在赋予接口变量动态值之前,它的值一定是nil(接口类型的零值)。当给接口变量赋值时,该变量的动态类型和动态值一起被存储在一个专用的数据结构(iface)中。这个接口变量的值其实是这个专用数据结构的实例,而不是赋予给接口变量的那个动态值。所以接口变量与动态值肯定是不同的,无论是存储的内容还是存储的结构都不同。

专用数据结构(iface)实例会包含两个指针,一个是指向类型信息(这里的类型信息有另一个专用数据结构的实例承载,包含动态类型以及使他实现了接口的方法和调用它们的途径)的指针,另一个是指向动态值的指针。

var dog1 *Dog       // 声明*Dog类型的变dog1,没有初始化,dog1的值为nil
fmt.Println("The first dog is nil. ")
dog2 := dog1        // dog2的值也为nil
fmt.Println("The second dog is nil. ")
var pet Pet = dog2  // pet的动态值不为nil,pet的动态类型为*Dog
if pet == nil {
    fmt.Println("The pet is nil. ")
} else {
    fmt.Println("The pet is not nil. ")
}
// dog2的值是真正的nil,把dog2赋值给pet时,Go语言把值和类型放在一起考虑
// 因此pet的动态值不是nil
// Go语言识别出赋予pet的值是一个*Dog类型的nil值
// Go语言用iface的实例包装它,包装后pet的值肯定不是nil

在Go语言中,把字面量为nil表示的值叫做无类型的nil,这是真正的nil,它的类型也是nil。把一个有类型的nil值赋给接口变量,那么这个变量的值一定不会是那个真正的nil

让接口变量的值为真正的nil的方法:

  1. 只声明接口变量但不初始化

  2. 直接把字面量nil赋予给接口变量

接口组合

接口类型之间的嵌入称为接口的组合,与结构体类型的组合相比,接口类型组合不会出现方法之间的屏蔽。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名不同也不行

与结构体组合相似,把接口类型的名称直接写到另一个接口类型的成员类别中,如下所示:

type Animal interface {
    ScientificName() string
    Category() string
}

type Pet interface {
    Animal      // 嵌入
    Name() string
}
// Animal接口包含的所有方法成为了Pet接口的方法

Go语言团队鼓励声明体量较小的接口,并建议通过接口组合来扩展程序、增加程序的灵活性。相比于包含很多方法的大接口,小接口可以专用地表达某一种能力或某一类特征,也更容易被组合在一起。

接口使用场景

在任何一门编程语言中,接口(方法或行为的集合),在功能和该功能的使用者之间构建了一层薄薄的抽象层。在使用接口时,并不需要了解底层函数是如何实现的,因为接口隔离了各个部分。跟不使用接口相比,使用接口的最大好处就是可以使代码变得简洁

构造函数

很多编程语言都有构造函数。构造函数是定义自定义类型(即,面向对象语言中的类)时使用的一种建立对象的方法,它可以确保必须执行的任何初始化逻辑均已执行。然而Go语言没有构造函数这么一说。约定俗成就是写一个New函数。

// 库源码文件
package widget

type Widget struct {
    id string
}

func (w Widget) ID() string {
    return w.id
}


// 命令源码文件
package main

import (
    "fmt"

    "github.com/Promacanthus/vigour/widget"
)

func main() {
    w := widget.Widget{}
    fmt.Println(w.ID())
}
// output
// 空字符串

在这里是输出的是空字符串,因为实例化w的时候,ID并没有被初始化,那么它就是字符串类型的零值(空字符串),这不是我们想要的结果。可以在widget包中增加New函数来充当构造函数,完成实例化对象时的初始化操作。

// 库源码文件
package widget

import "github.com/google/uuid"

type Widget struct {
    id string
}

func NewWidget() Widget {
    return Widget{id: uuid.New().String()}
}

func (w Widget) ID() string {
    return w.id
}

// 命令源码文件
package main

import (
    "fmt"

    "github.com/Promacanthus/vigour/widget"
)

func main() {
    w := widget.NewWidget()
    fmt.Println(w.ID())
}

// output
6608a452-ed5f-4473-84dc-b4f3e77c470d

这样实例化w的时候,调用New函数就完成了必要的初始化操作,但是与其他编程语言不同,这里的New函数不是一个强制执行操作而只是一个可选操作。进一步修改,Widget修改为包内类型(也就是该位小写widget),那就只能通过New函数来实例化了,虽然编译器不会报错。

其他编程语言中,是复用的基本单位,而在Go语言中,是复用的基本单位。任何无法被包外部访问的内容实质上都是私有的,是这个包内部的实现细节,在使用godoc工具生成说明文档的时候,不会为私有函数和类型生成文档的。

唯一的解决方案就是使用接口,通过创建一个外部可访问的Widget接口,并使用包内私有的widget类型实现这个接口,New函数返回一个公开的类型实例,同时godoc工具也能够生成文档。

// 库源码文件
package widget

import "github.com/google/uuid"

// Widget 接口说明
type Widget interface {
    ID() string
}

type widget struct {
    id string
}

// NewWidget 返回一个新建的 Widget 实例
func NewWidget() Widget {
    return widget{id: uuid.New().String()}
}

func (w widget) ID() string {
    return w.id
}

// 命令源码文件
package main

import (
    "fmt"

    "github.com/Promacanthus/vigour/widget"
)

func main() {
    w := widget.NewWidget()
    fmt.Println(w.ID())
}

// output
3207ae37-2256-4ba3-91f6-9c63d9e29120

总结一下就是,我们有一个类型,需要用户在实例化的时候使用该类提供的New函数(构造函数)进行初始化:

  1. 第一步将类型私有化,

  2. 第二步暴露一个接口类型(包含这个类型的全部方法,这样就实现了这个接口),New函数返回这个接口类型。这样就得到了效果。

最后更新于

这有帮助吗?