标签 C 下的文章

Go语言错误处理

近期闲暇用Go写一个lib,其中涉及到error处理的地方让我琢磨了许久。关于Go错误处理的资料和视频已有许多,Go authors们也在官方Articles和Blog上多次提到过一些Go error handling方面的一些tips和best practice,这里仅仅算是做个收集和小结,尽视野所及,如有不足,欢迎评论中补充。(10月因各种原因,没有耕博,月末来一发,希望未为晚矣 ^_^)

一、概述

Go是一门simple language,常拿出来鼓吹的就是作为gopher习以为傲的仅仅25个关键字^_^。因此Go的错误处理也一如既往的简单。我们知道C语言错误处理以返 回错误码(errno)为主流,目前企业第一语言Java则用try-catch- finally的处理方式来统一应对错误和异常(开发人员常常因分不清楚到底哪些是错误,哪些是异常而滥用该机制)。Go则继承了C,以返回值为错误处理的主要方式(辅以panic与recover应对runtime异常)。但与C不同的是,在Go的惯用法中,返回值不是整型等常用返回值类型,而是用了一个 error(interface类型)。

type interface error {
    Error() string
}

这也体现了Go哲学中的“正交”理念:error context与error类型的分离。无论error context是int、float还是string或是其他,统统用error作为返回值类型即可。

func yourFunction(parametersList) (..., error)
func (Receiver)yourMethod(parametersList) (..., error)

在Andrew Gerrand的“Error handling and Go“一文中,这位Go authors之一明确了error context是由error接口实现者supply的。在Go标准库中,Go提供了两种创建一个实现了error interface的类型的变量实例的方法:errors.New和fmt.Errorf:

errors.New("your first error code")
fmt.Errorf("error value is %d\n", errcode)

这两个方法实际上返回的是同一个实现了error interface的类型实例,这个unexported类型就是errorString。顾名思义,这个error type仅提供了一个string的context!

//$GOROOT/srcerrors/errors.go

type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

这两个方法也基本满足了大部分日常学习和开发中代码中的错误处理需求。

二、惯用法(idiomatic usage)

1、基本用法

就像上面函数或方法定义那样:

func yourFunction(parametersList) (..., error)
func (Receiver)yourMethod(parametersList) (..., error)

通常情况,我们将函数或方法定义中的最后一个返回值类型定义为error。使用该函数或方法时,通过如下方式判断错误码:

..., err := yourFunction(...)
if err != nil {
    //error handling
}

or

if ..., err := yourFunction(...); err != nil {
    //error handling
}

2、注意事项

1)、永远不要忽略(ignore)函数或方法返回的错误码,Check it。(例外:包括标准库在内的Go代码很少去判断fmt.Println or Printf系列函数的返回值)

2)、error的string context中的内容格式:头母小写,结尾不带标点。因为考虑到error被经常这么用:

... err := errors.New("error example")
fmt.Printf("The returned error is %s.\n", err)

3)、error处理流的缩进样式

prefer

..., err := yourFunction(...)
if err != nil {
    // handle error
}

//go on doing something.

rather than:

..., err := yourFunction(...)
if err == nil {
    // do something.
}

// handle error

三、槽点与破解之法

Go自诞生那天起就伴随着巨大争议,这也不奇怪,就像娱乐圈,如果没有争议,哪有存在感,刷脸的机会都没有。看来有争议是件好事,没争议的编程语言都已经成为了历史。炒作懂么!这也是很多Gopher的微博、微信、twitter、medium账号喜欢发“Why I do not like Go”类文章的原因吧^_^。

Go的error处理方式就是被诟病的点之一,反方主要论点就是Go的错误处理机制似乎回到了70年代(与C同龄^_^),使得错误处理代码冗长且重复(部分也是由于前面提到的:不要ignore任何一个错误码),比如一些常见的错误处理代码形式如下:

err := doStuff1()
if err != nil {
    //handle error...
}

err = doStuff2()
if err != nil {
    //handle error...
}

err = doStuff3()
if err != nil {
    //handle error...
}

这里不想去反驳这些论点,Go authors之一的Russ Cox对于这种观点进行过驳斥:当初选择返回值这种错误处理机制而不是try-catch这种机制,主要是考虑前者适用于大型软件,后者更适合小程序。当程序变大,try-catch会让错误处理更加冗长繁琐易出错(具体参见go faq)。不过Russ Cox也承认Go的错误处理机制对于开发人员的确有一定的心智负担。

好了,关于这个槽点的叙述点到为止,我们关心的是“如何破解”!Go的错误处理的确冗长,但使用一些tips,还是可以将代码缩减至可以忍受的范围的,这里列举三种:

1、checkError style

对于一些在error handle时可以选择goroutine exit(注意:如果仅存main goroutine一个goroutine,调用runtime.Goexit会导致program以crash形式退出)或os.Exit的情形,我们可以选择类似常见的checkError方式简化错误处理,例如:

func checkError(err error) {
    if err != nil {
        fmt.Println("Error is ", err)
        os.Exit(-1)
    }
}

func foo() {
    err := doStuff1()
    checkError(err)

    err = doStuff2()
    checkError(err)

    err = doStuff3()
    checkError(err)
}

这种方式有些类似于C中用宏(macro)简化错误处理过程代码,只是由于Go不支持宏,使得这种方式的应用范围有限。

2、聚合error handle functions

有些时候,我们会遇到这样的情况:

