The Go Programming Language(6-7)

Nov 21, 2020·
1ch0
1ch0
· 26 min read

The Go Programming Language(6-7)

说明

本文为Go语言编程圣经中文版内容,本人在阅读时将其制作为思维导图及博客文章形式,仅供学习,若侵权请及时与我联系。

源码、PDF版、Markdown、xmind版下载链接

https://1tnt1.lanzous.com/b00o36ytc

密码:

1ch0

ch6 方法

ch6.0 简介

  • 从90年代早期开始,面向对象编程(OOP)就成为了称霸工程界和教育界的编程范式,所以之后几乎所有大规模被应用的语言都包含了对OOP的支持,go语言也不例外。

  • 尽管没有被大众所接受的明确的OOP的定义,从我们的理解来讲,一个对象其实也就是一个简单的值或者一个变量,在这个对象中会包含一些方法,而一个方法则是一个一个和特殊类型关联的函数。

    • 一个面向对象的程序会用方法来表达其属性和对应的操作,这样使用这个对象的用户就不需要直接去操作对象,而是借助方法来做这些事情。

    • 在早些的章节中,我们已经使用了标准库提供的一些方法,比如time.Duration这个类型的Seconds方法

      const day = 24 * time.Hour
      fmt.Println(day.Seconds()) // "86400"
      
    • 并且在2.5节中,我们定义了一个自己的方法,Celsius类型的String方法:

      func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
      
  • 在本章中,OOP编程的第一方面,我们会向你展示如何有效地定义和使用方法。我们会覆盖到OOP编程的两个关键点,封装和组合。

ch6.1 方法声明

  • 在函数声明时,在其名字之前放上一个变量,即是一个方法。这个附加的参数会将该函数附加到这种类型上,即相当于为这种类型定义了一个独占的方法。

    • 下面来写我们第一个方法的例子,这个例子在package geometry下:

      gopl.io/ch6/geometry

      package geometry
      
      import "math"
      
      type Point struct{ X, Y float64 }
      
      // traditional function
      func Distance(p, q Point) float64 {
      	return math.Hypot(q.X-p.X, q.Y-p.Y)
      }
      
      // same thing, but as a method of the Point type
      func (p Point) Distance(q Point) float64 {
      	return math.Hypot(q.X-p.X, q.Y-p.Y)
      }
      
    • 上面的代码里那个附加的参数p,叫做方法的接收器(receiver),早期的面向对象语言留下的遗产将调用一个方法称为“向一个对象发送消息”。

  • 在Go语言中,我们并不会像其它语言那样用this或者self作为接收器;我们可以任意的选择接收器的名字。由于接收器的名字经常会被使用到,所以保持其在方法间传递时的一致性和简短性是不错的主意。

    • 这里的建议是可以使用其类型的第一个字母,比如这里使用了Point的首字母p。
  • 在方法调用过程中,接收器参数一般会在方法名之前出现。这和方法声明是一样的,都是接收器参数在方法名字之前。

    • 下面是例子:

      p := Point{1, 2}
      q := Point{4, 6}
      fmt.Println(Distance(p, q)) // "5", function call
      fmt.Println(p.Distance(q))  // "5", method call
      
    • 可以看到,上面的两个函数调用都是Distance,但是却没有发生冲突

      • 第一个Distance的调用实际上用的是包级别的函数geometry.Distance
      • 而第二个则是使用刚刚声明的Point,调用的是Point类下声明的Point.Distance方法。
      • 这种p.Distance的表达式叫做选择器,因为他会选择合适的对应p这个对象的Distance方法来执行。
      • 选择器也会被用来选择一个struct类型的字段,比如p.X。由于方法和字段都是在同一命名空间,所以如果我们在这里声明一个X方法的话,编译器会报错,因为在调用p.X时会有歧义(译注:这里确实挺奇怪的)。
  • 因为每种类型都有其方法的命名空间,我们在用Distance这个名字的时候,不同的Distance调用指向了不同类型里的Distance方法。让我们来定义一个Path类型,这个Path代表一个线段的集合,并且也给这个Path定义一个叫Distance的方法。

    // A Path is a journey connecting the points with straight lines.
    type Path []Point
    // Distance returns the distance traveled along the path.
    func (path Path) Distance() float64 {
    	sum := 0.0
    	for i := range path {
    		if i > 0 {
    			sum += path[i-1].Distance(path[i])
    		}
    	}
    	return sum
    }
    
    • Path是一个命名的slice类型,而不是Point那样的struct类型,然而我们依然可以为它定义方法。
    • 在能够给任意类型定义方法这一点上,Go和很多其它的面向对象的语言不太一样。因此在Go语言里,我们为一些简单的数值、字符串、slice、map来定义一些附加行为很方便。
    • 我们可以给同一个包内的任意命名类型定义方法,只要这个命名类型的底层类型不是指针或者interface。
    • 译注:这个例子里,底层类型是指[]Point这个slice,Path就是命名类型
  • 两个Distance方法有不同的类型。他们两个方法之间没有任何关系,尽管Path的Distance方法会在内部调用Point.Distance方法来计算每个连接邻接点的线段的长度。

    • 让我们来调用一个新方法,计算三角形的周长:

      perim := Path{
      	{1, 1},
      	{5, 1},
      	{5, 4},
      	{1, 1},
      }
      fmt.Println(perim.Distance()) // "12"
      
    • 在上面两个对Distance名字的方法的调用中,编译器会根据方法的名字以及接收器来决定具体调用的是哪一个函数。

    • 第一个例子中path[i-1]数组中的类型是Point,因此Point.Distance这个方法被调用;在第二个例子中perim的类型是Path,因此Distance调用的是Path.Distance。

  • 对于一个给定的类型,其内部的方法都必须有唯一的方法名,但是不同的类型却可以有同样的方法名

    • 比如我们这里Point和Path就都有Distance这个名字的方法;所以我们没有必要非在方法名之前加类型名来消除歧义,比如PathDistance。

    • 这里我们已经看到了方法比之函数的一些好处:方法名可以简短。

    • 当我们在包外调用的时候这种好处就会被放大,因为我们可以使用这个短名字,而可以省略掉包的名字,下面是例子:

      import "gopl.io/ch6/geometry"
      
      perim := geometry.Path{{1, 1}, {5, 1}, {5, 4}, {1, 1}}
      fmt.Println(geometry.PathDistance(perim)) // "12", standalone function
      fmt.Println(perim.Distance())             // "12", method of geometry.Path
      
    • 译注: 如果我们要用方法去计算perim的distance,还需要去写全geometry的包名,和其函数名,但是因为Path这个类型定义了一个可以直接用的Distance方法,所以我们可以直接写perim.Distance()。相当于可以少打很多字,作者应该是这个意思。因为在Go里包外调用函数需要带上包名,还是挺麻烦的。

ch6.2 基于指针对象的方法

  • ch6.2.0介绍

    • 当调用一个函数时,会对其每一个参数值进行拷贝,如果一个函数需要更新一个变量,或者函数的其中一个参数实在太大我们希望能够避免进行这种默认的拷贝,这种情况下我们就需要用到指针了。

      • 对应到我们这里用来更新接收器的对象的方法,当这个接受者变量本身比较大时,我们就可以用其指针而不是对象来声明方法,如下:

        func (p *Point) ScaleBy(factor float64) {
        	p.X *= factor
        	p.Y *= factor
        }
        
        • 这个方法的名字是(*Point).ScaleBy。这里的括号是必须的;没有括号的话这个表达式可能会被理解为*(Point.ScaleBy)
    • 在现实的程序里,一般会约定如果Point这个类有一个指针作为接收器的方法,那么所有Point的方法都必须有一个指针接收器,即使是那些并不需要这个指针接收器的函数。我们在这里打破了这个约定只是为了展示一下两种方法的异同而已。

    • 只有类型(Point)和指向他们的指针(*Point),才可能是出现在接收器声明里的两种接收器。

      • 此外,为了避免歧义,在声明方法时,如果一个类型名本身是一个指针的话,是不允许其出现在接收器中的,比如下面这个例子:

        type P *int
        func (P) f() { /* ... */ } // compile error: invalid receiver type
        
      • 想要调用指针类型方法(*Point).ScaleBy,只要提供一个Point类型的指针即可,像下面这样。

        r := &Point{1, 2}
        r.ScaleBy(2)
        fmt.Println(*r) // "{2, 4}"
        
      • 或者这样:

        或者这样:

      • 或者这样:

        p := Point{1, 2}
        (&p).ScaleBy(2)
        fmt.Println(p) // "{2, 4}"
        
    • 不过后面两种方法有些笨拙。幸运的是,go语言本身在这种地方会帮到我们。如果接收器p是一个Point类型的变量,并且其方法需要一个Point指针作为接收器,我们可以用下面这种简短的写法:

      p.ScaleBy(2)
      
      • 编译器会隐式地帮我们用&p去调用ScaleBy这个方法。这种简写方法只适用于“变量”,包括struct里的字段比如p.X,以及array和slice内的元素比如perim[0]。

      • 我们不能通过一个无法取到地址的接收器来调用指针方法,比如临时变量的内存地址就无法获取得到:

        Point{1, 2}.ScaleBy(2) // compile error: can't take address of Point literal
        
      • 但是我们可以用一个*Point这样的接收器来调用Point的方法,因为我们可以通过地址来找到这个变量,只要用解引用符号*来取到该变量即可。

        • 编译器在这里也会给我们隐式地插入*这个操作符,所以下面这两种写法等价的:

          pptr.Distance(q)
          (*pptr).Distance(q)
          
    • 这里的几个例子可能让你有些困惑,所以我们总结一下:在每一个合法的方法调用表达式中,也就是下面三种情况里的任意一种情况都是可以的:

      • 要么接收器的实际参数和其形式参数是相同的类型,比如两者都是类型T或者都是类型*T

        Point{1, 2}.Distance(q) //  Point
        pptr.ScaleBy(2)         // *Point
        
      • 或者接收器实参是类型T,但接收器形参是类型*T,这种情况下编译器会隐式地为我们取变量的地址:

        p.ScaleBy(2) // implicit (&p)
        
      • 或者接收器实参是类型*T,形参是类型T。编译器会隐式地为我们解引用,取到指针指向的实际变量:

        pptr.Distance(q) // implicit (*pptr)
        
    • 如果命名类型T(译注:用type xxx定义的类型)的所有方法都是用T类型自己来做接收器(而不是*T),那么拷贝这种类型的实例就是安全的;调用他的任何一个方法也就会产生一个值的拷贝。

      • 比如time.Duration的这个类型,在调用其方法时就会被全部拷贝一份,包括在作为参数传入函数的时候。
    • 但是如果一个方法使用指针作为接收器,你需要避免对其进行拷贝,因为这样可能会破坏掉该类型内部的不变性。

      • 比如你对bytes.Buffer对象进行了拷贝,那么可能会引起原始对象和拷贝对象只是别名而已,实际上它们指向的对象是一样的。紧接着对拷贝后的变量进行修改可能会有让你有意外的结果。
    • 译注: 作者这里说的比较绕,其实有两点:

        1. 不管你的method的receiver是指针类型还是非指针类型,都是可以通过指针/非指针类型进行调用的,编译器会帮你做类型转换。
        1. 在声明一个method的receiver该是指针还是非指针类型时,你需要考虑两方面的因素,第一方面是这个对象本身是不是特别大,如果声明为非指针变量时,调用会产生一次拷贝;第二方面是如果你用指针类型作为receiver,那么你一定要注意,这种指针类型指向的始终是一块内存地址,就算你对其进行了拷贝。熟悉C或者C++的人这里应该很快能明白。
  • ch6.2.1Nil也是一个合法的接收器类型

    • 就像一些函数允许nil指针作为参数一样,方法理论上也可以用nil指针作为其接收器,尤其当nil对于对象来说是合法的零值时,比如map或者slice。

      • 在下面的简单int链表的例子里,nil代表的是空链表:

        // An IntList is a linked list of integers.
        // A nil *IntList represents the empty list.
        type IntList struct {
        	Value int
        	Tail  *IntList
        }
        // Sum returns the sum of the list elements.
        func (list *IntList) Sum() int {
        	if list == nil {
        		return 0
        	}
        	return list.Value + list.Tail.Sum()
        }
        
    • 当你定义一个允许nil作为接收器值的方法的类型时,在类型前面的注释中指出nil变量代表的意义是很有必要的,就像我们上面例子里做的这样。

    • 下面是net/url包里Values类型定义的一部分。

      net/url

      package url
      
      // Values maps a string key to a list of values.
      type Values map[string][]string
      // Get returns the first value associated with the given key,
      // or "" if there are none.
      func (v Values) Get(key string) string {
      	if vs := v[key]; len(vs) > 0 {
      		return vs[0]
      	}
      	return ""
      }
      // Add adds the value to key.
      // It appends to any existing values associated with key.
      func (v Values) Add(key, value string) {
      	v[key] = append(v[key], value)
      }
      
      • 这个定义向外部暴露了一个map的命名类型,并且提供了一些能够简单操作这个map的方法。这个map的value字段是一个string的slice,所以这个Values是一个多维map。
    • 客户端使用这个变量的时候可以使用map固有的一些操作(make,切片,m[key]等等),也可以使用这里提供的操作方法,或者两者并用,都是可以的

      gopl.io/ch6/urlvalues

      m := url.Values{"lang": {"en"}} // direct construction
      m.Add("item", "1")
      m.Add("item", "2")
      
      fmt.Println(m.Get("lang")) // "en"
      fmt.Println(m.Get("q"))    // ""
      fmt.Println(m.Get("item")) // "1"      (first value)
      fmt.Println(m["item"])     // "[1 2]"  (direct map access)
      
      m = nil
      fmt.Println(m.Get("item")) // ""
      m.Add("item", "3")         // panic: assignment to entry in nil map
      
      • 对Get的最后一次调用中,nil接收器的行为即是一个空map的行为。我们可以等价地将这个操作写成Value(nil).Get(“item”),但是如果你直接写nil.Get(“item”)的话是无法通过编译的,因为nil的字面量编译器无法判断其准确类型。所以相比之下,最后的那行m.Add的调用就会产生一个panic,因为他尝试更新一个空map。
    • 由于url.Values是一个map类型,并且间接引用了其key/value对,因此url.Values.Add对这个map里的元素做任何的更新、删除操作对调用方都是可见的。

      • 实际上,就像在普通函数中一样,虽然可以通过引用来操作内部值,但在方法想要修改引用本身时是不会影响原始值的,比如把他置换为nil,或者让这个引用指向了其它的对象,调用方都不会受影响。
      • (译注:因为传入的是存储了内存地址的变量,你改变这个变量本身是影响不了原始的变量的,想想C语言,是差不多的)

