展望 C# 7 的未來

oschina發表於2016-06-27

目前的C#編譯器(即Roslyn)於2014年4月開源。目前不僅是編譯器在GitHub上開發;語言的設計也是進行公開的討論。 這允許感興趣的各方瞭解語言下一個版本的樣子。這篇文章概述了當前在設計語言新特性時的思考過程。如果你對現在Roslyn生態系統的更廣泛的方面感興趣的話,可以閱讀DotNetCurry(DNC)雜誌2016年3月版上我的文章: .NET編譯器平臺(Roslyn)概述 

下一版 C#的主題

截止目前,每個版本的C#(C# 6.0可能除外)都會圍繞一個特定的主題:

  • C# 2.0 引入泛型。
  • C# 3.0 通過擴充套件方法、lambda表示式、匿名型別和其他相關特性帶來了LINQ。
  • C# 4.0 都是關於與動態非強型別語言的互操作。
  • C# 5.0 簡化非同步程式設計和非同步等待等關鍵詞。
  • C# 6.0 完全重寫,並且引入了各種各種更易實現的小特性和改進。你可以在DotNetCurry(DNC)雜誌2016年1月版上找到一篇C#6.0特性的概述文章:U升級現有C#程式碼到 C# 6.0

C# 7.0 可能不會有例外。語言設計者們目前專注於三個主題:

  • Web服務的使用增長正在改變資料建模的方式。資料模型的定義正在成為服務契約的一部分,而不是在應用程式去完成。雖然這在函式式語言中是是非常方便的,但是它給物件導向開發帶來了額外的複雜度。幾個 C# 7的特性正是以通過外部資料契約來簡化該工作為目標的。
  • 日益增長的移動裝置共享使得效能成為一個重要的考量因素。C# 7.0的計劃特性允許進行效能優化,以前這在.Net框架上是不可能的。
  • 可靠性和魯棒性是軟體開發中一個永恆的挑戰。C# 7.0可能用一部分開發時間來應對這個挑戰。

讓我們仔細看看每個主題的一些計劃中特性。

處理資料

面嚮物件語言比如C#在一組預定義的操作作用於一組可擴充套件的資料型別這樣的場景中工作的很好。這些通常是通過一個介面(或者一個基類)對可用操作進行建模,以不斷增加的子類表示資料型別。通過實現介面,類包含了各種操作的實現。

比如,在一個遊戲中,武器可能是各種不同型別(比如一把劍或者一張弓),並且操作可能也是不同的動作(比如攻擊或者修復),增加一個新的武器型別(比如一把光劍)會很簡單:建立一個新類,實現武器的介面。增加一個新動作(如轉動)另外一方面就需要擴充套件介面和修改已有的武器實現。這在C#中是很自然的。

interface IEnemy
{
    int Health { get; set; }
}

interface IWeapon
{
    int Damage { get; set; }
    void Attack(IEnemy enemy);
    void Repair();
}

class Sword : IWeapon
{
    public int Damage { get; set; }
    public int Durability { get; set; }

    public void Attack(IEnemy enemy)
    {
        if (Durability > 0)
        {
            enemy.Health -= Damage;
            Durability--;
        }
    }

    public void Repair()
    {
        Durability += 100;
    }
}

class Bow : IWeapon
{
    public int Damage { get; set; }
    public int Arrows { get; set; }

    public void Attack(IEnemy enemy)
    {
        if (Arrows > 0)
        {
            enemy.Health -= Damage;
            Arrows--;
        }
    }

    public void Repair()
    { }
}

在函數語言程式設計中,資料型別不包括操作。相反,每一個函式對所有資料型別實現一個單一的操作。 這使得增加新操作(只需要定義一個新函式)更容易,但是增加新資料型別(需要修改所有已有相應的函式)卻更難了。但是這在C#中是可能的了,它更加繁瑣一些。

interface IEnemy
{
    int Health { get; set; }
}

interface IWeapon
{
    int Damage { get; set; }
}

class Sword : IWeapon
{
    public int Damage { get; set; }
    public int Durability { get; set; }
}

class Bow : IWeapon
{
    public int Damage { get; set; }
    public int Arrows { get; set; }
}

static class WeaponOperations
{
    static void Attack(this IWeapon weapon, IEnemy enemy)
    {
        if (weapon is Sword)
        {
            var sword = weapon as Sword;
            if (sword.Durability > 0)
            {
                enemy.Health -= sword.Damage;
                sword.Durability--;
            }
        }
        else if (weapon is Bow)
        {
            var bow = weapon as Bow;
            if (bow.Arrows > 0)
            {
                enemy.Health -= bow.Damage;
                bow.Arrows--;
            }
        }
    }

    static void Repair(this IWeapon weapon)
    {
        if (weapon is Sword)
        {
            var sword = weapon as Sword;
            sword.Durability += 100;
        }
    }
}

模式匹配是可以幫助簡化上述程式碼的特性。讓我們來一步一步將它應用到Attack方法中:

static void Attack(this IWeapon weapon, IEnemy enemy)
{
    if (weapon is Sword sword)
    {
        if (sword.Durability > 0)
        {
            enemy.Health -= sword.Damage;
            sword.Durability--;
        }
    }
    else if (weapon is Bow bow)
    {
        if (bow.Arrows > 0)
        {
            enemy.Health -= bow.Damage;
            bow.Arrows--;
        }
    }
}

替代原有兩句分離的語句來檢查武器型別並將其賦值相應型別的變數,現在is操作符將允許我們宣告一個新變數並分類型別值。

類似的結果,一個switch case語句可以替代if。這使得程式碼更加清晰,特別是有很多分支時:

switch (weapon)
{
    case Sword sword when sword.Durability > 0:
        enemy.Health -= sword.Damage;
        sword.Durability--;
        break;
    case Bow bow when bow.Arrows > 0:
        enemy.Health -= bow.Damage;
        bow.Arrows--;
        break;
}

注意下case語句是如何同時做到型別轉換和條件檢查的,增加了程式碼的簡潔性。

另外一個模式匹配相關的特性是 switch 表示式。你可以認為它是一種switch語句,每個case分支都會返回一個值。使用這個特性,一個有限狀態機的轉換就可以定義在一個表示式中了。

static State Request(this State state, Transition transition) =>
(state, transition) match
(
    case (State.Running, Transition.Suspend): State.Suspended
    case (State.Suspended, Transition.Resume): State.Running
    case (State.Suspended, Transition.Terminate): State.NotRunning
    case (State.NotRunning, Transition.Activate): State.Running
    case *: throw new InvalidOperationException()
);

上面的程式碼還使用了另外一個特性: tuples。 它們被設計成更加輕量級的匿名類的替代品。他們主要被用在函式返回多個值時,替代out型別引數。

public (int weight, int count) Stocktake(IEnumerable<IWeapon> weapons)
{
    var w = 0;
    var c = 0;
    foreach (var weapon in weapons)
    {
        w += weapon.Weight;
        c++;
    }
    return (w, c);
}

更多函數語言程式設計的開發方式會很快導致類只作為資料的容器,而不包含任何方法和業務邏輯。records 語法允許這種類的標準化實現,只需要最少的程式碼:

public class Sword(int Damage, int Durability);

這簡單的一行表示了一個完整的函式式類:

public class Sword : IEquatable<Sword>
{
    public int Damage { get; }
    public int Durability { get; }

    public Sword(int Damage, int Durability)
    {
        this.Damage = Damage;
        this.Durability = Durability;
    }

    public bool Equals(Sword other)
    {
        return Equals(Damage, other.Damage) && Equals(Durability, other.Durability);
    }

    public override bool Equals(object other)
    {
        return (other as Sword)?.Equals(this) == true;
    }

    public override int GetHashCode()
    {
        return (Damage.GetHashCode() * 17 + Durability.GetHashCode())
            .GetValueOrDefault();
    }

    public static void operator is(Sword self, out int Damage, out int Durability)
    {
        Damage = self.Damage;
        Durability = self.Durability;
    }

    public Sword With(int Damage = this.Damage, int Durability = this.Durability) => 
        new Sword(Damage, Durability);
}

正如你所看到的,這個類包含一些只讀的屬性,一個建構函式用來初始化這些屬性。它還實現了equality方法,並使用基於hash的集合正確的過載了GetHashCode, 比如Dictionary和Hashtable。你可能不認識最後兩個函式:

  • Is操作符過載允許模式匹配時拆分成元組結構。
  • 為了解釋With方法,請讀下面幾段。

Record將支援繼承,但具體的語法還沒定。

增加可靠性

上面使用record語法生成的Sword類,是不可變類的一個例子。這表示它的狀態(屬性的值)在類的例項建立後不能被改變。

如果你想知道它跟可靠性有什麼關係,想想多執行緒程式設計吧。隨著處理器有更多核而不是更高時脈頻率,在伺服器、桌面和移動端,多執行緒程式設計只會變得更重要和更流行。同時不可變物件需要不同的程式設計方式,它在設計上就避免了多執行緒在沒有合適的同步情況下修改同一物件時產生的條件競爭(比如,沒有正確使用鎖或者其他執行緒同步原語)。

儘管現在C#中建立不可變物件也是可以的,但是它太複雜了。下面介紹的C#7.0中的特性使得它更便捷的定義和使用不可變物件:

物件初始化器只作用於只讀屬性,自動回落到匹配的建構函式上:

IWeapon sword = new Sword { Damage = 5, Durability = 500 };

特殊的語法將用於建立簡潔的物件副本:

IWeapon strongerSword = sword with { Damage = 8 };

上面的表示式將建立一個Sword的副本物件,所有屬性有相同的值,除了Damage使用新提供的值。完成這個表示式的內部運作的細節仍在討論中。其中一個選項是需要的類有With方法,就像在records的例子中展示的那樣:

public Sword With(int Damage = this.Damage, int Durability = this.Durability) => 
    new Sword(Damage, Durability);

這將使 with表示式語法自動轉換成下面的方法呼叫:

IWeapon strongerSword = sword.With(Damage: 8);

C# 7可靠性工作的第二部分是null安全的主題。我們都同意NullReferenceException是最常見也最難以解決的失敗之一。任何可以減少此類異常的數量的語言的改進肯定會對整個應用程式的可靠性有積極的影響。

第三方供應商,如JetBrains著名Visual Studio擴充套件ReSharper已經在這個方向上走出了第一步。他們的工作是基於程式碼的靜態分析,開發人員試圖銷燬一個物件之前沒有檢查null值時,發出警告。這是通過Attibute來實現的,可以用來標註方法是否可以返回null值。他們也為BCL(基類庫)類準備了標註。如果開發人員會正確地標註他/她所有的程式碼,靜態分析應該能夠可靠地警告任何潛在的NullReferenceException來源。

C#語言設計團隊正試圖實現相同的目標,只不過是在語言層面上。核心思想是允許變數型別定義中包含是否可以賦值為空的資訊:

IWeapon? canBeNull;
IWeapon cantBeNull;

分配一個null值或潛在的null值給非空變數會導致編譯器的警告(開發人員可以配置在這些警告的情況下構建失敗,來增加額外的安全):

canBeNull = null;       // no warning
cantBeNull = null;      // warning
cantBeNull = canBeNull; // warning

這種改變的問題是它破壞現有程式碼:它假設以前程式碼中所有變數都是非空的。為了應對這種情況,可以在專案級別禁用靜態分析。開發人員可以決定何時進行nullability檢查。

在過去C#類似的改變已經被在考慮,但因為向後相容性的問題沒能實現。因為Roslyn已經改變了什麼編譯器和執行靜態分析的診斷能力,語言團隊決定再次重溫這個話題。讓我們保持祈禱,讓他們設法想出一個可行的解決方案。

改進的效能

C# 7.0中效能改進重點是減少記憶體位置中的資料複製。

