Programming Bash #3: Logical Operators

0

Last Updated on January 21, 2024 by David Both

In this series of articles we are looking at Bash as a programming language. In the second article, we explored some simple command line programming with Bash. We explored using variables and control operators. In this article we explore the five types of Bash file, string, numeric, and miscellaneous logical operators that provide execution flow control logic; different types of shell expansions.

Logical operators are the basis for making decisions in a program and executing different sets of instructions based on those decisions. This is sometimes called flow control.

Preparation

Before proceeding we need to create some test directories and files that will be used in this article. You should do this on a VM, a non-production computer, or a non-production account on your workstation. Make your home directory the PWD and execute the following instructions as a non-root user.

[dboth@testvm1 ~]$ for I in 0 1 2 3 4 5 6 7 8 9 ; do echo "This is testfile$I" > testfile$I.txt ; done
[dboth@testvm1 ~]$ mkdir -p ./testdir1/testdir2/testdir3/testdir4/testdir5 testdir6 testdir7
[dboth@testvm1 ~]$ tree
[dboth@testvm1 ~]$ cd testdir1
[dboth@testvm1 ~]$ for X in dmesg.txt dmesg1.txt dmesg2.txt dmesg3.txt dmesg4.txt ; do dmesg > $X ; done
[dboth@testvm1 testdir1]$ touch newfile.txt
[dboth@testvm1 testdir1]$ df -h > diskusage.txt
[dboth@testvm1 testdir1]$ for I in 0 1 2 3 4 5 6 7 8 9 ; do dmesg > file$I.txt ; done

The -p option for the mkdir command creates the entire specified directory hierarchy if any of them don’t exist. This prevents having to create each directory in the hierarchy individually. I use this fairly frequently and it is a great time-saver.

This now gives us a few files and directories to work with.

Logical operators

Bash has a large set of logical operators that can be used in conditional expressions. The most basic form of the if control structure is to test for a condition and then execute a list of program statements if the condition is true. There are three types of operators, file, numeric and non-numeric operators. Each operator returns true (0) as its return code if the condition is met and false (1) if the condition is not met.

The functional syntax of these comparison operators is one or two arguments with an operator that are placed within square braces. Then a list of program statements that are executed if the condition is true.

if [ arg1 operator arg2 ] ; then list

Or like this with an optional list of program statements if the condition is false.

if [ arg1 operator arg2 ] ; then list ; else list ; fi

The spaces in the comparison are required as shown. The single square braces, [ and ], are the traditional Bash symbols that are equivalent to the test command.

if test arg1 operator arg2 ; then list

There is also a more recent syntax that offers a few advantages and which some SysAdmins prefer. This format is a bit less compatible with different versions of Bash and other shells such as ksh (Korn shell).

if [[ arg1 operator arg2 ]] ; then list

File operators

One powerful set of logical operators that are part of Bash are the file operators. Figure 1 lists more than 20 different operators that can be performed on files by Bash. I use thise quite frequently in my scripts to determine the status of various attributes belonging to a file.

OperatorDescription
-a filenameTrue if the file exists. It can be empty or have some content but so long as it exists this will be true.
-b filenameTrue if file exists and is a block special file such as a hard drive like /dev/sda or /dev/sda1.
-c filenameTrue if the file exists and is a character special file such as a TTY device like /dev/TTY1.
-d filenameThis is true if the file exists and is a directory.
-e filenameTrue if file exists. This is the same as -a, above.
-f filenameTrue if the file exists and is a regular file as opposed to a directory a device special file or a link, among others.
-g filenameTrue if the file exists and is set-group-id, SETGID.
-h filenameThis is true if file exists and is a symbolic link.
-k filenameTrue if file exists and its “sticky” bit is set.
-p filenameTrue if file exists and is a named pipe (FIFO).
-r filenameTrue if file exists and is readable, i.e., has its read bit set.
-s filenameTrue if file exists and has a size greater than zero. A file that exists but that has a size of zero will return false.
-t fdTrue if the file descriptor fd is open and refers to a terminal.
-u filenameTrue if file exists and its set-user-id bit is set.
-w filenameTrue if file exists and is writable.
-x filenameTrue if file exists and is executable.
-G filenameTrue if file exists and is owned by the effective group id.
-L filenameTrue if file exists and is a symbolic link.
-N filenameTrue if file exists and has been modified since it was last read.
-O filenameTrue if file exists and is owned by the effective user id.
-S filenameTrue if file exists and is a socket.
file1 -ef file2True if file1 and file2 refer to the same device and iNode numbers.
file1 -nt file2True if file1 is newer (according to modification date) than file2, or if file1 exists and file2 does not.
file1 -ot file2True if file1 is older than file2, or if file2 exists and file1 does not.
Figure 1: The Bash file operators.

Let’s look at some examples. We will start by testing for the existence of a file.

[dboth@testvm1 testdir1]$ File="TestFile1" ; if [ -e $File ] ; then echo "The file $File exists." ; else echo "The file $File does not exist." ; fi
The file TestFile1 does not exist.
[dboth@testvm1 testdir1]$

Create a file for testing named TestFile1. For now it does not need to contain any data.

[david@testvm1 testdir1]$ touch TestFile1

Note how easy it is to change the value of the $File variable rather than a text string for the file name in multiple locations in this short CLI program.

[dboth@testvm1 testdir1]$ File="TestFile1" ; if [ -e $File ] ; then echo "The file $File exists." ; else echo "The file $File does not exist." ; fi
The file TestFile1 exists.
[dboth@testvm1 testdir1]$ 

Now let’s determine whether a file exists and has a non-zero length which means it contains data. We have three conditions we want to test for so we need a more complex set of tests. The three conditions are; 1, the file does not exist; 2, the file exists and is empty; and 3, the file exists and contains data. To accomplish this we need to use the elif stanza in the if-elif-else construct to test for all of the conditions.

Because these logic constructs can become complex I find it helpful to build them up one test at a time. In the following examples we will work up to testing for all three conditions. Let’s start by testing to see if the file exists and it contains data.

[david@testvm1 testdir1]$ File="TestFile1" ; if [ -s $File ] ; then echo "$File exists and contains data." ; fi
[david@testvm1 testdir1]$

That works but it is only truly accurate for one specific condition out of the three possible ones we have identified. Let’s add an else stanza so we can be somewhat more accurate, and delete the file so that we can fully test this new code.

[david@testvm1 testdir1]$ File="TestFile1" ; rm $File ; if [ -s $File ] ; then echo "$File exists and contains data." ; else echo "$File does not exist or is empty." ; fi
TestFile1 does not exist or is empty.

Finally, let’s add some content to the file. and test again.

[david@testvm1 testdir1]$ File="TestFile1" ; echo "This is file $File" > $File ; if [ -s $File ] ; then echo "$File exists and contains data." ; else echo "$File does not exist or is empty." ; fi
TestFile1 exists and contains data.

Delete the file so we start this next program with a file that doesn’t exist.

[david@testvm1 testdir1]$ File="TestFile1" ; rm $File

Now we add the elif stanza to discriminate between a file that does not exist and one that is empty.

[david@testvm1 testdir1]$ File="TestFile1" ; touch $File ; if [ -s $File ] ; then echo "$File exists and contains data." ; elif [ -e $File ] ; then echo "$File exists and is empty." ; else echo "$File does not exist." ; fi 
TestFile1 exists and is empty.

Finally add some data to the file.

[david@testvm1 testdir1]$ File="TestFile1" ; echo "This is $File" > $File ; if [ -s $File ] ; then echo "$File exists and contains data." ; elif [ -e $File ] ; then echo "$File exists and is empty." ; else echo "$File does not exist." ; fi
TestFile1 exists and contains data.
[david@testvm1 ~]$

