当前位置: 首页 > 工具软件 > go_bench > 使用案例 >

Go 入门笔记

秦毅
2023-12-01

函数

函数构成了代码执行的逻辑结构,在Go语言中,函数的基本组成为:关键字 func、函数名、参数列表、返回值、函数体和返回语句,每一个程序都包含很多的函数,函数是基本的代码块。

函数声明包括函数名、形式参数列表、返回值列表(可省略)以及函数体。

func 函数名(形式参数列表)(返回值列表){
    函数体
}

在Go语言中,函数也是一种类型,可以和其他类型一样保存在变量中,下面的代码定义了一个函数变量 f,并将一个函数名为 fire() 的函数赋给函数变量 f,这样调用函数变量 f 时,实际调用的就是 fire() 函数。

示例代码,

package main

import "fmt"

func fire() {
	fmt.Println("fire")
}

func main() {
	var f func()
	f = fire
	f()
}

输出结果,

fire

Go语言支持匿名函数,即在需要使用函数时再定义函数,匿名函数没有函数名只有函数体,函数可以作为一种类型被赋值给函数类型的变量,匿名函数也往往以变量方式传递,这与C语言的回调函数比较类似,不同的是,Go语言支持随时在代码里定义匿名函数。

示例代码,

package main

import "fmt"

func main() {
	func(data int) {
		fmt.Println("hello", data)
	}(100)
}

输出结果,

hello 100

示例代码,

package main

import "fmt"

func main() {
	// 将匿名函数体保存到f()中
	f := func(data int) {
		fmt.Println("hello", data)
	}

	// 使用f()调用
	f(100)
}

输出结果,

hello 100

匿名函数用作回调函数。下面的代码实现对切片的遍历操作,遍历中访问每个元素的操作使用匿名函数来实现,用户传入不同的匿名函数体可以实现对元素不同的遍历操作。

示例代码,

package main

import (
    "fmt"
)

// 遍历切片的每个元素, 通过给定函数进行元素访问
func visit(list []int, f func(int)) {

    for _, v := range list {
        f(v)
    }
}

func main() {

    // 使用匿名函数打印切片内容
    visit([]int{1, 2, 3, 4}, func(v int) {
        fmt.Println(v)
    })
}

输出结果,

1
2
3
4

使用匿名函数实现操作封装。下面这段代码将匿名函数作为 map 的键值,通过命令行参数动态调用匿名函数。

示例代码,

package main

import (
    "flag"
    "fmt"
)

var skillParam = flag.String("skill", "", "skill to perform")

func main() {

    flag.Parse()

    var skill = map[string]func(){
        "fire": func() {
            fmt.Println("chicken fire")
        },
        "run": func() {
            fmt.Println("soldier run")
        },
        "fly": func() {
            fmt.Println("angel fly")
        },
    }

    if f, ok := skill[*skillParam]; ok {
        f()
    } else {
        fmt.Println("skill not found")
    }

}

输出结果,

$ go run main.go --skill=fly
angel fly

$ go run main.go --skill=run
soldier run 

函数和其他类型一样都属于“一等公民”,其他类型能够实现接口,函数也可以,本节将对结构体与函数实现接口的过程进行对比。

示例代码,

package main

import (
    "fmt"
)

// 调用器接口
type Invoker interface {
    // 需要实现一个Call方法
    Call(interface{})
}

// 结构体类型
type Struct struct {
}

// 实现Invoker的Call
func (s *Struct) Call(p interface{}) {
    fmt.Println("from struct", p)
}

// 函数定义为类型
type FuncCaller func(interface{})

// 实现Invoker的Call
func (f FuncCaller) Call(p interface{}) {

    // 调用f函数本体
    f(p)
}

func main() {

    // 声明接口变量
    var invoker Invoker

    // 实例化结构体
    s := new(Struct)

    // 将实例化的结构体赋值到接口
    invoker = s

    // 使用接口调用实例化结构体的方法Struct.Call
    invoker.Call("hello")

    // 将匿名函数转为FuncCaller类型,再赋值给接口
    invoker = FuncCaller(func(v interface{}) {
        fmt.Println("from function", v)
    })

    // 使用接口调用FuncCaller.Call,内部会调用函数本体
    invoker.Call("hello")
}

输出结果,

from struct hello
from function hello

计算函数执行实际

要得到函数的运行时间,最简单的办法就是在函数执行之前设置一个起始时间,并在函数运行结束时获取从起始时间到现在的时间间隔,这个时间间隔就是函数的运行时间。

在Go语言中我们可以使用 time 包中的 Since() 函数来获取函数的运行时间,Go语言官方文档中对 Since() 函数的介绍是这样的。

func Since(t Time) Duration

Since() 函数返回从 t 到现在经过的时间,等价于time.Now().Sub(t)

示例代码,

package main

import (
	"fmt"
	"time"
)

func test() {
	start := time.Now() // 获取当前时间
	sum := 0
	for i := 0; i < 100000000; i++ {
		sum++
	}
	elapsed := time.Since(start)
	// elapsed := time.Now().Sub(start)
	fmt.Println("该函数执行完成耗时:", elapsed)
}

func main() {
	test()
}

执行结果,

该函数执行完成耗时: 9.3518ms

闭包(Closure)

Go语言中闭包是引用了自由变量的函数,被引用的自由变量和函数一同存在,即使已经离开了自由变量的环境也不会被释放或者删除,在闭包中可以继续使用这个自由变量。

