学习 Go:完整课程
目录
什么是 Go?
为什么要学习 Go?
安装和设置
你好世界
变量和数据类型
字符串格式化
流量控制
功能
模块
工作区
套餐
有用的命令
建造
指针
结构体
方法
数组和切片
地图
接口
错误
恐慌与恢复
测试
泛型
并发
Goroutines
频道
选择
等待组
互斥体
后续步骤
欢迎来到本课程,感谢你学习 Go。希望本课程能为你带来精彩的学习体验!
注意:本课程也可在我的网站上免费获取。
目录
-
入门
-
第一章
-
第二章
-
第三章
-
第四章
-
附录
什么是 Go?
Go(也称为Golang)是 Google 于 2007 年开发并于 2009 年开源的一种编程语言。
它注重简洁、可靠和高效。它旨在将静态类型和编译语言的高效、快速和安全性与动态语言的易编程性相结合,让编程再次变得更有趣。
在某种程度上,他们希望结合 Python 和 C++ 的优点,以便能够构建能够利用多核处理器的可靠系统。
为什么要学习 Go?
在开始本课程之前,我们先来谈谈为什么要学习 Go
1. 易于学习
Go 相当容易学习,并且拥有一个支持性和活跃的社区。
作为一种多用途语言,您可以将它用于后端开发、云计算以及最近的数据科学等领域。
2.快速可靠
这使得它非常适合分布式系统。Kubernetes 和 Docker 等项目都是用 Go 编写的。
3. 简单但强大
Go 语言只有 25 个关键字,这使得它易于阅读、编写和维护。语言本身非常简洁。
但不要被它的简单性所迷惑,Go 有几个强大的功能,我们稍后会在课程中学习。
4. 职业机会
Go 发展迅速,各种规模的公司都在采用它。同时,它也带来了新的高薪工作机会。
希望这能让你对 Go 产生兴趣。让我们开始这门课程吧。
在本教程中,我们将安装 Go 并设置我们的代码编辑器
安装和设置
下载
我们可以从下载部分安装 Go。
安装
这些说明来自官方网站
MacOS
-
打开下载的软件包文件,然后按照提示安装 Go。
软件包会将 Go 发行版安装到/usr/local/go
。软件包会将/usr/local/go/bin
目录添加到你的PATH
环境变量中。
你可能需要重启所有打开的终端会话才能使更改生效。 -
打开命令提示符并输入以下命令来验证是否已安装 Go:
$ go version
- 确认该命令打印已安装的 Go 版本。
Linux
- 通过删除文件夹(如果存在)来删除所有以前的 Go 安装
/usr/local/go
,然后将刚下载的档案提取到中/usr/local
,在中创建一个新的 Go 树/usr/local/go
:
$ rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.1.linux-amd64.tar.gz
(您可能需要以 root 身份或通过 sudo 运行该命令)
不要将存档解压到现有目录/usr/local/go
树中。这会导致 Go 安装失败。
- 添加
/usr/local/go/bin
到 PATH 环境变量。你可以将以下行添加到你的$HOME/.profile
或/etc/profile
(对于系统范围的安装)中:
export PATH=$PATH:/usr/local/go/bin
注意:对配置文件所做的更改可能要等到下次登录计算机时才会生效。要立即应用更改,只需直接运行 shell 命令,或使用 source 等命令从配置文件中执行更改$HOME/.profile
。
- 打开命令提示符并输入以下命令来验证是否已安装 Go:
$ go version
- 确认该命令打印已安装的 Go 版本。
视窗
- 打开您下载的 MSI 文件并按照提示安装 Go。
默认情况下,安装程序将安装“转到 Program Files”或“Program Files (x86)”。
您可以根据需要更改位置。安装完成后,您需要关闭并重新打开所有打开的命令提示符,以便安装程序对环境所做的更改能够反映在命令提示符中。
- 验证您是否已安装 Go。
- 在 Windows 中,单击“开始”菜单。
- 在菜单的搜索框中,输入 cmd,然后按 Enter 键。
- 在出现的命令提示符窗口中,键入以下命令:
$ go version
- 确认该命令打印已安装的 Go 版本。
VS 代码
在本课程中,我将使用VS Code ,您可以从这里下载它。
随意使用您喜欢的任何其他代码编辑器
扩大
确保还安装了Go 扩展
,这使得 在 VS Code 中使用 Go 更加容易。
这就是 Go 的安装和设置,让我们开始课程并编写我们的第一个 hello world!
你好世界
让我们编写第一个 Hello World 程序,首先初始化一个模块。为此,我们可以使用以下go mod
命令
$ go mod init example
可是等等……什么是 a module
?别担心,我们很快会讨论这个问题!不过现在,先假设模块基本上是 Go 包的集合。
接下来,让我们创建一个main.go
文件并编写一个简单打印 hello world 的程序。
package main
import "fmt"
func main() {
fmt.Println("Hello World!")
}
如果你想知道,fmt
它是 Go 标准库的一部分,它是 Go 语言提供的一组核心包
现在,让我们快速分解一下我们在这里所做的事情,或者更确切地说,分解一下 Go 程序的结构
首先,我们定义一个包,例如main
package main
然后,我们有一些进口
import "fmt"
最后但并非最不重要的是我们的main
函数,它充当我们应用程序的入口点,就像 C、Java 或 C# 等其他语言一样。
func main() {
...
}
请记住,这里的目标是记住这一点,在课程的后面,我们将详细了解、和其他内容functions
!imports
最后,要运行我们的代码,我们可以简单地使用go run
命令
$ go run main.go
Hello World!
恭喜,您刚刚编写了您的第一个 Go 程序!
让我们继续下一个话题
变量和数据类型
在本教程中,我们将学习变量。我们还将学习 Go 提供的不同数据类型。
变量
让我们从声明一个变量开始
这也称为无初始化声明
var foo string
带有初始化的声明
var foo string = "Go is awesome"
多个声明
var foo, bar string = "Hello", "World"
// OR
var (
foo string = "Hello"
bar string = "World"
)
类型被省略但会被推断
var foo = "What's my type?"
简写,这里我们省略了var
关键字,类型始终是隐式的。这就是我们大多数情况下变量声明的方式。我们也使用:=
for 声明加赋值
foo := "Shorthand!"
注意:简写仅适用于function
主体内部
常量
我们也可以用关键字声明常量const
。顾名思义,常量是固定值,不能重新赋值。
const constant = "This is a constant"
数据类型
太棒了!现在我们来看看 Go 中一些基本的数据类型。从字符串开始。
细绳
在 Go 中,字符串是字节序列。
它们使用双引号或反引号声明,可以跨越多行
var name string = "My name is Go"
var bio string = `I am statically typed.
I was designed at Google.`
布尔值
接下来是bool
用于存储布尔值的 。它可以有两个可能的值 -true
或false
。
var value bool = false
var isItTrue bool = true
运算符
我们可以在布尔类型上使用以下运算符
逻辑 | && | ! |
平等 | == | != |
数字类型
现在我们来讨论一下数字类型,首先是
有符号整数和无符号整数
Go 有几种不同大小的内置整数类型,用于存储有符号和无符号整数
int
泛型和类型的大小uint
取决于平台。这意味着在 32 位系统上,它的宽度为 32 位;在 64 位系统上,它的宽度为 64 位。
var i int = 404 // Platform dependent
var i8 int8 = 127 // -128 to 127
var i16 int16 = 32767 // -2^15 to 2^15 - 1
var i32 int32 = -2147483647 // -2^31 to 2^31 - 1
var i64 int64 = 9223372036854775807 // -2^63 to 2^63 - 1
与有符号整数类似,我们有无符号整数。
var ui uint = 404 // Platform dependent
var ui8 uint8 = 255 // 0 to 255
var ui16 uint16 = 65535 // 0 to 2^16
var ui32 uint32 = 2147483647 // 0 to 2^32
var ui64 uint64 = 9223372036854775807 // 0 to 2^64
var uiptr uintptr // Integer representation of a memory address
如果你注意到的话,还有一个无符号整数指针uintptr
类型,它是内存地址的整数表示。我们不建议使用它,所以我们不必担心。
那么我们应该使用哪一个呢?
建议无论何时我们需要一个整数值,都应该使用,int
除非我们有特殊原因要使用有大小或无符号的整数类型。
整数别名类型
接下来,让我们讨论整数别名类型。
字节和符文
Golang 有两个额外的整数类型,称为byte
和,它们分别是和数据类型的rune
别名。uint8
int32
type byte = uint8
type rune = int32
Arune
代表一个 unicode 代码点。
var b byte = 'a'
var r rune = '🍕'
浮点
接下来,我们有浮点类型,用于存储带有小数部分的数字。
Go 有两种浮点类型float32
和float64
。两种类型都遵循 IEEE-754 标准。
浮点值的默认类型是 float64
var f32 float32 = 1.7812 // IEEE-754 32-bit
var f64 float64 = 3.1415 // IEEE-754 64-bit
运算符
Go 提供了几个对数字类型执行运算的运算符。
复杂的
Go 中有 2 种复数类型。complex128 的实部和虚部都是 float64,complex64 的实部和虚部都是 float32。
我们可以使用内置复数函数或文字来定义复数。
var c1 complex128 = complex(10, 1)
var c2 complex64 = 12 + 4i
零值
现在我们来讨论一下零值。在 Go 中,任何声明时没有明确指定初始值的变量都会被赋予零值。例如,我们声明一些变量,并看看
var i int
var f float64
var b bool
var s string
fmt.Printf("%v %v %v %q\n", i, f, b, s)
$ go run main.go
0 0 false ""
因此,正如我们所见int
,和float
被赋值为 0、bool
false 和string
空字符串。这与其他语言的做法截然不同。例如,大多数语言将未赋值的变量初始化为 null 或 undefined。
这很棒,但是我们的函数中的百分号是什么呢Printf
?正如你已经猜到的,它们用于格式化,我们将在下一个教程中学习它们。
类型转换
接下来,我们已经了解了数据类型的工作原理,让我们看看如何进行类型转换。
i := 42
f := float64(i)
u := uint(f)
fmt.Printf("%T %T", f, u)
$ go run main.go
float64 uint
我们可以看到,它将类型打印为float64
和uint
。
请注意,这与解析不同
别名类型
Go 1.9 中引入了别名类型
它们允许开发人员为现有类型提供备用名称,并将其与底层类型互换使用。
package main
import "fmt"
type MyAlias = string
func main() {
var str MyAlias = "I am an alias"
fmt.Printf("%T - %s", str, str) // Output: string - I am an alias
}
定义类型
最后,我们定义了与别名类型不同不使用等号的类型。
package main
import "fmt"
type MyDefined string
func main() {
var str MyDefined = "I am defined"
fmt.Printf("%T - %s", str, str) // Output: main.MyDefined - I am defined
}
但是等等...有什么区别?
因此,定义类型不仅仅是为类型命名。
它首先定义了一个具有底层类型的新命名类型。然而,这个定义的类型与任何其他类型(包括其底层类型)都不同。
因此,它不能与别名类型等底层类型互换使用。
一开始有点令人困惑,希望这个例子能够让事情变得清楚。
package main
import "fmt"
type MyAlias = string
type MyDefined string
func main() {
var alias MyAlias
var def MyDefined
// ✅ Works
var copy1 string = alias
// ❌ Cannot use str (variable of type MyDefined) as string value in variable
var copy2 string = def
fmt.Println(copy1, copy2)
}
我们可以看到,与别名类型不同,我们不能将定义的类型与底层类型互换使用。
好了,Go 中的变量和数据类型基本就这些了。下期再见。
字符串格式化
在本教程中,我们将学习字符串格式化,有时也称为模板。
fmt
包中包含许多函数。为了节省时间,我们将讨论最常用的函数。让我们fmt.Print
从 main 函数开始。
...
fmt.Print("What", "is", "your", "name?")
fmt.Print("My", "name", "is", "golang")
...
$ go run main.go
Whatisyourname?Mynameisgolang
正如我们所见,Print
它不格式化任何内容,只是获取一个字符串并打印它
接下来,我们有Println
相同的Print
,但它在末尾添加了一个新行,并在参数之间插入空格
...
fmt.Println("What", "is", "your", "name?")
fmt.Println("My", "name", "is", "golang")
...
$ go run main.go
What is your name?
My name is golang
这好多了!
接下来,我们Printf
还有所谓的“打印格式化程序”,它允许我们格式化数字、字符串、布尔值等等。
让我们看一个例子
...
name := "golang"
fmt.Println("What is your name?")
fmt.Printf("My name is %s", name)
...
$ go run main.go
What is your name?
My name is golang
我们可以看到,它%s
被我们的变量替换了name
。
但问题是它是什么%s
以及它意味着什么?
这些被称为注解动词,它们告诉函数如何格式化参数。我们可以用它们来控制宽度、类型和精度等参数,而且有很多。这里有一个速查表
现在我们快速看一些例子。这里我们将尝试计算一个百分比并将其打印到控制台。
...
percent := (3/5) * 100
fmt.Printf("%f", percent)
...
$ go run main.go
58.181818
假设我们想要的只是58.18
2 点精度,我们也可以使用.2f
此外,要添加实际的百分号,我们需要对其进行转义。
...
percent := (3/5) * 100
fmt.Printf("%.2f %%", percent)
...
$ go run main.go
58.18 %
这将引出Sprint
、Sprintln
和Sprintf
。它们与打印函数基本相同,唯一的区别是它们返回字符串而不是打印它。
让我们举个例子
...
s := fmt.Sprintf("hex:%x bin:%b", 10 ,10)
fmt.Println(s)
...
$ go run main.go
hex:a bin:1010
因此,正如我们所看到的,Sprintf
我们的整数格式化为十六进制或二进制,并将其作为字符串返回。
最后,我们有多行字符串文字,可以像这样使用
...
msg := `
Hello from
multiline
`
fmt.Println(msg)
...
太棒了!但这只是冰山一角……所以请务必查看 Go 文档中的fmt
软件包。
对于有 C/C++ 背景的人来说,这应该很自然。但如果你之前学习过 Python 或 JavaScript,一开始可能会有点奇怪。但它非常强大,你会发现这个功能被广泛使用。
流量控制
让我们讨论一下流程控制,从 if/else 开始。
如果/否则
这与你预期的几乎相同,但表达式不需要用括号括起来()
func main() {
x := 10
if x > 5 {
fmt.Println("x is gt 5")
} else if x > 10 {
fmt.Println("x is gt 10")
} else {
fmt.Println("else case")
}
}
$ go run main.go
x is gt 5
紧凑型
我们还可以压缩 if 语句
func main() {
if x := 10; x > 5 {
fmt.Println("x is gt 5")
}
}
注意:这种模式很常见
转变
接下来,我们有switch
语句,这通常是编写条件逻辑的一种更简短的方式。
在 Go 中,switch case 只会执行第一个值等于条件表达式的 case,而不会执行后面的所有 case。因此,与其他语言不同,break
每个 case 的末尾都会自动添加 statement。
这意味着它会从上到下评估案例,当一个案例成功时停止。我们来看一个例子
func main() {
day := "monday"
switch day {
case "monday":
fmt.Println("time to work!")
case "friday":
fmt.Println("let's party")
default:
fmt.Println("browse memes")
}
}
$ go run main.go
time to work!
Switch 还支持像这样的简写声明
switch day := "monday"; day {
case "monday":
fmt.Println("time to work!")
case "friday":
fmt.Println("let's party")
default:
fmt.Println("browse memes")
}
我们还可以使用fallthrough
关键字将控制权转移到下一个案例,即使当前案例可能已经匹配。
switch day := "monday"; day {
case "monday":
fmt.Println("time to work!")
fallthrough
case "friday":
fmt.Println("let's party")
default:
fmt.Println("browse memes")
}
如果我们运行这个,我们会看到,在第一个 case 匹配之后,switch 语句会继续执行下一个 case,因为fallthrough
关键字
$ go run main.go
time to work!
let's party
我们也可以不带任何条件地使用它,这与switch true
x := 10
switch {
case x > 5:
fmt.Println("x is greater")
default:
fmt.Println("x is not greater")
}
循环
现在,让我们将注意力转向循环。
所以在 Go 中,我们只有一种循环,那就是for
循环。
但它的用途非常广泛。与 if 语句一样,for 循环不需要括号,这一点()
与其他语言不同。
让我们从基本的 for 循环开始。
func main() {
for i := 0; i < 10; i++ {
fmt.Println(i)
}
}
基本for
循环由三个用分号分隔的组件组成:
- init 语句:在第一次迭代之前执行
- 条件表达式:每次迭代之前进行评估
- post 语句:在每次迭代结束时执行
中断并继续
正如预期的那样,Go 也支持break
andcontinue
语句进行循环控制。我们来尝试一个简单的例子
func main() {
for i := 0; i < 10; i++ {
if i < 2 {
continue
}
fmt.Println(i)
if i > 5 {
break
}
}
fmt.Println("We broke out!")
}
因此,continue
当我们想要跳过循环的剩余部分时使用语句,break
当我们想要跳出循环时使用语句。
此外,Init 和 post 语句是可选的,因此我们可以使我们的for
循环也像 while 循环一样运行。
func main() {
i := 0
for ;i < 10; {
i += 1
}
}
注意:我们还可以删除多余的分号,使其更简洁一些
永远循环
最后,如果我们省略循环条件,它将永远循环,因此无限循环可以简洁地表达。这也被称为永久循环
func main() {
for {
// do stuff here
}
}
好吧,关于流量控制就讲到这里,下次再见!
功能
在本教程中,我们将讨论如何在 Go 中使用函数。首先,让我们从一个简单的函数声明开始。
简单声明
func myFunction() {}
我们可以按如下方式调用或执行它
...
myFunction()
...
让我们向它传递一些参数
func main() {
myFunction("Hello")
}
func myFunction(p1 string) {
fmt.Printtln(p1)
}
$ go run main.go
正如我们所见,它打印了我们的消息
如果连续的参数具有相同的类型,我们也可以进行简写声明。例如,
func myNextFunction(p1, p2 string) {}
返回值
现在让我们也返回一个值
func main() {
s := myFunction("Hello")
fmt.Println(s)
}
func myFunction(p1 string) string {
msg := fmt.Sprintf("%s function", p1)
return msg
}
多次返回
为什么每次只返回一个值呢?Go 还支持多个返回值!
func main() {
s, i := myFunction("Hello")
fmt.Println(s, i)
}
func myFunction(p1 string) (string, int) {
msg := fmt.Sprintf("%s function", p1)
return msg, 10
}
命名回报
另一个很酷的功能是命名返回,其中返回值可以被命名并视为它们自己的变量
func myFunction(p1 string) (s string, i int) {
s = fmt.Sprintf("%s function", p1)
i = 10
return
}
注意我们如何添加一个return
没有任何参数的语句,这也称为裸返回
我想说的是,尽管这个功能很有趣,但请小心使用,因为这可能会降低较大函数的可读性
函数作为值
接下来,我们来谈谈函数作为值的概念。在 Go 中,函数是一等公民,我们可以将其作为值来使用。所以,让我们整理一下函数,然后尝试一下!
func myFunction() {
fn := func() {
fmt.Println("inside fn")
}
fn()
}
我们还可以通过创建匿名函数fn
来简化这一点
func myFunction() {
func() {
fmt.Println("inside fn")
}()
}
注意我们是如何使用括号来执行它的
闭包
为什么要止步于此呢?我们再返回一个函数,这样就创建了一个叫做闭包的东西。一个简单的定义是,闭包是一个引用函数体外部变量的函数值。
闭包具有词法作用域,这意味着函数可以在定义函数时访问作用域内的值。
func myFunction() func(int) int {
sum := 0
return func(v int) int {
sum += v
return sum
}
}
...
add := myFunction()
add(5)
fmt.Println(add(10))
...
sum
可以看到,由于变量绑定到了函数,结果为 15。这是一个非常强大的概念,绝对值得了解。
可变函数
现在让我们看一下可变函数,它们可以使用...
省略号运算符接受零个或多个参数。
这里的一个例子是一个可以添加一堆值的函数
func main() {
sum := add(1, 2, 3, 5)
fmt.Println(sum)
}
func add(values ...int) int {
sum := 0
for _, v := range values {
sum += v
}
return sum
}
很酷吧?另外,不用担心range
关键词,我们稍后会在课程中讨论。
有趣的事实:fmt.Println
是一个可变函数,这就是我们能够向它传递多个值的方式。
推迟
最后,让我们讨论一下defer
关键字,它让我们推迟函数的执行,直到周围的函数返回。
func main() {
defer fmt.Println("I am finished")
fmt.Println("Doing some work...")
}
我们可以使用多个 defer 函数吗?当然可以,这就引出了所谓的defer stack,我们来看一个例子
func main() {
defer fmt.Println("I am finished")
defer fmt.Prinlnt("Are you?")
fmt.Println("Doing some work...")
}
$ go run main.go
Doing some work...
Are you?
I am finished
我们可以看到,defer 语句被堆叠起来并以后进先出的方式执行。
因此,Defer 非常有用,通常用于清理或错误处理。
函数也可以与泛型一起使用,但我们将在课程后面讨论它们。
这就是 Go 中的函数,下篇教程再见。
模块
在本教程中,我们将学习模块。
那么什么是模块?
简单来说,模块是存储在文件树中的Go 包go.mod
的集合,其根目录位于文件之外 $GOPATH/src
Go 1.11 引入了 Go 模块,该版本提供了对版本和模块的原生支持。之前,GO111MODULE=on
当模块功能处于实验阶段时,我们需要使用标志来启用它。但现在,从 Go 1.13 开始,模块模式已成为所有开发环境的默认模式。
但等等,是什么GOPATH
?
嗯,GOPATH
这是一个定义工作区根目录的变量,它包含以下文件夹:
- src:包含按层次结构组织的 Go 源代码
- pkg:包含已编译的包代码
- bin:包含已编译的二进制文件和可执行文件。
像之前一样,让我们使用go mod init
命令创建一个新模块,该命令创建一个新模块并初始化go.mod
描述它的文件
$ go mod init example
这里需要注意的是,如果你计划发布某个 Go 模块,它也可以对应一个 Github 仓库。例如:
$ go mod init example
现在,让我们探索一下定义模块的模块路径的go.mod
文件以及用于根目录的导入路径及其依赖要求
module <name>
go <version>
require (
...
)
如果我们想添加新的依赖项,我们将使用go install
命令
$ go install github.com/rs/zerolog
我们可以看到,还创建了一个文件。该文件包含新模块内容的go.sum
预期哈希值。
go list
我们可以使用以下命令列出所有依赖项
$ go list -m all
如果依赖项未使用,我们可以使用go mod tidy
命令简单地将其删除。
$ go mod tidy
结束对模块的讨论后,我们来讨论一下供应商。
Vendoring 是指为项目正在使用的第三方软件包制作自己的副本。这些副本通常放置在每个项目中,然后保存在项目仓库中。
这可以通过go mod vendor
命令完成
因此,让我们使用以下方法重新安装已删除的模块go mod tidy
package main
import "github.com/rs/zerolog/log"
func main() {
log.Info().Msg("Hello")
}
$ go mod tidy
go: finding module for package github.com/rs/zerolog/log
go: found github.com/rs/zerolog/log in github.com/rs/zerolog v1.26.1
$ go mod vendor
├── go.mod
├── go.sum
├── go.work
├── main.go
└── vendor
├── github.com
│ └── rs
│ └── zerolog
│ └── ...
└── modules.txt
所以对于模块来说这基本上就是这样,我们将在下一个教程中见到您。
工作区
在本教程中,我们将了解 Go 1.18 中引入的多模块工作区
工作区允许我们同时处理多个模块,而无需go.mod
为每个模块编辑文件。在解析依赖关系时,工作区中的每个模块都被视为根模块。
为了更好地理解这一点,我们先创建一个hello
模块
$ mkdir workspaces && cd workspaces
$ mkdir hello && cd hello
$ go mod init hello
为了演示目的,我将添加一个简单的main.go
并安装一个示例包。
package main
import (
"fmt"
"golang.org/x/example/stringutil"
)
func main() {
result := stringutil.Reverse("Hello Workspace")
fmt.Println(result)
}
$ go get golang.org/x/example
go: downloading golang.org/x/example v0.0.0-20220412213650-2e68773dfca0
go: added golang.org/x/example v0.0.0-20220412213650-2e68773dfca0
如果我们运行这个,我们应该看到相反的输出。
$ go run main.go
ecapskroW olleH
这很好,但是如果我们想修改stringutil
我们的代码所依赖的模块怎么办?
到目前为止,我们必须使用文件replace
中的指令来完成此操作go.mod
,但现在让我们看看如何在此处使用工作区。
因此,让我们在workspace
目录中创建我们的工作区
$ go work init
这将创建一个[go.work](http://go.work)
文件
$ cat go.work
go 1.18
我们还将把我们的hello
模块添加到工作区。
$ go work use ./hello
这应该使用对我们模块[go.work](http://go.work)
的引用来更新文件hello
go 1.18
use ./hello
现在我们下载并修改stringutil
包并更新Reverse
函数实现
$ git clone https://go.googlesource.com/example
Cloning into 'example'...
remote: Total 204 (delta 39), reused 204 (delta 39)
Receiving objects: 100% (204/204), 467.53 KiB | 363.00 KiB/s, done.
Resolving deltas: 100% (39/39), done.
example/stringutil/reverse.go
func Reverse(s string) string {
return fmt.Sprintf("I can do whatever!! %s", s)
}
最后,让我们将example
包添加到我们的工作区
$ go work use ./example
$ cat go.work
go 1.18
use (
./example
./hello
)
完美,现在如果我们运行我们的hello
模块,我们会注意到该Reverse
功能已被修改。
$ go run hello
I can do whatever!! Hello Workspace
这是 Go 1.18 中一个被低估的功能,但在某些情况下非常有用。
这就是 Go 中的工作区,我们将在下一个教程中见到您。
套餐
在本教程中,我们将讨论包。
那么什么是包?
包只不过是一个包含一个或多个 Go 源文件或其他 Go 包的目录。
这意味着每个 Go 源文件必须属于一个包,并且包声明在每个源文件的顶部完成,如下所示
package <package_name>
到目前为止,我们已经完成了 中的所有操作package main
。按照惯例,可执行程序(我指的是main
包中的程序)被称为 *命令,其他的则简称为包。
该main
包还应包含一个main()
函数,该函数是一个充当可执行程序入口点的特殊函数。
让我们举个例子,创建我们自己的包custom
并向其中添加一些源文件,例如code.go
package custom
在继续之前,我们应该先讨论一下 import 和 export。和其他语言一样,Go 也有一个 import 和 export 的概念,但它非常优雅。
基本上,如果使用大写标识符定义任何值(如变量或函数),则可以将其导出并从其他包中看到。
让我们在我们的custom
包中尝试一个例子
package custom
var value int = 10 // Will not be exported
var Value int = 20 // Will be exported
我们可以看到小写标识符不会被导出,并且对于它所定义的包来说是私有的。在我们的例子中是custom
包。
太棒了,但该如何导入或访问它呢?嗯,就像我们之前不知不觉就做过的那样。让我们进入main.go
文件并导入我们的custom
包。
这里我们可以使用之前在文件module
中初始化的go.mod
---go.mod---
module example
go 1.18
---main.go--
package main
import "example/custom"
func main() {
custom.Value
}
注意包名称是导入路径的最后一个名称
我们也可以像这样导入多个包。
package main
import (
"fmt"
"example/custom"
)
func main() {
fmt.Println(custom.Value)
}
我们还可以为导入添加别名,以避免此类冲突
package main
import (
"fmt"
abcd "example/custom"
)
func main() {
fmt.Println(abcd.Value)
}
外部依赖项
在 Go 中,我们不仅限于使用本地包,还可以go install
像之前看到的那样使用命令安装外部包。
因此,让我们下载一个简单的日志包github.com/rs/zerolog/log
$ go install github.com/rs/zerolog
package main
import (
"github.com/rs/zerolog/log"
abcd "example/custom"
)
func main() {
log.Print(abcd.Value)
}
另外,请务必查看你安装的软件包的 Go 文档,它通常位于项目的自述文件中。Go 文档会解析源代码并生成 HTML 格式的文档。它的参考资料通常位于自述文件中。
最后,我要补充一点,Go 没有特定的“文件夹结构”约定,请始终尝试以简单直观的方式组织您的包。
所以这就是关于包的全部内容,下篇教程再见!
有用的命令
在模块讨论中,我们讨论了一些与 go 模块相关的 go 命令,现在让我们讨论一些其他重要的命令
从开始go fmt
,它格式化源代码并由该语言强制执行,以便我们可以专注于我们的代码应该如何工作而不是我们的代码应该如何看起来。
$ go fmt
这可能一开始看起来有点奇怪,特别是如果你像我一样有 javascript 或 python 背景,但坦率地说,不用担心 linting 规则还是很不错的。
接下来,我们要go vet
报告包裹中可能存在的错误。
所以如果我继续犯语法错误,然后运行go vet
它应该通知我错误
$ go vet
接下来,我们go env
简单地打印所有的 go 环境信息,我们将在下一个教程中了解其中一些构建时变量。
最后,我们go doc
显示了包或符号的文档,这是格式包的示例
$ go doc -src fmt Printf
让我们使用go help
命令来查看还有哪些可用的命令。
$ go help
正如我们所见,我们有
go fix
查找使用旧 API 的 Go 程序,并重写它们以使用较新的 API
go generate
通常用于代码生成
go install
编译并安装包和依赖项
go clean
用于清理编译器生成的文件
其他一些非常重要的命令是go build
和,go test
但我们将在课程后面详细了解它们。
关于 go 命令的内容基本就是这样了,欢迎大家尝试!我们下篇教程再见。
建造
构建静态二进制文件是 Go 的最佳特性之一,它使我们能够高效地交付代码
go build
我们可以使用命令轻松完成此操作
package main
import "fmt"
func main() {
fmt.Println("I am a binary!")
}
$ go build
这应该会生成一个以我们模块名称命名的二进制文件。例如,这里有example
我们还可以指定输出
$ go build -o app
现在要运行它,我们只需执行它
$ ./app
I am a binary!
是的,就这么简单!
现在,让我们讨论一些重要的构建时间变量,首先是
GOOS
和GOARCH
这些环境变量有助于为不同的操作系统
和底层处理器架构构建 go 程序
我们可以使用go tool
命令列出所有支持的体系结构
$ go tool dist list
android/amd64
ios/amd64
js/wasm
linux/amd64
windows/arm64
.
.
.
这是从 macOS 构建窗口可执行文件的示例!
$ GOOS=windows GOARCH=amd64 go build -o app.exe
CGO_ENABLED
这个变量允许我们配置CGO,这是 Go 中调用 C 代码的一种方式。
这有助于我们生成无需任何外部依赖即可运行的静态链接二进制文件。
当我们想在具有最少外部依赖性的 docker 容器中运行我们的 go 二进制文件时,这非常有用。
以下是如何使用它的示例
$ CGO_ENABLED=0 go build -o app
指针
在本教程中,我们将讨论指针。那么什么是指针?
简单的定义,指针是一个用于存储另一个变量的内存地址的变量。
可以这样使用
var x *T
其中 T 是类型,例如int
、string
、float
等等
让我们尝试一个简单的例子并看看它的实际效果
package main
import "fmt"
func main() {
var p *int
fmt.Println(p)
}
$ go run main.go
nil
嗯,这会打印nil
,但是什么nil
?
因此,nil 是 Go 中的预声明标识符,表示指针、接口、通道、映射和切片的零值。
这就像我们在变量和数据类型部分学到的一样,我们看到未初始化的int
零值为 0,abool
为 false,等等。
好的,现在让我们给指针赋值
package main
import "fmt"
func main() {
a := 10
var p *int = &a
fmt.Println("address:", p)
}
我们使用&
与号运算符来引用变量的内存地址。
$ go run main.go
0xc0000b8000
这必须是变量的内存地址的值a
取消引用
我们还可以使用*
星号运算符来访问指针指向的变量中存储的值。这称为解引用
例如,我们可以使用星号运算符a
通过指针访问变量的值。p
*
package main
import "fmt"
func main() {
a := 10
var p *int = &a
fmt.Println("address:", p)
fmt.Println("value:", *p)
}
$ go run main.go
address: 0xc000018030
value: 10
我们不仅可以访问它,还可以通过指针改变它
package main
import "fmt"
func main() {
a := 10
var p *int = &a
fmt.Println("before", a)
fmt.Println("address:", p)
*p = 20
fmt.Println("after:", a)
}
$ go run main.go
before 10
address: 0xc000192000
after: 20
我认为这很棒!
指针作为函数参数
当我们需要通过引用传递一些数据时,指针也可以用作函数的参数。
这是一个例子
myFunction(&a)
...
func myFunction(ptr *int) {}
新功能
还有另一种初始化指针的方法。我们可以使用一个new
函数,该函数接受一个类型作为参数,分配足够的内存来容纳该类型的值,并返回一个指向该类型的指针。
这是一个例子
package main
import "fmt"
func main() {
p := new(int)
*p = 100
fmt.Println("value", *p)
fmt.Println("address", p)
}
$ go run main.go
value 100
address 0xc000018030
指向指针的指针
这里有一个有趣的想法……我们能创建一个指向指针的指针吗?答案是肯定的!
是的,我们可以。
package main
import "fmt"
func main() {
p := new(int)
*p = 100
p1 := &p
fmt.Println("P value", *p, " address", p)
fmt.Println("P1 value", *p1, " address", p)
fmt.Println("Dereferenced value", **p1)
}
$ go run main.go
P value 100 address 0xc0000be000
P1 value 0xc0000be000 address 0xc0000be000
Dereferenced value 100
注意的值如何p1
与的地址匹配p
另外,重要的是要知道 Go 中的指针不像 C 或 C++ 那样支持指针运算。
p1 := p * 2 // Compiler Error: invalid operation
==
但是,我们可以使用运算符比较两个相同类型的指针是否相等
p := &a
p1 := &a
fmt.Println(p == p1)
但为什么?
这就给我们带来了一个价值百万美元的问题:我们为什么需要指针?
嗯,这个问题没有明确的答案,指针只是另一个有用的功能,它可以帮助我们有效地改变数据,而无需复制大量数据。
并且可以应用于大量用例。
最后,我要补充一点,如果您来自一种没有指针概念的语言,请不要惊慌,并尝试形成一个指针如何工作的心理模型。
完美!我们了解了指针及其用例,现在让我们继续下一个主题。
结构体
在本教程中,我们将学习结构。
因此,Astruct
是一个用户定义类型,包含命名字段的集合。它的作用是将相关数据组合在一起,形成一个单元。
如果您来自面向对象的背景,请将结构视为支持组合但不支持继承的轻量级类。
定义
我们可以struct
像这样定义
type Person struct {}
我们使用type
关键字来引入新类型,然后是名称,然后是struct
关键字来表明我们正在定义一个结构体
现在让我们给它一些字段
type Person struct {
FirstName string
LastName string
Age int
}
如果字段类型相同,我们也可以折叠它们
type Person struct {
FirstName, LastName string
Age int
}
声明和初始化
现在我们有了结构体,我们可以像其他数据类型一样声明它
func main() {
var p1 Person
fmt.Println("Person 1:", p1)
}
$ go run main.go
Person 1: { 0}
我们可以看到,所有结构体字段都初始化为零值。因此,FirstName
和LastName
被设置为“”
空字符串,Age
被设置为 0。
我们也可以简单地将其初始化为“结构文字”
func main() {
var p1 Person
fmt.Println("Person 1:", p1)
var p2 = Person{FirstName: "Karan", LastName: "Pratap Singh", Age: 22}
fmt.Println("Person 2:", p2)
}
为了便于阅读,我们可以用新行分隔,但这也需要一个逗号
var p2 = Person{
FirstName: "Karan",
LastName: "Pratap Singh",
Age: 22,
}
$ go run main.go
Person 1: { 0}
Person 2: {Karan Pratap Singh 22}
我们也可以只初始化字段的子集
func main() {
var p1 Person
fmt.Println("Person 1:", p1)
var p2 = Person{
FirstName: "Karan",
LastName: "Pratap Singh",
Age: 22,
}
fmt.Println("Person 2:", p2)
var p3 = Person{
FirstName: "Tony",
LastName: "Stark",
}
fmt.Println("Person 3:", p3)
}
$ go run main.go
Person 1: { 0}
Person 2: {Karan Pratap Singh 22}
Person 3: {Tony Stark 0}
我们可以看到,3号人的年龄字段已经默认为零值。
不带字段名
Go 结构体也支持无字段名的初始化
func main() {
var p1 Person
fmt.Println("Person 1:", p1)
var p2 = Person{
FirstName: "Karan",
LastName: "Pratap Singh",
Age: 22,
}
fmt.Println("Person 2:", p2)
var p3 = Person{
FirstName: "Tony",
LastName: "Stark",
}
fmt.Println("Person 3:", p3)
var p4 = Person{"Bruce", "Wayne"}
fmt.Println("Person 4:", p4)
}
但这里有一个问题,我们需要在初始化期间提供所有值,否则它将失败
$ go run main.go
# command-line-arguments
./main.go:30:27: too few values in Person{...}
var p4 = Person{"Bruce", "Wayne", 40}
fmt.Println("Person 4:", p4)
我们还可以声明一个匿名结构体
func main() {
var p1 Person
fmt.Println("Person 1:", p1)
var p2 = Person{
FirstName: "Karan",
LastName: "Pratap Singh",
Age: 22,
}
fmt.Println("Person 2:", p2)
var p3 = Person{
FirstName: "Tony",
LastName: "Stark",
}
fmt.Println("Person 3:", p3)
var p4 = Person{"Bruce", "Wayne", 40}
fmt.Println("Person 4:", p4)
var a = struct {
Name string
}{"Golang"}
fmt.Println("Anonymous:", a)
}
访问字段
让我们稍微整理一下示例,看看如何访问各个字段
func main() {
var p = Person{
FirstName: "Karan",
LastName: "Pratap Singh",
Age: 22,
}
fmt.Println("FirstName", p.FirstName)
}
我们也可以创建指向结构的指针
func main() {
var p = Person{
FirstName: "Karan",
LastName: "Pratap Singh",
Age: 22,
}
ptr := &p
fmt.Println((*ptr).FirstName)
fmt.Println(ptr.FirstName)
}
这两个语句是相等的,因为在 Go 中我们不需要显式地取消引用指针
我们还可以使用内置new
函数
func main() {
p := new(Person)
p.FirstName = "Karan"
p.LastName = "Pratap Singh"
p.Age = 22
fmt.Println("Person", p)
}
$ go run main.go
Person &{Karan Pratap Singh 22}
附注:如果两个结构体的所有对应字段也相等,则它们相等
func main() {
var p1 = Person{"a", "b", 20}
var p2 = Person{"a", "b", 20}
fmt.Println(p1 == p2)
}
$ go run main.go
true
导出的字段
现在让我们了解一下结构体中的导出字段和非导出字段。与变量和函数的规则相同,如果结构体字段使用小写标识符声明,则它将不会被导出,并且仅在其定义的包中可见。
type Person struct {
FirstName, LastName string
Age int
zipCode string
}
因此,该zipCode
字段不会被导出。同样,Person
如果我们重命名结构体,person
它也会同样不会被导出。
type person struct {
FirstName, LastName string
Age int
zipCode string
}
嵌入和组合
正如我们之前讨论过的,Go 不一定支持继承,但我们可以通过嵌入做类似的事情
type Person struct {
FirstName, LastName string
Age int
}
type SuperHero struct {
Person
Power int
}
因此,我们的新结构体将具有原始结构体的所有属性。并且其行为应该与我们的正常结构体相同。
func main() {
s := SuperHero{}
s.FirstName = "Bruce"
s.LastName = "Wayne"
s.Age = 40
s.Alias = "batman"
fmt.Println(s)
}
$ go run main.go
{{Bruce Wayne 40} batman}
然而,通常不建议这样做,大多数情况下,组合更受欢迎。因此,我们不会将其嵌入,而是将其定义为普通字段。
type Person struct {
FirstName, LastName string
Age int
}
type SuperHero struct {
Person Person
Alias string
}
因此,我们也可以用组合来重写我们的例子
func main() {
p := Person{"Bruce", "Wayne", 40}
s := SuperHero{p, "batman"}
fmt.Println(s)
}
$ go run main.go
{{Bruce Wayne 40} batman}
再次强调,这里没有对错之分,但尽管如此,嵌入有时还是很有用的。
结构标签
结构标签只是一个标签,它允许我们将元数据信息附加到字段,该字段可用于使用reflect
包的自定义行为。
让我们学习如何定义结构标签。
type Animal struct {
Name string `key:"value1"`
Age int `key:"value2"`
}
您经常会在编码包中找到标签,例如 XML、JSON、YAML、ORM 和配置管理。
这是 JSON 编码器的标签示例。
type Animal struct {
Name string `json:"name"`
Age int `json:"age"`
}
特性
最后,我们来讨论一下结构体的属性。
结构体是值类型。当我们将一个struct
变量赋值给另一个变量时,struct
会创建并赋值该变量的一个新副本。
类似地,当我们将 a 传递struct
给另一个函数时,该函数会获得它自己的 的副本struct
。
package main
import "fmt"
type Point struct {
X, Y float64
}
func main() {
p1 := Point{1, 2}
p2 := p1 // Copy of p1 is assigned to p2
p2.X = 2
fmt.Println(p1) // Output: {1 2}
fmt.Println(p2) // Output: {2 2}
}
空结构占用零字节存储空间
package main
import (
"fmt"
"unsafe"
)
func main() {
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // Output: 0
}
好了,关于的讨论到此结束structs
。接下来,我们将学习如何使用方法扩展结构体。
方法
让我们来讨论方法,或者有时也称为函数接收器。
从技术上讲,Go 不是面向对象的编程语言。它没有类、对象和继承。
然而,Go 有类型。而且,你可以在类型上定义方法。
方法只不过是一个带有特殊接收者参数的函数。让我们看看如何声明方法
func (variable T) Name(params) (returnTypes) {}
接收者参数具有名称和类型。它出现在func
关键字和方法名称之间
例如,我们定义一个Car
结构体
type Car struct {
Name string
Year int
}
现在让我们定义一个方法,IsLatest
它可以告诉我们一辆汽车是否在过去 5 年内制造
func (c Car) IsLatest() bool {
return c.Year >= 2017
}
Car
如你所见,我们可以使用接收变量来访问实例c
。我喜欢把它看作是this
面向对象世界中的关键字。
现在我们应该能够在初始化结构后调用此方法,就像我们在其他语言中对类所做的那样
func main() {
c := Car{"Tesla", 2021}
fmt.Println("IsLatest", c.IsLatest())
}
带有指针接收器的方法
我们之前看到的所有例子都有一个值接收器。
使用值接收者时,方法操作的是传递给它的值的副本。因此,方法内部对接收者所做的任何修改对调用者都是不可见的。
例如,让我们创建另一个方法,UpdateName
它将更新Car
func (c Car) UpdateName(name string) {
c.Name = name
}
现在让我们运行这个
func main() {
c := Car{"Tesla", 2021}
c.UpdateName("Toyota")
fmt.Println("Car:", c)
}
$ go run main.go
Car: {Tesla 2021}
好像名称没有更新,所以现在让我们将接收器切换为指针类型并重试
func (c *Car) UpdateName(name string) {
c.Name = name
}
$ go run main.go
Car: {Toyota 2021}
正如预期的那样,带有指针接收者的方法可以修改接收者指向的值。此类修改对于方法的调用者也是可见的。
特性
我们还来看看这些方法的一些属性!
- Go 足够智能,能够正确解释我们的函数调用,因此,指针接收器方法调用只是 Go 为了方便而提供的语法糖。
(&c).UpdateName(...)
- 如果我们不使用接收器的变量部分,我们也可以省略它
func (Car) UpdateName(...) {}
- 方法不仅限于结构体,还可以用于非结构体类型
package main
import "fmt"
type MyInt int
func (i MyInt) isGreater(value int) bool {
return i > MyInt(value)
}
func main() {
i := MyInt(10)
fmt.Println(i.isGreater(5))
}
为什么使用方法而不是函数?
那么问题是,为什么是方法而不是函数?
一如既往,这个问题没有特定的答案,也不存在哪一种更好。相反,它们应该根据具体情况适当使用。
我现在能想到的一件事是方法可以帮助我们避免命名冲突。
由于方法与特定类型相关,因此我们可以为多个接收器使用相同的方法名称。
但一般来说,这可能只是取决于偏好?例如“方法调用比函数调用更容易阅读和理解”或反之亦然。
至此,我们关于方法的讨论就结束了,下篇教程再见
数组和切片
在本教程中,我们将学习 Go 中的数组和切片。
数组
那么数组是什么?
数组是相同类型元素的固定大小集合。数组元素按顺序存储,可以使用其index
宣言
我们可以按如下方式声明一个数组
var a [n]T
这n
是长度,T
可以是任何类型,如整数、字符串或用户定义的结构。
现在,让我们声明一个长度为 4 的整数数组并打印它。
func main() {
var arr [4]int
fmt.Println(arr)
}
$ go run main.go
[0 0 0 0]
默认情况下,所有数组元素都用相应数组类型的零值初始化。
初始化
我们还可以使用数组文字初始化数组
var a [n]T = [n]T{V1, V2, ... Vn}
func main() {
var arr = [4]int{1, 2, 3, 4}
fmt.Println(arr)
}
$ go run main.go
[1 2 3 4]
我们甚至可以做一个简写声明
...
arr := [4]int{1, 2, 3, 4}
使用权
与其他语言类似,我们可以使用访问元素,index
因为它们是按顺序存储的
func main() {
arr := [4]int{1, 2, 3, 4}
fmt.Println(arr[0])
}
$ go run main.go
1
迭代
现在,我们来讨论迭代。
因此有多种方法可以迭代数组。
第一个是使用 for 循环和len
函数来获取数组的长度
func main() {
arr := [4]int{1, 2, 3, 4}
for i := 0; i < len(arr); i++ {
fmt.Printf("Index: %d, Element: %d\n", i, arr[i])
}
}
$ go run main.go
Index: 0, Element: 1
Index: 1, Element: 2
Index: 2, Element: 3
Index: 3, Element: 4
另一种方法是使用range
for 循环中的关键字
func main() {
arr := [4]int{1, 2, 3, 4}
for i, e := range arr {
fmt.Printf("Index: %d, Element: %d\n", i, e)
}
}
$ go run main.go
Index: 0, Element: 1
Index: 1, Element: 2
Index: 2, Element: 3
Index: 3, Element: 4
我们可以看到,我们的示例与以前一样工作。
但 range 关键字用途非常广泛,可以以多种方式使用。
for i, e := range arr {} // Normal usage of range
for _, e := range arr {} // Omit index with _ and use element
for i := range arr {} // Use index only
for range arr {} // Simply loop over the array
多维
到目前为止,我们创建的所有数组都是一维的。我们也可以在 Go 中创建多维数组。
让我们举个例子
func main() {
arr := [2][4]int{
{1, 2, 3, 4},
{5, 6, 7, 8},
}
for i, e := range arr {
fmt.Printf("Index: %d, Element: %d\n", i, e)
}
}
$ go run main.go
Index: 0, Element: [1 2 3 4]
Index: 1, Element: [5 6 7 8]
...
我们还可以让编译器通过使用省略号而不是长度来推断数组的长度
func main() {
arr := [...][4]int{
{1, 2, 3, 4},
{5, 6, 7, 8},
}
for i, e := range arr {
fmt.Printf("Index: %d, Element: %d\n", i, e)
}
}
$ go run main.go
Index: 0, Element: [1 2 3 4]
Index: 1, Element: [5 6 7 8]
特性
现在我们来讨论一下数组的一些属性。
数组的长度是其类型的一部分。因此,数组a
和b
是完全不同的类型,我们不能将一个赋值给另一个。
这也意味着我们不能调整数组的大小,因为调整数组的大小意味着改变其类型。
package main
func main() {
var a = [4]int{1, 2, 3, 4}
var b [2]int = a // Error, cannot use a (type [4]int) as type [2]int in assignment
}
Go 中的数组是值类型,这与 C、C++ 和 Java 等其他语言中的数组是引用类型不同。
这意味着当我们将一个数组分配给一个新变量或将一个数组传递给一个函数时,整个数组都会被复制。
因此,如果我们对这个复制的数组进行任何更改,原始数组将不会受到影响并将保持不变。
package main
import "fmt"
func main() {
var a = [7]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
var b = a // Copy of a is assigned to b
b[0] = "Monday"
fmt.Println(a) // Output: [Mon Tue Wed Thu Fri Sat Sun]
fmt.Println(b) // Output: [Monday Tue Wed Thu Fri Sat Sun]
}
切片
我知道你在想什么,数组很有用,但由于其固定大小的限制而有点不灵活。
这给我们带来了切片,那么什么是切片?
切片是数组的一部分。切片基于数组构建,提供更强大的功能、灵活性和便捷性。
切片由三部分组成:
- 指向底层数组的指针引用。
- 切片包含的数组段的长度。
- 容量是段可以增长的最大大小。
就像函数一样len
,我们可以使用内置cap
函数来确定切片的容量。以下是一个例子:
package main
import "fmt"
func main() {
a := [5]int{20, 15, 5, 30, 25}
s := a[1:4]
// Output: Array: [20 15 5 30 25], Length: 5, Capacity: 5
fmt.Printf("Array: %v, Length: %d, Capacity: %d\n", a, len(a), cap(a))
// Output: Slice [15 5], Length: 3, Capacity: 4
fmt.Printf("Slice: %v, Length: %d, Capacity: %d", s, len(s), cap(s))
}
别担心,我们将详细讨论这里显示的所有内容。
宣言
让我们看看如何声明切片
var s []T
正如我们所见,我们不需要指定任何长度
让我们声明一个整数切片并看看它是如何工作的
func main() {
var s []string
fmt.Println(s)
fmt.Println(s == nil)
}
$ go run main.go
[]
true
因此,与数组不同,切片的零值是nil
初始化
初始化切片的方法有很多种。一种方法是使用内置make
函数
make([]T, len, cap) []T
func main() {
var s = make([]string, 0, 0)
fmt.Println(s)
}
$ go run main.go
[]
与数组类似,我们可以使用切片文字来初始化切片
func main() {
var s = []string{"Go", "TypeScript"}
fmt.Println(s)
}
$ go run main.go
[Go TypeScript]
另一种方法是从数组创建切片。由于切片是数组的一部分,我们可以从索引到索引处创建一个切片,如下所示low
:high
a[low:high]
func main() {
var a = [4]string{
"C++",
"Go",
"Java",
"TypeScript",
}
s1 := a[0:2] // Select from 0 to 2
s2 := a[:3] // Select first 3
s3 := a[2:] // Select last 2
fmt.Println("Array:", a)
fmt.Println("Slice 1:", s1)
fmt.Println("Slice 2:", s2)
fmt.Println("Slice 3:", s3)
}
$ go run main.go
Array: [C++ Go Java TypeScript]
Slice 1: [C++ Go]
Slice 2: [C++ Go Java]
Slice 3: [Java TypeScript]
缺少低指数意味着 0,缺少高指数意味着len(a)
这里要注意的是,我们也可以从其他切片创建切片,而不仅仅是数组
var a = []string{
"C++",
"Go",
"Java",
"TypeScript",
}
迭代
我们可以像迭代数组一样迭代切片,通过使用带有len
函数或range
关键字的 for 循环。
函数
那么现在我们来谈谈 Go 中提供的内置切片函数。
- 复制
该copy()
函数将元素从一个切片复制到另一个切片。它需要 2 个切片、一个目标切片和一个源切片作为参数。它还返回复制的元素数量。
func copy(dst, src []T) int
让我们看看如何使用它
func main() {
s1 := []string{"a", "b", "c", "d"}
s2 := make([]string, 0)
e := copy(s2, s1)
fmt.Println("Src:", s1)
fmt.Println("Dst:", s2)
fmt.Println("Elements:", e)
}
$ go run main.go
Src: [a b c d]
Dst: [a b c d]
Elements: 4
正如预期的那样,源切片中的 4 个元素被复制到目标切片
- 附加
现在让我们看看如何使用内置append
函数将数据附加到切片中,该函数将新元素附加到给定切片的末尾。
它接受一个切片和一个可变数量的参数,然后返回一个包含所有元素的新切片。
append(slice []T, elems ...T) []T
让我们在一个示例中尝试一下,将元素附加到切片中
func main() {
s1 := []string{"a", "b", "c", "d"}
s2 := append(a1, "e", "f")
fmt.Println("a1:", a1)
fmt.Println("a2:", a2)
}
$ go run main.go
a1: [a b c d]
a2: [a b c d e f]
我们可以看到,新元素被附加,并且返回了一个新的切片。
但是如果给定的切片没有足够的容量容纳新元素,则会分配一个具有更大容量的新底层数组。
现有切片的底层数组中的所有元素都被复制到这个新数组中,然后附加新元素。
特性
最后,我们来讨论一下切片的一些属性。
与数组不同,切片是引用类型。
这意味着修改切片的元素将修改引用数组中的相应元素。
package main
import "fmt"
func main() {
a := [7]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
s := a[0:2]
s[0] = "Sun"
fmt.Println(a) // Output: [Sun Tue Wed Thu Fri Sat Sun]
fmt.Println(s) // Output: [Sun Tue]
}
切片也可以与可变类型一起使用。
package main
import "fmt"
func main() {
values := []int{1, 2, 3}
sum := add(values...)
fmt.Println(sum)
}
func add(values ...int) int {
sum := 0
for _, v := range values {
sum += v
}
return sum
}
好了,这就是 Go 中的数组和切片的全部内容,下期再见!
地图
因此,Go 提供了一个内置的地图类型,我们将学习如何使用它。
但问题是,地图是什么?我们为什么需要它们?
嗯,Map 是键值对的无序集合。它将键映射到值。Map 中的键是唯一的,但值可能不唯一。
它用于根据键快速查找、检索和删除数据。它是最常用的数据结构之一。
宣言
让我们从声明开始
使用以下语法声明地图
var m map[K]V
其中K
,键类型是,V
值类型是
例如,我们可以这样声明一个string
键到int
值的映射
func main() {
var m map[string]int
fmt.Println(m)
}
$ go run main.go
nil
我们可以看出,映射的零值是nil
。
映射nil
没有键。此外,任何向映射添加键的尝试nil
都会导致运行时错误。
初始化
初始化地图的方法有多种。
使函数
我们可以使用内置make
函数,它为引用的数据类型分配内存并初始化其底层数据结构。
func main() {
var m = make(map[string]int)
fmt.Println(m)
}
$ go run main.go
map[]
地图文字
另一种方法是使用地图文字。
func main() {
var m = map[string]int{
"a": 0,
"b": 1,
}
fmt.Println(m)
}
请注意,最后一个逗号是必需的
$ go run main.go
map[a:0 b:1]
与往常一样,我们也可以使用自定义类型
type User struct {
Name string
}
func main() {
var m = map[string]User{
"a": User{"Peter"},
"b": User{"Seth"},
}
fmt.Println(m)
}
我们甚至可以删除值类型,Go 会解决它!
var m = map[string]User{
"a": {"Peter"},
"b": {"Seth"},
}
$ go run main.go
map[a:{Peter} b:{Seth}]
添加
现在,让我们看看如何向地图添加值。
func main() {
var m = map[string]User{
"a": {"Peter"},
"b": {"Seth"},
}
m["c"] = User{"Steve"}
fmt.Println(m)
}
$ go run main.go
map[a:{Peter} b:{Seth} c:{Steve}]
取回
我们还可以使用键从地图中检索值
...
c := m["c"]
fmt.Println("Key c:", c)
$ go run main.go
key c: {Steve}
如果我们使用地图中不存在的键会怎么样?
...
d := m["d"]
fmt.Println("Key d:", d)
是的,你猜对了!我们将获得地图值类型的零值。
$ go run main.go
Key c: {Steve}
Key d: {}
存在
当你检索分配给给定键的值时,它还会返回一个额外的布尔值。true
如果键存在,则返回布尔值,false
否则返回布尔值。
让我们在示例中尝试一下
...
c, ok := m["c"]
fmt.Println("Key c:", c, ok)
d, ok := m["d"]
fmt.Println("Key d:", d, ok)
$ go run main.go
Key c: {Steve} Present: true
Key d: {} Present: false
更新
我们还可以通过简单地重新分配键来更新键的值
...
m["a"] = "Roger"
$ go run main.go
map[a:{Roger} b:{Seth} c:{Steve}]
删除
或者,我们可以使用内置delete
函数删除该键。
语法如下
...
delete(m,
第一个参数是地图,第二个参数是我们要删除的键。
该delete()
函数不返回任何值。此外,如果键在映射中不存在,它也不会执行任何操作。
$ go run main.go
map[a:{Roger} c:{Steve}]
迭代
与数组或切片类似,我们可以使用range
关键字
package main
import "fmt"
func main() {
var m = map[string]User{
"a": {"Peter"},
"b": {"Seth"},
}
m["c"] = User{"Steve"}
for key, value := range m {
fmt.Println("Key: %s, Value: %v", key, value)
}
}
$ go run main.go
Key: c, Value: {Steve}
Key: a, Value: {Peter}
Key: b, Value: {Seth}
请注意,映射是一个无序集合,因此每次迭代映射时,其迭代顺序都不能保证相同。
特性
最后,我们来谈谈地图属性。
映射是引用类型,这意味着当我们将映射分配给新变量时,它们都引用相同的底层数据结构。
因此,一个变量所做的更改对另一个变量也是可见的。
package main
import "fmt"
type User struct {
Name string
}
func main() {
var m1 = map[string]User{
"a": {"Peter"},
"b": {"Seth"},
}
m2 := m1
m2["c"] = User{"Steve"}
fmt.Println(m1) // Output: map[a:{Peter} b:{Seth} c:{Steve}]
fmt.Println(m2) // Output: map[a:{Peter} b:{Seth} c:{Steve}]
}
好了,关于地图的讨论就到此结束,下篇教程再见!
接口
在本节中,我们来讨论一下接口。
什么是接口?
Go 中的接口是一种使用一组方法签名定义的抽象类型。接口定义了类似类型对象的行为。
这里,行为是一个关键词,我们将很快讨论。
让我们举一个例子来更好地理解这一点。
现实世界中接口最好的例子之一就是电源插座。想象一下,我们需要将不同的设备连接到电源插座。
让我们尝试实现它。以下是我们将使用的设备类型。
type mobile struct {
brand string
}
type laptop struct {
cpu string
}
type toaster struct {
amount int
}
type kettle struct {
quantity string
}
type socket struct{}
现在让我们Draw
在一个类型上定义一个方法,比如说mobile
。这里我们将简单地打印该类型的属性。
func (m mobile) Draw(power int) {
fmt.Printf("%T -> brand: %s, power: %d", m, m.brand, power)
}
太好了,现在我们将在接受我们的类型作为参数的类型Plug
上定义方法。socket
mobile
func (socket) Plug(device mobile, power int) {
device.Draw(power)
}
让我们尝试在函数中将类型“连接”或“插入”mobile
到我们的socket
类型中main
package main
import "fmt"
func main() {
m := mobile{"Apple"}
s := socket{}
s.Plug(m, 10)
}
如果我们运行它,我们会看到以下内容
$ go run main.go
main.mobile -> brand: Apple, power: 10
这很有趣,但现在我们想连接**我们的laptop
类型。
package main
import "fmt"
func main() {
m := mobile{"Apple"}
l := laptop{"Intel i9"}
s := socket{}
s.Plug(m, 10)
s.Plug(l, 50) // Error: cannot use l as mobile value in argument
}
正如我们所见,这将引发错误。
我们现在该做什么?定义另一个方法?比如PlugLaptop
?
当然,但是每次我们添加新的设备类型时,我们也需要向套接字类型添加新的方法,这并不理想。
这就是需要解决的问题interface
。本质上,我们想要定义一份将来必须实施的合同。
我们可以简单地定义一个接口,例如,PowerDrawer
并在我们的函数中使用它Plug
来允许任何满足标准的设备,即该类型必须具有与Draw
接口所需的签名匹配的方法。
无论如何,套接字不需要了解我们的设备的任何信息,只需调用该Draw
方法即可。
现在让我们尝试实现我们的PowerDrawer
接口。它看起来是这样的。
惯例是在名称中使用“-er”作为后缀。正如我们之前讨论过的,接口应该只描述预期的行为。在我们的例子中,也就是Draw
方法。
type PowerDrawer interface {
Draw(power int)
}
现在我们需要更新我们的Plug
方法来接受实现接口的设备PowerDrawer
作为参数。
func (socket) Plug(device PowerDrawer, power int) {
device.Draw(power)
}
为了满足接口,我们可以简单地Draw
向所有设备类型添加方法。
type mobile struct {
brand string
}
func (m mobile) Draw(power int) {
fmt.Printf("%T -> brand: %s, power: %d\n", m, m.brand, power)
}
type laptop struct {
cpu string
}
func (l laptop) Draw(power int) {
fmt.Printf("%T -> cpu: %s, power: %d\n", l, l.cpu, power)
}
type toaster struct {
amount int
}
func (t toaster) Draw(power int) {
fmt.Printf("%T -> amount: %d, power: %d\n", t, t.amount, power)
}
type kettle struct {
quantity string
}
func (k kettle) Draw(power int) {
fmt.Printf("%T -> quantity: %s, power: %d\n", k, k.quantity, power)
}
现在我们可以借助我们的接口将所有设备连接到插座!
func main() {
m := mobile{"Apple"}
l := laptop{"Intel i9"}
t := toaster{4}
k := kettle{"50%"}
s := socket{}
s.Plug(m, 10)
s.Plug(l, 50)
s.Plug(t, 30)
s.Plug(k, 25)
}
正如我们预期的那样,它确实有效。
$ go run main.go
main.mobile -> brand: Apple, power: 10
main.laptop -> cpu: Intel i9, power: 50
main.toaster -> amount: 4, power: 30
main.kettle -> quantity: Half Empty, power: 25
但为什么这被认为是如此强大的概念?
嗯,接口可以帮助我们解耦类型。例如,因为我们有接口,所以我们不需要更新socket
实现。我们只需定义一个带有Draw
方法的新设备类型即可。
与其他语言不同,Go 的接口是隐式实现的,因此我们不需要像implements
关键字这样的实现。这意味着,当一个类型拥有接口的“所有方法”时,它就自动满足该接口。
空接口
接下来我们来谈谈空接口。空接口可以接受任何类型的值。
以下是我们的声明方式。
var x interface{}
但是我们为什么需要它?
空接口可用于处理未知类型的值。
以下是一些示例:
- 从 API 读取异构数据
- 未知类型的变量,例如
fmt.Prinln
函数中的变量
要使用类型为 empty 的值interface{}
,我们可以使用类型断言或类型开关来确定该值的类型。
类型断言
类型断言提供对接口值的底层具体值的访问。
例如
func main() {
var i interface{} = "hello"
s := i.(string)
fmt.Println(s)
}
该语句断言接口值具有具体类型,并将底层类型值分配给变量。
我们还可以测试接口值是否具有特定类型。
类型断言可以返回两个值:
- 第一个是基础价值
- 第二个是布尔值,报告断言是否成功。
s, ok := i.(string)
fmt.Println(s, ok)
这可以帮助我们测试接口值是否具有特定类型。
从某种程度上来说,这类似于我们从地图中读取值的方式。
如果不是这种情况,ok
则将为 false,并且值将是该类型的零值,并且不会发生恐慌。
f, ok := i.(float64)
fmt.Println(f, ok)
但是如果接口不包含该类型,该语句将引发恐慌。
f = i.(float64)
fmt.Println(f) // Panic!
$ go run main.go
hello
hello true
0 false
panic: interface conversion: interface {} is string, not float64
类型切换
这里,switch
可以使用语句来确定空类型变量的类型interface{}
。
var t interface{}
t = "hello"
switch t := t.(type) {
case string:
fmt.Printf("string: %s\n", t)
case bool:
fmt.Printf("boolean: %v\n", t)
case int:
fmt.Printf("integer: %d\n", t)
default:
fmt.Printf("unexpected: %T\n", t)
}
如果运行这个,我们可以验证我们有一个string
类型
$ go run main.go
string: hello
特性
让我们讨论一下接口的一些属性。
零值
接口的零值是nil
package main
import "fmt"
type MyInterface interface {
Method()
}
func main() {
var i MyInterface
fmt.Println(i) // Output: <nil>
}
嵌入
我们可以嵌入像结构一样的接口。
例如
type interface1 interface {
Method1()
}
type interface2 interface {
Method2()
}
type interface3 interface {
interface1
interface2
}
价值观
接口值是可比较的
package main
import "fmt"
type MyInterface interface {
Method()
}
type MyType struct{}
func (MyType) Method() {}
func main() {
t := MyType{}
var i MyInterface = MyType{}
fmt.Println(t == i)
}
接口值
从本质上讲,接口值可以被认为是由值和具体类型组成的元组。
package main
import "fmt"
type MyInterface interface {
Method()
}
type MyType struct {
property int
}
func (MyType) Method() {}
func main() {
var i MyInterface
i = MyType{10}
fmt.Printf("(%v, %T)\n", i, i) // Output: ({10}, main.MyType)
}
至此,我们介绍了 Go 中的接口。
这是一个非常强大的功能,但请记住“界面越大,抽象越弱” - Rob Pike。
错误
在本教程中,我们来讨论错误处理。
请注意,我说的是错误而不是异常,因为 Go 中没有异常处理。
相反,我们可以只返回一个内置error
类型,即接口类型。
type error interface {
Error() string
}
我们很快就会回到这个问题。首先,我们来了解一下基础知识。
因此,让我们声明一个简单的Divide
函数,顾名思义,它将整数a
除以b
func Divide(a, b int) int {
return a/b
}
太好了。现在,我们想返回一个错误,比如说,为了防止除以零。这就引出了错误构造。
构造错误
有多种方法可以做到这一点,但我们将讨论最常见的两种。
errors
包裹
第一种是使用包New
提供的功能errors
。
package main
import "errors"
func main() {}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a/b, nil
}
注意,我们如何将错误与结果一起返回。如果没有错误,我们直接返回nil
错误零值,毕竟它是一个接口。
但是我们该如何处理呢?因此,让我们在函数Divide
中调用该函数main
。
package main
import (
"errors"
"fmt"
)
func main() {
result, err := Divide(4, 0)
if err != nil {
fmt.Println(err)
// Do something with the error
return
}
fmt.Println(result)
// Use the result
}
func Divide(a, b int) (int, error) {...}
$ go run main.go
cannot divide by zero
如你所见,我们只是检查错误是否存在,nil
并据此构建逻辑。这在 Go 中被认为是非常惯用的,你会看到它被广泛使用。
构建错误的另一种方法是使用fmt.Errorf
函数。
此函数类似于fmt.Sprintf
并让我们格式化我们的错误,但它不是返回字符串,而是返回错误。
它通常用于为我们的错误添加一些背景或细节。
...
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %d by zero", a)
}
return a/b, nil
}
它的工作原理应该类似
$ go run main.go
cannot divide 4 by zero
哨兵错误
Go 中另一项重要技术是定义预期错误,以便可以在代码的其他部分显式地检查它们。这些错误有时被称为标记错误 (sentinel error)。
package main
import (
"errors"
"fmt"
)
var ErrDivideByZero = errors.New("cannot divide by zero")
func main() {...}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a/b, nil
}
在 Go 中,在变量前添加 被认为是惯例Err
。
例如,ErrNotFound
但重点是什么?
因此,当遇到某种错误时我们需要执行不同的代码分支时,这会变得很有用。
例如,现在我们可以使用errors.Is
函数明确检查发生了哪个错误
package main
import (
"errors"
"fmt"
)
func main() {
result, err := Divide(4, 0)
if err != nil {
switch {
case errors.Is(err, ErrDivideByZero):
fmt.Println(err)
// Do something with the error
default:
fmt.Println("no idea!")
}
return
}
fmt.Println(result)
// Use the result
}
func Divide(a, b int) (int, error) {...}
$ go run main.go
cannot divide by zero
自定义错误
此策略涵盖了大多数错误处理用例。但有时我们需要额外的功能,例如在错误中使用动态值。
之前我们看到这error
只是一个接口。所以基本上,任何东西都可以是,error
只要它实现了Error()
返回字符串形式的错误消息的方法。
因此,让我们定义DivisionError
包含错误代码和消息的自定义结构。
package main
import (
"errors"
"fmt"
)
type DivisionError struct {
Code int
Msg string
}
func (d DivisionError) Error() string {
return fmt.Sprintf("code %d: %s", d.Code, d.Msg)
}
func main() {...}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, DivisionError{
Code: 2000,
Msg: "cannot divide by zero",
}
}
return a/b, nil
}
在这里,我们将使用errors.As
而不是errors.Is
函数将错误转换为正确的类型。
func main() {
result, err := Divide(4, 0)
if err != nil {
var divErr DivisionError
switch {
case errors.As(err, &divErr):
fmt.Println(divErr)
// Do something with the error
default:
fmt.Println("no idea!")
}
return
}
fmt.Println(result)
// Use the result
}
func Divide(a, b int) (int, error) {...}
$ go run man.go
code 2000: cannot divide by zero
errors.Is
但是和之间有什么区别errors.As
?
不同之处在于,此函数检查错误是否具有特定类型,而不像Is()
,它检查它是否是特定的错误对象。
我们也可以使用类型断言,但这不是首选
func main() {
result, err := Divide(4, 0)
if e, ok := err.(DivisionError); ok {
fmt.Println(e.Code, e.Msg) // Output: 2000 cannot divide by zero
return
}
fmt.Println(result)
}
最后,我想说的是,Go 中的错误处理与其他语言的传统做法截然不同try/catch
。但它非常强大,因为它鼓励开发人员以显式的方式实际处理错误,从而提高了代码的可读性。
希望本教程能帮助您了解 Go 中的错误以及如何处理它们。下期再见!
恐慌与恢复
前面我们了解到,Go 程序中处理异常情况的惯用方法是使用错误。虽然错误在大多数情况下已经足够,但在某些情况下程序无法继续运行。
在这些情况下,我们可以使用内置panic
函数。
func panic(interface{})
Panic 是一个内置函数,用于停止当前 的正常执行goroutine
。当一个函数调用 时panic
,该函数的正常执行将立即停止,并将控制权交还给调用者。此过程将重复进行,直到程序退出并返回 panic 消息和堆栈跟踪。
注意:我们将goroutines
在课程后面讨论
让我们看看如何使用该panic
函数
package main
func main() {
WillPanic()
}
func WillPanic() {
panic("Woah")
}
如果我们运行这个,我们可以看到panic
实际效果
$ go run main.go
panic: Woah
goroutine 1 [running]:
main.WillPanic(...)
.../main.go:8
main.main()
.../main.go:4 +0x38
exit status 2
正如预期的那样,我们的程序打印了恐慌消息,然后是堆栈跟踪,然后终止。
那么问题来了,当意外的恐慌发生时该怎么办?
嗯,可以使用内置recover
函数和defer
关键字重新控制崩溃的程序。
func recover() interface{}
让我们尝试创建一个handlePanic
函数来做个例子。然后我们可以使用defer
package main
import "fmt"
func main() {
WillPanic()
}
func handlePanic() {
data := recover()
fmt.Println("Recovered:", data)
}
func WillPanic() {
defer handlePanic()
panic("Woah")
}
$ go run main.go
Recovered: Woah
我们可以看到,我们的恐慌已经恢复,现在我们的程序可以继续执行。
最后,我想提一下,这panic
与其他语言中的惯用法recover
类似try/catch
。一个重要的因素是,我们应该避免恐慌,并尽可能地恢复和使用错误。
如果是这样,那么这就引出了一个问题,我们应该何时使用panic
?
有两个有效用例panic
:
- 不可恢复的错误
这可能是程序无法简单地继续执行的情况。
例如,读取对于启动程序很重要的配置文件,因为如果文件读取本身失败,则没有其他可做的事情。
- 开发人员错误
这是最常见的情况。
例如,当值是时取消引用指针nil
将导致恐慌。
希望本教程能帮助您理解如何在 Go 中使用panic
和recover
。下期再见。
测试
在本教程中,我们将讨论 Go 中的测试。那么,让我们从一个简单的例子开始。
我们创建了一个math
包含Add
函数的包,顾名思义,该函数可以将两个整数相加。
package math
func Add(a, b int) int {
return a + b
}
main
它在我们的包中被这样使用
package main
import (
"example/math"
"fmt"
)
func main() {
result := math.Add(2, 2)
fmt.Println(result)
}
如果我们运行这个,我们应该看到结果
$ go run main.go
4
现在,我们要测试我们的Add
函数。因此,在 Go 中,我们声明_test
文件名带有后缀的测试文件。因此add.go
,我们将创建一个测试,如下所示:add_test.go
我们的项目结构应该是这样的。
.
├── go.mod
├── main.go
└── math
├── add.go
└── add_test.go
我们将从使用一个math_test
包开始,并testing
从标准库中导入该包。没错!与许多其他语言不同,Go 内置了测试功能。
但是等等...为什么我们需要使用它math_test
作为我们的包,我们不能只使用相同的math
包吗?
是的,如果我们愿意,我们可以在同一个包中编写测试,但我个人认为在单独的包中执行此操作有助于我们以更加解耦的方式编写测试。
现在我们可以创建TestAdd
函数了。它将接受一个类型的参数,testing.T
该类型将为我们提供有用的方法。
package math_test
import "testing"
func TestAdd(t *testing.T) {}
在添加任何测试逻辑之前,我们先尝试运行一下。不过这次我们不能使用go run
命令,而是使用go test
命令。
$ go test ./math
ok example/math 0.429s
在这里,我们将使用我们的包名称,math
但我们也可以使用相对路径./...
来测试所有包。
$ go test ./...
? example [no test files]
ok example/math 0.348s
如果 Go 在包中找不到任何测试,它就会通知我们。
好了,让我们来写一些测试代码。为此,我们将结果与预期值进行检查,如果它们不匹配,我们可以使用该t.Fail
方法来使测试失败。
package math_test
import "testing"
func TestAdd(t *testing.T) {
got := math.Add(1, 1)
expected := 2
if got != expected {
t.Fail()
}
}
太好了!我们的测试似乎通过了。
$ go test math
ok example/math 0.412s
我们还看看如果测试失败会发生什么,因此,我们可以改变我们的预期结果
package math_test
import "testing"
func TestAdd(t *testing.T) {
got := math.Add(1, 1)
expected := 3
if got != expected {
t.Fail()
}
}
$ go test ./math
ok example/math (cached)
如果您看到此信息,请不要担心。为了优化,我们的测试已缓存。我们可以使用该go clean
命令清除缓存,然后重新运行测试。
$ go clean -testcache
$ go test ./math
--- FAIL: TestAdd (0.00s)
FAIL
FAIL example/math 0.354s
FAIL
测试失败的情况就是这样的。
表驱动测试
这让我们开始讨论表驱动测试。但它到底是什么呢?
之前我们定义了函数参数和预期变量,并将它们进行比较以确定测试是否通过。但如果我们将所有这些定义在一个切片中并对其进行迭代呢?这将使我们的测试更加灵活,并帮助我们轻松运行多个用例。
别担心,我们会通过例子来学习。所以我们先从定义addTestCase
结构体开始。
package math_test
import (
"example/math"
"testing"
)
type addTestCase struct {
a, b, expected int
}
var testCases = []addTestCase{
{1, 1, 3},
{25, 25, 50},
{2, 1, 3},
{1, 10, 11},
}
func TestAdd(t *testing.T) {
for _, tc := range testCases {
got := math.Add(tc.a, tc.b)
if got != tc.expected {
t.Errorf("Expected %d but got %d", tc.expected, got)
}
}
}
注意,我们是addTestCase
用小写字母声明的。没错,我们不想导出它,因为它在测试逻辑之外没有用处。
让我们运行测试
$ go run main.go
--- FAIL: TestAdd (0.00s)
add_test.go:25: Expected 3 but got 2
FAIL
FAIL example/math 0.334s
FAIL
好像我们的测试失败了,让我们通过更新测试用例来修复它们。
var testCases = []addTestCase{
{1, 1, 2},
{25, 25, 50},
{2, 1, 3},
{1, 10, 11},
}
完美,正在运行!
$ go run main.go
ok example/math 0.589s
代码覆盖率
最后,我们来谈谈代码覆盖率。编写测试时,了解测试覆盖了多少实际代码通常很重要。这通常称为代码覆盖率。
要计算和导出测试的覆盖率,我们可以简单地使用命令-coverprofile
中的参数go test
。
$ go test ./math -coverprofile=coverage.out
ok example/math 0.385s coverage: 100.0% of statements
看来我们的覆盖范围很广。我们再用go tool cover
命令检查一下报告,它会给出详细的报告。
$ go tool cover -html=coverage.out
我们看到,这是一种更易读的格式。最重要的是,它内置于标准工具中。
模糊测试
最后,我们来看看 Go 1.18 版本中引入的模糊测试。
模糊测试是一种自动化测试,它不断操纵程序的输入来查找错误。
Go fuzzing 使用覆盖指导来智能地遍历被模糊测试的代码,以查找故障并向用户报告。
由于模糊测试可以发现人类经常忽略的边缘情况,因此它对于查找错误和安全漏洞特别有价值。
让我们尝试一个例子,
func FuzzTestAdd(f *testing.F) {
f.Fuzz(func(t *testing.T, a, b int) {
math.Add(a , b)
})
}
运行这个程序,我们会看到它会自动创建测试用例。由于我们的Add
功能非常简单,测试应该能够通过。
$ go test -fuzz FuzzTestAdd example/math
fuzz: elapsed: 0s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 0s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
fuzz: elapsed: 6s, execs: 680218 (118402/sec), new interesting: 12 (total: 203)
fuzz: elapsed: 9s, execs: 1039901 (119895/sec), new interesting: 19 (total: 210)
fuzz: elapsed: 12s, execs: 1386684 (115594/sec), new interesting: 21 (total: 212)
PASS
ok foo 12.692s
但是如果我们用随机边缘情况更新我们的函数,这样当大于时Add
程序就会恐慌。b + 10
a
func Add(a, b int) int {
if a > b + 10 {
panic("B must be greater than A")
}
return a + b
}
如果我们重新运行测试,模糊测试就会捕获这个边缘情况。
$ go test -fuzz FuzzTestAdd example/math
warning: starting with empty corpus
fuzz: elapsed: 0s, execs: 0 (0/sec), new interesting: 0 (total: 0)
fuzz: elapsed: 0s, execs: 1 (25/sec), new interesting: 0 (total: 0)
--- FAIL: FuzzTestAdd (0.04s)
--- FAIL: FuzzTestAdd (0.00s)
testing.go:1349: panic: B is greater than A
我认为这是 Go 1.18 的一个非常酷的功能。您可以从官方 Go 博客中了解有关模糊测试的更多信息。
完美,本教程基本就到这里。下期再见!
泛型
在本节中,我们将了解泛型,这是 Go 1.18 版本发布的一项备受期待的功能
什么是泛型?
泛型指的是参数化类型。简而言之,泛型允许程序员编写代码,因为类型并非立即生效,所以可以稍后再指定类型。
让我们举一个例子来更好地理解这一点。
在我们的例子中,我们为不同类型的对象(例如、和)提供了简单的求和函数。int
由于float64
Gostring
不允许方法重写,我们通常必须创建新函数。
package main
import "fmt"
func sumInt(a, b int) int {
return a + b
}
func sumFloat(a, b float64) float64 {
return a + b
}
func sumString(a, b string) string {
return a + b
}
func main() {
fmt.Println(sumInt(1, 2))
fmt.Println(sumFloat(4.0, 2.0))
fmt.Println(sumString("a", "b"))
}
我们可以看到,除了类型之外,这些函数非常相似。
让我们看看如何定义通用函数。
func fnName[T constraint]() {
...
}
这里,T
是我们的类型参数,并且**constraint
将是允许任何类型**实现该接口**的接口。
我知道这有点让人困惑。那么,让我们开始构建我们的通用sum
函数吧。
在这里我们将使用T
空作为我们的类型参数interface{}
作为我们的约束。
func sum[T interface{}](a, b T) T {
fmt.Println(a, b)
}
此外,从 Go 1.18 开始我们可以使用any
与空接口几乎等同的东西。
func sum[T any](a, b T) T {
fmt.Println(a, b)
}
使用类型参数时需要传递类型参数,这会使我们的代码变得冗长。
sum[int](1, 2) // explicit type argument
sum[float64](4.0, 2.0)
sum[string]("a", "b")
幸运的是,Go 1.18 带有类型推断,它可以帮助我们编写调用没有明确类型的泛型函数的代码。
sum(1, 2)
sum(4.0, 2.0)
sum("a", "b")
让我们运行一下看看是否有效
$ go run main.go
1 2
4 2
a b
现在让我们更新sum
函数以添加我们的变量。
func sum[T any](a, b T) T {
return a + b
}
fmt.Println(sum(1, 2))
fmt.Println(sum(4.0, 2.0))
fmt.Println(sum("a", "b"))
但是现在如果我们运行它,我们将收到一个错误,即运算符+
未在约束中定义。
$ go run main.go
./main.go:6:9: invalid operation: operator + not defined on a (variable of type T constrained by any)
虽然类型约束any
通常有效,但它不支持运算符。
因此,让我们使用接口定义我们自己的自定义约束。我们的接口应该定义一个包含int
、float
和 的类型集string
。
SumConstraint
我们的界面是这样的
type SumConstraint interface {
int | float64 | string
}
func sum[T SumConstraint](a, b T) T {
return a + b
}
func main() {
fmt.Println(sum(1, 2))
fmt.Println(sum(4.0, 2.0))
fmt.Println(sum("a", "b"))
}
这应该可以按预期工作
$ go run main.go
3
6
ab
我们还可以使用constraints
定义了一组与类型参数一起使用的有用约束的包。
为此,我们需要安装constraints
包
$ go get golang.org/x/exp/constraints
go: added golang.org/x/exp v0.0.0-20220414153411-bcd21879b8fd
import (
"fmt"
"golang.org/x/exp/constraints"
)
func sum[T constraints.Ordered](a, b T) T {
return a + b
}
func main() {
fmt.Println(sum(1, 2))
fmt.Println(sum(4.0, 2.0))
fmt.Println(sum("a", "b"))
}
这里我们使用了Ordered
约束。
type Ordered interface {
Integer | Float | ~string
}
~
是添加到 Go 的新标记,表达式~string
表示所有类型的集合,其底层类型为string
它仍然按预期工作
$ go run main.go
3
6
ab
泛型是一个令人惊叹的特性,因为它允许编写抽象函数,在某些情况下可以大大减少代码重复。
何时使用泛型
那么,什么时候使用泛型呢?我们可以以以下用例为例
- 对数组、切片、映射和通道进行操作的函数
- 通用数据结构,例如堆栈或链表
- 减少代码重复
最后,我要补充一点,虽然泛型对于语言来说是一个很好的补充,但是应该谨慎使用。
并且,建议从简单的开始,只有在我们编写了至少 2 或 3 次非常相似的代码后才编写通用代码。
完美,关于泛型的讨论到此结束。下篇教程再见!
并发
在本课中,我们将学习并发性,这是 Go 最强大的功能之一。
那么我们首先要问什么是“并发”?
什么是并发
并发,顾名思义,就是将计算机程序或算法分解为各个部分,每个部分都可以独立执行的能力。
并发程序的最终结果与顺序执行的程序的最终结果相同。
使用并发,我们能够在更短的时间内获得相同的结果,从而提高程序的整体性能和效率。
并发与并行
很多人将并发与并行混淆,因为它们都暗示同时执行代码,但它们是两个完全不同的概念。
并发是指同时运行和管理多个计算的任务。而并行是指同时运行多个计算的任务。
Rob Pike 的一句简单引言几乎概括了这一点。
并发是指同时处理大量事务。并行是指同时执行大量任务。
但 Go 中的并发不仅仅是语法。为了充分利用 Go 的强大功能,我们首先需要了解 Go 如何处理代码的并发执行。Go 依赖于一种称为 CSP(通信顺序进程)的并发模型。
通信顺序进程(CSP)
通信顺序进程(CSP)是由 Tony Hoare 于 1978 年提出的一个描述并发进程间交互的模型,它在计算机科学领域,尤其是在并发领域取得了突破性进展。
Go 和 Erlang 等语言深受通信顺序进程 (CSP) 概念的启发。
并发很难,但 CSP 使我们能够更好地构建并发代码,并提供了一个更简单的并发思考模型。在这里,进程是独立的,它们通过共享通道进行通信。
我们将在课程后面学习 Golang 如何使用 goroutines 和通道实现它。
基本概念
现在让我们熟悉一些基本的并发概念
数据竞赛
当进程必须同时访问同一个变量时,就会发生数据争用。
例如,一个进程读取而另一个进程同时写入完全相同的变量。
竞争条件
当事件的时间或顺序影响一段代码的正确性时,就会出现竞争条件。
死锁
当所有进程因相互等待而被阻塞,程序无法继续执行时,就会发生死锁。
科夫曼条件
有四个条件,称为科夫曼条件,所有条件都必须满足才会发生死锁。
- 互斥
并发进程在任何时候都持有至少一个资源,使其不可共享。
在下图中,资源 1 有一个实例,并且仅由进程 1 持有。
- 等待
并发进程持有资源并正在等待额外的资源。
在下图中,进程 2 持有资源 2 和资源 3,并请求进程 1 持有的资源 1。
- 不抢占
并发进程持有的资源不能被系统拿走,只能由持有该资源的进程释放。
如下图所示,进程 2 不能从进程 1 手中抢占资源 1,只有进程 1 执行完成后主动放弃资源 1 时,资源 1 才会被释放。
- 循环等待
一个进程等待第二个进程持有的资源,第二个进程又等待第三个进程持有的资源,以此类推,直到最后一个进程等待第一个进程持有的资源。如此循环下去。
在下图中,进程 1 被分配了资源 2,并且它正在请求资源 1。同样,进程 2 被分配了资源 1,并且它正在请求资源 2。这形成了一个循环等待循环。
饥饿
当一个进程被剥夺了必要的资源并且无法完成其功能时,就会发生饥饿。
资源匮乏可能是由于进程死锁或低效的调度算法造成的。为了解决资源匮乏问题,我们需要采用更合理的资源分配算法,确保每个进程都能获得公平的资源份额。
至此,我们结束了关于 Go 并发基础知识的讨论。下节课再见,我们将学习 Channel 和 Goroutine。
Goroutines
在本课中,我们将学习 Goroutines。
但在我们开始讨论之前,我想分享一个重要的围棋谚语。
“不要通过共享内存来沟通,而要通过沟通来共享内存。” - Rob Pike
什么是 goroutine?
goroutine是一个轻量级的执行线程,由 Go 运行时管理,本质上让我们以同步的方式编写异步代码。
重要的是要知道它们不是实际的 OS 线程,并且主函数本身作为 goroutine 运行。
通过使用协作式调度的 Go 运行时调度程序,单个线程可以运行数千个 Goroutine。这意味着,如果当前 Goroutine 被阻塞或已完成,调度程序会将其他 Goroutine 移至另一个 OS 线程。因此,我们实现了高效的调度,因为没有 Goroutine 会被永久阻塞。
我们只需使用go关键字就可以将任何函数转换为 goroutine
go fn(x, y, z)
在编写任何代码之前,有必要简要讨论一下 fork-join 模型。
Fork-Join 模型
Go 在 goroutine 背后采用了 fork-join 模型的并发思想。fork-join 模型的本质是,子进程从其父进程中分离出来,与父进程并发运行。执行完成后,子进程会重新合并回父进程。子进程重新加入父进程的点称为连接点。
现在让我们编写一些代码并创建我们自己的 goroutine
package main
import "fmt"
func speak(arg string) {
fmt.Println(arg)
}
func main() {
go speak("Hello World")
}
这里的speak
函数调用以关键字为前缀go
。这将允许它作为单独的 Goroutine 运行。就这样,我们创建了第一个 Goroutine。就这么简单!
太好了,我们来运行一下
$ go run main.go
有趣的是,我们的程序似乎没有完全运行,因为它缺少一些输出。这是因为我们的主 Goroutine 退出了,没有等待我们创建的 Goroutine。
如果我们使用该函数让程序等待会怎么样time.Sleep
?
func main() {
...
time.Sleep(1 * time.Second)
}
现在如果我们运行这个
$ go run main.go
Hello World
好了,我们现在可以看到完整的输出。
好吧,虽然这样可行,但效果并不理想。那么,我们该如何改进呢?
嗯,使用 goroutine 最棘手的部分是知道它们何时停止。重要的是要知道 goroutine 运行在相同的地址空间中,因此对共享内存的访问必须同步。
这将引出渠道问题,我们将在下一篇中讨论。
频道
在本课中,我们将学习渠道。
那么什么是渠道?
嗯,简单定义一下。
通道是 Goroutine 之间的通信管道。数据以相同的顺序从一端进入,从另一端出来,直到通道关闭。
正如我们之前所了解的,Go 中的通道基于通信顺序进程(CSP)。
创建频道
现在我们了解了什么是通道,让我们看看如何声明它们
var ch chan T
在这里,我们在我们的类型T
(即我们想要发送和接收的值的数据类型)前加上chan
代表通道的关键字作为前缀。
c
让我们尝试打印类型通道的值string
。
func main() {
var ch chan string
fmt.Println(c)
}
$ go run main.go
<nil>
正如我们所见,通道的零值是nil
,如果我们尝试通过通道发送数据,我们的程序就会崩溃。
因此与切片类似,我们可以使用内置make
函数初始化我们的通道。
func main() {
ch := make(chan string)
fmt.Println(c)
}
如果我们运行这个程序,我们可以看到我们的频道已经初始化了。
$ go run main.go
0x1400010e060
发送和接收数据
现在我们对通道有了基本的了解,让我们使用通道实现我们之前的示例,以了解如何使用它们在我们的 goroutine 之间进行通信。
package main
import "fmt"
func speak(arg string, ch chan string) {
ch <- arg // Send
}
func main() {
ch := make(chan string)
go speak("Hello World", ch)
data := <-ch // Receive
fmt.Println(data)
}
注意我们如何使用语法发送数据channel<-data
以及如何接收数据data := <-channel
。
如果我们运行这个
$ go run main.go
Hello World
完美,我们的程序按预期运行。
缓冲通道
我们还有缓冲通道,它接受有限数量的值,而没有相应的接收器。
可以使用函数的第二个参数指定该缓冲区的长度或容量make
。
func main() {
ch := make(chan string, 2)
go speak("Hello World", ch)
go speak("Hi again", ch)
data1 := <-ch
fmt.Println(data1)
data2 := <-ch
fmt.Println(data2)
}
因为这个通道是缓冲的,所以我们可以将这些值发送到通道中,而无需相应的并发接收。
默认情况下,通道是无缓冲的,容量为 0,因此我们省略了make
函数的第二个参数。
接下来,我们有定向渠道。
定向通道
当使用通道作为函数参数时,我们可以指定通道是仅用于发送值还是仅用于接收值。这提高了程序的类型安全性,因为默认情况下,通道可以同时发送和接收值。
在我们的示例中,我们可以更新speak
函数的第二个参数,以便它只能发送一个值。
func speak(arg string, ch chan<- string) {
ch <- arg // Send Only
}
这里,chan<-
只能用于发送值,如果我们尝试接收值,就会发生恐慌。
关闭通道
另外,就像任何其他资源一样,一旦我们完成了通道的操作,就需要关闭它。这可以使用内置close
函数来实现。
在这里,我们可以将我们的通道传递给close
函数。
func main() {
ch := make(chan string, 2)
go speak("Hello World", ch)
go speak("Hi again", ch)
data1 := <-ch
fmt.Println(data1)
data2 := <-ch
fmt.Println(data2)
close(ch)
}
可选地,接收者可以通过向接收表达式分配第二个参数来测试通道是否已关闭。
func main() {
ch := make(chan string, 2)
go speak("Hello World", ch)
go speak("Hi again", ch)
data1 := <-ch
fmt.Println(data1)
data2, ok := <-ch
fmt.Println(data2, ok)
close(ch)
}
如果ok
是,false
则表示没有更多值可供接收,并且通道已关闭。
在某种程度上,这类似于我们检查地图中某个键是否存在的方式。
特性
最后,我们来讨论一下渠道的一些属性
- 发送到 nil 通道永远阻塞
var c chan string
c <- "Hello, World!" // Panic: all goroutines are asleep - deadlock!
- 从零通道接收永远阻塞
var c chan string
fmt.Println(<-c) // Panic: all goroutines are asleep - deadlock!
- 向已关闭的通道发送消息时发生恐慌
var c = make(chan string, 1)
c <- "Hello, World!"
close(c)
c <- "Hello, Panic!" // Panic: send on closed channel
- 从已关闭的通道接收数据会立即返回零值
var c = make(chan int, 2)
c <- 5
c <- 4
close(c)
for i := 0; i < 4; i++ {
fmt.Printf("%d ", <-c) // Output: 5 4 0 0
}
- 范围覆盖渠道
我们还可以使用for
andrange
来迭代从通道接收的值
package main
import "fmt"
func main() {
ch := make(chan string, 2)
ch <- "Hello"
ch <- "World"
close(ch)
for data := range ch {
fmt.Println(data)
}
}
至此,我们学习了 Go 中 goroutine 和 channel 的工作原理。希望这些内容对您有所帮助。下篇教程再见。
选择
在本教程中,我们将学习select
Go 中的语句
该select
语句阻止代码并同时等待多个通道操作。
Aselect
会阻塞,直到其中一个 case 可以运行,然后执行该 case。如果有多个 case 就绪,它会随机选择一个。
package main
import (
"fmt"
"time"
)
func main() {
one := make(chan string)
two := make(chan string)
go func() {
time.Sleep(time.Second * 2)
one <- "One"
}()
go func() {
time.Sleep(time.Second * 1)
two <- "Two"
}()
select {
case result := <-one:
fmt.Println("Received:", result)
case result := <-two:
fmt.Println("Received:", result)
}
close(one)
close(two)
}
与 类似switch
,select
它也有一个默认用例,当其他用例都未就绪时,它会运行。这将帮助我们无阻塞地发送或接收数据。
func main() {
one := make(chan string)
two := make(chan string)
for x := 0; x < 10; x++ {
go func() {
time.Sleep(time.Second * 2)
one <- "One"
}()
go func() {
time.Sleep(time.Second * 1)
two <- "Two"
}()
}
for x := 0; x < 10; x++ {
select {
case result := <-one:
fmt.Println("Received:", result)
case result := <-two:
fmt.Println("Received:", result)
default:
fmt.Println("Default...")
time.Sleep(200 * time.Millisecond)
}
}
close(one)
close(two)
}
了解空白select {}
会永远阻塞也很重要。
func main() {
...
select {}
close(one)
close(two)
}
这就是 Go 语言中 for 语句的全部内容select
。下篇文章再见。
等待组
正如我们之前所了解的,goroutine 在同一个地址空间中运行,因此对共享内存的访问必须同步。
该sync
包提供了有用的原语。
因此在本教程中,我们将学习 waitgroups
基本上,WaitGroup帮助我们等待多个 goroutine 完成。
我们可以通过以下函数使用 WaitGroup:
Add(int)
函数接受一个整数值,该整数值本质上是 waitgroup 需要等待的 goroutine 的数量。在执行 goroutine 之前必须调用此函数。Done()
在 goroutine 中调用函数来表示 goroutine 已成功执行。Wait()
函数会阻塞程序,直到所有由指定 goroutines从内部Add()
调用为止。Done()
让我们举个例子
package main
import (
"fmt"
"sync"
)
func work() {
fmt.Println("working...")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
wg.Wait()
}
如果我们运行这个,我们可以看到我们的程序按预期运行
$ go run main.go
working...
我们也可以将权重组直接传递给函数。
func work(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("working...")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go work(&wg)
wg.Wait()
}
但需要注意的是,WaitGroup 不能被复制。如果要显式地将其传递给函数,则应该使用指针。因为这可能会影响我们的计数器,从而扰乱程序的逻辑。
我们还增加 goroutine 的数量并更新我们的 waitgroupAdd
函数以等待 4 个 goroutine。
func main() {
var wg sync.WaitGroup
wg.Add(4)
go work(&wg)
go work(&wg)
go work(&wg)
go work(&wg)
wg.Wait()
}
正如预期的那样,我们所有的 goroutine 都已执行。
$ go run main.go
working...
working...
working...
working...
本教程就是这样,下篇再见!
互斥体
在本教程中,我们将学习互斥锁。
什么是互斥锁?
互斥锁可防止其他进程在一个进程占用关键数据部分时进入该部分,以防止发生竞争条件。
什么是临界区?
因此,关键部分可以是一段不能由多个线程同时运行的代码,因为该代码包含共享资源。
我们可以通过以下函数使用 Mutex:
Lock()
函数获取或持有锁Unlock()
函数释放锁。TryLock()
函数尝试锁定并报告是否成功。
让我们举个例子,我们将创建一个Counter
结构并添加一个Update
更新内部值的方法。
package main
import (
"fmt"
"sync"
)
type Counter struct {
value int
}
func (c *Counter) Update(n int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
}
func main() {
var wg sync.WaitGroup
c := Counter{}
wg.Add(4)
go c.Update(10, &wg)
go c.Update(-5, &wg)
go c.Update(25, &wg)
go c.Update(19, &wg)
wg.Wait()
fmt.Println(c.value)
}
让我们运行它并看看会发生什么。
$ go run main.go
Adding -5 to 0
Adding 10 to 0
Adding 19 to 0
Adding 25 to 0
Result is 49
这看起来不准确,好像我们的值总是零,但我们不知何故得到了正确的答案。
这是因为在我们的例子中,多个 goroutine 正在更新value
变量。正如你一定猜到的,这并不理想。
这是 Mutex 的完美用例。那么,让我们首先在和函数之间使用sync.Mutex
并包装临界区。Lock()
Unlock()
package main
import (
"fmt"
"sync"
)
type Counter struct {
m sync.Mutex
value int
}
func (c *Counter) Update(n int, wg *sync.WaitGroup) {
c.m.Lock()
defer wg.Done()
fmt.Printf("Adding %d to %d\n", n, c.value)
c.value += n
c.m.Unlock()
}
func main() {
var wg sync.WaitGroup
c := Counter{}
wg.Add(4)
go c.Update(10, &wg)
go c.Update(-5, &wg)
go c.Update(25, &wg)
go c.Update(19, &wg)
wg.Wait()
}
$ go run main.go
Adding -5 to 0
Adding 19 to -5
Adding 25 to 14
Adding 10 to 39
Result is 49
看起来我们解决了我们的问题并且输出看起来也是正确的。
包裹sync
到目前为止,我们已经讨论过了sync.WaitGroup
,sync.Mutex
但是包中还有许多其他功能sync
可以在编写并发代码时派上用场。
RWMutex
代表“读者/写者互斥”,本质上与 相同Mutex
,但它将锁赋予多个读进程或仅赋予一个写进程。它还使我们能够更好地控制内存。Pool
是临时对象的集合,可以被多个 goroutine 同时访问和保存。Once
是仅执行一次动作的对象。Cond
实现一个条件变量,指示正在等待事件或想要宣布事件的 goroutine。
后续步骤
希望这门课程能给你带来美好的学习体验。我很乐意听取你的反馈。祝你学习顺利!
鏂囩珷鏉ユ簮锛�https://dev.to/karanpratapsingh/learn-go-the-complete-course-plc