从NullString看Golang 哲学
我逐渐发现了Golang的设计哲学,就是可以折磨程序员,但不准折磨服务器。
我记得前一整子,在跳槽面试的时候,有几个面试官问过我,有没有用过什么ORM,比如GROM?
我说我不用,我就用sql和sqlx,理由是「goらしくない」“这不像golang”。
大学时代一直是写Java过来的,喜欢把什么语言都写成Java,什么依赖注入,条件反射,一直挂嘴边,但Golang教会了我很多,编程本该如此.jpg
回到sqlx为什么比gorm“更像golang”?
今天写业务,用到了sql库的NullString,虽然以前也不是没用过,但没有仔细研究过这个东西。
今天上班比较闲就打开瞅了瞅发现还挺有趣的,做个记录。golang的这些库真的写的特别好懂,建议阅读。
Go语言 NullString 到反射的设计哲学
理解 database/sql 背后的思考
1. 为什么有 NullString?
在 SQL 语言里,NULL 代表「不存在」, 但 Go 里的 string 不能表达「不存在」,只能是 ““(空字符串)。
所以 Go 标准库 database/sql 提供了 sql.NullString 结构体, 专门用来区分:
- 是 NULL? → Valid == false
- 是 ““? → Valid == true 且 String == “”
type NullString struct {
String string
Valid bool // true 代表这个值有效(非 NULL)
}
这是一种非常 简单、显式、零额外开销 的设计。
2. NullString.Scan 的核心逻辑
Scan 是数据库标准接口 sql.Scanner 的一部分, 它的作用是:把数据库里的一个字段(可能是 NULL)安全地复制到 NullString 里。
func (ns *NullString) Scan(value any) error {
if value == nil {
ns.String, ns.Valid = "", false // 数据库是 NULL
return nil
}
ns.Valid = true
return convertAssign(&ns.String, value)
}
核心点:
- 判断 value == nil 来区分是否是 SQL NULL。
- 非 NULL 时,调用 convertAssign 把数据库值转(字符串,整数,甚至是Time类型)换成 string。
3.走进 convertAssign —— 为什么不用反射?
把数据库值转(字符串,整数,甚至是Time类型)换成 string的源码(convertAssignRows):
switch s := src.(type) {
case string:
switch d := dest.(type) {
case *string:
if d == nil {
return errNilPtr
}
*d = s
return nil
case *[]byte:
if d == nil {
return errNilPtr
}
*d = []byte(s)
return nil
}
....
case time.Time:
switch d := dest.(type) {
case *time.Time:
*d = s
return nil
case *string:
*d = s.Format(time.RFC3339Nano)
return nil
...
}
- 完全是 type switch,没有用 reflect。
- 转换逻辑是 编译期就固定的,不会在运行时动态推断类型。
4. 为什么不直接用 reflect?
反射的劣势:
- 每次都要解析 reflect.Type → 慢
- 每次都要动态判断 Kind → 慢
- 每次设置都要 Value.Set → 慢
- 无法编译器优化(无法 inline、无法 escape analysis)
Go 哲学:
「编译期就能确定的,就不留到运行时。」
所以,基础库(database/sql)避免反射,
用 type switch 这种低成本、直接的写法。
5. 那 ORM(如 GORM, sqlx)为什么要用反射?
ORM 的目标是:
「你写个 struct,我帮你自动填字段,省事!」
但 struct 字段名、tag、类型顺序都不一定对得上,
必须要运行时动态解析:
t := reflect.TypeOf(dest).Elem()
v := reflect.ValueOf(dest).Elem()
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("db")
v.Field(i).Set(...)
}
这就是为什么 ORM 必须用反射,但 database/sql 不用。 database/sql 中,类型映射方式全靠手动 Scan,靠 type switch,快!
6. 反射为什么慢?
这个问题稍微有些复杂和偏题,就至少为总结一下:
- TypeOf:每次解析类型元数据表
- ValueOf:包装类型和值的组合
- Kind 判断:运行时判断,不能优化
- Set 操作:每次都要检查类型一致性
- 无法内联优化:编译器不能省略函数调用开销
- 可能引入堆分配:为了通用性,很多时候必须分配堆内存
实际上在benchmark中,反射赋值可能会比普通赋值慢上个百倍。
7.题外话
其实sql库的convertAssignRows方法中,最后还是用了反射的: 前面不用反射的原因是:
- 数据库返回的类型是有限的(string, []byte, time.Time, nil, 等)。
- *string, *int, *[]byte 这些接收类型也有限。
- 这些类型之间的转换用 type switch 快速匹配,性能非常好(编译器优化、跳表查找)。
大部分实际场景(90% 以上)会在前面的 switch 就 return 了。
最后还是用反射兜底: Go 的 Scan 接口必须支持:
- 自定义类型(用户定义了 type MyInt int)。
- sql.Scanner 接口类型(比如 NullString)。 用户可能传进任意 struct、指针、alias。
- type switch 写不完所有组合(MyInt、MyFloat、*UserDefinedDecimal……)。
例子:
type MyInt int64
var myval MyInt
row.Scan(&myval)
数据库可能返回 int64,但是:
type switch 里没写 *MyInt。
反射能发现:
- MyInt 是 int64 的 alias。
- 类型 ConvertibleTo。
如果不兜底反射,这种场景就会 panic 或 unsupported。
所以
Go 的 database/sql 并不是完全反对反射,而是用一种「最小必要原则」去使用它。