编程语言的一些基础概念(二):动态函数式编程

在上一篇《编程语言的一些基础概念(一)》中,通过静态类型的函数式编程语言,介绍了一些编程语言的特性,包括数据不可变,尾递归,匿名函数等。这一篇在上篇的基础上,通过 Dynamic Typing (动态类型) 的函数式编程语言 Racket,再介绍一些编程语言的特性,比如 Stream, 惰性求值, 宏 Macro 等。

括号的使用

天花乱坠的括号,这是 Racket 和 LISP 等这类语言最直观的特征。看一些例子:

(+ 1 2)
(and true (= 1 2))

这种代码写的方式和平时写的挺不一样的,(+ 1 2) 表示 1 + 2(and true (= 1 2)) 表示 true and (1 = 2) 的 boolean。大概也能看出个规律,( 后面第一个词,是要做的操作,多数情况下是函数调用,之后接的类似于函数调用要穿进去的参数。

再举一个例子:

(define (fact n) (if (= n 0) 1 (* n (fact (- n 1)))))

上面这段代码是典型的斐波那契函数的递归写法。

在写了一些 Racket 的程序后,最直接的感觉就是修改代码麻烦,不容易 Debug。这种写法对于写代码前逻辑的清晰度要求更高,因为括号数量多,常常看的眼花缭乱,如果再加上逻辑不清晰的话,修改起来不是一般的困难。从 syntax 的角度来说,并不是很友好,为什么语言的设计要将括号的地位放到这么高的地位呢?

将所有的表达式都加上括号,最大的好处是让编程语言的表意很明确,比如说 1 + 2 * 3,在没有括号情况下,我需要去判断乘法的优先级比加法高,然后计算。在有括号的情况下, (+ 1 (* 2 3)) 或者 (* (+ 1 2) 3),可以很明显的知道该怎么去运算,按照括号的顺序执行就行了。

第二个好处就是,这种表达式很容易被分解成的结构,程序的执行顺序从树的根往下,简单明了。比如说下面这个例子:


像我们这种写惯了顺序表达的,对这种有一点点逆序表达的是有一些偏见的,但是不能因为那么多括号的使用,而去否定一门编程语言,去否认一门编程语言优秀的地方。

变量作用域

变量的作用域在任何一门编程语言里,都是非常重要的一部分,因为它直接决定了变量的值。ML 是 Lexical Scope。Racket 有4种不同定义变量的方式,每一种变量的作用域都不太一样 ,他们分别是:let, let*, letrec, define

let

Let 绑定变量,变量值从 Let 这个定义之前来。

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

[x (+ x 3)]第二个 x 的值是函数传入的值, [y (+ x 2)] 中 x 的值不是 [x (+ x 3)] 中绑定的,而是函数传入的 x 的值。

let*

Let* 绑定变量,变量的值从之前的绑定来。这和 ML 的 let 是类似的。

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

[x (+ x 3)]第二个 x 的值仍然是函数传入的值,但是 [y (+ x 2)] 中 x 的值不再是函数传入的值,而是 [x (+ x 3)] 中绑定的。

letrec

Letrec 绑定变量,变量的值是在包含所有绑定的环境下求得的。通常用在递归的情况下。

(define (silly-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)))

even 中用了 odd,odd 中用了 even,互递归,用 let, let* 都会有问题,只能用 letrec。

define

(define (f x) (+ x 1))

define 是最普遍的变量绑定方式,通常用在函数一开始的定义上。

延迟求值 Delayed Evaluation

延迟求值的作用是减少不必要的运算

用来延迟求值的空参的函数,在计算机领域成为 Thunk ,可以说 Thunk the expression。

; 正常表达式
e

; 延迟求值
(lambda () e)

; 求值
(e)

在 Racket 中,函数的具体内容只有在被调用时才会求值,我们将一个表达式包在一个函数里,只有在这个函数被调用时,这个表达式才会求值,(lambda () e) 像这样,用 lambda 构造一个空参函数,来延迟对表达式 e 的求值。

为什么说延迟求值可以减少运算呢?

define (f thunk)
(if (…)
0
(… (thunk) …)))

在这个例子中,th 是一个 thunk,在 if 条件是 true 的情况下,th 不会被求值,只有在 if 条件是 false 的情况下,才会求值,这样可以减少不必要的运算。

惰性求值 Lazy Evaluation

像上面那样延迟求值,虽然是很直观的可以减少运算,但没用好,反而会增加运算量。

(define (f thunk)
(if (…) 0 (… (thunk) …))
(if (…) 0 (… (thunk) …))))

在这个例子中,有多个 if 条件的情况下,每次 false,都要重复对 thunk 求值,有 n 个这种判断,就要增加 n 倍的运算。

