浅谈用Go构建不可变的数据结构的方法

2020-01-28 14:07:25于丽

 me := Person{}.
   WithName("Elliot").
   WithFavoriteColors([]string{"black", "blue"})

 fmt.Printf("%+#vn", me)
 // main.Person{name:"Elliot", favoriteColors:[]string{"black", "blue"}}

处理切片

目前为止仍然不是完美的,因为对于最爱颜色我们返回的是切片。由于切片通过引用传递,我们来看看这么一个稍不留神就会忽略的 bug:


func updateFavoriteColors(p Person) Person {
  colors := p.FavoriteColors()
  colors[0] = "red"

  return p
}

func main() {
  me := Person{}.
    WithName("Elliot").
    WithFavoriteColors([]string{"black", "blue"})

  me2 := updateFavoriteColors(me)

  fmt.Printf("%+#vn", me)
  fmt.Printf("%+#vn", me2)
}

// main.Person{name:"Elliot", favoriteColors:[]string{"red", "blue"}}
// main.Person{name:"Elliot", favoriteColors:[]string{"red", "blue"}}

我们想要改变第一种颜色,但是连带地改变了 me 变量。因为在复杂应用程序中这不会导致代码无法运行,试图搜寻出这么个变化是相当烦人和耗时的。

解决方法之一是确保我们绝不通过索引赋值,而是永远都是分配一个新的切片:


func updateFavoriteColors(p Person) Person {
  return p.WithFavoriteColors(append([]string{"red"}, p.FavoriteColors()[1:]...))
}

// main.Person{name:"Elliot", favoriteColors:[]string{"black", "blue"}}
// main.Person{name:"Elliot", favoriteColors:[]string{"red", "blue"}}

在我看来,这有点拙而且容易出错。更好的方式是一开始就不返回切片。拓展我们的 getter 和 wither 来仅对元素操作(而不是整个切片):


func (p Person) NumFavoriteColors() int {
  return len(p.favoriteColors)
}

func (p Person) FavoriteColorAt(i int) string {
  return p.favoriteColors[i]
}

func (p Person) WithFavoriteColorAt(i int, favoriteColor string) Person {
  p.favoriteColors = append(p.favoriteColors[:i],
    append([]string{favoriteColor}, p.favoriteColors[i+1:]...)...)


  return p
}

译者注:上述代码是错误的,如果p.favoriteColors的容量大于i则会就地改变副本的favoriteColors,参见反例,稍作调整即可得到正确实现

现在我们就可以放心使用:


func updateFavoriteColors(p Person) Person {
  return p.WithFavoriteColorAt(0, "red")
}

想要了解更多切片的妙用参见这篇牛逼的wiki:https://github.com/golang/go/wiki/SliceTricks

构造函数

某些情况下,我们会假设结构体的默认值是合理的。但是,强烈建议总是创建构造函数,一旦将来需要改变默认值时,我们只需要改动一个地方:


func NewPerson() Person {
  return Person{}
}

你可以随心所欲地实例化 Person,但个人偏爱总是通过 setter 来执行状态变换从而保持代码一致性: