Bash conditionals
bash --version
: GNU bash, version 4.3.48(1)-release (x86_64-pc-linux-gnu)
INT (Arcana)
: DC 12
QOTD
:
Conditionals.
You know, the if… then… else stuff.
Bash have them too!
Bash has conditionals
Today I explored the lands of bash conditionals. Like most things in bash, they are usually used “by recipe”, without ever understanding how they really work.
“Of course I know how it works”, I hear you say, “I know conditionals!”. And that is fair, anyone familiarized with a programming languages (or even English!) understands the basic structure of a conditional.
However, conditionals in most programming languages have a very defined structure:
if <condition> then
<do stuff>
else
<do another stuff>
end
We understand that the “condition” that defines how the execution flows has to be a boolean proposition, which means it has to evaluate to either be true or false. We can put there a comparison between variables, a boolean value, a call to a method that returns such as value.
In bash however we don’t really have variables. Not in the same way as most languages. We cant just simply equal two variables as part of the expression and call it a day: bash is instead based around commands. Let’s look at an example, a first attempt to use bash conditional.
$ cat conditionals-1
#! /bin/bash
foo=1
if $foo == "1"; then
echo hi
fi
$ ./conditionals-1
./conditionals-1: line 5: 1: command not found
Hmmm… it did not work. How bad is that? No matter how hard we try to fit
bash into our standard model for conditionals we cannot seem to have it to
work. The command not found
error somehow makes matters a little bit more
off-putting. Here are some more failed attempts.
$ export foo=1
$ if $foo == 1; then echo hi; fi
1: command not found
$ if $foo == "1"; then echo hi; fi
1: command not found
$ if ( "$foo" == "1" ); then echo hi; fi
1: command not found
So we do what we all do: we search for a solution online and find the magic
of the double brackets [[]]
. We are done with it…
$ cat conditionals-1-revisited
#! /bin/bash
foo=1
if [[ $foo == "1" ]]; then
echo hi
fi
$ ./conditionals-1-revisited
hi
…or are we?
What did actually change? It must be bash weird syntax, I said to myself the first time I saw it. But then later on you see that replacing the double brackets with simple brackets also works. Less characters, brilliant!
$ if [ $foo == "1" ]; then echo hi; fi
hi
Then you see some people doing the same thing you do, exactly the same…
except that for some reason they changed the ==
for a -eq
. Weird right? I
mean, that it also works. So many ways to do the same thing.
$ if [ $foo -eq "1" ]; then echo hi; fi
hi
At that point its all good, until you stumble upon other “formats” of bash conditionals and if you are me, you start asking questions. How does this thing really work?
if test $foo -eq 1; then
echo hi
fi
if mountpoint /var/log; then
<do stuff if /var/log is actually a mountpoint, duh!>
fi
Of course, it is enough to see a couple of examples to get a feeling on what works and what doesn’t. But sometimes is not all that clear.
Everything is a command
So, we want to make sense of bash conditionals. The best place to start is, of course, the documentation. Let’s take a simplified version of what its on bash manual:
if list; then list; fi
The if list is executed. If its exit status is zero, the then list is executed.
Here list just means as a command or a group of commands; separated by |
,
||
, &&
or ;
. Note that it does not say anything about expressions or
conditions. The “condition” part of the if statement in bash is always
of the same type as the “execution” bit, and that is a command.
Bash particularly cares about the exit status of commands, and that is what it will use to determine if the then list of commands gets executed. We can start experimenting:
$ if true; then echo hi; fi
hi
$ if false; then echo hi; fi
Worth noting true
is a command that always succeeds (exit code 0), and
false
is a command that always fails (exit code 1).
We all have some day when we felt like unix false command:
false - do nothing, unsuccessfully
This explains why the mountpoint
conditional worked. It’s just a command
and its exit codes indicates whether or not the directory is a mountpoint,
exactly the kind of stuff our if structure wants.
However, what if we want to compare the output of a program? Or if we have a variable that we want to use as part of the condition? Well, as it is often the answer in Unix school, there is a tool that does exactly that, and only that.
Comparison command
Let’s suppose you want the very simple if $foo == 1
conditional to work;
but you only know the base structure of bash conditionals (i.e. that
everything is a command), and some C. You are very ingenious too.
You realize you could write a program in C that takes as inputs some values and operators and changes its exit code depending on the result of the comparison. You could then make a call to your program as part of the if list of commands: chose the proper arguments you are done. Let’s do that!
// compare.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(int argc, char* argv[]) {
// Check for number of arguments.
// We need have some exit code, even if it makes no sense
// because no comparison was made!
if (argc != 4) {
printf("You must provide exactly 3 arguments\n");
exit(1);
}
// The input command should be: compare elem1 op elem2
char* elem1 = argv[1];
char* op = argv[2];
char* elem2 = argv[3];
int cmp = strcmp(elem1, elem2);
if (strcmp(op, "==") == 0) {
return cmp;
}
printf("invalid operator\n");
exit(1);
}
That’s a simple silly program that takes three arguments, checks the middle
one is ==
and then just does a strcmp
between the other two arguments.
Easy but powerful enough to cover our very first use case.
$ export foo="foo"
$ export bar="bar"
$ if ./compare $bar == "foo"; then echo hi; fi
$ if ./compare $foo == "bar"; then echo hi; fi
hi
Note that the exit code somehow encodes both the result of the comparison,
and the success or failure of our program. The if statement can never
know if an exit code of 1
means “failure to execute” or “the comparison
turned out to be false”. It’s up to the person coding the comparison command
to define what’s the default value to output, things should go wrong.
Now we got the basic, we can extend this program however we want, adding more and more features: more input variables, logic operators, integration with filesystem to check for files, etc. However, we won’t have to, because it’s already done for us; or at least to a certain number of features.
The test
command
In the old times a command was developed to fulfill the very same requirement
that we explored with our compare
program. That command was a binary called
test
. Things have changed since then, but you can still find it in modern
distributions under /usr/bin/test
(try it! it’s part of the coreutils
package).
I know what you are thinking. Where is the source code?
Well, it can be found here. You are welcome.
So, let’s see how it works, shall we?
A quick look through its documentation shows us several valid expressions, and a million of flags to do several things. Just to list a couple of them:
STRING1 = STRING2 # the strings are equal
STRING1 != STRING2 # the strings are not equal
INTEGER1 -eq INTEGER2 # INTEGER1 is equal to INTEGER2
INTEGER1 -le INTEGER2 # INTEGER1 is less than or equal to INTEGER2
-e FILE # FILE exists
EXPRESSION1 -o EXPRESSION2 # either EXPRESSION1 or EXPRESSION2 is true
We have much more than we asked for: we can do logic and/or operations between expressions, compare strings and integers, and even check for several properties of a file. Keep in mind that this is still a regular binary, taking regular arguments and changing its return code.
Using the command we just introduced, we can rewrite our first example like
this. Of course we can always make sure the test
command is in our PATH
to avoid using the full path /usr/bin/test
.
$ export foo="foo"
$ export bar="bar"
$ if /usr/bin/test $bar = "foo"; then echo hi; fi
$ if /usr/bin/test $foo = "bar"; then echo hi; fi
hi
Why is not every bash conditional wrote using /usr/bin/test
? What about the
[]
and [[]]
expressions that also seemed to work?
Well, some people back in the day decided that an if with a condition
enclosed in brackets looked better than one with the test command [citation
needed]. So they created another command, and called it [
.
No, it’s not a mistake, [
it’s the actual name of the command!
Go, ask your machine where is the [
command
and it will tell you it’s in /usr/bin/[
.
The [
command is exactly the same thing as the test
command, the only
difference is that [
always needs an extra argument at the end: the
matching closing ]
. If you check, they are even built from the same source
code! You can find this in test.c
(linked above, see? You are welcome again).
#if LBRACKET
# define PROGRAM_NAME "["
#else
# define PROGRAM_NAME "test"
#endif
That blew my mind the first time I heard of it. Anyway, that’s part of the
mystery solved. We can rewrite any condition we used with test
, to an
equivalent with [
and it’ll do the same thing.
Of course, using the whole path /usr/bin[
kinda defeats the purpose.
Fortunately these commands are likely placed in your path already. This
leaves us with:
$ export foo="foo"
$ export bar="bar"
$ if [ $bar = "foo" ]; then echo hi; fi
$ if [ $foo = "bar" ]; then echo hi; fi
hi
A funny note on this [
command is that it allways requires you to add
the ]
as the last argument. And it has to be a separate argument. If not
you will get the infamous missing ']'
error, even if the character is
there!
$ if [ $foo == foo]; then echo hi; fi
-bash: [: missing `]'
When you think about it as arguments for a command, its crystal clear.
The built-in apotheosis
Those commands were being used a lot in shell scripting, so at some point
the shells themselves started porting the logic of test
command as
builtins. A shell builtin is just a command that is part of the shell
binary. It’s more portable because you don’t depend on extra programs and
it’s more performant because it does not need a new process to be executed.
Bash included both builtins test
and [
. The code probably drifted
slightly, but the functionality is similar for the most part. You can see,
under the test
command man pages the following note:
NOTE: your shell may have its own version of test and/or [, which usually
supersedes the version described here. Please refer to your shell's
documentation for details about the options it supports.
That has been true for bash since a while now. When you run test
or
[
, it will actually run the builtin before even checking for the command.
You can still use the coreutils version specifying the full path.
The improved “test command”
So, we’ve come a long way already. We now understand most of bash conditional
expressions, but there is one thing left: the double brackets [[]]
.
At this point I though just what you are thinking: they must be another command that was promoted to builtin. In a way that is right, but technically it’s wrong. Let me explain what I mean.
Double brackets are a first class citizens among bash syntax: [[
is not a command, it is not a builtin but it is a keyword. As such it has a special meaning inside bash, and has it’s own rule for parsing.
You can use the
type
builtin to check how bash will interpret a particular string:$ type [[ [[ is a shell keyword $ type [ [ is a shell builtin $ type test test is a shell builtin $ type type type is a shell builtin
This has no practical use at all, but its a nice thing to know, right?
Bash interprets expressions starting with [[
as compound commands. You
can get all the details in the manual pages, but in short it means that
patters with the format [[ EXPRESSION ]]
are parsed as a whole unit by bash
itself: it just takes the whole thing, evaluates it as a compound command and
makes it have an exit code depending on the result of the evaluation.
[[ expression ]]
Return a status of 0 or 1 depending on the evaluation of the
conditional expression expression.
So, now we should ask… what are the rules for that evaluation?
Well, the rules are called conditional expressions and are roughly the same
as the ones that we already covered for test
and [
builtins. This means
the format of what goes inside can be the same as for those commands. That
was exciting… Where is the catch? Why not add another alias to the test
command?
The power of the parser
Yes, the general flavor and format of the compound command [[
is almost
cloned from it’s predecessors. Mostly to avoid driving shell script
programmers crazy with a new way of doing their ifs. But, the interesting
part is in the subtle differences between simple commands and compound
commands
Having [[
integrated so deeply in bash shell as to become part of it’s very own
syntax means a couple of things.
We are no longer tied to the command syntax (which is also followed by builtins). You know, the good old “sequence of blank-separated arguments and control operators, where the first element indicates the command to run”.
Characters with special meaning inside a command (such as |
, >
or &&
)
had to be escaped to be escaped in test
and [
in order to avoid calling
specific features of the shell, like a redirection. Ever wondered why the
-a
to and two conditions in test
? Now you know why.
In a compound command rules change, meaning of symbols can be redefined by
the shell. We can use &&
and ||
for logical operations (-a
and -o
are
no longer supported). We can also use parenthesis for grouping and other
operators (like <
for “less than”) without having to escape them.
Compound commands in bash are part of the syntax, they are able to redefine the meaning of symbols
Another key difference is that we no longer have to worry about empty variables and wrong substitutions. Essentially quotes are needed whenever we want bash to pass the command and empty argument if the variable is empty or undefined. If we don’t quote, the variable is omitted, as if it had never existed.
The following example illustrates these points, and shows how the same expression is much more clear written with the “improved test” compound command.
# Parenthesis and , sign need to be escaped
# -a and -o as logical operators
# Quoting is needed if variables can be empty
[ \( "$foo" -eq 1 -a "$bar" -eq 2 \) -o 3 \< 2 ]
# Quoting is not needed
# This will work even if foo or bar are empty!
[[ ( $foo == 1 && $bar == 2 ) || 3 < 2 ]]
As a compound command, [[
is not tied to the same rules for substitution.
In fact, it performs substitutions in a more friendly and orderly way. This
allow anther nice feature: short circuiting. With builtins this cannot be
done, simply because all substitution and variable expansions have to be
performed before calling the builtin. However, [[
is part of the shell
syntax, and it does short circuiting!
# Second part of the -o could be ommited given that whole expression
# will always be true regardless
[ 1 -eq 1 -o $(touch oops; echo 1) -eq 1 ]
# Same here
[[ 1 == 1 || $(touch oops; echo 1) == 1 ]]
You can try it on your own, check both of them and see if any generates the
oops
file. You will see that the [[
one performs a more clever way of
evaluating the expression, and by doing so it can short circuit when possible.
Another big difference lies in error handling. Remember the quirk commands had when dealing with errors? A command has only its exit code to output both the result of the comparison and tell us if an error had happened; there is no way around that.
Making [[
part of shell syntax gives us more power on how
to handle errors. Now bash itself can halt the whole thing up if an error
happens while traversing the compound command, just the same as it would
stop a command from executing if a bad substitution happened.
Take the -d
flag, as an example. It checks if a particular file is a
directory. Obviously, it always need a parameter (i.e. the file to check),
but look what happens when we omit that value with [
and with [[
.
# No error, it just evaluates to false
$ [ -d ]
$ [[ -d ]]
-bash: unexpected argument `]]' to conditional unary operator
-bash: syntax error near `]]
With [[
bash shows us an error! The error es caught at parsing time, and
would have prevented the if from running, the same way a command not found
or misspelling of the then
keyword would.
Those are the main differences and advantages [[
has over [
, but there is
one more thing worth noting. [[
is part of bash, other shells (such as zsh)
also understand the keyword, but they may have slightly different syntax or
features. The point is there is no standard for [[
.
The old [
(and test
) both have a subset of features that are standardized
by POSIX, and most shells (even old ones like sh
) will have that subset for
sure. And even if they don’t the same standard also applies to the commands
(i.e. the standalone version of test
). For that reason, when writing a
script that has to work with the old world, or met high compatibility
standards,it’s not advised to use [[
and [
is preferred.
Putting it all together
Hopefully this article has given you some insight on bash conditionals, and you can now tell the difference between the different expressions and convert the same if statement from one type into the other. Of course you don’t have to know all these things by heart, but having the intuition of the different types will help you troubleshoot issues with them in the future.
When in doubt, always refer to the man pages
, and start dummy testing to
get an intuition for what works and what doesn’t.
Finally, I’m inclined to leave some expressions that may seem counter intuitive at first, but try to analyze them and see if you can tell what they are going to evaluate to. Of course, run them later to check if you were right.
Thank you for making it to the end, and happy bashing!
# Here is a compilation of some useless and counter-intuitive examples of bash
# conditionals. The objective is to decide whether the condition evaluates to
# "true" (exit code 0), or false (exit code 1).
0. test "This sentence is false"
1. [ false ]
2. [[ false ]]
3. [ true; ]
4. [[ true; ]]
5. [ $(true) ]
6. [[ $(false) ]]
7. [[ $(true) ]]
8. [[ $(echo false) ]]
9. [[ $(echo false && false) ]]
10. $(echo false)
11. $(echo false && false)
12. $(echo true)
13. $(echo true && true)
14. $(echo false && true)
15. $(echo hi && true)
16. $(echo true) && false
17. [[ $(test a = a) || $(test b = b) ]]
18. $([[ a == a ]])
19. false | true
# Solution
MC4gdHJ1ZQoxLiB0cnVlCjIuIHRydWUKMy4gZXJyb3IsIHRoZSAnOycgYnJlYWtzIHRoZSB0ZXN0
IGNvbW1hbmQKNC4gZXJyb3IsIHRoZSAnOycgYnJlYWtzIHRoZSBjb21wb3VuZCBjb21tYW5kCjUu
IGZhbHNlCjYuIGZhbHNlCjcuIGZhbHNlCjguIHRydWUKOS4gdHJ1ZQoxMC4gZmFsc2UKMTEuIGZh
bHNlCjEyLiB0cnVlCjEzLiB0cnVlCjE0LiBmYWxzZQoxNS4gQ29tbWFuZCAnaGknIG5vdCBmb3Vu
ZAoxNi4gZmFsc2UKMTcuIGZhbHNlCjE4LiB0cnVlCjE5LiB0cnVlCg==