实用主义观点下的函数式编程思想

99 阅读23分钟

讲解视频: www.bilibili.com/video/BV1hr…

函数式编程思想虽然已经进入主流开发领域,但是很多人对于它的理解还是仅限于map/flatMap等大量使用Lambda表达式的集合处理函数,似乎使用了Lambda表达式就是函数式编程。 另外有一些人会强调Haskell、Scala等支持复杂类型定义的函数式编程语言,似乎在一般的命令式语言如Java中很难真正的实现所谓的函数式编程,并进而收获函数式编程所带来的好处。 但以上观点都是片面的。因为函数式编程是一种通用的编程范式,它并不是一种语言特性,在Java语言中一样可以充分落实函数式编程思想。函数式编程的内容也很丰富,并不仅仅是使用高阶函数这么简单。

本文主要介绍在实用主义观点下使用常规的命令式语言来实践函数式编程思想的一些实践经验。文中介绍的内容主要参考如下文章:

一. 函数式编程的核心思想

如果问DeepSeek AI,函数式编程的核心思想是什么,它会做出如下回答:

函数式编程的核心思想可概括为以下要点:

  1. 纯函数 函数无副作用(不修改外部状态),输出仅由输入决定,相同输入永远返回相同结果。这使得代码可预测、易测试。

  2. 不可变数据 数据创建后不可修改,任何变更生成新数据。避免共享状态引发的并发问题,典型实现如持久化数据结构。

  3. 函数是一等公民 函数可像普通变量一样传递、组合和返回,支持高阶函数(如 map/filter)和闭包。

  4. 声明式编程 关注“做什么”而非“如何做”,通过表达式描述逻辑(如 list.map(f)),而非命令式循环控制。

  5. 递归与惰性求值 用递归替代循环迭代,依赖尾递归优化;惰性计算(如流处理)延迟执行提升效率。

  6. 函数组合与柯里化 通过组合小函数构建复杂逻辑(f(g(x)));柯里化(部分参数应用)增强复用性。

关键目标:通过数学函数式的严谨性,提升代码的模块化、可维护性和并发安全性。典型语言如 Haskell、Erlang,现代语言(如 JavaScript/Python)也吸纳了其特性。

=======DeepSeek AI创作完毕=======

二. 函数式编程的本质

如果继续追问函数式编程的本质性原因是什么,DeepSeek AI作出了如下回答:

函数式编程(FP)的本质性原因,需从数学逻辑、计算机科学哲学现实工程问题三个层面进行深度解析:

A、数学逻辑的映射:从λ演算到确定性

函数式编程的根源是数学中的λ演算(Lambda Calculus),其核心思想是:

  1. 将计算视为数学函数的映射
    • 函数是输入到输出的纯映射关系,而非带有副作用的“过程”(如命令式编程中的变量修改)。
    • 例如:f(x) = x + 1 是数学函数,而 void increment(int& x) 是命令式过程。
  2. 消除“时间”与“状态”的干扰
    • 数学函数的结果仅依赖于参数,而非外部环境或执行顺序(如全局变量、IO操作)。
    • 确定性(Referential Transparency):同一表达式在任何时间、任何上下文中求值结果一致。

本质目的:用数学的确定性对抗软件工程中的不确定性(如竞态条件、不可预测的副作用)。

B、计算机科学哲学:从“机器模型”到“数学模型”

传统命令式编程(如C、Java)基于冯·诺依曼体系,将程序视为“指令序列+内存状态”的操作,核心是如何改变状态。而函数式编程的哲学是:

  1. 将程序视为数学命题的构造
    • 程序是**表达式(Expression)**而非语句(Statement),通过组合函数推导结果,而非逐步改变状态。
    • 例如:map(f, filter(g, list)) 是声明式逻辑,而非循环计数器+临时变量。
  2. 抽象机器复杂性
    • 通过不可变数据和高阶函数,隐藏底层内存管理和执行顺序的细节,专注于逻辑本身。

