一、架构相关术语
1.1 架构
软件架构就是软件的基本结构。是有关软件整体结构与组件的抽象描述,用于指导大型软件系统各个方面的设计。
软件架构会包括软件组件、组件之间的关系,组件特性以及组件间关系的特性,如类与类之间的关系、模块与模块之 间的关系、客户端与服务端的关系。
五种常见的软件架构,软件架构入门 — 阮一峰
- 分层架构
- 三层:展现层、业务层、数据层
- 四层:展现层、业务层、网络层、本地数据层
- 事件驱动架构
- 微核架构(又称插件架构)
- 微服务架构
- 云架构
1.2 架构与框架
人们对软件架构存在非常多的误解,其中一个最为普遍的误解就是:将架构(Architecture)和框架(Framework)混为一谈。其实很简单, 一句话:框架是软件,架构不是软件。
- 框架落脚在“架”字上,可以理解成名词性的,是一个客观性的名词存在,如.Net Framework;
- 架构体现在“构”字上,理解成构造,是一个动词性的,是一系列动作发生的策略性体现。
框架是一种特殊的软件,它并不能提供完整无缺的解决方案,而是为构建整个解决方案提供良好的基础。
- 框架是半成品。典型地,框架是系统或子系统的半成品;框架中的服务尅被最终应用系统直接调用,而框架中的扩展点是供应用开发人 员定制的“可变化点”。
架构不是软件,而是一种设计理念(思想),是关于软件如何设计的重要策略。
- 软件架构决策设计到如何将软件系统分解成不同的部分、各部分之间的静态结构关系和动态交互关系等。
- 经过完整的开发过程之后,这些架构决策将体现在最终开发出的软件系统中;当然,引入软件架构之后,整个开发过程变成了“分两步走”,而架构决策往往会体现在框架之中。或许,人们常把架构和框架混为一谈的原因就在于此吧!我们不能指着某些代码,说这就是软件架构,因为软件架构是比具体代码高一个抽象层次的概念。
- 架构势必被代码所体现和遵循,但任何一段具体的代码都代表不了架构。
框架技术和架构技术的出现,都是为了解决软件系统日益复杂所带来的困难而采取“分而治之”思维的结果—–先大局后局部,就出现了架构;先通用后专用,就出现了框架。
简而言之,框架和架构的关系可以总结为两句话:
- 为了尽早验证架构设计,或者处于支持产品线开发的目的,可以将关键的通用机制甚至整个架构以框架的方式进行实现;
- 业界(及公司内部)可能存在大量可供重用的框架,这些框架或者已经实现了软件架构所需的重要架构机制,或者为未来系统的某个子 系统提供了可扩展的半成品,所以最终的软件架构可以借助这些框架构造。
1.3 架构模式
架构模式是软件架构中在给定环境下,针对常遇到的问题的、通用且可重用的解决方案。—— 维基百科
- 类似于软件设计模式,但覆盖范围更广,致力于软件工程中不同问题,如计算机硬件性能限制、高可用性、业务风险极小化。一些架构模式会透过软件框架实现。
- 维基百科中,将MVC、MVVM等称为架构模式。(在Head First设计模式中,也将其称为复合设计模式(是作者的个人名词),是指将两个以上的普通设计模式结合而成的新设计模式)。
1.4 设计模式
在软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。
- 设计模式并不直接用来完成代码的编写,而是描述在各种不同情况下,要怎么解决问题的一种方案。
- 面向对象设计模式通常以类别或对象来描述其中的关系和相互作用,但不涉及用来完成应用程序的特定类别或对象。
- 设计模式能使不稳定依赖于相对稳定、具体依赖于相对抽象,避免会引起麻烦的紧耦合,以增强软件设计面对并适应变化的能力。
并非所有的软件模式都是设计模式,设计模式特指软件“设计”层次上的问题。还有其他非设计模式的模式,如架构模式。同时,算法不能算是一种设计模式,因为算法主要是用来解决计算上的问题,而非设计上的问题。
1.5 内聚、耦合
1.5.1 内聚性
内聚性(Cohesion)也称为内聚力,是从功能角度来度量模块内的组成部分之间相互联系的紧密程度。
因为:
- 当一个模块或一个类被设计成只支持一组相关的功能时,那内聚性肯定很高。
- 反之,当被设计成支持一组不相关的功能时,那模块内的组成部分联系紧密程度肯定不高,也就是低内聚。
所以,内聚性也用来衡量一个类或模块是否达到单一目的或责任。
内聚是一个比单一责任原则更普遍的概念,但两者其实关系是很密切的。遵守这个原则的类容易具有很高的凝聚力,比背负很多责任的低内聚类更容易维护。
一般会希望程序的模块有高内聚性,因为高内聚性一般和许多理想的软件特性有关,包括鲁棒性、可靠度、可复用性及易懂性等特性,而低内聚性一般也代表不易维护、不易测试、不易复用以及难以理解。
1.5.2 耦合性
耦合性(Coupling,Dependency)或称耦合力或耦合度,是一个和内聚性相对的概念。描述的是软件结构中,模块及模块之间联系的紧密程度,可以体现在,信息或参数依赖的程度。
耦合度,可以简单理解为当一个类发生变更时,对其他类造成的影响程度,影响越小则耦合度越弱,影响越大耦合度越强。
耦合强弱取决于模块间接口的复杂程度、进入或访问一个模块的点以及通过接口的数据。
一般而言高内聚性代表低耦合性,反之亦然。内聚性是由赖瑞·康斯坦丁所提出,是以实务上可减少维护及修改的“好”软件的特性为基础。
1.5.3 高内聚、松耦合
软件开发的目标应该是创建这样的例程:内部完整,也就是高内聚;而与其他例程之间的联系则是小巧、直接、可见、灵活的,这就是松耦合。
1.6 透明(对xx透明)
在汉语中,透明有以下两种看起来截然相反的含义:(前者是看的清清楚楚,后者是看不到)
- 比喻公开而无遮掩。比如建立一个公开、公正、 透明的用人制度。
- 能透过光线的。比如透明玻璃。(对xx透明,意思就是xx看不到该事物/属性)
- 虽然在日常使用中所指的“透明”是对可见光,但它也可以延伸到用于指代任何种类的辐射。
- 例如医学中:肉体对X光是透明的,但骨头却不是,使得X光成像对医疗非常有用。
- 例如计算机中:计算机术语“透明”是指客观存在并且运行着但是我们看不到的特性。即它客观存在,但对于大多数、特定类别的开发人员而言是不需要了解的东西,这就是计算机学中所指的透明。换种说法,透明就是一个黑盒,你只需要应用它给出的接口,而不需要了解其内在机理。
- 计算机组织对电脑用户是透明的,就是说计算机组织对用户来说是看不到的,也不需要看到的。
- 透传,即透明传输(pass-through),指的是在通讯中不管传输的业务内容如何,只负责将传输的内容由源地址传输到目的地址,而不对业务数据内容做任何改变。
- 从上层角度看,似乎就是一个透明的管道,什么都可以传。
- 非透明传输就是底层协议要对传输内容有限制或者修改。
注意:计算机中,有些场景的“透明”,也会被人错表达为第一种的意思,阅读时需要结合上下文理解,避免被误导。
1.7 程序设计中的抽象与接口
此处,并不是指编程语言中的抽象类、接口类。而是程序设计中的抽象、接口概念。
抽象和抽象化的两种解读
抽象:(核心在于抽,抽离、抽取)
- 从众多的具体事物中,抽取共同的、本质的属性,舍弃个别的、非本质的属性,从而形成概念。(所以抽象作为形容词,也意味着不具体的、笼统的;空洞的(跟「具体」相对))
- 将复杂物体的一个或几个特性抽离出去而只注意其他特性的行动或过程(如头脑只思考树本身的形状或只考虑树叶的颜色,不受它们的大小和形状的限制)。比如:
- 抽象艺术:打破了艺术原来强调主题写实再现的局限,把艺术基本要素,进行抽象的组合,创造出抽象的形式,因而突破了艺术必须具有可以辨认形象的籓篱,开创了艺术新的发展天地。
- 抽象画:与自然物象极少或完全没有相近之处,而又具强烈的形式构成面貌的绘画。
与上面对应,抽象化也有了两种解读:
- 忽略一些信息,把不同的实体当作同样的实体对待。在面向对象中,将对象的共同性质抽取出来形成类的过程即为抽象化的过程。
- 在计算机科学中,抽象化(Abstraction)是将资料与程序,以它的语义来呈现出它的外观,但是隐藏起它的实现细节。抽象化是用来减少程序的复杂度,使得程序员可以专注在处理少数重要的部分。—— 维基百科
- 为了使抽象的成品(算法)不会出现问题,要注意抽象时是否漏掉重要特征。
程序设计中,需要根据上下文来区分语义。(尤其是桥接模式那里….乍得一看,都被定义搞晕了)。编程语言中的抽象类、接口类中,抽象很明显是前种含义。
程序设计接口
接口或界面(interface)泛指实体把自己提供给外界的一种抽象化物(可以为另一实体),用以由内部操作分离出外部沟通方法,使其能被修改内部而不影响外界其他实体与其交互的方式,就如面向对象编程提供的多重抽象化。
接口可能也提供某种意义上的在讲不同语言的实体之间的翻译,诸如人类与电脑之间。因为接口是一种间接手段,所以相比起直接沟通,会引致些额外负担。
常见的接口:
- 人类与电脑等信息机器或人类与程序之间的接口称为用户界面。
- 电脑等信息机器硬件组件间的接口叫硬件接口。
- 电脑等信息机器软件组件间的接口叫软件接口,其存在于分离的软件组件间,并提供一种机制使这些组件可以沟通。
程序编写或设计的方法论中所关心的接口,是作为程序组件功能的抽象化,属于软件接口的一类。提供给软件组件间的接口会被访问到的事物的种类可以包括:常量、资料类型、程序的种类、例外规格、类型签名。在某些个案,定义变量作为接口的一部分可能会很有用。
1.8 关注点分离原则(SOC)
定义
关注点分离 (separation of concerns,SoC) 是将计算机程序分成不同部分的设计原则。每个部分负责处理一个不同的关注点(concern)。
- 说是设计原则,但因为太过基本、深刻,所以一般讲设计原则时,有时不会将其列在内。
关注点是对计算机程序代码(的执行结果)有影响的一组特定信息。
- 关注点可以很宽泛,比如“应用程序的硬件细节”、“数据库交互的细节”;也可以很具体,比如“要实例化的类的名称”。
- 横切关注点(Cross-cutting concern):也是程序的一部分,但它会依赖或影响程序的其他多个部分。
- 一般是不会影响到系统核心功能的信息,其通常会作为一些附加功能,横切多个核心关注点模块。
- 比如:日志记录模块。因为日志记录策略必然会影响系统的每个已记录部分。因此,日志记录横切所有记录的类和方法,即日志代码会复制分散到各个相关位置。其他如信息安全、监控、数据验证模块等。
- AOP旨在将横切关注点封装到切面(aspects)以保持模块化。这允许对处理封装横切关注点的代码进行干净的隔离和重用。
所以关注点分离的应用,可大到系统架构层次,也可小到特定的类或函数的设计。
能够很好地体现 SoC 的程序称为模块化(modular)程序。通过将信息封装在具有良好定义接口的代码段中,可以实现模块化,从而实现关注点分离。封装是一种信息隐藏的手段。
实现
- 模块化:将整个程序的代码分开成各部分的高层分解(横向分离成模块或子系统);
- 架构层次:比如分层架构,将关注点纵向分离为表示层、业务逻辑层、数据访问层、持久层等。
- 架构模式层次:比如MVC 或 MVP 等架构设计模式可以将数据(Model)、显示(View)、控制(Controller,控制和处理输入输出等)三者分离。
- 编程思想上的体现:
- 面向过程编程,将关注点分离为过程或函数;
- 面向对象编程,将关注点分离为对象;
- 函数式编程,将关注点分离为函数;
- 面向切面编程,将关注点分离为切面(aspects)和对象等;
- 更低层的,比如:
- OOP中,单一职责原则(SRP),规定一个类应该只有一个引起它变化的原因(关注点),否则类应该被拆分。
- 同理,接口、函数的拆分等。
这也是SOC与OOP原则之单一职责原则(SRP)的区别:
- SOC是设计原则,除了OOP,还适用于其他很多的编程思想中。
- 本质区别是关注点分离的划分模块的大小,SOC中模块可大到架构分层,可小到函数设计。SRP原则可以看做是SOC在OOP编程中,Class 层次的一种实现。
优缺点
关注点分离的价值在于简化计算机程序的开发和维护。当关注点分开时,各部分可以重复使用,以及独立开发和更新。
关注点分离是一种抽象形式。与大多数抽象一样,分离关注点意味着添加额外的代码接口,通常会创建更多要执行的代码。
二、设计模式中对象之间的关系
在面向对象设计模式中,类与类之间主要有6种关系,他们分别是:依赖、关联、聚合、组合、泛化、实现。
它们的耦合度依次增强。
2.1 依赖(dependency)
依赖关系的定义为:对于两个相对独立的对象,当一个对象负责构造另一个对象的实例,或者依赖另一个对象的服务时,这两个对象之间主要体现为依赖关系。【use-a】
依赖(Dependency)关系是类与类之间的联接,表示一个类依赖于另一个类的定义。一般而言,依赖关系在Java语言中体现为局域变量、方法的形参,或者对静态方法的调用。
依赖关系用一条带箭头的虚线表示(A依赖于B)。
2.2 关联(association)
对于两个相对独立的对象,当一个对象的实例与另一个对象的一些特定实例存在固定的对应关系时,这两个对象之间为关联关系。【has-a】
关联关系分为单向关联和双向关联。
- 单向关联表现为:类A当中使用了类B,其中类B是作为类A的成员变量。
- 双向关联表现为:类A当中使用了类B作为成员变量;同时类B中也使用了类A作为成员变量。
关联(Association)关系是类与类之间的联接,它使一个类知道另一个类的属性和方法。关联关系一般使用成员变量来实现。
注意:
- java双向关联关系代码样例会抛出java.lang.StackOverflowError(未实验)
- 在OC中双向关联时,注意不要引起循环导入(会编译报错的)
关联关系用一条带箭头的实线表示(表示A关联了B,但 B没关联A)。
2.3 聚合(aggregation)
聚合关系是关联关系的一种,耦合度强于关联,他们的代码表现是相同的,仅仅是在语义上有所区别:关联关系的对象间是相互独立的,而聚合关系的对象之间存在着包容关系,他们之间是“整体-个体”的相互关系。
聚合(Aggregation) 是强的关联关系。聚合是整体和个体之间的关系。与关联关系一样,聚合关系也是通过实例变量实现的。但是关联关系所涉及的两个类是处在同一层次上的,而在聚合关系中,两个类是处在不平等层次上的,一个代表整体,另一个代表部分。
例如:部门类与员工类,部门由员工组成,部门解散员工照样生活。
聚合关系用一条带空心菱形箭头的实线表示(A聚合到B上,或者说B由A组成)。
2.4 组合(composition)
相比于聚合,组合是一种耦合度更强的关联关系。存在组合关系的类表示“整体-部分”的关联关系。一般来说,为了表示组合关系,常常会使用构造方法来达到初始化的目的,在初始化的时候,就将”部分”传入。
- 它要求普通的聚合关系中”整体”负责”部分”的生命周期,它们之间是共生共死的,并且”部分”单独存在时没有任何意义。”整体”负责保持”部分“存活,在一些情况下将”部分”湮灭掉
- 组合关系是不能共享的,整体“可以将”部分”传递给另一个对象,由后者负责其的生命周期。换言之,”部分”在每一个时刻只能与一个对象发生组合关系,由后者排他地负责生命周期。部分和整体的生命周期一样。
举例:
- 公司类与部门类。公司由部门组成,公司破产倒闭,部门则不复存在,没有部门存在,公司也没有了。
- 人class与灵魂类、肉体类。当人的生命周期开始时,必须同时有灵魂和肉体;当人的生命周期结束时,灵魂肉体随之消亡;无论是灵魂还是肉体,都不能单独存在,他们必须作为人的组成部分存在。
组合关系用一条带实心菱形箭头的实线表示(A组成B,或者B由A组成)。
2.5 泛化(generalization)
类的继承结构表现在UML中为:泛化(generalize)与实现(realize)。
继承关系为 is-a的关系;反之,两个对象之间如果可以用 is-a 来表示,就是继承关系:(..是..)
泛化关系表现为继承非抽象类;
类图中,泛化关系用一条带空心三角箭头的实线表示(A继承自B)。
2.6 实现(realize)
实线,又称为细化。表现为继承抽象类;
类图中,实现关系用一条带空心三角箭头的虚线表示;
关联、聚合、组合只能配合语义,结合上下文才能够判断出来,而只给出一段代码让我们判断是关联,聚合,还是组合关系,则是无法判断的。
2.7 关联与继承优缺点对比
在设计模式中,有一个原则为优先使用组合/聚合,而不是继承。如装饰者模式、桥接模式都是这个原则的体现。
在《阿里巴巴Java开发手册》中也重申了此设计原则:谨慎使用继承的方式来进行扩展,优先使用聚合/组合的方式来实现。不得已使用继承时,必须符合里氏替换原则。
关联关系 | 继承关系 |
---|---|
优点:不破坏封装,更安全。 | 缺点:破坏封装。 |
优点:整体类与局部类之间松耦合,彼此相对独立,灵活性高。 | 缺点:子类与父类之间紧密耦合,子类依赖于父类的实现,子类缺乏独立性。也称继承具备强侵入性(父类代码侵入子类) |
优点:具有较好的可扩展性。 | 缺点:支持扩展,但是往往以增加系统结构的复杂度为代价 |
优点:支持动态组合。在运行时,整体对象可以选择不同类型的局部对象 | 缺点:不支持动态继承。在运行时,子类无法选择不同的父类 |
优点:整体类可以对局部类进行包装,封装局部类的接口,提供新的接口 | 缺点:子类不能改变父类的接口(3.2节 里氏替换原则) |
缺点:整体类不能自动获得和局部类同样的接口 | 优点:子类能自动继承父类的接口。(父类封装共性,子类实现特性。继承可以很好的实现代码复用(封装)的功能。) |
缺点:创建整体类的对象时,需要创建所有局部类的对象 | 优点:创建子类的对象时,无须创建父类的对象 |
继承破坏封装?
封装:通过公有化方法访问私有化属性,使得数据不容易被任意窜改,常用private修饰属性;
继承:通过子类继承父类从而获得父类的属性和方法,正常情况下,用protected修饰属性,专门用于给子类继承的,权限一般在本包下和子类里;
继承破坏了封装:是因为属性的访问修饰符被修改,使得属性在本包和子类里可以任意修改属性的数据,数据的安全性从而得不到保障。
何时使用继承?
- 继承要慎用,其使用场合仅限于你确信使用该技术有效的情况。一个判断方法是,问一问自己是否需要从新类向基类进行向上转型(是否要使用多态)。如果是必须的,则继承是必要的。反之则应该好好考虑是否需要继承。 —— 《Java编程思想》
- 只有当子类真正是超类的子类型时,才适合用继承(从现实语义进行思考)。换句话说,对于两个类A和B,只有当两者之间确实存在is-a关系的时候,类B才应该继续类A。 —— 《Effective Java》
2.8 补充:委托
委托:是设计模式中的一项基本技巧。
有两个对象参与处理同一个请求,接受请求的对象将请求委托给另一个对象来处理。委托模式是一项基本技巧,许多其他的模式,如状态模式、策略模式、访问者模式本质上是在更特殊的场合采用了委托模式。委托模式使得我们可以用聚合来替代继承,它还使我们可以模拟mixin。 —— 维基百科
感觉这个与关联的概念很相似。虽然说委托模式,但应该不算是一种模式。
三、面向对象的七大设计原则
在软件开发中,为了提高软件系统的可维护性和可复用性,增加软件的可扩展性和灵活性,程序员要尽量根据 7 条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
OO原则是我们的目标,而设计模式是我们的做法。每个设计模式背后都包含了几个OO原则的概念,很多时候,在设计时有两难的情况,这时候我们必须回归到OO原则,以方便判断取舍。 — 《Header First 设计模式》
所有的原则、设计模式最终都是为了:松耦合、易复用、方便开发维护。
SOLID(单一功能、开闭原则、里氏替换、接口隔离以及依赖反转)是由罗伯特·C·马丁在21世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。
3.1 开放-封闭原则(OCP)
3.1.1 定义
开闭原则(Open Closed Principle,OCP)由勃兰特·梅耶提出,他在 1988 年的著作《面向对象软件构造》中提出:软件实体应当对扩展开放,对修改关闭,这就是开闭原则的经典定义。
这里的软件实体包括以下几个部分:
- 项目中划分出的模块
- 类与接口
- 方法
开闭原则的含义是:当应用的需求改变时,在不修改软件实体的源代码或者二进制代码的前提下,可以扩展模块的功能,使其满足新的需求。
3.1.2 作用
开闭原则是面向对象程序设计的终极目标,它使软件实体拥有一定的适应性和灵活性的同时具备稳定性和延续性。具体来说,其作用如下。
- 对软件测试的影响
- 软件遵守开闭原则的话,软件测试时只需要对扩展的代码进行测试就可以了,因为原有的测试代码仍然能够正常运行。
- 可以提高代码的可复用性
- 粒度越小,被复用的可能性就越大;在面向对象的程序设计中,根据原子和抽象编程可以提高代码的可复用性。
- 可以提高软件的可维护性
- 遵守开闭原则的软件,其稳定性高和延续性强,从而易于扩展和维护。
3.1.3 封装变化原则
找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。 —— Header First设计模式
换句话说,如果毎次新的需求一来,都会使某方面的代码发生变化,那么你就可以确定,这部分的代码需要被抽出来,和其他稳定的代码有所区分。
下面是这个原则的另一种思考方式:“把会变化的部分取出并封装起来,以便以后可以轻易地改动或扩充此部分,而不影响不需要变化的其他部分“。使得代码变化的不经意后果变少,变得更有弹性。
这样的概念很简单,几乎是毎个设计模式背后的精神所在。所有的模式都提供了一套方法让“系统中的某部分改变不会影响其他部分”。
3.1.4 实现
抽象约束、封装变化
可以通过“抽象约束、封装变化”来实现开闭原则,即通过接口或者抽象类为软件实体定义一个相对稳定的抽象层,而将相同的可变因素封装在相同的具体实现类中。
因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象派生来的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。
举例:
- 如装饰者模式
- 简单些的,如下面例子
3.1.5 举例
下面以 Windows 的桌面主题为例介绍开闭原则的应用。
分析:Windows 的主题是桌面背景图片、窗口颜色和声音等元素的组合。用户可以根据自己的喜爱更换自己的桌面主题,也可以从网上下载新的主题。这些主题有共同的特点,可以为其定义一个抽象类(Abstract Subject),而每个具体的主题(Specific Subject)是其子类。用户窗体可以根据需要选择或者增加新的主题,而不需要修改原代码,所以它是满足开闭原则的,其类图如下所示。
3.2 里氏替换原则(LSP)
对继承的理解
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了对象间的耦合性。
当对继承体系中的类修改时:
- 如果修改的是基类:那修改时,必须考虑到所有的子类,否则父类修改后,所有涉及到子类的功能都有可能会产生故障。
- 如果修改的是子类:通过重写父类的方法来完成新的功能写起来虽然简单,但是,其实继承包含这样一层含义:父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它并不强制要求所有的子类必须遵从这些契约。
- 也就是说,如果子类对父类的非抽象方法任意修改:
- 会对整个继承体系造成破坏。
- 整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。
- 也就是说,如果子类对父类的非抽象方法任意修改:
而里氏替换原则就是表达了上面这一层含义。
3.2.1 定义
里氏替换原则(Liskov Substitution Principle,LSP)由麻省理工学院计算机科学实验室的里斯科夫(Liskov)女士在 1987 年的“面向对象技术的高峰会议”(OOPSLA)上发表的一篇文章《数据抽象和层次》里提出来的,她提出:继承必须确保超类所拥有的性质在子类中仍然成立。即子类型必须能够替换掉它们的父类型。
- 性质1:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
- 性质2:所有引用基类的地方必须能透明地使用其子类的对象。
里氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承,以及其中蕴含的原理。
里氏替换原是继承复用的基础,它反映了基类与子类之间的关系,是对开闭原则的补充,是对实现抽象化的具体步骤的规范。
3.2.2 作用
里氏替换原则的主要作用如下。
- 里氏替换原则是实现开闭原则的重要方式之一。
- 它避免了继承中重写父类造成的可复用性变差的缺点。
- 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。
- 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。
3.2.3 实现
里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
根据上述理解,对里氏替换原则的定义可以总结如下:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法(即里氏转换原则要求子类从抽象继承而不是从具体继承)
- 子类中可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类的方法更宽松
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的的输出/返回值)要比父类的方法更严格或相等
疑问:如果子类继承或实现的是抽象类或者接口的话,那只能引用子类了,这还怎么体现“替换”这个原则?
回答:此时还需要我们遵循依赖倒置原则,那么在代码运行中,声明的变量、方法的形参应该都是抽象类或者是接口类型的,而实际传递的是抽象类的子类或者接口的实现类。声明->实际传递,就体现了“替换”的原则。
如果程序违背了里氏替换原则,则继承类的对象,如果在基类出现的地方,替换基类对象,就会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。
3.2.4 举例
下面以“几维鸟不是鸟”为例来说明里氏替换原则。
分析:鸟一般都会飞行,如燕子的飞行速度大概是每小时 120 千米。但是新西兰的几维鸟由于翅膀退化无法飞行。假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间。显然,拿燕子来测试这段代码,结果正确,能计算出所需要的时间;但拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”,明显不符合预期。
1 | //鸟类 |
程序运行错误的原因是:几维鸟类因为没有飞行的能力,所以重写了鸟类的 setSpeed(double speed) 方法,这违背了里氏替换原则。正确的做法是:取消几维鸟原来的继承关系,定义鸟和几维鸟的更抽象的父类,如动物类,然后定义它们奔跑的能力。
几维鸟的飞行速度虽然为 0,但奔跑速度不为 0,可以计算出其奔跑 300 千米所要花费的时间。
3.3 依赖倒置原则(DIP)
3.3.1 定义
依赖倒置原则(Dependence Inversion Principle,DIP)是 Object Mentor 公司总裁罗伯特·马丁于 1996 年在 C++ Report 上发表的文章。
依赖倒置原则的原始定义为:高层模块不应该依赖低层模块,两者都应该依赖(其)抽象;抽象不应该依赖细节,细节应该依赖抽象。
- 高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。
- 在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是实现接口或继承抽象类的类,其特点就是可以直接被实例化。
其核心思想是:要面向接口编程,不要面向实现编程。
依赖倒置原则是实现开闭原则的重要途径之一,它降低了客户与实现模块之间的耦合。
由于在软件设计中,细节具有多变性,而抽象层则相对稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多。这里的抽象指的是接口或者抽象类,而细节是指具体的实现类。
使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成。
3.3.2 作用
依赖倒置原则的主要作用如下:
- 依赖倒置原则可以降低类间的耦合性。
- 依赖倒置原则可以提高系统的稳定性。
- 依赖倒置原则可以减少并行开发引起的风险。
- 依赖倒置原则可以提高代码的可读性和可维护性。
3.3.3 实现
依赖倒置原则的目的是通过要面向接口的编程来降低类间的耦合性,所以我们在实际编程中只要遵循以下4点,就能在项目中满足这个规则。
- 每个类尽量提供接口或抽象类,或者两者都具备。
- 模块间的依赖关系都通过接口或抽象类产生,即形参、变量的声明类型尽量是接口或者是抽象类。
- 任何类都不应该从具体/实现类派生。
- 使用继承时尽量遵循里氏替换原则。
代表:工厂模式
3.3.4 举例
下面以“顾客购物程序”为例来说明依赖倒置原则的应用。
分析:本程序反映了 “顾客类”与“商店类”的关系。商店类中有 sell() 方法,顾客类通过该方法购物以下代码定义了顾客类通过韶关网店 ShaoguanShop 购物:
1 | class Customer { |
但是,这种设计存在缺点,如果该顾客想从另外一家商店(如婺源网店 WuyuanShop)购物,就要将该顾客的代码修改如下:
1 | class Customer { |
顾客每更换一家商店,都要修改一次代码,这明显违背了开闭原则。存在以上缺点的原因是:顾客类设计时同具体的商店类绑定了,这违背了依赖倒置原则。解决方法是:定义“婺源网店”和“韶关网店”的共同接口 Shop,顾客类面向该接口编程,其代码修改如下:
1 | class Customer { |
这样,不管顾客类 Customer 访问什么商店,或者增加新的商店,都不需要修改原有代码了
3.3.5 好莱坞原则
依赖倒置原则,延伸出一个好莱坞原则。
别调用(打电话给)我们,我们会调用(打电话给)你。
在好莱坞,演员把简历递交给演艺公司后就只有回家等待。由演艺公司(高层)对整个娱乐项的完全控制,演员(底层)只能被动式的接受公司的差使,在需要的环节中,完成自己的演出。
好莱坞原则可以给我们一种防止“依赖腐败”的方法。当高层组件依赖低层组件,而低层组件又依赖高层组件,而高层组件又依赖边侧组件,而边侧组件又依赖低层组件时,依赖腐败就发生了。在这种情况下,没有人可以轻易地搞懂系统是如何设计的。
在好莱坞原则之下,我们允许低层组件将自己挂钩到系统上,但是高层组件会决定什么时候和怎样使用这些低层组件。换句话说,高层组件对待低层组件的方式是“别调用我们,我们会调用你”。尽量避免向上调用和相互调用。
- 低层组件可以参与计算,但是高层组件控制何时以及如何让底层组件参与
- 低层组件绝对不可以直接调用高阶组件
代表:模板方法模式
- 由超类主控一切,当它们需要的时候,自然会去调用子类 — Header First设计模式
- 因为要重写父类的方法,为了不违背里氏替换原则,那就是将要重写的父类中的方法声明为抽象方法。
个人疑问:超类相比于子类,算是高层组件么?
Header First设计模式中:所谓“高层组件”,是由其他低层组件定义其行为的类。
3.4 单一职责原则(SRP)
3.4.1 定义
单一职责原则(Single Responsibility Principle,SRP)又称单一功能原则,由罗伯特·C.马丁 于《敏捷软件开发:原则、模式和实践》一书中提出的。
单一职责原则规定一个类应该只有一个引起它变化的原因,否则类应该被拆分。
职责、改变的联系?类的每个责任都有改变的潜在区域。超过一个责任,就意味着超过一个改变的区域。
比如我们设计一个类不但要管理某种聚合,还要负责相关的操作和遍历。那么如果这个集合改变,这个类也必须改变;如果我们遍历的方式改变的话,这个类也必须跟着改变。
该原则提出对象不应该承担太多职责,如果一个对象承担了太多的职责,至少存在以下两个缺点:
- 一个职责的变化可能会削弱或者抑制这个类实现其他职责的能力;
- 当客户端需要该对象的某一个职责时,不得不将其他不需要的职责全都包含进来,从而造成冗余代码或代码的浪费。
这个原则就在告诉我们,尽量让每个类保持单一责任。没错,这听起来很容易,但做起来并不简单:区分设计中的责任,是最困难的事情之一。我们的大脑很习惯看着一大群的行为,然后将它们集中化一起,尽管他们可能属于两个或多个不同的责任。想要成功的唯一方法,就是努力不懈地检查你的设计,随件系统的成长,随时观察有没有迹象显示某个类改变的原因超出一个。
注意:单一职责同样也适用于方法。一个方法应该尽可能做好一件事情。如果一个方法处理的事情太多,其颗粒度会变得很粗,不利于重用。
3.4.2 作用
单一职责原则的核心就是控制类的粒度大小、将对象解耦、提高其内聚性。如果遵循单一职责原则将有以下优点。
- 降低类的复杂度。一个类只负责一项职责,其逻辑肯定要比负责多项职责简单得多。
- 提高类的可读性。复杂性降低,自然其可读性会提高。
- 提高系统的可维护性。可读性提高,那自然更容易维护了。
- 变更引起的风险降低。变更是必然的,如果单一职责原则遵守得好,当修改一个功能时,可以显著降低对其他功能的影响。
3.4.3 实现
单一职责原则是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,再封装到不同的类或模块中。而发现类的多重职责需要设计人员具有较强的分析设计能力和相关重构经验。
代表:迭代器模式
3.4.4 举例
下面以大学学生工作管理程序为例介绍单一职责原则的应用。
析:大学学生工作主要包括学生生活辅导和学生学业指导两个方面的工作,其中生活辅导主要包括班委建设、出勤统计、心理辅导、费用催缴、班级管理等工作,学业指导主要包括专业引导、学习辅导、科研指导、学习总结等工作。如果将这些工作交给一位老师负责显然不合理,正确的做法是生活辅导由辅导员负责,学业指导由学业导师负责。
3.5 接口隔离原则(ISP)
3.5.1 定义
接口隔离原则(Interface Segregation Principle,ISP)要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法。
2002 年罗伯特·C.马丁给“接口隔离原则”的定义是:客户端不应该被迫依赖于它不使用的方法。
该原则还有另外一个定义:一个类对另一个类的依赖应该建立在最小的接口上。
以上两个定义的含义是:要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大(臃肿)的接口供所有依赖它的类去调用。
接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:
- 单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离。
- 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。
3.5.2 作用
接口隔离原则是为了约束接口、降低类对接口的依赖性,遵循接口隔离原则有以下 5 个优点。
- 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
- 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
- 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
- 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
- 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
3.5.3 实现
在具体应用接口隔离原则时,应该根据以下几个规则来衡量。
- 接口尽量小,但是要有限度。一个接口只服务于一个子模块或业务逻辑。
- 为依赖接口的类定制服务。只提供调用者需要的方法,屏蔽不需要的方法。
- 了解环境,拒绝盲从。每个项目或产品都有选定的环境因素,环境不同,接口拆分的标准就不同深入了解业务逻辑。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
3.5.4 举例
下面以学生成绩管理程序为例介绍接口隔离原则的应用。
分析:学生成绩管理程序一般包含插入成绩、删除成绩、修改成绩、计算总分、计算均分、打印成绩信息、査询成绩信息等功能,如果将这些功能全部放到一个接口中显然不太合理,正确的做法是将它们分别放在输入模块、统计模块和打印模块等 3 个模块中。
3.6 迪米特法则(LoD)
3.6.1 定义
迪米特法则(Law of Demeter,LoD)又叫作最少知识原则(Least Knowledge Principle,LKP),产生于 1987 年美国东北大学的一个名为迪米特的研究项目,由伊恩·荷兰提出,被 UML 创始者之一的布奇普及,后来又因为在经典著作《程序员修炼之道》提及而广为人知。
迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话。
- 这个原则告诉我们要减少对象之间的交互,一个对象应当对其他对象有尽可能少的了解,只留下几个“密友”。
- 如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用。如果其中一个类需要调用另一个类的某一个方法的话,可以通过第三方转发该调用。
- “朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
其目的是降低类之间的耦合度,提高模块的相对独立性。
这个原则希望我们在设计中,不要让太多的类耦合在一起,免得修改系统中一部分,会影响到其他部分。如果许多类之间相互依赖,那么这个系统就会变成一个易碎的系统,它需要话很多成本维护,也会以为太复杂而不容易被其他人了解。
3.6.2 作用
迪米特法则要求限制软件实体之间通信的宽度和深度,正确使用迪米特法则将有以下两个优点。
- 降低了类之间的耦合度,提高了模块的相对独立性。
- 由于亲合度降低,从而提高了类的可复用率和系统的扩展性。
但是,过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在釆用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰。
3.6.3 实现
从迪米特法则的定义和特点可知,它强调以下两点:
- 从依赖者的角度来说,只依赖应该依赖的对象。
- 从被依赖者的角度说,只暴露应该暴露的方法。
所以,在运用迪米特法则时要注意以下 6 点。
- 在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
- 在类的结构设计上,尽量降低类成员的访问权限。
- 在类的设计上,优先考虑将一个类设置成不变类。
- 在对其他类的引用上,将引用其他对象的次数降到最低。
- 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
- 谨慎使用序列化(Serializable)功能。
代表:外观模式、中介者模式
3.6.4 举例
明星与经纪人的关系实例。
分析:明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如与粉丝的见面会,与媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则。
3.7 合成复用原则(CRP)
3.7.1 定义
“有一个”比”是一个”更好。(有些地方不将其列入设计原则,太过具体)
合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)。它要求在软件复用时:
- 要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
- 如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。
3.7.2 作用
通常类的复用分为继承复用和合成复用两种,继承复用虽然有简单和易实现的优点,但它也存在以下缺点。
- 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,所以这种复用又称为“白箱”复用。
- 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
- 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点。
- 它维持了类的封装性。因为成员对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
- 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成员对象的唯一方法是通过成员对象的接口。
- 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的对象。
3.7.3 实现
合成复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。
代表:策略模式。
3.7.4 举例
下面以汽车分类管理程序为例来介绍合成复用原则的应用。
分析:汽车按“动力源”划分可分为汽油汽车、电动汽车等;按“颜色”划分可分为白色汽车、黑色汽车和红色汽车等。如果同时考虑这两种分类,其组合就很多。下图就是用继承关系实现的汽车分类的类图。
可以看出用继承关系实现会产生很多子类,而且增加新的“动力源”或者增加新的“颜色”都要修改源代码,这违背了开闭原则,显然不可取。但如果改用组合关系实现就能很好地解决以上问题,其类图如下所示。
3.8 小结
这 7 种设计原则是软件设计模式必须尽量遵循的原则,是设计模式的基础。在实际开发过程中,并不是一定要求所有代码都遵循设计原则,而是要综合考虑人力、时间、成本、质量,不刻意追求完美,要在适当的场景遵循设计原则。这体现的是一种平衡取舍,可以帮助我们设计出更加优雅的代码结构。
各种原则要求的侧重点不同,下面我们分别用一句话归纳总结软件设计模式的七大原则,如下表所示。
设计原则 |
一句话归纳 | 目的 |
---|---|---|
开闭原则 | 对扩展开放,对修改关闭 | 降低维护带来的新风险 |
里氏替换原则 | 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义 | 防止继承泛滥 |
依赖倒置原则 | 高层不应该依赖低层,要面向接口编程 | 更利于代码结构的升级扩展 |
单一职责原则 | 一个类只干一件事,实现类要单一 | 便于理解,提高代码的可读性 |
接口隔离原则 | 一个接口只干一件事,接口要精简单一 | 功能解耦,高聚合、低耦合 |
迪米特法则 | 不该知道的不要知道,一个类应该保持对其它对象最少的了解,降低耦合度 | 只和朋友交流,不和陌生人说话,减少代码臃肿 |
合成复用原则 | 尽量使用组合或者聚合关系实现代码复用,少使用继承 | 降低代码耦合 |
实际上,这些原则的目的只有一个:降低对象之间的耦合,增加程序的可复用性、可扩展性和可维护性。
四、设计模式分类
根据设计模式之间的共性(功能、针对的角色等角度),可以把设计模式分为几类,常见的有两种分类:
了解分类有利于我们理解、思考、比较、选型。
4.1 根据作用范围分类
根据模式主要是作用在类上,还是对象上,或者说所处理的是类还是对象,可以分为:
- 类模式:用于处理类与子类之间的关系,这些关系通过继承来建立,是静态的,在编译时刻便确定下来了。
- 即在类模式类图中,一般只存在继承和实现、依赖。
- 对象模式:用于处理对象之间的关系,这些关系可以通过关联、组合或聚合来实现,在运行时刻是可以变化的,更具动态性。
- 即在对象模式类图中,会存在关联、聚合、组合关系。
- 换句话说,只要存在对象的聚合或组合关系(对象存在关联关系),就叫做对象创建模式
根据“合成复用原则”,系统中要尽量使用关联关系来取代继承关系,因此大部分设计模式都属于对象型设计模式。
4.2 根据功能分类
4.2.1 创建型模式
- 类创建型模式:处理类的创建。
- 对象创建型模式:处理对象的创建。
详细地说,对象创建型模式把对象的部分创建的工作推迟到另一个对象中,而类创建型模式将它推迟到子类中。
除了工厂模式是类创建模式,其它都是对象创建型模式。
疑问:抽象工厂中看着没有涉及对象关联关系,为什么是对象创建型模式呢?
在某处看到另一种解读(正确性不确定…):
类创建型模式:只需要知道创建产品的类或类层次结构,就可以创建出相应的对象。调用者只需要看到创建对象的类,而不需要知道自己将会得到什么样的一个对象。在这里体现出了,调用者要知道创建对象的类。
对象创建型模式:调用者通过一个函数来创建对象,所以调用者必须知道自己将要得到什么样的对象,只要知道将要得到对象的特征就行了,将其传给工厂方法来获得要的对象。在这里体现出了,调用者要知道对象的特征。
4.2.2 结构型模式
- 类结构型模式:关心类的组合,由多个类可以组合成一个更大的系统,在类结构型模式中一般只存在继承关系和实现关系。
- 对象结构型模式:关心类与对象的组合,通过关联关系使得在一个类中定义另一个类的实例对象,然后通过该对象调用其方法。
除了Adapter(类)模式是类结构型模式,其它包括Adapter(对象)模式等都是对象结构型模式。
4.2.3 行为型模式
- 类行为型模式:主要通过继承、多态等方式来分配父类与子类的职责。
- 对象行为型模式:主要是通过对象关联等方式来分配两个或多个类的职责。一些行为对象模式描述了一组对等的对象怎样相互协作以完成其中任何一个对象都无法单独完成的任务。
除了解释器、模板方法模式是类行为型模式,其它都是对象行为型模式。
五、创建型模式(怎么创建对象)
概述
创建型模式(Creational Pattern)对类的实例化过程进行了抽象。通俗的说,就是用于描述“怎样创建对象”,它的主要特点是“将对象的创建与使用分离”。
为了使软件的结构更加清晰,外界对于这些对象只需要知道它们共同的接口,而不清楚其具体的实现细节,使整个系统的设计更加符合单一职责原则。
- 创建型模式在创建什么(What),由谁创建(Who),何时创建(When)等方面都为软件设计者提供了尽可能大的灵活性。创建型模式隐藏了类的实例的创建细节,通过隐藏对象如何被创建和组合在一起达到使整个系统独立的目的。
- 创建型模式都会将关于该系统使用哪些具体的类的信息封装起来。允许客户用结构和功能差别很大的‘产品’对象配置一个系统。配置可以是静态的,即在编译时指定,也可以是动态的,就是运行时指定。
- 通常应该是工厂方法开始,当设计者设计者发现需要更大的灵活性时,设计便会向其他创建型模式演化。当设计者在设计标准之间进行权衡的时候,了解多个创建型模式可以给设计者更多的选择余地。
5.1 简单工厂模式(生产多个类对象)
简而言之:一个工厂类 负责创建 多个具体类的对象(是同一父类)。 (因为太简单了?不计入23种设计模式之列)
定义
在简单工厂模式中创建实例的方法通常为静态(static)方法,所以简单工厂模式(Simple Factory Pattern)又称为静态工厂方法(Static Factory Method)模式,它属于类创建型模式。
在简单工厂模式中,可以根据参数的不同返回不同类的实例。简单工厂模式专门定义一个类来负责创建其他类的实例,被创建的实例通常都具有共同的父类。
结构
简单工厂模式包含如下角色:
- 工厂角色(Factory):负责实现创建所有实例的内部逻辑;工厂类的创建产品类的方法可以被外界直接调用,创建所需的产品对象。
- 抽象产品(Product):是所创建的所有对象的父类,负责描述所有实例所共有的公共接口;
- 具体产品(ConcreteProduct):是具体类,其实例也就是我们的创建目标。
1 | Product* Factory::createProduct(string proname){ |
优缺点
简单工厂模式的要点在于:当你需要什么,只需要传入一个正确的参数,就可以获取你所需要的对象,而无须知道其创建细节。
优点:
- 将对象的创建和对象本身业务处理分离可以降低系统的耦合度,使得两者修改起来都相对容易。
- 在调用工厂类的工厂方法时,由于工厂方法是静态方法,使用起来很方便,可通过类名直接调用,而且只需要传入一个简单的参数即可,可以减少使用者的记忆量。
- 在实际开发中,还可以在调用时将所传入的参数保存在XML等格式的配置文件中,修改参数时无须修改任何源代码。
缺点:
- 简单工厂模式最大的问题在于工厂类的职责相对过重,在产品类型较多时,有可能造成工厂逻辑过于复杂,不利于系统的扩展和维护。违背高聚合原则。
- 当增加新的产品时,需要修改工厂类的判断逻辑,这一点与开闭原则是相违背的。
- 使用简单工厂模式将会增加系统中类的个数,在一定程序上增加了系统的复杂度和理解难度。
- 简单工厂模式由于使用了静态工厂方法,造成工厂角色无法形成基于继承的等级结构。
适用场景
- 工厂类负责创建的对象比较少:由于创建的对象较少,不会造成工厂方法中的业务逻辑太过复杂。
- 客户端只知道传入工厂类的参数,对于如何创建对象不关心:客户端既不需要关心创建细节,甚至连类名都不需要记住,只需要知道类型所对应的参数。
5.2 工厂方法模式(生产单个类对象)
简而言之,一个工厂子类 负责创建 一个具体类的对象。
定义
工厂方法模式(Factory Method Pattern)又称为工厂模式,也叫虚拟构造器(Virtual Constructor)模式或者多态工厂(Polymorphic Factory)模式,它属于类创建型模式。
在工厂方法模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象,这样做的目的是将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。
当出现新的具体类时,只需要为其新创建一个具体的工厂类就可以获得该新类的实例,这一特点无疑使得工厂方法模式具有超越简单工厂模式的优越性,更加符合“开闭原则”。
结构
工厂方法模式包含如下角色:
- Product:抽象产品
- ConcreteProduct:具体产品
- Factory:抽象工厂
- ConcreteFactory:具体工厂
具体工厂同具体产品之间是一对一的关系。
1 | int main(int argc, char *argv[]) { |
优缺点
在工厂方法模式中,核心的工厂类不再负责所有产品的创建,而是将具体创建工作交给子类去做。这个核心类仅仅负责给出具体工厂必须实现的接口,而不负责哪一个产品类被实例化这种细节,这使得工厂方法模式可以允许系统在不修改工厂角色的情况下引进新产品。
优点:
- 用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程,甚至无须知道具体产品类的类名。
- 灵活性增强,对于新产品的创建,无须修改原来代码,只要添加一个具体工厂和具体产品就可以了。这样,系统的可扩展性也就变得非常好,完全符合“开闭原则”。
- 典型的解耦框架。高层模块只需要知道产品的抽象类,无须关心其他实现类,满足迪米特法则、依赖倒置原则和里氏替换原则。
缺点:
- 类的个数容易过多,增加系统的复杂度、编译开销。
- 由于考虑到系统的可扩展性,需要引入抽象层,在客户端代码中均使用抽象层进行定义,增加了系统的抽象性和理解难度。
- 抽象产品只能生产一种产品,此弊端可使用抽象工厂模式解决。
适用场景
- 客户只关心产品的品牌,不关心创建产品的细节。
- 客户只知道创建产品的工厂名,而不知道具体的产品名。
- 创建对象的任务由多个具体子工厂中的某一个完成,而抽象工厂类只提供创建产品的接口。
实例 — 日志记录器
某系统日志记录器要求支持多种日志记录方式,如文件记录、数据库记录等,且用户可以根据要求动态选择日志记录方式, 现使用工厂方法模式设计该系统。
5.3 抽象工厂模式(生产一个产品族)
简而言之,一个工厂子类 负责创建 一个产品族 (同一个工厂生产的,位于不同产品继承结构中的一组产品)
概念:产品等级结构、产品族
为了更清晰地理解工厂方法模式,需要先引入两个概念:
- 产品等级结构 :产品等级结构即产品的继承结构,如一个抽象类是电视机,其子类有海尔电视机、海信电视机、TCL电视机,则抽象电视机与具体品牌的电视机之间构成了一个产品等级结构。
- 产品族 :在抽象工厂模式中,产品族是指由同一个工厂生产的,位于不同产品等级结构中的一组产品。
- 商业中,产品族一般是指同一家公司以同一品牌生产的一组相关产品。一个公司可能会创建一个产品系列来利用现有客户对其原有品牌的忠诚度。
如海尔电器工厂生产的海尔电视机、海尔电冰箱,这是一个产品族。
而海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中。
族:事物有共同属性的一大类;种族(共同起源)、民族(共同语言、文化)、宗族(共同血缘)、家族(同姓)。
簇:相当于“丛”,密集的或长在一块儿但不粘在一起的一丛
族,某些场景,又称簇(family) ?
也可指具有相同或相似的功能结构或性能,共享主要的产品特征、组件或者子结构,并通过变型配置来满足特定市场的一组产品的聚类。
类簇:类簇是Foundation框架中广泛使用的设计模式。类簇将一些私有的、具体的子类组合在一个公共的、抽象的超类下面,以这种方法来组织类可以简化一个面向对象框架的公开架构,而又不减少功能的丰富性。
定义
抽象工厂(AbstractFactory)模式的定义:是一种为访问类提供一个创建一组相关或相互依赖对象的接口,且访问类无须指定所要产品的具体类就能得到同族的不同等级的产品的模式结构。
抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可生产多个等级的产品。
结构
标准结构
抽象工厂模式包含如下角色:
- 抽象工厂(Abstract Factory):包含多个创建产品的方法,可以创建多个不同等级的产品。
- 具体工厂(Concrete Factory)
- 抽象产品(Abstract Product):抽象工厂模式有多个抽象产品。
- 具体产品(ConcreteProduct)
具体工厂同具体产品之间是一对多的关系。
1 | int main(int argc, char *argv[]) { |
退化的工厂
当系统中只存在一个等级结构的产品时,抽象工厂模式将退化到工厂方法模式。
当工厂方法模式中抽象工厂与具体工厂合并,提供一个统一的工厂来创建产品对象,并将创建对象的工厂方法设计为静态方法时,工厂方法模式退化成简单工厂模式。
优缺点(开闭原则的倾斜性)
抽象工厂模式除了具有工厂方法模式的优点外,其他主要优点如下。
- 可以在类的内部对产品族中相关联的多等级产品共同管理,而不必专门引入多个新的类来进行管理。
- 当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用同一个产品族中的对象。这对一些需要根据当前环境来决定其行为的软件系统来说,是一种非常实用的设计模式。
- 抽象工厂增强了程序的可扩展性,当增加一个新的产品族时,不需要修改原代码,满足开闭原则。
其缺点是:抽象工厂模式的扩展有一定的“开闭原则”倾斜性。“开闭原则”要求系统对扩展开放,对修改封闭,通过扩展达到增强其功能的目的。而抽象工厂模式扩展时:
- 当增加一个新的产品族时只需增加一个新的具体工厂,不需要修改原代码,满足开闭原则。
- 当产品族中需要增加一个新种类的产品时,或者说增加一个新的产品等级时,所有的工厂类都需要进行修改,不满足开闭原则。
适用场景
使用抽象工厂模式一般要满足以下条件。
- 系统中有多个产品族,每个具体工厂创建同一族但属于不同等级结构的产品。
- 系统一次只可能消费其中某一族产品,即同族的产品一起使用。如有人只喜欢穿某一个品牌的衣服和鞋。
5.4 建造者/生成器模式(工厂模式拆成一步步)
简而言之:多个建造者子类 负责创建 同一个具体类对象。每个建造者子类 负责创建 不同的该类对象的配置
比如:建造者是在创建KFC套餐。抽象建造者规定要创建:主食+饮料。其中一个建造者子类创建的是汉堡+果汁;另一个建造者创建的是鸡肉卷+可乐。
在软件开发中,存在一些复杂对象,实例化时存在一些限制条件,如某些属性没有赋值则复杂对象不能作为一个完整的产品使用;有些属性的赋值必须按照某个顺序,一个属性没有赋值之前,另一个属性可能无法赋值等。
复杂对象相当于一辆有待建造的汽车,而对象的属性相当于汽车的部件,建造产品的过程就相当于组合部件的过程。由于组合部件的过程很复杂,因此,这些部件的组合过程往往被“外部化”到一个称作建造者的对象里,建造者返还给客户端的是一个已经建造完毕的完整产品对象,而用户无须关心该对象所包含的属性以及它们的组装方式,这就是建造者模式的模式动机。
定义
造者模式(Builder Pattern)是将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。
建造者模式是一步一步创建一个复杂的对象,它允许用户只通过指定复杂对象的类型和内容就可以构建它们,用户不需要知道内部的具体(产品的各项配置信息、构建步骤等)构建细节。
它将变与不变相分离,即产品的组成部分是不变的,但每一部分是可以灵活选择的。(成员变量们一样,值可以不一样)
建造者模式属于对象创建型模式。根据中文翻译的不同,建造者模式又可以称为生成器模式。
比较:与工厂模式的不同
建造者(Builder)模式和工厂模式的关注点不同:(但两者可以结合使用)
- 工厂方法模式更注重零部件的创建过程(一步生成);
- 建造者模式注重零部件的组装过程,它侧重于一步步构造一个复杂对象,返回一个完整的对象;
就是当工厂方法模式中,构建流程很复杂 且 各种具体产品都是相同的几个构建步骤时,此时,将原先一步(一个方法内)完成的构建,规定为几步完成,就成了建造者模式??
与抽象工厂模式的差别就更多了:
- 与抽象工厂模式相比, 建造者模式返回一个组装好的完整产品 ,而 抽象工厂模式返回一系列相关的产品,这些产品位于不同的产品等级结构,构成了一个产品族。
- 在抽象工厂模式中,客户端实例化工厂类,然后调用工厂方法获取所需产品对象,而在建造者模式中,客户端可以不直接调用建造者的相关方法,而是通过指挥者类来指导如何生成对象,包括对象的组装过程和建造步骤。
- 如果将抽象工厂模式看成 汽车配件生产工厂 ,生产一个产品族的产品,那么建造者模式就是一个 汽车组装工厂 ,通过对部件的组装可以返回一辆完整的汽车。
结构
标准结构
建造者模式包含如下角色:
- 指挥者(Director):它调用建造者对象中的部件构造与装配方法完成复杂对象的创建,在指挥者中不涉及具体产品的信息。
- 抽象建造者(Builder):它是一个包含创建产品各个子部件的抽象方法的接口,通常还包含一个返回复杂产品的方法 getResult()。
- 具体建造者(Concrete Builder):实现 Builder 接口,完成复杂产品的各个部件的具体创建方法。一个具体建造者会创建一种不同的产品对象(组成部分相同,数据不同)。
- 产品角色(Product):它是包含多个组成部件的复杂对象,由具体建造者来创建其各个零部件。
具体建造者同具体产品之间是多对一的关系(一个具体产品有多种建造方式)。
1 | class Product { |
建造者模式的结构中还引入了一个指挥者类Director,该类的作用主要有两个:
- 隔离了客户与生产过程;
- 负责控制产品的生成过程。
指挥者针对抽象建造者编程,客户端只需要知道具体建造者的类型,即可通过指挥者类调用建造者的相关方法,返回一个完整的产品对象。
简化结构
- 省略抽象建造者角色:如果系统中只需要一个具体建造者的话,可以省略掉抽象建造者。
- 省略指挥者角色:在具体建造者只有一个的情况下,如果抽象建造者角色已经被省略掉,那么还可以省略指挥者角色,让Builder角色扮演指挥者与建造者双重角色。
优缺点
优点:
- 封装性好,构建和表示分离。在建造者模式中, 客户端不必知道产品内部组成的细节,将产品本身与产品的创建过程解耦,使得相同的创建过程可以创建不同的产品对象。
- 扩展性好,符合“开闭原则”:
- 各个具体的建造者相互独立,有利于系统的解耦。可以很方便地替换具体建造者或增加新的具体建造者, 使用不同的具体建造者即可创建不同的产品对象。
- 指挥者类针对抽象建造者类编程,增加新的具体建造者无须修改原有类库的代码。
- 可以更加精细地控制产品的创建过程 。建造者可以对创建过程逐步细化,而不对其它模块产生任何影响,便于控制细节风险。
缺点:
- 产品的组成部分必须相同,这限制了其使用范围。
- 如果产品内部复杂,可能会导致需要定义很多具体建造者类来实现这种变化,导致系统变得很庞大。
- 如果产品内部发生变化,则建造者也要同步修改,后期维护成本较大。
适用场景
在以下情况下可以使用建造者模式:
- 需要生成的产品对象有复杂的内部结构,这些产品对象通常包含多个成员属性。
- 需要生成的产品对象的属性相互依赖,需要指定其生成顺序。
- 对象的创建过程独立于创建该对象的类。在建造者模式中引入了指挥者类,将创建过程封装在指挥者类中,而不在建造者类中。
- 隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品。
在很多游戏软件中,地图包括天空、地面、背景等组成部分,人物角色包括人体、服装、装备等组成部分,可以使用建造者模式对其进行设计,通过不同的具体建造者创建不同类型的地图或人物。
5.5 单例模式
在有些系统中,为了节省内存资源、保证数据内容的一致性,对某些类要求只能创建一个实例,这就是所谓的单例模式。
定义
单例模式(Singleton Pattern)是指一个类只有一个实例,且该类能自行创建这个实例并向整个系统提供使用的一种模式。
单例模式的要点有三个:
- 单例类只有一个实例对象;
- 该单例对象必须由单例类自行创建;
- 单例类对外提供一个访问该单例的全局访问点。
单例模式是一种对象创建型模式。单例模式又名单件模式或单态模式。
结构
单例模式包含如下角色:
- Singleton:单例
在单例模式的实现过程中,需要注意如下三点:
- 单例类的构造函数为私有,确保用户无法通过new关键字直接实例化它。
- 提供一个自身的静态私有成员变量;
- 提供一个公有的静态工厂方法。
优缺点
优点:
- 单例模式可以保证内存里只有一个实例,减少了内存上的开销。
- 对于一些需要频繁创建和销毁、实例化过程很占用资源的对象,单例模式无疑可以提高系统的性能。
- 单例模式设置全局访问点,所以它可以严格控制客户怎样以及何时访问它,可以优化和共享资源的访问。
- 扩展:允许可变数目的实例。我们可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。
缺点:
- 单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
- 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。
- 滥用单例将带来一些负面问题,如:
- 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
- 为了节省资源将数据库连接池对象设计为单例类,可能会导致共享连接池对象的程序过多而出现连接池溢出;
- 现在很多面向对象语言(如Java、C#)的运行环境都提供了自动垃圾回收的技术,因此,如果实例化的对象长时间不被利用,系统会认为它是垃圾,会自动销毁并回收资源,下次利用时又将重新实例化,这将导致对象状态的丢失。
适用场景
在以下情况下可以使用单例模式:
- 系统中要求一个类只有一个实例对象。比如:
- 业务限制:一个班中的班长、每个人的身份证号、系统中唯一的序列号生成器等。
- 系统限制:该类的实例,会占用过多的资源,只允许创建一个对象。
- 类对象,频繁实例化,又频繁被销毁的时候,如多线程的线程池、网络连接池等。
- 类对象,实例化时会占用较多的资源,或耗时较长,且经常使用。
- 客户调用类的单个实例只允许使用一个公共访问点,除了该公共访问点,不能通过其他途径访问该实例。
- 扩展:如果一个类可以有几个实例共存,就需要对单例模式进行改进,使之成为多例模式
5.6 原型模式(clone对象)
定义
原型(Prototype)模式的定义如下:用一个已经创建的实例作为原型,通过复制该原型对象来创建一个和原型相同或相似的新对象。在这里,原型实例指定了要创建的对象的种类。
用这种方式创建对象非常高效,根本无须知道对象创建的细节。
结构
原型模式包含以下主要角色。
- 抽象原型类:规定了具体原型对象必须实现的接口。
- 具体原型类:实现抽象原型类的 clone() 方法,它是可被复制的对象。
- 访问类:使用具体原型类中的 clone() 方法来复制新的对象。
原型模式的克隆分为浅克隆和深克隆。
- 浅克隆:创建一个新对象,新对象的属性和原来对象完全相同,对于非基本类型属性,仍指向原有属性所指向的对象的内存地址。
- 深克隆:创建一个新对象,属性中引用的其他对象也会被克隆,不再指向原有对象地址。
Java 中的 Object 类提供了浅克隆的 clone() 方法,具体原型类只要实现 Cloneable 接口就可实现对象的浅克隆,这里的 Cloneable 接口就是抽象原型类。
OC 中的 NSCopying 协议也指定了 copyWithZone() 方法,具体类需要遵循此协议,实现方法。
优缺点
优点:
- 在某些环境下,复制对象比创建一个新对象,性能上更加优良。
缺点:
- 需要为每一个类都配置一个 clone 方法
- clone 方法位于类的内部,当对已有类进行改造的时候,需要修改代码,违背了开闭原则。
- 当实现深克隆时,需要编写较为复杂的代码,而且当对象之间存在多重嵌套引用时,为了实现深克隆,每一层对象对应的类都必须支持深克隆,实现起来会比较麻烦。因此,深克隆、浅克隆需要运用得当。
适用场景
可以使用深克隆方式保存对象的状态,使用原型模式将对象复制一份,并将其状态保存起来,简化了创建对象的过程,以便在需要的时候使用(例如恢复到历史某一状态),可辅助实现撤销操作。
六、结构型模式(怎么组成更大的结构)
概述
结构型模式(Structural Pattern)用于描述如何将类或对象按某种布局组成更大的结构。就像搭积木,可以通过简单积木的组合形成复杂的、功能更为强大的结构。
或者说,结构型模式是描述怎样组装现有的类,设计他们的交互方式,从而达到实现一定的功能的目的。结构型模式包容了对很多问题的解决。例如:扩展性(外观、组成、代理、装饰)、封装性(适配器,桥接)。
如装饰、代理、外观、适配器、组合、桥接、享元等 7 种结构型模式。
6.1 装饰模式(功能扩展)
一般有两种方式可以实现给一个类或对象增加行为:
- 继承机制:子类在拥有父类方法的同时,还可以拥有自身方法。
- 关联机制:将一个类的对象嵌入另一个对象中,由另一个对象来决定是否调用嵌入对象的行为以便扩展自己的行为,我们称这个嵌入的对象为装饰器(Decorator)。(或者说装饰器对象包裹原始对象)
关联机制扩展功能相比继承的优点见下文。
定义
装饰器(Decorator)模式的定义:指在不改变现有对象结构的情况下,动态地给该对象增加一些职责(即增加其额外功能)的模式。
其别名也可以称为包装器(Wrapper),根据翻译的不同,装饰模式也有人称之为“油漆工模式”。
结构
标准结构
装饰器模式主要包含以下角色。
- 抽象构件类(Component):定义一个抽象接口,以规范准备接收附加责任的对象。
- 具体构件类(ConcreteComponent):实现抽象构件。将会通过装饰角色为其添加一些职责
- 抽象装饰类(Decorator):继承抽象构件,并包含具体构件的实例,可以通过其子类扩展具体构件的功能。
- 具体装饰类(ConcreteDecorator):实现抽象装饰的相关方法,并给具体构件对象添加附加的责任。
- 一个装饰类的接口必须与被装饰类的接口保持相同,对于客户端来说无论是装饰之前的对象还是装饰之后的对象都可以一致对待。
- 尽量保持具体构件类Component作为一个“轻”类,也就是说不要把太多的逻辑和状态放在具体构件类中,可以通过装饰类对其进行扩展。
简化结构
装饰器模式所包含的 4 个角色不是任何时候都要存在的,在有些应用环境下模式是可以简化的,如:
- 简化1:如果只有一个具体构件类而没有抽象构件类,那么抽象装饰类可以作为具体构件类的直接子类。
- 简化2:如果只有一个具体装饰时,可以将抽象装饰和具体装饰合并。
1 | package decorator; |
优缺点
装饰模式与继承关系的目的都是要扩展对象的功能。相比于继承,使用装饰器的好处:
- 与继承关系相比,关联关系的主要优势在于不会破坏类的封装性。
- 继承是一种耦合度较高的静态关系,无法在程序运行时动态扩展。即装饰模式可以提供比继承更多的灵活性。可以通过一种动态的方式来扩展一个对象的功能,通过配置文件可以在运行时选择不同的装饰器,从而实现不同的行为。
- 当扩展功能增多时,子类会很膨胀。而装饰器模式中,通过使用不同的具体装饰类、这些具体装饰类的排列组合,可以创造出很多不同行为的组合。
- 具体构件类与具体装饰类可以独立变化,用户可以根据需要增加新的具体构件类和具体装饰类,在使用时再对其进行组合,原有代码无须改变,符合“开闭原则”。
装饰模式的缺点:
- 使用装饰模式进行系统设计时将产生很多小对象,这些对象的区别在于它们之间相互连接的方式有所不同,而不是它们的类或者属性值有所不同,同时还将产生很多具体装饰类。这些装饰类和小对象的产生将增加系统的复杂度,加大学习与理解的难度。
- 这种比继承更加灵活机动的特性,也同时意味着装饰模式比继承更加易于出错,排错也很困难,对于多次装饰的对象,调试时寻找错误可能需要逐级排查,较为繁琐。
适用场景
- 在不影响其他对象的情况下,以动态、透明的方式给单个对象添加职责。
- 需要动态地给一个对象增加功能,这些功能也可以动态地被撤销。
- 当不能采用继承的方式对系统进行扩充或者采用继承不利于系统扩展和维护时。不能采用继承的情况主要有两类:
- 第一类是系统中存在大量独立的扩展,为支持每一种组合将产生大量的子类,使得子类数目呈爆炸性增长;
- 第二类是因为类定义不能继承(如final类).
6.2 代理模式(控制访问)
核心是控制访问。
定义
代理模式(Proxy Pattern) :给某对象提供一个代理(也叫替身、占位符)以控制对该对象的访问。这时,访问对象不适合或者不能直接引用目标对象,代理对象作为访问对象和目标对象之间的中介。
疑问:从定义来看,代理模式的核心是控制对目标对象的访问,类似服务器中的反向代理(安全防护、负载均衡等)?那正向代理(扩展目标对象的功能,如VPN软件等)算不算代理模式?如果不属于,那正向代理这种对应到软件开发中,应该属于什么模式呢?
结构
代理模式包含如下角色:
- Subject: 抽象主题角色。声明真实主题和代理对象实现的方法。
- Proxy: 代理主题角色。提供了与真实主题相同的接口,其内部含有对真实主题的引用。控制外部对真实主题的访问。
- RealSubject: 真实主题角色
1 | Proxy::Proxy(){ |
在代码中,一般代理会被理解为代码增强,实际上就是在原代码逻辑前后增加一些代码逻辑,而使调用者无感知。
在Java中,根据代理的创建时期,代理模式分为静态代理和动态代理。
- 静态:由程序员创建代理类或特定工具自动生成源代码再对其编译,在程序运行前代理类的 .class 文件就已经存在了。
- 动态:利用反射机制在运行时创建代理类。
优缺点
优点:
- 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度,增加了程序的可扩展性。
缺点:
- 代理模式会造成系统设计中类的数量增加,增加了系统的复杂度;
- 由于在客户端和真实主题之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢;
适用场景
根据代理模式的使用目的,常见的代理模式有以下几种类型:
- 图片代理:对大图浏览的控制。用户通过浏览器访问网页时先不加载真实的大图,而是先加载一个小图片,然后在后台使用另一个线程来调用大图片的加载方法将大图片加载到客户端。当需要浏览大图片时,再将大图片在新网页中显示。
- 远程(Remote)代理:为一个位于不同的地址空间的对象提供一个本地的代理对象,这个不同的地址空间可以是在同一台主机中,也可是在另一台主机中,远程代理又叫做大使(Ambassador)。
- 虚拟(Virtual)代理:如果需要创建一个资源消耗较大的对象,先创建一个消耗相对较小的对象来表示,真实对象只在需要时才会被真正创建。
- Copy-on-Write代理:它是虚拟代理的一种,把复制(克隆)操作延迟到只有在客户端真正需要时才执行。一般来说,对象的深克隆是一个开销较大的操作,Copy-on-Write代理可以让这个操作延迟,只有对象被用到的时候才被克隆。
- 保护(Protect or Access)代理:控制对一个对象的访问,可以给不同的用户提供不同级别的使用权限。
- 缓冲(Cache)代理:为某一个目标操作的结果提供临时的存储空间,以便多个客户端可以共享这些结果。
- 防火墙(Firewall)代理:保护目标不让恶意用户接近。
- 同步化(Synchronization)代理:使几个用户能够同时使用一个对象而没有冲突。
- 智能引用(Smart Reference)代理:当一个对象被引用时,提供一些额外的操作,如将此对象被调用的次数记录下来等。
6.3 外观模式(功能包装)
根据“单一职责原则”,在软件中将一个系统划分为若干个子系统有利于降低整个系统的复杂性,一个常见的设计目标是使子系统间的通信和相互依赖关系达到最小,而达到该目标的途径之一就是引入一个外观对象,它为子系统的访问提供了一个简单而单一的入口。
定义
外观模式(Facade Pattern):为子系统中的一组接口提供一个一致的界面。即外部与该子系统的通信必须通过一个统一的外观对象(高层接口)进行,这个高层接口使得这一子系统更加容易使用。
通俗点说:我们创建一个接口简化而统一的类,用来包装子系统中一个或多个复杂的类。
外观模式又称为门面模式,它是一种对象结构型模式。
在日常编码工作中,我们都在有意无意的大量使用外观模式。只要是高层模块需要调度2个以上的类对象,我们经常都会自觉地创建一个新的类封装这些类功能,提供精简的接口,让高层模块可以更加容易地使用子系统的功能。尤其是现阶段各种第三方SDK、开源类库,很大概率都会使用外观模式。
结构
标准结构
外观模式包含如下角色:
- 外观(Facade)角色:为子系统对外提供一个统一的接口。将客户的请求代理给适当的子系统中的类。
- 子系统(Sub System)角色:实现系统的部分功能,客户可以通过外观角色访问它。注意:子系统中的类是没有Facade的任何信息的,即没有对Facade对象的引用。
注意点:
- 一般将外观类设计为单例类。
- 一个系统中可以设计多个外观类,每个外观类都负责和一些特定的子系统交互,向用户提供相应的业务功能。
- 不要试图通过外观类为子系统增加新行为。
- 不要通过继承一个外观类在子系统中加入新的行为,这种做法是错误的。外观模式的用意是为子系统提供一个集中化和简化的沟通渠道,而不是向子系统加入新的行为,新的行为的增加应该通过修改原有子系统类或增加新的子系统类来实现,不能通过外观类来实现。
- 外观模式与迪米特法则
- 外观模式创造出一个外观对象,将客户端所涉及的属于一个子系统的协作伙伴的数量减到最少,使得客户端与子系统内部的对象的相互作用被外观对象所取代。外观类充当了客户类与子系统类之间的“第三者”,降低了客户类与子系统类之间的耦合度,外观模式就是实现代码重构以便达到“迪米特法则”要求的一个强有力的武器。
1 |
|
扩展: 抽象外观类的引入
- 外观模式最大的缺点在于违背了“开闭原则”,当增加、移除子系统类时需要修改外观类,可以通过引入抽象外观类在一定程度上解决该问题,客户端针对抽象外观类进行编程。
- 对于新的业务需求,不修改原有外观类,而对应增加一个新的具体外观类,由新的具体外观类来关联新的子系统类对象。
- 同时可以通过修改配置文件来达到不修改源代码并更换外观类的目的。
优缺点
优点:
- 降低了子系统与客户端之间的耦合度,使得子系统的变化不会影响调用它的客户类。
- 对客户屏蔽了子系统组件,减少了客户处理的对象数目,并使得子系统使用起来更加容易。
- 降低了大型软件系统中的编译依赖性,简化了系统在不同平台之间的移植过程,因为编译一个子系统不会影响其他的子系统,也不会影响外观对象。
缺点:
- 不能很好地限制客户使用子系统中的类,如果对客户访问子系统中的类做太多的限制则减少了可变性和灵活性。
- 在不引入抽象外观类的情况下,当增加或移除子系统中的类时可能需要修改外观类或客户端的源代码,违背了“开闭原则”。
适用场景
在以下情况下可以使用外观模式:
- 当要为一个复杂子系统提供一个简单接口时可以使用外观模式。该接口可以满足大多数用户的需求,而且用户也可以越过外观类直接访问子系统。
- 当一个子系统中多个类与客户程序、其他子系统之间存在依赖性。引入外观类将子系统与客户以及其他子系统解耦,可以提高子系统的独立性和可移植性。
- 在层次化结构(分层结构)中,可以使用外观模式定义系统中每一层的入口,层与层之间不直接产生联系,而通过外观类建立联系,降低层之间的耦合度。
6.4 适配器模式(API转换)
在现实生活中,经常出现两个对象因接口不兼容而不能在一起工作的实例,这时需要第三者进行适配。例如,讲中文的人同讲英文的人对话时需要一个翻译,用直流电的笔记本电脑接交流电源时需要一个电源适配器,用计算机访问照相机的 SD 内存卡时需要一个读卡器等。
在软件设计中也可能出现:需要开发的具有某种业务功能的组件在现有的组件库中已经存在,但它们与当前系统的接口规范不兼容,这可能是因为现有类中使用到的方法名与目标类中定义的方法名不一致等原因所导致的。而如果重新开发这些组件成本又很高,这时用适配器模式能很好地解决这些问题。
定义
适配器模式(Adapter Pattern) :将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。
在适配器模式中可以定义一个包装类,包装不兼容接口的对象,这个包装类指的就是适配器(Adapter),它所包装的对象就是适配者(Adaptee),即被适配的类。
适配器提供客户类需要的接口,适配器的实现就是把客户类的请求转化为对适配者的相应接口的调用。也就是说:当客户类调用适配器的方法时,在适配器类的内部将调用适配者类的方法,而这个过程对客户类是透明的,客户类并不直接访问适配者类。
适配器模式既可以作为类结构型模式,也可以作为对象结构型模式。
结构
适配器模式包含如下角色:
- Target:目标抽象类。当前系统业务所期待的接口,它可以是抽象类或接口。
- Adaptee:适配者类。被访问和适配的现存组件库中的组件接口。
- Adapter:适配器类。它是一个转换器,把适配者类的接口转换成目标接口,让客户按目标接口的格式访问适配者。
- 对象适配器:引用适配者类的对象。
- 类适配器:继承适配者类。
适配器模式有对象适配器和类适配器两种实现:
对象适配器
类适配器
1 | package adapter; |
模式扩展
- 认适配器模式(Default Adapter Pattern)或缺省适配器模式:
- 当适配器不需要全部实现目标接口提供的方法时,可先设计一个抽象类实现接口,并为该接口中每个方法提供一个默认实现(空方法),那么该抽象类的子类可有选择地覆盖父类的某些方法来实现需求,它适用于一个接口不想使用其所有的方法的情况。因此也称为单接口适配器模式。
- 双向适配器模式:既可以把适配者接口转换成目标接口,也可以把目标接口转换成适配者接口。(额,想象不出来使用场景)
优缺点
优点:
- 将目标类和适配者类解耦,解决了目标类和适配者类接口不一致的问题。
- 增加了类的透明性,将具体的实现封装在适配者类中,对于客户端类来说是透明的。
- 提高了类的复用性,程序员不需要修改原有代码而重用现有的适配者类。
- 灵活性和扩展性都非常好,通过使用配置文件,可以很方便地更换适配器,也可以在不修改原有代码的基础上增加新的适配器类,完全符合“开闭原则”。
类适配器模式的独有优点:
- 由于适配器类是适配者类的子类,因此可以在类适配器中修改一些适配者的方法,使得类适配器的灵活性更强。
对象适配器模式的独有优点:
- 一个对象适配器可以把多个不同的适配者适配到同一个目标,也就是说,同一个对象适配器可以把适配者类和它的子类都适配到目标接口。
- 而类适配器模式的缺陷在于:对于Java、C#等不支持多继承的语言,一次最多只能适配一个适配者类。
缺点:
- 增加代码阅读难度,降低代码可读性,过多使用适配器会使系统代码变得凌乱。
适用场景
在以下情况下可以使用适配器模式:
系统需要使用现有的类,而这些类的接口不符合系统的需要。
修改一下使用的地方不就行了?有些场景无法修改,比如要使用该类的系统是面向多个模块的,不能单因为这个新的类就修改原系统中的代码。
想要建立一个可以重复使用的类,用于与一些彼此之间没有太大关联的一些类,包括一些可能在将来引进的类一起工作。
6.5 组合模式(组合和个体使用一致性)
个人:这个组合,好像跟对象之间的组合关系并不等价。这个组合是表示”整体-部分“的关系,对应对象之间的聚合、组合关系?
在现实生活中、软件开发中,存在很多“部分-整体”的关系,例如:
- 大学中的部门与学院;
- 总公司中的部门与分公司;
- 卖电脑的商家,可以卖配件,也可以卖组装整机;
- 文件系统中,复制文件,可以一个个文件复制粘贴,也可以整个文件夹复制粘贴;
- 窗体程序中,可以操作一个个简单控件,也可以同样的方式操作容器控件;
对这些简单对象与复合对象的处理,如果用组合模式来实现会很方便。
定义
将多个对象组合成树状结构,以表示“整体-部分”的层次结构。实现组合模式,可以使得用户对单个对象和组合对象的使用具有一致性。
即不管将要操作的是组合对象还是单个对象,我们都可以统一处理,如果是组合对象,它自己负责将操作分发到内部的所有单个对象上。
树状结构图一般如下:
根节点和树枝节点本质上属于同一种数据类型,可以作为容器使用;
叶子节点与树枝节点在语义上不属于用一种类型。
但是在组合模式中,会把树枝节点和叶子节点看作属于同一种数据类型(用统一接口定义),让它们具备一致行为。即在组合模式中,整个树形结构中的对象都属于同一种类型,带来的好处就是用户不需要辨别是树枝节点还是叶子节点,可以直接进行操作,给用户的使用带来极大的便利。
结构
组合模式包含以下主要角色。
- 抽象构件(Component)角色:为树叶构件和树枝构件声明公共接口,并实现它们的默认行为。
- 在透明式的组合模式中,还声明访问和管理子节点的接口;
- 在安全式的组合模式中,不声明访问和管理子节点的接口,管理工作由树枝构件自己完成。
- 树叶构件(Leaf)角色:没有子节点,继承或实现抽象构件。
- 树枝构件(Composite)角色 / 中间构件:有子节点,继承和实现抽象构件。它的主要作用是存储和管理子部件,通常包含 Add()、Remove()、GetChild() 等方法。
组合模式分为:
透明式的组合模式
该方式中,抽象构件声明了所有子类中的全部方法,所以客户端无须区别树叶对象和树枝对象,对客户端来说是透明的。但其缺点是:树叶构件本来没有 Add()、Remove() 及 GetChild() 方法,却要实现它们(空实现或抛异常),这样会带来一些安全性问题。
安全式的组合模式
该方式中,将管理子构件的方法移到树枝构件中,抽象构件和树叶构件没有对子对象的管理方法,这样就避免了上一种方式的安全性问题,但由于叶子和分支有不同的接口,客户端在调用时要知道树叶对象和树枝对象的存在,所以失去了透明性。
1 | // 透明式的组合模式的使用 |
扩展: 复杂的组合模式
如果对前面介绍的组合模式中的树叶节点和树枝节点进行抽象,也就是说树叶节点和树枝节点拥有不同的实现,这时组合模式就扩展成复杂的组合模式了,如 Java AWT/Swing 中的简单组件 JTextComponent 有子类 JTextField、JTextArea,容器组件 Container 也有子类 Window、Panel。
优缺点
优点:
- 组合模式使得客户端代码可以一致地处理单个对象和组合对象,无须关心自己处理的是单个对象,还是组合对象,这简化了客户端代码;
- 更容易在组合体内加入新的对象,客户端不会因为加入了新的对象而更改源代码,满足“开闭原则”;
缺点:
- 设计较复杂,客户端需要花更多时间理清类之间的层次关系;
- 不容易限制容器中的构件;
- 不容易用继承的方法来增加构件的新功能;
适用场景
- 在需要表示一个对象整体与部分的层次结构的场合。
- 要求对用户隐藏组合对象与单个对象的不同,用户可以用统一的接口使用组合结构中的所有对象的场合。
应用实例
用组合模式实现当用户在商店购物后,显示其所选商品信息,并计算所选商品总价的功能。
说明:假如李先生到韶关“天街e角”生活用品店购物:
- 用 1 个红色小袋子装了 2 包婺源特产(单价 7.9 元)、1 张婺源地图(单价 9.9 元);
- 用 1 个白色小袋子装了 2 包韶关香藉(单价 68 元)和 3 包韶关红茶(单价 180 元);
- 用 1 个中袋子装了前面的红色小袋子和 1 个景德镇瓷器(单价 380 元);
- 用 1 个大袋子装了前面的中袋子、白色小袋子和 1 双李宁牌运动鞋(单价 198 元)。
现在要求编程显示李先生放在大袋子中的所有商品信息并计算要支付的总价。
安全组合模式设计,其结构图如下图:
1 | package composite; |
6.6 桥接模式(接口与实现分离)
设想如果要绘制矩形、圆形、椭圆、正方形,我们至少需要4个形状类,但是如果绘制的图形需要具有不同的颜色,如红色、绿色、蓝色等,此时至少有如下两种设计方案:
- 第一种设计方案是为每一种形状都提供一套各种颜色的版本。
- 第二种设计方案是根据实际需要对形状和颜色进行组合
对于有两个变化维度(即两个变化的原因)的系统,采用方案二来进行设计系统中类的个数更少,且系统扩展更为方便。设计方案二即是桥接模式的应用。桥接模式将继承关系转换为关联关系,从而降低了类与类之间的耦合,减少了代码编写量。
定义
桥接模式(Bridge Pattern):将抽象部分与它的实现部分分离,使它们都可以独立地变化。它是一种对象结构型模式,又称为柄体(Handle and Body)模式或接口(Interface)模式。
抽象、实现部分?(先见1.7小节)
注意,这里提到的抽象部分、实现部分与编程语言中的接口或抽象类、实现类是不同的含义。这里的抽象部分是指接口(interface,或者界面):
- 抽象部分(也被称为接口)是一些实体的高阶控制层。该层自身不完成任何具体的工作,它需要将工作委派给实现部分层(也被称为平台)。—— 《深入设计模式》
- 《Head First设计模式》一书中也有场景,将抽象部分表示为一个系统对外暴露的接口。
举个例子,在实际的程序中,抽象部分可以是用户操作界面(比如GUI),而实现部分则是底层操作系统代码(API),GUI层调用API层来对用户的各种操作做出响应。
一般来说,你可以在两个独立方向上扩展这种应用:
- 开发多个不同的GUI(例如面向普通用户和管理员进行分别配置)
- 支持多个不同的API(例如,能够在Windows、Linux和macOS上运行该程序)。
抽象与实现分离
- ”将抽象部分与它的实现部分分离“,就是实现系统可能有多角度/维度分类,每一种分类都有可能变化,那么就把这种多角度/维度分离出来让它们独立变化,减少它们之间的耦合。—— 《大话设计模式》
- 桥接模式通过将实现和抽象放在两个不同的类层次中而使它们可以独立改变。这两个类层次之间的关系就叫桥接。——《Head First设计模式》
维度(Dimension),又称维数、量纲和次元,是描述对象状态所需的独立参数(数学)或系统自由度(物理)的数量。在物理学和数学中,数学空间的维数被非正式地定义为指定其中任何点所需的最小坐标数(1维是线,只需指定长度。2维是一个平面,需指定长度和宽度。3维是一个立体,需指定长度、宽度、高度)。
具体实现
桥接模式通过将继承改为组合的方式来解决这个问题。具体来说,就是抽取其中一个维度并使之成为独立的类层次,这样就可以在初始类中引用这个新层次的对象,从而使得一个类不必拥有所有的状态和行为。
结构
标准结构
桥接模式包含如下角色:
- Abstraction:抽象类
- RefinedAbstraction:扩充/精炼/精确抽象类
- Implementor:实现类接口
- ConcreteImplementor:具体实现类
桥接模式的一个常见使用场景就是替换继承。在一个软件系统的抽象化和实现化之间使用关联关系(组合或者聚合关系)而不是继承关系,从而使得二者可以独立地变化。
1 | // RefinedAbstraction.cpp |
扩展: 与适配器模式联用
桥接模式和适配器模式用于设计的不同阶段,桥接模式用于系统的初步设计,对于存在两个独立变化维度的类可以将其分为抽象化和实现化两个角色,使它们可以分别进行变化;而在初步设计完成之后,当发现系统与已有类无法协同工作时,可以采用适配器模式。但有时候在设计初期也需要考虑适配器模式,特别是那些涉及到大量第三方应用接口的情况。
优缺点
优点:
- 抽象与实现分离。提高了系统的可扩充性,在两个变化维度中任意扩展一个维度,都不需要修改原有系统。符合开闭原则。
- 桥接模式有时类似于多继承方案,但是多继承方案违背了类的单一职责原则(即一个类只有一个变化的原因),复用性比较差,而且多继承结构中类的个数非常庞大,桥接模式是比多继承方案更好的解决方法。
- 符合合成复用原则
- 实现细节对客户透明,可以对用户隐藏实现细节。
缺点是:
- 由于聚合关系建立在抽象层,要求开发者针对抽象化进行设计与编程。
- 要求能正确地识别出系统中两个独立变化的维度,这增加了系统的理解与设计难度。
适用场景
- 一个类存在两个独立变化的维度,且这两个维度都需要进行扩展。
- 当一个系统需要在构件的抽象化角色和具体化角色之间增加更多的灵活性时。
- 对于那些不希望使用继承、或不希望因为多层次继承导致系统类的个数急剧增加的系统,桥接模式尤为适用。
应用案例
案例1:
设备及其遥控器的架构设计。 设备 Device类作为实现部分, 而 遥控器 Remote类则作为抽象部分。
最初类层次结构被拆分为两个部分: 设备和遥控器。
案例2:
女士皮包有很多种,可以按用途分、按皮质分、按品牌分、按颜色分、按大小分等,存在多个维度的变化,所以采用桥接模式来实现女士皮包的选购比较合适。
本实例按用途分可选钱包(Wallet)和挎包(HandBag),按颜色分可选黄色(Yellow)和红色(Red)。可以按两个维度定义为颜色类和包类。
1 | public static void main(String[] args) { |
6.7 享元模式(对象复用池)
定义
享元模式(Flyweight Pattern):运用共享技术有效地支持大量细粒度对象的复用。
- 系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。
- 模式要求能够共享的对象必须是细粒度对象,因此它又称为蝇量模式、轻量级模式。
它是一种对象结构型模式。
通过享元模式,可以大幅度减少需要创建的对象数量,节约内存空间,提高系统的性能。
结构
享元模式的核心在于享元工厂类,享元工厂类的作用在于提供一个用于存储享元对象的享元池,用户需要对象时,首先从享元池中获取,如果享元池中不存在,则创建一个新的享元对象返回给用户,并在享元池中保存该新增对象。
内部状态、外部状态
享元模式以共享的方式高效地支持大量的细粒度对象,享元对象能做到共享的关键是区分内部状态(Internal State)和外部状态(External State)。
- 内部状态是存储在享元对象内部并且不会随环境改变而改变的状态,因此内部状态可以共享。
- 外部直接只能读取不能修改其数值。
- 外部状态是随环境改变而改变的、不可以共享的状态。即外部状态可以被“从外部”改变。
- 享元对象的外部状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候再传入到享元对象内部。
- 一个外部状态与另一个外部状态之间是相互独立的。
比如,连接池中的连接对象,保存在连接对象中的用户名、密码、连接URL等信息,在创建对象的时候就设置好了,不会随环境的改变而改变,这些为内部状态。而当每个连接要被回收利用时,我们需要将它标记为可用状态,这些为外部状态。
标准结构
享元模式包含如下角色:
- Flyweight: 抽象享元类。所有具体享元类的超类或接口,接口中定义的方法,使得Flyweight可以接收并操作传入享元对象中的外部状态。
- ConcreteFlyweight: 具体享元类。继承Flyweight超类或实现Flyweight接口,并为内部状态增加存储空间。
- UnsharedConcreteFlyweight: 非共享具体享元类。
- Flyweight接口只是使共享成为可能,但它并不强制共享。
- 尽管我们大部分情况下,都需要共享对象来降低内存的消耗。但个别情况下也有可能不需要共享的。
- FlyweightFactory: 享元工厂类。负责创建和管理享元角色。当客户对象请求一个享元对象时,享元工厂检査系统中是否存在符合要求的享元对象,如果存在则提供给客户;如果不存在的话,则创建一个新的享元对象。
1 | // 享元工厂 |
单纯享元模式
在单纯享元模式中,所有的享元对象都是可以共享的,即所有抽象享元类的子类都可共享,不存在非共享具体享元类。
复合享元模式
将一些单纯享元使用组合模式加以组合,可以形成复合享元对象,这样的复合享元对象本身不能共享,但是它们可以分解成单纯享元对象,而后者则可以共享。
扩展: 与其他模式的联用
- 在享元模式的享元工厂类中通常提供一个静态的工厂方法用于返回享元对象,使用简单工厂模式来生成享元对象。
- 在一个系统中,通常只有唯一一个享元工厂,因此享元工厂类可以使用单例模式进行设计。
- 享元模式可以结合组合模式形成复合享元模式,统一对享元对象设置外部状态。
优缺点
优点:
- 享元模式的优点在于它可以极大减少内存中对象的数量,使得相同对象或相似对象在内存中只保存一份。
- 享元模式的外部状态相对独立,而且不会影响其内部状态,从而使得享元对象可以在不同的环境中被共享。
缺点:
- 享元模式使得系统更加复杂,需要分离出内部状态和外部状态,这使得程序的逻辑复杂化。
- 为了使对象可以共享,享元模式需要将享元对象的状态外部化,而读取外部状态使得运行时间变长。
适用场景
在以下情况下可以使用享元模式:
- 一个系统有大量相同或者相似的对象,由于这类对象的大量使用,造成内存的大量耗费。
- 对象的大部分状态都可以外部化,可以将这些外部状态传入对象中。
- 使用享元模式需要维护一个存储享元对象的享元池,而这需要耗费资源,因此,应当在多次重复使用享元对象时才值得使用享元模式。
享元模式在编辑器软件中大量使用,如在一个文档中多次出现相同的图片,则只需要创建一个图片对象,通过在应用程序中设置该图片出现的位置,可以实现该图片在不同地方多次重复显示。
七、行为型模式(协作及职责分配)
概述
在系统运行时,对象并不是孤立的,它们可以通过相互通信与协作完成某些复杂功能,一个对象在运行时也将影响到其他对象的运行。
行为型模式(Behavioral Pattern)用于描述类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,以及怎样分配职责。
只要是行为型模式,都涉及到类和对象如何交互及分配职责。
- 算法和对象间职责的分配。即通过行为型模式,可以更加清晰地划分类与对象的职责。
- 运行时,实例对象之间的交互(或者说通信模式)。行为型模式刻画了在程序运行时难以跟踪的、复杂的控制流。
如策略、模板方法、观察者、状态、备忘录、迭代器、命令、职责链、中介者、解释器、访问者等 11 种行为型模式。
7.1 策略模式(拆分算法族到各策略子类)
完成一项任务,往往可以有多种不同的方式,每一种方式称为一个策略,我们可以根据环境或者条件的不同选择不同的策略来完成该项任务。
比如想要进行数据的查找、排序,而查找、排序的实现有很多种,在指定的场景下选用正确的算法,效果更优。
选择策略的形式 :
- 一种常用的方法是硬编码(Hard Coding)在一个类中,即在该类中提供多个方法,每一个方法对应一个具体的查找算法;
- 将这些查找算法封装在一个统一的方法中,通过if…else…等条件判断语句来进行选择。
这两种的缺点:如果需要增加一种新的查找算法,需要修改封装算法类的源代码;且在这个算法类中封装了大量查找算法,该类代码将较复杂,维护较为困难。违背开闭原则、单一职责原则。
- 除了提供专门的查找算法类之外,还可以在客户端程序中直接包含算法代码,这种做法更不可取,将导致客户端程序庞大而且难以维护,如果存在大量可供选择的算法时问题将变得更加严重。
- 定义一些独立的类来封装不同的算法,每一个类封装一个具体的算法,在这里,每一个封装算法的类我们都可以称之为策略(Strategy),为了保证这些策略的一致性,一般会用一个抽象的策略类来做算法的定义,而具体每种算法则对应于一个具体策略类。
定义
策略模式(Strategy Pattern):定义一系列算法(也称算法族),将每一种算法封装起来,并让它们可以相互替换。(每种算法实现对应一个抽象策略的子类。)
此模式让算法独立于使用它的客户而变化,即算法的变化不会影响到使用算法的客户。也称为政策模式(Policy)。
结构
策略模式包含如下角色:
- Context: 上下文类。持有一个策略类的引用,最终给客户端调用。
- Strategy: 抽象策略类。定义了一个公共接口,各种不同的算法以不同的方式实现这个接口,上下文类中使用这个接口调用不同的算法,一般使用接口或抽象类实现。
- ConcreteStrategy: 具体策略类。实现了抽象策略定义的接口,提供具体的算法实现。
在策略模式中,应当由客户端自己决定在什么情况下使用什么具体策略角色。
策略模式仅仅封装算法,提供新算法插入到已有系统中,以及老算法从系统中“退休”的方便,策略模式并不决定在何时使用何种算法,算法的选择由客户端来决定。这在一定程度上提高了系统的灵活性,但是客户端需要理解所有具体策略类之间的区别,以便选择合适的算法,这也是策略模式的缺点之一,在一定程度上增加了客户端的使用难度。
1 | int main(int argc, char *argv[]) { |
优缺点
优点
- 使用策略模式可以避免使用多重条件转移语句,如 if…else 语句、switch…case 语句。
- 策略模式提供了对“开闭原则”的完美支持,用户可以在不修改原有系统的基础上选择算法或行为,也可以灵活地增加新的算法或行为。
- 策略模式提供了管理相关的算法族的办法:该模式提供了一系列的可供重用的算法族,恰当使用继承可以把算法族的公共代码转移到父类里面,从而避免重复的代码。
- 策略模式提供了可以替换继承关系的办法。
缺点
- 客户端必须知道所有的策略类,并自行决定使用哪一个策略类。
- 策略模式将造成产生很多策略类。当存在的策略很多时,可以通过使用享元模式在一定程度上减少对象的数量。
适用场景
在以下情况下可以使用策略模式:
- 如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。
- 一个系统需要动态地在几种算法中选择一种。
- 如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。
- 不希望客户端知道复杂的、与算法相关的数据结构,在具体策略类中封装算法和相关的数据结构,提高算法的保密性与安全性。
7.2 模板方法模式(延迟实现算法某些步)
在面向对象程序设计过程中,程序员常常会遇到这种情况:设计一个系统时知道了算法所需的关键步骤,而且确定了这些步骤的执行顺序,但某些步骤的具体实现还未知,或者说某些步骤的实现与具体的环境相关。
例如,去银行办理业务一般要经过以下4个流程:取号、排队、办理具体业务、对银行工作人员进行评分等,其中取号、排队和对银行工作人员进行评分的业务对每个客户是一样的,可以在父类中实现,但是办理具体业务却因人而异,它可能是存款、取款或者转账等,可以延迟到子类中实现。
我们把这些规定了流程或格式的实例定义成模板,允许使用者根据自己的需求去更新它,例如,简历模板、论文模板、Word 中模板文件等。
定义
模板方法(Template Method)模式:定义一个操作中的算法骨架,而将算法的一些步骤延迟到子类中,使得子类可以不改变该算法结构的情况下重定义该算法的某些特定步骤。
结构
模板方法模式包含以下主要角色。
- 抽象类/抽象模板(Abstract Class):负责给出一个算法的轮廓和骨架。它由一个模板方法和若干个基本方法构成。这些方法的定义如下:
- 模板方法:定义了算法的骨架,按某种顺序调用其包含的基本方法。
- 基本方法:是整个算法中的一个步骤,包含以下几种类型。
- 抽象方法:在抽象类中声明,由具体子类实现。
- 具体方法:在抽象类中已经实现,在具体子类中可以继承或重写它。
- 钩子方法:钩子是内容为空的可选步骤。 即使不重写钩子, 模板方法也能工作。 钩子通常放置在算法重要步骤的前后, 为子类提供额外的算法扩展点。正确使用“钩子方法”可以使得子类控制父类的行为。如下图中的
step2()
方法。
- 具体子类/具体实现(Concrete Class):可以重写所有步骤/基本方法,但不能重写模板方法自身。
客户端必须知道所有的具体实现类,并自行决定使用哪一个具体实现类。
优缺点
优点:
- 它封装了不变部分,扩展可变部分。它把认为是不变部分的算法封装到父类中实现,而把可变部分算法由子类继承实现,便于子类继续扩展。
- 它在父类中提取了公共的部分代码,便于代码复用。
- 部分方法是由子类实现的,因此子类可以通过扩展方式增加相应的功能,符合开闭原则。
缺点:
- 对每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象,间接地增加了系统实现的复杂度。
- 父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度。
- 由于继承关系自身的缺点,如果父类添加新的抽象方法,则所有子类都要改一遍。
适用场景
- 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
- 当多个子类存在公共的行为时,可以将其提取出来并集中到一个公共父类中以避免代码重复。首先,要识别现有代码中的不同之处,并且将不同之处分离为新的操作。最后,用一个调用这些新的操作的模板方法来替换这些不同的代码。
- 当需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展。
7.3 观察者模式(一对多的依赖关系)
在现实世界中,许多对象并不是独立存在的,其中一个对象的行为发生改变可能会导致一个或者多个其他对象的行为也发生改变。例如,某种商品的物价上涨时会导致部分商家高兴,而消费者伤心;股票价格与股民、微信公众号与微信用户、气象局的天气预报与听众等。
在软件世界也是这样,例如,Excel 中的数据与折线图、饼状图、柱状图之间的关系;MVC 模式中的模型与视图的关系;事件模型中的事件源与事件处理者。所有这些,如果用观察者模式来实现就非常方便。
定义
观察者模式(Observer Pattern):定义对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。
观察者模式又叫做发布-订阅(Publish/Subscribe)模式、模型-视图(Model/View)模式、源-监听器(Source/Listener)模式或从属者(Dependents)模式。
结构
标准观察者模式
观察者模式包含如下角色:
- Subject: 抽象目标/主题类。提供了一个用于保存观察者对象的聚集类和增加、删除观察者对象的方法,以及通知所有观察者的抽象方法。
- ConcreteSubject: 具体目标/主题类。当具体主题的内部状态发生改变时,通知所有注册过的观察者对象。
- Observer: 抽象观察者。是一个抽象类或接口,它包含了一个更新自己的抽象方法,当接到具体主题的更改通知时被调用。
- ConcreteObserver: 具体观察者。实现抽象观察者中定义的抽象方法,以便在得到目标的更改通知时更新自身的状态。
- 一个目标可以有任意数目的与之相依赖的观察者。
- 作为对这个通知的响应,每个观察者都将即时更新自己的状态,以与目标状态同步。
1 | // 抽象目标类 |
狭义的发布订阅者模式
- 发布订阅模式属于广义上的观察者模式:发布订阅模式是最常用的一种观察者模式的实现,并且从解耦和重用角度来看,更优于典型的观察者模式
- 发布订阅模式多了个事件通道:
- 在观察者模式中,观察者需要直接订阅目标事件;在目标发出内容改变的事件后,直接接收事件并作出响应。
- 在发布订阅模式中,发布者和订阅者之间多了一个发布通道;一方面从发布者接收事件,另一方面向订阅者发布事件;订阅者需要从事件通道订阅事件。以此避免发布者和订阅者之间产生依赖关系。(一般还会有个remove观察者的方法)
1 | // 需求:每当数据中心DataHub中有数据准备好,就通知DownloadTask进行下载 |
优缺点
优点:
- 观察者模式可以实现表示层和数据逻辑层的分离,并定义了稳定的消息更新传递机制,抽象了更新接口,使得可以有各种各样不同的表示层作为具体观察者角色。
- 观察者模式在观察目标和观察者之间建立一个抽象的耦合。
- 观察者模式支持广播通信。
- 观察者模式符合“开闭原则”的要求。
缺点:
- 如果一个观察目标对象有很多直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。
- 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。
适用场景
在以下情况下可以使用观察者模式:
- 一个抽象模型有两个方面,其中一个方面依赖于另一个方面。将这些方面封装在独立的对象中使它们可以各自独立地改变和复用。
- 一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少对象将发生改变,可以降低对象之间的耦合度。
- 实现类似广播机制的功能,不需要知道具体收听者,只需分发广播,系统中感兴趣的对象会自动接收该广播。
- 需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。
观察者模式在软件开发中应用非常广泛,如某电子商务网站可以在执行发送操作后给用户多个发送商品打折信息,某团队战斗游戏中某队友牺牲将给所有成员提示等等,凡是涉及到一对一或者一对多的对象交互场景都可以使用观察者模式。
MVC模式是一种架构模式,它包含三个角色:模型(Model),视图(View)和控制器(Controller)。观察者模式可以用来实现MVC模式,观察者模式中的观察目标就是MVC模式中的模型(Model),而观察者就是MVC中的视图(View),控制器(Controller)充当两者之间的中介者(Mediator)。当模型层的数据发生改变时,视图层将自动改变其显示内容。
7.4 状态模式(对象不同状态下不同行为)
在很多情况下,一个对象的行为取决于一个或多个动态变化的属性,这样的属性叫做状态,这样的对象叫做有状态的(stateful)对象,这样的对象状态是从事先定义好的一系列值中取出的。当一个这样的对象与外部事件产生互动时,其内部状态就会改变,从而使得系统的行为也随之发生变化。
对这种有状态的对象编程,传统的解决方案是:将这些所有可能发生的情况全都考虑到,然后使用 if-else 或 switch-case 语句来做状态判断,再进行不同情况的处理。但是显然这种做法对复杂的状态判断存在天然弊端,条件判断语句会过于臃肿,可读性差,且不具备扩展性,维护难度也大。且增加新的状态时要添加新的 if-else 语句,这违背了“开闭原则”,不利于程序的扩展。
状态模式的解决思想是:当控制一个对象状态转换的条件表达式过于复杂时,把相关“判断逻辑”提取出来:把受环境改变影响的对象行为包装在不同的状态对象中,系统处于哪种情况,直接使用相应的状态类对象进行处理。这样能把原来复杂的逻辑判断简单化,消除了 if-else、switch-case 等冗余语句,代码更有层次性,并且具备良好的扩展力。
在UML中可以使用状态图来描述对象状态的变化。
有限状态机
状态模式与有限状态机的概念紧密相关。
其主要思想是程序在任意时刻仅可处于几种有限的状态中。 在任何一个特定状态中,程序的行为都不相同,且可瞬间从一个状态切换到另一个状态。不过,根据当前状态,程序可能会切换到另外一种状态,也可能会保持当前状态不变。这些数量有限且预先定义的状态切换规则被称为转移。
这类系统具有一系列离散的输入输出信息和有穷数目的内部状态(状态:概括了对过去输入信息处理的状况)。系统只需要根据当前所处的状态和当前面临的输入信息就可以决定系统的后继行为。每当系统处理了当前的输入后,系统的内部状态也将发生改变。
定义
状态模式(State Pattern) :允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。其别名为状态对象(Objects for States)。
状态模式描述了对象状态的变化以及对象如何在每一种状态下表现出不同的行为。
结构
标准结构(可切换状态的状态模式)
状态模式包含如下角色:
- Context: 上下文类
- 保存了对于一个具体状态对象的引用, 并会将所有与该状态相关的工作委派给它。
- 上下文通过抽象状态接口与状态对象交互, 且会提供一个设置器用于传递新的状态对象。
- Context有时候可以充当状态管理器(State Manager)的角色,即也可以在上下文类中对状态进行切换操作。
- State: 抽象状态类
- 声明特定状态所对应的行为,可以有一个或多个行为。
- 这些方法应能被其他所有具体状态所理解, 因为你不希望某些状态所拥有的方法永远不会被调用。
- ConcreteState: 具体状态类
- 实现不同的状态所对应的行为。为了避免多个状态中包含相似代码,你可以提供一个封装有部分通用行为的中间抽象类。
- 状态对象可存储对于上下文对象的反向引用或者通过方法参数传入(上下文类和状态类之间存在一种双向的关联关系)。状态可以通过该引用从上下文处获取所需信息,并且能触发状态转移。
上下文和具体状态都可以设置上下文的下个状态。通过替换上下文所引用的状态对象来完成实际的状态转换。
抽象状态类的产生是由于上下文类存在多个状态,同时还满足两个条件:这些状态经常需要切换,在不同的状态下对象的行为不同。
由于上下文类可以设置为任一具体状态类,因此它针对抽象状态类进行编程。
上下文类对象在其内部状态改变时可以改变它的行为,对象看起来似乎修改了它的类,而实际上是由于切换到不同的具体状态类实现的。
1 | class ConcreteStateA : public State { |
简单状态模式结构
可切换状态的状态模式:
- 大多数的状态模式都是可以切换状态的状态模式。
- 在实现状态切换时,在具体状态类内部需要调用上下文类Context的setState()方法进行状态的转换操作,在具体状态类中可以调用到上下文类的方法,因此状态类与上下文类之间通常还存在关联关系或者依赖关系。通过在状态类中引用上下文类的对象来回调上下文类的setState()方法实现状态的切换。
- 在这种可以切换状态的状态模式中,增加新的状态类可能需要修改其他某些状态类甚至上下文类的源代码,否则系统无法切换到新增状态。
简单状态模式:
- 是指状态都相互独立,状态之间无须进行转换的状态模式,这是最简单的一种状态模式。
- 对于这种状态模式,每个状态类都封装与状态相关的操作,而无须关心状态的切换,可以在客户端直接实例化状态类,然后将状态对象设置到上下文类中。
- 遵循“开闭原则”,在客户端可以针对抽象状态类进行编程,而将具体状态类写到配置文件中,同时增加新的状态类对原有系统也不造成任何影响。
扩展:状态的共享
在有些情况下,会创建多个上下文对象,这些对象会共享这一组状态。为了避免具体状态类对象的重复创建,常见有以下两种解决方式:
- 引入享元模式,将这些具体状态对象放在集合中供程序共享。
- 将这些状态对象定义为的具体状态类的静态成员对象(如上面的demo代码)。
如果多个上下文对象需要共享同一个状态(意思是这个状态不是对象的,而是所有本类的对象共同拥有的属性?),那么需要将这些状态对象定义为上下文类的静态成员对象。
模式对比
状态模式和策略模式的 UML 类图架构很像,但两者的应用场景是不一样的。策略模式的多种算法行为择其一都能满足,彼此之间是独立的,用户可自行更换策略算法。而状态模式的各个状态间存在相互关系,彼此之间在一定条件下存在自动切换状态的效果,并且用户无法指定状态,只能设置初始状态。
优缺点
优点
- 结构清晰,状态模式将与特定状态相关的行为局部化到一个状态中,而不是集中在一个巨大的条件语句块中。并且将不同状态的行为分割开来,满足“单一职责原则”。
- 将状态转换显示化,减少对象间的相互依赖。将不同的状态引入独立的对象中会使得状态转换变得更加明确,且减少对象间的相互依赖。
- 状态类职责明确,有利于程序的扩展。通过定义新的子类很容易地增加新的状态和转换。
缺点
- 状态模式的使用必然会增加系统类和对象的个数。
- 状态模式的结构与实现都较为复杂,如果使用不当将导致程序结构和代码的混乱。
- 状态模式对“开闭原则”的支持并不太好,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态转换的源代码,否则无法切换到新增状态;而且修改某个状态的行为也需修改对应类的源代码。
适用场景
- 某个类的对象存在多种状态,对象的行为依赖于它的状态(属性)并且可以根据它的状态改变而改变它的相关行为。
- 一个操作中含有庞大的分支结构,并且这些分支决定于对象的状态时。
应用案例
案例1: TCPConnection
这个示例来自《设计模式》,展示了一个简化版的TCP协议实现;TCP连接的状态有多种可能,状态之间的转换有相应的逻辑前提;这是使用状态模式的场合;
案例2: 多线程的状态转换
多线程存在 5 种状态,分别为新建状态、就绪状态、运行状态、阻塞状态和死亡状态,各个状态当遇到相关方法调用或事件触发时会转换到其他状态,其状态转换规律如下图所示。
1 | public class ScoreStateTest { |
7.5 备忘录模式(保存临时状态以备回滚)
定义
备忘录(Memento)模式的定义:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后当需要时能将该对象恢复到原先保存的状态。该模式又叫快照模式。
snapshot(快照):在电脑系统中,快照是整个系统在某个时间点上的状态。该名词由摄影中借用而来。它储存了系统映象,让电脑系统在出现问题时,可以快速恢复到未出问题前的状况。
在版本管理中,snapshot快照版本通常是指开发过程中的不稳定版本。对比于release发布版本。
结构
分析
到底该如何生成一个快照呢?
- 遍历对象的所有成员变量并将其数值复制保存?但只有当对象对其内容没有严格访问权限限制的情况下,你才能使用该方式。不过很遗憾,绝大部分对象会使用私有成员变量来存储重要数据,这样别人就无法轻易查看其中的内容。
- 就算公开所有成员变量,你可通过上面的方式,随时生成对象的状态快照,但这种方式仍存在一些严重问题。未来你可能会添加或删除一些成员变量。这听上去很简单,但需要对负责复制受影响对象状态的类进行更改。
备忘录模式将创建状态快照(Snapshot)的工作委派给实际状态的拥有者原发器/发起人(Originator)对象。 这样其他对象就不再需要从 “外部” 复制对象状态了,原发起器类拥有其状态的完全访问权,因此可以自行生成快照。
模式建议将对象状态的副本存储在一个名为备忘录(Memento)的特殊对象中。而将备忘录保存在负责人/管理者(Caretakers)类中(通常会有个备忘录列表,如果不需要支持多次回滚,那仅持有一个对象引用即可)。
- 只有原发器拥有对备忘录所有成员的访问权限,从而能随时从备忘录中获取数据,来恢复其以前的状态。
- 其他对象必须使用受限接口与备忘录进行交互,它们可以获取快照的元数据(创建时间和操作名称等)。
标准结构(支持类嵌套)
所以,备忘录模式的主要角色如下。
- 原发器/发起人(Originator)角色:生成自身状态的快照,创建备忘录对象并能在需要时通过备忘录对象恢复自身状态。它可以访问备忘录里的所有信息。
- 备忘录(Memento)角色:是原发器状态快照的值对象(value object)。通常做法是将备忘录设为不可变的,并通过构造函数一次性传递数据。
- 负责人/管理者(Caretaker)角色:对备忘录进行管理,提供保存与获取备忘录的功能,但其不能对备忘录的内容进行访问与修改。
在支持嵌套类的编程语言中,可以将备忘录类嵌套在原发器中,这样原发器就可访问备忘录的成员变量和方法(即使这些方法被声明为私有),同时限制了Caretaker的访问权限。
不支持类嵌套的结构
在不支持嵌套类的编程语言中,为了能使原发器对象能够完全访问备忘录对象,需要将将备忘录的所有成员变量声明为公有。另一方面,为了限制其对备忘录成员变量的直接访问权限,可在Caretaker与备忘录之间新增一个中间接口进行交互,该接口仅声明与备忘录元数据相关的方法。
扩展: 与原型模式联用
在备忘录模式中,通过定义“备忘录”来备份“发起人”的信息,而原型模式的 clone() 方法具有自备份功能,所以,如果让发起人实现 Cloneable 接口就有备份自己的功能,这时可以删除备忘录类。
优缺点
优点:
- 提供了一种可以恢复状态的机制。当用户需要时能够比较方便地将数据恢复到某个历史的状态。
- 实现了内部状态的封装。除了创建它的发起人之外,其他对象都不能够访问这些状态信息。
- 简化了发起人类。发起人不需要管理和保存其内部状态的各个备份,所有状态信息都保存在备忘录中,并由管理者进行管理,这符合单一职责原则。
缺点:
- 资源消耗大。如果要保存的内部状态信息过多或者特别频繁,将会占用比较大的内存资源。
适用场景
- 需要保存与恢复数据的场景,如玩游戏时的中间结果的存档功能。
7.6 迭代器模式(抽取封装集合的遍历)
在现实生活以及程序设计中,经常要访问一个聚合对象中的各个元素,通常的做法是将创建和遍历都放在同一个类中,缺点:
- 不利于程序的扩展,如果要新增遍历方法(DFS、BFS、随机存取等)就必须修改程序源代码,这违背了 “开闭原则”。
- 不断向集合中添加遍历算法会模糊其 “高效存储数据” 的主要职责。此外,有些算法可能是根据特定应用订制的, 将其加入泛型集合类中会显得非常奇怪。
- 使用多种集合的客户端代码可能并不关心存储数据的方式。不过由于集合提供不同的元素访问方式,你的代码将不得不与特定的集合类进行耦合。
那将遍历方法由用户自己实现是否可行呢?答案是同样不可取,因为这种方式会存在两个缺点:
- 暴露了聚合类的内部表示,使其数据不安全;
- 增加了客户的负担。
“迭代器模式”能较好地克服以上缺点,它将集合的遍历行为抽取为单独的迭代器对象,置于客户访问类与聚合类之间,这分离了聚合对象与其遍历行为,对客户也隐藏了其内部细节,且满足“单一职责原则”和“开闭原则”。
定义
提供一种方法来顺序访问一个聚合对象中的各个元素,而又不暴露该聚合对象的内部表示。
结构
标准结构
迭代器模式主要包含以下角色。
- 抽象聚合(Aggregate)角色:需要声明一个或多个方法来获取与集合兼容的迭代器。请注意, 返回方法的类型要声明为抽象迭代器接口, 因此具体集合可以返回各种不同种类的迭代器。
- 具体聚合(ConcreteAggregate)角色:实现抽象聚合类,在客户端请求迭代器时返回一个具体迭代器的实例。
- 抽象迭代器(Iterator)角色:定义了遍历聚合所需的接口,通常包含 hasNext()、first()、next() 等方法。
- 具体迭代器(Concretelterator)角色:实现遍历集合的一种特定算法。迭代器对象必须跟踪自身遍历的进度。这使得多个迭代器可以相互独立地遍历同一集合。
客户端 (Client) 通过集合和迭代器的接口与两者进行交互。这样一来客户端无需与具体类进行耦合,允许同一客户端代码使用各种不同的集合和迭代器。
所有迭代器必须实现相同的接口。 这样一来,只要有合适的迭代器,客户端代码就能兼容任何类型的集合或遍历算法。如果你需要采用特殊方式来遍历集合,只需创建一个新的迭代器类即可,无需对集合或客户端进行修改。
在日常开发中,我们几乎不会自行创建迭代器,而是会从集合中获取。除非需要定制一个自己实现的数据结构对应的迭代器,否则,开源框架提供的 API 完全够用。
扩展: 与组合模式联用
迭代器模式常常与组合模式结合起来使用,在对组合模式中的容器构件进行访问时,经常将迭代器潜藏在组合模式的容器构成类中。当然,也可以构造一个外部迭代器来对容器构件进行访问。
优缺点
优点:
- 访问一个聚合对象的内容而无须暴露它的内部表示。
- 单一职责原则。 通过将体积庞大的遍历算法代码抽取为独立的类, 你可对客户端代码和集合进行整理。
- 开闭原则。 你可实现新型的集合和迭代器并将其传递给现有代码, 无需修改现有代码。
- 抽象迭代器为遍历不同的聚合结构提供一个统一的接口。
- 支持以不同方式遍历一个聚合,自定义迭代器的子类以支持新的遍历。
- 你可以并行遍历同一集合, 因为每个迭代器对象都包含其自身的遍历状态。相似的, 你可以暂停遍历并在需要时继续。
- 可以将数据和操作分离。数据由容器类别加以管理,操作则由可定制的算法定义。迭代器在两者之间充当“粘合剂”,以使算法可以和容器交互运作。提升了数据结构、算法的独立性、弹性、交互操作性。
缺点:
- 如果你的程序只与简单的集合进行交互,应用该模式可能会矫枉过正。
- 对于某些特殊集合,使用迭代器可能比直接遍历的效率低。
适用场景
- 当集合背后为复杂的数据结构,且你希望对客户端隐藏其复杂性时(出于使用便利性或安全性的考虑),可以使用迭代器模式。
- 迭代器封装了与复杂数据结构进行交互的细节, 为客户端提供多个访问集合元素的简单方法。 这种方式不仅对客户端来说非常方便, 而且能避免客户端在直接与集合交互时执行错误或有害的操作, 从而起到保护集合的作用。
- 当需要为聚合对象提供多种遍历方式时。
- 当需要为遍历不同的聚合结构提供一个统一的接口时。
- 该模式为集合和迭代器提供了一些通用接口。如果你在代码中使用了这些接口,那么将其他实现了这些接口的集合和迭代器传递给它时,它仍将可以正常运行。
由于聚合与迭代器的关系非常密切,所以大多数语言在实现聚合类时都提供了迭代器类,因此大数情况下使用语言中已有的聚合类的迭代器就已经够了。
7.7 命令模式(将方法调用转化为对象)
概念:参数、参数化
参数
- 也叫参变量。在所讨论的某个数学或物理问题中,于给定条件下取固定值的变量。如在平面直角坐标系中,如果曲线l上任意一点的坐标(x,y)都可以表示为在某个区间内的变量t的函数,那么所得到的方程x=f(t),y=g(t)就叫做该曲线的参数方程,变量t叫做参数。
- 表明任何现象、机构、装置的某种性质的量。如导电率、导热率、膨胀系数等。
- 在程序设计中,又称形式引数(formal argument),是一种在调用子程序时用以向子程序传递数据的特殊变量,这些被传递数据也就是子程序引数(arguments)的值。
参数化
参数化设计:将系统本身编写为函数与过程,使用某些可以编辑的参数或变量,来操纵或改变方程或系统的最终结果。
参数化是一种方法,或者说一种思想。在建模、生活中很多地方都可以用到。简单来说,就是用最少的元素,控制最多的内容。从一个物体中抽取一个或几个要素,作为参数。其他的要素作为从动要素。通过公式对参数的计算,得到所有从动要素的值。从而生成符合要求的整个物体。
这个物体你可以理解为一个零件,也可以是包含多个零件的装配体。建立起从动要素和参数的关联的这个过程,称之为参数化。这个关联(公式)是静态的,但参数是动态的,它在它的取值范围内可以随意改变。
所谓“参数化”就是把一个事物或者问题用参数来表示的行为。(知道了参数的值就知道了整个事物的模样)。
此外,有些场景,参数化就是简单的表面意思:使某个事物可以当做(泛型类、函数的)参数传递。比如C++模板中常说的类型参数化。
定义
举例引入
以编辑器为例,一般在工具栏、右键菜单栏、快捷键中 ,都会支持复制、粘贴功能,那我们需要怎么组织这段代码?
- 将操作代码复制进许多个类中。
- 让菜单栏依赖于我们工具栏中的按钮。(更糟)
优秀的软件设计通常会将关注点进行分离,而这往往会导致软件的分层。上面的例子中,我们可以清晰的划分出一层负责用户图像界面,一层负责业务逻辑。一个 GUI 对象传递一些参数来调用一个业务逻辑对象。 这个过程通常被描述为一个对象发送请求给另一个对象。
GUI 层可以直接访问业务逻辑层:
命令模式建议 GUI 对象不直接提交这些请求。 你应该将请求的所有细节(例如调用的对象、方法名称和参数列表) 抽取出来组成命令类, 该类中仅包含一个用于触发请求的方法。
命令对象负责连接不同的 GUI 和业务逻辑对象。 此后, GUI 对象无需了解业务逻辑对象是否获得了请求, 也无需了解其对请求进行处理的方式。 GUI 对象触发命令即可, 命令对象会自行处理所有细节工作。
此外,当我们订餐时,服务员记下你点的食物, 写在一张纸上。然后来到厨房,把订单贴在墙上。过了一段时间,厨师拿到了订单,他根据订单来准备食物。厨师将做好的食物和订单一起放在托盘上。服务员看到托盘后对订单进行检查,确保所有食物都是你要的,然后将食物放到了你的桌上。
那张纸就是一个命令,它在厨师开始烹饪前一直位于队列中。命令中包含与烹饪这些食物相关的所有信息,厨师能够根据它马上开始烹饪。
正式定义
命令模式(Command Pattern):将一个请求(的所有细节,例如调用的对象、方法名称和参数列表等)封装为一个对象,从而使我们可用不同的请求对客户进行参数化;对请求排队或者记录请求日志,以及支持可撤销的操作。命令模式是一种对象行为型模式,其别名为动作(Action)模式或事务(Transaction)模式。
命令模式将请求调用者和请求接收者解耦,使得两者不直接交互。
结构
标准结构
命令模式包含如下角色:
- 抽象命令类(Command):通常仅声明一个执行命令的方法 execute()。
- 具体命令类(Concrete Command):
- 实现各种类型的请求。
- 具体命令自身并不完成工作, 而是会将调用委派其所拥有的接收者对象(业务逻辑对象)。
- 接收对象执行方法所需的参数可以声明为具体命令的成员变量。可将命令对象设为不可变,仅允许通过构造函数对这些成员变量进行初始化。
- 接收者(Receiver):
- 包含部分业务逻辑,几乎任何对象都可以作为接收者。
- 绝大部分命令只处理如何将请求传递到接收者的细节,接收者自己会完成实际的工作。
- 发送者(Sender)/触发者(Invoker):
- 负责对请求进行初始化,其中必须包含一个成员变量来存储对于命令对象的引用。
- 发送者触发命令,而不向接收者直接发送请求。
- 注意,发送者并不负责创建命令对象,它通常会通过构造函数从客户端处获得预先生成的命令。
命令模式的本质是对命令进行封装,将发出命令的责任和执行命令的责任分割开。
- 每一个命令都是一个操作:请求的一方发出请求,要求执行一个操作;接收的一方收到请求,并执行操作。
- 命令模式允许请求的一方和接收的一方独立开来,使得请求的一方不必知道接收请求的一方的接口,更不必知道请求是怎么被接收,以及操作是否被执行、何时被执行,以及是怎么被执行的。
- 命令模式使请求本身成为一个对象,这个对象和其他对象一样可以被存储和传递。
- 命令模式的关键在于引入了抽象命令接口,且发送者针对抽象命令接口编程,只有实现了抽象命令接口的具体命令才能与接收者相关联。
1 | class Invoker { |
扩展: 与其他模式的联用
宏命令又称为组合命令,它是命令模式和组合模式联用的产物。
- 宏命令也是一个具体命令,不过它包含了对其他命令对象的引用,在调用宏命令的execute()方法时,将递归调用它所包含的每个成员命令的execute()方法,一个宏命令的成员对象可以是简单命令,还可以继续是宏命令。执行一个宏命令将执行多个具体命令,从而实现对命令的批处理。
命令模式还可以同备忘录(Memento)模式组合使用,这样就变成了可撤销的命令模式
优缺点
优点
- 降低系统的耦合度。
- 新的命令可以很容易地加入到系统中。
- 可以比较容易地设计一个命令队列和宏命令(组合命令)。
- 可以方便地实现对请求的Undo和Redo。
- 在需要的时候,可以很容易地将命令记入日志。
缺点
- 使用命令模式可能会导致某些系统有过多的具体命令类。因为针对每一个命令都需要设计一个具体命令类,因此某些系统可能需要大量具体命令类,这将影响命令模式的使用。
适用场景
- 系统需要将请求调用者和请求接收者解耦,使得调用者和接收者不直接交互。
- 命令模式将特定的方法调用转化为独立对象。 带来了许多有趣的应用:你可以将命令作为方法的参数进行传递、将命令保存在其他对象中,或者在运行时切换已连接的命令等。
- 系统需要在不同的时间指定请求、将请求排队和执行请求。
- 系统需要支持命令的撤销(Undo)操作和恢复(Redo)操作(结合备忘录模式实现)。
- 系统需要将一组操作组合在一起,即支持宏命令
很多系统都提供了宏命令功能,如UNIX平台下的Shell编程,可以将多条命令封装在一个命令对象中,只需要一条简单的命令即可执行一个命令序列,这也是命令模式的应用实例之一。
7.8 责任链模式(将请求处理者们连成链)
在现实生活中,一个事件需要经过多个对象处理是很常见的场景。例如,采购审批流程、请假流程等。公司员工请假,可批假的领导有部门负责人、副总经理、总经理等,但每个领导能批准的天数不同,员工必须根据需要请假的天数去找不同的领导签名,也就是说员工必须记住每个领导的姓名、电话和地址等信息,这无疑增加了难度。
定义
责任链模式(Chain of Responsibility),也叫职责链模式:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这个对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。(每个处理者收到请求后,均可选择处理该请求,或将其传递给链上的下个处理者。)
责任链模式将请求的发送者和处理者解耦,即客户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递过程,请求会自动进行传递。
而系统也可以在不影响客户使用的情况下,动态地重新组织和分配责任。
结构
标准结构
责任链模式主要包含以下角色。
- 抽象处理者(Handler):定义一个处理请求的接口,包含抽象处理方法和一个后继连接。
- 具体处理者(Concrete Handler):实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。
- 处理者通常是独立且不可变的,需要通过构造函数一次性地获得所有必要地数据。
- 客户类(Client):根据程序逻辑一次性或者动态地生成链。值得注意的是,请求可发送给链上的任意一个处理者,而非必须是第一个处理者。
责任链模式的独到之处是将其节点处理者组合成了链式结构,并允许节点自身决定是否进行请求处理或转发,相当于让请求流动起来。
扩展: 纯、不纯的责任链模式
责任链模式存在以下两种情况。
- 纯的责任链模式:一个请求必须被某一个处理者对象所接收,且一个具体处理者对某个请求的处理只能采用以下两种行为之一:自己处理(承担责任);把责任推给下家处理。
- 不纯的责任链模式:允许出现某一个具体处理者对象在承担了请求的一部分责任后又将剩余的责任传给下家的情况,且一个请求可以最终不被任何接收端对象所接收。
对比: 与状态模式的区别
状态模式和责任链模式。但在某些情况下,状态模式中的状态可以理解为责任,那么在这种情况下,两种模式都可以使用。
相似处:
- 都会发生状态或责任的转移。(状态的转移可能是有环的,而责任链不能出现环。)
- 都能消除 if-else 分支过多的问题。
不过两者还是有很明显的区别的:
- 从定义来看,状态模式强调的是一个对象内在状态的改变,而责任链模式强调的是外部节点对象间的改变。
- 从代码实现上来看,两者最大的区别就是状态模式的各个状态对象知道自己要进入的下一个状态对象,而责任链模式并不清楚其下一个节点处理对象,因为链式组装由客户端负责。
优缺点
优点:
- 将请求的发送者和接受者解耦。该模式使得一个对象无须知道到底是哪一个对象处理其请求以及链的结构,发送者和接收者也无须拥有对方的明确信息。
- 增强了系统的可扩展性。可以根据需要增加新的请求处理类,满足开闭原则。
- 增强了给对象指派职责的灵活性。当工作流程发生变化,可以动态地改变链内的成员或者调动它们的次序,也可动态地新增或者删除责任。
- 责任链简化了对象之间的连接。每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句。
- 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。
缺点:
- 不能保证每个请求一定被处理。由于一个请求没有明确的接收者,所以不能保证它一定会被处理,该请求可能一直传到链的末端都得不到处理。
- 对比较长的职责链,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
- 职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用。
适用场景
- 多个对象可以处理一个请求,但具体由哪个对象处理该请求在运行时自动确定。
- 可动态指定一组对象处理请求,或添加新的处理者。
7.9 中介者模式(将依赖从网状变星型)
在现实生活中,常常会出现好多对象之间存在复杂的交互关系,这种交互关系常常是“网状结构”,它要求每个对象都必须知道它需要交互的对象。
例如,每个人必须记住他(她)所有朋友的电话;而且,朋友中如果有人的电话修改了,他(她)必须让其他所有的朋友一起修改,牵一发而动全身,非常复杂。
如果把这种“网状结构”改为“星形结构”的话,将大大降低它们之间的“耦合性”,这时只要找一个“中介者”就可以了。
如前面所说的“每个人必须记住所有朋友电话”的问题,只要在网上建立一个每个朋友都可以访问的“通信录”就解决了。
这样的例子还有很多,例如:
- 你刚刚参加工作想租房,可以找“房屋中介”;
- 刚刚到一个陌生城市找工作,可以找“人才交流中心”帮忙。
- MVC 框架中,控制器(C)就是模型(M)和视图(V)的中介者;
- 常用的 QQ 聊天程序的“中介者”是 QQ 服务器。
所有这些,都可以采用“中介者模式”来实现,它将大大降低对象之间的耦合性,提高系统的灵活性。
定义
中介者模式(Mediator Pattern)定义:用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
中介者模式又称为调停者模式。
结构
标准结构
中介者模式包含以下主要角色。
- 抽象中介者(Mediator):它是中介者的接口,提供了同事对象注册与转发同事对象信息的抽象方法。
- 具体中介者(Concrete Mediator):实现中介者接口。
- 定义一个 List 或 map (可根据具体同事类对象的个数等因素来决定)来管理所有的同事对象。
- 协调各个同事角色之间的交互关系,因此它依赖于同事角色。
- 抽象同事类(Colleague):定义同事类的接口。
- 每个同事类都有一个指向中介者对象的引用。该引用被声明为中介者接口类型,可通过将其连接到不同的中介者以使其能在其他程序中复用。
- 提供同事对象交互的抽象方法,实现所有相互影响的同事类的公共功能。
- 具体同事类(Concrete Colleague):是抽象同事类的实现者。
- 是各种包含业务逻辑的类。
- 当需要与其他同事对象交互时,由中介者对象负责后续的交互。
中介者模式可以使对象之间的关系数量急剧减少。中介者承担两方面的职责:
- 中转作用(结构性):
- 通过中介者提供的中转作用,各个同事对象就不再需要显式引用其他同事,当需要和其他同事进行通信时,通过中介者即可。该中转作用属于中介者在结构上的支持。
- 对于同事对象来说,中介者看上去完全就是一个黑箱。发送者不知道最终会由谁来处理自己的请求,接收者也不知道最初是谁发出了请求。
- 协调作用(行为性):中介者可以更进一步的对同事之间的关系进行封装,同事可以一致地和中介者进行交互,而不需要指明中介者需要具体怎么做,中介者根据封装在自身内部的协调逻辑,对同事的请求进行进一步处理,将同事成员之间的关系行为进行分离和封装。该协调作用属于中介者在行为上的支持。
1 | class ConcreteMediator : public Mediator { |
结构: 不存在抽象中介者
- 不定义中介者接口,把具体中介者对象实现成为单例。
- 同事对象不持有中介者,而是在需要的时候直接获取中介者对象并调用。
结构: 不存在抽象组件类
抽象组件类并不是一定要存在的,尤其是对于已有代码的重构,更不能保证所有组件都会继承同一个抽象组件类。
优缺点
优点
- 降低了对象之间的耦合性,使得对象易于独立地被复用。
- 将对象间的一对多关联转变为一对一的关联,提高系统的灵活性,使得系统易于维护和扩展。
- 类之间各司其职,符合迪米特法则。
- 通过创造出一个中介者对象,将系统中有关的对象所引用的其他对象数目减少到最少,使得一个对象与其同事之间的相互作用被这个对象与中介者对象之间的相互作用所取代。因此,中介者模式就是迪米特法则的一个典型应用。
缺点
- 在具体中介者类中包含了同事之间的交互细节,可能会导致具体中介者类非常复杂,使得系统难以维护。
适用场景
- 当对象之间存在复杂的网状结构关系而导致依赖关系混乱且难以复用时。
- 当想创建一个运行于多个类之间的对象,又不想生成新的子类时。
- 交互的公共行为,如果需要改变行为则可以增加新的中介者类。
中介者模式可以方便地应用于图形界面(GUI)开发中,在比较复杂的界面中可能存在多个界面组件之间的交互关系。对于这些复杂的交互关系,有时候我们可以引入一个中介者类,将这些交互的组件作为具体的同事类,将它们之间的引用和控制关系交由中介者负责,在一定程度上简化系统的交互,这也是中介者模式的常见应用之一。(比如:iOS开发中的路由模块)
7.10 解释器模式(自定义嵌入式DSL)
解释器(Interpreter)模式能引起一些高级开发者的兴趣。这是因为解释器模式的思想是让非初级用户和领域专家使用一门简单的语言(没编程语言那么复杂的语言)来表达思想。
解释器模式,常用于创建一种专注于某个特定领域的计算机语言。这种语言称为领域特定语言(Domain Specific Language, DSL)。
DSL 分为内部/嵌入式 DSL 和外部 DSL(前者的实现依赖于某种宿主语言),而解释器模式仅与内部 DSL 相关。我们的目标是使用宿主语言提供的特性构建一种简单但有用的语言。
概念:文法、句子、语法树
语法:语言的结构规则,包括词的构成和变化、词组和句子的组织(概括起来就是两部分:词法和句法)。又称文法。
无论是机器语言还是自然语言,都有它自己的文法规则。例如,中文中的“句子”的文法如下。
1 | // 符号“::=”表示“定义为” |
句子是语言的基本单位,是语言集中的一个元素,它由终结符构成,能由“文法”推导出。例如,上述文法可以推出“我是大学生”,所以它是句子。
语法树是句子结构的一种树型表示,它代表了句子的推导结果,它有利于理解句子语法结构的层次。如下图所示是“我是大学生”的语法树。
定义
解释器模式(Interpreter)的定义:定义一个语言,并定义该语言的文法表示,再设计一个解析器来解释语言中的句子。是一种类行为型模式。
解释器模式需要解决的是,如果一种特定类型的问题发生的频率足够高,那么可能就值得将该问题的各个实例表述为一个简单语言中的句子。这样就可以构建一个解释器,该解释器通过解释这些句子来解决该问题。
比方说,我们常常会使用正则表达式,在字符串中搜索匹配的字符或判断一个字符串是否符合我们规定的格式。正则表达式就是解释器模式的一种应用,解释器为正则表达式定义了一套文法(如何表示一个特定的正则表达式),以及如何解释这个正则表达式。
这种模式用编译语言的方式来分析应用中的实例。实现了文法表达式处理的接口,该接口解释一个特定的上下文。
结构
解释器模式包含以下主要角色。
- 抽象表达式(Abstract Expression):定义解释器的接口,约定解释器的解释操作,主要包含解释方法 interpret()。
- 终结符表达式(Terminal Expression):抽象表达式的子类。
- 实现与文法中终结符相关联的解释操作。
- 文法中的每一个终结符都有一个具体终结表达式与之相对应。
- 非终结符表达式(Nonterminal Expression):抽象表达式的子类。
- 实现与文法中非终结符相关联的解释操作。
- 文法中的每条规则都对应于一个非终结符表达式类。
- 环境(Context):通常包含各个解释器需要的数据或是公共的功能,一般用来传递被所有解释器共享的数据,后面的解释器可以从这里获取这些值。
- 环境类传递数据给表达式类/解释器类有两种方式:1. 将Context对象作为 interpret() 方法的参数传入。 2. 事先在Context类中,完成表达式类对象的配置(如下图)。
- 客户端(Client):主要任务是将需要分析的句子或表达式转换成使用解释器对象描述的抽象语法树,然后调用解释器的解释方法,当然也可以通过环境角色间接访问解释器的解释方法。
1 | /** |
优缺点
优点:
- 扩展性好。由于在解释器模式中使用类来表示语言的文法规则,因此可以通过继承等机制来改变或扩展文法。
- 容易实现。在语法树中的每个表达式节点类都是相似的,所以实现其文法较为容易。
缺点:
- 执行效率较低。解释器模式中通常使用大量的循环和递归调用,当要解释的句子较复杂时,其运行速度很慢,且代码的调试过程也比较麻烦。
- 会引起类膨胀。解释器模式中的每条规则至少需要定义一个类,当包含的文法规则很多时,类的个数将急剧增加,导致系统难以管理与维护。
- 可应用的场景比较少。在软件开发中,需要定义语言文法的应用实例非常少,所以这种模式很少被使用到。
适用场景
- 当语言的文法较为简单,且执行效率不是关键问题时。
- 当问题重复出现,且可以用一种简单的语言来进行表达时。
- 当一个语言需要解释执行,并且语言中的句子可以表示为一个抽象语法树的时候,如 XML 文档解释。
注意:解释器模式在实际的软件开发中使用比较少,因为它会引起效率、性能以及维护等问题
7.11 访问者模式(“访问者”一词很贴切)
每个访问者对集合中每个元素的处理不同。将操作从数据结构中抽出,封成访问者类。
在现实生活中,有些集合对象存在多种不同的元素,且每种元素也存在多种不同的访问者和处理方式。例如:
- 公园中存在多个景点,也存在多个游客,不同的游客对同一个景点的评价可能不同;
- 电影或电视剧中的人物角色,不同的观众对他们的评价也不同;
- 顾客在商场购物时放在“购物车”中的商品,顾客主要关心所选商品的性价比,而收银员关心的是商品的价格和数量。
对于这些数据元素相对稳定而访问方式多种多样的数据结构,访问者模式能把处理方法从数据结构中分离出来,并可以根据需要增加新的处理方法,且不用修改原来的程序代码与数据结构,这提高了程序的扩展性和灵活性。
定义
访问者模式(Visitor)的定义:将作用于某种数据结构中的各元素的操作,从数据结构中分离出来封装成独立的类,使其在不改变数据结构的前提下可以添加作用于这些元素的新的操作,为数据结构中的每个元素提供多种访问方式。
结构
标准结构
访问者模式实现的关键是如何将作用于元素的操作分离出来封装成独立的类。
访问者模式包含以下主要角色。
- 抽象访问者(Visitor):定义一个访问具体元素的接口。
- 为每个具体元素类声明一个访问操作 visit() ,该操作中的参数类型标识了被访问的具体元素。
- 如果编程语言支持重载,这些方法的名称可以是相同的,但是其参数一定是不同的。
- 具体访问者(ConcreteVisitor):实现抽象访问者角色中声明的各个访问操作,确定访问者访问一个元素时该做什么。
- 抽象元素(Element):声明一个包含接受操作 accept() 的接口,被接受的访问者对象作为 accept() 方法的参数。
- 具体元素(ConcreteElement):实现抽象元素角色提供的 accept() 操作。
- 该方法的目的是根据当前元素类将其调用重定向到相应访问者的方法:方法体通常都是 visitor.visit(this)
- 另外具体元素中可能还包含本身业务逻辑的相关操作。
- 对象结构(Object Structure):是一个包含元素角色的容器,提供让访问者对象遍历容器中的所有元素的方法,通常由 List、Set、Map 等聚合类实现。
1 | package net.biancheng.c.visitor; |
扩展: 与其他模式联用
访问者(Visitor)模式是使用频率较高的一种设计模式,它常常同以下两种设计模式联用。
- 与“迭代器模式”联用。因为访问者模式中的“对象结构”是一个包含元素角色的容器,当访问者遍历容器中的所有元素时,常常要用迭代器。上面类图中的对象结构是用 List 实现的,它通过 List 对象的 Iterator() 方法获取迭代器。如果对象结构中的聚合类没有提供迭代器,也可以用迭代器模式自定义一个。
- 与“组合模式”联用。因为访问者模式中的“元素对象”可能是叶子对象或者是容器对象,如果元素对象包含容器对象,就必须用到组合模式。部分类图如下:
优缺点
优点:
- 扩展性好。能够在不修改对象结构中的元素的情况下,为对象结构中的元素添加新的功能。
- 复用性好。可以通过访问者来定义整个对象结构通用的功能,从而提高系统的复用程度。
- 灵活性好。访问者模式将数据结构与作用于结构上的操作解耦,使得操作集合可相对自由地演化而不影响系统的数据结构。
- 符合单一职责原则。访问者模式把相关的行为封装在一起,构成一个访问者,使每一个访问者的功能都比较单一。
缺点:
- 增加新的元素类很困难。在访问者模式中,每增加一个新的元素类,都要在每一个具体访问者类中增加相应的具体操作,这违背了“开闭原则”。
- 破坏封装。访问者模式中具体元素对访问者公布细节,这破坏了对象的封装性。
- 违反了依赖倒置原则。访问者模式依赖了具体类,而没有依赖抽象类。
适用场景
当系统中存在类型数量稳定(固定)的一类数据结构时,可以使用访问者模式方便地实现对该类型所有数据结构的不同操作,而又不会对数据产生任何副作用(脏数据)。
简而言之,就是当对集合中的不同类型数据(类型数量稳定)进行多种操作时,使用访问者模式。
- 对象结构相对稳定,但其操作算法经常变化的程序。
- 对象结构中的对象需要提供多种不同且不相关的操作,而且要避免让这些操作的变化影响对象的结构。
- 对象结构包含很多类型的对象,希望对这些对象实施一些依赖于其具体类型的操作。