设计模式——访问者模式
在阎宏博士的《JAVA与模式》一书中开头是这样描述访问者(Visitor)模式的:访问者模式是对象的行为模式。访问者模式的目的是封装一些施加于某种数据结构元素之上的操作。一旦这些操作需要修改的话,接受这个操作的数据结构则可以保持不变。
在进行访问者模式的学习之前,我们先来了解下分派的概念。
分派是什么?
变量被声明的类型叫做变量的静态类型(Static Type),或者成为显式类型(Apparent Type)。变量在运行时所引用对象的真实类型叫做变量的实际类型(Actural Type)。
比如下面的例子:
List<String> list = new ArrayList<String>();
在这里,list的显示类型就是List,而实际类型却是ArrayList。
根据对象的类型而对方法进行的选择,就是分派(Dispatch),分派(Dispatch)又分为两种,即静态分派和动态分派。
- 静态分派(Static Dispatch)发生在编译时期,分派根据静态类型信息发生。静态分派对于我们来说并不陌生,方法重载就是静态分派。
- 动态分派(Dynamic Dispatch)发生在运行时期,动态分派动态地置换掉某个方法。方法重写就是动态分派。
根据分派可以基于多少种宗量,可以将面向对象的语言划分为单分派语言(Uni-Dispatch)和多分派语言(Multi-Dispatch)。单分派语言根据一个宗量的类型进行对方法的选择,多分派语言根据多于一个的宗量的类型对方法进行选择。
Java就是动态的单分派语言,因为这种语言的动态分派仅仅会考虑到方法的接收者的类型,同时又是静态的多分派语言,因为这种语言对重载方法的分派会考虑到方法的接收者的类型以及方法的所有参数的类型。
在一个支持动态单分派的语言里面,有两个条件决定了一个请求会调用哪一个操作,第二个是请求的名字,而是接收者的真实类型。单分派限制了方法的选择过程,使得只有一个宗量可以被考虑到,这个宗量通常就是方法的接收者。
访问者模式
访问者模式是一种较为复杂的行为型模式,主要由访问者和被访问者两部分组成,通常被访问的元素具有不同的类型属性,一般不同的访问者对它们的访问侧重点操作不同。所以访问者模式的作用就是封装一些作用于某种数据结构的元素操作,然后在不改变数据结构的前提下定义这些元素的新操作。
访问者模式和装饰者模式很相似,都是新增功能。装饰模式更多的是实现对己有功能的加强、修改或者完全重新实现。而访问者模式更多的是实现为对象结构添加新的功能。
访问者模式结构
VisitorPattern.png访问者模式的组成角色
- 抽象访问者角色(Visitor):声明一个或多个元素(Element)的访问操作,抽象访问者的参数就是可访问的元素,一般情况下它的方法个数和元素个数是一样的,所以访问者模式要求元素的种类要稳定,不能频繁增加、删除元素类,不然会导致频繁修改Visitor接口。
- 具体访问者角色(ConcreteVisitor):具体访问者实现了抽象访问者方法的实现,定义出每个元素访问时的具体行为。
- 抽象元素角色(Element):一般是一个抽象类或接口,定义一个Accept方法,该方法通常以一个抽象访问者作为参数。
- 具体元素角色(ConcreteElement):具体元素实现了Accept方法,在Accept方法中调用访问者的访问方法以便提供具体的访问实现。
- 对象结构角色(ObjectStructure):对象结构是一个元素的集合,用于存放元素对象,且提供便利其内部元素的方法。
案例演示
访问者模式是一个结构、概念较为复杂的模式,使用频率不是很高,但是在特定场景下会体现出非常大的灵活性。
这里我以【Android源码设计模式】中的一个例子来演示下访问者模式的使用。在我们的绩效过评估中,CTO首席技术官只关注员工的成果数量,而CEO只需要了解员工的kpi值。在这种场景下,公司不同员工对应的领导需要访问的侧重点不同,这种情况下就可以使用访问者模式。
定义抽象员工类
abstract class Staff {
private String name;
private int kpi;
public Staff(String name, int kpi) {
super();
this.name = name;
this.kpi = kpi;
}
/**
* 定义accept接口,用于访问具体的内部成员
* @param visitor
*/
public abstract void accept(IVisitor visitor);
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getKpi() {
return kpi;
}
public void setKpi(int kpi) {
this.kpi = kpi;
}
}
在抽象员工类中,我们定义了共有的属性:姓名、kpi,同时定义了accept接口用来接收访问者。
声明具体员工
/**
* 软件开发工程师
* @author Iflytek_dsw
*
*/
class DeveloperStaff extends Staff{
/**
* 软件工程师特有特性,编码量。
*/
private int codes;
public DeveloperStaff(String name, int kpi, int codes) {
super(name, kpi);
this.codes = codes;
}
@Override
public void accept(IVisitor visitor) {
visitor.visit(this);
}
/**
* 获取程序员的编码量
* @return
*/
public int getCodesLines(){
return this.codes;
}
}
/**
* 产品经理
* @author Iflytek_dsw
*
*/
class ProductManager extends Staff{
/**
* 产品经理的特有属性,设计了多少产品
*/
private int products;
public ProductManager(String name, int kpi, int products) {
super(name, kpi);
this.products = products;
}
@Override
public void accept(IVisitor visitor) {
visitor.visit(this);
}
/**
* 获取产品经理设计的产品数
* @return
*/
public int getProductsCount(){
return this.products;
}
}
在上面,我们定义了软件开发工程师和产品经理两种角色,同时两种角色的侧重点不同,有代码量和产品设计量。
声明抽象访问者
/**
* 声明抽象访问者接口
* @author Iflytek_dsw
*
*/
interface IVisitor {
/**
* 访问开发工程师
* @param developerStaff
*/
void visit(DeveloperStaff developerStaff);
/**
* 访问产品经理
* @param deProductManager
*/
void visit(ProductManager deProductManager);
}
这里我们根据对应的元素种类(员工种类)声明具体的角色访问者接口。
声明具体的访问者接口
/**
* CEO关注
* @author Iflytek_dsw
*
*/
class CEOVisitor implements IVisitor{
@Override
public void visit(DeveloperStaff developerStaff) {
System.out.println("CEO只关注员工的kpi值,姓名:" + developerStaff.getName()
+ "--kpi:" + developerStaff.getKpi());
}
@Override
public void visit(ProductManager deProductManager) {
System.out.println("CEO只关注员工的kpi值,姓名:" + deProductManager.getName()
+ "--kpi:" + deProductManager.getKpi());
}
}
/**
* CTO访问者,关注员工的成功量
* @author Iflytek_dsw
*
*/
class CTOVisitor implements IVisitor{
@Override
public void visit(DeveloperStaff developerStaff) {
System.out.println("CTO只关注员工的成果值,姓名:" + developerStaff.getName()
+ "--代码量:" + developerStaff.getCodesLines());
}
@Override
public void visit(ProductManager deProductManager) {
System.out.println("CTO只关注员工的成果值,姓名:" + deProductManager.getName()
+ "--产品数:" + deProductManager.getProductsCount());
}
}
这里我们声明了CEO访问者和CTO访问者接口,针对他们访问不同的关键信息。
声明对象结构角色
public class StaffManager {
private List<Staff> staffList;
public StaffManager(){
staffList = new ArrayList<>();
staffList.add(new DeveloperStaff("Android学习笔记", 2, 23000));
staffList.add(new DeveloperStaff("Andoter学习", 3, 25000));
staffList.add(new ProductManager("产品设计师1号技师", 2, 5));
staffList.add(new ProductManager("产品设计师2号技师", 2, 5));
}
/**
* 访问所有员工的信息
* @param visitor
*/
public void showStaffInfo(IVisitor visitor){
for(Staff staff : staffList){
staff.accept(visitor);
}
}
/**
* 添加单个员工
* @param staff
*/
public void addStaff(Staff staff){
staffList.add(staff);
}
/**
* 添加所有员工
* @param staffList
*/
public void addStaffAll(List<Staff> staffList){
this.staffList.addAll(staffList);
}
}
声明StaffManager类用来对Staff进行管理。
客户端
public class Client {
/**
* @param args
*/
public static void main(String[] args) {
StaffManager staffManager = new StaffManager();
//创建CEOVisitor
IVisitor ceoVisitor = new CEOVisitor();
staffManager.showStaffInfo(ceoVisitor);
//创建CTOVisitor
IVisitor ctoVisitor = new CTOVisitor();
staffManager.showStaffInfo(ctoVisitor);
}
}
结果
CEO只关注员工的kpi值,姓名:Android学习笔记--kpi:2
CEO只关注员工的kpi值,姓名:Andoter学习--kpi:3
CEO只关注员工的kpi值,姓名:产品设计师1号技师--kpi:2
CEO只关注员工的kpi值,姓名:产品设计师2号技师--kpi:2
CTO只关注员工的成果值,姓名:Android学习笔记--代码量:23000
CTO只关注员工的成果值,姓名:Andoter学习--代码量:25000
CTO只关注员工的成果值,姓名:产品设计师1号技师--产品数:5
CTO只关注员工的成果值,姓名:产品设计师2号技师--产品数:5
访问者模式优缺点
优点
- 良好的扩展性,能够在不修改对象结构的元素下,为结构中的对象添加新功能;
- 良好的复用性,通过访问者来定义整个对象结构通用的功能,从而提高复用程序;
- 分离无关行为。可以通过访问者分离无关的行为,把相关的行为封装在一起,构成一个访问者,这样每一个访问者的功能都比较单一。
缺点
- 对象结构变化很困难。在于增添新的Element子类的时候会导致Visitor类发生改变,而且随着Element子类的增加,Visitor类会越来越庞大。
- 破坏封装。访问者模式通常需要对象结构开放内部数据给访问者和ObjectStructure,这破坏了对象的封装性。
二次分派技术
一个方法根据两个宗量的类型来决定执行不同的代码,这就是“双重分派”。Java语言不支持动态的多分派,也就意味着Java不支持动态的双分派。但是通过使用设计模式,也可以在Java语言里实现动态的双重分派。
案例演示
创建观察元素Element
class Problem {
void accept(Support support){
support.solve(this);
}
}
class HardProblem extends Problem{
@Override
void accept(Support support) {
support.solve(this);
}
}
创建Element元素,同时声明accept方法用来接收观察者。
创建观察者
class Support {
void solve(Problem problem){
System.out.println("普通支持者:修复一般问题");
}
void solve(HardProblem hardProblem){
System.out.println("普通支持者:修复困难问题");
}
}
class SpecialSupport extends Support{
@Override
void solve(Problem problem) {
System.out.println("专家支持者:修复一般问题");
}
@Override
void solve(HardProblem hardProblem) {
System.out.println("专家支持者:修复困难问题");
}
}
客户端演示
public class Client {
/**
* @param args
*/
public static void main(String[] args) {
Problem problem = new Problem();
Problem hardProblem = new HardProblem();
/**
* 单分派,因为solve函数是一个多态函数,会通过多态找到SpecialSupport中的solve方法
* 但由于函数的参数类型是静态绑定的,即Problem、HardProblem的静态类型是Problem。
* 所以编译到problem、hardProblem时,固定地绑定到solve(Problem)的函数,尽管运行期间
* problem、hardProblem的实际类型不同,但是Java并不会根据根据这个差别去分别调用对应的重载函数,
* 即不会进行第2次分派,即Java只支持单分派。
*/
SpecialSupport specialSupport = new SpecialSupport();
specialSupport.solve(problem);
specialSupport.solve(hardProblem);
System.out.println("");
/**
* 模拟双分派
* 通过在编译时期就让编译器知道传入的类型,就可以正确调用重载函数了。
* 可如何做到这点呢?解决技巧是反客为主,即让参数作为主调函数,并在参数类中提供一个多态的accept方法,
* 这个函数最主要目的是为了利用多态得到参数对象的实际类型
*/
problem.accept(specialSupport);
hardProblem.accept(specialSupport);
}
}
在第一次执行中,通过将Problem绑定到Support上进行,这种情况属于单分派,因为solve函数是一个多态函数,会通过多态找到SpecialSupport中的solve方法,但由于函数的参数类型是静态绑定的,即Problem、HardProblem的静态类型是Problem。所以编译到problem、hardProblem时,固定地绑定到solve(Problem)的函数,尽管运行期间problem、hardProblem的实际类型不同,但是Java并不会根据根据这个差别去分别调用对应的重载函数,即不会进行第2次分派,即Java只支持单分派。
第二次执行中,将problem指定具体的Support,属于模拟双分派,通过在编译时期就让编译器知道传入的类型,就可以正确调用重载函数了。可如何做到这点呢?解决技巧是反客为主,即让参数作为主调函数,并在参数类中提供一个多态的accept方法,这个函数最主要目的是为了利用多态得到参数对象的实际类型。
运行结果
专家支持者:修复一般问题
专家支持者:修复一般问题
专家支持者:修复一般问题
专家支持者:修复困难问题
两次分派技术使客户端的请求不再被静态地绑定在元素对象上,这个时候真正执行什么样的功能同时取决于访问者类型和元素的类型,就算同一种元素类型,只要访问者的类型不同,最终执行功能也会不一样****。这样一来,就可以在元素对象不变的情况下,通过改变访问者类型来改变真正执行的功能。
参照文献: