10

How to save output of a command that modifies environment into a variable?

I'm using bash shell.

Assume that I have:

function f () { a=3; b=4 ; echo "`date`: $a $b"; }

And now, I can use commands to run f:

$ a=0; b=0; f; echo $a; echo $b; echo $c
Sat Jun 28 21:27:08 CEST 2014: 3 4
3
4

but I would like to save output of f to variable c, so I tried:

a=0; b=0; c=""; c=$(f); echo $a; echo $b; echo $c

but unfortunatelly, I've got:

0
0
Sat Jun 28 21:28:03 CEST 2014: 3 4

so I don't have any environment change here.

How to save output of command (not only function) to variable and save environmental changes?

I know that $(...) opens new subshell and that is the problem, but is it possible to do some workaround?

HalosGhost
  • 4,790
faramir
  • 215
  • 1
  • 7
  • Right, so the issue is that $a and $b are local variables in your f function. You could export them, but that seems sketchy. – HalosGhost Jun 28 '14 at 20:00
  • 1
    @HalosGhost: No, I don’t think so. Look at the first example: …; f; echo $a; … results in 3 being echoed, so f is modifying a shell variable (and not just its own local variable). – Scott - Слава Україні Jun 28 '14 at 20:38

4 Answers4

2

If you're using Bash 4 or later, you can use coprocesses:

function f () { a=3; b=4 ; echo "`date`: $a $b"; }
coproc cat
f >&${COPROC[1]}
exec {COPROC[1]}>&-
read c <&${COPROC[0]}
echo a $a
echo b $b
echo c $c

will output

a 3
b 4
c Sun Jun 29 10:08:15 NZST 2014: 3 4

coproc creates a new process running a given command (here, cat). It saves the PID into COPROC_PID and standard output/input file descriptors into an array COPROC (just like pipe(2), or see here or here).

Here we run the function with standard output pointed at our coprocess running cat, and then read from it. Since cat just spits its input back out, we get the output of the function into our variable. exec {COPROC[1]}>&- just closes the file descriptor so that cat doesn't keep waiting forever.


Note that read takes only one line at a time. You can use mapfile to get an array of lines, or just use the file descriptor however you want to use it in a different way.

exec {COPROC[1]}>&- works in current versions of Bash, but earlier 4-series versions require you to save the file descriptor into a simple variable first: fd=${COPROC[1]}; exec {fd}>&-. If your variable is unset it will close standard output.


If you're using a 3-series version of Bash, you can get the same effect with mkfifo, but it's not much better than using an actual file at that point.

