大家好,我是极客老墨!

从 Java 或 Python 转 Go 的时候,我在变量声明这块卡了好一会儿。

不是说 Go 的变量有多复杂,而是它给了你好几种声明方式。var 和 := 到底啥区别?类型推导怎么玩?指针和 Java 有啥不同?常量还能玩出花来?

这篇文章一次讲透。

变量声明:var 和 :=

Go 有两种主要声明变量的方式。

用 var 声明

这是最"正统"的写法,适合先声明、后赋值的场景:

 1// 声明单个变量
 2var name string
 3name = "Go"
 4
 5// 声明时直接赋值(类型可省略)
 6var age = 18  // Go 自动推导为 int
 7
 8// 批量声明
 9var (
10    x int
11    y float64
12    z bool
13    s string
14)

用 := 声明

这是 Go 的语法糖,声明 + 赋值一步到位:

1// 必须初始化,Go 自动推导类型
2message := "Hello, Go!"
3fmt.Println(message)
4
5// 接收函数返回值
6result, err := someFunc()

什么时候用哪个?

用 var 的场景

  • 先声明后赋值
  • 全局变量
  • 需要指定类型

用 := 的场景

  • 局部变量
  • 声明 + 赋值同时进行
  • 接收函数返回值

⚠️ 重要:= 是声明,不是赋值。已声明的变量不能再用 :=

1var name = "Go"
2name := "Python"  // ❌ 编译错误:no new variables
3name = "Python"   // ✅ 正确:赋值

基本数据类型

Go 的基本数据类型分几大类。

整数类型

 1// 有符号整数
 2var i1 int8   // 范围:-128 ~ 127
 3var i2 int16  // 范围:-32768 ~ 32767
 4var i3 int32  // 范围:-21亿 ~ 21亿
 5var i4 int64  // 范围:很大
 6
 7// 无符号整数(只能存非负数)
 8var u1 uint8   // 范围:0 ~ 255
 9var u2 uint16  // 范围:0 ~ 65535
10var u3 uint32
11var u4 uint64
12
13// 平台相关(推荐日常使用)
14var i int     // 32 或 64 位,取决于系统
15var u uint    // 同上
16
17// 特殊类型
18var b byte    // uint8 的别名,存单个字节
19var r rune    // int32 的别名,存 Unicode 字符

选择建议:日常用 intint64,需要精确控制内存时用具体类型。

浮点数类型

 1var f1 float32  // 精度约 7 位小数
 2var f2 float64  // 精度约 15 位小数(推荐)
 3
 4// 浮点数比较要小心
 5a := 0.1 + 0.2
 6b := 0.3
 7fmt.Println(a == b)  // ❌ false!精度问题
 8
 9// 正确做法:比较差值
10diff := a - b
11if math.Abs(diff) < 0.0001 {
12    fmt.Println("近似相等")
13}

布尔类型

1var flag bool  // 默认 false
2flag = true
3
4// 逻辑运算
5fmt.Println(true && false)  // false(与)
6fmt.Println(true || false)  // true(或)
7fmt.Println(!true)          // false(非)

字符串类型

 1var s1 string  // 默认 ""
 2s1 = "Hello"
 3
 4// 字符串是不可变的
 5s1[0] = 'h'  // ❌ 编译错误
 6
 7// 获取长度(字节数)
 8fmt.Println(len(s1))  // 5
 9
10// 获取字符数(Unicode)
11fmt.Println(len([]rune(s1)))  // 5
12
13// 字符串拼接
14s2 := "Go"
15s3 := s2 + " is great"
16s3 += "!"  // 简写形式

byte 和 rune

这两个是 Go 处理字符的关键:

 1// byte 是 uint8 的别名,存单个字节
 2var b byte = 'A'  // ASCII 字符
 3fmt.Println(b)    // 65
 4
 5// rune 是 int32 的别名,存 Unicode 字符
 6var r rune = '中'  // Unicode 字符
 7fmt.Println(r)    // 20013
 8
 9// 遍历字符串
10s := "Hello, 世界"
11for i, ch := range s {
12    // ch 是 rune 类型
13    fmt.Printf("%d: %c (%d)\n", i, ch, ch)
14}

要点

  • byte 存 ASCII(1 字节)
  • rune 存 Unicode(4 字节)
  • 遍历字符串时用 range,自动按字符(rune)遍历

类型推导

Go 会根据初始值自动推导类型:

 1// 整数 → int
 2var a = 10
 3fmt.Printf("Type: %T\n", a)  // int
 4
 5// 浮点数 → float64
 6var b = 3.14
 7fmt.Printf("Type: %T\n", b)  // float64
 8
 9// 字符串 → string