ch6.3 通过嵌入结构体来扩展类型

  • 来看看ColoredPoint这个类型:

    gopl.io/ch6/coloredpoint

    import "image/color"
    
    type Point struct{ X, Y float64 }
    
    type ColoredPoint struct {
    	Point
    	Color color.RGBA
    }
    
    • 我们完全可以将ColoredPoint定义为一个有三个字段的struct,但是我们却将Point这个类型嵌入到ColoredPoint来提供X和Y这两个字段。
    • 像我们在4.4节中看到的那样,内嵌可以使我们在定义ColoredPoint时得到一种句法上的简写形式,并使其包含Point类型所具有的一切字段,然后再定义一些自己的。
  • 如果我们想要的话,我们可以直接认为通过嵌入的字段就是ColoredPoint自身的字段,而完全不需要在调用时指出Point,比如下面这样。

    var cp ColoredPoint
    cp.X = 1
    fmt.Println(cp.Point.X) // "1"
    cp.Point.Y = 2
    fmt.Println(cp.Y) // "2"
    
  • 对于Point中的方法我们也有类似的用法,我们可以把ColoredPoint类型当作接收器来调用Point里的方法,即使ColoredPoint里没有声明这些方法

    red := color.RGBA{255, 0, 0, 255}
    blue := color.RGBA{0, 0, 255, 255}
    var p = ColoredPoint{Point{1, 1}, red}
    var q = ColoredPoint{Point{5, 4}, blue}
    fmt.Println(p.Distance(q.Point)) // "5"
    p.ScaleBy(2)
    q.ScaleBy(2)
    fmt.Println(p.Distance(q.Point)) // "10"
    
    • Point类的方法也被引入了ColoredPoint。用这种方式,内嵌可以使我们定义字段特别多的复杂类型,我们可以将字段先按小类型分组,然后定义小类型的方法,之后再把它们组合起来。

    • 读者如果对基于类来实现面向对象的语言比较熟悉的话,可能会倾向于将Point看作一个基类,而ColoredPoint看作其子类或者继承类,或者将ColoredPoint看作"is a" Point类型。

    • 但这是错误的理解。请注意上面例子中对Distance方法的调用。Distance有一个参数是Point类型,但q并不是一个Point类,所以尽管q有着Point这个内嵌类型,我们也必须要显式地选择它。尝试直接传q的话你会看到下面这样的错误:

      p.Distance(q) // compile error: cannot use q (ColoredPoint) as Point
      
  • 一个ColoredPoint并不是一个Point,但他"has a"Point,并且它有从Point类里引入的Distance和ScaleBy方法。如果你喜欢从实现的角度来考虑问题,内嵌字段会指导编译器去生成额外的包装方法来委托已经声明好的方法,和下面的形式是等价的:

    func (p ColoredPoint) Distance(q Point) float64 {
    	return p.Point.Distance(q)
    }
    
    func (p *ColoredPoint) ScaleBy(factor float64) {
    	p.Point.ScaleBy(factor)
    }
    
    • 当Point.Distance被第一个包装方法调用时,它的接收器值是p.Point,而不是p,当然了,在Point类的方法里,你是访问不到ColoredPoint的任何字段的。
  • 在类型中内嵌的匿名字段也可能是一个命名类型的指针,这种情况下字段和方法会被间接地引入到当前的类型中

    • 译注:访问需要通过该指针指向的对象去取

    • 添加这一层间接关系让我们可以共享通用的结构并动态地改变对象之间的关系。

    • 下面这个ColoredPoint的声明内嵌了一个*Point的指针。

      type ColoredPoint struct {
      	*Point
      	Color color.RGBA
      }
      
      p := ColoredPoint{&Point{1, 1}, red}
      q := ColoredPoint{&Point{5, 4}, blue}
      fmt.Println(p.Distance(*q.Point)) // "5"
      q.Point = p.Point                 // p and q now share the same Point
      p.ScaleBy(2)
      fmt.Println(*p.Point, *q.Point) // "{2 2} {2 2}"
      
    • 一个struct类型也可能会有多个匿名字段。我们将ColoredPoint定义为下面这样:

      type ColoredPoint struct {
      	Point
      	color.RGBA
      }
      
      • 然后这种类型的值便会拥有Point和RGBA类型的所有方法,以及直接定义在ColoredPoint中的方法。
      • 当编译器解析一个选择器到方法时,比如p.ScaleBy,它会首先去找直接定义在这个类型里的ScaleBy方法,然后找被ColoredPoint的内嵌字段们引入的方法,然后去找Point和RGBA的内嵌字段引入的方法,然后一直递归向下找。
      • 如果选择器有二义性的话编译器会报错,比如你在同一级里有两个同名的方法。
  • 方法只能在命名类型(像Point)或者指向类型的指针上定义,但是多亏了内嵌,有些时候我们给匿名struct类型来定义方法也有了手段。

    • 下面是一个小trick。这个例子展示了简单的cache,其使用两个包级别的变量来实现,一个mutex互斥量(§9.2)和它所操作的cache

      var (
      	mu sync.Mutex // guards mapping
      	mapping = make(map[string]string)
      )
      
      func Lookup(key string) string {
      	mu.Lock()
      	v := mapping[key]
      	mu.Unlock()
      	return v
      }
      
    • 下面这个版本在功能上是一致的,但将两个包级别的变量放在了cache这个struct一组内:

      var cache = struct {
      	sync.Mutex
      	mapping map[string]string
      }{
      	mapping: make(map[string]string),
      }
      
      
      func Lookup(key string) string {
      	cache.Lock()
      	v := cache.mapping[key]
      	cache.Unlock()
      	return v
      }
      
    • 我们给新的变量起了一个更具表达性的名字:cache。因为sync.Mutex字段也被嵌入到了这个struct里,其Lock和Unlock方法也就都被引入到了这个匿名结构中了,这让我们能够以一个简单明了的语法来对其进行加锁解锁操作。

ch6.4 方法值和方法表达式

  • 我们经常选择一个方法,并且在同一个表达式里执行,比如常见的p.Distance()形式,实际上将其分成两步来执行也是可能的。

  • p.Distance叫作“选择器”,选择器会返回一个方法“值”->一个将方法(Point.Distance)绑定到特定接收器变量的函数。这个函数可以不通过指定其接收器即可被调用;即调用时不需要指定接收器,只要传入函数的参数即可:

    p := Point{1, 2}
    q := Point{4, 6}
    
    distanceFromP := p.Distance        // method value
    fmt.Println(distanceFromP(q))      // "5"
    var origin Point                   // {0, 0}
    fmt.Println(distanceFromP(origin)) // "2.23606797749979", sqrt(5)
    
    scaleP := p.ScaleBy // method value
    scaleP(2)           // p becomes (2, 4)
    scaleP(3)           //      then (6, 12)
    scaleP(10)          //      then (60, 120)
    
  • 在一个包的API需要一个函数值、且调用方希望操作的是某一个绑定了对象的方法的话,方法“值”会非常实用。

    • 举例来说,下面例子中的time.AfterFunc这个函数的功能是在指定的延迟时间之后来执行一个(译注:另外的)函数。且这个函数操作的是一个Rocket对象r

      type Rocket struct { /* ... */ }
      func (r *Rocket) Launch() { /* ... */ }
      r := new(Rocket)
      time.AfterFunc(10 * time.Second, func() { r.Launch() })
      
    • 直接用方法“值”传入AfterFunc的话可以更为简短:

      time.AfterFunc(10 * time.Second, r.Launch)
      
  • 和方法“值”相关的还有方法表达式。当调用一个方法时,与调用一个普通的函数相比,我们必须要用选择器(p.Distance)语法来指定方法的接收器。

    • 当T是一个类型时,方法表达式可能会写作T.f或者(*T).f,会返回一个函数“值”,这种函数会将其第一个参数用作接收器,所以可以用通常(译注:不写选择器)的方式来对其进行调用:

      p := Point{1, 2}
      q := Point{4, 6}
      
      distance := Point.Distance   // method expression
      fmt.Println(distance(p, q))  // "5"
      fmt.Printf("%T\n", distance) // "func(Point, Point) float64"
      
      scale := (*Point).ScaleBy
      scale(&p, 2)
      fmt.Println(p)            // "{2 4}"
      fmt.Printf("%T\n", scale) // "func(*Point, float64)"
      
      // 译注:这个Distance实际上是指定了Point对象为接收器的一个方法func (p Point) Distance(),
      // 但通过Point.Distance得到的函数需要比实际的Distance方法多一个参数,
      // 即其需要用第一个额外参数指定接收器,后面排列Distance方法的参数。
      // 看起来本书中函数和方法的区别是指有没有接收器,而不像其他语言那样是指有没有返回值。
      
  • 当你根据一个变量来决定调用同一个类型的哪个函数时,方法表达式就显得很有用了。你可以根据选择来调用接收器各不相同的方法。下面的例子,变量op代表Point类型的addition或者subtraction方法,Path.TranslateBy方法会为其Path数组中的每一个Point来调用对应的方法:

    type Point struct{ X, Y float64 }
    
    func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} }
    func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} }
    
    type Path []Point
    
    func (path Path) TranslateBy(offset Point, add bool) {
    	var op func(p, q Point) Point
    	if add {
    		op = Point.Add
    	} else {
    		op = Point.Sub
    	}
    	for i := range path {
    		// Call either path[i].Add(offset) or path[i].Sub(offset).
    		path[i] = op(path[i], offset)
    	}
    }
    

ch6.5 示例:Bit数组

  • Go语言里的集合一般会用map[T]bool这种形式来表示,T代表元素类型。集合用map类型来表示虽然非常灵活,但我们可以以一种更好的形式来表示它。

    • 例如在数据流分析领域,集合元素通常是一个非负整数,集合会包含很多元素,并且集合会经常进行并集、交集操作,这种情况下,bit数组会比map表现更加理想。
    • 这里再补充一个例子,比如我们执行一个http下载任务,把文件按照16kb一块划分为很多块,需要有一个全局变量来标识哪些块下载完成了,这种时候也需要用到bit数组。
  • 一个bit数组通常会用一个无符号数或者称之为“字”的slice来表示,每一个元素的每一位都表示集合里的一个值。当集合的第i位被设置时,我们才说这个集合包含元素i。

    • 下面的这个程序展示了一个简单的bit数组类型,并且实现了三个函数来对这个bit数组来进行操作:

      gopl.io/ch6/intset

      // An IntSet is a set of small non-negative integers.
      // Its zero value represents the empty set.
      type IntSet struct {
      	words []uint64
      }
      
      // Has reports whether the set contains the non-negative value x.
      func (s *IntSet) Has(x int) bool {
      	word, bit := x/64, uint(x%64)
      	return word < len(s.words) && s.words[word]&(1<<bit) != 0
      }
      
      // Add adds the non-negative value x to the set.
      func (s *IntSet) Add(x int) {
      	word, bit := x/64, uint(x%64)
      	for word >= len(s.words) {
      		s.words = append(s.words, 0)
      	}
      	s.words[word] |= 1 << bit
      }
      
      // UnionWith sets s to the union of s and t.
      func (s *IntSet) UnionWith(t *IntSet) {
      	for i, tword := range t.words {
      		if i < len(s.words) {
      			s.words[i] |= tword
      		} else {
      			s.words = append(s.words, tword)
      		}
      	}
      }
      
    • 因为每一个字都有64个二进制位,所以为了定位x的bit位,我们用了x/64的商作为字的下标,并且用x%64得到的值作为这个字内的bit的所在位置。UnionWith这个方法里用到了bit位的“或”逻辑操作符号|来一次完成64个元素的或计算。

  • 当前这个实现还缺少了很多必要的特性,我们把其中一些作为练习题列在本小节之后。但是有一个方法如果缺失的话我们的bit数组可能会比较难混:将IntSet作为一个字符串来打印。

    • 这里我们来实现它,让我们来给上面的例子添加一个String方法,类似2.5节中做的那样:

      
      // String returns the set as a string of the form "{1 2 3}".
      
      func (s *IntSet) String() string {
      
      	var buf bytes.Buffer
      
      	buf.WriteByte('{')
      
      	for i, word := range s.words {
      
      		if word == 0 {
      
      			continue
      
      		}
      
      		for j := 0; j < 64; j++ {
      
      			if word&(1<<uint(j)) != 0 {
      
      				if buf.Len() > len("{") {
      
      					buf.WriteByte(' ')
      
      				}
      
      				fmt.Fprintf(&buf, "%d", 64*i+j)
      
      			}
      
      		}
      
      	}
      
      	buf.WriteByte('}')
      
      	return buf.String()
      
      }
      
    • 这里留意一下String方法,是不是和3.5.4节中的intsToString方法很相似;bytes.Buffer在String方法里经常这么用。当你为一个复杂的类型定义了一个String方法时,fmt包就会特殊对待这种类型的值,这样可以让这些类型在打印的时候看起来更加友好,而不是直接打印其原始的值。fmt会直接调用用户定义的String方法。这种机制依赖于接口和类型断言,

  • 现在我们就可以在实战中直接用上面定义好的IntSet了:

    var x, y IntSet
    x.Add(1)
    x.Add(144)
    x.Add(9)
    fmt.Println(x.String()) // "{1 9 144}"
    
    y.Add(9)
    y.Add(42)
    fmt.Println(y.String()) // "{9 42}"
    
    x.UnionWith(&y)
    fmt.Println(x.String()) // "{1 9 42 144}"
    fmt.Println(x.Has(9), x.Has(123)) // "true false"
    
    • 这里要注意:我们声明的String和Has两个方法都是以指针类型*IntSet来作为接收器的,但实际上对于这两个类型来说,把接收器声明为指针类型也没什么必要。

    • 不过另外两个函数就不是这样了,因为另外两个函数操作的是s.words对象,如果你不把接收器声明为指针对象,那么实际操作的是拷贝对象,而不是原来的那个对象。

    • 因此,因为我们的String方法定义在IntSet指针上,所以当我们的变量是IntSet类型而不是IntSet指针时,可能会有下面这样让人意外的情况

      fmt.Println(&x)         // "{1 9 42 144}"
      fmt.Println(x.String()) // "{1 9 42 144}"
      fmt.Println(x)          // "{[4398046511618 0 65536]}"
      
      • 在第一个Println中,我们打印一个*IntSet的指针,这个类型的指针确实有自定义的String方法。
      • 第二Println,我们直接调用了x变量的String()方法;这种情况下编译器会隐式地在x前插入&操作符,这样相当于我们还是调用的IntSet指针的String方法。
      • 在第三个Println中,因为IntSet类型没有String方法,所以Println方法会直接以原始的方式理解并打印。所以在这种情况下&符号是不能忘的。
      • 在我们这种场景下,你把String方法绑定到IntSet对象上,而不是IntSet指针上可能会更合适一些,不过这也需要具体问题具体分析。

