Safer singletons with property wrappers
Tip22 November 2020
Singleton in programming are a common way to share an object across the overall code base. If they should be used with caution, there are sometimes a nice solution to share a single source of truth. In this article, we will take a look at two use cases where singleton are - according to me - a perfect fit if we update the concept with property wrappers.
Why use singletons?
Most often, it is not recommended to share an object which state can be updated from several places. This leads to many problems and the cost of resolving them can become greater than the benefit of easily access the shared object.
For example, the shared object might have a property that any other object can modify. In this case, this property should be hidden behind a private queue in case two objects read or write this property on different threads.
In this article, we will focus on singletons serving as a single source of truth: a singleton will only be used to share properties which cannot be mutated by other objects. In the last part, we will see how to extend the concept to also wrap an object (here a NSManagedObjectContext
) and expose its functions behind a protocol.
Sharing data
Let's take an example with an app which purpose is to edit shared files. For each file, the user can have three types of rights: owner, writer and reader. As you might imagine, those rights will be used to prevent the user from accessing to certain features to edit the file. This value can initially be retrieved by fetching an API, or by decoding a configuration file. The thing is: once it is retrieved, it should not be possible to update its value.
Also, a property lastModificationDate
will be similarly retrieved to inform about when the file was last modified.
Let's think about it. It would be useful to be able to let any object know what are the user rights on the file. For example, an EditorViewController
might disable some of the buttons on a view to prevent a modification. Also, a navigation bar would use this information to display the rights on the file. The same goes for the lastModificationDate
property. It could be used to send the last modifications on the file to an API, and be displayed in an ExportViewController
.
Thus, it appears that in this case, a singleton could be a good fit. Let's try to implement it.
enum Rights {
case owner, writer, reader
}
final class FileConfiguration {
static let shared = FileConfiguration()
private(set) var rights = Rights.reader
private(set) var lastModificationDate: Date?
private init() {}
static func setup(configurationURL: URL, sender: AppDelegate) {
// here the `shared` properties are updated
}
}
Some remarks
- The
init
is private as for any singleton to prevent another piece of code to instantiate aFileConfiguration
. - The properties are marked as
private(set)
to not be modified outside the scope of the class - The function
setup(configurationURL:sender:)
should be called at the launch of the app and should update theshared
properties with the ones in the configuration at the given URL. This call should not be made by another object but theAppDelegate
. Hence thesender
parameter to ensure no other object can make this call.
Here is how this FileConfiguration
implementation could be used in an EditorViewController
final class EditorViewController: UIViewController {
let editButton = UIButton()
func setupEditButton() {
editButton.isEnabled = [.owner, .writer].contains(FileConfiguration.shared.rights)
}
}
Having this kind of information is in one single place is great since it will allow us to not duplicate it. If we had to pass the rights
property on each object, we might want to update in somewhere thinking that it would be updated in the rest of the app.
But soon enough a problem arises when we want to test. For instance, how can we test that the button is well disabled depending on the rights? And if we want to show an alert to the user when they have only the reader
rights? There is now way yet we can inject a mock value inside FileConfiguration.shared.rights
.
A simple solution would be to remove the private(set)
mark on the rights
property. But this would be careless since it would allow any object to make a modification, which could change the behavior of other objects depending on this value.
Another - safer - solution would be to use the FileConfiguration.shared
as a default parameter.
final class EditorViewController: UIViewController {
let editButton = UIButton()
func setupEditButton(rights: Rights = FileConfiguration.shared.rights) {
editButton.isEnabled = [.owner, .writer].contains(rights)
}
}
This is fine but... Should we do that for each function that needs to access
FileConfiguration.shared
? 😅 As we, developers, are lazy by nature, an improved solution would be to store a reference to the
shared
instance.
final class EditorViewController: UIViewController {
var fileConfiguration: FileConfiguration
var editButton = UIButton()
init(fileConfiguration: FileConfiguration = .shared) {
self.fileConfiguration = fileConfiguration
super.init()
}
func setupEditButton() {
editButton.isEnabled = [.owner, .writer].contains(fileConfiguration.rights)
}
}
And... the compiler will complain that we are not calling the designated initialiser of
UIViewController
and that we have to implement the
required init?(coder: NSCoder)
. 🙈
NSObject
s do not make the life of developers easier to implement a custom
init
!
So here is how this could be done.
final class EditorViewController: UIViewController {
var fileConfiguration: FileConfiguration
var editButton = UIButton()
init(fileConfiguration: FileConfiguration = .shared) {
self.fileConfiguration = fileConfiguration
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setupEditButton() {
editButton.isEnabled = [.owner, .writer].contains(fileConfiguration.rights)
}
}
And suddenly, we realise that we once again cannot inject a mocked
FileConfiguration
. It's even worse than before because we have now to inject a
FileConfiguration
although the class
init
is private. 😢
In a desperate last attempt, we could write a FileConfigurationProtocol
and make FileConfiguration
conform to it. We would then have to declare each property we want to access in the protocol, and then provide a mock object that has all those properties, and... Stop! We said we were lazy, right? Surely, we do not want to do that. Also, such an implementation would be hardly maintainable.
Thus, as we face our code, dejected by the fact that we cannot easily inject mock singletons, an untold point appears in the sky! Is it a bird? Is it a plane? No, it's a property wrapper!
A property wrapper comes to rescue
The object we will use as a property wrapper will be a class named FileConfig
. It will take a KeyPath
to specify the value to be accessed. Then, to access to this value in the shared FileConfiguration
, an object will declare a property with this property wrapper: @FileConfig(\.property)
.
To enforce that, FileConfiguration.shared
will be marked as fileprivate
and the class FileConfig
will be declared in the same file.
final class FileConfiguration {
fileprivate static let shared = FileConfiguration()
private(set) var rights = Rights.reader
private(set) var lastModificationDate: Date?
private init() {}
static func setup(configurationURL: URL, sender: AppDelegate) {
// here the `shared` properties are updated
}
}
@propertyWrapper final class FileConfig<Value> {
let keyPath: KeyPath<FileConfiguration, Value>
var wrappedValue: Value {
FileConfiguration.shared[keyPath: keyPath]
}
init(_ keyPath: KeyPath<FileConfiguration, Value>) {
self.keyPath = keyPath
}
}
We can now modify
EditorViewController
.
final class EditorViewController: UIViewController {
@FileConfig(\.rights) var rights
var editButton = UIButton()
func setupEditButton() {
editButton.isEnabled = [.owner, .writer].contains(rights)
}
}
It seems nicer, don't you think? But wait! We cannot inject dependency here. Let's implement that now.
This syntax might reminds you the environment
from SwiftUI which is where this idea comes from. But as a side note, I do not think the environment
feature uses a singleton.
Dependency injection
To allow dependency injection, we will offer the possibility to override the returned value. This property will be settable only when accessing the projected value of the property wrapper. If you are accustomed to SwiftUI, you know that a projected value is specified by a dollar $
sign on the property name and means that it's the property wrapper itself that should be accessed rather than the wrapped property.
@propertyWrapper final class FileConfig<Value> {
/// To be set to override the value given by the configuration
/// when performing dependency injection
var overrideValue: Value?
let keyPath: KeyPath<FileConfiguration, Value>
var wrappedValue: Value {
overrideValue ?? FileConfiguration.shared[keyPath: keyPath]
}
var projectedValue: FileConfig { self }
init(_ keyPath: KeyPath<FileConfiguration, Value>) {
self.keyPath = keyPath
}
}
The comment for the
wrappedValue
makes it clear that it should be used for dependency injection only. And even if this property was set in an instance of an
EditorViewController
, it would only have an effect in the class scope.
Now, if we want to inject a mock
rights
value to test
EditorViewController
, we can do:
func testRights() {
let editorVC = EditorViewController()
editorVC.$rights.overrideValue = .owner
// perform the test
}
Perfect! Thanks property wrapper!
Extending to functions
I found the same concept was interesting when applied to functions. So far, we only used it for properties. To give a real life example, let's use a NSManagedObjectContext
with Core Data.
To commit the modifications the user made on the data, it's needed to call the save()
function on this context. If it is possible to perform this save only when the app enters in background, I think that many developers prefer to save the context at specific points rather than relying on the app life cycle.
To be able to save the context across the app, we have to share it. And I guess you are getting it. We will use a property wrapper to do that 😏
A common way to setup a context is to use a CoreDataStack
as explained in this tutorial. The goal is to have a single object responsible for setting up and holding the view context. Unfortunately, to access the stack viewContext
, it is required to declare and hold a CoreDataStack
in each object.
To improve that, we can have our stack declared as private
in the same file as our property wrapper. Also, rather than exposing the overall NSManagedObjectContext
, we will declare a protocol with the functions we want to make accessible.
final class CoreDataStack {
// container initialisation
var viewContext: NSManagedObjectContext { container.viewContext }
}
private var stack = CoreDataStack(modelName: "Model")
protocol Context {
func save() throws
func rollback()
}
extension NSManagedObjectContext: Context {}
@propertyWrapper final class ViewContext {
var overrideValue: Context?
var wrappedValue: { overrideValue ?? stack.viewContext }
var projectedValue: ViewContext { self }
}
To use the context in the EditorViewController
, we could do:
final class EditorViewController: UIViewController {
@ViewContext var context
func save() {
do {
try context.save()
} catch {
// fallback
}
}
}
And then to inject a mock context:
struct MockContext: Context {
func save() throws {}
func rollback() {}
}
// ...
editorVC.$context.overrideValue = MockContext()
Going further
In case several singletons have to be shared, an interesting solution would be to make a more generic property wrapper SingletonWrapper
.
protocol Singleton {
static var shared: Self { get }
}
@propertyWrapper final class SingletonWrapper<Root: Singleton, Value> {
/// To be set to override the value given by the configuration
/// when performing dependency injection
var overrideValue: Value?
let keyPath: KeyPath<Root, Value>
var wrappedValue: Value {
overrideValue ?? Root.shared[keyPath: keyPath]
}
var projectedValue: SingletonWrapper { self }
init(_ keyPath: KeyPath<Root, Value>) {
self.keyPath = keyPath
}
}
Then for each singleton:
extension FileConfiguration: Singleton {}
typealias FileConfig<Value> = SingletonWrapper<FileConfiguration, Value>