函数式编程里的函数,其实不是你天天写的那个函数——三大编程范式的边界在哪

「设计模式与范式」系列 Day07

写在前面

模块一从面向对象聊到设计原则,一直绕着"怎么组织代码"这个问题打转。这篇作为模块一的收官篇,把视野拉大到编程范式层面------面向对象、面向过程、函数式,这三者的边界到底划在哪。尤其是函数式编程,它里面说的"函数",压根不是你平时 public int add(...) 里写的那个函数,这个误解不厘清,后面看 Stream、Lambda 这些语法会一直觉得别扭。


一、是什么:三种范式的核心区别只在一件事上

三种编程范式说到底,区别只在用什么作为组织代码的最小单元

编程范式 组织代码的单元 数据和方法的关系
面向对象 类 / 对象 封装在一起,靠封装、抽象、继承、多态组织
面向过程 函数 分离,数据是数据,函数是操作数据的过程
函数式 无状态函数 函数不依赖、也不修改任何外部状态

三者并不是互相排斥、非此即彼的对立关系------不管哪种范式,都有变量、函数的概念,最顶层也都要有一个 main 入口去组装这些编程单元,区别仅仅在于"单元"本身长什么样。

函数式编程最容易被误解的地方,就藏在"函数"这两个字里:这里的"函数"指的是数学意义上的函数或表达式(比如 y = f(x)),不是编程语言里 int add(int a, int b) 这种函数。函数式编程认为,一个程序可以用一系列数学函数/表达式的组合来描述,它是比面向过程、面向对象更底层的一种抽象------把计算过程本身描述成表达式。只不过落到具体编程实现时,我们习惯性地把这些数学函数设计成编程语言里的函数来写,所以不深究的话,把它理解成"编程语言里的函数"问题也不大。


二、为什么:函数式编程为什么要求函数无状态

函数式和面向过程一样,都是以"函数"作为组织代码的单元,但区别在于函数式的函数必须是无状态的:函数内部只使用局部变量和入参,不读也不改任何外部变量(面向对象的成员变量、面向过程的全局变量都不行)。函数的执行结果只由入参决定,同样的入参不管执行多少次、什么时候执行,结果都一样。

java 复制代码
// 有状态函数:结果依赖外部变量b,即便入参a相同,多次调用结果也可能不同
int b;
int increase(int a) {
    return a + b;
}

// 无状态函数:结果只由入参决定,跟外部任何变量都无关
int increase(int a, int b) {
    return a + b;
}

这个约束换来的是确定性 :只要入参不变,函数的行为就是完全可预测的,不用担心某个共享变量在你不知道的地方被改掉。这也是为什么函数式编程常被拿来当作面向对象、面向过程的补充,而不是替代品------它解决的是"消除隐藏状态带来的不确定性"这一个具体问题,不是要重新组织整个系统的架构。


三、怎么用:Java 靠三个语法机制支持函数式编程

Java 本身是面向对象语言,要支持函数式编程风格,靠的是三个新增的语法概念:Stream 类Lambda 表达式函数接口(Functional Interface)

Stream:把多个函数操作用 . 级联起来

java 复制代码
Optional<Integer> result = Stream.of("f", "ba", "hello")
        .map(s -> s.length())      // 中间操作,返回 Stream<Integer>
        .filter(l -> l <= 3)       // 中间操作,返回 Stream<Integer>
        .max((o1, o2) -> o1 - o2); // 终止操作,返回 Optional<Integer>
System.out.println(result.get());  // 输出 2

Stream 上的操作分两种:中间操作mapfilter)返回的还是 Stream 对象,可以继续往下级联;终止操作max)返回的是确定的结果,链条到此为止。调用链画出来是这样:

flowchart LR A[Stream of<br/>得到字符串流] --> B[map<br/>转换成长度流] B --> C[filter<br/>过滤长度不超过3] C --> D[max 终止操作<br/>得到最终结果]

Lambda:函数接口实现方式的语法糖

map 函数接收的参数类型是 Function 接口,正常写法要写一整个匿名内部类:

java 复制代码
Stream.of("fo", "bar", "hello").map(new Function<String, Integer>() {
    @Override
    public Integer apply(String s) {
        return s.length();
    }
});

// 用 Lambda 简化后
Stream.of("fo", "bar", "hello").map(s -> s.length());

Lambda 表达式在 Java 里只是语法糖,底层就是上面那段匿名内部类的写法,没有引入新的运行时能力。它由输入、函数体、输出 三部分组成,标准写法是 (a, b) -> { 语句; return 输出; },常见的简化规则:只有一个入参可以省略括号;没有入参连箭头一起省略只留函数体;函数体只有一条语句可以省略花括号;没有返回值 return 也不用写。把开头那段 Stream 例子完整还原成函数接口实现方式对比一下,就能看出 Lambda 省了多少代码:

java 复制代码
// 完整实现方式
Optional<Integer> result2 = Stream.of("fo", "bar", "hello")
        .map(new Function<String, Integer>() {
            @Override
            public Integer apply(String s) { return s.length(); }
        })
        .filter(new Predicate<Integer>() {
            @Override
            public boolean test(Integer l) { return l <= 3; }
        })
        .max(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) { return o1 - o2; }
        });