err := doStuff1()
if err != nil {
    //handle A
    //handle B
    ... ...
}

err = doStuff2()
if err != nil {
    //handle A
    //handle B
    ... ...
}

err = doStuff3()
if err != nil {
    //handle A
    //handle B
    ... ...
}

在每个错误处理过程,处理过程相似,都是handle A、handle B等,我们可以通过Go提供的defer + 闭包的方式,将handle A、handle B…聚合到一个defer匿名helper function中去:

func handleA() {
    fmt.Println("handle A")
}
func handleB() {
    fmt.Println("handle B")
}

func foo() {
    var err error
    defer func() {
        if err != nil {
            handleA()
            handleB()
        }
    }()

    err = doStuff1()
    if err != nil {
        return
    }

    err = doStuff2()
    if err != nil {
        return
    }

    err = doStuff3()
    if err != nil {
        return
    }
}

3、 将doStuff和error处理绑定

在Rob Pike的”Errors are values”一文中,Rob Pike told us 标准库中使用了一种简化错误处理代码的trick,bufio的Writer就使用了这个trick:

    b := bufio.NewWriter(fd)
    b.Write(p0[a:b])
    b.Write(p1[c:d])
    b.Write(p2[e:f])
    // and so on
    if b.Flush() != nil {
            return b.Flush()
        }
    }

我们看到代码中并没有判断三个b.Write的返回错误值,错误处理放在哪里了呢?我们打开一下$GOROOT/src/

type Writer struct {
    err error
    buf []byte
    n   int
    wr  io.Writer
}

func (b *Writer) Write(p []byte) (nn int, err error) {
    for len(p) > b.Available() && b.err == nil {
        ... ...
    }
    if b.err != nil {
        return nn, b.err
    }
    ......
    return nn, nil
}

我们可以看到,错误处理被绑定在Writer.Write的内部了,Writer定义中有一个err作为一个错误状态值,与Writer的实例绑定在了一起,并且在每次Write入口判断是否为!= nil。一旦!=nil,Write其实什么都没做就return了。

以上三种破解之法,各有各的适用场景,同样你也可以看出各有各的不足,没有普适之法。优化go错误处理之法也不会局限在上述三种情况,肯定会有更多的solution,比如代码生成,比如其他还待发掘。

四、解调用者之惑

前面举的例子对于调用者来讲都是较为简单的情况了。但实际编码中,调用者不仅要面对的是:

if err != nil {
    //handle error
}

还要面对:

if err 是 ErrXXX
    //handle errorXXX

if err 是 ErrYYY
    //handle errorYYY

if err 是ErrZZZ
    //handle errorZZZ

我们分三种情况来说明调用者该如何处理不同类型的error实现:

1、由errors.New或fmt.Errorf返回的错误变量

如果你调用的函数或方法返回的错误变量是调用errors.New或fmt.Errorf而创建的,由于errorString类型是unexported的,因此我们无法通过“相当判定”或type assertion、type switch来区分不同错误变量的值或类型,唯一的方法就是判断err.String()是否与某个错误context string相等,示意代码如下:

func openFile(name string) error {
    if file not exist {
        return errors.New("file does not exist")
    }

    if have no priviledge {
        return errors.New("no priviledge")
    }
    return nil
}

func main() {
    err := openFile("example.go")
    if err.Error() == "file does not exist" {
        // handle "file does not exist" error
        return
    }

    if err.Error() == "no priviledge" {
        // handle "no priviledge" error
        return
    }
}

但这种情况太low了,不建议这么做!一旦遇到类似情况,就要考虑通过下面方法对上述情况进行重构。

2、exported Error变量

打开$GOROOT/src/os/error.go,你会在文件开始处发现如下代码:

var (
    ErrInvalid    = errors.New("invalid argument")
    ErrPermission = errors.New("permission denied")
    ErrExist      = errors.New("file already exists")
    ErrNotExist   = errors.New("file does not exist")
)

这些就是os包export的错误码变量,由于是exported的,我们在调用os包函数返回后判断错误码时可以直接使用等于判定,比如:

err := os.XXX
if err == os.ErrInvalid {
    //handle invalid
}
... ...

也可以使用switch case:

switch err := os.XXX {
    case ErrInvalid:
        //handle invalid
    case ErrPermission:
        //handle no permission
    ... ...
}
... ...

(至于error类型变量与os.ErrInvalid的可比较性可参考go specs

一般对于库的设计和实现者而言,在库的设计时就要考虑好export出哪些错误变量。

3、定义自己的error接口实现类型

如果要提供额外的error context,我们可以定义自己的实现error接口的类型;如果这些类型还是exported的,我们就可以用type assertion or type switch来判断返回的错误码类型并予以对应处理。

比如$GOROOT/src/net/net.go:

type OpError struct {
    Op string
    Net string
    Source Addr
    Addr Addr
    Err error
}

func (e *OpError) Error() string {
    if e == nil {
        return "<nil>"
    }
    s := e.Op
    if e.Net != "" {
        s += " " + e.Net
    }
    if e.Source != nil {
        s += " " + e.Source.String()
    }
    if e.Addr != nil {
        if e.Source != nil {
            s += "->"
        } else {
            s += " "
        }
        s += e.Addr.String()
    }
    s += ": " + e.Err.Error()
    return s
}

net.OpError提供了丰富的error Context,不仅如此,它还实现了除Error以外的其他method,比如:Timeout(实现net.timeout interface) 和Temporary(实现net.temporary interface)。这样我们在处理error时,可通过type assertion或type switch将error转换为*net.OpError,并调用到Timeout或Temporary方法来实现一些特殊的判定。

err := net.XXX
if oe, ok := err.(*OpError); ok {
    if oe.Timeout() {
        //handle timeout...
    }
}

五、坑(s)

每种编程语言都有自己的专属坑(s),Go虽出身名门,但毕竟年轻,坑也不少,在error处理这块也可以列出几个。

1、 Go FAQ:Why is my nil error value not equal to nil?

type MyError string

func (e *MyError) Error() string {
    return string(*e)
}

var ErrBad = MyError("ErrBad")

func bad() bool {
    return false
}

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = &ErrBad
    }
    return p // Will always return a non-nil error.
}

