C#的發展歷程第五 - C# 7開始進入快速迭代道路


C# 7開始,C#加快了迭代速度,多方面的打磨讓C#在易用性,效率等各方面都向完美靠近。另外得益於開源,社區對C#的進步也做了很大共享。下面帶領大家看看C# 7的新特性。其中一部分是博主已經使用過,沒用過的根據官方文檔進行了整理。

out變量

有一定C#編程經歷的園友一定沒少寫如下這樣的代碼:

int speed;
if (int.TryParse(speedStr, out speed))
    speed*=10;

為了增加程序的健壯性,在進行類型轉換時使用TryXXX方法是很好的實踐。但由於這樣的寫法實在太顯啰嗦,所以常常我們認為轉換一定能正確進行時就會偷懶直接用Parse了事,當然這樣就給程序留下了出現異常的隱患。現在有了out變量支持,可以以如下方式編寫安全的轉換:

if (int.TryParse(speedStr, out int speed))
    speed*=10;

雖然if還在,但少了孤零零的變量聲明,代碼看起來已經很美觀了,終於可以快樂的編寫健壯的代碼了。
out變量也支持類型推導,把out后面的int換成var也是完全可以了。另外speed變量的作用域不限於if內,在if外也是可以使用的。

除了Parse類方法,許多Get類方法也從中受益,比如從Dictionary中按key取值:

if (!dstDic.TryGetValue(fileName, out var dstFile))
{
    ...
}

而且自定義的out參數也可以享受這樣的便利。

值元組

值元組是博主認為C# 7帶來的比較重要的改進之一。因為之前Tuple雖好,但寫起來實在是太羅嗦。而值元組的出現使C#在元組的使用方面有這么一點點接近Python(雖然在類型方面靈活性還差了這么一丟丟)。值元組的類型ValueTuple在.NET Framework 4.7中首發,博主一度認為只有基於.NET Framework 4.7的項目才能享受值元組的便利,甚至為此感到遺憾(目前工作中大部分項目都是.NET Framework 4.5/4.6或.NETCore1.1/2.0)后來看到官方文檔才知道微軟單獨提供了一份System.ValueTuple的程序集供沒有原生ValueTuple類型的框架使用。
下面將通過與傳統元組對比的方式,讓各位領略值元組的簡練。

首先在元組類型對象的創建方面,ValueTuple看起來更簡潔。

// 創建Tuple的兩種方式
var tuple1 = Tuple.Create(1, "小明");
var tuple2 = new Tuple<int, string>(2, "小明");
// 創建ValueTuple
var valueTuple1 = (1, "小明");

而在使用方面,Tuple使用以Item1,Item2...為名的屬性來訪問相應位置的對象。ValueTuple也支持同樣的方式:

// Tuple
Console.WriteLine($"{tuple1.Item1}-{tuple1.Item2}");
// ValueTuple
Console.WriteLine($"{valueTuple1.Item1}-{valueTuple1.Item2}");

當然ValueTuple之所以好用,就是因為它有更方便的成員的訪問方式,可以在構造一個ValueTuple時指定相應成員的訪問名稱:

var valueTuple1 = (Id: 1, Name: "小明");
Console.WriteLine($"{valueTuple1.Id}-{valueTuple1.Name}");

在C#7.1中,如果使用變量構造ValueTuple會自動推斷元組成員的名稱,如上面的代碼改造一下:

// C#7.1有效
var Id = 1;
var Name = "小明";
var valueTuple1 = (Id, Name);
Console.WriteLine($"{valueTuple1.Id}-{valueTuple1.Name}");

如果不在構造時指定訪問名稱,也可以在聲明變量時使用下面這樣的類型:

(int Id, string Name) vt = (1, "小明");
Console.WriteLine($"{vt.Id}-{vt.Name}");

