Go语言系列——Go协程、信道(channel)、缓冲信道和工作池、Select、Mutex、结构体取代类、组合取代继承、多态、 Defer、错误处理
文章目录
21-Go协程
Go 协程是什么?
Go 协程是与其他函数或方法一起并发运行的函数或方法。Go 协程可以看作是轻量级线程。与线程相比,创建一个 Go 协程的成本很小。因此在 Go 应用中,常常会看到有数以千计的 Go 协程并发地运行。
Go 协程相比于线程的优势
- 相比线程而言,Go 协程的成本极低。堆栈大小只有若干 kb,并且可以根据应用的需求进行增减。而线程必须指定堆栈的大小,其堆栈是固定不变的。
- Go 协程会复用(Multiplex)数量更少的 OS 线程。即使程序有数以千计的 Go 协程,也可能只有一个线程。如果该线程中的某一 Go 协程发生了阻塞(比如说等待用户输入),那么系统会再创建一个 OS 线程,并把其余 Go 协程都移动到这个新的 OS 线程。所有这一切都在运行时进行,作为程序员,我们没有直接面临这些复杂的细节,而是有一个简洁的 API 来处理并发。
- Go 协程使用信道(Channel)来进行通信。信道用于防止多个协程访问共享内存时发生竞态条件(Race Condition)。信道可以看作是 Go 协程之间通信的管道。我们会在下一教程详细讨论信道。
如何启动一个 Go 协程?
调用函数或者方法时,在前面加上关键字 go
,可以让一个新的 Go 协程并发地运行。
让我们创建一个 Go 协程吧。
package main
import (
"fmt"
)
func hello() {
fmt.Println("Hello world goroutine")
}
func main() {
go hello()
fmt.Println("main function")
}
在第 11 行,go hello()
启动了一个新的 Go 协程。现在 hello()
函数与 main()
函数会并发地执行。主函数会运行在一个特有的 Go 协程上,它称为 Go 主协程(Main Goroutine)。
运行一下程序,你会很惊讶!
该程序只会输出文本 main function
。我们启动的 Go 协程究竟出现了什么问题?要理解这一切,我们需要理解两个 Go 协程的主要性质。
- 启动一个新的协程时,协程的调用会立即返回。与函数不同,程序控制不会去等待 Go 协程执行完毕。在调用 Go 协程之后,程序控制会立即返回到代码的下一行,忽略该协程的任何返回值。
- 如果希望运行其他 Go 协程,Go 主协程必须继续运行着。如果 Go 主协程终止,则程序终止,于是其他 Go 协程也不会继续运行。
现在你应该能够理解,为何我们的 Go 协程没有运行了吧。在第 11 行调用了 go hello()
之后,程序控制没有等待 hello
协程结束,立即返回到了代码下一行,打印 main function
。接着由于没有其他可执行的代码,Go 主协程终止,于是 hello
协程就没有机会运行了。
我们现在修复这个问题。
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Hello world goroutine")
}
func main() {
go hello()
time.Sleep(1 * time.Second)
fmt.Println("main function")
}
在上面程序的第 13 行,我们调用了 time 包里的函数 [Sleep
],该函数会休眠执行它的 Go 协程。在这里,我们使 Go 主协程休眠了 1 秒。因此在主协程终止之前,调用 go hello()
就有足够的时间来执行了。该程序首先打印 Hello world goroutine
,等待 1 秒钟之后,接着打印 main function
。
在 Go 主协程中使用休眠,以便等待其他协程执行完毕,这种方法只是用于理解 Go 协程如何工作的技巧。信道可用于在其他协程结束执行之前,阻塞 Go 主协程。我们会在下一教程中讨论信道。
启动多个 Go 协程
为了更好地理解 Go 协程,我们再编写一个程序,启动多个 Go 协程。
package main
import (
"fmt"
"time"
)
func numbers() {
for i := 1; i <= 5; i++ {
time.Sleep(250 * time.Millisecond)
fmt.Printf("%d ", i)
}
}
func alphabets() {
for i := 'a'; i <= 'e'; i++ {
time.Sleep(400 * time.Millisecond)
fmt.Printf("%c ", i)
}
}
func main() {
go numbers()
go alphabets()
time.Sleep(3000 * time.Millisecond)
fmt.Println("main terminated")
}
在上面程序中的第 21 行和第 22 行,启动了两个 Go 协程。现在,这两个协程并发地运行。numbers
协程首先休眠 250 微秒,接着打印 1
,然后再次休眠,打印 2
,依此类推,一直到打印 5
结束。alphabete
协程同样打印从 a
到 e
的字母,并且每次有 400 微秒的休眠时间。 Go 主协程启动了 numbers
和 alphabete
两个 Go 协程,休眠了 3000 微秒后终止程序。
该程序会输出:
1 a 2 3 b 4 c 5 d e main terminated
程序的运作如下图所示
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T1mtjT3h-1647424727752)(/Users/liuqingzheng/Desktop/go%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8B/image-20190210175410170.png)]
第一张蓝色的图表示 numbers
协程,第二张褐红色的图表示 alphabets
协程,第三张绿色的图表示 Go 主协程,而最后一张黑色的图把以上三种协程合并了,表明程序是如何运行的。在每个方框顶部,诸如 0 ms
和 250 ms
这样的字符串表示时间(以微秒为单位)。在每个方框的底部,1
、2
、3
等表示输出。蓝色方框表示:250 ms
打印出 1
,500 ms
打印出 2
,依此类推。最后黑色方框的底部的值会是 1 a 2 3 b 4 c 5 d e main terminated
,这同样也是整个程序的输出。以上图片非常直观,你可以用它来理解程序是如何运作的。
22-信道(channel)
什么是信道?
信道可以想像成 Go 协程之间通信的管道。如同管道中的水会从一端流到另一端,通过使用信道,数据也可以从一端发送,在另一端接收。
信道的声明
所有信道都关联了一个类型。信道只能运输这种类型的数据,而运输其他类型的数据都是非法的。
chan T
表示 T
类型的信道。
信道的零值为 nil
。信道的零值没有什么用,应该像对 map 和切片所做的那样,用 make
来定义信道。
下面编写代码,声明一个信道。
package main
import "fmt"
func main() {
var a chan int
if a == nil {
fmt.Println("channel a is nil, going to define it")
a = make(chan int)
fmt.Printf("Type of a is %T", a)
}
}
由于信道的零值为 nil
,在第 6 行,信道 a
的值就是 nil
。于是,程序执行了 if 语句内的语句,定义了信道 a
。程序中 a
是一个 int 类型的信道。该程序会输出:
channel a is nil, going to define it
Type of a is chan int
简短声明通常也是一种定义信道的简洁有效的方法。
a := make(chan int)
这一行代码同样定义了一个 int 类型的信道 a
。
通过信道进行发送和接收
如下所示,该语法通过信道发送和接收数据。
data := <- a // 读取信道 a
a <- data // 写入信道 a
信道旁的箭头方向指定了是发送数据还是接收数据。
在第一行,箭头对于 a
来说是向外指的,因此我们读取了信道 a
的值,并把该值存储到变量 data
。
在第二行,箭头指向了 a
,因此我们在把数据写入信道 a
。
发送与接收默认是阻塞的
发送与接收默认是阻塞的。这是什么意思?当把数据发送到信道时,程序控制会在发送数据的语句处发生阻塞,直到有其它 Go 协程从信道读取到数据,才会解除阻塞。与此类似,当读取信道的数据时,如果没有其它的协程把数据写入到这个信道,那么读取过程就会一直阻塞着。
信道的这种特性能够帮助 Go 协程之间进行高效的通信,不需要用到其他编程语言常见的显式锁或条件变量。
信道的代码示例
理论已经够了:)。接下来写点代码,看看协程之间通过信道是怎么通信的吧。
我们其实可以重写上章学习 [Go 协程]时写的程序,现在我们在这里用上信道。
首先引用前面教程里的程序。
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("Hello world goroutine")
}
func main() {
go hello()
time.Sleep(1 * time.Second)
fmt.Println("main function")
}
这是上一篇的代码。我们使用到了休眠,使 Go 主协程等待 hello 协程结束。如果你看不懂,建议你阅读上一教程 [Go 协程]。
我们接下来使用信道来重写上面代码。
package main
import (
"fmt"
)
func hello(done chan bool) {
fmt.Println("Hello world goroutine")
done <- true
}
func main() {
done := make(chan bool)
go hello(done)
<-done
fmt.Println("main function")
}
在上述程序里,我们在第 12 行创建了一个 bool 类型的信道 done
,并把 done
作为参数传递给了 hello
协程。在第 14 行,我们通过信道 done
接收数据。这一行代码发生了阻塞,除非有协程向 done
写入数据,否则程序不会跳到下一行代码。于是,这就不需要用以前的 time.Sleep
来阻止 Go 主协程退出了。
<-done
这行代码通过协程(译注:原文笔误,信道)done
接收数据,但并没有使用数据或者把数据存储到变量中。这完全是合法的。
现在我们的 Go 主协程发生了阻塞,等待信道 done
发送的数据。该信道作为参数传递给了协程 hello
,hello
打印出 Hello world goroutine
,接下来向 done
写入数据。当完成写入时,Go 主协程会通过信道 done
接收数据,于是它解除阻塞状态,打印出文本 main function
。
该程序输出如下:
Hello world goroutine
main function
我们稍微修改一下程序,在 hello
协程里加入休眠函数,以便更好地理解阻塞的概念。
package main
import (
"fmt"
"time"
)
func hello(done chan bool) {
fmt.Println("hello go routine is going to sleep")
time.Sleep(4 * time.Second)
fmt.Println("hello go routine awake and going to write to done")
done <- true
}
func main() {
done := make(chan bool)
fmt.Println("Main going to call hello go goroutine")
go hello(done)
<-done
fmt.Println("Main received data")
}
在上面程序里,我们向 hello
函数里添加了 4 秒的休眠(第 10 行)。
程序首先会打印 Main going to call hello go goroutine
。接着会开启 hello
协程,打印 hello go routine is going to sleep
。打印完之后,hello
协程会休眠 4 秒钟,而在这期间,主协程会在 <-done
这一行发生阻塞,等待来自信道 done
的数据。4 秒钟之后,打印 hello go routine awake and going to write to done
,接着再打印 Main received data
。
信道的另一个示例
我们再编写一个程序来更好地理解信道。该程序会计算一个数中每一位的平方和与立方和,然后把平方和与立方和相加并打印出来。
例如,如果输出是 123,该程序会如下计算输出:
squares = (1 * 1) + (2 * 2) + (3 * 3)
cubes = (1 * 1 * 1) + (2 * 2 * 2) + (3 * 3 * 3)
output = squares + cubes = 50
我们会这样去构建程序:在一个单独的 Go 协程计算平方和,而在另一个协程计算立方和,最后在 Go 主协程把平方和与立方和相加。
package main
import (
"fmt"
)
func calcSquares(number int, squareop chan int) {
sum := 0
for number != 0 {
digit := number % 10
sum += digit * digit
number /= 10
}
squareop <- sum
}
func calcCubes(number int, cubeop chan int) {
sum := 0
for number != 0 {
digit := number % 10
sum += digit * digit * digit
number /= 10
}
cubeop <- sum
}
func main() {
number := 589
sqrch := make(chan int)
cubech := make(chan int)
go calcSquares(number, sqrch)
go calcCubes(number, cubech)
squares, cubes := <-sqrch, <-cubech
fmt.Println("Final output", squares + cubes)
}
在第 7 行,函数 calcSquares
计算一个数每位的平方和,并把结果发送给信道 squareop
。与此类似,在第 17 行函数 calcCubes
计算一个数每位的立方和,并把结果发送给信道 cubop
。
这两个函数分别在单独的协程里运行(第 31 行和第 32 行),每个函数都有传递信道的参数,以便写入数据。Go 主协程会在第 33 行等待两个信道传来的数据。一旦从两个信道接收完数据,数据就会存储在变量 squares
和 cubes
里,然后计算并打印出最后结果。该程序会输出:
Final output 1536
死锁
使用信道需要考虑的一个重点是死锁。当 Go 协程给一个信道发送数据时,照理说会有其他 Go 协程来接收数据。如果没有的话,程序就会在运行时触发 panic,形成死锁。
同理,当有 Go 协程等着从一个信道接收数据时,我们期望其他的 Go 协程会向该信道写入数据,要不然程序就会触发 panic。
package main
func main() {
ch := make(chan int)
ch <- 5
}
在上述程序中,我们创建了一个信道 ch
,接着在下一行 ch <- 5
,我们把 5
发送到这个信道。对于本程序,没有其他的协程从 ch
接收数据。于是程序触发 panic,出现如下运行时错误。
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/sandbox249677995/main.go:6 +0x80
单向信道
我们目前讨论的信道都是双向信道,即通过信道既能发送数据,又能接收数据。其实也可以创建单向信道,这种信道只能发送或者接收数据。
package main
import "fmt"
func sendData(sendch chan<- int) {
sendch <- 10
}
func main() {
sendch := make(chan<- int)
go sendData(sendch)
fmt.Println(<-sendch)
}
上面程序的第 10 行,我们创建了唯送(Send Only)信道 sendch
。chan<- int
定义了唯送信道,因为箭头指向了 chan
。在第 12 行,我们试图通过唯送信道接收数据,于是编译器报错:
main.go:11: invalid operation: <-sendch (receive from send-only type chan<- int)
一切都很顺利,只不过一个不能读取数据的唯送信道究竟有什么意义呢?
这就需要用到信道转换(Channel Conversion)了。把一个双向信道转换成唯送信道或者唯收(Receive Only)信道都是行得通的,但是反过来就不行。
package main
import "fmt"
func sendData(sendch chan<- int) {
sendch <- 10
}
func main() {
cha1 := make(chan int)
go sendData(cha1)
fmt.Println(<-cha1)
}
在上述程序的第 10 行,我们创建了一个双向信道 cha1
。在第 11 行 cha1
作为参数传递给了 sendData
协程。在第 5 行,函数 sendData
里的参数 sendch chan<- int
把 cha1
转换为一个唯送信道。于是该信道在 sendData
协程里是一个唯送信道,而在 Go 主协程里是一个双向信道。该程序最终打印输出 10
。
关闭信道和使用 for range 遍历信道
数据发送方可以关闭信道,通知接收方这个信道不再有数据发送过来。
当从信道接收数据时,接收方可以多用一个变量来检查信道是否已经关闭。
v, ok := <- ch
上面的语句里,如果成功接收信道所发送的数据,那么 ok
等于 true。而如果 ok
等于 false,说明我们试图读取一个关闭的通道。从关闭的信道读取到的值会是该信道类型的零值。例如,当信道是一个 int
类型的信道时,那么从关闭的信道读取的值将会是 0
。
package main
import (
"fmt"
)
func producer(chnl chan int) {
for i := 0; i < 10; i++ {
chnl <- i
}
close(chnl)
}
func main() {
ch := make(chan int)
go producer(ch)
for {
v, ok := <-ch
if ok == false {
break
}
fmt.Println("Received ", v, ok)
}
}
在上述的程序中,producer
协程会从 0 到 9 写入信道 chn1
,然后关闭该信道。主函数有一个无限的 for 循环(第 16 行),使用变量 ok
(第 18 行)检查信道是否已经关闭。如果 ok
等于 false,说明信道已经关闭,于是退出 for 循环。如果 ok
等于 true,会打印出接收到的值和 ok
的值。
Received 0 true
Received 1 true
Received 2 true
Received 3 true
Received 4 true
Received 5 true
Received 6 true
Received 7 true
Received 8 true
Received 9 true
for range 循环用于在一个信道关闭之前,从信道接收数据。
接下来我们使用 for range 循环重写上面的代码。
package main
import (
"fmt"
)
func producer(chnl chan int) {
for i := 0; i < 10; i++ {
chnl <- i
}
close(chnl)
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch {
fmt.Println("Received ",v)
}
}
在第 16 行,for range 循环从信道 ch
接收数据,直到该信道关闭。一旦关闭了 ch
,循环会自动结束。该程序会输出:
Received 0
Received 1
Received 2
Received 3
Received 4
Received 5
Received 6
Received 7
Received 8
Received 9
我们可以使用 for range 循环,重写[信道的另一个示例]这一节里面的代码,提高代码的可重用性。
如果你仔细观察这段代码,会发现获得一个数里的每位数的代码在 calcSquares
和 calcCubes
两个函数内重复了。我们将把这段代码抽离出来,放在一个单独的函数里,然后并发地调用它。
package main
import (
"fmt"
)
func digits(number int, dchnl chan int) {
for number != 0 {
digit := number % 10
dchnl <- digit
number /= 10
}
close(dchnl)
}
func calcSquares(number int, squareop chan int) {
sum := 0
dch := make(chan int)
go digits(number, dch)
for digit := range dch {
sum += digit * digit
}
squareop <- sum
}
func calcCubes(number int, cubeop chan int) {
sum := 0
dch := make(chan int)
go digits(number, dch)
for digit := range dch {
sum += digit * digit * digit
}
cubeop <- sum
}
func main() {
number := 589
sqrch := make(chan int)
cubech := make(chan int)
go calcSquares(number, sqrch)
go calcCubes(number, cubech)
squares, cubes := <-sqrch, <-cubech
fmt.Println("Final output", squares+cubes)
}
上述程序里的 digits
函数,包含了获取一个数的每位数的逻辑,并且 calcSquares
和 calcCubes
两个函数并发地调用了 digits
。当计算完数字里面的每一位数时,第 13 行就会关闭信道。calcSquares
和 calcCubes
两个协程使用 for range 循环分别监听了它们的信道,直到该信道关闭。程序的其他地方不变,该程序同样会输出:
Final output 1536
关于信道还有一些其他的概念,比如缓冲信道(Buffered Channel)、工作池(Worker Pool)和 select。我们会在接下来的教程里专门介绍它们
23-缓冲信道和工作池
什么是缓冲信道?
在[上一教程]里,我们讨论的主要是无缓冲信道。我们在[信道]的教程里详细讨论了,无缓冲信道的发送和接收过程是阻塞的。
我们还可以创建一个有缓冲(Buffer)的信道。只在缓冲已满的情况,才会阻塞向缓冲信道(Buffered Channel)发送数据。同样,只有在缓冲为空的时候,才会阻塞从缓冲信道接收数据。
通过向 make
函数再传递一个表示容量的参数(指定缓冲的大小),可以创建缓冲信道。
ch := make(chan type, capacity)
要让一个信道有缓冲,上面语法中的 capacity
应该大于 0。无缓冲信道的容量默认为 0,因此我们在[上一教程]创建信道时,省略了容量参数。
我们开始编写代码,创建一个缓冲信道。
示例一
package main
import (
"fmt"
)
func main() {
ch := make(chan string, 2)
ch <- "naveen"
ch <- "paul"
fmt.Println(<- ch)
fmt.Println(<- ch)
}
在上面程序里的第 9 行,我们创建了一个缓冲信道,其容量为 2。由于该信道的容量为 2,因此可向它写入两个字符串,而且不会发生阻塞。在第 10 行和第 11 行,我们向信道写入两个字符串,该信道并没有发生阻塞。我们又在第 12 行和第 13 行分别读取了这两个字符串。该程序输出:
naveen
paul
示例二
我们再看一个缓冲信道的示例,其中有一个并发的 Go 协程来向信道写入数据,而 Go 主协程负责读取数据。该示例帮助我们进一步理解,在向缓冲信道写入数据时,什么时候会发生阻塞。
package main
import (
"fmt"
"time"
)
func write(ch chan int) {
for i := 0; i < 5; i++ {
ch <- i
fmt.Println("successfully wrote", i, "to ch")
}
close(ch)
}
func main() {
ch := make(chan int, 2)
go write(ch)
time.Sleep(2 * time.Second)
for v := range ch {
fmt.Println("read value", v,"from ch")
time.Sleep(2 * time.Second)
}
}
在上面的程序中,第 16 行在 Go 主协程中创建了容量为 2 的缓冲信道 ch
,而第 17 行把 ch
传递给了 write
协程。接下来 Go 主协程休眠了两秒。在这期间,write
协程在并发地运行。write
协程有一个 for 循环,依次向信道 ch
写入 0~4。而缓冲信道的容量为 2,因此 write
协程里立即会向 ch
写入 0 和 1,接下来发生阻塞,直到 ch
内的值被读取。因此,该程序立即打印出下面两行:
successfully wrote 0 to ch
successfully wrote 1 to ch
打印上面两行之后,write
协程中向 ch
的写入发生了阻塞,直到 ch
有值被读取到。而 Go 主协程休眠了两秒后,才开始读取该信道,因此在休眠期间程序不会打印任何结果。主协程结束休眠后,在第 19 行使用 for range 循环,开始读取信道 ch
,打印出了读取到的值后又休眠两秒,这个循环一直到 ch
关闭才结束。所以该程序在两秒后会打印下面两行:
read value 0 from ch
successfully wrote 2 to ch
该过程会一直进行,直到信道读取完所有的值,并在 write
协程中关闭信道。最终输出如下:
successfully wrote 0 to ch
successfully wrote 1 to ch
read value 0 from ch
successfully wrote 2 to ch
read value 1 from ch
successfully wrote 3 to ch
read value 2 from ch
successfully wrote 4 to ch
read value 3 from ch
read value 4 from ch
死锁
package main
import (
"fmt"
)
func main() {
ch := make(chan string, 2)
ch <- "naveen"
ch <- "paul"
ch <- "steve"
fmt.Println(<-ch)
fmt.Println(<-ch)
}
在上面程序里,我们向容量为 2 的缓冲信道写入 3 个字符串。当在程序控制到达第 3 次写入时(第 11 行),由于它超出了信道的容量,因此这次写入发生了阻塞。现在想要这次写操作能够进行下去,必须要有其它协程来读取这个信道的数据。但在本例中,并没有并发协程来读取这个信道,因此这里会发生死锁(deadlock)。程序会在运行时触发 panic,信息如下:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/tmp/sandbox274756028/main.go:11 +0x100
长度 vs 容量
缓冲信道的容量是指信道可以存储的值的数量。我们在使用 make
函数创建缓冲信道的时候会指定容量大小。
缓冲信道的长度是指信道中当前排队的元素个数。
代码可以把一切解释得很清楚。😃
package main
import (
"fmt"
)
func main() {
ch := make(chan string, 3)
ch <- "naveen"
ch <- "paul"
fmt.Println("capacity is", cap(ch))
fmt.Println("length is", len(ch))
fmt.Println("read value", <-ch)
fmt.Println("new length is", len(ch))
}
在上面的程序里,我们创建了一个容量为 3 的信道,于是它可以保存 3 个字符串。接下来,我们分别在第 9 行和第 10 行向信道写入了两个字符串。于是信道有两个字符串排队,因此其长度为 2。在第 13 行,我们又从信道读取了一个字符串。现在该信道内只有一个字符串,因此其长度变为 1。该程序会输出:
capacity is 3
length is 2
read value naveen
new length is 1
WaitGroup
在本教程的下一节里,我们会讲到工作池(Worker Pools)。而 WaitGroup
用于实现工作池,因此要理解工作池,我们首先需要学习 WaitGroup
。
WaitGroup
用于等待一批 Go 协程执行结束。程序控制会一直阻塞,直到这些协程全部执行完毕。假设我们有 3 个并发执行的 Go 协程(由 Go 主协程生成)。Go 主协程需要等待这 3 个协程执行结束后,才会终止。这就可以用 WaitGroup
来实现。
理论说完了,我们编写点儿代码吧。😃
package main
import (
"fmt"
"sync"
"time"
)
func process(i int, wg *sync.WaitGroup) {
fmt.Println("started Goroutine ", i)
time.Sleep(2 * time.Second)
fmt.Printf("Goroutine %d ended\n", i)
wg.Done()
}
func main() {
no := 3
var wg sync.WaitGroup
for i := 0; i < no; i++ {
wg.Add(1)
go process(i, &wg)
}
wg.Wait()
fmt.Println("All go routines finished executing")
}
[WaitGroup]是一个结构体类型,我们在第 18 行创建了 WaitGroup
类型的变量,其初始值为零值。WaitGroup
使用计数器来工作。当我们调用 WaitGroup
的 Add
并传递一个 int
时,WaitGroup
的计数器会加上 Add
的传参。要减少计数器,可以调用 WaitGroup
的 Done()
方法。Wait()
方法会阻塞调用它的 Go 协程,直到计数器变为 0 后才会停止阻塞。
上述程序里,for 循环迭代了 3 次,我们在循环内调用了 wg.Add(1)
(第 20 行)。因此计数器变为 3。for 循环同样创建了 3 个 process
协程,然后在第 23 行调用了 wg.Wait()
,确保 Go 主协程等待计数器变为 0。在第 13 行,process
协程内调用了 wg.Done
,可以让计数器递减。一旦 3 个子协程都执行完毕(即 wg.Done()
调用了 3 次),那么计数器就变为 0,于是主协程会解除阻塞。
在第 21 行里,传递 wg 的地址是很重要的。如果没有传递 wg 的地址,那么每个 Go 协程将会得到一个 WaitGroup 值的拷贝,因而当它们执行结束时,main 函数并不会知道。
该程序输出:
started Goroutine 2
started Goroutine 0
started Goroutine 1
Goroutine 0 ended
Goroutine 2 ended
Goroutine 1 ended
All go routines finished executing
由于 Go 协程的执行顺序不一定,因此你的输出可能和我不一样。😃
工作池的实现
缓冲信道的重要应用之一就是实现[工作池]。
一般而言,工作池就是一组等待任务分配的线程。一旦完成了所分配的任务,这些线程可继续等待任务的分配。
我们会使用缓冲信道来实现工作池。我们工作池的任务是计算所输入数字的每一位的和。例如,如果输入 234,结果会是 9(即 2 + 3 + 4)。向工作池输入的是一列伪随机数。
我们工作池的核心功能如下:
- 创建一个 Go 协程池,监听一个等待作业分配的输入型缓冲信道。
- 将作业添加到该输入型缓冲信道中。
- 作业完成后,再将结果写入一个输出型缓冲信道。
- 从输出型缓冲信道读取并打印结果。
我们会逐步编写这个程序,让代码易于理解。
第一步就是创建一个结构体,表示作业和结果。
type Job struct {
id int
randomno int
}
type Result struct {
job Job
sumofdigits int
}
所有 Job
结构体变量都会有 id
和 randomno
两个字段,randomno
用于计算其每位数之和。
而 Result
结构体有一个 job
字段,表示所对应的作业,还有一个 sumofdigits
字段,表示计算的结果(每位数字之和)。
第二步是分别创建用于接收作业和写入结果的缓冲信道。
var jobs = make(chan Job, 10)
var results = make(chan Result, 10)
工作协程(Worker Goroutine)会监听缓冲信道 jobs
里更新的作业。一旦工作协程完成了作业,其结果会写入缓冲信道 results
。
如下所示,digits
函数的任务实际上就是计算整数的每一位之和,最后返回该结果。为了模拟出 digits
在计算过程中花费了一段时间,我们在函数内添加了两秒的休眠时间。
func digits(number int) int {
sum := 0
no := number
for no != 0 {
digit := no % 10
sum += digit
no /= 10
}
time.Sleep(2 * time.Second)
return sum
}
然后,我们写一个创建工作协程的函数。
func worker(wg *sync.WaitGroup) {
for job := range jobs {
output := Result{job, digits(job.randomno)}
results <- output
}
wg.Done()
}
上面的函数创建了一个工作者(Worker),读取 jobs
信道的数据,根据当前的 job
和 digits
函数的返回值,创建了一个 Result
结构体变量,然后将结果写入 results
缓冲信道。worker
函数接收了一个 WaitGroup
类型的 wg
作为参数,当所有的 jobs
完成的时候,调用了 Done()
方法。
createWorkerPool
函数创建了一个 Go 协程的工作池。
func createWorkerPool(noOfWorkers int) {
var wg sync.WaitGroup
for i := 0; i < noOfWorkers; i++ {
wg.Add(1)
go worker(&wg)
}
wg.Wait()
close(results)
}
上面函数的参数是需要创建的工作协程的数量。在创建 Go 协程之前,它调用了 wg.Add(1)
方法,于是 WaitGroup
计数器递增。接下来,我们创建工作协程,并向 worker
函数传递 wg
的地址。创建了需要的工作协程后,函数调用 wg.Wait()
,等待所有的 Go 协程执行完毕。所有协程完成执行之后,函数会关闭 results
信道。因为所有协程都已经执行完毕,于是不再需要向 results
信道写入数据了。
现在我们已经有了工作池,我们继续编写一个函数,把作业分配给工作者。
func allocate(noOfJobs int) {
for i := 0; i < noOfJobs; i++ {
randomno := rand.Intn(999)
job := Job{i, randomno}
jobs <- job
}
close(jobs)
}
上面的 allocate
函数接收所需创建的作业数量作为输入参数,生成了最大值为 998 的伪随机数,并使用该随机数创建了 Job
结构体变量。这个函数把 for 循环的计数器 i
作为 id,最后把创建的结构体变量写入 jobs
信道。当写入所有的 job
时,它关闭了 jobs
信道。
下一步是创建一个读取 results
信道和打印输出的函数。
func result(done chan bool) {
for result := range results {
fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)
}
done <- true
}
result
函数读取 results
信道,并打印出 job
的 id
、输入的随机数、该随机数的每位数之和。result
函数也接受 done
信道作为参数,当打印所有结果时,done
会被写入 true。
现在一切准备充分了。我们继续完成最后一步,在 main()
函数中调用上面所有的函数。
func main() {
startTime := time.Now()
noOfJobs := 100
go allocate(noOfJobs)
done := make(chan bool)
go result(done)
noOfWorkers := 10
createWorkerPool(noOfWorkers)
<-done
endTime := time.Now()
diff := endTime.Sub(startTime)
fmt.Println("total time taken ", diff.Seconds(), "seconds")
}
我们首先在 main
函数的第 2 行,保存了程序的起始时间,并在最后一行(第 12 行)计算了 endTime
和 startTime
的差值,显示出程序运行的总时间。由于我们想要通过改变协程数量,来做一点基准指标(Benchmark),所以需要这么做。
我们把 noOfJobs
设置为 100,接下来调用了 allocate
,向 jobs
信道添加作业。
我们创建了 done
信道,并将其传递给 result
协程。于是该协程会开始打印结果,并在完成打印时发出通知。
通过调用 createWorkerPool
函数,我们最终创建了一个有 10 个协程的工作池。main
函数会监听 done
信道的通知,等待所有结果打印结束。
为了便于参考,下面是整个程序。我还引用了必要的包。
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Job struct {
id int
randomno int
}
type Result struct {
job Job
sumofdigits int
}
var jobs = make(chan Job, 10)
var results = make(chan Result, 10)
func digits(number int) int {
sum := 0
no := number
for no != 0 {
digit := no % 10
sum += digit
no /= 10
}
time.Sleep(2 * time.Second)
return sum
}
func worker(wg *sync.WaitGroup) {
for job := range jobs {
output := Result{job, digits(job.randomno)}
results <- output
}
wg.Done()
}
func createWorkerPool(noOfWorkers int) {
var wg sync.WaitGroup
for i := 0; i < noOfWorkers; i++ {
wg.Add(1)
go worker(&wg)
}
wg.Wait()
close(results)
}
func allocate(noOfJobs int) {
for i := 0; i < noOfJobs; i++ {
randomno := rand.Intn(999)
job := Job{i, randomno}
jobs <- job
}
close(jobs)
}
func result(done chan bool) {
for result := range results {
fmt.Printf("Job id %d, input random no %d , sum of digits %d\n", result.job.id, result.job.randomno, result.sumofdigits)
}
done <- true
}
func main() {
startTime := time.Now()
noOfJobs := 100
go allocate(noOfJobs)
done := make(chan bool)
go result(done)
noOfWorkers := 10
createWorkerPool(noOfWorkers)
<-done
endTime := time.Now()
diff := endTime.Sub(startTime)
fmt.Println("total time taken ", diff.Seconds(), "seconds")
}
为了更精确地计算总时间,请在你的本地机器上运行该程序。
该程序输出:
Job id 1, input random no 636, sum of digits 15
Job id 0, input random no 878, sum of digits 23
Job id 9, input random no 150, sum of digits 6
...
total time taken 20.01081009 seconds
程序总共会打印 100 行,对应着 100 项作业,然后最后会打印一行程序消耗的总时间。你的输出会和我的不同,因为 Go 协程的运行顺序不一定,同样总时间也会因为硬件而不同。在我的例子中,运行程序大约花费了 20 秒。
现在我们把 main
函数里的 noOfWorkers
增加到 20。我们把工作者的数量加倍了。由于工作协程增加了(准确说来是两倍),因此程序花费的总时间会减少(准确说来是一半)。在我的例子里,程序会打印出 10.004364685 秒。
...
total time taken 10.004364685 seconds
现在我们可以理解了,随着工作协程数量增加,完成作业的总时间会减少。你们可以练习一下:在 main
函数里修改 noOfJobs
和 noOfWorkers
的值,并试着去分析一下结果。
24-Select
title: “24-Select”date: 2019-03-10 08:08tags:
- Go
什么是 select?
select
语句用于在多个发送/接收信道操作中进行选择。select
语句会一直阻塞,直到发送/接收操作准备就绪。如果有多个信道操作准备完毕,select
会随机地选取其中之一执行。该语法与 switch
类似,所不同的是,这里的每个 case
语句都是信道操作。我们好好看一些代码来加深理解吧。
示例
package main
import (
"fmt"
"time"
)
func server1(ch chan string) {
time.Sleep(6 * time.Second)
ch <- "from server1"
}
func server2(ch chan string) {
time.Sleep(3 * time.Second)
ch <- "from server2"
}
func main() {
output1 := make(chan string)
output2 := make(chan string)
go server1(output1)
go server2(output2)
select {
case s1 := <-output1:
fmt.Println(s1)
case s2 := <-output2:
fmt.Println(s2)
}
}
在上面程序里,server1
函数(第 8 行)休眠了 6 秒,接着将文本 from server1
写入信道 ch
。而 server2
函数(第 12 行)休眠了 3 秒,然后把 from server2
写入了信道 ch
。
而 main
函数在第 20 行和第 21 行,分别调用了 server1
和 server2
两个 Go 协程。
在第 22 行,程序运行到了 select
语句。select
会一直发生阻塞,除非其中有 case 准备就绪。在上述程序里,server1
协程会在 6 秒之后写入 output1
信道,而server2
协程在 3 秒之后就写入了 output2
信道。因此 select
语句会阻塞 3 秒钟,等着 server2
向 output2
信道写入数据。3 秒钟过后,程序会输出:
from server2
然后程序终止。
select 的应用
在上面程序中,函数之所以取名为 server1
和 server2
,是为了展示 select
的实际应用。
假设我们有一个关键性应用,需要尽快地把输出返回给用户。这个应用的数据库复制并且存储在世界各地的服务器上。假设函数 server1
和 server2
与这样不同区域的两台服务器进行通信。每台服务器的负载和网络时延决定了它的响应时间。我们向两台服务器发送请求,并使用 select
语句等待相应的信道发出响应。select
会选择首先响应的服务器,而忽略其它的响应。使用这种方法,我们可以向多个服务器发送请求,并给用户返回最快的响应了。:)
默认情况
在没有 case 准备就绪时,可以执行 select
语句中的默认情况(Default Case)。这通常用于防止 select
语句一直阻塞。
package main
import (
"fmt"
"time"
)
func process(ch chan string) {
time.Sleep(10500 * time.Millisecond)
ch <- "process successful"
}
func main() {
ch := make(chan string)
go process(ch)
for {
time.Sleep(1000 * time.Millisecond)
select {
case v := <-ch:
fmt.Println("received value: ", v)
return
default:
fmt.Println("no value received")
}
}
}
上述程序中,第 8 行的 process
函数休眠了 10500 毫秒(10.5 秒),接着把 process successful
写入 ch
信道。在程序中的第 15 行,并发地调用了这个函数。
在并发地调用了 process
协程之后,主协程启动了一个无限循环。这个无限循环在每一次迭代开始时,都会先休眠 1000 毫秒(1 秒),然后执行一个 select 操作。在最开始的 10500 毫秒中,由于 process
协程在 10500 毫秒后才会向 ch
信道写入数据,因此 select
语句的第一个 case(即 case v := <-ch:
)并未就绪。所以在这期间,程序会执行默认情况,该程序会打印 10 次 no value received
。
在 10.5 秒之后,process
协程会在第 10 行向 ch
写入 process successful
。现在,就可以执行 select
语句的第一个 case 了,程序会打印 received value: process successful
,然后程序终止。该程序会输出:
no value received
no value received
no value received
no value received
no value received
no value received
no value received
no value received
no value received
no value received
received value: process successful
死锁与默认情况
package main
func main() {
ch := make(chan string)
select {
case <-ch:
}
}
上面的程序中,我们在第 4 行创建了一个信道 ch
。我们在 select
内部(第 6 行),试图读取信道 ch
。由于没有 Go 协程向该信道写入数据,因此 select
语句会一直阻塞,导致死锁。该程序会触发运行时 panic
,报错信息如下:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/tmp/sandbox416567824/main.go:6 +0x80
如果存在默认情况,就不会发生死锁,因为在没有其他 case 准备就绪时,会执行默认情况。我们用默认情况重写后,程序如下:
package main
import "fmt"
func main() {
ch := make(chan string)
select {
case <-ch:
default:
fmt.Println("default case executed")
}
}
以上程序会输出:
default case executed
如果 select
只含有值为 nil
的信道,也同样会执行默认情况。
package main
import "fmt"
func main() {
var ch chan string
select {
case v := <-ch:
fmt.Println("received value", v)
default:
fmt.Println("default case executed")
}
}
在上面程序中,ch
等于 nil
,而我们试图在 select
中读取 ch
(第 8 行)。如果没有默认情况,select
会一直阻塞,导致死锁。由于我们在 select
内部加入了默认情况,程序会执行它,并输出:
default case executed
随机选取
当 select
由多个 case 准备就绪时,将会随机地选取其中之一去执行。
package main
import (
"fmt"
"time"
)
func server1(ch chan string) {
ch <- "from server1"
}
func server2(ch chan string) {
ch <- "from server2"
}
func main() {
output1 := make(chan string)
output2 := make(chan string)
go server1(output1)
go server2(output2)
time.Sleep(1 * time.Second)
select {
case s1 := <-output1:
fmt.Println(s1)
case s2 := <-output2:
fmt.Println(s2)
}
}
在上面程序里,我们在第 18 行和第 19 行分别调用了 server1
和 server2
两个 Go 协程。接下来,主程序休眠了 1 秒钟(第 20 行)。当程序控制到达第 21 行的 select
语句时,server1
已经把 from server1
写到了 output1
信道上,而 server2
也同样把 from server2
写到了 output2
信道上。因此这个 select
语句中的两种情况都准备好执行了。如果你运行这个程序很多次的话,输出会是 from server1
或者 from server2
,这会根据随机选取的结果而变化。
请在你的本地系统上运行这个程序,获得程序的随机结果。因为如果你在 playground 上在线运行的话,它的输出总是一样的,这是由于 playground 不具有随机性所造成的。
这下我懂了:空 select
package main
func main() {
select {}
}
你认为上面代码会输出什么?
我们已经知道,除非有 case 执行,select 语句就会一直阻塞着。在这里,select
语句没有任何 case,因此它会一直阻塞,导致死锁。该程序会触发 panic,输出如下:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:
main.main()
/tmp/sandbox299546399/main.go:4 +0x20
25-Mutex
临界区
在学习 Mutex 之前,我们需要理解并发编程中临界区(Critical Section)的概念。当程序并发地运行时,多个 [Go 协程]不应该同时访问那些修改共享资源的代码。这些修改共享资源的代码称为临界区。例如,假设我们有一段代码,将一个变量 x
自增 1。
x = x + 1
如果只有一个 Go 协程访问上面的代码段,那都没有任何问题。
但当有多个协程并发运行时,代码却会出错,让我们看看究竟是为什么吧。简单起见,假设在一行代码的前面,我们已经运行了两个 Go 协程。
在上一行代码的内部,系统执行程序时分为如下几个步骤(这里其实还有很多包括寄存器的技术细节,以及加法的工作原理等,但对于我们的系列教程,只需认为只有三个步骤就好了):
- 获得 x 的当前值
- 计算 x + 1
- 将步骤 2 计算得到的值赋值给 x
如果只有一个协程执行上面的三个步骤,不会有问题。
我们讨论一下当有两个并发的协程执行该代码时,会发生什么。下图描述了当两个协程并发地访问代码行 x = x + 1
时,可能出现的一种情况。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SXJhgXlx-1647424781998)(/Users/liuqingzheng/Desktop/go%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8B/image-20190210180333643.png)]
我们假设 x
的初始值为 0。而协程 1 获取 x
的初始值,并计算 x + 1
。而在协程 1 将计算值赋值给 x
之前,系统上下文切换到了协程 2。于是,协程 2 获取了 x
的初始值(依然为 0),并计算 x + 1
。接着系统上下文又切换回了协程 1。现在,协程 1 将计算值 1 赋值给 x
,因此 x
等于 1。然后,协程 2 继续开始执行,把计算值(依然是 1)复制给了 x
,因此在所有协程执行完毕之后,x
都等于 1。
现在我们考虑另外一种可能发生的情况。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yCsFMf7f-1647424781999)(/Users/liuqingzheng/Desktop/go%E7%B3%BB%E5%88%97%E6%95%99%E7%A8%8B/image-20190210180428162.png)]
在上面的情形里,协程 1 开始执行,完成了三个步骤后结束,因此 x
的值等于 1。接着,开始执行协程 2。目前 x
的值等于 1。而当协程 2 执行完毕时,x
的值等于 2。
所以,从这两个例子你可以发现,根据上下文切换的不同情形,x
的最终值是 1 或者 2。这种不太理想的情况称为竞态条件(Race Condition),其程序的输出是由协程的执行顺序决定的。
在上例中,如果在任意时刻只允许一个 Go 协程访问临界区,那么就可以避免竞态条件。而使用 Mutex 可以达到这个目的。
Mutex
Mutex 用于提供一种加锁机制(Locking Mechanism),可确保在某时刻只有一个协程在临界区运行,以防止出现竞态条件。
Mutex 可以在 [sync] 包内找到。[Mutex] 定义了两个方法:[Lock]和 [Unlock](。所有在 Lock
和 Unlock
之间的代码,都只能由一个 Go 协程执行,于是就可以避免竞态条件。
mutex.Lock()
x = x + 1
mutex.Unlock()
在上面的代码中,x = x + 1
只能由一个 Go 协程执行,因此避免了竞态条件。
如果有一个 Go 协程已经持有了锁(Lock),当其他协程试图获得该锁时,这些协程会被阻塞,直到 Mutex 解除锁定为止。
含有竞态条件的程序
在本节里,我们会编写一个含有竞态条件的程序,而在接下来一节,我们再修复竞态条件的问题。
package main
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup) {
x = x + 1
wg.Done()
}
func main() {
var w sync.WaitGroup
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w)
}
w.Wait()
fmt.Println("final value of x", x)
}
在上述程序里,第 7 行的 increment
函数把 x
的值加 1,并调用 [WaitGroup] 的 Done()
,通知该函数已结束。
在上述程序的第 15 行,我们生成了 1000 个 increment
协程。每个 Go 协程并发地运行,由于第 8 行试图增加 x
的值,因此多个并发的协程试图访问 x
的值,这时就会发生竞态条件。
由于 [playground] 具有确定性,竞态条件不会在 playground 发生,请在你的本地运行该程序。请在你的本地机器上多运行几次,可以发现由于竞态条件,每一次输出都不同。我其中遇到的几次输出有 final value of x 941
、final value of x 928
、final value of x 922
等。
使用 Mutex
在前面的程序里,我们创建了 1000 个 Go 协程。如果每个协程对 x
加 1,最终 x
期望的值应该是 1000。在本节,我们会在程序里使用 Mutex,修复竞态条件的问题。
package main
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup, m *sync.Mutex) {
m.Lock()
x = x + 1
m.Unlock()
wg.Done()
}
func main() {
var w sync.WaitGroup
var m sync.Mutex
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, &m)
}
w.Wait()
fmt.Println("final value of x", x)
}
[Mutex]是一个结构体类型,我们在第 15 行创建了 Mutex
类型的变量 m
,其值为零值。在上述程序里,我们修改了 increment
函数,将增加 x
的代码(x = x + 1
)放置在 m.Lock()
和 m.Unlock()
之间。现在这段代码不存在竞态条件了,因为任何时刻都只允许一个协程执行这段代码。
于是如果运行该程序,会输出:
final value of x 1000
在第 18 行,传递 Mutex 的地址很重要。如果传递的是 Mutex 的值,而非地址,那么每个协程都会得到 Mutex 的一份拷贝,竞态条件还是会发生。
使用信道处理竞态条件
我们还能用信道来处理竞态条件。看看是怎么做的。
package main
import (
"fmt"
"sync"
)
var x = 0
func increment(wg *sync.WaitGroup, ch chan bool) {
ch <- true
x = x + 1
<- ch
wg.Done()
}
func main() {
var w sync.WaitGroup
ch := make(chan bool, 1)
for i := 0; i < 1000; i++ {
w.Add(1)
go increment(&w, ch)
}
w.Wait()
fmt.Println("final value of x", x)
}
在上述程序中,我们创建了容量为 1 的[缓冲信道],并在第 18 行将它传入 increment
协程。该缓冲信道用于保证只有一个协程访问增加 x
的临界区。具体的实现方法是在 x
增加之前(第 8 行),传入 true
给缓冲信道。由于缓冲信道的容量为 1,所以任何其他协程试图写入该信道时,都会发生阻塞,直到 x
增加后,信道的值才会被读取(第 10 行)。实际上这就保证了只允许一个协程访问临界区。
该程序也输出:
final value of x 1000
Mutex vs 信道
通过使用 Mutex 和信道,我们已经解决了竞态条件的问题。那么我们该选择使用哪一个?答案取决于你想要解决的问题。如果你想要解决的问题更适用于 Mutex,那么就用 Mutex。如果需要使用 Mutex,无须犹豫。而如果该问题更适用于信道,那就使用信道。😃
由于信道是 Go 语言很酷的特性,大多数 Go 新手处理每个并发问题时,使用的都是信道。这是不对的。Go 给了你选择 Mutex 和信道的余地,选择其中之一都可以是正确的。
总体说来,当 Go 协程需要与其他协程通信时,可以使用信道。而当只允许一个协程访问临界区时,可以使用 Mutex。
就我们上面解决的问题而言,我更倾向于使用 Mutex,因为该问题并不需要协程间的通信。所以 Mutex 是很自然的选择。
我的建议是去选择针对问题的工具,而别让问题去将就工具
26-结构体取代类
Go 支持面向对象吗?
Go 并不是完全面向对象的编程语言。Go 官网回答了 Go 是否是面向对象语言,摘录如下。
可以说是,也可以说不是。虽然 Go 有类型和方法,支持面向对象的编程风格,但却没有类型的层次结构。Go 中的“接口”概念提供了一种不同的方法,我们认为它易于使用,也更为普遍。Go 也可以将结构体嵌套使用,这与子类化(Subclassing)类似,但并不完全相同。此外,Go 提供的特性比 C++ 或 Java 更为通用:子类可以由任何类型的数据来定义,甚至是内建类型(如简单的“未装箱的”整型)。这在结构体(类)中没有受到限制。
在接下来的教程里,我们会讨论如何使用 Go 来实现面向对象编程概念。与其它面向对象语言(如 Java)相比,Go 有很多完全不同的特性。
使用结构体,而非类
Go 不支持类,而是提供了[结构体]。结构体中可以添加[方法]。这样可以将数据和操作数据的方法绑定在一起,实现与类相似的效果。
为了加深理解,我们来编写一个示例吧。
在示例中,我们创建一个自定义[包],它帮助我们更好地理解,结构体是如何有效地取代类的。
在你的 Go 工作区创建一个名为 oop
的文件夹。在 opp
中再创建子文件夹 employee
。在 employee
内,创建一个名为 employee.go
的文件。
文件夹结构会是这样:
workspacepath -> oop -> employee -> employee.go
请将 employee.go
里的内容替换为如下所示的代码。
package employee
import (
"fmt"
)
type Employee struct {
FirstName string
LastName string
TotalLeaves int
LeavesTaken int
}
func (e Employee) LeavesRemaining() {
fmt.Printf("%s %s has %d leaves remaining", e.FirstName, e.LastName, (e.TotalLeaves - e.LeavesTaken))
}
在上述程序里,第 1 行指定了该文件属于 employee
包。而第 7 行声明了一个 Employee
结构体。在第 14 行,结构体 Employee
添加了一个名为 LeavesRemaining
的方法。该方法会计算和显示员工的剩余休假数。于是现在我们有了一个结构体,并绑定了结构体的方法,这与类很相似。
接着在 oop
文件夹里创建一个文件,命名为 main.go
。
现在目录结构如下所示:
workspacepath -> oop -> employee -> employee.go
workspacepath -> oop -> main.go
main.go
的内容如下所示:
package main
import "oop/employee"
func main() {
e := employee.Employee {
FirstName: "Sam",
LastName: "Adolf",
TotalLeaves: 30,
LeavesTaken: 20,
}
e.LeavesRemaining()
}
我们在第 3 行引用了 employee
包。在 main()
(第 12 行),我们调用了 Employee
的 LeavesRemaining()
方法。
由于有自定义包,这个程序不能在 go playground 上运行。你可以在你的本地运行,在 workspacepath/bin/oop
下输入命令 go install opp
,程序会打印输出:
Sam Adolf has 10 leaves remaining
使用 New() 函数,而非构造器
我们上面写的程序看起来没什么问题,但还是有一些细节问题需要注意。我们看看当定义一个零值的 employee
结构体变量时,会发生什么。将 main.go
的内容修改为如下代码:
package main
import "oop/employee"
func main() {
var e employee.Employee
e.LeavesRemaining()
}
我们的修改只是创建一个零值的 Employee
结构体变量(第 6 行)。该程序会输出:
has 0 leaves remaining
你可以看到,使用 Employee
创建的零值变量没有什么用。它没有合法的姓名,也没有合理的休假细节。
在像 Java 这样的 OOP 语言中,是使用构造器来解决这种问题的。一个合法的对象必须使用参数化的构造器来创建。
Go 并不支持构造器。如果某类型的零值不可用,需要程序员来隐藏该类型,避免从其他包直接访问。程序员应该提供一种名为 NewT(parameters)
的 [函数],按照要求来初始化 T
类型的变量。按照 Go 的惯例,应该把创建 T
类型变量的函数命名为 NewT(parameters)
。这就类似于构造器了。如果一个包只含有一种类型,按照 Go 的惯例,应该把函数命名为 New(parameters)
, 而不是 NewT(parameters)
。
让我修改一下原先的代码,使得每当创建 employee
的时候,它都是可用的。
首先应该让 Employee
结构体不可引用,然后创建一个 New
函数,用于创建 Employee
结构体变量。在 employee.go
中输入下面代码:
package employee
import (
"fmt"
)
type employee struct {
firstName string
lastName string
totalLeaves int
leavesTaken int
}
func New(firstName string, lastName string, totalLeave int, leavesTaken int) employee {
e := employee {firstName, lastName, totalLeave, leavesTaken}
return e
}
func (e employee) LeavesRemaining() {
fmt.Printf("%s %s has %d leaves remaining", e.firstName, e.lastName, (e.totalLeaves - e.leavesTaken))
}
我们进行了一些重要的修改。我们把 Employee
结构体的首字母改为小写 e
,也就是将 type Employee struct
改为了 type employee struct
。通过这种方法,我们把 employee
结构体变为了不可引用的,防止其他包对它的访问。除非有特殊需求,否则也要隐藏所有不可引用的结构体的所有字段,这是 Go 的最佳实践。由于我们不会在外部包需要 employee
的字段,因此我们也让这些字段无法引用。
同样,我们还修改了 LeavesRemaining()
的方法。
现在由于 employee
不可引用,因此不能在其他包内直接创建 Employee
类型的变量。于是我们在第 14 行提供了一个可引用的 New
函数,该函数接收必要的参数,返回一个新创建的 employee
结构体变量。
这个程序还需要一些必要的修改,但现在先运行这个程序,理解一下当前的修改。如果运行当前程序,编译器会报错,如下所示:
go/src/constructor/main.go:6: undefined: employee.Employee
这是因为我们将 Employee
设置为不可引用,因此编译器会报错,提示该类型没有在 main.go
中定义。很完美,正如我们期望的一样,其他包现在不能轻易创建零值的 employee
变量了。我们成功地避免了创建不可用的 employee
结构体变量。现在创建 employee
变量的唯一方法就是使用 New
函数。
如下所示,修改 main.go
里的内容。
package main
import "oop/employee"
func main() {
e := employee.New("Sam", "Adolf", 30, 20)
e.LeavesRemaining()
}
该文件唯一的修改就是第 6 行。通过向 New
函数传入所需变量,我们创建了一个新的 employee
结构体变量。
下面是修改后的两个文件的内容。
employee.go
package employee
import (
"fmt"
)
type employee struct {
firstName string
lastName string
totalLeaves int
leavesTaken int
}
func New(firstName string, lastName string, totalLeave int, leavesTaken int) employee {
e := employee {firstName, lastName, totalLeave, leavesTaken}
return e
}
func (e employee) LeavesRemaining() {
fmt.Printf("%s %s has %d leaves remaining", e.firstName, e.lastName, (e.totalLeaves - e.leavesTaken))
}
main.go
package main
import "oop/employee"
func main() {
e := employee.New("Sam", "Adolf", 30, 20)
e.LeavesRemaining()
}
运行该程序,会输出:
Sam Adolf has 10 leaves remaining
现在你能明白了,虽然 Go 不支持类,但结构体能够很好地取代类,而以 New(parameters)
签名的方法可以替代构造器。
27-组合取代继承
title: “27-组合取代继承”date: 2019-03-10 08:08tags:
- Go
Go 不支持继承,但它支持组合(Composition)。组合一般定义为“合并在一起”。汽车就是一个关于组合的例子:一辆汽车由车轮、引擎和其他各种部件组合在一起。
通过嵌套结构体进行组合
在 Go 中,通过在结构体内嵌套结构体,可以实现组合。
组合的典型例子就是博客帖子。每一个博客的帖子都有标题、内容和作者信息。使用组合可以很好地表示它们。通过学习本教程后面的内容,我们会知道如何实现组合。
我们首先创建一个 author
结构体。
package main
import (
"fmt"
)
type author struct {
firstName string
lastName string
bio string
}
func (a author) fullName() string {
return fmt.Sprintf("%s %s", a.firstName, a.lastName)
}
在上面的代码片段中,我们创建了一个 author
结构体,author
的字段有 firstname
、lastname
和 bio
。我们还添加了一个 fullName()
方法,其中 author
作为接收者类型,该方法返回了作者的全名。
下一步我们创建 post
结构体。
type post struct {
title string
content string
author
}
func (p post) details() {
fmt.Println("Title: ", p.title)
fmt.Println("Content: ", p.content)
fmt.Println("Author: ", p.author.fullName())
fmt.Println("Bio: ", p.author.bio)
}
post
结构体的字段有 title
和 content
。它还有一个嵌套的匿名字段 author
。该字段指定 author
组成了 post
结构体。现在 post
可以访问 author
结构体的所有字段和方法。我们同样给 post
结构体添加了 details()
方法,用于打印标题、内容和作者的全名与简介。
一旦结构体内嵌套了一个结构体字段,Go 可以使我们访问其嵌套的字段,好像这些字段属于外部结构体一样。所以上面第 11 行的 p.author.fullName()
可以替换为 p.fullName()
。于是,details()
方法可以重写,如下所示:
func (p post) details() {
fmt.Println("Title: ", p.title)
fmt.Println("Content: ", p.content)
fmt.Println("Author: ", p.fullName())
fmt.Println("Bio: ", p.bio)
}
现在,我们的 author
和 post
结构体都已准备就绪,我们来创建一个博客帖子来完成这个程序。
package main
import (
"fmt"
)
type author struct {
firstName string
lastName string
bio string
}
func (a author) fullName() string {
return fmt.Sprintf("%s %s", a.firstName, a.lastName)
}
type post struct {
title string
content string
author
}
func (p post) details() {
fmt.Println("Title: ", p.title)
fmt.Println("Content: ", p.content)
fmt.Println("Author: ", p.fullName())
fmt.Println("Bio: ", p.bio)
}
func main() {
author1 := author{
"Naveen",
"Ramanathan",
"Golang Enthusiast",
}
post1 := post{
"Inheritance in Go",
"Go supports composition instead of inheritance",
author1,
}
post1.details()
}
在上面程序中,main 函数在第 31 行新建了一个 author
结构体变量。而在第 36 行,我们通过嵌套 author1
来创建一个 post
。该程序输出:
Title: Inheritance in Go
Content: Go supports composition instead of inheritance
Author: Naveen Ramanathan
Bio: Golang Enthusiast
结构体切片的嵌套
我们可以进一步处理这个示例,使用博客帖子的切片来创建一个网站。
我们首先定义 website
结构体。请在上述代码里的 main 函数中,添加下面的代码,并运行它。
type website struct {
[]post
}
func (w website) contents() {
fmt.Println("Contents of Website\n")
for _, v := range w.posts {
v.details()
fmt.Println()
}
}
在你添加上述代码后,当你运行程序时,编译器将会报错,如下所示:
main.go:31:9: syntax error: unexpected [, expecting field name or embedded type
这项错误指出了嵌套的结构体切片 []post
。错误的原因是结构体不能嵌套一个匿名切片。我们需要一个字段名。所以我们来修复这个错误,让编译器顺利通过。
type website struct {
posts []post
}
可以看到,我给帖子的切片 []post
添加了字段名 posts
。
现在我们来修改主函数,为我们的新网站创建一些帖子吧。
修改后的完整代码如下所示:
package main
import (
"fmt"
)
type author struct {
firstName string
lastName string
bio string
}
func (a author) fullName() string {
return fmt.Sprintf("%s %s", a.firstName, a.lastName)
}
type post struct {
title string
content string
author
}
func (p post) details() {
fmt.Println("Title: ", p.title)
fmt.Println("Content: ", p.content)
fmt.Println("Author: ", p.fullName())
fmt.Println("Bio: ", p.bio)
}
type website struct {
posts []post
}
func (w website) contents() {
fmt.Println("Contents of Website\n")
for _, v := range w.posts {
v.details()
fmt.Println()
}
}
func main() {
author1 := author{
"Naveen",
"Ramanathan",
"Golang Enthusiast",
}
post1 := post{
"Inheritance in Go",
"Go supports composition instead of inheritance",
author1,
}
post2 := post{
"Struct instead of Classes in Go",
"Go does not support classes but methods can be added to structs",
author1,
}
post3 := post{
"Concurrency",
"Go is a concurrent language and not a parallel one",
author1,
}
w := website{
posts: []post{post1, post2, post3},
}
w.contents()
}
在上面的主函数中,我们创建了一个作者 author1
,以及三个帖子 post1
、post2
和 post3
。我们最后通过嵌套三个帖子,在第 62 行创建了网站 w
,并在下一行显示内容。
程序会输出:
Contents of Website
Title: Inheritance in Go
Content: Go supports composition instead of inheritance
Author: Naveen Ramanathan
Bio: Golang Enthusiast
Title: Struct instead of Classes in Go
Content: Go does not support classes but methods can be added to structs
Author: Naveen Ramanathan
Bio: Golang Enthusiast
Title: Concurrency
Content: Go is a concurrent language and not a parallel one
Author: Naveen Ramanathan
Bio: Golang Enthusiast
28-多态
title: “28-多态”date: 2019-03-10 08:08tags:
- Go
Go 通过[接口]来实现多态。我们已经讨论过,在 Go 语言中,我们是隐式地实现接口。一个类型如果定义了接口所声明的全部[方法],那它就实现了该接口。现在我们来看看,利用接口,Go 是如何实现多态的。
使用接口实现多态
一个类型如果定义了接口的所有方法,那它就隐式地实现了该接口。
所有实现了接口的类型,都可以把它的值保存在一个接口类型的变量中。在 Go 中,我们使用接口的这种特性来实现多态。
通过一个程序我们来理解 Go 语言的多态,它会计算一个组织机构的净收益。为了简单起见,我们假设这个虚构的组织所获得的收入来源于两个项目:fixed billing
和 time and material
。该组织的净收益等于这两个项目的收入总和。同样为了简单起见,我们假设货币单位是美元,而无需处理美分。因此货币只需简单地用 int
来表示。
我们首先定义一个接口 Income
。
type Income interface {
calculate() int
source() string
}
上面定义了接口 Interface
,它包含了两个方法:calculate()
计算并返回项目的收入,而 source()
返回项目名称。
下面我们定义一个表示 FixedBilling
项目的结构体类型。
type FixedBilling struct {
projectName string
biddedAmount int
}
项目 FixedBillin
有两个字段:projectName
表示项目名称,而 biddedAmount
表示组织向该项目投标的金额。
TimeAndMaterial
结构体用于表示项目 Time and Material。
type TimeAndMaterial struct {
projectName string
noOfHours int
hourlyRate int
}
结构体 TimeAndMaterial
拥有三个字段名:projectName
、noOfHours
和 hourlyRate
。
下一步我们给这些结构体类型定义方法,计算并返回实际收入和项目名称。
func (fb FixedBilling) calculate() int {
return fb.biddedAmount
}
func (fb FixedBilling) source() string {
return fb.projectName
}
func (tm TimeAndMaterial) calculate() int {
return tm.noOfHours * tm.hourlyRate
}
func (tm TimeAndMaterial) source() string {
return tm.projectName
}
在项目 FixedBilling
里面,收入就是项目的投标金额。因此我们返回 FixedBilling
类型的 calculate()
方法。
而在项目 TimeAndMaterial
里面,收入等于 noOfHours
和 hourlyRate
的乘积,作为 TimeAndMaterial
类型的 calculate()
方法的返回值。
我们还通过 source()
方法返回了表示收入来源的项目名称。
由于 FixedBilling
和 TimeAndMaterial
两个结构体都定义了 Income
接口的两个方法:calculate()
和 source()
,因此这两个结构体都实现了 Income
接口。
我们来声明一个 calculateNetIncome
函数,用来计算并打印总收入。
func calculateNetIncome(ic []Income) {
var netincome int = 0
for _, income := range ic {
fmt.Printf("Income From %s = $%d\n", income.source(), income.calculate())
netincome += income.calculate()
}
fmt.Printf("Net income of organisation = $%d", netincome)
}
上面的[函数]接收一个 Income
接口类型的[切片]作为参数。该函数会遍历这个接口切片,并依个调用 calculate()
方法,计算出总收入。该函数同样也会通过调用 source()
显示收入来源。根据 Income
接口的具体类型,程序会调用不同的 calculate()
和 source()
方法。于是,我们在 calculateNetIncome
函数中就实现了多态。
如果在该组织以后增加了新的收入来源,calculateNetIncome
无需修改一行代码,就可以正确地计算总收入了。
最后就剩下这个程序的 main
函数了。
func main() {
project1 := FixedBilling{projectName: "Project 1", biddedAmount: 5000}
project2 := FixedBilling{projectName: "Project 2", biddedAmount: 10000}
project3 := TimeAndMaterial{projectName: "Project 3", noOfHours: 160, hourlyRate: 25}
incomeStreams := []Income{project1, project2, project3}
calculateNetIncome(incomeStreams)
}
在上面的 main
函数中,我们创建了三个项目,有两个是 FixedBilling
类型,一个是 TimeAndMaterial
类型。接着我们创建了一个 Income
类型的切片,存放了这三个项目。由于这三个项目都实现了 Interface
接口,因此可以把这三个项目放入 Income
切片。最后我们将该切片作为参数,调用了 calculateNetIncome
函数,显示了项目不同的收益和收入来源。
以下完整的代码供你参考。
package main
import (
"fmt"
)
type Income interface {
calculate() int
source() string
}
type FixedBilling struct {
projectName string
biddedAmount int
}
type TimeAndMaterial struct {
projectName string
noOfHours int
hourlyRate int
}
func (fb FixedBilling) calculate() int {
return fb.biddedAmount
}
func (fb FixedBilling) source() string {
return fb.projectName
}
func (tm TimeAndMaterial) calculate() int {
return tm.noOfHours * tm.hourlyRate
}
func (tm TimeAndMaterial) source() string {
return tm.projectName
}
func calculateNetIncome(ic []Income) {
var netincome int = 0
for _, income := range ic {
fmt.Printf("Income From %s = $%d\n", income.source(), income.calculate())
netincome += income.calculate()
}
fmt.Printf("Net income of organisation = $%d", netincome)
}
func main() {
project1 := FixedBilling{projectName: "Project 1", biddedAmount: 5000}
project2 := FixedBilling{projectName: "Project 2", biddedAmount: 10000}
project3 := TimeAndMaterial{projectName: "Project 3", noOfHours: 160, hourlyRate: 25}
incomeStreams := []Income{project1, project2, project3}
calculateNetIncome(incomeStreams)
}
该程序会输出:
Income From Project 1 = $5000
Income From Project 2 = $10000
Income From Project 3 = $4000
Net income of organisation = $19000
新增收益流
假设前面的组织通过广告业务,建立了一个新的收益流(Income Stream)。我们可以看到添加它非常简单,并且计算总收益也很容易,我们无需对 calculateNetIncome
函数进行任何修改。这就是多态的好处。
我们首先定义 Advertisement
类型,并在 Advertisement
类型中定义 calculate()
和 source()
方法。
type Advertisement struct {
adName string
CPC int
noOfClicks int
}
func (a Advertisement) calculate() int {
return a.CPC * a.noOfClicks
}
func (a Advertisement) source() string {
return a.adName
}
Advertisement
类型有三个字段,分别是 adName
、CPC
(每次点击成本)和 noOfClicks
(点击次数)。广告的总收益等于 CPC
和 noOfClicks
的乘积。
现在我们稍微修改一下 main
函数,把新的收益流添加进来。
func main() {
project1 := FixedBilling{projectName: "Project 1", biddedAmount: 5000}
project2 := FixedBilling{projectName: "Project 2", biddedAmount: 10000}
project3 := TimeAndMaterial{projectName: "Project 3", noOfHours: 160, hourlyRate: 25}
bannerAd := Advertisement{adName: "Banner Ad", CPC: 2, noOfClicks: 500}
popupAd := Advertisement{adName: "Popup Ad", CPC: 5, noOfClicks: 750}
incomeStreams := []Income{project1, project2, project3, bannerAd, popupAd}
calculateNetIncome(incomeStreams)
}
我们创建了两个广告项目,即 bannerAd
和 popupAd
。incomeStream
切片包含了这两个创建的广告项目。
package main
import (
"fmt"
)
type Income interface {
calculate() int
source() string
}
type FixedBilling struct {
projectName string
biddedAmount int
}
type TimeAndMaterial struct {
projectName string
noOfHours int
hourlyRate int
}
type Advertisement struct {
adName string
CPC int
noOfClicks int
}
func (fb FixedBilling) calculate() int {
return fb.biddedAmount
}
func (fb FixedBilling) source() string {
return fb.projectName
}
func (tm TimeAndMaterial) calculate() int {
return tm.noOfHours * tm.hourlyRate
}
func (tm TimeAndMaterial) source() string {
return tm.projectName
}
func (a Advertisement) calculate() int {
return a.CPC * a.noOfClicks
}
func (a Advertisement) source() string {
return a.adName
}
func calculateNetIncome(ic []Income) {
var netincome int = 0
for _, income := range ic {
fmt.Printf("Income From %s = $%d\n", income.source(), income.calculate())
netincome += income.calculate()
}
fmt.Printf("Net income of organisation = $%d", netincome)
}
func main() {
project1 := FixedBilling{projectName: "Project 1", biddedAmount: 5000}
project2 := FixedBilling{projectName: "Project 2", biddedAmount: 10000}
project3 := TimeAndMaterial{projectName: "Project 3", noOfHours: 160, hourlyRate: 25}
bannerAd := Advertisement{adName: "Banner Ad", CPC: 2, noOfClicks: 500}
popupAd := Advertisement{adName: "Popup Ad", CPC: 5, noOfClicks: 750}
incomeStreams := []Income{project1, project2, project3, bannerAd, popupAd}
calculateNetIncome(incomeStreams)
}
上面程序会输出:
Income From Project 1 = $5000
Income From Project 2 = $10000
Income From Project 3 = $4000
Income From Banner Ad = $1000
Income From Popup Ad = $3750
Net income of organisation = $23750
你会发现,尽管我们新增了收益流,但却完全没有修改 calculateNetIncome
函数。这就是多态带来的好处。由于新的 Advertisement
同样实现了 Income
接口,所以我们能够向 incomeStreams
切片添加 Advertisement
。calculateNetIncome
无需修改,因为它能够调用 Advertisement
类型的 calculate()
和 source()
方法。
29-Defer
什么是 defer?
defer
语句的用途是:含有 defer
语句的函数,会在该函数将要返回之前,调用另一个函数。这个定义可能看起来很复杂,我们通过一个示例就很容易明白了。
示例
package main
import (
"fmt"
)
func finished() {
fmt.Println("Finished finding largest")
}
func largest(nums []int) {
defer finished()
fmt.Println("Started finding largest")
max := nums[0]
for _, v := range nums {
if v > max {
max = v
}
}
fmt.Println("Largest number in", nums, "is", max)
}
func main() {
nums := []int{78, 109, 2, 563, 300}
largest(nums)
}
上面的程序很简单,就是找出一个给定切片的最大值。largest
函数接收一个 int 类型的切片作为参数,然后打印出该切片中的最大值。largest
函数的第一行的语句为 defer finished()
。这表示在 finished()
函数将要返回之前,会调用 finished()
函数。运行该程序,你会看到有如下输出:
Started finding largest
Largest number in [78 109 2 563 300] is 563
Finished finding largest
largest
函数开始执行后,会打印上面的两行输出。而就在 largest
将要返回的时候,又调用了我们的延迟函数(Deferred Function),打印出 Finished finding largest
的文本。
延迟方法
defer
不仅限于[函数]的调用,调用[方法]也是合法的。我们写一个小程序来测试吧。
package main
import (
"fmt"
)
type person struct {
firstName string
lastName string
}
func (p person) fullName() {
fmt.Printf("%s %s",p.firstName,p.lastName)
}
func main() {
p := person {
firstName: "John",
lastName: "Smith",
}
defer p.fullName()
fmt.Printf("Welcome ")
}
在上面的例子中,我们在第 22 行延迟了一个方法调用。而其他的代码很直观,这里不再解释。该程序输出:
Welcome John Smith
实参取值(Arguments Evaluation)
在 Go 语言中,并非在调用延迟函数的时候才确定实参,而是当执行 defer
语句的时候,就会对延迟函数的实参进行求值。
通过一个例子就能够理解了。
package main
import (
"fmt"
)
func printA(a int) {
fmt.Println("value of a in deferred function", a)
}
func main() {
a := 5
defer printA(a)
a = 10
fmt.Println("value of a before deferred function call", a)
}
在上面的程序里的第 11 行,a
的初始值为 5。在第 12 行执行 defer
语句的时候,由于 a
等于 5,因此延迟函数 printA
的实参也等于 5。接着我们在第 13 行将 a
的值修改为 10。下一行会打印出 a
的值。该程序输出:
value of a before deferred function call 10
value of a in deferred function 5
从上面的输出,我们可以看出,在调用了 defer
语句后,虽然我们将 a
修改为 10,但调用延迟函数 printA(a)
后,仍然打印的是 5。
defer 栈
当一个函数内多次调用 defer
时,Go 会把 defer
调用放入到一个栈中,随后按照后进先出(Last In First Out, LIFO)的顺序执行。
我们下面编写一个小程序,使用 defer
栈,将一个字符串逆序打印。
package main
import (
"fmt"
)
func main() {
name := "Naveen"
fmt.Printf("Orignal String: %s\n", string(name))
fmt.Printf("Reversed String: ")
for _, v := range []rune(name) {
defer fmt.Printf("%c", v)
}
}
在上述程序中的第 11 行,for range
循环会遍历一个字符串,并在第 12 行调用了 defer fmt.Printf("%c", v)
。这些延迟调用会添加到一个栈中,按照后进先出的顺序执行,因此,该字符串会逆序打印出来。该程序会输出:
Orignal String: Naveen
Reversed String: neevaN
defer 的实际应用
目前为止,我们看到的代码示例,都没有体现出 defer
的实际用途。本节我们会看看 defer
的实际应用。
当一个函数应该在与当前代码流(Code Flow)无关的环境下调用时,可以使用 defer
。我们通过一个用到了 [WaitGroup
] 代码示例来理解这句话的含义。我们首先会写一个没有使用 defer
的程序,然后我们会用 defer
来修改,看到 defer
带来的好处。
package main
import (
"fmt"
"sync"
)
type rect struct {
length int
width int
}
func (r rect) area(wg *sync.WaitGroup) {
if r.length < 0 {
fmt.Printf("rect %v's length should be greater than zero\n", r)
wg.Done()
return
}
if r.width < 0 {
fmt.Printf("rect %v's width should be greater than zero\n", r)
wg.Done()
return
}
area := r.length * r.width
fmt.Printf("rect %v's area %d\n", r, area)
wg.Done()
}
func main() {
var wg sync.WaitGroup
r1 := rect{-67, 89}
r2 := rect{5, -67}
r3 := rect{8, 9}
rects := []rect{r1, r2, r3}
for _, v := range rects {
wg.Add(1)
go v.area(&wg)
}
wg.Wait()
fmt.Println("All go routines finished executing")
}
在上面的程序里,我们在第 8 行创建了 rect
结构体,并在第 13 行创建了 rect
的方法 area
,计算出矩形的面积。area
检查了矩形的长宽是否小于零。如果矩形的长宽小于零,它会打印出对应的提示信息,而如果大于零,它会打印出矩形的面积。
main
函数创建了 3 个 rect
类型的变量:r1
、r2
和 r3
。在第 34 行,我们把这 3 个变量添加到了 rects
切片里。该切片接着使用 for range
循环遍历,把 area
方法作为一个并发的 Go 协程进行调用(第 37 行)。我们用 WaitGroup wg
来确保 main
函数在其他协程执行完毕之后,才会结束执行。WaitGroup
作为参数传递给 area
方法后,在第 16 行、第 21 行和第 26 行通知 main
函数,表示现在协程已经完成所有任务。如果你仔细观察,会发现 wg.Done() 只在 area 函数返回的时候才会调用。wg.Done() 应该在 area 将要返回之前调用,并且与代码流的路径(Path)无关,因此我们可以只调用一次 defer,来有效地替换掉 wg.Done() 的多次调用。
我们来用 defer
来重写上面的代码。
在下面的代码中,我们移除了原先程序中的 3 个 wg.Done
的调用,而是用一个单独的 defer wg.Done()
来取代它(第 14 行)。这使得我们的代码更加简洁易懂。
package main
import (
"fmt"
"sync"
)
type rect struct {
length int
width int
}
func (r rect) area(wg *sync.WaitGroup) {
defer wg.Done()
if r.length < 0 {
fmt.Printf("rect %v's length should be greater than zero\n", r)
return
}
if r.width < 0 {
fmt.Printf("rect %v's width should be greater than zero\n", r)
return
}
area := r.length * r.width
fmt.Printf("rect %v's area %d\n", r, area)
}
func main() {
var wg sync.WaitGroup
r1 := rect{-67, 89}
r2 := rect{5, -67}
r3 := rect{8, 9}
rects := []rect{r1, r2, r3}
for _, v := range rects {
wg.Add(1)
go v.area(&wg)
}
wg.Wait()
fmt.Println("All go routines finished executing")
}
该程序会输出:
rect {8 9}'s area 72
rect {-67 89}'s length should be greater than zero
rect {5 -67}'s width should be greater than zero
All go routines finished executing
在上面的程序中,使用 defer
还有一个好处。假设我们使用 if
条件语句,又给 area
方法添加了一条返回路径(Return Path)。如果没有使用 defer
来调用 wg.Done()
,我们就得很小心了,确保在这条新添的返回路径里调用了 wg.Done()
。由于现在我们延迟调用了 wg.Done()
,因此无需再为这条新的返回路径添加 wg.Done()
了。
30-错误处理
title: “30-错误处理 “date: 2019-03-10 08:08tags:
- Go
什么是错误?
错误表示程序中出现了异常情况。比如当我们试图打开一个文件时,文件系统里却并没有这个文件。这就是异常情况,它用一个错误来表示。
在 Go 中,错误一直是很常见的。错误用内建的 error
类型来表示。
就像其他的内建类型(如 int
、float64
等),错误值可以存储在变量里、作为函数的返回值等等。
示例
现在我们开始编写一个示例,该程序试图打开一个并不存在的文件。
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("/test.txt")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(f.Name(), "opened successfully")
}
在程序的第 9 行,我们试图打开路径为 /test.txt
的文件。os
包里的 [Open
]函数有如下签名:
func Open(name string) (file *File, err error)
如果成功打开文件,Open 函数会返回一个文件句柄(File Handler)和一个值为 nil 的错误。而如果打开文件时发生了错误,会返回一个不等于 nil 的错误。
如果一个[函数] 或[方法] 返回了错误,按照惯例,错误会作为最后一个值返回。于是 Open
函数也是将 err
作为最后一个返回值。
按照 Go 的惯例,在处理错误时,通常都是将返回的错误与 nil 比较。nil 值表示了没有错误发生,而非 nil 值表示出现了错误。在这里,我们第 10 行检查了错误值是否为 nil
。如果不是 nil
,我们会简单地打印出错误,并在 main
函数中返回。
运行该程序会输出:
open /test.txt: No such file or directory
很棒!我们得到了一个错误,它指出该文件并不存在。
错误类型的表示
让我们进一步深入,理解 error
类型是如何定义的。error
是一个[接口]类型,定义如下:
type error interface {
Error() string
}
error
有了一个签名为 Error() string
的方法。所有实现该接口的类型都可以当作一个错误类型。Error()
方法给出了错误的描述。
fmt.Println
在打印错误时,会在内部调用 Error() string
方法来得到该错误的描述。上一节示例中的第 11 行,就是这样打印出错误的描述的。
从错误获取更多信息的不同方法
现在,我们知道了 error
是一个接口类型,让我们看看如何从一个错误获取更多信息。
在前面的示例里,我们只是打印出错误的描述。如果我们想知道这个错误的文件路径,该怎么做呢?一种选择是直接解析错误的字符串。这是前面示例的输出:
open /test.txt: No such file or directory
我们解析了这条错误信息,虽然获取了发生错误的文件路径,但是这种方法很不优雅。随着语言版本的更新,这条错误的描述随时都有可能变化,使我们程序出错。
有没有更加可靠的方法来获取文件名呢?答案是肯定的,这是可以做到的,Go 标准库给出了各种提取错误相关信息的方法。我们一个个来看看吧。
1. 断言底层结构体类型,使用结构体字段获取更多信息
如果你仔细阅读了 [Open
] 函数的文档,你可以看见它返回的错误类型是 *PathError
。[PathError
]是[结构体]类型,它在标准库中的实现如下:
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }
通过上面的代码,你就知道了 *PathError
通过声明 Error() string
方法,实现了 error
接口。Error() string
将文件操作、路径和实际错误拼接,并返回该字符串。于是我们得到该错误信息:
open /test.txt: No such file or directory
结构体 PathError
的 Path
字段,就有导致错误的文件路径。我们修改前面写的程序,打印出该路径。
package main
import (
"fmt"
"os"
)
func main() {
f, err := os.Open("/test.txt")
if err, ok := err.(*os.PathError); ok {
fmt.Println("File at path", err.Path, "failed to open")
return
}
fmt.Println(f.Name(), "opened successfully")
}
在上面的程序里,我们在第 10 行使用了[类型断言](Type Assertion)来获取 error
接口的底层值(Underlying Value)。接下来在第 11 行,我们使用 err.Path
来打印该路径。该程序会输出:
File at path /test.txt failed to open
很棒!我们已经使用类型断言成功获取到了该错误的文件路径。
2. 断言底层结构体类型,调用方法获取更多信息
第二种获取更多错误信息的方法,也是对底层类型进行断言,然后通过调用该结构体类型的方法,来获取更多的信息。
我们通过一个实例来理解这一点。
标准库中的 DNSError
结构体类型定义如下:
type DNSError struct {
...
}
func (e *DNSError) Error() string {
...
}
func (e *DNSError) Timeout() bool {
...
}
func (e *DNSError) Temporary() bool {
...
}
从上述代码可以看到,DNSError
结构体还有 Timeout() bool
和 Temporary() bool
两个方法,它们返回一个布尔值,指出该错误是由超时引起的,还是临时性错误。
接下来我们编写一个程序,断言 *DNSError
类型,并调用这些方法来确定该错误是临时性错误,还是由超时导致的。
package main
import (
"fmt"
"net"
)
func main() {
addr, err := net.LookupHost("golangbot123.com")
if err, ok := err.(*net.DNSError); ok {
if err.Timeout() {
fmt.Println("operation timed out")
} else if err.Temporary() {
fmt.Println("temporary error")
} else {
fmt.Println("generic error: ", err)
}
return
}
fmt.Println(addr)
}
在上述程序中,我们在第 9 行,试图获取 golangbot123.com
(无效的域名) 的 ip。在第 10 行,我们通过 *net.DNSError
的类型断言,获取到了错误的底层值。接下来的第 11 行和第 13 行,我们分别检查了该错误是由超时引起的,还是一个临时性错误。
在本例中,我们的错误既不是临时性错误,也不是由超时引起的,因此该程序输出:
generic error: lookup golangbot123.com: no such host
如果该错误是临时性错误,或是由超时引发的,那么对应的 if 语句会执行,于是我们就可以适当地处理它们。
3. 直接比较
第三种获取错误的更多信息的方式,是与 error
类型的变量直接比较。我们通过一个示例来理解。
filepath
包中的 [Glob
] 用于返回满足 glob 模式的所有文件名。如果模式写的不对,该函数会返回一个错误 ErrBadPattern
。
filepath
包中的 ErrBadPattern
定义如下:
var ErrBadPattern = errors.New("syntax error in pattern")
errors.New()
用于创建一个新的错误。我们会在下一教程中详细讨论它。
当模式不正确时,Glob
函数会返回 ErrBadPattern
。
我们来写一个小程序来看看这个错误。
package main
import (
"fmt"
"path/filepath"
)
func main() {
files, error := filepath.Glob("[")
if error != nil && error == filepath.ErrBadPattern {
fmt.Println(error)
return
}
fmt.Println("matched files", files)
}
在上述程序里,我们查询了模式为 [
的文件,然而这个模式写的不正确。我们检查了该错误是否为 nil
。为了获取该错误的更多信息,我们在第 10 行将 error
直接与 filepath.ErrBadPattern
相比较。如果该条件满足,那么该错误就是由模式错误导致的。该程序会输出:
syntax error in pattern
标准库在提供错误的详细信息时,使用到了上述提到的三种方法。在下一教程里,我们会通过这些方法来创建我们自己的自定义错误。
不可忽略错误
绝不要忽略错误。忽视错误会带来问题。接下来我重写上面的示例,在列出所有满足模式的文件名时,我省略了错误处理的代码。
package main
import (
"fmt"
"path/filepath"
)
func main() {
files, _ := filepath.Glob("[")
fmt.Println("matched files", files)
}
我们已经从前面的示例知道了这个模式是错误的。在第 9 行,通过使用 _
空白标识符,我忽略了 Glob
函数返回的错误。我在第 10 行简单打印了所有匹配的文件。该程序会输出:
matched files []
由于我忽略了错误,输出看起来就像是没有任何匹配了 glob 模式的文件,但实际上这是因为模式的写法不对。所以绝不要忽略错误。
本教程到此结束。
这一教程我们讨论了该如何处理程序中出现的错误,也讨论了如何查询关于错误的更多信息。简单概括一下本教程讨论的内容:
- 什么是错误?
- 错误的表示
- 获取错误详细信息的各种方法
- 不能忽视错误
本文来自博客园,作者:码诅,转载请注明原文链接:https://www.cnblogs.com/zdwzdwzdw/p/17487861.html