最近更新: 2009-12-01

從 C++ Template 到 Java Generic,一步一步來

Java 實作了泛型(generic)機制以實現 C++ 樣板(template) 的一部份能力,兩者的語法乍看之下也有些相似。 雖然我覺得 C++ 樣板很難搞,而且兩者的語法有點像,但是相較於完全陌生的 Java 泛型,我用起 C++ 樣板來還是比較熟練的。很自然的,當我試圖要用 Java 的泛型重構程式碼時,我會先從 C++ 樣板的觀點來思考。

我將日前工作中碰到的一段我想用泛型重構的程式碼,取其大綱出來練習。本文紀錄了大致的改寫過程。

先用 C++ 樣板打草稿

我比較熟悉 C++ 樣板(template),所以在動手使用 Java 泛型(generic)之前,我還是習慣先用 C++ 樣板來打稿,讓我構想程式結構。

有 N, M, S 三個類別,這三個類別沒有繼承關係。但是在操作形式上卻有相當高的重複性,很多地方只是型別不同,程式結構完全一樣。幾乎是剪貼、複製後,再代換型別名稱就完工的情形。正是泛型派得上用場的情形。樣板類別 Cx 就是重複的程式碼內容。

#include <iostream>
using namespace std;

template<class DataType, class ReturnType>
class Cx {
  private:
    DataType data;

  public:
    Cx(DataType& v) { data = v; }

    ReturnType getData() {
        return data.value();
    }
};

class N {
  private:
    int n;

  public:
    N() { n = 0; }
    N(int v) { n = v; }

    int value() {
        return -n;
    }
};

class M {
  private:
    int m;

  public:
    M() { m = 0; }
    M(int v) { m = v; }

    int value() {
        return m * 10;
    }
};

class S {
private:
    string s;

public:
    S() { s = ""; }
    S(const char* v) { s = v; }

    string value() {
        return s;
    }
};

int main() {
    N n = N(1);
    Cx<N, int>* cn = new Cx<N, int>( n );
    cout << cn->getData() << endl;

    M m = M(1);
    Cx<M, int>* cm = new Cx<M, int>( m );
    cout << cm->getData() << endl;

    S s = S("hello");
    Cx<S, string>* cs = new Cx<S, string>( s );
    cout << cs->getData() << endl;

    return 0;
}

Revision 1

第一步,先按 Java 的規則,將每個類別打散到個別的源碼文件中。 將 C++ 的類別定義語法改寫成 Java 的類別定義語法。將樣板(template)改成 Java 的泛型(generic)語法。

Cx.java
//revision: 1
public class Cx<DataType, ReturnType> {
    private DataType data;

    public Cx(DataType v) {
        data = v;
    }

    public ReturnType getData() {
        return data.value();
    }
};
N.java
//revision: 1
public class N {
    private int n;

    public N() { n = 0; }
    public N(int v) { n = v; }

    public int value() {
        return -n;
    }
};
M.java
//revision: 1
public class M {
    private int m;

    public M() { m = 0; }
    public M(int v) { m = v; }

    public int value() {
        return m * 10;
    }
};
S.java
//revision: 1
public class S {
    private String s;

    public S() { s = ""; }
    public S(String v) { s = v; }

    public String value() {
        return s;
    }
};
Main.java
//revision: 1
public class Main {
    public static void main(String[] args) {
        N n = new N(1);
        Cx<N, int> cn = new Cx<N, int>( n );
        System.out.println( cn.getData() );

        M m = new M(1);
        Cx<M, int> cm = new Cx<M, int>( m );
        System.out.println( cm.getData() );

        S s = new S("hello");
        Cx<S, String> cs = new Cx<S, String>( s );
        System.out.println( cs.getData() );
    }
}

類別 N, M, S 編譯都沒問題,但是編譯 Cx 時 javac 告訴我不知道 data.value這個符號是什麼?

public ReturnType getData() {
        return data.value();
        //error:   ^  cannot find symbol
    }

這時,我才想到 Java 支援的是泛型而不是樣板,這兩者果然是不同的。

樣板基本上是把整個定義內容視為一個程式碼原型,編譯時再將參數中列出的類別符號,代換掉程式碼中的符號。我們可以把這個動作比擬為 script 對字串的竄寫動作。我用 PHP 來模擬示範。

<?php
//Cx<N, int>* cn = new Cx<N, int>( n );
$DataType = 'N';
$ReturnType = 'int';

//template<class DataType, class ReturnType>
$template_DataType_ReturnType =<<<CX
class Cx__${DataType}__${ReturnType} { //模擬 template 產生的新類別簽名
  private:
    ${DataType} data;

  public:
    Cx(${DataType}& v) { data = v; }

    ${ReturnType} getData() {
        return data.value();
    }
};
CX;

echo $template_DataType_ReturnType
?>

輸出結果是一個新的類別的程式碼。

class Cx__N__int { //模擬 template 產生的新類別簽名
  private:
    N data;

