设计模式—六大原则—里氏代换原则

里氏代换原则(Liskow-Substitution-Principle)

定义:子类对象能够替换父类对象,而程序逻辑不变

​ 里氏替换原则是确保继承正确使用的方法(继承使用的要求条件)。
​ Liskov替代原理(LSP)指出, 子类型必须可以替代其基本类型。违反此原理时,为了检查对象的特定类型,它往往导致大量额外的条件逻辑散布在整个应用程序中。随着应用程序的增长,这些重复的,分散的代码成为了滋生错误的温床。

里氏替换原则有至少两种含义

  1. 里氏替换原则是针对继承而言的,如果继承是为了实现代码重用,也就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义
    。子类只能通过新添加方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的,逻辑和父类一致的方法,这时用子类对象将父类对象替换掉时,当然逻辑一致,相安无事。
  2. 如果继承的目的是为了多态,而多态的前提就是子类覆盖并重新定义父类的方法,为了符合LSP,我们应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法,当父类是抽象类时,父类就是不能实例化,所以也不存在可实例化的父类对象在程序里。也就不存在子类替换父类实例(根本不存在父类实例了)时逻辑不一致的可能。

​ 简而言之:就是尽量不要从可实例化的父类中继承,而是要使用基于抽象类(也可以是抽象方法。里氏转换原则要避免重写父类的非抽象方法,而多态的实现是通过重写抽象方法实现的,所以并不冲突)和接口的继承。基于抽象编程,而不是基于具体。这样也就可以实现:对扩展(基于抽象)是开放的,对变更(基于具体)是禁止的。里氏转换原则和多态是相辅相成的!

《墨子:小取》

《墨子:小取》:“白马,马也;乘白马,乘马也。骊马,马也;乘骊马,乘马也”。
​ 文中的骊马是黑的马。意思就是白马和黑马都是马,乘白马或者乘黑马就是乘马。在面向对象中我们可以这样理解,马是一个父类,白马和黑马都是马的子类,我们说乘马是没有问题的,那么我们把父类换成具体的子类,也就是乘白马和乘黑马也是没有问题的,这就是我们上边说的里氏替换原则。

《墨子:小取》:“娣,美人也,爱娣,非爱美人也”。
​ 娣是指妹妹,也就是说我的妹妹是美人,我爱我的妹妹(出于兄妹感情),但是不等于我爱美人。在面向对象里就是,美人是一个父类,妹妹是美人的一个子类。哥哥作为一个类有“喜爱()”方法,可以接受妹妹作为参量。那么这个“喜爱()”不能接受美人类的实例,这也就说明了反过来是不能成立的。

本文所涉及的信息

  1. 开闭原则
  2. 面向对象-多态
  3. 抽象类
  4. 契约优先

wikipedia

​ 可替代性处于原理编程的面向对象的说明的是,在一个计算机程序中,如果S是T的子类,那么对象 T可以被替换为S的对象(即类型T的对象可被取代的任何子类型S的对象),而不会更改程序的任何所需属性(正确性,执行的任务等)。更正式地讲,Liskov替换原理(LSP)是子类型关系的一种特殊定义,称为(强)行为子类型,最初由Barbara Liskov引入。在1987年会议的主题演讲中,主题为数据抽象与层次。这是一个语义,而不仅仅是语法关系,因为它打算以保证语义互操作性类型的层次结构,尤其是对象类型。芭芭拉·里斯科夫(Barbara Liskov)和珍妮特·温(Jeannette Wing)在1994年的一篇论文中简要地描述了这一原理。

原理

​ Liskov的行为子类型概念定义了对象的可替换性概念。也就是说,如果S是T的子类型,则可以将程序中类型T的对象替换为类型S的对象,而无需更改该程序的任何所需属性(例如正确性)。
行为子类型是比类型理论中定义的函数的典型子类型更强的概念,后者仅依赖于实参类型和返回类型的协方差。

​ Liskov的原理对已在较新的面向对象编程语言中采用的签名提出了一些标准要求(通常在类级别而不是类型上;有关区别,请参见名义与结构子类型):
• 子类型中方法参数的矛盾性。
• 子类型中返回类型的协方差。
• 子类型的方法不应抛出新的异常,除非这些异常本身是超类型的方法所抛出的异常的子类型

行为条件

