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
}

正确的方式是:

  1. 不应该返回具体的错误类型,无论如何都应该返回接口error
  2. 在过程中不要自行声明具体类型变量,无论如何都应该使用接口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映射表的读取错误哦。

0%