Programming Languages, UW, Part A - ML Basic & FP
毫无疑问的神课,不愧于 Coursera 上接近满分的评价。Dan Grossman 教授显然对 PL 领域十分 passionate 并且有很深的造诣,在上课的过程中都能感受到他的热情和对课程倾注的心血。
这门课介绍了 PL 中的很多基本概念,尤其强调函数式编程的设计哲学;对我来说确实是一个全新的范畴。
另外,课程提供的总结性质的 material 质量很高,这篇笔记远远无法覆盖其所有的精华内容。当遇到难以理解的概念时,记得一定要去查阅原材料。
Part A is a precisely specified introduction to functional programming built up piece-by-piece, using Standard ML as an example.
This article is a self-administered course note.
It will NOT cover any exam or assignment related content.
ML Basics
绑定 (binding) 与我们熟悉的赋值语句 (assignment statement) 尽管形式相似,却有着本质的不同:最主要的区别在于 bindings 是 immutable 的,而 assignment statement 支持以赋值的形式 mutate 变量的值。
- Each binding gets type-checked and (assume it type-checks) then evaluated.
- What type of a binding has depends on a static environment or context, which is roughly the types of the preceding bindings.
- How a binding is evaluated depends on a dynamic environment or just environment, which is roughly the values of the preceding bindings.
这也就是说,if x
maps to some value v
in
some environment, it will forever map to v
in that
environment. There is NO assignment statement that changes
x
to map to a different value. 尽管你可以在之后引入新的
binding 来 shadows x
,但这并不会改变之前的环境中
x
与 v
对应的事实。
接下来,我们看看 ML 如何处理 variable bindings 与 function bindings。
Variable Bindings
variable binding 的语法 (syntax) 如下:
1 | var x = e; |
e
是一个表达式 (expression);注意表达式与值 (value)
之间的区别:A value is an expression that "has no more
computation to do". Therefore, all values are expressions, but not all
expressions are values.
- We use current static environment to type-check
e
, and (if it type-checks) addx : t
back to the static environment wheret
is the type ofe
. - We use current dynamic environment to evaluate
e
, and (if it type-checks) addx = v
back to the dynamic environment wherev
is the value ofe
.
Function Bindings
function binding 的语法如下:
1 | fun x0 (x1:t1, ..., xn:tn) = e |
Binding x0
takes arguments x1, ..., xn
of
types t1, ..., tn
and has an expression e
for
its body.
Type-checking:
- Type-check the body
e
in a static environment that (in addition to all the earlier binding) mapsx1
tot1
, ...xn
totn
andx0
tot1 * ... * tn -> t
. Sincex0
is in the environment, we can make recursive function calls, i.e., a function definition can use itself. - For the function binding to type-check, body
e
must have the typet
, i.e., the result type ofx0
. - If the function binding type-checks,
x0 : t1 * ... * tn -> t
is added to the static environment. Note that the arguments are not added to the top-level static environment.
Note that t
is never wrote down: it is up to the
type-checker to figure out what t
should be such that using
it for the result type of x0
makes everything work out.
This is called type inference.
Evaluation:
function bindings 的 evaluation 就比较 trivial 了,这是因为在 ML 中,A function is a value.
我们仅仅需要将 x0
加入 current dynamic environment
即可。As expected for recursion, x0
is in the dynamic
environment in the function body and for subsequent bindings.
Function Call
function binding 的存在是为了支持 function calls。它的语法如下:
1 | e0 (e1,...,en) |
Typing rules:
若 e0
的类型为 t1 * ... * tn -> t
且
ei
的类型为 ti
,则整个函数调用的类型为
t
.
Evaluation rules:
我们使用调用处的环境 (environment at the point of the
call) 来 evaluate e0
to v0
,
e1
to v1
, ..., en
to
vn
. 若 v0
是函数,则函数调用是 type-checked
的。
接下来,我们使用定义处的环境 (environment where the function
is defined) extends 函数参数形成的 bindings
x1 = v1, ..., xn = vn
来 evaluate 函数体。
First-Class Function
在函数式编程体系中,函数也是第一等公民:这是因为 All functions are values。函数就像所有其他类型的值一样,可以作为 higher-order 函数的参数,返回值,甚至可以被存储。
函数的 first-class 性质使得 function closure 的概念被提出,并进一步催生了匿名函数 (anonymous function),高阶函数 (higher-order function),函数的 combination,callback 与 currying 等机制。
一些典型的函数比如 map
, filter
,
fold
等在函数式编程体系中可以很方便的实现。
到底什么是函数式编程 (functional programming)?实际上,对此并没有一个准确的定义。但一般来说,函数式编程语言有以下特点:
- 函数是第一等公民:using functions as values.
- NOT using mutable data in most or all cases.
- 函数式编程鼓励使用递归 (recursive) 与递归数据结构。(支持尾递归优化)
- 函数式编程中的 function 最贴近数学中对函数的原始定义。
- 使用 laziness 的特性。
Anonymous Function
匿名函数是没有名字的函数,因此其可以方便的作为其他函数的参数或者返回值。但这也使得它无法进行递归。原因在于:evaluate
function bindings 时需要将函数的名字 x0
加入环境以实现后续的递归。
1 | (fn x => e) |
我们可以把关键字 fun
视为 syntactic
sugar:下面这两个表达其实是一致的。
1 | fun x = e |
在使用匿名函数时,要注意避免 unnecessary function wrapping:
1 | fun nth_tail_poor (n, x) = n_times((fn y => tl y), n, x) |
Currying
在 ML 中,每一个函数只有一个参数;多参数函数的形参本质上是一个包含多个变量的 tuple。
然而除此之外,有另一种方式可以实现所谓的多参数,那就是函数的 currying。即,通过匿名函数的嵌套实现类多参数函数结构,再通过语法糖进行等价转换。
注意第三条语句是第二条语句的 syntactic sugar,三个函数的作用完全相同。
1 | fun p(x, y, z) = e |
通过 currying,我们还能实现函数的部分化应用 (partial application),从而消除 redundant arguments。
1 | fun curry_p x y z = ... z |
基于 tuple 实现的多参数函数可以与 currying 多参数函数相互转换。
1 | fun uncurry f x y = f(x, y) |
Lexical Scope
在 ML 中,函数的 evaluation 遵循这样一条铁律 (之前在 function binding 部分中也介绍过):
也就是说,函数体的 evaluation 依赖的是函数各个函数 bindings 与被定义时的环境。我们通过一个 silly example 来认识这一点。
1 | val x = 1 |
变量 z
的值是 6,而不是 5。在调用函数 f
的环境中,x
的值为 2,y
的值为
3,因此函数的参数 evaluates to 5。而在 evaluate
函数体时,我们参照的是函数 f
定义时的环境:在那里
x
还未被 shadow,其值为 1。而参数 y
又与 5 绑定,最后计算出来的值为 1 + 5 = 6。
这样的 semantics,就是词法作用域 lexical scope。比较来说,函数体与参数均在调用处的环境被 evaluate 的机制更加符合直觉,这种与 lexical scope 对立的 semantics 称为动态作用域 dynamic scope。
早期的编程语言大多是遵循 dynamic scope 的,但是很快程序员就发现了其致命问题:变量的 shadowing 常常会与这一机制产生冲突。一个最典型的例子如下:
1 | var x = 3 |
这一程序是 type-checked 的:x : int
,
f : null -> int
, x (shadow) : string
,
y : int
;但在 dynamic scope 中,y = 3 +
"hello",这破坏了类型一致性。而在 lexical scope
中这一程序则不会出现问题。
dynamic scope 还会导致许多其他的问题 (见 section3sum),因此现在的绝大多数语言默认都采用 lexical scope 来作为函数 evaluation 的准则。但 dynamic scope 并没有完全消失:
- 少数语言 (如 Racket) 仍然提供对 dynamic scope 的支持。
- 一些特殊的编程 feature (如 exception) 遵循的是 dynamic scope。
Environments and Closures
我们一直强调在函数式编程中,All functions are values。那么这个“值”具体是什么呢?
- the code for the function.
- the environment that was current when we created the function.
也就是说,function value 包括 code 与定义时的 environment 两部分;这个 pair 被称为函数闭包 function closure 或者闭包 closure。闭包的定义也是符合词法作用域 lexical scope 的。
我们不能单独访问 pair 中的任意一部分,对于 function value,我们能够执行的操作只有调用 call。这其实也是一种 encapsulation。我们能够发现,the closure overall is closed —— it has everything it needs to produce a function result given a function argument.
1 | var x = 3 |
在上例中,the binding fun f y = x + y
bound
f
to a closure.
- the code part is the function
fn y => x + y
. - the environment part maps
x
to 1.
在函数 f
闭包创建以后,无论在哪调用 f
最终返回的值都是 4。闭包保证了 evaluate
该函数的环境与调用它时的环境完全隔绝:这是对闭包的另一种理解。
注意,lexical scope 词法作用域是目前大多数编程语言遵循的 semantics;然而 closure 并不是所有语言都具有的;closure 是 function 的 value,因此其在函数式编程语言中是最常见的。
Type Inference
type inference 是 ML 一个十分优雅的特性。在我接触过的所有 (极其有限的) 语言中从未见过这个 feature。
Java, C, ML 都是 statically typed languages。与之相对的是 dynamically typed language,例如 Racket, Ruby, Python。动态类型语言在运行时才会确定 bindings 的类型;因此在这些语言中类型错误是一种 run-time error 而非 compile-time error。
与 Java 和 C 又不同,ML 的类型是隐式 (implicitly typed) 的,即我们几乎不需要声明 binding 的类型。这个 feature 真的非常的厉害,它使得:
- binding 声明的流程大大简化;我们可以完全把类型的问题抛在一边。
- 优雅的支持参数多态 (parametric polymorphism)。
这样做的代价就是,ML 的 type-checker 远比其他显式类型语言的复杂。在程序员不具体声明 binding 类型的前提下,type-checker 需要根据代码与 bindings 之间的关系推断 (infer) 出所有可能的 type annotations。如果不存在任何一种解,type-checker 将会拒绝 (reject) 该程序。
注意,虽然 type inference 与多态联系紧密,但它们终究是两个独立的概念:举例来说,Java 支持泛型 (generics types),但它是一种显式类型语言,不支持类型推断。
Undecidability
When we say type inference may be impossible, we mean this is the technical sense of undecidability, like the famous halting problem.
对于某些 type system,不存在一个有效的 type inference 能够同时满足以下三个条件:
- the inference process always terminates.
- the inference process always succeeds if inference is possible.
- the inference process always fails if inference is not possible.
关于这一部分的详细内容将会在 P2 中介绍;由于 type inference 系统是 static checker 的一部分,这牵涉到 static checker 的正确性 (correctness) 问题。
幸运的是,ML 的 type inference 算法设计得非常简洁与优美;虽然存在 non-trivial 的程序能够使得 inference 效率变得非常低,但在现实应用背景下这种情况几乎不可能发生。
Overview of ML Type Inference
ML 的 type inference 大致遵循以下规则:
- It determines the types of bindings in order, using the types of earlier binding to infer the types of later ones. This is why you cannot use later bindings in a file. (When you need to, use mutual recursion and type inference determines the types of all mutually recursive bindings together)
- For each
val
andfun
binding, it analyzes the binding to determine necessary facts about its type. We gather facts for function calls, pattern-matches, etc. - Afterward, use type variable (e.g.,
'a
) for any unconstrained types in function arguments or results. - Enforce the value restriction such that only variables and values can have polymorphic types.
ML type inference 的这种 "going in order" 的优美设计使得其不会 reject 任何一个能够 type-check 的程序,同时也不会 accept 一个不能 type-check 的程序。因此,explicit type annotations really are optional.
1 | fun length xs = |
我们来看这个例子:type inferencer 开始按照顺序进行类型推断。
length
has typeT1->T2
.xs
has typeT1
. (参数)T1=T3 list
. (根据case...of
的 pattern matching 得出)T2=int
. (函数返回值可以为 0)x
has typeT3
andxs'
has typeT3 list
.
至此,我们已经获得了所有变量的类型。最后 type inferencer 对于所有的
unconstrained types 统一的分配 type
variables:T3='a
, T1=T3 list='a list
.
对于函数 length
的类型推断完成:length: 'a list->int
.
type variable 'a
indicates itself as a polymorphic
datatype (or generic type). 因此,函数 length
体现了
parametric polymorphism:它的参数类型是一个多态类型。
更多的例子,以及关于 value restriction
的相关介绍见课程提供的
section3sum.pdf
。在之后,它还介绍了在 ML 中使用 signature
实现抽象的方式。但是这一部分内容并不是 functional programming 的核心
feature,在此按下不表。
Reference
This article is a self-administered course note.
References in the article are from corresponding course materials if not specified.
Course info:
Programming Languages, Part A, University of Washington, Lecturer: Professor Dan Grossman.