函数式编程范式

xeonds

2023.12.11 22:00:41

对于大部分场合而言,优化不需要过早,到了需要优化的时候自然会意识到必要性。过早的优化只会带来负担。

最近JS写多了,都快不会写算法题了。其实不怪JS,只是绝大多数具体业务的实现一般很少需要用到什么重要算法。不过我遇到的问题是代码写烦了:这里写一坨那里写一坨,写一些忘一些,最后就成了一大坨,看着多但是实际功能并没多少。这让我想到了代码表达力的问题,于是我开始寻找方法去提高代码的表达力:写更少的代码,实现更多的功能。代码变得紧凑对于检查和维护来说也能降低一部分负担。

于是,我自然而然地正式接触到了函数式编程。对于当前的需求,这似乎就是答案。

介绍

下面这段是我和LLM一块写的

函数式编程(Functional Programming)是一种编程范式,区别于命令式编程关注指令和操作,函数式编程更加关注函数本身,函数的组合、副作用,流程和代码可读性。函数作为一等公民,基本上可以在任何位置上出现。它关注函数的施用,而非具体操作的实现。这种编程风格强调将数据抽象为函数,将函数作为基本构建块来构建复杂的计算。函数式编程的优势包括:可读性、可维护性、可重用性等。

首先为啥叫函数式,因为函数就是核心,一切围绕函数展开。刚上手时,我对它的印象就是

data.map(...).filter(...).reduce(...)
data.forEach(...)

这样的。只用这几个函数对数据进行操作就能完成大部分数据操作,这对于处理各种返回值得到裁剪/过滤过的目标数据而言,十分高效简洁。在这两个例子里边,代码将数据处理作为一个流程对待,我们只需要描述如何处理这个数据源,以及处理的规则,其他的都无需我们参与。同样的需求,使用命令式编程,我们需要:

let processed=[]
for(let i=0;i<data.length;i++){
    if (MATCHING_COND)
        processed.push(data[i])
}

为什么?因为上面的mapreducefilterforEach等都是高阶函数,它们可以被应用于数据源,接受一个函数作为参数,并使用函数处理数据。它们封装了常用的处理数据的流程,比如map会将数据的每一项使用传入的函数进行变换,并将它输出到新的数组中;filter会使用传入函数处理每一项,根据返回值是否为真将数据放入一个新的数组中并返回,等等。每一个高阶函数都是对于一个具体的数据处理流程的抽象描述,而这部分就是传统的命令式编程难以解决的问题——它们关注具体指令,你需要依靠具体的指令来解决你面对的问题,并自己编写具体的处理过程。

在上面传统的解决方法中,我们创建了变量processed,创建了用于循环的变量i,还使用了循环并规定了循环的结束条件,在循环体中规定了将结果加入循环的条件和流程。这些操作都在函数式操作中被抽象成了若干个高阶函数,我们无需再关心具体如何实现mapfilter等操作,更不用自己去管理其中的状态(比如循环变量和用于保存中间处理结果的变量),实现了降本增效用少量代码完成常见需求,并减少了我们花费在状态管理(循环变量的创建,自增,临时变量的创建和改变等)和具体实现上的细节,而让我们只专注于和任务本身相关的部分。

并且,我们使用传递参数的方式将它们组合在一起,而非通过继承将它们耦合在一起。这一点意味着函数式编程降低了代码的耦合度,并且提高了代码的复用率:比起来面向对象按照类,通过继承进行复用,函数式编程将复用粒度降低到函数的级别,这也显著减少了无用代码。不过继承除了复用,还有规范和约束实现的作用。函数式编程如何解决这个问题我暂时还不太清楚。不过函数式编程,面向对象编程以及元编程,这三者属于是几乎正交的关系,所以完全可以交叉使用没啥问题。

另外还有一点上面的例子并没有表现出来,那就是关于副作用这一点。这个说法应该和不可变这一点放在一起来讲:上面的循环变量i就是一个可变的变量,循环的副作用就是改变了这个变量的值。再比如C指针的经典例子:通过swap交换两个变量的值,这就是一个典型的有副作用的函数,它改变了传入参数的值。函数式编程之所以排斥这样做,就是因为它在尽量避免函数的副作用,试图将所有函数变为纯函数:函数的执行结果只依赖于输入的值,而和外部状态无关,并且不改变外部的状态。它借此消除副作用带来的不确定性。而数据的不可变是它消除不确定性的另一个方法,这里就是函数式编程的精髓了:没有可变变量要怎么迭代/处理数据?不用迭代了,用递归就行。使用递归也提供了另一个观察问题的方式,比如对上面的迭代筛选例子,我们就可以用递归来改写,从而避免使用循环变量i

let processed=[]
const iter=(data) => {
    if(data.length==0) return;
    else{
        if(data[0] MATCHES_COND)
            return data[0] + iter(data[1:]);
        return iter(data[1:])
    }
}

上面的代码没用任何可变变量,也完成了数据源的筛选。虽然初次接触不太好想,但是解决大部分问题时,另一种视角确实有时会有意想不到的便利。以及不可变变量的好处也挺多的,比如天然没有竞争和并发问题。

另外关于递归的性能问题,用尾递归可以在受到递归优化的编程语言中得到不输迭代的性能。比如经典的斐波那契数列计算函数,就可以通过尾递归的写法结合语言的优化,达到和迭代写法基本一致的性能。

下边这例子是LLM写的,暂时没验证

// 定义一个高阶函数,用于计算两个数的和
function sum(a, b) {
 return a + b;
}

// 使用高阶函数组合两个函数,用于计算两个数的和
function calculator() {
 return sum;
}

// 调用高阶函数组合器,并传入两个数字作为参数
const add = calculator();

// 调用高阶函数组合器,并传入两个数字作为参数
const subtract = calculator(a => a - b);

// 调用高阶函数组合器,并传入两个数字作为参数
const multiply = calculator(a => a * b);

// 调用高阶函数组合器,并传入两个数字作为参数
const divide = calculator(a => a / b);

// 测试计算器函数
console.log(add(2, 3)); // 输出 5
console.log(subtract(5, 2)); // 输出 3
console.log(multiply(2, 3)); // 输出 6
console.log(divide(10, 2)); // 输出 5

组合

组合和继承,都是复用代码的重要手段。组合简洁,继承稍微冗杂一些。

组合的概念就是将一些现有的单元组合到一起,形成新的工具去解决具体问题,而组合这个动作的适用对象就不止是函数那么简单了。它可以是函数的组合,可以是类型的组合,也可以是状态和函数的组合。

从某大佬那里偷来的一个说法,大多数新的语法结构就是让人们更合理地去使用固定范式的GOTO,而Algebraic Effect就是其中最合理那个佬最推崇的一个。从我的视角看来,主流编程语言的演进都是朝着可读可写且尽可能兼顾效率的方向发展的。在这个过程中,损失的一部分就是语言的”Tricks”,或者说是语言的灵活性/可能性等。比如GOTO受到的接连削弱,它从一个十分灵活的结构变成了一堆固定但是更理智的语法结构,以一部分灵活性为代价换取了语言的可读性,以此提升可维护性。

组合呢?组合也是一种对于GOTO的封装。甚至更底层的,汇编中的GOTO,(部分)也就是jmp,它在汇编中也有对应的封装:子程序。而这些说法,对于指针这个原始的内存模型中的概念同样适用。封装提高了可读性,限制GOTO的直接使用限制了代码的可写性。二者的矛盾在足够优秀的语法结构出现前基本上是一对难以调和的矛盾。