(int Id, string Name)這樣的類型聲明是C#語法為了對值元組進行支持帶來的最大的改進。在之前,如果不使用var類型推斷則只能使用Tuple<int,string>這樣冗長的類型來進行聲明(值元組也支持ValueTuple<int,string>這樣的類型聲明,但由於有了新的語法,這種方式基本沒有人用,(int,string)就是ValueTuple<int,string>等價的語法糖)。

另外一個語法級的值元組支持就是元組析構(不得不說這種翻譯很容易混淆),如:

(var id, var name) = (1, "小明");
// 也可以寫成
var (id,name) = (1, "小明");
Console.WriteLine($"{id}-{name}");

可以在元組析構時,使用_來表示放棄(可能你已經在模式匹配、lambda表達式創建等地方見到過_符號的蹤跡),如上面的例子,我們只需要值元組中的一部分:

(var id, _) = (1, "小明"); // 等效於 var (id, _) = (1, "小明");
Console.WriteLine($"{id}");

C# 7還給自定義類型也帶來擴展類似元組析構功能的方法,下面的例子是來自MSDN(改了一丟丟)

public class Point
{
    public double X { get; set; }
    public double Y { get; set; }

    public void Deconstruct(out double x, out double y)
    {
        x = this.X;
        y = this.Y;
    }
}
// 析構
var p = new Point(){X=1,Y = 2};
var (x1, y1) = p;

實現的關鍵就是自定義Deconstruct函數,由例子可見x1與out參數x沒有關系,析構過程定義的變量可以取任意名字。

基本上值元組的用法就是上面所述,值得多說的一點,當值元組應用在方法中時,可以給返回多個值的操作提供極大的遍歷:

private static (int, string) NewStudent()
{
    return (1, "小明");
}
// 使用
(int Id, string Name) = NewStudent();
Console.WriteLine($"{Id}-{Name}");

或者

private static (int Id, string Name) NewStudent()
{
    return (1, "小明");
}
var stu = NewStudent();
Console.WriteLine($"{stu.Id}-{stu.Name}");

就是這么靈活。好了,值元組部分就到這里。

本地函數

本地函數是一種嵌套在函數內部的函數。在本地函數出現之前要想在函數內實現一個可以被重復調用的函數一般只能選擇lambda表達式。下面展示一個博主寫代碼時遇到的例子,也是在寫這段代碼時博主才知道了本地函數的存在。

先來看一段代碼,代碼中使用TaskCompletionSource將一段調用噴碼機的EAP異步代碼包裝為async/await代碼:

值得注意的是我們需要在異步處理結束前取消事件的注冊,不然當這個函數被再次調用時TaskCompletionSource會拋出System.InvalidOperationException,異常消息為:“在已經完成任務后,嘗試將任務轉換為最終狀態。”。
解決辦法就是在事件處理函數中取消事件訂閱,在本地函數出現之前做法如上圖的代碼。
先聲明一下一個lambda表達式,然后去編寫lambda表達式的實現,在實現中取消對lambda的訂閱。一定要把聲明和賦值分開,不然無法取消訂閱。
這種實現是完全沒有問題的。那博主是怎么發現本地函數這個特性的呢。這要感謝大神級插件,站在宇宙第一IDE肩膀上的宇宙第一插件 - ReSharper。仔細觀察在failHandler這個表示lambda表達式的變量下有一條綠色的下划線,這是ReSharper在提示有更好的寫法。
鼠標焦點定位在failHandler上,並點擊此行前面出現的燈泡圖標,會看到如下提示:

樓主第一次看到也是比較愣。“local function”是什么東西。試着點擊后發現代碼被改寫為下面的樣子:

ConnectAsync函數中又出現了一個函數。然后不免一番搜索后發現這是C# 7的一個新特性 - 本地函數。而這段代碼又正好是本地函數一個比較好的使用例子。

最后附上本地函數的文檔地址。里面詳細的列出了本地函數的語法,可使用的位置及一些使用場景,值得一看。

模式匹配

