Go中nil意义的理解
对KM社区上译文的阅读 原文来自于Francesc Campoy在GopherCon 2016上的演讲Understanding nil:视频https://www.youtube.com/watch?v=ynoY2xz-F8s,Slides:https://speakerdeck.com/campoy/understanding-nil
nil是什么
就结果来说,nil是绝大部分Go中类型的初始值,包括指针、slices、maps、channels、functions等。而这其中,应该大部分类型最核心的实现都是指针,比如map和slice的本质就是指向内置对象的指针。
而对于接口而言就更加复杂一些,这个其实涉及到了接口的底层实现,详情参考Go语言接口的原理-Go语言设计与实现。
接口包括了一个指向值的指针和一个指向类型的指针。对接口来说,接口为nil代表着(nil,nil),因此如果声明了一个自珍并且把指针赋值给了接口,那类型就不为nil了(*Person,nil)
type Person struct{
a int
}
func (p Person) String() string{
return strconv.Itoa(p.a)
}
func main(){
var s fmt.Stringer//接口类型,要求实现String()函数
fmt.Println(s == nil)//true
var p *Person
s = p
fmt.Println(s == nil)//false,尽管值依旧为nil,但是类型不为nil
}
什么时候nil不是nil
nil可以是一个nil接口/切片或者指针,是有实际意义的
type doError struct{
errorMessage string
}
func (err doError) Error() string{
return err.errorMessage
}
func do() error{ //错误地方:返回了error接口类型。改正:应该返回具体类型 * doError
var err *doError
return err //类型*doError是空的,但是它实现了接口
}
func main(){
err := do()
fmt.Println(err == nil)//false
}
但如果使用了一个wrap方法,一样会出现问题。本质上是没有变化的。因此,最好不要返回具体的自定义错误类型(do not declare concrete error vars)。
func do() *doError{
return nil
}
func wrapDo() error{
return do() //返回空的*doError类型
}
func main(){
err := wrapDo() //error(*doError,nil)
fmt.Println(err == nil) // false
}
正确的方式是:
- 不应该返回具体的错误类型,无论如何都应该返回接口error
- 在过程中不要自行声明具体类型变量,无论如何都应该使用接口error变量 这种感觉,就是具体类型只出现在自己的实现中而不出现在其他的任何地方。实际的错误使用
func doRequest() error {
return &RequestError{
StatusCode: 503,
Err: errors.New("unavailable"),
}
}
这样的东西来返回,这样即使是nil也与具体类型无关。这个是我个人的理解。
nil的用法
在Go中,nil也是可以调用该类型的方法:(这个确实是有点出乎我的意料了,这个函数更接近于静态函数的实现而不是成员函数。这也说明了Go中的很多概念和OO中的概念不能很简单的一一对应)
type person struct{}
func sayHi(p *person) { fmt.Println("hi") }
func (p *person) sayHi() { fmt.Println("hi") }
var p *person
func main(){
p.sayHi() // hi
}
所以二叉树遍历可以有两个版本:
//精细版本
func (t *tree) Sum() int{
sum := t.v
if t.l != nil{
sum += t.l.Sum()
}
if t.r != nil{
sum += t.r.Sum()
}
return sum
}
//简单版本
func (t *tree) Sum() int {
if t == nil {
return 0
}
return t.v + t.l.Sum() + t.r.Sum()
}
后者同样是可行的。这虽然使得整体结构更加简洁了,但是我并不是很喜欢这种实现。
nil管道
作者给了一个问题,一个很简单的应用,要求将两个channel的内容合并到一个channel中并输出。这两个channel将在发送一定数量后分别进行关闭
一个很简单的想法是这样的:
func merge(out chan<- int, a,b <- chan int){
for{
select{
case v := <-a:
out<-v
case v:= <-b:
out<-v
}
}
}
func main(){
a := make(chan int,0)
b := make(chan int,0)
out := make(chan int,0)
counter := 0
go func() {
for{
t1 := time.After(time.Second)
<- t1
//fmt.Println("record a",time.Now())
a <- 1
counter ++
if counter > 5{
close(a)
}
}
}()
go func() {
for{
t2 := time.After(2 * time.Second)
<- t2
//fmt.Println("record b",time.Now())
b <- 2
if counter > 4{
close(b)
}
}
}()
go func(){
for{
select {
case v := <-out :
fmt.Println(v," ",time.Now())
}
}
}()
merge(out,a,b)
}
缺陷:看上去是没有问题的,但是实际上在通道关闭后会输出大量的0(因为被关闭的通道的特性,会给v赋值为0)。这是因为管道关闭后读取部分没有进行验证,依旧在获取数据,导致获得了大量无效的值。
显然,可以通过从管道接收值的时候第二个值是否为true来判断管道有没有关上,但是这也并不管用
func merge(out chan<- int, a,b <- chan int){
aClosed := false
bClosed := false
for !aClosed || !bClosed{
select{
case v,ok := <-a:
if !ok{
aClosed = true;
fmt.Println("a is now closed")
continue
}
out<-v
case v,ok := <-b:
if !ok{
bClosed = true;
fmt.Println("b is now closed")
continue
}
out<-v
}
}
}
func main(){
a := make(chan int,0)
b := make(chan int,0)
out := make(chan int,0)
t := 0
counter := 0
go func() {
for{
t1 := time.After(time.Second)
<- t1
//fmt.Println("record a",time.Now())
a <- 1
counter ++
if counter > 5{
close(a)
}
}
}()
go func() {
for{
t2 := time.After(2 * time.Second)
<- t2
//fmt.Println("record b",time.Now())
b <- 2
if counter > 10{
close(b)
}
}
}()
go func(){
for{
select {
case v := <-out :
t = v
//fmt.Println(v," ",time.Now())
}
}
}()
merge(out,a,b)
}
(这里需要稍微增大关闭的间隔,不然真的会同步关闭)
问题:结果显示,“a is now closed"被大量输出,说明已经被关闭的管道a被反复读取且没有办法阻塞,正常情况下这可能会导致程序崩溃。
PS:最后程序报错panic: send on closed channel
显示向a中发送了数据,并结束(这个是go协程中忘记写退出了)。
该问题的根源在于,已经关闭的管道仍然是可以读取的(只是不能写入,向一个已经关闭的管道中写入数据会引起panic)。此时如果有两个管道的话,单独关闭一个就会造成另外一个管道无法阻塞并被大量调用。
最好的方法是设置关闭的管道为nil
package main
import (
"fmt"
"time"
)
func merge(out chan<- int, a,b <- chan int){
for a!=nil || b!=nil{
select{
case v,ok := <-a:
if !ok{
a = nil
fmt.Println("a is now closed")
continue
}
out<-v
case v,ok := <-b:
if !ok{
b = nil
fmt.Println("b is now closed")
continue
}
out<-v
}
}
}
func main(){
a := make(chan int,0)
b := make(chan int,0)
out := make(chan int,0)
t := 0
counter := 0
go func() {
for{
t1 := time.After(time.Second)
<- t1
fmt.Println("record a",time.Now())
a <- 1
counter ++
if counter > 2{
close(a)
return
}
}
}()
go func() {
for{
t2 := time.After(2 * time.Second)
<- t2
fmt.Println("record b",time.Now())
b <- 2
counter ++
if counter > 3{
close(b)
return
}
}
}()
go func(){
for{
select {
case v := <-out :
t = v
//fmt.Println(v," ",time.Now())
}
}
}()
merge(out,a,b)
}
nil function
对于函数来说,可以选择给它赋值为nil来表示默认值 比如
func doSum(s Summer) int{
if s == nil{
return 0
}
return s.Sum()
}
//Summer是接口,t是具体的实现类型,但上述方法都是可以的。即使是传入具体类型(*tree,nil)也不会报错,因为值为nil的具体类型的方法依旧可以被调用
在HTTP中,http.HandleFunc('localhost:8080',nil)
就是这样的实现。
nil map
nil的map是不能够赋值的,因此对于需要写入的map无论何时都应该判断是否为nil,不然会直接panic退出:
func main(){
var s map[string]bool
fmt.Println(s==nil) //true
s["true"] = true //panic: assignment to entry in nil map
fmt.Println(len(s))
}
nil的map的读取会直接任意成功。像下面的代码,并不会如预期一样输出fail,而是会输出`pass through`+v的值false。
func main(){
var s map[string]bool
if v,ok := s["test"]; ok{
fmt.Println("fail")
}else{
fmt.Println("pass through")
fmt.Println(v)
}
}
因此,对于接受map为参数的函数,应该要谨慎传入nil。并且在写类似函数的时候一定要做好对应的检查。
总结
比较值得注意的主要是nil的接口、管道和map。
其中对于接口,比较值得注意的是具体类型所导致的接口nil的判断。
对于管道来说,比较需要注意的是对已经关闭的管道和nil的管道写入和读取等操作时与正常管道的差异。
对于map和其他类型来说,需要注意的是报错,比如nil切片的越界错误和nil映射表的读取错误哦。