「设计模式与范式」系列 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 上的操作分两种:中间操作 (map、filter)返回的还是 Stream 对象,可以继续往下级联;终止操作 (max)返回的是确定的结果,链条到此为止。调用链画出来是这样:
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 里的 Function、Predicate 都是这么定义的:
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.transform、Collections2.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 开始进入创建型模式:单例、工厂、建造者、原型解决的到底是同一类什么问题。