Go 笔记

《Go程序设计语言》阅读笔记

Posted on 2024-08-15,55 min read

《Go程序设计语言》阅读笔记

第1章 入门

给几个例子快速讲解go语言的结构例子,如果是对go比较熟悉的读者或许会比较方便。但我是从零开始,对我而言跳跃得有些太快了,看到1.4就跟不上了。不过也没关系,从第二章开始看更详细更系统化。

第2章 程序结构

2.1 名称

给出Go语言中的关键字预声明的常量、类型和函数,有需要可以查表。

  • 命名规则

一般使用驼峰式,首字母缩写使用相同大小写。

2.2 声明

没啥内容,重要的都放在后续章节详细展开。

2.3 变量

变量声明通用形式:var name = expression

类型和表达式可以省略其一。如果省略类型,将由表达式自动推断;如果省略表达式,初始值为对应类型的零值。

2.3.1 短变量声明

name := expresion

  • 短小灵活,常用于局部变量的声明和初始化
  • 不需要声明所有变量,对已声明变量相当于赋值
  • 最少声明一个新变量

2.3.2 指针

基本用法和C一致,使用&取地址,*解引用。

简单介绍了flag包的用法来辅助说明指针。

2.3.3 new 函数

表达式new(T)将创建一个未命名的T类型变量,初始化为该类型的零值,并返回其地址。简单来说,就是方便快捷地获取某一类型的地址,而不必先声明再取地址。

new是一个预声明的函数,不是关键字,可以被重定义。

2.3.4 变量的生命周期

生命周期的概念很多编程语言都有,不用再看一遍。

这部分还提到了垃圾回收和变量逃逸的概念,不过也是浅尝辄止,不看也行。

2.4 赋值

Go和C++类似有着形如+=这样的运算赋值和++,--这样的自增自减运算。

2.4.1 多重赋值

和Python类似,Go也可以同时为复数变量赋值,并可以将不需要的值赋给空标识符_

2.4.2 可赋值性

没看懂在讲什么,但是不重要。

2.5 类型声明

type name underlying-type

和C++的typedef类似,Go也可以通过type关键字为某一类型设置一个别名,方便阅读理解和使用。但不同的是,Go使用type定义的命名类型,即使底层类型相同也不能使用算术表达式进行比较和合并,主要是为了避免混用

这部分还提了下类型声明在接口中的使用方法,这将在第七章详细说明。

2.6 包和文件

  • 可见性

  • 声明在函数内部,是函数的本地值,类似private

  • 声明在函数外部,是对当前包可见(包内所有.go文件都可见)的全局值,类似protect

  • 声明在函数外部且首字母大写是所有包可见的全局值,类似public

2.7 作用域

大部分内容都不难理解。只是有一个地方需要注意一下:短变量声明依赖一个明确的作用域。错误示例:

var x int = 0
if true {
    x, y := 1, 2
    fmt.Println(x, y)
}
x += x
fmt.Println(x)
//1,2
//0

在如上代码中,x,y在内部作用域都为声明,短变量声明:=将会将x,y都声明为内部的局部变量,使外部的x声明不可见,从而未能成功变更外部x的值。这是一个很隐蔽的错误,甚至不会报错。

要避免这个错误,可以放弃使用短变量声明而是先声明再直接赋值:

var x int = 0
if true {
    var y int
    x, y = 1, 2
    fmt.Println(x, y)
}
x += x
fmt.Println(x)
//1,2
//2

第3章 基本数据

Go四类数据类型:基础类型(basic type),聚合类型(aggregate type),引用类型(reference type),接口类型(interface type)。

3.1 整数

数据类型和C中的stdint.h类似,对不同位数的int进行了精确划分,这里记几个比较特殊的。

  • runeint32同意,指明一个值是Unicode码点(code point)。
  • byteuint8同意,强调一个值是原始数据而非量值。
  • uintptr大小不定,完整存放指针,用于底层编程。

这部分还介绍了算数运算符,逻辑运算符和位运算符以及他们之间的优先级,有需要可以查表。

位运算符和C稍有不同,记一下。

  • ^作为二元运算符表示按位“异或”(XOR),如果需要写次方可以用math.Pow()。作为一元运算符表示取反。
  • &^表示按位清除(AND NOT),如表达式z=x&^y中,若y的某位是1,则z的对应位等于0;否则,它就等于x的对应位。

格式化输出八进制%o,十六进制%x时,前面的副词#告知是否输出相应前缀00x

3.2 浮点数

格式化输出浮点数时,%g会自动保持足够的精度,%e(有指数)%f(无指数)能自定义输出宽度和数值精度。

3.3 复数

GO居然还有复数类型,但感觉我用不上就没怎么看,应该科学计算或者图形学会用的多些。

3.4 布尔值

唯一需要注意的是Go中整数不能像C那样直接当布尔值用需要显性转换。

3.5 字符串

和python类似,没啥新东西。

3.5.1 字符串字面量

介绍了一些转义符,可以查表。

  • 字符串字面量使用双引号,可以转义。

  • 原生的字符串字面量使用反引号,不能转义,可以展开多行。

3.5.2 Unicode

介绍Unicode来源和rune

3.5.3 UTF-8

略,有需要再翻书。

3.5.4 字符串和字节 slice

比较了字符串和slice,因为不熟悉slice没看出什么重点,有空回头补。

3.5.5 字符串和数字的相互转换

略,有需要再翻书。该加速了,少扣点细节。

3.6 常量

同时声明一组常量时,可以省略第一项以外的表达式,这时会复用前一项的表达式及类型。

3.6.1 常量生成器 iota

iota从0开始取值逐项加1。复数声明常量时由于复用表达式可以达到不同初始值的效果。具体实例见书。

3.6.2 无类型常量

略,有需要再翻书。

第4章 复合数据类型

4.1 数组

大部分都是基础知识,有一些比较新奇的定义方法:

  • 使用...代替数组长度,数组长度将有初始化时的元素个数决定,如arr := [...]int{1,2,3}长度为3。
  • 初始化时可以使用pos:value仅定义部分元素,未定义的元素默认为零值。如arr := [...]int{4:1},即为{0,0,0,0,1}。

在如C等编程语言中,数组都是隐式的引用传递,而在Go中,直接传递数组当参将会和其他类型一样使用值传递,创建一个副本。此外,数组的大小也是其类型的一部分,类型 [10]int 和 [20]int 是不同的。

4.2 slice

slice的大部分操作都和数组类似,主要是长度不定,概念和用法更接近c的vector和python的list。slice的零值是nil。slice也可以通过make([]T, len, cap)来声明,其中容量可省略。

slice和数组的区别首先是定义式slice不需要指定长度。然后是数组可以直接比较而slice不可以,需要手写比较函数。

4.2.1 append 函数

在讲什么没找到重点。可能是想让读者注意一下len和cap吧,但这个基本上都是隐性的用的时候真不用想真么多。

4.2.2 slice 就地修改

介绍一些slice的精细操作,还是感觉没大用。

4.3 map

记一下定义方式:ages := make(map[string]int)

可以使用{key:value,...}初始化,以及delete(key,value)删除元素。

map和slice都是引用,不能直接获取地址,可以用range遍历,将返回键值对。

map中元素迭代顺序不固定,一般认为是随机的,可以是程序在不同散列算法实现下变得健壮。如需按顺序遍历,必须显式地给键排序。

map类型的零值是nil,大多数map操作都可以在零值nil上执行,但设置元素会导致错误,必须先初始化map。

可以通过if value, ok := m[key]; !ok来判断这个元素是不存在还是零值。

map需求特殊数据类型作为键(如slice),可以定义一个帮助函数中转,将不可比较的数据类型映射到字符串。示例如下:

var m make(map[string]int)
func k(list []string) string { return fmt.Sprintf("%q", list) }
func Add(list []string) { m[k(list)]++ }
func Count(list []string) { return m[k(list)] }

4.4 结构体

定义:

type struct1 struct{
    field1 type1
    field2 type2
    struct2 // 嵌入结构体,继承所有字段 Embedded
    struct3 struct3 // 嵌套结构体,通过.访问子结构体字段 Nested
}

结构体定义时不能定义一个相同结构体类型的成员变量(对其他聚合类型也适用),但可以定义一个指向该类型的指针,从而实现递归数据结构,比如链表和树。

结构体零值由成员零值组成。

没有成员变量的结构体为空结构体,写作struct{}。这里没有过多介绍空结构体,作者似乎也不提倡这么用。

4.4.1 结构体字面量

可以通过结构体字面量,即设置结构体成员变量来设置结构体的值。有两种格式,两种方法不能混用:

  • 按顺序为每个成员变量赋值
    可读性差,仅用于顺序明显的小结构体
  • 指定成员变量名称和值
    不需要按顺序,可以仅赋值部分成员

关于结构体指针的使用和c类似,不再赘述。

4.4.2 结构体比较

4.4.3 结构体嵌套和匿名成员

关于这部分内容我最早还是再gorm里看到的。那边把两种情况分开,分别叫嵌套(Nested)和嵌入(Embedded),这边都叫嵌套,然后匿名作为一种特殊用法。

type Point struct {X,Y int}
type Circle struct {Point; Radius int}
type Wheel struct {Circle; Spokes int}

这时就可以直接访问需要变量而省略中间变量,即匿名成员:

var w Whell
w.x = 1 // 两种用法等价
W.Circle.Point.y = 1

但是使用结构体字面量初始化并不能省略,需要遵循定义:

var w1, w2 Wheel
w1 = Wheel{Circle{Point{1,2},3},4}
w2 = Wheel{
    Circle : Circle{
        Point : Point{
            X : 1,
            y : 2,
        },
        Radius : 3,
    },
    Spoke : 4,
}

4.5 JSON

json格式略。

Go数据结构转换为JSON:

json.marshal(v any)
返回一个不带任何多余空白字符的很长的字符串。
json.marshalIndent(v any,prefix string,indent string)
输出格式化结果,prefix为前缀,indent为缩进。

只有可导出的成员才能转换为json。
可以使用 成员标签定义 更改转换为json后的字段名,如json:fieldname

JSON解析为Go数据结构

json.unmarshal(data []byte, v any)
jsong字段关联到结构体成员时忽略大小写,但存在下划线的话还是要用标签定义。

流式解码器json.Decoder,使用示例:

terms := []string{"..."}
q := url.QueryEscape(string.Join(terms, " "))
resp, err := http.Get(URL + "?q=" + q)
defer resp.Body.Close()
json.NewDecoder(resp.body).decode(&result)

4.6 文本和HTML模板

模板是一个字符串或者文件,它包含一个或者多个两边用双大括号包围的单元
{{...}},这称为操作

操作提供的功能:

  • 输出值
  • 选择结构体成员
  • 调用函数和方法
  • 描述控制逻辑
  • 实例化其他模板

. 表示模板里的参数
range var end 表示循环,此时的.表示var里的元素
| 将上一个操作的结果作为下一个操作的输入,和管道类似。

通过模板输出

template.New 创建并返回一个新的模板
Func 添加函数到模板内部可以访问的函数列表
Parse 解析文本模板
temlate.Must 错误处理,详见5.9

temp1, err := template.New("name").Funcs(template.FuncMap{"func1": func1}).Parse(text)
temp1 = temlate.Must(temp1)

html/template包使用和text/template包里面一样的API和表达式语
句,并且额外地对出现在HTML、JavaScript、CSS和URL中的字符串进行自动转义。

第5章 函数

5.1 函数声明

func name(parameter-list)(result-list){
    body
}

函数的基本内容略。

如果几个形参或返回值类型相同则可以简写:a int, b int->a, b int

go函数支持复数返回值,并允许在返回值列表中显性声明变量,此时return的内容可省略,即裸返回。
func func1()(res int){ ... ; return }

函数的类型称为 函数签名

