hello云胜

技术与生活

0%

深入理解Go的string

字符串类型是我们开发中最常用的类型。我们应该对Go语言的字符串有一个清晰完整的认识。

字符串

和java一样,go的string类型是不可变的。

go的优化点:

  • 对多行字符串的支持。

    在java里写多行字符串非常恶心,加一堆换行转义符。go只要用反引号引起来。你写什么就是什么。所见即所得。

  • 采用unicode编码,对中文支持友好。

字符串的长度问题

字符串的长度分字节长度和字符长度。一个汉字是一个字符,在unicode编码方案用utf-8编码存储的情况下,一个汉字是三个字节。

1
2
3
4
5
func TestChinese(t *testing.T) {
var s string = "中国"
t.Log("中国的字节长度是", len(s)) // 6
t.Log("中国的字符长度是", utf8.RuneCountInString(s)) // 2
}

直接len方法得到的时字节长度,utf8.RuneCountInString方法得到字符长度。

研究字符串之前,我们先研究下字符。

字符

rune这个看起来很陌生,似乎很难理解。但是其实只要类比java的char就好。

rune,在Go中,一个rune值表示一个Unicode码点。 我们可以将一个Unicode码点看作是一个Unicode字符,每个英文或中文Unicode字符只对应一个Unicode码点。

可以说,一个 rune 实例 就是一个 Unicode 字符,一个 Go的 字符串可以被视为 rune 实例的集合

unicode字符集

Unicode编码时一套字符集,也称万国码。从名字就可以感受到,unicode的目的是为了给世界上所有的字符设置其对应的编码,把所有的字符排成一队,某个字符在这个队伍中的位置点,就是上面说到的unicode码点。

比如汉字’中’的码点就是 4e2d。

一个rune字面量由若干包在一对单引号中的字符组成。包在单引号中的字符序列表示一个Unicode码点值。 rune字面量形式有几个变种,其中最常用的一种变种是将一个rune值对应的Unicode字符直接包在一对单引号中。比如:

1
2
'中' // 一个中文字符
'\u4e2d' // 使用\u或\U作前缀,表示一个Unicode字符

go中的字符使用unicode编码方案编码的,但是在存储上使用utf-8的方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func TestRune(t *testing.T) {
var c rune = '中'
t.Logf("中的unicode码点%x", c) // 4e2d

buf := make([]byte, 3)
utf8.EncodeRune(buf, c)
t.Logf("中的unicode码点的utf-8存储0x%x", buf) // 0xe4b8ad
// 一个汉字三个字节

// 对utf-8解码
r, size := utf8.DecodeRune(buf)
t.Logf("%s", string(r))
t.Log(size)
}

结构体

作为C语言家族的一员,go和c一样也支持结构体。可以类比于java的一个POJO。

在学习定义结构体之前,先学习下定义一个新类型。

定义一个新类型

1
2
type T1 int
type T2 T1

新类型 T1 是基于 Go 原生类型 int 定义的新自定义类型,而新类型 T2 则是 基于刚刚定义的类型 T1,定义的新类型。

这里要引入一个底层类型的概念。

如果一个新类型是基于某个 Go 原生类型定义的, 那么我们就叫 Go 原生类型为新类型的底层类型

在上面的例子中,int就是T1的底层类型。

但是T1不是T2的底层类型,只有原生类型才可以作为底层类型,所以T2的底层类型还是int

底层类型的重要性

底层类型是很重要的,因为对两个变量进行显式的类型转换,只有底层类型相同的变量间才能相互转换。底层类型是判断两个类型本质上是否相同的根本。

类型别名

1
type T = string

这种类型定义方式通常用在 项目的渐进式重构,还有对已有包的二次封装方面

类型别名表示新类型和原类型完全等价,实际上就是同一种类型。只不过名字不同而已。

定义结构体的基本形式

1
2
3
4
5
6
// 定义结构体
type Employee struct {
Id string
Name string
Age int
}

一般我们都是定义一个有名的结构体。

字段名的大小写决定了字段是否包外可用。只有大写的字段可以被包外引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 三种初始化的方式
func TestCreateObj(t *testing.T) {
e := Employee{"001", "yunsheng", 32} // 省略字段名。不建议
t.Log(e)

e2 := Employee{Name: "World", Age: 66}
t.Log(e2)

e3 := new(Employee) // 返回指针
e3.Age = 111
t.Log(e3)

// 三种方式创建的对象的类型
t.Logf("e : %T", e) // obj_test.Employee
t.Logf("e2 : %T", e2) // obj_test.Employee
t.Logf("e3 : %T", e3) // *obj_test.Employee
}

还有一个点提一下