C# 6中出現的異常過濾器有一點點模式匹配的味道。
C# 7全面引入的模式匹配,表現在對switch caseis進行了擴展,從而讓C#對類型的處理更加優雅。首先來看一下對switch case的擴展,下面這段方法依然是來自最近完成的一個項目中。
這個方法是一個訪問WebAPI服務獲取數據的本地代理的一部分,受到Jeffcky這篇博文的啟發,決定嘗試使用Polly庫完成超時重試等功能,同時使用Polly優雅的封裝異常,從而使調用方可以安心的去調用。

var oc = policyRet.Outcome;
if (oc == OutcomeType.Successful)
{
    resultStr = policyRet.Result;
}
else
{
    switch (policyRet.FinalException)
    {
        case WebException _:
        case HttpRequestException _:
            resultStatus = ResultStatus.NetFailed;
            break;
        case HttpStatusErrorException statusEx when statusEx.HttpStatus == 404:
            resultStatus = ResultStatus.PageNotFound;
            break;
        case HttpStatusErrorException statusEx when statusEx.HttpStatus == 500:
            resultStatus = ResultStatus.ServerError;
            break;
        case UriFormatException _:
            resultStatus = ResultStatus.UriError;
            break;
        case TaskCanceledException _:
            resultStatus = ResultStatus.TimeOut;
            break;
        case null:
        default:
            resultStatus = ResultStatus.Unknown;
            resultStr = $"{policyRet.FinalException?.GetType()}:{policyRet.FinalException?.Message}";
            break;
    }
}

oc對象是Polly的執行返回結果,代碼中使用模式匹配加持過的switch來進行異常類型的處理。傳統的switch不能處理除了數值類型和字符串以外的其它類型的對象。而模式匹配的出現使switch成為一個可以取代if ... else if ...的存在。
現在case中可以對switch內傳入的對象進行類型判斷並進入相應的分支,相當於以前這樣的判斷語句:

if (obj is Type) { ... }

現在寫成swtich case的形式,代碼看起來更加簡潔優雅。同時case分支中還新支持使用when關鍵字對類型匹配的對象進行進一步過濾。如上面代碼中第三與第四條case
對於不需要進一步對象屬性過濾的類型判斷,可以直接使用_作為占位符,_占位符這個語法在lambda表達式等中出現過。
甚至case語句還可以直接對null進行判斷,我們可以放心的把可能為空的對象直接傳入switch中。(上面代碼由於default分支的存在,case null:是可以省略的,這樣寫是為了展示null的操作)

模式匹配的另一個方面是體現在對is關鍵字的擴展,之前版本的C#將一個泛化的類型的對象轉為具體類型的對象免不了寫以下這樣的代碼(偽代碼):

if (obj is TypeA)
{
    (TypeA)obj;
    //或
    var objA = obj as TypeA;
}

現在可以把類型判斷與轉換合二為一了:

if (obj is TypeA objA)
   Console.WriteLine(objA.Name);

返回結果引用

說起引用ref,這可能是C#出現最早,但博主使用頻率最低的一個特性。由於這些年寫過的代碼很少是性能敏感型的,很少有意的去注意使用一些ref參數。之前ref只適用於方法的參數,現在7.0版本的C#將ref拓展到方法的返回值。同樣博主目前沒有在任何代碼中用過這個特性,下面所寫的內容也是在學習這個特性的過程中才了解到。
返回結果引用也是為了在一些性能敏感的場景,還是以一個例子來給讀者一個初印象:

public class Sample
{
    long[] _bigArr = { 11, 22, 33, 44, 55 };

    private ref long ReturnRef(int idx)
    {
        if (idx >= _bigArr.Length)
            throw new ArgumentOutOfRangeException();

        return ref _bigArr[idx];
    }

    private void TestReturnRef()
    {
        ref var refVal = ref ReturnRef(2);
    }
}

