Paul J. Lucas
Paul J. Lucas

Reputation: 7151

Bourne shell function return variable always empty

The following Bourne shell script, given a path, is supposed to test each component of the path for existence; then set a variable comprising only those components that actually exist.

#! /bin/sh
set -x             # for debugging

test_path() {
  path=""
  echo $1 | tr ':' '\012' | while read component
  do
    if [ -d "$component" ]
    then
      if [ -z "$path" ]
      then path="$component"
      else path="$path:$component"
      fi
    fi
  done
  echo "$path"    # this prints nothing
}

paths=/usr/share/man:\
/usr/X11R6/man:\
/usr/local/man

MANPATH=`test_path $paths`
echo $MANPATH

When run, it always prints nothing. The trace using set -x is:

+ paths=/usr/share/man:/usr/X11R6/man:/usr/local/man
++ test_path /usr/share/man:/usr/X11R6/man:/usr/local/man
++ path=
++ echo /usr/share/man:/usr/X11R6/man:/usr/local/man
++ tr : '\012'
++ read component
++ '[' -d /usr/share/man ']'
++ '[' -z '' ']'
++ path=/usr/share/man
++ read component
++ '[' -d /usr/X11R6/man ']'
++ read component
++ '[' -d /usr/local/man ']'
++ '[' -z /usr/share/man ']'
++ path=/usr/share/man:/usr/local/man
++ read component
++ echo ''
+ MANPATH=
+ echo

Why is the final echo $path empty? The $path variable within the while loop was incrementally set for each iteration just fine.

Upvotes: 2

Views: 583

Answers (4)

user2719058
user2719058

Reputation: 2233

The pipe runs all commands involved in sub-shells, including the entire while ... loop. Therefore, all changes to variables in that loop are confined to the sub-shell and invisible to the parent shell script.

One way to work around that is putting the while ... loop and the echo into a list that executes entirely in the sub-shell, so that the modified variable $path is visible to echo:

test_path()
{
  echo "$1" | tr ':' '\n' | {
  while read component
    do
      if [ -d "$component" ]
      then
        if [ -z "$path" ]
        then
          path="$component"
        else
          path="$path:$component"
        fi
      fi
    done
    echo "$path"
  }
}

However, I suggest using something like this:

test_path()
{
    echo "$1" | tr ':' '\n' |
    while read dir
    do
        [ -d "$dir" ] && printf "%s:" "$dir"
    done |
    sed 's/:$/\n/'
}

... but that's a matter of taste.

Edit: As others have said, the behaviour you are observing depends on the shell. The POSIX standard describes pipelined commands as run in sub-shells, but that is not a requirement:

Additionally, each command of a multi-command pipeline is in a subshell environment; as an extension, however, any or all commands in a pipeline may be executed in the current environment.

Bash runs them in sub-shells, but some shells run the last command in the context of the main script, when only the preceding commands in the pipeline are run in sub-shells.

Upvotes: 2

jlliagre
jlliagre

Reputation: 30843

Your script works just fine with no change under Solaris 11 and probably also most commercial Unix like AIX and HP-UX because under these OSes, the underlying implementation of /bin/sh is provided by ksh. This would be also the case if /bin/sh is backed by zsh.

It doesn't work for you likely because your /bin/sh is implemented by one of bash, dash, mksh or busybox sh which all process each component of a pipeline in a subshell while ksh and zsh both keep the last element of a pipeline in the current shell, saving an unnecessary fork.

It is possible to "fix" your script for it to work when sh is provided by bash by adding this line somewhere before the pipeline:

shopt -s lastpipe

or better, if you wan't to keep portability:

command -v shopt > /dev/null && shopt -s lastpipe

This will keep the script working for ksh, and zsh but still won't work for dash, mksh or the original Bourne shell.

Note that both bash and ksh behaviors are allowed by the POSIX standard.

Upvotes: 0

Henk Langeveld
Henk Langeveld

Reputation: 8456

Why is the final echo $path empty?

Until recently, Bash would give all components of a pipeline their own process, separate from the shell process in which the pipeline is run. Separate process == separate address space, and no variable sharing.

In ksh93 and in recent Bash (may need a shopt setting), the shell will run the last component of a pipeline in the calling shell, so any variables changed inside the loop are preserved when the loop exits.

Another way to accomplish what you want is to make sure that the echo $path is in the same process as the loop, using parentheses:

#! /bin/sh
set -x             # for debugging

test_path() {
  path=""
  echo $1 | tr ':' '\012' | ( while read component
  do
    [ -d "$component" ] || continue

    path="${path:+$path:}$component"
  done
  echo "$path"
  )
}

Note: I simplified the inner if. There was no else so the test can be replaced with a shortcut. Also, the two path assignments can be combined into one, using the S{var:+ ...} parameter substitution trick.

Upvotes: 0

Jonathan Leffler
Jonathan Leffler

Reputation: 754760

This should work in a Bourne shell that understands functions (and would work in Bash and other shells too):

test_path() {
  echo $1 | tr ':' '\012' |
  {
  path=""
  while read component
  do
    if [ -d "$component" ]
    then
      if [ -z "$path" ]
      then path="$component"
      else path="$path:$component"
      fi
    fi
  done
  echo "$path"    # this prints nothing
  }
}

The inner set of braces groups the commands into a unit, so path is only set in the subshell but is echoed from the same subshell.

Upvotes: 2

Related Questions