有段时间没有写编程相关的笔记了。这篇本来是打算作为条目写在月刊的「编程历程」里,但越写越多…… 干脆作为学习笔记发出来好了。其实应该写到学校博客上,但都写到这儿了,还是算了。之后翻译翻译再发到那儿。
Golang 学习笔记・壹:基础知识直到 struct
有段时间没有写编程相关的笔记了。这篇本来是打算作为条目写在月刊的「编程历程」里,但越写越多…… 干脆作为学习笔记发出来好了。其实应该写到学校博客上,但都写到这儿了,还是算了。之后翻译翻译再发到那儿。
我复习 Python 的一大原因是申请的一岗位要求做 Python 的 coding assessment,另是感觉自己确实对 Python 的理解不够透彻。
这次则是因为有猎头联系我,问我是否有意申请 Golang 开发的岗位,并且可以无视我的工作经验不足一点⸺他们居然都要求五年以上的工作经验!要是没有初级岗位的话,怎么才能攒够这么多工作经验呀!我的一到两年工作经验还是靠创业攒出来的。
之前我还是读过 Golang 的代码的,易读性让我想起了 Python 还有 JavaScript,所以不难读。不过要理解编程语言,不能只因为易读性所以就轻看它。
我用的教材依然是 freeCodeCamp 里的:
https://www.freecodecamp.org/news/learn-golang-handbook/
我意识到高中的时候我买了个国内的服务,叫路飞学城的黑金卡,差不多五年内都会陆续放编程语言或者框架的教程视频吧。但我一直都没有去看它。上次看还是学习网页爬虫的时候。唉,消费不代表学习。尤其是我买该服务的那年是 2023 年。年底 ChatGPT 出现,我的编程历程也被暂停了。悲哀。
如果有人感兴趣,我可以将账号密码分享给你,反正它也没法改密码啦。不过课程质量我只能说和 BiliBili 上面的免费录播课差不多。
基础知识
网站上第一个视频,也就是 9 个小时的那个。开头二十分钟都是在解释 Python 那样的解释型语言以及 Go 这样的编译型语言的区别。
总之先自己抄个 Go 代码好了:
1 | package main |
关于静态类型,我最初接触的 Python 还有 JavaScript 都是动态类型,后者还是弱类型、会做自动转换类型这种蛮讨厌的事情。
最开始我还觉得这是件不错的事情咧,结果后面还是乖乖入 TypeScript。我真的没法理解当时认识的网友们是如何靠 JavaScript 写大项目的。
上述的代码就运行不了,因为字符串和整数类型不能加在一起。
其实我才知道 Java 虚拟机是真的虚拟机。我以为是个修辞呢。我对 Java 一窍不通。我只知道我在行的 NestJS 框架是基于 Spring Boot 的架构设计出来的。
关于内存清理、垃圾收集什么的,因为之前一直都是个臭写 Python 的,根本不需要管它们,所以完全是初次接触。
Go 和 Java 有点像,会自动收集垃圾。不过它没有 JVM,有的是运行时。
Dexter Darwich 比对了下 Java、Go,还有 Rust 服务在闲置时会吃掉多少内存,结果 Go 和 Rust 的数字都在 1MB 以下,Java 单个干到了 162MB⸺不过视频里的图表没有说清楚条件、时间等因素。总之要知道的就是 Go 没有 Java 那么吃内存,但不如 Rust。
频道主也解释说在真实的情况下,Go 服务不可能真的比 Java 服务少吃 100 倍的内存。它绝对是比 Java 服务少吃不错,但没有图表中的那么夸张。
他没有解释「运行时」是什么,所以我自行搜索了下,因为我平时写的 TypeScript 还是要靠 Node.js 运行时才能变成网络服务的嘛。
我就引用王译锋的知乎回答好了:
https://www.zhihu.com/tardis/bd/ans/2133648600
实际上编程语境中的 runtime 至少有三个含义,而目前的回答都只侧重讲了其中的某一个,所以看起来很令人困惑。这几个含义分别可以这样概括:
- 指「程序运行的时候」,即程序生命周期中的一个阶段。例句:「Rust 比 C 更容易将错误发现在编译时而非运行时。」
- 指「运行时库」,即 glibc 这类原生语言的标准库。例句:「C 程序的 malloc 函数实现需要由运行时提供。」
- 指「运行时系统」,即某门语言的宿主环境。例句:「Node.js 是一个 JavaScript 的运行时。」
那 Go 运行时是什么哩?根据官方 FAQ 的说法:
https://go.dev/doc/faq#runtime
Go has an extensive runtime library, often just called the runtime, that is part of every Go program. This library implements garbage collection, concurrency, stack management, and other critical features of the Go language. Although it is more central to the language, Go's runtime is analogous to
libc, the C library.It is important to understand, however, that Go's runtime does not include a virtual machine, such as is provided by the Java runtime. Go programs are compiled ahead of time to native machine code (or JavaScript or WebAssembly, for some variant implementations). Thus, although the term is often used to describe the virtual environment in which a program runs, in Go the word "runtime" is just the name given to the library providing critical language services.
Go 拥有一个庞大的运行时库,通常简称为「运行时」,它是每个 Go 程序的一部分。这个库实现了垃圾回收、并发、栈管理以及 Go 语言的其他关键特性。尽管它在语言中的地位更为核心,但 Go 的运行时类似于 C 语言库
libc。然而,重要的是要理解,Go 的运行时并不包含像 Java 运行时提供的那种虚拟机。Go 程序是提前编译成原生机器码的(或者在某些变体实现中编译为 JavaScript 或 WebAssembly)。因此,尽管「运行时」一词通常用于描述程序运行的虚拟环境,但在 Go 中,「运行时」只是为提供关键语言服务而命名的库。
所以 Go 运行时更倾向于是第二类的「运行时库」。和 Node.js 运行时更像的其实是 Java 的虚拟机。
类型
到视频的 27 分钟,终于是开始讲类型了!Go 的基础类型有:
boolstringint、int8、int16、int32、int64uint、uint8、uint16、uint32、uint64、uintptrbyterunefloat32、float64complex64、complex128
uint 是 unsigned integer 的意思。之前学习 C 语言还是 C++ 的见过,但不记得是干什么使的了⸺频道主说 uint 只有正数,没有负数。
complex64 还有 complex128 用来代表虚数的。我的数学已经还给高中老师了,所以你要问我虚数是什么,我也不知道!我也不会使用这两个类型的!大概!
后面带数字的类型,那些数字指的是比特(或者位元,总之是 bit)大小。例如 uint8 类型的数据大小是 8 比特,那么能存储的最大的数字是 255。
byte 类型其实是 uint8 的别名;rune 类型其实是 int32 的别名。
虽然说是 short variable declaration,但这个 := 怎么看怎么是 Python 的海象运算符啊!不过我自己搜了下,并不是同个东西,莫名失望了一下。
Go 的情况是,以下两个语句有着相同的功能:
1 | var empty string |
它能够声明新的变量,并且初始化它。
Python 的海象运算符的作用是在表达式内部给变量赋值,并且返回该值。更重要的是它是个表达式,所以可以出现在 if、while 条件里。例子:
1 | if (n := len(data)) > 10: |
回到 Go 的类型。要打印变量的类型可以这样写:
1 | fmt.Println("The type is %T", x) |
频道主建议说,除非真的需要关心比特大小的话,用 int、uint、float64,还有 complex128 即可。
其实看到这里,我感觉自己看视频就略显啰唆了些。所以我打算直接看 handbook 的文字内容。等我在某个概念上卡住了,再回去看视频好了。
Go 的常量:
1 | const myInt = 15 |
它们被定义后就不能被改变了。
我本来想说它让我想到 JavaScript 的 const,结果视频里说没有。唉我又被表象欺骗了。
Go 的常量必须要在编译的时候就声明其值。也就是说,常量不能在代码运行时才决定自己的值。
频道主吐槽说 Go 格式化字符串的方式不如 JavaScript 还有 Python 的优雅。
Go 的做法有二:
fmt.Printf:这不是 C 语言的台词吗⸺吓,还真是。它将格式化的字符串打印到 STDOUTfmt.Sprintf():我有点怀疑设计师是不是想要拼个 sprint 出来。总之它返回格式化的字符串
它们俩都会用到格式化动词:
不确定用哪个格式的话,可以用 %v:
1 | s := fmt.Sprintf("I am %v years old", 10) |
%s 对字符串。
%d 对整数。
%f 对小数:
1 | fmt.Printf("I am %f years old", 10.512) |
我实在是不喜欢这种写法。我更习惯于 Python 的 f 字符串……
1 | shuang = "爽" |
条件
Go 的条件:
1 | if height > 6 { |
它实际上是:
1 | if INITIAL_STATEMENT; CONDITION {} |
所以可以这么写:
1 | if length := getLength(email); length < 1 { |
其实就是:
1 | length := getLength(email) |
函数
Go 的函数写法:
1 | func sub(x int, y int) int { |
很用心地,Go 的参数要是类型相同,就能这么写:
1 | func add(x, y int) int { |
频道主使用的平台 boot.dev 里提到,C 语言声明变量类型时会将类型放左、变量名放右。
Go 的开发者认为这很糟糕,因为:
1 | int (*fp)(int (*ff)(int x, int y), int b) |
这段代码声明了个指针 fp,指向一个函数。该函数的返回值是 int,并接收两个参数:第一个参数 ff 本身也是个指向函数的指针(接收两个 int 并返回 int);第二个参数 b 是 int。
它是由内向外的,也就是说阅读这段代码,需要从中间的 fp 出发,然后先看右边的括号、解引用。看到 ff 时又要重复一遍由内向外的解读。最后你才会看到最左边的 int,也就是 fp 的返回类型。
还有个例子是:
1 | int (*(*f)(int))[5] |
f 作为一个函数指针,返回「指向含 5 个 int 的数组的指针」。
我讨厌指针……
Go 则是名字在前、类型在后⸺想必读者朋友们也看出来了⸺阅读方法永远是从左到右。
作为比对。首先是 C 的写法:
1 | int x |
Go 的同样结果写法:
1 | x int |
我不太懂 C 语言,也不懂 Go,所以我就拿 Python 还有 TypeScript 做例子好了。它们正好也是站 Go 这边的:
1 | x: int |
怎么感觉 Python 这个写法也是莫名其妙。其实和 Go 的区别不大。
1 | let x: number; |
TypeScript 的读上去更好读些。因为有 => 可以表示后面的类型是返回类型,这样就能跟参数类型区别开来。
总之学回 Go。函数还有参数的用法:
1 | func main(){ |
猜猜看最终会打印什么咧?
答案是⸺
⸺ 还是 5。
Go 传参传的是复制品。到 increment() 的时候,其实出现了两个 x:一个在 main() 的世界,一个在 increment() 的世界。
所以 x++ 只是让在 increment() 世界的 x 加了一。main() 世界打印自己的 x,打印出来还是 5。
这个蛮好理解的。不过有趣的是 Python 里,如果传入的是可变对象,例如列表,在函数内进行 append() 操作,外部的列表就会有变化。
而 Go 是无论传什么都是传复印件。
这个话题应该是和引用、内存等概念相关的。我不是这块的专家……
Go 的赋值也是复制值。
1 | x := 5 |
找个空的内存地址然后塞入内容 5。
1 | y := x |
这里是将 x 的值复制了一份,放到了 y 的内存地址里。
1 | x = 10 |
如果改了 x 的话,就只有 x 的内存地址被改动。
如果希望函数能够直接改到原件,而不是简单改复印件的话,就要用到指针了。对于 Go,是这么写的:
1 | x := 5 |
& 会拿 x 的地址,然后存在 p 里面。然后我们可以用 *p 来顺着地址找到 x 的内存。
1 | func increment(p *int) { |
这样就能改变 main() 世界的 x 的值了。
无视返回值的做法和 Python 的很像:
1 | func getPoint() (x int, y int) { |
这很重要,因为 Go 编译器会因为你没有使用定义好的变量而报错⸺非常罕见地!很多编程语言只会将其视为警告。
Go 可以命名返回值:
1 | func getCoords() (x, y int) { |
不过这样写只适合给短小的方法。代码一旦长起来,这么写读起来就特别难受。你总不想读到最后然后发现代码没有写明会返回啥,然后扑哧扑哧跑回签名去看吧。
比较有趣的是,写在返回签名里的变量是已经被命名好的。所以:
1 | func calculator(a, b int) (mul, div int, err error) { |
对比这样写要好很多:
1 | func calculator(a, b int) (int, int, error) { |
因为前者可以直接从签名得知返回值的内容。
Go 有个概念叫作 guard clauses,即逻辑应当被写成一维的,而不是层层嵌套的。作为(噩梦般的)反例:
1 | func getInsuranceAmount(status insuranceStatus) int { |
这是人类写的代码吗。
等下,我笑不出来了。我校确实用的是类似的 if else 架构来写学生数据的。你知道我当时爬数据包看到六十万字符的内容有多么绝望吗:他们特么地为了让前端显示学生的兼修是什么,居然将所有的兼修都塞到一个 if else 逻辑内,然后让前端一个个查询该兼修的值是真还是假。如果假,就进到下个嵌套继续查。而同样的逻辑,在这一整个学生信息数据包内,出现了至少四次!
更绝望的是当时我在写浏览器插件,需要解析这个数据包并抽取所有有用的值出来,可以让学生们更快更方便地看到自己的数据、需要修什么课才能毕业…… 就因为这个数据包的设计,我放弃继续开发该插件⸺去你的吧!
Guard clauses 要求的写法是:
1 | func getInsuranceAmount(status insuranceStatus) int { |
这么写无论是哪个编程语言都是个好习惯。
不过对于这样的例子,就不适合用 guard clauses 了:
1 | func discount(c Customer, amount int) int { |
规则一和规则二的区别只是「有没有优惠券」而已,但读者要自己意识到第一个 if 拦走了带券的 VIP,所以第二个 if 到这里的必然是无券 VIP。
同时把它们对调的话,行为就错了,但是代码没有任何提示。
这时候用 switch 更好些:
1 | func discount(c Customer, amount int) int { |
还有这样的例子,写成 guard clauses 或者嵌套都很奇怪:
1 | func shippingFee(w, v int, remote, urgent bool) int { |
我如果要维护的代码都是这个样子,我会大哭出来的。
如果改成嵌套写法,我会哭得更大声。
正解是将两个独立的维度各自拆成函数,然后组合起来:
1 | func baseFee(w, v int) int { |
struct
struct 这个东西其实就是把不同类型的值打包在一起、起个名字,当作新的类型用。
也就是 TypeScript 里的 interface:
1 | interface Car { |
换成 Go:
1 | type car struct { |
我搜了下,发现 Python 也有类似的写法,只是我对 Python 的理解还没有到那儿:
1 | from dataclasses import dataclass |
Go 是这样用 struct 的:
1 | myCar := car{ |
如果 Go 的世界没有 struct 的话,假设我要在函数之间传递「车」的信息:
1 | func register(make string, model string, height int, width int, owner string) {} |
万一我想要给「车」的信息多加个「它有多少个轮子」咋办?我要给每个以「车」的信息作为参数的方法都改一遍么?
有 struct 的话,直接这样写就好了:
1 | func register(c car) {} |
不过这个概念总感觉很久之前我就听说过,一查,果然是 C 语言的东西:
1 | struct car { |
C++ 则在 C 语言的基础上,加上了方法、继承、访问控制,直接变成了类:
1 | struct Car { |
说回 Go 吧!
一个 struct 的字段可以是另一个 struct 类型,也就是套娃、嵌套:
1 | type Wheel struct { |
用法也是用点点一层层往下钻:
1 | myCar := car{} |
JavaScript 里的 config.server.port、Python 的 obj.attr.nested 都是这样的。好学。
Go 嵌套后,复制是递归的,所以 car 被复制后,里面的两个 Wheel 也会整个跟着被复制:
1 | a := car{FrontWheel: Wheel{Radius: 5}} |
这点倒是和 Python 还有 TypeScript 不同。它们对象嵌套时内层是共享引用,所以 b=a 后再改 b.front_wheel.radius 的话会影响到 a。
但是 Go 的 struct 是值语义,所以就不用学 copy.deepcopy 的坑。想要共享的话就乖乖显式用指针字段,或者传 &myCar 吧。
频道主还说过,Python 用字段的地方换成 Go 可以无脑用 struct 写。
Go 有匿名 struct,即在定义类型的同时直接造出一个值,这样类型本身就不用再留个名字了:
1 | myCar := struct { |
第一对花括号是类型定义,第二对是值。这种写法是一次性的。
我们还能这么写:
1 | type car struct { |
不过这意味着 Wheel 这个类型在别处是无法被复用的。
TypeScript 里这种写法蛮常见的:
1 | const myCar: { make: string; model: string } = { |
在 Go 实战中,匿名 struct 最常见的家是 handler 里的 JSON:
1 | func getUserHandler(w http.ResponseWriter, r *http.Request) { |
这样写的好处是,数据的形状和它的唯一用处在同一屏代码里,读者不需要跳转到别处的类型定义。
它也可以让后来的开发者没法偷懒引用它。如果哪天真的需要第二次使用,那你就显式起名吧,然后认真考虑它该叫什么、放哪个包里面。
一次性的东西就该长着一次性的样子!
Go 虽然没有继承,但会用嵌入替代它:
1 | type truck struct { |
嵌入的规则很简单,那就是只写类型。
普通嵌套写完后,消费它要用点点一层层钻,如 myCar.FrontWheel.Radius 对吧?
但是嵌入的字段会被提升到顶层,我们就可以这样写 lanesTruck.make,不需要再写 lanesTruck.car.make。
1 | lanesTruck := truck{ |
在效果上,truck 看上去继承了 car 的所有字段,但在内存上它和普通嵌套完全一样,所谓的「提升」仅仅只是访问上的语法糖啦。编译器看到 lanesTruck.make,发现 truck 自己没有,就自动转译成 lanesTruck.car.make。
因为只是访问上的语法糖,所以在构造时必须老老实实写上 car: car{ ... }。
不只是字段可以被提升,方法也可以:
1 | type car struct { |
明明 truck 没有写 describe() 方法,但就是可以直接调用它。
在 Go 里,给一个类型定义方法,会用 func (接收者) 名字 () 的形式挂在外面。
(c car) 的部分叫作 receiver,意思是「这个方法属于 car 类型,方法内部用 c 指代调用它的那个 car」。之后任何 car 值都能调用啦。
我们从头到尾都不用写 func (t truck) describe(),但依然可以正常运行 lanesTruck.describe()。truck 完全不需要付出任何代码,就能获得 describe() 的使用权。
原理也很简单,就是刚才说的「提升到顶层」:编译器发现 truck 自己没有 describe(),于是去查它嵌入的类型 car 有没有⸺嘿!还真有,那就给你转译成 lanesTruck.car.describe() 好啦。
Python 的继承可以实现同样的效果。不过 Go 的不是继承。对于 Python,父类代码和子类是互相知晓的。
但对于 Go,describe() 的接收者 c 永远只是一份 car。它不知道,也无法知道自己是被装在 truck 里的。
视频的 1:34:40 左右开始讲第六章节 interface。