C#的類型推斷髮展史

前言:隨着C#的版本升級,C#編譯器的類型推斷功能也在不斷的升級以適應語言進化過程當中的變化,併爲這個過程作了相應的優化。算法

隱式類型的數組

在C#1和C#2中,做爲變量聲明和初始化的一部分,初始化數組的語句是至關整潔的。若是想在其餘地方聲明並初始化數組,就必須指定數組類型。例如,下面的語句編譯起來沒有任何問題:數組

string[] names = {"pangjainxin", "zhengyanan"};

但這種寫法不適用於參數,假定要調用MyMethod方法,該方法被聲明爲void MyMethod(string[] names),那麼如下代碼是沒法編譯的:app

MyMethod({"pangjianxin","zhengyanan"});//報錯:未提供與方法須要的形參所對應的實參

相反,你必需要告訴編譯器你想要初始化的數組是什麼類型:asp.net

MyMethod(new string[]{"pangjianxin","zhengyanan"});

而C#3容許這二者之間的一種寫法:async

MyMethod(new[]{"pangjianxin","zhengyanan"});

顯然,編譯器必須本身判斷要使用什麼類型的數組。它首先構造一個集合,其中包括大括號內全部表達式的 編譯時類型。在這個類型的集合中,若是其餘全部類型都能隱式轉換爲其中一種類型,該類型即爲數組的類型。不然(或者全部值都是無類型的表達式,好比不變的null值或者匿名方法,並且不存在強制類型轉換), 代碼就沒法編譯。函數

注意,只有表達式的類型纔會成爲一個候選的數組類型(也就是說編譯器不會爲你作類型轉換)。這意味着你偶爾須要將一個值顯式轉型爲一個不太具體的類型。例如,如下代碼沒法編譯:優化

new[]{new MemoryStream(),new StringWriter()}

不存在從MemoryStream向StringWriter的轉換,反之亦然。二者都能隱式轉換爲object和IDisposable,但編譯器只能在表達式自己產生的原始集合中過濾(找到那個最適合的)。在這種狀況下,若是修改其中的一個 表達式,把它的類型變成object或IDisposable,代碼就能夠編譯了:ui

new[]{(IDisposable)new MemoryStream(),new StringWriter()}

最終,表達式的類型爲IDisposable[]。固然,代碼都寫成這樣了,還不如考慮一下顯式聲明數組的類型, 就 像在C# 1和C# 2中所作的那樣,從而更清楚地表達你的意圖。this

從以上的過程當中來看這個特性好像有一些垃圾,沒有什麼用,可是該特性用在匿名類型上面的話仍是很不錯的。因爲這裏主要描述C#的類型推斷,因此關於匿名類型的東西不作太多深刻,就給一個例子來代表類型推斷在匿名類型中的使用:spa

 var family = new[]
            {
                new {name = "pangjianxin", age = 30},
                new {name = "zhengyanan", age = 29},
                new {name = "pangxihe", age = 1}
            };

而匿名類型又服務於一個更大的目標:LINQ。

方法組轉換

在C#1中,若是要建立一個委託實例,就必須同時指定委託類型和要執行的操做。例如,若是須要建立一個KeyPressEventHandler時,會使用以下表達式:

new KeyPressEventHandler(LogKeyEvent)//LogKeyEvent是一個方法名

做爲一個獨立的表達式使用時,它並不「難看」。即便在一個簡單的事件訂閱中使用,它也是可以接受的。可是,在做爲某個較長表達式的一部分使用時,看起來就有點「難看」了。一個常見的例子是在啓動一個新線程時:

Thread t=new Thread(new ThreadStart(MyMethod));

同往常同樣,咱們但願以儘可能簡單的方式啓動一個新線程來執行MyMethod。爲此,C#2支持從方法組到一個兼容委託類型的隱式轉換。方法組(methodgroup)其實就是一個方法名,它能夠選擇添加一個目標——換言之,和在C#1中建立委託實例使用的表達式徹底相同。(事實上,表達式當時就已經叫作「方法組」,只是那時還不支持轉換。)若是方法是泛型的,方法組也能夠指定類型實參,不過根據個人經驗,不多會這麼作。新的隱式轉換容許咱們將事件訂閱轉換成:

button.keyPress+=LogKeyEvent;//LogKeyEvent是一個方法名

相似的,線程建立代碼能夠簡化成:

Thread t=new Thread(MyMethod);

若是隻看一行代碼,原始版本和改進的版本在可讀性上的差別彷佛並不大。但在代碼量很大時,它們對可讀性的提高就很是明顯了。爲了弄清楚到底發生了什麼,咱們簡單看看這個轉換具體都作了什麼。首先研究一下例子中出現的表達式LogKeyEvent和MyMethod。它們之因此被劃分爲方法組,是由於因爲重載,可能不止一個方法適用。隱式轉換會將一個方法組轉換爲具備兼容簽名的任意委託類型。因此,假定有如下兩個方法簽名:

void MyMehtod();
viod MyMethod(object sender,Eventargs e);

那麼在向一個ThreadStart或者一個EventHandler賦值時,均可以將MyMethod做爲方法組使用:

THreadStart x=MyMehtod;
EventHandler y=MyMethod;

然而,對於自己已重載成能夠獲取一個ThreadStart或者一個EventHandler的方法,就不能把它(MyMethod)做爲方法的參數使用——編譯器會報告該轉換具備歧義。一樣,不能利用隱式方法組轉換來轉換成普通的System.Delegate類型,由於編譯器不知道具體建立哪一個委託類型的實例。這確實有點不方便,但使用顯式轉換,仍然能夠寫得比在C#1中簡短一些。例如:

Delegate invalid=SomeMethod;
Delegate valid=(ThreadStart)SomeMethod;

對於方法組的轉換,大多數狀況下咱們要在本身的實驗過程當中得出真知,這沒有什麼困難的。

類型推斷和重載決策的改變

類型推斷和重載決策所涉及的步驟在C#3中發生了變化,以適應Lambda表達式,並使匿名方法變得更有 用。這些雖然不算是C#的新特性,但在理解編譯器所作的事情方面,這些變化是至關重要的。規則之因此發生了變化,是爲了使Lambda表達式可以以一種簡潔的方式工做,讓咱們稍微深刻地探討一下假如C#團隊堅守老的規則不變,將會遇到什麼問題。

改變的原由:精簡泛型方法調用

在幾種狀況下會進行類型推斷。經過之前的討論,咱們知道隱式類型的數組以及將方法組轉換爲委託類型都須要類型推斷,但將方法組做爲其餘方法的參數進行轉換時,會顯得極其混亂:要調用的方法有多個重載的方法,方法組內的方法也有多個重載方法,並且還可能涉及泛型泛型方法,一大堆可能的轉換會令人暈頭轉向。到目前爲止,最多見的類型推斷調用方法時不指定任何類型實參。在LINQ裏面,這類事情是時刻都在發生的——查詢表達式的工做方式嚴重依賴於此。這個過程被處理得如此順暢,以致於很容易忽視編譯器幫你作的大量工做,而這一切都是爲了使你的代碼更清晰,更簡潔。

隨着Lambda表達式的引入,C#3中的狀況變得更復雜——若是用一個Lambda表達式來調用一個泛型方法,同時傳遞一個隱式類型的參數列表,編譯器就必須先推斷出你想要的是什麼類型,而後才能檢查Lambda表達式的主體。用實際的代碼更容易說明問題。下面的代碼清單列出了咱們想解決的一類問題:用Lambda表達式調用一個泛型方法。

static void PrintSomeValue<TInput, TOutput>(TInput input, Converter<TInput,TOutput> convert)
        {
            Console.WriteLine(convert(input));
        }
....
PrintSomeValue("i am a string",x=>x.Length);

PrintSomeValue方法直接獲取一個輸入的值和委託,將該值轉換成不一樣類型的委託。它未就類型參數TInput和TOutput做出任何假設(沒有定義類型參數約束),所以徹底是通用的。如今,讓咱們研究一下在本例中最後一行調用該方法時實參的類型究竟是什麼。第1個實參明顯是字符串,但第2個呢?它是一個Lambda表達式,因此須要把它轉換成一個Converter<TInput,TOutput>——而那意味着要知道TInput和TOutput的類型。

