前言

golang与传统相比,有许多有趣的特性。它们做到例如降低耦合、提高代码灵活性的效果。在学习过程中我总结了以下一些特性,它们体现了Go箴言的思想:

简单,诗意,简洁

  • 不要通过共享内存进行通信,通过通信共享内存

Don’t communicate by sharing memory, share memory by communicating.

  • 并发不是并行

Concurrency is not parallelism.

  • 通道编排;互斥体序列化

Channels orchestrate; mutexes serialize.

  • 接口越大,抽象就越弱

The bigger the interface, the weaker the abstraction.

  • 使零值有用

Make the zero value useful.

  • interface {}什么也没说

interface{} says nothing.

  • Gofmt的风格不是人们最喜欢的,但gofmt是每个人的最爱

Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite.

  • 一点点复制比一点点依赖更好

A little copying is better than a little dependency.

  • 系统调用必须始终使用构建标记进行保护

Syscall must always be guarded with build tags.

  • 必须始终使用构建标记保护Cgo

Cgo must always be guarded with build tags.

  • Cgo不是Go

Cgo is not Go.

  • 对于不安全的package,没有任何保证

With the unsafe package there are no guarantees.

  • 清楚比聪明更好

Clear is better than clever.

  • 反射永远不清晰

Reflection is never clear.

  • 错误就是价值观

Errors are values.

  • 不要只检查错误,还要优雅地处理它们

Don’t just check errors, handle them gracefully.

  • 设计架构,命名组件,记录细节

Design the architecture, name the components, document the details.

  • 文档是供用户使用的

Documentation is for users.

  • 不要恐慌

Don’t panic.

函数控制

defer函数

在 Go 中,defer 语句会推迟函数(包括任何参数)的运行,直到包含 defer 语句的函数完成。 通常情况下,当你想要避免忘记任务(例如关闭文件或运行清理进程)时,可以推迟某个函数的运行。

可以根据需要推迟任意多个函数。 defer 语句按逆序运行,先运行最后一个,最后运行第一个。这说明defer使用的可能是类似于栈的数据结构来存储推迟运行的函数。

panic函数

运行时错误会使 Go 程序崩溃,例如尝试通过使用超出范围的索引或取消引用 nil 指针来访问数组。 你也可以强制程序崩溃。

当你使用 panic 调用时,程序会崩溃,但是defer添加的延迟函数会运行。 进程会在堆栈中继续,直到所有函数都返回。 然后,程序会崩溃并记录日志消息。 此消息包含错误信息和堆栈跟踪,有助于诊断问题的根本原因。

调用 panic() 函数时,可以添加任何值作为参数。 通常,你会发送一条错误消息,说明为什么会进入紧急状态。

什么时候通过显式调用 panic 函数触发 panic?

虽然 panic 可以使程序崩溃,我们尽量少用 panic,但是少用不等于不用。阅读过 golang 源码的读者应该发现在 golang 标准库代码中有显式调用 panic 函数的代码片段,比如 golang 标准库的 json 包。

请参阅在 encode.go 文件中 encodeState 类型的 error 和 marshal 方法的代码。

另外,当我们在程序中处理会影响程序正确运行的错误时,也可以考虑使用显式调用 panic 函数来返回错误。

不管是显式调用 panic 函数,还是运行时检测到违法情况自动触发 panic,都会导致程序崩溃。那么,我们应该怎么处理 panic 呢?

通常的做法是使用 defer 和 recover 捕获 panic,将 panic 错误写入日志文件,将程序恢复正常执行。需要注意的是,panic 是谁触发谁捕获,当我们调用三方库时,调用方是不会考虑处理三方库的 panic 异常。

但是,对于一些严重的 panic 异常,例如 main 函数和 init 函数中执行的程序代码,不应该使用 recover 捕获并将程序恢复正常执行,而是应该及时让 panic 执行,使程序崩溃,及时暴露出问题并解决。

例如,下面的代码将 panicdefer 函数组合在一起。 尝试运行此代码以了解控制流的中断。 请注意,清理过程仍会运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import "fmt"

func highlow(high int, low int) {
if high < low {
fmt.Println("Panic!")
panic("highlow() low greater than high")
}
defer fmt.Println("Deferred: highlow(", high, ",", low, ")")
fmt.Println("Call: highlow(", high, ",", low, ")")

highlow(high, low + 1)
}

func main() {
highlow(2, 0)
fmt.Println("Program finished successfully!")
}

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Call: highlow( 2 , 0 )
Call: highlow( 2 , 1 )
Call: highlow( 2 , 2 )
Panic!
Deferred: highlow( 2 , 2 )
Deferred: highlow( 2 , 1 )
Deferred: highlow( 2 , 0 )
panic: highlow() low greater than high

