1. 程式人生 > >操作複雜物件結構——訪問者模式

操作複雜物件結構——訪問者模式

想必大家都去過醫院,雖然沒有人喜歡去醫院(愛崗敬業的醫務工作人員除外,微笑)。在醫生開具處方單(藥單)後,很多醫院都存在如下處理流程:劃價人員拿到處方單之後根據藥品名稱和數量計算總價,藥房工作人員根據藥品名稱和數量準備藥品,如圖26-1所示:

      在圖26-1中,我們可以將處方單看成一個藥品資訊的集合,裡面包含了一種或多種不同型別的藥品資訊,不同型別的工作人員(如劃價人員和藥房工作人員)在操作同一個藥品資訊集合時將提供不同的處理方式,而且可能還會增加新型別的工作人員來操作處方單。

     在軟體開發中,有時候我們也需要處理像處方單這樣的集合物件結構,在該物件結構中儲存了多個不同型別的物件資訊,而且對同一物件結構中的元素的操作方式並不唯一,可能需要提供多種不同的處理方式,還有可能增加新的處理方式。在設計模式中,有一種模式可以滿足上述要求,其模式動機就是以不同的方式操作複雜物件結構,該模式就是我們本章將要介紹的訪問者模式。

1 OA系統中員工資料彙總

       Sunny軟體公司欲為某銀行開發一套OA系統,在該OA系統中包含一個員工資訊管理子系統,該銀行員工包括正式員工和臨時工,每週人力資源部和財務部等部門需要對員工資料進行彙總,彙總資料包括員工工作時間、員工工資等。該公司基本制度如下:

       (1)正式員工(Full time  Employee)每週工作時間為40小時,不同級別、不同部門的員工每週基本工資不同;如果超過40小時,超出部分按照100/小時作為加班費;如果少於40小時,所缺時間按照請假處理,請假所扣工資以80/小時計算,直到基本工資扣除到零為止。除了記錄實際工作時間外,人力資源部需記錄加班時長或請假時長,作為員工平時表現的一項依據。

       (2)臨時工(Part time  Employee)每週工作時間不固定,基本工資按小時計算,不同崗位的臨時工小時工資不同。人力資源部只需記錄實際工作時間。

       人力資源部和財務部工作人員可以根據各自的需要對員工資料進行彙總處理,人力資源部負責彙總每週員工工作時間,而財務部負責計算每週員工工資。

       Sunny軟體公司開發人員針對上述需求,提出了一個初始解決方案,其核心程式碼如下所示:

import java.util.*;

class EmployeeList
{
	private ArrayList<Employee> list = new ArrayList<Employee>(); //員工集合

    //增加員工
	public void addEmployee(Employee employee) 
	{
		list.add(employee);
	}
    
    //處理員工資料
	public void handle(String departmentName)
	{
		if(departmentName.equalsIgnoreCase("財務部")) //財務部處理員工資料
		{
			for(Object obj : list)
			{
				if(obj.getClass().getName().equalsIgnoreCase("FulltimeEmployee"))
				{
					System.out.println("財務部處理全職員工資料!");			
				}
				else 
				{
					System.out.println("財務部處理兼職員工資料!");
				}
			}
		}
		else if(departmentName.equalsIgnoreCase("人力資源部")) //人力資源部處理員工資料
		{
			for(Object obj : list)
			{
				if(obj.getClass().getName().equalsIgnoreCase("FulltimeEmployee"))
				{
					System.out.println("人力資源部處理全職員工資料!");					
				}
				else 
				{
					System.out.println("人力資源部處理兼職員工資料!");
				}
			}			
		}
	}
}

EmployeeList類的handle()方法中,通過對部門名稱和員工型別進行判斷,不同部門對不同型別的員工進行了不同的處理,滿足了員工資料彙總的要求。但是該解決方案存在如下幾個問題:

      (1) EmployeeList類非常龐大,它將各個部門處理各類員工資料的程式碼集中在一個類中,在具體實現時,程式碼將相當冗長,EmployeeList類承擔了過多的職責,既不方便程式碼的複用,也不利於系統的擴充套件,違背了“單一職責原則”。

      (2)在程式碼中包含大量的“if…else…”條件判斷語句,既需要對不同部門進行判斷,又需要對不同型別的員工進行判斷,還將出現巢狀的條件判斷語句,導致測試和維護難度增大。

      (3)如果要增加一個新的部門來操作員工集合,不得不修改EmployeeList類的原始碼,在handle()方法中增加一個新的條件判斷語句和一些業務處理程式碼來實現新部門的訪問操作。這違背了“開閉原則”,系統的靈活性和可擴充套件性有待提高。

      (4)如果要增加一種新型別的員工,同樣需要修改EmployeeList類的原始碼,在不同部門的處理程式碼中增加對新型別員工的處理邏輯,這也違背了“開閉原則”。

      如何解決上述問題?如何為同一集合物件中的元素提供多種不同的操作方式?訪問者模式就是一個值得考慮的解決方案,它可以在一定程度上解決上述問題(解決大部分問題)。訪問者模式可以為為不同型別的元素提供多種訪問操作方式,而且可以在不修改原有系統的情況下增加新的操作方式。

2 訪問者模式概述

      訪問者模式是一種較為複雜的行為型設計模式,它包含訪問者和被訪問元素兩個主要組成部分,這些被訪問的元素通常具有不同的型別,且不同的訪問者可以對它們進行不同的訪問操作。例如處方單中的各種藥品資訊就是被訪問的元素,而劃價人員和藥房工作人員就是訪問者。訪問者模式使得使用者可以在不修改現有系統的情況下擴充套件系統的功能,為這些不同型別的元素增加新的操作。

      在使用訪問者模式時,被訪問元素通常不是單獨存在的,它們儲存在一個集合中,這個集合被稱為“物件結構”,訪問者通過遍歷物件結構實現對其中儲存的元素的逐個操作。

      訪問者模式定義如下:

訪問者模式(Visitor Pattern):提供一個作用於某物件結構中的各元素的操作表示,它使我們可以在不改變各元素的類的前提下定義作用於這些元素的新操作。訪問者模式是一種物件行為型模式。

      訪問者模式的結構較為複雜,其結構如圖26-2所示:

      在訪問者模式結構圖中包含如下幾個角色:

      ●Vistor(抽象訪問者):抽象訪問者為物件結構中每一個具體元素類ConcreteElement宣告一個訪問操作,從這個操作的名稱或引數型別可以清楚知道需要訪問的具體元素的型別,具體訪問者需要實現這些操作方法,定義對這些元素的訪問操作。

      ●ConcreteVisitor(具體訪問者):具體訪問者實現了每個由抽象訪問者宣告的操作,每一個操作用於訪問物件結構中一種型別的元素。

      ●Element(抽象元素):抽象元素一般是抽象類或者介面,它定義一個accept()方法,該方法通常以一個抽象訪問者作為引數。【稍後將介紹為什麼要這樣設計。】

      ●ConcreteElement(具體元素):具體元素實現了accept()方法,在accept()方法中呼叫訪問者的訪問方法以便完成對一個元素的操作。

      ●ObjectStructure(物件結構):物件結構是一個元素的集合,它用於存放元素物件,並且提供了遍歷其內部元素的方法。它可以結合組合模式來實現,也可以是一個簡單的集合物件,如一個List物件或一個Set物件。

      訪問者模式中物件結構儲存了不同型別的元素物件,以供不同訪問者訪問。訪問者模式包括兩個層次結構,一個是訪問者層次結構,提供了抽象訪問者和具體訪問者,一個是元素層次結構,提供了抽象元素和具體元素。相同的訪問者可以以不同的方式訪問不同的元素,相同的元素可以接受不同訪問者以不同訪問方式訪問。在訪問者模式中,增加新的訪問者無須修改原有系統,系統具有較好的可擴充套件性。

      在訪問者模式中,抽象訪問者定義了訪問元素物件的方法,通常為每一種型別的元素物件都提供一個訪問方法,而具體訪問者可以實現這些訪問方法。這些訪問方法的命名一般有兩種方式:一種是直接在方法名中標明待訪問元素物件的具體型別,如visitElementA(ElementA elementA),還有一種是統一取名為visit(),通過引數型別的不同來定義一系列過載的visit()方法。當然,如果所有的訪問者對某一型別的元素的訪問操作都相同,則可以將操作程式碼移到抽象訪問者類中,其典型程式碼如下所示:

abstract class Visitor
{
	public abstract void visit(ConcreteElementA elementA);
	public abstract void visit(ConcreteElementB elementB);
	public void visit(ConcreteElementC elementC)
	{
		//元素ConcreteElementC操作程式碼
	}
}

在這裡使用了過載visit()方法的方式來定義多個方法用於操作不同型別的元素物件。在抽象訪問者Visitor類的子類ConcreteVisitor中實現了抽象的訪問方法,用於定義對不同型別元素物件的操作,具體訪問者類典型程式碼如下所示:

class ConcreteVisitor extends Visitor
{
	public void visit(ConcreteElementA elementA)
	{
		//元素ConcreteElementA操作程式碼
	}
	public void visit(ConcreteElementB elementB)
	{
		//元素ConcreteElementB操作程式碼
	}
}

對於元素類而言,在其中一般都定義了一個accept()方法,用於接受訪問者的訪問,典型的抽象元素類程式碼如下所示:

interface Element
{
	public void accept(Visitor visitor);
}

需要注意的是該方法傳入了一個抽象訪問者Visitor型別的引數,即針對抽象訪問者進行程式設計,而不是具體訪問者,在程式執行時再確定具體訪問者的型別,並呼叫具體訪問者物件的visit()方法實現對元素物件的操作。在抽象元素類Element的子類中實現了accept()方法,用於接受訪問者的訪問,在具體元素類中還可以定義不同型別的元素所特有的業務方法,其典型程式碼如下所示:

class ConcreteElementA implements Element
{
	public void accept(Visitor visitor)
	{
		visitor.visit(this);
	}
	
	public void operationA()
	{
		//業務方法
	}
}

在具體元素類ConcreteElementAaccept()方法中,通過呼叫Visitor類的visit()方法實現對元素的訪問,並以當前物件作為visit()方法的引數。其具體執行過程如下:

      (1)呼叫具體元素類的accept(Visitor visitor)方法,並Visitor子類物件作為其引數

      (2)在具體元素類accept(Visitor visitor)方法內部呼叫傳入的Visitor物件的visit()方法,如visit(ConcreteElementA elementA)將當前具體元素類物件(this)作為引數,如visitor.visit(this)

      (3)執行Visitor物件的visit()方法,在其中還可以呼叫具體元素物件的業務方法。

      這種呼叫機制也稱為“雙重分派”,正因為使用了雙重分派機制,使得增加新的訪問者無須修改現有類庫程式碼,只需將新的訪問者物件作為引數傳入具體元素物件的accept()方法,程式執行時將回調在新增Visitor類中定義的visit()方法,從而增加新的元素訪問方式。

思考

雙重分派機制如何用程式碼實現?


      在訪問者模式中,物件結構是一個集合,它用於儲存元素物件並接受訪問者的訪問,其典型程式碼如下所示:

class ObjectStructure
{
	private ArrayList<Element> list = new ArrayList<Element>(); //定義一個集合用於儲存元素物件

	public void accept(Visitor visitor)
	{
		Iterator i=list.iterator();
		
		while(i.hasNext())
		{
			((Element)i.next()).accept(visitor); //遍歷訪問集合中的每一個元素
		}
	}

	public void addElement(Element element)
	{
		list.add(element);
	}

	public void removeElement(Element element)
	{
		list.remove(element);
	}
}

在物件結構中可以使用迭代器對儲存在集合中的元素物件進行遍歷,並逐個呼叫每一個物件的accept()方法,實現對元素物件的訪問操作。

思考

訪問者模式是否符合“開閉原則”?【從增加新的訪問者和增加新的元素兩方面考慮。】

3 完整解決方案

      Sunny軟體公司開發人員使用訪問者模式對OA系統中員工資料彙總模組進行重構,使得系統可以很方便地增加新型別的訪問者,更加符合“單一職責原則”和“開閉原則”,重構後的基本結構如圖26-3所示:

       在圖26-3中,FADepartment表示財務部,HRDepartment表示人力資源部,它們充當具體訪問者角色,其抽象父類Department充當抽象訪問者角色;EmployeeList充當物件結構,用於儲存員工列表;FulltimeEmployee表示正式員工,ParttimeEmployee表示臨時工,它們充當具體元素角色,其父介面Employee充當抽象元素角色。完整程式碼如下所示:
import java.util.*;

//員工類:抽象元素類
interface Employee
{
	public void accept(Department handler); //接受一個抽象訪問者訪問
}

//全職員工類:具體元素類
class FulltimeEmployee implements Employee
{
	private String name;
	private double weeklyWage;
	private int workTime;

	public FulltimeEmployee(String name,double weeklyWage,int workTime)
	{
		this.name = name;
		this.weeklyWage = weeklyWage;
		this.workTime = workTime;
	}	

	public void setName(String name) 
    {
		this.name = name; 
	}

	public void setWeeklyWage(double weeklyWage) 
    {
		this.weeklyWage = weeklyWage; 
	}

	public void setWorkTime(int workTime) 
    {
		this.workTime = workTime; 
	}

	public String getName() 
    {
		return (this.name); 
	}

	public double getWeeklyWage() 
    {
		return (this.weeklyWage); 
	}

	public int getWorkTime() 
    {
		return (this.workTime); 
	}

	public void accept(Department handler)
    {
		handler.visit(this); //呼叫訪問者的訪問方法
	}
}

//兼職員工類:具體元素類
class ParttimeEmployee implements Employee
{
	private String name;
	private double hourWage;
	private int workTime;

	public ParttimeEmployee(String name,double hourWage,int workTime)
	{
		this.name = name;
		this.hourWage = hourWage;
		this.workTime = workTime;
	}	

	public void setName(String name) 
    {
		this.name = name; 
	}

	public void setHourWage(double hourWage) 
    {
		this.hourWage = hourWage; 
	}

	public void setWorkTime(int workTime) 
    {
		this.workTime = workTime; 
	}

	public String getName() 
    {
		return (this.name); 
	}

	public double getHourWage() 
    {
		return (this.hourWage); 
	}

	public int getWorkTime() 
    {
		return (this.workTime); 
	}

	public void accept(Department handler)
    {
		handler.visit(this); //呼叫訪問者的訪問方法
	}
}

//部門類:抽象訪問者類
abstract class Department
{
    //宣告一組過載的訪問方法,用於訪問不同型別的具體元素
	public abstract void visit(FulltimeEmployee employee);
	public abstract void visit(ParttimeEmployee employee);	
}

//財務部類:具體訪問者類
class FADepartment extends Department
{
    //實現財務部對全職員工的訪問
	public void visit(FulltimeEmployee employee)
	{
		int workTime = employee.getWorkTime();
		double weekWage = employee.getWeeklyWage();
		if(workTime > 40)
		{
			weekWage = weekWage + (workTime - 40) * 100;
		}
		else if(workTime < 40)
		{
			weekWage = weekWage - (40 - workTime) * 80;
			if(weekWage < 0)
			{
				weekWage = 0;
			}
		}
		System.out.println("正式員工" + employee.getName() + "實際工資為:" + weekWage + "元。");			
	}

    //實現財務部對兼職員工的訪問
	public void visit(ParttimeEmployee employee)
	{
		int workTime = employee.getWorkTime();
		double hourWage = employee.getHourWage();
		System.out.println("臨時工" + employee.getName() + "實際工資為:" + workTime * hourWage + "元。");		
	}		
}

//人力資源部類:具體訪問者類
class HRDepartment extends Department
{
    //實現人力資源部對全職員工的訪問
	public void visit(FulltimeEmployee employee)
	{
		int workTime = employee.getWorkTime();
		System.out.println("正式員工" + employee.getName() + "實際工作時間為:" + workTime + "小時。");
		if(workTime > 40)
		{
			System.out.println("正式員工" + employee.getName() + "加班時間為:" + (workTime - 40) + "小時。");
		}
		else if(workTime < 40)
		{
			System.out.println("正式員工" + employee.getName() + "請假時間為:" + (40 - workTime) + "小時。");
		}						
	}

    //實現人力資源部對兼職員工的訪問
	public void visit(ParttimeEmployee employee)
	{
		int workTime = employee.getWorkTime();
		System.out.println("臨時工" + employee.getName() + "實際工作時間為:" + workTime + "小時。");
	}		
}

//員工列表類:物件結構
class EmployeeList
{
    //定義一個集合用於儲存員工物件
	private ArrayList<Employee> list = new ArrayList<Employee>();

	public void addEmployee(Employee employee)
	{
		list.add(employee);
	}

    //遍歷訪問員工集合中的每一個員工物件
	public void accept(Department handler)
	{
		for(Object obj : list)
		{
			((Employee)obj).accept(handler);
		}
	}
}

為了提高系統的靈活性和可擴充套件性,我們將具體訪問者類的類名儲存在配置檔案中,並通過工具類XMLUtil來讀取配置檔案並反射生成物件,XMLUtil類的程式碼如下所示:

import javax.xml.parsers.*;
import org.w3c.dom.*;
import org.xml.sax.SAXException;
import java.io.*;
class XMLUtil
{
    //該方法用於從XML配置檔案中提取具體類類名,並返回一個例項物件
    public static Object getBean()
    {
		try
		{
			//建立文件物件
			DocumentBuilderFactory dFactory = DocumentBuilderFactory.newInstance();
			DocumentBuilder builder = dFactory.newDocumentBuilder();
			Document doc;							
			doc = builder.parse(new File("config.xml")); 
		
			//獲取包含類名的文字節點
			NodeList nl = doc.getElementsByTagName("className");
            Node classNode=nl.item(0).getFirstChild();
            String cName=classNode.getNodeValue();
            
            //通過類名生成例項物件並將其返回
            Class c=Class.forName(cName);
	  	    Object obj=c.newInstance();
            return obj;
        }   
        catch(Exception e)
        {
           	e.printStackTrace();
           	return null;
       	}
    }
}

配置檔案config.xml中儲存了具體訪問者類的類名,程式碼如下所示:

<?xml version="1.0"?>
<config>
	<className>FADepartment</className>
</config>

編寫如下客戶端測試程式碼:

class Client
{
	public static void main(String args[])
	{
		EmployeeList list = new EmployeeList();
		Employee fte1,fte2,fte3,pte1,pte2;

		fte1 = new FulltimeEmployee("張無忌",3200.00,45);
		fte2 = new FulltimeEmployee("楊過",2000.00,40);
		fte3 = new FulltimeEmployee("段譽",2400.00,38);
		pte1 = new ParttimeEmployee("洪七公",80.00,20);
		pte2 = new ParttimeEmployee("郭靖",60.00,18);

		list.addEmployee(fte1);
		list.addEmployee(fte2);
		list.addEmployee(fte3);
		list.addEmployee(pte1);
		list.addEmployee(pte2);

		Department dep;
		dep = (Department)XMLUtil.getBean();
		list.accept(dep);
	}
}

編譯並執行程式,輸出結果如下:

正式員工張無忌實際工資為:3700.0元。

正式員工楊過實際工資為:2000.0元。

正式員工段譽實際工資為:2240.0元。

臨時工洪七公實際工資為:1600.0元。

臨時工郭靖實際工資為:1080.0元。

      如果需要更換具體訪問者類,無須修改原始碼,只需修改配置檔案,例如將訪問者類由財務部改為人力資源部,只需將儲存在配置檔案中的具體訪問者類FADepartment改為HRDepartment,如下程式碼所示:

<?xml version="1.0"?>
<config>
    <className>HRDepartment</className>
</config>

重新執行客戶端程式,輸出結果如下:

正式員工張無忌實際工作時間為:45小時。

正式員工張無忌加班時間為:5小時。

正式員工楊過實際工作時間為:40小時。

正式員工段譽實際工作時間為:38小時。

正式員工段譽請假時間為:2小時。

臨時工洪七公實際工作時間為:20小時。

臨時工郭靖實際工作時間為:18小時。

      如果要在系統中增加一種新的訪問者,無須修改原始碼,只要增加一個新的具體訪問者類即可,在該具體訪問者中封裝了新的操作元素物件的方法。從增加新的訪問者的角度來看,訪問者模式符合“開閉原則”。

      如果要在系統中增加一種新的具體元素,例如增加一種新的員工型別為“退休人員”,由於原有系統並未提供相應的訪問介面(在抽象訪問者中沒有宣告任何訪問“退休人員”的方法),因此必須對原有系統進行修改,在原有的抽象訪問者類和具體訪問者類中增加相應的訪問方法。從增加新的元素的角度來看,訪問者模式違背了“開閉原則”。

      綜上所述,訪問者模式與抽象工廠模式類似,對“開閉原則”的支援具有傾斜性,可以很方便地新增新的訪問者,但是新增新的元素較為麻煩。

4 訪問者模式與組合模式聯用

      在訪問者模式中,包含一個用於儲存元素物件集合的物件結構,我們通常可以使用迭代器來遍歷物件結構,同時具體元素之間可以存在整體與部分關係,有些元素作為容器物件,有些元素作為成員物件,可以使用組合模式來組織元素。引入組合模式後的訪問者模式結構圖如圖26-4所示:

        需要注意的是,在圖26-4所示結構中,由於葉子元素的遍歷操作已經在容器元素中完成,因此要防止單獨將已增加到容器元素中的葉子元素再次加入物件結構中,物件結構中只儲存容器元素和孤立的葉子元素。

5 訪問者模式總結

      由於訪問者模式的使用條件較為苛刻,本身結構也較為複雜,因此在實際應用中使用頻率不是特別高。當系統中存在一個較為複雜的物件結構,且不同訪問者對其所採取的操作也不相同時,可以考慮使用訪問者模式進行設計。在XML文件解析、編譯器的設計、複雜集合物件的處理等領域訪問者模式得到了一定的應用。

1.主要優點

      訪問者模式的主要優點如下:

(1)增加新的訪問操作很方便。使用訪問者模式,增加新的訪問操作就意味著增加一個新的具體訪問者類,實現簡單,無須修改原始碼,符合“開閉原則”。

(2)將有關元素物件的訪問行為集中到一個訪問者物件中,而不是分散在一個個的元素類中。類的職責更加清晰,有利於物件結構中元素物件的複用,相同的物件結構可以供多個不同的訪問者訪問。

(3)讓使用者能夠在不修改現有元素類層次結構的情況下,定義作用於該層次結構的操作。

2.主要缺點

      訪問者模式的主要缺點如下:

(1)增加新的元素類很困難。在訪問者模式中,每增加一個新的元素類都意味著要在抽象訪問者角色中增加一個新的抽象操作,並在每一個具體訪問者類中增加相應的具體操作,這違背了“開閉原則”的要求。

(2)破壞封裝。訪問者模式要求訪問者物件訪問並呼叫每一個元素物件的操作,這意味著元素物件有時候必須暴露一些自己的內部操作和內部狀態,否則無法供訪問者訪問。

3.適用場景

      在以下情況下可以考慮使用訪問者模式:

(1)一個物件結構包含多個型別的物件,希望對這些物件實施一些依賴其具體型別的操作。在訪問者中針對每一種具體的型別都提供了一個訪問操作,不同型別的物件可以有不同的訪問操作。

(2)需要對一個物件結構中的物件進行很多不同的並且不相關的操作,而需要避免讓這些操作“汙染”這些物件的類,也不希望在增加新操作時修改這些類。訪問者模式使得我們可以將相關的訪問操作集中起來定義在訪問者類中,物件結構可以被多個不同的訪問者類所使用,將物件本身與物件的訪問操作分離。

(3)物件結構中物件對應的類很少改變,但經常需要在此物件結構上定義新的操作。

練習

Sunny軟體公司欲為某高校開發一套獎勵審批系統,該系統可以實現教師獎勵和學生獎勵的審批(Award Check),如果教師發表論文數超過10篇或者學生論文超過2篇可以評選科研獎,如果教師教學反饋分大於等於90分或者學生平均成績大於等於90分可以評選成績優秀獎。試使用訪問者模式設計該系統,以判斷候選人集合中的教師或學生是否符合某種獲獎要求。