on
Swift parameter packs - Implementing a lightweight dependency injection
Dependency injection is key to app development as it enables developers to benefit from modularity throughout the codebase while facilitating unit testing.
Although there are many good libraries such as Swinject or Resolver, a tailored lightweight dependency injection mechanism can often be sufficient to register and resolve services.
The first part of this article will be dedicated to the implementation of a lightweight dependency injection system which will then be improved using the latest Swift feature called parameter packs.
— Building a dependency injection system
To implement a lightweight dependency injection system, we need to focus on two main functions : register and resolve.
These functions are self-explanatory, one to register a service, the other to resolve the service at callsite.
To start with, let’s create our injector singleton object which will store our registrable services.
public final class Injector {
static private(set) public var shared = Injector()
private var services: [ServiceKey: ServiceEntryProtocol] = [:]
// MARK: - Object lifecycle
private init() {}
}
The services
property provides a basic key-value storage where ServiceKey
identifies the service we need to retrieve and ServiceEntryProtocol
holds the service initialization logic through a factory property.
struct ServiceKey {
let serviceType: Any.Type
let argumentsType: Any.Type
}
extension ServiceKey: Hashable {
public func hash(into hasher: inout Hasher) {
ObjectIdentifier(serviceType).hash(into: &hasher)
ObjectIdentifier(argumentsType).hash(into: &hasher)
}
static func == (lhs: ServiceKey, rhs: ServiceKey) -> Bool {
return lhs.serviceType == rhs.serviceType && lhs.argumentsType == rhs.argumentsType
}
}
typealias FunctionType = Any
protocol ServiceEntryProtocol: AnyObject {
var factory: FunctionType { get }
var serviceType: Any.Type { get }
}
public final class ServiceEntry<Service>: ServiceEntryProtocol {
let serviceType: Any.Type
let argumentsType: Any.Type
let factory: FunctionType
init(serviceType: Service.Type, argumentsType: Any.Type, factory: FunctionType) {
self.serviceType = serviceType
self.argumentsType = argumentsType
self.factory = factory
}
}
ServiceEntry
(which conforms to ServiceEntryProtocol
) store three essential informations to our mechanism:
- The service type
- The arguments type in the service initializer
- The factory (closure) to initialize the service
Our register
function is now able to construct a ServiceKey
and a ServiceEntry
.
public final class Injector {
static private(set) public var shared = Injector()
private var services: [ServiceKey: ServiceEntryProtocol] = [:]
private init() {}
@discardableResult
public func register<Service, Arg1>(_ serviceType: Service.Type, factory: @escaping (Arg1) -> Service) -> ServiceEntry<Service> {
return _register(serviceType, factory: factory)
}
fileprivate func _register<Service, Arguments>(_ serviceType: Service.Type, factory: @escaping (Arguments) -> Any) -> ServiceEntry<Service> {
let key = ServiceKey(serviceType: Service.self, argumentsType: Arguments.self)
let entry = ServiceEntry(
serviceType: serviceType,
argumentsType: Arguments.self,
factory: factory
)
services[key] = entry
return entry
}
}
Before moving forward to the resolve
function implementation, let’s make sure we can actually register a service.
For the purpose of this article, we’ll be creating a ViewModel
class that holds an unowned reference to a view controller.
class ViewModel {
unowned let viewController: ViewController
init(viewController: ViewController) {
self.viewController = viewController
}
}
Registering our ViewModel
is pretty straightforward as we simply need to call the dedicated function.
Injector.shared.register(ViewModel.self) { viewController in
ViewModel(viewController: viewController)
}
Compiler is able to infer the argument type since we’re basically passing the init of our object in the closure, here’s another way of writing it:
Injector.shared.register(ViewModel.self, factory: ViewModel.init)
Now that we’ve registered our ViewModel
service, we need to implement our resolve
function so we can actually use it.
resolve
asks for a service and an injectable argument.
public func resolve<Service, Arg1>(argument: Arg1) -> Service {
typealias FactoryType = ((Arg1)) -> Any
return _genericResolve(serviceType: Service.self) { (factory: FactoryType) in factory((argument)) }
}
To resolve our service, we first need to retrieve the entry from the services
key-value storage using a ServiceKey
. Service type and arguments types must match.
private func _genericResolve<Service, Arguments>(serviceType: Service.Type, invoker: @escaping ((Arguments) -> Any) -> Any) -> Service {
var resolvedInstance: Service?
var type: Any.Type
type = Service.self
let key = ServiceKey(serviceType: type, argumentsType: Arguments.self)
if let entry = services[key] {
resolvedInstance = resolve(entry: entry, invoker: invoker)
}
if let resolvedInstance = resolvedInstance {
return resolvedInstance
} else {
fatalError("You need to register concrete type for \(Service.self)")
}
}
If we do have a match, we’ll be able to invoke the closure and trigger the initialization of our service.
fileprivate func resolve<Service, Factory>(entry: ServiceEntryProtocol, invoker: (Factory) -> Any) -> Service? {
let resolvedInstance = invoker(entry.factory as! Factory)
return resolvedInstance as? Service
}
Assuming we already did the registration, let’s now try to resolve our ViewModel
service.
class ViewController: UIViewController {
var viewModel: ViewModel!
override func viewDidLoad() {
super.viewDidLoad()
viewModel = Injector.shared.resolve(argument: self)
}
}
Unfortunately, this code will result in a crash.
Fatal error: You need to register concrete type for Optional<ViewModel>
It does actually make sense as we registered a ViewModel
service and we’re trying to resolve an Optional<ViewModel>
service (which are both different).
Don’t forget that service type and arguments must match.
We need to tackle this issue as we may have optional services that we would still want to resolve whether the type if optional or not.
To properly resolve our service even it is of Optional
type, we need to retrieve the Wrapped
value and try to resolve it instead of resolving an Optional
service (which doesn’t make sense anyways)
protocol OptionalProtocol {
static var wrappedType: Any.Type { get }
}
extension Optional: OptionalProtocol {
public static var wrappedType: Any.Type {
Wrapped.self
}
}
Now, inside the _genericResolve
function, we can add the following code (right before the key
constant) to safely unwrap an optional service (if any) and access the wrapped service.
if let optionalType = Service.self as? OptionalProtocol.Type {
type = optionalType.wrappedType
} else {
type = Service.self
}
So, our function now looks complete.
private func _genericResolve<Service, Arguments>(serviceType: Service.Type, invoker: @escaping ((Arguments) -> Any) -> Any) -> Service {
var resolvedInstance: Service?
var type: Any.Type
if let optionalType = Service.self as? OptionalProtocol.Type {
type = optionalType.wrappedType
} else {
type = Service.self
}
let key = ServiceKey(serviceType: type, argumentsType: Arguments.self)
if let entry = services[key] {
resolvedInstance = resolve(entry: entry, invoker: invoker)
}
if let resolvedInstance = resolvedInstance {
return resolvedInstance
} else {
fatalError("You need to register concrete type for \(Service.self)")
}
}
ViewModel
instance can now be correctly resolved.
viewModel = Injector.shared.resolve(argument: self)
While above implementation works fine, our lightweight dependency injection system currently only supports injection for services with a single argument.
To cover most cases, we most likely need to add more resolve and register functions with multiple arguments.
Let’s add the functions so we can support multiple arguments when registering our services.
@discardableResult
public func register<Service, Arg1, Arg2, Arg3, Arg4, Arg5>(_ serviceType: Service.Type, factory: @escaping (Arg1, Arg2, Arg3, Arg4, Arg5) -> Service) -> ServiceEntry<Service> {
return _register(serviceType, factory: factory)
}
public func resolve<Service, Arg1, Arg2, Arg3, Arg4, Arg5>(arguments arg1: Arg1, _ arg2: Arg2, _ arg3: Arg3, _ arg4: Arg4, _ arg5: Arg5) -> Service {
typealias FactoryType = ((Arg1, Arg2, Arg3, Arg4, Arg5)) -> Any
return _genericResolve(serviceType: Service.self) { (factory: FactoryType) in factory((arg1, arg2, arg3, arg4, arg5)) }
}
Better but not optimal yet as it forces us to duplicate our register
and resolve
public functions to support as many arguments as we need.
Let’s improve this.
- Meet parameter packs
Parameter packs are variadic generics
that can support multiple types (vs. a single type before) so instead of creating multiple register and resolve functions with different generic types as arguments, we’ll be using only one resolve
and register
function to manage our services.
Before starting, please note that this is an experimental feature that required the following flag in a Swift package :
swiftSettings: [.enableExperimentalFeature("VariadicGenerics")])
A first adjustment we need to make is to remove the ServiceEntryProtocol
as variadic generics are not yet supported with protocols (on Xcode beta 2).
Our protocol must be replaced by Any
(not ideal but the only solution for now).
public final class Injector {
static private(set) public var shared = Injector()
private var services: [ServiceKey: Any] = [:]
// MARK: - Object lifecycle
private init() {}
}
Now the ServiceEntry
class needs some adjustments to use parameter packs.
The repeat each
syntax is used to declare variadic generics.
typealias FunctionType<each Arguments> = (repeat each Arguments) -> Any
public final class ServiceEntry<each Arguments> {
let argumentsType: Any.Type
let factory: FunctionType<repeat each Arguments>
// MARK: - Object lifecycle
init(argumentsType: Any.Type, factory: @escaping FunctionType<repeat each Arguments>) {
self.argumentsType = argumentsType
self.factory = factory
}
}
The register
methods also needs some tweaks.
public final class Injector {
static private(set) public var shared = Injector()
private var services: [ServiceKey: Any] = [:]
// MARK: - Object lifecycle
private init() {}
// MARK: - Register
@discardableResult
public func register<Service, each Argument>(
_ serviceType: Service.Type,
factory: @escaping (repeat each Argument) -> Service
) -> ServiceEntry<repeat each Argument> {
_register(serviceType, factory: factory)
}
fileprivate func _register<Service, each Arguments>(
_ serviceType: Service.Type,
factory: @escaping (repeat each Arguments) -> Any
) -> ServiceEntry<repeat each Arguments> {
let argumentsType = Mirror(reflecting: (repeat (each Arguments).self)).subjectType
let key = ServiceKey(serviceType: Service.self, argumentsType: argumentsType)
let entry = ServiceEntry(
argumentsType: argumentsType,
factory: factory
)
services[key] = entry
return entry
}
}
The public func register<Service, each Argument>
will allow us to pass multiple arguments (types can be different) when registering our services.
In other words, each Argument
allows our register
function to accept one, two or more arguments with different types.
Let’s move on to the resolve
methods, one major difference here is that we need to type cast our dictionary value to ServiceEntry
(since we had to remove ServiceEntryProtocol
).
Hopefully protocol using parameter packs will be supported in the future.
// MARK: - Resolve
public func resolve<Service, each Arguments>(arguments: repeat each Arguments) -> Service {
_genericResolve(serviceType: Service.self) { factory in
factory(repeat each arguments)
}
}
private func _genericResolve<Service, each Arguments>(
serviceType: Service.Type,
invoker: @escaping ((repeat each Arguments) -> Any) -> Any
) -> Service {
var type: Any.Type
if let optionalType = Service.self as? OptionalProtocol.Type {
type = optionalType.wrappedType
} else {
type = Service.self
}
let argumentsType = Mirror(reflecting: (repeat (each Arguments).self)).subjectType
let key = ServiceKey(serviceType: type, argumentsType: argumentsType)
if let entry = services[key] as? ServiceEntry<repeat each Arguments>,
let resolvedInstance: Service = resolve(entry: entry, invoker: invoker) {
return resolvedInstance
} else {
fatalError("You need to register concrete type for \(Service.self)")
}
}
fileprivate func resolve<Service, each Arguments>(
entry: ServiceEntry<repeat each Arguments>,
invoker: @escaping ((repeat each Arguments) -> Any) -> Any
) -> Service? {
let resolvedInstance = invoker(entry.factory)
return resolvedInstance as? Service
}
Finally, let’s register and resolve our ViewModel
using parameter packs
.
class ViewController: UIViewController {
var viewModel: ViewModel!
override func viewDidLoad() {
super.viewDidLoad()
Injector.shared.register(ViewModel.self) { viewController, isActive, randomNumber in
ViewModel(viewController: viewController, isActive: isActive, randomNumber: randomNumber)
}
viewModel = Injector.shared.resolve(arguments: self, Bool.random(), Int.random(in: 1...4))
}
}
Please note that if the number of arguments (or their types) is not correct when resolving the services, app will crash making sure everything is properly done.
Our dependency injection system now provides a rather simple, intuitive mechanism using the latest Swift feature, to register and resolve services on the fly without the cumbersomeness of a third-party library.
Thanks for reading!