Go 的每个变量都从零值开始,数字从不自动转换类型,常量比变量更灵活。本文讲解 var、:=、定长整数、溢出、iota,以及用来查看值的 Printf 格式动词。
在一些别的语言很宽松的地方,Go 对类型要求很严。int 永远不会悄悄变成 float64。变量里永远不会留着垃圾值。放不下的常量,程序运行前就会被发现。
本文讲声明变量、基本类型、零值、类型转换、常量和 iota,还有查看这些值时要用到的 fmt.Printf 格式动词。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果里复制。
声明变量:var 和 :=
Go 有两种声明变量的方式。var 写出变量名,类型和值可写可不写。:= 一步完成声明和赋值,类型从值推断出来。
package main
import "fmt"
var greeting = "hello"
func main() {
var count int
var name string = "Ada"
age := 36
fmt.Println(greeting, count, name, age)
}
输出:
hello 0 Ada 36
count 声明了类型但没给值,所以它是 0。name 类型和值都有。age := 36 从字面量 36 得到类型 int。
两种写法能用的地方不一样:
var在哪儿都能用,包括函数之外的包级别。greeting就是这样声明的。:=只能在函数内部用。在包级别写count := 3,构建会失败,报syntax error: non-declaration statement outside function body。
在函数内部,大多数 Go 代码用 :=。如果你就是想要零值,或者想要的类型和字面量默认给的不一样,比如 var ratio float64 = 2,就用 var。
:= 至少要有一个新名字
:= 是声明。同一作用域里已经存在的变量,不能用它来赋值:
package main
import "fmt"
func main() {
x := 1
x := 2
fmt.Println(x)
}
构建失败,报错:
./main.go:7:4: no new variables on left side of :=
要修改已有的变量,用 x = 2。
有一个例外,而且你会经常见到。当 := 左边有多个名字时,只要至少有一个是新的,就允许这样写。其余的只是赋值:
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
fmt.Println(n, err)
m, err := strconv.Atoi("x7")
fmt.Println(m, err)
}
输出:
42 <nil>
0 strconv.Atoi: parsing "x7": invalid syntax
第二行声明了 m,复用了 err。所以 Go 代码可以一个接一个地调用函数,一直检查同一个 err 变量。拿到它之后怎么处理,讲错误的那一篇会说。
基本类型
大多数 Go 代码有四种基本类型就够用:
| 类型 | 存放 | 例子 |
|---|---|---|
int |
整数 | 42 |
float64 |
带小数部分的数 | 2.99 |
string |
文本 | "Ada" |
bool |
true 或 false |
true |
在你几乎总会用到的 64 位机器上,int 是 64 位。需要确切大小的时候,比如处理文件格式、网络协议,或者要在超大切片里省内存,Go 提供了定长整数:
- 有符号:
int8、int16、int32、int64 - 无符号:
uint8、uint16、uint32、uint64,还有uint
int8 的范围是 -128 到 127。uint8 的范围是 0 到 255。另外还有 float32,但小数默认用 float64,像 2.99 这样的字面量也会变成它。
溢出:运行时回绕,编译时报错
定长整数只能容纳固定范围的值。超出范围时会怎样,取决于 Go 能不能提前看出来。在运行时,值会回绕:
package main
import "fmt"
func main() {
var small int8 = 127
small++
fmt.Println(small)
var u uint8 = 0
u--
fmt.Println(u)
}
输出:
-128
255
最大的 int8 加 1,得到最小的那个。值为 0 的 uint8 减 1,得到 255。没有 panic,也没有警告。这些位就像汽车里程表一样翻了一圈。
如果溢出的值是写在代码里的常量,编译器就能检查,而且确实会检查:
package main
import "fmt"
func main() {
var small int8 = 128
fmt.Println(small)
}
构建失败,报错:
./main.go:6:19: cannot use 128 (untyped int constant) as int8 value in variable declaration (overflows)
所以规则是:常量在构建时检查,变量上的算术在运行时悄悄回绕。如果计数器真可能变得那么大,就给它换个更大的类型。
零值:没有未初始化这回事
Go 里每种类型都有零值,声明时没给值的变量就是这个零值。不存在需要你操心的“未初始化”状态:
package main
import "fmt"
func main() {
var i int
var f float64
var s string
var b bool
var p *int
var nums []int
var ages map[string]int
var point struct{ X, Y int }
fmt.Println(i, f, b, p, nums, ages, point)
fmt.Printf("%q\n", s)
fmt.Println(nums == nil, ages == nil, p == nil)
}
输出:
0 0 false <nil> [] map[] {0 0}
""
true true true
数字从 0 开始。bool 从 false 开始。字符串从 "" 开始,也就是空字符串,所以这里用 %q 打印它:用 Println 什么都看不到。指针、切片和 map 从 nil 开始。结构体的每个字段都是各自的零值,所以 point 是 {0 0}。
用十岁孩子能懂的话说
想象你拿到的每个新盒子里都已经装了东西。装数字的盒子里放着一个 0。装“是或否”的盒子里放着一个“否”。装文字的盒子里放着一张空白卡片。
打开新盒子时,你永远不会看到别人留下的旧垃圾。所以就算忘了往里放东西,你也清楚里面是什么。
准确的说法
Go 为变量分配内存时,不管是用 var、new、make,还是用省略了部分字段的复合字面量,都会把这块内存全部置为零。对每种类型来说,全零都有合理的含义:数字是 0,bool 是 false,字符串是 "",指针、切片、map、通道(channel)、函数和接口是 nil。
所以 Go 程序读不到之前某个值残留的字节,go vet 也不需要警告你使用了未赋值的变量。这也影响了 Go 类型的设计方式。好的类型在零值时就能用:nil 切片就是一个能正常工作的空切片,var total int 声明完就可以往上加。
这个比喻的局限: 有些盒子送来时是空的,而且封死了。nil map 可以读,读任何键都得到零值,但往里写会 panic。讲 map 的那一篇会说明原因和应对办法。
没有隐式转换
Go 从不自动在数值类型之间转换,哪怕是从 int 转到 float64 这种不会丢失任何东西的情况:
package main
import "fmt"
func main() {
count := 3
price := 2.5
fmt.Println(count * price)
}
构建失败,报错:
./main.go:8:14: invalid operation: count * price (mismatched types int and float64)
count 是 int,price 是 float64,而运算符要求两边类型相同。想要哪种转换,就像调用函数一样写出类型名:
package main
import (
"fmt"
"math"
)
func main() {
count := 3
price := 2.99
total := float64(count) * price
fmt.Println(total)
fmt.Println(int(price), int(-price))
fmt.Println(int(math.Round(price)))
big := 300
fmt.Println(uint8(big))
}
输出:
8.97
2 -2
3
44
float64(count) 把 3 变成 3.0,乘法就能进行了。
反过来,int(price) 会截断。它直接丢掉小数部分,所以 2.99 变成 2,-2.99 变成 -2。它是向零取整,不是向下取整,也不是四舍五入。想要四舍五入,先调用 math.Round。
最后一行把值为 300 的 int 转成 uint8。只有低 8 位保留下来,300 减 256 等于 44。和溢出一样,值是常量时编译器能发现:uint8(300) 会报 constant 300 overflows uint8。换成变量,编译器没法知道,值就回绕了。
这种严格要多写几个字。换来的是:Go 程序里每一次转换都明明白白写在那里,值在哪儿丢了精度,你能找得到。
常量:有类型和无类型
常量是构建时就确定的值,用 const 声明。出人意料的是,没有类型的常量可以和好几种类型一起用:
package main
import "fmt"
const ratio = 2
func main() {
var count int = 5
var price float64 = 1.25
var tiny int8 = 3
fmt.Println(count*ratio, price*ratio, tiny*ratio)
fmt.Printf("%T %T %T\n", count*ratio, price*ratio, tiny*ratio)
}
输出:
10 2.5 6
int float64 int8
同一个 ratio 分别乘了 int、float64 和 int8,没有做任何转换。对比一下上面 count * price 的报错。换成一个值为 2 的变量,就做不到这一点。
给常量加上类型,这种灵活性就没了:
package main
import "fmt"
const ratio int = 2
func main() {
var price float64 = 1.25
fmt.Println(price * ratio)
}
构建失败,报错:
./main.go:9:14: invalid operation: price * ratio (mismatched types float64 and int)
现在 ratio 是 int,遵循和任何 int 变量相同的规则。
用十岁孩子能懂的话说
无类型常量就像写在便利贴上的数字 2。你可以把它贴在一罐弹珠、一壶水或一袋面粉上,它的意思就是那里的东西有“两份”。
有类型常量是印在弹珠上的数字 2。它现在就是两颗弹珠,你没法把它倒进水壶里。
准确的说法
无类型常量有一个种类,比如整数、浮点数或字符串,但还没有确定的类型。使用它时,只要值放得下,Go 就给它上下文需要的类型。如果没有别的东西来决定,它就取默认类型:2 是 int,2.5 是 float64,"hi" 是 string。age := 36 的 int 就是这么来的。
无类型常量还是精确的。编译器做常量运算时,精度远超 Go 的任何类型,所以中间值可以非常大,只要最终结果放得下就行:
package main
import "fmt"
const huge = 1 << 100
func main() {
fmt.Println(huge >> 98)
fmt.Println(huge / (1 << 90))
}
输出:
4
1024
1 << 100 放不进 Go 的任何整数类型。作为常量却没问题,因为只有结果 4 和 1024 会真正变成 int 值。
这个比喻的局限: 便利贴也得贴得上去。无类型常量 128 放不进 int8,这就是前面那个溢出错误。
用 iota 实现枚举
Go 没有 enum 关键字。取而代之的是,在 const 块里,iota 这个名字会按行计数:第一行是 0,第二行是 1,依此类推。再配合一个命名类型,就得到了枚举:
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
)
func main() {
fmt.Println(Sunday, Monday, Tuesday, Wednesday)
fmt.Printf("%T %v\n", Tuesday, Tuesday)
}
输出:
0 1 2 3
main.Weekday 2
只有 Sunday 明确写了 = iota。名字后面什么都没写的常量会重复上一行的表达式,所以 Monday 也是 Weekday = iota,只不过所在行的 iota 是 1。四个常量的类型都是 Weekday,而不是普通的 int。
跳过某些值
空白名字 _ 会占掉一行和一个 iota 值,但不会创建常量:
package main
import "fmt"
type Level int
const (
_ Level = iota
Debug
Info
_
Error
)
func main() {
fmt.Println(Debug, Info, Error)
var unset Level
fmt.Println(unset == Debug)
}
输出:
1 2 4
false
第一个 _ 用掉了 0,第二个用掉了 3。跳过 0 是很常见的做法,而且有实际的理由。Level 的零值是 0,所以没人设置过的 Level 不会意外地等于 Debug。unset == Debug 是 false。
用 1 << iota 表示大小和标志位
iota 可以出现在任何常量表达式里,移位是它的经典用法。这里每一行都把 1 再多左移 10 位,所以每个大小都是前一个的 1024 倍:
package main
import "fmt"
type ByteSize int64
const (
_ = iota // 0: thrown away
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
)
func main() {
fmt.Println(KB, MB, GB, TB)
file := 1536 * MB
fmt.Printf("%T\n", file)
fmt.Printf("%.2f GB\n", float64(file)/float64(GB))
}
输出:
1024 1048576 1073741824 1099511627776
main.ByteSize
1.50 GB
KB 所在行的 iota 是 1,所以它是 1 << 10。MB 重复这个表达式,此时 iota 是 2,也就是 1 << 20。1536 * MB 是无类型常量乘以 ByteSize,所以结果也是 ByteSize。
普通的 1 << iota 让每个常量占一个位,Go 代码就是这样写可以组合的标志位的:
package main
import "fmt"
type Permission uint8
const (
Read Permission = 1 << iota
Write
Execute
)
func main() {
fmt.Println(Read, Write, Execute)
perm := Read | Execute
fmt.Printf("%03b %d\n", perm, perm)
fmt.Println(perm&Write != 0, perm&Execute != 0)
}
输出:
1 2 4
101 5
false true
Read | Execute 设置了两个位,%03b 以二进制打印它们。perm&Write != 0 判断某一位有没有被设置。
用 fmt.Printf 查看值
fmt.Printf 接受一个格式字符串,里面是以 % 开头的格式动词。其中几个就能覆盖你想查看的几乎所有东西:
package main
import "fmt"
func main() {
name := "Ada"
age := 36
height := 1.6549
admin := true
tags := []string{"go", "math"}
fmt.Printf("%v %v %v %v %v\n", name, age, height, admin, tags)
fmt.Printf("%T %T %T %T %T\n", name, age, height, admin, tags)
fmt.Printf("%q %q\n", name, tags)
fmt.Printf("%d|%5d|%-5d|\n", age, age, age)
fmt.Printf("%.2f %8.2f\n", height, height)
}
输出:
Ada 36 1.6549 true [go math]
string int float64 bool []string
"Ada" ["go" "math"]
36| 36|36 |
1.65 1.65
%v以默认格式打印任何值。Println用的就是它。%T打印类型。想知道“:=到底给了我什么”,这是最快的办法。%q给字符串加上引号打印,这样空字符串和多余的空格都能看出来。它对字符串切片也有效。%d打印整数。中间加个数字表示最小宽度,加-表示在右边补空格,而不是左边。%.2f打印保留两位小数的浮点数,会四舍五入。%8.2f还会补齐到 8 个字符。
对结构体,%v 还有两个更详细的版本:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
p := Point{3, 4}
fmt.Printf("%v\n", p)
fmt.Printf("%+v\n", p)
fmt.Printf("%#v\n", p)
}
输出:
{3 4}
{X:3 Y:4}
main.Point{X:3, Y:4}
%+v 会加上字段名,调试输出更好读。%#v 把值按 Go 语法打印出来,连类型名都带上。
用错格式动词不会让程序停下。fmt.Printf("%d\n", "Ada") 会打印 %!d(string=Ada),然后继续运行。但 go vet 在你运行之前就能发现:fmt.Printf format %d has arg "Ada" of wrong type string。每次改动都跑一下 go vet,这个习惯值得养成。
byte 和 rune 是整数的别名
处理文本时,你马上会遇到两个类型名,它们都不是新类型。byte 是 uint8 的别名,rune 是 int32 的别名:
package main
import "fmt"
func main() {
var b byte = 'A'
var r rune = 'é'
fmt.Println(b, r)
fmt.Printf("%T %T\n", b, r)
fmt.Printf("%c %c %q\n", b, r, r)
var u uint8 = b
var i int32 = r
fmt.Println(u, i)
fmt.Println(len("café"))
}
输出:
65 233
uint8 int32
A é 'é'
65 233
5
Println 显示的是数字,因为它们本来就只是数字。%T 甚至不会显示 byte 或 rune:它报告的是 uint8 和 int32,因为别名就是同一个类型换了个名字。这也是 var u uint8 = b 不需要转换的原因。想看到字符,用 %c,想看带引号的字符,用 %q。
最后一行预告了后面的内容。"café" 有四个字符,长度却是 5,因为 len 统计的是字节数,而 é 占两个字节。讲字符串、字节和 rune 的那一篇会解释原因。
要点
var在哪儿都能用,省略值时得到零值。:=只能在函数内部用,而且左边至少要有一个新名字。- 每种类型都有零值:0、
false、""或nil。Go 变量永远不会处于未初始化状态。 - 定长整数在运行时会悄悄回绕。放不下的常量是编译错误。
- Go 从不替你转换数字。写
float64(n)或int(f),并记住int(f)是向零截断。 - 无类型常量会取上下文需要的类型,只要值放得下。有类型常量的行为和该类型的变量一样。
iota给const块的每一行计数。用_跳过值,常常是跳过 0,用1 << iota表示标志位。%v、%T和%q是看清一个值到底是什么的最快办法。
在 Go 里,每个值都有一个可以打印出来的类型,和一个可以预料的初始值。