goroutine 1 [running]:
main.highlow(0x2, 0x3)
/tmp/sandbox/prog.go:13 +0x34c
main.highlow(0x2, 0x2)
/tmp/sandbox/prog.go:18 +0x298
main.highlow(0x2, 0x1)
/tmp/sandbox/prog.go:18 +0x298
main.highlow(0x2, 0x0)
/tmp/sandbox/prog.go:18 +0x298
main.main()
/tmp/sandbox/prog.go:6 +0x37

Program exited: status 2.

recover 函数

recover可以捕获panic信息,以便在程序内部处理异常,或避免程序崩溃。例子如下:

1
2
3
4
5
6
7
8
9
10
11
func main() {
defer func() {
handler := recover()
if handler != nil {
fmt.Println("main(): recover", handler)
}
}()

highlow(2, 0)
fmt.Println("Program finished successfully!")
}

输出如下:

1
2
3
4
5
6
7
8
9
10
Call: highlow( 2 , 0 )
Call: highlow( 2 , 1 )
Call: highlow( 2 , 2 )
Panic!
Deferred: highlow( 2 , 2 )
Deferred: highlow( 2 , 1 )
Deferred: highlow( 2 , 0 )
main(): recover from panic highlow() low greater than high

Program exited.

在这个例子中:

  • main函数使用了defer来延迟调用一个匿名函数,函数中调用了recover()以此在内部处理错误
  • 当程序处于紧急状态时,对 recover() 的调用无法返回 nil。 你可以在此处执行一些操作来清理混乱,但在本例中,你只是简单地输出一些内容。
  • panicrecover 函数的组合是 Go 处理异常的惯用方式。 其他编程语言使用 try/catch 块。 Go 首选此处所述的方法

go中的异常处理

首先go中没有try catch的概念,可能是由于此功能会消耗更多的资源。

因此go认为:

  • 如果一个函数可能出现异常,那么应该把异常作为返回值,没有异常就返回 nil
  • 每次调用可能出现异常的函数时,都应该主动进行检查,并做出反应,这种 if 语句术语叫卫述语句

我们要做的就是在尽量少地影响程序和消耗资源,同时不要让未知的异常导致程序的崩溃。

使用方式

我们应该让异常以这样的形式出现

1
2
3
func Demo() (int, error) {
// TODO: ...
}

我们应该让异常以这样的形式处理(卫述语句)

1
2
3
4
5
_, err := errorDemo()
if err != nil {
fmt.Println(err)
return
}

比如程序有一个功能为除法的函数,除数不能为 0 ,否则程序为出现异常,我们就要提前判断除数,如果为 0 返回一个异常。那他应该这么写。

1
2
3
4
5
6
7
func divisionInt(a, b int) (int, error) {
if b == 0 {
// 创建异常并返回
return -1, errors.New("除数不能为0")
}
return a / b, nil
}

这个函数应该被这么调用

1
2
3
4
5
6
7
8
9
10
func main() {
a, b := 4, 0
res, err := divisionInt(a, b)
if err != nil {
// 判断错误并处理
fmt.Println(err.Error())
return
}
fmt.Println(a, "除以", b, "的结果是 ", res)
}

此外的内容例如error接口等,读者可以自行学习

封装

在go中,方法或结构首字母必须大写才能被其他包访问,类似于public。若小写则其他包无法访问。

如何体现封装:

  • 对结构体中的属性进行封装;
  • 通过方法,包,实现封装。

封装的实现步骤:

  • 将结构体、字段的首字母小写;
  • 给结构体所在的包提供一个工厂模式的函数,首字母大写,类似一个构造函数;
  • 提供一个首字母大写的 Set 方法(类似其它语言的 public),用于对属性判断并赋值;
  • 提供一个首字母大写的 Get 方法(类似其它语言的 public),用于获取属性的值。

事例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package model

import "fmt"

type person struct {
Name string
age int //其它包不能直接访问..
sal float64
}

//写一个工厂模式的函数,相当于构造函数
func NewPerson(name string) *person {
return &person{
Name : name,
}
}

//为了访问age 和 sal 我们编写一对SetXxx的方法和GetXxx的方法
func (p *person) SetAge(age int) {
if age >0 && age <150 {
p.age = age
} else {
fmt.Println("年龄范围不正确..")
//给程序员给一个默认值
}
}
func (p *person) GetAge() int {
return p.age
}

func (p *person) SetSal(sal float64) {
if sal >= 3000 && sal <= 30000 {
p.sal = sal
} else {
fmt.Println("薪水范围不正确..")
}
}

func (p *person) GetSal() float64 {
return p.sal
}

代码结构如下:

image-20220613113618675

组合大于继承

我们来看go独特的”继承”方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"fmt"
"strconv"
)

// 动物类
type Animal struct {
name string
subject string
}

// 动物的公共方法
func (a *Animal) eat(food string) {
fmt.Println(a.name + "喜欢吃:" + food +",它属于:" + a.subject)
}

// 猫类,继承动物类
type Cat struct {
// 继承动物的属性和方法
Animal
// 猫自己的属性
age int
}