func main() {
    err := returnsError()
    if err != nil {
        fmt.Println("return non-nil error")
        return
    }
    fmt.Println("return nil")
}

上面的输出结果是”return non-nil error”,也就是说returnsError返回后,err != nil。err是一个interface类型变量,其underlying有两部分组成:类型和值。只有这两部分都为nil时,err才为nil。但returnsError返回时将一个值为nil,但类型为*MyError的变量赋值为err,这样err就不为nil。解决方法:

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = &ErrBad
        return p
    }
    return nil
}

2、switch err.(type)的匹配次序

试想一下下面代码的输出结果:

type MyError string

func (e MyError) Error() string {
    return string(e)
}

func Foo() error {
    return MyError("foo error")
}

func main() {
    err := Foo()
    switch e := err.(type) {
    default:
        fmt.Println("default")
    case error:
        fmt.Println("found an error:", e)
    case MyError:
        fmt.Println("found MyError:", e)
    }
    return

}

你可能会以为会输出:”found MyError: foo error”,但实际输出却是:”found an error: foo error”,也就是说e先匹配到了error!如果我们调换一下次序呢:

... ...
func main() {
    err := Foo()
    switch e := err.(type) {
    default:
        fmt.Println("default")
    case MyError:
        fmt.Println("found MyError:", e)
    case error:
        fmt.Println("found an error:", e)
    }
    return
}

这回输出结果变成了:“found MyError: foo error”。

也许你会认为这不全是错误处理的坑,和switch case的匹配顺序有关,但不可否认的是有些人会这么去写代码,一旦这么写,坑就踩到了。因此对于通过switch case来判定error type的情况,将error这个“通用”类型放在后面或去掉。

六、第三方库

如果觉得go内置的错误机制不能很好的满足你的需求,本着“do not reinvent the wheel”的精神,建议使用一些第三方库来满足,比如:juju/errors。这里就不赘述了。

关于Go,你可能不注意的7件事

Go以简洁著称,但简洁中不乏值得玩味的小细节。这些小细节不如goroutine、interface和channel那样"高大上","屌 丝"得可能不经常被人注意到,但它们却对理解Go语言有着重要的作用。这里想挑出一些和大家一起通过详实的例子来逐一展开和理解。本文内容较为基础,适合初学者,高手可飘过:)

一、源文件字符集和字符集编码

Go源码文件默认采用Unicode字符集,Unicode码点(code point)和内存中字节序列(byte sequence)的变换实现使用了UTF-8:一种变长多字节编码,同时也是一种事实字符集编码标准,为Linux、MacOSX 上的默认字符集编码,因此使用Linux或MacOSX进行Go程序开发,你会省去很多字符集转换方面的烦恼。但如果你是在Windows上使用 默认编辑器编辑Go源码文本,当你编译以下代码时会遇到编译错误:

//hello.go
package main

import "fmt"

func main() {
    fmt.Println("中国人")
}

$ go build hello.go
# command-line-arguments
hello.go:6 illegal UTF-8 sequence d6 d0
hello.go:6 illegal UTF-8 sequence b9
hello.go:6 illegal UTF-8 sequence fa c8
hello.go:6 illegal UTF-8 sequence cb 22
hello.go:6 newline in string
hello.go:7 syntax error: unexpected }, expected )

这是因为Windows默认采用的是CP936字符集编码,也就是GBK编码,“中国人”三个字的内存字节序列为:

“d0d6    fab9    cbc8    000a” (通过iconv转换,然后用od -x查看)

这个字节序列并非utf-8字节序列,Go编译器因此无法识别。要想通过编译,需要将该源文件转换为UTF-8编码格式。

字符集编码对字符和字符串字面值(Literal)影响最大,在Go中对于字符串我们可以有三种写法:

1) 字面值

var s = "中国人"

2) 码点表示法

var s1 = "\u4e2d\u56fd\u4eba"

or

var s2 = "\U00004e2d\U000056fd\U00004eba"

3) 字节序列表示法(二进制表示法)

var s3 = "\xe4\xb8\xad\xe5\x9b\xbd\xe4\xba\xba"

这三种表示法中,除字面值转换为字节序列存储时根据编辑器保存的源码文件编码格式之外,其他两种均不受编码格式影响。我们可以通过逐字节输出来查 看字节序列的内容:

    fmt.Println("s byte sequence:")
    for i := 0; i < len(s); i++ {
        fmt.Printf("0x%x ", s[i])
    }
    fmt.Println("")

二、续行

良好的代码style一般会要求代码中不能有太long的代码行,否则会影响代码阅读者的体验。在C中有续行符"\"专门用于代码续行处理;但在 Go中没有专属续行符,如何续行需要依据Go的语法规则(参见Go spec)。

