深入理解Java泛型、協變逆變、泛型通配符、自限定

来源:https://www.cnblogs.com/dahua-dijkstra/archive/2023/04/20/17338184.html
-Advertisement-
Play Games

禁止轉載 重寫了之前博客寫的泛型相關內容,全部整合到這一篇文章里了,把坑都填了,後續不再糾結這些問題了。本文深度總結了函數式思想、泛型對在Java中的應用,解答了許多比較難的問題。 純函數 協變 逆變 泛型通配符 PECS法則 自限定 Part 1: 協變與逆變 Java8 引入了函數式介面,從此方 ...


禁止轉載

重寫了之前博客寫的泛型相關內容,全部整合到這一篇文章里了,把坑都填了,後續不再糾結這些問題了。本文深度總結了函數式思想、泛型對在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就應該支持協變,現在只能在使用時(方法聲明時)使用通配符表示

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 相信大家在學習 Java 的過程中,或多或少都見過 JSR 這個詞。本篇文章就科普下什麼是 JSR。 什麼是 JSR ? JSR(Java Specification Requests),是指 Java 規範請求(或者活規範提案)。這個請求(提案)是提給 JCP 的(Java Community P ...
  • 上周末,去參加了北京思否 hackathon,兩天時間內從腦暴 & 挖掘軟體 IDEA -> Demo 研發路演,這次經歷讓我難忘。這裡我的看法是每個開發者圈友,都應該去參加一次 hackathon ~ 做 niche 軟體正確的方法 這邊先說結論,如圖。我認為 做 niche 軟體正確的方法 或 ...
  • JVM中集成了兩種編譯器,Client Compiler和Server Compiler,它們的作用也不同。Client Compiler註重啟動速度和局部的優化,Server Compiler則更加關註全局的優化,性能會更好,但由於會進行更多的全局分析,所以啟動速度會變慢。兩種編譯器有著不同的應用 ...
  • Rust 的面向對象編程特性 一、面向對象語言的特性 Rust是面向對象編程語言嗎? Rust 受到多種編程範式的影響,包括面向對象 面向對象通常包含以下特性:命名對象、封裝、繼承 對象包含數據和行為 “設計模式四人幫”在《設計模型》中給面向對象的定義: 面向對象的程式由對象組成 對象包裝了數據和操 ...
  • 最近看到一個有趣的問題:Person類具有Hand,Hand可以操作杯子Cup,但是在石器時代是沒有杯子的,這個問題用編程怎麼解決? 簡單代碼實現 我們先用簡單代碼實現原問題: @Data public class Person { private final String name; privat ...
  • 本文首發於公眾號:Hunter後端 原文鏈接:Django筆記二十六之資料庫函數之數學公式函數 這一篇來介紹一下公式函數,主要是數學公式。 其中 sin,cos 這種大多數情況下用不上的就不介紹了,主要介紹下麵幾種: Abs() 絕對值 Ceil() 向上取整 Floor() 向下取整 Mod() ...
  • 正所謂百家爭鳴、見仁見智、眾說紛紜、各有千秋!在工作流bpmn2.0可視化建模工具實現的細分領域,網上撲面而來的是 bpmn.js 這個渲染工具包和web建模器,而筆者卻認為使用flowable官方開源 editor-app 才是王道。 Flowable 開源版本中的 web 版流程設計器edito ...
  • 背景:1、正常啟動,xml文件放在java目錄和resource目錄下均正常 2、junit啟動,xml文件放在resource目錄下正常,放在java目錄下報BindingException錯誤 mapperlocation綁定地址為:"classpath:com/a/b/**/*.xml" 原因 ...
一周排行
    -Advertisement-
    Play Games
  • 示例項目結構 在 Visual Studio 中創建一個 WinForms 應用程式後,項目結構如下所示: MyWinFormsApp/ │ ├───Properties/ │ └───Settings.settings │ ├───bin/ │ ├───Debug/ │ └───Release/ ...
  • [STAThread] 特性用於需要與 COM 組件交互的應用程式,尤其是依賴單線程模型(如 Windows Forms 應用程式)的組件。在 STA 模式下,線程擁有自己的消息迴圈,這對於處理用戶界面和某些 COM 組件是必要的。 [STAThread] static void Main(stri ...
  • 在WinForm中使用全局異常捕獲處理 在WinForm應用程式中,全局異常捕獲是確保程式穩定性的關鍵。通過在Program類的Main方法中設置全局異常處理,可以有效地捕獲並處理未預見的異常,從而避免程式崩潰。 註冊全局異常事件 [STAThread] static void Main() { / ...
  • 前言 給大家推薦一款開源的 Winform 控制項庫,可以幫助我們開發更加美觀、漂亮的 WinForm 界面。 項目介紹 SunnyUI.NET 是一個基於 .NET Framework 4.0+、.NET 6、.NET 7 和 .NET 8 的 WinForm 開源控制項庫,同時也提供了工具類庫、擴展 ...
  • 說明 該文章是屬於OverallAuth2.0系列文章,每周更新一篇該系列文章(從0到1完成系統開發)。 該系統文章,我會儘量說的非常詳細,做到不管新手、老手都能看懂。 說明:OverallAuth2.0 是一個簡單、易懂、功能強大的許可權+可視化流程管理系統。 有興趣的朋友,請關註我吧(*^▽^*) ...
  • 一、下載安裝 1.下載git 必須先下載並安裝git,再TortoiseGit下載安裝 git安裝參考教程:https://blog.csdn.net/mukes/article/details/115693833 2.TortoiseGit下載與安裝 TortoiseGit,Git客戶端,32/6 ...
  • 前言 在項目開發過程中,理解數據結構和演算法如同掌握蓋房子的秘訣。演算法不僅能幫助我們編寫高效、優質的代碼,還能解決項目中遇到的各種難題。 給大家推薦一個支持C#的開源免費、新手友好的數據結構與演算法入門教程:Hello演算法。 項目介紹 《Hello Algo》是一本開源免費、新手友好的數據結構與演算法入門 ...
  • 1.生成單個Proto.bat內容 @rem Copyright 2016, Google Inc. @rem All rights reserved. @rem @rem Redistribution and use in source and binary forms, with or with ...
  • 一:背景 1. 講故事 前段時間有位朋友找到我,說他的窗體程式在客戶這邊出現了卡死,讓我幫忙看下怎麼回事?dump也生成了,既然有dump了那就上 windbg 分析吧。 二:WinDbg 分析 1. 為什麼會卡死 窗體程式的卡死,入口門檻很低,後續往下分析就不一定了,不管怎麼說先用 !clrsta ...
  • 前言 人工智慧時代,人臉識別技術已成為安全驗證、身份識別和用戶交互的關鍵工具。 給大家推薦一款.NET 開源提供了強大的人臉識別 API,工具不僅易於集成,還具備高效處理能力。 本文將介紹一款如何利用這些API,為我們的項目添加智能識別的亮點。 項目介紹 GitHub 上擁有 1.2k 星標的 C# ...