Dependency Injection via Constructors vs Abstract Classes

abstract classdependency-injectiondesign-patterns

For the past few days I've been researching the relationship of abstract classes and dependency-injected via the constructor classes.

It appears that any time that I can have a dependency-injected class:

public interface IServiceC
{
    void methodC();
};

public class ServiceC implements IServiceC
{
    ServiceC(IServiceA a, IServiceB b) { .. }

    void methodC() { 
      //..
      a.methodA(); 
      //.. 
      b.methodB();
    }
};

At some point I will have to provide it with instances say instanceA, instanceB.

But isn't that the same as having an abstract class and a concrete class using the instances?:

public abstract class AServiceC implements IServiceC 
{
    abstract void methodA();
    abstract void methodB();

    void methodC() {
       //...
       serviceA();
       //...
       serviceB();
    }
}

public class CServiceC extends AServiceC
{
    CServiceC(ServiceAArgs argsa, ServiceBArgs argsb)
    {
        instanceA = new CServiceA(argsa);
        instanceB = new CServiceB(argsb);
    }

    void methodA() { instanceA.methodA(); }
    void methodB() { instanceB.methodB(); }

    CServiceA instanceA;
    CServiceB instanceB;
};

This is all assuming we know the concrete classes, eg we have only testing and release configurations. The release code knows the concrete instances while the testing code simply implement the appropriate interfaces.

The abstract classes can then forward only the bottom-layer via the constructor and manage the plumbing.

Am I missing something crucial here? I am trying to applying DI for an embedded firmware written in C in which I must have everything statically allocated. This means that I need to know the concrete instances before passing them to another object that has them as a dependency. Currently I am using callbacks instead of accepting interfaces in the constructor which is basically the same as single-method interfaces but the more I look at this the more abstract classes seem useful since we usually need to own the lower-level components when writing a higher-level one.

Best Answer

I think you've missed the point behind Dependency Injection which is Dependency Inversion.

Specifically the caller does not know the real implementation of the service, this necessitates indirection either via virtual (in C++) or function pointers (C).

It has several benefits, particularly with testing as the collaborators can be a test double of some sort. It also provides some flexibility by moving decisions from the doing part of the code, to the configuration part of the code which knows more about the larger desired behaviour.

In the case of your abstract object, the implementation (the actual constructed object) knows the implementations of its collaborates, because it specifically constructs them itself. It does manage to achieve logic sharing (via the base class), but you can't swap either of those two collaborators for any reason without that changing for everyone, or duplicating the derived class. This design also suffers from change ripples as implementations affect the base class, and the base class affects all its implementations.

This really needs an example. I'm going to use a compression program - basic use case is: it reads a file, compresses it, and writes it to another file.

Since most C compilers are also C++ compilers I am going to mostly write C, but using some C++ features so the below is C+.

int read_file(handle h, char* buffer, int length);
int write_file(handle h, char* buffer, int length);

int compress_file(handle from, handle to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = read_file(from, buffer, N)))
    {
        //compression logic here
        ...
        //buffer and k now represent the compressed data.

        if (k != write_file(to, buffer, k))
        {
            return -1; //failed report some error code
        }
    }

    return 0; //some success code, or maybe the compressed length.
}

int main()
{
    //open handles

    if (0 != compress_file(from, to))
    {
         //handle error
    }

    //cleanup
}

Great done.

Oops, requirements have changed and now we have to compress a file to a network. Unfortunately we have to use a different API.

class NetworkPoint
{
   void write_byte(char a);
}

So how do you get around this? One option is to push the logic for compression out. It is probably the most complicated part of this system, no point in rewriting that.

int compress_buffer(char* buffer_in, int N, int* n, char* buffer_out, int K, int* k)
{
     //logic about compression here
}

compress_file doesn't change much and now there is a second version to cover the new use case of compression to the network.

int compress_file(handle from, handle to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = read_file(from, buffer, N)))
    {
        compress_buffer(...);

        if (k != write_file(to, buffer, k))
        {
            return -1; //failed report some error code
        }
    }

    return 0; //some success code, or maybe the compressed length.
}
int compress_network(handle from, NetworkPoint& to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = read_file(from, buffer, N)))
    {
        compress_buffer(...);

        for (int i = 0; i < k; ++i)
        {
            to.write(buffer[i]);
        }
    }

    return 0; //some success code, or maybe the compressed length.
}

That is code duplication.

If another end-point is needed it will add another duplicate. If the fundamental business logic changes, there are two (or more) places that have to be kept up to date. Clearly this is fine in some cases but in others this will get out of hand quickly.

So what does the "compress" business logic actually need? It needs to "obtain data", preferably in a buffer, then it needs to do some "compression", then it needs to "send data" that was just compressed. Turns out each "" is a service.

class Reader
{
    virtual int read(char* buffer, int N)=0;
}
class Writer
{
    virtual int write(char* buffer, int N)=0;
}
class Compressor
{
    virtual int compress(char* buffer_in, int N, int* n, char* buffer_out, int K, int* k)=0;
}

Just to be clear an interface should never have any implemented functions, nor should it have any data members.

If you find some repeating structures implementing an abstract class for some of the service implementations may work, just don't pollute the interface definition itself.

This allows that behaviour to be compartmentalised.

class FileWriter : public Writer
{
    int handle;

    ...implement to write to a file handle...
}
class NetworkWriter : public Writer
{
    NetworkPoint point;

    ...implement to write to a NetworkPoint...
}

And now the Dependency becomes invertable.

int compress_data(Reader& from, Compressor& compress, Writer& to)
{
    handle h = //obtained by opening file
    const int N = //some length.
    char buffer[N];
    int k;

    while(0 != (k = from.read(from, buffer, N)))
    {
        compress.compress(...);

        if (k != to.write(to, buffer, k))
        {
            return -1; //failed report some error code
        }
    }

    return 0; //some success code, or maybe the compressed length.
}

If we need a new kind of reader, writer, or compression algorithm, this business logic does not care. Just implement the service and pass it in.

Now we already have Dependency injection going on. That is what those function parameters are. The caller "injects" the dependencies into this function. The function doesn't have direct control of those services, but it can interact with them.

Now lets presume that this business logic is itself a configurable service, say it should be run by a task manager.

class Task
{
   virtual void execute()=0;
}

class CompressionTask
{
   std::shared_ptr<Reader> reader;
   std::shared_ptr<Compressor> compressor;
   std::shared_ptr<Writer> writer;

   CompressionTask(std::shared_ptr<Reader> reader, std::shared_ptr<Compressor> compressor, std::shared_ptr<Writer> writer)
       : reader(reader), compressor(compressor), writer(writer)
   {}

   void execute()
   {
       compress_data(*reader, *compressor, *writer);
   }
}

CompressionTask takes several injected dependencies, it does not care about them too much, but it does ask for some life-time guarantees (shared_ptr). These dependencies are hidden from the TaskManager, and aside from their function the implementation is hidden from business logic.

Someone somewhere will have to create the CompressionTask object, and will have to know enough about the dependencies to either create them, request them, or be passed them. This piece of code however should have all of that information available to make those decisions. The rest of the code base can ignore it - treating it as either a generic collaborator or a source of dependencies.

Inverting the dependencies above achieved:

  • Multiple compressible data sources
  • Multiple compressible data sinks
  • Multiple compression algorithms
  • De-duplication of business logic
  • Hiding configuration decisions from the greater code base
  • Moving configuration decisions to one place were the knowledge to make that decision exists
  • Reduced resistance to requirement changes