Lisp的方言Scheme的方言Racket学习——安装汉化与使用

发布于:2025-04-03 ⋅ 阅读:(12) ⋅ 点赞:(0)

Racket安装与汉化

Racket可以从Racket download下载,也可以使用网上提供的在线编辑器。
在这里插入图片描述
安装基本是下一步,下一步就可以了。双击文件夹中的DrRacket.exe程序,即可打开Racket。
修改界面显示语言的选项在帮助菜单下。

在这里插入图片描述

例子

#lang racket/gui
 
(define my-language 'English)
 
(define translations
  #hash([Chinese . "你好 世界"]
        [English . "Hello world"]
        [French . "Bonjour le monde"]
        [German . "Hallo Welt"]
        [Greek . "Γειά σου, κόσμε"]
        [Portuguese . "Olá mundo"]
        [Spanish . "Hola mundo"]
        [Thai . "สวัสดีชาวโลก"]
        [Turkish . "Merhaba Dünya"]))
 
(define my-hello-world
  (hash-ref translations my-language
            "hello world"))
 
(message-box "" my-hello-world)

点击右上角的绿色三角按钮,运行这个例子,会弹出一个Hello world的GUI对话框。
在这里插入图片描述

Lisp 教程

Lisp 介绍

Lisp 是世界上最古老的编程语言之一 。(准确而言,是第二古老的语言;只有 FORTRAN 比它老。)但是,与 FORTRAN、C 等编程语言不同,Lisp 不是由标准规定的一种语言,而是一大堆语言:它们长得很像,都称呼自己为“Lisp 方言”。

我们要学习的,正是这些方言中的一种——“MIT Scheme”。MIT Scheme 简称 Scheme,截止笔者撰文一共有三个标准:R5RS、R6RS 和 R7RS small。Scheme 除了这些标准之外,又衍生出一些方言,比如 GNU Guile 和 Racket。其中 Racket 发展很快、文档全面、社区也很活跃,所以我建议先 安装一个 Racket 解释器 边学边试。

TIP

因为 Racket 是 Scheme 的方言,R5RS 是 Scheme 的最普遍标准(Racket 也兼容 R5RS),而 Scheme 又是 Lisp 的方言,所以后文提到 Racket、Scheme、R5RS、Lisp 时,可以假设指代的都是同一个东西。

安装好 Racket 的话,直接在命令行界面键入 racket 命令来启动解释器。你可以输入一些数,它会原模原样地打印出来:

> 42  ; 你的输入
42    ; 解释器的输出

但是你如果输入 (+ 1 2),它输出的就是 3

> (+ 1 2)
3

于是你就知道了,(+ a b) 可以计算整数 a b 的和。类似地,也有 (- ...) (* ...)(/ ...)

> (- 6 10)
-4
> (* 3 4)
12
> (/ 5 2)
5/2        ; Racket 支持有理数运算,以分数的形式给出答案

这种将运算符写在所有操作数开头的写法称为前缀表达式。你可以自己猜一猜试一试,如何计算 4 × (2 + 3)

第二课:组合式

叮叮咚!计算 4 × (2 + 3) 的写法是这样的:

> (* 4 (+ 2 3))
20

你猜对了吗?

Lisp 中,表达式的范围用小括号 ( ) 界定。而 Lisp 总是使用前缀表达式,所以表达式 (+ 2 3) 的意思就是计算 2 + 3 了。此外,表达式可以嵌套,就像刚刚所展示的那样:(* 4 (+ 2 3)),外侧的 (* ...) 表达式嵌套了一个子表达式 (+ 2 3)。只要你愿意,你完全可以嵌套很多很多层:

(+ (* 3 (+ (* 2 4) (+ 3 5))) (+ (- 10 7) 6))

Lisp 对空格不敏感。所以可以写成下面这种美观形式:

(+ (* 3
      (+ (* 2 4)
         (+ 3 5)))
   (+ (- 10 7)
      6))

可以看出,Lisp 不需要考虑运算符优先级(即“先乘除、后加减”等规定),所有运算符都是通过括号来界定范围并组合在一起的。Lisp 称这种表达式为 组合式。组合式中包含一系列有序元素;最左侧的元素称为 运算符,后续的元素称为 运算对象 或者 实参

