Functional Programming for Mathematical Computing

Matthias Endler

数学计算中的函数式编程

编程语言帮助我们描述问题的通用解决方案;只不过最终结果恰好可以由机器执行。每种编程语言都有一组不同的优势和劣势,其中一个原因在于,它的语法和语义会极大地影响哪些问题能够用它轻松解决。

简而言之:我认为,相比更常见的命令式方法,functional programming(函数式编程)更适合数学计算。

使用内置抽象进行数学计算

一种语言背后的理念(其底层编程范式)对于围绕它形成的社区来说各具特色。开发者会围绕语言核心创建独特的、可直接使用的库和框架生态。因此,有些语言在商业应用领域更强(比如 Cobol),另一些则非常适合系统编程(如 C 或 Rust)。

说到用计算机解决数学和数值问题,人们可能会想到 Fortran。虽然 Fortran 是一种通用语言,但它主要因科学计算而闻名。当然,这门语言创立之初就是为了这个目的——因此它的名字来自 Formula Translation(公式翻译)。

它在这一领域广受欢迎的原因之一,是能够提供一些用于表达数学概念的内置领域专用关键字,同时兼顾性能。例如,它为复数提供了专用数据类型——COMPLEX——并且有一个名为 DIMENSION 的关键字。这个关键字与数学术语非常相似,可用于创建数组和向量。

命令式风格与函数式风格

内置关键字可以帮助语言将表达能力扩展到特定的问题空间,但这种方法有很大局限。无限地扩展语言核心并不可行;那样只会让维护变得更加困难,也会延长学习时间。因此,大多数语言还提供了其他抽象方式——例如函数子程序对象——将一段程序拆分成更小、更易管理的部分。这些机制可能有助于控制程序的复杂性,但尤其是在处理数学问题时,必须小心,不要让样板代码掩盖了解决方案。

示例一——阶乘

举例来说,问题可以是将下面这个计算正数 n 的阶乘的公式转换为程序代码:

阶乘的数学定义:n! = 1 * 2 * 3 … * n

使用命令式风格的 Java 实现上述公式,可能会写成这样:

public static long fact(final int n) {
    if (n < 0) {
        // Negative numbers not allowed
        return 0;
    }
    long prod = 1;
    for (int i = 1; i <= n; ++i) {
        prod *= i;
    }
    return prod;
}

对于这样一个简短的问题定义来说,这是一个相当冗长的解决方案。(注意,这里特意写成了一个从 1 到 n 的显式循环;递归函数会更短,但它使用了数学公式中没有引入的概念。)

此外,程序中包含了许多特定于语言的关键字,例如 publicstaticSystem.err.println()。不仅如此,程序员还必须为所使用的变量显式提供所有数据类型——这是一项令人厌烦的义务。

所有这些都掩盖了数学定义。

将它与下面这个用 Haskell 这样的函数式语言编写的版本进行比较:

fact n = product [1..n]

这几乎是将问题定义直接翻译成代码。它不需要显式类型、临时变量或访问修饰符(例如 public)。

示例二——点积

有人可能会认为,上面的 Haskell 程序之所以简短,是因为这门语言为该特定任务提供了恰到好处的抽象(即 product 关键字和 [1..n] 范围语法)。因此,让我们来考察一个在 Haskell 和 Java 中都没有直接提供的简单函数:两个向量的点积。其数学定义如下:

向量点积的数学定义:a·b= aibi =a1b1+a2b2+···+anbn =abT

对于三维向量,可以写成

三维向量的点积:a·b = a1 * b1 + a2 * b2 + a3* b3

首先来看 Haskell 的实现:

type Scalar a = a
data Vector a = Vector a a a deriving (Show)
dot :: (Num a) => Vector a -> Vector a -> Scalar a
(Vector a1 a2 a3) `dot` (Vector b1 b2 b3) = a1*b1 + a2*b2 + a3*b3

注意,数学类型各自都可以用一行来定义。还要注意,我们使用了中缀表示法(infix notation)来定义 dot 函数,也就是说,将 dot 的第一个参数放在函数名之前,第二个参数放在函数名之后。这样一来,代码看起来就更像数学表达式。上述函数的调用示例如下:

(Vector 1 2 3) ’dot’ (Vector 3 2 1)

这种写法简短、精确且易读。

现在来看 Java 中的类似实现。

public static class Vector<T extends Number> {
    private T x, y, z;

    public Vector(T x, T y, T z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public double dot(Vector<?> v) {
        return (x.doubleValue() * v.x.doubleValue() +
                y.doubleValue() * v.y.doubleValue() +
                z.doubleValue() * v.z.doubleValue());
        }
    }

    public static void main(String[] args) {
        Vector<Integer> a = new Vector<Integer>(3, 2, 1);
        Vector<Integer> b = new Vector<Integer>(1, 2, 3);
        System.out.println(a.dot(b));
    }
}

若要为 Vector 提供合适的文本表示,还需要重写 toString() 方法。在 Haskell 中,只需像代码中那样从 Show 类型类派生即可。

创建新的抽象

如果函数和类型不足以编写直观的程序,Haskell 还提供了简单的构造,用于创建新的运算符和关键字,从而扩展语言核心本身。这使得domain-specific language(领域专用语言)成为可能,并让开发者能够更直接地处理实际问题,而不是绕开编程语言本身的种种特性(例如内存管理或数组迭代)。Haskell 对这一理念十分支持;Java 则没有这样的功能。

结论

我并不是想在这里抨击 Java 或崇拜 Haskell。两种语言都有各自的用武之地。我之所以选择 Java,只是因为很多程序员都能读懂它。

这里比较的其实是数值编程和符号编程中的函数式方法与命令式方法;而在这方面,我无论何时都更喜欢函数式方法。它能够去除杂乱内容,带来优雅的解决方案。它提供了在高层抽象上进行工作的便捷方式,让我们可以使用数学术语进行表达,但许多程序员仍然忽视了这些优势。

Abraham H. Maslow(亚伯拉罕·H·马斯洛)在其 1966 年出版的著作 The Psychology of Science(《科学心理学》)中的观察很适合用来说明这一点:

“我想,如果你手里唯一的工具是一把锤子,那么把一切都当成钉子来处理,恐怕是很有诱惑力的。”

原文由 Matthias Endler 发布

本文章由 openai/gpt-5.6-luna 进行翻译