祂说:「孩子,我们聊聊世界和平吧。」
最后是用 sql.NullTime 搞定的。我感到很惊奇。解决了别的语言不存在的问题。
但是我也很困惑,当初没有像 Rust 一样杜绝 nil ,那既然允许了别的值为 nil ,为什么 time.Time 不可以允许 nil 呢?

能啊,你用指针 *time.Time

啊哈,有可能行。我刚试的时候可能是油别的地方没搞对。明天再试试,

  • 油 -> 有

没错啊 比较常见的做法就是 *time.Timesql.NullTime

第一次用 null safety 语言吗...

就用 time.Time 有什么不妥吗?
通过 time.Time.IsZero() 方法来判断是否为空,大多时候外部过来的 null 值,在业务层并不严格区分传过来的时候是 nil 还是空(特殊情况例外),会统一认定为无意义的空值。如果输出的 JSON 格式不想看到 0001-01-01 这样,可以 go1.22 可以在 json tag 中加上 omitzero 。

这个其实不是 null safety ,这个是值类型。

因为 time.Time 是个值,不是引用,只有引用才可以是 null 。
*time.Time 是 pointer ,是引用,所有的 interface 都是引用。

就算用 *time.Time 或 sql.NullTime 也需要至少判断 if at != nil {} 或者 if at.Valid {} 来确定是否为 nil ,确定不为 nil ,也不代表 at 是一个有意义的时间。比如可能还是 at.IsZero()

不是的,sql.NullTime 这个东西如果 Valid ,那么里面的 Time 就是有意义的。就算它 IsZero()=true 它也是有意义的,0001-01-01 是个有意义的时间点

#10 看我 #6 回复:大多数业务层会把 IsZero() 这种当作无意义的值忽略处理,如果你严格区分 null 和 零值,那确实需要指针或者 sql.NullTime 之类的

也可以在写 sql 的时候写 ifnull(field, date(0))

用指针就行了

#10 我要表达的意思是,举个例子:
假设数据库中有个 vip 字段。0-不是 VIP 1-VIP 2-超级 VIP 。如果数据库中存在一个 vip 字段是 null ,但是 Go 中定义照样可以是 int ,因为 null 映射为 int 零值:0 ,null 和 0 都代表不是 VIP 。if vip != 0 { // 是尊贵的 VIP }如果 Go 设计成 *int ,就需要 if vip != nil && vip != 0 { // 是尊贵的 VIP} 这种判断了。

要不要区分 null value 和 zero value ,我认为大多情况下,设计好了是不需要区分的。但是不代表任何场景都不用区分。主要还得结合业务匹配最佳实践。

如果不为了追究最佳实践,其实 指针 和 sql.NullXXX 确实是通用的解决思路。

这种时候就体现语义严格的好处了,Rust 和 Scala 中的设计 Some 就是有 None 就是无,nil null pointer 是绝对不允许出现的,go 的话好像也有相关的 Option 库

你就说用 golang 写业务蛋疼不蛋疼吧

自己造一个 Optional

这个确实就很蛋疼,状态字段设计的时候都特意规避 0 值的使用,都是-1,1,2,3 这样。

其实就是要不要区分零值跟 NULL 值,只不过很多人想要的是 NULL 值,但是 golang 默认给了零值( golang 默认初始化所有值)
一开始就设计错了,导致后面积重难返

这也算是 go 里最经典的坑了

package main

import (
 "encoding/json"
 "fmt"
)

type User struct {
 Name string
 Age int
}

func main() {
 text := `{"Name":"Bob"}`
 var user User
 err := json.Unmarshal([]byte(text), &user)
 if err != nil {
 panic(err)
 }
 fmt.Println(user)
}

直觉上应该报错,但是实际上解析成功了,但是 Age 是 0 。

为啥算坑,我觉得 age 为 0 还蛮符合直觉的,反而报错有点不符合直觉。

我的理解是,比如 struct A {A,B}。
用字符串"{A:xxx,B:xxx,C:xxx}"可以成功解析,但是"{A:xxx}"不应该成功解析,因为缺少 B 字段的信息,这种情况要是想要成功解析需要库提供一种假如没有这个字段就用默认值的方法,但是不应该把这种解析方式当成库的缺省实现。
对于这个例子还好,因为 age 年龄逻辑上不可能为 0 ,但是更复杂的场景就容易碰到问题。

你问 Go 怎么办?是有哪个新的 LLM 起名叫 Go 了吗?

在编程语言 Null/Nil/Zero 设计取向这个问题上,我很难说清自己的偏好和立场。这个问题我也尝试去理解、思考过很多次,暂时的结论是,对语言设计者来说这是个技术或者取舍问题,对语言的使用者来说更倾向于是一个工程问题。

我就以 Go/Rust 为例做个对比。

Go 的设计是 make zero useful ,相当于扩大了 zero 的功能,减少了 null 的存在。这样做的好处是:

  1. 避免了 c 或其他语言的 UBI 初始化前就使用的情况( go 设计受 c 的影响很大)
  2. 简化了编译器开发,go 早期的编译器基本上是手搓的,没有 null 的话数据类型在内存的布局就很简洁
  3. 直接支持组合,go 是 duck typing 和组合优于继承思想的产物,基础类型默认零值初始化那么组合类型也间接初始化了
  4. 代码稍微简化一点,不太用写 null 判断了

缺点主要是两方面:

  1. 零值有歧义
  2. 引用类型的 nil 可能不够安全(运行时)

Rust 走的是完全内存安全的路线,所以设计上就没有传统的 Null ,所有 & 引用都永远不会是 Null 。

为了达到这个效果,Rust 强制开发者使用 Option 处理可能的 Null ,同时代码上也要显式初始化变量。即便有默认零值,也是通过 Default trait 来显式指定的。

内存布局方面,因为要在数据本身之外存储是否为 Null 的标记,所以这个内存布局是相对复杂的。为了解决这个问题,Rust 引入了一个叫 Null Pointer Optimization 的优化,具体原理我不太清楚。

总体上代码编译慢和这些设计因素是分不开的。好处是确实不需要写 Null 判断了。

这两个语言在 nil 安全上是没有本质区别的,对 nil 解引用只能在运行时才能发现。而这两个语言都有 ffi 的需要,所以业务层面也是无法避免的。

所以设计起点比较高的语言,根本的目标都是想要优化 Null 处理逻辑,只是方法和路线不一样。

当涉及到具体的业务时,只要用到“数值代表状态”的逻辑,最终总要有代码做判定,区别只是这个代码发生在什么地方。原本这个逻辑和 Null/Zero 不相关,只是因为开发中 Null/Zero 最常用,所以才出现业务代码要判定 Null/Zero 的需求。

所以我的认知里,Null 应当是一个技术细节,而永远不应该是一个业务特性。特别是软件开发越来越强调网络交互而不是单机单体应用的时候,Null 就是个隐患。我更倾向于强制在业务层面上禁止 Null 的使用。但我这种想法实施起来难度很大,几乎稍微有点历史的数据库都是会区分 Null/Zero 的,没有办法禁止其他人使用 Null 特性。