C#2的類型推斷規則時單獨針對每個實參來進行的,從一個實參推斷出的類型沒法直接用於另外一個實參。在當前這個例子中,這些規則會妨礙咱們爲第2個實參推斷出TInput和TOutput的類型。因此,若是仍是沿用C#2的規則,代碼清單9-11的代碼就會編譯失敗。本節的最終目標就是讓你明白是什麼使上述代碼清單在C#3中成功經過編譯,但讓咱們先從一些難度適中的內容入手。

推斷匿名函數的返回類型

下面的代碼清單展現了貌似能編譯,可是不符合C#2類型推斷規則的示例代碼。

 delegate T MyFunc<T>();

 static void WriteResult<T>(MyFunc<T> function)
        {
            Console.WriteLine(function());
        }
.........
WriteResult(delegate{return 5});

這段代碼在C#2中會報錯:

error CS04011:The type argument for method ..... can not be inferred from the usage.try specifying the type arguments explicitly.

能夠採起兩種方式修正這個錯誤:要麼顯式指定類型實參(就像編譯器推薦的那樣),要麼將匿名方法強制轉換爲一個具體的委託類型:

WriteResult<int>(delegate{return 5;});
WriteResult((MyFunc<int>)delegate {return 5;});

這兩種方式均可行,但看起來都有點兒使人生厭。咱們但願編譯器能像對非委託類型所作的那樣,執行相同的類型推斷,也就是根據返回的表達式的類型來推斷T的類型。那正是C#3爲匿名方法和Lambda表達式所作的事情——但其中存在一個陷阱。雖然在許多狀況下都只涉及一個return語句,但有時會有多個。

下面的代碼清單是上面代碼稍加修改的一個版本,其中匿名方法有時返回int,有時返回object:

..........
WriteResult(delegate
            {
                if (DateTime.Now.Hour < 10) return 5;
                else return new object();
            });

在這種狀況下,編譯器採用和處理隱式類型的數組時相同的邏輯來肯定返回類型,詳情可參見上面。它構造一個集合,其中包含了來自匿名函數主體中的return語句的全部類型1(本例是int和object),並檢查是否集合中的全部類型都能隱式轉換成其中的一個類型。int到object存在一個隱式轉換(經過裝箱),但object到int就不存在了。因此,object被推斷爲返回類型。若是沒有找到符合條件的類型,或者找到了多個,就沒法推斷出返回類型,編譯器會報錯。

咱們如今知道了怎樣肯定匿名函數的返回類型,可是,參數類型能夠隱式定義的lambda表達式又如何呢?

分兩個階段進行的類型推斷

C#3中的類型推斷的細節與C#2中相比,要複雜的多,你能夠參考C#語言規範中要求的那樣,一步一步的來,在這裏,咱們要採起一種較爲簡單明瞭的方式來思考類型推斷--效果和你粗讀一遍規範差很少。但這種方式更容易理解。而若是編譯器不能徹底按照你的意願進行推斷,最後也只是會生成一個錯誤的提示,而不會編譯成錯誤的結果(程序),因此沒什麼大不了的。

第一個巨大的改變是全部方法實參在C#3中是一個「團隊」總體。在C#2中,每一個方法實參都被單獨用於嘗試肯定一些類型參數。針對一個特定的類型參數,若是根據兩個方法實參推斷出不一樣的結果,編譯器就會報錯——即便推斷結果是兼容的。但在C#3中,實參可提供一些信息——被強制隱式轉換爲具體類型參數的最終固定變量的類型。用於推斷固定值(下面會提到的一個術語)所採用的邏輯與推斷返回類型和隱式類型的數組是同樣的。

下面展現一個例子:

 static void PrintType<T>(T first, T second)
        {
            Console.WriteLine(typeof(T));
        }
...
 PrintType(1,new object());