函数 + 引用环境 = 闭包

闭包对它作用域上部的变量可以进行修改,修改引用的变量会对变量进行实际修改。

示例代码,

package main

import "fmt"

func main() {
	// 准备一个字符串
	str := "hello world"

	// 创建一个匿名函数
	foo := func() {

		// 匿名函数中访问str
		str = "hello dude"
	}

	// 调用匿名函数
	foo()

	fmt.Println(str)
}

输出结果,

hello dude

被捕获到闭包中的变量让闭包本身拥有了记忆效应,闭包中的逻辑可以修改闭包捕获的变量,变量会跟随闭包生命期一直存在,闭包本身就如同变量一样拥有了记忆效应。

package main

import (
	"fmt"
)

// Accumulate 提供一个值, 每次调用函数会指定对值进行累加
func Accumulate(value int) func() int {

	// 返回一个闭包
	return func() int {

		// 累加
		value++

		// 返回一个累加值
		return value
	}
}

func main() {

	// 创建一个累加器, 初始值为1
	accumulator := Accumulate(1)

	// 累加1并打印
	fmt.Println(accumulator())

	fmt.Println(accumulator())

	// 打印累加器的函数地址
	fmt.Printf("%p\n", &accumulator)

	// 创建一个累加器, 初始值为1
	accumulator2 := Accumulate(10)

	// 累加1并打印
	fmt.Println(accumulator2())

	// 打印累加器的函数地址
	fmt.Printf("%p\n", &accumulator2)
}

输出结果,

2
3
0xc00000a028
11
0xc00000a038

闭包的记忆效应被用于实现类似于设计模式中工厂模式的生成器,下面的例子展示了创建一个玩家生成器的过程。

package main

import (
	"fmt"
)

// 创建一个玩家生成器, 输入名称, 输出生成器
func playerGen(name string) func() (string, int) {

	// 血量一直为150
	hp := 150

	// 返回创建的闭包
	return func() (string, int) {

		// 将变量引用到闭包中
		return name, hp
	}
}

func main() {

	// 创建一个玩家生成器
	generator := playerGen("high noon")

	// 返回玩家的名字和血量
	name, hp := generator()

	// 打印值
	fmt.Println(name, hp)
}

输出结果,

high noon 150

可变参数(变参函数)

可变参数是指函数传入的参数个数是可变的,为了做到这点,首先需要将函数定义为可以接受可变参数的类型。

示例代码,

func myfunc(args ...int) {
    for _, arg := range args {
        fmt.Println(arg)
    }
}

上面的例子中将可变参数类型约束为 int,如果你希望传任意类型,可以指定类型为 interface{},下面是Go语言标准库中 fmt.Printf() 的函数原型。

func Printf(format string, args ...interface{}) {
    // ...
}

示例代码,

package main

import (
	"fmt"
)

func MyPrintf(args ...interface{}) {
	for _, arg := range args {
		switch arg.(type) {
		case int:
			fmt.Println(arg, "is an int value.")
		case string:
			fmt.Println(arg, "is a string value.")
		case int64:
			fmt.Println(arg, "is an int64 value.")
		case float32:
			fmt.Println(arg, "is an float32 value.")
		case float64:
			fmt.Println(arg, "is an float64 value.")
		default:
			fmt.Println(arg, "is an unknown type.")
		}
	}
}

func main() {
	var v1 int = 1
	var v2 int64 = 234
	var v3 string = "hello"
	var v4 float32 = 1.234
	MyPrintf(v1, v2, v3, v4)
}

输出结果,

1 is an int value.
234 is an int64 value.
hello is a string value.
1.234 is an float32 value.

可变参数列表的数量不固定,传入的参数是一个切片,如果需要获得每一个参数的具体值时,可以对可变参数变量进行遍历。

package main

import (
	"bytes"
	"fmt"
)

// 定义一个函数, 参数数量为0~n, 类型约束为字符串
func joinStrings(slist ...string) string {

	// 定义一个字节缓冲, 快速地连接字符串
	var b bytes.Buffer
	// 遍历可变参数列表slist, 类型为[]string
	for _, s := range slist {
		// 将遍历出的字符串连续写入字节数组
		b.WriteString(s)
	}

	// 将连接好的字节数组转换为字符串并输出
	return b.String()
}

func main() {
	// 输入3个字符串, 将它们连成一个字符串
	fmt.Println(joinStrings("pig ", "and", " rat"))
	fmt.Println(joinStrings("hammer", " mom", " and", " hawk"))
}

输出结果,

pig and rat
hammer mom and hawk

当可变参数为 interface{} 类型时,可以传入任何类型的值,此时,如果需要获得变量的类型,可以通过 switch 获得变量的类型,下面的代码演示将一系列不同类型的值传入 printTypeValue() 函数,该函数将分别为不同的参数打印它们的值和类型的详细描述。

示例代码,

package main

import (
	"bytes"
	"fmt"
)

func printTypeValue(slist ...interface{}) string {

	// 字节缓冲作为快速字符串连接
	var b bytes.Buffer

	// 遍历参数
	for _, s := range slist {

		// 将interface{}类型格式化为字符串
		str := fmt.Sprintf("%v", s)

		// 类型的字符串描述
		var typeString string

		// 对s进行类型断言
		switch s.(type) {
		case bool: // 当s为布尔类型时
			typeString = "bool"
		case string: // 当s为字符串类型时
			typeString = "string"
		case int: // 当s为整型类型时
			typeString = "int"
		}

		// 写字符串前缀
		b.WriteString("value: ")

		// 写入值
		b.WriteString(str)

		// 写类型前缀
		b.WriteString(" type: ")

		// 写类型字符串
		b.WriteString(typeString)

		// 写入换行符
		b.WriteString("\n")

	}
	return b.String()
}