本质矛盾: 软件复杂性的根源之一是状态管理(如多线程锁、全局变量污染)。函数式编程通过消除可变状态,将问题简化为“输入→输出”的静态关系,降低认知负担。

C、现实工程问题的倒逼

函数式编程的复兴(如Scala、React Hooks的流行)与现代软件复杂性直接相关:

  1. 并发与分布式系统的挑战
    • 多核CPU、分布式计算要求无共享状态(Shared-Nothing),而纯函数和不可变数据天然避免锁竞争与数据污染。
    • 案例:Erlang通过Actor模型和不可变消息传递,实现电信级高可靠系统。
  2. 软件可维护性的需求
    • 纯函数和声明式代码更易测试、推理和重构(因无隐式依赖)。
    • 案例:React中UI = f(state),通过不可变状态更新保证视图一致性。
  3. 复杂系统的可组合性
    • 函数组合(如compose(f, g))和类型系统(如Haskell的Monad)允许模块化构建复杂逻辑。

本质动力: 函数式编程通过数学的严谨性组合性,对抗软件规模扩大后的熵增(混乱度上升)。

D、深层哲学:计算的另一种世界观

函数式编程的本质是对“计算”的另一种定义

  • 命令式编程:计算是状态的逐步改变(图灵机模型)。
  • 函数式编程:计算是表达式的化简(λ演算模型)。

两种模型在理论上是等价的(Church-Turing Thesis),但实践上选择不同抽象层级:

  • 图灵机模型:更贴近物理机器的实现,但需手动管理状态。
  • λ演算模型:更贴近人类逻辑推理,用数学规则保证正确性。

总结:本质是“确定性”与“组合性”的追求

函数式编程通过以下方式重构软件范式:

  1. 用数学函数消除副作用 → 提升确定性。
  2. 用不可变数据消除时间耦合 → 简化并发。
  3. 用高阶函数和类型系统构建抽象 → 增强组合能力。

其终极目标是:将软件工程从“机械操作”升维至“数学证明”,用形式化的逻辑约束换取系统的可靠性与可维护性。

========== DeepSeek AI创作完毕 =======

DeepSeek的理解还是挺深刻的,实际上我观察很多所谓的架构师对于这些概念的理解都达到不了这个深度。

简单来说,我们可以从如下几个层面来理解函数式编程思想。

2.1. 函数概念具有普适性

函数式编程是一种独特的观察世界和构建抽象的视角。面向对象编程宣称一切都是对象,类似的,函数式编程则宣称一切都是函数。 在函数式的视角下,所谓的值a可以认为等价为 ()=>a 这个Lambda表达式

有些程序语言如Scala,支持所谓的lazy变量。这些变量使用起来像是一个值,但本质上它是一个延迟加载的函数,第一次使用该变量的之后会执行对应的函数。

在函数式的世界,计算的结果只是计算过程的一个代名词。如果需要,我们随时可以重新计算,并且每次计算都会得到同样的结果。

仅依赖函数概念,就可以完成软件世界的构建,实现所有可行的计算,这种底层世界的均一性在认识论上存在着巨大的价值,但是需要注意的时,均一性在实际应用中可能是不充分的。比如说,物理世界中所有的物质都由少数种类的原子构成,但是实际的材料特性却是千变万化的。

值是信息压缩后的高效表示

中文和阿拉伯数字中1到9都是单个字符表示,但是起源于古罗马文明并在西方广为流传的罗马数字却与此不同。罗马数字I表示1,V表示5,IV表示4,相当于用5-1来表示4,而VI表示6, 相当于是用5+1来表示6。 法语中也存在类似的现象,比如‌80 = quatre-vingts(4×20),90 = quatre-vingt-dix(4×20+10)。

可以想见,我们完全可以采用如下数字表示法:1+1表示2, 1+1+1表示3, 如此等等。这种表示法在逻辑层面没有任何问题,就是在实际使用中比较费纸。

