GO语言编程之旅
GO语言编程之旅
变量声明
var声明:1. 声明一个或多个同类型的变量。2.声明位置可以是函数内,也可以是函数外
-
如果要声明一系列不同类型的变量,可以使用var(),在代码块中声明
:= 短变量声明:1.声明一个或多个可以不同类型的变量。2. 声明位置只能是函数内
基本类型
bool string int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr byte // uint8 的别名 rune // int32 的别名// 表示一个 Unicode 码位 float32 float64 complex64 complex128 /* 在 Go 语言中,complex 是一个内置的复数类型,用于表示复数。Go 语言中的复数类型由实部和虚部组成,虚部以 i 或者 j 为后缀表示。 复数类型可以通过两种方式来表示: 使用 complex 函数,它接受两个 float64 类型的参数,第一个参数是实部,第二个参数是虚部。例如: go realPart := 3.4 imagPart := 5.6 c := complex(realPart, imagPart) 使用字面量表示法,直接在常量中指定实部和虚部。实部和虚部之间用空格分隔,虚部后面紧跟 i 或者 j。例如: go c := 3.4 + 5.6i 复数类型在科学计算、工程学和物理学等领域中非常有用,这些领域经常需要处理复数。 */
变量类型转换
赋值时,变量的类型转换是强制的,不存在自动类型转换。
常量
常量的声明与变量类似,只不过使用 const
关键字。
常量可以是字符、字符串、布尔值或数值。
常量不能用 :=
语法声明。
数值常量
数值常量是高精度的 值。
一个未指定类型的常量由上下文来决定其类型。
再试着一下输出 needInt(Big)
吧。
(int
类型可以存储最大 64 位的整数,根据平台不同有时会更小。)
package main import "fmt" const (// 将 1 左移 100 位来创建一个非常大的数字// 即这个数的二进制是 1 后面跟着 100 个 0Big = 1 << 100// 再往右移 99 位,即 Small = 1 << 1,或者说 Small = 2Small = Big >> 99 ) func needInt(x int) int { return x*10 + 1 } func needFloat(x float64) float64 {return x * 0.1 } func main() {fmt.Println(needInt(Small))fmt.Println(needFloat(Small))fmt.Println(needFloat(Big)) } //这里想要说明的就是,未指定类型的常量,下面在使用的时候会自动变换类型
for 循环
1.Go 只有一种循环结构:for
循环。
基本的 for
循环由三部分组成,它们用分号隔开:
-
初始化语句:在第一次迭代前执行
-
条件表达式:在每次迭代前求值
-
后置语句:在每次迭代的结尾执行
初始化语句通常为一句短变量声明,该变量声明仅在 for
语句的作用域中可见。
一旦条件表达式求值为 false
,循环迭代就会终止。
注意:和 C、Java、JavaScript 之类的语言不同,Go 的 for
语句后面的三个构成部分外没有小括号, 大括号 { }
则是必须的。
2.初始化语句和后置语句是可选的。
3.变身为 “while” ,就是只有条件判断语句,并且没有分号
-
死循环:直接 for{......}
if 判断
Go 的 if
语句与 for
循环类似,表达式外无需小括号 ( )
,而大括号 { }
则是必须的。
if 和简短语句
和 for
一样,if
语句可以在条件表达式前执行一个简短语句。
该语句声明的变量作用域仅在 if
之内。
(在最后的 return
语句处使用 v
看看。)
package main import ("fmt""math" ) //v相当于是if块中的一个“局部变量” func pow(x, n, lim float64) float64 {if v := math.Pow(x, n); v < lim {return v}return lim } func main() {fmt.Println(pow(3, 2, 10),pow(3, 3, 20),) }
if 和 else
在 if
的简短语句中声明的变量同样可以在对应的任何 else
块中使用。
(在 main
的 fmt.Println
调用开始前,两次对 pow
的调用均已执行并返回其各自的结果。)
package main import ("fmt""math" ) func pow(x, n, lim float64) float64 {if v := math.Pow(x, n); v < lim {return v} else {fmt.Printf("%g >= %g\n", v, lim)}// can't use v here, thoughreturn lim } func main() {fmt.Println(pow(3, 2, 10),pow(3, 3, 20),) }
练习:循环与函数
为了练习函数与循环,我们来实现一个平方根函数:给定一个数 x,我们需要找到一个数 z 使得 z² 尽可能地接近 x。
计算机通常使用循环来计算 x 的平方根。从某个猜测的值 z 开始,我们可以根据 z² 与 x 的近似度来改进 z,产生一个更好的猜测:
z -= (z*z - x) / (2*z)
重复调整的过程,猜测的结果会越来越精确,得到的答案也会尽可能接近实际的平方根。
请在提供的 func Sqrt
中实现它。无论输入是什么,可以先猜测 z 为 1。 首先,重复计算 10 次并连续打印每次的 z 值。观察对于不同的 x 值(1、2、3 ...), 你得到的答案是如何逼近结果的,以及猜测改进的速度有多快。
提示:用类型转换或浮点数语法来声明并初始化一个浮点数值:
z := 1.0 z := float64(1)
然后,修改循环条件,使得当值停止改变(或改变非常小)的时候退出循环。 观察迭代次数大于还是小于 10。尝试改变 z 的初始猜测,如 x 或 x/2。 你的函数结果与标准库中的 math.Sqrt 有多接近?
( 注: 如果你对该算法的细节感兴趣,上面的 z² − x 是 z² 到它所要到达的值(即 x) 的距离,除数 2z 为 z² 的导数,我们通过 z² 的变化速度来改变 z 的调整量。 这种通用方法叫做牛顿法, 它对很多函数,特别是平方根而言非常有效。)
package main import ("fmt""math" ) func Sqrt(x float64) float64 {z := 1.0 // 初始猜测值应该是浮点数var min float64 = 0.000001 // 定义收敛的最小阈值for {newZ := z - (z*z - x) / (2*z) // 牛顿法迭代公式if math.Abs(newZ-z) < min { // 检查新旧值之间的差异是否小于最小阈值break}z = newZ // 更新猜测值}return z } func main() {fmt.Println(Sqrt(2)) // 调用 Sqrt 函数并打印结果fmt.Println(math.Sqrt(2)) }
switch 分支
switch
语句是编写一连串 if - else
语句的简便方法。它运行第一个 case
值 值等于条件表达式的子句。
Go 的 switch
语句类似于 C、C++、Java、JavaScript 和 PHP 中的,不过 Go 只会运行选定的 case
,而非之后所有的 case
。 在效果上,Go 的做法相当于这些语言中为每个 case
后面自动添加了所需的 break
语句。在 Go 中,除非以 fallthrough
语句结束,否则分支会自动终止。 Go 的另一点重要的不同在于 switch
的 case
无需为常量,且取值不限于整数。
package main import ("fmt""runtime" ) func main() {fmt.Print("Go 运行的系统环境:")switch os := runtime.GOOS; os {case "darwin":fmt.Println("macOS.")case "linux":fmt.Println("Linux.")default:// freebsd, openbsd,// plan9, windows...fmt.Printf("%s.\n", os)} }
另外:
fallthrough:
作用:可以控制case语句流程,继续进行下一个case语句。
注意:1. fallthrough 只能用在case语句的最后面
2.有额外的性能开销,应该谨慎使用
switch语句:
注意:
-
后面可以跟着除了整数和常量
-
后面可以先跟表达式进行赋值,再接变量
switch语句的其他用法:
在Go语言中,`switch` 语句后面可以接不同类型的表达式,不仅仅是常量和整数。以下是一些可以用于 `switch` 语句的条件类型:1. **字符串**:`switch` 可以基于字符串值进行分支。day := "Friday"switch day {case "Monday":fmt.Println("Monday!")case "Tuesday":fmt.Println("Tuesday!")case "Friday":fmt.Println("Friday!")default:fmt.Println("Weekday!")}2. **类型断言**:`switch` 可以用于类型断言,检查接口变量的实际类型。var i interface{} = "hello"switch v := i.(type) {case string:fmt.Printf("i is a string with value %s\n", v)case int:fmt.Printf("i is an int with value %d\n", v)default:fmt.Printf("i is of a different type\n")}3. **通道类型**:`switch` 可以用于检查通道是否关闭。ch := make(chan int, 1)ch <- 42close(ch)switch x, ok := <-ch; {case ok:fmt.Printf("Received %d from channel\n", x)case !ok:fmt.Println("Channel is closed")}4. **复合表达式**:`switch` 可以基于复合表达式的结果进行分支。a := 10b := 20switch {case a > b:fmt.Println("a is greater than b")case a == b:fmt.Println("a is equal to b")default:fmt.Println("a is less than b")}5. **函数调用**:`switch` 可以基于函数调用的结果进行分支。func isEven(n int) bool {return n%2 == 0}n := 5switch isEven(n) {case true:fmt.Println("Number is even")case false:fmt.Println("Number is odd")}6. **结构体比较**:`switch` 可以用于比较结构体的字段。type Point struct {X, Y int}p := Point{1, 2}switch {case p.X > p.Y:fmt.Println("X is greater than Y")case p.X < p.Y:fmt.Println("X is less than Y")default:fmt.Println("X and Y are equal")}//这些例子展示了 `switch` 语句的灵活性,它可以处理各种类型的条件,而不仅仅是整数或常量。
switch 的求值顺序
switch
的 case
语句从上到下顺次执行,直到匹配成功时停止。
例如,
switch i { case 0: case f(): }
在 i==0
时,f
不会被调用。)
注意: Go 练习场中的时间总是从 2009-11-10 23:00:00 UTC 开始, 该值的意义留给读者去发现。
但是,在Go语言中,switch
语句的高效执行通常得益于编译器的优化。编译器在编译期间会分析 switch
语句,并根据 case
标签的类型和值生成优化的代码。
因此,switch语句的效率接近0(1)
无条件 switch
无条件的 switch
同 switch true
一样。
这种形式能将一长串 if-then-else
写得更加清晰。
switch{。。。}相当于switch true{。。。}
defer 推迟
1. 推迟执行
-
defer
语句确实用于推迟函数的执行,直到其所在的函数即将返回。
2. 执行时机
-
被推迟的函数会在其外层函数执行完毕后、返回前执行。
-
如果外层函数通过
return
语句返回,defer
会在return
语句执行后、函数实际返回前执行。 -
如果外层函数通过
panic
退出,defer
会在panic
被处理前执行。
3. 执行位置
-
defer
可以在函数内的任何位置使用,包括循环体内部。
4. 语法
-
使用
defer
关键字后跟一个函数调用。
5. 执行特点
-
defer语句的函数调用会被压入一个栈中,多个
defer
语句会按照后进先出(LIFO)的顺序执行,即最后执行的defer
会最先被调用。
6. 使用场景
-
资源释放:如文件关闭、锁释放、网络连接关闭等。
-
清理工作:进行一些清理操作,如重置状态、清理临时文件等。
-
日志记录:记录函数的执行时间或执行完毕后的状态。
7. 参数求值
-
defer
语句中的函数参数会在defer
语句执行时立即求值,而不是在函数实际调用时。
8. 性能考虑
-
虽然
defer
非常有用,但过多的defer
可能会影响性能,因为每个defer
都会增加函数的退出成本。
9. 错误处理
-
如果
defer
中的函数调用返回错误,这些错误通常会被忽略,除非在函数返回前显式检查。
10. 与异常处理
-
在发生
panic
时,defer
语句中的函数会在panic
被传递到上层函数前执行,这可以用来恢复或记录panic
发生的信息。
示例:资源释放
func main() {file, err := os.Open("example.txt")if err != nil {log.Fatal(err)}defer file.Close() // 确保文件关闭// 执行文件操作 }
在这个例子中,无论文件操作成功与否,file.Close()
都会被调用,确保文件资源被正确释放。
示例:记录执行时间
func someFunction() {defer func() {fmt.Println("Function took", time.Since(start), "to execute.")}()start := time.Now()// 执行一些操作 }
在这个例子中,defer
用于记录函数的执行时间。
通过这些总结和示例,您可以更全面地理解 defer
语句的用途和特点。
追问一:
defer 语句中的函数参数会在 defer 语句执行时立即求值,而不是在函数实际调用时。这句话不能理解什么叫做参数求值?
在Go语言中,当您使用 defer
语句时,您实际上是在告诉Go运行时系统:“在当前函数返回之前,调用这个函数。”但是,defer
语句中的函数参数是立即求值的,这意味着在 defer
语句执行的时候,函数的参数就已经被计算好了,而不是在实际调用 defer
中的函数时才计算。
参数求值的例子
让我们通过一个例子来说明这一点:
package mainimport "fmt"func main() {i := 0defer fmt.Println(i) // 这里的 i 立即被求值i++ // 这个操作对 defer 中的 i 没有影响 }
在这个例子中,尽管在 defer
语句之后 i
的值被改变了,defer
中的 fmt.Println(i)
打印的值仍然是 0
。这是因为在 defer
语句执行时,i
的值(即 0
)已经被求值并保存起来了,之后对 i
的修改不会影响 defer
中的值。
为什么立即求值参数?
立即求值参数的原因是 defer
语句可能会在函数的任何地方使用,而且可能会在函数的逻辑中多次修改参数。如果参数在 defer
函数调用时才求值,那么就需要在调用时重新计算所有相关的表达式,这可能会导致效率低下,也可能使代码逻辑变得复杂和难以理解。
函数调用与参数求值的区别
当您调用一个函数时,通常参数是在调用时才求值的。例如:
func main() {i := 0fmt.Println(i) // 这里的 i 在调用时求值i++ // 这个操作影响 fmt.Println 中的 ifmt.Println(i) }
在这个例子中,第一次调用 fmt.Println(i)
时,i
的值是 0
,第二次调用 fmt.Println(i)
时,i
的值是 1
,因为参数是在函数调用时才求值的。
总结
所以,当您在 defer
语句中使用参数时,重要的是要记住这些参数是在 defer
语句执行时立即求值的,而不是在 defer
函数实际调用时。这意味着在 defer
语句之后的任何对参数的修改都不会影响 defer
函数中的参数值。
追问二:
虽然 defer 非常有用,但过多的 defer 可能会影响性能,因为每个 defer 都会增加函数的退出成本。这句话怎么理解?是因为 函数的退出是指函数返回,但是defer语句是在函数返回前执行,因此,defer语句阻碍了函数的退出?对吗?
函数退出的过程 1.函数执行完毕:当函数中的所有代码都执行完毕后,或者遇到 return 语句时,函数准备返回。 2.执行 defer 语句:在函数实际返回之前,Go运行时会按照后进先出的顺序(LIFO)执行所有已声明的 defer 语句。这意味着最后执行的 defer 会最先被调用。 3.返回结果:在所有 defer 语句执行完毕后,函数才会真正返回其结果。
比如:
package mainimport "fmt"func main() {defer fmt.Println("First defer")defer fmt.Println("Second defer")defer fmt.Println("Third defer")fmt.Println("Function body") }
Function body Third defer Second defer First defer //答案很容易想到,但是要说的是:Function body虽然是第一个执行,但是是最后一个完成的
更多的defer理解:延迟、恐慌和恢复 - Go 语言博客 (go-zh.org)
指针
Go 拥有指针。指针保存了值的内存地址。
类型 *T
是指向 T
类型值的指针,其零值为 nil
。
var p *int
&
操作符会生成一个指向其操作数的指针。
i := 42 p = &i
*
操作符表示指针指向的底层值。
fmt.Println(*p) // 通过指针 p 读取 i *p = 21 // 通过指针 p 设置 i
这也就是通常所说的「解引用」或「间接引用」。
与 C 不同,Go 没有指针运算。
结构体
一个 结构体(struct
)就是一组 字段(field)。
type Vertex struct {X intY int }
结构体字段
结构体字段可通过点号 .
来访问。
func main() {v := Vertex{1, 2}v.X = 4fmt.Println(v.X) }
结构体指针
结构体字段可通过结构体指针来访问。
如果我们有一个指向结构体的指针 p
那么可以通过 (*p).X
来访问其字段 X
。 不过这么写太啰嗦了,所以语言也允许我们使用隐式解引用,直接写 p.X
就可以。
func main() {v := Vertex{1, 2}p := &vp.X = 1e9fmt.Println(v) } 直接输出结构体
结构体字面量
使用 Name:
语法可以仅列出部分字段(字段名的顺序无关)。
特殊的前缀 &
返回一个指向结构体的指针。
var (v1 = Vertex{1, 2} // 创建一个 Vertex 类型的结构体v2 = Vertex{X: 1} // Y:0 被隐式地赋予零值v3 = Vertex{} // X:0 Y:0p = &Vertex{1, 2} // 创建一个 *Vertex 类型的结构体(指针) ) func main() {fmt.Println(v1, p, v2, v3) }
数组
类型 [n]T
表示一个数组,它拥有 n
个类型为 T
的值。
表达式
var a [10]int
会将变量 a
声明为拥有 10 个整数的数组。
数组的长度是其类型的一部分,因此数组不能改变大小。 这看起来是个限制,不过没关系,Go 拥有更加方便的使用数组的方式。(就是切片)
package mainimport "fmt"func main() {var a [2]stringa[0] = "Hello"a[1] = "World"fmt.Println(a[0], a[1])fmt.Println(a)primes := [6]int{2, 3, 5, 7, 11, 13}fmt.Println(primes) }输出: Hello World [Hello World] [2 3 5 7 11 13]
切片
每个数组的大小都是固定的。而切片则为数组元素提供了动态大小的、灵活的视角。 在实践中,切片比数组更常用。
类型 []T
表示一个元素类型为 T
的切片。.
切片通过两个下标来界定,一个下界和一个上界,二者以冒号分隔:
a[low : high]
它会选出一个半闭半开区间,包括第一个元素,但排除最后一个元素。
以下表达式创建了一个切片,它包含 a
中下标从 1 到 3 的元素:
a[1:4]
package mainimport "fmt"func main() {primes := [6]int{2, 3, 5, 7, 11, 13}var s []int = primes[1:4]fmt.Println(s) }//输出 [3 5 7]
切片类似数组的引用
切片就像数组的引用 切片并不存储任何数据,它只是描述了底层数组中的一段。
更改切片的元素会修改其底层数组中对应的元素。
和它共享底层数组的切片都会观测到这些修改。
package mainimport "fmt"func main() {names := [4]string{"John","Paul","George","Ringo",}fmt.Println(names)a := names[0:2]b := names[1:3]fmt.Println(a, b)b[0] = "XXX"fmt.Println(a, b)fmt.Println(names) } //输出: [John Paul George Ringo] [John Paul] [Paul George] [John XXX] [XXX George] [John XXX George Ringo] /*切片的下标和索引 下标(Index):指的是元素在切片中的位置。切片的下标总是从0开始,即使这个切片是从某个数组的中间部分开始的。 索引(Indexing):是访问切片或数组中特定位置元素的操作。*/
切片字面量
切片字面量类似于没有长度的数组字面量。
这是一个数组字面量:
[3]bool{true, true, false}
下面这样则会创建一个和上面相同的数组,然后再构建一个引用了它的切片:
[]bool{true, true, false}
package mainimport "fmt"func main() {q := []int{2, 3, 5, 7, 11, 13}fmt.Println(q)r := []bool{true, false, true, true, false, true}fmt.Println(r)s := []struct {i intb bool}{{2, true},{3, false},{5, true},{7, true},{11, false},{13, true},}fmt.Println(s) }
切片的默认行为
在进行切片时,你可以利用它的默认行为来忽略上下界。
切片下界的默认值为 0,上界则是该切片的长度。
对于数组
var a [10]int
来说,以下切片表达式和它是等价的:
a[0:10] a[:10] a[0:] a[:]
package mainimport "fmt"func main() {s := []int{2, 3, 5, 7, 11, 13}s = s[1:4]fmt.Println(s)s = s[:2]fmt.Println(s)s = s[1:]fmt.Println(s) } //结果 [3 5 7] [3 5] [5]
切片的长度与容量
切片拥有 长度 和 容量。
切片的长度就是它所包含的元素个数。
切片的容量是从它的第一个元素开始数,到其底层数组元素末尾的个数。
切片 s
的长度和容量可通过表达式 len(s)
和 cap(s)
来获取。
你可以通过重新切片来扩展一个切片,给它提供足够的容量。 试着修改示例程序中的切片操作,向外扩展它的长度,看看会发生什么。
package mainimport "fmt"func main() {s := []int{2, 3, 5, 7, 11, 13}printSlice(s)// 截取切片使其长度为 0s = s[:0]printSlice(s)// 扩展其长度s = s[:6]printSlice(s) /*// 舍弃前两个值s = s[2:]printSlice(s) *///舍弃后俩个值s = s[:len(s)-2]printSlice(s)}func printSlice(s []int) {fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s) }
总结
-
切片的长度是它包含的元素个数。
-
切片的容量是从它的第一个元素开始数,到其底层数组元素末尾的个数。
-
你可以通过
len(s)
获取切片的长度,通过cap(s)
获取切片的容量。 -
你可以通过重新切片来扩展一个切片的长度,只要不超过它的容量。
nil 切片
切片的零值是 nil
。
nil 切片的长度和容量为 0 且没有底层数组。
package mainimport "fmt"func main() {var s []intfmt.Println(s, len(s), cap(s))if s == nil {fmt.Println("nil!")} }//结果:[] 0 0 nil!
用 make 创建切片
切片可以用内置函数 make
来创建,这也是你创建动态数组的方式。
make
函数会分配一个元素为零值的数组并返回一个引用了它的切片:
a := make([]int, 5) // len(a)=5
要指定它的容量,需向 make
传入第三个参数:
b := make([]int, 0, 5) // len(b)=0, cap(b)=5b = b[:cap(b)] // len(b)=5, cap(b)=5 b = b[1:] // len(b)=4, cap(b)=4
package mainimport "fmt"func main() {a := make([]int, 5)printSlice("a", a)//5,5b := make([]int, 0, 5)printSlice("b", b)// 0,5c := b[:2]printSlice("c", c)//2,5d := c[2:5]printSlice("d", d)//3,3 }func printSlice(s string, x []int) {fmt.Printf("%s len=%d cap=%d %v\n",s, len(x), cap(x), x) }//结果:a len=5 cap=5 [0 0 0 0 0] b len=0 cap=5 [] c len=2 cap=5 [0 0] d len=3 cap=3 [0 0 0]
解析: 这段代码演示了Go语言中切片的创建和切片操作对长度和容量的影响。让我们逐行分析代码和输出:1. 创建一个长度为5的切片 `a`,并且使用 `make` 函数指定其容量也为5。这意味着 `a` 可以存储5个元素,索引从0到4。```goa := make([]int, 5)printSlice("a", a)// 输出: a len=5 cap=5 [0 0 0 0 0]
这里,a
的长度和容量都是5,切片中的元素被初始化为int类型的零值,即0。
-
创建一个容量为5的切片
b
,但是初始长度为0。这意味着b
可以增长到存储5个元素,但是目前它不包含任何元素。b := make([]int, 0, 5) printSlice("b", b) // 输出: b len=0 cap=5 []
b
的长度是0,容量是5,表示b
可以扩展到包含5个元素而不需要分配新的底层数组。 -
通过
b
创建一个新的切片c
,其长度为2,容量为5。这是通过在b
的基础上进行切片操作[:2]
实现的,它取b
的前两个元素(尽管b
中没有元素),但保留了扩展到5个元素的能力。c := b[:2] printSlice("c", c) // 输出: c len=2 cap=5 []
c
的长度是2,容量是5,但是它仍然没有存储任何实际的元素。 -
通过
c
创建一个新的切片d
,其长度为3,容量为3。这是通过在c
的基础上进行切片操作[2:5]
实现的。由于c
的长度是2,从索引2到c
的“末尾”(实际上是索引2和3,尽管它们没有被初始化)会创建一个长度为3的切片,但是d
的容量只能是c
的剩余容量加上从c
的末尾到d
所需长度的距离。d := c[2:5] printSlice("d", d) // 输出: d len=3 cap=3 []
d
的长度是3,容量是3。由于c
的长度是2,所以c[2:5]
实际上是指从索引2开始到索引4结束的区域,但因为c
只有2个空间,所以d
只能包含3个元素(索引2、3、4),其中索引2和3是超出了c
的范围,所以d
的容量是3。
总结
-
切片的长度是它包含的元素个数。
-
切片的容量是从它的第一个元素开始数,到其底层数组末尾的个数,这个数字表示切片可以增长到多大而不需要分配新的底层数组。
-
通过
make
可以创建一个指定长度和容量的切片。 -
通过切片操作
[low:high]
可以创建一个新的切片,新切片的长度是high-low
,容量是cap(x)-high+low
,其中x
是原始切片。
### 切片的长度和容量的计算方法切片长度 = high - low切片容量 = cap(x) - low,其中x是原数组或者切片的容量## 切片的切片切片可以包含任何类型,当然也包括其他切片。简单的理解就是更灵活的二维数组,可以是动态的、不规则的```go package mainimport "fmt"func main() {// 创建一个切片的切片,类似于二维数组matrix := [][]int{{1, 2, 3},{4, 5},{7, 8, 9, 10},}// 访问和修改元素fmt.Println("Original matrix:")for _, row := range matrix {fmt.Println(row)}// 修改第二行的第二个元素matrix[1][1] = 50// 扩展第三行matrix[2] = append(matrix[2], 11)fmt.Println("Modified matrix:")for _, row := range matrix {fmt.Println(row)} }//另外,如果不规则,要注意访问的下标同样不可以“越界”
切片的切片
切片可以包含任何类型,当然也包括其他切片。
package mainimport ("fmt" )func main() {matrix := [][]int{{1, 2, 3},{4, 5},{7, 8, 9, 10},}fmt.Println(matrix[1][2]) }
向切片追加元素
为切片追加新的元素是种常见的操作,为此 Go 提供了内置的 append
函数。内置函数的文档对该函数有详细的介绍。
func append(s []T, vs ...T) []T
append
的第一个参数 s
是一个元素类型为 T
的切片,其余类型为 T
的值将会追加到该切片的末尾。
append
的结果是一个包含原切片所有元素加上新添加元素的切片。
当 s
的底层数组太小,不足以容纳所有给定的值时,它就会分配一个更大的数组。 返回的切片会指向这个新分配的数组。
(要了解关于切片的更多内容,请阅读文章 Go 切片:用法和本质。)
package mainimport "fmt"func main() {var s []intprintSlice(s)// 可在空切片上追加s = append(s, 0)printSlice(s)// 这个切片会按需增长s = append(s, 1)printSlice(s)// 可以一次性添加多个元素s = append(s, 2, 3, 4)printSlice(s) }func printSlice(s []int) {fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s) }
range 遍历
for
循环的 range
形式可遍历切片或映射。
当使用 for
循环遍历切片时,每次迭代都会返回两个值。 第一个值为当前元素的下标,第二个值为该下标所对应元素的一份副本。
package mainimport "fmt"var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}func main() {for i, v := range pow {fmt.Printf("2**%d = %d\n", i, v)} }
range 遍历(续)
可以将下标或值赋予 _
来忽略它。
for i, _ := range pow for _, value := range pow
若你只需要索引,忽略第二个变量即可。
for i := range pow
package mainimport "fmt"func main() {pow := make([]int, 10)for i := range pow {pow[i] = 1 << uint(i) // == 2**i}for _, value := range pow {fmt.Printf("%d\n", value)} }
练习:切片
实现 Pic
。它应当返回一个长度为 dy
的切片,其中每个元素是一个长度为 dx
,元素类型为 uint8
的切片。当你运行此程序时,它会将每个整数解释为灰度值 (好吧,其实是蓝度值)并显示它所对应的图像。
图像的解析式由你来定。几个有趣的函数包括 (x+y)/2
、x*y
、x^y
、x*log(y)
和 x%(y+1)
。
(提示:需要使用循环来分配 [][]uint8
中的每个 []uint8
。)
(请使用 uint8(intValue)
在类型之间转换;你可能会用到 math
包中的函数。)
package mainimport "golang.org/x/tour/pic"func Pic(dx, dy int) [][]uint8 {// 创建一个二维切片var s [][]uint8for i := 0; i < dy; i++ {// 为每一行创建一个切片s = append(s, make([]uint8, dx))}// 遍历二维切片,赋值for x := 0; x < dy; x++ {for y := 0; y < dx; y++ {// 计算每个像素的值s[x][y] = uint8(x*y)}}return s }func main() {pic.Show(Pic) } //主要练习的是: 1.定义声明一个二维切片 2.会逐行创建二维切片 3.遍历二维切片 4.赋值强转
map 映射
map
映射将键映射到值。
映射的零值为 nil
。nil
映射既没有键,也不能添加键。
make
函数会返回给定类型的映射,并将其初始化备用。
package mainimport "fmt"type Vertex struct {Lat, Long float64 }var m map[string]Vertex//声明m为一个全局变量func main() {m = make(map[string]Vertex)//初始化m,为一个空的映射m["Bell Labs"] = Vertex{40.68433, -74.39967,}fmt.Println(m["Bell Labs"]) }
映射字面量
映射的字面量和结构体类似,只不过必须有键名。(也就是键和值一定是成对出现的)
修改映射
在映射 m
中插入或修改元素:
m[key] = elem
获取元素:
elem = m[key]
删除元素:
delete(m, key)
通过双赋值检测某个键是否存在:
elem, ok = m[key]
若 key
在 m
中,ok
为 true
;否则,ok
为 false
。
若 key
不在映射中,则 elem
是该映射元素类型的零值。
注:若 elem
或 ok
还未声明,你可以使用短变量声明:
elem, ok := m[key]
package mainimport "fmt"func main() {m := make(map[string]int)m["答案"] = 42fmt.Println("值:", m["答案"])m["答案"] = 48fmt.Println("值:", m["答案"])delete(m, "答案")fmt.Println("值:", m["答案"])v, ok := m["答案"]fmt.Println("值:", v, "是否存在?", ok) }/* 结果1: 值: 42 值: 48 值: 0 值: 0 是否存在? false未删除前: 结果2: 值: 42 值: 48 值: 48 是否存在? true那么,说明map的key可以返回两个数,value和布尔值 */
练习:映射
实现 WordCount
。它应当返回一个映射,其中包含字符串 s
中每个“单词”的个数。 函数 wc.Test
会为此函数执行一系列测试用例,并输出成功还是失败。
你会发现 strings.Fields 很有用。
package mainimport ("strings""golang.org/x/tour/wc" )func WordCount(s string) map[string]int {// 使用strings.Fields函数分割字符串words := strings.Fields(s)counts := make(map[string]int)for _, word := range words {// 增加每个单词的计数counts[word]++}return counts }func main() {//wc.Test系统库函数可以提供 测试wc.Test(WordCount) }
函数值
函数也是值。它们可以像其他值一样传递。
函数值可以用作函数的参数或返回值。
package mainimport ("fmt""math" )func compute(fn func(float64, float64) float64) float64 {return fn(3, 4) }func main() {hypot := func(x, y float64) float64 {return math.Sqrt(x*x + y*y)}fmt.Println(hypot(5, 12))fmt.Println(compute(hypot))fmt.Println(compute(math.Pow)) }
函数闭包
Go 函数可以是一个闭包。闭包是一个函数值,它引用了其函数体之外的变量。 该函数可以访问并赋予其引用的变量值,换句话说,该函数被“绑定”到了这些变量。
例如,函数 adder
返回一个闭包。每个闭包都被绑定在其各自的 sum
变量上。
package mainimport "fmt"func adder() func(int) int {sum := 0return func(x int) int {sum += xreturn sum} }func main() {pos, neg := adder(), adder()for i := 0; i < 10; i++ {fmt.Println(pos(i),neg(-2*i),)} }
练习:斐波纳契闭包
让我们用函数做些好玩的。
实现一个 fibonacci
函数,它返回一个函数(闭包),该闭包返回一个斐波纳契数列 (0, 1, 1, 2, 3, 5, ...)。
package main import "fmt" // fibonacci 是返回一个「返回一个 int 的函数」的函数 func fibonacci() func() int {a,b := 0,1return func() int{a,b=b,a+b//a是基于a,b经计算出的下一个结果return a} } func main() {f := fibonacci()for i := 0; i < 10; i++ {//输出10个斐波那契数fmt.Println(f())} }
方法
类的方法(Go 中的结构体方法):
-
结构体方法是与特定的结构体类型相关联的函数。方法的定义中有一个接收者,这个接收者指定了方法是属于哪个结构体类型的。
type Rectangle struct {width, height int } func (r Rectangle) Area() int {return r.width * r.height }
-
在
Area
方法中,(r Rectangle)
就是接收者,表明Area
方法是属于Rectangle
结构体的。通过接收者r
,方法可以访问结构体内部的成员变量width
和height
。
其实,本质是函数,细节的说,它是类的函数,类的概念是结构体。该种方法的接收者就是结构体或其他类型。
追问:那么这里的类型可以是基本类型吗?
-
Go 中的方法接收者类型
-
在 Go 语言中,方法的接收者类型可以是基本类型,但有一些特殊的规则和限制。
-
-
为基本类型定义方法
-
例如,可以为
int
类型定义方法,但不能直接在int
类型的基础上定义,而是需要通过定义一个新的类型别名或者新的类型基于int
来实现。 -
使用类型别名
-
下面是使用类型别名定义方法的示例:
-
-
type MyInt int func (i MyInt) Double() MyInt {return i * 2 } func main() {num := MyInt(5)result := num.Double()println(result) }
-
在这个例子中,首先定义了
MyInt
作为int
的类型别名,然后为MyInt
定义了Double
方法。 -
使用基于基本类型的新类型
-
也可以定义一个基于基本类型的新类型(本质还是结构体),如下所示:
-
type NewInt struct {value int } func (n NewInt) Triple() int {return n.value * 3 } func main() {num := NewInt{value: 4}result := num.Triple()println(result) }
-
这里定义了一个名为
NewInt
的结构体类型,它包含一个int
类型的成员变量value
,然后为NewInt
定义了Triple
方法。
注意:
-
如果真的要细分的话,方法一般指的是类型内的函数
-
方法的接收者不是参数
-
方法的调用是通过类型变量调用的(类似于对象实例调用类的方法)
方法(续)
注意:(规则)接收者的类型定义和方法声明必须在同一包内。
接收者可以是结构体类型也可以是非结构体类型(通过别名定义的其他类型)
根据规则,不能给其他包的类型声明方法,比如说int,但是可以通过别名定义,然后为这个新的类型进行声明方法
package main type MyFloat float64 func (f MyFloat) Abs() MyFloat {if f < 0 {return -f}return f } func main() {num := MyFloat(-3.14)absNum := num.Abs()println(absNum) }
类型转换和别名定义
类型转换的写法:
a = int(a) b = Myint(b) ...
别名定义的方法:
方法一: type Myint int 这是基于int类型的新类型:Myint,他们属于两个不同的类型。因此在一起使用运算的时候需要类型转换 方法二: type Myint = int 这是将int类型别名为Myint,他们属于两个相同的类型。只是名字不同。因此,不能直接为Myint进行类型方法的声明
指针类型的接收者
package main import ("fmt""math" ) type Vertex struct {X, Y float64 } func (v Vertex) Abs() float64 {return math.Sqrt(v.X*v.X + v.Y*v.Y) } func (v *Vertex) Scale(f float64) {v.X = v.X * fv.Y = v.Y * f } func main() {v := Vertex{3, 4}v.Scale(10)fmt.Println(v.Abs()) } 输出:50
去掉*:
func (v Vertex) Scale(f float64) {v.X = v.X * fv.Y = v.Y * f } 输出:5
发现:给结构体的指针定义方法,用法不变。但是,此时对接收者的修改是直接的。
如果不是给结构体的指针定义方法,而是给结构体的定义方法,那么对接收者的修改是间接的。那么就需要结构体变量接收这个副本
func (v Vertex) Scale(f float64) Vertex{v.X = v.X * fv.Y = v.Y * freturn v }func main() {v := Vertex{3, 4}v = v.Scale(10)fmt.Println(v.Abs()) } 输出:50
方法与指针重定向
比较前两个程序,你大概会注意到带指针参数的函数必须接受一个指针:
var v Vertex ScaleFunc(v, 5) // 编译错误! ScaleFunc(&v, 5) // OK
而接收者为指针的的方法被调用时,接收者既能是值又能是指针:
var v Vertex v.Scale(5) // OK p := &v p.Scale(10) // OK
对于语句 v.Scale(5)
来说,即便 v
是一个值而非指针,带指针接收者的方法也能被直接调用。 也就是说,由于 Scale
方法有一个指针接收者,为方便起见,Go 会将语句 v.Scale(5)
解释为 (&v).Scale(5)
。
package mainimport "fmt"type Vertex struct {X, Y float64 }func (v *Vertex) Scale(f float64) {//类的方法v.X = v.X * fv.Y = v.Y * f }func ScaleFunc(v *Vertex, f float64) {//一般函数v.X = v.X * fv.Y = v.Y * f }func main() {v := Vertex{3, 4}v.Sca le(2)ScaleFunc(&v, 10)p := &Vertex{4, 3}p.Scale(3)ScaleFunc(p, 8)fmt.Println(v, p) }
简单的说:
上面的代码说明了:接收者为指针的的方法被调用时,接收者既能是值又能是指针;带指针参数的函数必须接受一个指针。
指针的重定向是:当接收者为指针时,如果调用者为值,那么值在go底层中会被解析为(&值)的形式。这是 Go 语言提供的一种方便的语法糖(写法更简洁)。它允许开发者在编写代码时,不必严格区分是使用值调用还是指针调用方法,只要方法定义中的接收者类型(无论是值类型还是指针类型)能够正确处理对应的调用情况即可。
反之亦然
选择值或指针作为接收者
使用指针接收者的原因有二:
首先,方法能够修改其接收者指向的值。
其次,这样可以避免在每次调用方法时复制该值。若值的类型为大型结构体时,这样会更加高效。
在本例中,Scale
和 Abs
接收者的类型为 *Vertex
,即便 Abs
并不需要修改其接收者(引用了)。
通常来说,所有给定类型的方法都应该有值或指针接收者,但并不应该二者混用。 (我们会在接下来几页中明白为什么。)
接口
接口类型 的定义为一组方法签名。
接口类型的变量可以持有任何实现了这些方法的值。
定义接口时,接口的方法是方法签名(只有 方法名(参数列表)返回值类型),不是方法定义(func修饰)
注意: 示例代码的第 22 行存在一个错误。由于 Abs
方法只为 *Vertex
(指针类型)定义,因此 Vertex
(值类型)并未实现 Abser
。
package mainimport ("fmt""math" )// 定义Abser接口,规定了实现该接口的类型必须有Abs方法,返回值为float64 type Abser interface {Abs() float64 }func main() {// 声明一个Abser接口类型的变量a,初始值为nilvar a Abser// 创建一个MyFloat类型的变量f,初始值为 -math.Sqrt2f := MyFloat(-math.Sqrt2)// MyFloat类型实现了Abs方法,满足Abser接口,所以可以将f赋值给aa = f// 创建一个Vertex结构体类型的变量v,初始化X和Y的值为3和4v := Vertex{3, 4}// *Vertex类型实现了Abs方法,满足Abser接口,所以可以将v的地址&v赋值给aa = &v// 这行代码会编译失败,因为v是Vertex类型(不是*Vertex),Vertex类型没有实现Abs方法// a = v// 调用a所指向的具体类型(MyFloat或者*Vertex)的Abs方法,并打印结果fmt.Println(a.Abs()) }// 定义MyFloat类型,它是基于float64的自定义类型 type MyFloat float64// 为MyFloat类型定义Abs方法,实现Abser接口 // 如果f小于0,则返回 -f(转换为float64类型),否则返回f本身(转换为float64类型) func (f MyFloat) Abs() float64 {if f < 0 {return float64(-f)}return float64(f) }// 定义Vertex结构体类型,包含两个float64类型的字段X和Y type Vertex struct {X, Y float64 }// 为*Vertex类型定义Abs方法,实现Abser接口 // 根据勾股定理计算并返回Vertex结构体的模长 func (v *Vertex) Abs() float64 {return math.Sqrt(v.X*v.X + v.Y*v.Y) }
还要明白的一点是:接口类型的变量可以直接被赋值为实现了该类型的变量,这是什么用法?
-
接口的多态特性
-
在 Go 语言中,接口类型的变量可以直接被赋值为实现了该接口的具体类型的变量,这是接口多态性的一种体现。
-
接口定义了一组方法签名,任何类型只要实现了这些方法就被认为实现了该接口。这种赋值方式使得代码更加灵活和可扩展。
-
例如,在之前的代码中:
-
type Abser interface {Abs() float64 }type MyFloat float64func (f MyFloat) Abs() float64 {if f < 0 {return float64(-f)}return float64(f) }type Vertex struct {X, Y float64 }func (v *Vertex) Abs() float64 {return math.Sqrt(v.X*v.X + v.Y*v.Y) }
-
这里定义了
Abser
接口,MyFloat
类型和*Vertex
类型都实现了Abser
接口(因为它们都实现了Abs
方法)。
接口类型的变量可以直接被赋值为实现了该类型的变量,这是什么用法?
-
赋值的原理和好处
-
原理
-
当把实现了接口的具体类型(如
MyFloat
或者*Vertex
)赋值给接口类型变量(如var a Abser
)时,Go 语言会在内部进行类型转换。这个转换过程会检查具体类型是否实现了接口所要求的所有方法,如果实现了则允许赋值。
-
-
好处
-
代码复用和模块化:可以编写通用的函数或方法,这些函数或方法接受接口类型的参数。例如,可以编写一个函数,它接受
Abser
接口类型的参数,然后在函数内部调用Abs
方法。这个函数可以处理任何实现了Abser
接口的类型,无论是MyFloat
、*Vertex
还是将来可能定义的其他类型。(可以用接口类型变量处理所有实现了该接口的类型的变量) -
可扩展性:方便在代码中添加新的类型来实现接口,而不需要对使用接口的代码进行大量修改。例如,如果要添加一个新的类型
MyComplex
并且实现Abser
接口,那么现有的接受Abser
接口变量的代码可以直接使用MyComplex
类型的实例,无需改变这些代码的逻辑结构。
-
-
接口值
接口也是值。它们可以像其它值一样传递。
接口值可以用作函数的参数或返回值。
package mainimport ("fmt""math" )type I interface {M() }type T struct {S string }func (t *T) M() {fmt.Println(t.S) }type F float64func (f F) M() {fmt.Println(f) }func main() {var i Ii = &T{"Hello"}describe(i)i.M()i = F(math.Pi)describe(i)i.M() }func describe(i I) {fmt.Printf("(%v, %T)\n", i, i) }
注意:
-
接口类型的变量在被赋值后,仍然是接口类型
-
接口类型变量在调用方法时,直接 . 调用
-
区别于之前的结构体类型变量调用类型内方法提供的语法糖: 不论接收者是指针还是值都可以直接调用
底层值为 nil 的接口值
即便接口内的具体值为 nil,方法仍然会被 nil 接收者调用。
在一些语言中,这会触发一个空指针异常,但在 Go 中通常会写一些方法来优雅地处理它(如本例中的 M
方法)。
注意: 保存了 nil 具体值的接口其自身并不为 nil。
//下面说明了注意的部分: package mainimport ("fmt" )type MyInterface interface{} type MyStruct struct{}func main() {var s *MyStructvar i MyInterface = s//接口i接收了具体值为nil的指针类型fmt.Println(i == nil)//接口的本身(包含了 值+类型 两部分),值为nil,但是类型已经确定*MyStructfmt.Println(i)fmt.Printf("%T",i) }
//这里描述的是go是如何处理接口为nil时出现接口变量仍然被调用,比如:i.M()时,触发 “空指针” 的异常 func (t *T) M() {if t == nil {fmt.Println("<nil>")return}fmt.Println(t.S) }
其实,可以总结一下:
接口变量是一个整体,他包含了 值+类型 ,我们也叫做元组。任何一个为nil时,他都是一个不完全的接口变量,那么也不是一个某类型的实例(所以如果此时用接口变量调用方法时,一定会出现运行时异常)【所以也会说go不允许出现不完全的接口变量,即使有时候发现接口变量的值为空,但是类型不为空。在调用时仍然会报错】
空接口
指定了零个方法的接口值被称为 空接口:
interface{}
空接口可保存任何类型的值。(因为每个类型都至少实现了零个方法。)
空接口被用来处理未知类型的值。例如,fmt.Print
可接受类型为 interface{}
的任意数量的参数。
其他的使用场景:
-
数据存储与遍历(容器类型)
-
场景描述:当需要创建一个可以存储多种不同类型数据的容器(如切片、映射等)时,空接口非常有用。例如,创建一个切片来存储不同类型的元素。
-
示例代码:
-
package mainimport ("fmt" )func main() {var dataSlice []interface{}dataSlice = append(dataSlice, 1)dataSlice = append(dataSlice, "hello")dataSlice = append(dataSlice, true)for _, v := range dataSlice {fmt.Printf("%v ", v)} }
-
在这个示例中,
dataSlice
是一个元素类型为interface{}
的切片,可以存储int
、string
和bool
等不同类型的元素。然后通过for - range
循环遍历这个切片并打印出每个元素。
-
函数参数的通用性
-
场景描述:编写一个函数,该函数需要处理多种类型的数据,但事先并不知道具体的数据类型。
-
示例代码:
-
package mainimport ("fmt" )func printValue(v interface{}) {fmt.Printf("%v ", v) }func main() {printValue(5)printValue("world")printValue(false) }
-
这里的
printValue
函数接受一个空接口类型的参数,所以它可以接受任何类型的值作为参数,并将其打印出来。
-
结构体中存储不同类型数据
-
场景描述:在一个结构体中,某个字段可能需要存储不同类型的值。
-
示例代码:
-
package mainimport ("fmt" )type MyStruct struct {Data interface{} }func main() {var s1 MyStructs1.Data = 10fmt.Printf("%v ", s1.Data)var s2 MyStructs2.Data = "golang"fmt.Printf("%v ", s2.Data) }
-
在
MyStruct
结构体中,Data
字段的类型为interface{}
,所以可以分别将int
类型和string
类型的值赋给s1.Data
和s2.Data
。
-
类型断言和类型转换的前置操作
-
场景描述:当从外部数据源(如网络读取、配置文件读取等)获取数据时,数据的类型可能是未知的。首先将数据存储为
interface{}
类型,然后再根据实际情况进行类型断言和转换。 -
示例代码(模拟从配置文件读取数据):
-
package mainimport ("fmt" )func readConfigData() interface{} {// 这里模拟从配置文件读取数据,实际可能是从文件读取并解析的复杂过程return "config value" }func main() {data := readConfigData()if str, ok := data.(string); ok {fmt.Printf("The config data is a string: %s", str)} else {fmt.Println("The config data is not a string.")} }
-
在这个示例中,
readConfigData
函数返回一个interface{}
类型的数据,代表从配置文件读取的数据。在main
函数中,首先获取这个数据,然后通过类型断言判断它是否为string
类型,并根据结果进行相应的操作。
//问: if str, ok := data.(string); ok{fmt.Printf("The config data is a string: %s", str) } //这个if语句没看懂
答:
-
类型断言的语法
-
在 Go 语言中,
data.(string)
是类型断言的表达式。这里的data
是一个interface{}
类型的值,(string)
表示试图将data
断言为string
类型。 -
类型断言有两种形式:
-
一种是单值形式,如
str := data.(string)
,这种形式在data
不是string
类型时会触发运行时恐慌(panic)。 -
另一种是这里使用的双值形式,即
str, ok := data.(string)
。这种形式会返回两个值:str
是断言成功后的string
类型的值(如果断言成功),ok
是一个布尔值,表示断言是否成功。
-
-
-
if
语句的逻辑-
在
if str, ok := data.(string); ok
这个语句中:
-
首先执行类型断言
data.(string)
,将结果赋值给str
和ok
。 -
然后根据
ok
的值进行判断。如果ok
为true
,表示data
成功地被断言为string
类型,此时在if
语句块内部,可以安全地使用str
这个string
类型的值进行操作,如fmt.Printf("The config data is a string: %s", str)
。 -
如果
ok
为false
,表示data
不是string
类型,那么if
语句块内的代码不会执行,程序会继续执行if
语句块后面的代码。这种双值类型断言的形式提供了一种安全的方式来处理interface{}
类型的值,避免了因类型不匹配而导致的运行时恐慌。
-
-
追问:类型断言是简单的类型判断吗?
答:不是,类型断言最初进行判断,如果使用双值断言,那么会判断后交给布尔值,然后根据后面的布尔值变量判断。如果true,就继续操作:将接口类型的值转换为指定类型,并将值赋给前面的变量(类型转换+赋值) ;如果为false就不执行if语句块的语句;如果是单值断言,就是没有布尔值变量,那么如果是false直接报错(panic)。
联想:其实他也可以在某种程度上理解为泛型,但似乎比泛型更加强大。泛型是指定某一种数据,当然也可以是Object的 类型,但是这个不用指定,似乎就是Object类型?
-
与泛型的相似性
-
在某种程度上,Go 语言中的空接口
interface{}
确实和其他语言中的泛型有相似之处。 -
像在 Java 中,泛型可以用来编写更通用的代码。例如,
List<T>
可以用来表示一个可以存储任何类型T
的列表。在 Go 中,[]interface{}
(空接口类型的切片)也能起到类似的作用,它可以存储任意类型的值,就像一个通用的容器。 -
在 C# 中,泛型方法或类可以处理不同类型的数据。Go 的空接口在函数参数方面也有类似效果,例如
func processData(data interface{})
这个函数可以接受任何类型的数据作为参数,就像一个泛型函数。
-
-
与泛型的区别及更强大之处
-
类型安全方面
-
泛型系统通常在编译时提供严格的类型检查。例如在 Java 中,如果有一个
List<Integer>
,编译器会确保只有Integer
类型的值可以添加到这个列表中。而 Go 语言的空接口在编译时几乎没有类型限制,它可以接受任何类型的值。这意味着在运行时才可能发现类型不匹配的问题,但也给开发者更多的灵活性。
-
-
无需显式指定类型参数
-
在使用泛型的语言中,如 Java 或 C#,需要显式地指定类型参数(如
List<Integer>
中的Integer
)。而 Go 的空接口不需要指定类型,直接就可以用来表示任意类型。这使得代码更加简洁,尤其是在处理多种不同类型数据且类型难以事先确定的场景下。
-
-
反射与动态性
-
Go 语言的空接口与反射机制配合得很好。由于空接口可以接受任何类型的值,在需要动态处理不同类型数据(如根据数据类型执行不同操作)的场景下,可以方便地结合反射来实现。而在一些有泛型的语言中,虽然泛型提供了一定的通用性,但在动态处理类型方面相对受限。例如,Go 可以通过反射来遍历一个
interface{}
类型的结构体字段,根据字段类型进行不同的操作,这种灵活性是泛型语言较难实现的。
-
-
不过,空接口的这种灵活性也带来了一些风险,如类型错误可能在运行时才被发现,而泛型在编译时就能捕获很多类型错误,提高代码的健壮性。
类型断言
类型断言 提供了访问接口值底层具体值的方式。
t := i.(T)
该语句断言接口值 i
保存了具体类型 T
,并将其底层类型为 T
的值赋予变量 t
。
若 i
并未保存 T
类型的值,该语句就会触发一个 panic。
为了 判断 一个接口值是否保存了一个特定的类型,类型断言可返回两个值:其底层值以及一个报告断言是否成功的布尔值。
t, ok := i.(T)
若 i
保存了一个 T
,那么 t
将会是其底层值,而 ok
为 true
。
否则,ok
将为 false
而 t
将为 T
类型的零值,程序并不会产生 panic。
请注意这种语法和读取一个映射时的相同之处。
package mainimport "fmt"func main() {//i是接口变量,初始化为字符串类型var i interface{} = "hello"//短变量s声明,并断言i是否为string类型,如果是将i赋值给ss := i.(string)fmt.Println(s)//使用双值断言,断言i是否为string类型,如果是则ok为true,将i赋值给ss, ok := i.(string)fmt.Println(s, ok)//使用双值断言,断言i是否为string类型,如果是则ok为false “未报错”:f, ok := i.(float64)fmt.Println(f, ok)//panic:ok为false,“报错”:因为使用了单值断言//f,ok = i.(float64) f = i.(float64)fmt.Println(f) }
小总结:
-
接口变量具有动态类型
-
当一个具体类型的值赋给接口变量时,这个具体类型就是接口变量的动态类型
-
接口变量是否保存了某动态类型:意思就是接口变量的动态类型是否为这个具体类型(通过双值类型断言【安全】的方法去判断)
-
单值断言不安全,如果断言失败就会报出panic;双值断言安全,并可以记录类型断言的结果。
类型选择
类型选择 是一种按顺序从几个类型断言中选择分支的结构。
类型选择与一般的 switch 语句相似,不过类型选择中的 case 为类型(而非值), 它们针对给定接口值所存储的值的类型进行比较。
switch v := i.(type) { case T:// v 的类型为 T case S:// v 的类型为 S default:// 没有匹配,v 与 i 的类型相同 }
类型选择中的声明与类型断言 i.(T)
的语法相同,只是具体类型 T
被替换成了关键字 type
。
此选择语句判断接口值 i
保存的值类型是 T
还是 S
。在 T
或 S
的情况下,变量 v
会分别按 T
或 S
类型保存 i
拥有的值。在默认(即没有匹配)的情况下,变量 v
与 i
的接口类型和值相同。
package mainimport "fmt"func do(i interface{}) {switch v := i.(type) {//v最后记录的是i的值,下面的case是根据i的类型去判断的case int:fmt.Printf("二倍的 %v 是 %v\n", v, v*2)fmt.Println(v)case string:fmt.Printf("%q 长度为 %v 字节\n", v, len(v))fmt.Println(v)default:fmt.Printf("我不知道类型 %T!\n", v)fmt.Println(v)} }func main() {do(21)do("hello")do(true) }
Stringer
fmt 包中定义的 Stringer 是最普遍的接口之一。
type Stringer interface {String() string }
Stringer
是一个可以用字符串描述自己的类型。fmt
包(还有很多包)都通过此接口来打印值。
帮助理解:
-
接口的意义
-
在 Go 语言中,接口定义了一组方法签名。对于
Stringer
接口,它只定义了一个方法String()
,这个方法返回一个string
类型的值。 -
当一个类型实现了
Stringer
接口(即该类型定义了String()
方法且满足Stringer
接口的要求),就意味着这个类型可以以一种自定义的字符串形式来描述自己。
-
-
fmt 包中的使用
-
在
fmt
包中,当需要打印一个值时,fmt
包会检查这个值是否实现了Stringer
接口。 -
例如,假设我们有一个自定义的结构体类型:
-
package mainimport ("fmt" )type Person struct {name stringage int }func (p Person) String() string {return fmt.Sprintf("姓名:%s,年龄:%d", p.name, p.age) }func main() {p := Person{name: "张三", age: 20}fmt.Println(p) }
-
在这个例子中,
Person
结构体实现了Stringer
接口(通过定义String()
方法)。当我们使用fmt.Println(p)
打印p
这个Person
结构体实例时,fmt
包会调用p
的String()
方法来获取一个用于打印的字符串,而不是使用默认的结构体表示形式。这样就允许我们以一种自定义的、更有意义的方式来显示这个结构体的值。 -
如果一个类型没有实现
Stringer
接口,fmt
包会使用该类型的默认格式化方式来打印,通常是类似{field1:value1,field2:value2,...}
的形式。
小总结:
-
其实该接口是fmt包下的一个接口之一
-
该接口名为Stringer,作用是:实现Stringer接口(实现Stringer接口的String方法,返回一个string的字符串)可以实现对某自定义类型进行自定义形式的输出(格式化)
练习:Stringer
通过让 IPAddr
类型实现 fmt.Stringer
来打印点号分隔的地址。
例如,IPAddr{1, 2, 3, 4}
应当打印为 "1.2.3.4"
。
package mainimport "fmt"type IPAddr [4]byte// TODO: 为 IPAddr 添加一个 "String() string" 方法。 func (ip IPAddr) String() string{//func是用于定义函数的关键字//IPAddr基于是4个大小的字节数组的新类型return fmt.Sprintf("%d.%d.%d.%d",ip[0],ip[1],ip[2],ip[3])//Springf是格式化字符串的fmt包内置函数,但不输出到控制台 }func main() {hosts := map[string]IPAddr{"loopback": {127, 0, 0, 1},"googleDNS": {8, 8, 8, 8},}for name, ip := range hosts {fmt.Printf("%v: %v\n", name, ip)} }
Sprintf
和Printf
的区别
-
Printf
函数也是fmt
包中的函数,它会根据格式化字符串和相应的参数,将格式化后的结果直接输出到标准输出(控制台)。例如: -
fmt.Printf("Hello, %s!\n", "world")
会在控制台输出Hello, world!
并换行。 -
而
Sprintf
只是生成格式化后的字符串,不进行输出操作。这使得Sprintf
在需要对格式化后的字符串进行进一步处理(如将其存储到变量中、作为其他函数的参数等)时非常有用。
错误
Go 程序使用 error
值来表示错误状态。
与 fmt.Stringer
类似,error
类型是一个内建接口:
type error interface {Error() string }
(与 fmt.Stringer
类似,fmt
包也会根据对 error
的实现来打印值。)
通常函数会返回一个 error
值,调用它的代码应当判断这个错误是否等于 nil
来进行错误处理。
i, err := strconv.Atoi("42") if err != nil {fmt.Printf("couldn't convert number: %v\n", err)return } fmt.Println("Converted integer:", i)
error
为 nil 时表示成功;非 nil 的 error
表示失败。
package mainimport ("fmt""time" )type MyError struct {//自定义错误类型:错误时间、错误原因When time.TimeWhat string }func (e *MyError) Error() string {//实现error接口,实现Error方法return fmt.Sprintf("at %v,\n%s",e.When, e.What)//自定义错误原因输出格式 }func run() error {//定义一个run方法,返回类型是一个error的错误类型return &MyError{time.Now(),//获取当前时间"it didn't work",//错误信息} }func main() {if err := run(); err != nil {//err接收run的返回值,如果有错误信息【err不为nil】,那么输出自定义的err信息fmt.Println(err)} }
练习:错误
从之前的练习中复制 Sqrt
函数,修改它使其返回 error
值。
Sqrt
接受到一个负数时,应当返回一个非 nil 的错误值。复数同样也不被支持。
创建一个新的类型
type ErrNegativeSqrt float64
并为其实现
func (e ErrNegativeSqrt) Error() string
方法使其拥有 error
值,通过 ErrNegativeSqrt(-2).Error()
调用该方法应返回 "cannot Sqrt negative number: -2"
。
注意: 在 Error
方法内调用 fmt.Sprint(e)
会让程序陷入死循环。可以通过先转换 e
来避免这个问题:fmt.Sprint(float64(e))
。这是为什么呢?
修改 Sqrt
函数,使其接受一个负数时,返回 ErrNegativeSqrt
值。
package mainimport ("fmt" )//创建一个新的类型:基于float64的一个新的基本数据类型 type ErrNegativeSqrt float64 //实现Error函数: //实现了error接口而具备了表示错误的能力。 //但从本质上讲它们仍然是普通的 Go 类型 func (e ErrNegativeSqrt) Error() string {return fmt.Sprintf("cannot Sqrt negative number: %v", float64(e)) } //定义函数:接收一个float64类型的参数,返回float64类型和error的返回值 func Sqrt(x float64) (float64, error) {if x < 0 {//如果x是负数就返回错误原因,和默认值return 0, ErrNegativeSqrt(x)}return x, nil }func main() {result, err := Sqrt(2)if err!= nil {fmt.Println(err)} else {fmt.Println(result)}result, err = Sqrt(-2)if err!= nil {fmt.Println(err)} else {fmt.Println(result)} }
疑惑:type的用法
-
定义新的类型别名(Type Alias)
-
语法:
type newType = existingType
。 -
作用:
-
给现有类型创建一个别名。例如,
type MyInt = int
,这里MyInt
就是int
类型的别名。之后可以使用MyInt
来声明变量,它在本质上和使用int
是一样的。例如:var num MyInt = 10
。 -
在一些场景下可以提高代码的可读性,尤其是当现有类型名称在特定上下文中不够直观时。例如,如果有一个表示年龄的变量,使用
type Age = int
后,Age
比int
更能明确变量的语义。
-
-
-
定义新的结构体类型(Struct Type)
-
语法:
-
type structName struct {field1 type1field2 type2//... }
-
作用:
-
用于创建自定义的复合数据类型。结构体中的每个字段(
field
)可以是不同的类型,包括基本类型(如int
、string
等)或者其他自定义类型。例如:
-
type Person struct {name stringage int }
-
结构体类型允许将相关的数据组合在一起,方便在程序中表示复杂的实体。可以创建
Person
类型的实例并操作其中的字段,如p := Person{"Alice", 25}; fmt.Println(p.name)
。
-
定义新的接口类型(Interface Type)
-
语法:
-
type interfaceName interface {method1(params) returnType1method2(params) returnType2//... }
-
作用:
-
接口定义了一组方法签名,它是一种抽象类型。任何实现了接口中所有方法的具体类型都被认为实现了该接口。例如:
-
type Shape interface {Area() float64Perimeter() float64 }
-
接口在 Go 语言中用于实现多态性。不同的具体形状(如圆形、矩形等)只要实现了
Shape
接口中的Area
和Perimeter
方法,就可以在期望Shape
类型的地方使用。
-
定义新的函数类型(Function Type)
-
语法:
type functionTypeName func(params) returnType
。 -
作用:
-
将函数视为一种类型,可以像其他类型一样进行声明变量、作为参数传递、作为返回值等操作。例如:
-
-
type Adder func(int, int) int
-
这允许更灵活地处理函数,例如,可以将一个符合
Adder
类型定义的函数作为参数传递给另一个函数,或者从一个函数中返回一个Adder
类型的函数。
-
定义新的映射类型(Map Type)
-
语法:
type newMapType map[keyType]valueType
。 -
作用:
-
给特定键值类型的映射定义一个新的类型名称。虽然在 Go 中可以直接使用
map[keyType]valueType
,但定义新的映射类型可以增强代码的可读性和可维护性。例如:
-
-
type UserMap type map[string]User
-
当处理与用户相关的映射时,使用
UserMap
类型比直接使用map[string]User
更清晰地表达了这个映射的用途。
-
定义新的切片类型(Slice Type)
-
语法:
type newSliceType []elementType
。 -
作用:
-
类似映射类型,给特定元素类型的切片定义一个新的类型名称。这有助于在代码中更清晰地标识切片的用途。例如:
-
-
type StringSlice []string
-
在处理只包含字符串元素的切片时,使用
StringSlice
类型可以让代码的语义更加明确。
小总结:type用在类型别名、自定义类型(接口、slice、切片、map映射、结构体)
Readers
io
包指定了 io.Reader
接口,它表示数据流的读取端。
Go 标准库包含了该接口的许多实现,包括文件、网络连接、压缩和加密等等。
io.Reader
接口有一个 Read
方法:
func (T) Read(b []byte) (n int, err error)
Read
用数据填充给定的字节切片并返回填充的字节数和错误值。在遇到数据流的结尾时,它会返回一个 io.EOF
错误。
示例代码创建了一个 strings.Reader 并以每次 8 字节的速度读取它的输出。
package mainimport (// 导入fmt包,用于格式化输出fmt "fmt"// 导入io包,这里主要用于处理读取的结束标志(io.EOF)io "io"// 导入strings包,用于创建字符串读取器strings "strings" )func main() {// 使用strings.NewReader创建一个从字符串读取数据的读取器r := strings.NewReader("Hello, Reader!")// 创建一个字节切片,用于存储每次读取的数据b := make([]byte, 8)// 开始循环读取数据for {// 从读取器r中读取数据到字节切片b中,n表示实际读取的字节数,err表示读取过程中出现的错误n, err := r.Read(b)// 输出实际读取的字节数、错误信息和字节切片的内容fmt.Printf("n = %v err = %v b = %v\n", n, err, b)// 输出实际读取到的字节切片内容(转换为字符串形式)fmt.Printf("b[:n] = %q\n", b[:n])// 如果读取到文件末尾(io.EOF),则跳出循环if err == io.EOF {break}} }
练习:Reader
实现一个 Reader
类型,它产生一个 ASCII 字符 'A'
的无限流。
package mainimport "golang.org/x/tour/reader"type MyReader struct{}// 为MyReader添加Read方法 func (r MyReader) Read(b []byte) (int, error) {//遍历b切片并填充for i := range b {b[i] = 'A'}//返回b的长度(读取到的数据长度)和空return len(b), nil }func main() {//测试:reader.Validate(MyReader{}) }
练习:rot13Reader
有种常见的模式是一个 io.Reader 包装另一个 io.Reader
,然后通过某种方式修改其数据流。
例如,gzip.NewReader 函数接受一个 io.Reader
(已压缩的数据流)并返回一个同样实现了 io.Reader
的 *gzip.Reader
(解压后的数据流)。
编写一个实现了 io.Reader
并从另一个 io.Reader
中读取数据的 rot13Reader
,通过应用 rot13 代换密码对数据流进行修改。
rot13Reader
类型已经提供。实现 Read
方法以满足 io.Reader
。
package mainimport ("io""os""strings" )type rot13Reader struct {r io.Reader }func (r rot13Reader) Read(b []byte)(int,error){n,err := r.r.Read(b)if err != nil{return n,err }for i:=0;i<n;i++{if (b[i] >= 'A' && b[i] <= 'M') || (b[i] >= 'a' && b[i] <= 'm') {b[i] += 13} else if (b[i] >= 'N' && b[i] <= 'Z') || (b[i] >= 'n' && b[i] <= 'z') {b[i] -= 13}}return n, nil }func main() {s := strings.NewReader("Lbh penpxrq gur pbqr!")r := rot13Reader{s}io.Copy(os.Stdout, &r) } /** 另外,rot13就是简易的替换加密算法:'A' - 'M' 这些字母加密后会变成 'N' - 'Z',例如 'A' 加密后变成 'N','B' 变成 'O',以此类推;而 'N' - 'Z' 这些字母加密后会变成 'A' - 'M',例如 'N' 加密后变成 'A','O' 变成 'B'。小写字母相同
图像
image 包定义了 Image
接口:
package imagetype Image interface {//函数名 + 返回值ColorModel() color.Model/*该方法返回图像的颜色模型。颜色模型(color.Model)定义了如何解释图像中的颜色数据。例如,RGB是一种常见的颜色模型,在RGB颜色模型中,颜色由红(Red)、绿(Green)、蓝(Blue)三个分量组成。不同的颜色模型在表示颜色的方式、颜色空间以及颜色分量的取值范围等方面可能有所不同。*/Bounds() Rectangle/*Bounds方法返回图像的矩形边界:图像的左上角和右下角坐标*/At(x, y int) color.Color/*获取图像在坐标(0, 0)处的颜色信息*/ }//接口中函数的定义: 1.无参无返回值 2.无参有返回值 3.有参无返回值 4.有参有返回值
注意: Bounds
方法的返回值 Rectangle
实际上是一个 image.Rectangle,它在 image
包中声明。
(请参阅文档了解全部信息。)
color.Color
和 color.Model
类型也是接口,但是通常因为直接使用预定义的实现 image.RGBA
和 image.RGBAModel
而被忽视了。这些接口和类型由 image/color 包定义。
/** 作用: */ package mainimport ("fmt""image" )func main() {m := image.NewRGBA(image.Rect(0, 0, 100, 100))fmt.Println(m.Bounds())fmt.Println(m.At(0, 0).RGBA()) }
练习:图像
还记得之前编写的图片生成器 吗?我们再来编写另外一个,不过这次它将会返回一个 image.Image
的实现而非一个数据切片。
定义你自己的 Image
类型,实现必要的方法并调用 pic.ShowImage
。
Bounds
应当返回一个 image.Rectangle
,例如 image.Rect(0, 0, w, h)
。
ColorModel
应当返回 color.RGBAModel
。
At
应当返回一个颜色。上一个图片生成器的值 v
对应于此次的 color.RGBA{v, v, 255, 255}
。
package mainimport ("image""image/color""golang.org/x/tour/pic" )// Image是自定义的结构体类型,用于表示图像 // 它包含width和height两个字段,分别表示图像的宽度和高度 type Image struct {width intheight int }//Image实现image.Image接口:需要实现:Bounds、ColorModel、At //颜色模型 func (i Image) ColorModel() color.Model {return color.RGBAModel }//图像的边界范围:返回图像矩阵的左上角和右下角坐标 func (i Image) Bounds() image.Rectangle {return image.Rect(0, 0, i.width, i.height) }// At方法实现了image.Image接口中的At方法 // 该方法根据传入的坐标x和y计算一个值v,计算方式为(x + y) / 2 // 然后返回一个color.RGBA类型的颜色值,其中红色、绿色通道的值都为v // 蓝色通道的值固定为255,透明度通道的值固定为255 // 这个方法定义了图像在坐标(x, y)处的颜色 func (i Image) At(x, y int) color.Color {v := (x + y) / 2// 将v转换为uint8类型if v > 255 {v = 255}return color.RGBA{uint8(v), uint8(v), 255, 255} }/* RGBA结构体中的R(红色)、G(绿色)、B(蓝色)和A(透明度)通道的值要求是uint8类型,而代码中的v是int类型。Go 语言是强类型语言,不允许直接将int类型的值赋给uint8类型的字段。 */func main() {// 创建一个Image类型的实例m,传入宽度和高度都为200m := Image{200, 200}// 调用pic.ShowImage函数,将m作为参数传入// pic.ShowImage函数可能会根据传入的实现了image.Image接口的实例来显示图像pic.ShowImage(m) }
go实例的创建有哪些?举例:
-
结构体实例创建
-
对于没有字段或者字段有默认零值的结构体,可以直接使用类型名加花括号创建实例。
-
例如:
-
type Person struct{}var p Person// 或者p := Person{}
-
如果结构体有字段,在创建实例时可以初始化字段值。
-
例如:
type Point struct {X intY int}p1 := Point{1, 2}p2 := Point{X: 3, Y: 4}
-
基于内置类型创建实例(如切片、映射等)
-
切片实例创建
-
使用
make
函数创建切片实例,指定长度和容量(容量可选)。 -
例如:
-
// 创建一个长度为5,容量为5的int切片s1 := make([]int, 5)// 创建一个长度为3,容量为10的string切片s2 := make([]string, 3, 10)// 也可以直接使用字面量创建切片实例s3 := []int{1, 2, 3}
-
映射实例创建
-
使用
make
函数创建映射实例。 -
例如:
-
// 创建一个键为string,值为int的映射m1 := make(map[string]int)// 可以直接使用字面量创建并初始化映射实例m2 := map[string]int{"a": 1, "b": 2}
-
通过函数返回值创建实例
-
例如,
os.Open
函数返回一个*os.File
实例和一个错误。 -
示例:
file, err := os.Open("test.txt")if err!= nil {// 处理错误}// 这里的file就是*os.File类型的实例
-
使用
new
关键字创建实例(主要用于创建指针类型的实例)
-
例如对于一个自定义结构体:
type MyStruct struct {Field int}p := new(MyStruct)p.Field = 1
-
这里
new(MyStruct)
创建了一个MyStruct
类型的指针实例,p
是指向MyStruct
类型的指针,可以通过指针访问结构体的字段。
类型参数
可以使用类型参数编写 Go 函数来处理多种类型。 函数的类型参数出现在函数参数之前的方括号之间。
func Index[T comparable](s []T, x T) int
此声明意味着 s
是满足内置约束 comparable
的任何类型 T
的切片。 x
也是相同类型的值。
comparable
是一个有用的约束,它能让我们对任意满足该类型的值使用 ==
和 !=
运算符。在此示例中,我们使用它将值与所有切片元素进行比较,直到找到匹配项。 该 Index
函数适用于任何支持比较的类型。
package main import "fmt" // Index 返回 x 在 s 中的下标,未找到则返回 -1。 func Index[T comparable](s []T, x T) int {for i, v := range s {// v 和 x 的类型为 T,它拥有 comparable 可比较的约束,// 因此我们可以使用 ==。if v == x {return i}}return -1 } func main() {// Index 可以在整数切片上使用si := []int{10, 20, 15, -10}fmt.Println(Index(si, 15)) // Index 也可以在字符串切片上使用ss := []string{"foo", "bar", "baz"}fmt.Println(Index(ss, "hello")) }
简单的总结:
-
类型参数决定了后面使用了类型参数的变量类型,相当于泛型的概念
-
类型参数需要约束关键词修饰(any(可省略)、comparable等)
泛型类型
除了泛型函数之外,Go 还支持泛型类型。 类型可以使用类型参数进行参数化,这对于实现通用数据结构非常有用。
此示例展示了能够保存任意类型值的单链表的简单类型声明。
作为练习,请为此链表的实现添加一些功能。
package main import "fmt" // List 表示一个可以保存任何类型的值的单链表。 type List[T any] struct {next *List[T]val T } func (l *List[T]) add(n T){p:=&List[T]{nil,n}if(l == nil){l = p}else{// 找到链表末尾cur := lfor cur.next!= nil {cur = cur.next}cur.next = p} } func (l *List[T])print(){if(l == nil){fmt.Println("nil")}cur := lfor cur!=nil{//fmt.Print(fmt.Sprintf("%d->", cur.val))fmt.Printf("%d->",cur.val)cur = cur.next}fmt.Print("nil") } func main() {l:=&List[int]{nil,0}l.add(1)l.add(2)l.print() }
Go 协程
Go 程(goroutine)是由 Go 运行时管理的轻量级线程。
go f(x, y, z)
会启动一个新的 Go 协程并执行
f(x, y, z)
f
, x
, y
和 z
的求值发生在当前的 Go 协程中,而 f
的执行发生在新的 Go 协程中。
Go 程在相同的地址空间中运行,因此在访问共享的内存时必须进行同步。sync 包提供了这种能力,不过在 Go 中并不经常用到,因为还有其它的办法(见下一页)。
package main import ("fmt""time" ) func say(s string) {for i := 0; i < 5; i++ {time.Sleep(100 * time.Millisecond)fmt.Println(s)} } func main() {go say("world")say("hello") }
下面这个 注释 了say("hello"),使得主协程提前结束,还未等待go协程执行,就已经结束程序。下面采用了sync包实现等待
package main import ("fmt""sync""time" ) func say(s string, wg *sync.WaitGroup) {defer wg.Done() // 通知WaitGroup这个goroutine的工作已经完成for i := 0; i < 5; i++ {time.Sleep(100 * time.Millisecond)fmt.Println(s)} } func main() {var wg sync.WaitGroupwg.Add(1) // 增加WaitGroup的计数器go say("world", &wg)wg.Wait() // 等待goroutine完成//say("hello",&wg) }
信道
信道是带有类型的管道,你可以通过它用信道操作符 <-
来发送或者接收值。
ch <- v // 将 v 发送至信道 ch。 v := <-ch // 从 ch 接收值并赋予 v。
(“箭头”就是数据流的方向。)
和映射与切片一样,信道在使用前必须创建:
ch := make(chan int)
默认情况下,发送和接收操作在另一端准备好之前都会阻塞。这使得 Go 程可以在没有显式的锁或竞态变量的情况下进行同步。
以下示例对切片中的数进行求和,将任务分配给两个 Go 程。一旦两个 Go 程完成了它们的计算,它就能算出最终的结果。
package main import "fmt" func sum(s []int, c chan int) {sum := 0for _, v := range s {sum += v}c <- sum // 发送 sum 到 c } func main() {s := []int{7, 2, 8, -9, 4, 0} c := make(chan int)go sum(s[:len(s)/2], c)go sum(s[len(s)/2:], c)x, y := <-c, <-c // 从 c 接收 fmt.Println(x, y, x+y) }
package main import ("fmt""sync" ) func sum(s []int, c chan int, wg *sync.WaitGroup) {defer wg.Done() // 表示goroutine完成工作sum := 0for _, v := range s {sum += v}c <- sum // 发送 sum 到 c } func main() {s := []int{7, 2, 8, -9, 4, 0} c := make(chan int, 2) // 创建一个缓冲信道,可以存储两个int类型的值var wg sync.WaitGroup // 增加WaitGroup计数器wg.Add(2) // 启动第一个goroutine计算前半部分的和go func() {sum(s[:len(s)/2], c, &wg)//17}()// // 启动第二个goroutine计算后半部分的和go func() {sum(s[len(s)/2:], c, &wg)//-5}() // 等待两个goroutine都完成wg.Wait() // 从信道中接收两个和x := <-cy := <-cfmt.Println(x, y, x+y)close(c) // 关闭信道 }
因此go协程的执行顺序(到达信道的先后顺序)和信道的输出(发送数据)顺序无关
带缓冲的信道
信道可以是 带缓冲的。将缓冲长度作为第二个参数提供给 make
来初始化一个带缓冲的信道:
ch := make(chan int, 100)
仅当信道的缓冲区填满后,向其发送数据时才会阻塞。当缓冲区为空时,接受方会阻塞。
修改示例填满缓冲区,然后看看会发生什么?下面是修改后的代码,出现的问题:死锁以及死锁的原因
-
原始示例回顾与信道基本原理
-
在 Go 语言中,对于有缓冲的信道:仅当信道的缓冲区填满后,向其发送数据时才会阻塞;当缓冲区为空时,接收方会阻塞。
-
-
第一个死锁示例分析
-
原始代码
-
package mainimport "fmt"func main() {ch := make(chan int, 2)fmt.Println(<-ch)fmt.Println(<-ch)}
-
原因分析
-
这里创建了一个缓冲大小为 2 的信道
ch
,但是没有任何goroutine
向这个信道发送数据。 -
当执行
fmt.Println(<-ch)
时,由于信道为空(没有数据被发送进去),接收操作会阻塞,等待数据到来。同样,第二次的接收操作也会阻塞。整个程序没有goroutine
能够继续推进,从而导致死锁。
-
-
第二个死锁示例分析
-
原始代码
-
package mainimport "fmt"func main() {ch := make(chan int, 2)ch <- 1ch <- 2ch <- 3fmt.Println(<-ch)fmt.Println(<-ch)}
-
原因分析
-
创建了一个缓冲大小为 2 的信道
ch
。 -
首先执行
ch <- 1
和ch <- 2
,这两个发送操作是成功的,因为缓冲区没有填满。 -
当执行
ch <- 3
时,由于缓冲区已经被填满(大小为 2,已经有 2 个数据),这个发送操作会阻塞当前的goroutine
(这里是main
函数所在的goroutine
)。 -
之后程序试图从信道接收数据,但由于发送操作已经阻塞了
main
函数所在的goroutine
,没有其他goroutine
来执行接收操作以腾出缓冲区空间,从而导致整个程序陷入死锁状态。
-
-
第三个死锁示例分析
-
原始代码
-
package mainimport "fmt"func main() {ch := make(chan int, 2)ch <- 1fmt.Println(<-ch)fmt.Println(<-ch)}
-
原因分析
-
创建了一个缓冲大小为 2 的信道
ch
,并且成功向其中发送了 1 个数据(ch <- 1
)。 -
执行
fmt.Println(<-ch)
时,成功从信道中接收并打印出 1。 -
当再次执行
fmt.Println(<-ch)
时,由于信道缓冲区此时为空(之前只发送了 1 个数据并且已经被接收),这个接收操作会阻塞。整个程序没有其他goroutine
来向信道发送数据以解除阻塞,从而导致死锁。
-
-
修改示例填满缓冲区并分析
-
修改后的代码
-
package mainimport "fmt"func main() {ch := make(chan int, 2)ch <- 1ch <- 2// 此时缓冲区已满// 如果再尝试发送数据,例如:ch <- 3,将会阻塞fmt.Println(<-ch)fmt.Println(<-ch)}
-
分析
-
这里创建了一个缓冲大小为 2 的信道
ch
,并成功发送了两个数据 1 和 2,此时缓冲区已满。 -
当执行
fmt.Println(<-ch)
时,会从缓冲区取出最早放入的数据(这里是 1)并打印出来。 -
接着执行
fmt.Println(<-ch)
时,会取出缓冲区中剩下的唯一数据(这里是 2)并打印出来。整个过程中没有出现阻塞或死锁的情况,因为发送操作没有超过缓冲区大小,并且接收操作能够正常获取到已发送的数据。
-
总的来说:这里是因为带缓冲的信道中由于缓冲区大小,导致的发送和接收的阻塞问题,从而导致死锁
range 和 close
发送者可通过 close
关闭一个信道来表示没有需要发送的值了。接收者可以通过为接收表达式分配第二个参数来测试信道是否被关闭:若没有值可以接收且信道已被关闭,那么在执行完
v, ok := <-ch
此时 ok
会被设置为 false
。
循环 for i := range c
会不断从信道接收值,直到它被关闭。
注意: 只应由发送者关闭信道,而不应由接收者关闭。向一个已经关闭的信道发送数据会引发程序 panic。
还要注意: 信道与文件不同,通常情况下无需关闭它们。只有在必须告诉接收者不再有需要发送的值时才有必要关闭,例如终止一个 range
循环。
package mainimport ("fmt" )func fibonacci(n int, c chan int) {x, y := 0, 1 // 0 1for i := 0; i < n; i++ {//循环到第n个斐波那契数c <- x//0//1//1//2//3x, y = y, x+y//1,1//1,2//2,3//3,5}close(c) }func main() {c := make(chan int, 10)go fibonacci(cap(c), c)for i := range c {fmt.Println(i)} }
解释:
-
首先代码作用是:主函数创建一个协程区单独执行函数,将结果数全部发送到信道。在函数执行的同时,使用for range遍历信道,阻塞并等待接收信道的值,当信道中无值可取且信道关闭,则for循环结束
-
其次,要解释的是 “ 信道与文件不同,通常情况下无需关闭它们。只有在必须告诉接收者不再有需要发送的值时才有必要关闭,例如终止一个
range
循环。 ” 这句话:我们从for循环中拿取信道的值,如果信道关闭就会停止遍历、停止阻塞等待。如果这里不关闭信道就会使得for循环一直阻塞等待接收,导致死锁。(但是信道的值全都可以取出)
select 语句
-
基本概念
-
功能
-
select
语句是 Go 语言中用于处理多个信道(channel
)操作的一种机制。它允许一个go
程(goroutine
)在多个通信操作(发送或接收信道数据)之间进行选择,等待其中一个操作就绪并执行相应的操作。(多个分支都准备好时会随机选择一个执行)
-
-
-
阻塞与执行机制
-
阻塞
-
当执行
select
语句时,如果所有的select
分支中的信道操作都没有准备好(例如,对于接收操作,如果信道为空;对于发送操作,如果信道已满),那么select
语句就会阻塞当前的go
程。这个go
程会一直等待,直到至少有一个分支的信道操作可以继续执行。
-
-
执行分支选择
-
一旦有一个或多个分支中的信道操作可以执行(例如,对于接收操作,信道中有数据可供接收;对于发送操作,信道有足够的空间来发送数据),
select
就会随机选择一个准备好的分支来执行。这里的 “随机” 是指在多个就绪的分支中,Go 运行时系统会任意选择一个,而不是按照某种特定的顺序(如定义顺序)进行选择。
-
-
-
示例说明
-
简单示例
-
以下是一个简单的示例:
-
-
package mainimport ("fmt""time")func main() {ch1 := make(chan int)ch2 := make(chan string)go func() {time.Sleep(2 * time.Second)ch1 <- 1}()go func() {time.Sleep(1 * time.Second)ch2 <- "hello"}()select {case num := <-ch1:fmt.Println("Received from ch1:", num)case str := <-ch2:fmt.Println("Received from ch2:", str)}}
-
在这个示例中,创建了两个信道
ch1
和ch2
,并分别启动了两个go
程向这两个信道发送数据。由于go
程的启动和执行顺序以及time.Sleep
的时间设置,ch2
中的数据会先准备好(ch2
在 1 秒后就有数据可接收,而ch1
要 2 秒后才有数据可接收)。 -
当执行到
select
语句时,它会阻塞直到ch1
或ch2
中有一个信道可以进行接收操作。因为ch2
先准备好,所以select
语句会随机选择ch2
这个分支(在这个例子中实际上只有ch2
这个分支准备好,所以必然会选择它),然后执行fmt.Println("Received from ch2:", str)
,打印出Received from ch2: hello
。
-
没有默认分支时的死锁情况
-
如果
select
语句中的所有信道操作都一直没有准备好,并且没有default
分支,那么程序就会陷入死锁状态。例如:
-
package mainimport "fmt"func main() {ch := make(chan int)select {case <-ch:fmt.Println("Received from ch")}}
-
在这个例子中,创建了一个信道
ch
,但是没有向这个信道发送数据的go
程。当执行到select
语句时,由于ch
为空,接收操作无法进行,并且没有default
分支,所以程序就会出现死锁。
-
默认(
default
)分支的作用-
避免死锁(从而实现非阻塞的操作)
-
如果
select
语句包含default
分支,当所有其他非default
分支的信道操作都没有准备好时,select
语句会直接执行default
分支。这可以避免程序因为信道操作一直无法进行而陷入死锁。例如:
-
-
package mainimport "fmt"func main() {ch := make(chan int)select {case <-ch:fmt.Println("Received from ch")default:fmt.Println("No data in channel, executing default")}}
-
在这个例子中,由于信道
ch
为空,接收操作无法进行,但因为有default
分支,所以程序会执行default
分支,打印出No data in channel, executing default
。 -
实现非阻塞操作
-
default
分支还可以用于实现非阻塞的信道操作。如果只想尝试进行信道操作,如果操作不能立即执行就直接继续执行其他逻辑,就可以使用带default
分支的select
语句。例如:
-
package mainimport "fmt"func main() {ch := make(chan int)// 尝试发送数据到ch,如果ch已满则不阻塞直接执行default分支select {case ch <- 1:fmt.Println("Sent data to ch")default:fmt.Println("Channel is full, not sending data")}}
-
在这个例子中,如果信道
ch
有足够的空间来接收数据,那么会执行case ch <- 1:
分支,发送数据并打印Sent data to ch
;如果ch
已满,发送操作无法进行,就会执行default
分支,打印Channel is full, not sending data
。
练习:等价二叉查找树
不同二叉树的叶节点上可以保存相同的值序列。例如,以下两个二叉树都保存了序列 1,1,2,3,5,8,13
。
在大多数语言中,检查两个二叉树是否保存了相同序列的函数都相当复杂。 我们将使用 Go 的并发和信道来编写一个简单的解法。
本例使用了 tree
包,它定义了类型:
type Tree struct {Left *TreeValue intRight *Tree }
下面是整个tree的构建和判断:
package mainimport ("fmt" )type TreeNode struct {Left *TreeNodeRight *TreeNodeValue int }// New函数用于创建一个新的二叉查找树,保存值k, 2k, 3k,..., 10k func New(k int) *TreeNode {// 这里简单实现创建二叉查找树的逻辑root := &TreeNode{Value: k}for i := 2; i <= 10; i++ {node := &TreeNode{Value: i * k}insert(root, node)}return root }// insert函数用于将节点插入到二叉查找树中 func insert(root, node *TreeNode) {if node.Value < root.Value {if root.Left == nil {root.Left = node} else {insert(root.Left, node)}} else {if root.Right == nil {root.Right = node} else {insert(root.Right, node)}} }// Walk函数使用中序遍历二叉树,并将节点的值发送到信道ch中 func Walk(t *TreeNode, ch chan int) {var walk func(*TreeNode)walk = func(n *TreeNode) {if n == nil {return}walk(n.Left)ch <- n.Valuewalk(n.Right)}walk(t)close(ch) }func Same(t1, t2 *TreeNode) bool {ch1 := make(chan int)ch2 := make(chan int)go Walk(t1, ch1)go Walk(t2, ch2)for {v1, ok1 := <-ch1v2, ok2 := <-ch2if ok1!= ok2 || v1!= v2 {return false}if!ok1 {break}}return true }func main() {// 测试Walk函数ch := make(chan int)//创建信道chgo Walk(New(2), ch)//开启一个go程遍历(根节点为1的)树for i := 1; i <= 10; i++ {fmt.Println(<-ch)//从信道中得到树的遍历结果}// 测试Same函数t1 := New(1)t2 := New(2)fmt.Println(Same(t1, t2)) }
判断函数:
// Walk函数使用中序遍历二叉树,并将节点的值发送到信道ch中 func Walk(t *TreeNode, ch chan int) {var walk func(*TreeNode)walk = func(n *TreeNode) {if n == nil {return}walk(n.Left)ch <- n.Valuewalk(n.Right)}walk(t)close(ch) }func Same(t1, t2 *TreeNode) bool {ch1 := make(chan int)ch2 := make(chan int)go Walk(t1, ch1)go Walk(t2, ch2)for {v1, ok1 := <-ch1v2, ok2 := <-ch2if ok1!= ok2 || v1!= v2 {return false}if!ok1 {break}}return true }
sync.Mutex
我们已经看到信道非常适合在各个 Go 程间进行通信。
但是如果我们并不需要通信呢?比如说,若我们只是想保证每次只有一个 Go 程能够访问一个共享的变量,从而避免冲突?
这里涉及的概念叫做 互斥(mutualexclusion)* ,我们通常使用 互斥锁(Mutex) 这一数据结构来提供这种机制。
Go 标准库中提供了 sync.Mutex 互斥锁类型及其两个方法:
-
Lock
-
Unlock
我们可以通过在代码前调用 Lock
方法,在代码后调用 Unlock
方法来保证一段代码的互斥执行。参见 Inc
方法。
我们也可以用 defer
语句来保证互斥锁一定会被解锁。参见 Value
方法。
package mainimport ("fmt""sync""time" )// SafeCounter 是并发安全的 type SafeCounter struct {mu sync.Mutexv map[string]int }// Inc 对给定键的计数加一 func (c *SafeCounter) Inc(key string) {c.mu.Lock()// 锁定使得一次只有一个 Go 协程可以访问映射 c.v。c.v[key]++c.mu.Unlock() }// Value 返回给定键的计数的当前值。 func (c *SafeCounter) Value(key string) int {c.mu.Lock()// 锁定使得一次只有一个 Go 协程可以访问映射 c.v。defer c.mu.Unlock()//推迟 执行解锁:即使中间正常返回或者异常返回都会执行return c.v[key] }func main() {c := SafeCounter{v: make(map[string]int)}for i := 0; i < 1000; i++ {go c.Inc("somekey")}/** 由于goroutine的调度是由 Go 运行时系统决定的,我们无法确定所有的goroutine何时完成操作,所以通过休眠一段时间来确保在打印结果之前,大多数(如果不是全部)goroutine已经完成了对计数器的修改。 */time.Sleep(time.Second)//为了给所有的goroutine足够的时间来完成对计数器的操作fmt.Println(c.Value("somekey"))//最后输出map中somekey的值为1000:说明互斥锁起作用了 }这里互斥锁作用了: 修改数据 和 读取数据
练习:Web 爬虫
在这个练习中,我们将会使用 Go 的并发特性来并行化一个 Web 爬虫。
修改 Crawl
函数来并行地抓取 URL,并且保证不重复。
提示: 你可以用一个 map 来缓存已经获取的 URL,但是要注意 map 本身并不是并发安全的!
package mainimport ("fmt" )type Fetcher interface {// Fetch 返回 URL 所指向页面的 body 内容,// 并将该页面上找到的所有 URL 放到一个切片中。Fetch(url string) (body string, urls []string, err error) }// Crawl 用 fetcher 从某个 URL 开始递归的爬取页面,直到达到最大深度。 func Crawl(url string, depth int, fetcher Fetcher) {// TODO: 并行地爬取 URL。// TODO: 不重复爬取页面。// 下面并没有实现上面两种情况:if depth <= 0 {return}body, urls, err := fetcher.Fetch(url)if err != nil {fmt.Println(err)return}fmt.Printf("found: %s %q\n", url, body)for _, u := range urls {Crawl(u, depth-1, fetcher)}return }func main() {Crawl("https://golang.org/", 4, fetcher) }// fakeFetcher 是待填充结果的 Fetcher。 type fakeFetcher map[string]*fakeResulttype fakeResult struct {body stringurls []string }func (f fakeFetcher) Fetch(url string) (string, []string, error) {if res, ok := f[url]; ok {return res.body, res.urls, nil}return "", nil, fmt.Errorf("not found: %s", url) }// fetcher 是填充后的 fakeFetcher。 var fetcher = fakeFetcher{"https://golang.org/": &fakeResult{"The Go Programming Language",[]string{"https://golang.org/pkg/","https://golang.org/cmd/",},},"https://golang.org/pkg/": &fakeResult{"Packages",[]string{"https://golang.org/","https://golang.org/cmd/","https://golang.org/pkg/fmt/","https://golang.org/pkg/os/",},},"https://golang.org/pkg/fmt/": &fakeResult{"Package fmt",[]string{"https://golang.org/","https://golang.org/pkg/",},},"https://golang.org/pkg/os/": &fakeResult{"Package os",[]string{"https://golang.org/","https://golang.org/pkg/",},}, }
修改后:
package mainimport ("fmt""sync")type Fetcher interface {// Fetch返回URL所指向页面的body内容,// 并将该页面上找到的所有URL放到一个切片中。Fetch(url string) (body string, urls []string, err error)}func Crawl(url string, depth int, fetcher Fetcher, visited map[string]bool, mu *sync.Mutex, wg *sync.WaitGroup) {// 如果已经访问过该URL,则直接返回mu.Lock()if visited[url] {mu.Unlock()return}visited[url] = truemu.Unlock()// 如果深度小于等于0,直接返回if depth <= 0 {wg.Done()return}body, urls, err := fetcher.Fetch(url)if err!= nil {fmt.Println(err)wg.Done()return}fmt.Printf("found: %s %q\n", url, body)var innerWg sync.WaitGroupfor _, u := range urls {innerWg.Add(1)go func(u string) {defer innerWg.Done()Crawl(u, depth - 1, fetcher, visited, mu, wg)}(u)}innerWg.Wait()}func main() {var mu sync.Mutexvar wg sync.WaitGroupvisited := make(map[string]bool)wg.Add(1)Crawl("https://golang.org/", 4, fetcher, visited, &mu, &wg)wg.Wait()}// fakeFetcher是待填充结果的Fetcher。type fakeFetcher map[string]*fakeResulttype fakeResult struct {body stringurls []string}func (f fakeFetcher) Fetch(url string) (string, []string, error) {if res, ok := f[url]; ok {return res.body, res.urls, nil}return "", nil, fmt.Errorf("not found: %s", url)}// fetcher是填充后的fakeFetcher。var fetcher = fakeFetcher{"https://golang.org/": &fakeResult{"The Go Programming Language",[]string{"https://golang.org/pkg/","https://golang.org/cmd/",},},"https://golang.org/pkg/": &fakeResult{"Packages",[]string{"https://golang.org/","https://golang.org/cmd/","https://golang.org/pkg/fmt/","https://golang.org/pkg/os/",},},"https://golang.org/pkg/fmt/": &fakeResult{"Package fmt",[]string{"https://golang.org/","https://golang.org/pkg/",},},"https://golang.org/pkg/os/": &fakeResult{"Package os",[]string{"https://golang.org/","https://golang.org/pkg/",},},}
代码改进点解释
-
避免重复爬取
-
新增了一个
visited
的map[string]bool
来记录已经爬取过的URL
。在Crawl
函数中,每次检查要爬取的URL
是否已经在visited
中,如果是则直接返回。这里使用了互斥锁mu
来确保对visited
的并发访问安全,因为可能有多个goroutine
同时尝试访问和修改visited
。
-
-
并行爬取
-
在
Crawl
函数中,对于每个从当前页面获取到的URL
,使用goroutine
来并发地进行下一层的爬取。创建了一个内部的sync.WaitGroup
(innerWg
)来确保所有子goroutine
完成对下一层URL
的爬取。每个goroutine
调用Crawl
函数时传递相同的visited
、mu
和外部的wg
(sync.WaitGroup
)。在main
函数中,创建了sync.Mutex
(mu
)和sync.WaitGroup
(wg
),并将wg
的计数器初始化为 1,然后调用Crawl
函数开始爬取,最后通过wg.Wait()
等待所有的爬取操作完成。
-