众所周知,Go 里没有 undefined ,只有各类型的零值。多年来,Go 开发者一直依赖 JSON 结构标签 omitempty
来解决"字段可能缺失"这一需求。
然而omitempty
并不能覆盖所有场景,而且常常让人抓狂------到底什么算"空"?定义本就含糊不清。
在 编码(marshal) 时:
- 切片和 map 只有在为
nil
或长度为 0 时才算空。 - 指针只有
nil
时为空。 - 结构体永远不算空。
- 字符串长度为 0 时为空。
- 其余类型为各自的零值时为空。
而在 解码(unmarshal) 时......你根本无法区分:
- 输入里根本没有这个字段,还是该字段存在且值正好是 Go 的零值。
omitempty
需要考虑的情况太多,既不方便又容易出错。
常见变通办法
社区常见的权宜之计是对"可能缺失"的字段统统用指针类型,并配合 omitempty
:
- 编码时,
nil
字段一定不会写进输出。 - 解码时,字段若为
nil
,即可判断输入里没有此字段。
但这并不完美。当你需要"可空值"(null
本身就是业务允许的合法值)时,一切又回到原点:
- 解码时无法分辨字段缺失还是值为
null
(Go 对应nil
)。 - 编码时若继续用
omitempty
,那么值为nil
的字段又会被省略。
此外,大量指针也意味着到处都是判空和解引用,繁琐且易出错。
解决方案
随着 Go 1.24 引入 omitzero
标签,我们终于可以优雅地解决这一切。
omitzero
比 omitempty
简单得多:字段若为零值就被省略。它同样适用于结构体------当且仅当其所有字段都是零值时才算零。
举个例子,想省略零值的 time.Time
字段,如今只需:
go
type MyStruct struct {
SomeTime time.Time `json:",omitzero"`
}
再也不会输出 0001-01-01T00:00:00Z
了!不过仍有遗留难题:
- 编码时如何处理"可空值"?
- 如何区分"零值"与"未定义"?
- 解码时如何区分 null 与字段缺失?
Undefined 包装类型
得益于 omitzero
对结构体的支持,我们可以设计一个通用包装类型来一次性解决以上问题。思路:利用结构体"零值"+omitzero
标签。
go
type Undefined[T any] struct {
Val T // 实际值
Present bool// 标记字段是否出现
}
只要 Present
设为 true
,结构体就不再是零值;由此我们便能确定"字段已出现"。再实现 json.Marshaler
与 json.Unmarshaler
接口,使其按预期工作:
go
func (u *Undefined[T]) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &u.Val); err != nil {
return fmt.Errorf("Undefined: 反序列化失败: %w", err)
}
u.Present = true
return nil
}
func (u Undefined[T]) MarshalJSON() ([]byte, error) {
data, err := json.Marshal(u.Val)
if err != nil {
return nil, fmt.Errorf("Undefined: 序列化失败: %w", err)
}
return data, nil
}
// 供 encoding/json 判断零值
func (u Undefined[T]) IsZero() bool {
return !u.Present
}
- 若输入缺少该字段,
UnmarshalJSON
根本不会被调用,Present
仍为false
→ "未定义"。 - 若字段存在(哪怕值为
null
/零值),我们会运行UnmarshalJSON
并把Present
设为true
→ "已出现"。 - 编码时只输出
Val
本身;若Present=false
,omitzero
会令其整体被省略。 IsZero()
让标准库更高效地判断零值。
泛型参数 T
使其能包装任何类型,一劳永逸。
进一步扩展
同理也可实现数据库扫描(sql.Scanner
)接口------这样就能区分列是否被查询出来。完整实现已收录在 Goyave 框架中,内含更多实用工具与特性。