Embercode
✕
C++ ConditionalsLesson 6 of 14

40 min read

Guard clauses and early return

Why this matters

Most real functions check their input before they do any work. A bank checks the amount before it takes money out. A delivery app checks the distance before it quotes a price. Each check can be written as an if statement, and the real work goes inside the last one. When there are several checks, the code moves further to the right with every rule. The main job ends up three or four layers deep in braces, and a reader has to hold every open rule in mind to follow it.

A guard clause avoids this. A guard clause is an if that catches one bad case and leaves the function at once with return. After all the guards, the normal case runs with no extra indentation. This style is called early return, because the function returns early for the bad cases.

This lesson uses if, else, return and bool answers from the earlier lessons in this module. The next lesson, De Morgan rewrites, shows how to write negated conditions that guards often need.

The idea in plain words

Picture a security guard at the door of a building. The guard checks the badge first. If the badge is wrong, the visitor is sent away and the visit ends there. The guard does not walk the visitor through three more rooms before saying no.

A guard clause works the same way. It checks one rule. If the rule fails, it returns right away. A return ends the function and sends one value back to the code that called it. Lines after a return on the same path never run.

The rest of this lesson builds that idea up with three examples, each a little harder than the one before.

Example 1: a withdrawal with nested rules

A withdrawal function has two rules. The amount must be above zero, and the balance must be large enough to cover it. The first version nests one rule inside the other.

C++ · editable

The output is:

ok, balance 70
amount must be positive, balance 70
not enough money, balance 70
ok, balance 50
amount must be positive, balance 50
not enough money, balance 50

Read the nested version from the outside in. The first if checks that the amount is positive. The second if, inside it, checks that the balance covers the amount. The line that returns "ok" sits inside two if blocks, so you have to keep both conditions in your head to know when it runs.

The guard version does the same job in three flat steps. The first step handles a bad amount and returns. The second step handles a balance that is too small and returns. Only the success case is left, at the bottom, with no else and no extra braces.

The int& balance in the parameter list has an &. That symbol means the function receives the caller's own variable, not a copy. So balance -= amount changes the balance in main. Without the &, the change would be lost when the function ends.

Look at the output. The call with -5 gets the first message, and the balance stays at 70. The call with 500 gets the second message, and the balance does not change. Each early return stops the function before the subtraction runs, so the balance can only change on the success path.

Example 2: a delivery fee with several rules

A delivery function returns the fee in cents. It returns -1 when the order cannot be delivered. A value like -1 that means "no answer" is called a sentinel. A fee can never be negative, so -1 cannot be mistaken for a real fee.

The function has five rules. Each rule is one guard, and the rules are checked from top to bottom. The first rule that matches returns, and the rest are skipped.

C++ · editable

The output is:

-1
-1
0
0
150
300

Trace one call by hand. delivery_fee(3, 1500, false) has a distance of 3, which is not negative and not over 20. It is not a member. The order is under 2000. The distance is 5 or less, so the function returns 150. The last line, return 300;, is never reached for this call.

The order of the rules matters. Suppose the member rule were moved above the distance checks. Then a member 25 km away would get a fee of 0 instead of -1, because the member rule would return before the distance check ran. Put the checks that reject an order first, and the checks that give a price after them.

The same rules with nested ifs

Here is the same delivery function written with nested if statements. Its output matches the flat version above.

C++ · editable

The output is:

-1
-1
0
0
150
300

Look at the indentation. The return 150; line is indented 24 spaces, inside five blocks. In the guard version, every return line is indented 8 spaces or less. The nested version works, but a reader has to follow each opening brace to find out which line runs for which input. The guard version lets the reader check one rule at a time from the top.

Example 3: a date check with a helper and a loop

Calendar dates have more rules. The month must be from 1 to 12. The number of days depends on the month. February has 29 days in a leap year and 28 otherwise. A year is a leap year when it divides evenly by 4, except years that divide evenly by 100, unless they also divide evenly by 400. So 2024 and 2000 are leap years, and 2023 and 1900 are not.

The program splits the work into two functions. days_in_month is a helper that returns the length of a month, or 0 when the month is not from 1 to 12. is_valid_date uses that answer and adds its own guards. The file also has a short loop that uses continue, which is the loop version of an early return.

C++ · editable

The output is:

true
false
true
false
false
false
true
false
false
14

Follow is_valid_date(2024, 13, 1). The year is 1 or more, so the first guard passes. The helper gets month 13, which is not from 1 to 12, so it returns 0. The second guard sees 0 and returns false. The day is never checked, and the month table in the helper never runs for month 13.

