Extensible architecture using abstract factory and template metaprogramming

I am currently working on my master's thesis and I cannot seem to find a satisfactory solution to the following problem. The idea here is to develop a small library that should abstract away from the underlying APIs (such as DirectX11 and OpenGL4). It is not required that two or more APIs coexist in the same application, so I could theoretically write a bunch of preprocessor directives to differentiate between them, however this would remove my code and of course not extensible at all.

An abstract factory seems to be very handy, however it seems that I cannot find a way to make it work with templates.

Let it begin ...

I have an abstract class Factory

whose purpose is to instantiate objects that the application needs to work, such as Resources

and Context

. The former is used to load resources at runtime, while the latter is used to render the 3D scene. Both Resources

and Context

are abstract, because their implementation depends on the basic API.

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

      

Resources

the class will load the necessary resources and return objects of type Texture2D

and Mesh

. Again, these classes are abstract as they depend on a specific API.

Let's say that I am working with DirectX11 and OpenGL4.5. For each of these API-interfaces I have a class above derivatives, respectively DX11Factory

, DX11Resources

, DX11Context

, DX11Texture2D

, DX11Mesh

and so on. The class they are distributing is pretty obvious. Fair enough.

The trivial way to design a class interface Resource

is as follows:

class Resources{
   public:
      Texture2D LoadTexture(const wstring & path) = 0;
      Mesh LoadMesh(const wstring & path) = 0;
}

      

The class DX11Resource

will implement the above methods and everything will work fine ... except if I wanted to support a new resource type in the future, for example TextureCube

(and from a software developer's point of view, I'm sure. Right now I don't care ) , I will have to declare a new method TextureCube LoadTextureCube(...)

in the interface of the library the user will use, that is Resources

. This would mean that I would have to implement this method in every separate derived class (FTW public closed principle!).

My first idea for solving this problem was as follows:

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 );

         }
   }

}

      

So if I need to support a new resource type, I can just declare a new one resource_traits

inside the correct namespace (and of course a new abstract resource and a concrete type) and everything will work. Unfortunately, virtual template methods are not supported (and for a very good reason, imagine what would happen with writing something like this

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>

      

So basically the compiler won't be able to do the correct substitution and will indicate an error for a class that the user didn't even know about. )

I've thought of other solutions, but none of them satisfy my needs:

1. CRTP

I can use this:

template <typename TDerived>
class Resources{
   public:

      template <typename TResource>
      TResource Load(const wstring & path){

         return typename TDerived::resource_traits<TResource>::loader()( path );

      }
}

      

I think this will work, however Resources<TDerived>

cannot be returned by an object Factory

simply because TDerived is not known (and the end programmer shouldn't either way).

2. RTTI

class Resources{
   template <typename TResource>
   TResource Load(const wstring & path){

      return *static_cast<TResource *>( Load(path, typeid(TResource).hash_code()) );

   }

   virtual void * Load(const wstring & path, size_t hash) = 0;
}

      

In a derived class, I have to implement the pure virtual method above, and then, using the if-then-else cascade, I can instantiate the resource I want, or return nullptr if that particular API doesn't support it. This will work for sure, but it's ugly and of course it forces me to rewrite the implementation whenever I want to support a new resource type (but at least it will only be one class)!

if( hash == typeid(Texture2D).hash_code()) // instantiate a DX11Texture2D
else if (...)...

      

3. Visitor

By using the visitor template. This method won't actually help me at all, but I'm leaving it here just in case (I always think of the visitor whenever I see an infinite if-then-else cascade with an embedded downcast like in the previous point :)).

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);

}

      

Using this approach, we Resources

now need to declare a clean virtual method for every resource it can load, for example Texture2D Resources::Load(path, Texture2DVisitable &) = 0

. So again, in the case of new resources, I need to update the entire hierarchy accordingly ... at this point, I would use the trivial solution at the beginning.

4. Others?

Did I miss something? Which approach should I prefer? I feel like I'm, as always, too much exhausting!

Thanks in advance and sorry for the poorly written shorthand!

ps: getting rid of the Resource class in the first place is not an option as its real purpose is to prevent the same resource from loading over and over again. These are basically huge flies.

+3


source to share


1 answer


This problem really boils down to the "virtual function pattern" problem. Basically, the solution (no matter what it is) is to take compile-time information (like a template argument), turn it into temporary information (like value, type id, hash code, function pointer, etc.) , go past the runtime dispatch (virtual call) and then return this information at runtime at compile time (such as which piece of code to execute). Understanding this, you will understand that the most direct solution is to use this "RTTI" solution or change it.

As you note, the only real problem with this solution is that it is "ugly". I agree that this is a bit ugly and it's a great solution, especially the fact that the changes needed when adding a new supported type are localized only for the implementation (cpp files) related to the class you add that support (you really couldn't hope for something better than this).

As for the ugliness, well, something you can always improve with, with some trickery, but there will always be some kind of ugliness, especially static_cast

one that cannot be removed because you need a way to get out of the run - send time back to statically typed the result. Here is a possible solution based on 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);
};

      



There are several variations on this (for example, having member functions instead of free functions for loaders, or using non-templated functions instead of specialization, or both), but the basic idea is that to add a new supported type, all you do is add it to the list of supported types (DX11RES_SUPPORT_LOADER(SomeType)

) to the list and create the code as a new function (only in the cpp file). There's still a little ugliness in there, but the header file is clean, and the ugliness in the virtual "load" is "O (1)" in complexity, which means you don't add the ugliness for every new type, it's a constant bit of ugly code (instead of if-else sequences, where the amount of ugly code is proportional to the number of supported types). It also has the side benefit of making dispatch faster (with a hash table). Also, using type_index is important to avoid collisions with the two types of hash values ​​(you don't lose information on which typeid was used to create the hash value).

So, in general, my recommendation is to go with the "RTTI" solution and do what you can or want to remove some of the ugliness or inefficiency associated with it. The most important thing is to keep the interface (header, class declaration) of the derived class as clean as possible to avoid having to add anything to it in the future (you definitely don't want this class to display in its declaration what types of resources it supports via function declarations or something, otherwise you need to recompile the world every time to add one).

NB: If you don't need to use the RTTI parameter (for example -fno-rtti

), then there are ways to work around this issue, but that is beyond the scope of this question.

+1


source







All Articles