Function patterns: early exit or "no, no, yes!" Michał Moroz
There are many useful patterns that can be applied on a level of a single function in the code. And because most of the code is functions or class methods, improving legibility on this level was always an important part of my toolkit.
Here's one of the patterns that's useful that I use everyday in my work, make reading code easier, and you can use it, too.
No, no, yes!
This is a pattern, where guard clauses and early exits are placed in such a way that preserves integrity of the function.
Similarly to how we can restrict function domain of a mathematical function, e.g. from all numbers to only positive ones, here we can remove all kinds of invalid input before we do some actual computation with provided parameters.
Why should I use early exits?
The main benefit is that early exits are easy to reason about. The general structure looks like this:
- if condition A is not set, return
- if condition B is not set, return
- if condition C is not set, return
- do your primary purpose (now that we know that conditions A, B and C are all met)
There's no nested if statements, there's not much that we need to keep in our mind in order to read through this function.
Applicability
- functions that produce some side effect
- performing commands
- editing and saving objects
- we can't handle all possible combinations of parameter values in the input
- our type system cannot restrict to such granularity (e.g. can only restrict to all possible string values, not the ones we care about)
An example in Python
Let's say we need to write a function that handles voting on Slack team messaging platform with emojis added under a specific message.
In that context, adding a vote would be done by clicking on the 👍 emoji under a certain message. You could also retract your vote by "unclicking" the emoji, and the system should also handle that case.
A couple of things we should know about the system:
- There's a Poll model, under which the votes are cast. All poll objects are of the Poll type.
- There also is a Slack Event type, which is a simple object that carries information about the user, whether the vote was added or retracted, etc.
- The poll can be
resolved
, by which we mean that the voting is over and no further votes can be added.
Let's go to the function.
def vote(poll: Poll, slack_event: SlackEvent):
if poll.has_resolution():
return
user = slack_event.user
if user not in poll.recipients:
return
simulated_vote_count = slack_event.vote_count + votes_cast(poll, user)
if simulated_vote_count > 1 or simulated_vote_count < 0:
return
with transaction.atomic():
save_event(poll, slack_event)
check_if_resolved(poll, slack_event)
Let's go step by step through that function:
if poll.has_resolution():
return
This is our first guard statement, removing from it's function domain all polls that are already resolved.
In that case, our function will be a no-op, and we do not need to reason about what happens there anymore.
user = slack_event.user
if user not in poll.recipients:
return
Our next guard statement removes from its domain function all users that were not specifically as poll recipients.
We also have a third guard clause:
simulated_vote_count = slack_event.vote_count + votes_cast(poll, user)
if simulated_vote_count > 1 or simulated_vote_count < 0:
return
This one is might be a bit harder to understand, but with the use of right naming we can easily guess what it does.
votes_cast
gives us an existing number of votes cast by this userslack_event.vote_count
can be -1 or 1, depending on whether the person retracted or cast a votesimulated_vote_count
shows the vote count after the vote has been cast and it can be either 0 or 1.
This guard removes from its domain function all votes that would result in casting more than zero or one votes.
Only after that, an actual work is being done – we start a transaction and perform operations on the database.
with transaction.atomic():
save_event(poll, slack_event)
check_if_resolved(poll, slack_event)
To make it even clearer, we could make two functions:
def can_vote(poll: Poll, slack_event: SlackEvent):
if poll.has_resolution():
return False
user = slack_event.user
if user not in poll.recipients:
return False
simulated_vote_count = slack_event.vote_count + votes_cast(poll, user)
if simulated_vote_count > 1 or simulated_vote_count < 0:
return False
return True
def cast_vote(poll: Poll, slack_event: SlackEvent):
with transaction.atomic():
save_event(poll, slack_event)
check_if_resolved(poll, slack_event)
And use them like this:
if can_vote(poll, slack_event):
cast_vote(poll, slack_event)
Which makes our "no, no, yes" pattern even more visible.
In conclusion, the function domain restricts only to:
- an unresolved poll (that accepts votes)
- a user that is able to vote in this poll
- a vote that will result in user having zero or one votes cast on the poll