Day 1:Go 基础语法------我是怎么建立 Go 语言模型的
起因:读不懂 K8s 源码
最近在啃 Kubernetes,Service、Ingress、kube-proxy 这些概念好不容易理出点头绪,想着去翻翻源码加深理解。结果一打开就愣住了------满屏的 Go。
不是说 Go 是门"简单"的语言吗?可我看的第一眼是这样的:
go
result, err := doSomething()
if err != nil {
return err
}
一个函数返回两个值?:= 是什么符号,长得像海象却不是 Python 那个?if err != nil 为什么三行就出现一次,跟复读机一样?
那一刻我想明白了:想真正搞懂云原生,Go 是绕不过去的一关。所以就有了这篇 Day 1。目标很朴素------能看懂 Go 代码,能写出几十行的小工具。写完这篇的时候,我希望能自己动手做出来「用户登录」和「文件统计」两个小东西。
下面是我建立 Go 语言模型的整个过程,包括我卡住、翻车、然后想通的地方。
一、Go 是怎么"跑起来"的
我学的第一件事不是语法,是 Go 的编译模型。因为搞明白这个,后面很多"为什么"才有答案。
它从哪来
Go 是 2009 年 Google 的三个人------Rob Pike、Ken Thompson、Robert Griesemer------搞出来的。Ken Thompson 就是写 Unix 的那位,也是 C 语言前身 B 语言的作者。他们当时的痛点很具体:Google 内部大规模用 C++,编译慢到让人抓狂,语法又复杂到新人上手困难。
所以他们想要的是一门:编译飞快、天生带并发、语法简单的语言。Go 的"编译型 + 单二进制"这些特点,就是从这三个诉求里长出来的,不是随便定的。
三个命令
bash
# 看版本,确认环境装好了
go version
# 直接跑一个文件(临时编译,不留下可执行文件,适合边写边试)
go run main.go
# 编译成可执行二进制(发布用)
go build
go run 和 go build 的区别我一开始老混。后来用个比喻就记住了:
go run像试菜 ------厨师炒一口让你尝尝味道,尝完这盘就倒掉了,厨房里不留东西。
go build像打包外卖------真的产出一个能带走的餐盒,拿到哪台机器上都能直接吃。
(机制收尾)注意这个"餐盒"里装的是完整的机器码。Go 默认把依赖都静态链接进这一个文件里,产出的二进制不依赖解释器、不依赖运行时。这就是它跟 Python(要解释器)、Java(要 JVM)最本质的区别。也是为什么 Go 在云原生里地位这么高------容器镜像里塞一个二进制就能跑,基础镜像可以小到几乎为零。
整个链路长这样:
main.go
│
▼
Go 编译器
│
▼
一个独立二进制(拷到别的机器直接跑)
顺带记一句:在 Windows 上也能编出 Linux 的二进制(交叉编译),Day 6 会细讲。
第一个程序
新建 code/01_hello/main.go:
go
package main
import "fmt"
func main() {
fmt.Println("hello go")
}
跑起来:
bash
go run main.go
# 输出:hello go
逐行看:
| 代码 | 含义 |
|---|---|
package main |
声明包名。叫 main 的包是可执行程序的入口包,里面必须有 main 函数 |
import "fmt" |
引入标准库 fmt(负责格式化输入输出) |
func main() |
程序入口,跟 C/Java 的 main 一个作用 |
fmt.Println |
打印一行并换行 |
(机制收尾)为什么包名非得叫 main、函数非得叫 main?因为编译器链接的时候要去找一个固定的入口符号 main.main。叫别的名字,链接器找不到入口,就生成不出可执行文件。所以 package main 更像是一张"入场券"------拿不到它,你的代码只能当库被别人 import,自己跑不起来。
叫别的名字会怎样?我试过把包名改成 hello,go run 直接报错说不是 main 包。那一刻"入场券"这个比喻就落地了。
二、变量::= 这个符号
Go 声明变量有两种写法:
go
package main
import "fmt"
func main() {
// 方式一:完整声明,显式写类型
var name string = "tom"
var age int = 18
// 方式二:短变量声明 :=,让编译器自己推断类型
city := "beijing"
score := 98.5
fmt.Println(name, age, city, score)
}
:= 为什么存在
Go 的设计哲学里有一条:能省的字就省,但别牺牲可读性 。类型大多数时候能从右边的值推出来,再手写一遍就是废话。:= 就是这个思想的产物。
go
age := 18 // 编译器推断出 age 是 int
name := "tom" // 编译器推断出 name 是 string
(机制收尾)但它的能力是有边界的,有三条规则,读源码时经常撞上:
| 规则 | 说明 |
|---|---|
| 只能用在函数内部 | 包级变量必须用 var,因为包级要明确导出类型、保持稳定 |
| 左边至少有一个新变量 | 不能全是已经存在的变量,否则报 no new variables on left side of := |
已用 var 声明过的,赋值用 = |
= 是赋值,:= 是声明,两码事 |
这个坑我踩得很实在。一开始我以为 := 就是"赋值的简写",于是对一个已经声明过的变量又用了一次 :=,直接报错。当时还纳闷:不就少打个 var 吗,至于吗?
后来想通了::= 是"第一次见面自我介绍",= 是"跟老朋友打招呼" 。你没介绍过就直接打招呼,编译器当然不认。得先 var x int 打个招呼,后面再用 =。
go
var x int // 声明
x = 10 // 赋值,用 =
y := 20 // 声明 + 赋值一把梭
y = 30 // 之后的赋值用 =
三、数据类型
基础类型
go
var a int = 10 // 整数
var b int64 = 100 // 大整数
var c float64 = 3.14 // 浮点数
var d string = "go" // 字符串
var e bool = true // 布尔
Go 的整数分得很细:int8/int16/int32/int64,还有无符号的 uint 系列。平时 90% 用 int 和 int64 就够了。
为什么读 K8s 源码时满眼都是 int64?(机制收尾)因为 int 的大小取决于平台 ------64 位机器上通常是 64 位,但语言并不保证。只要涉及跨平台传输、存时间戳、存资源量这种"数值范围必须明确"的场景,就会用 int64 把宽度钉死。所以源码里到处都是它,不是作者啰嗦,是被平台差异逼的。
复合类型
这一组才是重点:
array 定长数组
slice 切片(动态数组)★ 实际开发 90% 用它
map 字典 / 哈希表
struct 结构体(Go 的"对象")
slice:数组的"可伸缩观察窗"
先说个最容易被绕进去的问题:为什么 Go 有数组,大家却几乎只用 slice?
来龙去脉是这样的:Go 的数组,长度是类型的一部分 。[3]int 和 [4]int 是两种完全不同的类型。更麻烦的是数组是值类型,函数之间传递会把整个数组拷贝一份。长度固定的东西在真实开发里太不灵活了,所以 Go 才做了 slice。
go
arr := [3]int{1, 2, 3} // array:长度固定,[3]int 是类型的一部分
s := []int{1, 2, 3} // slice:长度可变,[]int 才是类型
记一句话:中括号里写了数字是 array,没写数字是 slice。
slice 用起来像"能自动扩容的动态数组":
go
package main
import "fmt"
func main() {
nums := []int{1, 2, 3}
// 追加元素(注意 append 会返回一个新 slice)
nums = append(nums, 4)
nums = append(nums, 5, 6)
fmt.Println(nums) // [1 2 3 4 5 6]
fmt.Println(len(nums)) // 6 长度
fmt.Println(nums[0]) // 1 按下标取值(从 0 开始)
fmt.Println(nums[1:3]) // [2 3] 切片:取下标 [1,3) 的元素
}
(比喻)数组像一栋固定三层的楼,盖好了层数就改不了。slice 像在这栋楼外面装了个可伸缩的观察窗------你能透过它看到楼的某一段,还能把窗户拉长。
(机制收尾)但这个比喻会骗人。slice 底层其实是一个结构体:一个指向底层数组的指针 + 长度 + 容量 。它自己不存数据,数据在底层数组里。所以 append 一旦超出容量,Go 会重新分配一块更大的数组,把老数据拷过去,然后返回一个指向新数组的 slice。
这就是为什么必须写 nums = append(nums, 4) 而不是 append(nums, 4)------不接住这个新 slice,你用的还是旧的那个,改动就"丢了"。我第一次不理解这点的时候,append 完打印还是老样子,迷惑了好久。
map:字典
go
package main
import "fmt"
func main() {
// 声明 + 初始化
user := map[string]int{
"tom": 18,
"jerry": 20,
}
// 取值
fmt.Println(user["tom"]) // 18
// 赋值
user["alice"] = 25
// 判断 key 是否存在(第二个返回值是 bool)
age, ok := user["bob"]
if ok {
fmt.Println("bob 的年龄:", age)
} else {
fmt.Println("bob 不存在")
}
// 删除
delete(user, "tom")
}
value, ok := m[key] 这个写法,K8s 源码里到处都是。它为什么长这样?
(比喻)map 查一个不存在的 key,会给你回一个零值 ------这个例子里是 0。就像你问一个不在通讯录里的人要电话,他礼貌地回你"0"。你拿到这个 0,根本分不清他到底是"电话号码是 0"还是"我压根没这个人通讯录"。
(机制收尾)Go 的应对办法就是多给一个返回值 ok:true 表示这个 key 真存在,false 表示不存在、你拿到的是零值。所以标准写法是先看 ok 再看 age。这也是 Go 的一个习惯------需要表达"有没有"的时候,就多返一个布尔值,而不是搞个异常出来。这个习惯后面讲函数还会遇到。
四、流程控制
if:可以带初始化语句
最普通的 if 长这样,注意不用写小括号:
go
age := 20
if age >= 18 {
fmt.Println("成年")
} else if age >= 6 {
fmt.Println("青少年")
} else {
fmt.Println("儿童")
}
但 Go 的 if 有个特色写法,我第一次见还以为自己看错了:
go
if err := doSomething(); err != nil {
// 处理错误
}
分号前面那句是初始化语句 ,它声明的变量作用域只在 if 块内。
(机制收尾)为什么要这么设计?还是"少即是多"。如果先在外面写 err := doSomething(),这个 err 会活在整个函数里,后面几十行都可能误用它。塞进 if 里,出了这个块它就"销毁"了,你不可能再用错。这跟前面 (value, error) 那套是配套的------Go 处处在想办法把错误的处理限制在它该在的地方。
for:Go 只有这一种循环
Go 没有 while,也没有 do-while。C 语言那三种循环,Go 砍成了一个 for,用不同写法覆盖:
go
// 经典三件套(像 C/Java 的 for)
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// 当成 while 用
n := 0
for n < 5 {
fmt.Println(n)
n++
}
// 无限循环(相当于 while(true))
for {
// 得靠 break 跳出去
break
}
(机制收尾)这不是偷懒,是刻意为之。语言特性越少,读代码时心里的歧义就越少------看到 for 你不用先想"这是哪种循环"。而 for range 也只是 for 的一种特殊形式,不是什么新东西。
range:遍历容器
go
// 遍历 slice:拿到下标 + 值
nums := []int{10, 20, 30}
for i, v := range nums {
fmt.Println(i, v)
}
// 不想要下标,用 _ 占位(Go 里 _ 表示"忽略这个值")
for _, v := range nums {
fmt.Println(v)
}
// 遍历 map
m := map[string]int{"a": 1, "b": 2}
for k, v := range m {
fmt.Println(k, v)
}
这里有两个我后来才知道的机制,都是坑:
range拿到的是值的拷贝,不是引用。 所以你在循环里改v,改的是副本,原来的 slice 不变。- 遍历 map 的顺序是随机的。 Go 故意把顺序打乱,就是为了防着你写出"依赖遍历顺序"的代码------因为 map 底层是哈希表,本来就没有固定顺序可言。
switch:默认就带 break
go
switch os := "linux"; os {
case "linux":
fmt.Println("linux 系统")
case "windows":
fmt.Println("windows 系统")
default:
fmt.Println("其他")
}
Go 的 switch 和 C 有个正好相反的设计:每个 case 结束会自动 break ,不用手写。想故意往下穿透,得显式写 fallthrough。
(机制收尾)为什么反过来?因为在 C 里"忘了写 break 导致意外穿透"是经典 bug,无数人栽过。Go 干脆把默认行为改成"不穿透",让安全的那个选择变成不需要动手的选择。这种"把坑堵在语言层面"的思路,在 Go 里到处都是。
五、函数
基本函数
go
package main
import "fmt"
func add(a int, b int) int {
return a + b
}
// 参数类型相同时可以简写
func add2(a, b int) int {
return a + b
}
func main() {
fmt.Println(add(1, 2)) // 3
fmt.Println(add2(3, 4)) // 7
}
多返回值 + error
这是 Go 最大的特色,也是我一开始最不适应的。Go 函数能返回多个值,而最常用的组合是**"结果 + 错误"**:
go
package main
import (
"errors"
"fmt"
)
// 返回 (结果, 错误)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除数不能为 0")
}
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("出错:", err)
return
}
fmt.Println("结果:", result)
}
result, err := xxx() 后面跟一段 if err != nil { ... }------这个套路你会写一千遍,K8s 源码里也全是它。
(来龙去脉)为什么 Go 不用 try/catch 那种异常?Go 团队的观点很明确:异常会让控制流变得不透明 。一个函数可能在任意深的地方抛出异常,一路往上弹,你在调用点根本看不出来"这里可能会炸"。Go 选择让错误变成一个普通的值,跟其他返回值一样,必须被显式接住、显式处理。
(比喻)异常像藏在路上的炸弹 ,你走得好好的突然就被炸飞,还得回头查是哪一步炸的;Go 的 error 像快递单,每个环节都摊在你面前,自己签收,出了啥问题一目了然。
(机制收尾)而且这个 error 背后其实是个接口类型 ------只要一个类型实现了 Error() string 方法,它就能当 error 用。errors.New 返回的就是一个实现了这个接口的简单类型。所以你可以自定义错误类型塞进去,这个 Day 3 再展开。
命名返回值
go
func divide(a, b int) (result int, err error) {
if b == 0 {
err = errors.New("除数不能为 0")
return // 裸 return,会自动把 result 和 err 当前的值带回去
}
result = a / b
return
}
在返回值位置直接给它们起名字,函数里就能当普通变量用,最后一句 return 不用接任何东西,会自动带回当前值。
这写法有人爱有人嫌------它的真本事是能在 defer 里修改返回值(这个到后面才用得上),但容易让读代码的人发懵:"result 到底在哪被改的?"所以看到它知道是什么意思就行,自己写不一定要用。
六、包与可见性:大小写说了算
Go 没有 public / private 关键字 。一个标识符是公开还是私有,看首字母大小写:
go
package user
var Name string = "tom" // 大写开头:可被其他包访问(导出)
var age int = 18 // 小写开头:只有本包能看见(私有)
func GetName() string { // 大写:导出函数
return Name
}
(来龙去脉)为什么用这么"偷懒"的方案?还是那个"少即是多"------省掉关键字,而且好处是一眼就能看出来 :读代码时看到 Name 和 age,不用往回翻它是 public 还是 private,看首字母就知道。
(比喻)大写的名字像挂着工牌站在大厅里的人 ,谁路过都能跟他打招呼;小写的名字像待在办公室里,只有本部门同事才找得到。
(机制收尾)落到编译器层面,规则就一句:标识符首字母是 Unicode 大写字母 → exported,能被别的包引用;否则就是包内私有。这是读 K8s 源码的前提------看到大写的东西,就知道它是这个包对外的"门面",小写的都是内部实现,不用关心。
七、动手环节:两个练习
光看没用,我照着需求自己写了一遍。下面是我写的代码和当时翻的车。
练习 1:用户登录程序
需求:输入用户名密码,跟预设账号(admin / 123456)比对,返回成功或失败。
go
package main
import "fmt"
func main() {
// 预设的正确账号
rightUser := "admin"
rightPass := "123456"
var user, pass string
fmt.Print("用户名:")
fmt.Scanln(&user)
fmt.Print("密码:")
fmt.Scanln(&pass)
// 两个条件都为真才算成功
if user == rightUser && pass == rightPass {
fmt.Println("登录成功")
} else {
fmt.Println("用户名或密码错误")
}
}
我踩的坑:fmt.Scanln 忘了加 &。
我第一次写成了 fmt.Scanln(user),结果程序跑起来------要么直接跳过不让我输入,要么输进去了 user 还是空的,打印出来永远是"错误"。折腾了半天。
后来才想明白:fmt.Scanln 的任务是把你敲进去的值,写回到我传进去的那个变量里 。可 Go 是值传递的------你直接把 user 传进去,传的是它的拷贝 ,函数改的是拷贝,跟外面那个 user 一点关系都没有。
所以得传变量的地址 ,让它顺着地址找回去改。&user 就是"取 user 的地址"。这个 & 和 C 里的取地址符是一回事,只是 Go 不让你拿它做指针运算。加上 & 之后,程序立刻正常了。
练习 2:文件统计程序
需求:统计一个文本文件的行数和字符数。
go
package main
import (
"fmt"
"os"
"strings"
)
func main() {
// os.Args 是个 []string,第一个元素是文件名
if len(os.Args) < 2 {
fmt.Println("用法:go run main.go <文件名>")
return
}
// 读文件,返回值同样是 (数据, 错误)
data, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Println("读取失败:", err)
return
}
content := string(data)
// 行数:数换行符。如果最后一行没有换行符,要补一行
lines := strings.Count(content, "\n")
if content != "" && !strings.HasSuffix(content, "\n") {
lines++
}
// 字符数:按字符(rune)数,不是按字节
chars := 0
for range content {
chars++
}
fmt.Printf("行数:%d\n", lines)
fmt.Printf("字符数:%d\n", chars)
}
我踩的坑:用 len(content) 数中文字符,数字对不上。
一开始我很自然地写了 len(content) 当字符数。测纯英文文档没问题。结果换了个带中文的文件,字符数一下子大了三倍左右。
查了才明白:Go 的字符串底层是 UTF-8 字节序列 ,len() 数的是字节数,不是字符数。一个中文汉字在 UTF-8 里占 3 个字节,所以会偏大。
那怎么数真正的字符?用 for range 遍历字符串------它每次吐出来的不是字节,而是一个 rune (一个完整的字符,中文也算一个)。于是就有了上面那个 for range content { chars++ }。这一下把 range 的机制也用上了,算是意外收获。
复盘
这一天想明白的几件事,用一句话记下来:
| 概念 | 一句话 |
|---|---|
:= |
短声明,编译器推断类型;只在函数内可用 |
| slice | 动态数组,底层是"指针+长度+容量",占开发 90% |
for |
Go 唯一的循环,range 是它的特殊形式 |
| 多返回值 | (value, error) 是 Go 错误处理的核心,因为没有异常 |
| 可见性 | 首字母大写导出,小写私有 |
还留着、准备后面解决的困惑:
error接口到底怎么自定义?errors.New和fmt.Errorf有什么区别?- 满屏的
if err != nil,有没有更优雅的写法?(听说 Go 1.13 之后有errors.Is/errors.As) struct和interface长什么样?K8s 源码里那些一大坨定义到底在干嘛?- 交叉编译怎么在 Windows 上编出 Linux 的二进制?
2026年9月 · 写于宿舍 · 第一次看着 hello go 从终端里蹦出来的那个晚上