在 while 循环中修改的变量不会被记住

279
在下面的程序中,如果我在第一个`if`语句内将变量$foo设置为值1,那么它的值会被记住并保留在`if`语句之后。然而,当我在一个`while`语句内的`if`内将同一变量设置为值2时,它会在`while`循环后被遗忘。它的行为就像我在`while`循环内使用了某种变量$foo的副本,并且我只修改了该特定副本。以下是一个完整的测试程序:
#!/bin/bash

set -e
set -u 
foo=0
bar="hello"  
if [[ "$bar" == "hello" ]]
then
    foo=1
    echo "Setting \$foo to 1: $foo"
fi

echo "Variable \$foo after if statement: $foo"   
lines="first line\nsecond line\nthird line" 
echo -e $lines | while read line
do
    if [[ "$line" == "second line" ]]
    then
    foo=2
    echo "Variable \$foo updated to $foo inside if inside while loop"
    fi
    echo "Value of \$foo in while loop body: $foo"
done

echo "Variable \$foo after while loop: $foo"

# Output:
# $ ./testbash.sh
# Setting $foo to 1: 1
# Variable $foo after if statement: 1
# Value of $foo in while loop body: 1
# Variable $foo updated to 2 inside if inside while loop
# Value of $foo in while loop body: 2
# Value of $foo in while loop body: 2
# Variable $foo after while loop: 1

# bash --version
# GNU bash, version 4.1.10(4)-release (i686-pc-cygwin)

