掌握一门语言Go – 一面千人

摘要:Go语言的优势不必多说,通过本篇文章,让我们花时间来掌握一门外语,Let’s Go!

关键字:Go语言,闭包,基本语法,函数与方法,指针,slice,defer,channel,goroutine,select

Go开发环境

针对Go语言,有众多老牌新厂的IDE。本地需要下载Go安装包,无论Windows还是Linux,安装差不多。这里推荐手动安装方式,

  • 安装包下载地址:https://www.golangtc.com/download
  • 解压缩存放相应位置(linux可选位置usr/local),设置环境变量GOROOT指向Go安装目录,并将GOROOT/bin目录放置PATH路径下
  • 设置环境变量GOPATH,这个目录就是告诉Go你的workspace,一个Go工程对应一个workspace。每个workspace内的结构一般包含src,pkg,bin三个目录,其实是仿照Go安装目录,建立了一个独立的Go环境,可以执行bin中我们自己构建的命令。
  • Go语言开发相当于堆积木,Go安装目录下已有的内容为积木的底座,为了防止我们构建的包与标准库有区分,我们需要独立的命名空间,例如普遍采用开发者本人github账户的命名空间。

GOPATH和GOROOT

为了避免混淆加深印象,这里再针对GOPATH和GOROOT进行一个区分详解。

  • GOROOT是GO的安装目录,存放GO的源码文件。

  • GOPATH是我们使用GO开发的工作空间,类似于workspace的概念,但由于GO是高复用型,就像叠积木那样,我们开发的Go程序与GO标准库中的无异,我们编译的Go命令也与GOROOT/bin中的源码命令同级,因此对于一个GO工程,我们就要创建一个工作间添加到GOPATH中去,这个工程中的新开发的包都在该工作间目录结构下。

一个GO工程工作间的目录结构包括:bin,src,pkg。

先说src目录,该目录是我们开发的Go代码的所在地,bin是我们通过go install 将Go源码编译生成的一个可执行文件的存放地,pkg是go get获取的第三方依赖库,源码中使用到的第三方依赖包都会从pkg中去寻找,当然了也会在$GOROOT/pkg标准库中寻找。对了,在我看来,库和包的概念没有什么差异。

我们还可以直接使用go build编译我们的源码,那将会直接在源码位置生成一个可执行文件,而不是像go install那样将该可执行文件安装在$GOPATH/bin目录下。

我们应该将GOROOT和GOPATH均放到$HOME/.profile中去作为环境变量,同时要将$GOROOT/bin以及$GOPATH/bin均放到PATH中,以方便我们在任何位置直接访问go的命令以及我们自己生成的go命令。

hello, world

你的helloworld一定要交给我 ✿◡‿◡

可以来 https://play.golang.org/ 玩一玩,但我不推荐,下面我们来搞一个完整helloworld。

我们已完成上面介绍的开发环境的搭建,然后我们进入到GOPATH目录下,并进入src下我们设置的命名空间目录,

liuwenbin@ubuntu1604:~/workspace/src/github.com$ mkdir hello
liuwenbin@ubuntu1604:~/workspace/src/github.com$ cd hello/
liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ vi hello.go

我们创建了一个包hello,在包内又创建了一个hello.go文件,下面是具体helloworld代码编写内容:

package main

import "fmt"

func main() {
        fmt.Println("hello,world")
}

这里简单啰嗦两句。

每个可执行的Go程序都需要满足:1、有一个main函数,2、程序第一行引入了package main。

我们的代码满足可执行条件,下面继续:

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ go install

执行go install将Go程序代码打包为可执行文件,保存在GOPATH/bin下,

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ ls ~/workspace/bin
hello

经过检查,证实了可执行文件hello已被自动install成功。

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ ~/workspace/bin/hello 
hello,world

执行成功。

liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ export PATH=$HOME/workspace/bin:$PATH
liuwenbin@ubuntu1604:~/workspace/src/github.com/hello$ cd
liuwenbin@ubuntu1604:~$ hello
hello,world

将GOPATH/bin加入到PATH当中,然后在任何位置键入hello都会执行我们的程序。

IDE

IDE就选择官方主推的JetBrains家的goLand吧,亲测好用,至于激活码什么的,谷歌百度你懂的。

goLand可以帮助我们:

  • 时刻管理Go工程目录结构:包括源码位置、包管理一目了然,SDK或第三方依赖显而易见。
  • 统一管理环境变量,作用域可以是全局、工程以及模块。
  • 代码开发语法高亮,自动补全,代码候选项,源码搜索,文件对比,函数跳转,初步代码结构审查,格式化,根据你的习惯设置更方面的快捷键,设置TODO,任务列表。
  • 代码编译执行可视化,断点调试bug易于追踪。
  • IDE内部直接调取终端,不用切换。
  • 可集成各种插件扩展功能,例如版本控制Git,github客户端,REST客户端等。
  • 多种数据库连接客户端可视化。
  • 更炫酷的界面,多种配色主题可选。
  • 自定义宏小工具集成到IDE,更加方便扩展。

Go基本语法

每个Go程序都是由包组成,程序的入口为main包,bin中的自定义命令就是一个Go程序,入口为main包的main函数,该入口程序文件还会依赖其他库的内容,可以是标准库,第三方库或者自己编写的库。这时要通过关键字import导入。而导入的库的程序文件的包名一定是导入路径的最后一个目录,例如import “math/rand”,”math/rand”包一定是由package rand开始。

package main

import (
    "fmt"
    "math/rand"
)

func main() {
    fmt.Println("rand number ", rand.Intn(10))
}

package声明当钱包,import导入依赖包,这与Java很相似。另外这里的rand.Intn方法也与其他语言一样是一个伪随机数,根据种子的变化而变化,如果种子相同,则生成的“随机数”也相同,这其实就是一种哈希算法。

