Linux & Bash · Part I · Module 16 · Conditionals and Pattern Branches
if, elif, else, and What Bash Is Actually Testing
Bash conditionals look strange to anyone arriving from another language — the brackets, the mandatory spaces, the semicolon before then. Every one of those oddities follows from a single fact, and this chapter is that fact. if does not evaluate a condition. It runs a command.
Intermediate20 min readLinuxPart IBashConditionalsExit StatusScripting
Part I · Module 16 · Chapter 16.1 · if, elif, else, and What Bash Is Actually Testing
Module 11 taught you that every command returns a number, and that zero means success. Module 15 gave you a script with a name, a shebang, arguments and a return status. This module spends that investment: it is where a script stops running the same commands every time and starts deciding.
And it opens with the fact that makes the rest of the module trivial instead of arbitrary.
1. There Is No Condition
Here is a conditional, as small as one gets:
$ if true; then echo "taken"; fi
taken
$ if false; then echo "taken"; fi
$Nothing surprising yet. But look at what true and false actually are:
$ type true false
true is a shell builtin
false is a shell builtin
$ true; echo $?
0
$ false; echo $?
1They are not keywords. They are not boolean literals. They are commands — commands whose entire job is to return 0 and 1 respectively and do nothing else. Bash ships them precisely because the thing after if has to be a command, and sometimes you want a command that just succeeds.
That is the whole mechanism:
What a conditional actually does
Read it as a sentence and the syntax decodes itself:
if <run this command> ; then
<run these if it returned 0>
else
<run these if it returned anything else>
fiThe semicolon is there because then is a separate word from the command, and Bash needs to know the command's argument list has ended. A newline does the same job, which is why the multi-line form has no semicolon:
if make
then
echo "built"
fiBoth forms are identical to Bash. The one-line form with the semicolon is what you type at a prompt; the multi-line form is what goes in a script.
2. The Demonstration That Settles It
Talk is cheap. Here is the test that convinces people:
$ if [ 1 ]; then echo "true"; else echo "false"; fi
true
$ if [ 0 ]; then echo "true"; else echo "false"; fi
true[ 0 ] is true.
Everyone who believes if evaluates a boolean predicts false for the second one, and gets it wrong. Nothing about zero is being evaluated as a value at all. [ is a command (Chapter 16.2 is entirely about that), and given a single argument, it answers one question: is this string non-empty? The string 0 is one character long, so the answer is yes, so [ returns 0, so the branch is taken.
Which predicts this, correctly:
$ if [ "" ]; then echo "true"; else echo "false"; fi
false
$ if [ ]; then echo "true"; else echo "false"; fi
falseEmpty string: not non-empty, so [ returns 1, so the else branch runs. Nothing at all between the brackets: same answer.
3. Any Command, Which Means Any Command
If the thing after if is just a command, then there is no special list of things allowed there. Watch:
$ if ls /etc/hosts > /dev/null; then echo "it exists"; fi
it exists
$ if grep -q root /etc/passwd; then echo "found"; fi
found
$ if cd /tmp; then echo "now in $PWD"; fi
now in /tmp
$ if mkdir /root/nope 2>/dev/null; then echo "made it"; else echo "could not"; fi
could notls, grep, cd, mkdir — all ordinary commands, all usable as conditions, because all of them return a status. This is not a trick or an advanced technique; it is the normal, idiomatic way to write Bash, and Chapter 16.7 is dedicated to doing it well.
There is one consequence that catches people, and it follows directly from "it runs the command":
$ if echo "hello"; then echo "branch taken"; fi
hello
branch takenThe condition's output still goes where output goes. Bash did not quietly capture it. echo printed hello to your terminal exactly as it would anywhere else, and then its status was read. This is why real conditions are full of -q and > /dev/null:
if grep -q "ERROR" run.log; then # -q: find it, print nothing
if command -v vcs > /dev/null 2>&1; then # discard both streamsBoth of those are Module 12's redirections doing exactly what they always do. Nothing new is happening — the condition is a command, so it behaves like a command.
4. else, and Why It Is Not Always Wanted
else is one branch for "the command did not return zero" — which lumps every possible failure together:
if make > build.log 2>&1; then
echo "build succeeded"
else
echo "build failed, see build.log"
fiThat is perfectly good when all you need to know is pass or fail. But remember from Chapter 11.1 that the number carries information:
$ grep -q "pattern" /etc/hosts; echo $?
1
$ grep -q "pattern" /nonexistent-file 2>/dev/null; echo $?
2grep returns 1 for "no match" and 2 for "something went wrong". An else branch cannot tell those apart, and a script that reports "no errors found in the log" when the log did not exist is lying. Reading the specific number needs Chapter 16.3's numeric comparisons on $?, or case, which is Chapter 16.6.
5. elif, and That It Is Ordered
elif chains conditions. The first one that returns zero wins, and the rest are never run:
#!/usr/bin/env bash
# dispatch.sh — pick a simulator from whatever is installed
if command -v vcs > /dev/null 2>&1; then
sim="vcs"
elif command -v xrun > /dev/null 2>&1; then
sim="xrun"
elif command -v questa > /dev/null 2>&1; then
sim="questa"
else
echo "dispatch.sh: no supported simulator on PATH" >&2
exit 2
fi
echo "using $sim"Two properties of that chain matter more than the syntax:
It is ordered, and the order is a decision. This script prefers VCS. If both VCS and Xcelium are installed, xrun is never even looked for. That is a policy statement written in the sequence of the branches, and it should be a deliberate one.
Exactly one branch runs. Not "at most one" — with the else present, exactly one. That is the whole value of a chain over a sequence of separate if statements, which would each be tested independently:
# NOT the same thing — all three are tested, all three can run
if command -v vcs > /dev/null 2>&1; then sim="vcs"; fi
if command -v xrun > /dev/null 2>&1; then sim="xrun"; fi # overwrites!Here the last matching branch wins instead of the first, which is almost certainly not what was intended. The chain is not cosmetic.
Here is the ordering made visible, with a function standing in for a tool that may or may not be present:
$ check() { [ "$1" = "yes" ]; }
$ pick() {
> if check "$1"; then echo "first"
> elif check "$2"; then echo "second"
> else echo "neither"; fi
> }
$ pick yes yes
first
$ pick no yes
second
$ pick no no
neitherpick yes yes prints first and not both — the second condition was never evaluated.
6. The Status of an if Is the Status of Its Body
One subtlety worth knowing before Module 20, and it surprises people:
$ fail3() { return 3; }
$ if fail3; then echo "ok"; else echo "no"; fi
no
$ echo $?
0$? is 0, not 3. The condition returned 3, but the thing that ran last was echo "no", which succeeded. $? after fi is the status of whichever branch body ran, not the status of the condition.
And a conditional whose branch does not run at all reports zero:
$ if false; then echo "never"; fi
$ echo $?
0This is specified behaviour: an if with no else and a false condition returns 0. It is the reason set -e does not kill a script at a failing if condition — a fact Module 20 depends on, and one that would look like a bug if you had not seen this section.
To keep the condition's status, capture it inside the branch:
if ! make > build.log 2>&1; then
rc=$? # the status of make, read immediately
echo "make failed with status $rc" >&2
exit "$rc"
firc=$? has to be the branch's first line, for exactly the reason Chapter 11.1 gave: the next command overwrites $?.
7. A Complete Script
Everything so far, in something that would survive a code review. It checks a simulation log the way a real wrapper does — and notice that only one line in it is new since Module 15:
#!/usr/bin/env bash
# check-run.sh — report on a simulation log
# usage: check-run.sh <logfile>
log=$1
if [ -z "$log" ]; then
echo "usage: ${0##*/} <logfile>" >&2
exit 2
fi
if [ ! -f "$log" ]; then
echo "${0##*/}: no such log: $log" >&2
exit 2
fi
if grep -q "UVM_FATAL" "$log"; then
echo "FATAL $log"
exit 1
elif grep -q "UVM_ERROR" "$log"; then
echo "ERRORS $log"
exit 1
elif grep -q "TEST PASSED" "$log"; then
echo "PASS $log"
exit 0
else
echo "UNKNOWN $log — ran but reported nothing"
exit 3
fi$ printf 'starting\nUVM_ERROR : 2 mismatches\n' > run.log
$ ./check-run.sh run.log; echo "status=$?"
ERRORS run.log
status=1
$ printf 'starting\nTEST PASSED\n' > run.log
$ ./check-run.sh run.log; echo "status=$?"
PASS run.log
status=0
$ printf 'starting\nkilled\n' > run.log
$ ./check-run.sh run.log; echo "status=$?"
UNKNOWN run.log — ran but reported nothing
status=3
$ ./check-run.sh missing.log; echo "status=$?"
check-run.sh: no such log: missing.log
status=2Four behaviours, four distinct statuses, and the UNKNOWN branch is the one that earns its place: a run that neither failed nor passed is the single most common real outcome of a simulation that got killed, and a script with only pass/fail branches would have silently called it a pass or a failure.
The ordering is a decision again. UVM_FATAL is checked before UVM_ERROR because a fatal log usually contains both, and the more severe answer is the more useful one.
Practice
- Predict, then run:
if [ "0" ]; then echo A; else echo B; fi. Then try it with[ "" ],[ "false" ]and[ " " ]— one space between the quotes. Explain the last one. - Run
if echo marker; then echo done; fi. Where didmarkergo, and what single change stops it appearing while keeping the conditional working? - Write a conditional whose condition is
cd /tmpand whose body prints$PWD. Then do the same withcd /nonexistent. Why does the second one still print a message to your terminal even though the branch did not run? - Define
f() { return 7; }. Runif f; then echo y; else echo n; fi; echo $?. Why is the answer 0? Now get 7 to print instead. - Take the three-way
elifchain from Section 5 and reverse the order of the first two branches. Construct a situation where the two versions give different answers, then say which order you would actually ship and why. - Extend
check-run.shwith a branch that distinguishes "the log exists but is completely empty" from theUNKNOWNcase. You do not have the operator for it yet — guess it, check whether you guessed right, and read Chapter 16.4 to confirm.
Check Your Understanding
- Bash evaluates the expression between
ifandthenand takes the branch if it is true. What is wrong with that sentence? - Why is
[ 0 ]true? - You write
if grep "ERROR" run.log; then echo "found"; fiand the matching lines appear on your screen. Is this a bug ingrep, inif, or in your command? - What is the difference between an
elifchain and three separateifstatements? - After
if false; then echo hi; fi, what is$?and why? - Name one thing you can write after
ifthat is not a[test.
8. Remember
ifruns a command and reads its exit status. There is no boolean expression and nothing is evaluated for truthiness.- Zero takes the first branch — because zero means the command succeeded, not because zero is true.
trueandfalseare commands, not keywords. They exist because the slot afterifneeds a command.[ 0 ]is true and[ ]is false, which is the proof:[is testing string emptiness, not reading a number.- The condition's output still reaches your terminal, which is why real conditions carry
-qor> /dev/null. - An
elifchain is ordered and exclusive. The first zero wins; later conditions never run. $?afterfiis the branch body's status, not the condition's. Capture$?on the branch's first line if you need the real one.- The semicolon before
thenis a separator, nothing more. A newline does the same job.
Chapter 16.2 takes the thing this chapter kept using without explaining — those square brackets — and shows that [ is a program on your disk with a name made of one punctuation character, which is the entire reason its spaces are not optional.
Where this fits
Part of the Linux & Bash curriculum.
