8

聊聊在Go语言里使用继承的翻车经历

 4 years ago
source link: https://studygolang.com/articles/28096
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Go 不是面向对象的语言,但是使用组合、嵌套和接口可以支持代码的复用和多态。关于结构体嵌套:外层结构体类型通过匿名嵌套一个已命名的结构体类型后就可以获得匿名成员类型的所有导出成员,而且也获得了该类型导出的全部的方法。比如下面这个例子:

type ShapeInterface interface {
    GetName() string
}

type Shape struct {
    name string
}

func (s *Shape) GetName() string {
    return s.name
}

type Rectangle struct {
    Shape
    w, h float64
}

Shape 类型上定义了 GetName() 方法,而在矩形 Rectangle 的定义中匿名嵌套了 Shape 类型从而获得了成员 name 和成员方法 GetName() ,同时 RectangleShape 一样又都是 ShapeInterface 接口的实现。

我一开始以为这和面向对象的继承没有什么区别,把内部结构体看成是父类,通过嵌套一下结构体就能获得父类的方法,而且还能根据需要重写父类的方法,在实际项目编程中我也是这么用的。直到有一天......

由于我们这很多推广类促销类的需求很多,几乎每月两三次,每季度还有大型推广活动。产品经理也是绞尽脑汁想各种玩法来提高用户活跃和订单量。每次都是前面玩法不一样,但最后都是参与任务得积分啦、分享后抽奖啦。于是乎我就肩负起了设计通用化流程的任务。根据每次需求通用的部分设计了接口和基础的实现类型,同时预留了给子类实现的方法,应对每次不一样的前置条件,这不就是面向对象里经常干的事儿嘛。

为了好理解我们还是用上面那个 ShapeInterface 举例子。

type ShapeInterface interface {
    Area() float64
    GetName() string
    PrintArea()
}

// 标准形状,它的面积为0.0
type Shape struct {
    name string
}

func (s *Shape) Area() float64 {
    return 0.0
}

func (s *Shape) GetName() string {
    return s.name
}

func (s *Shape) PrintArea() {
    fmt.Printf("%s : Area %v\r\n", s.name, s.Area())
}

// 矩形 : 重新定义了Area方法
type Rectangle struct {
    Shape
    w, h float64
}

func (r *Rectangle) Area() float64 {
    return r.w * r.h
}

// 圆形  : 重新定义 Area 和PrintArea 方法
type Circle struct {
    Shape
    r float64
}

func (c *Circle) Area() float64 {
    return c.r * c.r * math.Pi
}

func (c *Circle) PrintArea() {
    fmt.Printf("%s : Area %v\r\n", c.GetName(), c.Area())
}

我们在 ShapeInterface 里增加了 Area()PrintArea() 方法,因为每种形状计算面积的公式不一样,基础实现类型 Shape 里的 Area 只是简单返回了 0.0 ,具体计算面积的任务交给组合 Shape 类型的 Rectange 类通过重写 Area() 方法实现, Rectange 通过组合获得了 ShapePrintArea() 方法就能打印出它自己的面积来。

到目前为止,这些还都是我的设想,规划完后自己感觉特兴奋,感觉自己已经掌握了组合(Composition)这种思想的精髓...... 按这个思路我就把整套流程都写完了,单元测试只测了每个子功能,前置条件太复杂加上我还管团队里的其他项目自己的时间不太富余,所以就交付给组里的伙伴们使用了让他们顺便帮我测试下整个流程,然后就现场翻车了......

我们把上面那个例子运行一下,为了能看出区别,又专门写了一个 Circle 类型并用这个类型重写了 Area()PrintArea()

func main() {

    s := Shape{name: "Shape"}
    c := Circle{Shape: Shape{name: "Circle"}, r: 10}
    r := Rectangle{Shape: Shape{name: "Rectangle"}, w: 5, h: 4}

    listshape := []c{&s, &c, &r}

    for _, si := range listshape {
        si.PrintArea() //!! 猜猜哪个Area()方法会被调用 !! 
    }

}

运行后的输出结果如下:

Shape : Area 0
Circle : Area 314.1592653589793
Rectangle : Area 0

