面向对象的设计原则

什么样的代码可以称之为好代码?又如何评价代码比较差?每个人也许都有自己的标准,但是在软件设计领域中,有一套通过经验总结出来的,可以有效解决问题的指导思想和方法论,遵从这些原则,可以事半功倍,反之则有可能带来一些麻烦。

SOLID

SOLID是五种设计原则首字母的缩写,有趣的是这个单词本身就有稳定的含义,遵从这些设计原则,有助于我们设计出更灵活,易于拓展和维护的软件系统。

  • Single Responsibility Principle(SRP):单一职责原则

  • Open Close Principle(OCP):开闭原则原则

  • Liskov Substitution Principle(LSP):里氏替换原则

  • Interface Segregation Principle(ISP):接口隔离原则

  • Dependency Inversion Principle(DIP):依赖倒置原则

SOLID原则之间并不是相互孤立的,彼此间存在着一定关联,一个原则可以是另一个原则的加强或基础;违反其中的某一个原则,可能同时违反了其他原则。其中,开闭原则和里氏代换原则是设计目标;单一职责 原则、接口分隔原则和依赖倒置原则是设计方法。

单一职责原则(SRP)

尽量让每个类只负责软件中的一个功能,并将该功能完全封装在该类中

这条原则的目的是为了减少类的复杂度。其实只有当程序规模不断扩大,变更不断增加后,违反单一职责的问题才会逐步显现出来,到了某个时间,类会变得过于庞大,以至于无法记清其中的细节,查找代码也会变得非常缓慢,必须浏览整个类或者整个程序才能找到需要的东西,需要看的东西实在太多,会让人感觉到对代码失去控制。另外的一个问题是,如果一个类负责的职责过多,那么当需求发生变更时,极有可能需要改动你并不想改动的部分,导致影响范围不可控。

单一职责可以称之为最简单的设计原则,简单到稍有经验的程序员即使从未了解过设计原则和设计模式的相关知识,也从未听说过单一职责的原则,在进行代码设计时也会不自觉的遵守这个原则,因为在软件开发迭代的过程中,谁也不希望因为修改了一个功能,而导致其他功能出现故障。虽然单一职责是如此的简单,但是它又是最容易被违背的设计原则之一,即使经验很丰富的开发人员写出的代码也会有违背这一原则的存在。

开闭原则原则(OCP)

软件功能应该对拓展开放,对修改关闭。

对拓展开发意味着当有新的需求或者变更发生时,可以进行拓展以适应新的情况。对修改关闭意味着代码一旦设计开发完成,就可以独立的进行工作,而不需要对代码内部进行修改。

可拓展性是衡量软件质量的重要指标,在软件的生命周期中,需求的新增与变更是不可避免的,如果有某种方法,既可以拓展软件功能而不需要修改原有的代码,那这在软件开发和交付时是梦寐以求的。

很多设计模式都是以达到OCP为目标的,例如装饰者模式,可以在不改变被装饰对象的情况下,通过包装一个类来拓展功能;策略模式通过制定策略接口,让不同的策略实现成为可能;适配器模式在不改变原有类的基础上,适配新的功能;观察者模式可以灵活的添加/删除观察者来拓展系统功能。

里氏替换原则(LSP)

程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换,即子类应该可以替换任何基类能够出现的地方,并且经过替换后,代码还能正常工作。

违反LSP原则最为频繁的一个地方就是子类方法覆盖父类方法,并且更改了其中的含义,而这在里氏替换时会发生意想不到的问题,发生这种问题往往是错误的继承关系导致的。经典的设计正方形-矩形问题就反映了这个问题。

按照常规理解,正方形是一个矩形,但是如果将正方形设计成矩形的子类,就会出现一些意料之外的问题。

定义一个矩形类

public class Rectangle {
    int length;
    int width;

    public void setLength(int length) {
        this.length = length;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getArea() {
        return length * width;
    }
}

子类正方形继承父类矩形,由于正方形的长宽是相同的,重写两个 setter 方法保证长宽一致:

public class Square extends Rectangle {
    
    public void setLength(int length) {
        this.length = length;
        this.width = length;
    }

