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

cuo*_*glm 241 shell text-processing

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

正如Stéphane Chazelas 所指出的,不使用 shell 循环的一些原因是概念可靠性易读性性能安全性

这个答案解释了可靠性易读性方面:

while IFS= read -r line <&3; do
  printf '%s\n' "$line"
done 3< "$InputFile"
Run Code Online (Sandbox Code Playgroud)

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

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

Sté*_*las 328

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

while read line; do
  echo $line | cut -c3
done
Run Code Online (Sandbox Code Playgroud)

或者更糟:

for line in `cat file`; do
  foo=`echo $line | awk '{print $2}'`
  echo whatever $foo
done
Run Code Online (Sandbox Code Playgroud)

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

通常来自shell脚本初学者。这些是你在命令式语言(如 C 或 python)中所做的事情的幼稚字面翻译,但这不是你在 shell 中做事的方式,而且这些例子非常低效,完全不可靠(可能导致安全问题),如果你曾经管理过为了修复大多数错误,您的代码变得难以辨认。

概念上

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

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

Unix 引入的一项伟大功能是管道和所有命令默认处理的默认 stdin/stdout/stderr 流。

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

你有一个切割工具和一个音译工具,你可以简单地做:

cut -c4-5 < in | tr a b > out
Run Code Online (Sandbox Code Playgroud)

shell 只是在做管道(打开文件,设置管道,调用命令),当一切准备就绪时,它只是在没有 shell 做任何事情的情况下运行。这些工具可以同时高效地按照自己的节奏完成工作,并具有足够的缓冲,以免一个阻止另一个,它既漂亮又简单。

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

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

while read line; do
  echo $line | cut -c3
done < file
Run Code Online (Sandbox Code Playgroud)

就像对于文件的每一行,read从厨房抽屉中取出工具(非常笨拙,因为它不是为此设计的),阅读一行,清洗阅读工具,然后将其放回抽屉。然后安排了一个会议echocut工具,从抽屉得到他们,调用它们,把它们洗干净,擦干,将它们放回抽屉等。

其中的一些工具(readecho)是建立在大多数炮弹,但很难使一个区别就在这里,因为echocut仍然需要在单独的进程中运行。

这就像切一个洋葱,但洗完你的刀,然后在每片之间把它放回厨房的抽屉里。

这里显而易见的方法是cut从抽屉里拿出你的工具,把你的整个洋葱切成薄片,然后在整个工作完成后把它放回抽屉里。

IOW,在 shell 中,尤其是处理文本时,您调用尽可能少的实用程序并让它们协作完成任务,而不是依次运行数千个工具,等待每个工具启动、运行、清理,然后再运行下一个。

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

表现

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

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

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

相应的偶数内置 shell 实用程序 ( read, echo, printf) 不能这样做。read旨在阅读一行。如果它读取超过换行符,则意味着您运行的下一个命令将错过它。因此read必须一次读取一个字节(如果输入是常规文件,则某些实现会进行优化,因为它们读取块并返回,但这仅适用于常规文件,bash例如仅读取 128 字节块,这是仍然比文本实用程序少很多)。

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

显然,按顺序运行命令意味着您必须等待它们,这是一个小小的调度程序舞蹈,可以从外壳程序控制到工具,然后返回。这也意味着(与在管道中使用长时间运行的工具实例相反)您不能在可用时同时利用多个处理器。

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

while read line; do
  echo ${line:2:1}
done
Run Code Online (Sandbox Code Playgroud)

(此处为bash),仍约为 1:600(一秒对 10 分钟)。

可靠性/易读性

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

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

使用默认值$IFS, 在如下输入上:

   foo\/bar \
baz
biz
Run Code Online (Sandbox Code Playgroud)

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

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

IFS= read -r line
Run Code Online (Sandbox Code Playgroud)

这不是很直观,但这就是它的方式,请记住,shell 不应该这样使用。

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

当然,有一种典型的忘记引用每个人都会陷入的变量的情况。所以更多的是:

while IFS= read -r line; do
  printf '%s\n' "$line" | cut -c3
done < file
Run Code Online (Sandbox Code Playgroud)

现在,还有一些警告:

  • 除了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
Run Code Online (Sandbox Code Playgroud)

这变得越来越不清晰。

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

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

安全考虑

当您开始使用 shell变量命令参数时,您就进入了一个雷区。

如果您忘记引用您的变量,忘记选项标记结尾,在具有多字节字符的语言环境中工作(这些天的规范),您肯定会引入迟早会成为漏洞的错误。

当您可能想要使用循环时。

