說十分鐘可能有嘩眾取寵的嫌疑,本人寫這個博客,話了半天時間,查閱了很多資料才完成,因此要弄懂協變逆變的本質,可能要多花點時間。
--------------------------------------------------------------------------------
很多文章中對於協變的描述大致如下:
協變是一個細節化程度高的類型賦值給細節化程度低的類型類型。例如一個方法M,返回值是Giraffe(長頸鹿),你可以把M的返回值賦值給Animal(動物)類型,因為Animal類型是細節化程度底的類型,和Giraffe類兼容。那麼方法是協變的,因為當你創建了協變接口,需要用到關鍵字out,返回值從方法中out。
這根本就不是協變的意思。這只是描述了賦值兼容性,兩個類型可以兼容。
--------------------------------------------------------------------------------
協變的准確定義是什麼呢?
先不考慮類型,我們考慮數學意義上的整數。考慮整數之間的小於關系——≤。這裡關系實際上就是一個方法,接受2個數,返回布爾值。
現在我們考慮一下對於整數的投影,投影指的是一個函數,接受一個整數,返回另一個整數。比如 z → z + z 我們可以定義為D(double), z → 0 - z定義為N for (negate), z → z * z,定義為S(square).
問題就出來了,是不是所有的情況下, (x ≤ y) = (D(x) ≤ D(y))?事實上就是,如果x小於等於y,那麼x的2倍也小於等於y的2倍。投影D保留了不等號的方向。
對於N,很顯然,1 ≤ 2 但是 -1 ≥ -2。即(x ≤ y) = (N(y) ≤ N(x)),投影N反轉了不等號的方向。
對於S, -1 ≤ 0, S(0) ≤ S(-1),但是 1 ≤ 2, S(2)≥ S(1)。可見投影S即沒保留不等號方向,也沒反轉不等號方向。
投影D就是協變的,它保留了整數上的次序關系。投影N是逆變的,它反轉了整數上的次序關系,投影S兩者都不是,所以是不變的。
所以這裡很清楚,整數自身不是變體,小於關系也不是變體。投影才是協變或者逆變——接受一個整數生成一個新整數。
--------------------------------------------------------------------------------
現在再來看類型,替換整數上的小於關系,我們在引用類型上也有一個小於關系。如果引用類型X的值可以存儲在類型Y上,那麼稱一個引用類型X小於或等於引用類型Y。
再考慮對於類型的投影,假設一個投影是把T變成IEnumerable<T>,即,如果接受一個參數Giraffe類型,返回一個新類型 IEnumerable<Giraffe>。那麼這個投影在C# 4.0中是協變嗎?是的,它保留了次序的方向。Giraffe可以賦值給Animal,因此Giraffes序列也可以賦值給IEnumerable<Giraffe>。
精煉的說,對於一個投影,如果A可以賦值給B,經過投影後的值A'可以賦值給B',那麼就可以說這個投影是一個協變。
我們可以認為 接受類型T,生成出類型IEnumerable<T>看作是一個投影,並稱這個投影為"IEnumerable<T>”。因此根據上下文,當我們說IEnumerable<T>是協變的,意思就是對於類型T生成類型IEnumerable<T>的投影是一個協變的投影。由於IEnumerable<T>只有一個類型參數,因此很明確我們說的參數就是T。
因此我們可以定義協變,逆變和不變。如果一個泛型類型I<T>,根據類型參數得出的結構,保留了賦值的兼容方向,那麼這個泛型類型是協變的。即如果一個泛型類型I<T>,對於類型A和B,如果A能賦值給B,而且I<A>也能賦值給I<B>,即保留了賦值的兼容方向,那麼說I<T>這個泛型類就是協變的。相反,逆變就是反轉了賦值的兼容方向。不變就是既不是協變也不是逆變。簡單准確的說,接受T,生成 I<T>的這樣的一個投影就是協變/逆變/不變的投影。
--------------------------------------------------------------------------------
在 C# 中,協變和逆變允許數組類型、委托類型和泛型類型參數進行隱式引用轉換。 協變保留分配兼容性,逆變與之相反。
數組是支持協變的,string兼容於object,string數組化後依然兼容於object。但是協變可能會導致類型不安全,如下例子:
object[] array = new String[10];
// 這裡會報錯,array[0]已經先分配給string了,無法再接受整型。 // array[0] = 10;
委托類型也支持協變。如下代碼,string兼容於object,返回值為string的fun兼容於返回值為object的委托。
public delegate object mydelege();
static string fun2()
{
return "";
}
static void Main(string[] args)
{
mydelege md1 = fun2; //string兼容於object,返回值為string的fun兼容於返回值為object的委托
}
對於泛型接口,在Framework4.0之前,不支持協變。例如: IEnumerable<object> b = new List<string>(); 無法編譯過。string兼容於object,但是List<string>無法兼容IEnumerable<object>,屬於不變。 但在Framework4.0之後,上述例子就能編譯過了,也就是說IEnumerable<object>這個泛型接口支持協變了。 Framework4.0後,支持協變的接口有很多。IEnumerable<T>、IEnumerator<T>、IQueryable<T> 和 IGrouping<TKey, TElement>
委托類型支持逆變。如下代碼:
兒子繼承父親
class Father
{ }
class Son : Father
{ }
class Program
{
public delegate void mydelege1(Father f);
public delegate void mydelege2(Son s);
static void fun1(Father s)
{
}
static void fun2(Son s)
{
}
static void Main(string[] args)
{
Father f = new Father();
Son s = new Son();
f = s;//ok,兒子可以賦值給父親
mydelege1 md1 = fun2;//error,輸入參數不支持協變,兒子類型的方法不能賦值給父親類型的委托 mydelege2 md2 = fun1;//ok,父親類型的方法可以賦值給兒子類型的委托,逆變了。
}
對於一個委托mydelege1(Father f),定義的輸入參數類型是Father。
可以看到son是可以賦值給father的,而經過委托定義這樣一個投影,發現son類型為參數的方法不能賦值給father類型為參數的委托。即經過這樣的委托投影,son類型方法無法賦值給father類型委托了。
而相反的,father類型為參數的方法卻可以賦值給son類型為參數的委托。即經過這樣的委托投影,father類型的方法可以賦值給son類型的委托了。這就逆天了,因此也就是逆變了。
簡單的來看,即投影前Son可以賦值給Fahter,投影(轉成委托類型)後Father可以賦值給Son,是一種逆變。
為什麼轉成委托後,Son類型的方法不能賦值給Father類型的委托了,很簡單,這個方法要接受的是Son的方法要處理的自然是Son類型的值,而Father為參數的委托可能接受Daughter類型的參數,(假設Daughter和Son並列的繼承了Father)。
因此Son方法就無法處理了。因此不允許這樣操作。 換成代碼來說,假設這樣的代碼合法了:
mydelege1 md1 = fun2;//假設是合法的,
那麼 md1(new Daughter())的代碼要處理的時候,肯定無法處理了。
所以,不允許存在這樣的協變,而只允許逆變。通過上面的一些例子,可以看出對於委托,協變只存在與返回值中,逆變只存在與輸入值中。
--------------------------------------------------------------------------------
再來看泛型委托中的協變和逆變 我們可以看微軟自定義的泛型委托,Func和Action這兩個。(Func必須有返回類型,Action返回void) 在Framework3.5的時候,Func,Action是不支持協變也不支持逆變。如下代碼:
class Father
{ }
class Son : Father
{ }
class Program
{
static void Main(string[] args)
{
Func<Father> fatherfun = () => new Father();
Func<Son> sonfun = () => new Son();
fatherfun = sonfun;//無法將類型“System.Func<ConsoleApplication2.Son>”隱式轉換為“System.Func<ConsoleApplication2.Father>
sonfun = fatherfun;//無法將類型“System.Func<ConsoleApplication2.Father>”隱式轉換為“System.Func<ConsoleApplication2.Son>” }
}
上面的代碼編譯不過。因此很是不方便,所以在Framework4.0後,允許其可以協變和逆變。在Framework4.0中,如下
static void Main(string[] args)
{
Func<Father> fatherfun = () => new Father();
Func<Son> sonfun = () => new Son();
fatherfun = sonfun;//ok協變成功,Son可以賦值給Father,Sonfun也可以賦值給Fatherfun了;
sonfun = fatherfun;//無法將類型“System.Func<ConsoleApplication2.Father>”隱式轉換為“System.Func<ConsoleApplication2.Son>”。存在一個顯式轉換(是否缺少強制轉換?)
}
對於逆變,如下代碼,同樣的,在Framework3.5時代,仍然是不可協變逆變。
class Father
{ }
class Son : Father
{ }
class Program
{
static void Main(string[] args)
{
Action<Father> fatherfun;
Action<Son> sonfun;
fatherfun = sonfun;//無法將類型“System.Action<ConsoleApplication2.Son>”隱式轉換為“System.Action<ConsoleApplication2.Father>
sonfun = fatherfun;//無法將類型“System.Action<ConsoleApplication2.Father>”隱式轉換為“System.Action<ConsoleApplication2.Son>”
}
}
在Framework4.0後,允許其可以逆變。
static void Main(string[] args)
{
Action<Father> fatherfun;
Action<Son> sonfun;
fatherfun = sonfun;//無法將類型“System.Action<ConsoleApplication2.Son>”隱式轉換為“System.Action<ConsoleApplication2.Father>”。存在一個顯式轉換(是否缺少強制轉換?)
sonfun = fatherfun;//ok 逆變成功
}
以上是微軟提供的泛型委托,事實上自己也可以定義自己的泛型委托,也可以知道這個委托是否支持協變或者逆變。在C#中,通過對參數標注in和out來標注此參數是逆變類型參數還是協變類型參數。
--------------------------------------------------------------------------------
微軟MSDN中的定義。
協變類型參數用 out 關鍵字(在 Visual Basic 中為 Out 關鍵字,在 MSIL 匯編程序中為 +)標記。 可以將協變類型參數用作屬於接口的方法的返回值,或用作委托的返回類型。 但不能將協變類型參數用作接口方法的泛型類型約束。 逆變類型參數用 in 關鍵字(在 Visual Basic 中為 In 關鍵字,在 MSIL 匯編程序中為 -)標記。 可以將逆變類型參數用作屬於接口的方法的參數類型,或用作委托的參數類型。 也可以將逆變類型參數用作接口方法的泛型類型約束。事實上Action和Func的定義在Framework4.0中如下:
public delegate void Action<in T>(
T obj )
public delegate TResult Func<in T, out TResult>(
T arg )
從定義可以看出,協變out主要用在返回值上,逆變in用在輸入參數。如果把out由於輸入參數,編譯不會通過。原因前面也已經解釋過了。這裡在說明一下:假設我們把out用於輸入參數,並假設編譯能夠通過,那麼這樣做的目的是使該參數能夠協變。因此假設如下代碼能成功編譯。
class Father
{ }
class Son : Father
{ }
class Daught : Father
{ }
class Program
{
public delegate void myAction<out T>(T t);//該委托和Action<in T>類似,就差了一個in,一個out,假設此段代碼編譯通過。
static void f_father(Father f)
{ }
static void f_son(Son f)
{ }
static void Main(string[] args)
{
myAction<Father> fatheract = f_father;
myAction<Son> sonact = f_son;
fatheract = sonact;//假設上面的委托能編譯成功,那麼就是支持協變。因此這段代碼也能成立。
fatheract(new Daught())//這段代碼就無法准確運行了。
//如果這段代碼成立了,
//那麼fatheract(new Daught())運行的時候,發現
//f_son裡面接受了Daughter類型,無法運行。
}
}
同樣的,如果對於輸出類型用in來修飾,即允許其可以逆變,也會出現類似的類型問題
class Father
{ }
class Son : Father
{ }
class Daught : Father
{ }
class Program
{
public delegate T myFunc<in T>();//假設這個代碼可以編譯成功。即假設是支持逆變
static void Main(string[] args)
{
myFunc<Father> fatherfunc = ()=> new Father();
myFunc<Son> sonfunc = () => new Son(); ;
sonfunc = fatherfunc;//假設上面的代碼編譯成功,支持了逆變,即支持了fatherfunc賦值給sonfunc那麼下面的代碼就會引起異常。
Son ason= sonfunc();//ason被迫接受father類型了,導致異常
}
}所以,in對應逆變,只能用於輸出參數,out對應協變,只能用於輸出參數。否則會出現問題。
因此,在泛型委托中用允許輸入類型協變會引起類型問題,因此只允許逆變。
基於上面的敘說,很多人會有疑問為何還要顯示的標注in或者out,編譯器完全可以推斷泛型參數是協變還是逆變。事實上,編譯器確實可以自動推斷,但C#團隊認為你需要明確的定義了一個契約,並且遵守這個契約。比如,如果編譯器替你決定了某個泛型類型參數是逆變的,但是,你卻在接口上加了個成員,並使用了out標記。這到後來可能會導致一些類型的錯誤,因此,一開始編譯器就要求你顯示的聲明泛型類型參數。如果你不按照你定義的規則那樣,協變或者逆變,編譯器會報錯,提示你違法了你定義的契約。
作者:cnn237111