云原生系列Go语言篇-类型、方法和接口

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

通过前面章节的学习,我们知道Go是一种静态类型语言,包含有内置类型和用户定义类型。和大部分现代编程语言一样,Go允许我们对类型关联方法。它也具备类型抽象,可以编写没有显式实现的方法。

然而,Go处理方法、接口和类型的方式与现行大部分其它语言大相径庭。Go的设计者鼓励软件开发者所提倡的最佳实践,避免继承、鼓励组合。本章中我们会学习类型、方法和接口,了解如何使用它们来构建可测试、易维护的程序。

Go的类型

复合类型一章中我们学习过如何定义结构体类型:

type Person struct {
    FirstName string
    LastName string
    Age int
}

读作声明名为Person的用户自定义类型,紧接着的是结构体字面量的底层类型。除结构体字面量为,也可以使用任意原始类型或复合类型来定义一个类型。举例如下:

type Score int
type Converter func(string)Score
type TeamScores map[string]Score

Go语言允许我们在包以下的块级声明类型。但仅能在其作用域内访问该类型。唯一的特例是导出的包级类型。我们在模块、包和导入一章中会深入讨论。

注:为更易于讨论类型,我们先做一些名词解释。抽象类型abstract type)指定类型的功能,但不包含如何实现。具象类型concrete type)指定了做什么以及如何做。也就是说它存在存储数据的方式并且提供了对该类型所声明的所有方法的实现。虽然在Go中都是抽象或具象的,但有些语言是允许混合类型的,比如Java中带默认方法的抽象类或接口。

方法

和大部分现代语言一样,Go支持对用户定义类型添加方法。

类型的方法在包级定义:

type Person struct {
    FirstName string
    LastName string
    Age int
}

func (p Person) String() string {
    return fmt.Sprintf("%s %s, age %d", p.FirstName, p.LastName, p.Age)
}

方法声明和函数声明一样,只是加了一个接收器(receiver)说明。接收器位于func关键字和方法名之间。和所有其它变量声明一样,接收器名称位于类型之前。按惯例,接收器名称为类型名的缩写,通常是其第一个字母。使用thisself被视为不地道的做法。

和函数一样,方法名称不能重载。对不同类型可使用相同的方法名,但对相同类型的不同方法不能使用相同名称。从带方法重载的编程语言转过来的会觉得这存在局限,但不复用名称是Go哲学的一部分,以使代码保持清晰。

我们会在模块、包和导入一章中讨论包,注意方法必须其关联类型的同一包中声明,Go不允许对不由你控制的类型添加方法。虽然可以在相同包下的不同文件中的按类型声明定义方法,最好是把类型定义和关联方法放在一起以便更容易跟进实现。

方法调用对于熟悉其它语言的开发者应当不会陌生:

p := Person {
    FirstName: "Fred",
    LastName:"Fredson",
    Age: 52,
}
output := p.String()

指针接收器和值接收器

指针一章中讲到了,Go使用指针类型的参数表示函数中可能会修改参数。对于方法接收器也同样适用。存在指针接收器(类型为指针)或值接收器(类型为值类型)。通过以下规则可协助决定使用哪种接收器:

  • 如果方法会修改接收器,则必须使用指针接收器。
  • 如果方法需要处理nil实例(参见为nil实例编写方法一节),则必须使用指针接收器。
  • 如果方法不修改接收器,则可使用值接收器。

对于不修改接收器的方法是否使用值接收器取决于该类型上所声明的其它方法。只要该类型有一个指针接收器的方法,通常会保持连续性对所有方法都使用指针接收器,不管具体的方法是否修改接收器。

下面有一些简单代码演示指针和值接收器。我们先看一个带两个方法的类型,一个使用值接收器,另一个使用指针接收器:

type Counter struct {
    total             int
    lastUpdated time.Time
}

func (c *Counter) Increment() {
    c.total++
    c.lastUpdated = time.Now()
}

func (c Counter) String() string {
    return fmt.Sprintf("total: %d, last updated: %v", c.total, c.lastUpdated)
}

可以使用如下代码测试这些方法。读者可在The Go Playground运行这段代码:

var c Counter
fmt.Println(c.String())
c.Increment()
fmt.Println(c.String())

得到的输出结果如下:

total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

读者可能注意到了即使c是值类型也能调用指针接收器方法。在通过值类型本地变量使用指针接收器时,Go会自动将其转化为指针类型,c.Increment()被相应地转化为了(&c).Increment()

但对函数传值的规则不变。如果对变量传递值类型,再通过传入值调用指针接收器方法,会使用拷贝来调用方法。可在The Go Playground中调试如下代码:

func doUpdateWrong(c Counter) {
    c.Increment()
    fmt.Println("in doUpdateWrong:", c.String())
}

func doUpdateRight(c *Counter) {
    c.Increment()
    fmt.Println("in doUpdateRight:", c.String())
}

func main() {
    var c Counter
    doUpdateWrong(c)
    fmt.Println("in main:", c.String())
    doUpdateRight(&c)
    fmt.Println("in main:", c.String())
}

运行代码输出结果如下:

in doUpdateWrong: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 0, last updated: 0001-01-01 00:00:00 +0000 UTC
in doUpdateRight: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC
    m=+0.000000001
in main: total: 1, last updated: 2009-11-10 23:00:00 +0000 UTC m=+0.000000001

doUpdateRight的参数为*Counter类型,这是一个指针实例。可以看到对其可调用IncrementString方法。Go中指针接收器和值接收器都被看作是指针实例的方法集。对值实例,只有值接收器方法才在方法集中。现在听上去有点绕,但讨论接口时我们还会回看这一概念。

