0%

【Golang 之旅】01 语言简介

作为系列的第一篇,本文会介绍 Golang 的基本概念和语法,后续会逐渐引入更多更深入的内容。


更新历史

  • 2020.09.25: 开始撰写
  • 2020.10.10: 完成第一版

Golang 简介

  • 2007 年末,由Robert Griesemer, Rob Pike, Ken Thompson 主持开发,后来还加入了Ian Lance Taylor, Russ Cox等人
  • 2009 年 11 月开源
  • 2010 年谷歌投入使用
  • 2011 年被 Google AppEngine 支持
  • 2012 年正式版 Go 1.0
  • 2015 年发布 Go 1.5,移除了“最后残余的C代码”。这个版本被认为是历史性的。完全移除 C 语言部分,使用 Go 编译 Go,少量代码使用汇编实现。GC 也重新设计,支持并发GC,解决了一直以来广为诟病的GC时延(STW)问题。
  • 最新版本 Go 1.15 (2020.08.11),具体的发布历史查看 这里

开发者基本都是神级人物

  • 肯·汤普逊(Ken Thompson):设计了 B 语言和 C 语言,创建了 Unix 和 Plan 9 操作系统,1983 年图灵奖得主,Go 语言的共同作者。
  • 罗布·派克(Rob Pike): Unix 小组的成员,参与 Plan9 和 Inferno 操作系统,参与 Limbo 和 Go 语言的研发,《Unix编程环境》作者之一。
  • 罗伯特·格里泽默(Robert Griesemer):曾协助制作 Java 的 HotSpot 编译器和 Chrome 浏览器的 JavaScript 引擎V8。
  • 布拉德·菲茨帕特里克(Brad Fitzpatrick):LiveJournal 的创始人,著名开源项目 memcached 的作者。

核心设计思想

  • Less is More,大道至简
  • 专注于工程需要而非语言研究,为软件工程服务
  • 完整性、可读性和简单性
  • 如果说 C++/Java 是关于类型继承和类型分类的,Go 就是关于组合的

优势和劣势见仁见智,这里我就挑一些个人最在意的点简单说明一下:

  • 优势
    • 语言层面天生支持并发
    • 丰富的标准库 + 完整的工具链,简单直观,没有那么多套路
    • 部署简单,直接就是一个二进制文件
  • 劣势
    • 依赖管理和包管理
    • 不支持很多在其他语言中习惯的用法,如泛型、静态变量等

适用场景

  1. 服务器编程
  2. 分布式系统
  3. 网络编程
  4. 内存数据库
  5. 云平台

其他更多资讯请参考 Documentation,这里不再赘述。

思考题合集

这里整理了整篇文章中的各个思考题,方便大家查漏补缺,对应实验代码放在这里,大家可以自行查看:

  1. 没有初始化的变量,默认值是什么?
  2. 能否准确给出包含 iota 的代码的执行结果?
  3. 如何通过 switch 语句来判断变量类型?
  4. 如果 switch 后没有表达式,具体执行哪个 case?
  5. 你能准确给出包含 select 语句的代码执行结果吗?
  6. 你能准确给出包含 select 语句的代码执行结果吗?(powered by jiasen)
  7. 传引用和引用类型一样吗?你能说出下面代码的执行结果吗?
  8. 如何通过传入函数实现回调?
  9. 你能准确说出以下包含闭包的函数执行结果吗?
  10. 你能说出切片的扩容逻辑吗?
  11. 你能说出上面类型转换的最终结果吗?
  12. 你能给出下面并发代码的执行结果吗?
  13. 给你一个数组,你能通过 channel 用 2 个 goroutine 完成求和计算吗?
  14. 你能通过 range 和 channel 打印出斐波那契数列吗?

基础语法

这里简单整理 Golang 的基础语法,可以当做一个简单的手册快速查询。这部分以实战为主,会省略很多具体的概念,建议大家先找个教程过一遍,实际编码的时候再看这部分。

数据类型

