为什么使用 shell 循环处理文本被认为是不好的做法?

为什么使用 shell 循环处理文本被认为是不好的做法?

正在使用一个while 循环在 POSIX shell 中处理文本通常被认为是不好的做法?

作为斯特凡·查泽拉斯 (Stéphane Chazelas) 指出,不使用 shell 循环的一些原因是概念性的,可靠性,易读性,表现安全

回答解释了可靠性易读性方面:

while IFS= read -r line <&3; do
  printf '%s\n' "$line"
done 3< "$InputFile"

为了表现while循环和从文件或管道读取时非常慢,因为读取 shell 内置一次读取一个字符。

怎么样概念性的安全方面?

答案1

是的,我们看到了很多事情,例如:

while read line; do
  echo $line | cut -c3
done

或者更糟:

for line in $(cat file); do
  foo=$(echo $line | awk '{print $2}')
  bar=$(echo $line | awk '{print $3}')
  doo=$(echo $line | awk '{print $5}')
  echo $foo whatever $doo $bar
done

(别笑,我见过很多这样的)。

通常来自 shell 脚本初学者。这些是你在 C 或 python 等命令式语言中所做的事情的简单直译,但这不是你在 shell 中做事的方式,而且这些例子效率非常低(后一个产生了每个输入行的子进程),完全不可靠(可能导致安全问题),并且如果您设法修复了大多数错误,您的代码就会变得难以辨认。

从概念上讲

在 C 或大多数其他语言中,构建块仅比计算机指令高一级。您告诉处理器要做什么,然后下一步要做什么。您用手拿起处理器并对其进行微观管理:您打开该文件,读取那么多字节,用它做这个,做那个。

shell 是一种高级语言。有人可能会说它甚至不是一种语言。它们位于所有命令行解释器之前。这项工作是由您运行的命令完成的,shell 只是为了编排它们。

Unix 推出的最伟大的事情之一是管道以及所有命令默认处理的默认 stdin/stdout/stderr 流。

50 年来,我们还没有找到比该 API 更好的方法来利用命令的力量并让它们合作完成任务。这可能是人们今天仍在使用 shell 的主要原因。

您有一个切割工具和一个音译工具,您可以简单地执行以下操作:

cut -c4-5 < in | tr a b > out

shell 只是做管道工作(打开文件、设置管道、调用命令),当一切准备就绪时,它就会正常运行,而 shell 不执行任何操作。这些工具同时、高效地按照自己的节奏完成工作,并有足够的缓冲,这样一个工具就不会阻塞另一个工具,它很漂亮,但又很简单。

不过,调用工具是有成本的(我们将在性能点上开发它)。这些工具可能是用 C 语言编写的数千条指令。必须创建一个进程,必须加载该工具,初始化,然后清理,销毁进程并等待。

调用cut就像打开厨房的抽屉,拿起刀,使用它,清洗它,擦干它,然后放回抽屉里。当你这样做时:

while read line; do
  echo $line | cut -c3
done < file

这就像对于文件的每一行,read从厨房抽屉中获取工具(这是一个非常笨拙的方法,因为它不是为此而设计的),读一行,清洗你的阅读工具,把它放回抽屉里。然后为echo工具安排一次会议cut,从抽屉中取出它们,调用它们,清洗它们,干燥它们,将它们放回抽屉等等。

其中一些工具(readecho)是在大多数 shell 中构建的,但这在这里几乎没有什么区别,因为echo仍然cut需要在单独的进程中运行。

这就像切洋葱,但把刀洗干净,然后把它放回厨房的抽屉里。

在这里,最明显的方法是从抽屉里取出cut工具,将整个洋葱切片,并在整个工作完成后将其放回抽屉中。

IOW,在 shell 中,特别是在处理文本时,您调用尽可能少的实用程序并让它们配合任务,而不是按顺序运行数千个工具,等待每个工具启动、运行、清理,然后再运行下一个工具。

进一步阅读布鲁斯的回答很好. shell 中的低级文本处理内部工具(可能除了zsh)有限、繁琐,通常不适合一般的文本处理。

表现

如前所述,运行一个命令是有成本的。如果该命令不是内置的,则成本巨大,但即使它们是内置的,成本也很大。

shell 并不是被设计成这样运行的,它们没有自称是高性能的编程语言。它们不是,它们只是命令行解释器。因此,在这方面几乎没有进行任何优化。

此外,shell 在单独的进程中运行命令。这些构建块不共享共同的内存或状态。当你在 C 中执行fgets()or时fputs(),这是 stdio 中的一个函数。 stdio 为所有 stdio 函数的输入和输出保留内部缓冲区,以避免过于频繁地进行代价高昂的系统调用。

甚至相应的内置 shell 实用程序 ( readechoprintf) 也无法做到这一点。read旨在读取一行。如果它读取超过换行符,则意味着您运行的下一个命令将错过它。因此read必须一次读取一个字节(如果输入是常规文件,某些实现会进行优化,因为它们会读取块并向后查找,但这仅适用于常规文件,例如bash仅读取 128 字节块,即仍然比文本实用程序要少得多)。

在输出端也是如此,echo不能只是缓冲其输出,它必须立即输出它,因为您运行的下一个命令不会共享该缓冲区。

显然,按顺序运行命令意味着您必须等待它们,这是一个小小的调度程序舞蹈,将控制权从 shell 移交给工具并返回。这也意味着(与在管道中使用长时间运行的工具实例相反)您无法同时利用多个处理器(当可用时)。

在我的快速测试中,该while read循环与(据称)等效循环之间cut -c3 < file的 CPU 时间比率约为 40000(一秒与半天)。但即使您只使用 shell 内置命令:

while read line; do
  echo ${line:2:1}
done

(这里用bash),仍然是 1:600 左右(一秒 vs 10 分钟)。

可靠性/易读性

很难得到正确的代码。我给出的例子在野外很常见,但它们有很多错误。

read是一个方便的工具,可以做很多不同的事情。它可以读取用户的输入,将其分割成单词以存储在不同的变量中。 read line不是读取一行输入,或者可能以一种非常特殊的方式读取一行。它实际上读的是在输入中,由$IFS和 分隔的单词可以使用反斜杠来转义分隔符或换行符。

默认值为$IFS,输入如下:

   foo\/bar \
baz
biz

read line将存储"foo/bar baz"$line,而不是" foo\/bar \"如您所期望的那样。

要读取一行,您实际上需要:

IFS= read -r line

这不是很直观,但就是这样,记住 shell 不应该这样使用。

同样对于echo.echo扩展序列。您不能将它用于任意内容,例如随机文件的内容。你需要printf这里。

当然,还有典型的忘记引用变量每个人都会陷入其中。所以更多的是:

while IFS= read -r line; do
  printf '%s\n' "$line" | cut -c3
done < file

现在,还有一些注意事项:

  • 除了 之外zsh,如果输入包含 NUL 字符,则该方法不起作用,而至少 GNU 文本实用程序不会出现此问题。
  • 如果最后一个换行符之后有数据,则会被跳过
  • 在循环内部,stdin 被重定向,因此您需要注意其中的命令不会从 stdin 读取。
  • 对于循环内的命令,我们不关心它们是否成功。通常,错误(磁盘已满、读取错误...)情况的处理会很差,通常比使用正确的相等的。许多命令(包括 的几个实现)printf也不会在退出状态中反映出无法写入标准输出的情况。

如果我们想解决上述一些问题,那就变成:

while IFS= read -r line <&3; do
  {
    printf '%s\n' "$line" | cut -c3 || exit
  } 3<&-
done 3< file
if [ -n "$line" ]; then
    printf '%s' "$line" | cut -c3 || exit
fi

这变得越来越难以辨认。

