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 ...)
。这种东西长得很像组合式/过程调用,对吧?但它并不是组合式。为什么呢?
组合式有着固定的计算方法:
- 首先,对运算符(过程)进行求值。比如,如果运算符是一个“变量名”,那么找到它所对应的定义。
- 其次,按顺序对每一个运算对象(实参)求值。比如,计算
(/ (+ 2 3))
的第二步就是计算(+ 2 3)
的值。需要这样一直做下去直到运算对象是平凡的(比如就是一个整数)。 - 最后,将运算对象绑定到形参名字上,计算结果。
然而,(define ...)
不能这样求值。用 (define size 2)
举例:如果按照组合式的计算方法来做的话,那么就要尝试去对 size
和 2
求值——不妙,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)
的结果也是 '()
。空表是一个特殊值,不能再被 car
或 cdr
作用。
> (cdr (list 1))
'()
> (list)
'()
其实这一课关于语法的内容只有这么多了。(如果你着急的话,可以直接翻到 下一课。)但是感觉什么都没讲?来看看下面的代码:
; 从列表 l 获取第 i 个元素(0 起始)
(define (at l i)
(if (equal? i 0)
(car l)
(at (cdr l) (- i 1))))
这里用到了一个没见过的内置过程 equal?
,我稍微解释一下,就是比较两个操作元素是否相等。从而,整段程序可以解读为:
定义过程 at
:接受两个实参 l
和 i
(其中 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
过程接受两个实参 f
和 l
。其中 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
两个名字的做法只需要计算一次car
或cdr
;否则需要多次计算。
由于“局部变量”这一需求非常常见,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)
,就代表这是一个用 1
和 2
组合起来的对子。(感觉很像列表的输出格式?别急,往下看看就知道。)
对子是有序的。用 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
部分作为实参,然后绑定到对应形参,执行过程的定义。
最后讲一个特殊形式 quote
。quote
特殊形式接受一个东西,不做任何求值操作直接原样返回。比如:
> (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 要高。这本书也有 中文版。