Default arguments in Swift
A major part of designing powerful, flexible APIs that still feel lightweight and easy to use, is deciding what aspects to make user-configurable. On one hand, the more configuration options that we add, the more versatile an API becomes — but on the other hand, it might also make it more complicated and harder to understand.
That’s what makes default arguments such a great tool in Swift — as they let us add solid, intuitive defaults to many of the configuration options that we’ll end up providing. That way we can often strike a nice balance between flexibility and ease of use, and this week, let’s take a look at a few examples of how we might do just that.
Making the easy path the right path
One of the key ways that a project can be made more maintainable (especially as it grows in size, both code-wise and team-wise), is by ensuring that the easiest way to accomplish a certain task is also the right way to do it. It’s so common for highly similar functionality to be accidentally re-implemented multiple times by different developers, simply because there was no simple, shared abstraction available to use.
For example, let’s say that we wanted to make an effort to unify the durations used for various animations across an app. To make that happen, our goal needs to be to create a simpler API than what the system provides out of the box, so that all members of our team (including ourselves) will continuously choose to use that new API over the default one. In this case, we might build something like this:
extension UIResponder {
// Here we use a default argument to define what we want
// our unified, default animation duration to be:
func animate(withDuration duration: TimeInterval = 0.3,
animations: @escaping () -> Void) {
UIView.animate(withDuration: duration,
animations: animations)
}
}
We scope the above function to UIResponder
(which both UIView
and UIViewController
are subclasses of), to avoid getting it as an autocompletion suggestion within non-UI contexts.
With the above in place, most animation code will — over time— most likely end up simply looking like this:
animate {
button.frame.size = CGSize(width: 100, height: 100)
}
That’s great, both for readability, and since we’ll now have a single source of truth for the duration of all of our default animations. Equally important, however, is that our new default can easily be overridden — simply by specifying a value for that argument at the call site:
animate(withDuration: 2) {
button.frame.size = CGSize(width: 100, height: 100)
}
Besides providing a great way to standardize various values across a code base, default arguments can also enable us to design more scalable APIs — by making increasingly complex use cases and customization possible, without requiring all API users to take on that added complexity.
For example, here’s how we might extend our simplified animation API to support a much larger set of parameters — while still keeping the default use case as simple as possible:
extension UIResponder {
func animate(withDuration duration: TimeInterval = 0.3,
delay: TimeInterval = 0,
options: UIView.AnimationOptions = .curveEaseInOut,
animations: @escaping () -> Void) {
UIView.animate(withDuration: duration,
delay: delay,
options: options,
animations: animations)
}
}
That’s really what makes default arguments so incredibly useful — they let us keep expanding our APIs to make them increasingly more powerful and flexible, in a way that won’t impact any of our code that doesn’t need to take advantage of those new capabilities.
The importance of being obvious
However, when deciding what values to turn into defaults, it’s always important to consider whether a given default will end up becoming intuitive to our API users. After all, the best kind of defaults are those that feel obvious, since it’ll help us avoid misunderstandings and bugs caused by an API doing something that we didn’t expect.
For example, let’s say that we’ve written a function for storing a given value in a local database, and that we enable the API user to decide how to handle conflicts — whenever a similar value already exists within that database. In an attempt to make our API as simple as possible, we’ve again specified a default argument — like this:
enum ConflictResolution {
case overwriteExisting
case stopIfExisting
case askUser
}
func store<T: Storable>(
_ value: T,
conflictResolution: ConflictResolution = .stopIfExisting
) throws {
...
}
Doing the above may at first seem like a good idea, but if we think about it, it’s not really obvious that calling our function without explicitly specifying a ConflictResolution
will result in no value being stored if our database already contains an existing one. By simply calling try store(value)
, we’d expect a value to actually be stored, but at the same time — we wouldn’t want to make overwriteExisting
the default either, as that could result in unexpected data loss.
In this type of situation, when there’s really no obvious default to be found, it might be better to simply define a separate function if we want to provide some form of convenience API. For example, here’s how we might create a storeIfNeeded
function to be able to easily store a value only if a similar one doesn’t already exist:
func storeIfNeeded<T: Storable>(_ value: T) throws {
try store(value, conflictResolution: .stopIfExisting)
}
So while default arguments are really useful in many kinds of situations, they’re not the only way to define convenience APIs, and like with so many other things — it all comes down to picking the right tool for the job within each given context.
Retrofitted dependency injection
Default arguments can also provide a great way to retrofit a given type or function with dependency injection. Like we’ve taken a look at in previous articles — injecting our code-level dependencies, rather than relying on singletons, is often key to writing more well-structured and testable code. However, completely refactoring a code base to introduce dependency injection everywhere can be a huge task — but thankfully, default arguments can enable us to perform those changes in a step-by-step fashion.
Let’s say that our code base makes heavy use of a FileLoader
class — that currently accesses its underlying FileManager
, as well as a globally shared Cache
, as singletons. That does have some benefits, as it lets us simply initialize a FileLoader
from anywhere, without having to worry about its dependencies. However, it also makes it much harder to both unit test that class, and to get a clear overview of what kind of dependencies that it has.
The good news is that by simply transforming the way we access those singletons to instead become default initializer arguments, we can both improve our type’s structure, and also make it a lot more testable — like this:
class FileLoader {
private let fileManager: FileManager
private let cache: Cache
init(fileManager: FileManager = .default,
cache: Cache = .shared) {
self.fileManager = fileManager
self.cache = cache
}
}
Since we’ve now parameterized all of our file loader’s dependencies, they can much more easily be mocked or stubbed within our tests. For example, here’s how we might replace our app’s default Cache
with one that automatically empties itself each time that our tests are run:
let loader = FileLoader(cache: .autoEmptyingForTests)
For more info on unit testing, check out this Basics article, as well as this site’s many other articles and podcast episodes on that topic.
Associated values within enums
Finally, let’s take a look at a new “flavor” of default arguments that was added as part of Swift 5.1 — default associated values for enum cases.
Let’s say that we’re building a Swift library for creating XML documents. Since XML is a tree-like format in which all data is defined using nodes that come in a finite number of variations, we might choose to model each such node using an XMLNode
enum that looks like this:
enum XMLNode {
// A standard element, which can contain child elements:
case element(
name: String,
attributes: [Attribute],
children: [XMLNode]
)
// A "void" element that closes itself, and can't have children:
case voidElement(
name: String,
attributes: [Attribute]
)
// An inline piece of text, defined as a child node:
case text(String)
}
Before Swift 5.1, using the above kind of approach did come with a quite major tradeoff, in that no default arguments could be defined. So even if we just wanted to create an empty (but still non-void) element, we’d still have to pass all associated values for that case:
let emptyItems = XMLNode.element(
name: "items",
attributes: [],
children: []
)
While we could’ve extended XMLNode
with convenience APIs that would’ve filled in those empty defaults for us, we no longer have to — as we’re now able to define default arguments for associated enum values as well, exactly the same way as we do for function arguments:
enum XMLNode {
case element(
name: String,
attributes: [Attribute] = [],
children: [XMLNode] = []
)
case voidElement(
name: String,
attributes: [Attribute] = []
)
case text(String)
}
With the above change in place, our XMLNode
API immediately becomes a lot more flexible — as we can now define all sorts of nodes, simply by using the above enum type:
let emptyItems = XMLNode.element(name: "items")
let link = XMLNode.element(name: "link", children: [.text(url)])
let metadata = XMLNode.voidElement(name: "meta", attributes: metadataAttributes)
Pretty cool! Especially for structures and models used to define data, using default values can be really powerful, as the granularity of any kind of data tends to vary quite a lot from use case to use case.
Conclusion
When deployed to provide a solid set of obvious and consistent defaults for an API, default arguments can be incredibly powerful. They can make our APIs feel so much more lightweight, and enable the callers of those APIs to ease their way into using them — by starting out simple, and then customizing and overriding the defaults values as needed.
Default arguments can also provide a way to easily retrofit an existing type or function with dependency injection, and to make enums a lot more flexible.
But we also have to be careful not to make too many assumptions when defining our default arguments. Because at the end of the day, even though defaults and strong conventions are incredibly useful, a non-obvious default is arguably much worse than having no default at all.
What do you think? How do you usually use default arguments, and what guiding principles do you think are good to keep in mind when deciding what value to turn into a default. Let me know — along with your questions, comments and feedback — either on Twitter or via email.
Thanks for reading! 🚀