通过参数将数据传递给命令或在变量中检索其输出还存在许多其他问题:

  • 参数大小的限制(一些文本实用程序实现也有限制,尽管所达到的效果通常问题不大)
  • NUL 字符(也是文本实用程序的问题)。
  • -当参数以(或+有时)开头时被视为选项
  • 通常在这些循环中使用的各种命令的各种怪癖,例如exprtest...
  • 各种 shell 的(有限的)文本操作符以不一致的方式处理多字节字符。
  • ...

安全考虑

当你开始使用 shell 时变量命令的参数,你正在进入雷区。

如果你忘记引用你的变量,忘记选项结束标记,在使用多字节字符的语言环境中工作(当今的常态),您肯定会引入错误,这些错误迟早会成为漏洞。

当你可能想使用循环时

当您的任务涉及 shell 擅长的事情:启动外部程序时,使用 shell 循环处理文本可能很有意义。

例如,像下面这样的循环可能有意义:

while IFS= read -r line; do
    someprog -f "$line"
done < file-list.txt

尽管上面的简单情况(输入未经修改地传递给)someprog也可以使用例如来完成xargs

<file-list.txt tr '\n' '\0' | xargs -r0 -n1 someprog -f 

或者使用 GNU xargs

xargs -rd '\n' -n1 -a file-list.txt someprog -f

答案2

就概念和易读性而言,shell 通常对文件感兴趣。它们的“可寻址单元”是文件,“地址”是文件名。 Shell 有各种测试文件存在、文件类型、文件名格式(从通配符开始)的方法。 Shell 用于处理文件内容的原语非常少。 Shell 程序员必须调用另一个程序来处理文件内容。

正如您所指出的,由于文件和文件名方向的原因,在 shell 中进行文本操作非常慢,而且还需要不清楚且扭曲的编程风格。

答案3

有一些复杂的答案,为我们当中的极客提供了许多有趣的细节,但实际上非常简单 - 在 shell 循环中处理大文件太慢了。

我认为提问者对一种典型的 shell 脚本感兴趣,它可能从一些命令行解析、环境设置、检查文件和目录以及更多的初始化开始,然后再开始其主要工作:经历一个大的过程。面向行的文本文件。

对于第一部分 ( initialization),shell 命令速度慢通常并不重要 – 它只运行几十个命令,可能还有几个短循环。即使我们编写该部分的效率很低,通常也只需不到一秒的时间即可完成所有初始化,这很好 - 它只发生一次。

但是当我们开始处理可能有数千或数百万行的大文件时,它是不好shell 脚本每行需要花费相当多的几分之一秒(即使只有几十毫秒),因为这可能会长达几个小时。

这时我们需要使用其他工具,而 Unix shell 脚本的优点在于它们使我们可以轻松地做到这一点。

我们需要传递整个文件,而不是使用循环来查看每一行命令管道。这意味着 shell 不会调用命令数千或数百万次,而是仅调用一次。确实,这些命令将有循环来逐行处理文件,但它们不是 shell 脚本,并且它们被设计为快速且高效。

Unix 有许多精彩的内置工具,从简单到复杂,我们可以用它们来构建管道。我通常会从简单的开始,只有在必要时才使用更复杂的。

我还会尝试坚持使用大多数系统上可用的标准工具,并尝试保持我的使用可移植性,尽管这并不总是可能的。如果您最喜欢的语言是 Python 或 Ruby,也许您不会介意付出额外的努力来确保它安装在您的软件需要运行的每个平台上:-)

简单的工具包括headtailgrepsortcuttrsedjoin(合并 2 个文件时)和awk单行语句等。有些人可以通过模式匹配和sed命令来完成令人惊奇的事情。

当它变得更加复杂,并且您确实必须对每一行应用一些逻辑时,awk这是一个不错的选择 - 要么是单行(有些人将整个 awk 脚本放在“一行”中,尽管这不是很可读),要么是在简短的外部脚本。

