使用抽象工厂和模板元编程的可扩展架构

Extensible architecture using Abstract Factory and template metaprogramming

本文关键字:编程 可扩展 抽象 工厂      更新时间:2023-10-16

我目前正在写硕士论文,似乎我找不到一个令人满意的解决方案。这里的想法是设计一个应该从底层api(如DirectX11和OpenGL4)抽象的小库。在同一个应用程序中不需要两个或多个api共存,所以理论上我可以编写一堆预处理器指令来区分它们,但是这会破坏我的代码,当然,它根本无法扩展。

抽象工厂似乎很方便,但是我似乎找不到一种方法使它与模板一起工作。

让我们开始…

我有一个抽象类Factory,其目的是实例化应用程序工作所需的对象,如ResourcesContext。前者用于在运行时加载资源,后者用于渲染3D场景。ResourcesContext都是抽象的,因为它们的实现依赖于底层API。

class Factory{
   public:
      virtual Resources & GetResources() = 0;
      virtual Context & GetContext() = 0;
}

Resources类将加载所需的资源,并返回类型为Texture2DMesh的对象。同样,这些类是抽象的,因为它们依赖于特定的API。

假设我正在使用DirectX11和OpenGL4.5。对于每个api,我都派生了上面的类,分别是DX11FactoryDX11ResourcesDX11ContextDX11Texture2DDX11Mesh等等。它们所扩展的类非常明显。很好。

设计Resource类接口的简单方法如下:
class Resources{
   public:
      Texture2D LoadTexture(const wstring & path) = 0;
      Mesh LoadMesh(const wstring & path) = 0;
}

DX11Resource将实现上述方法,一切都会正常工作…除非我想在未来支持像TextureCube这样的新资源类型(从软件工程师的角度来看,我肯定会的)。现在我不在乎),我必须在库用户实际使用的接口中声明一个新方法TextureCube LoadTextureCube(...),即Resources。这将意味着我必须在每个派生类中实现该方法(开闭原则FTW!)。

我解决这个问题的第一个想法是:

class Texture2D{...}
class Resources{
   public:
      template<typename TResource>
      virtual TResource Load(const wstring & path) = 0; // :(
}   
namespace dx11{
   class DX11Texture2D: public Texture2D{...}
   class DX11Texture2DLoader{...}
   template<typename TResource> struct resource_traits;
   template<> struct resource_traits<Texture2D>{
      using type = DX11Texture2D;
      using loader = DX11Texture2DLoader; //Functor type
   }
   class DX11Resources{
      public:
         template<typename TResource>
         virtual TResource Load(const wstring & path){
            return typename resource_traits<TResource>::loader()( path );
         }
   }
}

因此,如果我需要支持一种新的资源类型,我可以简单地在适当的名称空间内声明一个新的resource_traits(当然还有新的资源抽象和具体类型),一切都会工作。不幸的是,虚拟模板方法不受支持(有一个很好的理由,想象一下写这样的东西会发生什么情况

Resources * r = GrabResources(); //It will return a DirectX9 object
r->Load<HullShader>(L"blah");  //DX9 doesn't support HullShaders, thus have no resource_traits<HullShader>

所以基本上编译器将不能执行一个适当的替换,它会指出一个错误,一个类的用户甚至没有意识到。)

我考虑过其他的解决方案,但没有一个能满足我的需要:

<标题> 1。CRTP h1> 可以这样写:
template <typename TDerived>
class Resources{
   public:
      template <typename TResource>
      TResource Load(const wstring & path){
         return typename TDerived::resource_traits<TResource>::loader()( path );
      }
}

我认为这将工作,然而Resources<TDerived>不能由Factory对象返回,仅仅因为TDerived是未知的(最终程序员不应该知道)。

<标题> 2。RTTI h1> 派生类中,我必须实现上面的纯虚拟方法,然后,使用if-then-else级联,我可以实例化我需要的资源,或者如果特定API不支持它,则返回nullptr。这肯定会工作,但它是丑陋的,当然它迫使我重写实现每当我想支持一个新的资源类型(但至少它将只是一个类)!
if( hash == typeid(Texture2D).hash_code()) // instantiate a DX11Texture2D
else if (...)...
<标题> 3。游客h1> 用访问者模式。这个方法实际上对我没有任何帮助,但我把它留在这里以防万一(每当我看到一个永不结束的if-then-else级联,就像上一点一样,我总是想到访问者:))。
template <typename TResource> resource_traits;
template<> resource_traits<Texture2D>{
   using visitable = Texture2DVisitable;
}
struct Texture2DVisitable{
   Texture2D operator()(const wstring & path, Loader & visitor){
      return visitor.Load(path, *this);
   }
}
template<typename TResource>
TResource Resources::Load(path){
   return typename resource_traits<TResource>::visitable()(path, *this);
}

使用这种方法,Resources现在必须为它可以加载的每个资源(如Texture2D Resources::Load(path, Texture2DVisitable &) = 0)声明一个纯虚拟方法。所以,再一次,如果有新的资源,我必须相应地更新整个层次结构……此时,我将使用开始时的平凡解。

<标题> 4。其他人呢?我错过什么了吗?我应该选择哪种方法?我觉得我总是把事情弄得太复杂了!

提前感谢并为我写得很差的墙-o-text感到抱歉!

ps:首先摆脱Resource类不是一个选项,因为它的真正目的是防止反复加载相同的资源。

这个问题实际上归结为整个"虚函数模板"问题。基本上,解决方案(无论它是什么)必须接受编译时信息(例如,模板参数),将其转换为运行时信息(例如,值、类型id、哈希码、函数指针等),通过运行时调度(虚拟调用),然后将该运行时信息转换回编译时信息(例如,执行哪段代码)。通过理解这一点,您将意识到最直接的解决方案是使用"RTTI"解决方案,或其变体。

正如你所指出的,这个解决方案唯一真正的问题是它"丑陋"。我同意它有点难看,除此之外,它是一个很好的解决方案,特别是当添加新支持的类型时所需的修改仅本地化到与您要添加该支持的类相关的实现(cpp文件)时(您真的不能期望任何比这更好的东西)。

至于丑陋,好吧,这是你总是可以用一些技巧来改进的,但它总是会有一些丑陋,特别是static_cast,它不能被删除,因为你需要一种方法从运行时调度返回到静态类型的结果。下面是一个可能的解决方案,它依赖于std::type_index:

// Resources.h:
class Resources {
  public:
    template <typename TResource>
    TResource Load(const wstring & path){
      return *static_cast<TResource *>(Load(path, std::type_index(typeid(TResource))));
    }
  protected:
    virtual void* Load(const wstring & path, std::type_index t_id) = 0;
}
// DX11Resources.h:
class DX11Resources : public Resources {
  protected:
    void* Load(const wstring & path, std::type_index t_id);
};
// DX11Resources.cpp:
template <typename TResource>
void* DX11Res_Load(DX11Resources& res, const wstring & path) { };
template <>
void* DX11Res_Load<Texture2D>(DX11Resources& res, const wstring & path) {
  // code to load Texture2D
};
// .. so on for other things..
void* DX11Resources::Load(const wstring & path, std::type_index t_id) {
  typedef void* (*p_load_func)(DX11Resources&, const wstring&);
  typedef std::unordered_map<std::type_index, p_load_func> MapType;
  #define DX11RES_SUPPORT_LOADER(TYPENAME) MapType::value_type(std::type_index(typeid(TYPENAME)), DX11Res_Load<TYPENAME>)
  static MapType func_map = {
    DX11RES_SUPPORT_LOADER(Texture2D),
    DX11RES_SUPPORT_LOADER(Texture3D),
    DX11RES_SUPPORT_LOADER(TextureCube),
    //...
  };
  #undef DX11RES_SUPPORT_LOADER
  auto it = func_map.find(t_id);
  if(it == func_map.end())
    return nullptr;  // or throw exception, whatever you prefer.
  return it->second(*this, path);
};

有一些变化(例如为加载器使用成员函数而不是自由函数,或者使用非模板函数而不是专门化,或者这两种修改都有),但基本的思想是要添加一个新的支持类型,您所要做的就是将它添加到支持类型列表(DX11RES_SUPPORT_LOADER(SomeType))中,并将代码创建为一个新函数(仅在cpp文件中)。这里仍然有一些丑陋,但头文件是干净的,并且虚拟"Load"中的丑陋在复杂性上是"0(1)",这意味着您不会为每个新类型添加丑陋,它是恒定的丑陋代码位(而不是if-else序列,其中丑陋代码的数量与支持的类型数量成正比)。此外,这样做的附带好处是(使用哈希表)更快地进行分派。此外,使用type_index对于避免与两种类型的哈希值发生冲突也很重要(您不会丢失用于创建哈希值的typeid的信息)。

所以,总的来说,我的建议是使用"RTTI"解决方案,并尽你所能或想要消除与之相关的一些丑陋或低效率。最重要的是要保持派生类的接口(头文件,类声明)尽可能干净,以避免将来不得不添加任何东西(你肯定不希望那个类在它的声明中通过函数声明或其他东西暴露它支持的资源类型,否则,每次添加一个时,你都必须重新编译世界)。

注意::如果你需要避免使用RTTI(例如,-fno-rtti选项),那么有办法解决这个问题,但它超出了这个问题的范围。