Baby birds, rockets, freshly roasted coffee beans, and … immutable objects. What do all these things have in common? I love them.
An immutable object is one that cannot change after it is initialised. It has no variable properties. This means that when using it in a program, my pea brain does not have to reason about the state of the object. It either exists, fully ready to complete its assigned duties, or it does not.
Asynchronous programming presents a challenge to immutable objects. If the creation of an object requires network I/O, then we will have to unblock execution after we have decided to create the object.
As an example, let’s consider the Transaction
class inside Amatino Swift. Amatino is a double entry accounting API, and Amatino Swift allows macOS & iOS developers to build finance capabilities into their applications.
To allow developers to build rich user-interfaces, it is critical that Transaction
operations be smoothly asynchronous. We can’t block rendering the interface while the Amatino API responds! To lower the cognitive load demanded by Amatino Swift, Transaction should be immutable.
We’ll use a simplified version of Transaction
that only contains two properties: transactionTime
and description
. Let’s build it out from a simple synchronous case, to a full fledged asynchronous case.
class Transaction {
let description: String
let transactionTime: Date
init(description: String, transactionTime: Date) {
self.description = description
self.transactionTime = transactionTime
}
}
So far, so obvious. We can instantly initialise Transaction
. In real life, Transaction
is not initialised with piecemeal values, it is initialised from decoded JSON data received from an HTTP request. That JSON might look like this:
{
"transaction_time": "2008-08",
"description": "short Lehman Bros. stock"
}
And we can decode that JSON into our Transaction
class like so:
/* Part of Transaction definition */
enum JSONObjectKeys: String, CodingKey {
case txTime = "transaction_time"
case description = "description"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(
keyedBy: JSONObjectKeys.self
)
description = try container.decode(
String.self,
forKey: .description
)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM" //...
let rawTime = try container.decode(
String.self,
forKey: .txTime
)
guard let txTime: Date = dateFormatter.date(
from: rawTime
) else {
throw Error
}
transactionTime = txTime
return
}
Whoah! What just happened! We decoded a JSON object into an immutable Swift object. Nice! That was intense, so lets take a breather and look at a cute baby bird:
Break time is over! Back to it: Suppose at some point in our application, we want to create an instance of Transaction
. Perhaps a user has tapped ‘save’ in an interface. Because the Amatino API is going to (depending on geography) take ~50ms to respond, we need to perform an asynchronous initialisation.
We can do this by giving our Transaction
class a static method, like this one:
/* Part of Transaction definition */
static func create(
description: String,
transactionTime: Date,
callback: @escaping (Error?, Transaction?) -> Void
) throws {
/* dummyHTTP() stands in for whatever HTTP request
machinery you use to make an HTTP request. */
dummyHTTP() { (data: Data?, error: Error?) in
guard error == nil else {
callback(error, nil)
return
}
guard dataToDecode: Data = data else {
callback(Error(), nil)
return
}
let transaction: Transaction
guard transaction = JSONDecoder().decode(
Transaction.self,
from: dateToDecode
) else {
callback(Error(), nil)
return
}
callback(nil, transaction)
return
}
}
This new Transaction.create()
method follows these steps:
Accepts the parameters of the new transaction, and a function to be called once that transaction is available, the
callback(Error?:Transaction?)
. Because something might go wrong, this function might receive an error, (Error?
) or it might receive a transaction (Transaction?
)Makes an HTTP request, receiving optional Data and Error in return, which are used in a closure. In this example,
dummyHTTP()
stands in for whatever machinery you use to make your HTTP requests. For example, check out Apple’s guide to making HTTP requests in SwiftLooks for the presence of an error, or the absence of data and, if they are found, calls back with those errors: callback(error, nil)
Attempts to decode a new instance of
Transaction
and, if successful, calls back with that transaction:callback(nil, transaction)
We can use the .create()
static method like so:
Transaction.create(
description: "Stash sweet loot",
transactionTime: Date(),
callback: { (error, transaction) in
// Guard against errors, then do cool stuff
// with the transaction
})
The end result? An immutable object. We don’t have to reason about whether or not it is fully initialised, it either exists or it does not. Consider an alternative, wherein the Transaction
class tracks internal state:
class Transaction {
var HTTPRequestInProgress: bool
var hadError: Bool? = nil
var description: String? = nil
var transactionTime: Date? = nil
init(
description: String,
transactionTime: Date,
callback: (Error?, Transaction?) -> Void
) {
HTTPRequestInProgress = true
dummyHTTP() { data: Data?, error: Error? in
/* Look for errors, try decoding, set
`hadError` as appropriate */
HTTPRequestInProgress = false
callback(nil, self)
return
}
}
}
Now we must reason about all sorts of new possibilities. Are we trying to utilise a Transaction
that is not yet ready? Have we guarded against nil when utilising a Transaction
that is ostensibly ready? Down this path lies a jumble of guard
statements, if-else
clauses, and sad baby birdies.
Don’t make the baby birdies sad, asynchronously initialise immutable objects! 💕
Further Reading
- The full source code for the Amatino Swift
Transaction
class, which is immutable and makes heavy use of asynchronous operations - Apple’s guide to making asynchronous HTTP requests in Swift
- Apple’s guide to encoding & decoding JSON data in Swift
– Hugh
Originally posted on hughjeremy.com
Top comments (0)