内容


元编程艺术,第 2 部分

用 Scheme 进行元编程

宏工具可以生成代码从而简化大型项目

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 元编程艺术,第 2 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:元编程艺术,第 2 部分

敬请期待该系列的后续内容。

元编程艺术,第 1 部分:元编程简介 中:

  • 我们研究了代码生成程序能够很好地解决哪些问题,包括:
    • 需要预先生成数据表的程序
    • 具有许多不能提取成函数的样板代码的程序
    • 使用了编写起来太复杂的技术的程序
  • 然后研究了几种元编程系统和使用它们的示例,包括:
    • 一般性文本替换系统
    • 领域特有的程序和函数生成器
  • 然后研究了一个特定的表构建实例
  • 然后编写了一个代码生成程序来用 C 构建静态表
  • 最后,我们介绍了 Scheme 以及如何使用 Scheme 语言本身的构造解决在 C 语言中面对的问题

本文详细介绍如何编写 Scheme 宏以及它们如何显著地简化大型编程任务。

用 Scheme 编写 syntax-case 宏

syntax-case 宏并不是 Scheme 的标准部分,但是它们是使用最广泛的宏类型,允许健康和非健康形式,与标准的 syntax-rules 宏密切相关。

syntax-case 宏采用清单 1 所示的形式:

清单 1. syntax-case 宏的一般形式
(define-syntax macro-name
   (lambda (x)
     (syntax-case x (other keywords go here if any)
       (
         ;;First Pattern
         (macro-name macro-arg1 macro-arg2)
         ;;Expansion of macro (one or multiple forms)
         ;;(syntax is a reserved word)
         (syntax (expansion of macro goes here))
       )
       (
         ;;Second Pattern -- a 1-argument version
         (macro-name macro-arg1)
         ;;Expansion of macro
         (syntax (expansion of macro goes here))
       )
 )))

这种形式将 macro-name 定义为用于进行转换的关键字。用 lambda 定义的函数由宏转换器用来将表达式转换为展开形式。

syntax-case 以表达式 x 作为第一个参数。第二个参数是关键字列表,这些关键字在语法模式中采用字面意义。模式中使用的其他标识符用作模板变量。然后,syntax-case 接受一系列模式/转换器组合。它依次通过每个组合进行处理,尝试将输入形式与模式进行匹配,如果匹配的话,它就产生相关联的展开形式。

我们来看一个简单的示例。假设我们想编写一个比 Scheme 提供的版本更详细的 if 语句版本。还假设我们想寻找两个变量中比较大的一个并返回它。代码如下:

(if (> a b) a b)

对于非 Scheme 程序员,没有明显的文字可以指出哪个是 “then” 分支,哪个是 “else” 分支。为了帮助他们理解代码,可以创建定制的 if 语句,添加 “then” 和 “else” 关键字。代码如下:

(my-if (> a b) then a else b)

清单 2 演示执行此操作的宏:

清单 2. 定义扩展的 if 语句的宏
;;define my-if as a macro
(define-syntax my-if
  (lambda (x)
    ;;establish that "then" and "else" are keywords
    (syntax-case x (then else)
      (
        ;;pattern to match
        (my-if condition then yes-result else no-result)
        ;;transformer
        (syntax (if condition yes-result no-result))
       )
)))

在这个宏执行时,它按照以下形式将 my-if 表达式与模板进行匹配(换句话说,将宏调用与宏定义模式进行匹配):

(my-if  (> a b)  then     a      else    b)
   |       |      |       |       |      |
   |       |      |       |       |      |
   v       v      v       v       v      v
(my-if condition then yes-result else no-result)

因此,在转换表达式中,任何出现 condition 的地方就替换为 (> a b)(> a b) 是否是一个列表并不重要。它是包含列表中的一个元素,所以它在模式中作为一个单元。产生的 syntax 表达式只是将每个部分重新安排成一个新的表达式。

这种转换发生在执行之前,这个时期称为宏展开时期(macro-expansion time)。在许多基于编译器的 Scheme 实现中,宏展开在编译时发生。这意味着宏只在程序开始时或编译时执行一次,以后不必再次执行。因此,我们的 my-if 语句没有运行时开销 —— 它在运行时转换为一个简单的 if

在下一个示例中,我们要执行 swap! 宏。这个简单的宏要交换两个标识符的值。清单 3 给出了使用这个宏的示例。

清单 3. 使用 swap! 宏交换标识符的值
(define a 1)
(define b 2)
(swap! a b)
(display "a is now ")(display a)(newline)
(display "b is now ")(display b)(newline)

这个简单的宏(清单 4)通过引入一个新的临时变量来实现交换:

清单 4. 定义 swap! 宏
;;Define a new macro
(define-syntax swap!
  (lambda (x)
    ;;we don't have any keywords this time
      (syntax-case x ()
        (
          (swap! a b)
          (syntax
            (let ((c a))
              (set! a b)
              (set! b c)))
        )
)))

这个宏引入了一个名为 c 的新变量。但是,如果要交换的参数之一正好也名为 c,那么会怎么样?

syntax-case 解决这个问题的办法是在宏展开时将 c 替换为一个惟一的未使用的变量名。因此,语法转换器会自己负责这个问题。

注意,syntax-case 没有替换 let。这是因为 let 是一个全局定义的标识符。

用不冲突的名称替换引入的变量名,这种方法称为健康的(hygiene);产生的宏称为健康的宏(hygienic macros)。健康的宏可以安全地在任何地方使用,不必担心与现有的变量名冲突。对于许多元编程任务,这个特性使宏更可预测并容易使用。

引入标识符

健康的宏在宏中安全地引入变量名,但是有时候也想让宏成为非健康的。例如,假设您希望创建一个宏,它将一个变量引入调用宏的人可以使用的范围中。这会是一个非健康的宏,因为这个宏会 “污染” 用户代码的名称空间。但是,在许多情况下,这种能力是有用的。

作为一个简单的示例,假设我们希望编写一个宏,它引入几个算术常量的定义,在宏中使用(是的,这可以用其他更好的方式实现,但是我只是以它为例)。假设希望使用清单 5 这样的宏调用定义 pie

清单 5. 一个算术常量宏的调用
(with-math-defines
	(* pi e))

如果我们试图像上面的宏这样进行设置,它会失败:

清单 6. 失败的算术常量宏
(define-syntax with-math-defines
  (lambda (x)
    (syntax-rules x ()
      (
        (with-math-defines expression)
        (syntax
          (let ( (pi 3.14) (e 2.71828) )
               expression))
      )
)))

这个公式不起作用。原因在于 Scheme 会对 pie 进行重命名,使它们不会与包含或嵌入宏的范围中的其他名称冲突。因此,它们会获得新名称,代码 (* pi e) 会引用未定义的变量。我们需要能够引入字面符号,可以供调用宏的开发人员使用。

为了在宏中引入不被 Scheme 的自动健康机制修改的代码,代码必须从符号列表转换为语法对象,然后可以将语法对象分配给一个模式变量,并插入转换后的表达式。为此,我们将使用 with-syntax,这本质上是用于宏的 “let” 语句。它具有同样的基本形式,但是用于将语法对象分配给模板变量。

为了能够创建新的模板变量,需要能够在列表表示(编写语法的方式)和更抽象的语法对象表示 之间对符号和表达式进行来回转换。以下函数进行这些转换:

  • datum->syntax-object 将列表转换为更抽象的语法对象表示。
    • 这个函数的第一个参数往往是 (syntax k),这是一个小公式,帮助语法转换器获得正确的上下文。
    • 第二个参数是需要转换为语法对象的表达式。
    • 结果是一个语法对象,可以使用 with-syntax 将这个对象分配给模板变量。
  • syntax-object->datum 执行与 datum->syntax-object 相反的过程。它接受一个语法对象并将其转换为表达式,可以使用一般的 Scheme 列表处理函数操作这个表达式。
  • syntax 接受一个转换表达式(由模板变量和常量表达式组成)并返回产生的语法对象。

对于这个例子,为了取得模板变量中的字面值,应该结合使用 syntaxsyntax-object->datum。然后就能够操作表达式并使用 datum->syntax-object 将它转换回语法对象,使用 with-syntax 可将这个对象分配给模板变量。然后,在最终的转换表达式中,就可以像其他任何模板变量一样使用这个新的模板变量。

实际的处理过程就是将 Scheme 语法转换为可以操作的列表,对列表进行操作,然后将它转换回输出的 Scheme 语法表达式。

清单 7 给出的宏定义使用这些函数定义了算术符号:

清单 7. 能够起作用的算术常量宏
(define-syntax with-math-defines
  (lambda (x)
    (syntax-case x ()
      (
        ;;Pattern
        (with-math-defines expression)
        ;;with-syntax defines new pattern variables
        (with-syntax
          (
            (expr ;;the new pattern variable
              ;;convert expression into a syntax object
              (datum->syntax-object
                ;;syntax domain magic
                (syntax k)
                ;;expression to convert
                `(let ( (pi 3.14) (e 2.72))
                      ;;Insert the code for the "expression" template
                      ;;variable here.
                      ,(syntax-object->datum (syntax expression))))))
          ;;Use the newly-created "expr" pattern
          ;;variable as the resulting expression
          (syntax expr))
      )
)))

反引用也称为准引用(quasiquote),它与引用操作符相似,但是如果数据前面有逗号(称为非引用操作符(unquote operator) ),就允许包含非引用数据。这使我们能够将表达式拼接到样板代码的位中,然后将产生的整个表达式转换为语法对象。

因为我们显式地将新变量拼接到现有的语法对象中,所以不会对它们进行重命名。另外注意,datum->syntax-object 中的 (syntax k) 表达式是必需的,但是意义不大。它用于在语法处理器中 “变点儿魔术”,让 datum->syntax-object 函数知道应该在什么上下文中处理表达式。它总是写成 (syntax k)

非健康宏的问题是,引入的变量和代码中的其他变量可能相互覆盖。这使得混合非健康宏特别危险,因为宏意识不到其他宏使用的变量,它们有可能破坏其他宏的变量。因此,应该只在用一般函数或健康宏无法实现所需效果时才使用非健康宏,在这种情况下,还应该在文档中详细记录宏引入的符号。

构建样板宏

大型应用程序中的许多代码是样板代码,样板代码编写起来很麻烦,而且如果样板代码中有 bug,那么很难找到每个使用样板的地方并重写代码。这意味着样板代码是适合使用非健康宏的少数几种情况之一。

很大一部分样板代码仅仅是设置将在函数中使用的变量,因此样板宏应该引入许多通用的绑定,并执行其他内务处理任务。

假设我们要构建一个 CGI 应用程序,它由许多独立的 CGI 脚本组成。在大多数 CGI 应用程序中,大部分状态存储在数据库中,只有会话 ID 通过 cookie 传递给每个脚本。

但是,在几乎每个页面中都需要知道其他标准信息(比如用户名、组号、当前作业以及相关的其他信息)。另外,如果用户没有适当的 cookie,就需要将用户转发到别处。清单 8 给出了一个标准样板(假想的 Web 服务器函数带有 webserver: 前缀):

清单 8. Web 应用程序的样板代码
(define (handle-cgi-request req)
  (let (
        (session-id (webserver:cookie req "sessionid")))
    (if (not (webserver:valid-session-id session-id))
        (webserver:redirect-to-login-page)
        (let (
              (username (webserver:username-for-session session-id))
              (group (webserver:group-for-user username))
              (current-job (webserver:current-job-for-user username)))
          ;;Code for processing goes here
          ))))

可以用一个过程处理这个样板的一部分功能,但是无法进行绑定。但是,可以将它转换为宏。这个宏可以用下面的代码实现:

清单 9. 样板代码的宏
(define-syntax cgi-boilerplate
  (lambda (x)
    (syntax-case x ()
      (
        (cgi-boilerplate expr)
        (datum->syntax-object
          (syntax k)
          `(let (
                 (session-id (webserver:cookie req "sessionid")))
                (if (not (webserver:valid-session-id session-id))
                    (webserver:redirect-to-login-page)
                    (let (
                          (username (webserver:username-for-session session-id))
                          (group (webserver:group-for-user username))
                          (current-job (webserver:current-job-for-user username)))
                        ,(syntax-object->datum (syntax expr))))))
      )
)))

现在,就可以使用以下代码根据样板代码创建新表单:

(define (handle-cgi-request req)
  (cgi-boilerplate
   (begin
     ;;Do whatever I want here
     )))

另外,因为没有显式地定义变量,在样板中添加新的变量定义不会影响它的调用约定,所以可以添加新的特性,而不必创建全新的函数。

在任何大型项目中,都有一些模板不能转换成函数,这常常是因为要创建绑定。使用样板宏可以使这种模板化代码的维护更容易。

同样,也可以创建其他标准宏来使用样板中定义的变量。使用这样的宏可以显著减少代码输入量,因为不必不断地编写和重新编写变量绑定、派生和参数传递。这还会减少代码中出现错误的可能性。

但是,要意识到样板宏并不是万能的。可能出现许多显著的问题,包括:

  • 如果引入的变量名正好是一个宏中以前定义过的,那么就会覆盖绑定。
  • 很难跟踪问题,因为宏的输入和输出是隐式的,不是显式的。

通过对样板宏采取一些措施,可以在很大程度上避免这些问题:

  • 为宏建立明确的命名约定,并指出来自样板代码的变量。可以将 -m 加到宏上,将 -b 加到样板中定义的变量上。
  • 仔细地在文档中记录所有样板宏,尤其是引入的变量绑定和版本之间的所有变化。
  • 样板代码可以减少重复劳动,同时隐式功能会带来负面影响,所以应该只在好处远远大于危险时使用样板代码。

用于领域特有语言的宏

在编程过程中,许多时候确实需要小型的领域特有语言。当今存在许多领域特有语言:

  • 配置文件
  • Web 标记语言,比如 HTML
  • 作业控制语言

这些语言不必是图灵完全的(如果它具有与通用图灵机等同的计算能力 —— 换句话说,系统和通用图灵机可以相互模拟)。它们之间的共同点是:它们都具有许多隐式假设和隐式状态,而在通用编程语言中必须显式地处理这些东西。Scheme 允许定义作为领域特有语言操作的宏,从而结合了通用语言和领域特有语言的优点。

作为第一个例子,我们来考虑一个安全配置文件,它在一个配置文件中详细描述不同的安全领域。存在几种不同的安全领域,每个领域都有不同的访问控制和限制。

许多系统已经提供了声明式安全配置。例如,J2EE 有一些声明式安全特性,比如:

清单 10. J2EE 中的声明式安全特性
<![CDATA[
<security-constraint>
  <web-resource-collection>
      <web-resource-name>Test Resource</web-resource-name>
      <description>This is an example Resource</description>
      <url-pattern>/Test</url-pattern>
  </web-resource-collection>
  <auth-constraint>
    <role-name>USERS</role-name>
  </auth-constraint>
</security-constraint>
]]>

在此代码中,根据用户的角色限制对特定 URL 的访问,并指出对未登录用户使用的身份验证机制。在 Scheme 中,可以用宏实现相似的控制方式。可以定义一个宏,它允许使用下面这样的代码(一个声明式安全 宏):

(resource "Test Resource" "This is an example resource" "/Test"
   (auth-constraints (role "USERS")))

清单 10 是前面的宏调用的宏定义(所有带 webserver: 前缀的函数是 Web 服务器提供的假想函数):

清单 11. 编写声明式安全宏
;;This macro creates expressions which check the validity
;;of the authentication credentials in the variable "credentials"
;;and reports and redirects unauthorized access.
(define-syntax auth-constraints
  (lambda (x)
    (syntax-case x (auth-constraints time role)
      (
        ;;This causes the constraints to be processed one at a
        ;;time within a (begin) clause.
        (auth-constraints constraint1 constraint2 ...)
        (syntax
          (begin
            (auth-constraints constraint1)
            (auth-constraints constraint2 ...)))
      )
      (
        ;;This gives the expansion for the role checking mechanism
        ;;(note that "credentials" is defined in the "resource" macro below)
        (auth-constraints (role rolename ...))
        (syntax
          (if
            (not
              (webserver:is-in-role-list credentials (list rolename ...)))
            (webserver:report-unauthorized)
            #f))
      )
      (
        ;;Allows a time-based checking
        (auth-constraints (time beginning ending))
        (syntax
          (let (
                (now (webserver:getunixtime)))
               (if
                 (or (< now beginning) (> now ending))
                 (webserver:report-unauthorized) #f)))
      )
      (
        ;;Unknown case -- assume it is code or is transformed by
        ;;another macro
        (auth-constraints unknown)
        (syntax unknown)
      )
)))
;;Each resource definition expands to a function to check
;;credentials.  It piggy-backs onto the macros defined above,
;;which make up the body of the credential-checking function.
;;This sets up the "credentials" parameter which is used in the
;;expressions above
(define-syntax resource
  (lambda (x)
    (syntax-case x ()
      (
        (resource name description url security-features)
        (with-syntax
          (
            ;;This builds the function to check security information
            (security-function
              (datum->syntax-object
                (syntax k)
                `(lambda (credentials)
                   ,@(syntax-object->daturm (syntax security-features))))
          (syntax
            (webserver:add-security-function
              name description url security-function)))))))

这些宏需要解释一下。首先,这里引入了一个新构造 ...。这种表示法意味着 “重复前面的内容”。它可以用于宏模式和展开式中。

resource 宏构建一个处理安全凭证的函数,然后将它作为参数传递给 webserver:add-security-function。它定义一个具有单一参数 credentials 的函数,供 auth-constraints 宏使用。

auth-constraints 宏比较复杂。它采用两种形式之一 —— 要么用单一约束进行处理,要么用一系列约束进行处理。宏的第一部分将约束列表分割成多个单一约束。使用 ... 表示相似形式可以连续出现。在宏展开之后,结果可以再次进行宏展开,直到无法再展开。我们可以利用这种特性。如果对 auth-constraints 进行迭代式展开,就会看到它确实被展开成一系列 auth-constraints 宏,然后使用余下的宏形式分别处理它们。

auth-constraints 包含两个额外特性,但是并没有在这个例子中使用。第一个特性是基于时间的授权机制,第二个特性是能够由其他宏和代码进行进一步扩展。基于时间的授权机制只是作为例子,说明如何在这种机制中添加多种约束;扩展选项将在以后的例子中使用。

这些宏将安全声明展开成清单 12 所示的内容:

清单 12. Scheme 安全声明的展开形式
(webserver:add-security-function
  "Test Resource"
  "This is an example resource"
  "/Test"
  (lambda (credentials)
    (begin
      (if (not (webserver:is-in-role-list credentials (list "USERS")))
        (webserver:report-unauthorized)
        #f))))

这就出现了两个问题:

  • 为什么要把它实现成宏?
  • Java 使用的 XML 声明有什么不好?

有两个原因使得宏更适合 XML 声明式安全文件这样的数据语言:

  • 声明式信息在编译时转换为命令形式,而不是在运行时每次使用时进行转换,这使代码更快。
  • 更重要的是,如果声明式语言的表达能力不足以满足需要,那么还可以在文件中包含命令式语句,这样就能够充分利用编程语言的表达能力

第一个特性是有意义的,但是第二个特性更重要。因为宏可以以任何方式展开常规代码,所以如果声明式语言不能满足需要,那么随时可以切换回命令式编程。实际上,如果对转换进行良好的文档记录,甚至可以在配置中混合声明式语句和命令式语句。

例如,假设希望针对外部恶意 IP 地址列表来检查用户来自的域。可以混合声明式和命令式安全特性来实现这种功能:

(resource "Test Resource" "This is an example resource" "/Test"
  (auth-constraints
    (role "USERS")
    (if (rogue-ip-list:contains (webserver:ip-address credentials))
      (webserver:report-unauthorized)
      #f)))

这在编程方面提供了很大的灵活性。可以使用领域特有的子语言进行声明式编程,如果这种子语言不能完全满足需要,那么仍然能够转回特性全面的编程语言。

结束语

元编程在大型计算机编程中有许多用途。在本文中,我讨论了在 Scheme 中进行元编程所需的工具,并提供了几个元编程示例。元编程技术已经应用于几个领域:

  • 使语法更好
  • 自动化样板生成
  • 编写声明式子程序

在 Scheme 中,可以使用宏设施定义几乎任何类型的领域特有语言。还有所需的工具。问题只是决定哪些特性使用宏展开(而不是常规代码)来实现会更容易、更清晰。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=102992
ArticleTitle=元编程艺术,第 2 部分: 用 Scheme 进行元编程
publish-date=03162006