2

I have been practicing this hybrid approach for dependency injection in the last couple of days and I am wondering if it should also apply to components which are within the same package?

For example:

I have a GPIO module that uses the device chip and that required to be mocked for unit testing. I also exposed its Pins as interfaces, so their consumers would not have any coupling.

I have a Motor component that lives in another package and consumes the GPIO Pins' interfaces.

Then a ControlAgent component that lives within the same package as the Motor component, and consumes it.

One benefit of using an interface for the Motor and a template class for its implementation seems to be making its construction a little more generic (as long as I provide what's needed at compile time), and also makes the unit testing easier.

But I also have three more components (PID/Encoder/Odometry) and potentially more that are consumed by the ControlAgent.

Seems like a big effort in development time and complexity to setup each of those as interfaces when they are part of the same package.

What is the long term benefit (if any) in the ControlAgent consuming all of its neighboring components as std::unique_ptrs (or any pointers) to interfaces rather than friends/members?

Especially since the implementations use templates, so the types must be known at compile time.

Does the over-engineering make the implementation less readable but also more maintainable?

2
  • 1
    The main advantage I could think of would be ease of mocking and unit testing. It's generally much less effort to test small, isolated, decoupled components compared with large monoliths, and you're also more likely to get closer to 100% code coverage with those tests. Apr 12, 2019 at 16:06
  • @BenCottrell makes sense! so if the system demands high test coverage, it's probably worth the effort. Like an autonomous vehicle for example Apr 12, 2019 at 16:10

2 Answers 2

3

I have been practicing this hybrid approach for dependency injection in the last couple of days and I am wondering if it should also apply to components which are within the same package?

The question is hard to respond. I could respond to you a general pattern that would apply to generic problems, but I don't believe in generic problems.

Dependency injection is great, but I think it's a common misconception to think of type polymorphism to be mandatory when doing so (static with templates or dynamic with vtables).

Here's an example:

struct Logger {
    void log(int value) {
        switch(output) {
        case Output::File:
            // output to file
            break;
        case Output::Console:
            // output to console
            break;
        default:
            // noop
            break;
        }
    }

    enum struct Output { File, Console, NoOp } output;
};

struct Task {
    Logger logger;

    void process() {
        logger.log(42);
    }
};

int main() {
    // isn't that dependency injection?
    auto task = Task{Logger{Logger::Output::Console}};
}

So...

What is the long term benefit (if any) in the ControlAgent consuming all of its neighboring components as std::unique_ptrs (or any pointers) to interfaces rather than friends/members?

Especially since the implementations use templates, so the types must be known at compile time.

Does the over-engineering make the implementation less readable but also more maintainable?

I think adding type polymorphism where there is no benefit will hurt the maintainability of the code. Calling concrete implementation can upgrade readability greatly in some cases. Since you know by reading the code what happens, which functions are truely called it's easier to reason about it without dragging much of the stuff around in your head or having to run the program.

Polymorphism applied carefully only at the places where you know it will be a good extension point or where dynamic polymorphism is actually needed will help maintainability.

Also, note that polymorphism can also be introduce by values. For example, a class that has a std::function member has a polymorphic entry there, even though it's a concrete type.


What about unit tests? you need type polymorphism everywhere to inject mocks!

I think changing the whole design, introduce a maintainability overhead and introduce type polymorphism where it's unneeded will hurt more than help.

Mock classes are a natural solution in a language where all types are dynamic or polymorphic, but in other cases I think we should not forcefeed that solution.

There are way to mock concrete implementation with libraries such as elfspy so I think it's more of a tooling problem than a design problem.

1
  • That's a good point! Template injection or even constructor injection amounts to the same thing (at least for my cases) Apr 12, 2019 at 16:23
0

In my experience, not at all and quite the opposite. Everyone's mileage and opinions may vary and I respect that (is it rather difficult to talk about this sometimes without inviting an angry mob of people throwing SOLID at me like a weapon), but I've found it far more beneficial in our case to actually minimize the number of virtual interfaces and class/function templates involved if there's no actual need for static or dynamic polymorphism. To be clear, emphasis on 'minimize' and not 'eradicate'. It is hardly any fun to fire up a debugger in response to a bug report for an issue that managed to fly under the radar of our automated tests, only to look down at an enormous codebase that makes it largely unclear what concrete functions/methods we'll end up calling.

One practical thing to note just due to the nature of C++ is that a codebase that favors a lot of pure virtual interfaces will tend to move away from value semantics to pointer/reference semantics. More functions/methods will tend to take references or pointers via parameter as a result and cause more external side effects and in-place mutations to the pointers/references/smart pointers being passed in or injected. I could see that being largely mitigated if we had a copyable smart pointer available, like copyable_unique_ptr which captures not only the destructor of the concrete class that is instantiated but also its copy constructor (still optimized for move semantics when appropriate).

I think such a solution would be far superior if it was in wide usage over unique_ptr or shared_ptr in encouraging value/copy semantics. Yet absent such a thing, the tendency to favor a lot of pure virtual interfaces and polymorphic base pointers will tend to result in a codebase that is riddled with external side effects and in-place mutations. In my experience, if anything is going to make a codebase difficult to maintain (i.e., difficult to reason about what it's doing, and difficult to reason about the correctness or lack thereof in making changes to it), it's that... especially if we're in a domain where our consumers continue to demand better utilization of their multi-core hardware. But even if we're not, and we're just building a single-threaded architecture, I've often found the ability to reason easily and immediately about the thread-safety of what we're doing to be very much aligned with ease of maintenance and ease of testing.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.