    public void setWidth(int width) {
        this.width = width;
        this.length = width;
    }
}

这时我们在对矩形进行测试式极有可能出现问题,我们生产一个正方形,并且指派一个矩形质检员来负责检查这个正方形是否合格。质检员把长设为 4,宽设为 3,检查最后得到的面积是否为 12

public static boolean test(Rectangle rectangle) {
    rectangle.setLength(4);
    rectangle.setWidth(3);
    return rectangle.getArea() == 4 * 3; // Oops... 9 != 12
}

问题出现了,矩形质检员并不知道这是一个正方形,他使用传统的质检矩形的方法来检查竟然发现这个正方形连矩形也不是,难道正方形不是矩形?

其实对这个问题进行分析可以发现,矩形这个类同时包含了正方形和长方形两个语义,对于矩形而言,长宽是特征属性,正是长款决定了矩形是正方形还是长方形。而对于求面积这个功能并不是矩形所独有的功能,应该是所有封闭图形都有的功能。

那么如何打破正方形不是矩形的悖论呢?抽象是解决问题的一个方法。

长方形类和正方形类作为子类继承抽象矩形类。对于长方形,定义两个 final 修饰的字段 length & width 表示长宽,这是为了保证特征属性的不可变。对于正方形,定义一个 final 修饰的字段 sideLength 表示边长,并且构造函数只传入一个参数以保证其作为正方形的正确性。

分别重写父类的两个抽象方法和接口的一个抽象方法:

interface ClosedFigure {
    int getArea();
}

abstract class AbstractRectangle implements ClosedFigure {
    abstract int getLength();
    abstract int getWidth();
}

重写抽象矩形类作为父类,并实现 ClosedFigure 接口,这个接口的功能是求面积。求面积这个功能并不是矩阵本身具有的,而是它作为一个封闭图形能够实现的功能,所以需要用接口来实现。而抽象矩形定义两个方法,分别用来获取长和宽。

长方形类和正方形类作为子类继承抽象矩形类。对于长方形,定义两个 final 修饰的字段 length & width 表示长宽,这是为了保证特征属性的不可变。对于正方形,定义一个 final 修饰的字段 sideLength 表示边长,并且构造函数只传入一个参数以保证其作为正方形的正确性。

分别重写父类的两个抽象方法和接口的一个抽象方法:

class Rectangle extends AbstractRectangle implements ClosedFigure {
    private final int length;
    private final int width;

    Rectangle(int length, int width) {
        this.length = length;
        this.width = width;
    }

    @Override
    int getLength() {
        return length;
    }

    @Override
    int getWidth() {
        return width;
    }

    @Override
    public int getArea() {
        return length * width;
    }
}

class Square extends AbstractRectangle implements ClosedFigure {
    private final int sideLength;

    Square(int sideLength) {
        this.sideLength = sideLength;
    }

    @Override
    int getLength() {
        return sideLength;
    }

    @Override
    int getWidth() {
        return sideLength;
    }

    @Override
    public int getArea() {
        return sideLength * sideLength;
    }
    
    // 追加功能,获取边长
    int getSideLength() {
        return sideLength;
    }
}

接口隔离原则(ISP)

接口隔离原则要求一个类对另一个类的依赖性应当建立在最小的接口上,一个类只能有一个查询方法的接口。这样做可以避免角色冲突、污染代码等问题。同时,提供调用者需要的方法、屏蔽不需要的方法也是接口隔离的原则之一。

遵循ISP的接口设计在依赖关系和语义表达上会更加精准,最大的好处是可以将外部依赖减少到最小,只需要依赖自己需要的东西而不需要关注自己不用的东西,这样可以显著的降低模块之间的耦合度。

依赖倒置原则(DIP)

模块之间交互应该依赖抽象,而不是实现。高层模块不应该依赖底层模块,应该依赖于抽象。抽象不应该依赖细节,细节应该依赖于抽象。

一个类如果需要依赖于其他类协助完成功能,不应该直接依赖于特定类的实现,而是应该依赖于抽象,这也就是经常说的“面向接口编程”,面向接口编程正是DIP的一个体现。

遵循DIP会大大提高系统的灵活性。如果类只关心它们用于支持的特定 契约,而不是特定类型的组件,就可以快速而轻松地修改这些低级服务 的功能,同时最大限度地降低对系统其余部分的影响。

在Java应用中使用Logger框架有很多选择,比如log4j、logback、 common logging等。每个Logger的API和用法都稍有不同,如果要切换 不同的Logger框架,会非常复杂,可能要改动很多地方。产生这些问题 的原因是我们直接依赖了Logger框架,应用和Logger框架强耦合在一起了,而如果我们的业务代码中依赖SL4J而不是具体的实现,那么在需要切换日志框架时,只需要将相应的日志框架引入即可。

小结

上面的五项原则,正是告诉我们用抽象构建框架,用实现拓展细节的注意事项。

  1. 单一职责告诉我们实现类要单一
  2. 里氏替换告诉我们不要破坏继承结构
  3. 依赖倒置告诉我们合理的依赖,面向接口编程
  4. 接口隔离告诉我们在设计接口时要精简单一
  5. 开闭原则是总纲,告诉我们要对拓展开发,对修改关闭

对于SOLID的遵守并不是是与否的问题,而是多与少的问题,也就是说我们一般不会说有没有遵守,而是说遵守程度的多少。任何事情都是过犹不及,设计原则也是一样,他要求我们灵活的使用这些原则,对于他们的遵守程度只要在一个合理范围内,就可以说是良好的设计。

如下图所示,如果小圆形代表系统,五边形代表系统设计对于SOLID的遵循,大圆代表遵循SOLID的所有原则,那么设计1和设计2都是良好的设计,对于SOLID的遵循都在合理范围内,设计3和设计4虽然有些不足,但是也在可接受范围内,设计5则是严重不足,对各项原则都没有很好的遵守,而设计6则是遵循过度,设计5和设计6都是迫切需要重构的设计。

4C9CB121-F35F-496c-82BE-DC9D81FD2D98

重构原则

避免重复(DRY)

DRY是Don’t Repeat Yourself的缩写,DRY原则特指在程序设计和计算 中避免重复代码,因为这样会降低代码的灵活性和简洁性,并且可能导致代码之间的矛盾。

DRY是The Pragmatic Programmer 一书中提出的核心原则。系统的每一个功能都应该有唯一的实现。也就是说,如果多次遇到同样 的问题,就应该抽象出一个共同的解决方法,不要重复开发同样的功能。

贯彻DRY可以让我们避免陷入“散弹式修改(Shotgun Surgery)”的麻烦,“散弹式修改”是Robert Martin在《重构》一书中列出的一个典型代码“坏味道”,由于代码重复而导致一个小小的改动,会牵扯很多地方。

不需要原则(YAGNI)

YAGNI(You Ain’t Gonna Need It)的意思是“你不会需要它”,出自Ron Jeffries的 “极限编程” 一书,也就是系统设计编码时常说的避免过度设计。

YAGNI是针对“大设计”(Big Design)提出来的,是“极限编程”提倡的 原则,是指你自以为有用的功能,实际上都是用不到的。

这条原则的含义就是不要设计当前用不到的功能,不要去编写当前用不到的代码,核心思想是:不要过度设计。YAGNI也是敏捷开发方法中的一项原则,他的目的是避免花费过多的时间和精力在开发无用的代码上,从而提高开发效率和代码质量。

但是,这里出现了一个问题。仔细推敲,会发现DRY原则和YAGNI 原则是不兼容的。前者追求“抽象化”,要求找到通用的解决方法;后者 追求“快和省”,意味着不要把精力放在抽象化上面,因为很可能“你不会需要它”。因此,就有了Rule of Three原则。

三次原则(Rule of Three)

Rule of Three也被称为“三次原则”,是指当某个功能第三次出现时,就有必要进行“抽象化”了。这也是软件大师Martin Fowler在《重构》一书中提出的思想。

三次原则指导我们可以通过以下步骤来写代码。

  1. 第一次用到某个功能时,写一个特定的解决方法。
  2. 第二次又用到的时候,复制上一次的代码。
  3. 第三次出现的时候,才着手“抽象化”,写出通用的解决方法。

这3个步骤是对DRY原则和YAGNI原则的折中,是代码冗余和开发成本的平衡点。同时也提醒我们反思,是否做了很多无用的超前设计、代码 是否开始出现冗余、是否要重新设计。软件设计本身就是一个平衡的艺术,我们既反对过度设计,也绝对不赞成无设计。

保持简单、愚蠢原则(KISS)

KISS(Keep It Simple and Stupid)最早由Robert S. Kaplan在著名的平衡计分卡理论中提出。他认为把事情变复杂很简单,把事情变简单很复杂。好的目标不是越复杂越好,反而是越简洁越好。

KISS原则被运用到软件设计领域中,常常会被误解,这成了很多没有设计能力的工程人员的挡箭牌。在此,我们一定要理解“简单”和“简陋”的区别。

真正的“简单”绝不是毫无设计感,上来就写代码,而是“宝剑锋从磨砺 出”,亮剑的时候犹如一道华丽的闪电,背后却有着大量的艰辛和积累。真正的简单,不是不思考,而是先发散、再收敛。在纷繁复杂中,把握问题的核心。

最小惊奇原则(POLA)

POLA(Principle of least astonishment)是最小惊奇原则,写代码不是写侦探小说,要的是简单易懂,而不是时不时冒出个“惊喜”。

如何减少惊奇,首要的当然切实可行的规范和标准,而且要有相应的机制保证标准和规范能够执行下去,还要针对不同的业务不断的总结设计规范,给出建议的落地实施方案,让大家对系统设计的整体认知处在相似的维度。

小结

KISS原则讲的是“如何做”(尽量保持简单)

YAGNI原则讲的是“要不要做”(当前不需要的,就不要做)

三次原则讲的是“什么时候做”(需要做的时候就做)

DRY原则讲的是“不要重复做”

POLA原则讲的是“惊奇的事情不要做”

总结

设计原则能够知道我们编写出更好的代码,但是不要教条,过度设计要不得,软件是一种平衡的艺术,我们不是为了设计原则而编码,他只是背后的指导思想,我们的目的是构建可用的软件系统,并尽量减少系统的复杂度,在不能满足所有的设计原则时,要懂得适当的取舍。

posted @ 2023-06-11 16:16  ~鲨鱼辣椒~  阅读(39)  评论(0编辑  收藏  举报