原文:https://datascienceatthecommandline.com/2e/chapter-4-creating-command-line-tools.html
在整本书中,我将向您介绍许多基本上适合一行的命令和管道。这些被称为一行程序或管道。能够只用一行程序执行复杂的任务是命令行的强大之处。这是一种与编写和使用传统程序截然不同的体验。
有些任务你只执行一次,有些任务你执行得更频繁。有些任务非常具体,有些则可以概括。如果您需要定期重复某个命令行程序,那么将它变成自己的命令行工具是值得的。因此,一行程序和命令行工具都有它们的用途。识别机会需要练习和技巧。命令行工具的优点是您不必记住整个一行程序,并且如果您将它包含到其他管道中,它会提高可读性。在这个意义上,你可以把命令行工具想象成类似于编程语言中的一个函数。
然而,使用编程语言的好处是代码在一个或多个文件中。这意味着您可以轻松地编辑和重用这些代码。如果代码有参数,它甚至可以被一般化,并重新应用于遵循类似模式的问题。
命令行工具具有两个世界的优点:它们可以从命令行使用,接受参数,并且只需创建一次。在这一章中,你将熟悉用两种方式创建命令行工具。首先,我解释了如何将这些一行程序转换成可重用的命令行工具。通过在命令中添加参数,您可以增加编程语言提供的灵活性。随后,我将演示如何从用编程语言编写的代码中创建可重用的命令行工具。遵循 Unix 的理念,您的代码可以与其他命令行工具结合使用,这些工具可能是用完全不同的语言编写的。在这一章中,我将重点介绍三种编程语言:Bash、Python 和 R。
我相信,从长远来看,创建可重用的命令行工具会使您成为一名更高效的数据科学家。您将逐步构建自己的数据科学工具箱,从中可以提取现有工具,并将其应用于您之前遇到的问题。它需要实践来识别将一行程序或现有代码转化为命令行工具的机会。
为了将命令行变为 Shell 脚本, 我们会用一点 Shell 脚本语言. 这本书仅仅会展示一些较少的 Shell 变成概念, 包括变量, 判断和循环。完整的 Shell 变成教程应有一本专门的书来讲述它, 所以超出了这本书的范围. 如果你想更深入的了解 Shell 编程, 我推荐 Arnold Robbins 和 Nelson H. F. Beebe 写的《Shell 编程经典》这本书。
在本章中,您将学习如何:
本章从以下文件开始:
$ cd /data/ch04
$ l
total 32K
-rwxr-xr-x 1 dst dst 400 Mar 3 10:42 fizzbuzz.py*
-rwxr-xr-x 1 dst dst 391 Mar 3 10:42 fizzbuzz.R*
-rwxr-xr-x 1 dst dst 182 Mar 3 10:42 stream.py*
-rwxr-xr-x 1 dst dst 147 Mar 3 10:42 stream.R*
-rwxr-xr-x 1 dst dst 105 Mar 3 10:42 top-words-4.sh*
-rwxr-xr-x 1 dst dst 128 Mar 3 10:42 top-words-5.sh*
-rwxr-xr-x 1 dst dst 647 Mar 3 10:42 top-words.py*
-rwxr-xr-x 1 dst dst 584 Mar 3 10:42 top-words.R*
获取这些文件的说明在第二章中。任何其他文件都是使用命令行工具下载或生成的。
在这一节中,我将解释如何把一行程序变成一个可重用的命令行工具。比方说,您想获得一段文本中使用频率最高的单词。以刘易斯·卡罗尔的《爱丽丝漫游仙境》为例,这本书和许多其他伟大的书籍一样,可以在古腾堡计划上免费获得。
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" | trim
The Project Gutenberg eBook of Alice’s Adventures in Wonderland, by Lewis …
This eBook is for the use of anyone anywhere in the United States and
most other parts of the world at no cost and with almost no restrictions
whatsoever. You may copy it, give it away or re-use it under the terms
of the Project Gutenberg License included with this eBook or online at
www.gutenberg.org. If you are not located in the United States, you
will have to check the laws of the country where you are located before
using this eBook.
… with 3751 more lines
以下顺序的工具或管道应该可以完成这项工作:
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" | # ➊
> tr '[:upper:]' '[:lower:]' | # ➋
> grep -oE "[a-z\']{2,}" | # ➌
> sort | # ➍
> uniq -c | # ➎
> sort -nr | # ➏
> head -n 10 # ➐
1839 the
942 and
811 to
638 of
610 it
553 she
486 you
462 said
435 in
403 alice
➊ 使用curl
下载电子书。
➋ 使用tr
将整个文本转换成小写。
➌ 使用grep
提取所有单词,并将每个单词放在单独的行上。
➍ 用sort
将这些单词按字母顺序排序。
➎ 去掉所有重复的,用uniq
统计每个单词在列表中出现的频率。
➏ 使用sort
按计数降序排列这个独特单词列表。
使用head
只保留前 10 行(即单词)。
这些词确实在文章中出现得最多。因为这些单词(除了单词alice
)在许多英语文本中出现得非常频繁,所以它们没有什么意义。事实上,这些被称为停用词。如果我们去掉这些,我们会保留与这篇文章相关的最常用的词。
以下是我找到的停用词列表:
$ curl -sL "https://raw.githubusercontent.com/stopwords-iso/stopwords-en/master/
stopwords-en.txt" |
> sort | tee stopwords | trim 20
10
39
a
able
ableabout
about
above
abroad
abst
accordance
according
accordingly
across
act
actually
ad
added
adj
adopted
ae
… with 1278 more lines
使用grep
,我们可以在开始计数之前过滤掉停用词:
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
> tr '[:upper:]' '[:lower:]' |
> grep -oE "[a-z\']{2,}" |
> sort |
> grep -Fvwf stopwords | # ➊
> uniq -c |
> sort -nr |
> head -n 10
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
➊ 从一个文件中获取模式(在我们的例子中是停用词),每行一个,用-f
。用-F
将那些模式解释为固定字符串。只选择那些包含与-w
构成完整单词的匹配的行。用-v
选择不匹配的行。
每一个命令行都提供了一个帮助说明. 所以如果你想知道更多, 比如说, grep
, 你可以运行man grep
命令. 命令tr
, grep
, uniq
, 和sort
会在下章中讨论更加详细的用法。
只运行一次这个一行程序没有任何问题。然而,想象一下,如果你想拥有古腾堡计划中每本电子书的前 10 个单词。或者想象一下,你想要一个新闻网站每小时的前 10 个单词。在这种情况下,最好将这个一行程序作为一个单独的构建块,可以成为更大的东西的一部分。为了在参数方面给这个一行程序增加一些灵活性,让我们把它变成一个 Shell 脚本。
这允许我们以一行程序为起点,并逐步对其进行改进。为了将这个一行程序变成一个可重用的命令行工具,我将带您完成以下六个步骤:
第一步是创建一个新文件。您可以打开您最喜欢的文本编辑器,复制并粘贴这个一行程序。让我们将这个文件命名为top-words-1.sh
,以表明这是我们新的命令行工具的第一步。如果您喜欢呆在命令行,您可以使用内置的fc
,它代表“修复命令”,并允许您修复或编辑上次运行的命令。
$ fc
运行fc
调用默认的文本编辑器,它存储在环境变量编辑器中。在 Docker 容器中,这被设置为nano
,一个简单的文本编辑器。如您所见,该文件包含我们的一行程序:
GNU nano 5.4 /tmp/zshxzOKMw curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
[ Read 8 lines ]
^G Help ^O Write Out ^W Where Is ^K Cut ^T Execute ^C Location
^X Exit ^R Read File ^\ Replace ^U Paste ^J Justify ^_ Go To Line
让我们通过按下Ctrl-O
,删除临时文件名,并键入top-words-1.sh
来给这个临时文件一个合适的名称:
GNU nano 5.4 /tmp/zshxzOKMw curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
File Name to Write: top-words-1.sh
^G Help M-D DOS Format M-A Append M-B Backup File
^C Cancel M-M Mac Format M-P Prepend ^T Browse
按下Enter
:
GNU nano 5.4 /tmp/zshxzOKMw curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
Save file under DIFFERENT NAME?
Y Yes
N No ^C Cancel
按下Y
确认您要以不同的文件名保存:
GNU nano 5.4 top-words-1.sh curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
[ Wrote 8 lines ]
^G Help ^O Write Out ^W Where Is ^K Cut ^T Execute ^C Location
^X Exit ^R Read File ^\ Replace ^U Paste ^J Justify ^_ Go To Line
按下Ctrl-X
退出nano
,回到你来的地方。
我们正在使用文件扩展名.sh
说明我们正在创建一个 Shell 脚本。然而,命令行工具不需要有扩展。事实上,命令行工具很少有扩展。
确认文件的内容:
$ pwd
/data/ch04
$ l
total 44K
-rwxr-xr-x 1 dst dst 400 Mar 3 10:42 fizzbuzz.py*
-rwxr-xr-x 1 dst dst 391 Mar 3 10:42 fizzbuzz.R*
-rw-r--r-- 1 dst dst 7.5K Mar 3 10:42 stopwords
-rwxr-xr-x 1 dst dst 182 Mar 3 10:42 stream.py*
-rwxr-xr-x 1 dst dst 147 Mar 3 10:42 stream.R*
-rw-r--r-- 1 dst dst 173 Mar 3 10:42 top-words-1.sh
-rwxr-xr-x 1 dst dst 105 Mar 3 10:42 top-words-4.sh*
-rwxr-xr-x 1 dst dst 128 Mar 3 10:42 top-words-5.sh*
-rwxr-xr-x 1 dst dst 647 Mar 3 10:42 top-words.py*
-rwxr-xr-x 1 dst dst 584 Mar 3 10:42 top-words.R*
$ bat top-words-1.sh ───────┬────────────────────────────────────────────────────────────────────────
│ File: top-words-1.sh
───────┼────────────────────────────────────────────────────────────────────────
1 │ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
2 │ tr '[:upper:]' '[:lower:]' |
3 │ grep -oE "[a-z\']{2,}" |
4 │ sort |
5 │ grep -Fvwf stopwords |
6 │ uniq -c |
7 │ sort -nr |
8 │ head -n 10
───────┴────────────────────────────────────────────────────────────────────────
你现在可以使用bash
来解释和执行文件中的命令:
$ bash top-words-1.sh
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
这可以避免您下次再次输入一行程序。
然而,因为该文件不能独立执行,所以它还不是一个真正的命令行工具。让我们在下一步中改变这一点。
我们不能直接执行文件的原因是我们没有正确的访问权限。特别是,作为用户,您需要拥有执行该文件的权限。在本节中,我们将更改文件的访问权限。
为了比较步骤之间的差异,使用cp -v top-words-{1,2}.sh
将文件复制到top-words-2.sh
。
如果你想验证括号扩展或者其他形式的文件扩展会导致什么, 用echo
代替命令把结果打印出来. 比如, echo book_{draft,final}.md
or echo agent-{001..007}
.
要更改文件的访问权限,我们需要使用一个名为chmod
的命令行工具,代表更改模式。它改变特定文件的文件模式位。以下命令授予用户你执行top-words-2.sh
的权限:
$ cp -v top-words-{1,2}.sh
'top-words-1.sh' -> 'top-words-2.sh'
$ chmod u+x top-words-2.sh
参数u+x
由三个字符组成:(1)u
表示我们要为拥有该文件的用户,也就是您,更改权限,因为您创建了该文件;(2)+
表明我们要添加一个权限;以及(3)x
,其指示执行的权限。
现在让我们来看看这两个文件的访问权限:
$ l top-words-{1,2}.sh
-rw-r--r-- 1 dst dst 173 Mar 3 10:42 top-words-1.sh
-rwxr--r-- 1 dst dst 173 Mar 3 10:42 top-words-2.sh*
第一列显示每个文件的访问权限。对于top-words-2.sh
,这里是-rwxrw-r--
。第一个字符-
(连字符)表示文件类型。一个-
表示常规文件,一个d
表示目录。接下来的三个字符,rwx
,表示拥有该文件的用户的访问权限。r
和w
分别表示读和写 。(你可以看到,top-words-1.sh
有一个-
而不是一个x
,这意味着我们不能执行那个文件。)接下来的三个字符rw-
表示拥有该文件的组的所有成员的访问权限。最后,列中的最后三个字符,r--
,表示所有其他用户的访问权限。
现在,您可以执行该文件,如下所示:
$ ./top-words-2.sh
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
如果您试图执行一个您没有正确访问权限的文件,如top-words-1.sh
,您将看到以下错误消息:
$ ./top-words-1.sh
zsh: permission denied: ./top-words-1.sh
虽然我们已经可以单独执行文件,但是我们应该在文件中添加一个所谓的 Shebang。 Shebang 是脚本中的一个特殊行,它指示系统应该使用哪个可执行文件来解释命令。
Shebang 这个名字来源于前两个字:一个井号(She)和一个感叹号(Bang):#!
。就像我们在上一步中所做的那样,忽略它并不是一个好主意,因为每个 Shell 都有不同的默认可执行文件。如果没有定义 Shebang,我们在整本书中使用的 ZShell 默认使用可执行文件/bin/sh
。在这种情况下,我希望bash
将命令解释为比sh
给我们更多的功能。
同样,你可以随意使用任何你喜欢的编辑器,但我将坚持使用nano
,它安装在 Docker 映像中。
$ cp -v top-words-{2,3}.sh
'top-words-2.sh' -> 'top-words-3.sh'
$ nano top-words-3.sh
GNU nano 5.4 top-words-3.sh curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
[ Read 8 lines ]
^G Help ^O Write Out ^W Where Is ^K Cut ^T Execute ^C Location
^X Exit ^R Read File ^\ Replace ^U Paste ^J Justify ^_ Go To Line
继续输入#!/usr/bin/env/bash
,然后按Enter
。准备好后,按Ctrl-X
保存并退出。
GNU nano 5.4 top-words-3.sh * #!/usr/bin/env bash curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
Save modified buffer?
Y Yes
N No ^C Cancel
按下Y
以表示您想要保存文件。
GNU nano 5.4 top-words-3.sh * #!/usr/bin/env bash curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
tr '[:upper:]' '[:lower:]' |
grep -oE "[a-z\']{2,}" |
sort |
grep -Fvwf stopwords |
uniq -c |
sort -nr |
head -n 10
File Name to Write: top-words-3.sh
^G Help M-D DOS Format M-A Append M-B Backup File
^C Cancel M-M Mac Format M-P Prepend ^T Browse
让我们确认一下top-words-3.sh
是什么样子的:
$ bat top-words-3.sh ───────┬────────────────────────────────────────────────────────────────────────
│ File: top-words-3.sh
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env bash
2 │ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" |
3 │ tr '[:upper:]' '[:lower:]' |
4 │ grep -oE "[a-z\']{2,}" |
5 │ sort |
6 │ grep -Fvwf stopwords |
7 │ uniq -c |
8 │ sort -nr |
9 │ head -n 10
───────┴────────────────────────────────────────────────────────────────────────
这正是我们所需要的:我们的原始管道,前面有一个 Shebang。
有时,您会遇到以!/usr/bin/bash
或!/usr/bin/python
形式出现的脚本(对于 Python,我们将在下一节中看到)。虽然这通常是可行的,但是如果将bash
或python
可执行文件安装在与/usr/bin
不同的位置,那么该脚本将不再有效。最好使用我这里呈现的形式,即!/usr/bin/env bash
和!/usr/bin/env python
,因为env
可执行文件知道bash
和python
安装在哪里。简而言之,使用env
使您的脚本更具可移植性。
我们知道有一个有效的命令行工具,我们可以从命令行执行。但是我们可以做得更好。我们可以使我们的命令行工具更加可重用。我们文件中的第一个命令是curl
,它下载我们希望从中获得前 10 个最常用单词的文本。所以,数据和操作合二为一。
如果我们想从另一本电子书或任何其他文本中获得 10 个最常用的单词,会怎么样呢?输入数据在工具本身中是固定的。最好将数据从命令行工具中分离出来。
如果我们假设命令行工具的用户将提供文本,那么该工具将变得普遍适用。因此,解决方案是从脚本中删除curl
命令。下面是名为top-words-4.sh
的更新脚本:
$ cp -v top-words-{3,4}.sh
'top-words-3.sh' -> 'top-words-4.sh'
$ sed -i '2d' top-words-4.sh
$ bat top-words-4.sh ───────┬────────────────────────────────────────────────────────────────────────
│ File: top-words-4.sh
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env bash
2 │ tr '[:upper:]' '[:lower:]' |
3 │ grep -oE "[a-z\']{2,}" |
4 │ sort |
5 │ grep -Fvwf stopwords |
6 │ uniq -c |
7 │ sort -nr |
8 │ head -n 10
───────┴────────────────────────────────────────────────────────────────────────
这是因为如果一个脚本从一个需要来自标准输入的数据的命令开始,比如tr
,它将接受提供给命令行工具的输入。例如:
$ curl -sL 'https://www.gutenberg.org/files/11/11-0.txt' | ./top-words-4.sh
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
$ curl -sL 'https://www.gutenberg.org/files/12/12-0.txt' | ./top-words-4.sh
469 alice
189 queen
98 gutenberg
88 project
72 time
71 red
70 white
67 king
63 head
59 knight
$ man bash | ./top-words-4.sh
585 command
332 set
313 word
313 option
304 file
300 variable
298 bash
258 list
257 expansion
238 history
虽然我们没有在脚本里这样做, 但是我们仍然应该保存数据. 通常, 让用户使用输出重定向比在脚本里写明输出到哪个文件好. 当然, 如果你打算只在里自己的项目里使用命令行工具, 那么是否清楚的写明文件路径就没有什么限制了.
为了使我们的命令行工具更加可重用,还有一个步骤:参数。在我们的命令行工具中,有许多固定的命令行参数,例如用-nr
代表sort
,用-n 10
代表head
。最好保持前一个论点不变。然而,允许head
命令有不同的值是非常有用的。这将允许最终用户设置输出最常用的单词的数量。下面显示了我们的文件top-words-5.sh
的样子:
$ bat top-words-5.sh ───────┬────────────────────────────────────────────────────────────────────────
│ File: top-words-5.sh
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env bash
2 │
3 │ NUM_WORDS="${1:-10}"
4 │
5 │ tr '[:upper:]' '[:lower:]' |
6 │ grep -oE "[a-z\']{2,}" |
7 │ sort |
8 │ grep -Fvwf stopwords |
9 │ uniq -c |
10 │ sort -nr |
11 │ head -n "${NUM_WORDS}"
───────┴────────────────────────────────────────────────────────────────────────
NUM_WORDS
被设置为$1
的值,这是 Bash 中的一个特殊变量。它保存传递给我们的命令行工具的第一个命令行参数的值。下表列出了 Bash 提供的其他特殊变量。如果没有指定值,它将采用值10
$NUM_WORDS
变量的值,您需要在它前面放一个美元符号。当你设置它的时候,你并没有写一个美元符号。我们也可以直接使用$1
作为head
的参数,而不必费心创建一个额外的变量,比如NUM_WORDS
。然而,有了更大的脚本和更多的命令行参数,如$2
和$3
,当您使用命名变量时,您的代码变得更具可读性。
现在,如果您想查看我们文本中最常用的 20 个单词,我们将调用我们的命令行工具,如下所示:
$ curl -sL "https://www.gutenberg.org/files/11/11-0.txt" > alice.txt
$ < alice.txt ./top-words-5.sh 20
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
53 rabbit
50 head
48 voice
45 looked
44 mouse
42 duchess
40 tone
40 dormouse
37 cat
34 march
如果用户没有指定数字,那么我们的脚本将显示前 10 个最常用的单词:
$ < alice.txt ./top-words-5.sh
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
经过前面的五个步骤,我们终于完成了构建一个可重用的命令行工具。然而,还有一个非常有用的步骤。在这个可选步骤中,我们将确保您可以从任何地方执行您的命令行工具。
目前,当您想要执行您的命令行工具时,您要么必须导航到它所在的目录,要么包括完整的路径名,如步骤 2 所示。如果命令行工具是专门为某个项目而构建的,这是没问题的。然而,如果你的命令行工具可以应用于多种情况,那么从任何地方执行它都是有用的,就像 Ubuntu 自带的命令行工具一样。
为了实现这一点,Bash 需要知道在哪里可以找到您的命令行工具。它通过遍历存储在名为PATH
的环境变量中的目录列表来实现这一点。在一个新的 Docker 容器中,PATH
如下所示:
$ echo $PATH
/usr/local/lib/R/site-library/rush/exec:/usr/bin/dsutils:/home/dst/.local/bin:/u
sr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
目录由冒号分隔。我们可以通过将冒号转换为换行符,将其打印为目录列表:
$ echo $PATH | tr ':' '\n'
/usr/local/lib/R/site-library/rush/exec
/usr/bin/dsutils
/home/dst/.local/bin
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/sbin
/bin
要永久更改PATH
,您需要编辑位于您的主目录中的.bashrc
或.bash_profile
。如果您将所有自定义命令行工具放在一个目录中,比如说,~/tools
,那么您只需更改一次PATH
。现在,您不再需要添加./
,但也可以只用文件名。此外,您不再需要记住命令行工具的位置。
$ cp -v top-words{-5.sh,}
'top-words-5.sh' -> 'top-words'
$ export PATH="${PATH}:/data/ch04"
$ echo $PATH
/usr/local/lib/R/site-library/rush/exec:/usr/bin/dsutils:/home/dst/.local/bin:/u
sr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/data/ch04
$ curl "https://www.gutenberg.org/files/11/11-0.txt" |
> top-words 10
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 170k 100 170k 0 0 145k 0 0:00:01 0:00:01 --:--:-- 145k
403 alice
98 gutenberg
88 project
76 queen
71 time
63 king
60 turtle
57 mock
56 hatter
55 gryphon
我们在上一节创建的命令行工具是用 Bash 编写的。(当然,并不是 Bash 编程语言的所有特性都被采用了,但是解释器仍然是bash
。)正如您现在所知道的,命令行是语言不可知的,所以我们不一定要使用 Bash 来创建命令行工具。
在这一节中,我将演示命令行工具也可以用其他编程语言创建。我将重点介绍 Python 和 R,因为这是数据科学社区中最流行的两种编程语言。我无法提供这两种语言的完整介绍,所以我假设您对 Python 或 R 有一定的了解。其他编程语言,如 Java、Go 和 Julia,在创建命令行工具时也遵循类似的模式。
用不同于 Bash 的另一种编程语言创建命令行工具有三个主要原因。首先,您可能已经有了一些希望能够从命令行使用的代码。其次,命令行工具最终会包含一百多行 Bash 代码。第三,命令行工具需要更加安全和健壮(Bash 缺少许多特性,比如类型检查)。
我在上一节中讨论的六个步骤也大致适用于用其他编程语言创建命令行工具。然而,第一步不是从命令行复制粘贴,而是将相关代码复制粘贴到一个新文件中。用 Python 和 R 写的命令行工具需要分别指定python
和Rscript
,作为 Shebang 之后的解释器。
当使用 Python 和 R 创建命令行工具时,还有两个方面需要特别注意。首先,处理标准输入(这是 Shell 脚本的天性)必须在 Python 和 R 中明确处理。其次,由于用 Python 和 R 编写的命令行工具往往更复杂,我们可能还希望为用户提供指定更复杂的命令行参数的能力。
首先,让我们看看如何将刚刚创建的 Shell 脚本移植到 Python 和 R 中。换句话说,哪些 Python 和 R 代码为我们提供了标准输入中最常用的单词?我们将首先显示两个文件top-words.py
和top-words.R
然后讨论与 Shell 代码的区别。在 Python 中,代码如下所示:
$ cd /data/ch04
$ bat top-words.py
───────┬────────────────────────────────────────────────────────────────────────
│ File: top-words.py
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env python
2 │ import re
3 │ import sys
4 │
5 │ from collections import Counter
6 │ from urllib.request import urlopen
7 │
8 │ def top_words(text, n):
9 │ with urlopen("https://raw.githubusercontent.com/stopwords-iso/stopw
│ ords-en/master/stopwords-en.txt") as f:
10 │ stopwords = f.read().decode("utf-8").split("\n")
11 │
12 │ words = re.findall("[a-z']{2,}", text.lower())
13 │ words = (w for w in words if w not in stopwords)
14 │
15 │ for word, count in Counter(words).most_common(n):
16 │ print(f"{count:>7} {word}")
17 │
18 │
19 │ if __name__ == "__main__":
20 │ text = sys.stdin.read()
21 │
22 │ try:
23 │ n = int(sys.argv[1])
24 │ except:
25 │ n = 10
26 │
27 │ top_words(text, n)
───────┴────────────────────────────────────────────────────────────────────────
注意,这个 Python 例子没有使用任何第三方包。如果你想做高级文本处理,那么我推荐你去看看 NLTK 包 。如果你要处理大量的数字数据,那么我推荐你使用 Pandas 包 。
在 R 语言中,代码看起来像这样:
$ bat top-words.R
───────┬────────────────────────────────────────────────────────────────────────
│ File: top-words.R
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env Rscript
2 │ n <- as.integer(commandArgs(trailingOnly = TRUE))
3 │ if (length(n) == 0) n <- 10
4 │
5 │ f_stopwords <- url("https://raw.githubusercontent.com/stopwords-iso/sto
│ pwords-en/master/stopwords-en.txt")
6 │ stopwords <- readLines(f_stopwords, warn = FALSE)
7 │ close(f_stopwords)
8 │
9 │ f_text <- file("stdin")
10 │ lines <- tolower(readLines(f_text))
11 │
12 │ words <- unlist(regmatches(lines, gregexpr("[a-z']{2,}", lines)))
13 │ words <- words[is.na(match(words, stopwords))]
14 │
15 │ counts <- sort(table(words), decreasing = TRUE)
16 │ cat(sprintf("%7d %s\n", counts[1:n], names(counts[1:n])), sep = "")
17 │ close(f_text)
───────┴────────────────────────────────────────────────────────────────────────
让我们检查所有三个实现(即 Bash、Python 和 R)是否返回了相同计数的前 5 个单词:
$ time < alice.txt top-words 5
403 alice
98 gutenberg
88 project
76 queen
71 time
top-words 5 < alice.txt 0.56s user 0.04s system 139% cpu 0.427 total
$ time < alice.txt top-words.py 5
403 alice
98 gutenberg
88 project
76 queen
71 time
top-words.py 5 < alice.txt 2.15s user 0.03s system 97% cpu 2.232 total
$ time < alice.txt top-words.R 5
403 alice
98 gutenberg
88 project
76 queen
71 time
top-words.R 5 < alice.txt 1.67s user 0.10s system 94% cpu 1.886 total
精彩!当然,输出本身并不令人兴奋。令人兴奋的是,我们可以用多种语言完成同样的任务。让我们看看这两种方法之间的区别。
首先,显而易见的是代码量的差异。对于这个特定的任务,Python 和 R 都比 Bash 需要更多的代码。这说明,对于某些任务,使用命令行更好。对于其他任务,您最好使用编程语言。随着您在命令行上获得更多的经验,您将开始认识到何时使用哪种方法。当一切都是命令行工具时,您甚至可以将任务拆分成子任务,并将 Bash 命令行工具与 Python 命令行工具结合使用。无论哪种方法最适合手头的任务。
在前面的两个代码片段中,Python 和 R 都一次性读取了完整的标准输入。在命令行上,大多数工具以流的方式将数据传输到下一个命令行工具。有一些命令行工具在将数据写入标准输出之前需要完整的数据,比如sort
。这意味着管道被这样的命令行工具阻塞了。当输入数据是有限的,比如一个文件时,这并不是一个问题。但是,当输入数据是一个不间断的流时,这样的阻塞命令行工具是没有用的。
幸运的是 Python 和 R 支持处理流数据。例如,您可以逐行应用函数。下面是两个最小的例子,分别演示了这在 Python 和 R 中是如何工作的。
Python 和 R 工具都解决了现在已经臭名昭著的 Fizz Buzz 问题,该问题定义如下:打印从 1 到 100 的数字,除非该数字能被 3 整除,否则打印Fizz
;如果数字能被 5 整除,则改为打印buzz
;如果这个数字能被 15 整除,就打印fizzbuzz
。下面是 Python 代码 :
$ bat fizzbuzz.py
───────┬────────────────────────────────────────────────────────────────────────
│ File: fizzbuzz.py
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env python
2 │ import sys
3 │
4 │ CYCLE_OF_15 = ["fizzbuzz", None, None, "fizz", None,
5 │ "buzz", "fizz", None, None, "fizz",
6 │ "buzz", None, "fizz", None, None]
7 │
8 │ def fizz_buzz(n: int) -> str:
9 │ return CYCLE_OF_15[n % 15] or str(n)
10 │
11 │ if __name__ == "__main__":
12 │ try:
13 │ while (n:= sys.stdin.readline()):
14 │ print(fizz_buzz(int(n)))
15 │ except:
16 │ pass
───────┴────────────────────────────────────────────────────────────────────────
这是 R 代码:
$ bat fizzbuzz.R
───────┬────────────────────────────────────────────────────────────────────────
│ File: fizzbuzz.R
───────┼────────────────────────────────────────────────────────────────────────
1 │ #!/usr/bin/env Rscript
2 │ cycle_of_15 <- c("fizzbuzz", NA, NA, "fizz", NA,
3 │ "buzz", "fizz", NA, NA, "fizz",
4 │ "buzz", NA, "fizz", NA, NA)
5 │
6 │ fizz_buzz <- function(n) {
7 │ word <- cycle_of_15[as.integer(n) %% 15 + 1]
8 │ ifelse(is.na(word), n, word)
9 │ }
10 │
11 │ f <- file("stdin")
12 │ open(f)
13 │ while(length(n <- readLines(f, n = 1)) > 0) {
14 │ write(fizz_buzz(n), stdout())
15 │ }
16 │ close(f)
───────┴────────────────────────────────────────────────────────────────────────
让我们测试这两个工具(为了节省空间,我将输出传送到column
):
$ seq 30 | fizzbuzz.py | column -x
1 2 fizz 4 buzz
fizz 7 8 fizz buzz
11 fizz 13 14 fizzbuzz
16 17 fizz 19 buzz
fizz 22 23 fizz buzz
26 fizz 28 29 fizzbuzz
$ seq 30 | fizzbuzz.R | column -x
1 2 fizz 4 buzz
fizz 7 8 fizz buzz
11 fizz 13 14 fizzbuzz
16 17 fizz 19 buzz
fizz 22 23 fizz buzz
26 fizz 28 29 fizzbuzz
这个输出在我看来是正确的!很难证明这两个工具实际上以流的方式工作。在将输入数据传输到 Python 或 R 工具之前,您可以通过将输入数据传输到sample -d 100
来验证这一点。这样,您将在每一行之间添加一个小的延迟,以便更容易确认工具不会等待所有的输入数据,而是逐行操作。
在 intermezzo 这一章中,我向您展示了如何构建自己的命令行工具。只需要六个步骤就可以将您的代码变成可重用的构建块。你会发现这会让你更有效率。我建议你留意创造自己工具的机会。下一章将介绍 OSEMN 数据科学模型的第二步,即清理数据。