func main() {

	// 将不同类型的变量通过printTypeValue()打印出来
	fmt.Println(printTypeValue(100, "str", true))
}

输出结果,

value: 100 type: int
value: str type: string
value: true type: bool

可变参数变量是一个包含所有参数的切片,如果要将这个含有可变参数的变量传递给下一个可变参数函数,可以在传递时给可变参数变量后面添加...,这样就可以将切片中的元素进行传递,而不是传递可变参数变量本身。

package main

import "fmt"

// 实际打印的函数
func rawPrint(rawList ...interface{}) {

	// 遍历可变参数切片
	for _, a := range rawList {

		// 打印参数
		fmt.Println(a)
	}
}

// 打印函数封装
func printList(slist ...interface{}) {

	// 将slist可变参数切片完整传递给下一个函数
	rawPrint(slist...)
}

func main() {

	printList(1, 2, 3)
}

输出结果,

1
2
3

defer(延迟执行语句)

Go语言的 defer 语句会将其后面跟随的语句进行延迟处理,在 defer 归属的函数即将返回时,将延迟处理的语句按 defer 的逆序进行执行,也就是说,先被 defer 的语句最后被执行,最后被 defer 的语句,最先被执行。

示例代码,

package main

import (
	"fmt"
)

func main() {

	fmt.Println("defer begin")

	// 将defer放入延迟调用栈
	defer fmt.Println(1)

	defer fmt.Println(2)

	// 最后一个放入, 位于栈顶, 最先调用
	defer fmt.Println(3)

	fmt.Println("defer end")
}

输出结果,

defer begin
defer end
3
2
1

处理业务或逻辑中涉及成对的操作是一件比较烦琐的事情,比如打开和关闭文件、接收请求和回复请求、加锁和解锁等。在这些操作中,最容易忽略的就是在每个函数退出处正确地释放和关闭资源。

defer 语句正好是在函数退出时执行的语句,所以使用 defer 能非常方便地处理资源释放问题。

使用延迟并发解锁,示例代码,

var (
    // 一个演示用的映射
    valueByKey      = make(map[string]int)
    // 保证使用映射时的并发安全的互斥锁
    valueByKeyGuard sync.Mutex
)

func readValue(key string) int {
    valueByKeyGuard.Lock()
   
    // defer后面的语句不会马上调用, 而是延迟到函数结束时调用
    defer valueByKeyGuard.Unlock()
    return valueByKey[key]
}

使用延迟释放文件句柄,示例代码,

func fileSize(filename string) int64 {

    f, err := os.Open(filename)

    if err != nil {
        return 0
    }

    // 延迟调用Close, 此时Close不会被调用
    defer f.Close()

    info, err := f.Stat()

    if err != nil {
        // defer机制触发, 调用Close关闭文件
        return 0
    }

    size := info.Size()

    // defer机制触发, 调用Close关闭文件
    return size
}

指针

指针是一种特殊类型,它的实例讲保存被引用对象的内存地址,而不是对象自身的值。

指针变量在声明后如果未获得有效的内存地址,那么它的默认值就是nil(空引用)。

示例代码,

package main

import "fmt"

func main() {
	var p1 *bool
	fmt.Println("p1 的默认值:", p1)
	var p2 *int8
	fmt.Println("p2 的默认值:", p2)
	var p3 *string
	fmt.Println("p3 的默认值:", p3)
}

输出结果,

p1 的默认值: <nil>
p2 的默认值: <nil>
p3 的默认值: <nil>

在类型名称前面加上"*"(星号)运算符,就是对应的指针类型。

在变量名称前面加上"&"运算符,就能获得该变量值的内存地址。其运算结果为指向该变量类型的指针。

示例代码,

package main

import "fmt"

func main() {
	var k = "abcdefg"
	var pk = &k
		fmt.Println("k 的内存地址是:", pk)
}

输出结果,

k 的内存地址是: 0xc00008a260

new 函数

new 函数为指定的类型分配内存空间,并使用类型的默认值进行初始化,最后返回新分配内存空间的地址。

new 函数的定义如下,

func new(Type) *Type

代码示例,

package main

import "fmt"

func main() {
	var px *int16 = new(int16)
	fmt.Printf("px 指向的内存地址: %p\n", px)
	fmt.Printf("px 所指向对象的值: %v\n", *px)
}

输出结果,

px 指向的内存地址: 0xc00001c0c8
px 所指向对象的值: 0

以下几点需要注意,

  • new(String) 所分配的值并不是 nil,而是空字符串(“”)。

  • 结构体分配内存空间后,会为其字段分配默认值。

  • 接口类型使用 new 函数分配内存空间后,其默认值为 nil(空指针)。

代码示例,

package main

import "fmt"

func main() {
	type sender interface {
		LineOut(data []byte) int
	}

	var pi = new(sender)
	fmt.Println(*pi)
}

输出结果,

<nil>

iota 常量

iota 常量的定义如下,

const iota = 0