组合式的写法可以很轻松地支持带有多个实参的运算。比如计算 1 + 3 + 5 可以写成:

> (+ 1 3 5)
9

Scheme 中,减法和除法也允许只带一个实参;计算的分别是负数和倒数。

> (- 4)
-4
> (/ 5)
1/5

很简单,对吧?你可以自己试试多个实参的乘法,猜一下它的含义。接下来的难度开始升级了!

第三课:命名

说到编程语言,就不得不提到 变量。在 C/C++ 中,我们说变量是给内存起名字;在 Lisp 里也很类似,变量是给值起名字

Scheme 使用这种写法给值 2 起一个叫做 size 的名字:

(define size 2)

这样写下之后,解释器就认为名字 size 关联到了整数 2。之后的程序中所有出现 size 的地方,都认为它是 2

> (* 5 size)
10

没什么好说的,因为确实很简单:

> (define pi 3.14159)
> (define radius (+ 3 7))
> (* pi (* radius radius))
314.159

大的要来了:我们还可以定义“函数”!

(define (square x) (* x x))

解释一下上面这句话的含义:它定义了一个叫做 square 的“函数”:其接受一个运算对象 x,计算并“返回” (* x x) 的结果。当你把 square 放在组合式的操作数的位置的时候,就会“调用”这个函数并得到结果:

> (square 21)
441

如果你学过其它编程语言,这看上去不算陌生。只是说,在 Lisp 里,他们管这种“函数”叫做 过程。其实指的是同一个东西。过程可以接受任意多个运算对象:

; 猜猜这个过程在算什么?
> (define (volume h r)
          (* h
             (* r r 3.14159)))
> (volume 4 2)
50.26544

理所当然地,这里的 h r 叫做形参。总结一下语法吧:

; 定义变量的方法:
(define 变量名 值)
; 定义过程的方法:
(define (过程名 形参...) 运算结果)
; 调用过程的方法:
(过程名 实参...)   ; 所以聪明的你就知道了,+ - * / 这些“运算符”其实就是名字比较怪的过程

如果你觉得没问题,那么就 继续前进 吧!

第四课:特殊形式

上一课提到了 (define ...)。这种东西长得很像组合式/过程调用,对吧?但它并不是组合式。为什么呢?

组合式有着固定的计算方法:

  1. 首先,对运算符(过程)进行求值。比如,如果运算符是一个“变量名”,那么找到它所对应的定义。
  2. 其次,按顺序对每一个运算对象(实参)求值。比如,计算 (/ (+ 2 3)) 的第二步就是计算 (+ 2 3) 的值。需要这样一直做下去直到运算对象是平凡的(比如就是一个整数)。
  3. 最后,将运算对象绑定到形参名字上,计算结果。

然而,(define ...) 不能这样求值。用 (define size 2) 举例:如果按照组合式的计算方法来做的话,那么就要尝试去对 size2 求值——不妙,size 并不是已经定义的变量名(而是即将要定义的变量)。导致的结果就是,解释器不知道 size 是什么东西,这样子行不通。

因此,像 (define ...) 这种东西,称为 特殊形式。特殊形式类似于其它语言的 关键字,具有特殊的语法和特殊的计算方法。比如 define 的计算方法就是,将第一个“东西”作为变量名添加到当前的解释环境,其值为第二个“东西”求值后的结果(仅就变量定义而非过程定义)。

下面介绍一个常用的特殊形式:if。先看一个例子:

(define (abs x)
        (if (>= x 0)
            x
            (- x)))

从名字可以看出,代码定义了一个计算绝对值的 abs 过程。绝对值的定义是:如果 x 是非负数,那么 x 的绝对值就是 x;否则 x 的绝对值是 -x。代码里用 if 特殊形式来实现它。if 接受三个“东西”:

(if 条件 真分支 假分支)

如果 条件 成立,那么就返回 真分支 的结果;否则返回 假分支 的结果。这里的 条件 就是 (>= x 0):其中 >= 过程判断 x 大于等于 0 是否成立。如果成立,那么整个 if 的计算结果就是第三行的 x;否则就是第四行的 (- x)

TIP

