Appearance
P1.5 · 语法基础速通:12 个小节走完 Go 的日常语法
这章学完你能干什么:不看文档写出 Go 日常代码,包括变量、常量、
for全形态、切片、map、字符串、switch、defer、方法和闭包。 读者:会 Rust,没系统看过 Go 语法。前置:P1。后续:P2 换语言直觉,P2.5 惯用法,P3 并发。动手方式:开一个空目录,
go mod init demo,每个例子存成main.go,用go run main.go运行。片段省略了package main和import。
体系背景:变量住在哪里
程序运行时,变量放在两个地方。栈跟着函数调用走,函数返回就收回,几乎零成本。堆由垃圾回收(GC)管理,分配和回收都有开销。
Java 读者对此很熟:JVM 也做逃逸分析。JS 引擎同理。区别是 Java 的对象默认都在堆上,Go 的结构体默认在栈上。
Go 不让你选。编译器做逃逸分析:变量如果在函数返回后还被引用,就放到堆上。看一个例子:
go
func onStack() int {
p := point{1, 2}
return p.x + p.y
}
func onHeap() *point {
p := point{1, 2}
return &p // 地址传出去了
}sh
$ go build -gcflags=-m -o /dev/null main.go
./main.go:16:2: moved to heap: p只有 onHeap 里的 p 被移到了堆。new 和 &T{} 都不保证放在哪里,由编译器决定。排查性能时这条输出会再用到。
另一组数字决定了后面的"拷贝语义":
sh
$ go run main.go
切片头: 24 字符串头: 16 map 头: 8切片是三个字(指针、长度、容量),字符串是两个字,map 只是一个指针。传参时拷贝的就是这些"头"。
变量与多返回值
声明类型写在名字后面。:= 是"声明并推断"的短写法,只能用在函数内:
go
var a int = 1
var b = 2
c := 3
var x, y = 1, "hi"
fmt.Println(a, b, c, x, y)sh
$ go run main.go
1 2 3 1 hiKotlin 的 val/var 和 TS 的 const/let 都不强制这一点。Go 把"声明了不用"升格成编译错误。
声明了不用是编译错误,不是 Rust 那样的 warning:
sh
$ go run main.go
# command-line-arguments
./main.go:4:2: declared and not used: x函数可以返回多个值,error 习惯放最后一个:
go
func div(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("div by zero")
}
return a / b, nil
}
q, err := div(7, 2)
fmt.Println(q, err)
_, err = div(7, 0)
fmt.Println(err)sh
3 <nil>
div by zero不想要的返回值用 _ 丢掉。返回值也可以先命名,最后写一个光秃秃的 return:
go
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return
}
fmt.Println(split(17))sh
7 10短函数可以这么写,长函数别用,读起来像谜语。
常量与 iota
常量在编译期确定,只能是数字、字符串和布尔。要写一组相关的枚举值,用常量块加 iota,它是块内的行号,每行加 1:
go
type Level int
const (
Debug Level = iota
Info
Warn
Error
)
const (
_ = iota
KB = 1 << (10 * iota)
MB
GB
)
fmt.Println(Debug, Info, Warn, Error)
fmt.Println(KB, MB, GB)
fmt.Println(Level(99))sh
$ go run main.go
0 1 2 3
1024 1048576 1073741824
99Java 的 enum 和 TS 的 enum 都是真正的类型,Go 没有。后几行省略的表达式会隐式重复上一行。最后一行说明了 Go 的"枚举"只是 int 加常量,Level(99) 照样编译通过,没有 Rust enum 的类型安全。练习 TestIota 练的就是这个。
for 与 range
Go 只有 for 一种循环,写法有四种,对应 Rust 的 loop、while 和 for:
go
for i := 0; i < 3; i++ { } // C 形态
for x < 100 { } // while
for { } // loop,配 break
for i, v := range xs { } // for (i, v) in xs.iter().enumerate()range 遍历切片、map、字符串、channel、整数和函数迭代器。整数形态是 1.22 加的:
go
for i := range 3 {
fmt.Print(i, " ")
}
for i, r := range "Go语" {
fmt.Println(i, string(r))
}sh
$ go run main.go
0 1 2
0 G
1 o
2 语遍历字符串时,i 是字节下标,r 是字符。"语"从字节 2 开始,所以下标是 2,下一个字符就是 5 了,下面"字符串"一节会解释。
JS 读者会觉得眼熟:1.22 之前的 Go 像 var,之后像 let。
1.22 起循环变量每轮新建。闭包捕获它再也不会出错:
go
var fs []func()
for i := range 3 {
fs = append(fs, func() { fmt.Print(i, " ") })
}
for _, f := range fs { f() }sh
0 1 2把 go.mod 里的版本改成 go 1.21,再用 go run . 跑 C 形态的 for i := 0; i < 3; i++ 版本,实测输出 3 3 3。读老代码要警惕。注意 go run main.go 这种单文件写法实测不读 go.mod 的版本。
切片
数组 [3]int 的长度是类型的一部分,赋值是整体拷贝,日常很少用。你用的是切片,它是 (指针, len, cap) 三元组,指向一块底层数组。
先看 append 怎么扩容:
go
s := make([]int, 0, 2)
for i := range 5 {
s = append(s, i)
fmt.Println("len", len(s), "cap", cap(s))
}sh
$ go run main.go
len 1 cap 2
len 2 cap 2
len 3 cap 4
len 4 cap 4
len 5 cap 8容量不够时分配一块更大的数组并拷贝,通常翻倍。
Java 的 List.subList 同样是视图,JS 的 Array.slice 则会拷贝,Uint8Array.subarray 才是视图。
子切片 a[1:3] 不拷贝,和原切片共享同一块数组。Rust 的借用检查会拦住这种别名,Go 不会:
go
a := []int{1, 2, 3, 4}
b := a[1:3]
fmt.Println("b:", b, "len", len(b), "cap", cap(b))
b[0] = 99
fmt.Println("改 b[0] 后 a =", a)
b = append(b, 5)
fmt.Println("append 后 a =", a)sh
b: [2 3] len 2 cap 3
改 b[0] 后 a = [1 99 3 4]
append 后 a = [1 99 3 5]改 b[0] 改到了 a。b 的容量还有富余,append 直接写进了 a 的第 4 个元素。
想隔离有两个办法:三索引切片限制容量,或者克隆:
go
a = []int{1, 2, 3, 4}
c := a[1:3:3] // cap 限到 2
c = append(c, 5)
fmt.Println("三索引 append 后 a =", a, "c =", c)
d := slices.Clone(a)
d[0] = -1
fmt.Println(a[0], d[0])sh
三索引 append 后 a = [1 2 3 4] c = [2 3 5]
1 -1最后,nil 切片是合法的零值,len、append、range 都能直接用。练习 TestSliceShare 把这些都考了一遍。
map
go
m := map[string]int{"a": 1}
fmt.Println(m["missing"])
v, ok := m["missing"]
fmt.Println(v, ok)
v, ok = m["a"]
fmt.Println(v, ok)sh
$ go run main.go
0
0 false
1 trueJava 的 HashMap.get 缺键返回 null,JS 的 Map.get 返回 undefined,Go 返回零值,没法靠 null 判断。
这是第一个坑:取缺失的键返回零值,不报错。需要区分"没有"和"值就是零"时,必须用 v, ok :=。
第二个坑是 nil map 能读不能写,写入直接 panic(见 P2 的"零值"一节)。
第三个坑是迭代无序,而且并发读写会让进程直接崩溃:
go
big := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
for run := 0; run < 3; run++ {
for k := range big { fmt.Print(k) }
fmt.Println()
}sh
cdeab
eabcd
cdeab三轮顺序不同,每次运行也不同,运行时故意随机化。并发写则是这样:
sh
$ go run main.go
fatal error: concurrent map writes
goroutine 18 [running]:这是 fatal error,recover 接不住,进程退出码 2。加 -race 才会降级成 DATA RACE 报告。解法是加锁,见 P3。
键必须是可比较的类型:切片、map、函数不能当键,字段都可比较的结构体可以。
字符串与 rune
字符串是只读的 UTF-8 字节序列。关键认知是 len 和索引按字节算,不按字符:
go
s := "Go语言"
fmt.Println(len(s))
fmt.Println(s[2])
fmt.Println(utf8.RuneCountInString(s), len([]rune(s)))
for i, r := range s {
fmt.Print(i, ":", string(r), " ")
}sh
$ go run main.go
8
232
4 4
0:G 1:o 2:语 5:言Java 和 JS 的字符串是 UTF-16,"Go语言".length 是 4,遇到 emoji 才出现这类问题。Go 是 UTF-8,坑在汉字上就出现了。
Go 占 2 字节,"语言"各占 3 字节,共 8。s[2] 是"语"的第一个字节 232,不是字符。
处理"第 n 个字符"要先转 []rune。纯 ASCII 时不转也对,所以这个坑在英文环境里藏得很深。
改字符串只能重建,转 []byte 修改再转回来:
go
b := []byte("abc")
b[0] = 'X'
fmt.Println(string(b))
fmt.Println(strings.Join([]string{"a", "b"}, ", "), strings.Split("x,y,z", ","))sh
Xbc
a, b [x y z]数字和字符串互转用 strconv.Itoa 和 strconv.Atoi,热路径上比 fmt 快。练习 TestRuneIndex 考这一节。
switch
switch 默认每个分支自动 break,不需要写。最常用的是不带表达式的形态,相当于整洁的 if-else 链:
go
func grade(score int) string {
switch {
case score >= 90:
return "A"
case score >= 60:
return "B"
default:
return "F"
}
}
fmt.Println(grade(95), grade(70), grade(10))sh
$ go run main.go
A B F一个 case 可以列多个值,type switch 则按接口里的实际类型分支:
go
switch d {
case "sat", "sun":
fmt.Println(d, "weekend")
default:
fmt.Println(d, "weekday")
}
var v any = 3.5
switch x := v.(type) {
case string:
fmt.Println("string", x)
case float64:
fmt.Println("float64", x)
}sh
sat weekend
mon weekday
float64 3.5fallthrough 会强行进入下一个分支,几乎不用,见到先怀疑是 bug。练习 TestGrade 对应无表达式 switch。
defer
它相当于 Java 的 try/finally 和 try-with-resources、Kotlin 的 use、JS 的 finally,但写在资源获取的下一行。
defer 把一个调用推迟到当前函数返回前执行。粒度是函数,不像 Rust 的 Drop 是作用域。它有三条精确语义。
(1)后进先出。
go
for i := 1; i <= 3; i++ {
defer fmt.Println("defer", i)
}sh
$ go run main.go
defer 3
defer 2
defer 1(2)参数立即求值。 要看最新值就用闭包:
go
i := 0
defer fmt.Println("参数立即求值:", i)
defer func() { fmt.Println("闭包看到最新值:", i) }()
i = 7sh
闭包看到最新值: 7
参数立即求值: 0(3)能改命名返回值。 defer 在 return 之后运行,摸得到它们:
go
func safe() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
panic("boom")
}
fmt.Println(safe())sh
recovered: boomdefer 要紧跟资源获取:defer f.Close()、defer mu.Unlock()、defer wg.Done()。练习 TestDeferOrder 考前两条。
panic 与 recover
panic 对应 Rust 的 panic!,用于程序员的 bug。它不是错误处理机制,错误是返回值。
go
func mustParse(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(fmt.Sprintf("config parse %q: %v", s, err))
}
return n
}
fmt.Println(mustParse("42"))
fmt.Println(mustParse("4x2"))sh
$ go run main.go
42
panic: config parse "4x2": strconv.Atoi: parsing "4x2": invalid syntax
...
exit status 2recover 只在 deferred 函数里有效(上一节的 safe 就是例子)。库代码不应该用它吞 panic,那样会把 bug 藏起来。
服务边界不同:net/http 会替每个请求 recover,进程不死,但那条连接会被关闭。goroutine 的 panic 没有天然边界,P3 会让你亲眼看进程死掉。
方法
方法是带接收者的函数,接收者就是第一个参数:
go
type Counter struct{ n int }
func (c Counter) Value() int { return c.n } // 值接收者,拿到拷贝
func (c *Counter) Add(d int) { c.n += d } // 指针接收者,能改原值
var c Counter
c.Add(2)
c.Add(3)
fmt.Println(c.Value())sh
$ go run main.go
5选型规则全生态一致:要改接收者,或者结构体大、含锁,用指针接收者。一个类型别混用,要么全值要么全指针。
接收者种类还决定接口满足。方法集的精确规则在 P2,这里只看编译器的反应:
go
var _ Adder = c // c 是 Counter
_ = Counter.Addsh
$ go run main.go
# command-line-arguments
./main.go:11:16: cannot use c (variable of struct type Counter) as Adder value in variable declaration: Counter does not implement Adder (method Add has pointer receiver)
./main.go:12:14: invalid method expression Counter.Add (needs pointer receiver (*Counter).Add)改成 &c 和 (*Counter).Add 就通过。练习 TestPointerReceiver 让你亲手改一遍。
闭包与方法值
函数是一等公民,可以存进变量,也可以从函数里返回:
go
func counter() func() int {
n := 0
return func() int { n++; return n }
}
next := counter()
fmt.Println(next(), next(), next())
other := counter()
fmt.Println(other())sh
$ go run main.go
1 2 3
1闭包捕获的是变量本身,不是拷贝。每次调 counter() 得到各自独立的 n。Go 没有 Fn/FnMut 的区分,可变捕获不留任何痕迹。
方法也能当值用。c.Add 绑定了接收者,(*Counter).Add 没绑定,接收者变成第一个参数:
go
f := c.Add
f(10)
g := (*Counter).Add
g(&c, 100)
fmt.Println(c.n)sh
115值语义与引用语义
Java 和 JS 的对象变量本身就是引用,传参拷贝的是引用。Go 的结构体是值,要引用必须自己写 *。
Go 的传参和赋值一律是拷贝。有些类型内部含指针,拷贝的是"头",这就是体系背景里那组数字的含义:
go
func bump(p P) { p.X++ }
func bumpPtr(p *P) { p.X++ }
func setFirst(s []int) { s[0] = 99 }
func grow(s []int) { s = append(s, 4) }
func growRet(s []int) []int { return append(s, 4) }
p := P{1}
bump(p); fmt.Println(p.X)
bumpPtr(&p); fmt.Println(p.X)
s := []int{1, 2, 3}
setFirst(s); fmt.Println(s)
grow(s); fmt.Println(s, len(s))
s = growRet(s); fmt.Println(s, len(s))sh
$ go run main.go
1
2
[99 2 3]
[99 2 3] 3
[99 2 3 4] 4结构体按值拷贝,改不到原值。切片改元素对调用方可见,但 grow 里的 append 只改了函数内的头,调用方看不到长度变化。所以要增长就返回新切片。
数组整体拷贝,map 和 channel 传的是指针:
go
arr := [3]int{1, 2, 3}
arr2 := arr
arr2[0] = 99
fmt.Println(arr, arr2)sh
[1 2 3] [99 2 3]Rust 最大的观念差异在这里。Go 没有 & 和 mut 标记,谁在共享可变状态,从签名上看不出来。靠约定加 -race 兜底,把"这里是不是共享了"当作每次赋值的自检项。练习 TestValueSemantics 考这一节。
小结
:=声明推断,声明不用是编译错,error放最后一位。- 切片是 (ptr, len, cap) 头,子切片共享底层数组。隔离用三索引切片或
Clone。 - map 取缺键得零值,nil map 写入 panic,迭代无序,并发读写崩进程。
len和索引按字节算,处理字符要转[]rune。defer三语义:后进先出、参数立即求值、能改命名返回值。- 接收者选型:要改、结构体大、含锁用指针。一致性优先。
- 一切赋值皆拷贝。共享可变在签名上看不出来,这是没有 borrow checker 的代价。
练习:p1_5basics
先跑一遍看全绿:
sh
$ go test ./exercises/p1_5basics/
ok golearn/exercises/p1_5basics 0.472s预测,先写答案再跑:
TestSliceShare:b := a[1:3]; b[0] = 99后a[1]是多少?append(b, 5)后a[3]呢?改成a[1:3:3]再 append,a会变吗?TestMapZero:m["missing"]返回什么?怎么区分"键不存在"和"值就是零"?向 nil map 写会怎样?TestDeferOrder:三个 defer 的输出顺序?defer fmt.Println(i)在i = 7之后执行,打印几?闭包版本呢?TestRuneIndex:"Go语言"[2]的类型和值?len()和utf8.RuneCountInString()各返回几?
观察:写个 10 行小程序,建 5 键 map,连跑几次 for k := range m 拼出键序,验证顺序真的会变。go test -run TestIota -v 看 iota 的断言,错一个值就 FAIL 并打印实际值。
对照:TestIota、TestGrade、TestValueSemantics 的断言即答案,读完对应小节再看。
改造:TestPointerReceiver 里把 Add 改成值接收者,看编译错,再把接口断言改成 &c,想清楚为什么就绿了。
章末参考
range 的八种目标
语言规范里 range 共 8 类(Go 1.26 仍是这 8 类),返回值个数不同:
- 切片或数组:
i, v,v是拷贝。 - 只要下标:
for i := range s。 - map:
k, v,无序。 - 字符串:
i, r,i是字节下标,r是 rune。 - channel:一个值,迭代到 close。
- 整数(1.22):
for i := range n,不用下标写for range n。 - 函数一值(1.23):
iter.Seq[V]。 - 函数两值(1.23):
iter.Seq2[K, V]。
函数迭代器是"推式":迭代器调用循环体,循环体返回 false 即 break。Rust 的 Iterator 是拉式,所以 Go 里没有 .map().filter() 链,组合靠 slices 和 maps 包的函数(P8 迭代器节)。
声明与初始化
x := v用在函数内,不能包级。var x T用在包级或要零值。new(T)和&T{}等价,后者更常用,1.26 起new可带值。make(T, args)只用于 slice、map、chan。
运算符与类型
- 含 slice 或 map 字段的结构体不可
==,用slices.Equal或maps.Equal。 i++是语句,不是表达式,也没有++i。int平台相关,需要确定位宽就用int64或uint32。- 没有隐式转换,
int32赋给int64要写int64(x)。 - 浮点比较用
math.Abs(a-b) < eps,金额用整数分或big.Rat。 - 常量是无类型的,
const Big = 1 << 62赋给int64不溢出。
内建 min、max、clear
Go 1.21 起可用,都是泛型:
go
fmt.Println(min(3, 1, 2), max(2.5, 1), min("b", "a"))
m := map[string]int{"a": 1, "b": 2}
xs := []int{1, 2, 3}
clear(m)
clear(xs)
fmt.Println(len(m), xs)sh
$ go run main.go
1 2.5 a
0 [0 0 0]clear 对 map 是全删,对切片是元素归零、len 不变。min(xs...) 不行,内建不收变参,要折叠着写。可运行例子在 exercises/p1_5basics/builtin121.go,配 TestMinMaxBuiltins 和 TestClearBuiltins。
标签
break 和 continue 可以带标签跳出多层循环,对应 Rust 的 'outer: loop:
go
outer:
for i := range 3 {
for j := range 3 {
if i*j == 2 {
fmt.Println("命中", i, j)
break outer
}
}
}sh
命中 1 2与 Rust 的关键差异
| 主题 | Rust | Go |
|---|---|---|
| 声明 | let x: T,默认不可变 | x := v,默认可变 |
| 所有权 | move 与 borrow | 一切拷贝,大结构体传指针 |
| 枚举 | sum type 带数据 | int 常量加 iota,无类型安全 |
| 错误 | Result<T, E> 与 ? | (T, error) 与 if err != nil |
| Option | Option<T> | 零值加 comma-ok |
| 泛型 | 全功能 | 接口约束(P8) |
| 字符串 | String 可变,&str 只读 | 统一只读,改靠重建 |
| 迭代器 | 惰性链为主 | 显式 for 为主,iter 包是补充 |
Java/Kotlin 与 TS/JS 类比
| 概念 | Java / Kotlin | TS / JS |
|---|---|---|
:= | Kotlin val,Java var | const / let |
iota 枚举 | enum 类 | enum |
| 切片子视图 | List.subList | subarray(slice 会拷贝) |
| map 缺键 | null | undefined |
| 字符串 | UTF-16 | UTF-16 |
defer | finally,use | finally |
| 闭包循环变量 | 须 effectively final | let 每轮新建,var 共享 |
| 结构体传参 | 对象是引用 | 对象是引用 |