我的爱好:一边打开McIlroy的UNIX哲学放在一个显示器上,另一边阅读man手册。

McIlroy的第一条格言常被概括为“做好一件事”,其原文是“让每个程序只做好一件事。要完成新任务,就重新构建,而不是通过添加新’功能’来使旧程序复杂化。”

McIlroy对此格言的举例是:

让外人惊讶的是,UNIX编译器不生成清单:打印功能可以更出色、更灵活地由独立程序完成。

如果你在mac上打开ls的man手册,会看到开头写着:

ls [-ABCFGHLOPRSTUW@abcdefghiklmnopqrstuwx1] [file ...]

也就是说,ls的单字母选项涵盖了除{jvyz}外的所有小写字母、14个大写字母,外加@1。这就有22+14+2=38个单字符选项。

在ubuntu 17上,如果你阅读coreutils版ls的man手册,虽然看不到简洁的选项摘要,但会发现ls共有58个选项(包括--help--version)。

为了判断ls是特例还是命令普遍如此臃肿,我们可以按使用频率排序,查看一些常见命令。

table {border-collapse:collapse;margin:0px auto;}table,th,td {border: 1px solid black;}td {text-align:center;}

命令 1979 1996 2015 2017
ls 11 42 58 58
rm 3 7 11 12
mkdir 0 4 6 7
mv 0 9 13 14
cp 0 18 30 32
cat 1 12 12 12
pwd 0 2 4 4
chmod 0 6 9 9
echo 1 4 5 5
man 5 16 39 40
which 0 1 1
sudo 0 23 25
tar 12 53 134 139
touch 1 9 11 11
clear 0 0 0
find 14 57 82 82
ln 0 11 15 16
ps 4 22 85 85
ping 12 12 29
kill 1 3 3 3
ifconfig 16 25 25
chown 0 6 15 15
grep 11 22 45 45
tail 1 7 12 13
df 0 10 17 18
top 6 12 14

该表格展示了 v7 Unix(1979 年)、Slackware 3.1(1996 年)、Ubuntu 12(2015 年)和 Ubuntu 17(2017 年)中各种命令的命令行选项数量。单元格颜色越深、越蓝,表示选项越多(对数刻度);若未找到对应命令,则单元格显示为灰色。

可以看出,命令行选项的数量随时间大幅增加;向右看,条目颜色普遍加深(选项增多),且没有出现条目颜色变浅(选项减少)的情况。

McIlroy 长期以来一直对命令选项数量、体积及整体功能的膨胀持批评态度1

一切都变得臃肿,当我看到那些体积时,不禁为Linux感到惋惜[听不清]。过去只需8千字节就能装下的工具,现在动辄一兆。曾经名副其实只有一页的手册页,如今成了千余选项的厚厚一卷……我们当年围坐在UNIX工作室里,总在讨论“还能删掉什么?这个选项为什么存在?“通常,这往往是因为基础设计存在缺陷——你没能找准正确的设计点。与其增加选项,不如思考是什么迫使你添加这个选项。这种理念——部分源于当时极其有限的硬件条件——如今已被遗忘,而我们并未因此变得更好。

讽刺的是,命令行选项激增的根源之一,正是麦基尔罗伊的另一条箴言:“编写处理文本流的程序,因为这是通用接口”(以ls为例即可见一斑)。

若传递的是结构化数据或对象,格式化工作完全可以交由最后的格式化环节处理。但面对纯文本,格式与内容相互交织;由于格式化必须通过解析内容来实现,命令常会为了方便而添加格式化选项。另一种方式是用户利用对数据结构的理解,将其编码为cutawksed等命令的参数(同时还需了解这些程序处理格式化的方式——不同程序差异显著,用户需掌握诸如cut -f4awk '{ print $4 }'的区别2)。这比在管道末尾命令中传入一两个参数麻烦得多,实质上是将复杂性从工具转嫁给了用户。

有人声称不愿支持结构化数据,因为需要支持多种格式才能打造通用工具——但事实上,要打造通用工具,他们早已需要支持多种格式。某些标准命令无法读取其他命令的输出,只因格式不同;wc -w不能正确处理Unicode等等。声称“文本”是通用格式,无异于说“二进制”是通用格式。

我曾听人说,命令行工具的这类复杂性别无选择——但说这话的人从未真正尝试过替代方案,比如PowerShell。我对PowerShell有诸多不满,但传递结构化数据、无需在脑中记忆元数据信息就能轻松操作结构化数据(不必在管道正确位置向正确命令传递正确元数据)这一点,绝非我的抱怨之列3

当有人说“让所有东西都处理文本就能保持软件简单兼容”时,其障眼法在于假装文本数据没有需要解析的结构4。某些情况下我们可以把所有内容视为单行空格分隔数据,或是指定行列分隔符的表格(当然不同工具的行为并不一致)。即便可行也会带来麻烦,更有些场景下将数据序列化为纯文本格式会显著增加复杂度——因为数据结构本身意味着简单的扁平化处理需要大量解析工作才能有意义地重新读取数据。

命令选项增多的另一个原因是人们为原本可通过组合命令实现的功能添加了便捷标志。这种传统可追溯至v7 Unix时代:ls命令包含反向排序选项(本可通过将输出传递给类似tac的命令实现——如果他们当时写了tac而非添加特例反向选项)。

随着时间推移,更多便捷选项被加入。以最初零选项的mv为例,现在它可以移动文件并创建备份(三个选项:两种指定备份方式,一种需参数,另一种无需显式参数而从VERSION_CONTROL环境变量读取隐式参数;还有一个选项可覆盖默认备份后缀)。mv现在还包含从不覆盖和仅当文件更新时覆盖的选项。

mkdir是另一个曾经零选项的程序,排除SELinux/SMACK安全相关选项及帮助版本选项后,新增的都是便捷标志:设置新目录权限、自动创建不存在的父目录。

再看最初只有一个选项(-number指定起始位置)的tail,它既增加了格式化选项也增加了便捷选项。格式化方面有-z将行分隔符从换行符改为空字符。便捷选项示例包括:-f实时打印新增内容、-s设置检查-f变化的休眠间隔、--retry在文件不可访问时重试。

McIlroy认为增加这些选项“并未让我们变得更好”,但我确实受益了。我们讨论的某些选项我从未用过,其他选项也极少使用——但这正是命令行选项的美妙之处:与GUI不同,添加这些选项不会使界面变得杂乱。手册页可能会变得臃肿,但在谷歌和StackOverflow时代,我怀疑许多人根本不会阅读手册页,而是直接搜索解决方案。

这并非是说增加选项没有代价——更多选项意味着更高的维护负担,但这是维护者为造福用户而付出的成本,考虑到维护者与用户的比例,这并非明显不合理。这类似于Gary Bernhardt的评论:将一场演讲练习五十次是合理的,因为如果有三百名观众,观众观看演讲的时间与练习时间的比例仍仅为1:6。通常,对于常用的命令行工具而言,这一比例会更加极端。

有人可能会争辩说,所有这些额外选项给用户带来了负担。这并非完全错误,但这种复杂性负担始终存在,问题只在于负担落在何处。如果将命令行工具集与shell视为一种语言——一种任何人都能编写新方法、且一旦流行便有效加入标准库的语言,其标准由诸如“编写程序处理文本流,因为这是通用接口”这样的格言定义——那么从整体上看,这种语言注定会变成一种只可写入的混乱体。至少,那些打包了比UNIX风格更多功能和选项的工具,能让用户用一组规模虽大但内部可能一致的工具,取代一组极其不一致的庞大工具。

McIlroy暗示问题在于人们思考得不够深入——老派的UNIX专家们会坐在同一间房间里,更长久、更深入地思考,直到提出一套具有“非凡简洁性”的一致工具。但这永远无法扩展,这种哲学使我们陷入了当前的混乱局面。这不是思考时间长短或深度的问题;而是这种哲学无法扩展,除非你拥有一个规模相对较小、共享文化理解、并能坐在同一间房间里的团队。