这是一个有特殊用途的常量,在批量定义常量的代码中(这些代码一般写在小括号中),如果常量 A 使用了 iota 常量作为基础整数值,那么在 A 之后定义的常量会自动累加。

代码示例,

package main

import "fmt"

const (
	A = iota
	B
	C
	D
)

func main() {
	fmt.Println("A:", A)
	fmt.Println("B:", B)
	fmt.Println("C:", C)
	fmt.Println("D:", D)
}

输出结果,

D:\Users\thinkpad\AppData\Local\Temp\GoLand\___go_build_99_Learn.exe
A: 0
B: 1
C: 2
D: 3

当 iota 出现在常量列表的首位置时,它的值为 0,但随着出现的位置不同,iota 常量的值也会改变。

以下述代码为例,当定义 W 常量时,iota 的值为 4(第五个变量,从 0 开始计算),iota + 3 使得 W 的值为 7。

package main

import "fmt"

const (
	S = 17
	T = 9
	U = iota
	V = iota
	W = iota + 3
	X = iota + 3
	Y = iota + 3
	Z = iota + 3
)

func main() {
	fmt.Println("S:", S)
	fmt.Println("T:", T)
	fmt.Println("U:", U)
	fmt.Println("V:", V)
	fmt.Println("W:", W)
	fmt.Println("X:", X)
	fmt.Println("Y:", Y)
	fmt.Println("Z:", Z)
}

输出结果,

S: 17
T: 9
U: 2
V: 3
W: 7
X: 8
Y: 9
Z: 10

协程

Go 语言的异步操作引入了协程(原单词为 goroutines)的概念,启动新的协程后异步执行指定的函数。

在调用函数的语句加上 go 关键字,就可以启动一个新的协程,并在其上执行调用的函数。

go someFunc(...)

或者通过调用匿名函数来启动新协程,

go func(...) {
    ...
}(...)

代码示例,

package main

import (
	"fmt"
	"time"
)

func printSpring() {
	fmt.Println("Spring")
}

func printSummer() {
	fmt.Println("Summer")
}

func printAutumn() {
	fmt.Println("Autumn")
}

func printWinter() {
	fmt.Println("Winter")
}

func main() {
	go printSpring()
	go printSummer()
	go printAutumn()
	go printWinter()
	time.Sleep(1 * time.Second)
}

输出结果(随机),

Autumn
Summer
Winter
Spring

通道

在 Go 的异步编程中,通道类型(channel,类型名称为 chan)既可以用于协程之间的数据通信,也可以用于协程之间的同步。

通道有以下三种表示方式,

  • chan type: 双向通道,既可以发送数据,也可以接收数据

  • chan<-type:只能向通道发送数据

  • <-chan type:只能从通道接收数据

通道对象的实例是通过 make 函数创建的,此函数可以创建切片、映射、通道类型的实例。

var c = make(chan string)

或者

var c = make(chan string, 0)

示例代码,

package main

import (
	"fmt"
)

var (
	A = make(chan int)
	B = make(chan int)
	C = make(chan int)
	D = make(chan int)
)

func printSpring() {
	fmt.Println("Spring")
	A <- 1
}

func printSummer() {
	<-A
	fmt.Println("Summer")
	B <- 1
}

func printAutumn() {
	<-B
	fmt.Println("Autumn")
	C <- 1
}

func printWinter() {
	<-C
	fmt.Println("Winter")
	D <- 1
}

func main() {
	go printSpring()
	go printSummer()
	go printAutumn()
	go printWinter()
	<-D
}

输出结果,

Spring
Summer
Autumn
Winter

无缓冲的通道要求发送与接收操作同时进行,向通道发送数据的同时必须有另外一个协程在接收。

带缓冲的通道的读与写可以不同时进行。若是通道中缓存的数据量已满,再次向通道发送数据就好阻塞,直到数据被接收为止。可以使用 for 循环来枚举通道中的所有值。

示例代码,

package main

import (
	"fmt"
	"time"
)

func main() {
	// 可缓冲5个值
	var ch = make(chan float32, 5)
	// 在新的协程上向通道发送数据
	go func() {
		ch <- 1.021
		// 等待3秒,继续发送
		time.Sleep(3 * time.Second)
		ch <- 3.07
		ch <- 0.589
		// 等待2秒,再发送
		time.Sleep(2 * time.Second)
		ch <- 17.11
		// 等待1秒再发送
		time.Sleep(time.Second)
		ch <- 0.035
		// 通道缓存数量已满
		// 等待2秒后,关闭通道
		time.Sleep(2 * time.Second)
		close(ch)
	}()

	for data := range ch {
		fmt.Println(data)
	}
}

输出结果,

1.021
3.07
0.589
17.11
0.035

下述代码未单向通道示例,

var ch1 = make(<- chan bool)
var ch2 = make(chan<- bool)

ch1 为单向通道实例,只能从通道接收数据,不能向通道发送数据;ch2 也是单向通道实例,只能向通道发送数据,不能接收数据。

直接在代码中使用单向通道没有意义,因为数据无法完成输入和输出。不过,要是用于代码封装,作为数据进出的间接通道,单向通道很合适。

接口

Go语言不是一种 “传统” 的面向对象编程语言:它里面没有类和继承的概念。

但是Go语言里有非常灵活的接口概念,通过它可以实现很多面向对象的特性。很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的。

接口声明的格式,

