This posts builds upon my previous posts on my C++ dependency-injection container: Automated instance construction in C++, Improved automated instance construction in C++ and Even better automated instance construction in C++. I was actually quite happy with the version from the last post and didn’t really touch the implementation for a good long while. But lately, I identified a few related requirements that could be solved elegantly by an extension to the container, so I decided to give it a go.
New Requirements
- Sometimes I need to get services from the DI just to create them. They would then register themselves with an event bus or some other system. I would not really call into them actively, and therefore I did not need access to the instances created. This could previously be done via something like
(void)provider.get<my_autonomous_system>(), after all the services were registered. That works, but doesn’t scale up very well once you have a few of those. It would be much better to have something likeprovider.instantiate_all_autonomous_systems(). - Some groups of systems I would instantiate and keep around just to call them in a totally homogeneous way, like
system_one.update(),system_two.update(), etc.. Again it would be better to not require the concrete types at the call site and instead just get the requested systems and call theirupdate()in a loop.
Query Interface
It turns out that both requirements can be solved by requesting instances for “a group” of registered services. In the case of the first requirement, that’s actually all that is needed, but for the second requirement, the instances also need to be processed in some way, e.g. upcasting or other forms of type-erasure. Here’s how I wanted it to look:
di di;
di.insert_unique<actual_update_thing_one>().trait<update_trait>();
di.insert_unique<actual_update_thing_two>().trait<update_trait>();
auto updaters = di.query_trait<update_trait>();
for (auto const& each : updaters)
each->update();
After registration with the DI, types can be marked with one or many traits, which can later be queried. For this example, the trait looks like this:
struct update_trait
{
using type = update_service*;
static update_service* type_erase(update_service* x)
{
return x;
}
};
It really just does an upcast to update_service, which is derived-from by both of the types. But it would be equally possible to use std::function<> in case the types are only compatible via duck-typing:
struct update_trait
{
using type = std::function<void()>;
template <class T> static std::function<void()> type_erase(T* x)
{
return [x]
{
x->update();
};
}
};
Of course, that changes the final loop in the example to:
for (auto const& each : updaters)
each();
So a traits type needs to contain a type-alias for the target type and a function to process the instance pointer into that target type, be it by wrapping it in some sort of adaptor or via upcasting. They type is separate, and not the return type of the function, because it has to be independent of the instance type that goes in, while the function can be a template and thus have different return (which is fine if they all convert to the target type).
Implementation
When you add a trait for a type T via the .trait<Trait>() template, I register a what I call a ‘resolver’, which is just a std::function<typename Trait::type()> that invokes Trait::type_erase(get_ptr<T>()). These are all put into a std::vector<>:
template <typename Trait> using trait_resolvers =
std::vector<std::function<typename Trait::type()>>;
For all the traits, these are stored in an std::unordered_map<std::type_index, std::any> where the key is typeid(Trait).
On query_trait<Trait>, I look into that map, get the trait_resolvers<Trait> out of it, and call each resolver to fill a new std::vector<typename Trait::type>, which is then returned and can be iterated by the user.
This implementation maps better to to the second use-case, but the first can be done with bogus type_erase function in the trait like this:
struct auto_create_trait
{
using type = int;
template <class T>
static int type_erase(T* x)
{
return 0;
}
};
This creates an std::vector<int> that isn’t needed, which is not ideal but not a deal-breaker either. On the other hand, it is not too hard to properly support void as the type with just two if constexpr (std::is_same_v<typename Traits::type, void>), one in the resolver lambda that omits the type_erase call and one in query_trait that omits storing the resolver result. This way, I can also use [[nodiscard]] on query_trait, and the trait can be written as just struct auto_create_trait { using type = void; };.


