Coursera Programming Languages, Part B 华盛顿大学 Week 3

2023-03-10,,

ML Versus Racket

函数编程模式 (with constructs that encourage a functional style)
不鼓励 mutation (但提供了支持 mutation 的 constructs),头等函数模式 (first-class functions and closures),即函数可以作为参数,返回值或与变量进行绑定

syntax
ML 特别的模式匹配 (pattern matching) 机制
Racket 的 struct 与独特的 struct-accessor functions
ML 有而 Racket 没有的 静态类型 系统 (static type system),将在下面进行详细讨论

    静态类型系统

与 Racket 不同,ML 会在编译时执行类型检查,将会"拒绝" (reject) 一些程序并报告错误,不让它们进入运行阶段

这是因为 ML enforces certain restrictions (例如 list 中的所有元素类型一致),从而保证了在编译时就能排除掉某些错误 (例如让一个整数和一个字符串相加)

首先我们站在 ML 的视角看 Racket

若我们将一些 Racket 程序转成 ML,我们会发现 ML 会拒绝一些确实存在 bug 的程序

(define (f y) (+ y (car y))

这个函数无论传入什么参数都会引起错误;而 ML 的静态类型系统可以识别到类型错误并在编译时阻止函数进入运行阶段

而 Racket 只有等运行到这里才会弹出 error

然而,除了这些,ML 也会拒绝一些在 Racket 视角下看起来 没有 bug 的程序

(define xs (list 1 #t "hi"))

在 ML 中,list 中的所有元素类型应该保持一致,因此这个程序会被拒绝

接下来,我们再用 Racket 的视角来审视一下 ML

有一种看法保留上面的观点,也就是 Racket 是 ML 的 superset,因为它可以接受某些被 ML 拒绝的程序

另外一种看法更加有趣:Racket is just ML where every expression is part of one big datatype

我们用 ML 语言来定义这个 big datatype

datatype theType = Int of int
| String of string
| Pair of theType*theType
| Fun of theType->theType
| ... (one constructor per built-in type)

也就是说,Racket 中的每个表达,都被某个 constructor 隐式 (implicitly) 地包装入这个 big datatype 中

例如 42 实际被隐式包装称为 (Int 42)。这样处理使得每个表达的结果都是 theType 类型的

在程序运行时,一些 semantic 例如 + 会检查参数的 标签 (tag),如果不合法就 raise error (以 + 为例,检查两个参数是否均为数字),反之将结果隐式包装在对应的 theType 内

由于 Racket 的这种 "secret pattern-matching" 是不对程序编写者暴露的 (is not exposed to programmers),Racket 提供了类型检查函数

pair? 为例,如果在 ML 中实现的话代码如下

fun pair? v = case v of Pair _ => true | _ => false

但是,与 ML 中的 datatype 不同,在 Racket 中,我们可以通过 structtheType 中动态添加新的 constructors

What is static checking?

Week 1 中提过,程序由编写完毕到运行的几个过程

    由 \(parser\) 语法分析器将代码转换为 \(AST\) 抽象语法树

    若程序无法被 \(parser\) 分析,弹出的错误一般是 "syntax error" 或 "parsing error" (语法错误)

    \(type-checker\) 对 \(AST\) 进行类型检查

    这一过程就是我们一般提到的静态检查 static checking!弹出的错误一般是 "type error"

    程序运行,若出现错误弹出 "run-time error"

静态检查的方式视乎这种编程语言的定义方式。有些语言完全不进行静态检查,例如 Racket

而定义一个语言的静态检查系统的最常见的方法就是通过 type system

我们熟悉的 ML 是这样定义 type system 的:

每个变量都有其类型 (Each variable had a type)
一系列类型规则 (例:条件语句的两个分支类型一致;list 所有元素类型一致等等)

ML 的 type-checker (包括其 type-inferring system) 所做的事就是检查这一系列规则是否被遵守

以上就是 ML 的 静态检查 static checking 的实现方式 (how it does it)

但并不是静态检查这一过程的目的 (the purpose of static checking),或者说效果 (what it accomplishes)

静态检查的目的是阻止 make no sense 或者 may try to misuse a language feature 的某些程序的运行,但它并不是万能的,有很多 error 都无法被静态检查检测出来

Racket 的动态检查 (dynamic checking,或者说 run-time checking) 是通过 tagging each value 实现的 (我们上面提到的 constructor 可以说是一个 tag)

而 ML 的静态检查在程序运行之前就可以排除某些有问题的程序,这样做的代价是静态检查同样可能 reject 掉某些实际上没问题的程序

ML 与 Racket 向我们揭示了,阻止某些 bug 程序一般在 compile-time 或者是 run-time

但实际上,阻止的时机并不一定只有这两个时刻,而是一个 continuum (具体看 section7sum-What is static checking,有 5 个时机)

Correctness : Soundness, Completeness, Undecidability

我们定义一个静态检查器 (static checker) 的 correctness 正确性表现在两个方面:

soundness 可靠性
completeness 完整性

对于某个我们想避免出现的错误 \(X\)

我们说一个 static checker 是可靠的 (\(sound\)) 当其能够 reject 所有对于某些输入执行 \(X\) 的程序

而一个 static checker 是完整的 (\(complete\)) 当其 accept 所有对于任意输入都 不执行 \(X\) 的程序

打个比方,我们的程序就是待检测人群,其中对于某些输入可能执行 \(X\) 的就是感染者

static checker 的目的就是在程序运行之前就检测到感染者的存在

一个可靠的 (\(sound\)) static checker 不会出现假阴性 (false negative),即虽然通过了检测,但实际上是感染者

而一个完整的 (\(complete\)) static checker 不会出现假阳性,即虽然不是感染者,却没法通过检测

现代语言的 static checker 是可靠 (\(sound\)) 但并不完整 (\(incomplete\)) 的,但我们通常会采取各种措施在不影响其可靠性的同时提高其完整性

以我们熟悉的 ML 的 static checker 为例

若我们想避免的 \(X\) 为 "string 不能作为除法运算的运算数"

fun f1 x = 4 div "hi" (but f1 never get called)
fun f2 x = if true then 0 else 4 div "hi"
fun f3 x = if x <= abs x then 0 else 4 div "hi"

以上的三个函数均不会执行 4 div "hi" 这一语句,但是 static checker 会统统弹出 type error 拒绝程序的运行

事实上,static checker 不能同时做到

它是可靠的 (sound)
它是完整的 (complete)
它不会进入死循环 (always terminate)

为了确保 \(X\) 不出现,程序员选择了可靠性而牺牲了完整性

为什么 static checker 不能同时做到可靠与完整的原因实际上牵扯到了更深层次的东西:图灵机的不可决定性 (undecidablilty)

和经典的停机问题类似,我们的 static checker 本质上也是一段程序

将问题改为停机问题的形式:是否存在这样一段程序 (这样一种 static checker),对于任意的程序与某个输入,能够判断其运行过程中能够 精确的 避免 \(X\) 的发生?

"精确的避免" 即同时避免假阳性与假阴性,也就是兼具可靠性与完整性的 static checker,显然,这样的程序是不存在的。

Weak typing

接下来我们再来介绍一个不同的概念,强类型 (\(Strongly\) \(typed\)) 语言弱类型 (\(Weekly\) \(typed\)) 语言

首先要强调,语言的强/弱类型与动态/静态类型是两个完全不同的概念

假设某个 type checker 对于某个 \(X\) 是不可靠 (unsound) 的,通常的方法是在语言实现 (language implementation) 中加入一些 dynamic check 以避免 \(X\) 被执行

但还有一种方法:认定 \(X\) 的出现是程序员本身的问题,语言根本不去 check (哈哈哈哈哈)

\(C++\) 和 \(C\) 是非常典型的弱类型语言。举一个我非常熟悉的例子:\(C++\) 中的数组越界问题

就算我们访问了不合法的数组下标如 a[-1],\(C++\) 也不会阻止程序的运行,最后我们会得到相当奇怪的结果 (原因是访问了其他的地址)

所谓强类型语言,就是在其类型系统 (type system) 中存在较多的限制 (restrictions) ,阻止潜在 bug 程序的运行

而弱类型语言的限制就相对较少

相对于强类型语言,弱类型语言省去了进行检查的时间成本,存储数据 (例如 dynamic check 所需要记录的 tag) 的空间成本

但是所付出的代价是,程序的 bug 更依靠程序员本人来检出而不能依靠强类型语言的各种 checks 来发现 (相信我们都遇到过出现奇怪 bug,最后发现是数组越界的抓狂时刻)

最后要提的是,语言类型的强和弱比起是一种定义,不如说是一种相对的概念

可以参考这个贴子,回答者讲的很好

另外中文网上好像对强/弱类型还有另外的定义,好像是基于变量的定义和类型转换规则的

More flexible primitives is a Related but Different Issue

现在,对于一个我们想避免的 \(X\) ,不同的语言会有以下不同的处理办法

static checking: static checker 在 compile-time 就将其发现并阻止程序运行,弹出 type error
dynamic checking: 在 run-time 发现 \(X\) 并中断程序运行,弹出 run-time error
weak typing: \(X\) 通过所有 checker 的检查并成功被程序执行,程序员你自己找 bug 吧

还有一些语言选择扩展某些 primitive 的语义:

例如,传统的 \(+\) 只允许两个数字进行相加,若参数中出现 string 是不合法的

而在 \(Python\) 中,"Hello" + "World" 这样的表达是合法的,返回 "HelloWorld"

这是因为 \(Python\) 扩展了 + 的语义,使其在作用与两个 string 时起到连接的作用

关于是否将 \(X\) 之类本来应当视作 bug 的语句赋予新的意义这个问题见仁见智

一方面,这样会使一些 bug 更难被发现

而一方面这样的处理方式确实给程序员带来了很多方便

Coursera Programming Languages, Part B 华盛顿大学 Week 3的相关教程结束。

《Coursera Programming Languages, Part B 华盛顿大学 Week 3.doc》

下载本文的Word格式文档,以方便收藏与打印。