首页/文章/ 详情

仿真软件开发之:Visitor访问者模式

1年前浏览185

拿着刚磨好的热咖啡,我坐在了显示器前。“美好的一天又开始了”,我想。


昨晚做完了一个非常困难的任务并送给美国同事Review,因此今天只需要根据他们提出的意见适当修改代码并提交,一周的任务就完成了。剩下的两三天里,我就可以有一些空余的时间看看其它资料来继续充实自己了。


打开Review Board,可以看到我的代码已经被标记为可以提交,但是下面所留的注解引起了我的注意:


“Great job! With this solution, we can start our integration work and perform testing earlier. One thing is that we have used several “instance of” in the overrided function. That’s double dispatch, an obvious signature for using Visitor pattern. We can switch to that pattern in our future work.”


Visitor模式我知道,但是Double Dispatch是什么意思?我打开了搜索引擎,找了几篇有关Double Dispatch的介绍性文章开始读了起来。


Double Dispatch


当然,对Double Dispatch描述最为清晰和准确的还是在Wikipedia上:


In software engineering, double dispatch is a special form of multiple dispatch, and a mechanism that dispatches a function call to different concrete functions depending on the runtime types of two objects involved in the call. In most object-oriented systems, the concrete function that is called from a function call in the code depends on the dynamic type of a single object and therefore they are known as single dispatch calls, or simply virtual function calls.


而在该段文字的最后,我看到了一个再熟悉不过的名词“virtual function”。一看到这个词,我脑中就开始回忆对虚函数进行调用的步骤:在调用虚函数的时候,C++运行时将首先查找对象所对应的虚函数表,然后根据虚函数表中所记录的地址来调用相应的虚函数实现。由于虚函数表是与类型相关联的,因此对虚函数进行调用所执行的逻辑就与对象本身的类型相关。


而Double Dispatch则需要和参与函数调用的两个对象相关。于是我想:那通过为类型添加一个函数重载,不就可以实现Double Dispatch了么?我打开Visual Studio,并在其中写下了如下的代码:


// 普通汽车,折扣为0.03

class Vehicle

{

public:

virtual double GetBaseDiscountRate() { return 0.03; }

};

// 由于是奔驰特销商,因此可以得到更大的折扣

class Benz : public Vehicle

{

public:

virtual double GetBaseDiscountRate() { return 0.06; }

};

// 普通的销售人员,只能按照公司规定的折扣进行销售

class Sales

{

public:

virtual double GetDiscountRate(Vehicle& vehicle)

{

return vehicle.GetBaseDiscountRate();

}

virtual double GetDiscountRate(Benz& benz)

{

return benz.GetBaseDiscountRate();

}

};

// 销售经理,可以针对奔驰提供额外的优惠

class SalesManager : public Sales

{

public:

virtual double GetDiscountRate(Vehicle& vehicle)

{

return vehicle.GetBaseDiscountRate();

}

virtual double GetDiscountRate(Benz& benz)

{

return benz.GetBaseDiscountRate() * 1.1;

}

};

int _tmain(int argc, _TCHAR* argv[])

{

// 有两辆车需要销售,一辆是普通轿车,而另一辆则是奔驰

Vehicle& vehicle = Vehicle();

Vehicle& benz = Benz();

// 向普通销售询问这两辆车的折扣

Sales* pSales = new Sales();

double rate = pSales->GetDiscountRate(vehicle);

cout << "Sales: The rate for common vehicle is: " << rate << endl;

rate = pSales->GetDiscountRate(benz);

cout << "Sales: The rate for benz is: " << rate << endl;

// 向销售经理询问这两辆车的折扣

SalesManager* pSalesManager = new SalesManager();

rate = pSalesManager->GetDiscountRate(vehicle);

cout << "Sales Manager: The rate for common vehicle is: " << rate << endl;

rate = pSalesManager->GetDiscountRate(benz);

cout << "Sales Manager: The rate for benz is: " << rate << endl;

return 0;

}


点击运行,答案却不是我想的那样:



啊,销售经理并没有提供额外的折扣。这可是个大 麻烦。启动Visual Studio的调试功能,我看到了语句“pSalesManager->GetDiscountRate(benz)”所调用的是SalesManager类中定义的为普通汽车所定义的重载:


class SalesManager : public Sales

{

public:

virtual double GetDiscountRate(Vehicle& vehicle) <----传入的参数的运行时类型是Benz,却调用了为Vehicle定义的重载

{

return vehicle.GetBaseDiscountRate();

}

……

};


难道我对函数重载的理解不对?在搜索引擎中键入“C++ overload resolution”,我打开了C++标准中有关函数重载决议的讲解。其开始的一段话就给了我答案:


In order to compile a function call, the compiler must first perform name lookup, which, for functions, may involve argument-dependent lookup, and for function templates may be followed by template argument deduction. If these steps produce more than one candidate function, then overload resolution is performed to select the function that will actually be called.


哦,对!函数重载决议是在编译时完成的。也正因为我们传入的是Vehicle类型的引用,编译器并没有办法知道在运行时传入GetDiscountRate()这个函数的参数到底是Vehicle实例还是Benz实例,因此编译器只可能选择调用接受Vehicle类型引用的重载。如果传入参数benz的类型不再是Vehicle的引用,而是更具体的Benz的引用,那么编译器将会正确地决定到底其所需要调用的函数:



但这就不再是根据参数的类型动态决定需要调用的逻辑了,也就不再是Double Dispatch了。要如何达到这种效果呢?我苦苦地思索着。


“你在想什么?”身边的同事递给我今天公司派发的水果,一边吃着一边问我。我就把我刚刚写出的程序以及我现在正在考虑的问题告诉了他。


“既然你要动态决定需要调用的逻辑,那么就把这些逻辑放到动态运行的地方去啊,比如说放到你那些汽车类里面然后暴露一个虚函数,就可以根据所传入的汽车类型决定该汽车所需要使用的折扣率了啊。”


“哦对”,我恍然大悟。C++在运行时动态决议的基本方法就是虚函数,也就是一种Single Dispatch,如果依次在对象和传入参数上连续调用两次虚函数,那么它不就是Double Dispatch了么?在销售汽车这个例子中,我希望同时根据销售人员的职称和所销售的汽车类型一起决定需要执行的逻辑。那么我们首先需要通过Sales类型的指针调用一个虚函数,从而可以根据销售人员的实际类型来决定其在销售时所需要执行的实际逻辑。而在执行这些逻辑的过程中,我们还可以继续调用传入参数实例上定义的虚函数,就可以根据传入参数的类型来决定需要执行的逻辑了!


说做就做。我在Vehicle类中添加一个新的虚函数GetManagerDiscountRate(),以允许SalesManager类的函数实现中调用以获得销售经理所能拿到的折扣,并在Benz类中重写它以返回针对奔驰的特有折扣率。而在Sales以及SalesManager类的实现中,我们则需要分别调用GetBaseDiscountRate()以及新的GetManagerDiscountRate()函数来分别返回普通销售和销售经理所能拿到的折扣率。通过这种方式,我们就可以同时根据销售人员的职务以及所销售车型来共同决定所使用的折扣率了。更改后的代码如下所示:


// 普通汽车,折扣为0.03

class Vehicle

{

public:

virtual double GetBaseDiscountRate() { return 0.03; }

virtual double GetManagerDiscountRate() { return 0.03; }

};

// 由于是奔驰特销商,因此可以得到更大的折扣

class Benz : public Vehicle

{

public:

virtual double GetBaseDiscountRate() { return 0.06; }

virtual double GetManagerDiscountRate() { return 0.066; }

};

// 普通的销售人员,只能按照公司规定的折扣进行销售

class Sales

{

public:

virtual double GetDiscountRate(Vehicle& vehicle)

{

return vehicle.GetBaseDiscountRate();

}

};

// 销售经理,可以针对某些车型提供额外的优惠

class SalesManager : public Sales

{

public:

virtual double GetDiscountRate(Vehicle& vehicle)

{

return vehicle.GetManagerDiscountRate();

}

};

int _tmain(int argc, _TCHAR* argv[])

{

// 需要销售的两辆车

Vehicle& vehicle = Vehicle();

Benz& benz = Benz();

// 向普通销售询问这两辆车的折扣

Sales* pSales = new Sales();

double rate = pSales->GetDiscountRate(vehicle);

cout << "Sales: The rate for common vehicle is: " << rate << endl;

rate = pSales->GetDiscountRate(benz);

cout << "Sales: The rate for benz is: " << rate << endl;

// 向销售经理询问这两辆车的折扣

SalesManager* pSalesManager = new SalesManager();

rate = pSalesManager->GetDiscountRate(vehicle);

cout << "Sales Manager: The rate for common vehicle is: " << rate << endl;

rate = pSalesManager->GetDiscountRate(benz);

cout << "Sales Manager: The rate for benz is: " << rate << endl;

return 0;

}


再次运行程序,我发现现在已经可以得到正确的结果了:



也就是说,我自创的Double Dispatch实现已经能够正确地运行了。


你好,Visitor


“你说为什么C++这些高级语言不直接支持Double Dispatch?”我问身边正在和水果奋斗的同事。


“不需要呗。”他头也不抬,随口回答了一句,又拿起了另一只水果。


话说,他可真能吃。


“真的不需要么?”我心里想,就又在搜索引擎中输入了“why C++ double dispatch”。


在多年的工作中,我已经养成了一种固定的学习习惯。例如对于一个知识点,我常常首先了解How,即它是如何工作的;然后是Why,也就是为什么按照这样的方式来工作;然后才是When,即在知道了为什么按照这样的方式来工作后,我们才能在适当的情况下使用它。


幸运的是,在很多论坛中已经讨论过为什么这些语言不直接支持Double Dispatch了。简单地说,一个语言常常不能支持所有的功能,否则这个语言将会变得非常复杂,编写它的编译器及运行时也将变成非常困难的事情。因此到底支持哪些功能实际上由一个语言的目标领域所决定的。在一个语言可以通过一种简单明了的方式解决一种特定问题的时候,该语言就不再必须为该特定问题提供一个内置的解决方案。这些解决方案会逐渐固定下来,并被赋予了一个特有的名字。例如C++中的一种常用模式就是Observer。该模式实现起来非常简单,也易于理解。而在其它语言中就可能提供了对Observer的原生支持,如C#中的delegate。而Visitor模式实际上就是C++对Double Dispatch功能的标准模拟。


接下来,我又搜索了几个Visitor模式的标准实现并开始比较自己所实现的Double Dispatch与Visitor模式标准实现之间的不同之处。这又是我的另一个习惯:实践常常可以检验出自己对于某个知识点的理解是否有偏差。就像我刚刚所犯下的对重载决议的理解错误一样,形成自己解决方案的过程常常会使自己理解某项技术为什么这么做有更深的理解。而通过对比自己的解决方案和标准解决方案,我可以发现别人所做的一些非常精巧的解决方案,并标准化自己的实现。


我仔细地检查了自己刚才所写的有关销售汽车的实例与标准Visitor模式实现之间的不同。显然Visitor模式的标准实现更为聪明:在Sales和SalesManager的成员函数中,编译器知道this所指向的实例的类型,因此将*this当作参数传入到函数中就可以正确地利用C++所提供的函数重载决议功能。这比我那种在实现中调用不同函数的方法高明了不知多少:


class SalesManager : public Sales

{

public:

virtual double GetDiscountRate(Vehicle& vehicle)

{

return vehicle.GetDiscountRate(*this); <----编译器知道*this是SalesManager类型实例,因此可以正确地选择接受SalesManager类型参数的重载

}

};


那么在Vehicle类以及Benz类中,我们只需要创建接收不同类型参数的函数重载即可:


class Benz : public Vehicle

{

public:

virtual double GetDiscountRate(Sales& sales) { return 0.06; }

virtual double GetDiscountRate(SalesManager& salesManager) { return 0.066; }

};


而在Visitor模式的标准实现中,我们则需要使用Visit()及Accept()函数对替换上面的各成员函数,并为所诱得汽车及销售人员定义一个公共接口。因此对于上面的销售汽车的示例,其标准的Visitor模式实现为:


class Sales;

class SalesManager;

// 汽车接口

class IVehicle

{

public:

virtual double Visit(Sales& sales) = 0;

virtual double Visit(SalesManager& sales) = 0;

};

// 普通汽车,折扣为0.03

class Vehicle : public IVehicle

{

public:

virtual double Visit(Sales& sales) { return 0.03; }

virtual double Visit(SalesManager& salesManager) { return 0.03; }

};

// 由于是奔驰特销商,因此可以得到更大的折扣

class Benz : public IVehicle

{

public:

virtual double Visit(Sales& sales) { return 0.06; }

virtual double Visit(SalesManager& salesManager) { return 0.066; }

};

class ISales

{

public:

virtual double Accept(IVehicle& vehicle) = 0;

};

// 普通的销售人员,只能按照公司规定的折扣进行销售

class Sales : public ISales

{

public:

virtual double Accept(IVehicle& vehicle)

{

return vehicle.Visit(*this);

}

};

// 销售经理,可以针对某些车型提供额外的优惠

class SalesManager : public ISales

{

public:

virtual double Accept(IVehicle& vehicle)

{

return vehicle.Visit(*this);

}

};

int _tmain(int argc, _TCHAR* argv[])

{

// 需要销售的两辆车

Vehicle& vehicle = Vehicle();

Benz& benz = Benz();

// 向普通销售询问这两辆车的折扣

Sales* pSales = new Sales();

double rate = pSales->Accept(vehicle);

cout << "Sales: The rate for common vehicle is: " << rate << endl;

rate = pSales->Accept(benz);

cout << "Sales: The rate for benz is: " << rate << endl;

// 向销售经理询问这两辆车的折扣

SalesManager* pSalesManager = new SalesManager();

rate = pSalesManager->Accept(vehicle);

cout << "Sales Manager: The rate for common vehicle is: " << rate << endl;

rate = pSalesManager->Accept(benz);

cout << "Sales Manager: The rate for benz is: " << rate << endl;

return 0;

}


“那Visitor模式该如何进行扩展呢?”我自己问自己。毕竟在企业级应用中,各组成的扩展性可以很大程度上决定系统的维护性和扩展性。


我注意到上面的Visitor模式实现中主要分为两大类类型:IVehicle和ISales。在该Visitor实现中添加一个新的汽车类型十分容易。从IVehicle派生并实现相应的逻辑即可:


class Fiat : public IVehicle

{

public:

virtual double Visit(Sales& sales) { return 0.05; }

virtual double Visit(SalesManager& salesManager) { return 0.06; }

};


但是添加一个实现了ISales接口的类型则非常困难:需要更改所有已知的汽车类型并添加特定于该接口实现类型的重载。


那在遇到两部分组成都需要更改的情况该怎么办呢?经过查找,我也发现了一种允许同时添加两类型的模式:Acyclic Visitor。除此之外,还有一系列相关的模式,如Hierachical Visitor Pattern。看来和Visitor模式相关的各种知识还真是不少呢。


我再次打开搜索引擎,继续我的自我学习之旅。而身边的同事也继续和水果奋斗着。



======== 华丽分割线 ======== 


摘要:无环访问者模式确实解决了访问者模式不易扩展的问题,但同时也使整个系统变得复杂了。


蜀国有3支常设军队,分别由关羽、张飞、赵云三人率领,皆能完成攻城略地、支援友军、收缩防御等作战任务。三人都是先主亲信,因此只要先主号令一出,如臂使指,三人莫不相从。

孔明就不同了。赵云一贯是好好先生,丞相号令一出,子龙绝无二话。张飞就不免嘟嘟囔囔几句,只有待丞相拿出气势强压他时,他才勉强听宣,虽然墨迹了些,但到底与子龙相同,还算人孔明的权威。关羽则完全不听丞相指挥,二爷豪气干云,也是熟读兵书文武兼备之人,属于自带智商的上将,平生只服兄长一人,丞相自然拿他没辙。

关羽军、张飞军、赵云军作为相对稳定的军事组织,属于蜀军的子类。如果将蜀军看做内部稳定的被访问者,那么先主和孔明就是两个典型的访问者。访问者如何更加合理地访问被访问者,就是本文讨论的主要内容。


1. 访问者模式Visitor

访问者模式,就是在被访问者(ShuArmy)及其实现类中增加一个accept接口方法,以便访问者(ArmyVisitor)的实现类能够通过该方法访问到其所在的被访问者类。

(类图:访问者模式)

接口ShuArmy的accept方法,规定了入参为接口ArmyVisitor,这样其实现类也必须继承这一约束。另一方面,接口ArmyVisitor又在其visit方法中依赖于接口ShuArmy的全部实现类。这样实际上就形成了一个闭环,如上面的类图所示。

如果画成矩阵,那么Visitor模式属于典型的紧密矩阵,也就是说,纵轴和横轴的每一个交叉点都必须填充:

ArmyVisitor

GuanYuArmy

ZhangFeiArmy

ZhaoYunArmy

King

visit

visit

visit

Premier

visit

visit

visit

先来看看基本的数据结构,即被访问者,蜀军、关羽军、张飞军和赵云军的代码:

在访问者模式中,被访问者的数据结构是基本稳定的,不会轻易变更。

再来看看访问者及其子类:先主访问者和丞相访问者的代码:


测试类。孔明和先主必须实现访问者接口的所有方法。假设先主可以任意调遣三军,那么孔明也不得不尝试着去调遣关羽,即使调遣函数实际上没有任何行为(函数体为空)。让我们来看看孔明作为访问者时遇到的情况:

Visitor模式中的两次分发形成了一个紧密的功能矩阵,一条轴是各路军队,另一条轴是不同的访问者,该矩阵的每个单元都被一项功能填充。

另一方面,被访问者(ShuArmy)层次结构的接口类依赖于访问者(ArmyVisitor)层次结构的接口类;同时,访问者接口类对被访问者的每个派生类都有对应的访问函数,由此形成一个依赖环,将所有被访问的派生类绑定在了一起,当被访问者增加新的派生类时,访问者接口及其所有派生类必须增加一个对应的访问函数。

由此带来的问题是,访问者模式的扩展不够灵活,访问者与被访问者之间无法很好地解耦。

 

2. 无环访问者模式Acyclic Visitor

无环访问者模式,是访问者模式的一种变体,其核心思想是通过把访问者接口类ArmyVisitor变成退化的接口类,来解除访问者模式中存在的依赖环。

退化的接口类ArmyVisitor种没有任何方法,这意味着它没有依赖于被访问者的所有派生类。

(类图:无环访问者模式)

在无环访问者模式中,访问者的所有子类类依然派生自访问者(ArmyVisitor)接口。同时,对于被访问者接口的每一个子类,都拥有一个对应的访问者接口。这意味着从派生类到接口的180度旋转。这样做的代价是,被访问者的所有子类中的accept函数需要把访问者接口(ArmyVisitor)强转(cast)为适当的访问者接口。类型转换成功后,该方法就可以调用相应的visit方法。

 

访问者接口及其派生类的代码保持不变,我们主要看看无环模式为访问者接口及其子类带来的改变:


可以看到,访问者子类不但要实现退化的访问者接口,而且还要根据自己的实际情况,来实现那些为不同的被访问者提供的相应的访问者接口:

测试类。假设先主可以任意调遣三军,但孔明调不动关羽,我们来看看孔明作为访问者时遇到的情况:

如果画成矩阵,那么Acyclic Visitor模式属于稀疏矩阵,也就是说,纵轴和横轴的每一个交叉点不必全部填充,而是根据实际需要确定:

ArmyVisitor

GuanYuArmy

ZhangFeiArmy

ZhaoYunArmy

King

visit

visit

visit

Premier


visit

visit

 

这样做的好处显而易见,不但解除了依赖环,而且更易于被访问者和访问者的子类扩展。

但副作用也很明显,首当其冲的是增加了解决方案的复杂程度;其次,类的转换需要花费大量的时间。

尽管如此,对于被访问者层次不够稳定,且扩展存在很大可能的系统来说,选择无环访问者模式仍然是个不错的选择。

 

3. 访问者与接口适配器Visitor and Adapter

无环访问者模式确实解决了访问者模式不易扩展的问题,但同时也使整个系统变得复杂了。通过在访问者接口(ArmyVisitor)与其实现类中间增加一层抽象,可以从另一个角度来缓解访问者模式带来的问题。

(类图:访问者+接口适配器模式)

被访问者接口、被访问者子类、访问者接口三者的依赖环依然存在,但是访问者子类的处理却变得灵活了。

当被访问者子类需要扩展时,访问者层次结构中,只有访问者接口(ArmyVisitor)和访问者抽象类(AbstractArmyVisitor)需要随之改变。另一方面,访问者的子类扩展也变得和无环访问者模式一样灵活。且由于比无环访问者模式少了一层针对各个被访问者子类提供的访问接口,这样做实际上减少了细粒度对象产生的可能。

而接口适配器最大的好处在于,避免了类的强制转型(cast)所带来的资源消耗、未知异常等一系列问题。

下面看看访问者+接口适配器模式的核心代码,基本的被访问者数据结构与前面的Visitor模式相同,包括ArmyVisitor接口也保持不变,变化之处在于增加的抽象类和访问者子类,访问者不再直接实现ArmyVisitor,而是去继承抽象类AbstractArmyVisitor:

(类图:访问者+接口适配器模式)

被访问者接口、被访问者子类、访问者接口三者的依赖环依然存在,但是访问者子类的处理却变得灵活了。

当被访问者子类需要扩展时,访问者层次结构中,只有访问者接口(ArmyVisitor)和访问者抽象类(AbstractArmyVisitor)需要随之改变。另一方面,访问者的子类扩展也变得和无环访问者模式一样灵活。且由于比无环访问者模式少了一层针对各个被访问者子类提供的访问接口,这样做实际上减少了细粒度对象产生的可能。

而接口适配器最大的好处在于,避免了类的强制转型(cast)所带来的资源消耗、未知异常等一系列问题。

下面看看访问者+接口适配器模式的核心代码,基本的被访问者数据结构与前面的Visitor模式相同,包括ArmyVisitor接口也保持不变,变化之处在于增加的抽象类和访问者子类,访问者不再直接实现ArmyVisitor,而是去继承抽象类AbstractArmyVisitor:


测试类。假设先主可以任意调遣三军,但孔明调不动关羽,我们来看看孔明作为访问者时遇到的情况:

(类图:访问者+接口适配器模式)

被访问者接口、被访问者子类、访问者接口三者的依赖环依然存在,但是访问者子类的处理却变得灵活了。

当被访问者子类需要扩展时,访问者层次结构中,只有访问者接口(ArmyVisitor)和访问者抽象类(AbstractArmyVisitor)需要随之改变。另一方面,访问者的子类扩展也变得和无环访问者模式一样灵活。且由于比无环访问者模式少了一层针对各个被访问者子类提供的访问接口,这样做实际上减少了细粒度对象产生的可能。

而接口适配器最大的好处在于,避免了类的强制转型(cast)所带来的资源消耗、未知异常等一系列问题。

下面看看访问者+接口适配器模式的核心代码,基本的被访问者数据结构与前面的Visitor模式相同,包括ArmyVisitor接口也保持不变,变化之处在于增加的抽象类和访问者子类,访问者不再直接实现ArmyVisitor,而是去继承抽象类AbstractArmyVisitor:

测试类。假设先主可以任意调遣三军,但孔明调不动关羽,我们来看看孔明作为访问者时遇到的情况:

如果画成矩阵,那么Visitor模式+Interface Adapter模式,同样可以得到一个稀疏矩阵,也就是说,纵轴和横轴的每一个交叉点不必全部填充,而是根据实际需要确定:

ArmyVisitor

GuanYuArmy

ZhangFeiArmy

ZhaoYunArmy

King

visit

visit

visit

Premier


visit

visit 


小结

三种模式各有优劣,也各有其最适合的场景。在设计时,需要选择最合适的模式来应对。

来源:WELSIM
汽车
著作权归作者所有,欢迎分享,未经许可,不得转载
首次发布时间:2023-06-24
最近编辑:1年前
WELSIM
一枚搞仿真的老员工
获赞 23粉丝 59文章 253课程 0
点赞
收藏
未登录
还没有评论
课程
培训
服务
行家
VIP会员 学习 福利任务 兑换礼品
下载APP
联系我们
帮助与反馈