區域性函式將允許在其他函式內部巢狀宣告輔助函式。這不僅會縮小他們的作用域,也允許使用宣告涵蓋範圍內的變數,而且不會在堆上分配額外的記憶體和堆疊:

static void ReduceMagicalEffects(this IWeapon weapon, int timePassed)
{
    double decayRate = CalculateDecayRate();
    double GetRemainingEffect(double currentEffect) => 
        currentEffect * Math.Pow(decayRate, timePassed);

    weapon.FireEffect = GetRemainingEffect(weapon.FireEffect);
    weapon.IceEffect = GetRemainingEffect(weapon.IceEffect);
    weapon.LightningEffect = GetRemainingEffect(weapon.LightningEffect);
}

返回值和區域性變數的引用也能用來阻止不必要的資料拷貝,同時他們的行為也改變了。因為這些變數指向原本的記憶體地址,任何對此處值的改變都會影響到區域性變數的值:

[Test]
public void LocalVariableByReference()
{
    var terrain = Terrain.Get();

    ref TerrainType terrainType = ref terrain.GetAt(4, 2);
    Assert.AreEqual(TerrainType.Grass, terrainType);

    // modify enum value at the original location
    terrain.BurnAt(4, 2);
    // local value was also affected
    Assert.AreEqual(TerrainType.Dirt, terrainType);
}

在上面的例子中,terrainType是一個區域性變數的引用,GetAt是一個返回值的引用的函式:

public ref TerrainType GetAt(int x, int y) => ref terrain[x, y];

Slices 是提出的最後的效能相關的特性:

var array = new int[] { 1, 2, 3, 4, 5 };
var slice = Array.Slice(array, 0, 3); // refers to 1, 2, 3 in the above array

Slice(切片) 使得將一個陣列的一部分可以作為一個新的陣列進行處理,而實際指向原陣列的同一記憶體地址。

展望 C# 7

圖1: Slices是另一個陣列的一部分

同樣的,對任何一個陣列的修改將會同時影響兩個陣列,沒有任何值被拷貝。這將導致較大狀態的更有效的管理,比如在遊戲中。所有需要的記憶體只需要在應用開始的時候分配一次,完全避免了新記憶體分配和垃圾收集。

更進一步,它使我們可以用同樣的方式獲得一塊原生的記憶體塊,可以直接讀取和寫入,而不用再進行編組。

嘗試實驗功能

儘管所有上述的功能還遠沒有完成,任何工作已經可以在GitHub上使用。如果你有興趣試試,你完全可以這樣做。

在撰寫本文時,最簡單的方式是安裝Visual Studio “15”預覽版,從三月底起可以從此處下載。它包含新版的C#編譯器,帶有下列實驗功能等著你來試用:模式匹配,區域性函式,返回值和區域性變數的引用。

尚未成熟的特性需要你基於GitHub原始碼構建自己版本的編譯器,這超出了本文討論範圍。如果你感興趣,可以讀下這篇詳細指導的文章 。

甚至在 Visual Studio “15”預覽版中,預設情況下新的實驗功能還是不能用的。

儘管指示會有錯誤,在寫程式碼時最簡單的方式來啟用這些功能的方法是在工程的編譯屬性裡增加__DEMO__ 和 __DEMO_EXPERIMENTAL__條件編譯符號。

展望 C# 7

圖3: 增加條件編譯符號

現在你就可以使用任何支援的實驗語言特性了,編譯工程也不會有錯了。

結論

所有本文描述的C# 7新的語言功能都還在實現中。在C#7.0的最終版本里,他們可能會很不一樣或者根本不存在。這篇文章只是一個C#語言的當前狀態的總覽,讓你能一窺未來,也許能引發你足夠的興趣去更緊密得跟蹤開發,或者在新功能未完成時就去嘗試下。通過在語言開發過程中作為一個更積極的部分,你就可以影響它,同時也能學到新東西;可能在下一版本可用之前就能改善你現有的編碼實踐。

相關文章