Golang 学习笔记三 接口、错误异常

懒皮 · · 616 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

一、接口

《快学 Go 语言》第 9 课 —— 接口

1.接口定义
Go 语言的接口类型非常特别,它的作用和 Java 语言的接口一样,但是在形式上有很大的差别。Java 语言需要在类的定义上显式实现了某些接口,才可以说这个类具备了接口定义的能力。但是 Go 语言的接口是隐式的,只要结构体上定义的方法在形式上(名称、参数和返回值)和接口定义的一样,那么这个结构体就自动实现了这个接口,我们就可以使用这个接口变量来指向这个结构体对象。下面我们看个例子

package main

import "fmt"

// 可以闻
type Smellable interface {
  smell()
}

// 可以吃
type Eatable interface {
  eat()
}

// 苹果既可能闻又能吃
type Apple struct {}

func (a Apple) smell() {
  fmt.Println("apple can smell")
}

func (a Apple) eat() {
  fmt.Println("apple can eat")
}

// 花只可以闻
type Flower struct {}

func (f Flower) smell() {
  fmt.Println("flower can smell")
}

func main() {
  var s1 Smellable
  var s2 Eatable
  var apple = Apple{}
  var flower = Flower{}
  s1 = apple
  s1.smell()
  s1 = flower
  s1.smell()
  s2 = apple
  s2.eat()
}

--------------------
apple can smell
flower can smell
apple can eat

上面的代码定义了两种接口,Apple 结构体同时实现了这两个接口,而 Flower 结构体只实现了 Smellable 接口。我们并没有使用类似于 Java 语言的 implements 关键字,结构体和接口就自动产生了关联。

2.空接口
如果一个接口里面没有定义任何方法,那么它就是空接口,任意结构体都隐式地实现了空接口。

Go 语言为了避免用户重复定义很多空接口,它自己内置了一个,这个空接口的名字特别奇怪,叫 interface{} ,初学者会非常不习惯。之所以这个类型名带上了大括号,那是在告诉用户括号里什么也没有。我始终认为这种名字很古怪,它让代码看起来有点丑陋。

空接口里面没有方法,所以它也不具有任何能力,其作用相当于 Java 的 Object 类型,可以容纳任意对象,它是一个万能容器。比如一个字典的 key 是字符串,但是希望 value 可以容纳任意类型的对象,类似于 Java 语言的 Map 类型,这时候就可以使用空接口类型 interface{}。

package main

import "fmt"

func main() {
    // 连续两个大括号,是不是看起来很别扭
    var user = map[string]interface{}{
        "age": 30,
        "address": "Beijing Tongzhou",
        "married": true,
    }
    fmt.Println(user)
    // 类型转换语法来了
    var age = user["age"].(int)
    var address = user["address"].(string)
    var married = user["married"].(bool)
    fmt.Println(age, address, married)
}

-------------
map[age:30 address:Beijing Tongzhou married:true]
30 Beijing Tongzhou true

代码中 user 字典变量的类型是 map[string]interface{},从这个字典中直接读取得到的 value 类型是 interface{},需要通过类型转换才能得到期望的变量。

3.用接口来模拟多态

package main

import "fmt"

type Fruitable interface {
    eat()
}

type Fruit struct {
    Name string  // 属性变量
    Fruitable  // 匿名内嵌接口变量
}

func (f Fruit) want() {
    fmt.Printf("I like ")
    f.eat() // 外结构体会自动继承匿名内嵌变量的方法
}

type Apple struct {}

func (a Apple) eat() {
    fmt.Println("eating apple")
}

type Banana struct {}

func (b Banana) eat() {
    fmt.Println("eating banana")
}

func main() {
    var f1 = Fruit{"Apple", Apple{}}
    var f2 = Fruit{"Banana", Banana{}}
    f1.want()
    f2.want()
}

---------
I like eating apple
I like eating banana

使用这种方式模拟多态本质上是通过组合属性变量(Name)和接口变量(Fruitable)来做到的,属性变量是对象的数据,而接口变量是对象的功能,将它们组合到一块就形成了一个完整的多态性的结构体。

4.接口的组合继承
接口的定义也支持组合继承,比如我们可以将两个接口定义合并为一个接口如下

type Smellable interface {
  smell()
}

type Eatable interface {
  eat()
}

type Fruitable interface {
  Smellable
  Eatable
}

这时 Fruitable 接口就自动包含了 smell() 和 eat() 两个方法,它和下面的定义是等价的。

