这里对Section 5进行翻译。

课程主页:

https://www.coursera.org/learn/programming-languages-part-b/home

B站搬运:

https://www.bilibili.com/video/BV1tZ4y1D7

Coursera编程语言课程 第5节总结

标准说明:本总结涵盖的材料与课堂视频以及随视频发布的材料(幻灯片、代码)大致相同。它有助于以叙述的方式阅读材料,并将整个课程部分的材料放在一份文件中,特别是在以后复习材料时。请在讨论板上报告这些笔记中的错误。

目录

  • 从ML切换到Racket
  • Racket vs. Scheme
  • 入门:定义,函数,列表(和if
  • 语法和括号
  • 动态类型(和条件)
  • 本地绑定:let, let*, letrec, 本地define
  • 顶层定义
  • 绑定一般是可变的:set!存在
  • 关于cons的真相
  • cons是不可改变的,但有mcons
  • 延迟评估和形式转换简介
  • 带有延迟和强制的惰性评估
  • 记忆
  • 宏程序:关键点
  • 可选:tokenization化、括号化和范围
  • 可选:用define-syntar定义宏
  • 可选:变量、宏和hygiene
  • 可选:更多的宏例子

从ML切换到Racket

在编程语言的B部分,我们将使用Racket编程语言(而不是ML)和DrRacket编程环境(而不是SML/NJ和Emacs)。关于安装和基本使用说明的说明在课程网站上。

我们的重点仍将主要放在关键的编程语言结构上。我们将转换到Racket,因为其中的一些概念在Racket中更能体现出来。也就是说,Racket和ML有很多相似之处。它们都是主要的函数式语言(即存在突变,但不鼓励突变),有闭包、匿名函数、对列表的方便支持、无返回语句等。在第二种语言中看到这些特征应该有助于重新加强基本的想法。一个明显的区别是,我们不会在Racket中使用模式匹配。

对我们来说,Racket和ML之间最重要的区别是:

  • Racket不使用静态类型系统。因此,它可以接受更多的程序,程序员也不需要一直定义新的类型,但大多数错误在运行时才会发生。
  • Racket有一个非常简约和统一的语法。

Racket有很多先进的语言特性,包括宏、与ML截然不同的模块系统、引用/评估、头等函数、contract等等。我们将有时间只介绍其中的几个主题。

前面的几个主题涵盖了基本的Racket编程,因为在我们开始使用Racket来研究更高级的概念之前,我们需要介绍Racket。我们将迅速完成这一工作,因为

Racket vs. Scheme

Racket源于Scheme,这是一种著名的编程语言,从1975年就开始发展了。(Racket的设计者在2010年决定对Scheme进行足够的修改和补充,因此给这个结果起一个新的名字比把它当作Scheme的方言更有意义。两种语言仍然非常相似,只有一小部分关键差异(空列表是如何写的,由cons构建的对是否是可变的,模块是如何工作的),一个较长的小差异列表,以及一个较长的Racket提供的附加功能列表。

总的来说,Racket是一种正在积极开发的现代语言,它已经被用来构建几个”真实”(不管是什么意思)的系统。与Scheme相比,它的改进使它成为本课程和现实世界编程的好选择。然而,它更像是一个”移动的目标”——设计者在努力使语言和配套的DrRacket系统变得更好时,没有受到历史先例的约束。因此,课程材料中的细节更有可能变得过时。

入门:定义,函数,列表(和if

一个Racket文件(也是一个Racket模块)的第一行应该是

#lang racket

这在课程的安装/使用说明中有所讨论。这些讲义将转而关注这一行之后的文件内容。一个Racket文件包含了一系列的表示法。

一个像

(define a 3)

这样的定义扩展了顶层环境,使a被绑定到3。Racket对变量名称中可能出现的字符有非常宽松的规定,一个常见的惯例是用连字符来分隔像my-favorite-identifier这样的词。

然后如下定义

(define b (+ a 2))

b绑定到5。一般来说,如果我们有(define x e),其中x是一个变量,e是一个表达式,我们将e评估为一个值,并改变环境,使x被绑定到该值。除了语法之外,这应该是非常熟悉的,尽管在讲座的最后我们会讨论到,与ML不同的是,绑定可以引用文件中后来的绑定。此外,在Racket中,一切都用前缀表达式表示,比如上面使用的加法函数。

一个需要一个参数的匿名函数被写成(lambda (x) e),其中参数是变量x,主体是表达式e。因此,如下方式将cube1与立方函数绑定:

(define cube1 (lambda (x) (* x (* x))))

在Racket中,不同的函数确实需要不同数量的参数,调用参数数量错误的函数是运行时错误。一个三个参数的函数看起来像(lambda (x y z) e)。然而,许多函数可以接受任何数量的参数。乘法函数,*,就是其中之一,所以我们可以写成:

(define cube2 (lambda (x) (* x x)) 

你可以查阅Racket文档来学习如何定义你自己的可变参数函数。

与ML不同,你可以在匿名函数中使用递归,因为命名本身就在函数主体的范围内:

(define pow
  (lambda (x y)
    (if (= y 0)
        1
        (* x (pow x (- y 1))))))

上面的例子还使用了一个if表达式,它的一般语法形式是(if e1 e2 e3)。它对e1进行评估。如果结果是#f(Racket的常数,表示false),它就评估e3的结果。如果结果是其他任何东西,包括#t(Racket的常数,代表true),它就会评估e2的结果。请注意,这比ML中要灵活得多。

有一种非常常见的语法糖,你应该在定义函数中使用,它没有明确使用lambda这个词:

(define (cube3 x)
  (* x x x))

(define (pow x y)
    (if (= y 0)
        1
    (* x (pow x (- y 1)))))

这更像是ML的fun绑定,但在ML中fun不仅仅是语法糖,因为它是递归所必需的。

我们可以在Racket中使用currying。毕竟,Racket的头等函数和ML中一样都是闭包的,currying只是一种编程习惯:

(define pow
  (lambda (x)
    (lambda (y)
      (if (= y 0)
          1
          (* x ((pow x) (- y 1)))))))

(define three-to-the (pow 3))
(define eightyone (three-to-the 4))
(define sixteen ((pow 2) 4))

因为Racket的多参数函数确实是多参数函数(不是语法糖),所以currying并不常见。 调用curried函数没有语法糖:我们必须写((pow 2) 4),因为(pow 2 4)用两个参数调用与pow绑定的单参数函数,这是一个运行时错误。Racket添加了语法来定义curried函数。 我们可以这样写:

(define ((pow x) y)
   (if (= y 0)
       1
   (* x ((pow x) (- y 1)))))

这是一个相当新的功能,可能还没有被广泛了解。

Racket有内置的列表,和ML很像,而且Racket程序在实践中可能比ML程序更经常地使用列表。我们将使用内置函数来构建列表,提取部分内容,并查看列表是否为空。函数名称carcdr是一个历史性的意外。

原语 描述 例子
null 空列表 null
cons 构建一个列表 (cons 2 (cons 3 null))
car 获取列表的第一个元素 (car some-list)
cdr 获取列表的尾部 (cdr some-list)
null? 如果是空列表则返回#t,否则返回#f (null? some-value)

与Scheme不同,你不能用()表示空列表。你可以用'(),但我们更喜欢null

还有一个内置的函数list用于从任何数量的元素建立一个列表,所以你可以写(list 2 3 4)而不是(cons 2 (cons 3 (cons 4 null))。列表不需要持有相同类型的元素,所以你可以创建(list #t "hi" 14)而不出错。

这里有三个列表处理函数的例子。mapappend实际上是默认提供的,所以我们不会自己实现:

(define (sum xs)
  (if (null? xs)
      0
      (+ (car xs) (sum (cdr xs)))))
      
(define (append xs ys)
  (if (null? xs)
      ys
      (cons (car xs) (append (cdr xs) ys))))
      
(define (map f xs)
  (if (null? xs)
      null
      (cons (f (car xs)) (map f (cdr xs)))))

语法和括号

忽略一些小细节,Racket的语法简单得惊人。语言中的一切都是:

  • 某种形式的原语,如#t, #f, 34, "hi", null等等。一个特别重要的原语是变量(比如x或者something-like-this!),也可以是一个特殊的形式,比如define, lambda, if,还有更多。
  • 括号中的序列(t1 t2 ... tn)

序列中的第一件事会影响到序列中其他部分的含义。例如,(define ...)意味着我们有一个定义,接下来的东西可以是要被定义的变量,或者是一个用于函数定义的序列。

如果一个序列中的第一项不是一个特殊的形式,并且这个序列是表达式的一部分,那么我们就有一个函数调用。Racket中的许多东西都是函数,比如+>

顺便提一下,Racket也允许在任何地方用[]来代替()。作为一个风格问题,我们将展示几个地方,其中[...]是常见的首选选项。Racket不允许不匹配的括号形式,(必须和)匹配, [必须和]匹配。DrRacket让这个问题变得简单,因为如果你输入)来匹配[,它就会输入]来代替。

通过”括号化一切”,Racket有一个毫不含糊的语法。对于1+2*31+(2*3)还是(1+2)*3f x y(f x) y还是f (x y),从来没有任何规则需要学习。这使得解析,即把程序文本转换为代表程序结构的树,变得微不足道。请注意,基于XML的语言如HTML也采取了同样的方法。在HTML中,一个”左括号”看起来像<foo>,而与之匹配的右括号看起来像</foo>

出于某种原因,HTML很少被批评为充斥着小括号,但这是对LISP、Scheme和Racket的常见抱怨。如果你在街上拦住一个程序员,问他/她关于这些语言的情况,他们很可能会说一些关于”所有这些括号”的话。这是一种奇怪的迷恋:使用这些语言的人很快就习惯了,而且统一的语法让人感到愉快。例如,它使编辑器很容易正确缩进你的代码。

从学习编程语言和基本编程结构的角度来看,你应该认识到对小括号的强烈意见(无论是赞成还是反对)是一种语法偏见。虽然每个人都有权对句法有自己的看法,但不应该让它妨碍你学习Racket做得很好的先进理念,比如hygienic宏或动态类型语言中的抽象数据类型或头等延续。一个类比是,如果一个欧洲历史的学生不想了解法国大革命,只因为他或她不喜欢有法国口音的人。

综上所述,在Racket中进行实际编程的确需要你正确地使用括号,而且Racket在一个重要方面与ML、Java、C等不同:小括号会改变你程序的含义。你不能因为你喜欢而添加或删除它们。它们绝不是可有可无或毫无意义的。

在表达式中,(e)意味着对e进行评估,然后用0个参数调用结果函数。所以(42)将是个运行时错误:你把数字42当作一个函数。同样,((+20 22))也是一个错误,原因也是如此。

刚接触 Racket 的程序员有时很难记住括号很重要并确定程序失败的原因,通常是在运行时,当它们被错误括号时。作为一个例子,考虑一下这七个定义。第一个是阶乘的正确实现,其他都是错误的:

(define (fact n) (if (= n 0) 1 (* n (fact (- n 1))))) ; 1
(define (fact n) (if (= n 0) (1) (* n (fact (- n 1))))) ; 2
(define (fact n) (if = n 0 1 (* n (fact (- n 1))))) ; 3
(define fact (n) (if (= n 0) 1 (* n (fact (- n 1))))) ; 4
(define (fact n) (if (= n 0) 1 (* n fact (- n 1)))) ; 5
(define (fact n) (if (= n 0) 1 (* n ((fact) (- n 1))))) ; 6
(define (fact n) (if (= n 0) 1 (n * (fact (- n 1))))) ; 7

说明:

错误
2 将1作为一个没有参数的函数来调用
3 使用带有5个子表达式的if,而不是3个
4 错误的表示语法:(n)看起来像一个表达式,后面有更多的东西
5 将函数作为*的某个参数
6 fact调用0个参数
7 n作为函数,用其调用*

动态类型(和cond

Racket不使用静态类型系统来拒绝程序的运行。作为一个极端的例子,函数(lambda () (1 2))是一个完美的零参数函数,如果你调用它,会导致错误。我们将在以后的讲座中花大量的时间来比较动态和静态类型以及它们的相对好处,但现在我们要习惯于动态类型。

举个例子,假设我们想有数字列表,但其中的一些元素实际上可以是其他列表,而这些列表本身又包含数字或其他列表,等等,可以有很多层次。Racket允许直接这样做,例如,(list 2 (list 4 5) (list (list 1 2) (list 6)) 19 (list 14 0))。在ML中,这样的表达式不会进行类型检查;我们需要创建我们自己的数据类型绑定,并在正确的地方使用正确的构造函数。

现在在Racket中,假设我们想在这样的列表上计算什么。这也是没有问题的。例如,我们在这里设计了一个函数来对这种数据结构中的所有数字进行求和:

(define (sum xs)
  (if (null? xs)
      0
      (if (number? (car xs))
          (+ (car xs) (sum (cdr xs)))
          (+ (sum (car xs)) (sum (cdr xs))))))

这段代码简单地使用了空列表(null?)和数字(number?)的内置谓词。最后一行假设(car xs)是一个列表;如果不是,那么这个函数就被误用了,我们会得到一个运行时错误。

我们现在岔开话题,介绍一下cond的特殊形式,这种形式对于嵌套条件来说,比使用多个if表达式的风格更好。我们可以将前面的函数改写为:

(define (sum xs)
  (cond [(null? xs) 0]
        [(number? (car xs)) (+ (car xs) (sum (cdr xs)))]
        [#t (+ (sum (car xs)) (sum (cdr xs)))]))

一个cond只是有任意数量的括号内的表达式对,[e1 e2]。第一个表达式是一个测试;如果它的值是#f,我们就跳到下一个分支。否则我们评估e2,而这就是答案。作为一个风格问题,你的最后一个分支应该有测试#t,这样你就不会”跌入谷底”,在这种情况下,结果是某种你不想处理的void对象。

if一样,测试的结果不一定是#t#f。除了#f以外的任何东西都会被解释为#t。利用这个特性有时是不好的风格,但它可能是有用的。

现在让我们把动态类型再向前推进一步,改变我们的sum函数的规范。假设我们甚至想在我们的列表中允许非数字和非列表,在这种情况下,我们只想通过在sum中添加0来忽略这些元素。如果这是你想要的(也可能不是你想要的),那么我们可以在Racket中这样做。如下这段代码将永远不会引发错误:

(define (sum xs)
  (cond [(null? xs) 0]
        [(number? xs) xs]
        [(list? xs) (+ (sum (car xs)) (sum (cdr xs)))]
        [#t 0]))

本地绑定:let, let*, letrec, 局部define

出于所有常见的原因,我们需要能够在函数中定义局部变量。像ML一样,我们可以在任何地方使用表达式来做这件事。与ML不同的是,本地绑定的结构形式不是一种,而是四种。这种多样性是好的。不同的表达式在不同的情况下都很方便,使用最自然的表达式可以向阅读你的代码的人传达一些有用的信息,即局部绑定是如何相互关联的。这种多样性也会帮助我们学习范围和环境,而不是仅仅接受只能有一种语义的let表达式。变量如何在环境中被查找是编程语言的一个基本特征。

首先,有这样一种表达方式

(let ([x1 e1]
      [x2 e2] 
      ...
      [xn en])
  e) 

正如你所期望的,这创建了局部变量x1, x2, ... xn,与评估e1, e2, ..., en的结果绑定,然后主体e可以使用这些变量(即它们在环境中),e的结果就是整体结果。从语法上看,注意到在绑定的集合周围的/额外的”小括号”,以及我们使用方括号的地方的常见风格。

但是上面的描述遗漏了一件事。我们用什么环境来评估e1, e2, ..., en?事实证明,我们使用来自let表达式之前的环境。也就是说,后来的变量在其环境中没有先前的变量。如果e3使用x1x2,这要么是一个错误,要么是指一些同名的外部变量。这不是ML let表达式的工作方式。作为一个愚蠢的例子,这个函数将其参数翻倍:

(define (silly-double x)
  (let ([x (+ x 3)]
        [y (+ x 2)])
     (+ x y -5)))

这种行为有时是有用的。例如,为了在一些局部范围内交换xy的含义,你可以写(let ([x y][y x]) ...)。更多的时候,我们使用let,这种语义与”每个绑定在其环境中都有之前的绑定”相比并不重要:它传达了表达式是相互独立的。

如果我们用let*来代替let,那么语义上就会在由先前的表达式产生的环境中评估每个绑定的表达式。这就是ML的let表达式的工作方式。这通常是很方便的。如果我们只有常规let,我们将不得不把let表达式嵌套在彼此之间,这样以后的每个绑定都在外层let表达式的主体中。(我们将使用嵌套的let表达式,每个表达式有一个绑定,而不是一个有n个绑定的let)。下面是一个使用`let`的例子:

(define (silly-double x)
  (let* ([x (+ x 3)]
         [y (+ x 2)])
        (+ x y -8)))

如上所述,当语义上的差异无关紧要时,使用let而不是let*是常见的风格。

无论是let还是let*都不允许递归,因为e1, e2, ..., en不能指代被否定的绑定或任何后来的绑定。为了做到这一点,我们有第三个变体letrec,它可以让我们这样写:

(define (triple x)
  (letrec ([y (+ x 2)]
           [f (lambda (z) (+ z y w x))]
           [w (+ x 7)])
    (f -9)))

我们通常使用letrec来定义一个或多个(相互)递归的函数,比如这个非常慢,对非负数mod 2的方法:

(define (mod2 x)
  (letrec
      ([even? (lambda (x) (if (zero? x) #t (odd? (- x 1))))]
       [odd? (lambda (x) (if (zero? x) #f (even? (- x 1))))])
    (if (even? x) 0 1)))

另外,你可以通过使用局部定义来获得与letrec相同的行为,这在真正的Racket代码中非常常见,事实上是比let-表达式更受欢迎的风格。在本课程中,如果你愿意,你可以使用它,但不是必须的。对局部定义出现的位置有一些限制;在函数体的开头是一个允许出现的常见位置。

(define (mod2_b x)
  (define even? (lambda(x) (if (zero? x) #t (odd? (- x 1)))))
  (define odd? (lambda(x) (if (zero? x) #f (even? (- x 1)))))
  (if (even? x) 0 1))

我们需要谨慎对待letrec和局部定义。它们允许代码引用以后被初始化的变量,但每个绑定的表达式仍然按顺序被评估。

对于相互递归的函数,这从来不是一个问题:在上面的例子中,在上面的例子中,even?的定义引用了odd?的定义,尽管与odd?绑定的表达式还没有被评估。这没有问题,因为在even?中的使用是在一个函数体中,所以它要在odd?被初始化之后才会被使用。与此相反,letrec的这种使用方式是不好的:

(define (bad-letrec x)
  (letrec ([y z]
           [z 13])
     (if x y z)))

letrec的语义要求使用z来初始化y,但是z的表达式(13)还没有被评估。在这种情况下,当调用bad-letrec时,Racket将引发一个错误。(在Racket 6.1版之前,它会将y绑定到一个特殊的”未定义”对象上,这几乎总是有隐藏错误的效果)。

对于这门课,你可以决定是否使用局部定义。讲座材料一般不会使用,而是选择let, let*letrec中最方便、沟通效果最好的一种。但是欢迎你使用局部定义,它们“彼此相邻”的行为类似于letrec绑定。

顶层定义

Racket文件是一个有一系列定义的模块。就像let表达式一样,什么环境用于什么定义对语义学来说非常重要。在ML中,文件就像一个隐含的let*。在Racket中,它基本上就像一个隐含的letrec。这很方便,因为它可以让你在一个模块中随意排列你的函数。例如,你不需要把相互递归的函数放在一起或者使用特殊的语法。另一方面,有一些新的”问题”需要注意:

  • 你不能让两个绑定使用同一个变量。这毫无意义:对变量的使用会使用哪一个?在类似letrec的语义下,如果一个变量在同一个相互递归的绑定集合中被定义,我们就不会让它shadow另一个。
  • 如果一个较早的绑定使用了一个较晚的绑定,它需要在一个函数体中这样做,以便较晚的绑定在使用时被初始化。在Racket中,使用未初始化的值的情况会在使用模块时引起错误(例如,当你在DrRacket中点击le的”Run”时)。
  • 所以在一个模块/文件中,没有顶层的影子(你仍然可以在定义或let表达式中shadow),但一个模块可以shadow另一个文件中的绑定,比如Racket的标准库中隐含的文件。例如,虽然这样做的风格不好,但我们可以用我们自己的函数来shadow内置的list函数。我们自己的函数甚至可以是递归的,像其他递归函数一样调用自己。然而,在REPL中的行为是不同的,所以不要在REPL中用自己的递归函数定义shadow一个函数。在定义窗口中删除递归函数,并在REPL中使用它,仍可按预期工作。

绑定通常是可变的:set!存在

虽然Racket鼓励函数式编程风格,鼓励自由使用闭包和避免副作用,但事实是有赋值语句。如果x在你的环境中,那么(set! x 13)将改变绑定,使x现在映射到13的值。这样做会影响到所有在其环境中有这个x的代码。set!读作”set-bang”,感叹号是一种惯例,用来提醒读者你的代码正在发生副作用,可能会影响其他代码。下面是一个例子:

(define b 3)
(define f (lambda (x) (* 1 (+ x b))))
(define c (+ b 4))
(set! b 5)
(define z (f 4))
(define w c)

在评估这个程序后,z被绑定到9,因为绑定在f上的函数的主体在评估时,会查找b并发现5。然而,w被绑定到7上,因为当我们评估(define c (+ b 4))时,我们发现b是3,像往常一样,结果是将c绑定在7上,而不管我们是如何得到7的。所以当我们评估(define w c)时,我们得到7;b的变化并不重要。

你也可以对局部变量使用set!,同样的推理也适用:你必须考虑到你什么时候查询一个变量来决定你得到什么值。但习惯了带有赋值语句的语言的程序员都习惯了。

改变顶层绑定尤其令人担忧,因为我们可能不知道所有使用该符号的代码。例如,我们上面的函数f使用了b,如果b被突变为一个意想不到的值,它可能会表现得很奇怪,甚至是神秘地失败。如果f需要抵御这种可能性,它就需要在b可能发生变化后避免使用b。在软件开发中,有一种通用的技术你应该知道:如果某样东西可能被突变,而你又需要旧的值,那么在变异发生之前做一个拷贝。在Racket中,我们可以很容易地编写如下代码:

(define f
   (let ([b b])
      (lambda (x) (* 1 (+ x b)))))

这段代码使函数体中的b指的是一个本地b,它被初始化为全局b

但这是我们需要的防御性吗?因为*+只是绑定在函数上的变量,我们可能也想防御它们在以后被改变:

(define f
   (let ([b b]
         [+ +]
         [* *])
      (lambda (x) (* 1 (+ x b)))))

如果f使用了其他辅助函数,事情会变得更糟。对绑定在函数上的变量进行本地拷贝是不够的,除非这些函数也对其所有的辅助函数进行拷贝。

幸运的是,在Racket中,由于一个合理的妥协,这些都是不必要的:一个顶层的绑定是不可变的,除非定义它的模块包含了一个set!。因此,如果包含(define b 4)的文件没有一个改变它的set!,那么我们可以放心,其他的文件将不被允许在这个绑定上使用set!(它将导致一个错误)。而所有像+*这样的预定义函数都在一个不对它们使用set!的模块中,所以它们也不能被改变。(在Scheme中,所有顶层绑定都是可变的,但程序员通常只是假设它们不会被改变,因为假设可能会改变太痛苦了)。

所以前面的讨论并不会影响到你的大部分Racket编程,但是对于理解set!的含义以及如何通过复制来防御突变是很有用的。关键是,Racket经常避免突变的可能性,这使得编写正确的代码变得非常困难。

关于cons的真相

到目前为止,我们已经用cons, null, car, cdrnull?来创建和访问列表。例如,(cons 14 (cons #t null))产生了一个列表'(14 #t),其中的引号字符表明这是打印一个列表值,而不是表示对14的(错误的)函数调用。

但事实上,cons只是生成一个pair,你用car得到第一部分,用cdr得到第二部分。这样的pair在Racket等语言中通常被称为cons单元。所以我们可以写(cons (+ 7 7) #t)来产生pair '(14 . #t),其中的句号表明这不是一个列表。根据惯例和预定义的list?函数,一个列表要么是null,要么是一个pair,其中cdr(即第二个组件)是一个列表。一个不是列表的cons单元通常被称为不适当的列表,特别是如果它在第二位置有嵌套的cons单元,例如,(cons 1 (cons 2 (cons 3 4))),其结果打印为'(1 2 3 . 4)

大多数列表函数,如length,如果传递给一个不恰当的列表,会产生一个运行时错误。另一方面,内置的pair? 对任何用cons构建的列表都返回true,即除了空列表以外的任何不正确或正确的列表。

不适当的列表有什么用?真正的问题是,pair是构建每个类型的一种普遍有用的方式,即具有多个部分的东西。在一个动态类型的语言中,你所需要的列表就是pair和一些识别列表结束的方法,按照惯例,Racket使用null常量(打印为'())来识别。作为一个风格问题,你应该使用适当的列表,而不是不适当的列表,因为集合可能有任何数量的元素。

Cons单元是不可变的,但是有mcons

Cons单元是不可变的。当你创建一个cons单元时,它的两个字段被初始化,永远不会改变。(这是Racket和Scheme的主要区别。)因此我们可以继续享受cons单元不能被我们程序中的其他代码所改变的好处。它还有一个有点微妙的优势。Racket的实现可以很聪明地使列表成为一个恒定时间的操作,因为它可以在每个cons单元被创建时存储它是否是一个合适的列表。如果cons单元是可变的,这就不可行了,因为在列表中的一个突变可能会把它变成一个不恰当的列表。

要意识到cons单元真的是不可变的,尽管我们已经使用了set!,是一个有点微妙的问题。考虑一下这段代码:

(define x (cons 14 null))
(define y x)
(set! x (cons 42 null))
(define fourteen (car y))

set!改变了x的绑定内容,成为一个不同的pair;它没有改变x所指的旧对的内容。你可以尝试做一些类似(set! (car x) 27),但这是一个语法错误:set!需要一个变量来赋值,而不是其他类型的位置。

如果我们想要可变pair,Racket很乐意用一组不同的原语来满足我们的要求:

  • mcons产生一个可变pair
  • mcar返回一个可变pair的第一个分量
  • mcdr返回一个可变pair的第二个分量
  • mpair?如果给定一个可变对,则返回#t
  • set-mcar!接收一个可变对和一个表达式,并将第一个分量改变为表达式的结果
  • set-mcdr!接收一个可变对和一个表达式,并将第二个分量改变为表达式的结果

由于我们接下来要研究的一些强大的习语使用突变来存储先前的计算结果,我们会发现可变pair很有用。

延迟评估和thunks简介

语言结构的一个关键语义问题是它的子表达式何时被评估。例如,在Racket中(在ML和大多数但不是所有的编程语言中也是如此),给定(e1, e2 ... en),我们在执行函数主体之前对函数参数e2, ..., en进行一次评估,给定函数(lambda (...) ...),我们在函数被调用之前不会评估主体。我们可以将这一规则(提前评估参数)与(if e1 e2 e3)的工作方式进行对比:我们不会同时评估e2e3。这就是为什么:

(define (my-if-bad x y z) (if x y z))

是一个不能在任何使用if表达式的地方使用的函数;评估子表达式的规则从根本上是不同的。例如,这个函数永远不会终止,因为每次调用都是递归调用

(define (factorial-wrong x) (my-if-bad (= x 0) 1 (* x (factorial-wrong (- x 1))))))

然而,我们可以利用函数体在被调用之前不被评估这一事实,制作一个更有用的”if函数”版本:

(define (my-if x y z) (if x (y) (z))) 

现在我们可以在写(if e1 e2 e3)的地方改写(my-if e1 (lambda () e2) (lambda () e3))my-if的主体要么调用与y绑定的零参数函数,要么调用与z绑定的零参数函数。

(define (factorial x) (my-if (= x 0) (lambda () 1) (lambda () (* x (factorial (- x 1))))))) 

虽然肯定没有理由以这种方式包装Racket的”if”,但使用零参数函数来延迟求值(现在不评估表达式,在零参数函数被调用时再评估)的一般习语是非常强大的。作为方便的术语/行话,当我们使用一个零参数函数来延迟求值时,我们把这个函数称为thunk。你甚至可以说,”thunk参数”意味着”使用(lambda () e)代替e“。

使用thunks是一个强大的编程习语。它不是特定于Racket,我们在ML中也可以学习这种编程。

带有延迟和强制的惰性评估

假设我们有一个大型的计算,我们知道如何执行,但我们不知道是否需要执行它。程序的其他部分知道哪里需要计算结果,可能有0、1或更多不同的地方。如果我们做了thunk,那么我们可能会多次重复这个大的计算。但是,如果我们不做thunk,那么即使我们不需要,我们也会进行大型计算。为了获得两全其美的效果,我们可以使用一个编程习语,它有几个不同的名字(也许技术上略有不同):惰性评估、按需调用、承诺(promise)。这个想法是利用突变来记住我们第一次使用thunk时的结果,这样我们就不需要再使用thunk了。

在Racket中的一个简单实现是:

(define (my-delay f)
  (mcons #f f))
  
(define (my-force th)
  (if (mcar th)
      (mcdr th)
      (begin (set-mcar! th #t)
             (set-mcdr! th ((mcdr th)))
             (mcdr th))))

我们可以创建一个thunk f并将其传递给my-delay。这将返回一个pair,其中第一个字段表示我们还没有使用这个thunk。然后是my-force,如果它看到thunk还没有被使用,就会使用它,使用突变来改变这个pair,以保持使用thunk的结果。这样一来,以后用同一pair调用my-force就不会重复计算了。具有讽刺意味的是,虽然我们在实现中使用了突变,但如果传递给my-delay的thunk有副作用或者依赖于可变数据,那么这样就很容易出错,因为这些副作用最多出现一次,而且可能很难确定第一次调用my-force的时间。

考虑一下这个愚蠢的例子,我们想用一个递归算法将两个表达式e1e2的结果相乘(当然,你实际上只需要使用*,如果e1产生一个负数,这个算法就不起作用):

(define (my-mult x y)
  (cond [(= x 0) 0]
        [(= x 1) y]
        [#t (+ y (my-mult (- x 1) y))]))

现在调用(my-mult e1 e2)e1e2各评估一次,然后做0次或更多次的加法。但是如果e1的值是0,而e2的计算时间很长呢?那么评估e2就是浪费了。所以我们可以考虑使用如下方法:

(define (my-mult x y-thunk)
  (cond [(= x 0) 0]
        [(= x 1) (y-thunk)]
        [#t (+ (y-thunk) (my-mult (- x 1) y-thunk))]))

现在我们将调用(my-mult e1 (lambda () e2))。如果e1的值为0,这很好;如果e1的值为1,这很好用;如果e1的值为一个很大数,这很可怕。毕竟,现在我们在每次递归调用时都要评估e2。所以让我们使用my-delaymy-force来获得更好的效果:

(my-mult e1 (let ([x (my-delay (lambda () e2)) ]) (lambda () (my-force x))))) 

注意,我们在调用my-mult之前创建了一次延迟计算,然后在第一次调用传递给my-mult的thunk时,my-force将评估e2并记住结果,以便以后调用my-force x。另一种可能看起来更简单的方法是重写my-mult,期望从my-delay获得结果,而不是一个任意的thunk:

(define (my-mult x y-promise)
 (cond [(= x 0) 0]
       [(= x 1) (my-force y-promise)]
       [#t (+ (my-force y-promise) (my-mult (- x 1) y-promise))]))
       
(my-mult e1 (my-delay (lambda () e2)))

有些语言,最明显的是Haskell,对所有的函数调用都使用这种方法,也就是说,在这些语言中,函数调用的语义是不同的。如果一个参数从未被使用过,它就不会被评估,否则就只评估一次。这被称为按需调用,而我们将使用的所有语言都是按值调用(参数在调用前被完全评估)。

一个流是一个值的无限序列。我们显然不能明确地创建这样一个序列(这要花很长时间),但我们可以创建知道如何产生无限序列的代码,以及知道如何请求所需序列的代码。

流在计算机科学中是非常常见的。你可以把一个同步电路产生的比特序列看作是一个流,每个时钟周期有一个值。电路不知道它应该运行多长时间,但它可以永远产生新的值。UNIX管道(cmd1 | cmd2)也是流;它使cmd1产生与cmd2需要的输入一样多的输出。对用户在网页上点击的东西做出反应的网络程序可以把用户的活动当作一个流,即不知道下一个东西何时到来,也不知道有多少个,但可以随时做出适当的反应。更广泛地说,流可以是一种方便的分工:软件的一个部分知道如何在先天的序列中产生连续的值,但不知道需要多少个和/或如何处理它们。另一部分可以确定需要多少个,但不知道如何生成它们。

有很多方法可以对流进行编码;我们将采取简单的方法,将流表示为一个thunk,当被调用时产生pair,内容为

  • (1)序列中的第一个元素和
  • (2)一个表示第二至第无穷个元素的流的thunk

编写这样的thunks通常使用递归法。这里有三个例子:

(define ones (lambda () (cons 1 ones)))

(define nats
  (letrec ([f (lambda (x) (cons x (lambda () (f (+ x 1)))))])
    (lambda () (f 1))))

(define powers-of-two
  (letrec ([f (lambda (x) (cons x (lambda () (f (* x 2)))))])
    (lambda () (f 2))))

给定这种流的编码和流s,我们将通过(car (s))得到第一个元素,通过(car ((cdr (s))))))得到第二个元素,通过(car ((cdr (cdr (s))))))))得到第三个元素,等等。请记住括号的作用:(e)调用thunk e

我们可以写一个高阶函数,接收一个流和一个预测函数,并返回在预测函数返回真之前产生的流元素的数量:

(define (number-until stream tester)
  (letrec ([f (lambda (stream ans)
                (let ([pr (stream)])
                  (if (tester (car pr))
                      ans
                      (f (cdr pr) (+ ans 1)))))])
    (f stream 1)))

作为一个例子,(number-until powers-of-two (lambda (x) (= x 16)))的评估结果是4。

顺便说一句,给定前一个元素,上面所有的流都最多只能产生它们的下一个元素。因此,我们可以使用高阶函数来抽象出这些函数的共同点,这让我们把流的创建逻辑放在一个地方,而把特定流的细节放在另一个地方。这只是使用高阶函数来重用通用功能的另一个例子:

(define (stream-maker fn arg)
  (letrec ([f (lambda (x) 
                (cons x (lambda () (f (fn x arg)))))])
    (lambda () (f arg))))
(define ones (stream-maker (lambda (x y) 1) 1))
(define nats (stream-maker + 1))
(define powers-of-two (stream-maker * 2))

记忆化

一个与懒惰评估相关的、实际上并不使用thunks的系语是记忆化。如果一个函数没有副作用,那么如果我们用相同的参数多次调用它,我们实际上不需要多次调用。相反,我们可以查找第一次调用该函数时的答案是什么。

这是否是一个好主意,取决于权衡利弊。将旧的答案保存在一个表中需要空间,而且查表也需要一些时间,但与重新进行昂贵的计算相比,这可能是一个很大的胜利。同样,要使这种技术正确,需要给定相同的参数,函数将总是返回相同的结果,并且没有副作用。因此,能够使用这个备忘表(即做记忆化)是避免突变的另一个优势。

为了实现记忆化,我们确实使用了突变。每当函数被调用时有一个我们以前没见过的参数,我们就计算答案,然后把结果加到表中(通过突变)。

作为一个例子,让我们考虑一个函数的3个版本,它接收x并返回fibonacci(x)。(斐波那契数是一个著名的定义,在人口建模等方面很有用)。一个简单的递归定义是:

(define (fibonacci x)
  (if (or (= x 1) (= x 2))
      1
      (+ (fibonacci (- x 1))
         (fibonacci (- x 2)))))

不幸的是,这个函数需要指数级的时间来运行。我们可能开始注意到(fibonaccci 30)的停顿,而(fibonacci 40)需要比这长一千倍的时间,而(fibonacci 10000)需要的时间比宇宙中的粒子还多。现在,我们可以通过采取记住以前答案的方法来解决这个问题:

(define (fibonacci x)
  (letrec ([f (lambda (acc1 acc2 y)
                (if (= y x)
                    (+ acc1 acc2)
                    (f (+ acc1 acc2) acc1 (+ y 1))))])
    (if (or (= x 1) (= x 2))
        1
        (f 1 1 3))))

这需要线性时间,所以(fibonacci 10000)几乎立即返回(而且是一个非常大的数字),但它需要一个相当不同的方法来解决问题。通过记忆化,我们可以用一种适用于很多算法的技术将斐波那契变成一种古老的算法。它与”动态规划”密切相关,你经常在高级算法课程中了解到这一点。下面是进行这种记忆化的版本(下面介绍assoc库函数):

(define fibonacci
  (letrec([memo null] ; list of pairs (arg . result) 
          [f (lambda (x)
               (let ([ans (assoc x memo)])
                 (if ans 
                     (cdr ans)
                     (let ([new-ans (if (or (= x 1) (= x 2))
                                        1
                                        (+ (f (- x 1))
                                           (f (- x 2))))])
                       (begin 
                         (set! memo (cons (cons x new-ans) memo))
                         new-ans)))))])
    f))

f的不同调用必须使用同一个可变的备忘表:如果我们在对f的调用中创建这个表,那么每次调用都会使用一个新的空表,这是毫无意义的。但我们并没有把表放在顶层,因为这将是不好的风格,因为它的存在应该只被fibonacci的实现所知道。

为什么这种技术能使(fibonacci 10000)迅速完成?因为当我们在任何递归调用中评估(f (- x 2))时,结果已经在表中了,所以不再有指数级的递归调用。这比第二次调用(fibonacci 10000)会更快地完成要重要得多(因为答案会在备忘表中)。

对于一个大表来说,使用列表和Racket的assoc函数可能是一个糟糕的选择,但它对于演示备忘录的概念是正确的。assoc只是Racket的一个库函数,它接收值和列表的pair,并返回列表中第一个pair的car等于值的对。(assoc之所以返回pair而不是pair的cdr,是为了区分没有pair的情况和pair的cdr里有#f的情况。这就是我们在ML中使用选项的那种情况)。

宏:关键点

本模块的最后一个主题是宏,它通过让程序员使用自己的语法糖来增加语言的语法。为了给其他主题留出时间,大部分的宏材料都是可有可无的,但我们还是鼓励你去学习。本节包含了一些非选择性的关键观点。虽然本模块的家庭作业不需要用到宏,但我们在下一模块的学习中需要用到这个概念。

宏的定义在语言中引入了一些新的语法。它描述了如何将新的语法转化为语言本身的不同语法。宏系统是一种用于创建宏的语言(或更大语言的一部分)。宏的使用是指使用先前指定的一个宏。宏使用的语义是用宏定义所表示的适当的语法来替换宏使用。这个过程通常被称为宏扩展,因为语法转换产生更大的代码量是常见的,但不是必须的。

关键的一点是,宏扩展发生在我们所学的其他任何东西之前:在类型检查之前,在编译之前,在评估之前。把”扩展所有的宏 “看作是在其他事情发生之前对整个程序的预处理。因此,宏在任何地方都会被展开,例如在函数体中,条件的两个分支中,等等。

下面是Racket中可能出现的3个宏的例子:

  • 程序员可以写(my-if e1 then e2 else e3),其中my-if, then, else是关键字,这个宏可以扩展为(if e1 e2 e3)
  • 程序员可以写(comment-out e1 e2),并让它转换为e2,也就是说,这是一个方便的方法,可以把表达式e1从程序中取出(用e2代替),而不需要实际删除任何东西。
  • 程序员可以写(my-delay e)并让它转换为(mcons #f (lambda () e))。 这与我们前面提到的my-delay函数不同,因为该函数需要调用者传入一个thunk。在这里,宏的扩展完成了thunk的创建,宏的用户不应该包括一个明确的thunk。

Racket有一个优秀而复杂的宏系统。由于精确的技术原因,它的宏系统优于许多著名的宏系统,特别是C或C++的预处理器。所以我们可以用Racket来学习一般的宏的一些陷阱。本模块的其余部分(可选)将讨论:

  • 宏系统必须如何处理tokenization、括号化和范围化的问题,以及Racket如何比C/C++更好地处理括号化和范围化。
  • 如何在Racket中定义宏,比如上面描述的那些宏
  • 宏的命名应该如何注意表达式的评估顺序以及评估的次数
  • 宏中的变量绑定的关键问题和hygiene的概念

可选:符号化(tokenization)、括号化和范围化

宏和宏扩展的表示法比在文本编辑器中或在你手动编写的脚本中执行一些字符串替换时的”find-and-replace”更有条理,更微妙。宏的扩展大致有三个方面的不同。

首先,考虑一个宏,用car取代每个head的用法。在宏系统中,这并不意味着变量headt会被改写成cart。因此,宏的实现至少要理解编程语言的文本是如何被分解成tokens(即词)的。Token的概念在不同的语言中是不一样的。例如,a-b在大多数语言中是三个token(一个变量、一个减号和另一个变量),但在Racket中是一个标记。

其次,我们可以询问宏是否理解括号。例如,在C/C++中,如果你有一个宏

#define ADD(x,y) x+y 

那么ADD(1,2/3)*4就会被改写成1+2/3*4,这与(1+2/3)*4不是一回事。所以在这样的语言中,宏的编写者一般会在宏的名称中加入很多明确的括号,例如,

#define ADD(x,y) ((x)+(y)) 

在Racket中,宏扩展保留了代码结构,所以这个问题不存在。Racket的宏使用看起来总是像(x ...),其中x是宏的名字,扩展的结果“保持在同一个括号里”(例如,(my-if x then y else z)可能扩展成(if x y z))。这是Racket最小和一致的语法的一个优点。

第三,我们可以问,即使在创建变量绑定的时候,宏扩展是否也会发生。如果不是,那么局部变量就可以shadow宏,这可能是你想要的。例如,假设我们有

(let ([hd 0] [car 1]) hd) ; evaluates to 0
(let* ([hd 0] [car 1]) hd) ; evaluates to 0

如果我们用hd代替car,那么第一个表达式是一个错误(试图绑定hd两次),第二个表达式现在求值为1。 在Racket中,宏扩展不适用于变量名称,也就是说,上面的car是不同的,并且会shadow任何刚好在范围内的car的宏。

可选:用define-syntax定义宏

现在让我们来看看我们将在Racket中使用的定义宏的语法。(多年来,Racket的前身Scheme有很多变化;这是我们将使用的一种现代方法)。下面是一个宏,它允许用户为任何表达式e1, e2, e3使用(my-if e1 then e2 else e3),并让它准确地表示(if e1 e2 e3)

(define-syntax my-if
  (syntax-rules (then else)
    [(my-if e1 then e2 else e3)
     (if e1 e2 e3)]))
  • define-syntax是指定一个宏的特殊形式。
  • my-if是我们的宏的名称。它将my-if添加到环境中,这样,形式为(my-if ...)的表达式将根据宏定义的其余部分的语法规则进行宏扩展。
  • syntax-rules是一个关键字。
  • 下一个括号内的列表(在这里是(then else))是这个宏的关键字列表,也就是说,在my-if的主体中使用的任何thenelse都只是语法,而不在这个列表中的任何东西(不包括my-if本身)都代表一个任意表达式。
  • 其余的是一个pair列表:my-if可能被如何使用,以及如果它被这样使用,应该如何改写。
  • 在这个例子中,我们的列表只有一个选项:my-if必须被用于一个形式为(my-if e1 then e2 else e3)的表达式中,这就变成了(if e1 e2 e3)。否则就会出现错误。注意重写发生在对表达式e1, e2, e3的任何求值之前,这与函数不同。这就是我们对像my-if这样的条件表达式的要求。

下面是第二个简单的例子,我们使用宏来”注释”一个表达式。我们使用(comment-out e1 e2)将其改写为e2,这意味着e1将永远不会被评估。这在调试代码时可能比实际使用注释更方便:

(define-syntax comment-out
  (syntax-rules ()
    [(comment-out ignore instead) instead]))

我们的第三个例子是宏my-delay,这样,与前面提到的my-delay函数不同,用户会写(my-delay e)来创建一个promise,这样my-force会评估e并记住结果,而不是用户写(my-delay (lambda () e))。这只是宏,而不是函数,可以像这样”通过添加thunk来延迟评估”,因为函数调用总是评估其参数:

(define-syntax my-delay
  (syntax-rules ()
    [(my-delay e)
     (mcons #f (lambda () e))]))

我们不应该创建一个my-force的宏版本,因为我们前面的函数版本正是我们想要的。使用(my-force e),我们确实想把e评估为值,这应该是由my-delay创建的cons-cell,然后在my-force函数中执行计算。定义宏不会带来任何好处,而且容易出错。考虑一下这个糟糕的尝试:

(define-syntax my-force
  (syntax-rules ()
     [(my-force e)
      (if (mcar e)
          (mcdr e)
          (begin (set-mcar! e #t)
                 (set-mcdr! e ((mcdr e)))
                 (mcdr e)))]))

由于宏扩展,这个宏的使用最终会对其参数进行多次评估,如果e有副作用,就会产生奇怪的行为。宏的使用者不会想到这一点。在这样的代码中:

(let ([t (my-delay some-complicated-expression)])
  (my-force t))

这无关紧要,因为t已经绑定到一个值,但是在如下代码中:

(my-force (begin (print "hi") (my-delay some-complicated-expression)))

我们最终要打印多次。请记住,宏的扩展是将整个参数e复制到宏定义中出现的任何地方,但我们经常希望它只被评估一次。这个版本的宏在这方面做得很好:

(define-syntax my-force-macro
  (syntax-rules ()
    [(my-force e)
     (let ([x e])
       (if (mcar x)
           (mcdr x)
           (begin (set-mcar! x #t)
                  (set-mcdr! x ((mcdr x)))
                  (mcdr x))))]))

但是,还是那句话,没有理由使用这样的宏,因为一个函数正好可以做我们需要的事情。只要坚持使用:

(define (my-force th)
  (if (mcar th)
      (mcdr th)
      (begin (set-mcar! th #t)
             (set-mcdr! th ((mcdr th)))
             (mcdr th))))

可选:变量、宏和hygiene

让我们考虑一个将其参数加倍的宏。请注意,这是一个很差的风格,因为如果你想把一个参数加倍,你应该直接写一个函数:(define (double x) (* 2 x))(define (double x) (+ x x)),这两者是等价的。但这个简短的例子将让我们研究宏参数何时被评估以及在什么环境下被评估,所以我们将把它作为一个差劲的例子。

这两个宏是不等价的:

(define-syntax double1
  (syntax-rules ()
         [(double1 e)
          (* 2 e)]))
          
(define-syntax double2
  (syntax-rules ()
    [(double2 e)
     (+ e e)]))

原因是double2将评估其参数两次。所以(double1 (begin (print "hi") 17))会打印”hi“一次,但(double2 (begin (print "hi") 17))会打印”hi“两次。函数版本打印”hi“一次,只是因为像往常一样,函数参数在调用函数之前被评估为数值。

为了在不改变算法的情况下修复double2,使其成为乘法而不是加法,我们应该使用一个局部变量。

(define-syntax double3
  (syntax-rules ()
    [(double3 e)
     (let ([x e])
       (+ x x))]))

在宏定义中使用局部变量来控制表达式是否/何时被评估,这正是你应该做的,但在功能较少的宏语言中(同样,C/C++是一个容易被嘲笑的目标),宏中的局部变量通常被避免。其原因与范围和所谓的hygiene有关。为了举例说明,考虑一下double3的这个愚蠢的变体:

(define-syntax double4
  (syntax-rules ()
    [(double4 e)
     (let* ([zero 0]
            [x e])
       (+ x x zero))]))

在Racket中,这个宏总是按预期工作,但这可能/应该让你感到惊讶。毕竟,假设我有这样的用法:

(let ([zero 17])
   (double4 zero))

如果你按照预期进行句法重写,你最终会得到:

(let ([zero 17])
  (let* ([zero 0]
         [x zero])
       (+ x x zero)))

但是这个表达式的值是0,而不是34。问题是在宏使用处的一个自由变量((double4 zero)中的zero)最终进入了宏定义中的一个局部变量的范围。这就是为什么在C/C++中,宏定义中的局部变量往往有一些有趣的名字,比如__x_hopefully_no_conflict,希望这种事情不会发生。在Racket中,宏扩展的规则更加复杂,以避免这种问题。基本上,每次使用宏时,它的所有局部变量都会被改写成新的变量名,并且不与程序中的其他内容相冲突。这就是Racket宏hygienic的一半原因。

另一半与宏定义中的自由变量有关,并确保它们不会错误地出现在使用宏的某个局部变量的范围内。例如,考虑这个使用double3的奇怪代码:

(let ([+ *])
   (double3 17))

朴素的重写会产生:

(let ([+ *])
  (let ([x 17])
     (+ 17 17)))

然而这产生了$17^2$,而不是34。同样,朴素的重写并不是Racket所做的。宏定义中的自由变量总是指宏被定义的环境中的东西,而不是宏被使用的地方。这使得编写总是按预期工作的宏容易得多。同样,C/C++中的宏的工作方式与朴素的重写一样。

在有些情况下,你不需要hygiene。例如,假设你想要一个用于for-loop的宏,宏用户指定一个变量来保存循环索引,而宏生成器则确保该变量在每个循环迭代中保持正确的值。Racket的宏系统有一种方法可以做到这一点,这涉及到明确地违反hygiene规定,但我们不会在这里演示。

可选:更多的宏例子

最后,让我们考虑一些更有用的宏表示法,包括那些使用多种情况来进行重写的宏。首先,这里有一个宏,可以让你使用let*语义写出最多两个let绑定,但括号较少:

(define-syntax let2
  (syntax-rules ()
    [(let2 () body)
     body]
    [(let2 (var val) body)
     (let ([var val]) body)]
    [(let2 (var1 val1 var2 val2) body)
     (let ([var1 val1])
       (let ([var2 val2])
         body))]))

举例来说,(let2 () 4)求值为4,(let2 (x 5) (+ x 4)求值为9,(let2 (x 5 y 6) (+ x y))求值为11。

事实上,鉴于对递归宏的支持,我们可以完全用let来重新定义Racket的let*。我们需要一些方法来谈论”语法列表的其余部分”,Racket的...给了我们这个方法。

(define-syntax my-let*
  (syntax-rules ()
    [(my-let* () body)
     body]
    [(my-let* ([var0 val0]
               [var-rest val-rest] ...)
              body)
     (let ([var0 val0])
       (my-let* ([var-rest val-rest] ...)
                body))]))

由于宏是递归的,没有什么可以阻止你在宏扩展过程中,即在代码运行之前,产生一个固有的循环或固有的语法量。上面的例子没有这样做,因为它在一个较短的绑定列表上递归。

最后,这里是一个有限形式的for-loop的宏,它执行其主体$hi - lo$次。(它是有限的,因为主体没有给出当前的迭代次数。)注意使用let表达式来确保我们精确地评估lohi一次,但我们评估body的次数是正确的。

(define-syntax for
  (syntax-rules (to do)
    [(for lo to hi do body)
     (let ([l lo]
           [h hi])
       (letrec ([loop (lambda (it)
                        (if (> it h)
                            #t
                            (begin body (loop (+ it 1)))))])
         (loop l)))]))