Go与C一样,都是以分号(";")作为语句结束的标识。不过大多数情况下,分号无需程序员手工输入,而是由编译器自动识别语句结束位置,并插入 分号。因此续行要选择合法的位置。下面代码展示了一些合法的续行位置:(别嫌太丑,这里仅仅是展示合法位置的demo)

//details-in-go/2/newline.go
… …
var (
    s = "This is an example about code newline," +
        "for string as right value"
    d = 5 + 4 + 7 +
        4
    a = [...]int{5, 6, 7,
        8}
    m = make(map[string]int,
        100)
    c struct {
        m1     string
        m2, m3 int
        m4     *float64
    }

    f func(int,
        float32) (int,
        error)
)

func foo(int, int) (string, error) {
    return "",
        nil
}

func main() {
    if i := d; i >
        100 {
    }

    var sum int
    for i := 0; i < 100; i = i +
        1 {
        sum += i
    }

    foo(1,
        6)

    var i int
    fmt.Printf("%s, %d\n",
        "this is a demo"+
            " of fmt Printf",
        i)
}

实际编码中,我们可能经常遇到的是fmt.Printf系列方法中format string太长的情况,但由于Go不支持相邻字符串自动连接(concatenate),只能通过+来连接fmt字符串,且+必须放在前一行末尾。另外Gofmt工具会自动调整一些不合理的续行处理,主要针对 for, if等控制语句。

三、Method Set

Method Set是Go语法中一个重要的隐式概念,在为interface变量做动态类型赋值、embeding struct/interface、type alias、method expression时都会用到Method Set这个重要概念。

1、interface的Method Set

根据Go spec,interface类型的Method Set就是其interface(An interface type specifies a method set called its interface)。

type I interface {
    Method1()
    Method2()
}

I的Method Set包含的就是其literal中的两个方法:Method1和Method2。我们可以通过reflect来获取interface类型的 Method Set:

//details-in-go/3/interfacemethodset.go
package main

import (
    "fmt"
    "reflect"
)

type I interface {
    Method1()
    Method2()
}

func main() {
    var i *I
    elemType := reflect.TypeOf(i).Elem()
    n := elemType.NumMethod()
    for i := 0; i < n; i++ {
        fmt.Println(elemType.Method(i).Name)
    }
}

运行结果:
$go run interfacemethodset.go
Method1
Method2

2、除interface type外的类型的Method Set

对于非interface type的类型T,其Method Set为所有receiver为T类型的方法组成;而类型*T的Method Set则包含所有receiver为T和*T类型的方法。

// details-in-go/3/othertypemethodset.go
package main

import "./utils"

type T struct {
}

func (t T) Method1() {
}

func (t *T) Method2() {
}

func (t *T) Method3() {
}

func main() {
    var t T
    utils.DumpMethodSet(&t)

    var pt *T
    utils.DumpMethodSet(&pt)
}

我们要dump出T和*T各自的Method Set,运行结果如下:

$go run othertypemethodset.go
main.T's method sets:
     Method1

*main.T's method sets:
     Method1
     Method2
     Method3

可以看出类型T的Method set仅包含一个receiver类型为T的方法:Method1,而*T的Method Set则包含了T的Method Set以及所有receiver类型为*T的Method。

如果此时我们有一个interface type如下:

type I interface {
    Method1()
    Method2()
}

那下面哪个赋值语句合法呢?合不合法完全依赖于右值类型是否实现了interface type I的所有方法,即右值类型的Method Set是否包含了I的 所有方法。

var t T
var pt *T

var i I = t

or

var i I = pt

编译错误告诉我们:

     var i I = t // cannot use t (type T) as type I in assignment:
                  T does not implement I (Method2 method has pointer receiver)

T的Method Set中只有Method1一个方法,没有实现I接口中的 Method2,因此不能用t赋值给i;而*T实现了I的所有接口,赋值合 法。不过Method set校验仅限于在赋值给interface变量时进行,无论是T还是*T类型的方法集中的方法,对于T或*T类型变量都是可见且可以调用的,如下面代码 都是合法的:

    pt.Method1()
    t.Method3()

因为Go编译器会自动为你的代码做receiver转换:

    pt.Method1() <=> (*pt).Method1()
    t.Method3() <=> (&t).Method3()

很多人纠结于method定义时receiver的类型(T or *T),个人觉得有两点考虑:

1) 效率
   Go方法调用receiver是以传值的形式传入方法中的。如果类型size较大,以value形式传入消耗较大,这时指针类型就是首选。

2) 是否赋值给interface变量、以什么形式赋值
   就像本节所描述的,由于T和*T的Method Set可能不同,我们在设计Method receiver type时需要考虑在interface赋值时通过对Method set的校验。

3、embeding type的Method Set

interface embeding

我们先来看看interface类型embeding。例子如下:

//details-in-go/3/embedinginterface.go
package main

import "./utils"

type I1 interface {
    I1Method1()
    I1Method2()
}
type I2 interface {
    I2Method()
}

type I3 interface {
    I1
    I2
}

func main() {
    utils.DumpMethodSet((*I1)(nil))
    utils.DumpMethodSet((*I2)(nil))
    utils.DumpMethodSet((*I3)(nil))
}

$go run embedinginterface.go
main.I1's method sets:
     I1Method1
     I1Method2