ch6.6 封装

  • 一个对象的变量或者方法如果对调用方是不可见的话,一般就被定义为“封装”。封装有时候也被叫做信息隐藏,同时也是面向对象编程最关键的一个方面。

  • Go语言只有一种控制可见性的手段:大写首字母的标识符会从定义它们的包中被导出,小写字母的则不会。这种限制包内成员的方式同样适用于struct或者一个类型的方法。因而如果我们想要封装一个对象,我们必须将其定义为一个struct。

    • 这也就是前面的小节中IntSet被定义为struct类型的原因,尽管它只有一个字段:

      type IntSet struct {
          words []uint64
      }
      
    • 当然,我们也可以把IntSet定义为一个slice类型,但这样我们就需要把代码中所有方法里用到的s.words用*s替换掉了:

      type IntSet []uint64
      
    • 尽管这个版本的IntSet在本质上是一样的,但它也允许其它包中可以直接读取并编辑这个slice。换句话说,相对于*s这个表达式会出现在所有的包中,s.words只需要在定义IntSet的包中出现

      • (译注:所以还是推荐后者吧的意思)
  • 这种基于名字的手段使得在语言中最小的封装单元是package,而不是像其它语言一样的类型。一个struct类型的字段对同一个包的所有代码都有可见性,无论你的代码是写在一个函数还是一个方法里。

  • 封装提供了三方面的优点。

    • 首先,因为调用方不能直接修改对象的变量值,其只需要关注少量的语句并且只要弄懂少量变量的可能的值即可。

    • 第二,隐藏实现的细节,可以防止调用方依赖那些可能变化的具体实现,这样使设计包的程序员在不破坏对外的api情况下能得到更大的自由。

      • 把bytes.Buffer这个类型作为例子来考虑。这个类型在做短字符串叠加的时候很常用,所以在设计的时候可以做一些预先的优化,比如提前预留一部分空间,来避免反复的内存分配。又因为Buffer是一个struct类型,这些额外的空间可以用附加的字节数组来保存,且放在一个小写字母开头的字段中。

      • 这样在外部的调用方只能看到性能的提升,但并不会得到这个附加变量。Buffer和其增长算法我们列在这里,为了简洁性稍微做了一些精简:

        type Buffer struct {
            buf     []byte
            initial [64]byte
            /* ... */
        }
        
        // Grow expands the buffer's capacity, if necessary,
        // to guarantee space for another n bytes. [...]
        func (b *Buffer) Grow(n int) {
            if b.buf == nil {
                b.buf = b.initial[:0] // use preallocated space initially
            }
            if len(b.buf)+n > cap(b.buf) {
                buf := make([]byte, b.Len(), 2*cap(b.buf) + n)
                copy(buf, b.buf)
                b.buf = buf
            }
        }
        
    • 封装的第三个优点也是最重要的优点,是阻止了外部调用方对对象内部的值任意地进行修改。因为对象内部变量只可以被同一个包内的函数修改,所以包的作者可以让这些函数确保对象内部的一些值的不变性。

      • 比如下面的Counter类型允许调用方来增加counter变量的值,并且允许将这个值reset为0,但是不允许随便设置这个值(译注:因为压根就访问不到):

        type Counter struct { n int }
        func (c *Counter) N() int     { return c.n }
        func (c *Counter) Increment() { c.n++ }
        func (c *Counter) Reset()     { c.n = 0 }
        
      • 只用来访问或修改内部变量的函数被称为setter或者getter,例子如下,比如log包里的Logger类型对应的一些函数。在命名一个getter方法时,我们通常会省略掉前面的Get前缀。这种简洁上的偏好也可以推广到各种类型的前缀比如Fetch,Find或者Lookup。

        package log
        type Logger struct {
        	flags  int
        	prefix string
        	// ...
        }
        func (l *Logger) Flags() int
        func (l *Logger) SetFlags(flag int)
        func (l *Logger) Prefix() string
        func (l *Logger) SetPrefix(prefix string)
        
  • Go的编码风格不禁止直接导出字段。当然,一旦进行了导出,就没有办法在保证API兼容的情况下去除对其的导出,所以在一开始的选择一定要经过深思熟虑并且要考虑到包内部的一些不变量的保证,未来可能的变化,以及调用方的代码质量是否会因为包的一点修改而变差。

  • 封装并不总是理想的。

    • 虽然封装在有些情况是必要的,但有时候我们也需要暴露一些内部内容,比如:time.Duration将其表现暴露为一个int64数字的纳秒,使得我们可以用一般的数值操作来对时间进行对比,甚至可以定义这种类型的常量:

      const day = 24 * time.Hour
      fmt.Println(day.Seconds()) // "86400"
      
    • 另一个例子,将IntSet和本章开头的geometry.Path进行对比。Path被定义为一个slice类型,这允许其调用slice的字面方法来对其内部的points用range进行迭代遍历;在这一点上,IntSet是没有办法让你这么做的。

    • 这两种类型决定性的不同:geometry.Path的本质是一个坐标点的序列,不多也不少,我们可以预见到之后也并不会给他增加额外的字段,所以在geometry包中将Path暴露为一个slice。相比之下,IntSet仅仅是在这里用了一个[]uint64的slice。这个类型还可以用[]uint类型来表示,或者我们甚至可以用其它完全不同的占用更小内存空间的东西来表示这个集合,所以我们可能还会需要额外的字段来在这个类型中记录元素的个数。也正是因为这些原因,我们让IntSet对调用方不透明。

  • 在这章中,我们学到了如何将方法与命名类型进行组合,并且知道了如何调用这些方法。尽管方法对于OOP编程来说至关重要,但他们只是OOP编程里的半边天。为了完成OOP,我们还需要接口。Go里的接口会在下一章中介绍。

ch7 接口

ch7.0 简介

  • 接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让我们的函数更加灵活和更具有适应能力。
  • 很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的。也就是说,我们没有必要对于给定的具体类型定义所有满足的接口类型;简单地拥有一些必需的方法就足够了。
  • 这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不会去改变这些类型的定义;当我们使用的类型来自于不受我们控制的包时这种设计尤其有用。
  • 在本章,我们会开始看到接口类型和值的一些基本技巧。顺着这种方式我们将学习几个来自标准库的重要接口。很多Go程序中都尽可能多的去使用标准库中的接口。最后,我们会在(§7.10)看到类型断言的知识,在(§7.13)看到类型开关的使用并且学到他们是怎样让不同的类型的概括成为可能。

ch7.1 接口是合约

  • 目前为止,我们看到的类型都是具体的类型。一个具体的类型可以准确的描述它所代表的值,并且展示出对类型本身的一些操作方式:就像数字类型的算术操作,切片类型的取下标、添加元素和范围获取操作。具体的类型还可以通过它的内置方法提供额外的行为操作。总的来说,当你拿到一个具体的类型时你就知道它的本身是什么和你可以用它来做什么。

  • 在Go语言中还存在着另外一种类型:接口类型。接口类型是一种抽象的类型。它不会暴露出它所代表的对象的内部值的结构和这个对象支持的基础操作的集合;它们只会表现出它们自己的方法。也就是说当你有看到一个接口类型的值时,你不知道它是什么,唯一知道的就是可以通过它的方法来做什么。

  • 在本书中,我们一直使用两个相似的函数来进行字符串的格式化:fmt.Printf,它会把结果写到标准输出,和fmt.Sprintf,它会把结果以字符串的形式返回。

    • 得益于使用接口,我们不必可悲的因为返回结果在使用方式上的一些浅显不同就必需把格式化这个最困难的过程复制一份。实际上,这两个函数都使用了另一个函数fmt.Fprintf来进行封装。fmt.Fprintf这个函数对它的计算结果会被怎么使用是完全不知道的。

      package fmt
      
      func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)
      func Printf(format string, args ...interface{}) (int, error) {
      	return Fprintf(os.Stdout, format, args...)
      }
      func Sprintf(format string, args ...interface{}) string {
      	var buf bytes.Buffer
      	Fprintf(&buf, format, args...)
      	return buf.String()
      }
      
    • Fprintf的前缀F表示文件(File)也表明格式化输出结果应该被写入第一个参数提供的文件中。在Printf函数中的第一个参数os.Stdout是*os.File类型;在Sprintf函数中的第一个参数&buf是一个指向可以写入字节的内存缓冲区,然而它并不是一个文件类型尽管它在某种意义上和文件类型相似。

    • 即使Fprintf函数中的第一个参数也不是一个文件类型。它是io.Writer类型,这是一个接口类型定义如下:

      package io
      
      // Writer is the interface that wraps the basic Write method.
      type Writer interface {
      	// Write writes len(p) bytes from p to the underlying data stream.
      	// It returns the number of bytes written from p (0 <= n <= len(p))
      	// and any error encountered that caused the write to stop early.
      	// Write must return a non-nil error if it returns n < len(p).
      	// Write must not modify the slice data, even temporarily.
      	//
      	// Implementations must not retain p.
      	Write(p []byte) (n int, err error)
      }
      
    • io.Writer类型定义了函数Fprintf和这个函数调用者之间的约定。一方面这个约定需要调用者提供具体类型的值就像*os.File*bytes.Buffer,这些类型都有一个特定签名和行为的Write的函数。另一方面这个约定保证了Fprintf接受任何满足io.Writer接口的值都可以工作。Fprintf函数可能没有假定写入的是一个文件或是一段内存,而是写入一个可以调用Write函数的值。

    • 因为fmt.Fprintf函数没有对具体操作的值做任何假设,而是仅仅通过io.Writer接口的约定来保证行为,所以第一个参数可以安全地传入一个只需要满足io.Writer接口的任意具体类型的值。一个类型可以自由地被另一个满足相同接口的类型替换,被称作可替换性(LSP里氏替换)。这是一个面向对象的特征。

  • 让我们通过一个新的类型来进行校验,下面*ByteCounter类型里的Write方法,仅仅在丢弃写向它的字节前统计它们的长度。(在这个+=赋值语句中,让len(p)的类型和*c的类型匹配的转换是必须的。)

    gopl.io/ch7/bytecounter

    type ByteCounter int
    
    func (c *ByteCounter) Write(p []byte) (int, error) {
    	*c += ByteCounter(len(p)) // convert int to ByteCounter
    	return len(p), nil
    }
    
    • 因为*ByteCounter满足io.Writer的约定,我们可以把它传入Fprintf函数中;Fprintf函数执行字符串格式化的过程不会去关注ByteCounter正确的累加结果的长度。

      var c ByteCounter
      c.Write([]byte("hello"))
      fmt.Println(c) // "5", = len("hello")
      c = 0          // reset the counter
      var name = "Dolly"
      fmt.Fprintf(&c, "hello, %s", name)
      fmt.Println(c) // "12", = len("hello, Dolly")
      
  • 除了io.Writer这个接口类型,还有另一个对fmt包很重要的接口类型。Fprintf和Fprintln函数向类型提供了一种控制它们值输出的途径。在2.5节中,我们为Celsius类型提供了一个String方法以便于可以打印成这样"100°C" ,在6.5节中我们给*IntSet添加一个String方法,这样集合可以用传统的符号来进行表示就像"{1 2 3}"。给一个类型定义String方法,可以让它满足最广泛使用之一的接口类型fmt.Stringer:

    package fmt
    
    // The String method is used to print values passed
    // as an operand to any format that accepts a string
    // or to an unformatted printer such as Print.
    type Stringer interface {
    	String() string
    }
    
  • 我们会在7.10节解释fmt包怎么发现哪些值是满足这个接口类型的。

ch7.2 接口类型

  • 接口类型具体描述了一系列方法的集合,一个实现了这些方法的具体类型是这个接口类型的实例。

  • io.Writer类型是用得最广泛的接口之一,因为它提供了所有类型的写入bytes的抽象,包括文件类型,内存缓冲区,网络链接,HTTP客户端,压缩工具,哈希等等。

  • io包中定义了很多其它有用的接口类型。Reader可以代表任意可以读取bytes的类型,Closer可以是任意可以关闭的值,例如一个文件或是网络链接。

    package io
    type Reader interface {
    	Read(p []byte) (n int, err error)
    }
    type Closer interface {
    	Close() error
    }
    
    • 到现在你可能注意到了很多Go语言中单方法接口的命名习惯
  • 再往下看,我们发现有些新的接口类型通过组合已有的接口来定义。下面是两个例子:

    type ReadWriter interface {
    	Reader
    	Writer
    }
    type ReadWriteCloser interface {
    	Reader
    	Writer
    	Closer
    }
    
  • 上面用到的语法和结构内嵌相似,我们可以用这种方式以一个简写命名一个接口,而不用声明它所有的方法。这种方式称为接口内嵌。尽管略失简洁,我们可以像下面这样,不使用内嵌来声明io.ReadWriter接口。

    type ReadWriter interface {
    	Read(p []byte) (n int, err error)
    	Write(p []byte) (n int, err error)
    }
    
  • 或者甚至使用一种混合的风格:

    type ReadWriter interface {
    	Read(p []byte) (n int, err error)
    	Writer
    }
    
  • 上面3种定义方式都是一样的效果。方法顺序的变化也没有影响,唯一重要的就是这个集合里面的方法。

ch7.3 实现接口的条件

  • 一个类型如果拥有一个接口需要的所有方法,那么这个类型就实现了这个接口。

    • 例如,*os.File类型实现了io.Reader,Writer,Closer,和ReadWriter接口。
    • *bytes.Buffer实现了Reader,Writer,和ReadWriter这些接口,但是它没有实现Closer接口因为它不具有Close方法。
    • Go的程序员经常会简要的把一个具体的类型描述成一个特定的接口类型。举个例子,*bytes.Buffer是io.Writer;*os.Files是io.ReadWriter。
  • 接口指定的规则非常简单:表达一个类型属于某个接口只要这个类型实现这个接口。所以:

    var w io.Writer
    w = os.Stdout           // OK: *os.File has Write method
    w = new(bytes.Buffer)   // OK: *bytes.Buffer has Write method
    w = time.Second         // compile error: time.Duration lacks Write method
    
    var rwc io.ReadWriteCloser
    rwc = os.Stdout         // OK: *os.File has Read, Write, Close methods
    rwc = new(bytes.Buffer) // compile error: *bytes.Buffer lacks Close method
    
    • 这个规则甚至适用于等式右边本身也是一个接口类型

      w = rwc                 // OK: io.ReadWriteCloser has Write method
      rwc = w                 // compile error: io.Writer lacks Close method
      
    • 因为ReadWriter和ReadWriteCloser包含有Writer的方法,所以任何实现了ReadWriter和ReadWriteCloser的类型必定也实现了Writer接口

  • 在进一步学习前,必须先解释一个类型持有一个方法的表示当中的细节。回想在6.2章中,对于每一个命名过的具体类型T;它的一些方法的接收者是类型T本身然而另一些则是一个*T的指针。还记得在T类型的参数上调用一个*T的方法是合法的,只要这个参数是一个变量;编译器隐式的获取了它的地址。但这仅仅是一个语法糖:T类型的值不拥有所有*T指针的方法,这样它就可能只实现了更少的接口。

    • 举个例子可能会更清晰一点。在第6.5章中,IntSet类型的String方法的接收者是一个指针类型,所以我们不能在一个不能寻址的IntSet值上调用这个方法:

      type IntSet struct { /* ... */ }
      func (*IntSet) String() string
      var _ = IntSet{}.String() // compile error: String requires *IntSet receiver
      
    • 但是我们可以在一个IntSet变量上调用这个方法:

      var s IntSet
      var _ = s.String() // OK: s is a variable and &s has a String method
      
    • 然而,由于只有*IntSet类型有String方法,所以也只有*IntSet类型实现了fmt.Stringer接口:

      var _ fmt.Stringer = &s // OK
      var _ fmt.Stringer = s  // compile error: IntSet lacks String method
      
  • 12.8章包含了一个打印出任意值的所有方法的程序,然后可以使用godoc -analysis=type tool(§10.7.4)展示每个类型的方法和具体类型和接口之间的关系

  • 就像信封封装和隐藏起信件来一样,接口类型封装和隐藏具体类型和它的值。即使具体类型有其它的方法,也只有接口类型暴露出来的方法会被调用到:

    os.Stdout.Write([]byte("hello")) // OK: *os.File has Write method
    os.Stdout.Close()                // OK: *os.File has Close method
    
    var w io.Writer
    w = os.Stdout
    w.Write([]byte("hello")) // OK: io.Writer has Write method
    w.Close()                // compile error: io.Writer lacks Close method
    
  • 一个有更多方法的接口类型,比如io.ReadWriter,和少一些方法的接口类型例如io.Reader,进行对比;更多方法的接口类型会告诉我们更多关于它的值持有的信息,并且对实现它的类型要求更加严格。

  • 那么关于interface{}类型,它没有任何方法,请讲出哪些具体的类型实现了它?

    • 这看上去好像没有用,但实际上interface{}被称为空接口类型是不可或缺的。因为空接口类型对实现它的类型没有要求,所以我们可以将任意一个值赋给空接口类型。

      var any interface{}
      any = true
      any = 12.34
      any = "hello"
      any = map[string]int{"one": 1}
      any = new(bytes.Buffer)
      
    • 尽管不是很明显,从本书最早的例子中我们就已经在使用空接口类型。它允许像fmt.Println或者5.7章中的errorf函数接受任何类型的参数。

  • 对于创建的一个interface{}值持有一个boolean,float,string,map,pointer,或者任意其它的类型;我们当然不能直接对它持有的值做操作,因为interface{}没有任何方法。我们会在7.10章中学到一种用类型断言来获取interface{}中值的方法。

  • 因为接口与实现只依赖于判断两个类型的方法,所以没有必要定义一个具体类型和它实现的接口之间的关系。也就是说,有意地在文档里说明或者程序上断言这种关系偶尔是有用的,但程序上不强制这么做。下面的定义在编译期断言一个*bytes.Buffer的值实现了io.Writer接口类型:

    // *bytes.Buffer must satisfy io.Writer
    var w io.Writer = new(bytes.Buffer)
    
    • 因为任意*bytes.Buffer的值,甚至包括nil通过(*bytes.Buffer)(nil)进行显示的转换都实现了这个接口,所以我们不必分配一个新的变量。并且因为我们绝不会引用变量w,我们可以使用空标识符来进行代替。总的看,这些变化可以让我们得到一个更朴素的版本:

      // *bytes.Buffer must satisfy io.Writer
      var _ io.Writer = (*bytes.Buffer)(nil)
      
  • 非空的接口类型比如io.Writer经常被指针类型实现,尤其当一个或多个接口方法像Write方法那样隐式的给接收者带来变化的时候。一个结构体的指针是非常常见的承载方法的类型。

  • 但是并不意味着只有指针类型满足接口类型,甚至连一些有设置方法的接口类型也可能会被Go语言中其它的引用类型实现。我们已经看过slice类型的方法(geometry.Path,§6.1)和map类型的方法(url.Values,§6.2.1),后面还会看到函数类型的方法的例子(http.HandlerFunc,§7.7)。甚至基本的类型也可能会实现一些接口;就如我们在7.4章中看到的time.Duration类型实现了fmt.Stringer接口。

  • 一个具体的类型可能实现了很多不相关的接口。考虑在一个组织出售数字文化产品比如音乐,电影和书籍的程序中可能定义了下列的具体类型:

    Album
    Book
    Movie
    Magazine
    Podcast
    TVEpisode
    Track
    
    • 我们可以把每个抽象的特点用接口来表示。一些特性对于所有的这些文化产品都是共通的,例如标题,创作日期和作者列表。

      type Artifact interface {
      	Title() string
      	Creators() []string
      	Created() time.Time
      }
      
    • 其它的一些特性只对特定类型的文化产品才有。和文字排版特性相关的只有books和magazines,还有只有movies和TV剧集和屏幕分辨率相关。

      type Text interface {
      	Pages() int
      	Words() int
      	PageSize() int
      }
      type Audio interface {
      	Stream() (io.ReadCloser, error)
      	RunningTime() time.Duration
      	Format() string // e.g., "MP3", "WAV"
      }
      type Video interface {
      	Stream() (io.ReadCloser, error)
      	RunningTime() time.Duration
      	Format() string // e.g., "MP4", "WMV"
      	Resolution() (x, y int)
      }
      
    • 这些接口不止是一种有用的方式来分组相关的具体类型和表示他们之间的共同特点。我们后面可能会发现其它的分组。举例,如果我们发现我们需要以同样的方式处理Audio和Video,我们可以定义一个Streamer接口来代表它们之间相同的部分而不必对已经存在的类型做改变。

      type Streamer interface {
      	Stream() (io.ReadCloser, error)
      	RunningTime() time.Duration
      	Format() string
      }
      
  • 每一个具体类型的组基于它们相同的行为可以表示成一个接口类型。不像基于类的语言,他们一个类实现的接口集合需要进行显式的定义,在Go语言中我们可以在需要的时候定义一个新的抽象或者特定特点的组,而不需要修改具体类型的定义。当具体的类型来自不同的作者时这种方式会特别有用。当然也确实没有必要在具体的类型中指出这些共性。

ch7.4 flag.Value接口

  • 在本章,我们会学到另一个标准的接口类型flag.Value是怎么帮助命令行标记定义新的符号的。思考下面这个会休眠特定时间的程序:

    gopl.io/ch7/sleep

    var period = flag.Duration("period", 1*time.Second, "sleep period")
    
    func main() {
    	flag.Parse()
    	fmt.Printf("Sleeping for %v...", *period)
    	time.Sleep(*period)
    	fmt.Println()
    }
    
  • 在它休眠前它会打印出休眠的时间周期。fmt包调用time.Duration的String方法打印这个时间周期是以用户友好的注解方式,而不是一个纳秒数字:

    $ go build gopl.io/ch7/sleep
    $ ./sleep
    Sleeping for 1s...
    
    • 默认情况下,休眠周期是一秒,但是可以通过 -period 这个命令行标记来控制。flag.Duration函数创建一个time.Duration类型的标记变量并且允许用户通过多种用户友好的方式来设置这个变量的大小,这种方式还包括和String方法相同的符号排版形式。这种对称设计使得用户交互良好。
  • 因为时间周期标记值非常的有用,所以这个特性被构建到了flag包中;但是我们为我们自己的数据类型定义新的标记符号是简单容易的。我们只需要定义一个实现flag.Value接口的类型,如下:

    package flag
    
    // Value is the interface to the value stored in a flag.
    type Value interface {
    	String() string
    	Set(string) error
    }
    
    • String方法格式化标记的值用在命令行帮助消息中;这样每一个flag.Value也是一个fmt.Stringer。
    • Set方法解析它的字符串参数并且更新标记变量的值。
    • 实际上,Set方法和String是两个相反的操作,所以最好的办法就是对他们使用相同的注解方式。
  • 让我们定义一个允许通过摄氏度或者华氏温度变换的形式指定温度的celsiusFlag类型。注意celsiusFlag内嵌了一个Celsius类型(§2.5),因此不用实现本身就已经有String方法了。为了实现flag.Value,我们只需要定义Set方法:

    gopl.io/ch7/tempconv

    // *celsiusFlag satisfies the flag.Value interface.
    type celsiusFlag struct{ Celsius }
    
    func (f *celsiusFlag) Set(s string) error {
    	var unit string
    	var value float64
    	fmt.Sscanf(s, "%f%s", &value, &unit) // no error check needed
    	switch unit {
    	case "C", "°C":
    		f.Celsius = Celsius(value)
    		return nil
    	case "F", "°F":
    		f.Celsius = FToC(Fahrenheit(value))
    		return nil
    	}
    	return fmt.Errorf("invalid temperature %q", s)
    }
    
    • 调用fmt.Sscanf函数从输入s中解析一个浮点数(value)和一个字符串(unit)。虽然通常必须检查Sscanf的错误返回,但是在这个例子中我们不需要因为如果有错误发生,就没有switch case会匹配到。
  • 下面的CelsiusFlag函数将所有逻辑都封装在一起。它返回一个内嵌在celsiusFlag变量f中的Celsius指针给调用者。

    // CelsiusFlag defines a Celsius flag with the specified name,
    // default value, and usage, and returns the address of the flag variable.
    // The flag argument must have a quantity and a unit, e.g., "100C".
    func CelsiusFlag(name string, value Celsius, usage string) *Celsius {
    	f := celsiusFlag{value}
    	flag.CommandLine.Var(&f, name, usage)
    	return &f.Celsius
    }
    
    • Celsius字段是一个会通过Set方法在标记处理的过程中更新的变量。
    • 用Var方法将标记加入应用的命令行标记集合中,有异常复杂命令行接口的全局变量flag.CommandLine.Programs可能有几个这个类型的变量。
    • 调用Var方法将一个*celsiusFlag参数赋值给一个flag.Value参数,导致编译器去检查*celsiusFlag是否有必须的方法。
  • 现在我们可以开始在我们的程序中使用新的标记:

    gopl.io/ch7/tempflag

    var temp = tempconv.CelsiusFlag("temp", 20.0, "the temperature")
    
    func main() {
    	flag.Parse()
    	fmt.Println(*temp)
    }
    
    • 下面是典型的场景:

      $ go build gopl.io/ch7/tempflag
      $ ./tempflag
      20°C
      $ ./tempflag -temp -18C
      -18°C
      $ ./tempflag -temp 212°F
      100°C
      $ ./tempflag -temp 273.15K
      invalid value "273.15K" for flag -temp: invalid temperature "273.15K"
      Usage of ./tempflag:
        -temp value
              the temperature (default 20°C)
      $ ./tempflag -help
      Usage of ./tempflag:
        -temp value
              the temperature (default 20°C)
      

ch7.5 接口值

  • ch7.5.0 简介

    • 概念上讲一个接口的值,接口值,由两个部分组成,一个具体的类型和那个类型的值。它们被称为接口的动态类型和动态值。对于像Go语言这种静态类型的语言,类型是编译期的概念;因此一个类型不是一个值。在我们的概念模型中,一些提供每个类型信息的值被称为类型描述符,比如类型的名称和方法。在一个接口值中,类型部分代表与之相关类型的描述符

    • 下面4个语句中,变量w得到了3个不同的值。(开始和最后的值是相同的)

      var w io.Writer
      w = os.Stdout
      w = new(bytes.Buffer)
      w = nil
      
      • 让我们进一步观察在每一个语句后的w变量的值和动态行为。第一个语句定义了变量w:

        var w io.Writer
        
        • 在Go语言中,变量总是被一个定义明确的值初始化,即使接口类型也不例外。对于一个接口的零值就是它的类型和值的部分都是nil

        • 一个接口值基于它的动态类型被描述为空或非空,所以这是一个空的接口值。你可以通过使用w==nil或者w!=nil来判断接口值是否为空。调用一个空接口值上的任意方法都会产生panic:

          w.Write([]byte("hello")) // panic: nil pointer dereference
          
      • 第二个语句将一个*os.File类型的值赋给变量w

        w = os.Stdout
        
        • 这个赋值过程调用了一个具体类型到接口类型的隐式转换,这和显式的使用io.Writer(os.Stdout)是等价的。这类转换不管是显式的还是隐式的,都会刻画出操作到的类型和值。这个接口值的动态类型被设为*os.File指针的类型描述符,它的动态值持有os.Stdout的拷贝;这是一个代表处理标准输出的os.File类型变量的指针

        • 调用一个包含*os.File类型指针的接口值的Write方法,使得(*os.File).Write方法被调用。这个调用输出“hello”。

          w.Write([]byte("hello")) // "hello"
          
        • 通常在编译期,我们不知道接口值的动态类型是什么,所以一个接口上的调用必须使用动态分配。因为不是直接进行调用,所以编译器必须把代码生成在类型描述符的方法Write上,然后间接调用那个地址。这个调用的接收者是一个接口动态值的拷贝,os.Stdout。效果和下面这个直接调用一样:

          os.Stdout.Write([]byte("hello")) // "hello"
          
      • 第三个语句给接口值赋了一个*bytes.Buffer类型的值

        w = new(bytes.Buffer)
        
        • 现在动态类型是*bytes.Buffer并且动态值是一个指向新分配的缓冲区的指针

        • Write方法的调用也使用了和之前一样的机制:

          w.Write([]byte("hello")) // writes "hello" to the bytes.Buffers
          
        • 这次类型描述符是*bytes.Buffer,所以调用了(*bytes.Buffer).Write方法,并且接收者是该缓冲区的地址。这个调用把字符串“hello”添加到缓冲区中。

      • 最后,第四个语句将nil赋给了接口值:

        w = nil
        
        • 这个重置将它所有的部分都设为nil值,把变量w恢复到和它之前定义时相同的状态
    • 一个接口值可以持有任意大的动态值。例如,表示时间实例的time.Time类型,这个类型有几个对外不公开的字段。我们从它上面创建一个接口值:

      var x interface{} = time.Now()
      
      • 从概念上讲,不论接口值多大,动态值总是可以容下它。
    • 接口值可以使用和!=来进行比较。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的操作相等。因为接口值是可比较的,所以它们可以用在map的键或者作为switch语句的操作数。

    • 然而,如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic

      var x interface{} = []int{1, 2, 3}
      fmt.Println(x == x) // panic: comparing uncomparable type []int
      
    • 考虑到这点,接口类型是非常与众不同的。

      • 其它类型要么是安全的可比较类型(如基本类型和指针)
      • 要么是完全不可比较的类型(如切片,映射类型,和函数)
      • 但是在比较接口值或者包含了接口值的聚合类型时,我们必须要意识到潜在的panic
      • 同样的风险也存在于使用接口作为map的键或者switch的操作数
      • 只能比较你非常确定它们的动态值是可比较类型的接口值。
    • 当我们处理错误或者调试的过程中,得知接口值的动态类型是非常有帮助的。所以我们使用fmt包的%T动作:

      var w io.Writer
      fmt.Printf("%T\n", w) // "<nil>"
      w = os.Stdout
      fmt.Printf("%T\n", w) // "*os.File"
      w = new(bytes.Buffer)
      fmt.Printf("%T\n", w) // "*bytes.Buffer"
      
      • 在fmt包内部,使用反射来获取接口动态类型的名称。
  • ch7.5.1 警告:一个包含nil指针的接口不是nil接口

    • 一个不包含任何值的nil接口值和一个刚好包含nil指针的接口值是不同的。这个细微区别产生了一个容易绊倒每个Go程序员的陷阱。

    • 思考下面的程序。当debug变量设置为true时,main函数会将f函数的输出收集到一个bytes.Buffer类型中。

      const debug = true
      
      func main() {
      	var buf *bytes.Buffer
      	if debug {
      		buf = new(bytes.Buffer) // enable collection of output
      	}
      	f(buf) // NOTE: subtly incorrect!
      	if debug {
      		// ...use buf...
      	}
      }
      
      // If out is non-nil, output will be written to it.
      func f(out io.Writer) {
      	// ...do something...
      	if out != nil {
      		out.Write([]byte("done!\n"))
      	}
      }
      
    • 我们可能会预计当把变量debug设置为false时可以禁止对输出的收集,但是实际上在out.Write方法调用时程序发生了panic:

      if out != nil {
      	out.Write([]byte("done!\n")) // panic: nil pointer dereference
      }
      
    • 当main函数调用函数f时,它给f函数的out参数赋了一个*bytes.Buffer的空指针,所以out的动态值是nil。然而,它的动态类型是*bytes.Buffer,意思就是out变量是一个包含空指针值的非空接口(如图7.5),所以防御性检查out!=nil的结果依然是true。

    • 动态分配机制依然决定(*bytes.Buffer).Write的方法会被调用,但是这次的接收者的值是nil。对于一些如*os.File的类型,nil是一个有效的接收者(§6.2.1),但是*bytes.Buffer类型不在这些种类中。这个方法会被调用,但是当它尝试去获取缓冲区时会发生panic。

    • 问题在于尽管一个nil的*bytes.Buffer指针有实现这个接口的方法,它也不满足这个接口具体的行为上的要求。特别是这个调用违反了(*bytes.Buffer).Write方法的接收者非空的隐含先觉条件,所以将nil指针赋给这个接口是错误的。解决方案就是将main函数中的变量buf的类型改为io.Writer,因此可以避免一开始就将一个不完整的值赋值给这个接口:

      var buf io.Writer
      if debug {
      	buf = new(bytes.Buffer) // enable collection of output
      }
      f(buf) // OK
      
    • 现在我们已经把接口值的技巧都讲完了,让我们来看更多的一些在Go标准库中的重要接口类型。在下面的三章中,我们会看到接口类型是怎样用在排序,web服务,错误处理中的。

