函数式编程已经有65年的历史了,但为什么根据IEEE、Tiobe等数据,所有函数式编程语言加起来的市场份额占比还不到5%?本文将描述两种原因,解释为什么函数式编程语言比其他语言更难,这使得它们难以被更广泛地采用。
攀登兰波塔山
在 2024 年的 Advent of Code 期间,我完成了 50 道题中的 39 道。F# 自称是功能第一的语言,对于解决这种类型的题目非常适合。这很有趣,也很好地帮助我学习了 F#。我的解决方案平均比其他语言的代码行数更少。我发现我的 F# 解决方案也更易读,但我想每个程序员都会觉得自己的代码更容易读。
然而,即使经过几周的F#练习后,我有时还是因为函数式编程特有的挑战而感到写代码困难,这也不是我第一次遇到这种情况。
我在大学时上过的第一门计算机科学课程是在90年代,那时课程是用Scheme教授的。Scheme是Lisp的一种简化形式。Lisp是第一个函数式编程语言,它首次发布于1960年。
在2020年,我开始开发一个新的网页应用,并花了几周的时间对Elm进行评估。Elm是一种运行在浏览器中的函数式语言。我还用Elm写了一个小的猜单词游戏。最后我选择了其他技术来构建我的应用,不过我真的很喜欢Elm。
所以至少这是我第三次尝试函数式编程语言。特别是用F#解决谜题时,两个原因变得很明显,可以明显看出为什么函数式编程语言比其他编程语言要难。
原因 1:写一个循环要难得多。循环对于代码来说简直是必不可少的。我写的第一行代码写的是 10 PRINT “JEFF IS KING”
,而第二行代码写的是 20 GOTO 10
。所以,我写的第二行代码其实就是一个循环。如果一种编程语言使编写简单的循环变得复杂,那么它将很难获得用户的青睐。
Haskell, Roc, Elm, Clojure, Elixir 和 OCaml 没有提供其他语言中常见的 while
和 for
循环。使得编写循环变得相对容易。在这些函数式语言中,你有两种选择:递归或“更高层次的函数”。
递归真的很难。不相信吗?试着向一个从未写过一行代码的新手解释。接下来试着向他解释循环。当我尝试这个实验时,我的听众很快就理解了循环,但不太能理解递归。仍然觉得递归比写循环更简单吗?如果你觉得这样,请留言——你是我遇到的第一个这么认为的程序员。在我的30年职业生涯中,我大约写过300多个递归函数,我仍然觉得写循环更容易。我喜欢在可行的情况下避免使用递归,在函数式语言中这意味着使用更高级的函数。
要在功能编程语言中不使用递归来编写循环,你需要掌握大约 50 个高级函数。 相比之下,你只需要掌握 2 或 3 个语言构造,比如 for
、while
、foreach
,就可以写循环。
这里有一些 F# 提供的高级功能[参阅 F# 文档中的链接:https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-seqmodule.html]。下面标黑的那些功能是在我 Advent of Code 中用到的。
**allPairs**
, average
, averageBy
, **choose**
, chunkBySize
, **collect**
, compareWith
, **contains**
, countBy
, except
, **exists**
, **filter**
, find
, findBack
, findIndex
, findIndexBack
, **fold**
, foldBack
, forall
, **groupBy**
, **iter**
, **iteri**
, **map**
, mapFold
, mapFoldBack
, **mapi**
, max
, maxBy
, min
, minBy
, **pairwise**
, pick
, reduce
, reduceBack
, **rev**
, **scan**
, scanBack
, skip
, **sum**
, **sumBy**
, **tryFind**
, tryFindBack
, tryFindIndex
, tryFindIndexBack
, **tryPick**
, **unfold**
, where
, windowed
, zip
.
你可能想忽略高级功能,只用递归,但这并不实际。你的队友很可能使用高级功能,你也需要阅读他们的代码。当你队友审查你的代码时,他们可能会让你用调用高级功能的代码替换递归函数。这是一个合理的请求,因为调用高级功能的代码通常比递归函数更易阅读。此外,调用高级功能的代码通常会更短一两行,而函数式编程人员喜欢玩代码高尔夫。因此,你得学大约50个函数,相比之下,学习非函数式语言你只需学3种循环结构。通过一些可疑的计算,函数式编程语言的学习难度比非函数式语言高约15倍。
即使独自编程,我也不由自主地被更高级的功能吸引。更令人惊讶的是,我的进步历程与这一过程——五个哀悼阶段——相吻合。
第一阶段:否认。我否认了递归和高阶函数的存在,继续使用for
和while
循环,因为F#和其他函数式编程语言有一点不一样,提供了这些循环,所以这对我来说很方便。
第二阶段:生气。F# 提供了关键字 for
和 while
,但没有提供关键字 break
、continue
或 return
。这让写循环变得很麻烦。在第 21 天,我想要写这样的代码:
这段代码无法编译,因为F#语言中没有return
语句。为了使代码编译通过,我只能这样写:如下:
我感到非常生气。我不得不添加了额外的变量和逻辑,仅仅为了跳出循环,因为F#并没有提供return
语句。这让我觉得F#的设计者似乎故意让我的工作变得更复杂。
第三阶段:讨价还价阶段。我通过将循环改为递归来跟F#讨价还价。递归让我可以提前跳出循环。
第四阶段:抑郁。我有些失望,因为切换到递归并没有带来任何额外的好处。递归代码避免了可变状态,有些人可能会认为这样算是有进步,但我并不这么认为,因为我的原始函数已经是一个带有循环的纯函数了。递归代码的行数与之前的 while
循环差不多,所以这也不算是个胜利。
到了接受这一步。我终于最终明白,要想真正发挥F#的作用,我必须花时间去了解50个更高级的函数。
练习F#超过60小时后,并且在研究了每一个高阶函数之后,我仍然花了20分钟,将moveIsLegal
(移动是否合法)转换成调用高阶函数的实现。
而这,比我在下面要讨论的另一个原因更重要,正是为什么人们在使用函数式编程语言时会遇到困难。我用了整整30分钟才用函数式方式写出moveIsLegal
函数,而用过程式方式只需10分钟就写完了。这是Advent of Code的第21天挑战。我已经花了至少60个小时练习F#,但仍然觉得写一个简单的循环很困难。
原因二是那些容易误导人的错误信息。
当我使用F#编写代码时出错,编译器的错误信息很难理解。虽然这些错误信息没有错,但它们并不是特别有帮助,有时甚至可能误导。我在2020年使用Elm时也有类似的体验,我相信在Haskell、Roc和OCaml中也会有类似的感受,因为它们也采用了类型推断和柯里化。
类型推导意味着你很少需要声明函数参数、函数返回值或局部变量的类型,因为编译器会根据上下文推导类型。由于代码正确,类型推导非常方便,因为代码中没有被繁琐的类型信息干扰。然而,当代码无法编译时,编译器缺乏足够的线索来理解代码的意图,因此很难给出有用的错误提示。这里有个例子,如下所示:
我的错误是什么?第11行的逗号应该是一个分号。F#编译器报告的5个错误并没有指明真正的错误所在。我经常把代码复制到ChatGPT并问它哪里错了。大多数时候,ChatGPT都帮我解决了问题。这次,ChatGPT的第二个提示给了我修正错误所需的线索。但并不是每次都能复制代码到ChatGPT。许多组织明智地禁止将源代码复制到ChatGPT。甚至ChatGPT也会告诉你不该把代码复制到ChatGPT。
柯里化 是一种编程语言特性,当你正确使用时会非常有用,但当你犯错时却难以察觉。如果你想用一个参数调用 f(a)
,会发生什么?在大多数编程语言中,编译器会报告一个错误。而在支持柯里化的语言中,编译器不会报告错误,而是在其他地方报告。在支持柯里化的语言中,f(a)
会返回一个新的接受单个参数 b
的函数。换句话说,let g = f(a); let n = g(b);
其结果等同于 let n = f(a, b);
。这意味着当你意外给一个函数传递了太少的参数时,编译器不会在调用的地方报告错误。相反,错误会在其他地方报告。这里是我犯的另一个错误:
我的错误在哪?我在第12行漏掉了给 Seq.scan
的额外参数。你能从错误信息中看出问题吗?
我并不是说函数式编程语言在本质上存在缺陷或不适合任何用途。它们有许多其他语言很少具备的独特优势。在我的经验里,用函数式语言写代码时,代码量少且错误少。如果有人想让我用.NET构建一个金融应用程序并为此付钱,我会首先问:“我能用F#吗?”
此外,我也并不是说其他编程语言通过引入函数式编程语言的特性就不会成功。Rust 有不可变状态和 tagged unions(标记联合体)。JavaScript 数组有 filter()
、map()
和 reduce()
等方法。已经出版了如《C++/JavaScript/Go 等语言中的函数式编程》之类的很多书籍。这些语言并不会因为上述提到的两点而受影响,所以引入函数式语言的特性不会影响它们的流行。
我认为……函数式编程语言比其他语言要难一些,这种难度会阻碍它们成为主流。
我也认为,功能性语言特性被引入过程式和面向对象语言中不会帮助功能性语言成为主流。我认为,编程语言演化的终点将是一个融合了各种编程范式优点的综合体。我认为不会出现一种向功能性编程发展的趋势。
开发和维护函数式编程语言的人也许并不希望他们的语言被更广泛采用。但对于支持更广泛采用的人来说,我在这里提出一些建议。
首先,在语言中加入简单的循环结构,如for
、while
、foreach
,别忘了还包括continue
、break
和return
。如果这意味着需要以某种有限的方式引入可变状态,那就这样吧。具有内部可变状态的函数仍然可以保持纯净。
其次,改进编译器的错误消息。或许可以在编译器中内置一些AI,能够更准确地识别错误并给出更清楚的错误信息。
第三,我听到一些高调的演讲者声称,函数式编程语言并不比过程式编程语言更难。这种信息适得其反。当有人听到这条信息并第一次尝试函数式编程语言时,会发生什么呢:经过几个小时的努力后,他们会觉得自己很笨,并对你和你的语言产生怀疑。没有人喜欢觉得自己很笨,他们会对你和你推广的语言感到沮丧。他们的经历让他们觉得函数式编程更难。你曾声称它不难。你所说的其他内容中有多少也是错误的?程序员在使用函数式语言时真的犯的错误更少,还是只是一个错误的说法呢?
与其说函数式编程语言不更难,,承认它们确实更难,但展示其好处如何胜过难度。F# 主页在这方面只说对了一半,因为它侧重于好处,而没有任何地方为读者准备这样一段漫长而令人沮丧的学习旅程。
前方的道路会很崎岖不平。
最后的疑惑和问题也许我说得不对。也许函数式编程语言即将迎来一个新的流行热潮。但这引出了一个问题,是什么让未来与过去的65年不同?如果不是因为处理循环和编译错误带来的额外麻烦,究竟是什么让函数式编程语言至今未能成为主流?我期待在评论中看到大家的理由。