零值:Go 没有构造函数
在 Kotlin 里,一个没初始化的对象引用是 null,碰它就 NPE;一个非空字段你必须在构造函数里赋值,否则编译不过。Go 把整个构造函数机制删了。那 var u User 得到的是什么?不是 null,是一个每个字段都被填成「零值」的、拿来就能用的 User。这一章讲清零值是什么、为什么它是 Go 类型系统的地基。
先问一个你可能没想过的问题
在 Kotlin 里,你写 var u: User,然后不给它赋值就用,会怎样?编译器不让你过 —— 要么它是可空的 User?(那你得处处判空),要么你必须立刻初始化。「一个变量声明了但还没有值」这个状态,在 Kotlin 里是被严防死守的,因为那个状态在 Java 里就是 null,就是 NPE 的源头。
Go 对这个问题的回答完全不同,而且简单到有点粗暴:每一种类型,都有一个「零值」。你声明一个变量却不赋初值,它就自动等于那个零值。不存在「声明了但没有值」这个状态 —— 声明的一瞬间,它就已经是零值了。
var n int // n 是 0 var s string // s 是 ""(空字符串,不是 nil!) var b bool // b 是 false var p *User // p 是 nil var u User // u 是一个所有字段都取零值的 User 结构体
注意最后两行的区别,它是理解 Go 的关键:var p *User 是一个指针,它的零值是 nil;而 var u User 是一个结构体值,它不是 nil,它是一个实实在在存在的 User,只不过里面每个字段都是零值。Go 里的结构体默认是「值」不是「引用」 —— 这个下一章专门讲,先记住这个反差。
零值是递归的
一个结构体的零值,就是把它每个字段都取零值。字段如果又是结构体,就再往里递归。规则很整齐:
| 类型 | 零值 |
|---|---|
int / float64 / 各种数字 | 0 |
string | ""(空串,长度 0,不是 nil) |
bool | false |
指针 *T | nil |
切片 []T | nil(但长度是 0,可直接 append) |
map map[K]V | nil(可读,写会 panic) |
chan / func / 接口 / error | nil |
数组 [3]int | [0 0 0](每个元素取零值) |
结构体 struct | 每个字段递归取零值 |
下面这台 Demo 就是一个零值检视器。你敲进一个 struct,它逐字段算出零值 —— 注意,这些结果不是我写死的,是它当场按上面这张表算的:
试试第一个例子 User:7 个字段,你一个都没赋值,它就已经是一个完整的、能打印能传递的 User 了 —— 数字 0、字符串 ""、bool false、指针 / slice / map 都是 nil。没有构造函数,没有 init,没有任何仪式。var u User 这一行,就是全部。
为什么删掉构造函数是个大胆的决定
构造函数在 OOP 语言里是天经地义的。它负责「把对象初始化成一个合法状态」—— 校验参数、设默认值、建立不变式。删掉它,听起来像是要出乱子:谁来保证对象一创建就是合法的?
Go 的赌注是:如果零值本身就是一个合法、可用的状态,你就根本不需要构造函数来「修正」它。这个约束反过来逼着 Go 的库设计者做一件很了不起的事 —— 把零值设计得有意义。举几个标准库里的经典例子:
var mu sync.Mutex // 零值就是一把「没上锁的锁」,直接能 Lock() var buf bytes.Buffer // 零值就是一个空缓冲区,直接能 Write() var wg sync.WaitGroup // 零值就是一个计数为 0 的等待组,直接能用
这三个类型你都不用 New 什么,var 一声明就能用。这不是巧合,是 Go 的库作者刻意把零值设计成「开箱即用」的初始状态。「零值可用」(zero value is useful)是 Go 社区一条重要的设计准则。
这是 Go 减法哲学一个很深的例子:删掉一个特性,反而逼出了更好的设计。
因为没有构造函数可以依赖,库作者必须让「什么都不做」就是一个好状态。于是 sync.Mutex 的零值是解锁的、bytes.Buffer 的零值是空的、一个 nil 切片可以直接 append —— 这些「零值即可用」的贴心设计,很大程度上是「没有构造函数」这个约束逼出来的。限制不总是坏事,它常常是好品味的催化剂。
那需要初始化逻辑时怎么办
零值不够用的时候当然有 —— 比如一个字段必须是非零的、或者创建时要连数据库。Go 的答案是一个约定,不是一个语言特性:写一个普通函数,名字以 New 开头。
// 约定俗成的「构造函数」:就是个返回该类型的普通函数
func NewUser(name string, age int) *User {
return &User{
Name: name,
Age: age,
Roles: make(map[string]bool), // map 零值是 nil,这里得显式建
}
}
// 调用方
u := NewUser("harry", 30)
看清楚:NewUser 没有任何特殊地位,它就是一个返回 *User 的普通函数,Go 不认识「构造函数」这个概念。约定代替了语法。你想要多个「构造函数」?起不同的名字(NewUserFromJSON、NewGuestUser)—— 还记得第 1 章账本里「重载没了」吗,这就是替代方案:不重载,起不同的名字。
那个 User{Name: name, Age: age} 叫复合字面量(composite literal),是 Go 里创建结构体的标准方式。你按字段名填你在意的,没填的自动是零值。这又回到了本章主题:零值是你永远的兜底。
切片的零值 nil 很温和 —— 你可以对一个 nil 切片直接 append,它会自动分配。但 map 的零值 nil 是个陷阱:你可以从 nil map 读(得到零值),但往 nil map 写会直接 panic。所以 map 字段几乎总要在 New 函数里用 make(map[...]...) 显式建出来。这是「零值可用」原则的一个例外,也是新手常踩的坑。
Kotlin 对照
Kotlin 花了巨大力气对付「未初始化 / null」这个状态:可空类型 T?、非空断言 !!、lateinit、by lazy、构造函数里的强制初始化、init 块 —— 这一整套机制,都是为了保证「你拿到的对象一定是合法初始化过的」,把 null 关进笼子里。
Go 用「零值」把这个问题从根上换了个问法。它不问「这个变量初始化了吗」,因为声明即零值,永远有值。基础类型(int / string / bool / struct)根本没有 null 这个状态 —— var n int 就是 0,不可能是 null。只有指针、切片、map、接口这些「引用类」的东西才有 nil。
代价是:Go 没有 Kotlin 那种编译期空安全。一个 *User 指针可能是 nil,你解引用它照样 panic(相当于 NPE),编译器不拦你。Go 的选择是「减少能为 nil 的东西 + 让零值可用」,而不是「用类型系统标记可空性」。你会想念 Kotlin 的 ?. —— 但你也会享受「一个 struct 声明出来就是完整可用的」这种干脆。
这一章的一句话
Go 删掉了构造函数,代之以「每种类型都有零值、声明即为零值」;这个约束逼着好的库把零值设计成开箱即用的状态,而需要初始化逻辑时,一个叫 New 开头的普通函数(约定,非语法)就够了。
本章反复提到「结构体是值,指针才是引用」。这个区分是理解 Go 的下一道坎,也是你 Kotlin 直觉最容易翻车的地方。下一章我们正面拆它:为什么 Go 里几乎一切默认都是复制。