type 接口类型名 interface{
    方法名1( 参数列表1 ) 返回值列表1
    方法名2( 参数列表2 ) 返回值列表2
    …
}

接口被实现的条件,

  • 接口的方法与实现接口的类型方法格式一致

  • 接口中所有方法均被实现

示例代码,

package main

import (
	"fmt"
)

// 定义一个数据写入器
type DataWriter interface {
	WriteData(data interface{}) error

	// 能否写入
	//CanWrite() bool
}

// 定义文件结构,用于实现DataWriter
type file struct {
}

// 实现DataWriter接口的WriteData方法
func (d *file) WriteData(data interface{}) error {

	// 模拟写入数据
	fmt.Println("WriteData:", data)
	return nil
}

func main() {

	// 实例化file
	f := new(file)

	// 声明一个DataWriter的接口
	var writer DataWriter

	// 将接口赋值f,也就是*file类型
	writer = f

	// 使用DataWriter接口进行数据写入
	writer.WriteData("data")
}

输出结果,

WriteData: data

一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。也就是说,使用者并不关心某个接口的方法是通过一个类型完全实现的,还是通过多个结构嵌入到一个结构体中拼凑起来共同实现的。

示例代码,

package main

import "fmt"

// 一个服务需要满足能够开启和写日志的功能
type Service interface {
	Start()     // 开启服务
	Log(string) // 日志输出
}

// 日志器
type Logger struct {
}

// 实现Service的Log()方法
func (g *Logger) Log(l string) {
	fmt.Println(l)
}

// 游戏服务
type GameService struct {
	Logger // 嵌入日志器
}

// 实现Service的Start()方法
func (g *GameService) Start() {
}

func main() {
	var s Service = new(GameService)
	s.Start()
	s.Log("hello")
}

输出结果,

hello

类型断言

类型断言(Type Assertion)是一个使用在接口值上的操作,用于检查接口类型变量所持有的值是否实现了期望的接口或者具体的类型。

在Go语言中类型断言的语法格式如下:

value, ok := x.(T)

示例代码,

package main

import "fmt"

func main() {
	var x interface{}
	x = 10
	value, ok := x.(int)
	fmt.Println(value, ",", ok)
}

输出结果,

10 , true

类型断言还可以配合 switch 使用,示例代码,

package main

import "fmt"

func main() {
	var a int
	a = 10
	getType(a)
}

func getType(a interface{}) {
	switch a.(type) {
	case int:
		fmt.Println("the type of a is int")
	case string:
		fmt.Println("the type of a is string")
	case float64:
		fmt.Println("the type of a is float")
	default:
		fmt.Println("unknown type")
	}
}

输出结果,

the type of a is int

sort.Interface接口

sort 包内置的提供了根据一些排序函数来对任何序列排序的功能。

Go语言的 sort.Sort 函数不会对具体的序列和它的元素做任何假设。相反,它使用了一个接口类型 sort.Interface 来指定通用的排序算法和可能被排序到的序列类型之间的约定。这个接口的实现由序列的具体表示和它希望排序的元素决定,序列的表示经常是一个切片。

一个内置的排序算法需要知道三个东西:序列的长度,表示两个元素比较的结果,一种交换两个元素的方式;这就是 sort.Interface 的三个方法,

package sort
type Interface interface {
    Len() int            // 获取元素数量
    Less(i, j int) bool // i,j是序列元素的指数。
    Swap(i, j int)        // 交换元素
}

示例代码,

package main

import (
    "fmt"
    "sort"
)

// 将[]string定义为MyStringList类型
type MyStringList []string

// 实现sort.Interface接口的获取元素数量方法
func (m MyStringList) Len() int {
    return len(m)
}

// 实现sort.Interface接口的比较元素方法
func (m MyStringList) Less(i, j int) bool {
    return m[i] < m[j]
}

// 实现sort.Interface接口的交换元素方法
func (m MyStringList) Swap(i, j int) {
    m[i], m[j] = m[j], m[i]
}

func main() {

    // 准备一个内容被打乱顺序的字符串切片
    names := MyStringList{
        "3. Triple Kill",
        "5. Penta Kill",
        "2. Double Kill",
        "4. Quadra Kill",
        "1. First Blood",
    }

    // 使用sort包进行排序
    sort.Sort(names)

    // 遍历打印结果
    for _, v := range names {
            fmt.Printf("%s\n", v)
    }

}

输出结果,

1. First Blood
2. Double Kill
3. Triple Kill
4. Quadra Kill
5. Penta Kill

通过实现 sort.Interface 接口的排序过程具有很强的可定制性,可以根据被排序对象比较复杂的特性进行定制。例如,需要多种排序逻辑的需求就适合使用 sort.Interface 接口进行排序。但大部分情况中,只需要对字符串、整型等进行快速排序。Go语言中提供了一些固定模式的封装以方便开发者迅速对内容进行排序。

sort 包中的 StringSlice 的代码与 MyStringList 的实现代码几乎一样。因此,只需要使用 sort 包的 StringSlice 就可以更简单快速地进行字符串排序。

示例代码,

package main

import (
	"fmt"
	"sort"
)

func main() {
	names := sort.StringSlice{
		"3. Triple Kill",
		"5. Penta Kill",
		"2. Double Kill",
		"4. Quadra Kill",
		"1. First Blood",
	}

	sort.Sort(names)
	
	for _, v := range names {
		fmt.Printf("%s\n", v)
	}
}

输出结果,