最后一点,不要为Go结构体编写getter和setter方法,除非是实现接口需要(我们会在接口快速教程一节中讲到接口)。Go语言鼓励直接访问字段。把方法留给业务逻辑。在想要通过单次操作更新多个字段或更新不是直接赋新值的情况例外。前面定义的Increment方法演示了这两种情况。

为nil实例编写方法

刚刚讲到了指针实例,读者可能会想如果对nil实例调用方法会出现什么情况。对于大部分编程语言,这都会导致报错。(Objective-C允许对nil实例调用方法,但什么也不会做。)

Go则有些不一样。它会尝试调用这一方法。如果方法带值接收器,会panic(我们在错误处理一章的panic和recover中会讨论),原因是该指针没有指向的值。但如查方法带的是指针接收器,如果编写了方法处理nil实例则可正常执行。

在部分情况下,nil接收器会让代码简化。下面是二叉树的一个实现,使用了nil作为接收器:

type IntTree struct {
    val         int
    left, right *IntTree
}

func (it *IntTree) Insert(val int) *IntTree {
    if it == nil {
        return &IntTree{val: val}
    }
    if val < it.val {
        it.left = it.left.Insert(val)
    } else if val > it.val {
        it.right = it.right.Insert(val)
    }
    return it
}

func (it *IntTree) Contains(val int) bool {
    switch {
    case it == nil:
        return false
    case val < it.val:
        return it.left.Contains(val)
    case val > it.val:
        return it.right.Contains(val)
    default:
        return true
    }
}

注:Contains方法不修改*IntTree,但使用指针接收品进行了声明。这是为了演示前面所讲的对nil接收的支持。带值接收器的方法无法检测nil,在出现了nil接收器时会像前面说的那样panic。

下面是使用这个二叉树的代码。可在The Go Playground中执行:

func main() {
    var it *IntTree
    it = it.Insert(5)
    it = it.Insert(3)
    it = it.Insert(10)
    it = it.Insert(2)
    fmt.Println(it.Contains(2))  // true
    fmt.Println(it.Contains(12)) // false
}

Go支持对nil接收器调用方法是非常聪明的做法,在某些场景也非常有用,比如二叉树节点的示例。但大部分时候用处没有那么大。指针接收器类似于函数指针参数,是传入方法中的指针拷贝。就像传入函数中的nil参数,如果修改了指针副本,并不会改变原始值。也就是说不能编写指针接收器方法处理nil让原始指针变为非nil。如方法带指针接收器又不支持nil,请进行nil检测并返回错误(在错误处理一章讨论错误)。

方法也是函数

Go语言中的方法和函数很像,可以在有函数类型变量或参数时随时可用方法替换。

下面看一个简单示例:

type Adder struct {
    start int
}

func (a Adder) AddTo(val int) int {
    return a.start + val
}

我们按通常的方式创建一个该类型的实例并调用其方法:

myAdder := Adder{start: 10}
fmt.Println(myAdder.AddTo(5)) // prints 15

也可以将方法赋给变量或传给func(int)int类型的参数。这称为方法值(method value):

f1 := myAdder.AddTo
fmt.Println(f1(10))           // prints 20

方法值和闭包有点像,因其可访问所创建实例中的字段值。

可通过该类型本身创建一个函数。这称为方法表达式:

f2 := Adder.AddTo
fmt.Println(f2(myAdder, 15))  // prints 25

在方法表达式中第一个参数是方法的接收器,函数签名为func(Adder, int) int

方法值和方法表达式不是对特殊情况的灵光乍现。我们会在隐式接口让依赖注入更简单一节中学习到如何依赖注入时使用它们。

函数 vs. 方法

因为可以将方法用作函数,读者可能会想什么时候用方法、什么时候用函数。

区别在于函数是否依赖于其它数据。我们已多次提到,包级状态应是不可变的。在逻辑中出现值是在启用时配置并在执行过程中发生改变时,这些值应放到结构体中,这段逻辑应使用方法进行实现。如果逻辑只依赖于入参,则应使用函数。

类型、包、模块、测试和依赖注入是相互关联的概念。本章稍后会讲到依赖注入。有关包和模块参见模块、包和导入一章,测试参见编写测试一章。

类型声明不是继承

除了根据内置Go类型和结构体字面量声明类型外,也可以根据另一个自定义类型声明用户自定义类型:

type HighScore Score
type Employee Person

很多概念会被看成是面向对象,尤其是继承。这时父类型中声明的方法和状态可在子类型中使用,也可使用子类型的值来替换父类型。(那些计算机科学家的读者,我知道子类型不是继承。但大部分编程语言使用继承来实现子类型,所以在日常使用中经常会混为一谈。)

根据另一种类型声明类型看起来像是继承,但并不是。这两种类型有共同的底层类型,但仅此而已。这些类型没有等级之分。在具有继承的语言中,子实例可用在任何使用父级实例的地方。但在Go语言中并不是这样。不能将HighScore类型的实例赋给Score类型的变量,反过来也是,除非进行类型转换,也不能在没有做类型转换的情况下将它们赋值给int类型的变量。此外,为Score所定义的方法并没在HighScore上进行定义:

// 使用无类型常量赋值没有问题
var i int = 300
var s Score = 100
var hs HighScore = 200
hs = s                  // compilation error!
s = i                   // compilation error!
s = Score(i)            // ok
hs = HighScore(s)       // ok

对于底层类型为内置类型的自定义类型,可以使用这些内置类型的运算符。上例中可以看到,可对它们赋与底层类型兼容的字面量以及常量。

小贴士:对底层类型相同的类型做类型转化会保留同样的底层存储,但关联的方法不同。

类型是可执行文档

