Bash Patterns

Concepts I've found useful in bash

By Shawn Wilson

Audience

I assume some prior Unix command line experience

Reason

I keep running into "bash scripts" that look like old style shell scripts - capitalized variable names, tons of subshells piping all over the place, etc. This does not need to be the case - bash scripts can be written with some elegance

I hope this talk shows some mechanisms you can use to write nicer bash scripts

Bash Help (is not man)

While some help docs are incomplete, (besides the 3100 line man page) these are the best resources on bash (paid or free):
These documents are generally about a page long and do not go through a pager so may scroll.


              $ help help | head -4
              help: help [-dms] [pattern ...]
                  Display information about builtin commands.
                 
                  Displays brief summaries of builtin commands.  If PATTERN is
            

              $ help [ | head -4
              [: [ arg... ]
                  Evaluate conditional expression.
                 
                  This is a synonym for the "test" builtin, but the last argument must
            

              $ help [[ | head -4
              [[ ... ]]: [[ expression ]]
                  Execute conditional command.
                 
                  Returns a status of 0 or 1 depending on the evaluation of the conditional
            

              $ help declare | head -4
              declare: declare [-aAfFgilnrtux] [-p] [name[=value] ...]
                  Set variable values and attributes.
                 
                  Declare variables and give them attributes.  If no NAMEs are given,           

              $ help set | head -4
              set: set [-abefhkmnptuvxBCHP] [-o option-name] [--] [arg ...]
                  Set or unset values of shell options and positional parameters.
                 
                  Change the value of shell attributes and positional parameters, or           

              $ help enable | head -4
              enable: enable [-a] [-dnps] [-f filename] [name ...]
                  Enable and disable shell builtins.
                 
                  Enables and disables builtin shell commands.  Disabling allows you to           

              $ help hash | head -4
              hash: hash [-lr] [-p pathname] [-dt] [name ...]
                  Remember or display program locations.
                  
                  Determine and remember the full pathname of each command NAME.  If
            

Command precedence

Bash has an order of operations for when and where to look for stuff:

  1. Functions
    If you set a function in your scope - it is a first order citizen as long as it is set
  2. Aliases
    If you set an alias, you can get around it by putting a backslash (\) before the command
  3. Built-ins
    See the enable built-in (mentioned above)
  4. Hashed commands
    See the hash built-in (mentioned above)
  5. Any executable in your $PATH
    Bash goes down the list of directories until it finds a match

So, for instance the test operator ([) is actually a command as well as a built-in:


              $ type [
              [ is a shell builtin
              $ whereis [
              [: /usr/bin/[ /usr/share/man/man1/[.1.gz
            

Expansion and itteration

{0..5}

>

$(seq 0 5)

DON'T DO THIS


              for i in $(seq 0 5); do ...; done
            

EVER

But maybe just this one time?

NO

Maybe you're still listening ant want an alternative?


                for i in {0..5}; do ...; done
              

Now what is bash doing?

The for loop loops over "words" (which can be a bash array or the english definition of a "word" - space/tab/newline separated words)

The while loop uses read which has a bit more going on

In either case "words" can be combined into a word with quotes

Examples of the two commands:


              $ echo {0..3}
              0 1 2 3
              $ seq 0 3
              0
              1
              2
              3
            

With bash expansion, any command that accepts multiple parameters may be used for cheap fuzzing:


              dig computer{1..5}.example.com
            

But really, why stop there?


              $ echo foo{0..1}{0..1}
              foo00 foo01 foo10 foo11
            

And if you really want to fuzz stuff (I'm guessing there's a better way)


              for i in {0..255}; do eval "echo -n $'\x$i'"; done
            

But wait, there's more

Bash has a C-style for loop


              $ for ((i = 0; i <= 10; i+=2)); do echo -n "$i "; done; echo
              0 2 4 6 8 10
            

Which has the exact same functionality as:


              $ for i in $(seq 0 2 10); do echo -n "$i "; done; echo
              0 2 4 6 8 10 
            
But I had to refer to seq's antiquated documentation for the later

Math

Bash can handle anything that the expr command can:


            $ echo $((5 % 2))
            1
            $ echo $((5 / 2))
            2
            $ echo $((5**2))
            25
          

And then some:


            $ echo $(( (5**2) < 30 ))
            1           
          

Sanity checks

Wouldn't it be nice to know you don't have a dependency before you waste time running a script?

Bash has two commands that can do this:


              $ command -v [
              [
              $ echo $?
              0
              $ type [
              [ is a shell builtin
              $ echo $?
              0
            

There are differences, but the main one from a UX perspective is that command won't output to STDERR if the command doesn't exist:


              $ type blah >/dev/null 2>&1
              $ command -v blah >/dev/null
            

So for a sanity check, something like this works:


              if ! command -v printf >/dev/null 2>&1; then
                echo "We can not find the bash printf command" >&2
                exit 1
              fi
            

Since we'll probably want to die in multiple parts of the script, having a die() function (or similar) probably isn't a bad idea.

If you want an example, (along with a warn() and debug() function) see here

File IO

Reading config files

This is quite simple (and has security implications):


              source ~/.config/file >/dev/null 2>&1
            

Sourced files are executable - using them as config data is kind of a hack


              $ echo "echo foo" > sourced
              $ source ./sourced
              foo
            

Notes:

  • The config file should have the same level of trust as the script - this does what you expect:
    
                        foo=$(rm -rf /*)
                      
    Or just put the command in the file without the subshell
  • I prefer to redirect all output because I don't want that getting in the way of output I want from my script

Reading data files

To slurp a file into a variable (while and mapfile can also do similar):


              $ echo $(< foo.csv)
              foo,bar,baz aaa,bbb,ccc
              $ echo "$(< foo.csv)"
              foo,bar,baz
              aaa,bbb,ccc
            

Modifying input as it gets read:


              $ mapfile -t -c 1 -C 'echo $@' < foo.csv
              0 foo,bar,baz
              1 aaa,bbb,ccc
              $ while read line; do echo $line; done < foo.csv
              foo,bar,baz
              aaa,bbb,ccc
            
The mapfile command is pretty new (to bash), powerful, and somewhat arcane (I recommand not using it unless you have to)

Making sane defaults

Don't make users define every single variable you need in a configuration file (or command line option). There are two ways of doing this:

This is similar to the ||= or //= operator from other languages

This looks ugly (so try to avoid it)


              [[ -z foo ]] && foo="something"
            

This looks slightly cleaner


              : "${foo:="something"}"
            

Manipulating CSV

Grabbing the first column

From a file


              $ while IFS="," read -a data; do
                echo "${data[0]}"
              done < foo.csv
              foo
              aaa
            

Or directly from a string:


              $ while IFS="," read -a data; do
                echo "${data[0]}"
              done <<< "aaa,bbb,ccc"
              aaa
            

Variable manipulation

Bash has 5 variable types: string, integer, array, associative array (aka dictionary or hash), and reference. You normally only care about strings and arrays.

Though associative arrays can be useful when you know you will always run on a newer bash version

All variables may be defined with the respective switch to declare, or bash will determine the type based on how they are defined

Verbose declaration


              $ unset f; declare f=; declare -p f
              declare -- f=""
              $ unset f; declare -a f=; declare -p f
              declare -a f='([0]="")'
              $ unset f; declare -A f=; declare -p f
              declare -A f='([0]="" )'
            

Implied declation


              $ unset f; f=; declare -p f
              declare -- f=""
              $ unset f; f=(); declare -p f
              declare -a f='()'
            
It is not possible to imply an associative array - f='([A]="0" )' creates a string

Manipulating arrays is really powerful


              $ echo "${foo[@]/a/A}"
              Aaa bbb ccc
              $ echo "${foo[@]/a*/A}"
              A bbb ccc
              $ echo "${foo[@]/#a/A}"
              Aaa bbb ccc
            
This works on all elements - not just the first

Array manipulation for commands


              $ f=(foo bar baz)
              $ find -type f -name foo ${f[@]/#/-o -name }
            

Functions

Bash functions are better than most languages (they can be scoped and redefined on the fly - not as powerful as vimscript but pretty good)

An example of a function I have in my shell's rc file


              # long dig
              ldig () {
                dig +trace +nocmd "$@" any +multiline +answer
              }
            
You can't do this with an alias

Passing functions to other sessions


              $ _f () { whoami; }
              $ sudo bash -c "$(declare -f _f) && _f"
              root
              $ _f () { groups $1; }
              $ sudo bash -c "$(declare -f _f) && _f tuser"
              tuser : tuser
            

Passing arrays to functions is possible by reference


              $ func () { 
                echo "zero [${!0}]" \
                  "one [${!1}]" \
                  "two [${!2}]" \
                  "three [${!3}]";
              }
              $ func foo[@] i
              zero [] one [aaa bbb ccc] two [5] three []
            

Again, don't use subshells (they're expensive)

Don't do this


              func () {
                echo "something"
              }
              foo=$(func)
            

I use RET_<FUNC NAME> as a pattern for return variables or RET_<FUNC NAME>_<THING> if returning multiple variables


              declare F_RET=""
              f () {
                F_RET=""
                ......
              }
            
Note that declare -g doesn't work in older bash so it's easier to stay away from it and declare the global outside the function and make sure it's in a sane state within

And you can redefine functions on the fly


              f () {
                f {} {
                  echo "foo"
                }
                f $@
              }
            
Useful if you have a time consuming check and want to decide what a function should do based on that check - that function only gets redefined once

Debugging

Use set's x option


              f () {
                set -ex
                ....
                set +ex
              }
            

Note on -e (aka errexit) - it is an old POSIX atandard (since revised). Will exit when $? != 0


              $ unset i; i=1; (( i-- )); echo $?; (( i-- )); echo $?
              0
              1
              $ set -e
              $ unset i; i=1; (( i-- )); echo $?; (( i-- )); echo $?
              0
            
Maybe not the best for production?

And the same statement used to pass a function to another environment (subshell, sudo, ssh, etc) is quite useful here too


              $ declare -f echo
              echo ()
              {
                  command echo $@
              }
            

BONUS

File descriptor/handle

If a program wants a file handle and you want to give input instead


              echo "foo" | cat /dev/fd/0
            

Or another thing I include in my rc file (to tell ssh to ignore the config file)


              alias sshn="ssh -F /dev/null"
              alias scpn="scp -F /dev/null"
            

Shell compatability

Tell which shells some syntax works with


              for sh in dash mksh zsh ksh93 posh 'busybox sh' bash; do 
                $sh -c 'a=b b=2; echo $((a++)) $a $b'
              done
            
Used verbatum from the bug-bash mailing list

Profiling

The reason I say not to use subshells when at all possible


              $ time for i in {0..1000}; do unset f; f="$((5+2))"; done
              
              real    0m0.037s
              user    0m0.037s
              sys     0m0.000s
              $ time for i in {0..1000}; do unset f; f="$(expr 5 + 2)"; done
              
              real    0m5.219s
              user    0m1.582s
              sys     0m3.595s
            

Thank You

Shawn Wilson

Mail ag4ve.us@gmail.com
Github AG4VE
Twitter AG4VE
Facebook AG4VE
FCC AG4VE

Resources