1
e2 := Employee{Name: "World", Age: 66}

如果换行来写

1
2
3
4
e2 := Employee{
Name: "World",
Age: 66,
}

Age: 66,后面这个都好不能省略

还有一个点,观察e3的赋值

1
2
e3 := new(Employee) // 返回指针
e3.Age = 111

new返回的是一个指针。然后指针可以直接点号赋值。这说明go默认进行了取值操作

e3.Age等价于(*e3).Age

空结构体

1
2
3
type Empty struct {}
var e Empty
t.Log(unsafe.Sizeof(e))

如上定义了一个空的结构体Empty。打印了元素e的内存大小是0。

有什么用呢?

基于空结构体类型内存零开销这样的特性,我们在日常 Go 开发中会经常使用空 结构体类型元素,作为一种“事件”信息进行 Goroutine 之间的通信

这种以空结构体为元素类建立的 channel,是目前能实现的、内存占用最小的 Goroutine 间通信方式。

1
2
var c = make(chan Empty) // 声明一个元素类型为Empty的channel
c <- Empty{} // 向channel写入一个“事件”

结构体的字段可以是另一个结构体

这种形式需要说的是几个语法糖。

1
2
3
4
5
6
7
8
9
type Reader struct {
ReaderName string
Age int
}

type Book struct {
BookName string
Reader Reader
}

语法糖1:

1
2
3
4
type Book struct {
BookName string
Reader
}

对于结构体字段,可以省略字段名,只写结构体名。默认字段名就是结构体名

这种方式称为 嵌入字段或者叫匿名字段

语法糖2:

如果是以嵌入字段形式写的结构体

1
2
3
4
reader := Reader{"yunsheng", 20}
book := Book{"禅与摩托车维修艺术", reader}
t.Log(book.Reader.ReaderName)
t.Log(book.ReaderName)

可以省略嵌入的Reader字段,而直接访问ReaderName

如果匿名字段有同名的字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type A struct {
a int
b int
}

type B struct {
b float32
c string
d string
}

type C struct {
A
B
a string
c string
}

规则(1),直属于C的a和c会分别覆盖A.a和B.c。可以直接使用c.a、c.c分别访问直属于C中的a、c字段,使用c.d或c.B.d都访问属于嵌套的B.d字段。如果想要访问内部struct中被覆盖的属性,可以c.A.a的方式访问。

规则(2),A和B在C中是同级别的嵌套结构,所以A.b和B.b是冲突的,将会报错,因为当调用c.b的时候不知道调用的是c.A.b还是c.B.b。

初始化问题

  1. 零值初始化

    1
    var book Book

此时book是一个各个属性全是对应类型零值的一个实例。不是nil。这种情况在Go中称为零值可用。不像java会导致npe

  1. 不建议使用字段顺序复制方式初始化

    如上面的代码是一个不好的示例

    1
    book := Book{"禅与摩托车维修艺术", reader}

    这样按字段顺序一个个复制的方式,问题很多:

    当定义的结构体字段顺序改变或者出现字段增删,必须跳转初始化的代码。或者出现非导出字段,这种方式也不支持。

    推荐使用“field:value”形式赋值初始化。

    1
    book := Book{BookName:"禅与摩托车维修艺术", Reader: reader}

字段标签

结构体定义时可以在字段后面追加标签说明。

1
2
3
4
5
type Employee struct {
Id string `json:"id"`
Name string `json:"name"`
Age int `json:"age,omitempty"`
}

tag的格式为反单引号

1
key1:"value1" key2:"value2"

tag的作用是可以使用[反射]来检视字段的标签信息。

具体的作用还要看使用的场景。

比如这里的tag是为了帮助encoding/json标准包在解析对象时可以利用的规则。比如omitempty表示该字段没有值就不打印出来。

Arthas实践

安装

安装的方法很多,推荐最简单的这种

1
2
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

控制结构

在go中代码控制的关键字只有三种,if,for和switch。

if

没什么好说的。

稍微特殊的是:go的if表达式中可以声明变量,称为if语句的自用变量。该变量的作用范围是这个if代码块。

1
2
3
4
a, b := 1, 1
if ok := (a == b); ok {
t.Log("go 的 if,格式可以是先声明变量,然后使用。这点和java不同")
}

for

几个特点

  1. go的for循环支持多循环变量

    1
    2
    3
    4
    5
    6
    7
    8
    	for i, j := 1, 2; (i < 10) && (j < 10); i, j = i+1, j+2 {
    t.Log(i, j)
    }
    ---------------------------------------------------------------------------
    loop_test.go:8: 1 2
    loop_test.go:8: 2 4
    loop_test.go:8: 3 6
    loop_test.go:8: 4 8
  2. 可以仅仅保留循环判断条件表达式

    1
    2
    3
    4
    5
    i := 0
    for i < 10 {
    println(i)
    i++
    }
  3. 无限循环

    1
    2
    3
    for {
    // 循环体代码
    }
    1. 支持continue和break,加label跳转