虽然都清楚应声明结构体类型来存储一组关联数据,但何时声明基于内置类型或其它自定义类型的自定义类型就不那么清楚了。简单的回答是类型即文档。通过为一个概念提供名称并描述所需数据类型让代码更清晰。对方法传入Percentage类型的参数会比int类型让读代码的人更清楚用途,这样在调用时就不太可能传入无效值。

这一逻辑同样适用基于另一个自定义类型声明新的自定义类型。在底层数据相同,但所执行的操作不同时,使用两种类型。基于另一个进行声明会避免重复并且也会让人清楚这两种类型是相关联的。

ioto(有时)用于枚举

很多编程语言都有枚举的概念,可用于指定具有一组有限值的类型。Go语言没有枚举类型。它有一个iota,可用于对一组常量赋递增的值。

注:iota的概念来自于APL语言(A Programming Language的简写)。APL严重依赖于自己的标记法,因此要求电脑使用特制键盘。比如(~R∊R∘.×R)/R←1↓ιR是一段APL程序,用于查找变量R的值之内的质数。

对于Go这种关注可读性的语言,从一种将极简发挥到病态的语言中借用概念可能看起来很讽刺,但这正是我们应该学习不同编程语言的原因:灵感无处不在。

在使用iota时,最佳实践是基于int定义一个用于表示所有有效值的类型:

type MailCategory int

接着使用const代码块来定义一组该类型的值:

const (
    Uncategorized MailCategory = iota
    Personal
    Spam
    Social
    Advertisements
)

const代码块中的第一个常量指定了类型并将值设置为iota。随后的各行既没有指定类型也没有赋值。Go编译器遇到这种情况时,会为随后的 所有变量赋值,每一行中对iota做递增。也就是对第一个常量(Uncategorized)赋值0,第二个常量(Personal)赋值1,以此类推。在新的const代码中,iota又重新设置为0.

下面是作者见过的有关iota最好的建议:

不要使用iota定义(在各处)显式定义了值的常量。例如,来实现某部分规格而其中又明确哪个常量的值为多少时,应当显式地写下常量值。仅将iota用作“内部”使用。换句话说,通过名称而不是值进行引用的常量。这样可以享受在任何时间或列表的任意位置插入新变量的好处,而又不会产生任何风险。

Danny van Heumen

需要知道Go不会阻止你(或其他人)为自定义类型创建其它值。此外如果在字面量列表中间插入新的标识符,所有后续的都会重新编号。如这些常量表示其它系统或数据库中的值时可能会让程序出现不易察觉的问题。由于存在这两个限制,基于iota的枚举仅在希望区分一组值而不在意背后的值时才有意义。如果实际的值很重要,则应显式定义。

警告:因可对常量赋字面量表达式,你可能会看到如下这种建议使用iota的示例代码:

type BitField int

const (
    Field1 BitField = 1 << iota // assigned 1
    Field2                      // assigned 2
    Field3                      // assigned 4
    Field4                      // assigned 8
)

虽然很聪明,但在使用这种模式时要小心。如果这么做,应写好注释。前面提到过,如果在意值的话使用iota可能会易错。你一定不希望未来的维护者在列表中间插入一个常量,导致代码崩溃。

注意iota是从0开始。如果使用一组常量表示不同一配置状态,零值可能会有用。在前面的MailCategory类型中就是这样。邮件到达时为未分类,因此零值正好适用。如果对于常量没有能自圆其说的默认值,通常的做法是将常量代码块中的第一个iota赋值给_,它表示值无效。这样更容易发现未正常初始化的变量。

使用内嵌实现组合

软件工程关于“组合优于继承”的建议可追溯到1994年的由Gamma、Helm、Johnson和Vlissides所著《设计模式》一书(艾迪生-韦斯利出版社),他们还有一个响当当的名号Gang of Four(或 GoF)。Go语言中没有继承,鼓励通过内置的组合和改进实现代码复用:

type Employee struct {
    Name         string
    ID           string
}

func (e Employee) Description() string {
    return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}

type Manager struct {
    Employee
    Reports []Employee
}

func (m Manager) FindNewEmployees() []Employee {
    // do business logic
}

注意Manager中包含一个Employee类型字段,但没有给该字段命名。这样Employee就成为了内嵌字段。内嵌字段中声明的字段或方法在包含它的结构体中可直接进行调用。这样下面就是合法代码;

m := Manager{
    Employee: Employee{
        Name:         "Bob Bobson",
        ID:             "12345",
    },
    Reports: []Employee{},
}
fmt.Println(m.ID)            // prints 12345
fmt.Println(m.Description()) // prints Bob Bobson (12345)

注:在结构体中不止可以内嵌结构体,还可以嵌套其它任意类型。这样会将嵌套类型的方法上发给外层结构体。

如果外层结构体中有同名字段或方法,需要使用嵌套字段类型来调用被隐藏的方法。比如有如下的类型:

type Inner struct {
    X int
}

type Outer struct {
    Inner
    X int
}

只能显式地指定Inner才能访问Inner内的X

o := Outer{
    Inner: Inner{
        X: 10,
    },
    X: 20,
}
fmt.Println(o.X)       // prints 20
fmt.Println(o.Inner.X) // prints 10

嵌套不是继承

编程语言中内置嵌套是很罕见的(作者并不了解有支持它的知名语言)。很多熟知继承(在很多语言中都支持)的开发者会按照继承来理解嵌套。这背后是坑。并不能将Manager类型的变量赋值给Employee类型的变量。如果要访问Manager中的Employee字段,必须显式指定。可在The Go Playground中运行如下代码:

var eFail Employee = m        // compilation error!
var eOK Employee = m.Employee // ok!

得到的错误如下:

cannot use m (type Manager) as type Employee in assignment

