Initializers in Swift
Although Swift’s overall design incorporates several distinct programming paradigms, and makes it possible to write code using many different styles, it’s still substantially rooted in the object-oriented world.
From the way objects and values are constructed, to how inheritance and references still play a huge role in how Apple’s frameworks are designed, object-oriented concepts are a key part of Swift — even though they are often mixed with influences from other paradigms, such as functional programming.
This week, let’s take a look at one of the core aspects of object-oriented programming — initialization, which is the act of preparing an object or value for use. What characteristics should an initializer ideally have, and what sort of techniques and patterns could be useful in order to keep our initializers simple and predictable? Let’s dive right in.
The simplicity of structs
Arguably one of the most significant characteristics that we’re looking for in an initializer is simplicity. After all, the way an object or value is created often acts as our very first impression of its API — so making that process as simple and as easy to understand as possible is definitely important.
One way that Swift assists us in achieving that sort of simplicity is through the way structs work — specifically how their memberwise initializers enable us to easily create a new instance of a given type without requiring any custom logic.
For example, let’s say that our code base contains a User
type that has the following properties — some of which are required, and some of which are optional:
struct User {
let id = ID()
var name: String
var address: String?
var cityName: String?
var emailAddress: String?
}
While Swift was always capable of auto-generating initializers matching a struct’s list of properties, as of Swift 5.1, those initializers no longer require us to pass values for the properties that are optional (or that have a default value) — enabling us to create a new User
instance like this:
let user = User(id: User.ID(), name: "John")
Not only does that act as a great convenience, and helps keep our call sites clean and simple, it also serves another important function — it encourages us to keep our initializers free of logic and custom setup code.
By sticking with the initializers that the compiler generates for us, we’ll automatically keep those initializers simple and side-effect-free, as assigning the passed values to our type’s various properties is the only work that they’ll perform.
Worth noting is that memberwise initializers are only available internally within the module that each given struct is defined in. While that may at first seem like an inconvenience (or an oversight, even), the benefit is that it “forces” us to consider what we want each type’s public API to be, and to not have that API implicitly change if we modify a given type’s set of properties — which would most likely be a breaking change for our API users.
Ready from the start
Since an initializer’s main job is to prepare its instance for use, the more complete and thorough we can make that setup process, the more robust our types are likely to become. Requiring additional data or dependencies to be assigned post-init often leads to misunderstandings and unintentional results, since it’s — in most cases — fair to assume that an instance is fully ready for use once it has been initialized.
Let’s say that we’re working on an app that includes some form of audio processing, and that we’ve built an AudioProcessor
class to perform that work. Currently, that type uses the delegate pattern to enable its owner to decide whether or not a given file should actually be processed — like this:
protocol AudioProcessingDelegate: AnyObject {
func audioProcessor(_ processor: AudioProcessor,
shouldProcessFile file: File) -> Bool
}
class AudioProcessor {
weak var delegate: AudioProcessingDelegate?
func processAudioFiles(_ files: [File]) throws {
for file in files {
guard let delegate = delegate else {
// This code path should ideally never be entered
try process(file)
continue
}
let shouldProcess = delegate.audioProcessor(self,
shouldProcessFile: file
)
if shouldProcess {
try process(file)
}
}
}
...
}
While the delegate pattern is incredibly useful in many different circumstances, the way we’ve implemented it above does come with a quite significant downside. Since we can’t guarantee that a delegate has been assigned by the time the processAudioFiles
method is called, we’re forced to include a code path that causes all files to be processed whenever a delegate is missing — which will most likely lead to unintended results, at least in some cases.
Thankfully, like we took a look at in “Delegation in Swift”, using a weakly-referenced protocol isn’t the only way to implement the delegate pattern. When we’re dealing with a situation in which our delegation logic is required in order to perform an object’s work, it ideally shouldn’t be assigned post-init — but rather be a part of the initializer itself. That way, we can be 100% sure that all of our required dependencies will be available right from the start.
One way to do just that in this case is to change our protocol-based approach from before into a closure-based one instead — which also results in a much more compact implementation:
class AudioProcessor {
private let predicate: (File) -> Bool
init(predicate: @escaping (File) -> Bool) {
self.predicate = predicate
}
func processAudioFiles(_ files: [File]) throws {
for file in files where predicate(file) {
try process(file)
}
}
...
}
A simple closure might be all that we need in the above situation, but if we wanted to take things one step further, we could’ve also opted for a more powerful predicate implementation — like the one from “Predicates in Swift”.
Regardless of whether we choose a protocol, a closure, or some other form of abstraction, the goal remains the same — to be able to guarantee that our objects are as fully configured as possible by the time their initializer returns. The more we can avoid requiring any form of post-init setup, the easier to understand our types usually become.
Avoiding complexity and side effects
Another factor that often has a huge impact on our types’ ease of use is how predictable their underlying implementations are. If we can make the results of calling our APIs match user expectations — then we can avoid misunderstandings, and ultimately, bugs.
When it comes to initializers in particular, avoiding all sorts of side-effects is often key to maintaining a high degree of predictability. For example, the following Request
type’s initializer doesn’t only set up its instance, it also starts the underlying URLSessionDataTask
— which might be quite unexpected:
class Request<Value: Codable> {
private let task: URLSessionDataTask
init(url: URL,
session: URLSession = .shared,
handler: @escaping (Result<Value, Error>) -> Void) {
task = session.dataTask(with: url) {
data, response, error in
...
handler(result)
}
task.resume()
}
...
}
Whenever we have to use the word “and” when explaining what one of our initializers does, chances are high that it causes some form of side-effect. For example, the above initializer might be described as “Setting up and performing a given request” — and while that may seem completely harmless at first, it’ll most likely make our initializer way too complex, and less flexible — as we give our API users no control over when and how each request is actually performed.
Instead, let’s make our initializer only set up a Request
instance with its required property values, and then create a new, dedicated method for performing it — like this:
class Request<Value: Codable> {
private let url: URL
private let session: URLSession
private var task: URLSessionDataTask?
init(url: URL, session: URLSession = .shared) {
self.url = url
self.session = session
}
func perform(then handler: @escaping (Result<Value, Error>) -> Void) {
task?.cancel()
let task = session.dataTask(with: url) {
[weak self] data, _, error in
...
handler(result)
self?.task = nil
}
self.task = task
task.resume()
}
...
}
The above change doesn’t only give each call site the power to freely decide when to perform the requests they create, we’ve also made it possible to reuse a single Request
instance within each given context — since a new task is now created (and any previous one cancelled) each time that our perform
method is called.
Another potential source of unpredictability is when a given type’s initializer performs some form of internal setup that varies in terms of complexity and execution time. For example, the following TagIndex
type is initialized with an array of Note
values, that it then iterates over in order to index them according to their assigned tags:
struct TagIndex {
private var notes = [Tag : [Note]]()
init(notes: [Note]) {
for note in notes {
for tag in note.tags {
self.notes[tag, default: []].append(note)
}
}
}
...
}
Again, the above approach might not seem problematic — but the fact that we’re hiding an O(n)
iteration behind what looks like a constant-time initializer isn’t really a great design — since calling it with a large amount of notes within some form of critical path will most likely turn it into a bottleneck.
Just like how we before moved our Request
type’s main work out from its initializer, let’s do the same thing here, and define a separate method dedicated to performing our indexing work:
struct TagIndex {
private var notes = [Tag : [Note]]()
mutating func index(_ notes: [Note]) {
for note in notes {
for tag in note.tags {
self.notes[tag, default: []].append(note)
}
}
}
...
}
It’s a subtle change, but it’s now crystal clear that calling the index
method causes indexing to performed, rather than having that be an implicit side-effect of initializing our type.
However, just like how we before wanted to avoid requiring any post-init calls in order to make a type usable, it would be great if we could find a way to perform our indexing process in a way that’s both automatic, and predictable.
One way that we could do that is by using a static factory method, which we can name in a way that makes it obvious that calling that method doesn’t only create a new TagIndex
instance, but also indexes the notes that were passed into it:
extension TagIndex {
static func makeByIndexing(_ notes: [Note]) -> Self {
var instance = Self()
instance.index(notes)
return instance
}
}
Using static factory methods can be a great way to let us keep our initializer implementations simple, while still making it as easy as possible to create and configure new instances of our types.
Conclusion
By keeping our initializers free of side-effects, and by making the process of configuring our types as simple and predictable as possible, we not only make our types easier to understand, but also give them a much greater degree of flexibility.
However, if we make our initializers too simple, that can sometimes result in quite ambiguous setups that require a fair amount of post-init configuration — which isn’t ideal. Like with so many other things, writing great initializers becomes a balancing act between simplicity and completeness — and by using techniques like closures and static factory methods, we can often get closer to achieving that perfect balance.
What do you think? How do you like your initializers to work, and what kind of principles do you tend to follow when configuring your objects and values? Let me know — along with any questions, comments and feedback that you might have — either via Twitter or email.
Thanks for reading! 🚀