  public:
    Cx(N& v) { data = v; }

    int getData() {
        return data.value();
    }
};

C++ 編譯器(或者是前置處理器)先將樣板內容進行如上所模擬的代換動作,產生新的類別程式碼,再將生成的程式碼交給一般程式碼編譯單元(或者是後端的 C compiler)編譯成目的碼(object code)。

但是 Java 提供的是泛型而不是樣板,所以它無法這麼簡單地完成型別參數的代換動作。 泛型只是告訴 javac ,我們會將類別當成參數傳遞過來,這些類別之間可能沒有繼承關係,但是卻共用一組一般化的程式碼進行演算。 再者,如果我們在一般化的程式碼中,要調用參數化型別之實體的方法,那麼我們也必須告訴 javac 一個一般化、泛型化的類別定義,這樣 javac 才會知道這個參數化型別大概長什麼樣子、有哪些方法。

Revision 2

在第一個修改版本中,我只告訴 javac: "data 的型別將會由 DataType 參數傳遞過來"。 但我沒有告訴它 DataType 大概長什麼樣子,所以它無從尋找 data.value 這個符號。

我們必須再定義一個東西,這個東西至少要有 value() 方法。 而我們要把這個東西當成 DataType 參數化型別的泛型、一般化內容。 這個東西只要有個輪廓,並不需要有任何實際的內容,用介面(interface) 或抽象類別(abstract class)都可。

我再加一個介面 IDataType 作為 DataType 的泛型吧。

//revision: 2
public interface IDataType {
    public int value();
}

//revision: 2
public class Cx<DataType extends IDataType, ReturnType> {
    private DataType data;

    public Cx(DataType v) {
        data = v;
    }

    public ReturnType getData() {
        return data.value();
        //error: incompatible types
        //found   : int
        //required: ReturnType
    }
};

Revision 3

哎呀,我在宣告 DataType 時,沒有考慮到 data.value 用於 DataType.getData() 的回傳值,只是隨手寫了 int 作為 IDataType.value() 回傳型態。於是 javac 又說找到 int 但要的是 ReturnType,兩者型態不符合。

但是 ReturnType 是另一個參數化的型別,顯然我得把 ReturnType 這個參數再傳給 IDataType 才行。這就要把 IDataType 也變成另一個泛型,具有一個型別參數。 接著修改 Cx,把 Cx 泛型定義中的 ReturnType 再傳給 IDataType<?>

// revision: 3
public interface IDataType<ReturnType> {
    public ReturnType value();
}

// revision: 3
public class Cx<DataType extends IDataType<ReturnType>, ReturnType> {
    private DataType data;

    public Cx(DataType v) {
        data = v;
    }

    public ReturnType getData() {
        return data.value();
    }
};

Ok, 這次 javac 沒再抱怨了,泛型的主要內容沒錯。接下來編譯 Main。

//revision: 1
public class Main {
    public static void main(String[] args) {
        N n = new N(1);
        Cx<N, int> cn = new Cx<N, int>( n );
        //    ^  unexpected type
        //found   : int
        //required: reference
        System.out.println( cn.getData() );

        M m = new M(1);
        Cx<M, int> cm = new Cx<M, int>( m );
        //    ^  unexpected type
        //found   : int
        //required: reference
        System.out.println( cm.getData() );

        S s = new S("hello");
        Cx<S, String> cs = new Cx<S, String>( s );
        System.out.println( cs.getData() );
    }
}

不接受 int 類別作為參數,但是接受 String 類別作為參數。

Revision 4

喔喔,我又忘了 Java 沒有把原始型態和參考型態一視同仁,泛型不支援原始型態。 所以原始型態的 int 要改成參考型態的 Integer 。

//revision: 4
public class Main {
    public static void main(String[] args) {
        N n = new N(1);
        Cx<N, Integer> cn = new Cx<N, Integer>( n );
        // ^  type parameter N is not within its bound
        System.out.println( cn.getData() );

        M m = new M(1);
        Cx<M, Integer> cm = new Cx<M, Integer>( m );
        // ^  type parameter M is not within its bound
        System.out.println( cm.getData() );

        S s = new S("hello");
        Cx<S, String> cs = new Cx<S, String>( s );
        // ^  type parameter S is not within its bound
        System.out.println( cs.getData() );
    }
}

type parameter ? is not within its bound 又是什麼意思?

Revision 5

回顧一下 Cx 泛型的內容,我告訴 javac: "參數 DataType 的泛型是 IDataType 介面"。 如此一來就給了一個限制條件,將 DataType 可以接受的類型侷限在實作了 IDataType 的類別。但是類別 N, M, S 並未宣告它們實作了 IDataType 介面,所以不在 DataType 可接受的範圍中。因此我要再修改類別 N, M, S 的內容,加上 IDataType 的宣告。

原本這三個類別之間沒有關係,但改寫至此, Java 強迫我們拉上關係,讓這三個類別實現了同一個介面。此非我所願,幸好在這個範例中的影嚮不大。得過且過吧。

//revision: 5
public class N implements IDataType<Integer> {
    private Integer n;

    public N() { n = 0; }
    public N(Integer v) { n = v; }

    public Integer value() {
        return -n;
    }
};

//revision: 5
public class M implements IDataType<Integer> {
    private Integer m;

    public M() { m = 0; }
    public M(Integer v) { m = v; }

    public Integer value() {
        return m * 10;
    }
};

//revision: 5
public class S implements IDataType<String> {
    private String s;

    public S() { s = ""; }
    public S(String v) { s = v; }

    public String value() {
        return s;
    }
};
$ javac *.java
$ java Main
-1
10
hello

這次大功告成,我終於如願以償地寫了一個 Java 的泛型類別... 差點忘了還有一個泛型介面。 同時,我也覺得 C++ 樣板沒那麼難了。

Java 的泛型語法不改要程序員先跳過火圈才能吃到香蕉的本色,我只跳了兩個圈圈就重構完成這個很單純的範例程式。 不過現實可沒那麼輕鬆,至少在我日前負責的案子中,有幾處地方我就放棄用泛型去重構它們,那簡直是自討苦吃。舉個例子來說,我想在 Cx 泛型中增加一個無參數的預設建構子,如下列:

public class Cx<DataType extends IDataType<ReturnType>, ReturnType> {
    private DataType data;

    public Cx() {
        data = new DataType();
    }

    public Cx(DataType v) {
        data = v;
    }

    public ReturnType getData() {
        return data.value();
    }
};

我增加了第4~6行的預設建構子,看起來非常簡單、非常合理、不應該受到任何刁難的需求,但是 javac 高舉手中的法杖發出刺目紅光對我大喊: Unexcepted type!。我就是不可以直接 new 一個參數化型別的實例。但是 C++ 樣板可以這麼做,一點都不廢話。

反正案子快結了,也沒人關心軟體內部是不是充斥太多重複的程式碼。至少我沒省略測試案例,天天都跑一次 AllTests 和 Nightly build ,交給客戶的軟體外在品質合格,也就夠了。既然 Java 語言並沒有提供靈活的方法讓我們輕鬆地進行重構工作,還是算了吧,早點下班比較實在。

相關文章
樂多舊網址: http://blog.roodo.com/rocksaying/archives/10890551.html

樂多舊回應
未留名 (#comment-20134591)
Tue, 01 Dec 2009 22:50:26 +0800
看你的文章,的確有很多值得深省的地方

其實我重頭看你的作法,我直覺就會想到用 Strategy Pattern 的方式做
用繼承的方式,而不是委託

這的確就與 C++ Template 那種方式,就有不一樣的意義了
不過,不一樣的語言,就會有不同的概念
因為 Generic Type 本來就不是在做 Template 的
是以 OO 的觀點來做到不 DRY ~ 這是我自己的理解
edwardsayer@gmail.com(Edward) (#comment-20155029)
Tue, 08 Dec 2009 13:18:48 +0800
如果N, M, S 互不相干,那把它們透過同一個 template 引用的用意何在?

而既然用了同一個 template 引用,我覺得N, M, S其實在某些方面就是相干的,而這相關性,可能就是原著透過 refactor 方式找出來的 IDataType。只是 C++ 透過 template 置換的方法,忽略掉這個事實罷了。

設想在 C++ 的版本中,有人把 M 的 value 方法改成 data 方法,則你的程式非得連結到 Cx 才會在 compile time 出現錯誤訊息。但 Java 的版本,因為有 IDataType 的限制,即使沒有 Cx 類別,也是可以很快的找出錯誤所在。就強固性而言,C++ 這點就比較不好了。

另外一個考量,也不一定要為了 template 而template,如果在 Cx 中有相同的程式碼,最簡單的作法,就是把 Cx 變成 helper 類別直接在 N, M, S 中引用。若是情況合理,也可考慮作成共用父類別。
未留名 (#comment-22132051)
Wed, 16 Nov 2011 00:10:44 +0800
walk like a duck, it is a duck.

C++寫起來真的粉爽~~ ^^
未留名 (#comment-22138973)
Tue, 22 Nov 2011 15:55:59 +0800
vs只講了一半啊。C++ template 在使用上具有天堂與地獄的兩極特性。
單純地使用現成的 template library 非常爽快。
但是要自己寫一個 template library 卻很要命。
yuri@hotmail.com(tiara) (#comment-22615790)
Thu, 13 Sep 2012 17:02:37 +0800
>但是要自己寫一個 template library 卻很要命。
1 : 至少比java輕鬆很多很多(去你的java generic)
2 : 簡單的template library很好寫的,像SGI STL或boost那種費盡心力,
不把效能榨乾誓不罷休的library才會難寫。光是算法的部分
要看懂就得具備相當紮實的功力才有可能辦到。