如今我们仍在使用、五十年前就已存在的许多UNIX长期反功能和反模式,都源于“我们应表现得像身处同一房间”的设计理念。这种理念与你想要创建优秀、易用、通用的接口——能适应原始设计者未曾预想到的问题——所需的方法恰恰相反。例如,现代Shell和终端缺乏一系列明显功能,这是常见抱怨,而任何设计现代接口的人都会想要这些功能。当你与那些基于现代原则编写新Shell和新终端的人(如Jesse Luehrs)交流时,他们会指出一个主要问题:UNIX模型没有良好的接口与实现分离。如果你要编写一个与五十年前创建的终端行为一致的终端,这或许还行得通;但如果你想构建一个现代终端,这立刻就会暴露出明显的问题。这种设计哲学在所有人都身处同一房间、系统无需扩展贡献者数量或随时间演进时或许可行,但这根本不是我们今天所处的世界。

如果任何人都能编写工具,而主要指导原则来自“UNIX哲学”,那么人们对“简洁”或“做好一件事”5的含义、以及正确做事的方式就会产生不同看法,不一致性将蔓延开来,最终导致处理像PHP这样极不一致的语言时才会遇到的复杂性。人们嘲笑PHP和JavaScript有各种缺陷和奇怪的不一致性,但作为一种语言和标准库,任何常用Shell加上广泛使用的*nix工具集合,其整体情况要糟糕得多,并且由于不一致性——即使在单个Linux发行版内部——包含了更多的意外复杂性,而且这种结果别无他法。如果你比较不同Linux发行版、BSD、Solaris、AIX等系统,用户在切换系统时需要在脑中记住的意外复杂性,远超PHP或JavaScript的不连贯性。相比之下,那些被嘲笑最多的编程语言,在设计上堪称典范。

需要说明的是,我并非声称自己或任何人在70年代掌握的知识基础上,能设计出既在当时实用、又在今天看来优雅的系统。事后诸葛亮总是容易的。我不认同的是当下Unix大师们的言论——比如McIlroy暗示我们只是遗忘或不懂简洁的价值,或是Ken Thompson说C语言和其他语言一样安全,想要无bug就该写无bug代码。这类言论暗示着历史经验毫无价值:70年代的系统构建效率与今天无异;五十年集体经验、数千万人年的积累毫无意义;只要回归Unix大师们的构建方式,一切问题都将迎刃而解。对此我恕难苟同。

附录:内存

虽然讨论McIlroy对二进制体积膨胀的抱怨有些偏离主题,但我想指出:2017年我花300美元买的Chromebook就有16GB内存。1MB的二进制文件在1979年可能是严重问题——当时标准Apple II只有4KB内存。一台Apple II在1979年售价1298美元,相当于2020年的4612美元。如今一台低端Chromebook价格不到其十五分之一,内存却多出四百万倍。抱怨内存占用增长一千倍,而价格便宜一个数量级以上的(便携!)设备内存却多出四百万倍,这似乎有些荒谬。

我本人偏爱精简软件——所以把个人主页优化到仅两个数据包(如果CDN支持高级brotli压缩,甚至能压缩到一个包),但这纯粹是审美偏好和业余乐趣。命令行工具的瓶颈从来不是内存占用,花时间优化一个1MB工具的内存占用,就像把主页压缩到单个数据包——或许是种有趣的消遣,但绝非值得推广的准则。

表格方法论

命令频率数据来自GitHub上的公开命令历史文件,未必代表您的个人使用习惯。仅保留“简单”命令,排除了curl、git、gcc(超1000个选项)和wget等。何为“简单”存在主观判断。Shell内置命令(如cd)未纳入统计。

重复选项不单独计数。例如git blame -Cgit blame -C -Cgit blame -C -C -C行为不同,但均计为单一参数——尽管-C -C本质上与-C不同。

表格将子选项视为单一选项。例如ls包含:

--format=WORD across -x, commas -m, horizontal -x, long -l, single-column -1, verbose -l, vertical -C

尽管有七种格式选项,但这仍被视为仅一个选项。

明确列出不执行任何操作的选项仍会被计入,例如 ls -g,其说明为“忽略;用于Unix兼容性”,仍算作一个选项。

同一选项的多个版本也被视为一个选项。例如,对于 ls-A--almost-all 算作同一个选项。

如果手册页中声称某个选项应该存在但实际上不存在,则该选项不计入。例如,v7 版本的 mv 手册页写道:

错误

如果 file1 和 file2 位于不同的文件系统上,mv 必须复制文件并删除原文件。在这种情况下,所有者名称变为复制进程的所有者,并且与其他文件的任何链接关系都会丢失。

Mv 应该像 rm 一样采用 -f 标志,以在目标存在且不可写时抑制询问。

-f 在表格中不计为标志,因为该选项实际上并不存在。

表格中的最新年份是 2017 年,因为我在 2017 年写了这篇文章的初稿,直到 2020 年才整理完成。

相关链接

mjd 关于 Unix 哲学的讨论,顺便提及 /usr/bin/time 与内置 time 的混乱

mjd 在 1991 年关于命令行选项泛滥的玩笑

在 HN 上:

p1mrx:

奇怪的是,ls 已经发展到 58 个选项,但仍然无法输出以 \0 结尾的文件名

作为练习,尝试按大小或日期对目录进行排序,并将结果传递给 xargs,同时支持任何有效的文件名。我最终放弃了,让我的脚本忽略任何包含 \n 的文件名。

whelming_wave:

给你:按修改时间排序当前目录中的所有文件,且对文件名中的空格安全。printf (od -> sed)' 结构将空分隔字符转换回换行分隔,不过你可以随意替换为任何接受空分隔输入的内容。当然,sort –zero-terminated’ 是 GNU 扩展,有点作弊,但它在 macOS 上也可用,所以应该没问题。

      printf '%b' $(
        find . -maxdepth 1 -exec sh -c '
          printf '\''%s %s\0'\'' "$(stat -f '\''%m'\'' "$1")" "$1"
        ' sh {} \; | \
        sort --zero-terminated | \
        od -v -b | \
        sed 's/^[^ ]*//
      s/ *$//
      s/  */ \\/g
      s/\\000/\\012/g')

如果你在 zsh 下运行此命令,需要在前面加上 `command’ 以使用系统可执行文件:zsh 的内置 printf 不支持为通常可打印字符输出八进制转义码,你可能需要将输出赋值给一个变量并显式进行单词分割。

据我所知,除了 sort 之外,这全部符合 POSIX 标准。

Unix 仇恨者手册

为什么要创建新的 shell

感谢 Leah Hanson、Jesse Luehrs、Hillel Wayne、Wesley Aptekar-Cassels、Mark Jason Dominus、Travis Downs 和 Yuri Vishnevsky 提供的评论/更正/讨论。


  1. 这段引文与我常见版本略有不同,因为我观看了原始视频。据我所知,互联网上(被Bing、DuckDuckGo或Google索引的)所有该引文的副本,都源自某个人对该引文的转录。由于音频质量较低,我听到的内容与转录者听到的略有出入,因此存在一些歧义。[返回]
  2. 另一个用户因不同命令处理格式方式不同而需要消化复杂性的例子是时间格式化——Shell内置的time命令与/usr/bin/time显然不一致,用户需要了解这一点并知道如何处理。[返回]
  3. 举例来说,你可以对任何对象使用ConvertTo-JsonConvertTo-CSV可以使用cmdlet更改对象的属性显示方式,还可以编写定义你偏好格式的格式化配置文件。

另一种看待这个问题的方式是通过康威定律的视角。如果一组命令行工具由不同的人构建,且通常没有组织上的关联,那么除非有人能定义标准并让人们采纳,否则这些工具将极不一致。这在Windows上相对有效,而且不仅限于PowerShell。

关于微软的一个常见抱怨是,他们造成了大量的API变动,通常出于非技术性的组织原因(例如,Sinofsky的权力博弈,如现已删除的推文回复中所描述的:https://twitter.com/stevesi/status/733654590034300929)。这确实如此。即便如此,从一个普通用户的角度来看,现成的Windows软件在传递非文本数据方面通常比*nix好得多。其中一个原因是Windows对非文本数据的支持,这至少可以追溯到1999年的COM(以及可以说更早的OLE和DDE,分别于1990年和1987年发布)。

例如,如果你从支持二进制格式AB的Foo复制内容,粘贴到支持格式BC的Bar,然后再从Bar复制到支持CD的Baz,即使Foo和Baz没有共同支持的格式,这也能正常工作。

当你剪切/复制某些内容时,应用程序基本上会“告诉”剪贴板它可以提供哪些格式的数据。当你粘贴到应用程序时,目标应用程序可以请求任何可用格式的数据。如果数据已经在剪贴板中,“Windows”会直接提供。如果没有,Windows会从源应用程序获取数据,然后交给目标应用程序,并在Windows中保存一份副本一段时间。如果你从Excel中“剪切”,它会“告诉”你它提供了数十种格式的数据。这种系统在兼容性方面相当不错,尽管它肯定不简单也不精简。

除了完美支持多种格式且持续时间足够长,使得许多软件能与之良好配合外,Windows 通常还自带更出色的剪贴板支持。

假设你复制并粘贴少量文本。大多数情况下,在 Windows 和 Linux 上都能按预期工作。但假如你复制一些文本后关闭来源程序再粘贴,很多人会默认认为复制时数据存储在剪贴板中,而非来源程序里。在 Windows 上,软件通常遵循这一预期(尽管技术上剪贴板 API 的使用者不必如此)。而在使用 X 系统的 Linux 上则较少见,大多数软件的正确理解是:复制操作存储的是指向数据的指针,数据仍归来源程序所有,这意味着关闭程序后粘贴将失效。当我(非正式)调查程序员时,若他们未实际处理过应用的复制粘贴功能,通常会对这点感到惊讶。而当我调查非程序员时,他们往往觉得这种行为既令人困惑又意外。

让操作系统直接拥有剪贴板内容的缺点是复制大量数据成本高昂。假设你复制了数 GB 的极长文本或复杂对象却从未粘贴,你并不希望将数据从程序复制到操作系统以供随时调用。Windows 对此处理得也合理:应用程序可在认为有利时仅按需提供数据。在上述关闭程序的情况下,程序可自行决定是否将数据推入剪贴板或丢弃。此时许多软件(如 Excel)会提示“保留”剪贴板数据或丢弃,这相当合理。

在 Linux 上实现部分功能并非不可能。例如,ClipboardManager 规范描述了持久化机制,GNOME 应用通常在一定程度上支持它(尽管存在一些缺陷),但 *nix 系统的情况与 Windows 应用程序普遍支持的优秀剪贴板行为存在显著差异。

[return] 4. 现代编译器之上提供的工具是另一个例子。回顾 McIlroy 的经典案例——真正的 UNIX 编译器高度专业化,以至于列表功能都是独立工具——我们可以看到,即便现在仍有独立的列表工具可用,情况也已发生变化。一些常用的 Linux 编译器拥有数千个选项,能完成许多工作。例如,clang 现在具备静态分析功能。截至本文撰写时,已有 79 个常规静态分析检查和 44 个实验性检查。如果这些功能是独立命令(或许是单个命令,或许是 static_analysis 命令),它们仍然依赖相同的底层编译器基础设施,并带来相同的维护负担——让这些静态分析工具直接操作纯文本并重新实现整个编译器工具链以进行静态分析,这并不合理。它们可以独立于 clang 成为单独的命令,但仍需依赖编译器所使用的相同机制,要么给编译器增加维护和复杂性负担(编译器必须为基于其构建的工具提供非破坏性接口),要么它们就会频繁失效。

“一切皆文本,简单至上”听起来很动听,但现实中,如果你想做真正有用的工作,数据的文本表示往往并非你所需。

具体到 clang,无论你将其设计为单一命令还是数千个小命令,clang 的功能都远超 1979 年的任何编译器,甚至超过当时所有编译器的总和。说 1979 年更简单、现代程序员迷失了方向很容易。但要提出一个真正更简单且能被广泛采用的设计却很难。这样的设计不可能在保留所有现有功能和可配置性的同时,还像 1979 年的东西一样简单。

[return] 5. 自诞生以来,curl 支持的协议从 3 个增加到 40 个。这是否意味着它做了 40 件事,将其拆分为 40 个独立命令会更符合“UNIX 风格”?这取决于你问谁。如果每个协议都是一个独立命令,由不同的人创建和维护,我们就会陷入与其他命令相同的困境:命令行选项不一致,尽管输出都是文本流,但格式也不统一,等等。这是否更接近 McIlroy 所倡导的简单性?同样取决于你问谁。[return]