设计和实现领域特定语言
在《星际迷航 5:最终边疆》中,斯科蒂提醒一位初级工程师要“为正确的工作使用正确的工具”。这句至理名言同样适用于计算机用户和星舰工程师。GNU/Linux 用户拥有特别令人印象深刻的工具组合,其中许多工具都具有独特的语法,有助于简洁地表达复杂的操作。
好的工具将反映它们旨在解决的特定问题的具体需求。考虑一下高效的文本处理实用程序 awk 和 sed。通过简单的命令,用户可以对流执行高效的搜索和替换操作,或过滤复杂的数据。用 C 代码完成相同的事情需要多少代码?即使使用像 Python 这样简洁的通用语言,这些任务仍然需要比等效的命令行工具更多的输入。像 awk 和 sed 这样的实用程序之所以有效,是因为它们与其他命令行实用程序接口良好,并且它们利用了领域特定语言 (DSL) 的强大功能,这些语法专门用于一组特定的相关任务。
尽管为 GNU/Linux 开发了大量强大的应用程序,但对于任何给定的工作,合适的工具并不总是可用。当选择有限时,有资源的用户应该怎么做?在大多数情况下,可以组合现有工具,可能在此过程中创建新工具。有时,需要使用通用编程语言从头开始制作新工具。开发人员可以通过为其实现领域特定语言来增加新工具的价值并提高其生产潜力。
开发时间是一种投资,许多程序员努力通过编写可重用的代码库来最大化这种投资的回报。使用专用代码库开发的工具通常只公开库功能的有限子集。开发人员可以通过构建可以用作接口的领域特定语言来提供对库功能的更广泛访问。一个构建良好的 DSL 允许用户使用直观且自我记录的语法快速构建大量高度专业的工具。
DSL 的实现可能是一项棘手的事情。解析和验证专门语法的代码很难编写和维护,特别是当 DSL 支持复杂的控制结构时。使用 DSL 编写的工具出了名的难以调试,并且除非您自己创建一个,否则您的新语言将没有可用的 IDE。
反对企业使用 DSL 最有力的论据之一是所谓的巴别塔效应。当许多开发人员都构建自己独立的 DSL 时,大量不同的语法会造成极大的混乱。
当开发人员不断增加 DSL 目标领域的范围时,他们可能会面临专业化不足的风险。当目标领域增长到难以管理的程度时,DSL 将转变为个人 Perl 实现,并且它将不再充分满足与实际领域相关的各个任务的需求。
元编程是编写生成或操作代码的代码的艺术。它是语言实现的基础,并且有很多方法可以做到这一点。元编程可以是静态的或动态的,具体取决于实现语言的类型系统。静态元编程通常使用预处理器完成,动态元编程通常使用在运行时评估的宏完成。
许多优秀的开源语言开发平台可用于 GNU/Linux。最令人印象深刻的静态元编程实用程序之一是 Camlp4,它是 Inria 的 Ocaml 编程语言的可扩展预处理器。Camlp4 有助于快速开发高效、类型安全的 DSL。在可用的动态元编程平台中,最好的是 Logix,这是一个为 Python 实现和使用 Python 实现的极其通用的语言设计系统。
LiveLogix 是一家咨询和开发公司,拥有宏伟的计划和创新的想法。Logix 在 GPL 下可用,是他们的第一个主要版本,也是他们的 LiveLogix 应用程序平台(一套多功能和动态的开发工具,目前处于开发的早期阶段)的先锋。Logix 的灵感来自 Python 的动态性、Haskell 的语法优雅性和 Lisp 的可变性,是功能和灵活性的独特融合。
Logix 开发人员不构建完整的形式语法,他们逐步定义构成语言的各个运算符。然后可以将这些运算符组合起来形成表达式,Logix 处理器可以解析这些表达式并将其转换为 Python 字节码。Logix DSL 可以选择利用强大的 Python 语言功能,如控制结构、面向对象和列表处理。与 Python 的无缝集成以及访问 Python 可用的海量实用库和模块进一步提高了 Logix 的功能和价值。
Logix 开发人员使用标准或基本 Logix 方言构建他们的程序。基本方言的语法类似于普通的 Python 语法,但增加了一些用于语言扩展的功能。标准方言包括各种出色的语法增强和独特功能。
经验丰富的 Python 开发人员可以快速适应标准 Logix 习惯用法。文档包含针对 Python 程序员的出色介绍,其中全面探讨了语法差异。许多实质性差异与 Logix 对表达式的特殊处理有关。所有语句都返回值,因此可以编写如下代码
x = if 10 * 2 == 20: "yes it is!" else: "no"
函数调用写成一系列表达式
min 2 6
在标准方言中,圆括号区分单个表达式,就像在代数中一样。圆括号不是实际调用命名法的一部分。标准 Logix 表达式
min 2 6 (min 10 15)
与 Python 表达式相同
min(2, 6, min(10, 15))
不需要参数的函数是例外。它们仍然像在 Python 中一样使用尾随圆括号调用
function()
语言扩展使用 defop 语句编写。可以添加新的前缀、后缀和中缀运算符,以及特殊的 mixfix 运算符,如 C 的条件表达式。也可以添加新的关键字。
运算符定义由结合性规范、绑定值、运算符语法和实现组成。结合性用单个字母指定,l 表示左结合,r 表示右结合。如果未指定结合性,Logix 会自动使新运算符成为左结合。绑定值指定运算符优先级。绑定值语法是我真正不喜欢 Logix 的少数几件事之一。即使在具有静态语法的语言中,运算符优先级也往往让我感到困惑。在结构动态语言中,跟踪运算符优先级要困难得多。幸运的是,优先级和结合性在简单的工具语言中不会那么重要。
运算符语法由变量和常量组成。常量用引号括起来,变量按类型指定:expr(表达式)、符号、术语、标记、块和 freetext。运算符实现可以是宏或函数。函数在运行时评估,而宏在编译时执行代码替换。
让我们看一下 Logix 文档中的一个示例
defop 50 expr "isa" expr func ob typ: isinstance ob typ
这描述了一个 isa 运算符。新的 isa 运算符由一个表达式组成,后跟常量 isa,后跟一个表达式。func 关键字指示实现是一个函数,后面的两个符号是变量的名称。实现中的每个变量都与语法定义中的一个变量相关联。在本例中,第一个 expr 是 ob,第二个 expr 是 typ。当调用运算符时,将评估 func 块中的代码。
在以下代码行中
"test" isa str
现在我们已经了解了基础知识,让我们尝试一个更复杂的示例。想象一家公司拥有一支名副其实的网络打印机舰队,这些打印机具有可通过 telnet 访问的管理界面。这家假设的公司维护着一个文本文件,其中记录了所有打印机的当前配置。当有人想要更改特定打印机的配置时,他们会在文本文档中记录更改,然后连接到打印机并进行更改。该公司可以设计一个简单的 DSL,将配置记录视为程序。因此,当有人想要更改打印机的配置时,他们只需更改文档并运行它即可。运行时,文本文档将连接到所有打印机并重新填充配置数据。
首先,让我们看一下文档
default: syslog_facility:local3 idle_timeout:120 old_idle_mode:off accounting printers: - 10 hp5mo1 syslog_facility:local2 - 28 lpt9 - 29 lpt10 - 48 lpt6 developer printers: - 26 lpt4 - 27 lpt7 marketing printers: - 62 hpcolor5: old_idle_mode:on - 154 lpt11 for department in [accounting, developer, marketing]: for printer in department: print ("Configuring %s..."%printer.host) printer.transmit() print "Finished!"
在设计自己的 DSL 时,您必须考虑所选语法的含义。如果您想添加更多功能,您能做到吗?经验不足的 DSL 开发人员垄断了常用的元字符,以使语法尽可能简洁。从长远来看,这使得它更难学习、更难使用和更难扩展。
默认块包含将在所有打印机上设置的默认配置选项。每个 printers 块都包含单个部门中所有打印机的描述。每个单独的打印机定义都包含打印机的 IP 地址的末尾和关联的主机名。打印机定义可以选择后跟一个块,其中包含特定于该打印机的配置选项。我们的 DSL 将每个 printer 块转换为 Printer 对象列表,并将该列表分配给一个变量,该变量的名称为部门名称。然后可以使用标准 Logix 方言编写的代码来操作这些列表。
现在,让我们看一下实现
setlang logix.stdlang from telnetlib import Telnet class TelnetDebug: def write self txt: print "dbg:%s"%txt class Printer: def __init__ self ip host data: self.ip = ip self.host = host self.data = Printer.default.copy() self.data.update data def transmit self: #tn = Telnet "192.168.0.%s"%self.ip tn = TelnetDebug() tn.write "printer_password" tn.write ("host %s"%self.host) for x,y in self.data.items(): tn.write ("%s %s"%(x,y)) deflang printerdef: defop 50 expr ":" expr macro n v: str n, str v defop 0 "-" token expr [":" block]/- macro ip v *b: ["host":str v, "ip":str ip, "block":b] deflang printlang(logix.stdlang): defop 0 expr "printers:" block@printerdef macro n *v: `\n = [\@.Printer p/ip p/host (dict p/block) for p in \v] defop 0 "default:" block@printerdef macro *b: `\@.Printer.default = dict \b
该实现以 setlang 指令开始,该指令告诉解释器使用标准 Logix 方言。接下来,我们定义 Printer 类。在 printers 块中定义的每个打印机最终都会成为 Printer 类的一个实例。Printer 类不包含特定于 DSL 的代码,并且可以轻松地在另一个项目中使用。Printer 初始化方法接受三个参数:打印机 IP 地址的最后一部分、打印机主机名以及将选项名称与选项值关联的 dict。init 方法还将默认打印机选项从类变量复制到名为 data 的实例变量中,并使用通过 data 参数传递到实例中的打印机特定选项对其进行更新。
现在我们进入了精彩的部分,语言定义。在 Logix 中,deflang 语句用于启动新的语言块。每个语言块都包含一系列运算符定义。第一个语言块描述了我们将在单个 printers 块和默认块中使用的语法。printerdef 语言的第一个运算符是冒号,这是一个中缀运算符,用于解析单个选项。第一个 expr 是选项名称,第二个 expr 是选项值。冒号运算符实现是一个宏,它将表达式转换为字符串并将它们放在一个元组中。
printerdef 语言中的第二个运算符是连字符运算符,这是一个 mixfix 运算符,用于定义单个打印机。这个有点复杂。运算符以文字连字符开头,后跟变量标记、表达式和可选块。标记是单个值,在本例中是一个数字。块,顾名思义,是使用 Python 的缩进规则解析的内容块。
在定义中,文字冒号和块用大括号括起来,后跟 /-。大括号将语法元素分组,组后面的 /- 表示它是可选的。这使得可以省略不需要指定自己的配置选项的打印机的块。实现是一个宏,它接受三个参数。标记是 IP 地址后缀,expr 是打印机主机名,块包含打印机选项。b 前面的星号表示该变量是一个序列。如果您未指定块变量是一个序列,则无法解析具有多行的块。该实现返回一个 dict,其中包含主机名、IP 后缀和块。该块包含选项,这些选项被转换为元组,因此在实现中,b 变量是元组序列。
实现中的第二种语言包含我们 DSL 的主要语法。在语言名称之后,您可以看到对标准 Logix 方言的引用,用括号括起来。像类一样,Logix 语言支持继承。括号内的 stdlang 引用表明我们的 printlang 继承了 stdlang 的所有运算符。开发人员现在可以使用标准 Logix 语法以及 printlang 中定义的专用运算符。这就是打印机配置程序末尾的 for 循环成为可能的原因。
printers 运算符以表达式开头,后跟文字printers然后是一个块。在此定义中,块紧随其后的是@printerdef,它告诉解释器应该使用 printerdef 语言解析块的内容。printers 实现是一个带有两个运算符的宏:组的名称和块,块是包含打印机定义的 dict 序列。
实现宏开头的反引号用它们的值替换转义变量,并将表达式转换为代码数据。我们希望能够创建一个使用用户提供的名称的变量。例如,我们希望将第一个 printers 块的值分配给变量 accounting。如果实现没有被引用,它将尝试将值分配给变量 n,而不是创建一个使用值提供的名称的新变量。引用就像 Python 的 exec 函数
n = 'test'
就像引用的内容。
在 Logix 中,正斜杠表示转义变量。转义变量将替换为它们的值,就像 %s 在示例 exec 表达式中被 n 的值替换一样。转义的 @ 表示当前模块,因此\@.Printer是对 Printer 类的引用。列表推导式为每个打印机定义构建一个 Printer 实例。Logix 为字典访问提供特殊语法
some_dict/key
转换为
some_dict["key"]
因此,解释器从 Printer 定义 dict 中获取 IP、主机和选项块,并将它们作为参数传递给 Printer 构造函数。
default 运算符获取其块并将其分配给默认 Printer 类变量。
这就是全部内容。现在您可以为任何工作构建合适的工具了!有了强大的语言开发平台在您的指挥下,唯一的限制就是您的想象力。
Logix 的这种诱人味道是否引起了您的兴趣?我请 Logix 创建者 Tom Locke 阐明 Logix 的未来。我们很快就可以看到更快、更有效的 Logix。下一个版本将具有高效的新解析器,完全用 C 编写。最终,Tom 计划将 Logix 移植到更合适的语言平台,如 Mono。他想要一个通用的运行时引擎,该引擎强调安全性并提供各种功能丰富的库。
Logix 目前在 GPL 下可用。未来的版本还将提供限制性较小的许可证,这将使开发人员能够在源代码和二进制形式中分发原始作品和修改作品。
本文资源: /article/8209。
Ryan Paul 是一名系统管理员、自由撰稿人,也是开源技术的忠实拥护者。他欢迎您提出问题和意见。可以通过 segphault@sbcglobal.net 联系 Ryan。