Bash Nameref: Mutate Function Arguments (Pointer-Like Behavior)

This is the type of thing that's really handy when you need it but you may not need it too often.
I’ve been writing shell scripts for around 10 years and I only had to do this once. It was a delight to know the feature exists because it helped make some code a lot more readable.
# TL;DR
The short answer is you can use local -n my_var="${1}" inside of a function
to create a
nameref
(a call by name reference). When you call my_func SOME_VAR,
any changes you make to my_var in that function will mutate SOME_VAR.
It works with Bash 4.3+. I see it as “pointer-like” because it gives you a way to manipulate a reference of something else. In this case it does it by name instead of memory address.
This helps avoid using globals and subshells, for example having your function
return a value which you then assign to SOME_VAR at call time with
SOME_VAR=$(my_func). Avoiding that is especially helpful when you want to
return an array since it’s quite painful to deal with this in Bash. The demo
video covers this example.
Make sure to avoid naming your local variable the same as the variable passed
in. If you do then Bash will throw warnings about a circular name reference,
use your discretion on naming it, for example you could prefix the local -n
variable with an underscore or use the lowercase version of it if applicable.
# Use Case
In my dotfiles, I have an install script which handles setting up a complete system. One thing it has are quite a few config array variables for setting which packages you want installed.
There’s a bunch of these variables for system package installation with pacman, yay, brew and apt as well as handling Mise packages for Arch, Debian and macOS.
I wanted to make it easy for anyone to skip installing certain packages without having to redefine each array variable with a complete list of packages.
For example, you can define PACKAGES_PACMAN_GUI_SKIP=("kdenlive") if you
didn’t want to install that video editing tool. This is way friendlier than
having to redefine PACKAGES_PACMAN_GUI which has over 50+ packages defined
only to remove kdenlive.
In code I ended up creating a _skip_packages function which takes the default
package variable and its associated skip variable as arguments, calling it
looks like this:
_skip_packages "PACKAGES_PACMAN" "PACKAGES_PACMAN_SKIP"
_skip_packages "PACKAGES_PACMAN_GUI" "PACKAGES_PACMAN_GUI_SKIP"
_skip_packages "PACKAGES_AUR" "PACKAGES_AUR_SKIP"
_skip_packages "PACKAGES_AUR_GUI" "PACKAGES_AUR_GUI_SKIP"
_skip_packages "PACKAGES_APT" "PACKAGES_APT_SKIP"
_skip_packages "PACKAGES_BREW" "PACKAGES_BREW_SKIP"
_skip_packages "PACKAGES_BREW_CASK" "PACKAGES_BREW_CASK_SKIP"
_skip_packages "MISE_ARCH" "MISE_ARCH_SKIP"
_skip_packages "MISE_DEBIAN" "MISE_DEBIAN_SKIP"
_skip_packages "MISE_MACOS" "MISE_MACOS_SKIP"
That function looks like this:
_skip_packages() {
local -n packages_default="${1}"
local -n packages_skip="${2}"
local packages_filtered=()
declare -A packages_skip_map
for item in "${packages_skip[@]}"; do
packages_skip_map["${item}"]=1
done
for item in "${packages_default[@]}"; do
# Avoid adding skipped packages to the new filtered packages.
if [ -z "${packages_skip_map[${item}]}" ]; then
packages_filtered+=("${item}")
fi
done
packages_default=("${packages_filtered[@]}")
}
Take a look at the first 2 lines, there’s our nameref variables. The logic of
the function just builds up a new list which omits the skipped packages. Doing
it this way is cleaner and more predictable than using unset because Bash
won’t re-index your array when you unset values. I wanted my array to be in
pristine form at the end of it.
The last line in the function assigns the new filtered variable to the default
package variable. This is where the mutation occurs. Using our example from
before, PACKAGES_PACMAN_GUI would now have all of its original packages minus
anything in PACKAGES_PACMAN_GUI_SKIP.
Now the rest of the script doesn’t need to know or care about the skip list. It
can deal with PACKAGES_PACMAN_GUI and treat it as a single source of truth.
This helped me because I display the packages before they get installed. None
of that logic had to change.
The demo video below goes over the above and a few extra examples showing a before and after of using globals, subshells and namerefs.
# Demo Video
Timestamps
- 0:00 – Global variable
- 1:04 – Subshell shenanigans
- 2:33 – Nameref to the rescue
- 3:35 – Avoiding circular references
- 4:24 – Real world use case, dotfiles skip packages
- 6:22 – Looking at the code
Have you used this before? Let me know below.