用Duet教授Haskell
向完全的初学者教授Haskell是一个愉快的经历。Haskell是陌生的;它的许多功能对其他程序员来说是陌生的。它是纯粹的函数式。它是不严格的。它的类型系统是其他实用语言中比较普遍的。
简单的核心
不过,Haskell的核心语言很简单。麻省理工学院的《计算机程序的结构化和解释》从函数应用的替换模型开始教授Lisp。事实证明,这对Haskell也很有效。这就是我在FP Complete为我们的客户向初学者教授Haskell的方式。
例如,在SICP中,他们使用了这个例子:
(+ (square 6) (square 10))
将函数square 减少到
(+ (* 6 6) (* 10 10))
其中通过乘法减少为:
(+ 36 100)
最后到
136
正如他们在SICP中指出的那样:
替换的目的是帮助我们思考过程的应用,而不是提供解释器真正工作方式的描述。典型的解释器并不是通过操作过程的文本来评估过程的应用,以替代形式参数的值。
这一点,这是一个模型,它不是真实的东西。事实上,如果你真的目测一下最开始的步骤,你可能会想知道(square ..) ,这两个参数之间哪个先被评估。Scheme并没有指定参数的顺序;它是变化的。一个实现甚至可以内联整个事情。
相反,如果我们用简单的重写序列来思考程序,我们就能在推理和理解方面得到很多好处。
正确的建模语言
在这一目标的激励下,我开始考虑将这一过程自动化,以便学生能够更容易地使用这一模型,并直观地看到函数和算法的形状。我想出的解决方案是一种新的语言,它是Haskell的一个子集,我将在这篇文章中介绍它。
它不是完整的Haskell的原因是,Haskell有很多表面的语法糖。评估真正的语言是复杂而不可行的。下面的内容同时包含了太多的东西需要考虑。
quicksort1 :: (Ord a) => [a] -> [a]
quicksort1 [] = []
quicksort1 (x:xs) =
let smallerSorted = quicksort1 [a | a <- xs, a <= x]
biggerSorted = quicksort1 [a | a <- xs, a > x]
in smallerSorted ++ [x] ++ biggerSorted
定义处的模式匹配,列表语法,理解性,让。这里有很多事情,让新手眼花缭乱。我们必须从简单的开始。但有多简单呢?
GHC Haskell有一个叫做Core的小语言,所有的Haskell程序都编译成这个语言。它的AST看起来大致是这样的:
data Expr
= App Expr Expr
| Var Var
| Lam Name Type Expr
| Case Expr [Alt]
| Let Bind Expr
| Lit Literal
对Core的评估很简单。然而,Core也有点太低级了,因为它把多态类型和类型类字典作为普通参数,内联了很多东西,在盒式类型下面看,如Int (进入I# ),并增加了一些正常Haskell没有的额外功能,这些功能只适合于编译器编写者看到。上述编译成Core的函数是这样开始的:
quicksort1
= \ (@ a_a1Zd) ($dOrd_a1Zf :: Ord a_a1Zd) (ds_d22B :: [a_a1Zd]) ->
case ds_d22B of {
[] -> GHC.Types.[] @ a_a1Zd;
: x_a1sG xs_a1sH ->
++
@ a_a1Zd
(quicksort1
@ a_a1Zd
$dOrd_a1Zf
(letrec {
ds1_d22C [Occ=LoopBreaker] :: [a_a1Zd] -> [a_a1Zd]
...
我们必须一次性解释特殊的列表语法、模块限定、多态类型、字典等等,此外还有一个明显的挑战,那就是无法阅读的命名规则。Core是为编译器和编译器编写者制作的,不是为人类制作的。
二重奏
因此,我采取了一种中间方式。去年我写了一个叫Duet的语言,它是在学习Haskell的这个时期专门为教学而做的Haskell子集。Duet只有这些语言特征:数据类型、类型类、顶层定义、lambdas、case表达式和一些字面符号(字符串、积分、有理数)。它的主要特点是可步入性;能够步入代码。每个步骤都会产生一个有效的程序。
回到使用新工具的SICP例子,这里是Duet中的同一个程序。
square = \x -> x * x
main = square 6 + square 10
chris@precision:~/Work/duet-lang/duet$ duet run examples/sicp.hs
(square 6) + (square 10)
((\x -> x * x) 6) + (square 10)
(6 * 6) + (square 10)
36 + (square 10)
36 + ((\x -> x * x) 10)
36 + (10 * 10)
36 + 100
136
在这里我们看到了一个正在运行的替换模型。每一行都是一个有效的程序!你可以从输出中抽取任何一行,并从该点开始运行它。
与Scheme不同,Duet为整数运算等严格的函数选择了一个参数顺序(从左到右)。
注意:你可以在家里通过创建一个文件并在Linux、OS X或Windows上使用docker运行来跟随。
折叠
让我们把注意力转向折叠的教学,这是一个典型的让新手通过的障碍,因为它是各种主题的一种强制函数。
右边的折叠在经典上是这样定义的。
foldr f z [] = z
foldr f z (x:xs) = f x (foldr f z xs)
这不是一个有效的Duet程序,因为(1)它使用了列表语法(列表并不特殊),(2)它在声明层使用了案例分析。如果你尝试替换踩点,你很快就会进入一个尴尬的对话,即看似三参数的函数foldr ,和lambdas、部分应用、currying和模式匹配之间的区别,以及我们是在定义两个函数还是一个。下面是Duet中的同一个程序。
data List a = Nil | Cons a (List a)
foldr = \f -> \z -> \l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
在讲授替换模型的最后,我介绍了\x y z是\x -> \y -> \z -> ... 的语法糖,但只是在巩固了所有Haskell函数都需要一个参数的直觉之后。它们可以返回其他函数。所以更新后的程序是:
data List a = Nil | Cons a (List a)
foldr = \f z l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
这是完全有效的Haskell,它的每一部分都可以被预测地重写。
让我们看一下foldr 与foldl 的比较。
data List a = Nil | Cons a (List a)
foldr = \f z l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
foldl = \f z l ->
case l of
Nil -> z
Cons x xs -> foldl f (f z x) xs
list = Cons 1 (Cons 2 Nil)
折叠一目了然
简单的说,我们可以像在普通的Haskell中那样使用孔,由_ 或_foo 表示。在Duet中,这些被类型系统和步进器忽略,让你在运行步进器时也有孔。它们不会导致错误,所以你可以用它们来建立表达式。
main_foldr = foldr _f _nil list
main_foldl = foldl _f _nil list
list = Cons 1 (Cons 2 (Cons 3 (Cons 3 Nil)))
(我增加了列表的大小,以获得更长更有说服力的输出。)
我们可以通过--concise ,这是一个方便的标志,用来过滤掉中间步骤(case、lambdas),这有助于我们看到 "高级 "递归。这个标志还在评估中(不是双关语),但在这里是有用的。完整的输出也值得与学生一起研究,但太长了,无法在这篇博文中体现。我将在下面附上一个非明确的例子的片段。
输出看起来是这样的:
$ duet run examples/folds-strictness.hs --main main_foldr --concise
foldr _f _nil list
_f 1 (foldr _f _nil (Cons 2 (Cons 3 (Cons 4 Nil))))
_f 1 (_f 2 (foldr _f _nil (Cons 3 (Cons 4 Nil))))
_f 1 (_f 2 (_f 3 (foldr _f _nil (Cons 4 Nil))))
_f 1 (_f 2 (_f 3 (_f 4 (foldr _f _nil Nil))))
_f 1 (_f 2 (_f 3 (_f 4 _nil)))
$ duet run examples/folds-strictness.hs --main main_foldl --concise
foldl _f _nil list
foldl _f (_f _nil 1) (Cons 2 (Cons 3 (Cons 4 Nil)))
foldl _f (_f (_f _nil 1) 2) (Cons 3 (Cons 4 Nil))
foldl _f (_f (_f (_f _nil 1) 2) 3) (Cons 4 Nil)
foldl _f (_f (_f (_f (_f _nil 1) 2) 3) 4) Nil
_f (_f (_f (_f _nil 1) 2) 3) 4
我们可以立即看到foldr的 "右边 "部分是什么意思。有经验的Haskellers已经可以看到在这一点上萌生的教学机会了。我们在这里使用了O(n)空间,建立了嵌套的thunks,或者使用了太多的栈。问题比比皆是。
同时,在foldl ,我们已经将嵌套thunks的积累转移到了foldr ,但在最后,我们仍然有一个嵌套thunk。进入严格的左折!
我们也看到了参数顺序的作用:_f 在foldr (_f 1 (foldr ...))中首先应用于1,但在foldl (_f (_f _nil 1) ...)中最后应用,这是理解两者之间区别的另一个重要部分。
严格的折叠
为了看到低层次的力学,并作为教授严格折叠的前奏,我们应该使用一个实际的算术运算(因为你不能严格评估一个_ 孔,根据定义,它是缺少的)。
main_foldr = foldr (\x y -> x + y) 0 list
main_foldl = foldl (\x y -> x + y) 0 list
两种折法最终都会产生:
1 + (2 + 0)
1 + 2
3
而且:
((\x y -> x + y) 0 1) + 2
((\y -> 0 + y) 1) + 2
(0 + 1) + 2
1 + 2
3
(这里你也可以很容易地看到0 在树上的位置)。
这两者都有上面提到的建立thunk的问题。
Duet有bang模式,所以我们可以像这样定义一个严格的折叠。
data List a = Nil | Cons a (List a)
foldr = \f z l ->
case l of
Nil -> z
Cons x xs -> f x (foldr f z xs)
foldl = \f z l ->
case l of
Nil -> z
Cons x xs -> foldl f (f z x) xs
foldl_ = \f z l ->
case l of
Nil -> z
Cons x xs ->
case f z x of
!z_ -> foldl_ f z_ xs
list = Cons 1 (Cons 2 Nil)
main_foldr = foldr (\x y -> x + y) 0 list
main_foldl = foldl (\x y -> x + y) 0 list
main_foldl_ = foldl_ (\x y -> x + y) 0 list
(我们不允许将' 作为变量名的一部分,因为这其实是没有必要的,而且对于非Haskeller的初学者来说是很混乱的。一个底核就足够了)。
现在,在没有--concise arg的情况下,在递归之前,我们看到了加法的力量。
case Cons 1 (Cons 2 Nil) of
Nil -> 0
Cons x xs ->
case (\x y -> x + y) 0 x of
!z_ -> foldl_ (\x y -> x + y) z_ xs
case (\x y -> x + y) 0 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
case (\y -> 0 + y) 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
case 0 + 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
case 1 of
!z_ -> foldl_ (\x y -> x + y) z_ (Cons 2 Nil)
foldl_ (\x y -> x + y) 1 (Cons 2 Nil)
最后,看一眼--concise ,我们看到
$ duet run examples/folds-strictness.hs --main main_foldl_ --concise
foldl_ (\x y -> x + y) 0 list
foldl_ (\x y -> x + y) 1 (Cons 2 (Cons 3 (Cons 4 Nil)))
foldl_ (\x y -> x + y) 3 (Cons 3 (Cons 4 Nil))
foldl_ (\x y -> x + y) 6 (Cons 4 Nil)
foldl_ (\x y -> x + y) 10 Nil
10
这很清楚地说明了我们现在是。(1) 直接递归,(2) 在每个递归步骤中计算累加器 (0,1,3,6,10)。
结语
这篇帖子既是我们团队的知识分享,也是一个公开的帖子,展示了我们为客户做的那种详细程度的培训。