在Lambda演算理论(函数式编程的理论基础)中,一切都是函数这个概念被推进到一种极致,以至于我们完全可以抛弃整数这种值的概念,用函数来表达整数,代价当然就是表示非常冗长。 比如3这个整数用Lambda表达式来表示,就是 λf.λx.f (f (f x)), 本质上是用f作用到x上3次这个计算过程来表达3。显然,我们习惯的整数3是一种信息压缩后的高效表示。但是在数学上,它存在着无穷多种可能的表示,这些表示的底层都隐含了3的某种计算过程。

从这个意义上说,一切都是函数明显在计算层面是不经济的,我们必须要大力发展高效的中间结果表示。

2.2 函数具有良好的数学特性

数学中的函数具有数学真理所特有的一种放之四海而皆准的确定性,而且满足结合律,从而允许我们通过分而治之的方式来利用有限的局部知识进行推理。所以如果充分利用函数的数学特性可以获得科学意义上的好处。

所谓结合律就是 a + (b + c) = (a + b) + c, 在一组计算中,我们随意增加括号,对任意的计算进行分组而不会影响到最终计算结果。分组内可以独立于外部进行计算,这意味着局部具有独立存在的价值,并可以独立被认知。

但是有趣的是,数学意义上的函数并不一定需要用具体程序语言中的一个函数去承载和表达。比如说,在Java语言中对流程模型进行建模,流程的每个步骤在数学层面上可以对应于一个函数,但是在Java中我们实际是将这个函数建模为一个步骤对象,通过StepModel去表达这个函数以及这个函数相关的元数据信息。

反过来考虑,函数式语言中的函数难道就是数学中的函数概念的合适载体吗?这也未必。数学的威力来自于它的可分析性和逻辑推演的能力,但是函数式语言中的函数对于一般的应用层而言是黑箱模型,只有底层的编译器才能分析函数的结构,而一般的应用层没有对应的分析能力。 在大数据处理模型中,表面上看起来是通过函数组合来实现功能,但核心仍然是构造一个可分析的DAG图,需要对这个图进行分析,然后重新组织逻辑结构,执行优化等。仅依赖在编译器层面暴露的低层信息有可能不足以完成相关的自动推理。

2.3 函数没有时间概念

数学对象所在的世界是一个没有时间的世界,也不存在因果约束,是一个完全自由的永恒世界。在这个世界中,永远都拥有后悔的权利,做任何事情都不会造成不可逆的影响。 需要注意的是,并不是只有数学函数才具有这种特性,而是一切数学对象都具有这种特性(比如包含逆元的差量概念使得我们可以不断叠加变化最终回到原点)。

根据命题A推导得到命题B,并不意味着命题A比命题B更基本,完全可以由命题B再反向推导得到命题A。

函数式编程中,可以认为所有函数都是并行执行的,谁先执行谁后执行,最后得到的结果都一样。因为没有值会被修改,也就不会出现竞争的情况。所以,函数式编程在分布式和并行编程领域具有独特的优势。

在命令式编程中,一般都要频繁修改状态,而我们要区分变化前和变化后就必然会引入时间(时刻t的值是X,t+1时刻赋值后值变化为Y,没有时间怎么定义变化呢?或者说正是因为我们能够识别出变化,所以才发现并定义了时间)。 所以命令式的计算总是在时间线中展开,而当多个时间线发生交叉时(使用了共享可变状态),很多时候都会引入不必要的复杂性(业务本身并没有这些复杂性,只是因为计算过程所引入)。

而在纯函数+不可变数据的函数式编程范式中,类似于量子力学的多重宇宙诠释,每个计算步骤都衍生出一个新的宇宙,所有的这些宇宙可以并行不悖,唯一的缺憾是可能会耗费大量资源。所以考虑到资源限制,数学抽象所承诺的好处并不一定能落到实处。

三. 面向对象语言中的函数式编程

