Go 最容易让 Java 背景的人误判的地方不是语法,而是变量默认就有值。Java 里局部变量不赋值直接用,编译器会拦;Go 里 var n int 立刻可用,值是 0。差异往下延伸成多米诺:nil 指针、nil slice、nil map 看着都是 nil,行为却分三类;:= 在 if 里写一行,外面那个变量根本没更新。这篇把声明语法、零值、遮蔽、iota 的整条链路拆开,每段给出可运行判据。
1. 四种声明语法的分工
package main
import "fmt"
var pkgLevel = "包级变量,必须用 var"
func main() {
var a int // 显式类型,取零值 0
var b, c = 1, "two" // 多变量,类型由右值推断
var (
d = 3.14
e bool
)
f := "短声明" // 只能在函数内部
fmt.Println(a, b, c, d, e, f, pkgLevel)
}
| 语法 | 位置限制 | 类型来源 | 典型用途 |
|---|---|---|---|
var a int |
包级 / 函数级 | 显式写出 | 读代码就要知道类型,或要显式零值 |
var a = 1 |
包级 / 函数级 | 右值推断 | 包级变量带初值 |
var a, b = 1, 2 |
包级 / 函数级 | 逐个推断 | 同组变量 |
var (...) 块 |
包级 / 函数级 | 逐个推断 | 一组相关配置 |
a := 1 |
仅函数内 | 右值推断 | 函数内主场景 |
:= 不能用在包级:包级声明要承载初始化顺序与依赖关系,必须显式 var。多变量赋值时右侧一次性求值,所以交换不需临时变量:x, y = y, x。
2. 零值:Go 没有"未初始化"
每个类型都有确定零值,声明即初始化。这是 Go 不需要构造函数的根本原因。
| 类型 | 零值 | 直接使用的后果 |
|---|---|---|
| 整数 / 浮点 / 复数 | 0、0、0+0i |
安全 |
| 布尔 | false |
安全 |
| 字符串 | "" |
安全,可 len、可 + |
指针 *T |
nil |
可判空;解引用 panic |
slice []T |
nil |
len=0、可 range、可 append |
map map[K]V |
nil |
可读返回零值;写入 panic |
| channel | nil |
收发永久阻塞 |
函数 func() |
nil |
调用 panic |
| 接口 | nil |
判空为 true;调方法 panic |
| 结构体 / 数组 | 各字段零值 | 安全 |
type Config struct {
Name string
Timeout int
Tags []string
}
func main() {
var c Config
fmt.Println(c) // { 0 []}
c.Tags = append(c.Tags, "x") // nil slice 可 append
fmt.Println(len(c.Tags)) // 1
}
2.1 为什么 nil slice 可用而 nil map 写入 panic
原因在运行时结构。slice 是描述符(指针 + len + cap),nil slice 的描述符是 (nil, 0, 0);append 发现 cap 为 0 就分配新数组,返回新描述符,不需要原地改任何东西。map 的底层是 hmap 指针,nil map 里这个指针为空,m[k] = v 要写进桶数组却没有目标,运行时直接抛 assignment to entry in nil map。
func main() {
var s []int
s = append(s, 1) // OK
var m map[string]int
fmt.Println(m["k"]) // OK,读到零值 0
// m["k"] = 1 // panic
m = make(map[string]int)
m["k"] = 1 // OK
}
判据:需要写底层结构的操作(map 写、chan 收发)对 nil 无效;返回新描述符或只读(append、len、range)有效。没有 new 也能用,也就没有"半初始化对象"。
| 操作 | nil map |
nil slice |
nil chan |
|---|---|---|---|
len / range |
0 / 0 次 | 0 / 0 次 | 0 / 永久阻塞 |
| 下标读 | 返回零值 | s[0] panic |
— |
| 写 | panic | — | 发送阻塞 |
delete |
安全无操作 | — | — |
append |
— | 安全 | — |
close |
— | — | panic |
结论:slice 直接声明后就能 append,map 必须 make,无例外。
3. 短声明与遮蔽
:= 要求左侧至少一个新变量,否则报 no new variables on left side of :=。真正的坑是遮蔽:嵌套块里 := 声明新变量,外层同名变量不受影响。
func bad() error {
var err error
if true {
_, err := doSomething() // 新变量,遮蔽外层
_ = err
}
return err // 永远 nil
}
func doSomething() (int, error) { return 0, fmt.Errorf("boom") }
修复只需让外层变量参与赋值,用 =:
func good() error {
var err error
if true {
var n int
n, err = doSomething()
_ = n
}
return err
}
| 场景 | 写法 | 后果 | 正确做法 |
|---|---|---|---|
if 内 := |
if v, err := f(); err == nil |
外面拿不到 v |
前置声明 + = |
循环内 := |
for i := 0; ... |
循环外不可见(正常) | 需要就用循环前声明 |
err 链式复用 |
多处 := |
第一处 err 被遮蔽 |
统一 var err error + = |
语句头(if/for/switch)里 := 的变量作用域覆盖整块含所有分支,配合 err 时易误判。检测:go vet -vettool=$(which shadow) ./...(需单独 go install,按当前版本文档核对)。
4. 三种作用域与最小作用域原则
| 层级 | 范围 | 何时结束 |
|---|---|---|
| 包级 | 该包所有文件 | 程序结束 |
| 文件级 | 单文件(import 别名、var 块) |
文件结束 |
| 函数级 | 函数体 | 函数返回 |
| 块级 | {}、if/for/switch 体内 |
块结束 |
Go 没有访问修饰符,首字母大小写决定导出:Name 导出、name 包内可见、_name 未导出。
最小作用域不只是风格:变量越早结束越早可回收,遮蔽概率也更低。
// 差:f 的生命周期拉满整个 main
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// 好:圈进函数
func read(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(io.Discard, f)
return err
}
5. 常量与 iota
const 在编译期求值,只能放常量表达式,不能放函数调用。iota 有两条必须记死的规则:每个 const 块开始时重置为 0;按行递增(不是按常量个数),省略表达式时重复上一行。
const (
A = iota // 0
B // 1
C // 2
)
const (
_ = iota // 0 被跳过
KB = 1 << (10 * iota) // 1 << 10
MB // 1 << 20
GB // 1 << 30
)
const (
a, b = iota, iota + 10 // 0, 10
c, d // 1, 11
)
| 情况 | iota 值 |
说明 |
|---|---|---|
新 const 块首行 |
0 | 每块重置 |
| 同一行多个常量 | 相同值 | 不按常量个数递增 |
用 _ 跳值 |
该行仍占一个值 | 后面从 1 继续 |
块内混入非 iota 表达式 |
不影响后续行递增 | 按行号算 |
标准枚举写法是"自定义类型 + iota + String()":
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarn
LevelError
)
func (l Level) String() string {
switch l {
case LevelDebug:
return "debug"
case LevelInfo:
return "info"
case LevelWarn:
return "warn"
case LevelError:
return "error"
}
return "unknown"
}
6. const 与 var:无类型常量
Go 常量有个 Java 没有的机制:无类型常量。字面量默认不带类型,赋值或传参时按上下文转型,因此可以超出目标类型范围而不报错。
| 特性 | 无类型常量 | 有类型常量 |
|---|---|---|
| 声明 | const A = 1 |
const A int = 1 |
| 精度 | 编译期任意精度 | 受类型约束 |
| 溢出检查 | 使用时按目标类型检查 | 声明时即检查 |
| 适合 | 数值常量、位掩码 | 要参与运算、要明确类型 |
const Timeout = 30 * time.Second // 有类型:要和 Duration 运算
const MaxRetry = 3 // 无类型:哪都能用
const Pi float64 = 3.14159265358979 // 有类型:避免被推成 float32
取舍判据:编译期确定且永不变 → const;需要地址、make 或运行时求值 → var。_ 空标识符不占存储、不参与作用域:
| 用途 | 写法 |
|---|---|
| 忽略多返回值之一 | n, _ := f() |
| 副作用导入 | import _ "net/http/pprof" |
| 编译期接口断言 | var _ io.Writer = (*Buf)(nil) |
跳过 iota 某值 |
const (_ = iota; A) |
7. 与 Java 的对照
| 维度 | Go | Java |
|---|---|---|
| 包级变量 | var 可在包顶层 |
静态字段必须在类里 |
| 默认初始化 | 一切类型都有零值 | 字段有默认值,局部变量必须赋值 |
| 不可变 | const(编译期) |
final(运行期引用不变) |
| 枚举 | iota + 自定义类型 |
enum 是真类 |
| 作用域收窄 | if/for 头支持 := |
只能块首声明 |
| 导出控制 | 首字母大小写 | public/protected/private |
final 保证引用不变,const 保证编译期可算值,两者不同。想在 Go 表达运行期不可变,只能靠不导出字段 + 无 setter。
8. 排错:五类高频问题
| 现象 | 原因 | 处理 |
|---|---|---|
no new variables on left side of := |
左侧全已声明 | 改 = 或引入新变量 |
返回 nil 但内部明明出错 |
if 内 := 遮蔽外层 err |
var err error + = |
assignment to entry in nil map |
只 var 没 make |
make(map[K]V) |
mismatched types untyped int |
无类型常量赋给不兼容类型 | 显式 T(x) |
append 后前置元素被改 |
切片共享底层数组(见 X03) | copy 或全量切片 |
9. 边界:这些情况本就该换写法
| 场景 | 不建议 | 建议 |
|---|---|---|
| 常量列表要可遍历 | 多处手写 switch | var All = []Level{...} 配 String() |
| 运行期不可变结构体 | 注释"勿修改" | 不导出字段 + 返回副本的构造函数 |
| 大量配置项常量 | 每项单独 const |
Config 结构体 + 默认值函数 |
| 初始化顺序复杂 | 包级 var 互引 |
func init() 或显式 New() |
| 枚举要字符串化 | 手写 String() |
stringer 生成 |
包级 var 的初始化顺序是按文件名字母序 + 文件内声明序,跨文件循环引用会被编译器直接拒绝——拆包时最容易出事,值得提前设计成显式构造函数。
10. 自测
var m map[string]int之后执行delete(m, "k")会不会 panic?- 为什么
nilslice 可以append而nilmap 不能写入?从运行时结构解释。 if块里用:=声明同名err会造成什么后果,怎么自动发现?- 同一个
const块里,iota按常量个数还是按行递增?跳过一行怎么写? - 无类型常量与有类型常量在溢出检查上的区别是什么?
- 包级
var的初始化顺序由什么决定,跨文件互相引用会怎样?