Argument passing in Bash scripts
The subtleties of dollar-star `$*` vs dollar-at `$@`
Posted by Owen Stephens on November 7, 2019
My guess would be that most development teams have at least one or two Bash shell scripts that they use to avoid repetitive, error-prone tasks, or otherwise automate part of their day-to-day tasks. A common requirement is to pass arguments from one script/function/command to another. While in other languages, this is an unambiguous, simple task, in Bash, there are subtleties that it is important to be aware of.
Reading the manual, we can see that there are two
special parameters that expand to "positional parameters" (a.k.a arguments):
$*
and $@
. Judging by the length of the descriptions of theses parameters
(684 and 878 characters, respectively) we can assume there are some nuances,
and as we will see, there certainly are.
Recently here at Bamboo we wanted to write a Bash function that took optional
arguments and interpolated them into an invocation of another command
(in our case, curl
). Think of passing flags such as --dry-run --description 'this is a dry run'
to a command so the user can confirm the
output is as they expect before making a configuration change, without having
to duplicate the command (imagine if it already took 10 flags/arguments).
An argument-printing Ruby script
To help illustrate the problem, I'll be using a one-line Ruby script
that prints the count and contents of the arguments passed to it by using
ARGV
:
$ ruby -e 'p ARGV.size; p ARGV'
0
[]
$ ruby -e 'p ARGV.size; p ARGV' foo
1
["foo"]
$ ruby -e 'p ARGV.size; p ARGV' foo bar
2
["foo", "bar"]
$ ruby -e 'p ARGV.size; p ARGV' foo 'bar baz'
2
["foo", "bar baz"]
Notice that the 'bar baz'
argument is treated as a single argument
since we quoted it to avoid word splitting (a complicated related topic in its
own right, see the Bash manual or this informative
wiki for more).
Four Bash functions: demonstrating $*
, $@
, "$*"
and "$@"
Now, we set up functions that pass their arguments to our Ruby "argument printer":
dollar_star() {
ruby -e 'p ARGV.size; p ARGV' -- $*
}
dollar_at() {
ruby -e 'p ARGV.size; p ARGV' -- $@
}
dollar_star_quoted() {
ruby -e 'p ARGV.size; p ARGV' -- "$*"
}
dollar_at_quoted() {
ruby -e 'p ARGV.size; p ARGV' -- "$@"
}
N.B. that we're using --
to prevent the Ruby interpreter interpreting
flag-like arguments.
Calling the Bash functions
Calling these functions shows the differences in output. When unquoted, $*
and $@
both behave the same (badly, for our purposes!):
$ dollar_star --dry-run --flag 'with space'
4
["--dry-run", "--flag", "with", "space"]
$ dollar_at --dry-run --flag 'with space'
4
["--dry-run", "--flag", "with", "space"]
Notice how in both cases, we pass four arguments - our originally quoted 'with space'
argument has been split into two separate arguments. For "$*"
, we get
a different result:
$ quoted_dollar_star --dry-run --flag 'with space'
1
["--dry-run --flag with space"]
Now we are passing a single argument, which contains all the original arguments seemingly pasted together with spaces, which again, isn't what we want.
Finally, we see that "$@"
gives us the behaviour we desire - ensuring that
any whitespace within the original arguments is preserved:
$ quoted_dollar_at --dry-run --flag 'with space'
3
["--dry-run", "--flag", "with space"]
It's worth noting that my earlier "pasted together with spaces" description of
"$*"
isn't quite accurate, which we can demonstrate by setting a different
$IFS
variable value:
$ IFS='!' dollar_star_quoted --verbose --flag 'with space'
1
["--verbose!--flag!with space"]
With a modified $IFS
, we are still passing a single argument, but it is
formed of the three original arguments pasted together, separated by !
(the
first character of $IFS
) N.B. also that the space in the 'with space'
quoted argument has been preserved as we are now using a different
separator.
Summary
The different behaviours of two simple-looking variables can be a little bit
tricky to understand, and it's certainly worth reading the
manual carefully. For alternative further reading,
there are plenty of Stack Exchange
questions on this exact topic. The executive summary for our
original purpose (pass flags to another command, preserving quoted arguments)
is simple: just use "$@"
, but your requirements may differ.
Writing Bash scripts can be daunting vs say, Ruby. To help us avoid bugs and
issues in our Bash scripts, we use the excellent Shellcheck tool
to alert us to potential problems. Indeed, for the above example Bash functions
Shellcheck warns on both of the unquoted variables - for $*
SC2048
is triggered (which correctly suggests that we use "$@"
) and for $@
SC2068 (which warns us that our elements may be re-split, as we saw,
when we ended up with 4 instead of 3 arguments).