详细阐述参见文章 函数式编程为什么有利于解耦(Decouple)

  1. 函数应该具有参数和返回值
  2. 尽量减少对共享数据的修改操作
  3. 使用高阶函数代替继承
  4. 函数组合的价值源于函数的结合律
  5. 解耦遍历逻辑和计算逻辑
  6. 惰性求值减少不必要的因果耦合

四. 实现框架中立的最小化信息表达

详细阐述参见 如何打破框架束缚,实现真正的框架中立性

framework agnosticism allows create technology solutions that are independent of any predefined frameworks or platforms.

首先,我们需要认识到框架中立是最终表现出来的一种结果,并不一定是我们明确追求的目标。我们应该追求的第一个目标是如何实现信息的最小化表达

最小化的表达一定是业务特定的。本质上,业务逻辑是技术中立的,它必然是可以独立于任何框架被表达、被实现,因此如果剥离了所有额外的信息,剩下的概念就只能是业务领域内部的概念。比如说

void activateCard(HttpServletRequest req){
    String cardNo = req.getParameter("cardNo");
    ...
}

void activateCard(CardActivateRequest req){
    ...
}

对比上面两个函数,第一个函数的表达不是最小化的,因为它引入了额外的Http上下文信息,而第二个函数的表达只使用了专门针对当前业务定制的CardActivateRequest对象信息。

最小化当前的信息表达,从反方向理解,就是最大化未来可能出现的信息表达。如果表达是最小化的,那么我们必然就只会描述我们希望达到的目标,而省略为了达到目标所需的各类执行细节信息,例如使用什么方式达到、按照什么样的执行顺序达到等。也就是说,我们会尽可能的延迟做出具体的技术决策,尽可能的延迟表达与具体执行相关的信息。因此,最小化的信息表达必然是描述式的,执行细节信息应该在运行时再指定,或者由底层运行时引擎根据某种最优化策略自动推导得到。

为了实现框架中立,我们需要用框架中立的方式处理以下内容:

  1. 数据(数据输入输出与存储)
  2. 控制(命令、事件等)
  3. 副作用
  4. 上下文

4.1. 数据(数据输入输出与存储)

在数学层面上理解,最小化信息表达会导致与外界相关的信息都集中在边界层中,写成公式就是

  output = biz_process(input)

4.2. 控制(命令、事件等)

事件处理传统的做法是向组件传入事件响应函数,然后在组件内部回调这个函数。这个过程与异步回调函数的处理过程本质上是一致的。目前在异步处理领域,大部分现代框架都放弃了回调函数的做法,转向了Promise抽象和async/await语法。类似的,对于事件处理,我们同样可以将事件触发抽象为一个Stream流对象,然后在output中返回这个流对象。

Callback<E> ==> Promise<E>

EventListener<E> ==> Stream<E>

4.3. 副作用

仅仅将业务逻辑与外部世界的相互纠缠理解为输入和输出,很多时候都过分简单了一些。更精细的描述可以表达为如下公式:

[output, side_effect] = biz_process(input, context)

副作用的问题是它一般具有执行语义,很容易引入对特定的运行时环境的依赖。例如,在一般的Web框架中实现文件下载,我们会写

 void download(HttpServletResponse response) {
     OutputStream out = response.getOutputStream();
     InputStream in = ...
     IoHelper.copy(in, out);
     out.flush();
 }

因为我们需要使用运行时框架所提供的response对象,导致我们的业务代码和Servlet接口绑定,这样的话我们编写的文件下载的业务代码就无法自动的兼容Spring和Quarkus这两种运行时框架了。

Quarkus一般使用RESTEasy作为Web层,它不支持Servlet接口

