首页 > 编程语言 >深入理解Java泛型、协变逆变、泛型通配符、自限定

深入理解Java泛型、协变逆变、泛型通配符、自限定

时间:2023-04-20 20:22:05浏览次数:37  
标签:Java 函数 逆变 builder class 协变 泛型 public

禁止转载

重写了之前博客写的泛型相关内容,全部整合到这一篇文章里了,把坑都填了,后续不再纠结这些问题了。本文深度总结了函数式思想、泛型对在Java中的应用,解答了许多比较难的问题。

  • 纯函数
  • 协变
  • 逆变
  • 泛型通配符
  • PECS法则
  • 自限定

Part 1: 协变与逆变

Java8 引入了函数式接口,从此方法传参可以传递函数了,有人说这是语法糖。

实际上,这是编程范式的转换,思想体系的变化。

一、纯函数—没有副作用

纯函数的执行不会带来对象内部参数、方法参数、数据库等的改变,这些改变都是副作用。比如Integer::sum是一个纯函数,输入为两个int,输出为两数之和,两个输入量不会改变,在Java 中可以申明为final int类型。

副作用的执行

Java对于不变类的约束明显不足,比如final array只能保证引用的指向不变,array内部的值还是可以改变的,如果存在第二个引用指向相同的array,那么将无法保证array不可变;标准库中的collection常用的还是属于可变mutable类型,可变类型在使用时很便利。

在函数式思想下,函数是一等公民,函数是有值的,比如Integer::sum就是函数类型BiFunction<Integer, Integer, Integer>的一个值,没有副作用的函数保证了函数可以看做一个黑盒,一个固定的输入便有固定的输出。

那么Java中对象的方法是纯函数吗?

大多数时候不是。对象的方法受到对象的状态影响,如果对象的状态不发生改变,同时不对外部产生影响(比如打印字符串),可以看做纯函数。

本文之后讨论的函数都默认为纯函数。

二、协变—更抽象的继承关系

协变和逆变描述了继承关系的传递特性,协变比逆变更好理解。

协变的简单定义:如果A是B的子类,那么F(A)是F(B) 的子类。F表示的是一种类型变换。

比如:猫是动物,表示为Cat < Animal,那么一群猫是一群动物,表示为List[Cat] < List[Aniaml]。

上面的关系很好理解,在面向对象语言中,is-a表示为继承关系,即猫是动物的子类(subtype)。

所以,协变可以这样表示:

A < B ⇒ F(A) < F(B)

在猫的例子中,F表示集合。

那么如果F是函数呢?

我们定义函数F=Provider,函数的类型定义包括入参和出参,简单地考虑入参为空,出参为Animal和Cat的情况。简单理解为方法F定义为获取猫或动物。

那么Supplier作用Cat和Animal上,原来的类型关系保持吗?

答案是保持,Supplier[Cat] < Supplier[Animal]。也就是说获取一只猫就是获取一只动物。转换成面向对象的语言,Supplier[Cat]是Supplier[Animal]的子类。

在面向对象语言中,子类关系常常表现为不同类型之间的兼容。也就是说传值的类型必须为声明的类型的子类。如下面的代码是好的

List[User] users = List(user1, user2)
List[Animal] animals = cats
Supplier[Animal] supplierWithAnimal = supplierWithCat
// 使用Supplier[Animal],实际上得到的是Cat
Animal animal = supplierWithAnimal.get()

我们来看下某百科对于里氏替换原则(LSP)的定义:

里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。 里氏代换原则中说,任何父类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当子类可以替换掉父类,软件单位的功能不受到影响时,父类才能真正被复用,而子类也能够在父类的基础上增加新的行为。里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而子类与父类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。

Animal animal = new Cat(”kitty”);

在UML图中,一般父类在上,子类在下。因此,子类赋值到父类声明的过程可以形象地称为向上转型。

总结一下:协变是LSP的体现,形象的理解为向上转型。

三、逆变—难以理解的概念

与协变的定义相反,逆变可以这样表示:

A < B ⇒ F(B) < F(A)

最简单的逆变类是Consumer[T],考虑Consumer[Fruit] 和 Consumer[Apple]。榨汁机就是一类Consumer,接受的是水果,输出的是果汁。我定义的函数accpt为了避免副作用,返回字符串,然后再打印。

下面我用scala写的示例,其比Java简洁一些,也是静态强类型语言。你可以使用网络上的 playground 运行(eg: scastie.scala-lang.org)。

// scala 变量名在前,类型在后,函数返回类型在括号后,可以省略
class Fruit(val name: String) {}

class Apple extends Fruit("苹果") {}

class Orange extends Fruit("橙子") {}

// 榨汁机,T表示泛型,<:表示匹配上界(榨汁机只能榨果汁),-T 表示T支持逆变
class Juicer[-T <: Fruit] {
  def accept(fruit: T) = s"${fruit.name}汁"
}

val appleJuicer: Juicer[Apple] = Juicer[Fruit]()
println(appleJuicer.accept(Apple()))

// 编译不通过,因为appleJuicer的类型是Juicer[Apple]
// 虽然声明appleJuicer时传递的值是水果榨汁机,但是编译器只做类型检查,Juicer[Apple]类型不能接受其他水果
println(appleJuicer.accept(Orange()))

榨汁机 is-a 榨苹果汁机,因为榨汁机可以榨苹果。

逆变难以理解的点就在于逆变考虑的是函数的功能,而不是函数具体的参数。

参数传参原则上都可以支持逆变,因为对于纯函数而言,参数值并不可变。

再举一个例子,Java8 中stream的map方法需要的参数就是一个函数:

// map方法声明
<R> Stream<R> map(Function<? super T, ? extends R> mapper);