此外,Go对具象类型没有动态调度(dynamic dispatch)的支持。嵌套字段的方法并不知道它是嵌套的。如果嵌套字段方法调用了该字段的另一个方法,而恰巧外层结构体具有同名方法,嵌套字段的方法并不会调用外层结构中的方法。我们在如下代码中进行了演示,请在The Go Playground中运行:

type Inner struct {
    A int
}

func (i Inner) IntPrinter(val int) string {
    return fmt.Sprintf("Inner: %d", val)
}

func (i Inner) Double() string {
    return i.IntPrinter(i.A * 2)
}

type Outer struct {
    Inner
    S string
}

func (o Outer) IntPrinter(val int) string {
    return fmt.Sprintf("Outer: %d", val)
}

func main() {
    o := Outer{
        Inner: Inner{
            A: 10,
        },
        S: "Hello",
    }
    fmt.Println(o.Double())
}

运行以上代码的输出如下:

Inner: 20

虽然在具象类型中嵌套另一种具象类型不能将外层类型当成内层类型处理,嵌套字段方法却能成为外层结构体的方法集。这样外层结构体就可以实现接口了。
本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

接口快速教程

虽然Go并发(在并发一章讲解)是聚光灯下的宠儿,便Go设计中真正的明星是其隐式接口,也是Go中唯一的抽象类型。下面就来学习它的伟大之处。

我们先快速学习如何声明接口。接口的内在很简单。和其它自定义类型一样,可以使用type关键字进行定义。

以下是fmt包中Stringer接口的定义:

type Stringer interface {
    String() string
}

在接口声明中,接口字面量(interface)位于接口类型之后。其中包含具象类型实现接口所必须定义的方法。接口中定义的方法称为接口的方法集。

和其它类型一样,接口可在任意作用域中声明。

接口通常以er结尾。像刚刚看到的fmt.Stringer,但还有很多,比如io.Readerio.Closerio.ReadCloserjson.Marshalerhttp.Handler

接口是类型安全的鸭子类型

至此的内容和其它语言中的接口还没有什么分别。Go接口的特别之处在于其隐式实现。具象类型不声明它实现了某个接口。如果具象类型的方法集包含了某个接口方法集中的所有方法,则具象类型实现了该接口。也就是说具象类型可以赋值给声明为接口类型的变量或字段。

这一隐式行为将接口变为Go语言中最有魅力的类型,因为它同时保障了类型安全和解耦,桥接了静态语言和动态语言的功能。

要理解背后的原因,就要先讨论编程语言中为什么有接口。前面提到《设计模式》教育开发者组合优于继承。该书中的另一条建议是“面向接口而非实现。”这样做就依赖于行为而不是实现,在需要时可以切换实现。代码可以不段演进,因为需求总是在变。

Python、Ruby和JavaScript这样的动态语言没有接口。这些语言的开发者使用“鸭子类型”,这基于一个表达“如果像鸭子一样走也像鸭子一样叫,那么就是鸭子。”概念就是只函数能找到调用方法就可以对其使用某一类型的实例传参:

class Logic:
    def process(self, data):
        # business logic
    
def program(logic):
    # get data from somewhere
    logic.process(data)
    
logicToUse = Logic()
program(logicToUse)

鸭子类型一开始听起来可能很怪,但它已成功用于构建大型系统了。如果你使用静态类型语言编程,这听起来简直离经叛道。不指定显式类型,很难知道会有什么样的功能。因为新开发者接收项目或是老开发忘了代码的功能,就要查代码确定真正的依赖。

Java开发者使用另一种模式。他们定义接口,创建该接口的实现,但仅在客户端代码中引用接口:

public interface Logic {
    String process(String data);
}

public class LogicImpl implements Logic {
    public String process(String data) {
        // business logic
    }
}

public class Client {
    private final Logic logic;
    // this type is the interface, not the implementation

    public Client(Logic logic) {
        this.logic = logic;
    }

    public void program() {
        // get data from somewhere
        this.logic.process(data);
    }
}

public static void main(String[] args) {
    Logic logic = new LogicImpl();
    Client client = new Client(logic);
    client.program();
}

动态语言开发者看到Java中的显式接口会纳闷在有显式依赖时如何在未来重构代码。切换到不同逻辑的新实现意味着要按新的接口来重写代码。

Go语言的开发者觉得两种都没有错。如果应用要不断增长和变化,需要有灵活性来改变实现。但为了让人们理解代码的功能(因为会有新人来维护同样的代码),需要指明代码的依赖。这便出现了隐式接口。Go是上述两种方式的合体:

type LogicProvider struct {}

func (lp LogicProvider) Process(data string) string {
    // business logic
}

type Logic interface {
    Process(data string) string
}

type Client struct{
    L Logic
}

func(c Client) Program() {
    // get data from somewhere
    c.L.Process(data)
}

main() {
    c := Client{
        L: LogicProvider{},
    }
    c.Program()
}

在这段Go代码中,有一个接口,但仅有调用者(Client)知道,LogicProvider中没有进行任何声明来表示它实现了该接口。这足以为未来新逻辑的编写者提供可执行文档来确保传入client的类型符合其要求。

小贴士:接口指定了调用者之需。客户端代码定义接口指定其所需的功能。

这不是说接口无法共享。我们已经看到在标准库中有多个接口用于输入和输出。标准接口很强大,如果编写代码使用io.Readerio.Writer,不论是写入本地磁盘的文件还是在内存中写值都可正常操作。

此外,使用标准接口鼓励采用装饰器模式。Go中随处可见接收接口实例并返回实现相同接口其它类型的工厂函数。例如,定义了如下的函数:

func process(r io.Reader) error

可以使用如下代码处理文件中的数据:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
return process(r)