The loop in sum_of_positive uses the same idea. continue ends the current pass through the loop body and moves on to the next value. The values -2 and 0 are skipped, and the total is 4 + 7 + 3, which is 14. The const vector<int>& in the parameter list means the function reads the list and does not change it. A vector is a resizable list from the standard library. The call {4, -2, 7, 0, 3} builds one from a list of values in braces.

Example 4: a guard before a division

Some operations fail for certain inputs. Dividing a whole number by zero can crash the program, so a guard should stop the function before the division happens. The function below averages a total over a count of items. If there are no items, it returns -1 instead.

C++ · editable

The output is:

2.5
-1
2

The third line of main shows a separate trap. 10 / 4 is a division of two whole numbers, so C++ drops the remainder and gives 2, not 2.5. The function avoids this with static_cast<double>(total). A cast converts a value to another type. Here it makes the top of the division a double, so the division keeps the fraction.

The guard runs before the division. For average(10, 0), the test count <= 0 is true, so the function returns -1.0 and never divides. The guard is what makes the division safe. If the guard came after the division, the division would run first with a count of 0, and the result would be infinity, which is not a usable average.

Guards in a function that returns nothing

Some functions do not send back a value. Their return type is void, which means "no value." A guard in such a function still uses return, but it has nothing after the word. A bare return; ends the function at once.

C++ · editable

The output is:

no delivery
fee: 150

For -1, the first message prints, and the return; stops the function and hands control back to main, which is the caller here. The line that prints the fee never runs. For 150, the guard is false, so the function skips it and prints the fee. Use this form for a function that prints or changes something and has no answer to send back. The idea is the same as the guards above: check the bad case, leave, and keep the normal case at the bottom.

Choosing the order of guards

The order of the guards decides which answer a bad input gets, so choose the order on purpose. Three rules cover most cases.

  • Check that the input is usable before anything else. A negative distance should be rejected before any rule about members runs.
  • Check the more specific case before the general case. The practice section shows what goes wrong when a general check comes first.
  • Put the normal case at the end, so a reader who scans from the top sees every rejection first.

Example 2 shows what happens when a rule about members comes before the distance checks.

Keep one rule in each guard when the message matters. If one guard joins two unrelated rules with ||, its message cannot say which rule failed. In withdraw_guarded from Example 1, the two guards give two different messages, so the caller knows whether the amount or the balance was the problem.

Common mistakes

A path with no return

If one path through a function reaches the closing brace without a return, the function has no answer for that path. This program has that problem for n equal to 0.

#include <iostream>
using namespace std;
 
int check(int n) {
    if (n > 0) {
        return 1;
    }
    if (n < 0) {
        return -1;
    }
}
 
int main() {
    cout << check(0) << "\n";
    return 0;
}

Saved as l06_m1.cpp, g++ with -Wall reports the problem:

l06_m1.cpp: In function ‘int check(int)’:
l06_m1.cpp:11:1: warning: control reaches end of non-void function [-Wreturn-type]
   11 | }
      | ^

g++ prints the same warning without -Wall. If the program then runs check(0), the behaviour is undefined, which means the program can print anything or crash. The fix is a last guard, return 0;, placed after the other two checks. Always read the warnings from -Wall, because they are the compiler telling you about a path you forgot.

Returning a value of the wrong type

A function's return type is a promise. An int function must return an int. This program breaks that promise.

#include <iostream>
using namespace std;
 
int check_amount(int amount) {
    if (amount <= 0) {
        return "amount must be positive";
    }
    return 1;
}
 
int main() {
    cout << check_amount(-5) << "\n";
    return 0;
}

Saved as l06_m4.cpp, g++ rejects it with this error:

l06_m4.cpp: In function ‘int check_amount(int)’:
l06_m4.cpp:6:16: error: invalid conversion from ‘const char*’ to ‘int’ [-fpermissive]
    6 |         return "amount must be positive";
      |                ^~~~~~~~~~~~~~~~~~~~~~~~~

The text in quotes has type const char*, which is a pointer to text. It cannot turn into an int. The [-fpermissive] at the end of the message names a flag that can downgrade this error to a warning. The code is still wrong, so the fix is to change the return value, not the flag. Return a number here, or change the function to return a string if it needs to send back a message.

A text message that quietly becomes true

A bool function has a sneakier version of the same mistake. This one compiles with no error, and the output is wrong.

#include <iostream>
using namespace std;
 
bool check_amount(int amount) {
    if (amount <= 0) {
        return "amount must be positive";
    }
    return true;
}
 
int main() {
    cout << check_amount(-5) << "\n";
    return 0;
}