type Fruitable interface {
  smell()
  eat()
}

5.接口变量的赋值
变量赋值本质上是一次内存浅拷贝,切片的赋值是拷贝了切片头,字符串的赋值是拷贝了字符串的头部,而数组的赋值呢是直接拷贝整个数组。接口变量的赋值会不会不一样呢?接下来我们做一个实验

package main

import "fmt"

type Rect struct {
    Width int
    Height int
}

func main() {
    var a interface {}
    var r = Rect{50, 50}
    a = r

    var rx = a.(Rect)
    r.Width = 100
    r.Height = 100
    fmt.Println(rx)
}

------
{50 50}
二、错误异常

《快学 Go 语言》第 10 课 —— 错误与异常
Go 语言的异常处理语法绝对是独树一帜,在我见过的诸多高级语言中,Go 语言的错误处理形式就是一朵奇葩。一方面它鼓励你使用 C 语言的形式将错误通过返回值来进行传递,另一方面它还提供了高级语言一般都有的异常抛出和捕获的形式,但是又不鼓励你使用这个形式。后面我们统一将返回值形式的称为「错误」,将抛出捕获形式的称为「异常」。

1.错误接口
Go 语言规定凡是实现了错误接口的对象都是错误对象,这个错误接口只定义了一个方法。

type error interface {
  Error() string
}

注意这个接口的名称,它是小写的,是内置的全局接口。通常一个名字如果是小写字母开头,那么它在包外就是不可见的,不过 error 是内置的特殊名称,它是全局可见的。

编写一个错误对象很简单,写一个结构体,然后挂在 Error() 方法就可以了。

package main

import "fmt"

type SomeError struct {
    Reason string
}

func (s SomeError) Error() string {
    return s.Reason
}

func main() {
    var err error = SomeError{"something happened"}
    fmt.Println(err)
}

---------------
something happened

对于上面代码中错误对象的形式非常常用,所以 Go 语言内置了一个通用错误类型,在 errors 包里。这个包还提供了一个 New() 函数让我们方便地创建一个通用错误。var err = errors.New("something happened")如果你的错误字符串需要定制一些参数,可使用 fmt 包提供了 Errorf 函数

var thing = "something"
var err = fmt.Errorf("%s happened", thing)

1.错误处理首体验
在 Java 语言里,如果遇到 IO 问题通常会抛出 IOException 类型的异常,在 Go 语言里面它不会抛异常,而是以返回值的形式来通知上层逻辑来处理错误。下面我们通过读文件来尝试一下 Go 语言的错误处理,读文件需要使用内置的 os 包。

package main

import "os"
import "fmt"

func main() {
    // 打开文件
    var f, err = os.Open("main.go")
    if err != nil {
        // 文件不存在、权限等原因
        fmt.Println("open file failed reason:" + err.Error())
        return
    }
    // 推迟到函数尾部调用,确保文件会关闭
    defer f.Close()
    // 存储文件内容
    var content = []byte{}
    // 临时的缓冲,按块读取,一次最多读取 100 字节
    var buf = make([]byte, 100)
    for {
        // 读文件,将读到的内容填充到缓冲
        n, err := f.Read(buf)
        if n > 0 {
            // 将读到的内容聚合起来
            content = append(content, buf[:n]...)
        }
        if err != nil {
            // 遇到流结束或者其它错误
            break
        }
    }
    // 输出文件内容
    fmt.Println(string(content))
}

-------
package main

import "os"
import "fmt"
.....

在这段代码里有几个点需要特别注意。第一个需要注意的是 os.Open()、f.Read() 函数返回了两个值,Go 语言不但允许函数返回两个值,三个值四个值都是可以的,只不过 Go 语言普遍没有使用多返回值的习惯,仅仅是在需要返回错误的时候才会需要两个返回值。除了错误之外,还有一个地方需要两个返回值,那就是字典,通过第二个返回值来告知读取的结果是零值还是根本就不存在。var score, ok := scores["apple"]

第二个需要注意的是 defer 关键字,它将文件的关闭调用推迟到当前函数的尾部执行,即使后面的代码抛出了异常,文件关闭也会确保被执行,相当于 Java 语言的 finally 语句块。defer 是 Go 语言非常重要的特性,在日常应用开发中,我们会经常使用到它。

第三个需要注意的地方是 append 函数参数中出现了 … 符号。在切片章节,我们知道 append 函数可以将单个元素追加到切片中,其实 append 函数可以一次性追加多个元素,它的参数数量是可变的。