​ 除了签名要求之外,该子类型还必须满足许多行为条件。这些术语在类似于按合同设计方法的术语中进行了详细说明,从而对合同如何与继承进行交互产生了一些限制:

  1. 前提条件不能在子类型中得到加强。
  2. 子条件不能弱化后置条件。
  3. 超类型的不变量必须保留在子类型中。
  4. 历史记录约束(“历史记录规则”)。对象只能通过其方法(封装)被视为可修改的。因为子类型可能会引入父类型中不存在的方法,所以这些方法的引入可能会导致子类型中状态不允许在父类型中发生变化。历史记录约束禁止这样做。这是Liskov和Wing引入的新颖元素。可以通过将可变点定义为不可变点的子类型来举例说明违反此约束的情况。这违反了历史记录约束,因为在不可变点的历史记录中,状态在创建后始终是相同的,因此它不能包含可变点的历史记录一般来说。但是,可以安全地修改添加到子类型的字段,因为无法通过超类型方法观察到它们。因此,可以在不违反LSP的前提下从不变点得出具有固定中心但半径可变的圆。

起源

​ 前置条件和后置条件的规则与Bertrand Meyer在1988年的《面向对象的软件构造》一书中引入的规则相同。Meyer和后来的Pierre America(第一个使用行为子类型的人)都给出了一些行为子类型概念的证明理论定义,但是它们的定义没有考虑支持引用或指针的编程语言中可能出现的别名。。考虑混叠是Liskov和Wing(1994)所做的重大改进,关键因素是历史约束。根据Meyer和America的定义,MutablePoint将是ImmutablePoint的行为子类型,而LSP禁止这样做。

案例

圆形椭圆问题(又称矩形正方形问题)

​ 在软件开发中的圆形椭圆问题(有时称为矩形正方形问题)说明了在对象建模中使用子类型多态性时可能出现的一些陷阱。
​ 使用面向对象的编程(OOP)时最常遇到这些问题。根据定义,此问题违反了SOLID原则之一的Liskov替代原则。

/**
 * 定义一个长方形类,只有标准的get和set方法
 */
public class Rectangle {
    protected long width;
    protected long height;

    public void setWidth(long width) {
        this.width = width;
    }

    public long getWidth() {
        return this.width;
    }

    public void setHeight(long height) {
        this.height = height;
    }

    public long getHeight() {
        return this.height;
    }
}

/**
 * 定义一个正方形类继承自长方形类,只有一个side
 */
public class Square extends Rectangle {
    public void setWidth(long width) {
        this.height = width;
        this.width = width;
    }

    public long getWidth() {
        return width;
    }

    public void setHeight(long height) {
        this.height = height;
        this.width = height;
    }

    public long getHeight() {
        return height;
    }
}
/**
 * 测试类
 */
public class Test
{
    /**
     * 长方形的长不短的增加直到超过宽
     */
    public void resize(Rectangle r)
    {
        while (r.getHeight() <= r.getWidth() )
        {
            r.setHeight(r.getHeight() + 1);
        }
    }
}

​ 在上边的代码中我们定义了一个长方形和一个继承自长方形的正方形,看着是非常符合逻辑的,但是当我们调用Test类中的resize方法时,长方形是可以的,但是正方形就会一直增大,一直long溢出。
​ 但是我们按照里氏替换原则,父类可以的地方,换成子类一定也可以,所以上边的这个例子是不符合里氏替换原则的。

案例

/**
 * 鸟
 */
class Bird{
    public static final int IS_OSTRICH = 1;//是鸵鸟
    public static final int IS_SPARROW = 2;//是麻雀 
    public int isType;
    public Bird(int isType) {
        this.isType = isType;
    }
}
/**
 * 鸵鸟
 */
class Ostrich extends Bird{
    public Ostrich() {
        super(Bird.IS_OSTRICH);
    }
    public void toBeiJing(){
        System.out.print("跑着去北京!");
    }
}

/**
 * 麻雀
 */
class Sparrow extends Bird{
    public Sparrow() {
        super(Bird.IS_SPARROW);
    }
    public void toBeiJing(){
        System.out.print("飞着去北京!");
    }
}


/**
 * 调用方
 */
public void birdLetGo(Bird bird) {
        if (bird.isType == Bird.IS_OSTRICH) {
            Ostrich ostrich = (Ostrich) bird;
            ostrich.toBeiJing();
        } else if (bird.isType == Bird.IS_SPARROW) {
            Sparrow sparrow = (Sparrow) bird;
            sparrow.toBeiJing();
        }
    }