The output is:

1

The program prints 1, which means true. A text message is converted to a pointer, and a pointer that is not zero counts as true in a bool. So a negative amount is reported as valid. Neither g++ with -Wall nor the code itself warns about it. Use a bool only when the function answers yes or no, and send messages through another function or through a string return.

A guard with the test backwards

A guard that tests the opposite of what you mean rejects good input and lets bad input through.

C++ · editable

The output is:

amount must be positive
ok

Five is a good amount, but it is rejected. Negative five is a bad amount, and it passes. The test should be amount <= 0. Before you move on, test each guard with one input that should fail it and one that should pass it.

An else after a return

Writing else after a return still runs correctly, but it adds a level of indentation for no reason. The guard style drops the else and returns at the first check. This is a style point, not an error, but it is the habit that keeps the normal path flat.

Practice

Exercise 1

Rewrite speed_label_before with guard clauses, so that it has no else and no nested if. The function returns "invalid" below zero, "slow" up to 30, "normal" up to 100, and "fast" above that.

string speed_label_before(int kmh) {
    string label;
    if (kmh >= 0) {
        if (kmh <= 30) {
            label = "slow";
        } else {
            if (kmh <= 100) {
                label = "normal";
            } else {
                label = "fast";
            }
        }
    } else {
        label = "invalid";
    }
    return label;
}

Show the answer

string speed_label(int kmh) {
    if (kmh < 0) {
        return "invalid";
    }
    if (kmh <= 30) {
        return "slow";
    }
    if (kmh <= 100) {
        return "normal";
    }
    return "fast";
}

The two functions return the same text for every input. The guard version returns for the first three cases, and the last line handles the rest.

Exercise 2

Predict the output of score_for(-3), score_for(12), score_for(4) and score_for(7).

int score_for(int n) {
    if (n < 0) {
        return 0;
    }
    if (n > 10) {
        return 10;
    }
    if (n % 2 == 0) {
        return 2;
    }
    return 1;
}

Show the answer

The output is 0 10 2 1, printed with spaces between the numbers in that order.

  • -3 is below zero, so the first guard returns 0.
  • 12 is above 10, so the second guard returns 10.
  • 4 passes both guards and divides evenly by 2, so it returns 2.
  • 7 passes both guards and is odd, so it reaches the last line and returns 1.

Exercise 3

Write is_valid_password with guard clauses. A password is valid when it has at least 8 characters and contains no space. Return false for any other case, and true otherwise.

Show the answer

bool is_valid_password(const string& pw) {
    if (pw.size() < 8) {
        return false;
    }
    if (pw.find(' ') != string::npos) {
        return false;
    }
    return true;
}

pw.size() returns the number of characters. pw.find(' ') returns the position of the first space, or string::npos when there is no space. The != check is what makes the second guard work.

Exercise 4

The function below is meant to return 2 for values of 10 and above, 1 for values from 0 to 9, and -1 for negative values. It does not do that. Say what classify_bad(12) prints, and fix the function.

int classify_bad(int n) {
    if (n >= 0) {
        return 1;
    }
    if (n >= 10) {
        return 2;
    }
    return -1;
}

Show the answer

classify_bad(12) prints 1. The first guard, n >= 0, is true for 12, so the function returns 1 before the second check runs. The second check can only be reached for negative numbers, and no negative number is 10 or more, so return 2; never runs at all.

The fix is to put the more specific check first:

int classify(int n) {
    if (n >= 10) {
        return 2;
    }
    if (n >= 0) {
        return 1;
    }
    return -1;
}

classify(12) prints 2, classify(3) prints 1, and classify(-4) prints -1.

Exercise 5

What does count_long_words return for the list {"tree", "", "hello", "algorithm", ""}?

int count_long_words(const vector<string>& words) {
    int count = 0;
    for (const string& w : words) {
        if (w.empty()) {
            continue;
        }
        if (w.size() >= 5) {
            ++count;
        }
    }
    return count;
}

Show the answer

It returns 2. The two empty strings are skipped by continue. "tree" has four characters, which is not 5 or more, so it is not counted. "hello" and "algorithm" are counted, which gives 2.

Summary

  • A guard clause checks one bad case and returns at once.
  • Put the bad cases first and the normal case last, with no else around it.
  • A sentinel such as -1 can signal "no answer" when the real answers are never negative.
  • Inside a loop, continue does the same job as an early return.
  • Every path through a function must return, and the value must match the return type.
  • A text message returned from a bool function silently becomes true.
Practice this topic: coding problems for C++ Conditionals →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Module assignment: Ticket Router