main.I2's method sets:
     I2Method

main.I3's method sets:
     I1Method1
     I1Method2
     I2Method

可以看出嵌入interface type的interface type I3的Method Set包含了被嵌入的interface type:I1I2的Method Set。很多情况下,我们Go的interface type中仅包含有少量方法,常常仅是一个Method,通过interface type embeding来定义一个新interface,这是Go的一个惯用法,比如我们常用的io包中的Reader, Writer以及ReadWriter接口:

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

type Writer interface {
    Write(p []byte) (n int, err error)
}

type ReadWriter interface {
    Reader
    Writer
}

【struct embeding interface】

在struct中嵌入interface type后,struct的Method Set中将包含interface的Method Set:

type T struct {
    I1
}

func (T) Method1() {

}

… …
func main() {
    … …
    var t T
    utils.DumpMethodSet(&t)
    var pt = &T{
        I1: I1Impl{},
    }
    utils.DumpMethodSet(&pt)

}

输出结果与预期一致:

main.T's method sets:
     I1Method1
     I1Method2
     Method1

*main.T's method sets:
     I1Method1
     I1Method2
     Method1

【struct embeding struct】

在struct中embeding struct提供了一种“继承”的手段,外部的Struct可以“继承”嵌入struct的所有方法(无论receiver是T还是*T类型)实现,但 Method Set可能会略有不同。看下面例子:

//details-in-go/3/embedingstructinstruct.go
package main

import "./utils"

type T struct {
}

func (T) InstMethod1OfT() {

}

func (T) InstMethod2OfT() {

}

func (*T) PtrMethodOfT() {

}

type S struct {
}

func (S) InstMethodOfS() {

}

func (*S) PtrMethodOfS() {
}

type C struct {
    T
    *S
}

func main() {
    var c = C{S: &S{}}
    utils.DumpMethodSet(&c)
    var pc = &C{S: &S{}}
    utils.DumpMethodSet(&pc)

    c.InstMethod1OfT()
    c.PtrMethodOfT()
    c.InstMethodOfS()
    c.PtrMethodOfS()
    pc.InstMethod1OfT()
    pc.PtrMethodOfT()
    pc.InstMethodOfS()
    pc.PtrMethodOfS()
}

$go run embedingstructinstruct.go
main.C's method sets:
     InstMethod1OfT
     InstMethod2OfT
     InstMethodOfS
     PtrMethodOfS

*main.C's method sets:
     InstMethod1OfT
     InstMethod2OfT
     InstMethodOfS
     PtrMethodOfS
     PtrMethodOfT

可以看出:
类型C的Method Set = T的Method Set + *S的Method Set
类型*C的Method Set = *T的Method Set + *S的Method Set

同时通过例子可以看出,无论是T还是*S的方法,C或*C类型变量均可调用(编译器甜头),不会被局限在Method Set中。

4、alias type的Method Set

Go支持为已有类型定义alias type,如:

type MyInterface I
type Mystruct T

对于alias type, Method Set是如何定义的呢?我们看下面例子:

//details-in-go/3/aliastypemethodset.go
package main

import "./utils"

type I interface {
    IMethod1()
    IMethod2()
}

type T struct {
}

func (T) InstMethod() {

}
func (*T) PtrMethod() {

}

type MyInterface I
type MyStruct T

func main() {
    utils.DumpMethodSet((*I)(nil))

    var t T
    utils.DumpMethodSet(&t)
    var pt = &T{}
    utils.DumpMethodSet(&pt)

    utils.DumpMethodSet((*MyInterface)(nil))

    var m MyStruct
    utils.DumpMethodSet(&m)
    var pm = &MyStruct{}
    utils.DumpMethodSet(&pm)
}

$go run aliastypemethodset.go
main.I's method sets:
     IMethod1
     IMethod2

main.T's method sets:
     InstMethod

*main.T's method sets:
     InstMethod
     PtrMethod

main.MyInterface's method sets:
     IMethod1
     IMethod2

main.MyStruct's method set is empty!
*main.MyStruct's method set is empty!

从例子的结果上来看,Go对于interface和struct的alias type给出了“不一致”的结果:

MyInterface的Method Set与接口类型I Method Set一致;
而MyStruct并未得到T的哪怕一个Method,MyStruct的Method Set为空。

四、Method Type、Method Expression、Method Value

Go中没有class,方法与对象通过receiver联系在一起,我们可以为任何非builtin类型定义method:

type T struct {
    a int
}

func (t T) Get() int       { return t.a }
func (t *T) Set(a int) int { t.a = a; return t.a }

在C++等OO语言中,对象在调用方法时,编译器会自动在方法的第一个参数中传入this/self指针,而对于Go来 说,receiver也是同样道理,将T的method转换为普通function定义:

func Get(t T) int       { return t.a }
func Set(t *T, a int) int { t.a = a; return t.a }

这种function形式被称为Method Type,也可以称为Method的signature

Method的一般使用方式如下:

var t T
t.Get()
t.Set(1)

不过我们也可以像普通function那样使用它,根据上面的Method Type定义:

var t T
T.Get(t)
(*T).Set(&t, 1)

这种以直接以类型名T调用方法M的表达方法称为Method Expression。类型T只能调用T的Method Set中的方法;同理*T只能调用*T的Method Set中的方法。上述例子中T的Method Set中只有Get,因此T.Get是合法的。但T.Set则不合法:

    T.Set(2) //invalid method expression T.Set (needs pointer receiver: (*T).Set)