// 猫类独有的方法
func (c Cat) sleep() {
fmt.Println(c.name + " 今年" + strconv.Itoa(c.age) + "岁了,特别喜欢睡觉")
}

func main() {
// 创建一个动物类
animal := Animal{name:"动物", subject:"动物科"}
animal.eat("肉")

// 创建一个猫类
cat := Cat{Animal: Animal{name:"咪咪", subject:"猫科"},age:1}
cat.eat("鱼")
cat.sleep()
}

如果你熟悉 Java 或 C++ 等 OOP 语言,则可能会认为 Animal 结构看起来像基类,而 Cat 是一个子类(如继承),但事实并不是如此。 实际上,Go 编译器会通过创建如下的包装器方法来推广 eat 方法:

1
2
3
func (a *Cat) eat(food string) {
a.Animal.eat(food)
}

你不必再创建这样的方法。你可以选择创建,但 Go 已在内部为你完成了此工作。

当然,我们也可以使用接口。例子在下面给出。

方法绑定

其实这个与其他OOP语言的内部方法类似,但是go的低耦合、组合大于继承的思想让它变得较为独特。我们看如下的例子:

1
2
3
func (variable type) MethodName(parameters ...) {
// method functionality
}

这是一个函数的声明。一般情况下,func后面的括号部分可以不写。如果添加了,那么说明此函数与此类型绑定。在调用时,就像其他语言一样使用variable.MethodName(parameters)进行调用。函数可以像使用参数一样使用该绑定结构。

由于go是按值传递,所以此种声明方式只能对绑定的变量进行读操作。因此若想对绑定的变量进行写操作,或者传递的结构太大导致复制效率低下,可以这样声明:

1
2
3
func (t *triangle) doubleSize() {
t.size *= 2
}

这样则代表接收一个变量的指针。

以上都针对用户自定义结构变量的绑定。从这里可以看到,go弱化了面向对象,但依然可以在简洁、低耦合的方式下使用面向对象。下面介绍如何声明其他类型的方法。

声明其他类型的方法

有时你想声明其他类型的方法,而不只是针对自定义类型(如结构)进行定义。 但是,你不能通过属于其他包的类型来定义结构。 因此,不能在基本类型(如 string)上创建方法。

但是我们可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"strings"
)

type upperstring string

func (s upperstring) Upper() string {
return strings.ToUpper(string(s))
}

func main() {
s := upperstring("Learning Go!")
fmt.Println(s)
fmt.Println(s.Upper())
}

发现了吗?我们可以基于基本类型创建自定义类型,然后将其用作基本类型。

go中的接口

来看一段话:

Go语言中的函数有具名和匿名之分:具名函数一般对应于包级函数,是匿名函数的一种特例。当匿名函数引用了外部作用于中的变量时就成了闭包函数。方法是绑定到一个具体类型的特殊函数,Go语言中的方法依托于类型的,必须在编译时静态绑定。接口定义了方法的集合,这些方法依托于运行时的接口对象,因此接口对应的方法是在运行时动态绑定的。Go语言通过隐式接口机制实现了面向对象模型。

有点抽象,但不是特别抽象。我们简单地理解下:

接口是一种抽象类型,它在意义上和其他语言的接口类似。比如定义一个接口:

1
2
3
4
type Shape interface {
Perimeter() float64
Area() float64
}

要想实现这个接口,必须使得所有考虑该接口的类型都具有这两个方法。

1
2
3
4
5
6
7
8
9
10
11
type Square struct {
size float64
}

func (s Square) Area() float64 {
return s.size * s.size
}

func (s Square) Perimeter() float64 {
return s.size * 4
}

由此可以知道,go中接口的实现是隐式实现的,在运行时go会知道你什么时候使用了接口。例如如果加上以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Circle struct {
radius float64
}

func (c Circle) Area() float64 {
return math.Pi * c.radius * c.radius
}

func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.radius
}

func printInformation(s Shape) {
fmt.Printf("%T\n", s)
fmt.Println("Area: ", s.Area())
fmt.Println("Perimeter:", s.Perimeter())
fmt.Println()
}

func main() {
var s Shape = Square{3}
printInformation(s)

c := Circle{6}
printInformation(c)
}

程序会输出:

1
2
3
4
5
6
7
main.Square
Area: 9
Perimeter: 12

main.Circle
Area: 113.09733552923255
Perimeter: 37.69911184307752

使用接口的优点在于,对于 Shape的每个新类型或实现,printInformation 函数都不需要更改。 正如之前所述,当你使用接口时,代码会变得更灵活、更容易扩展。

参考文章:

Golang 语言怎么使用 panic 函数? - 云+社区 - 腾讯云

在 Go 中使用方法 - Learn | Microsoft Docs

Go语言封装简介及实现细节

golang异常处理详解 - 知乎

go语言-函数、方法和接口 - Go语言中文网 - Golang中文社区

⬆︎TOP