打包:观察代码可以发现,这里的import结构与上面编写helloworld的 import “fmt”相比发生了变化。这里导入了两个包,用圆括号组合了导入,这种方式称为“打包”。

它等同于

import "fmt"
import "math"

但是仍旧提倡使用打包的方式来导入多个包。

下面贴一个官方包的api地址: https://go-zh.org/pkg/ ,这里面的包除了标准库,还有一些其他的附加包,新增包等,我们都可以通过上面提到的方式进行导入,在我们自己的代码中复用他们。

函数

这里面最大的不同之处在于函数的参数类型是在变量名的后面的,相应的,返回值的类型也在参数列表的后面。

package main

import "fmt"

func swap(x, y string) (string, string) {
    return y, x
}

func main() {
    a, b := swap("hello", "world")
    fmt.Println(a, b)
}

相同类型的变量可以只在最后用一个类型表示。这里声明了返回值为两个string类型数据(string, string)。我们可以在这里通过声明返回值的类型返回任意数量的值。

Go语言的函数与其他语言最大的不同除了数据类型在变量名后面进行声明以外,函数的返回值也是可以被命名的。上面讲到了可以直接定义返回值的数量以及数据类型,除此之外,还可以进一步对返回值进行定义。

package main

import "fmt"

func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return
}

func main() {
    fmt.Println(split(17))
}

(x, y int)定义了返回值的数量,类型,以及变量名,这些变量名在方法体内部处理进行赋值,方法在返回时return后面无需任何内容即可返回x和y的值。

变量

变量的声明定义创建以及初始化需要关键字var

package main

import "fmt"

var i, j int = 1, 2

func main() {
    var c, python, java = true, false, "no!"
    fmt.Println(i, j, c, python, java)
}

注意,var后面加变量名,然后是变量类型,后面还可以直接等号加入初始化内容。var定义的变量的作用域可以在函数内也可在函数外。

函数内部的短声明变量 :=

在函数内部,在明确类型的情况下,也即变量声明即初始化情况下,可以使用短声明变量的方式,省略了var关键字以及变量类型,例如k := 3,与var k int = 3等同,但要注意同一个变量不能被var或者:=声明两次,也即var或:=只能作用于新变量上。但是要注意只有函数内部才可以使用。函数外每条语句必须是var func等关键字为开头。

基本类型

Go语言的数据基本类型包括bool,string,int,uint,float,complex。其中bool是布尔类型不多介绍,string是字符串,注意开头s是小写。int根据长度不同包括int8 int16 int32(rune) int64。uint为无符号整型类型,无符号整型就代表只能为正整数,根据长度也分为uint8(byte) uint16 uint32 uint64 uintptr。float浮点型包括float32 float64,复数类型包括complex64 complex128,Go语言支持了复数类型,这是java所不具备的。

布尔类型 字符串 整型 无符号整型 浮点型 复数类型
关键字 bool string int uint float complex
包含 bool string int8/16/32(rune)/64 uint8(byte)/16/32/64 uintptr float32/64 complex64/128
true, false 字符串 正负整数 正整数 小数 复数
零值 false “” 0 0 0(注意浮点的零值也为0,而不是0.0) (0+0i)

注意:int,uint,uintptr类型在32位系统上的长度是32位,在64位系统上是64位,经过测试可知,Go Playground后台是32位系统,因为int溢出了。另外,复数的运算一般都是与数学运算相关联,与业务处理关系较少。所以常用的类型就是bool,string,int,float四种。

Go数据类型的转换直接采用以类型关键字为函数名,参数为待转换变量的方式即可。

var i int = 42
var f float64 = float64(i)
var u uint = uint(f)

函数内,直接使用短声明变量的方式

i := 42
f := float64(i)
u := uint(f)

Go作为功能强大的新型编程语言,也具备自动类型推导的功能。

package main

import "fmt"

func main() {
    var v = 1
    k:=3.1
    fmt.Printf("v is of type %Tn", v)
    fmt.Printf("k is of type %Tn", k)
}
v is of type int
k is of type float64

类型推导:即在未显示指定变量类型的时候,可以根据赋值情况来自动推导出变量类型。然而,当你在前面已经通过赋值推导出某变量的类型以后,再改变其值为其他类型就会报错。

另外,在Println表达式中,%v代表值的默认形式,%T代表值的类型。

常量

使用关键字const声明,可以是字符、字符串、布尔或数字类型的值,不能使用 := 语法定义。

package main

import "fmt"

const Pi = 3.14

func main() {
    const World = "世界"
    fmt.Println("Hello", World)
    fmt.Println("Happy", Pi, "Day")

    const Truth = true
    fmt.Println("Go rules?", Truth)
}
Hello 世界
Happy 3.14 Day
Go rules? true

此外,还有数值常量,数值常量往往是高精度的值,例如

const (
    Big   = 1 << 100
    Small = Big >> 99
)

我们看到了位运算符<<和>>,这里再复习一些位运算的知识。首先定义运算符左侧为原值,右侧为操作位数,运算符“<<”代表左移,即将原值用二进制方式表示,然后将其中的值左移相应位数,再还原回十进制表示结果,反之则为运算符“>>”。那么用一种更加容易理解的方式来讲是左移即为乘以2的n次方,n=操作位数,右移即除以2的n次方,n=操作位数。

循环

同样的,关键字也为for,for的循环结构也与java相似,有初始化语句,循环终止条件,以及后置变化语句(例如自增自减)。不一样的地方是,这个循环结构没有圆括号,初始化变量的作用域在整个循环体内,另外,for也可以相当于其他语言的while使用,即去掉初始化语句和后置变化语句,只有一个循环终止条件,同样没有圆括号,但是循环体必须用花括号包围{}。下面看例子。

    for i := 0; i < 10; i++ {
        sum += i
    }
    // 如果初始化语句和后置变化都去掉的话,则省略分号;
    for ; sum < 10; {
        sum += sum
    }
    // 相当于while
    for sum < 10 {
        sum += sum
    }

