gogogo!go语言感悟大观--打卡day01

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 rungo 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,自己跑不起来。

叫别的名字会怎样?我试过把包名改成 hellogo 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% 用 intint64 就够了。

为什么读 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 的应对办法就是多给一个返回值 oktrue 表示这个 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)
}

这里有两个我后来才知道的机制,都是坑:

  1. range 拿到的是值的拷贝,不是引用。 所以你在循环里改 v,改的是副本,原来的 slice 不变。
  2. 遍历 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
}

(来龙去脉)为什么用这么"偷懒"的方案?还是那个"少即是多"------省掉关键字,而且好处是一眼就能看出来 :读代码时看到 Nameage,不用往回翻它是 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.Newfmt.Errorf 有什么区别?
  • 满屏的 if err != nil,有没有更优雅的写法?(听说 Go 1.13 之后有 errors.Is / errors.As
  • structinterface 长什么样?K8s 源码里那些一大坨定义到底在干嘛?
  • 交叉编译怎么在 Windows 上编出 Linux 的二进制?

2026年9月 · 写于宿舍 · 第一次看着 hello go 从终端里蹦出来的那个晚上

相关推荐
探索云原生2 小时前
KubeClipper 1.7.0 发布:Operation 优化与 Kubernetes 1.37 支持
linux·docker·云原生·kubernetes·go
user_admin_god2 小时前
第 01 篇:OpenAI 兼容 API 初探 —— 用 curl 跑通第一次对话
java·人工智能·spring boot·语言模型·devops
右耳朵猫AI2 小时前
Go周刊2026W37 | Ebitengine 纯 Go 化、simd 重写 TurboPFor、json/v2 落地
后端·微服务·go
あ-3 小时前
企业 DevOps + 协同办公 + 可观测性 + 网络基础设施平台-Part 15.12 DevOps Role
devops
Dachui_11223 小时前
内网穿透 Webhook 告警实战:客户端离线、应用探测、资源删除全覆盖
运维·远程工作·内网穿透·devops·webhook·服务器监控·应用探测
szephyr3 小时前
GitHub Actions 实战:给个人项目接上免费的自动部署流水线
ci/cd·github·devops·自动化部署·github actions
あ-4 小时前
企业 DevOps + 协同办公 + 可观测性 + 网络基础设施平台-Part 15.10 Kubernetes Role
devops
あ-4 小时前
企业 DevOps + 协同办公 + 可观测性 + 网络基础设施平台-Part 15.15 Backup & Disaster Recovery Role
devops
troy1284 小时前
Python 进阶提升(十二):自动化测试与 DevOps 实战
python·devops