15

Can someone explain why this script doesn't produce the output I was expecting?

 #!/bin/bash
 #

var=0

ls -1 /tmp| while read file
do
     echo $file
     var=1
done

echo "var is $var"

I get a list of files followed by var is 0

Why isn't var equal to 1? Is it because the while loop spawns a sub-shell?

Aditya K
  • 2,060
  • Ah, the perennial bash while/subshell/pipe question! See http://mywiki.wooledge.org/BashFAQ/024 And http://unix.stackexchange.com/questions/9954/why-is-my-variable-being-localized-in-one-while-read-loop-but-not-in-another/9994#9994 And http://unix.stackexchange.com/questions/21743/piping-for-loop-output-prevents-local-variable-modification ...among others. – Mike S May 20 '15 at 18:08

3 Answers3

15

Piping does. You can check for yourself, for example by printing $BASHPID from inside and outside of the while loop or by doing something like:

ls | while read file; do
    sleep 100;
done

, stopping it with C-Z and checking ps or ps --forest afterwards to see the process tree in your terminal session.

You can avoid the subshell by "piping" a little differently:

var=0
while read file
do 
  echo $file; var=1
done < <(ls -1 /tmp/)

echo $var #=> 1
Petr Skocik
  • 28,816
  • 3
    I like your demonstration and solution, but I think your first sentence isn't accurate, the while loop itself does not spawn a subshell, otherwise your solution wouldn't work either – Eric Renouf May 20 '15 at 14:30
  • @Eric Renouf Thanks for the comment. Fixed it. – Petr Skocik May 20 '15 at 14:32
6

The reason why your variable value is not kept has already been explained by the other good answer. If you feel like, you can read an interesting article about this topic in I set variables in a loop that's in a pipeline. Why do they disappear after the loop terminates? Or, why can't I pipe data to read?.

I just want to show another way to loop through your files, so that you don't parse ls at all:

for file in /tmp/*
do
   echo "$file"
   var=1
done

That's it! Just let /tmp/* expand to provide all the contents in the /tmp directory.

I guess your script was just some dummy code, not the real code. But if you happen to be checking whether /tmp contains some values or not, you can also say:

shopt -s nullglob
r=(/tmp/*)

And then count the elements in the array:

echo ${#r[@]}

Note I used shopt -s nullblog to prevent /tmp/* to expand to the literal string /tmp/* if nothing matches this pattern.

fedorqui
  • 7,861
  • 7
  • 36
  • 74
2

while doesn't, but a pipe does, so the while in your example will be run in a subshell because of that

One can often accomplish the same task with a for loop where the command producing the list to iterate over is run in the subshell instead of the loop. For example:

for file in /tmp/*; do
    echo "$file"
    var=1
done
izabera
  • 103
  • 2
Eric Renouf
  • 18,431
  • 1
    @AdityaK I edited my answer to show an alternate way to do it with a for loop instead of a while loop with a pipe, perhaps that will help. As always, be careful processing the output of ls though, spaces can cause a lot of problems for a script! – Eric Renouf May 20 '15 at 14:22
  • 2
    If you quote for file in "$(ls)" it will be treated as a single element and the loop will only run once. If you don't quote it, files with spaces will break it. Please don't recommend broken code – izabera May 20 '15 at 15:47
  • @izabera thanks for the edit, it's both more correct and more efficient since it skips an unnecessary process spawning, better all the way around – Eric Renouf May 20 '15 at 15:51