Now we have a Bash CLI program that can test for these three different conditions and the possibilities are endless. Experiment with this some more to re-verify the code with all three conditions for the file.

It is easier to see the logic structure of the more complex compound commands if we arrange the program statements more like we would in a script that we saved in a file. Figure 2 shows how this would look. The indents of the program statements in each stanza of the if-elif-else structure help to clarify the logic.

File="TestFile1"
echo "This is $File" > $File
if [ -s $File ]
then
   echo "$File exists and contains data."
elif [ -e $File ]
then
   echo "$File exists and is empty."
else
   echo "$File does not exist."
fi

Figure 2: The command line program rewritten as it would appear in a script.

Logic of some complexity becomes too lengthy for most CLI programs. Although any Linux or Bash built-in commands may be used in CLI programs, as the CLI programs get longer and more complex it makes sense to create a script that is stored in a file and which can be executed at any time now or in the future. We’ll get to that in the next article of this series. But for now, we have more logic operators to explore.

String comparison operators

String comparison operators enable comparison of alphanumeric strings of characters. There are only a few of these operators which are listed in Figure 3.

OperatorDescription
-z stringTrue if the length of string is zero.
-n stringTrue if the length of string is non-zero.
string1 == string2 or string1 = string2True if the strings are equal. A single = should be used with the test command for POSIX conformance. When used with the [[ command, this performs pattern matching as described above (Compound Commands)
string1 != string2True if the strings are not equal.
string1 < string2True if string1 sorts before string2 lexicographically1.
string1 > string2True if string1 sorts after string2 lexicographically.
Figure 3: Bash string logical operators.

First, let’s look at string length. Notice that the quotes around $MyVar in the comparison itself must be there for the comparison to work. We are still working in the home directory.

[david@testvm1 testdir1]$ MyVar="" ; if [ -z "" ] ; then echo "MyVar is zero length." ; else echo "MyVar contains data" ; fi
MyVar is zero length.
[david@testvm1 testdir1]$ MyVar="Random text" ; if [ -z "" ] ; then echo "MyVar is zero length." ; else echo "MyVar contains data" ; fi
MyVar is zero length.

We could also do it this way.

[david@testvm1 testdir1]$ MyVar="Random text" ; if [ -n "$MyVar" ] ; then echo "MyVar contains data." ; else echo "MyVar is zero length" ; fi
MyVar contains data.
[david@testvm1 testdir1]$ MyVar="" ; if [ -n "$MyVar" ] ; then echo "MyVar contains data." ; else echo "MyVar is zero length" ; fi
MyVar is zero length

Since we are already talking about strings and whether they are zero length or more than zero, it might make sense that we sometimes need to know the exact length. Although this is not a comparison it is related to them. Unfortunately there is no simple way to determine the length of a string. There are a couple ways to do this but I think using the expr (evaluate expression) is the easiest. Read the man page for expr for more of what it can do. Quotes are required around the string or variable being tested.

[david@testvm1 testdir1]$ MyVar="" ; expr length "$MyVar"
0
[david@testvm1 testdir1]$ MyVar="How long is this?" ; expr length "$MyVar"
17
[david@testvm1 testdir1]$ expr length "We can also find the length of a literal string as well as a variable."
70

Back to our comparison operators. I use a lot of testing in my scripts to determine whether two strings are equal, that is, identical. I use the non-POSIX version of this comparison operator

[david@testvm1 testdir1]$ Var1="Hello World" ; Var2="Hello World" ; if [ "$Var1" == "$Var2" ] ; then echo "Var1 matches Var2" ; else echo "Var1 and Var2 do not match." ; fi
Var1 matches Var2
[david@testvm1 testdir1]$ Var1="Hello World" ; Var2="Hello world" ; if [ "$Var1" == "$Var2" ] ; then echo "Var1 matches Var2" ; else echo "Var1 and Var2 do not match." ; fi
Var1 and Var2 do not match.

You can experiment some more on your own to try out these operators.

Numeric comparison operators

These numeric operators make comparisons between two numeric arguments. Like the other operator classes, most of these are easy to understand.

OperatorDescription
arg1 -eq arg2True if arg1 equals arg2.
arg1 -ne arg2True if arg1 is not equal to arg2.
arg1 -lt arg2True if arg1 is less than arg2.
arg1 -le arg2True if arg1 is less than or equal to arg2.
arg1 -gt arg2True if arg1 is greater than arg2.
arg1 -ge arg2True if arg1 is greater than or equal to arg2.
Figure 4: Bash numeric comparison logical operators.

Here are some simple examples. In the first instance we set the variable $X to 1 and then test to see if $X is equal to 1. In the second instance, X is set to 0 so the comparison is not true.

[david@testvm1 testdir1]$ X=1 ; if [ $X -eq 1 ] ; then echo "X equals 1" ; else echo "X does not equal 1" ; fi
X equals 1
[david@testvm1 testdir1]$ X=0 ; if [ $X -eq 1 ] ; then echo "X equals 1" ; else echo "X does not equal 1" ; fi
X does not equal 1

Try some more experiments on your own.

Miscellaneous operators

These miscellaneous operators allow us to see if a shell option is set or a shell variable has a value but it does not discover the value of the variable, just whether it has one.

OperatorDescription
-o optnameTrue if the shell option optname is enabled. See the list of options under the description of the -o option to the Bash set builtin in the Bash man page.
-v varnameTrue if the shell variable varname is set (has been assigned a value).
-R varnameTrue if the shell variable varname is set and is a name reference.
Figure 5: Miscellaneous Bash logical operators.

You can also experiment on your own to try out these operators.

Expansions

Bash supports a number of types of expansions and substitutions which can be quite useful. According to the Bash man page, Bash has seven (7) forms of expansions. We will look at tilde (testdir) expansion, arithmetic expansion, and pathname expansion. Unofficially I also consider the dash (-) to be the 8th form of expansion.

Brace expansion

Brace expansion is a method to use for generating arbitrary strings. Let’s start with brace expansion because we will use this tool to create a large number of files to use in experiments with special pattern characters. Brace expansion can be used to generate lists of arbitrary strings and insert them into a specific location within an enclosing static string, or at either end of a static string. This may be hard to visualize so let’s just do it.

First let’s just see what a brace expansion does.

[david@testvm1 testdir1]$ echo {string1,string2,string3}
string1 string2 string3

Well, that is not very helpful, is it? But look what happens when we use it just a bit differently.

[david@testvm1 testdir1]$ echo "Hello "{David,Jen,Rikki,Jason}.
Hello David. Hello Jen. Hello Rikki. Hello Jason.

That looks like something we might be able to use because it can save a good deal of typing. Now try this.

[david@testvm1 testdir1]$ echo b{ed,olt,ar}s
beds bolts bars

I could go on but you get the idea. We’ll use expansions like this in Episode III

Tilde expansion

Arguably the most common expansion we run into is the tilde (~) expansion. When we use this in a command like cd ~/Documents, the Bash shell actually expands that shortcut to the full home directory of the user. The tilde uses the value in the $PWD environment variable but the tilde requires less typing than $PWD so all of us lazy sysadmins are very happy about that. The $PWD variable is always set to the home directory of the logged-in user.

[dboth@testvm1 ~]$ echo $PWD
/home/dboth
[dboth@testvm1 ~]$

Use these little Bash programs to explore tilde expansion.

[david@testvm1 testdir1]$ echo ~
/home/student
[david@testvm1 testdir1]$ echo ~/Documents
/home/student/Documents
[david@testvm1 testdir1]$ Var1=~/Documents ; echo $Var1 ; cd $Var1
/home/student/Documents
[david@testvm1 Documents]$

Dash expansion

The dash (-) is also a shortcut and expands to the value of the previous directory. It uses the value in the $OLDPWD environment variable. So let’s first look at that value and then use the dash to return to the previous directory.

[dboth@testvm1 Documents]$ echo $OLDPWD
/home/dboth/testdir1
[dboth@testvm1 Documents]$ cd -
/home/dboth/testdir1
[dboth@testvm1 testdir1]$

The dash (-) is also a shortcut and expands to the value of the previous directory. It uses the value in the $OLDPWD environment variable. So

Pathname expansion

Pathname expansion is fancy term for expansion of file globbing patterns using the characters ? and * into the full names of directories and files that match the pattern. File globbing means special pattern characters that allow us significant flexibility in matching file names, directories, and other strings when performing various actions. These special pattern characters allow matching single, multiple or specific characters in a string.

  • ? – Matches only one of any character in the specified location within the string.
  • * – Zero or more of any character in the specified location within the string.

In this case we apply this expansion to matching file and directory names. Let’s see how this works. Make your home directory the PWD and start with a plain listing. Of course the contents of my home directory will be different from yours.

[dboth@testvm1 testdir1]$ cd ; ls
Desktop        dmesg3.txt  file0.txt  file5.txt  Music        testdir1       testfile1.txt  testfile6.txt
development    dmesg4.txt  file1.txt  file6.txt  newfile.txt  testdir6       testfile2.txt  testfile7.txt
diskusage.txt  dmesg.txt   file2.txt  file7.txt  Pictures     testdir7       testfile3.txt  testfile8.txt
dmesg1.txt     Documents   file3.txt  file8.txt  Public       testfile0.txt  testfile4.txt  testfile9.txt
dmesg2.txt     Downloads   file4.txt  file9.txt  Templates    TestFile1      testfile5.txt  Videos
[dboth@testvm1 ~]$

Now list the directories that start with “te”.

[dboth@testvm1 ~]$ ls te*
testfile0.txt  testfile2.txt  testfile4.txt  testfile6.txt  testfile8.txt
testfile1.txt  testfile3.txt  testfile5.txt  testfile7.txt  testfile9.txt

testdir1:
diskusage.txt  dmesg2.txt  dmesg4.txt  file0.txt  file2.txt  file4.txt  file6.txt  file8.txt  newfile.txt
dmesg1.txt     dmesg3.txt  dmesg.txt   file1.txt  file3.txt  file5.txt  file7.txt  file9.txt  testdir2

testdir6:

testdir7:
[dboth@testvm1 ~]$

Well that did not do exactly what we want. It listed all the files and directories in the home directory as well as the contents of the directories that begin with “te”. To list only the current directory and its contents we can use the -d option.

[dboth@testvm1 ~]$ ls -d te*
testdir1  testdir7       testfile1.txt  testfile3.txt  testfile5.txt  testfile7.txt  testfile9.txt
testdir6  testfile0.txt  testfile2.txt  testfile4.txt  testfile6.txt  testfile8.txt
[dboth@testvm1 ~]$

So what happens here – in both cases – is that the Bash shell expands the te* pattern into the names of the two directories and all the files that match the pattern. Any files and directories that match the pattern are expanded to their full names and displayed.

Command substitution

Command substitution is a form of expansion. Command substitution is a tool that allows the STDOUT data stream of one command to be used as the argument of another command, for example, as a list of items to be processed in a loop. The Bash man page says, “Command substitution allows the output of a command to replace the command name.” I find that to be accurate, if a bit obtuse.

There are two forms of this substitution, `command` and $(command). In the older form using back tics (`), a backslash (\) used in the command retains its literal meaning. However when used in the newer parenthetical form, the backslash takes on its meaning as a special character. Note also that the parenthetical form uses only single parentheses to open and close the command statement.

I frequently use this capability in command line programs and scripts where the results of one command can be used as an argument for another command.

Let’s start with a very simple example using both forms of this expansion. Ensure that testdir1 is the PWD.

[dboth@testvm1 testdir1]$ echo "Todays date is `date`"
Todays date is Mon Nov 20 10:41:43 AM EST 2023
[dboth@testvm1 testdir1]$ echo "Todays date is $(date)"
Todays date is Mon Nov 20 10:41:59 AM EST 2023
[dboth@testvm1 testdir1]$

The sequence command can be used to generate a sequence of numbers for use in Bash scripts and command line programs. This is another of those tools that I use frequently. Let’s start with some simple examples.

[dboth@testvm1 testdir1]$ seq 25
1
2
3
4
5
6
7
8
9
10
<SNIP>
22
23
24
25
[dboth@testvm1 testdir1]$

This stream won’t sort well so the -w option to the seq utility adds leading zeros to the numbers generated to that they are all the same width, i.e., number of digits regardless of the value. This makes it easier to sort them in numeric sequence.

[dboth@testvm1 testdir1]$ seq -w 50
01
02
03
04
05
06
07
08
09
10
11
12
<SNIP>
47
48
49
50
[dboth@testvm1 testdir1]$

Now we can do something a bit more useful like create a large number of empty files for testing.

[david@testvm1 testdir1]$ for I in $(seq -w 5000) ; do touch file-$I ; done ; ls | column | less

In this usage, the statement seq -w 5000 generates a list of numbers from 1 to 5000. By using command substitution as part of the for statement, the list of numbers is used by the for statement to generate the numerical part of the file names. The column statement causes the output to be displayed in columns so that it can be viewed more easily by humans.

Arithmetic expansion

Bash does perform integer math but it is rather cumbersome to do so, as you will soon see. The syntax for arithmetic expansion is $((arithmetic-expression)) using double parentheses to open and close the expression. Arithmetic expansion works like command substitution in a shell program or script – the value calculated from the expression replaces the expression for further evaluation by the shell.

Once again we will start with something simple.

[dboth@testvm1 testdir1]$ echo $((1+1))
2
[dboth@testvm1 testdir1]$  Var1=5 ; Var2=7 ; Var3=$((Var1*Var2)) ; echo "Var 3 = $Var3"
Var 3 = 35
[dboth@testvm1 testdir1]$

The following division results in zero because the result would be a decimal value of less than one.

[dboth@testvm1 testdir1]$ Var1=5 ; Var2=7 ; Var3=$((Var1/Var2)) ; echo "Var 3 = $Var3"
Var 3 = 0
[dboth@testvm1 testdir1]$ 

Here is a simple calculation that I do in a script or CLI program that tells me how much total virtual memory I have in a Linux host. The free command does not provide that data.

[dboth@testvm1 testdir1]$ RAM=`free | grep ^Mem | awk '{print $2}'` ; Swap=`free | grep ^Swap | awk '{print $2}'` ; echo "RAM = $RAM and Swap = $Swap" ; echo "Total Virtual memory is $((RAM+Swap))" ;
RAM = 8117080 and Swap = 8116220
Total Virtual memory is 16233300
[dboth@testvm1 testdir1]$

Note that I used the ` character to delimit the sections of code that were used for command substitution. I use Bash arithmetic expansion mostly for checking system resource amounts in a script and then choosing a program execution path based on the result.

Summary

In this second installment of our series on Bash as a programming language we explore the five types of Bash file, string, numeric, and miscellaneous logical operators that provide execution flow control logic; different types of shell expansions.

In the third article of this series, we will explore the use of loops for performing various types of iterative operations.


Series Articles

This list contains links to all eight articles in this series about Bash.

  1. Programming Bash #1 – Introducing a New Series
  2. Programming Bash #2: Getting Started
  3. Programming Bash #3: Logical Operators
  4. Programming Bash #4: Using Loops
  5. Programming Bash #5: Automation with Scripts
  6. Programming Bash #6: Creating a template
  7. Programming Bash #7: Bash Program Needs Help
  8. Programming Bash #8: Initialization and sanity testing