for range形式

对切片循环

1
2
3
4
slc := []string{"a", "b", "c"}
for index, v := range slc{
t.Log(index, v)
}
要重点说一下对string的循环
1
2
3
4
5
6
func TestString(t *testing.T) {
s := "你好中国"
for index, v := range s{
t.Log(index, v, string(v))
}
}

结果是

1
2
3
4
loop_test.go:23: 0 20320 你
loop_test.go:23: 3 22909 好
loop_test.go:23: 6 20013 中
loop_test.go:23: 9 22269 国

对于string的循环,每次得到的v是一个unicode字符码点。index是该字符码点在该字符串中的位置。一个汉字的unicode编码用utf8形式存储占3个字节。

重点说一个数组循环的一个大坑
1
2
3
4
5
6
7
8
9
10
11
12
13
func TestArray(t *testing.T) {
arr := [5]int{1, 2, 3, 4, 5}
var newArr [5]int
for index, value := range arr {
if index == 0 {
arr[1] = 12
arr[2] = 13
}
newArr[index] = value
}
t.Log("arr:", arr)
t.Log("newArr:", newArr)
}

结果:

1
2
loop_test.go:41: arr: [1 12 13 4 5]
loop_test.go:42: newArr: [1 2 3 4 5]

原因是:参与 for range 循环的是 range 表达式的副本。也就是说参与循环的是arr的副本,和原始的arr是完全两个东西。

使用切片就不会有这个问题,因为切片传递的是引用。

对map循环的坑

之前的文章说过map的循环顺序具有随机性,每次循环的顺序都不一样。

所以不要在map的循环里做任何修改或增删的操作。结果会是不确定的

switch

几个特点

  1. case语句可以接多个表达式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    switch initStmt; expr {
    case expr1:
    // 执行分支1
    case expr2:
    // 执行分支2
    case expr3_1, expr3_2, expr3_3:
    // 执行分支3
    default:
    // 执行默认分支
    }

  2. 不需要写break,和java不一样,go不会默认执行后面的分支。

  3. 如果需要继续执行下一个case,使用fallthrough关键字

  4. go的swaitch表达式支持各种类型,只要这个类型可以进行比较操作就可以。不像java,只支持整型,枚举和字符串。

  5. 如果case的表达式都是布尔型的,可以省略swicth的表达式

1
2
3
4
5
6
7
8
9
10
11
12
// 带有initStmt语句的switch语句
switch initStmt; {
case bool_expr1:
case bool_expr2:
... ...
}
// 没有initStmt语句的switch语句
switch {
case bool_expr1:
case bool_expr2:
... ...
}

type switch

“type switch”这是一种特殊的 switch 语句用法。用来根据变量类型的不同而执行不同的分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestType(t *testing.T)  {
var x interface{} = 100
switch x.(type) {
case nil:
t.Log("type is nil")
case int:
t.Log("type is int")
case string:
t.Log("type is string")
default:
t.Log("other type")
}
}

switch 关键字后面跟着的表达式为x.(type),这种表达式形式是 switch 语句专有的,而 且也只能在 switch 语句中使用。

这个表达式中的 x 必须是一个接口类型变量,表达式的求 值结果是这个接口类型变量对应的动态类型。

Go 中所有类型都实现了 interface{}类型,所以case可以接各种类型。

如果x是指定的某个接口类型,那么case接的必须是实现了这个接口的类型。

另外还可以拿到x的具体值

1
2
3
4
5
6
7
8
9
10
11
12
13
func TestType(t *testing.T)  {
var x interface{} = 100
switch v := x.(type) {
case nil:
t.Log("type is nil")
case int:
t.Log("type is int", v)
case string:
t.Log("type is string")
default:
t.Log("other type")
}
}

这里的 v := x.(type),v得到的就是具体的值,不是x的类型。这点千万注意。这种写法也是诡异。

协程goroutine

go中的多线程称为协程goroutine。和java的Thread是有区别的。

goroutine相比java的Thread更加轻量,(在一些地方被称为绿色线程)。goroutine创建的用户态线程。java的thread是内核态线程,是真正的系统级线程。

(linux操作系统分为用户态和内核态)。所以thread进行上下文切换时需要进行内核态和用户态的切换,此时的性能开销就比较大。goroutine就没有这些切换开销,只要内存充足,一个go程序可以轻松支持上万的并发goroutine。

Go不支持创建系统线程,所以协程是一个Go程序内部唯一的并发实现方式。

每个Go程序启动的时候只有一个对用户可见的协程,称之为主协程。 一个协程可以开启更多其它的协程。

在Go中,开启一个新的协程是非常简单的。 只需在一个函数调用之前使用一个go关键字,即可让此函数调用运行在一个新的协程之中。 当此函数调用退出后,这个新的协程也随之结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"log"
"math/rand"
"time"
)

func SayGreetings(greeting string, times int) {
for i := 0; i < times; i++ {
time.Sleep(2 * time.Second) // 睡眠2秒
log.Println(greeting)
}
}

func main() {
rand.Seed(time.Now().UnixNano())
log.SetFlags(11)
go SayGreetings("hi!", 10)
go SayGreetings("hello!", 10)
time.Sleep(7 * time.Second)
}

只能分别执行3次,因为当main主协程结束后,子协程也就随之结束了。

这里使用log标准库的打印函数是因为log标准库的打印函数是经过了同步处理的,如果使用fmt的Println可能会有并发问题,不同协程的打印会交织在一起(这个例子来说,概率较低)。

如果改用java写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ThreadTest {

public void sayGreeting(String greeting, int times) {
for (int i = 0; i < times; i++) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(greeting + i);
}
}

public static void main(String[] args) {
ThreadTest demo = new ThreadTest();
new Thread(() -> {
demo.sayGreeting("hi", 10);
}).start();

new Thread(() -> {
demo.sayGreeting("hello", 10);
}).start();
}

}

猜猜这个代码会打印什么结果?

结果是十次全部执行完毕。

在java的main方法中,并没有使用类似go的main函数的sleep方法。java程序会等待所有线程执行完后才会彻底退出。

原因是java的程序的运行是由jvm控制的,jvm的退出由以下条件决定:

  1. jvm退出的时机是所有的非守护进程结束了。(守护进程是setDaemon(true)设置的)
  2. main线程是一个用户线程。是jvm启动的运行用户程序的一个用户线程。
  3. 用户在main线程里开启的其他线程还是用户线程。
  4. 即使main线程退出了,只要有其他用户线程还在运行,jvm就不会退出

协程的调度

我们可以很容易的启动多个线程。但并不是所有处于运行状态的协程都在执行。在同一时刻,只能最多有和主机逻辑cpu个数一样多的协程在同时执行。

go运行时来保证cpu不断在多个协程之间切换,从而使多个协程得以并行执行。这和操作系统并行调度执行系统级线程的原理使一样的,只是这里由go-runtime来做这个事。

goroutine-schedule

协程的详细状态图,注意睡眠和等待都属于运行状态。

go运行时采用一种被称为M-P-G模型的算法来实现协程调度。 其中,M表示系统线程,P表示逻辑处理器(并非上述的逻辑CPU),G表示协程。 大多数的调度工作是通过逻辑处理器(P)来完成的。 逻辑处理器P像一个监工一样通过将不同的处于运行状态协程(G)交给不同的系统线程(M)来执行。 一个协程在同一时刻只能在一个系统线程中执行。一个执行中的协程运行片刻后将自发地脱离让出一个系统线程,从而使得其它处于等待子状态的协程G得到执行机会。

并发同步措施

多线程代码我们最主要的是需要保障数据的并发安全问题。

WaitGroup

和java并发包中的CountDownLatch含义一致,用法也相似。用来等待其他线程/协程执行全部执行完毕后,才继续进行后续逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"log"
"math/rand"
"sync"
"time"
)

var wg sync.WaitGroup

func SayGreetings(greeting string, times int) {
for i := 0; i < times; i++ {
time.Sleep(2 * time.Second) // 睡眠2秒
log.Printf("%v,%d", greeting, i)
}
wg.Done()
}

func main() {
rand.Seed(time.Now().UnixNano())
log.SetFlags(11)
wg.Add(2)
go SayGreetings("hi!", 10)
go SayGreetings("hello!", 10)
wg.Wait()
}

WaitGroup主要用三个方法

Add:声明等待几个任务完成

Done:完成其中1个任务

Wait:组合等待,知道全部任务完成。

和java的CountDownLatch用法几乎一样

chan

指针

go被划为C语言家族的一员,一个重要的原因就是Go和C一样,也支持指针。 当然Go中的指针不是完全体的指针,相比C指针有很多限制。

指针的形式和C一样,*T。T称为基类型。

如果一个指针类型的基类型为T,则此指针类型的值只能存储类型为T的值的地址。

获取一个指针值的方式有两种:

  1. 使用内置函数new。new函数可以为任何类型的值在内存中开辟一块内存并将该内存块的起始地址返回。
  2. 使用取地址符&。&取地址符用来获取一个可寻址值的内存地址。

上面提到了可寻址值,所有变量是可寻址的。但是:常量、函数返回值、强制类型转换的结果是不可寻址的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

func TestPointer(t *testing.T) {
p0 := new(int) // p0指向一个int类型的零值
fmt.Println(p0) // (打印出一个十六进制形式的地址)
fmt.Println(*p0) // *取值

x := *p0 // x是p0所引用的值的一个复制。
p1, p2 := &x, &x // p1和p2现在都存储着x的地址。
// x、*p1和*p2表示着同一个int值。
fmt.Println(p1 == p2) // true
fmt.Println(p0 == p1) // false
p3 := &*p0 // 对p0取值再取地址,等价于p3 := p0
fmt.Println(p0 == p3) // true
*p0, *p1 = 123, 789 // p0的值改为123,p0和p3是同一个地址。p1,p2和x是同一个地址
fmt.Println(*p2, x, *p3) // 789 789 123
}

在Go中,所有的赋值(包括函数调用传参)过程都是一个值复制过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func double(x *int) {
*x += *x // 取出地址对应的值进行操作
x = nil // 操作的是p地址的副本
}

func main() {
var a = 3
double(&a)
fmt.Println(a) // 6
p := &a
double(p)
fmt.Println(a, p == nil) // 12 false
}

将a的地址传入,操作的是地址指向的值。所以函数内的操作反应到了函数外。

而x = nil,此时的x是传入的p的地址值的一个副本。修改x并不会影响p的值

go指针的限制

  • go指针不支持算术运算。

    这样更安全,开发者不能随意修改内存

  • 一个指针类型的值不能随意转为为另一个指针类型。只有两个基类型的底层数据类型一致才能转换。

  • 使用unsafe包可以打破上述限制

java中没有指针。硬要类比,java的引用类型可以放一起看看。

函数

函数是go语言中的一等公民。

在go中一个函数的声明可以出现在它的调用之前,也可以出现在它的调用之后。

当然,在java中我们一般称呼函数为方法。在函数定义语法上,go和java还是有很多区别点的。

普通函数

一个简单例子

1
2
3
4
5
6
func SquareSumAndDiff(a int64, b int64) (c int64, d int64) {
x, y := a+b, a-b
c = x * x
d = y * y
return // 等价于 return c, d
}

这一个简单的例子就有很多可说的。

  1. go的参数声明都是名字在前,类型在后。函数名首字母大小写规则遵循导出规则。

  2. 多个参数,如果类型一样,可以简写(a, b int64)。另外go支持变长参数,在类型前加...

  3. go支持多返回值。这个确实方便。习惯了之后,会觉得java的单返回值写起来有点麻烦。

  4. 如果只有一个返回值,并且返回值没有名字,那么可以不写括号()。但是入参的括号永远不能省略。

  5. 仔细看例子,返回值是可以有名字的。这个名字相当于进行了变量申明,在函数中可以直接使用。

    在这种返回值有名字的情况下,return c, d可以简写为return。如果返回值是匿名的,那么必须显示return c,d

    1
    2
    3
    4
    5
    6
    func SquareSumAndDiff2(a int64, b int64) (int64, int64) {
    x, y := a+b, a-b
    c := x * x
    d := y * y
    return c, d
    }

关于函数参数

go函数传参采用的是值传递的方式。就是将实际参数在内存中一个bit一个bit的拷贝到形参里,比如传整型,数组或者string类型,即使这个数组很大,也是copy整个原始数组传进去。

但是像切片,map,struct等类型,他们的内存存储的是具体内容的引用(或者叫指针)。这样传参时copy的也是引用。

关于多返回值

返回值是可以有名字的,称为具名返回值。

Go 标准库以及大多数项目代码中的函数,都是使用普通的非具名返回值形式

匿名函数

首先要明确的一个事情是:匿名函数的定义不是一种函数声明。一个标准的函数只能声明在包级。不能在一个函数中再声明新的函数。

但是可以在一个函数中定义一个匿名函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

func main() {
// 这个匿名函数没有输入参数,但有两个返回结果。
x, y := func() (int, int) {
return 3, 4
}() // 一对小括号表示立即调用此函数。不需传递实参。

// 下面这些匿名函数没有返回结果。
func(a, b int) {
println("a*a + b*b =", a*a + b*b) // a*a + b*b = 25
}(x, y) // 立即调用并传递两个实参。

func(x int) {
// 形参x遮挡了外层声明的变量x。
println("x*x + y*y =", x*x + y*y) // x*x + y*y = 32
}(y) // 将实参y传递给形参x。

func() {
println("x*x + y*y =", x*x + y*y) // x*x + y*y = 25
}() // 不需传递实参。
}

闭包

上面例子中的最后一个匿名函数处于变量xy的作用域内,所以在在它的函数体内可以直接使用这两个变量。 这样的函数称为闭包(closure)。

事实上,Go中的所有的自定义函数(包括普通函数和匿名函数)都可以被视为闭包。

换句话说,所谓的闭包,不过是一个函数可以使用函数外定义的变量。

一个很常见的闭包的例子就是一个函数的返回值是函数

1
2
3
4
5
6
7
func incr() func() int {
var x int // 默认值是0
return func() int {
x++
return x
}
}

函数incr()的返回值是一个函数。

1
2
3
4
5
6
func TestClosure(t *testing.T) {
f := incr()
t.Log(f()) // 1
t.Log(f()) // 2
t.Log(f()) // 3
}

可以看到,多次调用闭包函数。x都增加了。也就是说x并没有随着函数的结束而销毁。因为x已经被变量f持有,只要f还在其作用域内,x也就一直存在下来了。通过x的增加,也可说明f对x的持有是保存其地址,所以x始终是同一个x。

1
2
3
t.Log(incr()())   // 1
t.Log(incr()()) // 1
t.Log(incr()()) // 1

这样写,每次都是一个新的x。所以一直是1

init函数

init函数的作用是在main执行前进行一些初始化工作。

每个代码包下,可以有多个init函数。init函数和main函数一样,没有入参,没有返回值。

1
2
3
func init() {
fmt.Println("init===")
}

init函数是在包加载的时候执行。

多个init按照声明的顺序串行执行。

一个代码包依赖于应一个代码包,那么一定是被依赖的代码包的init先执行。

在加载一个代码包的时候,此代码包中声明的所有包级变量都将在此包中的任何一个init函数执行之前初始化完毕。

defer函数

go的函数内部可以使用defer关键字,产生类似java的try-catch-finally的finally效果。

但是,go的defer函数可以写多个。最后的执行顺序和声明顺序使相反的。可想而知,是用栈结构来存的多个defer函数。

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

func main() {
defer fmt.Println("333333")
defer fmt.Println("222222")
fmt.Println("111111")
}

panic恢复

go没有java的异常抛出机制。go产生一个运行时异常称为发生了panic(恐慌)。如果这个panic没有处理,将导致整个应用的崩溃。

消除panic,可以在defer中调用recover函数,这是go内置的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"

func main() {
defer func() {
fmt.Println("正常退出")
}()
fmt.Println("嗨!")
defer func() {
v := recover()
fmt.Println("恐慌被恢复了:", v)
}()
panic("拜拜!") // 产生一个恐慌
fmt.Println("执行不到这里")
}
1
2
3
嗨!
恐慌被恢复了: 拜拜!
正常退出

无论在哪个 Goroutine 中发生未被恢复的 panic,整个程序都将崩溃退出。

一等公民

我们说函数在go语言中是一等公民。怎么理解这句话?

首先什么叫一等公民?这个业界还没有公认的准确定义,引用wiki 发明人的解释

如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value) 一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。 拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以 在函数内部创建并可以作为返回值从函数返回。

也就是说一门语言的一等公民元素,其创建和使用非常灵活,可以用在各个地方。那我们对比下go的函数

  • 存储在变量里

    类似前面匿名函数的例子

    1
    2
    3
    4
    5
    6
    func TestFuncAsParam(t *testing.T) {
    var f = func() string {
    return "hello"
    }
    t.Log(f())
    }
  • 函数作为返回值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func TestFuncAsReturn(t *testing.T) {
    var f1 = func() func() string {
    return func() string{
    return "a func"
    }
    }
    var f2 = f1()
    t.Logf("%T", f1())
    t.Log(f2())
    }

    匿名函数的返回值还是一个函数,保存到变量f1

  • 作为参数传到方法里

    1
    2
    3
    4
    5
    6
    7
    func TestTime(t *testing.T) {
    time.AfterFunc(time.Second * 2, func() {
    println("after 2 second...")
    })
    time.Sleep(time.Second * 3)
    t.Log("end")
    }

    比如time包的AfterFunc函数就可以接收一个函数作为参数

  • 函数有自己的类型,函数类型

​ func关键字+函数的参数列表+返回值列表就是一个函数类型。

​ 其中函数的参数列表+返回值列表也成为函数签名

​ 不用管参数或者返回值的名字。无所谓。

​ 函数签名一样的两个函数就是同一个函数类型

​ 因为函数有类型,也就是说函数可以进行显式的类型转换

函数拥有以上众多特性,可以称为go语言的一等公民

容器类型

java

java中的容器类型常用的是List,Set,HashMap等。

preview

在java中谈容器,一般指的是Collection和Map。数组不属于容器的范围。

但是go中我们说到容器类型,一般是说数组、切片和map

go的数组

go数组的两个特性:长度固定,元素类型相同。

1
var arrName [n]T    //长度为n,类型是T

[n]T 是arrName的数组类型。也就是说如果两个数组类型的元素类型 T 与数组长度 N 都是一样 的,那么这两个数组类型是等价的,如果有一个属性不同,它们就是两个不同的数组类 型。

因为数组在定义时其类型和长度都是明确的,所以实际内存分配上,也是一块连续的,可容纳所有数据的内存。

1
2
3
4
5
6
7
8
9
10
11
12
// 数组的声明
var a [3]int // 默认初始化为int的零值
a[0] = 1

b := [3]int{1, 3, 5} // 声明同时初始化
c := [2][2]int{{1, 1}, {2, 2}} //多维数组

d := [...]int{1, 2, 3, 4, 5} // 不用写数组的长度

var e = [...]int{ // 稀疏数组
99: 39, // 将第100个元素(下标值为99)的值赋值为39,其余元素值均为0
}

Go 提供了预定义函数 len 可以用于获取一 个数组类型变量的长度,通过 unsafe 包提供的 Sizeof 函数,我们可以获得一个数组变量 的总大小

1
2
t.Log(len(d))  // 5
t.Log(unsafe.Sizeof(d)) // 40

切片slice

切片的定义和数组很像,仅仅是少了一个“长度”属性。切片的存在是为了解决数组的问题,数组长度固定,很不灵活。

1
var myslice = []int{1, 2, 3, 4, 5, 6}

使用内置的append函数添加元素

1
myslice = append(myslice, 100)

slice的实现

slice的底层结构是

1
2
3
4
5
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片的长度,即切片中当前元素的个数;
cap int // 底层数组的长度,也是切片的最大容量,cap 值永远大于等于 len 值
}

每个新建的slice都会新建一个底层数组。数组的长度和切点初始元素的个数相同。

我们还有其他方法创建切片。

  1. 通过make 函数来创建切片,并指定底层数组的长度
    1
    2
    c := make([]int, 3, 5) // 切点的len是3,cap是5,即底层数组的长度是5.如果不指定。默认cap = len
    t.Log(len(c), cap(c))
  2. 在已有数组的基础上创建切片

    采用 array[low : high : max]语法基于一个已存在的数组创建切片。这种方式被 称为数组的切片化

    1
    2
    3
    4
    5
    6
    func TestArr2Slice(t *testing.T) {
    month := [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
    slc := month[3:6:9]
    t.Log(slc) // [Apr May Jun]
    t.Log(len(slc), cap(slc)) // 3 6
    }

    len = high - low

    cap = max - low。通常省略max,max默认是数组的长度

    注意1,现在这个切片slc是直接指向数组month的。也就是说slc的改变会直接改变数组month

    1
    2
    slc[0] = "4月"
    t.Log(month) // [Jan Feb Mar 4月 May Jun Jul Aug Sep Oct Nov Dec]

    注意2,对一个数组可以创建多个切片。因为这些切片底层都是指向数组的。所以任意一个切片的改变都会影响其他切片。

    1
    2
    3
    4
    5
    6
    slc2 := month[3:6]
    t.Log(slc2) // [4月 May Jun]
    t.Log(len(slc2), cap(slc2)) // 3 9
    slc2[1] = "5月"
    t.Log(month) // [Jan Feb Mar 4月 5月 Jun Jul Aug Sep Oct Nov Dec]
    t.Log(slc) // [4月 5月 Jun]
  3. 基于切片创建切片

    用法和基于数组创建切片一样,底层指向同一个数组,所以互相影响。

slice的动态扩容

slice相比array的特点就是不定长。当len == cap时,再对slice进行append,就会发生切片的动态扩容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 切片的容量是翻倍增加
func TestSliceGrowing(t *testing.T) {
s := []int{}
for i := 0; i < 20; i++ {
s = append(s, i)
t.Log(len(s), cap(s))
}
}
结果打印:
slice_test.go:49: 1 1
slice_test.go:49: 2 2
slice_test.go:49: 3 4
slice_test.go:49: 4 4
slice_test.go:49: 5 8
slice_test.go:49: 6 8
slice_test.go:49: 7 8
slice_test.go:49: 8 8
slice_test.go:49: 9 16
slice_test.go:49: 10 16
slice_test.go:49: 11 16
slice_test.go:49: 12 16
slice_test.go:49: 13 16
slice_test.go:49: 14 16
slice_test.go:49: 15 16
slice_test.go:49: 16 16
slice_test.go:49: 17 32
slice_test.go:49: 18 32
slice_test.go:49: 19 32
slice_test.go:49: 20 32

可以看到slice的容量cap时翻倍增加的。

动态扩容导致的与原数组的分割

前面说了,切片可以从数组创建。切片的修改会直接修改原数组。但是,切片是可以继续追加元素的,那么切片追加元素超出了原数组的最大边界会怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func TestArr2SliceOut(t *testing.T) {
month := [12]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
slc := month[3:6:9]
t.Log(slc) // [Apr May Jun]
t.Log(len(slc), cap(slc)) // 3 6
slc = append(slc, "7月")
t.Log(slc) // [Apr May Jun 7月]
t.Log(month) // [Jan Feb Mar Apr May Jun 7月 Aug Sep Oct Nov Dec]
slc = append(slc, "8月")
slc = append(slc, "9月")
slc = append(slc, "10月")
t.Log(slc) // [Apr May Jun 7月 8月 9月 10月]
t.Log(month)// [Jan Feb Mar Apr May Jun 7月 8月 9月 Oct Nov Dec]
}

还是用月份举例。slc切片出了4,5,6三个月。len是3,cap是6。

追加一个7月。此时还在slc的容量范围内,所以直接影响了原数组。

但是当追加到10月时,已经超出了slc的容量范围。此时会进行扩容。slc的扩容会创建一个新的数组,与原数组不在有关系。所以10月不会影响原数组。之后slc做的任何操作都与原数组无关。

同样的道理推广到多个切片指向同一个数组。当某一个切片发生扩容后,他便于其他切片不在指向同一数组,也就不会再相互影响了。

切片的扩容这里是经常埋坑的地方。

一定要清楚的认识到slice与底层数组的关系

继承

首先 Go 不支持经典的面向对象编程范式,但是我们也会看到go中存在继承这种说法。其实这里的继承只是习惯性的使用了继承这个词而已,真正表达的意思是一种组合的思想。

这种组合是通过go的类型嵌入实现的。

类型嵌入

类型嵌入指的就是在一个类型的定义中嵌入了其他类型。Go 语言支持两种类型嵌入,分别 是接口类型的类型嵌入和结构体类型的类型嵌入。

接口类型的类型嵌入

go中的接口定义

1
2
3
4
5
// 定义接口
type Programmer interface {
WriteHello() string
Dance() string
}

定义了一个接口Programmer,他有两个方法WriteHello和Dance。如果某个类型实现了这两个方法,我们就说这个类型实现了接口Programmer。

如果我们再定义一个接口

1
2
3
4
5
type GoProgrammer interface {
WriteHello() string
Dance() string
WriteK8S() string
}

接口GoProgrammer的方法集合中也有WriteHello和Dance,那么就可以用接口Programmer替换这两个方法。

1
2
3
4
type GoProgrammer interface {
Programmer
WriteK8S() string
}

这就是接口类型的类型嵌入。

并且这两种写法的GoProgrammer是等价的。也就是说方法的声明会组合到一起。

这种写法有什么好处呢?

按 Go 语言惯例,Go 中的接口类型中只包含少量方法,并且常常只是一个方法。通过在接 口类型中嵌入其他接口类型可以实现接口的组合,这也是 Go 语言中基于已有接口类型构建新接口类型的惯用法。

实这也是 Go 组合设计哲学的一种体现。

结构体类型的类型嵌入

我们之前讲结构体的时候举过一个例子

1
2
3
4
5
6
7
8
9
type Reader struct {
ReaderName string
Age int
}

type Book struct {
BookName string
Reader
}

Book中的Reader就是一种结构体类型的类型嵌入

这种情况下,Reader的方法会被提升为Book的方法,成为Book方法集合的一部分。(之前的例子是属性,方法也是一样的原理)

1
2
3
4
reader := Reader{"yunsheng", 20}
book := Book{"禅与摩托车维修艺术", reader}
t.Log(book.Reader.ReaderName)
t.Log(book.ReaderName)

可以省略嵌入的Reader字段,而直接访问ReaderName

这也是go语言组合设计思想的一种体现。更具体点说是组合中的代理模式。由Book的ReaderName代理了Book.Reader.ReaderName

如果Book和嵌入的Reader有同名的方法,通过book调用时,先查找Book是否有该方法,没有再查找嵌入的字段有没有该方法。