1. First Blood
2. Double Kill
3. Triple Kill
4. Quadra Kill
5. Penta Kill

除了字符串可以使用 sort 包进行便捷排序外,还可以使用 sort.IntSlice 进行整型切片的排序。

示例代码,

package main

import (
	"fmt"
	"sort"
)

func main() {
	names := []string{
		"3. Triple Kill",
		"5. Penta Kill",
		"2. Double Kill",
		"4. Quadra Kill",
		"1. First Blood",
	}

	sort.Strings(names)

	for _, v := range names {
		fmt.Printf("%s\n", v)
	}
}

输出结果,

1. First Blood
2. Double Kill
3. Triple Kill
4. Quadra Kill
5. Penta Kill

Go语言中的 sort 包中定义了一些常见类型的排序方法,如下表所示。

类 型实现 sort.lnterface 的类型直接排序方法说 明
字符串(String)StringSlicesort.Strings(a [] string)字符 ASCII 值升序
整型(int)IntSlicesort.Ints(a []int)数值升序
双精度浮点(float64)Float64Slicesort.Float64s(a []float64)数值升序

编程中经常用到的 int32、int64、float32、bool 类型并没有由 sort 包实现,使用时依然需要开发者自己编写。

除了基本类型的排序,也可以对结构体进行排序。结构体比基本类型更为复杂,排序时不能像数值和字符串一样拥有一些固定的单一原则。结构体的多个字段在排序中可能会存在多种排序的规则,例如,结构体中的名字按字母升序排列,数值按从小到大的顺序排序。一般在多种规则同时存在时,需要确定规则的优先度,如先按名字排序,再按年龄排序等。

示例代码,

package main

import (
    "fmt"
    "sort"
)

// 声明英雄的分类
type HeroKind int

// 定义HeroKind常量, 类似于枚举
const (
    None HeroKind = iota
    Tank
    Assassin
    Mage
)

// 定义英雄名单的结构
type Hero struct {
    Name string  // 英雄的名字
    Kind HeroKind  // 英雄的种类
}

// 将英雄指针的切片定义为Heros类型
type Heros []*Hero

// 实现sort.Interface接口取元素数量方法
func (s Heros) Len() int {
    return len(s)
}

// 实现sort.Interface接口比较元素方法
func (s Heros) Less(i, j int) bool {

    // 如果英雄的分类不一致时, 优先对分类进行排序
    if s[i].Kind != s[j].Kind {
        return s[i].Kind < s[j].Kind
    }

    // 默认按英雄名字字符升序排列
    return s[i].Name < s[j].Name
}

// 实现sort.Interface接口交换元素方法
func (s Heros) Swap(i, j int) {
    s[i], s[j] = s[j], s[i]
}

func main() {

    // 准备英雄列表
    heros := Heros{
        &Hero{"吕布", Tank},
        &Hero{"李白", Assassin},
        &Hero{"妲己", Mage},
        &Hero{"貂蝉", Assassin},
        &Hero{"关羽", Tank},
        &Hero{"诸葛亮", Mage},
    }

    // 使用sort包进行排序
    sort.Sort(heros)

    // 遍历英雄列表打印排序结果
    for _, v := range heros {
        fmt.Printf("%+v\n", v)
    }
}

输出结果,

&{Name:关羽 Kind:1}
&{Name:吕布 Kind:1}
&{Name:李白 Kind:2}
&{Name:貂蝉 Kind:2}
&{Name:妲己 Kind:3}
&{Name:诸葛亮 Kind:3}

从 Go 1.8 开始,Go语言在 sort 包中提供了 sort.Slice() 函数进行更为简便的排序方法。sort.Slice() 函数只要求传入需要排序的数据,以及一个排序时对元素的回调函数,类型为 func(i,j int)bool,sort.Slice() 函数的定义如下,

func Slice(slice interface{}, less func(i, j int) bool)

示例代码,

package main

import (
	"fmt"
	"sort"
)

type HeroKind int

const (
	None = iota
	Tank
	Assassin
	Mage
)

type Hero struct {
	Name string
	Kind HeroKind
}

func main() {

	heros := []*Hero{
		{"吕布", Tank},
		{"李白", Assassin},
		{"妲己", Mage},
		{"貂蝉", Assassin},
		{"关羽", Tank},
		{"诸葛亮", Mage},
	}

	sort.Slice(heros, func(i, j int) bool {
		if heros[i].Kind != heros[j].Kind {
			return heros[i].Kind < heros[j].Kind
		}

		return heros[i].Name < heros[j].Name
	})

	for _, v := range heros {
		fmt.Printf("%+v\n", v)
	}
}

输出结果,

&{Name:关羽 Kind:1}
&{Name:吕布 Kind:1}
&{Name:李白 Kind:2}
&{Name:貂蝉 Kind:2}
&{Name:妲己 Kind:3}
&{Name:诸葛亮 Kind:3}

处理运行时错误

Go语言的错误处理思想及设计包含以下特征:

  • 一个可能造成错误的函数,需要返回值中返回一个错误接口(error),如果调用是成功的,错误接口将返回 nil,否则返回错误。

  • 在函数调用后需要检查错误,如果发生错误,则进行必要的错误处理。

