热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

SOLID设计原则之里氏替换原则

1.引入SOLID设计原则的之中的开闭原则(OpenClosedPrinciple,OCP)主要是基于抽象和多态实现的。而实现抽象和多态的关键机制之一

1. 引入

SOLID设计原则的之中的开闭原则(Open/Closed Principle, OCP)主要是基于抽象多态实现的。而实现抽象和多态的关键机制之一就是继承
如何设计继承体系才能使得抽象和多态正常的发挥作用,并且不违背开闭原则呢? 这是里氏替换原则(Liskov Substitution Principle, LSP)要解决的问题。


2. 定义

里氏替换原则的定义如下:


Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.


即: 方法(函数)中使用对基类对象引用的地方,必须可以替换成其子类对象,而方法并不知道发生了这一替换。

自己关于without knowing it的理解:


  • 一方面,表示编写该方法时应该面向超类/接口编程,并不应该面向实现编程,参考下一小节的例①。
  • 另一方面,在替换之后,应该确保方法的功能/运行结果符合我们自己约定的预期

里氏替换原则并没有要求子类不能重写父类方法,有些博客中这样说应该是错误的,参考:
https://stackoverflow.com/questions/1735137/liskov-substitution-principle-no-overriding-virtual-methods


上面的定义有些难以理解,下面结合例子进一步说明:


3. 实例


① 违反LSP的例子1

违反里氏替换原则的一个典型的例子就是试图在运行时期判断对象的实际类型,例如下面的代码中App类的drawShape()方法:

class Point {private double x;private double y;
}class Shape { }class Circle extends Shape {private Point center; // 圆心private double radius; // 半径public Circle(Point center, double radius) {this.center = center;this.radius = radius;}public void draw() {// draw circle}
}class Square extends Shape {private Point topLeft; // 左上角点private double sideLen; // 边长public Square(Point topLeft, double sideLen) {this.topLeft = topLeft;this.sideLen = sideLen;}public void draw() {// draw square}
}public class App {public void drawShape(Shape shape) {if (shape instanceof Circle)((Circle) shape).draw();else if (shape instanceof Square)((Square) shape).draw();}
}

你或许会觉得,drawShape()方法中的shape用子类CircleSquare的对象替换不是完全可以吗,程序还是正常运行? 为什么违背里氏替换原则了?

注意前面提到定义中的without knowing it,drawShape()的参数替换为子类CircleSquare的对象后确实能够正常工作,但是它是建立在了解子类的基础之上的,也就是我们提前知道了drawShape()会接受子类对象, 显然违背了里氏替换原则。

进一步理解,里氏替换原则的目的是为了规范继承体系,使得多态能够正常工作,不违背开闭原则。而上面的代码显然违背了开闭原则,因为每增加一个新的Shape的子类,就要修改drawShape()的代码,增加一个新的else if语句来判断新增的类型。

下面是对上面代码的改进,使之符合里氏替换原则:

class Point {private double x;private double y;
}abstract class Shape {public abstract void draw();
}class Circle extends Shape {private Point center; // 圆心private double radius; // 半径public Circle(Point center, double radius) {this.center = center;this.radius = radius;}@Overridepublic void draw() {// draw circle}
}class Square extends Shape {private Point topLeft; // 左上角点private double sideLen; // 边长public Square(Point topLeft, double sideLen) {this.topLeft = topLeft;this.sideLen = sideLen;}@Overridepublic void draw() {// draw square}
}public class App {public void drawShape(Shape shape) {shape.draw();}
}

② 违反LSP的例子2

正方形是特殊的长方形,它们之间存在IS-A的关系。那么我们是不是可以让正方形继承长方形呢? 假设可以,看如下的代码:

class Rectangle {private int width;private int height;public int getWidth() {return width;}public void setWidth(int width) {this.width = width;}public int getHeight() {return height;}public void setHeight(int height) {this.height = height;}public int getArea() {return width * height;}
}class Square extends Rectangle {/*** 确保长和宽同时被设置, 避免违反正方形的定义,下同* @param width 宽度*/@Overridepublic void setWidth(int width) {super.setWidth(width);super.setHeight(width);}@Overridepublic void setHeight(int height) {super.setHeight(height);super.setWidth(height);}
}public class App {public void foo(Rectangle rectangle) {rectangle.setWidth(5);rectangle.setHeight(4);assert rectangle.getArea() == 20;}
}

App类的foo()方法中,我们基于长方形的性质(即我们自定义的预期): 面积 = 长 × 宽 断言了rectangle.getArea() == 20
而对foo()方法,如果传入一个Square类实例,断言就会报错。这显然违背了我们对foo()方法的预期。
因此在foo()方法中,子类Square的对象不能替换超类Rectangle的对象,说明这个继承关系违背了里氏替换原则


4. 契约设计和里氏替换原则

上面的第二个例子中,我们对foo()方法定义了一个预期(面积 = 长 × 宽 )。这个预期是我们脑子里的约定,对于上面的foo()方法我们也可以有别的预期。

那么有没有一种方法,能将我们的预期写到代码里面,从而约束程序员按照这个预期来编程,防止违背里氏替换原则呢? 当然有了,可以借助**契约设计(Design by Contract)**的方法来实现。

契约设计涉及以下几个术语:


  • 前置条件( precondition ): 一个方法要想执行,它的前置条件必须满足。 前置条件是指方法执行之前方法的参数、用到的变量/对象的状态所满足的特定约束。
  • 后置条件 (postcondition): 一个方法执行完毕,它的后置条件必须满足。 后置条件是指方法执行之后方法的返回值、用到的变量/对象的状态所满足的特定约束。
  • 不变条件 (invariant): 一般是指在public类型方法执行之前和之后都保持不变的条件,对于一个类的所有方法都是这个条件。

关于这三个属于,参考:

  • https://stackoverflow.com/questions/35303332/what-are-preconditions-and-postconditions
  • https://www.cs.cmu.edu/~ckaestne/15214/s2017/slides/20170131-design-for-reuse-1.pdf

我们可以在代码中指定precondition, postcondition, invariant来对代码进行约束。

用契约设计的方法来看,要使得设计遵循里氏替换原则应该满足:


  • 子类的前置条件不能强于父类的前置条件(体现在参数上是参数类型必须与父类一样或者是父类参数类型的超类,但是记住前置条件不仅仅包括参数)
  • 子类的后置条件不能弱于父类的后置条件 (体现在参数上是参数类型必须与父类一样或者是父类参数类型的子类,但是记住后置条件不仅仅包括参数)
  • 子类的不变条件不能弱于父类的不变条件

(个人理解)并且方法的实现不能和上面3个条件产生冲突


例子

例子中的require代表前置条件,ensures代表后置条件


例1.

下面满足上面三个条件(不变条件更严格,前置条件放宽,后置条件更严格),是符合里氏替换原则的设计

class Car extends Vehicle {int fuel;boolean engineOn;//@ invariant fuel >= 0;//@ requires fuel > 0 && !engineOn;
//@ ensures engineOn;void start() {}void accelerate() {}//@ requires speed != 0;
//@ ensures speed void brake() {}
}class Hybrid extends Car {int charge;
//@ invariant fuel >= 0 && charge >= 0;//@ requires (charge > 0 || fuel > 0) &&!engineOn;
//@ ensures engineOn;void start() {}void accelerate() {}//@ requires speed != 0;
//&#64; ensures speed <\old(speed)
//&#64; ensures charge > \old(charge)void brake() {}
}

例2.

下面例子中RectanglesetWidth()方法破坏了Square类的不变条件h &#61;&#61; w&#xff0c;因此不遵循里氏替换原则。
在这里插入图片描述


5. java编译器体现的里氏替换原则

Java编译器内置的一些规则遵循了里氏替换原则&#xff1a;


  • 子类可以增加方法&#xff0c;但不能删除方法
  • 子类必须实现抽象方法/接口中没有默认实现的方法
  • 重写方法必须返回相同类型或是子类型
  • 重写方法必须接受相同相同类型的参数
  • 重写方法不能抛父类没抛的异常

6.总结


  • 里氏替换原则和开闭原则的关系
  • 里氏替换原则主要是为了规范继承体系
  • 从契约设计的角度看里氏替换原则

推荐阅读
  • 微软头条实习生分享深度学习自学指南
    本文介绍了一位微软头条实习生自学深度学习的经验分享,包括学习资源推荐、重要基础知识的学习要点等。作者强调了学好Python和数学基础的重要性,并提供了一些建议。 ... [详细]
  • 如何用UE4制作2D游戏文档——计算篇
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了如何用UE4制作2D游戏文档——计算篇相关的知识,希望对你有一定的参考价值。 ... [详细]
  • Java String与StringBuffer的区别及其应用场景
    本文主要介绍了Java中String和StringBuffer的区别,String是不可变的,而StringBuffer是可变的。StringBuffer在进行字符串处理时不生成新的对象,内存使用上要优于String类。因此,在需要频繁对字符串进行修改的情况下,使用StringBuffer更加适合。同时,文章还介绍了String和StringBuffer的应用场景。 ... [详细]
  • 闭包一直是Java社区中争论不断的话题,很多语言都支持闭包这个语言特性,闭包定义了一个依赖于外部环境的自由变量的函数,这个函数能够访问外部环境的变量。本文以JavaScript的一个闭包为例,介绍了闭包的定义和特性。 ... [详细]
  • 本文介绍了在开发Android新闻App时,搭建本地服务器的步骤。通过使用XAMPP软件,可以一键式搭建起开发环境,包括Apache、MySQL、PHP、PERL。在本地服务器上新建数据库和表,并设置相应的属性。最后,给出了创建new表的SQL语句。这个教程适合初学者参考。 ... [详细]
  • C语言注释工具及快捷键,删除C语言注释工具的实现思路
    本文介绍了C语言中注释的两种方式以及注释的作用,提供了删除C语言注释的工具实现思路,并分享了C语言中注释的快捷键操作方法。 ... [详细]
  • 本文介绍了C函数ispunct()的用法及示例代码。ispunct()函数用于检查传递的字符是否是标点符号,如果是标点符号则返回非零值,否则返回零。示例代码演示了如何使用ispunct()函数来判断字符是否为标点符号。 ... [详细]
  • Python正则表达式学习记录及常用方法
    本文记录了学习Python正则表达式的过程,介绍了re模块的常用方法re.search,并解释了rawstring的作用。正则表达式是一种方便检查字符串匹配模式的工具,通过本文的学习可以掌握Python中使用正则表达式的基本方法。 ... [详细]
  • 拥抱Android Design Support Library新变化(导航视图、悬浮ActionBar)
    转载请注明明桑AndroidAndroid5.0Loollipop作为Android最重要的版本之一,为我们带来了全新的界面风格和设计语言。看起来很受欢迎࿰ ... [详细]
  • 从零学Java(10)之方法详解,喷打野你真的没我6!
    本文介绍了从零学Java系列中的第10篇文章,详解了Java中的方法。同时讨论了打野过程中喷打野的影响,以及金色打野刀对经济的增加和线上队友经济的影响。指出喷打野会导致线上经济的消减和影响队伍的团结。 ... [详细]
  • 本文介绍了PE文件结构中的导出表的解析方法,包括获取区段头表、遍历查找所在的区段等步骤。通过该方法可以准确地解析PE文件中的导出表信息。 ... [详细]
  • Android源码深入理解JNI技术的概述和应用
    本文介绍了Android源码中的JNI技术,包括概述和应用。JNI是Java Native Interface的缩写,是一种技术,可以实现Java程序调用Native语言写的函数,以及Native程序调用Java层的函数。在Android平台上,JNI充当了连接Java世界和Native世界的桥梁。本文通过分析Android源码中的相关文件和位置,深入探讨了JNI技术在Android开发中的重要性和应用场景。 ... [详细]
  • 本文介绍了iOS数据库Sqlite的SQL语句分类和常见约束关键字。SQL语句分为DDL、DML和DQL三种类型,其中DDL语句用于定义、删除和修改数据表,关键字包括create、drop和alter。常见约束关键字包括if not exists、if exists、primary key、autoincrement、not null和default。此外,还介绍了常见的数据库数据类型,包括integer、text和real。 ... [详细]
  • Java和JavaScript是什么关系?java跟javaScript都是编程语言,只是java跟javaScript没有什么太大关系,一个是脚本语言(前端语言),一个是面向对象 ... [详细]
  • 十大经典排序算法动图演示+Python实现
    本文介绍了十大经典排序算法的原理、演示和Python实现。排序算法分为内部排序和外部排序,常见的内部排序算法有插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等。文章还解释了时间复杂度和稳定性的概念,并提供了相关的名词解释。 ... [详细]
author-avatar
手机用户2502905845
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有