os.Open返回的os.File实例符合io.Reader接口,可在任何代码中用于读取数据。如果为gzip压缩文件,可以将io.Reader封装在另一个io.Reader中:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
gz, err = gzip.NewReader(r)
if err != nil {
    return err
}
defer gz.Close()
return process(gz)

这时读取非压缩文件同样的代码可用于读取压缩文件。

小贴士:如果标准库中的接口描述的是你代码所需的,那么就使用它!

实现了某个接口的类型完全可以指定不属于接口的其它方法。一组客户端代码可能用不到它们,但另一组可能就需要了。例如,io.File类型同时实现了io.Writer接口。如果你的代码只需要读取文件,使用io.Reader接口来引用文件实例,忽略其它方法。

嵌套和接口

就像可以在结构体中嵌套类型一样,也可以在接口中嵌套接口。例如,io.ReadCloser接口由io.Readerio.Closer组成:

type Reader interface {
        Read(p []byte) (n int, err error)
}

type Closer interface {
        Close() error
}

type ReadCloser interface {
        Reader
        Closer
}

注:就像我们可以在结构体中嵌套具象类型一样,也可以在结构体中嵌套接口。我们在编写测试一章的Go语言的Stub一节会看到用法。

接收接口,返回结构体

经常会听到Go语言老开发说代码应该“接收接口,返回结构体。”意思是函数所调用的业务逻辑应由接口触发,但函数的输出应为具象类型。多们已经讲解过为什么函数应接收接口:接口会让代码更灵活并显式声明具体要使用的功能。

如果创建一个返回接口的API,就会浪费掉隐式接口的一个主要优势:解耦。应当限制客户端代码所依赖的第三方接口,因为代码会永久依赖包含这些接口的模块,以及该模块的所有依赖。(在模块、包和导入一章中会讲解模块和依赖。)这会限制未来的灵活性。为避免出现耦合,需要编写另一个接口并通过类型转换将一个接口转换为另一个。依赖具象类型会产生依赖,而在应用层中使用依赖注入层又会影响效果。我们会在隐式接口让依赖注入更简单一节中进一步讨论依赖注入。

另一个避免返回接口的原因是版本化。如果返回了具象类型,可添加新方法和新字段,已有代码可正常运行。但对接口情况就不一样了。对接口添加方法意味着需要更新该接口的所有实现,否则代码会崩溃。如果API向后不兼容,应增加主版本号。

不要写根据入参返回接口背后不同实例的单个工厂函数,而尝试为每个具象类型编写单独的工厂函数。在某些场景下(比如可返回一种或多种类型词法单元的解析器),不可避免地要返回接口。

错误就是一种例外。我们在错误处理一章中会学到,Go的方法和函数声明error接口类型的返回参数。针对error,很可能返回接口的不同实现,因此需要使用接口处理所有可能的选项,因为接口是Go语言中唯一的抽象类型。

这一模式有一个潜在的不足。在指针一章的降低垃圾回收器的工作量一节中讨论过,减少堆内存分配会通过减少垃圾回收器的工作量而提升性能。返回结构体避免堆内存的分配,因此是好事。但在调用带接口类型参数的函数时,每个接口参数都会发生堆内存分配。权衡抽象更重要还是性能更重要可能会常伴你的编程生涯。如果程序太慢且进做过性能测试,又发现性能问题是由于接口参数所导致的堆内存分配,那么应重写为使用具象类型参数的函数。如果对函数传入了同一接口的多种实现,这表示在创建带有重复逻辑的多个函数。

接口和nil

指针一章中讨论过指针,我们还讨论过指针类型的零值nil。我们还使用nil来表示接口类型的零值,这并不简单因为它用于具象类型。

为使用接口为nil,其类型和值都必须为nil。以下代码前两行打印true,最后一行打倒false

var s *string
fmt.Println(s == nil) // prints true
var i interface{}
fmt.Println(i == nil) // prints true
i = s
fmt.Println(i == nil) // prints false

读者可在The Go Playground中自行运行。

在Go运行时中,接口通过指针对实现,一个为其底层类型,另一个为底层值。只要类型为非nil,那么接口也不是nil。(因变量不能没类型,如果值指针非nil,类型指针要一定是非nil。)

对于接口nil表明我们是否可调用其方法。前面讲到可对nil具象实例调用方法,貌似在对接口变量赋nil具象实例时可以调用其方法。如果接口为nil,调用其任意方法会panic(我们在错误处理一章的panic和recover中会讨论)。如果接口非nil,则可调用其方法。(但注意如果值为nil,而所赋类型的方法无法正确处理nil,仍会panic。)

因非nil类型的接口实例不等于nil,很容易在类型不是nil时知道接口关联值是否为nil。必须要使用反射才能知道(在使用反射检查接口值是否为nil一节中讨论)。

空接口无信息量

有时在静态类型语言中,需要有方式说明变量存储任意类型的值。Go使用interface{}来进行表示:

var i interface{}
i = 20
i = "hello"
i = struct {
    FirstName string
    LastName string
} {"Fred", "Fredson"}

应该注意interface{}不是特例语法。空接口类型只是说明变量可以存储实现了零个或多个方法类型的值。只是它能匹配Go中的所有类型。因为空接口并没有所表示值的任何信息,并不能做些什么。空接口的一个常见用途是作为从外部数据源读取的模式不定的数据的占位符,比如说JSON文件:

// 一对花括号表示interface{}类型,另一个是实例化map实例
data := map[string]interface{}{}
contents, err := ioutil.ReadFile("testdata/sample.json")
if err != nil {
    return err
}
defer contents.Close()
json.Unmarshal(contents, &data)
// 内容现在就放到data中了