为了解决这个问题,一个标准化的解决方案来自于函数式编程:我们可以不实际执行副作用,而是把副作用所对应的信息封装为一个描述式对象作为返回值返回。例如,在Nop平台中,文件下载采用如下方式实现:

    @BizQuery
    public WebContentBean download(@Name("fileId") String fileId,
                                   @Name("contentType") String contentType,                                            IServiceContext ctx) {
        IFileRecord record = loadFileRecord(fileId, ctx);
        if (StringHelper.isEmpty(contentType))
            contentType = MediaType.APPLICATION_OCTET_STREAM;

        return new WebContentBean(contentType, record.getResource(),
                  record.getFileName());
    }

Nop平台的做法是并不实际执行下载动作,而是把待下载的文件包装为WebContentBean返回,然后在框架层统一识别WebContentBean,使用不同运行时框架所提供的下载机制去执行具体的下载过程。在业务层面上,我们 只需要表达“需要下载某个文件”这一意图即可,没有必要真的由自己执行下载动作

4.4. 上下文

Nop平台的做法是弱化上下文对象的行为语义,将它退化为一个通用的数据容器。具体来说,Nop平台统一使用IServiceContext来作为服务上下文对象(不同的引擎都采用这一个上下文接口,上下文与具体的运行时环境脱离),但是它没有特殊的执行语义,本质上就是一个可以随时创建和销毁的Map容器。

在前端开发领域,React Hooks机制非常巧妙的利用隐式存在的通用上下文,将生命周期函数与组件解耦, 拓展了函数这一表达形式的应用范围。

UI领域是面向对象编程的传统优势领域,此前所有基于函数式编程语言以及相关思想所构建的UI框架,都未能取得与面向对象编程类似的成功,但是React Hooks开创了新的局面,现在前端组件基本都退化为一个函数形式了。当然,在某种意义上,Hooks函数也背弃了传统的纯化要求,通过隐式传递的上下文实现了响应式的数据驱动。 (在理论层面上,Hooks类似于所谓的代数效应)。

为什么SpringBatch是一个糟糕的设计?一文中我介绍了如何将类似Hooks函数的方案推广到批处理框架中的一个设计,可以克服SpringBatch框架的种种弊病。

代数效应

以下是DeepSeek对代数效应概念的解释

代数效应(Algebraic Effects)是一种编程语言特性,允许函数声明所需操作(如状态、IO)而不处理具体实现,将副作用逻辑从主流程中解耦。其核心特征是:

  1. 声明式副作用:通过 perform 关键字标记操作(如 perform FetchData),由外部**效应处理器(handler)**接管执行;
  2. 可恢复的执行:函数暂停后,处理器完成操作(如获取数据),自动恢复原函数继续执行;
  3. 隐式上下文传递:无需手动传递依赖(如 monad 链式调用),运行时自动管理上下文关联。

对比传统错误处理(try/catch)

  • try/catch 仅处理错误且不可恢复
  • 代数效应可处理任意操作(状态、异步等),且能携带值恢复执行

示例(伪代码):

function getUser() {
  const token = perform GetToken();  // 声明需要 token
  return fetchUser(token);          // 恢复执行时 token 已由处理器提供
}

// 外部处理器提供具体实现
handle GetToken {
  resume with "xxx_token";  // 注入 token 并恢复原函数
}

价值:提升代码的组合性(自由组合效应处理器)和可维护性(分离纯逻辑与副作用)。 (如 React Hooks 通过 Fiber 架构模拟了类似模式,实现状态管理的声明式抽象。)

五. Y组合子

Y组合子(Y-combinator)是一种用于实现递归函数的技巧,它的定义如下:

Y = λf.(λx.f (x x)) (λx.f (x x))

Y组合子的作用是将一个非递归的匿名函数转换为一个递归调用的函数。比如在JavaScript中的实现

const Y = f => (x => f(v => x(x)(v)))(x => f(v => x(x)(v)));
const fact = Y(g => n => n === 0 ? 1 : n * g(n - 1));
console.log(fact(5)); // 输出 120

上面定义的fact函数等价于

function fact(n){
  if(n === 0)
    return 1;
  return n * fact(n - 1);
}

Y组合子在理论上意义重大,因为它可以在原本没有递归机制的语言中实现递归。但是因为一般的语言都直接支持命名函数引用和递归调用,所以在实践中没有什么大用。

一般对于Y组合子的推导看起来都是云山雾绕,比如这篇 Y 组合子详解 (The Y Combinator),还有这篇Y不动点组合子 zhuanlan.zhihu.com/p/100533005

大部分的Y组合子的介绍文章本质上都是在验证Y组合子的定义确实是正确的,但是无法解释为什么Y组合子一定要长成这个样子。比如说它里面包含的那个x x到底是什么意思,能改成x x x吗?

以下是我发现的一个对于Y组合子形式的启发式推导,它非常直观,并可以遵循同样的逻辑得到图灵组合子等更多的组合子,并自动提供一套构造无限多个组合子的方案。

首先,我们来看一下递归函数的基本形式:

let f = x => 函数体中用f指代自身,实现递归调用
// 例如阶乘函数
let fact = n => n < 2 ? 1 : n * fact(n-1)

上述递归函数是所谓的一阶递归函数,即定义中只引用自身导致递归的函数

我们看到,递归函数的实现体中通过函数名f引用了自身。如果我们希望消除这个对自身的引用,就必须把它转换为一个参数。从而得到

let g = f => x => 函数体中用f表示原递归函数

函数g相当于是在f的基础上加盖了一层,使它成为了一个高阶函数。因为f是任意的某个递归函数,关于函数g,我们唯一知道的就是它能作用到函数f上。

g(f) = x => 函数体中的f通过闭包变量引用了参数f

显然g(f)的返回值就是我们所需要的目标递归函数,由此我们得到了所谓的不动点方程

g(f) = f

函数g作用于参数f上,返回的结果也等于f,在这种情况下,f称作是函数g的不动点

现在我们可以制定一个构造匿名的递归函数的标准程序:

  1. 根据命名函数f定义辅助函数g

  2. 求解函数g的不动点

假设存在一个标准解法可以得到g的不动点,我们把这个解法记作Y,

f = Y g  ==>  Y g = g (Y g)

Y这个解法如果存在,它到底长什么样?为了求解不动点方程,一个常用的方法是迭代法:我们反复应用原方程,然后考察系统演进的结果。

f = g(f) = g(g(g...))

如果完全展开,则f对应于一个无限长的序列。假设这个无限长的序列可以开平方

f = g(g(g...)) = G(G) = g(f) = g(G(G))

如果存在这样的函数G,它的定义是什么?幸运的是G(G) = g(G(G))本身就可以被看作是函数G的定义

G(G) = g(G(G)) ==> G = λG. g(G(G)) = λx. g(x x)

上式中的最后一个等号对应于函数的参数名重命名,即λ演算中的所谓alpha-变换。

在G已知的情况下,Y的定义就很显然了

Y g = f = G(G) = (λx.g (x x)) (λx.g (x x))   (1)
Y = λg. (λx.g (x x)) (λx.g (x x))            (2)

上式中(1)是直接代入G的定义。而(2)是把 Y g看作是对Y的定义

 Y g = expr ==> Y = λg. expr

我们可以继续执行alpha-变换,改变参数名,从而使得Y组合子的定义成为一般文献中常见的样子。

Y = λf. (λx.f (x x)) (λx.f (x x))

进一步的介绍,参见Y组合子的一个启发式推导

具体到Y组合子为什么长成这么吓人的样子,这么复杂的东西到底是怎么想出来的?为什么要选择 f = G(G)这种分解形式? 事实的真相是,压根没有什么为什么。选择开平方完全是一个很随意的选择(或者说最简单的选择)。如果不选择开平方,还可以选择开立方f=G G G,或者选择做个夹心饼干f=G g G,如果你乐意,你甚至可以做个千层饼。同样的套路你可以反复套用,产生无限多的不动点组合子。