如何同时提高一个软件系统的可维护性 和 可复用性是面向对象对象要解决的核心问题。
通过学习和应用设计模式,可以更加深入地理解面向对象的设计理念,从而帮助设计师改善自己的系统设计。但是,设计模式并不能够提供具有普遍性的设计指导原则。在经过一段时间的对设计模式的学习和使用之后,就会觉得这些孤立的设计模式的背后应当还有一些更为深层的、更具有普遍性的、共同的思想原则。
比如“开-闭”原则,这样的面向对象设计原则就是这些在设计模式中不断地显现出来的共同思想原则,它们隐藏在设计模式背后的,比设计模式本身更加基本和单纯的设计思想。
目录
一、软件与产品
1.1、可维护性
1.2、可复用性
1.3、可维护性与复用性的关系
二、接口
2.1 什么是接口
2.2 为什么使用接口
2.3 接口常见的用法
三、抽象类
3.1 什么是抽象类
3.2 为什么使用抽象类
3.2 抽象类常见的用法
3.3 哪些设计模式使用抽象类
四、软件设计原则
4.1 单一职责原则(SRP)
4.1.1 如何做到单一职责原则
4.1.2 与其它设计模式的关系
4.1.3 示例
4.2 开闭原则(OCP)
4.2.1 如何做到开闭原则
4.2.2 与其它设计模式的关系
4.2.3 示例
4.3 里氏代替原则(LSP)
4.3.1 如何做到里氏代替原则
4.3.2 与其它设计模式的关系
4.3.3 示例
4.4 依赖倒转原则(DIP)
4.4.1 如何做到依赖倒转原则
4.4.2 与其它设计模式的关系
4.4.3 示例
4.5 接口隔离原则(ISP)
4.5.1 如何做到接口隔离原则
4.5.2 与其它设计模式的关系
4.5.3 示例
4.6 合成/聚合复用原则(CARP)
4.6.1 如何做到合成/聚合复用原则
4.6.2 与其它设计模式的关系
4.6.3 示例
4.7 迪米特法则(LoD)
4.7.1 如何做到迪米特法则
4.7.2 与其它设计模式的关系
4.7.3 示例
一、软件与产品
生命周期
- 软件:分析、设计、开发、测试、部署、维护和退役。
- 产品:概念、产品研发、成长阶段、成熟阶段、衰退阶段 和 终结阶段。
关系
软件作为产品的一部分:软件可以作为产品的一部分进行开发。许多产品都需要软件来实现其功能和特性。例如,智能手机的操作系统、汽车的车载娱乐系统、智能家居设备的控制应用程序等都是软件作为产品的一部分而开发的。在这种情况下,软件的开发和产品的开发密不可分,软件贡献了产品的核心功能和用户体验。
软件作为独立产品:软件本身也可以作为独立的产品进行开发和销售。这种情况下,软件的开发和产品的开发是独立的过程。例如,办公软件、图像处理软件、游戏等都是作为独立的产品开发的。软件产品可以通过直接销售、订阅模式、广告等方式获得盈利,并满足用户的特定需求。
软件驱动和增强产品:软件可以在产品中起到驱动和增强的作用。通过软件的不断升级和优化,产品可以获得新的功能、性能提升和改进的用户体验。例如,智能家居设备通过软件更新获得新的智能控制功能,汽车通过车载软件升级获得新的驾驶辅助功能。软件的升级可以延长产品的寿命周期并提升产品的竞争力。
软件支持和服务:软件在产品生命周期中通常需要提供支持和服务。这包括技术支持、软件更新、bug修复、安全补丁等。产品的使用者需要软件供应商提供及时的支持和服务来确保软件的正常运行。这些支持和服务对于产品的用户满意度和产品的市场声誉至关重要。
共同点
阶段划分:软件生命周期和产品生命周期都可以划分为多个不同的阶段。例如,软件生命周期可以包括规划、设计、开发、测试、部署和维护等阶段;而产品生命周期可以包括市场导向、产品开发、增长、成熟和衰退等阶段。这些阶段的划分都有助于管理和控制产品的不同阶段的活动和目标。
目标导向:软件生命周期和产品生命周期都以实现特定的目标为导向。在软件生命周期中,目标可能包括开发高质量、稳定的软件系统,并在市场上取得成功;而在产品生命周期中,目标可能包括通过不同阶段的活动实现产品的成功上市、增加市场份额并获取利润。
迭代与优化:在软件和产品的生命周期过程中,都存在迭代和优化的机会。软件开发团队和产品管理团队都会根据用户反馈和市场需求进行持续改进和优化。软件开发过程中可能会有多个版本的发布和升级,以满足不断变化的用户需求;产品生命周期中也可能会有产品的升级、特性增加和市场推广等举措,以适应竞争压力和市场变化。
维护与退役:软件和产品都需要进行维护和最终退役。在软件生命周期中,维护阶段涉及缺陷修复、升级和性能优化等工作;退役阶段则包括软件的退市、替代或停止支持。在产品生命周期中,维护阶段可能包括产品技术支持、售后服务等;而退役阶段则可能涉及产品的下架、关闭或转型。
1.1、可维护性
系统可维护性指的是一个系统在部署后,能够便捷、有效地进行维护的能力。一个具有良好可维护性的系统能够降低维护成本,提高系统的可靠性和可用性,同时保证系统能够快速响应和适应后续的变化和需求。
软件退役根本原因
- 过于僵硬:很难在一个软件系统里加入一个新的性能,哪怕是很小的都很难。这是因为加入一个新性能,不仅仅意味着建造一个独立的新模块,而且因为这个新性能会波及很多其他模块,最后变成跨越几个模块的改动。使得一个起初只需要几天的工作,最后需要几个月才能完成。
- 过于脆弱:软件系统在个性已有代码时过于脆弱。对一个地方个性,往往会导致看上去没有什么关系的另一个地方发生故障。尽管在个性之前,设计师们会竭尽所能预测可能故障点,但是在个性完成之前,系统的原始设计师们甚至都无法确切预测到可能会波及到的地方。一碰就碎的情况,造成软件系统过于脆弱。
- 利用率低:复用指一个软件的组成部分,可以在同一个项目的不同地方甚至另一个项目中重复使用。每当程序员发现一段代码、函数、模块所做的事情是可以在新的模块、或者新系统中使用的时候,他们总是发现,这些已有的代码依赖于一大堆其他的东西,以至于很难将它们分开。最后,他们发现最好的办法就是不去碰这些已有的东西,而是重新写自己的代码。他们可能会使用源代码剪贴的办法,以最原始的复用方法,节省一些时间。这样的系统就有复用低的问题。
- 黏度过高:有时,一个改动呆以以保存原始设计意图和原始设计框架的方式进行,也可以以破坏原始意图和框架的方式进行。第一种办法无疑会对系统的未来有利,第二种办法是权宜之计,可以解决短期的问题,但是会牺牲中长期利益。第二种办法比较容易,且快速。一个系统的设计,如果使用第二种办法比第一种办法很多的话,就叫做黏度过高。一个黏度过高的系统会诱使维护它的程序员采取错误的维护方案,并惩罚采取正确维护方案的程序。
设计目的
一个软件有良好的维护性,在软件设计之初和实现过程中要做到如下几个理念:
可扩展性
新的性能可以很容易地加入到系统中去,就是可扩展性。(与“过于僵硬”的属性相反)
灵活性
可以允许代码个性平稳地发生,而不会波及到很多其它的模块,这就是灵活性。(与“过于脆弱”的属性相反)
可插入性
可以很容易地将一个类抽出去,同时将另一个有同样接口的类加入进来,这就是可插入性。(与“黏度过高”的属性相反)
通过考虑和提升系统的可维护性,可以减少系统维护过程中的风险和工作量,同时促进系统的持续演化和升级。在软件开发过程中,考虑和优化系统的可维护性是非常重要的,它有助于提高开发效率、降低维护成本,最终提升系统的整体质量和用户满意度。
1.2、可复用性
系统可复用性是指系统中的软件组件、模块或功能能够在多个不同的场景或系统中被重复利用的能力。这种能力可以帮助开发人员在不同的项目中共享和重复利用已有的代码和功能,从而提高开发效率、降低成本,并且可以带来更高的质量和一致性。
可复用性通常包括以下几个方面
算法的复用:各种算法比如排序算法得到了大量的研究。现在几乎不会有人在应用程序编程时试图建立自己的排序算法,通常的做法是在得到了很好的研究的各种算法中选择一个。这就是算法的复用。
数据结构复用:与算法的复用相对的,是数据结构的复用。如队、栈、队列、列表等数据结构得到了十分透彻的研究,所有的计算机教学都要详细描述这些数据结构。这就是数据结构的复用。
模块化设计:系统应该具有明确定义的模块化结构,不同的功能模块之间通过清晰的接口进行连接。每个模块应该有自己独立的功能和责任,以便于被其他系统或项目所复用。
标准化接口:系统中的模块和组件应该提供标准化的接口,以确保与其他组件的兼容性和可替换性。常见的接口标准包括 API、规范文件、数据格式等。
文档和示例:系统中的复用组件应该配备完善的文档和示例,帮助其他开发人员理解和正确使用这些组件。文档可以包括 API 文档、使用指南、示例代码等。
独立性:可复用的组件应当尽可能地与系统的其余部分解耦,降低对具体上下文的依赖性,以便于在不同的环境中被引入和使用。
兼容性:可复用组件应当能够在不同的系统环境中被无缝集成和使用,不论是技术架构、操作系统还是开发语言都应当尽量具有通用性。
1.3、可维护性与复用性的关系
可维护性与可复用性之间存在密切的关系:
设计影响:软件的设计对于可维护性和可复用性都有着重要的影响。一个良好的软件设计不仅能够提高软件的可维护性,还能够提高其可复用性。模块化的设计、清晰的接口定义和低耦合的架构都有利于提高软件的可维护性和可复用性。
目标一致:在设计阶段,提高软件的可维护性和可复用性的目标是一致的。例如,通过提高模块间的独立性和接口的规范化,既可以使得软件更易于维护,又可以使得模块更容易被其他系统或项目复用。
共同影响因素:软件的可维护性和可复用性都受到诸如模块化、文档化、标准化接口、独立性等因素的影响。这些因素对于两者的提升都是有益的。
适用范围:可复用的组件通常具有良好的接口定义和独立性,这也同时使得这些组件更容易维护。而在维护过程中发现的一些通用性问题和bug的修复,也可以促进组件的可复用性。
软件的可维护性和可复用性之间是相辅相成的关系。通过提高软件的可维护性,可以使得软件更易于维护和变更,从而增强了软件的可复用性。而一个具有高可复用性的软件组件也通常会具有良好的可维护性,因为它必须能够在不同的环境和场景下被重复利用。因此,在软件工程中,需要综合考虑提高软件的可维护性和可复用性,将有助于构建高质量、易维护和可持续演进的软件系统。
二、接口
2.1 什么是接口
在Java编程语言中,接口(Interface)是一种特殊的引用类型,它是一组没有方法体的抽象方法的集合。接口定义了一个规范,所有实现该接口的类都必须实现接口中定义的所有方法。接口在Java中扮演了重要的角色,用于定义类之间的契约,实现了面向对象编程中的接口隔离原则和多态特性。
特点
完全抽象:接口中的方法都是抽象方法,没有方法体,只有方法签名,没有具体的实现。接口只定义了方法的规范,具体的实现由实现接口的类来完成。
多实现:一个类可以实现多个接口,通过关键字
implements
来实现。这种多实现的特性使得Java支持了接口的多继承,一个类可以具有多个接口的行为。接口间的继承:接口也可以继承其他接口,通过关键字
extends
来实现。这种接口间的继承关系可以帮助组织和继承接口的行为,形成更复杂的接口体系。规范契约:接口用于定义类之间的契约,描述了类应该具有的行为和特征。实现接口的类必须提供接口中定义的所有方法的具体实现。
常量定义:接口中可以定义常量,常量默认为
public static final
,表示一旦定义后不允许修改。默认方法:从Java 8开始,接口中可以定义默认方法,使用关键字
default
来标识。默认方法是一种带有方法体的方法,可以在接口中提供一些默认的实现,而不需要实现类重新实现。接口变量:变量可以声明为接口类型,即接口变量。通过接口变量,可以引用实现了接口的类的对象,实现了接口的多态特性。
接口的应用:接口在Java中广泛应用,用于实现回调函数、定义API规范、实现插件系统等各种场景。
2.2 为什么使用接口
实现多态:接口提供了多态性,允许使用接口类型的引用来引用实现了该接口的任何类的对象。这样可以根据具体对象的类型来调用相应的方法,从而实现多态性,提高代码的灵活性和扩展性。
降低耦合:接口可以将实现类与接口的使用者解耦,实现了代码的分离。实现类在实现接口的方法时,只需遵循接口规范,而不用关心具体的调用方,从而降低了模块之间的依赖关系,提高了代码的可维护性和灵活性。
定义规范:接口是对行为的规范化描述,定义了类应该具有的行为。通过接口,可以清晰地定义出系统的接口或规范,使得系统更加清晰,方便团队协作开发。
多继承:接口可以被多个类实现,因此接口在一定程度上弥补了Java单继承的不足。一个类可以实现多个接口,从而获得多个不同接口的特性,增强了类的灵活性。
实现回调:接口常常用于实现回调机制,例如事件监听器、观察者模式等。通过接口,可以定义回调方法,然后由其他类来实现这些接口,从而实现灵活的回调逻辑。
适用于插件开发:接口可用于插件式开发,即定义一个接口规范,然后由插件来实现这个接口,系统可以动态加载这些插件,实现了系统的可扩展性和灵活性。
接口是面向对象编程中非常重要的概念,它具有多种优点,包括提高代码的灵活性和可维护性、降低耦合度、定义规范和契约、支持多继承等。在软件设计中,合理地使用接口可以提高代码的质量、可扩展性和复用性,是一种非常有用的工具和编程思想。
不足
不支持方法实现:接口只能定义方法签名,无法提供默认的方法实现。这意味着每个实现接口的类都必须自己实现接口中的所有方法,即使这些方法在多个实现类中代码是相同的,也无法进行代码复用。
局限于公共接口:接口的方法默认都是公共的,无法定义私有方法。这可能会导致对一些实现类不应该暴露的方法,也需要在接口中进行定义。
无法包含状态和变量:接口只能包含静态常量,而无法包含实例变量和非静态方法。这意味着无法在接口中保存状态,也无法定义实例方法来访问或修改状态。
不支持多继承:与抽象类不同,接口可以实现多继承,一个类可以实现多个接口。然而,这也可能导致类之间的接口过于复杂,代码难以维护和理解。
接口版本兼容性问题:在接口中新增方法或修改方法签名后,所有实现该接口的类都需要做相应的改动以适应新的接口。这可能导致对现有代码的修改,破坏已有的稳定状态。
容易出现过多接口:接口的设计应遵循高内聚低耦合的原则,但有时为了满足不同的需求,容易在系统中出现过多的接口,增加了代码的复杂性和理解难度。
综上所述,接口在软件开发中具有许多优点,但也有一些缺点和限制。在使用接口时应权衡利弊,根据具体需求进行选择,并结合抽象类或其他设计模式来实现代码的灵活性和可维护性。
2.3 接口常见的用法
接口是一种定义行为的方法,即定义了某个类或模块需要实现的方法,而不需要关心具体的实现细节。
常见用法
定义 API 规范:接口通常被用来定义类之间的契约或规范,描述类应该具有的行为和特征。通过定义接口,可以明确规定类应该实现哪些方法,并提供给其他开发者使用,以便在不知道具体实现类的情况下,使用接口定义的方法进行编程。
实现回调函数:接口经常在实现回调机制时使用。定义一个接口,其中包含回调方法,然后其他类可以实现这个接口以提供具体的回调行为。通过回调函数,实现类可以在特定的事件发生时通知调用方。
多态性:接口实现了多态性的特性,使得可以使用接口类型的引用来引用实现了该接口的任何类的对象。这样可以根据具体对象的类型来调用相应的方法,实现多态操作。
插件机制:接口可用于实现插件系统,通过定义接口规范,不同的插件可以实现这个接口,并在系统中使用。这种插件式开发方式使得系统具有良好的扩展性和灵活性。
实现策略模式:接口可以用于实现策略模式,其中定义一个策略接口,多个具体策略实现这个接口,并被用来实现不同的算法或策略。在运行时,可以根据需要动态切换策略实现。
组织代码:接口可以用于组织代码,将具有相似功能的方法定义在一个接口中,然后不同的类实现这个接口来提供具体的功能。这样可以更清晰地组织和管理代码。
简化单元测试:接口可以用于简化单元测试。通过使用接口来定义类的依赖,可以更容易地创建模拟对象,进行单元测试,提高代码的可测试性。
- 扩展性:接口具有良好的扩展性,即可以在不修改原有代码的基础上增加新的方法和属性。这样,当需要增加新的功能时,只需要实现新的接口,而不需要修改原有的代码。
- 依赖注入:接口可以用于依赖注入的实现。通过接口,可以将不同的依赖注入到类或模块中,使得代码更加灵活和可测试。
利用接口可以提高代码的可扩展性、灵活性和可维护性,增强程序的可读性和可测试性。合理地使用接口可以帮助我们更好地组织代码,规范开发流程,并促进更好的代码复用和扩展。
示例
当定义一个接口时,可以为接口中的方法提供一个约定,而具体的类可以根据需要来实现这些方法。以下是一个简单的 Java 接口使用示例:
首先,定义一个接口
Shape
:public interface Shape { double getArea(); // 计算图形的面积 double getPerimeter(); // 计算图形的周长 }
然后,我们可以创建实现这个接口的具体类,比如
Circle
和Rectangle
:public class Circle implements Shape { private double radius; public Circle(double radius) { this.radius = radius; } @Override public double getArea() { return Math.PI * radius * radius; } @Override public double getPerimeter() { return 2 * Math.PI * radius; } } public class Rectangle implements Shape { private double length; private double width; public Rectangle(double length, double width) { this.length = length; this.width = width; } @Override public double getArea() { return length * width; } @Override public double getPerimeter() { return 2 * (length + width); } }
接着,我们可以使用这些具体类来创建对象并调用接口中定义的方法:
public class Main { public static void main(String[] args) { Shape circle = new Circle(5.0); System.out.println("Circle Area: " + circle.getArea()); System.out.println("Circle Perimeter: " + circle.getPerimeter()); Shape rectangle = new Rectangle(4.0, 6.0); System.out.println("Rectangle Area: " + rectangle.getArea()); System.out.println("Rectangle Perimeter: " + rectangle.getPerimeter()); } }
在上面的示例中,我们定义了
Shape
接口,并且创建了两个实现了Shape
接口的具体类Circle
和Rectangle
。然后在Main
类中,我们创建了Circle
和Rectangle
的对象,并调用了接口中定义的方法。这样,通过接口,我们可以对不同的形状对象使用相同的方法进行操作,从而实现了接口的多态特性。这个简单的示例展示了接口的使用,通过接口可以定义一组类的共同行为规范,并实现这些规范的具体方法,从而提高代码的灵活性和可维护性。
三、抽象类
3.1 什么是抽象类
抽象类是Java中一种特殊的类,它不能被实例化,只能被子类继承。抽象类通过使用关键字
abstract
来声明,并可以包含抽象方法和具体方法。
特点
不能被实例化:抽象类不能直接创建对象,只能被继承后才能使用。由于抽象类包含抽象方法(没有具体实现),因此无法直接实例化对象。
可以包含抽象方法和具体方法:抽象类可以包含抽象方法和具体方法。抽象方法是没有实现体的方法,只有方法的声明。具体方法是有具体实现的方法。子类继承抽象类时,必须实现所有的抽象方法;而具体方法可以被继承,也可以在子类中进行重写。
用于定义规范和共享代码:抽象类常常被用于定义类之间的共同行为和特征,作为一种规范或模板。它可以定义抽象方法作为规范,要求具体子类提供方法的实现。同时,抽象类中的具体方法可以被多个子类共享和复用。
支持单继承:与接口不同,抽象类只能被一个类继承,即Java只支持单继承。一个类只能继承一个抽象类,但可以实现多个接口。
具有继承特性:抽象类可以被其他类继承,子类继承了抽象类后,可以继承和访问抽象类中的成员变量和方法。
3.2 为什么使用抽象类
定义模板和规范:抽象类可以定义一些方法的框架结构,让子类去实现具体的细节。这样可以为子类提供一种模板和规范,确保子类都具备一定的基本行为,有助于统一代码风格,提高代码的一致性和可读性。
提高代码复用:抽象类可以包含一些通用的方法和属性,这些通用内容可以被多个子类继承和复用,从而避免了重复编写相同的代码,提高了代码的重用性。
封装共同行为:抽象类可以封装一些子类共同的行为,将公共的部分抽象到抽象类中,有助于降低代码的耦合度,提高代码的内聚性,增强了系统的可维护性和扩展性。
定义共同接口:抽象类可以定义抽象方法,要求继承它的子类必须实现这些方法,这样可以定义一组类的共同接口,确保继承类具备特定的行为和功能。
实现多态性:抽象类允许多态性的应用,子类可以向上转型为其抽象类类型的引用,从而通过统一的接口来处理不同的子类对象,提高了程序的灵活性和可扩展性。
方便后续扩展:通过抽象类可以很容易地向系统中添加新的功能和行为,通过新增具体子类来扩展系统的功能,不需要修改抽象类本身,符合开闭原则。
总的来说,抽象类提供了一种优雅的方式来定义类之间的共同行为和关联,通过创建抽象类,可以简化代码的设计和维护,提高代码的可读性和可维护性,从而更好地满足软件开发中的需求。
不足
限制了单继承:Java 只支持单继承,因此如果一个类继承了某个抽象类,就无法再继承其他类。这会限制类的灵活性,特别是在需要使用或继承多个类的情况下,可能会受到限制。
增加了类的耦合度:因为子类必须继承抽象类,这意味着子类和抽象类之间存在一定的耦合关系,导致子类的实现与抽象类的定义紧密相连。这种紧耦合可能会增加代码的复杂性并影响代码的灵活性。
对新功能的扩展可能会受限:一旦一个抽象类被创建并投入使用,如果后续需要为其添加新的方法,将会影响所有的子类,因为它们都必须实现新添加的方法。这可能导致现有子类需要做修改,破坏已有的稳定状态。
难以对现有抽象类进行修改:在现有的抽象类中修改方法签名或删除已存在的方法,会对所有的子类造成影响,因为它们都必须相应地修改。这可能导致修改波及现有代码的大规模变更。
复杂性增加:随着应用的扩展和发展,抽象类体系可能变得越来越复杂。难以管理的复杂度可能导致代码维护和理解的困难。
考虑到这些缺点,在使用抽象类时需要认真权衡利弊,根据具体需求来选择是否使用抽象类,或者考虑使用其他实现接口等方式来弥补抽象类的缺陷。
3.2 抽象类常见的用法
常见用法
定义规范和模板:抽象类可以定义一些方法的框架结构,让子类去实现具体的细节。这种用法常见于设计模式中的模板方法模式,通过定义抽象类中的算法流程,然后让子类根据实际情况进行具体实现。
封装共同行为:抽象类可以封装一组子类的共同行为,将公共的部分抽象到抽象类中。这样可以避免重复编写相同的代码,提高代码的复用性和可维护性。
定义共同接口:抽象类可以定义一组子类必须实现的方法,形成共同的接口。这种用法常见于设计模式中的策略模式,通过定义抽象类中的方法签名,让不同的子类提供不同的实现,以实现不同的策略。
实现多态性:抽象类可以作为父类,允许子类向上转型为其抽象类类型的引用,从而通过统一的接口来处理不同的子类对象。这种用法常见于多态性的应用场景,可以提高程序的灵活性和可扩展性。
作为框架基类:抽象类常被用作框架的基类,提供框架的核心结构和基本行为。子类可以继承抽象类并进行扩展,以实现具体的业务逻辑。
为子类提供默认实现:抽象类可以为一些方法提供默认实现,这样子类可以选择性地覆盖这些方法,而不是强制全部重新实现。这种用法可以减少子类的工作量,并提供一些通用的默认行为。
- 组织相关方法:当有一组相关的方法需要定义时,可以使用抽象类将这些方法组织在一起。这样,其他类可以实现这个抽象类,并重写这些方法以满足自己的需求。这有助于提高代码的组织性和可维护性。
- 代码复用:通过使用抽象类,可以在不同的类之间共享一些通用的行为和属性。子类可以继承抽象类,并覆盖抽象类中定义的方法来实现自己的行为。这样可以避免代码的重复编写,提高代码的复用性。
- 设计模式:在许多设计模式中,抽象类都起着重要的作用。例如,在工厂模式中,可以使用抽象类来定义一个通用的工厂接口,其他工厂类可以实现这个接口来创建不同的对象。这有助于提高代码的可扩展性和可维护性。
总的来说,抽象类具有许多实用的用法,可以提供模板、规范、共同接口和共享代码等功能。这些用法可以帮助开发者更好地组织和设计代码,提高代码的可读性、可维护性和可扩展性。
示例
假设我们正在开发一个几何图形的应用程序,其中有多种不同类型的几何图形,如圆形、矩形和三角形等。我们希望通过抽象类定义一个共享的接口,并在具体的子类中实现不同的几何图形。
// 定义抽象类Shape abstract class Shape { // 抽象方法,获取图形的面积 public abstract double getArea(); // 抽象方法,获取图形的周长 public abstract double getPerimeter(); } // 具体的子类Circle class Circle extends Shape { private double radius; public Circle(double radius) { this.radius = radius; } public double getArea() { return Math.PI * radius * radius; } public double getPerimeter() { return 2 * Math.PI * radius; } } // 具体的子类Rectangle class Rectangle extends Shape { private double length; private double width; public Rectangle(double length, double width) { this.length = length; this.width = width; } public double getArea() { return length * width; } public double getPerimeter() { return 2 * (length + width); } } // 使用示例 public class Main { public static void main(String[] args) { Circle circle = new Circle(5); System.out.println("Circle Area: " + circle.getArea()); // 输出圆形的面积 Rectangle rectangle = new Rectangle(3, 4); System.out.println("Rectangle Perimeter: " + rectangle.getPerimeter()); // 输出矩形的周长 } }
在上述示例中,我们定义了一个抽象类
Shape
,其中包含了两个抽象方法getArea()
和getPerimeter()
,用于获取图形的面积和周长。然后,我们通过具体的子类Circle
和Rectangle
来继承抽象类,并实现这两个抽象方法。在
Main
类中,我们创建了一个Circle
对象和一个Rectangle
对象,并分别调用它们的getArea()
和getPerimeter()
方法来获取圆形的面积和矩形的周长,并打印输出结果。通过使用抽象类,我们可以定义图形对象的公共接口,并在具体的子类中实现不同的几何图形的操作。这样,我们可以以统一的方式处理不同类型的几何图形对象,提高代码的复用性和可扩展性。
3.3 哪些设计模式使用抽象类
抽象类在设计模式中的作用
定义公共接口:抽象类用于定义一组共享的方法或属性,形成公共接口。这些方法或属性可以被子类继承和实现,确保在不同的子类中具有一致的行为和约束。
提供默认实现:抽象类可以为一些方法提供默认的实现,这样子类可以选择性地覆盖它们,而不是强制全部重新实现。这样可以减少子类的工作量,并提供一些通用的默认行为。
促进代码复用:抽象类可以定义一些通用的实现,供多个相关的子类共享。通过使用抽象类,可以减少代码的重复编写,提高代码的复用性和维护性。
实现多态性:抽象类作为父类,允许子类向上转型为其抽象类类型的引用,从而通过统一的接口来处理不同的子类对象。这种多态性的应用可以提高程序的灵活性和可扩展性。
框架的基类:抽象类经常被用作框架的基类,提供框架的核心结构和基本行为。子类可以继承抽象类并进行扩展,以实现具体的业务逻辑。
用作模板方法模式的骨架:抽象类可以作为模板方法模式的关键组成部分。抽象类中定义了一个算法的骨架,将具体的实现延迟到子类中,以实现算法的定制化。
抽象类在设计模式中扮演着重要的角色,通过定义共享接口、提供默认实现、促进代码复用、实现多态性等方式,帮助开发者设计和组织更加灵活、可维护和可扩展的代码结构。
常见的设计模式
模板方法模式(Template Method Pattern):模板方法模式使用抽象类定义一个算法的骨架,将具体的实现延迟到子类中。抽象类中的模板方法定义了算法的流程,而具体实现则交给子类去实现。
工厂方法模式(Factory Method Pattern):工厂方法模式使用抽象类作为工厂的基类,定义一个创建对象的接口。每个具体的子类工厂继承抽象类并实现工厂方法来创建不同的对象。
策略模式(Strategy Pattern):策略模式使用抽象类定义一组算法族,通过继承抽象类并实现其中的方法来提供不同的具体算法。客户端根据需要选择不同的策略来完成任务。
状态模式(State Pattern):状态模式使用抽象类定义一组状态,并使用子类继承并实现这些状态。通过切换不同的状态对象,可以改变对象的行为和状态。
桥接模式(Bridge Pattern):桥接模式使用抽象类作为桥梁,将抽象类和实现类分离开来。抽象类定义了抽象方法,而实现类负责具体的实现,通过组合的方式实现抽象类和实现类的解耦。
装饰器模式(Decorator Pattern):装饰器模式使用抽象类作为装饰器的基类,通过继承抽象类并进行装饰来扩展对象的功能。装饰器模式允许动态地给对象添加新的功能,而无需修改其原始类。
适配器模式(Adapter Pattern):适配器模式使用抽象类作为适配器的基类,通过继承抽象类并实现适配器接口的方法来将不兼容的接口进行转换。适配器模式将两个不兼容的接口之间的转换工作放在适配器类中。
这些设计模式都是基于抽象类和继承的概念,通过使用抽象类来定义接口、提供默认实现,以及实现多态性和代码复用。它们帮助开发者在设计和组织代码时更加灵活、可维护和可扩展。
四、软件设计原则
设计原则
- 单一职则原则(Single Responsibility Principle,SRP)
- “开-闭”原则(Open-Closed Principle,OCP)
- 里氏替换原则(Liskov Substitution Principle,LSP)
- 依赖倒转原则(Dependency Inversion Pronciple,DIP)
- 接口隔离原则(Interface Segregation Principle,ISP)
- 组合/聚合复用原则(Composition/Aggregation Principle,CARP)
- 迪米特法则(Law of Demeter,LoD)
4.1 单一职责原则(SRP)
定义
单一职责原则(Single Responsibility Principle,SRP)是面向对象设计中的重要原则之一,它指导着我们设计和组织类、模块和函数。单一职责原则的核心思想是一个类或模块应该只有一个引起它变更的原因,即应该只有一个职责。
具体来说,单一职责原则要求一个类或模块只负责一项特定的功能或职责,它应该只有一个改变的原因。换句话说,一个类内部的代码应该实现一种类型的责任,如果一个类承担的职责过多,就等于给这个类增加了变更的原因,使得类变得复杂、难以维护和扩展。
优点
降低类的复杂性:每个类只负责单一的功能,使得类的职责更加清晰明确,避免了类的职责过于复杂和混乱。
提高代码的可读性:类的职责单一使得类的结构更加清晰,易于理解,提高了代码的可读性和可维护性。
提高代码的可维护性:当类的职责单一时,当需求发生变化或 bug 需要修复时,代码变更的影响范围更加集中,维护和修改起来更加方便。
降低耦合度:类的职责单一减少了类与类之间的依赖关系,降低了代码的耦合度,使得系统更加灵活,易于扩展和维护。
支持单元测试:类的职责单一易于编写和执行单元测试,因为每个类的功能更加明确,测试范围更加集中和清晰。
通过遵循单一职责原则,可以设计出更加健壮、可维护和可扩展的系统,有助于提高软件质量和开发效率。
缺点和挑战
类的数量增多:遵循单一职责原则可能会导致类的数量增多。如果每个功能都要创建一个独立的类,可能会导致类的数量庞大,增加了系统的复杂性。
代码重复:将功能分离到不同的类中可能会导致代码的重复。因为不同的类需要处理不同的职责,可能会有一些共享的代码逻辑需要在多个类中重复编写。
跨类协作复杂性增加:当类的职责被细分为多个单一职责时,不同的类之间需要进行协作。这可能导致类之间的交互复杂化,增加了设计和调试的难度。
可能引入额外的接口和依赖关系:为了实现单一职责原则,可能需要定义更多的接口和依赖关系。这可能增加了代码的耦合度和维护的复杂性。
需要权衡单一职责原则的利弊,并结合具体的项目需求和设计目标来进行决策。在某些情况下,追求单一职责原则可能会使代码更加清晰、可维护和灵活,但在其他情况下,过度分割职责可能会导致代码冗余和复杂性增加。在实际应用中,需要根据项目特点、团队的技术水平和业务要求来平衡设计和代码组织的复杂性。
4.1.1 如何做到单一职责原则
明确定义类的职责:在设计类时,明确定义该类的职责范围,确保每个类只负责一项特定的功能或职责。
关注领域内的功能:确保类内部的方法和属性都与该类的职责相关,避免将不相关的功能混合到同一个类中。
精简类的函数和方法:每个函数和方法应该只实现单一功能,避免函数和方法包含过多的业务逻辑。如果一个函数或方法需要实现多个功能,考虑将其拆分为多个单一职责的函数或方法。
避免跨界操作:避免类与类之间进行过多的交互,尽量保持类的独立性,减少类之间的耦合。
应用设计模式:在实际设计中,可以使用一些常见的设计模式,如工厂模式、策略模式、观察者模式等,来帮助实现单一职责原则,将不同的职责分配到不同的类中,并通过接口抽象来降低类之间的依赖关系。
持续重构:在开发过程中,保持对代码的持续审查和重构,确保每个类的职责都得到了恰当的划分,避免职责蔓延和功能耦合。
遵循设计原则:除了单一职责原则,还需要结合其他设计原则,如开闭原则、依赖倒置原则等,来保持代码的灵活性、可扩展性和可维护性。
通过遵循以上指导,可以更好地实现单一职责原则,设计出清晰、灵活且易于维护的代码结构。
4.1.2 与其它设计模式的关系
工厂模式(Factory Pattern):工厂模式可以帮助将对象的创建逻辑单独封装到工厂类中,实现了对象创建和具体职责的分离,符合单一职责原则。
策略模式(Strategy Pattern):策略模式定义了一系列算法,并将每个算法封装到具有单一职责的类中。使用策略模式可以使得算法的变化独立于使用算法的客户,符合单一职责原则。
观察者模式(Observer Pattern):观察者模式定义了一种一对多的依赖关系,当一个对象的状态发生变化时,所有依赖它的对象都得到通知并自动更新。观察者模式中,观察者和被观察者各自有各自的职责,符合单一职责原则。
装饰器模式(Decorator Pattern):装饰器模式可以动态地为对象添加额外的职责,而且还能够避免类的职责蔓延。装饰器模式通过将职责分割到单一的装饰类中,符合单一职责原则。
命令模式(Command Pattern):命令模式将请求封装成对象,从而使你可以用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。命令模式中,命令对象负责封装命令执行的操作,符合单一职责原则。
这些设计模式帮助将类的职责进行有效分离,使得每个类都具有清晰的单一职责,从而满足了单一职责原则。使用这些设计模式有助于编写结构清晰、易于维护和扩展的代码。
4.1.3 示例
可以通过以下几个步骤来实现单一职责原则:
明确类的职责:在设计类时,明确确定该类的职责范围,并确保该类只负责一项特定的功能。
拆分多余职责:如果存在一个类包含多个不相关的职责,将其拆分为多个单一职责的类。每个类都负责一个具体的职责。
定义接口:定义接口来描述每个类的职责。接口可以帮助明确类的功能,并提供一致的契约。
封装数据和行为:为每个类封装相应的数据和方法。确保类的属性和方法与其职责相关,并且不包含其他职责的逻辑。
下面是一个简单的示例:
// 定义接口来描述职责 interface EmployeeManagerInterface { void addEmployee(Employee employee); void removeEmployee(Employee employee); void calculateSalary(Employee employee); } // 实现具体的职责 class EmployeeManager implements EmployeeManagerInterface { @Override public void addEmployee(Employee employee) { // 添加员工的逻辑 } @Override public void removeEmployee(Employee employee) { // 移除员工的逻辑 } @Override public void calculateSalary(Employee employee) { // 计算员工薪水的逻辑 } } // 定义员工类 class Employee { // 员工属性和方法 } public class Main { public static void main(String[] args) { EmployeeManagerInterface manager = new EmployeeManager(); Employee employee = new Employee(); // 使用 manager 对象进行员工管理的操作 manager.addEmployee(employee); manager.calculateSalary(employee); manager.removeEmployee(employee); } }
如上示例中,我们使用接口
EmployeeManagerInterface
描述了员工管理的职责,并提供了相应的方法。具体的职责则由EmployeeManager
类来实现,包括添加员工、移除员工和计算薪水等操作。同时,我们还定义了Employee
类来表示员工的属性和方法。通过将不同的职责分离为不同的类和接口,我们实现了单一职责原则,使得每个类都具有明确的职责,便于代码的维护和扩展。
4.2 开闭原则(OCP)
定义
软件设计的开闭原则(Open-Closed Principle,OCP)是面向对象设计中的重要原则之一,由勃兰特·梅耶(Bertrand Meyer)提出。该原则的核心思想是“对扩展开放,对修改关闭”。
具体来说,开闭原则要求一个软件实体(类、模块、函数等)应该对扩展开放,即在不修改原有代码的情况下,可以通过扩展来增加新功能或修改旧功能。同时,对修改关闭意味着一旦软件实体的设计完成后,就不应该再修改其源代码,以避免对系统其他部分造成影响。
优点
可扩展性:开闭原则要求系统对扩展是开放的,可以通过添加新的代码来增加新的功能或模块。这使得系统更容易适应变化和支持新的需求,同时降低了向现有功能添加新功能时的风险。
可维护性:由于开闭原则鼓励使用扩展而不是修改现有代码,这使得维护工作更加简化。通过增加新代码来实现变化,可以避免对已有功能可能产生的潜在错误或导致其他不必要的修改。
可测试性:遵守开闭原则使得代码更具可测试性。由于不需要修改现有代码来实现新功能,可以更方便地编写针对新增代码的单元测试,确保新功能的正确性和稳定性,而不会对现有功能造成意外影响。
代码复用性:开闭原则鼓励通过扩展已有的抽象概念和接口来实现新功能,这样可以使得代码更具有复用性。当需要添加类似的功能时,可以直接使用已有的抽象类或接口,减少重复编写代码的工作量。
系统稳定性:由于开闭原则要求对现有代码的修改尽量降低,这有助于维持系统的稳定性和正确性。通过扩展而不是修改现有代码,可以减少引入错误的风险,降低系统出错的概率。
总的来说,遵守开闭原则可以提高软件系统的可扩展性、可维护性、可测试性,并提升代码的复用性,同时也有助于保持系统的稳定性。这些优点使得开闭原则成为设计高质量、可持续发展的软件系统的重要指导原则。
缺点和挑战
抽象设计的复杂性:为了实现开闭原则,需要预先设计良好的抽象层次结构和接口,这可能增加了系统的复杂性。正确地确定和定义抽象概念可能需要更多的时间和精力。
维护成本:在长期维护过程中,系统可能需要频繁地进行扩展和变化。遵守开闭原则要求增加新功能而不是修改现有代码,这可能导致系统的代码量增加,增加了对代码的维护成本。
非充分性:虽然开闭原则的目标是尽量避免修改现有代码,但现实情况下可能会有一些特殊情况无法完全符合开闭原则。有时候,对现有代码进行一些必要的修改可能是更有效的解决方案。
引入复杂性:为了遵守开闭原则,可能需要引入更多的抽象概念、接口和设计模式,从而增加了系统的复杂性。在某些情况下,这些复杂性可能会对开发人员的理解和维护造成困难。
对扩展的需求无法预见:在系统设计初期,很难准确地预见未来可能的扩展需求。过度地设计抽象层次结构和接口可能会带来不必要的复杂性和开销。
开闭原则是一种高层次的设计原则,它提供了更灵活和可扩展的系统设计方案。然而,遵守开闭原则可能需要权衡其他方面的考虑,并需要根据具体的项目需求和环境来判断是否适用。在实践中,需要综合考虑开闭原则的优点和缺点,合理地应用和权衡,以求达到系统设计的最佳平衡点。
4.2.1 如何做到开闭原则
采用以下设计方法
- 利用抽象类和接口:通过定义抽象类或接口,可以让实现类对扩展开放,同时限制了对源代码的修改。
- 使用多态性:通过多态性,可以在不修改原有代码的情况下改变对象的行为。
- 使用设计模式:许多设计模式(如工厂模式、策略模式、观察者模式等)都是为了帮助我们设计满足开闭原则的系统结构。这些设计模式可以帮助我们将系统的不同部分相互解耦,以便进行灵活的扩展。
参数化配置:将系统的行为参数化配置,使得系统的行为可以通过配置而不是修改代码来改变。
代码重构:当需要修改现有代码时,可以考虑使用代码重构的方式,将代码结构重组或抽取出通用部分,从而使得系统更加符合开闭原则。
遵守开闭原则的关键在于设计具有良好扩展性和灵活性的系统架构,使得系统的不同部分可以相互独立地扩展和变化,而不影响其他部分的稳定性和正确性。
4.2.2 与其它设计模式的关系
与开闭原则密切相关设计模式
工厂模式(Factory Pattern):工厂模式通过定义一个公共的接口和抽象类来创建对象,使得系统能够面向接口编程而不是具体实现类。这样,当需要添加新的产品时,只需扩展工厂类和产品类,而不需要修改现有的代码。
策略模式(Strategy Pattern):策略模式通过将一组可替换的算法封装成独立的类,并通过一个公共接口进行调用。这样,系统的行为可以在运行时动态地更改和选择,实现了开闭原则的要求。
观察者模式(Observer Pattern):观察者模式定义了一种一对多的依赖关系,当一个对象状态发生变化时,其他依赖的对象都能够接收到通知并做出相应响应。这样,可以通过添加/移除观察者来扩展系统的功能,而不需要修改被观察者的代码。
装饰器模式(Decorator Pattern):装饰器模式通过包装和增强已有对象的功能,而不需要修改原始对象的代码,实现了开闭原则。可以通过添加装饰器类来扩展对象的功能,同时保持现有代码的稳定性。
适配器模式(Adapter Pattern):适配器模式用于将一个类的接口转换成客户端所期望的接口。通过适配器,可以在不修改现有代码的情况下将已有的类和新的接口进行适应,实现了开闭原则的要求。
还有许多其他的设计模式,如模板方法模式、享元模式、状态模式等,它们在不同的情况下都可以帮助我们实现开闭原则,并提高系统的灵活性和可扩展性。根据具体的需求和设计情境,选择合适的设计模式可以更好地满足开闭原则的要求。
4.2.3 示例
通过创建接口和抽象类来实现开闭原则,并且使用多态和依赖倒置原则来保持系统的灵活性。
假设有一个图形绘制系统,需要支持绘制不同形状的图形,比如圆形和矩形。我们通过接口和抽象类来实现开闭原则:
首先,定义一个抽象的图形接口
Shape
,并且定义一个抽象方法draw
:public interface Shape { void draw(); }
然后,创建实现这一接口的具体图形类,比如
Circle
和Rectangle
:public class Circle implements Shape { @Override public void draw() { System.out.println("绘制圆形"); } } public class Rectangle implements Shape { @Override public void draw() { System.out.println("绘制矩形"); } }
接下来,我们定义一个图形绘制器
ShapeDrawer
,它接收一个Shape
对象,并能够绘制不同的图形:public class ShapeDrawer { public void drawShape(Shape shape) { shape.draw(); } }
现在,如果需要添加新的图形,比如三角形,我们只需创建一个新的实现
Shape
接口的类,并实现draw
方法,而无需修改已有的代码。这样,系统保持了对修改关闭的同时对扩展开放,符合开闭原则。通过遵循开闭原则,我们可以更容易地扩展系统的功能,同时保持较低的修改成本和风险。
4.3 里氏代替原则(LSP)
定义
里氏代换原则(Liskov Substitution Principle)是面向对象设计中的重要原则之一,由计算机科学家Barbara Liskov提出。
该原则是对继承和子类型化的一个准则,它阐述了子类型(派生类或子类)应当能够替换其基类型(父类或超类)而不影响程序的正确性。换句话说,只要程序中使用基类的地方,都应当能够用其子类来替换而不产生任何错误或异常行为。
优点
提高代码的可复用性:遵循LSP原则可以使得基类和子类之间的关系更加清晰和稳定,使得代码更容易被复用。
降低系统的耦合度:通过LSP原则,子类可以替换父类而不影响系统的行为,从而减少了模块间的耦合,使得各个模块可以更加独立地开发、测试和维护。
便于功能的扩展和修改:当需要引入新的子类时,遵循LSP原则可以更容易地进行系统扩展,不需要对已有的代码做出修改,降低了引入新功能时的风险。
增强程序的健壮性和可靠性:符合LSP原则的代码能够更好地避免因为子类替换父类导致的意外错误,提高了软件的健壮性和可靠性。
促进代码的可理解性和可维护性:遵循LSP原则可以使得代码结构更加清晰和易于理解,也更容易进行维护和重构,从而降低了代码的复杂性。
遵循里氏代换原则可以帮助我们设计出更加稳定、可靠、可维护和可扩展的软件系统,使得软件设计更加符合面向对象设计的原则和思想。
缺点和挑战
增加设计和开发的复杂性:严格的LSP要求子类能完全替换父类,这对继承结构和接口设计提出更高的要求,可能会增加设计和开发的复杂性。
可能会产生过度设计:为了满足LSP,可能需要对类设计进行过度的抽象和泛化,导致出现过于复杂和冗余的设计,甚至在某些情况下会降低代码的可读性和可维护性。
可能会限制一些特定的优化和实现方式:有时为了满足LSP,可能会限制一些特定的优化和实现方式,导致性能上的一些损失。
可能会引发“类爆炸”问题:为了满足LSP原则,可能会产生大量微小的子类,导致继承体系变得过于庞大和复杂。
可能会增加系统的耦合度:虽然LSP有助于降低模块间的耦合度,但过于严格地依赖LSP也可能使得类与类之间的依赖关系过于紧密,导致了一定程度的耦合。
虽然里氏代替原则有利于提高系统的设计质量和稳定性,但在实际应用中,需要权衡好遵循LSP原则和系统的复杂度、灵活性等因素,不应盲目追求LSP而导致过度设计或者对系统性能造成不必要的影响。
4.3.1 如何做到里氏代替原则
采用以下设计方法
- 子类可以扩展父类的功能,但不应该改变父类原有的功能,确保子类可以完全替代父类在所有的地方。
- 子类应该在不改变父类原有行为的基础上提供特定的功能扩展。
子类可以增加自己特有的方法,子类可以拥有自己的特有方法,但不能重写父类的非抽象方法。
- 子类的方法重载父类的方法时,方法的入参类型、出参类型和异常类型需要保持一致或者更宽松。
- 尽量不要重写父类的方法,而是通过子类扩展的方式来实现新的功能。
- 父类的抽象方法可以让子类有多样性的实现,但子类不应该覆盖父类的非抽象方法。
通过遵循上述原则,可以确保子类能够无缝地替代父类,并且在保持系统稳定性的基础上实现功能的扩展。
4.3.2 与其它设计模式的关系
与里氏代替原则密切相关设计模式
模板方法模式(Template Method Pattern):模板方法模式通过定义一个算法的骨架,将具体步骤的实现延迟到子类中。里氏代替原则可以确保子类在实现具体步骤时不会改变骨架的逻辑,从而实现算法的复用和扩展。
工厂方法模式(Factory Method Pattern):工厂方法模式通过将对象的创建延迟到子类,以便子类可以决定实例化哪个具体类。里氏代替原则可以确保子类创建的对象能够替代父类对象,保持系统的灵活性和可扩展性。
策略模式(Strategy Pattern):策略模式定义了一系列算法,将每个算法封装起来,并使它们可以相互替换。里氏代替原则可以确保新的算法能够替代现有的算法而不会影响系统的其他部分。
装饰者模式(Decorator Pattern):装饰者模式通过动态地给一个对象添加一些额外的职责,来扩展该对象的功能。里氏代替原则可以确保装饰者子类可以替代被装饰的对象,以便实现透明地扩展对象的功能。
总的来说,里氏代替原则与设计模式密切相关,可以确保子类与父类之间的替代关系,从而帮助设计出灵活而稳定的面向对象系统。
4.3.3 示例
实现里氏代替原则需要遵循以下几点:
- 子类必须保证可以替换父类并且不影响原有功能。
- 子类可以扩展父类的功能,但不能修改父类已有的功能。
- 父类中的抽象方法由子类去实现,但实现过程中不应该改变父类行为的基本逻辑。
假设有一个图形类 Shape,包括获取面积的方法 area(),然后有一个矩形类 Rectangle 继承自 Shape 类。我们来看看如何保证里氏代替原则:
// 定义图形类 public class Shape { public double area() { return 0.0; } } // 定义矩形类 public class Rectangle extends Shape { private double width; private double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double area() { return width * height; } }
在这个示例中,Rectangle 类继承自 Shape 类,并且重写了父类的 area() 方法,但并没有改变原有的功能,而是在原有的基础上进行了扩展。
另外,可以结合接口来实现里氏代替原则,确保子类可以完全替代父类在所有的场景中。在软件设计中,继承并不是唯一的实现里氏代替原则的方式,还可以通过组合、接口实现等方式来达到同样的效果。
4.4 依赖倒转原则(DIP)
定义
依赖倒转原则(Dependency Inversion Principle,简称DIP)是面向对象设计的一条重要原则之一,它强调高层模块不应该依赖于低层模块,而是应该依赖于抽象。同时,抽象不应该依赖于具体细节,而是具体细节应该依赖于抽象。
换句话说,依赖倒转原则主张要通过抽象来解耦程序的各个部分,高层模块和低层模块都应该依赖于抽象的接口或者抽象类,而不是依赖于具体的实现。这样可以提高系统的灵活性、可扩展性和可维护性。
优点
降低耦合性:依赖倒转原则可以减少高层模块对低层模块的依赖。高层模块只需要依赖于抽象接口,而不需要依赖于具体实现类,从而降低了模块之间的耦合性。
提高可扩展性:通过依赖倒转原则,系统的各个模块都依赖于抽象接口,当需要新增或者修改功能时,通过添加新的实现类或者修改现有的实现类,而不需要修改高层模块的代码,从而提高了系统的可扩展性。
增加灵活性:依赖倒转原则可以使得系统更加灵活,可以方便地切换不同的具体实现类,满足不同的业务需求,而不需要修改高层模块的代码。
提高可维护性:通过依赖倒转原则,将模块之间的依赖关系解耦,使系统的各个模块独立变化,当一个模块需要修改时,不会对其他模块造成影响,从而提高了系统的可维护性。
促进代码重用:依赖倒转原则通过抽象接口进行编程,可以使得模块的功能更加独立和可重用。不同的模块可以共享同一个抽象接口,从而实现代码的重用。
依赖倒转原则可以提高系统的灵活性、可扩展性、可维护性,降低模块之间的耦合度,从而提高整个系统的质量和可靠性。它是面向对象设计中非常重要的原则之一。
缺点和挑战
复杂性增加:引入依赖倒转原则需要引入抽象接口和依赖注入等机制,这可能会使得代码结构变得更加复杂,增加了开发和维护的难度。
学习成本增加:对于团队中没有经验的开发人员来说,理解和应用依赖倒转原则可能需要较长的学习周期,增加了学习成本和上手难度。
过度设计:有时为了满足依赖倒转原则,可能会过度设计接口和抽象类,导致系统结构变得过于复杂,甚至出现“设计模式过度使用”的问题。
性能开销:依赖注入等方式会带来额外的性能开销,尤其是在一些性能要求较高的场景下,可能需要权衡性能和设计规范之间的关系。
增加代码量:引入抽象接口和依赖注入可能会增加代码量,使得代码变得更加冗长,增加了阅读和维护的复杂度。
虽然存在这些缺点,但在大多数情况下,依赖倒转原则的优点仍然能够为软件系统带来更多的长期利益。在实际应用中,需要根据具体情况和需求权衡利弊,合理应用依赖倒转原则,避免过度设计和过度使用。
4.4.1 如何做到依赖倒转原则
识别依赖关系:首先,要明确识别出模块之间的依赖关系,确定哪些模块是高层模块,哪些模块是低层模块。
创建抽象接口:将高层模块所需的功能抽象成接口或者抽象类。这个接口应该是相对稳定、通用的,而且要符合单一职责原则。
低层模块实现接口:低层模块需要实现高层模块所定义的抽象接口。这样,高层模块就可以通过接口来使用低层模块,而不直接依赖于具体的实现类。
高层模块依赖注入:在高层模块中,通过依赖注入(Dependency Injection)将具体的实现类注入进来,而不是直接实例化具体的实现类。这样可以动态地切换具体实现类,提高系统的灵活性。
使用依赖注入容器(可选):可以使用依赖注入容器来管理对象的创建和依赖注入。依赖注入容器可以帮助自动实现依赖注入,减少手动配置的工作。
遵循开闭原则:依赖倒转原则和开闭原则(Open-Closed Principle)相辅相成。要严格遵循开闭原则,即模块应该对扩展开放,对修改关闭。当需要新增功能时,应该通过添加新的实现类来扩展,而不是修改原有代码。
通过以上步骤,可以实现依赖倒转原则,将模块之间的依赖关系解耦,提高系统的灵活性、可扩展性和可维护性。同时,在应用过程中,也要根据具体情况合理应用和调整依赖倒转的策略,以满足项目需求。
4.4.2 与其它设计模式的关系
工厂模式(Factory Pattern):工厂模式可以用来实现依赖倒转原则。通过工厂模式,高层模块可以依赖于抽象的工厂接口,由工厂负责创建具体的实例,从而将高层模块与具体实现类解耦。
策略模式(Strategy Pattern):策略模式可以帮助实现依赖倒转原则。通过定义一组策略接口,并将不同的策略实现注入到高层模块中,高层模块可以根据需要动态切换不同的策略实现,实现了高层模块与具体策略的解耦。
观察者模式(Observer Pattern):观察者模式也与依赖倒转原则有关。通过定义抽象的观察者接口和被观察者接口,高层模块可以依赖于抽象接口,具体的观察者可以通过实现观察者接口注入到被观察者中,实现了高层模块与具体实现类的解耦。
除了以上几种设计模式,还有其他一些设计模式也与依赖倒转原则相关,如策略工厂模式、代理模式等。这些设计模式都有助于实现依赖倒转原则,提高系统的灵活性和可维护性,并支持解耦模块之间的依赖关系。
4.4.3 示例
下面使用构造函数注入来实现依赖倒转原则。
假设有以下接口和实现类:
// 服务接口 public interface Service { void execute(); } // 具体服务实现类 public class ConcreteService implements Service { @Override public void execute() { System.out.println("Executing ConcreteService"); } } // 高层模块 public class HighLevelModule { private final Service service; // 通过构造函数注入 public HighLevelModule(Service service) { this.service = service; } public void doWork() { // 使用注入的服务 service.execute(); } }
在这个示例中,通过构造函数注入的方式将具体的服务实现类注入到高层模块中,实现了高层模块对具体实现类的解耦。
然后可以在应用的入口处进行依赖的注入和组装:
public class Application { public static void main(String[] args) { // 创建具体的服务实现类对象 Service concreteService = new ConcreteService(); // 将具体的服务实现类注入到高层模块中 HighLevelModule highLevelModule = new HighLevelModule(concreteService); // 执行高层模块的业务逻辑 highLevelModule.doWork(); } }
通过这种方式,高层模块依赖于抽象的服务接口,具体的服务实现类通过构造函数注入的方式被注入到高层模块中。这样就实现了依赖倒转原则,高层模块不依赖于具体实现类,而是依赖于抽象接口,从而提高了系统的灵活性和可扩展性。
以上示例演示了如何在 Java 中通过构造函数注入的方式实现依赖倒转原则。当然,实际应用中也可以使用其他依赖注入的方式,如方法注入、属性注入,或者利用依赖注入容器(如Spring Framework)来管理对象的创建和依赖注入。
4.5 接口隔离原则(ISP)
定义
接口隔离原则(Interface Segregation Principle,简称ISP)是面向对象设计中的一条重要原则,由罗伯特·C·马丁在他的著作《敏捷软件开发:原则、模式与实践》中提出。
该原则指导我们设计接口时应该将庞大臃肿的接口拆分成更小、更具体的接口,以便客户端只需要依赖于其需要使用的接口,而不用依赖于多余的接口。
优点
降低耦合性:遵循接口隔离原则可以将庞大的接口拆分成更小、更具体的接口,从而降低类与接口之间的耦合度。客户端只需要依赖于自身需要使用的接口,而不用依赖于不必要的接口,降低了类之间的依赖关系,提高了系统的灵活性和可维护性。
促进代码重用:当接口被精心设计并分离出单一的职责时,可以更容易地被其他模块或服务所重用,这有利于系统的拓展和维护。
便于测试和调试:拆分成小而专一的接口意味着更小的单元测试范围和更清晰的功能定义,方便进行测试和调试,提高了软件质量。
降低修改的风险:精简的接口代表更小的变更范围,若需要对某个功能进行修改,只需关注受影响的接口,而不会牵扯到其他无关的接口,降低了修改代码带来的风险。
符合单一职责原则:ISP 的遵循有利于类拥有单一的职责,一个类只需要实现与其业务逻辑相关的接口,不需要实现不相关的接口,符合单一职责原则。
遵循接口隔离原则有助于提高系统的灵活性、可维护性和扩展性,降低代码的复杂度和耦合性,并促进系统的健壮性和可测试性。
缺点和挑战
接口的细粒度可能过分分散:遵循接口隔离原则会导致接口的细粒度变得更小,可能会造成接口数量的增加。如果接口过于细分,可能会增加代码的复杂度和理解难度,增加维护成本。
引入更多的抽象层次:拆分接口可能需要引入更多的抽象层次,这会导致代码结构的复杂化。过多的抽象可能增加了学习成本和理解难度,尤其是对于新加入的团队成员。
实现类的数量可能增加:拆分接口意味着某个功能可能需要由多个实现类来分别实现,这会增加实现类的数量。如果实现类过多,可能会增加代码的复杂性和系统的维护成本。
需要更多的接口管理和协调:拆分接口后,需要更多的接口管理和协调工作,包括接口的版本管理、兼容性调整等,这可能会增加项目的开发和维护工作量。
尽管遵循接口隔离原则可能会带来上述的一些缺点,但在大多数情况下,其优点远远超过了缺点。需要权衡考虑业务需求、系统的复杂性和团队的实际情况,以找到合适的划分接口的方法。另外,合理的代码结构和良好的设计规范也可以帮助解决和缓解接口隔离原则可能带来的一些缺点。
4.5.1 如何做到接口隔离原则
分析需求和功能:首先,仔细分析系统的需求和功能,了解各个模块或类的职责和功能,明确它们之间的关系和依赖。
拆分庞大接口:根据需求和功能分析的结果,将庞大的接口拆分为更小、更具体的接口,每个接口只包含与其职责相关的方法。避免把不相关的功能强制放在一个接口中。
接口要小而精,不要臃肿:接口应该是精简的,不应该包含太多的方法。一个接口应该满足一个单一职责,不将过多的职责塞入一个接口中。
精心设计接口:设计拆分后的接口时,应该关注接口的单一职责,确保每个接口都只包含一组高内聚的方法。接口命名要具有清晰和准确的表达力。
避免接口的冗余和重复:仔细审查已有的接口,避免接口之间的重复和冗余。确保每个接口只包含必要的方法,避免无效的方法声明。
使用抽象类和接口继承:根据接口隔离原则,可以使用抽象类和接口继承来帮助实现合理的接口设计。抽象类和接口的使用可以对相似的接口进行抽象和分类,以提高代码的复用性和灵活性。
评估接口设计的灵活性和可扩展性:在设计接口时,要考虑系统的灵活性和可扩展性,预留必要的扩展点,避免频繁修改接口导致的影响范围扩大。
测试和验证接口设计:在设计完接口后,进行充分的测试和验证,确保接口设计与实际需求一致,并符合接口隔离原则。
客户端不应该被迫依赖于它不使用的接口:一个类对另外一个类的依赖应该建立在最小的接口上,这样就降低了客户端与之耦合的可能性。
接口设计优于继承:ISP原则也暗示了接口设计要优于继承,因为接口设计更加灵活,能够更好地满足不同类的实际需求。
通过遵循接口隔离原则,可以使接口设计更加灵活和可维护,降低类之间的耦合性,提高系统的可扩展性和可维护性,从而更好地满足软件设计的开闭原则和单一职责原则。
4.5.2 与其它设计模式的关系
适配器模式(Adapter Pattern):适配器模式可以帮助遵循ISP,因为它允许客户端通过特定接口与适配器进行交互,而无需了解适配器是如何与其他类进行交互的。
桥接模式(Bridge Pattern):桥接模式将抽象部分与它的实现部分分离,这符合ISP的理念,可以帮助避免庞大接口的出现,同时有助于避免不相关的接口方法导致耦合性的增加。
观察者模式(Observer Pattern):观察者模式允许主题对象与观察者对象之间的松耦合联系,观察者只需要实现一个接口,这符合ISP的原则,每个观察者只需要关注自己感兴趣的事件。
策略模式(Strategy Pattern):策略模式定义了一系列算法,将每个算法封装到具有共同接口的独立类中。这有利于遵循ISP,因为它可以确保每个算法只关注自己的功能,比如一个计算税金的算法不需要关心其他算法。
这些设计模式可以帮助支持接口隔离原则的实施,并在实际编程中有助于解决ISP所涉及的设计挑战。结合这些设计模式,可以更好地设计出遵循ISP的接口,并确保系统具有良好的灵活性、可维护性和扩展性。
4.5.3 示例
实现接口隔离原则(ISP)涉及以下几个方面:
定义接口:根据ISP的原则,需要定义符合单一职责的接口。在Java中,可以使用
interface
关键字来定义接口,例如:public interface Animal { void eat(); } public interface Flyable { void fly(); }
实现接口:根据实际需求,创建实现接口的类。每个类只需实现与自己相关的接口方法,而不需要实现不相关的方法。例如:
public class Bird implements Animal, Flyable { @Override public void eat() { System.out.println("Bird is eating"); } @Override public void fly() { System.out.println("Bird is flying"); } } public class Dog implements Animal { @Override public void eat() { System.out.println("Dog is eating"); } }
使用接口:在使用对象时,尽可能使用接口类型引用对象,而不是具体类的引用。这样做可以降低代码的耦合度,增加灵活性。例如:
public class Main { public static void main(String[] args) { Animal bird = new Bird(); bird.eat(); if (bird instanceof Flyable) { ((Flyable) bird).fly(); } Animal dog = new Dog(); dog.eat(); } }
通过以上方式,在Java中可以实现接口隔离原则,确保接口的单一职责和高内聚性。这样的设计使得系统更具灵活性和可维护性,能够更好地应对变化和扩展。
4.6 合成/聚合复用原则(CARP)
定义
合成/聚合复用原则(Composite/Aggregate Reuse Principle,CARP)也被称为合成/聚合原则,它是面向对象设计中的一个重要原则,强调应该尽量使用合成/聚合关系(Composition/Aggregation)而不是继承来实现代码的复用。
CARP 的主要思想是,更倾向于通过将对象组合成更大的对象来达到复用的目的,而不是依赖于继承的层次结构。通过合成/聚合关系,一个对象可以包含其他对象作为其组成部分或成员,而不是通过继承来继承其行为。
优点
灵活性:使用合成/聚合关系可以更灵活地组合和替换对象,因为对象之间的耦合度更低。这使得系统更容易应对变化和扩展,更具灵活性。
可维护性:合成/聚合关系使得代码更具模块化,易于理解和维护。每个独立的对象都只负责自己的职责,使得在对系统进行修改和维护时更加容易。
松耦合:由于不依赖于继承,组成对象之间的耦合度更低。这意味着各个对象可以更独立地进行开发、测试和维护,而不会因为一个对象的改动而影响其他对象。
复用性:通过合成/聚合关系,可以更灵活地复用对象,因为对象的组合可以根据需求进行调整,而不会受到继承层次结构的限制。
降低继承的缺点:继承层次结构会引入一些固有的问题,如“脆弱基类”问题等。合成/聚合复用原则通过减少对继承的依赖,有助于避免这些问题的产生。
合成/聚合复用原则有利于增加系统的灵活性、可维护性和复用性,帮助设计出模块化、低耦合度的系统。这些优点使得合成/聚合复用原则成为面向对象设计中的重要原则之一。
缺点和挑战
增加设计复杂性:使用合成/聚合关系可能会增加设计的复杂性。在组合对象时需要考虑对象之间的关系和交互,这可能需要更多的代码和设计。
增加代码量:使用合成/聚合关系可能会增加代码量。相比于简单的继承关系,合成/聚合关系需要创建和维护更多的对象,并且涉及更多的交互和调用。
引入间接性:使用合成/聚合关系引入了更多的间接性。当一个对象使用其他对象的方法时,需要通过组合对象进行间接调用,可能导致额外的开销和复杂性。
难以确定组成对象的生命周期:合成/聚合关系下,对象的生命周期管理变得更复杂。如果一个对象包含其他对象作为组成部分,需要确保正确处理其生命周期,避免可能出现的对象泄露或内存泄漏问题。
可能降低性能:使用合成/聚合关系可能会导致一定的性能损失。相比于直接继承,对象之间的交互和间接调用可能会引入额外的开销,导致性能下降。
尽管存在这些缺点,但在面对应对变化、增强灵活性和可维护性的需求时,合成/聚合复用原则仍然是一种有价值的设计原则。它可以帮助设计出更模块化、可复用和易于维护的系统。在实际应用中,需要根据具体情况权衡使用合成/聚合关系的利弊,找到合适的设计方案。
4.6.1 如何做到合成/聚合复用原则
Identify对象的组成关系:首先需要识别出对象之间的组成关系。确定哪些对象是整体对象的组成部分,以及它们之间的关系是合成(强关联)还是聚合(弱关联)关系。
封装对象的组成关系:使用合成/聚合关系时,需要将对象之间的关系封装起来。这意味着在整体对象中包含成员对象,并且对外部提供统一的访问接口,隐藏内部组成结构的具体细节。
注重对象间的交互:在设计时需要思考对象之间的交互方式。合成/聚合关系下,整体对象与其部分对象之间可能会有交互和协作,需要合理设计对象之间的消息传递和调用方式。
管理对象的生命周期:特别要注意管理组成对象的生命周期,确保整体对象的创建和销毁不会导致组成对象的不当操作或泄漏。合成对象通常应该负责管理其组成对象的生命周期。
强调接口而非实现:在整体对象与其部分对象之间的交互时,应该强调接口而不是具体的实现。通过定义清晰的接口,可以降低对象之间的耦合度,增强灵活性。
灵活地调整组成关系:合成/聚合关系下,对象之间的组成关系应该是灵活可调的。需要设计出可以轻松替换、增加或移除组成部分对象的结构。
避免过度设计:在应用合成/聚合复用原则时,需要避免过度设计。根据实际需求和场景来灵活运用合成/聚合关系,避免引入不必要的复杂性。
通过遵循以上指导原则,可以帮助设计出符合合成/聚合复用原则的对象组成结构,从而实现代码的高复用性、可维护性和灵活性。
4.6.2 与其它设计模式的关系
组合模式(Composite Pattern):组合模式使用合成/聚合复用原则,通过将对象组织成树形结构来表示部分-整体关系。这种关系允许客户端统一处理单个对象和对象组合,从而实现了透明地处理对象的组合。
桥接模式(Bridge Pattern):桥接模式也使用了合成/聚合复用原则,通过将抽象部分与其实现部分分离,使它们可以独立地变化。这样的设计遵循了合成/聚合复用原则的思想,通过组合关系将抽象部分和实现部分进行解耦。
装饰者模式(Decorator Pattern):装饰者模式利用合成/聚合复用原则,以动态、透明的方式给单个对象添加功能。通过将对象进行包装和组合,装饰者模式可以灵活地扩展对象的功能,同时保持对象的接口不变。
策略模式(Strategy Pattern):策略模式利用合成/聚合复用原则,定义一系列算法家族,分别封装起来,并使它们可以互相替换。这样的设计使得算法的变化独立于使用算法的客户,符合合成/聚合复用原则的灵活组合和替换特性。
适配器模式(Adapter Pattern):适配器模式使用合成/聚合复用原则,通过组合关系将不兼容的接口转换为可兼容的接口。这样的设计保持了两个接口的独立性,遵循了合成/聚合复用原则的思想。
这些设计模式与合成/聚合复用原则紧密相关,都是为了提高代码的灵活性、可维护性和可复用性而设计的。通过结合设计模式和合成/聚合复用原则,可以更好地应对软件开发中的变化和需求,帮助构建出更加模块化、低耦合度的系统。
4.6.3 示例
实现合成/聚合复用原则可以通过以下方式进行:
组合关系的实现:
在 Java 中,实现合成/聚合复用原则的一种简单方式就是通过组合关系来包含其他对象。这通常涉及将一个类的实例作为另一个类的成员变量。例如:
public class Engine { // 引擎的相关属性和方法 } public class Car { private Engine engine; public Car() { this.engine = new Engine(); // 组合关系 } }
在这个例子中,Car 类包含一个 Engine 对象作为其成员,实现了组合关系。
构造函数注入实现聚合关系:
通过在类的构造函数中接受其他对象作为参数,实现聚合关系,让外部对象可以注入一个对象到另一个对象中。例如:
public class Car { private Engine engine; public Car(Engine engine) { this.engine = engine; // 聚合关系 } }
接口的应用:
使用接口定义对象之间的交互,而不是依赖于具体的实现类,可以帮助降低对象之间的耦合度。例如:
public interface Engine { void start(); void stop(); } public class GasEngine implements Engine { // GasEngine 的实现 } public class ElectricEngine implements Engine { // ElectricEngine 的实现 } public class Car { private Engine engine; public Car(Engine engine) { this.engine = engine; // 聚合关系 } public void startEngine() { engine.start(); // 通过接口进行交互 } }
通过上述方式,可以在 Java 中实现合成/聚合复用原则,提高代码的灵活性和可维护性。这些技术可以帮助将对象组织成更灵活、可复用的结构,符合软件设计的良好实践。
4.7 迪米特法则(LoD)
定义
迪米特法则(Law of Demeter,LoD)又称最少知识原则(Principle of Least Knowledge),是面向对象设计中的一个重要原则。迪米特法则指导着如何降低系统中各对象之间的耦合度,以提高代码的灵活性、可维护性和复用性。
迪米特法则的核心思想可以总结为:一个对象应该对其他对象有尽可能少的了解,即一个对象应该对其它对象尽可能少的暴露接口。
优点
降低耦合度:迪米特法则通过限制对象之间的交互,降低了对象之间的直接依赖关系。这样可以减少对象间的耦合,使得系统的各个模块之间更加独立,修改一个模块不会对其他模块产生太大的影响。
增强模块的独立性:迪米特法则使得对象只需与直接朋友进行通信,对象不需要了解它们之间的详细实现,从而提高了模块的独立性。当一个模块变化时,只需关注与之直接交流的模块,而不需要关注其他模块的变化,降低了系统的复杂度。
提高代码的可维护性:迪米特法则提倡减少对象之间的依赖,降低了对象之间的关联,从而使得代码更加清晰和简洁。当系统需要进行维护时,定位问题和修改代码将会更加容易。
促进代码的复用性:通过减少对象之间的耦合,迪米特法则可以提高代码的复用性。当某个对象需要替换或者重用时,由于对象之间关联的减少,它的影响范围会更小,因此代码的复用性也会更高。
提升系统的扩展性:迪米特法则降低了模块之间的依赖关系,使得系统更加灵活和可扩展。当需要增加新的功能或模块时,迪米特法则使得代码的修改范围变小,减少了对原有代码的影响,提高了系统的扩展性。
迪米特法则通过限制对象之间的耦合度,增强模块的独立性,提高代码的可维护性和复用性,同时促进系统的扩展性。这些优点使得应用迪米特法则的代码更加灵活、可维护和可扩展,有助于构建高质量的软件系统。
缺点和挑战
可能导致过多的中间类:严格遵循迪米特法则可能导致需要引入大量的中间类来传递消息,使得系统中存在过多的简单委托方法。这可能增加类的数量,使得系统变得复杂,同时也增加了维护成本。
可能引入不必要的间接性:迪米特法则要求对象对于所调用的对象的朋友只能进行有限的了解,这可能导致需要引入一层层的间接方法调用,从而引入不必要的间接性。这样导致了代码的冗余和复杂性,可能影响系统的性能。
可能增加代码组织的难度:严格遵循迪米特法则可能会导致需要更多的类和接口,使得代码组织和管理变得更加困难。特别是在大型项目中,过多的中间类和接口可能增加了项目的复杂度,使得代码的理解和维护变得更加困难。
可能导致过度设计:迪米特法则的严格遵循可能导致过度设计的问题,过分将注意力放在降低对象之间的直接交流上,而忽视了系统的实际需求和功能。这可能会增加开发和维护的成本,同时也使得系统的设计变得过于复杂。
可能牺牲了效率:严格遵循迪米特法则可能会导致需要增加额外的方法调用和对象之间的交互,从而牺牲了一定的效率。特别是在对性能要求较高的系统中,过度遵循迪米特法则可能会带来一定的性能损失。
虽然迪米特法则有一些潜在的缺点,但在实际应用中,需要根据具体的业务需求和系统情况来权衡,避免盲目地追求原则而导致过度设计或者牺牲效率和可理解性。合理地应用迪米特法则,有助于降低系统的耦合度,提高代码的灵活性和可维护性。
4.7.1 如何做到迪米特法则
封装对象的内部细节:对象应该尽量隐藏其内部的实现细节,只暴露必要的公共方法。通过封装,可以减少对象之间的直接依赖关系,降低耦合度。
限制对象间的交互:对象之间的交互应该通过少量的“朋友”来进行,对象只与自身直接关联的对象通信。不直接与陌生对象进行交互,避免链式调用。
使用中介者或者外观模式:中介者模式或者外观模式可以作为对象之间通信的桥梁,在中介者或外观对象的中部分隔对象之间的直接联系,降低耦合度。
遵循依赖倒置原则:依赖倒置原则要求针对抽象进行编程,而不是针对具体实现。通过面向接口编程,可以减少对具体类的依赖,降低耦合度。
合理划分模块和责任:合理划分模块和责任,尽量使得每个模块独立、高内聚低耦合。每个模块只负责自己的业务逻辑实现,并尽量减少模块之间的直接交互。
避免暴露不必要的信息:对于对象的方法参数和返回值,应该尽量只传递和返回必要的信息,避免暴露对象的内部状态和实现细节。
注意设计和代码的简洁性:在遵循迪米特法则的前提下,尽量保持设计和代码的简洁性。避免过度设计,关注系统的功能和实际需求。
遵循迪米特法则需要注意对象之间的交互、信息封装和模块划分等方面。合理应用迪米特法则可以降低系统的耦合度,提高代码的可维护性、复用性和扩展性。
4.7.2 与其它设计模式的关系
中介者模式(Mediator Pattern):中介者模式通过引入一个中介者对象,将对象之间的复杂交互转移到中介者对象中进行管理。中介者模式符合迪米特法则的原则,将对象之间的依赖关系限制在中介者对象上,降低了对象之间的耦合度。
观察者模式(Observer Pattern):观察者模式是一种对象间的一对多依赖关系,当被观察对象状态发生改变时,会自动通知依赖于它的观察者对象。观察者模式符合迪米特法则的原则,被观察对象只需要与观察者对象进行通信,而不需要了解观察者对象的具体实现。
外观模式(Facade Pattern):外观模式提供了一个统一的接口,将子系统的复杂性进行封装,使得客户端只需要与外观对象进行交互。外观模式符合迪米特法则的原则,将客户端与子系统的耦合度降低,客户端只需与外观对象进行通信。
适配器模式(Adapter Pattern):适配器模式将一个类的接口转换成客户端所期望的接口,使得原本不兼容的类能够协同工作。适配器模式符合迪米特法则的原则,适配器作为两个不相关的对象之间的中间类,限制了对象之间的直接交互。
这些设计模式在实现过程中都注重降低对象之间的耦合度,符合迪米特法则的原则。它们通过引入中间对象、定义统一接口、限制对象之间的交互等方式,实现了系统的解耦和模块的独立性,提高了系统的灵活性、可维护性和扩展性。
4.7.3 示例
迪米特法则要求一个对象应该对其他对象有尽可能少的了解,也就是说,一个对象不应直接调用其他对象的内部方法,而应通过以下方式来实现:
只与直接的朋友通信:一个对象的直接朋友包括成员变量、方法参数、方法返回值等。根据迪米特法则,一个对象应当只与其直接的朋友通信,不应该与间接朋友(朋友的朋友)通信。
尽量不在方法内部调用其他对象的方法:一个对象的方法中不应该直接调用其他对象的方法,而是应当通过参数、返回值等间接途径。
实现迪米特法则的简单示例:
// 课程类 class Course { private String name; public Course(String name) { this.name = name; } public String getName() { return name; } } // 学生类 class Student { private String name; private Course course; public Student(String name, Course course) { this.name = name; this.course = course; } public String getName() { return name; } // 课程信息通过方法参数传递 public void studyCourse(Course course) { System.out.println(name + " is studying " + course.getName()); } } // 学校类 class School { private String name; public School(String name) { this.name = name; } // 学生信息通过方法参数传递 public void registerCourse(Student student, Course course) { System.out.println(student.getName() + " registered at " + name + " for " + course.getName()); // 学校不直接调用课程的方法 } } public class Main { public static void main(String[] args) { Course math = new Course("Math"); Student student = new Student("Alice", math); School school = new School("ABC School"); school.registerCourse(student, math); } }
上面的示例中,通过将课程信息通过方法参数传递的方式实现了迪米特法则。学生对象不直接调用课程对象的方法,而是通过参数传递的方式与间接的朋友通信。这样设计遵循了迪米特法则,减少了对象之间的耦合,提高了代码的灵活性和可维护性。