函数接口:把函数包裹成变量来用

Java 没有 C 语言那种函数指针,没法把函数直接当变量传来传去。函数接口就是用来补这个位置的:它本质上还是接口,特殊之处在于只能有一个未实现的方法 ------只有这样,Lambda 表达式才能唯一确定自己对应的是哪个方法,如果接口里有两个签名相同的未实现方法,Java 翻译 Lambda 时就没法判断该匹配哪一个了。JDK 里的 FunctionPredicate 都是这么定义的:

java 复制代码
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t); // 唯一的未实现方法

    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        return (T t) -> after.apply(apply(t));
    }
}

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t); // 唯一的未实现方法

    default Predicate<T> and(Predicate<? super T> other) {
        return (t) -> test(t) && other.test(t);
    }
}

常见的坑:函数式不是用得越多越好

Google Guava 对函数式编程的支持很克制,只封装了 Iterables.transformCollections2.filter 这类遍历集合相关的接口,没有提供更多。Guava 的态度很明确:过度使用函数式编程会让代码可读性变差,这跟性能优化里"能一行搞定就不用 for 循环"的直觉不一样------链式调用越叠越长,反而会让读代码的人要在脑子里反向拆解一整条 .map().filter().max(),才能搞清楚每一步在干什么。函数式适合用在遍历、转换集合这类局部场景,不是要拿它取代面向对象或面向过程去组织整个系统。


四、面试追问

Q1:函数式编程里说的"函数",和 Java 里 public int add(...) 这种函数是一回事吗?

不完全是一回事。函数式编程里的"函数"指数学意义上的函数或表达式(比如 y = f(x)),函数式编程的核心思想是把程序描述成一系列数学函数/表达式的组合,是比面向过程更底层的抽象。只是具体编程实现时,我们习惯把这些数学函数设计成编程语言里的函数来写,所以不深究的话理解成普通函数也不算错,但两者的出发点是不一样的。

Q2:面向对象、面向过程、函数式三种范式分别以什么作为组织代码的单元?它们是互相排斥的吗?

面向对象以类/对象为单元,靠封装、抽象、继承、多态组织;面向过程以函数为单元,数据和方法分离;函数式以无状态函数为单元。三者不互斥,都有变量、函数的概念,顶层也都要有 main 入口组装这些单元,区别只在于组织单元本身是什么。

Q3:什么是无状态函数?为什么函数式编程要求函数无状态?

无状态函数是指函数内部只用局部变量和入参,不读写任何外部变量,执行结果只由入参决定,同样的入参不管执行多少次结果都一样。要求无状态是为了换取确定性------排除隐藏的外部状态对结果的干扰,让函数的行为完全可预测,这也是函数式编程区别于面向过程(共享全局变量)和面向对象(共享成员变量)的核心特征。

Q4:Lambda 表达式的本质是什么?它和函数接口是什么关系?

Lambda 表达式本质上只是 Java 的语法糖,底层就是函数接口匿名实现类的简化写法,没有引入新的运行时能力。函数接口负责把一个函数包裹成接口的形式,让函数可以像变量一样被传递(弥补 Java 没有函数指针的缺陷);Lambda 表达式则负责简化"实现这个函数接口"这一步的代码量,两者是配套关系,Lambda 离不开函数接口。

Q5:函数接口为什么要求接口里只能有一个未实现的方法?

因为 Lambda 表达式本身只有输入、函数体、输出这三部分,没有方法名信息,编译器只能靠"这个接口里唯一的未实现方法长什么样"去反推 Lambda 表达式应该实现的是哪个方法。如果接口里有两个签名相同的未实现方法,编译器就没法判断该匹配哪一个,Lambda 表达式也就没法正确翻译成对应的实现。


下一篇预告

模块一到这里就收官了。Day08 开始进入创建型模式:单例、工厂、建造者、原型解决的到底是同一类什么问题。

相关推荐
Zane19941 天前
策略模式现在该不该上?一次讲清楚过度设计和设计不足怎么找平衡
设计模式
她说..1 天前
常见设计模式-模板方法模式
java·spring·设计模式·springboot
xiaofeiyang1502 天前
第六章 · 桥接 — 三支毛笔,画出九种颜色
设计模式
Shadow(⊙o⊙)2 天前
OTOL设计模式 One Thread One Loop
服务器·网络·设计模式
sarasuki3 天前
如何让LLM 能在半夜偷偷打开网易云呢?
人工智能·设计模式·agent
小王师傅663 天前
【设计模式】装饰模式(四):框架源码实战——从 Java I/O 到 Spring 到 MyBatis
java·设计模式
执明wa3 天前
Android RecyclerView 多类型, 多种 Item
android·xml·开发语言·设计模式·android studio
sarasuki3 天前
如何让 Agent 获取更多的能力?插件 / 技能系统
人工智能·设计模式·agent
sarasuki3 天前
如何让 Agent 安全运行你的命令 :命令分级 + Hook + 读写锁
人工智能·设计模式·agent