语言:创建函数,再调用函数
本节阅读量:第四章在已有语言上增加两种表达式:
|
|
第一种产生函数值,第二种调用函数值。它们对应两个不同的时刻:
|
|
先固定三个会贯穿本章的名字:
| 名字 | 在 ((lambda x (+ x 1)) 41) 中对应什么 |
|---|---|
| parameter,形参 | lambda 中等待接收值的名字 x |
| argument,实参 | 调用时交给函数的表达式 41 |
| callee,被调用者 | 调用位置的 (lambda x (+ x 1)),求值后必须得到函数 |
lambda 的结果是值
|
|
x 是参数,(+ x 1) 是函数体。这个程序已经保存在 examples/function.lang。进入 code/04_functions 目录运行:
|
|
会看到:
|
|
不会看到 1,也不会出现 x 未定义。原因是创建函数值时根本没有执行函数体。等函数收到参数后,x 才有具体的值。
源语言没有单独的 return 语法。函数体本身就是一个表达式;调用函数时,函数体算出的值就是整个调用表达式的结果。
调用有明确的求值顺序
直接调用一个 lambda:
|
|
解释器按照下面的顺序工作:
|
|
因此这里先得到函数值,再得到参数值 41,最后在 x -> 41 的环境里计算 (+ x 1),结果是 42。
调用位置和参数位置都是普通表达式。下面的贯穿示例只是先用 let 给函数值起了名字:
|
|
let 不需要知道右边是整数还是函数。它把 inc 绑定到右边的求值结果,调用时再把这个值取出来。
函数确实可以作为普通值传递
本章说“函数是一等值”,意思是函数可以像整数一样被 let 绑定、作为实参传入、作为调用结果返回,并再次出现在 callee 位置。它仍然不是整数,不能拿去做 + 或本章的 eq?。
函数可以作为参数传给另一个函数:
|
|
外层函数收到的参数 f 本身就是函数值,所以 (f 41) 可以继续调用它,结果仍然是 42。
函数也可以返回函数:
|
|
(make 0) 的结果是内层函数,外层调用再把 41 交给它。内层函数没有使用 ignored,所以这里不需要捕获外层环境,本章的解释器和编译器都能处理。
这两个程序分别保存在 examples/function_argument.lang 和 examples/function_return.lang:
|
|
它们都会输出 42。
到这里,函数已经可以被创建、绑定、传递、返回和调用。
现在有两种值
第四章的表达式可能产生:
|
|
源语言仍然是动态类型:不要求用户书写类型标注,也不要求 if 的两个分支产生同一种值。值的种类要等表达式运行后才确定。
不同位置接受的值也不同:
|
|
本章没有定义函数相等,因此不能用 eq? 比较两个函数。
if 的真值规则则扩展为:
|
|
所以这个程序的结果是 42:
|
|
它保存在 examples/function_truthy.lang,可以运行:
|
|
解释器直接把函数值视为真;编译器中的函数值是非零的代码地址,分支也会走 then 路线。
if 仍然只求值被选中的分支,两个分支也不要求产生同一种值。例如 (if 1 (lambda x x) 0) 的结果是函数;else 分支中的整数不会被求值。
函数体的名字边界
一次调用会给函数体建立新的参数环境。函数能看到:
- 自己的参数。
- 函数体内部用
let建立的名字。
这条规则要对每个 lambda 分别判断。例如 (lambda x (lambda y (+ x y))) 的内层函数仍然需要读取外层函数的 x,因此越过了第四章的捕获边界。
例如:
|
|
调用先建立 x -> 40,函数体的 let 再临时加入 y -> 2,所以结果是 42。
本章还不能读取创建 lambda 时的外层局部变量:
|
|
lambda 里的 x 既不是参数,也不是函数体内部的绑定,它是自由变量。要让函数在离开创建位置后仍然记得 x,函数值就必须同时保存外层环境,这正是后面闭包章节要加入的能力。
当前实现没有单独的自由变量检查 pass,因此两条路径发现问题的时机不同:
|
|
这个差别在“函数没有被调用”时尤其明显:
|
|
run 会输出 <function>,因为函数体没有执行;ir 和 compile 会在处理函数体时报告 undefined variable: x。这不是闭包的另一种实现,只是解释和编译在不同阶段遇到了同一个越界程序。
解释器的类型错误不等于编译器的运行时检查
解释器会检查值的种类。例如:
|
|
用 run 执行会得到:
|
|
函数参与 + 或 eq? 时,run 也会报告相应位置需要数字。
但是,第四章的编译器还没有带标签的运行时值表示。它把整数和代码地址都当作一个机器字,也不会在间接调用前判断 callee 是否为函数。因此 (42 1) 仍可能成功生成汇编,链接后执行则可能跳到无效地址;把函数交给 + 也只会把地址当成整数运算。
所以本章讨论编译器时,把“实际执行到的 + 和 eq? 收到整数、实际执行到的 call 收到函数、不含自由变量、整数使用本章示例中的小值,并且加法不发生有符号 long 溢出”作为程序前提。这只是对示例程序的约定,不是静态类型检查器。不要把错误种类程序或极端整数程序生成的汇编行为当作语言语义。
后续的值表示会加入 tag,让机器有能力区分整数、函数引用和堆对象;但这不等于后面会自动补齐所有动态类型检查。本章只需要明确当前边界。
怎样观察最终结果
mini run 能清楚显示两种顶层结果:
|
|
编译器则把结果放在 %rax。当前生成的 main 按 C ABI 返回后,C 运行时再把它的整数返回值变成进程退出状态;shell 只能可靠观察 0 到 255 之间的小整数,更大的整数不能从 $? 原样读回。
若顶层结果是函数,%rax 中只会留下代码地址。链接后的程序不会把它打印成 <function>,进程退出状态也不能用来理解这个地址。
因此,顶层函数值用 run 观察;编译路径的示例以最终得到整数的程序为主。
主线语法
在第三章语法上新增最后两项:
|
|
普通 identifier 仍然只能由一个或多个英文字母组成。eq? 这样的拼写只在括号的 head 位置作为固定特殊形式识别。
let、if 和 lambda 也会在括号 head 位置优先按特殊形式识别;具体的名字边界和解析例子留到 parser 一节。
本章暂不支持多参数、可变参数、环境捕获,也没有 letrec 这样的具名递归构造。函数自应用在当前无类型语言里可以形成递归,但不作为本章教学内容;第五章会加入可以直接命名和调用自己的递归函数。
章末的可选 C++ runtime 实验还会只在第四章快照中加入 (cin) 与 (cout expr) 两个固定 primitive。它们不属于后续章节继承的主线语法,也不会改变普通 lambda 和普通调用仍然只有一个参数的规则。