Go-03 变量常量与基础语法
1. 变量声明
Go 是静态强类型语言,每个变量在编译期都有确定的类型。但 Go 又通过类型推断、短声明等语法糖大幅减少了冗余,写起来接近动态语言。本章把变量、常量、作用域、运算符这些"地基"讲透。
1.1 var 声明的四种写法
package main
import "fmt"
func main() {
// 1) 完整写法:var 变量名 类型 = 值
var a int = 10
// 2) 省略类型,由右侧值推断
var b = 20 // b 被推断为 int
// 3) 只声明不赋值,自动赋"零值"
var c int // c == 0
// 4) 一次声明多个(同类型可共享类型名)
var x, y int = 1, 2
var name, age = "Tom", 18 // 类型可以不同,各自推断
fmt.Println(a, b, c, x, y, name, age)
}
1.2 批量声明(var 块)
包级变量常用 var (...) 块统一组织,可读性更好:
var (
appName = "myapp"
version = "1.0.0"
maxRetries = 3
debug bool // 零值 false
)
1.3 零值机制
Go 没有"未初始化"这个概念。任何变量声明后若不显式赋值,都会被赋予该类型的零值(zero value)。这是 Go “零值可用"设计哲学的基础——很多类型不初始化就能直接用。
| 类型分类 | 具体类型 | 零值 |
|---|---|---|
| 布尔 | bool |
false |
| 整型 | int/int8~64/uint*/byte/rune |
0 |
| 浮点 | float32/float64 |
0.0 |
| 复数 | complex64/complex128 |
0+0i |
| 字符串 | string |
""(空串,非 nil) |
| 指针 | *T |
nil |
| 切片 | []T |
nil |
| map | map[K]V |
nil |
| channel | chan T |
nil |
| 函数 | func(...) |
nil |
| 接口 | interface{} / any |
nil |
| 数组 | [n]T |
每个元素都是各自的零值 |
| 结构体 | struct{...} |
每个字段都是各自的零值 |
func main() {
var (
i int
f float64
s string
p *int
sl []int
m map[string]int
)
fmt.Printf("%d %f %q %v %v %v\n", i, f, s, p, sl, m)
// 输出:0 0.000000 "" <nil> [] map[]
// 零值可用示例:nil 切片可以直接 append
sl = append(sl, 1, 2, 3)
fmt.Println(sl) // [1 2 3]
// 但 nil map 不能直接写入,会 panic!
// m["a"] = 1 // panic: assignment to entry in nil map
}
注意区别:nil 切片可以 append,nil map 不能直接写。这是零值可用的一个重要边界。
2. 短变量声明与变量遮蔽
2.1 := 的规则
:= 是短变量声明,只能用在函数内部,编译器自动推断类型:
func demo() {
x := 42 // 等价 var x = 42
name := "gopher" // string
ok := true // bool
// 多变量短声明
a, b := 1, 2
// 特殊规则:左侧只要有一个是新变量即可复用 :=
a, c := 10, 20 // a 是已存在变量(赋值),c 是新变量(声明)
_ = c
fmt.Println(x, name, ok, a, b)
}
2.2 var 与 := 的区别与选型
| 维度 | var |
:= |
|---|---|---|
| 使用位置 | 包级 + 函数内 | 仅函数内 |
| 是否需赋值 | 否(有零值) | 是(必须有初值) |
| 显式指定类型 | 可以 | 不可以(只能推断) |
| 典型场景 | 包级变量、需要零值、需要指定精确类型 | 函数内局部变量、for/if 临时变量 |
// 需要精确类型时用 var(推断会给默认 int/float64)
var count int64 = 0
var ratio float32 = 0.5
// 局部临时变量用 := 更简洁
for i := 0; i < 10; i++ {
v := i * i
_ = v
}
2.3 变量遮蔽(Shadowing)陷阱
短声明最常见的坑:在内层作用域用 := 声明了与外层同名的变量,形成"遮蔽”,导致对外层变量的修改失效。
func shadowBug() (err error) {
if true {
// 本意是给外层 err 赋值,却因为 := 新建了一个内层 err!
err := doSomething() // ← 遮蔽了返回值 err
if err != nil {
fmt.Println("内层捕获:", err)
}
}
// 外层 err 始终是 nil,调用方永远拿不到错误
return err
}
func doSomething() error { return fmt.Errorf("boom") }
正确写法:用 = 而非 :=,或提前声明。
func shadowFix() (err error) {
if true {
err = doSomething() // = 赋值给外层 err
if err != nil {
return err
}
}
return nil
}
用 go vet 检测遮蔽
标准工具链自带 go vet,但 shadow 检查在独立分析器中,需要单独安装:
# 安装 shadow 分析器
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
# 以 vettool 方式运行
go vet -vettool=$(which shadow) ./...
输出示例:
./main.go:5:3: declaration of "err" shadows declaration at line 3
实践建议:在 CI 中加上 shadow 检查;返回值命名 err 时,函数体内一律用 = 赋值给它。
3. 常量 const
3.1 常量的本质:编译期确定
常量的值必须在编译期就能计算出来,因此只能由字面量、其他常量、编译期可求值的表达式(含内置函数 len、cap 对常量数组等)组成。
const Pi = 3.14159
const Greeting = "hello"
// 常量表达式:编译期计算
const (
KB = 1 << 10 // 1024
MB = 1 << 20 // 1048576
Sec = 60
Min = 60 * Sec
)
func main() {
// 常量不能取地址(它没有运行期内存位置)
// p := &Pi // 编译错误:cannot take the address of Pi
// 常量不能被运行期的值赋值
x := 10
// const y = x // 编译错误:x (variable) is not constant
_ = x
}
3.2 无类型常量(Untyped Constant)原理
这是 Go 常量系统最精妙的地方。一个不指定类型的常量是无类型常量,它以"任意精度"(arbitrary precision)保存,直到在具体上下文中被使用时才转换成某个具体类型。
const big = 1 << 62 // 无类型整型常量,精度极高
func main() {
// 无类型常量能同时"适配"多种类型
const c = 100
var i int = c // 当作 int
var f float64 = c // 当作 float64
var b byte = c // 当作 byte(100 在 0~255 范围内)
fmt.Println(i, f, b)
// 无类型常量的运算保持高精度
const third = 1.0 / 3.0 // 高精度浮点常量
var x float64 = third // 用到时才截断为 float64
fmt.Println(x)
}
对比有类型常量:一旦指定类型,就丧失了"随上下文变形"的能力。
const typed int = 100
// var f float64 = typed // 编译错误:类型不匹配,需要显式转换 float64(typed)
const untyped = 100
var f float64 = untyped // OK:无类型常量自动适配
无类型常量分六类:无类型布尔、无类型 rune、无类型整数、无类型浮点、无类型复数、无类型字符串。它们各有一个默认类型(见本章第 8 节),只有在必须落地为具体类型(如赋值给 interface{}、用 := 声明)时才启用默认类型。
4. iota 枚举
Go 没有专门的 enum 关键字,用 const + iota 实现枚举。iota 是常量块内的行计数器:每个 const (...) 块开始时 iota 归 0,块内每新增一行(ConstSpec)自增 1。
4.1 基础自增
const (
A = iota // 0
B // 1(沿用上一行表达式 iota,此时 iota=1)
C // 2
D // 3
)
关键点:常量块内若某行省略了 = 表达式,它会自动重复上一行的表达式,而 iota 每行都在递增。
4.2 跳值与占位
用 _ 跳过不需要的值,iota 照常递增:
const (
_ = iota // 0,丢弃
KB = 1 << (10 * iota) // iota=1 → 1<<10
MB // iota=2 → 1<<20
GB // iota=3 → 1<<30
TB // iota=4 → 1<<40
)
func main() {
fmt.Println(KB, MB, GB, TB)
// 1024 1048576 1073741824 1099511627776
}
4.3 位运算枚举(标志位)
用左移生成互不重叠的位,可用按位或组合、按位与检测:
type Permission uint8
const (
Read Permission = 1 << iota // 1 (0b001)
Write // 2 (0b010)
Execute // 4 (0b100)
)
func main() {
// 组合权限
perm := Read | Write // 0b011 = 3
// 检测是否含某权限
fmt.Println(perm&Read != 0) // true
fmt.Println(perm&Execute != 0) // false
// 用 &^ 清除某权限(见本章第 7 节 运算符)
perm = perm &^ Write // 移除写权限
fmt.Println(perm) // 1
}
4.4 表达式技巧与多组常量
// 每行都能用表达式,iota 只是变量
const (
_ = iota
a = iota * 10 // 10
b // 20(重复 iota*10,iota=2)
c // 30
)
// 同一行多个常量共享同一个 iota 值
const (
x0, y0 = iota, iota + 10 // iota=0 → 0, 10
x1, y1 // iota=1 → 1, 11
x2, y2 // iota=2 → 2, 12
)
4.5 枚举加 String() 方法(工程实践)
裸的整型枚举打印出来是数字,不可读。惯例是配合 stringer 工具生成 String():
type Weekday int
const (
Sunday Weekday = iota
Monday
Tuesday
)
// go:generate stringer -type=Weekday
// 生成后:fmt.Println(Monday) 输出 "Monday" 而非 1
go install golang.org/x/tools/cmd/stringer@latest
go generate ./...
5. 作用域与生命周期
5.1 三级作用域
作用域层级(由外到内):
┌─────────────────────────────────────────┐ ← 包级作用域 package scope
│ Package scope │
│ var / const / func / type declared here │
│ ┌───────────────────────────────────┐ │ ← 函数级作用域 function scope
│ │ Function scope │ │
│ │ func params, vars declared in func│ │
│ │ ┌─────────────────────────────┐ │ │ ← 块级作用域 block scope
│ │ │ Block scope │ │ │
│ │ │ { } / if / for / switch │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
变量查找:由内向外逐层查找,找到第一个即停(内层遮蔽外层)
var g = "package" // 包级
func scope() {
f := "function" // 函数级
{
b := "block" // 块级,出了 {} 即不可见
fmt.Println(g, f, b)
}
// fmt.Println(b) // 编译错误:b 未定义
}
5.2 if / for 的隐式块作用域
if、for、switch 的初始化语句声明的变量,作用域仅限该语句及其分支:
func main() {
if v := compute(); v > 0 { // v 只在 if-else 内可见
fmt.Println("正数", v)
} else {
fmt.Println("非正", v)
}
// fmt.Println(v) // 编译错误:v 已出作用域
}
func compute() int { return 1 }
5.3 生命周期与逃逸
作用域是"编译期可见性",生命周期是"运行期内存存活时长",二者不同。Go 有垃圾回收,变量只要还被引用就存活。局部变量若被外部引用(如返回其地址),编译器会做逃逸分析,把它分配到堆上:
func newCounter() *int {
n := 0 // 局部变量
return &n // 取地址返回 → n 逃逸到堆,函数返回后依然存活
}
# 查看逃逸分析结果
go build -gcflags="-m" main.go
# 输出:./main.go:2:2: moved to heap: n
5.4 可见性由首字母大小写决定
Go 用标识符首字母是否大写来控制包外可见性(导出/exported):
| 首字母 | 可见性 | 示例 |
|---|---|---|
| 大写 | 导出,包外可访问 | Println、MaxInt、User |
| 小写 | 未导出,仅包内可见 | count、parse、userID |
package model
type User struct {
Name string // 导出字段,其他包可读写
age int // 未导出字段,仅 model 包内可访问
}
func NewUser(name string) *User { return &User{Name: name} } // 导出构造函数
func validate(u *User) bool { return u.age >= 0 } // 包内辅助函数
6. 命名规范
Go 官方对命名有一套约定,golint/revive 会检查。核心原则:简洁 + 语境。
6.1 通用规则
- 使用驼峰命名(camelCase / PascalCase),不用下划线:
userName而非user_name。 - 导出用大写驼峰
UserName,未导出用小写驼峰userName。 - 变量名长度与作用域成正比:循环里用
i、v,包级变量用完整名字。
6.2 缩写词全大写或全小写
缩写词(ID、URL、HTTP、API)保持大小写一致,不要写成 Url、Http:
// 推荐
var userID int
var apiURL string
type HTTPServer struct{}
func ServeHTTP() {}
// 不推荐
var userId int // 应为 userID
var apiUrl string // 应为 apiURL
type HttpServer struct{} // 应为 HTTPServer
6.3 包名
- 全小写、单个单词、无下划线无复数:
http、strconv、user。 - 包名应与目录名一致。
- 避免与标准库/常见变量冲突的名字(如别用
util、common这种无意义大杂烩)。
// 调用时包名是前缀,所以包内不用再重复包名
package user
func New() *User {} // 调用方写 user.New(),而不是 user.NewUser()
6.4 receiver 命名约定
方法接收者用 1~2 个字母的短名,通常取类型名首字母缩写,且同一类型的所有方法保持一致:
type Server struct{ addr string }
// 推荐:短且一致,全用 s
func (s *Server) Start() {}
func (s *Server) Stop() {}
// 不推荐:忽长忽短、用 this/self(这不是 Go 风格)
// func (this *Server) Start() {}
// func (server *Server) Stop() {}
6.5 golint 常见规则速查
| 规则 | 反例 | 正例 |
|---|---|---|
| 缩写词大小写 | Id |
ID |
| 不用下划线 | my_func |
myFunc |
| receiver 一致 | this/self |
类型首字母 |
| 导出需注释 | 无注释的 Foo |
// Foo does ... |
| 错误变量名 | Err/e |
err |
| 错误类型 | MyError |
xxxError |
| 错误值 | MyErr |
ErrXxx |
7. 运算符
7.1 算术运算符
func main() {
fmt.Println(7 + 3) // 10
fmt.Println(7 - 3) // 4
fmt.Println(7 * 3) // 21
fmt.Println(7 / 3) // 2 ← 整数除法向零截断
fmt.Println(7 % 3) // 1 ← 取模
// 浮点除法
fmt.Println(7.0 / 3.0) // 2.3333333333333335
// Go 没有 ++x / x++ 作为表达式,只能作为语句
x := 1
x++ // 正确:语句
// y := x++ // 编译错误:x++ 不是表达式
_ = x
}
注意:Go 中 ++/-- 只能是独立语句,不能出现在表达式里,也没有前缀形式 ++x。
7.2 比较与逻辑运算符
func main() {
// 比较运算符返回 bool
fmt.Println(1 == 1, 1 != 2, 1 < 2, 2 >= 2)
// 逻辑运算:&& 和 || 有短路特性
a, b := true, false
fmt.Println(a && b) // false
fmt.Println(a || b) // true
fmt.Println(!a) // false
// 短路:左侧已能确定结果,右侧不再求值
if b && expensiveCheck() { // b 为 false,expensiveCheck 不执行
}
}
func expensiveCheck() bool { fmt.Println("被调用"); return true }
7.3 位运算符(重点:&^)
func main() {
const (
a = 0b1100 // 12
b = 0b1010 // 10
)
fmt.Printf("%04b\n", a&b) // 1000 按位与
fmt.Printf("%04b\n", a|b) // 1110 按位或
fmt.Printf("%04b\n", a^b) // 0110 按位异或
fmt.Printf("%04b\n", a<<1) // 11000 左移
fmt.Printf("%04b\n", a>>1) // 0110 右移
// &^ 按位清零(AND NOT / bit clear)
// a &^ b:b 中为 1 的位,在结果中被清 0;b 中为 0 的位,保留 a 原值
fmt.Printf("%04b\n", a&^b) // 0100
}
&^(bit clear)等价于 a & (^b),常用于从标志位集合中移除某些标志(见 4.3 权限示例),比 perm & ^Write 少一次取反书写,语义更清晰。
a &^ b 真值表(逐位):
a=1 b=1 → 0 (b 为 1 则清零)
a=1 b=0 → 1 (b 为 0 则保留 a)
a=0 b=1 → 0
a=0 b=0 → 0
7.4 没有三元运算符
Go 刻意不提供 a ? b : c,官方理由是三元表达式容易被滥用成难读的嵌套。替代方案是 if-else:
// 其他语言:max := a > b ? a : b
// Go 写法:
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Go 1.21+ 起,内置泛型函数 max/min 可直接用
func main() {
fmt.Println(max(3, 5)) // 5,也可直接用内置 max(3,5)
}
7.5 运算符优先级
Go 只有 5 个优先级,比 C 简单得多(数字越大越先算):
| 优先级 | 运算符 |
|---|---|
| 5 | * / % << >> & &^ |
| 4 | + - | ^ |
| 3 | == != < <= > >= |
| 2 | && |
| 1 | || |
// & 与 == 优先级:& 高于 ==,所以下面按预期工作
if flags&mask == mask { // (flags & mask) == mask
}
8. 类型推断与默认类型
8.1 类型推断原理
Go 编译器在 := 或 var x = 值 时,根据右侧表达式的类型来推断变量类型。若右侧是有类型的值,直接沿用;若是无类型常量,则采用该常量的默认类型。
func main() {
var f float64 = 3.14
x := f // x 是 float64(沿用右侧有类型值)
y := 3.14 // 3.14 是无类型浮点常量 → 默认类型 float64
z := 42 // 42 是无类型整型常量 → 默认类型 int
fmt.Printf("%T %T %T\n", x, y, z) // float64 float64 int
}
8.2 无类型常量的默认类型表
当无类型常量必须"落地"为具体类型(如通过 := 声明、传给 interface{} 参数)时,采用如下默认类型:
| 无类型常量种类 | 默认类型 | 示例 |
|---|---|---|
| 布尔 | bool |
true |
| rune(字符) | rune(int32) |
'A' |
| 整数 | int |
42 |
| 浮点 | float64 |
3.14 |
| 复数 | complex128 |
1+2i |
| 字符串 | string |
"hi" |
func main() {
i := 42 // int
r := 'A' // rune (int32)
f := 3.14 // float64
c := 1 + 2i // complex128
s := "hi" // string
b := true // bool
fmt.Printf("%T %T %T %T %T %T\n", i, r, f, c, s, b)
// int int32 float64 complex128 string bool
}
8.3 类型转换必须显式
Go 不做隐式数值转换(与 C 不同),不同类型运算必须显式转换,否则编译报错:
func main() {
var i int = 10
var f float64 = 3.5
// sum := i + f // 编译错误:mismatched types int and float64
sum := float64(i) + f // 显式转换
fmt.Println(sum) // 13.5
// int → int64 也要显式
var big int64 = int64(i)
_ = big
// 但无类型常量不受此限(编译期适配)
var g float64 = 10 // OK:10 是无类型常量
_ = g
}
9. 高频面试题
9.1 := 与 var 有什么区别?
答:
var可用于包级和函数内,:=只能用于函数内。var可只声明不赋值(取零值),:=必须有初值。var可显式指定类型,:=只能靠推断(得到默认类型)。:=左侧要求至少有一个新变量;若全是旧变量应用=。
9.2 下面这段 iota 输出什么?
const (
a, b = iota, iota + 1 // iota=0
c, d // iota=1
e, f // iota=2
)
答:a=0 b=1、c=1 d=2、e=2 f=3。同一行两个常量共享当前 iota 值;下一行省略表达式则重复上一行的表达式 iota, iota+1,而 iota 每行加 1。
9.3 什么是无类型常量?有什么用?
答:不指定类型的常量以任意精度保存,直到使用时才转换成具体类型。好处:
- 一个常量能适配多种数值类型,无需为每种类型定义一份。
- 常量运算保持高精度,避免中间截断。
- 数学常量(如
math.Pi)能同时用于float32/float64场景。 一旦给常量指定了类型,就失去这种"变形"能力。
9.4 下面代码有什么 bug?
func read() (data string, err error) {
if f, err := open(); err == nil {
data = process(f)
}
return
}
答:变量遮蔽。if f, err := open() 中的 err 是新声明的内层变量,遮蔽了命名返回值 err。当 open() 返回错误时,内层 err 被赋值但外层返回值 err 始终是 nil,调用方拿不到错误。修复:把 f, err := 改为先声明 f、再对返回值 err 用 =,或整体重构。可用 go vet -vettool=$(which shadow) 检测。
9.5 nil 切片和 nil map 有什么区别?
答:
- nil 切片可以直接
append(append 会分配底层数组),len/range也安全。 - nil map 可以读(返回零值)和
range(空循环),但写入会 panic,必须先make。 这体现了 Go “零值可用"的边界:读侧尽量友好,写侧需要显式初始化 map。
9.6 Go 为什么没有三元运算符?没有隐式类型转换?
答:
- 无三元运算符:官方认为
?:易被嵌套滥用降低可读性,坚持用if-else,语言更少即更清晰(Go 1.21 后可用内置max/min覆盖常见场景)。 - 无隐式转换:避免 C 语言中隐式转换引发的精度丢失、符号问题等隐蔽 bug,强制显式转换让类型意图清晰。但无类型常量在编译期可自动适配,兼顾了便利与安全。
9.7 &^ 运算符是什么?
答:&^ 是"按位清零”(AND NOT / bit clear)。a &^ b 会把 b 中为 1 的位在结果里清 0,其余位保留 a 的值,等价于 a & (^b)。常用于从标志位集合中移除特定标志,比手写取反更直观。
9.8 什么是"零值可用"设计?举例。
答:让类型不经初始化就能安全使用。典型例子:
sync.Mutex零值即可直接Lock,无需构造。bytes.Buffer零值可直接Write。sync.WaitGroup、sync.Once均零值可用。 设计自己的类型时也应尽量让零值有意义,减少调用方的初始化负担和出错概率。
小结
- 变量声明:
var通用(包级/函数级、可零值、可指定类型),:=只能在函数内且必须有初值;任何变量不赋值都有确定的零值,这是"零值可用"的基础。 - 变量遮蔽是短声明最大的坑,尤其对命名返回值
err,用go vet -vettool=$(which shadow)检测。 - 常量编译期确定、不可取地址;无类型常量以任意精度保存、随上下文变形,是 Go 常量系统的精髓。
- iota 是常量块内的行计数器,配合左移做位标志、配合
_跳值、配合 stringer 生成可读枚举。 - 作用域分包级/函数级/块级,由内向外查找;可见性由标识符首字母大小写决定。
- 命名遵循驼峰、缩写词全大写(
ID/URL)、包名全小写、receiver 短名一致。 - 运算符没有三元表达式、没有隐式转换,
++/--只能作语句;牢记&^按位清零。 - 类型推断优先沿用有类型值,否则采用无类型常量的默认类型(整数→int、浮点→float64)。
下一章将深入 Go 的基本数据类型:整型、浮点、byte/rune 与类型转换的种种细节。
xingliuhua