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,它把一个算术表达式(运算)作为符号接收,并像一台简化的科学计算器那样对它求值。