教妹學 Java:晦澀難懂的泛型

沉默王二發表於2019-05-17

00、故事的起源

“二哥,要不我上大學的時候也學習程式設計吧?”有一天,三妹突發奇想地問我。

“你確定要做一名程式媛嗎?”

“我覺得女生做程式設計師,有著天大的優勢,尤其是我這種長相甜美的。”三妹開始認真了起來。

“好像是啊,遇到女生提問,我好像一直蠻熱情的。”

“二哥,你不是愛好寫作嘛,還是一個 Java 程式設計師,不妨寫個專欄,名字就叫《教妹學 Java》。我高考完就開始跟著你學習程式設計,還能省下一筆培訓費。”三妹看起來已經替我籌劃好了呀。

“真的很服氣你們零零後,蠻有想法的。剛好我最近在寫 Java 系列的專欄,不妨試一試!”

PS:親愛的讀者朋友們,我們今天就從晦澀難懂的“泛型”開始吧!(子標題是三妹提出來的,內容由二哥我來回答)

01、二哥,為什麼要設計泛型啊?

三妹啊,聽哥慢慢給你講啊。

Java 在 5.0 時增加了泛型機制,據說專家們為此花費了 5 年左右的時間(聽起來很不容易)。有了泛型之後,尤其是對集合類的使用,就變得更規範了。

看下面這段簡單的程式碼。

ArrayList<String> list = new ArrayList<String>();
list.add("沉默王二");
String str = list.get(0);

但在沒有泛型之前該怎麼辦呢?

首先,我們需要使用 Object 陣列來設計 Arraylist 類。

class Arraylist {
    private Object[] objs;
    private int i = 0;
    public void add(Object obj) {
        objs[i++] = obj;
    }

    public Object get(int i) {
        return objs[i];
    }
}

然後,我們向 Arraylist 中存取資料。

Arraylist list = new Arraylist();
list.add("沉默王二");
list.add(new Date());
String str = (String)list.get(0);

你有沒有發現兩個問題:

  • Arraylist 可以存放任何型別的資料(既可以存字串,也可以混入日期),因為所有類都繼承自 Object 類。
  • 從 Arraylist 取出資料的時候需要強制型別轉換,因為編譯器並不能確定你取的是字串還是日期。

對比一下,你就能明顯地感受到泛型的優秀之處:使用型別引數解決了元素的不確定性——引數型別為 String 的集合中是不允許存放其他型別元素的,取出資料的時候也不需要強制型別轉換了。

02、二哥,怎麼設計泛型啊?

三妹啊,你一個小白只要會用泛型就行了,還想設計泛型啊?!不過,既然你想了解,那麼哥義不容辭。

首先,我們來按照泛型的標準重新設計一下 Arraylist 類。

class Arraylist<E> {
    private Object[] elementData;
    private int size = 0;

    public Arraylist(int initialCapacity) {
        this.elementData = new Object[initialCapacity];
    }

    public boolean add(E e) {
        elementData[size++] = e;
        return true;
    }

    elementData(int index) {
        return (E) elementData[index];
    }
}

一個泛型類就是具有一個或多個型別變數的類。Arraylist 類引入的型別變數為 E(Element,元素的首字母),使用尖括號 <> 括起來,放在類名的後面。

然後,我們可以用具體的型別(比如字串)替換型別變數來例項化泛型類。

Arraylist<String> list = new Arraylist<String>();
list.add("沉默王三");
String str = list.get(0);

Date 型別也可以的。

Arraylist<Date> list = new Arraylist<Date>();
list.add(new Date());
Date date = list.get(0);

其次,我們還可以在一個非泛型的類(或者泛型類)中定義泛型方法。

class Arraylist<E> {
    public <T> T[] toArray(T[] a) {
        return (T[]) Arrays.copyOf(elementData, size, a.getClass());
    }
}

不過,說實話,泛型方法的定義看起來略顯晦澀。來一副圖吧(注意:方法返回型別和方法引數型別至少需要一個)。

現在,我們來呼叫一下泛型方法。

Arraylist<String> list = new Arraylist<>(4);
list.add("沉");
list.add("默");
list.add("王");
list.add("二");

String [] strs = new String [4];
strs = list.toArray(strs);

for (String str : strs) {
    System.out.println(str);
}

最後,我們再來說說泛型變數的限定符 extends。在解釋這個限定符之前,我們假設有三個類,它們之間的定義是這樣的。

class Wanglaoer {
    public String toString() {
        return "王老二";
    }
}

class Wanger extends Wanglaoer{
    public String toString() {
        return "王二";
    }
}

class Wangxiaoer extends Wanger{
    public String toString() {
        return "王小二";
    }
}

我們使用限定符 extends 來重新設計一下 Arraylist 類。

class Arraylist<extends Wanger> {
}

當我們向 Arraylist 中新增 Wanglaoer 元素的時候,編譯器會提示錯誤:Arraylist 只允許新增 Wanger 及其子類 Wangxiaoer 物件,不允許新增其父類 Wanglaoer

