阅读 936

【译】什么是SOLID原则(第2部分)

翻译自:What’s the deal with the SOLID principles? (part 2)

在文章的 第1部分,我们主要讨论了前两个 SOLID 原则,它们分别是单一职责原则和开闭原则。在这一部分,我们将按照首字母缩略词中的顺序来处理接下来的两个原则。让我们启程吧!

L

在 SOLID 原则中,最具神秘色彩的就是里氏替换原则(Liskov Substitution Principle,简称 LSP)了。此原则以 Barbara Liskov 的名字命名,他在 1987年 首次提出了这一原则。里氏替换原则要阐述的内容是:如果对象 A 是对象 B 的子类,或者对象 A 实现了接口 B(本质上讲,A 就是 B 的一个实例),那么我们应该能够在不做任何特殊处理的情况下,像使用一个对象 B 或者 B 的一个实例那样使用对象 A。

为了理清思路,让我们看一个关于多个自行车的示例。Bike 基类如下:

class Bike {
  void pedal() {
    // pedal code
  }
  
  void steer() {
    // steering code
  }
  void handBrakeFront() {
    // hand braking front code
  }
  void handBrakeBack() {
    // hand braking back code
  }
}
复制代码

山地自行车类 MountainBike 继承自基类 Bike (译者注:山地车有通过齿轮的机械原理调整档位的特性):

class MountainBike extends Bike {
  void changeGear() {
    // change gear code
  }
}
复制代码

MountainBike 类遵循了里氏替换原则,因为它能够被当作一个 Bike 类的对象使用。如果我们有一个自行车类型数组,并用 BikeMountainBike 的实例对象来填充它,那么我们完全可以正确无误地调用 steer()pedal()Bike 基类的所有方法。所以,我们可以在不经过特殊处理的情况下,把 MountainBike 类型的元素当作 Bike 类型的元素来使用。

现在想象一下,我们添加了一个名为 ClassicBike 的类,如下所示:

class ClassicBike extends Bike {
  void footBrake() {
    // foot braking code
  }
}
复制代码

这个类代表了一种经典自行车,你可以通过向后踩踏板来进行制动。这种自行车没有手刹。基于此,如果我们有一个 ClassicBike 类型的元素混在了上述的自行车数组中,我们仍然能够无误地调用 steerpedal 方法。但是,当我们尝试调用 handBrakeFront 或者 handBrakeBack 的时候,问题就暴露出来了。取决于具体的实现,调用这些方法可能导致系统崩溃或者什么也不会做。我们可以通过检查当前元素是否是 ClassicBike 的实例来解决这个问题:

foreach(var bike in bikes) {
  bike.pedal()
  bike.steer()
  
  if(bike is ClassicBike) {
    bike.footBrake()
  } else {
    bike.handBrakeFront()
    bike.handBrakeBack()
  }
}
复制代码

如你所见,假如没有类似上面的类型判断,我们就不能再把一个 ClassicBike 的实例看作一个 Bike 实例了。这显然违背了里氏替换原则。有多种方法可以解决这个问题,当我们讨论到 SOLID 中的 I 原则时,就会看到一个解决之道。遵循里氏替换原则的一个有趣的后果就是你编写的代码将很难不符合开闭原则。

【译者注】原文中,上图有个标题“There is no spoon”,这句话是电影《黑客帝国》的一句台词。

面向对象编程存在的一个问题就是我们常常忘记正在打交道的数据,以及处理这些数据和真实世界里对象的关系。现实生活中的有些事情无法在代码中直接建立模型,因此我们必须牢记:抽象本身并不神奇,底层的数据仅是数据而已(并不是一个真正的自行车)。

