Go 初步

 Go 󰈭 7210字

本文十分简略的对Go语言的一些语法进行学习,并对 Go语言圣经进行了大量的抄录,且十分没有条理,0%自我理解,故仅以此作为一个简单的入门。

编程语言的学习必须扎根于代码实战,因而基础的八股什么的还是走马观花比较好吧

如何编写Hello Wold?

go
1package main
2
3import "fmt"
4
5func main() {
6    fmt.Println("Hello, 世界")
7}

关于fmt包,有解释

Package fmt implements formatted I/O with functions analogous to C’s printf and scanf. The format ‘verbs’ are derived from C’s but are simpler.

通过go run 可以运行go文件,而go build则可以根据平台生成对应的可执行文件,其采用静态编译的方式。

go语言以package的格式组织,一个包由单个或多个go源码文件组成。 main包比较特殊,它定义了一个独立可执行的程序,而不是一个库。 在main里的main 函数也很特殊,它是整个程序执行时的入口。

必须恰当导入需要的包,缺少了必要的包或者导入了不需要的包,程序都无法编译通过。 这项严格要求避免了程序开发过程中引入未使用的包(译注:Go语言编译过程没有警告信息,争议特性之一)。

Go语言不需要在语句或者声明的末尾添加分号,除非一行上有多条语句。

Go语言在代码格式上采取了很强硬的态度,gofmt工具把代码格式化为标准格式,且没有任何可以调整代码格式的参数。

命令行参数

go
 1// Echo1 prints its command-line arguments.
 2package main
 3
 4import (
 5    "fmt"
 6    "os"
 7)
 8
 9func main() {
10    var s, sep string
11    for i := 1; i < len(os.Args); i++ {
12        s += sep + os.Args[i]
13        sep = " "
14    }
15    fmt.Println(s)
16}

通过os库提供的Args来访问命令行参数,os.Args是一个字符串的切片(类似于pyhon的数组切片), 其字符串内容与C语言相同。

var声明了2个string类型的变量,通用语法为var identifier [,identifier]... type,未初始化的变量会被默认赋0值。

在go中,i++, i--是语句,因而j=i++非法。

go提供了短变量声明方式,如:

go
1// 声明初始化一个变量
2s := "hello"
3// 声明初始化一组同类型变量
4min, max := 1, 1000
5// 声明初始化一组不同类型变量
6a, b, c := 1.32, true, "你好"

短变量声明只能用于函数内部声明局部变量,不能在函数外使用。

go语言只有for循环这一种循环方式,且三个部分不需要括号包围,初始化语句必须是一条简单语句(短变量声明、子曾语句、复制语句或函数调用)。

for循环的另一组方式是在字符串、切片的区间上进行遍历:

go
1for _, arg := range os.Args[1:] {
2    s += sep + arg
3    sep = " "
4}

go语言居然不允许无用的局部变量,这会导致编译错误。。。解决方法是使用空标识符,即下划线_

上述几种echo方法的实现效率较低,简单且高效的方式是使用string包的Join函数。

go
1func main() {
2    fmt.Println(strings.Join(os.Args[1:], " "))
3}

数组

数组的声明、定义方式

go
1var a [3]int  
2var r [3]int = [3]int{1, 2}
3//自动判断长度
4q := [...]int{1, 2, 3}
5// 通过索引方式定义数组,第99个元素被置-1,其余用0初始化
6r := [...]int{99: -1}

当调用一个函数的时候,函数的每个调用参数将会被赋值给函数内部的参数变量,所以函数参数变量接收的是一个复制的副本,并不是原始调用的变量。

可以显式地传入一个数组指针,那样的话函数通过指针对数组的任何修改都可以直接反馈到调用者。

go
1func zero(ptr *[32]byte) {
2    *ptr = [32]byte{}
3}

切片 Slice

Slice(切片)代表变长的序列,序列中每个元素都有相同的类型。一个slice类型一般写作[]T,其中T代表slice中元素的类型;slice的语法和数组很像,只是没有固定长度而已。

切片操作返回的是原始字节序列的子序列,底层都是共享之前的底层数组,因此这种操作都是常量时间复杂度。 切片(slice)性能及陷阱

哈希表 Map

创建一个Map:

go
1ages := make(map[string]int)
2
3ages := map[string]int{
4    "alice":   31,
5    "charlie": 34,
6}

使用内置的delete来删除元素,且操作是安全的,即使元素不在map中也没有关系,查找失败将返回value类型对应的零值。

不能对map的元素取地址,原因是map可能随着元素数量的增长而重新分配更大的内存空间,从而可能导致之前的地址无效。

Map的迭代顺序是随机的,每一次的遍历顺序都是随机的,这是故意用于强制要求程序不会依赖具体的哈希函数实现。如果要按顺序遍历key/value对,我们必须显式地对key进行排序,可以使用sort包的Strings函数对字符串slice进行排序。下面是常见的处理方式:

go
 1import "sort"
 2
 3var names []string
 4for name := range ages {
 5    names = append(names, name)
 6}
 7sort.Strings(names)
 8for _, name := range names {
 9    fmt.Printf("%s\t%d\n", name, ages[name])
10}

map上的大部分操作,包括查找、删除、len和range循环都可以安全工作在nil值的map上,它们的行为和一个空的map类似。但是向一个nil值的map存入元素将导致一个panic异常。

map的下标语法将产生两个值,第二个是一个布尔值,用于报告元素是否真的存在。布尔变量一般命名为ok,特别适合马上用于if条件判断部分:

go
1if age, ok := ages["bob"]; !ok { /* ... */ }

Go语言中并没有提供一个set类型,但是map中的key也是不相同的,可以用map实现类似set的功能。

结构体

下面两个语句声明了一个叫Employee的命名的结构体类型,并且声明了一个Employee类型的变量dilbert, dilbert结构体变量的成员可以点操作符访问。

go
 1type Employee struct {
 2    ID        int
 3    Name      string
 4    Address   string
 5    DoB       time.Time
 6    Position  string
 7    Salary    int
 8    ManagerID int
 9}
10
11var dilbert Employee

结构体字面值赋值:

go
1type Point struct{ X, Y int }
2
3p := Point{1, 2}
4Q := POINT{X: 5}

如果结构体的全部成员都是可以比较的,那么结构体也是可以比较的,那样的话两个结构体将可以使用==或!=运算符进行比较。

Go语言有一个特性让我们只声明一个成员对应的数据类型而不指名成员的名字,得益于匿名嵌入的特性,我们可以直接访问叶子属性而不需要给出完整的路径:

go
 1type Point struct {
 2    X, Y int
 3}
 4
 5type Circle struct {
 6    Center Point
 7    Radius int
 8}
 9
10type Wheel struct {
11    Circle Circle
12    Spokes int
13}
14
15var w Wheel
16w.X = 8            // equivalent to w.Circle.Point.X = 8
17w.Radius = 5       // equivalent to w.Circle.Radius = 5

匿名成员并不要求是结构体类型;其实任何命名的类型都可以作为结构体的匿名成员。简短的点运算符语法可以用于选择匿名成员嵌套的成员,也可以用于访问它们的方法。实际上,外层的结构体不仅仅是获得了匿名成员类型的所有成员,而且也获得了该类型导出的全部的方法。这个机制可以用于将一些有简单行为的对象组合成有复杂行为的对象。

函数

函数声明通用语法:

go
1func name(parameter-list) (result-list) {
2    body
3}

如果一组形参或返回值有相同的类型,我们不必为每个形参都写出参数类型:

go
1func f(i, j, k int, s, t string)                 { /* ... */ }
2func f(i int, j int, k int,  s string, t string) { /* ... */ }

在函数调用时,Go语言没有默认参数值,也没有任何方法可以通过参数名指定形参,因此形参和返回值的变量名对于函数调用者而言没有意义。

实参通过值的方式传递,因此函数的形参是实参的拷贝。对形参进行修改不会影响实参。但是,如果实参包括引用类型,如指针,slice、map、function、channel等类型,实参可能会由于函数的间接引用被修改。

如果一个函数所有的返回值都有显式的变量名,那么该函数的return语句可以省略操作数,这称之为bare return。当一个函数有多处return语句以及许多返回值时,bare return 可以减少代码的重复,但是使得代码难以被理解。

go
 1func CountWordsAndImages(url string) (words, images int, err error) {
 2    resp, err := http.Get(url)
 3    if err != nil {
 4        return
 5    }
 6    doc, err := html.Parse(resp.Body)
 7    resp.Body.Close()
 8    if err != nil {
 9        err = fmt.Errorf("parsing HTML: %s", err)
10        return
11    }
12    words, images = countWordsAndImages(doc)
13    return
14}

在Go中,函数被看作第一类值(first-class values):函数像其他值一样,拥有类型,可以被赋值给其他变量,传递给函数,从函数返回。对函数值(function value)的调用类似函数调用。

函数类型的零值是nil。调用值为nil的函数值会引起panic错误。函数值可以与nil比较,但是函数值之间是不可比较的。

在声明可变参数函数时,需要在参数列表的最后一个参数类型之前加上省略符号“…”,这表示该函数会接收任意数量的该类型参数:

go
1func sum(vals ...int) int {
2    total := 0
3    for _, val := range vals {
4        total += val
5    }
6    return total
7}

文件结尾错误

io包保证任何由文件结束引起的读取失败都返回同一个错误——io.EOF,下面的例子展示了如何从标准输入中读取字符,以及判断文件结束:

go
 1in := bufio.NewReader(os.Stdin)
 2for {
 3    r, _, err := in.ReadRune()
 4    if err == io.EOF {
 5        break // finished reading
 6    }
 7    if err != nil {
 8        return fmt.Errorf("read failed:%v", err)
 9    }
10    // ...use r…
11}

匿名函数

拥有函数名的函数只能在包级语法块中被声明,通过函数字面量(function literal),我们可绕过这一限制,在任何表达式中表示一个函数值。函数字面量的语法和函数声明相似,区别在于func关键字后没有函数名。函数值字面量是一种表达式,它的值被称为匿名函数(anonymous function)。通过这种方式定义的函数可以访问完整的词法环境(lexical environment),这意味着在函数中定义的内部函数可以引用该函数的变量,如下例所示:

go
 1// squares返回一个匿名函数。
 2// 该匿名函数每次被调用时都会返回下一个数的平方。
 3func squares() func() int {
 4    var x int
 5    return func() int {
 6        x++
 7        return x * x
 8    }
 9}
10func main() {
11    f := squares()
12    fmt.Println(f()) // "1"
13    fmt.Println(f()) // "4"
14}

squares的例子证明,函数值不仅仅是一串代码,还记录了状态。在squares中定义的匿名内部函数可以访问和更新squares中的局部变量,这意味着匿名函数和squares中,存在变量引用。这就是函数值属于引用类型和函数值不可比较的原因。Go使用闭包(closures)技术实现函数值,Go程序员也把函数值叫做闭包。

编程陷阱:捕获迭代变量

在for循环内的函数变量记录的是循环变量的内存地址,而不是循环变量某一时刻的值!

go
 1var slice []func()
 2
 3func main() {
 4    sli := []int{1, 2, 3, 4, 5}
 5    for _, v := range sli {
 6        fmt.Println(&v)
 7        slice = append(slice, func(){
 8            fmt.Println(v * v) // 直接打印结果
 9        })
10    }
11
12    for _, val  := range slice {
13        val()
14    }
15}
16// 输出 25 25 25 25 25

通常为了解决这问题,我们引入一个与循环变量同名的局部变量:

go
1for _, dir := range tempDirs() {
2    dir := dir // declares inner dir, initialized to outer dir
3    // ...
4}

defer

Go语言设计与实现-defer

defer的两个现象

  • 倒序执行:
go
 1func main() {
 2	for i := 0; i < 5; i++ {
 3		defer fmt.Println(i)
 4	}
 5}
 6
 7$ go run main.go
 84
 93
102
111
120
  • 预计算参数。一种可能的希望计算main函数执行时间的代码如下:
go
1func main() {
2	startedAt := time.Now()
3	defer fmt.Println(time.Since(startedAt))
4	
5	time.Sleep(time.Second)
6}
7-
8$ go run main.go
90s

这是由于调用 defer 关键字会立刻拷贝函数中引用的外部参数,所以 time.Since(startedAt) 的结果不是在 main 函数退出之前计算的,而是在 defer 关键字调用时计算的,最终导致上述代码输出 0s。

而由于调用defer关键字使用值传递,因而传递函数指针后将获得预期的结果。

go
1func main() {
2	startedAt := time.Now()
3	defer func() { fmt.Println(time.Since(startedAt)) }()
4	
5	time.Sleep(time.Second)
6}
7
8$ go run main.go
91s

数据结构

在Go源码中,defer拥有如下的数据结构:

go
 1type _defer struct {
 2	siz       int32
 3	started   bool
 4	openDefer bool
 5	sp        uintptr
 6	pc        uintptr
 7	fn        *funcval
 8	_panic    *_panic
 9	link      *_defer
10}

runtime._defer 结构体是延迟调用链表上的一个元素,所有的结构体都会通过 link 字段串联成链表。

而其余几个字段的简单说明如下:

  • siz 是参数和结果的内存大小;

  • sp 和 pc 分别代表栈指针和调用方的程序计数器;

  • fn 是 defer 关键字中传入的函数;

  • _panic 是触发延迟调用的结构体,可能为空;

  • openDefer 表示当前 defer 是否经过开放编码的优化;

除了上述的这些字段之外,runtime._defer 中还包含一些垃圾回收机制使用的字段,这里不多叙述。

执行机制

中间代码生成阶段的 cmd/compile/internal/gc.state.stmt 会负责处理程序中的 defer,该函数会根据条件的不同,使用三种不同的机制处理该关键字:

go
 1func (s *state) stmt(n *Node) {
 2	...
 3	switch n.Op {
 4	case ODEFER:
 5		if s.hasOpenDefers {
 6			s.openDeferRecord(n.Left) // 开放编码
 7		} else {
 8			d := callDefer // 堆分配
 9			if n.Esc == EscNever {
10				d = callDeferStack // 栈分配
11			}
12			s.callResult(n.Left, d)
13		}
14	}
15}

早期的 Go 语言会在堆上分配 runtime._defer 结构体,不过该实现的性能较差,Go 语言在 1.13 中引入栈上分配的结构体,减少了 30% 的额外开销1,并在 1.14 中引入了基于开放编码的 defer,使得该关键字的额外开销可以忽略不计。

不再具体探究了。。。原文比较详细的进行了说明了。。

使用指南

深入解析Go-defer关键字 文本给出了三种情况的例子,比较好的解释了如何理解defer实际的机制。

通用的语法大致如下:

go
1返回值 = xxx
2调用defer函数
3return

panic和recover

一般而言,当panic异常发生时,程序会中断运行,并立即执行在该goroutine中被延迟的函数。随后,程序崩溃并输出日志信息。日志信息包括panic value和函数调用的堆栈跟踪信息。

不是所有的panic异常都来自运行时,直接调用内置的panic函数也会引发panic异常;panic函数接受任何值作为参数。当某些不应该发生的场景发生时,我们就应该调用panic。

断言函数必须满足的前置条件是明智的做法,但这很容易被滥用。除非你能提供更多的错误信息,或者能更快速的发现错误,否则不需要使用断言,编译器在运行时会帮你检查代码。

在健壮的程序中,任何可以预料到的错误,如不正确的输入、错误的配置或是失败的I/O操作都应该被优雅的处理,最好的处理方式,就是使用Go的错误机制。

显然,下述的MustCompile不能接收不合法的输入,函数名中的Must前缀是一种针对此类函数的命名约定。

go
1package regexp
2func Compile(expr string) (*Regexp, error) { /* ... */ }
3func MustCompile(expr string) *Regexp {
4    re, err := Compile(expr)
5    if err != nil {
6        panic(err)
7    }
8    return re
9}

通常来说,不应该对panic异常做任何处理,但有时,也许我们可以从异常中恢复,至少我们可以在程序崩溃前,做一些操作。

如果在deferred函数中调用了内置函数recover,并且定义该defer语句的函数发生了panic异常,recover会使程序从panic中恢复,并返回panic value。导致panic异常的函数不会继续运行,但能正常返回。在未发生panic时调用recover,recover会返回nil:

go
1func Parse(input string) (s *Syntax, err error) {
2    defer func() {
3        if p := recover(); p != nil {
4            err = fmt.Errorf("internal error: %v", p)
5        }
6    }()
7    // ...parser...
8}

不加区分的恢复所有的panic异常,不是可取的做法;因为在panic之后,无法保证包级变量的状态仍然和我们预期一致。比如,对数据结构的一次重要更新没有被完整完成、文件或者网络连接没有被关闭、获得的锁没有被释放。此外,如果写日志时产生的panic被不加区分的恢复,可能会导致漏洞被忽略。

虽然把对panic的处理都集中在一个包下,有助于简化对复杂和不可以预料问题的处理,但作为被广泛遵守的规范,你不应该试图去恢复其他包引起的panic。公有的API应该将函数的运行失败作为error返回,而不是panic。同样的,你也不应该恢复一个由他人开发的函数引起的panic,比如说调用者传入的回调函数,因为你无法确保这样做是安全的。

方法

方法不同于函数,在函数声明时,在其名字之前放上一个变量,即是一个方法。这个附加的参数会将该函数附加到这种类型上,即相当于为这种类型定义了一个独占的方法。

go
 1package geometry
 2
 3import "math"
 4
 5type Point struct{ X, Y float64 }
 6
 7// traditional function
 8func Distance(p, q Point) float64 {
 9    return math.Hypot(q.X-p.X, q.Y-p.Y)
10}
11
12// same thing, but as a method of the Point type
13func (p Point) Distance(q Point) float64 {
14    return math.Hypot(q.X-p.X, q.Y-p.Y)
15}

上面的代码里那个附加的参数p,叫做方法的接收器(receiver),早期的面向对象语言留下的遗产将调用一个方法称为“向一个对象发送消息”。在Go语言中,我们并不会像其它语言那样用this或者self作为接收器;我们可以任意的选择接收器的名字。由于接收器的名字经常会被使用到,所以保持其在方法间传递时的一致性和简短性是不错的主意。这里的建议是可以使用其类型的第一个字母,比如这里使用了Point的首字母p。

其本质其实就是为一个类定义一个新的成员函数。(大概)

只有类型(Point)和指向他们的指针(*Point),才可能是出现在接收器声明里的两种接收器。此外,为了避免歧义,在声明方法时,如果一个类型名本身是一个指针的话,是不允许其出现在接收器中的,比如下面这个例子:

go
1type P *int
2func (P) f() { /* ... */ } // compile error: invalid receiver type

一种正确的的调用方法是:

go
1r := &Point{1, 2}
2r.ScaleBy(2)
3fmt.Println(*r) // "{2, 4}"

但是这样稍微有些麻烦,Go语言允许我们使用下面的这种写法:

go
1p.ScaleBy(2)