interface{}的另一个用法是作为存储用户创建数据结构中值的一种方式。这是因为当前缺乏用户定义的泛型(1.18版本中已添加泛型)。如查需要切片、数组或map之外的数据结构,而又不希望只支持一种类型,需要使用类型为interface{}的字段来存储其值。可以尝试在The Go Playground中运行如下代码:

type LinkedList struct {
    Value interface{}
    Next    *LinkedList
}

func (ll *LinkedList) Insert(pos int, val interface{}) *LinkedList {
    if ll == nil || pos == 0 {
        return &LinkedList{
            Value: val,
            Next:    ll,
        }
    }
    ll.Next = ll.Next.Insert(pos-1, val)
    return ll
}

警告:对于链表插入而言这不是一种高效的实现,但对以学习足够了。不要在真实代码中使用它。

如果看到函数接收空接口,很可是使用了反射(在恶龙三剑客:反射、Unsafe 和 Cgo一章中讨论)来输入或读者数据。有上面的例子中,json.Unmarshal函数的第二个参数声明为interface{}类型。

这些场景应该相对少见。避免使用interface{}。我们看到,Go设计为强类型语言,而尝试绕过这一点是不纯正的做法。

如果发现需要将值存入空接口,可能会想如何读回该值。这时需要使用类型断言和类型判断 。

类型断言和类型判断

Go提供了两种方式查看接口类型变量是否有具体的具象类型或具象类型是否实现了其它接口。我们先来学习类型断言。类型断言说明某具象类型是否实现了该接口,或是接口具象类型是否也实现了另一个接口。可在The Go Playground:中运行如下代码:

type MyInt int

func main() {
    var i interface{}
    var mine MyInt = 20
    i = mine
    i2 := i.(MyInt)
    fmt.Println(i2 + 1)
}

以上代码中,变量i2的类型为MyInt

你可能会想如果类型断言出错会发生什么。那样代码会panic。可在The Go Playground中运行如下代码:

i2 := i.(string)
fmt.Println(i2)

运行上述代码会产生以下的panic:

panic: interface conversion: interface {} is main.MyInt, not string

可以看到Go对于具象类型还是很谨慎的。即使两种炮灰攻的春天的底层类型一致,类型断言也必须匹配底层值的类型。下面的代码会panic。可在The Go Playground中运行如下代码:

i2 := i.(int)
fmt.Println(i2 + 1)

显然崩溃非我们之所欲。应当使用逗号ok语法来进行避免,在逗号ok语句一节中我们在检测字典中是否为零值是使用过:

i2, ok := i.(int)
if !ok {
    return fmt.Errorf("unexpected type for %v",i)
}
fmt.Println(i2 + 1)

如果类型转换成功布尔值ok设为true。而如果失败,ok会设为false,另一个变量(本例中为i2)设为零值。然后在if语句中处理预期外的条件,但在纯正的Go语言中,我们对错误处理代码进行缩进。我们会在错误处理一章中讨论。

注:类型断言与类型转换不同。类型转换可用于具象类型和接口,在编译时进行检查。类型断言只能用于接口类型,在运行时检查。因其在运行时检查,可能会出现失败。转换修改类型,断言揭示问题。

即使是绝对确定类型断言有效,也请使用逗号ok语句。我们无法预知其他人(或是半年后的你)会如何复用这段代码。迟早未验证的类型断言会在运行时出错。

在接口可能为多种类型之一时,使用类型判断:

func doThings(i interface{}) {
    switch j := i.(type) {
    case nil:
        // i为nil,j的类型为interface{}
    case int:
        // j的类型为int
    case MyInt:
        // j的类型为MyInt
    case io.Reader:
        // j的类型为io.Reader
    case string:
        // j是string
    case bool, rune:
        // i为bool或rune类型,因此j的类型为interface{}
    default:
        // 不知道i是什么类型,因此j的类型为interface{}
    }
}

类型判断和switch语句很像,我们在代码块,遮蔽和控制结构一章中学习过。取代指定布尔运算,我们指定一个接口类型的变量在其后接.(type)。通常将待检测变量赋给另一个仅在switch中有效的变量。

注:因类型判断的目的是从已有变量获取新变量,将进行判断的变量赋值给同名变量是一种纯正的做法(i := i.(type)),这也是代码遮蔽是好做法的极少的案例之一。为了让注释可读性更强,本例没有使用代码遮蔽。

新变量类型取决于匹配得是哪个分支。可在一个分支中使用nil来查看该接口是否没有关联类型。如果在一个分支中有多个类型,新变量的类型为interface{}。和switch语句一样,可有一个default分支在没有指定类型时进行匹配。否则新变量为匹配分支的类型。

小贴士:如果不知道底层类型,需要使用反射。在恶龙三剑客:反射、Unsafe 和 Cgo一章中会讨论反射。

少用类型断言和类型判断

虽然从接口变量中提取具象实现看起来很方便,但应减少使用这种技术。大部分情况,对参数或返回值作所提供类型对待,而不是其它类型。不然函数的API无法精确声明其执行任务所需的类型。如果需要另一种类型,则应进行指定。

话虽这么说,但类型断言和类型判断在有些场景下是非常有用的。类型断言的一个常见用途是查看接口后的具象类型是否实现了另一个接口。这允许我们指定可选接口。例如,标准库使用了这一技术来在调用io.Copy函数时做更高效的拷贝。这个函数有两个类型分别为io.Writerio.Reader的类型,调用io.copyBuffer函数来完成工作。如果io.Writer参数还实现了io.WriterTo,或是io.Reader参数还实现了io.ReaderFrom,函数中大部分的工作都可以略过:

// copyBuffer is the actual implementation of Copy and CopyBuffer.
// if buf is nil, one is allocated.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rt, ok := dst.(ReaderFrom); ok {
        return rt.ReadFrom(src)
    }
    // function continues...
}

