Returning Multiple Values (“Tuples”-ish) from Bash Functions

1120 words. Time to Read: About 11 minutes.

Let’s say you need to return three values from a function in Bash for some reason. How do you do that? There are a few options, but my favorite is using Bash’s built-in field separating powers and read command. It makes the code really clean and crisp and lets you use your functions as streams of text, just like any other command line utility, which is always a good thing.

Here’s our example:

We want a function that mimics Python’s partition function. This takes an input string and a splitting character and returns the part before, the delimiter, and the part after as a tuple.

Here’s the function.

# Splits up a string based on the first occurrence of a
# character in a string.
# Returns <part before delim>, <delim>, <part after delim>
function partition() {
  local string="$1"
  local delim="$2"
  local before after

  before=${string%%$delim*}
  after=${string#*$delim}
  echo "$before"
  echo "$delim"
  echo "$after"
}

Now, how we handle this depends on a few different cases and there are a few different gotchas to look out for along the way.

The Less than Ideal Way

In the simplest case, your outputs are very controlled and you know for sure that you won’t ever have any character that could be construed as part of the default IFS—essentially, whitespace. In that case, you can get them all in one read command.

local before delim after
read -r -d '' before delim after <<< "$( partition 'youtube.com' '.' )"
echo "$before | $delim | $after"
# => youtube | . | com

Here’s what we did:

This method has one major downside. If read gets to the end of the file and doesn’t see the delimiter you told it to look for, it returns an exit code of 1 (i.e. an error). Since we made the delimiter nothing, we’re guaranteed for that to happen. It’s what we want to happen. However. If you’ve got set -e or set -o errexit (which do the same thing) at the top of your script, which is usually a good and safe thing to do, this “error” will cause your script to die with no useful error message and you’ll tear your hair out debugging the issue.

So, if we want to read all three lines with one read, we either have to take out the set -o errexit option (not great) or change our partition function to return something with spaces instead of newlines (maybe OK, depending on your use case, but less flexible for later.

If we wanted to, it would be even cleaner to replace the last three lines of our function with this:

- echo "$before"
- echo "$delim"
- echo "$after"
+ echo "$before $delim $after"

Then our main function can receive it like this:

local before delim after
read -r before delim after <<< "$( partition 'youtube.com' '.' )"

Getting rid of the extra -d '' option cleans things up a little.

A Better Way: Multiple Lines into Specific Variables

OK, that’s great! But what if you really, really want your function to output line by line. Not a problem. read is really the most ergonomically set up to read one line per call. But who says we need to stick to one read call?

We’re back to our three-line return that we had originally:

- echo "$before $delim $after"
+ echo "$before"
+ echo "$delim"
+ echo "$after"

Now let’s read!

{
  read -r before
  read -r delim
  read -r after
} <<< "$( partition 'youtube.com' '.' )"

We’ll just group our reads together inside a block of grouping braces and those lines will proceed using the output of our partition function as their input. The neat thing about this is that you don’t have to process all of the arguments!

{ read -r before; } <<< "$( partition 'youtube.com' '.' )
# Bash just drops the other two outputs!

Reading an Unknown Number of Lines

OK, this is the last scenario. What if your function kicks out an unknown number of lines?

function laugh() {
  local times
  times=$(( RANDOM % 8 ))
  for ((i=0; i<times; i++)); do
    echo "HA!"   # Just an example to show some unknown number of lines returned.
  done
}

readarray laughs <<< "$( laugh )"
echo "There were ${#laughs[@]} laughs."
# => There were 6 laughs.

readarray is an exceptionally useful command and it does exactly what we want really cleanly.

Functions are Just Mini-Commands

It’s amazing how often I am reminded that Bash functions are essentially just miniature commands or scripts, and that the neatest, cleanest answer to a question of “how do I do this in Bash” is often “stop fighting against Bash and use its strengths.”

How do we return multiple arguments? The same way we output anything in any Bash command. Kick it out as output and assume the calling code knows what to do with it. How do we retrieve multiple things being emitted from a function? Read it line by line and process it a line at a time like most other command line utilities.

There shouldn’t be that much of a difference in usage between your internal functions and commands like grep, sed, or awk. If an idiom was good enough for the authors of the classics, you should probably have a darn good reason why it’s not good enough for you.

Happy scripting!

Author: Ryan Palo | Tags: bash scripting | Buy me a coffee Buy me a coffee

Like my stuff? Have questions or feedback for me? Want to mentor me or get my help with something? Get in touch! To stay updated, subscribe via RSS