Subroutines that live in memory
◈ 10 cardsDefine and call Bourne shell functions, state the cost model that justifies them, predict what a callee sees under dynamic scoping, and find a quoting bug from an sh -xv trace instead of by staring at the script.
A function is a script that never touches the disk
A shell function is a named command list stored in the shell’s memory. The portable definition form is:
lookup() {
grep -i "$1" phones.txt | sort
}
The parentheses are empty and always empty — they are punctuation announcing "this is a function", not a parameter list. Arguments arrive the way a script’s do: as 2 and so on, with $# counting them. Inside the function those positional parameters are rebound to the function’s own arguments for the duration of the call and restored afterwards, which is the one thing that surprises people arriving from C.
The braces are shell keywords, so they need whitespace around them, and the last command before the closing brace needs a ; or a newline after it. lookup() { echo hi } is a syntax error; lookup() { echo hi; } is not.
A function returns an exit status, not a value. return 3 sets it; with no return, the status is that of the last command run. To hand back data, print it and let the caller capture it: hits=$(lookup hopper).
The cost model
Why use a function instead of a second script? Because a function body is already in the shell’s memory. Calling a script means a fork, a PATH search, an exec, and reading the file off the disk; calling a function means none of those. For a menu redrawn twenty times, or a validation routine called once per line of input, the difference is several times over.
The counter-argument is scope of reuse: a script is visible to anybody with execute permission on it, and a function is visible only to the shell that defined it. That is the whole trade — speed against reach.
Worked example — the lookup, refactored
The here-document phone directory earlier in this module took one name. Make the lookup a function and drive it with while/shift, and it takes any number:
#!/bin/sh
lookup() {
grep -i "$1" phones.txt | sort
}
while [ $# -gt 0 ]
do
lookup "$1"
shift
done
Run as ./phones.sh hopper thompson it searches twice. Watch the two $1s: the one in the lookup call is the script’s first remaining argument, and the one inside the function body is the function’s first argument. They are different variables that happen to share a spelling, and shift moves the script’s along without touching the function’s.
Dynamic scoping, proved
By default a variable set inside a function is an ordinary shell variable: the caller sees it after the function returns. There is no declaration in POSIX sh that hides it. Every shell you will actually meet — dash, bash, ksh, BusyBox ash — provides local as an extension, and it is worth using, but know that it is an extension.
What local gives you is not the scoping rule you may expect. Shell functions are dynamically scoped: a name declared local in a caller is visible to everything that caller calls, however far away those functions are defined, and it vanishes when the caller returns.
show() { echo "show sees: $tag"; }
outer() {
local tag=inner-visible
show
}
outer
echo "after: $tag"
Output:
show sees: inner-visible
after:
Read that carefully. show is defined at the top level; it is not nested inside outer and knows nothing about it. It still sees tag, because the binding is found by walking the call stack, not by looking at the surrounding text. Under the lexical scoping of C or Python, show would see nothing. And after outer returns, the binding is gone, so the last line prints an empty value.
The practical consequence is a hazard: a function that sets count without declaring it local silently clobbers its caller’s count. Either declare your temporaries local, or give them names nobody else would pick.
Debugging: -v shows what you wrote, -x shows what it became
The classic Bourne bug, and it produces a message that names nothing you typed:
var=yes
if ["$var" = yes ]
then
echo ok
fi
Running it gives [yes: not found. The reason is that [ is a command, not syntax, so ["$var" is a single word — the shell searched the PATH for a program called [yes.
Two options make the shell narrate:
sh -v scriptechoes each line as written, before any substitution.sh -x scriptechoes each line after substitution, prefixed with+, immediately before it runs.
The trace is the teaching artefact:
$ sh -x check.sh
+ var=yes
+ [yes = yes ]
check.sh: 2: [yes: not found
The second + line ends the argument. The word is [yes — so the missing space is between the bracket and the quote, and you did not have to reread the script to find it.
Use -v when you suspect the text you typed, -x when you suspect what a variable expanded to, and sh -xv when the bug is in the substitution itself, because that puts the line as written and the line as executed next to each other. You can bake it into the interpreter line as #!/bin/sh -xv while you work, but take it out before you ship: a traced script writes its entire execution to standard error.