初始化语句和后置变化语句都可以被省略,如果终止条件语句也被省略,循环就成了死循环。简洁的表示为

for {
    }

判断语句

Go的if语句也不要用圆括号括起来,但方法体还是要用花括号{}的。与for一样,if语句也可以包含一个初始化语句,然后再接判断表达式,这个初始化变量的作用域仅在if语句内,包括与其成对的else语句。

package main

import (
    "fmt"
    "math"
)

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),
    )
}

pow函数复写了math.pow(x,y float64),加入了一个限制lim参数,当加幂值结果超过lim的时候,返回lim,未超过则返回结果。通过这个例子,可以看到if语句在判断表达式中加入了初始化语句。此外,main函数中的Println输出多条信息的方式,与前面介绍的import打包,const数值常量集都很相似,可以说明这种形式是Go的一种编程习惯。

switch的逻辑同其他语言并没有太多出入。

package main

import (
    "fmt"
    "time"
)

func main() {
    t := time.Now()
    switch {
    case t.Hour() < 12:
        fmt.Println("Good morning!")
    case t.Hour() < 17:
        fmt.Println("Good afternoon.")
    default:
        fmt.Println("Good evening.")
    }
}

输出:Good evening.

由于现在已经17:37,确实过了17点,所以输出为晚上好是合理的。此外,这里面引用到了time包,获取了当前时间,time.Now(),同样的,这可以通过上面给出的标准包文档查看。

延迟执行

这是一个Go语言独特的内容,关键字为defer,意思是defer声明的一行代码要它的上层函数执行完毕返回的时候再执行。

package main

import "fmt"

func main() {
    defer fmt.Println("world")

    fmt.Println("hello")
}
hello
world

defer关键字的声明使得world的输出虽然写在hello输出的上方,但必须等待hello输出完毕以后再输出。

  • defer下压栈

当defer关键字声明的代码不止一行的时候,就引入了defer下压栈的特性,这也是Go比骄强大的地方,根据下压栈的特点,后压入的那行代码会在上层函数执行完毕后先执行。

package main

import "fmt"

func main() {
    fmt.Println("counting")

    for i := 0; i < 10; i++ {
        defer fmt.Println(i)
    }

    fmt.Println("done")
}

输出:

counting
done
9
8
7
6
5
4
3
2
1
0

指针

指针我们都熟悉,大学时期学习C语言的时候折磨我们好久,Go语言中也支持指针,但它并不像我们印象中那么恐怖,因为它并不包含C的指针运算。

指针保存了变量的内存地址。

  • & 符号会【生成】一个指向其作用对象的指针。
  • * 符号表示指针指向的【底层的值】。
package main

import "fmt"

func main() {
    i, j := 42, 2701

    p := &i         // p为i的指针
    fmt.Println(*p) // 通过指针显示的是i的值
    *p = 21         // 通过指针修改的是i的值
    fmt.Println(i) 

    p = &j         // 没有:了,因为是第二次修改值,不是初始化,将p改为j的指针
    *p = *p / 37   // 通过指针操作的是j的值,除以37的结果重新通过指针赋给j
    fmt.Println(j)
}
42
21
73

结构体struct

package main

import "fmt"

type Vertex struct {
    X int
    Y int
}

func main() {
    fmt.Println(Vertex{1, 2})
}
  • 以type开头,用来声明创建一种类型,创建以后可以被var声明该类型的变量。
  • 关键字struct,它相当于一个字段的集合,使用方式与基本类型相似,也是写在变量名后面。
    v := Vertex{1, 2}
    fmt.Println(v.X)
    // 输出1

可以用短声明方式定义一个变量,通过点获得相关字段的内容。

    p := &v
    p.X = 1e9
    fmt.Println(v)
    // 输出{1000000000 2}

结构体同样可以像一个普通变量那样有指针,通过指针可以操作结构体字段。

package main

import "fmt"

type Vertex struct {
    X, Y int
}

var (
    v1 = Vertex{1, 2}  // 类型为 Vertex
    v2 = Vertex{X: 1}  // Y:0 被省略
    v3 = Vertex{}      // X:0 和 Y:0
    p  = &Vertex{1, 2} // 类型为 *Vertex
)

func main() {
    fmt.Println(v1, v2, v3, p)
}
// 输出:{1 2} {1 0} {0 0} &{1 2}

通过对结构体的字段操作,用一个变量来接受,可以重新组装新的结构体。以上代码中,使用var圆括号列表的方式,分别定义了v1,v2,v3和p四个变量,前三个对原结构体的数据进行了不同的赋值,p为结构体的指针,输出也是带有&符号的结果。

数组和slice

类型 [n]T 是一个有 n 个类型为 T 的值的数组。

数组的声明方式:

var a [10]int

定义一个数组变量,变量名为a,长度为10,数据类型为int。Go的数组与其他语言一样,都是定长的,一旦声明无法自动伸缩,但Go提供了更好的解决方案,就是slice。

[]T 是一个元素类型为 T 的 slice。

slice与数组最大的区别就是不必定义数组的长度,它可以根据赋值的长度来设定自己的长度,而不是提前设定。

package main

import "fmt"

func main() {
    s := []int{2, 3, 5, 7, 11, 13}
    fmt.Println(len(s))
    fmt.Println("s ==", s)

    for i := 0; i < len(s); i++ {
        fmt.Printf("s[%d] == %dn", i, s[i])
    }
}
6
s == [2 3 5 7 11 13]
s[0] == 2
s[1] == 3
s[2] == 5
s[3] == 7
s[4] == 11
s[5] == 13

通过len(s)方法可以获得slice当前的长度。此外,上面代码中Printf中的格式化字符串的&d,与C和java相同,都代表是整型数字。

数组和slice都可以是二维的。

