面向对象设计的原则


面向对象设计的原则

前言

学习面向对象应该遵循的六大原则,能更好的帮助我们去理解面向对象的概念,在平时写代码时候用到,来养成我们良好的代码规范。
参考文章:

六大原则

  • SRP (Single Responsibility Principle) 单一职责原则
  • OCP(Open Closed Principle) 开闭原则
  • LSP(Liskov Substitution Principle) 里氏替换原则
  • DIP(Dependency Inversion Principle) 依赖倒置原则
  • LOD(Law of Demeter) 迪米特法则
  • ISP (Interface Segregation Principle) 接口隔离原则

单一职责原则

Definition : There should never be more than one reason for a class to change.

其解释就是有且仅有一个类来引起类的改变,说的就是一个类只担负一个职责。

单一职责规定每个类都应该有单一的功能,该功能由这个类完全封装。所有这个类的服务都应该和该功能平行。

如果一个类所承担的职责过多,就等于把这些职责全都耦合在了一起,一个职责的变化极大可能会影响到别的职责的实现过程,很容易使我们的设计破坏,

因此我们应该尽可能的遵守单一原则。

举个形象点的例子,在公司里可能会有这样的角色。由于某些不可抗力,一个开发人员可能需要去承担多个原则,既要自己去设计方案写需求,还要自己去编写代码实现需求,而且还要去客户交流去解决大大小小的问题。

public class Code2hu0{
    public void writePlan(){}
    public void writeCode(){}
    public void meetCustomer(){}
}

? 这种代码的写法显然不符合我们上文所提到的单一原则,因为有多种类都可以去改变类的改变。当哪天需要多加一个功能什么的,其他使用该类的类也要进行相应的变化,这样我们在后期维护代码时候成本就非常高。

? 因此我们需要把这些方法都拆分为独立的职责,让每一个类只负责一个方法。每个类只用专心处理自己的方法即可。

单一职责的好处与解决的问题

  • 降低耦合,提高内聚力。类的复杂性降低, 每个类有清晰的实现功能。
  • 代码的逻辑性提高,可读性提高,减少了后期代码的维护成本。
  • 降低了因某个类变更而引起的风险。增强程序的健壮性。

单一职责的注意事项

  • 单一职责的关键就是如何细化职责
  • 单一职责原则提出标准:用职责和变化原因来衡量接口或者类设计的是否优良,但是职责的变换原因在不同的项目之间都是不可度量的,所以我们需要因地制宜
  • 接口一定要做到单一职责,类的设计尽量做到只有一个原因来使它引起变化。

开闭原则

Definition :software entities (classes,modules,function,etc.)should be open for extension, but closed for modification.

中文解释就是:一个软件实体,如类、模块、函数应该对扩展开放,对修改关闭。

开闭原则是Java世界里最基础的设计原则,是面向对象设计中'‘可复用设计“的基石,其他很多的设计原则和设计模式都是实现开闭原则的一种手段。

因此,软件中的各种组件 如ModuleclassFunction 需要进行改变时候,我们应该在不修改已有代码的基础上来引进新的功能。做到能不动就不动。开闭原则中的,指的是对于组件功能的扩展是开放的;开闭原则中的,指的是对于现有代码的修改是封闭的。

我们遵循开闭原则的最好手段就是抽象。把系统所有可能的行为抽象成一个抽象底层。这也就是我们为什么要面向接口编程的原因,例如上文提到的工程师类,我们说的是把方法抽离成单独的类,但更好的方式是把每个职责封装到接口里。

作为方法设计的抽象层,我们要预见所有可能的扩展,来使得后面进行扩展时,我们的抽象底层不用动。只需要通过从抽象底层所具体出的一个个实例来进行对程序的扩展。

推荐文章:我们为什么要面向接口编程?

开闭原则的好处

  • 可复用好
  • 可维护性好

里氏替换原则

Definition: Function that use pointers of references to base classes must be able to use objects of derived classes without knowing it.

中文解释就是:所有引用基类的地方必须能透明地使用其子类的对象。

通俗点说:只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生异常,但是反过来是不可以的。

一句话:子类可以扩展父类没有的功能,但是子类不能改变父类原有的功能。

面向对象的三大特性继承封装多态并不和谐。 我们知道,继承有很多缺点,当子类继承父类时,虽然可以复用父类的代码,但是父类的属性和方法都是对子类是透明的,子类可以随意修改父类的成员。如果需求变更,子类对父类的方法进行一些复写的时候,其他的子类可能就需要一起随之改变,这一定程度上违反了我们封装的原则,解决的方案就是引入里氏替换原则。