既然问题是多次重复运算,那能不能将第一次运算的值先存起来,之后需要直接用,不用再运算了。这其实就是惰性求值,延迟求值,记录第一次求值结果,之后不用再运算一次。

(define (my-delay thunk)
(mcons #f thunk))

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

; 用法
; 函数 f 里 th 部分得修改成 (my-force th)
(f (my-delay (lambda () e)))

; 函数 f 不需要修改,调用时复杂
(f (let [p (my-delay (lambda () e))]
(lambda () (my-force p))))

代码不是太好懂,mcons 表示构建一个可以修改的 list (#f, thunk),通过 set-mcar!set-mcdr! 可以修改 list 的第一个和第二个值。 my-force 是对延迟求值的调用,每一次调用,都会看看 (mcar p) 这个值是不是 True,如果是的话,说明已经求值过啦,直接用 (mcdr p) 取值;如果不是的话,(set-mcar! p #t)(#f thunk) 这个的 #f 改成 True,然后求值 ((mcdr p))(set-mcdr! p ((mcdr p)) 把 (#f thunk) 的 thunk 替换成求值的结果。

流 Stream

Stream 是一个可以无限”取数据”的流,每次一次都可以拿到这个流的下一个数据。比如说一个自然数的流,1 2 3 4 5 6 …,第一次是1,下一次调用,就是2,以此类推。

这个概念在计算机领域还是挺普遍的,比如说 socket, message queue 等。但是作为一门编程语言的一个”特性”,还是第一次见到。

; 定义流 1 1 1 1 1 1 ...
(define ones (lambda () (cons 1 ones)))

; 定义流 1 2 3 4 5 6 ...
(define nats
(letrec ([f (lambda (x) (cons x (lambda () (f (+ x 1)))))])
(lambda () (f 1))))

上面定义了两个 stream。用的是 递归 + 延迟求值,非常的巧妙!

; 使用
(car (s))
(car ((cdr (s))))

(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 nats (lambda (x) (= x 10))

(car (s)) 可以取流里的一个数据,通常应该放在递归里,不断的去调用得到下一个流里的数据。

宏 Macro

Macro 全称是 macroinstruction,指的是把较长的指令序列用某种规则对应到较短的指令序列的规则或模式。在编程语言中,可以自己去定义一些宏,意味着可以在编程语言的基础上去做语法的扩展,或者替换。比如说 Racket 中的条件判断是 (if e1 e2 e3),如果 e1 是 true,执行 e2,否则执行 e3。有了宏,你可以定义自己的条件判断 (my-if e1 then e2 else e3) 的语句。

(define-syntax my-if            ; macro name
(syntax-rules (then else) ; other keywords
[(my-if e1 then e2 else e3) ; macro use
(if e1 e2 e3)])) ; implementaion

Macro 给了程序员很大的自由度,被放飞了就很容易被滥用/过度使用,有一些函数能解决的问题,偏偏还要定义成 macro,所以可以有一个不成文的准则 当你不知道该用 function 还是 macro 时,用 function 就对了

比 C/C++ 的 Macro 更”强大”

Racket 的 Macro 的编译解析比 C/C++ 的更加完善。举一个例子:

(define-syntax double
(syntax-rules ()
[(double x) (* 2 x)]))

(let ([* +]) (double 42))

; 展开
(let ([* +]) (* 2 42))

使用 macro double,在展开后是 (let ([* +]) (* 2 42)) ,let 将 * 绑定成 +,(* 2 42)这里的运算,应该是 + 还是 ?在 Racket 中,macro 里定义的`( 2 42)` 仍然是乘法运算,但是 C/C++ 则会因为绑定,而编程加法运算,导致了 macro 和使用 macro 的变量需要很小心的定义,不然一不小心就会出现奇怪的很难发现的 bug。

判断条件

if condition else ... 在静态语言中,condition 做为判断条件,必须是 boolean 类型,只能是 true 或者 false。但是在像 Racket 这类的动态语言中, 条件定义好的会是 False,其他表达式都判断为 True。在 Racket 里,只有 #f,但是在 Python 中,False, 0, [] 等都是会判断为 False。

对于静态语言来说,类型必须正确,所以判断条件一定必须要是 Boolean,不然 Type Inference, Type Check 等很难成立。对于动态语言来说,就自由很多,Racket,Python 以及其他的动态语言会有各自不同的定义吧。这影响的是编程语言的语法,和一定的代码风格,是一个挺有趣的小点。

总结

这些内容是 Coursera 上 Programming Languages, Part B 内容的总结和笔记。知道与不知道这些,对平时工作没有太大的影响,但是可以拓宽对于编程语言的认识。你只能想到你知道的,在接触一门新的编程语言时,可以想想这些特性在那个编程语言上是怎么实现的,作者为什么这么去实现,从这个角度,或许能更深的认识这个语言。