编译器会隐式地帮我们用&p去调用ScaleBy这个方法。不过这种简写方法只适用于“变量”,我们不能通过一个无法取到地址的接收器来调用指针方法,比如临时变量的内存地址就无法获取得到。

两个需要注意的地方:

  • 不管你的method的receiver是指针类型还是非指针类型,都是可以通过指针/非指针类型进行调用的,编译器会帮你做类型转换。
  • 在声明一个method的receiver该是指针还是非指针类型时,你需要考虑两方面的因素,第一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷贝;第二方面是如果你用指针类型作为receiver,那么你一定要注意,这种指针类型指向的始终是一块内存地址,就算你对其进行了拷贝。熟悉C或者C++的人这里应该很快能明白。

Nil也是一个合法的接收器类型

就像一些函数允许nil指针作为参数一样,方法理论上也可以用nil指针作为其接收器,尤其当nil对于对象来说是合法的零值时,比如map或者slice。在下面的简单int链表的例子里,nil代表的是空链表。

类似于函数值,方法也有方法值。方法值是一个将方法绑定到特定接收器变量的函数,这个函数可以不通过指定其接收器即可被调用:

go
 1p := Point{1, 2}
 2q := Point{4, 6}
 3
 4distanceFromP := p.Distance        // method value
 5fmt.Println(distanceFromP(q))      // "5"
 6var origin Point                   // {0, 0}
 7fmt.Println(distanceFromP(origin)) // "2.23606797749979", sqrt(5)
 8
 9scaleP := p.ScaleBy // method value
10scaleP(2)           // p becomes (2, 4)
11scaleP(3)           //      then (6, 12)
12scaleP(10)          //      then (60, 120)

在一个包的API需要一个函数值、且调用方希望操作的是某一个绑定了对象的方法的话,方法“值”会非常实用。举例来说,下面例子中的time.AfterFunc这个函数的功能是在指定的延迟时间之后来执行一个(译注:另外的)函数。且这个函数操作的是一个Rocket对象r:

go
1type Rocket struct { /* ... */ }
2func (r *Rocket) Launch() { /* ... */ }
3r := new(Rocket)
4time.AfterFunc(10 * time.Second, func() { r.Launch() })

直接用方法“值”传入AfterFunc的话可以更为简短, 省掉了上面那个例子里的匿名函数:

go
1time.AfterFunc(10 * time.Second, r.Launch)

如果通过类来获得方法值,那么其第一个参数用作接收器:

go
 1p := Point{1, 2}
 2q := Point{4, 6}
 3
 4distance := Point.Distance   // method expression
 5fmt.Println(distance(p, q))  // "5"
 6fmt.Printf("%T\n", distance) // "func(Point, Point) float64"
 7
 8scale := (*Point).ScaleBy
 9scale(&p, 2)
10fmt.Println(p)            // "{2 4}"
11fmt.Printf("%T\n", scale) // "func(*Point, float64)"

封装

Go语言只有一种控制可见性的手段:大写首字母的标识符会从定义它们的包中被导出,小写字母的则不会。这种限制包内成员的方式同样适用于struct或者一个类型的方法。因而如果我们想要封装一个对象,我们必须将其定义为一个struct。

这种基于名字的手段使得在语言中最小的封装单元是package。

嗨! 这里是 rqdmap 的个人博客, 我正关注 GNU/Linux 桌面系统, Linux 内核 以及一切有趣的计算机技术! 希望我的内容能对你有所帮助~
如果你遇到了任何问题, 包括但不限于: 博客内容说明不清楚或错误; 样式版面混乱; 加密博客访问请求等问题, 请通过邮箱 rqdmap@gmail.com 联系我!
修改日志
  • 2023-09-01 18:14:49 单独划分ACM专题; 移动部分博客进入黑洞归档
  • 2023-05-29 23:05:14 博客结构与操作脚本重构
  • 2023-05-08 21:44:36 博客架构修改升级
  • 2022-11-16 01:27:34 迁移老博客文章内容