Linux & Bash · Part I · Module 18 · Functions and Script Decomposition
Arguments, and Why a Function Has Its Own $1
Those empty parentheses in a function definition are not a parameter list, so how does data get in? The same way it gets into any command: as words after the name. A function reads $1, $2 and $@ exactly as a script does — and because both use the same names, the one thing worth being precise about is which set you are looking at.
Intermediate19 min readLinuxPart IBashFunctionsPositional ParametersScripting
Part I · Module 18 · Chapter 18.2 · Arguments, and Why a Function Has Its Own $1
Chapter 15.4 taught $1, $@, $# and shift for scripts. A function uses the identical set, which is convenient and is also the only thing about function arguments that ever confuses anyone.
1. A Call Swaps the Parameters Out
#!/usr/bin/env bash
# s.sh
show() { echo " in fn: \$0=$0 \$1=$1 \$#=$# \$@=$@"; }
echo "script: \$0=$0 \$1=$1 \$#=$#"
show alpha beta gamma
echo "after: \$1=$1 \$#=$#"$ ./s.sh one "two three" four
script: $0=./s.sh $1=one $#=3
in fn: $0=./s.sh $1=alpha $#=3 $@=alpha beta gamma
after: $1=one $#=3Three lines, three things established.
Inside the function, $1 is the function's first argument. alpha, not one. The call replaced the positional parameters for its duration.
After the call, the caller's are back. $1 is one again. Nothing was destroyed; the function's set was pushed on top and popped off.
And $# happened to be 3 both times — a coincidence of this example worth pointing out, since the script took three arguments and the call passed three. They are entirely separate counts.
What a call replaces, and what it does not
2. The One That Is Not Replaced
$ cat >> s.sh <<'EOF'
whoami_fn() { echo " \$0=$0 FUNCNAME=$FUNCNAME"; }
whoami_fn
EOF
$ ./s.sh one two three
...
$0=./s.sh FUNCNAME=whoami_fn$0 inside a function is still the script. It is not the function's name and never becomes it. The function's own name lives in $FUNCNAME, which exists only inside a function.
That asymmetry is not arbitrary — $0 is the name the process was invoked as, and calling a function does not start a process. But it has a practical edge, because ${0##*/} is exactly how Chapter 15.5 taught you to write a usage message:
# in a function, this is RIGHT — it names the script, which is what a user ran
usage() { echo "usage: ${0##*/} <dir>" >&2; }
# and this is a bug waiting to happen
check() {
if [ $# -ne 2 ]; then
echo "usage: ${0##*/} <a> <b>" >&2 # names the SCRIPT's usage
return 2
fi
}The first is correct: a usage message is about the command the user typed, and $0 is that. The second is wrong in a subtler way — it is reporting a function's arity problem using the script's name, so a user who passes the right arguments to the script sees a usage line that does not match what they did. For an internal arity error, name the function:
check() {
if [ $# -ne 2 ]; then
printf '%s: %s: expected 2 arguments, got %d\n' "${0##*/}" "$FUNCNAME" "$#" >&2
return 2
fi
}$ check one
triage.sh: check: expected 2 arguments, got 1Script name for the user, function name for whoever is debugging. Both come from the right place.
3. Forwarding: "$@" and Nothing Else
A wrapper function has to pass its arguments onward, and Chapter 15.4's rule applies with no change:
fwd() { echo " fwd got $# args:"; printf ' [%s]\n' "$@"; }
fwd "$@" # quoted
fwd $@ # bare$ ./s.sh one "two three" four
--- forwarding "$@":
fwd got 3 args:
[one]
[two three]
[four]
--- forwarding bare $@ (broken):
fwd got 4 args:
[one]
[two]
[three]
[four]Three arguments became four. Bare $@ is subject to word-splitting, so two three arrived as two separate arguments and the receiving function has no way to know it was ever one.
"$@" is the only correct way to forward an argument list. Not $*, not "$*", not $@:
| Form | What the callee receives |
|---|---|
"$@" | each argument as a separate word, boundaries intact |
$@ | the arguments re-split on whitespace |
"$*" | one argument, all of them joined by a space |
$* | the joined string, then re-split |
The one legitimate use for "$*" is when you genuinely want a single string — which is exactly what a logging helper wants:
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }$ note could not read run.log
triage.sh: could not read run.log"$*" joins the words into one message. With "$@" the printf format would be reused per argument and you would get four separate lines. That is Chapter 15.4's distinction, and a logging function is the place where the usually-wrong one is right.
4. shift Is Local to the Call
sh2() { shift 2; echo " in fn after shift 2: \$#=$# \$1=$1"; }
sh2 a b c d
echo " caller still: \$#=$# \$1=$1" in fn after shift 2: $#=2 $1=c
caller still: $#=3 $1=oneshift inside a function shifts the function's parameters. The caller's are untouched, because they were never the ones in play. So the option-parsing loop from Chapter 16.6 works identically inside a function, which is how a wrapper takes its own flags:
run_test() {
local verbose=0
while [ $# -gt 0 ]; do
case $1 in
-v) verbose=1; shift ;;
--) shift; break ;;
-*) printf '%s: %s: unknown option: %s\n' "${0##*/}" "$FUNCNAME" "$1" >&2; return 2 ;;
*) break ;;
esac
done
[ $# -ge 1 ] || { printf '%s: %s: need a test name\n' "${0##*/}" "$FUNCNAME" >&2; return 2; }
...
}local is Chapter 18.5; everything else there you already have.
5. A Function Sees Only Its Own
Nesting makes the stack behaviour concrete:
$ outer() { echo " outer \$1=$1"; inner X; echo " outer \$1 still=$1"; }
$ inner() { echo " inner \$1=$1"; }
$ outer OUTERARG
outer $1=OUTERARG
inner $1=X
outer $1 still=OUTERARGinner cannot see OUTERARG and outer is unaffected by inner's call. Positional parameters are the one thing in Bash that is properly scoped per call — which is worth noting now because Chapter 18.5 shows that ordinary variables emphatically are not.
If a callee needs the caller's arguments, pass them:
outer() { inner "$@"; }And recursion works, because each call gets its own set:
$ fact() { if [ "$1" -le 1 ]; then echo 1; else echo $(( $1 * $(fact $(( $1 - 1 )) ) )); fi; }
$ fact 5
120That is a demonstration rather than a recommendation — each level forks a subshell for the command substitution, so it is slow and the recursion depth is bounded by FUNCNEST and your stack. For real arithmetic use a loop. The point is that $1 is per-call, and nothing else in this module will be.
6. Validate Arity, Because Nothing Else Will
A function called with too few arguments does not fail — it reads an empty string:
$ greet() { echo "hello $1"; }
$ greet
helloset -u does notice — this is worth measuring rather than assuming, because it is often stated the other way round:
$ bash -c 'set -u; greet() { echo "hello $1"; }; greet'
bash: $1: unbound variableSo set -u converts the silent empty string into an abort. That is an improvement and it is still not an arity check, for four reasons:
- It names a line, not a problem. In a script the message is
g.sh: line 3: $1: unbound variable— no function name, no statement of what was expected. - It fires only where you read the parameter, so a function that reads
$1on line 40 passes forty lines of setup first. - It says nothing about too many arguments.
derive_tb a b cis silently fine. - And
${1:-}disables it. Defensive scripts are full of${1:-}precisely to surviveset -u— at which point you are back to an empty string with no complaint at all.
The guard is explicit and belongs at the top of any function that takes arguments:
derive_tb() {
if [ $# -ne 1 ]; then
printf '%s: %s: expected 1 argument, got %d\n' "${0##*/}" "$FUNCNAME" "$#" >&2
return 2
fi
printf '%s_tb.sv\n' "${1%.sv}"
}$ derive_tb top.sv
top_tb.sv
$ derive_tb
triage.sh: derive_tb: expected 1 argument, got 0
$ echo $?
2For a two-line helper used once, this is overkill. For anything called from several places, or from a library other scripts source, it is the difference between an error that names itself and a mysterious empty string three functions away.
7. A Complete Script
A wrapper whose functions each take arguments, validate them, and forward correctly:
#!/usr/bin/env bash
# run-one.sh — run one test at one seed, with per-call options
# usage: run-one.sh [-v] <test> [seed]
#
# exit: 0 passed · 1 failed · 2 unusable arguments
set -o nounset
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
fatal() { note "$*"; exit 2; }
# arity error that names the FUNCTION, for whoever is debugging
arity() {
printf '%s: %s: expected %s, got %d\n' "${0##*/}" "${FUNCNAME[1]}" "$1" "$2" >&2
}
# derive_tb <source.sv> -> prints the testbench name
derive_tb() {
[ $# -eq 1 ] || { arity "1 argument" "$#"; return 2; }
printf '%s_tb.sv\n' "${1%.sv}"
}
# log_name <test> <seed> -> prints the log file name
log_name() {
[ $# -eq 2 ] || { arity "2 arguments" "$#"; return 2; }
printf '%s_s%s.log\n' "$1" "$2"
}
# simulate <test> <seed> <logfile> — stands in for the simulator
simulate() {
[ $# -eq 3 ] || { arity "3 arguments" "$#"; return 2; }
if [ "$1" = known_bad ]; then
printf 'UVM_ERROR : mismatch in %s seed %s\n' "$1" "$2" > "$3"
else
printf 'TEST PASSED (%s seed %s)\n' "$1" "$2" > "$3"
fi
}
# verdict <logfile> -> 0 passed, 1 not
verdict() {
[ $# -eq 1 ] || { arity "1 argument" "$#"; return 2; }
grep -q 'TEST PASSED' "$1"
}
# ---- argument handling ---------------------------------------------------
verbose=0
while [ $# -gt 0 ]; do
case $1 in
-v|--verbose) verbose=1; shift ;;
-h|--help) echo "usage: ${0##*/} [-v] <test> [seed]"; exit 0 ;;
--) shift; break ;;
-*) fatal "unknown option: $1" ;;
*) break ;;
esac
done
[ $# -ge 1 ] || fatal "need a test name"
[ $# -le 2 ] || fatal "too many arguments"
test=$1
seed=${2:-1}
# ---- the work ------------------------------------------------------------
log=$(log_name "$test" "$seed") || exit 2
tb=$(derive_tb "${test}.sv") || exit 2
if [ "$verbose" -eq 1 ]; then
note "test=$test seed=$seed tb=$tb log=$log"
fi
simulate "$test" "$seed" "$log" || exit 2
if verdict "$log"; then
printf 'pass %s seed %s\n' "$test" "$seed"
exit 0
fi
printf 'FAIL %s seed %s (see %s)\n' "$test" "$seed" "$log"
exit 1$ ./run-one.sh smoke
pass smoke seed 1
$ echo $?
0
$ ./run-one.sh -v smoke 7
run-one.sh: test=smoke seed=7 tb=smoke_tb.sv log=smoke_s7.log
pass smoke seed 7
$ echo $?
0
$ ./run-one.sh known_bad 3
FAIL known_bad seed 3 (see known_bad_s3.log)
$ echo $?
1
$ ./run-one.sh --bogus smoke
run-one.sh: unknown option: --bogus
$ echo $?
2
$ ./run-one.sh
run-one.sh: need a test name
$ echo $?
2
$ ./run-one.sh a b c
run-one.sh: too many arguments
$ echo $?
2Four decisions from this chapter:
arity uses ${FUNCNAME[1]}, not $FUNCNAME. Inside arity, $FUNCNAME is arity — useless. ${FUNCNAME[1]} is whoever called it, which is the function that actually got the wrong number of arguments. That is the callout in §2 put to work, and it is why a shared error helper needs the stack rather than the scalar.
Every function validates $# on its first line. Five one-line guards. Without them, log_name smoke with a missing seed silently produces smoke_s.log and the failure appears much later as a missing file.
The option loop uses shift at the top level, and would work identically inside a function because §4 showed shift is per-call.
note uses "$*" and printf '%s\n' "$@" is not used anywhere for a message. fatal "unknown option: $1" passes three words and gets one line, which is only true because note joins with "$*".
Practice
- Write the Section 1 script yourself and run it with three arguments, one of which contains a space. Confirm
$1and$#inside and outside, and say why$#being equal both times is a coincidence. - Print
$0and$FUNCNAMEinside a function. Then write a usage message that names the script and an arity message that names the function, and explain which is right for which reader. - Build a three-level call chain and print
${FUNCNAME[*]}at the bottom. Identify whatmainis. - Write
fwdand call it with"$@",$@,"$*"and$*from a script invoked withone "two three" four. Record the argument count for each and fill in the §3 table from your own output. - Write a
notehelper with"$*", then change it to"$@"and call it with four words. Explain the difference in output in terms ofprintfreusing its format. shift 2inside a function, then print$#and$1both inside and in the caller. Then move the option loop fromrun-one.shinto a function and confirm it still works.- Call
greetwith no arguments wheregreet() { echo "hello $1"; }. Addset -uand call it again, noting the exact message and the script's exit status. Then givegreetthree arguments underset -uand say what it reports. List the four reasonsset -uis not an arity check. - Compare
local name=${1:?need a name}with an explicit[ $# -eq 1 ] || return 2. Call each with no arguments from a script and note what happens to the script. - In
run-one.sh, changearityto use$FUNCNAMEinstead of${FUNCNAME[1]}and trigger an arity error. What does the message now say, and why is it useless? - Write
outer() { inner "$@"; }and show thatinnersees the caller's arguments. Then drop the quotes and break it with an argument containing a space.
Check Your Understanding
- What happens to the caller's
$1while a function runs? - Which positional parameter is not replaced by a call, and what is it instead?
- Where does a function's own name live?
- Why is
fwd $@wrong? - When is
"$*"the right choice? - Does
shiftinside a function affect the caller? greetis called with no arguments. What is$1, and what doesset -udo about it?
8. Remember
- A call swaps in its own
$1,$2,$#and$@, and swaps the caller's back on return. $0is not replaced. It still names the script; the function's name is$FUNCNAME.- Script name for the user,
$FUNCNAMEfor the debugger. A usage message wants${0##*/}; an arity error wants both. FUNCNAMEis a stack.${FUNCNAME[1]}is the caller, which is what a shared error helper needs."$@"is the only correct way to forward arguments. Bare$@re-splits;"$*"collapses to one."$*"is right for a log message, where one string is the point.shiftis per-call and does not touch the caller, so an option loop works unchanged inside a function.- Positional parameters are properly per-call — the one thing in Bash that is, which Chapter 18.5 will contrast sharply.
- Validate
$#on the first line of any function with arguments.set -uturns a missing argument into an abruptunbound variableabort rather than an empty string, but it names a line instead of the problem, ignores extra arguments, and is disabled by${1:-}. ${1:?msg}exits the script, wherereturn 2lets the caller decide. Choose deliberately.
Chapter 18.3 is that choice. return and exit look interchangeable in a small script and are not — one hands a number back, the other ends everything, and the difference only shows up once the script is big enough for it to hurt.
Where this fits
Part of the Linux & Bash curriculum.
