从第一原理看函数式编程,第1部分——动机
摘要
本文从第一原理介绍函数式编程,涵盖函数的数学定义及编程语言范式的分类。这是面向命令式编程者系列文章的第一部分。
<p><a href="https://lobste.rs/s/ajqxvq/functional_programming_from_first">评论</a></p>
查看缓存全文
缓存时间: 2026/07/27 09:43
# 从基本原则出发的函数式编程,第一部分 – 动机
来源:https://www.endoflineblog.com/functional-programming-from-first-principles-part-1-motivation
发布于 2026\-07\-26函数式编程 (https://en.wikipedia.org/wiki/Functional_programming) 被认为是四大编程语言范式之一。典型的分类将所有编程语言分为**命令式**或**声明式**。命令式语言可进一步细分为**过程式**(例如:C、Go 和 Rust)和**面向对象**(例如:Smalltalk (https://en.wikipedia.org/wiki/Smalltalk)、C++ 和 Java),而声明式语言可细分为**逻辑式**(例如:Prolog (https://en.wikipedia.org/wiki/Prolog) 和 Rocq (https://en.wikipedia.org/wiki/Rocq))和**函数式**(例如:Haskell (https://www.haskell.org/)、Elm (https://elm-lang.org/) 和 Idris (https://idris-lang.org/))。
在本系列文章中,我们将聚焦于最后这一类——函数式编程语言:它们的目的、设计,以及与其他类型语言的不同之处。我们将通过从头创建一个全新的函数式编程语言,并观察函数式编程的约束如何塑造其设计,来探索所有这些主题。
本系列文章的目标是以对熟悉命令式语言的程序员易于理解的方式介绍函数式编程概念,展示函数式方法与命令式方法之间的差异,函数式语言的优势,并说明设计函数式语言时可以做出的不同权衡。
请注意,这并非一门从零开始学习编程的课程;它假定你已经知道如何编程,并且至少熟悉一种命令式家族的语言。
## 函数的数学定义
函数式编程得名于数学中 (https://en.wikipedia.org/wiki/Function_(mathematics%29)的**函数**概念。由于这些形式化的基础,讨论函数式编程概念时往往涉及一些数学知识。不要因此气馁;数学通常并不难,而且拥有扎实的形式化基础确实有助于验证语言某个方面是否设计良好。
那么,数学中的函数是什么?它是两个集合 (https://en.wikipedia.org/wiki/Set_(mathematics%29) 之间的映射,满足两个条件:
1. 第一个集合(称为**定义域**)中的每个元素都映射到第二个集合(称为**陪域**)中的某个元素——没有元素被“遗漏”。这个条件称为**完全性**。
2. 定义域中的每个元素都映射到陪域中的**恰好一个**元素(既不能映射到零个元素——这已由完全性涵盖——也不能映射到多个元素)。这个条件称为**单值性**。
重要的是,一个函数虽然在集合上“操作”,但它本身也是一个集合(在这种情况下,它是定义函数定义域与陪域之间映射的有序对 (https://en.wikipedia.org/wiki/Ordered_pair) 的集合)。这一特性是函数可组合性的重要方面,并允许高阶函数——即接受其他函数作为参数或返回其他函数的函数。
现在,这个定义只涵盖了接受单一参数的函数。然而,我希望清楚的是,很容易将其扩展以适用于多个参数。假设我们有多个集合 `X1`、`X2`、……、`XN` 和 `Y`,我们想定义一个接受 `N` 个参数并返回 `Y` 的函数。我们可以简单地将 `N` 个集合的笛卡尔积 (https://en.wikipedia.org/wiki/Cartesian_product) 形成新集合 `X`:`X = X1 x X2 x ... x XN`,然后说我们的“`N` 参数函数”是从这个新集合 `X` 到 `Y` 的映射。
函数式编程的理念就是从这些函数构建你的程序。现在,数学函数和编程语言函数之间存在概念上的差异——后者涉及**计算**。在数学中,我们可以简单地写 `f: R -> R, f(x) = x * x`,然后“神奇地”表示一个无限对组成的集合,将每个实数映射到它的平方。但在编程中,当我们写 `f(2.3456)` 时,我们不会立即知道它“映射”到什么;我们需要使用浮点数乘法规则来计算 `2.3456` 的平方,然后才能得到结果。
然而,这种差异主要是概念上的。在某种程度上,需要一些计算才能产生结果这一事实并不重要——只要计算是确定性的,并且在有限时间内总是产生相同的结果,它基本上就等同于数学上的对的集合。在计算机科学中,我们实际上并不处理无限——所有程序都是有限的,并且在产生结果之前进行有限量的计算,所以我们并不“需要”函数在现实世界中像数学世界那样是无限的对集合。
由于其他编程语言也经常使用“函数”这一术语,但含义不那么精确,我们有时将这些类似数学的函数称为**纯函数**,以区分二者。
### 示例
让我们看几个不同集合间映射的例子,判断它们是否是函数。
在这个函数中,定义域是三个字符的集合(`A`、`2` 和 `!`),陪域是字符类型(`Letter`、`Digit` 和 `Punctuation`)。该函数将定义域中的每个字符映射到其类型。
在这个函数中,定义域是三个数字的集合(`1`、`2` 和 `3`),而其陪域是它们的符号(可以是 `Negative`、`Zero` 或 `Positive`)。该函数将定义域中的所有三个数字映射到 `Positive`,因为它们都大于零。这仍然符合函数的定义:没有要求必须使用陪域中的每个元素,也没有要求定义域中的不同元素必须映射到陪域中的不同元素。
这个例子展示了一个非函数,因为我们无法将 `Prolog` 映射到其类型,因为陪域缺少 `Logical` 编程语言。定义域中的所有元素都必须映射到陪域中的某个东西,因此这个映射违反了上述的完全性条件,从而不是一个合适的函数。
这个例子也不是一个函数。它将数字 `1`、`1/2` 和 `e` 映射到它们所属的数值集合(`Integers`、`Rationals` 和 `Reals`)。问题在于 `Integers` 是 `Rationals` 的子集(每个整数也是有理数),而 `Rationals` 是 `Reals` 的子集。因此,`1` 必须映射到所有三个集合,`1/2` 必须映射到 `Rationals` 和 `Reals`。这违反了单值性条件,因此这个映射也不是函数。
这是一个“双参数”函数的例子,代表整数的简单加法。我们有两个输入集合:`X1 = {1, 2}` 和 `X2 = {4, 7}`。这个“双参数”函数不是从 `X1` 或 `X2` 的元素到 `Y` 的映射,而是从 `X1` 和 `X2` 的笛卡尔积 (https://en.wikipedia.org/wiki/Cartesian_product) 元素——即 `{ (1, 4), (1, 7), (2, 4), (2, 7) }`(我使用 `(a, b)` 表示有序对 (https://en.wikipedia.org/wiki/Ordered_pair),因为用集合 (https://en.wikipedia.org/wiki/Ordered_pair#Kuratowski's_definition) 表达该概念的公认方式——写作 `{ {a}, {a, b} }`——可能有点难读)——到 `Y` 的映射。由于该函数表示加法,我们将 `(1, 4)` 映射到 `5`,`(1, 7)` 映射到 `8`,`(2, 4)` 映射到 `6`,`(2, 7)` 映射到 `9`。作为这个函数的集合可以写成:`{ ((1, 4), 5), ((1, 7), 8), ((2, 4), 6), ((2, 7), 9) }`。
## 函数式编程的优势
那么,我们已经得出了函数的正式定义。但这如何帮助我们创建编程语言呢?
这些“纯函数”的关键方面在于它们自然地组合。如果你有两个兼容定义域和陪域的函数,`f: X -> Y` 和 `g: Y -> Z`,你可以立即定义第三个函数 `h: X -> Z, h(x) = g(f(x))`。因此,你定义的任何函数都可以与任何其他函数(包括语言提供的任何内置函数)组合,以创建新函数。
为什么这是一种构建程序的好方法?因为它迫使你在程序中的一切事情上使用单一工具——函数组合——这种方式具有某种优雅性和规律性,而这正是命令式编程语言所缺乏的。这使你能够以更高层次进行编码——你不必关心你使用的概念(如集合和函数)是如何实现的细枝末节,而是可以使用严格的函数和(不可变的)集合以及函数组合,在高层描述程序需要完成什么(这就是函数式编程属于声明式语言家族的原因),然后语言的编译器和运行时将把那个高层描述转换为计算机执行程序所需的低层代码。
将其与像 Java 这样的典型命令式语言比较。假设你想要表达更新类中字段的行为。为了具体化这个例子,假设我们想将名为 `C` 的类中 `int` 类型字段 `f` 的值加倍。
在 Java 中有许多不同的方式来表达这一点:
1. 它可以是 `C` 类上的一个实例方法:`` class C { int f; void doubleF() { this.f *= 2; } } ``
2. 它可以是 `C` 类上的一个静态方法,接受一个 `C` 实例作为参数:`` class C { static void doubleF(C c) { c.f *= 2; } int f; } ``
3. 它可以是与 `C` 不同但位于同一包中的类上的静态方法,因此仍可访问 `f`:`` class InSamePackageAsC { static void doubleF(C c) { c.f *= 2; } } ``
4. 它可以是与 `C` 不同且位于不同包中的类上的静态方法,因此不能直接访问 `f`,但 `C` 为 `f` 定义了 `public` 的 getter 和 setter:`` class InDifferentPackageThanC { static void doubleF(C c) { c.setF(c.getF() * 2); } } ``
5. 它可以是与 `C` 不同且位于不同包中的类上的静态方法,但 `C` 是不可变的,并为 `f` 定义了一个 `with` 风格 (https://projectlombok.org/features/With) 的 setter,返回一个 `f` 已更改的 `C` 的新实例:`` class InDifferentPackageThanC { static C doubleF(C c) { return c.withF(c.getF() * 2); } } ``
6. 它可以是 `C` 上的一个实例方法,但 `C` 是不可变的,并且实现使用 `with` 风格的 setter:`` class C { final int f; // constructor and withF() method... C doubleF() { return this.withF(this.f * 2); } } ``
这是一个非常简单的例子(`doubleF` 甚至不接受任何参数),但我们已经有6种不同的方式可以在语言中表达这个简单的功能,而且你可以争辩说,前4个返回 `void` 的选项可以根据不同的潜在返回类型进一步拆分为更多的子选项(`doubleF` 应该返回 `f` 的新值,还是被更改的 `C` 的实例,还是其他什么?)。
你可能会说我的列表很愚蠢,因为在6个选项中有一个明显的解决方案,你总是会选择它。但是如果你需要交互的其他代码(比如作为你依赖项的库)对此有不同的看法呢?表达同一事物的大量选项意味着你需要了解使这些选项成为可能的所有语言特性,因为你可能被迫使用采用完全不同惯用法的 API,而不是你自己的代码所选择的方式。
## 函数式编程语言的特征
到目前为止,我们主要关注的是函数式编程的数学基础。但这如何影响函数式语言的实际特性呢?
通常情况下,是否为函数式语言并不是非黑即白的区分,而是一个光谱。随着近年来函数式编程越来越流行,来自这些语言的许多想法已经作为不同的特性被纳入命令式语言中。
我将一种语言的“函数式程度”描述为一系列级别,每个级别都要求前一个级别。一种语言达到的级别可以看作是它们的“函数式得分”;分数越高,该语言的函数式程度越高。
### 级别 1:函数作为一等值
在基本层面上,一种语言要被认为至少有点函数式,它需要支持函数作为一等值。这意味着函数应该像其他内置基本类型(如布尔值、整数或字符串)一样被对待。
实际上,这意味着函数需要满足一些条件,才能被认为在给定语言中是一等的:
1. 函数可以作为参数传递给其他函数。
2. 函数可以从其他函数返回。
3. 语言具有函数字面量(类似于数字或字符串字面量),通常称为 lambda 表达式或匿名函数。
如今,几乎所有语言,包括命令式语言,都支持上述所有三个特性。Java 曾长期是唯一的例外,但它最终在 2014 年发布的 Java 8 中获得了 lambda 表达式。
### 级别 2:不可变性
函数作为一等值之后的下一级别是不可变性。
在函数的数学定义中,没有变异的概念:你可以形成新的集合,但不能以任何方式改变现有的集合或其元素。
消除变异大大简化了集合和函数的数学模型,这种简化也扩展到了编程语言。不必考虑值在执行程序期间变化,消除了整个一大类状态管理相关的 bug。缺点是在变异可能更可取的情况下可能会带来性能损失,但函数式编程语言故意做出这种权衡,并将正确性的好处置于可能的性能损失之上。这就是为什么它们通常不适合大量状态且性能至关重要的场景,例如游戏、模拟或神经网络。
由于这些性能考虑,对不可变性的支持(与一等函数不同,一等函数在包括命令式语言在内的语言中已成为一个几乎普遍接受的特征)的差异要大得多。例如,在 Rust 中,不可变性是默认的。Java 对不可变性有相当出色的支持,它的 `final` 关键字可以应用于变量、字段和类,但可变性仍然是默认的。而其他现代命令式语言,如 Go,则不支持不可变性。
### 级别 3:纯性
可变性是一个更通用概念的特例:**副作用**。副作用的概念是,给定的一段代码(比如一个函数)被执行不是为了它计算的值,而是为了执行一些对程序其余部分产生影响的动作。这些动作的例子包括改变变量或值,但还有许多其他效果,如:读取文件、写入控制台、执行 HTTP 请求等。总的来说,任何输入/输出操作,比如从套接字读取或写入,都是副作用。
在命令式语言中,副作用通常由返回 `void` 的函数表示。主要为了其副作用而执行的函数有时被称为**过程**,尽管这个术语并不广泛使用。
纯性的想法是完全禁止执行副作用的代码。纯函数式语言只允许定义和调用那些真正是数学意义上的函数——没有副作用的函数。这意味着不能有变更、打印、读取文件等操作。这是相当严格的,大多数人也认为过于严格,这也是为什么大多数函数式语言不是纯的。唯一的例外是 Haskell(它使用一种聪明的类型系统技术来允许副作用),而 Elm 或 Erlang 等其他函数式语言则不是纯的。
相似文章
软件,从基本原理出发
一篇从基本原理出发解释计算和软件基础的文章,旨在为普通读者揭开计算机工作原理的神秘面纱。
hica中的函数式编程
hica语言中函数式编程的介绍,涵盖表达式、不可变性、纯函数、闭包以及高阶函数(如map、filter和fold)。
系统编程入门,第一部分:程序员编写程序(2025)
一篇系统编程入门文章,涵盖诸如位操作、解析、文件系统、系统调用和内存管理等基础知识,面向程序员。
无点逻辑编程
本文探讨了无点逻辑编程,这是一个与函数式编程范式相关的概念。
Prism:一种带类型效应的非纯函数式语言
Prism 是一种新型函数式语言,它结合了代数效应与类型系统,允许在没有单子的情况下使用可变状态及其他效应,同时从外部保持纯函数性。其目标是让效应成为类型系统的一等公民,从而实现优化和安全使用。