I am working on a game engine in C++. One of the design decisions I made was to separate a “public” and “private” API, with the former being visible to clients, while the latter is used internally by the engine. The idea is to both limit the client API to whatever I want them to be able to use (and thus not be distracted by engine internals), and to hide the specific implementation modules used by the engine, e.g platform-specific code and libraries (DirectX, SDL, etc.)
Within this framework, I also want to set up a system for managing various engine resources. The engine has ownership of the objects it creates, since these objects may reference resources that the engine does not expose to the client (and must be cleaned up once the client is done with the object). So for creation and destruction, the client must call into engine systems, and then be provided with a way to access the relevant objects. A trivial implementation would be something like this:
// Visible to client:
namespace PublicAPI
{
class Object
{
public:
virtual void foo() = 0;
protected:
int m_id = -1;
};
class Manager
{
public:
virtual int add_object() = 0;
virtual Object* get_object(int object_id) const = 0;
virtual void remove_object(int object_id) = 0;
};
}
// Only in backend:
namespace PrivateAPI
{
class Object final : public PublicAPI::Object
{
public:
void foo() override;
};
class Manager final : public PublicAPI::Manager
{
public:
int add_object() override;
Object* get_object(int object_id) const override;
void remove_object(int object_id) override;
};
}
// Example usage:
PublicAPI::Manager& manager = get_manager(); // This will point to an instance of PrivateAPI::Manager
PublicAPI::Object* object = manager.get_object(object_id);
if(object)
{
object->foo();
}
NOTE: the OOP may be misleading, at runtime each interface will only have a single implementation each. The abstraction is purely to separate public and private code.
A specific example would be if I'm trying to wrap the specific API of render window management. In DirectX, this means having a swap chain and a render target view, for which I would implement an object and a manager in the PrivateAPI
. The client doesn't need to know about DirectX being under the hood, all it needs is a public Window
interface which they can resize, direct render tasks to it, and so on.
In general, the idea is to hide the data within the objects (which only the PrivateAPI
should know about) and only provide an interface to the functionality, which is intended to be agnostic to the implementation.
The above approach would work, but one serious drawback is that it exposes a pointer to the object directly, meaning the user could technically call delete
on it, instead of doing it through the manager. There are ways to manage this, such as runtime errors or special wrapper objects that do not allow destroying the object itself, but I am not sure if it's actually worth the effort, instead of just expecting the users to understand that they shouldn't be doing it (SDL and DirectX seem to agree). Another drawback is that, under the hood, Object
might need a lot of extra dependencies to access the rest of the engine and actually perform the tasks, whereas making Object
into a POD class might be more efficient (referring back to the render window example: it would mean Window
now needs access to the D3D device and context at the very least, just to get anything done)
Another approach would be to not provide pointers at all, and instead move all the functionality of Object
into Manager
, with each function taking an ID to identify which object to manipulate (i.e Manager::foo(int object_id)
). This solves most of the above issues, but now the API becomes a bit of a mess. It's much harder to nicely encapsulate object-specific functionality, each manager would just have a ton of functions crammed into them, not to mention every operation would require a lookup step. This can be somewhat mitigated in various ways, but again it feels like it might be an unnecessary compromise.
With all that said, is there an elegant design pattern which fits this setup? Or is the design itself flawed? What are good case studies of other engines solving this problem?