ch7.6 sort.Interface接口

  • 排序操作和字符串格式化一样是很多程序经常使用的操作。尽管一个最短的快排程序只要15行就可以搞定,但是一个健壮的实现需要更多的代码,并且我们不希望每次我们需要的时候都重写或者拷贝这些代码。

  • 幸运的是,sort包内置的提供了根据一些排序函数来对任何序列排序的功能。它的设计非常独到。在很多语言中,排序算法都是和序列数据类型关联,同时排序函数和具体类型元素关联。

  • 相比之下,Go语言的sort.Sort函数不会对具体的序列和它的元素做任何假设。相反,它使用了一个接口类型sort.Interface来指定通用的排序算法和可能被排序到的序列类型之间的约定。这个接口的实现由序列的具体表示和它希望排序的元素决定,序列的表示经常是一个切片。

  • 一个内置的排序算法需要知道三个东西

    • 序列的长度

    • 表示两个元素比较的结果

    • 一种交换两个元素的方式

    • 这就是sort.Interface的三个方法:

      package sort
      
      type Interface interface {
      	Len() int
      	Less(i, j int) bool // i, j are indices of sequence elements
      	Swap(i, j int)
      }
      
  • 为了对序列进行排序,我们需要定义一个实现了这三个方法的类型,然后对这个类型的一个实例应用sort.Sort函数。

    • 思考对一个字符串切片进行排序,这可能是最简单的例子了。下面是这个新的类型StringSlice和它的Len,Less和Swap方法

      type StringSlice []string
      func (p StringSlice) Len() int           { return len(p) }
      func (p StringSlice) Less(i, j int) bool { return p[i] < p[j] }
      func (p StringSlice) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
      
    • 现在我们可以通过像下面这样将一个切片转换为一个StringSlice类型来进行排序:

      sort.Sort(StringSlice(names))
      
    • 这个转换得到一个相同长度,容量,和基于names数组的切片值;并且这个切片值的类型有三个排序需要的方法。

  • 对字符串切片的排序是很常用的需要,所以sort包提供了StringSlice类型,也提供了Strings函数能让上面这些调用简化成sort.Strings(names)。

  • 这里用到的技术很容易适用到其它排序序列中,例如我们可以忽略大小写或者含有的特殊字符。(本书使用Go程序对索引词和页码进行排序也用到了这个技术,对罗马数字做了额外逻辑处理。)对于更复杂的排序,我们使用相同的方法,但是会用更复杂的数据结构和更复杂地实现sort.Interface的方法。

  • 我们会运行上面的例子来对一个表格中的音乐播放列表进行排序。每个track都是单独的一行,每一列都是这个track的属性像艺术家,标题,和运行时间。想象一个图形用户界面来呈现这个表格,并且点击一个属性的顶部会使这个列表按照这个属性进行排序;再一次点击相同属性的顶部会进行逆向排序。让我们看下每个点击会发生什么响应。

  • 下面的变量tracks包含了一个播放列表。(One of the authors apologizes for the other author’s musical tastes.)每个元素都不是Track本身而是指向它的指针。尽管我们在下面的代码中直接存储Tracks也可以工作,sort函数会交换很多对元素,所以如果每个元素都是指针而不是Track类型会更快,指针是一个机器字码长度而Track类型可能是八个或更多。

    type Track struct {
    	Title  string
    	Artist string
    	Album  string
    	Year   int
    	Length time.Duration
    }
    
    var tracks = []*Track{
    	{"Go", "Delilah", "From the Roots Up", 2012, length("3m38s")},
    	{"Go", "Moby", "Moby", 1992, length("3m37s")},
    	{"Go Ahead", "Alicia Keys", "As I Am", 2007, length("4m36s")},
    	{"Ready 2 Go", "Martin Solveig", "Smash", 2011, length("4m24s")},
    }
    
    func length(s string) time.Duration {
    	d, err := time.ParseDuration(s)
    	if err != nil {
    		panic(s)
    	}
    	return d
    }
    
  • printTracks函数将播放列表打印成一个表格。一个图形化的展示可能会更好点,但是这个小程序使用text/tabwriter包来生成一个列整齐对齐和隔开的表格,像下面展示的这样。注意到*tabwriter.Writer是满足io.Writer接口的。它会收集每一片写向它的数据;它的Flush方法会格式化整个表格并且将它写向os.Stdout(标准输出)。

    func printTracks(tracks []*Track) {
    	const format = "%v\t%v\t%v\t%v\t%v\t\n"
    	tw := new(tabwriter.Writer).Init(os.Stdout, 0, 8, 2, ' ', 0)
    	fmt.Fprintf(tw, format, "Title", "Artist", "Album", "Year", "Length")
    	fmt.Fprintf(tw, format, "-----", "------", "-----", "----", "------")
    	for _, t := range tracks {
    		fmt.Fprintf(tw, format, t.Title, t.Artist, t.Album, t.Year, t.Length)
    	}
    	tw.Flush() // calculate column widths and print table
    }
    
    • 为了能按照Artist字段对播放列表进行排序,我们会像对StringSlice那样定义一个新的带有必须的Len,Less和Swap方法的切片类型。

      type byArtist []*Track
      func (x byArtist) Len() int           { return len(x) }
      func (x byArtist) Less(i, j int) bool { return x[i].Artist < x[j].Artist }
      func (x byArtist) Swap(i, j int)      { x[i], x[j] = x[j], x[i] }
      
    • 为了调用通用的排序程序,我们必须先将tracks转换为新的byArtist类型,它定义了具体的排序:

      sort.Sort(byArtist(tracks))
      
      • 在按照artist对这个切片进行排序后,printTrack的输出如下

        Title       Artist          Album               Year Length
        -----       ------          -----               ---- ------
        Go Ahead    Alicia Keys     As I Am             2007 4m36s
        Go          Delilah         From the Roots Up   2012 3m38s
        Ready 2 Go  Martin Solveig  Smash               2011 4m24s
        Go          Moby            Moby                1992 3m37s
        
    • 如果用户第二次请求“按照artist排序”,我们会对tracks进行逆向排序。然而我们不需要定义一个有颠倒Less方法的新类型byReverseArtist,因为sort包中提供了Reverse函数将排序顺序转换成逆序。

      sort.Sort(sort.Reverse(byArtist(tracks)))
      
      • 在按照artist对这个切片进行逆向排序后,printTrack的输出如下

        Title       Artist          Album               Year Length
        -----       ------          -----               ---- ------
        Go          Moby            Moby                1992 3m37s
        Ready 2 Go  Martin Solveig  Smash               2011 4m24s
        Go          Delilah         From the Roots Up   2012 3m38s
        Go Ahead    Alicia Keys     As I Am             2007 4m36s
        
  • sort.Reverse函数值得进行更近一步的学习,因为它使用了(§6.3)章中的组合,这是一个重要的思路。sort包定义了一个不公开的struct类型reverse,它嵌入了一个sort.Interface。reverse的Less方法调用了内嵌的sort.Interface值的Less方法,但是通过交换索引的方式使排序结果变成逆序。

    package sort
    
    type reverse struct{ Interface } // that is, sort.Interface
    
    func (r reverse) Less(i, j int) bool { return r.Interface.Less(j, i) }
    
    func Reverse(data Interface) Interface { return reverse{data} }
    
    • reverse的另外两个方法Len和Swap隐式地由原有内嵌的sort.Interface提供。因为reverse是一个不公开的类型,所以导出函数Reverse返回一个包含原有sort.Interface值的reverse类型实例。
  • 为了可以按照不同的列进行排序,我们必须定义一个新的类型例如byYear:

    type byYear []*Track
    func (x byYear) Len() int           { return len(x) }
    func (x byYear) Less(i, j int) bool { return x[i].Year < x[j].Year }
    func (x byYear) Swap(i, j int)      { x[i], x[j] = x[j], x[i] }
    
    • 在使用sort.Sort(byYear(tracks))按照年对tracks进行排序后,printTrack展示了一个按时间先后顺序的列表:

      Title       Artist          Album               Year Length
      -----       ------          -----               ---- ------
      Go          Moby            Moby                1992 3m37s
      Go Ahead    Alicia Keys     As I Am             2007 4m36s
      Ready 2 Go  Martin Solveig  Smash               2011 4m24s
      Go          Delilah         From the Roots Up   2012 3m38s
      
  • 对于我们需要的每个切片元素类型和每个排序函数,我们需要定义一个新的sort.Interface实现。如你所见,Len和Swap方法对于所有的切片类型都有相同的定义。

    • 下个例子,具体的类型customSort会将一个切片和函数结合,使我们只需要写比较函数就可以定义一个新的排序。顺便说下,实现了sort.Interface的具体类型不一定是切片类型;customSort是一个结构体类型。

      type customSort struct {
      	t    []*Track
      	less func(x, y *Track) bool
      }
      
      func (x customSort) Len() int           { return len(x.t) }
      func (x customSort) Less(i, j int) bool { return x.less(x.t[i], x.t[j]) }
      func (x customSort) Swap(i, j int)	{ x.t[i], x.t[j] = x.t[j], x.t[i] }
      
  • 让我们定义一个多层的排序函数,它主要的排序键是标题,第二个键是年,第三个键是运行时间Length。下面是该排序的调用,其中这个排序使用了匿名排序函数:

    sort.Sort(customSort{tracks, func(x, y *Track) bool {
    	if x.Title != y.Title {
    		return x.Title < y.Title
    	}
    	if x.Year != y.Year {
    		return x.Year < y.Year
    	}
    	if x.Length != y.Length {
    		return x.Length < y.Length
    	}
    	return false
    }})
    
    • 这下面是排序的结果。注意到两个标题是“Go”的track按照标题排序是相同的顺序,但是在按照year排序上更久的那个track优先。

      Title       Artist          Album               Year Length
      -----       ------          -----               ---- ------
      Go          Moby            Moby                1992 3m37s
      Go          Delilah         From the Roots Up   2012 3m38s
      Go Ahead    Alicia Keys     As I Am             2007 4m36s
      Ready 2 Go  Martin Solveig  Smash               2011 4m24s
      
  • 尽管对长度为n的序列排序需要 O(n log n)次比较操作,检查一个序列是否已经有序至少需要n-1次比较。sort包中的IsSorted函数帮我们做这样的检查。像sort.Sort一样,它也使用sort.Interface对这个序列和它的排序函数进行抽象,但是它从不会调用Swap方法:这段代码示范了IntsAreSorted和Ints函数在IntSlice类型上的使用:

    values := []int{3, 1, 4, 1}
    fmt.Println(sort.IntsAreSorted(values)) // "false"
    sort.Ints(values)
    fmt.Println(values)                     // "[1 1 3 4]"
    fmt.Println(sort.IntsAreSorted(values)) // "true"
    sort.Sort(sort.Reverse(sort.IntSlice(values)))
    fmt.Println(values)                     // "[4 3 1 1]"
    fmt.Println(sort.IntsAreSorted(values)) // "false"
    
  • 为了使用方便,sort包为[]int、[]string和[]float64的正常排序提供了特定版本的函数和类型。对于其他类型,例如[]int64或者[]uint,尽管路径也很简单,还是依赖我们自己实现。

ch7.7 http.Handler接口

  • 在第一章中,我们粗略的了解了怎么用net/http包去实现网络客户端(§1.5)和服务器(§1.7)。在这个小节中,我们会对那些基于http.Handler接口的服务器API做更进一步的学习:

    net/http

    package http
    
    type Handler interface {
    	ServeHTTP(w ResponseWriter, r *Request)
    }
    
    func ListenAndServe(address string, h Handler) error
    
  • ListenAndServe函数需要一个例如“localhost:8000”的服务器地址,和一个所有请求都可以分派的Handler接口实例。它会一直运行,直到这个服务因为一个错误而失败(或者启动失败),它的返回值一定是一个非空的错误。

  • 想象一个电子商务网站,为了销售,将数据库中物品的价格映射成美元。

  • 下面这个程序可能是能想到的最简单的实现了。它将库存清单模型化为一个命名为database的map类型,我们给这个类型一个ServeHttp方法,这样它可以满足http.Handler接口。这个handler会遍历整个map并输出物品信息。

    gopl.io/ch7/http1

    func main() {
    	db := database{"shoes": 50, "socks": 5}
    	log.Fatal(http.ListenAndServe("localhost:8000", db))
    }
    
    type dollars float32
    
    func (d dollars) String() string { return fmt.Sprintf("$%.2f", d) }
    
    type database map[string]dollars
    
    func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    	for item, price := range db {
    		fmt.Fprintf(w, "%s: %s\n", item, price)
    	}
    }
    
    • 如果我们启动这个服务,

      $ go build gopl.io/ch7/http1
      $ ./http1 &
      
    • 然后用1.5节中的获取程序(如果你更喜欢可以使用web浏览器)来连接服务器,我们得到下面的输出:

      $ go build gopl.io/ch1/fetch
      $ ./fetch http://localhost:8000
      shoes: $50.00
      socks: $5.00
      
  • 目前为止,这个服务器不考虑URL,只能为每个请求列出它全部的库存清单。更真实的服务器会定义多个不同的URL,每一个都会触发一个不同的行为。让我们使用/list来调用已经存在的这个行为并且增加另一个/price调用表明单个货品的价格,像这样/price?item=socks来指定一个请求参数。

    func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    	switch req.URL.Path {
    	case "/list":
    		for item, price := range db {
    			fmt.Fprintf(w, "%s: %s\n", item, price)
    		}
    	case "/price":
    		item := req.URL.Query().Get("item")
    		price, ok := db[item]
    		if !ok {
    			w.WriteHeader(http.StatusNotFound) // 404
    			fmt.Fprintf(w, "no such item: %q\n", item)
    			return
    		}
    		fmt.Fprintf(w, "%s\n", price)
    	default:
    		w.WriteHeader(http.StatusNotFound) // 404
    		fmt.Fprintf(w, "no such page: %s\n", req.URL)
    	}
    }
    
    • 现在handler基于URL的路径部分(req.URL.Path)来决定执行什么逻辑。如果这个handler不能识别这个路径,它会通过调用w.WriteHeader(http.StatusNotFound)返回客户端一个HTTP错误;这个检查应该在向w写入任何值前完成。(顺便提一下,http.ResponseWriter是另一个接口。它在io.Writer上增加了发送HTTP相应头的方法。)等效地,我们可以使用实用的http.Error函数:

      msg := fmt.Sprintf("no such page: %s\n", req.URL)
      http.Error(w, msg, http.StatusNotFound) // 404
      
    • /price的case会调用URL的Query方法来将HTTP请求参数解析为一个map,或者更准确地说一个net/url包中url.Values(§6.2.1)类型的多重映射。然后找到第一个item参数并查找它的价格。如果这个货品没有找到会返回一个错误。

    • 这里是一个和新服务器会话的例子:

      $ go build gopl.io/ch7/http2
      $ go build gopl.io/ch1/fetch
      $ ./http2 &
      $ ./fetch http://localhost:8000/list
      shoes: $50.00
      socks: $5.00
      $ ./fetch http://localhost:8000/price?item=socks
      $5.00
      $ ./fetch http://localhost:8000/price?item=shoes
      $50.00
      $ ./fetch http://localhost:8000/price?item=hat
      no such item: "hat"
      $ ./fetch http://localhost:8000/help
      no such page: /help
      
  • 显然我们可以继续向ServeHTTP方法中添加case,但在一个实际的应用中,将每个case中的逻辑定义到一个分开的方法或函数中会很实用。此外,相近的URL可能需要相似的逻辑;例如几个图片文件可能有形如/images/*.png的URL。因为这些原因,net/http包提供了一个请求多路器ServeMux来简化URL和handlers的联系。一个ServeMux将一批http.Handler聚集到一个单一的http.Handler中。再一次,我们可以看到满足同一接口的不同类型是可替换的:web服务器将请求指派给任意的http.Handler而不需要考虑它后面的具体类型

  • 对于更复杂的应用,一些ServeMux可以通过组合来处理更加错综复杂的路由需求。Go语言目前没有一个权威的web框架,就像Ruby语言有Rails和python有Django。这并不是说这样的框架不存在,而是Go语言标准库中的构建模块就已经非常灵活以至于这些框架都是不必要的。此外,尽管在一个项目早期使用框架是非常方便的,但是它们带来额外的复杂度会使长期的维护更加困难。

  • 在下面的程序中,我们创建一个ServeMux并且使用它将URL和相应处理/list和/price操作的handler联系起来,这些操作逻辑都已经被分到不同的方法中。然后我们在调用ListenAndServe函数中使用ServeMux为主要的handler。

    func main() {
    	db := database{"shoes": 50, "socks": 5}
    	mux := http.NewServeMux()
    	mux.Handle("/list", http.HandlerFunc(db.list))
    	mux.Handle("/price", http.HandlerFunc(db.price))
    	log.Fatal(http.ListenAndServe("localhost:8000", mux))
    }
    
    type database map[string]dollars
    
    func (db database) list(w http.ResponseWriter, req *http.Request) {
    	for item, price := range db {
    		fmt.Fprintf(w, "%s: %s\n", item, price)
    	}
    }
    
    func (db database) price(w http.ResponseWriter, req *http.Request) {
    	item := req.URL.Query().Get("item")
    	price, ok := db[item]
    	if !ok {
    		w.WriteHeader(http.StatusNotFound) // 404
    		fmt.Fprintf(w, "no such item: %q\n", item)
    		return
    	}
    	fmt.Fprintf(w, "%s\n", price)
    }
    
    • 让我们关注这两个注册到handlers上的调用。第一个db.list是一个方法值(§6.4),它是下面这个类型的值。

      func(w http.ResponseWriter, req *http.Request)
      
  • 也就是说db.list的调用会援引一个接收者是db的database.list方法。所以db.list是一个实现了handler类似行为的函数,但是因为它没有方法(理解:该方法没有它自己的方法),所以它不满足http.Handler接口并且不能直接传给mux.Handle。

    • 语句http.HandlerFunc(db.list)是一个转换而非一个函数调用,因为http.HandlerFunc是一个类型。它有如下的定义:

      net/http

      package http
      
      type HandlerFunc func(w ResponseWriter, r *Request)
      
      func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
      	f(w, r)
      }
      
  • HandlerFunc显示了在Go语言接口机制中一些不同寻常的特点。这是一个实现了接口http.Handler的方法的函数类型。ServeHTTP方法的行为是调用了它的函数本身。因此HandlerFunc是一个让函数值满足一个接口的适配器,这里函数和这个接口仅有的方法有相同的函数签名。实际上,这个技巧让一个单一的类型例如database以多种方式满足http.Handler接口:一种通过它的list方法,一种通过它的price方法等等。

    • 因为handler通过这种方式注册非常普遍,ServeMux有一个方便的HandleFunc方法,它帮我们简化handler注册代码成这样:

      gopl.io/ch7/http3a

      mux.HandleFunc("/list", db.list)
      mux.HandleFunc("/price", db.price)
      
  • 从上面的代码很容易看出应该怎么构建一个程序:由两个不同的web服务器监听不同的端口,并且定义不同的URL将它们指派到不同的handler。我们只要构建另外一个ServeMux并且再调用一次ListenAndServe(可能并行的)。但是在大多数程序中,一个web服务器就足够了。此外,在一个应用程序的多个文件中定义HTTP handler也是非常典型的,如果它们必须全部都显式地注册到这个应用的ServeMux实例上会比较麻烦。

  • 所以为了方便,net/http包提供了一个全局的ServeMux实例DefaultServerMux和包级别的http.Handle和http.HandleFunc函数。现在,为了使用DefaultServeMux作为服务器的主handler,我们不需要将它传给ListenAndServe函数;nil值就可以工作。

    • 然后服务器的主函数可以简化成:

      gopl.io/ch7/http4

      func main() {
      	db := database{"shoes": 50, "socks": 5}
      	http.HandleFunc("/list", db.list)
      	http.HandleFunc("/price", db.price)
      	log.Fatal(http.ListenAndServe("localhost:8000", nil))
      }
      
  • 最后,一个重要的提示:就像我们在1.7节中提到的,web服务器在一个新的协程中调用每一个handler,所以当handler获取其它协程或者这个handler本身的其它请求也可以访问到变量时,一定要使用预防措施,比如锁机制。我们后面的两章中将讲到并发相关的知识。

ch7.8 error接口

  • 从本书的开始,我们就已经创建和使用过神秘的预定义error类型,而且没有解释它究竟是什么。实际上它就是interface类型,这个类型有一个返回错误信息的单一方法:

    type error interface {
    	Error() string
    }
    
  • 创建一个error最简单的方法就是调用errors.New函数,它会根据传入的错误信息返回一个新的error。整个errors包仅只有4行:

    package errors
    
    func New(text string) error { return &errorString{text} }
    
    type errorString struct { text string }
    
    func (e *errorString) Error() string { return e.text }
    
  • 承载errorString的类型是一个结构体而非一个字符串,这是为了保护它表示的错误避免粗心(或有意)的更新。并且因为是指针类型*errorString满足error接口而非errorString类型,所以每个New函数的调用都分配了一个独特的和其他错误不相同的实例。我们也不想要重要的error例如io.EOF和一个刚好有相同错误消息的error比较后相等。

    fmt.Println(errors.New("EOF") == errors.New("EOF")) // "false"
    
  • 调用errors.New函数是非常稀少的,因为有一个方便的封装函数fmt.Errorf,它还会处理字符串格式化。我们曾多次在第5章中用到它。

    package fmt
    
    import "errors"
    
    func Errorf(format string, args ...interface{}) error {
    	return errors.New(Sprintf(format, args...))
    }
    
  • 虽然*errorString可能是最简单的错误类型,但远非只有它一个。例如,syscall包提供了Go语言底层系统调用API。在多个平台上,它定义一个实现error接口的数字类型Errno,并且在Unix平台上,Errno的Error方法会从一个字符串表中查找错误消息,如下面展示的这样:

    package syscall
    
    type Errno uintptr // operating system error code
    
    var errors = [...]string{
    	1:   "operation not permitted",   // EPERM
    	2:   "no such file or directory", // ENOENT
    	3:   "no such process",           // ESRCH
    	// ...
    }
    
    func (e Errno) Error() string {
    	if 0 <= int(e) && int(e) < len(errors) {
    		return errors[e]
    	}
    	return fmt.Sprintf("errno %d", e)
    }
    
    • 下面的语句创建了一个持有Errno值为2的接口值,表示POSIX ENOENT状况:

      var err error = syscall.Errno(2)
      fmt.Println(err.Error()) // "no such file or directory"
      fmt.Println(err)         // "no such file or directory"
      
  • Errno是一个系统调用错误的高效表示方式,它通过一个有限的集合进行描述,并且它满足标准的错误接口。我们会在第7.11节了解到其它满足这个接口的类型。

ch7.9 示例:表达式求值

  • 在本节中,我们会构建一个简单算术表达式的求值器。我们将使用一个接口Expr来表示Go语言中任意的表达式。现在这个接口不需要有方法,但是我们后面会为它增加一些。

    // An Expr is an arithmetic expression.
    type Expr interface{}
    
  • 我们的表达式语言由浮点数符号(小数点);二元操作符+,-,*, 和/;一元操作符-x和+x;调用pow(x,y),sin(x),和sqrt(x)的函数;例如x和pi的变量;当然也有括号和标准的优先级运算符。所有的值都是float64类型。这下面是一些表达式的例子:

    sqrt(A / pi)
    pow(x, 3) + pow(y, 3)
    (F - 32) * 5 / 9
    
  • 下面的五个具体类型表示了具体的表达式类型。

    // A Var identifies a variable, e.g., x.
    type Var string
    
    // A literal is a numeric constant, e.g., 3.141.
    type literal float64
    
    // A unary represents a unary operator expression, e.g., -x.
    type unary struct {
    	op rune // one of '+', '-'
    	x  Expr
    }
    
    // A binary represents a binary operator expression, e.g., x+y.
    type binary struct {
    	op   rune // one of '+', '-', '*', '/'
    	x, y Expr
    }
    
    // A call represents a function call expression, e.g., sin(x).
    type call struct {
    	fn   string // one of "pow", "sin", "sqrt"
    	args []Expr
    }
    
    • Var类型表示对一个变量的引用。

      • 我们很快会知道为什么它可以被输出
    • literal类型表示一个浮点型常量。

    • unary和binary类型表示有一到两个运算对象的运算符表达式,这些操作数可以是任意的Expr类型。

    • call类型表示对一个函数的调用;我们限制它的fn字段只能是pow,sin或者sqrt。

  • 为了计算一个包含变量的表达式,我们需要一个environment变量将变量的名字映射成对应的值:

    type Env map[Var]float64
    
  • 我们也需要每个表达式去定义一个Eval方法,这个方法会根据给定的environment变量返回表达式的值。因为每个表达式都必须提供这个方法,我们将它加入到Expr接口中。这个包只会对外公开Expr,Env,和Var类型。调用方不需要获取其它的表达式类型就可以使用这个求值器。

    type Expr interface {
    	// Eval returns the value of this Expr in the environment env.
    	Eval(env Env) float64
    }
    
  • 下面给大家展示一个具体的Eval方法。Var类型的这个方法对一个environment变量进行查找,如果这个变量没有在environment中定义过这个方法会返回一个零值,literal类型的这个方法简单的返回它真实的值。

    func (v Var) Eval(env Env) float64 {
    	return env[v]
    }
    
    func (l literal) Eval(_ Env) float64 {
    	return float64(l)
    }
    
  • unary和binary的Eval方法会递归的计算它的运算对象,然后将运算符op作用到它们上。我们不将被零或无穷数除作为一个错误,因为它们都会产生一个固定的结果——无限。最后,call的这个方法会计算对于pow,sin,或者sqrt函数的参数值,然后调用对应在math包中的函数。

    func (u unary) Eval(env Env) float64 {
    	switch u.op {
    	case '+':
    		return +u.x.Eval(env)
    	case '-':
    		return -u.x.Eval(env)
    	}
    	panic(fmt.Sprintf("unsupported unary operator: %q", u.op))
    }
    
    func (b binary) Eval(env Env) float64 {
    	switch b.op {
    	case '+':
    		return b.x.Eval(env) + b.y.Eval(env)
    	case '-':
    		return b.x.Eval(env) - b.y.Eval(env)
    	case '*':
    		return b.x.Eval(env) * b.y.Eval(env)
    	case '/':
    		return b.x.Eval(env) / b.y.Eval(env)
    	}
    	panic(fmt.Sprintf("unsupported binary operator: %q", b.op))
    }
    
    func (c call) Eval(env Env) float64 {
    	switch c.fn {
    	case "pow":
    		return math.Pow(c.args[0].Eval(env), c.args[1].Eval(env))
    	case "sin":
    		return math.Sin(c.args[0].Eval(env))
    	case "sqrt":
    		return math.Sqrt(c.args[0].Eval(env))
    	}
    	panic(fmt.Sprintf("unsupported function call: %s", c.fn))
    }
    
  • 一些方法会失败。例如,一个call表达式可能有未知的函数或者错误的参数个数。用一个无效的运算符如!或者<去构建一个unary或者binary表达式也是可能会发生的(尽管下面提到的Parse函数不会这样做)。这些错误会让Eval方法panic。其它的错误,像计算一个没有在environment变量中出现过的Var,只会让Eval方法返回一个错误的结果。所有的这些错误都可以通过在计算前检查Expr来发现。这是我们接下来要讲的Check方法的工作,但是让我们先测试Eval方法。

  • 下面的TestEval函数是对evaluator的一个测试。它使用了我们会在第11章讲解的testing包,但是现在知道调用t.Errof会报告一个错误就足够了。这个函数循环遍历一个表格中的输入,这个表格中定义了三个表达式和针对每个表达式不同的环境变量。第一个表达式根据给定圆的面积A计算它的半径,第二个表达式通过两个变量x和y计算两个立方体的体积之和,第三个表达式将华氏温度F转换成摄氏度。

    func TestEval(t *testing.T) {
    	tests := []struct {
    		expr string
    		env  Env
    		want string
    	}{
    		{"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
    		{"pow(x, 3) + pow(y, 3)", Env{"x": 12, "y": 1}, "1729"},
    		{"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"},
    		{"5 / 9 * (F - 32)", Env{"F": -40}, "-40"},
    		{"5 / 9 * (F - 32)", Env{"F": 32}, "0"},
    		{"5 / 9 * (F - 32)", Env{"F": 212}, "100"},
    	}
    	var prevExpr string
    	for _, test := range tests {
    		// Print expr only when it changes.
    		if test.expr != prevExpr {
    			fmt.Printf("\n%s\n", test.expr)
    			prevExpr = test.expr
    		}
    		expr, err := Parse(test.expr)
    		if err != nil {
    			t.Error(err) // parse error
    			continue
    		}
    		got := fmt.Sprintf("%.6g", expr.Eval(test.env))
    		fmt.Printf("\t%v => %s\n", test.env, got)
    		if got != test.want {
    			t.Errorf("%s.Eval() in %v = %q, want %q\n",
    			test.expr, test.env, got, test.want)
    		}
    	}
    }
    
  • 对于表格中的每一条记录,这个测试会解析它的表达式然后在环境变量中计算它,输出结果。这里我们没有空间来展示Parse函数,但是如果你使用go get下载这个包你就可以看到这个函数。

  • go test(§11.1) 命令会运行一个包的测试用例:

    $ go test -v gopl.io/ch7/eval
    
  • 这个-v标识可以让我们看到测试用例打印的输出;正常情况下像这样一个成功的测试用例会阻止打印结果的输出。这里是测试用例里fmt.Printf语句的输出:

    sqrt(A / pi)
        map[A:87616 pi:3.141592653589793] => 167
    
    pow(x, 3) + pow(y, 3)
        map[x:12 y:1] => 1729
        map[x:9 y:10] => 1729
    
    5 / 9 * (F - 32)
        map[F:-40] => -40
        map[F:32] => 0
        map[F:212] => 100
    
  • 幸运的是目前为止所有的输入都是适合的格式,但是我们的运气不可能一直都有。甚至在解释型语言中,为了静态错误检查语法是非常常见的;静态错误就是不用运行程序就可以检测出来的错误。通过将静态检查和动态的部分分开,我们可以快速的检查错误并且对于多次检查只执行一次而不是每次表达式计算的时候都进行检查。

  • 让我们往Expr接口中增加另一个方法。Check方法对一个表达式语义树检查出静态错误。我们马上会说明它的vars参数。

    type Expr interface {
    	Eval(env Env) float64
    	// Check reports errors in this Expr and adds its Vars to the set.
    	Check(vars map[Var]bool) error
    }
    
  • 具体的Check方法展示在下面。literal和Var类型的计算不可能失败,所以这些类型的Check方法会返回一个nil值。对于unary和binary的Check方法会首先检查操作符是否有效,然后递归的检查运算单元。相似地对于call的这个方法首先检查调用的函数是否已知并且有没有正确个数的参数,然后递归的检查每一个参数。

    func (v Var) Check(vars map[Var]bool) error {
    	vars[v] = true
    	return nil
    }
    
    func (literal) Check(vars map[Var]bool) error {
    	return nil
    }
    
    func (u unary) Check(vars map[Var]bool) error {
    	if !strings.ContainsRune("+-", u.op) {
    		return fmt.Errorf("unexpected unary op %q", u.op)
    	}
    	return u.x.Check(vars)
    }
    
    func (b binary) Check(vars map[Var]bool) error {
    	if !strings.ContainsRune("+-*/", b.op) {
    		return fmt.Errorf("unexpected binary op %q", b.op)
    	}
    	if err := b.x.Check(vars); err != nil {
    		return err
    	}
    	return b.y.Check(vars)
    }
    
    func (c call) Check(vars map[Var]bool) error {
    	arity, ok := numParams[c.fn]
    	if !ok {
    		return fmt.Errorf("unknown function %q", c.fn)
    	}
    	if len(c.args) != arity {
    		return fmt.Errorf("call to %s has %d args, want %d",
    			c.fn, len(c.args), arity)
    	}
    	for _, arg := range c.args {
    		if err := arg.Check(vars); err != nil {
    			return err
    		}
    	}
    	return nil
    }
    
    var numParams = map[string]int{"pow": 2, "sin": 1, "sqrt": 1}
    
  • 我们在两个组中有选择地列出有问题的输入和它们得出的错误。Parse函数(这里没有出现)会报出一个语法错误和Check函数会报出语义错误。

    x % 2               unexpected '%'
    math.Pi             unexpected '.'
    !true               unexpected '!'
    "hello"             unexpected '"'
    
    log(10)             unknown function "log"
    sqrt(1, 2)          call to sqrt has 2 args, want 1
    
  • Check方法的参数是一个Var类型的集合,这个集合聚集从表达式中找到的变量名。为了保证成功的计算,这些变量中的每一个都必须出现在环境变量中。从逻辑上讲,这个集合就是调用Check方法返回的结果,但是因为这个方法是递归调用的,所以对于Check方法,填充结果到一个作为参数传入的集合中会更加的方便。调用方在初始调用时必须提供一个空的集合。

  • 在第3.2节中,我们绘制了一个在编译期才确定的函数f(x,y)。现在我们可以解析,检查和计算在字符串中的表达式,我们可以构建一个在运行时从客户端接收表达式的web应用并且它会绘制这个函数的表示的曲面。我们可以使用集合vars来检查表达式是否是一个只有两个变量x和y的函数——实际上是3个,因为我们为了方便会提供半径大小r。并且我们会在计算前使用Check方法拒绝有格式问题的表达式,这样我们就不会在下面函数的40000个计算过程(100x100个栅格,每一个有4个角)重复这些检查。

  • 这个ParseAndCheck函数混合了解析和检查步骤的过程:

    gopl.io/ch7/surface

    import "gopl.io/ch7/eval"
    
    func parseAndCheck(s string) (eval.Expr, error) {
    	if s == "" {
    		return nil, fmt.Errorf("empty expression")
    	}
    	expr, err := eval.Parse(s)
    	if err != nil {
    		return nil, err
    	}
    	vars := make(map[eval.Var]bool)
    	if err := expr.Check(vars); err != nil {
    		return nil, err
    	}
    	for v := range vars {
    		if v != "x" && v != "y" && v != "r" {
    			return nil, fmt.Errorf("undefined variable: %s", v)
    		}
    	}
    	return expr, nil
    }
    
  • 为了编写这个web应用,所有我们需要做的就是下面这个plot函数,这个函数有和http.HandlerFunc相似的签名:

    func plot(w http.ResponseWriter, r *http.Request) {
    	r.ParseForm()
    	expr, err := parseAndCheck(r.Form.Get("expr"))
    	if err != nil {
    		http.Error(w, "bad expr: "+err.Error(), http.StatusBadRequest)
    		return
    	}
    	w.Header().Set("Content-Type", "image/svg+xml")
    	surface(w, func(x, y float64) float64 {
    		r := math.Hypot(x, y) // distance from (0,0)
    		return expr.Eval(eval.Env{"x": x, "y": y, "r": r})
    	})
    }
    
    • 这个plot函数解析和检查在HTTP请求中指定的表达式并且用它来创建一个两个变量的匿名函数。这个匿名函数和来自原来surface-plotting程序中的固定函数f有相同的签名,但是它计算一个用户提供的表达式。环境变量中定义了x,y和半径r。最后plot调用surface函数,它就是gopl.io/ch3/surface中的主要函数,修改后它可以接受plot中的函数和输出io.Writer作为参数,而不是使用固定的函数f和os.Stdout。图7.7中显示了通过程序产生的3个曲面。

ch7.10 类型断言

  • 类型断言是一个使用在接口值上的操作。语法上它看起来像x.(T)被称为断言类型,这里x表示一个接口的类型和T表示一个类型。一个类型断言检查它操作对象的动态类型是否和断言的类型匹配。

  • 这里有两种可能。

    • 第一种,如果断言的类型T是一个具体类型,然后类型断言检查x的动态类型是否和T相同。如果这个检查成功了,类型断言的结果是x的动态值,当然它的类型是T。换句话说,具体类型的类型断言从它的操作对象中获得具体的值。如果检查失败,接下来这个操作会抛出panic。例如:

      var w io.Writer
      w = os.Stdout
      f := w.(*os.File)      // success: f == os.Stdout
      c := w.(*bytes.Buffer) // panic: interface holds *os.File, not *bytes.Buffer
      
    • 第二种,如果相反地断言的类型T是一个接口类型,然后类型断言检查是否x的动态类型满足T。如果这个检查成功了,动态值没有获取到;这个结果仍然是一个有相同动态类型和值部分的接口值,但是结果为类型T。换句话说,对一个接口类型的类型断言改变了类型的表述方式,改变了可以获取的方法集合(通常更大),但是它保留了接口值内部的动态类型和值的部分。

  • 在下面的第一个类型断言后,w和rw都持有os.Stdout,因此它们都有一个动态类型*os.File,但是变量w是一个io.Writer类型,只对外公开了文件的Write方法,而rw变量还公开了它的Read方法。

    var w io.Writer
    w = os.Stdout
    rw := w.(io.ReadWriter) // success: *os.File has both Read and Write
    w = new(ByteCounter)
    rw = w.(io.ReadWriter) // panic: *ByteCounter has no Read method
    
  • 如果断言操作的对象是一个nil接口值,那么不论被断言的类型是什么这个类型断言都会失败。我们几乎不需要对一个更少限制性的接口类型(更少的方法集合)做断言,因为它表现的就像是赋值操作一样,除了对于nil接口值的情况。

    w = rw             // io.ReadWriter is assignable to io.Writer
    w = rw.(io.Writer) // fails only if rw == nil
    
  • 经常地,对一个接口值的动态类型我们是不确定的,并且我们更愿意去检验它是否是一些特定的类型。如果类型断言出现在一个预期有两个结果的赋值操作中,例如如下的定义,这个操作不会在失败的时候发生panic,但是替代地返回一个额外的第二个结果,这个结果是一个标识成功与否的布尔值:

    var w io.Writer = os.Stdout
    f, ok := w.(*os.File)      // success:  ok, f == os.Stdout
    b, ok := w.(*bytes.Buffer) // failure: !ok, b == nil
    
  • 第二个结果通常赋值给一个命名为ok的变量。如果这个操作失败了,那么ok就是false值,第一个结果等于被断言类型的零值,在这个例子中就是一个nil的*bytes.Buffer类型。

    • 这个ok结果经常立即用于决定程序下面做什么。if语句的扩展格式让这个变的很简洁:

      if f, ok := w.(*os.File); ok {
      	// ...use f...
      }
      
  • 当类型断言的操作对象是一个变量,你有时会看见原来的变量名重用而不是声明一个新的本地变量名,这个重用的变量原来的值会被覆盖,如下面这样:

    if w, ok := w.(*os.File); ok {
    	// ...use w...
    }
    
    • 其实是声明了一个同名的新的本地变量,外层原来的w不会被改变

ch7.11 基于类型断言识别错误类型

  • 思考在os包中文件操作返回的错误集合。I/O可以因为任何数量的原因失败,但是有三种经常的错误必须进行不同的处理:文件已经存在(对于创建操作),找不到文件(对于读取操作),和权限拒绝。

    • os包中提供了三个帮助函数来对给定的错误值表示的失败进行分类:

      package os
      
      func IsExist(err error) bool
      func IsNotExist(err error) bool
      func IsPermission(err error) bool
      
    • 对这些判断的一个缺乏经验的实现可能会去检查错误消息是否包含了特定的子字符串

      func IsNotExist(err error) bool {
      	// NOTE: not robust!
      	return strings.Contains(err.Error(), "file does not exist")
      }
      
  • 但是处理I/O错误的逻辑可能一个和另一个平台非常的不同,所以这种方案并不健壮,并且对相同的失败可能会报出各种不同的错误消息。在测试的过程中,通过检查错误消息的子字符串来保证特定的函数以期望的方式失败是非常有用的,但对于线上的代码是不够的。

  • 一个更可靠的方式是使用一个专门的类型来描述结构化的错误。os包中定义了一个PathError类型来描述在文件路径操作中涉及到的失败,像Open或者Delete操作;并且定义了一个叫LinkError的变体来描述涉及到两个文件路径的操作,像Symlink和Rename。这下面是os.PathError:

    package os
    
    // PathError records an error and the operation and file path that caused it.
    type PathError struct {
    	Op   string
    	Path string
    	Err  error
    }
    
    func (e *PathError) Error() string {
    	return e.Op + " " + e.Path + ": " + e.Err.Error()
    }
    
  • 大多数调用方都不知道PathError并且通过调用错误本身的Error方法来统一处理所有的错误。尽管PathError的Error方法简单地把这些字段连接起来生成错误消息,PathError的结构保护了内部的错误组件。调用方需要使用类型断言来检测错误的具体类型以便将一种失败和另一种区分开;具体的类型可以比字符串提供更多的细节。

    _, err := os.Open("/no/such/file")
    fmt.Println(err) // "open /no/such/file: No such file or directory"
    fmt.Printf("%[[v]]\n", err)
    // Output:
    // &os.PathError{Op:"open", Path:"/no/such/file", Err:0x2}
    
  • 这就是三个帮助函数是怎么工作的。例如下面展示的IsNotExist,它会报出是否一个错误和syscall.ENOENT(§7.8)或者和有名的错误os.ErrNotExist相等(可以在§5.4.2中找到io.EOF);或者是一个*PathError,它内部的错误是syscall.ENOENT和os.ErrNotExist其中之一。

    import (
    	"errors"
    	"syscall"
    )
    
    var ErrNotExist = errors.New("file does not exist")
    
    // IsNotExist returns a boolean indicating whether the error is known to
    // report that a file or directory does not exist. It is satisfied by
    // ErrNotExist as well as some syscall errors.
    func IsNotExist(err error) bool {
    	if pe, ok := err.(*PathError); ok {
    		err = pe.Err
    	}
    	return err == syscall.ENOENT || err == ErrNotExist
    }
    
    • 下面这里是它的实际使用:

      _, err := os.Open("/no/such/file")
      fmt.Println(os.IsNotExist(err)) // "true"
      
  • 如果错误消息结合成一个更大的字符串,当然PathError的结构就不再为人所知,例如通过一个对fmt.Errorf函数的调用。区别错误通常必须在失败操作后,错误传回调用者前进行。

ch7.12 通过类型断言查询接口

  • 下面这段逻辑和net/http包中web服务器负责写入HTTP头字段(例如:“Content-type:text/html”)的部分相似。io.Writer接口类型的变量w代表HTTP响应;写入它的字节最终被发送到某个人的web浏览器上。

    func writeHeader(w io.Writer, contentType string) error {
    	if _, err := w.Write([]byte("Content-Type: ")); err != nil {
    		return err
    	}
    	if _, err := w.Write([]byte(contentType)); err != nil {
    		return err
    	}
    	// ...
    }
    
  • 因为Write方法需要传入一个byte切片而我们希望写入的值是一个字符串,所以我们需要使用[]byte(…)进行转换。这个转换分配内存并且做一个拷贝,但是这个拷贝在转换后几乎立马就被丢弃掉。让我们假装这是一个web服务器的核心部分并且我们的性能分析表示这个内存分配使服务器的速度变慢。这里我们可以避免掉内存分配么?

  • 这个io.Writer接口告诉我们关于w持有的具体类型的唯一东西:就是可以向它写入字节切片。如果我们回顾net/http包中的内幕,我们知道在这个程序中的w变量持有的动态类型也有一个允许字符串高效写入的WriteString方法;这个方法会避免去分配一个临时的拷贝。(这可能像在黑夜中射击一样,但是许多满足io.Writer接口的重要类型同时也有WriteString方法,包括*bytes.Buffer*os.File*bufio.Writer。)

  • 我们不能对任意io.Writer类型的变量w,假设它也拥有WriteString方法。但是我们可以定义一个只有这个方法的新接口并且使用类型断言来检测是否w的动态类型满足这个新接口。

    // writeString writes s to w.
    // If w has a WriteString method, it is invoked instead of w.Write.
    func writeString(w io.Writer, s string) (n int, err error) {
    	type stringWriter interface {
    		WriteString(string) (n int, err error)
    	}
    	if sw, ok := w.(stringWriter); ok {
    		return sw.WriteString(s) // avoid a copy
    	}
    	return w.Write([]byte(s)) // allocate temporary copy
    }
    
    func writeHeader(w io.Writer, contentType string) error {
    	if _, err := writeString(w, "Content-Type: "); err != nil {
    		return err
    	}
    	if _, err := writeString(w, contentType); err != nil {
    		return err
    	}
    	// ...
    }
    
  • 为了避免重复定义,我们将这个检查移入到一个实用工具函数writeString中,但是它太有用了以致于标准库将它作为io.WriteString函数提供。这是向一个io.Writer接口写入字符串的推荐方法。

  • 这个例子的神奇之处在于,没有定义了WriteString方法的标准接口,也没有指定它是一个所需行为的标准接口。一个具体类型只会通过它的方法决定它是否满足stringWriter接口,而不是任何它和这个接口类型所表达的关系。它的意思就是上面的技术依赖于一个假设,这个假设就是:如果一个类型满足下面的这个接口,然后WriteString(s)方法就必须和Write([]byte(s))有相同的效果。

    interface {
    	io.Writer
    	WriteString(s string) (n int, err error)
    }
    
  • 尽管io.WriteString实施了这个假设,但是调用它的函数极少可能会去实施类似的假设。定义一个特定类型的方法隐式地获取了对特定行为的协约。对于Go语言的新手,特别是那些来自有强类型语言使用背景的新手,可能会发现它缺乏显式的意图令人感到混乱,但是在实战的过程中这几乎不是一个问题。除了空接口interface{},接口类型很少意外巧合地被实现。

  • 上面的writeString函数使用一个类型断言来获知一个普遍接口类型的值是否满足一个更加具体的接口类型;并且如果满足,它会使用这个更具体接口的行为。这个技术可以被很好的使用,不论这个被询问的接口是一个标准如io.ReadWriter,或者用户定义的如stringWriter接口。

  • 这也是fmt.Fprintf函数怎么从其它所有值中区分满足error或者fmt.Stringer接口的值。在fmt.Fprintf内部,有一个将单个操作对象转换成一个字符串的步骤,像下面这样:

    package fmt
    
    func formatOneValue(x interface{}) string {
    	if err, ok := x.(error); ok {
    		return err.Error()
    	}
    	if str, ok := x.(Stringer); ok {
    		return str.String()
    	}
    	// ...all other types...
    }
    
  • 如果x满足这两个接口类型中的一个,具体满足的接口决定对值的格式化方式。如果都不满足,默认的case或多或少会统一地使用反射来处理所有的其它类型;我们可以在第12章知道具体是怎么实现的。

  • 再一次的,它假设任何有String方法的类型都满足fmt.Stringer中约定的行为,这个行为会返回一个适合打印的字符串。

ch7.13 类型分支

  • 接口被以两种不同的方式使用。

    • 在第一个方式中,以io.Reader,io.Writer,fmt.Stringer,sort.Interface,http.Handler和error为典型,一个接口的方法表达了实现这个接口的具体类型间的相似性,但是隐藏了代码的细节和这些具体类型本身的操作。重点在于方法上,而不是具体的类型上。
    • 第二个方式是利用一个接口值可以持有各种具体类型值的能力,将这个接口认为是这些类型的联合。类型断言用来动态地区别这些类型,使得对每一种情况都不一样。在这个方式中,重点在于具体的类型满足这个接口,而不在于接口的方法(如果它确实有一些的话),并且没有任何的信息隐藏。我们将以这种方式使用的接口描述为discriminated unions(可辨识联合)。
  • 如果你熟悉面向对象编程,你可能会将这两种方式当作是subtype polymorphism(子类型多态)和 ad hoc polymorphism(非参数多态),但是你不需要去记住这些术语。对于本章剩下的部分,我们将会呈现一些第二种方式的例子。

  • 和其它那些语言一样,Go语言查询一个SQL数据库的API会干净地将查询中固定的部分和变化的部分分开。一个调用的例子可能看起来像这样:

    import "database/sql"
    
    func listTracks(db sql.DB, artist string, minYear, maxYear int) {
    	result, err := db.Exec(
    		"SELECT * FROM tracks WHERE artist = ? AND ? <= year AND year <= ?",
    		artist, minYear, maxYear)
    	// ...
    }
    
    • Exec方法使用SQL字面量替换在查询字符串中的每个’?’;SQL字面量表示相应参数的值,它有可能是一个布尔值,一个数字,一个字符串,或者nil空值。

    • 用这种方式构造查询可以帮助避免SQL注入攻击;这种攻击就是对方可以通过利用输入内容中不正确的引号来控制查询语句。在Exec函数内部,我们可能会找到像下面这样的一个函数,它会将每一个参数值转换成它的SQL字面量符号。

      func sqlQuote(x interface{}) string {
      	if x == nil {
      		return "NULL"
      	} else if _, ok := x.(int); ok {
      		return fmt.Sprintf("%d", x)
      	} else if _, ok := x.(uint); ok {
      		return fmt.Sprintf("%d", x)
      	} else if b, ok := x.(bool); ok {
      		if b {
      			return "TRUE"
      		}
      		return "FALSE"
      	} else if s, ok := x.(string); ok {
      		return sqlQuoteString(s) // (not shown)
      	} else {
      		panic(fmt.Sprintf("unexpected type %T: %v", x, x))
      	}
      }
      
  • switch语句可以简化if-else链,如果这个if-else链对一连串值做相等测试。一个相似的type switch(类型分支)可以简化类型断言的if-else链。

  • 在最简单的形式中,一个类型分支像普通的switch语句一样,它的运算对象是x.(type)——它使用了关键词字面量type——并且每个case有一到多个类型。一个类型分支基于这个接口值的动态类型使一个多路分支有效。这个nil的case和if x == nil匹配,并且这个default的case和如果其它case都不匹配的情况匹配。一个对sqlQuote的类型分支可能会有这些case:

    switch x.(type) {
    case nil:       // ...
    case int, uint: // ...
    case bool:      // ...
    case string:    // ...
    default:        // ...
    }
    
  • 和(§1.8)中的普通switch语句一样,每一个case会被顺序的进行考虑,并且当一个匹配找到时,这个case中的内容会被执行。当一个或多个case类型是接口时,case的顺序就会变得很重要,因为可能会有两个case同时匹配的情况。default case相对其它case的位置是无所谓的。它不会允许落空发生。

  • 注意到在原来的函数中,对于bool和string情况的逻辑需要通过类型断言访问提取的值。因为这个做法很典型,类型分支语句有一个扩展的形式,它可以将提取的值绑定到一个在每个case范围内都有效的新变量。

    switch x := x.(type) { /* ... */ }
    
  • 这里我们已经将新的变量也命名为x;和类型断言一样,重用变量名是很常见的。和一个switch语句相似地,一个类型分支隐式的创建了一个词法块,因此新变量x的定义不会和外面块中的x变量冲突。每一个case也会隐式的创建一个单独的词法块。

  • 使用类型分支的扩展形式来重写sqlQuote函数会让这个函数更加的清晰:

    func sqlQuote(x interface{}) string {
    	switch x := x.(type) {
    	case nil:
    		return "NULL"
    	case int, uint:
    		return fmt.Sprintf("%d", x) // x has type interface{} here.
    	case bool:
    		if x {
    			return "TRUE"
    		}
    		return "FALSE"
    	case string:
    		return sqlQuoteString(x) // (not shown)
    	default:
    		panic(fmt.Sprintf("unexpected type %T: %v", x, x))
    	}
    }
    
  • 在这个版本的函数中,在每个单一类型的case内部,变量x和这个case的类型相同。例如,变量x在bool的case中是bool类型和string的case中是string类型。在所有其它的情况中,变量x是switch运算对象的类型(接口);在这个例子中运算对象是一个interface{}。当多个case需要相同的操作时,比如int和uint的情况,类型分支可以很容易的合并这些情况。

  • 尽管sqlQuote接受一个任意类型的参数,但是这个函数只会在它的参数匹配类型分支中的一个case时运行到结束;其它情况的它会panic出“unexpected type”消息。虽然x的类型是interface{},但是我们把它认为是一个int,uint,bool,string,和nil值的discriminated union(可识别联合)

ch7.14 示例:基于标记的XML解码

  • 第4.5章节展示了如何使用encoding/json包中的Marshal和Unmarshal函数来将JSON文档转换成Go语言的数据结构。encoding/xml包提供了一个相似的API。当我们想构造一个文档树的表示时使用encoding/xml包会很方便,但是对于很多程序并不是必须的。encoding/xml包也提供了一个更低层的基于标记的API用于XML解码。在基于标记的样式中,解析器消费输入并产生一个标记流;四个主要的标记类型-StartElement,EndElement,CharData,和Comment-每一个都是encoding/xml包中的具体类型。每一个对(*xml.Decoder).Token的调用都返回一个标记。

  • 这里显示的是和这个API相关的部分:

    encoding/xml

    package xml
    
    type Name struct {
    	Local string // e.g., "Title" or "id"
    }
    
    type Attr struct { // e.g., name="value"
    	Name  Name
    	Value string
    }
    
    // A Token includes StartElement, EndElement, CharData,
    // and Comment, plus a few esoteric types (not shown).
    type Token interface{}
    type StartElement struct { // e.g., <name>
        Name Name
        Attr []Attr
    }
    type EndElement struct { Name Name } // e.g., </name>
    type CharData []byte                 // e.g., <p>CharData</p>
    type Comment []byte                  // e.g., <!-- Comment -->
    
    type Decoder struct{ /* ... */ }
    func NewDecoder(io.Reader) *Decoder
    func (*Decoder) Token() (Token, error) // returns next Token in sequence
    
  • 这个没有方法的Token接口也是一个可识别联合的例子。传统的接口如io.Reader的目的是隐藏满足它的具体类型的细节,这样就可以创造出新的实现:在这个实现中每个具体类型都被统一地对待。相反,满足可识别联合的具体类型的集合被设计为确定和暴露,而不是隐藏。可识别联合的类型几乎没有方法,操作它们的函数使用一个类型分支的case集合来进行表述,这个case集合中每一个case都有不同的逻辑。

  • 下面的xmlselect程序获取和打印在一个XML文档树中确定的元素下找到的文本。使用上面的API,它可以在输入上一次完成它的工作而从来不要实例化这个文档树。

    gopl.io/ch7/xmlselect

    // Xmlselect prints the text of selected elements of an XML document.
    package main
    
    import (
    	"encoding/xml"
    	"fmt"
    	"io"
    	"os"
    	"strings"
    )
    
    func main() {
    	dec := xml.NewDecoder(os.Stdin)
    	var stack []string // stack of element names
    	for {
    		tok, err := dec.Token()
    		if err == io.EOF {
    			break
    		} else if err != nil {
    			fmt.Fprintf(os.Stderr, "xmlselect: %v\n", err)
    			os.Exit(1)
    		}
    		switch tok := tok.(type) {
    		case xml.StartElement:
    			stack = append(stack, tok.Name.Local) // push
    		case xml.EndElement:
    			stack = stack[:len(stack)-1] // pop
    		case xml.CharData:
    			if containsAll(stack, os.Args[1:]) {
    				fmt.Printf("%s: %s\n", strings.Join(stack, " "), tok)
    			}
    		}
    	}
    }
    
    // containsAll reports whether x contains the elements of y, in order.
    func containsAll(x, y []string) bool {
    	for len(y) <= len(x) {
    		if len(y) == 0 {
    			return true
    		}
    		if x[0] == y[0] {
    			y = y[1:]
    		}
    		x = x[1:]
    	}
    	return false
    }
    
    • main函数中的循环每遇到一个StartElement时,它把这个元素的名称压到一个栈里,并且每次遇到EndElement时,它将名称从这个栈中推出。这个API保证了StartElement和EndElement的序列可以被完全的匹配,甚至在一个糟糕的文档格式中。注释会被忽略。当xmlselect遇到一个CharData时,只有当栈中有序地包含所有通过命令行参数传入的元素名称时,它才会输出相应的文本。

    • 下面的命令打印出任意出现在两层div元素下的h2元素的文本。它的输入是XML的说明文档,并且它自己就是XML文档格式的。

      $ go build gopl.io/ch1/fetch
      $ ./fetch http://www.w3.org/TR/2006/REC-xml11-20060816 |
          ./xmlselect div div h2
      html body div div h2: 1 Introduction
      html body div div h2: 2 Documents
      html body div div h2: 3 Logical Structures
      html body div div h2: 4 Physical Structures
      html body div div h2: 5 Conformance
      html body div div h2: 6 Notation
      html body div div h2: A References
      html body div div h2: B Definitions for Character Normalization
      ...
      

ch7.15 补充几点

  • 当设计一个新的包时,新手Go程序员总是先创建一套接口,然后再定义一些满足它们的具体类型。这种方式的结果就是有很多的接口,它们中的每一个仅只有一个实现。不要再这么做了。这种接口是不必要的抽象;它们也有一个运行时损耗。你可以使用导出机制(§6.6)来限制一个类型的方法或一个结构体的字段是否在包外可见。接口只有当有两个或两个以上的具体类型必须以相同的方式进行处理时才需要。
  • 当一个接口只被一个单一的具体类型实现时有一个例外,就是由于它的依赖,这个具体类型不能和这个接口存在在一个相同的包中。这种情况下,一个接口是解耦这两个包的一个好方式。
  • 因为在Go语言中只有当两个或更多的类型实现一个接口时才使用接口,它们必定会从任意特定的实现细节中抽象出来。结果就是有更少和更简单方法的更小的接口(经常和io.Writer或 fmt.Stringer一样只有一个)。当新的类型出现时,小的接口更容易满足。对于接口设计的一个好的标准就是 ask only for what you need(只考虑你需要的东西)
  • 我们完成了对方法和接口的学习过程。Go语言对面向对象风格的编程支持良好,但这并不意味着你只能使用这一风格。不是任何事物都需要被当做一个对象;独立的函数有它们自己的用处,未封装的数据类型也是这样。观察一下,在本书前五章的例子中像input.Scan这样的方法被调用不超过二十次,与之相反的是普遍调用的函数如fmt.Printf。
1ch0
Authors
Software Developer
Software Developer passionate about Go, Python, and Cloud Native technologies. Sharing my learning journey through technical blog posts.