while循环中设置的Shell变量在外部不可见

46

我正在尝试找到其中字符最多的文件路径名。可能有更好的方法来解决这个问题,但我想知道为什么会出现这个问题。

LONGEST_CNT=0
find samples/ | while read line
do
    line_length=$(echo $line | wc -m)
    if [[ $line_length -gt $LONGEST_CNT ]] 
    then
        LONGEST_CNT=$line_length
        LONGEST_STR=$line
    fi
done

echo $LONGEST_CNT : $LONGEST_STR

它总是返回:

0 :
如果我在 while 循环内部打印结果以进行调试,那么输出的值是正确的。所以为什么 Bash 没有将这些变量设置为全局变量呢?
4个回答

85
在Bash中,当你将内容通过管道符传递到一个while循环中时,会创建一个子shell。当子shell退出时,所有变量都会返回到它们之前的值(可能为null或unset)。可以通过使用进程替换来避免这种情况。
LONGEST_CNT=0
while read -r line
do
    line_length=${#line}
    if (( line_length > LONGEST_CNT ))
    then
        LONGEST_CNT=$line_length
        LONGEST_STR=$line
    fi
done < <(find samples/ )    # process substitution

echo $LONGEST_CNT : $LONGEST_STR

1
Dennis提供的解决方案是有效的,但请注意,它违反了POSIX规范。尝试使用“set -o posix”命令,脚本将无法工作! - RobSis
2
@Robert:这个问题被标记为[bash]。虽然进程替换没有被POSIX指定,但是除非可移植性到仅支持POSIX的shell是一个问题,否则没有理由不使用Bash特定的功能。顺便说一下,进程替换也被ksh和zsh支持。但是,它们不会创建一个导致变量丢失的子shell。还要注意,Bash 4.2有一个选项,shopt -s lastpipe,它“在当前shell上下文中运行管道的最后一个命令”(而不是子shell)-除非作业控制生效。 - Dennis Williamson
我使用这个,但是出现了语法错误:语法错误,附近的标记“done”意外 - Aswin Murugesh
@AswinMurugesh:很可能你漏掉了 do - Dennis Williamson
1
“返回到它们之前的值”... 嗯,我认为更好的表述应该是更改发生在环境的临时副本中(甚至可能是不同的PID),并且随着while循环的结束而被销毁。原始全局变量根本没有被触及;没有理由“返回”。 - Alois Mahdal

20

“正确”的答案由Dennis提供。但是,如果循环中包含多行代码,则我发现进程替换技巧极其难以阅读。当阅读脚本时,我希望在看到处理过程之前先看到管道中的内容。

因此,我通常更喜欢使用在“{}”中封装while循环的技巧。

LONGEST_CNT=0
find /usr/share/zoneinfo | \
{ while read -r line
    do
        line_length=${#line}
        if (( line_length > LONGEST_CNT ))
        then
            LONGEST_CNT=$line_length
            LONGEST_STR=$line
        fi
    done
    echo $LONGEST_CNT : $LONGEST_STR
}

完全同意可读性的观点。+1。尽管如此,这是必须要走的路。 - 0xC0000022L
2
除了这个例子不修改全局变量之外。如果你在循环之后检查LONGEST_CNT,你会发现它仍然是零。 - Deim0s
@Deims0s:是的。这就是为什么echo语句在“{}”内部的原因。如果稍后需要再次使用变量,则可能更适合使用进程替换方式。 - mivk

2

关于查找最长路径名。这里有一个替代方案:

find /usr/share/zoneinfo | while read line; do
    echo ${#line} $line 
done | sort -nr | head -n 1

# Result:
58 /usr/share/zoneinfo/right/America/Argentina/ComodRivadavia

如果这被认为是离题的话,请原谅我,我希望它能帮助到某些人。


使用 echo 命令来打印反引号输出通常是没有用的或者不必要的。请参考 useless use of echo - tripleee
1
@tripleee: 就我所知,这个例子中两个echo都很有用:wc需要从$line输入,并且在wc的输出之后需要显示$line。请随意提出改进意见,但不要添加更多行或变量。 - grebneke
2
@grebneke 我已经为你的回答(和评论)点赞了,但是在仔细思考后,我意识到你可以避免使用命令替换来运行 wc。相反,你可以使用 POSIX shell 参数扩展echo ${#line} $line; - Anthony Geoghegan
1
@AnthonyGeoghegan 不错的建议。为了明确起见,您的示例使用# for String Length,而不是Number of positional parameters - 我认为您意外地发布了错误的链接。用于两者,这可能会让人感到困惑。 - grebneke
@grebneke 很好的发现。我在试图快速时发布了错误的链接。 :( - Anthony Geoghegan
1
@AnthonyGeoghegan 已更新您的解决方案,速度快得多,非常感谢。 - grebneke

2

请按照一贯的做法:

  • 分离关注点,
  • 避免全局变量,
  • 记录你的代码,
  • 易读性好,
  • 可以采用POSIX风格。

(是的,我在这个“汤”中添加了比绝对必要更多的“最佳实践”成分;))

所以,我对于不可见子shell问题的最喜欢的“凭直觉反应”是使用函数:

#!/bin/sh

longest() {
    #
    # Print length and body of the longest line in STDIN
    #
    local cur_ln    # current line
    local cur_sz    # current size (line length)
    local max_sz    # greatest size so far
    local winner    # longest string so far
    max_sz=0
    while read -r cur_ln
    do
        cur_sz=${#cur_ln}
        if test "$cur_sz" -gt "$max_sz";
        then
            max_sz=$cur_sz
            winner=$cur_ln
        fi
    done
    echo "$max_sz" : "$winner"
}

find /usr/share/zoneinfo | longest

# ok, if you really wish to use globals, here you go ;)
LONGEST_CNT=0
LONGEST_CNT=$(
    find /usr/share/zoneinfo \
      | longest \
      | cut -d: -f1 \
      | xargs echo\
)
echo "LONGEST_CNT='$LONGEST_CNT'"

除了避免子shell的烦恼,使用函数让你有完美的文档编写位置,并且有点像添加命名空间:注意在函数内部,你可以使用更短、更简单的变量名而不会失去可读性。


1
这段话的最后一部分其实是最好的答案,但是你可以把管道符号放在行末,避免使用行继续反斜杠。 - Priv Acyplease
@PrivAcyplease 这种风格的有意优势在于它使管道结构明显,尤其是对于中间的命令。我认为这非常值得额外的字符。 - Alois Mahdal

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