待定

  • 清晰(生动)、可读且非常有帮助。再一次谢谢你。这实际上是我在互联网上看到的关于 shell 脚本和编程之间根本区别的最佳解释。 (30认同)
  • @OlivierDulac 我认为这有点幽默。该部分将永远待定。 (11认同)
  • _“45 年来,我们没有找到比该 API 更好的方法来利用命令的力量并让它们合作完成任务。”_ - 实际上,PowerShell 已经通过传递结构化数据解决了可怕的解析问题而不是字节流。shell 还没有使用它的唯一原因(这个想法已经存在了很长一段时间,并且当现在标准的列表和字典容器类型成为主流时,这个想法基本上已经在 J​​ava 的某个时候具体化了)是他们的维护者还不能就要使用的通用结构化数据格式 (. (8认同)
  • 但是如果你有一个没有任何特殊字符的小文本并且你一个人在一个房间里没有任何堆栈交换用户在你身边,我认为你仍然应该写“while read line”没有问题,;-)这会让你成为一个初学者几秒钟......但它可能会起作用! (4认同)
  • “这就像切洋葱,但把刀洗干净,然后把它放回厨房的抽屉里。” 很棒的比喻。我坐在那里目瞪口呆。20 年后,我仍然像初学者一样编写 shell 代码。因为“把事情做好”是当务之急。我的工作没有优雅,没有效率。 (4认同)
  • 像这样的帖子可以帮助初学者了解 Shell 脚本并看到它的细微差别。应该将引用变量添加为 ${VAR:-default_value} 以确保您不会得到空值。并设置 -o nounset 在引用未定义的值时对您大喊大叫。 (3认同)
  • @ivan_pozdeev。不知道 PowerShell 做什么,但我看不到 shell 将如何参与其中。结构化数据必须由外壳互连的应用程序生成和理解。像 bash 这样的 shell 用来互连应用程序的管道所使用的字节流能够很好地承载任何结构化数据。这里的问题是唯一得到广泛支持的 _structure_ 是该行(并且是通过严重限制实现的:例如使用字节分隔符,有时长度有限,有时不允许字节值)。或者我错过了什么? (3认同)
  • 正是由于缺乏任何商定的标准,当涉及任意数据时,程序很难可靠地相互理解。PowerShell 所做的是“切断中间人”,提供该标准本身,不需要程序部分的编码和解码(并且还提供专用工具来解析/编码以与仍然使用非结构化流的程序交互)。 (3认同)
  • 这基本上是每种编程语言对容器数据类型所做的事情(Perl 的目标和核心语法特性是专门设计的,目的是将非结构化数据解析为结构化数据,以便能够比 shell 更有效和可靠地处理它),但对于每种语言,这有效地始终仅限于链接在一起的该语言中的代码块,仅此而已。 (3认同)
  • @StéphaneChazelas:因为这个答案的结尾说:“当你可能想使用循环时。TBD”:我经常回来这个答案(和其他答案),这部分需要确定......我希望有一天你会找到时间来完成它:) (3认同)
  • 您可以通过管道将循环传递到其他内容中,这有时对诸如 *.bar 中的 `for foo; 之类的东西很有用。做一些命令“$foo”;完成 | ` 后面是一些合理的文本处理管道,其中 `some_command` 是一个不喜欢获取多个参数的东西,所以你不能直接将 glob 交给它。如果您的命令出于某种奇怪的原因害怕文件扩展名,您也可以执行 `some_command "${foo%.bar}"`。其中大部分是 `some_command` 中的设计缺陷,但外壳的全部意义在于提供管道胶带来解决这些缺陷。 (3认同)
  • @countermode, `cat file`, `awk &lt; file '{...}'`, `perl -ne '...' &lt; file`, `sed '...' &lt; file`... `cut . .. &lt; 文件 | 特... | 粘贴...`。`for i in $(...)` 比 `while read` 循环更糟糕,因为它调用 split+glob 运算符,大多数人在使用前忘记调整该运算符,并且还意味着将整个文件内容存储在内存中(多次) ,并且以非常低效的方式使用一些 shell 实现,例如“bash”)。 (2认同)
  • PowerShell 的设计者本质上是第一个成功地将其应用于任意程序的人(早先曾尝试使用 XML 作为交换格式复制 POSIX 工具,但由于 XML 不是流式/二进制/松散结构的数据)。 (2认同)
  • 我还注意到作者把 shell 循环搞得一团糟。不知道其他 shell 的情况,但在 bash 中它只是 `while IFS= read -r line || [ -n "$line" ]; 执行 echo "$line" ...; 完成`。最后一个条件不需要。至于 echo 扩展序列 - 在 bash 中它是错误的(没有“-e”)。文件句柄 3 不必要地引入,如果需要的话可以使用 `cut .. &lt;/dev/null` 来完成。 (2认同)