有些函数声明没有函数体,说明这个函数使用了Go以外的语言实现。

5.2 递归

5.3 多返回值

如果一个函数返回一组值,想要使用这些返回值就必须显式地将其赋给变量。
如果想要忽视其中一部分可以赋给空标识符_
一个多值调用可以当作多个参数进行传参。

5.4 错误

与许多其他语言不同,Go语言通过使用普通的值而非异常来报告错误。尽管Go语言有异常机制,这将在5.9节进行介绍,但是Go语言的异常只是针对程序bug导致的预料外的错误,而不能作为常规的错误处理方法出现在程序中。
这样做的原因是异常会陷人带有错误消息的控制流去处理它,通常会导致预期外的结果:错误会以难以理解的栈跟踪信息报告给最终用户,这些信息大都是关于程序结构方面的而不是简单明了的错误消息。

5.4.1 错误处理策略

  1. 传递错误,使子例程的错误变为主调例程错误。
    使用fmt.Errorf()格式化错误信息并返回新的错误值,为原始错误信息添加额外上下文信息来建立可读的错误描述。
  2. 对操作进行重试,超出次数或时间后再报错退出。
  3. 输出错误并停止程序。
    fmt.Fprintf(os.Stderr, "...%v...\n", err)
    log.Fatalf("...%v\n", err)
  4. 只记下错误信息然后程序继续运行。

5.4.2 文件结束标识

io.EOF定义:

package io
import "errors"

// 当没有更多输入时返回EOF
var EOF =errors.New("EOF")

检测示例:

in := bufio.NewReader(os.Stdin)
for {
    r, _, err := in.ReadRune()
    if err == io.EOF {
        break //结束读取
    }
    if err != nil{
        return fmt.Errorf("read failed: %v", err)
    }
}
// ...使用r...

5.5 函数变量

函数变量的零值为nil(空值),不能调用空的函数变量。

函数变量可以和空值比较的,但它们本身不可比较。

5.6 匿名函数

命名函数只能在包级别作用域声明。但使用 函数字面量 能在任何表达式内指定函数变量。函数字面量就像函数声明,但在func关键字后面没有函数的名称。它是一个表达式,它的值称作匿名函数

更重要的是,以这种方式定义的函数能够获取到整个词法环境,因此里层的函数可以使用外层函数中的变量。

警告:捕获迭代变量

func main() {
    var printNums []func()
    nums := []int{1,2,3,4,5,6,7,8,9,10,11}
    for _, num := range nums {
        // num := num // 解决方法
        printNums = append(printNums, func() {
            fmt.Printf("%d ", num) // NOTE: incorrect!
        })
    }
    for _, f := range printNums {
        f()
    }
    fmt.Println()
}
// $ go run main.go
// 11 11 11 11 11 11 11 11 11 11 11

在Go语言中,迭代变量捕获通常指的是在使用循环(如for循环)与匿名函数或闭包结合时,循环的迭代变量被闭包捕获的现象。这种情况下,如果闭包在其外部函数返回后仍然存活,并且在之后的某个时刻被调用,那么它访问的是迭代变量的最终值,而不是特定迭代中的值。

解决方法:用局部变量拷贝循环变量的值

简单说就是闭包中存的迭代变量使用的是引用地址,而不是本身的值,所以迭代完成后存的是同一个地址。拷贝变量就为每次迭代分离出一个独立的地址。因为Go中为了方便,值和指针基本是混用的区别很少,所以可能不是很好理解。我也不确定我的理解对不对。

5.7 变长函数

在参数列表最后的类型名称前使用省略号...声明变长函数,可以传递任意数目该类型的参数。
当实参存在slice中时,在和后面放一个省略号来调用变长函数。以下两种调用等价:

func sum(vals ...int) int { }
values := []int{1,2,3,4}
sum(1,2,3,4)
sum(values...)

5.8 延迟函数调用

语法上,一个defer语句就是一个普通的函数或方法调用,在调用之前加上关键字defer。函数和参数表达式会在语句执行时求值,实际的调用推迟到包含defer语句的函数结束后才执行。defer语句没有限制使用次数;执行的时候以调用defer语句顺序的倒序进行。

5.9 宕机

宕机发生时,程序执行终止,goroutine所有延迟函数执行,程序异常退出并留下日志信息,包括宕机的值(错误消息),函数调用的栈跟踪消息,用于诊断问题原因。

5.10 恢复

8910这三部分其实都没看懂,勉强抄点书。

如果内置的recover函数在延迟函数的内部调用,而且这个包含defer 语句的函数发生宕机,recover会终止当前的宕机状态并且返回宕机的值。函数不会从之前宕机的地方继续运行而是正常返回。如果recover在其他任何情况下运行则它没有任何效果且返回nil。

func doSomethingThatPanic() { panic("something went wrong") }

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Recovered in main: %v", r)
        }
    }()

    doSomethingThatPanic()
    fmt.Println("This line will not be reached.")
}

第6章 方法

6.1 方法声明

方法声明和函数类似,但在前面多了一个参数,将方法显式地绑定在对应类型上。

Go语言中,接收者不使用如thisself的特殊名,而是自己定义。

Go和其他面向对象语言不同,可以把方法绑定到任何类型上,可以方便地为简单类型定义附加行为。

6.2 指针接收者的方法

由于主调函数会复制每一个实参变量,如果函数需要更新一个变量,或者如果一个实参太大而我们希望避免复制整个实参,因此我们必须使用指针来传递变量的地址。这也同样适用于更新接收者:我们将它绑定到指针类型,比如*Point。
习惯上遵循如果Point的任何一个方法使用指针接收者,那么所有的Point方法都应该使用指针接收者,即使有些方法并不一定需要。

关于实参接收者和形参接收者:

  • 同一类型,比如都是T或*T,没问题
  • 实参T形参*T,编译器会隐式取地址
  • 实参*T形参T,编译器会隐式解引用

总之就是在调用方法时不用太在意类型,编译器基本都会隐式转换为合适的类型。这样做简洁明了,避开了指针这一个常规重难点,但有时也会引起混乱,分不清楚当前类型。

nil是一个合法的接收者

就像一些函数允许nil指针作为实参,方法的接收者也一样,尤其是当nil是类型中有意义的零值(如map和 slice 类型)时,更是如此,例如用list *List = nil表示空链表。当定义允许nil作为接收者时应该在注释中显式标明。

6.3 通过接口提内嵌组成类型

和前面匿名成员提到的内容差不多,不再赘述。

6.4 方法变量与表达式

可以把方法赋给一个方法变量,它是一个函数,把方法绑定在一个接收者上,此时函数只需要提供实参不需要提供接收者。如func1 := t.func; func1(a,b,c)

类似的还有方法表达式,必须提供接受者并按照选择子语法调用,把原来的接收者换成函数的第一个形参,可以像函数一样调用。如func2 := T.func; func2(t,a,b,c)

6.5 示例:位向量

没啥内容

6.6 封装

Go语言只有一种方式控制命名的可见性:定义的时候,首字母大写的标识符是可以从包中导出的,而首字母没有大写的则不导出。同样的机制也同样作用于结构体内的字段和类型中的方法。结论就是,要封装一个对象,必须使用结构体。
另一个结论就是在Go语言中封装的单元是包而不是类型。无论是在函数内的代码还是方法内的代码,结构体类型内的字段对于同一个包中的所有代码都是可见的。

封装的优点:

  • 使用方不能直接修改对象的变量,不需要更多语句检查变量的值。
  • 隐藏实现细节防止属性改变,设计者可以更灵活地改变API实现而不破坏兼容性。
  • 防止使用者肆意更改对象内变量。

第7章 接口

7.1 接口即约定

接口是一种抽象类型,不暴露所含数据和内部结构,只提供一些方法。简单来说相当于提供一个黑盒,不用关心内部实现只要知道能干什么就行。其实我们学编程大部分适合也是学着使用一个个黑盒而已,只不过我们不止用还负责造罢了。

7.2 接口类型

定义所含方法,嵌入其他接口,或者两者混用。这有必要单独开一节讲吗。。。

7.3 实现接口

如果一个类型实现了接口要求的所有方法,那么这个类型实现了这个接口。这时我们常说某类型是一个(is a)特定的接口类型。

给接口赋值时,只有当表达式实现了接口时才能赋值给该接口。

接口封装了对应的类型和数据,只有通过接口暴露的方法才可以调用,类型的其他方法无法通过接口调用。

interface{}被称为空接口类型。它对其实现类型没有任何要求,因此可以把任何值赋给空接口类型。

之后介绍了一下定义接口的方法,例如从类型出发,提取共性等等。

7.4 使用 flag.Value 来解析参数

没啥内容,就一示例。

7.5 接口值

接口值分为两部分:一个具体类型和该类型的一个值,分别称为接口的动态类型和动态值。

讲什么编译时动态分发,没看懂。不过不影响用接口。

一个接口值可以指向多个任意大的动态值。 这段也没看懂。

  • 接口值的比较
    接口值可以用==和!=做比较。
    两个接口相等要求都是nil或动态类型和动态之都一致。
    因为接口值可以比较,所以可以作为map的键或则和switch的操作数。
    此外,接口值动态类型一致但动态值不可比较也很常见。
    相比其他类型的可比性是确定的,接口类型的比较必须是否小心确认其可比性。

  • 获取接口值动态类型
    可以使用fmt包的%T实现。

var w io.Writer
fmt.Printf("%T\n",w)//"<nil>"
w= os.Stdout
fmt.Printf("%T\n",w)//"*os.File"
w= new(bytes.Buffer)
fmt.Printf("%T\n",w)//"*bytes.Buffer"

在内部实现中,fmt用反射获取接口的动态类型,这将在12章详细讨论。

  • 注意:含有空指针的非空接口
    假设由如下代码
var buf *bytes.Buffer
f(buf)

func f(out io.Writer){
    if(out!=nil){
        out.Write([]byte("done!\n"))
    }
}

当函数运行到out.Write时会报错,因为out的值为空,即方法的接收者为空。前面我们说过,接口值包括动态类型值和动态值。在把形参的空指针buf赋给实参out时,out的动态类型由nil变为了*bytes.Buffer。而只有动态类型和动态值都为nil的接口值才为空,所以out通过了非空检查。

正确做法是一开始就把buf类型定义为io.Writer,避免把功能不完整的值(仅有类型)赋给接口。

7.6 使用 sort.Interface 来排序

一个接口的使用示例

7.7 http.Handler 接口

接口的使用示例,介绍一个特殊的接口http.Handler,但我没看懂特殊在哪,而且对http相关也不熟悉,到底什么课会教啊,怎么感觉不管看什么都默认对它很熟悉的样子。。。

7.8 error 接口

这里还找到个错,书上“包含”写成“不含”了。投稿会不会给我钱(想peech)

后面好像全是示例,先跳一下吧。

7.9 示例:表达式求值器

7.10 类型断言

一文掌握 Golang 中的类型断言
书上原文讲的有点散,换了篇更通俗的文自己看。有时候学东西没必要死磕,看看别人怎么讲的对照一下说不定更好理解。

Golang 中的接口是一种抽象类型,可以存储任何实现了该接口方法的类型实例。然而,由于接口本身不包含类型信息,需要通过类型断言来将接口变量转换为实际类型。基本语法如下:
value, ok := x.(T)
x 是一个接口类型的变量,T 是希望断言的类型。value 将会是 x 转换为类型 T 后的值,ok 是一个布尔值,当类型断言成功时为 true,失败时为 false 。也可以不带ok,但如果断言失败会引发 panic。

x 必须是接口类型,非接口类型的 x 不能做类型断言。
T如果是非接口类型,检查x的动态类型是否为T,即 T 应该实现 x 的接口。
T 如果是接口,则 x 的动态类型也应该实现接口 T。
如果操作数是空接口值,则类型断言失败。

7.11 使用类型断言来识别错误