Go语言没有类似 Java 或 .NET 中的异常处理机制,虽然可以使用 defer、panic、recover 模拟,但官方并不主张这样做,Go语言的设计者认为其他语言的异常机制已被过度使用,上层逻辑需要为函数发生的异常付出太多的资源,同时,如果函数使用者觉得错误处理很麻烦而忽略错误,那么程序将在不可预知的时刻崩溃。

Go语言希望开发者将错误处理视为正常开发必须实现的环节,正确地处理每一个可能发生错误的函数,同时,Go语言使用返回值返回错误的机制,也能大幅降低编译器、运行时处理错误的复杂度,让开发者真正地掌握错误的处理。

示例代码,

package main

import (
	"errors"
	"fmt"
)

// 定义除数为0的错误
var errDivisionByZero = errors.New("division by zero")

func div(dividend, divisor int) (int, error) {

	// 判断除数为0的情况并返回
	if divisor == 0 {
		return 0, errDivisionByZero
	}

	// 正常计算,返回空错误
	return dividend / divisor, nil
}

func main() {

	fmt.Println(div(1, 0))
}

输出结果,

0 division by zero

使用 errors.New 定义的错误字符串的错误类型是无法提供丰富的错误信息的,那么,如果需要携带错误信息返回,就需要借助自定义结构体实现错误接口。

下面代码将实现一个解析错误(ParseError),这种错误包含两个内容,分别是文件名和行号,解析错误的结构还实现了 error 接口的 Error() 方法,返回错误描述时,就需要将文件名和行号返回。

示例代码,

package main

import (
	"fmt"
)

// 声明一个解析错误
type ParseError struct {
	Filename string // 文件名
	Line     int    // 行号
}

// 实现error接口,返回错误描述
func (e *ParseError) Error() string {
	return fmt.Sprintf("%s:%d", e.Filename, e.Line)
}

// 创建一些解析错误
func newParseError(filename string, line int) error {
	return &ParseError{filename, line}
}

func main() {

	var e error
	// 创建一个错误实例,包含文件名和行号
	e = newParseError("main.go", 1)

	// 通过error接口查看错误描述
	fmt.Println(e.Error())

	// 根据错误接口具体的类型,获取详细错误信息
	switch detail := e.(type) {
	case *ParseError: // 这是一个解析错误
		fmt.Printf("Filename: %s Line: %d\n", detail.Filename, detail.Line)
	default: // 其他类型的错误
		fmt.Println("other error")
	}
}

输出结果,

main.go:1
Filename: main.go Line: 1

宕机(panic)——程序终止运行

Go语言的类型系统会在编译时捕获很多错误,但有些错误只能在运行时检查,如数组访问越界、空指针引用等,这些运行时错误会引起宕机。

宕机不是一件很好的事情,可能造成体验停止、服务中断,就像没有人希望在取钱时遇到 ATM 机蓝屏一样,但是,如果在损失发生时,程序没有因为宕机而停止,那么用户将会付出更大的代价,这种代价可以是金钱、时间甚至生命,因此,宕机有时也是一种合理的止损方法。

一般而言,当宕机发生时,程序会中断运行,并立即执行在该 goroutine(可以先理解成线程)中被延迟的函数(defer 机制),随后,程序崩溃并输出日志信息,日志信息包括 panic value 和函数调用的堆栈跟踪信息,panic value 通常是某种错误信息。

对于每个 goroutine,日志信息中都会有与之相对的,发生 panic 时的函数调用堆栈跟踪信息,通常,我们不需要再次运行程序去定位问题,日志信息已经提供了足够的诊断依据,因此,在我们填写问题报告时,一般会将宕机和日志信息一并记录。

虽然Go语言的 panic 机制类似于其他语言的异常,但 panic 的适用场景有一些不同,由于 panic 会引起程序的崩溃,因此 panic 一般用于严重错误,如程序内部的逻辑不一致。任何崩溃都表明了我们的代码中可能存在漏洞,所以对于大部分漏洞,我们应该使用Go语言提供的错误机制,而不是 panic。

Go语言可以在程序中手动触发宕机,让程序崩溃,这样开发者可以及时地发现错误,同时减少可能的损失。

Go语言程序在宕机时,会将堆栈和 goroutine 信息输出到控制台,所以宕机也可以方便地知晓发生错误的位置,那么我们要如何触发宕机呢,示例代码如下,

package main

func main() {
    panic("crash")
}

输出结果,

panic: crash

goroutine 1 [running]:
main.main()
        D:/workspace/pro-go/99-Learn/main.go:4 +0x27

当 panic() 触发的宕机发生时,panic() 后面的代码将不会被运行,但是在 panic() 函数前面已经运行过的 defer 语句依然会在宕机发生时发生作用,示例代码如下,

package main

import "fmt"

func main() {
	defer fmt.Println("宕机后要做的事情1")
	defer fmt.Println("宕机后要做的事情2")
	panic("宕机")
}

输出结果,

宕机后要做的事情2
宕机后要做的事情1
panic: 宕机

goroutine 1 [running]:
main.main()
        D:/workspace/pro-go/99-Learn/main.go:8 +0xac

Process finished with the exit code 2

宕机恢复(recover)——防止程序崩溃

Recover 是一个Go语言的内建函数,可以让进入宕机流程中的 goroutine 恢复过来,recover 仅在延迟函数 defer 中有效,在正常的执行过程中,调用 recover 会返回 nil 并且没有其他任何效果,如果当前的 goroutine 陷入恐慌,调用 recover 可以捕获到 panic 的输入值,并且恢复正常的执行。

通常来说,不应该对进入 panic 宕机的程序做任何处理,但有时,需要我们可以从宕机中恢复,至少我们可以在程序崩溃前,做一些操作,举个例子,当 web 服务器遇到不可预料的严重问题时,在崩溃前应该将所有的连接关闭,如果不做任何处理,会使得客户端一直处于等待状态,如果 web 服务器还在开发阶段,服务器甚至可以将异常信息反馈到客户端,帮助调试。

提示:在其他语言里,宕机往往以异常的形式存在,底层抛出异常,上层逻辑通过 try/catch 机制捕获异常,没有被捕获的严重异常会导致宕机,捕获的异常可以被忽略,让代码继续运行。Go语言没有异常系统,其使用 panic 触发宕机类似于其他语言的抛出异常,recover 的宕机恢复机制就对应其他语言中的 try/catch 机制。

下面的代码实现了 ProtectRun() 函数,该函数传入一个匿名函数或闭包后的执行函数,当传入函数以任何形式发生 panic 崩溃后,可以将崩溃发生的错误打印出来,同时允许后面的代码继续运行,不会造成整个进程的崩溃。

示例代码,

package main

import (
	"fmt"
	"runtime"
)

// 崩溃时需要传递的上下文信息
type panicContext struct {
	function string // 所在函数
}

// 保护方式允许一个函数
func ProtectRun(entry func()) {

	// 延迟处理的函数
	defer func() {

		// 发生宕机时,获取panic传递的上下文并打印
		err := recover()

		switch err.(type) {
		case runtime.Error: // 运行时错误
			fmt.Println("runtime error:", err)
		default: // 非运行时错误
			fmt.Println("error:", err)
		}

	}()

	entry()

}

func main() {
	fmt.Println("运行前")

	// 允许一段手动触发的错误
	ProtectRun(func() {

		fmt.Println("手动宕机前")

		// 使用panic传递上下文
		panic(&panicContext{
			"手动触发panic",
		})

		fmt.Println("手动宕机后")
	})

	// 故意造成空指针访问错误
	ProtectRun(func() {

		fmt.Println("赋值宕机前")

		var a *int
		*a = 1

		fmt.Println("赋值宕机后")
	})

	fmt.Println("运行后")
}

运行结果,

运行前
手动宕机前
error: &{手动触发panic}
赋值宕机前
runtime error: runtime error: invalid memory address or nil pointer dereference
运行后

Process finished with the exit code 0

panic 和 recover 的组合有如下特性:

  • 有 panic 没 recover,程序宕机。

  • 有 panic 也有 recover,程序不会宕机,执行完对应的 defer 后,从宕机点退出当前函数后继续执行。

提示:虽然 panic/recover 能模拟其他语言的异常机制,但并不建议在编写普通函数时也经常性使用这种特性。

在 panic 触发的 defer 函数内,可以继续调用 panic,进一步将错误外抛,直到程序整体崩溃。

如果想在捕获错误时设置当前函数的返回值,可以对返回值使用命名返回值方式直接进行设置。

Test功能测试函数

Go语言自带了 testing 测试包,可以进行自动化的单元测试,输出结果验证,并且可以测试性能。

Go语言的 testing 包提供了三种测试方式,分别是单元(功能)测试、性能(压力)测试和覆盖率测试。

要开始一个单元测试,需要准备一个 go 源码文件,在命名文件时文件名必须以_test.go结尾,单元测试源码文件可以由多个测试用例(可以理解为函数)组成,每个测试用例的名称需要以 Test 为前缀,例如,

func TestXxx( t *testing.T ){
    //......
}

编写测试用例有以下几点需要注意:

  • 测试用例文件不会参与正常源码的编译,不会被包含到可执行文件中;

  • 测试用例的文件名必须以_test.go结尾;

  • 需要使用 import 导入 testing 包;

  • 测试函数的名称要以TestBenchmark开头,后面可以跟任意字母组成的字符串,但第一个字母必须大写,例如 TestAbc(),一个测试用例文件中可以包含多个测试函数;

  • 单元测试则以(t *testing.T)作为参数,性能测试以(t *testing.B)做为参数;

  • 测试用例文件使用go test命令来执行,源码中不需要 main() 函数作为入口,所有以_test.go结尾的源码文件内以Test开头的函数都会自动执行。

示例代码,

demo.go

package demo

// 根据长宽获取面积
func GetArea(weight int, height int) int {
	return weight * height
}

demo_test.go

package demo

import "testing"

func TestGetArea(t *testing.T) {
	area := GetArea(40, 50)
	if area != 2000 {
		t.Error("测试失败")
	}
}

func BenchmarkGetArea(t *testing.B) {
	for i := 0; i < t.N; i++ {
		GetArea(40, 50)
	}
}

单元(功能)测试,

$ go test -v        
=== RUN   TestGetArea
--- PASS: TestGetArea (0.00s)
PASS
ok      99-Learn/demo   0.136s

性能(压力)测试,

$ go test -bench="."
goos: windows
goarch: amd64
pkg: 99-Learn/demo
cpu: 13th Gen Intel(R) Core(TM) i9-13900KF
BenchmarkGetArea-32     1000000000               0.09503 ns/op
PASS
ok      99-Learn/demo   0.270s

覆盖率测试,

$ go test -cover
PASS
coverage: 100.0% of statements
ok      99-Learn/demo   0.145s

未完待续!

 类似资料: