2805 字
约 9 分钟
14
S09-X03 · Go 变量、常量与作用域:从声明到零值
2024-12-31
2026-11-08

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. 自测

  1. var m map[string]int 之后执行 delete(m, "k") 会不会 panic?
  2. 为什么 nil slice 可以 append 而 nil map 不能写入?从运行时结构解释。
  3. if 块里用 := 声明同名 err 会造成什么后果,怎么自动发现?
  4. 同一个 const 块里,iota 按常量个数还是按行递增?跳过一行怎么写?
  5. 无类型常量与有类型常量在溢出检查上的区别是什么?
  6. 包级 var 的初始化顺序由什么决定,跨文件互相引用会怎样?
S09-X03 · Go 变量、常量与作用域:从声明到零值
https://www.clxhxhhr.top/posts/5773/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。