Arraylist<Wanger> list = new Arraylist<>(3);
list.add(new Wanger());
list.add(new Wanglaoer());
// The method add(Wanger) in the type Arraylist<Wanger> is not applicable for the arguments 
// (Wanglaoer)
list.add(new Wangxiaoer());

也就是說,限定符 extends 可以縮小泛型的型別範圍。

03、二哥,聽說虛擬機器沒有泛型?

三妹,你功課做得可以啊,連虛擬機器都知道了啊。哥可以肯定地回答你,虛擬機器是沒有泛型的。

囉嗦一句哈。我們編寫的 Java 程式碼(也就是原始碼,字尾為 .java 的檔案)是不能夠被作業系統直接識別的,需要先編譯,生成 .class 檔案(也就是位元組碼檔案)。然後 Java 虛擬機器(JVM)會充當一個翻譯官的角色,把位元組碼翻譯給作業系統能聽得懂的語言,告訴它該幹嘛。

怎麼確定虛擬機器沒有泛型呢?我們需要把泛型類的位元組碼進行反編譯——強烈推薦超神反編譯工具 Jad !

現在,在命令列中敲以下程式碼吧(反編譯 Arraylist 的位元組碼檔案 Arraylist.class)。

jad Arraylist.class

命令執行完後,會生成一個 Arraylist.jad 的檔案,用文字編輯工具開啟後的結果如下。

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3) 
// Source File Name:   Arraylist.java

package com.cmower.java_demo.fanxing;

import java.util.Arrays;

class Arraylist
{

    public Arraylist(int initialCapacity)
    {
        size = 0;
        elementData = new Object[initialCapacity];
    }

    public boolean add(Object e)
    {
        elementData[size++] = e;
        return true;
    }

    Object elementData(int index)
    {
        return elementData[index];
    }

    private Object elementData[];
    private int size;
}

型別變數 <E> 消失了,取而代之的是 Object !

既然如此,那如果泛型類使用了限定符 extends,結果會怎麼樣呢?我們先來看看 Arraylist2 的原始碼。

class Arraylist2<extends Wanger> {
    private Object[] elementData;
    private int size = 0;

    public Arraylist2(int initialCapacity) {
        this.elementData = new Object[initialCapacity];
    }

    public boolean add(E e) {
        elementData[size++] = e;
        return true;
    }

    elementData(int index) {
        return (E) elementData[index];
    }
}

位元組碼檔案 Arraylist2.class 使用 Jad 反編譯後的結果如下。

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.
// Jad home page: http://www.kpdus.com/jad.html
// Decompiler options: packimports(3) 
// Source File Name:   Arraylist2.java

package com.cmower.java_demo.fanxing;


// Referenced classes of package com.cmower.java_demo.fanxing:
//            Wanger

class Arraylist2
{

    public Arraylist2(int initialCapacity)
    {
        size = 0;
        elementData = new Object[initialCapacity];
    }

    public boolean add(Wanger e)
    {
        elementData[size++] = e;
        return true;
    }

    Wanger elementData(int index)
    {
        return (Wanger)elementData[index];
    }

    private Object elementData[];
    private int size;
}

型別變數 <E extends Wanger> 不見了,E 被替換成了 Wanger

通過以上兩個例子說明,Java 虛擬機器會將泛型的型別變數擦除,並替換為限定型別(沒有限定的話,就用 Object)。

04、二哥,型別擦除會有什麼問題嗎?

三妹啊,你還別說,型別擦除真的會有一些“問題”。

我們來看一下這段程式碼。

public class Cmower {

    public static void method(Arraylist<String> list) {
        System.out.println("Arraylist<String> list");
    }

    public static void method(Arraylist<Date> list) {
        System.out.println("Arraylist<Date> list");
    }

}

在淺層的意識上,我們會想當然地認為 Arraylist<String> listArraylist<Date> list 是兩種不同的型別,因為 String 和 Date 是不同的類。

但由於型別擦除的原因,以上程式碼是不會通過編譯的——編譯器會提示一個錯誤(這正是型別擦除引發的那些“問題”):

Erasure of method method(Arraylist) is the same as another method in type
Cmower

Erasure of method method(Arraylist) is the same as another method in type
Cmower

大致的意思就是,這兩個方法的引數型別在擦除後是相同的。

也就是說,method(Arraylist<String> list)method(Arraylist<Date> list) 是同一種引數型別的方法,不能同時存在。型別變數 StringDate 在擦除後會自動消失,method 方法的實際引數是 Arraylist list

有句俗話叫做:“百聞不如一見”,但即使見到了也未必為真——泛型的擦除問題就可以很好地佐證這個觀點。

05、二哥,聽說泛型還有萬用字元?

三妹啊,哥突然覺得你很適合作一枚可愛的程式媛啊!你這預習的功課做得可真到家啊,連萬用字元都知道!

萬用字元使用英文的問號(?)來表示。在我們建立一個泛型物件時,可以使用關鍵字 extends 限定子類,也可以使用關鍵字 super 限定父類。

為了更好地解釋萬用字元,我們需要對 Arraylist 進行一些改進。

class Arraylist<E> {
    private Object[] elementData;
    private int size = 0;