// 此时方法的参数就是T,我们传递的mapper的入参可以为T的父类, 因为mapper支持参数逆变
// 如下程序可以运行
// 你可以对任意一个Stream<T>流使用map(Object::toString),因为在Java中所有类都继承自Object。
Stream.of(1, 2, 3).map(Object::toString).forEach(System.out::println);

问题可以再复杂一点,如果函数的参数为集合类型,还可以支持逆变吗?

当然可以,如前所述,逆变考虑的是函数的功能,传入一个更为一般的函数也可以处理具体的问题。

// Scala中可以使用 ::: 运算符合并两个List, 下一行是List中对方法:::的声明
// def ::: [B >: A](prefix: List[B]): List[B]
// 这个方法在Java很难实现,你可以看看ArrayList::addAll的参数, 然后想想曲线救国的方案,下一篇文章我会详细讨论

// usage
val list: List[Fruit] = List(Apple()) ::: (List(Fruit("水果")))
println(list)
// output: List(Playground$Apple@74046e99, Playground$Fruit@8f0fecd)

总结一下:函数的入参可以支持逆变,即参数的继承关系和函数的继承关系相反,逆变的函数更通用。

Part 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)

image

从上面可以看出,虽然实现的是相同的语义,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");

常见的使用举例

  1. Comparable

举例来说,实现 集合类的sort方法,方法签名如下:

// 最简单的声明
public static <T> void sort(Collection<T> col);

// 加入可比较约束,编译器检查:如果没有实现Comparable,则编译不通过
public static <T extends Comparable<T>> void sort(Collection<T> col);

// 使用通配符匹配更多使用场景,大多数类库都是这样声明的,缺点是看起了比较繁琐
// 其实只需要理解了函数的入参逆变,出参协变的准则,关注extends、super后面的类型即可理解
public static <T extends Comparable<? super T>> void sort(Collection<T> col);
  1. 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就应该支持协变,现在只能在使用时(方法声明时)使用通配符表示

标签:Java,函数,逆变,builder,class,协变,泛型,public
From: https://www.cnblogs.com/dahua-dijkstra/p/17338184.html

相关文章

  • java -- 函数式编程
    函数式编程面向对象过分强调“必须通过对象的形式来做事情”,而函数式思想则尽量忽略面向对象的复杂语法——强调做什么,而不是怎么做。有时只是为了做某事情而不得不创建一个对象,而传递一段代码才是我们真正的目的。LambdaLambda是一个匿名函数,可以理解为一段可以传递的代码。......
  • Java学习笔记(二)
    1.请描述标识符的命名规则答:(1)由26个英文字母大小写,数字,_或$组成。(2)不能以数字开头。(3)不能使用关键字和保留字(指已经定义过的变量),但是可以包含关键字和保留字。(4)严格区分大小写,无长度限制。(5)不能有空格。2.请描述数据类型存在的意义数据有明确的类型划分,为了确保变量保留的......
  • Java学习笔记(一)
    1、JDK,JRE,JVM三者之间的关系、答:JDK是编译环境,集成了JRE和一些JAVA开发工具包。JRE是运行环境。JVM是一种平台软件,负责将字节码文件解释成机器码并提交操作系统执行。将.class文件解释并提交操作系统。2、为什么要配置环境变量配置环境变量:为了在系统中的任何位置都可以访问jdk......
  • 浪潮集团Java研发实习
    2023.4.19上网课上多久三个项目最熟悉那几个SpringBoot常见注解SpringBoot配置数据库配置url时区自己写各个层?框架生成.Mybatis#和$用法5.Mysql分组关键字事务使用过吗Vue怎么创建......
  • java RandomAccess 遍历效率
     RandomAccess 是判断集合是否支持快速随即访问,以下是个测试用例:JDK中推荐的是对List集合尽量要实现RandomAccess接口如果集合类是RandomAccess的实现,则尽量用for(inti=0;i<size;i++)来遍历而不要用Iterator迭代器来遍历,在效率上要差一些。反过来,如果List是SequenceList......
  • 15 个必须知道的 Java 面试问题(2年工作经验)
    【Java核心】1)Whatisthepurposeofserialization?2)WhatisthedifferencebetweenJDKandJRE?3)Whatisthedifferencebetweenequalsand==?4)WhenwillyouuseComparatorandComparableinterfaces?5)Whatisthewait/notifymechanism?6)......
  • java CountDownLatch 实例
    一个线程等待CountDownLatch使用其await()等待其他线程完成(使用减值为0来判断是否完成)。是一个线程等待多个线程(1-N)的锁工具。以下为实例代码: packagecom.common;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.Executor;importjava.util.concurr......
  • 关于Java中对象的向上转型和向下转型
    什么是多态?同一个类调用同一个方法会产生不同的影响/结果这就是多态publicclassPet{ publicvoideat(){ System.out.println("Peteat...") }}classDogextendsPet{ publicvoideat(){ System.out.pringln("Dogeat...") } publicvoidrun(){ System.ou......
  • day 07 7.1 前端基础之JavaScript基础【一】
    前端基础之JavaScript基础【一】【1】、JavaScript的历史1992年底,美国国家超级电脑应用中心(NCSA)开始开发一个独立的浏览器,叫做Mosaic。这是人类历史上第一个浏览器,从此网页可以在图形界面的窗口浏览。但是该浏览器还没有面向大众的普通用户。1994年10月,NCSA的一个主要......
  • day 08 8.2 前端基础之JavaScript基础【三】
    前端基础之JavaScript基础【三】【1】、jQuery介绍jQuery是什么jQuery是一个快速、简洁的JavaScript框架。jQuery设计的宗旨是“writeLess,DoMore”,即倡导写更少的代码,做更多的事情。它封装JavaScript常用的功能代码,提供一种简便的JavaScript设计模式,优化HTML文档操作、事件......