Pure functions in Swift
Pure functions is one of those core programming concepts that can be applied to more or less any language that supports some form of functions or subroutines.
A function is considered pure when it doesn’t produce any side effects and when it doesn’t rely on any external state. The core idea is that a pure function will always produce the same output for a given set of input — regardless of when and how many times it’s called.
While the above might sound like a mostly theoretical concept, pure functions do have the potential to give us some very real, practical benefits — from increased reuse and testability, to more predictable code. This week, let’s take a look at how pure functions might be used in Swift — and how we can apply them to solve real problems in a very nice way.
Purifying functions
Let’s start by taking a look at an example of a function that has the potential to become pure, but doesn’t yet fulfill the requirement of not producing any side effects — since it mutates the value that it’s called on:
extension String {
mutating func addSuffixIfNeeded(_ suffix: String) {
guard !hasSuffix(suffix) else {
return
}
append(suffix)
}
}
The fact that the above function is mutating
might not seem like a big deal — but since String
is a value type, we’ll only be able to call it on mutable values, which can often lead to the classic “var mutate assign”-dance:
var fileName = contentName
fileName.addSuffixIfNeeded(".md")
try save(content, inFileNamed: fileName)
Let’s instead purify our function — by making it return a new String
value, rather than mutating the one that it was called on — like this:
extension String {
func addingSuffixIfNeeded(_ suffix: String) -> String {
guard !hasSuffix(suffix) else {
return self
}
return appending(suffix)
}
}
The above may seem like a very subtle change, but it can both reduce the amount of mutable state we’ll need to keep within our code — and can also lead to much clearer call sites, such as this updated version of our code from before:
let fileName = contentName.addingSuffixIfNeeded(".md")
try save(content, inFileNamed: fileName)
Another thing that can prevent a function from being considered pure is if it depends on some form of external, mutable state. For example, let’s say that we’re building a login screen for our app, and that we want to display a different error message in case the user has repeatedly failed to log in. The function that contains that logic currently looks like this:
extension LoginController {
func makeFailureHelpText() -> String {
guard numberOfAttempts < 3 else {
return "Still can't log you in. Forgot your password?"
}
return "Invalid username/password. Please try again."
}
}
Since the above function depends on the view controller’s numberOfAttempts
property — which is external to the function itself — we can’t consider it pure, since it might start producing different results when the property it depends on is mutated.
One way to fix that would be to parameterize the state that our function relies on — turning it into a pure function from Int
to String
— or, in other words, from number of attempts to help text:
extension LoginController {
func makeFailureHelpText(numberOfAttempts: Int) -> String {
guard numberOfAttempts < 3 else {
return "Still can't log you in. Forgot your password?"
}
return "Invalid username/password. Please try again."
}
}
One major benefit of pure functions is that they are usually really easy to test — since we can simply verify that they produce the right output for any given input. For example, here’s how we could easily test our above function by passing it different numberOfAttempts
values:
class LoginControllerTests: XCTestCase {
func testHelpTextForFailedLogin() {
let controller = LoginController()
XCTAssertEqual(
controller.makeFailureHelpText(numberOfAttempts: 0),
"Invalid username/password. Please try again."
)
XCTAssertEqual(
controller.makeFailureHelpText(numberOfAttempts: 3),
"Still can't log you in. Forgot your password?"
)
}
}
Pure functions are also almost always much easier to compose, structure and parallelize — since they don’t affect, or rely on, anything that’s outside of them (that isn’t either passed in as input, or produced as output). We’ll take a closer look at both function composition and parallelization in future articles.
Enforcing purity
While pure functions have a ton of benefits, during day-to-day coding it can sometimes be a bit tricky to know whether any given function is really pure — since most of the code that we write when working on apps and products rely on a lot of different state.
However, one way to enforce at least a certain degree of “purity”, is to structure our logic around value types. Since a value can’t mutate itself, or any of its properties, outside of mutating
functions — it gives us a much stronger guarantee that our logic is indeed pure.
For example, here’s how we might set up the logic for calculating the total price of buying an array of products — using a struct that only consists of let
properties (that in turn are also value types), and a non-mutating
method:
struct PriceCalculator {
let shippingCosts: ShippingCostDirectory
let currency: Currency
func calculateTotalPrice(for products: [Product],
shippingTo region: Region) -> Cost {
let productCost: Cost = products.reduce(0) { cost, product in
return cost + product.price
}
let shippingCost = shippingCosts.shippingCost(
forRegion: region
)
let totalCost = productCost + shippingCost
return totalCost.convert(to: currency)
}
}
The benefit of the above approach is that it becomes much harder to accidentally introduce mutable state — since when doing so we’d be required to turn the above calculateTotalPrice
function into a mutating
one — which is something we could catch with either tooling or peer-to-peer code review.
Purifying refactors
While pure functions often look great when shown using highly contrived or isolated examples — the question is how we can conveniently fit them into a real code base for an app? Most app code isn’t 100% neatly compartmentalized, and most logic does end up mutating some form of state — whether that’s updating a file, modifying in-memory data, or by making a network call.
Let’s take a look at another example, in which we’re handling taps on a next-button within an article reading app’s ReaderViewController
. Depending on the view controller’s current state we either display the next article within the user’s reading queue, show a set of promotions, or dismiss the current flow — using logic that looks like this:
private extension ReaderViewController {
@objc func nextButtonTapped() {
guard !articles.isEmpty else {
return didFinishArticles()
}
let vc = ArticleViewController()
vc.article = articles.removeFirst()
present(vc)
}
func didFinishArticles() {
guard !promotions.isEmpty else {
return dismiss()
}
let vc = PromotionViewController()
vc.promotions = promotions
vc.delegate = self
present(vc)
}
}
The above isn’t ”bad code” in any way — it’s quite easy to read, and it’s even split up into two distinct functions to make it easier to get an overview of the logic. But since the above nextButtonTapped
function isn’t pure, it’d be really hard to test (especially given that it depends on lots of private state that we’d have to expose).
Logic like the above is also a very common cause of the ”Massive View Controller” problem — when view controllers end up making too many decisions on their own, resulting in complex logic intertwined with presentation and layout code.
Let’s instead extract the above logic into a pure logic type — which only role will be to contain the logic for our button. That way, we can model our logic as a pure function from state to outcome — and use a static function, combined with value types, to ensure that our logic is and remains pure:
struct ReaderNextButtonLogic {
enum Outcome {
case present(UIViewController, remainingArticles: [Article])
case dismiss
}
static func outcome(
forArticles articles: [Article],
promotions: [Promotion],
promotionDelegate: PromotionDelegate?
) -> Outcome {
guard !articles.isEmpty else {
guard !promotions.isEmpty else {
return .dismiss
}
let vc = PromotionViewController()
vc.promotions = promotions
vc.delegate = promotionDelegate
return .present(vc, remainingArticles: [])
}
var remainingArticles = articles
let vc = ArticleViewController()
vc.article = remainingArticles.removeFirst()
return .present(vc, remainingArticles: remainingArticles)
}
}
What’s really important about the above code is that it does everything that our view controller previously did to handle tap events — except to mutate any form of state (such as modifying the articles
property, or presenting child view controllers). That’s still something we let the view controller itself do, after determining what the Outcome
of the button tap was:
private extension ReaderViewController {
@objc func nextButtonTapped() {
let outcome = ReaderNextButtonLogic.outcome(
forArticles: articles,
promotions: promotions,
promotionDelegate: self
)
switch outcome {
case .present(let vc, let remainingArticles):
articles = remainingArticles
present(vc)
case .dismiss:
dismiss()
}
}
}
While the above refactor has made us end up with a bit more code (if we just look at the line count) — it has also given us much more predictable and decoupled code, that is now fully testable. For example, here’s how we could now easily test that the right outcome is produced when our next-button is tapped while there are still articles left in the reading queue:
class ReaderNextButtonLogicTests: XCTestCase {
func testNextArticleOutcome() {
let articles = [Article.stub(), Article.stub()]
let outcome = ReaderNextButtonLogic.outcome(
forArticles: articles,
promotions: [],
promotionDelegate: nil
)
guard case .present(let vc, let remaining) = outcome else {
return XCTFail("Invalid outcome: \(outcome)")
}
XCTAssertTrue(vc is ArticleViewController)
XCTAssertEqual(remaining, [articles[1]])
}
}
For more information about the kind of value stubbing that is used for the articles in the above code sample, check out “Static factory methods in Swift”.
While there are other ways to encapsulate logic like the above — for example using logic controllers or view models — by just moving our logic to a dedicated, pure function, we were able to make our code testable without making any major changes — like changing our architecture, or introducing techniques like dependency injection.
Conclusion
You don’t have to be a big fan of functional programming to appreciate the elegance and real practical benefits of pure functions. While writing entire applications that only consists of pure functions is most often extremely hard — at least using Apple’s current frameworks — if we can convert our core logic to use pure functions as much as possible, we often end up with code that is more robust, and easier to test.
We’ll continue exploring the realm of functional programming, and pure functions in particular, in future articles — especially when it comes to how pure functions can be easily composed and parallelized. Like always, the best way to get notified of new content on this site is to subscribe to the once-a-month Swift by Sundell newsletter.
What do you think? Do you currently model your logic based on pure functions, or is it something you’ll try out? Let me know — along with your questions, comments and feedback — either by contacting me or on Twitter @johnsundell.
Thanks for reading! 🚀