Monday, February 23, 2015

Dependency injection with interface-based programming: how to avoid repeating constructor arguments ad nauseum

I've already written about interface-based programming in C++ here. I haven't written specifically about dependency injection, but there's no shortage of material out there on that subject; for example here, here, and here. It's natural to want to combine the two, but in C++ at least, doing so has the unfortunate side effect of forcing you to repeat the dependency list many times. For example, consider a class with three dependencies: Foo, Bar, and Baz (for the sake of simplicity, suppose they will all be passed and stored in shared_ptrs). First, we need to list these in the declaration of the class's public factory method:

// MyClass.h
#include <memory>

class MyClass
{
public:
   static std::unique_ptr<MyClass> Create(
      const std::shared_ptr<Foo>& foo,
      const std::shared_ptr<Bar>& bar,
      const std::shared_ptr<Baz>& baz);
   // ...
};

Then again as member variables in the impl class:

// MyClass.cpp
#include "MyClass.h"

namespace
{
   class MyClassImpl : public MyClass
   {
   private:
      const std::shared_ptr<Foo> m_foo;
      const std::shared_ptr<Bar> m_bar;
      const std::shared_ptr<Baz> m_baz;
      // ...
   };
}

Then a third time as constructor arguments to the impl class, and a fourth and fifth in the constructor's initializer list (thankfully, we only need the names here, not the types):

// MyClass.cpp
#include "MyClass.h"

namespace
{
   class MyClassImpl : public MyClass
   {
      // ...
   public:
      MyClassImpl(
         const std::shared_ptr<Foo>& foo,
         const std::shared_ptr<Bar>& bar,
         const std::shared_ptr<Baz>& baz)
         : m_foo(foo)
         , m_bar(bar)
         , m_baz(baz)
      {
      }
      // ...
   };
}

A sixth statement is needed in the signature of the factory method's implementation, and finally, a seventh in that method's body when we instantiate the impl (again, with just the names this time):

// MyClass.cpp
// ...
std::unique_ptr<MyClass> MyClass::Create(
      const std::shared_ptr<Foo>& foo,
      const std::shared_ptr<Bar>& bar,
      const std::shared_ptr<Baz>& baz)
{
   return std::unique_ptr<MyClass>(new MyClassImpl(foo, bar, baz));
   // use std::make_unique with C++14
}

That's a lot of repetition. It means if you want to add or remove or just rename a dependency, you need to edit code in a minimum of seven different places.

A simple thing we can do to reduce the pain is to wrap all the dependencies up in a single struct:

// MyClass.h
#include <memory>

class MyClass
{
public:
   struct Dependencies
   {
      std::shared_ptr<Foo> foo;
      std::shared_ptr<Bar> bar;
      std::shared_ptr<Baz> baz;
   };

   static std::unique_ptr<MyClass> Create(const Dependencies& dependencies);
   // ...
};

// MyClass.cpp
#include "MyClass.h"

namespace
{
   class MyClassImpl : public MyClass
   {
   private:
      const Dependencies m_d;

   public:
      MyClassImpl(const Dependencies& d)
         : m_d(d)
      {
      }
      // ...
   };
}

std::unique_ptr<MyClass> MyClass::Create(const Dependencies& d)
{
   return std::unique_ptr<MyClass>(new MyClassImpl(d));
   // use std::make_unique with C++14
}

We still have to repeat Dependencies itself the same number of times, but it's only one thing, and more importantly, it stays the same even if we modify the dependency list. Further, it's really easy to make reusable code snippets out of something like the above (minus the actual contents of the Dependencies struct).

However, there's another problem now. When each dependency was a constructor argument, it was impossible to forget one. That isn't the case when they are struct members; for example, with the original code, the compiler would reject something like this:

auto myObject = MyClass::Create(someFoo, someBar); // oops, forgot the Baz

But with the new code, the below would build with no problems:

MyClass::Dependencies d;
d.foo = someFoo;
d.bar = someBar;
// oops, forgot the Baz
auto myObject = MyClass::Create(d);

We could handle this by validating that d.baz != nullptr inside MyClassImpl's constructor, but that wouldn't happen until runtime. Your first thought might be to give Dependencies a constructor instead of using memberwise initialization, but then we're back to the original problem of having to repeat all the arguments over and over (though admittedly not as many times).

Another idea is to use aggregate initialization:

MyClass::Dependencies d =
{
   someFoo,
   someBar,
   someBaz
};
auto myObject = MyClass::Create(d);

This looks promising, but what happens if we forget that Baz now?

MyClass::Dependencies d =
{
   someFoo,
   someBar,
   // oops; forgot the baz
};
auto myObject = MyClass::Create(d);

Unfortunately for our current purposes, this still compiles; d.baz will simply be initialized using shared_ptr's default constructor. But there is a way to prevent thiswe just need to make sure the last member in Dependencies is something that cannot be default-constructed. For example:

// FinalMember.h
struct FinalMember
{
   FinalMember(int) {}
};

// MyClass.h
#include "FinalMember.h"

class MyClass
{
public:
   struct Dependencies
   {
       const std::shared_ptr<Foo> foo;
       const std::shared_ptr<Bar> bar;
       const std::shared_ptr<Baz> baz;
       FinalMember finalMember;
   };
   // ...
};

Now callers have no choice but to initialize all the members:

MyClass::Dependencies d =
{
   someFoo,
   someBar,
   someBaz,
   FinalMember(0) // can't omit or put in wrong position without a compile error
};
auto myObject = MyClass::Create(d);

Note that FinalMember could be replaced with anything that can't be value-initialized, including a plain const & to anything, but using a named type helps communicate our intent more clearly.

Downside (maybe): You now must use aggregate initialization when you set up new instances of Dependencies. That's not so bad (and in fact it allows us to make every member const, which makes me feel all warm and fuzzy), but since aggregate initialization is not used all that often with structs, your IDE may not understand what you're doing well enough to help you put things in the right order. Also, some compilers may be uncomfortable with the fact that Dependencies has no user-defined constructors since a default one cannot be generated. For example, with Visual Studio 2012, the code above triggers 3 warnings (the last one is simply incorrect, as we've seen):

warning C4510: 'MyClass::Dependencies' : default constructor could not be generated
warning C4512: 'MyClass::Dependencies' : assignment operator could not be generated
warning C4610: struct 'MyClass::Dependencies' can never be instantiated - user defined constructor required

I have to go to the trouble of disabling these with a pragma since I build with "treat warnings as errors" enabled. But if you (and your tools) can get past this, there's a lot to be said for the convenience this approach can offer.

No comments:

Post a Comment