Nested Context Pattern

David Stark / Zarkonnen
2015-02-13 11:43

Developing Airships, I've been bumping up against an old problem a lot recently. There's a lot of looping over entities and sub-entities, and a lot of context that the entities need at each level, which makes for some truly unwieldy method signatures, such as this monstrosity:

public void draw(MyDraw d,
                 double cropX, double cropY, double cropW, double cropH,
                 Image[] light, float lightStrength, float ambient, Clr soilTint)

And every time I introduce some new value like the ambient light strength, I have to go all over the code, threading that value into each function call. So I was discussing this with David, who agreed that the fix was a context object passed down the invocation chain of entities and sub-entities.

I worried about context objects becoming an unholy mess of variables in various states, or having to instantiate a specific object for each invocation. But after a bit of thought, I realized that the type system could fix this:

The idea is to have the context object implement a different interface for each level it's used at, exposing only the getters for meaningful values at that level, and methods for turning the context into a more specialized one. These methods set some variables and then return the same object under a different interface. It's a bit of work, but it can all go into one well-structured file, and it keeps the code and semantics nice and clean.

As an example, let's say we're rendering a World containing Ships containing Crew. At the world level, we just need to know the global illumination value. At the ship level, we also want the computed location of the ship on screen. Finally, at the crew level, we additionally want to know if we should show detailed crew stats.

Resulting in the following code:

public final class RenderCtx {
    public interface Crew {
        public double lightIntensity();
        public double shipX();
        public double shipY();
        public boolean crewStatsVisible();
    }
    
    public interface Ship {
        public double lightIntensity();
        public double shipX();
        public double shipY();
        public Crew crewCtx(boolean crewStatsVisible);
    }
    
    public interface World {
        public double lightIntensity();
        public Ship shipCtx(double shipX, double shipY);
    }
    
    public World get(double lightIntensity) {
        return new Impl(lightIntensity);
    }
    
    private RenderCtx() {} // Can't create directly.
    
    private static final class Impl implements Crew, Ship, World {
        double lightIntensity;
        double shipX, shipY;
        boolean crewStatsVisible;
        
        Impl(double lightIntensity) { this.lightIntensity = lightIntensity; }

        @Override public double lightIntensity() { return lightIntensity; }
        @Override public double shipX() { return shipX; }
        @Override public double shipY() { return shipY; }
        @Override public boolean crewStatsVisible() { return crewStatsVisible; }

        @Override
        public Crew crewCtx(boolean crewStatsVisible) {
            this.crewStatsVisible = crewStatsVisible;
            return this;
        }

        @Override
        public Ship shipCtx(double shipX, double shipY) {
            this.shipX = shipX;
            this.shipY = shipY;
            return this;
        }
    }
}
// Usage example.
RenderCtx.World wc = get(0.8); // Get top-level context.
wc.lightIntensity(); // Can only access light intensity.
RenderCtx.Ship sc = wc.shipCtx(3.8, -103.1); // Specialize into ship context.
sc.shipX(); // Can now also see ship screen coordinates.
sc.lightIntensity(); // And stuff from the higher context.
RenderCtx.Crew cc = sc.crewCtx(true); // Specialize again. Remains same object.
cc.crewStatsVisible(); // Context is mutable but behaves as if it weren't.

Now I haven't yet implemented this in Airships, though I likely will, at which point I'll report back. Meanwhile - comments? Is this a sensible way of doing things? Over-engineered? Am I missing some trick?