Go程序设计语言读书笔记

Posted by FridayLi on March 28, 2020

Alan A.A.A Donovan Brian W.Kernighan

第1章 入门

  1. go 是编译型语言, 最简单的子命令是run,它将一个或多个以.go 为后缀的源文件进行编译、链接, 然后运行生成的可执行文件
  2. go原生的支持Unicode,所以它可以处理所有国家的语言。
  3. Println 是 fmt 中一个基本输出函数,它输出一个或多个用空格分隔的值,结尾使用一个换行符,这样看起来这些值是单行输出。
  4. ”{“ 符号必须和关键字func在同一行,不能独自成行,并且在 x+y 这个表达式中, 换行符可以在 + 操作符的后面,但不能在 + 的前面
  5. 在go中,所有的索引使用半开区间, 即包含第一个索引,不包含最后一个索引
  6. os.Args[0] 是命令本身的名字
  7. i++, i–, 但是不能像C里那样 j = i ++
  8. for 是go里唯一的循环语句。for init; condition; post {} , 三个部分都可以省略, 如果没有init和post, 分号也可以省略
  9. go里不允许存在无用的临时标量,或者没有被用到的import
  10. map 是一个使用make 创建的数据结构的引用。当一个map被传递给一个函数时, 函数接收到这个引用的副本。
  11. 当一个goroutine 试图在一个通道上进行发送或接受操作时, 它会阻塞,直到另一个goroutine试图进行接收或发送操作才传递值,并开始处理两个goroutine
  12. 一个关联了命名类型的函数称为方法

第2章 程序结构

  1. go中函数、变量、常量、类型、语句标签和包的名称遵循一个简单的规则:名称的开头是一个字母(Unicode中的字符即可)或下划线, 后面可以跟任意的字符、数字和下划线, 并区分大小写。
  2. 实体第一个字母的大小写决定其可见性是否挎包。如果名称以答谢字母的开头, 它是导出的,意味着它对包外是可见和可访问的。报名本身总是由小写字母组成。像ASCII 和 HTML这样的首字母缩写词通常使用相同的大小写。
  3. 通常, 名称的作用域越大,就使用越长且更有意义的名称。
  4. var name type = expression 类型和表达式部分可以省略一个,但是不能都省略。如果表达式省略,其初始值对应于类型的零值——对于数字是0,对于布尔值是false, 对于字符串是 ““, 对于接口和引用类型(slice、指针、map、通道、函数) 是nil。对于一个像数组或结构体这样的复合类型,零值是其所有元素或成员的零值。 零值机制保障所有的变量是良好定义的,Go里边不存在未初始化变量。
  5. 短变量声明不需要声明所有在左边的变量。如果一些变量在同一个词法块中声明了,那么对于那些变量,短声明的行为等同于赋值

    1
    2
    
     in, err := os.Open(name)
     out, err := os.Create(outfile)  // 该短变量声明仅声明了out, 对已有的err则直接赋值 短变量声明最少声明一个新变量, 否则, 代码编译将无法通过。
    
  6. 指针的值是一个变量的地址。不是所有的值都有地址, 但是所有的变量都有。两个指针当且仅当指向同一个变量或者两者都是nil的情况下才相等
  7. 函数返回局部变量的地址是非常安全的。
  8. 表达式new(T)创建一个未命名的T类型变量,初始化为T类型的零值, 并返回其地址(地址类型为*T)。new 是一个预声明的函数, 不是一个关键字。
  9. 多重赋值, 在实际更新变量前, 右边所有的表达式被推演。
  10. 两个值使用 == 和 != 进行比较与可赋值性相关:任何比较中,第一个操作数相对于第二个操作数的类型必须是可赋值的,或者可以反过来赋值。
  11. 如果两个类型具有相同的底层类型或二者都是指向相同底层类型变量的未命名指针类型,则二者是可以相互转换的。(但不可以直接相互算术运算)。 通过 == 和 < 之类的比较操作符,命名类型的值可以与其相同类型的值或者底层类型相同的未命名类型的值相比较,但是不同命名类型的值不能直接比较。
  12. 如果包级别的名字(类型和变量)在包的一个文件中声明, 就像所有的源代码在同一个文件中一样,它们对于同一个包中的其他文件可见。
  13. init 函数不能被调用和引用, 按照声明的顺序自动执行。包在初始化按照在程序中导入的顺序来进行, 依赖顺序优先。
  14. 在包级别,声明的顺序和它们的作用域没有关系, 所以一个声明可以引用它自己或者跟在它后面的其他声明。
  15. 像for循环一样, 除了本身的主体块之外, if和switch语句还会创建隐式的词法块:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    import "fmt"
    if x := 0; x==0{
        fmt.Println(x)
    } else if y := 1; y !=x {  // x在这里可以继续用
        fmt.Println(y)
    }
    fmt.Println(x, y)  // 报错, x, y没声明
    
    for x := 1; x < 10; x++ {}
    fmt.Println(x) // 报错, x没声明
    

第3章 基本数据

  1. go 的数据类型分四大类:基础类型、聚合类型、引用类型和接口类型。基础类型包括数字、字符串和布尔型。
  2. go 同时具备有符号整数和无符号整数。 int8、int16、int32、int64, uint8、uint16、uint32、uint64, 此外还有int 和 uint, 为32位或64位(跟硬件有关), byte是uint8的同义词,强调一个值是原始数据,而非量值。
  3. 对go而言, 取模余数的正负号总是与被除数一致, 于是 -5%3 和 -5 % -3 都得-2。除法运算的行为取决于操作数是否都为整型,整数相除, 商会舍弃小数的部分,于是 5.0/4.0得到1.25, 而 5/4 结果是1
  4. 运算符&^是按位清除(AND NOT),表达式 z = x&^y中,若y的某位是1,则z的对应位是0;否则,它就等于x的对应位。
  5. 算术上, 左移运算 x « n等价于x乘以 2^n;而右移运算 x » n等价于 x 除以 2^n, 向下取整。
  6. 通常Printf 的格式化字符串含有多个%, 要求提供相同数目的操作数, 而 %后面的副词 [1] 告知Printf 重复使用第一个操作数。
  7. go 具有两种大小的浮点数, float32和float64,十进制下,float32的有效数字大约是6位,float64的有效数字大约是15位。绝大多数情况下,应优先选用float64。避免累计误差。
  8. && 较   优先级更高(助记窍门:&&表示逻辑乘,   表示逻辑加)
  9. 字符串是不可变的字节序列,内置的len函数返回字符串的字节数, 下标访问操作s[i]则是第i个字节(原文我觉得有误)
  10. 字符串不可变意味着两个字符串能安全地共用同一段底层内存,使得复制任何长度字符串的开销都低廉。类似的, 字符串及其子字符串也可以安全地共用数据。
  11. 原生的字符串字面量的书写形式是 ``
  12. UTF-8 以字节为单位对Unicode码点作变长编码。每个文字符号用1-4个字节表示, ASCII字符的编码仅占1个字节, 而其他常用的文书字符的编码只是2或3个字节。
  13. 若将一个整数值转换成字符串, 其值按文字符号类型解读, 并且产生代表该文字符号值的UTF-8码:fmt.Println(string(65)) 输出的是”A” 而不是 “65”
  14. 常量是一种表达式,其可以保证在编译阶段就计算出表达式的值。本质上都属于基本类型:布尔值、字符串或数字。

第4章 复合数据类型

  1. 复合数据类型是由基本数据类型以各种方式组合而构成的,四种常见的复合数据类型分别是数组、slice、map和结构体。数组和结构体的长度都是固定的。反之,slice和map都是动态数据结构,它们的长度在元素添加到结构中时可以动态增长。
  2. 数组是具有固定长度且拥有零个或者多个相同数据类型元素的序列。go 把数组和其他的类型都看成值传递。而在其他的语言中,数组是隐式地使用引用传递。
  3. slice 通常写成 []T, 看上去像是没有长度的数组类型。
  4. slice是一种轻量级的数据结构, 可以用来访问数组的部分或全部元素,而这个数组称为slice的底层数组。slice有三个属性:指针、长度和容量。容量的大小通常是从slice的起始元素到底层数组的最后一个元素间的元素的个数。
  5. 求字符串子串操作和对字节slice做slice操作都写作 x[m:n], 并且都返回原始字节的一个子序列,同时它们的底层引用方式也是相同的,所以两个操作都消耗常量时间。 创建一个数组的slice等于为数组创建了一个别名。
  6. slice没有指定长度, 和数组不同,slice无法做比较。如果底层数组变动, 同一个slice在不同的时间会拥有不同的元素。slice 唯一允许的比较操作时和nil做比较。
  7. 如果想检查一个slice是否是空,那么使用len(s) == 0, 而不是 s == nil, 因为s != nil的情况下, slice也有可能为空。(s := []int{})
  8. append调用可能会涉及新的内存分配(底层数组改变), 所以我们不能假设原始的slice和调用append后的结果slice指向同一个底层数组。
  9. 对于map, 键的类型k, 必须是可以通过操作符 == 来进行比较的数据类型, 所以map可以检测某一个键是否已经存在。
  10. map 中元素的迭代顺序是不固定的。
  11. map的零值是nil, 设置元素前, 必须初始化map (ages := map[string]int{})
  12. 和 slice 一样, map 不可比较, 唯一合法的比较就是和nil做比较。
  13. 如何判断一个键在不在map中? age, ok := ages[“bob”], 根据ok的bool来判断。
  14. go没有提供集合类型, 可以用map来实现(value是bool)。
  15. 成员变量的顺序对于结构体同一性和重要。如果一个结构体的成员变量名称是首字母大写的,那么这个变量是可导出的。
  16. 命名结构体类型s不可以定义一个拥有相同结构体类型s的成员变量,但是s中可以定义一个s的指针类型*s。(用来递归)
  17. 出于效率的考虑, 大型的结构体通常都使用结构体指针的方式直接传递给函数或者从函数中返回。这种方式在函数需要修改结构体内容的时候也是必须的,在Go这种按值调用的语言中, 调用的函数接收到的是实参的一个副本,并不是实参的引用。
  18. 如果结构体的所有成员变量都可以比较, 那么这个结构体就是可以比较的。可比较的结构体类型都可以作为map的键类型。
  19. go 允许我们定义不带名称的结构体成员,只需要指定类型即可,这种结构体成员称为匿名成员。
  20. 在 go中, 组合是面向对象编程方式的核心。
  21. 只有可导出的成员可以转换成JSON字段,这就是为什么Go结构体里边的所有成员都定义为首字母大写的。

第5章 函数

  1. 函数的类型称作函数签名。当两个函数拥有相同的形参列表和返回列表时,认为这两个函数的类型和签名是相同的。
  2. go 语言没有默认参数值的概念, 也不能指定实参名。
  3. 实参是按值传递的,所以函数接收到的形参是每个实参的副本;修改函数的形参变量并不会影响到调用者提供的实参。然而,如果提供的实参包含引用类型,比如指针、slice、map函数或者通道, 那么当函数使用形参变量时就有可能会间接地修改实参变量。
  4. 许多编程语言使用固定长度的函数调用栈;大小在64KB到2MB之间,递归的深度会受限于固定长度的栈大小,所以当进行深度递归调用时必须谨防栈溢出。Go语言的实现使用了可变长度的栈,栈的大小会随着使用增长,可达到1GB左右的上限。
  5. 习惯上将错误值作为最后一个结果返回。如果错误只有一种情况, 结果通常是指为布尔类型。
  6. error 是内置的接口类型,一个错误可能是空值或非空值,空值意味着成功而非空值意味着失败。
  7. 与许多其他语言不同,Go语言通过使用普通的值而非异常来报告错误。
  8. io包保证任何由文件结束引起的读取错误,始终都会得到一个与众不同的错误——io.EOF
  9. 函数的零值是nil(空值), 调用一个空的函数变量将导致宕机。
  10. 函数字面量就像函数声明,但在func关键字后面没有函数的名称。它是一个表达式,它的值称作匿名函数。
  11. 当一个匿名函数需要进行递归,必须先声明一个变量然后将匿名函数赋给这个变量。
  12. 在循环里创建的所有函数变量共享相同的变量——一个可访问的存储位置,而不是固定的值。
  13. defer 语句经常使用于成对的操作, 比如打开和关闭,连接和断开,加锁和解锁, 即使是再复杂的控制流,资源在任何情况下都能够正确释放。
  14. 在许多文件系统中,尤其是NFS,写错误往往不是立即返回而是推迟到文件关闭的时候。

第6章 方法

  1. 面向对象编程就是使用方法来描述每个数据结构的属性和操作,使用者不需要了解对象本身的实现。
  2. 方法的声明和普通函数的声明类似,只是在函数前面多了一个参数。这个参数把这个方法绑定到这个参数对应的类型上。ex: func (p Point) Distance(q Point) float64 {…}
  3. 由于方法和字段来自于同一个命名空间, 因此在同一个结构类型中声明一个叫做x的方法会与字段x冲突。
  4. Go 和许多其他面向对象的语言不同,它可以将方法绑定到任何类型上。可以很方便地为简单的类型(数字、字符串等)定义附加的行为。同一个包下的任何类型都可以声明方法,只要它的类型既不是指针类型也不是接口类型。
  5. 未防止混淆,不允许本身是指针的类型进行方法声明。习惯上遵循如果Point的任何一个方法使用指针接收者,那么所有的Point方法都应该使用指针接收者, 即使有些方法不一定需要。
  6. 如果所有类型T方法的接收者是T自己(而非*T),那么复制它的实例是安全的;但是任何方法的接收者是指针的情况下,应该避免复制T的实例,因为这么做会破坏内部原本的数据。
  7. 和普通函数一样,方法对调用本身做的任何改变,都不会在调用者身上产生作用。(?)
  8. 当编译器处理选择子(比如p.ScaleBy)的时候,首先, 它先查找到直接声明的方法ScaleBy, 之后再从来自ColoredPoint 的内嵌字段的方法中进行查找, 再之后从Point和RGBA中内嵌字段的方法中进行查找,以此类推。当同一个查找级别中有同名方法时,编译器会报告选择子不明确的错误。
  9. 如果变量或方法是不能通过对象访问到的,这称作封装的变量或者方法。
  10. 在GO中,要封装一个对象,必须使用结构体。另外, 在Go语言中封装的单元是包而不是类型。无论是在函数内的代码还是方法内的代码,结构体类型内的字段对于同一个包中的所有代码都是可见的。
  11. 封装隐藏实现细节可以防止使用方依赖的属性发生改变,使得设计者可以更加灵活地改变API的实现而不破坏兼容性。
  12. Go语言也允许导出字段, 但一旦导出就必须要面对API的兼容问题,因此最初的决定需要慎重,要考虑到之后维护的复杂程度,将来发生变化的可能性。

第7章 接口

  1. Go 语言的接口的独特之处在于它是隐式实现。换句话说, 对于一个具体的类型,无须声明它实现了哪些接口,只要提供接口所必需的的方法即可。
  2. fmt.Printf 把结果发到标准输出(标准输出其实是一个文件),fmt.Sprintf把结果以string类型返回。
  3. 一个接口类型定义了一套方法,如果一个具体类型要实现该接口,那么必须实现接口类型定义中的所有方法。
  4. 接口的赋值规则很简单,仅当一个表达式实现了一个接口时,这个表达式才可以赋值给该接口。
  5. 如果一个变量是接口类型, 那么只有通过接口暴露的方法才可以调用,类型的其它方法则无法通过接口来调用。
  6. 正因为空接口类型对其实现类型没有任何要求,所以我们可以把任何值赋给空接口类型。
  7. 从具体类型出发,提取其共性而得出的每一种分组方式都可以表示为一种接口类型。与基于类的语言不同的是, 在Go语言里我们可以在需要的时候才定义新的抽象和分组,并且不用修改原有类型的定义。
  8. 一个接口类型的值其实有两个部分:一个具体类型和该类型的一个值。二者称为接口的动态类型和动态值。
  9. 一个接口值是否是nil取决于它的动态类型。空的接口值与仅仅动态值为nil的接口值是不一样的。
  10. 一个原地排序算法需要知道三个信息:序列长度、比较两个元素的含义以及如何交换两个元素。
  11. 满足同一个接口的多个类型是可以互相替代的。
  12. 类型断言是一个作用在接口值上的操作, 如: x.(T), 其中x是一个接口类型表达式, 而T是一个类型(称为断言类型)。类型断言会检查作为操作数的动态类型是否满足指定的断言类型。如果检查成功, 类型断言的结果就是x的动态值, 类型当然就是T。
  13. 一个不错的接口设计经验是仅要求你所需要的。

第8章 goroutine 和通道

  1. Go 有两种并发编程的风格,一种是利用共享内存的多线程的传统模型;一种是通过goroutine和通道(channel),它们支持通信顺序进程(Communicating Sequential Process, CSP), CSP 是一个并发模式,在不同的执行体之间传递值,但是变量本身局限于单一的执行体。
  2. main函数返回时,所有的goroutine都暴力地直接终结。
  3. 如果说goroutine是GO程序并发的执行体,通道就是它们之间的链接。
  4. 像map一样,通道是一个使用make创建的数据结构的引用。
  5. 在一个已经关闭的通道上进行接收操作,将获取所有已经发送的值,直到通道为空。
  6. 无缓冲通道也称为同步通道,当一个值在无缓冲通道上传递时,接收值后发送方goroutine才被再次唤醒。
  7. 通过通道发送消息有两个重要的方面需要考虑,每一条消息有一个值,但有时候通信本身以及通信发生的时间也很重要。当我们强调这方面的时候,把消息叫做事件(event)。
  8. 当关闭的通道被读完后, 所有后续的接收操作顺畅进行,只是获取到的是零值。
  9. 垃圾回收器根据通道是否可以访问来决定是否回收它,而不是根据它是否关闭。
  10. Go 的类型系统提供了单向通道类型, 类型 chan <- int 是一个只能发送通道; 类型 <- chan int 是一个只能接收的int类型通道。
  11. 可以用sync.WaitGroup来给goroutine计数, wg.Add(1), defer wg.Done()
  12. 通道的零值是nil。令人惊讶的是,nil通道有时候很有用。因为在nil通道上发送和接收将永远阻塞。
  13. 标签化的break语句(break loop)将调出指定的循环, 而没有标签的break只能跳出当前循环, 而外层循环继续执行。

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

  1. 如果我们无法自信地说一个事件肯定优先于另外一个事件, 那么这两个事件就是并发的。
  2. 如要回避并发访问,要么限制变量只存在于一个goroutine内,要么维护一个更高层的互斥不变量。
  3. 数据竞态发生于两个goroutine并发读写同一个变量并且至少其中一个是写入时。
  4. 不要通过共享内存来通信,而应该通过通信来共享内存。使用通道请求来代理一个受限变量的所有访问的goroutine称为该变量的监控 goroutine(monitor goroutine).
  5. 可以用一个容量为1的通道来保证同一时间最多有一个goroutine能访问共享变量。互斥锁模式应用非常广泛,所以sync包有一个单独的Mutex类型来支持, 它的Lock方法用于获取令牌, Unlock方法用于释放令牌。
  6. 这种函数、互斥锁、变量的组合方式称为监控模式。
  7. 读写互斥锁: sync.RWMutex。 它允许只读操作可以并发执行, 但写操作需要获得完全独享的访问权限。
  8. 现代的计算机一般都会有多个处理器, 每个处理器都有内存的本地缓存。为了提高效率, 对内存的写入时缓存在每个处理器中的,只在必要时才刷回内存。甚至刷回内存的顺序都可能与goroutine的写入顺序不一致。像通道通信或者互斥锁操作这样的同步原语都会导致处理器把积累的写操作刷回内存并提交,所以这个时刻之前goroutine的执行结果就保证了对运行在其它处理器的goroutine可见。
  9. 如果两个goroutine在不同的CPU上执行,每个CPU都有自己的缓存, 那么一个goroutine的写入操作在同步到内存之前对另外一个goroutine的print语句是不可见的。 在可能的情况下,把变量限制在单个goroutine中, 对于其他变量,使用互斥锁。
  10. sync包提供了针对一次性初始化问题的特化解决方案: sync.Once
  11. 与OS线程类似,goroutine的栈也用于存放那些正在执行或临时暂停的函数中的局部变量。但与OS不同的是, goroutine的栈不是固定大小的, 它可以按需增大和缩小。goroutine的栈大小限制可以达到1GB,比线程典型的固定大小栈高几个数量级。
  12. 与操作系统的线程调度器不同的是, Go调度器不是由硬件时钟来定期触发的,而是由特定的Go语言结构来触发的。所以调用一个goroutine比调度一个线程成本低很多。
  13. Go 调度器使用 GOMAXPROCS 参数来确定需要使用多少个OS线程来同时执行GO代码。默认值是机器上的CPU数量。阻塞在 I/O 和其他系统调用中或调用非Go语言写的函数的goroutine需要一个独立的OS线程, 但这个线程不计算在GOMAXPROCS内。
  14. goroutine没有可供程序员访问的标识(比如当前goroutine的id)。Go 语言鼓励一种更简单的编程风格, 其中, 能影响一个函数行为的参数应当是显示指定的。

第10章 包和go工具

  1. 包通过控制名字是否导出使其对包外可见来提供封装能力。
  2. 为了避免冲突,除了标准库中的包之外, 其他包的导入路径应该以互联网域名作为路径开始。
  3. 重命名导入: import mrand “math/rand”
  4. 为了防止“未使用的导入”错误, 我们必须使用一个重命名导入, 它使用一个替代的名字 _, 这表示导入的内容为空白标识符。
  5. GoPATH 有三个子目录。src子目录包含源文件,pkg子目录是构建工具存储编译后的包的位置,bin子目录放置像helloword这样的可执行程序。
  6. go build 工具会特殊对待导入路径中包含路径片段internal的情况,这些包叫内部包。内部包只能被另一个包导入,这个包位于以internal目录的父目录为根目录的树种。

例子

8.3 并发目录遍历

串行版本

// walk_dir_1
package main

import (
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
)

func walkDir(dir string, fileSizes chan <- int64) {
	for _, entry := range dirents(dir) {
		if entry.IsDir() {
			subDir := filepath.Join(dir, entry.Name())
			walkDir(subDir, fileSizes)
		} else {
			fileSizes <- entry.Size()
		}
	}
}

// 返回dir目录中的条目
func dirents(dir string) [] os.FileInfo {
	 entries, err := ioutil.ReadDir(dir)
	 if err != nil {
	 	fmt.Fprintf(os.Stderr, "error: %v\n", err)
	 	return nil
	 }
	 return entries
}

func main(){
	flag.Parse()
	roots := flag.Args()
	fileSizes := make(chan int64)
	go func() {
		for _, root := range roots {
			walkDir(root, fileSizes)
		}
		close(fileSizes)
	}()

	var nfiles, nbytes int64
	for size := range fileSizes {
		nfiles ++
		nbytes += size
	}

	fmt.Printf("%d files %.1fGB\n", nfiles, float64(nbytes)/1e9)
}

运行方法: go build walk_dir_1.go ./walk_dir_1 /Users/friday/Desktop

goroutine并发版本

package main

import (
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
	"sync"
)

var sema = make(chan struct{}, 20)


func walkDir2(dir string, n *sync.WaitGroup, fileSizes chan <- int64) {
	defer n.Done()
	for _, entry := range dirents2(dir) {
		if entry.IsDir() {
			n.Add(1)
			subDir := filepath.Join(dir, entry.Name())
			go walkDir2(subDir, n, fileSizes)
		} else {
			fileSizes <- entry.Size()
		}
	}
}

// 返回dir目录中的条目
func dirents2(dir string) [] os.FileInfo {
	sema <- struct{}{}  //控制并发数目, 防止同一时间打开文件太多
	defer func() {<- sema}()
	entries, err := ioutil.ReadDir(dir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		return nil
	}
	return entries
}

func main(){
	flag.Parse()
	roots := flag.Args()
	fileSizes := make(chan int64)
	var n sync.WaitGroup
	for _, root := range roots {
		n.Add(1)
		go walkDir2(root, &n, fileSizes)
	}
	go func() {
		n.Wait()
		close(fileSizes)
	}()

	var nfiles, nbytes int64
	for size := range fileSizes {
		nfiles ++
		nbytes += size
	}

	fmt.Printf("%d files %.1fGB\n", nfiles, float64(nbytes)/1e9)
}