1. 程式人生 > >C# 泛型協變和逆變

C# 泛型協變和逆變

Visual Studio 2010 CTP亮相的C#4VB10,雖然在支援語言新特性方面走了相當不一樣的兩條路:C#著重增加後期繫結和與動態語言相容的若干特性,VB10著重簡化語言和提高抽象能力;但是兩者都增加了一項功能:泛型型別的協變(covariant)和逆變contravariant)。許多人對其瞭解可能僅限於增加的in/out關鍵字,而對其諸多特性有所不知。下面我們就對此進行一些詳細的解釋,幫助大家正確使用該特性。

背景知識:協變和逆變

很多人可能不不能很好地理解這些來自於物理和數學的名詞。我們無需去了解他們的數學定義,但是至少應該能分清協變和逆變。實際上這個詞來源於型別和型別之間的繫結。我們從陣列開始理解。陣列其實就是一種和具體型別之間發生繫結的型別。陣列型別

Int32[]就對應於Int32這個原本的型別。任何型別T都有其對應的陣列型別T[]。那麼我們的問題就來了,如果兩個型別TU之間存在一種安全的隱式轉換,那麼對應的陣列型別T[]U[]之間是否也存在這種轉換呢?這就牽扯到了將原本型別上存在的型別轉換對映到他們的陣列型別上的能力,這種能力就稱為“可變性(Variance)”。在.NET世界中,唯一允許可變性的型別轉換就是由繼承關係帶來的“子類引用->父類引用”轉換。舉個例子,就是String型別繼承自Object型別,所以任何String的引用都可以安全地轉換為Object引用。我們發現String[]陣列型別的引用也繼承了這種轉換能力,它可以轉換成
Object[]陣列型別的引用,陣列這種與原始型別轉換方向相同的可變性就稱作協變(covariant

由於陣列不支援逆變性,我們無法用陣列的例子來解釋逆變性,所以我們現在就來看看泛型介面和泛型委託的可變性。假設有這樣兩個型別:TSubTParent的子類,顯然TSub型引用是可以安全轉換為TParent型引用的。如果一個泛型介面IFoo<T>IFoo<TSub>可以轉換為IFoo<TParent>的話,我們稱這個過程為協變,而且說這個泛型介面支援對T的協變。而如果一個泛型介面IBar<T>IBar<TParent>可以轉換為

T<TSub>的話,我們稱這個過程為逆變(contravariant而且說這個介面支援對T的逆變。因此很好理解,如果一個可變性和子類到父類轉換的方向一樣,就稱作協變;而如果和子類到父類的轉換方向相反,就叫逆變性。你記住了嗎?

.NET 4.0引入的泛型協變、逆變性

剛才我們講解概念的時候已經用了泛型介面的協變和逆變,但在.NET 4.0之前,無論C#還是VB裡都不支援泛型的這種可變性。不過它們都支援委託引數型別的協變和逆變。由於委託引數型別的可變性理解起來抽象度較高,所以我們這裡不準備討論。已經完全能夠理解這些概念的讀者自己想必能夠自己去理解委託引數型別的可變性。在.NET 4.0之前為什麼不允許IFoo<T>進行協變或逆變呢?因為對介面來講,T這個型別引數既可以用於方法引數,也可以用於方法返回值。設想這樣的介面

Interface IFoo(Of T)

    Sub Method1(ByVal param As T)

    Function Method2() As T

End Interface

interface IFoo<T>

{

    void Method1(T param);

    T Method2();

}

如果我們允許協變,從IFoo<TSub>IFoo<TParent>轉換,那麼IFoo.Method1(TSub)就會變成IFoo.Method1(TParent)。我們都知道TParent是不能安全轉換成TSub的,所以Method1這個方法就會變得不安全。同樣,如果我們允許逆變IFoo<TParent>IFoo<TSub>,則TParent IFoo.Method2()方法就會變成TSub IFoo.Method2(),原本返回的TParent引用未必能夠轉換成TSub的引用,Method2的呼叫將是不安全的。有此可見,在沒有額外機制的限制下,介面進行協變或逆變都是型別不安全的。.NET 4.0改進了什麼呢?它允許在型別引數的宣告時增加一個額外的描述,以確定這個型別引數的使用範圍。我們看到,如果一個型別引數僅僅能用於函式的返回值,那麼這個型別引數就對協變相容。而相反,一個型別引數如果僅能用於方法引數,那麼這個型別引數就對逆變相容。如下所示:

Interface ICo(Of Out T)

    Function Method() As T

End Interface

Interface IContra(Of In T)

    Sub Method(ByVal param As T)

End Interface

interface ICo<out T>

{

    T Method();

}

interface IContra<in T>

{

    void Method(T param);

}

可以看到C#4VB10都提供了大同小異的語法,用Out來描述僅能作為返回值的型別引數,用In來描述僅能作為方法引數的型別引數。一個介面可以帶多個型別引數,這些引數可以既有In也有Out,因此我們不能簡單地說一個介面支援協變還是逆變,只能說一個介面對某個具體的型別引數支援協變或反變。比如若有IBar<in T1, out T2>這樣的介面,則它對T1支援逆變而對T2支援協變。舉個例子來說,IBar<object, string>能夠轉換成IBar<string, object>,這裡既有協變又有逆變。

.NET Framework中,許多介面都僅僅將型別引數用於引數或返回值。為了使用方便,在.NET Framework 4.0裡這些介面將重新宣告為允許協變或逆變的版本。例如IComparable<T>就可以重新宣告成IComparable<in T>,而IEnumerable<T>則可以重新宣告為IEnumerable<out T>。不過某些介面IList<T>是不能宣告為inout的,因此也就無法支援協變或逆變。

下面提起幾個泛型協變和逆變容易忽略的注意事項:

1.僅有泛型介面和泛型委託支援對型別引數的可變性,泛型類或泛型方法是不支援的。

2.值型別不參與協變或逆變,IFoo<int>永遠無法變成IFoo<object>,不管有無宣告out。因為.NET泛型,每個值型別會生成專屬的封閉構造型別,與引用型別版本不相容。

3.宣告屬性時要注意,可讀寫的屬性會將型別同時用於引數和返回值。因此只有只讀屬性才允許使用out型別引數,只寫屬效能夠使用in引數。

協變和逆變的相互作用

這是一個相當有趣的話題,我們先來看一個例子:

Interface IFoo(Of In T)

End Interface

Interface IBar(Of In T)

    Sub Test(ByVal foo As IFoo(Of T)) '對嗎?

End Interface

interface IFoo<in T>

{

}

interface IBar<in T>

{

    void Test(IFoo<T> foo); //對嗎?

}

你能看出上述程式碼有什麼問題嗎?我聲明瞭in T,然後將他用於方法的引數了,一切正常。但出乎你意料的是,這段程式碼是無法編譯通過的!反而是這樣的程式碼通過了編譯:

Interface IFoo(Of In T)

End Interface

Interface IBar(Of Out T)

    Sub Test(ByVal foo As IFoo(Of T))

End Interface

interface IFoo<in T>

{

}

interface IBar<out T>

{

    void Test(IFoo<T> foo);

}

什麼?明明是out引數,我們卻要將其用於方法的引數才合法?初看起來的確會有一些驚奇。我們需要費一些周折來理解這個問題。現在我們考慮IBar<string>,它應該能夠協變成IBar<object>,因為stringobject的子類。因此IBar.Test(IFoo<string>)也就協變成了IBar.Test(IFoo<object>)。當我們呼叫這個協變後方法時,將會傳入一個IFoo<object>作為引數。想一想,這個方法是從IBar.Test(IFoo<string>)協變來的,所以引數IFoo<object>必須能夠變成IFoo<string>才能滿足原函式的需要。這裡對IFoo<object>的要求是它能夠逆變IFoo<string>!而不是協變。也就是說,如果一個介面需要對T協變,那麼這個介面所有方法的引數型別必須支援對T的逆變。同理我們也可以看出,如果介面要支援對T逆變,那麼介面中方法的引數型別都必須支援對T協變才行。這就是方法引數的協變-逆變互換原則。所以,我們並不能簡單地說out引數只能用於返回值,它確實只能直接用於宣告返回值型別,但是隻要一個支援逆變的型別協助,out型別引數就也可以用於引數型別!換句話說,in引數除了直接宣告方法引數之外,也僅能借助支援協變的型別才能用於方法引數,僅支援對T逆變的型別作為方法引數也是不允許的。要想深刻理解這一概念,第一次看可能會有點繞,建議有條件的情況下多進行一些實驗。

剛才提到了方法引數上協變和逆變的相互影響。那麼方法的返回值會不會有同樣的問題呢?我們看如下程式碼:

Interface IFooCo(Of Out T)

End Interface

Interface IFooContra(Of In T)

End Interface

Interface IBar(Of Out T1, In T2)

    Function Test1() As IFooCo(Of T1)

    Function Test2() As IFooContra(Of T2)

End Interface

interface IFooCo<out T>

{

}

interface IFooContra<in T>

{

}

interface IBar<out T1, in T2>

{

    IFooCo<T1> Test1();

    IFooContra<T2> Test2();

}

我們看到和剛剛正好相反,如果一個介面需要對T進行協變或逆變,那麼這個介面所有方法的返回值型別必須支援對T同樣方向的協變或逆變這就是方法返回值的協變-逆變一致原則。也就是說,即使in引數也可以用於方法的返回值型別,只要藉助一個可以逆變的型別作為橋樑即可。如果對這個過程還不是特別清楚,建議也是寫一些程式碼來進行實驗。至此我們發現協變和反變有許多有趣的特性,以至於在程式碼裡inout都不像他們字面意思那麼好理解。當你看到in引數出現在返回值型別,out引數出現在引數型別時,千萬別暈倒,用本文的知識即可破解其中奧妙。

總結

經過本文的講解,大家應該已經初步瞭解的協變和逆變的含義,能夠分清協變、逆變的過程。我們還討論了.NET 4.0支援泛型介面、委託的協變和逆變的新功能和新語法。最後我們還套了論的協變、逆變與函式引數、返回值的相互作用原理,以及由此產生的奇妙寫法。我希望大家看了我的文章後,能夠將這些知識用於泛型程式設計當中,正確運用.NET 4.0的新增功能。祝大家使用愉快!