slice可以内部重新切片s[lo:hi],lo是低位,hi是高位,hi>lo,若hi=lo则为空。

slice除了上面的直接字面量赋值以外,还可以通过make创建。func make([]T, len, cap) []T

a := make([]int, 5)  // len(a)=5 cap(a)=5
b := make([]int, 0, 5) // len(b)=0, cap(b)=5
// slice内部继续切片,空为从头,这不是下标的概念,而是个数的概念。
b = b[:cap(b)] // len(b)=5, cap(b)=5,b原来的容量为5,重切以后的切片是b[:5]意思是b的前五个数组成的切片,顺序不变。
b = b[1:]      // len(b)=4, cap(b)=4,b原来的容量为5,重切以后的切片是b[1:]意思是除去前一个数(即第一个数)剩余的数组成的切片,顺序不变。

slice的零值是nil。Go语言中的空值用nil来表示。一个 nil 的 slice 的长度和容量是 0。

slice是通过append函数来添加元素。

range遍历slice和map

package main

import "fmt"

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}

func main() {
    for i, v := range pow {
        fmt.Printf("2^%d = %dn", i, v)
    }
}
2^0 = 1
2^1 = 2
2^2 = 4
2^3 = 8
2^4 = 16
2^5 = 32
2^6 = 64
2^7 = 128

注意使用range的格式,返回值i,v分别代表了当前下标,下标对应元素的拷贝。

利用下划线_作占位符

当我们不需要当前下标的时候,可以将i用下划线_代替。然而如果只想要下标,可以把, v直接去掉,不必加占位符。

map

与其他语言一样,map也是一个映射键值对的数据类型。在Go中,与slice相同的是,它也需要使用make来创建,零值为nil,注意值为nil的map不可被赋值。

package main

import "fmt"

type Vertex struct {
    Lat, Long float64
}

// []中的为key数据类型,[]外面紧跟着的是value的数据类型,这里value的数据类型是上面type新创建的struct类型。
var m map[string]Vertex

func main() {
    m = make(map[string]Vertex)
    m["Bell Labs"] = Vertex{
        40.68433, -74.39967,
    }
    fmt.Println(m["Bell Labs"])
}

注意,map在赋值时必须有键名。赋值的时候可以省略类型名

var m = map[string]Vertex{
    "Bell Labs": {40.68433, -74.39967},
    "Google":    {37.42202, -122.08408},
}
  • 修改map中一个元素的内容:m[key]=elem
  • 获得map中的一个元素:elem=m[key]
  • 删除元素:delete(m,key)
  • elem, ok = m[key]判断key是否存在m中,如果没有ok为false,elem=map零值,如果有ok为true,elem为key对应的value
package main

import "fmt"

func main() {
    m := make(map[string]int)
    // 赋值key为"Answer",值为42。
    m["Answer"] = 42
    // 检查key是否存在,此时是存在的。那么v=42。
    v, ok := m["Answer"]
    fmt.Println("The value:", v, "Present?", ok)
    // 删除key
    delete(m, "Answer")
    fmt.Println("The value:", m["Answer"])
    
    // 注意下面没有冒号了,因为是第二次赋值,再次检查key是否存在,此时是不存在的。那么v=0,整型的零值是0。
    v, ok = m["Answer"]
    fmt.Println("The value:", v, "Present?", ok)
}

输出:

The value: 42 Present? true
The value: 0
The value: 0 Present? false

与JavaScript似曾相识?

函数值概念:函数也是值,可以像其他值一样被传递和操作,例如可以当作函数的参数和返回值,这非常强大,跟JavaScript的思想如出一辙。

闭包:当时学习JavaScript闭包概念的时候也是蒙圈,没想到Go语言也支持,下面我们再来复习一下。闭包是基于函数值概念的,它引用了函数体之外的变量,可以对该变量进行访问和赋值。


func adder() func(int) int {
    sum := 0
    return func(x int) int {
        sum += x
        return sum
    }
}

adder函数的返回值类型为func(int) int,好叼哦。也就是说返回的是另一个函数,它没有函数名(有点匿名内部类的意思哈),该函数只有一个int型的参数,返回值为int。继续我们来看函数体,声明并赋值给sum变量为0,然后是return阶段的确返回了符合上面定义的一个函数。在这个返回函数的函数体内,我们直接使用到了外部的sum变量,对其进行了操作并返回,这就是闭包的概念(一个无名函数小子和一个大家闺秀变量的感情纠葛)。

func main() {
    pos, neg := adder(), adder()
    for i := 0; i < 10; i++ {
        fmt.Println(
            pos(i),
            neg(-2*i),
        )
    }
}

接着,我们在main函数中调用这个adder函数。首先声明并初始化变量pos和neg为adder函数值,然后定义一个循环,直接调用pos和neg变量并传参,相当于调用了adder函数。

以上就是函数值和闭包的概念的学习,根据以上知识完成斐波那契数列的练习:

package main

import "fmt"

// fibonacci 函数会返回一个返回 int 的函数。
func fibonacci() func() int {
    back1,back2 := 0,1
    return func() int{
        temp := back1
        // 重新赋值back1和back2的值,下面是关键代码
        back1,back2 = back2, (back2+back1)
        return temp
    }
}

func main() {
    f := fibonacci()
    for i := 0; i < 10; i++ {
        fmt.Println(f())
    }
}

类的概念?Go的方法

Go中是没有类的概念的,但是可以实现与类相同的功能。在java中,如果一个方法属于一个类,我们是如何做的?直接在类文件中加入方法即可,外部调用的时候,我们可以通过类的实例来调用该方法,如果是静态类的话,可以直接通过类名调用。

那么在Go中是如何实现“类的方法”的?

方法,属于一个“东西”的函数被称为这个“东西”的方法。Go中是通过绑定的方式来处理的。我们先type定义一个结构体类型

type Vertex struct {
    X, Y float64
}

这段代码我们上面已经学习过了,应该没有任何疑问。接着,我们要创建一个Vertex类型的变量(java中称为对象的实例,就看你怎么解读了),并且让这个变量拥有一个方法。

func (v *Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

我们慢慢来看,

  • 正常的函数是func Abs() float64, 但我们在func和函数名之间加入了一个东西,定义了一个Vertex指针类型的变量v。
  • 在该函数体中,我们可以直接把v当作参数来使用,因为v是一个结构体类型的对象指针,所以v可以调用结构体中的各个字段。(TODO:Go语言圣经继续深入研究这一部分)
func main() {
    v := &Vertex{3, 4}
    fmt.Println(v.Abs())
}

我们在main函数中可以直接通过变量v调用上面的函数Abs,此时函数Abs就是v的方法了。

注意,type关键字可以创建任意类型

type MyFloat float64

MyFloat就是一个类型,我们在下面可以直接使用,该类型仍然可以与函数绑定。

下面我们再来重申区分一下函数和方法:

  • 函数是func后面直接跟函数名,跟任何类型都无关系。
  • 方法是func后面加入类型的变量,然后再加函数名,这个类型的变量本身也是该方法的参数,同时该方法是属于该类型的,但要用类型的对象来调用(Go没有静态方法)。

上面我们分别使用了结构体指针和自定义类型,使用指针的好处就是可以避免在每个方法调用中拷贝值同时可以修改接收者指向的值。

package main

import (
    "fmt"
    "math"
)

type Vertex struct {
    X, Y float64
}

func (v *Vertex) Scale(f float64) {
    v.X = v.X * f
    v.Y = v.Y * f
}

func (v *Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func main() {
    v := &Vertex{3, 4}
    fmt.Printf("Before scaling: %+v, Abs: %vn", v, v.Abs())
    v.Scale(5)
    fmt.Printf("After scaling: %+v, Abs: %vn", v, v.Abs())
}
/* 输出:
    Before scaling: &{X:3 Y:4}, Abs: 5
    After scaling: &{X:15 Y:20}, Abs: 25
*/

当Scale使用Vertex而不是*Vertex的时候,main函数中调用v.Scale(5)没有任何作用,此时输出结果应该毫无变化“After scaling: &{X:3 Y:4}, Abs: 5”。因为接收者为值类型时,修改的是Vertex的副本而不是原始值。

当我们修改Abs的函数接收者为Vertex的时候,并不会影响函数执行结果,原因是这里只是读取v而没有修改v,读取的话无论是指针还是值的副本都不受影响,但是修改的话就只会修改值的副本,然而打印程序打印的是原始值。

接口

Go的接口定义了一组方法,同样的没有实现方法体。接口类型的值可以存放任意实现这些方法的值。

首先,我们来看一个接口是如何定义的:

type Abser interface {
    Abs() float64
}

然后我们再type创建两个类型,并绑定与接口相同方法名,参数,返回值的方法。

type MyFloat float64

func (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)
}

上面我们定义了一个MyFloat类型和Vertex类型,在他们类型定义的下方都绑定了方法Abs() float64,并有各自具体的实现。

func main() {
    var a Abser
    f := MyFloat(-math.Sqrt2)
    a=f
    fmt.Println(a.Abs())
}

//输出:1.4142135623730951

最后我们在main函数中去做具体操作,首先定义一个接口类型的值var a Abser,然后我们先定义一个刚刚我们创建的MyFloat类型的值f,将f赋值给a,接口类型值a就存放了实现了接口方法Abs的MyFloat类型的值f,最后我们去用a调用Abs方法,实际上调用的是f的Abs方法。

func main() {
    var a Abser
    v := Vertex{3, 4}
    a = &v // a *Vertex 实现了 Abser
    //a = v //等于v为什么不行,而必须是v的指针?
    fmt.Println(a.Abs())
}
// 输出:5

然后我们来测试上面创建的另一个类型Vertex,创建并初始化Vertex的值变量v,将v的指针赋值为接口类型值a,用a调用Abs方法,实际上调用的是*Vertex的Abs方法。

解答代码注释里的问题:因为我们实现接口方法的时候,绑定的是*Vertex而不是Vertex,所以必须是Vertex的指针类型才拥有该方法,如果使用Vertex的值类型而不是指针,则会报错“Vertex does not implement Abser”。

Go的接口属于隐式接口,类型通过实现接口方法来实现接口,方法也不必像java那样必须全部实现,没有显示声明,也就没有“implements”关键字。隐式接口解耦了实现接口的包和定义接口的包:互不依赖。

  • Stringer接口
type Stringer interface {
    String() string
}

常用的一个接口就是Stringer接口,它就如同java复写toString方法,输出对象的时候不必显示调用toString,而是直接输出该接口的实现方法。

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func (p Person) String() string {
    // Sprintf 根据于格式说明符进行格式化并返回其结果字符串。
    return fmt.Sprintf("%v (%v years)", p.Name, p.Age)
}

func main() {
    a := Person{"Arthur Dent", 42}
    z := Person{"Zaphod Beeblebrox", 9001}
    fmt.Println(a, z)
}
// 输出:Arthur Dent (42 years) Zaphod Beeblebrox (9001 years)

我们看到新创建的类型Person,它实现了Stringer的String方法(注意这里开头S是大写,与基本类型string不同)。我们在fmt.Println的时候会默认调用该方法输出。

发现一个问题:Person的实现接口方法以及main函数初始化Person调用String方法输出,这整个过程都没有出现真正的原接口名称“Stringer”!这是非常有意思的部分,我们日后要注意。

那么原因是什么?我们来分析一下,上面我们调用接口方法的时候是需要利用接口类型值来调用的(如var a Abser,a.Abs())。然而这里由于特殊原因(跟其他语言一样,大家都不会显示调用toString吧),并没有显式使用
接口类型变量,所以全文没有出现接口名称,这种情况在之后的Go工作中,应该不少见,还望注意。

error

error在Go中是一个接口类型,与Stringer一样。

type error interface {
    Error() string// 注意接口方法为Error()
}

一般函数都会返回一个error值,调用函数的代码要对这个error进行判断,如果为空nil则说明成功,如果不为空则需要做相应处理,例如报告出来。

i, err := strconv.Atoi("42")
if err != nil {
    fmt.Printf("couldn't convert number: %vn", err)
    return
}
fmt.Println("Converted integer:", i)

Go语言的io包中的Reader接口定义了从数据流结尾读取的方法,标准库中有很多包对Reader的这个接口方法进行了实现,包括文件、网络连接、加密、压缩等。该Read方法的声明为:

func (T) Read(b []byte) (n int, err error)

下面使用strings包中的NewReader实现方法,它的介绍是:

func NewReader(s string) *Reader
NewReader returns a new Reader reading from s. It is similar to bytes.NewBufferString but more efficient and read-only.

会返回一个新的读取参数字符串的Reader类型。

package main

import (
    "fmt"
    "io"
    "strings"
)

func main() {
    r := strings.NewReader("Hello, Reader!")

    b := make([]byte, 8)//定义一个8位字节数组用来存上面的字符串
    for {// 无限循环here
        n, err := r.Read(b)
        fmt.Printf("n = %v err = %v b = %vn", n, err, b)
        fmt.Printf("b[:n] = %qn", b[:n])// b[:n]输出字节数组b的所有值,n是最大长度
        if err == io.EOF {//随着不断循环,上面字符串已经读取完毕,当前字节数组为空,返回EOF(已到结尾)错误
            break// 手动置顶break跳出循环
        }
    }
}

HTTP web服务器

主要通过调用标准库的http包来实现。

package http

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

下面我们创建一个结构体类型,实现该接口方法:

package main

import (
    "fmt"
    "net/http"
    "log"
)

type Hello struct{}

func (h Hello) ServeHTTP(
    w http.ResponseWriter,
    r *http.Request) {
    fmt.Fprint(w, "hello, Go server!")
}
func main() {
    var h Hello
    err := http.ListenAndServe("localhost:4000", h)
    if err != nil {
        log.Fatal(err)
    }
}

在编写这段代码过程中,针对goland有两点收获:

  • import内容完全不必手写,goland会全程帮助你自动补全代码。
  • 每次goland自动补全的时候都会自动格式化你的代码。

此外,我们发现对于Handler接口的ServeHTTP方法,我们自定义的结构体类型Hello全程并未见到Handler的字样,这个问题我在前面已经研究过,这里的Hello的实例h直接作为参数传给了http的ListenAndServe方法,可能在这个方法内部才会有Handler的出现。

因此,这种我实现了你的接口方法,但根本不知道你是谁的情况在Go中十分常见。

我想深层原因就是Go并没有强连接的关系例如继承,显式implements关键字去实现,这是一种解耦的,松散的实现接口方法的方式,才会有这种情况的出现。这个特点称不上好坏,但需要适应。

下面让我们直接在goland中将该文件run起来,会发现在控制台中该程序处于监听状态。然后我们可以

  • 通过浏览器去访问http://localhost:4000/
  • 通过终端curl http://localhost:4000/
  • 通过goLand的REST client输入网址http://localhost:4000/,get的方式run

总之,最终会得到结果:hello, Go server!一个简单的web服务器通过GO就搭建完成了。

并发

goroutine

goroutine 是由 Go 运行时runtime环境管理的轻量级线程。

goroutine 使用关键字go来执行,例如

go f(x, y, z)

意思为开启一个新的goroutine来执行f(x,y,z)函数。

一般来讲,多线程通信都需要处理共享内存同步的问题,Go也有sync功能,但是不常用,后面继续研究。

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,函数体为一个循环输出,每隔100微秒输出一遍参数值,共输出5次。

Go的time包中定义了多个常量来表示时间,我们可以直接调用而不需要再自行计算。

package time

type Duration int64

const (
    Nanosecond  Duration = 1
    Microsecond          = 1000 * Nanosecond
    Millisecond          = 1000 * Microsecond
    Second               = 1000 * Millisecond
    Minute               = 60 * Second
    Hour                 = 60 * Minute
)

接着,我们在main函数中调用了两遍say函数,不同的是第一行调用加入了关键字go,这就使得这两行调用并不存在依次顺序,而是两个线程互不干扰的在跑。我们来看一下结果。

world
hello
hello
world
world
hello
hello
world
world
hello

可以发现,world和hello的输出并没有显然顺序,而是交替输出。

然而经测试,这个输出虽然是交替但顺序不变,这说明了goroutine并不是“完全的多线程”,也就是说goroutine是通过程序控制内存资源的调配的,而不是真正意义的互不干扰独立的并行地享用各自的内存空间运行。

channel

我们在之前学习java的nio的时候就介绍过channel,编程语言都是换汤不换药,各取所需,所以本质上区别不大,下面我们来具体介绍一下Go的通道。

channel 是有类型的管道,使用chan关键字定义,可以用 channel 操作符 <- 对其发送或者接收值。

我们要使用通道,记住这个次操作符即可,箭头就是数据流的方向,不过注意你只能调整箭头左右的对象,这两个对象至少有一个是channel类型的,而不能改变操作符的箭头方向,操作符只有这一个,方向就是从右向左。

与map和slice一样,channel创建也要使用make

ch := make(chan int)

ch是变量名,chan声明了这是一个channel,int说明这个channel的数据类型是整型。chan有点像java的final或static关键字的用法,它是用来修饰变量的,与数据类型不冲突。判断一个变量是不是通道值,就看它的定义中是否有关键字chan即可。

注意:在channel传输数据的操作中,只要另一端没有准备好,发送和接收都会阻塞,这使得goroutine可以在没有明确锁或竞态变量的情况下进行同步。

package main

import "fmt"

func sum(a []int, c chan int) {
    sum := 0
    for _, v := range a {
        sum += v
    }
    c <- sum // 将和送入 c
}

func main() {
    a := []int{7, 2, 8, -9, 4, 0}

    ch := make(chan int)
    go sum(a[4:], ch)
    go sum(a[:1], ch)

    x, y := <-ch, <-ch // 从 ch 中获取

    fmt.Println(x, y, x+y)
}
// 输出:7 4 11

函数sum很好理解,我们就把channel c当做普通的整型值,意思就是将第一个参数的整型数组的值之和传给c。

main函数中,先定义了一个整型切片a,根据初始化值可以确定它的长度和容量均为6。

然后借助make定义了一个通道变量ch,它的数据类型是整型。

下面我们使用goroutine来“多线程”调用sum函数,除了都传入了通道变量ch以外,第一个调用传入的是a的后6-4个数组成的新切片a[4:]等于{4,0},第二个调用传入的是前1个数组成的新切片a[:1]等于{7}

因此可以得出,第一个调用sum以后,ch接收到值为4+0=4,第二个调用sum以后,ch接收到值为7。

那么下一行代码是如何执行的? x和y应该如何分配ch的值。

x, y := <-ch, <-ch

这一行代码我也比较confuse,首先来看x是先得到ch的值,y是后得到ch的值。

那么关于ch的值到底在传给x和传给y这之间发生了什么?

回到goroutine的特性,我们上面已经分析了一波,它不是真正的独立多线程,而是有序的,有章法地通过语言底层逻辑来实现资源调配,那么经测试,我可以总结出来这两个调用的执行顺序是第一个调用的结果后传给ch,第二个调用的结果先传给ch。那么是否可以总结出来,第二个调用的结果给到了先接收的x,第一个调用的结果给到了后接收的y。

以上的分析完全是结果反推的,我不确定是否正确,但我是根据结果这么理解的。(TODO:参照其他书籍的解释)

channel的缓冲区概念

package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 1
    ch <- 2
    ch <- 3
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

可以看到,在正常的创建channel变量ch的结构ch := make(chan int)的make后面加入了第二个参数2。这个2代表了当前通道变量ch的缓冲区大小为2

2的意思是什么?

我们从长计议,再回到上面的channel的概念继续分析,

package main

import "fmt"
func add(i int,c chan int){
    c<-i
}
func main() {
    ch := make(chan int)
    go add(1,ch)
    fmt.Println(<-ch)
}
// 输出:1

channel这种类型的变量必须伴随这goroutine的使用,而goroutine必须修饰的是函数,也就是说线程执行的一定是函数,而不能是一行代码。

你写 go c:=1 就是错的,go只能用于函数不能用于一行代码。

所以,我写了一个add函数来做这行代码相同的事,然后用go来修饰。这才能通过fmt.Println(<-ch)打印出ch的值。

我们再继续测试。

func main() {
    ch := make(chan int)
    go add(3,ch)
    go add(2,ch)
    go add(5,ch)
    go add(11,ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

输出情况如下:

11
3
2
5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
    /tmp/sandbox859149002/main.go:17 +0x360

通过输出结果继续分析,我写了4个go修饰的add函数调用,然而下面使用<-ch了5次,第五次的输出报错了,

报错信息为:所有的goroutine都睡眠了,死锁,下面是通道接收报错,main函数是在tmp临时目录下建立了一个沙盒sandbox加沙盒id的目录,在这个目录下执行main函数时,第17行出错。

第17行对应的就是第五次输出。

这说明了通道channel传输的次数一定要等于go调用函数的次数。

包含通道channel类型参数的函数必须要用goroutine来调用。

好,这种情况我们来评判一下是好是坏呢?我觉得这是一种规定,但是稍显死板,必须是相等的才行。那么Go也提供了一种机制来变通,就是上面提到的channel的缓冲区概念。下面来看代码,深入体验一下缓冲区的“疗效”。

func main() {
    ch := make(chan int,4)
    ch<-1
    ch<- 100
    go add(3,ch)
    go add(2,ch)
    go add(5,ch)
    go add(11,ch)
    add(12,ch)
    add(222,ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

输出结果:

1
100
12
222
11
3
2
5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
    /tmp/sandbox119279221/main.go:25 +0x640

我们先来描述一下上面发生了什么,上面代码中我们定义了四次goroutine调用add函数,四次针对通道channel变量ch进行的普通赋值操作,然后下方对通道变量的接受者输出了九次。

下面我们来总结一下Go语言中通道channel缓冲区的特性。可以发现,如果没有缓冲区的话,不能对通道进行非线程操作,也就是说不使用goroutine调用函数来操作通道的话,就会报错。而有了缓冲区以后,通道可以按照你设定的缓冲区大小来做普通的非goroutine参与的非线程的同步操作,从上面的输出结果我们也能看出来了,非goroutine参与的代码都是按照先后顺序执行的,只有goroutine参与的是无序的,但是所有的goroutine参与的操作一定是在所有普通操作结束以后再执行的,1,100,12,222就是普通操作的结果,后面的都是goroutine的操作结果。

最后一行的报错信息是因为通道ch被接受值的次数多于通道ch被发送值的次数一次,所以有一次报错,但这与缓冲区大小无关。

最后,让我尝试一下用精简的一句话来总结一下通道缓冲的概念。

通道缓冲定义了通道变量允许被最多普通操作的次数。

那么它的意义是什么?

我好像又绕回来了,上面讲过那么一大段通道缓冲的存在意义了。下面再来一句话解释通道缓冲区。

缓冲区大小是可以发送到通道而没有发送阻塞的元素数。

这样的解释更加清晰了,就是通道有了缓冲区可以存放(通道作为接受者)一定大小的数据而不是直接进入阻塞。

缓冲区的高级用法range,close

可以通过for range 语句来遍历缓冲区,close是关闭通道的方法。

package main

import (
    "fmt"
)

func fibonacci(n int, c chan int) {
    x, y := 0, 1
    for i := 0; i < n; i++ {
        c <- x
        x, y = y, x+y
    }
    close(c)
}

func main() {
    c := make(chan int, 10)
    go fibonacci(cap(c), c)
    for i := range c {
        fmt.Println(i)
    }
}

斐波那契函数我们前面练习过,这里对函数做了修改,加入了通道参数,用通道来替代temp(不懂temp的来历的请翻到上面斐波那契函数)。

temp是需要返回的,但通道不需要,通道可以与线程共享数据。

我们先来看上面代码发生了什么?

通道变量c被定义了大小为10的缓冲区,goroutine调用斐波那契函数向通道发送值,每发送一次,会被下面的for range循环的循环体中接收通道的值并输出,也就是说通道c接收到一个值就会立马在goroutine线程发送出去,所以goroutine调用斐波那契函数和下面的for range循环是并行的。直到for range将通道c的缓冲区遍历结束,通道c由于缓冲区大小的限制也不会继续再接收值了,这时就会被close掉。

总结几点注意:

  • close方法是放在了斐波那契函数内尾部而不是想当然的放在for循环后面。原因是Go规定了只有发送者可以关闭通道,作为发送者的只有斐波那契函数,for循环是作为接受者的,它无权对通道进行关闭操作。
  • 我们在斐波那契函数中的循环次数为手动输入的通道的缓冲区大小,如果不是这样的话,发送次数超过了缓冲区大小就会报错。
  • for range循环在对通道c进行遍历的时候,它并不会自动按照c的缓冲区大小来循环,而是通道c被关闭以后,触发了for range循环的中止,而如果不是这样的话,通道一般是不需要被close的。
  • 向一个已经关闭的 channel 发送数据会引起 panic(可以理解为一种error)。

select

select的用法有点像switch,基于上面我们对通道的深入了解,结合select我们可以做很多事,select可以根据判断执行哪个分支,下面看代码。

package main

import "fmt"

func fibonacci(c, quit chan int) {
    x, y := 0, 1
    for {
        select {
        case c <- x:
            x, y = y, x+y
        case <-quit:
            fmt.Println("quit")
            return
        }
    }
}

func main() {
    c := make(chan int)
    quit := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println(<-c)
        }
        quit <- 0
    }()
    fibonacci(c, quit)
}

输出:

0
1
1
2
3
5
8
13
21
34
quit

先来看这段程序都发生了什么,我们又对斐波那契函数进行了改动,函数内有一个for死循环,这就要求我们在循环体中去设置中止办法。循环体中用到了select关键字,它就像switch那样,这里有两个case判断:

  • 第一个是判断c是否可以接收值,如果可以就执行第一个分支,那么c在什么情况下不能接收值呢?上面我们研究过多次了,这是没有缓冲区的通道,它的接收次数一定要与它的发送次数相等,当它的发送结束的时候,它也就不能再继续接收值了。
  • 第二个判断是quit通道是否可以发送值,同样的道理,通道的发送次数一定与它的接收次数相等,当quit通道接收了值,这个判断的分支就可以被执行。

只要有goroutine的函数,执行时一定会与普通函数并行,无论这个普通函数的调用是写在它的前面还是后面。

没有缓冲区的通道,在代码中它的接收次数和发送次数一定是相等的,这个主动权当然是在main函数里,因为只有main函数才是真正开始执行的函数。

所以下面来看main函数。

main函数定义了一个go修饰的匿名函数,函数体内是一个循环10次发送通道c的循环,然后是一次quit通道的接收。上面说了主动权在main,所以main函数要求的这些次数必将在斐波那契函数中得以平衡(即发送次数与接收次数相等)。所以下面的斐波那契函数并行地执行了对应的10次通道c的接收和1次quit通道的发送,这些操作放到select的判断中去就是执行10次的斐波那契数列,每次通道c接收到数列的一个值就会被go匿名函数发送打印出去,10次结束以后,会接收quit通道的值,return中止斐波那契函数内部的死循环。

select 操作很像switch,所以select也有default判断的分支,当其他分支不满足的时候,就会走default分支,一般default分支会执行一个时间的休眠等待,等待外部其他函数的通道操作能够满足select的某些分支。

sync.Mutex

sync.Mutex是一个互斥锁类型,它有Lock和Unlock一个上锁一个解锁的方法,Lock和Unlock之间的代码只会被一个goroutine访问共享变量(共享变量不仅是通道,普通类型的实例也可以,例如struct类型),从而保证一段代码的互斥执行。目前没有什么太好的例子,以后有机会再学而时习之吧。

总结

总结里面依然不说Go的优势,只对本篇文章做一个总结,本篇文章的目标是一次系统性的从零到一的学习Go语言。我本想多看基本书概况总结他们来放到这篇文章中去,但我觉得学习分为理论和实践,比例约为2:8,不能再多了,也由于项目紧留给我搞理论的时间实在不多,因此我就顺着官方文档这一支完完整整地捋下来,对其中每一个特性,语法细节都做了仔细的研究,开发环境的逐步搭建,也对源码进行了复现,甚至自己也开发了一些测试代码。当然,这篇文章远远不能称为Go语言的高级深入使用手册,只是一个入门到了解的过程,未来还有着长久的实践,在使用中会有更多的心得,到时有机会我再总结一篇深入版吧,可以基于《effective go》来写。

参考资料

《A Tour of Go》

其他更多内容请转到醒者呆的博客园



You must enable javascript to see captcha here!

Copyright © All Rights Reserved · Green Hope Theme by Sivan & schiy · Proudly powered by WordPress

无觅相关文章插件,快速提升流量