Michael Homer
  • 76,565
  • I can use only bash 3.1. The pipes are hard to use. Does mktemp and is the only option? Aren't here special syntax or option to run command and get output but with environment changes? – faramir Jun 29 '14 at 17:04
  • You can use mkfifo to make a named pipe as a substitute for coproc, which doesn't involve actually writing the data to disk. With that you'd redirect output to the fifo and input from it instead of the coprocess. Otherwise no. – Michael Homer Jun 29 '14 at 22:10
  • +1 for adapting my answer not to use a file. But, in my tests, the exec {COPROC[1]}>&- command (at least sometimes) terminates the coprocess immediately, so its output is no longer available to be read. coproc { cat; sleep 1; } seems to work better. (You may need to increase the 1 if you’re reading more than one line.) – Scott - Слава Україні Jun 30 '14 at 18:10
  • This seems overly complex. Also, a FIFO is an actual file - and so are the files your coprocesses are using to relay data back and forth. Still, even if you don't want to use parameter expansion, couldn't you just: f() { a=1 b=2 ; c="$(date): $a $b" ; echo "$c" ; }? – mikeserv Jul 01 '14 at 19:20
  • @mikeserv: the coprocess handles certainly are not "actual files"; they're opposite ends of an anonymous pipe. – Michael Homer Jul 01 '14 at 21:39
  • As for complexity, the question asks for a workaround to capture standard output of a function in the same shell; this is that workaround. As far as I know it's the only one that doesn't involve writing to disk. The actual function must be more complicated than the example or the question wouldn't be worth asking, so command substitution presumably doesn't cut it. – Michael Homer Jul 01 '14 at 21:42
  • @MichaelHomer - anonymous pipes are files. The same way stdin and stdout are files. These are all files. And if you want a file on input or output just set one. f() { cat <&4 ; } 4<<HEREDOC\n${c=$(date) $((a=3)) $((b=4))}\nHEREDOC\n – mikeserv Jul 01 '14 at 23:31
  • 1
    There is a conventional, well-understood meaning of "actual file" in use here that you're wilfully disregarding. – Michael Homer Jul 01 '14 at 23:42
  • @MichaelHomer - that is a pretty bold statement. The only conventional understanding of files that I have is that in Unix everything is a file. And actually, I'm really not trying to nitpick - but it really is easier for me - and as I think - for others to remember that file streams and descriptors behave differently than arguments and environment variables. They're always only a bytestream - there is an offset and an in/out radix and they must be parsed. – mikeserv Jul 02 '14 at 01:49
  • 1
    "Actual file" meant a file on disk, as understood by everyone here except, allegedly, you. Nobody has ever implied or permitted a reasonable reader to infer that either of them was the same as arguments or environment variables. I am not interested in continuing this discussion. – Michael Homer Jul 02 '14 at 02:27
  • Actually, i think you meant allegedly understood by everyone here... – mikeserv Jul 04 '14 at 02:54
2

If you're already counting on the body of f executing in the same shell as the caller, and thus being able to modify variables like a and b, why not make the function just set c as well? In other words:

$ function f () { a=3; b=4 ; c=$(echo "$(date): $a $b"); }
$ a=0; b=0; f; echo $a; echo $b; echo $c
3
4
Mon Jun 30 14:32:00 EDT 2014: 3 4

One possible reason may be that the output variable needs to have different names (c1, c2, etc) when called in different places, but you could address that by having the function set c_TEMP and having the callers do c1=$c_TEMP etc.

godlygeek
  • 8,053
1

It’s a kluge, but try

f > c.file
c=$(cat c.file)

(and, optionally, rm c.file).

  • Thank you. That is simple answer, and works, but have some drawbacks. I know that I can use mktemp, but I would like not to create additional files. – faramir Jun 29 '14 at 17:06
0

Just assign all of the variables and write the output at the same time.

f() { c= ; echo "${c:=$(date): $((a=3)) $((b=4))}" ; }

Now if you do:

f ; echo "$a $b $c"

Your output is:

Tue Jul  1 04:58:17 PDT 2014: 3 4
3 4 Tue Jul  1 04:58:17 PDT 2014: 3 4

Note that this is fully POSIX portable code. I initially set c= to the '' null string because parameter expansion limits concurrent variable assignment + evaluation to either only numeric (like $((var=num))) or from null or nonexistent values - or, in other words, you can't concurrently set and evaluate a variable to an arbitrary string if that variable is already assigned a value. So I just ensure that it is empty before trying. If I did not empty c before attempting to assign it the expansion would only return the old value.

Just to demonstrate:

sh -c '
    c=oldval a=1
    echo ${c:=newval} $((a=a+a))
'
###OUTPUT###
oldval 2

newval is not assigned to $c inline because oldval is expanded into ${word}, whereas the inline $((arithmetic=assignment)) always occurs. But if $c has no oldval and is empty or unset...

sh -c '
    c=oldval a=1
    echo ${c:=newval} $((a=a+a)) 
    c= a=$((a+a))
    echo ${c:=newval} $((a=a+a))
'
###OUTPUT###
oldval 2
newval 8

...then newval is at once assigned to and expanded into $c.

All other ways to do this involve some form of secondary evaluation. For example, let's say I wished to assign the output of f() to a variable named name at one point and var at another. As currently written, this will not work without setting the var in the caller's scope. A different way could look like this, though:

f(){ fout_name= fout= set -- "${1##[0-9]*}" "${1%%*[![:alnum:]]*}"
    (: ${2:?invalid or unspecified param - name set to fout}) || set --
    export "${fout_name:=${1:-fout}}=${fout:=$(date): $((a=${a:-50}+1)) $((b=${b:-100}-4))}"
    printf %s\\n "$fout"
}

f &&
    printf %s\\n \
        "$fout_name" \
        "$fout" \
        "$a" "$b"

I've provided a better formatted example below, but, called as above the output is:

sh: line 2: 2: invalid or unspecified param - name set to fout
Wed Jul  2 02:27:07 PDT 2014: 51 96
fout
Wed Jul  2 02:27:07 PDT 2014: 51 96
51
96

Or with different $ENV or arguments:

b=9 f myvar &&
    printf %s\\n \
        "$fout_name" \
        "$fout" \
        "$myvar" \
        "$a" "$b"

###OUTPUT###

Tue Jul  1 19:56:42 PDT 2014: 52 5
myvar
Tue Jul  1 19:56:42 PDT 2014: 52 5
Tue Jul  1 19:56:42 PDT 2014: 52 5
52
5

Probably the trickiest thing to get right when it comes to twice evaluating is to ensure that variables don't break quotes and execute random code. The more times a variable is evaluated the harder it gets. Parameter expansion helps a great deal here, and using export as opposed to eval is far safer.

In the above example f() first assigns $fout the '' null string and then sets positional params to test for valid variable names. If both tests do not pass a message is emitted to stderr and the default value of fout is assigned to $fout_name. Regardless of the tests, however, $fout_name is always assigned to either fout or the name you specify and $fout and, optionally, your specified name are always assigned the output value of the function. To demonstrate this I wrote this little for loop:

for v in var '' "wr;\' ong"
    do sleep 10 &&
        a=${a:+$((a*2))} f "$v" || break
    echo "${v:-'' #null}" 
    printf '#\t"$%s" = '"'%s'\n" \
        a "$a" b "$b" \
        fout_name "$fout_name" \
        fout "$fout" \
        '(eval '\''echo "$'\''"$fout_name"\")' \
            "$(eval 'echo "$'"$fout_name"\")"
 done

It plays around some with variable names and parameter expansions. If you have a question just ask. That only runs the same few lines in the function already represented here. It's worth mentioning at least though that the $a and $b variables behave differently depending on whether they are defined at invocation, or set already. Still, the for does almost nothing but format the data set and provided by f(). Have a look:

###OUTPUT###

Wed Jul  2 02:50:17 PDT 2014: 51 96
var
#       "$a" = '51'
#       "$b" = '96'
#       "$fout_name" = 'var'
#       "$fout" = 'Wed Jul  2 02:50:17 PDT 2014: 51 96'
#       "$(eval 'echo "$'"$fout_name"\")" = 'Wed Jul  2 02:50:17 PDT 2014: 51 96'
sh: line 2: 2: invalid or unspecified param - name set to fout
Wed Jul  2 02:50:27 PDT 2014: 103 92
'' #null
#       "$a" = '103'
#       "$b" = '92'
#       "$fout_name" = 'fout'
#       "$fout" = 'Wed Jul  2 02:50:27 PDT 2014: 103 92'
#       "$(eval 'echo "$'"$fout_name"\")" = 'Wed Jul  2 02:50:27 PDT 2014: 103 92'
sh: line 2: 2: invalid or unspecified param - name set to fout
Wed Jul  2 02:50:37 PDT 2014: 207 88
wr;\' ong
#       "$a" = '207'
#       "$b" = '88'
#       "$fout_name" = 'fout'
#       "$fout" = 'Wed Jul  2 02:50:37 PDT 2014: 207 88'
#       "$(eval 'echo "$'"$fout_name"\")" = 'Wed Jul  2 02:50:37 PDT 2014: 207 88'
mikeserv
  • 58,310