C#2中,上述代碼雖然在語法上是有效的,但不能成功編譯:類型推斷會失敗,由於從第一個實參判斷出T應該是int,第二個判斷出T確定是object,兩個就衝突了。可是在C#3中,編譯器的推斷過程已經更加全面,推斷返回類型時所採用的規則就是其中一個具備表明性的例子。

第二個改變在於,類型推斷如今是分兩個階段進行的。第一個階段處理的是「普通」的實參,其類型是一開始便知道的。這包括那些參數列表是顯式類型的匿名函數。

稍後進行的第二個階段是推斷隱式類型的Lambda表達式和方法組的類型。其思想是,根據咱們迄今爲止拼湊起來的信息,判斷是否足夠推斷出Lambda表達式(或方法組)的參數類型。若是能,編譯器就能夠檢查Lambda表達式的主體並推斷返回類型——這個返回類型一般能幫助咱們肯定當前正在推斷的另外一個類型參數。若是第二個階段提供了更多的信息,就重複執行上述過程,直到咱們用光了全部線索,或者最終推斷出涉及的全部類型參數。

下面的流程圖展現了這一過程,不過這只是該算法簡化後的版本。

下面用兩個例子來展現這個過程。下面使用了上面展現出來的一段代碼:

static void PrintSomeValue<TInput, TOutput>(TInput input, Converter<TInput,TOutput> convert)
        {
            Console.WriteLine(convert(input));
        }
....
PrintSomeValue("i am a string",x=>x.Length);

上述代碼清單須要推斷的類型參數是TInput和TOutput。具體步驟以下。

一、階段1開始。

二、第1個參數是TInput類型,第1個實參是string類型。咱們推斷出確定存在從string到TInput的隱式轉換。

三、第2個參數是Converter<TInput,TOutput>類型,第2個實參是一個隱式類型的Lambda表達式。此時不執行任何推斷,由於咱們沒有掌握足夠的信息。

四、階段2開始。

五、TInput不依賴任何非固定的類型參數,因此它被肯定爲string。

六、第2個實參如今有一個固定的輸入類型,但有一個非固定的輸出類型。咱們可把它視爲(stringx)=>x.Length,並推斷出其返回類型是int。所以,從int到TOutput一定會發生一個隱式轉換。

七、重複「階段2」。

八、TOutput不依賴任何非固定的類型參數,因此它被肯定爲int。

九、如今沒有非固定的類型參數了,推斷成功。

下一個例子更好的展現了重複階段2的重要性。他執行了兩個轉換,第一個輸出成爲第二個的輸入。在推斷出第一個轉換的輸出類型以前,咱們不知道第二個的輸入類型,因此也不能推斷出它的輸出類型。

 public static void ConvertTwice<TInput,TMiddle,TOutput>(TInput input ,
            Converter<TInput,TMiddle> firstConverter,
            Converter<TMiddle,TOutput> secondConverter)
        {
            TMiddle middle = firstConverter(input);
            TOutput output = secondConverter(middle);
            Console.WriteLine(output);
        }
.............
ConvertTwice("another string",text=>text.Length,length=>Math.Sqrt(length));

要注意的第一件事是方法簽名看起來至關恐怖,但當你再也不懼怕,並仔細觀察它時,發現它也沒那麼恐怖——固然示範用法使它看上去更直觀。咱們獲取一個字符串,對它執行一次轉換:這個轉換和以前的轉換是相同的,只是一次長度計算。而後,咱們獲取長度(int),並計算它的平方根(double)。類型推斷的「階段1」告訴編譯器確定存在從string到TInput的一個轉換。第一次執行「階段2」時,TInput固定爲string,咱們推斷確定存在從int到TMiddle的一個轉換。第二次執行「階段2」時,TMiddle固定爲int,咱們推斷確定存在從double到TOutput的一個轉換。第三次執行「階段2」時,TOutput固定爲doluble,類型推斷成功。當類型推斷結束後,編譯器就能夠正確地理解Lambda表達式中的代碼。

