Linux & Bash · Part I · Module 15 · Writing and Running Bash Scripts
Positional Parameters: $1, $@, $# and shift
A script that takes no input does one thing. A script that takes arguments is a tool. The mechanism is half a dozen special variables, and one of them has a quoting rule that is the difference between a wrapper that works and one that corrupts every path containing a space.
Intermediate18 min readLinuxPart IFundamentalsScripting
Part I · Module 15 · Chapter 15.4 · Positional Parameters
Chapter 15.3 listed arguments as one of the four things a script receives. This is them.
1. The Variables
$ ./args.sh alpha "two words" gamma
$0 = [./args.sh]
$# = 3
$1 = [alpha] $2 = [two words] $3 = [gamma]| Variable | Holds |
|---|---|
$0 | How the script was invoked |
$1, $2, $3, … | The arguments, in order |
$# | How many arguments there are |
"$@" | All of them, as separate words |
"$*" | All of them, joined into one |
Note that "two words" arrived as a single argument. The quotes were the caller's, consumed by their shell at Chapter 9.1's quote-removal stage — but the word stayed whole, and $2 holds all nine characters.
$0 is not counted in $#. Three arguments means $# is 3 and the script's own name is separate.
2. The Three Forms, and Only One Is Right
This is the chapter's critical content. A script that passes its arguments to another command has three ways to spell it:
$ ./at.sh alpha "two words" gamma
passing "$@":
received 3 arguments
[alpha]
[two words]
[gamma]
passing $@ :
received 4 arguments
[alpha]
[two]
[words]
[gamma]
passing "$*":
received 1 arguments
[alpha two words gamma]Three, four and one. Same input, three different results.
#!/bin/bash
# a wrapper that adds one flag and passes everything else through
exec ./real_tool --verbose "$@"That is the whole shape of a wrapper script, and $@ without its quotes would break it on the first path containing a space.
When "$*" is right
echo "running with: $*" # a message for a person
log "invoked as $0 $*""$*" joins the arguments with the first character of IFS — a space by default. It is for describing the arguments, never for passing them. A log line is the main use.
With no arguments at all
$ ./args.sh
$# = 0
"$@" one per line:
[]One empty line, from printf '[%s]\n' "$@" with nothing to print — because printf runs its format once even when given no arguments. "$@" itself expanded to nothing, which is correct: zero arguments produce zero words, not one empty one.
That distinction matters when passing through: cmd "$@" with no arguments runs cmd with none, which is what you want. cmd "$*" would run it with one empty argument.
3. Counting and Checking
if [ "$#" -lt 1 ]; then
echo "usage: $0 <test-name> [seed]" >&2
exit 2
fi$# is how a script checks it was called properly, and a usage message with a documented exit code is the minimum interface (Chapter 15.6).
The shorter form, from Chapter 8.2:
test_name="${1:?usage: $0 <test-name> [seed]}"
seed="${2:-0}":? for required, :- for optional with a default. Two lines, no if, and the error names what is missing. It is the form to reach for when the script takes one or two arguments.
4. shift
$ ./sh.sh a b c d
before: $#=4 $1=[a]
after 1 shift: $#=3 $1=[b]
after shift 2: $#=1 $1=[d]
shift past the end FAILS (status 1)shift discards $1 and moves everything down, so $2 becomes $1 and $# decreases. shift N does it N times.
It is how a script consumes a leading argument and passes the rest on:
#!/bin/bash
# usage: wrapper.sh <mode> [args to pass through...]
mode="${1:?usage: $0 <mode> [args...]}"
shift
case "$mode" in
fast) exec ./real_tool --quick "$@" ;;
full) exec ./real_tool --thorough "$@" ;;
*) echo "unknown mode: $mode" >&2; exit 2 ;;
esacAfter shift, "$@" is everything except the mode. That pattern — take one, pass the rest — is most of what shift is for. (case is Module 16.)
Shifting past the end fails with status 1 rather than erroring, which is occasionally useful as a loop condition and is mostly a thing to know so it does not surprise you.
5. Looping Over Arguments
for arg in "$@"; do
echo "processing $arg"
donefor arg in "$@" is the safe loop, and the quotes are the same rule as everywhere else. for arg in $@ splits each argument again.
A useful shorthand: for arg; do with no in clause iterates over "$@" implicitly, and is impossible to get wrong because there is nothing to quote:
for arg; do
echo "processing $arg"
done(for is Chapter 17.1; this one form is worth having now.)
The shift-based alternative, for when you need to consume arguments as you go:
while [ "$#" -gt 0 ]; do
echo "handling $1"
shift
done6. Replacing the Arguments
$ set -- x "y z"
$ printf '[%s]\n' "$@"
[x]
[y z]
$ echo "count=$#"
count=2set -- replaces the positional parameters. The -- is required so that an argument starting with a hyphen is not read as an option (Chapter 9.6's -- rule).
Two real uses:
Setting defaults when none were given:
[ "$#" -eq 0 ] && set -- "$(cat default_tests.txt)"Splitting a string into arguments, deliberately:
IFS=, read -r a b c <<< "$csv_line" # usually better — Chapter 8.6
set -- $unquoted_flag_string # deliberate splitting — Chapter 9.2That second one is Chapter 9.2's legitimate unquoted expansion, and like all of them it deserves a comment saying it is intentional.
7. Where Flags Belong
Everything above is positional — the meaning comes from the order. That works for one or two arguments and stops working beyond that.
./run_sim.sh alu 7 fast /tmp/out yesNobody can read that, and adding a sixth means changing every caller.
./run_sim.sh --test alu --seed 7 --mode fast --out /tmp/out --verboseNamed options are self-describing, order-independent and extensible. Bash has getopts for parsing them, and it is Chapter 21.2's subject.
8. A Wrapper Script
The shape that uses everything in this chapter:
#!/usr/bin/env bash
#
# sim — run one simulation, with sensible defaults.
#
# usage: sim <test-name> [seed]
# sim --help
#
# exit: 0 passed · 1 failed · 2 bad usage · 3 simulator not found
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
usage() {
sed -n '3,8p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
# --help before anything else, so it works even when nothing is configured.
if [ "$#" -ge 1 ] && [ "$1" = "--help" ]; then
usage
exit 0
fi
test_name="${1:?usage: $0 <test-name> [seed]}"
seed="${2:-0}"
command -v vcs >/dev/null 2>&1 || { echo "vcs not on PATH" >&2; exit 3; }
echo "test: $test_name seed: $seed"
exec "$script_dir/run_one.sh" --name "$test_name" --seed "$seed"Five things worth naming:
--helpis checked before the required argument, so asking for help does not produce a usage error.[ "$#" -ge 1 ] &&guards the$1test, so it is safe with no arguments even underset -u.usagereads the comment block out of the script itself, so the documentation cannot drift from the help text.${1:?}and${2:-0}give the required-and-optional pair in two lines.execreplaces the script's process with the real tool, so there is no extra shell waiting around and the tool's exit status becomes the script's directly.
Practice
mkdir -p /tmp/pp && cd /tmp/pp1 — See the basic variables.
cat > args.sh <<'EOF'
#!/bin/bash
echo " \$0=[$0] \$#=$#"
echo " \$1=[$1] \$2=[$2] \$3=[${3-unset}]"
EOF
chmod +x args.sh
./args.sh alpha "two words" gamma
./args.sh2 — Prove the three forms differ.
cat > at.sh <<'EOF'
#!/bin/bash
f() { echo " $# arguments:"; printf ' [%s]\n' "$@"; }
echo ' "$@":'; f "$@"
echo ' $@ :'; f $@
echo ' "$*":'; f "$*"
EOF
chmod +x at.sh
./at.sh alpha "two words" gammaThree, four and one.
3 — Meet the tenth argument.
printf '#!/bin/bash\necho " \\$10=[$10] \\${10}=[${10}]"\n' > ten.sh
chmod +x ten.sh
./ten.sh a b c d e f g h i j4 — Use shift.
cat > sh.sh <<'EOF'
#!/bin/bash
echo " start: \$#=$# \$1=[$1]"
shift; echo " shift: \$#=$# \$1=[$1]"
shift 2; echo " shift2: \$#=$# \$1=[${1-none}]"
shift 99 2>/dev/null || echo " shift past the end: status $?"
EOF
chmod +x sh.sh
./sh.sh a b c d5 — Write a pass-through wrapper.
cat > wrap.sh <<'EOF'
#!/bin/bash
mode="${1:?usage: $0 <mode> [args...]}"
shift
echo " mode=$mode, passing $# arguments on:"
printf ' [%s]\n' "$@"
EOF
chmod +x wrap.sh
./wrap.sh fast alpha "two words"6 — Required and optional in two lines.
cat > req.sh <<'EOF'
#!/bin/bash
name="${1:?usage: $0 <name> [seed]}"
seed="${2:-0}"
echo " name=$name seed=$seed"
EOF
chmod +x req.sh
./req.sh alu
./req.sh alu 7
./req.sh; echo " status $?"7 — Loop two ways.
cat > loop.sh <<'EOF'
#!/bin/bash
for a in "$@"; do echo " explicit: [$a]"; done
for a; do echo " implicit: [$a]"; done
EOF
chmod +x loop.sh
./loop.sh one "two words"8 — Replace the arguments.
bash -c 'set -- x "y z"; echo "count=$#"; printf "[%s]\n" "$@"'9 — Clean up. cd /tmp && rm -rf /tmp/pp
Check Your Understanding
1. $# is 3. Does that include the script's name?
2. Give the argument count each of "$@", $@ and "$*" produces from alpha "two words" gamma, and say which to use.
3. $10 gives a0. What happened?
4. After shift, what is in $1 and what is $#?
5. When is "$*" the right choice?
6. Why does a wrapper's last line often use exec?
Tools have their own argument conventions on top of this one — Chapter 27.4 covers plusargs, where a misspelled name is accepted without complaint and the default runs instead.
9. Remember
$0is the invocation,$1…$9are the arguments,$#is how many — and$0is not counted.- Past nine, use braces.
$10is$1followed by a0, and it fails silently. "$@"is special-cased to expand to one word per argument, whatever they contain.- It is the only correct way to pass a set of arguments on.
$@splits them;"$*"joins them. "$*"is for describing arguments in a message, never for passing them.- With no arguments,
"$@"expands to nothing — zero words, not one empty one. ${1:?usage: …}and${2:-default}give required-and-optional in two lines, with noif.shiftdiscards$1and moves the rest down; shifting past the end returns 1.- Take one,
shift, pass"$@"on is the wrapper pattern. for arg; doiterates over"$@"implicitly and cannot be misquoted.set --replaces the arguments, and the--matters.- One or two arguments can be positional; three or more want named options and Chapter 21.2's
getopts. execat the end of a wrapper saves a process and forwards the status and signals for free.- A function gets its own set of these, and the caller's come back on return — Chapter 18.2 measures which ones, and the one that is not replaced.
A script can now be told what to do. The next chapter is about somebody else being able to read how.
Where this fits
Part of the Linux & Bash curriculum.
