A C++ dependency injection framework. The injector is configured via templated binding functions and provides get-instance methods for non-polymorphic and polymorphic values, references, thread-local and non-thread-local singletons. The code based application configuration mechanism is performed via implementations of the ApplicationConfiguration base class. Binding declaration calls within application configuration classes define non-polymorphic value (termed value), polymorphic value (termed unique), reference, and singleton bindings.
The dependency injection framework also integrates with the hierarchical property framework. This allows typed and untyped (std::string) environment properties (simple and composite) to be created in the injector via implementations of the EnvironmentConfiguration class.
Using this approach, multiple application processes (one set of processes per application environment) may be run from the same injector configuration. Each environment is configured according to the environment's property file(s) and the environment configuration is validated and loaded into the injector by the meta-configuration contained in the implementations of the EnvironmentConfiguration class.
Using a dependency injection approach for the structural wiring of a software application is useful when the complexity of the application reaches a certain threshold. As an application's source code becomes larger and more complex, manual management of dependencies becomes overly complicated and error prone.
In this respect, using one or more injectors in the design of a software application reduces complexity, concentrates structural wiring into a concise set of declarations, and delegates object lifetime management of long lived objects to the injection framework. The dependency injection paradigm also facilitates isolating a class for unit testing, by allowing mocked or stubbed dependencies to be supplied to an instance of the class being tested.
The Balau injector provides a constructor injection paradigm, where the constructors of injectable classes are the populating mechanism of injected instances. Configuration of the injector dependency graph is performed within one or more configuration classes that are specified during injector instantiation. Configuration of each injectable class is achieved via an injector macro that specifies the dependencies that the injector will provide during instantiation of the class.
Templated binding calls are used to provide type information at configuration time. Binding calls are available for the specification of values, prototypes, instances, references, providers, thread-local singletons, singletons, and provided singletons.
In addition to providing configured dependencies, the injector can provide itself as a dependency when the shared Injector class is requested. This allows complex injectable classes to use the injector directly, in addition to their standard dependencies. Differently configured injectors can also be injected into a service at runtime, by using injector hierarchies.
There are four types of instance provided by the injector:
Each meta-type has its own instantiation semantics:
The Balau injector is designed to allow arbitrary injector hierarchies to be arranged at runtime and obtained via an instance of the simple (non-template) Injector class. This includes injection of the injector into complex injectable classes that require direct access to it. Bindings may also be named, allowing identically typed but differently named bindings to be created and looked up dynamically.
In order to ensure that binding issues are caught before injectors are used, each constructed injector runs a dependency validation phase during instantiation. During this validation phase, the injector constructs a dependency graph of all registered interfaces, classes, providers, and relationships. The required bindings of each dependency are verified. Dependency cycle analysis is also performed. This validation ensures that binding issues are exposed during injector instantiation, allowing simple unit tests to be constructed to test the structural wiring of each the application injector configuration.
#include <Balau/Application/Injector.hpp>
#include <Balau/Application/Injectable.hpp>
The steps involved in creating a software application based on the Balau injector are:
When creating an injector, the injector factory function takes one or more injector configuration classes. There are two types of injector configuration:
Application configuration defines the fixed application binding definitions for values, instance creation, references, thread-local singletons, and singletons. Environment configuration defines requirements and type information for environment specific value bindings (simple and composite), created from environment specific properties files.
Both types of configuration are defined by creating a class containing an implementation of the configure method. The code contained within the configure method is different for the two types of configuration.
Application configuration is defined by inheriting from the ApplicationConfiguration class and implementing the configure method with binding calls.
Each binding is specified by a two part fluent call chain, defined within the configure method. The first call in a binding call chain provides the binding's instance type and optional UTF-8 string name. The second call in the chain defines the binding meta-type and any additional type, object, or provider information required.
// An example injector configuration class. class Configuration : public ApplicationConfiguration { public: void configure() const override { bind<Base>().toSingleton<Derived>(); bind<Base2>().toUnique<Derived2>(); bind<Base2>("alternative").toUnique<Derived3>(); } };
There following binding calls are available.
Binding type | Description |
---|---|
toValue() | Bind a concrete class. |
toValue(ValueT) | Bind a prototype value. |
toValueProvider(std::function) | Bind a value provider function. |
toValueProvider<ProviderT>() | Bind an injectable value provider class. |
toValueProvider<ProviderT>(std::shared_ptr<ProviderT>) | Bind an injectable value provider instance. |
toUnique<DerivedT>() | Bind an interface to a concrete class. |
toUniqueProvider(std::function) | Bind an interface to a polymorphic provider function. |
toUniqueProvider<ProviderT>() | Bind an interface to an injectable unique pointer provider class. |
toUniqueProvider<ProviderT>(std::shared_ptr<ProviderT>) | Bind an interface to a provided unique pointer provider instance. |
toReference(BaseT &) | Bind a reference type to the supplied reference object. |
toThreadLocal<DerivedT>() | Bind an interface to a thread-local, lazy concrete singleton class. |
toThreadLocal() | Bind a thread-local, lazy concrete singleton class. |
toSingleton<DerivedT>() | Bind an interface to a lazy concrete singleton class. |
toSingleton() | Bind a lazy concrete singleton class. |
toSingleton(std::shared_ptr<BaseT>) | Bind an interface to the supplied singleton object. |
toSingleton(BaseT *) | Bind an interface to the supplied singleton object via pointer container initialisation style syntax. |
toSingletonProvider<ProviderT>() | Bind an interface to an injectable singleton provider class. |
toSingletonProvider<ProviderT>(std::shared_ptr<ProviderT>) | Bind an interface to a provided singleton provider instance. |
toEagerSingleton<DerivedT>() | Bind an interface to a concrete eager singleton class. |
toEagerSingleton() | Bind a concrete eager singleton class. |
Each of the binding types belongs to one or two of six meta-types. The meta-types are:
Bindings that provide references or shared instances can be non-const or const. The meta-type is determined via the presence or absence of a const qualifier in the specified type in the binding call.
The polymorphic, heap based, instantiated instance meta-type is termed unique instead of instance in order to avoid confusion with the universal getInstance methods that access instances from all meta-types according to the full specified type. These calls are discussed later in the Injector usage section.
Environment configuration is defined by one or two methods:
For more information on defining environment configurations via environment properties within an injector based application, refer to the Environment chapter.
In order that classes take part in dependency injection, an injector macro needs to be added to each of their declarations. There are three types of injector macro available.
Macro | Description |
---|---|
BalauInjectConstruct BalauInjectConstructNamed |
Specify the class' direct dependency fields and implicitly create an injectable constructor. |
BalauInject BalauInjectNamed |
Specify the class' direct or indirect dependency fields. Do not implicitly create an injectable constructor. |
BalauInjectTypes BalauInjectNamedTypes |
Specify the types of the class' dependencies to be injected. Do not implicitly create an injectable constructor. |
All macros take an initial parameter which is the class name. The named versions of the macros take the names of the dependencies in addition to the field names or types.
The choice of which macro to use depends on whether the injectable class' dependencies correspond to direct / indirect fields, or whether one or more of the dependencies will be used in some temporary way instead of being assigned to a field. If the former is the case, then the BalauInjectConstruct / BalauInjectConstructNamed or BalauInject / BalauInjectNamed macros can be used. If the latter is the case, then the BalauInjectTypes / BalauInjectNamedTypes macro should be used, as it will not be possible to automatically determine the types of the dependencies.
The choice of whether to use the BalauInjectConstruct / BalauInjectConstructNamed or the BalauInject / BalauInjectNamed macros when all dependencies are being assigned to fields depends on whether all the dependencies are direct or not, and whether all assignments in the constructor initialisation list are simple or not. If they are direct and simple, then one of the BalauInjectConstruct / BalauInjectConstructNamed macros can be used. Otherwise, the injectable constructor should be manually written and one of the BalauInject / BalauInjectNamed macros used.
The chosen injector macro should be placed within the injectable class' declaration.
// // ////// BalauInjectConstruct macro ////// // // Specify the injected dependencies via the class fields // and implicitly create an injectable constructor. // class Derived2 : public Base2 { private: std::shared_ptr<Base> dependency; BalauInjectConstruct(Derived2, dependency) public: void foo() override; }; // // ////////// BalauInject macro /////////// // // Specify the injected dependencies via the class fields. // class Derived2 : public Base2 { private: std::shared_ptr<Base> dependency; BalauInject(Derived2, dependency) // Explicitly created injectable constructor. public: Derived2(std::shared_ptr<Base> dependency_) : dependency(std::move(dependency_)) {} public: void foo() override; }; // // //////// BalauInjectTypes macro //////// // // Specify the injected dependencies' types. // class Derived2 : public Base { private: std::shared_ptr<Base> dependency; BalauInjectTypes(Derived2, std::shared_ptr<Base>) // Explicitly created injectable constructor. public: Derived2(std::shared_ptr<Base> dependency_) : dependency(std::move(dependency_)) {} public: void foo() override; };
The alternative Named macros take dependency names. When these macros are used, all dependency names must be specified. Empty names must be specified with empty string literals "".
// // Example with a dependency name. // class Derived2 : public Base { private: std::shared_ptr<Base> dependency; BalauInjectConstructNamed(Derived2, dependency, "myDependency") public: void foo() override; };
The injector is created by calling the Injector::create(Conf(), ...) function.
auto injector = Injector::create(Configuration(), ExtraConfiguration());
An alternative create function is also available, which takes the configuration instances in a std::vector.
std::vector<std::shared_ptr<InjectorConfiguration>> conf; conf.emplace_back(new Configuration()); conf.emplace_back(new ExtraConfiguration()); auto injector = Injector::create(conf);
The alternative create function can be useful for defining the configuration in a single place and using it in the main application and in a configuration validation test. Validation of injector configuration is discussed later in this chapter.
Instances can be obtained directly from the injector via the getValue<ValueT> call for non-polymorphic new instances, getUnique<BaseT> for polymorphic new instances, getReference<BaseT> for polymorphic referenced objects, and getShared<BaseT> for polymorphic shared values.
auto value = injector->getValue<MyValueCls>(); auto unique = injector->getUnique<MyBaseCls>(); auto & ref = injector->getReference<MyReferencedCls>(); auto shared = injector->getShared<MySharedBaseCls>();
Alternatively, the unified getInstance<T> method may be used to determine which of the four getValue<ValueT>, getUnique<BaseT>, getReference<BaseT>, or getShared<BaseT> methods should be called, via compile time examination of the type parameter.
// Calls getValue<MyValueCls>() auto value = injector->getInstance<MyValueCls>(); // Calls getUnique<MyBaseCls>() auto unique = injector->getInstance<std::unique_ptr<MyBaseCls>>(); // Calls getReference<MyReferencedCls>() auto & ref = injector->getInstance<MyReferencedCls &>(); // Calls getReference<const MyReferencedCls>() auto & ref = injector->getInstance<const MyReferencedCls &>(); // Calls getShared<MySharedBaseCls>() auto shared = injector->getInstance<std::shared_ptr<MySharedBaseCls>>>(); // Calls getShared<const MySharedBaseCls>() auto shared = injector->getInstance<std::shared_ptr<const MySharedBaseCls>>>();
In this documentation, when it is not important to differentiate between the above methods, the expression get-instance is used.
Child injectors may be created by calling the createChild(Conf(), ...) method. These instance methods take one or more configuration class template parameters and instantiates a child injector with the current injector as the parent.
// Create child injector with the specified configuration. auto c = injector->createChild(ChildConf());
The alternative create method is also available for child injector creation. This create method takes the configuration instances in a std::vector.
std::vector<std::shared_ptr<InjectorConfiguration>> conf; conf.emplace_back(new ChildConfiguration()); conf.emplace_back(new ExtraChildConfiguration()); auto c = injector->createChild(conf);
Alternatively, child injectors may be created by first creating a prototype child injector as above, then repeatedly calling the createChild(prototype) method each time a new child injector is required.
// Create child injector with the specified configuration. auto prototype = injector->createChild(ChildConf()); // Create child injector from the prototype. auto c = injector->createChild(prototype);
Using prototype child injectors avoids the build and validation phases of injector construction each time a new child injector is required. The total overhead of creating a child injector from a prototype is limited to the copying of two shared pointers.
It is important to note that the instances of singleton and thread-local singleton bindings of the prototype will be shared between all child injectors created from the prototype. If this is not desired behaviour, then a new child injector must be created via the other createChild factory functions that instantiate their own bindings.
There are two places where injector configuration is located. The first is within the application's injector configuration. This configuration is the internal wiring of the application. The configuration takes the form of one or more injector configuration classes, instances of which are passed to the injector at construction time. Configuration classes provide the binding information that is used to create the dependency graph.
The second place where injector configuration is located is within participating classes. These are typically each defined with a Balau injector macro in the class declaration. These macros provide information (normally via the decltype of direct or indirect class member variables) on the injected dependency types and optionally dependency names that the injector will use during instantiation.
An injector aware class only identifies the injected objects to deliver to the class' injectable constructor, and is independent to the main dependency wiring configuration of a developed software application. Injectable classes may thus be developed independently of the main application, with the application's configuration subsequently wiring them into the dependency graph.
Non-injector aware types and primitive types used for prototype based instance provision do not have any injector macro applied to them. Instances that are constructed manually and passed to a binding call as prototypes, references, or singletons do not require an injector macro, as they are not instantiated by the injector. Similarly, instances that are constructed within a provider function or class that is passed to a binding call do not require an injector macro, as they are not instantiated by the injector either.
The injector is configured via one or more configuration classes, instances of which are passed to the injector's constructor. An example of an application configuration class is:
class Configuration : public ApplicationConfiguration { public: void configure() const override { bind<Base>().toSingleton<DerivedA>(); bind<Base2>().toUnique<Derived2>(); } };
Each configuration class must implement the configure method. Binding statements are placed inside application configuration implementation configure methods. Environment property name/type declarations are placed inside environment configuration implementation configure methods. This documentation chapter discusses application configuration in detail. For more information about environment configuration, see the Environment chapter.
Each binding command in an application configuration class implicates a particular type of binding. In the above example, polymorphic singleton and polymorphic new instance bindings are configured.
Each binding command consists of a two part fluent call. The first bind call specifies the interface class as the function template parameter and an optional binding name as a call argument.
The second call specifies the binding type via the function name, along with implementation or provider type as the template parameter for binding types that require one. Bindings that require an object receive the object as a call argument.
The available binding calls are as follows.
Binding type | Description |
---|---|
toValue() | Bind a concrete class. A new instance of the class will be stack created each time an instance is requested, and returned via copy elision. The object will be supplied as ValueT. |
toValue(ValueT) | Bind a prototype value. A new instance of the value will be created each time an instance is requested via copy semantics. The value will be supplied as ValueT. |
toValueProvider(std::function<ValueT ()>) | Bind a concrete class to a provider function. A new instance of the class will be stack constructed by the provider each time an instance is requested. The object will be supplied as ValueT. |
toValueProvider<ProviderT>() | Bind a concrete class to an injectable provider class. A new instance of the value class will be stack constructed by the provider each time an instance is requested. The object will be supplied as ValueT. The provider will be constructed via standard injection of the provider's dependencies. |
toValueProvider<ProviderT>(std::shared_ptr<ProviderT>) | Bind a concrete class to an injectable provider instance. The provider instance is supplied in a shared pointer container, allowing the caller to retain shared ownership if required. A new instance of the value class will be stack constructed by the provider each time an instance is requested. The object will be supplied as ValueT. The provider will be constructed via standard injection of the provider's dependencies. |
toUnique<DerivedT>() | Bind an interface to an implementing class. A new instance of the class will be heap constructed each time an instance is requested. The object will be supplied as std::unique_ptr<BaseT>. |
toUniqueProvider( std::function<std::unique_ptr<BaseT> ()> ) |
Bind an interface to a provider function. A new instance deriving from the base type will be heap constructed by the provider each time an instance is requested. The object will be supplied as std::unique_ptr<BaseT>. |
toUniqueProvider<ProviderT>() | Bind an interface to an injectable provider class. A new instance deriving from the base type will be heap constructed by the provider each time an instance is requested. The object will be supplied as std::unique_ptr<BaseT>. The provider will be constructed via standard injection of the provider's dependencies. |
toUniqueProvider<ProviderT>(std::shared_ptr<ProviderT>) | Bind an interface to an injectable provider class. The provider instance is supplied in a shared pointer container, allowing the caller to retain shared ownership if required. A new instance deriving from the base type will be heap constructed by the provider each time an instance is requested. The object will be supplied as std::unique_ptr<BaseT>. The provider will be constructed via standard injection of the provider's dependencies. |
toReference(BaseT &) | Bind a reference type to the supplied reference object. A reference to the object referenced in the injector's configuration will be returned on each call. The object will be supplied as BaseT &. |
toThreadLocal<DerivedT>() | Bind an interface to an implementing class with thread-local singleton semantics. The thread-local singleton will be heap constructed lazily for each new calling thread. The object will be supplied as std::shared_ptr<BaseT>. |
toThreadLocal() | Bind a concrete class with thread-local singleton semantics. The thread-local singleton will be heap constructed lazily for each new calling thread. The object will be supplied as std::shared_ptr<T>. |
toSingleton<DerivedT>() | Bind an interface to an implementing class with singleton semantics. The singleton will be heap constructed lazily. The object will be supplied as std::shared_ptr<BaseT>. |
toSingleton() | Bind a concrete class with singleton semantics. The singleton will be heap constructed lazily. The object will be supplied as std::shared_ptr<T>. |
toSingleton(std::shared_ptr<BaseT>) | Bind an interface to the supplied singleton object. The injector will share ownership of the pointer. The object will be supplied as std::shared_ptr<BaseT>. |
toSingleton(BaseT *) | Bind an interface to the supplied singleton object. The injector will take ownership of the pointer. The object will be supplied as std::shared_ptr<BaseT>. |
toSingletonProvider() | Bind a singleton provider class for singleton semantics. The singleton will be provided by instantiating the provider and calling it a single time during injector creation. The object will be supplied as std::shared_ptr<T>. |
toSingletonProvider(std::shared<ProviderT>) | Bind a singleton provider instance for singleton semantics. The singleton will be provided by calling the provider a single time during injector creation. The provider will then be dereferenced. The object will be supplied as std::shared_ptr<T>. |
toEagerSingleton<DerivedT>() | Bind an interface to an implementing class with singleton semantics. The singleton will be heap constructed eagerly. The object will be supplied as std::shared_ptr<BaseT>. |
toEagerSingleton() | Bind a concrete singleton class. The singleton will be heap constructed eagerly. The object will be supplied as std::shared_ptr<T>. |
Each of the binding types belongs to one of four meta-types. These meta-types are:
These classifications correspond to the four types of instance provided by the injector.
Two of the four meta-type classifications are also available in const form. There are thus effectively six meta-types in total:
The meta-type classification and const qualifier of a binding forms part of the binding key (the other parts being the typeid and the name). The six meta-type classifications thus form six binding groups. In each binding group, the typeid and name must be unique. If an attempt is made to create an injector with a configuration that has duplicate typeid/name pairs in a classification group, a DuplicateBindingException will be thrown during creation of the injector.
When the injector completes the configuration phase, a validation phase is run. This validation phase verifies that all the dependencies required by each injectable participating class (i.e. a class that takes one or more dependencies) can be satisfied by the injector. If this is not the case, the injector throws a NoBinding exception.
The validation phase ensures that any binding issues that may occur with registered classes are caught at the time of injector instantiation. The only subsequent binding runtime errors that may occur are thus direct attempts to obtain instances from the injector for bindings that do not exist.
Although reference bindings are conceptually simple, care must be taken with regard to referenced object lifetimes.
As referenced objects are not instantiated inside the injector nor does the injector take ownership of the object, the injector has no control on the lifetime of the supplied objects. Consequently, it is important to take into account that the lifecycle management of referenced objects is the responsibility of the application and not of the injector.
The policy for referenced object lifetimes must therefore be to ensure that all objects passed to the injector's configuration for referencing remain alive past the end of the injector's lifetime and the lifetimes of other objects that have obtained references to these objects from the injector.
Due to the potential complexity of managing this, it is best to limit the use of reference bindings to that of objects that are easily known to have sufficiently long lives.
The injector supports const bindings for Reference and Shared binding types.
If const bindings are specified for for Value or Unique binding types, the const qualifier will be stripped from the type and a warning logged to the balau.injector. Stripping the const qualifier from Value or Unique binding types will not affect the injector semantics, as bindings of these meta-types produce new instances. If a new instance needs to be const, the instance can be set to const at the calling site.
Base interfaces / abstract base classes do not require an injector macro and are no different from any other abstract C++ class.
Concrete implementation classes that are to be instantiated by the injector require an injector configuration macro. This macro specifies the types (via decltype for the BalauInjectConstruct / BalauInjectConstructNamed and BalauInject / BalauInjectNamed macros, and directly for the BalauInjectTypes / BalauInjectNamedTypes macros) and optional names of the injected dependencies. The BalauInjectConstruct / BalauInjectConstructNamed macros also define an injectable constructor.
Injector macros are currently defined with up to sixteen unnamed or named dependencies.
The following is an example of an injectable class that uses an inject-construct macro.
class Derived2 : public Base2 { // The dependency that is populated via the constructor. private: std::shared_ptr<Base> dependency; // The injector boilerplate. BalauInjectConstruct(Derived2, dependency) public: void foo2() override; };
The macro BalauInjectConstruct specifies that this implementation of Base2 takes a single dependency. The specified member variable's type will be used to form the factory method in the class and the corresponding constructor.
The resulting implicit constructor defined by the BalauInjectConstruct macro is as follows.
private: explicit Derived2(std::shared_ptr<Base> dependency_) : dependency(std::forward<std::shared_ptr<Base>>(dependency_)) {}
The automatically generated constructor will move value, unique pointer, and shared pointer rvalue type dependencies into the member variables, and will assign supplied reference lvalue type dependencies to their associated class members.
The general form of the automatically generated constructors via the BalauInjectConstruct / BalauInjectConstructNamed macros is as follows (d0, d1, d2, ... are the member variables/references of the class).
private: ClassName(decltype(d0) d0_, decltype(d1) d1_, decltype(d2) d2_, // ... up to 16 in total ... ) : d0(std::forward<decltype(d0)>(d0_)) , d1(std::forward<decltype(d1)>(d1_)) , d2(std::forward<decltype(d2)>(d2_)) // ... up to 16 in total ... {}
The two possible formats of the macro are:
BalauInjectConstruct(ClassName, MemberVariable ... ) BalauInjectConstructNamed(ClassName, { MemberVariable, Name } ... )
where:
The first parameter in the macros is always the name of the class. As C++ does not have any way of specifying the class' type within a class declaration, the class name must be provided to the macro.
The MemberVariable entries specified in the macro are used in decltype expressions in order to obtain the required types for the dependencies.
In addition to the BalauInjectConstruct / BalauInjectConstructNamed macros, a similar pair of BalauInject / BalauInjectNamed macros exist.
BalauInject(ClassName, MemberVariable ... ) BalauInjectNamed(ClassName, { MemberVariable, Name } ... )
These macros are identical to the BalauInjectConstruct / BalauInjectConstructNamed macros with the exception that they leave the definition of the injectable constructor to the end developer. This allows the injectable constructor to be customised. The notable requirement for this is when injected objects are consumed in a non-uniform way.
The following is an example of a constructor-less macro used to specify named dependencies.
class Derived2WithNamed : public Base2 { private: std::shared_ptr<Base> dependency; // The injector boilerplate for a named dependency. BalauInjectNamed(Derived2WithNamed, dependency, "namedBase") // Explicitly defined injectable class, allowing customisation. private: explicit Derived2WithNamed(std::shared_ptr<Base> aDependency) : dependency(std::move(aDependency)) { capture.add("Derived2WithNamed constructor"); } public: ~Derived2WithNamed() override = default; public: void foo2() override { capture.add("Derived2WithNamed.foo2"); dependency->foo(); } };
One notable use case for using the BalauInject / BalauInjectNamed macros instead of the BalauInjectConstruct / BalauInjectConstructNamed macros is when indirect member variables need to be specified.
Another example of the use of an explicit injectable constructor can be seen in the HttpServer class of the Balau library. This example illustrates the use of indirect member variables to define the injected types. Several of the injected objects are consumed by the inner state object instead of direct fields, necessitating an explicit definition of the injected constructor.
// The injector macro. BalauInjectNamed( HttpServer , state->injector, "" , state->serverId, "httpServerIdentification" , state->endpoint, "httpServerEndpoint" , threadNamePrefix, "httpServerThreadName" , workerCount, "httpServerWorkerCount" , state->httpHandler, "httpHandler" , state->wsHandler, "webSocketHandler" , state->mimeTypes, "mimeTypes" ); // The explicitly defined injectable constructor. HttpServer(std::shared_ptr<Injector> injector, std::string serverIdentification, TCP::endpoint endpoint, std::string threadNamePrefix_, size_t workerCount_, std::shared_ptr<HttpWebApp> httpHandler, std::shared_ptr<WsWebApp> wsHandler, std::shared_ptr<MimeTypes> mimeTypes);
The standard injector macros use the direct or indirect field names of the class in decltype expressions in order to obtain the required types for the dependencies. This approach is compact and efficient and should be used in the majority of cases. However, these macros will not work if:
The alternative BalauInjectTypes / BalauInjectNamedTypes macros perform a similar job to the standard macros, but take the types of the dependencies instead of the direct or indirect member variable names.
BalauInjectTypes(ClassName, DependencyType ... ) BalauInjectNamedTypes(ClassName, { DependencyType, Name } ... )
If the HttpServer class were to be declared with a BalauInjectNamedTypes macro instead of the BalauInjectNamed macro, the source code would look like the following extract.
// The injector macro with explicit dependency type information. BalauInjectNamedTypes( HttpServer , std::shared_ptr<Injector>, "" , std::string, "httpServerIdentification" , TCP::Endpoint, "httpServerEndpoint" , std::string, "httpServerThreadName" , size_t, "httpServerWorkerCount" , std::shared_ptr<HttpWebApp>, "httpHandler" , std::shared_ptr<WsWebApp>, "webSocketHandler" , std::shared_ptr<MimeTypes>, "mimeTypes" ); // The explicitly defined injectable constructor. HttpServer(std::shared_ptr<Injector> injector, std::string serverIdentification, TCP::endpoint endpoint, std::string threadNamePrefix_, size_t workerCount_, std::shared_ptr<HttpWebApp> httpHandler, std::shared_ptr<WsWebApp> wsHandler, std::shared_ptr<MimeTypes> mimeTypes);
Given that the types of the direct or indirect member variables of an injectable class often match the types of the dependencies, use of the BalauInjectTypesX macros should only occur in a minority of cases.
Once one or more suitable configuration classes have been defined, an injector instance may be created by calling the Injector::create(conf, ...) function:
std::shared_ptr<Injector> injector = Injector::create(Config1(), Config2());
This instantiates an injector instance in a std::shared_ptr<Injector> and initialises the bindings from the supplied configuration(s). The auto keyword can be used to condense the statement:
auto injector = Injector::create(Config1(), Config2());
Injectors can only be instantiated within a std::shared_ptr<Injector>. This allows them to supply themselves as a dependency when required (via shared_from_this()), and also be accessed as class member fields of type Injector in classes that require direct access to the injector.
An injector may be shared throughout the application by copying the shared pointer. Injectors may be used across multiple threads of the application without any synchronisation.
In order to obtain instances from an injector, four templated method calls are available:
ValueT stackInstance = injector.getValue<ValueT>(); std::unique_ptr<BaseT> heapInstance = injector.getUnique<BaseT>(); BaseT & reference = injector.getReference<BaseT>(); std::shared_ptr<BaseT> singleton = injector.getShared<BaseT>();
The type BaseT can be non-const or const for reference and shared bindings.
Depending on the injector's configuration:
In addition to the above calls, there is a unified templated method call that resolves the meta-type by specialising on the supplied type parameter.
T object = injector.getInstance<T>();
Unlike the four previous template functions that all accept the direct value or base type of the instance(s) represented by the binding, the getInstance template method resolves at compile time to:
The getInstance template method is useful when an injector is used within a template class or function, where the exact type to be requested is deduced by the compiler.
Bindings may be created const for reference and shared meta-types. For example, the following application creates an injector configuration with a const reference binding and a const singleton binding, along with an injected double value, then gets the objects from the constructed injector.
#include <Balau/Application/Injector.hpp> struct A { double value; BalauInjectConstruct(A, value); A(A &) = delete; // Prevent copying. }; const A a(543.2); int main () { class Configuration : public ApplicationConfiguration { public: void configure() const override { // A double value injected into A. bind<double>().toValue(123.456); // Bind a const reference. bind<const A>().toReference(a); // Bind a const singleton. bind<const A>().toSingleton(); } }; auto injector = Injector::create<Configuration>(); auto & r = injector->getReference<const A>(); auto a = injector->getShared<const A>(); }
The injector calls return a const reference and a shared pointer containing a const pointer. Note that the reference call requires an ampersand after the auto type keyword in order for the code to compile.
Care should be take with regard to getting references from the injector. If the copy constructor of class A were not deleted, the code would compile if the ampersand were removed.
// Class A2 has a copy constructor.. auto a3 = injector->getReference<const A2>();
The result of this would be a copy of the reference instead of a reference to it. Such semantics are best created via the toValue(prototype) binding call instead.
Consequently, it is wise to delete the copy constructor of classes that are destined to be referenced via the injector. This will enforce referencing at compile time.
When a get-instance call is made for a const object, the resulting binding used may be a non-const binding that is promoted to a const binding. This non-const to const binding semantics of the injector parallels the non-const to const binding semantics of the C++ language.
Although value and unique meta-types do not support const bindings, const promotion nevertheless applies to these non-polymorphic and polymorphic new instance binding types. These const promotions are similar to that in C++ when a non-const object is copy assigned to a new const declared object.
When the injector is part of a hierarchy, const promotion applies to the whole hierarchy. The whole hierarchy will thus first be checked for a const binding, then the whole hierarchy will be checked again for a non-const binding.
The promotion rules are listed in the following table. The left hand column lists the requested const types. The middle column lists the default binding type supplied by the injector if a binding of that type is available. If such a binding is not available, the injector will lookup a binding of the type listed in the third column.
Requested meta-type | Default provided meta-type | Promoted provided meta-type |
---|---|---|
const ValueT | - | ValueT |
std::unique_ptr<const BaseT> | - | std::unique_ptr<BaseT> |
const std::unique_ptr<const BaseT> | - | std::unique_ptr<BaseT> |
const BaseT & | const BaseT & | BaseT & |
std::shared_ptr<const BaseT> | std::shared_ptr<const BaseT> | std::shared_ptr<BaseT> |
const std::shared_ptr<const BaseT> | std::shared_ptr<const BaseT> | std::shared_ptr<BaseT> |
When a get-instance call is made for a std::weak_ptr<BaseT>, the binding request will be promoted to a shared binding. Weak pointer fields are thus initialised via shared bindings during injection.
The promotion rules for weak pointers are as follows.
Requested meta-type | Default provided meta-type | Fallback provided meta-type |
---|---|---|
std::weak_ptr<BaseT> | std::shared_ptr<BaseT> | - |
std::weak_ptr<const BaseT> | std::shared_ptr<const BaseT> | std::shared_ptr<BaseT> |
const std::weak_ptr<const BaseT> | std::shared_ptr<const BaseT> | std::shared_ptr<BaseT> |
The C++ std::unique_ptr and std::shared_ptr containers can be created with custom deletion policies. These allow deletion of pointers at the ends of their lifespans via deletion mechanisms other than the standard delete call as provided by std::default_delete.
The mechanism by which custom deletion polices is specified in C++ is different for each pointer container type. The Balau injector thus provides two different mechanism for deletion policy specification in binding calls, one for std::unique_ptr and another for std::shared_ptr. Accordingly, the mechanism for obtaining unique and shared instances that have custom deleters is different for each binding type.
The C++ std::unique_ptr container requires a custom deletion policy to be specified as a type argument to the unique pointer class template. This is thus performed at compile time, and becomes part of the pointer container's type.
Due to this, the deleter type of a unique binding is part of the binding key. For unique bindings that do not have a custom deletion policy, the binding key deleter type is std::default_delete<BaseT>.
In order to specify a custom deleter for a unique binding, a custom deleter type is specified in the first part of the fluent call chain, i.e. as a type argument to the bind() call.
// // A custom deleter. // struct CustomDeleter { public: void operator () (U * object) { log.trace("Object deleted {}", (size_t) object); delete object; } }; // // Custom deleter type specified for a binding. // class Configuration : public ApplicationConfiguration { public: void configure() const override { // A unique binding for U, with std::default_delete<BaseT>. bind<U>().toUnique<V>(); // A unique binding for U, with custom deleter type CustomDeleter. bind<U, CustomDeleter>().toUnique<V>(); } };
For other binding types, any deleter type specified in the bind() call will be ignored.
As the custom deleter type is part of the binding key for unique bindings, it must be specified in order to obtain a polymorphic new instance of the specified type with the custom deletion policy.
// Create an injector with the above configuration. auto injector = Injector::create(Configuration()); // Get a polymorphic new instance specified by binding {U, std::default_delete<U>}. auto a = injector->getUnique<U>(); // Get a polymorphic new instance specified by binding {U, CustomDeleter}. auto b = injector->getUnique<U, CustomDeleter>();
The C++ std::shared_ptr container requires a custom deletion policy to be specified as an argument to the shared pointer's constructor. Although custom deleter types for shared bindings are specified at compile time in the Balau binding configuration fluent call chain, the deleter instance itself is supplied at runtime to the C++ std::shared_ptr container. The deleter type does not thus become part of the pointer container's type.
Due to this, the deleter type of a shared binding is not part of the binding key.
In order to specify a custom deleter for a shared binding, a custom deleter type is specified in the second part of the fluent call chain, i.e. as a type argument to the toSingleton(), toEagerSingleton(), and toThreadLocal() calls.
// Custom deleter type specified for a binding. class Configuration : public ApplicationConfiguration { public: void configure() const override { // // A shared binding for U, with std::default_delete<BaseT>. // bind<U>().toShared<V>(); // // A shared binding for U, with custom deleter type CustomDeleter. // // A name is required, otherwise the binding would be identical // to the previous one. // bind<U>("custom").toShared<V, CustomDeleter>(); } };
As the custom deleter type is not part of the binding key for shared bindings, it must not be specified in order to obtain a polymorphic shared instance of the specified type, regardless of whether or not the shared instance has a custom deletion policy.
// Create an injector with the above configuration. auto injector = Injector::create(Configuration()); // Get a polymorphic shared instance specified by binding {U, ""}. auto a = injector->getShared<U>(); // Get a polymorphic shared instance specified by binding {U, "custom"}. auto b = injector->getShared<U>("custom");
The injector resolves instances from its binding configuration or from its parent injector. Injectors can form a hierarchy, the binding configurations of which are queried in turn when an instance is requested.
In order to construct a child injector, the createChild(Conf(), ...) member function is used.
auto childInjector = parent->createChild(Config());
This method call is identical to the function used to create a parentless injector, with the exception that it is a member function.
Child injectors may also be created by first creating a prototype child injector as discussed previously, then repeatedly calling the createChild(prototype) method each time a new child injector is required.
// Create child injector with the specified configuration. auto prototype = injector->createChild(ChildConf()); // Create child injector from the prototype. auto c = injector->createChild(prototype);
Using prototype child injectors avoids the build and validation phases of injector construction each time a new child injector is required. The total overhead of creating a child injector from a prototype is thus limited to the copying of two shared pointers.
It is important to note that the instances of singleton and thread-local singleton bindings of the prototype will be shared between all child injectors created from the prototype. If this is not desired behaviour, then a new child injector must be created via the other createChild functions that instantiate their own bindings.
An additional feature of the injector is the ability to register post-construction and pre-destruction callbacks. Registered callbacks will then be called by the injector, either directly after injector creation (for post-construction callbacks), or immediately before injector destruction (for pre-destruction callbacks).
Registering callbacks provides a convenient way to execute program logic immediate after injector creation and/or immediately before injector destruction. Post-construction and pre-destruction callbacks are also useful for the explicit management of cyclic dependencies between singletons (discussed in the next section).
The only restriction to the program logic that may be run within a callback is that pre-destruction callbacks must be noexcept(true). This is because the pre-destruction callbacks are run from within the injector desctructor.
The signatures of the callback registration methods are as follows.
// Post-construction callback registration. void registerPostConstructionCall(const std::function<void (const Injector &)> & call) const; // Pre-destruction callback registration. void registerPreDestructionCall(const std::function<void ()> & call) const;
The signatures of the callbacks are thus:
// Post-construction function signature. const std::function<void (const Injector &)> // Pre-destruction function signature. const std::function<void ()>
Post-construct callbacks are supplied with a reference to the injector. Pre-destruction callbacks are not. Although pre-destruction callbacks must be noexcept(true), the pre-destruction function signature does not contain noexcept(true), as this is not yet handled by std::function in C++17. Despite this, functions registered as pre-destruction callbacks must nevertheless be noexcept(true).
The static singleton call convenience method provides registration of shared pointer containers that will be managed by the injector post-construction and pre-destruction. This method allows a singleton to be statically available in the application, between the post-construction and pre-destruction execution points.
The signature of the static singleton registration method is as follows.
// Static singleton pointer registration. template <typename T> void registerStaticSingleton( std::shared_ptr<T> * ptrPtr , std::string_view name = std::string_view() ) const;
The diagram on the right illustrates the region of validity for static singleton pointers registered via the registerStaticSingleton injector method. The execution time-line travels from top to bottom. Static singleton pointers are not guaranteed to be valid during binding creation or binding destruction. Dereferencing them directly or indirectly from within the constructors of other injectables may thus result in segmentation faults, depending on the non-deterministic order of the construction of singletons.
In order to ensure that the static singleton registrations methods are called during inject construction, singleton bindings containing static singleton registration calls must be eager, or the singletons must be dependencies of eager singletons. Otherwise, the singletons may not be constructed during binding construction and the registration callbacks will never be executed.
Static singleton registration is a feature that is aimed solely for developers that are rearchitecting a codebase with hard-wired singletons to one that uses dependency injection. Use of static singleton registration is not recommended for greenfield projects. Migration away from static singleton registration should also be planned for rearchitected codebases that have been moved to a dependency injection architecture.
This section discusses how automatic cyclic dependencies are prevented by the injector and how to manually manage cyclic dependencies between instances.
The Balau injector provides a constructor injection paradigm. One consequence of this is that if a cyclic dependency is created between instances obtained from the injector, the application will crash due to a call stack overflow for stack based types or a segmentation fault for hreap based types.
Due to this, the injector runs cyclic dependency analysis in the validation phase run during injector instantiation. If a cycle is found in the binding dependency tree constructed from the supplied configuration, a CyclicDependencyException is thrown.
If a cyclic relationship between two instances is required, this must be managed explicitly by the application. If an explicitly constructed cyclic relationship approach is used using shared pointer containers, the normal rules in C++ regarding std::shared_ptr cycles apply and must be managed accordingly.
The best way to achieve an explicitly managed cyclic dependency is by creating a weak or shared pointer in one of the cyclically depedendent classes, then registering a post-construction callback with the injector from within the constructor of the class. This callback can then set the pointer container to point to the other instance, by obtaining the instance from the injector supplied in the callback.
The choice of weak or shared pointer will depend on the chosen destruction strategy. If no action is taken to explicitly remove the cyclic relationship, then a weak pointer should be used. This ensures that there is no permanent cyclic dependency in place. The inconvenince with using a weak pointer is that the pointer must be obtained via the lock call each time the pointer is required.
If a shared pointer is used, then a pre-destruction callback should be registered with the injector from within the constructor of one of the instances that form the cyclic dependency (typically the same class that contains the post-construction callback registration call). This pre-destruction callback should reset the shared pointer, breaking the cyclic dependency before the injector destructor destroys the binding map.
In order to perform explicit management of cyclic pointers, the injector needs to be injected into one of the cyclically dependent classes.
In order to inject the injector, it is sufficient to specify the injector type as a weak pointer, either via an injector macro that specifies the relevant injector field in the class (BalauInject, BalauInjectConstruct, BalauInjectNamed, or BalauInjectConstructNamed), or explicitly via an injector macro that specifies the exact type to be injected (BalauInjectTypes, or BalauInjectNamedTypes). Once this is done, the injector will inject itself during a get-instance call.
As singleton instances may be created during the creation of the injector, it is important to note that such singletons must not use the injector from within their constructor. Instead, a pointer to the injector should be maintained within a field of the class and set in the constructor initialisation list. The injector can then be used in non-constructor methods in the class.
If the injector is nevertheless required during construction, a post construct callback may be registered with the injected injector. This callback will be executed by the injector immmediate after construction is complete.
Injection of the injector can only be achieved via a std::weak_ptr<Injector>, either specified as a field of the injectable class, or explicitly via the injector macros that specify the exact types of the dependencies. This ensures that the writer of the injectable class be aware that maintaining a shared pointer to the injector in the instance will result in a cyclic dependency if the instance is a singleton (i.e. owned by the injector). If a shared pointer is used to reference the injected injector, the injector will become a node within an implicitly created dependency cycle.
As previously mentioned, injection of the injector can only be performed if the receiving type is a std::weak_ptr<Injector>. An example of what would happen if a shared pointer is used instead is illustrated in the following diagrams. The diagram below shows a set of relationships between an injector, two singleton bindings, and some shared pointers obtained from the injector by the application. Instances are shown in blue, shared pointers internal to the instances are shown in purple, and shared pointers in the application code are shown in brown.
In this example, singleton instance B has a shared pointer field to singleton instance A. It can be verified that there are no cycles by following sequences of agregation/composition paths and nodes. All paths lead to the terminal node A.
If a std::shared_ptr<Injector> member variable is added to A, then the relationships change to those in the diagram below. Two cycles have been formed within the pathways. The new std::shared_ptr<Injector> member variable of A is shown in red. Also shown in red are the path segments that form the cycles.
If an attempt to instantiate an injector is made with such a configuration, the injector will throw a SharedInjectorException during the validation phase.
The solution to this is to use a weak pointer when a Shared binding needs the injector to be injected into it. If a std::weak_ptr<Injector> member variable is created in A instead of the previous std::shared_ptr<Injector>, no such exception is thrown by the injector. The relationships created by this modified configuration are illustrated below.
The std::weak_ptr<Injector> forms a dependency break in the pathways and consequently there are no cycles present in the dependency graph.
Regardless of the above, it is important to note that the normal rules of C++ still apply after the weak pointer is supplied. If an injector shared pointer is manually created in the injectable class' constructor from a supplied injector weak pointer and then subsequently used to set a shared pointer field, a cyclic dependency will be created and the injector will never be destroyed. Note that using a pre-destructor callback will not work either, as these callbacks are run from within the injector's destructor, which will never get called. It is thus essentially up the end developer to respect the requirement that pointers to the injector in injectable classes be maintained as weak pointers.
Once the configuration(s) of the application's injector(s) have been created, they can be unit tested via one or more simple unit tests.
The injector provides two static methods validate and validateChild. The first method validate validates root injector configurations and the second method validateChild validates child configurations.
Unit testing an injector configuration that does not contain any eager singletons could be achived by simply instantiating the injector. However, if the configuration has any eager singletons, they would be instantiated in the constructor. The validate method thus performs injector instantiation without eager singleton instantiation.
The unit test can consist of a single statement. The test passes if no exception is thrown.
// Test the main runtime configuration of the application. void InjectorConfigurationTest::mainRuntimeConfiguration() { // Configuration objects obtained at runtime. Config1 config1(); Config2 config2(); Injector::validate(config1, config2); }
The vector based validate function is also available if required.
// Test the main runtime configuration of the application. void InjectorConfigurationTest::mainRuntimeConfiguration() { std::vector<std::shared_ptr<InjectorConfiguration>> conf; conf.emplace_back(new Config1()); conf.emplace_back(new Config2()); Injector::validate(conf); }
The vector based validate function is useful because the application's final configuration can be defined in a single place, and then accessed by the main application and the validation unit test. No duplication of the configuration instantiation list is then necessary, and the unit test automatically picks up configuration instance changes without the test needing any modification.
Validation of child injector configuration requires a suitable parent injector to be supplied to the validation method. In order to avoid the eager singleton issues discussed previously, the root injector validate method returns a ValidationParent object that represents the validated parent injector for use in child injector validation calls.
// Test the child configuration. void InjectorConfigurationTest::childConfiguration() { auto parent = Injector::validate(Config1(), Config2()); Injector::validateChild(parent, Config3()); }
The validateChild method also returns a ValidationParent object, allowing deep injector hierarchies to be validated.
// Test 4 levels of child configuration. void InjectorConfigurationTest::childConfiguration() { auto parent1 = Injector::validate(Config1(), Config2()); auto parent2 = Injector::validateChild(parent1, Config3()); auto parent3 = Injector::validateChild(parent2, Config4()); Injector::validateChild(parent3, Config5(), Config6()); }
The alternative vector based validateChild method is also available if required for child injector testing.
At creation time, the injector logs the dependencies to the "balau.injector" logging namespace, and the dependency tree to the "balau.container" logging namespace. This logging can be useful for debugging dependency issues.
The logging output is set to TRACE level. In order to see one or both of these logging outputs, set the "balau.injector" and/or "balau.container" logging namespaces to log at TRACE level.
This section provides a summary of some aspects of the philosophy and design of the Balau injection framework. It is not necessary to read this section in order to use the injector.
The design of the Balau injection framework was partly influenced from experience with the Java and C# based Guice, Spring, and Unity dependency injection frameworks in enterprise software development. The Balau injector has a technical approach that reflects the more comprehensive type system available in the C++ language and standard library, and the runtime reflection limitations in C++.
Java based dependency injection frameworks work within the confines of the Java type system. The combination of dual primitive/reference types and generics type erasure has resulted in Java based injectors having an API based on a single meta-type: the Java reference.
Outside of the compiler imposed final keyword, Java references may be bound and rebound. Assigning a Java reference copies the reference "value", resulting in a new reference "value" that points to the same object. In the context of C++, the Java reference is most similar to a C++ pointer. Java dependency injection frameworks thus effectively work wholly with pointers to objects.
In C++, we have a much richer type system than in Java. We also have a responsibility for managing object lifetime that can only be partially automated via pointer containers.
Given the richer type system, a C++ dependency injection framework does not need to be limited to providing T * pointers to objects. Potentially some or all or more than the following meta-types could be supplied.
Meta-type | Description |
---|---|
T | value |
const T | const value |
T * | pointer to object |
const T * | pointer to const object |
T * const | const pointer to object |
const T * const | const pointer to const object |
T & | reference to object (final pointer) |
const T & | reference to const object |
unique_ptr<T> | uniquely owned pointer |
const unique_ptr<T> | const uniquely owned pointer to object |
unique_ptr<const T> | uniquely owned pointer to const object |
const unique_ptr<const T> | const uniquely owned pointer to const object |
shared_ptr<T> | shared ownership pointer |
const shared_ptr<T> | const shared ownership pointer to object |
shared_ptr<const T> | shared ownership pointer to const object |
const shared_ptr<const T> | const shared ownership pointer to const object |
The key questions raised during the development of the Balau injection framework were:
Some of the key requirements determined during the injector design phase were:
It rapidly became clear that the technical implications of these questions and requirements were tightly coupled. Allowing a lot of technical freedom in the technical solution to one of the questions often resulted in unacceptable limitations for the technical solutions to one or more of the other questions and requirements.
The final chosen design aims to reflect the common needs of enterprise software development with a simple API, whilst maintaining safety, testability, and minimising feature shrinkage. Good performance was a requirement, but not to the point of detriment to other requirements. Enterprise dependency injection is not a replacement for fine grained object lifetime management. The design thus reflects real world requirements for wiring enterprise C++ applications.
The table below lists the previous meta-types again, along with comments raised during the design phase with regard to binding creation.
Meta-type | Provide binding? | Comments |
---|---|---|
T | Yes | Copy elision / copy semantics of stack based new instances. |
unique_ptr<T> | Yes | Unique ownership of heap based polymorphic new instances. |
T & | Yes | Warn in the documentation that object lifetime is the responsibility of the end developer. |
const T & | Yes | Warn in the documentation that object lifetime is the responsibility of the end developer. |
shared_ptr<T> | Yes | Shared ownership of heap based polymorphic singletons and thread-local singletons. |
shared_ptr<const T> | Yes | Const singletons could be useful and their inclusion does not impact the design. |
T * | No | Raw pointers should be managed inside pointer containers. |
const T * | No | Raw pointers should be managed inside pointer containers. |
T * const | No | Raw pointers should be managed inside pointer containers. |
const T * const | No | Raw pointers should be managed inside pointer containers. |
const T | Promote | The semantics are identical to non-const new value instance provision. |
const unique_ptr<T> | Promote | The semantics are identical to non-const new polymorphic instance provision. |
unique_ptr<const T> | Promote | The semantics are identical to non-const new polymorphic instance provision. |
const unique_ptr<const T> | Promote | The semantics are identical to non-const new polymorphic instance provision. |
const shared_ptr<T> | Promote | The semantics are identical to non-const shared pointer to T. |
const shared_ptr<const T> | Promote | The semantics are identical to non-const shared pointer to const T. |
The use of T, std::unique_ptr<T>, and std::shared_ptr<T> meta-types for non-polymorphic values, polymorphic instances, and singletons respectively was natural from the outset.
A decision that was taken during the design of the injector was to raise the severity of using raw pointer bindings to a compile time error, via static assertions. Thus any bindings defined with raw pointers result in a compile time error, along with an error message that proposes using Unique or Shared bindings instead.
One consideration was whether reference bindings should be allowed, or whether the lifetime management of this would open up the risk of dangling references. The conclusion was that references should be provided, but the implications of providing references from the injector should be clearly discussed in the documentation.
Another consideration was how to implement const versions of long lived objects (i.e. const BaseT & and std::shared_ptr<const BaseT>, including provision for promoting a non-const binding to a const binding when no suitable const binding has been registered.
The final design thus provides the following types of non-const object.
Type of object | Comments |
---|---|
non-polymorphic instances T |
Non-polymorphic instances are stack based values produced from default construction, prototype copying, and provider bindings. A new instance is created on each call. |
polymorphic instances std::unique_ptr<T> |
Polymorphic instances are heap based abstract values. A new instance is created on each call. |
polymorphic references T & |
Long lived provided objects, managed by the application. The injector plays no part in lifetime management, and assumes that the referenced object specified in the configuration will live longer than the injector and the consumers of references supplied by the injector. |
polymorphic thread-local singletons std::shared_ptr<T> |
Thread-local singletons are, amongst other things, useful for tunnelling information through a call stack without the need for explicit and repeated call parameters or concurrent techniques. |
polymorphic singletons std::shared_ptr<T> |
Singletons form the basic wiring of the software application. Lazy singletons (the default) allow optional singletons to be defined in configuration but only instantiated if requested. |
In addition to non-const bindings, the injector provides the following types of const object.
Type of object | Comments |
---|---|
polymorphic references const T & |
Const version of the reference binding. |
polymorphic thread-local singletons std::shared_ptr<const T> |
Const version of the thread-local singleton binding. |
polymorphic singletons std::shared_ptr<const T> |
Const version of the singleton binding. |
Whilst certain const meta-type forms are not included in the previous list, the injector does nevertheless implement const promotion. When a binding request for a const type is not available, a suitable non-const type will be provided instead if available. This applies equally to the non-polymorphic and polymorphic new instance binding types which do not support const bindings in the configuration.
The following table details these non-const to const binding promotions.
Requested meta-type | Default provided meta-type | Promoted provided meta-type |
---|---|---|
const T | - | T |
const std::unique_ptr<T> | - | std::unique_ptr<T> |
std::unique_ptr<const T> | - | std::unique_ptr<T> |
const std::unique_ptr<const T> | - | std::unique_ptr<T> |
const T & | const T & | T & |
const std::shared_ptr<T> | - | std::shared_ptr<T> |
std::shared_ptr<const T> | std::shared_ptr<const T> | std::shared_ptr<T> |
const std::shared_ptr<const T> | std::shared_ptr<const T> | std::shared_ptr<T> |
Once an application is compiled with optimisation, each get-instance call in an injector collapses down to a lookup in the binding map and a virtual method call on the looked up binding object. The keys used in the binding map contain a type index encapsulating the meta-type and const qualifier, plus a UTF-8 string for the name. The hash thus consists of the type index hash combined with the hash of the string.
The injector's binding map is only mutated during the configuration phase of the injector instantiation. Each injector's binding map is thus const and consequently an unsynchronised hash map is used internally. The injector is thread safe and no synchronisation is used in get-instance calls.
One feature that has not been implemented in the current version of the Balau injector is compile time binding keys. The idea of building the binding keys at compile time via the get-instance typename and string literal is not achievable in a simple way in C++17.
In a get-instance call, the type argument encapsulates all the information required for the key apart from the name, i.e. binding meta-type, const qualifier, and typeid. In order to provide compile time binding keys, the compile time name would also need to be specified as a template argument.
// This is not possible in C++17. auto obj = injector->getInstance<int, "blah">();
A fully compile time key would also allow hashes to be precalculated, reducing the binding lookup to a modulus calculation and one or more equals calls on the hash map bin contents.
Whilst there are fudges and hacks to get the above kind of working in C++17, it was decided that runtime binding keys would be sufficient until C++20 is released. C++20 should allow string literal template arguments to be used, allowing the above code to be used without any hacks.
The current runtime binding key get-instance methods will remain as they are. The addition of compile time binding key get-instance methods will be implemented within an #ifdef block, allowing continued use of the library with a C++17 compiler.