Bru*_*ger 47

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

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


Lau*_*haw 28

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

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

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

但是当我们开始处理可能有数千或数百万行的大文件时,shell 脚本为每一行花费一秒钟的时间(即使只有几十毫秒)是不好的,因为这可能会加起来几个小时。

这就是我们需要使用其他工具的时候,Unix shell 脚本的美妙之处在于它们让我们很容易做到这一点。

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

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

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

简单的工具包括head, tail, grep, sort, cut, tr, sed, join(合并 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命令的数量和它们正在排序的文件的大小
  • 如果可能,单个管道通常是最有效的——按顺序运行多个管道,带有中间文件,可能更具可读性和可调试性,但会增加程序花费的时间

  • 许多简单工具的管道(特别是提到的工具,如 head、tail、grep、sort、cut、tr、sed 等)经常被不必要地使用,特别是如果您在该管道中已经有一个 awk 实例可以做那些简单工具的任务也是如此。另一个需要考虑的问题是,在管道中,您不能简单而可靠地将状态信息从管道前端的进程传递到出现在后端的进程。如果您将 awk 程序用于此类简单程序的管道,则您将拥有单个状态空间。 (9认同)
  • 设置“LC_ALL”已被过度使用,因为它会覆盖所有可能的区域设置,而不仅仅是影响输入解析的设置。为了确保“sort”一致地工作,“LC_COLLATE=C”是适当的设置。也就是说,“LC_ALL=C.UTF-8”具有与“C”相同的排序,并且不会破坏需要处理 Unicode 字符的工具。 (2认同)

F. *_*uri 15

对,但是...

Stéphane Chazelas正确答案是基于的每个文本操作委托给特定的二进制数据,像观grepawksed和其他人。

作为 能够自己做很多事情,所以放下叉子可能会变得更快(甚至比运行另一个解释器来完成所有工作)。

例如,看看这篇文章:

/sf/answers/2715330971/

/sf/answers/502605491/

测试和比较...

当然

没有考虑用户输入安全性

不要在下编写 web 应用程序!!

但是对于许多服务器管理任务,可以使用代替,使用内置 bash 可能非常有效。

我的看法:

编写bin utils 之类的工具与系统管理不同。

所以不是同一个人!

系统管理员必须知道shell,他们可以写原型通过使用他的最佳(和最有名的)工具。

如果这个新实用程序(原型)真的有用,其他一些人可以使用一些更合适的语言开发专用工具。

  • 好例子。您的方法肯定比 lololux 更有效,但请注意 [tensibai 的答案](http://stackoverflow.com/a/32327705)(IMO 执行此操作的正确方法,即不使用 shell 循环)是几个数量级比你的快。如果你**不**使用`bash`,你的速度会快很多。(在我的系统测试中,ksh93 的速度是其速度的 3 倍多)。`bash` 通常是最慢的 shell。甚至“zsh”在该脚本上的速度也是两倍。您还存在一些关于未加引号的变量和“read”的使用的问题。所以你实际上在这里说明了我的很多观点。 (2认同)
  • @Tensibai,你会发现 [POSIX `sh`](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_01), [Awk](http://pubs.opengroup.org/ onlinepubs/9699919799/utilities/awk.html)、[Sed](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/sed.html)、[`grep`](http://pubs.opengroup. org/onlinepubs/9699919799/utilities/grep.html)、`ed`、[`ex`](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/ex.html)、`cut`、`sort `, [`join`](http://pubs.opengroup.org/onlinepubs/9699919799/utilities/join.html)...所有这些都比 Bash * 或 * Perl 更可靠。 (2认同)
  • @Tensibai,在 U&amp;L 关注的所有系统中,大多数(Solaris、FreeBSD、HP/UX、AIX、大多数嵌入式 Linux 系统...)默认情况下都没有安装“bash”。`bash` 主要只在 Apple macOS 和 GNU 系统上找到(我想这就是你所谓的_主要发行版_),尽管许多系统也将它作为可选包(如 `zsh`、`tcl`、`python`... ) (2认同)
  • @Stephane 好吧,Cisco nexus 确实使用 bash、checkpoint、f5,当心,bluecoat 也(我确实称之为嵌入式系统)我正在 Linux 上确定发行版,但我几乎不记得没有 bash 的 hp/ux 或 Aix,甚至 Aix 仿真在 os400 内我见过广告吧。但无论如何,重点是“获得 bash 的机会比 perl 更好”,我完全同意保持 posix 兼容应该是编写“可移植”代码时的主要目标。 (2认同)

归档时间:

查看次数:

54743 次

最近记录:

4 年,5 月 前