說明 檢查lambda表達式的主體 Lambda表達式的主體只有在輸入參數的類型已知以後才能進行檢查。若是x是一個數組或者字符串,那麼Lambada表達式x=>x.Length就是有效的,但在其餘許多狀況下它是無效的。當參數類型是顯式聲明的時候,這並非一個問題,但對於一個隱式(類型)參數列表,編譯器就必須等待,直到他執行了相應的類型推斷以後,才能嘗試去理解lambda表達式的含義。

這些例子每次只展現了一個改變①——在實際應用中,圍繞不一樣的類型變量可能產生多個方面的信息,這些信息多是在不一樣的重複階段發現的。爲了不你(和我)絞盡腦汁,我決定再也不展現任何更復雜的例子了——你只需理解常規的機制就能夠了,即便確切的細節可能仍然是模模糊糊的也沒關係。

①所說的「改變」是指本節所描述的C#3與C#2相比,在類型推斷上的兩個改變,一個改變是方法實參協同肯定最後的類型實參,另外一個改變是類型推斷如今分兩個階段進行。但這些改變並非孤立的,而是相互聯繫,共同發揮做用的。可是,做者前面的例子並無反映出這一點,他的每一個例子只是展現了其中的一個改變

雖然這種狀況表面上很是罕見,彷佛不值得爲其設立如此複雜的規則,但它在C#3中實際是很是廣泛的,尤爲是對LINQ而言。事實上,在C#3中,你能夠在不用思考的狀況下大量地使用類型推斷——它會成爲你的一種習慣。然而,若是推斷失敗,你就會奇怪爲何。屆時,你能夠從新參考這裏的內容以及語言規範。

還有一個改變須要討論,但聽到下面的話,你會很高興,這個改變比類型推斷簡單:方法重載。

選擇正確的被重載的方法

若是多個方法的名字相同但簽名不一樣,就會發生重載。有時,具體該用哪一個方法是顯而易見的,由於只有它的參數數量是正確的,或者只有用它,全部實參才能轉換成對應的參數類型。可是,假如多個方法看起來都合適,就比較麻煩了。7.5.3節規範中的具體規則至關複雜--但關鍵在於每一個實參類型轉換成參數類型的方式。例如,假定有如下方法簽名,彷佛他們都是在同一個類中聲明的:

void Write(int x);
void Write(double y);

Write(1.5)的含義顯而易見,由於不存在從double到int的隱式轉換,但Write(1)對應的調用就麻煩一些。因爲存在從int到double的隱式轉換,因此以上兩個方法彷佛都合適。在這種狀況下,編譯器會考慮從int到int的轉換,以及從int到double的轉換。從任何類型「轉換成它自己」被認爲好於「轉換成一個不一樣的類型」。這個規則稱爲「更好的轉換」規則。因此對於這種特殊的調用,Write(intx)方法被認爲好於Write(doubley)。

若是方法有多個參數,編譯器須要確保存在最適合的方法。若是一個方法所涉及的全部實參轉換都至少與其餘方法中相應的轉換「同樣好」,而且至少有一個轉換嚴格優於其餘方法,咱們就認爲這個方法要比其餘方法好。

現給出一個簡單的例子,假定如今有如下兩個方法簽名:

void Write(int x,double y);
void Write(double x,int y);

對Write(1,1)的調用會產生歧義,編譯器會強迫你至少爲其中的一個參數添增強制類型轉換,以明確你想調用的是哪一個方法。每一個重載都有一個更好的實參轉換,所以都不是最好的。

一樣的邏輯在C#3中仍然適用,但額外添加了與匿名函數(lambda表達式和匿名方法的統稱)有關的一個規則(匿名函數永遠不會指定一個返回類型)。在這種狀況下,推斷的返回類型在「更好的轉換」規則中使用。

下面來看一個須要新規則的例子。下列代碼包含兩個名爲Execute的方法,另外還有一個使用了Lambda表達式的調用。

 static void Execute(Func<int> action)
        {
            Console.WriteLine($"action result is an int {action()}");
        }

        static void Execute(Func<double> action)
        {
            Console.WriteLine($"action result is a double {action()}");
        }
......
Execute(()=>1);

