Coursera Programming Languages, Part B 华盛顿大学 Week 1
来上 programming language 的第二 part 了!这一部分介绍的语言是 Racket,之前就听说过它独特的括号语法,这次来具体了解一下
Racket definitions, functions and conditionals
- definition
(define x 3)
(define y (+ x 3)) ; 在 racket 中,+ 是一个函数,后面接着函数的两个参数
- functions
(define cube1
lambda (x) (* x x x)) ; lambda 关键字类似于 ML 中的 fn, 创建一个匿名函数,格式为 lambda(args) (function body)
(define (cube2 x)
(* x x x)) ; cube1 的 syntactic sugar 写法
(define (pow1 x y)
(if (= y 0)
1
(* x (pow1 x (- y 1)))))
- conditionals
racket 中的条件语句格式为(if e1 e2 e3)
翻译为 C 即if (e1) then e2 else e3
Racket Lists
(list e1 e2 ... en) ; build a list
null ; empty list
cons ; connect 2 lists
car ; get the head of a list
cdr ; get the tail of a list
null? ; check whether a list is empty
syntax and parenthetes
Racket 的整个语法结构是建立在括号之上的,而且非常简洁!
Racket 中的一个语素 (term) 有以下几种类型:
- Atom (不可再分,原子):如
#t
,#f
,"hi"
,null
(和线性表中的定义一样!) - Special form : 如
lambda
,define
,if
- A sequence of terms in parens:
(t1 t2 ... tn)
注意,如果t1
属于special form
那么整个序列的格式需满足t1
的要求
否则序列为一个函数调用 (function call)
Racket 语言其实是一个前序序列,我们可以对一条条语句建立对应的语法树
由于采取前序序列,可以保证每一条语句不会产生歧义 (ambiguity),且不需要讨论优先级 (operator precedence) 问题
在这个语法树中:
- Atom 是叶子节点
- Sequence 的头元素是内部结点,而其余的元素代表的结点都在该结点的子树中
Dynamic typing
与 Static typing 不同,在函数实际运行之前不能发现类似 (n * x)
的错误
但是可以定义许多灵活的的数据结构,不会受到类型的束缚,例如,一个 List 的元素可以不是同一类型的
(list (list 4 5) 5 "hi" (list foo "hello") 4)
Cond
cond 语法是多个嵌套 if
语句的语法糖
对于嵌套 if 语句:
(if e1 e2
(if e3 e4
(if e5 e6 e7)))
可以运用语法糖 cond 写成
(cond [e1 e2]
[e3 e4]
[e5 e6]
[#t e7]) ; the 1st element in cond's last bracket should always be #t
值得注意的是,在 Racket 中,除了 #f
之外的所有 term 都被 evaluate 成 #t
所以,if
或者 cond
的条件语句可以是除了 #f
或 #t
以外的 term
local bindings
引入用于定义本地变量的 special form -> let
let*
letrec
格式如下:
(let ([x1 e1] [x2 e2] ...[xn en]) body)
本地变量在 let
后的第一个括号中进行定义,并且每一对变量-值对都被包在中括号里
与 ML 中我们已熟知的 let
不同,所有在 let
环境中的表达都是在 let
之前的环境中进行 evaluate 的
例如:
(define (silly-double x)
(let ([x (+ x 3)]
[y (+ x 3)])
(+ x y -5)))
若传入的参数 x 为 3,若按照传统的 dynamic type 语言,let 环境中的 x 值应为 6,而 y 则是 9 (因为第二个 x 将传入的参数 x shadow 掉了)
但实际上 x 与 y 都为 6,evaluate 它们表达的环境是 let 之前的环境,也就是 x 为 3 的环境;至于 let 环境中的 x 不参与对 y 的 evaluate
而 let*
则与 ML 中 let
的 evaluate 原则相同
(define (silly-double x)
(let* ([x (+ x 3)]
[y (+ x 3)])
(+ x y -5)))
这里,let*
expression 中的 x 为 6,y 为 9
最后的 letrec
则在定义 mutual recursion 时非常常用 (除了定义 mutual recursion 之外,其他地方最好不用 letrec)
在 letrec
环境定义新 binding 时,你既可以使用之前的 bindings,也可以使用之后的,但只能在函数体 (body) 中使用之后的 binding 进行定义
(define (silly-triple x)
(letrec ([y + x 2]
[f (lambda(z) (+ z y w x))] ; 在定义函数体中使用了之后的 binding w
[w (+ x 7)])
(f -9)))
在这里,w 定义在了使用 w 的函数 f 之后,但是却不会出现问题
这是因为只有我们在调用函数时 ((f -9)
),racket 才会 evaluate 函数的 body 部分,而此时 w 已经 evaluate 为 x + 7 了
本质上来讲,racket 仍然是按照次序进行 evaluate 的,所以以下这个函数会出现问题
(define (bad-letric x)
letric ([y z] ; z 在之后才形成 binding,所以这里对 y 的 binding 是失败的
[z 13])
if (x y z)))
除了用 let
定义本地变量,还可以使用 define
;不过只能在函数 body 的起始处进行定义
有人认为使用 define
时 good style
格式如下
(define (silly-mod2 x)
(define (even? x) (if (zero? x) #t (odd? (- x 1))))
(define (odd? x) (if (zero? x) #f (even? (- x 1)))))
(if (even? x) 0 1))
Toplevel binding
Racket 中进行 binding 的方式与 letrec
等定义 local binding 的规则一样
按顺序进行 evaluate (保证了 early reference: 使用之前的 binding)
且函数直到调用时才对函数体进行 evaluate (保证了在定义函数体时可以进行 back reference: 使用之后的 binding)
而这也造成了 Racket 中不会出现 shadow 的现象:
(define (f x) (+ x 3))
(define f 17) ; not okay, f already defined in this module
以上代码会出现错误:因为在一个环境里不可能出现两个 f
Delayed evaluation and Thunks
延时求值(delayed evaluation),顾名思义就是不立即求值,而是在值不得不被使用的时候再进行求值。
在一次求值之后,后面的求值会直接使用第一次求值得到的值,而无需再次对原表达式进行求值。
我们利用 Racket 中函数直到调用时才对函数体进行 evaluate 的特性,将表达式用 lambda 包起来创建一个匿名函数(即为创建了一个 thunk)
e ; e will be evaluate immediately
(define x (lambda () e)) ; wrap e in a thunk
(x) ; e will be evaluate only when calling the function x(thunk)
只有在调用 thunk 的时候才会对 e 求值,这就实现了延时求值
然而,若一个函数中多次调用 thunk,就会出现许多重复计算,如下例子
(define my-mult x y-thunk
(cond [(= x 0) 0]
[(= x 1) (y-thunk)]
[#t (+ (y-thunk) (my-mult (- x 1) y-thunk))]))
这个自定义的乘法函数用递归实现,每次递归一层都要对 y-thunk 进行一次调用
为了优化这个过程,在调用函数时我们利用 let
提前存储 y-thunk 的结果
(my-mult x (lambda () (+ 3 4))) ; ordinary call
(my-mult x (let ([z (+ 3 4)]) (lambda () z))) ; store the result in z
(my-mult x (lambda () (let ([z (+ 3 4)]) z))) ; the same as ordinary call
注意第三种写法与第一种其实是一样的,因为 let
中计算的过程仍然被包在了 thunk 中
Delay and force
依然是延时求值内容的扩展,采取一种更加可扩展化的操作来对 thunk 进行处理
(define (my-delay th)
(mcons #f th)) ; create a promise
(define (my-force p)
(if (mcar p) (mcdr p)
(begin (set-mcar! p #t)
(set-mcdr! p ((mcdr p))
(mcdr p)))) ; extract/modify the value in promise
delay 操作是将 thunk 包装入一个可变 pair 中,这个可变 pair 被称为 promise
在包装的过程中,由于没有调用 thunk,所以表达式不会被计算
promise 的第一个元素初始化为 false,用来标记 thunk 有没有被调用过
而 force 操作则是替换原本朴素的 (thunk)
操作,保证 thunk 最多只被调用一次
Streams
- streams
流 (streams) 是一个长度无限的值序列
流是以 thunk 的形式表示的,当调用该 thunk 时返回一个 pair<val, next-thunk>
val
为 stream 序列中当前元素的值,next-thunk
为接下来的元素序列形成的 stream
无限序列可由
cons
单元的嵌套结构表示,cons
单元的car
与cdr
分别是最终值与延时对象 (promise),后一个cons
单元通过强制求值后的cdr
部分产生,这个过程可以不断重复,从而形成无限序列
- using streams
powers-of-two
是 Racket 中自带的一个 stream,值为 \(\{2^1,2^2,2^3,2^4,2^5...\}\)
我们知道 stream 是一个 thunk,所以需要进行调用来求值
> (car (powers-of-two))
2
> (car ((cdr (power-of-two))))
4
这里定义一个计算函数,计算满足条件的元素在 stream 中的位置
(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
- defining streams
下面定义一个全 \(1\) 序列
(define ones (lambda () (cons 1 (lambda () (cons 1 (lambda () (cons 1 ......))))))) ; infinite definitions
(define ones (lambda () (cons 1 ones)))
第一句是无限循环递归下去定义的,但我们可以发现,其实后面的部分就是 ones 本身
下面我们自己定义一个 powers-of-two
(define powers-of-two
(letrec ([f (lambda (x) (cons x (lambda () (f (* x 2)))))]) (lambda () (f 2))))
同样也是利用的递归定义,有点难理解
f 是一个本地函数,其输入一个参数 x,输出一个 pair <x, thunk>
其中 thunk 包装的是以 \(2x\) 为参的 f 函数
那么整个 stream 就可以表示为 thunk(f(2))
我们要知道,对 stream 的定义,核心还是利用了延时求值 (delay evaluation) 的性质:
例如在对 ones 的定义中我们又用到了 ones,但由于其被 thunk 包装起来,所以并不会被立即 evaluate 从而保证了定义的完整性
如果这样定义:
(define ones (cons 1 ones))
这样就会出现无限循环的错误:因为没有 thunk 来延迟 ones 主体部分的 evaluation,程序在定义 ones 时使用了还未完成定义的 ones 本身,因此会无限循环下去
Memoization
记忆化(memoization) 的英文是一个生造词,即采用 memo 进行记忆化
贴一段 Racket 实现斐波那契数列递归记忆化代码实现
(define fibonacci
(letrec ([memo null]
[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))
这里的 memo 是一个元素为 pair 的 list,<x, ans>
代表斐波那契数列中的第 \(x\) 个元素的值为 \(ans\)
其中 (assoc x list)
为内置函数,用来查询 list 中首个 pair 第一关键字为 \(x\) 的第二关键字
若不存在第一关键字为 \(x\) 的元素,则返回 #f
对 memo 的修改是采用 set!
进行的
Macros
Racket 中的宏 (Macros) 可以说是 Racket 语言的特色
简单的说, Macro 就是能让你在正式编译之前的,先对你的代码进行一次预编译,在这个时候把你代码中的一些符合条件的规则,全部替换成你需要的代码,或者说去生成你想要的代码
和 C++ 中的宏意义很相似
例如以下代码,用宏实现 delay 操作 (即将一个表达式封装为 promise)
(define-syntax my-delay
syntax-rules ()
[(my-delay e)
(mcons #f (lambda () e))]))
> (my-delay (begin (print("hi") (* 3 4))))
; will not print hi
在以上例子中,对 begin 表达式进行 delay 操作并没有输出 hi
这说明宏进行的是词义替换,并不会对 e 进行求值
若采用以下的函数方式来实现 my-delay
(define (my-delay e)
(mcons #f (lambda () e)))
> (my-delay (begin (print("hi") (* 3 4))))
hi
对同样的 begin 表达式进行 delay 操作,用函数实现的 my-delay
则会输出 hi
这是因为 Racket 在调用函数时一定会对所有的参数进行求值
这就是宏实现与函数实现的区别:宏实现进行纯粹的语义替换,而函数则会创建一个新的函数栈,且会对所有参数进行求值
语义替换是把双刃剑,在 C 系列语言中,不规范的使用宏会出现很多问题
(define-syntax db1
(syntax-tules ()
[(db1 x) (let ([y 1]) (* 2 x y))])) ; macros
(let ([y 7]) (db1 7)) ; use
db1
的作用是将某个数乘 \(2\),那么 use 语句结果的期望肯定是 \(14\)
对以上的 use 语句进行 macros 词义替换,得到以下代码
(let ([y 7]) (let ([y 1]) (* 2 y y))) ; naive expansion for use
可以发现,经过词义替换后的函数输出的是 \(2\),而不是期望中的 \(14\)
这就是为什么在 C 系列语言中,宏一般不定义本地变量,且变量名都故意设置的很奇怪
但在 Racket 中并不会出现这样的问题,也就是说,程序会输出正确的结果 \(14\)
这一特性被称为卫生宏 (hygiene macros)
具体表现在:
- racket 将 macros 中本地变量名称改成其他的名称
- 在 macros 被定义处寻找对应的变量