忽视里氏替换原则可能会让你遇到各种麻烦。拿 Donald (之前一篇文章 中的一个开发者) 来说,他写了一个 String 的子类,名叫 SuperSmartString 。这个子类做了所有的事情并覆写了父类 String 中的一些方法。他的这种编码方式显然违背了里氏替换原则。之后,他在他的代码中全都使用子类 SuperSmartString 的实例,而且还把这些实例视同 String 实例。不久,Donald 就注意到了一些“奇怪”、“神秘”的 bug 开始四处出现。当这些问题出现时,程序员就该开始抱怨之旅了,编程语言、编译器,编码平台、操作系统,甚至是市长和上帝都要跟着挨批评了。这些“神奇的” bug 其实可以通过遵守里氏替换原则来避免。就算不是为了代码质量,单单是为了程序员应该头脑清晰的职业属性,这个原则也应该受人尊敬。如果你的工作项目稍有复杂,那么只需少许的 SuperSmartStringsClassicBike 们就能让你工作不堪忍受。

【译者注】关于里氏替换原则的相关内容远不止上文提到的这些,比如覆写(override)与重载(overload)的区别、子类需要个性化时该怎么做等等都需要我们关注。

I

至此,我们还剩下两个原则。I 代表的是接口隔离原则(Interface Segregation Principle,简称 ISP)。这个很容易理解。它说的是我们应该保持接口短小,在实现时选择实现多个小接口而不是庞大的单个接口。我会再次使用自行车的例子,但是这次我用一个 Bike 接口而不是 Bike 基类:

interface Bike {
  void pedal()
  void steer()
  void handBrakeFront()
  void handBrakeBack()
}
复制代码

MountainBike 类必须实现这个接口的所有方法:

class MountainBike implements Bike {
  override void pedal() {
    // pedal implementation
  }
  
  override void steer() {
    // steer implementation
  }
  
  override void handBrakeFront() {
    // front hand brake implementation
  }
  
  override void handBrakeBack() {
    // back hand brake implementation
  }
  
  void changeGear() {
    // change gear code
  }
}
复制代码

目前尚好。对于有问题的带有脚刹功能的 ClassicBike 类,我们可以采用下面这种笨拙的实现:

class ClassicBike implements Bike {
  override pedal() {
    // pedal implementation
  }
  
  override steer() {
    // steer implementation
  }
  
  override handBrakeFront() {
    // no code or throw an exception
  }
  
  override handBrakeBack() {
    // no code or throw an exception
  }
  
  void brake() {
    // foot brake code
  }
}
复制代码

在这个例子中,我们不得不重写手刹的两个方法,尽管不需要它们。正如前所述,我们打破了里氏替换原则。

比较好的一个做法就是重构这个接口:

interface Bike() {
  void pedal()
  void steer()
}

interface HandBrakeBike {
  void handBrakeFront()
  void handBrakeBack()
}

interface FootBrakeBike {
  void footBrake()
}
复制代码

MountainBike 类将实现 BikeHandBrakeBike 接口,如下所示:

class MountainBike implements Bike, HandBrakeBike {
  // same code as before
}
复制代码

ClassicBike 将实现 BikeFootBrakeBike ,如下所示:

class ClassicBike implements Bike, FootBrakeBike {
  override pedal() {
    // pedal implementation
  }
  override steer() {
    // steer implementation
  }
  
  override footBrake() {
    // code that handles foot braking
  }
}
复制代码

接口隔离原则的优势之一就是我们可以在多个对象上组合匹配接口,这提高了我们代码的灵活性和模块化。

我们也可以有一个 MultipleGearsBike 接口,在它里面添加 changeGear() 方法。现在,我们就可以构建一个拥有脚刹和换挡功能的自行车了。

此外,我们的类现在也遵循了里氏替换原则,ClassicBikeMountainBike 都能够看作 Bike 而毫无问题了。如前所述,遵循里氏替换原则也有助于开闭原则的实现。

……

如果你还没看过第 1 部分的内容,可以在 这里 查看。在 第3部分 我们将探讨最后一个 SOLID 原则。如果你喜欢这篇文章,你可以在我们的 官方站点 上找到更多信息。