birdLetGo方法明显的违反了开闭原则,它必须要知道所有Bird的子类。并且每次创建一个Bird子类就得修改它一次.

行为条件

//动物
public class Animal {
    private String food;
    public Animal(String food) {
        this.food = food;
    }
    public String getFood() {
        return food;
    }

}

//鸟
class Bird extends Animal{
    public Bird(String food) {
        super(food);
    }
}

//鸵鸟
class Ostrich extends Bird{
    public Ostrich() {
        super("草");
    }
}

//麻雀
class Sparrow extends Bird{
    public Sparrow() {
        super("虫子");
    }
}


class Zoo {
    /**
     * 吃早餐
     */
    public String eatBreakfast(Animal animal){
    
        //错误的写法为new 子类
        return animal.getFood();
    }
}

前置条件就是你要让我执行,就必须满足我的条件;
后置条件就是我执行完了需要反馈,标准是什么。

• 这里的满足前置条件就是调用方需满足能接受String这个食物类型
• 满足后置条件可以看做是参数和返回类型
• 前置条件不能更强,只能更弱,比如可以这样调用:
Object food = new Zoo().eatBreakfast(new Animal(“肉”));
后置条件可以更强,比如可以这样写:
String food = new Zoo().eatBreakfast(new Ostrich());

前置条件

说明
​ 为了方便说明情况,请忽略子类覆盖父类具体方法的情况,理论中是不允许子类覆盖父类中的方法的。

public class Father {      
     public Collection doSomething(HashMap map){
             System.out.println("父类被执行...");    
             return map.values();
     }
}
public class Son extends Father {
     //重载(Overload)父类方法
     //放大输入参数类型
     public Collection doSomething(Map map){
             System.out.println("子类被执行...");
             return map.values();
     }
}
public class Client {
     public static void invoker(){
             //父类存在的地方,子类就应该能够存在
             Father f = new Father();
             HashMap map = new HashMap();
             f.doSomething(map);
     }
     public static void main(String[] args) {
             invoker();
     }
}
代码运行后的结果:
父类被执行...

根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的粗体部分修改为子类.

public class Client {
     public static void invoker(){
             //父类存在的地方,子类就应该能够存在
             Son f =new Son();
             HashMap map = new HashMap();
             f.doSomething(map);
     }
     public static void main(String[] args) {
             invoker();
     }
}
代码运行后的结果:
父类被执行...

父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。
扩大父类的前置条件
public class Father {      
     public Collection doSomething(Map map){
             System.out.println("父类被执行...");    
             return map.values();
     }
}
public class Son extends Father {
     //重载(Overload)父类方法
     //缩小输入参数范围
     public Collection doSomething(HashMap map){
             System.out.println("子类被执行...");
             return map.values();
     }
}
public class Client {
     public static void invoker(){
             //父类存在的地方,子类就应该能够存在
             Father f = new Father();
             HashMap map = new HashMap();
             f.doSomething(map);
     }
     public static void main(String[] args) {
             invoker();
     }
}
代码运行后的结果:
父类被执行...

根据里氏替换原则,父类出现的地方子类就可以出现,我们把上面的粗体部分修改为子类.

public class Client {
     public static void invoker(){
             //父类存在的地方,子类就应该能够存在
             Son f =new Son();
             HashMap map = new HashMap();
             f.doSomething(map);
     }
     public static void main(String[] args) {
             invoker();
     }
}
代码运行后的结果:
子类被执行...

父类方法的输入参数是HashMap类型,子类的输入参数是Map类型,也就是说子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。

​ 子类在没有覆写父类的方法的前提下,子类方法被执行了,这会引起业务逻辑混乱,因为在实际应用中父类一般都是抽象类,子类是实现类,你传递一个这样的实现类就会“歪曲”了父类的意图,引起一堆意想不到的业务逻辑混乱,所以子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松(参数范围更大)。

后置条件

覆写或实现父类的方法时输出结果可以被缩小。
​ 父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S的范围必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类,为什么呢?

  1. 如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T,这是覆写的要求,这才是重中之重,子类覆写父类的方法,天经地义(抽象的)。
  2. 如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数,也就是说你写的这个方法是不会被调用的,参考上面讲的前置条件。

总结

  1. 如果LSP有效运用,程序会具有更多的可维护性、可重用性和健壮性
  2. LSP是使OCP成为可能的主要原则之一
  3. 正是因为子类的可替换性,才使得父类模块无须修改的情况就得以扩展

引用

你可能感兴趣的:(设计模式)