10var c = "Go"
11fmt.Printf("Type: %T\n", c)  // string
12
13// 多变量推导
14x, y := 10, 20
15fmt.Printf("x: %T, y: %T\n", x, y)  // x: int, y: int
16
17// 函数返回值推导
18name := getName()  // 函数返回 string,name 就是 string

注意:= 只能用于新变量,已声明的变量不能重新推导类型。

常量与 iota

常量用 const 声明,编译期确定值:

1const PI = 3.14159
2const MaxAge = 120
3
4// 批量声明
5const (
6    StatusOK      = 200
7    StatusNotFound = 404
8    StatusServerError = 500
9)

iota:枚举生成器

iota 是常量组的行索引,从 0 开始:

 1const (
 2    a = iota  // 0
 3    b = iota  // 1
 4    c = iota  // 2
 5)
 6
 7// 简写(同一行可以省略)
 8const (
 9    d = iota  // 0
10    e        // 1(自动继承上一行的 iota)
11    f        // 2
12)
13
14// 跳值用法
15const (
16    _ = iota        // 0(跳过)
17    KB = 1 << (10 * iota)  // 1 << 10 = 1024
18    MB                        // 1 << 20
19    GB                        // 1 << 30
20)
21
22// 混合使用
23const (
24    FlagRead    = 1 << iota  // 1
25    FlagWrite               // 2
26    FlagExecute             // 4
27)

iota 常见用法

  • 生成枚举值
  • 生成位掩码
  • 生成常量序列

Go 中的常量很重要,也很“与众不同”,这里只是混个眼熟,下一篇我们还要重点介绍它。

指针

Go 有指针,但不像 C 那样危险。

基本用法

1// & 取地址
2x := 10
3p := &x  // p 是 *int 类型,存 x 的地址
4
5// * 解引用
6fmt.Println(*p)  // 10(读取 p 指向的值)
7*p = 20          // 修改 p 指向的值
8fmt.Println(x)   // 20(x 也变了)

指针作为函数参数

 1func swap(a, b *int) {
 2    // 交换指针指向的值
 3    *a, *b = *b, *a
 4}
 5
 6func main() {
 7    x, y := 10, 20
 8    swap(&x, &y)
 9    fmt.Println(x, y)  // 20 10
10}

new 函数

1// new 创建指针并初始化为零值
2p := new(int)  // *int,值为 0
3*p = 100
4fmt.Println(*p)  // 100

要点

  • 指针存的是内存地址
  • & 取地址,* 解引用
  • 指针传递可以实现"引用传递"效果
  • Go 有垃圾回收,不用担心内存释放

变量的作用域

Go 的变量有作用域,块级作用域:

 1var x = "全局"
 2
 3func main() {
 4    var x = "局部"
 5    fmt.Println(x)  // 局部(局部变量遮蔽全局变量)
 6
 7    // 内层块
 8    {
 9        var x = "内层"
10        fmt.Println(x)  // 内层
11    }
12
13    fmt.Println(x)  // 局部
14}
15
16func other() {
17    fmt.Println(x)  // 全局(访问不到 main 的局部变量)
18}

作用域规则

  • 大括号 {} 定义新的作用域
  • 变量遮蔽 (Variable Shadowing):内层作用域可以定义与外层同名的变量,此时内层变量会“遮蔽”外层变量。
  • 循环变量作用域:在 Go 1.22+ 中,for 循环的变量在每次迭代中都是新变量。

匿名变量:_

_ 是匿名变量,用来忽略不需要的返回值:

 1func getUser() (name string, age int, email string) {
 2    return "Alice", 30, "alice@example.com"
 3}
 4
 5func main() {
 6    // 只需要 name
 7    name, _, _ := getUser()
 8    fmt.Println(name)
 9
10    // 只需要 email
11    _, _, email := getUser()
12    fmt.Println(email)
13}

注意_ 不能读取值,赋值给它会丢弃。

完整示例

把上面的知识点串起来:

 1package main
 2
 3import "fmt"
 4
 5const (
 6    StatusOK = iota
 7    StatusPending
 8    StatusFailed
 9)
10
11func swap(a, b int) (int, int) {
12    return b, a
13}
14
15func main() {
16    // 变量声明
17    var name string = "Go"
18    version := 1.21
19
20    // 指针
21    ptr := &version
22    fmt.Printf("Value: %d, Pointer: %d\n", version, *ptr)
23
24    // 常量
25    fmt.Printf("Status: %d\n", StatusOK)
26
27    // 多重赋值和交换
28    x, y := 10, 20
29    x, y = swap(x, y)
30    fmt.Printf("After swap: x=%d, y=%d\n", x, y)
31
32    // 匿名变量
33    _, age := "Bob", 25
34    fmt.Printf("Age: %d\n", age)
35}

输出:

Value: 1, Pointer: 824633794576
Status: 0
After swap: x=20, y=10
Age: 25

老墨踩过的坑

说变量会玩“躲猫猫”,可不是跟你开玩笑的。下面这几个坑,老墨可是用真金白银的 Bug 换来的教训。

坑 1:幽灵般的“影子变量” (Shadowing)

这是 Go 最隐蔽的坑,没有之一。看这段代码:

 1var user = "Guest"
 2
 3func main() {
 4    isVip := true
 5    if isVip {
 6        // ❌ 坑在这里!用 := 声明了一个新的局部 user
 7        user, err := getUserName() 
 8        fmt.Println("内部用户:", user) // 打印 VipUser
 9    }
10    fmt.Println("外部用户:", user) // 打印 Guest!外面的变量没变
11}

虽然在 if 块里你觉得改了 user,但因为用了 :=,Go 偷偷在 if 的作用域里创建了一个同名变量,把外面的变量“遮住”了。最常见也最危险的场景是在处理 err 时:

 1func main() {
 2    n, err := f1()
 3    if err != nil { return }
 4
 5    if n > 0 {
 6        // ❌ 这里的 n 和 err 都是新声明的局部变量!
 7        n, err := f2() 
 8        if err != nil { return }
 9        fmt.Println("内部 n:", n)
10    }
11    // ⚠️ 这里的 n 依然是 f1 返回的值,而不是 f2 的
12    fmt.Println("外部 n:", n)
13}

避坑指北

  • 手动补救:想改外部变量,请先用 var err error 声明,然后使用 = 进行赋值,而不是 :=
  • 工具揪影子:安装 shadow 工具并集成到 lint 中(go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest),或者使用 go vet 加载该插件。

坑 2:类型系统的“洁癖”

别的语言可能帮你偷偷转了,Go 绝对不惯着你。

1var a int = 10
2var b int64 = 20
3// c := a + b // ❌ 编译报错:invalid operation (mismatched types int and int64)

哪怕你觉得 int 在 64 位系统下就是 int64,编译器也会坚持认为它们是两个物种。

避坑指北

  • 涉及不同类型计算,必须显式强转:c := int64(a) + b
  • 常量(无类型)混用没问题,变量绝对不行。

坑 3:空指针的“自作多情”

声明了指针就开始用,是新手最容易犯的错。

1var p *int
2// *p = 100 // ❌ 运行时 Panic: invalid memory address or nil pointer dereference

指针默认是 nil,它还没指向任何地方呢,你就想往里面塞值?

避坑指北

  • 用指针前,先用 new()& 初始化。
  • 永远记得检查 if ptr != nil

老墨总结

Go 变量和常量的 5 个关键点:

  1. 声明方式var 适合先声明后赋值,:= 适合声明+赋值一步到位
  2. 基本类型:int 系列、float64、bool、string、byte、rune
  3. 指针& 取地址,* 解引用,传递指针可以实现引用传递
  4. 常量const 声明,iota 生成枚举值
  5. 作用域:块级作用域,内层遮蔽外层

实战建议

  • 日常用 intfloat64string,不用纠结具体类型
  • 指针传递比值传递更高效,但要注意 nil 检查
  • 常量组用 iota 生成枚举,清晰又安全
  • 多返回值用 _ 忽略不需要的值
  • 变量命名用驼峰,const 用全大写加下划线

你在变量声明上踩过什么坑?指针和 Java 比有什么不同的感觉?欢迎评论区聊聊!

练习题

  1. 编写程序,交换两个整数的值(用函数和不用函数两种方式)
  2. 定义常量组,使用 iota 生成周一到周日的枚举值
  3. 写一个函数,接收字符串,返回字节切片和字符切片
  4. 理解以下代码的输出结果:
    1x := 10
    2p := &x
    3*p = 20
    4fmt.Println(x)
    
  5. 创建一个程序,演示不同整数类型的取值范围

思考题

  1. 为什么 Go 要设计 := 这种语法糖? 它解决了什么痛点?
  2. byte 和 rune 有什么区别? 什么场景下必须用 rune?
  3. 指针传递和值传递怎么选? 什么情况下用指针更高效?
  4. iota 的行索引是怎么计算的? 为什么有时候需要用 _ 跳过?

评论区秀出你的想法,大家一起讨论!


极客老墨,继续折腾!

如果有任何问题,欢迎在评论区留言或关注公众号「极客老墨」交流。

本文示例代码在 go-tutorial-code/03-var


相关阅读