Swift-y Dependency Injection, Part Two
Injecting unique, mutable and persistent property values
Update: The original approach and sample code in this post have been modified slightly — instead of every Injected instance defining its own storage for injected traits, the Injectable struct instead defines global storage for all Injected instances. This is to allow let / constant struct instances to be injected with a stored trait without needing to themselves be mutated.
My previous post about a Swift Protocol-Oriented approach to Dependency Injection had quite a positive reception and sparked some great conversations. One of the most frequent follow-up questions is how to use this approach to inject instance variables as properties, and to do so in a way such that those properties are persistent and can even be optionally modified themselves post-injection.
For example, if a Person instance is injected with a Pet that is a dog name "Rover", how do we use the injection pattern from the previous article to ensure that:
The first time the Person's pet property is accessed, a new instance of a pet dog is created with the name "Rover".
If the Person's pet dog has its name changed to "Coco", the next time the Person's pet property is accessed, it will be the same modified instance with the new name "Coco", not a brand new instance named "Rover"
Bonus: If Person is a struct / value type, and we make a copy of the Person instance and change the copy's pet name to "Bubba", the first Person instance still has their pet named "Coco"
The Key Requirement
In order to make this possible, we have to set up a way to store injected property values for each instance of an injected type (unlike the last article, which showed examples of injecting global static values identically across any number of instances).
The strategy will be similar to associated objects in Objective-C — we will set up a general method for storing arbitrary values that are associated with a specific struct or class instance, which can then also be removed from memory when their associated instance is deallocated. Unlike Objective-C associated objects however, we will be implementing this entirely in Swift, and we will be able to associate values with both classes and structs.
The protocol for any Injected type will require a unique identifier that can be used to track traits associated with an instance of the type.
Something like this:
protocol Injected {
var injectableIdentifier: InjectableIdentifier { get set }
}
So what is this InjectableIdentifier
? Well, in order to implement copy-on-write for the third requirement mentioned above, we need to have a reference type (a class) for holding the unique identifier, so we can check its reference count when we modify it. In this case, the class InjectableIdentifier
will simply define a unique hash value based on its memory address.
class InjectableIdentifier {
fileprivate var id:Int { return ObjectIdentifier(self).hashValue }
}
Next we need a hashable key type that will combine an instance's InjectableIdentifier and a trait name into a single value that can be used as a key in the global storage dictionary for a specific trait value.
private class TraitIdentifier : Hashable {
fileprivate weak var injectableIdentifier:InjectableIdentifier?
private let initialHash:Int
private let traitName:String
var hashValue: Int { return initialHash ^ traitName.hashValue }
init(injectableIdentifier:InjectableIdentifier, traitName:String) {
self.injectableIdentifier = injectableIdentifier
initialHash = injectableIdentifier.id
self.traitName = traitName
}
}
private func ==(left:TraitIdentifier, right:TraitIdentifier) -> Bool {
return left.hashValue == right.hashValue
}
Note that the injectableIdentifier
property is weak. That's because we don't want the fact that we have a trait associated with an instance in our global storage to also hold a reference to that instance's identifier. Instead, when an instance is deallocated, we want its identifier to also deallocate.
Now, we'll define a empty (no cases) enumeration to namespace the global storage for associated injected traits, and some global injection-related functions that will allow our different Injected protocols in different files to access this special trait storage:
enum Injectable {
// Global storage dictionary for all injected traits, stored by a key that associates each value with a specific instance + traitName
private static var injectedTraits = [TraitIdentifier : Any]()
// Get a trait with the supplied name associated with the supplied InjectableIdentifier
static func get<T>(traitName:String, for identifier:InjectableIdentifier) -> T? {
pruneInjectedValues()
let traitIdentifier = TraitIdentifier(injectableIdentifier: identifier, traitName: traitName)
return injectedTraits[traitIdentifier] as? T
}
// Get a trait with the supplied name associated with the supplied InjectableIdentifier. If no value for that trait name exists, set it to the supplied default and return that
static func get<T>(traitName:String, for identifier:InjectableIdentifier, orDefaultTo value:T) -> T {
pruneInjectedValues()
let traitIdentifier = TraitIdentifier(injectableIdentifier: identifier, traitName: traitName)
if (injectedTraits[traitIdentifier] as? T) == nil {
injectedTraits[traitIdentifier] = value
}
return injectedTraits[traitIdentifier] as! T
}
// Set a trait with the supplied name associated with the supplied InjectableIdentifier to the supplied value
static func set<T>(traitName:String, for identifier:inout InjectableIdentifier, to value:T) {
pruneInjectedValues()
if !isKnownUniquelyReferenced(&identifier) {
identifier = Injectable.identifier()
}
let traitIdentifier = TraitIdentifier(injectableIdentifier: identifier, traitName: traitName)
injectedTraits[traitIdentifier] = value
}
// Remove any stored values whose associated instances have been deallocated
static func pruneInjectedValues() {
injectedTraits.keys.filter{ trait in trait.injectableIdentifier == nil }.forEach{ trait in injectedTraits[trait] = nil }
}
}
That pruneInjectedValues()
function is an important detail: every time it runs it finds TraitIdentifier
s whose associated instances have been deallocated (and whose injectableIdentifier
's are therefore nil), and it removes them from the global dictionary. This prevents values from stacking up in memory once their associated instances have been deallocated. The function is called before every time a trait is retrieved or set, but if it is important to release associated values earlier, it can be called directly as needed.
Note also that we use generic return types and parameters so the compiler can infer and enforce the right values are passed in an retrieved for injected properties.
Using the 'Injected' Protocol
Now that we have create a simple protocol to handle storing injected values for associated instances, let's see how to use it with our Person and Pet example.
First, in another file we'll define a Pet type:
struct Pet {
var name:String
let species:String
}
Next, we create a PetInjected
protocol and extension. Any type that declares conformance to this protocol will automatically have a pet property added! We'll create a PetInjector to hold the default Pet instance that everyone will start with. You can control what starting pet is injected throughout the app and for different types right from this one location.
protocol PetInjected : Injected { }
struct PetInjector {
static var defaultPet = Pet(name:"Rover", species:"Dog")
}
extension PetInjected {
var pet:Pet {
get {
return Injectable.get(traitName: "pet", for: injectableIdentifier, orDefaultTo: PetInjector.defaulPet)
} mutating set {
Injectable.set(traitName: "pet", for: &injectableIdentifier, to: newValue)
}
}
}
Some things to note about the above code:
- The
PetInjected
protocol inherits from the baseInjected
protocol. This is important because it requires the InjectableIdentifier we set up to associated any instance with the stored Pet value. - The
PetInjected
protocol extension uses those special namespaced global functions we created in order to store or retrieve values associated with this instance'sInjectableIdentifier
. - The setter is marked
mutating
so it will work with both struct and class types
Lastly, we create a Person type and make it conform to PetInjected
. We simply create an identifier for any injected traits, and set up whatever other properties we want for Person. In this case, a name and a personality type:
enum PersonalityType {
case Comical
case Serious
}
struct Person: PetInjected {
var injectableIdentifier = InjectableIdentifier()
var name:String
let personality:PersonalityType
init(name:String, personality:PersonalityType) {
self.name = name
self.personality = personality
}
}
Notice that we don't have to declare a specific "pet" property here. Because Person adopts PetInjected
, any instance will automatically get a pet property injected as needed.
We can now test our requirements from the beginning of this post and confirm that yes, we have met them!
var joe = Person(name: "Joe", personality: .Comical)
print(joe.pet) // Pet(name: "Rover", species: "Dog")
joe.pet.name = "Coco"
print(joe.pet) // Pet(name: "Coco", species: "Dog")
var bob = joe
bob.pet.name = "Bubba"
print(joe.pet) // Pet(name: "Coco", species: "Dog")
print(bob.pet) // Pet(name: "Bubba", species: "Dog")
- By default, the new Person (Joe) gets the default pet — a dog named Rover
- If we change something about the injected Pet instance and then check it again later, those changes have persisted for just this instance. In this case, Joe's dog is renamed to "Coco"
- Lastly, when we create a copy of Joe called Bob, and change the name of Bob's dog to "Bubba", we see that Coco is unchanged for Joe, and each Person has a unique associated Pet.
Adding Additional Injected Properties Is Even Easier
Because all injected properties use the single storage location and identifier for their associated instance, there is no extra coding required to simply stack additional injected instance properties onto a type.
For example, let's further extend Person to have an injected "catchphrase" property. But in this case, we're going to get a little more clever about injection and inject a different default catchphrase depending on a Person's personality. This is all controlled centrally from a single injector. So there may be many possible types and instances with catchphrases, and they can all be managed from a single point for testing or future changes:
protocol CatchphraseInjected : Injected { }
extension CatchphraseInjected {
var catchphrase:String {
get {
var defaultCatchphrase = "..."
if let person = self as? Person {
switch person.personality {
case .Comical:
defaultCatchphrase = CatchphraseInjector.defaultComicalCatchphrase
case .Serious:
defaultCatchphrase = CatchphraseInjector.defaultSeriousCatchphrase
}
}
return Injectable.get(traitName: "catchphrase", for: injectableIdentifier, orDefaultTo: defaultCatchphrase)
} mutating set {
Injectable.set(traitName: "catchphrase", for: &injectableIdentifier, to: newValue)
}
}
}
struct CatchphraseInjector {
static var defaultComicalCatchphrase = "'Tis but a scratch!"
static var defaultSeriousCatchphrase = "I think, therefore I am"
}
And now we simply add one more protocol conformance to our Person type, to get the catchphrase property injected automatically:
struct Person: PetInjected, CatchphraseInjected {
var injectableIdentifier = InjectableIdentifier()
var name:String
let personality:PersonalityType
init(name:String, personality:PersonalityType) {
self.name = name
self.personality = personality
}
}
And let's see what happens when we try it out:
var jen = Person(name: "Jen", personality: .Comical)
let jess = Person(name: "Jess", personality: .Serious)
print(jen.catchphrase) // 'Tis but a scratch!
print(jess.catchphrase) // I think, therefore I am
jen.catchphrase = "Wubba Wubba Wubba"
print(jen.catchphrase) // Wubba Wubba Wubba
Perfect! We get a different default catchphrase depending on personality, but we can always override the catchphrase for any specific instance as well.
Keep in mind that you can always exclude the mutating setter in an injected protocol to prevent changes to the initial / default property value while still keeping those defaults unique per instance.
Also note that this example Injected protocol will work with both class types and struct types. However, because of how Swift handles protocols that are not limited to just class types, it's worth noting that you won't be able to modify injected properties of a class type on a let
instance of that class type! (This behavior is the same as how structs function). You can still modify non-injected properties on a let
class instance however, and you can also temporarily assign a let
instance to a var
if you do want to change injected properties.
Below is the full code for these examples, all in one place and it can be copied directly into a Playground and tested. Hope you have fun trying out this approach and as always I appreciate and questions or feedback in the comments down below!
protocol Injected {
var injectableIdentifier: InjectableIdentifier { get set }
}
class InjectableIdentifier {
fileprivate var id:Int { return ObjectIdentifier(self).hashValue }
}
private class TraitIdentifier : Hashable {
fileprivate weak var injectableIdentifier:InjectableIdentifier?
private let initialHash:Int
private let traitName:String
var hashValue: Int { return initialHash ^ traitName.hashValue }
init(injectableIdentifier:InjectableIdentifier, traitName:String) {
self.injectableIdentifier = injectableIdentifier
initialHash = injectableIdentifier.id
self.traitName = traitName
}
}
private func ==(left:TraitIdentifier, right:TraitIdentifier) -> Bool {
return left.hashValue == right.hashValue
}
enum Injectable {
// Global storage dictionary for all injected traits, stored by a key that associates each value with a specific instance + traitName
private static var injectedTraits = [TraitIdentifier : Any]()
// Get a trait with the supplied name associated with the supplied InjectableIdentifier
static func get<T>(traitName:String, for identifier:InjectableIdentifier) -> T? {
pruneInjectedValues()
let traitIdentifier = TraitIdentifier(injectableIdentifier: identifier, traitName: traitName)
return injectedTraits[traitIdentifier] as? T
}
// Get a trait with the supplied name associated with the supplied InjectableIdentifier. If no value for that trait name exists, set it to the supplied default and return that
static func get<T>(traitName:String, for identifier:InjectableIdentifier, orDefaultTo value:T) -> T {
pruneInjectedValues()
let traitIdentifier = TraitIdentifier(injectableIdentifier: identifier, traitName: traitName)
if (injectedTraits[traitIdentifier] as? T) == nil {
injectedTraits[traitIdentifier] = value
}
return injectedTraits[traitIdentifier] as! T
}
// Set a trait with the supplied name associated with the supplied InjectableIdentifier to the supplied value
static func set<T>(traitName:String, for identifier:inout InjectableIdentifier, to value:T) {
pruneInjectedValues()
if !isKnownUniquelyReferenced(&identifier) {
identifier = InjectableIdentifier()
}
let traitIdentifier = TraitIdentifier(injectableIdentifier: identifier, traitName: traitName)
injectedTraits[traitIdentifier] = value
}
// Remove any stored values whose associated instances have been deallocated
static func pruneInjectedValues() {
injectedTraits.keys.filter{ trait in trait.injectableIdentifier == nil }.forEach{ trait in injectedTraits[trait] = nil }
}
}
struct Pet {
var name:String
let species:String
}
protocol PetInjected : Injected { }
struct PetInjector {
static var defaultPet = Pet(name:"Rover", species:"Dog")
}
extension PetInjected {
var pet:Pet {
get {
return Injectable.get(traitName: "pet", for: injectableIdentifier, orDefaultTo: PetInjector.defaultPet)
} mutating set {
Injectable.set(traitName: "pet", for: &injectableIdentifier, to: newValue)
}
}
}
protocol CatchphraseInjected : Injected { }
extension CatchphraseInjected {
var catchphrase:String {
get {
var defaultCatchphrase = "..."
if let person = self as? Person {
switch person.personality {
case .Comical:
defaultCatchphrase = CatchphraseInjector.defaultComicalCatchphrase
case .Serious:
defaultCatchphrase = CatchphraseInjector.defaultSeriousCatchphrase
}
}
return Injectable.get(traitName: "catchphrase", for: injectableIdentifier, orDefaultTo: defaultCatchphrase)
} mutating set {
Injectable.set(traitName: "catchphrase", for: &injectableIdentifier, to: newValue)
}
}
}
struct CatchphraseInjector {
static var defaultComicalCatchphrase = "'Tis but a scratch!"
static var defaultSeriousCatchphrase = "I think, therefore I am"
}
enum PersonalityType {
case Comical
case Serious
}
struct Person: PetInjected, CatchphraseInjected {
var injectableIdentifier = InjectableIdentifier()
var name:String
let personality:PersonalityType
init(name:String, personality:PersonalityType) {
self.name = name
self.personality = personality
}
}
var joe = Person(name: "Joe", personality: .Comical)
print(joe.pet) // Pet(name: "Rover", species: "Dog")
joe.pet.name = "Coco"
print(joe.pet) // Pet(name: "Coco", species: "Dog")
var bob = joe
bob.pet.name = "Bubba"
print(joe.pet) // Pet(name: "Coco", species: "Dog")
print(bob.pet) // Pet(name: "Bubba", species: "Dog")
var jen = Person(name: "Jen", personality: .Comical)
let jess = Person(name: "Jess", personality: .Serious)
print(jen.catchphrase) // 'Tis but a scratch!
print(jess.catchphrase) // I think, therefore I am
jen.catchphrase = "Wubba Wubba Wubba"
print(jen.catchphrase) // Wubba Wubba Wubba
You might also be interested in these articles...