先记住这个答案
nil切片是var s []int,其内部指针为nil,长度和容量均为0。空切片是s := []int{}或make([]int,0),指针可能指向零数组。判空应使用len(s)==0而非s==nil。JSON序列化时,nil切片输出null,空切片输出[],这是最常见的差异。append对两者都有效,但nil切片在反射和某些库中需要特殊处理。
- nil切片与nil比较为true,空切片为false
- 判空用len(s)==0,不要用s==nil
- JSON序列化nil为null,空为[]
内部结构与零值机制
切片在运行时是slice header,包含指向底层数组的指针、长度len和容量cap。当声明var s []int时,这三个字段均为零值,指针为nil,len和cap为0,因此s==nil成立。而空切片如s:=[]int{}或make([]int,0)会创建一个底层数组(可能长度0),指针非nil(可能指向runtime.zerobase),len和cap为0,所以s!=nil。但len(s)都是0,因此len判空更通用。
append对nil切片和空切片的行为类似,都会分配底层数组并返回新切片。但注意,append一个nil切片会分配底层数组,结果不再是nil。另外,reflect.ValueOf(s).IsNil()只对nil切片返回true,空切片返回false。这与接口的nil陷阱不同,因为切片本身是引用类型,但nil切片和空切片的底层指针不同。
JSON编码与前端数据填充场景
场景:后端Go API返回一个列表字段,当数据库查询无结果时,希望返回空数组[]而不是null,以方便前端直接遍历。若使用var items []Item,则序列化后为null;若使用items := []Item{}或make([]Item,0),则序列化为[]。决策是在构造响应时显式初始化空切片,而不是依赖默认零值。结果:前端能安全使用items.length而无需额外判断。
若忽略此差异,前端可能因items为null而报错。同样,在读取JSON时,null会被解析为nil切片,[]会被解析为空切片。因此,在反序列化后判空同样应使用len(s)==0,因为两者len为0,但nil切片与空切片在反射或部分库中可能表现不同,需要额外处理。
适用边界与易错条件
容易失效的条件是:当使用第三方库或反射时,库可能区分nil和空切片。例如encoding/json的Marshal就区分输出,而数据库驱动扫描NULL与空值也可能有不同语义,但切片类型通常不直接扫描,需借助JSON等序列化。如果库将空切片视为无记录,可能与nil混淆。所以对业务数据,明确约定空结果返回空切片还是nil,避免依赖库的默认行为。
处理方式:在API入口统一转换,如对查询结果做defensive check,若nil则赋值为[]。代价是额外分配,但可忽略。另一种方式是文档中明确不依赖JSON类型,但前端仍需处理null。建议在持久化或传输层统一为[]语义。
容易答错的地方
- 用s==nil判断切片是否为空
- 错误,因为空切片s:=[]int{} s!=nil但len=0。正确应使用len(s)==0,这样对nil和空都有效。只适用于需要区分nil的场合才用s==nil。
- 认为nil切片不能append
- 实际上append对nil切片有效,会分配底层数组并返回新切片。但要注意,原nil切片不会被修改,需接收返回值。这与其他语言不同。
面试官还会怎么问?
nil切片和空切片的性能有差别吗?
性能差异微乎其微。nil切片不分配底层数组,空切片可能分配零大小的数组或指向zerobase。在循环中反复append时,nil切片首次append会分配,空切片可能已有容量但通常也需分配。关键看是否追加元素。
JSON反序列化时如何区分字段缺失和空数组?
字段缺失时,切片保持零值nil;空数组时得到空切片。若要区分,需使用指针类型比如*[]int,但通常无必要。可用自定义UnmarshalJSON。
数据库查询结果返回NULL时,切片会是什么?
database/sql并不直接支持扫描到切片,通常需扫描到[]byte或string后再解析。若通过JSON解析,NULL对应nil,空数组对应空切片。建议在业务层统一转换,避免依赖数据库默认值。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。