批处理脚本编程之书-全-

批处理脚本编程之书(全)

原文:zh.annas-archive.org/md5/9b100141775160857024c1bb4aa7463c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

一本关于 bat 文件的书?为什么?难道阿兹特克人也用过 Batch 吗?它不是脚本语言中的 Betamax 吗?你应该写一本关于更新、更有吸引力的编程语言的书,而不是一本 Model T 的维修手册。

我希望我能直接忽略这种抗议,将其视为啰嗦小人的消极言论,但这是一个我感到必须回应的观点。Batch 语言并不新颖,按照今天的标准来看,它缺乏一些功能让人费解,但它依然是一个极为有用的语言,短期内不会消失,尤其是它已经与每台 Windows 电脑上的操作系统捆绑在一起。虽然 Batch 只是众多脚本语言中的一种,但仍然有许多大型和小型公司在使用 Batch 代码,有些任务确实比任何其他语言更适合用 bat 文件来完成。至于那些缺失的功能——布尔值、数组、哈希表、栈,甚至面向对象设计等等——在本书的结尾,我会教你如何自己构建这些功能。

但我个人写一本关于 Batch 脚本的书的最直接原因是,经过二十年的个人和职业用途的 bat 文件编写,我相信自己已经在这个领域学到了足够的知识,能够与更广泛的社区分享我的经验和见解。在很多年里,我在一家公司为 Windows 服务器编写大规模处理程序,所有的程序都由 bat 文件驱动。其他人可能会选择更现代的脚本语言,但在我之前的那位程序员已经精通了 bat 文件的艺术,以至于没有人认真考虑过使用 Batch 之外的替代方案。直到他退休,我才在非正式的情况下接替了他的工作,扮演起了罗宾的角色,直到后来我被正式晋升为蝙蝠侠。

编写 Batch 代码仍然是任何程序员甚至非程序员的重要技能,但现有的文档往往稀缺、分散,甚至有时不准确。与其他语言相比,掌握 Batch 语言需要大量的经验和实验,而我有一个独特的视角可以分享。这就是我写这本书的原因。

本书的读者群体

这本书既不是为初学者写的,也不是为专家写的;它是为两者而写的。实际上,我希望能够接触到三个群体。第一个是那些几乎每天都在编写、维护或与 bat 文件打交道的程序员。第二个是所有在 Windows 机器上工作的其他程序员,第三个是那些也在 Windows 电脑上工作的非程序员。

第一类人群,那些与 Batch 密切合作的人,显然也在本书的读者范围内。这本书是我在 Batch 脚本领域二十年深入工作的结晶。到本书的最后,你将会探索到一些复杂的概念,比如创建命令、数据结构、运算符,甚至是语言的创造者未曾预见的编码范式。我将逐步讲解这些复杂的内容,但我希望在这些页面中,你能找到掌握这门语言以及进一步探索其未涉及部分所需的所有工具。

如果你属于第二类人群,你可能不会维护成千上万行的 Batch 代码,但在 Windows 电脑上,你会用其他语言编写代码,并且你至少应该对 Batch 有一定的了解。这项技能让你可以通过运行一个简单的(或者可能不太简单的)bat 文件,完成一些常见且重复的任务。用其他语言编写的代码在动画化时会面临一些挑战,其中之一就是你的机器环境与程序最终运行的生产环境不同。为此,我将向你展示如何用几行 Batch 代码模拟或模拟另一个计算机的环境。到本书的最后,我相信你会发现,bat 文件是许多问题的解决方案。

即使是非程序员,最后这一类人,也能通过一些 Batch 代码来简化重复性任务,比如移动文件、合并报告或连接到网络驱动器,从而使 Windows 资源管理器更易使用。由于编程不在你的工作职责范围内,你的雇主不太可能为你的计算机安装其他编程语言的基础设施,来让你完成相对简单的编码任务,但你所需要的编写和执行 bat 文件的工具已经在你的工作站上了。编写 bat 文件所需的技能就是能够创建文本文件、重命名文件,并向文件中输入几行内容。如果你能双击文件,你就能运行 bat 文件。这就是你所需要的一切(除了这本书)。

如何阅读本书

每个作者,无论哪种类型的作品,都会设想读者坐在火旁,品着雪利酒(或者对我来说是一杯好的麦芽酒,不要太甜),全神贯注地读着每一个字,阅读、理解,再继续阅读,直到书本结束。嗯……这是一本技术书籍,所以我的读者中有相当一部分是程序员,他们坐在电脑前,试图弄明白为什么他们的 bat 文件没有按预期运行。我曾经也经历过这种困境,深刻理解其中的难处,为了帮助你,我将这本书组织成了有标题、子标题、详细的目录和索引。你可以找到能解答问题的章节和页码,并直接跳到那一部分,但这并不是阅读这本书或任何一本书的理想方式。

我将本书结构设计为简短而简洁的章节。即使你只是想解决某个特定问题,我也建议你通读相关章节,因为每一章都像是一份课程计划。(我的日常工作是编码,但我受过数学训练,已经在康涅狄格州曼彻斯特社区学院教授了二十多年的数学课程。)

一堂典型的课程从基本概念开始,接着是一些简单的示例。然后我会深入探讨该主题的复杂性,展示该概念的应用,甚至解释常见的陷阱和避免的错误。并不是每一课(或每一章)都会遵循这个结构,但很多都会。如果你有关于如何复制文件的疑问,我建议你从头到尾阅读第七章。跳到章节中间就像是上课迟到 20 分钟。

我还建议你亲自执行一些我提供的编码示例。大部分代码片段都很短且容易输入,你可以从本书的在线版本中获取较长的代码。更好的做法是修改代码,探索其结果,并将其变成你自己的代码。

本书结构

Batch 的独特之处在于一个单一的命令——for 命令,远远超过其他命令,支配了整个 Batch 脚本语言。因此,我将本书分为三部分,围绕这个至关重要的命令进行组织。第一部分标题为“基础知识”,涵盖了你在学习 for 命令之前需要掌握的相关内容。第一部分包括以下章节:

第一章:Batch    本章将向你介绍 Batch 脚本语言,同时帮助你编写可能是你第一次编写的 bat 文件。我还会提供一些编辑技巧,由于 Batch 是一种解释型语言,我还会讨论解释器的角色和重要性。

第二章:变量与值    本章讲解如何定义变量,并查询其值,无论是显示到控制台还是用于其他目的。

第三章:作用域与延迟扩展    在学习如何定义变量在 bat 文件中的访问范围后,我将介绍 Batch 中最有趣的特性之一——延迟扩展,它影响如何解析变量。

第四章:条件执行    if...else 语句是大多数编程语言中的基本结构,Batch 也不例外。在本章中,你将学习如何根据不同的条件语句执行或跳过代码片段。

第五章:字符串与布尔数据类型    本章讲解构建和连接字符串,提取更大字符串中的子字符串,以及在字符串中替换特定文本的任务。我还将介绍我们将要构建的许多非 Batch 内建工具中的第一个——布尔值或评估为真或假的变量。

第六章: 整数与浮点数据类型    你将学习加法、减法、乘法和除法等整数操作的所有细节。本章还详细介绍了取余运算,以及八进制和十六进制的算术运算。然后我将深入探讨另一种在批处理语言中并非固有的数据类型:浮点数。

第七章: 与文件的操作    本章处理与文件相关的许多任务,如复制、移动、删除、重命名文件,甚至创建一个空文件。

第八章: 执行编译后的程序    本章探讨了如何在有路径和没有路径的情况下调用程序,特别是当你没有提供路径时,解释器是如何找到你的程序的。

第九章: 标签与非顺序执行    本章介绍了标签以及它们在允许你将代码执行转向批处理文件中的前后命令中所起的作用,有时甚至会启动一个循环。

第十章: 调用例程和批处理文件    在上一章的基础上,你将学习在批处理文件中创建可调用例程的所有内容,以及如何从另一个批处理文件中调用一个批处理文件。

第十一章: 参数与参数值    如果你不能将参数传递给被调用的代码,或者被调用的代码不能将参数值返回给你,那么调用其他代码往往毫无意义。本章深入探讨了这一过程的所有细节,甚至揭示了隐藏的参数。

第十二章: 输出、重定向与管道    在区分了程序员和解释器所产生的输出后,我讨论了如何将这两者重定向到控制台或文件,这自然引出了将一个命令的输出传递给另一个命令的管道技术及其应用。

第十三章: 与目录的操作    本章详细介绍了如何创建和删除目录,以及如何检索有关目录及其内容的大量信息。我还演示了将本地和网络目录映射到驱动器字母的技术。

第十四章: 转义    如果你想在字符串中使用某个字符,而这个字符在批处理(Batch)中是一个具有特定功能的特殊字符,你会遇到问题。本章详细介绍了针对这一问题的解决方案,有时这些解决方案可能会出乎意料地复杂。

第十五章: 交互式批处理    在本章中,你将构建一个功能完整的批处理用户界面,允许从控制台接受自由格式文本,并让用户从列表中选择一个项目,除此之外还有其他功能。

第十六章: 代码块    代码块不仅仅是代码的块。本章探讨了代码块中的变量为何以及如何能够拥有两个不同的值。我还将介绍裸代码块,并解释它的意义。

第二部分的标题为“for命令”,正如其名称所暗示的,这部分内容深入探讨了前面提到的for命令,它为你提供了一大批(双关含义)功能。你将在这里找到以下内容:

第十七章:for命令的基础    本章详细介绍了for命令的功能,未引入任何选项,但仍然非常强大。它可以创建处理任意数量输入文件或文本字符串的循环,并通过使用修饰符,你将能够确定关于文件的几乎所有信息,除了文件内容。

第十八章:目录、递归和迭代循环    本章探讨了for命令的一些选项,这些选项提供了更多的功能。使用其中一个选项,命令可以遍历一个目录列表,而不是文件名列表。使用另一个选项,你可以递归地处理目录和子目录,例如,在一个文件夹及其所有子文件夹中搜索符合特定模式的文件。还有一个选项将命令转换为一个迭代循环,每次执行时递增或递减索引。

第十九章:读取文件和其他输入    最后一个选项为for命令提供了强大功能,允许你读取文件。本章详细介绍了如何在读取文件时解析或重新格式化每一条记录。除了传统的文件,命令还可以读取和处理普通文本,无论是硬编码的还是来自变量的,甚至可以将另一个命令的输出作为文件读取。

第二十章:for命令的高级技巧    本章深入探讨了for命令的一些令人印象深刻的应用,例如将其他语言(例如 PowerShell 和 Python)的命令嵌入到你的批处理脚本中。我还讨论了绕过命令限制的一些技巧。

“高级主题”是第三部分的标题,讨论了各种各样的主题,特别是那些在我拥有for命令工具之前无法涉及的内容。以下是详细内容:

第二十一章:伪环境变量    本章详细介绍了伪环境变量,或称特殊变量,这些变量并不总是由你控制。例如,批处理有一些特定的变量,用来保存日期、时间以及批处理命令和被调用程序的返回代码。我还解释了如何安全地设置这些变量,并分享了批处理文件(.bat)和命令文件(.cmd)之间的区别。

第二十二章:编写报告    本章解释了如何使用批处理格式化基础的文本文件报告,包括标题、详细记录和结尾记录。

第二十三章:递归    一些问题非常适合使用递归技术,即代码调用自身的方法。本章通过详细且有趣的例子演示了如何在批处理语言中实现递归。

第二十四章:文本字符串搜索    本章探讨了多种文本字符串搜索的排列方式。搜索文件、变量或硬编码文本中的一个或多个单词或字面字符串。你还会找到一些使用正则表达式的例子。

第二十五章:批处理文件构建批处理文件    本章详细介绍了一个批处理文件如何构建第二个完全功能的批处理文件,包括动态和静态代码,同时也思考了如果是阿基米德,他会如何使用批处理。

第二十六章:自动重启与多线程    在讨论如何自动重启失败的进程后,本章通过构建一个批处理文件来自动终止并重启一个挂起的进程。我还讨论了如何在单一批处理文件的控制下同时执行多个线程或并发任务。

第二十七章:与/或运算符    这可能听起来像是一个基础话题,但批处理语言本身并没有与运算符或或运算符。本章构建了模拟这些运算符的技术,用于各种情况。

第二十八章:紧凑的条件执行    本章详细介绍了一种紧凑且有趣的结构,它看起来和行为非常类似于 if...else 结构。我会在检查两者之间微妙但重要的差异后,讨论何时最好使用每种方式。

第二十九章:数组和哈希表    这些数据结构并非批处理的内建功能,但你将学习如何填充和从数组及哈希表中检索数据。

第三十章:杂项    本章涵盖了一些不同的主题:文件属性、位操作、查询 Windows 注册表以及排序文件内容。

第三十一章:故障排除技巧与测试技术    我分享了多年来在开发和测试批处理文件中学到的许多技巧和方法。

第三十二章:面向对象设计    尽管听起来有点疯狂,本章将呈现用户自定义批处理文件功能的顶峰。我将在讲解面向对象设计的四个支柱后,带领你通过一个尽可能全面实现这些原则的模型。我希望经验丰富的编码者会觉得本章既有信息性又有娱乐性。

第三十三章:堆栈、队列与现实世界中的对象    本章应用刚学到的面向对象设计原则,构建实现堆栈和队列数据结构的对象。

在第一部分和第三部分的每一章中,我都设定了一个狭窄的主题或任务讨论;我不打算讨论某个特定命令,但通常会在章节中介绍一条或多条命令。对于每一条命令,我都会解释它的功能,展示它的语法,并详细介绍我认为最有用的特性。

我的目标是,如果你不是程序员,你至少能读懂前两部分并从中获得信息。读得更深入一点,你或许就能成为一名程序员。

其他资源

如果你在寻找单个批处理命令的全面且简明的解释,不妨去看看 <wbr>ss64<wbr>.com<wbr>/nt<wbr>/。这是一个很棒且组织良好的资源,在写这本书时我参考了它很多次。本书不是命令的列表,而是关于如何用这些命令解决问题的讨论。我通常会展示我认为最有用的命令选项,但你可以在这个网站上找到完整的命令列表。

如果你(希望)遇到的情况很少能在这些页面上找到解决方案,那么下一个最好的选择是向在线的技术社区寻求帮助。用“bat file”加上你的问题在网络上搜索应该能得到一些结果。在众多的在线论坛中,我发现最好的创意和建议常常来自于 <wbr>stackoverflow<wbr>.com

风格说明

大多数技术书籍和手册读起来都很枯燥,而我已尽最大努力打破这种趋势。首先,我没有忘记我的首要任务是解释我试图传授的技术内容。但举个例子,当讨论 sort 命令时,我不想对像苹果和香蕉这样的东西进行排序;与其如此,不如把《星际迷航》中的 Enterprise 星舰的舰长们排序,或者至少我觉得这样更有趣。讨论参数时,我使用了一个 Mad Libs 游戏,通过不同的词性作为参数传递。关于交互式批处理的章节还与用户分享了些笑话。

并非每一章都能举出有趣的例子或幽默的轶事,但我已经尽力避免让文件的第一条记录是“Record 1”或一串用管道符号分隔的字段(如 field 1| field 2| field 3)。

理想情况下,我希望能引发一阵可听的笑声;如果能看到一抹微笑和点头,我也会很高兴;即便只是翻个白眼和叹气,我也能接受。无聊去死。我秉持着“与其平凡,不如独特地糟糕”这一座右铭。(我希望能为这句话归功于自己,但很多年前,在加利福尼亚州索诺玛县的本齐格酒庄旅游时,我们的导游用这句话来形容酒庄的哲学。)

注意事项

根据我的经验,Batch 相较于其他语言有许多显著的注意事项。在接下来的章节中,我常常会在一些看似明确的语法或用法陈述后加上除了这个词。(例如,“&符号用于终止命令,除非后面跟着第二个&符号或……”)英语独特之处在于,它的语法中有很多注意事项,这些在其他语言中根本不存在——想想看“ie 前面,除了c 后面。”也许这使得 Batch 成为典型的爱国美国编程语言(或者也可能是英国式的)。

这些Batch 注意事项如此普遍,以至于我已开始称它们为 batveat(发音为 bat-vē-ät,商标待定)。这些注意事项对于没有指导的新用户来说可能非常令人沮丧,但随着章节的展开,我会指出那些曾经让我受困的 batveat,希望你能避免这些痛苦。

伍迪·格思里

我为这本书选择的引言来自传奇艺术家伍迪·格思里(Woody Guthrie)的一句相对著名的名言,但我曾犹豫是否使用它,因为担心它可能会被误解。引用的目的并非自负,而是富有理想的。伍迪曾穿越美国,提倡经济正义,同时也宣扬反对种族主义和性别歧视。他并非通过枯燥无味的演讲,而是通过吉他和敏锐的歌词来传播这些思想,这些歌词即使在他早逝之后,依然引起人们的共鸣。

伍迪·格思里试图将历史的轨迹引向社会正义,而我则在尝试通过富有信息性、可读性和娱乐性的语言,使一个深奥的编程语言变得更易接近。我希望能够为理解一个复杂话题做出贡献,并且我只能以伍迪的崇高榜样为目标,努力追随。

Batch 的爱

永远,永远不要邀请超过一个 Batch 程序员参加派对。一个人就足够了。如果我们没有同行,我们会像其他人一样谈论体育、政治、书籍、电影和旅行。但一旦你把至少两个程序员聚在一起,你就会听到类似“我最近找到了一种在 if 命令的条件语句中编码 or 操作符的新方法。你想让我分享给你吗?”我们会毁掉你的派对。

乐观主义者会说 Batch 是深奥的,而悲观主义者则会说它是晦涩的。事实可能介于两者之间,你会在本书中经常看到这两个词。它的语法与大多数编程语言不同,某些功能的缺失促使人们以富有创意的方式解决问题,这些问题在其他语言中可能显得无趣。结果就是,一些人在你的超级碗派对上讨论构造哈希表的不同方式,几乎让空气都变得稀薄。

我觉得这些难题令人精神焕发,这也是我喜欢在 Batch 脚本中编程的主要原因,而其他人可能会觉得这是一项苦差事。有时候,我真的很享受用一种编程语言来编写栈实现,这本身就是一项了不起的成就。为了简单展示一个挑战,"@"符号本身可以作为变量名,而从其值中提取倒数第二个字符需要使用语法 %@:~-2,1%。这看起来更像是漫画中的脏话,而不是代码,诚然,它确实显得有些深奥,甚至可能显得有些神秘,但请不要因为害怕就把这本书放下;我保证,只需几章,你就会完全理解。

在一群对这门技术略懂一二的程序员中成为 Batch 专家,可能会让你感觉像是一个苏美尔祭司——你是那种能解读脚本并将其意义与智慧传授给他人的特权人群中的一员。但我之所以能占据这个位置,并不是因为某种偶然的天赋,也不是为了个人私利而守护解读这门楔形文字的能力。通过这本书,我希望能让所有想学习这门并不那么古老的脚本的人,都成为高阶祭司和祭司女。在接下来的章节中,我会诚实地谈论我在这门语言中的问题和挫折,但我真的很喜欢编写批处理文件,等你读完这本书后,我希望能把你变成一个信徒。

第一部分:基础知识

就像批处理世界围绕着至关重要的 for 命令运转一样,本书也是如此。第一部分将探讨脚本语言的核心内容,而非基础知识,因为 Batch 语法没有什么是“基础”的。完成本部分后,你将能够编写许多 Batch 解决方案,但随着章节的推进,你会逐渐感受到第二部分中 for 命令的引力。

在这一部分,你将学习变量、作用域以及字符串、整数、浮点数和布尔值等数据类型。我还将讨论文件、目录,以及如何调用程序、内部例程和其他批处理文件。你将了解管道和转义等技巧,如何构建交互式批处理用户界面,等等。

第一章:1 批处理

如果你正在阅读这本书(我敢肯定你正在阅读),你是想编写一些批处理代码。在本章结束时,你将实现这一目标,编写并执行你可能的第一个 bat 文件。

在此过程中,我将介绍批处理脚本语言及其起源于 MS-DOS 命令提示符,以及包含其源代码的两种文件类型:bat 和 cmd。我还将讨论编辑器,它们是编写 bat 文件的主要工具,以及你可以使用的选项。最后,没有对批处理世界的介绍,就不能算完整,必须提及解释器的概况。

MS-DOS 命令提示符

MS-DOS(微软磁盘操作系统)命令提示符存在于每台安装了 Microsoft Windows 的计算机上。如果你年纪稍大一点——也就是,如果你见证过个人计算机的初期发展——你可能还记得那个黑色的矩形框(或者如果你回到 Windows 之前的时代,可能是整个屏幕),你在其中输入命令来执行各种任务,比如安装或启动程序,复制或删除文件。如今,普通用户几乎不再接触它了,因为图形用户界面将这些任务简化到几次点击就能完成,但它依然存在。

要访问 Windows 计算机上的命令提示符,进入开始菜单,在搜索框中输入CMD,即命令的缩写。按回车,MS-DOS 命令提示符将打开。在这个提示符下,你可以输入多种命令。你可以执行程序、复制文件,甚至删除整个*C:*驱动器。别担心,不过;你得知道相关命令才能做到这一点。我不会在这里列出所有可能的命令,因为这本书就是讲这个的,甚至它也并非详尽无遗,但我们可以看一个例子。假设你在一个项目中,文件夹里有好几个文档。每天下来,工作几个小时后,备份文件到另一个驱动器是个好主意。为此,你可以将列表 1-1 中的单个命令输入命令提示符并按回车。

**xcopy C:\****`YourPath`****\*.* D:\****`YourBackUpPath`****\ /F /S /Y**

列表 1-1:备份一些文件的命令

目前不用担心语法(等到第七章再说)。重点是,你可以每天在命令提示符中输入这个命令来复制所有文件。你可能会反驳说,这行命令很容易出错,比如路径中的某个部分或者那些斜杠后面的字母,这些字母到底是干什么的呢。难道不应该使用 Windows,即进入某个文件夹,选择所有文件,右击选择“复制”,然后进入另一个文件夹,右击选择“粘贴”,最后点击确认复制吗?我同意,这种方式比输入命令更简单,但请注意,Windows 的操作涉及八个独立步骤,并且可能需要十几个鼠标点击。

第三种方法比命令提示符方法和点击密集的 Windows 方法都要简单。这种方法就是使用批处理脚本语言进行编程。

批处理脚本语言

批处理对任何编码语言来说都是一个不太合适的名字,无论是脚本语言还是其他语言。一些流行的语言通常以咖啡、宝石、音乐符号或英国喜剧团体的名字命名。即便是被广泛诟病的 COBOL 也有自己独特的缩写,当提到 Pascal 时,大多数人首先想到的通常是 20 世纪的编程语言,而非 17 世纪的数学家。在这个谱系的另一端,“批处理”一词显得平淡无奇;即便在计算机科学的语境中,它也很模糊。批处理是指一次性运行多个作业或大量数据的通用术语,与本书的主题——批处理脚本语言截然不同。

微软在 1980 年代初为 MS-DOS 操作系统开发了批处理脚本或编码语言,并且自 1985 年以来,这一语言已被安装在每一台运行 Windows 的机器上。我猜想,微软的一位员工在厌倦了不断输入一系列重复命令后,可能会想,“如果我们能把这些命令批量处理成一个单一的文件,然后快速而轻松地执行,那该多好?”

这些批处理命令代表了批处理语言的起源。单独的命令并不构成一种语言;语言是执行这些命令的框架。将这些命令批量处理成一个文件使得分支逻辑、复杂循环和可重用代码成为可能——这就是一种真正语言的雏形。

批处理有许多用途。它的命令行起源使它成为计算机或系统管理的理想语言:创建、复制、移动和删除文件。批处理还可以检查和修改目录、注册表,并设置计算机的环境。

一个简单的批处理文件可以设置一些变量,并执行用许多其他语言编写的程序。批处理可以与用户互动,显示信息并收集数据。文件可以被读取、写入和修改。可以生成基本的报告,久而久之,你会发现批处理能够支持复杂而精密的脚本。

在个人电脑普及之前,其他操作系统也有类似批处理的脚本语言。Unix Shell 脚本在基于 Unix 的操作系统上执行,而 JCL(作业控制语言)对于 IBM 大型机至关重要。在向熟悉大型机的新同事介绍批处理时,我通常不会太严谨,而是将其描述为“PC 版的 JCL”。

根据我的经验,程序员对批处理的知识既广泛又浅薄。(我脑海里浮现出一群程序员,手持笔记本电脑,站在儿童游泳池中,裤脚卷起,害怕进入成人池,更不用说它的深水区了。)大多数程序员能够创建一个简单的批处理应用程序,但在面对更复杂的问题时,他们本能地会转而使用其他语言,而这些问题其实可以通过批处理更容易地解决。

Bat 文件

虽然“批处理”这个术语含糊不清,但大多数程序员都知道什么是 bat 文件。命令批处理被输入到批处理文件中(简称为 bat 文件)。因此,bat 文件是包含批处理源代码的文件。(然而,batphile是指那些对夜行性飞行哺乳动物有极大热情甚至是爱好的人。)当执行 bat 文件时,它包含的命令将依次执行,直到遇到终止执行的命令或文件末尾。

大多数 Windows 文件在文件名后面都有扩展名。现代的 Word 文档以 .docx 结尾。Excel 电子表格以 .xlsx 结尾。简单的文本文件通常以 .txt 结尾,PDF 文件则以...嗯,你应该明白了。为了区分 bat 文件与其他文件,它必须具有 .bat 扩展名。

默认情况下,Windows 中文件扩展名不会显示在文件名后面,但我们需要显示它们。要显示扩展名,请在 Windows 资源管理器的 查看 菜单下寻找显示文件扩展名的选项。如果不容易找到,可以在网上搜索“显示文件扩展名”以及你的操作系统名称。

现在你的文件会显示扩展名;例如,你的 Word 文档可能会以 .docx 为后缀。更重要的是,你的 bat 文件会以 .bat 为后缀。许多人称之为 批处理文件,但在接下来的内容中,我将简单地称它们为 bat 文件

你的第一个 bat 文件

让我们创建一个 bat 文件。首先,右键点击桌面以弹出上下文菜单,选择 新建文本文档。将文本文件重命名为类似 SaveProject.bat 的名称。文件名可以是你认为合适的任何名称,但文件名后的扩展名必须从 .txt 改为 .bat。右键点击文件,选择 重命名,输入新的名称和扩展名,然后按下 ENTER 键。弹出的提示可能会警告你更改扩展名可能会造成严重后果。其实不会;只需选择 确认即可。右键点击桌面上的新文件,选择 编辑(不要选择打开,我稍后会解释)。它应该会在记事本中打开供你编辑。

将清单 1-2 中的两行文本输入到你的 bat 文件中。

xcopy `C:\YourPath\`*.* `D:\YourBackUpPath\`/F /S /Y
pause 

清单 1-2:你第一个 bat 文件的完整内容

将 C:\YourPath\ 更改为你想要备份的文件夹,并将 D:\YourBackUpPath\ 更改为你想要保存所有内容的文件夹。(我假设你的备份设备,例如闪存驱动器,被分配为 *D:* 盘符,但它可能是其他盘符。如果没有其他方法,至少为了测试这个功能,你可以在同一个驱动器上定义一个备份路径。)请注意,清单 1-2 中的第一行代码与我们在 清单 1-1 中输入到命令提示符的代码完全相同,但现在它后面跟着第二行,包含一个单词:pause。

在付出努力整理这些内容之后,你可以获得收益。无论何时你想在未来进行备份,只需执行这个 bat 文件。为此,你有多种选择;一种是双击桌面上的图标,另一种是右键点击 bat 文件并选择 打开。你可能会期望 打开 选项打开文件进行编辑,但它实际上是执行 bat 文件。

就这样。一个窗口将会打开,显示所有被复制的文件以及它们从哪里复制到哪里。bat 文件会保持窗口打开,直到你按下任意键关闭它。如果没有这一点,复制过程仍然会发生,但窗口可能会迅速关闭,以至于你无法确定是否成功。

警告

有时,Windows 文件关联可能没有正确分配。也就是说,Word 文档应该与 Word 关联并通过 Word 打开。同样,bat 文件应该与执行 bat 文件的 Windows 程序关联。例如,如果你的 bat 文件在记事本中打开,那么具有 .bat 扩展名的文件关联就已损坏。解决此问题的方法因操作系统而异。请在网上搜索“bat 文件关联修复”和你的操作系统,以了解如何修复该问题。

你可以在 MS-DOS 命令提示符中输入的几乎任何内容,都可以编写成 bat 文件,这样就可以轻松并重复执行。你不需要从互联网上下载任何东西。你需要的一切都已经存在于你的 Windows 计算机上。

我在上一段前加上了 几乎,因为某些命令,尤其是即将讨论的重要 for 命令,在 bat 文件和命令提示符中有稍微不同的语法。更奇怪的是,bat 文件中的命令有时可能会与在命令提示符中输入的完全相同的命令产生略微不同的输出。

本书的重点是编写 bat 文件,而不是使用命令提示符,因此本书中的所有代码示例都将适用于 bat 文件,所有输出示例也将是 bat 文件代码的输出。帮助命令,虽然尚未讨论,将解释任何语法上的差异。基于同样的原因,本书不会涉及主要在命令提示符中使用的命令。

cmd 文件

在示例 1-2 中,我们创建了一个带有——我知道我说的很明显——.bat 扩展名的批处理文件。在 Windows NT 发布时,微软引入了一个非常相似的文件,扩展名为.cmd,它同样包含批处理源代码。任何来自 bat 文件的批处理命令都可以输入到 cmd 文件中。事实上,如果你将第一个批处理文件从示例 1-2 改名为.cmd 扩展名,并以与 bat 文件相同的方式执行它,你将得到相同的结果。

在如何执行这两种类型的文件方面,存在一些技术上的差异,但从用户的角度来看,它们几乎是相同的。唯一显著的区别(我将在第二十一章中讨论)是关于返回码的设置时机和方式,即便如此,这个差异也只会在非常狭窄的情况下显现出来。

本书中几乎所有提到的 bat 文件同样适用于 cmd 文件,但出于多种原因,我会仅仅称其为 bat 文件。在程序员的日常用语中,bat 文件是常态。带有.cmd扩展名的文件通常也会被称为 bat 文件,但反过来却很少发生。单音节的bat相比其没有元音的替代词(通常被称为“see-em-dee”文件)更容易发音,而后者一旦频繁使用,就显得相当累赘。最后一个不容忽视的原因是:你手中这本书的封面艺术。如果这本书是关于 cmd 文件的,它的视觉吸引力肯定会差很多。

由于 cmd 文件较新,有人可能会认为它们在未来会得到更好的支持,且更适合用于新的开发。我无法反驳这个观点,但我仍然发现自己创建带有.bat扩展名的文件,而微软继续支持这两种格式。如果在过去三十年中,cmd 文件并没有取代 bat 文件,我不认为 bat 文件会很快消失。

编辑器推荐

我之前提到过,当你编辑第一个批处理文件时,它可能会在记事本中打开。记事本是最基础的文本文件编辑器。虽然许多人认为 Word 已经发展成了一个臃肿的庞然大物,但记事本则正好相反,缺乏任何有用的功能。它可以用来编辑一个简单的批处理文件,但在我们继续之前,我必须强调使用更好的编辑器的重要性。这里所说的编辑器,是指一个可以在窗口中打开(而非执行)批处理文件,用于阅读和修改的工具。

我个人最喜欢的是 Notepad++。初次听到这个名字时,我错误地认为它不过是一个强化版的记事本,但实际使用起来真的很令人愉悦。命令、变量、运算符、标签、注释,以及我们尚未覆盖的更多项目,都以不同的颜色显示,帮助提高可读性。大多数编辑器都有这个功能,但如果你双击一个变量,它不仅会高亮该变量,还会高亮文件中所有该变量的其他实例,这使得查找拼写错误变得更加容易,尽管由于 Notepad++ 的易用自动完成功能,拼写错误在其中并不常见。如果你输入一个变量名,比如 myVeryVerboseVariableName——首先,真丢脸,缩写是有原因的——下次你开始输入 myVe 时,Notepad++ 会巧妙地给你一个选项,通过按一个键就能插入剩下的 21 个字符。

Notepad++ 非常可配置。如果你觉得变量的橙色有点刺眼,你可以将其改为烧橙色,或者改为紫红色。它也适用于其他多种编程语言,最棒的是,它是免费的。只需访问 <wbr>notepad<wbr>-plus<wbr>-plus<wbr>.org 或在网上搜索 “Notepad++ 下载”,你就能轻松完成下载。

UltraEdit 也是一个强大的编辑器,Visual Studio Code 正快速成为多种编程语言(包括 Batch)中非常流行的编辑器。在互联网上,你还会找到其他几个编辑器,其中许多是免费的。下载两个或三个并进行试验,但无论如何,千万不要满足于记事本。

Batch 解释器

Batch 是一种脚本语言,而不是编译语言。编译语言,如 Java、Visual Basic 和 C#,是以文本形式编写的,但这些文本不能直接执行。相反,程序员通常在集成开发环境(IDE)中编写程序,通过点击一个或两个按钮运行 编译器,将代码转换为可执行文件。生成的文件完全不可读,但对计算机而言是可读的,并且经过优化以便执行。无法阅读的文本还有一个附加好处,就是能将任何专有信息隐藏起来,不被用户看到。

这段编译代码的下一个组成部分是 运行时;运行时有不同的形式,但运行时会加载到计算机上,并用于运行任何用特定语言编写的可执行文件。

作为一种脚本语言,Batch 也是以文本形式编写的,但其余的过程与编译语言大相径庭。没有编译器,也没有运行时;相反,Batch 解释器,或者说 cmd.exe 可执行文件,实际上承担了两者的角色。(它的 16 位前身是 command.com。)Batch 解释器有时被称为 命令解释器命令行解释器,但通常它仅仅被简单地称为 解释器

解释器执行包含文本的 bat 文件。缺少中间编译步骤确实有一些优势。很快,我会分享一些有趣的批处理技巧,正是因为代码没有编译,这些技巧才得以实现,但它也给编码者带来了一些挑战。当一个程序被编译时,编译器会捕捉到语法错误,甚至会给出如何改进代码的建议。例如,编译器很容易捕捉到缺少的括号,这样编码者可以在几秒钟内修复它。在 bat 文件中,缺少的字符不会被捕捉,直到解释器执行它,甚至可能在第一次执行时也不会发现。通过这种方式,解释器扮演了编译器的角色——一个非常低效的编译器。

解释器更像是一个运行时。当本章前面描述的 bat 文件被打开或执行时,就会发出一个调用给解释器,读取 bat 文件并逐行执行——或者说解释它。这意味着 bat 文件并未针对执行进行优化,且无论好坏,任何拥有记事本技术的用户都可以看到代码。此外,bat 文件可以在任何 Windows 机器上执行,因为所有这些机器都装有解释器。一个有趣的副作用是,特别长时间运行的 bat 文件的后期阶段实际上可以在先前逻辑执行时进行编码,这是一个令人印象深刻的,但并不特别实用的功能。

所有现代集成开发环境(IDE)都配有动画器或调试器,允许你逐行执行代码,分析变量,甚至可能修改它们。你可以设置执行在特定行或多行时停止。这是任何编码者都非常有用的工具,但批处理不支持动画器。bat 文件的生命周期如下:它被编写,然后被执行。就是这样。

一些脚本语言,如 JavaScript,通常可以两种方式运行——源代码可以通过解释器运行,或者使用来自多个公司的产品将其编译成可执行文件。批处理文件中并没有类似的常用方式。bat 文件只能通过解释器运行,理解解释器是什么非常重要,因为在接下来的章节中我会经常提到它。

总结

在本章中,我介绍了批处理脚本语言、bat 文件、cmd 文件和解释器。你编写并执行了一个 bat 文件(也许是你的第一个),并学习了有关编辑器的知识。

现在你已经准备好真正开始编码了。在第二章中,你将学习如何使用常用的 set 命令给变量赋值,以及如何解析或提取这些值。你还将探索一些在批处理世界中广泛使用的其他命令,为你未来编写任何 bat 文件奠定必要的基础。

第二章:2 变量和值

现在我们准备开始编码,我们将探讨变量、值和 Batch 的 set 命令,后者用于将值分配给变量。虽然这些话题如果你在其他语言中有过编码经验可能显得微不足道,但 Batch 有一些独特的怪癖,值得注意。

你将学习如何在控制台显示变量的值,以确认它已经正确设置。此外,我将介绍 命令分隔符,它允许你在一行中输入多个命令。我还会向你展示如何创建注释以及如何设置保存到你计算机上的变量,这些变量即使在关闭 bat 文件后也能继续使用。最后,你将学习如何在命令提示符下访问任何 Batch 命令的文档,这对任何使用 bat 文件的人来说都是一项有用的技能。

设置和解析变量

变量 是一个命名字段,定义了一个内存位置,用于存储稍后使用的值。许多甚至大多数语言允许并通常要求在赋值之前定义变量的特定数据类型,通常是某种文本或数字类型。但 Batch 不这么做;变量在第一次被“设置”值时就会存在,而且该值可以包含字母、数字和其他字符。之后,程序员可以选择将其视为某种数据类型,或者不做任何处理。在 第五章 和 第六章 中,我会回到如何处理数据类型的方法,但本章重点讨论将值赋给变量这一看似简单的任务。

以变量为例,程序员可能有一个字段来表示他们的心情状态;无论是出于自恋还是强迫症原因都无关紧要。该变量定义为或命名为 myMood,可能的两种值是 happy 和 sad。要将变量设置为 happy,显然可以使用 set 命令:

set myMood=happy

执行此操作后,myMood 变量将包含值 happy。

如果这个话题特别令人困惑,以下命令会清除之前的值,并用不同的值替换它:

set myMood=nonplussed

但你怎么能确定这个或任何其他变量的值呢?在 Batch 中,揭示变量的值被称为 解析变量,通常是通过将变量用百分号包围来完成的。也就是说,%myMood% 会在执行先前的 set 命令后解析为 nonplussed。现在,为了实际看到解析变量的结果,我们需要稍微偏离一下——一个非常重要的偏离。

显示变量的值

在本节中,你将学习如何快速显示计算机屏幕上已解析的变量值,但这种技巧不仅仅用于此。我们将在未来的章节中回到这一点,展示 Batch 的其他许多特性,例如将其作为测试技术,这对于任何 Batch 程序员来说都是至关重要的。

写入控制台

要在屏幕上显示一个变量的内容,我们需要两个额外的命令:echo 和 pause。为了演示,让我们创建一个小的 bat 文件。打开计算机上的一个新文件夹,可能是 *C:\Batch*,然后在其中创建一个名为 Mood.bat 的 bat 文件,文件内容为 Listing 2-1 中显示的三行。

set myMood=happy
echo My mood is %myMood%.
pause 

Listing 2-1: bat 文件 Mood.bat 显示已解析的变量

如果你双击或打开 Mood.bat,bat 文件应该会执行,并且一个黑色窗口将显示白色文字。这个窗口是 DOS 窗口,或称为 控制台,在本书中我会称它为控制台。

我们已经在 Listing 2-1 的第一行讨论过 set 命令。在此上下文中,echo 命令将剩余的语句(不包括实际文本 echo 后面的空格)输出到控制台。显示的文本是 "My mood is" 和一个尾随的空格,然后是 myMood 的内容或值——也就是文本 happy——以及尾随的句点。pause 命令保持控制台打开。如果没有它,窗口会在你来得及阅读之前打开并关闭。

Listing 2-2 显示了当这个 bat 文件执行时写入控制台的所有内容,这比你预期的可能还要多。

C:\Batch>set myMood=happy

C:\Batch>echo My mood is happy.
My mood is happy.

C:\Batch>pause
Press any key to continue . . . 

Listing 2-2: Mood.bat bat 文件的控制台显示

每个命令前面都有当前目录 *C:\Batch*,然后是一个大于号(>)作为分隔符。(你将在 第八章 中了解当前目录的更多内容。现在,只需将其视为正在执行的 bat 文件的路径。)

第一行显示了 set 命令的执行,第二行显示了 echo 命令的执行。第三行是执行 echo 命令后的结果——即 Listing 2-2 中描述的输出到控制台。你可以从缺少前缀文本 C:\Batch> 来判断它不是一个命令。更重要的是,%myMood% 解析为文本 happy。pause 命令也会生成输出,显示文本 Press any key to continue ... ,正如消息所示,执行会暂停,直到按下任意键,此时 bat 文件将结束,控制台也会关闭。另外,注意 set 命令不会生成任何输出,因为它只是设置一个变量的值——没有东西需要输出。

清理控制台

输出到控制台的问题之一是,命令与来自 echo 和 pause 命令的期望输出交织在一起,造成了一团乱。有关不同输出和如何管理它们的更多内容,你将在 第十二章 中学习;在这里,我们只快速看一下清理这些内容的方式。在 Mood.bat 文件的顶部执行一个 echo 命令,后跟参数 off,正如 Listing 2-1 中所示:

@echo off
set myMood=happy
echo My mood is %myMood%.
pause 

echo off 命令并不是抑制后续命令的实际输出,而是抑制显示每个命令正在执行的行——即,显示当前目录的那一行。此外,在 echo 命令前加上 @ 符号,可以抑制它自身执行的内容不显示在控制台上。

与 Listing 2-2 相比,当执行修改后的 bat 文件时,控制台的显示变得更加简洁:

My mood is happy.
Press any key to continue . . . 

现在我们可以轻松演示如何将 myMood 变量初始化为忧郁值,然后将其重置为快乐值:

@echo off
set myMood=gloomy
echo My mood is %myMood%.
set myMood=cheerful
echo Now my mood is %myMood%.
pause 

结果显示相同的变量在不同时间解析为两个不同的值:

My mood is gloomy.
Now my mood is cheerful.
Press any key to continue . . . 

随着我们的 bat 文件变得越来越复杂,echo 命令可以将输出发送到控制台以外的其他地方(有关更多细节,请参见 第十二章)。通过在命令前加上 > con,可以显式地将数据重定向到控制台:

> con echo This will always get to the "con" or console

这种技术将在后续章节中非常有用,用于演示代码片段中的操作。为了简洁起见,后面的示例中我不会包含初始的 echo off 和尾随的 pause 命令,但我建议你添加它们来清理并保持控制台开启。

set 命令的特性

设置变量通常是大多数编程语言中的简单话题,但批处理并不像大多数编程语言。所有批处理程序员都需要理解以下 set 命令的特性,以避免将来可能遇到的一些问题。

大小写敏感性

仔细检查以下两个命令。它们看起来有些不同,但功能上是等效的:

SET myMood=whimsical
set MYMOOD=whimsical 

批处理命令和变量不区分大小写。这里 set 命令在一个命令中是大写的,在另一个命令中是小写的,但解释器将它们视为相同。你也可以使用 Set,而功能不受影响。为了保险起见,sET 和 SeT 也能以相同方式工作,但你得真是个反叛者才会这样编码。同样,你可以交替使用 myMood、MYMOOD 和 mymood 变量。然而,变量的值是按输入的方式存储的,因此它是区分大小写的。如果变量被设置为 WHIMSICAL,它将被解析为 WHIMSICAL;同样,如果设置为 Whimsical,它将被解析为 Whimsical。

这完全是风格和个人偏好的问题。我发现许多 bat 文件中过多的内容都被大写了。大写本应让某些东西突出,但如果一切都在闪烁霓虹灯,什么也就没法突出。大多数批处理程序员将命令名称中的所有字母都大写,但在本书中,我使用小写字符来表示所有批处理命令。此外,我更喜欢使用驼峰命名法的变量。

注意

驼峰式命名文本易于阅读,即使它包含多个没有空格或其他字符分隔的单词。驼峰式命名的第一个字母可以是大写(首字母大写)或小写(首字母小写)。但要符合驼峰式命名规则,所有后续单词的第一个字母必须大写,其余部分小写。一个首字母大写变体(也叫做Pascal大驼峰命名法)是 MyMood。对应的首字母小写变体(也叫做dromedary小驼峰命名法)将是 myMood。想象一下,一只骆驼低着头喝水。

有效的变量字符

大多数编程语言都有严格的规则,规定了变量名中可使用的字符列表。通常,数字和 26 个字母(大小写均可)是允许的,另外还有一些特殊字符可以使用。但批处理语言不同,几乎键盘上的每个字符都是有效的变量名字符,尽管你应该避免将数字作为变量名的第一个字符。(虽然你可以设置以数字开头的变量名,但解决这些变量时会有问题,为什么会这样,我会在第三章中解释。)

一些字符是非法的,因为它们在批处理语言中有特定用途;例如,波浪号 (~)、和号 (&)、百分号 (%),以及小于号 (<) 和大于号 (>) 是保留字符,但其他一些字符会让任何不熟悉批处理语言的程序员感到惊讶。 列表 2-3 中的三个 set 命令成功地将这三个具有奇怪单字符名称的变量设置为它们各自的描述。

set ;=semicolon
set @=at
set #=hashtag

> con echo %;% %@% %#% 

列表 2-3:设置具有奇怪单字符名称的变量

列表 2-3 中的 echo 命令将文本分号和井号写入控制台。

即使是以下这种怪异的做法,也能将文本“这确实有效”存储到包含美元符号、点和不匹配括号的变量中:

set var$with.oddchars}=This actually works

这个变量名展示了可能的内容,但它难以阅读,并不推荐使用。

然而,明智地使用这些字符在变量名中,可以成为一个方便的工具。例如,一组相关的变量可能都带有前导或尾部的下划线,作为它们之间关系的视觉提示;数字可以用 # 来表示,这比 nbr 更简洁,也比 no 更清晰。在本书的后面,我将利用这个有趣的特性构建包含括号的有意义的数组和哈希表。

等号两侧的空格

以下是大多数新手批处理程序员在学习其他语言时常犯的经典错误。仔细检查[列表 2-4 中显示的 set 命令。

set X = Hello
> con echo The value of X is "%X%". 

列表 2-4:设置带有等号两侧空格的变量

如果你期望 echo 命令的结果是

The value of X is "Hello".

那将是一个可以理解的错误,但仍然是错误。实际结果是这样的:

The value of X is "".

空的引号意味着 X 没有被设置,或者被设置为 null,意味着根本没有任何内容,甚至没有空格。

注意

在 第一章 中,我提到过在命令提示符下,语法和输出可能与 bat 文件有所不同,这是一个典型的例子。在命令提示符下输入相同的代码,显示的是对未设置变量的尝试解析,表现得非常不同:

The value of X is "%X%". 

我不会在接下来的页面中指出每个差异,因此如果你在命令提示符下看到任何异常,尝试将代码放入 bat 文件中。

现在,Listing 2-4 中的 set 命令并不复杂,显然是在将 X 设置为文本 Hello,对吧?而且它看起来与其他更现代的语言中的赋值命令很像,应该会按预期进行赋值。

这是我们的第一个 batveat(批处理警告;有关 batveat 的详细信息,请参见介绍部分)。这个问题的关键是等号前的空格。批处理解释器就像难搞的青少年一样,聪明而且无情,对自己都不好。变量名从 set 命令后的第一个非空格字符开始,到赋值运算符或等号之前的字符结束——不管这个字符是什么。因此,这里设置的变量由两个字符组成,一个 X 后跟一个空格:

set X = Hello

%X% 没有解析出任何内容,但 %X % 确实有一个值,这个值是 Hello,对吧?其实不完全是;这是我们的第二个警告。变量的值是等号后的字符串,一直到语句的末尾。因此,赋值的内容是等号后面的空格,然后是单词 Hello 中的五个字符。

让我们像这样修改 Listing 2-4 中的 echo 命令:

set X = Hello
> con echo The value of X-space is "%X %". 

现在,解决带空格的变量会显示其值,而该值包含一个前导空格:

The value of X-space is " Hello".

这个方法可行,但通常变量名后带空格是一个潜在的意外。我们甚至可以在变量名中间嵌入空格,但能做某件事和做这件事是个好主意之间有很大区别。这绝不应被解读为鼓励创建晦涩的代码;这更像是一个关于在 set 命令中等号两侧空格的警告。

再次查看 Listing 2-4,我们去掉等号前后空格,回到原始的 echo 命令:

set X=Hello
> con echo The value of X is "%X%". 

最后,我们得到了写入控制台的预期结果:

The value of X is "Hello".

一个更容易犯的错误是无意中在行末加上一个或两个空格。因为在编辑器中看不到空格,这很容易被忽略。(在 Notepad++ 中,进入 查看显示符号显示空格和制表符,可以将空格显示为淡点。其他优秀的编辑器也会有类似的功能。)

在本书后面你会学到,有些有效的原因会将变量的值前置或后置空格,但一定要小心不要不小心这样做。然而,我很难想到一个合法的例子,说明变量的名称后面会被附加一个或多个空格。在使用基本的 set 命令时,确保变量名和等号之间没有空格。

命令分隔符

& 符号是一个特殊字符,作为命令分隔符使用,它不像普通文本那样处理。例如,你可以将 Listing 2-3 中的三行代码串联成一行,每个命令之间用 & 字符分隔:

set ;=semicolon& set @=at& set #=hashtag

这在功能上等同于将三个命令写在三行上。

有时,这种技巧对于合并简单且相似的命令很有用,但过度使用它会使代码难以阅读。然而,我发现了命令分隔符的两个非常有用的应用。

向命令添加备注

命令分隔符的一个用途是将文本添加到一行的末尾,使其被视为注释。rem 命令将其后面的文本变成注释。通常,我们会在某些有趣的代码前面(或前几行)放置 rem 命令作为注释,但我们也可以将它与特定命令一起使用,通过命令分隔符来附加它。例如,下面的两行执行相同的逻辑:

set myMood=reflective
set myMood=reflective& rem This is a thoughtful and contemplative mood. 

然而,第二行为任何阅读代码的人提供了更多的信息。

终止命令

命令分隔符的第二个用途是明确地终止命令,以便编码人员能够清楚地区分是否有多余的空格。以下命令是在将变量设置为空值、一个空格还是多个空格?

set myMood=

按照现在的写法,不可能告诉(除非你让编辑器显示空格作为可见字符)。

如果你没有阅读前面的讨论,下面的命令可能会让你觉得它正在将变量设置为一个 & 符号:

set myMood=&

但这个命令明确地告诉读者,变量被设置为空值,因为该语句在等号操作符后立即被 & 符号终止。

同样,编码人员可能希望变量具有特定长度(你将在 第二十二章 中看到这个应用,特别是在格式化报告时)。下面的命令将变量设置为一个 10 字节左对齐的值,包含文本 pensive 后跟三个空格:

set myMood=pensive    &

如果没有 & 符号,确定文本后面跟了多少个空格(如果有的话)将非常困难。从技术上讲,& 符号并没有分隔两个命令,但它无疑终止了一个命令。

显示变量信息

set 命令还有一个有趣的用法。当没有等号时,它会输出变量的值,因此,如果 myMood 变量已经定义,你可以在 bat 文件中输入此命令:

set myMood

输出到控制台的结果可能如下所示:

myMood=hopeful

如果在 set 命令后仅输入变量名的第一部分,则所有以该文本开头的变量将会被显示。因此,以下命令可能会输出比 myMood 变量的值更多的内容:

set myM

也许,真的也许,它会输出如下内容:

Mymar=A Genus of Fairyflies
myMood=hopeful 

虽然不太可能设置这样的变量,但如果它存在,并且如果这些是以 mym 开头的唯一两个变量,那么输出的结果将是这些变量的值。请注意,该命令会查找所有变量,忽略大小写。同时还要注意,变量值中包含了嵌入空格的示例。

在看到此技巧在完整变量名和部分变量名中都能正常工作之后,我们可以将其扩展到完全没有变量名的情况:

set

这个没有参数的命令会生成所有活动变量的列表,这些变量是在 bat 文件启动时加载的,以及来自 bat 文件本身的任何添加和修改。

持久设置变量

set 命令本质上是临时的。它在特定的批处理流中定义变量,直到变量被重新赋值或脚本终止,此时通过 set 命令设置的所有变量都会消失在记忆中。但有时我们希望一个变量能够供其他进程或计算机上的其他 bat 文件访问——甚至在原始 bat 文件终止后很久,甚至在计算机关机和重启后也能访问。我们需要的是一种极限版的 set 命令,或者叫做 set Xtreme 命令。名字起得很恰当,我们有了专为此目的设计的 setx 命令。(说实话,我不知道这个命令名字的来源,但这是我在聚会上讲的一个故事。在引言中你已经被提醒过不要邀请我这种人去参加派对。)

合乎逻辑的假设是 set 和 setx 命令的语法应该是相同的。你还能期待什么呢,除了变量、等号和一个值?这有点令人困惑,但事实并非如此。setx 命令不需要等号。相反,变量名和值之间由空格或多个空格分隔,如下所示:

setx myMood puzzled

在讨论 set 命令时,我们明确看到变量名和值确实可以包含空格。这显然会在设置变量的命令中造成困扰,因为空格是命令的分隔符。但将变量名和/或值用双引号括起来,可以轻松解决这个问题。这个命令创建了一个有两个词的变量名,并将一个包含两个词的值赋给它:

setx "my mood"  "cautiously optimistic"

要测试这个,首先在一个 bat 文件中执行上面的命令,然后在另一个 bat 文件中或直接在命令提示符下执行以下命令:

set my m

要查看效果,你必须在 setx 命令完成后启动第二个 bat 文件或打开命令提示符,因为当会话开始时,解释器会加载计算机现有的变量。

所有以我的 m 开头的变量(不区分大小写,包括嵌入的空格)都会被显示,包括变量 my mood。除非该变量被其他进程重新赋值,例如未来的 setx 命令,否则它会在计算机可操作时一直存在,直到被改变。

setx 命令是一个很棒的工具,可以帮助开发已编译的代码。当开发中的某个程序最终在生产环境中运行时,显然它会在不同的机器上运行,并且有自己的环境变量。在启用该程序时,一些 IDE 有很好的机制来模拟这些环境变量和文件连接设置;而其他 IDE 遗憾地没有。我见过一些不太优雅的解决方案来绕过这个缺陷,但一个好的解决方案是实际上在开发机器上设置所有需要的值,然后再启用程序。

对于用某些语言开发的每个程序,我会创建一个包含一系列 setx 命令的 bat 文件,每个变量都需要被持久化设置。执行 bat 文件后,我可以启用程序,它会在后续的生产环境中找到我预期的所有环境变量。如果我想启用另一个程序,我可以首先快速且轻松地运行与其关联的 bat 文件。如果在操作完成后,我希望恢复某些变量的先前状态,我也可以为此创建一个 bat 文件。(某些集成开发环境(IDE)在打开时只会存储一次环境中的所有变量。如果你的 IDE 是这种行为,确保在打开 IDE 之前运行 bat 文件。)

命令行帮助

本章将介绍的最后一个批处理命令是记录我已经讨论过的命令以及将来会介绍的所有其他命令的命令。help 命令接受另一个命令作为参数,并返回关于该命令的大量信息,从简要的功能描述和一般语法开始。

我将使用 set 命令进行演示,因为它在本章中起着核心作用。要调用 help 命令,请在命令提示符下输入它(在 Windows 开始菜单中输入 CMD 然后按回车)。接着,要获取更多关于 set 命令的详细信息,请输入以下内容:

**help set**

解释器生成了关于 set 命令的过多信息,无法在此完全显示,但以下是前几行内容:

Displays, sets, or removes cmd.exe environment variables.

SET [variable=[string]]

  variable  Specifies the environment-variable name.
  string    Specifies a series of characters to assign to the variable. 

命令的简要描述后紧接着是其一般语法,显然语法的开头是命令名本身。所有方括号中的文本(也称为硬括号)是可选的。方括号包围的文本,[variable=[string]],表示命令可以使用或不使用括号内的文本。请记住,未带参数的 set 命令会返回所有活动变量的列表。嵌套的方括号进一步表明,string 也是可选的——也就是说,variable 可以设置为空值。

一些命令的帮助信息中提供了该命令的使用示例、附加说明和可用选项列表。选项是指为命令分配的设置或调整,用于开启或关闭某些附加功能。它们也叫做开关;事实上,帮助命令令人沮丧地将这两个术语交替使用。为了保持一致性,我将仅使用更常见的术语选项,但如果有人提到批处理命令开关,他们指的其实是选项。

选项通常由一个斜杠和一个字母组成,但你最终会遇到一些更复杂的选项。在命令提示符下滚动显示 set 命令的帮助信息,可以看到两个有趣且有用的选项。/A 选项允许命令执行算术运算(第六章)。/P 或 prompt 选项用于通过用户输入的数据设置变量(第十五章)。

在第一章中,你可能遇到了第一个批处理文件,其中包含了一个用于复制文件的命令。我不会在第七章中详细讲解 xcopy 命令,但在前一章中,它使用了三个选项(/F、/S 和 /Y)。这些选项具体的作用暂时不重要——但它们能够开启或关闭某些功能,并且这些选项在帮助命令中有文档说明。

一些命令有许多选项,其他命令则只有少数选项,甚至有些命令没有选项。在介绍命令时,我会详细讲解我认为重要且实用的选项,但你还需要使用帮助命令来查找更完整的列表。然而,一些未记录的选项无法通过帮助命令找到,要发现这些隐藏的宝藏,请访问 <wbr>ss64<wbr>.com<wbr>/nt<wbr>/ 或其他相关资源。

我建议在第一次使用某个特定命令时,或者作为可用选项的提示时使用帮助命令。尝试将其用于本章中提到的任何其他命令,甚至是帮助命令本身。是的,在命令提示符下输入此命令

**help help**

显示有关帮助命令的文档。

注意

在任何批处理命令后跟上 /? 将检索相同的信息。也就是说,输入 set /? 的效果与 help set相同。

总结

批处理编程可能很复杂,即使是看似简单的设置变量也可能有一些细微差别。在本章中,我详细介绍了 set 命令及其特殊之处,并将其与 setx 命令进行了比较,后者设置的变量是持久化的。你还学习了如何解析变量并将其显示在控制台上。现在你可以向批处理文件中添加注释,使用命令分隔符来实现多种功能,最重要的是,能够快速访问任何命令的文档。

在下一章,我们将进一步探讨变量,特别是变量的作用域。我们将研究如何定义变量在何时何地拥有特定的值,以及如何启用强大的延迟扩展功能。作为预告,我将告诉你一个秘密:一个变量的值可以是另一个变量的名称,而这个变量本身有自己的值。

第三章:3 范围和延迟扩展

在上一章中,你学习了变量的概念、如何设置它们以及如何解析它们的值。在本章中,我将重点介绍 setlocal 命令,这是批处理的一些重要且不同特性的核心,它改变了你处理变量的时机、地点和方式。首先,它定义了范围:这些变量在哪里、何时可以访问和操作。其次,它启用了一个叫做延迟扩展的功能,它改变了变量的解析方式,其中一个结果是允许你将一个变量存储在另一个变量中。

所有语言都以某种方式处理范围,但延迟扩展,或者类似的功能,要少得多,你会看到一些出乎意料的用途。最后,setlocal 命令启用了命令扩展,这是一个尴尬的术语,指的是为许多其他 Batch 命令启用的一堆附加功能。

范围

范围定义了一个变量的生命周期。全局变量可以在任何地方设置、解析、删除和修改,这对于大多数简单的批处理文件来说是有效的。局部变量在一个有限的生命周期内创建,旨在仅在某个代码段中可访问,在该段中它是在范围内的。如果在其他地方修改变量而无法识别,那么该变量就超出范围了。

在 Batch 中,setlocal 命令开始了一个代码段,在该段中变量是可访问的,而 endlocal 命令结束该代码段,使这些变量超出范围。在两个命令之间定义或操作的所有内容在该空间内有效,但在执行 endlocal 命令后,这些变量会恢复为先前的状态。

为了演示,以下代码将变量的状态输出到控制台,既在 setlocal 命令的范围内,也在范围外。一个变量仅在 setlocal 的范围内定义,一个仅在范围外定义,另一个则在范围内外都定义。在 echo 命令的右侧,我包含了备注,显示了结果,特别是解析后的变量,输出到控制台:

❶ set inAndOut=OUT    
set outer=OUT

❷ setlocal   

❸ set inAndOut=IN     
set inner=IN

❹ > con echo Inside Scope:                   &rem Inside Scope:
> con echo   Outer Variable = %outer%      &rem   Outer Variable = OUT
> con echo   Inner Variable = %inner%      &rem   Inner Variable = IN
> con echo  In/Out Variable = %inAndOut%   &rem  In/Out Variable = IN

❺ endlocal  

❻ > con echo Outside Scope:                  &rem Outside Scope:
> con echo   Outer Variable = %outer%      &rem   Outer Variable = OUT
> con echo   Inner Variable = %inner%      &rem   Inner Variable =
> con echo  In/Out Variable = %inAndOut%   &rem  In/Out Variable = OUT 

这里有很多内容需要解释。我们先来看定义的第一个变量:在 setlocal ❷ 执行之前,inAndOut 被设置为 OUT ❶,这意味着它是在命令的范围外设置的。在执行 setlocal 后,同一个变量被设置为 IN ❸,此时它在范围内。当首次查询 inAndOut 时,它解析为 IN ❹,因为它在范围内。但在执行 endlocal ❺ 后,它就超出了范围并恢复为先前的状态,即 OUT ❻。(顺便提一下,IN-N-OUT 始终是美味的。)

现在考虑一下内部变量,它只在范围内定义一次。也就是说,在执行 setlocal ❷ 后,它被设置为 IN ❸。然后在执行 endlocal ❺ 之前,该变量解析为 IN ❹ 的值,但有趣的地方在这里;在执行 endlocal ❺ 后,它恢复为先前未定义的状态——也就是 null 或空 ❻。

最终的变量是 outer,它也只定义了一次,但当它超出作用域时,它在 setlocal ❷ 执行前被设置为 OUT ❶。正如你所料,在 endlocal ❺ 执行时,当它超出作用域时,变量的值仍然是 OUT ❻。但如你所不料,它的值在 setlocal ❷ 的作用域内也可以使用,因为它的值在 endlocal ❺ 执行前也是 OUT ❹。

这个例子表明,setlocal 命令不会阻止我们使用已经在作用域内的变量。到目前为止存在的一切仍然可用。它的作用是:setlocal 执行时会对环境进行快照,并在 endlocal 执行时返回该环境快照。

使用 setlocal 和 endlocal 命令定义作用域只有一个用途,但它非常重要:隐藏或分隔代码中的变量,以防止冲突。默认情况下,Batch 变量是全局的;在一个 bat 文件中设置的变量可以在被调用的 bat 文件中解析或重置,甚至在被调用的内部例程中也是如此。默认情况下,许多其他编程语言使用相反的方法,限制在被调用的程序和例程内部使用的变量的作用域。有时,全局变量完全没问题,但在其他情况下,限制作用域是更好的选择。定义作用域的能力使你能够为你的应用选择最合适的方式。

如果你正在编写一个将被许多其他进程调用的工具 bat 文件,你可能不知道调用进程正在使用哪些变量。在 bat 文件的顶部放置一个 setlocal,并在末尾或接近末尾放置一个 endlocal,可以定义并限制作用域。这样做的结果是,如果你恰好使用了与调用 bat 文件相同的变量名,你不会覆盖它的变量,这样调用者可以放心地调用你的 bat 文件,确保没有不良副作用。对于被调用的内部例程,通常也会采取这种做法。(在第十章中,我们将研究如何调用内部例程和其他 bat 文件。)

定义作用域提出了一个有趣的问题。如果调用一个工具 bat 文件来执行特定任务,那么很有可能这个任务的部分内容是设置并返回某个变量。有一种方法可以让变量在执行 endlocal 命令后仍然生效,我将在第十六章中分享这个方法。

延迟扩展

setlocal 命令是一个多功能工具。除了定义作用域外,当与非常具体的参数一起使用时,它还启用了延迟扩展:

setlocal EnableDelayedExpansion

恰如其分,参数是完全展开的,没有任何缩写的迹象。

延迟扩展实现了两轮变量解析:初始解析和延迟解析或扩展。当解释器执行 bat 文件时,它会逐行处理代码,首先读取或解析一行,然后执行该行。初始解析发生在解释器解析这一行时,而延迟扩展发生在它执行这一行时。

这个特性允许一些在大多数编程语言中无法实现的有趣行为。例如,你可以将一个变量的值视为变量本身——或者它的值可以作为另一个变量名的一部分。在 列表 3-1 中,Toyota 既是变量名,也是值;这不是巧合。

setlocal EnableDelayedExpansion
set Car=Toyota
set Toyota=Prius 

列表 3-1:设置启用延迟扩展的 Car 和 Toyota

首先,我们需要使用 setlocal 命令并提供启用延迟扩展的参数。接下来,我们将 Car 设置为汽车的品牌,这里是 Toyota。但 Toyota 生产多个车型,如果我们想捕获特定的车型,可以将定义为 Toyota 的变量设置为 Prius 的值。

值与变量

如前所述,Toyota 既是一个值,也是一个变量。它是 Car 变量的值,同时也是一个包含 Prius 值的变量。现在,我们可以执行三条语句,将三个变量输出到控制台,如 列表 3-2 所示。

> con echo                 Car = %Car%
> con echo           Car Again = !Car!
> con echo   Delayed Expansion = !%Car%! 

列表 3-2:通过三种不同方式解析 Car

下面是 列表 3-2 生成的输出:

 Car = Toyota
          Car Again = Toyota
  Delayed Expansion = Prius 

Car 的第一次解析现在显得相当平常。将变量用百分号包围(%)会解析为它的值 Toyota。第二条命令引入了新东西:使用感叹号(!)作为分隔符来解析变量 !Car!,而不是百分号。被感叹号包围的变量同样会解析为 Toyota,但为什么要使用两个不同的符号来完成相同的功能呢?答案将在我们查看最后一条命令后显现。

第三种解析方法真正展示了延迟扩展的强大功能。变量被百分号包围,接着又被感叹号包围。解释器首先将 %Car% 解析为 Toyota。请确保你坐好,接下来的部分可能让你吃惊:这个值现在被感叹号包围,这导致它再次被解析,因此 !Toyota! 变成了 Prius。将所有内容组合在一起,变量被解析如下:

!%Car%! → !Toyota! → Prius

为了回答关于两个不同符号执行相同功能的问题,解释器需要这两个符号来完成解析,因为现在我们有了两轮解析:百分号用于内层解析,而感叹号用于外层解析。(我们能不能用两组双百分号将变量包裹起来?不行,for 命令的语法对双百分号有特定用途,你将在 第十七章 中学习到这个内容。)

演示延迟扩展如何影响代码的最佳方式是运行相同的代码,但不启用延迟扩展。如果我们从列表 3-1 中移除 setlocal,则列表 3-2 的结果是:

 Car = Toyota
          Car Again = !Car!
  Delayed Expansion = !Toyota! 

如果没有延迟扩展,感叹号将被视为普通文本,对批处理没有任何意义。!Car! 变量根本没有被解析;解释器甚至不会把这三个字母当作一个变量。!%Car%! 变量经历了一轮变量解析,但感叹号只是陪衬。

在第二章中,我巧妙地避开了一个问题,提到过变量名不应该以数字开头。从技术上讲,你可以设置这样的变量,但你无法通过百分号解析它;只能通过感叹号,并且必须启用延迟扩展。处理这个小怪癖的最佳方法是避免让变量名以数字开头。

现在我们有一个变量,它可以解析为一个值,这个值再次被解析为另一个值。这通常在那些现代编译语言中难以做到,或者根本做不到。说实话,尽管整个词既是变量又是值听起来很酷,但在现实世界中并不常用,但部分变量名有很多应用场景。

部分变量名

当解析值仅作为变量名的一部分使用时,这种技巧变得更加有趣和实用。为了演示,考虑以下设置命令,它们定义了五个城市的标志性美食:

set foodNash=Hot Chicken
set foodNYC=Thin Crust Pizza
set foodChic=Deep Dish Pizza
set foodNO=Muffuletta Sandwich
set foodSTL=Frozen Custard 

每个变量名是由食物和城市的常用缩写连接而成,并被设置为该城市著名的菜肴。这里只展示了五个变量,但你可以定义任意数量的变量。

以下一组变量使用了五个城市的相同缩写,每个缩写后附加了 "Full",并被赋予该城市的全名:

set NashFull=Nashville
set NYCFull=New York City
set ChicFull=Chicago
set NOFull=New Orleans
set STLFull=St Louis 

现在,考虑这个带有两种延迟扩展示例的 echo 命令:

> con echo The best !food%city%! can be found only in !%city%Full!.

如果 city 被设置为 NO 并启用了延迟扩展,那么这个命令会将以下内容写入控制台:

The best Muffuletta Sandwich can be found only in New Orleans.

为了理解这一过程,让我们先看看 !food%city%! 变量。内部变量 city 和它的百分号符号被解析为 NO,从而揭示了 foodNO 变量。接着,感叹号定界符将其解析为最美味的三明治;不,这不是一份华丽的火腿芝士三明治。总结一下:

!food%city%! → !foodNO! → 穆夫尔塔三明治

同样,城市的全名也会通过两步解析。这里唯一的区别是,变量名中的硬编码部分位于要解析的部分之后:

!%city%Full! → !NOFull! → 新奥尔良

echo 命令对不同的城市值表现不同,这一点很重要。它会根据变量设置为 NYC、Nash、Chic 和 STL 时,依次向控制台输出以下四句:

The best Thin Crust Pizza can be found only in New York City.
The best Hot Chicken can be found only in Nashville.
The best Deep Dish Pizza can be found only in Chicago.
The best Frozen Custard can be found only in St Louis. 

我在这一节开始时提到过,将解析后的值作为变量名的一部分更为有用。这个例子是为了教学目的,但你可以很容易地将这种技术扩展到更实际的应用中。在专业领域中,与城市中心的美食领域不同,你可以创建一组变量,根据不同地点定义文件传输路径,例如 pathNYC、pathNash 和 pathSTL。然后,一条命令就可以利用相同的延迟扩展技术,将文件传输到多个目的地中的一个。(我将在第五章中再次使用这种技术,讨论子字符串操作。)

创意程序员可以几乎无限制地使用延迟扩展,我们将在第二十九章中探讨数组和哈希表时,深入了解一些这些应用。第二部分中的 for 命令(见第 II 部分)将大大依赖延迟扩展,或许它最有趣的应用将在第十六章中展示,在那里一个变量将能够同时保存两个值。

命令扩展

setlocal 命令也接受一个参数,用于开启命令扩展。与延迟扩展不同,命令扩展默认应处于激活状态,但你可以通过以下命令显式启用它们:

setlocal EnableExtensions

启用命令扩展解锁了大量额外的功能和可用选项,适用于多个批处理命令。例如,for 命令对于任何批处理程序员来说都是不可或缺的。我们尚未讨论它,但批处理有一种变体,当命令扩展禁用时使用。启用命令扩展后,它变成了一匹动力十足的工作马,至少有 10 种形式。即使是第二章中讨论的 set 命令(通常不被认为是动态或有趣的命令)也有了这个设置后,附加的功能和可用选项。具体功能因命令而异,你可以通过命令提示符上的 help 命令(在第二章中也有介绍)获取它们的详细信息。

为了演示通过启用命令扩展解锁的额外功能,返回命令提示符并输入上一章中的相同命令,以获取关于 set 命令的文档:

**help set**

在简短的几行文字后,解释当命令扩展未启用时命令的作用,解释器将显示以下行:

If Command Extensions are enabled SET changes as follows:

以下是所有已解锁的扩展功能。信息量太大,无法全部展示,但在这个小样本中,展示了两个先前无法使用的选项:

Two new switches have been added to the SET command:

    SET /A expression
    SET /P variable=[promptString] 

我在第二章中提到了这些选项,但没有提到命令扩展会启用它们。帮助命令在启用命令扩展时,比在禁用时提供更多的 set 命令功能信息,许多其他命令也同样如此。随着我介绍更多的命令,我鼓励你通过帮助命令进一步调查它们,以查看更多的用途和选项,并了解命令扩展开启时会启用哪些功能。

关于 setlocal 和 endlocal 的最终想法

在编写 bat 文件的二十年中,我对使用 setlocal 和 endlocal 命令有一些强烈的看法,我并不忌讳分享它们。每个我编写的高层 bat 文件,在代码的最前面或靠近最前面的位置都有这个命令:

setlocal EnableExtensions EnableDelayedExpansion

我将高层 bat 文件定义为没有从其他 bat 文件调用的 bat 文件。我很少遇到不希望启用命令扩展和延迟扩展的情况。这些额外的功能几乎没有成本。就像你可以把一辆丰田变成一辆兰博基尼,而没有像成本和油耗这样的问题。但在那种罕见的情况下,你可以通过 DisableExtensions 和 DisableDelayedExpansion 参数禁用这些功能。

此外,每当我编写可能对其他代码产生不良影响的逻辑时,我会在该逻辑前加上一个没有参数的简单 setlocal 命令,并用相应的 endlocal 命令结束它。别担心;延迟扩展仍然是从原始的 setlocal 命令中启用的。你甚至可以嵌套多个 setlocal 和 endlocal 命令,在子部分内创建具有定义范围的代码子区块,但不能超过 32 层深。我从未遇到过接近这个限制的情况,但如果你遇到,可以在被调用的例程或其他 bat 文件中进一步嵌套。(关于这些调用是如何执行的,我会在第十章中讲到。)

为了完整性起见,最好让原始的 setlocal 命令在 bat 文件的末尾有一个相应的 endlocal 命令,但如果省略,解释器会在退出高层 bat 文件前执行一个隐式的 endlocal。

重要的是,这本书是在假设启用了命令扩展和延迟扩展的前提下编写的。通常情况下,我不会让你感到无聊去了解在这些设置下解锁了哪些功能,哪些没有。如果你在测试中发现书中的示例无法正常工作,请确保你在执行该命令时使用了两个启用参数。

注意

我只有一个例外,就是关于所有高级 bat 文件都以之前提到的特定 setlocal 命令开始的规则,这个例外出现在本书中。在后面的章节中,我将提供一些非常简短的 bat 文件示例,可能只有两三行。这些简单的示例可能不需要这个命令,它的使用可能会把注意力从当前话题中分散开。在这些情况下,我不会包含该命令,但请理解,它本来可以并且应该存在。

总结

本章的主要内容是 setlocal 命令,它定义作用域并启用命令扩展。最重要的是,它启用了延迟扩展,为定义和使用变量开辟了广阔的可能性。

启用延迟扩展后,你看到如何仅通过一个命令,根据定义城市的变量值,输出五个句子中的一个。但如果禁用了延迟扩展,你可能不得不通过五个 if 命令来查询该变量。在本章中的示例中,这将是一个不优雅的解决方案,但一般来说,if 命令是任何语言中重要的工作马,Batch 也不例外。在下一章,我将详细讨论它——而且由于这是 Batch——还会谈到它的一些特性。

第四章:4 条件执行

if 命令,几乎所有编程语言中都有,是在条件为真时执行一行或多行代码,而在该条件为假时执行不同的代码块。

基本概念很简单,但在 Batch 中,条件子句,或评估为真或假的实体,与其他语言中的类似子句有很大不同。大多数比较运算符在 Batch 中是独有的,在本章中你将学习如何使用语法来确定路径或文件是否存在,以及变量是否已赋值。了解评估返回码的不同技术也很重要。

此外,你还将学习如何有效管理需要评估多个条件的情况,以及如何避免一些常见的障碍。编写一个大部分时间都能正常工作的 if 命令并不难,但在某些数据条件下,它可能会中断或无法按预期执行。

基本的 if 命令

在最基本的形式中,if 命令会在条件为真时执行一行或多行代码。我将展示如何扩展此命令,在相同条件为假时执行不同的代码,但我们先从其基本结构开始。

几乎每个 Batch 命令实现都以命令名称本身开始,通常后跟参数和/或选项。例如,set 命令总是以这三个字母开始。通常,它后面跟着一个由变量名、等号和一个值组成的参数,但在第二章中,你已经学到它也可以在没有任何参数或选项的情况下工作。(这样的命令会输出活动变量的列表。)

if 命令是独特的。它也以命令名称开始,但相似之处止于此;它可以跨越多行,并且有两个主要组件。以下是一般形式,你可以用代码替换斜体部分的文本:

if `conditional clause` (
   `true code block`
) 

条件子句是一个会计算为真或假的表达式。如果它为真,解释器会执行真代码块中的命令;如果它为假,则不会执行这些代码。在第十六章中,我会更深入地讨论代码块,但目前为止,代码块只是放在括号之间的一行或多行代码。

使用这种语法时,左括号不仅必须紧跟在条件子句后面,而且还必须位于同一行。其他语言可能允许(甚至鼓励)你将左括号放在下一行,并与右括号对齐,但在 Batch 中这是不允许的。然而,良好的格式要求右括号应与 if 命令的开头对齐,而中间的命令需要缩进。我的惯例是使用三个空格缩进,但任何数量的缩进都可以。以下是一个有效的示例:

if "%today%" equ "07/04/2026" (
   set event=sestercentennial
) 

条件语句 "%today%" equ "07/04/2026" 正在寻找一个已解析的变量和一些硬编码文本之间的相等性。这个条件语句相对简单,但我很快会展示使用不同的比较运算符、关键字甚至选项的更为复杂的语句。许多编程语言将条件语句放在括号内;无论好坏,在 Batch 中,条件语句是独立存在的,括号用来包围即将出现的代码块。

如果条件语句为真,将执行代码块中的命令(在这个例子中是 set 命令),导致 event 被设置为一个表示四分之一千年庆典的术语。

以下更加紧凑的单行格式与之前的例子功能等效:

if "%today%" equ "07/04/2026"  (set event=sestercentennial)

使用单行时不再需要括号。以下代码在功能上与前面的两个例子等效:

if "%today%" equ "07/04/2026"  set event=sestercentennial

从技术上讲,由于缺少括号,set 命令不再位于代码块中。没有一个简洁的术语,它现在仅仅是当条件语句为真时执行的命令。

在前面的两个例子中,我在条件语句后留下了两个空格,而且我通常会留下超过两个空格。从语法上讲,这并不是必须的,但由于没有明确区分条件语句和后续内容,稍作分隔可以提高可读性。

你甚至可以在单行中执行多个命令,通过 & 命令分隔符,如第二章中所讨论:

if "%today%" equ "07/04/2026"  set event=sestercentennial& set code=ugly

如果条件语句为真,将执行一个额外的 set 命令,这个命令恰好对代码进行自我审查。我偶尔见过这种技巧,通常是在设置错误代码和错误信息时使用,但在这种情况下,使用单行代码会让代码难以理解。

如果执行的逻辑中有任何稍微有趣的地方,请使用多行代码:

if "%today%" equ "07/04/2026" (
   set event=sestercentennial
   set code=elegant
) 

“优雅”在这里无疑是个过誉的说法,但我希望你同意,这种技巧使得代码更加易读。只需一眼,读者就能知道,如果条件为真,两个变量将被设置。

条件语句

前一节中的例子都使用了简单的条件语句,但该语句可以更加动态,采取许多不同的形式,并使用不同的运算符和关键字。

比较运算符

一个比较运算符,顾名思义,用于比较两个操作数是否相等,或者一个是否大于另一个。你可能已经猜到,前面例子中的 equ 运算符表示相等,没错。

这是 Batch 比较运算符的完整列表:

equ ==    相等

neq    不相等

lss    小于

leq    小于或等于

gtr    大于

geq    大于或等于

你可以选择两个功能等效的替代选项作为等号运算符。为了与用于赋值的单个等号区分开来,例如在 set 命令中,Batch 使用双等号作为比较运算符。我偏好的语法是 equ 运算符,因为它看起来与其他的类似,但有些程序员因为相反的原因更喜欢使用 == 运算符。

neq 运算符会在比较的两个操作数不相等时将条件子句评估为 true。最后四个运算符用于判断哪个操作数大于或小于另一个。例如,假设 age 被设置为一个数字值,以下单行代码块仅在变量设置为大于 12 的值时执行:

if %age% gtr 12 (
   > con echo Adult Movie Theater Ticket Required
) 

你可能会尝试将 %age% > 12 作为条件子句,但大于符号在 Batch 中已经有一个定义的用途;事实上,它在此代码块中用于向控制台写一条短消息。因此,你必须使用三个字符的字母代码 gtr 作为运算符。同样,本节列出的运算符用于大于或等于、小于以及小于或等于比较。

不太直观的是,这些运算符也适用于字母数字值。所有数字小于所有字母;a 小于 A;A 小于 b;b 小于 B;以此类推。这不会结束更大的争论,但至少在 Batch 的世界里,Picard 大于 Kirk。

条件子句关键字

你会在 if 命令的帮助文档中找到以下不可或缺的关键字,但不要搞错了;这些关键字是特定于条件子句的:

exist    exist 关键字检查路径或文件是否存在,如果找到则返回 true。你可以将路径或文件硬编码,或者为了灵活性,你可以使用包含潜在路径或文件的变量:

if exist C:\Batch\myFile.txt      set do=something
if exist %pathAndFileName%        set do=something 

你还可以将多个变量串联在一起以构建路径或文件名。

defined    使用 defined 关键字的以下条件子句检查变量是否已定义——也就是说,它是否解析为任何值,即使是一个空格?一个常见的错误是将百分号放在变量两边,但以下是使用该关键字的正确语法:

if defined varThatMayBeEmpty      set do=something 

这与以下使用百分号解析变量的代码功能等效:

if "%varThatMayBeEmpty%" neq ""  set do=something 

这个关键字常用于验证期望的输入变量。如果一个或多个变量未定义,你可以采取适当的措施,可能会启动中止操作。

not    not 关键字在条件子句的最前面时会否定整个条件子句。这在为变量设置默认值时非常有用,特别是在变量尚未被其他人或其他程序设置时。例如,下面的代码确保 skyColor 被设置为它通常的颜色:

if not defined skyColor           set skyColor=Blue 

你可以将 not 关键字与 exist 关键字结合使用,以判断特定文件是否不存在。掌握了这一点,你就可以创建文件、发起中止操作,或者根据你的应用需求做其他事情。一些程序员会将 not 关键字与 equ 运算符一起使用,但我认为这样做最多只是勉强可行,我更倾向于单独使用 neq 运算符。从逻辑上讲没有区别,但不论你偏好哪种方式,都要保持一致。

警告

经过二十年的批处理编码,我仍然会不自觉地在 exist 关键字末尾加上 s,尽管我不太愿意承认这一点。Notepad++ 每次都会忠实地提醒我,因为它会加粗关键字;那个多余的字符会让整个单词失去加粗效果,从而使它显得突出。没有这样的编辑器,自己写代码要小心。

不区分大小写选项

if 命令有一个选项,就像我们之前看到的关键字一样,它适用于条件语句。/i 选项使得条件语句中的相等(和不等)运算符不区分大小写。

举个例子,下面的 if 命令中,如果没有选项,条件语句只有在 myMood 完全等于 happy 时才会返回 true——这是不等式右侧的硬编码值:

if "%myMood%" equ "happy"         set do=something

下面是添加了 /i 选项的相同代码:

if /i "%myMood%" equ "happy"      set do=something

现在,如果变量的值为 HAPPY、Happy、happy 或该词大小写变化的其他 29 种可能组合时,条件语句就会返回 true。

注意

/i 选项可能看起来与我之前提到的其他选项以及未来提到的选项有点不同。如前所述,尽管解释器不区分大小写,我在写批处理命令时总是使用小写字母。选项也不受大小写影响。尽管如此,由于选项通常只是一个斜杠后跟一个字符,我通常会将其大写以突出显示。但根据字体的不同,大写字母 I 经常看起来像小写字母 L,因此我在使用 /i 选项时会偏离我的个人习惯,这个选项最常与 if 命令一起使用。是的,我知道这个选项与不区分大小写有关,讽刺的是这一点我并不忽视。

errorlevel 变量

在调用可执行文件或执行多个批处理命令后,返回代码会存储在 errorlevel 伪环境变量中。(第一章中复制文件的命令就是一个会设置此变量的例子。)你将在第二十一章中了解更多关于伪环境变量的内容,但现在,先把 errorlevel 看作是一个包含返回代码的变量,你不应该使用 set 命令设置它。(如果你这样做,就会破坏 errorlevel 变量。)errorlevel 变量可以像其他变量一样在 Batch 中通过 if 命令进行评估。例如,下面的命令将返回代码为 1 或更大的情况视为失败:

if %errorlevel% geq 1    set msg=FAILURE

Batch 还支持一种古老的语法,仅适用于这个特殊变量,其中百分号符号和等号运算符被省略。以下代码与前面的示例功能等效:

if errorlevel 1          set msg=FAILURE

起初,这看起来可能简化且吸引人,因为省略了内容而没有新增任何内容,但语法掩盖了一个令人惊讶的陷阱。许多 Batch 编程人员误将这个条件语句理解为检查返回码是否等于 1。毕竟,用返回码 0 测试时,确实返回 false,用返回码 1 测试时,确实返回 true。但条件语句 errorlevel 1 等同于 %errorlevel% geq 1%errorlevel% gtr 0。它对所有正整数返回 true。

将这种语法与 not 关键字结合使用,效果将更加晦涩:

if not errorlevel 0      set msg=The Return Code is NEGATIVE

这看起来像是一个在返回码不等于 0 时评估为 true 的条件语句吗?实际上它是返回码大于或等于 0 的否定。%errorlevel% lss 0 条件语句功能等效,且可读性更强。

缺乏比较运算符的语法的另一个问题是,通常情况下,0 代表良好的返回码,而其他所有值,包括负值,都表示某种问题。

neq 运算符使得这个条件语句对所有非零值返回 true:

if %errorlevel% neq 0    set msg=FAILURE

你可能会遇到这种晦涩的语法,因此理解其工作原理很重要,但更重要的是不要传播它。始终使用百分号符号(或感叹号)和比较运算符来评估 errorlevel 伪环境变量。

if...else 结构

编程语言中的一条不成文规则是,if 命令必须配有 else 关键字。前面提到的关键字与条件语句相关,但这个关键字则与 if 命令本身紧密相连。下面是 if...else 结构的一般形式,再次提醒,斜体文本必须替换为代码:

if `conditional clause` (
   `true code block`
) else (
   `false code block`
) 

前两行和第三行开头的右括号与我在本章开头展示的通用形式相同。接下来是 else 关键字,紧随其后的是 false 代码块,用第二对括号括起来。这表示当条件语句评估为 false 时执行的代码。

下面是一个简单的 if...else 结构示例:

if %fahrenheit% gtr 70 (
   set pants=shorts
) else (
   set pants=jeans
) 

如果 fahrenheit 变量大于 70,则 pants 变量设置为 shorts。否则,pants 变量设置为 jeans。总有一个代码块会被执行。

你可以将此结构压缩成一行代码:

if %fahrenheit% gtr 70 (set pants=shorts) else (set pants=jeans)

包围 false 代码块的括号在技术上是可选的,但为了可读性,建议包含括号。

除非你对阅读你代码的人不屑一顾,否则单行的 if...else 结构通常不是一个好习惯,虽然对于最简单的任务你可能会例外。

与其他语言不同,在 Batch 中,else 关键字不能单独写在一行上;它甚至不能是行的开头或结尾。为了清晰地标记这两个代码块,最好将关键字夹在一对闭合和打开的括号之间,写在同一行。

else if 构造

if...else 构造非常适合逻辑流程中只有两个分支的情况,一个用于 true,另一个用于 false。当分支超过两个时,else if 构造允许多个条件语句。清单 4-1 有三个语句和四个分支,每个条件语句对应一个分支,默认分支则在没有任何条件语句评估为 true 时执行。

if %fahrenheit% gtr 80 (
   set pants=shorts
) else if %fahrenheit% gtr 60 (
   set pants=light khakis
) else if %fahrenheit% gtr 32 (
   set pants=jeans
) else (
   set pants=lined jeans
) 

清单 4-1:具有四个逻辑分支的 else if 构造

该逻辑假设 fahrenheit 被设置为描述温度的整数。如果大于 80 度,第一个 set 命令将执行。如果大于 60 度,也就是说在 61 到 80 度之间(包括 80),第二个 set 命令将执行。如果前两个条件语句为 false 且温度高于冰点,第三个 set 命令将执行。如果所有三个条件语句为 false,则温度为 32 度或更低,第四个也是最后一个 set 命令会分配一条非常暖和的裤子。

在第一个 else 关键字后面没有紧跟着左括号。相反,它后面跟着另一个 if 命令,并带有自己的条件语句,然后才是左括号。

清单 4-1 包含两个 else if 语句,但根据需要你可以编写任意数量的条件语句。解释器会执行第一个评估为 true 的条件语句对应的代码块;之后,控制跳转到整个结构的末尾,不会继续评估其他语句。

很多时候,如果没有任何条件语句为 true,你可能需要执行一个最终的代码块,也就是默认的代码块。例如,如果清单 4-1 未能设置某个变量,那么有人可能会在没有穿适当衣物的情况下离开家。最后的 else 关键字后面没有 if 命令,因此它的代码块(默认代码块)会在前面三个条件语句都未评估为 true 时执行。

在清单 4-1 中,fahrenheit 被用来判断它落在哪四个范围中的哪个范围。这是使用 else if 条件语句的常见方式,但它们并不一定要紧密关联。每个条件语句可以检查完全不同的变量,或者使用之前提到的三个关键字。例如,下面是清单 4-1 的重新构想,只有三个条件语句发生了变化:

if /i "%season%" equ "Summer" (
   set pants=shorts
) else if exist C:\Batch\Spring.txt (
   set pants=light khakis
) else if %celsius% gtr 0 (
   set pants=jeans
) else (
   set pants=lined jeans
) 

第一个条件子句执行不区分大小写的相等比较,比较已解析的变量与硬编码文本。第二个子句是检查文件是否存在,第三个子句是检查 celsius 变量的值是否高于冰点。再次由于默认的代码块,这段代码保证将变量设置为四个值之一。

增强的相等性判定技巧

如果我不提到与这些条件子句相关的一个重要警告,我将感到遗憾。在本章的一些示例中,我将相等式两侧都用双引号括起来,但即使没有它们,命令通常仍然会工作。以下两个 if 命令非常相似,但功能上不等价:

if /i "%myMood%" equ "happy"      set do=something
if /i %myMood% equ happy          set do=something 

如果 myMood 设置为 happy,则子句评估为 true;如果设置为 sad,结果为 false。无论哪种情况,它对这两个命令都有效。

这很棒,但现在假设变量没有设置,或者它被设置为 null 或某些空格。没有双引号的命令会崩溃,但它通过以下含糊的消息提示了问题(假设你没有使用 第二章 中提到的 echo off 命令):

happy was unexpected at this time.

C:\Batch>  if /i  equ happy          set do=something 

这里的第一行是错误消息,接下来是困惑解释器的内容。要理解这个错误,我们必须像解释器一样思考。一旦它看到 if 命令开始这一行,它期望接下来是有限列表中的某个项(可能是 not、exist、defined 或 /i),任何无法识别的内容都会被假定为条件语句的左侧部分。显然,它找到了 /i。假设接下来没有出现三个关键字之一,解释器现在期望的是三个特定顺序的项目:一些文本;一个运算符,如 equ、neq 或 ==;以及更多文本。如果 myMood 的值几乎是任何东西,它将被解析为第一个文本字段。解释器接着会很高兴地找到 equ 运算符,并且知道它正在处理一个相等式,它会将硬编码的 happy 解释为这个相等式的右侧。成功

当变量解析为空或任何数量的空格时,所有的逻辑就会崩溃。解释器看到 if /i 开始语句,因此它不期望接下来看到 equ。not 关键字本来是合理的,但不是 equ。因此,它错误地认为 equ 是可能的相等式的左侧,而接下来的内容应该是运算符。但接下来是 happy 文本,而运算符列表显然不包含这个单词。正如消息所述,解释器此时不期望看到 happy。失败

幸运的是,有两种评估可能解析为空的变量的方法。

前导点技巧

解决这个问题的一种常见技巧是,在等式的每一边加上一个点符号,或者几乎任何字符,只要它一致应用,这样解释器就一定能在等式两边找到内容:

if /i .%myMood% equ .happy         set do=something

前导点技巧在变量设置为空时效果很好,因为命令会解析为 if /i . equ .happy。点符号不等于点符号加上单词,所以它被评估为 false,然后我们继续。如果变量被设置为 happy,命令将解析为 if /i .happy equ .happy,并且会找到相等性。成功

但我不太喜欢这种技巧,因为它容易受到另一个陷阱的影响。现在假设变量被设置为一个由两个单词组成的情绪,比如“恼怒的沮丧”——从多个方面来看,这与 Batch 代码无关,实在不好处理。再次出现问题:

depressed was unexpected at this time.
C:\Batch>  if /i .irritably depressed equ .happy            set do=something 

别灰心。解释器被嵌入的空格骗了。别告诉别人,但它真的没有那么聪明。它认为.irritably 是子句的左边,并将 depressed 视为完全意外的运算符。失败。但是,还有另一种技巧。

双引号技巧

回到最初的例子,这将我们带回到在等式两边加上双引号的情况:

if /i "%myMood%" equ "happy"      set do=something

这里的双引号与前导点提供的内容类似,但并不仅仅是如此。

解释器将双引号内的所有内容视为一个整体。当带有嵌入空格的变量被解析时,解释器会看到如下内容:

if /i "irritably depressed" equ "happy"

尽管有嵌入空格,解释器将“irritably depressed”视为一个整体,或者在这种情况下视为等式的左侧,右侧为“happy”。结果是 Batch 正确地识别出这两个实体是不相等的。成功

如果被查询的字母数字变量可能未设置,或者可能包含嵌入的空格,我几乎总是会在等式的每一边加上双引号。然而,你可能注意到我在条件子句中没有加上双引号来包围%errorlevel%。该变量总是被设置为一个数字,因此不需要引号。更重要的是,当解释器看到没有引号的数字进行比较时,它会做数值比较,意味着 000 等于 0。加上引号则会进行文本比较,“000”就不等于“0”了。

前导点与双引号

在比较字母数字值时,我通常会在等式的每一边加上双引号。当一个值为空时有效;当一个值是一个或多个空格时有效;当一个值中有嵌入的空格时有效;当一个值是更典型的非空格值时也有效。然而,我之所以使用非定语修饰词通常,是因为有一个非常细微的点。

假设有一个变量包含一个带有尾随空格的值。也许值 sad 是通过在尾部添加空格来填充的,变成了四个字符的值。这与三个字符的值 sad 相等吗?从最纯粹和最准确的角度来看,不,它们是不相等的——使用双引号方法正确地可以发现它们是不同的。但在一个不那么严格的场景下,你可能会认为这些值是相等的。

使用点号方法会找到两个值相等,因为尾随空格只是成为了等号左侧与操作符之间的一个普通空格。在这种尾随空格的狭窄情况中,点号方法更好,但它只在变量没有嵌入空格的情况下才有效。

最终分析,双引号技巧远胜于点号方法,虽然在某些特定情况下它不是。养成几乎在所有非数字比较中使用双引号的习惯。

注意

Batch 提供了一个有趣且紧凑的替代方案来代替if命令,尽管它的行为有所不同。它不是一个命令,没有任何关键字,甚至不支持条件语句。在第二十八章中,我将回到条件执行的话题,并提供更多关于它是什么的信息,而不是它不是什么。

总结

if命令在几乎所有,甚至是所有,编程语言中都是必不可少的,Batch 也不例外。在本章中,你了解了条件语句,包括其比较两个操作数的有效运算符,以及用于验证变量、路径或文件是否存在的关键字,以及当条件为真或假时会发生什么。你还学习了如何评估多个条件语句,以便有条件地执行多个逻辑分支。

如常见情况,Batch 给了你更多需要考虑的内容,因此我详细介绍了增强条件语句的有用技巧,比较了字母数字值和数字值。但是,什么样的值算作字母数字值或数字值呢?我将在接下来的两章中讨论数据类型,来回答这个问题。

第五章:5 字符串和布尔数据类型

在学习 Batch 数据类型赋值的第一件事就是,Batch 不允许赋予数据类型。这里并没有什么平等主义的底层思想,但所有 Batch 变量都是平等的。从本质上讲,保存数字、文本甚至布尔值的变量没有任何区别。然而,设置为数字的变量可以被视为数值类型,接下来我将集中讲解这些数据类型。

在本章中,在概述所有 Batch 数据类型之后,你将学习字符串和字符变量。你还将进一步探索字符串,了解如何进行子字符串提取和文本替换。布尔值并非 Batch 的创建者设计的,但我将教你如何构建和使用这个有用的数据类型。

常见数据类型

许多(如果不是大多数的话)编程语言不仅允许,而且要求在赋值或以任何方式使用变量之前,必须先声明其数据类型。不同语言之间有所不同,但这里有一个一般的数据类型列表:

字符    单个字母数字字符

字符串    零个或多个字母数字字符

整数    正数和负整数

浮点数    具有小数点的数字

布尔值    真或假

无论好坏,Batch 变量并没有显式声明。变量名第一次被解释器发现时,它就会凭空产生。这种做法确实提供了很大的灵活性,但也可能很棘手且危险。解释器会认为变量名拼写错误的实例是一个完全不同的变量,且编译器并不会捕捉到这个错误。相反,它会被当作一个新的变量,这个变量可能最终什么也没有。

变量可以被赋予一个整数,并且可以对其进行算术运算。这个变量之后可以被赋值为文本,并像字符串一样处理。这也意味着,包含字符串的变量可能不小心进行算术运算,但从好的一面看,数字可以轻松地当作字符串处理,在控制台或报告中无需任何转换。这是纯粹的数字混乱,是为虚无主义者设计的编程语言,不知为何它居然能工作。

即使你不能直接赋予数据类型,你仍然可以创建变量,并将其当作某种类型来使用,但——我必须强调这一点——每个 Batch 变量的底层结构实际上只是几字节的内存,没有明确的类型区分。

字符

字符仅仅是单个字节的文本;在 Batch 世界中,可以把它看作是一个非常短的字符串,因为它和任何其他单字符字符串的处理方式完全相同。我将简短讲解这一部分,并继续介绍字符串。

字符串

字符串是任意长度的文本,包含字母字符、数字和/或特殊字符。以下命令将 aString 变量设置为一个包含五个单词的字符串:

set aString=Awesome Batch Code Dares Excellence

包括嵌入的空格,其长度总计为 35 个字符,或者用程序员的话来说,是 35 个字节。

许多特殊字符,如美元符号和英镑符号,可以明确地包含在字符串中,但其他字符,如百分号符号,则不行,因为它们在 Batch 中有特定的用途。在第十四章中,我将讲解如何通过转义实现将所有字符包含在字符串中的方法,但现在要理解的是,当解释器在字符串中遇到感叹号时,它不会中止执行,但你可能不会看到预期的结果。例如,赋值给这个变量的值中的最后一个字符是一个感叹号:

set aString=Awesome Batch Code Dares Excellence!
> con echo A String is "%aString%" 

这是 echo 命令的结果:

A String is "Awesome Batch Code Dares Excellence"

标点符号没有写入控制台,因为它没有包含在字符串变量中。

注意

如第三章中所述,我假设本书中启用了延迟扩展。这个例子很好地说明了这一点,因为如果延迟扩展被禁用,感叹号将仅仅是另一个字符,而不是用来解析变量的分隔符。这个字符会作为值的一部分被包含在内,并与其余文本一起写入控制台。能够将感叹号视为普通文本,可能是禁用延迟扩展的唯一优势。这个微不足道的优势与延迟扩展所提供的功能相比显得微不足道,这也是我推荐全书都使用它的原因。

在后续章节中,我会讨论如何将字符串和其他数据类型写入文件,但在这里我将解释如何构建、连接、提取子串和操作字符串。

构建和连接

上一个例子使用单个 set 命令将值 Awesome Batch Code Dares Excellence 赋给一个变量。以下六行执行相同的任务:

set a=Awesome
set b=Batch &
set c=Code
set d=Dares
set e= Excellence
set aString=%a% %b%%c% %d%%e% 

实际上,这种方法在构建字符串时效率极低,但它很好地展示了连接的原理。

由字母表前五个字母定义的变量每个都被设置为一个单词。然后在最后一行,所有五个变量都被解析并连接起来创建一个 aString。注意结果中嵌入的四个空格:一个是来自 Batch 后面的空格,另一个是来自 Excellence 前面的空格,其他两个则是嵌入在最后的 set 命令中。

上一个例子展示了如何通过连接其他字符串来创建一个字符串,但你也可以通过附加或预置其他文本来扩展现有字符串:

set longText=This field contains a brutal run-on sentence and if its prose 
set longText=%longText% were to be typed into a single line the reader would
set longText=%longText% be forced to scroll way over to the right to read what
set longText=%longText% you are reading now and then scroll way back to the
set longText=%longText% left after mercifully getting to this period. 

这里一个字符串被四次附加额外文本,从而创建了一个非常长的字符串。

这种方法是我创建长字符串变量的偏好方式,但你也可以使用“续行符”或插入符号(^)完成相同的任务。当解释器遇到行尾的插入符号时,它会将下一行附加到该行:

 set longText=This field contains a brutal run-on sentence and if its prose ^
were to be typed into a single line the reader would be forced to scroll way^
 over to the right to read what you are reading now and then scroll way back ^
to the left after mercifully getting to this period. 

在这个例子中,使用了三个插入符号来制作一个四行的 set 命令。第一行和第三行前面有一个空格,而它们的后续行从第一字节开始,这导致了单词之间的空格。为了展示一种不同的实现方式,第二个插入符号紧跟在单词 way 后面,接下来的行在下一个单词 over 前有一个空格。最终结果是一个由空格分隔的长字符串。

我不太喜欢这种技术,原因很简单,它会破坏我的缩进规则。我通常将大部分命令缩进两个或更多空格,正如 set 命令的第一行所示,但任何后续行开头的空格都被视为附加文本的一部分。这实际上意味着这些行必须左对齐。我会在第九章中深入讨论缩进规则。现在只需理解它是有效的——但不美观。

注意

我把“续行符”加上引号是因为这是一个过度简化。插入符号实际上是一个转义字符。在第十四章中,我会解释为什么这很重要,但许多批处理程序员通常称其为续行符

子字符串

任何值得一提的语言都会支持一种子字符串函数,能够提取字符串的一部分,而 Batch 也不例外。在接下来的几个例子中,我们假设 aString 变量已经像之前那样设置好了:

set aString=Awesome Batch Code Dares Excellence

一个子字符串函数需要两个数字,即偏移量或起始位置和所需文本的长度。令人惊讶的是,Batch 使用的是更现代语言中常见的零偏移量,而不是 20 世纪语言中更常见的 1 偏移量。这意味着第一个字节的位置是 0(而不是 1),第二个字节的位置是 1,第 100 个字节的位置是 99,以此类推。

子字符串的语法有点笨重。变量使用百分号进行解析,这是常见的做法,但闭合的百分号前面会加上冒号、波浪号、偏移量、逗号,最终是长度。因此,下面的语法会返回 aString 变量的前三个字符:

set subString=%aString:~0,3%

偏移量为 0 告诉解释器从第一个字节开始,长度定义为 3,最终将文本 Awe 赋值给 subString。

以下代码从同一字符串的第一个单词中提取文本 some:

set subString=%aString:~3,4%

我们需要从第 4 个字节开始,这就是零偏移量 3。如果你觉得零偏移量有些混乱,可以将偏移量理解为子字符串之前的字节数。更明显的是,长度是 4。

这里有两个子字符串与硬编码的 "to" 和几个空格一起拼接:

set phrase=%aString:~15,3% to %aString:~8,5%

第 15 个字节是 Code 中的字母 C,因此第一个子字符串是该单词的剩余三个字节。第 8 个字节是 Batch 前的空格,因此接下来的五个字节包含整个单词。结果是一个恰当的,虽然不那么高深的,重新诠释原始字符串:ode to Batch。

如果没有定义长度,解释器将返回字符串的其余部分。为了演示,以下子字符串没有长度,也没有前导逗号。偏移量对应于 35 字节变量中倒数第一个单词前面的 25 个字节:

set subString=%aString:~25%

结果是,subString 被赋值为字符串“Excellence”,即原始字符串的最后 10 个字节。

负偏移量

注意以下示例中的负偏移量。有趣的是,这也将“Excellence”赋值给变量:

set subString=%aString:~-10%

一个负偏移量表示起始位置相对于字符串的结尾,而不是开头,这意味着-10 告诉解释器子字符串应从字符串末尾起 10 个字节的位置开始。由于没有给定长度,它将返回文本的其余部分。只要变量已填充,%aString:~-1%是检查其最后一个字节的简便方法。

这两个命令都会返回相同的子字符串:

set subString=%aString:~15,3%
set subString=%aString:~-20,3% 

第一个命令的偏移量是原始字符串起始位置的 15 个字节,而第二个命令通过从 35 字节的变量末尾起始的 20 个字节来找到相同的位置。

负长度

负长度的工作方式类似。不要把它看作是一个长度;把它看作是字符串末尾包含在子字符串中的字节数。例如,以下命令返回一个去掉首尾字节的字符串:

set subString=%aString:~1,-1%

你甚至可以将负偏移量与负长度一起使用。以下命令提取字符串的倒数第二个字节:

set subString=%aString:~-2,-1%

偏移量-2 告诉解释器从倒数第二个字节开始,长度-1 表示删除最后一个字节。

实际中的子字符串

批处理中的一个不错的特性是,如果请求的子字符串超出了字符串的长度,解释器会返回 null,而不会崩溃。因此,当遇到 35 字节字符串的命令%aString:~99,1%时,解释器不会崩溃,也不会返回空格。它只会返回一个空字符串。这是确定字符串长度的一种方便方法,避免了编译代码中常见的 null 指针异常。如果第 36 个字节为空(即"%aString:~35,1%"为""),但第 35 个字节已填充,则字符串的长度正好为 35 个字节。

然而,这种语法仅在截取已填充的字符串时有效。正如我刚才提到的,当字符串长度在 1 到 35 字节之间时,%aString:~35,1%的解析结果为 null;当然,如果字符串长度为 36 字节或更长,它会解析为第 36 个字节。但如果字符串为空或设置为 null,%aString:35,1%会解析为35,1,或冒号和后续定界符之间的所有内容。同样,由于这个警告,当尝试检查空字符串的最后一个字节时,%aString:-1%会解析为-1,而不是你可能期望的 null。

现在你已经知道如何从另一个字符串中提取字符串的任何部分,但之前的例子中,所有的偏移量和长度都是硬编码的。通常情况下,甚至大多数情况下,这两个数字会是变量。在以下示例中,偏移量和长度被定义为显而易见的命名变量,并在第三个命令中使用:

set offset=15
set length=3
set subString=!aString:~%offset%,%length%! 

包围偏移量和长度的百分号首先会解析这些变量的数值。然后,感叹号发挥作用,使得 !aString:~15,3! 解析为我们熟悉的 ode,这是启用延迟扩展的又一次胜利。

完成下一章后,我将在其中讨论算术运算,你将能够计算持有整数值的变量,用作偏移量和长度来查找子字符串。

文本替换

Batch 还有一个便捷的机制,可以将字符串的全部或部分替换为其他文本。例如,假设以下变量包含这个不太合适的文件名:

set filNm=File_Name_With_Underscores.docx

如果你不喜欢这个文件名,你可以将下划线替换为短横线。在第七章中,我将介绍用于重命名文件的理想命令,但在这里我将讨论如何构建包含新文件名的变量。

文本替换语法类似于用于子字符串提取时的语法。变量和冒号仍然被百分号包围,但现在没有波浪号。冒号后面是要查找并更改的文本,接着是等号分隔符,最后是替换文本:

set newFilNm=%filNm:_=-%

每一个下划线字符(_),而不仅仅是遇到的第一个下划线,都会被替换为短横线(-),从而得到 File-Name-With-Underscores.docx。小心不要更改超过预期的文本。

看着这个文件名,也可以考虑将 Underscores 替换为 Dashes。幸运的是,Batch 不要求目标文本和替换文本的长度相同,因此这个附加命令进一步将变量的值更新为 File-Name-With-Dashes.docx:

set newFilNm=%newFilNm:underscor=Dash%

由于这两个单词都以 es 结尾,我使用单数形式的 Dash 作为替换文本,而目标文本是 underscor,后者甚至不是一个真实的单词。此外,注意到在变量的值中 Underscores 是大写的,而在替换语法中,underscor 是小写的。非常重要的一点是,Batch 执行的是不区分大小写的替换。目标文本的大小写可以是任意的,甚至是混合大小写,这对结果没有影响,但替换文本会按原样使用。因此,%newFilNm:UNDERscor=Dash% 与之前命令中的变量解析功能完全相同,但 %newFilNm:underscor=DASH% 将会导致文件名变为 File-Name-With-DASHes.docx。

这很微妙,但前两个命令展示了两种不同的赋值方法。第一个命令将修改后的 filNm 值赋给 newFilNm,而不改变 filNm。第二个命令将 newFilNm 重新赋值给它自己,以便其最终值反映两个文本替换。这两种方法为你提供了灵活性,可以选择直接在变量内修改值,或者保持两个变量,一个保存旧文本,一个保存新文本。

你还可以使用延迟扩展将目标文本 targ 和替换文本 repl 转换为变量。这里有一个例子:

set targ=Love
set repl=Hate
set aString=I Love Broccoli
set aString=!aString:%targ%=%repl%! 

结果是更诚实的字符串 "I Hate Broccoli"。

文本搜索是文本替换语法的一个绝妙应用。在第二十四章中,我将对比两种判断一个字符串是否为另一个字符串一部分的方法。findstr 命令效果不错,但基于前述语法的方法执行速度要快得多。剧透:文本搜索逻辑会将搜索到的文本替换为空,然后将结果与原始文本进行比较。如果它们不同,说明文本被找到了。

布尔值

布尔值在编译语言中无处不在,它们只有两个状态:true 或 false。一旦设置,你可以单独使用它们作为 if 命令中的条件语句,评估为真或假,从而决定是否执行一段代码。Batch 并不明确支持布尔值,但通过一点巧妙的设计,你可以创建布尔值。

许多篇幅已被用来探讨“上帝是否存在?”这个问题。这不是那类书籍,但我们可以回答一个更简单的问题:“God.txt 是否存在?”在第四章中,我展示了如何使用 if 命令来判断一个文本文件是否存在:

if exist C:\Batch\God.txt (
   set god=Found
) else (
   set god=NotFound
) 

一个变量根据某一时刻文件的状态被设置为 FoundNotFound。然后,可以在未来询问 god 变量,以确定 God.txt 是否在那个较早的时刻存在。它能工作,但有点笨重;布尔值将提供更优雅的解决方案。然后,你可以根据需要在代码中多次引用这个布尔值,甚至可能重置它。

设置和评估布尔值

在 Batch 中,布尔值像所有变量一样,本质上只是一些文本,但这些文本可以被评估为真或假。按照惯例,我总是将布尔变量名以小写字母 b 开头,后跟大写字母,以便使其作为布尔值脱颖而出。(一个更冗长且描述性的选项是以 bool 文字开头。)让我们复制之前示例中的逻辑,唯一的区别是将笨重的变量 god 替换为布尔值 bGod,如果找到 God.txt 则将其设置为 true,否则为 false:

if exist C:\Batch\God.txt (
   set bGod=true==true
) else (
   set bGod=false==x
) 

在其他编程语言中,布尔值通常显式设置为 true 或 false。例如,一个有效的 Java 命令是 bGod = true;。但是,Batch 布尔值的前述设置命令看起来有点不同;特别是每个命令都有三个等号。第一个等号仅用于赋值;另外两个则是赋值的一部分。当 if 命令的条件语句为真时,我们将 bGod 设置为 truetrue;如果不是,则值为 falsex。这看起来确实有些奇怪,但现在该变量虽然技术上仍然只是文本,但可以像这样被评估为另一个 if 命令的条件语句:

if %bGod%  > con echo Let us pray.

那么怎么做呢?如果 bGod 被设置为我们认为的 true,解释器将 %bGod% 解析为 if true == true。该变量包含一个等号操作符,两个相等的等号,并且两边的值是相同的。(别问操作符周围的空格,这就是解释器看到的。)将所有这些放在 if 命令后面,它将被评估为真。

然而,如果该变量被设置为我们认为的 false,那么命令将被解析为 if false == x,它比较两个明显不同的值,导致 if 命令后面的代码不被执行。

带有布尔值的 if 命令还可以与 not 子句一起使用:

if not %bGod%  > con echo Live every day to the fullest.

如果文本 if not %bGod% 被解析为 if not true == true,那么评估结果是 not true 或 false。但当文本解析为双重否定 if not false == x 时,它将评估为 not false 或 true,并且文本将被写入控制台。

布尔值转换为字符串

我选择 truetrue 作为 true 的值,但 xx 或 0 == 0 也能工作,并且需要更少的击键。即使是 falsefalse 也会评估为 true,但我们不必这样做。同样,falsex 本来可以包含任何两个不同的字符串,但我选择了这些值,使得布尔值的文本 true 或 false 始终处于前沿。布尔变量的结构使你能够模仿编译代码中的另一个布尔特性——将布尔值转换为字符串。

如前所述,你可以通过简单地去掉等号后面的所有内容,将 Batch 布尔值转换为字符串 true 或 false。当我们在 第十九章中讲到 for 命令时,我将展示具体是如何实现的,但现在,下面的代码行可以截断多余的文本:

for /F "delims==" %%b in ("%bGod%") do  set bStrGod=%%b

在此执行之后,针对有效的布尔值,名为 bStrGod 的 布尔字符串 变量将包含 true 或 false。

(如果布尔变量以 b 开头,那么将布尔字符串变量以 bs 开头可能是有意义的,但我选择的惯例避免了人们对我的代码充满 BS 的指责。)

总结

字符串在 Batch 中无处不在,在本章中,我详细讲解了如何构建和连接字符串。子字符串提取和文本替换是两个强大而有用的工具,尽管它们的语法较为深奥,但所有 Batch 编程人员都应该掌握。布尔值虽然不那么常见,但我希望我已经展示了这一不常用数据类型的实用性。

在下一章,我将继续讨论数据类型,深入探讨数字数据类型。我将详细介绍三种不同进制的整数和浮点数,为探索 Batch 中如何处理算术运算提供一个很好的机会。

第六章:6 整数与浮点数据类型

在第五章中,我详细介绍了字符串和布尔数据类型。在这一章中,我将转向数值数据类型,特别是整数和浮点数据类型,并对它们进行深入研究。批处理能够轻松处理整数,无论是十进制、十六进制还是八进制变体。

然而,浮点数和布尔值类似,因为批处理实际上并不显式支持它们作为数据类型。但再一次,这个限制为富有创意的批处理程序员提供了发挥想象力的机会,这正是我们在本章结束之前将要做的。

八进制案例研究

8 月 1 日,某个“零零年代”的年份:我记不清确切的年份,但对于月份和日期我是非常确定的,原因到本章结束时就会明了。

我当时还相对较新于批处理,但我知道的比许多人都多,所以一个同事找到了我,帮忙处理他一直在挣扎的任务。在这段批处理代码中,他需要根据当前日期来确定前一天的日期。对于大部分日期来说,这个任务相当简单,但当今天是月初时,情况就变得复杂了。因为每个月的天数不同;新年的第一天是一个独特的挑战;闰年每四年发生一次,除了不发生的情况。

这个初步事件发生在 2 月,可能是 3 月,这是一个有趣的小练习,我写了代码并进行了测试。像所有优秀的程序员一样,我测试了每年第一天和最后一天。我还测试了几个极端月份的第一天,特别是像一月和十二月这样的月份。我测试了不同年份的 3 月 1 日,不是因为我是在 2 月编写这段代码,而是因为闰年的特殊性。不久后,我将代码交了出去,转向了其他项目。

代码在大约六个月内运行得很好。然后在 8 月 1 日,它突然就不工作了。我不记得后续的结果是什么,但我的同事花了大量时间追踪根本原因。他最终锁定了我的批处理文件,但无法弄清楚为什么它在那天停止工作。他的老板根本不听这些解释——代码能正常运行半年,然后突然崩溃。我的同事一定做了某种更改,导致了这个问题,他被挑战去找到这个更改。

这次搜索最终浪费了他半天的工作时间,但经过大量细致的工作,他终于将问题带给了我。我打开执行日志,找到了试图查找 08/01 之前日期的逻辑结果,然后...

我抬头望向天空,举起双手,带着沙特纳式的戏剧化大声喊道:“八进制!”我有些夸张——那一刻并没有像《星际迷航 II:可汗的愤怒》中可汗将柯克舰长(由威廉·沙特纳以莎士比亚式的风格演绎)困在一颗死去的星球中心那样戏剧化,但至少对我来说那一刻是相当难忘的。

执行日志中到底有什么让我不高兴的地方?让我们来看看,但在深入研究八进制之前,我将从整数开始。

整数

我们已经使用 set 命令处理字母数字值,但它也可以通过/A 选项用于算术运算。回想一下,像这样的语句会发生什么:

set x=4+5

x 所表示的变量被设置为文本 4+5。

使用/A 选项会将其转换为算术设置命令,因此以下命令将 x 变量设置为数字 9:

set /A x=4+5

/A 选项将 set 命令转化为执行加法和其他算术运算的工具。那些先前的值显然是硬编码为数字的。

一个稍微有趣一点的例子是将变量设置为数字值,然后通过 set /A 命令将它们相加,如列表 6-1 所示。

set nbr1=4
set nbr2=5
set /A sum = nbr1 + nbr2
> con echo The sum is %sum%. 

列表 6-1:通过 set /A 命令添加两个数字变量

控制台输出为“和是 9”,并且列表 6-1 展示了/A 选项显著改变了 set 命令——三次。首先也是最明显的,解锁了算术。第二,等号两边有空格,而在第二章中,我曾经大篇幅讲过空格带来的危险。为了说明这一点,这个没有使用/A 选项的命令:

set myVar = X

并没有将 myVar 设置为 X,而是将一个六字符的变量 myVar(后面跟着空格)设置为一个由空格和 X 组成的两字符值。相比之下,/A 选项使得 set 命令更像现代编程语言中的赋值操作符,因为命令中的空格不会被视为变量名或值的一部分;令人耳目一新的是,它们只是空格。

这三个命令在功能上是等价的;每个命令都将 myVar 设置为 7:

set myVar=7
set /A myVar=7
set /A myVar = 7 

若不使用/A 选项,等号两边不能有空格。然而,使用/A 选项时,可以有空格,但空格不是必需的,这就是/A 选项解锁的第二个重要区别。

列表 6-1 中的第三个区别在于变量 nbr1 和 nbr2 没有被百分号包围。因此,/A 选项允许你在没有常见分隔符的情况下解析变量。为了灵活性考虑,你仍然可以使用百分号和嵌入空格,或者不使用,所以这四个语句在逻辑上是等价的:

set /A result = nbr1 + nbr2
set /A result = %nbr1% + %nbr2%
set /A result=nbr1+nbr2
set /A result=%nbr1%+%nbr2% 

空格使代码更加易读,因此我不建议使用之前代码中的最后两个选项。第一个选项最简洁,但有些人已经习惯了变量周围有百分号,第二个选项或许能提供一种让人感到安稳的一致性。

让我们再来看一遍列表 6-1 中的 set /A 命令,不过这次它会在批处理文件的最开始执行:

set /A sum = nbr1 + nbr2
> con echo The sum is %sum%. 

控制台输出的求和结果将是 0。由于 nbr1 和 nbr2 尚未定义,未设置的变量在数值上下文中默认视为 0,而与此不同的是,在字母数字上下文中未设置的变量默认视为 null。由于两者都未设置,算术 0 + 0 的结果为 0。

警告

允许的整数范围包括从-2,147,483,648 到 2,147,483,647(包含这两个值)。批量算术将数字存储为 32 位带符号字段,因此任何整数将属于这些 2**³² 个值。这通常不会导致问题,但由于代码不是编译型的,需要注意确保正在处理的数据符合这一限制。代码不会中止,也不会挂起;它只是无法计算出正确的值。批量算术不是宏观经济学的首选语言。

批量算术

批量算术不仅仅是简单的加法。以下列表展示了五种主要的算术运算(加法、减法、乘法、除法和取模运算)及其语法:

set /A sum = nbr1 + nbr2
set /A difference = nbr1 - nbr2
set /A product = nbr1 * nbr2
set /A quotient = nbr1 / nbr2
set /A modulo = nbr1 %% nbr2 

这些运算符与其他编程语言类似,但请注意取模除法的双百分号。帮助命令显示的是单一的百分号,但正确的批量语法需要两个百分号。(实际上,取模字符只是单一的百分号,但第一个百分号实际上是转义第二个。如果现在这并不太容易理解,可以等到第十四章再深入了解,但现在就使用两个符号。)

现在我们来执行这些算术命令,但首先我们要定义两个操作数 nbr1 和 nbr2。每条语句右侧的结果作为注释显示(如前所述,&符号用于分隔两个命令,第二个命令可以是 rem 命令):

set nbr1=7
set nbr2=2

set /A sum = nbr1 + nbr2           &rem sum=9
set /A difference = nbr1 - nbr2    &rem difference=5
set /A product = nbr1 * nbr2       &rem product=14
set /A quotient = nbr1 / nbr2      &rem quotient=3
set /A modulo = nbr1 %% nbr2       &rem modulo=1 

加法、减法和乘法操作没有什么意外的结果,但将 7 除以 2 的结果是 3,而不是 3.5,因为批量算术只处理整数,并且会截断结果的小数部分。将 19 除以 10 不会得到 1.9,甚至不会返回四舍五入后的值 2\。1.9 的中间结果会被截断为 1。

取模是一个有用的运算符,用于返回余数。取模n返回的值是从 0 到n - 1,因此取模 2 操作对于偶数返回 0,因为 2/2、4/2、6/2 等的结果都是整数,不会产生余数。奇数则返回 1,因为 3/2、5/2、7/2 等都有余数 1。

奇怪的是,批量算术不支持指数或幂函数,这对一些人来说是一个令人沮丧的缺陷,但对另一些人来说则是激发创意的动力。你可以创建一个例程,输入基数和指数并返回指数结果(我将在第十八章中这样做)。

增强赋值运算符

增量赋值运算符 可以简化代码,当你希望将一个数字添加到变量并将结果存储在同一变量中时。最明显的例子是一个简单的计数器,可能在每次执行 set 命令时都想将一个变量递增 1,例如:

set /A veryVerboseTallyVariable = veryVerboseTallyVariable + 1

我故意选择了一个冗长且笨重的变量名,因为无论我们这些程序员怎么努力,它们有时几乎是不可避免的。

以下语法在逻辑上等价、简洁,并且更易理解:

set /A veryVerboseTallyVariable += 1

下一个命令将 17 添加到一个更简洁命名的变量中:

set /A nbr += 17

同样,以下的 set 命令分别减去 2、乘以 2、除以 2,并执行模 2 除法:

set /A nbr -= 2
set /A nbr *= 2
set /A nbr /= 2
set /A nbr %%= 2 

再次注意模除运算符的双百分号。许多经验丰富的 Batch 程序员并不知道增量赋值运算符在 Batch 中是可用的,错误地认为它们只存在于现代编程语言中,但它们确实存在,且在适当时应该使用。

运算顺序

你可以使用数学中的运算顺序规则进行更复杂的算术运算。你可能在代数预备课程中学过 PEMDAS 首字母缩略词(或者用“请原谅我亲爱的莎莉阿姨”作为记忆法),表示“括号、指数、乘法和除法、加法和减法”。在 Batch 中,我们使用 PMDAS,它发音困难,但如前所述,指数运算并不被支持(也许“请做甜点莎莉阿姨”这一记忆法能流行起来)。让我们看这个例子:

set /A nbr = 3 * (1 + 2) / 4 - 5

首先,1 和 2 被加在一起得 3,因为它们在括号内,尽管加法和减法在运算顺序中排在最后。乘法和除法具有相同的优先级,因此解释器会从左到右执行它们。表达式开头的 3 与加法中的 3 相乘,得到 9,然后 9 被 4 除,结果是 2.25。实际上,这是截断的,所以结果是 2。最后,减去 5,结果是 -3。

这个例子仅用于教学,因为直接将 nbr 设置为 -3 会更简单。实际上,会使用硬编码数字和变量的混合。例如:

set /A nbr = ((nbr1 + nbr2) * -10) / 4 

根据 PMDAS 规则,这里外部的括号是多余的,但它们使得语句更易读。

增量赋值运算符也可以与更复杂的表达式一起使用。以下两个语句在逻辑上是等价的:

set /A nbr = nbr + (2 * (4 + nbr) - -5)
set /A nbr += 2 * (4 + nbr) - -5 

在这两个命令中,变量 nbr 都在通过包含 nbr 的数学表达式递增,唯一的区别是第二个命令使用了增量赋值运算符。根据运算顺序,两者都会给变量加 4,然后将其翻倍,最后减去 -5(减去 -5 等同于加 5)。最终,这个表达式的结果是 nbr 增加的量。

八进制与十六进制运算

Batch 支持八进制和十六进制算术运算。这两种数制比十进制更接近计算机的思维方式,因此对程序员来说,理解它们并能够使用它们是很有帮助的。

十进制数系统是基数为 10,使用数字 0 到 9。没有代表 10 的数字;相反,有两个数字:新的位值从 1 开始,而个位数从 0 重新开始,因此是 10。与此相反,八进制数系统是基数为 8,使用数字 0 到 7。将 1 加到八进制的 7 不会得到 8,因为 8(和 9)在八进制数系统中是无意义的字符。相反,八进制数 10(发音为“一零”,因为它不是“十”)等于十进制数 8。同样,八进制数 11 等于十进制数 9,依此类推。

十六进制数系统是基数为 16,因此它和八进制面临相反的问题:它需要 16 个独特的数字,超过了大多数人类数制中使用的 10 个数字,因为我们是进化出了每只手有五个手指的结构。从 0 数到 9 后,我们有了“数字”A、B、C、D、E 和 F。十六进制的 B 等于十进制的 11,十六进制的 F 等于十进制的 15,十六进制的 10 等于十进制的 16。

Batch 可以进行八进制、十六进制和/或十进制输入的算术运算,并始终以十进制返回结果。十六进制数前面会加上 0x,而八进制数前面则单独加 0。因此,这两个变量分别被赋予八进制和十六进制的值:

set octalNbr=012
set hexadecimalNbr=0xB 

无论操作数的基数是十进制、八进制还是十六进制,Batch 总是将结果存储为十进制。为了演示,首先看这个例子:

set decimal7=7
set decimal1=1
set octal7=07
set octal1=01

set /A decimal = decimal7 + decimal1
set /A octal = octal7 + octal1 

数字 7 和 1 作为十进制和八进制相加。十进制的结果显然是 8。两个八进制数的和是八进制 10(“一零”,而不是十进制 10),但解释器会立即将其作为十进制 8 存储。在这个例子中,十进制和八进制表现相同,但这并不总是这样。

现在看这个例子:

set decimal11=11
set decimal2=2
set octal11=011
set octal2=02

set /A decimal = decimal11 + decimal2 
set /A octal = octal11 + octal2
> con echo The decimal sum is %decimal%.
> con echo The octal sum is %octal%. 

十进制加法得到十进制 13,而八进制加法得到八进制 13(“一三”,而不是十进制 13)。记住,八进制数系统没有 8 或 9。八进制 10 是十进制 8,在这个例子中,八进制 13 是十进制 11。因此,在 Batch 中,11 + 2 = 13,但 011 + 02 = 013 = 11,所以下面显示的结果是:

The decimal sum is 13.
The octal sum is 11. 

解释器甚至可以处理十进制和八进制值混合的算术运算。十进制的 10 + 10 是 20,而八进制的 010 + 010 是 16。当将十进制和八进制相加时,比如 10 + 010,Batch 会给出正确的结果 18。通常,这种类型的算术是偶然发生的,但有时精明的程序员会利用这一点,了解它是可能的也是很有用的。

以类似的方式,这些值被视为十六进制:

set /A hexadecimalNbr = 0xA * 0x14

通过这次乘法,0xA 等于十进制的 10,而 0x14 转换为十进制时比 16 大 4。执行此语句后,变量的值为 200,10 和 20 的积。

八进制和十六进制可以是强大的工具;但是,如果你打算做十进制算术运算,务必小心确保没有前导零。由于十六进制以 0x 开头,因此不小心进行十六进制运算要困难得多,但由于一个看似无害的前导零不知不觉地进行八进制运算则极为容易。

注意

因为数学无处不在,你会在第十六章、第十八章和第二十一章中找到包含各种批处理文件算术运算示例的框。Batch 还具有用于位操作的算术运算符:按位与、按位或、按位异或、逻辑左移和逻辑右移。我将等到第三十章再探索它们,因为这些运算符使用一些具有其他用途的特殊字符,而且许多有经验的程序员从未在编译代码中操作过位,更不用说在批处理文件中了。

浮动点数

批处理不明确处理浮动点数——也就是非整数有理数。事实上,如果需要对这样的数字进行大量处理,使用比批处理更好的工具。它就像是用铁锹挖掘房屋地基。虽然可以做到,但只有最严谨的苦行者才能完成。如果任务足够大,可以编写一些编译代码并从批处理文件中调用,但当只需要做一些轻量级的浮动点算术时,Batch 可以处理,就像你可以用铁锹在前院种几个郁金香球茎一样。

请记住,所有批处理变量实际上只是华丽的字符串。我们可以很容易地为几个变量赋予浮动点值——也就是带有小数点的数字。以下是两笔金额,单位为美元和美分:

set amt1=1.99
set amt2=2.50 

如果这些是整数,我们可以简单地使用 set /A 命令将它们相加。让我们试试,看会发生什么:

set /A sum = amt1 + amt2

结果是 3 存储在总和中,而不是期待的 4.49。每个数字的小数部分完全被忽略,导致 1 和 2 的整数和。

我们需要去掉小数点,进行算术运算,再恢复小数点。将每个金额乘以 100 就能解决这个问题,但批处理不会允许这样做。不过,由于浮动点值实际上只是一个伪装的字符串,我们可以使用上一章中描述的语法去掉小数点:

set amt1=%amt1:.=%
set amt2=%amt2:.=% 

现在金额是 199 和 250。此 set /A 命令的结果是 449:

set /A sum = amt1 + amt2

为了恢复小数,我们不能仅仅除以 100——这仅适用于整数——但是我们可以使用上一章中的字符串解析逻辑。通过子字符串提取,以下 set 命令将变量重置为三个项目的连接:数字的前面部分(去掉最后两个字节),一个硬编码的小数点(或点),以及数字的最后两个字节:

set sum=%sum:~0,-2%.%sum:~-2%
> con echo The sum is %sum%. 

最终,写入控制台的变量被设置为 4.49。

乘法的工作方式相同。如果你以 499 美元购买那台新电脑,第一年无需支付款项,利率为 19%,那么一年后你将欠多少钱?利率转化为 1.19 的倍数,但我们仍然必须去掉小数点。在找到两个整数的乘积后,我们通过在最后两个字节前插入小数点来恢复小数,如列表 6-2 所示。

set amt=499
set factor=1.19
set factor=%factor:.=%
set /A product = amt * factor
set product=%product:~0,-2%.%product:~-2%
> con echo The product is %product%. 

列表 6-2:整数与浮动小数的乘法

593.81 的乘积可能会让你重新考虑融资计划。

每个程序员的目标应该是编写“防弹”的代码。不幸的是,之前的代码更像是棉网而不是凯夫拉尔,并且有许多警告需要讨论。我们做了几个假设,如果其中任何一个被违反,代码就会出错。加法假设两个数字都包含两位小数;1.9 而不是 1.90 会使结果偏差 10 倍。除了小数点外,任何非数字字符都会引发问题,值前面的零会触发八进制运算。乘法则更为复杂。列表 6-2 包含一个整数,但如果 amt 表示的是美元和分,乘积将会有四个小数位,而不是两个。为了表示美元和分,应该去掉最后两个字节——或者更好地说,进行四舍五入。

我在这里不会深入探讨这些细节,原因很简单,如果输入数据不一致并且需要数据验证,批处理中的浮动小数算术可能不是最佳解决方案。为所有可能的情况编写代码无疑是繁琐的。重要的是,程序员要了解现有的选项。如果所有的值都具有一致的小数位数,则可以通过几行代码进行运算。当我在批处理程序中不得不使用浮动小数类型时,通常是为了处理涉及一致数据的特定任务。拿出那把铁锹,但只在合适的时候。

八进制案例研究,继续

那么,我在千年初的 8 月 1 日的执行日志中到底发现了什么呢?在批处理文件中,今天的日期被格式化为 CCYYMMDD,例如 20050801,并且被分解为三个独立的字段:

todaysYear = 2005
todaysMonth = 08
todaysDay = 01 

如果 todaysDay 不是 01,我们只需从八位数中减去 1,然后继续。但当它是 01 时,我们需要进行一些额外的算术运算。仅考虑月份的逻辑(并且理解 1 月会有一些特殊逻辑),我们必须减去 1 来确定前一个月份:

set /A month = todaysMonth - 1

当 todaysMonth 是 03 时,月份为 2;当 todaysMonth 是 07 时,月份为 6。但当 todaysMonth 是 08(即 8 月 1 日)时,前面的算术结果会变成-1。

解释器看到前导 0,并将算术视为八进制运算。八进制只理解数字 0 到 7,所以当解释器看到 8 时,它会认为这个字符像“ohkuh”一样陌生(“ohkuh”是瓦肯语言中数字 8 的发音),并简单地忽略它。最终,set /A命令将表达式中剩余部分的数学结果(即-1)赋值给月份变量。这个值最终打破了日期逻辑,导致我们无法获得所需的 7 月 31 日日期。

“八进制!”

使用子字符串提取和if命令,我插入了这一行代码来去除 todaysMonth 变量中可能存在的前导零:

if %todaysMonth:~0,1% equ 0  set todaysMonth=%todaysMonth:~1%

这段代码在未来几年都能正常工作,甚至在 8 月和 9 月的 1 号。如果原始代码没有在 8 月 1 日运行,那么在 9 月 1 日运行时会失败,因为 9 月被表示为 09。但如果代码没有在这两天运行呢?下一次会在哪一天失败呢?在 10 月 1 日,月份会被表示为 10。解释器会把它当作十进制数处理,因此代码会按预期执行。所以,8 月和 9 月的 1 号是唯一能打破这段代码的日期。

要非常注意八进制。

总结

在本章中,我讨论了数值数据类型以及它们在批处理中的处理方式。与大多数其他编程语言不同,批处理变量并没有定义为某种特定的数据类型。从本质上讲,所有变量都是简单的字符串,但当该字符串包含一个数字时,它可以被视为数值。

加法、减法、乘法、除法,甚至取模运算都能很容易地在十进制整数上进行,按照你可能在学校里学过的运算顺序规则。八进制和十六进制整数也得到了支持,尽管八进制运算容易被误用。根据我的个人经验,确保你的十进制整数没有前导零。增强赋值运算符提供了一种方便且未充分利用的工具,用于递增整数。

批处理中不支持浮动点数数据类型,但你已经学会了,通过一些小技巧,可以对带小数点的数字进行一些简单的算术运算。

转变话题,我将在下一章讨论文件操作。批处理的一个非常有用的功能是创建、复制、移动、重命名和删除文件和目录。

第七章:7 操作文件

如果你问一个对 Batch 只有间接了解的程序员,它的主要用途是什么,他们的回答很可能会提到移动文件。Batch 能做的远不止这些,但毫无疑问,它的主要用途之一就是文件操作。在本章中,我们将探索可用的不同命令和技巧。你还将学习如何创建空文件,以及合并、移动、重命名和删除文件的方法。我将介绍文件掩码和通配符,允许你对许多同名文件而不仅仅是一个文件执行你即将学习的命令。

复制文件的命令

Batch 有三个复制文件的命令:copy、xcopy 和 robocopy。在这一节中,我将对它们进行比较,并提供何时使用每个命令的建议,因为它们各自有不同的应用场景。你是在复制许多小文件还是几个大文件?网络稳定吗?速度是一个考虑因素吗?你希望返回码是简单的还是更细致的?日志记录对你有多重要?你需要回答许多问题,才能决定在特定的复制任务中使用最合适的命令和选项。

copy

copy 命令提供了一种快速简便的方法来创建空文件:

copy nul C:\Target\EmptyFile.dat

在 Batch 中,nul 这个词表示一个始终为空的文件(显然,有人觉得需要缩写 null)。在这里,我们通过复制 nul 文件来创建一个空文件,文件路径和名称由我们选择(在第十二章中,我将演示如何通过将不需要的输出发送到这个文件来处理它,让它消失)。有趣的事实是:Windows 不允许你在任何文件夹中创建名为 nul 的文件,无论扩展名是什么,或者没有扩展名,甚至手动创建也不行。你可以试试。

我使用复制命令的唯一其他场景是合并两个或更多相对较小的文件。/B 选项执行 二进制 文件复制,这意味着每一个字节,包括回车符和换行符等特殊字符,都会被原样复制,从而实现文件的真实连接。源文件通过加号分隔,后面跟着合并后的文件:

copy /B C:\Source\Header.txt + C:\Source\Details.txt C:\Target\MergedFile.txt

我们在这里将两个文件连接起来,但我们可以通过更多的加号分隔符合并更多文件。

你可能已经注意到,我只提到了复制命令的两种用途,而且都不涉及复制实际的文件。你可以使用该命令复制文件,但我从不使用它,因为它过于原始,选项也很少。这些不足很快在 Batch 首次发布后就显现出来,随后它很大程度上被更有用且可配置的 xcopy 命令取代。

xcopy

xcopy 命令的基本语法有两个参数——源文件和目标路径:

xcopy C:\Source\File2Copy.txt C:\Target\

xcopy 命令的一个巨大优势是,如果目标目录尚不存在,它会自动创建该目录。相比之下,copy 命令找不到路径就会失败。

在前面的示例中只给出了目标路径,这意味着复制的文件将与源文件同名。但我们可以通过简单地提供文件名来在 xcopy 命令中重命名目标文件:

xcopy C:\Source\File2Copy.txt C:\Target\RenamedFile.dat

这个命令有时会失败,因为解释器无法确定目标是目录还是文件(在第十二章中,我会讨论如何让这始终正常工作)。

xcopy 命令有两个选项,我几乎每次调用命令时都会使用。/Y 选项抑制确认覆盖目标文件的提示。虽然在通过命令提示符或交互式运行时,询问是否确认覆盖可能有意义,但在大多数其他情况下,它只是停止处理并造成卡顿,因此最好关闭它。

另一个我离不开的选项是 /F。它显示每个被复制文件的完整源路径和目标路径及文件名,这在使用通配符复制多个文件时,能留下有用的审计记录。如果没有 /F 选项,只会显示源文件或文件的路径,并给出文件总数。处于另一极端,如果你对这些信息不感兴趣,可以使用 /Q 选项(即安静模式)完全关闭显示。

使用这两个不可或缺的选项,以下命令会抑制提示并提供详细的日志记录:

xcopy C:\Source\File2Copy.txt C:\Target\ /Y /F

这个命令有太多可用选项,无法在此逐一介绍;请使用 help 命令查看完整列表。不过,作为一小部分示例,/U 选项仅复制目标中已经存在的文件;/S 选项复制文件夹及子文件夹;/J 选项使用无缓冲 I/O,适用于非常大的文件。

尽管 copy 命令已被弃用,xcopy 命令取而代之,微软实际上也认为 xcopy 本身已被弃用,取而代之的是更新的 robocopy 命令。尽管如此,xcopy 命令的广泛使用意味着它仍将在可预见的操作系统中可用,正如我稍后会详细说明的那样,在许多情况下它仍然是更好的选择。事实上,我在第一章中使用的就是这个命令,也许是你第一个 bat 文件的一部分。

robocopy

删除命令名中的最后一个字符,能揭示一部老科幻电影的标题,但 robocopy 实际上代表的是强大复制,这并非夸张。尽管 xcopy 拥有许多有用的选项,robocopy 提供了令人印象深刻的日志记录和令人目不暇接的选项。

尽管 robocopy 命令比 xcopy 强大得多,但它同样非常易于使用。参数略有不同:首先提供源目录(不带文件名),然后是目标目录,最后是要复制的文件、文件或文件掩码:

robocopy C:\Source\ C:\Target\ File2Copy.txt

这个示例在功能上与前一节中的 xcopy 命令等效,并在此重现:

xcopy C:\Source\File2Copy.txt C:\Target\

由于 robocopy 命令的前两个参数被认为是路径,因此你可以省略结尾的斜杠,这相比 xcopy 是一个很大的优势,因为在 xcopy 中,漏掉斜杠会把目标目录误当作文件名。

robocopy 日志记录

要了解 robocopy 的强大功能,不妨看看它的日志记录能力。如果你是 xcopy 的爱好者,最好先坐下再继续往下看。Verbose(详细)是唯一能形容它的词,甚至连这个词也不够准确。除了复制的文件列表,日志还包括一个漂亮的头部、开始和结束时间、文件大小、复制速度统计、源路径和目标路径、复制和跳过的文件和目录总数、任何失败的复制、使用的命令行选项列表等等。它的格式也很不错,有很多空白区域,便于阅读。

前面展示的简单 robocopy 命令用于复制一个文件,它会生成以下日志:

-----------------------------------------------------------------------------
   ROBOCOPY      ::      Robust File Copy for Windows
-----------------------------------------------------------------------------

  Started : Tuesday, January 30, 2007 12:18:44 PM
   Source : C:\Source\
     Dest : C:\Target\

    Files : File2Copy.txt

  Options : /DCOPY:DA /COPY:DAT /R:1000000 /W:30 

-----------------------------------------------------------------------------

     New Dir          1 C:\Source\
       New File         83146 File2Copy.txt
100% 

-----------------------------------------------------------------------------
 Total    Copied   Skipped  Mismatch    FAILED    Extras
    Dirs :         1         1         0         0         0         0
   Files :         1         1         0         0         0         0
   Bytes :    81.1 k    81.1 k         0         0         0         0
   Times :   0:00:00   0:00:00                       0:00:00   0:00:00

   Speed :            20786500 Bytes/sec.
   Speed :            1189.413 MegaBytes/min.
   Ended : Tuesday, January 30, 2007 12:18:44 PM 

一些结果不言自明,比如 Total(总数)、Copied(已复制)和 FAILED(失败),但其他结果则不太明显。如果要复制的文件已经存在于目标路径中并且完全相同,robocopy 会聪明地避免浪费时间覆盖它,而是将其视为合理的 Skipped(跳过)。当复制被阻止,因为源文件和目标文件夹(或源文件夹和目标文件)同名时,会发生 Mismatch(不匹配)。Extras(额外项)是指不在源目录中,但已经存在于目标位置的文件和目录。显然,这些文件并没有被复制,但解释器觉得有必要记录它们的存在。对于仅复制一个小文件的任务来说,日志记录可能显得有些过多。一个更有趣的任务可能会持续几页,但对于那个更有趣的任务,日志记录可能是不可或缺的。

默认情况下,所有这些信息都会写入控制台(在第十二章中,我会讨论如何将任何命令甚至整个批处理文件的日志重定向到日志文件)。但 robocopy 命令的独特之处在于,你可以轻松地通过选项创建日志文件。日志文件的路径和名称紧随其后,通过冒号与 /LOG 选项分隔。

这可能是你第一次看到选项后有多个字符的情况,而且这个命令还有更多类似的选项。将加号添加到选项中,如/LOG+,会导致信息附加到日志文件中(如果文件已存在)。为了演示,考虑以下内容:

set roboLog=C:\Batch\Robocopy.log
robocopy C:\Source\ C:\Target\ File2Copy.txt /LOG:%roboLog% /NP
robocopy C:\Source\ C:\Target\ AnotherFile2Copy.txt /LOG+:%roboLog% /NP 

在定义 roboLog 为日志文件路径和名称后,它会在两个 robocopy 命令中使用。第一个命令创建日志文件并写入复制信息,如果文件已存在,则会覆盖;第二个 robocopy 命令则将复制结果追加到该日志文件中。

最后两个命令都使用了/NP 选项,代表无进度,因为在创建日志文件时我认为它是必需的。如果信息被写入控制台,复制的进度会实时显示为百分比,并且每秒更新多次。较大的文件可能会显示几十个进度值,直到最终显示 100%。这种状态在控制台上查看时非常好,但在日志文件中,每次更新都会成为一个新记录。即使是几个大文件,也能让日志文件变得一团糟。即使是小文件,也可能导致日志文件中出现一些不需要的额外记录。/NP 选项非常好地解决了这个问题,它只会在每次复制完成后更新日志。

有用的 robocopy 选项

robocopy 有许多选项,所以我将快速介绍其中最有用的几个。但首先,给出一个命令作为选项的基础:

robocopy C:\Source\ C:\Target\

这个简单的命令(没有文件的第三个参数)会将源目录中的所有文件复制到目标目录。

这个命令会自动重试失败的复制操作,功能非常棒,但默认的重试次数高达一百万次,每次之间间隔 30 秒。这可能会导致在进行有致命错误的复制尝试时,程序挂起几个月。/R 和/W 选项分别覆盖了默认的重试次数和等待时间(秒):

robocopy C:\Source\ C:\Target\ /R:20 /W:5

使用这些选项时,解释器会在失败的复制操作中最多重试 20 次,每次尝试之间间隔 5 秒,之后才会中止。选择适合你的硬件和需求的值,但默认值不能继续使用。

如果添加了/S 选项,它还会复制所有的子目录,不过会排除空子目录:

robocopy C:\Source\ C:\Target\ /S

/E 选项会复制所有的子目录,包括目录和非空目录:

robocopy C:\Source\ C:\Target\ /E

你可以精细化此选项,只复制源目录(C:\Source*)及其直接子目录中的文件,但不包括更低层级的子目录。/LEV 选项将子目录的层级*(包括根目录)定义为 2,以此来处理:

robocopy C:\Source\ C:\Target\ /E /LEV:2

无缓冲 I/O 对于非常大的文件来说效率更高,而且通过/ J 选项调用,奇怪的是,它的实现方式与 xcopy 命令中的/U 选项有关(/U 已经被占用了)。/MIN 和/MAX 选项设置了文件复制时的最小最大字节限制。以下两个命令会复制文件夹中的所有文件,只对至少 1GB 的文件使用无缓冲 I/O:

robocopy C:\Source\ C:\Target\ /MIN:1000000000 /J
robocopy C:\Source\ C:\Target\ /MAX:999999999 /MT 

我偷偷将/MT 选项(代表多线程)加到了小文件的命令中。默认情况下,robocopy 命令是串行复制文件的,但这个选项会并行复制八个文件。你甚至可以定义线程数;/MT:128 是最大值,但根据我的经验,这并不会比默认的八个线程快太多。我们可以讨论阈值,但对于大文件的无缓冲 I/O 和小文件的多线程操作,都会优化任何复制过程。

/MINAGE 和 /MAXAGE 选项(最小最大年龄)定义了基于最后修改日期的复制规则。你可以单独使用它们,也可以结合使用以创建日期范围。以下命令只复制自 Microsoft 在 2000 年 9 月 14 日发布 Windows Me 以来修改过的文件,并排除过去七天内发生变化的文件:

robocopy C:\Source\ C:\Target /MAXAGE:20000914 /MINAGE:7

你可以将两个选项的值定义为日期(格式为 CCYYMMDD)或天数。解释器足够聪明,可以识别八字节数字表示的是日期,而不是超过 50,000 年的时间跨度。批处理可能不是一种新语言,但它既没有被丹尼索瓦人使用,也没有被我们的猎人-采集者祖先使用。任何小于 1,900 的数字都会被解释器认为是天数。

/PURGE 选项会删除目标位置的额外文件和目录,显然你应该谨慎使用,但它是创建备份时非常实用的工具。如果你在某一天备份了一个文件夹,第二天更改了源目录中某个文件的名称,然后再次备份该文件夹,除非使用 /PURGE 选项,否则备份中会多出一个不必要的文件。更好的做法是使用 /MIR 选项,它会镜像整个目录树。/MIR 选项基本上做的事情和 /PURGE 选项相同,但它还包括子目录。

/XF 选项会将一个或多个文件从复制中排除,而 /XD 选项则类似,排除一个或多个目录。/L 选项不会复制任何内容;它会生成一个列表,列出如果没有使用该选项,所有本应复制的内容。

你还可以使用 robocopy 命令来移动文件(参见第 80 页的“移动文件”)。像往常一样,使用帮助命令可以获得完整的可用选项列表。

robocopy 返回代码

xcopy 命令与大多数批处理命令类似,成功执行时返回错误级别 0,而失败时返回一个非 0 的数字。相比之下,robocopy 命令是独特的——如果不理解这一点,可能会感到非常困惑。返回代码 0 表示没有复制任何内容,但如果至少有一个文件成功复制,解释器会返回一个介于 1 到 15 之间的奇数,但即便是这些代码中,也并非都是良好的返回代码。以下是 robocopy 命令生成的六个基本返回代码:

0    没有错误,但没有文件被复制;换句话说,所有文件都被跳过了。

1    一个或多个文件复制成功。

2    发现了一个或多个额外的文件或目录,但没有任何文件被复制。

4    发现了一个或多个不匹配项;没有文件被复制。

8    某些文件或目录无法复制。

16    没有文件被复制;出现了严重错误。

哦,二的幂的优雅;一定是某个数学家想出了这些返回代码。难道用 0 到 5 这几个代码不更简单吗?不,中间的四个代码并不互相排斥;也就是说,它们中的多个(甚至全部四个)可以同时为真。解释器会将所有为真的代码加起来,并返回其总和作为错误级别。

举个例子,设想一个 robocopy 命令,将源文件夹中的所有文件复制到目标文件夹,其中一些文件复制成功,但目标文件夹也有额外的副本。返回代码 1 和 2 都为真,因此返回 3(它们的总和)作为错误级别。也可能在复制文件时,找到额外的文件,并且发生不匹配,另一个文件因为被某个进程或人占用而未能复制。做个数学运算(1 + 2 + 4 + 8),解释器返回 15。

这些返回代码为精明的编码员提供了细化错误处理的机会。返回代码 1 和 3 显然是好的,我通常认为 3 或更少也是好的。任何 4 到 7 的返回代码涉及不匹配,任何高于这个范围的返回代码至少包含一个显式的失败。根据具体情况,不匹配可能是完全可以接受的,因此只有返回代码为 8 或更大时才被视为不良。

为了明确验证至少有一个文件被复制,我们只需要查找一个奇数返回代码,这可以通过 第六章中的模运算函数来实现。

xcopy 与 robocopy

尽管“更好”这一词具有主观性质,但大多数批处理编码员一致认为 robocopy 比 xcopy 更,因为它提供了丰富的选项、多线程、自动重试、强大的日志功能,以及许多后台操作,使得它更高效。但有一个主要的警告。当尝试复制单个文件时,xcopy 的错误处理显然更好。这两个命令都试图复制一个不存在的文件:

xcopy C:\Source\NonExistentFile.txt C:\Target\
robocopy C:\Source\ C:\Target\ NonExistentFile.txt 

xcopy 命令报告没有复制任何文件,并返回错误级别 4,而 robocopy 命令则简单地返回 0,表示没有错误也没有文件被复制。我认为这应该算作一个错误。当尝试复制多个文件时,如果没有源文件,两个命令都会返回 0——这很合理,但这两个命令都要求显式地复制一个文件,而 robocopy 的返回代码并未区分文件未找到和文件因为已存在于目标目录而被跳过。跳过未更新的文件是 robocopy 的一个很棒的功能,但它与文件未找到完全不同。强大的日志功能使这一点变得清晰,但返回代码并没有体现。

返回代码未能报告为何文件未被复制(未找到或被跳过)是一个缺陷,但我们可以克服它。使用 robocopy 命令复制特定文件时,你可以在 errorlevel 为 0 时对目标文件执行 if exist 检查。如果文件存在,说明文件已被正确跳过;如果不存在,则表示出现了错误。或者,像我一样,在这种情况下直接使用 xcopy。

还有一些情况下,robocopy 命令复杂的返回代码超过了实际需要。你可能不关心是否有额外或不匹配的文件,而只需要一个简单的结果:好与坏,零与非零。这样做并没有错,说实话,有时候我仍然因为习惯和使用方便而在新代码中使用它。

也就是说,robocopy 命令正如其名,具有更强的健壮性。它的配置更加灵活,速度更快,失败的可能性更小。如果需要复制非常大的文件、数量庞大的文件,或者因网络连接问题可能会失败的文件,robocopy 无疑是最佳选择,而它复杂的返回代码在某些时候也是一大优势。此外,xcopy 命令对路径和文件名的长度有 254 字节的限制。我从未接近过这个限制,但如果你真的碰到这个问题,robocopy 可以应对。

我对这个话题最明确的声明是,你永远不应该使用 copy 命令来复制文件,但可以将它保留在工具箱中,用于创建空文件或合并文件。

文件掩码和通配符

之前的所有 xcopy 示例都是复制单个文件,而 robocopy 示例要么复制单个文件,要么复制目录中的所有文件。但使用文件掩码和通配符后,你可以创建更具针对性的命令,只复制目录中的部分文件;实际上,它们每次执行时可能会复制不同数量的文件。文件掩码替代这两个命令中的文件名,并由一个或多个通配符字符组成,可能还包含一些硬编码文本或已解析的变量。然后,当命令执行时,它会复制所有符合文件掩码条件的文件。

文件掩码并非仅限于复制文件的命令。接下来本章中的移动、删除甚至重命名文件的命令同样接受文件掩码来代替文件名,这样你就可以一次性移动、删除或重命名多个文件。for 命令充分利用了文件掩码,我将在第二部分中展示如何使用。任何对文件执行操作的命令可能都支持通配符;如果不确定,可以尝试一下。

Batch 识别两个字符作为通配符:星号()和问号(?)。它们的行为截然不同,我将在使用 xcopy 命令时详细说明这两者,但首先让我们看一组需要复制的文件。一个精心组织的人可能会保持包含预算信息的电子表格,每个月一个文件。为了演示,C:\Budget* 文件夹中包含按年份和月份命名的电子表格。这里列出三个:

Budget.January2008.xlsx
Budget.February2008.xlsx
Budget.March2008.xlsx 

这些文件来自金融危机的年份和大萧条的开始,文件夹中还包含前后年份命名相似的文件以及其他类型的文件。

星号通配符字符

星号是 Batch 中最常见的通配符字符,代表零个或多个字符。为了演示,以下的 xcopy 命令将复制所有 2008 年的文件,或者更具体地说,复制*C:\Budget* 中满足 Budget.2008.xlsx 通配符的文件:

xcopy C:\Budget\Budget.*2008.xlsx C:\Target /F /Y

星号是通配符,意味着该命令复制所有以 Budget. 开头,2008.xlsx 结尾,且中间可以有任何内容,甚至没有内容的文件。如果文件夹中有一个名为 Budget.2008.xlsx 的文件,它也满足这个通配符并将被复制。

到年底时,应该有十二个这样的文件满足此通配符,一个对应每个月,这样命令就会复制所有 12 个文件。但该命令不会复制 2007 年或 2009 年的文件,也不会复制具有相同名称但扩展名不同的 Word 文档,例如 Budget.June2008.docx。如果你将其中一个文件的文件名中的第一个点删除,像 BudgetAugust2008.xlsx,解释器也不会复制它,因为它不满足通配符。

以下的细微变化——在通配符前插入字母 J——导致命令只复制三份文件,分别是 1 月、6 月和 7 月的文件:

xcopy C:\Budget\Budget.J*2008.xlsx C:\Target /F /Y

在通配符前再加一个字符,Budget.Ju*2008.xlsx 的通配符将排除 1 月的文件。

你不只限于在一个通配符中使用单一的星号。这里是一个例子,使用了两个星号,其中最后一个代表文件扩展名:

xcopy C:\Budget\Budget.*2008.* C:\Target /F /Y

前面提到的 Word 文档,Budget.June2008.docx,现在也被拖网捕获。

你现在知道星号(*)通配符可以表示任何长度的文本,包括不包含任何文本的情况,但有时你可能希望更具限制性。Batch(批处理)有一个鲜为人知且更少被理解的通配符字符,专门用于这个目的。

问号通配符字符

虽然星号是零个或多个字符的通配符,但问号通常是精确匹配一个字符的通配符。(是的,斜体表示有个小陷阱。)为了只复制包含四个字符月份的文件,我将在原本是星号的位置使用四个问号:

xcopy C:\Budget\Budget.????2008.xlsx C:\Target /F /Y

该命令复制了 6 月和 7 月的文件,但没有复制 3 月、4 月、5 月以及所有其他月份的文件,这些月份的文件名通常较长。

这看起来很简单,但我之前承诺会告诉你一个注意事项。如果一个或多个问号通配符出现在文件掩码的末尾,或者这些问号紧接着一个点号,Batch 也会认为空字符是每个问号的有效替代值。

举个例子,我会稍微调整文件命名规则,在月份和年份之间插入一个句点,从而得到如下的文件名:

Budget.April.2008.xlsx
Budget.May.2008.xlsx
Budget.June.2008.xlsx
Budget.July.2008.xlsx 

接下来,我会将句点插入到文件掩码中的四个问号和年份之间,如下所示:

xcopy C:\Budget\Budget.????.2008.xlsx C:\Target /F /Y

即便是一些经验丰富的 Batch 程序员,可能也只会认为 6 月和 7 月的文件符合这个通配符条件。事实上,他们是对的,3 月、4 月以及所有其他月份(用超过四个字母表示的月份)的文件不会被复制,但 5 月的文件也会符合这个条件。前面三个通配符与“May”中的每个字母匹配,但第四个问号则与一个空字符或不存在的字符匹配。即使是命名奇怪的Budget..2008.xlsx也符合这个条件。

关键是,当n个问号通配符出现在文件掩码末尾或紧跟一个点号时,该掩码可以由零到n个字符满足。否则,n个问号通配符则要求正好n个字符。这是一个非常微妙的特性,理解它可能会节省你几个小时的麻烦。

为了更明确地说明这两个 Batch 通配符字符之间的区别,“shot”、“shoot”、“shut”、“shunt”、“shallot”甚至“sht”都符合 sh*t 文件掩码。对于这些单词(以及一个非单词),只有“shot”和“shut”符合 sh?t 文件掩码。

移动文件

移动文件类似于复制文件;唯一的区别是,复制文件后,它会存在于两个地方,而移动文件后,原始文件就不复存在了。移动命令可以轻松完成这一任务。它只需将源文件和目标文件作为参数,通常你会看到它带有/Y 选项,用于抑制覆盖目标文件时的确认提示:

move C:\Source\File2Move.txt C:\Target\ /Y

许多旧代码仍然包含移动命令,但它已被大部分弃用,转而使用 robocopy 命令。带有/MOV 选项的以下命令在功能上等同于之前的移动命令:

robocopy C:\Source\ C:\Target\ File2Move.txt /MOV

从这个 robocopy 命令中去掉文件名会移动源文件夹中的所有文件,但不会移动子文件夹中的任何文件。

添加/S 选项会导致移动所有子文件夹中的内容,即使目标子文件夹需要创建:

robocopy C:\Source\ C:\Target\ /MOV /S

该命令移动所有文件。文件不再位于源位置,但源文件夹结构保持不变。现在,这里有点奇怪,值得注意。我们一直在讨论 /MOV 选项,这显然是 move(移动)的缩写,但一个类似的选项 /MOVE 是指……我猜是 带 E 的移动。这两个选项有微妙的不同。带有 /MOVE 选项的 robocopy 命令真正地移动了文件和目录。

在选项中添加 E 会在将所有文件复制到目标位置后删除源目录结构:

robocopy C:\Source\ C:\Target\ /MOVE /S

我们有一个 /MOV 选项,它在复制子目录的同时移动文件,还有一个 /MOVE 选项,它移动文件和子目录。这个区别并不特别直观。

在考虑使用哪条命令来移动文件时,robocopy 命令是最有效的选择,原因和“xcopy 与 robocopy”中第 76 页提到的完全一样。但像 xcopy 命令一样,move 命令返回的代码更直观,仍然在批处理环境中有它的位置。

删除文件

当文件不再需要时,清理它们是很有道理的。del 命令可以轻松删除一个或多个文件。/Q 选项,代表 quiet 模式,可以防止解释器询问是否删除文件。该命令接受多个要删除的文件参数,既可以使用显式文件名,也可以使用文件掩码。以下命令删除名为 Junk.txt 的特定文件以及文件夹中所有扩展名为 .OLD 的文件:

del /Q C:\Source\Junk.txt C:\Source\*.OLD

使用 /A 选项根据文件的属性选择文件进行删除。例如,使用 /AH 选项仅删除隐藏文件。取反逻辑,/A-H 选项仅删除 隐藏文件。和往常一样,使用 help 命令查看完整的选项列表。

只需将目录作为参数传递给 del 命令,即可删除该文件夹中的所有文件,但目录本身仍然存在。要删除目录本身,你需要使用不同的命令,我会在第十三章中分享它。

重命名文件

renrename 命令是批处理命令的同义词;也就是说,它们是相同的命令。第一个参数是要重命名的文件,第二个参数是新的文件名:

ren C:\Batch\File2Rename.txt NewFileName.txt

如果目标文件已经存在,解释器会返回错误级别 1。如果有可能同名文件已经存在,我会在重命名之前悄悄删除它:

del C:\Batch\NewFileName.txt /Q
ren C:\Batch\File2Rename.txt NewFileName.txt 

即使对于此命令也支持通配符,但我只使用 ren 命令来显式指定文件名,主要是因为该命令不会在控制台上写出重命名文件的列表。(如果我要重命名多个文件,我会使用 dir 命令作为 for 命令的输入,然后一个一个地重命名。我们将在第十三章和第十七章进一步探讨这些命令。)

ren 命令并不复杂,但我见过一个常见的陷阱太多次了。虽然在两个参数中都使用路径很简单,但如果稍微思考一下,解释器已经从第一个参数中知道了路径。文件并没有被移动或复制到其他地方;根据命令的性质,它只是在当前位置被重命名。如果第一个参数没有路径,系统会默认当前目录(更多内容将在下一章介绍),但第二个参数仅是新文件名,不应该包含路径。

总结

本章可能会成为你在本书中最常引用的一章。如果没有创建、复制、移动、合并和删除文件的能力,Batch 就什么都不是,我在这里介绍了许多执行这些任务的命令。我讨论的一些命令非常简单,但我也覆盖了不止一个常见的陷阱,并提供了解决方案。你还学习了如何利用文件掩码和通配符在多个文件上同时执行这些命令。

复制文件听起来是一个简单的任务,但我详细介绍了可用的多种技术和需要考虑的因素。我希望我能向你展示 robocopy 命令的强大与实用,同时也让你认识到 xcopy 命令的简单性和实用性。

在下一章,我将描述如何执行在其他语言中编译的程序,这将涉及到更深入的讨论,探讨当你没有提供路径时,解释器是如何找到要执行的程序的。

第八章:8 执行编译程序

本章名义上是关于一个 bat 文件执行或调用用其他语言编写并编译的程序。实际上,执行这些操作的语法非常简单。本章最有趣的部分是,有时候被执行的程序在 bat 文件中并没有定义路径。那么,bat 文件是如何找到可执行文件的呢?

本章的主要内容将集中在寻找此类程序的两个重要机制上:当前目录 和路径变量。这个话题不仅仅局限于执行程序。当你调用其他 bat 文件时,也会用到这些机制,而且它影响到很多其他资源未在 bat 文件中定义路径时的情况。例如,在第七章中,我讨论了许多用于复制、移动、删除和重命名文件的命令。当这些命令中的文件或文件未在 bat 文件中定义路径时,它们仍然能在你的 bat 文件中完美运行,只要你理解这些概念。当然,你还将学习不同的方式来调用程序并传递参数。

调用可执行文件

bat 文件通常仅仅是调用编译程序的载体或包装器,也就是所谓的 可执行文件。bat 文件会设置一些程序所需的变量,调用可执行文件,并在后台执行一些错误处理。更复杂的 bat 文件可能会调用数十个不同的程序,甚至在某些调用上使用条件逻辑。无论简单还是复杂,Batch 的一个特点是能够调用用其他语言编写的可执行文件。

call 命令接受可执行文件作为它的第一个参数,可能也是唯一的参数。以下命令调用或执行位于 *C:\Executables* 目录下的程序 MyProg.exe

call C:\Executables\MyProg.exe

call 命令用于调用程序;这应该不会让人感到惊讶,但接下来要说的会有些奇怪。这是 Batch 中唯一一个,在命令名本身被省略时仍然能正常工作的命令,可能其他语言中也没有类似的情况。以下命令虽然在技术上不是 call 命令,但它执行的功能与前面的例子中的 call 命令相同:

C:\Executables\MyProg.exe

想一想这个问题。命令 set x=1 设置了一个变量,但语句 x=1 只是让解释器感到困惑。如果 robocopy 命令前面没有 robocopy 这个文本,没有理智的人会期望剩下的文本能复制一个文件。(如果这还不够奇怪,调用其他 bat 文件时,call 命令的有无会变得更加怪异,在第十章中有所讨论。)

这几乎看起来像魔法,但从解释器的角度来看。当它解释一行新代码时,通常期望第一项是一个命令。当它找到 set 时,它会预期一个变量、一个等号和一个值;当它找到 robocopy 时,它接下来会寻找不同的参数。当它遇到完全出乎意料的东西时,解释器不会退缩;它会给你,程序员,一个怀疑的好处,假设无论它是什么,都可以执行,并执行它——就像 call 命令那样。

一些批处理程序员使用 call 命令来执行可执行文件;有些则不使用。我属于后者,更喜欢程序仅由可执行文件本身或只是一个解决的变量在单行代码中呈现的干净样式,但对于那些明确拼写出命令的人,我没有异议。更重要的是,一致性是关键;坚持你选择的约定。

我还更倾向于将程序名保存在一个带有完整路径的变量中,只有在它尚未定义时才进行设置。这确保了所需的程序默认存储在变量中,同时也允许其他人将其设置为备用程序,以增加灵活性:

if not defined pgmMyProg  set pgmMyProg=C:\Executables\MyProg.exe

然后,当执行程序时,这个简单的命令,如果我可以称它为命令,将会调用期望的程序:

%pgmMyProg%

这个变量包含可执行文件的路径,但让我们回到一个仅由硬编码路径和文件名组成的代码行的概念。

你可以通过移除路径,保留程序名和可能的扩展名来简化它:

myProg.exe

这看起来更简单,但当你停下来思考解释器该如何在机器或网络的某个位置找到程序时,复杂性就增加了。在深入这些细节之前,我需要稍微插开话题,讲讲两个命令/变量。

cd 命令和变量

cd 命令也是一个变量,是少数几个批处理伪环境变量之一。在第二十一章中,我将有更多内容讨论这些变量。目前,只需将它们视为解释器最初设置的变量,具有一些独特的特性。

这个变量代表当前目录。该命令稍微有些模糊,因为它也可以表示更改目录,因为它被用来……嗯,改变当前目录。

当你双击或打开一个 bat 文件时,当前目录就是 bat 文件所在的目录或文件夹。如果从另一个进程调用相同的 bat 文件,当前目录将从该进程继承。仅仅在不同目录中调用一个 bat 文件或可执行文件并不会改变当前目录,但 cd 命令会。

接下来的第一行和最后一行使用 cd 变量来显示当前目录。中间部分是 cd 命令,巧妙地将当前目录更改为其参数,假设该目录存在:

> con echo Current Directory is: %cd%
cd C:\NewDir\
> con echo Current Directory is: %cd% 

如果一个包含这三行代码的 bat 文件位于 *C:\Batch* 目录下,执行它会在控制台上显示原始当前目录和新分配的当前目录:

Current Directory is: C:\Batch
Current Directory is: C:\NewDir 

你还可以相对现有的当前目录设置当前目录。一个点表示现有的值,因此这将把 cd 变量分配到一个子目录:

cd .\Child\

两个点表示当前目录的父目录,因此以下命令会将当前目录上移一个级别:

cd ..

(.... 参数查找祖父目录。)

你甚至可以通过先使用两个点“..”上移一个级别,再重新分配 cd 变量到同级目录:

cd ..\Sibling\

我甚至不愿提及这一点,但 chdir 是 cd 命令的批处理同义词。也就是说,前一个示例中的命令在功能上等同于 chdir ..\Sibling\。然而,cd 变量没有同义词,所以你可以使用 chdir 或 cd 来更改当前目录,但在解析当前目录时,你需要使用 cd。我发现最简单的做法是始终使用 cd 来完成这两个目的。

在讲解当前目录的用途之前,我需要介绍另一个命令,它也是一个变量。

path 命令和变量

类似于 cd,path 也是一个命令和伪环境变量。该变量在 Windows 机器上预定义,包含一个以分号分隔的目录列表,这些目录对计算机是必需的,例如 Java 和 Windows 可执行文件的路径。(要查看当前在 Windows 机器上设置的 path 变量,可以打开命令提示符,使用我们在第二章中学到的知识,输入命令 set path。)

就像 cd 命令设置当前目录一样,path 命令设置 path 变量。在以下代码行中,现有的值被两个其他目录添加到前后;请注意,在每个附加目录的末尾插入了分号作为分隔符:

path C:\PrependDir\;%path%C:\AppendDir\;

你可以完全重新分配 path 变量——甚至可以完全清除它,如果参数仅是一个分号的话。此变量中的各个目录是有目的的,可能是为了允许某些必要的进程运行。对于在机器上持久更改该变量(例如使用 setx 命令)要非常小心,但前面显示的 path 命令只会更改 bat 文件执行时的路径。最坏的情况是你可能会破坏 bat 文件,但不会破坏计算机上的其他任何内容。在下一节中,我将解释为什么你可能想要更改 path 变量。

警告

set 命令提供了重置 cd 和 path 变量的另一种方法,但出于一致性考虑,我抵制这种方法,因为一些其他伪环境变量不能或不应该使用此命令重置——而且它需要更多的按键操作。

查找可执行文件

让我们回到通过仅调用程序的名称和扩展名来执行程序,如下所示:

myProg.exe

解释器在哪里找到可执行文件?它首先会在当前目录查找。如果在那里找到了,它会执行那个文件。否则,解释器会按顺序在路径变量中定义的每个目录中查找,并执行第一个找到的可执行文件。如果在这些目录中找不到该文件,解释器只会将错误级别设置为 9009 的值。(奇怪的是,如果call命令出现在可执行文件名之前,错误代码是 1。)

假设myProg.exe位于*C:\Executables*,我们执行相同的代码行。如果该目录是当前目录,程序将被找到并执行。否则,如果该目录在路径变量中,程序可能会被找到并执行。这假设程序没有被当前目录中或路径变量更高位置中的同名、同扩展名的其他程序所覆盖。

如果以上都不成立,程序将无法找到,但有多种方法可以确保解释器找到可执行文件。首先,我们可以使用cd命令在执行程序之前更改当前目录:

cd C:\Executables\

或者,我们可以通过两种方式来修改路径变量,使其包含目录。这里我是在路径前添加目录:

path C:\Executables\;%path%

这里我是在路径后追加目录:

path %path%C:\Executables\;

如果目录被追加,并且路径变量中较早定义的目录中存在另一个名为myProg.exe的文件,那么将会执行那个程序。将目录添加到前面确保了我的可执行文件在任何其他文件之前被选中,但这也有一定的风险。它可能会将某些内容引入到路径变量中,覆盖其他进程使用的资源。

这绝不是一种不好的技巧;事实上,当合理管理时,它非常有用。使用当前目录或路径变量来查找可执行文件的一个很好的应用是使代码具有可移植性。你可以将一个批处理文件保存在单个文件夹中,或者一个更复杂的文件夹结构中,里面包含其他批处理文件、可执行文件、配置文件和其他资源。然后,你可以将这个文件夹复制到具有不同根目录结构的其他计算机和网络中。由于当前目录本质上跟随高层批处理文件,它会在这些不同的位置工作,只要使用当前目录来查找它的其他组件。

你可以将默认可执行文件与批处理文件放在同一个文件夹中。如果单独运行,它将使用这个可执行文件。如果从另一个批处理文件调用,且当前目录不同,它可能会找到一个不同的程序,从而允许其他人使用你的批处理文件来调用他们自己的可执行文件。简而言之,你可以创建一组同名的程序,在不同的实例中执行不同的程序。

进一步说,我之前提到过,实际上甚至不需要扩展名就可以调用一个程序。也就是说,如果 myProg.exe 存在于当前目录,它很可能会通过以下代码行被调用:

myProg

解释器通过另一个伪环境变量 pathext 来找到没有扩展名的可执行文件,pathext 包含一个由分号分隔的扩展名列表,类似于 path 变量包含的目录层级。解释器仍然会在当前目录中查找可执行文件,然后是 path 变量中的各个目录,但在每个文件夹中,它现在会查找第一个可以找到的,文件名为 myProg 且扩展名位于给定层级中的可执行文件。

如果 pathext 变量没有被其他人或其他程序修改,它通常包含大约十几种文件扩展名,按顺序包括:.com.exe.bat.cmd。因此,唯一会阻止先前的命令在当前目录执行 myProg.exe 的实体,就是当前目录中的 myProg.com。 (如果你需要重置这个变量,可以使用 set 命令。pathext 变量只是一个变量,而不是一个命令。)

推送和弹出当前目录

cd 命令非常有效地更改当前目录,但先前的当前目录会消失在空中,再也无法找回。通常这完全没有问题,但在某些情况下,你可能希望在更改当前目录后暂时恢复原来的状态。也许有一个实用的批处理文件(bat 文件)是为了被其他许多批处理文件调用而编写的。稍后我将详细讨论如何从另一个批处理文件调用一个批处理文件,但现在,我们只需要理解被调用的批处理文件的角度。

被调用的批处理文件可能会在某个文件夹中创建或使用资源,因此在批处理文件开始时更改当前目录是有意义的。然而,当被调用的批处理文件完成并将控制权交还给调用它的批处理文件时,应该恢复先前的当前目录。这是基本的礼貌,因为调用的批处理文件可能正在不同的目录中工作,改变它的当前目录可能会给它带来问题。一个更自私的动机是,被调用的批处理文件可能希望保持它自己的目录。如果被调用的批处理文件不恢复当前目录,调用的批处理文件可能会在现在的当前目录中丢下不必要的文件。被调用的批处理文件可以在不让外部干扰的情况下保护它的目录,同时也表现得非常有礼貌。

为了解决这个问题,你可以在执行 cd 命令之前将先前的当前目录存储在一个变量中,然后在批处理文件的末尾执行另一个 cd 命令来恢复它。但批处理提供了两个命令,结合使用可以更优雅地完成这个任务,它们就是 pushdpopd 命令。

pushd 命令像 cd 命令一样改变当前目录,但它还会将先前的当前目录 推送 到堆栈中,供以后使用。有时它被称为 推送目录 命令,尽管为了简便,通常按字面发音,即“push-d”命令。在 bat 文件的开头附近,此命令将简洁地执行这两个任务:

pushd C:\NewDir\

在 bat 文件的末尾或接近末尾的位置,以下简短的命令将删除 *C:\NewDir* 作为当前目录,并从堆栈中获取或 弹出 之前的当前目录,用它来恢复当前目录:

popd

这有时被称为 弹出目录 命令,但更常见的是“pop-d”命令。

注意,没有参数;popd 是一个极少接受任何参数的命令。当多个 pushd 命令执行时,每个命令都会将另一个先前的当前目录推送到堆栈中,而每个随后的 popd 命令都会恢复最近添加的目录。

另需注意的是,如果传递给 pushd 命令的参数是网络路径,则该路径会分配给最高的未使用驱动器字母,popd 命令将取消该分配。最后,未带参数的 pushd 命令会显示堆栈中目录的完整列表,从最近添加的目录开始。

警告

pushd 和 popd 命令必须平衡使用。 如果一个 pushd 分配了网络路径,则应始终执行相应的 popd,即使有错误被处理。如果没有,任何映射的驱动器字母将保持映射,即使 bat 文件已经完成。如果这种情况发生得足够频繁,计算机会用完可用的驱动器字母。

使用当前目录查找其他资源

当前目录用于的不仅仅是查找可执行程序。对于任何资源,例如文件,如果路径没有被定义,当前目录将被假定为其路径。例如,在第七章中,以下命令删除了一个显式文件和所有以特定扩展名结尾的文件:

del /Q C:\Source\Junk.txt C:\Source\*.OLD

以下命令在按下的键数更少的情况下完成相同的任务 如果——if 是一个关键限定词——当前目录是 *C:\Source*,即前一个命令的路径移除两次:

del /Q Junk.txt *.OLD

xcopy 命令的源参数和任何其他接受路径和文件名作为参数的命令相同。我通常更倾向于使用显式路径,以避免任何歧义,但这种技巧赋予了本章中描述的同类型的灵活性,适用于大量命令。再翻阅一遍第七章,想象所有用于复制、移动和重命名文件的命令都没有显式路径。如果解释器能够在当时的当前目录中找到特定的文件或文件,它们都会是有效的命令。

向可执行文件传递参数

在本章开始时,我演示了如何调用一个已编译的程序。在继续之前,我有一个关于这个语法的最后观察要分享。

可执行文件通常在执行时接受一个或多个参数。这些参数通过简单地将它们列在程序后面作为参数传递给程序。为了便于阅读,我将这三个参数放入了变量中:

set inFile=C:\Batch\Input.dat
set outFile=C:\Batch\Output.dat
set logFile=C:\Batch\Log.dat

%pgmMyProg% %inFile% %outFile% %logFile% 

输入文件是传递给程序的第一个参数;在许多语言中,这通常被视为程序中的 args[0]。同样,输出文件是第二个参数 args[1],日志是第三个参数 args[2]。你也可以使用硬编码的值,参数可以是任何你想要的,它们不一定是文件。

摘要

执行已编译的程序一开始看起来非常基础。毕竟,你甚至不需要一个命令。但如果不了解当前目录和我在这里详细介绍的路径变量,你就无法真正理解它是如何工作的。你已经学会了解释器如何使用它们来查找可执行文件、文件和其他资源,以及管理这些重要变量内容的多种方法。

执行另一个 bat 文件类似于但不完全相同于执行已编译的程序,你将在第十章中了解这些区别。但在我深入讨论之前,你将学习标签及其在下一章中的多种重要用途,主要是它们对命令执行时间和频率的影响。

第九章:9 标签与非顺序执行

在政治领域,标签常常被误解,但在最基本的层面上,标签是一个标识符,它以尽可能少的字词简明地定义一个产品或对象。如果没有标签,商业就会停滞不前;超市会堆满满满的神秘罐头产品。晚餐吃什么?可能是豆子,也可能是南瓜派混合粉;我们打开之前无法知道。

没有标签,Batch 文件不会完全陷入这种混乱,但作为创建更复杂批处理文件的重要工具,它将缺失在你的编程工具箱中。到目前为止,本书中的每个批处理文件、代码片段和列表都是按顺序执行的。解释器逐行解释,每次先执行第一条命令,然后是第二条。这一过程会一直继续,直到发生以下两种情况之一:要么是批处理文件的最后一条命令被解释,要么是语法错误导致批处理文件崩溃。标签允许你以非顺序的方式执行 Batch 命令。在本章中,我将介绍如何在代码中进行前后分支、根据数据条件重复某些代码段,甚至创建一些 Batch 本身不包含的命令。

标签还为我提供了一个很好的机会,来讨论一个至关重要的话题:编码规范,尤其是缩进。

标签

在 Batch 中,标签就是你可能预期的那样,一个定义代码块的标签。更具体地说,批处理文件中的某个位置或点被标记为标签。标签本身不是命令,虽然它从未被执行,但你很快会发现它对于执行流程至关重要。

标签可以包含字母、数字和一些特殊字符,最重要的是,它们必须以冒号开头。奇怪的是,标签名可以包含额外的冒号,但绝不能出现在第二个位置。例如,这里有一些代码,按照它的作用进行了定义或标记,用于检查特定变量的状态:

:CheckStatus
 if /i "%status%" equ "fail"  > con echo Failure
 if /i "%status%" equ "good"  > con echo Success 

类似地,这段代码处理一个非常基本的中止过程,并被标记为这样:

:Abort
 echo The Process is aborting
 exit /B 1 

我将在第十章中讨论退出命令。现在,它仅用于退出批处理文件。

定义标签非常简单,但在深入了解标签的影响及如何使用它们之前,请允许我稍微偏离一下,甚至有点愤怒地谈谈编码规范。

缩进

许多批处理编码员拒绝给他们的代码添加缩进。我不太清楚原因,因为我所熟悉的每种编程语言都有某种约定,即使不是强制要求,也有对缩进的规范。我的最佳猜测是,批处理语言本质上缺乏对其本身的尊重,认为批处理只是一个必须尽快完成的工具性麻烦,根本不考虑可读性,更别提美学了。为了让你的批处理代码赢得应有的尊重,我建议所有命令前加上两个空格的缩进。在 if 命令的代码块(以及其他类似结构,后面会讨论)内部,缩进再加三个空格,嵌套结构则应缩进更多。

这个话题可能在讲解标签的章节中看起来有些不合时宜,但实际上,这是最理想的位置。标签应该稍微突出一点,甚至更突出。任何格式良好的文档都有部分、章节、节和/或小节,每个部分通常都有某种标题或提示——或者我敢说是 标签——通过不同的字体、字号、加粗、下划线、着色或上述几种方式的组合,使其在视觉上与其余文本区别开来。然而,在编写 bat 文件时,这些选项都不可用。我们的工具库只剩下一个重要的项目——缩进,同时也可以借助大写字母和空格。

由于标签的第一个字符总是冒号,因此我通常将冒号放在行的第二个字符位置,这样我的典型缩进就减少为一个字符。因此,当任何人,包括我自己,查看我编写的 bat 文件时,所有标签都会非常明显。我将行的第一个字符保留给 rem 命令的开始。例如,下面是一个基本的备注、标签和两个简单的命令:

rem - This code does something.
 :DoSomething
  set do=something
  set doMore=somethingElse 

标签中的冒号后的大写字母也增加了其显著性。

希望我不是把自己当作批处理编码规范的斯大林。这只是一个编码员的个人意见,实际上还有许多经过深思熟虑、与我不同的规范。重要的是代码应该易于阅读。实现这一点有很多方法,但完全不缩进肯定无法通过测试,即使这个话题让我显得有些专制。

goto 命令

既然我们已经有了一个定义代码片段的标签,那它有什么用呢?一些编码员实际上将标签当作临时备注(我想这也没问题),但标签的真正功能是指引程序流向标签下方的代码。这就是 goto 命令的作用,它按字面意思指示解释器跳转到由标签定义的代码位置。考虑以下两个命令:

goto :Abort
goto :DoSomething 

goto 命令将控制转到本章前面定义的 :Abort 和 :DoSomething 标签。

好吧,这不完全正确;第一条命令将执行跳转到 abort 例程,第二个 goto 命令永远不会执行。在 bat 文件中,标签本身可以出现在 goto 命令之前或之后,但重要的是要明白,一旦执行了 goto 命令,执行不会返回到 goto 命令后的那条命令。执行一旦跳转,我们就完全受制于标签下的代码。

要跳转到定义为:Abort 的标签,你也可以使用以下命令:

goto ABORT

这里有两件事。首先,goto 命令中标签名称的冒号被省略了。我怀疑这是一个早期的错误,微软为了保持向后兼容性可能不会修复。其次,实际的标签只有 A 是大写的,但 goto 命令显示的是整个标签名称都大写。

这个例子演示了标签名称是大小写不敏感的,就像 Batch 通常一样,并且在 goto 命令中冒号是可选的。虽然解释器允许这样做,但我认为没有理由让两个标签名称有所不同,因为这只会引起混乱。保持一致性是关键。

在第八章中介绍的 call 命令也与标签一起使用,但其行为与 goto 命令非常不同。我将在第十章中回到 call 命令和这些区别。

前进分支

goto 命令将控制权或流程转向两个方向之一;一个是跳过代码的前进分支。在这个例子中,三个 echo 命令将文本输出到控制台,但只有第一个和第三个会被执行:

 > con echo Before GOTO
 goto :MyLabel
 > con echo After GOTO
:MyLabel
 > con echo After LABEL 

很容易想象使用这种技术的更复杂代码。一个 goto 命令可能基于 if 命令的结果有条件地执行,而不仅仅是跳过单个 echo 命令,它可能跳过更大的一段代码。例如,如果某个文件存在或不存在,或者检测到失败,你可以跳转到将中止 bat 文件执行的代码,跳过其他所有内容。

goto 也可以作为跳出循环的工具。不幸的是,我还没有讨论循环;在第二部分中,我将详细讨论 for 命令和循环。但现在,为了理解这个逻辑,你只需要知道这个循环会根据 listOfNames 变量中列出的每个名称执行一次,不管它包含多少个名称:

 for %%n in (%listOfNames%) do (
    if /i "%%n" equ "Waldo"  goto :FoundName
 )
 > con echo ** Name Not Found **
:FoundName 

if 命令在搜索特定名称。如果找到了,它会让 goto 命令跳出循环,跳转到最后一行的标签。

这很重要,原因有两个。首先,它是高效的——如果名字出现在列表的前面,CPU 不会浪费周期去无谓地搜索列表的其余部分。更重要的是,如果名字被找到,echo 命令就不会被执行。请注意,逻辑不仅提前跳出了循环,而且还跳过了写出未找到名字的消息的部分。

向后跳转

上一节中的示例使用了 goto 命令来跳过代码的前进。接下来,我将展示一些 goto 命令反向跳转的示例。但首先,我已经讨论了如何构建一些更现代语言的组件,这些组件并不是批处理语言的一部分(比如布尔值和浮点数),但很多其他组件还未出现。批处理没有 while 命令,也不支持 do...while 命令。在其他语言中,while 命令会根据条件执行一段代码零次或多次,直到条件满足。do...while 命令也非常类似;唯一的区别是,在评估条件之前,代码块会执行一次。让我们在批处理中创建这两种命令。

while “命令”

为了演示批处理 while 命令的有用性,我们将编写一些代码,去除值的所有前导零,这对于任何不想意外执行八进制算术的程序员来说是必要的(如果你错过了,可以在第六章找到详细解释)。while 命令会执行一段代码,只要——或者 ——第一个字节是 0,并且这段代码将仅仅做一件事:去除一个前导字节。

以下代码完美地执行了任务:

:StripLead0s
 if "%nbr:~0,1%" equ "0" (
    if "%nbr:~1,2%" neq "" (
       set nbr=%nbr:~1%
       goto :StripLead0s
 )  ) 

解释器在第一次遇到标签时,基本上会忽略它,并检查 nbr 的第一个字符。如果是 0,接下来代码会验证是否有第二个字节——也就是说,0 是否确实位于某个值的前面。如果两者都成立,代码块会被执行,它会移除前导 0,然后 goto 命令将控制权传回到 if 命令之前的标签。

让我们通过三个不同的数字逐步查看代码,真正理解其逻辑。如果变量没有前导 0,则代码块永远不会执行。如果有一个前导 0,则代码块执行一次。然后,再次检查前导字节,由于它不再是 0,执行流程将继续执行后续的内容。如果 nbr 有 17 个前导 0,移除 0 的代码块会执行 17 次,在前导字节第 18 次检查后,执行流程继续向下进行。

在这个列表中并没有出现 while 这个词,但它做的事情和一个标准的 while 命令一样。就我而言,它就是一个批处理中的 while 命令。

注意

前面的代码片段是我第一次展示一个 if 命令嵌套在另一个命令中的例子,但在接下来的章节中,你将看到更多的嵌套命令。关于编码规范的另一个说明,我在那个列表中将两个尾随的右括号堆叠在同一行。这使得代码更紧凑,尤其是在嵌套多个层级时,也使得关注点集中在有趣的逻辑上。不过我承认,我可能是少数派。大多数 Batch 程序员会将每个右括号与其各自的 if 命令对齐。这需要更多的代码行,但以下代码在功能上等同于我之前的代码:

:StripLead0s
 if "%nbr:~0,1%" equ "0" (
 if "%nbr:~1,2%" neq "" (
 set nbr=%nbr:~1%
 goto :StripLead0s
 )
 ) 

做自己觉得对的事情,并坚持去做。另外,请注意标签名称包含了一个数字值。如前所述,我们并不局限于字母。顺便说一下,那个缩进看起来不错吧?

do...while “命令”

Batch 的 do...while 命令看起来与 while 命令非常相似;唯一的区别在于主逻辑必须至少执行一次。在具有内建 do...while 命令的语言中,条件子句通常位于结构的尾部(可以理解为在主逻辑执行一次后),Batch 也不例外。与 while 命令相比,主逻辑从 if 命令代码块内部移到了标签之后、if 命令之前的位置。

为了演示,让我们以一个例子为例,其中 textStr 变量需要右填充至少一个空格,以便将其扩展到最少 25 字节的长度。如果原始字符串的长度小于 25 字节,结果将是 25 字节;如果它本身已经至少 25 字节长,则会在结果中附加一个空格。(这个字符串可能是某些连接文本的一部分,用于在控制台上显示,其中空格填充将对齐列。当然,我们希望它和接下来的内容之间有一个空格,即使这需要额外的一个字节。)

右填充必须至少执行一次,这使得 do...while 命令非常适用:

:PadRight
 set textStr=%textStr% &
 if "%textStr:~24,1%" equ ""  goto :PadRight 

与 while 命令一样,标签位于大部分代码之前,但核心逻辑紧随其后,在这种情况下是一个单独的 set 命令,用一个空格填充字符串。然后检查第 25 个字节。(记住,它是零偏移。)如果不存在,goto 命令会将执行返回到标签处,以便可以向字符串中添加另一个空格。这个过程会一直重复,直到第 25 个字节被填充,确保字符串至少为 25 字节长,并且无论长度如何,都至少添加了一个空格。

在第二十六章中,我将详细介绍如何执行自动重启失败的进程,通常只需使用一个标签和 goto 命令—也就是 do...while 命令—就能使进程成功重启。

:eof 标签

一个不是由程序员创建但对所有 bat 文件内在存在的特殊标签是 :eof,它代表 文件结束。当以下的 goto :eof 命令在被调用的 bat 文件的主逻辑中执行时,控制权会返回到调用的 bat 文件:

> con echo We are about to exit the bat file.
goto :eof
> con echo This command will never be executed. 

在高级 bat 文件中执行相同的命令会完全停止过程,即使 bat 文件中没有定义 :eof 标签。

如果你本性反其道而决定定义自己的 :eof 标签,解释器将简单地忽略它,仿佛它是一个无意义的备注。在第十章中,我将进一步探讨这个独特的标签,特别是解释器如何在可调用例程内部处理 goto :eof 命令。

变量标签

在没有编译器的语言中工作确实有一些缺点,但我已经向你展示了一些“银 lining”(例如延迟扩展)。另一个优点是能够在执行时在 goto 命令中定义标签名,尽管标签本身必须是硬编码的。为了设置这个,设想为每个月定义一个不同的标签。这里显示了前三个标签,但它们下方没有与月份相关的代码:

:MonthJanuary
:MonthFebruary
:MonthMarch 

显然,以下命令将把执行指向前面代码片段中的某个特定标签:

goto :MonthFebruary

但现在这已经是陈旧的消息了。更有趣的是,如果变量 month 被设置为二月,以下命令将调用相同的标签:

goto :Month%month%

这个 goto 命令的参数是硬编码的 :Month 和 month 变量的值的拼接。在变量被解析后,命令将执行转向标签 :MonthFebruary。同样的道理也适用于其他有效的月份,这意味着如果 month 被设置为三月,这行代码也会跳转到 :MonthMarch。

但这确实引出了一个问题,那就是当生成的标签名在 bat 文件中不存在时会发生什么,比如,如果 month 被设置为 Erele(在约鲁巴语中表示二月)。解释器会向控制台写入以下消息:

The system cannot find the batch label specified - MonthErele

不幸的是,你永远不会看到此消息,因为过程会立即崩溃。

在第十章中,你将看到当与 call 命令一起使用时,Batch 可以更好地处理错误的标签名,但如果你使用这种技术与 goto 命令,确保该参数解析为有效的标签。

总结

在本章中,我介绍了标签的概念以及如何通过 goto 命令导航到它们。你学习了如何创建标签,探索了它们的使用技巧,并了解了它们在构建 while 和 do...while 命令中的重要作用。我还介绍了不可或缺的 :eof 标签。

但是你可以通过两种不同的方式导航到标签。下一章的大部分内容也将集中在标签上,探讨如何使用它们在 bat 文件内部创建可调用的例程。我还将详细说明如何从一个 bat 文件调用另一个 bat 文件,这是在你开始创建过于复杂的项目时一个至关重要的话题。

第十章:10 调用例程和批处理文件

在上一章中,我介绍了标签和非顺序执行,它们在本章中也发挥了重要作用。我将很快介绍一种已经讨论过的命令的新变化,使你能够创建并调用由标签定义的例程。控制权不会仅仅交给标签后面的代码,而是在例程执行后返回到调用它的地方。当你编写更复杂、更有趣的批处理文件时,你将希望充分理解例程。

在第八章中,我介绍了调用用其他语言编译的可执行文件的概念。这里我将扩展这一讨论,描述一个批处理文件调用另一个批处理文件的不同技巧。显然,你将了解最典型的调用方式,即将控制权返回给调用批处理文件的方式。但你也会学到将控制权交给被调用批处理文件的技巧,以及如何生成一个并行的第二个批处理进程。此外,你还将探索不同的退出例程或批处理文件的方式,无论是否带有返回代码。

调用命令,再探讨

在你创建可调用的内部例程之前,你必须了解两个与标签相关的命令的相似性和差异性。一个是第八章中首次介绍的 call 命令,我们用它来调用用其他语言编译的程序。另一个是第九章中介绍的 goto 命令,用于改变批处理文件的执行流程。

为了对比这两个命令,回顾第九章中的这段代码:

 > con echo Before GOTO
 goto :MyLabel
 > con echo After GOTO
:MyLabel
 > con echo After LABEL 

goto 命令跳过了中间的 echo 命令,导致输出结果如下:

Before GOTO
After LABEL 

为了演示两者的对比,清单 10-1 将代码中的每个 goto 实例替换为 call,包括 goto 命令和 echo 命令中的文本,同时保持此非常简洁的批处理文件中的其他内容不变。

 > con echo Before CALL
 call :MyLabel
 > con echo After CALL
:MyLabel
 > con echo After LABEL 

清单 10-1:演示 call 命令的简短批处理文件

执行清单 10-1 中的批处理文件,你会在控制台上看到清单 10-2 中显示的四行内容,而不是某些人预期的三行。

Before CALL
After LABEL
After CALL
After LABEL 

清单 10-2:执行清单 10-1 时写入控制台的结果

“Before CALL”的显示显然会立即执行(双关含义)。call 命令暂时将控制权交给标签后面的代码,导致显示“After LABEL”。当时如果是 goto 命令,那就结束了;批处理文件在那次显示之后就终止了。但是使用 call 命令时,在执行完:MyLabel 和批处理文件结尾之间的所有内容后,控制权会返回到 call 命令后面紧接的命令。因此,显示“After CALL”。

有些人可能会认为执行到此为止,但解释器接下来再次遇到 :MyLabel。我们没有调用它,也没有跳转到它;它只是代码的下一行。请注意,我没有称它为命令或语句。它只是代码的一行,占位符,在这个上下文中,除了是通向下一个命令的微妙“减速带”外,几乎没有其他意义。解释器继续执行 bat 文件中的最后一行,并且 After LABEL 文本第二次显示。解释器没有找到其他命令需要解释,bat 文件执行完毕。

虽然 goto 命令放弃了控制权,但 call 命令会记住它来自何处,并在完成任务后返回到该位置。现在我们已经具备了可调用的内部例程,我们将通过 call 命令调用该例程。

调用内部例程

当你的 Batch 代码变得更加复杂时,你可能会想从 bat 文件中的不同位置多次执行某段代码。例如,你可能想多次调用一个可执行文件,或者定期检查某个目录中是否有需要复制的文件。当我们进入交互式 Batch 时,你可能会想多次询问用户问题并获取响应。

面对需要多次调用某段代码的需求,一位新手程序员可能会 resort to cut and paste——在我极为评判(但准确)的看法中,这是一种令人厌恶的选择。一个更好的解决方案是创建一个内部例程,并从多个位置调用它。你甚至可以将一些只调用一次的代码放入一个例程中,便于更好地组织你的 bat 文件。有时直接通过标签运行是完全可以的,但更多时候,你可能希望创建一个只能通过调用才会执行的例程。

对于接下来的练习,我将以清单 10-1 为例,重新配置它,使得标签定义了一个可调用的例程。也就是说,执行流程会调用该例程,执行完毕后返回,再退出 bat 文件,避免再次执行该例程。为此,我需要一种方法来终止例程和 bat 文件。在清单 10-2 中,After LABEL 显示的内容将不再出现。相反,我们将期待以下三行输出:

Before CALL
After LABEL
After CALL 

以下代码,尽管看起来与之前的不同,但正是做了这件事:

 > con echo Before CALL
❶ call :MyLabel
 > con echo After CALL
❷ goto :eof & rem End of TestCall.bat

❸ :MyLabel
 > con echo After LABEL
❹ goto :eof & rem End of :MyLabel

❺ :AnotherLabel
 > con echo This is Never Executed
 ❻ goto :eof & rem End of :AnotherLabel 

在逐步执行代码之前,请注意这三条 goto :eof 命令。正如你所预料的,第一个 ❷ 跳转到文件末尾,终止 bat 文件。其他两条 ❹ ❻ 则是完全不同的——是一些新的内容。

在初始的 echo 命令之后,call 命令 ❶ 调用了 :MyLabel ❸ 定义的例程,该例程仅包含两条命令。第一条是熟悉的将 “After LABEL” 打印到控制台,第二条是一个 goto :eof 命令 ❹。因为该命令在标签调用后执行,所以它结束的不是文件而是例程,控制会返回到 call 命令 ❶ 之后的命令,打印 “After CALL” 到控制台。最后,主 goto :eof 命令 ❷ 退出了 bat 文件,因为解释器知道它不在例程中。

当在 :MyLabel ❸ 例程内部时,跳转到 :eof(或 文件结束)是一个误称;它实际上更像是 例程结束,但我们不必在语义上纠缠。如果去掉这个 goto :eof 命令 ❹,控制会继续执行到 :AnotherLabel ❺ 下的代码,然后再返回主线逻辑。但有了这个命令 ❹,则 :AnotherLabel 下的代码永远不会执行。

由于 goto :eof 命令有两种不同的用途,我通常会在此类命令后添加注释,明确指出它正在终止的内容,可能是例程的名称或是 bat 文件本身。我只是将 rem 命令放在一个 & 符号后面,& 符号将两条命令分开,写在同一行代码中。从程序角度看,这并不是必须的,但这种做法确实大大提高了代码的可读性,尤其是在例程变得比之前的示例更长、更复杂时。

调用 Bat 文件

短小或重复的代码片段非常适合放入内部例程中;你可以在 bat 文件的末尾添加一个或多个例程,创建一个结构良好的模块,你可以为此感到自豪。但有时这些短小的代码片段并不那么简短,或者它们非常有用,以至于你希望将它们提供给其他由你编写的,甚至是其他人编写的 bat 文件。在这种情况下,应该用一个 bat 文件调用另一个 bat 文件,而不是创建一个例程。例如,你可以创建一个单独的 bat 文件来处理日志记录,并从多个其他 bat 文件中调用它。

从另一个 bat 文件执行一个 bat 文件的方式与执行内部例程有些不同。但首先,让我们回到 第八章 中如何执行编译程序。当解释器遇到一行仅包含可执行文件名的代码时,它会调用该可执行文件。因此,这条“命令”执行了程序:

C:\Executables\CompiledProg.exe

程序完成任务后,控制会返回到 bat 文件。你可能期望调用一个 bat 文件的方式与此相同,但实际上并非如此。然而,以下这一行代码确实执行了被调用的 bat 文件,但存在一个巨大的警告:

C:\Batch\CalledBat.bat

那个 bat 文件的特点很简单:控制永远不会返回到调用它的 bat 文件。整个过程在被调用的 bat 文件结束时就结束了。绝大多数情况下,你都希望控制能够返回;否则,在调用 bat 文件后继续编码就没有什么意义了。为了看到控制返回,你可以在被调用的 bat 文件之前插入 call 命令:

call C:\Batch\CalledBat.bat

总结一下,无论是调用 bat 文件还是调用其他语言编译的可执行文件,你都可以使用 call 命令或者省略它,但它们是有区别的。调用可执行文件时,这两种方法几乎是相同的。而调用另一个 bat 文件时,call 命令确保控制能够返回给调用者。如果没有这个命令,控制将永远不会返回。

由于我从未找到过不返回的 bat 文件调用的用途,我的偏好一直是对于可执行文件省略 call 命令,对于 bat 文件使用它。一个优点是,一眼就能看出被调用的是哪种类型的文件。

在我的职业生涯初期,我通过一次艰难的经历学到了调用命令(call command)在 bat 文件中的必要性,那时我无法理解为什么我的 bat 文件停止执行。没有挂起或中止的消息,它就是停止了。更复杂的是,我的故障排除自然集中在被调用的 bat 文件上。过了很久,我才注意到缺少了 call 命令,更重要的是,才理解了它的重要性。但这并不是关于 call 命令的唯一特性。

关于调用标签的备注

在上一章中,我提到过,在 goto 命令的参数中,标签名称后面的冒号是可以省略的,尽管强烈建议包含它。而在 call 命令中,调用定义内部例程的标签时,冒号是始终需要的。

这个明显的不一致可能无法理解,直到你考虑到 goto 命令只关心跳转到它自己 bat 文件中的标签,而 call 命令则能调用它自己 bat 文件内外的实体。结果就是,当没有冒号的情况下尝试调用 :MyLabel 时,会发生一些非常意外的事情:

call MyLabel

冒号本应告诉解释器调用一个内部例程,但解释器却试图调用一个外部文件。首先,它会在当前目录中查找一个可执行文件,例如 MyLabel.comMyLabel.exe。接着它会查找 MyLabel.bat 以及一些其他类型的可执行文件,仍然在当前目录中。然后它会在路径变量中的所有目录中查找任何名为 MyLabel 的可执行文件。如果没有找到这样的文件,解释器就不会再查找该名称的标签,即使 :MyLabel 是 bat 文件中的有效标签;相反,它会产生一个错误。

在使用 goto 或 call 命令跳转到标签时,始终使用冒号,至少为了保持一致性。

注意

在第九章中,我提到过,当找不到标签时,goto 命令会中止进程。call 命令则宽容一些。当其参数是无效标签时,二者都会输出错误信息,但 call 命令还会将 errorlevel 设置为 1。如果你选择不检查返回码,进程会继续执行,就像什么都没发生一样。(有关如何处理失败的 call 命令的更多细节,请参见第二十八章。)

启动 Bat 文件

有时你可能希望将 bat 文件作为新进程启动或生成。也就是说,你可能希望启动另一个 bat 文件,但不希望解释器在继续之前等待它完成。例如,你可以并行执行多个进程,从而加速整体处理时间。你还可以启动一个非关键但耗时的任务,可能是一个日志记录过程,让它自行执行。在第二十六章中,我将讨论如何自动终止和重启一个挂起的进程。为了实现这一点,我将把容易挂起的进程生成一个独立的 bat 文件,并从主 bat 文件中监控它。

要启动或生成 bat 文件,只需使用 start 命令替代 call 命令:

start C:\Batch\LaunchedBat.bat

该命令创建了第二个命令或 DOS 窗口,LaunchedBat.bat文件与启动它的 bat 文件同时执行。

exit 命令

如你所料,exit 命令会退出例程、bat 文件或整个执行过程,甚至可以设置返回码。它的功能与 goto :eof 命令重叠,但我很快会展示出一个显著的区别。

不带参数的 exit 命令会突然结束整个进程。遗憾的是,第二个 echo 命令将不会被执行:

> con echo The Meaning of Life is...
exit
> con echo ... %meaningOfLife% 

第一个 echo 命令将消息输出到控制台,但 exit 命令会在你阅读之前关闭窗口。无论 exit 命令在哪里调用——无论是在高层 bat 文件中、在调用的 bat 文件中,还是在任何类型的 bat 文件中的例程——都会发生这种情况。此命令的变体类似于使用大锤。

然而,/B 选项将 exit 命令变成了更像一只珠宝锤。文档中没有明确说明 B 代表什么,但对我来说,它代表break,即后续命令仅中断被调用的代码,无论是调用的 bat 文件,还是 bat 文件内部的例程:

exit /B

该命令仅在高层 bat 文件的主逻辑中调用时才会退出整个进程。它不会更改 errorlevel,逻辑上等同于 goto :eof。两个命令都是有效的,使用哪个通常取决于个人偏好。我的偏好是 goto :eof 命令,但仅在不需要返回码的情况下。

在第九章的开头,我提到了清单 10-3 中复现的基本中止逻辑,但将解释留到后面,现在就是解释的时机。

:Abort
 echo The Process is aborting
 exit /B 1 

清单 10-3:一个标记为:Abort 的中止例程

这个exit命令的行为与exit /B非常相似,唯一的例外是,当控制权返回到代码被调用的位置时,跟随选项的命令数字参数将成为errorlevel中的新值。简而言之,这个命令会中断批处理文件或例程,并返回退出或返回代码。在之前的示例中,返回代码是 1。如果没有检测到错误,批处理文件的主逻辑可能通过将返回代码设置为 0 来结束:

exit /B 0

如果检测到致命错误,主逻辑中的goto :Abort命令将引导解释器进入清单 10-3 中显示的终止逻辑。必须使用goto命令,因为call命令会将终止逻辑当作被调用的例程;错误级别会被设置,但控制权会返回到致命错误发生的地方。而当通过goto命令导航到标签时,并不会调用例程;它仍然被认为是在主逻辑中,exit命令会结束批处理文件,而不是调用一个例程。

为了增加灵活性,你可以为退出代码创建一个变量,并为不同的失败设置不同的值:

:Abort
 echo The Process is Aborting
 exit /B %exitCode% 

然后,这段逻辑可以通过多个goto命令在批处理文件中进行访问。

(一个真实的终止例程会比这个简单的回显命令更有趣。错误信息可能包含多行内容并包含变量,还会被写入日志文件和控制台,但为了保持对退出命令的关注,我在这里进行了简化。)

总结

在本章中,我详细介绍了调用内部例程和其他批处理文件的不同方法。你已经学会了如何带或不带返回代码从这些调用中返回,或者如何从任何地方直接中止整个过程。你还学会了如何启动或生成另一个与第一个批处理文件完全独立的批处理文件。最重要的是,你现在理解了gotocall命令之间重要而微妙的差异。简而言之,call会返回控制权并可以访问其外部,而goto则没有这种能力。

这个谜题中仍然有一个大块未解。一个调用的批处理文件可以将多个参数传递给被调用的批处理文件,而且被调用的批处理文件甚至可以设置并返回参数。这个过程比人们预期的要复杂,我将在接下来的章节中详细讲解所有的细节。

第十一章:11 参数和参数传递

在前一章中,我演示了如何通过 bat 文件调用内部例程和其他 bat 文件,但我没有讨论如何在调用逻辑和被调用逻辑之间传递数据。默认情况下,所有在调用代码中设置的变量都对被调用代码可见,反之亦然。如果代码紧密耦合,从技术上讲,不需要传递参数和接受参数,但要使其生效,两个代码集必须达成一致并使用相同的变量集。

如果您只是创建一个第二个 bat 文件来拆分一个大型项目,并且被调用的 bat 文件永远不会从其他地方调用,那么这样做是可以接受的。但为了创建更通用的代码,使得其他过程和程序员可以重复使用,参数化传递给 bat 文件和例程的数据是至关重要的。

在本章中,我将详细介绍您需要了解的所有关于参数和参数传递的知识,包括 Batch 独有的晦涩语法。您将学习如何将参数传递给 bat 文件或例程,以及如何接受返回的参数。您甚至会了解隐藏参数、如何以及为什么要调整参数,以及如何通过几次鼠标点击将参数传递给 bat 文件。

传递参数

为了演示如何传递参数和接受参数,我将编写一个简短的 bat 文件,构建一个简单的 Mad Libs 示例,这是一个在便携式电子设备普及之前,让孩子们在长时间车程中保持耐心的游戏。这个 bat 文件接受三个顺序参数(一个形容词,一个动词和一个名词),并将它们插入以下文本中,然后显示结果给用户:

蝙蝠是 _______(形容词)哺乳动物。它们在洞穴中 _______(动词)飞来飞去,但如果你站在它们下方,你可能会被 _______(名词)砸中。

我还不打算与您分享这个调用的 bat 文件,因为我首先会集中讲解如何在传递这三个参数时调用MadLibs.bat。毕竟,调用的 bat 文件并不关心香肠是如何做出来的;它只需要一个绞肉机。调用的 bat 文件只需要知道要传递的参数和预期的结果,仅此而已。(在下一部分,我将从被调用的 bat 文件 MadLibs.bat 的角度来分析这个问题。)

在第十章中,我演示了如何使用 call 命令调用另一个 bat 文件。接下来的命令正是如此操作的,不过现在,bat 文件名后面跟着三个参数,分别是形容词、动词和名词,按照这个顺序,且每个参数之间用空格分隔:

call C:\Batch\MadLibs.bat adorable fly guano

显然有人在作弊,因为写入控制台的结果对这个游戏来说太合理了:

Bats are adorable mammals. They fly around in caves,
but if you stand under them, you might get hit with guano. 

更典型的情况是,如果一个 11 岁的男孩玩这个游戏,他可能会想到以下一组代表形容词、动词和名词的词:

call C:\Batch\MadLibs.bat stinky fart poop

结果是

Bats are stinky mammals. They fart around in caves,
but if you stand under them, you might get hit with poop. 

这种方式不知为何仍然有效——至少对于一个青少年男孩来说是这样。

参数分隔符

在前面的示例中,传递的参数通过空格彼此分隔,并且与被调用的批处理文件名分开。空格无疑是最常见的分隔符,逗号是第二常用的分隔符,但分号、等号和制表符也可以作为分隔符。考虑以下两个调用命令:

call C:\Batch\MadLibs.bat adorable fly guano
call C:\Batch\MadLibs.bat,adorable;fly=guano 

两个命令在功能上是等效的,但第二个命令看起来像是故意混淆其意图的练习。

逗号分隔的数据是相当常见的(例如.csv文件的内容)。因为逗号是批处理参数分隔符的一部分,你可以将逗号分隔的数据存储在一个变量中,然后将该变量作为命令的一部分传递,像这样:

set myArgs=adorable,fly,guano 
call C:\Batch\MadLibs.bat %myArgs% 

解释器将每一段由逗号分隔的文本视为一个独立的参数(假设数据中没有其他分隔符)。这看起来可能像是一个参数,但实际上命令传递了三个参数。

参数封装

查看允许的参数分隔符列表会引发一个有趣的问题:是否可以将空格和其他分隔符作为实际的参数数据传递?是的,这完全可能,但首先要考虑以下这些(三个?)参数所带来的问题:

call C:\Batch\MadLibs.bat ad hominem took off ice cream

显然有人在故意制造麻烦,使用三个参数的双词版本。(而任何把“ad hominem”作为《疯狂填字游戏》形容词使用的人,其实比制造麻烦还更为做作。他可能是办公室里那个纠正每个人使用“参数”和“论点”错误的人。)结果是,解释器需要处理六个参数,而不是三个。

由于空格是分隔参数的字符之一,解释器将“ad”视为形容词参数,而动词参数是“hominem”,名词参数是“took”。这导致了一个毫无意义的词组,即便是按照《疯狂填字游戏》的标准来看也显得荒谬。命令的其余部分“off ice cream”变成了一个接收最多三个参数的批处理文件的第四、第五和第六个参数;它们被适当忽略,不会造成进一步的影响。

解决这个问题的方法是将每个参数用双引号括起来,这样做还有一个额外的好处,就是可读性大大提高:

call C:\Batch\MadLibs.bat "ad hominem" "took off" "ice cream"

被调用的批处理文件需要处理可能被双引号包裹的参数,稍后我会详细讲解。

通过这些设置,输出至少在语法上是有意义的,大致正确:

Bats are ad hominem mammals. They took off around in caves,
but if you stand under them, you might get hit with ice cream. 

包裹的双引号提供了另一个巨大优势;它们能够占据任何缺失参数或设置为空格的参数的位置。例如,作为一种反向思维,某人可能拒绝提供第一个参数。(毕竟,省略形容词仍然能保持语法正确,但省略名词或动词必定会破坏句子结构。)如果没有双引号,在以下示例中,第二个参数会偏移成为第一个,第三个则变成第二个。相反,即使第一个参数为空,依然传递了三个参数:

call C:\Batch\MadLibs.bat "" "hop" "fudge"

hop 和 fudge 参数正确映射到 verb 和 noun,而没有 adjective,因此输出如下:

Bats are  mammals. They hop around in caves,
but if you stand under them, you might get hit with fudge. 

双引号的包围还允许你将逗号、分号、等号甚至制表符作为参数或参数的一部分使用。

参数变量

到目前为止,我只展示了硬编码的参数,但实际上,参数通常是变量。如果你不能百分百确定没有(并且永远不会有)嵌入的空格,建议你在解析后的变量周围加上双引号:

call C:\Batch\MadLibs.bat "%arg0%" "%arg1%" "%arg2%"

当调用命令执行时,三个参数变量会被解析并传递给被调用的 bat 文件,每个参数都被双引号包围。

注意

“参数与论据”的争论:关于这两个术语之间的区别有一些争议。我见过不同的定义,但我在写作时使用的是这样一个区分: 论据 是从 调用 代码传递的, 参数 是被 被调用 代码接受的。但也有灰色地带,程序员常常谈论“传递参数”和“接收论据”。我们都知道什么意思。我不想成为那个在办公室里纠正每个人的刻板讨厌家伙,但我会尽量保持这些词的一致使用。

接受参数

让我们通过 180 度转变视角,来看一下示例 11-1,在这个示例中,被调用的 bat 文件接受三个参数,这意味着最终共享 MadLibs.bat 文件,该文件在前面的示例中生成了多轮输出。

set adjective=%~1
set verb=%~2
set noun=%~3

> con echo.
> con echo Bats are %adjective% mammals. They %verb% around in caves, 
> con echo but if you stand under them, you might get hit with %noun%.
goto :eof 

示例 11-1:MadLibs.bat 文件接受三个参数并显示一个 Mad Lib。

任何接受参数的 bat 文件应该以两种方式之一开始。它应该包含说明 bat 文件接受哪些参数的注释,或者它应该在文件顶部使用 set 命令定义这些参数,并使用明确命名的变量。对于这个 bat 文件,我选择了后一种方式。

第一个参数被描述性地命名为 adjective,而 %~1 是解析传递给 bat 文件的第一个参数的最佳语法。递增该整数可以得到第二个参数的值 %~2,并将其赋值给 verb。最后,另一个明确命名的变量 noun 被赋值为第三个参数 %~3。

解析带波浪号的参数

下一个要点既重要又微妙;如果你从 bat 文件的第一行中去掉波浪号,%1 会解析为接收到的第一个参数,无论是否有双引号。然而,%~1 会解析为去掉双引号的第一个参数——如果没有双引号需要去掉,解析后的参数保持不变,因此波浪号不会带来任何问题。其他参数也一样:%~2 解析为去掉双引号的第二个参数,%2 则解析为传递的原始参数。

波浪号的使用正是我在前一节中提到的内容,指的是被调用的批处理文件需要做些什么,以便处理可能被或可能不被双引号括起来的参数。MadLibs.bat文件显然使用了波浪号的语法,使它能够同时兼容两种情况,从而给调用批处理文件的开发者提供了便利。

唯一不会使用波浪号来解析参数的情况是当我明确希望保留双引号时,而这种情况极为罕见。使用波浪号可以让调用的批处理文件灵活地选择是否使用双引号。请考虑以下这两个功能上等价的命令:

call C:\Batch\MadLibs.bat ugly running "cell phone"
call C:\Batch\MadLibs.bat "ugly" "running" "cell phone" 

调用的批处理文件必须使用双引号来处理任何包含空格的参数,例如上面提到的第三个参数;否则,双引号不是必需的,但使用它们不会有害。我提到这两个命令在功能上是等价的,但如果MadLibs.bat没有在前面三个命令中使用波浪号,那就不成立了。

如果你觉得解析参数的语法看起来很奇怪,那你并不孤单。变量通常通过两个百分号来解析,但在这里,单个百分号后面跟着一个一位数(或波浪号和数字),别无他物。以数字开头的变量不能通过百分号解析,因此当解释器看到百分号后跟着一个数字(可能中间有一个波浪号)时,就会认为这是一个参数。一旦理解了,它确实提供了一个非常简洁的语法来解析参数。

解析整个参数列表

你可以通过在参数列表前加上百分号和星号,轻松解析整个参数列表,无论值的数量如何,这在被调用的批处理文件需要调用另一个批处理文件或例程并传递相同的参数列表时非常方便。考虑以下命令,特别是末尾的%*字符:

call C:\Batch\SecondCalledBat.bat "%arg0%" %*

该命令传递了一个由简单变量组成的参数列表,后面跟着传递到批处理文件或例程的参数列表。我已将第一个参数,即 arg0 变量,用双引号括起来,以防它包含任何嵌入的分隔符字符,如空格;这确保了解释器将该变量视为单一参数,而不是多个参数。其余的参数是传递到被调用的批处理文件的完整参数集,无论数量如何。最终,传递到批处理文件的第一个参数是调用中的第二个参数,以此类推。(剧透:当我们在第三十二章讲解面向对象设计时,我们将广泛使用这项技术。)

内部例程参数

到目前为止,我只讨论了与调用另一个 bat 文件相关的参数。幸运的是,在调用 bat 文件内部的例程时,实际上并没有什么不同。我们可以轻松地将列表 11-1 中的完整 bat 文件重写为一个内部例程。请注意,在列表 11-2 中,唯一的区别是添加了标签。

:MadLibs
 set adjective=%~1
 set verb=%~2
 set noun=%~3

 > con echo.
 > con echo Bats are %adjective% mammals. They %verb% around in caves, 
 > con echo but if you stand under them, you might get hit with %noun%.
 goto :eof 

列表 11-2::MadLibs 例程接受三个参数并显示一个 Mad Lib。

调用例程的 call 命令具有相同的参数,并且双引号的处理方式也相同:

call :MadLibs "ad hominem" "took off" "ice cream"

唯一的区别是调用了一个标签,而不是另一个 bat 文件。其他一切都完全一样。

隐藏参数

许多现代语言将传入的参数列表视为一个数组,特别是一个零偏移的数组。再加上批处理文件意外地使用零偏移来进行子字符串提取,许多程序员曾经尝试在 %~0 中寻找一个参数(包括我自己)。结果可能让人困惑,正如在列表 11-2 中展示的,添加以下 echo 命令来说明这一点:

:MadLibs
 > con echo Parm 0 is %~0
 set adjective=%~1 

写入控制台的文本包含正在执行的例程的名称:

Parm 0 is :MadLibs

在我解释到底发生了什么之前,章节前面提到过,Mad Libs 的逻辑在一个外部文件中,并像这样被调用:

call C:\Batch\MadLibs.bat "adorable" "fly" "guano"

将之前添加到 :MadLibs 例程中的相同 echo 命令,添加到列表 11-1 中的被调用 bat 文件中,结果如下:

Parm 0 is C:\Batch\MadLibs.bat

我不太愿意称 %0 为第一个参数。它更像是一个隐藏参数,位于第一个参数之前。从这个例子来看,很明显隐藏的参数是正在执行的例程或 bat 文件的名称,但它来自哪里呢?

一个 bat 文件通常是 call 命令的第一个参数,并且这个参数会作为 %0 传递给被调用的 bat 文件本身。事实上,%0 匹配的是 call 命令中路径和文件名(或标签名)的大小写,而不是实际路径和文件名的大小写。如果路径不是参数的一部分,那么它就不是 %0 的一部分。此外,如果 call 命令中路径和文件名被双引号括起来,那么 %0 也会被双引号括起来。因此,通常最好像处理其他参数一样使用波浪符号来解析它,即 %~0。

隐藏的参数在所有 bat 文件中都无处不在,不仅仅是被调用的 bat 文件或例程。即使是一个高层次的 bat 文件,那个你可能通过双击打开的文件,其路径和文件名也包含在隐藏的参数中。我们通常不会考虑打开 bat 文件时发生了什么,但这并不是魔法。Windows 执行一个 call 命令,并将 bat 文件作为唯一参数传递给你。结果是 %0 被解析为 bat 文件的路径和名称,并被双引号括起来。

这个隐藏参数有很多用途。对于错误处理,它提供了一种简单的方式来记录错误发生的例程。如果 bat 文件被移动到不同的目录、计算机或域中,隐藏参数可以帮助 bat 文件知道它的位置。当我们介绍到 for 命令时,有一种相对简单的方法可以从一个包含路径和文件名的变量中仅获取路径。然后,你可以以多种方式使用它。例如,你可以将输出文件放入 bat 文件所在目录下的子文件夹,或者 bat 文件可以根据它所处的位置执行不同的操作。它是 Batch 中一个非常有用且隐藏的特性。

移动参数

如果第一个参数通过%~1 解析,第九个参数通过%~9 解析,那么第 10 个参数通过%~10 解析似乎是合理的。但实际上不行,解释器只识别参数 0 到 9,或者单个数字的序数。要是编译器能够温和地提醒你%~10 不是有效参数那该多好,但同样的,这就是 Batch。为了演示在解析这个参数时可能出现的问题,可以参考示例 11-3 中的一个双数字参数列表。例程传递给它的是字母表的前一半,目的是将五个选定的字母写入控制台。

 call :Alphabet A B C D E F G H I J K L M
 goto :eof

:Alphabet
 > con echo Parm 1 is "%~1"
 > con echo Parm 2 is "%~2"
 > con echo Parm 9 is "%~9"
 > con echo Parm 10 is "%~10"
 > con echo Parm 13 is "%~13"
 goto :eof 

示例 11-3:第一次尝试编写例程:Alphabet,以显示五个参数

前三个看起来没问题,但第 10 和第 13 个参数似乎是无效的:

Parm 1 is "A"
Parm 2 is "B"
Parm 9 is "I"
Parm 10 is "A0"
Parm 13 is "A3" 

当我们人类看到%~13 时,我们看到的是数字 13,可能会期待第 13 个参数 M 被解析出来。但解释器从来没有被误认为是人工智能,更不可能是人类智慧。当它遇到%~13 时,它看到的是第一个参数%~1,解析为 A,后面跟着硬编码的值 3,结果是 A3。类似地,%~10 会解析为第一个参数,但会附加一个零,结果是 A0。

然而,Batch 并不限于只有九个参数。事实上,我曾见过传递了几十个参数,因为没有实际的限制(除了任何命令中最大字符数为 8,191 的限制)。要访问第 10 个及以后的参数,你需要使用 shift 命令。为了演示,让我们修正示例 11-3 中的:Alphabet 例程:

:Alphabet
 > con echo Parm 1 is "%~1"
 > con echo Parm 2 is "%~2"
 > con echo Parm 9 is "%~9"
 shift
 > con echo Parm 10 is "%~9"
 shift & shift & shift
 > con echo Parm 13 is "%~9"
 goto :eof 

在 shift 命令执行之前,第 1、2 和 9 号参数的解析结果没有变化,shift 命令会将每个参数向左移动一个位置。第二个参数变成第一个,第三个变成第二个,第 10 个参数变成第九个。虽然这可能直觉上让人不太明白,但在 shift 命令之后,%~9 会解析为第 10 个参数。再经过三次 shift 命令,%~9 会解析为 M,第 13 个参数,从而产生期望的输出:

Parm 1 is "A"
Parm 2 is "B"
Parm 9 is "I"
Parm 10 is "J"
Parm 13 is "M" 

在第二章中介绍命令分隔符(&)时,我提到过应该谨慎使用它。在其他情况下,它可能会弄乱代码,但 shift 是一个非常简单和简洁的命令,将三个命令写在一行实际上可以清理代码。

请注意,shift 命令不会影响%*的解析。这种奇怪的语法无论执行了多少个 shift 命令,仍然会解析为完整且原始的参数列表。

移动参数引发了一个有趣的问题。在执行 shift 命令后,使用%0 解析的路径和文件名会发生什么?简短的回答是,它会被清除并替换为第一个参数,至少在默认情况下是这样。但是 shift 命令有一个独立的选项,这个选项定义了在 shift 时哪个参数会被丢弃;所有在该参数之前的参数都会被保留。它的格式与我们之前描述的选项有所不同。/n 选项丢弃第n个参数,因此以下命令丢弃第一个参数并保留%0:

shift /1

参数 0,或者说隐藏参数,保持不变。参数 1 被丢弃,而参数 2 向前滑动成为参数 1,第三个参数变成第二个,以此类推。通过一个小改动,以下命令保留了前四个参数(以及隐藏参数),同时丢弃了第五个参数:

shift /5

参数 6 被移动到参数 5,其他参数也相应地移动。

这个选项接受参数 0 到 8——尽管没有必要使用/0,因为这是默认行为。由于某些未知原因,/9 是无效的。别问。

返回参数

你现在知道如何将参数传递给例程和其他批处理文件,并将接收到的参数写入控制台,但在真实的编码世界中,许多例程接受一些参数并将其他参数传回调用者。由于在被调用进程中的批处理变量是全局可用的,一些程序员干脆在例程中设置一个硬编码的变量名,并在其他地方使用它。真是些庸俗的人!一种更优雅、灵活的解决方案是允许调用者定义返回变量的名称。

注意

我使用庸俗一词作为贬义词,但并非没有犹豫。今天,这个词是指对美学和艺术无感的粗暴个体,而我正是以此含义使用它。但是,如果他们有现代代表性,我们不会诋毁整个民族。甚至连“吉普赛蛾”也正在被重新命名,正确地说是为了不冒犯某一群体,尽管这只蛾仍然可以被肆意诽谤。我相信当时有好有坏的庸俗人,但由于他们在两千五百多年前被完全消灭,并且在三大世界宗教的经文中被视为不祥之物,他们的名字被轻蔑地使用,而几乎没有任何悔意。近几十年来,甚至我们的远亲——尼安德特人,形象的恢复比庸俗人更加正面。

以下示例包含三个参数;前两个是要相加的数字,第三个是加法的结果。这个例程可能很短,但由于其复杂性,确实需要注释:

rem - Parm 3 is the sum of Parms 1 and 2
 :Add
  set /A %~3 = %~1 + %~2
  goto :eof 

在 set /A 命令的等号右侧,前两个参数被解析并相加。等号左侧——即被设置的部分——是神秘的文本%~3。这里本应是一个变量名,但实际上解析的是第三个参数。set /A 命令实际上是将一个命名变量设置为两个数字的和。最重要的是,这个变量名就是传入此例程的第三个参数。这种技术不难理解,但却不直观,并且在其他语言中并不常见。我见过很多程序员被这个弄得一头雾水。

一个对该例程的调用示例可以澄清发生了什么。以下调用命令传递了两个数字和一个变量名,其中双引号是可选的:

call :Add "7" "8" "sum"

在此调用之后,sum 变量的值为 15。我不能过分强调的是,在这种技术中,调用代码定义了要返回的变量名。为了进一步说明这一点,本例中的三个调用都是对同一个:Add 例程的调用:

call :Add "5" "8" "sum1"
call :Add "9" "11" "sum2"
call :Add "%sum1%" "%sum2%" "sum4Nbrs"
> con echo The sum of all four numbers is %sum4Nbrs%. 

前两个调用使用了硬编码的数字,分别返回 sum1 和 sum2 变量。这两个变量都被解析并作为参数传递到第三个调用中,返回 sum4Nbrs。

请注意,在前两个调用中,sum1 和 sum2 没有带有分隔符的百分号,因为变量名被传递。但是在第三个调用中,我正在解析它们,因为它们的值被传递了——这些值是在前两个调用中赋值的。最后,echo 命令将这些内容写入控制台:

The sum of all four numbers is 33.

一个调用中的输出使用的同一个变量,在另一个调用中作为输入,但这个变量是否可以在单个调用中同时用于这两种用途呢?

一个变量作为输入和输出

一个单独的变量可以同时作为例程或批处理文件的输入和输出。为了设置这个,想象一个求整数平方的例程。第一个参数是输入,第二个参数是输出——简单来说,就是输入乘以它本身:

:Square
 set /A %~2 = %~1 * %~1
 goto :eof 

该例程没有展示我们尚未讨论的内容,尽管%~1 的输入被使用了两次。调用代码定义了输出变量,并在例程中将其解析为%~2。

调用代码可以使用两个不同的变量来作为输入和输出。但是假设你想用一个变量的平方来替换它的值。为了实现这一点,你可以将它的值作为第一个参数传递——请注意,值两侧有百分号——并将变量名作为第二个参数传递,而不加百分号:

call :Square %nbr% nbr

如果在调用之前,nbr 被设置为 5,那么调用之后它会被设置为 25。:Square 例程提供了一些真正的灵活性,可以与两个不同的变量或相同的变量一起使用。

让我们再对这段代码做一次小改动,写一个只有一个参数的例程,该参数既是输入也是输出。该参数是一个包含数字值的变量,例程将该值替换为其平方,这使得调用例程变得更加简便:

 call :SquareMe nbr
 > con echo The squared number is %nbr%.
 goto :eof

:SquareMe
 set /A %~1 = !%~1! * !%~1!
 goto :eof 

首先,注意到新例程(或标签)的名称。更重要的是,注意到该例程接受一个单一的参数,即整数的未解析变量名——而不是整数值。

将:SquareMe 与:Square 例程进行比较,set /A 命令有两个关键更新。首先,不再将%~1 自乘,而是将!%1!作为每个操作数。在之前的例子中,接受的是一个值,而现在输入的是一个变量名,因此%1 解析为变量名,并且通过延迟扩展,感叹号将该名称解析为整数值。(我是否提到过延迟扩展的真正强大之处?它的应用仅受限于你的想象力。)

第二个变化是积被分配给第一个参数%1,而不是第二个参数%2。记住,在:SquareMe 中,唯一的参数现在是变量名。结果是,调用此例程会更改变量的值,即使该变量在例程中没有明确提到。

注意

我已经通过例程演示了返回参数,但从被调用的 bat 文件返回参数的方式几乎是一样的。实际上,如果被调用的 bat 文件没有通过 setlocal 和 endlocal 命令限制作用域,那么它的工作方式完全相同。如果它限制了作用域,那么有一种特殊技巧可以让一个或多个变量在 endlocal 之后存活,我将在第十六章中详细说明。

输入参数列表的长度变化

让我们构建一个更接近实际的例程,使用我们目前为止在本章中学到的内容。以下例程接受一系列一对多的数字(没有合理的限制),并返回两个变量,一个填充为输入数字的和,另一个填充为输入数字的积:

rem - Parm 1 = Sum of multiple numbers, returned parm
rem - Parm 2 = Product of multiple numbers, returned parm
rem - Parms 3+ = Set of numbers to add and multiply
 :Arithmetic
  set %~1=0
  set %~2=1
 :NextParm
  set /A %~1 += %~3
  set /A %~2 *= %~3
  shift /3
  if "%~3" neq ""  goto :NextParm
  goto :eof 

由于我们不知道在给定调用中会有多少输入参数,因此将这些参数放在参数列表的末尾,输出参数占据前两个位置。(没人说输入必须在输出之前。)由于其复杂性,:Arithmetic 例程包含了许多必要的注释,用于定义参数列表。

和,表示为%~1,因为它还没有实际的变量名,初始化为 0。同样,积,%~2,初始化为 1。(对于我的数学迷朋友们来说,这分别被称为加法和乘法的单位。)暂时忽略第二个标签:NextParm,集中注意力在接下来的两个 set /A 命令上。第一个命令将第一个参数,即和,设置为它本身加上列表中的第一个输入数字%3,它是第三个参数。类似地,接下来的命令将第二个参数,即积,设置为它本身乘以列表中的第一个数字,同样是%3。

接下来的 shift 命令非常关键。输入参数的数量是未知的,所以我们希望丢弃刚刚使用过的第三个参数,并将其后面的参数向左移动,而不干扰前两个参数,这两个参数在本例中也是返回参数。/3 选项能够无缝地实现这一点。接下来,我们查看新生成的第三个参数,看看它是否已被填充。请记住,这个参数在原始列表中是第二个输入数字,或者说在 shift 之前是第四个总体参数。如果它已经被填充,我们将返回之前忽略过的 :NextParm 标签。现在,这两个 set /A 命令分别将第二个输入参数加到和中,并乘到积中。

这个过程会一直重复,直到数字列表(无论其长度如何)用完为止,此时 if 命令的条件语句为假,控制权将转移到后续的 goto :eof 命令,例程将返回前两个参数。

以下代码测试了这个新例程:

call :Arithmetic sum product 5 8 9 11
> con echo The sum is %sum%.
> con echo The product is %product%. 

结果文本将写入控制台:

The sum is 33.
The product is 3960. 

顺便说一下,如果这个结构看起来很熟悉,那是因为它是来自第九章的 Batch do...while 命令的示例。至少假设有一个输入参数,并且只要存在更多的参数,例程就会执行算术运算,或者说是 只要

拖放参数

通过几个鼠标点击,你可以将任何文件的路径和名称,或者多个文件,传递给 bat 文件。这是当我学习 Batch 时让我和其他许多人都感到惊讶的事情。只需右键单击任何文件并选择 复制;然后右键单击 bat 文件进行执行并选择 粘贴。bat 文件执行时,唯一的参数是复制文件的路径和文件名(如果路径或文件名包含空格,则会用双引号括起来)。另外,你还可以选择文件,拖动并将其放到 bat 文件上,结果是一样的。

这种技术适用于任何数量的文件。如果你将 n 个文件复制到 bat 文件上,n 个以空格分隔的参数将被传递——每个参数都是一个路径和文件名。如果你将 n 个文件拖放到 bat 文件上,情况也是一样,甚至适用于目录。

起初,将文件拖放到 bat 文件上可能看起来像是一个简单的 Batch 小把戏,但它的用途非常广泛。你可以设计一个包装的 bat 文件来处理单个文件。也许一个程序正在执行,并以一个文件作为输入;该程序可能会将文件转换为不同的格式或添加尾部记录。也许 Batch 代码只是简单地重命名文件或给现有的文件名添加扩展名。重要的是,bat 文件正在对输入文件执行某些操作。

一种可能的操作是简单地将输入文件复制到另一个目录。考虑以下两个命令,它们构成了 BackUpOneFile.bat 的全部内容:

xcopy %1 D:\Some\Deep\Hard\To\Reach\Folder\ /F /Y
pause 

xcopy 命令的第一个参数 %1 解析为 bat 文件的第一个参数,该参数将是任何拖放到 BackUpOneFile.bat 上的文件的路径和文件名。该命令将输入文件复制到目标路径,即 *D:* 驱动器中的一个深层、难以触及的文件夹。(我故意保留了 %1 语法中的双引号,而不是 %~1,因为如果路径或文件名包含空格,xcopy 命令需要双引号。)最后,pause 命令仅仅是让窗口保持打开状态,以便用户查看复制结果。

最终,如果你将 BackUpOneFile.bat 放在 Windows 桌面上,你可以快速将任何一个文件拖放到它上面,bat 文件将把输入文件复制到所需的目录,而无需你导航到该目录。

为了在没有这种拖放技术的情况下将参数传递给 bat 文件,你必须输入一个调用命令(到另一个 bat 文件或命令提示符中),并将输入文件作为参数键入。但通过这种技术,任何用户,甚至是非程序员,都可以轻松地运行 bat 文件,而无需键盘输入。你可以为位于网络上任何位置的 bat 文件创建一个 Windows 快捷方式,从而隐藏源 Batch 代码,使用户更难意外删除或修改它。

当路径和文件名作为参数传递给 bat 文件时,Batch 提供了一种简便的方法来获取关于文件的各种信息,例如最后修改日期和时间、大小、属性、路径、扩展名、文件名等。我将在探索 第十七章 中的 for 命令后详细讨论这一点。for 命令还将解锁循环功能,这样如果我们将 n 个文件拖放到 bat 文件中,系统就可以依次处理每个文件。

总结

本章以及 第八章 到 第十章 希望能够让你对 Batch 的理解超越单一的无规律顺序执行的 bat 文件。在不久的过去,你的 bat 文件无法调用可执行文件或其他 bat 文件,解释器只会按顺序执行每个命令,直到遇到 bat 文件的结尾。但是现在,你有了开始构建有趣且复杂的 bat 文件的工具。

在本章中,你学习了如何将参数传递给 bat 文件和程序,并在调用的 bat 文件中接收它们作为参数。我详细介绍了如何分隔参数,甚至如何将分隔符本身作为参数传递。你还初步了解了隐藏参数——Batch 世界中的“雪人”。我展示了如何传递回调用者定义的变量,其中包含返回值。你甚至可能学会了如何玩疯狂填字游戏。

在下一章,我将通过介绍各种由解释器和你自己创建的输出,进一步拓展这一领域。我将向你展示如何捕获 Batch 命令的输出,以及如何创建自己的文件。

第十二章:12 输出、重定向和管道

现在你可以使用 Batch 做很多事情,比如设置、重置和查询各种数据类型的变量,调用例程和其他 bat 文件,执行算术运算;然而,你学到的大部分内容不会产生持久的影响(除了文件移动和能够持久设置变量)。在 bat 文件的短暂执行结束时,所有被操作的位和字节可能会消失在空气中,就好像这个 bat 文件从未存在过一样。

我将留待以后再讨论 Batch 的形而上学问题,但每当任何类型的代码被执行时,目的就是产生某种变化。一些编码人员仅将 Batch 用作一个包装器,在调用可执行文件之前设置一些变量来实现这种变化,但 Batch 能做的远不止这些。在本章中,我将讨论两种输出类型:解释器输出(stdout 和 stderr)和你,编码人员的输出。了解了这一区别后,你将能够将不同类型的输出写入控制台、新文件和现有文件。通过这种方式,你可以将所有那些变量、调用和算术运算的结果存储在你的计算机上。

讨论这两种输出类型会引出几个相关且有趣的话题。其中一个是将任何 Batch 命令的输出重定向到文件,另一个是管道技术,将一个命令的输出传递到另一个命令。也许最重要的是,你将学会如何管理快速滚动的控制台内容——即要么保存它,要么抑制它。

解释器生成的输出与编码器生成的输出

当你打开或执行一个 bat 文件时,它会生成两种类型的输出:解释器生成的输出和编码器生成的输出。为了完全清楚,解释器技术上生成了所有的输出,但其中一部分输出是你,编码员,通过命令写入控制台或文件所产生的。这就是编码员生成的输出。作为运行的副产品,解释器还会生成你没有明确要求的输出,这就是解释器生成的输出

所有 Batch 命令都会生成解释器输出;少数命令还会生成编码器输出。默认情况下,Batch 会将两种类型的输出写入控制台,如果 bat 文件稍微复杂一些,文本滚动得太快,以至于无法阅读。

echo 命令就是一个很好的例子,它可以生成两种类型的输出。在最简单的表现形式中,它会将所有的参数写入控制台。考虑以下命令:

echo Greetings, Earthlings.

执行此命令会将 清单 12-1 中所示的输出写入控制台。

C:\Batch>echo Greetings, Earthlings. 
Greetings, Earthlings. 

清单 12-1:解释器和编码器生成的输出

所需的文本“Greetings, Earthlings.”会被输出,但它并不孤单,会出现两次,且由于某种原因,当前目录会被附加到第一行之前。(假设本章中当前目录为*C:\Batch*。)

Listing 12-1 中的两行表示完全不同类型的输出。第二行来自编码者——通过 echo 命令写入控制台的消息。第一行由解释器生成。它不是 echo 命令的输出;它是 echo 命令的执行报告

这种区别既微妙又重要。至少,解释器会通过写入每个命令并附加提示符来记录每个命令的执行。默认情况下,提示符是当前目录,后跟一个大于符号。许多命令会产生额外的解释器生成的输出,比如 xcopy 命令,它通常会列出已复制的文件。

显然,交织的输出很乱,如果这个例子看起来不乱,那是因为我只展示了一个命令的输出。很快,我将向你展示如何通过将这些不同的输出发送到不同的目标来清理控制台,但首先你需要更好地理解由解释器生成的输出,实际上它是两个不同的输出。

stdout 和 stderr

批处理将每一部分由解释器生成的输出写入两个数据流之一。数据流是从源(在本例中是解释器)到目标的传输信息,默认情况下目标是控制台。每个数据流由文件描述符表示,而最大的数据流是被描述为stdout的流,发音为标准输出(或较少使用的标准输出)。事实上,唯一不在 stdout 中的解释器生成的输出是错误消息,它们被写入文件描述符stderr,发音为标准错误

stdout 数据流可能会变得复杂且难以理解,但它通常对你帮助极大,能帮助你确定批处理文件执行过程中到底发生了什么。你通常可以看到 if 命令的结果、哪些文件被创建等等。为了演示,以下 del 命令删除一个文件,接着是一个 set 命令,它捕获 errorlevel 作为返回代码:

del C:\Batch\DeleteMe.txt 
set rc=%errorlevel% 

这里没有产生由编码器生成的输出;所有输出都来自解释器。

如果存在DeleteMe.txt文件,前面的代码会删除它并将以下内容写入 stdout:

C:\Batch>del C:\Batch\DeleteMe.txt   

C:\Batch>set rc=0 

大多数变量在 stdout 中解析,比如在这个例子中,errorlevel 被解析为 0。(令人沮丧的是,解释器在某些情况下无法完全解析变量,比如在使用延迟扩展时。更多细节见第三十一章。)

标准输出(stdout)中间可以夹杂第二种解释器生成的输出,即写入到 stderr 数据流的输出,但仅在发生错误时才会有输出。例如,如果 del 命令中的文件不存在,解释器会输出如下内容:

C:\Batch>del C:\Batch\DeleteMe.txt   
Could Not Find C:\Batch\DeleteMe.txt   

C:\Batch>set rc=0 

第一行和最后一行被写入标准输出(stdout),但中间一行表示文件找不到的信息则写入标准错误输出(stderr)。理解这一点非常重要:错误消息被写入 stderr,而所有其他解释器生成的输出都会写入 stdout。为了全面了解批处理文件执行过程中发生的情况,两个输出都需要,并且除非我们采取措施干预,否则它们都会写入控制台。

(顺便说一下,别纠结于为什么即使生成了错误消息,返回代码仍然是 0。但如果非要解释的话,del 命令完成后文件不存在,从某种平凡的意义上来说,它是成功的,或者这算是个 bug。)

写入文件

创建、写入和追加文件是大多数编程语言的基本功能,而批处理(Batch)允许你构建包含编码器和解释器生成输出的文件。直到现在,你所看到的所有输出都被写入到控制台,并且当批处理文件完成时,窗口会关闭。

通常,你可能希望创建在批处理文件执行完毕后仍然存在的文件。你可以将数据写入文件,以便作为可执行文件或另一个批处理文件的输入。如果我想记录其他人运行我的批处理文件的频率,我可以设置它将一条记录写入到一个中央日志文件中,记录何时以及在哪个服务器上运行了该文件。你甚至可以生成报告,并将解释器生成的输出捕获到文件中,这样可以为你提供关于批处理文件执行过程中发生的事情的良好审计跟踪。

来自编码器生成的输出

我已经展示了如何使用> con 语法将文本写入控制台。通过类似的语法,我们可以通过两个命令写入文件:熟悉的 echo 命令写入一条记录,而不那么熟悉的 type 命令将整个文件写入另一个文件。

写入记录到文件

让我们回到外星人。与其通过控制台向我们问好,它们可能想把问候语写入一个简单的文本文件中,它们可以通过一行代码来实现:

echo Greetings, Earthlings.> C:\Batch\ET.txt

这行代码有四个不同的元素。第一个是 echo 命令,第二个是命令的参数:Greetings, Earthlings. 文本。第三个元素是大于符号(>),即重定向字符。这个字符将前面的命令输出重定向到目标,目标是第四个元素:C:\Batch\ET.txt 文件。将它们结合起来,前面的语句将问候语写入文本文件。

以下的替代语法执行相同的任务,但因为信息不再紧贴着大于符号,所以更容易阅读。我将重定向符号移到了前面,后面跟着目标:

> C:\Batch\ET.txt  echo Greetings, Earthlings.

如果你将此示例中的路径和文件名替换为 con,你将会认出这种常见的语法来写入控制台。

以下示例演示了这种语法的优势。如果外星人有多行信息需要传递,他们可以将目标文件路径和名称设置为变量,并在后续的 echo 命令中使用它。这种语法允许将多个重定向、目标和 echo 命令按顺序排列,使得更容易阅读写入文件的内容:

set alienFile=C:\Batch\ET.txt 
>  %alienFile%  echo Greetings, Earthlings.
>> %alienFile%  echo.
>> %alienFile%  echo Take us to your leader. 

在我们继续之前,我在之前的代码中悄悄加入了几个微妙但重要的功能。第一个 echo 命令使用了单一的大于符号来进行重定向,而后续的命令则每个使用了两个大于符号。单字符操作符会创建一个新的文件并写入内容,如果该文件已存在,则会删除它。两个字符操作符则会将内容追加到现有文件中。一个新手常犯的错误是对多个命令使用单一的大于符号,结果让初学者困惑,为什么只有最后一个命令有效,实际上每个命令都有效,只不过每次都会清空文件并写入一行文本。

另一个重要特性是 echo 命令后面紧跟一个点;它会写入一个空行,而不是一个点。执行完此代码后,ET.txt 的完整内容包括以下三行:

Greetings, Earthlings.

Take us to your leader. 

如果你需要在一行中写入一个单独的点,记得在 echo 和点之间留一个空格。

写入文件到文件

另一个你可以与重定向结合使用的有用命令是 type 命令。单独使用时,该命令会将文件的完整内容写入标准输出和控制台。结合重定向时,它可以将该文件的完整内容插入到另一个文件中。以下示例将 DetailRecs.txt 的内容写入 OutFile.txt,并通过两个 echo 命令在前面添加一个头记录,在后面添加一个尾记录:

set outFil=C:\Batch\OutFile.txt
>  %outFil%  echo This is a Header Record
>> %outFil%  type C:\Batch\DetailRecs.txt
>> %outFil%  echo This is a Trailer Record 

请注意,只有第一个 echo 命令使用了单一的重定向符号,从而确保它是真正的头记录。

来自解释器生成的输出

你现在知道,控制台上滚动的大多数杂乱信息是标准输出(stdout),可能还包含一些标准错误(stderr)和程序生成的输出。你也可以控制程序生成的输出的目标(控制台或文件)。缺失的一部分是如何处理解释器生成的输出。默认情况下,它会被发送到控制台,但通过一些努力,你可以将解释器生成的所有内容写入一个文件,通常称为 跟踪文件

在上一节中,你学会了如何将 echo 和 type 命令的输出重定向到文件,但你可以将任何命令的输出重定向,尤其是 call 命令。在第十章中,我介绍了在 bat 文件中调用例程的概念,因此你可以创建一个包含 bat 文件主要逻辑的例程,并在文件的顶部调用它。这里的新内容是重定向操作符和在下面示例中追加到第一行的追踪文件:

 @call :GetTrace > C:\Batch\Trace.txt
 pause
 goto :eof

:GetTrace
 > con echo Greetings, Earthlings.
 > con echo.
 > con echo Take us to your leader.
 goto :eof 

这是一个完整的 bat 文件,它将问候语写入控制台并捕获追踪文件。

在第一个命令前加上@符号可以抑制 call 命令本身在控制台的显示,但不会抑制 call 命令的输出。关键的是,call 命令的输出是从被调用的例程中出来的 stdout,而该输出通过第一个命令中的大于符号被重定向,避免显示在控制台并转到追踪文件。注意,文件底部的问候语明确被发送到控制台,每个 echo 命令前都有> con 前缀。如果没有重定向,问候语将与其余的 stdout 一起写入追踪文件。

每个数据流都有一个引用或数字句柄,你可以用最少的按键引用其中之一。stdout 的引用是 1,将该引用号放在重定向符号之前,可以明确地将 stdout 重定向到追踪文件。但默认情况下,stdout 是通过大于符号(>)单独重定向的,因此以下两个命令在功能上是等效的:

@call :GetTrace > C:\Batch\Trace.txt
@call :GetTrace 1> C:\Batch\Trace.txt 

在这两个命令中,仅将 stdout 写入追踪文件,stderr 中的任何错误消息将显示在控制台上。

stderr 数据流由 2 引用,因此你可以在重定向符号之前通过修改一个字节来重定向这些错误消息:

@call :GetTrace 2> C:\Batch\Trace.txt

你甚至可以将每个数据流,stdout 和 stderr,同时重定向到完全不同的文件:

@call :GetTrace 1> C:\Batch\stdout.txt 2> C:\Batch\stderr.txt

在实际操作中,分离数据流很少有用,因为任何错误消息都不会与生成它们的命令关联。

一个远远优于此的解决方案,如 Listing 12-2 所示,是将两个输出都写入追踪文件,且通过在命令末尾使用特别晦涩的语法,其中 2 和 1 分别代表 stderr 和 stdout,来实现这一点。

@call :GetTrace > C:\Batch\Trace.txt 2>&1

Listing 12-2:将 stdout 和 stderr 重定向到追踪文件的理想技巧

如你所知,&符号是用于在单行中执行两个命令的命令分隔符。但在 Listing 12-2 中所示的方式使用时,解释器将&符号视为重定向语法的一部分。记住这一点作为另一个 bat 的警告,不要问为什么,但这是捕获追踪文件的最佳技巧。

抑制 stdout 和 stderr

你可以观察到 stdout 和 stderr 在控制台上滚动,或者你可以将这两个数据流保存到跟踪文件中。在其他情况下,这些数据根本不需要。有时为每次执行一个稳定且频繁运行的过程创建日志可能不值得占用磁盘空间,而且你可能不希望 stdout 和 stderr 输出到控制台,因为你希望控制台对任何程序员生成的输出保持整洁。在这种情况下,你将希望彻底抑制解释器生成的输出。有两种技巧可以做到;其中一种简单但只适用于 stdout,而稍微复杂的技巧也适用于 stderr。

@echo off 技巧

抑制 stdout 的简单技巧是使用 @echo off 命令。根据你对 echo 命令的了解,你可能会预期这个特定的命令会将内容输出到 stdout。毕竟,echo Hello 会将 Hello 输出到 stdout。通常,echo 会输出它的参数,但有两个例外。

off 参数指示解释器抑制(或关闭)stdout,on 参数则将其重新打开,但有一个限制。用于抑制 stdout 的 echo 命令本身是写入 stdout 的。幸运的是,Batch 允许通过在命令前加上 at 符号 (@) 来抑制单个命令对 stdout 的贡献。因此,抑制 stdout 的命令本身被抑制写入 stdout 为 @echo off。我在第二章中悄悄介绍了这个技巧来清理控制台,但并未做深入解释,今天来讲解(现在是时候了)。

如果外星人要通过控制台与我们沟通,这个简单的 bat 文件就能派上用场:

@echo off
echo Greetings, Earthlings.
echo.
pause 

第一个 echo 命令抑制了 stdout,以保持控制台的整洁和可读性。尝试去掉这一行,看看差异。效果很差;每个 echo 命令都会将两种类型的输出都发送到控制台。使用这个命令后,第二个 echo 命令仅将它的参数写入控制台。紧跟着点号的 echo 命令会写出一个空行。最后,pause 命令保持窗口打开,避免它迅速消失。最终的结果是:

Greetings, Earthlings.

Press any key to continue ... 

按下任意键,pause 命令允许 bat 文件继续执行并关闭窗口。在第三章中,我提到我在每个高级 bat 文件的开头都会使用 setlocal 命令,以启用命令扩展和延迟扩展。这个 echo 命令是唯一可能出现在 setlocal 之前的命令,从而保持控制台的整洁。不过,你也可以在 setlocal 命令前面加上一个 at 符号,以抑制其从 stdout 的执行。

作为附带说明,每个初学者 Batch 编程者可能会试图在 echo 命令后仅通过空格写出一行空白,即便这只是个意外。但这个命令只是写出 echo 的状态,而状态只有开或关。例如,在 bat 文件的开头执行以下两行,会将 ECHO is off. 输出到控制台:

echo off
> con echo 

同样,用 echo on 替换第一行会激活 stdout,输出为 ECHO is on。

@echo off 技巧还有一个注意点。尽管 stdout 被抑制,stderr 不受影响,这意味着任何未重定向的错误信息会在控制台上显示,且几乎没有上下文。

重定向到 nul 的技巧

抑制所有解释器生成的输出的最佳技巧是将 stdout 和 stderr 重定向到第七章中介绍的nul文件。无论写入其中什么内容,这个文件始终为空,因此它有点像是一个 Batch 垃圾接收器。例如,将清单 12-2 中的 call 命令重新编写为将 stdout 和 stderr 发送到nul而不是跟踪文件,有效地抑制了所有解释器生成的输出:

@call :GetTrace > nul 2>&1

然而,例程的名称现在已经不准确了。(如果需要,重新命名标签为:SuppressTrace。)

这个技巧确实需要你为主线逻辑创建一个例程,但它非常有效,且相对简单,你可以用它来抑制所有被调用的 bat 文件和例程生成的输出。前导的@符号甚至可以将 call 命令本身从 stdout 中抑制。

你可以使用这种技巧来抑制任何命令的输出。例如,以下代码执行编译后的程序,同时简单地丢弃其命令行输出:

> nul SomeProgramWithUnwantedOutput.exe

在程序执行前添加(或甚至附加)重定向到nul文件,可以很好地解决这个问题。

stdout 中的备注

我对 stdout 还有最后一点说明。在第二章中,我介绍了 rem 命令,它是将备注或注释简单地写入代码中的一种方式。还有另一种语法,它对 stdout 有影响。

使用 rem 命令生成的备注会被写入 stdout。然而,代码中任何以两个冒号(::)开头的行也会被视为备注,但它是一个隐藏的备注,且会从 stdout 中被抑制。请看这两个有效的备注:

rem This is a Remark shared to stdout.
::This is a Top Secret Remark not meant for the Hoi Polloi. 

你只会在 stdout 中看到第一个备注。还要注意,双冒号前不需要空格。 在第九章中,我提到标签必须以冒号开头,但第二个字符必须是不同的字符。这是因为双冒号表示隐藏的备注。

隐藏的备注并没有什么不当之处。解释代码的备注在 stdout 中可能很有用,但其他备注可能会让它变得杂乱。对于程序员来说,这是一个很好的技巧,可以在代码中仅为自己保留注释。例如,始终保持源代码修订的详细历史是件好事,但这些细节可能会把跟踪文件弄得一团糟。如果是这样,可以为此类备注使用双冒号。

任何命令的重定向

我已经展示过如何通过重定向 echotypecall 命令将输出写入文件,但这只是三个例子。你可以重定向任何 Batch 命令的输出。举个例子,你可以将以下 xcopy 命令的输出发送到一个日志文件:

set copyLog=C:\Batch\Copy.log
>> %copyLog% xcopy C:\Batch\*.dat D:\Backup\ /Y /F 

无论是否重定向,.dat 文件都会被复制到 *D:\Backup* 目录,但解释器会将列出所有刚刚复制的文件及其总数的文本附加到 Copy.log 文件中。它之所以是附加的,是因为使用了两个大于号;如果是单个字符运算符,则会创建一个新的文件来存储输出。

虽然你可以对任何命令进行重定向,但许多命令没有输出,或者其输出非常乏味,不值得捕捉。在下一章中,我将介绍 dir 命令,它将目录中所有文件和子文件夹的详细信息写入标准输出。这种信息通常很容易且经常被重定向到文件。编译程序的命令行输出是另一种值得捕捉的数据。

管道

重定向通常将输出发送到文件,而管道则将输出发送到完全不同的目标。管道 是将两个不同命令连接起来的概念。解释器通过某种方式将第一个命令的输出作为输入传递给第二个命令,像通过管子、软管、管道等 ... 其实有一个更合适的物理比喻,就是管道。用来建立这种连接的字符恰如其分地被称为管道字符,也叫竖线或直杠。在大多数键盘上,它位于回车键上方,按下 SHIFT-\ 就能输入。

到目前为止,你已经看到 echo 命令的结果被重定向到控制台、标准输出或某个特定文件,但你同样可以将这些结果通过管道传递给其他 Batch 命令。回想一下 第七章 中提到的 xcopy 命令,它在复制过程中将目标文件命名为与源文件不同的名字。当时我警告过,类似的命令有时会失败,并承诺在这一章提供解决方案,那个解决方案就是管道。以下命令显然是一个直接的复制操作,并将目标文件重新命名:

xcopy C:\Batch\OldName.txt C:\Target\NewName.txt /Y /F

如果目标路径下已经存在 NewName.txt 文件,这个命令会简单地覆盖该文件并继续执行。但由于 Batch 的不确定性(或者更直白地说,可能是一个 bug),如果 NewName.txt 不存在,解释器会有些困惑。源文件显然是 OldName.txt,但是 NewName.txt 是目标文件的名字,还是目标文件夹的名字呢?.txt 扩展名应该能让解释器明白,但目录名中也可以包含点(.)。(不过,给文件夹命名时加上常见的文件扩展名真是个让人羞愧的做法。)当解释器感到困惑时,它会像迷路的人一样寻求帮助:

C:\Batch>xcopy C:\Batch\OldName.txt C:\Target\NewName.txt /Y /F 
Does C:\Target\NewName.txt specify a file name
or directory name on the target
(F = file, D = directory)? 

如果你在命令提示符下输入了 xcopy 命令并看到了该信息被写入控制台,你无疑会直接输入 F 然后完成操作。如果这个 stdout 输出来自一个批处理文件,也会是同样的情况,但当这个命令在一个有重定向 stdout 的批处理文件中时,解释器本质上会向追踪文件请求响应,并会一直等待下去。某个时候,会有人调查这个长时间运行的进程,滚动到追踪文件的底部,找到前面示例中的文本。这就是程序员所说的挂起;它比中止更糟糕,因为执行永远不会结束。原因可能是一个无限循环,但在这种情况下,原因是解释器向一个无法响应的实体请求反馈。唯一的人工回应就是终止命令窗口,找到并修正问题,然后重新运行。

在程序中实时响应并给出答案的唯一方式是预测问题并在批处理文件执行之前编写相应代码。为此,我将在 xcopy 命令前加上 echo 命令的响应。以下的 echo F 命令只是写入 F,这是针对文件的响应,并且这个响应被作为输入传递给 xcopy 命令:

echo F | xcopy C:\Batch\OldName.txt C:\Target\NewName.txt /Y /F

现在标准输出显示 F 作为对问题的回答,尽管这个回应并不是来自一个人类:

C:\Batch>echo F   | xcopy C:\Batch\OldName.txt C:\Target\NewName.txt /Y /F 
Does C:\Target\NewName.txt specify a file name
or directory name on the target
(F = file, D = directory)? F
C:\Batch\OldName.txt -> C:\Target\NewName.txt
1 File(s) copied 

(是的,解释器和标准输出在管道符号之前的空格上做了一些处理。)

最终,解释器使用新名称将文件复制到目标文件夹。如果在管道符之前是 echo D,那么目标文件会是C:\Target\NewName.txt\OldName.txt。显然,这不是这里的意图,但在不同的情况下,你可以使用管道技术将目标定义为一个目录。

为了使这一过程更具通用性,你可以设置一个变量,根据目标的格式将其设置为 F 或 D。如果目标以句点和扩展名结尾,你可以假设它是一个文件;如果不是,它就是一个目录。然后你可以将解析后的变量通过回显命令传递给 xcopy 命令。

但是,当 xcopy 命令不要求反馈时,这种技术有什么效果呢?它就像试图向某人传授智慧,也许是一个不想听的青少年。就像父母的话语消失在空洞中一样,如果没有提出问题,管道传递给 xcopy 命令的信息会被完全忽略。就好像 echo 命令从未执行过一样。因此,如果需要回应,管道就会给出反馈;如果不需要回应,那它就是无害的。

管道有许多应用。在第二十四章中,你将学习如何通过将 echo 和 type 命令通过管道传递给 findstr 命令来执行一些相当复杂的文本搜索,从而允许你在字符串中找到特定文本,或者在文件中找到包含该文本的所有记录。你甚至可以通过将命令的输出传递给尚未讨论的 sort 命令来对其进行排序。

标准输入(stdin)

尽管本章讲的是输出,但任何关于 stdout 和 stderr 的讨论,如果不提及输入数据流 stdin(发音为 standard in 或不太常见的 standard input),以及它通过 0 来引用来自控制台的输入,都会是不完整的。不过,仅需简单提及即可。在大多数相关文献中,通常会将三者一起提及,仿佛它们同等重要,但尽管 stdout 和 stderr 是无处不在的,stdin 的使用却相对偶尔。

在下面的例子中,第一个命令通过 echo 将消息重定向到控制台。第二个命令则相对较新,可以视为对第一个命令的反转:

@> con echo Enter some data to be saved in a file:
@type con > C:\Batch\FromTheConsole.txt 

stdin 数据流是来自键盘或控制台的输入,在此上下文中通过保留字 con 来表示。直到现在,我只将 con 用作输出,特别是输出到控制台。最终,type 命令将 stdin 重定向到文本文件中。

该命令会暂时暂停处理。用户可以输入一行文本并按 ENTER 键将该行文本写入文件。文件可以接受多行文本,直到用户通过按 CTRL-Z 后再按 ENTER 键(当光标位于行首时)来终止命令。(我可没说过它是用户友好的。)

许多时候,你可能会在控制台请求用户的基本响应——通常是简单的“是”或“否”——我将在第十五章讨论这种交互式 bat 文件如何工作。在那些你请求用户提供更复杂输入的罕见情况下,重定向 stdin 会将数据保存到文件中,以便稍后使用。

总结

在本章中,我介绍了三个相关的主题:输出、重定向和管道。stdout 和 stderr 数据流是重要且有用的由解释器生成的输出,它们为你提供有关 bat 文件执行的详细信息。这些输出不同于由编码人员生成的输出——你显式创建的输出。你学会了如何通过重定向创建新文件并附加到现有文件。我展示了如何将解释器生成的输出捕获到跟踪文件中或完全抑制它。各个命令也有输出,你学会了如何将其重定向到文件并将其管道传输到其他命令。

在本书的后续章节中,我将讨论这些新工具的许多应用。在第二十二章中,使用批处理格式化简单报告将充分利用重定向。我已经提到过使用管道进行文本搜索以及通过 dir 命令进行重定向。在下一章中,我将讨论这个极为有用的命令以及你需要了解的有关目录的所有内容。

第十三章:13 使用目录

Batch 是一个理想的工具,用于查询 Windows 目录。目录中有哪些文件或文件类型?是否有某些目录快满了?丢失的文件在哪里?你可以通过一些 Batch 代码回答这些问题,甚至更多。

在本章中,你将学习如何创建目录、删除目录,以及如何获取关于现有目录的大量信息。很快,你将能够快速生成一个报告,详细列出目录的内容,包括文件名、子目录以及任何子目录的所有内容。信息可以包括所有文件的大小、最后修改日期和属性,或者仅选择某些文件。最终,你将学习如何在 bat 文件中逐一处理这些文件和目录,但我将从分享如何获取这些有用的数据开始。我还将探讨如何轻松确定特定文件或文件掩码是否存在。

最后,你将学习如何将本地和网络目录映射到驱动器字母。几乎所有使用 Windows 计算机的人都可以通过将本章的一些小技巧编入一个简单的 bat 文件,从而减轻一些日常的繁琐工作,提高工作效率。

目录基础

目录 是计算机磁盘驱动器上的一个映射位置,可以容纳文件和其他目录或子目录。在 Windows 计算机上,目录由文件夹表示;事实上,目录文件夹 这两个术语经常可以互换使用。在 Windows 资源管理器中,你可以通过几次鼠标点击在目录中创建和删除子目录及文件。使用 bat 文件,你也可以做到这一点,可能还更简单。

创建目录

要创建一个目录,Batch 使用 md 和 mkdir 命令。这两个命令都代表 创建目录,实际上它们是相同的命令(批处理同义词)。

md 命令不接受任何选项,唯一的参数是要创建的目录:

md C:\Batch\MakeMe\

这个既有用又简单的命令可以接受多个目录进行创建,但当你为每个目录使用单个命令时,每个命令的返回代码清楚地告诉你哪些目录已成功创建,哪些没有。md 命令可以在有无斜杠的情况下工作,但我建议使用它,理由仅仅是它的存在让参数看起来像一个目录。

删除目录

md 命令的对等命令是 rd 命令,用于 删除目录,它会删除一个目录及其中的任何文件。它还有一个批处理同义词 rmdir,并且只有两个选项,我总是使用这两个选项:/Q 启用 安静 模式,/S 删除任何 子目录 及其内容:

rd /Q /S C:\DeleteMe\

此命令还接受一个到多个目录作为参数,带或不带尾部斜杠。如果没有子目录,该命令可以使用或不使用 /S 选项,但如果有一个或多个子目录,则没有任何东西会在没有选项的情况下被删除。我还无法概念化删除一个目录而不删除其子目录意味着什么,因此我总是使用 /S 选项。

检索目录信息

批处理有两个命令用于检索关于目录及其包含文件的详细信息,其中一个比另一个更有用。我将从不可或缺的命令开始。

dir 命令

一个极其有用的工具是 dir 命令,它是 directory 的缩写。许多人会在命令提示符下使用它来在控制台上显示信息。当在批处理文件中单独使用时,该信息只是简单地输出到标准输出流(stdout),通常意味着它被 stdout 中包含的所有其他内容所包含,因此并不是很有用。但是,通常您会以以下两种方式之一使用 dir。

首先,根据您在第十二章中学到的知识,您可以将命令的输出重定向到文件中供程序、人类或最终批处理文件读取。其次,更令人印象深刻的是,我将展示如何将 dir 命令输入到 for 命令中,跳过创建文件的步骤。这将在第二部分中讨论,但在您使用 dir 命令输入 for 命令之前,您需要了解 dir 命令本身的复杂性。

没有选项

最简单的 dir 命令接受一个单一的参数:目录或文件夹。如果路径中没有嵌入空格,则双引号是可选的:

dir "C:\Important Stuff\"

为了演示目的,假设该目录包含一些重要个人文档的电子副本和几个其他重要材料的子目录。命令的结果可能是向 stdout 显示的列表 13-1 中显示的格式良好的报告。

 Volume in drive C is OS
 Volume Serial Number is 2E7D-DB30

 Directory of C:\Important Stuff

07/25/2020  05:19 PM    <DIR>          .
07/25/2020  05:19 PM    <DIR>          ..
10/05/2019  06:44 PM           280,643 Birth Certificate.jpg
04/01/2014  08:28 PM           120,542 Car Title.pdf
07/25/2020  05:18 PM            61,124 Passport.png
07/25/2020  05:20 PM    <DIR>          Retirement
07/25/2020  10:51 PM            64,760 SSI Card.png
07/18/2020  02:26 PM    <DIR>          Taxes
               4 File(s)         527,069 bytes
               4 Dir(s)  173,275,090,944 bytes free 

列表 13-1:无选项 dir 命令的示例输出

经过三行标题后,前两个

条目表示这是一个子目录,而不是与驱动器号相关联的根文件夹;这两行在 dir C:\ 命令中不会出现。更有趣的是,此文件夹中的所有文件都清晰显示,每个文件都带有其最后修改的日期和时间以及文件大小。子目录也清晰地显示出来。

注意,文件和子目录是交错显示的,默认按字母顺序排序,但正如您很快将看到的那样,该命令非常可定制,具有其选项,可以极大地控制输出。

您甚至可以在单个命令中列出多个目录:

dir "C:\Important Stuff\" C:\Batch\

此命令显示第一个文件夹下的所有文件和子目录,然后是另一个标题下的第二个文件夹的类似信息:C:\Batch 目录。

一些有用的选项

dir 命令有很多有用的选项;其中有几个选项甚至还有自己的选项。我将讨论我经常使用的那些选项,但如常,完整的选项列表可以在帮助中找到,命令为 dir /?

/O 选项控制排序顺序,但它的工作方式与到目前为止你所见的大多数选项略有不同。附加字符定义了排序顺序。例如,/OG 将目录排在文件前面,而减号则反转排序顺序,/O-G 将文件排在目录前面。(令人痛苦的是,G 代表先显示目录。)/OEN 选项按扩展名名称排序,而/O-E-N 则反转顺序。有些人可能觉得它讨厌,但是/ODS 按修改日期和时间排序,然后按文件大小排序:

dir "C:\Important Stuff\" /ODS

属性选项,/A,限制显示内容。/AH 选项仅列出隐藏文件,而/A-H 则从列表中省略隐藏文件;/AD 仅显示目录,/A-D 则不显示目录。实际上应该有一个仅针对文件的选项,但“无目录”选项能够完成任务,就像只有批处理能做到的一样。

默认情况下,文件大小以逗号分隔,方便阅读(对于人类来说),但如果我们想对这些数字进行任何算术操作,/ -C 选项会去掉逗号。编码者经常希望生成一个没有任何杂乱的简单文件名列表。/B 选项,即选项,能够很好地完成这个任务。

将这些选项组合在一起,以下命令跳过目录(/A-D),按大小从小到大排序(/OS),并仅显示文件名而不显示其路径(/B):

dir "C:\Important Stuff\" /B /A-D /OS

这条没有任何选项的命令生成了列表 13-1 中的报告。使用这些选项后,结果简洁得多,仅仅是一个简单的文件名列表:

Passport.png
SSI Card.png
Car Title.pdf
Birth Certificate.jpg 

从列表 13-1 中可以看到,护照是最小的文件,出生证明是最大的,这表明尽管没有显示字节数,文件仍然按大小排序。简洁的输出可能看起来像是一种降级;确实,它对人类不如之前那么有信息量,但在第二部分中,这将是理想的数据,可以输入到一个 for 命令中逐个处理文件。

另一个有用的选项是/S,代表子目录。它本质上是先对一个目录运行 dir 命令,然后对其所有子目录再次运行此命令,返回一个格式良好的报告,每个子目录都有小标题。dir C:\ /S 命令提供了你计算机上每个文件夹的报告,但速度不快,结果可能比本书还长。当与/B 选项一起使用时,每个纯文件名都会加上其路径——这可能看起来像是一个矛盾,但在考虑到每个文件可能位于多个目录中时,就能理解了。

默认选项集

如果你打算运行多个 dir 命令,而且每个命令的选项都相同,那么你不需要为每个命令都重复这些选项。相反,你可以将一个或多个选项加载到 dircmd 伪环境变量中。一旦设置,所有后续的 dir 命令将默认使用 dircmd 变量中的选项。

例如,以下代码执行两个 dir 命令,显示裸文件名 (/B),不显示目录 (/A-D),并按扩展名排序 (/OE):

set dircmd=/B /A-D /OE
dir "C:\Important Stuff\"
dir "C:\Some Other Folder\" 

你可以为特定的 dir 命令覆盖 dircmd 变量中的一个或多个选项。假设接下来的 dir 命令遵循了前面的代码,其中 dircmd 已经设置:

dir "C:\Important Stuff\" /O-E

这仍然使用了仅适用于文件裸格式的选项,但排序顺序被反转了。

任何时候,你都可以通过将 dircmd 设置为空或完全不设置来关闭此功能。

where 命令

where 命令类似于 dir 命令;它搜索一个或多个目录来查找文件 所在的位置。如果你在读这段话时耸了耸肩,心想:“dir 命令不也能做这个,而且还能做更多吗?”那么我的回答肯定是肯定的。大多数你能用 where 做的事情,dir 也能做,只是做得更好。

然而,where 命令在执行某些任务时,比 dir 命令更高效。使用 /Q 选项时,where 命令返回一个退出代码,表示成功或失败,而不是返回找到的文件列表,这使得判断某个文件是否存在或是否有至少一个与文件掩码匹配的文件变得更加容易。(/Q 选项代表 安静 模式,像你见过的其他模式一样,但在此命令中,"安静"的含义稍有不同。对于其他命令,提示被抑制,但在这里,输出被抑制。)

以下命令会在文件夹 *C:\Batch* 中查找至少一个以 FindMe 开头的文件,忽略大小写,并根据结果设置错误级别:

where /Q C:\Batch\:FindMe*

如果至少有一个文件符合掩码,返回代码将为 0;如果没有,则为 1;如果语法不正确,则为 2。

如果你仔细查看,会发现路径和文件掩码之间似乎有一个多余的冒号。dir 命令正确地将路径和文件名(或掩码)视为一个参数一起处理。而 where 命令将它们视为由冒号分隔的两个参数。这确实允许你用一个文件名或掩码输入多个由分号分隔的路径,但这并没有多少安慰。更令人困惑的是,当使用 /R 选项时——表示 递归,即它还会搜索子目录——冒号会被空格替代:

where /Q /R C:\Batch\ FindMe*

我勉强包括了这个命令。语法本身就是错误的,但它确实有一个值得使用的功能。可以将其视为带有返回代码的 if exist 命令。仅在执行这个特定任务时使用它,其他情况请继续使用 dir。

映射驱动器字母

批处理有两个非常有用的命令用于映射驱动器字母。一个是将本地路径映射到驱动器字母,另一个是将网络路径和共享映射到驱动器字母。如果您不是编程人员——首先,给您慢拍手,恭喜您已经阅读到第十三章——但更重要的是,如果这听起来像是只有程序员才能使用的东西,那其实并不是这样的。

映射路径是一个很好的工具,特别是如果您在一天中经常在计算机或网络的多个特定路径下工作。导航到这些路径可能会花费一些时间,特别是当这些路径深层嵌套时。另一个挑战,尤其是如果您在家工作,可能是您在连接到虚拟私人网络(VPN)之前无法看到网络路径。为了简化这一切,编写一个简短的批处理文件,每天早上运行,可能是在连接到网络后,这样您就可以轻松访问这些路径了。映射完成后,通过点击 Windows 资源管理器中的驱动器字母即可访问每个路径。

subst 命令

subst 命令将本地目录或您 Windows 计算机上的任何文件夹映射到驱动器字母。命令名称是 substitute 的缩写,因为使用它后,您可以用驱动器字母替代目录。(不,这不是用来做子字符串操作的。)以下命令将 *Z:* 映射到所示路径,尽管如果路径不存在或驱动器字母已映射到另一个路径,则会失败:

subst Z: C:\ParentFolder\ChildFolder\GrandchildFolder\

执行此命令后,您将在 Windows 资源管理器中找到 *Z:*,它作为 *C:\ParentFolder\ChildFolder\GrandchildFolder* 路径的别名。现在,批处理文件可以仅通过调用驱动器字母来访问该路径中的任何内容。例如,在执行前述命令后,以下命令将在 *GrandchildFolder* 目录中创建一个空文件,从而减少输入量:

copy nul Z:\EmptyFile.txt

subst 命令如果没有参数或选项,将显示所有当前通过之前的 subst 命令映射的文件夹。如果在之前的 subst 命令之前,已经有其他文件夹被映射,简洁命令将...

subst

可能会生成以下输出:

Y:\: => C:\Certain\Other\Folder\
Z:\: => C:\ParentFolder\ChildFolder\GrandchildFolder\ 

*Z:* 的映射现在将在计算机上生效,甚至对于其他进程(无论是批处理还是人工操作),直到计算机注销或运行以下命令通过 /D 选项 删除 或断开该映射:

subst Z: /D

此命令仅映射本地目录;它不会映射其他计算机上的目录,但还有一个其他命令可以完成这个任务。

net use 命令

net use 命令将网络目录和共享映射为 subst 命令映射本地目录。以下命令将 *Y:* 映射到远程服务器上的共享:

net use Y: \\RemoteServer\ShareName\

您现在可以使用驱动器字母 *Y:* 来访问另一台计算机上的此路径,再次适用于批处理和人工操作,直到映射被删除或计算机关闭。与 subst 命令类似,net use 命令也有一个选项来断开或删除映射,但它更为冗长:

net use Y: /DELETE

/D 和/DELETE 选项的创始人至少在潜意识里是在尊重那句著名的名言:“愚蠢的一致性是狭隘心智的鬼怪。”拉尔夫·沃尔多·爱默生可能会不赞同,但为了致敬这种愚蠢的一致性,我像对待其他选项一样将这个冗长的选项大写化,因为这是我的惯例,但并非没有保留。我的这一惯例背后的主要动机是尽量减少大写字母的使用,但当一个通常是单个字符的选项变为六个字符时,它自然应该使用小写字母。做你觉得对的事情。

与 subst 命令类似,net use 命令如果没有附加参数,将会列出通过早期 net use 命令映射的所有目录和驱动器。

注意

我需要先预防一些恶评。这实际上是 net 命令,其中 use 是它的第一个参数。该命令有十多个其他的第一个参数——例如,share 用来创建文件共享。但是由于 use 的广泛使用,程序员通常称其为 net use 命令。事实上,help net use /?会像任何其他命令一样提供相关信息。

总结

在这一章中,你学习了如何创建和删除目录。我详细介绍了极为重要的 dir 命令,稍后你还会再见到它,还有 where 命令,你在本书中不会再见到它,因为之前提到的原因。现在,你可以获取关于目录及其所有内容的惊人信息,在第二部分中,我将演示如何遍历这些数据,以便你可以对每个文件或目录执行任务。你还学会了如何映射本地和网络目录,这对于任何人来说都是极其有用的技能,不仅仅是程序员,尤其是那些经常在 Windows 计算机上工作的用户。

转换话题,我接下来将深入探讨逃逸的概念。对某些人来说,这可能是本书中最让人困惑的标题;什么是逃逸,逃逸的是什麼,我们又在试图逃避什么?这些问题和更多的疑问将在下一章中解答。

第十四章:14 转义

本章讨论的是一个令人头疼的问题及其批处理解决方案。问题在于,有时你想将某个字符作为普通文本使用,但该字符在编码语言中有特定的功能。解决方案是转义。

在本章中,我将解释如何在批处理语言中转义字符的所有复杂细节。大多数时候会使用某种特定的语法,除了在需要时会有不同。你将了解多轮转义、语法,以及为什么你可能需要多次转义一个字符。我还会回到“续行符”,它用于将命令延续到多行代码,因为当你揭开它的面纱时,你会发现它原来是一个转义字符。然而,在讨论如何解决这个问题之前,你首先需要理解并欣赏这个问题的本质。

问题陈述

你可能希望在某些代码中使用特定字符,但如果该字符是编码语言中具有某种预定义功能的特殊字符怎么办?例如,假设你尝试在文本字符串中使用该特殊字符。这种情况在所有语言中都会发生,但在批处理语言中比较常见,因为该语言具有独特的深奥语法。

正如你在本书中反复看到的那样,百分号用于限定变量;百分号位于变量的两边时,变量会被解析为它的值。但是,在批处理语言出现之前,百分号被用来表示百分比——即 100 的比例。因此,批处理中的文本字符串不能简单地表示 50%,否则百分号就会被当作分隔符来解析。这个问题的隐秘之处在于,没有编译器能捕捉到这个问题,解释器甚至可能不会失败,而是产生意外的结果。

举个例子,考虑一下这个命令,它将看似简单的语句写入控制台:

> con echo Between 60% and 80% of Americans don't understand percents!

解释器将两个百分号之间的内容(即后面跟着 80 的空格)视为一个变量。假设该变量没有设置,这是几乎可以肯定的,那么它(连同百分号)会解析为空值。结果就是在控制台上写出这样一个无意义的语句:

Between 60 of Americans don't understand percents

如果该命令只使用了一个百分号,它本身就会被从输出中去掉。顺便问一下,结尾处的感叹号去哪了?先记住这个问题。

解决这个难题的方法是转义任何特殊字符。转义字符可能很棘手,但在许多情况下,它们非常有用且不可或缺。很快,我会回到前面的 echo 命令,向你展示如何让它输出所需的文本。

插入符号转义字符

主要的 Batch 转义字符是插入符号 (^)。在其他语境中,它被称为帽子符号或用来表示指数,但在 Batch 领域,它就是插入符号。在大多数键盘上,你可以通过按 SHIFT-6 来输入它。关键是,解释器会把插入符号后面的大多数字符视为普通文本。

以下 echo 命令试图向控制台输出一些陈词滥调,那些你可能在由不理解 办公室空间 是一部喜剧的办公室里的坏激励海报上看到的内容,但令人尴尬的内容只是问题的一部分。它根本无法工作:

> con echo Together We Are > You & Me Alone

解释器将第二个大于号视为第二个重定向符号,在当前目录中创建一个名为 You 的无扩展名文件,而 & 符号则结束一个命令并开始另一个命令。显然,带有 Alone 参数的 Me 命令会直接失败。

这个命令显然很乱,但通过插入符号可以修复。我在之前被阻碍的两个字符前插入了主要的 Batch 转义字符:

> con echo Together We Are ^> You ^& Me Alone

你可以将这个命令中的每个插入符号看作一个特殊的信使。每当解释器遇到转义字符时,它就会接收到这个清晰的信号:

紧接着我的下一个字符将被视为普通文本。不要像通常那样解析它。哦,顺便说一下,迅速丢弃我,因为我不过是一个数字版的斐迪比德斯,一个在完成任务后就消失的简单信使。

结果是这个或许具有激励性和鼓舞人心的信息写入了控制台:

Together We Are > You & Me Alone

另一种让解释器将特殊字符视为文本的方式是将字符串用双引号括起来:

> con echo "Together We Are > You & Me Alone"

需要注意的是,虽然这个命令没有转义字符,但它也会将双引号输出到控制台。

我很快会揭示一些例外情况,但插入符号是最常用的 Batch 转义字符,你可以用它来转义小于符号 (<)、管道符号 (|) 和圆括号 (()),以及其他特殊字符。但 Batch 并不平等对待所有字符。

转义插入符号

由于解释器将插入符号视为转义字符并将其丢弃,你可能会思考一个插入符号希望仅作为文本来处理的困境。例如,如果你要在控制台上写出毕达哥拉斯定理,我希望你不会惊讶地发现 Batch 不支持上标:A² + B² = C²。相反,如果我们能够让它起作用,插入符号表示指数运算会足够:A² + B² = C²。(毕达哥拉斯定理假设 A 和 B 是直角三角形的两条直角边,C 是斜边。)这可能是解决方案的第一次尝试:

> con echo The Pythagorean Theorem:  A² + B² = C²

不幸的是,每个插入符号都会告诉解释器将随后的字符(每个实例中的 2)视为普通文本,而这本来它就会如此处理:

The Pythagorean Theorem:  A2 + B2 = C2

解释器只是将插入符号丢弃,好像它们从未存在过。

解决方案在于插入符号是自转义的;一个插入符号用另一个插入符号进行转义。我已将以下代码中的每个插入符号替换为双插入符号。在每个实例中,第一个插入符号是转义字符,后面是文本插入符号:

> con echo The Pythagorean Theorem:  A^² + B^² = C^²

现在你会得到期望的结果:

The Pythagorean Theorem:  A² + B² = C²

我们仍然无法管理上标,但写入控制台的结果是最接近的,并且很符合数学家的喜好。

转义百分号和感叹号

在学习了勾股定理后,你可能会在数学考试中获得满分,但这个庆祝性批处理命令无法产生预期的结果,因为两个特殊字符从写入控制台的文本中被丢弃了:

> con echo I Scored 100% on my Math Test!

哎呀!我们忘记了插入符号。你可能会认为这个快速修复会显示百分号和感叹号:

> con echo I Scored 100^% on my Math Test^!

但输出没有改变。不幸的是,正如批处理中的常见情况那样,存在一些限制。插入符号不能作为百分号或感叹号的转义字符。

百分号的转义字符是另一个百分号,而感叹号的转义字符——实际上是转义字符(复数)——是两个插入符号。如果这对你没有任何意义,你并不孤单。我从未找到过一个合理的解释来说明这个异常,但以下命令会将“I Scored 100% on my Math Test!”写入控制台:

> con echo I Scored 100%% on my Math Test^^!

思考一下;与勾股定理示例相比,Batch 如何处理双插入符号似乎存在矛盾。文本^² 会解析为²,但与数学测试相关时,文本^^!会解析为!,完全没有留下插入符号。是的,就是这样工作。解释器处理双插入符号的方式取决于它后面跟着感叹号或其他任何字符。可以把它看作是一个限制的限制(或者是一个元限制)。

回到本章开头的题目,这条命令写入了期望的文本:

> con echo Between 60%% and 80%% of Americans don't understand percents^^!

解释器通过每一对百分号和前置于感叹号前的两个插入符号将适当的文本写入控制台。

注意

正如在第三章中提到的,我写这本书时假设始终启用了延迟扩展,但如果它被禁用,Batch 会将感叹号当作任何其他字符来处理,在 Batch 中没有特殊意义,也不需要转义。

多层转义

之前的示例演示了如何通过单层转义将硬编码文本写入控制台,同样的技巧也成功地设置了一个简单的变量,但有一个陷阱。例如,以下的 set 命令解析了两个转义字符并将“Together We Are > You & Me Alone”存储到变量中:

set pureDrivel=Together We Are ^> You ^& Me Alone

不幸的是,这个变量的用途非常有限。该变量确实包含了两个特殊字符,但如果你尝试将它写入控制台或文件,或者尝试将它传递给另一个命令,它将无法按预期工作。因为在将该文本赋值给变量时,转义字符被移除了,所以当该变量稍后被解析时,解释器认为具有特殊意义的字符又会引发原本转义解决的问题。

解决方案是转义转义字符——是的,双重转义。以下两行代码将期望的文本写入控制台,我所说的期望文本是指它包含了一个大于号和一个和号,并且没有转义字符:

set pureDrivel=Together We Are ^^^> You ^^^& Me Alone
> con echo %pureDrivel% 

为了看清发生了什么,让我们专注于^^^&。第一个插入符号是第二个插入符号的转义字符,第三个插入符号是和号的转义字符。解析后,set 命令将^&作为变量值的一部分进行存储。当 echo 命令解析该变量时,剩下的插入符号——它刚才被当作文本处理——现在是和号的转义字符,最终只有和号被写入控制台。

让我们看一下整个文本字符串。第一个命令将pureDrivel设置为Together We Are ^> You ^& Me Alone的值;然后第二个命令将文本Together We Are > You & Me Alone写入控制台。

多级转义可能会变得更复杂。例如,如果你在将两个变量拼接成一个更大的变量后再将该第二个变量写入文件,你将需要三轮转义。

至于三重转义的机制,考虑一下这个:由于^^^^^^^&中有四个转义字符(即七个插入符号),它解析为^^^&。第二轮转义将其解析为^&,最终在第三轮解析时变成&。转义字符的数量是2*^n* - 1,其中n是转义的次数。我说它很棘手,但它也挺酷的。

连续字符

我不止一次听到程序员将插入符号称为批处理的连续字符,我甚至在第五章中介绍过这种用法,举了一个例子,展示了它在一个跨越四行代码的 set 命令中的应用。严格来说,这不正确,但在实践中,它确实执行了这个功能。让我来解释一下。

每个程序员的目标都应该是写出不需要读者左右滚动的代码。(当然,它还应该是高效的、文档完善的、结构清晰的,甚至是优雅的,但这也许只是我的个人看法,所以我离题了。)在大多数编译语言中,当命令过长不容易阅读时,你只需按 ENTER 键,然后在下一行继续输入。编译器足够聪明,知道该命令包含了两行、三行,甚至更多行。

Batch 解释器没有那么宽容(或聪明),但当你在代码行的最后添加一个插入符号时,语句会继续到下一行。即使一行代码不特别长,我有时也会使用这种技术将传递给可执行文件的参数对齐,以提高可读性:

C:\Batch\SomeExecutable.exe %Arg1% ^
                            %Arg2% ^
                            %AnotherArg% ^
                            %YetAnotherArg% ^
                            %AndAnother% 

在文本文件的大多数行末尾,两个字节表示回车换行符(carriage return line feed)。在十六进制中,这两个字节是 x'0D' x'0A',它们通常合在一起称为 CRLF,但在文本编辑器中通常不可见。(如果使用 Notepad++,可以选择查看显示符号显示行尾,以便让 CRLF 可见。其他编辑器也有类似功能。)

实际上,插入符号仍然只是一个转义字符,它是在转义 CRLF。遵循转义字符的作用,当解释器看到插入符号时,它不会像通常那样将后面的 CRLF 视为行末,而是将其视为其他空白字符并忽略它,实际上是“换行”了。从这个角度来看,插入符号就是“续行符”。(但我仍然感到不太舒服。)

一个常见的错误是,在行尾看似结束的插入符号后加上一两个空格,从而使文本包装失效。由于插入符号转义的是紧随其后的字符,这样做不过是转义了一个空格,这几乎等于什么都没做,并且不会打乱 CRLF。对于那些仅将插入符号视为续行符而不是转义 CRLF 的用户来说,这种疏忽可能很难排查。知识就是力量。

你已经学到,单个字符通过一个转义字符来转义,除了感叹号,它需要两个转义字符。CRLF 是另一个例外,但原因正好相反。CRLF 实际上是两个字符,回车字符和换行字符,它是 Batch 中唯一一个由一个字符转义的两个字符示例。

总结

在本章中,你学习了许多使用插入符号(caret)以及有时使用百分号(percent sign)来转义特殊字符的方法,但讨论才刚刚开始。这项技术是一个不可或缺的工具,稍后在本书中你将看到它的多个应用。

如果没有别的,我希望本章能让你认识到转义是多么棘手。当我还是一个初学者时,我从一位更有经验的同事那里得到了一个简单却富有智慧的建议:要认真测试;测试代码可能遇到的所有字符。由于有许多警告、特殊情况和例外,你不应该在看到转义在某个特定场景下有效后,就假设它在所有环境下都会有效。在你的测试计划中,加入所有可能遇到的特殊字符,来测试那些进行转义的代码。

为了完全不同的内容,下一个章节将讨论如何使批处理文件与人类进行交互,提问、获取回答,并根据这些回答执行条件逻辑。

第十五章:15 互动批处理

如果你心中有任何疑虑,让我来告诉你,Batch 没有图形用户界面(GUI),但它有功能性的用户界面(UI)。在本章中,我将讨论如何在 bat 文件执行时获取用户输入的不同方式,比如从列表中选择一个选项或输入对某个问题的回答。我还会描述如何更改控制台的视觉显示或外观,包括清除屏幕、改变颜色以及更新标题。最后,我将把所有内容整合在一起,构建一个完全可操作的 Batch UI(BUI)准备执行。

用户界面、图形用户界面和批处理用户界面

用户界面本质上是用户与计算机之间进行沟通的一种方式,用户输入信息并获取反馈。每次你进行在线购买时,你都在使用图形用户界面,它是一种更复杂的用户界面,允许通过不仅仅是键盘的图形化用户输入。视频游戏是一个被美化的用户界面,每次你在智能手机上点击一个图标来打开应用时,你都在使用一个用户界面。《星际迷航:下一代》中的指挥官数据是一个安卓人,拥有一个极其先进的用户界面,能够通过五种感官与人类互动。

继续沿用科幻主题,Batch UI 更像是 1983 年的电影战争游戏。在 Batch UI 或 BUI(发音为 boo-ē)中,没有面板、下拉菜单、图标、菜单或单选按钮,更没有触摸屏或语音命令。请注意,如果你跟程序员提到这个词,可能会看到他们一脸茫然或扬起眉毛。我曾尝试过,但到目前为止未能将 BUI 纳入编程术语,但我仍然抱有希望,它会被接受并流行起来。

BUI 可能并不性感,但它能够向用户提问,用户可以通过输入一串文本或按一个键从列表中选择一个选项来回应。若是程序员用 Batch 来为大量用户构建一个复杂的用户界面,那简直是个施虐狂,更别说这位程序员很快就会失业了。但在许多情况下,bat 文件确实需要从用户那里获取一两个数据,尤其是当用户也是程序员时。我编写了很多 BUI,但每个 BUI 我都能数得出有多少人曾经使用过它。

BUI 可能有很多需求。你的 bat 文件可能需要将文件从或向服务器复制,并要求用户指定服务器。例如,你可能想创建一个报告,但能够根据用户的偏好从测试数据或生产数据中生成它。或者,你可能要求用户输入一个文件备份的日期范围。

从列表中选择一个选项

有两个命令允许用户输入数据到 BUI 中。一个要求用户从多个可能的选项中选择一个,另一个要求输入自由格式的响应。第一个是选择命令,顾名思义,它允许用户从两个或更多选项中做出选择

为了开始,让我们问用户一个问题——你想要笑话、双关语还是谜语?——并允许他们输入 J、P 或 R,分别对应三种选择。以下选择命令正是这样做的:

choice /C:JPR /M:"Do you want a Joke, Pun, or Riddle"

/C 选项列出选择,/C:JPR,双引号括起来的文本与/M 选项相关联,是展示给用户的消息。这两个选项在没有冒号分隔符的情况下也能工作。也就是说,/C JPR 在功能上等同于/C:JPR,但我更喜欢使用冒号,因为它巧妙地将选项与其值或消息联系在一起,就像一块有价值的地毯能把房间融为一体。另外,注意问题末尾没有问号。解释器在向用户展示可选择项列表后会自动添加标点符号。

上一个命令在控制台上向用户显示以下内容:

Do you want a Joke, Pun, or Riddle [J,P,R]?

执行批处理文件时,命令会在选择命令处暂停,直到用户按下三个键中的一个(或退出命令窗口)。如果用户按下列表中没有的键,计算机会发出哔声并继续等待,但当用户选择其中一个选项时会发生什么呢?

到目前为止,errorlevel 仅仅是一个返回代码,通常为 0 表示命令成功执行,0 以外的数字表示执行失败。但在执行选择命令后,errorlevel 会被设置为用户的选择;更具体地说,它会被设置为与用户选择在列表中的位置相对应的整数值。更简单地说,如果选择是由/C:JPR 定义的,选择 J 会返回 1,P 返回 2,R 返回 3。

我觉得返回有效选择的变量名字中包含error一词有点误导,但在克服了语义问题以及这个保留字承担双重职责的事实后,检查变量以确定用户的选择并执行接下来的逻辑并不困难。

这是另一个选择命令的示例,似乎也缺少了一些内容(除了问号之外):

choice /M:"Do you want to try again"

缺少了/C 选项,但这是一个是或否的问题,当省略此选项时,隐含的默认值是/C:YN,Y 返回 1,N 返回 2。

另外两个选项总是配合使用。/T 选项设置超时,即给定的秒数,在超时之前,命令会等待,解释器会选择/D 选项定义的默认选择。以下命令给用户 20 秒的时间,/T:20,在此之后会强制执行一个双关语,/D:P:

choice /C:JPR /M:"Do you want a Joke, Pun, or Riddle" /T:20 /D:P

有时列出选择项并不合适。例如,在询问用户给某项打分时,从 1 到 5 之间的评分,可能更倾向于通过文字来解释评分系统。假设你想向用户提出以下问题,要求其做出回应:

Rate your agreement to the statement on a scale of 1 to 5.
I love bat comedy.  1 (Agree) to 5 (Disagree) 

以下的 echo 命令和 choice 命令一起生成期望的文本,并等待响应:

echo Rate your agreement to the statement on a scale of 1 to 5.
choice /C:12345 /M:"I love bat comedy.  1 (Agree) to 5 (Disagree)" /N 

你将需要 /C:12345 选项,以便解释器能够知道所有可能的选择,但你不希望显示 [1,2,3,4,5],因为它会与前一行的指示产生冲突。/N 选项(无选择键)会抑制选择的显示,只向用户显示期望的消息。问号也会随着 /N 选项被抑制,但如果消息是以问题的形式提问,你可以将问号包含在消息字符串中。

如果一个 bat 文件要在选定的服务器上执行任务,可以使用一系列的 echo 命令列出任意数量的服务器及其相关的键盘键,作为选择命令的前奏。对于预定义的服务器列表,这样做效果很好,但如果列表特别长或在编码时无法预知,可以改为要求用户输入服务器名称,作为下一个命令的输入。

自由格式的用户输入

另一个允许用户输入数据的命令是 set 命令(见第二章),当它与 /P 或 提示字符串 选项一起使用时。与没有选项的 set 命令类似,set /P 命令将一个值赋给变量,这个值可以是任何合理长度的字符串,甚至为空。不同之处在于等号后的文本不是赋给变量的值,而是显示给用户的提示字符串。用户输入的内容将在按下 ENTER 键后赋值给变量。

为了演示,等号后的问题将显示在控制台上:

set /P yourAns=How are bats like false teeth?  &

执行会被暂停,直到用户响应,此时解释器会将该响应赋值给 yourAns 变量。

如果忘记使用 /P 选项,变量将直接赋值为等号后面的文本,而不会提示用户。注意三个重要的细节。首先,这个命令没有在消息字符串后附加问号,所以我加上了。其次,我在问号后添加了一些空格,以便将响应的开始与问题分开。最后,我在行末加了一个 & 符号,让这些空格一目了然。这些细微的调整会让你的用户和任何阅读你代码的人都感到满意。

每个笑话都需要一个结尾,而这个结尾会在用户有机会思考问题并输入猜测后,出现在以下代码的第二行:

set /P yourAns=How are bats like false teeth?  &
echo ** They both come out at night.
echo ** You said: "%yourAns%" 

第三行和最后一行显示用户的答案,答案被双引号括起来。这是一个笑话。我并没有说这是一个好笑话。

修改界面外观和感觉

Batch 提供了三个更多的命令来改变控制台的外观和感觉,其中有一些命令还有其他用途:

更新标题

当一个 bat 文件执行时,会打开一个命令窗口,其顶部的白色条上的标题很可能是 C:\WINDOWS\system32\cmd.exe,即执行 bat 文件的程序。title 命令将这个标题重置为更具辨识度且不那么通用的标题。以下命令会将标题更改为紧随其后的文本:

title Batch Improv Theater 

参数列表中的嵌入空格不是问题,如果你将文本括在双引号中,它们也将成为标题的一部分。

使用这个命令不仅限于交互式 bat 文件,在其值显而易见的情况下也可以使用。任何在其他 bat 文件可能也在运行的机器上运行的 bat 文件,都可以通过标题得到增强。如果其中一个 bat 文件挂起或陷入死循环,应该终止哪个呢?如果它们没有标题,它们可能看起来一模一样,导致你无法知道是哪一个。title 命令解决了这个问题。实际上,我将在第二十六章中使用这个命令,给一个可能会挂起的进程添加标题,以便另一个 bat 文件能找到它并终止它,如果它确实挂起的话。你甚至可以在运行期间多次重置标题,可能显示当前状态或正在执行的步骤。

清除屏幕

cls 命令是 clear screen 的简写。当这个不带选项的命令执行时,屏幕或控制台会被清除,显示一个空白(目前是黑色)的画布。为了减少干扰,你可以在向用户提问之前执行这个命令。

更改颜色

打开或执行 bat 文件时,会弹出一个带有白色文本和黑色背景的命令窗口,这与人类自古埃及纸莎草和墨水出现以来的阅读方式完全相反。想象一下你现在正在阅读的文本,如果它是白色的,背景是漆黑的纸张。现在看起来似乎有些过时,但在 Batch 早期,它一定是前卫的。

color 命令提供了 16 种不同的颜色,供用户选择用于前景文本和背景,总共有 240 种组合,尽管某些组合几乎不可读,甚至会让眼睛感到不适。

进入帮助菜单,输入 color /? 来查看完整的颜色列表,颜色由十六进制数字 0 到 F 表示,但流行的颜色包括黑色(0)、蓝色(1)、红色(4)和白色(7)。color 命令接受一个两字符的颜色属性作为参数,其中第一个字符表示背景色,第二个字符表示前景色或文本色。顺便提一下,解释器足够智能,能够拒绝将相同颜色同时赋给背景和前景的命令。

黑色背景和白色文本是默认的 Batch 颜色方案,你可以通过以下命令显式调用它:color 07。将属性反转为 70 会创建一个白色背景和黑色文本的组合,但黑色文本配上明亮的白色(F)背景更加吸引人:

color F0 

我个人偏好的是在蓝色背景上使用亮白色文字,颜色 1F,但撇开美学不谈,这个命令最主要的用途是标记问题。你可能每天运行某些 bat 文件以执行一些日常或重复的任务。你可能每天早晨在登录时运行这样的 bat 文件,然后忽略它,但在文件无法复制或进程中止的罕见情况下,颜色命令提供了一种很好的方式,通过"红旗"(字面意义上)来警告用户。如果使用默认的颜色设置,写完错误信息后,这行命令会立即将屏幕从黑色(默认)变为红色(4),稍微加亮白色文字(F),并保持窗口打开:

@color 4F & pause 

即使用户已经转向其他任务并且命令窗口被置于一旁,这应该能够吸引他们的注意。如果没有,你也可以使用 cls 命令在写出错误信息之前清除屏幕。为了增强对比度,当过程成功完成时,你可以将背景设置为绿色,使用颜色 2F。

如果 stdout 已被重定向到追踪文件或 nul 文件,那么 cls 和 color 命令将不起作用。如果只需要清除屏幕一次,在重定向 stdout 到追踪文件之前执行 cls 命令;否则,echo off 是唯一能够关闭控制台输出的现实选择。同样,你可以在重定向前或后执行 color 命令。提前执行,它将为整个重定向过程设置颜色。你也可以在执行结束后返回重定向时,将屏幕变为红色,以表示中止。title 命令是其中最受欢迎的,因为它在 bat 文件中的任何位置都能正常工作,无论是否有重定向。

完全功能的批处理用户界面

让我们将这一切组合成一个完全功能的 bat 文件,可以反复互动地分享笑话、双关语或谜语。接下来的两个代码片段包含了完整的 bat 文件。这是BatchImprov.bat的第一部分:

❶ @setlocal EnableExtensions EnableDelayedExpansion
 @echo off
 color 1F
 title Batch Improv Theater

❷ :Again
 cls
 > con echo.
❸ > con choice /C:JPR /M:"Do you want a Joke, Pun, or Riddle"
 > con echo.
❹ if %errorlevel% equ 1 (
    call :Joke
 ) else if %errorlevel% equ 2 (
    call :Pun
 ) else if %errorlevel% equ 3 (
    call :Riddle
 )
 > con echo.
❺ > con choice /M:"Do you want to try again"
 if %errorlevel% equ 1  goto :Again
 goto :eof 

在 setlocal 命令❶(我常用的开头命令)之后,echo off 命令抑制 stdout,使得只有我生成的输出能显示在控制台上。注意,由于前面的 at 符号(@),这两个命令都不会写入控制台。然后,color 命令将背景设置为蓝色,文本设置为亮白色,目的是为了更好的可读性。接下来,title 命令定义命令窗口的标题。在:Again 标签❷之后,cls 命令清除屏幕,完成设置。

三个 echo.命令被策略性地放置在代码中,用于显示空白行,提升可读性。与之前相同的 choice 命令❸询问用户他们喜欢哪种幽默类型。由于有三种选择,if 命令配合 else if 结构根据 errorlevel❹的值判断用户的选择。根据用户的回答,分别调用笑话、双关语或谜语的不同 call 命令,分别对应 1、2 或 3 的值。

第二个选择命令❺询问用户是否希望继续欣赏这些幽默,默认选择为 Y 和 N。如果用户选择 Y,解释器返回 1 并且我们回到:Again 标签❷,在这里清屏并重新开始。选择 N 表示用户已经看够了,我们退出 bat 文件。

在前面的代码列表中调用的三个例程在BatchImprov.bat的最后部分定义如下:

:Joke
 > con echo Please give an answer to the joke:
 > con set /P yourAns=How are bats like false teeth?  &
 > con echo ** They both come out at night.
 > con echo ** You said: "%yourAns%"
 goto :eof

:Pun
 > con echo We hope you find this punny:
 > con echo Crossing a vampire bat with a computer means love at first byte.
 goto :eof

:Riddle
 > con echo Please give an answer to the riddle:
 > con set /P yourAns=This type of bat is silly.  &
 > con echo ** A Dingbat.
 > con echo ** You said: "%yourAns%"
 goto :eof 

:Joke 和:Riddle 例程结构相似。set /P 命令在揭示笑点和用户答案之前要求输入。:Pun 例程则仅仅输出机智的双关语,不需要用户输入。

这个 bat 文件并没有捕捉 stdout 和 stderr,因为这么做会阻止在每次选择之间使用 cls 命令。如果在开发过程中这成为问题,你可以暂时注释掉 echo off 命令,但要准备好面对一个凌乱的控制台,影响你的测试。

BatchImprov.bat中的每个 choice 和 set /P 命令与本章前面展示的有所不同。除了 echo 命令外,每个命令都使用重定向,通过> con 语法显式地将提示或消息写入控制台。如果 stdout 被重定向到跟踪文件,这个重定向就显得必要;否则,它会将无法回答的提示字符串写入跟踪文件。但由于 stdout 只是被抑制了,控制台的重定向在这种情况下是多余的。我在本章早些地方没有包含重定向,是因为我想专注于介绍新命令,但在实际使用中,最好总是显式地定义目标,如我在这里所做的,即使它不是必需的。

BatchImprov.bat bat 文件现在已经完全功能化。运行它,你可以回答问题并查看结果,直到你在"Do you want to try again?"提示下选择 N。毫无疑问,由于内容有限,你很快就会感到厌倦,但我们仍然有许多增强功能要讨论。在附录 A 中,你会找到一个更加动态的 bat 文件版本,能够读取包含笑话、双关语和谜语的文件;将它们存储到数组中;并在单次执行中随机访问这些数组以获得独特的内容。

总结

在本章中,我创建了迄今为止最重要的 bat 文件,目的是演示如何与用户进行交互式通信。你学习了如何提供一个选择列表供用户选择,以及如何将用户输入的自由格式响应(无论长度如何)存储到一个变量中。我还介绍了用于清屏、更新标题、改变背景和文本颜色的有用命令,包括这些命令的其他非交互式用途。

在下一章,即第一部分的最后一章,我将讨论代码块,这是一个在你深入学习过程中不可或缺的话题。代码块不仅仅是“代码块”那么简单。我将在接下来的几页中解释它是什么、为什么它很重要以及它如何有用。

第十六章:16 代码块

代码块 有时是一个相当通用的术语,指的是程序中的某个模糊部分或几行代码。在 Batch 中,它是一个明确定义的实体:一组在一对圆括号之间的命令。一个显著的例子是当 if 命令为真时经常执行的代码。

这看起来很简单,但实际上,正确且明智地使用代码块远比定义它要困难得多。Batch 的一个非常强大的功能是延迟扩展,它允许你在代码块内部以两种不同的方式解析变量,但初学者往往将这种功能误解为一个 bug。在本章中,我将详细介绍所有涉及的复杂问题,另外你还会学到如何使用代码块,特别是裸代码块,提供一种有趣的技术,使变量能够在有限范围的代码中生存。如果这听起来还不够令人印象深刻,我相信很快你会觉得它非常有用。

在代码块中解析变量

我在第二章中详细讨论了设置和解析变量的过程,但这些规则在代码块内部会有所不同。正如我很快将展示的那样,这是 Batch 的一个伟大功能,但它经常被误解,并可能导致程序员感到困惑和烦躁。即使是在多年的 Batch 编码经验之后,偶尔我也会碰到这个问题。虽然我通常能够相对迅速地找到并修复这个问题(在发出一声“哎呀!”后),但对于初学者来说,这可能会导致数小时的沮丧。这种警告最好通过一个示例来说明。

在许多情况下,同事们向我展示了一些看似简单的代码片段,比如列表 16-1 中的代码。

set price=$450
if %bSale% (
   set price=$350
   > con echo The sale price of a 50-inch TV is %price%.
) 

列表 16-1:在代码块中设置并解析的变量...以及一个谜团

代码块旁边跟着一条令人沮丧的提问:

一个变量有一个初始值,但我将其重设为另一个值,然而它似乎并没有“生效”。我将一台 50 英寸电视的价格设置为 450 美元,且我知道 bSale 布尔值为真,因为控制台确实输出了信息,但是变量的值并没有被重设为 350 美元。回显命令输出了 450 美元。这就像是代码块中的第一条命令没有执行,而第二条却执行了。疯狂吧?为了验证这个理论,我将回显命令从 if 命令代码块内部移动到其后面。突然,我得到了我想要的销售价格显示,但这并不是解决方法,因为我只想在有促销的情况下执行命令。我甚至尝试在 if 命令之前不设置变量,但结果它什么都没有。唉!这太不合逻辑了。到底发生了什么?

简短且过于简略的回答是:“将百分号替换为感叹号。”对列表 16-1 所需的唯一更改就是在回显命令的末尾解析价格:

set price=$450
if %bSale% (
   set price=$350
   > con echo The sale price of a 50-inch TV is !price!.
) 

结果正是那位困惑的程序员一直预料的:

The sale price of a 50-inch TV is $350.

当程序员难以置信地尝试并看到感叹号生效时,他们通常会感到更加恼火而非宽慰,并带着另一个问题和新的抱怨返回:“有时候你用百分号来解析一个变量,而有时又用感叹号。什么样的编程 sadist 会想到这个?难道 Batch 还不够深奥吗?当我设置一个变量时,我希望它被设置好。就这样。它有什么用?”这种抨击的唯一变化就是其强度和粗俗程度。这些评论来自一位非常温和且虔诚的同事。在回答这个功能用途的问题之前,我得先给你更好的解释,说明价格变量发生了什么。

一个变量同时拥有两个值是延迟展开的另一个应用,延迟展开首次在第三章中介绍,它允许在解析时或执行时解析变量。当一个变量在代码块内被设置时,你可以认为它此时拥有两个值。一个是它在代码块中设置时的当前值,在执行时解析。另一个是它进入代码块时被赋予的值,在解析时解析。

如果一个变量同时有两个不同的值,我们就需要两种不同的方式来解析这个变量。为此,百分号是用于揭示其进入代码块时的值的分隔符,而感叹号是用于获取其在代码块内当前值的分隔符。代码可以多次重设一个变量,而百分号分隔符仍然会将它解析为进入代码块之前的状态。

结果是,在列表 16-1 中的 echo 命令时,%price% 被解析为 $450,而 !price! 被解析为 $350。

尽管我的同事表示抗议,这根本不是什么虐待狂行为;它实际上代表了一个在大多数编程语言中缺乏的有趣特性。一个变量能够持有两个值可能很难理解,但一旦理解,它会带来许多可能性。为了演示,我将修改在列表 16-1 中写入控制台的消息。与其仅仅给出销售价格,展示原价和销售价格,显示节省的金额会更容易理解。我在列表 16-2 中为这两个值使用了相同的价格变量——一次使用百分号解析,一次使用感叹号解析。

set price=$450
if %bSale% (
   set price=$350
   > con echo A 50-inch TV has been marked down from %price% to !price!.
) 

列表 16-2:一个变量解析为两个不同的值

这一点从本质上来说是有意义的,因为这两个值实际上都是一个价格;一个是原始价格,另一个是销售价格。你本可以使用两个不同的变量,也许是 origPrice 和 salePrice,但具有敏锐眼光的程序员可能会称列表 16-2 为优雅,这是对作者的最高赞誉,尤其是在看到输出结果之后:

A 50-inch TV has been marked down from $450 to $350.

这个特性为富有想象力的编码者提供了许多可能性。你可能有一个计数器或一个变量在循环内部累计数字(在接下来的几章中,我将最终介绍 for 命令和循环)。在该循环内部,你可能需要访问原始的计数器或累计值以进行比较。循环的某些迭代中的数据条件可能会提醒你该循环本不该被处理。如果没有这个特性,你需要先执行一次循环进行验证,再执行一次处理核心逻辑。而有了延迟扩展,你只需一个循环,在任何时刻,你可以将所有变量恢复到原始值并退出循环。

这就引出了一个问题:嵌套代码块中会发生什么?在一个 if 命令的代码块内嵌套另一个 if 命令代码块时,是否会有三个活动的变量值?不会。只有两个值;一个是进入最外层代码块之前的值,另一个是代码块内部的当前值,无论嵌套层次如何。

F. Scott Fitzgerald 曾著名写道:“一流的智慧的考验是能够同时持有两个相反的观点,并且仍然保持功能。”在之前的章节中,我曾侮辱过解释器的智力,但 Batch 在这种二元性中的功能能力确实表明我可能过于苛刻了。也许解释器能处理更高级的话题,甚至是理论物理学。SchrodingersCat 变量可以同时保存两个值:生与死。

裸代码块

在之前的例子中,我仅处理了 if 命令代码块中的变量,但本章之前讨论的内容适用于任何代码块。请记住,代码块实际上只是一个或多个命令放在一对圆括号内。

另一个代码块的例子是 if 命令的 else 关键字后面的代码。我之前已经提到过,for 命令使用代码块,这些代码块可能会非常复杂,包含嵌套和频繁赋值与重新赋值的多个变量。这就是为什么这一章是接下来的讨论最重要 Batch 命令的最后先决条件。但是代码块不一定非得与命令关联。

一个 裸代码块 是作为其自身的实体创建的,而不是与 if 或 for 等命令相关联。例如,我们可以在没有 if 和条件子句的情况下重写 列表 16-2 中的 if 命令。列表 16-3 中的裸代码块刚开始看起来有些奇怪,但请注意,除了删除了文本 if %bSale% 外,其他一切都与之前相同。

set price=$450
(
   set price=$350
   > con echo A 50-inch TV has been marked down from %price% to !price!.
) 

列表 16-3:一个裸代码块,其中价格有两个值

这段代码仍然在进入代码块之前将价格变量设置为原始价格,而在代码块内,我们将该变量重置为销售价格。

在输出中,我们看到相同的文本,包括两个价格,唯一的区别是代码总是将以下内容写入控制台,因为原本是条件逻辑的部分现在无条件执行:

A 50-inch TV has been marked down from $450 to $350.

要真正展示这些括号的威力,只需移除它们并检查效果。这正是我在这里所做的,而且我甚至没有重新对齐缩进,尽管这么做对结果没有任何影响。将其与 清单 16-3 进行对比:

set price=$450
   set price=$350
   > con echo A 50-inch TV has been marked down from %price% to !price!. 

实际上,这段代码没有意义。我们在一行中设置变量,并在接下来的紧接一行中重置它,这完全废除了第一个 set 命令,那个命令不如注释掉或删除。现在,price 变量只有一个值,百分号符号和感叹号都将变量解析为其唯一值 $350,导致输出不合逻辑:

A 50-inch TV has been marked down from $350 to $350.

这组括号对代码清单产生了显著影响。它们创建了一个裸代码块,使得变量可以具有两个值,每个值可以通过不同的分隔符访问。没有括号时,代码就是废话。

使用裸代码块时,保持打开和关闭括号在同一列,并确保代码块中的代码按需缩进,就像它跟随一个 if 命令一样,这是一种良好的编码风格。你可以将 清单 16-3 中的第二个 set 命令与打开括号放在同一行,并将关闭括号放在 echo 命令后面,但这样做会让代码变得非常难以阅读。(我甚至不想展示它。)如果你在编写裸代码块时,可能有充分的理由这么做,如果你隐藏了它的存在,那么一个优雅的解决方案突然就变得难以理解。

使用裸代码块的一个很好的理由是交换两个变量的值,而不需要中介变量。这个代码将 fact 转换为 fiction,将 fiction 转换为 fact,做得比任何政治家都好:

(
   set fact=%fiction%
   set fiction=%fact%
) 

第一个 set 命令只是重置了 fact 变量,但第二个 set 命令在重置 fiction 时并没有使用这个更新后的值。相反,百分号符号在进入裸代码块之前将 fact 解析为其值。解释器在设置任何变量之前会读取并解析这两个 set 命令——并解析两个变量。如果你删除了括号,两个变量都会采用最初定义为 fiction 的值,从而完全失败了值交换。

生存于一个 endlocal 命令

任何代码块,尤其是裸代码块,还有一个非常有用的功能:让变量在 endlocal 命令之后依然生存。在第三章中,你学到了所有在 setlocal 命令和 endlocal 命令之间的变量都会在 endlocal 执行后恢复到它们的先前状态。这个很棒的批处理功能确保被调用的例程不会覆盖可能由调用者使用的变量,但它也提出了一个非常关键的问题。如果 endlocal 命令后什么都不能存活,那被调用的例程如何返回结果呢?

endlocal 命令的“问题”

为了演示这个问题,清单 16-4 中的例程接受一个以美元和美分表示的货币金额作为第一个参数,并尝试将加上 6% 销售税后的金额作为第二个参数返回。

:AddTax
 setlocal
 set factor=106
 set inAmt=%~1
 set amtNoDec=%inAmt:.=%
 set /A wTaxNoDec = amtNoDec * factor + 50
 set wTaxDec=%wTaxNoDec:~0,-4%.%wTaxNoDec:~-4,2%
❶ set %2=%wTaxDec%
❷ endlocal
 goto :eof 

清单 16-4:浪费的好数学

现在,不要被数学问题困住。(有关详细信息,请参阅“给我数学极客朋友的算术插曲”框。)与此讨论相关的是,:AddTax 例程以 setlocal 命令开始,随后是六个 set 命令。最后一个 set 命令 ❶ 将算术结果分配给第二个参数,但 endlocal 命令 ❷ 紧接着就清除了它。没有任何返回。我试图保护或隐藏对前五个变量的更改,使其不被例程外的代码看到,但我也希望让这个最后一个变量通过。到目前为止,我没有成功。

经过一番深思熟虑,倒退命令可能在 goto :eof 之前有意义。

endlocal
set %2=%wTaxDec%
goto :eof 

可惜,这个方法也不行。现在,wTaxDec 变量在 endlocal 后消失了,所以这个逻辑很可能将返回参数设置为“无”(或者 wTaxDec 在例程之前设置的值)。这是同一问题的不同表现;在 setlocalendlocal 之间设置的内容都不会保存。

裸代码块解决方案

只需简单地添加两个括号(并加上一些缩进以提高可读性),就可以创建一个以 endlocal 命令开始的代码块——并解决问题。与清单 16-4 相比:

:AddTax
 setlocal
 set factor=106
 set inAmt=%~1
 set amtNoDec=%inAmt:.=%
 set /A wTaxNoDec = amtNoDec * factor + 50
 set wTaxDec=%wTaxNoDec:~0,-4%.%wTaxNoDec:~-4,2%
 (
    endlocal
    set %2=%wTaxDec%
 )
 goto :eof 

左括号开始了代码块。endlocal 命令清除了五个变量的当前状态,将它们恢复到 setlocal 之前的状态。现在,事情变得有趣了。感叹号会将变量解析为代码块内部的当前状态,而百分号会将变量解析为代码块开始前的状态,即 endlocal 执行之前的值。因此,!wTaxDec! 解析为无(或垃圾),但 %wTaxDec% 解析为代码块前赋值的那个值,而那正是我在离开例程之前分配给第二个参数的值。

关键点是,在裸代码块内有一个狭窄的窗口——从 endlocal 到右括号之间——我们可以通过百分号解析这五个变量。我利用这个窗口,通过百分号解析出我需要的唯一一个变量,并将其值赋给返回参数。

现在我们只需要调用例程来查看它的效果:

call :AddTax 25.75 result
> con echo The amount with tax is $%result%. 

以下输出展示了成功将 6% 的销售税添加到原始金额中:

The amount with tax is $27.30.

这个例子设置了一个由例程返回的参数,但例程并不是这种技术的必需部分。在批处理文件的任何位置,你都可以通过调用 setlocal 命令来隐藏变量。在以下示例中,两个变量 survive 和 persist 在代码块中的 endlocal 之后依然存在,但 extinct 变量则不会:

setlocal
set survive=This variable will survive the endlocal
set persist=Multiple variables can survive and persist past the endlocal
set extinct=Time is very short for this variable
(
   endlocal
   set survive=%survive%
   set persist=%persist%
) 

这个代码块类似于之前的示例,但有两个相关的不同之处。首先,它保留了多个变量。其次,set 命令看起来是多余的——每个变量都被设置为其自身的解析值。每个变量的当前值在 endlocal 后是空的,但最后两个 set 命令将变量在代码块之前的值恢复过来。

这个技巧简单却不直观。endlocal 命令开始了一个裸代码块,接着是一条或多条 set 命令赋值变量,通常是赋值给它们自己。裸代码块外部的世界现在可以使用共享的变量,但不能使用任何未共享的变量。如果你希望有条件地进行赋值,只需在裸代码块中的 set 命令周围加上 if 命令和你选择的条件子句。

我必须承认,对于这个任务,还有一种不涉及裸代码块的替代方案。我的一部分心情有些后悔分享它,但我还是会分享,因为你可能某天会遇到它。你也可以通过将之前的裸代码块替换为这行非常丑陋的三条命令,使这两个变量在 endlocal 之后仍然生效:

endlocal&set survive=%survive%&set persist=%persist%

在每个命令分隔符(&)后添加一个或两个空格可能会使其更具可读性,但远远不够。请使用裸代码块。

总结

在本章中,你学习了如何在代码块中解析变量。下次当你听到有人说,“在代码块内使用感叹号,外部使用百分号”时,我希望你能有足够的知识为这场对话增添一些深度。现在你已经学会了延迟扩展和变量解析在代码块中的细微差别,你不仅能让某些东西工作,还能在适当的地方使用变量中的两个值。我还介绍了裸代码块,并展示了它在允许变量在 endlocal 命令后依然存在方面的重要作用。

接下来是久违的命令。现在我们已经准备好深入探讨这个非常重要的批处理命令,在第二部分中。

第二部分 for命令

for命令无疑是整个 Batch 编码世界中最不可或缺和最基本的命令。如果没有它,循环几乎不可能实现,而且它有许多形式,提供了许多选项和关键字,用于执行各种各样的任务。这个命令拥有本书的一个专门章节,而你现在正拿着这本书。

如果for命令没有任何选项,它仍然是 Batch 中最强大的命令,我将在第十七章中详细介绍这个没有选项的变体。但这个命令还提供了四个令人印象深刻的选项。在第十八章中,我将介绍处理目录、递归和迭代循环的选项。在第十九章中,你将了解文件读取选项,实际上它是个误称,因为它解锁了远超文件读取的强大功能。我们将在第二十章中讨论关于for命令的高级技巧。

第十七章:17 for命令的基础

在本章中,我将介绍for命令,特别是没有任何选项的for命令,它仅仅展示了该命令的整体功能。这个没有选项的命令创建了循环,其中输入可以是零个到多个值,既可以是简单的文本值,也可以是文件名。有些人称之为基本for命令,但我不太喜欢这个修饰词,因为即使没有选项(我将在后续章节中讨论),for命令也并不“基本”。

说到修饰符,在完全不同的上下文中,这个命令的语法允许使用多个修饰符,你将学习如何使用这些修饰符提取关于任何文件的丰富信息,比如它的大小、最后修改的日期和时间、属性、以及路径和文件名的部分内容。你还会看到几个没有选项的for命令在实际中的应用示例,作为它强大功能的一个小展示,我将从如何构建有关这个重要命令的个性化文档开始。

创建个性化文档

在正式开始之前,我强烈建议将for命令的帮助文档导出到一个文本文件中以备将来参考。正如你在第十二章中学到的,你可以将任何命令的输出重定向到文件,这对于help命令也是适用的:

for /? > %userprofile%\OneDrive\Desktop\ForCommand.txt

使用这个路径,命令会在我的 Windows 桌面上创建ForCommand.txt文件。你可以尝试删除*OneDrive*节点,或者将其写入你选择的文件夹,但桌面是一个很方便存放这个文件的地方。(你将在第二十一章中了解 userprofile 伪环境变量。)

我没有建议你对任何其他命令这样做,而且你总是可以在命令提示符下找到帮助信息,所以你可能会好奇为什么我在这里建议这样做。随着你探索和实验for命令,这最终将变得更多是自我文档(就像一个数字笔记本)而不是帮助文档。部分文档并不十分清晰,正如你很快会看到的,确实需要一些注解。通过这个文件,你可以添加自己的评论并修改语法,使其更符合你的理解。你还可以添加命令可以采用的不同形式的示例,并包括你以后可以提取和使用的模板。

for 命令的语法细微变化会极大影响其功能,甚至可能导致其无法工作。因此,程序员常常会尝试编写 for 命令。如果第一次不行,他们会继续尝试,可能会添加某些关键字,给输入加上双引号,或者尝试单引号。最终,他们会找到某个有效的或者看似有效的方案。更好的方法是理解这个命令多种形式的复杂性。创建个性化文档,你将能将关于这个命令的所有信息集中在一个地方(当然除了这本书)。

无选项 for 命令

让我们从没有选项的 for 命令开始。for 命令可以与(恰如其分地)四个选项一起使用,我将在接下来的两章中详细讲解这些选项,但即便不带选项,它依然是个强力工具。以下不是一个实际的命令,而是用于执行循环零次或多次的通用语法, loosely 基于帮助文档中找到的内容:

for `%%variable` in (`input`) do `command`

关键词 forindo 是保留字,在你的 for 命令中将按此方式出现。括号也会如示例所示出现,但括号中的内容是无选项 for 命令的三个主要组成部分之一。这些组成部分分别是 for 变量(%%variable)、你传递给命令的输入(input),以及在循环中执行的核心逻辑(command)。

for 变量

for 变量是这个命令的核心。如果循环执行多次,它的值会在每次迭代时发生变化。你通过两个百分号符号加上一个字符来定义 for 变量。数字和许多特殊字符都是有效的,但大多数程序员普遍使用字母。通常,%%i 是最常用的变量,其中 i 代表 索引

有些程序员专门使用 %%i,好像它是唯一允许的 for 变量,但不要局限于此。一个单字符的变量名没有太多的描述潜力,但可以利用手头的工具。我常用 %%f 表示 文件%%c 表示 计数%%n 表示 名称数字,虽然 %%# 也可以表示 数字。与其他变量名一样,很多程序员使用大写字母,但我使用小写字母。做你认为合理的,并且保持一致。

输入

for 命令的第二个组成部分是括号内的输入,或者说你传递给命令的输入。它可以是一组文件名、单个文件名、文件掩码、多个文件掩码、硬编码文本或已解析的变量。要等一段时间才能给你展示所有这些,因此目前先将其视为输入,从单个文件开始。(帮助文档使用 set 代替输入,像是文件的 集合,但这个术语不完整,且容易与同名的命令混淆。)

命令

最后,命令是每次循环执行的核心逻辑,可以执行零次或多次。它可以是单个命令,也可以是跨越多行代码的多个命令,在该逻辑中的任何地方,你都可以将 for 变量解析为它的当前值,但这个变量与之前见过的变量完全不同。通常,你使用百分号符号(前后都有)来解析变量,但你按照定义的方式解析 for 变量:前缀是两个百分号符号。更简单地说,如果你将 for 变量定义为 %%n,你可以在命令中将其解析为 %%n

类似于批处理接受参数的方式, strategically 放置的波浪号(tilde)可以移除围绕变量的双引号。对于 for 变量,波浪号出现在第二个百分号符号之后和变量名之前——例如,%%~n。与参数类似,如果没有双引号,波浪号对解析没有任何影响。

在一个非常令人惊讶的转折中,for 变量是区分大小写的,这是批处理世界中的一个显著异常。因此,%%i%%I 不是同一个实体。在另一个转折中,在第十四章你学会了如何用另一个百分号转义百分号符号。这个变量也带有双百分号符号,但解释器足够聪明,能够区分它们。例如,如果 %%i 结果是一个 for 变量,解释器会将其解析为其值;如果不是,解释器会将第一个字符视为转义字符,并将其解析为文本 %i。让我们将这些内容组合成一些实际可执行的示例。

以下 for 命令将路径和文件名写入控制台:

for %%f in (C:\Batch\MyInputFile.txt) do  > con echo Filename is %%f

对于其三个组件,我选择 %%f 作为 for 变量的简写,表示文件,因为输入是一个文件,C:\Batch\MyInputFile.txt。命令组件是一个简单的 echo 命令,用于将文本输出到控制台。

不久你就会看到 for 命令如何创建一个执行多次的循环,但这个例子只执行一次,变量被设置为括号内的路径和文件名。接下来是 do 保留字后的命令,echo 命令中的 %%f 会解析为输入文本,并将以下内容写入控制台:

Filename is C:\Batch\MyInputFile.txt

注意代码行中两个 %%f 实例,一个在行首附近,另一个在行末。第一个定义了 for 变量,第二个实例使用了该变量;也就是说,命令组件解析它并使用其值。

如果你在 echo 命令周围加上括号,会稍微更容易理解,这也是合法的语法:

for %%f in (C:\Batch\MyInputFile.txt) do  (> con echo Filename is %%f)

如果命令组件包含的逻辑稍微复杂一点,最好将其重写为多行代码,以便于阅读。

以下示例在功能上等同于前两个示例,但 echo 命令现在独立成一行。使用多行语法时,括号是必需的:

for %%f in (C:\Batch\MyInputFile.txt) do (
    > con echo Filename is %%f
) 

开括号必须紧跟在 do 保留字后面,且与其位于同一行。这是一个重要的规定,因为其他语言允许,甚至鼓励,开括号单独位于新的一行,并与闭括号对齐。

下一个例子展示了关于无选项 for 命令的三个额外要点:

for %%F in ("C:\Program Files (x86)\Notepad++\notepad++.exe") do (
   > con echo Filename is %%F
   > con echo Filename is %%~F
) 

首先,注意到 for 命令的代码块中可以包含多个命令。其次,我将 for 变量改为 %%F,只是为了展示我可以将它设为任何我想要的字符,只要在代码块中一致使用 %%F%%~F(尽管你不会再看到我用大写字母来表示这种变量)。第三,我将输入路径和文件名括在双引号中。即使路径和文件名中包含空格、括号,甚至加号,解释器也不会受到影响,因为使用了双引号。

为了演示波浪号与参数的作用,注意到在第一次使用 %%Fecho 命令输出中有双引号,而在第二次使用 %%~F 时没有双引号:

Filename is "C:\Program Files (x86)\Notepad++\notepad++.exe"
Filename is C:\Program Files (x86)\Notepad++\notepad++.exe 

对于单个文件名作为输入,这个 for 命令看起来可能有些过度,因为它的确是过度的。你完全可以用两个 echo 命令更轻松地将这两行文本输出到控制台。这个逻辑的真正优势在于你可以使用文件集和文件掩码来多次执行循环,每个文件执行一次。

注意

“for”命令for循环在很大程度上可以互换使用,但存在一个细微的差别。从技术上讲,循环可以执行零次到多次,但我通常在确定输入的性质使得循环会执行多次时,使用循环这个术语。相反,我会在知道代码块中的逻辑只会执行一次时使用命令,比如在所有之前的例子中,但如果存在歧义,我也会使用这个术语,因为它是更一般且包含的术语。

文件集、文件掩码和循环

文件集,正如你可能预期的那样,是一组文件,而 for 命令接受文件集作为输入,像接受单个文件一样简单。这里的输入文件集包含两个由逗号和空格分隔的文件,但你可以包含任意数量的文件:

for %%f in (C:\Batch\MyInputFile.txt, C:\AnotherFolder\AnotherFile.dat) do (
   > con echo Filename is %%f
) 

如果没有空格,仅使用逗号,也可以分隔这两个文件,空格而不使用逗号也是如此,但两者都能使代码更具可读性。

这是输出结果:

Filename is C:\Batch\MyInputFile.txt
Filename is C:\AnotherFolder\AnotherFile.dat 

这是第一个例子,展示了一个 for 命令如何转变为执行多次的操作——也就是更常见的所谓的 for 循环。解释器执行代码块两次,因为文件集包含正好两个文件。如果文件集中的文件数为三个,将会生成第三行输出。

在第七章中,我介绍了在 xcopy 和 robocopy 命令中使用通配符的文件掩码,创建文件掩码作为 for 命令输入时,适用相同的规则(以及注意事项)。星号代表任意数量的字符,甚至没有字符,问号通常表示恰好一个字符。然而,问号位于掩码末尾或后面跟着点时,也可以表示没有字符。

为了演示,我将从一个熟悉的 for 命令开始,替换文件名为最简单的掩码,即一个孤立的星号:

for %%f in (C:\Batch\*) do (
   > con echo Filename is %%f
) 

现在,解释器不会再将单个文件名写入控制台,而是会将文件夹中的每个文件逐一输出,因为每个文件都符合掩码。如果*C:\Batch*中有 17 个文件,所有 17 个文件都符合掩码,解释器会为所有 17 个文件写一条消息到控制台。如果文件夹中只有一个文件,echo 命令只会执行一次,如果没有文件,echo 命令则完全不执行。

将文件名输出到控制台并不是很令人满足,但你可以使用文件掩码执行更有趣的任务。你可以重命名满足掩码条件的每个文件,或者可能为每个文件调用一次编译程序。无论任务是什么,for 循环的基本结构都不会改变,如果逻辑会比较复杂,最好将其设置为内部例程。这里是一个调用命令,它为每个文件调用这样的例程,并将每个文件的路径和文件名作为唯一参数传递:

for %%f in (C:\Batch\*) do (
   call :SomeComplexTask "%%~f"
) 

如果有十几个文件符合此掩码,call 命令会调用例程 12 次,每次针对一个文件。

Batch 甚至接受以逗号分隔的文件掩码列表作为 for 命令的输入。也就是说,你可以创建一个包含多个文件和/或文件掩码的文件集。

简单文本作为输入

另一个有趣的无选项 for 命令的用法在帮助文档中找不到。你可以通过将值逐一放入括号内作为输入,处理一个值的列表。Batch 用于分隔传递参数的字符集也用于分隔此列表,所以你可以使用逗号、分号、等号和制表符,但空格分隔的列表是常见的做法。语法与之前几个示例类似,但分隔的文本替代了文件名或掩码。例如,这个 for 循环会依次将括号中的五个单词传递给代码块:

for %%i in (Individual line for each word) do (
   > con echo %%i
) 

结果是每个单词都会被逐行写入控制台:

Individual
line
for
each
word 

注意,Batch 将括号内的 for 视为简单文本,而不是作为启动命令的保留字。我曾对解释器提出过一些质疑,但它足够聪明,能够根据上下文区分这些差异。

先前的输入列表是以空格分隔的,但解释器会将双引号包围的内容视为一个单独的值,这意味着以下 for 命令会执行两次其代码块:

for %%i in ("Just two lines for" "these seven words") do (
  > con echo %%~i
) 

结果是这两行输出到控制台,而不是七行:

Just two lines for
these seven words 

注意,使用波浪符号 ~ 解析 for 变量时,%%~i 会去掉每个字符串中的双引号。

这些简单的示例掩盖了该技巧的巨大实用性。为了说明这一点,我将从一个接受单个参数进行处理的 bat 文件开始。这里的处理过程不重要;也许传递的值会被添加到数据结构中,或者它是一个传递给可执行文件的文件名。无论是什么处理过程,关键在于,如果我有 17 项需要处理,我必须调用 bat 文件 17 次。但使用这个将列表作为 for 命令输入的技巧,我可以增强 bat 文件,使其能够接受任意数量的参数,并在一次执行中逐一处理它们。

我将首先把处理单个参数的逻辑放入一个可调用的例程中,并通过 :ProcessParm 标签进行定义。现在,我可以通过在 bat 文件顶部加入以下代码,调用该例程零次或多次:

for %%i in (%*) do (
   call :ProcessParm %%i
)
goto :eof 

我在括号中输入了 %* 作为 for 命令的输入。(记住在第十一章中,%* 会扩展为 bat 文件接收到的完整参数列表。)

如果有 99 个参数,所有 99 个值都会作为 for 命令的输入,解释器会执行循环体 99 次。在第一次执行时,%%i 解析为列表中的第一个参数,并作为参数传递给调用命令中的例程。第一次执行完后,第二个参数成为第二次执行的参数,以此类推,直到解释器处理完所有 99 个参数。

这种技巧的其他用途也很多。例如,你可以轻松地对一个空格分隔的数字列表求和。我还没有介绍数组,但这提供了一种很好的方法来将多个值添加到数组中。

检索文件信息

无选项的 for 命令还有一个更棒的功能。它可以检索关于文件的大量信息和数据,比如文件大小、最后修改日期/时间等等。在本章前面,我讨论了将文件名传递给 for 循环的技巧,目的是简单地将路径和文件名写入控制台。例如:

for %%f in ("C:\Program Files (x86)\Notepad++\notepad++.exe") do (
   > con echo Filename is %%~f
) 

让我们把这个变得更有意义。代码不再只是简单地回显路径和文件名,而是检索并输出关于文件的大量信息。如果文件有什么需要隐藏的,我们就把它暴露出来。我最终对前面示例中的代码所做的唯一修改是与 for 命令相关的代码块,但在此之前,我必须先介绍修饰符。

修饰符

提取文件有用数据的工具被称为 修饰符,其中 9 个修饰符都是单个字母字符,巧妙地插入到 for 变量中进行解析。(我很快就会介绍第十个修饰符。)

要使用修饰符,请从一个 for 变量(例如 %%f)开始。然后在两个百分号之间和一个字符变量名之前插入波浪号和修饰符字符。例如,X 是用于检索文件扩展名的修饰符,这意味着对于 for 变量 %%f,解释器将 %%~Xf 解析为扩展名。

接下来的代码块现在有 11 个 echo 命令。前两个没有什么新意;它们都显示路径和文件名,第一个带双引号,第二个去除了双引号。其他九个 echo 命令使用特定的修饰符来修改 %%f 变量,这一点很容易理解:

for %%f in ("C:\Program Files (x86)\Notepad++\notepad++.exe") do (
   > con echo                  Filename = %%f
   > con echo   Filename Without Quotes = %%~f
   > con echo Fully Qualified Path Name = %%~Ff
   > con echo         Drive Letter Only = %%~Df
   > con echo                 Path Only = %%~Pf
   > con echo             Filename Only = %%~Nf
   > con echo       File Extension Only = %%~Xf
   > con echo           Short Name Only = %%~Sf
   > con echo           File Attributes = %%~Af
   > con echo        File Date and Time = %%~Tf
   > con echo                 File Size = %%~Zf
) 

我在代码中已记录了这九个修饰符。

执行此代码可能会将以下文本写入控制台。仔细检查每个修饰符对输出的影响:

 Filename = "C:\Program Files (x86)\Notepad++\notepad++.exe"
  Filename Without Quotes = C:\Program Files (x86)\Notepad++\notepad++.exe
Fully Qualified Path Name = C:\Program Files (x86)\Notepad++\notepad++.exe
        Drive Letter Only = C:
                Path Only = \Program Files (x86)\Notepad++\
            Filename Only = notepad++
      File Extension Only = .exe
          Short Name Only = C:\PROGRA~2\NOTEPA~1\NOTEPA~1.EXE
          File Attributes = --a--------
       File Date and Time = 03/11/2010 01:23 PM
                File Size = 2958480 

这确实是相当多的数据。从第三行开始,F 修饰符为我们提供了完全限定的路径名。通常,%%~Ff 解析出的值与 %%~f 相同,正如这里所示,但并非总是如此。如果输入仅由文件名和扩展名组成,没有路径,并且解释器在当前目录中找到了该文件,那么 %%~f 会模拟输入,省略路径,而 %%~Ff 会解析为完整的路径和文件名。

接下来,你可以在接下来的四个修饰符中看到这个完全限定路径名的各个组成部分:D(带冒号的驱动器字母)、P(没有驱动器字母的路径或目录)、N(裸文件名——即没有扩展名)、以及前面提到的 X(文件扩展名,包括前面的点)。

S 修饰符生成操作系统定义的短文件名(我答应过会在第七章中展示如何找到这个文件名),而 A 修饰符生成文件属性的列表。一个文件有 11 种可能的属性,如果文件没有某个特定属性,相应的字节会显示为破折号。此列表中唯一显示的属性是 a,表示文件被归档,但缺少其他值意味着文件不是隐藏的、压缩的、只读的,也不具有其他任何可能的属性。(我将在第三十章中进一步讨论属性。)

最后,T 修饰符提供了文件最后修改的日期和时间,而 Z 修饰符返回文件大小(以字节为单位)。(记住,S 已被占用。)缺乏逗号使得输出不易读取,但从输出中可以看到文件接近 3MB。

注意

除非你直接跳到本书的这一部分,否则你应该知道我并不喜欢过度使用大写字母,但每种方式都有其适用的场合。修饰符不区分大小写,但我使用大写字母来使它们更加显眼。小写字母的变量,在这种情况下是 f 代表文件,终结了需要解析的内容,但我知道我是少数派。请注意,大多数程序员会做相反的操作,所以你更可能看到 %%~zI 来获取 %%I 的文件大小,而不是我用来获取 %%f 文件大小的 %%~Zf。

修饰符的应用几乎是无限的,但一个简单的用途是对文件执行某种复杂的任务,但前提是文件中有数据。假设批处理代码是处理来自其他来源的数据文件。即使来源没有数据可报告,最好也让它创建一个空文件,因为根本没有文件将意味着一个失败的进程。为了使其生效,bat 文件需要判断文件是空的还是已填充数据。考虑以下内容:

for %%f in ("C:\Batch\IntermediateFile.dat") do (
   if %%~Zf gtr 0  call :SomeComplexTask "%%~Ff"
) 

for 命令内部的 if 命令有效地(甚至可能优雅地)验证了中间数据文件%%~Zf 的大小是否大于 0 字节,只有在通过验证后,才会调用一个例程并传递文件的路径和名称。

路径修饰符

我欠你一个第十个修饰符,万一你还没有觉得这个语法足够复杂,路径修饰符的语法与其他九个完全不同。但这并不是问题,因为它的功能也完全不同。我会稍后解释它的作用,但首先要理解的是,这个修饰符实际上是一个奇特的分隔变量。其他修饰符都是单一字符,而路径修饰符是一个以美元符号开头并以冒号结尾的变量名。

为了设置这个,且为了马上就能明白的原因,让我们在修饰符中使用路径变量(与第八章中介绍的包含由分号分隔的目录的连接的相同变量)。这个变量应该已经在你的机器上设置好了,但你可以向其中添加或前置额外的目录:

path C:\Batch\SubDir\;C:\Batch\;%path%C:\Budget\;

为了看到这个修饰符的实际效果,让我们使用%%~\(path:f 语法修改%%f 变量。注意路径变量夹在\)和:字符之间;这三个部分组成了修饰符。现在我们可以在 for 命令中使用它:

for %%f in (FourBrits.txt) do (
   > con echo File Found: %%~$path:f
) 

这个修饰符的实际功能是什么?它指示解释器遍历路径变量,从其中列出的第一个目录开始,寻找第一个(并且只有第一个)名为FourBrits.txt的文件。如果该文件存在于路径变量的第二个目录中,但不在第一个目录中,结果输出将包含完整的路径和文件名:

File Found: C:\Batch\FourBrits.txt

如果解释器在路径变量定义的任何目录中找不到名为FourBrits.txt的文件,它会简单地将%%~$path:f 解析为 null。

这个功能显然是为路径变量设计的,帮助文档在示例中明确使用了路径变量;只有在文档底部才提到,你可以用任何有效的变量替换它,意味着任何包含目录列表的变量。尽管如此,它通常只是被称为路径修饰符,并几乎专门用于路径变量。

堆叠修饰符

单独使用时,这些修饰符非常有用,但它们的真正威力在于将多个修饰符叠加使用时展现出来——也就是说,当它们一起使用时,可以一次解决多个文件特性。例如,你可以分别解析文件名和扩展名,然后将它们拼接为%%Nf%%Xf,但这有点混乱。相反,%%~NXf 巧妙地以更优雅的方式产生相同的结果。以下是三个典型的例子:

for %%f in ("C:\Batch\FourBrits.txt") do (
   > con echo  Drive Letter and Path = %%~DPf
   > con echo Filename and Extension = %%~NXf
   > con echo    File Date/Time/Size = %%~TZf bytes
) 

这段代码可能会向控制台输出以下内容:

 Drive Letter and Path = C:\Batch\
Filename and Extension = FourBrits.txt
   File Date/Time/Size = 04/20/2018 01:14 PM 518 bytes 

使用叠加修饰符,你可以轻松获取文件的完整路径而不带文件名,或者仅获取文件名和扩展名而不带路径。最后一个示例可能会在报告中显示,并在结尾添加硬编码的文本“bytes”。

叠加修饰符甚至可以与路径修饰符一起使用:

for %%f in (FourBrits.txt) do (
   > con echo Path Found: %%~DP$path:f
) 

这段代码输出在路径层级中找到的第一个文件的完整路径,不包括文件名和扩展名,该文件名为FourBrits.txt

带修饰符的参数

在进入几个实际示例之前,我将回到第十一章关于参数的讨论。事实证明,提取文件信息的修饰符在 for 命令的上下文中同样适用于参数。

我曾提到过,你可以将带或不带路径的文件名作为参数传递给批处理文件,并且被调用的批处理文件可以通过%1、%2 等解析它们。此外,你还可以将%~0 解析为正在执行的批处理文件的完整路径和文件名。好吧,前面讨论的相同修饰符(以及叠加修饰符)也适用于任何代表文件的参数。假设以下代码接收两个文件名作为前两个参数:

> con echo           Size of File #1 = %~Z1
> con echo  Date and Time of File #2 = %~T2 

第一个命令输出第一个文件的字节大小,第二个命令输出第二个文件的最后修改日期和时间。

将两个特定的叠加修饰符应用于隐藏参数,%~DP0 解析为正在执行的批处理文件的驱动器字母和路径。凭借这些信息,你可以创建子目录,将其他文件存放在此目录或兄弟目录中,或者更新与批处理文件相关的日志文件,而且不需要知道批处理文件最终将安装在哪里。也许它会部署在多台服务器上。

修饰符允许你通过最少的键盘操作检索大量的文件信息。

实际应用

让我们在两个实际示例中应用你刚刚在本章中学到的所有有用工具。

备份中的文件重命名

使用文件掩码,无需选项的 for 命令可以生成文件列表,而修饰符可以提取每个文件路径和文件名的各个组件。结合这两种功能,你可以将一组文件复制到另一个驱动器上的镜像文件夹结构中,同时调整目标文件名。

假设我有一个名为C:\Budget*的文件夹,顾名思义,它包含预算信息。这个文件夹的名称显然表示应该进行备份,可能是备份到外部D:*驱动器,但保持相同的文件夹结构,以便文件容易找到和比较。更复杂的是,我希望在每个文件名前添加 Bkup_,因为当两个文件夹同时打开时,很容易在错误的文件夹中工作,但如果备份目录中的每个文件都以这个独特且具描述性的文本开头,就不容易出错。完成这个任务的一种方法是在for循环中使用单个命令(尽管在实际的实际应用中,你可能需要一些错误处理):

for %%f in ("C:\Budget\*.*") do (
   echo f | xcopy "%%~Ff" "D:%%~PfBkup_%%~NXf" /Y /F
) 

我已经将源路径和目标路径都用双引号括起来,以处理文件名中可能包含空格的情况。源路径是完全限定的路径和文件名,%%~Ff,但目标路径则稍微复杂一些。尽管它看起来像是随机的按键输入,实际上它是四个项目的拼接,当我加粗了两个带修饰符的for变量与常量的对比时,它变得更易读:

D:**%%~Pf**Bkup_**%%~NXf**

目标路径以硬编码的驱动器字母和冒号 D:开头,后跟源文件变量的路径,去掉了驱动器字母%%~Pf;这模仿了源驱动器中的源文件夹。路径的开始和结束都有反斜杠,因此接下来的是文件名的开始部分 Bkup_。为了完成目标文件名,解释器找到第二个for变量,%%~NXf,这个变量包含了源文件名(N)和文件扩展名(X)的堆叠修饰符。

综合起来,如果解释器找到一个名为C:\Budget\Budget.January2023.xlsx的文件,结果目标字符串是:

D:**\Budget\**Bkup_**Budget.January2023.xlsx**

如果你习惯了由百分号分隔的批处理变量,变量的起始和结束位置可能会让你感到困惑。当编译器看到%%~时,它知道接下来将解析一个for变量——那是开始的位置。由于由这个for命令定义的变量是%%f,解释器会在看到小写字母 f 时结束该变量——那是结束的位置。在开始和结束之间,解释器会查找零个或几个有效的修饰符。终止后的部分可能是常量,一个百分号符号开始解析一个更传统的批处理变量,或者是另一个for变量。

源目录中每个符合条件的文件都会被复制到*D:*驱动器的备份路径,并且目标文件的名称会经过调整。这个工具非常强大,虽然看起来只是一个简单的for命令,但要充分利用它的能力,必须对语法有深入的理解。

处理可变数量的文件

在第十一章的末尾,我介绍了包装器批处理文件的概念——即一个批处理文件,它所做的几乎只是执行一个处理单个输入文件的程序。这个批处理文件是可执行文件的包装器。在那一章中,我还演示了如何将多个文件拖放到一个批处理文件上,从而使得批处理文件执行一次并带有多个参数。尽管这很令人印象深刻,但如果没有一个能够处理所有这些参数的批处理文件,或者能够处理不同数量参数的批处理文件,这个技巧就几乎没有用处。

在本章中,我讨论了两个对于构建此类包装器批处理文件非常重要的概念。一个是使用修饰符提取文件信息的非常有用的技巧,另一个是 for 命令处理值列表的能力。

现在,来介绍一下设置。我创建了一个已编译的程序,用于将 Java 代码转换为 C#代码,并且它接受两个参数:输入文件和输出文件。代码转换程序可以帮助减少将旧代码更新为新语言时的麻烦。一个语言的模块作为输入传递给程序,该程序将原始语法的大部分转换为另一种语言,并在相同的文件夹中输出一个同名但扩展名不同的文件。从批处理文件的角度来看,它接受一个或多个.java文件作为参数,确定相应的.cs输出文件的路径和文件名,然后使用这两个参数调用已编译的代码——而这些.java文件可以在任何文件夹中。

以下是一个批处理文件,去除了所有错误处理和注释,它完成了所有期望的功能。for 命令接受整个参数列表作为%*,将它们一个个传递到代码块中作为%%f,后者则是传递给:ConvOneFile 例程的唯一参数:

 for %%f in (%*) do (
    call :ConvOneFile %%f
 )
 goto :eof

:ConvOneFile
 ConvJava2CS.exe  %~F1  %~DPN1.cs
 goto :eof 

在调用ConvJava2CS.exe时,这段批处理代码将输入文件和输出文件作为两个参数传递给它。我使用%~F1 获取输入文件的完整文件名,这是第一个参数加上 F 修饰符。输出路径和文件名%~DPN1.cs 则更为复杂。我使用相同的参数,也就是%1,但加上了驱动器(D)、路径(P)和文件名(N)的修饰符——也就是说,%F1 去掉扩展名(通过 X 修饰符表示)。然后,我再加上硬编码的.cs 扩展名,形成输出文件名。注意,这些变量前面只有一个百分号,而不是两个,因为这些是参数,不是变量;我在一个例程中调用可执行文件,而不是在 for 命令中。

一个评论家(或者可能是一个不熟悉批处理的人)可能会对此提出异议,认为接受编译代码中的单个参数并在程序中进行文件名操作更好或更容易。但这种方法缺乏灵活性;如果其他人希望在许多文件甚至数百个文件上运行此过程,则可能希望将输出放入子文件夹,甚至放在另一个服务器上的文件夹中。通过在批处理代码中操作文件名,将输出放入子目录无需更改编译代码;而是简单地将%~DPN1.cs 更改为%DP1%subDir%%N1.cs。您可以定义一个硬编码的子目录,但在这里我使用 subDir 作为子目录节点的变量。如果子目录不存在,甚至可以执行带有%~DP1%subDir%\作为其参数的 md 命令来创建子目录。

即使是对编译代码的简单更改,也总会有额外开销。您必须运行编译器并注意保持源代码和可执行文件同步。在可能的情况下,编码人员应该进行简单的更改,例如在批处理代码中获取文件连接器或文件名,至少在我看来是如此。

现在,您可以使用此批处理文件处理多个文件或单个文件,甚至不处理任何文件。如果没有传递参数,for 命令就没有输入,并且代码块永远不会执行。再次强调,几行代码正在执行比眼前看到的更多的工作。

在代码块中解析变量

在继续解锁 for 命令选项提供的所有功能和强大功能之前,我必须提到,到目前为止,我已经解析了与 for 命令相关联的代码块内部的单一用途变量,但这太过简单和幼稚。更常见的做法是将数据分配给一个变量,然后在复杂的代码块内使用它,甚至可能修改它。这就是第十六章详细描述的同一类型的代码块,解析变量的规则也适用于此。

例如,您可能希望为文件的日期和时间分别使用两个不同的字段,但 T 修饰符将它们解析为单个值,而在这段代码中这根本不是问题:

for %%f in ("C:\Program Files (x86)\Notepad++\notepad++.exe") do (
   set filDtTm=%%~Tf
   > con echo                 File Date = !filDtTm:~0,10!
   > con echo                 File Time = !filDtTm:~11!
) 

在代码块顶部,我将整个字符串分配给 filDtTm 变量,然后在接下来的两行分别截取日期和时间。这是一个微妙但至关重要的点:我使用感叹号和延迟扩展来解析刚在代码块内设置的变量。百分号分隔符将在代码块之前解析为 filDtTm 的值,而且由于这个值很可能根本没有被设置,结果可能会是垃圾(~0,10 和~11)。

最终,如果你在代码块内为变量赋值,你必须使用感叹号来获取其当前值。如果逻辑变得过于复杂,还有其他技巧,而在第二十章中,我将演示如何在这些更复杂的代码块中充分利用延迟扩展。但通常来说,要理解这些变量有两种可能的值,并尽量避免让代码过于复杂。

总结

在这一章中,我详细介绍了不带选项的for命令、它的组成部分、如何与文件集和文件掩码一起使用,以及如何通过修饰符检索大量文件信息。你了解到这个命令通常接受文件或多个文件作为输入,但它也可以接受一串文本。我希望你觉得这些实际应用既有趣又富有启发性,并且激发你思考这个重要命令的其他相关用途。我还提供了一些关于如何解析for命令代码块中定义的变量的提示。

第二部分的剩余章节将揭示更多内容,所有这些内容将有助于全面理解for命令的全貌。在下一章中,我将讨论一些通过选项开启的功能;其中一个列举目录而不是文件,另一个遍历子目录寻找文件,最后一个实现了一个至关重要的功能:迭代循环。

第十八章:18 目录、递归和迭代循环

许多批处理命令都有选项,但大多数提供的只是命令的稍微不同的变种或调整。for 命令的选项则是一个完全不同的故事。四个可用的选项以四种不同的方式影响 for 命令。在本章中,我将详细介绍其中的三个,第四个将在下一章中讲解,并且需要专门的一章来描述。

一个选项将命令的焦点从文件转向目录,另一个则使用递归遍历子目录以查找文件。你还将学到,如何将这两个选项结合使用来遍历一个目录树,查找子目录。

本章中的最后一个选项将 for 命令变成了一个我尚未讨论过的全新东西。它的功能与没有选项的 for 命令或带有其他选项的命令有很大的不同。它创建了一个迭代循环,在从一个数字到另一个数字的过程中,按照固定的增量或减量执行逻辑。这项工具对于任何程序员来说都是绝对必要的。

目录选项

不是所有选项字母都能准确描述它们的功能,但 /D 选项代表 directory(目录)。虽然没有选项的 for 命令会枚举文件名列表,但 /D 选项使得命令可以枚举目录或文件夹的列表。通用语法显示,除了插入该选项外,语法与没有选项的 for 命令完全相同:

for /D %%`variable` in (`input`) do `command`

在使用该选项之前,这里有一个类似于上一章的示例,一个没有选项的 for 命令,在路径后使用了一个唯一的通配符字符作为文件名:

for %%f in (C:\Budget\*) do (
   > con echo Filename is %%f
) 

结果输出包括文件夹中每个文件的路径和文件名,并写入控制台。

在下面的示例中,我对没有选项的 for 循环做了两个修改:一个是重要的,另一个是外观上的修改。重要的修改是将 /D 选项插入到 for 变量之前。注意,for 命令的其余部分保持完全不变。外观上的修改是我将 echo 命令中的“File”替换成了“Directory”:

for /D %%f in (C:\Budget\*) do (
   > con echo Directory Name is %%f
) 

对这段代码的另一个可能修改是将 %%f 替换为 %%d 来代表 directory(目录)(但我不打算使这个示例过于复杂)。

在没有 /D 选项的情况下,这个文件夹中的文件不再是输出的一部分。现在,解释器将立即将每个位于 *C:\Budget* 下的目录写入控制台,可能是:

Directory Name is C:\Budget\BankStatements
Directory Name is C:\Budget\CreditCardStatements 

该输出假定这是唯一的两个子目录,但那些子目录的子目录并未出现在输出中。

递归选项

另一个有用的选项是/R,它方便地代表递归。这个选项使得for命令能够递归地在一个目录及其所有子目录(以及它们的子目录,依此类推)中搜索符合某个模式的文件。与没有选项的命令语法相比,它的通用语法与仅有选项的命令语法有所不同:

for /R [[`drive`:]`path`] %%`variable` in (`input`) do `command`

最显著的区别在于要搜索的路径现在出现在for变量之前。如果省略尾部斜杠,解释器不会受到影响,但最好加上斜杠,以明确表示它是一个路径。路径可以仅是驱动器字母后跟冒号,并且对于包含空格的路径,你需要用双引号括起来。在括号内,输入将是一个或多个没有路径的文件名,文件名之间用空格或逗号分隔。例如,以下命令会在*C:\Budget*目录及其所有子目录中搜索 Word 文档,并将找到的文件写入控制台:

for /R C:\Budget\ %%w in (*.docx) do (
   > con echo Word Document Name is %%w
) 

以下for命令会找到你在意大利旅行中错放的照片,或者至少是任何扩展名为*.jpg**.bmp*的文件,且文件名以Italy开头。由于使用了/R选项,命令不仅会在根目录C:*文件夹中查找,还会在你整个C:*驱动器中查找:

for /R C:\ %%p in (Italy*.jpg, Italy*.bmp) do (
   > con echo Photo from the Italy Trip is %%p
) 

(多个文件模式在所有for命令中都可以使用,无论是否有选项。)

for /R 命令有其他几种变体。在一般语法中,围绕驱动器和路径的方括号意味着它们是可选的,如果省略,则假定当前目录。因此,前面的命令在功能上等同于以下命令,因为*C:*是当前目录:

cd C:\
for /R %%p in (Italy*.jpg, Italy*.bmp) do (
   > con echo Photo from the Italy Trip is %%p
) 

在这两个例子中,%%p将解析为每个找到的照片的完整路径和文件名。

在一个微妙的警告中,所有传递给for /R命令的输入必须至少包含一个通配符字符。如果你在之前的命令中使用了显式的文件名(或没有星号或问号的任何文本)作为输入,它会返回*C:*及其所有子目录,并附带你的文件名,即使文件在目录中不存在。要找到显式的文件名,你必须在文件名输入中添加一个通配符字符。我建议使用尾部星号,因为它通常最不容易意外捕获其他文件。

注意

无论好坏,解释器自行处理递归,即进入每个子文件夹,隐藏它给你。在第二十三章中,我将重新讲解递归并解释如何定义实际的递归调用,从而开启更多可能性。

目录递归

如果/D选项允许目录搜索,而/R选项允许递归文件搜索,你可能会认为它们可以一起用于递归目录搜索,确实如此。格式遵循/R命令的通用语法,/D/R之前,并且括号中的输入仅为一个星号:

for /D /R C:\Budget\ %%d in (*) do (
   > con echo Directory is %%d
) 

运行此代码会显示 *C:\Budget* 所有子目录及其所有子目录,依此类推。例如,假设存在两个特定的子目录和一个子子目录,输出会是:

Directory is C:\Budget\SubDir
Directory is C:\Budget\Taxes
Directory is C:\Budget\SubDir\SubSubDir 

在一个有趣的怪异情况下,单独使用 for /R 命令也可以执行相同的功能,而不需要 /D 选项,或者至少执行的是类似的功能。为了演示这一点,我对之前的 for 命令做了两个小调整。我去掉了 /D 选项,并将输入从星号改为点:

for /R C:\Budget\ %%d in (.) do (
   > con echo Directory is %%d
) 

这并不直观,但括号中的点会指示 for /R 命令列举目录而不是文件。(目录和点都以 D 开头,如果这有帮助的话。)这也会产生子目录的列表,但请注意三点不同:

Directory is C:\Budget\.
Directory is C:\Budget\SubDir\.
Directory is C:\Budget\SubDir\SubSubDir\.
Directory is C:\Budget\Taxes\. 

当你使用 /R 选项并将输入改为点时,第一个不同之处是解释器现在会列举根目录,在这个例子中是 *C:\Budget*,以及所有子目录。第二,输出的每个目录后面会跟着一个反斜杠和一个点,第三,输出排序方式也不同,解释器会先处理一个目录的子目录,再处理其兄弟目录。请注意,当同时使用 /D 和 /R 选项,并且输入为星号时,解释器会先处理 *C:\Budget\Taxes*,再处理 *C:\Budget\SubDir\SubSubDir*,这与使用 /R 选项和点时的结果正好相反。

警告

我所详细说明的关于点作为输入传递给 for /R 命令的内容,正是帮助文档所说的那样,但这并不完全正确。每行输出末尾的点只是输入的再现。这是前一节中提到的 batveat 的另一种表现形式。任何没有通配符的输入都会告诉命令逐步遍历所有子目录,并将每个子目录附加上该输入。点在上面的例子中很好地结束了每个目录,但如果你改为使用波浪号作为输入,输出中的目录都会以波浪号结尾,而不是点。

输出中的差异很细微,根据你的应用程序的不同,这些差异可能并不重要,但无论如何,它们依然存在。

迭代循环选项

/L 选项将 for 命令转变为一个迭代循环,这是任何编码者工具箱中的必备项目,并且可能是最常用的命令变体。我至今讨论的循环都在遍历一系列文件、目录或某种文本。然而,这个选项将 for 命令变成一个通过某个数值或步长从一个数字递增或递减到另一个数字,并且有一个固定结束值的循环。大多数编程语言都会以某种方式实现迭代循环。事实上,许多语言也有一个专门用于此目的的 for 命令。批处理脚本的独特之处在于,for 命令做了这么多其他事情。

将命令转变为迭代循环的一般语法是:

for /L %%`variable` in (`start`, `step`, `end`) do `command`

除了添加 /L 选项外,这与没有选项的 for 命令之间唯一的区别是括号内的数据,在这里三个用逗号分隔的数字组成了输入。第一个是起始值或起始索引;第二个是步长,即每次循环迭代中索引递增的量;最后一个是结束值,即索引可以达到的最后一个值。

例如,这个 /L 循环从值 1 开始,每次迭代步进 2,直到 3 结束:

for /L %%i in (1, 2, 3) do  > con echo Index is %%i

这个命令在循环的第一次迭代中将 for 变量 %%i 设置为 1;然后,它以步长 2 递增,使得 %%i 第二次通过时变为 3。这与结束值匹配,因此循环不再执行。以下是输出到控制台的内容:

Index is 1
Index is 3 

以下 for 命令从变量设置为 10 开始,然后每次递增 1,直到达到 12:

for /L %%i in (10, 1, 12) do  > con echo Index is %%i

这会遍历索引 10、11 和 12。

要递减索引,请将步长赋值为负数:

for /L %%i in (2, -1, 0) do  > con echo Index is %%i

这会导致索引从 2 降到 0:

Index is 2
Index is 1
Index is 0 

所有三个数值输入都可以是负数:

for /L %%i in (-1, -3, -7) do  > con echo Index is %%i

这个 for 循环会对这三个索引执行:

Index is -1
Index is -4
Index is -7 

给定以下的起始值和步长,显然这个 for 命令生成了一个从 10 开始的递增的正数 10 的倍数序列:

for /L %%i in (10,10,35) do  > con echo Index is %%i

更不清楚的是序列到底在哪里结束。35 的结束值不是 10 的倍数,因此不在序列中,但一旦索引大于 35,循环就会结束,所以 30 是序列中的最后一个数字。如果结束值是 30、39 或任何介于它们之间的整数,命令在功能上是等效的,但为了清晰起见,30 会是最好的选择。此外,请注意,我在输入中省略了逗号后的空格。通常,我会为了可读性添加空格,但这个例子展示了它们并不是必需的(并且很容易被忽略)。

幂函数例程

不幸的是,Batch 不支持幂函数。在第六章中,我提到我们可以为此任务编写一个简短的例程,这里是承诺中的例程,它使用了一个迭代循环。它接受三个参数:指数的基数、指数值以及包含结果的返回变量的名称:

:Pow
 set %3=1 
 for /L %%i in (1, 1, %2) do  set /A %3 *= %1
 goto :eof 

(如果返回参数让你困惑,可以回到第十一章。)

我将返回参数初始化为 1。然后,循环从 1 开始,每次递增 1,直到达到指数或第二个参数的值。因此,如果指数是 n,循环执行 n 次。循环内的命令将返回参数乘以指数的基数或第一个参数。因此,如果基数是 b,这个 for 循环将 nb 相乘。当循环完成时,返回参数包含 b**^n,例程结束。

要找到 53,请调用例程并传递这三个参数:

call :Pow 5 3 pow
> con echo Five cubed = %pow% 

代码将 3 个 5 相乘,并将结果 Five cubed = 125 输出到控制台。

案例研究

作为 18 世纪末的七岁数学神童,伟大的数学家卡尔·弗里德里希·高斯和全班其他同学一起,从老师那里接到了一项繁重的任务。学生们需要将 1 到 100 的所有数字加起来。过了一会儿,老师抬起头,看到所有的孩子都在忙着用粉笔和石板做加法,只有一个人没有。他走近年轻的高斯,准备严厉训斥,结果发现高斯的石板上写着正确答案 5050。

高斯意识到有 50 对数字加起来是 101(100 + 1,99 + 2,...,51 + 50)。他迅速将 50 乘以 101,写下答案,然后坐回去等待同学们完成,可能在想他们怎么还没做完。

这个故事有其他不同的版本,虽然它可能是传说,但如果那些其他的学生能够使用一台 Windows 电脑,他们也许能在年轻天才之前就完成任务——前提是他们能够快速键入以下内容:

set sum=0
for /L %%i in (1, 1, 100) do (
   set /A sum += %%i
)
> con echo The sum is %sum%. 

这个循环从 1 到 100 迭代%%i 索引,其中 set /A 命令会累加所有索引的值。在循环之前,set 命令明确地将 sum 初始化为 0,从而保证最终结果是所需的值。高斯的技术娴熟的同学们或许能在与高斯本人相当的时间内,把输出“总和是 5050”写到他们的石板上。

总结

在本章中,你学习了 for 命令的三个选项。/D 选项允许命令枚举目录而不是文件,/R 选项则使用递归来遍历子目录。我甚至演示了递归枚举目录的两种方法。

你还学习了如何使用/L 选项创建一个迭代循环,并掌握了它的所有方面。我使用了带此选项的 for 命令来创建一个将一个数字提升为另一个数字的例程,甚至还顺便讲了一下快速的数学历史课程以及一点批处理算术。

现在只剩下一个选项了,你将在下一章学习它。它允许读取文件以及更多操作。

第十九章:19 读取文件和其他输入

到目前为止,功能强大的 for 命令的最复杂语法和最强大的功能是通过 /F 选项实现的,它允许进行文件读取。没有选项的 for 命令已经能够处理文件,比如获取文件的名称、路径、驱动器、大小和属性,几乎涵盖了与文件相关的所有内容,除了文件的实际内容。接下来就是 /F 选项。

在介绍语法后,我将演示如何逐条记录地读取文件。你将学习如何操作输入数据,将记录的全部内容放入变量中,或将其拆分为批处理所称的 tokens。通过对数据进行标记化处理,你将能够提取你需要的特定数据部分。

尽管 for /F 命令表面上是用于读取文件的,但输入也可以采用其他形式。这个命令可以将字符串视为单个记录文件,甚至可以将另一个批处理命令的输出作为其输入。所有这些将允许你以多种不同的方式处理和操作不同形式的信息。

含 /F 选项的 for 命令

/F 选项将 for 命令转变为一个多功能的文件读取工具。带有此选项的命令有多种形式,但我将首先考虑其中一种。以下是读取文件或文件集的 for /F 命令的通用语法:

for /F ["`suboptions`"] %%`variable` in (`file-set`) do `command`

与没有选项的 for 命令相比,这种语法有很大不同。最明显的是,/F 选项已经到位。我还将括号内的通用输入占位符更改为更具体的术语 file-set,但这仅仅是命名上的不同。与没有选项的命令版本相比,这个变体只接受一个文件、文件掩码或多个文件或文件掩码。

最显著的区别是增加了["suboptions"]子句。方括号表示这些子选项本身是可选的,但如果包含它们,必须用双引号括起来。每个可能的子选项都有自己的特定关键字。我将在本章稍后讨论其中一个子选项 usebackq,但其他子选项会直接影响你如何读取和操作数据。

读取文件内容

关键字 tokens、delims、skip 和 eol 可能不容易说出口,但它们是子选项子句的关键元素,控制着文件的读取。了解它们的最佳方法是探索没有选项的 for /F 命令的行为,然后逐一添加与这四个关键字相关的子句,以展示它们的用途。

让我们回到第十七章中 for 命令的介绍部分,在那里我简要处理了一个名为FourBrits.txt的文件,但没有提及其内容。对于这个演示,假设这是一个包含四条记录的小文件。每条记录有五个元素,第一个是语言,其余四个是该语言中写出的四个特定男性名字。元素之间由空格或制表符分隔,下面是文件的完整内容:

English   John      Paul      George    Richard
Spanish   Juan      Pablo     Jorge     Ricardo
French    Jean      Pol       Georges   Richard
Italian   Giovanni  Paolo     Giorgio   Riccardo 

考虑这个 for 命令:

for %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a
) 

输出正是你现在期望的:一行数据,显示输入文件的路径和文件名:

C:\Batch\FourBrits.txt

为了演示新选项,我将在逻辑中添加/F:

for /F %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a
) 

现在是输出:

English
Spanish
French
Italian 

这样一个小小的变化导致了截然不同的结果。现在,代码不是以文件名的形式向控制台写入单条信息,而是输出文件中每条记录的第一个单词。由于/F 选项,命令正在打开并读取文件的内容。还有很多工作要做,但命令实际上是在读取文件中的每一条记录,并将%%a 设置为记录中的第一个单词。如果文件包含 64 条记录,那么之前的逻辑将在控制台上写入 64 个单词,每个单词占一行。

这个输出引发了更多问题,而不是解答了它们。为什么这不是写入整个记录?我们可以只解析记录的一部分吗?我们必须读取每条记录吗?这就是通用语法中的 suboptions 子句的关键字发挥作用的地方。

标记化数据

在 Batch 中,一个标记是记录的一个元素,默认情况下,每个标记由空格或制表符分隔。检查文件中的第一条记录,它正好包含五个标记:

English   John      Paul      George    Richard

默认情况下,Batch 将标记数量设置为 1。这解释了为什么在之前的示例中,解释器将%%a 解析为每条记录中的第一个单词(或标记),并丢弃了记录中的其余部分。

要更改这个默认值,你可以使用 tokens 子句;它是 tokens 关键字,后面跟着一个等号和设置,在这种情况下是 2:

for /F "tokens=2" %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a
) 

tokens 子句告诉解释器将哪个标记或哪些标记作为 for 变量传递到循环中。执行此代码会产生以下输出,写入控制台:

John
Juan
Jean
Giovanni 

由于 tokens 子句,解释器现在将第二个单词或标记传递到循环中,将 John 及其翻译写入控制台。将其设置为 5 将检索到 Richard 的名字。通过这个子句,你可以相对轻松地提取单个标记。

提取多个标记

能够提取单个标记很不错,但能够提取多个并选择标记会更有用。要提取所有五个标记,将文本 1-5 分配给 tokens 关键字。但这引出了一个有趣的问题。如何仅通过定义单个变量%%a 来解析这五个标记呢?

额外的变量以简单的字母顺序优雅地表示。如果%%a 是 for /F 命令中定义的变量,它解析为标记子句中定义的第一个标记,接着%%b 解析为第二个,%%c 解析为第三个,%%d 解析为第四个,最后%%e 解析为第五个。考虑以下内容:

for /F "tokens=1-5" %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

这段代码将记录格式化,使语言后跟冒号和两个空格。然后,在输出中,名称之间用逗号和空格分隔,最后一个名字前有一个连接词:

English:  John, Paul, George, and Richard 
Spanish:  Juan, Pablo, Jorge, and Ricardo 
French:  Jean, Pol, Georges, and Richard 
Italian:  Giovanni, Paolo, Giorgio, and Riccardo 

你也可以选择提取特定的标记,而不是一个范围。在标记子句中,用逗号分隔所需的标记。此代码仅将第二个和第四个标记传入循环:

for /F "tokens=2,4" %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a and %%b played guitar.
) 

结果将写入控制台:

John and George played guitar. 
Juan and Jorge played guitar. 
Jean and Georges played guitar. 
Giovanni and Giorgio played guitar. 

现在,数据记录中的第二个标记是标记子句定义的第一个标记,因此 Batch 将其分配给%%a,而不是%%b。同样,%%b 现在解析为记录中的第四个标记,也就是第二个选中的标记。简而言之,变量字母模仿的是标记子句中定义的标记位置,而不是它在记录中的位置。

到目前为止,我使用了%%a 作为 for 变量。无论你选择哪个字母作为变量,解释器都会将后续标记分配给字母表中的相应字母。之前的代码在功能上等价于以下代码:

for /F "tokens=2,4" %%x in (C:\Batch\FourBrits.txt) do (
   > con echo %%x and %%y played guitar.
) 

唯一的区别是我将%%a 改为了%%x,这意味着%%y 现在解析为第二个标记,而不是%%b。

需要注意的是,选择%%x 作为 for 变量限制了你只能处理三个可能的标记,因为在%%z 之后没有更多的标记可以分配(尽管《超越斑马》(On Beyond Zebra!)中的 Dr. Seuss 努力过)。你不会经常看到这种情况,但数字也有效。例如,如果你提取三个标记并将 for 变量定义为%%7,解释器将使用%%8 和%%9 作为隐式标记。

该关键字还接受数字、逗号和短横线的组合。例如,tokens=1,3-5 子句处理除 John(第二个标记)之外的所有标记。

提取记录的其余部分

关于标记关键字,还有另一个变化。星号表示输入记录的其余部分,包括嵌入的空格。考虑这段使用 2* 的代码在标记子句中的情况:

for /F "tokens=2*" %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a, Rest of the Band: "%%b"
) 

这个命令将第二个标记分配给%%a,因为子句中的 2;然后解释器将记录的其余部分,即第三、第四和第五个标记,包括任何嵌入的空格和制表符,分配给%%b,因为星号(*)。语言,即每个记录中的第一个标记,在输出中找不到:

John, Rest of the Band: "Paul      George    Richard" 
Juan, Rest of the Band: "Pablo     Jorge     Ricardo" 
Jean, Rest of the Band: "Pol       Georges   Richard" 
Giovanni, Rest of the Band: "Paolo     Giorgio   Riccardo" 

为了让这个讨论圆满结束,当我们在本章开始时仅仅为 for 命令添加了 /F 选项时,默认的标记子句只将第一个标记传递到代码块。在许多情况下,你会希望将整个记录完整地提取出来,正如它在输入文件中所呈现的那样作为一个整体。由于星号代表输入记录的其余部分,单独将它分配给标记就会选择整个记录:

for /F "tokens=*" %%r in (C:\Batch\FourBrits.txt) do (
   > con echo Entire Record: "%%r"
) 

同时请注意,我将变量改为 %%r。这里是输出结果:

Entire Record: "English   John      Paul      George    Richard" 
Entire Record: "Spanish   Juan      Pablo     Jorge     Ricardo" 
Entire Record: "French    Jean      Pol       Georges   Richard" 
Entire Record: "Italian   Giovanni  Paolo     Giorgio   Riccardo" 

之前使用 %%a 完全可以正常工作,但我在这里做了修改,原因有两个:首先,r 代表记录,其次,作为一个简单的提醒,任何字母字符都可以使用。

注意

如第十七章中所提到,for 变量实际上是区分大小写的,并且允许一些非字母数字字符。这意味着 %%a 和 %%b 仅能解析为定义为 %%a 的变量的前两个标记;%%A 和 %%B 不会生效,除非你将 for 变量改为 %%A。我可能不需要再提,但那些奇怪的字符(比如 %%#)根本不适合用来提取多个标记。

定义数据分隔符集

能够将输入记录分解为由空格和制表符等分隔符定义的标记确实非常强大,但在许多情况下,你无法控制正在读取的文件中的数据,而不同的分隔符,甚至是多个分隔符,会更加适用。delims 关键字定义了用于标记数据的一种或多种分隔符

解析逗号分隔数据

逗号分隔格式是一种流行且简便的存储数据方式。例如,.csv 文件包含任意数量的逗号分隔记录,你可以使用 Excel 打开并查看数据。每个标记由逗号分隔,每个标记可以包含空格。在展示 Batch 如何读取 CSV 数据之前,我需要做一些设置。

FourBrits.txt 文件可能包含带有嵌入空格的名字,比如 Richard aka Ringo。在之前讨论的空格分隔示例中,这段文本包含三个不同的标记。我现在将重新格式化数据,并以逗号分隔的文件 FourBrits.csv 提供。该文件的内容将与其同名的 .txt 文件相同,只是分隔符和 Richard 的别名有所不同:

English,John,Paul,George,Richard aka Ringo
Spanish,Juan,Pablo,Jorge,Ricardo aka Ringo
French,Jean,Pol,Georges,Richard aka Ringo
Italian,Giovanni,Paolo,Giorgio,Riccardo aka Ringo 

下一步任务是修改 for /F 命令,使其将逗号作为分隔符而不是空格,我将通过 delims 关键字实现这一点。当关键字不存在时,解释器会默认为只包含空格和制表符字符的分隔符集。在以下示例中,delims=, 将分隔符集定义为单个字符,即逗号:

for /F "tokens=1-5 delims=," %%a in (C:\Batch\FourBrits.csv) do (
    > con echo %%a:  %%b, %%c, %%d, and %%e
) 

当解释器遇到第五个也是最后一个标记——一个名字后跟别名 Ringo 时——它会将文本写入控制台,保留其中的空格:

English:  John, Paul, George, and Richard aka Ringo
Spanish:  Juan, Pablo, Jorge, and Ricardo aka Ringo
French:  Jean, Pol, Georges, and Richard aka Ringo
Italian:  Giovanni, Paolo, Giorgio, and Riccardo aka Ringo 

Ringo 是不可翻译的。

使用管道分隔的数据文件也很流行;delims=|子句解析输入数据中的管道,这样内嵌的空格和逗号就不会创建额外的标记。等号甚至可以作为分隔符,使用 delims==子句。(你很快会看到相关示例。)

定义多个分隔符

你甚至可以通过一个 delims 子句定义多个分隔符。以下代码将分隔符集合定义为逗号和空格。虽然很微妙,但与之前的示例相比,唯一的变化是我在 delims=和后续的双引号之间添加了一个空格:

for /F "tokens=1-5 delims=, " %%a in (C:\Batch\FourBrits.csv) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

现在,解释器每次遇到空格或逗号时,都会看到一个新的标记。第五个标记将仅包含名字 Richard,而 aka 和 Ringo 分别被分配到未分配的第六个和第七个标记。

为了展示更大范围的分隔符,我将使用一个名为Alphabets.txt的单记录文件,其中包含大写和小写的拉丁字母,中间用一个空格隔开:

ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz

完全省略 delims 子句将调用默认的分隔符集合,并仅生成两个标记:大写字符集和小写字符集,除此之外没有更多内容。相反,在以下代码中,我将 delims 关键字设置为 Delims 文本和一个尾随空格:

for /F "tokens=1-8 delims=Delims " %%a in (C:\Batch\Alphabets.txt) do (
   > con echo Token 1 = %%a
   > con echo Token 2 = %%b
   > con echo Token 3 = %%c
   > con echo Token 4 = %%d
   > con echo Token 5 = %%e
   > con echo Token 6 = %%f
   > con echo Token 7 = %%g
   > con echo Token 8 = %%h
) 

如果数据中每个分隔符仅出现一次,似乎逻辑上会产生八个标记。虽然这不一定是正确的,但我已经通过 tokens=1-8 子句定义了这么多个标记,并添加了相同数量的 echo 命令。让我们关注一下生成的标记数量。以下是结果输出:

Token 1 = ABC
Token 2 = EFGHIJKLMNOPQRSTUVWXYZ
Token 3 = abcd
Token 4 = fgh
Token 5 = jk
Token 6 = nopqr
Token 7 = tuvwxyz
Token 8 = 

这里有许多需要注意的点,包括分隔符集合是区分大小写的事实。大写字母 D 是一个分隔符,但小写字母 d 不是。小写字母 e、l、i、m 和 s,以及空格,组成了完整的分隔符集合。当查看原始数据,并将除了空格以外的分隔符加粗时,更容易理解这七个标记的来源:

ABC**D**EFGHIJKLMNOPQRSTUVWXYZ abcd**e**fgh**i**jk**lm**nopqr**s**tuvwxyz

注意,这些数据中列出了两个分隔符(l 和 m)按顺序排列,并且由于它们之间没有任何内容,你可能会预期解释器将为第六个标记分配空值或空白,并将其后面的文本 nopqr 移到第七个标记。然而,Batch 将连续的分隔符(lm)视为一个单一的分隔符,因此第六个标记是文本 nopqr。因此,tuvwxyz 是第七个标记,且由于它后面没有内容,第八个标记的值为空。

注意只使用你已定义的标记。如果子句是 tokens=1-7,最终的 echo 命令将导致 Token 8 = %h,因为%%h 不会是第八个标记;它将是一个百分号转义另一个百分号,后面跟着一个硬编码的字符。

在解析逗号分隔(或管道分隔)数据时,许多编译语言将连续的分隔符视为两个分隔符,并尊重它们之间的空值,这完全是合理的。然而,Batch 通常以空格作为分隔符,只需看看原始的FourBrits.txt 数据,其中每个单词之间的空格数量不一,就可以清楚地看到,视多个空格为单一分隔符也是非常合乎逻辑的。

不幸的是,这确实会带来一些挫败感,尤其是当数据不是以空格,而是逗号分隔时,但在第二十章中,我会向你展示如何让 for 循环尊重连续分隔符之间的空值。然而,如果我能够控制仅以逗号分隔的输入数据,我会总是在两个逗号之间写入一个空元素作为空格。

我将空格放在分隔符集合中的最后,因为它必须排在最后。我不想再次贬低解释器,但如果空格出现在其他位置,它会感到困惑。因此,在将空格包含在分隔符集合中时,delims 子句必须放在子选项的最后,紧接在结尾的双引号之前。

跳过头部记录

如你刚刚看到的,tokens 和 delims 关键字有很多微妙之处。接下来的几个关键字则简单得多,特别是 skip 关键字,它可以让你在读取文件时跳过任意数量的头部记录。

让我们回到原始的FourBrits.txt 文件,在接下来的四条记录之前添加一个头部记录:

Four Liverpudlians in Four Languages:
English   John      Paul      George    Richard
Spanish   Juan      Pablo     Jorge     Ricardo
French    Jean      Pol       Georges   Richard
Italian   Giovanni  Paolo     Giorgio   Riccardo 

让我们运行来自 tokens 关键字讨论的相同的 for /F 循环:

for /F "tokens=1-5" %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

它将头部行作为其他行一样处理,输出以下垃圾数据,后面跟着四行有效输出(我只展示其中一行):

Four:  Liverpudlians, in, Four, and Languages:
English:  John, Paul, George, and Richard 

幸运的是,skip 关键字定义了在文件开头要跳过的记录数。这样就会跳过第一条记录,并完美处理数据记录:

for /F "tokens=1-5 skip=1" %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

如果有三条头部记录,skip=3 子句会使 for 命令从第四条记录开始。

抑制注释记录

虽然 skip 关键字仅跳过文件开头的记录,但 eol 关键字(表示行尾注释字符)跳过文件中所有以定义字符开头的记录。如果我要宽容一点的话,我会说,当解释器在记录的第一个字节中看到定义的字符时,它会将该字符视为行尾,并将剩余部分视为注释

为了演示,我们再次编辑输入文件。这一次,我添加了第二条注释记录,更重要的是,每条注释记录现在都以句点开头:

.Four Liverpudlian in Four Languages:
English   John      Paul      George    Richard
Spanish   Juan      Pablo     Jorge     Ricardo
French    Jean      Pol       Georges   Richard
.This is a random and annoying mid-file comment with no discernable purpose.
Italian   Giovanni  Paolo     Giorgio   Riccardo 

向相同的 for 命令中添加 eol=. 子句指示解释器跳过所有注释行,无论它们在数据中出现的位置如何,并只处理其余的记录:

for /F "tokens=1-5 eol=." %%a in (C:\Batch\FourBrits.txt) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

不幸的是,这个简单明了的关键字也有它自己的限制。鉴于 delims 子句接受一组分隔符,你可能期望 eol 子句接受一个或多个字符。但实际上,它只接受一个字符。

术语 tokensdelimsskipeol 可能不像 John、Paul、George 和 Ringo 那样朗朗上口,但这四个精彩的关键字提供了极大的灵活性,本章及其他章节中还会有更多与这些关键字相关的子句示例。

定义输入

你现在知道如何使用 for /F 命令读取一个文件,但你可能还不知道如何读取一个文件——这之间是有区别的。传统的文件是由存储在驱动器上的一个可引用位置中的记录组成的字节集合,这些记录通过目录和文件名进行定义。而文件是某种可以被 for /F 命令读取的输入;它可以采取不同的形式,并不一定是编辑器能够打开的东西。

除了传统的文件,这个命令还可以接受字符串作为输入,也可以接受另一个批处理命令的输出。正如你将很快看到的那样,语法中的微小差别决定了输入的类型。

文件输入

文件集输入是传统的文件或一组文件。我在本章中已经展示了几次这种形式的示例。每个示例中括号内都包含一个文件,现在我们知道这些输入是文件,因为没有任何围绕的引号,无论是单引号还是双引号。你还没有看到的是一组文件。以下命令按文件集中的列出顺序读取两个文件的内容,文件之间由逗号、空格或两者的组合作为分隔符,方便阅读:

for /F "tokens=1-5" %%a in (FourBrits.txt, MoreBrits.txt) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

假设第一个文件 FourBrits.txt 有四条记录,for /F 命令按顺序处理每一条记录,向控制台写入四行。解释器接着关闭该文件并打开 MoreBrits.txt。此文件中的每一条记录都会触发一次代码块内逻辑的执行,并向控制台再写入一行。我还删除了文件路径,因此解释器将在当前目录以及路径变量中定义的目录中查找这些文件。

字符串输入

以下是使用字符串作为输入的 for /F 命令的一般语法。请注意括号内的输入:

for /F ["`suboptions`"] %%`variable` in ("`string`") do `command`

这种形式将输入文本替换为“字符串”。双引号告诉解释器它们包围的是某种文本,而完全没有引号则告诉解释器考虑输入为文件或一组文件。其他部分的工作方式完全相同。

以下 for /F 命令与本章前面使用的其他示例类似,唯一不同的是输入:

for /F "tokens=1-5" %%a in ("Italian Giovanni Paolo Giorgio Riccardo") do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

唯一的区别是,我已将 FourBrits.txt 的最后一行文本移除了一些额外的空格以提高可读性,并将其作为被双引号包围的字符串输入。

代码块中的 echo 命令并不知道这五个标记的来源,是否来自硬编码的字符串或从文件读取的记录。这里是我们熟悉的结果:

Italian:  Giovanni, Paolo, Giorgio, and Riccardo

在这个例子中,括号内的文本是硬编码的,但你也可以使用包含文本的变量,只要它被双引号括起来。你可以使用任何字符串作为输入,解释器会将其视为一个单一记录文件。

这个特性使得复杂的字符串解析成为可能。实际上,我在 第五章 中使用了这个功能,却完全没有解释它是如何工作的,承诺稍后会解释。for /F 命令将布尔值转换为布尔字符串,其中 bGod 布尔值包含两个值中的一个,true==truefalse==x。当时我提到,尽管将其归结为不过是个魔术技巧,这个命令会去掉两个等号及其后的所有内容,从而将 bStrGod 的值设为 truefalse

for /F "delims==" %%b in ("%bGod%") do  set bStrGod=%%b

通过本章所学,你现在可以弄清楚它是如何工作的。

for /F 命令将括号内双引号中的解析布尔值视为字符串或文本输入。delims 子句将等号定义为唯一的分隔符,并且由于我没有定义 tokens 关键字,所以隐式地采用了默认子句 tokens=1。结果是,等号前的词是第一个标记,解释器将其解析为 %%b(当然是为 boolean),同时丢弃等号后面的第二个标记。set 命令然后在第一次也是唯一一次执行时将第一个标记赋值给布尔字符串。

所以,这根本不是什么魔术,而仅仅是一种简洁的方法,通过一行代码从更大的字符串中提取一个单一的分隔值。请看 for /F 命令及其多个输入的强大功能。

命令输入

括号内的内容决定了任何 for /F 命令的输入。你知道没有引号表示一个文件,双引号表示一个字符串。单引号表示另一个 Batch 命令是输入,下面是它的一般语法:

for /F ["`suboptions`"] %%`variable` in ('`command`') do `command`

在单引号之间输入的任何 Batch 命令会先执行,其结果的标准输出(stdout)将作为 for /F 命令的输入。解释器随后会逐行处理它认为是输入的内容,如果有多行,就像处理一个文件一样。

为了展示命令作为输入的有用性,考虑在 第十三章 中介绍的 dir 命令。以下命令列出给定目录中的所有文本文件:

dir C:\Batch\*.txt /B

/B 选项只会生成文件名和扩展名的列表——没有文件日期和大小,也没有头部和尾部行。命令只会将这些信息写入标准输出供你查看,仅此而已。很多时候,你会想将这个文件列表拿来对每个文件执行某些逻辑。例如,让我们编写一些代码,将目录中所有 *.txt 文件的扩展名改为 *.bak,以表示它们是备份文件。

单引号告诉 for /F 命令输入的是一个命令。dir 命令生成一个文件名列表,每个文件名都带有扩展名但没有路径:

for /F "tokens=*" %%f in ('dir C:\Batch\*.txt /B') do (
   ren "C:\Batch\%%f" "%%~Nf.bak"
) 

tokens=* 子句确保 %%f 在遍历文件列表时将整个字符串作为其值。默认子句会丢弃名称中第一个嵌入空格后的所有内容。ren 命令重命名文件,使用文件名的修改符(N),并附加上硬编码的扩展名。

我希望你能理解这一点有多么令人印象深刻。三个命令被绑定在一起,只需几行代码就可以完成相当多的操作。对于经常使用它的人来说,这可能变得司空见惯,而对于其他人来说,可能会觉得有些压倒性,但在许多语言中,类似的操作没有中间文件是做不到的。使用这种技术,dir 命令不仅仅是将信息输出到控制台或文件中(虽然这也很有用)。输出的原始格式显然是为此目的设计的。你可以对任意数量的文件执行许多处理,无论是简单的还是复杂的。我会在深入一些实际应用时进一步展开。

输入类型的替代语法

对于某些输入,刚才讨论的语法根本不起作用。有时文件名包含空格,但将其用双引号括起来会使其变成字符串输入。如果字符串中包含双引号,你就无法将其用一对双引号括起来。同样,命令有时会包含单引号。这些情况就需要不同的语法,而 usebackq 关键字,作为 for /F 命令的最后一个子选项,是解决方案。

在深入之前,有些批处理缩写是显而易见的;我大概不需要告诉你 delims 是 delimiters 的缩写,tokens 是 tokens 的缩写。但你可能会对这个加密的文本 usebackq 感到困惑。这个关键字代表 use back quote,虽然这可能没什么帮助,但至少现在你知道看到它时该怎么发音了。

文件输入

展示 usebackq 关键字有用性的最佳方法是重新创建一种无疑促使其诞生的情境。到目前为止,本章中的许多示例都读取了一个名为 FourBrits.txt 的文件。我很少在文件名中嵌入空格,但别人可能会将这个文件命名为 Four Brits.txt

为了展示空格可能带来的问题,我从之前的内容中直接复制了以下 for /F 命令,只是文件名现在包含了一个嵌入的空格:

for /F "tokens=1-5" %%a in (C:\Batch\Four Brits.txt) do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

批处理将此视为两个文件(而不是一个),可能会失败,因为找不到名为 Four 且没有扩展名的文件。显而易见的解决方案是将路径和文件名用双引号括起来,但 for /F 命令会将其视为文本字符串而不是文件名,因此这种方法行不通。

解决方案是使用 usebackq 关键字 在输入周围加上双引号:

for /F "usebackq tokens=1-5" %%a in ("C:\Batch\Four Brits.txt") do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

这个关键字还使处理包含特殊字符(如括号)的路径和文件名变得更容易。

for /F 命令成功地读取文件并产生与之前相同的输出。你甚至可以读取多个文件,每个文件的名字中都有空格:

for /F "usebackq tokens=1-5" %%a in ("Four Brits.txt", "More Brits.txt") do (
   > con echo %%a:  %%b, %%c, %%d, and %%e
) 

注意,文件名由逗号分隔,并且每个文件名都被一对双引号括起来。

这是使用文件集作为输入并带有 usebackq 关键字的命令的一般语法:

for /F "usebackq [`suboptions`]" %%`variable` in ("`file-set`") do `command`

这个关键字改变了规则。双引号可以包围每个文件名,尽管它在没有双引号的情况下仍然与关键字一起工作,前提是文件名中没有嵌入空格。

字符串输入

usebackq 关键字解决了一个问题,但又创造了另一个问题。它允许在文件名周围使用双引号,但这似乎禁止了处理字符串的能力。解决方案是将文本用不同的字符括起来,这个字符就是单引号。以下是一般语法:

for /F "usebackq [`suboptions`]" %%`variable` in ('`string`') do `command`

因此,以下两个 for 命令在功能上是等价的:

for /F "tokens=1-4" %%a in ("Larry Moe Curly Iggy") do break
for /F "usebackq tokens=1-4" %%a in ('Larry Moe Curly Iggy') do break 

如果没有 usebackq,我将 Stooges 的简短列表括在双引号中,而当关键字存在时,单引号会将相同的文本括起来。(我还使用了 break无操作 命令,使这些命令在关注关键字和引号类型的同时有效,但这些命令不会产生任何输出。)

为什么 Batch 提供两种执行相同任务的方法?为了回答这个问题,我将使用一串芝加哥机场的名称,每个机场名出于某种未知原因都被括在括号中,作为输入。以下两个命令在功能上是等价的:

for /F "tokens=1-2" %%a in ("(O'Hare) (Midway)") do break
for /F "usebackq tokens=1-2" %%a in ('^(O'Hare^) ^(Midway^)') do break 

第一个示例,没有使用 usebackq,要更加易于操作。你必须转义特殊字符,如括号,因为缺少双引号。但如果没有关键字,这些字符只是出现在双引号中的一部分。

但是,在双引号字符串中,唯一永远不起作用的字符就是孤立的双引号本身——即使它被转义。回到 Stooges,虽然 Iggy Pop 是 The Stooges 的主唱,但他显然不是 The Three Stooges 的成员,所以我可能想用引号或者双引号把他的名字括起来,以表示讽刺:

for /F "usebackq tokens=1-4" %%a in ('Larry Moe Curly "Iggy"') do (
   > con echo The Four Stooges are %%a, %%b, %%c, and %%d.
) 

(解释器足够智能,可以处理双引号字符串中的双引号个数是偶数的情况,但为了清晰起见,当字符串中包含任何双引号时,我总是使用关键字并将整个输入用单引号括起来。)

这个 for /F 命令将以下内容写入控制台:

The Four Stooges are Larry, Moe, Curly, and "Iggy".

(可怜的 Shemp,大家都忘了 Shemp。)

命令输入

你刚刚学会了在使用 usebackq 和字符串输入时使用单引号,因此你不能再用它们来括起命令。但是,这种通用语法允许你将关键字与命令作为输入一起使用:

for /F "usebackq [`suboptions`]" %%`variable` in (``command``) do `command`

在某些字体中,括住命令输入的两个字符可能看起来像单引号,但它们是全新的字符。

这让我们回到那个悬而未决的问题,关于文本 usebackq 或 use back quote 的含义。这个名称源于那些弯曲的单引号,它们把命令括起来,实际上这些单引号更准确地说是反引号(或称为反撇号)。反引号的键在大多数键盘上位于 TAB 键的上方和数字 1 键的左侧。这个键有双重功能,按住 Shift 键可以输入波浪号。它被 relegated 到键盘的底层区域,因为大多数人几乎从不使用它,但它是 Batch 编程者的一个重要键。

以下 for /F 命令是功能等效的;第一个模仿了前一节的一个命令,第二个使用了关键字和反引号:

for /F "tokens=*" %%f in ('dir C:\Batch\*.txt /B') do break
for /F "usebackq tokens=*" %%f in (`dir C:\Batch\*.txt /B`) do break 

第一行支持命令中可能包含反引号的情况,而你会使用第二行—带有 usebackq 关键字的语法—当命令可能包含单引号时。

何时使用 usebackq

你可以在有或没有 usebackq 的情况下实现每种类型的输入,那么应该什么时候使用这个关键字呢?简单来说:无论何时可能,我都会对文件集和命令输入使用 usebackq,对于字符串输入则不使用。

用双引号括起输入能够提供最大的灵活性,并且应该在可能的情况下使用。它支持输入中嵌入空格和许多可能需要转义的字符。因此,在处理文件集时,我使用双引号和 usebackq,而在处理字符串时,我使用双引号并省略该关键字。

当输入是一个命令时,双引号不会参与计算。有些人可能会跳过关键词来节省一些按键,但我使用 usebackq 关键字和反引号将输入括起来,原因有两个。首先,虽然单引号和反引号在命令中都不太常见,但反引号更不常见。更重要的是,当你看到单引号时,你会立刻问自己输入是字符串还是命令;而反引号消除了所有的歧义,它就是一个命令。

如果你实施我的建议,最终结果是任何查看你 for /F 命令的人都能够从输入中推断出很多信息。如果它被反引号括起来,那就是一个命令;如果它被双引号括起来,焦点就转移到了 usebackq 上。如果存在,那输入就是一个文件集;如果没有,那它就是一个字符串。还有三种形式在“蓝月亮”时可用,但你会比看到两次满月还要少见到包含双引号的输入字符串或包含反引号的输入命令。

关于 usebackq 的最后一个提示,如果你觉得这个关键字还不够难懂,q 是可选的。你可以用它的同义词 useback 替换本章代码示例中出现的每一个 usebackq 实例。我通常支持减少按键次数,但这样做会在已经对许多人来说难以理解的东西上加上一层面纱。

实际应用

读取文件的能力,以及将字符串和命令输出当作文件来处理的能力,具有许多应用。真正理解 for /F 命令可以接受的不同类型的输入,将使你能够提出富有创意的解决方案,解决一些曾经看似难以克服的问题。

仅处理文件夹中的大文件

在第十七章中,我探讨了一个应用程序,该程序将多个文件传递到一个 bat 文件中逐个处理。在这个例子中,我们仍然会逐个处理文件,但我们不再使用参数列表,而是处理某个特定文件夹中的所有.txt文件。为了让它更有趣一点,我们只处理大文件,定义为至少 100KB 大小的文件。

你可以将 dir 命令作为输入传递给 for /F 命令,前提是使用 usebackq 关键字并将命令放入反引号中。裸格式选项/B 会生成一个没有头部的干净文件列表,非常适合这个目的,但使用这个选项时会丢失文件大小,因此我们暂时放弃这个想法。

然而,不使用/B 选项会带来其他挑战。文件的日期和时间会被列出,并且还会有头部和尾部记录。你需要使用每个文件的大小来评估它是否足够大以进行处理,但显示的文件大小中的恼人逗号会成为一个麻烦。

在第十三章中,我提到过 dir 命令的/-C 选项会抑制逗号,但要弄清楚如何解析其输出中的内容,你需要查看一个示例。除非你拥有比我更为详细的记忆,否则构建任何复杂的 for /F 命令时,内嵌命令的执行是一个必不可少的步骤,该内嵌命令位于反引号中,你需要执行它并检查其输出。以下是命令,其中 workDir 是相关文件夹:

dir %workDir%\*.txt /-C

它可能生成如下内容:

 Volume in drive C is OS
 Volume Serial Number is 2E6D-DBF0

 Directory of C:\Batch

12/25/2011  12:42 PM            538346 Big.txt
11/24/2011  05:22 PM             17234 Little.txt
09/05/2011  10:43 AM                10 Wee Tiny.txt
07/04/2011  07:22 PM           1864408 Wicked Big.txt
               4 File(s)        2419998 bytes
               0 Dir(s)    146181152768 bytes free 

这里有很多内容需要解析,但通过这些数据,你可以构建剩余的 for /F 命令。你关注的是四个详细记录,但包括空记录在内的五个头部记录,你需要通过 skip=5 子句跳过它们。

接下来,你需要找出找到与文件大小和名称对应的标记的最佳方法。空格是默认的分隔符之一,在这里效果很好,但请注意,如果你想从数据中提取月份、日期、年份、小时或分钟,你会使用一个定义了正斜杠、冒号和空格的分隔符集的 delims 子句。由于空格是分隔符,你必须戴上解释器的帽子,寻找以空格为分隔符的标记。日期是第一个标记,时间由接下来的两个标记组成;由于内嵌的空格,上午/下午(AM/PM)成为第三个标记。因此,第四个标记是文件大小,其余数据是文件名。

为了表示大小,你将使用 %%s 作为 for 变量,这意味着 %%t 将是文件名及扩展名。(如果你使用 %%e 作为大小,那么 %%f 就变成了文件。)

可能会有诱惑认为文件名是第五个令牌,但文件名中可能包含空格,任何空格都会将其分隔成另一个令牌。幸运的是,文件名出现在数据的末尾,这意味着 tokens=4* 子句会将其分配为两个令牌。第四个令牌是文件大小,由于有星号,其余数据即文件名则成为第五个令牌。

你还没有完成,但可以将 for /F 命令的主要结构组合起来。这里讨论的所有内容都在这里,尤其是反引号中的 dir 命令:

for /F "usebackq tokens=4* skip=5" %%s in (`dir %workDir%\*.txt /-C`) do (

在调用处理大文件的逻辑之前,你需要处理两个 if 命令。第一个简单地验证文件大小 %%s 是否大于或等于 100000。for /F 命令允许你跳过头部记录,但尾部记录仍然会影响结果,因此你必须灵活处理。在 dir 命令的输出中,注意到两个尾部记录的第四个令牌是字节数,这肯定不适用于你感兴趣的详细记录。因此,你需要一个第二个 if 命令,通过检查同一个 %%s 变量来过滤掉这两条不需要的记录。

现在你可以将它们组合起来了:

for /F "usebackq tokens=4* skip=5" %%s in (`dir %workDir%\*.txt /-C`) do (
   if %%s geq 100000 (
      if /i "%%s" neq "bytes" (
         call :ProcessBigTxtFile "%workDir%\%%t"
)  )  ) 

如果 workDir 变量已填充为文本文件的工作目录,那么此代码就可以处理由 %%t 表示的大型文本文件了。此外,可能会觉得很奇怪,%%s 被用于数值和字母比较。在第四章中,我提到过,数值和字母值是有顺序的;字母被认为大于数字,因此字节数大于等于 100000 的条件子句总是为真。如果它一直为假,那么你本可以省略第二个 if 命令。

总是存在多个解决方案,看到以不同方式思考问题是一件很棒的事情。我打算再次尝试 dir 命令的 /B 选项。我知道它不会提供文件大小,但也许有其他方法可以获取它。我在第十七章中展示了如何使用修饰符来获取文件大小,使用不带选项的 for 命令。也许两个 for 命令会比一个更好。

我将从这个解决方案开始,它实现了嵌套的 for 命令:

for /F "usebackq tokens=*" %%f in (`dir %workDir%\*.txt /B`) do (
   for %%g in ("%workDir%\%%f") do (
      if %%~Zg geq 100000 (
         call :ProcessBigTxtFile %%g
)  )  ) 

外部 for 命令具有 /F 选项,并使用 usebackq 关键字,同时它将 %%f 用作变量。它的输入是一个带有 /B 选项的 dir 命令,并用反引号包裹。tokens=* 子句确保无论文件名中是否有空格,代码都会将每个文件名传入代码块,在这里我会立即为其添加路径,用双引号括起来,并将其作为输入传递给内部 for 命令。

这个内部命令没有使用 /F 选项,它使用%%g 作为变量,但除了%%f 之外,任何其他变量名都可以是良好的格式。我通过使用修饰符%%~Zg 来推导文件大小;如果文件足够大,call 命令会调用处理大文件的例程。

嵌套的 for 命令与非嵌套命令的行为差别不大,但一个重要的点是每个命令所选的 for 变量的字母字符。我本可以为两个命令都使用%%f,但我总是使用唯一的值,因为这样更清晰、更易读。此外,外部变量在内部命令中仍然可用,但前提是它没有被内部变量覆盖。

为了防止 for 变量冲突,它们不仅要不同,而且我还建议错开它们,以确保任何隐含的变量不会发生冲突。例如,如果外部命令从输入数据中提取三个标记并使用%%f 作为其 for 变量,那么%%g 和%%h 也应当避免在内部命令中使用。

你已经看到了两个解决同一问题的方法。一个使用了更复杂的命令来解析数据,另一个则使用了嵌套命令。哪种方式更好是有争议的,但无可争议的是,和其他语言一样,所有问题都有多种可能的解决方案。永远不要满足于一个解决方案。

全局文本替换

想象一下,你维护一个旧系统,运行着许多可执行文件,其中许多文件存放在一台旧服务器上。团队正在退役这台服务器\OldServer,并用恰如其分地命名为\NewServer的新服务器替换它。计划是将旧服务器上的每个文件迁移到新服务器,并保持相同的目录结构。

幸运的是,你的团队明智地实施并遵循了一致的命名规范,定义每个程序的变量都以 _pgm 开头。你控制其中一些变量的创建,但不是所有变量。你永远无法追踪到所有变量,但为了顺利切换到新服务器,你需要实时调整所有这些变量——也就是说,你需要将旧服务器的文本更改为新服务器的文本,而且你甚至无法获取所有变量名的列表。

第一个需要解决的问题是检索给定执行中的变量名列表。在第二章中,你学习了以下命令会生成所有当前活动的变量列表,这些变量名以 _pgm 开头:

set _pgm

如果仅设置了四个这样的变量,以下可能是命令的结果:

_pgmDBMrge=\\OldServer\Executables\DatabaseMerge.exe
_pgmSTElse=\\NewServer\Executables\SomethingElse.exe
_pgmTtlDiff=\\ThirdServer\Progs\TotallyDifferent.exe
_pgmVldtn=\\OldServer\Executables\Validation.exe 

这些中的第一项和最后一项需要调整,第二项已经使用了新文本更新,第三项有一个完全不同的目录结构。你需要更新其中两个,而不改变另外两个。解决方案包括将前一个 set 命令作为 for /F 命令的输入。代码块将接受变量名和对应的值,并用更新后的文本重置该变量。

以下 for /F 命令是其中一种解决方案:

for /F "usebackq tokens=1,2 delims==" %%p in (`set _pgm`) do (
   set tempPgm=%%q
   set %%p=!tempPgm:\\OldServer\=\\NewServer\!
) 

你将命令 set _pgm 放在反引号之间,并在 for /F 命令中使用了 usebackq 关键字。使用 dir 命令作为输入时,我强调了理解其输出的重要性,同样的道理也适用于 set 命令。查看四行示例输出,它们的共同点是变量名后面跟着一个值,两者之间用等号隔开。因此,你通过 delims==子句按等号进行分隔,并使用 tokens=1,2 子句提取两个令牌。for 变量%%p 是变量名,意味着%%q 是它的值。

这段代码仅用两个命令就能重置代码块中的变量。你将输入变量%%q 的原始值赋给临时变量 tempPgm。第二个命令将变量名%%p 重置为该值,并将\OldServer替换为\NewServer。注意,由于 tempPgm 在代码块中被设置并使用,因此必须使用感叹号来解析该变量的当前值。

这个逻辑会更新所有具有特定前缀的变量,同时忽略没有该前缀的变量。基于这一原则还有很多其他应用。你可以通过用一个命令 set %%p=替换代码块中的代码,并使用默认的 tokens 子句,来将所有类似名称的变量重置为 null。

文档说明

我对不同形式的 for /F 命令的语法做了一些调整,所以我将把你在这里看到的与帮助文档中的内容进行比较,并解释我的理由。首先,这是你通过运行 for /?命令可以看到的内容:

FOR /F ["`options`"] %`variable` IN (`file-set`) DO `command` [`command-parameters`]
FOR /F ["`options`"] %`variable` IN ("`string`") DO `command` [`command-parameters`]
FOR /F ["`options`"] %`variable` IN ('`command`') DO `command` [`command-parameters`]

    or, if usebackq option present:

FOR /F ["`options`"] %`variable` IN (`file-set`) DO `command` [`command-parameters`]
FOR /F ["`options`"] %`variable` IN ('`string`') DO `command` [`command-parameters`]
FOR /F ["`options`"] %`variable` IN (``command``) DO `command` [`command-parameters`] 

我列出了我几乎专门使用的三种形式,然后是我不常使用的几种:

for /F "usebackq [`suboptions`]" %%`variable` in ("`file-set`") do `command`
for /F ["`suboptions`"] %%`variable` in ("`string`") do `command`
for /F "usebackq [`suboptions`]" %%`variable` in (``command``) do `command`

for /F ["`suboptions`"] %%`variable` in (`file-set`) do `command`
for /F "usebackq [`suboptions`]" %%`variable` in ('`string`') do `command`
for /F ["`suboptions`"] %%`variable` in ('`command`') do `command` 

首先,我比大多数程序员更喜欢小写字母。而且,我将["options"]子句替换为["suboptions"]。这个 for 命令已经有一个选项/F,我认为没有必要再引入另一种类型的选项,所以我选择了suboptions这个术语,但这两个调整都是表面上的。

我去掉了每行末尾的[command-parameters],因为这只是说明命令可能有参数,但这显而易见,不值得浪费额外的篇幅。为了真正正确地展示,我本可以显示额外的可选命令以及定义可能代码块的可选括号。为了让文档更具可读性,它必须保持一定的模糊性,因此我决定让它简洁明了。

另一个区别是,我对 for 变量%%variable 使用了两个百分号,而不是一个。帮助文档显示了如何从命令提示符执行命令,唯一的区别是它调用的是单个百分号。这是一本关于 bat 文件的书,而不是命令提示符,而且命令通常过于复杂,不适合在提示符下输入。

我明确地在使用 usebackq 关键字的形式中展示它,而帮助文档将其放在一个奇怪的标题后面。为了适应这一变化,我还将其他子选项放入方括号中,因为它们仍然是可选的。

最后的区别在于第一个形式,这也是最关键的一点。在研究这本书时,我感到非常震惊,发现两个使用文件集作为输入的形式在帮助文档中是相同的。添加 usebackq 关键字意味着现在双引号可以包含输入内容。从技术上讲,它们并不一定需要存在,但这不正是使用 usebackq 的意义所在吗?我很难想出在使用该关键字时我不想使用双引号的情况,因此我在文档中包含了它们,但请理解它们是可选的。

使用对你有意义的形式。更好的是,选择其中一个形式开始(我希望是我的形式),然后在你自己的 for 命令文档中进行构建。

拆解任何 for 命令

for 命令,尤其是跟随/F 选项时,可能会让人感到压倒性。你可以在仅几行代码中加入相当多的逻辑。当你遇到这样的构造时,可能很难准确知道从哪里开始分析。我多年来已经拆解了许多这样的命令(其中不少是我自己写的),每当我看到一个以 for 开头的命令时,我都会使用这些问题和步骤来弄清楚代码在做什么:

1.  什么是选项?是没有选项、/D、/R、/L 还是/F?

2.  根据选项,它使用的是什么类型的输入?如果没有选项,那么输入是文件、文件集还是值的列表?如果是/D 选项,期望输入为一个目录。如果是/R 选项,期望对一个或多个文件进行递归处理。如果是/L 选项,查看定义迭代循环的三个数字。在这四种情况下,按照输入的逻辑步骤通过代码块。

但是,如果选项是/F,那么你才刚刚开始拆解。命令使用的是什么类型的输入?如果没有引号,输入是文件集;如果是反引号,则是一个命令。如果是双引号,usebackq 将其转换为文件集;否则是一个字符串。如果是单引号,usebackq 将其转换为字符串;否则是一个命令。

3.  确定输入贡献了什么。文件集是否包含通配符?大概处理了多少个文件?文件或文件中的数据是什么类型?该字符串是硬编码的还是变量?命令做了什么,输出是什么?大致创建了多少行输出?数据的稳定性如何——也就是说,会有头部或尾部数据吗?

4.  检查子选项,并确定输入是如何被处理的。根据 tokens 和 delims 子句(或它们的默认值),会创建多少个标记,它们将如何从输入中填充?是否有记录通过 skip 或 eol 子句被丢弃?

5.  识别 for 变量,可能是 %%i,并确定与之相关的输入数据及其后续变量,可能是 %%j 和 %%k。

6.  检查代码块。确定 for 变量以及任何隐含变量的引用方式。是否有文件被复制、重命名、删除或以其他方式处理?

也许这些问题和步骤看起来令人难以应对,但为了展示它们的简单性,让我们拆解一些代码。某个刚认识的在线用户给了你这个命令,告诉你它会备份一些文件。只需要运行它:

for /F "usebackq tokens=1,3" %%x in (`xcopy * %userprofile%\%random%\`) do (
   if "%%y" neq "copied" > %%x echo This file has been randomly moved.
) 

但在执行之前,你应该先拆解它。使用之前的六个步骤:

1.  选项是/F。

2.  反引号包裹输入,所以它是一个命令。

3.  xcopy 的源参数是星号,这意味着该命令将当前目录中的所有文件复制到一个奇怪命名的文件夹中,使用了两个伪环境变量,我还没有讨论(但会在第二十一章中讨论)。如果此命令成功复制五个文件,输出将是五个源文件的列表,后面跟着一条记录,写着:5 个文件已复制。

4.  基于 tokens 子句和缺少 delims 子句,该代码提取了从空格分隔输入中提取的第一个和第三个标记。对于大部分输出,第一个标记是源文件的完整路径和名称,假设文件名中没有嵌入空格,第三个标记为空。对于输入的最后一行,或者说记录的结尾,第一个标记解析为已复制的文件数量,而第三个标记是“copied”这个词。

5.  %%x 是路径和文件名,直到输出的最后一行,当它变成已复制的文件数量时。%%y 完全为空(假设没有嵌入的空格),在处理输出的最后一行时解析为“copied”。到目前为止,一切正常。

6.  切换到代码块,if 命令过滤掉最后一条记录,其中 %%y 解析为特定文本,这意味着每个被复制的文件,echo 命令都会将其整个内容替换成看似模糊的威胁。

这看起来有点可疑。代码将当前目录中的所有文件隐藏起来,包括假设它在当前目录中的 bat 文件本身,放进一个随机命名的文件夹中。然后它擦除并替换每个源文件的内容,替换成一些文本。即使带有嵌入空格的文件名可能会被保留下来也无济于事。

因为你现在知道如何拆解一个 for 命令,所以你没有被这个 bat 文件欺骗。你没有执行代码,但已经推断出它是恶意代码——一个 bat 病毒。

概要

在这一章中,你了解了 for 命令的文件读取选项,并且学到了它不仅仅是读取文件。输入可以是文件、字符串,或者是命令的输出,每种都有不同的语法。我详细介绍了如何操作输入数据、创建标记,并在命令的代码块中使用这些标记的语法。我还通过一些现实世界的问题展示了这些功能,最后讨论了如何拆解这个重要的命令。

在下一章中,我将通过考察一些高级技巧来结束第二部分,这些技巧要么解决与使用 for 命令相关的问题,要么解决 for 命令使用中的问题。

第二十章:20 个高级for技巧

过去的三章展示了for命令的强大功能,但也演示了它使用起来的复杂性,甚至揭示了一些限制。在本章中,我将探讨一些高级主题,包括如何绕过这些限制的技巧。

在本章中,我将解释如何执行以下任务:

  • 让解释器在for /F命令查询的数据中,尊重连续分隔符字符之间的空值。

  • 使用传统的 Batch,或者通过将 PowerShell 或 Python 命令嵌入 Batch 代码中,强制将字符串转换为大写(或小写),这是一种可以扩展到其他命令、语言和应用程序的技巧。

  • for命令的代码块中实现两个级别的延迟扩展

  • for命令中处理转义,尤其是使用delims子句中的双引号。

这些技巧将使你能够处理更多类型的数据和更复杂的变量。如果你编写批处理文件的时间足够长,至少其中的几个话题对你来说会变得相关。更重要的是,它们应该展示了一种解决问题的方法,这将激发你在遇到其他未知问题时的想象力。

尊重空值

在第十九章中,我提到过,当使用delims子句将字符串分割为独立标记时,解释器不会尊重空值。考虑一个名为staff.csv的文件,其中包含每个员工记录的五个项目:名字、中间名、姓氏,后跟职位和政府 ID:

Amy,Amanda,Andersen,Architect,111-11-1111
Colin,,Clark,Coder,222-22-2222
Mai,Maria,McManus,Manager,333-33-3333 

数据组织得很好,但可读性不强。然而,以下代码会将来自前四个标记的数据以基础报告的形式显示到控制台,同时不显示记录末尾的敏感数据——或者至少是这样的意图:

set cnt=0
for /F "usebackq tokens=1-4 delims=," %%a in ("C:\Batch\staff.csv") do (
   set /A cnt += 1
   > con echo Employee !cnt!:
   > con echo    First Name: %%a
   > con echo   Middle Name: %%b
   > con echo     Last Name: %%c
   > con echo     Job Title: %%d
) 

前两条记录生成如下内容,但请注意,这里存在一个重大问题:

Employee 1:
   First Name: Amy
  Middle Name: Amanda
    Last Name: Andersen
    Job Title: Architect
Employee 2:
   First Name: Colin
  Middle Name: Clark
    Last Name: Coder
    Job Title: 222-22-2222 

正如你在数据Colin,,Clark中看到的,Colin 没有中间名,但for /F命令会跳过两个逗号之间的空值,导致后续数据出现偏差,并暴露了 Colin 的政府 ID。在许多情况下,比如在按多个空格分隔时,作为程序员,你可能希望这种行为,但在这里就不行。当遇到这样的数据时,你需要一种方法来强制 Batch 尊重空值。

这个解决方案通过使用嵌套的for /F命令,在连续的逗号分隔符之间插入一个空格:

set cnt=0
❶ for /F "usebackq tokens=*" %%r in ("C:\Batch\staff.csv") do (
   set inRec=%%r
 ❷ for /F "tokens=1-4 delims=," %%a in ("!inRec:,,=, ,!") do (
      set /A cnt += 1
      > con echo Employee !cnt!:
      > con echo    First Name: %%a
    ❸ > con echo   Middle Name: %%b
      > con echo     Last Name: %%c
      > con echo     Job Title: %%d
)  ) 

在外层 for /F 命令❶的开始,我将整个记录存储在 inRec 变量中,然后将它作为内层 for /F 命令❷的文本输入,但请仔细查看这个输入:!inRec:,,=, ,!。首先,我通过感叹号解析 inRec,因为我已经在代码块中为它赋值。更重要的是,这个语法将双逗号替换为由空格分隔的两个逗号,实际上插入了一个空格。内层 for /F 命令❷将四个以逗号分隔的标记传递到它的代码块中,当处理科林的数据时,插入的空格变成了第二个标记%%b,并将其作为中间名❸与其他信息一起写入控制台。

艾米的报告没有变化,科林的条目看起来好多了:

Employee 2:
   First Name: Colin
  Middle Name: 
    Last Name: Clark
    Job Title: Coder 

他的中间名正确地显示为空,而他的政府身份证明被隐藏起来。

尽管这些嵌套命令对于该特定数据有效,但逻辑远非万无一失。当记录中的第一个标记是空值时,它就无法正常工作,因为这样的空值不会表现为双逗号;空值作为第一个标记时,只表现为一个逗号引领记录,后面跟着第二个标记。它也无法捕捉到两个连续的空值,或是连续三个逗号,在第二个逗号前插入一个空格,但在它后面却没有插入空格。最后,这些嵌套的 for 命令将空值更改为空格,因此它被改变了。根据你如何使用数据,这通常不是问题,但有时你会希望保持数据的完整性。

所有这些规定可能看起来会使这种方法不适用,但如果你了解你的数据,它可能是完全可以接受的。例如,在这个示例数据中,如果你确信中间名是唯一可能缺失的标记,并且你可以接受在中间名标签后写入一个额外的空格,那么这个语法就完全没问题。但总是有改进的空间。

对之前解决方案做一些调整,下面的代码修正了刚才讨论的所有局限性:

set cnt=0
❶ for /F "usebackq tokens=*" %%r in ("C:\Batch\staff.csv") do (
   set inRec=%%r
 ❷ for /F "tokens=1-4 delims=," %%a in ("_!inRec:,=,_!") do (
    ❸ set a=%%a& set b=%%b& set c=%%c& set d=%%d
      set /A cnt += 1
      > con echo Employee !cnt!:
    ❹ > con echo     First Name: !a:~1!
      > con echo   Middle Name: !b:~1!
      > con echo     Last Name: !c:~1!
      > con echo     Job Title: !d:~1!
)  ) 

外层 for /F 命令❶保持不变。内层 for /F 命令❷仍然传递四个以逗号分隔的标记,但它的输入(!inRec:,=,!)在每个标记前加上了一个下划线。这个前置的下划线实际上是对整个记录进行前置操作,只是将第一个标记前加了一个下划线。然后,替换语法将每个逗号改为一个逗号后跟下划线,这会在每个逗号后插入下划线,从而有效地使每个剩余的标记前加上下划线。空值标记现在是一个下划线(,_),所以解释器会尊重它。

虽然这解决了一个问题,但也引发了另一个问题;我们必须在使用数据项或令牌之前,去除每个数据项或令牌前面新添加的下划线。我不常在单行代码中合并多个命令,但在这里我使用了四个 set 命令,因为它们简短、简单且重复❸。第一个命令,set a=%%a,将令牌 %%a 赋值给变量 a,之后我可以在代码块中通过 !a:~1! ❹ 截取第一个字符,留下只有去除前导下划线后的数据,即原始内容。接着,我对其他三个令牌使用相同的技巧,因为我确信每个令牌前都有下划线。

这种在每个令牌前添加一个字符然后去除它的技巧要更加通用。

强制将字符串转换为大写

如果你还没有意识到,我非常喜欢 Batch 编程,但有时其他编程语言能提供与我在 Batch 中构思的不同,甚至更好的解决方案。例如,强制将文本转换为大写在现代语言中是一个微不足道的操作,这些语言有某种可调用的方法,比如 PowerShell 中的 .toUpper() 或 Python 中的 .upper()。不幸的是,Batch 并没有类似的命令或任何内建机制来将文本转换为大写,但我会展示一些不同的方法来完成这个任务。

到现在为止,你已经看到我们如何从头开始构建一些东西,而在这个解决方案中,我们将利用 Batch 中固有的不区分大小写的特性,创建一个实际改变文本大小写的例程。以下例程接受一个字符串变量名作为输入,并返回该变量,其中内容被强制转换为大写:

:ToUpper
 for %%u in (A B C D E F G H I J K L M N O P Q R S T U V W X Y Z) do (
    set %~1=!%~1:%%u=%%u!
 )
 goto :eof 

这个不带选项的 for 命令执行 set 命令,以延迟扩展的方式更新变量 26 次,每次处理字母表中的一个字符,每次传递都将 %%u 解析为一个大写字符。(也许我应该打破惯例,在这里使用 %%U。)

第一次传递将 A 改为 A,这看起来有些多余,直到你记得批处理文本替换语法是不区分大小写的,这意味着这个技巧同时将 A 和 a 都改为 A。接下来,我们将 B 改为 B,C 改为 C,依此类推,直到整个字母表都被替换完。

当 for 循环完成时,它将所有小写字母转换为大写字母,而不会影响现有的大写字母和非字母字符。传递给这个逻辑的变量作为唯一参数,现在包含的是转换为大写的文本。

你可以创建一个类似的例程,将字符串强制转换为小写,只需将输入的值列表更改为所有小写字符的集合。唯一可能需要更改的是标签,你可能需要将其更新为类似 :ToLower 的名称。

嵌入 PowerShell 命令

另一种解决方案是将前面提到的 PowerShell 命令作为输入传递给 Batch 的 for /F 命令。没错,你没看错;你可以将其他编程语言的逻辑嵌入到 Batch 代码中。但是在将 PowerShell 命令作为 for /F 命令的一部分之前,我们先来看一下它在命令提示符下的运行情况。

如果你的 Windows 电脑中预装了 PowerShell,你可以在命令提示符下执行 PowerShell 命令,除非你在第一代 iPhone 发布之前购买了电脑,否则自 XP SP2(大约 2006 年)以来,每个 Windows 操作系统都会自带 PowerShell。

在命令提示符下输入此命令:

**powershell 'Set this to Upper-Case'.toUpper^(^)**

解释器将单引号内的文本输出到控制台,所有字母字符都会被转为大写。

一眼看去,这看起来像是一个名为 powershell 的 Batch 命令,但并没有这样的命令。实际上,这是程序 powershell.exe,解释器应该能够在路径层级中找到它(大多数电脑上的路径为 *C:\Windows\System32\WindowsPowerShell\v1.0*)。该程序的参数是 PowerShell 命令,用于将文本转换为大写,这个命令恰好使用了点表示法,正好与传统 Batch 命令相反。被强制转换为大写的文本位于单引号内,后面跟着点和适当的 PowerShell 方法,其括号已转义。

你还可以将此命令放入 bat 文件中,它在你希望看到“SET THIS TO UPPER-CASE”写入标准输出时表现良好,但如果你希望将此文本赋值给一个变量,那么就需要使用 for /F 命令:

:ToUpper
 for /F "usebackq tokens=*" %%u in (`powershell '!%~1!'.toUpper^(^)`) do (
    set %~1=%%u
 )
 goto :eof 

与第一个解决方案一样,这个例程有一个参数,作为输入和输出使用——也就是包含文本的变量的名称。

包围在反引号中的 for /F 命令的输入告诉解释器这是一个命令输入,在本例中是嵌入的 PowerShell 命令。请注意,这个命令看起来非常像我们之前在命令提示符下输入的命令。唯一的区别是现在 !%~1! 替代了硬编码的文本。%~1 参数解析为输入变量名;然后,感叹号和延迟扩展会将变量解析为其值。PowerShell 命令的输出是该文本的大写版本,解释器将其作为变量 %%u 发送到代码块中,最终将其重新赋值给相同的输入参数:%~1。

嵌入 Python 命令

你不必止步于 PowerShell 命令。任何你可以在命令提示符下输入的内容,都可以作为 for /F 命令的有效输入,包括调用任何程序,而不仅仅是 Batch 命令。在这个示例中,我将调用 Python 运行时,但只要你能够在命令提示符下执行它,你就可以使用任何语言的运行时。你甚至可以执行用其他语言编写并编译的程序,将它的输出作为 for /F 命令的输入。

与 PowerShell 不同,你的 Windows 计算机可能没有预装 Python 运行时,但你可以从 <wbr>www<wbr>.python<wbr>.org<wbr>/downloads<wbr>/ 下载。这里不是一本关于 Python 的书(虽然 No Starch Press 的确有一些很棒的书籍),所以我不会详细探讨语法,但只要你的计算机上安装了 Python,下面的例程在功能上等同于前两个具有相同标签的例程:

:ToUpper
 set subopts=usebackq tokens=*
 for /F "%subopts%" %%u in (`python -c ^"print^('!%~1!'.upper^(^)^)^"`) do (
    set %~1=%%u
 )
 goto :eof 

我答应不深入探讨,但简而言之,python 是没有扩展名的可执行文件或运行时,-c 告诉运行时接下来是一个 Python 命令。print() 函数输出的是括号内内容的结果,该内容是解析后的输入变量,包含单引号、一个点和将字符串转换为大写的 Python 方法。去掉六个转义字符后,Python 命令更容易阅读:print('!%~1!'.upper())

代码块中的 set 命令再次将嵌入命令的输出赋值给它的唯一参数。但还要注意,我设置了一个包含 usebackq 关键字和 tokens 子句的文本变量,然后将该文本作为 for /F 命令的一部分进行解析。这样做有两个原因。第一个也是最直接的原因是,否则命令就放不下在页面上(尽管它是完全有效的)。第二,这是另一个机会,让我提醒你,我们如何在一个未编译的脚本语言中将命令拼接在一起。

实际的 PowerShell 和 Python 命令本身当然很简单,但使用它们所需的 Batch 机制确实增加了一些复杂性。这通常不是解决问题时首先想到的技巧,但当你能够真正利用其他语言的命令时,for /F 命令为你提供了一种有效的方式来分配其输出。

注意

在第十九章中,为了保持一致性,我建议养成在命令作为 for /F 命令输入时使用 usebackq 的习惯。我推荐的部分原因是,虽然在这样的命令中通常找不到反引号或单引号,但单引号是两者中更可能出现的。对于这最后两个例子,关键字和包含反引号不仅是好的实践;它们是必须的,因为嵌入的 PowerShell 和 Python 命令都包含单引号。

延迟扩展的两个层次

延迟扩展是 Batch 的一大特点,其他大多数语言中都没有。当你积累经验并开始构建一个更有趣且复杂的代码库时,你会遇到一个相当常见的问题,即如何在 for 命令的代码块中处理两个层次的延迟扩展。

在第三章中,我通过定义包含五个城市美食的变量来演示了延迟扩展。这里有两个例子:

set foodNash=Hot Chicken
set foodNYC=Thin Crust Pizza 

使用相同的缩写,我还设置了每个城市完整名称的变量:

set NashFull=Nashville
set NYCFull=New York City 

最终,以下的 echo 命令包含了两个延迟扩展的示例,这些示例解析了这些变量,前提是 city 变量被分配了有效的城市缩写:

> con echo The best !food%city%! can be found only in !%city%Full!.

现在,让我们基于这个例子,列出美国所有 50 个州,并为每个州指定一个烹饪之都。例如,虽然杰斐逊市是密苏里州的首府,但圣路易斯以其冷冻卡士达闻名,许多人认为它是该州的烹饪之都。在我成长的州——康涅狄格州,纽黑文独特的薄脆比萨和米德尔顿的蒸奶酪汉堡之间长期以来一直存在争论。我并不想卷入其中,但为了本练习的需要,我(某个权威机构)被赋予了定义每个州烹饪之都的权力,并且明确了每个城市,进而确定该州的代表性菜肴。给定一个州的邮政缩写代码列表,我可以这样定义每个州的烹饪之都:

set culCapTN=Nash
set culCapNY=NYC
set culCapIL=Chic
set culCapLA=NO
set culCapMO=STL 

例如,TN(田纳西州)的烹饪之都 culCapTN 是 Nash(纳什维尔)。现在,我们可以使用两位数的州代码作为输入,进行 for 命令,其中延迟扩展将烹饪之都变量解析为城市缩写。然后,第二级的延迟扩展将这些缩写解析为食物和完整的城市名称。

这些州的代码可能来自文件中的数据,但为了让这个练习尽可能简单,我使用了硬编码的州代码列表作为没有选项的 for 命令的输入:

for %%s in (TN NY IL LA MO) do (
   set city=!culCap%%s!
   > con echo The best !food%city%! can be found only in !%city%Full!.
) 

%%s 变量会在五次迭代中解决到州的代码。在数据的第一次迭代中,!culCap%%s! 会解决为 !culCapTN!,然后它会解决为 Nash(纳什维尔),并且我们将其分配给 city 变量。然后,echo 命令从第三章中按字面意思提取,解决了两个延迟扩展的示例,就像在上一章中那样,对吧?

错了!如果这种情况有效,我就不会称其为“高级技巧”了。虽然解释器成功地为 city 变量分配了城市缩写,但当代码处于像这样的代码块内时,变量会有两个不同的值:感叹号定界符将其解决为代码块中已取得的值,而百分号定界符则将其解决为代码块执行之前的值。

因此,%city% 解决为无(或它在先前代码中设置的任何值),然后 !food%city%! 解决为 !food!,而 !food! 很可能也解决为无。代码不会挂起或抛出中止错误——更糟糕的是,它只会显示一个不完整的句子。

我们陷入了困境。由于 city 是在代码块内部设置的,解决它当前值的唯一方法涉及感叹号,但延迟展开要求我们用百分号来解析内部变量。(当首次遇到这个困境时,大多数程序员,如果不是所有程序员,都会尝试交换定界符。你可以试试,但它就是不行。)以某种方式,你必须通过百分号让 city 可解析。

一种解决方案涉及一个隐藏的例程。这个技巧并不会将第一次延迟展开的结果赋值给像 city 这样的变量;相反,它将值传递给一个例程:

for %%s in (TN NY IL LA MO) do (
   call :WriteFoodText "!culCap%%s!"
)
if 0 equ 1 (
  :WriteFoodText
   > con echo The best !food%~1! can be found only in !%~1Full!.
   goto :eof
) 

例程中的代码随后使用 %~1 语法将城市解析为一个简单的参数。由于例程在 for 命令的代码块外部执行解析,变量 !food%~1! 会很好地解析为 !foodTN!,再次解析为热鸡肉。

然而,这种技巧也不是没有问题的。它不是那种通常适合放在 bat 文件末尾的复杂例程,而且绝对不会单独出现在一个 bat 文件中。它其实只是一个单一的命令(加上标签和终止的 goto :eof 命令),所以最好将其靠近 call 命令。但如果我将标签紧接在这个 for 命令后面,解释器会对每个州执行一次例程,然后在 for 命令完成并控制流转到下一个命令时,再执行一次。为了解决这个问题,我使用了一个永远不会为真的笨拙条件语句 0 equ 1,它作为例程的门卫。

这种技巧很容易让任何阅读代码的人感到困惑,但它成功地隐藏了一个放在 call 命令附近的隐藏例程,而这是访问它的唯一方法。它有效,我经常使用它,但这不是你会想挂在冰箱上和孩子的艺术作品一起展示的那种逻辑。

更为优雅的解决方案是将第二个 for 命令嵌套在第一个命令中:

for %%s in (TN NY IL LA MO) do (
   for /F "tokens=*" %%c in ("!culCap%%s!") do (
     > con echo The best !food%%c! can be found only in !%%cFull!.
)  ) 

首先,%%s 解析为州代码;然后 !culCap%%s!,解析为相应的城市缩写,是传入内部 for 命令的字符串,我们将其赋值给 %%c。因此,内部的 for 命令只不过是将 city 赋值给一个变量,我们可以通过百分号来解析它。注意,我已经将问题陈述中解析为 null 的 %city% 替换为 %%c 变量。同样,以田纳西州为例,%%c 解析为 Nash,然后 !food%%c! 变为 !foodNash!,进一步解析为美味的热鸡肉。类似地,!%%cFull! 解析为 !NashFull!,并解析为 Nashville。现在,这可是值得贴在冰箱上的代码!

我使用/F选项与内部for命令配合,并用双引号将文本输入括起来。将 tokens 关键字设置为星号确保%%c解析为整个文本字符串,即使它包含空格,这意味着代码块只会执行一次。(一个更简单的不带选项的for命令对于这个特定数据也能作为内部for命令使用,但for /F命令是一个更通用的解决方案,因为它可以处理包含空格和不包含空格的输入。)

本节中详细介绍的两种解决方案会将以下输出写入控制台:

The best Hot Chicken can be found only in Nashville.
The best Thin Crust Pizza can be found only in New York City.
The best Deep Dish Pizza can be found only in Chicago.
The best Muffuletta Sandwich can be found only in New Orleans.
The best Frozen Custard can be found only in St Louis. 

我提供的这个问题陈述看起来可能有些牵强,但这种技术的需求常常出现。更现实的例子通常涉及更复杂的情况,涉及文件、数组、哈希表等更多我尚未深入探讨的主题。例如,若要为robocopy命令获取目标路径,你可能需要从文件中读取目标城市,然后在哈希表中查找该城市以获得路径。但这些情况归根结底都是同一个问题:你需要在代码块中使用延迟扩展来处理之前设置的变量。要时刻留意类似的情况。

使用for命令进行转义

for命令中转义特殊字符是一个挑战,可能以多种不同方式表现出来,其中一个例子就是在子选项子句中。输入数据通常完全不在我们程序员的掌控之中。如果所有数据文件都是以逗号或管道符分隔的那该多好,但很多时候你不得不使用更复杂的分隔符。最糟糕的情况是文本以双引号分隔。如果你试图提取六个标记,你可能会考虑这个子选项子句,但它是无效的:

"tokens=1-6 delims=""

虽然解释器正确地将第一个双引号识别为子句的开始,但第二个双引号终止了子句,第三个双引号则处于上下文之外。但这个看起来晦涩的子句完成了任务:

tokens^=1-6^ delims^=^"

没有双引号开启子句,且结尾的双引号并没有关闭子句——它是分隔符集中的唯一项。包围子句的双引号不再存在。这允许你在delims子句中包含双引号,只要你用前导插入符号进行转义。但是,缺少包围选项子句的双引号会引发其他问题。等号甚至空格不再受到包围双引号的保护,现在也需要转义。

我在两个等号前面都加了插入符号(^)。这两个关键字子句之间的空格会终止较大的子选项子句,因此这个空格也需要转义。这个插入符号看起来似乎跟在 6 后面,但实际上它是引导空格的。在批处理的一个不太文档化的特性中,只要你转义了特殊字符和空格,你就可以省略包围的双引号。

现在让我们将这一切结合起来并进行测试。考虑这个非常小的文件,UglyData.txt,其中仅有一行数据,用五个双引号分隔六个单词:

This"is"some"messed"up"data

这个 for /F 命令包含了前面讨论过的选项子句:

for /F tokens^=1-6^ delims^=^" %%a in (C:\Batch\UglyData.txt) do (
   > con echo %%a %%f %%b no longer %%d %%e.
) 

这是输出到控制台的内容:

This data is no longer messed up.

在 第十九章 中,我提到过我通常在读取文件时使用 usebackq。这里没有使用,因为子选项已经变得很混乱,但如果你把双引号放在输入文件周围,你需要在 tokens 子句前添加 usebackq^ 和一个空格。(你稍后会看到类似的内容。)

当存在多个分隔符时,相同的原理也适用。让我们通过添加插入符号、管道符、百分号和与符号分隔符,使UglyData.txt变得更加混乱:

This^is|some%messed"up&data

现在,以下的子选项子句使得这个操作得以实现:

tokens^=1-6^ delims^=^"^|%%^^^&

一个插入符号转义所有这些特殊字符(甚至是插入符号分隔符),除了一个;我们只能通过另一个百分号来转义百分号。

在分隔符集里可能很少会出现双引号的需求,但一旦需要,这项技术就显得不可或缺。

我们还没有完全完成转义。在接下来的示例中,我将使用相同的数据并将相同的输出写入控制台,但数据的来源会不同。我将从一个文件中提取混乱数据,并将其设置为一个变量,这将要求对数据和 for /F 命令进行更改。

我将从数据开始。在 第十四章 中,你学到了设置变量时必须转义所有特殊字符,但即便如此,你得到的变量仍然包含无法稍后解析的特殊字符,除非将它们暴露出来。解决方法是进行两层转义:

set uglyData=This^^^^is^^^|some%%%%messed^^^"up^^^&data

使用这个 set 命令,所有的双插入符号都会被解析为单插入符号。解释器会丢弃所有其他插入符号,因为它们是在转义其他字符;双百分号会解析为一个,从而使得四个变成两个。最后,解释器将 uglyData 设置为 This^is|some%%messed"up&data 文本。下一次我们解析这个变量时,现有的转义字符将被丢弃。

对 for /F 命令也做了更改:

for /F usebackq^ tokens^=1-6^ delims^=^"^|%%^^^& %%a in ('%uglyData%') do (
   > con echo %%a %%f %%b no longer %%d %%e.
) 

该命令包含了更复杂的 delims 子句,包含五个已转义的分隔符。输入到 for 命令中的 %uglyData% 文本会解析为 This^is|some%messed"up&data,且被单引号包围,并且 usebackq 关键字也已就位,并带有其后缀转义字符。

由于双引号是输入的一部分,因此此 for /F 命令必须在输入字符串周围使用单引号,这就需要使用 usebackq 关键字。(这正是 第十九章 中提到的那种罕见情况。)注意,当双引号位于正在读取的文件中时,Batch 更容易处理它;通常,解析字符串比解析文件数据要复杂。最后,我们看到 This data is no longer messed up. 被写入控制台。

从这次讨论中,最大的收获是你应该始终弄清楚需要预期多少级别的转义,并确保代码能够支持输入中所有预期的特殊字符。

总结

在本章中,我介绍了一些我在作为批处理编码员的职业生涯中遇到的高级技巧。这并不是关于for命令的所有有趣难题的详尽列表,但它代表了许多典型情况。要充分发挥批处理的能力,你必须理解for命令,而这需要实验和探索。不要害怕。如果你在批处理中编程足够长时间,你会遇到一些这里没有涉及的内容,但如果你遵循这些示例中的思考过程,你应该能够通过实验找到解决方案。

这部分内容结束了第二部分,但这绝不是你在接下来的章节中最后一次见到for命令。这个命令的更多应用还在后面,但在下一章,我会稍微退一步,探索一些重要的伪环境变量。

第三部分 高级主题

第一部分介绍了批处理文件的基础知识,第二部分探讨了至关重要的 for 命令。第三部分将涵盖需要使用这一关键命令的技巧,以及一些实用的高级主题。

在本书的这一部分,你将学习伪环境变量、编写报告、递归、文本搜索、自动重启,以及构建其他批处理文件的批处理文件。其他主题将深入探讨条件执行(再一次)、数组、哈希表以及测试与故障排除。我将最后讲解面向对象设计,并通过数据结构——栈和队列的应用来进行说明。这些主题中的许多并不是批处理的创始者所设想的。但这从未阻止过我们,也不会在接下来的章节中阻止我们。

第二十一章:21 伪环境变量

我在第二章中介绍了环境变量及其 set 命令,它们从那时起出现在了几乎每一页中。你可以定义和解析一个简单的变量,然后可能会重置并以不同方式再次使用它。伪环境变量与其类似,但也有显著不同。你可以像常规环境变量一样解析它们,但它们的来源或设置方式有本质的不同。

我们已经讨论了一些伪环境变量,包括 path、pathext、cd 和 errorlevel。所有伪环境变量都有一些共同特点,但许多都有独特的属性。许多伪环境变量有不同的设置方式,有些你根本不应设置。某些变量在运行 bat 文件之前就已激活,而解释器会在执行过程中反复更新其他变量。

每个伪环境变量在批处理世界中都有一个固有的特性,在正确使用之前你必须理解这一点。在本章中,我将解释一些已经提到的伪环境变量的复杂性,并探索一些我们将在未来章节中使用的有用的伪环境变量。我还将提供一直承诺的关于从编码者角度看,bat 文件和 cmd 文件的主要区别的解释。

日期和时间

你可以轻松地通过分别使用恰如其分命名的日期和时间伪环境变量来获取当前的日期和时间:

> con echo Date = "%date%";  Time = "%time%"

如果在波士顿红袜队结束了 86 年的冠军荒之后不久执行该命令,它将产生如下输出:

Date = "Wed 10/27/2004";  Time = "23:39:12.34"

日期变量包含当前日期,格式为星期几、月份、日期和年份。星期几总是以三个字符的大写和小写混合形式呈现,后跟一个空格、一个两字节的月份、一个斜杠、一个两字节的日期、另一个斜杠和一个四字节的年份。例如,Sun 06/08/1986 是批处理时代早期的一个例子。

时间变量包含当前时间,格式为小时、分钟、秒和百分之一秒。它使用 24 小时制,因此晚上 11:39 转为 23:39:00.00。如果小时数是单个数字,批处理会在其前面加上一个空格,而不是前导零;例如,在上午 10:00 临近时,时间将解析为一个空格,后跟 9:59:59.99,而午夜则显示为空格加上 0:00:00.00。

这确实有些奇怪,但请记住,时间在前面有一个空格代替零,而日期对于所有单数字的月份和日期则有一个前导零。

由于日期和时间格式良好,你可以轻松地将它们用来增强报告和日志文件,而且因为日期格式一致,所以可以轻松地提取一个日期戳,格式为 CCYYMMDD,如 Listing 21-1 所示。

set #=%date%
set datestamp=%#:~10,4%%#:~4,2%%#:~7,2% 

Listing 21-1: 在 Datestamp.bat 文件中使用的两个命令来构建日期戳

(为了节省按键次数,我喜欢使用井号作为一个非常简短的变量名,但仅限于非常简洁和有限的使用。在 bat 文件中稍后使用这个变量会造成困扰,但在接下来的一两行中立即使用它则是一个很好的方式来简化代码。虽然我必须承认,%#:~7,2%是深奥的,并非每个人都喜欢。)

使用不同的技术,以下是构建按 HHMMSSss 格式的时间戳的代码:

set timestamp=%time: =0%
set timestamp=%timestamp::=%
set timestamp=%timestamp:.=% 

第一个命令将替换前导空格(如果存在的话),用 0 代替。接下来的两个命令将移除两个冒号和小数点。

除非在上午 10 点之前进行编码和测试,否则很容易忘记前导空格,但它是至关重要的。如果在下午测试时忘记了它,变量将会在午夜时刻突然包含一个空格,而根据你计划如何使用它,可能会在非常不合时宜的时刻发生失败。这可以看作是另一个警告。

日期戳和时间戳有许多用途。你可以在文件名中使用它们来表示创建日期和时间。在 if 命令中,你可以将它们与其他日期戳或时间戳进行比较,在特定日期和时间启用逻辑。你甚至可以在某些过程之前和之后捕获它们来衡量经过的时间。

Prompt

提示符伪环境变量同时也作为命令使用。就像 path 是用来更改路径变量的命令一样,prompt 是用来更改提示符变量的命令。

在第十二章中,当讨论解释器生成的输出(stdout)时,我提到了解释器会在每个命令的输出前加上提示符,无论输出最终是显示在控制台上,还是重定向到跟踪文件中。默认情况下,提示符是当前目录后跟一个大于符号。例如,如果从* C:\Batch* 目录运行清单 21-1 中生成日期戳的两行代码,它可能会生成以下输出到跟踪文件:

C:\Batch>set #=Wed 10/27/2004
C:\Batch>set datestamp=20041027 

但默认设置是可以更改的。提示符变量包含硬编码文本和/或特殊代码,定义了在 stdout 中看到的提示符内容,而提示符命令是更新提示符变量的工具。该命令的唯一参数是新的提示符变量,它将改变我们在 stdout 中看到的内容。

为了演示,如果我特别自恋并希望每行执行的代码上都带有我的签名,这个简单的命令可以满足我的虚荣心:

prompt Jack's$SCode$G

参数中有两段硬编码文本,分别是 Jack's 和 Code。此外,还有两个特殊代码,在单词之间插入一个空格(\(S),在末尾插入一个大于符号(\)G)。运行相同的两行代码,在同一天现在会生成如下输出:

Jack's Code>set #=Wed 10/27/2004
Jack's Code>set datestamp=20041027 

但是这个命令和变量并不是为了虚荣而创建的;它们是为了在提示符中填充自定义信息而创建的。考虑这个更为复杂和深奥的示例:

prompt %~NX0$A$N:$$$C$D$B$T$F$G

这个提示符命令并不容易阅读,但这里我将其分解如下:

%~NX0    Bat 文件名和扩展名;可能解析为 Datestamp.bat

$A    和符号;解析为 &

$N    驱动器字母;可能解析为 C

:    硬编码文本;显示为 :

\[    美元符号;解析为 $ $C    左括号;解析为 ( $D    日期;可能解析为 Wed 10/27/2004 $B    管道符号;解析为 | $T    时间;可能解析为 23:39:12.34 $F    右括号;解析为) $G    大于号;解析为 > 在你分配这个提示变量之后,执行相同的两行可能会得到如下输出: ``` Datestamp.bat&C:$(Wed 10/27/2004|23:39:12.34)>set #=Wed 10/27/2004 Datestamp.bat&C:$(Wed 10/27/2004|23:39:12.35)>set datestamp=20041027 ``` 在这个提示变量中的这些实体,除了少数几个,都是特定于提示命令的特殊代码。一个字节,冒号,是硬编码的,第一个项目,%~NX0,只是一个解析后的变量。它恰好是正在执行的 bat 文件的名称和扩展名,但几乎任何变量都可以在这里使用。 时间的特殊代码 ($T) 允许你精确看到每个命令执行的时间。如果一个 bat 文件执行时间似乎比预期长,这是一个简单的方式来确定瓶颈。在前面的例子中,第二个命令执行的时间比第一个多了百分之一秒,这个信息会在提示符中显示出来,而其他部分保持不变。 另外,请注意,大于号 ($G) 是提示符中的最后一个字符。如果没有它,提示符会与后面的命令混在一起,产生相当难以读取的标准输出。它不是必须的,但最好在提示符末尾加上大于号或某种特殊字符。 你甚至可以在大于号后加上一个或两个空格,以进一步区分提示符和输出。无论当前提示变量是什么,这个命令都会在提示符后加上两个空格: ``` prompt %prompt%$S$S ``` 这个命令清楚地展示了提示符的两种用法。假设默认的提示符是活动的,提示符变量解析为 $P$G(当前目录和大于号)。然后命令将此与 $S$S 连接,并将其作为提示命令的参数: ``` C:\Batch>prompt $P$G$S$S ``` 这个命令激活带有尾随空格的新提示符,以执行任何后续命令: ``` C:\Batch> set #=Wed 10/27/2004 C:\Batch> set datestamp=20041027 ``` 通过硬编码的文本和所有特殊代码可用的字符,你可以轻松地定制提示符,几乎可以实现任何你想得到的文本,以满足任何需求。我之前展示了一些特殊代码,但帮助命令提供了完整的列表。 ### 随机数字 随机伪环境变量解析为一个介于 0 和 32,767 之间的随机数,包括两者。你可以用它来模拟抛硬币;偶数为正面,奇数为反面。如果你想随机启动一个过程在任意数量的服务器上,这个伪环境变量可以为你提供这种能力。 为了演示随机的一个用法,在第十五章中,我展示了交互式批处理,其中有一个 bat 文件提供笑话、双关语或谜语——但每次只提供其中一个。不幸的是,这些内容很快就用完了。现在,想象一个包含 100 个 bat 笑话、100 个 bat 双关语和 100 个 bat 谜语的库。不幸的是(或幸运的是),我不会在这里列出它们。再想象一下,当用户请求一个双关语时,我们从这个库中随机选择一个双关语。 为了实现这一点,我们需要做几件事。首先,我们需要将 100 个双关语放入数组中,以便单独选择它们。(这一点将在第二十九章中讨论。)更重要的是,我们需要一种生成 100 个可能数字中的一个随机数字的方法。以下的 set /A 命令使用随机伪环境变量和模运算符生成一个介于 0 和 99 之间的 *随机* 数字(可以说): ``` set /A punNbr = %random% %% 100 ``` 如果将这些双关语标记为 0 到 99,我们可以简单地根据 punNbr 的值选择双关语。如果将双关语标记为 1 到 100,我们只需在结果中加 1: ``` set /A punNbr = %random% %% 100 + 1 ``` 我提到这“有点”有效的原因有两个。首先,像大多数计算机生成的随机数一样,这实际上是一个伪随机数,而不是真正的随机数。(是的,这就是伪随机的伪环境变量。)当 bat 文件第一次启动时,解释器会用当前时间为随机函数提供种子,这个时间会被用于生成后续请求的所有随机数的算法。这意味着,在同一时刻启动的两个 bat 文件会看到相同的伪随机数生成集——甚至是相隔几秒钟启动的两个 bat 文件,至少在最初的几次调用中,也会看到非常相似的 *随机* 数字。虽然伪随机数对于大多数应用程序来说是完全可以接受的,但还是要注意这一点。 punNbr 不是完全随机的第二个原因是 32,768 这个随机数的总数不能被 100 整除。如果你在这一节中执行第一次 set 命令 32,768 次(每次执行一次,数字从 0 到 32,767),结果为 0 的次数是 328 次。同样,1 到 67 的结果各出现 328 次。然而,68 作为结果的次数仅为 327 次,其余数字直到 99 也都是如此。结果是,有些数字会被 *随机* 选择的频率稍微高于其他数字。 本节的其余内容是一个略微有强迫症的数学家成为程序员,最终处理随机数的不可避免结果。大多数时候,伪随机数和模运算能完全完成任务,但如果你想知道如何尽可能接近真正的随机数,请继续阅读。 ### cmdcmdline 变量 另一个有趣的伪环境变量是 cmdcmdline,或者*命令行命令*。它看起来很冗余,但它实际上是最初启动当前执行的命令行命令。在 Windows 计算机上,*.bat*扩展名默认与 Windows 的*cmd.exe*程序关联,当你打开一个 bat 文件时,这个程序会执行它。为了演示,假设*DateTime.bat*文件包含以下命令: ``` > con echo %cmdcmdline% ``` 执行 bat 文件时可能会将这些内容写入控制台: ``` C:\WINDOWS\system32\cmd.exe /C ""C:\Batch\DateTime.bat" " ``` 当你打开或双击一个 bat 文件时,Windows 在后台会调用*cmd.exe*程序,并传入/C 选项和 bat 文件作为其参数。如果这个 bat 文件调用了另一个 bat 文件,cmdcmdline 的值不会改变。它始终是启动高级进程的命令。 你可以解析这个变量来获取一些有用的信息。即使从被调用的 bat 文件中,你也可以检索到原始的参数列表。如果你已经将 stdout 重定向到跟踪文件,跟踪文件的路径和名称就位于变量值的末尾,准备好被提取。一个 bat 文件可能被设计成可以在两种不同模式下运行,独立运行或被另一个 bat 文件调用,每种模式下需要略有不同的逻辑。为了智能地确定模式,可以将这个变量的内容与%0 隐藏参数进行比较。如果你发现这两个字段中都包含同一个 bat 文件的名称,那么它就是高级 bat 文件,因此是独立的。如果没有,那么它一定是被调用的 bat 文件。 ### 系统变量 另一类伪环境变量告诉你 bat 文件执行所在的机器信息。它们被称为*系统变量*。 这些系统变量的一个示例包括 USERNAME、USERPROFILE、PROCESS_ARCHITECTURE、NUMBER_OF_PROCESSORS 等,这些对任何熟悉 Windows 计算机工作原理的人来说都有意义。变量 ProgramFiles 解析为 Microsoft 安装其 64 位程序文件的根目录,而 ProgramFiles(x86)则对其 32 位版本做同样的事。 在这些冗长命名的变量之后,可能会显得有些奇怪,但 OS 是*操作系统*的一个简化缩写。用于存储*临时*文件的目录足够特殊,值得拥有两个系统变量 TEMP 和 TMP,而你可以通过 windir 引用根*Windows 目录*。 USERDOMAIN 变量在你的 bat 文件可以在不同域中运行时非常有用,甚至可能在不同的物理位置。每个域可能有不同的基础设施,例如某些资源的路径,而这个变量是使代码足够智能以便在多个位置运行的关键。我常用的另一个系统变量是 COMPUTERNAME。如果一个进程可以在几十台服务器中的任何一台上启动,你可以使用这个变量轻松确定 bat 文件在哪里被执行。 > 注意 *尽管看起来可能不一致,但我在本节中对系统变量名称的大小写并不是随意的。你可能知道所有批处理变量都是不区分大小写的,而我通常使用驼峰式命名,但我展示每个伪环境变量的方式是按照微软的呈现方式——即,通过在命令提示符下运行没有参数的 set 命令时所显示的内容,以避免任何混淆。有些完全大写,有些完全小写,有些是驼峰式命名,有些甚至是蛇形命名(用下划线分隔的单词),还有一些甚至在变量名中包含括号。这对于任何渴望一致性的人来说,简直是噩梦。* 进一步探索。打开命令提示符并输入三字节命令 set,查看你计算机上设置的所有变量。所有这些变量在该计算机上运行任何 bat 文件时都会可用。 ### Bat 文件与 cmd 文件 在第一章中,我介绍了 bat 文件和 cmd 文件。当时,我提到从编码者的角度来看,唯一显著的区别在于解释器设置返回码的方式和时机。这里我将详细说明这些区别。 我们已经讨论过三个命令,当命令执行失败时,会将 errorlevel 设置为非零值:set、path 和 prompt。实际上,大多数命令都会这样做,但它们的不同之处在于,当命令执行成功时,它们不会将 errorlevel 设置为 0。最终的结果是,执行这些命令后,你不能信任 errorlevel 的值。 然而,在 cmd 文件中,成功执行这些命令时,errorlevel 总是设置为 0,而当命令执行失败时,errorlevel 总是设置为非零值。(我说这是*最大的*区别。我并没有说这是*很大的*区别。) 大多数编码者,包括我自己,很少检查这些命令的返回码。即使你将一个不存在的目录传给 path 命令,它也不会失败;只有在某些原因导致它无法用有效路径重置变量时才会失败,而我从未见过这种情况。同样,prompt 命令几乎接受任何东西作为提示变量,我也从未见过它失败。使用 set 命令设置简单变量也很难出错。唯一可能的例外是使用 set /A 做一些算术运算时。例如,缺少操作数或除以零会生成非零的 errorlevel 值。 话虽如此,如果你想在执行这些命令后检查 errorlevel,有两种解决方法。首先,在使用 bat 文件时,无论以何种方式,你都需要在执行 set、path 或 prompt 命令之前,将 errorlevel 重置为零。如果命令失败,解释器会将 errorlevel 重置为非零值;如果命令成功,errorlevel 仍然是零。另一种选择是使用 cmd 文件。 在 bat 文件中将返回码设置为零有一个障碍。一般来说,问题在于如何设置伪环境变量。更具体地说,errorlevel 永远不应通过 set 命令设置,但总有解决办法。 ### 设置伪环境变量 你有能力设置和重置一些伪环境变量。你已经了解到,path、cd 和 prompt 既可以作为命令,也可以作为变量,命令会重置同名的变量。它们在 bat 文件启动时会设置为默认值,但你可以更改这些值。 其他伪环境变量,如 windir,在 bat 文件启动时已经设置,重置它们是愚蠢的行为。解释器在 bat 文件执行过程中会设置并重置其他这样的变量,可能多次。例如,大多数命令会重置 errorlevel;time 变量每 1/100 秒就会获得一个新值,如果 bat 文件在午夜运行,date 也会发生变化。你绝不应该设置这些变量。请注意,我没有写出你*不能*设置这些变量。没有什么能阻止你执行这个命令: ``` set errorlevel=0& rem Never Never Never Do This... Ever ``` 没有编译器来阻止这种傲慢行为。解释器不会中止并崩溃执行,它本质上说:“你现在想设置 errorlevel 吗?这是我的变量,但如果你想要它,没问题,它现在是你的了。不过,我再也不管它了。”我可能把解释器拟人化得太过了,但它有时真的显得非常消极攻击性。 这个简单但不明智的 set 命令将变量转换为一个简单的用户变量,并将其从伪环境变量的范畴中移除。随后执行的命令将会成功或失败,这些命令通常会生成返回码,但由于 errorlevel 不再是伪环境变量,它将保持原样,不会从其错误分配的值中改变,直到运行流结束或另一个不明智的 set 命令再次错误地重置它。 一些永远不应通过 set 命令设置的伪环境变量包括 errorlevel、date、time、cmdcmdline 和 random。如果有疑问,最好假设任何伪环境变量都不应该被重置。重置这些变量大多数时候没有意义,但有一个你可能希望重置:errorlevel。在上一节中,我详细描述了这种场景。 以下命令在没有提及 errorlevel 的情况下将 errorlevel 重置为 0: ``` cmd /C exit 0 ``` cmd 命令打开一个新的命令行窗口,/C 选项告诉解释器执行其后的命令,并在执行完毕后终止新命令行窗口。在这个示例中,它执行一个简单的退出命令,返回 0。结果是,它将 errorlevel 设置为 0——无需使用 set 命令。 如果这看起来有点熟悉,那是因为我在本章前面提到过 *cmd.exe* 程序。你可以使用 cmd 命令调用这个程序,它也是解释器,从而执行 bat 文件或其他命令。 ### 总结 真正理解伪环境变量将为你提供更多工具,让你完成更多任务。我没有提供一个详尽的列表,但我提到了重要的几个。你已经学会了如何操作提示符,如何生成随机数,以及错误设置某些伪环境变量的危险。我还在两种情况下讨论了 errorlevel 伪环境变量。你可以将它重置为任何数字,但并不是你可能想象的那样,并且它在 bat 文件中和 cmd 文件中的表现有所不同。 在下一章中,你将学习如何使用日期和时间伪环境变量在批处理文件中创建和格式化报告。 ## 第二十二章:22 撰写报告 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 即使用力眯眼到差点得脑动脉瘤,也没有人会把批处理程序误认为是 Power BI 或其他类似的报告生成工具,但当你需要一个简单的格式化文本报告时,批处理程序是一个可以胜任的工具。 在本章中,我们将构建一个基于管道分隔输入文件的报告。通过上一章的两个伪环境变量,你将学习如何构建一个包含当前日期和时间的标题,以及格式化的列标题。你还将通过读取输入文件并使用在第二部分中学到的某个命令来创建详细记录。我将分享一些技术,用于将字符串、整数和浮动点数据右对齐或左对齐,并在固定宽度或等宽字体中整齐排列列。最后,你将学习如何对数据进行汇总,创建带有总量和平均量的尾部记录。 如果你想生成饼图、直方图或散点图……还有其他工具可以选择。 ### 数据和报告 本次练习中,我们将从一个简单的管道分隔文件开始,文件包含了 2019 年少数精选富裕国家的三项重要健康指标。每条记录的第一个标记是国家名称,后面跟着该国用于医疗保健的国内生产总值百分比。这是衡量一个国家在其总体财富中用于医疗保健开支的一个很好的指标。第三个标记是预期寿命,最后一个是每 10 万人中的可避免死亡人数。可避免死亡是指由于缺乏有效且高质量医疗保健而导致的死亡,例如糖尿病、高血压和某些癌症等疾病。这两个指标是衡量医疗系统效果的非常好的标志。*HealthStats.dat* 文件可以包含任意数量的记录,但为了简便起见,我这里只包含了七个国家及其统计数据,如清单 22-1 所示。 ``` Australia|9.3|82.6|62 Canada|10.7|82|72 France|11.2|82.6|60 Germany|11.2|81.1|86 Sweden|11|82.5|65 UK|9.8|81.3|84 US|16.9|78.6|112 ``` 清单 22-1:包含健康统计数据的管道分隔 HealthStats.dat 文件 注意前两个数字标记是浮动点值,但有人(是我)没有为某些条目的值包含.0。我们需要在代码中考虑到这一点。(该文件中的数据来自“英联邦基金会”,并经许可使用,网址是* [`www.commonwealthfund.org/publications/issue-briefs/2020/jan/us-health-care-global-perspective-2019`](https://www.commonwealthfund.org/publications/issue-briefs/2020/jan/us-health-care-global-perspective-2019) *。) 这是很棒的数据,但管道分隔文件的可读性并不出名。我们的任务是将清单 22-1 中的数据转换成清单 22-2 中更具可读性和描述性的报告。 ``` A Comparison of National Health Expenditures and Outcomes, 2019 Date: 03/23/2020 Time: 14:30 % of Life Avoidable Country GDP Expectancy Deaths/100K ------- ---- ---------- ----------- Australia 9.3 82.6 62 Canada 10.7 82.0 72 France 11.2 82.6 60 Germany 11.2 81.1 86 Sweden 11.0 82.5 65 UK 9.8 81.3 84 US 16.9 78.6 112 ------- ---- ---------- ----------- Averages 11.4 81.5 77 ``` 清单 22-2:由 HealthRpt.txt 批处理文件生成的报告 最多你也只能称这个报告为功能性报告。它没有你在浏览器中查看的 HTML 报告那样的不同字体大小、框架、突出显示、自动居中或其他特性,但它是有用的、信息丰富且格式良好的。 这样一份报告有三个不同的部分:介绍、正文和总结,分别由头记录、详细记录和尾记录组成。我会分享完整的批处理文件来构建这个报告,但我会将其分解成这三部分。在本章结束时,你将能够构建自己的数据文件并生成自己的报告。 ### 头记录 开始的明显位置是介绍部分,包括标题和列头。以下是创建报告的批处理文件的第一部分: ``` setlocal EnableExtensions EnableDelayedExpansion ❶ set rpt=C:\Batch\HealthRpt.txt ❷ set cnt=0 set totPerGDP=0 set totLifeExp=0 set totDeaths=0 ❸ > %rpt% echo A Comparison of National Health >> %rpt% echo Expenditures and Outcomes, 2019 >> %rpt% echo Date: %date:~4% Time: %time:~0,5% >> %rpt% echo. ❹ >> %rpt% echo %% of Life Avoidable >> %rpt% echo Country GDP Expectancy Deaths/100K >> %rpt% echo ------- ---- ---------- ----------- ``` 在打开的`setlocal`命令后,我们定义了 rpt 变量 ❶,其包含报告文件的路径和名称。我保持变量名简洁,因为我们每次写入报告记录时都会使用它,这将是非常频繁的。接下来,我们将四个变量 ❷ 初始化为 0。cnt 变量用于记录详细记录的数量,其他变量则是报告中三个数量的总和,我们将在批处理文件的后两部分使用它们。 介绍部分实际上由两部分组成:标题 ❸ 和列头 ❹。在这个特定报告中,它们总共占用了七个头记录,我们将通过七个`echo`命令将它们重定向到报告文件中。 我们通过将标题的开头重定向到由 rpt 变量定义的文件 ❸ 来启动报告;仅对这条命令使用了一个重定向符号,所以如果文件已存在,将会覆盖它。接着,我们将标题的其余部分附加到文件中,后面跟上日期和时间,并通过`echo`命令输出一个空行。 我们在第三个记录中填充日期和时间,分别使用恰如其名的日期和时间变量。这些伪环境变量在第二十一章中介绍,它们提供了一种简单的方法来记录报告生成的时间。注意,我从每个值中提取部分信息,以去除日期中的星期几和时间中的秒数。有时候数据过多也是一种负担。 最后三个`echo`命令用于输出列头 ❹。大部分数据是硬编码的,但注意我用另一个百分号转义了百分号符号,以防解释器认为它是一个非常尴尬的变量名的开始(参见第十四章)。 标题和列头中的一些数据似乎没有对齐,但这只是由于变量解析和转义的结果。将所有内容对齐的最佳方法是使用固定宽度字体将标题、列头和一行示例数据输入到文本文件中,并按所需对齐所有内容——也就是,输入清单 22-2 中的报告示例。当对对齐结果满意后,将生成的标题复制到 bat 文件中,并在每个标题前加上重定向和 echo 命令。然后添加任何转义字符,并将任何临时文本(如示例日期和时间)替换为将要取代它们的变量。 在这最后一步中,允许数据发生偏移;所有内容将在最终输出中重新对齐。例如,日期和时间记录❸似乎被向右偏移,但那只是因为带有子字符串语法和包围百分号的变量名比最终显示的时间要长。同样,额外的百分号❹会使第一列头部记录中的其余数据出现偏差。Life 和 Avoidable 似乎没有与接下来的两行对齐,但当解释器将两个百分号合并为一个时,所有内容将再次对齐。 ### 详细记录 这里有很多内容需要解释,但以下代码会为输入文件中的每一条记录写入一个格式化的详细记录,并跟踪记录数以及三个字段的累计总和: ``` ❶ for /F "usebackq tokens=1-4 delims=|" %%a in ("C:\Batch\HealthStats.dat") do ( ❷ set ctry=%%a eol ❸ for /F "tokens=1-2 delims=." %%m in ("%%b") do ( set dcml=%%n0 set perGDP= %%m.!dcml:~0,1! ) ❹ for /F "tokens=1-2 delims=." %%m in ("%%c") do ( set dcml=%%n0 set lifeExp= %%m.!dcml:~0,1! ) ❺ set deaths= %%d ❻ >> %rpt% echo !ctry:~0,15! !perGDP:~-5! !lifeExp:~-14! !deaths:~-15! ❼ set /A cnt += 1 set /A totPerGDP += !perGDP:.=! set /A totLifeExp += !lifeExp:.=! set /A totDeaths += deaths ) ``` 在第十九章中介绍的 for /F 命令❶是提取管道分隔数据文件(delims=|)中每条记录的四个标记(tokens=1-4)的明显解决方案。这个逻辑将数据文件中的国家分配给 for 变量%%a,这意味着 GDP 百分比是%%b,预期寿命是%%c,避免死亡人数是%%d。 #### 使用对齐数据的方式对齐列 我正在填充 ctry 变量❷,它包含字符串数据,填充一些空格,并最终加上不会出现在报告中的文本 eol。为了让列对齐,我将使这个字段左对齐,并最终截取它的前 15 个字节,但为了让这个方法奏效,字段的长度必须至少是 15 个字节——因此,使用了空格填充。 结尾的 eol 标签仅仅是为了向读者展示该字段有尾随空格。在写入记录之前我会去掉它,因此任何文本都可以,但它代表了*行尾*。(如果你对报告特别自豪,可以通过输入你的名字来标记你的作品。)如果没有某种标记,未来的开发者可能会删除尾随空格,尤其是在他们更熟悉忽略尾随空格的编程语言时,而几乎所有语言都不包括 Batch 语言。 警告 *在第二章中,我提到过,你可以在后导空格后放置一个和符号(&)或命令分隔符,但由于一个令人沮丧的限制,这在代码块中不起作用,或者至少不像在其他地方那样工作。当你在代码块中使用和符号而没有后续命令时,解释器会停止工作,这意味着你可以用&rem 替代 eol。在更奇怪的情况下,如果进行了转义,和符号可以在没有第二个命令的代码块中工作,所以你也可以用^&替代 eol。* 将国家变量与对应于最后一列的变量进行对比,后者详细描述了每 10 万人中可避免的死亡人数。与其使用后导空格,我在死亡人数 ❺ 后添加了 15 个*前导*空格。这个值是整数,并且与我们应该按第一个字符对齐的字符串数据项不同,我们应该按可避免死亡人数的最后一个字符或个位数对齐。 为了右对齐一个数字,我做的是与处理字符串时相反的操作。我*在前面加上*一些空格,以便后来能从字段的末尾提取所需的文本。为了保持报告所需的空格数量,我将从这个字段提取 15 个字节,所以如果这个字段的长度不足 15 个字节,生成的数据将会偏斜。 #### 处理浮点数据 十进制或浮点值代表中间的两列,因为我们将以相同的方式处理它们,所以我只关注其中一列。输入文件中的数据表示预期寿命 ❹ 以十进制形式,所有值都包含十分位,除了加拿大,它恰好是一个整数,但我们希望报告中的每个值都有小数位,并且我们希望这些数字的小数点对齐。 我正在从外层`for`命令的第三个标记%%c ❶ 中解析预期寿命,并将其作为输入字符串传递给一个内层`for`命令 ❹。通过点号分隔后,值被分为小数点前的整数部分和小数点后的十进制部分。我将后者赋值给 dcml 或十进制变量,同时附加 0。批处理语法可以非常深奥,容易忽视,但在四个字节%%n0 中,前三个是内层`for`循环的第二个标记,最后一个是硬编码的数字。 在内层`for`命令的代码块中的第二个也是最后一个命令里,我提取了十进制的第一个字节:!dcml:~0,1!。对于大多数国家,我们将 0 附加到十进制值后面,然后立即将其去掉。这似乎毫无意义,直到考虑到加拿大。由于加拿大的预期寿命为 82 且没有十进制值,所以附加到末尾的 0 成为唯一的十进制字节。最后,我将整个数字%%m、一个点和十进制值的第一个数字拼接在一起。为了避免遗忘,所有这些都必须跟随一些前导空格以进行右对齐。如果我们想格式化带有两位小数的数字,比如美元金额,我们本可以附加两个零并提取前两个字节。 (在这个示例中,%%n0 代表一个变量后跟硬编码的 0,但只改变一个字节会产生完全不同的结果:%~n0。此时,n 变成了隐藏参数%~0 的修饰符。因此,%~n0 解析为 bat 文件的无扩展名的名称。哦,批处理的奇妙之处。) 每个国家 GDP 中用于医疗保健的百分比的逻辑❸几乎与预期寿命的逻辑相同。由于列的对齐方式,唯一的区别是我们为每个值附加的前导空格数量。 #### 编写详细记录 这四个变量最终会在实际将格式化文本字符串写入报告文件的那一行中合并❻。这是类似于我们在本章前面看到的回显命令重定向,通过解析这四个变量并通过子字符串提取每个部分,每个部分之间用空格分隔。 为了左对齐 ctry 变量,我使用了偏移量 0 和长度 15,从而提取前 15 个字节并丢弃其他所有内容(包括行尾标记)。接下来的三个值,perGDP、lifeExp 和 deaths,是右对齐的数字,因此我使用负偏移量来获取最后的 5、14 和 15 个字节。 各种长度依赖于布局。格式化详细记录并确定合适布局的最佳方法是输入我为头部记录建议的相同类型的示例行。找出每个对齐字段的长度,进行实验,并预期可能需要一些调整。只需小心确保如果你打算提取*n*字节,那么字符串中至少有*n*字节。更直白地说,国家字段是 15 个字节,因此确保添加 15 个空格以确保完美对齐。 #### 使用计数器和总和 代码块的最后四行❼都使用第六章中的增量赋值运算符进行一些算术运算。第一个是简单的计数器 cnt,用来追踪条目的数量。最后三个,totPerGDP、totLifeExp 和 totDeaths,是报告中三个量的累计总和。我为这些变量命名时使用了 tot,代表*总计*,并在常见的变量名之前加上了这一前缀。 逻辑是通过每条记录中的死亡人数来递增死亡总数的变量。其他两个是小数,如你在第六章中学到的,浮点数运算需要一些技巧。文本替换语法会在将每个值加到总数之前去掉小数点——例如,!perGDP:.=!。这实际上是将总数乘以 10,因此我们在计算平均值并写出尾部记录时需要解决这个不一致性。 我在这个报告中没有做,但是你可能想在一定数量的详细记录后插入分页符。通常,你可能希望在底部显示页码,然后是几行空白,之后再复制表头,接着显示另一页的详细记录。要在每 25 行详细记录后进行分页检查,可以在循环结束时检查 cnt %% 25。如果等于 0,表示记录数是 25 的倍数,那么你就可以插入分页符。你还可以创建另一个计数器用于页码,并将其作为页面尾部信息的一部分进行写入,同时将表头逻辑移到一个可调用的程序中,这样你就可以多次调用它。 ### 结束记录 bat 文件的第三个也是最后一部分查找并格式化平均值,然后将其写入报告: ``` ❶ set /A avePerGDP = (totPerGDP * 10 / cnt + 5) / 10 set /A aveLifeExp = (totLifeExp * 10 / cnt + 5) / 10 set /A avgDeaths = (totDeaths * 10 / cnt + 5) / 10 ❷ set avePerGDP= %avePerGDP% set aveLifeExp= %aveLifeExp% set avgDeaths= %avgDeaths% ❸ >> %rpt% echo ------- ---- ---------- ----------- >> %rpt% echo Averages !avePerGDP:~-5,-1!.!avePerGDP:~-1!^ !aveLifeExp:~-13,-1!.!aveLifeExp:~-1! !avgDeaths:~-15! goto :eof ``` 为了计算平均值,我们可以简单地将总计除以详细记录的数量,但由于 Batch 会截断解决方案的小数部分,实际上所有的值都会被向下舍入。为了弥补这一点,每个 set /A 命令❶首先将值乘以 10 再除以 cnt。将 5 加到这个数值上可以修正舍入,使得除以 10 时可以得到正确的平均值。例如,77.4 的死亡人数应该向下舍入:77.4 + 0.5 = 77.9,在截断小数后变为 77。而 77.6 应该向上舍入:77.6 + 0.5 = 78.1,最终变为 78。因为我们不能直接加上小数 0.5,所以我们通过先乘以 10,加 5,再除以 10 来实现。 代码的下一个部分❷为每个平均值添加前导空格,为数据对齐的子字符串做准备。代码的最后一部分❸通过两个 echo 命令将尾部记录写入报告。第一个命令写入与表头记录相同的硬编码破折号。第二个命令将国家名称替换为硬编码的文本“Averages”,其余命令显示三个平均值,经过如此密集的子字符串处理,以至于我不得不在下一行继续命令。 我正在从 avgDeaths 变量中提取最后 15 个字节,但由于其他两个总计值实际上是其实际值的 10 倍,因此它们对应的平均值 avePerGDP 和 aveLifeExp 也增加了 10 倍。我们不能通过除以 10 来纠正这个问题,因为那样会丢失小数部分。然而,通过在写入数字时插入一个小数点,我们能够正确显示数字,实际上是通过除以 10 同时保留小数部分,这样两全其美。注意,!avePerGDP:~-5,-1!.!avePerGDP:~-1!解析为倒数第二个字节之前的四个字节,一个硬编码的小数点和最后一个字节。 其他数据集可能适合仅显示总计而非平均值,这意味着浮动点算术运算将减少或消失。即使在这个例子中使用了平均值,我们也能够创建一个相当令人印象深刻的报告,而不需要大量的代码。 ### 总结 在本章中,我介绍了使用 Batch 格式化的典型文本报告的三个部分。如果你期待一个能轻松自动对齐列的巧妙程序,我敢肯定我让你失望了,但只要稍微注意细节,你就能创建出高质量的报告。你学会了如何构建标题、头部、任意数量的详细记录,以及包含总数和平均值的尾部记录。在此过程中,我展示了如何对齐列并处理数据项对齐的技巧,还分享了处理浮动小数点数据的建议。这并不是一个重型工具,我相信没有人仅仅靠制作 Batch 报告谋生,但当需要一个简单的文本报告时,编译程序就显得不必要了。 下一章将转变话题,深入探讨一个对我来说非常重要的主题:递归。你将学习如何编写调用自身的 Batch 代码,并探索一些有趣的应用。 ## 第二十三章:23 递归 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 本章介绍我最喜欢的主题之一:递归,或者说调用自身的代码。我将从一个更详细的定义开始,但理解这个概念的唯一真正方法是通过例子,所以我们将逐步讲解多个批处理递归的实例。其中一个将是阶乘的计算,这是一个真正经典的例子,另一个将是十进制转换为十六进制。最后一个例子将是批处理的典型应用:在目录及其子目录中进行递归搜索。然后,你将了解在编写调用自身的代码之前需要注意的一个重要限制。 ### 定义递归 *递归*是一种技术,代码调用或自我调用。你可以在绝大多数编程语言中做到这一点。在面向对象语言中,一个方法内部的命令调用该方法。即使在像 COBOL 这样的过程语言中,程序内部的命令也会调用该程序。批处理语言也不例外。在递归批处理中,常规包含一个调用命令来调用该常规。较少情况下,一个批处理文件会包含一个调用命令来调用该批处理文件。 递归有一种简单的逻辑美感,可以用一个词来概括:*优雅*。在成为程序员之前,我接受过数学训练,在这两种学科中,“优雅”是最大的赞美,而“*实用*”充其量不过是间接的恭维,无论是在前者的证明中还是在后者的程序中都是如此。赞美代码的词语和短语有很多——结构良好、流畅、巧妙、聪明、深思熟虑——但“优雅”独树一帜,是程序员能听到的最佳形容词。但当有人称你的代码为“实用”时,隐含的意思是:“它会工作,但极其丑陋,甚至在逻辑上令人反感,我本可以做得更好。”在描述代码和超级模特的词汇的文氏图中,交集只有一个词:优雅。 如果你的第一反应是递归听起来像是一个无限循环的开始,那么你的谨慎是明智的。如果调用是无条件执行的,确实,结果将是一个无限循环(或者当调用堆栈溢出时,程序崩溃)。递归必须有某种条件逻辑,通常是一个 if 命令,它会根据递归情况或基准情况来执行代码。 *递归情况*会执行递归调用,而*基准情况*则不会。一个设计得当的递归调用会使你更接近基准情况。几次递归情况的执行通常会导致执行基准情况的调用,从而开始撤回递归调用的过程。理解这一点的最好方法是通过例子(接下来会讲解),逐步跟踪每一次递归调用,并记录每次调用中每个变量的状态,通常会用纸和笔记录。 递归与第九章中介绍的 while 和 do...while 命令有很大的不同。goto 命令是通过向后跳转到代码中某一部分重新执行已执行的代码,但并没有调用任何东西;没有控制权被返回。而递归技术则调用或调用它所属的代码。 递归是编程中等同于乌洛波洛斯的概念,乌洛波洛斯是一个传说中的蛇或龙在吃自己的尾巴(见图 23-1)。这个生物有着悠久的历史,源于中国、埃及和希腊的古代。它常常象征着“永恒的循环更新”、无穷、永恒,甚至炼金术。我一直认为它是代码调用自身的一个极好的隐喻,甚至在我知道它的名字之前就有这种感觉。 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/fig23-1.jpg) 图 23-1:代表乌洛波洛斯的图示 萨尔瓦多·达利在他的作品《乌洛波洛斯》中展现了他典型的非典型解读。虽然这肯定有些自负,但每当我完成一些递归逻辑的编码时,我总能感受到一种微小的亲近感,就像达利那样,想象他在与世界分享他的作品时所感受到的自豪。伟大的画家理应希望展示自己的作品,就像一位杰出的厨师一定期待食客品尝她的招牌菜一样。与画廊展览或餐厅开张不同,我期待的是与同行们共同进行的下一次代码审查。我稍微有点夸张(也不太愿意承认夸张的程度),但我确实为一段精妙的递归感到自豪,并且我希望你也能或将会如此。 ### 阶乘 递归在任何数学或编程书籍中的经典例子都是阶乘,我认为没有理由反驳这一传统。*n* 的阶乘,表示为 *n*!,是 *n* × (*n* – 1) × (*n* – 2) … 2 × 1,或者更通俗地说,就是整数和所有小于它的整数相乘的结果,直到 1。 > 注意 *当我问一位数学家他多大岁数时,他回答:“我的最后一个阶乘生日。”他快 24 岁了,且不指望自己能活到 120 岁或 5!。我曾经装饰过一个 30 岁生日蛋糕,上面写着 6! / 4!。阶乘既有趣又有用,但我跑题了。* 4 的阶乘是 4 和 3 的阶乘的乘积,而 3 的阶乘又是 3 和 2 的阶乘的乘积,2 的阶乘是 2 和 1 的阶乘的乘积,1 的阶乘就是 1。这个模式自然要求使用递归。一个接受数字作为输入并返回其阶乘的例程可以将这个数字乘以比它小 1 的数字的阶乘。而找到第二个阶乘的最好方法就是让这个例程调用它自己。当计算一个大于 1 的整数的阶乘时,我们会触发递归情况,而当计算 1 的阶乘时,我们就达到了基本情况,并优雅地返回数字 1。那就是递归!现在我们需要将它转化为代码。 :Factorial 函数接受一个数值输入参数,并将该数值的阶乘通过我们在第二个参数中传递的变量名返回。在讲解函数本身之前,下面的调用将 4 的阶乘填充到变量 factorial 中: ``` call :Factorial 4 factorial > con echo The Factorial of 4 is %factorial%. ``` 你可能期望一个更复杂的函数,但它其实非常简单。请注意调用命令是如何递归地调用 :Factorial 函数的: ``` :Factorial if %~1 equ 1 ( set %~2=1 ) else ( set /A nbrLessOne = %~1 - 1 call :Factorial !nbrLessOne! lessOneFact set /A %~2 = %~1 * !lessOneFact! ) goto :eof ``` 如果输入参数 %~1 等于 1,if 命令会确认基本情况已满足,并将第二个参数 %~2 设置为 1,因为 1 的阶乘是 1,这样我们就完成了。 如果整数大于 1,控制将转到 else 关键字下的代码块,在这里执行递归情况的逻辑。接着,我们找出比输入值小 1 的数字:nbrLessOne。为了计算 nbrLessOne 的阶乘,我们递归地调用当前的函数并将结果存入 lessOneFact 变量。最后,我们将函数的输入值与递归调用返回的阶乘相乘,将结果赋值给第二个参数 %~2,并返回给调用代码,完成整个过程。 这种从上到下的读取方式有助于理解流程,并且是一个很好的第一步,但它忽略了后续递归调用中的具体细节。为了真正理解发生了什么,让我们通过输入参数为 4 的示例执行,重新梳理逻辑。 因为 4 大于 1,我们立即跳到 else 代码块,找出前一个数字 3,并进行递归调用。让我们留下一个关键步骤,稍后再回到这里。 第二次执行时,输入参数为 3,因此我们再次递归调用,计算 2 的阶乘。在调用命令处留下第二个关键步骤。 第三次执行时,输入为 2,因此我们再次递归调用,这次计算 1 的阶乘。在此处留下第三个关键步骤。 最终,if 命令为真,基本情况已满足,我们将值 1 作为第二个参数 %~2 返回。 现在我们可以按逆序拾取这些关键步骤,回到最初的调用。在第三个关键步骤处,我们获取到 1 的阶乘,存储在 lessOneFact 变量中,并将其与该调用的输入参数 %~1(即 2)相乘。我们将结果 2 赋值给返回参数,并将其传回。 现在,我们回到了第二个关键步骤,在这里我们将调用的输入参数 3 乘以 lessOneFact,而 lessOneFact 保存着刚刚返回的 2 的值。函数将返回结果 6,即 3 的阶乘,传回到第一个关键步骤的地方。 逻辑上,原始输入参数 4 会乘以现在包含 6 的`lessOneFact`。我们将结果 24 返回给最初的调用。这个最后的细节很微妙也非常关键:我们不是将结果传回递归调用中的某一项,而是最终将结果传回最初的调用。这样,任务就完成了。 这个概念一开始可能有些让人困惑,反复阅读最后几行是完全没有问题的。令人好奇的是,变量似乎在同一时间拥有多个状态。`nbrLessOne`和`lessOneFact`变量各自包含三个不同的值,输入参数有四个值,而我们将输出参数赋值了四次。批处理通过*调用堆栈*来完成这一过程。在执行递归调用之前,它会将相关数据存储在调用堆栈上,并且可以为多个调用执行此操作。 解释器会在执行第一次递归调用之前,将所有活跃的变量放置在调用堆栈上。在那次调用中,变量可能会赋予新的值,并再次将这些新值放在调用堆栈的顶部,然后再进行下一次递归调用。每次调用结束时,控制会从调用中返回,解释器会从调用堆栈顶部恢复相应的值,并继续处理。 顺便提一下,也可以通过非递归的方法来计算阶乘,但这些方法没有创意,远不如递归有趣。 ### 十进制转十六进制 在使用递归批处理代码将十进制数字(基数 10)转换为十六进制数字(基数 16)之前,我们先来看看如何通过数学方法来做这件事。对于小于 256 的十进制数字,首先将数字除以 16,得到商和余数。这两个数字将成为十六进制数的两个数字,但有一个注意事项。每个数字的范围是从 0 到 15,但我们希望得到一个单一字符。数字 0 到 9 没问题,但如果值是两位数的十进制数,我们必须将其映射为十六进制数字。也就是说,10 映射为 A,11 映射为 B,依此类推,直到 15 映射为 F。 如果十进制数字介于 256 和 4,095 之间,它映射为一个三位的十六进制数。这需要两轮除法运算。第一次除法的余数就是最右边的十六进制数字,然后我们再将商除以 16。余数就是第二个最右边的十六进制数字,新的商则是最前面的十六进制数字。随着数字增大,类似的模式依旧适用;例如,一个六位的十六进制数字需要五次除法。 这正是适合递归的模式。以下的批处理程序将十进制数字转换为十六进制数字。与阶乘例子类似,这里有两个参数:第一个是十进制输入,第二个是包含十六进制输出的变量。以下是代码: ``` :GetHex set hexChars=0123456789ABCDEF set /A quotient = %~1 / 16 set /A remainder = %~1 %% 16 if %quotient% equ 0 ( set %~2=!hexChars:~%remainder%,1! ) else ( call :GetHex %quotient% recur set %~2=!recur!!hexChars:~%remainder%,1! ) goto :eof ``` 我将 16 个十六进制字符存储在 hexChars 中,以备后用。这个例程将十进制数字除以 16,得到商,而对 16 取模得到余数。如果商是 0,结果就是一个字符。这是基本情况。我们从 hexChars 中提取适当的字符,使用余数作为偏移量。注意,0 映射到 0,1 映射到 1,以此类推,直到 9 映射到 9。然后 10 映射到 A,11 映射到 B,最终,15 映射到 F。我们返回这个单一的数字作为第二个参数的值,以完成基本情况。 当商大于 0 时,发生递归情况。商变量需要进一步转换,因此我们递归调用 :GetHex,并将其十六进制值返回到 recur 变量中,返回的值可以是一个或多个字符。我们将返回的参数赋值为该值与余数映射到十六进制数字的连接值,这就是我们刚才看到的内容。 (顺便提一下,注意那两个连接的值。我们用感叹号解决 recur 变量,因为它在代码块中作为调用命令的一部分进行赋值。最右边的字节是通过以下文本解析的:!hexChars:~%remainder%,1!。这次我在第一次解析偏移量或余数时,使用感叹号来进行延迟展开,或者使用百分号来解决。) 为了真正理解这个逻辑,让我们一步一步地进行操作,将 700 转换为十六进制数。首先,进行数学计算:700 / 16 = 43,余数是 12。12 对应于十六进制数字 C,这将是最终结果的最右边的字节。接下来,43 / 16 = 2,余数是 11,这对应于最终结果中的下一个字节,从右往左是 B。商 2 是一个一位数,因此它代表它自己。结果是十六进制数 2BC。 以下的调用命令返回值 2BC,作为 hexVal 变量的值: ``` call :GetHex 700 hexVal ``` 一步步进行,由于输入参数是 700,商变量是 43,余数变量是 12。因为商不为 0,递归情况的逻辑执行。解释器将余数 12 放入堆栈中,并将 43 作为递归调用的第一个参数传入。 在这一轮中,商是 2,余数是 11。再次执行递归情况的逻辑,11 被放到堆栈中,我们将 2 作为另一个递归调用的参数传入。 在最终的一轮中,商是 0,余数是 2。由于商等于 0,基本情况的逻辑最终执行。十进制数字 2 映射到十六进制数字 2,我们将其作为输出参数传回。 现在让我们反向操作,回溯我们刚才做过的调用。解释器在 set 命令连接两个值之前,将余数恢复为 11。第一个值是刚刚返回的 2,第二个值是 B,它是从 11 映射过来的。因此,例程返回 2B 作为输出参数。 在初始遍历中,解释器从调用栈中恢复了余数变量 12。我们将两个值连接在一起,刚刚返回的 2B 和 C,这对应于 12 的十六进制值。最后,例程将 2BC 作为输出参数返回给原始调用命令。 每次调用时,递归逻辑都会确定另一个十六进制数字,最终返回一个多字节的十六进制值。乍一看,这几行代码似乎不算多,但仔细查看后,这段程序实际上相当复杂且有趣。 ### 递归目录搜索 最后的两个例子很好地展示了递归,允许我们逐步跟踪递归调用,但在我的最后一个例子中,我想要一些带有 Batch 基因的东西,是其他语言中难以做到的。我们将递归地搜索一个目录及其所有子目录,以生成报告,详细列出每个文件夹中的字节数和文件数。 如果这听起来很熟悉,你可能在第十八章中学习过带有/D(目录)和/R(递归)选项的 for 命令。这个命令轻松创建了所有子目录的简单列表,但它为你处理了递归调用,并且没有留下太多修改的空间。真正的递归提供了更大的灵活性和更强大的输出控制,这正是我们在这里要做的。 在编写递归例程之前,我们需要一个计划和一些分析。为了生成详细记录,我们将使用一个 dir 命令,针对一个目录,并将其作为 for /F 命令的输入。这个例程会将目录的总数写入报告,并递归地调用自己,传递每个子目录。然后,它会处理每个子目录,对任何子目录的子目录进行递归调用。 编写这样的代码唯一的方法是查看嵌入命令的输出,在这个例子中是一个 dir 命令。根据目录的内容,dir C:\Batch\*命令可能会产生以下输出: ``` Volume in drive C is OS Volume Serial Number is 2E6D-DBF0 Directory of C:\Batch 10/31/2002 10:05 AM <DIR> . 10/31/2002 10:05 AM <DIR> .. 05/01/2001 11:18 AM 197 FourBrits.txt 02/14/2001 03:37 PM 178 FourBrits.csv 02/06/2002 12:47 PM <DIR> OrphanedFolder 06/20/2000 05:52 PM 89,402 outFile.dat 10/27/2002 08:36 AM <DIR> Subfolder 07/02/2002 02:03 PM 2,828 test.bat 4 File(s) 92,605 bytes 5 Dir(s) 147,918,372,864 bytes free ``` 我们需要跳过七行,五个标题记录加上两条显示一个或两个句点的目录项。在剩余的记录中,如果第二个标记等于 File(s),我们找到了包含文件总数(标记 1)和这些文件的总字节数(标记 3)的条目。如果第四个标记等于<DIR>,我们找到了子目录的文件夹名称(标记 5)。我们还不知道子目录的详细信息,但递归调用会提供关于它的相同类型的信息。我们可以忽略其他记录,这些记录详细描述了每个文件和最后的尾部记录。 我们可以将数据写入控制台,但让我们使用第二十二章中的报告编写技巧。第一部分代码创建了标题数据,并首次调用递归的:GetFldrSz 例程,从*C:\Batch\*的内容开始获取文件夹信息: ``` set rpt=C:\Report\FolderSizes.txt > %rpt% echo ==== In Search of Lost Disk Space ==== >> %rpt% echo Total Bytes Files Folder >> %rpt% echo ----------- ----- ------ call :GetFldrSz C:\Batch goto :eof :GetFldrSz for /F "usebackq tokens=1-4* skip=7" %%a in (`dir "%~1\*"`) do ( if /i "%%d" equ "<DIR>" ( call :GetFldrSz "%~1\%%e" ) else if /i "%%b" equ "File(s)" ( set ttlFiles= %%a set ttlBytes= %%c >> %rpt% echo !ttlBytes:~-17! !ttlFiles:~-8! %~1 ) ) goto :eof ``` 集中在 :GetFldrSz 例程上,for /F 命令将 dir 命令作为输入,而该命令使用例程的唯一参数——一个目录,并在其后附加通配符作为参数。for /F 命令使用五个标记(tokens=1-5),并跳过不需要的标题记录(skip=7)。 如果第四个标记 %%d 等于 <DIR>,我们就找到了递归情况;递归调用将输入目录与第五个标记中的子文件夹名称拼接:%~1\%%e。否则,基本情况会查找第二个标记 %%b 是否匹配文本 File(s)。如果是,我们将文件夹中的文件总数和字节数存储到带有前导空格的变量中。使用那些新学到的格式化技巧,我们将记录写入报告,详细说明这三项信息。 如果目录结构不是很复杂,这段代码可能会递归生成以下报告: ``` ==== In Search of Lost Disk Space ==== Total Bytes Files Folder ----------- ----- ------ 24,533 12 C:\Batch\OrphanedFolder 2,419,998 4 C:\Batch\Subfolder\SubSub\Child 67,150 3 C:\Batch\Subfolder\Sub 242 3 C:\Batch\Subfolder 92,605 4 C:\Batch ``` 最后一行包含我们执行 dir 命令以了解预期输出时得到的信息,同时也显示了初始调用命令中的根目录参数。其他四行详细信息是递归调用的结果。 这个设计的美妙之处在于,无论子文件夹存在多少个,代码都会对每个子文件夹进行调用。如果没有子文件夹,代码也能正常运行。这个例程是一个框架,通过小的修改可以实现无数的辅助流程。或许只有文件数或字节数超过某个数量的目录才应当生成报告。转向文件,或许你想将最近修改过的文件标记出来,作为完全不同报告的一部分,或者将它们归档。也许你想删除旧的或过大的文件,或者删除符合某个掩码或具有某些属性的文件。可以做的事情不胜枚举。 然而,也有几个警告。之前的代码在根目录(如 *C:\*)上不起作用,因为我们跳过了 dir 命令生成的前两个详细记录(<DIR> 记录,目录后面跟着一个或两个点)。记得在第十三章中提到过,当参数仅是驱动器字母时,这个命令不会显示这两条记录。经过一些修改后,我们可以解决这个问题,但另一个警告更像是一个递归的陷阱,适用于递归的更一般情况,那就是栈溢出的可能性。 ### 递归栈溢出 关于递归的一个主要警告很重要,但完全可以避免。随着每次递归调用,解释器会将数据放到调用栈中,使你可以使用一个可能有多个值的变量,每个调用一个值。但内存是有限的,且解释器为调用栈分配的内存不多。当调用栈的内存占用达到其分配的 90% 时,解释器会终止并显示类似以下的消息: ``` ****** B A T C H R E C U R S I O N exceeds STACK limits ****** Recursion Count=507, Stack Usage=90 percent ****** B A T C H PROCESSING IS A B O R T E D ****** ``` 除去对 Batch 似乎随意使用大写和空格分隔字母的所有合理批评外,这在 507 次递归调用后失败了,但 506 次并不是极限。我使用递归已经有一段时间了,从未见过在 300 次或更少的递归调用中出现崩溃,但这因机器和情况而异。每种语言都有递归的限制,但大多数通常允许更多的层级。这是可以管理的,但在编写解决方案时,你必须考虑到这一限制。 如果代码是一个更大过程的一部分,且在某种环境中中止可能会导致重大经济损失,甚至可能在半夜被叫醒来处理后果,那么你应该将递归调用限制在调用栈远低于可能溢出的阈值的情况。但请不要让这吓到你,避免使用这么棒的技术。设计良好的递归可以轻松保持在这些安全范围内。 例如,将整数转换为十六进制数字对于小于九万亿的数字只需要 10 次或更少的递归调用。即使是遍历目录结构的递归代码也不容易造成调用栈溢出。某些目录可能包含数百个子目录,但前一节中代码的仔细阅读表明,同级文件夹永远不会同时出现在调用栈上。解释器不断地从调用栈中添加和移除项目,因此它的深度永远不会超过从根目录到最深文件夹的层级,通常不会超过十几层。你可以在任何语言中安全地编写这些解决方案,包括 Batch。 ### 总结 我希望你和我一样喜欢这一章。你已经从程序员的角度和解释器的角度学习了递归是如何工作的——也就是说,我展示了如何执行递归,并且还讨论了解释器如何在幕后使用调用栈。 我还展示了逐步调试代码的有用性,每次递归调用都有详细的示例。你必须小心避免递归栈溢出,但当明智地使用时,递归是一种优雅的工具。要留意需要递归解决的问提。对于任何需要迭代有限次重复步骤的过程,循环通常是第一个想到的解决方案,但要寻找递归案例和基本案例。如果你发现它们,你就能编写递归逻辑。(有关这个主题的更多内容,请参阅第二十三章。) 递归非常适合搜索目录。在下一章中,我还将讨论搜索,但焦点会更加狭窄。你将学习如何在更大的字符串、文件,甚至多个文件中搜索一个子字符串,而不是搜索你电脑上的每个目录。 ## 第二十四章:24 文本字符串搜索 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在前面的章节中,你已经看到了多种搜索方法,例如查找文件、目录,甚至是丢失的磁盘空间。在本章中,我将讨论多种文本字符串搜索方式以及这些搜索的应用场景。你将看到单个单词的搜索、从多个可能单词中选择一个的搜索、以及包含多个单词的文字字符串搜索。另一种搜索方式将查找一个单词列表,并且只有当每个单词都被找到时,搜索才算成功。我将演示如何对文件、多个文件以及其他字符串执行这些搜索。 为了执行这些搜索,我将比较并对比两种非常不同的技术。一种更加灵活,另一种执行速度更快,因此它们各自都有极高的实用价值。我还将讨论正则表达式,并展示如何利用它们构建一些非常强大的 Batch 搜索。 ### 在文件中搜索 为了演示如何在文件中搜索字符串,第一个要求是有一个文件可供搜索。在实际应用中,你可能会有一个包含成千上万条记录的日志文件,其中夹杂着许多客户端信息;搜索客户端名称可以提取出所有相关条目,从而生成一个更有针对性的报告。每日报告文件的结尾可能每个都有总计;在这些报告文件中搜索 Totals 文本可以提取出每个报告文件中的所有尾部记录。更好的是,你还可以搜索满足文件掩码的所有文件,也许是针对上个月或去年的文件。 在本次演示中,我将使用一个更小的(也希望更有趣的)输入文件,一个名为 *12Movies.txt* 的文件,里面包含了一份跨越三十年的电影名单,按照上映日期排列。仔细观察这些标题,看是否能发现任何共同点。以下是该文件的完整内容: ``` Here Come the Littles Little Shop of Horrors Big Trouble in Little China Big The Little Mermaid The Big Lebowski Stuart Little Big Momma's House My Big Fat Greek Wedding Little Miss Sunshine Big Hero 6 The Big Short ``` 其中一些是很棒的电影,有些则一般,还有一些我从未看过,但我们将在本章的多个示例中使用这个文件。 #### 一个简单的字符串 `findstr` 命令是 Batch 中查找一个或多个文件内文本字符串的主要工具。尽管命令名较为简短,但程序员通常称其为 *find string* 命令。你将很快看到这个命令的多功能性,但我将从一个没有选项的简单命令开始,该命令搜索输入文件中的 Little 一词。第一个参数是搜索字符串,第二个参数是要搜索的文件: ``` findstr Little C:\Batch\12Movies.txt ``` 该命令会将包含六个连续字母的每一条记录从文件中写入 stdout: ``` Here Come the Littles Little Shop of Horrors Big Trouble in Little China The Little Mermaid Stuart Little Little Miss Sunshine ``` 注意,尽管返回的第一个标题包含了搜索词后缀的字母 "s",但解释器仍然返回了该标题;这个命令并不是在搜索完整的单词。此外,这个特定的命令会将伪环境变量 errorlevel 设置为 0,表示它找到了至少一个匹配的搜索字符串。如果没有找到任何匹配项,errorlevel 的值会变成 1。 通常,编码人员会以三种方式处理此命令的输出。如果你仅仅想知道是否存在一个或多个记录,你只需要检查 errorlevel 并继续。其他情况下,你可以将写入标准输出的返回记录列表重定向到控制台或输出文件,以供后续使用或查看。最后一种用法是通过程序化方式处理每一个返回的记录,你可以通过将 findstr 命令作为输入传递给 for /F 命令来实现。在接下来的大部分示例中,我会描述写入标准输出的内容,但请理解,这些输出有许多不同的用途。 现在,让我们对之前的命令做一个微小的修改,将搜索字符串全部改为小写: ``` findstr little C:\Batch\12Movies.txt ``` 这个命令没有返回任何结果,因为 findstr 命令默认是区分大小写的,而且文件中每个单词的实例都以大写字母 L 开头。另一个有趣的结果是,由于命令没有返回任何内容,errorlevel 的值为 1。通常,区分大小写的搜索正是你想要的;但很多时候,正是你不想要的。 #### 自定义选项 幸运的是,这个命令有许多选项可以自定义每次搜索。我将在本章中讨论其中的许多选项,从一些改变 findstr 命令行为的简单但强大的选项开始。 就像 /i 选项启用 if 命令的不区分大小写功能一样,同样的选项也适用于 findstr 命令。(如第四章中所提到的,我使用小写字母表示此选项,但 /I 也有效。)注意该选项和奇怪的大写方式 tHE: ``` findstr /i tHE C:\Batch\12Movies.txt ``` 我并不是推荐这种大写规则,但我这样做是为了清楚地展示解释器返回的四个标题,包含这三个字母,并按此顺序出现,不论大小写,正如你在输出中看到的: ``` Here Come the Littles The Little Mermaid The Big Lebowski The Big Short ``` 另一个有用的选项是 /E。使用它时,命令只返回搜索字符串位于记录*末尾*的记录。考虑这个命令: ``` findstr /i /E little C:\Batch\12Movies.txt ``` 唯一返回的标题是*Stuart Little*。另外,请注意此命令使用了多个选项进行进一步的自定义;通过 /i 和 /E 选项,它进行不区分大小写的搜索,查找以搜索字符串结尾的记录。 类似地,/B 选项仅返回搜索字符串位于记录*开头*的记录。你还可以将此选项与 /i 以及 /N 选项组合使用,/N 选项会将*行号*添加到返回的记录前,行号与记录之间用冒号分隔: ``` findstr /i /B /N big C:\Batch\12Movies.txt ``` 这个 findstr 命令返回以下四个标题,所有标题都以大写字母开头,不区分大小写,并在前面加上相应的行号: ``` 3:Big Trouble in Little China 4:Big 8:Big Momma's House 11:Big Hero 6 ``` 另一个有用的选项是 /V,它会否定搜索逻辑。以下命令与之前的命令相同,除了包含了 /V 选项: ``` findstr /i /B /N /V big C:\Batch\12Movies.txt ``` 之前返回的四条记录现在已从输出中消失,取而代之的是不符合搜索标准的另外八条记录。行号仍然显示在每条记录的前面,因为使用了/N 选项,但/V 选项改变了逻辑,使得输出仅包含所有*不*以“big”开头的记录,大小写不敏感: ``` 1:Here Come the Littles 2:Little Shop of Horrors 5:The Little Mermaid 6:The Big Lebowski 7:Stuart Little 9:My Big Fat Greek Wedding 10:Little Miss Sunshine 12:The Big Short ``` 这些选项中的最后一个是/X,它只查找完全匹配搜索字符串的记录: ``` findstr /i /X big C:\Batch\12Movies.txt ``` 这个命令返回一个标题,即汤姆·汉克斯的电影*Big*。 /i、/B、/E 和/N 选项中的单字符代码代表它们的功能,但/V、/X 等选项的功能则不那么明显。在使用 findstr 命令时,计划充分利用帮助功能。 #### 多个词 两个词的搜索有多种变体(当搜索字符串包含嵌入的空格时)。我们可以搜索包含任意一个词、两个词都包含,或者这两个词按空格(或空格)分隔的字面字符串的所有记录。findstr 命令可以处理所有这些变体,尽管搜索两个词都存在的变体需要做一些额外的工作。我们可以将这些解决方案推展到搜索多个词的情况。 ##### 列表中的任何词 与单词搜索字符串不同,以下的 findstr 命令将两个词放在双引号中。如果你以前没见过这种情况,你可能期待它返回所有包含“the”后跟“big”的标题,但事实并非如此: ``` findstr /i "the big" C:\Batch\12Movies.txt ``` 实际上,双引号将一组以空格分隔的搜索字符串括起来。解释器会在文件中的每条记录中搜索该搜索字符串集合中的每个词,返回至少匹配一个的所有记录。此命令返回九个标题;该列表中的所有电影除三部外,包含了单词“the”或“big”,或者两者都有。 向搜索字符串集合中添加三个特定的词将返回所有 12 个标题: ``` findstr /i "the big art hop sun" C:\Batch\12Movies.txt ``` 搜索字符串集合中的另外三个词分别是*Stuart*、*Shop*和*Sunshine*,而每个词都出现在之前缺失的标题中。尽管如此,如果你还没有注意到,双词搜索字符串集“big little”能更高效地返回文件中的每条记录。 ##### 一个字面值字符串 通过简单修改,你可以将搜索命令从查找两个词之一,改为查找单一字符串:the,后跟一个空格和 big。/C 选项定义了一个字面值搜索字符串,当搜索字符串包含至少一个空格时,必须使用此选项。 你之前已经看到过对*12Movies.txt*的大小写不敏感的搜索。在这个例子中,我在现在被双引号括起来的字面搜索字符串前插入了/C:选项: ``` findstr /i /C:"the big" C:\Batch\12Movies.txt ``` 该 findstr 命令仅返回这两个标题: ``` The Big Lebowski The Big Short ``` 这两个标题恰好以该文本开头,但如果该字面字符串出现在标题后面,记录也会出现在输出中。 字面搜索字符串不必包含完整的单词。以下命令 ``` findstr /i /C:"y big fat greek wed" C:\Batch\12Movies.txt ``` 返回的标题是*My Big Fat Greek Wedding*。 ##### 列表中的所有单词 在*12Movies.txt*文件中,只有一个电影标题包含了两个特定的词,Big 和 Little。不幸的是,findstr 命令无法通过单次搜索同时找到包含这两个词的所有记录,但凭借一些巧妙的技巧,你可以将两个 findstr 命令结合起来完成任务: ``` findstr /i big C:\Batch\12Movies.txt | findstr /i little ``` 这通过管道字符将一个 findstr 命令的输出传递给另一个 findstr 命令(这是在第十二章中介绍的管道技术的另一个应用)。第一个 findstr 命令使用*12Movies.txt*文件作为输入,执行不区分大小写的“big”字搜索。正如你现在已经看到的,这个命令本身会将七个标题写入标准输出。 但这并不是一个简单的命令。解释器将输出(那七条记录)写入一个无名的临时文件,并将其通过管道传递到第二个 findstr 命令,该命令执行不区分大小写的“小”字搜索。请注意,在第二个命令中我没有定义输入文件。它不需要,因为它的输入是第一个 findstr 命令的输出。如下所示的输出表明,在包含“big”这个词的七个标题中,只有一个也包含了“little”这个词: ``` Big Trouble in Little China ``` 最终结果是一个只返回包含两个字符串的记录的搜索。 中间文件丢失了,但如果你希望保留事件的审计轨迹,可以将输出重定向到一个文件中。然后你可以将该文件的类型命令通过管道传递到第二个 findstr 命令: ``` findstr /i big C:\Batch\12Movies.txt > C:\Batch\BigMovies.txt type C:\Batch\BigMovies.txt | findstr /i little ``` 这两个命令找到了相同的标题,但现在*BigMovies.txt*包含了第一次搜索字符串找到的七条记录。 你可以使用这种技术与任意数量的搜索字符串。以下命令再次找到唯一的标题,因为它包含了所有四个搜索字符串,即使第三个词只是标题中一个单词的一部分: ``` findstr /i big C:\Batch\12Movies.txt | findstr /i little ^ | findstr /i chi ^ | findstr /i trouble ``` 这种技术最难的部分是使其可读。在这个例子中,我将除了第一个之外的所有搜索字符串通过在多行上继续命令并使用尾部的插入符号来排列。 ### 搜索多个文件 到目前为止,我已经使用单一的输入文件执行了 findstr 命令,但你可以通过一次调用搜索多个文件。该命令接受多个文件作为额外的参数,并且也支持文件掩码。以下命令将在我们一直使用的文件中以及满足两个文件掩码之一的任何文件中查找不区分大小写的 miss 文本: ``` findstr /i miss C:\Batch\12Movies.txt C:\Flicks\*Movies.txt C:\Movies\* ``` 这个例子引发了一个关于输出的问题,因为之前的调用只是将每个找到的记录写入标准输出。当搜索单一文件时,这种方法很好用,但当搜索多个文件时,这样的输出会让你无法知道每条输出记录的来源文件。解释器聪明地检测到这种差异,并在找到的记录中输出路径和文件名: ``` C:\Batch\12Movies.txt:Little Miss Sunshine C:\Movies\Some Other File.dat: Will findstr miss this record? C:\Movies\Some Other File.dat: Sorry, that was misserable. Sorry again. ``` 命令如预期一样在 *12Movies.txt* 文件中找到了 *Little Miss Sunshine*,它还在两个符合尾部文件掩码的文件的记录中找到了该文本,从而得到了输出的最后两行。格式化效果不尽如人意。尽管有冒号分隔符,但很难看清文件名的结尾和记录的开始。当将输出写入控制台时,/A 选项会以你选择的颜色方案突出显示路径和文件名,但当你将输出重定向到文件或管道到另一个命令时,这显然不会有任何作用。 冒号作为分隔符是一个不太理想的选择,因为它通常是驱动器字母后路径的一部分,就像在这个实例中一样。如果你想解析这些数据,可以将 findstr 命令的输出作为输入传递给 for /F 命令,并以冒号为分隔符。但鉴于此输出,路径和文件名会跨越前两个标记,实际记录位于第三个标记。一个不能出现在路径或文件名中的分隔符,例如管道符,会是一个更好的选择,但你仍然可以通过稍作额外工作来解析这些数据。 /S 选项将搜索范围扩展到包括子目录。我经常与通配符一起使用它来搜索目录树中的所有文件或目录树中某种扩展名的所有文件,但在下面的示例中,我使用了一个显式的文件名: ``` findstr /i /S /N miss C:\Batch\12Movies.txt ``` 解释器在 *C:\Batch\* 及其所有子文件夹中搜索名为 *12Movies.txt* 的文件。然后,它会在每个找到的文件中搜索包含搜索字符串的记录。 此外,请注意,我重新引入了之前提到的 /N 选项。现在输出包含路径和文件名、行号(两侧用冒号分隔),以及包含搜索字符串的完整记录: ``` C:\Batch\12Movies.txt:10:Little Miss Sunshine C:\Batch\Subfolder\12Movies.txt:2:The misspelling of miserable was painful. C:\Batch\Subfolder\12Movies.txt:3:It should be a missdemeanor. ``` 两个目录中的两个文件有相同的名称,但该命令的结果表明它们的内容截然不同。 警告 *当 findstr 命令未找到符合文件掩码的文件时,它会向 stderr 输出错误信息,说明无法打开特定掩码。如果你已将 stdout 和 stderr 都重定向到跟踪文件中,命令会将错误信息与期望的输出混合。如果掩码可能无效,使用 2> nul 语法抑制 stderr 预期了 batveat 的可能性,并清理了输出:* ``` findstr /i miss C:\Batch\12Movies.txt C:\NotADir\* 2> nul ``` > *解释器返回它在有效文件中找到的所有记录,但它会将关于不存在目录的错误信息发送到 nul 文件中。此技巧甚至适用于已将 stderr 重定向的例程或 bat 文件中。* ### 辅助搜索文件 当你的搜索变得更加复杂时,可以通过两个辅助文件更容易地管理它们,一个包含搜索字符串列表,另一个包含要搜索的文件列表。 #### 搜索字符串文件 我之前提到过,你可以通过将多个以空格分隔的搜索字符串括在双引号中来搜索列表中的任何字符串。这对于少数几个字符串非常有效,但当列表足够长,导致命令变得杂乱时,你可以使用 findstr 命令并通过一个包含搜索字符串列表的文件来执行。你可以通过在/G 选项后输入文件名来定义此文件,文件名与选项之间用冒号分隔。以下示例执行区分大小写的搜索,查找“小”和它的四个同义词,你可以很容易地添加更多: ``` > SearchStr.temp echo Little >> SearchStr.temp echo Small >> SearchStr.temp echo Short >> SearchStr.temp echo Minuscule >> SearchStr.temp echo Tiny findstr /G:SearchStr.temp C:\Batch\12Movies.txt del SearchStr.temp ``` 我正在构建一个包含硬编码字符串的临时文件,但它们可以很容易地是变量,甚至可以来自用户输入,而且因为这是一个临时文件,我在完成后会将其删除。输出结果包含所有至少包含其中一个这五个字符串的记录,在这个例子中包括六个包含“小”的标题,再加上*《大空头》*,而其他三个搜索字符串没有找到任何内容。 这种技术在搜索标准每次执行时都可能变化时特别有用。你可以在代码中动态生成一个文件,甚至手动更新它,然后可以运行相同的代码并得到不同的结果。 #### 待搜索的文件列表 更进一步,我将使用刚刚创建的文件,里面包含搜索字符串的列表,并与另一个包含待搜索文件列表的文件一起使用。这个*文件列表(FOF)*是通过 findstr 命令和/F 选项来定义的。类似于/G 选项,文件在选项之后,使用冒号分隔: ``` findstr /i /G:C:\Batch\SearchStrings.txt /F:C:\Batch\SearchFiles.txt ``` 奇怪的是,只有在使用/G 选项时,才能使用/F 选项。 如果要搜索的文件列表只有在执行时才知道,你可以在运行时动态构建这个 FOF 文件。当你无法轻松定义一个文件掩码时,这种技术也非常有用。例如,如果你计划搜索多个文件集(可能是生产文件集或测试文件集,其中部分文件名带有日期戳),这些选项是理想的选择。你还可以使用用户输入的数据来创建搜索字符串文件,可能是一个客户名称的列表。然而,如果待搜索的文件列表比较固定,你也可以使用一个静态文件。 ### 搜索字符串 大多数编译语言都包含一种方法,返回一个布尔值,指示一个字符串是否在另一个字符串中部分或完全包含,因为这种需求在各种不同的情况下经常出现。在批处理文件中,你可以搜索包含路径的变量,以查找特定的节点或服务器名称,或者检查路径变量,看看它是否包含某个特定的目录。你甚至可以使用这种技术来验证用户输入,确认响应中至少包含列表中的一个单词。 查找一个字符串是否包含另一个字符串有两种非常不同的方法。Batch 的开发者设计了 findstr 命令用于搜索文件,但第一种方法将其调整为搜索字符串。第二种方法基于第五章中的文本替换语法,每种方法都有其显著的优势。 **findstr 方法** 寻找爱情可能很困难,但使用 findstr 命令,寻找“love”字符串只需要几行代码。在这个练习中,我将 aString 变量设置为两个相似的文本字符串之一,这些字符串是给计划前往亚得里亚海两个邻国的冬季旅行者的建议: ``` set aString=Bring mittens and a sweater to Croatia. set aString=Bring gloves and a pullover to Slovenia. ``` 我将通过接下来的几个代码示例进行两次演示,变量分别设置为这些字符串,以展示完全不同的行为。 你能在这两个字符串中找到“love”吗?findstr 命令可以: ``` echo "%aString%" | findstr love if %errorlevel% equ 0 ( set bLove=true==true ) else ( set bLove=false==x ) ``` 要在 aString 中搜索“love”文本,我使用了之前介绍的管道技术,唯一的区别是,我不是通过 type 命令将一个多记录的文件管道传输到 findstr 命令,而是通过 echo 命令将一个变量传输。实际上,我将变量的内容当作一个单记录的输入文件传递给 findstr 命令。最终的结果是,命令在字符串中搜索文本。 请记住,当 findstr 命令找到搜索字符串时,会将 errorlevel 设置为 0;否则,值为 1。如果是 0,代码将 bLove 布尔值设置为 true;如果不是,结果为 false。 首先,设想这个逻辑:aString 设置为克罗地亚的字符串。该字符串中没有“love”文本,所以命令返回 1,我们将布尔值设置为 false。现在,使用斯洛文尼亚的字符串进行相同的操作。该字符串中的“love”文本嵌入在三个不同的单词中——Bring g**love**s 和 a pul**love**r 到 S**love**nia。一个或多个匹配项将 errorlevel 设置为 0,因此我们将布尔值设置为 true。 还请注意,我们如何轻松地将其转换为不区分大小写的搜索: ``` echo "%aString%" | findstr /i love ``` 在搜索文件时所具有的灵活性,在搜索字符串时仍然适用。可以针对字符串的开头或结尾、否定逻辑等进行操作,这些选项在这种情况下都能完美工作。 **文本替换方法** 文本替换方法背后的思想与其执行方式一样直接。该技术将已解析的变量与去除搜索字符串后的变量进行比较。如果它们不同,则找到了搜索字符串;如果它们相同,则没有找到文本。以下代码使用此方法来确定一个字符串是否包含另一个字符串,在这个例子中是“love”,并将前一个示例中的布尔值设置为 true 或 false: ``` if "%aString%" neq "%aString:love=%" ( set bLove=true==true ) else ( set bLove=false==x ) ``` 不等式的左侧现在已经很基础了,已经是一个用双引号括起来的解析过的变量。if 命令将其与 "%aString:love=%" 进行比较,这是同样解析过的变量,但其中所有的 love 字符串都被替换为空值;注意,在等号和终止的百分号之间没有任何内容。结果是,如果 aString 包含至少一个 love 字符串的实例,这两个值就会不同,我们将 bLove 布尔值设置为 true;如果搜索字符串不在我们要搜索的字符串内,那么这两个值是相同的,我们将 bLove 设置为 false。 让我们用 aString 的两种可能值来执行这段代码。假设它包含克罗地亚文本,那么这两个值是相同的,因为没有找到 love 字符串,我们将 bLove 设置为 false。然而,文本替换语法通过删除 love 字符串的三个实例来改变斯洛文尼亚文本,结果是这样的混乱: "Bring gs and a pulr to Snia." 显然,这不等于原始文本,因此我们将 bLove 设置为 true。 延迟扩展使得这一技术更具通用性,允许你使用变量来表示搜索字符串和待搜索的字符串: ``` if "%stringToSearch%" neq "!stringToSearch:%searchString%=!" ( set bLove=true==true ) else ( set bLove=false==x ) ``` 现在,搜索字符串和待搜索字符串是变量,这使你可以在执行搜索之前,在批处理代码中确定这两个值。 在搜索字符串时,这两种方法在批处理的应用中各有其明确的作用。findstr 方法的最大优势是其灵活性,主要体现在它能够执行区分大小写的搜索,并且你可以使用前面讨论过的选项来轻松定制任何搜索。相比之下,文本替换方法本质上是大小写不敏感的,因为你无法轻易改变解释器忽略被更改文本的大小写这一事实。 文本替换方法也有它自身的优势。首先,我认为它稍微简单一些。两种方法都不复杂,但即使是带有延迟扩展的 if 命令也比通过 echo 管道传递给 findstr 更加直接。不过,它最大的优势是性能。 当你调用 findstr 命令时,实际上是在调用一个程序,*findstr.exe*,而任何程序调用都会比简单的两个变量比较涉及更多的开销。它们都会在瞬间完成,但文本替换方法发生的时间要小得多。你可能在进行少量搜索时无法察觉到这一点,但我对两种方法进行了广泛的测试,发现文本替换方法比 findstr 方法快了超过 200 倍。测试性能时需要考虑许多变量,我的测试结果也远非最终结论,但可以肯定地说,文本替换方法的一个主要优势就是速度更快。 最终分析来看,如果你的代码需要重复执行搜索,例如在一个可能包含数百甚至数千次调用的循环中,替换文本方法是更好的选择。然而,如果效率不是一个大问题,或者你需要更复杂的搜索,即便是区分大小写的搜索,findstr 方法则是更好的选择。 ### 正则表达式 在本书中,我已经多次暗示,批处理语法可能是深奥且反直觉的,即使是对于那些已经编写多年代码的人来说。但在某个地方,某个持不同意见的人可能会说,“其实没那么糟”或者“我们中谁没有记住 findstr 命令的所有选项?”对这个人,我只有两个词:“*正则表达式*”或“*regex*”。 正则表达式不仅限于批处理(Batch)。许多编程语言和编辑器都将它们作为一个强大的搜索工具。通过正则表达式,你可以搜索数字值、非数字值以及非常复杂的字符模式和范围。findstr 命令的 /B 和 /E 选项允许你搜索记录的开头或结尾的文本,但正则表达式让你在一个命令中同时完成这两项工作——即,搜索一个字符串位于记录开头,另一个字符串位于记录结尾。让我们通过一些例子来看看正则表达式的实际应用。 #### 搜索任何数字 以下使用正则表达式选项的 findstr 命令(通过 /R 标记)搜索 *12Movies.txt* 文件,查找标题中至少有一个数字的所有电影: ``` findstr /R "[0-9]" C:\Batch\12Movies.txt ``` 正则表达式 [0-9] 表示所有从 0 到 9 的字符,包括两端。根据我们在本章中使用的输入文件,命令返回一个标题: ``` Big Hero 6 ``` 如果我们处理的是一个更完整的电影列表,返回的标题可能包括 *2001: 太空漫游*、*十二怒汉*、*第 12 人* 和 *海洋的十一*,但不会包括重拍版的 *海洋的十一*。 正则表达式在搜索字符串而非文件时也非常有效。考虑以下示例,该示例使用否定逻辑(/V)与正则表达式(/R)进行搜索: ``` :TryAgain > con set /P reply=Enter a movie title that does NOT include a number: echo %reply% | findstr /R /V "[0-9]" if errorlevel 1 ( > con echo Invalid response. Please try again. goto :TryAgain ) ``` 该代码提示用户输入一个标题中没有数字的电影标题。如果他们输入一个典型的续集电影标题或像 *28 天后* 这样的标题,程序会提示他们输入不同的标题,直到他们最终按照指示操作。 #### 使用复杂条件进行搜索 对于没有正则表达式的话,搜索会变得更加困难,可以考虑以下内容: ``` findstr /R "^The...........*Man$" C:\Batch\12Movies.txt ``` 在这个上下文中,前导插入符号(caret)不是转义字符,而是双引号包围的字符串的一部分。插入符号是一个正则表达式指示符,表示它后面的文本被固定在字符串的开头,而结尾的美元符号则表示它前面的文本被固定在字符串的结尾。11 个点是通配符,星号表示它前面的通配符可以是任意长度,包括零。 将这些内容整合在一个非正则表达式程序员能理解的语言中,搜索的目的是查找所有以“The”开头并以“Man”结尾的记录,区分大小写,且两者之间至少有 10 个字符(包括空格)。 如果你执行这个命令,使用一个包含更完整电影列表的文件,它可能会返回标题为*隐形人*和*神奇蜘蛛侠*的记录。然而,*神奇蜘蛛侠 2*、*音乐之人*和前述的*第 12 个人*却不会出现在结果中。(第一个标题附加了数字,而其他两个标题太短。) 如果不使用正则表达式,你可以将一个 findstr 命令(使用/B 选项)通过管道传递给另一个 findstr 命令(使用/E 选项),但你仍然需要过滤掉所有标题中两个词之间少于 10 个字符的记录;这虽然可行,但非常混乱。许多其他复杂到几乎不可能的搜索,使用正则表达式会变得更加容易。 正则表达式的主题足以写一本书。我展示了几个在 Batch 中有用的示例,但下次当你的搜索变得过于复杂时,查阅书籍或在网上查找你问题的正则表达式语法,尝试使用 findstr 命令和/R 选项。即使是经验丰富的程序员,有时也会因正则表达式的复杂性而避开它,但在这些相对简单的示例中所展示的强大功能,实际上为其他应用打开了一扇窗。 为了让事情更加有趣,Batch 只支持常见正则表达式功能的子集,因此一些在其他地方有效的正则表达式在 Batch 中不起作用。显而易见,进行严格测试是必须的,既要考虑正向案例,也要考虑负向案例。 ### 查找文件的记录数 收集或许是一种恶习,但在 Batch 中,什么都不会真正被丢弃。虽然 xcopy 和 robocopy 命令早已取代了 copy 命令处理所有与复制相关的功能,但 copy 命令仍然有用,可以用来创建一个空文件。同样,find 命令与 findstr 命令相比几乎没有用处,因此我将跳过关于如何使用它进行搜索的讨论。然而,它确实有一个有用的功能:获取文件的记录数。请看以下内容: ``` find "" /V /C C:\Batch\12Movies.txt ``` 这个命令在双引号之间执行对空字符串的搜索,以*12Movies.txt*作为输入文件。/V 选项与 findstr 命令一样,否定了搜索逻辑。它返回所有不包含空字符串的记录,这实际上返回了文件中的每一条记录。/C 选项提供了返回的记录的*数量*,即文件中所有记录的数量,因为每一条记录都会返回,但它写入标准输出的内容比我们需要的要详细一些: ``` ---------- C:\BATCH\12MOVIES.TXT: 12 ``` 方便且奇怪的是,当我们通过 type 命令将输入文件传递给 find 命令时,它的行为有所不同: ``` type C:\Batch\12Movies.txt | find "" /V /C ``` 这个命令简洁地将记录数(本例中为 12)写入标准输出。 为了将该值捕获到变量中,我们可以执行两个通过管道传递的命令作为 `for /F` 命令的输入,但在 `find` 命令之前,我们必须转义管道符: ``` for /F usebackq %%c in (`type C:\Batch\12Movies.txt ^| find "" /V /C`) do ( set recCount=%%c ) ``` (注意使用 `usebackq` 关键字和反引号将 `for /F` 命令的输入组件包围,这种组合清晰地展示了命令输入。) 这看起来可能是获取记录数的漫长过程。是的,通过使用转义,我们将一个命令(`type`)的输出通过管道传递到第二个命令(`find`),并将其输出作为输入传递给第三个命令(`for`),这个命令包含一个包含第四个命令(`set`)的代码块,第四个命令实际上设置了变量。呼。尽管如此,它仍然有效,而且以其独特的方式非常优雅。 ### 总结 在这一章中,我讨论了批处理文本搜索的多个方面。你学会了如何执行多种类型的字符串搜索,如何搜索文件中的每一条记录,以及如何在一个字符串中查找另一个字符串。 我还比较和对比了两种不同的文本搜索技术。`findstr` 方法功能强大且灵活,而文本替换方法简单、高效且速度极快。你现在知道在何时使用每种方法。我介绍了正则表达式,并演示了它们在复杂搜索中的巨大实用性。你甚至学会了如何确定文件中有多少条记录。 在下一章中,我将介绍另一个迷人且实用的话题——生成代码的代码,特别是创建其他 bat 文件的 bat 文件。 ## 第二十五章:25 批处理文件构建批处理文件 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在我们的后工业时代,制造商可以相对轻松地制造一个烤面包机,但没有人能制造出一个能够制造另一个烤面包机的烤面包机,我敢说,任何制造商都不可能做到这一点。至少部分上,机器人可以制造机器人,但汽车不能制造汽车,智能手机也不够聪明,无法制造任何具有智能的手机。然而,在软件领域,代码创造代码,程序传播程序,而批处理文件则创造批处理文件。 本章不是讨论飞行哺乳动物的繁殖,而是讨论一个批处理文件如何创建另一个批处理文件的技术。几种编程语言提供了自动化代码生成器,但这些通常只创建一个模板或一个良好的起点,供你进行有趣的编码工作。而这里我指的是,一个批处理文件可以创建另一个完全功能且准备好执行的批处理文件。 如果这听起来像是一个魔术把戏,那可不是。你可能需要从一个批处理过程中获取信息,才能编写后续过程的代码。与其为第二个过程编写批处理文件,不如让第一个批处理文件足够智能,能够写出第二个文件,并且包含所有需要的信息。这还可以让一个批处理文件根据输入的大小,动态地拆分大型过程,创建任意数量的过程。 我将首先直接展示一个完整的逐步示例,讲解一个批处理文件如何构建另一个批处理文件,从父级批处理文件到创建的子级批处理文件,最终到子级文件的输出。我还将详细说明如何用静态数据、已解析变量和未解析变量填充一个动态创建的批处理文件。我会将这一切与一个现实世界的应用结合起来,展示多代批处理文件,并且最重要的是,讨论这种技术的有用应用。 ### 动态创建批处理文件 解释批处理文件构建批处理文件如何工作的最佳方式是通过一个简单但(希望)有趣的示例,因此我将从 *Mother.bat* 的演示开始。当它执行时,它会在控制台显示它的名字,这并不新鲜,然后继续做一件曾经听起来像是炼金术的事情:构建一个恰如其名的 *Daughter.bat* 文件,这是一个将在执行时把自己的名字以及赋予它生命的批处理文件的名字显示到控制台上的批处理文件。 清单 25-1 不是一个代码片段;它展示了完整的 *Mother.bat* 文件内容,这是父级批处理文件。 ``` @setlocal EnableExtensions EnableDelayedExpansion @echo off ❶ > con echo ***** This bat file is "%~NX0". > con echo. set batDtr=C:\Batch\Daughter.bat ❷ > %batDtr% echo @setlocal EnableExtensions EnableDelayedExpansion >> %batDtr% echo @echo off ❸ >> %batDtr% echo ^> con echo ***** This bat file is "%%~NX0". ❹ >> %batDtr% echo ^> con echo ***** It was created by "%~NX0". ❺ >> %batDtr% echo ^> con echo. ❻ >> %batDtr% echo pause ❼ pause ``` 清单 25-1: 父级批处理文件,Mother.bat bat 文件以 setlocal 命令开始(就像我写的所有 bat 文件一样)。它将 echo 设置为关闭,从而保持控制台显示干净。代码的第一部分以两个 echo 命令结束,分别将 bat 文件的名称写入控制台,并随后加上一个空行 ❶。由于我们没有进入常规过程,%~0 解析为正在执行的 bat 文件的路径和名称的隐藏参数(第十一章)。通过使用两个修饰符,%~NX0 只提取隐藏参数中的文件名和扩展名(第十七章)。 bat 文件的最后一部分才是最有趣的地方。六个 echo 命令分别写一行来构建子 bat 文件。第一个 echo 命令 ❷ 使用一个重定向字符创建子 bat 文件,并将 setlocal 命令写入其中。(我提到过,我写的每个 bat 文件都以这个命令开始,这对于通过其他 bat 文件间接写入的 bat 文件同样适用。) 这里没有执行 setlocal 命令;这段代码将其视为简单文本,将其重定向或写入 *Daughter.bat*。不要把它当作 setlocal 命令,而应当视作 *proto* setlocal 命令。echo 命令后跟三个空格;第一个空格将命令本身与它写入的文本分开,接下来的两个空格是它写入文件的文本的一部分。从功能上讲,这两个额外的空格不是必需的,但出于美学考虑,我总是进行缩进,即使是在动态创建的 bat 文件中也是如此。 执行重定向的多个命令似乎每个都有两个 echo 命令。从这些命令中的最后一个 ❺ 开始,第一个 echo 将一行写入子 bat 文件,这一行完全由 `> con echo.` 文本组成。插入符号转义字符在这里至关重要。如果没有它,解释器会把它后面的大于号当作第二个重定向操作符,这显然是不好的。有了插入符号,大于号只是作为另一个字符写入子 bat 文件。 之前的两个命令看起来非常相似。它们都是将记录写入 *Daughter.bat*;这两条记录都是 echo 命令,将某些内容写入控制台,并且都使用插入符号作为其重定向字符的转义字符。然而,它们之间有一个关键的区别。 每个命令在星号后写出不同的文本,但这无关紧要。第一个命令❸在文件名解析中看似有第二个百分号符号。别被这微妙之处欺骗;它是本章的关键。第二个命令❹中的文本实际上解析为父 bat 文件的名称。但是在第一个命令❸中,第一个百分号符号是第二个百分号符号的转义字符,这意味着解释器将这两个字符解析为一个百分号符号,并将其作为普通文本字符写入文件。因此,后面的波浪号和其他字符在这个上下文中对 Batch 没有特殊含义,解释器也将它们按原样作为文本写入子 bat 文件。 这话不必说得太尖锐,但以下是解释解释器如何在写入子 bat 文件时处理这些相似文本字符串的方式: + “%%~NX0”文本❸变成了“%~NX0”。 + “%~NX0”文本❹变成了“Mother.bat”。 父 bat 文件写入最后一行,这将成为子 bat 文件中的暂停命令❻。最后,一个真正的暂停命令❼将*Mother.bat*补充完整。 现在我们可以执行*Mother.bat*,这是 Listing 25-1 中展示的 bat 文件构建 bat 文件。它的第一部分代码中的“%~NX0”❶解析为“Mother.bat”,然后在控制台上显示以下内容: ``` ***** This bat files is "Mother.bat". Press any key to continue ... ``` *Mother.bat* 结尾的那个暂停命令保持控制台打开,以便我们可以阅读这个信息。 更有趣的是,*Mother.bat* 创建了 *Daughter.bat*,一个完全功能的 bat 文件。这个观点不能过分强调;Listing 25-2 中展示的 bat 文件*并非*由人直接创建。 ``` @setlocal EnableExtensions EnableDelayedExpansion @echo off > con echo ***** This bat file is "%~NX0". > con echo ***** It was created by "Mother.bat". > con echo. pause ``` Listing 25-2: 子 bat 文件,Daughter.bat 子 bat 文件有六条记录,其中中间的两条最为有趣。正如预期的那样,解释器将*Mother.bat* (Listing 25-1)中的“%%~NX0”解析为*Daughter.bat*中的第一个 echo 命令中的“%~NX0”。当*Daughter.bat*本身(她?)执行时,这一点很重要。同样,子 bat 文件中的下一个 echo 命令包含了“Mother.bat”文本,这是解释器从父 bat 文件中的“%~NX0”解析出来的。 这最终导致执行子 bat 文件 *Daughter.bat*。像运行任何其他 bat 文件一样运行它,它会将以下内容写入控制台: ``` ***** This bat file is "Daughter.bat". ***** It was created by "Mother.bat". Press any key to continue ... ``` 当子 bat 文件执行时,解释器将第一个重定向的 echo 命令中的“%~NX0”解析为“Daughter.bat”。下一行输出来自包含已解析的“Mother.bat”的 echo 命令。 总结一下,我们来看看从父文件到子文件再到子文件输出的流程。当父 bat 文件执行时,“%%~NX0”在子 bat 文件中变成了“%~NX0”——再次,由于转义,两个百分号符号会解析为一个。然后当子 bat 文件执行时,“%~NX0”在最终输出中解析为“Daughter.bat”。 与此对比,父级 bat 文件中的 `"%~NX0"` 会变成子级 bat 文件中的 "Mother .bat"。从子级的角度来看,那时它是硬编码文本,并且当子级 bat 文件执行时,它会将 "Mother.bat" 写入控制台。 这个例子展示了两个重要的观点。首先,完全可能通过 bat 文件创建另一个功能齐全的 bat 文件。其次,一些包含转义字符的文本可以成为子级中一个 *可解析* 的变量,而不是已经解析的变量。换句话说,父级可以将一些文本写入子级,而当子级执行时,这些文本会解析为变量的值。但这个例子只是触及了其中的可能性。 没有人会将这个过程与未来某天可能会消灭或奴役全人类的人工智能混淆,但我不会称 *Mother.bat* 为一个愚蠢的 bat 文件。 ### 变量解析 现在你可以在父级或子级 bat 文件中解析隐藏参数,但解析普通变量在任一 bat 文件中同样重要,甚至更为关键。 用百分号和感叹号定界的变量行为有所不同。为了演示这一点,下面的代码列出了两个在所有 Windows 计算机上设置的系统变量:`computername` 是计算机的名称,`os` 是计算机的操作系统(第二十一章)。此代码创建一个名为 *Dynamic.bat* 的小型 bat 文件: ``` set batDyn=C:\Batch\Dynamic.bat > %batDyn% echo @setlocal EnableExtensions EnableDelayedExpansion >> %batDyn% echo @echo off ❶ >> %batDyn% echo ^> con echo This bat was built on %computername%, >> %batDyn% echo ^> con echo using the operating system !os!. ❷ >> %batDyn% echo ^> con echo This bat is running on %%computername%%, >> %batDyn% echo ^> con echo using the operating system ^^!os^^!. >> %batDyn% echo pause ``` 我已经将第一次引用到的 `computername` 用一对百分号符号 ❶ 括起来,在接下来的命令中,我将第一次引用的 `os` 用一对感叹号括起来。(这两种定界符都适用于这两个变量;我只是对比并比较它们而已。)这两个变量在这两条 echo 命令执行时会解析为各自的值,可能会将以下内容写入动态 bat 文件: ``` > con echo This bat was built on JACKLAPTOP, > con echo using the operating system Windows_NT. ``` 然而,接下来的两条 echo 命令 ❷ 会写出以下两行: ``` > con echo This bat is running on %computername%, > con echo using the operating system !os!. ``` 当解释器遇到 `%%computername%%` 时,它并不会将其识别为变量。由于百分号本身是转义字符,每对百分号都会解析为一个单独的百分号符号,且它们之间的文本并不是一个变量名;至少目前来看,它只是随便附带的。正如在 第十四章 中详细说明的那样,转义感叹号稍微复杂一点,但 `^^!os^^!` 同样会解析为 `!os!`。 在任何计算机上执行子级 bat 文件 *Dynamic.bat* 时,前两条 echo 命令会将现在的硬编码文本写入控制台——也就是说,它们写入的是第一个 bat 文件运行的计算机上的信息。然而,接下来的两条 echo 命令包含两个变量,这两个变量会在 *Dynamic.bat* 执行时解析,它们的值会反映出运行此子级 bat 文件的计算机的计算机名称和操作系统。 让我们看一个实际应用来演示何时将变量解析并写入子级 bat 文件是有意义的,何时将其解析并执行时再进行处理才更合适。 ### 一个实际应用 之前的示例展示了如何在动态创建的批处理文件中解析变量和参数,但这些示例本质上是教学用的。仅仅用来宣布其来源和当前状态的批处理文件并没有实际用途。一个真实的批处理文件构建批处理文件将会做更多的事情,并且更加有用。 例如,它可能会将变量从父级传递到子级。每当批处理文件执行时,它会累积和修改变量,但没有什么是自动将这些变量赋值到动态创建的批处理文件中的。确保变量在其后代文件中保留的简单方法是写入一个`set`命令,将该变量传递给子文件。当该批处理文件运行时,`set`命令执行,变量将可用,直到它被重置或文件执行结束。 我已经展示了如何用`echo`命令写入一行代码,但你也可以使用`type`命令将整个文件的内容写入动态创建的批处理文件中(第十二章)。通常,我会用前言静态文件开始一个子批处理文件,并用尾声静态文件结束它。 前言静态文件可能以`setlocal`命令开始(因为大多数批处理文件应该这样做),并且很可能设置了一些变量,但其后内容可以是任何内容。重要的是,这个文件包含硬编码的批处理代码,启动每个动态创建的批处理文件的过程。同样,尾声静态文件包含完成所有子批处理文件所需的公共代码。至少,它通常包含一些可调用的例程和错误处理。 静态批处理文件不必仅仅出现在动态创建的批处理文件的开始和结束。你可以插入其他命令,并且将多个`type`命令与`echo`命令交替使用。你甚至可以将少量代码存储在一个静态文件中,以避免使用转义字符。 以下列表包含一个实际批处理文件构建的模板: ``` set batDyn=C:\Batch\Dynamic.bat ❶ > %batDyn% type C:\Batch\StaticPrologue.bat ❷ >> %batDyn% echo. ❸ >> %batDyn% echo set someVar=%someVar% >> %batDyn% echo set someOtherVar=%someOtherVar% ❹ >> %batDyn% echo set parentPath=%path% >> %batDyn% echo. ❺ >> %batDyn% echo %someExe% ❻ >> %batDyn% echo if %%errorlevel%% neq 0 ^( ❼ >> %batDyn% echo ^> con echo Some EXE FAILED >> %batDyn% echo pause >> %batDyn% echo goto :Abort ❽ >> %batDyn% echo ^) ❾ >> %batDyn% type C:\Batch\StaticEpilogue.bat ``` 初看起来,开始创建*Dynamic.bat*的重定向❶可能像是众多`echo`命令中的第一个,但实际上它是列表中第二个`type`命令的第一个。这个命令将*StaticPrologue.bat*文件的全部内容写入动态创建的批处理文件中。 两个`echo`命令❸演示了如何在子批处理文件中保持父级变量。每个逐渐展开的`set`命令的参数都包含变量名,等号左侧是变量名,右侧是解析后的值。看起来有些冗余,但这会变成一个硬编码的`set`命令,将值赋给子批处理文件中的变量。 接下来的 echo 命令 ❹ 展示了这一技术的一个变体;新的变量获得了一个新的名称。子 bat 文件可能需要知道父 bat 文件使用的路径变量,但我们不想影响子文件自身的路径变量。此命令写入一个 set 命令,最终会将父文件路径变量的完整内容赋值给一个名为 parentPath 的变量,当 *Dynamic.bat* 执行时。 使用转义技术来创建动态 bat 文件有两个原因。首先,用于解析变量的百分号和感叹号有时需要转义——这取决于我们何时应该解析变量。 *Dynamic.bat* 将调用某个可执行文件,然后检查返回码。在这个示例中,假设我们在创建动态 bat 文件时就已经知道了可执行文件。因此,我只是将变量解析为 %someExe% ❺ 并将其直接写入动态 bat 文件中,成为硬编码的文本。 返回码 ❻ 是一个完全不同的故事;显然,我们*不应该*在命令执行之前解析它,因为动态 bat 文件在运行时会执行此命令,所以我使用了转义字符来处理分隔符。解释器看到 %%errorlevel%% 并将 %errorlevel% 写入文件。如果没有转义字符,变量会在写入时直接解析为它当时的状态,可能是 0,导致一个永远为假的条件语句:if 0 neq 0。转义字符将其保留为尚未解析的变量。 使用转义字符的第二个原因是在字符在 Batch 中有其他特殊意义时。例如,当管道符和与符号要作为动态代码的一部分时,必须始终对它们进行转义。在这个例子中,我们需要转义代码块周围的开括号 ❻ 和闭括号 ❽,以及代码块内部的重定向符号或大于号 ❼。 假设 someExe 被设置为某个特定值,解释器可能会将 ❺ 和 ❽ 之间的代码段写入动态 bat 文件,如下所示: ``` C:\Batch\SomeExecutable.exe if %errorlevel% neq 0 ( > con echo Some EXE FAILED pause goto :Abort ) ``` 第二个 type 命令 ❾ 补充了这个列表,并将整个 *StaticEpilogue.bat* 写入动态文件。它可能使用了之前代码中明确写入动态 bat 文件的三个变量。考虑到代码块内的 goto 命令,我也很有信心这个文件中某处会有一个 :Abort 标签。不管它的内容是什么,它们都会完成动态 bat 文件的编写。 但是,将静态文件写入子 bat 文件时有一个微妙的警告。我忽略了代码中一个非常关键的命令,这么关键,以至于我把它留到了最后。在第一个 type 命令将静态序言数据写入动态创建的 bat 文件之后,一个简单的 echo 命令 ❷ 会向 *Dynamic.bat* 添加一个空行。 您可能会认为我这样做只是为了简单地分隔新批处理文件中的静态代码和即将到来的设置命令,这本身就是一个很好的理由。毕竟,在设置命令之后的空白行❹也仅为此目的创建了一些空白空间,但是第一个空白行的作用远不止美观。 为了解释这个空白行修复的问题,我曾经与一个同事分享过这个批处理构建技术,他在其*StaticPrologue.bat*的等效末尾设置了一个关键变量: ``` set criticalVar=criticalValue ``` 这些数据是文件的最后;甚至没有尾随的空白行。这很快就会变得至关重要。 在他的主批处理文件中的 type 命令之后,他没有写一个空白行。相反,他设置了一个变量: ``` >> %batDyn% echo set someVar=%someVar% ``` 最终结果是他动态批处理文件中间的这行代码: ``` set criticalVar=criticalValue set someVar=Whatever ``` 看起来像是第二个设置命令实际上并不是命令;它只是更多文本的附加,包括空格,附加到*实际*设置命令的预期值上。结果是动态代码未将 criticalVar 设置为预期值,并且根本没有设置 someVar。显然,结果是垃圾,但是是什么导致了这种情况? 当解释器执行重定向到文件的 echo 命令时,它将文本追加到目标文件的末尾。然后解释器立即追加两个字符以换行回车,或者更不正式地说,添加一个 CRLF。(我在第十四章详细描述了 CRLF。)最终结果是,在编辑器中查看时,在文件底部会有一个空行。当您连续重定向多个 echo 命令时,每个命令都会追加一行文本和一个 CRLF,以便下一个命令将其文本追加为新记录。 当你仅通过 echo 命令动态生成代码创建文件时,这很有效,但是 type 命令会将整个文件原样写入目标而不添加 CRLF。如果静态批处理文件的创建者没有在文件的最后一个记录上附加 CRLF,则可能会引发问题。(也就是说,他们没有将光标定位在最后一个记录的末尾,按 ENTER 键并保存文件。)当解释器将缺少该 CRLF 的静态文件写入动态批处理文件,然后尝试通过 echo 命令追加记录时,实际上会将该记录追加到复制的静态数据的最后一个记录上,导致我们在这里看到的一团糟。(这也导致了一位沮丧的同事试图弄清楚为什么一些变量没有按预期解析。) `echo.`命令❷会写入一个空白记录(甚至没有任何空格),更重要的是,会写入一个 CRLF(回车换行符)。在这里加入它是为了确保,如果静态文件的最后一条记录缺少 CRLF,代码会在动态生成的 bat 文件中插入另一个 CRLF;如果 CRLF 并不缺失,它会写入一个空记录,这样能很好地分隔代码部分。如果你真的依赖那个空白行,可以写两行空白。 看似是一个永无止境的战斗,要让代码万无一失,并预见到所有可能导致它崩溃的条件,但有两种主要方法可以避免这个问题:确保你使用的每个静态文件都以 CRLF 结尾,或者在每个未完成子 bat 文件的`type`命令后写入一个空白行。我发现用后者方法在代码中控制这一点更容易,但总是做两者也是个不错的主意。 ### 多代 bat 文件 这句话“给我一个足够长的杠杆和一个支点,我将撬动地球”通常归于阿基米德。我想,如果古希腊人有 Batch,伟大的思想家一定会说,“给我足够的转义字符和磁盘空间,我将创建一个无限生成 bat 文件的 bat 文件。”毫无疑问,这句话与原文相比显得苍白无力,但转义字符是可以被转义的,而 bat 文件也不限于单一的后代生成。 去掉所有多余的部分,这个简化版的*Mother.bat*会创建一个子 bat 文件,这个子 bat 文件将会创建它自己的后代: ``` set batDtr=C:\Batch\Daughter.bat > %batDtr% echo set batGDtr=C:\Batch\GrandDaughter.bat >> %batDtr% echo ^> %%batGDtr%% echo @echo off >> %batDtr% echo ^>^> %%batGDtr%% echo ^^^> con echo I am "%%%%~NX0" >> %batDtr% echo ^>^> %%batGDtr%% echo ^^^> con echo Begat by "%%~NX0" >> %batDtr% echo ^>^> %%batGDtr%% echo ^^^> con echo Begat by "%~NX0" >> %batDtr% echo ^>^> %%batGDtr%% echo pause ``` 执行*Mother.bat*会产生*Daughter.bat*: ``` set batGDtr=C:\Batch\GrandDaughter.bat > %batGDtr% echo @echo off >> %batGDtr% echo ^> con echo I am "%%~NX0" >> %batGDtr% echo ^> con echo Begat by "%~NX0" >> %batGDtr% echo ^> con echo Begat by "Mother.bat" >> %batGDtr% echo pause ``` 注意到`^>`会转义成`>`,而`%%`会转义成`%`,但是通过转义转义字符,`^^^>`会变成`^>`,`%%%%`会变成`%%`。 执行*Daughter.bat*会产生*GrandDaughter.bat*: ``` @echo off > con echo I am "%~NX0" > con echo Begat by "Daughter.bat" > con echo Begat by "Mother.bat" pause ``` 运行*GrandDaughter.bat*会在控制台输出如下内容,其中`%~NX0`最后一次解析为"GrandDaughter.bat": ``` I am "GrandDaughter.bat" Begat by "Daughter.bat" Begat by "Mother.bat" Press any key to continue ... ``` 我有个承认的地方。这个最后的例子不过是作者无耻炫耀的一个明显实例——就像是程序员版的触地舞或扣篮后的垃圾话。我已经编写批处理脚本很多年了,这是我第一次甚至想到编写一个 bat 文件来构建另一个 bat 文件,进而构建一个新的 bat 文件。我这么做只是为了展示可能性,实际上很难想到它的现实应用场景,但创建一个生成第二个 bat 文件的 bat 文件,确实有很多用途。 ### 推荐 你可以使用前面讨论过的实际应用作为更复杂的动态 bat 文件的模板。它包含了你需要的所有部分:它分配变量,使用现有和新创建的名称;它使用转义来允许后续解析变量;它转义其他特殊字符,以便在子 bat 文件中使用;它还使用部分静态 bat 文件与动态生成的代码配合使用。 当一个过程增长到需要将其拆解为更小的部分并独立执行时,这种技术非常有效。也许你会根据输入的大小来进行拆分,从而编写一个 bat 文件,首先检查输入后动态生成一个或多个 bat 文件。换句话说,你可以动态创建一个动态数量的子进程。 不同的子过程可能最终在不同的服务器上运行,以平衡负载。你可能能够使用静态的 bat 文件处理这些子过程,但当你动态生成其他 bat 文件时,可以在执行原始 bat 文件的过程中收集信息,并将其注入到新生成的 bat 文件中。 有人可能会反对,认为你可以将动态信息作为参数传递给第二个 bat 文件,但这只有在第一个 bat 文件确实调用第二个时才有效。可能有其他完全不同的进程会执行你动态创建的 bat 文件。那个进程不需要传递任何参数,甚至不需要理解 bat 文件的内容或功能。你甚至可以创建一个 bat 文件,暂时保留它,并在稍后的时间执行它。 ### 总结 在本章中,我展示了一个 bat 文件如何构建另一个 bat 文件。在详细演示之后,我讨论了如何将已解析的变量和可解析的变量写入动态 bat 文件。我还分享了我用于在动态 bat 文件中创建变量以及构建包含动态和静态代码的 bat 文件的一些技术。你学到了动态生成的 bat 文件最有用和最适用的情况。 在下一章中,我将演示一个有趣的 bat 文件构建应用。在讨论了一个非常有用的技术——自动重启间歇性失败之后,我将应用本章中的经验,执行一个更为复杂的任务:终止并重启挂起的执行。我们将动态创建第二个 bat 文件来调用易于挂起的进程,并从第一个 bat 文件中监控它。随着工具箱的不断扩展,问题开始在工具中找到解决方案,真是令人惊叹。 ## 第二十六章:26 自动重启与多线程 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在本章中,我将探讨程序员常遇到的两个令人头疼的问题:间歇性故障和挂起,特别是那种通常在重启后可以成功执行的类型。我将详细介绍如何在批处理文件中自动重启任何进程的间歇性故障。挂起问题稍微复杂一些,但我会介绍一种在重启前终止或杀死挂起进程的技巧。对于这两种问题,我将一步步展示如何创建解决方案,包括设计考虑、规格、编码,甚至是测试。 在过程中,我还会介绍一些有趣的命令,它们的应用不仅仅限于自动重启。其中一个命令可以让批处理文件在定义的时间段内“休眠”。另一个命令监控计算机上运行的所有进程(可以把它看作是批处理文件中的任务管理器)。还有一个命令可以终止计算机上的任何特定进程或多个进程。最终,这个讨论将引出一个看似无关的技巧——多线程或并发处理。 ### 间歇性故障的五个阶段 程序员最头痛的事情之一就是间歇性故障。这可能是一个简单的 xcopy 命令,由于暂时的网络或服务器通信问题而失败。你可能需要调用一个由别人编写的程序,这个程序有时会因为无法连接到网络服务或莫名其妙地失败而无法正常运行。只需重新运行该进程就能“修复”问题,但这么做浪费了资源并造成了延迟。我曾参与过许多类似的情况,而且每次都遵循了这些“间歇性故障的五个阶段”(大致根据“悲伤的五个阶段”改编): **第一阶段:否认**    “这看起来像是偶发的。我找不到任何问题。就重试一下吧。” **第二阶段:愤怒**    被分配负责追踪和重启故障的操作员感到恼火;没有控制网络或被调用的可执行文件的批处理程序员感到愤怒;控制更少的中层管理人员则感到烦躁。每一次故障都让情况变得更糟,直到每个人都怒不可遏。 **第三阶段:指责**    “是网络的问题,我们的服务器是用胶带和铁丝连起来的。” “不,是供应商的产品出了问题。” “不,你的环境无法支持我们的产品。” “是我们互联网服务提供商的问题。” “那个几个月前退休的人留下的都是废品。” **阶段 4:探索**    一位经理哼了一声,“我不需要创可贴,我们需要找到根本原因。”这是每个人都能认同的,因为没有人愿意成为创可贴派的唯一成员——至少最初是如此。额外的日志记录被启用,诊断工具被安装到任何可能与问题远程相关的硬件上。不同的团队深入到他们的代码库中并提出理论。有时根本原因被找到,从而避免了最后阶段,但更多时候,机器中的幽灵依然是一个谜。 **阶段 5:接受**    最终听从伏尔泰的格言:“最好的往往是敌人,好的。”Batch 编码员通过自动重启可疑进程解决了问题。 只要一个人坚持寻找根本原因,就很难进入最终的接受阶段。这可能需要几天、几周或几个月,但当显而易见地发现根本原因不会出现,或者修复起来异常昂贵或困难时,唯一的选择就是从问题的表现着手。请注意,在第五阶段,我没有把*修复*一词放在引号里。尽管有些坚持的人仍然会称之为创可贴或权宜之计,但一个设计良好的自动重启过程会解决问题,确保再也不会被打扰。我称之为*修复*。 ### 超时命令 在构建自动重启之前,我将介绍一个在设计中非常有用的新命令。在启动自动重启之前,我会希望将 bat 文件暂时睡眠一段时间,以便清除任何瞬时的服务器或连接问题。什么都不做对人类来说是容易的;对一些人来说,这是我们的默认状态,但计算机程序的设计目的是尽可能快速地执行。幸运的是,Batch 提供了超时命令,这可能是模仿运动中暂停的概念,或者是对不听话的孩子强制执行的。 这是一个简单的命令,接受一个参数:超时的长度,以秒为单位。这个命令将会睡眠一分钟: ``` timeout 60 ``` Batch 允许最大超时为 99,999 秒,这相当于超过 27 小时。当这个命令以交互方式使用时,用户会在控制台上看到一个倒计时,直到处理恢复,并且可以按任意键提前结束超时并继续。 超时命令有几个轻微的怪异之处。首先,它接受一个值为 0 的参数。作为硬编码值,这没有意义,但考虑一下这个命令: ``` timeout %sleepSeconds% ``` 如果你在代码中确定睡眠时间,它提供了一种简单的方式来实质上关闭命令,而无需用 if 命令包裹它。将 sleepSeconds 设置为 300 可以获得五分钟的休息,设置为 0 则跳过命令。-1 的参数会导致无限等待,直到按下任意键,这实际上是一个华丽的暂停命令。 ### 自动重启 让我们一步步分析构建一个自动重启的过程,该过程偶尔会失败。设计考虑因素将指导规格的制定,从而进行代码编写和测试。 #### 设计考虑因素 自动重启的基本概念很简单。当任何进程生成错误的返回码时,主逻辑通常会有序地中止执行。自动重启则会回到原点,重新运行出错的进程。在批处理(Batch)中,这可能听起来仅仅是一个 if 命令和一个 goto 命令,但细节很快就变得复杂,需要精心设计。 理想情况下,重启会成功,过程会继续直到完成,但有时自动重启也会失败,如果继续失败,陷入无限循环的前景就愈加明显。必须有人决定在承认失败并启动中止之前,重启过程的次数。 如果一个进程在 100 次中无解释地失败一次,重启后的进程可能也会在 100 次中失败一次。基础概率理论表明,如果某个事件在 100 次尝试中发生一次,那么发生的概率是 1/100。要找到这个事件连续两次随机发生的概率,比例需要平方,结果是 1/10,000。将原始概率立方后,我们就得到三次连续尝试发生该事件的概率为 1/1,000,000。指数为 5 时,意味着 10 亿次尝试中仅有一次会出现连续五次失败。 这意味着,五到六次尝试应该能让我们达到一个实际的情况,即如果——这是一个巨大的如果——100 次尝试中的一次失败真的是随机的,那么这些失败就不会再发生了。 许多时候失败并非随机发生,这也会影响设计考虑。失败可能发生在服务器或数据库在高峰处理期间很忙的时候。偶尔,两个服务器会在没有明显原因的情况下断开连接,可能是片刻、几秒钟或几分钟。如果从其中一台服务器到另一台服务器的复制失败,那么接下来的 10 次自动重启可能会在你读完这句话之前发生,意味着这 10 次都发生在连接丢失的窗口期内。这并不是随机的。 对于这种类型的失败,你需要进行更多的分析。问题通常需要多长时间才能自行解决?在大多数情况下,最好在第一次自动重启之前睡几秒钟,正如之前提到的那样。在随后的失败之后,我们可以设置越来越长的等待时间。如果问题通常在一分钟内清除,总的重启尝试应该在大约三到四分钟内完成,并且从那里调整时间框架。 如果这一切看起来令人不知所措,那么是否有意义在几个小时内进行数百次自动重启尝试呢?不,没意义。如果自动重启过程尝试的次数过多,或者每次尝试之间的睡眠时间过长,解决方案反而会适得其反。如果一个服务器在周五中午失去连接,过于慷慨的自动重启可能会掩盖问题,直到晚上才被发现。处理过程可能会在几小时后才意识到问题,远远落后于预定计划。需要找到一个折衷方案。 另一个需要考虑的因素是故障的性质。你应该尽力区分合法的失败和可重启的失败。如果一个程序因为数据条件导致中止,而这个数据条件在重启后不会发生变化,那么自动重启只会浪费时间和 CPU 周期。有时候,你可以通过返回代码推测错误的性质。如果可以,你可以根据返回代码来决定下一步行动。如果不能,这也会影响决策;也许你应该尝试减少重启次数。 #### 规格说明 在这个练习中,我将编造一个需要自动重启的场景,并在考虑到刚才讨论的所有设计要素后编写一些规格说明。 一个与远程服务器上的数据库通信的已编译可执行文件大多数时候运行良好。它每天运行超过 30 次,但大约每三天会发生一次故障,返回错误级别为 7。最初几次发生时,有人重启了它,但每周几次的失败逐渐成为一种困扰。直到某个星期天早上的故障未被及时注意到,直到本周稍晚才成为了大家的巨大尴尬。必须采取一些措施了。 这种虚拟故障大约每 100 次尝试发生一次,这其实是幸运的,因为我刚好进行了假设这种频率的数学计算。在收集了所有统计数据之后,某些失败似乎确实是随机的,但数据库连接问题似乎会在不到五秒钟内恢复,因此我们不需要很长的休眠时间。 幸运的是,编写可执行文件的人在返回代码上做得很好。其他失败情况,比如无法在数据库中找到某些条目,返回的错误代码不是 7,而成功调用则总是返回 0。 现在我们有了编写规格说明所需的所有信息。第一个规格是显而易见的,但另外两个需要一点技巧,因为你可能会得出略有不同的数字: 1.  如果错误级别是 7,则启动自动重启。如果是 0,则继续;对于所有其他返回值,则中止。 2.  尝试最多四次自动重启,总共执行五次程序,超过第五次则中止。 3.  第一次尝试后暂停 2 秒钟,之后每次尝试将暂停时间加倍,即等待时间将是 2 秒、4 秒、8 秒和 16 秒。 稍微超过 30 秒后(加上可执行文件运行的时间),第五次失败将触发中止。在计算机艺术与计算机科学同样重要的工作中,这应该是一个不错的折衷。现在,我们准备好编码了。 #### 自动重启代码 列表 26-1 符合定义的规范。执行此代码的唯一前提是我们必须将 flakyExe 变量设置为正在经历间歇性故障的程序或进程。 ``` ❶ prompt $T$G if not defined sleepIncrmt set sleepIncrmt=2 if not defined maxAttempts set maxAttempts=5 set attempt=0 ❷ :Restart set /A attempt += 1 %flakyExe% ❸ if %errorlevel% equ 7 ( ❹ if %attempt% lss %maxAttempts% ( ❺ timeout %sleepIncrmt% set /A sleepIncrmt *= 2 goto :Restart ) else ( ❻ goto :Abort ) ) else if %errorlevel% neq 0 ( ❼ goto :Abort ) else ( ❽ > con echo Successful Call of the Flaky Executable ) ``` 列表 26-1:启动最多四次自动重启不稳定可执行文件的代码 在代码的第一部分,提示命令❶(在第二十一章中介绍)将时间嵌入到提示字符串中,该字符串会添加到 stdout 中每个执行的行之前,从而更容易验证进程是否暂停了所需的时间。我将休眠增量的变量定义为 2 秒,将最大尝试次数的变量定义为 5 次尝试。我还初始化了尝试变量,该变量跟踪正在执行的尝试,初始值为 0。 为了增加代码的灵活性,我使用了仅在 sleepIncrmt 和 maxAttempts 尚未定义时才设置它们的技巧。尽管已经为规格付出了很多努力,但如果避免中止与最小化自动重启所需时间之间的折衷不完全合适,用户可以在调用此逻辑之前设置这些变量,或者在计算机上全局设置它们。如果每个月仍然发生一次中止,任何人都可以在不更改代码的情况下增加休眠增量或最大尝试次数,但为了本练习的方便,我将假设默认值。 逻辑继续经过:Restart 标签❷并将尝试变量增加到 1,然后解析 flakyExe,从而执行程序。(在此练习中,变量包含正在失败的不稳定可执行文件,但此调用可以是向远程服务器的 xcopy 命令,也可以是对一个不稳定的 bat 文件或任何可能失败并需要重启的进程的调用。) 接下来,我评估可执行文件返回的错误级别。如果代码遇到错误返回码 7❸,我检查是否已进行所需的 5 次尝试❹。由于这是第一次尝试,执行将进入代码块❺并执行 timeout 命令,休眠 2 秒钟。然后,我将休眠增量 sleepIncrmt 加倍,以便如果再次调用该进程时,进程将休眠 4 秒钟。接下来,我通过 goto 命令向后跳出此逻辑。 这将我们带回到刚刚执行过的:Restart 标签❷。这是第九章中 do...while 命令的一个很好的应用。注意标签的战略性放置。在调用第一次重启后,我将总尝试次数增加到 2,然后再次执行不稳定的进程。 如果返回码是 7 ❸,相同的过程将执行另一次重启,这次尝试次数增加到 3,仍然小于 5 ❹。回到代码块 ❺ 并在等待四秒超时后,我将睡眠时间增量加倍至 8,然后再次返回到 :Restart 标签 ❷。 如果这失败了四次,我将睡眠 16 秒 ❺ 并进行最后一次尝试。如果这次也失败,则尝试次数变为 5,并且不再小于 maxAttempts 中的目标值 ❹,因此代码将通过未显示的 :Abort 程序 ❻ 中止。 我们还需要像解释器一样思考另外两种情况。你已经知道错误级别为 7 时会发生什么。现在想象一下,那个不稳定的进程返回的不是 0 或 7。else if 子句 %errorlevel% neq 0 为真,代码会调用 :Abort 程序 ❼。 最后的情况是,假设返回码为 0。这是到达代码块 ❽ 的唯一可能途径,在默认的 else 子句之后,它验证执行是否成功。 中止程序应区分两种类型的中止。一种是在多次自动重启后失败 ❻,另一种则是在仅发生一次失败后,由与重启无关的原因导致的失败 ❼。 #### 测试 这显然是一些高级的批处理编码,但在你掌握了这个概念并编写了类似 Listing 26-1 中的代码后,可能看起来更难的任务会随之而来:测试。如果你运行这段代码 100 次,你可能会看到至少一次失败,但仍然有超过三分之一的概率完全没有失败。即使执行十亿次,也不能保证看到五次失败。(如果你对数学感兴趣,可以查阅泊松分布。) 我们需要一种模拟故障的方法,在 Listing 26-1 之前输入此命令将实现这一目的: ``` set flakyExe=cmd /C exit 7 ``` 当解释器解析代码中的 flakyExe 时,cmd 命令(在第二十一章中介绍)执行,运行 exit 命令,将 errorlevel 设置为 7。如果主逻辑编写正确,这将触发自动重启,然后它将重复相同的过程,总共进行五次后中止。 在你对测试结果满意后,将 exit 7 改为 exit 4,并重新运行;预期会看到一个不同的中止,且不会触发重启。再将其更改为 exit 0,你应该看到一个成功的运行结果(或至少是一个模拟的成功运行)。 理想的测试可能在两次失败后触发两次自动重启,返回码为 7,然后在第三次调用时成功,返回码为 0。你可以在此设置中将 cmd 命令替换为一个简单的 bat 文件调用命令,该文件包含一些条件逻辑,根据正在执行的尝试来设置返回码。更好的是,直接使用此设置: ``` set flakyExe=if ^^!attempt^^! lss 3 (cmd /C exit 7) else (cmd /C exit 0) ``` 如你在第十四章中所学,我会转义感叹号,以便它们能存储在变量中。然后,当代码解析 flakyExe 时,attempt 变量中包含的值将成为条件子句的一部分。它将在前两次执行时为真,导致返回代码为 7;然后在第三次执行时,条件子句将为假。因此,返回代码将为 0。我在第四章中曾建议避免像这样的混乱 if 命令,但它非常适合这种类型的测试。 #### 中央日志 上面的例子已经简化并且完全可用,但在实际实施时,另一个关键特性是每次自动重启的中央日志。当此过程完成时,我们可以查询 attempt;任何大于 1 的值都意味着代码至少执行了一次自动重启。通过这些信息,我们可以将带有时间戳的条目写入日志文件,记录有关自动重启的详细信息,尤其是尝试次数。 如果自动重启完全掩盖了故障,你可能会成为自己成功的受害者。依然存在一个潜在的未解决问题,因此,保持已避免的中止记录很重要。一个月后,你可以利用日志明确说明没有自动重启的情况下,某些进程会中止。可能只有两三个,也可能是十几个,甚至几百个。 无论数字是多少,你都可以使用这个日志作为自动重启过程成功实施的证明。它还将监控潜在的根本问题。问题的频率可能在安装新硬件后减少或消失,或者随着现有硬件的老化而发生得更多。这些信息有助于确定针对根本原因解决方案的紧迫性。(它也可能成为你年度评估中的硬性指标。) ### 挂起 我已经讨论过自动重启失败的进程,它返回特定的值或错误级别值的集合。一个更大的挑战是挂起;*挂起*发生在一个调用的进程未能终止且永远不返回错误级别的值——更糟糕的是,挂起从不将控制权交回批处理代码。 挂起可能是由于一个无限循环造成的,该循环在重启时会再次发生,但有时当连接断开或无法找到资源时,一个有缺陷的进程也会挂起。简而言之,如果你可以进入任务管理器,杀死挂起的进程,重启它,并且它能够在没有其他干预的情况下成功处理,那么该进程就是一个非常适合自动终止和重启的候选者。 但任何自动杀死并重启进程的过程都面临一个重大障碍。自动重启在概念上是相当直接的。当你收到某些错误的返回代码时,你只需调用重启逻辑。但在挂起的情况下,Batch 代码是……挂起的。进程已经“拿着球回家”,并承诺再也不与任何人分享它了。根据定义,挂起不会将控制权交还给 Batch 代码,因此调用它的 bat 文件无法执行任何操作,更不用说杀死并重启该进程了。但总是有办法的:必须有某个东西或某个人来杀死挂起的进程。因此,程序员必须预见到并为挂起的可能性进行编码。 为了完成这个任务,我们需要一些在日常 bat 文件中没有的命令。start 命令(详见第十章)将打开一个第二个命令窗口,实际运行可能会挂起的进程。tasklist 命令将监控可能挂起的 bat 文件,而 taskkill 命令则...我想这个命令就不需要解释了。 仅仅列出这些命令大致勾画了计划的框架,但在深入细节之前,我必须正式介绍这两个新命令。 ### 检索进程列表 tasklist 命令提供了你可以通过任务管理器在 Windows 机器上“手动”获取的大部分信息。它检索当前在机器上运行的所有或部分进程列表。我们将在 bat 文件中使用它,但首先打开命令提示符并输入 tasklist,你将获得一份正在机器上运行的所有程序的列表,包括内存使用情况、会话信息和进程标识符(PID)。在任何给定时间,成百上千的进程会在运行,但以下是其中几行的示例: ``` Image Name PID Session Name Session# Mem Usage ========================= ======== ================ =========== ============ System Idle Process 0 Services 0 8 K System 4 Services 0 3,168 K Registry 120 Services 0 51,168 K cmd.exe 8692 Services 0 432 K ``` PID 是操作系统在进程执行开始时分配给该进程的一个数字。系统空闲进程始终在运行,其 PID 始终为 0;System 通常使用 PID 4 或 8,具体取决于操作系统,所有其他进程都会分配一个可以被 4 整除的唯一数字。PID 会在某个时刻重新使用,但直到机器上运行了成千上万的不同进程之后才会发生。tasklist 命令显示每个执行中的 bat 文件,其映像名称为 *cmd.exe*,如第四行和最后一行所示。 该命令具有一些非常有用的参数。在解析命令输出时(我们很快会这么做),我们需要避免输出的前两行标题信息。/NH 选项(表示 *no headers*)会移除第一行标题信息,以及第二行的装饰性等号。 /FO 选项(表示 *format*)会改变数据的显示格式。可用的格式有逗号分隔(/FO:CSV)、列表(/FO:LIST)和表格(/FO:TABLE),后者也是示例输出中显示的默认格式。 /FI 选项将*过滤*掉不需要的条目或仅包含所需的条目。你可以包括或排除某些窗口标题,或者构建涉及 CPU 时间、PID、镜像名称等的过滤器。例如,以下命令列出机器上所有正在运行的记事本++实例: ``` tasklist /FI:"ImageName eq notepad++.exe" ``` (请不要问为什么这个选项使用等号操作符 eq 而不是 equ,但它确实是这样。) 使用通配符(*)会列出所有记事本、记事本++以及可能以这七个字母开头的任何其他程序: ``` tasklist /FI:"ImageName eq notepad*" ``` 以下命令使用大于符号来显示当前占用大量内存的所有进程: ``` tasklist /FI:"MemUsage gt 250000" ``` 使用帮助命令查看更多关于/FI 选项的详细信息;我展示的只是操作符和操作数的一部分。 ### 终止进程 taskkill 命令可以一次终止一个或多个特定进程。它的一些参数与 tasklist 命令共享,最重要的是/FI 选项。以下命令尝试终止机器上所有正在运行的记事本实例: ``` taskkill /FI:"imagename eq notepad.exe" ``` 该命令尝试关闭所有打开的记事本文件,对于所有已保存的文件,立即执行此操作。对于所有未保存的文件,记事本会生成一个弹窗,友好地提示用户保存文件,但/F 选项会*强制*终止进程。使用此选项会毫不留情地终止未保存的进程。/T 选项不仅会终止一个进程,还会终止它可能启动的任何进程。很快,我们将使用这两个选项来终止生成的 bat 文件,以确保它及其相关的所有进程都被真正终止。 与/PID 选项一起使用时,taskkill 命令基于进程指示符终止一个或多个特定进程。例如,以下命令会终止两个进程: ``` taskkill /PID 12348 /PID 6784 ``` 你还可以通过非常具体的窗口标题强制终止进程。(预示并不仅仅是小说中的技巧。) ### 自动关闭与重启 就像我们进行自动重启一样,让我们逐步构建一个自动杀死并重启偶尔会挂起的进程的过程,包括设计、规格、编码和测试。 在这个虚构的场景中,那个不稳定的可执行文件 99.9%的时间会完美运行,但每千次中有一次会挂起。我们知道根本原因不是一个简单的死循环,因为我们可以手动终止并重启它,并且看到它成功运行。团队推测与数据库的交互可能会导致问题,或者也许与数据库无关。它发生得如此罕见,几乎无法进行故障排除,但发生得足够频繁,成为一个问题,并且没人能找到根本原因。 在经历了五个阶段之后,我们决定通过自动终止并重启来修复问题。我们将把有问题的进程调用放入一个动态创建的 bat 文件中,并生成它来代替直接调用该进程。生成的进程将完全独立于主 bat 文件执行,然后主文件会监控生成的 bat 文件,如果检测到挂起,则会终止并重启它。 #### 设计考虑因素 设计中最棘手的部分无疑是确定在假设挂起之前,究竟应该等多长时间让执行文件完成。通常需要多长时间?在没有挂起的情况下,最大时间是多少?执行时间是一致的吗,还是会波动?如果波动,我们能否根据输入文件的大小预测,还是完全随机的?如果一个进程通常需要 3 或 4 分钟来执行,将最大执行时间设置为 10 分钟是合理的。但这将会终止并重启一个特别慢的执行,导致它刚好在完成之前被终止,完成时需要 10 分钟零一秒。 在确定时间长度之前,我们必须进行大量分析。如果执行时间比较一致,我通常将其设置为典型运行时间的三倍。如果执行时间由输入文件的大小决定,对于较大的文件可以等待更长时间。如果执行时间看起来是随机的,可以增加更多的时间,但不要设置过长的最大时间,以免适得其反。 在放弃之前尝试重启的次数是你必须确定的另一个因素。与自动重启进程时应用的相同考虑因素同样适用于这里。简而言之,挂起的频率和随机性有多大? 提议的自动终止并重启设计的另一个考虑因素是检查生成的 bat 文件状态的频率。如果一个进程通常需要 3 或 4 分钟,而最大执行时间设置为 10 分钟,那么在检查之前等满 10 分钟是没有意义的。我们可以每 15 秒检查一次;如果完成了,我们可以继续,不用再等;如果没有完成,我们可以再等 15 秒,最多等到 10 分钟。需要注意的是,所选的间隔是我们可能在单次执行中增加的最大时间。 #### 规范 继续这个假设的场景,执行文件通常在 10 或 20 秒内完成。经过大量测试,甚至是并行测试,我们观察到的最大运行时间是 35 秒。一个合理的最大等待时间似乎是 60 秒;这是典型长时间执行的三倍,并且轻松超过了观察到的最长执行时间。 每隔 10 秒检查一次生成的 bat 文件似乎是合理的。一次快速运行将在第一次检查前完成,大多数其他任务将在第二次检查时完成,所有任务中只有少数将在第三次完成,相较于直接调用不稳定的可执行文件,这种方式最多会增加 10 秒的执行时间。 总共四次尝试后再中止应该也能奏效。因为它每 1000 次尝试中会失败一次,四次完全随机的失败在万亿次尝试中才会发生一次。听起来这可能是过度的,但我们已经观察到多个几乎同时发生的卡死,这意味着这些失败并非完全随机。它们可能发生在网络连接丢失的时期。 最糟的情况是,四次失败的尝试将耗时略多于四分钟,然后我们会启动中止操作。这个过程旨在尽可能在短时间内减轻卡死的风险。就像自动重启一样,这些规格的制定也有一定的艺术性。合理的同事可能希望设置更长或更短的等待时间,或者更多或更少的重启次数。 #### 核心自动杀死与重启逻辑 在分享代码之前,我会讨论这个整个过程的核心——生成、跟踪和可能终止第二个 bat 文件的过程。 从高层次来看,我将通过`start`命令启动生成的 bat 文件,而不是使用`call`命令,这样两个 bat 文件就会独立执行。生成的 bat 文件会将其标题更改为一些主进程也知道的唯一文本。这将使得主进程能够通过`tasklist`命令跟踪或监视生成的 bat 文件,如果它花费的时间过长,我们将使用`taskkill`命令将其终止。然后我们会再次生成这个动态创建的 bat 文件,再次监控它……并可能再次终止它。 在第十五章中,我介绍了`title`命令,用来更改我们运行交互式 bat 文件时在命令窗口左上角显示的标题。为命令窗口命名总是有益的,这样我们可以区分不同的窗口,但`title`命令的用途不仅仅是外观上的。我将在这里使用它来附加一个跟踪设备。 就像海洋生物学家可能会在海龟的壳上安装电子跟踪设备一样,我们也会为生成的 bat 文件附加一个跟踪设备。生物学家利用卫星遥感追踪海龟在全球海洋和海滩上的运动,追踪它们交配、觅食和产卵的过程。我们的目标则没那么宏大;我们只是简单地跟踪在一台机器上执行的 bat 文件的生命周期。 如果`title`命令附加了跟踪设备,那么`tasklist`命令就类似于卫星追踪海龟。在主 bat 文件中,`tasklist`命令将使用生成的 bat 文件中`title`命令所用的相同唯一标题。以下命令将使用`uniqTitle`变量中包含的唯一标题来跟踪生成的 bat 文件: ``` tasklist /FI:"WindowTitle eq %uniqTitle%" /NH ``` /FI 选项允许我们根据机器上运行的各种进程的不同特征进行筛选。例如,生成的 bat 文件的 ImageName 将是 *cmd.exe*,但这对于所有 bat 文件(包括进行跟踪的主 bat 文件)来说都是正确的,因此在这里并无用处。但筛选 WindowTitle 将返回唯一的一个具有该标题的进程。(因此,在这种设计中,确保唯一标题的确具有唯一性至关重要。) /NH 选项去除了那些讨厌的标题,因此如果在执行前一个命令时生成的 bat 文件正在运行,它将向 stdout 返回类似如下内容: ``` cmd.exe 9736 Console 1 4,996 K ``` 这显示了镜像名称、PID、会话名称、会话编号和内存使用情况。我们对大多数信息不感兴趣,但我们确实关心的是这个条目与该进程未运行时该命令返回的内容的对比: ``` INFO: No tasks are running which match the specified criteria. ``` 显而易见的区别会告诉我们生成的 bat 文件是否仍在运行。可以使用 for /F 命令提取第一个标记进行检查;如果是 *cmd.exe*,则带有唯一标题的 bat 文件正在运行;如果是 INFO:,则没有带有该标题的进程在运行。 在小心地确认挂起的进程后,以下 taskkill 命令将以精准的方式终止它。使用相同的 /FI 选项,并查找来自 tasklist 命令的相同 WindowTitle,确保我们不会过度操作并杀死其他进程: ``` taskkill /F /T /FI:"WindowTitle eq %uniqTitle%" ``` /F 和 /T 选项会强制终止生成的 bat 文件及其可能启动的任何子进程。 顺便说一下,这就是我结束海龟类比的地方。我们不想以任何方式伤害海龟。它们是世上最美丽、最迷人的动物之一,当然,蝙蝠除外。 现在让我们把这一切结合起来。 #### 自动终止并重启代码 清单 26-2 显然是书中最复杂的代码之一,但即便如此,我已经去除了大部分错误处理,并使用了硬编码的值,这些值实际上应该被参数化。我稍后会讨论这些问题,但假设 flakyExe 被分配给偶尔挂起的可执行文件,清单 26-2 满足所有规格。 ``` set spawnedBat=C:\Batch\Spawned.bat set uniqTitle=Spawned Bat - %date% %time% ❶ > %spawnedBat% echo setlocal EnableExtensions EnableDelayedExpansion >> %spawnedBat% echo title %uniqTitle% >> %spawnedBat% echo %flakyExe% >> %spawnedBat% echo goto :eof set totHangs=0 ❷ :Restart set totSleep=0 ❸ start /MIN %spawnedBat% timeout 1 ❹ for /F usebackq %%i in (`tasklist /FI:"WindowTitle eq %uniqTitle%" /NH`) do ( if /i "%%i" equ "INFO:" ( call :Abort "%uniqTitle% Not Spawned" ) ) ❺ :WaitMore timeout 10 set /A totSleep += 10 ❻ for /F usebackq %%i in (`tasklist /FI:"WindowTitle eq %uniqTitle%" /NH`) do ( if /i "%%i" equ "cmd.exe" ( ❼ if %totSleep% lss 60 ( goto :WaitMore ) else ( ❽ taskkill /F /T /FI:"WindowTitle eq %uniqTitle%" set /A totHangs += 1 ❾ if !totHangs! lss 4 ( goto :Restart ) else ( call :Abort "%uniqTitle% - 4 Hangs" ) ) ) ) ❿ > con echo The Spawned Bat Has Completed. pause goto :eof :Abort > con echo Aborting: %~1 > con pause exit ``` 清单 26-2:启动最多三次自动终止并重启 flaky 可执行文件的代码 这段代码的主要部分围绕着生成的 bat 文件展开:创建它、启动或执行它,以及监控它。我将更仔细地查看每个部分。 1. 创建 *Spawned.bat*:使用 第二十五章 中的 bat 创建 bat 技巧,我通过四个 echo 命令 ❶ 创建了 *Spawned.bat*。这是一个简单的 bat 文件,除了定义一个唯一标题 uniqTitle 并调用 flaky 执行文件外,几乎没有做其他事情。甚至没有错误处理。 完整的 *Spawned.bat* 内容可能如下所示,具体取决于 date、time 和 flakyExe 的内容: ``` setlocal EnableExtensions EnableDelayedExpansion title Spawned Bat - Mon 11/01/2004 10:30:59.33 C:\Batch\Flaky.exe goto :eof ``` 我们在创建生成的 bat 文件之前为唯一标题赋值,以便两个 bat 文件都知道它是什么。通过使用 uniqTitle 变量中的日期和时间,我假设不会有两个进程在百分之一秒内被调用,但理想情况下,每次执行 bat 文件时应该有一个唯一的变量可以添加到标题中。(为了本示例,我假设这个标题是真正唯一的。) 2.  启动 *Spawned.bat*:目前 :Restart 标签 ❷ 对代码没有影响,但最终我们会使用它在终止挂起后重新启动进程。我在标签之前将挂起的总次数 totHangs 初始化为 0,确保重启不会重置它。我还将总睡眠时间(以秒为单位)totSleep 设置为 0,但它在标签之后。如果此值累积到 60 秒的等待时间,我将启动重启并重新执行此命令,从而重新初始化 totSleep 为 0。 start 命令 ❸ 启动、生成或独立于主进程启动动态创建的 bat 文件。为了不让桌面被额外的窗口淹没,/MIN 选项会立即最小化生成的窗口。 为了验证生成的 bat 文件是否已启动并运行,我会暂停一秒钟,给它时间执行其标题命令,从而让这段代码能够找到它。这假设生成的进程会至少运行一秒钟。(如果有丝毫可能不成立,我可以在生成的 bat 文件末尾添加 timeout 1 命令,确保它至少运行一秒钟。) for 命令 ❹ 的输入与前面详细介绍的 tasklist 命令相同,默认只将第一个令牌传入代码块。如果该令牌等于 INFO:,说明出现问题——生成的 bat 文件尚未启动,而且距离它完成还为时过早——我们会转到中止例程并传递适当的错误信息;否则,我们继续执行。 3.  监控 *Spawned.bat*:最后,我们进入核心逻辑。另一个 timeout 命令 ❺ 按照我们的规范睡眠 10 秒,并将这 10 秒加到 totSleep 中,表示等待生成的 bat 文件完成的总时间。接下来,另一个与之前相似的 for 命令 ❻ 执行,但代码块现在查找第一个令牌是否等于 *cmd.exe*,这表示该进程仍在运行。如果是,并且如果评估下一个 if 命令 ❼ 时显示我们还没有等待 60 秒,那么 goto 命令会返回到 :WaitMore 标签 ❺。 当总等待时间达到或超过 60 秒 ❼ 时,taskkill 命令 ❽ 会终止生成的 bat 文件。我们增加并检查挂起的总次数 totHangs。经过四次尝试 ❾ 后,如果仍然没有解决问题,就会发生更大的问题,因此我们启动中止操作。否则,我们会返回 :Restart 标签 ❷,重新启动整个过程。 请注意,代码中包含了两个重叠的 do...while 命令,这是 第九章 中介绍的技术的一种变体。较简单的命令会等待 10 秒钟,直到过去一分钟(:WaitMore ❺❼)。另一个命令则在遇到挂起时重新启动整个进程(:Restart ❷❾)。 如果一切顺利,for 命令 ❻ 中嵌入的 tasklist 命令最终将找不到带有特定标题的 *cmd.exe* 实例。这表明生成的 bat 文件已经完成,我们将继续执行 echo 命令来记录它的完成 ❿。 #### 测试 如果测试一个发生频率为 1% 的间歇性故障已经很困难,那么测试一个发生频率为 0.1% 的间歇性挂起几乎是不可能的,除非进行一些干预。要修复挂起问题,我们首先需要创建一个用于测试的挂起。你可以编写一个偶尔执行无限循环的程序(每个程序员在不经意间都会做过这个事情)。但由于这是一本关于批处理的书,让我们编写一个 bat 文件,让它在大约 20% 的情况下进入无限循环。*OccasionalHang.bat* 就是做这个的;以下是它的完整内容: ``` timeout 15 set /A rand = %random% %% 5 :EndlessLoop if %rand% equ 0 goto :EndlessLoop exit ``` timeout 命令模拟了一个运行 15 秒的程序。如果没有这个命令,bat 文件通常会在短短的几分之一秒内完成。set /A 命令通过对随机伪环境变量(第二十一章)进行取余操作,捕获 0 到 4 之间的伪随机数,并将其存储在 rand 变量中。如果 rand 大于 0,我们就成功退出 bat 文件,但大约每五次中会有一次 rand 等于 0,这时我们会进入无限循环。如果 rand 等于 0,goto 命令会不断回到上一行的 :EndlessLoop 标签,无法逃脱。要让这个 bat 文件更频繁地挂起,你可以调整取余操作,例如 %% 2 会让程序大约一半的时间挂起。 要调用这个 bat 文件而不是那个不稳定的可执行文件,只需在主 bat 文件中输入以下命令,放在 Listing 26-2 中代码之前: ``` set flakyExe=call C:\Batch\OccasionalHang.bat ``` call 命令是变量值的一部分,因此当 flakyExe 被解析时,解释器会调用 *OccasionalHang.bat*。 #### 现实世界的调整 尽管 Listing 26-2 的内容非常复杂,我还是省略了一些错误处理,并且在通常情况下不会使用硬编码值,所有这些都是为了专注于自动终止和重启的逻辑,但我有一些关于现实应用的调整建议或改进。 代码中有三个硬编码的值应该是变量。我硬编码它们纯粹是为了可读性,但我应该像这样设置休眠间隔、最大挂起次数和最大休眠时间,使用可重写的默认值: ``` if not defined sleepIntrvl set sleepIntrvl=10 if not defined maxHangs set maxHangs=4 if not defined maxSleep set maxSleep=60 ``` 就像自动重启一样,这些变量应该替换代码中的硬编码数字。如果用户想更改任何这些值,可以在执行逻辑之前使用 set 命令进行更改。 我之前提到过,你可能可以根据输入文件的大小预测进程的预期执行时间。以下代码将最大休眠时间设置为基准的 30 秒,并根据输入文件中每 1,000 字节的数据增加 1 秒,前提是没有人已经定义过它: ``` if not defined maxSleep ( for %%s in (C:\Batch\InputFile.txt) do ( set /A maxSleep = 30 + %%~Zs / 1000 ) ) ``` 你可以通过一些分析,比较文件大小和典型运行时间来微调这个算法。 有时候,你可以通过查看预期的输出文件来识别程序是否挂起。如果经过了这么多秒文件仍然没有出现,你可能已经知道进程挂起了。你可以定期捕获文件的大小,如果在某个时间段内它没有变大,这也可能是挂起的一个迹象。进行一些小的分析将极大地提升进程的效率,找到在不终止一个良好但长时间运行的执行与仅暂停必要时间之间的平衡。 另一个可能的调整是增加最后一次尝试之前的最大休眠时间。即使在我们处理的这个场景中做了所有分析,一个执行可能确实需要超过一分钟。如果你已经在每次 60 秒后终止了进程三次,允许最终尝试有 2 分钟的时间也许并不是坏主意。如果尝试次数和最大挂起次数相等,只需将 maxSleep 乘以 2。如果未来的日志显示多次失败最终在最后一次尝试中成功,你可以重新评估最大休眠时间。 如果挂起现象似乎发生在两次运行同时启动时,很可能是一个竞争问题。如果我们在相同的时间表上终止并重启这两次运行,它可能再次发生,因为它们仍然同步。一种解决方法是在终止后稍等几秒钟。错开休眠时间可能会有些棘手,因为如果它们在同一时刻启动,两个 bat 文件中的随机伪环境变量也会同步,但通过一些努力,你可以理想地在每次执行中找到一个唯一的数字。 清单 26-2 中的一个明显遗漏是生成的 bat 文件内部的错误处理。主 bat 文件中的代码可以告诉我们生成的 bat 文件何时完成,但它无法知道是否成功执行。这就相当于运行一个程序但不检查返回码。一个解决方案是让生成的 bat 文件将 errorlevel 甚至状态信息写入一个小文件。主逻辑然后可以读取该文件并根据其内容继续执行。 一个良好的设计会使最终产品更易用、更稳定。这种类型的进程真的需要非常详细地思考,考虑到在两个线程同时运行时可能发生的所有情况。说到线程... ### 多线程 在进入下一章之前,让我们将我们用来构建自动杀死和重启过程的组件,重新构思成完全不同的东西:*多线程*或*并发*。 这是 Batch 的另一个功能,虽然它的创建者并未预见到,但后来由开发者实现。要完全实现其他语言中的多线程,我们需要执行三项任务。首先,我们需要创建两个或更多的被称为*线程*的进程。第二,我们需要监控这些进程,确定每个进程何时完成。第三,我们需要允许这些创建的进程彼此之间以及与主进程进行通信,甚至共享数据。 你可以通过本章所学完成前两项任务。`start`命令将启动或创建任何数量的其他批处理文件,我现在将这些称为线程。你可以使用`title`和`tasklist`命令跟踪具有唯一名称的线程,就像你跟踪容易挂起的进程一样。唯一的区别是,现在你将启动并监控多个进程,而不仅仅是一个。如果进程的数量可能变化,你可以将它们保存在一个数组中。(我将在第二十九章中讲解数组。)带有内置多线程工具的语言通常提供一种方法,能在一定时间后杀死所有仍在运行的线程,你也可以在 Batch 中使用`timeout`和`taskkill`命令实现同样的功能。 诚然,完全实现的一个难点是主进程和线程之间的通信能力。毫无疑问,不存在可以被多个线程访问的异步 Batch 代码块,但某些通信是可能的。每个线程可以在一个以其标题命名的简单文本文件中记录其状态和高层信息,如已处理记录的数量。由于主进程也知道每个线程的标题,主进程可以在每个线程完成时,甚至在每个线程执行时找到并处理这些信息。 多线程是一种很棒的技术,可以将耗时的过程拆分成更易管理的块,通过一些努力,你可以在 Batch 中实现这一点。 ### 总结 开发人员可以将所有间歇性故障和挂起情况按从恼人到灾难的等级进行评分。在这一章中,你学习了一些高级 Batch 编程技巧来缓解这些问题。我详细介绍了自动重启和自动杀死与重启过程的设计、规范、编码,甚至是测试。你还学习了一些新的有用命令,以及对之前讨论的命令进行的新应用,甚至介绍了多线程。 最重要的是,我希望你能理解构建这些解决方案所需的细致入微的技巧。即使在深入讨论具体解决方案的细节之前,我们也需要进行大量分析,仅仅为了确定自动重启是否适用。并且记住,这是真正的修复,而不是一个“修复”。 在下一章中,我将深入探讨一个听起来应该是第四章中非常简短一节的主题,即在 if 命令的条件语句中使用 and 和 or 运算符。我在这里不剧透为什么这是第三部分的内容。 ## 第二十七章:27 与/或 运算符 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在这一章中,我将讨论可能是 Batch 中最明显的缺陷。它不支持与运算符,也没有或运算符。简要介绍问题后,我将详细描述一些模拟或模仿这两个运算符的技巧,处理真值和假值的情况。与运算符相对简单,但或运算符则需要更多的创造力。 和 Batch 的情况一样,你可以对这个缺陷感到沮丧,也可以将其视为创造力的灵感。这些技巧是每个 Batch 程序员必备的,因为没有它们,你就无法编写出即使是稍微有趣的 if 命令。 ### 问题陈述 有一个特定的对话,是每个学习 Batch 的人必经的洗礼: *Bobby*:我有个语法问题。我正在尝试用与运算符编写 if 命令——你知道的,如果 variable1 等于 A 且 variable2 等于 B——这不复杂。我本以为语法就是简单的*and*这个词。结果不行,所以我尝试了一个和符号,再加上两个和符号。我知道 Batch 可能不太直观,所以一定有某种奇怪的与运算符语法,可能是一个带有波浪符号的@符号,或者类似的胡说八道。那么,我该怎么写? *Jack*:抱歉,Batch 没有与运算符。 *Bobby*:认真的吗?这没有任何意义。 *Jack*:真的,没有。 *Bobby*:你是在逗我吧?每种有 if 命令的编程语言都有与运算符。我觉得这应该是有规定的。 *Jack*:不, 不是所有语言都有。我现在想不起来其他语言,但 Batch 在这一点上并没有随大流。 *Bobby*:这就像卖一辆不能左转的车。那该怎么办? *Jack*:走三次右转。哦...顺便说一下,或运算符...也不是回事。 *Bobby*:我还是不确定你是不是在耍我。离愚人节还远着呢,我真的需要让这个东西工作。有什么建议吗? *Jack*:我同意,Batch 应该有这些运算符,但这也是创造力派上用场的地方。任何人都可以在 Java 或 Perl 中编写与运算符,但在 Batch 中做到这一点,会让你有一种巨大的自豪感和成就感。至于或运算符,它更有趣——或者说,从你的角度看,可能更成问题。 我还记得在这段对话中扮演新手的角色,之后我在对立的角色中无数次体验过似曾相识的感觉。我担心有些人会觉得我夸大了这个问题,但它确实让很多人困惑。这个与运算符如果两个比较都为真时,**确实**不会返回真: ``` if "%var1%" equ "A" and "%var2%" equ "B" ( > con echo This is junk code. ) ``` 这并不意味着代码块中的命令永远不会执行;实际上,解释器通常会在丢弃无效的 if 命令后执行它。更复杂的是,无效的命令可能会将错误信息输出到 stderr,也可能不会,错误级别(errorlevel)可能是非零值,也可能不是。 这个*无效*的或运算符也是如此: ``` if "%var1%" equ "A" or "%var2%" equ "B" ( > con echo This is more junk code. ) ``` 如果其中一个或两个比较为真,它不会返回真。 但够了,先不谈那些不可行的方法。针对这些操作符,还有一些优雅或实用的解决方案。 ### 复制`and`操作符 编写绕过缺少`and`操作符的第一个且最明显的技巧是使用嵌套的`if`命令: ``` if "%var1%" equ "A" ( if "%var2%" equ "B" ( > con echo Nesting works but is oh so uninspired. ) ) ``` 如果条件语句中的任何一个使用了`exist`关键字来判断资源是否存在,或使用`defined`关键字来判断变量是否已定义,那么这是唯一可能的解决方案。但如果你要检查多个变量是否与常量或其他变量相等,我将分享一个更加优雅的解决方案。以下的`if`命令,将两个解析后的变量放在等号操作符的一侧,两个值放在另一侧,其功能上与前面示例中的嵌套命令是等效的: ``` if "%var1%-%var2%" equ "A-B" ( > con echo This if command has an AND operator even if the AND is implied. ) ``` 等式的左侧,%var1%-%var2%,包含了三个部分:第一个变量的解析结果、一个破折号以及第二个变量的解析结果。如果两个变量分别设置为 A 和 B,那么"%var1%-%var2%"会解析为"A-B",并且相等。如果任一变量(或两者)设置为其他任何值,则条件判断为假。 破折号分隔符有两个重要的作用。如果没有分隔符,当其中一个变量解析为 AB 而另一个变量解析为空时,我们会错误地认为它们相等。而且,使用分隔符使得代码更易读,特别是当比较变得更复杂时。例如,以下两个`if`命令——每个包含三个比较——非常相似,但你更愿意阅读哪一个? ``` if /i "%writer%%coworker%%admin%" equ "jackbobbysteve" ( > con echo These variables and values are smushed together. ) if /i "%writer%-%coworker%-%admin%" equ "jack-bobby-steve" ( > con echo We can all agree this is far more readable. ) ``` 我使用了破折号作为分隔符,但几乎任何简洁的字符都可以使用。点(`.`)也是一个不错的选择,但最重要的是,你选择的分隔符应该是一个你不希望出现在数据中的字符。(虽然我不太愿意触及这个话题,但条件语句`"%A%-%B%" equ "%X%-%Y%"`在 A 和 Y 被设置为破折号且 B 和 X 为空的情况下会错误地判断为真。虽然从理论上讲这是一个问题,但如果你了解你的数据并明智地选择分隔符,在实际应用中这不会成为问题。) 还要注意,前面列表中的两个比较是不区分大小写的。你可以通过添加`/i`选项对任何多重比较进行类似的操作,但它是全局生效的——也就是说,它会应用到示例中的所有三个比较。如果你需要进行大小写敏感与不敏感混合的比较,嵌套结构是你最好的选择。 在大多数其他语言中,上述逻辑需要两个`and`操作符。而在 Batch 中,你可能会通过三个嵌套的`if`命令来实现,但将操作数通过破折号连接的技巧,比即便是使用真实的`and`操作符要简洁且易读。 ### 复制`or`操作符 `or`操作符的复杂性与`and`操作符的简单性截然不同。我将展示最适合不同情况的技巧。 #### 将一个变量与多个值进行比较 或运算符的一个常见应用是确定一个变量的内容是否等于两个或更多值中的一个。例如,你可以从一个州的邮政编码中获得很多信息。如果它等于 WA、OR 或 CA,那么该州位于美国本土的太平洋海岸;而任何一个包含在 10 个值中的邮政编码则表示该州位于密西西比河上。 为了构建一个或运算符来查找分配给单个变量的多个值中的一个,我将再次使用非常有用的 for 命令: ``` for %%p in (ND SD) do ( if "%postalCode%" equ "%%p" ( > con echo This is an OR operator of a variable and multiple values. ) ) ``` echo 命令只有在变量表示一个达科他州的邮政编码时才会执行。 for 命令会执行其代码块两次,分别传递 ND 和 SD 作为 %%p 变量。代码块仅包含一个 if 命令,它将 postalCode 的内容与作为 for 变量传递的内容进行比较。因此,if 命令的第一次执行会检查变量是否解析为 ND,第二次执行会将 postalCode 的值与 SD 进行比较。如果变量等于 ND 或 SD,if 命令会评估为 true,从而触发其代码块的执行。从本质上讲,这就是一个 Batch 或运算符。 上一个例子是针对两个可能的值,但由于 for 命令接收一个由空格分隔的列表,你可以传递任意数量的值。以下是一个 Batch 或运算符匹配六个名称中的任何一个的例子: ``` for %%p in (Cleese Gilliam Jones Chapman Idle Palin) do ( if /i "%name%" equ "%%p" ( > con echo %name% is a Python ) ) ``` 一个单一的变量,如 postalCode 或 name,不能同时具有两个当前值,这意味着使用这种技术时,if 命令中的条件子句最多只能评估为一次 true。这对其他变种的或运算符并不适用,因此其他考虑因素和修改会发挥作用。 #### 将多个变量与一个值进行比较 让我们反转上面的例子,将多个变量与一个硬编码的值进行比较。假设某个特定的程序有两个函数,如果其中一个变量设置为某个共同值,我们希望执行该函数。 在这个例子中,for 命令使用两个变量的解析值作为输入,并将它们传递给 if 命令,然后将它们与 A 进行比较: ``` for %%i in (%var1% %var2%) do ( if "A" equ "%%i" ( > con echo This OR operator compares a value to multiple variables. goto :OrDone ) ) :OrDone ``` 在这种情况下,两个变量都有可能等于 A。如果两个变量都为 true,正确的或运算符将不会在 if 命令的代码块中执行两次代码。为了模拟这种行为,我们必须在第一个 true 条件满足并执行代码块后跳出逻辑,这可以通过 goto 命令将控制转到 for 循环之后的标签来实现。 很多时候,执行代码块多次是完全可以接受的。例如,如果你在条件为真的时候设置一些变量,重新设置它们为相同的值也不会有什么问题,在这种情况下,你可以通过移除 goto 和标签来简化代码。不过,即便如此,这段代码在所有情况下也并非万无一失。 上一个示例中的技术假设变量不包含任何嵌入的空格。由于 for 命令接受一个以空格分隔的列表,解释器将把一个包含空格的单一值当作两个不同的值来处理。下面的清单考虑到了这一限制: ``` for %%i in ("%var1%" "%var2%") do ( if "A" equ "%%~i" ( > con echo This OR operator compares a value to multiple variables. goto :OrDone ) ) :OrDone ``` 我已经把每个解决的输入变量用双引号括起来,确保我们将每个变量整体传递到代码块中。为了处理我刚刚添加的部分,我还在 for 变量的解析中加上了波浪线,以便在比较时去掉这些双引号:%%~i。 仅仅是这些变量中的一个内容中有双引号就可能会破坏这段代码。了解你的数据。 #### 比较多个变量和值 之前的示例展示了常见但相对狭窄的情况。它们不适用于包含或运算符的更复杂条件子句,这些子句需要比较多个变量与不同的硬编码值,或是比较不同的变量之间的关系。 作为一个具体的例子,某个期望的条件子句可能会在一个变量等于特定值或第二个变量等于第三个变量时返回 true。也就是说,我们可能会尝试执行如下操作,但同样,这在批处理(Batch)中*不*会生效: ``` if "%var1%" equ "A" or "%var2%" equ "%var3%" ( > con echo This is one last example of junk code. ) ``` 有两种方法可以模仿这种或运算符的变体: **"else if" 解决方案** 通过一些蛮力,你可以先评估第一个条件,然后使用 第四章中的 else if 结构来评估后续条件: ``` if "%var1%" equ "A" ( > con echo This executes for only one of multiple conditions. ) else if "%var2%" equ "%var3%" ( > con echo This really needs to be the same code as what's above. ) ``` 这段代码可以工作,但有一个我在代码中已经暗示的重大缺点。如果 if 命令中的条件子句为真,你必须重复执行要执行的代码块。如果这段代码只有一条语句,那也许还可以接受,但如果逻辑更加复杂,甚至是两三行代码,代码就会变得非常混乱。在这种情况下,最好将那段代码块放到一个带标签的方法中,并从多个位置调用它: ``` if "%var1%" equ "A" ( call :CommonLogic ) else if "%var2%" equ "%var3%" ( call :CommonLogic ) ``` :CommonLogic 例程可能包含一些复杂的逻辑并位于 bat 文件的其他地方,但如果它仅仅是少量的命令,我建议将它直接放置在 else if 结构之后,放入一个始终为假的 if 命令中。(有关这种技术的更多信息,请参见 第二十章。提示:if 0 equ 1。)然而,如果代码块真的不值得拥有自己的方法(或者即使它值得),还有另一种值得探索的技术。 **嵌套的 **for** 命令解决方案** 实现或操作符的最后一种技巧并不简单,但它很优雅,我已经多次使用过。它模拟了两个项目的多次比较,只要有一个匹配就满足条件,但这两个项目可以是任何组合的已解析变量和硬编码值。它甚至可以处理任何值中的嵌入空格。 在这个解决方案中,我将 if 命令包裹在两个嵌套的 for 命令中: ``` for %%i in ("%var1%:A" "%var2%:%var3%") do ( for /F "tokens=1-2 delims=:" %%j in ("%%~i") do ( if "%%j" equ "%%k" ( > con echo A complex OR conditional clause has evaluated to true^^! goto :IfOrDone ) ) ) :IfOrDone ``` 外部 for 命令的输入是一个以空格分隔的由冒号分隔的值对集合,其中每一对值都被双引号括起来。这意味着外部代码块的每次执行都会将由冒号分隔的一对值解析为%%~i。值对可以是已解析变量和硬编码值的组合,比如第一个值对:%var1%:A。相比之下,第二个值对展示了另一种可能性,即两个已解析的变量:%var2%:%var3%。 内部 for /F 命令依次接受每一对由 %%~i 解析的值,并将其视为字符串输入,因为它们被双引号括起来。(请注意,“%%~i”会去掉双引号再加回。虽然我本可以使用%%i,但显式的双引号使得输入给 for /F 命令的是一个字符串这一点更加明确。) tokens 和 delims 子句通过冒号分隔符将值对分割成 %%j 和 %%k 令牌(第十九章)。最后,if 命令比较它们是否相等,如果为真,则执行代码块。 我正在使用之前采用的相同技术来跳出逻辑,以免它多次执行。再次说明,如果没有问题让代码块执行多次,你可以省略 goto 命令和标签。如果比较的值中可能包含冒号,你可以选择不同的分隔符。同样,如果某个值中可能包含双引号,你可以去掉输入列表中每对值周围的双引号,但这样会暴露数据中的空格和特殊字符。 当你掌握了它的工作原理后,再用全新的视角重新审视并问自己,这看起来像是一个带有或操作符的 if 命令吗?有些人可能会觉得它很晦涩,明确来说,确实需要一个解释性备注,但我在这里展示的代表了你可能遇到的最全面、复杂的 Batch 或操作符。 ### else 关键字 还有一个与任何 if 命令相关的最后一个重要话题我们不能忘记:else 关键字。我已经讨论过当多个条件都为真或至少一个条件为真时执行一个代码块,但通常你会希望在最终的与或或条件为假时执行另一个代码块。传统上,这就是紧随 else 关键字之后的代码块。 使用多个已解析变量和硬编码值串联在一起的模拟与操作符,适合使用 else 代码块: ``` if "%var1%-%var2%" equ "A-B" ( > con echo Both are true. ) else ( > con echo One of these variables doesn't match the value, or both don't. ) ``` 这也适用于 else if 结构,可以模拟“或”运算符。但你只能在其他情况中模拟 else 关键字,我将在这里分享两种非常有用的方法: **抢占性行动法** 执行 else 逻辑的最简单方法是先抢占性地执行它: ``` set result=NoMatchFound for %%p in (ND SD) do ( if "%postalCode%" equ "%%p" ( set result=Match ) ) ``` 显然,只有在找到匹配项后,能够轻松撤销抢占性逻辑时,这才有效。如果 else 逻辑是复制或删除文件,这个技巧就没什么价值,但如果逻辑仅仅是将变量设置为两个值之一,你可以先执行 else 逻辑,如果 if 命令返回 true,则撤销它。本质上,这就是 if...else 结构。 **分支跳过法** 另一种方法是利用标签前的那一小段空间,在第一次匹配时跳出代码块: ``` for %%p in (ND SD) do ( if "%postalCode%" equ "%%p" ( set result=Match goto :OrDone ) ) set result=NoMatchFound :OrDone ``` goto 命令已经确保它上方的逻辑只会执行一次,但现在它也会在 for 命令完成后跳过紧接其后的逻辑。如果该逻辑找到一个匹配项,它会将变量设置为 Match,但如果 if 命令在输入列表耗尽后没有找到匹配项,控制流才会转到 set 命令,将变量设置为 NoMatchFound。 这种方法提供了更大的灵活性。你可以在找到匹配项时调用一个程序,如果没有找到,则删除一个目录。代码并不做任何抢占性操作,因此不需要撤销任何东西。因此,分支跳过法是更接近 if...else 结构的真正形式。由于 else 逻辑位于最后,它看起来更像传统结构。看到足够多的这种写法后,你会开始把那两个闭括号视为一种 else 关键字。 ### 总结 在本章中,我详细介绍了几种模拟条件语句中的“和”运算符的方法,你也学习了多种模拟“或”运算符的技巧,以满足多种不同的情况。我还演示了模拟 else 关键字的方法,用于执行传统上在条件语句为假时执行的逻辑。 确实如此,本章讨论的技巧在大多数编程语言中完全不必要,但对于任何想编写复杂逻辑的 Batch 编码者来说,它们是必需的。它们也尽可能优雅地填补了 Batch 世界中最显著的空白。 Bobby 不应因尝试使用&&作为“和”运算符而受到责备;如果他尝试使用||作为“或”运算符,也不应受到责备。在其他编程语言中,&&和||常被用于这些目的。我还没有提到过,但&&和||运算符确实在 Batch 中有其用武之地,只不过与完全不同的话题——条件执行相关。在第四章中,我详细介绍了最常用的条件执行技巧(if 命令),而在下一章中,我会回到这个话题,分享一些鲜为人知的替代技巧。 ## 第二十八章:28 紧凑型条件执行 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在本章中,我将讨论条件执行... 但不,这不是一章关于 if 命令的内容。的确,if 命令是条件执行代码的经典示例,本章将从第四章重新审视这一讨论,但会有一个非常不同的角度。批处理有一个叫做*条件执行*的特殊构造,它基于前一个命令的成功或失败来执行一个或多个命令。这是 if...else 结构的一个紧凑且精简的替代方案,但也有显著的区别,理解这些差异在使用前至关重要。 我将介绍条件执行语法中使用的两个运算符,你会看到一些类似但不完全相同的内容,类似于 if...else 结构。我将演示如何依次执行多个命令,在这些命令中,如果任何一个命令失败,其余命令将不会执行。反转脚本后,我将展示如何依次执行多个命令,如果任何一个命令*成功*,则其余命令不会执行。 ### 条件执行运算符 在许多编程语言中,双安培符号(&&)是“与”运算符,双管道符号(||)是“或”运算符,在第二十七章中,我详细说明了批处理(Batch)不支持这些功能。相反,条件执行语法使用双安培符号和双管道符号作为运算符,这对于熟悉其他语言的程序员来说可能会产生一定的认知失调,但这些运算符提供了一种简洁且独特的替代方法,代替了 if 命令: **“&&” 运算符** 在以下的通用语法中,命令 1 总是执行,而命令 2 仅在命令 1 完成后,`errorlevel`的值为 0 时才会执行: ``` command1 && command2 ``` 单个安培符号分隔的两条命令不加条件地执行,但额外的安培符号会触发条件逻辑。可以称之为“正向条件执行运算符”。 **“||” 运算符** 将安培符号替换为管道符号会改变逻辑。在以下示例中,命令 2 仅在命令 1 执行完毕后,`errorlevel`的值*不是*0 时才会执行: ``` command1 || command2 ``` 单个管道符号(恰如其分)将一个命令的输出传递给另一个命令,但这里没有任何内容被传递到第二个命令,后者可能根本不会执行,因为它是负向条件执行运算符。 ### 使用单个运算符 作为 if 命令的替代方案,你通常可以使用单个条件执行运算符来简化代码,但首先需要比较和对比这两种方法。 #### 正向条件执行 为了演示,列表 28-1 提供了这个简洁易懂的示例,用于创建一个空文件并检查返回代码。 ``` copy nul C:\Batch\Empty.dat if %errorlevel% equ 0 ( > con echo Empty.dat Created Successfully ) ``` 列表 28-1:带有错误处理的空文件创建 使用条件执行,这一行代码在功能上是等效的: ``` copy nul C:\Batch\Empty.dat && > con echo Empty.dat Created Successfully ``` 如果 copy 命令创建了空文件并返回 0,echo 命令会将消息写入控制台。实质上,&&等同于 if %errorlevel% equ 0。如果 copy 命令未能创建空文件,它会返回非 0 的值作为返回代码,echo 命令则不会执行。 由于在条件执行语法中,errorlevel 的值仅为暗示,因此其值并不会出现在 stdout 中,这有时会让我们无法确定某些代码是否执行过。如果你需要在跟踪文件中找到返回代码的值,那么更为详细的选项是明智的选择。 条件执行语法可以是 if 命令的一个简洁(有些人可能会说是难以理解)替代方式,但当你将其重写为多行时,它更易于阅读,也更接近 if 命令。你可以通过在&&运算符后面放一个左括号来启动代码块。然后,你可以将一个或多个命令放入代码块中,最后用一个右括号结束它。以下代码在功能上等同于之前的两种写法: ``` copy nul C:\Batch\Empty.dat && ( > con echo Empty.dat Created Successfully ) ``` 与列表 28-1 进行对比,我减少了一行代码,并将 if 命令及其条件语句替换为&&运算符。代码显然被简化了,但是否简化取决于读者对条件执行的理解。 #### 否定条件执行 现在,让我们对这个逻辑进行一些修改,以实验否定条件执行运算符。首先,使用一个不存在的文件夹。(剧透:copy 命令未能创建空文件。)其次,将&&更改为||运算符,并使消息反映出失败情况: ``` copy nul C:\NonExistentFolder\Empty.dat || ( > con echo Empty.dat NOT Created ) ``` 现在,echo 命令只有在 copy 命令失败时执行,因为当 copy 命令失败时,它会将 errorlevel 设置为 1。如果命令成功创建文件,它会将返回代码设置为 0,并且解释器不会向控制台输出任何内容。 #### 现实世界中的应用 应用场景有很多;我将在这里详细介绍几个使用单个条件执行运算符的例子。 ##### 文本搜索 一种场景开始时,变量存储了一个程序名称。如果你能在其路径中找到特定的服务器名称,可能想要调用某个内部例程中包含的进程。 以下代码搜索 progName 的内容以查找 svrName 的值,如果找到服务器,则调用一个例程: ``` echo "%progName%" | findstr %svrName% && call :ServerFound ``` echo 命令将程序名称传递给 findstr 命令,如果在输入文本中找到服务器名称,findstr 会将 errorlevel 设置为 0。不要被单个管道符迷惑;那不是条件执行,而双&符号则确实代表了这种技术。 类似地,使用||运算符则会导致以下代码调用不同的例程,但只有在没有找到文本时才会调用: ``` echo "%progName%" | findstr %svrName% || call :ServerNotFound ``` 这个例子也鲜明地说明了管道符和双管道符之间的区别。 ##### 调用另一个 Bat 文件 有一个常见的条件执行用法是验证 `call` 命令,当参数是一个包含要调用的 bat 文件名称的变量时,但有一些常见的陷阱需要避免。 对于这个示范,`calledBat` 变量可能包含一个有效的 bat 文件的路径和名称,但它也可能包含垃圾数据,导致 `call` 命令失败。失败会触发解释器将 `errorlevel` 设置为 1,因此,如果你使用 || 运算符并配合以下错误处理,这是可以理解的: ``` call %calledBat% || ( > con echo Called Bat File Invalid: %calledBat% ) ``` 然而,如果 `call` 命令成功调用了 bat 文件,解释器并不会将 `errorlevel` 设置为 0——实际上,它根本不会改变返回码。这个可能是一个 bug,但这并不会改变程序员的困境。如果 `call` 命令成功,返回码将保存最后一个命令返回的值来更新它。 我之所以要提到这一点,是因为这很容易被忽视。你可以用一个错误的 bat 文件测试这段代码,它会正常工作——也就是说,它会输出错误信息。然后你可以用一个正确的 bat 文件再次测试,可能看起来会正常工作——也就是说,它不会输出消息——但它*之所以工作*,仅仅是因为在执行 `call` 命令之前,`errorlevel` 正好被设置为 0,这是你可能无法始终依赖的。早些时候,我曾建议不要在使用 `robocopy` 命令时使用条件执行,因为它的返回码不规范。我并没有建议在使用那些没有统一重置返回码的命令时(比如 `call` 命令)采取相同的做法,但我确实推荐一定程度的谨慎。使用这种类型的命令和条件执行时,潜在的、但可以纠正的问题是存在的。 返回码问题绝不应妨碍你使用这种技术。事实上,如果你在执行 `call` 命令后在传统的 `if` 命令中评估返回码时,同样的问题也会存在。记住,这种技术本质上是将 `errorlevel` 的值与特定值(即 0)进行比较的高级方式。要使其生效,你需要确保在 `call` 命令执行之前返回码的值为 0。以下 `cmd` 命令确保你在 `call` 命令执行前重置返回码: ``` cmd /C exit 0 call %calledBat% || ( > con echo Called Bat File Invalid: %calledBat% ) ``` 最后一个问题是,某些在被调用的 bat 文件中的命令可能会在完成后将 `errorlevel` 设置为非零值。不幸的是,这种错误处理不会将错误的返回码与失败的 `call` 命令区分开来。在这种情况下,这两者都会在 `call` 命令之后以非零 `errorlevel` 出现,从而可能导致被调用的 bat 文件中的一个不相关的失败错误,错误地触发 echo 命令,声明 bat 文件无效。 最佳的解决方案是两个 bat 文件之间达成协议。被调用的 bat 文件可以使用 `exit /B 0` 命令,确保在成功调用结束时始终返回 0。两个 bat 文件之间的错误处理可以采取另一种形式,也许是一个包含描述性错误信息的参数,其中被调用的 bat 文件将其设置为 null,以表示成功执行。 这是一个非常有用的工具,但你必须考虑在流程的不同阶段 errorlevel 的所有可能值。尤其在多个条件执行操作符联用时,这个建议更加重要。 ### 使用多个操作符 单独来看,这些操作符相当直观,但当你将它们组合在一起使用时,它就变得既有趣又有用。 #### 一个伪 if...else 结构 你已经了解到伪随机数并不是真正的随机数,但它接近随机数;同样,伪 if...else 结构并不是真正的 if...else 结构,但它很相似。下面的代码看起来与这种结构非常相似,而且行为上也很像: ``` copy nul C:\Batch\Empty.dat && ( > con echo Empty.dat Created Successfully ) || ( > con echo Failure to Create Empty.dat ) ``` 我用 `&&` 操作符代替了 if 命令及其条件子句;更奇怪的是,`||` 操作符取代了 else 关键字(看起来有点像一个失败的表情符号尝试,夹在括号的开闭之间)。在实际操作中,这通常表现得像一个 if...else 结构。复制命令尝试创建一个空文件;如果成功,第一个 echo 命令执行,如果失败,第二个 echo 命令会写出不同的消息。这正是 if...else 结构会做的,但这种技术有一个重大的 batveat。 如果第一个代码块中的某个操作在完成时将 errorlevel 设置为非零值,那么这个结构也会触发第二个代码块。在这个例子中,每个代码块由一个单独的 echo 命令组成,但很容易想象括号之间会有更复杂的逻辑。 这非常违反直觉;如果这段代码成功创建了*Empty.dat*,那么第一个 echo 命令会执行,但如果那个命令失败,那么可以认为是 else 代码块中的 echo 命令也会执行。一个可以执行两个代码块的 if...else 结构并不是一个真正的 if...else 结构。 在这个特定实例中,这种行为可能是完全可以接受的,因为一个简单的 echo 命令向控制台输出不太可能失败,如果失败,那几乎是不可能的,但任何复杂到足以重置 errorlevel 的操作放在第一个代码块中都是不明智的。因为有这个 batveat,我不常使用这种技术,但接下来的几个例子展示了它的真正用处。 #### 多个 && 操作符 条件执行在你连续执行多个类似的命令并希望统一处理错误时最为强大和有用。例如,你可以使用四个不同的 xcopy 命令将四个不同的文件复制到目标路径。目标可能是,如果其中任何一个复制操作失败,就终止执行,但并不关心是哪个失败。同时,我不想重复四次询问返回码。你可以使用两种不同的方法来实现,一种使用条件执行,另一种不使用条件执行。 作为条件执行的替代方法,这种方法将每次复制尝试返回的 errorlevel 值连接起来。为了演示,我将只在控制台写出一条错误信息,而不是中止操作。假设四个源文件和目标路径在代码的前面已定义,这个示例完成了任务: ``` xcopy %sorc1% %targ%\ /Y /F set cmlRC=%errorlevel% xcopy %sorc2% %targ%\ /Y /F set cmlRC=%cmlRC%%errorlevel% xcopy %sorc3% %targ%\ /Y /F set cmlRC=%cmlRC%%errorlevel% xcopy %sorc4% %targ%\ /Y /F if %cmlRC%%errorlevel% neq 0 ( > con echo One or more of the four copies FAILED ) ``` 在第一个 xcopy 命令之后,我将 cmlRC(累计返回码)设置为 errorlevel 的值。然后,我将接下来两个命令返回的 errorlevel 附加到 cmlRC 的末尾。最后,我将 cmlRC 的三个返回码与最后一个 xcopy 命令的 errorlevel 一起连接,并在 if 命令的条件句中将其与 0 进行比较。解释器足够智能,可以进行数字比较——四个零被视为与一个零相等。(如果条件句的每一边都被双引号包围,Batch 会将它们视为字符串,实际上是不相等的字符串——即,0000 等于 0,但"0000"不等于"0"。) 如果只有第二个 xcopy 失败,条件句的左侧可能会解析为 0400。由于它不等于 0,错误处理逻辑将启动。这个方法是可行的、可读的,并且在 Batch 语法中占有一席之地,但现在让我们将其与使用条件执行的解决方案进行比较。 语法有几种变体。第一种要求所有命令都在一行上,我们可以使用插入符号作为续行符来使其更具可读性,如 Listing 28-2 所示。 ``` xcopy %sorc1% %targ%\ /Y /F ^ && xcopy %sorc2% %targ%\ /Y /F ^ && xcopy %sorc3% %targ%\ /Y /F ^ && xcopy %sorc4% %targ%\ /Y /F ^ || > con echo Exactly one of four possible copies FAILED ``` Listing 28-2:多个&&操作符后跟一个||操作符 与之前的方法相比,条件执行可以大大简化逻辑,但它的直观性不强。 每个&&操作符将一对 xcopy 命令分开,这意味着如果第一个命令成功,第二个命令就会执行,如果第二个成功,第三个命令也会执行,且如果这些命令都没有失败,最后一个 xcopy 命令也会执行。代码最后一行前面的||操作符表示只有在任何一个 xcopy 命令失败后,并且立即执行,echo 命令才会执行。这是一个微妙且重要的点,导致这两种方法*并不*在功能上等效。 在连接方法中,所有四个 xcopy 命令会在我们检查完整的返回代码集之前执行,无论是否有早期的失败。在条件执行方法中,如果一个命令失败,执行会立即跳转到两个管道符后的错误处理部分。那么,它到底是怎么工作的呢? 假设第一条 xcopy 命令执行成功。在下一个 xcopy 命令前看到 && 运算符,解释器会检查返回代码。返回值是 0,因此第二条命令也会执行,但假设它因磁盘空间不足而失败。第三条命令前的 && 运算符再次告诉解释器检查返回代码。这次返回的不是 0;解释器会跳过第三条命令,直到找到第三个也是最后一个 && 运算符。由于 errorlevel 仍然非零,第四条命令不会执行。接下来,解释器会找到 || 运算符,并执行 echo 命令,恰恰因为 errorlevel 不等于 0。只有当所有四个 xcopy 命令都成功执行(即每个返回 0)时,echo 命令才不会执行。 所以,如果即使一个命令失败,执行所有命令仍然有意义,那么返回代码连接方法更为优先。但通常来说,如果一个失败的复制意味着你会中止执行,那么尝试其他复制操作没有任何意义。在这种情况下,不去尝试其他复制操作会更高效;因此,条件执行方法是最佳选择。选择最适合你情况的方式。 我之前提到过一种条件执行的替代语法。对于那些喜欢使用括号而非插入符号的用户,以下版本的代码在功能上与之前的示例相同: ``` xcopy %sorc1% %targ%\ /Y /F && ( xcopy %sorc2% %targ%\ /Y /F) && ( xcopy %sorc3% %targ%\ /Y /F) && ( xcopy %sorc4% %targ%\ /Y /F) || ( > con echo Exactly one of four possible copies FAILED ) ``` 哪个更容易阅读是一个有争议的问题。我喜欢两者,都没有强烈的意见(这本身可能和任何语法一样奇怪)。 > 注意 *当使用多个运算符进行条件执行时,我通常会将继续的行缩进两个空格,以便与代码块区分开来,代码块通常会缩进三个空格。有时我会将继续的行缩进超过三个空格,但我从不将其缩进正好三个空格。* #### 多个 || 运算符 让我们颠倒一下最后的场景。我们将使用相同的 xcopy 命令,并且我们不一定希望它们都执行。不同之处在于,我们只需要其中一个复制操作成功。也许某个资源文件有多个可能的位置,且有一个层级结构决定选择的顺序。如果我们成功复制了一个文件,我们希望跳过后续的复制尝试,并调用一些使用该文件的进程,但为了简化起见,echo 命令会简单地声明成功。 再次假设源文件和目标路径在代码前面已经定义,示例 28-3 中的代码至多复制一个文件。 ``` ( xcopy %sorc1% %targ%\ /Y /F ^ || xcopy %sorc2% %targ%\ /Y /F ^ || xcopy %sorc3% %targ%\ /Y /F ^ || xcopy %sorc4% %targ%\ /Y /F ) && ( > con echo Exactly one of four possible copies SUCCEEDED ) ``` 示例 28-3:多个 || 运算符后跟一个 && 运算符 与 Listing 28-2 相比,这里最显著的区别是多个||运算符分隔了 xcopy 命令,并且&&运算符位于尾部命令之前。一个更微妙的区别是,xcopy 命令构成了整个代码块。 假设源文件都不存在,我们逐步分析逻辑。第一个 xcopy 命令无条件执行,并且失败,设置 errorlevel 为 4;非零的返回码和第一个||运算符触发了第二个 xcopy 命令,该命令也失败;两个更多的||运算符和两次失败导致最后两个 xcopy 命令执行并失败。最后一次失败将 errorlevel 设置为 4,控制流退出第一个代码块。解释器立即找到&&运算符,快速检查 errorlevel,发现它不是 0,于是*不*执行 echo 命令。 当其中一个复制成功时,事情变得更加有趣。假设第一个 xcopy 命令成功地将 sorc1 表示的文件复制到目标路径。由于返回码良好和第一个||运算符,解释器不会执行下一个 xcopy 命令。此外,它甚至不会识别第三个和第四个命令。当你考虑到这四个 xcopy 命令实际上都是同一行继续代码的一部分时,这就更容易理解了。一旦解释器确定在||运算符之后的命令不会执行,之后的任何命令都不会执行。 更有趣的是,控制流在第一个成功复制之后退出第一个代码块,解释器找到了&&运算符。由于第一个 xcopy 命令成功,errorlevel 等于 0,因此包含 echo 命令的代码块会执行。 如果第一个 xcopy 命令失败,而第二个命令成功,第三个和第四个命令不会执行,因为第二个||运算符,但 echo 命令会执行,因为第一个代码块之后跟着&&运算符。同样,如果第三个或第四个 xcopy 命令是第一个成功复制文件的命令,echo 命令也会执行。 括号在这个例子中至关重要。在前面的部分,四个命令通过&&运算符分隔,最后一个命令在||运算符之后执行。在这个例子中,运算符被反转,展示了它们之间的显著差异。我稍后会解释括号为何存在,但首先,由于使用这种条件执行语法的主要原因之一是为了简化代码,注意下面这五行代码在功能上等同于之前的列表: ``` (xcopy %sorc1% %targ%\ /Y /F ^ || xcopy %sorc2% %targ%\ /Y /F ^ || xcopy %sorc3% %targ%\ /Y /F ^ || xcopy %sorc4% %targ%\ /Y /F ) && > con echo Exactly one of four possible copies SUCCEEDED ``` 我不太喜欢这种语法,因为它确实违背了我关于裸代码块的括号对齐惯例(第十六章)。不过,它简洁明了,但我发现有更多空格的版本更具可读性。 这里是相同的基本逻辑,使用伪`if...else`结构来写一条消息,表示复制一个文件的成功或失败: ``` ( xcopy %sorc1% %targ%\ /Y /F ^ || xcopy %sorc2% %targ%\ /Y /F ^ || xcopy %sorc3% %targ%\ /Y /F ^ || xcopy %sorc4% %targ%\ /Y /F ) && ( > con echo Exactly one of four possible copies SUCCEEDED ) || ( > con echo One or more of four copies FAILED ) ``` 这里有一个必要的警告,如果第一个`echo`命令以某种方式将错误级别重置为非零值,那么第二个`echo`命令也会执行。 #### 多个`&&`与多个`||`运算符 我承诺在 Listing 28-3 中解释为什么在使用多个`||`运算符时需要括号,但在 Listing 28-2 中使用多个`&&`运算符时不需要。 要理解这一点,首先看一下这个由多个通过`&&`运算符分隔的命令以及后续`||`运算符组成的通用语法: ``` command1 && command2 && command3 && command4 && command5 || command6 ``` 假设`command1`返回 0。如果是这样,`command2`会执行;假设它执行失败并返回非 0。由于`&&`运算符在`command3`之前,解释器不会执行该命令,而是跳过它,继续寻找另一个`&&`运算符;错误级别仍然非 0,因此它也跳过`command4`。它又找到了一个`&&`运算符,所以`command5`不会执行。但接下来解释器找到了`||`运算符,第二条命令返回的非 0 错误级别触发了`command6`的执行。 现在让我们交换每个条件执行运算符。这更像是我们想让一个命令成功执行,但不超过一个的场景: ``` command1 || command2 || command3 || command4 || command5 && command6 ``` 假设这次`command1`返回的不是 0。由于`command2`之前有`||`运算符,第二条命令也会执行;现在假设它成功执行并返回 0。由于后面的`||`运算符,逻辑流程会跳过`command3`。这就是有趣的地方。 解释器*不会*继续查找另一个运算符。该行在此被放弃,无论后续的命令和运算符是什么。 这是非常重要的,也是我在 Listing 28-3 中加入括号并使用多个`||`运算符的原因。请考虑这种看似微小的语法调整: ``` (command1 || command2 || command3 || command4 || command5) && command6 ``` 如果用一对括号将通过`||`运算符分隔的命令包围起来,第一个命令会无条件执行。括号内的其他命令只有在前一个命令失败时才会执行。执行多少条命令并不重要;只要其中一条命令成功执行,或者它们全部失败,控制流就会退出括号内的逻辑。接着,解释器会立即遇到`&&`运算符,只有在某个命令(最后一个执行的命令)返回 0 时,`command6`才会执行。 使用多个条件执行运算符时不要做任何假设;一定要进行测试。 ### 总结 如果从这一章没有别的东西要记住的话,`&&`不是一个与运算符,`||`也不是一个或运算符。它们分别是评估错误级别是否为 0 或不为 0 的条件执行运算符。我已经详细讲解了每个运算符单独、组合以及串联使用时的工作原理。 条件执行类似于 if 命令,但你已经了解了在成功使用该技巧之前必须知道的重要区别。尽管在所有情况下它并不理想,但它确实提供了一种非常简洁的替代语法。我还解释了解释器如何处理这两种运算符之间的重要区别,并演示了一些实际应用。 在下一章,我将回到在 Batch 中构建最初未曾设想的工具的概念,即数组和哈希表。 ## 第二十九章:29 数组和哈希表 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) “数组?Batch 没有数组!”在敢于在 Batch 代码中提到使用这种数据结构后,我收到了各种不可置信的回应。从技术角度看,这些怀疑论者确实有一定道理。数组并不是 Batch 语言的固有部分,Batch 的创建者从未预见到它们的使用。 然而,多年来,一些创新的程序员找到了构建类似数组的东西的方法,它看起来像数组,走起来像数组,甚至像数组一样嘎嘎作响(如果数组会嘎嘎作响……并且会走的话)。在本章中,我将探索在 Batch 中构建固定长度和可变长度数组的多种方法。你将学会如何遍历一个数组并访问其中的任何给定元素,以及如何初始化一个数组。我还会讨论哈希表,详细说明它们与数组的相似性和差异,然后展示如何用数据填充它们并检索数据。最重要的是,你将学到这两种工具的应用,可以在批处理文件执行期间更好地组织和存储小型和大型的相似数据集。 ### 数组 无论是什么编程语言,*数组*都是一种数据结构,用来存储多个具有相同名称的变量,它们通过索引来区分。你可以将*相似变量*看作是列表中的项或元素;它们之间必须有某种共同性。一种数组可能包含你同事的名字,另一种可能包含世界各国的名称,第三个数组可能包含篮球队的成员。这些都是字符串类型变量的数组示例,但数组也可以包含其他数据类型。再举三个数组可能表示该篮球队赛季每场比赛的统计数据:得分(整数),分差(整数),或进攻效率(浮动小数)。 数组有一个独特的名称,数组中的每个元素由数组名称和一个数字或*索引*的组合表示。在大多数现代语言中,索引是从 0 开始的,这种数组被称为*零偏移数组*。相反,从 1 开始计数的数组被称为*一偏移数组*。我将只使用更常见的零偏移数组。(如果你是个固守传统的 COBOL 程序员,你也可以在 Batch 中轻松构建一偏移数组,但你可能会被年轻的同事们标记为“老古董”哦。) 上面提到的其中一个数组可能被命名为 coworker,另一个则命名为 points。points[0] 的值将是赛季第一场比赛中得分的数值,points[1] 则对应第二场比赛,以此类推。你的同事们可能没有这么有序,但 coworker[0] 会是一个人,coworker[1] 会是另一个。如果你和 100 个人一起工作,那么 coworker[99] 将是这个 100 元素数组中的最后一个元素。 在许多编程语言中,你可以在内存中定义数组,将数组的所有元素分配给特定的数据类型。正如第五章中详细描述的那样,Batch 根本不允许你将变量定义为某些数据类型,这一点在数组中也没有变化,但你可以设置以下变量: ``` set celtics[0]=Bird set celtics[1]=McHale set celtics[2]=Parish ``` 任何程序员都会告诉你,这看起来无疑像是名为 celtics 的数组的前 3 个元素,而它的表现正是如此。需要注意的是,在内存中没有一个统一的数据结构来包含这三个元素。相反,解释器将它们视为三个不同的普通变量,其名称恰好都以文本 celtics 开头,后面跟着一个数字和一个尾随符号。 由于 Batch 在变量名中允许的字符非常宽松,你可以轻松地将大多数键盘字符嵌入到变量名中。其他程序员可能会有不同的数组命名惯例,但我使用方括号,也叫硬括号,来表示数组索引。于是,一个数组诞生了。 #### 创建数组 你可以通过多种方式创建数组。在上一节中,我创建了一个包含三个元素的 celtics 数组。那段代码通过将三个硬编码值分配给三个硬编码变量来定义数组的三个元素。但你也可以从多个不同的来源构建固定或可变大小的数组。 ##### 用户输入的固定大小数组 继续篮球主题,构建一个包含正好五个元素的数组——即首发五名球员——并通过用户从控制台输入的数据,这段代码并不复杂。固定大小的数组需要一个特定数量的赋值操作,这就需要通过 for /L 命令创建一个迭代循环(参见[第十八章)。同样,用户输入也需要使用 set /P 命令(参见第十五章)。以下是代码: ``` > con echo Please Enter the Starting Five: for /L %%i in (0,1,4) do ( > con set /P myTeam[%%i]=Enter Array Value %%i = &rem ) ``` 后缀&rem 仅仅是用来突出显示它前面的空格。 这个循环执行命令,要求用户输入五次,每次迭代索引%%i,从 0 到 4。Batch 编程的一个优点是,变量名可以包含其他变量,而这是许多其他语言中不容易做到的。注意,变量 myTeam[%%i]有三个组成部分: + 以开放方括号结尾的文本字符串:myTeam[ + %%i 解析得到的数字:从 0 到 4 + 文本中单个字符的关闭方括号: ] 在循环的第一次执行中,变量名解析为 myTeam[0],此时代码将从控制台获取的第一个值赋给它。然后 myTeam[1]接收第二个输入值,依此类推,直到 myTeam[4]接收第五个也是最后一个输入值。在接下来的元素赋值示例中,我将使用相同的基本技巧。 ##### 从参数列表创建可变大小数组 我定义了前面的数组为固定大小,但你通常无法预知数组的最终大小。以下例程使用没有选项的`for`命令(第十七章)来处理传递给它的所有参数`%*`,将每个参数添加到 parmArr(参数数组)中。 ``` :BuildParmArray set parmArrSize=0 for %%i in (%*) do ( set parmArr[!parmArrSize!]=%%~i set /A parmArrSize += 1 ) set parmArr goto :eof ``` 这段代码并没有使用带有内建索引的迭代循环,而是针对每个参数执行一次`for`命令代码块。因此,我首先定义或初始化索引,将其定义为 parmArrSize,然后在每次循环枚举时递增它。由于我最初将其设置为 0,循环的第一次执行将第一个参数分配给 parmArr[0]。如果有 20 个参数,`for`循环会分配 20 个元素,一直到 parmArr[19]。 这是一个微妙的要点,但请注意,我在循环结束时递增 parmArrSize,设置为下一个参数的索引值,不管是否还有下一个参数。结果是,如果这段代码分配了 0 到 19 号元素,最终的 parmArrSize 值将是 20,这也是数组的实际大小。许多编程语言都有返回数组大小的方法;而在 Batch 中,最接近的做法是使用一个包含该数据项的变量。 这段代码构建了一个零偏移数组,但你可以通过稍作修改来构建一个一偏移数组。只需交换代码块中的两个`set`命令,将 parmArr[1]设置为第一个元素,parmArrSize 仍然会包含正确的数组大小。 如果调用此例程时没有传递任何参数,parmArrSize 将保持为 0,此逻辑不会向数组中添加任何内容,因为`for`命令的代码块根本不会执行。因此,这段代码成功地创建了一个空数组。 此外,我不想忽视代码块后面的`set`命令。在构建或填充数组后,我通常喜欢记录它的当前内容。此命令会将所有以 parmArr 开头的变量及其值写入标准输出(stdout)或跟踪文件中。由于数组的所有元素都以该文本开头,它们都会被显示出来。 我强烈建议对所有不是特别大的数组都做这个操作。你通常会忽略这些数据,但当需要进行故障排查时,这样做会让任务变得更加轻松。出于诊断的目的,它提供了一个很好的审计跟踪,记录了这段代码如何加载数组,而且在后续修改数组后,你可以轻松地在代码的其他地方重复这段操作。 关于这个清单的最后一点,你可能对我为索引使用的变量名有所疑问。通常最好简洁地定义索引,但 parmArrSize 这个名字的确显得有些冗长,甚至有些笨重。之所以这么命名,是因为这个变量名是数组名与 Size 文本的连接,set parmArr 命令会显示它及其值,并且同时显示数组内容。如果数组的名字相对独特,那么不太可能有其他内容满足这个条件,从而使这个命令干净地显示出你想知道的关于该数组的所有信息:它的元素及其大小。 ##### 从文件中加载的共生数组 在下一个例子中,我将演示几个有趣的概念,你可以将它们一起使用,也可以单独使用。一个是从数据文件加载数组,另一个是构建共生数组。当两个数组大小相同且通过它们的索引同步时,它们就是*共生*数组。例如,一个数组包含同事,另一个数组包含电话号码,它们可能各自包含 10 个条目。这并不意味着它们是共生的,但如果索引 0 处的电话号码属于另一个数组中索引 0 处的同事,且所有 10 组元素都满足这一条件,那么这两个数组就是共生数组。 在第十五章中,我展示了一个交互式的 bat 文件,它会讲一个笑话、一个双关语或一个谜语。在第二十一章中,当讨论随机伪环境变量时,我通过想象几十个笑话、双关语和谜语,所有内容都在内存中并可以随机访问——显然给用户提供了数小时的娱乐。这个难题的最后一块拼图是读取一个包含数十个笑话的库(也就是一个文件),并将它们加载到内存中供随机访问。这听起来像是一个数组……或者可能是两个数组。 现在我们暂时只关注笑话,先将双关语和谜语放一边,知道稍后我们可以对它们做类似的处理。幸运的是,我只会包括 *BatJokes.txt* 文件中的前三行(不要与 *BadJokes.txt* 文件混淆),但请想象成有数百行内容。每条记录包含一个笑话,后面跟着它的答案,通过管道符分隔: ``` Why are bats so active at night?|They charge their bat-teries by day. How do bats flirt?|They bat their eyes. What's a good pick-up line for a bat?|Let's hang. ``` 清单 29-1 中的代码将这些喜剧金句加载到两个数组中,笑话加载到笑话数组,答案加载到答案数组。 ``` set jokes=0 for /F "tokens=1-2 delims=|" %%b in (C:\Batch\BatJokes.txt) do ( set joke[!jokes!]=%%~b set answer[!jokes!]=%%~c set /A jokes += 1 ) set joke set answer ``` 清单 29-1:从数据文件构建的共生数组 这些是共生数组,因为位于特定索引处的笑话对应于相同索引处的答案。当 for /F 命令读取每条记录时,它会根据管道符分隔来标记笑话和答案的文本。前两个 set 命令使用复数形式的笑话索引将每个字符串分配给适当数组的元素。我为两个数组都使用这个索引,因为它们是共生数组,并且在代码块的末尾递增它。 循环外的 set 命令会将以下内容写入控制台,验证两个数组都已成功加载: ``` C:\Batch>set joke jokes=3 joke[0]=Why are bats so active at night? joke[1]=How do bats flirt? joke[2]=What's a good pick-up line for a bat? C:\Batch>set answer answer[0]=They charge their bat-teries by day. answer[1]=They bat their eyes. answer[2]=Let's hang. ``` 这也验证了 3 是笑话的总数。 我已经从一个文件中构建了这两个数组,answer[1]的内容是 joke[1]内容的关键。如果这个文件有一千个条目,那么这两个共生数组将有一千个元素,而且它们的所有元素都会同步。我可以再为谜语设置两个数组,不过我必须使用不同于 answer 的名称来命名谜语答案数组。双关语没有答案,因此我可以将每一条记录都加载到一个双关语数组中。 现在我们几乎拥有了构建一个真正可用的用户界面的所有必要内容,包括蝙蝠笑话、双关语和谜语。我们可以构建幽默的库,并将这些库加载到内存中作为数组。我们可以从用户那里接收到一个请求,要求选择某种类型的幽默,并且我们可以随机决定选择哪个笑话、双关语或谜语。最后一步是能够访问这些数组——也就是提取一个笑话及其答案并显示出来。 #### 访问数组元素 为了让这个成为一个*真正的*数组,我们必须能够遍历它,重新分配元素,将元素分配给其他变量,解析元素等。为了演示如何访问数组元素,我将首先为 myChar 数组分配 14 个元素: ``` set myChar[0]=B set myChar[1]=a set myChar[2]=t set myChar[3]=c set myChar[4]=h set myChar[5]= & set myChar[6]=i set myChar[7]=s set myChar[8]=%myChar[5]% set myChar[9]=C set myChar[10]=o set myChar[11]=!myChar[10]! set myChar[12]=l set myChar[13]=. ``` 我正在为每个元素分配一个单一字符。大多数是直接的,但有几个稍微有点有趣。第六条 set 命令将元素 5 分配为空格,并以命令分隔符结束,这样就显而易见它不是被设置为 null 或多个空格。元素 8 和元素 11 则取用了之前定义的元素值,因此元素 8 是一个空格,元素 10 和元素 11 都是字母 o。 使用硬编码索引的两种元素解析方式表明,这两种分隔符都同样有效。使用百分号时,%myChar[5]%解析为第 6 个元素,使用感叹号时,!myChar[10]!解析为第 11 个元素。当索引是一个变量时,语法更加有限,稍后你会看到。 现在我们可以编写一个 for /L 命令,它将遍历这个数组,将所有值连接成一个句子: ``` set mySentence=& for /L %%i in (0,1,13) do ( set mySentence=!mySentence!!myChar[%%i]! ) > con echo %mySentence% ``` 构建完字符串后,最后一条命令会将“Batch is Cool.”写入控制台。 回到前面,for 命令将%%i 从 0 迭代到 13,其中!myChar[%%i]!依次解析为 14 个元素中的每一个。解释器首先解析%%i 索引,然后解析数组元素。例如,第一次通过循环时,中间结果是 myChar[0]。由于它被感叹号包围,解释器将其解析为 B,并将其赋值给 mySentence。第二次通过循环时,myChar[1]解析为 a,解释器将其与前一个结果连接起来,得到 Ba。这个过程重复了十几次,直到我们构建出完整的句子。 这是延迟展开功能的又一个例子,使用延迟展开时,必须使用感叹号作为外部分隔符;百分号是无法工作的。虽然很容易尝试使用%myChar[%%i]%,但是如果你像解释器那样思考,你会发现并没有数组。你会看到两个独立的变量需要解决:myChar[和 i]。在这个例子中,for 变量是索引,但延迟展开对使用普通变量作为索引时也适用。考虑以下例子,其中百分号包含 idx 索引,而感叹号包含外部数组元素,适用于第二级延迟展开: ``` set idx=9 > con echo The Tenth Element is: !myChar[%idx%]! ``` 这会将大写字母 C 写入控制台。 同样,要从 Listing 29-1 中构建的共生数组中提取笑话及其答案,你可以将一个小于笑话数量的随机非负数输入到一个索引变量中,例如 jokeIdx。然后,你可以通过!joke[%jokeIdx%]!来解析笑话。因为这些是共生数组,你可以使用相同的索引来提取答案:!answer[%jokeIdx%]!...你现在拥有了更新第十五章中的 bat 文件所需的所有工具,可以随机显示多个笑话、双关语和谜语中的任意一个。 #### 初始化数组 支持数组的语言通常提供一个简单的一行命令来创建数组,甚至重新初始化现有数组的所有元素。Batch 中没有变量的实例化,更不用说数组了,因此再次需要发挥创造力。 在构建笑话数组之前,最好先初始化它,就像我在进入循环之前常常初始化变量为 0 一样。虽然很不可能任何活动的变量以 joke[text 开头,但确实有些情况下你会想要重新初始化并重建已在使用的数组。以下命令会清空笑话数组中的所有元素: ``` for /F "usebackq delims==" %%j in (`set joke[`) do (set %%j=) ``` for /F 命令接受输入的 set 命令,列出了数组的每个现有元素及其值。通过在等号上进行分隔,并仅传递第一个标记——即变量或元素名称,而不是其值——我们在代码块中将每个元素设置为 null。如果没有变量需要重置,第二个 set 命令就不会执行,最终结果也是一样的。 在之前的示例中,我假设在构建数组时没有任何元素已存在。为了确保准确,最好在构建和使用数组之前执行类似这样的命令来初始化数组。 #### 实现多维数组 这个技巧甚至可以扩展到构建和访问多维数组。Batch 中基本上可以使用一维数组,因为你可以在变量名中嵌入方括号。对于多维数组,我们只需要再加一个字符:逗号。以下命令将 my2dArray 数组的第 1 行、第 2 列设置为硬编码值,明显类似于二维数组元素赋值,无论使用什么语言: ``` set my2dArray[1,2]=Row1Col2Data ``` 作为演示,我将简要地将之前的两个示例重新构想为二维数组: **用户输入** 返回到由用户输入构建的数组,以下嵌套的 for /L 命令构建一个三行四列的二维数组: ``` > con echo Please Enter 2-Dimensional Array Data for 3 rows and 4 columns: for /L %%r in (0,1,2) do ( for /L %%c in (0,1,3) do ( > con set /P my2dArray[%%r,%%c]=Enter Row %%r, Column %%c = &rem ) ) ``` 外层 for 变量 %%r 遍历三行,而 %%c 则遍历每行的四列。此代码在继续之前会接受恰好 12 个值。 如果 rowIdx 设置为 2,colIdx 设置为 3,那么以下内容将解析为用户输入的最后一个数据元素: ``` !my2dArray[%rowIdx%,%colIdx%]! ``` 这两个索引首先通过百分号解析,结果为!my2dArray[2,3]!。然后,感叹号和延迟扩展完成了整个过程。 **文件输入** 之前,我们从每条输入记录构建了两个共生数组,一个用于笑话,一个用于答案。相反,我们可以将数据加载到一个单一的二维数组中,其中第二级索引 0 是笑话,1 是答案: ``` set jokes=0 for /F "tokens=1-2 delims=|" %%b in (C:\Batch\BatJokes.txt) do ( set joke[!jokes!,0]=%%~b set joke[!jokes!,1]=%%~c set /A jokes += 1 ) set joke ``` 你可以把这个数组看作有两列;注意在两个赋值中第二维的硬编码索引:0 和 1。set /A 命令会递增笑话的索引。行数由输入文件中的记录数决定。最后,列表结尾的单一 set 命令在使用相同的三条记录输入文件时生成如下审计追踪: ``` jokes=3 joke[0,0]=Why are bats so active at night? joke[0,1]=They charge their bat-teries by day. joke[1,0]=How do bats flirt? joke[1,1]=They bat their eyes. joke[2,0]=What's a good pick-up line for a bat? joke[2,1]=Let's hang. ``` 更大维度的数组仅仅差几条逗号:my4dArray[1,2,3,4]。我不记得曾经编写过类似的代码,但一维数组甚至二维数组在批处理中的应用非常广泛。 无论维度如何,如果你需要一个极其庞大的数组,或者如果你计划访问它成千上万次,编译代码是一个更高效的解决方案。但当合理使用时,批处理数组非常有用,且出乎意料地易于管理。 ### 哈希表 事实证明,批处理确实支持数组,或者至少我们可以利用我们手头的基本工具构建一个数组。但哈希表肯定不可能吧?你可能已经从本章的标题和你现在阅读的这一节中猜到了答案,但在构建哈希表之前,让我们先定义一下它是什么。 *哈希表*(有时称为*哈希映射*)是一种以键值对存储数据的数据结构。*值*可以是任何数据类型,甚至是其他数据结构。*键*类似于数组的索引,但它不必是整数。事实上,在批处理环境中,数组和哈希表的行为非常相似;实际上,比较和对比它们的最佳方法是将数组转换为哈希表。 #### 数组与哈希表 想象一个数组,其中索引是按 CCYYMMDD 格式排列的日期,数据则是一个人一天可能走的步数。如果这个本来活跃的人在第一次大流行圣诞节期间保持静止,除了孤独地打开礼物和喝蛋酒,步数数组可能包含以下三条记录: ``` set steps[20201224]=15842 set steps[20201225]=987 set steps[20201226]=13009 ``` 这些索引非常大,即使我们只使用了少量元素。幸运的是,我们没有像在许多其他语言中那样在内存中定义这个数组,因为它的大小可能会超过 2000 万个元素。Batch 数组的一个优点是,它只为已定义的元素使用内存。虽然内存已经变得相当便宜,但仍然没有理由浪费。 内存的最小使用量直接源于数组元素(例如,steps[20201225])是一个简单的变量,变量名由一些文本、一个数字和几个方括号组成。Batch 元素的另一个优点是,索引根本不需要是数字;实际上,它甚至不需要是真正的索引。为了演示,我们将日期格式化为 MM/DD/CCYY 以提高可读性: ``` set steps[12/24/2020]=15842 set steps[12/25/2020]=987 set steps[12/26/2020]=13009 ``` 由于索引不是整数,这不再是一个数组;我们已经将它转变成了哈希表。就这么简单。我们不能像遍历数组那样迭代它,但可以根据格式化后的日期查找步骤数。我们不能再通过给定的索引来检索元素;相反,查找过程涉及方括号之间的键。 进一步讲,我们可以向键中添加星期几的文本,甚至嵌入空格: ``` set steps[Thu 12/24/2020]=15842 set steps[Fri 12/25/2020]=987 set steps[Sat 12/26/2020]=13009 ``` 无意间,这个关键正开始看起来像我们可以从日期伪环境变量中解析出的内容。 这个语法有一个小问题,它看起来像是一个数组。非数字键代替了索引,可能会让它看起来不像数组,但这个键很可能是一个变量,从而模糊了它的数据类型。因此,采用另一种约定是理想的。就个人而言,我选择了使用大括号,也叫括号: ``` set steps{Thu 12/24/2020}=15842 set steps{Fri 12/25/2020}=987 set steps{Sat 12/26/2020}=13009 ``` 这只是其中一种约定,其他约定也同样有效。例如,你可以在哈希表变量前加上 ht。重要的是,你要使数据结构看起来像是某种独特的、不同于数组的东西。任何阅读代码的人,即使是快速浏览,也应该能注意到区别。即使读者不了解这种约定,语法的独特性也会引起好奇心,从而打开理解的大门。 这是一个简单的命令,同时又不那么简单的 set 命令: ``` set steps{%date%}=987 ``` 该命令正在设置步骤哈希表的一个元素,键是命令执行时的格式化日期。 如果你选择使用我的约定,我会将其总结为 array[index] 和 hashTable{key},但像往常一样,使用适合你的约定并坚持下去。 #### 基本哈希表功能 另一个简单的哈希表示例包含了多对人员和其职业,其中人员的姓名为键,职业为值。例如,Darwin 键检索到“Naturalist”值。Lincoln、Poe 和 Braille 是总统、诗人和发明家的键。下面是该哈希表的表格形式: | Key | Value | | --- | --- | | Lincoln | President | | Darwin | Naturalist | | Poe | Poet | | Braille | Inventor | 注意元素缺乏索引以及任何排序的痕迹。 每个键必须是唯一值。如果这个哈希表中有两个人名为 Darwin,我们需要通过某种方式来区分他们,或许可以通过添加名字和中间名来区分。不过,多个不同的人可以拥有相同的职业,比如“诗人”;也就是说,值不必是唯一的。 在 Batch 中构建和访问哈希表元素的语法与许多其他语言非常不同,在其他语言中,在声明哈希表之后,你可以通过某种变体的*put*方法定义单个键值对。例如,这是在 Java 中实现的方式: ``` jobs.put("Lincoln", "President"); ``` 在 Batch 中没有点符号或内置方法,但以下的 set 命令将相同的硬编码键值对赋给哈希表: ``` set jobs{Lincoln}=President ``` 更典型的是,键和值都是变量。以下代码创建了相同的条目: ``` set person=Lincoln set aJob=President set jobs{%person%}=%aJob% ``` 在许多语言中,提取值需要某种变体的*get*方法。这是来自 Java 的例子: ``` String job = jobs.get("Lincoln"); ``` 对应的 Batch 代码检索元素的方式与我们从 Batch 数组中获取元素的方式类似,只是使用了大括号和键,而不是方括号和索引。这两个命令的效果一样好: ``` > con echo The Job is: !jobs{Lincoln}! > con echo The Job is: %jobs{Lincoln}% ``` 但键通常是变量,这就需要现在大家熟悉的延迟展开: ``` > con echo The Job is: !jobs{%person%}! ``` 如果 person 设置为 Braille,输出为:The Job is: Inventor。 现在我们有了一个简单的哈希表,可以向其中添加更多的键(人名)和值(职业)作为一对一对的条目。如果某人被裁员并重新考虑自己的职业,我们也可以移除其中一条特定的记录: ``` set jobs{Jack}=& ``` 以下命令仅在哈希表中尚未存在该键时,才为其分配一个值: ``` if not defined jobs{Kai} set jobs{Kai}=Chef ``` 这个 for /F 命令模拟了许多其他语言中用于获取哈希表大小以及提取键值对列表的三种不同方法: ``` set hashSize=0 set listKeys=& set listValues=& for /F "usebackq tokens=2-3 delims={}=" %%x in (`set jobs{`) do ( set /A hashSize += 1 set listKeys=!listKeys! "%%x" set listValues=!listValues! "%%y" ) ``` 两个列表都用空格分隔,每个元素都包含在双引号中。稍作处理,我们就可以利用这些结果来判断哈希表是否为空: ``` if not defined listKeys > con echo The jobs hash table is EMPTY. ``` 以下代码告诉我们某个特定的键是否存在于哈希表中: ``` echo %listKeys% | findstr "Poe" && > con echo Quoth the Raven, "Nevermore." ``` 如果"listKeys"中包含"Poe",则 echo 命令会将著名的副歌输出到控制台。Poe 被双引号包围,因为我们就是这样将每个键添加到哈希表中的,我们也可以类似地在 listValues 中搜索特定的值。 #### 复杂的哈希表 一个更复杂的哈希表也可能以人名为键,但它的值可能不仅仅是一个简单的字符串,而是更像现代语言中的对象。例如,它可能包含关于该人的更完整的信息,如职业、州、爱好等。(关于对象的话题,我将在第三十二章中详细讨论。) 一旦你理解了 Batch 中的哈希表和数组是通过简单地将文本、部分变量名、解析后的变量、索引和括号串联起来构建的,那么要创建一个以人名为键的人员哈希表,并将职位和州作为标识符,甚至是一个爱好数组,就不难了: ``` set people{Lincoln.job}=President set people{Lincoln.state}=Illinois set people{Lincoln.hobbies[0]}=Wrestling set people{Lincoln.hobbies[1]}=Cats set people{Lincoln.hobbies[2]}=Storytelling ``` 不要将其与点符号表示法混淆;people{Lincoln.hobbies[1]}仅仅是一个变量名,取决于你的视角,它可以是一个杂乱的名称,也可以是一个优雅的名称。 在之前的命令执行后,以下代码从哈希表中的数组提取第二个爱好: ``` set info=hobbies[1] set person=Lincoln set hobby=!people{%person%.%info%}! ``` 延迟扩展首先解析被百分号包围的两个变量。然后,感叹号解析中间结果为 Cats。(美国第 16 任总统与 Tabby 和 Dixie 一起住在白宫。) 我之前提到的关于数组的考虑同样适用于哈希表。这个技巧并不是为了进行重型处理,但在许多情况下,哈希表的快速且相对简单的解决方案可能就在眼前。 在第三章中,我介绍了使用延迟扩展将传输路径存储并从包含城市缩写的变量中检索的概念,比如 pathNYC、pathNash 和 pathSTL。这实际上是一个伪装的哈希表;考虑这三个元素:path{NYC}、path{Nash}和 path{STL}。将城市视为一个变量,并使用它通过!path{%city%}!来提取相应的传输路径。 ### 总结 通过一些巧妙的构思,我们可以让 Batch 完成许多最初并未设计的任务,就像用旧轮胎和绳子做秋千一样。在本章中,我演示了如何从硬编码数据、用户数据、参数和文件数据加载数组。你学习了延迟扩展在访问数组元素中的重要性以及多维数组所带来的可能性。接着,我扩展了这一技巧,构建并访问哈希表,解释了它们与数组的相似性和差异,并展示了两者的应用。 我希望这些例子能够展示 Batch 中可能实现的灵活性。在下一章,我将切换话题,介绍一些虽然看似不同但对语言本身非常有用的主题。 ## 第三十章:30 杂项内容 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在本书中,我努力编写简短、简洁的章节,针对具体话题进行讨论,同时在过程中介绍一些相关的命令。在本章中,我将讨论一些无法归类到其他章节的有趣话题,这些话题太短,无法单独成章,但它们的重要性和实用性丝毫不逊色于其他内容。 在这些杂项内容中,你将学习如何排序文件并查询注册表中的有用信息。你还将学习如何检索和设置文件和目录的属性,我还将讨论位操作,以完善本书对批处理运算的覆盖。 ### 排序文件 排序命令完全符合你的预期;它将输入文件排序为输出文件。为了演示,假设有一个小文件,其中包含未来八位星际舰队*企业号*船长的名字,按他们担任船长的顺序列出。前 15 个字节包含一个名字,后面跟着一个字节作为中间名首字母(如果有的话,只有一位船长使用了中间名)。姓氏从字节 17 开始,后面可能跟着一些空格: ``` Jonathan Archer Robert April Christopher Pike James TKirk Willard Decker John Harriman Rachel Garrett Jean-Luc Picard ``` (*星际迷航*以其平行宇宙和时间线著称,但这是*我们*宇宙中的船长名单,且有一项重要遗漏。斯波克,或者说斯波克先生,未列入名单有两个原因。首先,最主要的是他只有一个名字,这个名字既不是名字也不是姓氏,而有时被当作名字或姓氏,这使得数据文件的格式变得复杂。其次,他作为船长的时间在*星际迷航 II:可汗的愤怒*的开头只有大约三分钟。尽管我非常注重每个批处理事实的准确性,但我也力求忠于*星际迷航*的正史。) 以下命令将文件作为第一个参数接受,/O 选项紧接着并定义了其后的*输出*文件: ``` sort C:\Batch\Captains.txt /O C:\Batch\SortedByName.txt ``` 该命令可以非常轻松地将小型输入文件排序到大小相同的输出文件中。以下是执行前述命令后,*SortedByName.txt* 文件的完整内容: ``` Christopher Pike James TKirk Jean-Luc Picard John Harriman Jonathan Archer Rachel Garrett Robert April Willard Decker ``` 这些船长按名字排序,因为排序命令默认从记录的第一个字节开始排序。如果有两个船长的名字相同,他们将按中间名首字母排序,最后按姓氏排序,但我们可以通过 /+ 选项轻松改变排序的起始字符。以下命令从字节 17 开始排序,这是姓氏的起始位置: ``` sort C:\Batch\Captains.txt /O C:\Batch\SortedByLastName.txt /+17 ``` *SortedByLastName.txt* 文件按姓氏排序,正如其名称所示: ``` Robert April Jonathan Archer Willard Decker Rachel Garrett John Harriman James TKirk Jean-Luc Picard Christopher Pike ``` 该命令还有一些其他有用的选项,用于自定义排序。/R 选项会*反转*排序顺序,因此如果你在上一个命令中添加了 /R,Pike 会排在第一,April 会排在最后。/UNIQ 选项仅输出*唯一*的行,换句话说,它会删除重复的记录。如果输入文件中的某些记录可能超过默认的最大长度 4,096 字节,可以使用 /REC 来定义不同的最大*记录*长度,最大为 65,535 字节。 > 注意 *我必须声明我对任何由单个字母 O 表示的选项、参数或设置的反感;用户总是会因错误而输入零。在我年轻时的编码失误之后,我避免使用这种以及其他含糊不清的字符(如 I 和 l),但不幸的是,sort 命令就是这样。* 在处理大文件时,sort 命令的性能远不如商业工具,而且不能定义多个排序字段,但该命令提供了一个简便的方法,可以对小型到中型文件进行简单排序。 商用的排序工具比 sort 命令要快得多,并且提供更多的功能。通过一点努力,你可以设置一个批处理文件,根据工具是否在机器上注册来执行不同的命令。例如,如果存在商业排序工具(如 Syncsort),批处理文件可以执行它;如果不存在,则会执行较慢但仍然有效的 sort 命令。通过这种方式,你可以在已注册工具的机器上受益于更快的排序工具,但这个计划的主要挑战在于确定工具是否已安装并注册在特定的机器上。方便的是,这直接引出了我们的下一个话题。 ### Windows 注册表 Windows 注册表是一个层次结构的数据库,存储操作系统和所有已安装应用程序的配置设置和选项。它的结构类似于 Windows 本身,看起来像是一个文件夹结构,但每个看似文件夹的地方实际上是一个注册表键,位于根键或树干下的一个或多个级别。 如果某个应用程序已安装在特定的计算机上,你可以在注册表中找到有关该应用程序的信息。其他机器是否存在该信息,就能判断该应用程序是否安装在该计算机上,我们可以通过几行批处理代码来确定这一点。 例如,如果 Syncsort 已安装在 Windows 计算机上,它在注册表中有一个注册表键,最终我们将通过一些批处理代码查询与该应用程序相关的注册表键。如果找到该键,则说明 Syncsort 已安装;如果未找到,则说明未安装。但在使用这一逻辑之前,我们需要知道与该应用程序相关的注册表键。找到该键的最佳方法是查找已安装该软件(在此示例中为 Syncsort)计算机的注册表。 #### 探索注册表 regedit 命令,代表 *注册表编辑器*,提供了一个类似于 Windows 资源管理器的入口,允许你浏览注册表。在命令提示符下,输入以下命令并按回车键: ``` **regedit** ``` 注册表编辑器应该会打开。 警告 *再想一想,稍等一下。当我第一次使用这个编辑器时,感觉就像是闯进了一座老宅子里的一个秘密房间,通过一个陷阱门进入,但这里确实有做出一些破坏的潜力。无需因此而感到害怕,但除非你对注册表有深刻的理解,否则不要在注册表编辑器中删除或修改任何内容。谨慎是必须的,但即使许多数据是晦涩难懂的,调查注册表也能带来启示。* 计算机上加载的所有软件都位于 *HKEY_LOCAL_MACHINE\SOFTWARE* 下,因此这是查找应用程序的第一个地方。如果 *SOFTWARE* 键下的许多键中没有一个显然是该产品的,你还可以右键点击 *HKEY_LOCAL_MACHINE* 根键或根 hive,选择 **查找**,输入应用程序名称或其他搜索字符串,然后按回车键。第一个匹配该字符串的键将出现,按 F3 可以跳转到下一个匹配的键。 为了演示,我们假设找到了下列应用程序的注册表键: ``` HKEY_LOCAL_MACHINE\SOFTWARE\Syncsort ``` regedit 命令是本书中为数不多的几乎专门在命令提示符下使用的命令之一,在 bat 文件中几乎没有用处。如果你在 bat 文件中使用它,它将仅仅打开注册表编辑器,并暂停 bat 文件的执行,直到用户关闭编辑器。但是,通过这个命令和一点努力,我们现在已经得到了注册表键。接下来我们需要一些 Batch 代码,用来判断这个键是否存在于其他计算机的注册表中。 #### 查询注册表 解决此挑战的方法是 reg 命令,reg 简单代表 *注册表*。从 bat 文件中,reg 命令本身就可以在注册表中做出相当大的修改。如果你查看此命令的帮助信息,你会发现它拥有多个操作,可以操作注册表,包括添加、删除、复制和导入,某些心怀不轨的人可能会轻易利用这些操作来构建 bat 病毒。了解这些命令的存在非常重要,但只有在你完全理解注册表及其可能影响的情况下,才应使用它们。我将严格聚焦于从注册表中读取或查询操作,也就是说,执行 reg 命令的查询操作。 最基本的 reg query 命令接受一个可能的注册表键作为参数。方便的是,Batch 允许我们将 HKEY_LOCAL_MACHINE 根键缩写为 HKLM。此命令正在注册表中查找 Syncsort: ``` reg query "HKLM\SOFTWARE\Syncsort" ``` 此命令返回参数的注册表键值和其他直接从属的注册表键的列表。对于我们的目的来说更重要的是,如果此命令在注册表中找到该参数键,它将设置 errorlevel 为 0;如果找不到,则返回值为 1。考虑到这一点,请看使用相同命令的代码,并通过条件执行设置布尔值: ``` reg query "HKLM\SOFTWARE\Syncsort" && ( set bSyncsort=true==true ) || ( set bSyncsort=false==x ) ``` 执行此操作后,您可以在流程的其他地方引用该布尔值,以确定特定的应用程序是否已安装并可用。 我们还可以查询注册表中的其他类型信息。例如,以下命令使用 /V 选项查找当前 Windows 版本的特定注册表键 *值*,ProductName: ``` reg query "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion" /V ProductName ``` 如果没有 /V 选项,命令可能会将许多键值和从属键写入标准输出,但如果使用此选项,它将仅在找到键值时写入两行输出。一个例子可能是: ``` HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion ProductName REG_SZ Windows 10 Home ``` 参数中的注册表键是输出的第一行,关于 ProductName 的所需信息包含在第二行。 以下的 for /F 命令能够很好地解析出 Windows 版本并将其分配给一个变量: ``` set key=HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion for /F "usebackq tokens=1,2*" %%i in (`reg query "%key%" /V ProductName`) do ( if /i "%%i" equ "ProductName" ( set winVersion=%%k ) ) ``` 由于 for /F 命令处理两行文本,而我们只关心第二行,因此 if 命令将仅提取包含 ProductName 的那一行作为第一个令牌。(我们也可以通过 第十九章 中的 skip=1 子句忽略第一行。) 由于 tokens 子句中的星号,第三个令牌 %%k 包含从未使用和丢弃的第二个令牌之后的所有内容,包括嵌入的空格。因此,winVersion 的值变为 Windows 10 Home。如果在另一台机器上运行此代码,它可能将 Windows 7 Enterprise 分配给该变量。 关于注册表,可以写下更多的内容。我在这里仅从 Batch 的角度简单介绍了一下,展示了如何安全地查询一些非常有用的信息。reg 命令的帮助文档为好奇者提供了更多的信息。 ### 文件属性 就像你可以用 reg 命令做坏事一样,你也可以用另一个有趣的命令,attrib 命令,做同样的事情。它的名字是 *attribute*(属性)的缩写,既能检索也能分配文件和目录属性。坏人可以利用此命令在计算机上创建和隐藏恶意文件,以实现各种恶意目的,尽管这种行为违反了批处理编码者的誓言,誓言只将其力量用于正当用途。 #### 检索属性 如果命令的唯一参数是文件,它将返回文件的属性。请考虑以下内容: ``` attrib C:\Batch\SomeFile.txt ``` 如果文件从左到右处于准备归档(A,字节 1)、系统文件(S,字节 4)、隐藏(H,字节 5)和只读(R,字节 6)状态,则此命令将以下结果写入标准输出: ``` A SHR C:\Batch\SomeFile.txt ``` 第二和第三字节始终为空。 字符串中的每个位置代表一个预定义的属性。例如,第五个字节中 H 的值表示文件是隐藏的,或者至少在 Windows 资源管理器中默认情况下无法看到它。相反,那个位置上的空格意味着文件不是隐藏的。 要确定特定文件是否为只读,以下 for /F 命令使用 attrib 命令作为输入: ``` for /F "usebackq tokens=*" %%a in (`attrib C:\Batch\SomeFile.txt`) do ( set attrs=%%a if "!attrs:~5,1!" equ "R" ( set bReadOnly=true==true ) else ( set bReadOnly=false==x ) ) ``` 代码块中的逻辑根据与第六个字节相关联的文件属性的存在与否,将 bReadOnly 布尔值设置为 true 或 false。 带有通配符的文件掩码可以返回多个文件的结果。/S 选项在目录及其所有子目录中的所有文件上匹配文件名或掩码,并返回每个文件的结果。此外,/D 选项处理目录属性,而不是文件的属性。 #### 设置属性 该命令的真正强大之处在于它能够重置属性。在属性字符前加上负号会关闭该属性,加上正号则会开启该属性。例如,以下命令确保文件不是系统文件(-S)且不是隐藏文件(-H),同时是只读的(+R): ``` attrib -S -H +R C:\Batch\SomeFile.txt ``` 这非常有用。如果你创建了一个批处理程序,用于创建或修改一个用户可能会访问的文件,并且这些用户不应信任该文件,你可以使用 attrib 命令在不使用文件时保护并隐藏它。为了让文件可访问,在更新文件之前运行带有-H -R 参数的命令,更新后再运行另一个带有+H +R 参数的命令,从而将文件保持隐藏并只读,直到代码再次需要它。这相当于数字化的“解锁工具棚,取出并使用割草机,用完后再放回,并重新上锁,直到草又长起来”。 有趣的是,当文件是系统文件或隐藏文件时,attrib 命令无法设置属性——除了系统和隐藏文件属性本身。因此,如果文件是隐藏的,并且你仅在参数字符串中使用+R,attrib 命令将无法将文件设置为只读。然而,之前的命令(-S -H +R 参数字符串)确保这些文件属性没有被设置,从而使得可以使用最后的属性。如果需要,你可以执行第二个 attrib 命令来重置系统和/或隐藏属性:+S +H。你可以通过 help 命令查看你可以设置和取消设置的完整属性列表。 为了演示最终用途,del 命令(第七章)非常擅长删除特定文件,但不能删除所有文件*除了*特定文件。假设你的工作目录没有任何隐藏文件,这三行代码会删除除了那个文件之外的所有内容: ``` attrib +H C:\Work\Noah.txt del C:\Work\* /Q /A-H attrib -H C:\Work\Noah.txt ``` 第一个 attrib 命令将*Noah.txt*文件改为隐藏;接着 del 命令通过/A-H 选项删除目录中所有未隐藏的文件。最后,第二个 attrib 命令将文件恢复到原先的状态,没有任何损伤,也没有删除目录中的其他文件。 一旦掌握了操作文件属性的技巧,你就可以开始操作位了。 ### 位操作 在第六章中,我承诺会回到 Batch 支持的最后几个算术操作符:三个按位操作符和两个逻辑位移操作符。这些操作符作用于位级别,因此你需要在理解它们的行为时转向二进制世界。*半字节*,即一个字节的一半,包含四个位,每个位代表一个递减的二次方。将位的值设置为 1 表示打开该位,将其值设置为 0 则表示关闭该位。 当你开启四个位中最左边的第一个位时,它表示十进制 8——第二个位是 4,第三个位是 2,最后一个位是 1。因此,二进制 0001 等于十进制 1,而二进制 1000 等于十进制 8。 你可以通过开启一组位来得到其他数字。二进制 1111 等于十进制 15——即 8 + 4 + 2 + 1。由两个半字节组成的完整字节有 256 个独特的值,但在本讨论中,我将坚持使用更加易于管理的半字节及其 16 个独特值来做大多数即将出现的例子。 #### 按位操作 *按位与* 操作接受两个操作数,并返回在每个位位置上,如果两个操作数的对应位都设置为 1,则该位置为 1。在这个例子中,考虑十进制数字 3 和 6。十进制数字 3 等于二进制 0011,打开了 2 和 1 的位,十进制数字 6 等于二进制 0110,打开了 4 和 2 的位。唯一共同的设置为 1 的位是第三位,其值为 2,所以 3 和 6 的按位与操作结果是二进制 0010 或十进制 2。 *按位或* 运算符会查找在*任一*操作数中被设置为 1 的位。在 3 和 6 之间有三个这样的位,结果是二进制 0111 或十进制 7。*按位异或* 会开启两个操作数中*不同*的位。使用相同的数字,只有第二位和第四位不同,所以结果是二进制 0101 或十进制 5(即 4 + 1)。 这种操作在表格形式中更容易理解。表格 30-1 还介绍了每个按位算术操作的 Batch 操作符。 表格 30-1:按位算术和操作符 | | 操作符 | 示例 | | --- | --- | --- | | 按位与 | & | 3 & 6 = 0011 & 0110 = 0010 = 2 | | 按位或 | &#124; | 3 &#124; 6 = 0011 &#124; 0110 = 0111 = 7 | | 按位异或 | ^ | 3 ^ 6 = 0011 ^ 0110 = 0101 = 5 | 在掌握按位逻辑运算的工作原理后,你可能会对操作符的选择感到疑惑。一个与号(&)通常用于终止命令,插入符号(^)是一个转义字符,而按位或操作符(|)通常用于将数据从一个命令传输到另一个命令(更别提条件执行了)。难道你不能在算术运算中使用这些字符吗? 你是可以的,但你需要采取一些措施,确保这些字符不会触发它们的其他用途。实际上有三种不同的方法,我在这三个功能等价的按位和算术运算示例中展示了它们: ``` set /A bitAnd = "3 & 6" set /A "bitAnd = 3 & 6" set /A bitAnd = 3 ^& 6 ``` 我的偏好是将算术运算用双引号括起来,就像第一个示例所示。你也可以用双引号将变量名、等号操作符和算术运算括起来,正如第二个示例所示。最后,你可以用插入符号(caret)转义操作符。 以下展示了使用我偏好的方法进行的三种按位运算: ``` set /A bitAnd = "3 & 6" set /A bitOr = "3 | 6" set /A bitXOr = "3 ^ 6" ``` 执行这些命令后,bitAnd、bitOr 和 bitXOr 分别包含值 2、7 和 5,这与之前计算的结果相同。 这里我通常会详细说明刚才讨论的内容的多种用途,但我不能说我每天都在操作位。实际上,我从未在批处理脚本中使用过任何按位运算符。在早期的计算机编程中,程序员通常会在位级上连接一组标志,生成一个压缩的字段。然后,他们可以通过位操作来设置和获取表示单独标志的位。 便宜且丰富的内存让这种技术成为过去的记忆,但我仍然可以分享一个用例。以下代码判断一个数字是否是 2 的幂次方: ``` set /A bitAnd = "nbr & (nbr - 1)" if %bitAnd% equ 0 > con echo %nbr% is a power of 2. ``` 只有当 nbr 等于 0、1、2、4、8、16 等时,echo 命令才会输出消息。 任何一个 2 的幂次方的数字都有且只有一位是开启的,而比它小 1 的数字则将这位关闭,同时右边的所有位都变为开启。例如,十进制 8 = 二进制 1000,而 7 = 0111。对这两个操作数进行按位与运算的结果为 0,因为它们没有共同开启的位。如果数字不是 2 的幂次方,至少有一个对应的位在它和小于它的数字中都被开启。例如,6 = 0110,而 5 = 0101;第二位在两个数字中都被开启,因此按位与运算的结果非零:4 = 0100。 #### 逻辑移位运算 批处理还提供了两种操作位的工具。*逻辑左移*运算符将第一个操作数中的所有位向左移,移位的位数由第二个操作数决定,并且右边的位用零替代。*逻辑右移*运算符类似,但它将位向右移,将左侧空缺的位用零填充,同时丢弃右侧相同数量的位。 这里是逻辑移位运算符,并附有示例: | | 运算符 | 示例 | | --- | --- | --- | | 逻辑左移 | << | 3 << 2 = 0011 → 1100 = 12 | | 逻辑右移 | >> | 9 >> 1 = 1001 → 0100 = 4 | 逻辑移位插入到结果中的位用粗体显示。第一个命令在将位向左移两位后附加了两个零。第二个命令在将位向右移一位后前置一个零,同时在过程中去掉了最右边的 1。 以下两个命令在 Batch 中实现了这两个示例: ``` set /A logicLeftShift = "3 << 2" set /A logicRightShift = "9 >> 1" ``` (为了处理小于号和大于号,我们需要转义字符或前文提到的两种双引号技巧之一。) 我同样感到困惑,试图找到逻辑移位的应用,因为我在实际工作中从未使用过这些运算符。然而,由于 Batch 不支持指数运算,我们可以使用逻辑左移将一个数字提升到某个幂次……前提是这个基数是二。可以把它看作一个非常狭义的幂函数。考虑这些计算 2³ 和 2⁹ 的示例: ``` set /A TwoCubed = "1 << 3" set /A TwoToTheNinth = "1 << 9" > con set Two ``` 每个字节中的每一位(以及字节本身)都代表 2 的幂,因此,第一个命令将 0001 向左移 3 位,得到 1000,即十进制数 8,正好是 2 的三次方。后续的 set 命令将以下内容写入控制台: ``` TwoCubed=8 TwoToTheNinth=512 ``` 第二个命令在计算中使用了多个字节。注意,二进制的 1 后跟 9 个零等于 512,即 2 的 9 次方。 如果你在实际应用中使用过这些运算符,给我留言,我会考虑将你的应用案例添加到本书的下一个版本中。 ### 总结 在这一章中,我讨论了一些话题,尽管它们太简短,无法单独成章,但我还是觉得必须分享。你学习了如何排序中小型数据文件,以及如何定制排序。我简要介绍了 Windows 注册表,并展示了如何使用几个有趣的命令进行查询。你还学会了如何设置和获取文件属性,以及如何使用位运算符和逻辑移位运算符操作位。 下一章可能是本书中最重要的一章,最终也会是被引用最多的一章。故障排除技巧和测试技术在任何编程语言中都很重要,特别是在没有编译器的脚本语言中——而且没有动画器或调试器。 ## 第三十一章:31 个故障排除技巧和测试技术 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 当我最初开始与少数朋友和同事分享我正在写这本书的事实时,他们最想在书中看到的是关于测试和故障排除的一章,因为批处理带来了独特的开发挑战。 我经常提到没有批处理编译器,也许这不言而喻,但其实也没有动画器或调试器。逐步执行一些批处理命令,跳过其他命令,设置断点,检查或修改变量,这些都纯粹是幻想,或者说是一本很糟糕的小说(或者可能是我的下一个项目)。批处理代码的生命周期有两个步骤:编写和执行。就是这么简单;没有编译,也没有动画。但这并不意味着你不能测试批处理文件。实际上,这让测试变得更加重要。 在本章中,我将逐步介绍一些技巧,讨论我多年来在编码批处理应用程序过程中学到和开发的各种技术,无论是小型应用程序还是大型应用程序。 (如果这个押头韵的标题让你不太喜欢,那就感谢我将它从“令人垂涎的整洁故障排除技巧和永恒的测试技术”删减掉——这简直是 Ts 的海啸。) ### 捕获 stdout 和 stderr 毋庸置疑,测试和故障排除任何批处理文件的第一步是将 stdout 和 stderr 捕获到追踪文件中。虽然其他编程语言提供了动画功能,但批处理提供了次优选择——详细记录每个命令执行的结果。在某些有限的情况下,追踪信息甚至可能比动画更好。确实,你不能在批处理文件执行时逐步调试代码并操作变量,但你可以看到变量的值,并通过简单的向上滚动轻松返回。 然而,追踪信息可能让人感到望而生畏。如果一个循环执行了 1,000 次,所有 1,000 次执行都会出现在追踪文件中,可能会跨越成千上万行。因此,本章的大部分技巧将围绕如何解读追踪信息展开。追踪中的信息非常宝贵,如果你不记得如何捕获 stdout 和 stderr,请返回到第十二章,重新熟悉一下捕获过程再继续阅读。 现在你可以捕获追踪信息,能否读取这些信息至关重要。 ### 如何导航追踪信息 始终在查看追踪信息时参考生成它的批处理文件。这是因为追踪文件的内容可能会让人迷失其中,原始的批处理代码就像是森林的地图。这个建议可能看起来很显而易见,但有时追踪信息可能与批处理代码差异很大。例如,在追踪信息中,你可能会看到像这样设置 myVar: ``` C:\Batch>set myVar=finalValue ``` 但这并不能告诉你 `finalValue` 是硬编码的,还是通过其他方式设置的,比如从一个中间变量解析出来的。不过,原始的 bat 文件会告诉你: ``` set myVar=%intermediate% ``` 此外,追踪信息可能变得非常长且密集,只有通过搜索才能找到所需的输出部分,但这样做可能比你想象的要复杂。考虑以下代码: ``` rem Execute the Database Purge Program %_pgmPurgeDB% if %errorlevel% neq 0 ( set errorMsg=The Database Purge Program Failed goto :Abort ) ``` 生成的追踪信息可能如下所示: ``` C:\Batch>rem Execute the Database Purge Program C:\Batch>DatabasePurge.exe C:\Batch>if 0 NEQ 0 ( set errorMsg=The Database Purge Program Failed goto :Abort ) ``` 解释器显示变量已解析,因此你无法通过从 bat 文件中搜索 `_pgmPurgeDB` 变量名来找到追踪信息中的这一部分。代码的多个部分通常看起来相似。例如,多个程序可能会执行,并且后面跟着类似的错误处理。 这个 Batch 代码中最容易识别的两个部分是注释和错误信息。我们可以利用这些硬编码的文本来解决问题。为了解决这个问题,确保每个程序前都有一个独特的注释,或者每个程序后在错误处理代码块中有一个独特的错误信息。现在,为了在追踪信息中找到这一段代码,只需从 bat 文件中复制注释或错误信息,并在追踪信息中进行搜索。这样,你也在记录代码——这简直是双赢的局面。 成功的执行过程通常相似且乏味,但失败往往独特且有趣。让我们再次执行相同的代码,但这次假设一个用户要求我们调查为什么数据库没有被清除,尽管 bat 文件看似成功执行了。线索就在追踪信息中: ``` C:\Batch>rem Execute the Database Purge Program C:\Batch>if 0 NEQ 0 ( set errorMsg=The Database Purge Program Failed goto :Abort ) ``` 首先是注释。然后,`errorlevel` 变量在 if 命令的条件语句中解析为 0,因此程序应该是成功执行了,对吧?不,这仅仅意味着最后设置该变量的过程将其设置为 0。最后设置该变量的过程是可执行文件吗?等一下……可执行文件在哪里?它没有在注释后出现在追踪信息中。这是一个线索。 这证明了在读取追踪信息时,逐行参考原始 Batch 代码是多么重要。逐行分析可以清楚地暴露缺失的内容,在这种情况下,就是程序调用。如果没有原始的 bat 文件,就很难发现有东西缺失。 回到我们特定的问题,其近因可能是程序的变量解析为 null 或一个或多个空格,但根本原因是什么?也许这个变量在代码中拼写错误;也许在早期定义它时有人拼错了它;也许它根本没有被定义过;也许是其他进程将其清空了。(但也许是有人在 `_pgmPurgeDB` 的内容前加了 `@`,从而抑制了可执行文件名在追踪信息中的显示,即使它确实执行了,或者也许是我想得太复杂了。)你可以确定的是,程序的执行没有出现在追踪信息中,稍微再深挖一点,你就能找到实际的根本原因。 记住,这是未编译的代码;使用未定义的变量是编译器会抓到的问题,但批处理程序员没有这个奢侈的待遇。 ### 不要被幽灵跟踪所迷惑 我曾见过不止一个新手批处理程序员,盯着类似以下的跟踪记录部分,坚信程序失败了: ``` C:\Batch>if 0 NEQ 0 ( set errorMsg=The Database Purge Program Failed goto :Abort ) ``` 毕竟,跟踪记录清楚地显示了一个将 errorMsg 变量设置为一个明确表示程序失败的字符串的行,但这只是“幽灵跟踪”。该消息上方的行包含 if 命令和触发逻辑的条件子句:if 0 NEQ 0。这个条件是假的,因此代码块没有执行,任何看似执行的情况都只是幻象。如果存在 else 关键字和代码块,那个代码块中的代码本应会执行,但在这个例子中,解释器评估了 if 命令后,控制跳过了代码块后面的内容。 无论好坏,解释器不会抑制这种未执行的代码块。跟踪记录中并非每一部分都执行了。如果代码块中的命令生成了输出到标准输出(stdout),则该输出在跟踪记录中是否存在,可以验证它是否执行了。然而,很多时候代码块中的内容并不会生成输出,因此你必须像解释器一样,尝试评估条件子句。 令人沮丧的是,甚至这个方法也有局限性。你并不总是能够从跟踪记录中评估某些条件子句的结果。例如,if exist 命令用于判断文件是否存在,但仅凭跟踪记录中的条件子句,无法评估出文件是否存在。你几乎无法得知在代码执行时,文件是否存在,或者文件的连接是否暂时丢失。 如果代码分支显著,你可以从跟踪记录中接下来的情况推断出 if 命令的结果。例如,如果错误消息已经被设置,控制会立即跳转到:Abort 标签,因为有 goto 命令。问问自己,跟踪记录中的接下来的几行是否像该标签下的代码,还是像代码块后的代码。(这也是我之前提到的描述性注释的另一个原因。)然而,如果代码块中的代码只是设置了一个或两个变量,你很难仅凭跟踪记录判断它是否真正执行了(尽管下一个提示可能会有所帮助)。 如果没有其他办法,你可以在条件子句为真的时候,开始执行的代码块中加入一个 echo true 命令。我不太愿意建议这样不优雅的代码杂乱无章,但它会在 if 命令执行后立即将“true”文本,或者你希望的任何文本,写入到跟踪记录中,但只有当代码块执行时,才能明确显示 if 命令的结果。例如: ``` C:\Batch>if exist C:\Batch\MysteryFile.txt ( echo true set errorMsg=The Database Purge Program Failed goto :Abort ) true ``` 代码块中的 echo true 命令总会出现在追踪中。毕竟,这个提示的重点在于,幽灵追踪包含了未执行的命令。但紧接在右括号之后的 true 文本显示了代码块已执行,并且当 if 命令查找该文件时,神秘文件确实存在。 ### 创建变量的审计追踪 在批处理文件执行过程中,所有已设置、重置或未设置的变量的审计追踪可以极大地帮助你排除故障。如前面的提示所述,这可能是唯一能知道某个代码块是否执行的方式。为此,在一个长时间运行的批处理文件的开始部分,我通常会添加以下两行: ``` rem - All Variables in Effect Before Execution: set ``` 这个简单的命令,set 命令不带参数,会将所有现有的变量写入捕获的追踪。在同一过程的末尾,你可以执行相同的命令,但备注中引用执行后的状态。 这些备注使得所有已填充的变量列表易于查找。初始命令列出在执行开始时机器上设置的所有变量,后者则显示在执行过程中发生了什么变化。你只有在调试或作为每次执行的审计追踪时,才能执行此操作。 我在第二十九章中曾提到过这一技术,涉及数组和哈希表。你可能通过各种方式填充这些数据结构,而数据结构的构建即使条目不多,也可能使追踪变得杂乱。在那一章中,我建议使用更具针对性的 set 命令将数组或哈希表的内容转储到追踪中。例如,我们构建了一个名为 people 的哈希表。由于数据结构中的每个条目都以 people{开头,以下命令将在加载后或任何其他流程节点显示哈希表的全部内容: ``` rem - The Contents of people{key}: set people{ ``` 类似地,如果你的约定是将所有包含可执行程序的变量前缀设置为 _prog 文本,那么以下命令将显示所有这些变量的完整列表: ``` rem - All Program Variables: set _prog ``` 不带参数的命令在长时间的过程开始和结束时效果最佳,因为它提供了全局变量集,而带有目标的命令则适用于在批处理文件中战略位置的较短变量列表,可能是在某些复杂代码之后。 ### 理解 for 命令的标准输出 由 for 命令生成的追踪复杂性与 for 命令本身的复杂性成正比,但即使是一个简单的例子也能展示一个常见的误解。以下代码来自第十八章,但它的作用不是求 1 到 100 的整数和,而是求 1 到由 count 变量定义的数字范围的整数和: ``` for /L %%i in (1,1,%count%) do ( set /A sum += %%i ) ``` 为了减少追踪的长度,假设计数设置为 3,这意味着它会执行三次,尽管以下追踪显示了四个设置命令: ``` C:\Batch>for /L %i in (1 1 3) do (set /A sum += %i) C:\Batch>(set /A sum += 1) C:\Batch>(set /A sum += 2) C:\Batch>(set /A sum += 3) ``` 第一行是整个 for /L 命令——但*不是*它的执行。请注意,count 以及括起来的百分号在输入中解析为 3,而两个%%i 的引用都变成了%i。这实际上是你在命令提示符中输入 for 命令时的变量形式,出于某种原因,它就是这样显示在 stdout 中的。接下来的三行展示了代码块的三次实际执行,for 变量分别解析为 1、2 和 3。 因为像 count 这样的变量在第一行中被解析,所以它可能看起来像是实际执行。这一点在使用复杂代码块或更多循环执行的更复杂示例中尤为明显。但请理解,这只是对即将到来的执行的一个信息性设置。可以把它看作是一本书的无言介绍,后面还有章节,每一章对应一次循环执行。 下一个提示展示了另一个关于 for 命令追踪的混淆来源。 ### 如何解释未解析的变量 这非常让人沮丧,但解释器在追踪中解析了一些变量,而其他变量没有解析。在上一条提示中我提到,for 命令的追踪可以变得相当复杂,且不需要太多的东西。为了演示这一点,这里是一个不同的文件版本,我曾经在讨论第十九章中的 for /F 命令时频繁使用。我的做法只是将文件的整个记录放入一个变量,并将该记录同时写入 stdout 和控制台: ``` for /F "tokens=*" %%r in (C:\Batch\FourBrits.txt) do ( set inRec=%%r echo Writing to Stdout: !inRec! > con echo Writing to the Console: !inRec! ) ``` (如果你问我为什么不直接写%%r 标记,而是将多余的 inRec 变量包含进来,我正在展示一个在 for 命令的代码块内设置和使用变量的追踪结果,并尽可能减少其他内容的干扰。这种情况在逻辑稍微复杂一点的情况下自然而然地会发生。) 与上一条提示类似,追踪首先显示 for 命令,虽然大体上没有改变,但与实际代码相比还是有所不同。相反,解释器将这个更复杂的命令及其关联的代码块分成多行显示,便于阅读: ``` C:\Batch>for /F "tokens=*" %r in (C:\Batch\FourBrits.txt) do ( set inRec=%r echo Writing to Stdout: !inRec! echo Writing to the Console: !inRec! 1>con ) ``` 接下来,文件中的第一条记录在追踪中生成以下内容。括号内的是正在执行的代码,而最后一行是第一个 echo 命令的结果: ``` C:\Batch>( set inRec=English John Paul George Richard echo Writing to Stdout: !inRec! echo Writing to the Console: !inRec! 1>con ) Writing to Stdout: English John Paul George Richard ``` 顺便提一下,文件中的每一条记录都会生成类似这样的内容,因此你可以看到追踪很容易迅速变得非常庞大;100 条记录将会生成 600 行文本。 前面的追踪信息清晰地显示了从%%r 解析出的输入记录及其赋值给 inRec 变量的过程,但是——这点很重要——接下来的两行追踪完全没有显示!inRec!的解析。它是否被正确设置并解析了呢?在这个实例中,我在第一个 echo 命令中使用它,清楚地展示了变量的内容在输出的尾部行中,但你在追踪信息中看不到该变量的其他使用情况。例如,第二个 echo 命令正在向控制台写入相同的文本,但它没有出现在追踪中。(可以将这看作是与幽灵追踪相反的情况。) 简单来说,任何在代码块内部赋值并通过感叹号解决的变量(也在代码块内),会以感叹号包围的变量名形式呈现,也就是说,未解决的。当执行时,它按预期解决,但追踪信息不会给出任何相关提示。 这是一个极其令人不安的警告(batveat)。有几乎无数的理由在 for 命令的代码块内设置并使用变量——而且通常最有趣的代码就藏在这里——但是发生的事情却消失在虚无之中。延迟扩展(在代码块内部和外部执行)是这一问题的另一个受害者。 处理这个问题有几种方法。第一种方法是完全不做任何处理;一旦理解了这种行为,一个好的程序员可以通过了解需要进行一定的推断来读取追踪信息,从而应对这些特性。 第二种方法是巧妙地放置 echo 命令来显示几行逻辑的结果。你可以将文本写入控制台以进行故障排除(稍后会详细介绍),或者简单地写入追踪文件。这实际上是我在前一个列表中用第一个 echo 命令所做的。 第三种方法是,如果需要逐行显示每个命令的结果,你可以将有趣的代码块移到一个调用例程中,并通过 call 命令调用它,传递所需的令牌作为参数。该例程可以将令牌当作参数处理,最终结果是功能上等效的代码,且现在在追踪信息中完全解析。 我对这个问题有一些职业经历。在实现一个非常复杂的嵌套 for 结构,最终导致重命名文件后,一直有人提出是否真的发生了重命名的问题。为了最终解决这个争论,我使用调用例程重写了代码。不幸的是,我们有时不得不为了功能性而妥协优雅性。 顺便提一下,这个追踪信息包含了一些怪异现象。代码块内部的第一行没有缩进,其他行缩进了一个空格,无论 bat 文件中的缩进如何。重定向语法`> con`出现在 bat 文件中的 echo 命令之前,但在追踪信息中,它被移到了末尾。我无法解释这些不一致之处,但我知道它们存在,我们必须接受追踪信息中的内容是对实际代码的扭曲呈现。 ### 识别不一致的命令输出 由于批处理命令的多变性,命令间产生的消息会有所不同,从而加重了前面提到的麻烦。例如,如果一个 xcopy 命令在代码块内执行,并使用在代码块中设置并解析的变量,那么跟踪信息不会显示解析后的文件名或文件名。然而,如果使用/F 选项,该命令会将一条清晰的消息写入跟踪信息,详细列出命令的结果,列出复制的文件或文件。 相比之下,一个类似的 ren 命令在成功重命名时不会产生任何消息,意味着你可以重命名一个文件,但在跟踪信息中除了一个未解析的文件名的 ren 命令外没有任何提及。一个失败的 ren 命令会将一个通用的错误消息写入 stderr,但不同于 xcopy 命令,它不包含文件名。最终结果是,如果一个失败的 ren 命令在 for 循环中使用了感叹号,或者使用了延迟扩展,那么错误消息会说明重命名失败,但没有提及文件名或提供任何其他信息。 随着经验的积累,你会学会预期何时从常用命令中获得什么输出。对于其他命令,你则要预期一些出乎意料的情况。 ### 将变量写入控制台 除非你直接跳到这一章和这个提示,否则你一定非常熟悉回显命令通过> con 语法将输出重定向到控制台。我多次使用过这种技巧来展示代码列表的结果,尤其在故障排除时,它非常方便,特别是在处理复杂的 for 循环时。 跟踪信息可能既隐晦又庞大,这对于可读性来说不是一个好组合。为了提取出重要数据,我经常在循环中放置一个临时重定向的回显命令,以便在每次循环时查看一个或多个变量的状态。 例如,“理解 for 命令的标准输出”第 380 页中的逻辑,计算一系列整数的和,这很难进行测试,因为虽然我们能看到 sum 变量被设置,但我们从未在任何一次循环迭代中看到它在跟踪信息中被解析。直到在循环后使用 sum 时,你才会看到它的值,但如果你没有得到预期的结果,你很可能会想查看那些丢失的中间值。你可以在求和代码中添加一行,以便获得事件的求和。看看你能否发现它: ``` for /L %%i in (1,1,%count%) do ( set /A sum += %%i > con echo ---- index = %%i ---- sum = !sum! ----- ) ``` 假设在执行之前,count 的值被设置为 4;最终的和应该是 10,但我们得到了 35。 如果你需要更多的细节,跟踪信息仍然没有改变,但额外的命令将以下简洁明了的文本写入控制台。变量被清楚地标注,破折号使它们更突出、更易于阅读: ``` ---- index = 1 ---- sum = 26 ----- ---- index = 2 ---- sum = 28 ----- ---- index = 3 ---- sum = 31 ----- ---- index = 4 ---- sum = 35 ----- ``` 索引变量应该递增(这没什么意外的),但对每个条目的 sum 变量进行仔细检查后,问题显现出来。代码从未初始化 sum,只有当它在进入循环时为 25 时,第一行中的 sum 才能为 26。之前的某个过程必须使用了这个同名的变量。添加一个快速的 set 命令将该变量初始化为 0 后,代码显示如下: ``` ---- index = 1 ---- sum = 1 ----- ---- index = 2 ---- sum = 3 ----- ---- index = 3 ---- sum = 6 ----- ---- index = 4 ---- sum = 10 ----- ``` 再次仔细检查 sum 变量,现在可以发现一切按预期工作;也就是说,之前的 sum 加上当前的索引,确实等于每个条目中的当前 sum,从第一个条目开始,它仅为 1。一旦测试和故障排除完成,你可以并且应该删除 echo 命令,然后继续往下。 我已经在最简单的 for 命令之一上演示了这种技巧,但 for 命令很快就变得晦涩难懂。一两个简单的重定向 echo 命令可以迅速且轻松地生成一些复杂且重复的逻辑摘要,从而大大帮助你的故障排除。 在第九章中,我曾对缩进表达过一些非常明确的看法,而我似乎在这个 echo 命令中违背了这些规则。简单回顾一下,注释没有缩进,标签缩进一个字节,其他所有内容缩进两个字节或更多。在我看来,这对于最终产品是必须遵守的,但这个 echo 命令是临时的,最好让这一点显而易见。否则,很有可能你会不小心将其遗留在代码中。这个花哨且未缩进的命令几乎在乞求被删除。 ### 解读不同类型的语法错误 解释器将两种通用的错误信息写入 stderr:由命令生成的错误和语法错误。失败的 xcopy 或 del 命令会在跟随清晰错误信息的跟踪中显示命令本身,说明为何无法复制或删除文件。 不幸的是,语法错误并不总是以一致的方式呈现。语法错误的一个例子是在条件子句中使用不存在的 and 运算符。如第二十七章中提到的,解释器可能会直接忽略它,继续执行代码块中的内容,甚至不会写入 stderr。一个稍微不同的条件子句使用相同的错误运算符,可能会将 if 命令写入跟踪,然后是错误信息: ``` C:\Batch>if "" NEQ "A" and "" equ "B" ( 'and' is not recognized as an internal or external command, operable program or batch file. ``` 但这仍然继续执行代码块及之后的内容。顺便提一下,这条错误信息的意思是解释器错误地尝试将命令中的某些内容当作程序来执行,但通常这实际上是语法错误。 语法错误至少还有一种表现形式。为了演示这一点,让我们考虑一下本章早些时候讨论的稍作修改的代码版本,该代码除了执行一个程序并检查返回码外,几乎没有做其他任何事情: ``` %_pgmPurgeDB% if %errorlevel% neq 0 set errorMsg=The Database Purge Program Failed goto :Abort ) ``` 尽管这段代码看起来很简单,但执行时却崩溃了,命令窗口关闭,追踪底部留下了以下文字: ``` C:\Batch>DatabasePurge.exe The syntax of the command is incorrect. C:\Batch> if 0 neq 0 ``` 悲观主义者可能会指出,通用错误信息完全缺乏细节。乐观主义者可能会指出,解释器明确地告诉你语法错误,但当你开始分析这个信息时,你会意识到它的数字化作者无意间显得有些阴险。 错误信息指出语法不正确,紧随程序执行之后;它们之间甚至没有空行。这显然暗示着前一行有语法错误。但实际上,解释器更像是个占卜师。它声明接下来的那一行——被空行隔开的那一行——是错误的源头,尽管它看起来像是一个无争议的 if 命令。 总结一下,语法错误有时会在追踪中写出错误信息,有时则不会。有时解释器会忽略语法错误,继续执行接下来的命令,有时则不会。错误信息有时会在出错的命令之后出现,有时会出现在之前。我的最佳建议是,找出错误的大致位置。查看错误信息前后的代码,避免出现“隧道视野”。 在继续之前,那个 if 命令的语法到底有什么问题呢?这正是编译器提供简洁明了的错误信息时,简直是天降甘霖,但我们仅有的武器不过是我们的聪明才智和一个耸耸二进制肩膀的解释器,它说:“出错了,自己找原因。”紧盯着它后面的代码行进行仔细检查,命令名称几乎不可能拼错,if;有效的运算符,neq;以及两个要比较的数字。哎呀!缺少了一个左括号。 ### 尽可能模块化 创建执行特定任务的小 bat 文件有很多好处,其中最重要的一项是测试。你可以轻松设置另一个 bat 文件来多次调用新 bat 文件,使用不同的输入参数,每次都验证所有输出结果,这些输出可以是返回的参数、写入控制台的数据,或是某些操作,比如复制文件。 要查看这个例子的实例,只需翻到第十一章。在构建了*MadLib.bat*文件后,我多次使用不同的输入参数调用它。转到第二十九章,如果你在一个已经很大的过程中的某一部分代码里创建一个数组或哈希表,一种选择是将其编码到现有的 bat 文件中。但大多数情况下,更好的选择是创建一个新的 bat 文件,包含所有有趣的逻辑。然后你可以通过从另一个 bat 文件使用 call 命令来快速并反复测试新 bat 文件,以排除任何漏洞。最后,你只需要在包含更大过程的现有 bat 文件中添加一两个 call 命令即可。 你很快就会看到更多这样的例子。在第三十二章中,在创建一个面向对象的 bat 文件后,我将通过另一个 bat 文件使用多个 call 命令来调用它。然后在第三十三章中,我将创建一个单独的 bat 文件来处理栈。随后对它的调用将推送项目到栈上并从栈中取出项目。尽可能进行模块化。 ### 测试 Bat 文件中的代码片段 不幸的是,有时候模块化并不可行。一个大型应用程序可能需要调整现有的 for 命令。如果它在一个大型的 bat 文件中,且这个文件是一个更大流程的一部分,将更改集成到新模块中可能不切实际。更复杂的是,由于缺乏编译器,当你做出即使是最微小的更改时,出错的几率大大增加。如果这是一个长时间运行的过程,你可能需要一小时甚至更长时间才能发现自己遗漏了一个括号,而更复杂的更改通常需要多轮的编码-测试-调整。最好只测试待测试的代码,能够快速反复运行它。 我们需要的是一种方法,在简单和受控的环境中模拟新逻辑或更新逻辑的实际环境。为了实现这一目标,我在每台工作计算机上都有一个*C:\Batch\*文件夹,其中包含一个名为*Test.bat*的 bat 文件,至少一开始其内容是完整的: ``` @setlocal EnableExtensions EnableDelayedExpansion @call :GetTrace > C:\Batch\Trace.txt 2>&1 @pause goto :eof :GetTrace rem --- Variables Set Prior to Code Snippet rem --- Code Snippet Goes Here rem --- Variables Set After to Code Snippet goto :eof ``` 目前,这个 bat 文件将执行一个非常无聊的例程,包含三个 rem 命令,捕获 stdout 和 stderr 到追踪文件,并通过 pause 命令保持控制台打开。但这个 bat 文件仅仅是一个外壳。要测试来自更大 bat 文件的代码片段,可以将其粘贴到第二个 rem 命令之后。示例可能包括一个复杂的 for 命令,或者一个更长的片段,它在构建和使用数组的同时,还读取和写入多个文件。 如果代码期望在代码片段执行之前设置某些变量,你可以在第一个 rem 命令之后将它们作为硬编码值输入。如果需要读取某个文件,可以在此部分创建一个模拟文件,并添加其文件连接器。 类似地,如果被测试的过程是为了在环境中设置某些变量以供后续使用,你可以在第三个 rem 命令之后,通过将 echo 命令重定向到控制台来查询它们。你不仅能在控制台上看到这些解析后的变量,还能在详细的追踪信息中看到它们(除非未解析的变量问题再次出现)。 为了演示,如果你使用这种技术来测试本章前面详细介绍的求和逻辑,你可以像这样更新 shell bat 文件中的内部例程: ``` :GetTrace rem --- Variables Set Prior to Code Snippet set count=100 rem --- Code Snippet Goes Here for /L %%i in (1,1,%count%) do ( set /A sum += %%i ) rem --- Variables Set After to Code Snippet > con echo sum = %sum% goto :eof ``` 待测试的代码放在中间部分,但这只是起点。你需要在核心逻辑执行之前定义计数变量,并且由于这段代码的最终结果是一个变量的值,你将需要在程序的第三部分解析并显示 sum 到控制台。 现在你可以快速、高效、频繁地进行测试、调整、重新测试和再次调整。这不是最终测试,但一旦测试满意,你可以将清单复制并粘贴到主流程中。现在你可以以较高的信心执行完整的端到端测试。 我也使用同一个批处理文件来测试我在之前的技巧中介绍的模块化代码。你可以轻松地在中间部分添加一个或多个其他批处理文件的调用命令,同时仍然使用第一部分进行设置,第三部分进行验证。剪切和粘贴从来不优雅,但毫无疑问,在测试批处理文件中的代码片段是非常有效的。 ### 总结 我无法过分强调掌握测试和故障排除批处理代码技术的重要性。在这一章中,我插入了*《尖峰时刻》*中的放大器,并把音量调到 11。通过这 11 个技巧,我展示了我如何开发和维护批处理文件。用一句话总结:捕获并能够读取跟踪,写入控制台输出,尽可能进行模块化,并在适当时使用*Test.bat*。 我相信其他人会有有用的补充内容,我鼓励你去寻找它们。对批处理的测试,唯一的替代方案就是最终放弃并编写一些已编译的代码来执行本可以并且应该通过几行批处理代码完成的任务。 下一章将讨论一种令人兴奋且有趣的编码方法:面向对象设计,这个话题你可能现在还不会与批处理(Batch)联系在一起,但很快你会发现它的关联。 ## 第三十二章:32 面向对象设计 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在前面的章节中,我探讨了许多通常与 Batch 无关的主题,从布尔值到哈希表,还有很多其他内容。在这一章中,我将处理 Batch 用户自建工具的圣杯:面向对象设计。 在解释了过程式编码和面向对象编码的区别后,我将介绍面向对象编程的四大支柱。接着,我会展示一个完整功能的 Batch 面向对象设计模型,其中包含代表父对象、中间对象和子对象的 bat 文件。在你学会如何调用面向对象的代码后,我将分享许多建议并分析这个模型如何满足面向对象设计的基本原则。 ### 过程式与面向对象编程 在计算机的早期,所有的编码都是*过程式的*——也就是说,所有程序由一系列计算步骤组成。这个方法论至今仍然存在,可能有超过一万亿行这样的代码仍在使用,每天还有更多新的代码被编写出来。在过程式代码中,变量默认是全局访问的。有时,程序开头的一部分会定义数百个变量,而在其他时候,比如在批处理程序中,变量是在首次引用时创建的。即使调用了一个内部例程或过程,活动和可用的变量集合也不会改变。通常可以采取一些措施来限制作用域,比如第三章中讨论的 setlocal,但在过程式编码中,变量通常可以在程序文件的任何地方被设置或重置,这些文件通常非常庞大。 很快,一些聪明的人提出了*面向对象编程(OOP)*的概念,在这种方法中,代码被分解成小的、易于管理的*对象*,每个对象包含变量形式的数据和方法形式的可执行代码。*方法*类似于批处理中的内部例程;它可以被调用,当它完成执行时,控制权会返回到调用点,但方法也可以被外部调用;即,其他程序文件中的代码可以直接调用该方法。不管调用的来源是什么,方法都接受明确定义的输入并返回同样明确定义的输出。然而,方法内部的大多数数据通常无法从程序文件的其他地方访问,尤其是从其他程序文件中。 代码的模块化将其分解为独立的对象,从而简化了逻辑并防止了代码某一部分对其他部分产生不良影响。此外,如果其中一个对象在不同的上下文中有用,它可以在不修改或重复的情况下被两个进程轻松使用。一个等效有用的过程式程序部分可能最终被复制并粘贴到另一个程序中。 如果我要列出一些个人的重大烦恼——为了简洁起见,我将把范围限制在与工作/编码相关的烦心事——在我愤懑的清单上,排在最前的是伪装成面向对象代码的过程式代码。某些语言和框架是专门为支持每种编码范式设计的。Java 和 C#是面向对象语言;COBOL 和 Batch 是过程式语言。但与许多人的看法相反,事情并没有那么简单;过程式代码可以很容易地在 Java 中编写,而经过一些努力,面向对象的原则也可以在 Batch 中实现。 ### 四大支柱 任何声称是面向对象的编程语言,都必须完全支持面向对象编程的四大支柱。被认为是面向对象的语言以不同的方式实现这些支柱,但它们都有内置的机制,引导程序员编写具有这四个特征的代码。 第一大支柱是*抽象*,它简化了代码与外部世界的接口。它只向用户展示必要或相关的特性,同时屏蔽实现细节和任何与功能使用无关的信息。 这引出了第二大支柱——*封装*,它限制了对对象中某些方法和变量的访问,将数据和代码封装成多个小单元。数据隐藏,或者将某些变量和方法视为私有,是这一支柱的重要组成部分。另一个重要部分就是创建紧凑、易读且可重用模块的行为。 *继承*,第三大支柱,允许创建基础或父对象。然后,派生对象可以在扩展父对象以满足子对象的更具体需求时使用它。经典的例子是一个“动物”作为基础或父对象,定义了整个动物王国中共同的特征。然后,作为“爬行动物”和“哺乳动物”的中介对象可以从“动物”对象继承数据和程序,并在此基础上添加各自的生物学分类信息。接着,“猫”、“鼠标”甚至“蝙蝠”对象可以从“哺乳动物”对象继承,而“哺乳动物”对象又继承自“动物”对象。然后,地球上每一种蝙蝠物种都可以从“蝙蝠”对象继承。最终,每个继承的对象都可以使用其父对象中的数据和代码。 第四大支柱,*多态*,意味着以多种不同形式出现的状态,字面上源自希腊语,意为*多种形式*。这使得调用一个例程在不同情况下表现得不同。可重用的代码总是很棒的,但这一支柱将重用的概念推向了巅峰。父对象中的方法可能会被多个子对象调用,而每次调用对每个子对象来说都是独特的。 在接下来的页面中,我将尝试模仿这四大支柱:抽象、封装、继承和多态。 ### Batch 面向对象设计 本章讲解的是 Batch 面向对象设计,而不是 Batch 面向对象编程,原因有两个。首先,更琐碎的一点是,BOOP 是一个糟糕的缩写,让人联想到在亲切地戳宝宝鼻子时发出的声音。BOOD 也不怎么样,但我确实有一个“辉煌缩写团队”(Brilliant Acronym Team,简称 BAT)正在努力做一些更好的……抱歉,是更棒的事情。 更为重要的是,Batch 没有实现面向对象编程(OOP)所固有的内建特性,这些特性在更现代的编程语言中得到了实现。我将展示如何模拟其四个支柱,并且创建面向对象的批处理文件的成功程度可能会让许多人感到惊讶,但我会比其他功能更完整地构建一些其功能。真正的面向对象编程在 Batch 中是不可能实现的,但我鼓励你在 Batch 代码的设计中,完全或部分地融入这些设计元素。 在 Batch 中,变量不能被定义为公有或私有;事实上,变量根本无法定义。没有像“extends”或“implements”这样的关键字来调用继承。面向对象编程(OOP)中,子对象可以覆盖父对象的方法;你很快就会看到如何在 Batch 中执行这些任务,但它们不会自动完成。请记住,这是一个没有布尔值、浮动值、while 和 do...while 命令、以及 and 和 or 运算符、数组和哈希表的语言。我已经演示了如何利用手头的工具构建所有这些功能(并且在第三十三章中我将以同样的方式演示堆栈和队列的构建),所以应该不会让人感到惊讶,Batch 并没有现成的面向对象编程功能。 然而,面向对象的原则是可以使用的,而且应该被使用,不论提供了什么样的工具集。在“面向对象语言”中编程的人,往往会轻易地定义过多的类级别变量,将所有数据项设置为公有,并将大部分代码放入一个“上帝对象”(一个做得太多且太大,无法高效管理的单一对象)中。但无论是什么编程语言,这种设计模式——大模块和过多的全局变量——本质上还是过程式编程。更现代的语言提供了面向对象的护栏,但这些护栏是可以突破的。 相反,许多人认为批处理程序员应该只创建大型、无趣的模块。但尽管批处理过程式代码是标准做法,却没有“批处理警察”强制要求它。当面向对象编程或设计被视为一种技术,而不是仅与某些语言相关的特征时,你会恍若顿悟,仿佛走在前往大马士革的路上。批处理并没有其他语言所提供的坚固护栏,但你可以使用任何编程语言来打造面向对象的技术,就像即使在悬崖峭壁的山路上开车也完全有可能到达山顶。批处理中可能没有基础设施或管道,也不是所有事情都能实现,但批处理中的面向对象设计的优雅性提供了与其他语言相同的好处。 ### 类与对象 面向对象编程与批处理面向对象设计之间的一个区别是,传统上每个模块或文件被定义为一个 *class*,而 *object* 是该类的一个实例化。实例化一个对象就像定义一个变量一样。就像在许多语言中,变量可以定义为字符串或整数,实例化对象就是将其定义为类的一个具体实例。 举例来说,假设定义了一个类 *Bat*,表示所有飞行的夜行哺乳动物,其中每个蝙蝠物种都有自己的类,从 *Bat* 类继承。其中一个物种是马达加斯加果蝠,其类可能定义为 *MadFruitBat*。这个类表示所有属于该物种的蝙蝠,并且它可以被实例化多次,每次代表一个特定的个体。一个包含 500 只马达加斯加果蝠的洞穴可以被填充为 500 个对象,每一个都是 *MadFruitBat* 类的一个实例,且每个实例会为特定的蝙蝠命名。 这就是批处理(Batch)首次遇到面向对象限制的地方。记住,这是一种无法定义变量为字符串或整数的语言。显然,因此,变量不能被定义或实例化为与类模块相对应的数据类型。在批处理中,类与对象之间的区别是不存在的。模块或批处理文件本身就是一个对象。它不需要实例化,也无法实例化。 出于这个原因,*class* 这个词并不是批处理面向对象设计术语的一部分。我很快会分享一些完全构成对象的批处理文件,每一个都可以被调用,我将把它们称作 *objects*。在面向对象编程(OOP)中定义的马达加斯加果蝠类(*MadFruitBat*)将会是批处理中的 *MadFruitBat.bat* 对象批处理文件。为了实现 500 只马达加斯加果蝠,你需要为每只果蝠创建一个小的批处理文件,每个文件都继承自 *MadFruitBat.bat*。 事实上,*oMadFruitBat.bat* 是对象 bat 文件的更好名称。为了区分对象 bat 文件与其他常见的 bat 文件,我总是在它的名字前加上小写字母 *o* 作为视觉提示。*obj* 文字也可以使用。 ### 批处理面向对象设计模型 描述面向对象设计的最佳方式是通过示例或模型。接下来我将简要介绍本章中展示的批处理面向对象设计的示例。然后,我将展示父对象,并附上中间对象和子对象的示例,同时解释每个模块中实现的面向对象概念。在接下来的部分“执行面向对象的批处理”中,我将详细讨论如何调用这个模型,而在后续部分“批处理中的四大支柱”中,我将探讨这个模型如何紧密模仿面向对象原则的核心概念。 在金字塔的顶部是基类或父对象。其他对象从它继承,但它是这个模型中唯一一个不从其他对象继承的对象。在父对象下方是直接继承自父对象的中间对象。其他中间对象可以从第一级中间对象继承,最终在金字塔底部形成子对象。中间对象和子对象都被称为 *派生对象*,因为它们继承或来源于其他对象。(从技术上讲,中间对象也是一个子对象,因为它是另一个对象的子对象,但我使用“子对象”一词来描述那些可能更准确地称为“无子对象”的对象。) 在本章的批处理面向对象设计模型中,电影是父对象,派生出了许多不同的电影类型对象,如科幻片、浪漫喜剧和动作片,虽然这里只展示了喜剧和剧情两种类型。每部电影都是一个特定的对象,每部电影从某个特定的类型对象继承而来。全球发布的电影超过两百万部,但在这里我只展示了三部,其中两部是有史以来最伟大的电影,*教父* 也是一部伟大的电影。我将在本章中展示的对象,在图 32-1 中已用星号标注。 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/fig32-1.jpg) 图 32-1:父对象、中间对象和子对象 电影对象包含所有电影共有的数据和方法,或者至少是所有电影的默认数据和方法。(借用面向对象编程的术语,从这一点起我将使用 *方法* 来代替 *例程* 一词。)喜剧和剧情对象各自继承自电影对象,并对其进行扩展,定义了与每个类型特有的数据和方法。个别电影对象继承自类型对象,因此间接地继承自电影对象,并包含有关其特定电影的详细数据。(每条箭头表示从派生对象到其父对象的继承关系。) 但这不是一个严格的层次结构。像 StarTrek 这样的电影系列对象可以继承自科幻类型对象(两者都未显示),而个别的 *Star Trek* 电影则可以作为电影系列对象的子对象。 更重要的是,所有对象都是 bat 文件。Movie 对象最具体的形式是 *oMovie.bat* bat 文件,而 Comedy 对象是 *oComedy.bat* bat 文件。同样,子对象之一将是 *oBigLebowski.bat*。 一些面向对象的语言提供多重继承;也就是说,一个派生对象可以继承自两个或更多不同的对象。例如,Dramedy 对象可能同时继承自 Comedy 和 Drama 对象。虽然即使在 Batch 中从理论上讲也是可能的,但我将此模型限制为单一继承,因此 Dramedy 对象只是继承自 Movie 对象的另一种类型对象,个别的 dramedy 将继承自 Dramedy 对象。 #### 父对象 一旦这些概念变得清晰,你就会想看看一切是如何运作的。从基本或父对象开始,列表 32-1 显示了 *oMovie.bat* 的内容。请注意,这个和其他对象的 bat 文件不会以 setlocal 命令开头,因为它们总是从包含该命令的其他 bat 文件中调用。 ``` set meth=%~1 ❶ for %%m in (Constructor DispInfo Set Get) do ( if "%%m" equ "%meth%" ( ❷ call :%meth% "%~2" "%~3" "%~4" ❸ goto :eof ) ) ❹ > con echo ABORT - Invalid Method Invoked: %meth% pause & exit ❺ :Constructor set gauge{%~1}=35mm set clrOrBW{%~1}=color goto :eof ❻ :Set set %~1{%~3}=%~2 goto :eof ❼ :Get set %~2=!%~1{%~3}! goto :eof ❽ :DispInfo > con echo "%~1" is a !year{%~1}! !clrOrBW{%~1}! !gauge{%~1}! film. > con echo Genre: !genre{%~1}! > con echo Plot: !plot{%~1}! > con echo Starring: !star{%~1}! > con echo Cast: !cast{%~1}! > con echo Country: !cntry{%~1}! > con echo Language: !lang{%~1}! > con echo. goto :eof ❾ :PrivateMeth rem - some private method code goes here goto :eof ``` 列表 32-1:基本或父对象,oMovie.bat 这个对象有很多有趣的部分。 ##### 隐藏方法 封装,面向对象编程(OOP)的支柱之一,包括隐藏方法。默认情况下,bat 文件的所有方法都是隐藏的,因为没有内置的方式可以创建多个入口点进入 bat 文件;当 bat 文件执行时,每次都会从顶部开始。因此,这里的任务不是隐藏某些方法,而是使某些方法公开可访问,同时保持其他方法隐藏。 for 命令 ❶ 充当“交通警察”;它包含了 bat 文件的公共方法列表作为输入:构造函数(Constructor)、显示信息(DispInfo)、设置(Set)和获取(Get)。它代码块中的 if 命令将收到的第一个参数 meth 与此列表进行比较。如果它与列表中的某个方法匹配,则该参数(在前面加上冒号)将成为调用命令 ❷ 的参数,剩余的参数将作为附加参数传递。 在调用方法完成后,控制通过 goto :eof 命令 ❸ 立即返回到调用的 bat 文件,该命令位于 for 循环的末尾。注意,最后一个方法 ❾ 不在列表中。顾名思义,它是一个私有方法,只能从对象内部的方法中调用。如果一个调用的 bat 文件尝试调用任何不在列表中的其他方法,或者是一些根本不是方法的文本,代码将输出一个中止消息 ❹ 并在暂停后退出整个进程。(更强大的错误处理会更好,但这足以作为演示。) ##### 构造函数 在有面向对象约束的编程语言中,实例化一个对象时会自动调用该类所属的构造方法。事实上,甚至在调用构造方法之前,无法调用其他公共方法。构造方法可能会设置一些变量或打开一些文件,但其一般目的是设置对象,以便你可以在后续使用它。这个模型通过每个批处理对象顶部的 :Constructor 方法❺模拟了这一行为。 这个父对象的构造方法只是将两个变量设置为默认值,这样大多数单独的电影对象就不需要再次设置它们。过去几十年发布的大多数电影都是彩色拍摄并使用 35 毫米胶片拍摄的。这个构造方法只接受一个参数,即电影标题,所以对于一部特定的电影,两个设置命令分别将 35mm 和 color 的值赋给分别定义为 gauge{The Big Lebowski} 和 clrOrBW{The Big Lebowski} 的变量。这些变量名有空格,如果因为某些原因这造成了问题,你可以使用替换语法去掉空格,只要这样做时保持一致。 我们还需要为情节、上映年份以及其他特性设置变量,但这些显然没有默认值,因此我们会在其他地方设置它们。不过,你很快会看到,70 毫米黑白电影的对象将能够覆盖在父对象构造方法中设置的默认值。 ##### 设置器和获取器 在严格的面向对象编程中,类通常会有许多 setter 和 getter 方法;也就是说,对于每个定义的数据项,一个方法设置它,另一个方法获取它的值。例如,可能会有一对方法用于获取和设置计量仪表,还有一对方法用于情节,等等。但由于延迟扩展和批处理缺乏定义的数据类型,我们可以将所有的 setter 方法合并成一个单一的 :Set 方法❻,并且我们可以通过一个单独的 :Get 方法❼来处理所有的 getter 方法。 :Set 方法包含一行有趣的代码,调用的批处理文件可以用来为对象设置任何变量。这是一个使用三个参数的设置命令:被设置的特性是 %~1,赋值的内容是 %~2,而电影标题是 %~3。要设置的变量名是第一个和最后一个参数的组合:%~1{%~3}。 为了演示这个是如何工作的,你可以通过以下调用命令来调用 :Set 方法: ``` call :Set plot "A day repeats ad infinitum" "Groundhog Day" ``` 这次调用的结果将把第二个参数中的情节值设置为 plot{Groundhog Day} 变量,但注意在这个批处理文件中并没有显式地调用 :Set 方法。我稍后会展示如何从这个父级批处理文件外部进行此调用。 :Get 方法将赋值操作反转,返回值代替赋值操作。setter 和 getter 方法的参数相同,唯一值得注意的区别是 getter 的第二个参数是我们赋值给 plot 的变量名,而不是 plot 本身。方便的是,在感叹号内解析其他两个参数时,将提取 plot 的值作为赋值的一部分:!%~1{%~3}!。 下面是该方法的一种可能调用: ``` call :Get plot plotVar "Groundhog Day" ``` 这将返回将 plotVar 变量设置为值 A 的结果,表示一天重复无穷或我们之前传递给:Set 方法的相同字符串。 很容易忽视这背后真正的微妙之处。再一次,延迟扩展为我们提供了一个在大多数语言中都无法获得的有用且优雅的解决方案。令人吃惊的是,一个拥有数十个变量的对象只需要一个 setter 和一个 getter 方法。在至少某个小的方面,Batch 比传统的面向对象语言提供了更好的解决方案。 ##### 应用程序特定的公共方法 在传统的面向对象编程中,构造函数是必需的,setter 和 getter 是无处不在的,但通常还有其他应用程序特定的公共方法,我在*oMovie.bat*对象中就包含了一个这样的例子。:DispInfo 方法❽将电影的详细信息显示到控制台。该方法中的一系列 echo 命令将电影标题和其他几项数据写入控制台。例如,它的第一行提到电影本身,并详细介绍了上映年份、是否为彩色或黑白影片以及胶片规格。 这两个最后的变量是在构造函数中设置的,但年份变量和其他变量并没有设置。事实上,它们在父 bat 文件中唯一的提及就在这里。(我很快会分享它们的来源,但这与继承有关。)无论这些数据来自何处,这个方法可以为任何直接或间接派生自该对象的电影对象显示信息。这就是多态性。 > 注意 *如果像 plot{Groundhog Day}这样的变量让你想起哈希表元素,那是因为它们实际上就是哈希表的元素(见第二十九章)。你不需要理解数据结构就能理解这个模型,但在执行逻辑以为 17 部不同电影构建 17 个情节之后,plot 哈希表包含了 17 个元素。* #### 中间对象 在这个模型中,所有的类型对象都是中间对象,*oComedy.bat*对象就是其中一个例子。它是*oMovie.bat*的子类,但它也有自己的子对象,其中两个在图 32-1 中显示。 列表 32-2 展示了完整的 bat 文件对象,*oComedy.bat*即为其中之一。 ``` ❶ set extends=C:\Batch\oMovie.bat set meth=%~1 ❷ for %%m in (Constructor PublicMeth) do ( if "%%m" equ "%meth%" ( ❸ call :%meth% "%~2" "%~3" goto :eof ) ) ❹ call %extends% %* goto :eof ❺ :Constructor call %extends% %meth% %* ❻ set genre{%~1}=Comedy goto :eof ❼ :PublicMeth :: some public method code ❽ call :PrivateMeth goto :eof ❾ :PrivateMeth :: some private method code goto :eof ``` 列表 32-2:中间对象,oComedy.bat 一眼看去,这看起来和父对象的结构很相似,但也有不同之处。第一行定义了另一个批处理文件,紧接着交通警察之后的是一个调用命令,而不是中止命令。构造函数还调用了另一个批处理文件,并且设置器和获取器方法都缺失了,还有用于显示电影信息的方法也不存在。 严格的面向对象语言有保留字和语法,允许一个对象无缝地继承另一个对象。与 Batch 不同,这就是子类如何使用 extends 关键字来继承 Java 中父类的方法: ``` public class oComedy extends oMovie {} ``` 在 Batch 中,没有类似的内建语法,我们的做法是完全不同的。*oComedy.bat* 对象中的第一条语句定义了它继承的父对象批处理文件,即 *oMovie.bat* ❶,它也是它扩展的对象。然后,交通警察看起来和父对象中的差不多,维护着自己在 for 命令中的公共方法列表❷。如果第一个参数与列表中的两个公共方法匹配,调用命令就会调用它❸。但在父类中,如果要调用的方法不是公共的,它会中止,而子对象则会调用通过 extends 定义的父类,传递相同的参数❹。这就是继承。 中介(和子)对象可以定义父对象中尚未定义的方法。它还可以通过执行我已经展示的操作,继承父对象的方法。如果第一个参数不在公共方法列表中,这个批处理文件就成为一个简单的透传,因为它会做相同的调用传递给父对象❹。%* 语法确保了这个批处理文件接收到的参数列表就是传递给父对象的参数列表。 我们很快就能通过刚才描述的批处理继承方法调用父对象中的重要方法(:DispInfo、:Set 和 :Get)。优雅的是,所有的类型对象及其子对象都可以轻松地使用这些方法。中介对象不仅不需要重现这些方法,甚至无需提及它们。如果有人错误地将一个未在此处或父对象中公开的方法传递给这个对象,会发生什么呢?中介对象会将其传递下去,父对象会在一个地方处理所有子对象的错误。 这个对象不仅继承了父对象的方法。构造函数展示了如何*扩展*父对象中已经定义的方法。父对象中有一个 :Constructor 方法,但是通过交通警察设置的继承方式并不会调用它。相反,中介对象的 :Constructor 方法❺ 会覆盖它,但这并不意味着我们永远不会调用父对象的构造函数逻辑。 :Constructor 方法的第一行调用父对象以调用其同名的方法,即构造函数。这是传统面向对象语言的另一个内置特性,我们必须模仿。在调用父类构造函数之后,派生对象的构造函数对数据进行处理。记住,*oMovie.bat*对象在其构造函数中将电影定义为色彩和 35mm 格式。这里,*oComedy.bat*对象接受这两个值,并将类型定义为喜剧❻。正如我们将很快看到的,它的子对象将进一步扩展这一点。 最后,注意两个恰如其名的方法,一个是公共的❼,另一个是私有的❾。私有方法没有列出在交通警察中,因此我们只能在此 bat 文件内部的某个地方显式调用它,例如从公共方法❽内部。 #### 子对象 最后,我们来到了每部电影的对象。清单 32-3 是一个电影对象的完整 bat 文件,*oBigLebowski.bat*。 ``` ❶ set extends=C:\Batch\oComedy.bat set meth=%~1 ❷ set title=The Big Lebowski ❸ for %%m in (Constructor) do ( if "%%m" equ "%meth%" ( call :%meth% "%~2" "%~3" goto :eof ) ) ❹ call %extends% %* "%title%" goto :eof ❺ :Constructor ❻ call %extends% %meth% "%title%" ❼ set plot{%title%}=The Dude seeks recompense for a valued rug micturated upon. set star{%title%}=Jeff Bridges set cast{%title%}=John Goodman, Julianne Moore, Steve Buscemi set lang{%title%}=English set year{%title%}=1998 set cntry{%title%}=United States goto :eof ``` 清单 32-3:子对象,oBigLebowski.bat 这个子对象中的交通警察❸看起来很像中间对象中的交通警察,但它现在扩展了*oComedy.bat*对象❶,并且新的标题变量❷定义了电影。我们将标题作为最终参数❹传递给其父对象,在那里它被用来设置变量,例如在*oMovie.bat*中的 gauge{The Big Lebowski}和在*oComedy.bat*中的 genre{The Big Lebowski}。 这个子对象只有一个公共方法,没有私有方法,但它可以很容易地有多个任何类型的方法。(由于公共方法列表中只有一个条目,我们可以去掉 for 命令,只保留 if 命令。) :Constructor 方法❺比中间对象中的构造函数更有趣。它首先调用其父类的构造函数,即*oComedy.bat*❻,而我们已经看到中间对象中的构造函数将依次调用其父类的构造函数,即*oMovie.bat*。两个父类在返回控制权给单个电影对象的构造函数之前,都会设置一个或两个变量,在该构造函数中,设置与实际电影相关的所有内容,例如剧情、明星和演员阵容❼。 现在我们可以为其他电影设置类似的对象 bat 文件。请注意之前的对象与以下对象之间的异同,*oLifeOfBrian.bat*,它包含了另一部伟大电影的数据和构造方法,见清单 32-4。 ``` set extends=C:\Batch\oComedy.bat set meth=%~1 set title=The Life of Brian for %%m in (Constructor) do ( if "%%m" equ "%meth%" ( call :%meth% "%~2" "%~3" goto :eof ) ) call %extends% %* "%title%" goto :eof :Constructor call %extends% %meth% "%title%" set plot{%title%}=A very naughty boy is taken for the Messiah in old Judea. set star{%title%}=Graham Chapman set cast{%title%}=John Cleese, Eric Idle, Michael Palin set lang{%title%}=English set year{%title%}=1979 set cntry{%title%}=United Kingdom ❶ set troupe{%title%}=Monty Python goto :eof ``` 清单 32-4:子对象,oLifeOfBrian.bat 两个构造函数都在设置剧情、明星、演员阵容、语言、上映年份和国家,但这个还在设置 troupe{%title%}❶。*The Life of Brian*是由一个喜剧团体创作的,但这个变量不适用于大多数其他喜剧。每个构造函数对于特定的电影和 bat 文件都是独特的;虽然它应该在这个模型中定义一组特定的变量,但它可以包含与其电影相关的额外逻辑,正如该对象所展示的。 我之前已经暗示过,你可以在子类中重写父类构造函数中定义的变量,而这一操作发生在子类的构造函数中。例如,*oRagingBull.bat* 中的构造函数会将`clrOrBW{Raging Bull}`变量重置为黑白。类似地,对于采用非 35mm 格式拍摄的电影,比如数字格式,我们可以在子类的构造函数中重置该参数。 面向对象设计的另一个特性是子对象能够重写父类中定义的方法。你已经看到了方法的继承和扩展。刚才讨论的构造函数就是扩展方法的一个很好的例子。现在想象一下,如果没有调用父类中同名的方法,这就是一个*方法重写*。子对象永远不会调用父类中的公共方法,因为子类已经用自己的方法重写了它。 为了演示,我们最后考虑一个子对象,它的标题变量如下: ``` set title=Star Wars: Episode I - The Phantom Menace ``` 如果`DispInfo`在此特定子对象的公共方法列表中,编写此对象的程序员可以将以下方法添加到批处理文件中: ``` rem – Override Method :DispInfo > con echo "%title%" is the worst movie ever released. Members of an > con echo accomplished cast are reduced to one-dimensional characters > con echo upstaged by the uniquely annoying Jar Jar Binks in a plot so > con echo tortured it evokes war crimes. It's little more than a big budget > con echo excuse to unveil the technology to meld animated characters into > con echo live action. Only the most ardent Star Wars apologists and young > con echo children found it enjoyable, but the racist tropes made it > con echo unsuitable for viewing by anyone of any age. > con echo. goto :eof ``` 声明该方法是重写的方法并不是必需的,但这样做是良好的编码习惯。 注意,该方法并没有调用其父类中对应的方法。它只是写了几行注释,接着是一个空行,什么也没有做。它完全取代或重写了父类中同名的方法。与其他电影不同,这个标题不会像我们在使用`:DispInfo`方法的其他电影中看到的那样,将情节、演员阵容和其他信息输出到控制台中。这个批处理文件的作者显然觉得,为如此独特的电影的显示信息需要一些特别的处理。(请不要发恨信;原版三部曲很好,甚至比好还要好。) ### 执行面向对象批处理 我们已经创建了`*oMovie.bat*`父对象和`*oComedy.bat*`中介对象,后者扩展或继承自父对象。理论上,我们可以创建更多的中介(或类型)对象,以及超过两百万个子对象用于个别电影,但眼前我们只有两个子对象的批处理文件,分别代表两部喜剧片,每部都扩展了`*oComedy.bat*`对象。最后,我们可以执行这段面向对象的代码。如果没有其他方法,你可以使用第三十一章中提到的`*Test.bat*`文件,但你需要将所有调用这段代码的操作放入一个不在模型中的批处理文件。 实现代码只调用子对象;父类和中介类对象仅通过其子类来调用。让我们从这四个命令开始: ``` call C:\Batch\oBigLebowski.bat Constructor call C:\Batch\oLifeOfBrian.bat Constructor call C:\Batch\oBigLebowski.bat DispInfo call C:\Batch\oLifeOfBrian.bat DispInfo ``` 记住,必须先调用构造函数,才能调用对象中的其他方法。这前两个命令是批处理中实例化对象的等效操作。 我们首先调用*oBigLebowski.bat*,它的交通警察调用了其:Constructor 方法;正如我们已经看到的,那个构造函数又调用了其父对象*oComedy.bat*中的构造方法,再由*oComedy.bat*调用其父对象*oMovie.bat*中的相应方法。每个 bat 文件都为描述这部电影的九个变量的创建做出了贡献。接着,我们对*oLifeOfBrian.bat*执行相同的操作,创建第二组电影变量。 构造函数不会生成任何输出,但最后两个调用命令会将示例 32-5 中显示的信息输出到控制台。 ``` "The Big Lebowski" is a 1998 color 35mm film. Genre: Comedy Plot: The Dude seeks recompense for a valued rug micturated upon. Starring: Jeff Bridges Cast: John Goodman, Julianne Moore, Steve Buscemi Country: United States Language: English "The Life of Brian" is a 1979 color 35mm film. Genre: Comedy Plot: A very naughty boy is taken for the Messiah in old Judea. Starring: Graham Chapman Cast: John Cleese, Eric Idle, Michael Palin Country: United Kingdom Language: English ``` 示例 32-5:使用两个不同子对象的两个 DispInfo 调用的输出 第三个命令调用了*oBigLebowski.bat*,并将 DispInfo 作为唯一参数传入。这个子对象没有找到名为:DispInfo 的公共方法,因此继承机制启动,调用它的父对象*oComedy.bat*,但该方法在*oComedy.bat*中也不存在。由于继承,再次调用基类对象*oMovie.bat*,最终找到了并执行了该公共方法。它会将关于*The Big Lebowski*的简要信息,以七行文本的形式显示在控制台上。 最后的命令也提供了大量信息,但关于的是另一部电影,因为它调用了*oLifeOfBrian.bat*对象。我们最终调用的是完全相同的方法:DispInfo,且在完全相同的对象*oMovie.bat*中,但请注意输出结果的显著差异。输出描述了两部完全不同的电影。这就是多态性。 批处理具有一个至关重要且相当独特的特性,有助于实现多态性:延迟展开。在大多数编程语言中,解析变量是直接的,但批处理允许你解析一个或多个变量,以创建一个变量名,然后该变量名本身也可以被解析。 两组输出都来自父对象*oMovie.bat*中的:DispInfo 方法(示例 32-1)。以一个变量为例,延迟展开首先将嵌入的参数!plot{%~1}!解析为电影标题。在第一次调用中,这会得到!plot{The Big Lebowski}!,然后解析为该电影的情节。第二次调用时,完全相同的代码会得到!plot{The Life of Brian}!,这时解析为完全不同的情节。这就是批处理的多态性表现。 编写*oBigLebowski.bat*对象的人在演员名单中遗漏了一位奥斯卡获奖演员。已故的菲利普·塞默·霍夫曼在电影中扮演了一个相对较小的角色,他是标题角色的傲慢私人助理 Brandt,但他以精湛的演技和细腻的表现力预示着未来更大的角色。以下两个命令将他加入当前的演员名单: ``` call C:\Batch\oBigLebowski.bat Get cast lebowCast call C:\Batch\oBigLebowski.bat Set cast "%lebowCast%, Philip Seymour Hoffman" ``` 两个调用命令的第一个参数是要调用的方法名称,第二个参数是与调用相关的变量类型,并且在这两种情况下都会进行类型转换。第一个命令会获取当前的演员阵容,并将其作为 lebowCast 变量的内容返回。第二个命令会将当前演员阵容的列表设置(或重置)为刚刚返回的值,并附加上霍夫曼先生的全名。如果你再次调用*oBigLebowski.bat*对象,并传入 DispInfo 参数,你将看到演员阵容中包含四位演员,而不是示例 32-5 中列出的三位。 ### 推荐 为了让这个复杂的例子尽可能易于理解,我一直没有提到一些可能的调整,直到现在。 我曾分享过严格的 OOP 要求在使用对象之前必须先调用构造函数。这个模型将这一限制留给了自律系统,但你可以通过一个“实例化”开关来解决这个问题。首先,子对象的构造函数在执行时会设置这个开关。然后,在尝试调用公共方法之前,你可以检查这个开关,如果它还没有被设置,则抛出一个中止错误。这几乎肯定是大材小用,但实现起来既不困难也不优雅。我更倾向于这里使用自律系统。 我从未在《蒙提·派森》电影中使用过喜剧团体的变量集,但你可以在*oLifeOfBrian.bat*对象中定义一个:DispInfo 方法,以扩展来自*oMovie.bat*的同名方法。子对象在调用以继承父对象(或父对象的父对象)执行的显示方法后,它可以为喜剧团体附加类似的行。更好的是,由于喜剧团体只适用于某些喜剧类,新的方法可以放入*oComedy.bat*对象中,在那里只有在团体存在时才会显示。 在 OOP 中最重要的元素是我们未能在 Batch 中成功复制的数据隐藏。当设置一个变量时,它会在环境中存在,直到重新赋值或进程终止,而且没有现实的手段来防止它被其他代码重新赋值。我们可以使用 setlocal 和 endlocal 命令来隐藏变量,但在执行 endlocal 命令后,这些变量会消失。正如我在第十六章中提到的,变量可以在 endlocal 命令后存活,但这样做会使它们变为全局变量,这就违背了“数据隐藏”的原则。我们可以将变量隐藏在临时文件中,并在下一次通过代码时恢复它们,但这无疑是大材小用。 无法声明私有变量是一个限制,这使得 Batch 对象无法像其他语言那样广泛适用于众多用户。但是,让我们退一步来看一下整体情况。将一个对象普遍提供给大众并不是我所设计的目标;这是 Batch 语言,并且无论如何也没有实际的基础设施来支持那种规模的应用。 我建议构建面向自己使用的对象,可能是与少数朋友或同事在同一网络上合作时使用。这并不是你希望从 Web 服务中调用的东西。因此,无法隐藏数据的唯一实际后果是,少数开发人员必须意识到他们不应该干扰某些对象中使用的变量。 批量面向对象设计是一种我强烈推荐的方法论。你可能不会像我在这里展示的模型那样广泛地实现它,也不应该这样做。(更多内容将在下一章和最后一章讨论。)即使只采用该方法论的一部分,对你来说也可能非常有用。即便没有使用继承,你也可以编写一个具有多态性的模块,它会根据调用者的不同而表现出不同的行为。继承的简化版本可能依赖于一个中央的批处理文件来执行一些重要任务。仅仅编写小型且可重用的模块,就能带有面向对象设计的气息,且是一个巨大的进步。 ### 批量中的四大支柱 你已经看到了,在这个批量模型中,我们能够出奇地好地模拟面向对象编程(OOP)的许多特征。让我们再仔细看看四个支柱,详细说明我们实际上距离目标有多近。 抽象,作为第一个支柱,简化了代码的接口,只展示必要或相关的特性,并且隐藏了实现细节。再看一下列表 32-3 和 32-4 中的两个完整的子对象。获取和设置变量的方法,以及显示电影信息的方法,都已经被抽象化了。除了每个对象顶部列出公共方法并处理继承的逻辑外,实际上只有一个方法,即构造函数,且其中的所有内容都与该对象相关。批量面向对象设计中的抽象获得了很高的评价。 更小、更丰富的模块呈现了更优的设计,无论使用什么语言或编码方法。每个文件都有明确的目的,代码更加易于维护和扩展。我在本章中展示的模型有助于编写简短、紧凑且可重用的代码模块,这也是定义第二个支柱——封装的三个组成部分之一,另外两个组成部分是数据和方法隐藏。在批量中,方法隐藏是微不足道的;实际上,我们不得不做一些工作来暴露公共方法。然而,在这个模型中,数据隐藏根本不被支持。所有为不同电影设置的变量都在环境中浮动,任何人都可以随时修改它们。三项中的两项已经及格。 继承的示例,第三个支柱,散布在前面的页面中。子对象继承了中介对象的构造函数,而中介对象又继承了父对象的构造函数。显示信息到控制台的方法,以及设置器和获取器方法,都是继承的典型例子。我们在父对象中编写了一次这些方法,供多达两百万个子对象使用。我甚至展示了如何在子对象中重写一个方法。为了使其工作,我们在每个 bat 文件的顶部需要一些冗余代码,但在继承方面,这个模型显然在同类中名列前茅。 第四个支柱,多态,允许代码在不同情况下表现出不同的行为,显示电影信息的方法就是这一功能的一个典型示例。我们在一个单一的 bat 文件中使用单一的方法,写入几百万部电影的独特信息——其中显示的两个变量最初是在父对象的多态构造函数中设置的。设置器和获取器方法更进一步推动了这一过程。传统的面向对象编程(OOP)要求为每个已定义的变量提供一对这样的设置器和获取器方法。在这个模型中,一个设置器和一个获取器处理每个电影对象的所有变量。批处理的多态性是许多学生称之为*曲线破坏者*的东西。 我将把最终评分留给你,读者。我承认,由于缺乏数据隐藏,A 的分数是无法达到的,但我希望它并不太遥不可及。 ### 总结 批处理语言是作为面向对象的语言设计的吗?绝对不是。那么,是否可以在 bat 文件中构建面向对象设计的大部分内容——即抽象、封装、继承和多态?完全可以。批处理的面向对象设计能否达到其他语言的效率和规模?不能。在阅读完本章之后,多少批处理程序员会向他们的雇主提出一个全公司的面向对象项目?不多。如果这个数字大于零,那么有多少人能在三个月后继续留在公司?毫无疑问,没人能留下来。多少程序员会尝试将批处理面向对象设计的元素融入他们的代码中?很难说,但我希望能有不少人。 面向对象设计是本书的结局部分,那么为什么它仅仅是本书的倒数第二章呢?在接下来的最后一章,我将介绍两种数据结构:栈和队列。但我也将利用这些数据结构作为进一步展示面向对象设计的机会,因为这部分讨论远未完成。我在本章中详细描述的模型实现了所有可能的面向对象设计原则,但它并不是我通常编写批处理对象的方式。最好只使用所需的内容,而不是不需要的内容。在下一章构建栈和队列时,你将看到每种数据结构对应的实际对象,我还会分享一些关于批处理面向对象设计的最终想法。 ## 第三十三章:33 堆栈、队列和现实世界中的对象 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在本章中,我将从头开始构建两个 Batch 数据结构:堆栈和队列。它们都存储一组有序的有限值,唯一的区别是堆栈遵循后进先出(LIFO)原则,而队列遵循先进先出(FIFO)原则。我将详细介绍这两种数据结构的一般概念,并展示如何构建它们独特的功能。当然,你还将学习这些新工具的应用,甚至会制作一个将其他 bat 文件编译的 Batch 伪编译器。 然而,本章不仅仅是关于堆栈和队列的,它同样涉及现实世界中的 Batch 对象。我还将使用你在前一章学到的内容,为每个数据结构构建对象 bat 文件。这些对象将允许你同时维护多个堆栈或队列,并且它们理想地会激发你未来设计属于自己的现实世界 Batch 对象。我甚至会以对 Batch 面向对象设计的最终思考做结尾。 ### 堆栈 *堆栈* 是一种包含有序数据项的结构,按*后进先出(LIFO)*的方式组织。毫无疑问,堆栈最好的比喻就是带弹簧的自助餐盘分配器。每个盘子的重量推动分配器底部的弹簧,使得只有堆栈最顶端的盘子可以拿到。增加更多盘子后,堆栈会下沉,只剩下新的顶盘露出。第一个添加的盘子在堆栈底部,而第一个被取出的盘子是堆栈顶端最近添加的盘子。你不能在不先移除上面的盘子的情况下访问堆栈下方的盘子。拿掉顶盘后,堆栈会升高一个盘子的高度,暴露出刚才位于下方的盘子,同时保护其他盘子免受可能不小心且不卫生的干扰。 同样,你可以将一个数据项添加或*压入*堆栈,接着添加更多的数据项。在任何时刻,你都可以检索或*弹出*堆栈顶端的数据项——即获取最近添加的项。大多数面向对象的编程语言都提供了内建的堆栈数据结构,包含将项压入堆栈、弹出最后一项和查看最后添加项(但不移除它)的方法。还应该有方法来清空堆栈,并确定堆栈是否为空。 Batch 没有这种内建的数据结构,但如果你还跟得上本章的内容,我会假设你也喜欢挑战创建非典型的 Batch 功能。Batch 中堆栈的底层结构仅仅是一个单一的变量。要将第一个值压入堆栈,我们只需将其赋值给堆栈变量。然后,我们通过在该变量前面添加新项来将后续数据项压入堆栈,这样会把现有项推到堆栈下方。 要执行 pop 操作,我们将从变量中提取出第一个项目,剩下的则保留不动。peek 操作则类似,只是我们不改变堆栈的内容。为了实现这一点,我们需要确保每个项目之间有一个分隔符,且该分隔符不会出现在数据中。空格、管道符和逗号都是不错的选择,具体取决于数据的预期内容,但我个人偏好使用制表符。如果你的数据中可能包含制表符,建议选择其他符号作为分隔符,甚至可以由用户自定义变量来设定分隔符。 为了展示实现堆栈所需的不同功能模块,我将构建并使用一个朋友堆栈。第一个任务是给变量命名;任何名字都可以,但我会使用 stkFriends 变量。我的惯例是将堆栈的名称前缀加上 stk,以便让任何偶然看到它(且知道这个惯例)的人清楚地知道这个变量是堆栈的体现。很快,我会把这些功能整合到一个对象中,但首先我将逐步讲解 push、pop 和 peek 函数的方法。 **Push** 合理的起点是*push*,这条独立的命令就足以将 Walter 推入堆栈: ``` set stkFriends=Walter▶%stkFriends% ``` 在这里以及以后的代码示例中,我使用实心箭头来表示制表符(tab)。默认情况下,大多数编辑器中它与一个或多个空格无法区分,但如果使用 Notepad++,你可以通过选择**视图** ▶ **显示符号** ▶ **显示空格和制表符**来显示制表符为箭头。(其他编辑器也有类似功能,如果你到现在一直在使用记事本,显然你是一个自虐狂。) 我在命令中两次使用了 stkFriends 变量,将其赋值给自身,并用百分号标记解析出来,前面加上我正在添加的项,Walter,以及分隔符——制表符。这实际上已经将该值添加到了堆栈顶部。你可以轻松地将硬编码的数值替换成解析后的变量,代表要添加的朋友。 如果堆栈已经为空,这条命令将其填充为第一个值,然后是一个制表符,后面没有其他内容。 **Peek** *peek*函数能够查看堆栈顶部的项,但不会改变堆栈的内容。它只是“偷看”一下,而不是弹出一个项。我们无法知道堆栈中有多少个值,甚至是否有值,但我们知道如果 stkFriends 已被填充,其第一个制表符分隔的值位于堆栈顶部。 以下代码将堆栈顶部的内容赋值给 aFriend 变量: ``` set aFriend=& for /F "delims=▶" %%s in ("%stkFriends%") do ( set aFriend=%%s ) ``` 解析分隔数据显然是`for /F`命令的工作。我正在使用堆栈变量作为文本输入,由于我们用制表符分隔了变量,我将`delims`关键字设置为制表符。`tokens=1`子句是隐式的,这意味着定义为`%%s`的`for`变量解析为字符串中的第一个标记,并且由于第一个标记是最后放入堆栈的项,我们将其捕获为 aFriend。 还要注意,在`for`命令之前我清除了 aFriend 变量。如果堆栈为空,`for /F`命令不会执行,因此初始化变量可以确保空堆栈返回一个空值。 **弹出** *pop*函数与`peek`函数非常相似,唯一的显著区别是,它还会从堆栈顶部移除返回的数据项。请注意,这个任务的逻辑模仿了之前的列表,但有两个补充:`tokens`子句和最后的`set`命令: ``` set aFriend=& for /F "tokens=1* delims=▶" %%s in ("%stkFriends%") do ( set aFriend=%%s set stkFriends=%%t ) ``` 我没有使用隐式的`tokens`子句,而是将关键字设置为`1*`。这对`for`变量`%%s`本身没有影响。它仍然解析为堆栈上的顶部项,但现在解释器将文本字段的其余部分分配给第二个标记`%%t`。由于文本字段的其余部分是整个堆栈减去第一个项,我只需使用最后一个`set`命令将缩略堆栈重新分配给堆栈变量。整个过程类似于从巧克力棒的一端掰下一块,然后把剩余的巧克力棒重新放回包装纸里。 ### 队列 堆栈和队列就像巧克力和花生酱一样搭配。队列的核心是堆栈的*先进先出(FIFO)*版本。(这和后进先出相同,但 LILO 从未流行起来。)堆栈的比喻带我们去了自助餐厅,而队列的比喻带我们去了餐厅。如果你是没有预约的第一个到达者,在忙碌的时段,接待员可能会把你的名字写在名单的顶部。他们会将其他人添加到名单中,当桌子空出来时,你将是第一个被安排座位的人。名单上的其他人将按顺序上升,继续等待他们的轮次。 这是一个真正糟糕的商业创意。我将开一家煎饼餐厅,叫做 Stacks,并使用堆栈来等待入座的人。刚好在我们开始忙碌时到达的那一组人首先进入堆栈,他们会等待,或许耐心等待,因为其他人会被加入或从堆栈中移除(即被安排座位)。即使堆栈上有 20 组人,刚到的人也会得到下一个桌子。显然,这更适合用队列来实现。 在 Batch 中实现队列几乎与实现堆栈相同。唯一显著的区别是,当向队列添加数据项时,你需要将它附加到变量的末尾,而不是前面: ``` set queFriends=%queFriends%▶Walter ``` 在命名法上也有一些区别。首先,因为变量代表的是队列,所以我将其前缀从 stk 改为 que,但这仅仅是其中一种约定。其次,*push*和*pop*这两个术语只有在考虑堆栈时才有意义,比如盘子分发器。当对队列进行值的添加和移除时,我将使用更简单且准确的术语*add*和*remove*,分别表示添加和移除。 其他方面,这两个数据结构是相同的。请注意,之前的 set 命令也使用了制表符作为分隔符。add 和 remove 函数分别与 push 和 pop 函数完全相同,唯一的区别是变量名称,因为它们都针对变量中第一个以分隔符分隔的数据项。事实上,它们是如此相似,以至于我将在构建队列对象时才向你展示这些函数。 警告 *我们生活在一个有限的世界中,就像自助餐盘分发器只能接受有限数量的盘子一样,批处理也有与堆栈和队列相关的限制。一个变量不能超过 32,767 字节,且由于堆栈和队列的设计依赖于单个变量来保存整个数据结构,因此堆栈或队列中所有数据项和分隔符的累积大小不能超过这一限制。例如,你可以存储 16,383 个一字节的值,或者 1,927 个十六字节的值。对于大多数应用来说,这已经足够,但如果不够,你可以使用数组和指针来构建这些数据结构,以跟踪相关的索引。* ### 现实世界中的批处理对象 在第三十二章中,我探讨了批处理面向对象的设计。我详细介绍了一个实现所有可能功能的模型,涉及面向对象编程的四个支柱,但事实上,这个模型并不代表典型的对象 bat 文件。幸运的是,堆栈和队列为批处理对象提供了有教育意义的现实世界示例。 #### 堆栈对象 我将分两部分展示堆栈对象 bat 文件,*oStack.bat*,首先是注释和主逻辑: ``` rem ****** Stack Object ****** rem parm 1 – Name of Stack rem parm 2 - Method: Push, Pop, Peek, Clear, or IsEmpty rem parm 3 - Input/Output Variable: rem Value Pushed, Variable Popped, or Variable Peeked rem Boolean for isEmpty cmd /C exit 0 call :%~2 "%~1" "%~3" || ( > C:\Batch\Log.txt echo ** ERROR - Invalid Method Name "%~2" exit ) goto :eof ``` 这段代码与上一章中的对象最显著的区别是,我将隐藏私有方法的“交通警察”替换为一个 call 命令,公开所有方法为公共方法。 在第三十二章中,所有的对象 bat 文件都有一个 for 命令,包含公共方法的列表,但*oStack.bat*对象 bat 文件只有公共方法。因此,call 命令不再维护列表,而是直接将执行引导到相应的方法,从而使所有方法都变为公共方法。由于第二个参数是要调用的方法名称,我将去掉可能的双引号,并在其前面加上冒号,以形成被调用标签的名称: :%~2。 堆栈的名称是传递到 bat 文件中的第一个参数,也是我传递给每个方法的第一个参数。传递给每个方法的第二个参数是第三个输入参数,它的用途因方法而异。但与其在这里讨论,不如直接指引你查看 bat 文件顶部的注释,明确列出了该对象及其公共方法所接受的参数。 两个管道符和调用命令后的代码块是条件执行的实际应用。(在第二十八章中,我提供了一个详细的示例,解释了为什么 bat 文件顶部的 cmd 命令会将返回代码重置为 0。)结论是,如果对象接收到有效的方法,它会成功调用该方法;如果参数是无效的方法名称,则此逻辑会将错误信息写入 *Log.txt* 并结束执行。因此,所有方法都是公共的。 如果没有这个条件执行和错误处理会发生什么呢?如果每次调用 bat 文件时都传递有效的方法名称,那么它会正常工作。然而,如果第一个参数错误地传递了指代山顶的 Peek 的同音词,解释器会将以下内容写入 stderr,且执行会继续进行: ``` The system cannot find the batch label specified - Peak ``` 如果解释器没有找到或调用该方法,也没有设置返回变量(如果适用),则会导致下游结果无法预测。像这样的情况值得立即终止,以引起我们的注意,这正是条件执行和基础错误处理所要完成的任务。 以下是 *oStack.bat* 的其余部分及其在之前注释中提到的公共方法: ``` :Push set stk%~1=%~2▶!stk%~1! goto :eof& rem End :Push :Pop set %~2=& for /F "tokens=1* delims=▶" %%s in ("!stk%~1!") do ( set %~2=%%s set stk%~1=%%t ) goto :eof& rem End :Pop :Peek set %~2=& for /F "delims=▶" %%s in ("!stk%~1!") do ( set %~2=%%s ) goto :eof& rem End :Peek :Clear set stk%~1=& goto :eof& rem End :Clear :IsEmpty if defined stk%~1 ( set %~2=false==x ) else ( set %~2=true==true ) goto :eof& rem End: IsEmpty ``` 我已经在“堆栈”部分讨论了推送(push)、弹出(pop)和查看(peek)功能的机制,在这个对象中,你会找到每个功能的多态版本,并且在适当命名的标签下。比如,:Push 方法将 stk%~1 变量设置为 stkFriends。为了使这个对象能够与多个堆栈一起工作,它不能显式地引用某个特定的堆栈变量。相反,它通过解析第一个参数并将其与 stk 文本连接起来,来构建堆栈的名称,然后将其设置为 %~2、制表符字符和 !stk%~1! 的拼接结果。第二个参数是被推送到堆栈上的值,制表符字符是分隔符。此命令第二次使用 stk%~1 变量来检索堆栈上的现有值,通过感叹号和延迟扩展解析该变量。最终,这会将第二个参数推送到堆栈上。 :Pop 和 :Peek 方法也使用 stk%~1 来设置和/或解析堆栈。这些方法中的唯一新特性是,我将 %~2 设置为返回值,因为这两个方法的第二个参数是要返回的变量的名称。 为了使这个对象成为一个合适的堆栈对象,它必须提供两个其他语言中堆栈数据结构典型的方法。第一个方法接受堆栈的名称作为唯一参数,并清空堆栈中的所有数据项。为了完成任务,:Clear 方法只需将 stk%~1 设置为空。 另一个方法返回一个布尔值,如果堆栈为空,则为 true;如果堆栈中有项,则为 false。:IsEmpty 方法决定 stk%~1 是否已定义。根据结果,它将第二个参数设置为 true 或 false。 现在我们准备使用堆栈对象。这四个调用将三个朋友添加到新的堆栈中,其中马蒂是最后一个加入的: ``` set stack=C:\Batch\oStack.bat call %stack% Friends clear call %stack% Friends push Walter call %stack% Friends push Donny call %stack% Friends push Marty ``` 第一个调用可能不必要,但如果 stkFriends 变量已定义,这个调用会初始化它。即使这个设计没有构造函数,你也可以把它当作构造函数来理解。 多次执行此操作后,aFriend 变量每次都会返回马蒂,因为它仅查看数据项: ``` call %stack% Friends peek aFriend ``` 这在调用时也返回马蒂,但仅返回一次: ``` call %stack% Friends pop aFriend ``` 下一个 pop 调用返回 Donny,这是在马蒂之前放入堆栈的值。 现在只有沃尔特仍然留在堆栈中。你可以再添加两个朋友,但沃尔特仍然处于底部位置: ``` call %stack% Friends push "The Stranger" call %stack% Friends push Maude ``` 记住,包含空格的参数需要用双引号括起来,*oStack.bat* 使用波浪号巧妙地处理了这些。 isEmpty 调用返回一个布尔变量: ``` call %stack% Friends isEmpty bool if %bool% (> con echo Empty) else (> con echo NOT Empty) ``` 由于堆栈中有三个项,因此布尔变量的值为 false,这条逻辑在控制台上显示“NOT Empty”。 最后,为了从头开始,我们可以清空堆栈中的所有数据项: ``` call %stack% Friends clear ``` 再次调用该对象以调用 isEmpty 方法,它会返回布尔值 true。 #### 队列对象 由于堆栈和队列非常相似,我将队列对象建模为堆栈对象。但是,当你检查堆栈对象 bat 文件 *oQueue.bat* 的完整内容时,仍然可以注意到许多微妙的区别,只有一个显著的不同: ``` rem ****** Queue Object ****** rem parm 1 - Name of Queue rem parm 2 - Method: Add, Remove, Peek, Clear, or IsEmpty rem parm 3 - Input/Output Variable: rem Value Added, Variable Removed, or Variable Peeked rem Boolean for isEmpty cmd /C exit 0 call :%~2 "%~1" "%~3" || ( >> C:\Batch\Log.txt echo ** ERROR - Invalid Method Name "%~2" exit ) goto :eof :Add ❶ set que%~1=!que%~1!▶%~2 goto :eof& rem End :Add :Remove set %~2=& for /F "tokens=1* delims=▶" %%q in ("!que%~1!") do ( set %~2=%%q set que%~1=%%r ) goto :eof& rem End :Remove :Peek set %~2=& for /F "delims=▶" %%q in ("!que%~1!") do ( set %~2=%%q ) goto :eof& rem End :Peek :Clear set que%~1=& goto :eof& rem End :Clear :IsEmpty if defined que%~1 ( set %~2=false==x ) else ( set %~2=true==true ) goto :eof& rem End: IsEmpty ``` 我已经将变量名称前的 stk 文本改为 que,原因显而易见。:Push 和 :Pop 方法分别被 :Add 和 :Remove 方法替代,且对象顶部的注释清晰地反映了这些变化。显著的变化在于 set 命令❶,它将数据项添加到队列中,使用 :Add 方法。现在它将值添加到队列的末尾,而不是开头,这意味着第一个进来的项是第一个出去的项。 方法名称略有不同,但该对象的执行应该看起来很熟悉。以下是五个调用的示例: ``` set queue=C:\Batch\oQueue.bat call %queue% Friends clear call %queue% Friends add Walter call %queue% Friends add Donny call %queue% Friends add Marty call %queue% Friends peek aFriend ``` 最后的调用返回一个名为 aFriend 的变量,里面包含沃尔特的值,这是第一个添加到队列中的数据项。与堆栈对象相比,它为一个非常类似的调用返回了马蒂。 ### 堆栈和队列的应用 你可以在需要按顺序处理数据的地方找到队列对象的应用。你可能创建一个队列来保存从某个来源(可能是一个或多个文件)检索到的服务器名称列表。然后,当你从队列中移除每个服务器时,可以对该服务器执行特定的任务。任务可能是简单的验证服务器是否正常运行,或者可能涉及目录创建或复杂的文件移动。重要的是,队列允许你按顺序处理每个服务器。 使用交互式批处理,你可能会要求用户提供多个输入。与其一个接一个地获取数据并处理,再询问下一个,你可以提前要求所有数据,并将其全部添加到队列中。然后,bat 文件可以按顺序处理每个数据项,而无需再提问。 栈的应用并不总是显而易见的,但当你需要它时,通常它是唯一适合这个任务的工具。你可以使用栈来逆序排列字母或单词,寻找回文。递归是栈的另一个应用。每次递归调用时,解释器会将所有变量的当前状态推送到栈中。你无法直接访问这个栈,但在递归调用过多时,解释器会报告递归已超过“栈限制”(第二十三章)。这并非巧合。 使用栈,你甚至可以为 bat 文件或其他未编译的源代码创建一个伪编译器。我不想过度宣传这个功能;真正的编译器会执行许多任务,虽然我们可以在执行这些任务中的某一项(括号平衡)上取得很大进展,但它不会是万无一失的。这个伪编译器的概念是,一个开括号需要匹配的闭括号,就像大括号和方括号必须成对出现一样。它们可以深度嵌套,但方括号不能闭合大括号。 以下是 *PseudoCompiler.bat* 的完整内容,这是一个尝试平衡 bat 文件中所有括号的 bat 文件: ``` @setlocal EnableExtensions EnableDelayedExpansion @echo off set stack=C:\Batch\oStack.bat call %stack% Compiler clear ❶ for /F "usebackq tokens=*" %%r in ("%~1") do ( set rec=%%r ❷ for /L %%i in (0,1,100) do ( set byte=!rec:~%%i,1! ❸ if .^!byte! neq .^" ( ❹ for %%c in ({[^() do ( if "!byte!" equ "%%c" ( ❺ call %stack% Compiler push !byte! ) ) ❻ for %%p in ("[:]" "{:}" "(:)") do ( ❼ for /F "tokens=1,2 delims=:" %%x in (%%p) do ( if "!byte!" equ "%%y" ( ❽ call %stack% Compiler pop popped if "!popped!" equ "" ( > con echo ABORT Unmatched Close Bracket pause & exit ) if "!popped!" neq "%%x" ( > con echo ABORT Bracket Mismatch pause & exit ) ) ) ) ) ) ) ❾ call %stack% Compiler isEmpty bool if not %bool% ( > con echo ABORT Unmatched Open Bracket pause & exit ) ❿ > con echo Successful Pseudo-Compile pause ``` 从高层次来看,这个 bat 文件接受一个文件作为唯一参数进行伪编译❶。当这段代码遇到任何类型的括号(包括圆括号)时,它会将该字符推送到栈中❺。然后,每当它遇到任何类型的闭括号时,它会从栈中弹出最后一个开括号❽,并检查它们是否是匹配的。如果不是,它将中止。当它完成读取 bat 文件后,需要验证栈是否为空❾,因为如果栈不为空,我们必须中止,因为输入中至少有一个未闭合的括号。 更深入的探讨展示了本书中介绍的许多技术,拆解一个四层嵌套的 for 命令总是很有趣。在清空编译器栈后,外层的 for /F 命令❶接受唯一的参数 %~1 作为输入,并按顺序读取每一条记录。你可以通过将 bat 文件拖放到 *PseudoCompiler.bat* 上来伪编译任何 bat 文件。 `for /L`命令❷遍历输入记录的前 100 个字节。双引号在后续的`if`命令解析时会引起问题,因此第一个`if`命令❸巧妙地过滤掉了有问题的字符。我使用了两个转义字符来进行比较,并且在下一行中,我执行了一个不带选项的`for`命令❹,将每一种可能的左括号传递给其代码块。它将大括号和中括号当作文本处理,但我必须对左圆括号进行转义。如果我发现了三个字符中的一个,我将左括号压入栈中❺。 另一个`for`命令❻是处理闭括号实例的驱动程序。它将三对左右括号(由冒号分隔)传递到其代码块中,最后的`/F`命令❼会将每一对括号拆分为两个标记。如果我检查的文件字节与第二个标记匹配,我就找到了一个闭括号,于是我将栈中最后添加的项目弹出❽。如果该字节为 null,则闭括号是孤立的,代码会中止并向控制台写入一个基础消息。如果弹出的字节与相应的左括号不匹配,程序会报告括号不匹配的错误信息。 如果我们在整个文件中都没有发现不匹配的括号,尾部逻辑会检查栈是否为空,确保所有括号都已经匹配❾,并使用布尔值进行判断。如果有孤立的括号存在,代码会中止,因为至少有一个没有匹配的左括号。在清除这个最后的`if`命令之后,括号已经全部平衡,代码成功地报告了匹配成功❿。 这段代码展示了栈的一个很好的应用,但它有局限性,正如前面提到的,远非万无一失。首先,它假设每个记录不超过 100 个字节。一般来说,错误处理方面也有很大的改进空间。至少,它应该追踪违规的行号。 当解释器处理每个记录时,它会解析由感叹号分隔的变量,因此此例程不会验证包含括号的变量名(例如数组和哈希表)。这些变量可能会解析为空,但如果两个批处理文件共享任何变量,也可能导致问题。例如,当我尝试递归伪编译*PseudoCompiler.bat*(通过复制并粘贴到自身),`popped`解析成了一个左圆括号,导致它错误地报告了一个不匹配错误。这个自我伪编译也在未配对的左括号❹被当作文本数据处理时失败。 即使在这些限制下,强调了伪编译器中的*伪*性质,这仍然是追踪典型批处理文件中大多数缺失括号的可靠方法——并且是栈的一个伟大应用。注意,这个批处理文件调用了我们之前构建的栈对象,用于四个不同的任务:清空栈、压栈、弹栈,以及判断栈是否为空。 > 注意 *关于嵌套 for 命令的最后一点,注意我为变量选择了描述性名称,确保它们之间不会冲突。我捕获整个记录(%%r),并用索引(%%i)迭代它,抓取单个字节(%%b)。然后,我将一对(%%p)括号传入循环,将它们拆分成开放括号(%%x)和隐含的闭合括号(%%y)。最后两个变量可能不够描述性,但由于它们必须是连续的字母字符,所以我的选择有限。* ### 批处理面向对象设计的最终思考 我本可以讨论堆栈和队列数据结构,而不将它们呈现为对象。本章中的方法本可以是普通批处理文件中的例程,数据结构的名称是硬编码的,但堆栈和队列的重要性使得本章不仅仅是关于这些数据结构,它还涉及到现实世界中的面向对象设计。 前一章中的面向对象示例(第三十二章)本质上是教学性的。它展示了尽可能多的批处理面向对象设计的各个方面,但本章中的这两个对象则是现实世界中批处理对象的示例。当我编写对象时,它们更可能类似于*oStack.bat*,而不是*oMovie.bat*或*oComedy.bat*。 我希望你能看到将所有与堆栈相关的代码放入一个可重用且简洁的批处理文件中的优势——也就是一个对象。这使得你能够通过这个对象编写数百个未来的堆栈,而不需要考虑堆栈本身是如何实现的。考虑到这一点,你可以重新审视数组和哈希表(第二十九章),想象为每个创建一个对象,并为其提供添加元素、检索元素和清除所有元素的方法。进一步想象为显示数组或哈希表内容、将所有元素写入文件,甚至对数组元素进行排序的其他方法。如果你敢于尝试,你甚至可以在数组对象中使用堆栈对象,反转数组元素的顺序。 现实世界中的批处理对象仅实现完成工作所需的面向对象编程功能。在严格的面向对象语言中,你必须先调用构造函数才能使用对象,但在批处理脚本中,无论好坏,你有更多的灵活性。本章中的对象甚至没有构造函数。由于批处理不是一种真正的面向对象语言,你可以自由选择只使用 OOP 范式的某些部分,而且没有编译器会对此提出异议。我本可以创建一个构造函数(它看起来会像:Clear 方法),但我之所以没有创建它,仅仅是因为我认为不需要它。 本章中也没有继承。你可以轻松地使用*oStack.bat*和*oQueue.bat*,而不需要继承,这正是我通常实现批处理面向对象设计的方式,但你总是可以扩展任何对象。未来的某个程序员,甚至可能是你,可能会创建一个继承自这些对象的子对象。 例如,如果你使用队列对象来维护一个需要以某种方式处理的服务器列表,你可能还需要查找每个服务器的 IP 地址,或者只是验证它是否在网络上。你可以将这种新逻辑封装到一个对象中,同时这个对象也继承了作为*父*队列对象的数据和方法,而程序员通常会通过笨拙地克隆队列逻辑来实现。(我希望你能感受到我输入最后那句时的轻蔑。) 我还展示了如何维护一个公共方法列表,或者简单地将所有方法暴露为公共方法。所有这些都展示了批处理面向对象设计的灵活性。你可以仅使用你想要或需要的设计元素,而不是那些你不需要或不想要的。如果需要构造函数,就创建一个;否则,不必创建。如果没有其他,针对小而专注任务的批处理文件是理想的。我鼓励你在你的批处理文件中使用我在这两章中展示的部分或全部批处理面向对象设计范式。 ### 总结 在本章中,我详细介绍了两种新的数据结构——栈和队列,并向你展示了如何推入和弹出(或添加和移除)数据项。你还学会了如何查看下一个值、清除所有值,并确定数据结构是否为空。我分享了一些栈和队列应用的想法,甚至创建了一个伪编译器。它不是完美的,但非常有用,并很好地展示了如何使用栈。 我还将你学习的关于栈和队列的内容总结成了两个现实世界的批处理对象。你学会了如何构建类似的对象,使用那些对你有意义的面向对象设计组件,并学会了如何调用这些对象。我希望你能利用栈和队列对象,并寻找未来需要面向对象解决方案的问题。 # 第三十四章:后记 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 我希望你读这本书的乐趣,能够与我写这本书的乐趣半分相当。对我来说,这个项目是一次全新、激动人心且富有启发性的尝试。这是我的第一本书,也可能是唯一的一本。如果你在我高中毕业时告诉我,我有一天会成为一名作者,我绝对不相信你。 我是一个数学迷,而且在我高年级时的一门 BASIC 编程课程中也表现优秀。我在英语课上成绩还不错,但当时我认为写作是一项苦差事。在大学时,作为我的专业课程,我上过两门技术写作课,但即便如此,写一本书的想法依然显得荒谬。经过多年阅读不同类型的书籍和文章后,我渐渐开始欣赏写作这一创造性输出。虽然偶尔尝试过写作,但从未发表过任何作品,甚至没有写过博客。大约在我 50 岁生日时,经过多年的 BAT 文件编程,我脑海中开始有了写这本书的一个微弱的想法,虽然那时我并没有准备好去面对这场看似荒唐的中年危机(如果它真的是一种危机的话)。 两年多后的 2020 年 5 月,两个事件碰撞在一起,使我有了一些空闲时间,可以把手指放到键盘上。 (在计算机时代,我们应该有一个更好的数字化委婉说法来代替“提笔写作”)。首先,显而易见的是,新冠疫情将持续超过几个月。我有充足的假期时间,却没有旅行计划。其次,在经历了十五年的紧张育儿生活,包括教导多个青少年运动队之后,我的儿子到了一个年龄,开始不再要求我陪伴,反而要求我完全放手(像大多数青少年一样)。 我和妻子(虽然有时是强行带着儿子)那个夏天做了相当多的远足。我也跑了不少步,但我还是回到了这本书的构思,尤其是意识到正常生活在东北地区的气温降下之前是不会恢复的。我时不时地、没有特定顺序地写了一章又一章,不知道是要将它们发布在博客上,还是找其他媒介,但很快我知道,我想写一本书,一本真正的实体书。随着气温的下降,我每个周末都写作,这种状态一直持续到春天、夏天……再到下一个秋天,甚至更久。(我写代码很快,但写作很慢。)2021 年 6 月初,我妻子对我说:“那么,父亲节你想要什么,除了一个人待着,好让你写作?” 经过几年的努力,我将我认为已经完成的作品呈现给了 No Starch Press。令我惊讶的是,我的首选出版社签约了我。作为新手,我的手稿还很粗糙,但他们提出了一些很好的建议,帮助我在不失去个人风格的前提下改进和组织内容。(至于我的风格,我首先是为自己写作,然后才考虑可能的评论。谁会写“一个若有若无的想法的迹象”?这算是矫情还是空洞呢?我不知道,但我喜欢这样,只能希望你也喜欢。) 本书中的 90%以上内容代表了我个人和职业生涯中经常使用的编程和技术。至于那些我必须研究的小部分内容,我希望我能够将它们与我擅长的内容无缝地融合在一起,做到不留痕迹。 对于大多数编程语言来说,一本书涵盖该语言大部分功能的想法是荒谬的。例如,有介绍 Python 的入门书籍,还有一些专注于 Python 和 Web 开发的书籍,甚至还有一些专注于 Python 和数据库的书籍,仅举几例。但是批处理脚本是一个相对更易于管理的主题。我希望我已经包括了对初学者和专家都很重要的大部分语言特性,但没有任何一本书可以做到真正的全面性。 许多命令没有被纳入到本书中。例如,curl 命令用于从服务器传输数据,cipher 命令用于加密和解密文件,runas 命令用于在不同的用户配置文件下运行批处理文件。还有一些特定于系统管理的命令我没有涉及,而有些命令我之所以没有包含,是因为我认为它们不太实用。我之前没有提到 replace 命令(直到现在)是因为批处理有更好的工具来用一个文件替换另一个文件。 对于那些我没有涉及的批处理语言的部分,我希望你现在已经有了自己进行研究的工具。我曾多次提到,你可以在 *[`<wbr>ss64<wbr>.com<wbr>/nt<wbr>/`](https://ss64.com/nt/)* 上找到一个完整的命令列表,包括描述、语法、选项和示例。你可以浏览命令列表,寻找可能对某个特定问题有用的命令,或者如果你听说过某个用于配置互联网协议的命令并想了解更多,只需点击 **ipconfig** 进行探索。没有任何一本书能够包含所有内容,但我希望你能在这本书中找到必备的知识,甚至更多。 对于完成本书的编程人员,下次当你在处理一个需要一些不同寻常的内容——数组、布尔值、浮点运算、栈,甚至可能是对象——的简单批处理文件时,我希望你不要立刻回过头去编写一些编译代码。根据具体情况,几行批处理代码可能是最有效的解决方案。 对于非程序员来说,如果你已经读完了第三部分,可以认为自己是一个荣誉程序员。但更重要的是,下次如果你遇到必须在特定时间间隔内(可能是每日)完成的重复任务,考虑使用 Batch。每天结束时运行一个简单的 bat 文件,将你当天的工作复制到备份服务器或共享目录。如果需要将几个报告合并为一个文件,不要进行剪切粘贴。给你的老板和同事留下深刻印象,或许你自己也会觉得惊讶。 按照这个思路,我有一个最后的 bat 文件想要分享。当我写这本书的时候,显然我会将文件备份到云端,但我也希望有一个带有日期戳的文件夹,里面存放着所有 Word 文档、bat 文件和与这本书相关的其他文件,并且我希望在每个写作日的结束时进行备份。每次登出前,我都会将 U 盘插入我的笔记本电脑,并从桌面运行一个仅包含以下代码的 bat 文件: ``` set @=%date% xcopy C:\Batch\*.* D:\Batch\%@:~10,4%%@:~4,2%%@:~7,2%\ /F /S /Y pause ``` 这个简单粗暴的 bat 文件为目标文件夹的名称生成日期戳,文件夹位于*D:\Batch\*。xcopy 命令会从*C:\Batch\*创建备份,而 pause 命令会保持窗口打开,这样我就能知道是否磁盘空间已满。这是一个非常简单的解决方案,满足了我的需求,但你可以在此基础上扩展,构建一个更强大的 bat 文件。 我通常认为自己是一个脚本推广者,尤其是 Batch 脚本推广者。程序员们常常在需要执行一些可以更轻松、更高效地通过脚本完成的任务时,依赖编译过的代码。在第一章中,我将*batphile*一词定义为“喜欢夜行飞行哺乳动物的人”。现在,我想给它一个次要的定义,“喜欢 bat 文件的人”。(Batfilephile 听起来实在太笨拙了。)我希望如果你还不是,现在至少在某种意义上你已经是一个骄傲的 batphile 了。有些人害怕这些飞行哺乳动物,但它们是迷人的动物,而且相当可爱。 ## 第三十五章:完全功能的批处理改进版 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在第十五章中,我构建了一个简单的*BatchImprov.bat*版本,它只共享一个笑话、谜语或双关语。在后来的章节中,你学到了几种工具来增强这个过程,例如读取文件、加载数组和使用随机数从数组中选择一个任意元素。 现在让我们将所有内容结合到一个增强版的批处理文件中,它首先读取包含任意数量笑话、谜语和双关语的库文件,并将它们加载到数组中。然后,BUI 会随机检索并分享一个用户请求的幽默示例,并询问他们是否想要另一个示例。 从数据开始,以下是*BatJokes.txt*的完整内容: ``` Why are bats so active at night?|They charge their bat-teries by day. How do bats flirt?|They bat their eyes. What's a good pick-up line for a bat?|Let's hang. Why did the bat cross the road?|To prove he wasn't chicken. ``` *BatRiddles.txt*类似地包含谜语: ``` This type of bat is silly.|A Dingbat. This circus performer can see in the dark.|An Acro-bat. This is the strongest and meanest bat in the cave.|The Alpha-bat. This sport uses bats and is also food for bats.|Cricket. ``` 这些双关语的格式不同,因此每个*BatPuns.txt*记录中不包含由管道分隔的答案: ``` Crossing a vampire bat with a computer means love at first byte. The first thing bat pups learn at school is the alpha-bat. Bat pups are trained to go potty in the bat-room. ``` 最后,将这三个库文件放置在与此版本的*BatchImprov.bat*相同的目录中: ``` @setlocal EnableExtensions EnableDelayedExpansion @echo off color 1F title Batch Improv Theater call :LoadArray joke call :LoadArray riddle call :LoadArray pun pause :Again cls > con echo. > con choice /C:JPR /M:"Do you want a Joke, Pun, or Riddle" > con echo. if %errorlevel% equ 1 ( call :Joke ) else if %errorlevel% equ 2 ( call :Pun ) else if %errorlevel% equ 3 ( call :Riddle ) > con echo. > con choice /M:"Do you want to try again" if %errorlevel% equ 1 goto :Again goto :eof :Joke call :GetRandNbr joke > con echo Please give an answer to the joke: > con set /P yourAns=!joke[%randNbr%]! & > con echo ** !jokeAns[%randNbr%]! > con echo ** You said: "%yourAns%" goto :eof :Pun call :GetRandNbr pun > con echo We hope you find this punny: > con echo !pun[%randNbr%]! goto :eof :Riddle call :GetRandNbr riddle > con echo Please give an answer to the riddle: > con set /P yourAns=!riddle[%randNbr%]! & > con echo ** !riddleAns[%randNbr%]! > con echo ** You said: "%yourAns%" goto :eof :LoadArray set %1sTot=0 for /F "tokens=1-2 delims=|" %%b in (Bat%1s.txt) do ( set %1[!%1sTot!]=%%~b set %1Ans[!%1sTot!]=%%~c set /A %1sTot += 1 ) > con echo. > con echo Results of array load of %1s: > con set %1 goto :eof :GetRandNbr set nbrPossVal=!%1sTot! set /A maxRandNbr = 32768 / %nbrPossVal% * %nbrPossVal% - 1 :GetAnotherRand set randNbr=%random% if %randNbr% gtr %maxRandNbr% goto :GetAnotherRand set /A randNbr = %randNbr% %% %nbrPossVal% goto :eof ``` 这部分批处理文件的大部分内容应该看起来很熟悉,但也有很多新的部分。我多次调用:LoadArray,并将笑话、谜语或双关语作为参数传递给它。这个例程类似于第二十九章中的一些代码,使用这些文本查找并读取当前目录中的特定文件,并构建适当命名的数组。 一个不太熟练的程序员可能会先让笑话部分工作,然后才将其克隆到谜语和双关语部分。相反,我使用了通用代码,其中第一次调用填充了笑话和笑话答案数组,并将 jokesTot 设置为加载到数组中的笑话总数,尽管实际的变量名从未出现在批处理文件中。我通过将参数解析为%1sTot 的一部分来创建该变量。 第二次调用类似地填充了谜语和谜语答案数组,并设置了 riddlesTot 变量。但双关语的格式不同。由于没有管道符号且没有答案,因此没有第二个参数,代码不会填充答案数组。相反,相同的逻辑构建了双关语数组,并将 punsTot 设置为数组中双关语的数量。 你可以稍后移除它,但出于测试目的,我将每次加载的结果显示到控制台: ``` Results of array load of jokes: jokeAns[0]=They charge their bat-teries by day. jokeAns[1]=They bat their eyes. jokeAns[2]=Let's hang. jokeAns[3]=To prove he wasn't chicken. jokesTot=4 joke[0]=Why are bats so active at night? joke[1]=How do bats flirt? joke[2]=What's a good pick-up line for a bat? joke[3]=Why did the bat cross the road? Results of array load of riddles: riddleAns[0]=A Dingbat. riddleAns[1]=An Acro-bat. riddleAns[2]=The Alpha-bat. riddleAns[3]=Cricket. riddlesTot=4 riddle[0]=This type of bat is silly. riddle[1]=This circus performer can see in the dark. riddle[2]=This is the strongest and meanest bat in the cave. riddle[3]=This sport uses bats and is also food for bats. Results of array load of puns: punsTot=3 pun[0]=Crossing a vampire bat with a computer means love at first byte. pun[1]=The first thing bat pups learn at school is the alpha-bat. pun[2]=Bat pups are trained to go potty in the bat-room. Press any key to continue ... ``` cls 命令在开始批处理文件的用户界面部分之前清除屏幕。 :Again 标签下的主要逻辑与之前版本的批处理文件没有变化。:Joke、:Riddle 和:Pun 例程通过调用:GetRandNbr 获取一个随机数。为了获得适当数组中的元素总数,例程将其参数解析为!%1sTot!的一部分。其余的逻辑与你在第二十一章中看到的类似。 在获取它们数组(或数组)的指针后,这些例程与之前的版本相似,不同之处在于它们从数组中获取内容。例如,!joke[%randNbr%]! 解析为一个笑话,!jokeAns[%randNbr%]! 解析为其答案。(延迟扩展真是太棒了。) 现在你可以运行*BatchImprov.bat*来获取多个笑话、谜语和双关语。你甚至可以在不修改代码的情况下向库文件中添加更多内容。更棒的是,可以将其作为使用 BUI、数组、分隔数据文件和随机数的应用程序模板。尽情享受吧。 ## 第三十六章:B 数组和哈希表对象 ![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/bk-bth-scr/img/chapter.jpg) 在第二十九章中,我展示了如何构建、访问和更新数组以及哈希表。这些数据结构虽然与 bat 文件不常相关,但它们是第三十三章中讨论的同样不典型的现实世界批处理对象的极好应用。 在这个附录中,我将通过为每种数据结构提供一个有良好注释的对象 bat 文件来将这些概念结合起来。 ## 数组对象 这里是数组对象 *oArray.bat* 的完整内容: ``` :: ****** Array Object ****** :: Parm 1 – Name of Array :: Parm 2 - Name of Method: :: AddElemAt - Insert One Element at an Index :: Parm 3 - Index of Element being Added :: Parm 4 - Value of Element being Added :: AddElem - Add One Element to the Array :: Parm 3 - Element being Added :: GetElem - Get the Element at an Index :: Parm 3 - Index of Element :: Parm 4 - Returned Variable Name :: GetFirst - Get the First Element in the Array :: Parm 3 - Returned Variable Name :: GetNext - Get the Next Element in the Array; call after :: :GetElem or :GetFirst or gets first element :: Parm 3 - Returned Variable Name :: GetSize - Get the Number of Elements in the Array :: Parm 3 - Returned Variable Name :: RemoveElemAt - Remove One Element from the Array :: Parm 3 - Index of Element being Removed :: Clear - Empty the Array of all its Elements :: IndexOf - Get the Index of a Specific Value :: or return -1 if Not Found :: Parm 3 - Value of Search Element :: Parm 4 - Returned Variable Name :: Contains - Get a Boolean Indicating if a Value is :: Anywhere in the Array :: Parm 3 - Value of Search Element :: Parm 4 - Returned Boolean Name :: Clone - Create a Copy of the Array :: Parm 3 - Name of New Array :: Global Variables: :: <arrayName>Size = Size or Length of the Array :: <arrayName>Index = Index or Pointer to the Next Element :: <arrayName>[n] = Nth Element of the Array cmd /C exit 0 call :%~2 "%~1" "%~3" "%~4" || ( > C:\Batch\Log.txt echo ** ERROR - Invalid Method Name "%~2" exit ) goto :eof :AddElemAt call :GetSize %~1 size if %~2 gtr %size% ( echo ** Invalid Index "%~2" greater than Array Size "%size%" goto :eof ) set /A startIndex = !%size! - 1 for /L %%i in (%startIndex%, -1, %~2) do ( set /A nextIndex = %%i + 1 for /F %%n in ("!nextIndex!") do ( set %~1[%%n]=!%~1[%%i]! ) ) set %~1[%~2]=%~3 set /A %~1Size += 1 goto :eof :AddElem call :GetSize "%~1" size set %~1[!size!]=%~2 set /A %~1Size += 1 goto :eof :GetElem set %~3=!%~1[%~2]! set /A %~1Index = %~2 + 1 goto :eof :GetFirst set %~2=!%~1[0]! set %~1Index=1 goto :eof :GetNext if not defined %~1Index set %~1Index=0 call :GetSize "%~1" size set targIndex=!%~1Index! if %targIndex% geq %size% ( set %~2=No More Elements ) else ( set %~2=!%~1[%targIndex%]! set /A %~1Index += 1 ) goto :eof :GetSize if not defined %~1Size set %~1Size=0 set %~2=!%~1Size! goto :eof :RemoveElemAt call :GetSize "%~1" size if %~2 geq %size% ( echo ** Nothing to do, Index "%~2" greater than Array Size "%size%" goto :eof ) set /A %~1Size -= 1 for /L %%i in (%~2, 1, !%~1Size!) do ( set /A nextIndex = %%i + 1 for /F %%n in ("!nextIndex!") do ( set %~1[%%i]=!%~1[%%n]! ) ) set %~1[!nextIndex!]=& goto :eof :Clear for /F "usebackq delims==" %%a in (`set %~1`) do ( set %%a=&rem ) set %~1Size=0 goto :eof :IndexOf set %~3=-1 set /A sizeLess1 = %~1Size - 1 for /L %%i in (0, 1, %sizeLess1%) do ( if "%~2" equ "!%~1[%%i]!" ( set %~3=%%i ) ) goto :eof :Contains call :IndexOf "%~1" "%~2" indexOf if %indexOf% equ -1 ( set %~3=false==x ) else ( set %~3=true==true ) goto :eof :Clone call :Clear "%~2" for /F "usebackq tokens=1,2 delims==" %%p in (`set %~1`) do ( set oldArrayItem=%%p set !oldArrayItem:%~1=%~2!=%%q ) goto :eof ``` 这个 bat 文件应该经常被调用,所以我使用了::(两个冒号)来进行备注,而不是使用 rem 命令,这样可以减少解释器写入到标准输出的内容。 每次调用这个对象至少传递两个参数:数组的名称和正在调用的方法或操作;根据方法的不同,可能需要传递一个或两个额外的参数。你可以将元素添加到数组的末尾或指定的索引位置;你可以获取第一个元素、下一个元素,或者获取特定索引位置的元素。该对象有删除指定索引元素、获取数组大小以及清空数组的方法。你可以获取某个特定值第一次出现的索引,或者通过布尔值检查该值是否存在于数组中。你甚至可以克隆或复制数组。 我不会逐一讲解每个方法,而是让注释来说明。请注意,我已经为每个方法附上了简短的描述,并列出了它们所需的参数。 然而,这里有一些有趣的代码值得一提。你会在这个列表中看到大量延迟扩展的例子。实际上,一些方法使用嵌套的 for 命令完全是为了延迟扩展;每个 for 命令都将外部 for 命令中分配的变量转换成可以用百分号符号(第二十章)解析的变量。此外,`:IndexOf` 和 `:Contains` 方法执行相似的功能。为了避免重复工作,后者调用了前者,将结果转化为布尔值。同样,多个方法通过调用 `:GetSize` 来获取数组的大小。`:Clone` 方法将一个数组相关的所有变量赋值给另一个数组,利用文本替换的特性以及数组元素仅仅是普通变量的事实。 你可以从另一个 bat 文件调用该对象来执行所有这些功能。这里有一个小示例: ``` call C:\Batch\oArray.bat friends AddElem Walter call C:\Batch\oArray.bat friends AddElem Donny call C:\Batch\oArray.bat friends AddElemAt 1 Maude call C:\Batch\oArray.bat friends RemoveElemAt 0 call C:\Batch\oArray.bat friends GetFirst oneFriend call C:\Batch\oArray.bat friends GetNext anotherFriend ``` 这段代码将 `oneFriend` 和 `anotherFriend` 分别赋值为 Maude 和 Donny。 为了可读性,这个对象的错误处理和传入参数的验证非常简单,但这些相对较少的代码行已经准备好可以创建、修改和访问任意数量的数组。 ## 哈希表对象 这里是哈希表对象 *oHashTable.bat* 的完整内容: ``` :: ****** Hash Table Object ****** :: Parm 1 – Name of Hash Table :: Parm 2 - Name of Method: :: Clear - Empty the Hash Table of all its Keys and Values :: Put - Put One Key-Value Pair into the Hash Table :: Parm 3 - Key being Added :: Parm 4 - Value being Added :: Get - Get a Value Given a Key :: Parm 3 - Search Key :: Parm 4 - Returned Variable Name :: GetSize - Get the Number of Key-Value Pairs in the Hash Table :: Parm 3 - Returned Variable Name :: Remove - Remove One Key and its Value from the Hash Table :: Parm 3 - Key being Removed :: ContainsKey - Get a Boolean Indicating if a Key is :: Anywhere in the Hash Table :: Parm 3 - Search Key :: Parm 4 - Returned Boolean Name :: ContainsValue - Get a Boolean Indicating if a Value is :: Anywhere in the Hash Table :: Parm 3 - Search Key :: Parm 4 - Returned Boolean Name :: Clone - Create a Copy of the Hash Table :: Parm 3 - Name of New Hash Table :: Global Variable: :: <hashTable>Size = Size or Length of the Hash Table cmd /C exit 0 call :%~2 "%~1" "%~3" "%~4" || ( > C:\Batch\Log.txt echo ** ERROR - Invalid Method Name "%~2" exit ) goto :eof :Clear for /F "usebackq delims==" %%a in (`set %~1`) do ( set %%a=&rem ) set %~1Size=0 goto :eof :Put call :ContainsKey "%~1" "%~2" bool set %~1{%~2}=%~3 if not %bool% ( set /A %~1Size += 1 ) goto :eof :Get call :ContainsKey "%~1" "%~2" bool if %bool% ( set %~3=!%~1{%~2}! ) else ( set %~3=Key Does Not Exist ) goto :eof :GetSize if not defined %~1Size set %~1Size=0 set %~2=!%~1Size! goto :eof :Remove call :ContainsKey "%~1" "%~2" bool if %bool% ( set /A %~1Size -= 1 ) set %~1{%~2}=& goto :eof :ContainsKey if defined %~1{%~2} ( set %~3=true==true ) else ( set %~3=false==x ) goto :eof :ContainsValue set %~3=false==x for /F "usebackq tokens=2 delims==" %%v in (`set %~1{`) do ( if "%%v" equ "%~2" ( set %~3=true==true ) ) goto :eof :Clone call :Clear "%~2" for /F "usebackq tokens=1,2 delims==" %%p in (`set %~1`) do ( set oldHashTblItem=%%p set !oldHashTblItem:%~1=%~2!=%%q ) goto :eof ``` 此对象还接受至少两个参数:哈希表的名称和正在调用的方法或操作。您可以通过调用:Put 方法向数据结构添加键值对,并通过:Get 方法获取给定键的值。其他方法可以清空整个哈希表或仅移除一个键值对。您可以获取键值对的数量,并检索一个布尔值,显示键或值是否存在,并且像数组对象一样,还有一个克隆方法。 在.bat 文件的开头注释中描述了每个方法及其对应的参数。最有趣的方法是:ContainsValue,在执行搜索之前将布尔值预设为 false,然后查看每对的值。然而,确定哈希表中是否存在键仅需稍作判断。 这里有几行代码,演示了对对象功能进行简单测试的方法: ``` call C:\Batch\oHashTable.bat jobs Put Lincoln President call C:\Batch\oHashTable.bat jobs Put Poe Poet call C:\Batch\oHashTable.bat jobs Put Darwin Naturalist call C:\Batch\oHashTable.bat jobs Get Poe aJob ``` 在这些命令完成后,aJob 变量包含值 Poet。 您可以从多个.bat 文件中调用此对象,甚至可以从单个进程构建多个哈希表。现在寻找其他实例,通过将有趣的逻辑放置在可重用对象.bat 文件中,可以使您的主代码保持简洁。 # 第三十七章:INDEX + 符号 + + (加法操作符), 58–61 + = (参数分隔符), 113 + , (参数分隔符), 113 + ; (参数分隔符), 113 + 空格 (参数分隔符), 113 + tab (参数分隔符), 113 + "..." (参数括起), 113–114 + [...] (数组), 348–349 + = (赋值操作符), 16–18 + += (增强的加法赋值操作符), 61–62 + /= (增强的除法赋值操作符), 61–62 + %%= (增强的模除赋值操作符), 61–62 + *= (增强的乘法赋值操作符), 61–62 + –= (增强的减法赋值操作符), 61–62 + ` (反引号), 218, 220 + & (按位与操作符), 371–372 + ^ (按位异或操作符), 371 + | (按位或操作符), 371 + & (命令分隔符), 18–19 + 示例, 37, 61, 106, 119, 162, 175, 234, 263 + || (条件执行操作符, 否定), 336, 338, 343–346 + && (条件执行操作符, 肯定), 336, 337–338, 341–346 + ^ (“续行符”), 50, 154–155, 283 + . (当前目录), 86 + > (当前目录分隔符), 13, 250 + / (除法操作符), 60–61 + == (等于操作符), 38 + ^ (转义字符), 150–153 + ^^ (转义的脱字符), 151–152 + ^^! (转义的感叹号), 152–153 + %% (转义的百分号), 152–153, 261 + %%... (用于命令变量), 180–181 + {...} (哈希表), 357–360 + : (标签标识符), 94 + << (逻辑左移操作符), 372–373 + >> (逻辑右移操作符), 372–373 + + (合并文件操作符), 70 + %% (模除操作符), 60–61 + * (乘法操作符), 60–61 + / (选项前缀), 22 + .. (父级目录), 86 + $...:(路径修饰符),188–189 + $(提示符特殊代码前缀),249–251 + >>(重定向和附加操作符),131–132 + 2>&1(标准输出和标准错误输出重定向),133 + > (重定向操作符),14,130–132 + ::(备注,隐藏),135–136,434 + %*(解析整个参数列表),116,186,192–193,398–401 + %...(解析参数),115,117 + %...%(解析变量),12 + !...!(解析变量,延迟扩展),28–31 + ~(解析变量/参数不带双引号),115,117,181,183 + 2(标准错误输出),133 + 0(标准输入),138 + 1(标准输出),133 + :~(子字符串),51–53,261–262 + -(减法操作符),60–61 + @(抑制输出),14,134–135 + =(文本替换),53–54 + *(通配符字符),78–79 + ?(通配符字符),78,79–80 + A + /A(选项) + 算术(集合),32,58–59 + 属性(del),81 + 属性(dir),144 + 颜色属性(findstr),284 + 抽象,391,406 + 加法操作符(+),58–61 + 与操作符 + 按位与,370–372 + 复制,327–328 + 语法错误,385 + 动画师,9,21,376 + 阿基米德,301 + 参数 + 分隔符字符,113 + 包裹("..."),113–114 + 通过,90–91,112–115 + 算术 + 十进制,60–61 + 为我的数学迷朋友们的岔路话题,173,202,252–253 + 浮动点,65–66,262–264,265–266 + 十六进制,63–64 + 八进制,63–64 + set /A,58–59 + 数组,348 + 访问或读取,352–353 + 构建或填充,349–352 + 示例,428–430 + 索引, 348 + 初始化, 353–354 + 缺少索引越界, 354 + 多维的, 355–356 + 对象, 431–435 + 共生的, 351–352 + 赋值运算符(=), 16–18 + attrib 命令, 368–370 + 属性, 文件 + 检索, 144, 187–188, 368–369 + 设置或定义, 369–370 + 增强赋值运算符, 61–62 + 自动终止并重启, 315–322 + 自动重启, 307–312 + B + /B(选项) + bare (dir), 144 + 记录开始(findstr), 280 + 二进制复制(复制), 70 + 跳出(退出), 109 + 反引号(`), 218, 220 + 批处理文件, 5–6 + *BatchImprov.bat*, 163–164, 428–429 + 批处理解释器, 8–9 + 批处理脚本语言, 4–5 + 批处理用户界面(BUI), 157–158 + bat 文件, 5 + 调用, 106–107 + 创建, 6–7 + 退出, 99, 106, 109–110 + batphile, 5, 425 + 蝙蝠(哺乳动物), 5, 293, 318, 425 + 马达加斯加果蝠, 393 + batveat, xxxii + bat 病毒, 228, 367 + 披头士乐队, 215 + *大 Lebowski*, *The*, 278, 394, 400–401, 403, 416, 418, 435 + 有价值的地毯, 159 + 按位操作 + 与运算符(&), 371–372 + 排他或运算符(^), 371 + 或运算符(|), 371 + 布尔数据类型, 48, 54–55 + 转换为字符串, 56, 216 + 示例, 286–288 + 路易·布莱叶, 358–359 + 波士顿凯尔特人队, 248, 348 + 波士顿红袜队, 248 + break 命令, 101, 219 + 跳出循环, 96–97 + BUI(批处理用户界面), 157–158 + C + /C(选项) + 执行命令并终止 (cmd),254,256 + 记录计数 (find),291 + 选择列表 (choice),158–159 + 字面量搜索字符串 (findstr),282 + /-C (选项),移除文件大小的逗号 (dir),144 + C#,8,390 + call 命令,85,87,104–105 + 参数,112–114 + 编码规范,84,106–108 + 条件执行,339–340 + 重定向 stdout 和 stderr,132–133,135 + 变量,321 + 驼峰命名法,15 + 回车换行 (CRLF),154–155,301 + 不区分大小写,39–40 + cd 命令,85–86,87 + 伪环境变量,85–86 + 字符数据类型,48 + chdir 命令,85–86,87 + 选择命令,158–160,163–164 + 从列表中选择,158–160,163–164 + cls 命令 (清除屏幕),161,163 + cmdcmdline 伪环境变量,253–254 + cmd 命令,256–257,311–312 + *cmd.exe*,9,161,253–254,257,313,317–320 + cmd 文件,7–8 + 与 bat 文件比较,255–256 + COBOL,4,100,348,390 + 代码块,36–37,167 + 裸露的,170–172,174–175 + 解析变量,168–170,193–194,225 + 在 endlocal 后生存,172–175 + 交换变量值,172 + 代码生成的输出,128–129 + 编码规范。*参见* 编码规范 + color 命令,161–162,163 + 命令字符限制,119 + *command.com*,9 + 命令扩展,31–33 + 命令选项,22 + 命令提示符,3–4 + 命令 + attrib,368–370 + break, 101, 219 + call. *见* call 命令 + cd, 85–86, 87 + chdir, 85–86, 87 + choice, 158–160, 163–164 + cls (清屏), 161, 163 + cmd. *见* cmd 命令 + color, 161–162, 163 + copy, 70 + del, 81, 129–130 + dir. *见* dir 命令 + echo. *见* echo 命令 + @echo off, 14, 134–135 + endlocal. *见* endlocal 命令 + exit, 94, 109–110 + find, 290–291 + findstr, 54, 278–290 + for. *见* for 命令 + goto. *见* goto 命令 + help, 7, 21–23, 31–32 + if. *见* if 命令 + md, 142 + mkdir, 142 + move, 80–81 + net use, 148 + path. *见* path 命令 + pause, 6, 12–14 + popd, 89–90 + prompt. *见* prompt 命令 + pushd, 89–90 + rd, 142 + regedit, 366–367 + reg query, 367–368 + rem, 18–19, 135–136 + ren, 81–82 + rename, 81–82 + rmdir, 142 + robocopy. *见* robocopy 命令 + set. *见* set 命令 + set /A, 58–59 + setlocal, 26–33, 172–175 + set /P, 160–161, 164 + setx, 20–21 + shift, 119–120, 123–124 + sort, 364–365 + start, 108, 316, 318–319 + subst, 147 + taskkill, 314–315, 317–320 + tasklist, 313–314, 317–320 + timeout, 307, 310–311, 318, 320–321 + title, 161, 163, 317–318, 320 + type, 132, 283, 298–301 + where, 145–146 + xcopy,6,70–71,76–77,137–138 + 命令分隔符(&),18–19 + 示例,37,61,106,119,162,175,234,263 + 命令终止,19 + 注释,18–19,135–136 + 英联邦基金健康统计,260 + 编译器,8–9 + 计算机名系统变量,254,297 + 字符串连接,49–50 + 并发或并发进程,323 + 条件语句,36–37 + 大小写不敏感,39–40 + 关键字,38–39 + 条件执行 + 示例,367 + 多个运算符,340 + 负值(||),336,338,343–346 + 正值(&&),336,337–338,341–346 + 控制台 + 输入 + 从列表中选择,158–160,163–164 + 自由格式文本,160–161 + 多条记录,138–139 + 测试考虑,164 + 输出,12–14,383–385 + “续行符”(^),50,154–155,283 + 约定,编码 + 数组和哈希表,357–358 + 布尔变量名,55–56 + 调用命令,84,106–108 + 案例,15,40,188,148,235 + 关闭括号,97–98 + 循环变量,181,420–421 + goto 命令,107–108 + 缩进,36,50,94–95,343,384–385 + 对象命名,394 + 队列命名,413 + 间距,37 + 栈命名,410 + 变量命名,16 + 复制命令,70 + 复制文件,70–77 + 计数器变量,262,265 + CRLF(回车换行符),154–155 + 动态构建批处理文件, 301 + 当前目录 (.), 83 + 分隔符 (>), 13, 250 + 寻找资源, 90 + 压栈和弹栈, 89–90 + 更新, 85–86, 87 + D + /D (选项) + 默认选择 (choice), 159 + 删除映射 (subst), 147 + 目录 (for), 196 + 目录属性 (attrib), 369 + 达利, 萨尔瓦多, 269 + 大马士革, 393 + 达尔文, 查尔斯, 358, 437 + 数据流, 129 + 数据类型, 48 + 日期伪环境变量, 248–249, 261, 357 + 定义关键字, 39 + 延迟扩展, 27–31, 49, 53–54, 153, 430, 434 + 在代码块中, 168–170, 193–194, 225 + 禁用, 32–33 + 示例, 123, 193–194, 272, 288, 353, 360 + 面向对象设计, 397–398, 404 + 解决 (!...!), 28–31 + 堆栈, 415 + 两个层次, 237–240 + del 命令, 81, 129–130 + /DELETE (选项), 删除映射 (net use), 148 + 删除文件, 81 + 除了一个文件外的所有文件, 370 + 分隔符集合 (delims), 210–213 + 转义, 240–243 + 示例, 225, 232–234, 262–263, 331, 351, 359, 411–412 + 在分隔符之间忽略空值, 232–234 + 丹尼索瓦人, 75 + dircmd 伪环境变量, 145 + dir 命令, 142–145 + 输入给 for 命令, 217, 223–224 + 使用递归, 273–275 + 目录 + 创建和删除, 142 + 枚举, 196, 198–199 + 询问, 142–146 + 递归搜索, 273–275 + 除法运算符 (/), 60–61 + do...while “命令”, 98–99 + 示例, 123–124, 253, 309–311 + 两个重叠的“命令”, 318–320 + 驱动器映射, 90, 146 + 本地目录, 147 + 网络目录和共享, 148 + 动态构建 bat 文件, 294–296 + 应用程序, 298–301, 302–303, 318 + 静态数据, 300–301 + 变量解析, 297–298, 299–300 + E + /E(选项) + 复制空子目录(robocopy), 74 + 记录结束(findstr), 280 + echo 命令, 12–14, + 抑制输出, 134–135 + 写入空行, 131 + 写入文件, 130–131 + @echo off 命令, 14, 134–135 + 编辑器, 8 + else if 结构, 42–43, 330–331 + else 关键字, 41–43 + 与或运算符, 332–333 + 嵌入其他语言的命令, 234–237 + 爱默生,拉尔夫·沃尔多, 148 + 创建空文件, 70 + 封装, 391, 396, 405–406 + 无限循环, 320–321 + endlocal 命令, 26–27, 32–33 + 生存, 172–175 + :eof(文件结束)标签, 99 + 在常规操作中, 106 + eol(行尾注释字符), 214 + equ(等号运算符), 38 + 等号运算符(==), 38 + errorlevel 伪环境变量 + 评估, 40–41, 336–337 + 设置, 256 + 转义 + 插入符号(^^), 151–152 + “续行字符”, 154–155 + 转义字符(^), 150–153 + 示例, 294, 297–298, 299, 302 + 感叹号(^^!), 152–153 + 多级, 153–154 + 百分号(%%), 152–153, 261 + 可执行文件 + 调用一个, 84–85 + 解释器查找, 87–88 + 传递参数, 90–91 + exist 关键字, 38–39 + 退出命令, 94, 109–110 + 指数, 61, 201 + 外星通讯, 130–131, 134 + F + /F(选项) + 搜索文件列表(findstr), 285 + 文件读取(用于), 205–206 + 强制终止(taskkill), 314 + 完整的源路径和目标路径及文件名(xcopy), 71 + 阶乘, 269–271 + /FI(选项) + 过滤器(taskkill), 314 + 过滤器(tasklist), 314 + 文件关联, 7 + 文件信息, 检索, 186–190 + 文件掩码, 77–80, 183–184 + 文件 + 复制, 70–77 + 删除, 81, 370 + 空, 70 + 合并, 70 + 移动, 80–81 + 阅读, 206–215 + 记录计数, 290–291 + 重命名, 81–82 + 写作, 130–133 + 文件集, 183–184 + 查找命令, 290–291 + findstr 命令, 54, 278–290 + 费茨杰拉德, F. 斯科特, 170 + 浮点数据类型, 48, 65–66, 173, 262, 265–266 + /FO(选项), 格式(tasklist), 314 + 文件夹。*参见* 目录 + 用于命令 + 应用程序 + 全局文本替换, 224–225 + 处理变量数量的文件, 192–193 + 仅处理文件夹中的大文件, 221–224 + 备份中的文件重命名, 191–192 + 组成部分, 181–182 + 约定, 420–421 + 解构, 227–228 + 延迟扩展, 与, 237–240 + 目录选项(/D), 196, 198 + 文档, 180, 225–227 + 转义, 240–243 + 文件读取选项(/F), 205–206 + 嵌入其他语言的命令, 234–237 + 示例, 233–234, 236, 262, 274, 318, 351, 368–369, 419 + 输入 + 命令, 217, 220, 235–236 + 文件集(fileset),182–183,215,218–219 + 字符串(string),185–186,215–216,219–220 + 迭代循环选项(/L),199–202,380–381,383–384 + 示例,349,353,355,419,432–434 + 修饰符(modifiers),186–190 + 嵌套(nested),223–224,233–234,240,262,331,355,419 + 无选项(optionless),180–183 + 示例,235,322,328–330,350,395 + 解析输入数据(parsing input data),206 + 分隔符集(delims),210–213 + 换行符(eol),214 + 跳过(skip header records),213–214 + 标记(tokens),207–210 + 递归选项(/R),197–199 + 标准输出(stdout),380–383 + usebackq(使用反引号),218–220,226–227 + 何时使用(when to use),221,237 + 变量解析(%%...),181–183,193–194 + 格式化报告数据(formatting report data),261–266 + 自由格式用户输入(freeform user input),160–161,164 + G + /G(选项),查找字符串文件(findstr),285 + 高斯,卡尔·弗里德里希(Gauss, Carl Friedrich),201–202 + 大于等于(geq),38 + *God.txt* 是否存在?,54–56 + 跳转命令(goto command),95–99,104–108 + 简史(brief history),100–101 + 大于(gtr),38 + 图形用户界面(GUI),157–158 + 古思利,伍迪(Guthrie, Woody),xxxii + H + 停顿(hang),312–313 + 哈希表(hash tables),{...} + 应用程序(application),398 + 构建和访问,358–359 + 复杂(complex),360–361 + 键值对(key-value pair),356–358 + 对象(object),435–437 + *HealthStats.dat* 文件,260 + 帮助命令(help command),7,21–23,31–32 + 十六进制(hexadecimals),63–64 + 将小数转换为(converting decimals to),271–273 + 隐藏参数,117–118,254 + 霍夫曼,菲利普·塞莫尔,404 + I + /i(选项) + 大小写不敏感(findstr),279 + 大小写不敏感(if),39–40 + IBM 主机,5 + IDE(集成开发环境),8–9,21 + if 命令,36–43,328–333 + 双引号技巧,45–46 + 评估 errorlevel,40–41 + 嵌套,97–98,327 + 数值与文本比较,45 + 前导点技巧,44–46 + if...else 结构,41–42 + 使用与/或运算符,332–333 + 伪,340–341,345 + 缩进,36,50,94–95 + 继承,391,395,398–400,404,406 + In-N-Out 汉堡,26 + 整数数据类型,48,58–59 + 算术,60–61 + 增强赋值,61–62 + 运算顺序,62 + 允许范围,60 + 集成开发环境(IDE),8–9,21 + 间歇性故障,五个阶段,306–307 + 解释器,8–9 + 解释器生成的输出,128–130 + J + /J(选项) + 无缓冲 I/O(robocopy),74 + 无缓冲 I/O(xcopy),71 + Java,8,55,86,358–359,390,399 + JavaScript,9 + JCL(作业控制语言),5 + 报告数据对齐,261–266 + K + 键值对,356–358 + L + /L(选项) + 迭代循环(for),199 + 仅列表(robocopy),75 + 标签,93 + 约定,94–95 + :eof(文件结束),99,106 + 标识符(:),94 + 变量名,99–100 + 去除前导零,97–98 + leq(小于或等于),38 + /LEV(选项),子目录层级(robocopy),74 + *布莱恩的一生*,*生活的*,394,401,403 + 林肯,亚伯拉罕,358–360,437 + 列表,从中选择或挑选,158–160,163–164 + /LOG(选项),日志文件(robocopy),73 + /LOG+(选项),附加到日志文件(robocopy),73 + 逻辑位移运算符 + left (<<),372–373 + right (>>),372–373 + lss(小于),38 + M + /M(选项),消息(choice),158 + *MadLibs.bat*,115 + 映射驱动器字母,146–148 + 掩码,文件,184 + /MAXAGE(选项),最大年龄(robocopy),75 + /MAX(选项),最大字节限制(robocopy),74 + md 命令,142 + 合并文件运算符(+),70 + 批处理的形而上学,127 + 最小化窗口(start),318–319 + 最小字节限制(robocopy),74 + /MIN(选项),最小字节限制(robocopy),74 + /MINAGE(选项),最小年龄(robocopy),75 + /MIR(选项),镜像目录树(robocopy),75 + mkdir 命令,142 + 修饰符,文件,186–187 + 示例,223–224,322 + 路径,188–189 + 堆叠,189–190 + 模块化,386 + 模除运算符(%%),60–61 + 示例,251–253 + 蒙提·派森,329,401,403 + /MOV(选项),移动文件(robocopy),80–81 + /MOVE(选项),移动文件和子目录(robocopy),81 + 移动命令,80–81 + 移动文件,80–81 + MS-DOS 命令提示符,3–4 + /MT(选项),多线程(robocopy),74–75 + Muffuletta,30 + 乘法运算符(*),60–61 + 多线程,323 + 复制文件,74–75 + N + /N(选项) + 显示行号(findstr),280 + 无选项键(choice),160 + 裸代码块,170–172,174–175 + 示例,343–346 + 尼安德特人,121 + neq(不等于),38 + net use 命令,148 + /NH(选项),无头信息(tasklist),314 + Notepad,6,8 + Notepad++,8,39 + *notepad-plus-plus.org*, 8 + not 关键字, 39 + /NP (选项), 无进度 (robocopy), 73 + 空 (null) 文件, 70 + 重定向到, 135 + O + /O (选项) + 输出顺序 (dir), 144 + 输出文件 (sort), 364 + *oArray.bat*, 431–434 + *oBigLebowski.bat*, 400 + 面向对象设计,批处理 (BOOD), 392–393 + 子对象, 400–402 + 构造函数, 396–397 + 执行, 403–404 + 中间对象, 398–400 + 方法, 396, 398 + 扩展, 400–403, 405, 421 + 重写, 402 + 设置器和获取器, 397–398, 404 + 对象, 390, 393–394 + 类型, 394–395 + 父对象, 395–398 + 现实世界对象, 421–422 + 面向对象编程 (OOP), 390 + 类, 393 + 四大支柱, 391–392, 406 + *oComedy.bat*, 398–399 + 八进制, 58, 63–64, 66–67 + *Office Space*, 150 + *oHashTable.bat*, 435–437 + *oLifeOfBrian.bat*, 401 + *oMovie.bat*, 395–396 + *On Beyond Zebra!*, 209 + OOP. *参见* 面向对象编程 + 操作符 + 和 (复制), 327–328 + 算术 + 加法 (+), 61 + 除法 (/), 61 + 取模除法 (%%), 61 + 乘法 (*), 61 + 减法 (-), 61 + 增强赋值, 61 + 位操作 + 按位与 (&), 370–372 + 按位异或 (^), 371 + 按位或 (|), 371 + 逻辑左移 (<<), 372–373 + 逻辑右移 (>>), 372–373 + 比较 + 等于 (equ 或 ==), 38 + 大于 (gtr), 38 + 大于或等于 (geq), 38 + 小于 (lss), 38 + 小于或等于 (leq), 38 + 不等于 (neq), 38 + 条件执行, 336–338 + 或 (复制), 328–332 + 选项, 命令, 22 + *oQueue.bat*, 416–418 + 运算顺序, 62 + 或运算符 + 位或(|), 371 + 复制, 328–332 + *oStack.bat*, 413–416 + 乌罗波罗斯(Ouroboros), 268–269 + P + /P(选项),提示字符串(set), 32, 160 + 参数 + 接受, 115–117 + 拖放, 124–126 + 隐藏, 117–118, 254 + 长度不同的列表, 123–124 + 解析 (%...), 115, 117 + 解析整个参数列表 (%*), 116, 186, 192–193, 398–401 + 不带双引号解析 (~), 115, 117, 181, 183 + 返回, 120–123, 172–175 + 移位, 118–120, 123–124 + 与修饰符一起使用, 190 + 上级目录(..), 86 + 帕斯卡(Pascal), 4, 15 + 路径命令, 86–88 + 在 cmd 文件中, 255 + pathext 伪环境变量, 88 + 路径变量, 83 + 修饰符 ($...:), 188–189 + 更新, 86–87 + 暂停命令, 6, 12–14 + 菲迪比底斯(Pheidippides), 151 + 非利士人, 121 + /PID(选项),终止进程标识符(taskkill), 315 + PID(进程标识符), 313 + 管道, 136–138 + 例子, 191, 282–283, 291, 338–339 + 埃德加·艾伦·坡(Poe), 358, 360, 437 + 泊松分布, 311 + 多态, 392, 398, 403, 406 + Pop, Iggy, 219–220 + popd 命令, 89–90 + 幂函数, 201, 373 + PowerShell 命令, 嵌入, 235–236 + 二的幂, 76, 154, 252–253, 370, 372–373 + 程序化编码, 390–391, 422 + 进程标识符(PID), 313 + 提示命令 + 在 cmd 文件中,255 + 示例,310 + 伪环境变量,249–251 + 特殊代码前缀 ($),249–251 + *PseudoCompiler.bat*,418–420 + 伪环境变量 + cd,85–86 + cmdcmdline,253–254 + 日期,248–249,261 + dircmd,145 + errorlevel + 在 cmd 文件中,255–256 + 评估,40–41,336–337 + 设置,256 + 路径,83,86–87 + pathext,88 + 随机,228,251–253,320–322,429–430 + 系统变量,254–255 + 时间,248–249,261 + /PURGE (选项),从目标删除多余项 (robocopy),75 + pushd 命令,89–90 + Python 命令,嵌入,236–237 + Q + /Q (选项) + 安静模式 (del),81 + 安静模式 (rd),142 + 安静模式 (where),146 + 安静模式 (xcopy),71 + 队列,412 + 添加函数,413,417–418 + 应用程序,418 + peek 函数,417–418 + 队列对象,416–418 + 删除函数,413,417–418 + R + /R (选项) + 递归 (for),197–198 + 递归搜索 (where),146 + 正则表达式 (robocopy),289 + 重试 (robocopy),74 + 反向排序顺序 (sort),365 + 随机伪环境变量,251–253 + 应用程序,228,320–322,429–430 + *Raven*,*The*,360 + rd 命令,142 + 读取,文件内容,206–215 + /REC (选项),最大记录长度 (sort),365 + 记录计数,查找文件的,290–291 + 递归,197–199,268–269 + 基本与递归情况,268 + 堆栈溢出,275–276 + 重定向,135–136 + 追加操作符 (>>),131–132 + 操作符 (>),14,130–132 + stdout 和 stderr (2>&1),133 + regedit 命令,366–367 + 注册表,Windows,365–366 + 查询,367–368 + reg query 命令,367–368 + 正则表达式(regex),289–290 + 备注,18–19 + 约定,94–95 + 隐藏(::),136,434 + rem 命令,18–19,135–136 + 重命令,81–82 + 重命名文件,81–82 + ren 命令,81–82 + 解析 + 延迟扩展 (!...!),28–31 + 整个参数列表(%*),116,186,192–193,398–401 + 参数(%...),115,117 + 变量(%...%),12 + 没有双引号的变量/参数(~),115,117,181,183 + 重启,自动结束并,315–322 + 自动重启,307–312 + 设置批处理文件的返回代码,109–110 + 连接,341–342 + 返回参数,120–123 + rmdir 命令,142 + robocopy 命令,71,77 + 日志记录,72–73 + 移动文件,80–81 + 选项,74–75 + 返回代码,75–76 + 例程 + 可调用,105 + 退出,106,109–110 + 隐藏,239 + 参数,117 + 运行时,9 + S + /S(选项) + 复制子目录(robocopy),74 + 复制子目录(xcopy),71 + 显示子目录(dir),145 + 删除子目录(rd),142 + 搜索子目录(attrib),369 + 搜索子目录(findstr),284 + 施罗丁格的猫,170 + 范围,26–27 + 搜索文件 + 列表中的所有单词,282–283 + 任何数字,282–283 + 列表中的任何单词,281 + 复杂条件,290 + 字面字符串,282 + 字符串,278–281 + 搜索字符串,286 + 文本替换方法,287–288 + 搜索文件,辅助 + 要搜索的文件列表,285–286 + 搜索字符串的文件,285 + 搜索多个文件,283–284 + 海龟,317–318 + sestercentennial,36–37 + set /A 命令,58–59 + 设置命令,12, 15–18, 379–380 + 在 cmd 文件中,255 + 输入给 for 命令,224–225 + 用于显示变量,19–20 + setlocal 命令,26–33, 172–175 + set /P 命令,160–161, 164 + 设置批处理文件的返回代码,109–110 + setx 命令,20–21 + 苏斯博士,209 + shift 命令,119–120, 123–124 + 短文件名,80, 188 + 辛普森,霍默,152, 168, 386 + 大小,确定文件,187–188 + 跳过(跳过头部记录),213–214 + 例子,222–223, 274, 368 + 蛇形命名法,255 + sort 命令,364–365 + 空格 + 与 set /A 命令一起使用,59 + 与 set 命令一起使用,16–18, 262–263, 265–266 + 意大利面代码,100 + 生成批处理文件,108, 316, 318–319 + *脊椎弹簧*,*这是*,388 + *ss64.com*,xxxi, 22, 425 + *stackoverflow.com*,xxxi + 堆栈,410 + 应用程序,418–420 + 当前目录,89 + peek 函数,411, 415–416 + 弹出函数,411, 415–416 + 推函数,411, 414–416 + 递归调用堆栈,271 + 堆栈对象,413–416 + 斯大林,约瑟夫,95 + 标准错误。*查看* stderr + 标准输入。*查看* stdin + 标准输出。*查看* stdout + start 命令,108, 316, 318–319 + *星际迷航* + 詹姆斯·T·柯克船长,38, 58, 364–365 + 吉恩-卢克·皮卡德船长,38, 364–365 + 数据指挥官,158 + *Enterprise* 队长,列表,364–365 + 可汗,58 + 斯波克博士,364 + 石刀和熊皮,391 + 对象,395 + 沙特纳,威廉,58 + *II:可汗的愤怒*,58,364 + 伏尔甘语,67 + *星际大战*,402 + stderr(标准错误),129–130 + 2,133 + 捕获,376 + 抑制,133–135 + stdin(标准输入),139 + 0,138 + stdout(标准输出),129–130,132 + 1,133 + 捕获,3 + 注释,135–136 + 抑制,133–135 + *三傻*,219–220 + 字符串数据类型,48,54 + 连接,49–50 + 强制转换为大写或小写,234–237 + 子字符串提取(:~),51–53,261–262 + subst 命令,147 + 子字符串,51–53,261–262 + 减法运算符(-),60–61 + 抑制输出(@),14,134–135 + 交换变量值,172 + 开关,22 + Syncsort,365–367 + 语法错误,385–386 + 系统变量,254–255 + T + /T(选项) + 终止子进程(taskkill),314 + 超时(选择),159 + 总计/总数变量,262,265 + taskkill 命令,314–315,317–320 + tasklist 命令,313–314,317–320 + *Test.bat*,387–388 + 文本替换(=),53–54 + 线程,323 + 超时命令,307,310–311,318,320–321 + 时间伪环境变量,248–249,261 + title 命令,161,163,317–318,320 + 令牌,207 + 令牌(令牌化数据),207–210 + 示例,223–225,232,236–237,274,331,359,368–369,412,419 + 跟踪文件,132 + 幻影跟踪,378–379 + 导航,376–378 + 未解析的变量,381–383 + type 命令,132,283,298–301 + U + /U(选项),复制已在目标位置的文件(xcopy),71 + UI(用户界面),157–158 + UltraEdit,8 + /UNIQ(选项),仅输出唯一记录(排序),365 + Unix shell,5 + usebackq(使用反引号)关键字,218–221,226–227 + 应用程序,232–234,236–237,262,274,291,318,354,359,368 + userprofile 系统变量,180,228,254 + V + /V(选项) + 否定搜索逻辑(find),291 + 否定搜索逻辑(findstr),280 + 注册表键值(reg query),367 + 变量 + 分配或设置,12 + 创建审计跟踪,379–380 + 默认设置,310,321 + 本地和全局,26 + 持久设置,20–21 + 解析,12 + 在代码块中,168–170,193–194 + 动态构建的批处理文件,297–298 + 大小限制,413 + 有效字符,15–16,29 + 病毒,批处理,228 + Visual Basic,8 + Visual Studio Code,8 + 伏尔泰,306 + W + /W(选项),重试之间的等待时间(秒)(robocopy),74 + where 命令,145–146 + 当使用“命令”时,97–98 + 通配符,77,82 + 星号(*),78–79 + for 命令,184,196–198,199 + 问号(?),78,79–80 + windir 系统变量,254,256 + Windows 版本,查找,368 + 包装器批处理文件,124–125,192–193 + X + /X(选项),在整个搜索字符串上匹配(findstr),281 + xcopy 命令,6,70–71,76–77,137–138 + /XD(选项),排除目录(robocopy),75 + /XF(选项),排除文件(robocopy),75 + Y + /Y(选项) + 屏蔽确认提示(移动),80 + 屏蔽确认提示(xcopy),71 + 雪人,126 + 約魯巴語,100 \]

posted @ 2025-11-30 19:38  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报