Trimming long argument lists in Swift
Adding new features to an app or framework often involves adding new arguments to existing functions. We might need a new way of customizing a common operation, or to make a slight tweak to how something is rendered, in order to be able to integrate our new feature with the rest of our code base.
While most such changes may seem trivial at first, if we're not careful, we could - over time - end up significantly extending the scope of a function or type, far beyond what it was originally designed for - leaving us with APIs that are a bit unclear and cumbersome to use.
This week, let's take a look at how to deal with such functions, and how they often can be simplified by reducing the number of arguments they accept.
Growing pains
Let's start by taking a look at an example. Here we have a function that we can use to easily present the current user's profile in our app - optionally with an animation:
func presentProfile(animated: Bool) {
...
}
Simple enough. However, over time, as new features are added to the app - we might find ourselves needing to add more and more customization options to the above function - transforming what was originally super simple into something far more complicated:
func presentProfile(animated: Bool,
duration: TimeInterval = 0.3,
curve: UIViewAnimationCurve = .easeInOut,
completionHandler: (() -> Void)? = nil) {
...
}
The above function isn't necessarily bad (it does follow most modern Swift API design conventions), but it's starting to become a bit hard to understand, and it lacks the simplicity and elegance of its original version. It's also easy to see a trend here - the above function isn't likely to stop growing, as new features will surely also demand similar tweaks and new options as well.
Reducing ambiguity
One common reason that functions with long argument lists can be harder to understand is because they often start becoming a bit ambiguous. If we take a closer look at our presentProfile
example from above, we can see that it's now possible to call it with a combination of arguments that don't really make sense. For example, it's possible to tell it both to disable animations and to use a specific animation duration:
presentProfile(animated: false, duration: 2)
It's very unclear what the function is expected to do in this case. Should it respect the false
animation flag, or should it animate over two seconds, as indicated by the duration argument?
Let's try to reduce some of that ambiguity and confusion by reducing the length of our function's argument list - without sacrificing any functionality. Instead of having all animation options as top-level arguments, we're going to package them up into an Animation
configuration struct - like this:
struct Animation {
var duration: TimeInterval = 0.3
var curve = UIViewAnimationCurve.easeInOut
var completionHandler: (() -> Void)? = nil
}
With the above in place, we are able to reduce all previous four arguments into a single one - making it possible to return to a similarly elegant and simple function signature as we originally had:
func presentProfile(with animation: Animation? = nil) {
...
}
The beauty of the above approach, is that it's no longer possible to send conflicting arguments to our function - if the animation
argument is nil
, no animation will be performed. No more ambiguity and no more long argument lists to keep track of.
An alternative to creating a new type for the animation options, like we do above, is to use a tuple. For more on that, check out "Using tuples as lightweight types in Swift".
Composition
Another common problem with long argument lists is that we end up with functions that do too many things, making their implementations hard to read and maintain as well. For example, here we have a function that loads a list of a user's friends matched by a search query, while also including options for sorting and filtering:
func loadFriends(matching query: String,
limit: Int?,
sorted: Bool,
filteredByGroup group: Friend.Group?,
handler: @escaping (Result<[Friend]>) -> Void) {
...
}
While it might be really convenient to have one function do everything we need when loading friends, it's very common for such functions to become messy and buggy due to complicated logic with lots of different code paths depending on what combination of arguments that were passed.
Instead, let's reduce the scope of loadFriends
to only include the actual loading of friends, without any sorting or filtering features. That way it can become simpler, easier to test, and we prevent it from becoming a code dumping ground. Since sorting and filtering is very context-specific anyway, we'll delegate those operations to the callers of our function, like this:
loadFriends(matching: query) { [weak self] result in
switch result {
case .success(let friends):
self?.render(friends.filtered(by: group).sorted())
case .failure(let error):
self?.render(error)
}
}
What we've essentially done above is to break things up to favor composition. Instead of having one function contain all of the functionality we need, we can now mix and match different functions to achieve the intended result - just like how we use the standard library's sorted()
API and our own filtered(by:)
utility function above. While that might result in some minor code duplication here and there, we usually end up with a much more flexible solution that doesn't rely on one central point for all kinds of semi-unrelated logic.
If you want to read more about composition - check out "Composing types in Swift". I'll also cover function composition in much more detail in a future article.
Extraction
Finally, let's take a look at how we might extract long argument lists into a new, dedicated type. As an example, let's say we have an extension on UIViewController
that lets us present a dialog to the user from anywhere in the app - where the user can choose to either accept or reject a proposition:
extension UIViewController {
func presentDialog(withTitle title: String,
message: String,
acceptTitle: String,
rejectTitle: String,
handler: @escaping (DialogOutcome) -> Void) {
...
}
}
Again, we're dealing with a quite long argument list, which can only be expected to keep growing as we might need to present more and more complex dialogs. While we could refactor the above to use multiple UIViewController
extensions, that could also grow messy over time as we'll fill up the UIViewController
API with lots of dialog presenting functionality.
Instead, let's try moving the above functionality into its own, dedicated type. In this case, we'll create a struct called DialogPresenter
, which'll contain properties for all options that were previously arguments - like this:
struct DialogPresenter {
typealias Handler = (DialogOutcome) -> Void
let title: String
let message: String
let acceptTitle: String
let rejectTitle: String
let handler: Handler
func present(in viewController: UIViewController) {
...
}
}
While extensions are great, when we're dealing with a very specific task - such as presenting dialogs, like above - using a dedicated type can often lead to clearer code and improved separation of concerns. It also makes it much easier to define type aliases (like we do for Handler
above) and to define convenience APIs as extensions directly on the DialogPresenter
type - instead of having to keep adding new extensions to UIViewController
:
extension DialogPresenter {
init(title: String, handler: @escaping Handler) {
self.title = title
self.handler = handler
message = ""
acceptTitle = "Yes".localized
rejectTitle = "No".localized
}
}
While we probably want to keep our simpler extensions the way they are, extracting the ones that have long argument lists into dedicated types can be a great option.
Conclusion
Long and complicated argument lists are often the result of multiple changes that took place over a long period of time, and while each atomic change might've seemed perfectly fine at the time, it's easy for things to grow out of control without proper maintenance. Just like with code structure, even the most perfectly designed API quickly breaks down if its scope grows too much.
So when should an argument list be broken down, trimmed or extracted into a dedicated type? What I usually do - is that every time I'm about to add a new argument to an existing function - I ask myself: "Would I have added this argument to this function if I wrote it from scratch today?". If the answer is "No", a refactor is usually in order.
What do you think? How do you usually deal with extended scopes and long argument lists? Do you have a favorite technique, or will you try out some of the techniques from this post? Let me know - along with your questions, comments and feedback - on Twitter @johnsundell.
Thanks for reading! 🚀