作为awk一种解释性语言(如您的 shell),它能够如此高效地进行逐行处理真是令人惊奇,但它是专门为此构建的,而且速度确实非常快。

还有Perl大量其他脚本语言非常擅长处理文本文件,并且还附带许多有用的库。

最后,如果你需要的话,还有古老的 C 语言最大速度灵活性高(虽然文本处理有点繁琐)。但是,为遇到的每个不同的文件处理任务编写一个新的 C 程序可能是一种非常浪费时间的做法。我经常使用 CSV 文件,因此我用 C 语言编写了几个通用实用程序,可以在许多不同的项目中重复使用它们。实际上,这扩展了我可以从 shell 脚本调用的“简单、快速的 Unix 工具”的范围,因此我只需编写脚本就可以处理大多数项目,这比每次编写和调试定制的 C 代码要快得多!

最后一些提示:

  • 不要忘记以 启动你的主 shell 脚本export LANG=C,否则许多工具会将你的普通旧 ASCII 文件视为 Unicode,从而使它们慢得多
  • export LC_ALL=C如果您想sort无论环境如何都产生一致的排序,还可以考虑设置!
  • 如果您需要sort数据,这可能会比其他所有事情花费更多的时间(和资源:CPU、内存、磁盘),因此请尽量减少命令的数量sort和它们正在排序的文件的大小
  • 如果可能的话,单个管道通常是最有效的 - 使用中间文件按顺序运行多个管道可能更具可读性和可调试性,但会增加程序花费的时间

答案4

接受的答案很好,因为它清楚地说明了在 shell 中解析文本文件的缺点,但人们一直在崇拜主要思想(主要是 shell 脚本不能很好地处理文本处理任务)来批评任何使用 shell 循环的东西。

shell 循环本身并没有什么问题,就 shell 脚本中的循环或循环外的命令替换没有任何问题而言。确实,在大多数情况下,您可以用更惯用的结构来替换它们。例如,不要写

for i in $(find . -iname "*.txt"); do
...
done

写这个:

for i in *.txt; do
...
done

在其他场景中,最好依靠更专业的工具,例如具有awk良好文本处理能力的通用编程语言(例如 perl、python、ruby)或特定文件类型(XML sed、HTML 、JSON)cutjoinpastedatamashmiller

话虽如此,使用 shell 循环是正确的选择,只要您知道:

  1. 性能不是优先考虑的事情。您的脚本运行速度是否重要?您是否每隔几个小时运行一次任务作为 cron 作业?那么也许性能就不是问题了。或者,如果是,请运行基准测试以确保您的 shell 循环不是瓶颈。关于什么工具“快”或“慢”的直觉或先入为主的观念不能代替准确的基准。
  2. 保持易读性。如果您在 shell 循环中添加了太多逻辑以致难以遵循,那么您可能需要重新考虑这种方法。
  3. 复杂性并没有大幅增加。
  4. 安全性得以保留。
  5. 可测试性不会成为问题。正确测试 shell 脚本已经很困难了。如果使用外部命令使您更难知道代码中何时存在错误,或者您在关于返回值的错误假设下工作,那么这就是一个问题。
  6. shell 循环与替代循环具有相同的语义或者这些差异对于你现在正在做的事情并不重要。例如,find上面的命令递归到子目录并匹配名称以.. (如果您的文件名称中包含空格,则两者都可能出现问题。)

作为一个例子,证明满足前面的陈述并不是不可能的任务,这是一个著名商业软件的安装程序中使用的模式:

i=1
MD5=... # embedded checksum
for s in $sizes
do
    checksum=`echo $VAR | cut -d" " -f $i`
    if <checksum condition>; then
       md5=`echo $MD5 | cut -d" " -f $i
       ...
done

它运行的次数非常少,其目的明确,简洁,不会增加不必要的复杂性,不使用用户控制的输入,因此,安全性不是问题。它在循环中调用其他进程重要吗?一点也不。

相关内容