Learn Docker With My Newest Course

Dive into Docker takes you from "What is Docker?" to confidently applying Docker to your own projects. It's packed with best practices and examples. Start Learning Docker →

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

bash-nameref-mutate-function-arguments-pointer-like-behavior.jpg

This is the type of thing that's really handy when you need it but you may not need it too often.

Quick Jump:

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.

Never Miss a Tip, Trick or Tutorial

Like you, I'm super protective of my inbox, so don't worry about getting spammed. You can expect a few emails per year (at most), and you can 1-click unsubscribe at any time. See what else you'll get too.



Comments