在前面的章节中,我们详细讨论了 C++ 二进制兼容性的困难。然后,我们引入了插件框架的设计和实现,讨论了插件的生命周期、跨平台开发的注意事项以及如何根据业务模型设计基于插件的系统等等。下面,我们将讨论如何使插件通过 C 接口与应用程序交互,同时还能允许开发者使用 C++ 接口开发插件。最后,我们将开始 RPG 游戏的编码,同样会基于这个插件模型。我们将介绍游戏的概念、如何以及为什么这么设计接口,最后将探讨应用程序的业务模型。
为插件开发者提供的 C++ 外观模式
回忆一下,我们的插件框架同时支持 C 和 C++ 插件。C 插件更易于移植,但是并不容易开发。不过,好消息是,插件开发者可以使用 C++ 编程模型来兼容 C。当然,可以想象的是,这也是要有一定的代价的:从 C 到 C++ 以及反过来的转换都需要一定的代价。当一个插件函数被应用程序调用时,它会被转发给一个 C 接口的函数调用。一个精心设计的 C++ 包装类(由应用程序提供给插件开发者)会把 C 插件封装起来,将每一个非基本数据类型的 C 参数包装成相应的 C++ 类型,调用由插件对象实现的 C++ 函数。函数返回值(如果有的话)必须由 C++ 重新转换成 C 类型,并且通过 C 接口发送回应用程序。
是不是感觉很熟悉?
这难道不是前面介绍的 C/C++ 双模型的行为吗?自动适配 C 对象?不是!这里的情况完全不同。在这里,对象来自应用程序的业务模型:由应用程序进行实例化。它们可能转换成 C 和 C++ 接口,也可能不是。在插件端,你需要使用 C 接口,不会管它到底是不是双对象模型。真正需要关心的是如何从一个接口转换成另外一个。另外,即使插件知道双对象模型的类型,也是不够的。因为应用程序和插件可能由不同编译器、不同内存模型、不同命名方式进行构建。同一个对象的内存中的物理布局也可能不一样。如果你能保证应用程序和插件的虚表兼容,直接使用 C++ 接口就好了!
C++ 对象模型包装类
C++ 包装类有些绕,但是这是必须的。你可以有一个完美的 C/C++ 双对象模型,能够允许仅通过 C 接口进行访问,并且将其包装进 C++ 接口。包装器可能会更加复杂,特别是带有遍历器的时候。包装器可以保留 C 遍历器,通过 C 遍历器来响应next()
和reset()
函数,或者是复制整个集合。
对于这个简单的游戏,我们选择的是第二种实现方案。在调用时会有一些性能问题,但是如果你始终重复使用这个数据,那么性能就会好得多,因为我们不需要为每一次遍历包装结果(如果需要遍历很多遍)。
下面则是使用了对象模型包装器的示例:
#ifndef OBJECT_MODEL_WRAPPERS_H #define OBJECT_MODEL_WRAPPERS_H #include <string> #include <vector> #include <map> #include "object_model.h" #include "c_object_model.h" struct ActorInfoIteratorWrapper : public IActorInfoIterator { ActorInfoIteratorWrapper(C_ActorInfoIterator * iter) : index_(0) { iter->reset(iter->handle); // Create an internal vector of ActorInfo objects const ActorInfo * ai = NULL; while ((ai = iter->next(iter->handle))) vec_.push_back(*ai); } // IActorInfoIteraotr methods virtual void reset() { index_ = 0; } virtual ActorInfo * next() { if (index_ == vec_.size()) return NULL; return &vec_[index_++]; } private: apr_uint32_t index_; std::vector<ActorInfo> vec_; }; struct TurnWrapper : public ITurn { TurnWrapper(C_Turn * turn) : turn_(turn), friends_(turn->getFriends(turn->handle)), foes_(turn->getFoes(turn->handle)) { } // ITurn methods virtual ActorInfo * getSelfInfo() { return turn_->getSelfInfo(turn_->handle); } virtual IActorInfoIterator * getFriends() { return &friends_; } virtual IActorInfoIterator * getFoes() { return &foes_; } virtual void move(apr_uint32_t x, apr_uint32_t y) { turn_->move(turn_->handle, x, y); } virtual void attack(apr_uint32_t id) { turn_->attack(turn_->handle, id); } private: C_Turn * turn_; ActorInfoIteratorWrapper friends_; ActorInfoIteratorWrapper foes_; }; #endif // OBJECT_MODEL_WRAPPERS_H
注意,我们需要将传递给主接口C_Actor
的任意对象的 C 接口进行包装,同时也包括它们的参数。幸运的是(或者说我们就是这么设计的),并没有很多需要包装的类。ActorInfo
接口对于 C 和 C++ 都适用,所以并不需要包装。另外就是C_Turn
和C_ActorInfoIterator
。这些对象通过 ActorInfoIteratorWrapper
和TurnWrapper
进行包装。这些包装类的实现很简单,但是如果需要包装大量的对象,实际工作还是很令人厌烦的。
每一个包装类都是由 C++ 接口导出,其构造函数接受一个 C 接口指针。例如,TurnWrapper
对象实现 C++ITurn
接口,其构造函数参数是C_Turn
指针。包装类对象保存其 C 接口指针,在其函数的实现中,将对包装对象的函数调用直接转发给内部的 C 接口指针,然后如果需要的话,还得再将返回结果进行包装。而ActorInfoIteratorWrapper
使用了不同的实现。在其构造函数中,它需要遍历传递过来的C_ActorInfoIterator
,在一个内部集合中保存ActorInfo
对象。然后在其next()
和reset()
函数中,直接使用其内部集合的实现方式。当然,如果集合遍历器在集合创建之后能够修改集合元素的话,这种实现就不能正确工作。不过在这里则没有问题,因为所有传递的ActorInfo
集合都是不可变的。然而,这是我们在实现时需要注意的。你需要仔细研究业务模型,然后才能设计出最合理的包装类。TurnWrapper
则比较保守,只是将getSelfInfo()
、attack()
以及 move()
这些函数调用转发给内部的C_Turn
指针。只是getFoes()
和getFriends()
函数的实现有所不同。它会将由getFriends()
和getFoes()
返回的数据直接保存到ActorInfoIteratorWrapper
数据成员。ActorInfoIteratorWrapper
实现了IActorInfoIterator
接口,所以它就会有 C++ITurn
接口所需要的合适的数据类型。
性能到底有多糟?
要回答这么问题,就不得不具体分析。记住,你或许需要将业务模型中的每一个 C 类型都进行包装,也可能不必须。或许你可以直接使用某些 C 对象。真正的开销在于,你使用嵌套很深的数据结构作为参数,而其中嵌套的每一个都得进行包装。在最近的一个项目中,这就是我不得不面对的问题。我有一个极端复杂的数据结构,有若干保存着某些结构的 vector 的 map 组成。不过,我并不担心其性能,因为这个数据结构仅仅用于初始化,因此不需要进行包装。
如果你需要调用者维护数据的隶属关系,或者是需要复制数据,但是又不想让调用者管理内存,或者数据是可变的或者不可变(这种情况下会产生一个系统快照)等等,这时候就有问题了。这些都是 C++ 设计问题,不是特定于对象模型的。