看出问题来了不, Rectangle 通过组合 Shape 获得的 PrintArea() 方法并没有去调用 Rectangle 实现的 Area() 方法,而是去调用了 ShapeArea() 方法。 Circle 是因为自己重写了 PrintArea() 所以在方法里调用到了自身的 Area()

在项目里那个类似例子里 PrintArea() 比这里的复杂很多而且承载着标准化流程的职责,肯定是不能每组合一次自己去实现一遍 PrintArea() 方法啊,那叫什么设计,而且面子上也说不过去,对吧,好不容易炫一次技,可不能被打脸。

经过 Google 上一番搜索后找到了一些详细的解释,上面我们期待的那种行为叫做虚拟方法:期望 PrintArea() 会去调用重写的 Area() 。但是在Go语言里没有继承和虚拟方法, Shape.PrintArea() 的定义是调用 Shape.Area()Shape 不知道它是否被嵌入哪个结构中,因此它无法将方法调用“分派”给虚拟的运行时方法。

Go语言规范:选择器 里描述了计算 x.f 表达式(其中 f 可能是方法)以选择最后要调用的方法时遵循的确切规则。里面的关键点阐述是

  • 选择器f可以表示类型T的字段或方法f,或者可以引用T的嵌套匿名字段的字段或方法f。遍历到达f的匿名字段的数量称为其在T中的深度。
  • 对于类型T或* T的值x(其中T不是指针或接口类型),x.f表示存在f的T中最浅深度的字段或方法。

回到我们的例子中来就是:

对于 Rectangle 类型来说 si.PrintArea() 将调用 Shape.PrintArea() 因为没有为 Rectangle 类型定义 PrintArea() 方法(没有接受者是 *RectanglePrintArea() 方法),而 Shape.PrintArea() 方法的实现调用的是 Shape.Area() 而不是 Rectangle.Area() -如前面所讨论的, Shape 不知道 Rectangle 的存在。所以会看到输出结果:

Rectangle : Area 0

那么既然在 Go 里不支持继承,如何以组合解决类似的问题呢。我们可以通过定义参数为 ShapeInterface 接口的方法定义 PrintArea

func  PrintArea (s ShapeInterface){
    fmt.Printf("Interface => %s : Area %v\r\n", s.GetName(), s.Area())
}

因为并不像例子里的这么简单,后来我的解决方法是定义了一个类似 InitShape 的方法来完成初始化流程,这里我把 ShapeInterface 接口和 Shape 类型做一些调整会更好理解一些。

type ShapeInterface interface {
    Area() float64
    GetName() string
    SetArea(float64)
}
type Shape struct {
    name string
    area float64
}

...
func (s *Shape) SetArea(area float64) {
    s.area = area
}

func (s *Shape) PrintArea() {
    fmt.Printf("%s : Area %v\r\n", s.name, s.area)
}
...

func InitShape(s ShapeInterface) error {
  area, err := s.Area()
  if err != nil {
    return err
  }
  s.SetArea(area)
  ...
}

对于 RectangleCircle 这样的组合 Shape 的类型,只需要按照自己的计算面积的公式实现 Area()SetArea() 会把 Area() 计算出的面积存储在 area 字段供后面的程序使用。

type Rectangle struct {
    Shape
    w, h float64
}

func (r *Rectangle) Area() float64 {
    return r.w * r.h
}

r := &Rectangle {
    Shape: Shape{name: "Rectangle"},
    w: 5, 4
}

InitShape(r)
r.PrintArea()

这个案例也是我使用 Go 写代码以来第一次研究继承和组合的区别,以及怎么用组合的方式在 Go 语言里复用代码和提供多态的支持。我觉得很多之前用惯面向对象语言的朋友们或多或少都会遇到同样的问题,毕竟思维定式形成后要靠刻意练习才能打破。由于我不能透漏公司代码的设计,所以以这个简单的例子把这部分的使用经验记录下来分享给大家。读者朋友们在用 Go 语言设计接口和类型时如果遇到类似问题或者有其他疑问可以在文章下面留言,一起讨论。

JJBJJfi.png!web

欢迎关注我们的微信公众号,每天学习Go知识

FveQFjN.jpg!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK