-
Notifications
You must be signed in to change notification settings - Fork 503
Dependency Injection
Singletons and hard-coded dependencies in general, do wonders to make a codebase hard to understand, hard to test, and hard to maintain.
The solution is to use a specific style of coding to avoid having to deal with this. Dependency Injection is a widely used paradigm in industry for this problem, and it is easier than it sounds.
We should strive to write code using the DI framework. There are certain exceptions to this: debug logging, and other similar tools for developers don't have to conform to this standard. In the general case though, all code should make all external dependencies explicit through DI-like programming style.
Read the link above for a thorough introduction, but otherwise, here is a quick example of how these things work. Suppose we want to write a LinkedList, but linked list nodes will be reused:
struct LinkedListNode {
// ...
};
class LinkedList {
// ...
template <typename T>
void Add(T content) {
// ...
LinkedListNodeObjectPool::GetInstance().New(content);
}
};
At this point, it is hiding the fact that it mutates the state of the global singleton LinkedListNodeObjectPool
, and because we made an explicitly call to it, preventing modularized testing. Now consider a scenario where for some reason LinkedListNodeObjectPool
hands out large chunks of memory (say, 1 MB each node), and we want to do scale testing on the operations of LinkedList
(e.g. Insert, Delete concurrently), our test will have to run slowly because there is no way for us to change the LinkedListNodeObjectPool
's memory behavior without changing the code. Whereas in an ideal world, we know that in this test, it doesn't matter what the content of these LinkedListNode
are, and can get away with a fake object that just has the pointer field.
Suppose we have written the code instead in this way:
class LinkedList {
LinkedList(LinkedListNodeObjectPool &pool) : pool_(pool) {}
// ...
template <typename T>
void Add(T content) {
// ...
pool_.New(content);
}
//...
LinkedListNodeObjectPool &pool_;
};
Everything still works. But now suppose we want to write the above test, we simply do:
// fake implementation with low memory overhead
class FakeLinkedListNodeObjectPool : public LinkedListNodeObjectPool {
// ...
}
TEST(LinkedListTests, LargeTest) {
FakeLinkedListNodeObjectPool fake_pool;
LinkedList tested(fake_pool);
// test on tested
}
When we execute the real program, presumably in main.cpp
:
int main() {
// ...
LinkedListNodeObjectPool real_pool;
LinkedList tested(real_pool);
// ...
}
To sum it up, dependency injection states that object creation and the logic be separated. This allows us to change the object without changing the logic. Of course, in practice, there is no need to be as strict about this; it might make sense, for example, for objects to create and own objects when the owned object is essential to functionality (e.g. rarely do we want to change a std::vector implementation, so creating a vector member in the object is fine). However, if the object in question logically is a different component, and it might make sense for the logic to be tested apart from the object, write the code in this way.
boost::di is the framework we would use to help us simplify the manual process of wiring constructors as shown above. The website provides good documentation for the framework, and this talk by the creator is also useful in gaining proficiency in it.
boost::di does not suit our exact needs. The primary issue is that its automatic scope deduction conflicts with our preference of pointers and ManagedPointer
s . To circumvent this, we implemented a set of extensions to the boost::di framework in src/include/di/di_help.h
. The primary modification is three new scopes:
TerrierSharedModule
TerrierSingleton
DisabledModule
All new scopes can inject types T *
and common::ManagedPointer<T>
. TerrierSharedModule
and TerrierSingleton
can also inject const T &
. TerrierSharedModule
is similar to the shared
scope in boost::di, where all objects created from the same injector share the same instance, and the instance has life time the same as the injector. TerrierSingleton
has singleton scope, and the instance has the same life time as the application. DisabledModule
is a special scope that injects nullptr
. This means the module is turned off for many parts of the system (e.g. logging).
There are also two custom binding policies, StrictBindingPolicy
and TestBindingPolicy
. These are less important in making boost::di work and serve more as examples about how to customize boost::di's behavior. Read the code for details.
Carnegie Mellon Database Group Website