Featured image of post 函数式编程大辨析

函数式编程大辨析

决定写这篇博客,是因为在过去的一年时间,不知为何,我经常与「函数式编程」打交道,调研静态分析工具、学习Go语言、实习中遇到的C++代码以及现在正在学习的响应式范式,函数式编程真的无处不在。从初次接触开始,我就对这种优雅、强大的编程范式非常着迷。但是因为大脑先入为主地接受了面向对象之类的思想,所以接受函数式体系中各种概念对我会很困难,在胡思乱想的过程中,急需理顺一些概念,完成一些辨析

综上,这是一篇科普性质的博客,以我较为熟悉的语言为载体,结合之前的一些学习笔记,记录我这个小蒟蒻对函数式编程在意的点,尽量不犯事实型错误,希望激励自己深入学习的同时也可以帮到大家😸️

啥是函数式编程

函数式编程(Functional Programming, FP)是一种编程范式。面向过程or指令的编程思想通常是强调一行代码要去做什么(what)或者怎么做(how),强调过程,函数是封装一些指令的工具。而函数式更加关注执行的结果,行为和数据是分离的,程序中所有的一切都可以理解为函数。

这种表达可能有点抽象,但是我暂时无法想出一个精妙的比喻来更好地理解这一切,所以先这样吧!总之,可以把函数式编程理解为推导出一大串嵌套的f(x)式的数学公式,最后再代数求解。

理解大概的思想之后,可能才能理解,为什么后续提到的一些概念会和函数式编程紧密关联。

但是首先这里要明确一个概念: 函数式编程语言 != 函数式编程思想(风格)。比如说像OCaml, Haskell等语言就是函数式编程语言,C++和java中虽然支持使用Lambda表达式,也可以写出函数式风格的代码,但是并不是函数式编程语言。这个后面会详细说明。

函数式的特征

最大的两个特征就是 不可变性(immutability)避免了副作用(side effect)

  • 不可变性: 在函数式编程中,数据结构被视为不可变的,一旦数据被创建,它的值就不能被修改。如果需要对数据进行更改,通常是创建一个新的数据结构,而不是在现有数据上进行修改,即我们不会去改变变量的值,而是通过函数的返回值来改变变量的值。
  • 副作用: 函数的返回值不仅仅取决于函数的参数,还取决于函数的执行过程中,对外部变量的影响。如果一个函数本身影响到了外部变量,那么这个函数就是有副作用的。所以函数式编程很好地规避了这一点。

为了更好地理解,可以看一下这个斐波那契数列的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let rec fib n =
  match n with
  | 0 -> 0
  | 1 -> 1
  | _ -> fib (n - 1) + fib (n - 2)

let () =
  for i = 0 to 9 do
    Printf.printf "fib(%d) = %d\n" i (fib i)
  done
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
#include <vector>

long long fib_iterative(int n) {
    if (n <= 0) return 0;
    if (n == 1) return 1;
    
    long long a = 0; // f(n-2)
    long long b = 1; // f(n-1)
    long long result = 0;

    for (int i = 2; i <= n; ++i) {
        result = a + b;
        a = b;
        b = result;
    }
    return result;
}

int main() {
    for (int i = 0; i < 10; ++i) {
        std::cout << fib_iterative(i) << std::endl;
    }
    return 0;
}

why函数式?

因为很炫啊这还用问吗

因为其两个重要特性,让函数式语言有了很多天然的优势——这些优势是否配得上这个学习曲线有待商榷😹️。函数式语言在静态分析工具、编译器形式化验证等领域被广泛使用,这是符合逻辑的,由于函数为第一公民的思想,递归就变成了常见的编程手段,很利于这些工具去建立AST(抽象语法树)。同时因为不存在副作用,天然适配这些需要高可靠性的领域。这些领域之外,因为函数之间相互独立不共享,也让部分并发操作更好实现。

但是非常让我惊喜的是,函数式思想,或者说Lambda表达式,在工业界的代码中普及度也很高了。这里就引出个问题: Lambda表达式又是啥?

Lambda表达式

这是个数学上的概念,我不认为我可以比wiki百科讲得更好理解,所以直接贴在下面(偷懒):

在λ演算中,每个表达式都代表一个函数,这个函数有一个参数,并且会返回一个值。不论是参数和返回值,也都是一个单参的函数。可以这么说,λ演算中只有一种“类型”,那就是这种单参函数。函数是通过λ表达式匿名地定义的,这个表达式说明了此函数将对其参数进行什么操作。 例如,“加2”函数f(x)= x + 2可以用lambda演算表示为λx.x + 2(或者λy.y + 2,参数的取名无关紧要),而f(3)的值可以写作(λx.x + 2) 3。函数的应用(application)是左结合的:f x y =(f x) y。

考虑这么一个函数:它把一个函数作为参数,这个函数将被作用在3上:λf.f 3。如果把这个(用函数作参数的)函数作用于我们先前的“加2”函数上:(λf.f 3)(λx.x+2),则明显地,下述三个表达式:

(λf.f 3)(λx.x+2) 与 (λx.x + 2) 3 与 3 + 2

是等价的。

所以说,Lambda表达式是函数式思想的语义基础,从coding的角度讲,Lambda表达式本质上是一个匿名函数,它是一种简洁的、可传递的“行为参数”。但是就像上面提到的,在函数式编程语言中它是符合定义的(函数可以在运行时创建),但在部分支持函数式的语言当中,它是不符合的。这里如果再仔细探讨,可能会涉及编程语言演变史,以及元语言(Meta Language, ML)的内容,后面有时间我会继续去调研😵‍💫。

闭包又是啥?

虽然暂时跳过了一堆概念,但是闭包(Closure)还是不得不提一下。

在C++的语境下,闭包和捕获(Capture)关系紧密。C++的Lambda规范:

1
[capture list] (parameter list) -> return type { function body }

其中[]中就是捕获列表,是指将Lambda表达式外部作用域中的变量引入到Lambda内部环境的动作。直观地理解,就是老师在上课之前点名,捕获了上课前的状态;一个善良的老师要是想上课点名回答问题,那一定是按照捕获的名单点名。即使上课的时候,这些同学可能进进出出,但都不影响一节课正常地进行。此时,这一节课就是闭包

闭包是运行中的函数实例,包含了行为(点名回答)和捕获的状态(名单)。即: 闭包 = 函数体 + 状态。

这时候你就要问了: 诶说起实例,这个Lambda表达式有this指针吗?

(呵呵,真是没完没了了呢😵‍💫)

C++中的Lambda与this

记得我在实习的时候,看到了一段非常深邃的代码,几年里没人敢去动,就是一个捕获了*this的Lambda表达式。

实际上对于普通的this(Lambda所属对象),编译器已为你扛下所有。其实不去捕获this,不去写this->之类的表达,也可以正常地运行。尽管这种写法存在不少和面向对象一样的生命周期相关的隐患。原始对象销毁后,这个捕获的指针仍然指向之前的地址,但该内存已无效,程序崩溃。

然后就是C++17加入的捕获*this,提高了安全性,因为这是捕获了一个对象的副本(完整拷贝)。

Java中的Lambda

Java中的Lambda实际上是一个函数式接口,与Runnable接口配合可以很简洁地实现多线程操作。

1
2
3
4
5
6
7
8
9
Runnable lambdaTask = () -> {
    System.out.println("Lambda 线程正在运行...");
    for (int i = 0; i < 3; i++) {
        System.out.println("Lambda 线程计数: " + i);
    }
};
// 使用这个 Lambda 任务创建并启动线程
Thread t2 = new Thread(lambdaTask);
t2.start();

在我的理解中,Java的Lambda表达式本身是借用了函数式的设计思想,并不是一个匿名对象,意思就是一个Lambda表达式只有在它能被编译器确定地赋值给某个函数式接口时,才是合法的。在Java语境下,和Lambda关联密切的还有一个概念(怎么还有)——Stream API。

Stream API

流(Stream)可以理解为是一个类似链表的结构,但它和一个数据结构最大的区别就是,流里面的元素不一定预先存在内存里,而是随着流操作去实时计算产生的。

所以这和函数式有什么关系?

因为流式编程的概念贯彻了函数式的思想。还记得那两个特征吗:不可变性和避免副作用。对对Strea 执行的任何操作,都不会修改原始数据源,也不会修改 Stream 自身。每个中间操作都会返回一个新的 Stream。原始数据保持不变Stream只是对数据源进行“视图”或“管道”操作。

那Lambda呢?难点就是Stream的每个操作(如 mapfilter)都接受一个 Lambda 表达式作为参数。关注的重点是创造出一个公式,一个流水线,逻辑复杂又优雅安全。

惰性求值

惰性求值(Lazy Evaluation),好soutenu(雅言)的翻译乍一看完全无法理解呢,好在还有个别称: call-by-need。通俗地将就是我上课的时候不先想好问题的答案,当老师点到我我在现场想出答案,如果老师没点我那我一点脑力都没有消耗。所以不难看出,这种机制非常适配纯函数式编程和流式计算,性能上也看似有很大提升。

这个概念其实应该一开始就写,但是我个人认为,在Stream后再说接受难度更低一点。但如果去严格辨析哪些语言的函数式编程是严格惰性的,哪些不是,又要耗费很多精力,需要从编译器的角度看事情。所以在这里了解这个概念即可,并且认识到在大多数语言中,都是采用半惰性求值策略。

结语

总而言之,不知道这篇文章有没有帮助你——作为新手,更好地理解了函数式思想中的一些概念,反正我是写爽了

后面等我再修炼下,可能会更贴近语言特性地完善函数式知识体系。就这样,我去学响应式去了😭️(🏃🏻

参考资料

使用 Hugo 构建
主题 StackJimmy 设计
发表了8篇文章 · 共嘟嘟了17.79k字
·