例子中ReturnRef就是返回引用的函數,包括調用在內的整段代碼中ref關鍵字一共出現了4次。前兩個出現在方法聲明和return語句中。后兩個分別是引用變量的聲明和表示以引用方式調用函數。它們一個都不能少。
refVal作為一個引用變量,必須在聲明的時候直接賦值,如上面代碼中那樣,否則是不能通過編譯的。refVal這個引用變量和C++中的引用非常類似,對其賦值會修改其所指向的位置所存儲的值,如:

ref var refVal = ref ReturnRef(2);
refVal = 100;
Console.WriteLine(_bigArr[2]);

這段代碼輸出100,即修改后的值。

如果調用語句寫成如下這樣:

private void TestReturnNoRef()
{
    var val = ReturnRef(2);
}

這樣也可以執行,但這無異於調用一個返回普通值的方法,val只是一個普通的本地變量,修改它不會導致引用位置的值被修改:

ref varval = ref ReturnRef(2);
val = 100;
Console.WriteLine(_bigArr[2]);

這段代碼將輸出33。
MSDN中給出了返回引用的函數的三種使用限制,其中只有第二條,即“不能將引用返回給其生存期不超出方法執行的變量。(英文原文:You cannot return a ref to a variable whose lifetime does not extend beyond the execution of the method. ps.翻譯有問題...)”需要注意,其余兩條會引起編譯失敗可以不提。那這最難理解的第二條是什么意思的呢,還是以上面的例子來描述,我們返回的引用來自類成員變量,其生命周期大於函數的聲明周期,這是合理的,如果_bigArr是在ReturnRef方法內部聲明的本地變量,雖然不會報錯,但並沒有實際的使用價值,從而被列入使用限制中。

返回引用的方法這個新特性就介紹到此。更多引用方面的增強見C#7.2部分

一些其它小改進

表達式體可應用於更多場景

在之前介紹C#6的博文的這部分介紹了可以使用表達式體(expression-bodied)的一些場景,如方法實現,只讀屬性的實現。C#7中擴展了表達式體可應用的場景:

  • 構造函數及析構函數
  • 屬性和索引器的get/set訪問器

如:

private Dictionary<int,string> _students = new Dictionary<int, string>();

public string this[int id]
{
    get => _students[id];
    set => _students[id] = value ?? "no name";
}

新的異步返回類型 - ValueTask

C#7.0之前異步方法支持TaskTask<T>void三種返回類型,而7.0開始語言層面支持一個新增的ValueTask<T>(需要自行添加Nuget包才能使用)作為異步方法的返回類型。ValueTask<T>是值類型,在一些需要頻繁創建Task的場景中比引用類型的Task<T>性能更好。如:

public ValueTask<int> GetConstVal()
{
    return  new ValueTask<int>(100);
}

相信一個經常使用異步方法的程序猿肯定常遇到需要像上面這樣返回一個值的異步方法。MSDN給出的一個例子是包裝獲取緩存值的異步方法。這時用ValueTask<T>有可能獲得更好的性能。

throw表達式

C#中throw作為作為最早一批語句出現,用於拋出異常。而C#7中throw多了一個孿生兄弟,作為表達式的throw
這樣throw就可用於像是之前介紹的表達式體中。值得注意是像是上小節介紹的將表達式體用於構造函數,屬性初始化等場景中,如果用throw表達式拋出異常,則直接會導致對象構造失敗,最壞情況下會導致全局未處理異常,而使程序意外退出。
一個合理的使用方式如下,改自上面表達式體使用的示例:

public string this[int id]
{
    get => _students[id];
    set => _students[id] = value ?? throw new Exception("name can't be null");
}

數值字面量改進

這個特性包括兩部分,一是使用_作為數字間的分隔符,以使數值可讀性更好。且可以同時應用於整數與浮點數。如:

var num1 = 123_456_789L;
var num2 = 0.123_456_789D;

另一部分,就是C#終於開始支持二進制字面量,使用0b開頭表示這是一個二進制數值。同時也支持_作為數字間的分隔符。在7.0中不允許0b與數字間有_,C#7.2開始則接受0b與最高位數字間有_(十進制或十六進制數字字面量也可以以_開頭)。

