DEV Community

loading...

Code coupling and control hierarchy

tzhechev profile image tzhechev ・1 min read

Some years past I had taken on the job of disentangling a massive Delphi codebase with the purpose of making it possible to compile only sections of it into executables. The code was quite old and good practices were not followed - as a result, you could not compile the smallest portion of it without creating a 20mb exe that would make attempts to boot up databases.

The sort of patterns that lead to this issue are something that I've commonly seen since and have attempted to advise against. This post is my attempt to create a brief, somewhat succint explanation of why I personally think certain things should be done a certain way.

Loose coupling

The common idea of loose code coupling tends to be expressed as a need for strict contract-based, rather than class-based interaction between objects. What I tend to see suggested as good practice is to just make sure you define your dependencies as interfaces rather than the actual classes you're going to be using and limiting those interfaces to only the part of the API you'll actually be using.

public class Furnace implements BreadBaker {
    ...
    @Override
    public void breadIsBaked(Bread bread) {
        ...
    }
    public void addBread() {
        Bread newBread = new Bread(this);
        contents.add(newBread);
    }
    public void startBaking() {
        for (Bakeable bakeable : contents) {
            bakeable.startBaking();
        }
    }
}

public class Bread implements Bakeable {
    private BreadBaker baker;
    public Bread(BreadBaker baker) {
        this.baker = baker;
    }
    @Override
    public void startBaking() {
        ...
        baker.breadIsBaked(this);
    }
...
}

While I think that's generally a fine idea, I think of less frequently stated importance is what I'm going to (probably errorenously) refer to as control hierarchy. Additionally, I think creating extremely specific interfaces for every class dependency is not an inherently good strategy. It's very important to identify instances in which you would actually be improving code reuse.

Control hierarchy

Let's consider that if an object holds a reference to another object, then it has knowledge of the latter. (Generally, it is possible to obscure that knowledge by referring to the object only via a limited interface, but I don't consider that a sufficient barrier.)

I think the only way to maintain actual code reusability is to make sure that if we were to look at a codebase as a graph of such object references, that it would be a directed graph with no loops.

The only way to be able to extract only part of a codebase is if that part is not dependent on the rest of the codebase. I mentioned how it's possible to insulate that dependency through a contract (interface or whatever else), but in my experience that tends to not properly protect against using that referred object as a sort of oracle or global variable. And in general, if that reference is necessary, as in it cannot be addressed by simply passing simpler data down to the referring class, it is most likely a violation of the Law of Demeter.

Following this rule, code execution can be conceptualized as starting from a single point in the 'center' of the graph. That node has zero paths leading to it and all paths leading away from it. Execution then cascades out, covering whatever paths of the graph logic gates direct it along.

This causes stratification in the code structure, where execution can only flow 'down' from more 'knowledgeable' objects to less. This has immense benefits in making code more testable - it is easy to start execution down any node without involving anything in the layers above it. It is also very beneficial in code reuse - less knowledgeable objects can be lifted out and safely executed under a different controlling object. Another benefit is that this significantly assists in preventing side effect bugs, as classes shouldn't be able to modify anything above their scope.

public class Furnace {
    ...

    public void breadIsBaked(Bread bread) {
        ...
    }
    public void addBread() {
        Bread newBread = new Bread();
        contents.add(newBread);
    }
    public void startBaking() {
        for (Bakeable bakeable : contents) {
            bakeable.startBaking();
            if (bakeable instanceof Bread) {
                breadIsBaked((Bread)bakeable);
            }
        }
    }
}

public class Bread implements Bakeable {

    public Bread() {

    }
    @Override
    public void startBaking() {
        ...
    }
...
}

What about events?

The issue with all that's laid out so far, as far as I can tell, is that it works fine if you only consider a single initial execution point. But the fact is that in any responsive application, there are numerous other execution points - events. And events do need to flow back up from the end nodes (as that is where they tend to be generated) and execute code in higher strata.

And in that aspect, it is worth asking, why not simply supply a reference to the higher-level object so that it can be operated on in case of an event? You can define its API such that the handler logic remains in the higher level object and is just called as a function with the event data by the lower level object.

But higher level objects can define how lower level objects should respond when events occur - this is done in Java via setting anonymous classes as listeners and in JS by setting functions defined by'smarter' objects on 'stupider' objects.

public class Furnace implements BreadBaker {
    ...
    @Override
    public void breadIsBaked(Bread bread) {
        ...
    }
    public void addBread() {
        Bread newBread = new Bread();
        newBread.setBreadBakedHandler(new BreadBakedHandler {
            @Override
            public void breadIsBaked(Bread bread) {
                breadIsBaked(bread);
            }
        }
        contents.add(newBread);
    }
    public void startBaking() {
        for (Bakeable bakeable : contents) {
            bakeable.startBaking();
        }
    }
}

public class Bread implements Bakeable {

    public Bread() {

    }
    @Override
    public void startBaking() {
        ...
        if (breadBakedHandler != null) {
            breadBakedHandler.breadIsBaked(this);
        }
    }
    public setBreadBakedHandler(BreadBakedHandler handler) {
        this.breadBakedHandler = handler;
    }
...
}

In an absolute sense, that still might be considered to create a reference loop, as often these functions will refer to objects of a higher level than those executing them, but at least they are defined within their appropriate strata. When defining a listener, the natural proclivity tilts toward only exposing the minimum necessary for the event to be handled. On the other hand, when assigning a reference to a higher level object, the natural proclivity is to expose more than is necessary to the lower level object - constraining the API to the absolute minimum is a specific task of defining a tight interface and can be overlooked.

There are other ways of handling event proliferation, such as using an event bus, but to me at least that seems like a worse solution for two reasons. One is that it tends to act as a global object, having to be passed around the object graph (implicitly or explicitly). The other is that it tends to make the call stack far harder to debug, obscuring the path from event execution to event handling.

Other solutions?

I'm not sure. I'd love to hear about better patterns though, so feel free to comment!

Discussion (0)

Forem Open with the Forem app