Go学习笔记

Posted by 皮皮潘 on 05-24,2022

设计哲学

并发哲学:不要通过共享内存来通信,而要通过通信来实现内存共享

面向对象哲学:Go中没有继承!没有继承!Go中是叫组合!是组合!如果要面向对象,那就统一使用接口,尽量减少使用仅仅是语法糖的类型嵌入,而是改为通过组合的方式将其他类型或者接口组合成为成员变量

变量

在Go、C等语言中,为了方便操作内存特定位置的数据,往往会用一个特定的名字与位于特定位置的内存块绑定在一起,这个名字被称为变量,修改变量其实就是修改了对应的位置的内存块的数据,变量所绑定的内存区域拥有一个明确的边界的,也即对应的变量类型(在Java中所有的非基本类型的变量其实都是指针,而Go则是具体的值

Go的变量定义整体与C相同,唯一不同地方在于在Go中数组变量对应了是一块数据空间而非指针,Go中的slice变量才是指针

Go 语言的变量可以分为两类:一类称为包级变量 (package varible),也就是在包级别可见的变量。如果是导出变量(大写字母开头),那么这个包级变量也可以被视为全局变量;另一类则是局部变量 (local varible),也就是 Go 函数或方法体内声明的变量,仅在函数或方法体内可见,包级变量只能使用带有 var 关键字的变量声明形式,不能使用短变量声明形式

常量

Go 的 const 语法提供了“隐式重复前一个非空表达式”的机制,也即会自动给之后的没有赋值的const表达式自动赋值上一个非空表达式,在这个特性的基础上,Go还提供了iota标识符,它表示的是 const 声明块(包括单行声明)中,每个常量所处位置在块中的偏移值(从零开始),通过“隐式重复前一个非空表达式”的机制结合iota可以使得同一个表达式计算出的值不同,如下:

const ( Apple, Banana = iota, iota + 10 // 0, 10 (iota = 0) 
Strawberry, Grape // 1, 11 (iota = 1) 
Pear, Watermelon // 2, 12 (iota = 2))

string

Go原生支持string字符串并且支持Unicode字符,其中使用 rune 这个类型来表示一个 Unicode 码点。rune 本质上是 int32 类型的别名类型,它与 int32 类型是完全等价的,通过下标操作获取到的是字符串中特定下标上的字节而不是字符,如果想要获取字符则需要使用range表达式

底层类型相同(本质上相同)的两个类型,它们的变量可以通过显式转型进行相互赋值,相反,如果本质上是不同的两个类型,它们的变量间连显式转型都不可能,更不要说相互赋值了,Go支持字符串与字节Slice以及字符串与runce Slice的双向转化:

s := "测试Go"
rs := []rune(s)
bs := []byte(s)
s1 := string(rs)
s2 := string(bs)

初始化

通用的复合类型的 初始化+赋值 都可以通过 <类型名> { } 的格式去实现,如下所示:

arr := []string {"a", "b", "c"}
m := map[string]int {
    "a": 1,
    "b": 2
}
p := Person {name: "pipipan", age: 22}

但是如果不需要赋值的话,那么结构体就用new关键字初始化,而map,slice以及channel则用make关键字初始化

除了通用的初始化方式之外,slice初始化的方式还有2种:

  1. 采用array[low: high: max]语法基于一个已存在的数组创建切片:arr := [10]int{1,2,3,4,5,6,7,8,9,10} sl := arr[3:7:9]
  2. 基于切片创建切片

另外一个比较头疼的二维slice初始化的方式则主有2种:

  1. 先通过行数构建二维数组的外围,再一行一行构建一维数组
// Allocate the top-level slice.
picture := make([][]uint8, YSize)
// Loop over the rows, allocating the slice for each row.
for i := range picture {
    picture[i] = make([]uint8, XSize)
}
  1. 先通过行数构建二维数组的外围,再声明一个足够包含所有元素的一维数组,再一行一行通过切片的方式进行行数组赋值
// Allocate the top-level slice, the same as before.
picture := make([][]uint8, YSize) 
// Allocate one large slice to hold all the pixels.
pixels := make([]uint8, XSize*YSize) 
// Loop over the rows, slicing each row from the front of the remaining pixels slice.
for i := range picture {
    picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

函数

函数是一等公民

函数签名本身可以通过type关键字转化为类,而函数的实现则可以看作是对应的实例并且可以赋值给变量,另外任何的类实例(包括函数签名转换成的类的实例)都可以作为方法的接收者

http包中的HandlerFunc将这个特性灵活地应用了起来:

首先任何实现了以下Handler接口的实例都可以通过注册到http.Handle函数中用来处理HTTP请求

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

但是由于函数本身无法作为接口的实现的实例,因此以下函数无法直接注册到http.Handle函数中,而必须将它作为某个没有意义的结构体的方法并将方法名改为ServeHttp或者使用代理模式才可以:

func ArgServer(w http.ResponseWriter, req *http.Request) {
    fmt.Fprintln(w, os.Args)
}

为了解决该问题,type关键字以及函数是一等公民的概念就派上了用场,首先将对应的通用的方法声明为一个类:

type HandlerFunc func(ResponseWriter, *Request)

接着使该类通过方法的方式实现对应的Handler接口:

func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
    f(w, req)
}

最后通过类型转化的方式就可以很轻松地将上述函数注册到http.Handle函数中而不需要定义一个没有意义的结构体方法并实现对应的接口了:

http.Handle("/args", http.HandlerFunc(ArgServer))

闭包

Go闭包不会捕获定义闭包时的变量,而是捕获真正运行时的变量,而方法的入参在调用时会拷贝一份副本并且压入栈中,因此defer以及go的入参变量具体值取决于定义时的变量值(此时相当于已经将变量拷贝并压入栈了)而不会随着之后变量的改变而改变,而函数体中闭包的变量则取决于运行时的变量,因此要注意以下bug场景以及修复方法:

// bug: 所有的goroutine的函数闭包都共享了同一个i变量,因此打印出的i的值取决于goroutine运行时i的值是多少,而非定义对应函数闭包时的值
func serve() {
    for i:=0; i<10; i++ {
        go func() {
            fmt.Println(i)
         }()
    }
}

// fix1:将变量作为入参传入
func serve() {
    for i:=0; i<10; i++ {
        go func(i int) {
             fmt.Println(i)
        }(i)
    }
}

// fix2:定义作用域内的临时变量
func serve() {
    for i:=0; i<10; i++ {
        i := i
        go func() {
            fmt.Println(i)
        }()
    }
}

方法

方法本质上就是函数的语法糖,它左侧的Receiver类型在实际运行时会作为函数的第一个参数传入,方法与Receiver类型对应的结构体需要在同一个包中

在方法中的Receiver类型使用T还是*T取决于性能以及是否需要修改原始结构体,由于Go是值传递的也即会拷贝一份原始对象,因此在使用T类型时会把原始结构体拷贝一份因此修改不会影响到原始结构体,而在使用*T类型时则会把原始结构体的指针拷贝一份,因此修改会影响到原始结构体

方法集合是用来判断一个类型是否实现了某接口类型的唯一手段,但是并不是用来判断类型能否调用对应方法的依据,在下面的代码场景中T的方法集合只有M1,*T的方法集合为M1和M2,因此T类型没有实现I接口但是*T类型实现了I接口,与此同时,不管是T类型还是*T类型都可以调用M1和M2方法,Go运行时会做对应的类型转换:

type T struct{}

type I interface {
    M1()
    M2()
}

func (t T) M1() {}

func (t *T) M2() {}

嵌入类型

在结构体中只声明类型而不定义对应的字段就是所谓的嵌入类型

嵌入类型除了会使得外部类型继承嵌入类型的结构体之外,还会使得外部类型继承嵌入类型的方法,其实说是继承本质上还是组合的一个语法糖罢了,因此无法在Go中通过继承的方式实现模板方法的设计模式,通过嵌入类型可以在测试的Mock中嵌入接口,从而仅仅实现接口中想要实现的方法,而对于其他方法无需实现

对于嵌入类型的方法集合而言,外部类型Out会依据嵌入类型的方式,继承嵌入类型In*In对应的方法集合,而外部指针类型*Out则会继承嵌入类型In*In对应的方法集合

接口

Go的接口实现是Duck类型,不需要显示地声明实现了某个接口,只需要类的方法集合覆盖了接口中定义的函数,那么就可以视作该类实现了对应的接口

接口是Duck类型的一个好处在于,当引入第三方包时,可以在自己的项目中定义与第三方包中的类具有相同的函数签名的接口从而在不修改第三方包的基础上让第三方包实现自己项目定义的接口,提高解耦性

另外接口仅仅关心方法调用,接口的实现方法既可以是基于值的也可以是基于指针的,反正在调用的时候都是 xxx.method() ,Go本身会根据具体实现是基于值还是基于指针去做一定的隐式修改(语法糖),接口一般用作结构体字段、方法入参以及返回类型从而起到多态的作用

Module和Package

一个项目对应一个Module,一个Module下面会有多个Package,一般我们都是在项目中引入其他Module(go.mod),然后在源文件中引入Module的Package(import /

一个module下可以有多个main包,每个main包对应一个可执行文件,每个main包一般放在cmd目录下的各个分目录中,作为功能的入口

关于更详细的包机制以及相关命令请移步另外一篇博文Go的包机制

格式化

fmt中的常用符号:

符号作用
%v调用String方法打印具体的
%+v%v 的基础上打印具体的 字段
%#v%+v 的基础上打印具体的类型
%T打印类型

参考

  1. 《Go程序设计语言》
  2. 《Effective Go》
  3. 《Go语言第一课》