var num1 = 0b0001_0011;  // C#7.0
var num2 = 0b_0001_0011; // C#7.2
var num5 = _123_456; // C#7.2
var num3 = 0x_00AA_00BB; // C#7.2

C# 7.1

最近一兩年來,C#的步伐明顯加快,C#第一次出現了0.1這樣的小版本號。可能和新的編譯器的出現及開源有關系,另外這也是第一次實現VS與語言版本的分開,之前版本的VS雖然可以兼容不同版本的.NET Framework,但都只對應特定版本的C#(編譯器),從現在開始可以獨立設置項目所使用C#語言版本。
7.x版本新增的特性與7.0版本相關的部分在上文已經一並介紹了。這里把其它一些改進也列出來。

首先介紹下如何去設置VS中項目所使用的語言版本。如下圖,在生成選項卡中點高級,彈出的對話框中,通過語言版本的下拉菜單可以選擇語言版本。默認項是“C#最新主要版本”,主要版本即對應7.0這種大版本。如果選擇色“C#最新的次要版本”則表示使用當前VS支持的最新語言版本,對於博主使用的VS15.4.3來說即7.1。

另外這個設置是配置敏感的,也就是說對於Debug和Release要進行同樣的設置,否則在進行不同的配置的生成時會出現問題。

異步入口方法

在之前版本的C#中入口方法即Main方法不能是異步的,所以在某些需要在Main中調用異步方法的情況下就不得不使用Wait().GetAwaiter().GetResult()來將異步方法轉為同步調用。而在某些情況下這可能導致死鎖。(控制台程序可以直接Wait異步方法而不會死鎖)
所以新版本帶來了支持異步的Main方法,這樣再也不用怕異步向上傳播了。

// 無返回值
static async Task Main(string[] args)
{
    await DoSomeTask();
}
// 返回int
static async Task<int> Main(string[] args)
{
    return await GetTaskResult();
}

增強的default

如果你常需要寫支持泛型的代碼,肯定對default這樣的用法不陌生:

public T GetSomething<T>()
{
    return default(T);
}

C#7.1開始極大的簡化了default的使用並擴展了default的由於范圍。
首先現在default可以對后面的類型自動自行對推斷,上面的返回語句可以直接寫為return default
同樣下面的簡化也可以:

(int, string) student = default((int,string));
// 簡化為
(int, string) student = default;

在擴展方面,現在可以使用default初始化參數默認值,如:

public NewStudent(int id, string name = default)
{
    // ...
}

而在之前,聲明string類型的默認參數只能用""。在調用方法時,可以傳入default表示相應的參數使用默認值(參數是否聲明為可選參數沒有關系)。

private static (int Id, string Name) NewStudent(int id, string name)
{
    return (id, name);
}
// 調用
NewStudent(1, default);

對於返回默認值的場景也可以直接簡單的使用default

private (int Id, string Name) NewStudent(int id, string name)
{
    if (!string.IsNullOrEmpty(name))
        return (id, name);
    return default;
}

C# 7.2

值類型的引用增強

in參數

之前版本的C#對於通過引用傳遞值類型的參數提供了refout兩種方式,各位應該也都或多或少的用過,而且也肯定知道如果不使用任何修飾來傳入值類型參數則會導致復制從而產生性能開銷。C#7.2開始新提供了in關鍵字來通過引用的方式進行值類型參數的傳遞。inref有一定的相似性,有一定限制,同時也更靈活。
in類型的參數最大的特點(一定意義上也是限制)就是不能在方法內修改in參數的值,也就是說其在方法作用域內是只讀的,這也是in參數最大特點所在。編譯器可以保證in參數的只讀,對於in參數的賦值或對於in參數(結構體)成員的賦值都是無法通過編譯的。in最大的作用是傳遞比較大的結構體時可以減少內存開支,相對而言比如傳遞int時用不用in參數都沒有更多益處,因為int所占的空間與引用(地址)所占的空間差不多。另外in也可用於引用類型參數,但同樣沒有太多益處。
in類型參數相對ref另一個大不同就是只需要在形參中使用in標明參數類型,在傳入實參時不需要再次添加in關鍵字。
in類型的參數支持默認值,支持實參為常量或字面量。