對Execute方法的調用能夠換用一個匿名方法來寫,也能夠換用一個方法組----無論以什麼方式,凡是涉及轉換,所應用的規則都是同樣的。那麼,最後會調用哪一個Execute方法呢?重載規則指出,在執行了對實參的轉換以後,若是發現兩個方法都合適,就對那些實參轉換進行檢查,看哪一個轉換「更好」。這裏的轉換並非從一個普通的.NET類型到參數類型,而是從一個Lambda表達式到兩個不一樣的委託類型。那麼,哪一個轉換「更好」?

使人吃驚的是,一樣的狀況若是在C#2中發生,那麼會致使一個編譯錯誤——由於沒有針對這種狀況的語言規則。但在C#3中,最後會選中參數爲Func<int>的方法。額外添加的規則能夠表述以下:若是一個匿名函數能轉換成參數列表相同,但返回類型不一樣的兩個委託類型,就根據從「推斷的返回類型」到「委託的返回類型」的轉換來斷定哪一個委託轉換「更好」。

若是不拿一個例子來做爲參考,這段話會繞得你頭暈。讓咱們回頭研究一下代碼清單:如今是從一個無參數、推斷返回類型爲int的Lambda表達式轉換成Func<int>或Func<double>。兩個委託類型的參數列表是相同的(空),因此上述規則是適用的。而後,咱們只需判斷哪一個轉換「更好」就能夠了:int到int,仍是int到double。這樣就回到了咱們熟悉的問題上——如前所述,int到int的轉換更好。所以,代碼清單會在屏幕上顯示:action result is an int:1。

類型推斷和重載決策

這一節說了那麼多可是我感受仍是沒有很明白的講清楚,由於這是一個很龐大的主體,它自己就是複雜的。總結一下這一小節的內容吧:

  • 匿名函數(匿名方法和Lambda表達式)的返回類型是根據全部return語句的類型來推斷的;
  • Lambda表達式要想被編譯器理解,全部參數的類型必須爲已知;
  • 類型推斷不要求根據不一樣的(方法)實參推斷出的類型參數的類型徹底一致,只要推斷出來的結果是兼容的就好;
  • 類型推斷如今分階段進行,爲一個匿名函數推斷的返回類型可做爲另外一個匿名函數的參數類型使用;
  • 涉及匿名函數時,爲了找出「最好」的重載方法,要將推斷的返回類型考慮在內。

asp.net core中的管道註冊

上面說了這麼多,不把他用於實踐中也是不划算的。在asp.net core中的管道註冊使用了RequestDelegate這個委託來表示asp.net core中的抽象,可是這個管道拼接的過程又是由一個Func<RequestDelegate,RequestDelegate>來表示的。讓咱們深究一下。咱們先來看一段註冊的例子:

代碼1:

 app.Use(async (context, next) =>
            {
                if (context.Request.Path == "/foo")
                {
                    await context.Response.WriteAsync("foo");
                }
                else
                {
                    await next();
                }
            });

上面代碼就是一個使用Use註冊asp.net core管道的例子,他接受的參數爲一個Func<HttpContext,Func<Task>,Task>的委託。在這個方法的內部它是調用另外一個Use方法來實現的:

代碼2:

 public static IApplicationBuilder Use(this IApplicationBuilder app, Func<HttpContext, Func<Task>, Task> middleware)
    {
      return app.Use((Func<RequestDelegate, RequestDelegate>) (next => (RequestDelegate) (context =>
      {
        Func<Task> func = (Func<Task>) (() => next(context));
        return middleware(context, func);
      })));
    }

代碼1中Use方法實現如代碼2所示,在調用代碼1中的Use方法時,傳入了一個表明Func<HttpContext,Func<Task>,Task>委託的middleware變量,而在代碼2表示的Use方法內部,又調用了另外一個Use方法,這個Use方法接收一個Func<RequestDelegate, RequestDelegate> 委託類型的參數,並最終將這個委託添加到IApplicationBuilder實現類的一個列表上去。代碼2中的context是根據Use方法傳入的Func<RequestDeleagte,RequestDelegate>類型的第二個類型參數推斷出來的。

相關文章
相關標籤/搜索