    public Arraylist(int initialCapacity) {
        this.elementData = new Object[initialCapacity];
    }

    public boolean add(E e) {
        elementData[size++] = e;
        return true;
    }

    public E get(int index) {
        return (E) elementData[index];
    }

    public int indexOf(Object o) {
        if (o == null) {
            for (int i = 0; i < size; i++)
                if (elementData[i]==null)
                    return i;
        } else {
            for (int i = 0; i < size; i++)
                if (o.equals(elementData[i]))
                    return i;
        }
        return -1;
    }

    public boolean contains(Object o) {
        return indexOf(o) >= 0;
    }

    public String toString() {
        StringBuilder sb = new StringBuilder();

        for (Object o : elementData) {
            if (o != null) {
                E e = (E)o;
                sb.append(e.toString());
                sb.append(',').append(' ');
            }
        }
        return sb.toString();
    }

    public int size() {
        return size;
    }

    public E set(int index, E element) {
        E oldValue = (E) elementData[index];
        elementData[index] = element;
        return oldValue;
    }
}

1)新增 indexOf(Object o) 方法,判斷元素在 Arraylist 中的位置。注意引數為 Object 而不是泛型 E

2)新增 contains(Object o) 方法,判斷元素是否在 Arraylist 中。注意引數為 Object 而不是泛型 E

3)新增 toString() 方法,方便對 Arraylist 進行列印。

4)新增 set(int index, E element) 方法,方便對 Arraylist 元素的更改。

你知道,Arraylist<Wanger> list = new Arraylist<Wangxiaoer>(); 這樣的語句是無法通過編譯的,儘管 Wangxiaoer 是 Wanger 的子類。但如果我們確實需要這種 “向上轉型” 的關係,該怎麼辦呢?這時候就需要萬用字元來發揮作用了。

利用 <? extends Wanger> 形式的萬用字元,可以實現泛型的向上轉型,來看例子。

Arraylist<? extends Wanger> list2 = new Arraylist<>(4);
list2.add(null);
// list2.add(new Wanger());
// list2.add(new Wangxiaoer());

Wanger w2 = list2.get(0);
// Wangxiaoer w3 = list2.get(1);

list2 的型別是 Arraylist<? extends Wanger>,翻譯一下就是,list2 是一個 Arraylist,其型別是 Wanger 及其子類。

注意,“關鍵”來了!list2 並不允許通過 add(E e) 方法向其新增 Wanger 或者 Wangxiaoer 的物件,唯一例外的是 null。為什麼不能存呢?原因還有待探究(苦澀)。

那就奇了怪了,既然不讓存放元素,那要 Arraylist<? extends Wanger> 這樣的 list2 有什麼用呢?

雖然不能通過 add(E e) 方法往 list2 中新增元素,但可以給它賦值。

Arraylist<Wanger> list = new Arraylist<>(4);

Wanger wanger = new Wanger();
list.add(wanger);

Wangxiaoer wangxiaoer = new Wangxiaoer();
list.add(wangxiaoer);

Arraylist<? extends Wanger> list2 = list;

Wanger w2 = list2.get(1);
System.out.println(w2);

System.out.println(list2.indexOf(wanger));
System.out.println(list2.contains(new Wangxiaoer()));

Arraylist<? extends Wanger> list2 = list; 語句把 list 的值賦予了 list2,此時 list2 == list。由於 list2 不允許往其新增其他元素,所以此時它是安全的——我們可以從容地對 list2 進行 get()indexOf()contains()。想一想,如果可以向 list2 新增元素的話,這 3 個方法反而變得不太安全,它們的值可能就會變。

利用 <? super Wanger> 形式的萬用字元,可以向 Arraylist 中存入父類是 Wanger 的元素,來看例子。

Arraylist<? super Wanger> list3 = new Arraylist<>(4);
list3.add(new Wanger());
list3.add(new Wangxiaoer());

// Wanger w3 = list3.get(0);

需要注意的是,無法從 Arraylist<? super Wanger> 這樣型別的 list3 中取出資料。為什麼不能取呢?原因還有待探究(再次苦澀)。

雖然原因有待探究,但結論是明確的:<? extends T> 可以取資料,<? super T> 可以存資料。那麼利用這一點,我們就可以實現陣列的拷貝——<? extends T> 作為源(保證源不會發生變化),<? super T> 作為目標(可以儲存值)。

public class Collections {
    public static <T> void copy(Arraylist<? super T> dest, Arraylist<? extends T> src) {
        for (int i = 0; i < src.size(); i++)
            dest.set(i, src.get(i));
    }
}

06、故事的未完待續

“二哥,你今天苦澀了啊!嘿嘿。竟然還有你需要探究的。”三妹開始調皮了起來。

“……”

“不要不好意思嘛,等三妹啥時候探究出來了原因,三妹給你講,好不好?”三妹越說越來勁了。

“……”

“二哥,你還在想泛型萬用字元的原因啊!那三妹先去預習下個知識點了啊,你思考完了,再給我講!”三妹看著我陷入了沉思,扔下這句話走了。

“……”

 

相關文章