shellcheck 实用程序会捕捉到这个问题(请参见 https://github.com/koalaman/shellcheck/wiki/SC2030 );将上述代码粘贴到 https://shellcheck.net 中,第19行会收到以下反馈: SC2030: Modification of foo is local (to subshell caused by pipeline). - qneill
8个回答

343
echo -e $lines | while read line 
    ...
done

while循环在子shell中执行。因此,您对变量所做的任何更改在子shell退出后将不可用。

相反,您可以使用here字符串来重写while循环,使其在主shell进程中运行; 只有echo -e $lines将在子shell中运行:

while read line
do
    if [[ "$line" == "second line" ]]
    then
        foo=2
        echo "Variable \$foo updated to $foo inside if inside while loop"
    fi
    echo "Value of \$foo in while loop body: $foo"
done <<< "$(echo -e "$lines")"

通过在分配lines时立即扩展反斜杠序列,可以消除上述Here-String中相当丑陋的echo。可以在此处使用引号$'...'形式:

lines=$'first line\nsecond line\nthird line'
while read line; do
    ...
done <<< "$lines"

31
最好将 <<< "$(echo -e "$lines")" 更改为简单的 <<< "$lines" - beliy
2
@mteee 您可以使用 while read -r line; do echo "LINE: $line"; done < <(tail -f file) (循环将不会终止,因为它继续等待来自 tail 的输入)。 - P.P
@user9645 你可以从子shell中打印并获取结果。例如,myfunc() { echo -e "1\n2\n3\n" | (while read line; do count=\expr $count + 1`; done; echo $count;) }然后val=`myfunc``。 - P.P
4
问题实际上与 while 循环或 for 循环无关;而是与子shell的使用有关,即在 cmd1 | cmd2中,cmd2 处于子shell中。因此,如果在子shell中执行 for 循环,则会展现出意外/有问题的行为。 - P.P
1
当我使用上述语法时,出现了“语法错误:重定向意外”的错误。是否有其他解决方案? - Navin Gelot
显示剩余7条评论

60

更新#2

解释在Blue Moons的答案中。

替代方案:

消除echo

while read line; do
...
done <<EOT
first line
second line
third line
EOT

在这里是文档内部添加回显。
while read line; do
...
done <<EOT
$(echo -e $lines)
EOT

在后台运行echo命令:

coproc echo -e $lines
while read -u ${COPROC[0]} line; do 
...
done

显式地将重定向到文件句柄(注意在<<中的空格!):

exec 3< <(echo -e  $lines)
while read -u 3 line; do
...
done

或者直接重定向到 stdin

while read line; do
...
done < <(echo -e  $lines)

还有一种方法专门为chepner设计的(消除echo):

arr=("first line" "second line" "third line");
for((i=0;i<${#arr[*]};++i)) { line=${arr[i]}; 
...
}

变量$lines可以在不启动新的子 shell 的情况下转换为数组。 需要将字符\n转换为一些字符(例如真实的新行字符),并使用IFS(内部字段分隔符)变量将字符串拆分为数组元素。 可以这样做:

lines="first line\nsecond line\nthird line"
echo "$lines"
OIFS="$IFS"
IFS=$'\n' arr=(${lines//\\n/$'\n'}) # Conversion
IFS="$OIFS"
echo "${arr[@]}", Length: ${#arr[*]}
set|grep ^arr

结果为

first line\nsecond line\nthird line
first line second line third line, Length: 3
arr=([0]="first line" [1]="second line" [2]="third line")

+1 for the here-doc,因为lines变量的唯一目的似乎是提供给while循环。 - chepner
@chepner:谢谢!我又添加了一个,专门为您准备的! - TrueY
这里提供了另一种解决方案链接for line in $(echo -e $lines); do ... done - dma_k
@dma_k 感谢您的评论!这个解决方案将会产生6行只包含一个单词的代码。但是OP的要求不同... - TrueY
在ash中,upvoted。在here-is内部运行echo子shell是少数可行的解决方案之一。 - Hamy
如果 $lines 格式正确,则不需要在子shell中使用 $(echo -e $lines) 运行。只需使用 for line in $lines 即可正常工作。如果您因某种原因尝试使用 Cygwin,则会发现 done < <(echo -e $lines)exec 3< <(echo -e $lines) 在那里无法正常工作,据我所知。 - not2qubit

10
您正在查阅此 bash FAQ。回答同样描述了由管道创建的子 shell 中设置变量的一般情况:
E4) 如果我将命令的输出通过管道传递给 read 变量名,为什么当 read 命令执行完毕时,输出不会出现在 $variable 中?
这与 Unix 进程之间的父子关系有关。它影响所有运行在管道中的命令,而不仅仅是对 read 的简单调用。例如,将命令的输出管道传递到一个反复调用 readwhile 循环中会导致相同的行为。
管道的每个部分,甚至是内置函数或 shell 函数,都运行在单独的进程中,是运行管道的 shell 的子进程。一个子进程无法影响其父进程的环境。当 read 命令将变量设置为输入时,该变量仅在子 shell 中设置,而不是在父 shell 中设置。当子 shell 退出时,变量的值就丢失了。
许多以 read variable 结尾的管道可以转换为命令替换,它将捕获指定命令的输出。然后,可以将输出分配给变量:
grep ^gnu /usr/lib/news/active | wc -l | read ngroup

can be converted into

ngroup=$(grep ^gnu /usr/lib/news/active | wc -l)

很遗憾,这种方式不能像read命令在给定多个变量参数时那样将文本分割到多个变量中。如果您需要这样做,可以使用上面的命令替换将输出读入变量并使用bash模式删除扩展运算符来分割变量或使用以下方法的某些变体。

假设/usr/local/bin/ipaddr是以下shell脚本:

#! /bin/sh
host `hostname` | awk '/address/ {print $NF}'

Instead of using

/usr/local/bin/ipaddr | read A B C D
要将本地机器的IP地址分成单独的八位组,请使用:
OIFS="$IFS"
IFS=.
set -- $(/usr/local/bin/ipaddr)
IFS="$OIFS"
A="$1" B="$2" C="$3" D="$4"

请注意,这将更改shell的位置参数。如果您需要它们,应该在执行此操作之前保存它们。

这是一般方法——在大多数情况下,您不需要将$IFS设置为不同的值。

其他用户提供的替代方案包括:

read A B C D << HERE
    $(IFS=.; echo $(/usr/local/bin/ipaddr))
HERE

在支持进程替换的情况下,

read A B C D < <(IFS=.; echo $(/usr/local/bin/ipaddr))

12
你忘记了“家庭矛盾”问题。有时候很难找到与回答者写的答案相同的单词组合,这样你既不会被错误的结果淹没,也不会过滤掉所说的答案。 - anon
该链接已标记为“此文档不再维护”。 - Graham Leggett
@GrahamLeggett 链接已更换。 - JRFerguson

4

嗯... 我几乎可以发誓这个命令适用于原始的Bourne shell,但我现在没有运行副本来检查。

然而,有一个非常简单的解决办法。

将脚本的第一行改为:

#!/bin/bash

为了

#!/bin/ksh

完成了!在管道末尾读取数据是可以正常工作的,前提是您已经安装了Korn shell。


zsh 也可以使用。 - Vej

2

我使用stderr在循环中存储,并在外部读取。这里变量i最初在循环内设置并读取为1。

# reading lines of content from 2 files concatenated
# inside loop: write value of var i to stderr (before iteration)
# outside: read var i from stderr, has last iterative value

f=/tmp/file1
g=/tmp/file2
i=1
cat $f $g | \
while read -r s;
do
  echo $s > /dev/null;  # some work
  echo $i > 2
  let i++
done;
read -r i < 2
echo $i

或者使用heredoc方法来减少子shell中的代码量。注意,迭代变量i的值可以在while循环之外读取。

i=1
while read -r s;
do
  echo $s > /dev/null
  let i++
done <<EOT
$(cat $f $g)
EOT
let i--
echo $i

1
非常有用。您可以在while循环中拥有多个变量,并将它们分别输出到不同的数字上 echo $i > 2; echo $j > 3。然后,在while循环之后,您可以将它们重定向回全局变量 read -r i < 2; read -r j < 3 - James L.

1
这是一个有趣的问题,涉及到 Bourne shell 和子 shell 中非常基本的概念。在此我提供了一种与以前的解决方案不同的解决方法,通过进行某种过滤来实现。我将给出一个在实际生活中可能有用的示例。这是一个用于检查下载的文件是否符合已知校验和的片段。校验和文件看起来像下面这样(仅显示3行):
49174 36326 dna_align_feature.txt.gz
54757     1 dna.txt.gz
55409  9971 exon_transcript.txt.gz

这个shell脚本:

#!/bin/sh

.....

failcnt=0 # this variable is only valid in the parent shell
#variable xx captures all the outputs from the while loop
xx=$(cat ${checkfile} | while read -r line; do
    num1=$(echo $line | awk '{print $1}')
    num2=$(echo $line | awk '{print $2}')
    fname=$(echo $line | awk '{print $3}')
    if [ -f "$fname" ]; then
        res=$(sum $fname)
        filegood=$(sum $fname | awk -v na=$num1 -v nb=$num2 -v fn=$fname '{ if (na == $1 && nb == $2) { print "TRUE"; } else { print "FALSE"; }}')
        if [ "$filegood" = "FALSE" ]; then
            failcnt=$(expr $failcnt + 1) # only in subshell
            echo "$fname BAD $failcnt"
        fi
    fi
done | tail -1) # I am only interested in the final result
# you can capture a whole bunch of texts and do further filtering
failcnt=${xx#* BAD } # I am only interested in the number
# this variable is in the parent shell
echo failcnt $failcnt
if [ $failcnt -gt 0 ]; then
    echo $failcnt files failed
else
    echo download successful
fi

父子进程通过echo命令进行通信。您可以选择一些易于解析的文本用于父进程。这种方法不会破坏您的正常思维方式,只需要进行一些后处理。您可以使用grep、sed、awk等工具来完成此操作。

0

怎么样,使用一个非常简单的方法?

    +call your while loop in a function 
     - set your value inside (nonsense, but shows the example)
     - return your value inside 
    +capture your value outside
    +set outside
    +display outside


    #!/bin/bash
    # set -e
    # set -u
    # No idea why you need this, not using here

    foo=0
    bar="hello"

    if [[ "$bar" == "hello" ]]
    then
        foo=1
        echo "Setting  \$foo to $foo"
    fi

    echo "Variable \$foo after if statement: $foo"

    lines="first line\nsecond line\nthird line"

    function my_while_loop
    {

    echo -e $lines | while read line
    do
        if [[ "$line" == "second line" ]]
        then
        foo=2; return 2;
        echo "Variable \$foo updated to $foo inside if inside while loop"
        fi

        echo -e $lines | while read line
do
    if [[ "$line" == "second line" ]]
    then
    foo=2;          
    echo "Variable \$foo updated to $foo inside if inside while loop"
    return 2;
    fi

    # Code below won't be executed since we returned from function in 'if' statement
    # We aready reported the $foo var beint set to 2 anyway
    echo "Value of \$foo in while loop body: $foo"

done
}

    my_while_loop; foo="$?"

    echo "Variable \$foo after while loop: $foo"


    Output:
    Setting  $foo 1
    Variable $foo after if statement: 1
    Value of $foo in while loop body: 1
    Variable $foo after while loop: 2

    bash --version

    GNU bash, version 3.2.51(1)-release (x86_64-apple-darwin13)
    Copyright (C) 2007 Free Software Foundation, Inc.

7
也许这里隐藏着一个合理的答案,但是由于格式被搞砸了,因此阅读起来很不舒服。 - Mark Amery
你的意思是原始代码很容易阅读吗?(我只是跟着做了:p) - Marcin

0

虽然这是一个老问题,已经被问了很多次,但在我花费数小时摆弄here字符串后,唯一有效的选项是在while循环子shell期间将值存储在文件中,然后检索它。 简单。

使用echo语句存储和cat语句检索。Bash用户必须chown目录或具有读写chmod访问权限。

#write to file
echo "1" > foo.txt

while condition; do 
    if (condition); then
        #write again to file
        echo "2" > foo.txt      
    fi
done

#read from file
echo "Value of \$foo in while loop body: $(cat foo.txt)"

1
你试过这个吗? xxx=0; while true; do if true; then xxx=100 ; fi; break; done; echo $xxx - Nik O'Lai

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接