47
47
函数式编程(FP)的本质性原因,需从** 数学逻辑、计算机科学哲学** 和** 现实工程问题** 三个层面进行深度解析:
48
48
49
49
50
- ### ** 一 、数学逻辑的映射:从λ演算到确定性**
50
+ ### ** A 、数学逻辑的映射:从λ演算到确定性**
51
51
函数式编程的根源是数学中的** λ演算** (Lambda Calculus),其核心思想是:
52
52
1 . ** 将计算视为数学函数的映射**
53
53
- 函数是输入到输出的** 纯映射关系** ,而非带有副作用的“过程”(如命令式编程中的变量修改)。
58
58
59
59
** 本质目的** :用数学的确定性对抗软件工程中的** 不确定性** (如竞态条件、不可预测的副作用)。
60
60
61
- ### ** 二 、计算机科学哲学:从“机器模型”到“数学模型”**
61
+ ### ** B 、计算机科学哲学:从“机器模型”到“数学模型”**
62
62
传统命令式编程(如C、Java)基于** 冯·诺依曼体系** ,将程序视为“指令序列+内存状态”的操作,核心是** 如何改变状态** 。而函数式编程的哲学是:
63
63
1 . ** 将程序视为数学命题的构造**
64
64
- 程序是** 表达式(Expression)** 而非语句(Statement),通过组合函数推导结果,而非逐步改变状态。
69
69
** 本质矛盾** :
70
70
软件复杂性的根源之一是** 状态管理** (如多线程锁、全局变量污染)。函数式编程通过** 消除可变状态** ,将问题简化为“输入→输出”的静态关系,降低认知负担。
71
71
72
- ### ** 三 、现实工程问题的倒逼**
72
+ ### ** C 、现实工程问题的倒逼**
73
73
函数式编程的复兴(如Scala、React Hooks的流行)与** 现代软件复杂性** 直接相关:
74
74
1 . ** 并发与分布式系统的挑战**
75
75
- 多核CPU、分布式计算要求无共享状态(Shared-Nothing),而纯函数和不可变数据天然避免锁竞争与数据污染。
83
83
** 本质动力** :
84
84
函数式编程通过数学的** 严谨性** 和** 组合性** ,对抗软件规模扩大后的熵增(混乱度上升)。
85
85
86
- ### ** 四 、深层哲学:计算的另一种世界观**
86
+ ### ** D 、深层哲学:计算的另一种世界观**
87
87
函数式编程的本质是** 对“计算”的另一种定义** :
88
88
- ** 命令式编程** :计算是状态的逐步改变(图灵机模型)。
89
89
- ** 函数式编程** :计算是表达式的化简(λ演算模型)。
@@ -106,7 +106,7 @@ DeepSeek的理解还是挺深刻的,实际上我观察很多所谓的架构师
106
106
107
107
简单来说,我们可以从如下几个层面来理解函数式编程思想。
108
108
109
- ### 1. 函数概念具有普适性
109
+ ### 2. 1. 函数概念具有普适性
110
110
函数式编程是一种独特的观察世界和构建抽象的视角。面向对象编程宣称** 一切都是对象** ,类似的,函数式编程则宣称** 一切都是函数** 。
111
111
在函数式的视角下,** 所谓的值a可以认为等价为 ` ()=>a ` 这个Lambda表达式** 。
112
112
@@ -116,9 +116,20 @@ DeepSeek的理解还是挺深刻的,实际上我观察很多所谓的架构师
116
116
117
117
仅依赖函数概念,就可以完成软件世界的构建,实现所有可行的计算,这种底层世界的均一性在认识论上存在着巨大的价值,但是需要注意的时,均一性在实际应用中可能是不充分的。比如说,物理世界中所有的物质都由少数种类的原子构成,但是实际的材料特性却是千变万化的。
118
118
119
- ### 2. 函数具有良好的数学特性
119
+ #### 值是信息压缩后的高效表示
120
+ 中文和阿拉伯数字中1到9都是单个字符表示,但是起源于古罗马文明并在西方广为流传的罗马数字却与此不同。罗马数字I表示1,V表示5,IV表示4,相当于用5-1来表示4,而VI表示6, 相当于是用5+1来表示6。
121
+ 法语中也存在类似的现象,比如80 = quatre-vingts(4×20),90 = quatre-vingt-dix(4×20+10)。
120
122
121
- 数学中的函数具有数学真理所特有的确定性,且满足结合律,从而允许我们通过分而治之的方式来利用有限的局部知识进行推理。所以如果充分利用函数的数学特性可以获得科学意义上的好处。
123
+ 可以想见,我们完全可以采用如下数字表示法:1+1表示2, 1+1+1表示3, 如此等等。这种表示法在逻辑层面没有任何问题,就是在实际使用中比较费纸。
124
+
125
+ 在Lambda演算理论(函数式编程的理论基础)中,一切都是函数这个概念被推进到一种极致,以至于我们完全可以抛弃整数这种值的概念,用函数来表达整数,代价当然就是表示非常冗长。
126
+ 比如3这个整数用Lambda表达式来表示,就是 ` λf.λx.f (f (f x)) ` , 本质上是用f作用到x上3次这个计算过程来表达3。显然,我们习惯的整数3是一种信息压缩后的高效表示。但是在数学上,它存在着无穷多种可能的表示,这些表示的底层都隐含了3的某种计算过程。
127
+
128
+ 从这个意义上说,一切都是函数明显在计算层面是不经济的,我们必须要大力发展高效的中间结果表示。
129
+
130
+ ### 2.2 函数具有良好的数学特性
131
+
132
+ 数学中的函数具有数学真理所特有的一种放之四海而皆准的确定性,而且满足结合律,从而允许我们通过分而治之的方式来利用有限的局部知识进行推理。所以如果充分利用函数的数学特性可以获得科学意义上的好处。
122
133
123
134
> 所谓结合律就是 a + (b + c) = (a + b) + c, 在一组计算中,我们随意增加括号,对任意的计算进行分组而不会影响到最终计算结果。分组内可以独立于外部进行计算,这意味着局部具有独立存在的价值,并可以独立被认知。
124
135
@@ -127,7 +138,7 @@ DeepSeek的理解还是挺深刻的,实际上我观察很多所谓的架构师
127
138
反过来考虑,函数式语言中的函数难道就是数学中的函数概念的合适载体吗?这也未必。数学的威力来自于它的可分析性和逻辑推演的能力,但是函数式语言中的函数对于一般的应用层而言是黑箱模型,只有底层的编译器才能分析函数的结构,而一般的应用层没有对应的分析能力。
128
139
在大数据处理模型中,表面上看起来是通过函数组合来实现功能,但核心仍然是构造一个可分析的DAG图,需要对这个图进行分析,然后重新组织逻辑结构,执行优化等。仅依赖在编译器层面暴露的低层信息有可能不足以完成相关的自动推理。
129
140
130
- ### 3. 函数没有时间概念
141
+ ### 2.3 函数没有时间概念
131
142
132
143
数学对象所在的世界是一个没有时间的世界,也不存在因果约束,是一个完全自由的永恒世界。在这个世界中,永远都拥有后悔的权利,做任何事情都不会造成不可逆的影响。
133
144
需要注意的是,并不是只有数学函数才具有这种特性,而是一切数学对象都具有这种特性(比如包含逆元的差量概念使得我们可以不断叠加变化最终回到原点)。
@@ -136,7 +147,7 @@ DeepSeek的理解还是挺深刻的,实际上我观察很多所谓的架构师
136
147
137
148
函数式编程中,可以认为所有函数都是并行执行的,谁先执行谁后执行,最后得到的结果都一样。因为没有值会被修改,也就不会出现竞争的情况。所以,函数式编程在分布式和并行编程领域具有独特的优势。
138
149
139
- 在命令式编程中,一般都要频繁修改状态,而我们要区分变化前和变化后就必然会引入时间(时刻t的值是X,t+1时刻赋值后值变化为Y,没有时间怎么定义变化呢?)。
150
+ 在命令式编程中,一般都要频繁修改状态,而我们要区分变化前和变化后就必然会引入时间(时刻t的值是X,t+1时刻赋值后值变化为Y,没有时间怎么定义变化呢?或者说正是因为我们能够识别出变化,所以才发现并定义了时间 )。
140
151
所以命令式的计算总是在时间线中展开,而当多个时间线发生交叉时(使用了共享可变状态),很多时候都会引入不必要的复杂性(业务本身并没有这些复杂性,只是因为计算过程所引入)。
141
152
142
153
而在纯函数+不可变数据的函数式编程范式中,类似于量子力学的多重宇宙诠释,每个计算步骤都衍生出一个新的宇宙,所有的这些宇宙可以并行不悖,唯一的缺憾是可能会耗费大量资源。所以考虑到资源限制,数学抽象所承诺的好处并不一定能落到实处。
@@ -183,14 +194,14 @@ void activateCard(CardActivateRequest req){
183
194
3 . 副作用
184
195
4 . 上下文
185
196
186
- #### 1. 数据(数据输入输出与存储)
197
+ #### 4. 1. 数据(数据输入输出与存储)
187
198
在数学层面上理解,最小化信息表达会导致与外界相关的信息都集中在边界层中,写成公式就是
188
199
189
200
```
190
201
output = biz_process(input)
191
202
```
192
203
193
- ### 2. 控制(命令、事件等)
204
+ ### 4. 2. 控制(命令、事件等)
194
205
事件处理传统的做法是向组件传入事件响应函数,然后在组件内部回调这个函数。这个过程与异步回调函数的处理过程本质上是一致的。目前在异步处理领域,大部分现代框架都放弃了回调函数的做法,转向了Promise抽象和async/await语法。类似的,对于事件处理,我们同样可以将事件触发抽象为一个Stream流对象,然后在output中返回这个流对象。
195
206
196
207
```
@@ -199,7 +210,7 @@ Callback<E> ==> Promise<E>
199
210
EventListener<E> ==> Stream<E>
200
211
```
201
212
202
- ### 3. 副作用
213
+ ### 4. 3. 副作用
203
214
204
215
仅仅将业务逻辑与外部世界的相互纠缠理解为输入和输出,很多时候都过分简单了一些。更精细的描述可以表达为如下公式:
205
216
@@ -240,7 +251,160 @@ EventListener<E> ==> Stream<E>
240
251
Nop平台的做法是并不实际执行下载动作,而是把待下载的文件包装为WebContentBean返回,然后在框架层统一识别WebContentBean,使用不同运行时框架所提供的下载机制去执行具体的下载过程。** 在业务层面上,我们
241
252
只需要表达“需要下载某个文件”这一意图即可,没有必要真的由自己执行下载动作** 。
242
253
243
- ### 4. 上下文
244
- Nop平台的做法是** 弱化上下文对象的行为语义,将它退化为一个通用的数据容器** 。具体来说,Nop平台统一使用IServiceContext来作为服务上下文对象(不同的引擎都采用这一个上下文接口),但是它没有特殊的执行语义,本质上就是一个可以随时创建和销毁的Map容器。
254
+ ### 4.4. 上下文
255
+ Nop平台的做法是** 弱化上下文对象的行为语义,将它退化为一个通用的数据容器** 。具体来说,Nop平台统一使用IServiceContext来作为服务上下文对象(不同的引擎都采用这一个上下文接口,上下文与具体的运行时环境脱离),但是它没有特殊的执行语义,本质上就是一个可以随时创建和销毁的Map容器。
256
+
257
+ 在前端开发领域,React Hooks机制非常巧妙的利用隐式存在的通用上下文,将生命周期函数与组件解耦, 拓展了函数这一表达形式的应用范围。
258
+
259
+ UI领域是面向对象编程的传统优势领域,此前所有基于函数式编程语言以及相关思想所构建的UI框架,都未能取得与面向对象编程类似的成功,但是React Hooks开创了新的局面,现在前端组件基本都退化为一个函数形式了。当然,在某种意义上,Hooks函数也背弃了传统的纯化要求,通过隐式传递的上下文实现了响应式的数据驱动。
260
+ (在理论层面上,Hooks类似于所谓的代数效应)。
261
+
262
+ 在[ 为什么SpringBatch是一个糟糕的设计?] ( https://mp.weixin.qq.com/s/1F2Mkz99ihiw3_juYXrTFw ) 一文中我介绍了如何将类似Hooks函数的方案推广到批处理框架中的一个设计,可以克服SpringBatch框架的种种弊病。
263
+
264
+ #### 代数效应
265
+ 以下是DeepSeek对代数效应概念的解释
266
+
267
+ ** 代数效应** (Algebraic Effects)是一种编程语言特性,允许函数** 声明所需操作(如状态、IO)而不处理具体实现** ,将副作用逻辑从主流程中解耦。其核心特征是:
268
+ 1 . ** 声明式副作用** :通过 ` perform ` 关键字标记操作(如 ` perform FetchData ` ),由外部** 效应处理器(handler)** 接管执行;
269
+ 2 . ** 可恢复的执行** :函数暂停后,处理器完成操作(如获取数据),自动恢复原函数继续执行;
270
+ 3 . ** 隐式上下文传递** :无需手动传递依赖(如 monad 链式调用),运行时自动管理上下文关联。
271
+
272
+ ** 对比传统错误处理(try/catch)** :
273
+ - ` try/catch ` 仅处理错误且不可恢复
274
+ - 代数效应可处理任意操作(状态、异步等),且能携带值恢复执行
275
+
276
+ ** 示例** (伪代码):
277
+ ``` ocaml
278
+ function getUser() {
279
+ const token = perform GetToken(); // 声明需要 token
280
+ return fetchUser(token); // 恢复执行时 token 已由处理器提供
281
+ }
282
+
283
+ // 外部处理器提供具体实现
284
+ handle GetToken {
285
+ resume with "xxx_token"; // 注入 token 并恢复原函数
286
+ }
287
+ ```
288
+ ** 价值** :提升代码的** 组合性** (自由组合效应处理器)和** 可维护性** (分离纯逻辑与副作用)。
289
+ (如 React Hooks 通过 Fiber 架构模拟了类似模式,实现状态管理的声明式抽象。)
290
+
291
+ ## 五. Y组合子
292
+ ![ ] ( https://gitee.com/canonical-entropy/nop-entropy/raw/master/docs/theory/fp/Y-combinator.png )
293
+
294
+ Y组合子(Y-combinator)是一种用于实现递归函数的技巧,它的定义如下:
295
+
296
+ ```
297
+ Y = λf.(λx.f (x x)) (λx.f (x x))
298
+ ```
299
+
300
+ Y组合子的作用是将一个非递归的匿名函数转换为一个递归调用的函数。比如在JavaScript中的实现
301
+
302
+ ``` javascript
303
+ const Y = f => (x => f (v => x (x)(v)))(x => f (v => x (x)(v)));
304
+ const fact = Y (g => n => n === 0 ? 1 : n * g (n - 1 ));
305
+ console .log (fact (5 )); // 输出 120
306
+ ```
307
+
308
+ 上面定义的fact函数等价于
309
+
310
+ ``` javascript
311
+ function fact (n ){
312
+ if (n === 0 )
313
+ return 1 ;
314
+ return n * fact (n - 1 );
315
+ }
316
+ ```
317
+
318
+ > Y组合子在理论上意义重大,因为它可以在原本没有递归机制的语言中实现递归。但是因为一般的语言都直接支持命名函数引用和递归调用,所以在实践中没有什么大用。
319
+
320
+ 一般对于Y组合子的推导看起来都是云山雾绕,比如这篇 [ Y 组合子详解 (The Y Combinator)] ( https://mp.weixin.qq.com/s/EfGq9pfWXsu3IoHzau0D_Q ) ,还有这篇[ Y不动点组合子] ( https://zhuanlan.zhihu.com/p/100533005 ) https://zhuanlan.zhihu.com/p/100533005
321
+
322
+ 大部分的Y组合子的介绍文章本质上都是在验证Y组合子的定义确实是正确的,但是无法解释为什么Y组合子一定要长成这个样子。比如说它里面包含的那个` x x ` 到底是什么意思,能改成` x x x ` 吗?
323
+
324
+ 以下是我发现的一个对于Y组合子形式的启发式推导,它非常直观,并可以遵循同样的逻辑得到图灵组合子等更多的组合子,并自动提供一套构造无限多个组合子的方案。
325
+
326
+ 首先,我们来看一下递归函数的基本形式:
327
+
328
+ ``` javascript
329
+ let f = x => 函数体中用f指代自身,实现递归调用
330
+ // 例如阶乘函数
331
+ let fact = n => n < 2 ? 1 : n * fact (n- 1 )
332
+ ```
333
+
334
+ > 上述递归函数是所谓的一阶递归函数,即定义中只引用自身导致递归的函数
335
+
336
+ 我们看到,递归函数的实现体中通过函数名f引用了自身。如果我们希望消除这个对自身的引用,就** 必须把它转换为一个参数** 。从而得到
337
+
338
+ ``` javascript
339
+ let g = f => x => 函数体中用f表示原递归函数
340
+ ```
341
+
342
+ 函数g相当于是在f的基础上加盖了一层,使它成为了一个高阶函数。因为f是任意的某个递归函数,关于函数g,我们唯一知道的就是它能作用到函数f上。
343
+
344
+ ``` javascript
345
+ g (f) = x => 函数体中的f通过闭包变量引用了参数f
346
+ ```
347
+
348
+ 显然g(f)的返回值就是我们所需要的目标递归函数,由此我们得到了所谓的不动点方程
349
+
350
+ ``` javascript
351
+ g (f) = f
352
+ ```
353
+
354
+ 函数g作用于参数f上,返回的结果也等于f,在这种情况下,** f称作是函数g的不动点** 。
355
+
356
+ 现在我们可以制定一个构造匿名的递归函数的标准程序:
357
+
358
+ 1 . ** 根据命名函数f定义辅助函数g**
359
+
360
+ 2 . ** 求解函数g的不动点**
361
+
362
+ 假设** 存在一个标准解法可以得到g的不动点** ,我们把这个解法记作Y,
363
+
364
+ ``` javascript
365
+ f = Y g ==> Y g = g (Y g)
366
+ ```
367
+
368
+ Y这个解法如果存在,它到底长什么样?为了求解不动点方程,一个常用的方法是迭代法:我们反复应用原方程,然后考察系统演进的结果。
369
+
370
+ ``` javascript
371
+ f = g (f) = g (g (g... ))
372
+ ```
373
+
374
+ 如果完全展开,则f对应于一个无限长的序列。** 假设这个无限长的序列可以开平方**
375
+
376
+ ``` javascript
377
+ f = g (g (g... )) = G (G ) = g (f) = g (G (G ))
378
+ ```
379
+
380
+ 如果存在这样的函数G,它的定义是什么?幸运的是** G(G) = g(G(G))本身就可以被看作是函数G的定义**
381
+
382
+ ``` javascript
383
+ G (G ) = g (G (G )) ==> G = λG . g (G (G )) = λx . g (x x)
384
+ ```
385
+
386
+ 上式中的最后一个等号对应于函数的参数名重命名,即λ演算中的所谓alpha-变换。
387
+
388
+ 在G已知的情况下,Y的定义就很显然了
389
+
390
+ ```
391
+ Y g = f = G(G) = (λx.g (x x)) (λx.g (x x)) (1)
392
+ Y = λg. (λx.g (x x)) (λx.g (x x)) (2)
393
+ ```
394
+
395
+ 上式中(1)是直接代入G的定义。而(2)是把 Y g看作是对Y的定义
396
+
397
+ ```
398
+ Y g = expr ==> Y = λg. expr
399
+ ```
400
+
401
+ 我们可以继续执行alpha-变换,改变参数名,从而使得Y组合子的定义成为一般文献中常见的样子。
402
+
403
+ ```
404
+ Y = λf. (λx.f (x x)) (λx.f (x x))
405
+ ```
406
+
407
+ 进一步的介绍,参见[ Y组合子的一个启发式推导] ( https://mp.weixin.qq.com/s/ARsrYJpApqB2_72tl-MSQQ )
245
408
246
- 在前端开发领域,React Hooks机制非常巧妙的利用隐式存在的通用上下文,将声明周期函数与组件解耦, 拓展了函数这一表达形式的应用范围。
409
+ 具体到Y组合子为什么长成这么吓人的样子,这么复杂的东西到底是怎么想出来的?为什么要选择 ` f = G(G) ` 这种分解形式?
410
+ 事实的真相是,压根没有什么为什么。选择开平方完全是一个很随意的选择(或者说最简单的选择)。如果不选择开平方,还可以选择开立方` f=G G G ` ,或者选择做个夹心饼干` f=G g G ` ,如果你乐意,你甚至可以做个千层饼。同样的套路你可以反复套用,产生无限多的不动点组合子。
0 commit comments