另一个可选接口的用处是演进API。在上下文一章中我们会讨论上下文。上下文是传递给函数的参数,可用于管理取消等标准方式。这在Go 1.7中添加,也就是老代码不支持。包括旧的数据库驱动。

在Go 1.8中,在database/sql/driver包中定义了与已有接口上下文感知相似的内容。例如,StmtExecContext定义了一个名为ExecContext的方法,它是StmtExec方法具有上下文感知的替代。在将Stmt的实现传入标准库数据库代码时,它检查其是否实现了StmtExecContext。如果实现了则调用ExecContext。若未实现,Go标准库提供了新代码提供的取消支持的备用实现:

func ctxDriverStmtExec(ctx context.Context, si driver.Stmt,
                       nvdargs []driver.NamedValue) (driver.Result, error) {
    if siCtx, is := si.(driver.StmtExecContext); is {
        return siCtx.ExecContext(ctx, nvdargs)
    }
    // fallback code is here
}

可选接口技术有一个不足。我们前面学过接口实现使用装饰器模式封装相同接口行为层其它实现是常见行为。问题是如果可选接口由其中一个封装实现进行实现,就无法通过类型断言或类型判断进行检测了。例如,标准库有bufio包提供带缓冲读取。可以通过将其传递给bufio.NewReader函数来缓冲其它的io.Reader实现,并使用返回的*bufio.Reader。如果传入的io.Reader还实现了io.ReaderFrom,将其封装到带缓冲读取接口则会截断优化。

在错误处理中也存在这种情况。在前面讲到,它们实现了error接口。错误可通过封装其它错误来包含额外的信息。类型判断或类型断言无法检测或匹配封装的错误。如果希望在处理返回错误的不同具体实现时有不同的行为,使用errors.Iserrors.As来测试或访问封装的错误。

类型判断语句提供了区分接口要求有不同处理的各实现的能力。只对接口所能提供某些有效类型最为有用。确保在处理开发时尚不知道的实现时对switch添加一个default分支。这会防止我们在添加新的接口实现时忘记更新switch语句:

func walkTree(t *treeNode) (int, error) {
    switch val := t.val.(type) {
    case nil:
        return 0, errors.New("invalid expression")
    case number:
        // we know that t.val is of type number, so return the
        // int value
        return int(val), nil
    case operator:
        // we know that t.val is of type operator, so
        // find the values of the left and right children, then
        // call the process() method on operator to return the
        // result of processing their values.
        left, err := walkTree(t.lchild)
        if err != nil {
            return 0, err
        }
        right, err := walkTree(t.rchild)
        if err != nil {
            return 0, err
        }
        return val.process(left, right), nil
    default:
        // if a new treeVal type is defined, but walkTree wasn't updated
        // to process it, this detects it
        return 0, errors.New("unknown node type")
    }
}

可在The Go Playground中查看完整的实现。

注:可以进一步保护自己不出现意外的接口实现,通过不导出接口以及至少不导出其中一个方法,如果导出接口,就可以在另一个包的结构体中嵌套它,让结构体实现该接口。我们会在中模块、包和导入一章中讨论包及标识符导出。

函数类型是接口的桥梁

关于类型声明还差一件事没有讨论。很容易陷入对整型或字符串添加方法,但Go对任意自定义类型添加方法,包括自定义的函数类型。这听起来像是学术上的钻牛角尖,但实际上却是非常有用的。这会允许函数实现接口。最常见的用法是HTTP处理器。HTTP handler用于处理HTTP服务请求。由接口定义:

type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

通过将类型转换为http.HandlerFunc,任何签名为func(http.ResponseWriter,*http.Request)的函数都可以用作http.Handler

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

这样可以使用函数、方法或闭包实现HTTP处理器,用完全相同的代码路径作为符合http.Handler接口的其它类型。

Go中的函数是一级概念,因此传入为函数的参数。同时,Go鼓励使用小接口,仅一个方法的接口可轻易替换函数类型的参数。问题是:什么时候使用或方法指定函数类型的入参,什么时候使用接口呢?

如果一个函数可能依赖入参中未指定的其它的函数或状态,使用接口参数、定义函数类型来成为函数到接口的桥梁。这正是http包中的做法,很可能Handler只是需要配置的外加工调用的入口。但如果它是一个简单函数(类似sort.Slice所用的那个),那么函数类型的参数是个好选择。

隐式接口让依赖注入更简单

只要做过编程,不论老手还是新手都可以很快知道应用需要随时间发生变化。一种用于让解耦变轻松的技术称为依赖注入。依赖注入的概念是代码应显式指定其需执行任务的功能。这比想象中要更古早,1996年Robert Martin写了一篇名为依赖反转原理的文章。

Go显式接口一个出人意料的好处是它让依赖注入成为解耦代码的优秀方式。虽然其它语言的开发者经常使用大型、复杂的框架来注入依赖,事实是Go不需要其它库就可以轻松实现依赖注入。我们通过简单示例来了解如何使用隐式接口借助依赖注入编写应用。

为更好理解这一概念并学习如何在Go中实现依赖注入,我们构建一个简单的web应用。(我们会在标准库一章中讲解Go内置的HTTP服务器,这里可以当成是预览。)先来编写一个工具函数,日志工具:

func LogOutput(message string) {
    fmt.Println(message)
}

应用还需要数据存储。我们来创建一个简单版本:

type SimpleDataStore struct {
    userData map[string]string
}

func (sds SimpleDataStore) UserNameForID(userID string) (string, bool) {
    name, ok := sds.userData[userID]
    return name, ok
}

再定义一个工厂函数来创建一个SimpleDataStore实例:

func NewSimpleDataStore() SimpleDataStore {
    return SimpleDataStore{
        userData: map[string]string{
            "1": "Fred",
            "2": "Mary",
            "3": "Pat",
        },
    }
}

接收来我们会编写一些业务逻辑查找用户并进行问候和道别。我们的业务逻辑需要用到一些数据,因此需要有数据存储。我们还想要业务逻辑记录何时调用,因此需要日志工具。但是我们不想强制它依赖于LogOutputSimpleDataStore,因为我们未来可能会使用其它日志工具或数据存储。业务逻辑需要的正是描述其依赖的接口:

type DataStore interface {
    UserNameForID(userID string) (string, bool)
}

type Logger interface {
    Log(message string)
}

为让LogOutput函数符合接口,我们定义一个函数类型并添加方法:

type LoggerAdapter func(message string)

func (lg LoggerAdapter) Log(message string) {
    lg(message)
}

非常巧的是,我们的LoggerAdapterSimpleDataStore刚好符合业务逻辑所需要的接口,但两种类型都不知道它的功能。

现在依赖已定义好我们来看业务逻辑的实现:

type SimpleLogic struct {
    l  Logger
    ds DataStore
}

func (sl SimpleLogic) SayHello(userID string) (string, error) {
    sl.l.Log("in SayHello for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Hello, " + name, nil
}

func (sl SimpleLogic) SayGoodbye(userID string) (string, error) {
    sl.l.Log("in SayGoodbye for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Goodbye, " + name, nil
}

我们的结构体有两个字段,一个是Logger,另一个是DataStoreSimpleLogic中没有具象类型,因为它们没有依赖。稍后切换为其它提供者的新实现不会有问题,因为提供者与接口无关。这与Java这样的显式接口完全不同。虽然Java使用接口来解耦对其的实现,显式接口同时绑定客户端和服务提供者。这会让在Java(以及其它带显式接口的编程语言)中替换依赖远比在Go中要困难。

在我们需要SimpleLogic实例时,会调用工厂函数,传入接口、返回结构体:

func NewSimpleLogic(l Logger, ds DataStore) SimpleLogic {
    return SimpleLogic{
        l:    l,
        ds: ds,
    }
}

注:SimpleLogic中的字段未导出。也就是说仅可用由SimpleLogic相同包的代码访问。我们无法强制Go中的不可变性,但限制哪段代码可访问这些字段使得不太可能出现意外修改。我们会模块、包和导入一章中导入或非导入标识符。

现在到API了。我们只有一个端点/hello,对提供了ID的用户进行问候。(在真实应用中请不要使用查询参数进行身份认证,这里只是一个快速示例)。我们的控制器需要问候的业务逻辑,因此这样定义接口:

type Logic interface {
    SayHello(userID string) (string, error)
}

SimpleLogic结构上已有这个方法,但具象类型是感知不到该接口的。此外,SimpleLogic的其它方法SayGoodbye没在接口中,因为控制器用不到它。接口由客户端代码持有,因此方法集按照客户端代码需求自定义:

type Controller struct {
    l     Logger
    logic Logic
}

func (c Controller) SayHello(w http.ResponseWriter, r *http.Request) {
    c.l.Log("In SayHello")
    userID := r.URL.Query().Get("user_id")
    message, err := c.logic.SayHello(userID)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(err.Error()))
        return
    }
    w.Write([]byte(message))
}

和其它类型的工厂方法一样,我们来编写Controller

func NewController(l Logger, logic Logic) Controller {
    return Controller{
        l:     l,
        logic: logic,
    }
}

同样是接收接口返回结构体。

最后在main函数中拼装这些组件,启动服务端:

func main() {
    l := LoggerAdapter(LogOutput)
    ds := NewSimpleDataStore()
    logic := NewSimpleLogic(l, ds)
    c := NewController(l, logic)
    http.HandleFunc("/hello", c.SayHello)
    http.ListenAndServe(":8080", nil)
}

main函数是这里唯一知道所有具象类型是哪些的。如果希望切换为不同的实现,只需在这里修改。通过依赖注入外部分依赖表示我们限制了演进代码时所需做的修改。

依赖注入也是让测试变简单的伟大模式。这一点不奇怪,因为测试需要复用其它环境中的代码,这些环境中输入和输出都受验证功能约束。例如,我们可以通过注入捕获日志输出的类型并实现Logger接口来验证日志输出。在编写测试一章中会进一步讨论。

注:http.HandleFunc("/hello", c.SayHello)演示前面所说的两个部分。

首先,我们把SayHello方法看成函数。

其次,http.HandleFunc函数接收函数并将其转换为http.HandlerFunc函数类型,它声明了一个方法来实现http.Handler接口,这是用于表示Go请求处理器的类型。我们从一个类型中取出方法,转换为另一个带此方法的类型。干净利落。

Wire

如果觉得手动编写依赖注入代码工作量太大,可以使用Wire,它是Google编写的依赖注入辅助工具。它使用代码生成自动生成我们在main中所编写的具体类型声明。

Go并不是面向对象(这很好)

我们已经学了Go中类型的纯正用法,可以看到很难将Go归类为某种具体语言类型。很明显它不是严格意义上的过程化语言。同时,Go中没有方法重载、继承或是对象,所以它也不是面向对象语言。Go具有函数类型和闭包,但它也不是函数式语言。如果硬要把Go向这些分类靠,写出的代码会不伦不类。

如果一定要给Go打个标签,那最好的词是实用。它吸收了各处的概念,旨在打造一款简单、易读且可供大团队长期维护的语言。

小结

本章中我们讲解了类型、方法、接口以及它们的最佳实践。下一章中我们会学习使用Go最具争议的特性(错误处理)。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值