我们只能使用(*T).Set(&t, 11)

这样看来Method Expression有些类似于C++中的static方法(以该类的某个对象实例作为第一个参数)。

另外Method express自身类型就是一个普通function,可以作为右值赋值给一个函数类型的变量:

    f1 := (*T).Set //函数类型:func (t *T, int)int
    f2 := T.Get //函数类型:func(t T)int
    f1(&t, 3)
    fmt.Println(f2(t))

Go中还定义了一种与Method有关的语法:如果一个表达式t具有静态类型T,M是T的Method Set中的一个方法,那么t.M即为Method Value。注意这里是t.M而不是T.M。

    f3 := (&t).Set //函数类型:func(int)int
    f3(4)
    f4 := t.Get
//函数类型:func()int   
    fmt.Println(f4())

可以看出,Method value与Method Expression不同之处在于,Method value绑定了T对象实例,它的函数原型并不包含Method Expression函数原型中的第一个参数。完整例子参见:details-in-go/4/methodexpressionandmethodvalue.go

五、for range“坑”大阅兵

for range的引入提升了Go的表达能力,但for range显然不是”免费的午餐“,在享用这个美味前,需要搞清楚for range的一些坑。

1、iteration variable重用

for range的idiomatic的使用方式是使用short variable declaration(:=)形式在for expression中声明iteration variable,但需要注意的是这些variable在每次循环体中都会被重用,而不是重新声明。

//details-in-go/5/iterationvariable.go
… …
    var m = [...]int{1, 2, 3, 4, 5}

    for i, v := range m {
        go func() {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }()
    }

    time.Sleep(time.Second * 10)
… …

在我的Mac上,输出结果如下:

$go run iterationvariable.go
4 5
4 5
4 5
4 5
4 5

各个goroutine中输出的i,v值都是for range循环结束后的i, v最终值,而不是各个goroutine启动时的i, v值。一个可行的fix方法:

    for i, v := range m {
        go func(i, v int) {
            time.Sleep(time.Second * 3)
            fmt.Println(i, v)
        }(i, v)
    }

2、range expression副本参与iteration

range后面接受的表达式的类型包括:array, pointer to array, slice, string, map和channel(有读权限的)。我们以array为例来看一个简单的例子:

//details-in-go/5/arrayrangeexpression.go
func arrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("a = ", a)

    for i, v := range a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

    fmt.Println("r = ", r)
}

我们期待输出结果:

a =  [1 2 3 4 5]
r =  [1 12 13 4 5]

a =  [1 12 13 4 5]

但实际输出结果却是:

a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 12 13 4 5]

我们原以为在第一次iteration,也就是i = 0时,我们对a的修改(a[1] = 12,a[2] = 13)会在第二次、第三次循环中被v取出,但结果却是v取出的依旧是a被修改前的值:2和3。这就是for range的一个不大不小的坑:range expression副本参与循环。也就是说在上面这个例子里,真正参与循环的是a的副本,而不是真正的a,伪代码如 下:

    for i, v := range a' {//a' is copy from a
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

Go中的数组在内部表示为连续的字节序列,虽然长度是Go数组类型的一部分,但长度并不包含的数组的内部表示中,而是由编译器在编译期计算出 来。这个例子中,对range表达式的拷贝,即对一个数组的拷贝,a'则是Go临时分配的连续字节序列,与a完全不是一块内存。因此无论a被 如何修改,其副本a'依旧保持原值,并且参与循环的是a',因此v从a'中取出的仍旧是a的原值,而非修改后的值。

我们再来试试pointer to array:

func pointerToArrayRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("pointerToArrayRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range &a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }

        r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
    fmt.Println("")
}

这回的输出结果如下:

pointerToArrayRangeExpression result:
a =  [1 2 3 4 5]
r =  [1 12 13 4 5]
a =  [1 12 13 4 5]

我们看到这次r数组的值与最终a被修改后的值一致了。这个例子中我们使用了*[5]int作为range表达式,其副本依旧是一个指向原数组 a的指针,因此后续所有循环中均是&a指向的原数组亲自参与的,因此v能从&a指向的原数组中取出a修改后的值。

idiomatic go建议我们尽可能的用slice替换掉array的使用,这里用slice能否实现预期的目标呢?我们来试试:

func sliceRangeExpression() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("sliceRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a[:] {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }

        r[i] = v
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
    fmt.Println("")
}

pointerToArrayRangeExpression result:
a =  [1 2 3 4 5]
r =  [1 12 13 4 5]
a =  [1 12 13 4 5]

显然用slice也能实现预期要求。我们可以分析一下slice是如何做到的。slice在go的内部表示为一个struct,由(*T, len, cap)组成,其中*T指向slice对应的underlying array的指针,len是slice当前长度,cap为slice的最大容量。当range进行expression复制时,它实际上复制的是一个 slice,也就是那个struct。副本struct中的*T依旧指向原slice对应的array,为此对slice的修改都反映到 underlying array a上去了,v从副本struct中*T指向的underlying array中获取数组元素,也就得到了被修改后的元素值。

slice与array还有一个不同点,就是其len在运行时可以被改变,而array的len是一个常量,不可改变。那么len变化的 slice对for range有何影响呢?我们继续看一个例子:

