教授函数式编程:两种主要方法
函数式编程 (FP) 的历史与面向对象编程 (OOP) 一样悠久,甚至更久。但它直到最近才开始流行起来,尤其是在 JavaScript 社区。这是为什么呢?
我在 21 世纪初进入麻省理工学院学习。我的教材是《计算机程序结构与解释》(SICP,也就是俗称的“ sick-pee”)。所以我正式学习的第一门编程语言是函数式编程语言。之后我在工业界工作了十多年,几乎从未想过函数式编程。如今,我震惊地发现,我大学时那本我不太记得的教材竟然被誉为“函数式编程圣经”。
别误会我的意思。这是一本很好的教材。我确信它让我成为了一名更优秀的程序员。但在我的 Java/ActionScript/PHP/Python/Ruby/JavaScript 职业生涯中,FP 并不是我经常使用的东西。OOP 模式才是主流。
后来我在Wyncode 学院教了四年书,发现自己需要向新人解释一些函数式编程(FP)的概念。在一个由面向对象编程(OOP)主导的世界里,解释函数式编程(FP)非常困难。它太不一样了。
学习了OOP之后,为什么FP变得这么难?
相关问题:为什么函数式编程花了这么长时间才流行起来?为什么我不在函数式编程主导的世界中讨论学习面向对象编程的技巧?
我们编程社区需要思考,为什么从面向对象编程 (OOP) 到函数式编程 (FP) 的转变如此难以传授。像宗教一样宣扬函数式编程 (FP)只会重蹈覆辙,导致函数式编程在业界长期低迷。
很多关于函数式编程的介绍都缺少了一些东西。它不仅仅是一种另类的编程风格,而是一种全新的思维方式。当我向学生介绍一些庞大而新颖的东西时,我会尽量让他们轻松上手。这些技巧或许也适用于更有经验的面向对象编程 (OOP) 程序员。
我在Wyncode用来快速理解复杂概念的技巧之一是讲故事。如果我能让学生理解背景——也就是整体情况——之后解释技术细节就会更容易。
因此,这里有两种介绍 FP 的宏观策略 - 特别是对于 OOP 受众。
大图1:历史
有时最好从头开始:计算机是如何工作的?
最常见的(流行的?易于理解的?)计算模型是图灵机。函数式编程员所抱怨的状态,在图灵机中就摆在我们面前。操作该机器的算法表示不同状态之间的转换,例如,从某些框的开/关状态(1 或 0)到另一些框的开/关状态。
如果我们想象一下两台图灵机同时在同一段磁带上操作,我们就能理解为什么“共享状态”和面向对象编程中的并发性是难题。不过,这留待下次再讨论。
图灵机是一台通用机器。它可以解决所有可解(有效计算)的数学和逻辑问题。这一系列简单的操作——左移、右移、写入一个点、读取一个点、擦除一个点——足以(只要有足够的时间和资源)解决宇宙中的所有数学问题。这正是艾伦·图灵在1936年证明的。
从许多方面来说,图灵机就是计算机“工作”的方式。
但这也是计算机的工作原理。
这是一个加法电路。它是计算机CPU内部的一种元件。
这不是图灵机。它不是通用的。它只是个加法器。它不能(轻易地)被“重新编程”。
也没有图灵机式的“状态”。将电压施加到与待加数字对应的输入端,并检测与和对应的输出端的电压。一旦电压被切断,答案就消失了。没有“磁带”可供读取或操作。两个电路不能同时作用于同一个逻辑门。(我不认为它们可以,但我相信有人会评论来证明我错了。)
这个电路也很快。经典的图灵机在某些介质上来回翻转1和0,而这个电路以电流通过导线的速度运行。它没有任何移动部件。
电路是一种不同的计算模型。每个逻辑门(AND、OR、NAND、NOR、XOR 等)都是纯函数。它们接受输入并产生输出,没有任何副作用。如果我们拥有的只是创建和组合这些“函数”的能力,那么我们也能解决宇宙中所有可解的数学问题。这也是阿隆佐·丘奇在 1936 年证明的。
所以,我们得到了两种不同的计算模型:图灵机的 0 和 1 组成的小盒子(对象),以及阿隆佐·丘奇的由逻辑门构建的 λ 演算(函数)。哪一个是正确的?
有一段时间,人们争论抽象的图灵机是否能够解决与λ演算相同的一组数学问题(反之亦然)。最终,它们被证明是 等价的。
等价意味着它们同等强大。任何能用图灵机编写的算法,也能用函数编写。因此,任何能用图灵机软件编写的程序,也能用电路硬件来表示。
“硬件编程”是什么意思?
我们可以看到“硬件编程”在专用集成电路(ASIC)中的体现。可以创建经过“编程”的电路,使其能够快速完成某件事,例如挖掘比特币或下棋。
自从丘奇-图灵论题提出以来,我们面临两种编程选择。硬件更快,软件更慢。软件出错?直接按删除键重试就行。硬件出错?那就得拿起烙铁了。这是典型的工程设计权衡。
假设我们有一个用面向对象编程 (OOP) 风格编写的算法,想将其转换成 ASIC 芯片。用函数式编程 (FP) 风格重写该程序或许是个不错的策略,这样它就能更好地映射到电路图的领域。大多数编程语言都足够灵活地做到这一点,但有些语言在这方面做得更好。
# Elixir pipes
"1" |> String.to_integer() |> Kernel.*(2) # returns 2
许多面向函数式编程的语言往往看起来像电路。具体来说, Unix、Elixir、F#、JavaScript(或许将来会用到)以及其他编程语言中的“管道运算符”使代码看起来像电路图:输入从左侧进入,流经多个“门”(管道),直到转换为右侧的最终输出。某些语言(|>
)使用的管道运算符看起来像逻辑门,这可能并非巧合。
戴上我的编码讲师帽子,介绍 FP 的一个好的“大局”方式是首先谈论电路如何工作,如何对它们进行“编程”,以及如何在代码中建模电路图。
大局2:哲学
我主修计算机科学学位,辅修了哲学,所以我对这两个学科领域的交叉点非常感兴趣。我发现在教授新程序员时,谈论它们的交集很有帮助,尤其是对那些拥有人文学科而非STEM背景的程序员来说。
FP 中一个具有哲学意义的重要概念是“功能等价”。
也许证明这种等价性的最好例子是汤姆·斯图尔特的伟大文章“从无到有编程”。
Stuart 演示了如何完全用函数编写程序(特别是无处不在的 FizzBuzz)。我不会在这里重复整个练习,但我将借用他关于如何完全用函数表示数字的解释(即Church 编码)。
首先将零的概念定义为接受函数参数但不执行任何操作的函数。
# Ruby
ZERO = -> (func) {
# does nothing
func
}
类似地,我们可以将所有自然数定义为接受函数参数并调用n
-times 的函数。
ONE = -> (func) {
# calls it once
# same as "func.call()"
func[]
func
}
TWO = -> (func) {
# calls it twice
func[]
func[]
func
}
为了测试这些“函数编号”,请向它们传递一个测试函数。
HELLO = ->() { puts "hello" }
# same as "ZERO.call(HELLO)"
ZERO[HELLO] # nothing displayed
ONE[HELLO] # one "hello" displayed
TWO[HELLO] # "hello" twice
这种函数数字表示可能很难操作和调试。
p ZERO
# outputs #<Proc:0x000055d195ae57b0@(repl):3 (lambda)>
因此,为了更容易使用,我们可以定义一种方法,将这些功能数字转换为我们习惯的对象数字。
# convert number function into number object
def to_integer(func)
# count how many times counter is called
n = 0
counter = ->() { n += 1 }
func[counter]
n
end
p to_integer(ZERO) # 0
p to_integer(ONE) # 1
p to_integer(TWO) # 2
此转换器创建一个计数函数并将其传递给数字函数。该ZERO
函数将调用它零次,该ONE
函数将调用它一次,等等。我们会跟踪计数器被调用的次数以获取结果。
有了这些函数数定义,我们就可以实现加法。
ADD = -> (func1, func2) {
-> (f) { func1[func2[f]] }
}
sum = ADD[ZERO, ZERO]
p to_integer(sum) # 0
sum = ADD[ZERO, ONE]
p to_integer(sum) # 1
sum = ADD[ONE, ONE]
p to_integer(sum) # 2
如果TWO
调用一个函数两次,那么ADD[TWO, TWO]
将返回一个调用其参数四次的函数号(函数号FOUR
)。
这真是个烧脑的练习。当我读完《从零开始编程》时,我感觉这是巧妙运用计算机科学基础概念的有趣产物,但并非我日常工作中能用到的东西。
这正是我(以及我猜想的很多人)对函数式编程(FP)的总体看法——它很聪明,但似乎不太实用。如果我们希望让函数式编程技术更普及,这种不必要的复杂性正是我们需要解决的问题。
因此,与丘奇数相比, 《黑客帝国》是开始教授 FP 的更好的地方。
在1999年的那部科幻电影中,大多数人感知到的现实实际上是一个名为“黑客帝国”的模拟。几个月前,埃隆·马斯克提出,这个“模拟假说”可能是真的,引发了媒体连续数周就此话题展开“哲学入门”级别的讨论。
黑客帝国与 FP有何关系?
这场形而上学的争论由来已久,有时甚至复杂得令人麻木,而“模拟假说”只是其中一种回应。因此,我试图总结它,并不能完全体现其意义。但核心观点是,我们没有证据证明我们周围的世界是真实的。也许世界上存在着真实的物体,又或许我们只是罐子里的大脑。
因此,关于数字1是什么,至少存在两种相互矛盾的理论。它是我们可以与之互动(触摸和感受)的事物(名词、物体)吗?还是一种作用于世界但尚未具身化的行为(动词、函数)?
函数式 1 是对数字 1 的模拟。它在功能上等同于对象 1,这意味着它可以完成对象 1 能做的所有事情。例如,我们可以用它来进行算术运算。
但它并不像 OOP 中的对象那样真正“存在”。它只是矩阵的模拟。它没有固有属性——它不是x,它只是执行x。
举一个不那么抽象的例子,你坐的椅子是真实的,还是仅仅是压迫你身体的力量?“椅子”可能是现实世界中存在的椅子物体,也可能是一个椅子函数:一种(希望是舒适的)压迫你的力量,没有任何客观依据。
考虑一下颜色。一个红色的美味苹果真的是红色的(形容词,描述名词)还是它的行为是红色的(动词)?颜色是真实存在的苹果对象的固有属性,还是仅仅是苹果函数在光照时被编程执行的操作?苹果是真实的还是仅仅是一个模拟?
# A "real" apple
class Apple
attr_reader :color
def initialize
@color = "ruby red"
end
end
p Apple.new.color # "ruby red"
# A "simulated" apple
APPLE = -> (applied) {
return "ruby red" if applied == "light"
}
p APPLE["light"] # "ruby red"
这个哲学概念的难度恰好解释了为什么在面向对象编程 (OOP) 主导的世界中,函数式编程 (FP) 如此难以教授。为了帮助学生理解,首先要让他们了解一个完全由“函数”组成的世界的可能性。从这个宏观概念开始,然后过渡到函数式编程 (FP) 模型:它们如何与面向对象编程 (OOP) 的表示形式不同,却又能保持相同的结果。请一位经验丰富的面向对象编程 (OOP) 开发人员考虑将一个函数式编程 (FP) 重写class
为与其等价的函数式编程 (Functional)。
结论
从面向对象编程 (OOP) 过渡到函数式编程 (FP) 可能很困难。这不仅仅是一种不同的编程风格,更是另一种世界模型。我们越能帮助学生轻松适应这种范式转变,就越能避免程序员工具箱中这一有用工具在未来半个世纪内被忽视。
编辑
写作和代码一样可调试。因此,我决定澄清一下,我所介绍的教学策略是向具有面向对象编程思维的程序员介绍函数式编程 (FP)。函数式编程本身并不难,难的是范式转变需要支持。