代码改变世界

《设计模式》杂记之里氏替换原则

2011-04-19 13:03  王祖康  阅读(1124)  评论(0编辑  收藏  举报

在这篇博文中,我想把自己学习过的里氏替换原则一些好知识点分享给大家。首先我想把继承的一下优缺点给大家分享一下,然后再引出里氏替换原则吧!

我们都知道在面向对象的语言中,继承是必不可少的,那么它的优点是哪些呢?引用书上一段话吧!

(1)       代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;

(2)       提高了代码的重用性;

(3)       子类可以形似父类,但有异于父类;

(4)       提高了代码的可扩展性;

(5)       提高产品或项目的开放性。

既然有了优点就应该有缺点:

(1)       继承是侵入性的。只有继承,就必须拥有父类的所有属性和方法;

(2)       降低代码的灵活性。子类必须拥有父类的属性和方法,让子类受到了许多的约束;

(3)       增强了耦合性。当父类的常量,变量和方法被修改时,必须要考虑子类的修改,更糟糕的结果就是大片的代码需要重构。

C#与C++不同,不支持多重继承(即一个类从多个直接基类派生)。那么这里就引出了里氏替换原则。

里氏替换原则通俗的讲就是:只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常,我们根本不需要知道是父类还是子类。

但是这里我们需要注意的是:有子类出现的地方,父类未必就能适应。

前面我们引出了里氏替换原则的概念,通过这些概念我们可以了解到里氏替换原则为良好的继承定义了一个规范。这里就包含了4层含义:

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

《设计模式之禅》这本书上的例子,我个人感觉比较有意思,所以自己就直接把书上的例子分享给大家学习吧!呵呵~~

 书上引用的是CS打枪游戏例子,这里把枪的类图列出:

        

通过这个类图我们可以知道,枪得主要职责是射击,如何设计在各个具体的子类中去定义。在Soldier中定义了一个方法killEnemy,使用枪来杀敌,具体使用什么枪来杀敌人,只有我们在调用时才知道啊!

下面是AbstractGun类的源程序代码:

abstract class AbstractGun

    {

        //射击

        public abstract void shoot();

    }

短枪,步枪,机枪的实现类源代码:

class Handgun:AbstractGun

    {

        public override void shoot()

        {

            Console.WriteLine("短枪射击");

        }

    }

class Rifle:AbstractGun

    {

        public override void shoot()

        {

            Console.WriteLine("步枪射击");

        }

    }

class MachineGun:AbstractGun

    {

        public override void shoot()

        {

            Console.WriteLine("机枪扫射");

        }

    }

下面我们来定义一个士兵来使用这些枪吧!代码如下:

class Program

    {

        static void Main(string[] args)

        {

            //产生个士兵

            Soldier soldier = new Soldier();

            //给士兵一支枪

            soldier.setGun(new Handgun());        

            soldier.killEnemy();

            System.Threading.Thread.Sleep(5000);

        }

    }

在这个程序中,在编写程序时Solider士兵类根本不用知道是哪个型号的枪(子类)被传入。

注意:在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。

可能有人会说,我们如果使用仿真枪呢?大家可以想象仿真枪是不能用来射击杀死人的,所以说我们就不能写在shoot方法中。下面是ToyGun的源代码:

class ToyGun:AbstractGun

    {

        //仿真枪是不能射击的只能虚构一个了。

        public override void shoot()

        {

            //仿真枪不能杀人,所以这个方法不能实现。

        }

    }

因为我们引入了新的子类,在main方法中源代码如下:

class Program

    {

        static void Main(string[] args)

        {

            //产生一个士兵

            Solder soldier = new Solder();

            soldier.setGun(new ToyGun());

            soldier.killEnemy();

            System.Threading.Thread.Sleep(5000);

        }

    }

在这种情况下,我们发现业务调用类已经出现问题了,正常的业务逻辑不能运行。作者给我们提供了两种解决办法:

1)              在Soldier类中增加判断,如果是仿真枪,就不用来杀人。这个方法可以解决问题,但是在程序中,我们每增加一个类,所有与这个父类有关系的类都必须修改,这样就不可行了。所以这个方案被否定了。

2)              ToyGun脱离继承,建立一个独立的父类,可以与AbstractGun建立关联委托关系。类图如下:

 

         大家都知道C#的三大特性:继承,封装,多态。继承就是告诉我们拥有父类的方法和属性,然后我们就可以重写父类的方法。那么按照继承原则,我们定义的仿真枪继承AbstractGun是没有问题的。但是我们在具体应用场景中就要考虑这个问题:子类是否能够完整地实现父类的业务。

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

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

由于里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。这次我们以步枪举例子吧!步枪也分为冲锋枪和狙击枪,如AK47,AUG狙击枪等等,Rifle子类图如下:

 

AUG狙击枪源代码如下:

class AUG:Rifle

    {

        public void zoomout()

        {

            Console.WriteLine("通过望远镜观看敌人?");

        }

 

        public override void shoot()

        {

            Console.WriteLine("AUG射击"); ;

        }

    }

狙击手的源代码如下:

class Sniper

    {

        private AUG aug;

 

        public void setGun(AUG _aug)

        {

            this.aug = _aug;

        }

 

        public void killEnemy()

        {

            //首先用望远镜观察

            aug.zoomout();

            //开始射击

            aug.shoot();

        }

    }

那么如何让狙击手使用AUG杀死人呢?

static void Main(string[] args)

        {

            Sniper sniper = new Sniper();

            sniper.setGun(new AUG());

            sniper.killEnemy();

            System.Threading.Thread.Sleep(5000);

        }

在这里,系统直接调用了子类。如果我们这个时候使用父类传递进来呢?代码如下:

static void Main(string[] args)

        {

            Sniper sniper = new Sniper();

            sniper.setGun((AUG)(new Rifle()));

            sniper.killEnemy();

            System.Threading.Thread.Sleep(5000);

        }

 在运行时抛出InvalidCastException异常,从里氏替换原则来看,有子类出现的地方父类未必就可以出现。

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

我们可能都知道方法中的输入参数称为前置条件,在里氏替换原则中也有一个契约,就是父类或接口。契约制定了,也就同时制定了前置条件和后置条件,前置条件就是你要让我执行,就必须满足我的条件;后置条件就是我执行完了需要反馈,标准是什么(大家可能对作者的这句话不很理解,其实我和大家刚看的时候都一样不理解啊!)。

那么我们还是通过作者的例子来理解理解吧!呵呵~~(这里我直接用作者的java版代码啦!)

Father类源代码:

public class Father {

   

    public Collection doSomething(HashMap map){

        System.out.println("父类被执行...");       

        return map.values();

       

    }

}

子类源代码:

public class Son extends Father {

 

    //放大输入参数类型

    public Collection doSomething(Map map){

        System.out.println("子类被执行...");

        return map.values();

    }

}

通过子类,我们可以发现子类重载了父类的方法。那么在场景类中调用代码如下:

public class Client {

    public static void invoker(){

        //父类存在的地方,子类就应该能够存在

        //Father f = new Father();

        Son f =new Son();

        HashMap map = new HashMap();

        f.doSomething(map);

    }

   

    public static void main(String[] args) {

       

        invoker();

    }

}

更具里氏替换原则,父类出现的地方子类就可以出现。

public class Client {

    public static void invoker(){

        //有父类的地方就有子类

        //Father f= new Father();

        Son f =new Son();

        HashMap map = new HashMap();

        f.doSomething(map);

    }

   

    public static void main(String[] args) {

        invoker();

    }

}

这两个的运行结果都是一样,通过代码我们了解到父类的方法输入参数是HashMap类型,子类的输入参数是Map类型,可以理解成子类的输入参数类型的范围扩大了,子类代替父类传递到调用者中,子类的方法永远都不会被执行。

如果我们想让子类的方法运行,就必须覆写父类的方法。

那么我们现在反过来想想,如果Father类的输入参数类型宽于子类的输入参数类型。会出现父类存在的地方,子类未必可以存在,因为一旦我们把子类作为参数传入,使用者就很可能进入子类的方法范畴。

所以子类中方法方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松

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

父类的一个方法的返回值是一个类型T,子类的相同方法(重载或覆写)的返回值为S,在里氏替换原则中要么S和T是同一个类型,要么S是T的子类。

如果是覆写,父类和子类的同名方法的输入参数是相同的,两个方法的范围值S小于等于T。

如果是重载,则要求方法的输入参数类型或数量不相同,在里氏替换原则要求下,就是子类的输入参数宽于或等于父类的输入参数。

 

引用作者的一段话:我们采用里氏替换原则目的是增强程序的健壮性,版本升级时也可以保持非常好的兼容性,即使增加子类,原有的子类还可以继续运行