func sliceLenChangeRangeExpression() {
    var a = []int{1, 2, 3, 4, 5}
    var r = make([]int, 0)

    fmt.Println("sliceLenChangeRangeExpression result:")
    fmt.Println("a = ", a)

    for i, v := range a {
        if i == 0 {
            a = append(a, 6, 7)
        }

        r = append(r, v)
    }

    fmt.Println("r = ", r)
    fmt.Println("a = ", a)
}

输出结果:

a =  [1 2 3 4 5]
r =  [1 2 3 4 5]
a =  [1 2 3 4 5 6 7]

在这个例子中,原slice a在for range过程中被附加了两个元素6和7,其len由5增加到7,但这对于r却没有产生影响。这里的原因就在于a的副本a'的内部表示struct中的 len字段并没有改变,依旧是5,因此for range只会循环5次,也就只获取a对应的underlying数组的前5个元素。

range的副本行为会带来一些性能上的消耗,尤其是当range expression的类型为数组时,range需要复制整个数组;而当range expression类型为pointer to array或slice时,这个消耗将小得多,仅仅需要复制一个指针或一个slice的内部表示(一个struct)即可。我们可以通过 benchmark test来看一下三种情况的消耗情况对比:

对于元素个数为100的int数组或slice,测试结果如下:

//details-in-go/5/arraybenchmark
go test -bench=.
testing: warning: no tests to run
PASS
BenchmarkArrayRangeLoop-4             20000000           116 ns/op
BenchmarkPointerToArrayRangeLoop-4    20000000            64.5 ns/op
BenchmarkSliceRangeLoop-4             20000000            70.9 ns/op

可以看到range expression类型为slice或pointer to array的性能相近,消耗都近乎是数组类型的1/2。

3、其他range expression类型

对于range后面的其他表达式类型,比如string, map, channel,for range依旧会制作副本。

【string】
对string来说,由于string的内部表示为struct {*byte, len),并且string本身是immutable的,因此其行为和消耗和slice expression类似。不过for range对于string来说,每次循环的单位是rune(code point的值),而不是byte,index为迭代字符码点的第一个字节的position:

    var s = "中国人"

    for i, v := range s {
        fmt.Printf("%d %s 0x%x\n", i, string(v), v)
    }

输出结果:
0 中 0x4e2d
3 国 0x56fd
6 人 0x4eba

如果s中存在非法utf8字节序列,那么v将返回0xFFFD这个特殊值,并且在接下来一轮循环中,v将仅前进一个字节:

//byte sequence of s: 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
    var sl = []byte{0xe4, 0xb8, 0xad, 0xe5, 0x9b, 0xbd, 0xe4, 0xba, 0xba}
    for _, v := range sl {
        fmt.Printf("0x%x ", v)
    }
    fmt.Println("\n")

    sl[3] = 0xd0
    sl[4] = 0xd6
    sl[5] = 0xb9

    for i, v := range string(sl) {
        fmt.Printf("%d %x\n", i, v)
    }

输出结果:

0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba

0 4e2d
3 fffd
4 5b9
6 4eba

以上例子源码在details-in-go/5/stringrangeexpression.go中可以找到。

map

对于map来说,map内部表示为一个指针,指针副本也指向真实map,因此for range操作均操作的是源map。

for range不保证每次迭代的元素次序,对于下面代码:

 var m = map[string]int{
        "tony": 21,
        "tom":  22,
        "jim":  23,
    }

    for k, v := range m {
        fmt.Println(k, v)
    }

输出结果可能是:

tom 22
jim 23
tony 21

也可能是:

tony 21
tom 22
jim 23

或其他可能。

如果map中的某项在循环到达前被在循环体中删除了,那么它将不会被iteration variable获取到。
    counter := 0
    for k, v := range m {
        if counter == 0 {
            delete(m, "tony")
        }
        counter++
        fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)

反复运行多次,我们得到的两个结果:

tony 21
tom 22
jim 23
counter is  3

tom 22
jim 23
counter is  2

如果在循环体中新创建一个map元素项,那该项元素可能出现在后续循环中,也可能不出现:

    m["tony"] = 21
    counter = 0

    for k, v := range m {
        if counter == 0 {
            m["lucy"] = 24
        }
        counter++
        fmt.Println(k, v)
    }
    fmt.Println("counter is ", counter)

执行结果:

tony 21
tom 22
jim 23
lucy 24
counter is  4

or

tony 21
tom 22
jim 23
counter is  3

以上代码可以在details-in-go/5/maprangeexpression.go中可以找到。

【channel】

对于channel来说,channel内部表示为一个指针,channel的指针副本也指向真实channel。

for range最终以阻塞读的方式阻塞在channel expression上(即便是buffered channel,当channel中无数据时,for range也会阻塞在channel上),直到channel关闭:

//details-in-go/5/channelrangeexpression.go
func main() {
    var c = make(chan int)

    go func() {
        time.Sleep(time.Second * 3)
        c <- 1
        c <- 2
        c <- 3
        close(c)
    }()

    for v := range c {
        fmt.Println(v)
    }
}

运行结果:

1
2
3

如果channel变量为nil,则for range将永远阻塞。

六、select求值 

golang引入的select为我们提供了一种在多个channel间实现“多路复用”的一种机制。select的运行机制这里不赘述,但select的case expression的求值顺序我们倒是要通过一个例子来了解一下:

// details-in-go/6/select.go

func takeARecvChannel() chan int {
    fmt.Println("invoke takeARecvChannel")
    c := make(chan int)

    go func() {
        time.Sleep(3 * time.Second)
        c <- 1
    }()

    return c
}

