设计模式 —— 里氏替换原则

前言

● 第一种定义,也是最正宗的定义:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2 时,程序P的行为没有发生变化,那么类型 S 是类型 T 的子类型。

● 第二种定义:所有引用基类的地方必须能透明地使用其子类的对象。

第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会 产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。

 


6大设计原则:

  • 单一职责原则
  • 里氏替换原则
  • 依赖倒置原则
  • 接口隔离原则
  • 迪米特法则
  • 开闭原则

继承相关:(在面向对象的语言中,继承是必不可少的)

继承的优点:
● 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
● 提高代码的重用性;
● 子类可以形似父类,但又异于父类;
● 提高代码的可扩展性,很多开源框架的扩展接口都是通过继承父类来完成的;
● 提高产品或项目的开放性。

继承的缺点:
● 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
● 降低代码的灵活性。子类必须拥有父类的属性和方法;
● 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果—— 大段的代码需要重构。

 C++采用了多重继承的规则,一个子类可以继承多个父类。 Java 则使用 extends 关键字来实现继承, 它采用了单一继承的规则。从整体上来看,利大于弊,怎么才能让“ 利”的因素发挥最大的作用,同时减少“ 弊”带来的麻烦呢? 解决方案是引入里氏替换原则( Liskov Substitution Principle, LSP)。


里氏替换原则为继承定义的规范:

1. 子类必须完全实现父类的方法

2. 子类可以有自己的个性

3. 覆盖或实现父类的方法时输入参数可以被放大

4. 覆写或实现父类的方法时输出结果可以被缩小

详细介绍:

1. 子类必须完全实现父类的方法

我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现, 调用类则直接传入接口或抽象类,其实这里已经使用了里氏替换原则。

示例一:
场景:CS,非常经典的 FPS 类游戏,我们来描述一下里面用到的枪,类图 如图 2- 1 所示。

 

分析:枪的主要职责是射击,如何射击在各个具体的子类中定义,手枪是单发射程比较近,步枪威力大射程远,机枪用于扫射。在士兵类中定义了一个方法 killEnemy,使用枪来杀敌人,具体使用什么枪来杀敌 人,调用的时候才知道, AbstractGun 类的源程序,如代码 清单 2- 1 所示。
(注:这里的c++代码使用了c++的多态,与原文献中java的使用有点不同)

//枪支的抽象类
class AbstractGun 
{
public:
    virtual void    shoot(){} 
};

//手枪、步枪、机枪的实现类
class Handgun:public AbstractGun //手枪
{
public:
    virtual void    shoot(){cout << "hand gun shoot";}
};

class Rifle:public AbstractGun  //步枪
{
public:
    virtual void    shoot(){cout << "rifle shoot";}
};

class MachineGun:public AbstractGun //机枪
{
public:
    virtual void    shoot(){cout << "machine gun shoot";}
};

//士兵的实现类
class Soldier //士兵
{
public:
    void    setGun(AbstractGun &gun){this->m_gun = &gun;}
    void    killEnemy()
    {
        cout << "soldier is killing";
        this->m_gun->shoot();
    }
private:
    AbstractGun *m_gun;
};


int main ()
{
    Soldier sanMao;
    Rifle   rifle;
    //通过这里传入的参数来修改三毛这个士兵使用的枪
    sanMao.setGun(rifle);
    sanMao.killEnemy();
    
    
    return 0;
}

示例二:
场景:如果有一个玩具手枪,应该如何定义,在之前的类图上增加一个类ToyGun,然后继承与AbstractGun类,修改后的类图2-2

①修改前方案:
分析:玩具枪不能用来射击的,杀不死人,这个不应该写在shoot方法中,但是编译器又要求实现这个方法,那么就虚构一个,于是玩具枪的代码:

class ToyGun:public AbstractGun
{
public:
    virtual void    shoot(){}//不做实现
};

加入到main中,那么在使用的时候,当我们把玩具枪传递给三毛用来杀敌,就会出现问题

有两种解决办法:
● 在 Soldier 类中增加 instanceof 的判断, 如果是玩具枪,就不用来杀敌人。 这个方法可以解决问题, 但是你要知道,在程序中,每增加一个类,所有与这个父类有关系的类都必须修改,你觉得可行吗?如果 你的产品出现了这个问题,因为修正了这样一个Bug,就要求所有与这个父类有关系的类都增加一个判断,显然,这个方案被否定了。
● ToyGun 脱离继承,建立一个独立的父类, 为了实现代码复用,可以与 AbastractGun 建立关联委托关系, 如图 2- 3 所示。


例如,可以在AbstractToy 中声明将声音、形状都委托给 AbstractGun 处理, 仿真枪嘛, 形状和声音都要和真实的枪一样了, 然后两个基类下的子类自由延展,互不影响。 C++与 Java 共有的三大特征,封装、继承、多态。继承就是告诉你拥有父 类的方法和属性,然后你就可以重写父类的方法。按照继承原则, 我们上面的玩具枪继承 AbstractGun 是绝对没有问题的, 玩具枪也是枪嘛,但是在具体应用场景中就要 考虑下面这个问题 了:子类是否能够完整地实现父类的业务,否则就会出现像上面的拿枪杀敌人时却发现 是把玩具枪的笑话。

(注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“ 畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。)

 2. 子类可以有自己的个性

子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则 可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。

示例:
以刚才的关于枪支的例子为例,步枪有几个比较“ 响亮”的型号,比如 AK47、AUG 狙击步枪等,把这 两个型号的枪引入后的 Rifle 子类 图 如图 2- 4 所示。

分析:AUG 继承了 Rifle 类,狙击手(Snipper)则直接使用AUG狙击步枪,源代码如代码清单2- 7所示。

//AUG狙击枪
class AUG:public Rifle
{
public:
    void    zoonOut(){cout << "通过望远镜观察";}
    virtual void shoot(){cout << "AUG shoot";}
};

//狙击手
class Snipper:public Soldier
{
public:
    void    killEnemy(AUG aug)
    {
        aug.zoonOut();
        aug.shoot();
    }
};

int main()
{
    Snipper sanMao;
    AUG     aug;
    sanMao.setGun(aug);
    sanMao.killEnemy(aug);
    
    return 0;
}

 

 

分析:在这里,系统直接调用了子类,狙击手是很依赖枪支的,别说换一个型号的枪了,就是换一个同型 号的枪也会影响射击,所以这里就直接把子类传递了进来。这个时候,我们能不能直接使用父类传递进来 呢?修改一下Client 类,如代码清单 2- 10 所示。

int main()
{
    Snipper sanMao;
    AbstractGun *aug = new AUG();

    sanMao.setGun(*aug);
    sanMao.killEnemy(*aug);
    
    return 0;
}

 

显示 是 不行 的,
① c++ 在编译时会报错
error: C2664: 'void Snipper::killEnemy(AUG)' : cannot convert argument 1 from 'AbstractGun' to 'AUG' ,No user-defined-conversion operator available that can perform this conversion, or the operator cannot be called

② java:会在 运行 期 抛出 java. lang. ClassCastException 异常

这也是大家经常说的向下转型(downcast)是不安全的,从里氏替换原则来看,就是有子类出现的地方 父类未必就可以出现。

 

3. 覆盖或实现父类的方法时输入参数可以被放大

方法中的输入参数称为前置条件,这是什么意思呢? 里氏替换原则要求制定一个契约,就是父类或接口, 这种设计方法也叫做 Design by Contract( 契约设计)。契约制定了,也就同时制定了前置条件和后置 条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么。

场景1:当子类的输入参数类型的范围大于父类,子类代替父类传递到调用者中,子类的方法永远都不会被 执行。这是正确的,如果你想让子类的方法运行,就必须覆写父类的方法。大家可以这样想, 在一个 Invoker 类中关联了一个父类,调用了一个父类的方法,子类可以覆写这个方法,也可以重载这个方法, 前提是要扩大这个前置条件,就是输入参数的类型宽于父类的类型覆盖范围。

场景2:当父类的输入参数类型宽于子类的输入参数类型,会出现什么问题呢? 会出现父类存在的地方, 子类就未必可以存在,因为一旦把子类作为参数传入,调用者就很可能进入子类的方法范畴。所以子类中 方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。

 

4. 覆写或实现父类的方法时输出结果可以被缩小

解析:父类的一个方法的返回值是一个类型 T,子类的相同方法(重载或覆写) 的返回值为S, 那么里氏 替换原则就要求 S 必须小于等于 T,也就是说, 要么S 和 T 是同一个类型, 要么 S 是 T 的子类,为什么 呢? 分两种情况, 如果是覆写, 父类和子类的同名方法的输入参数是相同的, 两个方法的范围值 S 小于 等于 T, 这是覆写的要求, 这才是重中之重, 子类覆写父类的方法,天经地义。 如果是重载, 则要求 方法的输入参数类型或数量不相同。


总结:
采用里氏替换原则的目的就是增强程序的健壮性, 版本升级时也可以保持非常好的兼容性。 即使增加子类, 原有的子类还可以 继续运行。 在实际项目中,每个子类对应不同的业务含义,使用父类作为参数, 传递不同的子类完成不同的业务逻辑。

实践:
在项目中,采用里氏替换原则时, 尽量避免子类的“ 个性”, 一旦子类有“ 个性”, 这个子类和父类 之间的关系就很难调和了, 把子类当做父类使用, 子类的“ 个性” 被抹杀—— 委屈 了点; 把子类单独 作为一个业务来使用, 则会让代码间的耦合关系变得扑朔迷离—— 缺乏类替换的标准。

 


参考文献《秦小波. 设计模式之禅》(第2版) (华章原创精品) (Kindle 位置 308-310). 机械工业出版社

 

展开阅读全文

没有更多推荐了,返回首页