Named parameters in shell scripts and Makefiles
Table of contents
I recently learned 1 a nice pattern to improve parameter handling in shell scripts. The pattern involves parameter expansion:
#!/usr/bin/env bash
set -euo pipefail
function main {
local arg1="${A1:?'FAIL. Provide A1 var'}"
echo $arg1
}
main
When calling the script (called t.sh
throughout this post), it will fail:
bash t.sh
# t.sh: line 7: A1: FAIL. Provide A1 var
But this will succeed:
A1=foo bash t.sh
# foo
It can also be used to provide default/required positional arguments.
#!/usr/bin/env bash
set -euo pipefail
function f1 {
local arg1="${1:?'FAIL. Provide <something> as the first argument'}"
local arg2="${2:-default_two}"
echo "$arg1"
echo "$arg2"
}
function main {
f1 "foo" # <-- prints "foo" and "default_two"
f1 "foo" "bar" # <-- prints "foo" and "bar"
f1 # <-- prints "FAIL. Provide <something> as the first argument"
}
main
But this pattern can be extended for more use cases.
Faking named function parameters
Named function parameters are one of the things I miss the most when using shell scripts. However, they can be “faked” using this pattern. The only downside is that the parameters have to be defined before the function call.
#!/usr/bin/env bash
set -euo pipefail
function f1 {
local arg1="${A1:?'FAIL. Provide <something> as the first argument'}"
local arg2="${A2:?'FAIL. Provide <something> as the second argument'}"
echo "$arg1"
echo "$arg2"
}
function main {
A1="foo" A2="bar" f1
}
main
In this example, we call the f1
function with the named parameters A1
and A2
. Those values will only exist in that function (A1
and A2
are undefined in the other lines of the main
function).
You can also pass just one of the parameters and get the other one from the environment
- A1="foo" A2="bar" f1
+ A1="foo" f1
Then call the script with A2
:
A2="bar" bash t.sh
Using this in Makefiles
Makefiles also use environment variables as “arguments”. We can use this pattern inside a recipe too. When calling make, you can also use --warn-undefined-variables
, but as the name suggests, this will just show a warning. The technique explained here can be used to enforce setting variables and also making a variable optional in a recipe and required in another.
Remember to use $$
because $
is used to reference a variable from the Makefile.
Single use variable
In this example, the A1
variable only exists inside a line (except if .ONESHELL
is used)
a:
@echo $${A1:?'FAIL. Set A1'}
Global variable
Here, $(VAR)
is global to the whole Makefile.
VAR := $${A1:?'FAIL. Set A1'}
a:
@echo $(VAR)
Global variable, scoped to a single recipe
In this case, $(VAR)
is global to the a:
recipe and its dependencies.
a: VAR := $${A1:?'FAIL. Set A1'}
a: b
@echo $(VAR)
In all the examples above, the execution will fail when the A1
variable is missing:
make # <- fails
A1="foo" make # <- succeeds
Required vs. optional
In this example, setting A1
is required when building the target a
, but it’s optional when building b
. If the variable is not set when building b
, it will use the default optional_value_set
.
a: VAR := $${A1:?'FAIL. A1 required'}
a:
@echo $(VAR)
b: VAR := $${A1:-'optional_value_set'}
b:
@echo $(VAR)
This is what happens when running the different targets:
make a
# /bin/sh: A1: FAIL. A1 required
# make: *** [a] Error 127
A1=foo make a
# foo
make b
# optional_value_set
A1="different value" make b
# different value
Important final notes
- Variable names When using this technique in Makefiles, you need to use a different name for the environment variable and the name inside the Makefile.
Incorrect examples:
target: A1 := $${A1:?'FAIL'}
target:
@echo $(A1)
Correct:
target: VAR := $${A1:?'FAIL'}
target:
@echo $(VAR)
Even if it doesn’t break, it’s probably better to follow this rule in shell scripts too.
- local A2="${A2:?'FAIL'}"
+ local arg2="${A2:?'FAIL'}"
- Variable evaluation In the makefile example, each variable will be evaluated when it’s accessed. For a makefile like this:
target: VAR_1 := $${A1:?'FAIL'}
target: VAR_2 := $${A2:?'FAIL'}
target:
@echo $(VAR_1)
@echo $(VAR_2)
If you only set A1
, which will be used as VAR_1
inside the makefile (A1=foo make target
), the first command of the makefile @echo $(VAR_1)
will run correctly, and it will only fail when trying to access VAR_2
.