calc.rkt
通过一个简易 Racket 计算器程序,理解读取-求值-打印循环的工作原理。
概览
本小节中,我们将玩转一个用 Racket 编写的计算器程序。
我们的计算器程序将用与 Racket 相同的语法进行算术运算。为什么要这样做?我们想加深对 Racket 如何求值的理解。在下一个实验中,我们会给它添加更多功能,但目前它只能做算术运算。
你可以从这里下载这个文件。你也可以在终端中输入下面这行,把它复制到你的课程账户:
cp ~cs61as/lib/calc.rkt .
注意末尾的 ’.’。它会把 .rkt 文件复制到当前目录。
READ 函数
在深入计算器之前,有一个函数我们必须知道:read 函数。当你调用 (read) 时,它会提示你输入一些内容。
> (read)
123
123
在上面的例子中,我们向解释器输入了 123。解释器显示的后一个 123 是 read 返回的值。那它有什么用呢?试试这个:
> (define a (read))
123
a
> a
123
这里,我们把 a 绑定到你输入的值。因此,当我们再次输入 123 时,就把这个值存进了变量 a。再试试下一个,看看更有趣的现象:
> (define a (read))
(+ 1 2)
a
> a
(+ 1 2)
> (equal? a '(+ 1 2))
#t
这一次,当解释器问我们想给 a 赋什么值时,我们输入了 '(+ 1 2)。a 最终的值是 '(+ 1 2),而不是 3。下一行测试 a 是否等于引用列表 '(+ 1 2)。我们能从中学到什么呢?(read) 把用户输入作为符号接收,它们不会被求值。
弄明白这一点后,我们去看计算器程序!
Calc:它如何工作?
我们来运行这个程序,看看实际发生了什么。在 Racket 解释器中加载 calc.rkt(输入 (enter! "calc.rkt")),然后调用函数 (calc):
> (calc)
calc:
注意,我们平常的提示符 ”>” 被替换成了 “calc:“。这是判断”接下来输入的表达式将由 calc.rkt 求值”的简单方法。现在,试着输入一些算术运算,如 (+ 10 20),或者像 300 这样的数字,动手玩一玩!
它怎么知道如何求值算术运算呢?我们来看看 calc 函数做了什么。它的定义复现如下:
(define (calc)
(display "calc: ")
(flush)
(print (calc-eval (read)))
(calc))
第一行 (display "calc: ") 告诉解释器在”屏幕”/输出上显示 “calc: ”。
flush 告诉解释器把我们在”屏幕”输出上输入的内容显示出来(现在可以先忽略它)。
下一行 (print (calc-eval (read))) 告诉解释器用用户输入调用 calc-eval 并打印结果。
最后一行是对 calc 的递归调用,它把我们带回起点。这就是读取-求值-打印循环(REPL,read-eval-print-loop):请求用户输入、求值、打印结果,然后循环。
这就是 calc 的全部工作。计算器的魔力发生在 calc-eval 中。
Calc:数字输入
那么 calc-eval 做什么呢?考虑我们在 calc 中输入一个数字的情况:
calc: 42
42
这个结果没什么惊喜,但在幕后,许多东西在相互作用。由于用户输入是 42,calc-eval 会以 (print (calc-eval '42)) 的形式被调用。(记住,(read) 返回一个被引用的符号。)我们来看看 calc-eval 如何处理它。它的代码复现如下。
(define (calc-eval exp)
(cond ((number? exp) exp)
((list? exp)
(calc-apply (car exp)
(map calc-eval (cdr exp))))
(else (error "Calc: bad expression:" exp))))
calc-eval 的主体是一个 cond,由于形参 exp 以 42 调用,第一个条件 (number? exp) 会满足,calc-eval 返回 exp,即 42。所有数字都以相同方式处理。这里有一个细微之处:这是基本情况。对于任何算术计算,能传来传去的最简单实参就是数字。
Calc:单个运算符
接下来我们要试的表达式是单运算符函数调用,如 (+ 1 1)、(* 2 3 10) 或 (- 100 50 20 10)。
calc: (* 2 3 10)
这会以 (print (calc-eval '(* 2 3 10))) 的形式调用 calc-eval。(再说一次,记住 read 把用户输入当作符号。)calc-eval 如何处理它呢?
测试理解
calc-eval 的代码复现如下:
(define (calc-eval exp)
(cond ((number? exp) exp)
((list? exp)
(calc-apply (car exp)
(map calc-eval (cdr exp))))
(else (error "Calc: bad expression:" exp))))
当我们调用下面这个表达式时会发生什么:
(calc-eval '(* 2 3 10))
选项:
- 它返回 60,不调用其他复合过程
- 它返回
'(* 2 3 10) - 它调用
(calc-apply '(* 2 3 10)) - 它调用
(calc-apply '* '(2 3 10)):正确!(✅)exp 是'(* 2 3 10)。它显然不是数字,所以不满足第一个条件。它满足第二个条件,因为它是一个列表。它的 car 是'*,cdr 是'(2 3 10)。因此它会调用(calc-apply '* (map calc-eval '(2 3 10))) - 它报错
Calc-Apply
我们传给 calc 的简单表达式 (* 2 3 10) 会作为 (calc-apply '* '(2 3 10)) 传进 calc-apply。接下来它做什么?下面是 calc-apply 的代码:
(define (calc-apply fn args)
(cond ((eq? fn '+) (accumulate + 0 args))
((eq? fn '-) (cond ((null? args) (error "Calc: no args to -"))
((= (length args) 1) (- (car args)))
(else (- (car args) (accumulate + 0 (cdr args))))))
((eq? fn '*) (accumulate * 1 args))
((eq? fn '/) (cond ((null? args) (error "Calc: no args to /"))
((= (length args) 1) (/ (car args)))
(else (/ (car args) (accumulate * 1 (cdr args))))))
(else (error "Calc: bad operator:" fn))))
注意,calc-apply 的形参 fn 只接受 4 个值:'+、'-、'* 或 '/。其他任何值都会导致错误。calc-apply 可以描述为”找到它是哪个函数,然后做相应的事”。在本例中,由于 fn 是 '*,calc-apply 会对 args(即 '(2 3 10))调用 accumulate,返回 60。
请说服自己:对于 fn 的 4 个可接受实参中的任何一个,以及任何数字列表 args,calc-apply 都会做正确的计算。
Calc:嵌套运算符
测试理解
我们调用一个更复杂的表达式来测试计算器程序。calc-eval 的代码复现如下:
(define (calc-eval exp)
(cond ((number? exp) exp)
((list? exp)
(calc-apply (car exp)
(map calc-eval (cdr exp))))
(else (error "Calc: bad expression:" exp))))
当我们调用下面这个表达式时会发生什么:
(calc-eval '(+ 4 5 (* 10 2) 7))
选项:
- 它返回 36,不调用其他复合过程
- 它返回
'(+ 4 5 20 7) - 它调用
(calc-apply '(+ 4 5 20 7)) - 它调用
(calc-apply '+ '(4 5 (* 10 2) 7)) - 它调用
(calc-apply '+ '(4 5 20 7)):正确!(✅)同样,exp 是一个列表,car 是'+,cdr 是'(4 5 (* 10 2) 7)。然后我们对(map calc-eval '(4 5 (* 10 2) 7))求值。我们知道对数字调用calc-eval会返回该数字。那么调用(calc-eval '(* 10 2))会发生什么呢?我们递归地求值一个表达式!这次它是一个只含单个运算符的更简单表达式。这就是数字作为基本情况的用武之地。 - 它报错
复合表达式
那么,我们的计算器程序如何求值复合表达式呢?它对更简单的表达式调用 calc-eval,并递归重复这个过程,直到表达式简单到(只是数字)可以直接返回该表达式。我们知道 calc-eval 和 calc-apply 对数字和单运算符表达式都有效。其他一切都只是组合。相信递归!
测试理解
以下哪个不可能是 calc 中的调用?
选项:
(calc-apply '+ '()):第一个选项是可能的。用无实参调用+会返回 0。(calc-apply '+ '(1 2 (+ 3 4))):正确!(✅)这个选项不可能,因为第二个实参总是经过calc-eval映射。你永远不会有未求值的表达式;它应该是'(1 2 7)。
要点总结
本小节你了解了 calc.rkt,它把一个算术表达式(运算)作为符号接收,并像一台简化的科学计算器那样对它求值。