ASDF 3 - 为什么说 Lisp 现在是一种可以接受的脚本语言

ASDF 3 - 为什么说 Lisp 现在是一种可以接受的脚本语言

ASDF 是 Common Lisp 的事实上的标准构建系统,在 2012 年到 2014 年间得到了极大的改进。这些改进以及其他一些改进最终使得 Common Lisp 在易于编写和部署,可访问的,可移植的代码方面达到了“脚本语言”的水平, 并将底层系统或外部程序的功能“粘合”在一起。 因此,可以用 Common Lisp 来编写“脚本”,并利用其表达能力、良好定义的语义和高效的实现。 我们描述了 ASDF 3 中最显着的改进,以及它们如何使得以前难以使用且无法移植的编程语言成为可能。 我们讨论了在改进这一关键软件基础架构方面过去和未来面临的挑战,以及哪些方法在为 Common Lisp 社区带来变革方面起到了作用或没有起到作用。

介绍

截至 2013 年,人们可以使用 Common Lisp (CL) 来编写程序,而传统上使用所谓的“脚本”语言编写程序:人们可以编写小脚本,将操作系统 (OS) 提供的功能、外部程序结合在一起 、C 库或网络服务; 可以将它们扩展为大型、可维护的模块化系统; 并且可以通过命令行以及通过网络协议等将这些新服务提供给其他程序。

使这一切成为可能的最后一个障碍是缺乏一种可移植的方式来构建和部署代码,使得相同的脚本可以在使用一个或多个不同编译器的一台或多台机器上被不同的用户运行而无需修改。 ASDF 3 解决了这个问题。

自 2002 年 Dan Barlow 发布后不久,ASDF 一直是可移值的 CL 软件的事实上的标准构建系统(Barlow 2004)。 构建系统的目的是实现软件开发中的分工:源代码被组织在依赖于其他组件的独立开发的组件中,构建系统将这些组件的传递闭包转换为可以工作的程序。

ASDF 3 是这个系统最新的重写。 除了修复了许多错误之外,它还具有一个新的可移植层。 现在可以使用 ASDF 编写可以从命令行调用的 Lisp 程序,或者可以调用外部程序并捕获它们的输出。 ASDF 可以将这些程序作为独立的可执行文件交付; 此外,伴随脚本 cl-launch(参见 cl-launch)可以创建轻量级脚本,无需修改即可在许多不同类型的机器上运行,每种机器的配置都不同。 这些特性使得可移植脚本成为可能。 以前,必须配置程序的关键部分以和特定的 CL 实现、操作系统和软件安装路径匹配。 现在,使用 CL 完全可以满足个人所有常见的脚本需求,这得益于其高效的实现、数百个软件库等。

在本文中,我们将讨论 ASDF 3 中的创新以及这些创新如何在 CL 中实现新型软件开发。 在 什么是 ASDF 中,我们解释了什么是 ASDF; 我们将其与 C 世界中的常见做法进行比较。 在 ASDF 3: A Mature Build 中,我们描述了 ASDF 3 和 ASDF 3.1 中为解决软件交付问题而引入的改进; 本节需要对 CL 有一定的了解。 保守社区中的代码进化,我们讨论了开发社区软件所面临的挑战,并总结了从我们的经验中吸取的教训。

这是本文的简短版本。 它在有时候引用了仅在扩展版本(Rideau 2014)中出现的附录,其中还包括一些额外的示例和脚注。

1 什么是 ASDF

1.1 ASDF:基本概念

1.1.1 组件

ASDF 是 CL 的构建系统:它帮助开发人员将软件划分为不同层次结构的组件,并从所有源代码自动生成可以工作的程序。

在古老的 Lisp 传统中,顶层组件被称为 系统 ,而底层组件是源文件,通常用 CL 编写。 在这两者之间,可能存在递归的模块层次结构。

然后,用户可以使用各种构建操作对这些组件进行操作(operate),最突出的是编译源代码(操作 compile-op)并将输出加载到当前的 Lisp 映像中(操作 load-op)。

几个相关的系统可以在同一个源代码项目中一起开发。 每个系统都可能依赖于来自其他系统的代码,无论是来自同一个项目还是来自不同的项目。 ASDF 本身没有项目的概念,但 ASDF 之上的其他工具有:Quicklisp (Beane 2011) 将项目中的系统打包成一个发行,并提供数百个发行作为分发,自动按需下载所需的系统及其所有传递依赖。

此外,每个组件可以显式声明对其他组件的依赖关系:每当编译或加载组件依赖于另一个组件中存在的包、宏、变量、类、函数等的声明或定义时,程序员必须声明前者依赖于 (depends-on) 后者。

1.1.2 系统定义的例子

下面是如何在文件 fare-quasiquote.asd 中定义 fare-quasiquote 系统(带有省略号)。 它包含三个文件,packagesquasiquotepp-quasiquote(.lisp 后缀是根据组件类自动添加的;参见附录 C。 后面的每一个文件都依赖于第一个文件,因为前面的文件定义了 CL 包:

(defsystem "fare-quasiquote" ...
  :depends-on ("fare-utils")
  :components
  ((:file "packages")
   (:file "quasiquote" :depends-on ("packages"))
   (:file "pp-quasiquote" :depends-on ("quasiquote"))))

省略掉的元素包括元数据,例如 :license "MIT",以及额外的依赖信息 :in-order-to ((test-op (test-op "fare-quasiquote-test"))),在另一个系统上运行测试来测试当前系统。 请注意系统本身如何依赖于另一个系统 fare-utils,它是来自另一个项目的实用函数和宏的集合,而测试指定由 fare-quasiquote-test 完成,该系统定义在不同的文件 fare-quasiquote-test.asd,在同一个项目中。

1.1.3 行动图

构建软件的过程被建模为动作的有向无环图 (DAG),其中每个动作都是一对操作和组件。 DAG 定义了一个部分顺序,即每个动作都必须执行,但只有在它(传递地)依赖的所有动作都已经执行之后。

比如上面的fare-quasiquotequasiquote 的加载(载入编译的输出)依赖于 quasiquote 的编译,而 quasiquote的编译本身又依赖于 packages 的编译和加载等等。

不过,重要的是,该图与前面的组件图不同:动作图不仅仅是对组件图的改进,而是对它的转换,它还包含有关操作结构的关键信息。

ASDF 从这个 DAG 中提取出一个计划,默认情况下是一个按拓扑排序的动作列表,然后按照 Pitman 的设计(Pitman 1984)按顺序执行。

用户可以定义新的操作和/或组件的子类以及使用它们的方法来扩展 ASDF,或者使用 global、per-system 或 per-component 钩子。

1.1.4 In-image

ASDF 是一个“映像内”的构建系统,传统的 Lisp defsystem 编译(如果需要的话)并将软件加载到当前的 CL 映像中,然后可以通过重新编译和重新加载已更改的组件来更新当前映像。 无论好坏,这明显不同于大多数其他语言的常见做法,其中构建系统是在单独的进程中运行的独立软件。一方面,它最大限度地减少了编写构建系统扩展的开销。 另一方面,它也给 ASDF 带来了很大的压力,要求其保持最小化。

从质量上讲,ASDF 必须作为单个源文件交付,并且不能使用任何外部库,因为它本身定义了可能加载其他文件和库的代码。从数量上讲,ASDF 必须将其内存占用降至最低,因为它存在于所有构建的程序中,并且任何资源的花销都由每个一程序负担。

出于所有这些原因,ASDF 遵循极简主义原则,即任何可以作为扩展提供的东西都应该作为扩展提供并排除在核心之外。 因此,它无法支持由构建表达式的加密摘要索引的持久性缓存,或分布式工作网络等。然而,可以想象,这些都可以作为 ASDF 扩展来实现。

1.2 与 C 编程实践的比较

大多数程序员都熟悉 C,但不熟悉 CL。因此,将 ASDF 与 C 程序员通常用于提供类似服务的工具进行对比是有意义的。但是请注意,在 CL 和 C 中,这些服务是如何以相当不一样的方式进行分解的。

为了构建和加载软件,C 程序员通常使用 make 来构建软件,用 ld.so 加载程序。此外,他们还使用 autoconf 之类的工具来定位可用的库并识别它们的特性。在许多方面,这些 C 解决方案比 ASDF 设计得更好。但在某些重要的方面,ASDF 显示了这些 C 系统具有许多偶然的复杂性,而 CL 由于更好的体系结构而消除了这些复杂性。

  • Lisp 使得所有的运行时功能在编译时是可用的,因此很实现领域特定语言(DSL):程序员只需要定义新的功能,作为一个扩展,然后与语言的其余部分(包括其他扩展)无缝结合。在C语言中,许多需要DSL的实用程序必须从头开始进行繁重的扩展;某个领域的专家很少有可能同时也是一个编程语言专家,这意味着大量互不兼容、设计错误、缺乏能力、实现错误的语言必须通过一种无原则的混乱的昂贵但缺乏表达能力的通信手段来组合。

  • Lisp 在运行时和编译时都提供了完整的内省,以及声明特性和有条件地包含或省略基于这些特性的代码或数据的协议。因此,您不需要在编译时使用黑魔法来检测可用的特性。在 C 语言中,人们依靠的是极其难以维护的配置脚本,这些配置脚本由 shell 脚本、m4宏、C 预处理和 C 代码组成,通常还有一些pythonperlsed等代码。

  • ASDF 拥有一种标准且可扩展的方式来配置在哪里找到你的代码所依赖的库,这在 ASDF 2 中得到了进一步的改进。在 C 语言中,有数十种互不兼容的方法,例如:libtool,autoconfkde-config, pkg-config,各种手册。./configure脚本,和无数的其他协议,以便每个新软件要求用户学习一种新的特别的配置方法,使得使用和分发一个库的努力变得昂贵。

  • ASDF 使用完全相同的机制来配置运行时和编译时,因此只有一种配置机制可以学习和使用,并且差异最小。 在 C 中,在运行时(ld.so)和编译时(未指定)使用完全不同、不兼容的机制,这使得很难匹配源代码、头文件、静态和动态库,需要复杂的“软件分发”基础设施( 诚然,它还管理版本控制、下载和预编译); 当差异蔓延时,这有时会导致细微的错误。

尽管如此,与 CL、C、Java 或其他系统的其他构建系统相比,ASDF 在很多方面都显得相形见绌:

  • ASDF 不是一个通用的构建系统。它相对简单直接关系到它是定制的,仅用于构建 CL 软件。从一个方面来看,这是一个标志,表明如果你有一个良好的基础架构,你可以做的很少;类似的简单解决方案不适用于大多数其他编程语言,它们需要更复杂的工具来实现类似的目的。从另一个角度来看,这也是 CL 社区未能融入外部世界,并提供具有足够普遍性的解决方案来解决更复杂的问题。

  • 在另一个极端,如果可以要求软件遵循一些简单的组织约束,而不需要与遗留的代码保持兼容的话,CL 的构建系统可以比 ASDF 更简单、更优雅。一个建设性的证明是quick-build(Bridgewater 2012),它的规模只有 ASDF 的一小部分,它本身也只是 ASDF 3 的一小部分,并且有一小部分错误——但没有通用性和可扩展性(参见 包推断系统)。

  • ASDF 完全不适应在现代的对抗性多用户、多处理器、分布式环境中构建大型软件,在这些环境中,源代码有许多不同的版本和配置。ASDF 植根于一种古老的镜像构建软件模型,更重要的是,它植根于传统的单处理器、单机环境,具有友好的单用户、单一连贯的源代码视图和单一目标配置。新的 ASDF 3 设计具有一致性和通用性,可以想象,它可以按比例生产,但这需要大量工作。

2. ASDF 3:成熟的构建

2.1 一致的、可扩展的模型

对于每天使用它的 CL 程序员来说可能会感到惊讶,ASDF 的核心存在一个根本的 BUG:它甚至没有尝试将时间戳从一个动作传播到下一个动作。 然而,在大部分情况下它都可以正常工作。 该 BUG 从 2001 年的第一天开始就存在,甚至早在 1990 年以来就出现在 mk-defsystem 中了(Kantrowitz 1990),并且它一直存在到 2012 年 12 月,尽管我们自 2009 年以来进行了所有的鲁棒化努力(Goldman 2010),但是修复这个 bug 需要完全重写 ASDF 的核心。

因此,ASDF 的对象模型同时变得更强大、更健壮且更易于解释。 它的遍历函数的黑魔法被一个有据可查的算法所取代。 扩展 ASDF 比以前更容易,限制更少,陷阱更少:用户可以控制他们的操作如何沿着组件的层次结构传播或不传播。 因此,ASDF 现在可以表达任意动作图,可以想象在未来可以将它用于构建非 CL 程序。

一个好的设计的证明在于它易于扩展。在 CL 中,扩展不需要对代码库进行特权访问。因此,我们通过调整最复杂的现有 ASDF 扩展来测试我们的设计。这样做的结果确实更简洁,消除了以前对重写的需要,因为重写需要重新定义相当大的基础结构块。然而,按照时间顺序,我们在开发 ASDF 3 的过程中有意识地开始了这个移植过程,从而确保 ASDF 3 拥有避免重新定义所需的所有扩展钩子。

请参阅附录 F 中的整个故事

2.3 捆绑操作

捆绑操作为整个系统或系统集合创建单个输出文件。最直接的面向用户的的捆绑操作是compile-bundle-opload-bundle-op:前者将系统中每一个源文件的compile-op的输出结果捆绑到单个编译文件中;后者加载前者的结果。此外,lib-op将系统中所有的对象文件链接到一个库中,dll-op将从中创建一个可动态加载的库。上述捆绑操作还有所谓的 monolithic变种,它将系统中的所有文件及其所有可传递依赖项捆绑到一起。

捆绑操作使代码的交付变得更加容易。 它们最初是作为 asdf-ecl 引入的,它是特定于 ECL 实现的 ASDF 的扩展,早在 ASDF 1 时代。asdf-ecl 与 ASDF 2 一起分发,尽管在某种程度上使 ECL 用户的升级有点尴尬,他们 升级 ASDF 后必须显式重新加载它,即使在初始文件中包含了(require "asdf")。 2012 年,它作为外部系统 asdf-bundle,被推广到其他实现。 然后它在 ASDF 3 的开发过程中被合并到 ASDF 中:它不仅提供了有用的新操作,而且 ASDF 3 出于安全目的自动升级自身的方式(见附录 B),如果 捆绑支持本身并未与 ASDF 捆绑在一起。会对 ECL 用户造成严重破坏。

在ASDF 3.1中,使用deliver-asd-op,您可以从compile-bundle-op.asd 文件创建捆绑包,用于仅以二进制格式交付系统。

2.3 可理解的内部结构

在捆绑支持被合并到 ASDF 中之后,实现一个新的 concatenate-source-op 操作变得微不足道。因此,ASDF 可以开发为多个文件,这将提高可维护性。出于交付目的,源文件将以正确的依赖顺序连接到引导所需的单个文件 asdf.lisp 中。

在我们接管 ASDF 后不久,就有人提议将 ASDF 划分为更小、更易于理解的部分。 但是当时我们拒绝了这个提议,理由是 从源代码升级 ASDF 自身不得依赖外部工具 ,这是另外一个很强烈的需求(参见附录 B)。 使用 concatenate-source-op,交付和定期升级不需要外部工具,仅用于引导。 与此同时,这个划分也变得更加重要,因为 ASDF 发展得如此之快,自那时起到现在为止,规模几乎翻了三倍,并且有望进一步增长。 即使对于维护者来说,在那个大文件中导航也很困难,新手可能根本就无法理解它。

为了给这个划分带来一些原则,我们遵循了”一个文件,一个包“的原则,正如 faslpath (Etter 2009) 和 quick-build (Bridgewater 2012) 所展示的那样,尽管 ASDF 本身还没有积极支持(参见 package-inferred-system)。 这种编程风格确保文件确实提供了相关的功能,只对其他文件有显式的依赖,并且在没有特殊声明的情况下没有任何前向依赖。 确实,这在使 ASDF 在易于理解方面取得了巨大成功,即使不是新手,至少维护者本人也是如此。 这反过来又触发了一系列原本不明显或明显正确的增强功能,说明了好的代码是您可以理解的代码的原则,这些代码组织成块,每个块都可以放入您的大脑。

2.4 升级包

保持 ASDF 的热升级能力一直是一个强烈的要求(见附录 B)。 在这种包重构的情况下,这意味着开发了一个与热升级配合得很好的 CL defpackage 变体:define-package。 前者不能保证工作,并且在以不兼容的方式重新定义包时可能会发出错误信号,而后者将更新旧的包以匹配新的定义,同时从该包和其他包中回收已有的符号。

因此,除了来自 defpackage 的常规子句之外,define-package 还接受一个子句 :recycle: 它尝试以给定的顺序从每个指定的包中回收每个声明的符号。 出于 idempotence,包自身必须是列表中的第一个。 为了从旧的 ASDF 升级, :asdf 包总是最后命名的。 默认的回收列表包含包及其昵称的列表。

新功能还包括 :mix:reexport:mix 混合来自多个包的导入符号:当多个包导出具有相同名称的符号时,冲突会自动解决,最先导入的包优先,而使用标准的 :use 子句时会引发错误情况。 :reexport 重新导出与从给定包中导入的相同符号,和/或导出替代它们的同名符号。 ASDF 3.1 添加了 :mix-reexport:use-reexport,它们将 :reexport:mix:use 组合在一个语句中,这比重复一个包列表更易于维护。

2.5 可移植层

将 ASDF 拆分为许多文件表明其中很大一部分已经用于通用实用程序。 这部分只是在以下压力下增长:将 ASDF 分成许多文件后,很多改进的机会变得明显; 从以前的扩展和库中添加或合并的功能需要新的通用实用程序; 随着为新特性添加更多测试,并在所有支持的实现上运行,在多个操作系统上,新的可移植性问题突然出现,需要开发健壮和可移植的抽象。

可移植层在被完整记载后,最终比 ASDF 的其余部分还要大一点。 早在那之前,ASDF 就被正式分为两部分:这个可移植层和 defsystem 本身。 可移植层最初被称为 asdf-driver,因为它合并了 xcvb-driver 的许多功能。 因为用户要求一个不包含 ASDF 的更短的名称,但会以某种方式提醒 ASDF,它最终被重命名为 UIOP:Utilities for Implementation and OS Portability。 它作为可移植库独立于 ASDF 提供,可单独使用; 然而,由于 ASDF 仍需要作为单个文件 asdf.lisp 交付,因此 UIOP 被嵌入到该文件中,现在使用 monolithic-concatenate-source-op 操作进行构建。 在 Google,构建系统实际上使用 UIOP 来实现可移植性,而无需使用 ASDF 的其余部分。 这导致 UIOP 的改进将随 ASDF 3.1.2 一起发布。

大多数实用程序需要应付合理的路径名抽象(参见附录 C)、文件系统访问、合理的输入/输出(包括临时文件)、基本的操作系统交互——许多 CL 标准缺乏的东西。 在不太兼容的遗留实现上还有一个抽象层、一组通用实用程序和一个用于 ASDF 配置 DSL 的公共核心。 对构建系统来说重要的是,有用于编译 CL 文件的可移植抽象,同时控制所有可能发生的警告和错误,并且支持 Lisp 映像的生命周期:转储和恢复映像、初始化和终结钩子、错误 处理,回溯显示等。然而,最复杂的部分结果是 run-prorgam 的可移植实现。

2.6 run-program

使用 ASDF 3,您可以运行如下外部命令:

(run-program `("cp" "-lax" "--parents"
               "src/foo" ,destination))

在 Unix 上,这会递归地将目录 src/foo 中的文件硬链接到由字符串 destination 命名的目录中,并保留前缀 src/foo。 您可能必须添加 :output t :error-output t 才能在 *standard-output**error-output* 流上获取错误消息,因为默认值 nil 指定 /dev/null。 如果调用的程序返回错误代码,run-program 会发出结构化的 CL 错误信号,除非您指定 :ignore-error-status t

该实用程序对于 ASDF 扩展和 CL 代码通常是必不可少的,以便可移植地执行任意的外部程序。 编写是一个挑战:每个实现都提供了不同的底层机制,具有截然不同的功能集和无数的极端情况。 更好的可以 forkexec 一个进程并控制它的标准输入、标准输出和错误输出; 较小的只能调用 system(3) C 库函数。 此外,Windows 支持与 Unix 有很大的不同。 ASDF 1 本身实际上有一个 run-shell-command,最初是从 mk-defsystem 复制过来的,但这与其说是一个解决方案,不如说是一个吸引人的麻烦,尽管我们修复了许多错误:它隐式地调用了 format; 捕获输出是特别做作的; 以及在不同的实现中使用的 shell 会有所不同,在 Windows 上更是如此。

ASDF 3 的 run-program 功能齐全,基于最初来自 XCVB 的 xcvb-driver (Brody 2009) 的代码。 它抽象出所有这些差异,以提供对程序标准输出的控制,并在需要时使用下面的临时文件。 从 ASDF 3.0.3 开始,它还可以控制标准输入和错误输出。 它接受程序和参数的列表,或 shell 命令字符串。 因此,您之前的程序可能是:

(run-program
 (format nil "cp -lax --parents src/foo ~S"
         (native-namestring destination))
 :output t :error-output t)

其中(UIOP)的 native-namestringpathname 名对象目标转换为适合操作系统使用的名称,而不是可能以某种方式转义的 CL namestring

您还可以给外部程序注入输入和捕获输出:

(run-program '("tr" "a-z" "n-za-m")
 :input '("uryyb, jbeyq") :output :string)

返回字符串"hello, world"。 对于(未捕获的)错误输出和(成功的)退出代码,它还分别返回二级和三级值 nil0

run-program 只提供了一个基本的抽象; 在 UIOP 之上编写了一个单独的系统 inferior-shell,并提供了更丰富的接口、处理管道、zsh 风格的重定向、将字符串和/或列表拼接到参数中,以及将路径名隐式转换为本地的路径字符串,将符号转换为 小写字符串,将关键字转换为带有 -- 前缀的小写字符串。 它的简称函数runrun/nilrun/srun/ss,都可以运行外部命令,分别输出到 Lisp 标准和错误输出,不输出,输出到字符串,或输出到 一个 stripped 的字符串。 因此,您可以获得与以前相同的结果:

(run/ss '(pipe (echo (uryyb ", " jbeyq))
               (tr a-z (n-z a-m))))

要获取 Linux 机器上的处理器数量,您可以:

(run '(grep -c "^processor.:"
            (< /proc/cpuinfo))
     :output #'read)

2.7 配置管理

ASDF 对配置管理的支持是最小化的。 ASDF 3 没有引入根本性的变化,而是为旧功能提供了更多可用的替换或改进。

例如,ASDF 1 一直支持版本检查:每个组件(通常是一个系统)都可以被赋予一个版本字符串,例如 :version "3.1.0.97",并且可以告诉 ASDF 检查是否使用了至少给定版本的依赖项,如 :depends-on ((:version "inferior-shell" "2.0.0"))。 此功能可以及早检测到依赖项不匹配,从而避免用户必须找出升级某些库所需的困难方法,以及哪些方法。

现在,ASDF 总是要求组件使用“语义版本控制”,其中版本是由点分隔数字组成的字符串,如 3.1.0.97。 但它并没有强制执行,当用户预期该机制会有效,但遭遇失败时,会给用户带来了不好的意外。 当发现不符合格式的版本时,ASDF 3 会发出警告。 如果没有破坏太多现有系统,它实际上会发出错误信号。

版本字符串的另一个问题是它们必须作为文字写入 .asd 文件中,除非该文件采取了痛苦的步骤从另一个源文件中提取它。虽然源代码很容易从系统定义中提取版本,但一些作者合理地希望他们的代码不依赖于 ASDF 本身。此外,在项目的每个系统定义中重复文字版本和/或提取代码也很痛苦。因此,ASDF 3 可以从源树中的文件中提取版本信息,例如:version (:read-file-line "version.text") 读取文件 version.text 的第一行作为版本号。要读取第三行,应该是 :version (:read-file-line "version.text" :at 2) (请注意英语中的错误)。或者您可以从源代码中提取版本。例如,poiu.asd 指定 :version (:read-file-form "poiu.lisp" :at (1 2 2)) 这是文件 poiu.lisp 中第二个形式的第三个子形式的第三个子形式。第一种形式是内包装,必须跳过。第二种形式是 (eval-when (...) body...),其主体以 (defparameter *poiu-version* ...) 形式开始。因此,ASDF 3 解决了所有软件的版本提取问题——除了它自己,因为它自己的版本必须由 ASDF 2 以及查看单个交付文件的人读取;因此其版本信息由使用正则表达式的管理脚本维护,当然是用 CL 编写的。

ASDF 1 和 2 的另一个令人痛苦的配置管理问题是缺乏一种有条件地包含文件的好方法,具体取决于使用的实现及其支持的功能。 可以始终使用 CL 读取器条件,例如 #+(or sbcl clozure),但这意味着 ASDF 甚至无法看到被排除的组件,如果调用某些涉及打印或打包代码而不是编译代码的操作 - 或者更糟的情况是, 它是否应该涉及具有不同功能集的另一个实现的交叉编译。 组件有一种模糊的方式来声明对 :feature 的依赖,并使用 :if-component-dep-fails :try-next 注释其封闭模块以捕获失败并继续尝试。但是,该实现是一个笨拙的 traverse,它缩短了通常的依赖项传播,并且在嵌套此类伪依赖项以痛苦地模拟特征表达式时最坏复杂度是指数级的。

ASDF 3 摆脱了 :if-component-dep-fails: 它根本不适合固定的依赖模型。 只保留了没有嵌套的有限兼容模式以继续处理旧版本的 SBCL。 作为替代,ASDF 3 在组件声明中引入了一个新选项 :if-feature,这样只有在计划阶段给定的特征表达式为真时,组件才会包含在构建计划中。 因此,使用 :if-feature (:and :sbcl (:not :sb-unicode)) 注释的组件(及其子项,如果有的话)仅包含在没有 Unicode 支持的 SBCL 中。 这比之前的更具表现力,不需要依赖模型中的不一致,也没有病态的性能行为。

2.8 独立可执行程序

ECL 团队贡献的捆绑操作之一是 program-op,它创建了一个独立的可执行文件。 由于这现在是 ASDF 3 的一部分,因此将其他支持 ASDF 的实现提升到同等水平是很自然的:CLISP、Clozure CL、CMUCL、LispWorks、SBCL、SCL。 因此,UIOP 具有一个 dump-image 函数来转储当前堆映像,除了 ECL 及其遵循链接模型并使用 create-image 函数的后继者。 这些函数基于来自 cl-launchxcvb-driver 的代码。

ASDF 3 还引入了一个 defsystem 选项来指定一个入口点,例如::entry-point "my-package:entry-point"。 程序映像初始化后,不带参数调用指定的函数(指定为创建包后要读取的字符串); 在进行自己的初始化之后,它可以显式地查询 *command-line-arguments* 或将其作为参数传递给某个主函数。

我们在 ITA Software 使用大型应用服务器的经验表明了钩子的重要性,以便各种软件组件可以模块化地注册要在转储图像之前调用的终结函数,以及在调用入口点之前要调用的初始化函数。 因此,我们向 UIOP 添加了对图像生命周期的支持。 我们还添加了对以非交互方式和交互方式运行程序的基本支持:非交互程序退出时带有回溯和在回溯上方和下方重复的错误消息,而不是让最终用户进入调试器; 任何来自入口点函数的非 nil 返回值都被认为是成功而 nil 表示失败,并具有适当的程序退出状态。

从 ASDF 3.1 开始,不支持生成独立可执行文件的实现仍然可以使用 image-op 操作转储堆映像,包装器脚本(比如由cl-launch所创建的)可以调用程序; 然后交付连同包装器脚本一起交付。 所有实现都可以使用 image-op 在分阶段构建中创建中间映像,或者为其他非交互式应用程序提供准备调试的映像。

2.9 cl-launch

运行Lisp代码以从Lisp可移植地创建可执行命令是很好的,但存在一个引导问题:当您只能假设是Unix shell时,如何可移植地调用创建初始可执行文件的Lisp代码?

几年前,我们通过 cl-launch 解决了这个问题。 这个双语程序,既是一个可移植的 shell 脚本,也是一个可移植的 CL 程序,提供了一个很好的 shell 命令接口来从 Lisp 代码构建 shell 命令,并支持作为可移植的 shell 脚本或自包含的预编译可执行文件交付。

它的最新版本 cl-launch 4(2014 年 3 月)进行了更新,以充分利用 ASDF 3。它的构建规范接口变得更加通用,它的 Unix 集成也得到了改进。 这样,您就可以从 Unix shell 调用 Lisp 代码:

cl -sp lisp-stripper \
   -i "(print-loc-count \"asdf.lisp\")"

您也可以使用 cl-launch 作为脚本“解释器”,它又调用下层的 Lisp 编译器:

#!/usr/bin/cl -sp lisp-stripper -E main
(defun main (argv)
  (if argv
      (map () 'print-loc-count argv)
      (print-loc-count *standard-input*)))

在上面的例子中,选项 -sp--system-package 的简写,在构建阶段同时使用 ASDF 加载系统,并适当地选择当前包; -i--init 的简写,在执行阶段开始时求值表单; -E,--entry 的简写配置一个函数,该函数在初始化表单被求值后调用,命令行参数列表作为其参数。 至于 lisp-stripper,它是一个简单的库,可以在删除注释、空行、文档字符串和字符串中的多行之后计算代码行数。

cl-launch 会自动检测机器上安装的 Common Lisp 实现,默认设置是合理的。您可以使用适当的命令行选项、配置文件或一些安装时配置轻松覆盖所有默认设置。有关完整信息,请参阅 cl-launch --more-help。请注意,cl-launch的目的是在 Linux 发行版上创建可执行路径 /usr/bin/cl, 将它作为cl-launch调用可能会更容易移植。

cl-launch 的一个很好的用途是比较各种实现如何求值某种形式,看看它在实践中的可移植性,标准是否要求特定的结果:

for l in sbcl ccl clisp cmucl ecl abcl \
         scl allegro lispworks gcl xcl ; do
 cl -l $l -i \
 '(format t "'$l': ~S~%" `#5(1 ,@`(2 3)))' \
 2>&1 | grep "^$l:" # LW, GCL are verbose
done

cl-launch 编译所有指定的文件和系统,并将编译结果保存在与 ASDF 3 相同的输出文件缓存中,按实现、版本、ABI 等很好地隔离它们。因此,它第一次看到给定的 文件或系统,或更新后,编译器处理文件时可能会出现启动延迟; 但是由于直接加载编译的代码,后续调用会更快。 这与其他每一次都必须缓慢地解释或重新编译的“脚本”语言形成鲜明对比。 出于安全原因,缓存不在用户之间共享。

2.10 package-inferred-system

ASDF 3.1 引入了一个新的扩展package-inferred-system(包推断系统),它支持一个文件、一个包、一个系统的编程风格。 这种风格由 faslpath (Etter 2009) 和最近的 quick-build (Bridgewater 2012) 开创。 扩展实际上与后者兼容,但与前者不兼容,因为 ASDF 3.1 和quick-build使用斜杠“/”作为层次分隔符,而 faslpath 使用点“.”。

这种风格包含在以 defpackagedefine-package 形式开头的每个文件中; 从它的 :use:import-from 和类似的子句中,构建系统可以识别它所依赖的包列表,然后将包名称映射到需要首先加载的系统和/或其他文件的名称。 因此包名 lil/interface/all 指的是系统 lil 注册的层次结构下的文件 interface/all.lisp,在 lil.asd 中使用类 package-inferred-system 定义如下:

(defsystem "lil" ...
  :description "LIL: Lisp Interface Library"
  :class :package-inferred-system
  :defsystem-depends-on ("asdf-package-system")
  :depends-on ("lil/interface/all"
               "lil/pure/all" ...)
  ...)

:defsystem-depends-on ("asdf-package-system") 是一个外部扩展,提供与 ASDF 3.0 的向后兼容性,是 Quicklisp 的一部分。 因为并非所有包名称都可以直接映射回系统名称,所以您可以为 package-inferred-system 注册新的映射。 因此,lil.asd 文件可能包含以下形式:

(register-system-packages :closer-mop  
 '(:c2mop :closer-common-lisp :c2cl ...))

然后,在 lil 层次结构下的文件 interface/order.lisp 定义了用于顺序比较的抽象接口,从以下形式开始,从 :use:mix 子句简单地计算依赖关系:

(uiop:define-package :lil/interface/order
  (:use :closer-common-lisp
   :lil/interface/definition
   :lil/interface/base
   :lil/interface/eq :lil/interface/group)
  (:mix :fare-utils :uiop :alexandria)
  (:export ...))

这种风格提供了许多可维护性的好处:通过将较小的命名空间规则强加给程序员,具有显式的依赖关系,尤其是显式的前向依赖关系,这种风格鼓励将代码很好地分解为连贯的单元; 相比之下,传统的“一切都在一个包中”的风格开销很低,但扩展性不是很好。 ASDF 本身作为 ASDF 2.27(最初的 ASDF 3 预发行版)的一部分以这种风格进行了重写,取得了非常积极的成果。

由于它依赖于 ASDF 3,因此 package-inferred-system 不如 quick-build 轻量级,后者比 ASDF 3 小了近两个数量级。但它确实与 ASDF 的其余部分完美互操作,它继承了许多功能,可移植性和鲁棒性。

2.11 恢复向后兼容性

ASDF 3 不得不打破与 ASDF 1 和 2 的兼容性:所有操作过去都沿组件 DAG 横向和向下传播(参见附录 F)。 在大多数情况下,这是不希望的。 事实上,ASDF 3 是基于一个新的操作 prepare-op 而不是向上传播。 因此,大多数现有的 ASDF 扩展都包含解决该问题的变通方法和近似值。 但是少数扩展确实预期这种行为,现在它们被破坏了。

在 ASDF 3 发布之前,已经联系了 Quicklisp 分发的所有已知 ASDF 扩展的作者,以使他们的代码与新的固定模型兼容。 但是除了向邮件列表发送通知之外,没有办法联系到身份不明的专有扩展作者。 然而,无论发送什么信息,都没有引起足够的关注。 当工作中使用的扩展程序停止工作时,甚至我们的共同维护者罗伯特·戈德曼(Robert Goldman)也被敲了一闷棍,浪费了好几天时间来解决这个问题。

因此,ASDF 3.1 具有增强的向后兼容性。 operation类在所有上实现了横向和向下传播,这些类没有显式地继承自任何传播的 Mixin downward-operationupward-operationsideway-operationselfward-operation,除非它们显式地从新的 Mixin 非传播操作继承。当实例化一个不继承上述任何 mixin 的操作类时,ASDF 3.1 会在运行时发出警告,这将有希望提醒专有扩展的作者,是时候修复他们的代码了。要告诉 ASDF 3.1 它们的操作类是最新的,扩展作者可能必须定义它们的非传播操作,如下所示:

(defclass my-op (#+asdf3.1 non-propagating-operation operation) ())

这是“负继承”的一种情况,这种技术通常是不被认可的,因为它的明确目的是向后兼容。现在 ASDF 不能使用元对象协议(MOP),因为它还没有标准化到可以在不使用诸如 closeer-mop 之类的抽象库的情况下进行移植使用,但是 ASDF 不能依赖任何外部库,这也是一个小问题来证明将一个相当大的 MOP 库作为 UIOP 的一部分是合理的。 因此,负继承是在运行时以一种特别的方式实现的。

3 保守社区中的代码进化

3.1 Feature Creep? No, Mission Creep

在从 ASDF 1 到 ASDF 3 添加的许多特征和大小增加十倍的过程中,ASDF 仍然忠实于其极简主义——但相对于让代码保持最小的使命,它被扩展了好几次:一开始,ASDF 是最简单的构建 CL 软件的 defsystem 的可扩展变种(参见附录 A)。 对于 ASDF 2,它必须是可升级的、便携的、模块化配置的、健壮的、高性能的、可用的(参见附录 B)。 然后它必须更具声明性、更可靠、更可预测,并且能够支持语言扩展(参见附录 D)。 现在,ASDF 3 必须支持表示依赖关系的连贯模型、用于声明它们的另一种每个文件一个包的样式、作为脚本或二进制文件的软件交付、包括映像生命周期和外部程序调用的文档化可移植层, 等等(参见 ASDF 3:成熟的构建)。

3.2 向后兼容性是社会性的,而不是技术性的

随着努力改进 ASDF,一个不变的限制是向后兼容性:每个新版本的 ASDF 都必须与以前的版本兼容,即使用以前的版本定义的系统必须能够在新的 ASDF 版本中继续使用。 但是什么是向后兼容性?

在一个排除掉任何行为变化的过于严格的定义中,即使是最没有争议的错误修复也不是向后兼容的:任何变化,尽管它可能更好,都是不兼容的,因为根据定义,某些行为已经改变了!

人们可能会想稍微弱化约束,并将“向后兼容”定义为与“保守扩展”相同:保守扩展可以修复错误的情况,并为以前未定义的情况赋予新的含义, 但可能不会改变先前已定义的情况的含义。 然而,这个定义是非常令人不满意的。 一方面,它排除了对先前错误决定的任何修改; 开个玩笑,如果它不是倒退的,就是不兼容的。 另一方面,即使它只是在以前出错的地方创建了可以正常工作的新情况,一些现有的分析工具可能会假设这些情况永远不会出现,并且当它们出现时会感到困惑。

当 ASDF 3 试图更好地支持辅助系统时,确实发生了这种情况。 ASDF 按名称查找系统:如果您尝试加载系统 foo,ASDF 将在注册目录中搜索名为 foo.asd 的文件。现在,通常的做法是程序员可以在同一个 .asd 文件中定义多个“辅助”系统,例如除了 foo 之外的测试系统 foo-test。当文件 foo-test.asd 存在于同一库的不同版本(否则为阴影)时,这可能会导致“有趣”的情况,从而导致系统与其测试之间不匹配。为了降低这些情况的可能性,ASDF 3 建议您将辅助系统命名为 foo/test 而不是 foo-test,这在 ASDF 2 中应该同样有效,但会降低冲突的风险。此外,ASDF 3 可以识别模式并在请求 foo/test 时自动加载 foo.asd,以保证不会与以前的用法发生冲突,因为在任何现代操作系统中,没有目录可以包含这样命名的文件。相反,ASDF 2 无法根据辅助系统的名称自动定位 .asd 文件,因此您必须确保在使用辅助系统之前加载了主系统的 .asd 文件。此功能可能看起来像是向后兼容的“保守扩展”的教科书案例。然而,这也是 Quicklisp 本身仍未采用 ASDF 3 的主要原因:Quicklisp 假设它总是可以创建一个以每个系统命名的文件,这在 ASDF 3 创新之前的实践中碰巧是正确的(尽管不能保证);新加入使用这种风格的辅助系统的系统打破了这个假设,并且需要 Quicklisp 进行大量的工作来支持。

那么,什么是向后兼容性? 这不是技术限制。 向后兼容是一种社会约束。 如果用户满意,新版本是向后兼容的。 这并不意味着在所有数学上可以想象的输入上都匹配以前的版本; 这意味着改进用户使用的所有实际输入的结果; 或为他们提供可用于改进结果的替代输入。

3.3 弱同步需要增量修复

即使一些“不兼容”的更改没有争议,也经常需要提供临时向后兼容的解决方案,直到所有用户都可以迁移到新设计。 改变一个软件系统的语义,而其他系统仍然依赖它,这类似于更换行驶中的汽车的轮子:通常不能一次全部更换,在某些时候,您必须同时激活两种软件,并且不能删除旧的,直到你不再依赖它们。 在一家快速发展的公司中,整个代码库的这种迁移可以在一次 checkin 中发生。 如果它是一家拥有许多团队的大公司,迁移可能需要数周或数月的时间。 当软件被 CL 社区等弱同步组使用时,迁移可能需要数年时间。

在发布 ASDF 3 时,我们花了几个月的时间确保它可以与所有公开可用的系统兼容。 我们必须修复其中的许多系统,但大多数情况下,我们正在修复 ASDF 3 本身以使其更加兼容。 实际上,必须放弃一些预期的更改,这些更改没有增量升级的路径,或者无法修复所有的客户端。

一个成功的改变就是将默认编码从不受控制的与环境相关的:default修改为事实上的标准:utf-8; 这发生在添加对编码和 :utf-8 的支持一年后,并且已经预先警告社区成员未来默认值的变化,但仍有一些系统需要修复(参见附录 D)。

另一方面,一个失败的尝试是,试图让一个创新的系统来控制编译器发出的警告。首先,*uninteresting-conditions* 机制允许系统构建者隐藏他们知道他们不关心的警告,以便任何编译器输出都是他们关心的东西,而他们关心的任何东西都不会被淹没在无趣的输出信息中。该机制本身包含在 ASDF 3 中,但默认禁用,因为除了空集之外没有一致同意的值,并且(到目前为止)没有好的方法来模块化和轻松地配置它。其次,另一个同样被禁用的相关机制是延迟警告,由此 ASDF 可以检查由 SBCL 或其他编译器延迟到当前编译单元结束的警告。这些警告特别包括对函数和变量的前向引用。在 ASDF 的早期版本中,这些警告在第一次构建文件时在构建结束时输出,但未检查,之后也不显示。如果在 ASDF 3 中 您(uiop:enable-deferred-warnings),则每次编译或加载系统时都会显示和检查这些警告。这些检查有助于捕获更多错误,但启用它们会阻止许多具有此类错误的系统在 Quicklisp 中成功加载,即使这些系统所需的功能不受这些错误的影响。在存在允许开发人员在不破坏旧代码的情况下对新代码运行所有这些检查的配置系统之前,该功能必须保持默认禁用的状态。

3.4 规则不足会给可移植性埋下地雷

CL 标准留下了许多关于路径名的未指定内容,以努力定义许多当时存在的实现和文件系统共有的有用子集。 然而,结果是可移植程序永远只能访问完整所需功能的一小部分。 这一结果可以说使该标准远不如预期的有用(见附录 C)。 教训是不要标准化部分指定的特征。 最好标准化某些情况会导致错误,并将任何解决方案保留给标准的更高版本(然后跟进),或者将规范委托给现有或未来的其他标准。

每个操作系统可能有一个路径名协议,通过标准的 FFI 委托给底层操作系统。 然后,库可以整理出 N 个操作系统的可移植性。 相反,通过仅标准化一个公共片段并让 M 个实现中的每一个在每个操作系统上尽其所能,库现在必须考虑 N*M 个操作系统和实现的组合。 如果出现分歧,最好让每个实现的变体存在于自己的、不同的命名空间中,这样可以避免任何混淆,而不是在同一个命名空间中存在不兼容的变体,从而导致冲突。

有趣的是,在 CL 标准中包含 defsystem 的提议也被中止了,它指定了一个不足以大规模使用的最小子集,而让其余部分未指定。 由于该提案的失败,CL 社区可能躲过了一劫。

3.5 Safety before Ubiquity

Guy Steele 曾吹嘘 Lisp 语法的可编程性: 如果你给了某人 Fortran,他就拥有了 Fortran。如果你给了某人 Lisp,他就拥有了他喜欢的任何语言。不幸的是,如果他谈论的是 CL,他不得不再加一句: 但是它和别人的不一样。

事实上,CL 中的语法是通过一组模糊的全局变量来控制的,主要包括 *readtable*。对变量和/或表进行重大的修改是可能的,但是让忽略这些修改成了一个严重的问题;因为系统的作者无法控制哪些系统在他的系统之前加载或在他的系统之后加载,或者是不加载哪些系统——这取决于用户的请求以及已经编译或加载的内容。因此,在没有进一步约定的情况下,要么依赖于语法表具有来自以前系统的非默认值,要么将非默认值强加给下一个系统,这终究是一个错误。更糟糕的是,更改语法只有在交互式 REPL 和适当的编辑器缓冲区中也发生时才有用。然而,这些交互式的语法更改可能会影响到交互式构建的文件,包括在修改时不依赖于语法支持的组件,更糟糕的情况下,影响会波及语法支持所依赖的组件;这可能会导致灾难性的循环依赖,需要在清除输出文件缓存后重新开始。诸如 named-readtablescl-syntax 之类的系统有助于进行语法控制,但目前 CL 或 ASDF 都没有强制执行适当的卫生措施,而是由用户决定,尤其是在 REPL 中。

因此,对于安全的语法修改,强烈需要构建支持;但是ASDF 3中还没有这种构建支持。出于向后兼容的原因,ASDF不会对语法实施严格的控制,至少在默认情况下不会。但是,通过在每个操作周围绑定标准语法表的只读副本,可以很容易地加强卫生。一个更向后兼容的行为是让系统修改一个共享的可读文件,并让用户负责确保在给定映像中使用的对这个可读文件的所有修改是相互兼容的;ASDF仍然可以在每次编译时将当前的“可读文件”绑定到共享的“可读文件”上,以确保在REPL中选择一个不兼容的可读文件不会影响构建。此效果的补丁等待新的维护者的接受。

在这些问题得到解决之前,尽管 Lisp 理想的语法扩展是普遍存在的,而且实际上通过宏进行的扩展是普遍存在的,但在 CL 社区中,尽管对 reader 进行了更改,但扩展却很少。这与其他 Lisp 方言(如Racket)形成了对比,后者通过将语法严格限定在当前文件或REPL范围内,成功地使语法定制既安全又普遍。任何语言特性在普及之前都必须是安全的;如果特性的语义依赖于系统作者无法控制的环境,例如用户在其REPL上绑定的语法变量,那么这些作者就不能依赖于该特性。

3.6 最后一课:解释一下

在撰写本文时,我们不得不重新审视许多概念和代码片段,这导致了许多错误的修复和 ASDF 和cl-launch的重构。通过谷歌 Hangout 进行的早期交互式“ASDF演练”也带来了增强。我们的经验说明了这样一个原则,即您应该始终解释您的程序:必须将概念用语言清楚地表达出来,这将使您更好地理解它们。

posted @ 2022-02-18 15:44  fmcdr  阅读(697)  评论(0编辑  收藏  举报