它包含了以下4曾含义:

  • 子类必须完全实现父类的方法。在类中调用其他类是务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
  • 子类可以有自己的个性。子类当然可以有自己的行为和外观了,也就是方法和属性。
  • 覆盖或实现父类的方法时输入参数可以被放大。即子类可以覆盖父类的方法,但输入参数应比父类方法中的大,这样在子类代替父类的时候,调用的仍然是父类的方法。即以子类中方法的前置条件必须与超类中被覆盖的方法的前置条件相同或者更宽松。
  • 覆盖或实现父类的方法时输出结果可以被缩小,也就是说返回值要小于或者等于父类的方法返回值。

比如父类有一个方法,参数是HashMap

public class Father{
    public void test(HashMap map){
        System.Out.println("父类被执行");
    }
}

子类的同名方法输入参数的类型可以扩大,这里我们输入参数为Map

public class Son extends Father{
    public void test(Map map){
        System.Out.println("子类被执行");
    }
}

测试父类方法的执行结果

public class Test{
    public static void main(String[] args){
        Father father =  new Father();
        HashMap map = new HashMap();
        father.test(map);
    }
}

输出结果 :父类被执行

根据里氏替换原则,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何的异常。我们调用子类的方法。

public class Test {
    public static void main(String[] args) {
        Father father = new Son();
        HashMap map = new HashMap();
        father.test(map);
    }
}

输出结果 :父类被执行

可以看到两次的运行结果是一样的,因为子类方法的输入参数类型范围扩大了,子类代替父类传递到调用者中,子类的方法永远不会执行。如果想让子类的方法执行,可以重写方法体。

反之,如果子类的输入参数类型范围比父类还小,比如父类中的参数是Map,而子类是HashMap,那么上述代码的结果就会是子类的方法,这难道不对吗?子类显示自己的内容啊。其实是不对的,因为子类没有复写父类的同名方法,方法就被执行了,这会引起逻辑的混乱,如果父类是抽象类,子类是实现类,你传递一个这样的实现类就违背了父类的意图了,所以子类覆盖或重载父类的方法时输入参数必定是相同或者放大的。

里氏替换原则的优点

  • 提高代码的重用性,子类拥有父类的方法和属性;
  • 提高代码的可扩展性,子类可形似于父类,但异于父类,保留自我的特性;

缺点

  • 继承是侵入性的,只要继承就必须拥有父类的所有方法和属性,在一定程度上约束了子类,降低了代码的灵活性;
  • 增加了耦合,当父类的常量、变量或者方法被修改了,需要考虑子类的修改,所以一旦父类有了变动,很可能会造成非常糟糕的结果,要重构大量的代码。

依赖倒置原则

Definition:High-level modules should not depend on low-level modules. Both should depend on abstractions.

中文:高层模块不应该依赖低层模块,两者都应该依赖其抽象

Definition:Abstractions should not depend on details. Details should depend on abstractions.

中文:抽象不应该依赖细节;细节应该依赖抽象

该原则提到了两个模块,高层和底层模块。什么意思呢?

不可分割的原子逻辑就是底层模块,原子逻辑的再组装就是高层模块。

抽象在Java中就是接口和抽象类,不能被实例化。而细节是实现接口或继承抽象类产生的类,也就是可以被实例化的实现类。依赖倒置原则是指模块间的依赖是通过抽象来发生的,实现类之间不发生直接的依赖关系,其依赖关系是通过接口是来实现的,这就是俗称的面向接口编程。

我们用人做健身运动来举例,当一个人跑步的时候,其代码表示为:

public class RunSport{
    public String run(){
        return "Running!";
    }
}

public class People{
    //运动的方法
    public void doSport(Sport sport){
        System.out.println("人"+sport.run());
    }
}
public class Client {
    public static void main(String[] args) {
        People people = new People();
        RunSport sport = new RunSport();
        people.doSport(sport);
    }
}

执行结果:人Running!

现在我们的人不想跑步了,他想去游泳,在这个类中,我们发现是很难做的,因为我们的People类依赖与一个具体的实现类RunSport,如果我们以后要增加很多的运动,那People类不是一直都要被修改?这显然不是我们想要看到的结果。

所以我们需要用面向接口编程的思想来优化我们的方案

public interface Sport{
    public string type();
}

public class RunSport implements Sport{
    public String type(){
        return "Running!";
    }
}
public class SwimmingSport implements Sport{
    public String type(){
        return "Swimming!";
    }
}
public class People{
    //运动的方法
    public void doSport(Sport sport){
        System.out.println("人"+sport.type);
    }
}

我们把运动单独抽象为一个接口,每个项目都继承该接口并且重写方法,这样一来,人的代码就不必改动。

相对于细节的多变性,抽象的东西要稳定的多依赖倒置原则就是基于这样一句话来进行设计的。

迪米特法则

Definition:talk only to your immediate friends.(只与直接的朋友通信)

一个类应该对自己需要耦合或者调用的类尽可能的知道的少,类和类之间的关系越密切,耦合度越大。那么累的变化对其耦合的类也会产生很大的影响。不利于我们遵守 低耦合,高内聚 的设计原则

什么是直接的朋友呢?每个对象都必然与其他对象有耦合关系,两个对象的耦合就成为朋友关系,这种关系的类型很多,例如组合、聚合、依赖等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

举一个之前看到过的例子,上体育课之前,老师让班长先去体务室拿10个足球,等下上课的时候要用。根据这一场景,我们可以设计出三个类 Teacher(老师),Monitor (班长) 和 FootBall (足球),以及发布命令的方法command 和 拿篮球的方法takeBall

public class Teacher {
    // 命令班长去拿球
    public void command(Monitor monitor) {
        List ballList =
            new ArrayList();
        // 初始化足球数目
        for (int i = 0;i<10;i++){
            ballList.add(new FootBall());
        }
        // 通知班长开始去拿球
        monitor.takeBall(ballList);
    }
}
public class FootBall {
}
public class Monitor {
    // 拿球
    public void takeBall(List balls) {
        System.out.println("足球数目:" + balls.size());
    }
}

然后,我们写一个情景类进行测试:

public class Client {
    public static void main(String[] args) {
        Teacher teacher = new Teacher();
        teacher.command(new Monitor());
    }
}

结果为 10

虽然结果是正确的,但我们的程序其实还是存在问题,因为从场景来说,老师只需命令班长拿足球即可,Teacher只需要一个朋友----Monitor,但在程序里,Teacher的方法体中却依赖了FootBall类,也就是说,Teacher类与一个陌生的类有了交流,这样Teacher的健壮性就被破坏了,因为一旦FootBall类做了修改,那么Teacher也需要做修改,这很明显违背了迪米特法则。

因此,我们需要对程序做些修改,在Teacher的方法中去掉对FootBall类的依赖,只让Teacher类与朋友类Monitor产生依赖,修改后的代码如下:

public class Teacher {
    // 命令班长去拿球
    public void command(Monitor monitor) {
        // 通知班长开始去拿球
        monitor.takeBall();
    }
}
public class Monitor {
    // 拿球
    public void takeBall() {
        List ballList = 
            new ArrayList();
        // 初始化足球数目
        for (int i = 0;i<10;i++){
            ballList.add(new FootBall());
        }
        System.out.println("足球数目:" + ballList.size());
    }
}

这样一来,Teacher类就不会与FootBall类产生依赖了,即时日后因为业务需要修改FootBall也不会影响Teacher类。

注意事项

迪米特法则不希望类之间建立直接的联系。如果真的有需要建立联系,也希望能通过它的“朋友”类来转达。因此,应用迪米特法则有可能造成的一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互调用关系——这在一定程度上增加了系统的复杂度,同时也为系统的维护带来了难度。所以,在采用迪米特法则时需要反复权衡,不遵循不对,严格执行又会“过犹不及”。既要做到让结构清晰,又要做到高内聚低耦合。

还是那四个字: 因地制宜

接口隔离原则

所谓ISP原则,即:Interface Segregation Principle接口隔离原则。原始定义如下:

Clients should not be forced to depend upon interfaces that they do not use.(客户端只依赖于它所需要的接口;它需要什么接口就提供什么接口,把不需要的接口剔除掉。)

The dependency of one class to another one should depend on the smallest possible interface.(类间的依赖关系应建立在最小的接口上。)

即,接口尽量细化,接口中的方法尽量少。接口隔离原则与单一职责原则的审视角度是不同的,单一职责原则要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。根据接口隔离原则拆分接口时,首先必须满足单一职责原则。

单一职责原则要求的是类和接口职责单一,注重的是职责,一个职责的接口是可以有多个方法的,而接口隔离原则要求的是接口的方法尽量少,模块尽量单一,如果需要提供给客户端很多的模块,那么就要相应的定义多个接口,不要把所有的模块功能都定义在一个接口中,那样会显得很臃肿。

举个例子,在我们年轻人的观念里,好的智能手机应该是价格便宜,外观好看,功能丰富的,由此我们可以定义一个智能手机的抽象接口 ISmartPhone,代码如下所示:

public interface ISmartPhone {
    public void cheapPrice();
    public void goodLooking();
    public void richFunction();
}

接着,我们定义一个手机接口的实现类,实现这三个抽象方法,

public class SmartPhone implements ISmartPhone{
    public void cheapPrice() {
        System.out.println("便宜");
    }

    public void goodLooking() {
        System.out.println("好看");
    }

    public void richFunction() {
        System.out.println("功能多");
    }
}

然后,定义一个用户的实体类 User,并定义一个构造方法,以ISmartPhone 作为参数传入,同时,我们也定义一个使用的方法usePhone 来调用接口的方法,

public class User {

    private ISmartPhone phone;
    public User(ISmartPhone phone){
        this.phone = phone;
    }
    public void usePhone(){
        phone.cheapPrice();
        phone.goodLooking();
        phone.richFunction();
    }
}

可以看出,当我们实例化User类并调用其方法usePhone后,控制台上就会显示手机接口三个方法的方法体信息,这种设计看上去没什么大毛病,但是我们可以仔细想下,ISmartPhone这个接口的设计是否已经达到最优了呢?很遗憾,答案是没有,接口其实还可以再优化。

因为除了年轻人之外,中年商务人士也在用智能手机,在他们的观念里,智能手机并不需要丰富的功能,甚至不用考虑是否便宜 (有钱就是任性~~~~),因为成功人士都比较忙,对智能手机的要求大多是外观大气,功能简单即可,这才是他们心中好的智能手机的特征,这样一来,我们定义的 ISmartPhone 接口就无法适用了,因为我们的接口定义了智能手机必须满足三个特性,如果实现该接口就必须三个方法都实现,而对商务人员的标准来说,我们定义的方法只有外观符合且可以重用而已。你可能会说,我可以重写一个实现类啊,只实现外观的方法,另外两个方法置空,什么都不写,这不就行了吗?但是这也不行,因为 User 引用的是ISmartPhone 接口,它调用三个方法,你只实现了两个,那么打印信息就少了两条了,只靠外观的特性,使用者怎么知道智能手机是否符合自己的预期?

分析到这里,我们大概就明白了,其实ISmartPhone的设计是有缺陷的,过于臃肿了,按照接口隔离原则,我们可以根据不同的特性把智能手机的接口进行拆分,这样一来,每个接口的功能就会变得单一,保证了接口的纯洁性,也进一步提高了代码的灵活性和稳定性。

补充

组合复用法则

CRP(Composite Reuse Principle)

组合复用原则的核心思想是:尽量使用对象组合,而不是继承来达到复用的目的。该原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分:新的对象通过向这些对象的委派达到复用已有功能的目的。

继承的缺点主要有以下几点:

  • 继承复用破坏数据封装性,将基类的实现细节全部暴露给了派生类,基类的内部细节常常对派生类是透明的,白箱复用。虽然简单,但不安全,不能在程序的运行过程中随便改变。
  • 基类的实现发生了改变,派生类的实现也不得不改变。
  • 从基类继承而来的派生类是静态的,不可能在运行时间内发生改变,因此没有足够的灵活性。

由于组合可以将已有的对象纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做有下面的好处:

  • 新对象存取组成对象的唯一方法是通过组成对象getter/setter方法。
  • 组合复用是黑箱复用,因为组成对象的内部细节是新对象所看不见的。
  • 组合复用所需要的依赖较少。
  • 每一个新的类可以将焦点集中到一个任务上。
  • 组合复用可以在运行时间动态进行,新对象可以动态的引用与成分对象类型相同的对象。

组合复用的缺点:就是用组合复用建造的系统会有较多的对象需要管理。

组合复用原则可以使系统更加灵活,类与类之间的耦合度降低,一个类的变化对其他类造成的影响相对较少,因此一般首选使用组合来实现复用;其次才考虑继承。在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。

使用继承时必须满足Is-A的关系是才能使用继承,而组合却是一种Has-A的关系。导致错误的使用继承而不是使用组合的一个重要原因可能就是错误的把Has-A当成了Is-A

总结

六大原则虽说是原则,但它们并不是强制性的,更多的是建议。

我们首先得学会走路,再学跑步。

学习这些经典的设计原则,让我们脑中有,这样才能因地制宜,选用合适的设计方案进行开发。