MARK
没怎么看懂,好像大概意思就是通过断言转换成特定类型,含有更多的信息。
再给给个例子说明,并提示通过fmt.Errorf等合并错误消息可能丢失结构信息,最好在失败操作发生时马上处理错误,而不是返回给调用者后。

7.12 通过接口类型断言来查询特性

咕,看不懂。接口这个东西放到实战怎么能衍生出这么多复杂东西。我还以为有个方法就算接口了,结果用起来千奇百怪,也不知道是go的特性还是其他面向对象语言都这样。

7.13 类型分支

MARK
关于联合,可识别联合,类型多态,特设多态等一大堆专有名词没看懂。

简单来说,x.(type)可以返回类型,配合switch和重用变量名可以很方便判断类型并操作:

func sqlQuote(x interface{}) string {
 switch x := x.(type) {
 case nil:
  return "NULL"
 case int, uint:
  return fmt.Sprintf("%d", x) //这里x类型为interface{}
 case bool:
  if x {
   return "TRUE"
  }
  return "FALSE"
 case string:
  return sqlQuotestring(x) //(未显示具体代码)
 default:
  panic(fmt.Sprintf("unexpected type %T:%v", x))
 }
}

7.14 示例:基于标记的XML解析

看得脑子一团浆糊,先看看别的换换脑子吧。是我前置知识不够吗?在学校的时候应该多学点的。

7.15 一些建议

MARK

第8章 goroutine和通道

8.1 goroutine

go的并发相对其他编程语言很简单,只需要在函数或方法前加上go关键字即可创建一个goroutine并发执行操作。goroutine类似于线程,但其实是一个更小的单位。

8.2 示例:并发时钟服务器

略,很简单的一个例子,顺便介绍了时间格式化。

8.3 示例:并发回声服务器

略,很简单的一个例子

8.4 通道

通道是可以让一个goroutine发送特定值到另一个goroutine的通信机制。通道的类型为chan

ch := make(chan int)

通道是一个使用make创建的数据结构的引用。当复制或参数传递时引用同一份数据结构,零值为nil。
同种类型的通道可以比较,当引用同一通道数据时返回true,也可以和nil进行比较。

通道的两个操作:发送和接收,都使用操作符<-,数据从右到左传递。在接收表达式中,结果未使用也是合法的。

ch <- x     // 发送语句
x = <- ch   // 赋值语句,接收表法式
<- ch       // 接收语句,丢弃结果

可以使用close(ch)来关闭通道。它设置一个标志位来指示通道关闭。关闭后发送将宕机(panic),关闭后接收将获取发送的值直到通道为空。此时接收操作完成并获取通道元素类型对应的零值。

make不带参数创建的为 无缓冲通道,也可以带一个可选参数表示通道容量来创建 缓冲通道

8.4.1 无缓冲通道

无缓冲通道上的发送和执行操作将 堵塞 ,直到另一方执行相反操作。这将导致发送和接收goroutine 同步化 ,因此无缓冲通道也被称为 同步通道

当x即不比y早也不比y晚时称x和y并发,但顺序是不确定的,需要主动排序。

一点点错误处理知识,略。

强调通道本身及通讯发送时间等方面时,把消息叫做事件。当事件没有携带消息,只用于进行同步时,通过一个struct{}类型的通道来强调它done <- struct{}{}

8.4.2 管道

通道可以连接goroutine,使一个的输出成为另一个的输入,即管道(pipeline)。

没有一个直接的方式判断通道是否以及关闭,但可以通过接收结果间接判断。

go func(){
    for {
        x,ok := <- ch
        if !ok {
            break // 通道关闭且读完
        }
        // ...
    }
}

关闭每一个通道不是必须的,只有通知接收方goroutine所有数据发送完毕才需要关闭通道。垃圾回收器将根据是否可访问来回收,而不是是否关闭。
关闭已关闭的通道和关闭空通道都会导致宕机。关闭通道也可以作为一个广播机制,详见8.9。

8.4.3 单向通道类型

为了文档化限制通道仅发送或仅接收的意图,Go提供了单向通道类型,chan<-只能发送<-chan只能接收,违反这个原则会在编译时被检查出来。

close操作说明的是通道上没有数据再发送,所以只能关闭发送通道,关闭仅接收通道会在编译时报错。

赋值操作或者参数调用可以把双向通道转化为单向通道,但反过来不行。

8.4.4 缓冲通道

可以通过cap(ch)获取缓冲区容量,len(ch)获取通道内元素个数。

MARK
一点关于goroutine泄露的内容,不是很懂。

最后介绍一点缓冲和无缓冲的区别,用流水线作例子。有点似懂非懂。

8.5 并行循环

MARK
看是看懂了好像,但找不出重点。以后需要些并行循环时再回来看看吧。

8.6 示例:并发的Web爬虫

MARK
提了一下避免死锁,但没有展开,我也不知道为什么会导致死锁
之后介绍并行度不是越高越好,总有各种限制。
使用容量为n的缓冲通道建立并发原语,称为 计数信号量。每一个孔宪草表示一个令牌,保证没有接收操作时最多由n个发送。
另一种方法是通过n个长期存活的goroutine调用,保证最多只有n个并发。

8.7 使用select多路复用

select多路复用示例

select {
    case <-ch1:
        // ...
    case x := <-ch2:
        // ...use x...
    case ch3 <- y:
        // ...
    default:
        // ...
}

select将保持等待直到出现通信,然后进行这次通信并执行对应语句。
如果没有对应情况将永远等待。可以设置一个<-time.After(...)来实现超时退出。
如果多个情况同时满足,select将随机选择一个。

此外还介绍了计时器time.tick怎么避免goroutine泄露。

可以使用select实现非阻塞通信。select的default用于指定没有其他通信发生时立即执行的动作,这是一个非阻塞的动作,重复这个动作称为对通道轮询

8.8 示例:并发目录遍历

进一步介绍并发的写法,引入select,time.Tick,break loop,sync.WaitGroup,计数信号量等。

8.9 取消

可以创建一个取消通道,再上面不发送任何值,关闭表示取消该goroutine。同时定义一个工具函数cancelld,被调用时检测或轮询取消状态。

var done = make(chan struct{})

func cancelled() bool {
    select {
        case <-done:
            return true
        default:
            return false
    }
}

创建一个goroutine,堵塞地等待触发条件,如果满足条件就通过关闭goroutine来关闭done通道来广播取消事件。

// 当检测到输入时取消遍历
go func() {
    os.Stdin.Read(make([]byte,1)) // 读一个字节
    close(done)
}

在主goroutine添加关闭通道后的处理:

for{
    select{
        case <-done:
            // 处理一些待完成任务,相当于退出函数
            return
    }
}

让子goroutine在开始时轮询取消状态,如果已被取消,则什么都不做立即返回。

go func ... {
    if cancelled(){
        return
    }
    // 正常子goroutine内容
}

MARK:
最后介绍一点程序退出后的清理,这部分没看懂。

8.10 示例:聊天服务器

MARK:
暂略

第9章 使用共享变量实现并发

9.1 竞态

考虑一个在串行程序中正确工作的函数,如果它在并发调用时仍能正确工作,那么这个函数是并发安全的。
程序并发安全不需要每一个类型都是并发安全的。对于绝大部分变量,如果要回避并发访问,要么限制变量只存在一个goroutine内,要么维护一个更高层的互斥不变量
导出的包级别函数通常可以认为是并发安全的,因为包级别变量无法限制在一个gouroutine内,修改这些变量的函数必须采用互斥机制。

数据竞态:两个goroutine并发读写同一个变量且至少一个是写入。

  1. 不要修改变量。
    e.g. 直接初始化全局变量且不再修改。
  2. 避免从多个goroutine访问同一个变量。
    必须使用通道来发送查询请求或更新变量。
    “不要通过共享内存来通信,而应该通过通信来共享内存”
    即使变量不能受限于单个goroutine,也可以借助通道传递变量地址实现变量共享,即串行受限
  3. 同一时间只有一个goroutine可以访问变量,即互斥机制。见下节。

9.2 互斥锁 sync.Mutex

可以使用容量为1的通道保证同时只有一个goroutine访问共享变量,即二进制信号量

var(
    sema = make(chan struct{}, 1)
    v int
)

func Use(){
    sema <- struct{}{} // 获取令牌
    defer <- sema // 释放令牌
    // 使用变量v
}

sync包有一个单独的Mutex变量支持互斥锁模式。

var(
    mu = sync.Mutex
    v int
)

func Use(){
    mu.Lock() // 获取令牌
    defer mu.Unlock() // 释放令牌
    // 使用变量v
}

Go语言的互斥量是不可再入的(不能对已上锁互斥量再次上锁),所以嵌套申请互斥锁将导致死锁。
MARK
这里关于go互斥量不可再入的理由和解决方法没完全懂。

9.3 读写互斥锁:sync.RWMutex

多个读操作可以安全并发运行,但写操作需要独享的访问权限,这种锁称为多读单写锁,可以通过go的sync.RWMutex实现。

var mu sync.RWMutex
var v int

func Read() int {
    mu.RLock() // 读锁
    defer mu.RUnlock()
    return v
}

RLock只能用于无写操作,但逻辑上只读的操作不一定没有写操作,如递增计数器或更新缓存。如果不确定是否有写操作仍以使用Lock
因为需要更复杂的内部工作,只在竞争激烈时RWMutex才有优势,否则它比普通的互斥锁慢。

9.4 内存同步

MARK
介绍了一种由内存的本地缓存可能引发的问题,结论是尽量限制变量在单个goroutine内或使用互斥锁来避免数据竞态。

9.5 延迟初始化:sync.Once

对于那些一次性初始化以及初始化非必须的变量,如果直接用判v!=nil是并发不安全的,因为初始化时首先将v使用make构造再进行赋值操作,在这期间判得v!=nil但读不到正确值。
如果直接使用Mutex无法并发读,使用RWMutex实现并发安全较为复杂。
可以使用sync.Once实现这一并发安全,它包含一个布尔变量和一个互斥量,前者记录初始化是否完成,后者保护数据。

var once sync.Once
var v map[string]int

func InitV(){...}

func GetV(s string)(int){
    once.Do(InitV)
    return v[s]
}

9.6 竞态检测器

go工具链有一个动态分析工具:竞态检测器(race detector),将-race参数加到go build,go run,go test命令中即可使用。
竞态检测器只能检测到运行时发生的竞态,所以需要晚完善的测试用例以获得最佳效果。

9.7 示例:并发非阻塞缓存

TODO:
看困了,以后再看。
有个singleflight包好像也能实现这个需求。

9.8 goroutine 与线程

9.8.1 可增长的栈

OS线程的栈内存是固定大小(通常为2MB)。
goroutine的栈内存是可增长的,初始通常为2KB,最大可达1GB。

9.8.2 goroutine 调度

线程由内核调度,通过硬件时钟触发,线程切换需要一个完整的上下文切换,成本高操作慢。
goroutine由Go自己的调度器调度,使用m:n调度(复用/调度m个goroutine到n个OS线程),通过特定的go语言结构触发,不需要切换到内核语境,成本低很多。

9.8.3 GOMAXPROCS

Go调度器使用GOMAXPROCS参数确定使用多少个OS线程同时执行Go代码,默认值是机器CPU数量(m:n 调度中的n)。休眠或通道通信阻塞的goroutine不占用线程。I/O阻塞和其他系统调用或非Go函数的goroutine需要独立OS线程,但不计算在GOMAXPROCS内

可以用GOMAXPROCS环境变量或runtime.GOMAXPROCS函数显式控制该参数。

9.8.4 goroutine 没有标识

在大部分支持多线程的操作系统和编程语言中,线程都有一个独特的标识(通常是一个整数或指针),本质上是一个全局map,能够轻松构建一个线程的局部储存。

goroutine在设计中没有提供这个标识,这是为了避免线程局部存储被滥用,鼓励一种更简单的编程风格。

第10章 包和 go 工具

10.1 引言

10.2 导入路径

10.3 包的声明

每个go源文件开头都需要进行包声明,作为被其他包引入时的标识符。

10.4 导入声明

导入多个包时可以使用多个import声明导入,也可以使用一个import (...)一次性导入多个包。一般后者更常见。

import (
    "fmt"
    "os"
)

导入两个同名包时,需要指定别名避免冲突,也就是 重命名导入
即使没有包名冲突,也可以使用重命名导入来简化包名或者避免局部变量冲突。

import (
    "crypto/rand"
    mrand "math/rand"
)

10.5 空导入

可以通过将别名设置为_的方式实现空白导入,例如import _ "image/png" // 注册png解码器。多数情况下,它用来实现一个编译时的机制,开启主程序中可选的特性。

比起图片格式解码器,数据库驱动程序中用到的空白导入对我而言更为熟悉。

package main

import (
	"database/sql"

	_ "github.com/go-sql-driver/mysql" // 添加 MySQL 支持
	_ "github.com/lib/pq"              // 添加 Postgres 支持
)

db, err = sql.Open("postgres", dbname) // 0K
db, err = sql.Open("mysql", dbname)    // 0K
db, err = sql.Open("sqlite3", dbname)  //返回错误消息:unknown driver“sqlite3"
=

10.6 包及其命名

10.7 go 工具

10.7.1 工作空间的组织

现在都用go module不用gopath了,略。

10.7.2 包的下载

10.7.3 包的构建

10.7.4 包的文档化

go doc命令可以输出包的注释和内部声明,也可以输出包成员或方法的注释。

godoc在新版本不再内置,通过 go install golang.org/x/tools/cmd/godoc@latest安装。

运行命令godoc -http=:6060 -play -index,之后访问localhost:6060,就可以以互相链接的网页的形式查看注释文档了。官方的pkg.go.dev也是用的这种方式。

10.7.5 内部包

go build工具会特殊对待internal路径下的包,即 内部包。内部包只能被internal父目录为根的目录树下的包导入。

10.7.6 包的查询

go list 工具将给出可用包的信息。参数也可以包含...通配符,将匹配任意字串。go list用法如下:

  • go list <包名> 判断包是否存在于工作空间,如果存在将输出导入路径。
  • go list ... 枚举工作空间中的所有包
  • go list <路径>/...一个指定子树中的所有包
  • go list ...<关键字>... 查询路径包含关键字的包

go list命令获取的是完整元数据,而不仅仅是导入路径。可以使用-json,-f等标记定制输出内容和格式。

第11章 测试

11.1 go test 工具

_test.go结尾的文件不是go build编译的目标,而是go test编译的目标。

*_test.go中有三种特殊函数:

  • 功能测试函数
    Test前缀,检测逻辑正确性,结果为PASS或FAIL
  • 基准测试函数
    Benchmark开头,测试操作性能,结果为平均执行时间
  • 示例函数
    Example开头,提供机器检查过的文档

11.2 Test 函数

功能测试函数必须以Test开头,后缀名以大写字母开头,如下是一个测试函数示例:

type TestCase struct{
    input interface{}
    want interface{}
}

func TestFunc(t *testing.T) {
    var tests = []TestCase
    // 初始化测试用例
    for test := range tests{
        if got := Func(test.input); got != test.want{
            t.Errorf("Func(%v) = %v , want %v",test.input, got, test.want)
        }
    }
}

go test在不指定包参数时以当前目录所在包为参数。可选的命令行参数如下:

  • -v
    输出包中每个测试用例的名称和执行时间
  • -run = 正则表达式
    只运行测试函数名称匹配给定模式的函数

11.2.1 随机测试

要确定随机输入的正确输出有两种方法:

  • 使用低效但清晰的算法,比较结果是否一致。
  • 构建符合模式的输入,从而知道对应输出。

使用rand.New(rand.NewSource(time.Now().UnixNano()))生成随机数种子,进而生成随机输入。在保存测试用例的输入时,比起保存整个输入数据结构,记录随机数种子更简便。

11.2.2 测试命令

go test除了测试代码也可用于测试命令。

11.2.3 白盒测试

黑盒测试:假设测试者仅了解公开API和文档,而内部逻辑不透明。
百合测试: 可以访问包的内部函数和数据结构,并且可以做一些常规用户无法做到的观察和改动。

在进行白盒测试时,可以保存全局变量或方法后更新其值,最后使用defer恢复,从而实现更精细的调试。

// develop file
var globalVar := 10

func globalFunc( ) { 
    //... 
}

targetFunc( ) {
    // used globalVar
    // used globalFunc
}

// test file
func testFunc(t testing.T){
    // 保存原变量和方法
    savedVar = globalVar
    savedFunc = globalFunc
    // 退出时恢复原值
    defer func ( ) {
        globalVar = savedVar
        globalFunc = savedFunc
    }

    // 定义用于测试的变量和方法
    globalVar = newVar
    globalFunc = newFunc

    // 测试targetFunc,将使用赋的新值
}

11.2.4 外部测试包

11.2.5 编写有效测试

11.2.6 避免脆弱的测试

TODO:
不是很懂

11.3 覆盖率

11.4 Benchmark 函数

11.5 性能刨析

11.6 Example 函数

OMIT:
略 需要再查

第12章 反射

12.1 为什么使用反射

有时需要写一个统一处理各种类型的函数(如Printf),但这些类型可能:

  • 无法共享同一个接口
  • 布局位置
  • 在设计函数时不存在

12.2 reflect.Type 和 reflect.Value

reflect.TypeOf接受interface{}参数,并将接口中动态类型以reflect.Type形式返回。

t := reflect.TypeOf(3)      // reflect.Type
fmt.Println(t)                  // int
fmt.Println(t.String())     // int

注意reflect.TypeOf总是返回具体类型,而不是接口类型。所以可以使用%T直接获取接口的具体类型:

fmt.Printf("%T\n", 3)  // <int Value>

reflect.ValueOf接受interface{}参数,并将接口中动态值以reflect.Value形式返回。
这里说返回值是具体值,但 也可以包含一个接口值。在接口部分我们了解到接口值包括动态类型和动态值,即reflect.Value也是包含动态值和动态类型的,调用Value的Type方法将类型以 reflect.Type返回。
reflect.ValueOf的逆操作是``reflect.Value.Interface方法,返回一个interface{}接口值,与reflect.Value`值相同。

var i interface{} = 3
fmt.Println(i)        // 3
fmt.Printf("%v\n", i) // 3
fmt.Printf("%T\n", i) // int

v := reflect.ValueOf(i)
fmt.Println(v)          // 3
fmt.Println(v.Int())    // 3
fmt.Println(v.Type())   // int
fmt.Println(v.String()) // <int Value>

x := v.Interface()
fmt.Println(x)              // 3
fmt.Printf("%d\n", x.(int)) // 3
fmt.Printf("%T\n", x.(int)) // int

reflect.Valueinterface{}都可以包含任意值,区别是空接口隐藏了值的信息,需要先进行类型断言否则操作有限,而reflect.Value有很多方法去分析值而不用知道类型。

可以使用reflect.Valuekind方法来区分类型:

  • 基础类型:Bool,String,以及各种数字类型
  • 聚合类型:Array和Struct
  • 引用类型:Chan,Func,Prt,Slice和Map
  • 接口类型:Interface
  • Invalid类型:表示没有任何值,如reflect.Value的零值

12.3 Display: 一个递归的值显示器

12.4 示例: 编码S表达式

OMIT: 示例略

12.5 使用 reflect.Value 来设置值

一个变量是一个可寻址的存储区域,其中包含一个值,并且可以通过这个地址来更新。对reflect.Value也有类似区分,只有可寻址的Value才能用于更新变量。

调用reflect.ValueOf(&x).Elem()来获得任意变量x的可寻址的Value值。对slice的e[i]也有reflect.ValueOf(e).Index(i),得到的Value也是可寻址的,有一定的指针基础会更好理解。
通过reflect.Value.CanAddr()可以询问Value变量是否可寻址。

从一个可寻址的reflect.Value()获取变量有如下三步:

  1. 调用Addr(),返回一个Value,包含指向变量的指针。
  2. 调用Interface(),返回包含该指针的interface{}值。
  3. 使用类型断言将接口转换成普通指针。

然后就可以使用这个指针来更新变量了。
也可以使用reflect.Value.Set()直接从可寻址的Value来更新变量。

x := 2
d := reflect.ValueOf(&x).Elem()   // d refers to the variable x
px := d.Addr().Interface().(*int) // px := &x
*px = 3                           // x=3
fmt.Println(x)                    // 3

d.Set(reflect.ValueOf(4))
fmt.Println(x)                    // 4

12.6 示例:解码S表达式

OMIT: 示例略。

12.7 访问结构体字段标签

主要介绍通过反射去解析字段标签的底层实现,例如解析http请求并将参数绑定到结构体上。
大致过程是通过Field和Tag等方法解析结构体中的字段和标签,通过kind方法分类http请求中的数据类型选择合适的方法赋值。现在用的web框架都把这些底层逻辑封装好了,有需要时再翻书。

12.8 显示类型的方法

reflect.Typereflect.Value都有Method(i)方法,前者返回reflect.method类型的实例,描述方法的名称和类型等。后者返回一个reflect.Value类型的方法值,可以通过reflect.Value.Call进行调用。

12.9 注意事项

  • 基于反射的代码很脆弱。编译时能报错的错误,使用反射只有在执行时才会崩溃报告。
    • 将反射的使用完整地封装在包内。
    • 包的API中避免使用reflect.Value,而是特定类型。
    • 每个危险操作前做额外的动态检查。
  • 反射降低了自动重构和分析工具的安全性与准确度,因为它们无法检测到类型信息。类型也算文档的一直,而反射相关操作无法做静态类型检查。
  • 基于反射的函数比特定类型优化的函数慢一两个数量级。

第13章 低级编程

很多实现细节无法通过Go程序访问,通过隐藏底层细节,Go程序有着更好的可读性和可移植性。但有时也可以放弃一些有益的保证去追求高性能,和其他语言交互和实现纯Go无法描述的函数。

这一章主要是读一下扩宽视野,掌握一些与操作系统底层交互的方式,普通程序用不上低级编程。
有C/C++基础看这些内容就跟回家了一样。

13.1 unsafe.Sizeof、Alignof 和 Offsetof

关于内存对齐的知识和方式。个人感觉平时编程其实不用关注内存对齐,关注那点内存提升不如提升可读性。真搞高性能要求的开发之类还是用C。

13.2 unsafe.Pointer

指针相关内容,C++的指针本质上只是一个内存地址,可以往里面写入任何数据。Go原本的指针做了个类型封装,而unsafe.Pointer相当于解除了这个限制。使用unsafe.Pointer还有很多注意事项,有需要再回来翻一下。

13.3 示例:深度相等

reflect.DeepEqual判断两个变量的值是否"深度"相等。对基本数据类型使用==进行比较,对组合类型逐层深入比较。
注意:对nil和空结构体将判断为不相等。如 reflect.DeepEqual(nil,[]string{})结果为false。

OMIT: 示例略。

13.4 使用cgo调用C代码

简单介绍cgo的用法,有需要再回来翻书。

13.5 关于安全的注意事项

高级语言隔离了许多底层细节,让我们可以编写安全健壮的代码并在任何操作系统上运行。
包unsafe可以穿透这层隔离去使用一些特性或实现更高性能,代价则是可移植性和安全性,需要自己评估风险。大多数时候我们都不需要用到unsafe包,如果确实需要使用,则尽可能限制在小范围内。


完结撒花🎉

2025.1.18


下一篇: 【毕设】网络代购货品分析与决策系统的设计与实现→