var s = []int{1,2,3,4,5}
s = append(s,6,7,8,9)

但是读文件的代码中需要将整个切片的内容追加到另一个切片中,这时候就需要 … 操作符,它的作用是将切片参数的所有元素展开后传递给 append 函数。你可能会担心如果切片里有成百上千的元素,展开成元素再传递会不会非常耗费性能。这个不必担心,展开只是形式上的展开,在实现上其实并没有展开,传递过去的参数本质上还是切片。

第四个需要注意的地方是读文件操作 f.Read() ,它会将文件的内容往切片里填充,填充的量不会超过切片的长度(注意不是容量)。如果将缓冲改成下面这种形式,就会死循环!

var buf = make([]byte, 0, 100)

另外如果遇到文件尾了,切片就不会填满。所以需要通过返回值 n 来明确到底读了多少字节。

2.体验 Redis 的错误处理
上面读文件的例子并没有让读者感受到错误处理的不爽,下面我们要引入 Go 语言 Redis 的客户端包,来真实体验一下 Go 语言的错误处理有多让人不快。

使用第三方包,需要使用 go get 指令下载这个包,该指令会将第三方包放到 GOPATH 目录下。

go get github.com/go-redis/redis

下面我要实现一个小功能,获取 Redis 中两个整数值,然后相乘,再存入 Redis 中

package main

import "fmt"
import "strconv"
import "github.com/go-redis/redis"

func main() {
 // 定义客户端对象,内部包含一个连接池
    var client = redis.NewClient(&redis.Options {
        Addr: "localhost:6379",
    })

    // 定义三个重要的整数变量值,默认都是零
    var val1, val2, val3 int

    // 获取第一个值
    valstr1, err := client.Get("value1").Result()
    if err == nil {
        val1, err = strconv.Atoi(valstr1)
        if err != nil {
            fmt.Println("value1 not a valid integer")
            return
        }
    } else if err != redis.Nil {
        fmt.Println("redis access error reason:" + err.Error())
        return
    }

    // 获取第二个值
    valstr2, err := client.Get("value2").Result()
    if err == nil {
        val2, err = strconv.Atoi(valstr2)
        if err != nil {
            fmt.Println("value1 not a valid integer")
            return
        }
    } else if err != redis.Nil {
        fmt.Println("redis access error reason:" + err.Error())
        return
    }

    // 保存第三个值
    val3 = val1 * val2
    ok, err := client.Set("value3",val3, 0).Result()
    if err != nil {
        fmt.Println("set value error reason:" + err.Error())
        return
    }
    fmt.Println(ok)
}

------
OK

因为 Go 语言中不轻易使用异常语句,所以对于任何可能出错的地方都需要判断返回值的错误信息。上面代码中除了访问 Redis 需要判断之外,字符串转整数也需要判断。

另外还有一个需要特别注意的是因为字符串的零值是空串而不是 nil,你不好从字符串内容本身判断出 Redis 是否存在这个 key 还是对应 key 的 value 为空串,需要通过返回值的错误信息来判断。代码中的 redis.Nil 就是客户端专门为 key 不存在这种情况而定义的错误对象。

相比于写习惯了 Python 和 Java 程序的朋友们来说,这样繁琐的错误判断简直太地狱了。不过还是那句话,习惯了就好。

二、异常与捕捉

1.Go 语言提供了 panic 和 recover 全局函数让我们可以抛出异常、捕获异常。它类似于其它高级语言里常见的 throw try catch 语句,但是又很不一样,比如 panic 函数可以抛出来任意对象。下面我们看一个使用 panic 的例子

package main

import "fmt"

var negErr = fmt.Errorf("non positive number")

func main() {
    fmt.Println(fact(10))
    fmt.Println(fact(5))
    fmt.Println(fact(-5))
    fmt.Println(fact(15))
}

// 让阶乘函数返回错误太不雅观了
// 使用 panic 会合适一些
func fact(a int) int{
    if a <= 0 {
        panic(negErr)
    }
    var r = 1
    for i :=1;i<=a;i++ {
        r *= i
    }
    return r
}

-------
3628800
120
panic: non positive number

goroutine 1 [running]:
main.fact(0xfffffffffffffffb, 0x1)
    /Users/qianwp/go/src/github.com/pyloque/practice/main.go:16 +0x75
main.main()
    /Users/qianwp/go/src/github.com/pyloque/practice/main.go:10 +0x122
exit status 2

上面的代码抛出了 negErr,直接导致了程序崩溃,程序最后打印了异常堆栈信息。下面我们使用 recover 函数来保护它,recover 函数需要结合 defer 语句一起使用,这样可以确保 recover() 逻辑在程序异常的时候也可以得到调用。

package main

import "fmt"

var negErr = fmt.Errorf("non positive number")

func main() {
    defer func() {
        if err := recover(); err != nil {
            fmt.Println("error catched", err)
        }
    }()
    fmt.Println(fact(10))
    fmt.Println(fact(5))
    fmt.Println(fact(-5))
    fmt.Println(fact(15))
}

func fact(a int) int{
    if a <= 0 {
        panic(negErr)
    }
    var r = 1
    for i :=1;i<=a;i++ {
        r *= i
    }
    return r
}

-------
3628800
120
error catched non positive number

输出结果中的异常堆栈信息没有了,说明捕获成功了,不过即使程序不再崩溃,异常点后面的逻辑也不会再继续执行了。上面的代码中需要注意的是我们使用了匿名函数 func() {…}

defer func() {
  if err := recover(); err != nil {
   fmt.Println("error catched", err)
  }
}()

尾部还有个括号是怎么回事,为什么还需要这个括号呢?它表示对匿名函数进行了调用。对比一下前面写的文件关闭尾部的括号就能理解了

defer f.Close()

还有个值得注意的地方时,panic 抛出的对象未必是错误对象,而 recover() 返回的对象正是 panic 抛出来的对象,所以它也不一定是错误对象。

func panic(v interface{})
func recover() interface{}

我们经常还需要对 recover() 返回的结果进行判断,以挑选出我们愿意处理的异常对象类型,对于那些不愿意处理的,可以选择再次抛出来,让上层来处理。

defer func() {
    if err := recover(); err != nil {
        if err == negErr {
            fmt.Println("error catched", err)
        } else {
            panic(err)  // rethrow
        }
    }
}()

2.异常的真实应用
Go 语言官方表态不要轻易使用 panic recover,除非你真的无法预料中间可能会发生的错误,或者它能非常显著地简化你的代码。简单一点说除非逼不得已,否则不要使用它。

在一个常见的 Web 应用中,不能因为个别 URL 处理器抛出异常而导致整个程序崩溃,就需要在每个 URL 处理器外面包括一层 recover() 来恢复异常。


image.png

在 json 序列化过程中,逻辑上需要递归处理 json 内部的各种类型,每一种容器类型内部都可能会遇到不能序列化的类型。如果对每个函数都使用返回错误的方式来编写代码,会显得非常繁琐。所以在内置的 json 包里也使用了 panic,然后在调用的最外层包裹了 recover 函数来进行恢复,最终统一返回一个 error 类型。


image.png

你可以想象一下,内置 json 包的开发者在设计开发这个包的时候应该也是纠结的焦头烂额,最终还是使用了 panic 和 recover 来让自己的代码变的好看一些。

知乎最近发表了内部的 Go 语言实践方案,因为忍受不了代码里太多的错误判断语句,它们的业务异常也改用 panic 抛出来,虽然这并不是官方的推荐模式。

3.多个 defer 语句
有时候我们需要在一个函数里使用多次 defer 语句。比如拷贝文件,需要同时打开源文件和目标文件,那就需要调用两次 defer f.Close()。

package main

import "fmt"
import "os"

func main() {
    fsrc, err := os.Open("source.txt")
    if err != nil {
        fmt.Println("open source file failed")
        return
    }
    defer fsrc.Close()
    fdes, err := os.Open("target.txt")
    if err != nil {
        fmt.Println("open target file failed")
        return
    }
    defer fdes.Close()
    fmt.Println("do something here")
}

需要注意的是 defer 语句的执行顺序和代码编写的顺序是反过来的,也就是说最先 defer 的语句最后执行,为了验证这个规则,我们来改写一下上面的代码

package main

import "fmt"
import "os"

func main() {
    fsrc, err := os.Open("source.txt")
    if err != nil {
        fmt.Println("open source file failed")
        return
    }
    defer func() {
        fmt.Println("close source file")
        fsrc.Close()
    }()

    fdes, err := os.Open("target.txt")
    if err != nil {
        fmt.Println("open target file failed")
        return
    }
    defer func() {
        fmt.Println("close target file")
        fdes.Close()
    }()
    fmt.Println("do something here")
}

--------
do something here
close target file
close source file

有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:懒皮

查看原文:Golang 学习笔记三 接口、错误异常

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

616 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传