面向对象编程的五大原则例子分析

时间:2022-02-12 17:26:07

在应用开发的过程中,感觉最快乐也是最痛苦的莫过于优化、重构代码。在版本不断地迭代更新上线中,我们不但要保证功能能正常运行,而且我们的代码需要保证健壮性、稳定性、拓展性。然而在我们不断接受新的知识过程中,我们对代码的理解也会越来越深刻,从而出现了优化,甚至是重构代码的过程。在此之前我们更需要知道面向对象编程的五大原则。

单一职责原则(Single Responsibility Principle)

单一职责原则比较容易理解,所谓单一职责,就是对于一个类来说,应该只专注于做一件事和只有一个引起他变的原因。所谓职责,我们可以理解成功能的意思。

  • 如果一个类负责的职责过多,应该细分给其他类。
  • 要尽量清楚职责的划分,设计类的功能只负责它所应该负责的一个功能。
  • 然而每个人对划分的理解都不一致。例一个复杂的功能封装一个类中导致代码X百行,正确是有一个个封装类来组成来实现这个功能。

开闭原则(Open Close Principle)

一个软件实体类,模块和函数应该对扩展是开放,对修改是封闭的。
在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展.换言之,应当可以在不必修改源代码的情况下改变这个模块的行为,在保持系统一定稳定性的基础上,对系统进行扩展。这是面向对象设计(OOD)的基石,也是最重要的原则。之前我们说的工厂模式就很好解析了开闭原则。

里氏替换原则(Liskov Substitution Principle)

说明:子类型必须能够替换他们的基类型。
可以很容易的实现同一父类下各个子类的互换,而替换成子类也不会产生任何错误或异常。
换个方式说就是:如果每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都代换称o2时,程序P的行为没有变化,那么类型T2是类型T1的子类型。
里氏替换原则核心原理是抽象,抽象又依赖于继承这个特性,继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。
我们用例子分析下:

public class JavaDemo {

public static void main(String[] args) {
A a = new A();
System.out.println("100-50=" + a.func1(100, 50));
System.out.println("100-80=" + a.func1(100, 80));
}
}

class A {
public int func1(int a, int b) {
return a - b;
}
}

运行结果:

100-50=50
100-80=20

新需求,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:

public class JavaDemo {

public static void main(String[] args) {
B b = new B();
System.out.println("100-50=" + b.func1(100, 50));
System.out.println("100-80=" + b.func1(100, 80));
System.out.println("100+20+100=" + b.func2(100, 20));
}
}

class A {
public int func1(int a, int b) {
return a - b;
}
}

class B extends A {
public int func1(int a, int b) {
return a + b;
}

public int func2(int a, int b) {
return func1(a, b) + 100;
}
}

类B完成后,运行结果:

100-50=150
100-80=180
100+20+100=220

我们发现原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法,造成所有运行相减功能的代码全部调用了类B重写后的方法,造成原本运行正常的功能出现了错误。在本例中,引用基类A完成的功能,换成子类B之后,发生了异常。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  • 子类中可以增加自己特有的方法。
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

依赖倒置原则(Dependence Inversion Principle)

定义:高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象。

我们用例子好好理解下:

public class JavaDemo {

public static void main(String[] args) {
Mother mother = new Mother();
mother.narrate(new Book());
}
}

class Book {
public String getContent() {
return "很久很久以前有一个白雪公主";
}
}

class Mother {
public void narrate(Book book) {
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}

运行结果:

妈妈开始讲故事
很久很久以前有一个白雪公主

感觉挺不错的,但是某一天需求变了,妈妈说的不是故事书,而是一份当天的报纸。报纸的代码:

    class NewsPaper{  
public String getContent(){
return "双12准备开启,某某大减价!";
}
}

但是妈妈没有读报纸的方法,这样妈妈就不会读报纸了,这可说不过去,那只能重载妈妈的narrate()方法。那以后读杂志、读邮件等等呢。我们需要不断修改妈妈这个高层类吗?这肯定不是一个好方法。

所以我们引入一个抽象的接口IReader。读物接口。

interface IReader{  
public String getContent();
}

Mother类与接口IReader发生依赖关系,而Book和NewsPaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码修改为:

public class JavaDemo {

public static void main(String[] args) {
Mother mother = new Mother();
mother.narrate(new Book());
mother.narrate(new NewsPaper());
}
}

class NewsPaper implements IReader {
public String getContent() {
return "双12准备开启,某某大减价!";
}
}

class Book implements IReader {
public String getContent() {
return "很久很久以前有一个白雪公主";
}
}

class Mother{
public void narrate(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}

interface IReader {
public String getContent();
}

运行结果:

妈妈开始讲故事
很久很久以前有一个白雪公主
妈妈开始讲故事
双12准备开启,某某大减价!

这样修改后,无论以后怎样扩展Main类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。

依赖倒置原则的核心思想是:面向接口编程。

接口隔离原则 (Interface Segregation Principle)

  • 一个类对另外一个类的依赖是建立在最小的接口上。
  • 使用多个专门的接口比使用单一的总接口要好。
  • 客户端不应该依赖它不需要的接口。

广义的接口:一个接口相当于剧本中的一种角色,而此角色在一个舞台上由哪一个演员来演则相当于接口的实现。因此一个接口应当简单的代表一个角色,而不是一个多重身份角色。如果系统设计多个角色的话,则应当每一个角色都由一个特定的接口代表。
狭义的接口(Interface):接口隔离原则讲的就是同一个角色提供宽、窄不同的接口,以对付不同的客户端。

我们用例子理解一下:

public class JavaDemo {

public static void main(String[] args) {
A a = new A();
a.depend1(new B());
a.depend2(new B());
}
}

interface I {
public void method1();

public void method2();

public void method3();

public void method4();

}

class A {
public void depend1(I i) {
i.method1();
}

public void depend2(I i) {
i.method2();
}

}

class B implements I {
public void method1() {
System.out.println("类B实现接口I的方法1");
}

public void method2() {
System.out.println("类B实现接口I的方法2");
}

public void method3() {
System.out.println("类B实现接口I的方法3");
}

// 对于类B来说,method4不是必需的,但是由于接口I中有这个方法,
// 所以在实现过程中即使这个方法的方法体为空,也要将这个没有作用的方法进行实现。
public void method4() {
}

}

运行结果:

类B实现接口I的方法1
类B实现接口I的方法2

可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。具体如下:

interface I1 {
public void method1();
}

interface I2 {
public void method2();

public void method3();
}

interface I3 {
public void method4();
}

class A {
public void depend1(I1 i) {
i.method1();
}

public void depend2(I2 i) {
i.method2();
}

public void depend3(I2 i) {
i.method3();
}
}

class B implements I1, I2 {
public void method1() {
System.out.println("类B实现接口I1的方法1");
}

public void method2() {
System.out.println("类B实现接口I2的方法2");
}

public void method3() {
System.out.println("类B实现接口I2的方法3");
}
}

接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

参考:
卡奴达摩的专栏
OOP几大原则