Blog

Go 的值与类型:零值、常量和 iota

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 truefalse true

在你几乎总会用到的 64 位机器上,int 是 64 位。需要确切大小的时候,比如处理文件格式、网络协议,或者要在超大切片里省内存,Go 提供了定长整数:

  • 有符号:int8int16int32int64
  • 无符号:uint8uint16uint32uint64,还有 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 开始。boolfalse 开始。字符串从 "" 开始,也就是空字符串,所以这里用 %q 打印它:用 Println 什么都看不到。指针、切片和 map 从 nil 开始。结构体的每个字段都是各自的零值,所以 point{0 0}

用十岁孩子能懂的话说

想象你拿到的每个新盒子里都已经装了东西。装数字的盒子里放着一个 0。装“是或否”的盒子里放着一个“否”。装文字的盒子里放着一张空白卡片。

打开新盒子时,你永远不会看到别人留下的旧垃圾。所以就算忘了往里放东西,你也清楚里面是什么。

准确的说法

Go 为变量分配内存时,不管是用 varnewmake,还是用省略了部分字段的复合字面量,都会把这块内存全部置为零。对每种类型来说,全零都有合理的含义:数字是 0,boolfalse,字符串是 "",指针、切片、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)

countintpricefloat64,而运算符要求两边类型相同。想要哪种转换,就像调用函数一样写出类型名:

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 分别乘了 intfloat64int8,没有做任何转换。对比一下上面 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)

现在 ratioint,遵循和任何 int 变量相同的规则。

用十岁孩子能懂的话说

无类型常量就像写在便利贴上的数字 2。你可以把它贴在一罐弹珠、一壶水或一袋面粉上,它的意思就是那里的东西有“两份”。

有类型常量是印在弹珠上的数字 2。它现在就是两颗弹珠,你没法把它倒进水壶里。

准确的说法

无类型常量有一个种类,比如整数、浮点数或字符串,但还没有确定的类型。使用它时,只要值放得下,Go 就给它上下文需要的类型。如果没有别的东西来决定,它就取默认类型:2int2.5float64"hi"stringage := 36int 就是这么来的。

无类型常量还是精确的。编译器做常量运算时,精度远超 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 不会意外地等于 Debugunset == Debugfalse

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 << 10MB 重复这个表达式,此时 iota 是 2,也就是 1 << 201536 * 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,这个习惯值得养成。

byterune 是整数的别名

处理文本时,你马上会遇到两个类型名,它们都不是新类型。byteuint8 的别名,runeint32 的别名:

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 甚至不会显示 byterune:它报告的是 uint8int32,因为别名就是同一个类型换了个名字。这也是 var u uint8 = b 不需要转换的原因。想看到字符,用 %c,想看带引号的字符,用 %q

最后一行预告了后面的内容。"café" 有四个字符,长度却是 5,因为 len 统计的是字节数,而 é 占两个字节。讲字符串、字节和 rune 的那一篇会解释原因。

要点

  • var 在哪儿都能用,省略值时得到零值。:= 只能在函数内部用,而且左边至少要有一个新名字。
  • 每种类型都有零值:0、false""nil。Go 变量永远不会处于未初始化状态。
  • 定长整数在运行时会悄悄回绕。放不下的常量是编译错误。
  • Go 从不替你转换数字。写 float64(n)int(f),并记住 int(f) 是向零截断。
  • 无类型常量会取上下文需要的类型,只要值放得下。有类型常量的行为和该类型的变量一样。
  • iotaconst 块的每一行计数。用 _ 跳过值,常常是跳过 0,用 1 << iota 表示标志位。
  • %v%T%q 是看清一个值到底是什么的最快办法。

在 Go 里,每个值都有一个可以打印出来的类型,和一个可以预料的初始值。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。