>= 过程,当然也有 < = > <= 等等。它们都是比较整数的大小;如果成立就返回 #t(就是真命题、true 的意思),不成立时返回 #f(就是假命题、false 的意思)。

在 R5RS 中,只有 #f 代表条件不成立,其余的所有值,包括 #t,都代表条件成立。

为什么说 if 是特殊形式而非过程呢?因为 if惰性求值的。它一上来只会对 条件 求值,如果为真才会求值 真分支 并扔掉 假分支 上的表达式不管。反之,就扔掉 真分支 只求值 假分支。如果按照过程的求值方式,那么条件和两个分支都会被求值后才会去进行取舍:这样做的效率会比较低,尤其是表达式很长的时候。

TIP

if 的惰性求值是在 Lisp 中实现递归算法的必要条件。想想看,为什么?

如果将 if 特殊形式对应于 C 里的 if 语句,那么 switch 语句在 Scheme 里对应于一个叫 cond 特殊形式,其原理是类似的我们这里不多提及。但是 C 里的循环语句 while 或者 for,在 Lisp 里是没有这样的概念的。那怎么写循环的逻辑呢?别急,马上就讲。

第五课:列表

在 Lisp 中,最常用的数据结构就是列表。列表顾名思义就是一系列值的组合,类似于 Python 中的 list,C++ 中的 std::vector

构建一个列表的方法是使用 list 过程:

> (list 1 4 2 8)
'(1 4 2 8)

解释器返回的结果 '(1 4 2 8) 就代表一个列表——用一个单引号,外加一对括号;内部是空格分隔的值。

TIP

这种表示方法很像组合式——其实这不是巧合,我们之后再讲。

如何访问一个列表的元素呢?事实上 Lisp 没有直接提供访问元素的方法。Lisp 提供两个过程 car cdr,可以分别获取列表的首元素除去首元素外的其余部分构成的新列表

> (define x (list 1 4 2 8))
> (car x)
1
> (cdr x)
'(4 2 8)

如果列表只有一个元素,那么将 cdr 作用在其上会得到空表 '()。此外,(list) 的结果也是 '()。空表是一个特殊值,不能再被 carcdr 作用。

> (cdr (list 1))
'()
> (list)
'()

其实这一课关于语法的内容只有这么多了。(如果你着急的话,可以直接翻到 下一课。)但是感觉什么都没讲?来看看下面的代码:

; 从列表 l 获取第 i 个元素(0 起始)
(define (at l i)
        (if (equal? i 0)
            (car l)
            (at (cdr l) (- i 1))))

这里用到了一个没见过的内置过程 equal?,我稍微解释一下,就是比较两个操作元素是否相等。从而,整段程序可以解读为:

定义过程 at:接受两个实参 li(其中 l 是列表,i 是整数,意思是取 l 的第 i 个元素)。过程是这样执行的:

判断 i 是否等于零;

  • 如果是零的话,就返回 (car l):也就是 l 的首个元素;
  • 否则,就返回 (at (cdr l) (- i 1)) ——就是 (cdr l) 的第 i - 1 个元素。

有感觉了吗?这里用到的是对 at 的递归。由于 (cdr l) 会“删除” l 的首个元素,因此 l 的第 i 个元素就相当于 (cdr l) 的第 i - 1 个元素。这样递归做下去,就可以遇到 i 为零的时刻,随后用 car 作用一下就可以了。

> (define my-list (list 1 4 2 8))
> (at my-list 0)
1
> (at my-list 3)
8

通过这个小例子,我们对 Lisp 这种编程语言的运作方式有了一个感性的认识。比如最直观的,在 Lisp 中,通常使用递归来表示循环的逻辑。

第六课:映射

Lisp 是一门函数式编程语言。函数式编程中的一个关键操作就是映射。Lisp 用 map 过程来表示映射。

map 过程接受两个实参 fl。其中 f 是一个过程,而 l 则是一个列表。先来看例子:

> (define (double x) (* x 2))
> (define my-list (list 1 2 3 4))
> (map double my-list)
'(2 4 6 8)

将过程 double 和列表 my-list 传递给 map 后,得到了一个新的列表 '(2 4 6 8)。从结果上不难猜到 map 的计算流程。很明显,double 过程会将传入的元素翻一倍返回,而 (map double my-list) 则将 my-list 中的每一个元素都翻了一倍,得到一个新列表——从 '(1 2 3 4)'(2 4 6 8)

所以,(map f l) 会将 f 依次作用在 l 的所有元素上,并将结果重新收集为一个列表作为返回值。这里不太直观的一点,是将过程 f (而非普通的数据变量)作为 map 的实参。像这样接受过程的过程在其他编程语言中是比较少见的,它们称为高阶过程或者高阶函数,是函数式编程的重要组成部分。

在使用高阶过程的时候,为了定义传入高阶过程的实参,我们特地编写了 double 过程。但是,这并不是必须的;lambda 特殊形式可以省略这一定义:

> (map (lambda (x) (* x 2))
       my-list)
'(2 4 6 8)

这段代码使用了特殊形式 lambda,其含义是定义一个匿名过程,也被称为 Lambda 表达式。这个匿名过程接受形参 x,并返回 (* x 2)。事实上,用来定义过程的 define 特殊形式,其实是 lambda 特殊形式的简写:

; 下面两行是等价的
(define (double x) (* x 2))
(define double (lambda (x) (* x 2)))

此外,lambda 特殊形式可以用来定义“局部变量”。看下面的例子:

(if (equal? my-list '())
    '()
    ((lambda (first rest)
             (do-sth-with first rest))
     (car my-list) (cdr my-list)))

重点是第三行的 lambda 特殊形式。第三行到第五行的 Lambda 表达式接受两个形参 first rest,同时第三行的第一个括号直接以组合式的写法调用这个 Lambda 表达式,实参写在第六行,分别是 (car my-list)(cdr my-list)

其实这种写法和 (do-sth-with (car my-list) (cdr my-list)) 效果上没有区别,只是用 first 名字代替了 (car ...),用 rest 名字代替了 (cdr ...)。但是前者的写法可以让程序意图更明确,其效果类似于其它编程语言里的局部变量——用一个有意义的名字代替长表达式。

TIP

此外,如果你回顾 Lisp 组合式的求值规则,就会发现如果需要多次使用 (car ...)(cdr ...),那么绑定到 first rest 两个名字的做法只需要计算一次 carcdr;否则需要多次计算。

由于“局部变量”这一需求非常常见,Lisp 引入了 let 特殊形式。刚刚的代码可以写成:

(if (equal? my-list '())
    '()
    (let ((first (car my-list))        ; “定义局部变量” first 为 (car ...)
          (rest  (cdr my-list)))       ; “定义局部变量” rest  为 (cdr ...)
         (do-sth-with first rest)))    ; 在后面的部分使用这些局部变量

WARNING

为什么不用 define 特殊形式?因为 define 不能出现在 if 里。不同的 Scheme 方言对 define 出现的位置规定不同。大多数方言只允许 define 出现在“全局”,或者过程定义的开头部分。而 let 则是所有 Lisp 方言——甚至是其它函数式编程语言——在任何位置都允许出现的。

第七课:对子

最后我们来看 Lisp 最核心的东西——对子。简单来说,对子就是由两个东西组合在一起构成的东西,类似数学上的“有序数对”。在 Lisp 中,使用 cons 过程来构造一个对子。

> (cons 1 2)
'(1 . 2)

解释器输出了 '(1 . 2),就代表这是一个用 12 组合起来的对子。(感觉很像列表的输出格式?别急,往下看看就知道。)

对子是有序的。用 car 过程输出对子的左边的部分;用 cdr 过程输出对子的右边的部分。

> (define x (cons 1 2))
> (car x)
1
> (cdr x)
2

你可能大脑已经开始飞速旋转了——怎么这个和列表的那个操作都叫 car cdr 呢?函数重载吗?还是什么特殊的语法?……

好了不卖关子了,其实列表就是由一系列对子构成的。看这个例子:

> (cons 42 (cons 69 (cons 613 '())))
'(42 69 613)

当我连续用三个 cons 过程构建出三个对子后,解释器直接将它解释为一个列表。首先,第一个对子的左半部分是 42,右半部分是一个对子;这个对子的左半部分是 69,右半部分又是一个对子;最后这个对子的左半部分是 613,右半部分是空表。当一系列对子以“右半部分”链接起来且最后一个右半部分是空表的时候,那么它就是一个列表。列表中的元素,就是这些对子的左半部分的值。

因此,将 car 作用在列表上,输出的正是第一个元素。因为列表作为对子的左半部分,就是第一个元素。将 cdr 作用在列表上,输出的就是除首元素外的列表,因为第一个对子的 cdr 就是这样的一个列表。

TIP

这种由对子构建列表的方式非常像其它编程语言中常见的“链表”。
在这里插入图片描述

接下来的内容和使用 Lisp 编程已经没有太大关系了,但如果你想编写一个 Lisp 解释器,那么可以继续读下去。

对子是 Lisp 中唯一的复合数据类型。其余的数据类型,比如整数 42、有理数 5/2、浮点数 3.14、布尔值 #t,都是原子类型,即不可再分的。而基于对子,不仅可以构建出列表,还可以结合一些语言特性构建出更复杂的数据结构(比如树、散列表等等)。

列表不仅构成 Lisp 程序的大量数据结构,也是 Lisp 源码的结构。不论是特殊形式,还是组合式,它们不都写成列表的样子吗(少了一个单引号)?

(define x 42)  ; 看上去很像由 define、x 和 42 构成的列表
(+ x 1)        ; 看上去很像由 +、x 和 1 构成的列表

它们不仅是列表的样子,它们实际上就是列表。用源码 (define x 42) 举例;它其实是由符号 define、符号 x 和整数 42 构成的列表。

这里提到了“符号”。符号是其它编程语言中很少出现的概念。在 Lisp 中,你可以将符号直接理解为一种特殊的字符串类型——用作变量名的字符串,目前就先这样理解好了。使用一个单引号再加上变量名,就可以或得到表示这个变量名的符号:

> 'define
'define

如果将符号 '+、符号 'x 和整数 1 构成一个列表,那么你就得到了源码 (+ x 1) 的表示形式。把源码的表示形式塞到 eval 过程中,就可以直接对这个源码求值!

> (define x 42)
> (define source (list '+ 'x 1))
> (eval source)
43

从这个案例我们可以一窥 Lisp 解释器的构造。假设 Lisp 解释器正在解释一个列表形式的源码。如果列表的 car 是一个符号,而且符号名指代了特殊形式如 'define,那么执行相关的特殊逻辑。否则,对列表中的每一个元素进行求值

  • 对整数、布尔类型的求值结果仍然是它自身——这种元素类型称为自求值类型
  • 对符号类型的求值结果,就是查找当前环境定义的所有变量名,找到这个符号名对应的值作为求值结果。
  • 对列表类型求值,就是调用过程的逻辑了。将列表的 car 部分作为要调用的过程,将 cdr 部分作为实参,然后绑定到对应形参,执行过程的定义。

最后讲一个特殊形式 quotequote 特殊形式接受一个东西,不做任何求值操作直接原样返回。比如:

> (quote (+ 1 2))
'(+ 1 2)          ; 由符号 +、整数 1 和整数 2 构成的列表

quote 特殊形式还可以简写成 '。因此 '(1 2 3) 就是 (quote (1 2 3)) —— 解释器就解释为 '(1 2 3)。哈哈,绕了一圈又回到起点。这就能解释为什么解释器输出的列表长成 '( ... ) 的样子,因为你直接把这个样子作为源码输入到解释器,得到的就是这个东西。

TIP

思考题:请问 (car ''x) 求值得到的结果是什么?为什么?试一试,想一想。

结语

下面是一些学习资料:

  • SICP,在首页已经提到了,是最推荐的学习 Lisp 的专业教科书。
  • Racket Get Started,提供了一些学习 Racket 方言的资料。
  • CS 61A Scheme Reference。CS 61A Scheme 是 Scheme 的一个教学用子集,使用 Python 和 JavaScript 实现。本课程的大作业项目也受此启发。
  • Learn your Haskell。Haskell 是另外一套非常流行的函数式编程语言,在工业界也颇为常用;但是 Haskell 的学习难度个人认为比 Lisp 要高。这本书也有 中文版