rand[om]

rand[om]

med ∩ ml

Named parameters in shell scripts and Makefiles

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

  1. 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'}"
  1. 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.