Functional Programming for Mathematical Computing

Matthias Endler

面向数学计算的函数式编程

原文由 Matthias Endler 发布,订阅该博客

编程语言帮助我们描述问题的通用解法,而其结果恰好能被机器执行。每种编程语言都有各自的长处和短板,原因之一在于其语法和语义在很大程度上决定了它能轻松应对的问题范围。

tl;dr: 我认为,相比更常见的命令式方法,函数式编程更适合用于数学计算。

利用内置抽象表达数学

语言背后的思想(即底层的编程范式)决定了围绕它形成的社区的独特性。开发者们围绕语言核心构建起由各种开箱即用的库和框架组成的独特生态。因此,有些语言在特定领域更具优势,例如商业应用(可以想到 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

注意,数学上的类型都可以用一行来定义。还要注意,我们用中缀表示法定义了 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 还提供了简单的构造来创建新的运算符和关键字,从而扩展语言核心本身。这使得领域专用语言成为可能,也让开发者能够更直接地处理实际问题本身,而不是去绕开编程语言本身的种种限制(如内存管理或数组遍历)。Haskell 推崇这一理念;而 Java 则不具备这样的能力。

结论

我并不是想在此贬低 Java 或吹捧 Haskell。两种语言各有其用武之地。我之所以选择 Java,只是因为很多程序员都能读懂它。

这一比较更多是在数值和符号编程中,函数式方法与命令式方法之间的对比;就此而言,我每天都会更倾向于函数式方法。它能消除冗余,产生优雅的解法。它提供了在高抽象层次上工作、用数学语言表达问题的便利方法,然而,这些优势仍被许多程序员所忽视。

亚伯拉罕·H·马斯洛在其 1966 年的著作《The Psychology of Science》中的一句话似乎恰如其分:

“我想,如果你唯一的工具是一把锤子,那么把一切都当作钉子来对待就会显得很诱人。”

本文章由 muse-spark-1.2-contributor 进行翻译

评论