第一篇介绍了Go的最基本语法, 第二篇接着说一下Go剩下的重要语言特性, 但不包括
go并发, 第三篇单独说
0x00. 结构体和指针
1. 指针
很多人看到指针, 终于真正想起被 C支配的恐惧了, 不过与C不同, go的指针可以理解为大幅简化+限制版的C指针, 没有指针运算,
- 引入简化和限制后的指针个人觉得是好事
var *type代表指向Type类型的指针, 存储对应值的内存地址, 其初始值是nil- 有
&符号地址访问符,用来表示内存中的地址值 (e.g:0x1024) - 定义时, 指针
*挨着类型, 而不是变量 - 总的来说, Go中变量就可以按存值类型分为两种
- 普通变量: 存数据本身, 比如
1,'a',0.618 - 指针变量: 存储的是某个值对应的内存地址(映射)
- 普通变量: 存数据本身, 比如
1 | func main(){ |
然后进一步会想, 指针有哪些初始方式, 大体有三种:
- 先定义普通变量, 然后
&取出对应值的地址, 初始化指针 - 使用
new(type)先分配内存, 然后再传入地址, 这里new()只是一个语法糖函数, 无需多想. var p *type声明一个空的指针, 后续再给它传入地址,
1 | // demo1, 并思考注释解开后输出 |
Go的指针简化了许多, 不过后续还是有一些需要注意的地方:
- 指针数组(尽量不用)
- 传递指针到函数
- slice(切片)与指针
- Go传递值和传递引用
可以看一个思考: Go指针的一个坑
2. 结构体
说完指针再看结构体, 除了必须type修饰, 其他还不错:
- 允许简式部分初始化
Node{a:1, b:2}, 初始化结构体更简单 (构造函数的语法糖) - 自动解引用, 通俗说就是假设node是一个结构体的指针, 无需
(*node).a, 可以直接node.a - 实现结构体的复用/继承, 在Go中更为松散, 直接把结构体B写到 结构体A中即可, 所以也常被大家说成是一种”组合“
1 | // 两个结构体的组合 |
总的来说Go的结构体比较够用, 又比C要简便和统一, 配合快速初始化的语法糖, 还算讨喜, 开始简单知道核心就行, 之后指针和结构体多用一些就熟了.
0x01. 数组和集合
1. 数组
数组在官方教程里引入较晚, 而且单独说数组也没什么特别的, 原生数组长度也是固定的, 注意数组大小放的位置在前即可 (var arr [length]int)
1 | func main() { |
- 注意多维数组
- 数组传递给函数时候的使用
- 要修改数组的值, 有两个方式 (代码↑)
- 传递指针, 不推荐, 因为需要指定数组长度
- 传递切片, 无需指定数组长度. (推荐)
2. 切片(slice)
切片本质是数组的一个引用, 有点类似Python中的list, 或是弱化版C++的Vector ,比裸用数组更方便一些, 无需单独声明/引入包, 也很像数组指针的一个语法糖, 原理可参考官方blog, 建议进阶再系统看.
定义切片有三个方式:
- 从一个普通数组中截取全部或部分为切片
- 直接
var slice []Type就声明了一个指定类型的空(nil)切片, 或者不指定长度声明的数组slice := []int{} - 使用
make([]Type, size, cap)函数直接创建, 缺省的切片也就是空数组,初始化是nil,len/cap为0
补充: 关于make函数与new的区别
1 | // 1. 从数组中用[a,b]下标截取 |
然后有几个小的语法糖说一下:
- 如果你使用
a[x:y]的语法, 可以选择省略x和y,全省略a[:]则取数组a的全部元素 - 自带有
len()和cap()函数, 前者代表当前切片实际元素个数, 后者是最大元素个数, 前者 ≤ 后者 - 扩充切片使用
append(), 复制切片用copy(), 注意类似list, 强烈建议给切片初始化长度, 否则自动扩容可能严重影响插入性能 - 多维数组/切片请勿乱嵌套, 影响可读性.
1 | func main() { |
思考题:
Go的数组和切片引用表面看是比较好用的, 不过也暗藏一些容易出错的地方, 比如下面的例子, 推测一下输出, 以及为什么(易错)
1 | // 题1 |
所以, Go里虽然有一些看起来简便的语法糖, 但是个人建议还是遵循简单易读的原则, 而不是为了过分长度精简, 以免造成不必要的误会和黑魔法.
3. range
range 是Go的关键字之一, 提供了for - in类遍历数组和map的语法糖, 这里的区别在于多返回值: (你可以任选接收)
- 当遍历数组的时候, 除了返回值之外, 还返回数组下标
- 遍历map的时候, 返回Key-Value, 更好理解
1 | var arr = []int{1, 2, 3, 4} |
0x02. 小练习
描述如下:
实现一个图片x, y像素灰度生成函数, 它返回一个
uint8二度二维数组arr[dx][dy], 然后传入后得到一个图像, 计算x/y值的方式自行决定, 可尝试传入x和y的任意组合, 运行后会输出一个对应的图片
这里用两种for循环都可以, 推荐用普通for循环, 清晰易懂. 用range要稍微注意一下细节, 因为Go的二维数组配合range初始化并不太方便.
1 | // 你不能一次初始化二维数组, 你需要迭代.. |
0x03. map
map可以理解为是一个哈希表数据结构的语法糖, Go认为它使用很频繁, 直接封装为了关键字. 声明方式也比较简单 (这里不推荐用var, 麻烦)
- 简单的初始化函数内是
maps := map[keyType]valueType{k1:v1, k2:v2} - 或者使用make创建. 例如
maps := make(map[int]string) - 同大部分的Map设计一样,
key默认只能是基本类型, 可被直接hash的, 你传入一个特定对象, Go似乎不允许自己重写hashCode. - 增改直接用
maps[key] = value, 删除用delete(maps, key)内置函数, 注意读取/删除不存在的key, Go默认不会报错. 读不存在返回类型初始值 - 所以你不能用传统语言中的判断是否读为空来判断, Go这里给key提供了多返回值, 第二个单独表示key是否存在…
- 虽然提供了关键字语法糖, 但是它未自带
keys()/values()这样的函数, 你还是得通过range去遍历, 这点并不太友好.
1 | // 简单展示一下多返回值是否存在把, 并不太好用 |
这里还是用词频统计的练习来熟悉它
练习:
实现
WordCount, 简单说就是传入一个不定长的字符串, 返回一个单独的map, key是单词, value是出现次数. 运行后程序会告诉你是否通过了测试.可以直接使用切分字符串方法 –> strings.Fields
因为官方告诉了我们字符串的特点, 然后一键切分后, 思路是很简单的, 需要注意的是修改值的方式
1 | // 如果假设字符串按空格切分, 最简单的思路就是split(" ") |
0x02. 函数和闭包
1. 函数值传递
Go中支持把函数作为值传递, 同理, 函数值也就可以作为参数或者返回值. 这种思想源自FP(函数式编程), 函数是一等公民, 觉得引入也算不错, 会灵活许多, 不过如果不熟悉, 就别乱用了, 同样会引入不必要的复杂性和降低可读性.
1 | // f func(inType) outType 就是一个函数(看做一个整体) |
FP相关建议大家参考学习Scheme, 然后理解之后再多使用
2. 闭包
既然Go支持函数式传递值, 那么自然也支持另一个重要的特性就是闭包(closure: ['kloʊʒər]), 闭包本身就是一种函数值, 也就是你要时刻把它当一个函数来看
简单说,很多语言包括go是不能在函数里面声明另一个函数的. 都是在外层声明. 但是上面说了,go中是有函数类型的变量的, 这样就可以在一个函数中声明一个函数类型的变量. 此时的函数称为 闭包
1 | func adder() func(int) int { |
闭包通常分析原理的时候, 会看到一个对象逃逸的名词, 所谓逃逸说起来唬人, 实际就是说一个变量{a} 本来应该在方法体内就结束, 但是它因为某种方式, 在方法体外还能存在, 那就可以称之为对象逃逸, 而闭包这里就是放大了函数内的变量生命周期, 通过指针让它的地址可以在外层继续传递了.
一般可以不用, 同样建议学好Scheme 之后再看, 否则一知半解写的, 闭包理解透彻还是有一定难度的, 下面的Case初学可以跳过, 非主线:
1 | // 待补充 |
可参考这篇文章, 举了一些很典型的例子, 关键就是搞清楚变量生命周期如何传递, 加深理解看汇编的跳转.
练习: fibo闭包
补充下面的代码,实现fibonacci函数. 加深理解闭包, 当然如果理解不了就看看解答, 无需费时
1 | package main |
3. 方法
Go不同于传统OOP的语言, 方法必须依附于一个Class, 它对方法和函数进行了一个简单区分, 名字无所谓, 主要是理解为什么这样设计:
- 普通函数是包级别的, 不依赖结构体或其他成员, 直接可调用 (类似类静态方法?)
- 方法是类型(type)级别的, 常见的除了结构体外, 普通变量也可以有个独有的方法, 那就要想普通类型为什么需要独有方法呢?
看下面的例子
1 | type Vertex struct { |
是否传入指针主要取决于你是否需要修改值:
- 最常见的, 你需要修改引用的值, 比如修改数组的值, 修改结构体的成员
- 另外, 传递指针的好处在于效率. 它避免了重复的值传递拷贝 (具体区别多大?)
0x03. 接口和错误
1. 接口
这里的interface也可看做简版的面向对象接口设计. 只不过其他结构体不需要显式的实现, 也不是强制的, 声明接口: type Operate interface { method define }
接口本身的作用就不多说了, 本质就是一种先抽象, 再具化的思想, 现在一般也很推荐面向接口来编程, 这里我们关注一下两点:
- 如果无需显式声明, 如何实现接口, 实现一部分而不是所有的考虑?
- 这样设计有什么优点和弊端?
- 优点:
- 实现接口更简单, 更随性
- 无需实现接口所有方法, 更灵活
- 弊端:
- 但是这样是否实现一个接口的判断依据是? 变得不够清晰
- 意外实现了某个接口? 然后发现编译报错:) 潜在的二义性
interface{}的通用类型设计, 造成了许多void *类的一次接收, 随处强转的问题.
- 优点:
下面举一个更常见, 好理解的登录接口例子:
1 | // 声明一个interface, 两个方法 |
然后Go的接口里还有一些比较特殊的地方:
空的接口, 在Go中等同于一个C中的
void *, 或者简版Object的类型, 代表接收任意类型的值, 所以也就有接口数组这个说法, 这种设计见仁见智..Go中的接口可以理解为一种特殊值类型, 而不是单纯的关键字, 所以接口可以这样用:
- 作为普通类型定义,
var obj interface{} = "object" - 自然也可在方法中传递 (
func f(param interface{}) {...}
- 作为普通类型定义,
Go自动处理了接口方法的NPE调用,
nil.InterfaceMethod()在其他语言里都会抛出空指针异常, 但是Go中可以正常执行因为1和2的设计, 空接口又是一个任意类型的值, 那Go专门提供了语法糖断言来确认接口的具体类型, 同样返回两个值, 一个是原始值本身, 另一个是bool(费解?)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20func main() {
var i interface{} = "str"
// 等同于AssertTrue(i instance of string)
// 但是你必须得接收, 不能直接调用
s := i.(string)
fmt.Println(s)
s, ok := i.(string)
fmt.Println(s, ok)
// 返回初始值和false
f, ok := i.(float64)
fmt.Println(f, ok)
// 如果仅接受值, 则不满足直接抛出panic终止
// 这种类似和Go的map设计也是类似的
f = i.(float64)
fmt.Println(f)
}这里可以看出Go的设计总是比较灵活和巧妙, 但是又有些需要小心, 你如果写成
f, _ =i.(int32), 它也是不会终止或者有异常的, 写程序就需要注意.基于4的设计, Go又在
switch中支持了接口的类型判断, 只不过这里由于缺乏Object这种定义, 所以它又把type关键字传了进去, 个人觉得不是很统一
最后介绍一个Go自带的常用接口Stringers, 实现一个String() 方法, 我理解这很像是Java中简化的重写toString() 方法, 你通过实现String()来实现覆盖输出默认内容, 就不多说了, 直接看一下官方的小练习, 这个太简单, 就简单写了, 当然%d应该有更合适的选择, 就懒得管了:)
return fmt.Sprintf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3])
2. error (错误)
同上, 在Go中, error 的设计也是一个内置接口, 大部分的包都实现了它, 并且它也是一个关键字, 你通过重写Error()方法, 也就可以重写默认的错误处理逻辑.
其次, Go的error处理主要是类C的模式上, 只不过因为支持多返回值, 所以可以顺便处理异常, 但是这样有不少弊端:
- 无法直接溯源错误栈信息, 以至于你需要去到处加错误输出. 甚至于引入一个封装的库
- 由于通过nil是否满屏的
if err != nil, 这又有两个主要问题- 有一些错误, 类似
io.EOF我们认为是正常的结尾, 需要单独处理跳过 - 其他错误, 我们才认为是真的错误, 但是由于通关是否为空来判断, 导致很杂乱/不优雅
- 有一些错误, 类似
直接看小练习, 是基于我们之前的Sqrt() 做了一个改进, 我们之前正好说了不考虑负数, 这里就把负数当错误抛出.
1 | type ErrNegativeSqrt float64 |
3. IO
Go的目标场景是分布式/网络开发, 那么I/O 自然就是其中很常用的API, 在io包中提供了读/写的接口设计, 来看看有什么特别之处, 首先它包括了:
- 文件操作
- 网络操作
- 压缩 & 编码
- 加/解密
这里有个比较让人费解的是, EOF标记也被放在了error中, 但它实际是正常的结束, 这种让错误有二义性的做法, 个人是不太推崇的…(linus应该要暴怒了hah)
然后从这里开始, 建议至少使用VSCode来编写代码了, 因为大量的API你也不知道入参和返回值, 就别乱猜了.
1 | // Reader接口定义的Read方法, 传入byte切片 |
这也不多说, 直接来做两个练习, 主要说下第二个, rot13是老凯撒密码了, 简单说就是把每个字母后移13位加密, 然后解密就逆向前移13位就得到明文了, 这里的要求是用io.Reader 来重新实现. 这题没理解还是很有点易错的:
- 理解什么叫包装了一个Reader
- 如何使用包装后的XXReader
rot13的实现, 注意大小写边界
1 | // 第一个是实现一直循环发送'A'字符, 其实这题有点懵.. |
这里再补充一个Images接口练习, 然后就完结了, Image接口有三个方法:
ColorModel()推测是返回颜色类型, 比如RGB/十六进制之类的Bounds()返回矩形类型? 不明所以At(x, y int)应该是返回横纵坐标的点对应的颜色
然后再之前切片的时候, 做过一个可以显示一个图片的小程序, 当时传入x, y 返回一个二维切片, 现在改造升级一下, 直接返回image.Image , 不过说实话, 这个题我一开始真没看懂意思..但是简单说你至少把刚说的几个接口实现一下, 至于为什me这样实现我没细看, 因为主要还是用浏览器写, 建议先看看image-color-API文档, 不然你都不知道参数是什么类型..
1 | func (i Image) ColorModel() color.Model { |
0x04. 小结
Go的基础特性就差不多说完了, 这些设计给我的感觉总体是:
- 简洁, 但不一定简单
- 巧妙, 但暗藏危险
总体来说, 如果是一个有C/C++/Java基础的同学, Go作为替代Python/Java的代替, 我觉得还是比较合适的, 当然, 你能去研究一下Go的设计实现原理就更好, 关于Go的危险和不合理的设计, 你可以参考下面的第一篇文章, 我觉得写得很好.
下一篇来单独说Go的最关键的地方, Go原生的并发特性.
参考资料:
- Go good bad & ugly(Recommend)
- Go的缺陷-Github
- Go Tour (2nd)