in的出現,加上之前的refout,使C#在通過引用傳入傳出值類型方面功能就很完備了。

refout一樣也不能通過in來區分函數的不同重載。

ref readonly返回

當希望使用(只讀)引用方式來提供一個值訪問的時候可以使用7.2中新增的ref readonly變量。這個特性解釋起來很簡單,直接照搬MSDN的示例代碼:

private static Point3D origin = new Point3D();
public static ref readonly Point3D Origin => ref origin;

代碼中Point3D是一個結構體,Origin就是結構體實例的一個只讀引用。使用這個引用有兩種方式:

var originValue = Point3D.Origin;
ref readonly var originReference = ref Point3D.Origin;

第一行代碼通過拷貝方式生產了一個Origin的副本。而第二行代碼傳遞的為引用,這也是我們希望ref readonly所發揮的作用。同時,注意對originReference的訪問都是只讀的。

readonly struct類型

新增的只讀的struct可以保證struct中的每個成員都不可修改,所以只讀的struct很適合與上面介紹的in參數或ref readonly返回共同使用。由於只讀struct的特性,編譯器不用再做其它工作來保證struct的成員不可變。同時當使用struct的成員時,編譯器會自動采用in參數的處理方式從而節省復制開銷。

只讀struct只需要struct聲明的最前面加上readonly關鍵字即可。

ref struct類型

新增的ref struct類型用於定義保證在棧空間內分配的值類型,這就意味着這中類型不能被裝箱,即不能作為class的成員等。(更多限制參見MSDN,這些限制都是為了保證ref struct不被放到堆中 )
ref struct類型的一個作用就是聲明ReadOnlySpan<T>這個類型,ref struct的特性保證了ReadOnlySpan<T>內索引操作的句對安全。關於Span<T>系類型推薦YOYOFx的這篇文章

靈活的命名參數位置

C# 4.0增加的命名參數有一個限制,就是當普通參數與命名參數混合使用時,名命參數必須位於最后。C#7.2中這個限制被放開,命名參數可以位於任意位置,但要求其它位置的參數必須在恰當的順位上。繼續借用MSDN的示例,來看看對於C#7.2什么樣的調用是合法什么是不合法。

// 被調用的函數
static void PrintOrderDetails(string sellerName, int orderNum, string productName)
{
    // 省略...
}

// C#7.2之前,混合使用時,只允許命名參數放在最后
PrintOrderDetails("Gift Shop", 31, productName: "Red Mug");

// C#7.2,混合使用,命名參數可以在任意位置,普通參數需位於恰當的順位
PrintOrderDetails(sellerName: "Gift Shop", 31, productName: "Red Mug"); // 31對應形參orderNum

// C#7.2非法示例
// PrintOrderDetails(productName: "Red Mug", 31, "Gift Shop"); // 31, "Gift Shop"無正確對應的形參,編譯不通過

private protected修飾符

新增的private pretected修飾符表示被修飾的成員可以在下范圍內訪問:

  • 在其所在的類內部
  • 與其所在類位於同一個程序集的所在類的子類內部

值得注意的就是區分之前存在的protected internal修飾符,后者表示訪問范圍在當前程序集內部所有類以及所在類的子類(主要指那些與所在類不在同一個程序集的子類)。這么來看它們的區別還是很大的。

本文到此,截止到7.2版C#,應該是覆蓋了7.0到7.2所有新特性。如果7.x還有0.3或0.4出現會放在后面8.x的文章中。
歡迎各位收藏查閱。走過路過也不妨點個贊。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM