This blog discusses the pros and cons of C++ dependency injection with templates and abstract classes, and provides guidance on when to use which.
As software projects grow, it becomes increasingly difficult to test and reuse code. That's where dependency injection comes in. The most popular patterns are templates and abstract classes. Let's take a look at both…
Abstract Classes
Implementation
Dependency injection with abstract classes uses a class with only virtual functions to define the interface. The different implementations are derived from this class and implement all these pure virtual functions.
// fancy_interface.hpp
#pragma once
#include <cstdint>
class FancyInterface {
public:
virtual uint32_t DoSomethingFancy(uint32_t value) = 0; // Pure virtual function
};
// fancy_implementation_1.hpp
#pragma once
#include <cstdint>
#include "fancy_interface.hpp"
class FancyImplementation1 : public FancyInterface {
public:
uint32_t DoSomethingFancy(uint32_t value) override {
return value + 42;
};
};
// fancy_implementation_2.hpp
#pragma once
#include <cstdint>
#include "fancy_interface.hpp"
class FancyImplementation2 : public FancyInterface {
public:
uint32_t DoSomethingFancy(uint32_t value) override {
return value + 86;
};
};The class that will use the implementations does not need to know about the different implementations, it just knows about the abstract class.
// fancy_user.hpp
#pragma once
#include <cstdint>
#include <memory>
#include "fancy_interface.hpp"
class FancyUser {
public:
FancyUser(std::shared_ptr<FancyInterface> dependency)
: dependency_{dependency} {
}
uint32_t CallTheDependency(uint32_t value) {
return dependency_->DoSomethingFancy(value);
}
private:
std::shared_ptr<FancyInterface> dependency_{nullptr};
};Finally, the user will simply invoke the class that was provided at initialization.
// main.cpp
#include <iostream>
#include <memory>
#include "fancy_implementation_1.hpp"
#include "fancy_implementation_1.hpp"
#include "fancy_user.hpp"
int main() {
auto impl1 = std::make_shared<FancyImplementation1>();
auto impl2 = std::make_shared<FancyImplementation2>();
auto user1 = std::make_shared<FancyUser>(impl1);
auto user2 = std::make_shared<FancyUser>(impl2);
std::cout << user1->CallTheDependency(12) << std::endl; // -> 54
std::cout << user2->CallTheDependency(12) << std::endl; // -> 98
return 0;
}Pros
- Dependencies can easily be mocked in unit tests
- Dependencies can be changed at runtime
- Reduced compile-time, because dependencies are resolved at runtime
Cons
- Poor performance, because dependencies are resolved at runtime
Notes
- Ability to split definition and declaration into source and header files
Templates
Implementation
Dependency injection with templates do not have any kind of interface. There are only the implementations.
// fancy_implementation_1.hpp
#pragma once
#include <cstdint>
class FancyImplementation1 {
public:
uint32_t DoSomethingFancy(uint32_t value) {
return value + 42;
};
};
// fancy_implementation_2.hpp
#pragma once
#include <cstdint>
class FancyImplementation2 {
public:
uint32_t DoSomethingFancy(uint32_t value) {
return value + 86;
};
};The class that will use the implementations does not need to know about the different implementations, it just knows about the template parameter.
// fancy_user.hpp
#pragma once
#include <cstdint>
#include <memory>
template <typename TFancyImpl>
class FancyUser {
public:
FancyUser(std::shared_ptr<TFancyImpl> dependency)
: dependency_{dependency} {
}
uint32_t CallTheDependency(uint32_t value) {
return dependency_->DoSomethingFancy(value);
}
private:
std::shared_ptr<TFancyImpl> dependency_{nullptr};
};Finally, same as above, the user will simply invoke the class that was provided at initialization.
// main.cpp
#include <iostream>
#include <memory>
#include "fancy_implementation_1.hpp"
#include "fancy_implementation_2.hpp"
#include "fancy_user.hpp"
int main() {
auto impl1 = std::make_shared<FancyImplementation1>();
auto impl2 = std::make_shared<FancyImplementation2>();
auto user1 = std::make_shared<FancyUser<FancyImplementation1>>(impl1);
auto user2 = std::make_shared<FancyUser<FancyImplementation2>>(impl2);
std::cout << user1->CallTheDependency(12) << std::endl; // -> 54
std::cout << user2->CallTheDependency(12) << std::endl; // -> 98
return 0;
}Pros
- Dependencies can easily be mocked in unit test
- Best performance, because dependencies are resolved at compile-time
Cons
- Increased compile-time, because dependencies are resolved when compiling
- Dependencies can not be changed at runtime, they are fixed when compiling
- Compiler errors and warnings are harder to read
Notes
- Cannot split definition and declaration into source and header files (header-only)
Conclusion
When to use which pattern depends on the use case. Both are great for unit testing. If performance matters, nothing beats templates. If a more dynamic handling is required, and performance is not as crucial, abstract classes are the method of choice.
Another criterion is the file structure. With an abstract class, it is possible to split the declaration and definition into header and source files. With templates, everything of a class is in one file (header-only). Which one you prefer depends on your personal style.
Personally, I almost always prefer templates over abstract classes, because in my projects performance is most often the deciding factor. I also like the header-only approach, where everything about a class is in one file. For me, this results in cleaner and more readable code.