对应类型会在后面详细介绍,这里只列举与简单说明:

  • 布尔型 true/false,例如 bool := false
  • 数字类型
    • 整型 int: unit8/uint16/uint32/uint64/int8/int16/int32/int64
    • 浮点型: float32/float64/complex64/complex128
    • 其他类型: byte(类似 uint8), rune(类似 int32), int/uint(32 或 64 位), uintptr(无符号整型,用于存放指针)
  • 字符串类型:由单个字节链接起来,默认为 UTF-8 Unicode
  • 派生类型:指针/数组/函数/struct/channel/slice/interface/map

变量与常量

直接看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 声明变量
var item int
// 声明多个变量
var item1, item2 int

// 自动根据值生成变量
item1 := "wdxtub.com"

// 注:基本类型是值类型,指针等是引用类型

// 声明常量
const NAME string = "wdxtub"
// 常量枚举
const (
UNKNOWN = 0
SHENZHEN = 1
GUANGZHOU = 2
)
// iota 的用法,和前面是等价的
const (
UNKNOWN = iota
b
c
)

思考题 1:没有初始化的变量,默认值是什么?

对应代码为 1.go,实验结论如下:

  1. 字符串默认值为空
  2. 整型、浮点型默认值为 0
  3. 布尔型默认值为 false
  4. 指针、channel、func、接口都为 nil
  5. 数组默认为 []
  6. 字典默认为 map[]

思考题 2:能否准确给出包含 iota 的代码的执行结果?

对应代码为 2.go,实验结论结论如下,你答对了吗:

  1. 0 1 2 wdxtub wdxtub wdxtub 10086 10086 10086 9 10 11
  2. 1 2 4 16 32 64 分别对应 1 << 0, 1 << 1, 1 << 2, 2 << 3, 2 << 4, 2 << 5

运算符

运算这部分比较常规,简单列举一下,本节没有思考题:

  • 算数运算符:+ - * / % ++ --
  • 关系运算符:== != > < >= <=
  • 逻辑运算符:&& || !
  • 位运算符:& | ^ << >>
  • 赋值运算符:= += -= *= /= %= <<= >>= &= ^= |=
  • 其他运算符:&(取地址) *(指针)

条件语句与循环

if 语句

1
2
3
4
5
6
7
8
a := 10
if a % 3 == 0 {
fmt.Println('a 能被 3 整除')
} else if a % 3 == 1 {
fmt.Println('a-1 能被 3 整除')
} else {
fmt.Println('a-2 能被 3 整除')
}

switch 语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
a := 10
// 一种写法
switch a % 3 {
case 0:
fmt.Println('a 能被 3 整除')
case 1:
fmt.Println('a-1 能被 3 整除')
case 2:
fmt.Println('a-2 能被 3 整除')
}
// 也可以这么写
switch {
case a % 3 == 0:
fmt.Println('a 能被 3 整除')
case a % 3 == 1:
fmt.Println('a-1 能被 3 整除')
case a % 3 == 2:
fmt.Println('a-2 能被 3 整除')
fallthrough // 继续执行下一条语句
default:
fmt.Println('oh 到我了')
}

思考题 3:如何通过 switch 语句来判断变量类型?

对应代码为 3.go

思考题 4:如果 switch 后没有表达式,具体执行哪个 case?

对应代码为 4.go,结果为

1
2
3
2 - true
3 - false
4 - true

select 语句

select 语句主要用于控制通讯,要么是发送要么是接收,如果没有条件满足,则会一直阻塞(特定情况可以利用这样的特性,但一定要小心),具体语法为:

  • 每个 case 都必须是一个通信
  • 所有 channel 表达式都会被求值
  • 所有被发送的表达式都会被求值
  • 如果任意一个通信可以进行,就会执行并忽略其他
  • 如果多个 case 都可以运行,select 会随机选出一个执行

思考题 5:你能准确给出包含 select 语句的代码执行结果吗?

对应代码为 5.go,从结果中可以看到,在所有满足条件的 case 中会随机选一个执行。

思考题 6:你能准确给出包含 select 语句的代码执行结果吗?(来自小伙伴 jiasen 的一道题目)

对应代码为 6.go,这题综合了循环和 channel,是很好的题目

for 语句

这部分比较简单,直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 正常循环
sum := 0
for i := 0; i <= 10; i++ {
sum += i
}

// 无限循环
sum = 0
for {
sum += 220
if sum > 1000 {
break
}
}

函数

函数本身的定义比较简单,直接看代码就可以,重点在于把函数当做变量来处理的时候,这个后面会介绍。我们先来看看基本的函数,下面的函数会把两个数字加起来

1
2
3
4
5
6
7
8
// 函数定义
func add(num1, num2 int) int {
return num1 + num2
}

// 函数调用
a, b := 220, 314
c := add(a, b)

有且仅有值传递

首先要强调的是,Golang 中只有值传递,但是具体传的值,可以分为引用类型(指针/map/slice/chan/等)和非引用类型(int/string/struct/array/等)

对于函数的进一步了解,需要掌握的是值传递和引用传递,简单来说就是值传递的变量在函数中被修改后,原变量不变,而传引用的变量,原变量也会改变。

思考题 7:传引用和引用类型一样吗?你能说出下面代码的执行结果吗?

对应代码为 7.go,从结果中可以明确看到引用类型和非引用类型的差别

函数作为参数

我们可以把函数直接传给另一个函数,以实现更加灵活的代码编写,比如我可以写这样一个自定义打印函数:

1
2
3
4
5
6
7
8
9
func main() {
// 函数变量
customStr := func(content string) {
return "[wdxtub.com]" + content
}

// 使用函数
fmt.Println(customStr("Hello World"))
}

思考题 8:如何通过传入函数实现回调?

对应代码为 8.go,掌握了这个部分之后,就可以开始继续学习闭包了。

闭包

熟悉了如何把函数当做参数传入,就可以通过闭包(匿名函数)的方式实现一些特定的功能,闭包给访问外部函数定义的内部变量创造了条件,将关于的函数的一切封闭到了函数内部,减少了全局变量。

比如,我们可以通过闭包非常方便地实现不同等级的日志打印,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type logFunc func(format string, v...interface{})

// Logger 包装
func LoggerWrapper(logType string) logFunc {
return func(format string, v...interface{}) {
fmt.Printf(fmt.Sprintf("[%s] %s", logType, format), v...)
fmt.Println()
}
}

// 具体使用
func main() {
infoLogger = LoggerWrapper("INFO")
debugLogger = LoggerWrapper("DEBUG")
errorLogger = LoggerWrapper("ERROR")

infoLogger("Get a %s log", "info")
debugLogger("Get a %s log", "debug")
errorLogger("Get a %s log", "error")
}

思考题 9:你能准确说出以下包含闭包的函数执行结果吗?

对应代码为 9.go

结构体

主要用于面向对象的封装,比较灵活,直接看代码

1
2
3
4
5
6
7
8
9
10
11
type Book struct {
title string
author string
date string
}

// 使用
func main() {
book := Book{"《今天是周末吗》", "小土刀", "2020-09-29"}
fmt.Println(book.title)
}

数组与切片

Golang 中的数组是有固定长度的,一旦创建了就不允许改变,空余位置用 0 填补并且不允许越界。但是实际使用的时候,固定长度的数组不是很方便,所以我们一般都用切片来操作。关于数组我们来看一段代码,然后主要来介绍切片。

1
2
3
4
5
6
7
8
9
// 一维数组
a := [3]int{1,2,3}
b := [...]int{1,2,3,4}

// 二维数组
a := [2][3]int{
{1, 2, 3},
{2, 3, 4}
}

切片的底层结构如下,是一个包含指向数组的地址和数据元素的结构体:

1
2
3
4
5
6
7
8
9
type slice struct {
array unsafe.Pointer // 指向数组的指针
len int // 切片中的元素数量
cap int // array 数组的总容量
}

// 深浅拷贝的差别
copy(sliceA, sliceB) // 深拷贝
sliceA = sliceB // 浅拷贝

因为切片的底层是数组,当容量不够的时候,是需要进行扩容的。

思考题 10:你能说出切片的扩容逻辑吗?

对应代码为 10.go

通过 append 扩容的时候,指针会发生变化,原切片不受影响。在 1024 大小之前每次扩容会翻倍,之后会按照 1.25 倍扩容。

Range 与 Map

range 可以用于遍历 array/slice/channel/map,在 array/slice 中返回索引和对应值,在 map 中返回 key-value 对。具体代码如下:

1
2
3
4
5
6
7
8
9
names := []string{"wdx", "tub", "wdxtub"}
for _, name := range names {
fmt.Println(name)
}

nameDict := map[string]int{"wdx":1, "tub":2, "wdxtub":3}
for k, v := range nameDict {
fmt.Println(k, v)
}

range 相对来说比较清晰,这里我们重点看一下 map 及其底层实现。map 的基础用法如下:

1
2
3
4
5
6
7
nameAgeMap := map[string]int{}
nameAgeMap["wdxtub"] = 30
nameAgeMap["yiyi"] = 1
nameAgeMap["snail"] = 20000

// 删除 key
delete(nameAgeMap, "snail")

在目前最新的 go 1.15 中,map 的实现在 src/runtime/map.go 文件中,这里我们只简单了解一下 map 的底层结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Go map 类型的结构定义
type hmap struct {
count int // 包含元素的个数,调用 len() 会返回这个值
flags uint8 // 状态标识
B uint8 // 可以最多容纳 loadFactor * 2 ^ B 个元素,这里 B = log_2 buckect 数量
noverflow uint16 // 近似的溢出 bucket 个数
hash0 uint32 // 哈希种子

buckets unsafe.Pointer // 桶的地址 array of 2^B Buckets,count 为 0 时值可能为 nil
oldbuckets unsafe.Pointer // 旧桶的地址,用于扩容
nevacuate uintptr // 迁移进度,小于该值的 bucket 已经迁移成功

extra *mapextra // 可选,用于保存溢出桶的地址
}

更加具体的说明可以参考 这里,虽然代码版本较老,但是核心思想没有变化。

类型转换与错误处理

类型转换和其他强类型的语言非常类似,直接加类型,具体如下:

1
2
3
4
5
6
7
8
9
10
11
oneInt := 17
oneFloat := 17.2

int2Float := float32(oneInt)
float2Int := int(oneFloat)

fmt.Println(int2Float)
fmt.Println(float2Int)
oneFloat = 17.6
float2Int = int(oneFloat)
fmt.Println(float2Int)

思考题 11:你能说出上面类型转换的最终结果吗?

对应代码为 11.go,答案是 17 17 17,并没有四舍五入。

错误处理是 Golang 里我最喜欢的部分,简单直接,不搞什么花里胡哨的,通过内置的错误接口来处理,不搞什么 try catch,定义如下:

1
2
3
4
// 只要返回 string 就可以,后面可以根据 string 来判断状态
type error interface {
Error() string
}

并发与通道

Golang 在语言层面支持并发,使用也非常简单,只需要在函数前加上 go 关键字,就会开启 goroutine 执行,同一个程序中的所有 goroutine 共享同一个地址空间。

思考题 12:你能给出下面并发代码的执行结果吗?

对应代码为 12.go,答案是什么都不输出,因为需主进程在 goroutine 执行之前就结束了,所以我们一般会通过 channel 来控制并发!

思考题 13:给你一个数组,你能通过 channel 用 2 个 goroutine 完成求和计算吗?

对应代码为 13.go,我们可以利用两个 goroutine 分别计算前半和后半部分,并最终加起来。

通道还有一个用法就是和 range 搭配进行遍历,可以实现类似 python 中 yield 的效果,大家可以通过下面的思考题尝试一下

思考题 14:你能通过 range 和 channel 打印出斐波那契数列吗?

对应代码为 14.go

写在最后

至此,Golang 语言层面相关的内容基本介绍完毕,接下来会基于这些基础知识,深入剖析经典的项目与设计。