func getAStorageArr() *[5]int {
    fmt.Println("invoke getAStorageArr")
    var a [5]int
    return &a
}

func takeASendChannel() chan int {
    fmt.Println("invoke takeASendChannel")
    return make(chan int)
}

func getANumToChannel() int {
    fmt.Println("invoke getANumToChannel")
    return 2
}

func main() {
    select {
    //recv channels
    case (getAStorageArr())[0] = <-takeARecvChannel():
        fmt.Println("recv something from a recv channel")

        //send channels
    case takeASendChannel() <- getANumToChannel():
        fmt.Println("send something to a send channel")
    }
}

运行结果:

$go run select.go
invoke takeARecvChannel
invoke takeASendChannel
invoke getANumToChannel

invoke getAStorageArr
recv something from a recv channel

通过例子我们可以看出:
1) select执行开始时,首先所有case expression的表达式都会被求值一遍,按语法先后次序。

invoke takeARecvChannel
invoke takeASendChannel
invoke getANumToChannel

例外的是recv channel的位于赋值等号左边的表达式(这里是:(getAStorageArr())[0])不会被求值。

2) 如果选择要执行的case是一个recv channel,那么它的赋值等号左边的表达式会被求值:如例子中当goroutine 3s后向recvchan写入一个int值后,select选择了recv channel执行,此时对=左侧的表达式 (getAStorageArr())[0] 开始求值,输出“invoke getAStorageArr”。

七、panic的recover过程

Go没有提供“try-catch-finally”这样的异常处理设施,而仅仅提供了panic和recover,其中recover还要结合 defer使用。最初这也是被一些人诟病的点。但和错误码返回值一样,渐渐的大家似乎适应了这些,征讨之声渐稀,即便有也是排在“缺少generics” 之后了。

【panicking】

在没有recover的时候,一旦panic发生,panic会按既定顺序结束当前进程,这一过程成为panicking。下面的例子模拟了这一过程:

//details-in-go/7/panicking.go
… …
func foo() {
    defer func() {
        fmt.Println("foo defer func invoked")
    }()
    fmt.Println("foo invoked")

    bar()
    fmt.Println("do something after bar in foo")
}

func bar() {
    defer func() {
        fmt.Println("bar defer func invoked")
    }()
    fmt.Println("bar invoked")

    zoo()
    fmt.Println("do something after zoo in bar")
}

func zoo() {
    defer func() {
        fmt.Println("zoo defer func invoked")
    }()

    fmt.Println("zoo invoked")
    panic("runtime exception")
}

func main() {
    foo()
}

执行结果:

$go run panicking.go
foo invoked
bar invoked
zoo invoked
zoo defer func invoked
bar defer func invoked
foo defer func invoked
panic: runtime exception

goroutine 1 [running]:
… …
exit status 2

从结果可以看出:
    panic在zoo中发生,在zoo真正退出前,zoo中注册的defer函数会被逐一执行(FILO),由于zoo defer中没有捕捉panic,因此panic被抛向其caller:bar。
    这时对于bar而言,其函数体中的zoo的调用就好像变成了panic调用似的,zoo有些类似于“黑客帝国3”中里奥被史密斯(panic)感 染似的,也变成了史密斯(panic)。panic在bar中扩展开来,bar中的defer也没有捕捉和recover panic,因此在bar中的defer func执行完毕后,panic继续抛给bar的caller: foo;
    这时对于foo而言,bar就变成了panic,同理,最终foo将panic抛给了main
    main与上述函数一样,没有recover,直接异常返回,导致进程异常退出。
 

【recover】

recover只有在defer函数中调用才能起到recover的作用,这样recover就和defer函数有了紧密联系。我们在zoo的defer函数中捕捉并recover这个panic:

//details-in-go/7/recover.go
… …
func zoo() {
    defer func() {
        fmt.Println("zoo defer func1 invoked")
    }()

    defer func() {
        if x := recover(); x != nil {
            log.Printf("recover panic: %v in zoo recover defer func", x)
        }
    }()

    defer func() {
        fmt.Println("zoo defer func2 invoked")
    }()

    fmt.Println("zoo invoked")
    panic("zoo runtime exception")
}

… …

这回的执行结果如下:

$go run recover.go
foo invoked
bar invoked
zoo invoked
zoo defer func2 invoked
2015/09/17 16:28:00 recover panic: zoo runtime exception in zoo recover defer func
zoo defer func1 invoked
do something after zoo in bar
bar defer func invoked
do something after bar in foo
foo defer func invoked

由于zoo在defer里恢复了panic,这样在zoo返回后,bar不会感知到任何异常,将按正常逻辑输出函数执行内容,比如:“do something after zoo in bar”,以此类推。

但若如果在zoo defer func中recover panic后,又raise another panic,那么zoo对于bar来说就又会变成panic了。

Last、参考资料

1、The Go Programming Language Specification (Version of August 5, 2015,Go 1.5);
2、Effective Go (Go 1.5);
3、Rob Pike: Go Course Day 1~3

本文实验环境:Go 1.5 darwin_amd64。示例代码在这里可以下载。

我就是这样一种人:对任何自己感兴趣且有极大热情去做的事情都喜欢刨根问底,彻底全面地了解其中细节,否则我就会有一种“不安全 感”。我不知道在心理学范畴这样的我属于那种类别^_^。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats