samedi 15 janvier 2022

Achieving DRY and decoupling with incongruous APIs

In my time as a game engine developer, I have frequently encountered the following scenario, and I've never seen a particularly great solution to this problem. I hope to break this down so that it's generic enough to be discipline-agnostic and people from other fields can chime in.

Scenario

Say we're designing an API. Part of this API is described by InterfaceA and InterfaceB. This allows us to provide many diverse implementations of their respective responsibilities, then dynamically select or modularly swap them at runtime without needing to alter the client code. This is especially useful when the implementations use components that are available on some platforms but not others. This design allows us to provide at least one permutation of modules that completes the API on every desired target platform.

Problem

APIs in the wild come in all shapes and sizes, and the ways they interact can get quite ugly.

Consider a windowing API and a rendering API. The former provides a desktop window, and the latter provides a context in which we can draw graphics into the window. In a perfect(ly standard) world, every window would provide a simple CreateRenderer() method that would let us choose our desired implementation, and the window would take ownership of it. Unfortunately, this is not that world. Here are a few examples of asymmetries we might encounter in the ecosystem of APIs:

Potential Windowing API Constraints
• Needs to know which rendering API will be used in advance before a window can be constructed.
• Windowing API may not be compatible with all rendering APIs available on the platform.
• Changing certain renderer settings requires destroying the window and creating a new one that "supports" the desired value for the setting.
• When the window is resized, it will have to report the new size to the renderer so it can react to the change. (for pathological fun, let's say one renderer doesn't even support resizing and will crash if you try)

Potential Rendering API Constraints
• Requires some sort of ID for an existing window, which manifests in many different types/formats depending on which windowing API we used.
• The implementation might need to follow different code paths depending on which windowing API is used. • The renderer needs to know the "capabilities" of the window so it doesn't try to do anything unsupported.

I said I would keep this general, so let's boil this back down to a pure software engineering problem. It's clear that a lot of communication may take place between APIs, and a lot of the time we don't know for sure what communication will be necessary until runtime. In the API examples above, there's a strong sense that the window should "own" the renderer, but let's assume there is no clear ownership between our two concepts.

Fragments of Solutions

To this day, I have never found a completely elegant way to encapsulate all these messy unknowns, but I know of a few mechanisms that can help contain the mess a bit.

Events
First of all, we can rein in a lot of the complexity by taking an event-driven approach to the problem. When ImplA does something ImplB needs to react/respond to, it can send out an event, which ImplB can handle internally. One problem with this, however, is that we may also need to include event info, and we may need to pass data which is represented differently from API to API.

Opaque Pointers
We can fight API micromanagement with opaque pointers. Instead of having to pollute the implementations with tons of methods like GetDataFormattedForSpecificImplB(), we can create a struct

struct DataForImplB
{
    int data, neededBy, implB;
};

or

typedef void* DataForImplB;

and then have a single virtual function

virtual DataForImplB* GetDataForImplB() = 0;

that each A impl provides its own override of. The problem now is that each B impl has to know what the underlying type is so it can cast it back and make use of it. I find myself having to create enums of all the implementations to pass around and then use select statements to branch depending on which implementation the pointer was received from. This all feels very messy, very boilerplate, and not DRY. Furthermore, the amount of work needed grows exponentially with each added implementation.

Question

There are enough ugly APIs and inconsistencies out there that I just know there has to be some design pattern to coax these jagged teeth into interlocking. If you see a pattern hiding in this problem, what is it? If not, will I have to throw modularity out the window and consign myself to a life writing endless boilerplate and big, monolithic, tightly-coupled classes?

Hopefully I've explained the problem clearly enough. If anyone has found themselves in a similar situation, how did you solve it?

Aucun commentaire:

Enregistrer un commentaire