软件设计原则之里氏替换原则

举报
小钢炮 发表于 2023/07/07 02:43:39 2023/07/07
【摘要】 软件设计原则之里氏替换原则 什么是里氏替换原则?里氏替换原则(Liskov Substitution Principle,LSP)是面向对象设计中的一个重要原则,由麻省理工学院的计算机科学家Barbara Liskov提出(一位姓里的女士)。氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承。定义1: If S is a subtype of T, ...

软件设计原则之里氏替换原则


什么是里氏替换原则?

里氏替换原则(Liskov Substitution Principle,LSP)是面向对象设计中的一个重要原则,由麻省理工学院的计算机科学家Barbara Liskov提出(一位姓里的女士)。氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承。

定义1: If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。 如果S是T的子类,则T的对象可以替换为S的对象,而不会破坏程序。

定义2:Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。 所有引用其父类对象方法的地方,都可以透明的替换为其子类对象。

该原则指出,如果一个类型是某个抽象类型的子类型,那么在使用程序中,只要使用抽象类型的地方都可以替换成该子类型而不会产生错误或异常。换句话说,子类应该完全符合父类的行为规范,同时还可以扩展或修改其功能。

使用JavaScript语言实现里氏替换原则

在JavaScript中,实现里氏替换原则的关键在于遵循以下几点:

a) 子类必须保持父类的接口
子类应该实现与父类相同的方法和属性,确保在使用父类对象的地方可以使用子类对象。

b) 子类可以添加自己的特定行为
子类可以通过扩展父类的方法和属性,添加自己的特定行为,但不能修改父类已有的行为。

c) 子类不能引发父类中不存在的异常
子类的方法不能引发比父类方法更宽泛的异常,即子类方法抛出的异常类型应该与父类方法相同或更为具体。

d) 子类的前置条件(输入)不能强于父类
子类的方法在被调用时,对参数的要求应该不高于父类方法,即子类的前置条件(接收的输入)不能比父类更严格。

e) 子类的后置条件(输出)不能弱于父类
子类的方法在执行完毕后,应该满足与父类相同或更为强大的后置条件,即子类的输出结果应该符合父类的约定。

遵循上述原则,可以确保子类对象可以无缝地替换父类对象,而不会导致程序出现错误或异常。

通过重写父类的方法来完成新的功能写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。

如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。

关于里氏替换原则的例子,最有名的是“正方形不是长方形”(https://zhuanlan.zhihu.com/p/158386715)。当然,生活中也有很多类似的例子,例如,企鹅、鸵鸟和几维鸟从生物学的角度来划分,它们属于鸟类;但从类的继承关系来看,由于它们不能继承“鸟”会飞的功能,所以它们不能定义成“鸟”的子类。同样,由于“气球鱼”不会游泳,所以不能定义成“鱼”的子类;“玩具炮”炸不了敌人,所以不能定义成“炮”的子类等。

什么场景可以使用里氏替换原则?有什么好处?

里氏替换原则可以在以下场景中应用:

  • 继承关系:当存在继承关系时,子类应该能够替代父类,以实现代码的重用性和可扩展性。
  • 接口实现:当一个类实现某个接口时,它必须能够满足接口定义的契约,以便其他代码能够以接口类型调用该类的对象。
  • 多态性:通过父类定义通用的接口,子类实现具体的行为,可以在不修改原有代码的情况下扩展系统功能。

应用里氏替换原则的好处包括:

  • 代码重用:通过继承和多态性,可以更好地重用父类的代码。
  • 可扩展性:子类可以在不破坏原有代码功能和逻辑的情况下,增加新的行为和功能。
  • 稳定性和可靠性:使用里氏替换原则可以减少代码错误和异常,提高系统的稳定性和可靠性。
  • 维护性:符合里氏替换原则的代码结构更清晰、更易于维护和修改。提高代码的可维护性和可扩展性:通过使用里氏替换原则,代码的结构更清晰,更易于理解和修改。
  • 提高代码的可测试性:通过将子类对象替换为父类对象,可以更方便地进行单元测试和模块测试。

举一个例子来展示使用了里氏替换原则和不使用里氏替换原则之间的代码差异,以及优缺点分析。

假设我们有一个父类 Shape,它定义了一个抽象方法 calculateArea() 用于计算形状的面积。现在我们派生了两个子类 RectangleCircle,分别表示矩形和圆形。让我们比较一下使用和不使用里氏替换原则的代码示例:

使用里氏替换原则的代码示例:

class Shape {
  calculateArea() {
    throw new Error('This method must be overridden by the subclass');
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  calculateArea() {
    return Math.PI * this.radius * this.radius;
  }
}

function printArea(shape) {
  console.log('Area:', shape.calculateArea());
}

const rectangle = new Rectangle(5, 3);
const circle = new Circle(4);

printArea(rectangle);  // 输出:Area: 15
printArea(circle);     // 输出:Area: 50.26548245743669

在上述代码中,父类 Shape 定义了抽象方法 calculateArea(),而子类 RectangleCircle 分别实现了自己的计算面积的方法。通过传递不同的子类对象给 printArea() 函数,我们可以正常计算并打印出各种形状的面积。

使用里氏替换原则的优点是代码结构清晰、扩展性好,新增其他形状的子类只需要继承 Shape 并实现 calculateArea() 方法即可。

不使用里氏替换原则的代码示例:

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  calculateArea() {
    return this.width * this.height;
  }
}

class Circle {
  constructor(radius) {
    this.radius = radius;
  }

  calculateArea() {
    throw new Error('This method is not applicable for Circle');
  }
}

function printArea(shape) {
  console.log('Area:', shape.calculateArea());
}

const rectangle = new Rectangle(5, 3);
const circle = new Circle(4);

printArea(rectangle);  // 输出:Area: 15
printArea(circle);     // 抛出错误:This method is not applicable for Circle

在上述代码中,子类 Circle 继承了 Rectangle,但它的 calculateArea() 方法并没有实现,而是抛出了一个错误。这违反了里氏替换原则,因为无法将 Circle 对象无缝替换为 Rectangle 对象,导致程序在调用 calculateArea() 方法时出现错误。

不使用里氏替换原则的缺点是代码的可维护性和可扩展性较差。当我们新增其他形状的子类时,需要修改 printArea() 函数来处理不同的情况,代码变得不够灵活且容易出错。

不使用里氏替换原则的代码示例:

如果你设计的子类不能完全实现父类的抽象方法那么你的设计就不满足里式替换原则。

// 定义抽象类枪
public abstract class AbstractGun{
    // 射击
    public abstract void shoot();
    
    // 杀人
    public abstract void kill();
}

比如我们定义了一个抽象的枪类,可以射击和杀人。无论是步枪还是手枪都可以射击和杀人,我们可以定义子类来继承父类。

// 定义手枪,步枪,机枪
public class Handgun extends AbstractGun{   
    public void shoot(){  
         // 手枪射击
    }
    
    public void kill(){    
        // 手枪杀人
    }
}
public class Rifle extends AbstractGun{
    public void shoot(){
         // 步枪射击
    }
    
    public void kill(){    
         // 步枪杀人
    }
}

但是如果我们在这个继承体系内加入一个玩具枪,就会有问题了,因为玩具枪只能射击,不能杀人。但是很多人写代码经常会这么写。

public class ToyGun extends AbstractGun{
    public void shoot(){
        // 玩具枪射击
    }
    
    public void kill(){ 
        // 因为玩具枪不能杀人,就返回空,或者直接throw一个异常出去
        throw new Exception("我是个玩具枪,惊不惊喜,意不意外,刺不刺激?");
    }
}

这时,我们如果把使用父类对象的地方替换为子类对象,显然是会有问题的(士兵上战场结果发现自己拿的是个玩具)。

总结

面向对象的编程思想中提供了继承和多态是我们可以很好的实现代码的复用性和可扩展性,但继承并非没有缺点,因为继承的本身就是具有侵入性的,如果使用不当就会大大增加代码的耦合性,而降低代码的灵活性,增加我们的维护成本,然而在实际使用过程中却往往会出现滥用继承的现象,而里式替换原则可以很好的帮助我们在继承关系中进行父子类的设计。通过以上示例可以看出,使用里氏替换原则的代码更加灵活、可扩展和易于维护。它允许我们通过父类引用来操作不同的子类对象,而无需关心具体的子类类型。这种松耦合的设计使得代码更加健壮、可测试,并且能够适应未来需求的变化。

More…

  1. https://zhuanlan.zhihu.com/p/158386715
  2. https://cloud.tencent.com/developer/article/1831005
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。