2016
06-29

展望 C# 7

目前的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(切片) 使得将一个数组的一部分可以作为一个新的数组进行处理,而实际指向原数组的同一内存地址。

图1: Slices是另一个数组的一部分

同样的,对任何一个数组的修改将会同时影响两个数组,没有任何值被拷贝。这将导致较大状态的更有效的管理,比如在游戏中。所有需要的内存只需要在应用开始的时候分配一次,完全避免了新内存分配和垃圾收集。

更进一步,它使我们可以用同样的方式获得一块原生的内存块,可以直接读取和写入,而不用再进行编组。

尝试实验功能

尽管所有上述的功能还远没有完成,任何工作已经可以在GitHub上使用。如果你有兴趣试试,你完全可以这样做。

在撰写本文时,最简单的方式是安装Visual Studio “15”预览版,从三月底起可以从此处下载。它包含新版的C#编译器,带有下列实验功能等着你来试用:模式匹配,局部函数返回值和局部变量的引用

尚未成熟的特性需要你基于GitHub源码构建自己版本的编译器,这超出了本文讨论范围。如果你感兴趣,可以读下这篇详细指导的文章 。

甚至在 Visual Studio “15”预览版中,默认情况下新的实验功能还是不能用的。

尽管指示会有错误,在写代码时最简单的方式来启用这些功能的方法是在工程的编译属性里增加__DEMO__ 和 __DEMO_EXPERIMENTAL__条件编译符号。

图3: 增加条件编译符号

现在你就可以使用任何支持的实验语言特性了,编译工程也不会有错了。

结论:

所有本文描述的C# 7新的语言功能都还在实现中。在C#7.0的最终版本里,他们可能会很不一样或者根本不存在。这篇文章只是一个C#语言的当前状态的总览,让你能一窥未来,也许能引发你足够的兴趣去更紧密得跟踪开发,或者在新功能未完成时就去尝试下。通过在语言开发过程中作为一个更积极的部分,你就可以影响它,同时也能学到新东西;可能在下一版本可用之前就能改善你现有的编码实践。

原文地址:http://www.dotnetcurry.com/csharp/1286/csharp-7-new-expected-features

扫码二维码 获取免费视频学习资料

Python编程学习

查 看2022高级编程视频教程免费获取