Java 函数式【2】:深入理解泛型,协变,PECS法则,使用举例
Java 函数式2:深入理解泛型
上次说到函数入参支持协变,出参支持逆变。那么Java中是如何实现支持的?
一切都可以归因于Java的前向兼容,Java泛型是一个残缺品,不过也可以解决大量的泛型问题。
Java中对象声明并不支持协变和逆变,所以我们看到的函数接口声明如下:
// R - Result
@FunctionalInterface
public interface Function<T, R> {
// 1. 函数式接口
R apply(T t);
// 2. compose 和 andThen 实现函数复合
// compose 的入参函数 before 支持入参逆变,出参协变
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
// Java9 支持了静态方法
static <T> Function<T, T> identity() {
return t -> t;
}
}
Java中仅在使用时支持逆变与协变的匹配,可以在方法上使用通配符,也就是说,andThen方法接受的参数支持入参逆变、出参协变。不使用通配符则为不变,在IDEA中可以开启通配符的提示,很有用,一般情况下,编写时可以考虑不变,然后再考虑增加逆变与协变的支持。
但是Java中通配符使用了和继承相关的super、 extends 关键字,而实际协变与逆变和继承没有关系。在scala中协变和逆变可以简单地写作+和-,比如声明List[+T]。
通配符继承了Java一贯的繁琐,函数声明更甚。函数的入参和出参都在泛型参数中,Function<T, R> 和 T → R 相比谁更简洁一目了然。特别是定义高阶函数(入参或出参为函数的函数)更为麻烦,比如一个简单的加法:
// Java 中的声明,可以这样考虑:Function泛型参数的右边为返回值
Function<Integer, Function<Integer, Integer>> add;
// 使用时连续传入两个参数
add.apply(1).apply(2);
// 其他语言
val add : Int -> Int -> Int = x -> y -> x + y
add(1)(2)
// 传入 tuple 的等价形式 Java
Function<Tuple<Integer, Integer>, Integer> add = (x, y) -> x + y;
add.apply(new Tuple(1, 2));
BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
add.apply(1, 2);
// 其他语言
val add: (Int, Int) -> Int = x + y
add(1, 2)
从上面可以看出,虽然实现的是相同的语义,Java对函数的支持还是有明显不足的。没有原生的Tuple类型,但是在使用时又可以使用 (x, y)。
话虽如此,毕竟可以实现相同的功能,丰富的类库加之方法引用、lambda表达式等的存在,Java中使用函数式编程思想可以说是如虎添翼。
三人成虎
理解函数式思想实际上只需要了解三种函数式接口,生产者、函数、消费者。只有生产者和消费者可以有副作用,函数就是纯函数。
Function<T, R>
public interface Supplier<T> {
T get();
}
public interface Consumer<T> {
void accept(T t);
// 多次消费合并为一次
default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> { accept(t); after.accept(t); };
}
}
函数式编程将操作都通过链式调用连接起来。
Supplier → Func1 → … → Funcn → Consumer
比如stream流的整个生命周期,只消费一次。
// Stream
Stream.of("apple", "orange")
.map(String::toUpperCase)
.forEach(System.out::println);
// reactor, 简单理解为stream++, 支持异步 + 背压
Flux.just(1, 2, 3, 4)
.log()
.map(i -> i * 2)
.zipWith(Flux.range(0, Integer.MAX_VALUE),
(one, two) -> String.format("First Flux: %d, Second Flux: %d", one, two))
.subscribe(elements::add);
assertThat(elements).containsExactly(
"First Flux: 2, Second Flux: 0",
"First Flux: 4, Second Flux: 1",
"First Flux: 6, Second Flux: 2",
"First Flux: 8, Second Flux: 3");
常见的使用举例
- Comparable
举例来说,实现 集合类的sort方法,方法签名如下:
// 最简单的声明
public static <T> void sort(Collection<T> col);
// 加入可比较约束,编译器检查:如果没有实现Comparable,则编译不通过
public static <T extends Comparable<T>> void sort(Collection<T> col);
// 使用通配符匹配更多使用场景,大多数类库都是这样声明的,缺点是看起了比较繁琐
// 其实只需要理解了函数的入参逆变,出参协变的准则,关注extends、super后面的类型即可理解
// 提问:为什么col不支持协变?
public static <T extends Comparable<? super T>> void sort(Collection<T> col);
- Stream
这个方法声明在Stream接口中,可以把Stream<Stream
public interface Stream<T> extends BaseStream<T, Stream<T>> {
Stream<T> filter(Predicate<? super T> predicate);
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
// flatMap 把 Stream<Stream<T>> 展开,也有叫 bind 的。
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);
}
可以看看flatMap中mapper的返回类型,完美遵循出参协变和集合类支持协变的特性。
你看,本来Stream
- Collections工具类
public static <T> int binarySearch(List<? extends Comparable<? super T>> list, T key)
和例子1相比,通配符在List里,也可以放在静态方法的泛型参数声明上?
还可以观察到方法声明的返回值一般都不使用通配符,而是指定的类型(T,R,U),这样方便user使用,同时避免误用。在使用时,只需要考虑方法和参数之间的匹配,不需要考虑声明集合类对于协变和逆变的支持:
// 尽量不要有这样的代码,基本没啥卵用,还把问题复杂化了,这些复杂化的问题尽量放到方法中。
List<? extends User> userList = ...
PECS法则
说了这么多,好像也没有提到PECS法则(provider- extends, consumer-super ),也有叫 The Get and Put Principle 的。其实,函数式思想中所有的类都是不可变的,对于一个不可变的类remove,add等操作并不会改变原有对象的值,而是返回一个新对象,所以就没有PECS这样的约束。
那么在Java中是怎样的?
Java集合类的设计大多都是以命令式编程的角度,实现了集合类的增删改查,这种类对象天生就是有状态的。Stream可以简单理解为函数式中不变的List,没有内部状态,或者说只有一种状态。再比如String就是没有状态的,“apple”永远是”apple”。
对于可变集合对象的增删改查,适用于PECS法则。
PECS法则可以理解为extends通配符的Collection为provider,只读;super通配符下的Collection为consumer,只写。
// 只读,求和
public static double sum(Collection<? extends Number> nums) {
double s = 0.0;
for (Number num : nums) s += num.doubleValue();
return s;
}
// 只写,数组 --> 集合类
@SafeVarargs
public static <T> boolean addAll(Collection<? super T> c, T... elements) {
boolean result = false;
for (T element : elements)
result |= c.add(element);
return result;
}
// 读 + 写,参见 Collections
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
int srcSize = src.size();
if (srcSize > dest.size())
throw new IndexOutOfBoundsException("Source does not fit in dest");
if (srcSize < COPY_THRESHOLD ||
(src instanceof RandomAccess && dest instanceof RandomAccess)) {
for (int i=0; i<srcSize; i++)
dest.set(i, src.get(i));
} else {
ListIterator<? super T> di=dest.listIterator();
ListIterator<? extends T> si=src.listIterator();
for (int i=0; i<srcSize; i++) {
di.next();
di.set(si.next());
}
}
}
上面三个例子,只有第一个方法是纯函数。
总之,要注意区分两种编程范式,命令式和函数式思想的差别,它们在泛型中都有具体的应用。Java对于函数式思想有很多借鉴的地方,特别是从Java8之后。如果想学好泛型,不妨了解一下其他语言对于泛型的实现。从我个人的学习经历来说,在没有系统学习函数式思想前,虽然我阅读了On Java 8、Java Generics and Collections、Effective Java、Java8 函数式编程等书,我还是不能理解Comparator<? extends T>以及通配符满天飞的类库,虽然我可以通过忽略extends/super通配符阅读,但是我不敢保证我会写出带有通配符的bugfree的代码。如果你问我从前面提到的那些书中学到了什么,我只能说学到了很多corner cases,泛型擦除、物化、数组、@SuppressWarnning 等等属于可以归结于语言缺陷的东西。
从使用者的角度,泛型可以很容易,但是从编写者的角度,泛型可以很复杂,特别是在Java中。
之后有时间说说协变override、自限定等概念吧。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?