C# 7编程模式与实践

时间:2024-04-03 20:16:10

C# 7是一个重大更新,其中提供了很多有意思的新功能。虽然已有大量的文章介绍这些功能可以做什么,但是鲜有文章介绍应如何使用这些功能。本文将过一遍《.NET设计规范:.NET约定惯用法与模式》(译者注:英文书名为“Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries”)一书中给出的指导原则,力图更好地使用C# 7的新特性。

元组返回(Tuple Returns)

通常在C#编程中,一个函数返回多个值实现起来十分繁琐。一种做法是使用输出参数,这只适用于暴露异步方法的情况。另一种做法是使用Tuple<T>。创建Tuple<T>过于啰嗦,需要做内存分配,并且Tuple的字段没有描述性名字。也可以使用自定义的结构体。虽然结构体在性能上要优于元组,但是大量使用一次性类型会将代码弄得一团糟。而使用具有动态特性的匿名类型,存在性能不好的问题,还缺少静态类型检查。

在C# 7中新提供了元组返回语法,它解决了全部上述问题。下面给出一个基本语法的例子:

public (string, string) LookupName(long id) // tuple return type
{
    return ("John", "Doe"); //元组常值。
}
var names = LookupName(0);
var firstName = names.Item1;
var lastName = names.Item2;

该函数的实际返回类型是ValueTuple<string, string>。正如名称所示,ValueTuple<string, string>类似于Tuple<T>类,是一个轻量级的结构体。它解决了类型膨胀(Type Bloat)问题,但是依然没有解决描述性名称这一困扰Tuple<T>的问题。我们看一

public (string First, string Last) LookupName(long id) 
var names = LookupName(0);
var firstName = names.First;
var lastName = names.Last;

其中的返回类型依然是ValueTuple<string, string>,但是现在编译器在函数中添加了一个TupleElementNames属性。这样调用该函数的代码就可以使用描述性名称,而不再是Item1或Item2这样的名称了。

警告: TupleElementNames属性只能由编译器赋予。如果返回类型上使用了反射,你将只能看到裸的ValueTuple<T>结构体。因为在获得结果时,属性是位于函数本身上,而这个信息丢失了。

编译器会尽可能维护额外类型的幻象。例如,给出如下这些声明:

var a = LookupName(0);  
(string First, string Last) b = LookupName(0); 
ValueTuple<string, string> c = LookupName(0); 
(string make, string model) d = LookupName(0);

在编译器看来,a和b同是(string First, string Last)。鉴于c被显式声明为ValueTuple<string, string>,因此不存在c.First属性。

该例中d的赋值语句展示了这一设计的失灵之处,即会在一定程度上导致缺失类型安全。字段意外地重命名是一个非常容易发生的问题,一个元组可以错误地指定给另一个恰好具有同样形状的元组。这同样是由于编译器没有真正地将(string First, string Last)和(string make, string model)区分为不同的类型。

ValueTuple是可变的

有意思的是, ValueTuple是可变的。Mads Torgersen给出了这样的解释:

为什么通常可变结构体是不好的,不要应用于元组?下面给出原因。

如果你按正常的封装方式编写了一个可变结构体,并且其中具有私有的状态,还有公开的修改器(Mutator)属性和方法,那么你可能就会陷入一些严重的错误中。因为只要结构体是保持在只读变量中,那么修改器就会默默地工作于结构体的一个拷贝上!

但是元组的确有公开的可变字段。它在设计上并未考虑修改器,因此不存在出现上述现象的风险。

此外,ValueTuple是结构体,而结构体在传递时需要进行拷贝。结构体并不直接在线程间共享,也不承担“共享可变状态”的风险。这不同于System.Tuple家族的类型,这些类型也是类。为确保线程安全,需要这些类型是不可变的。

注意,这里Torgersen所指的是“字段”,而不是“属性”。对于使用元组返回函数结果的反射库,这会导致问题。

元组返回的指导原则

  • 当字段列表规模较小并不会发生更改时,考虑使用元组返回,而不是out参数。

  • 对元组返回中的描述性名字使用帕斯卡拼写法(PascalCase),这会使得元组字段看上去就像是正常的类和结构中的属性。

  • 在不进行解析就读取元组返回时,使用var,以避免意外地误标字段。

  • X 如果想要对返回值使用反射,应避免返回值元组。

  • X 如果在未来的版本中可能会返回额外的字段,那么就不要在公开API上使用元组返回。在元组返回中添加字段是一种破坏性变更。

析构多值返回

回到LookupName例子,如果一个命名变量仅在被局部变量替换前短暂使用,看上去创建这样的变量好像是自找麻烦。C# 7中使用一种称为“析构”的方法解决了这个问题。该语法有多种变体,例如:

(string first, string last) = LookupName(0);
(var first, var last) = LookupName(0);
var (first, last) = LookupName(0);
(first, last) = LookupName(0);

上例中的最后一行,我们假定变量first和last已事先声明。

析构函数

虽然析构函数从名字上看像是“毁灭者”,但是析构函数与对象销毁毫无关系。正如构造函数将各个独立值组合成一个对象,析构函数输入一个对象并分离对象中的各个值。析构函数允许任何类使用如上所示的析构语法。让我们看一下Rectangle类,它具有如下的构造函数:

public Rectangle(int x, int y, int width, int height)

在一个新的实例上调用ToString方法时,会得到“{X=0,Y=0,Width=0,Height=0}”。这些事实组合在一起,指明了自定义析构方法中字段的提供顺序。

public void Deconstruct(out int x, out int y, out int width, out int height)
{
    x = X;
    y = Y;
    width = Width;
    height = Height;
} 

var (x, y, width, height) = myRectangle;
Console.WriteLine(x);
Console.WriteLine(y);
Console.WriteLine(width);
Console.WriteLine(height);

你可能会有疑问,为什么在此使用的是输出参数,而不是返回元组。这部分原因是出于性能上的考虑,因为这种做法减少了需拷贝的数量。但是Microsoft这样做的最主要原因在于,它为重载Deconstruct开启了便利之门。

继续研究上面的例子。我们注意到,Rectangle类还有另一个构造函数:

public Rectangle(Point location, Size size);

我们构建与之相匹配的析构方法:

public void Deconstruct(out Point location, out Size size);
var (location, size) = myRectangle;

每个析构方法需要具有不同的参数数量。否则,即便类型是显式列出的,编译器还是无法确定应使用哪个析构方法。

从API设计的角度看,析构函数通常更适用于结构体。在一些类上或许不能有析构方法,尤其是Customer和Employee这样的模型或DTO(数据传输对象,Data Transfer Object)。一些问题并不存在可满足每个人需要的解决方法,例如,“应该使用(firstName, lastName, phoneNumber, email),还是(firstName, lastName, email, phoneNumber)?”。

析构函数的指导原则

  • 在读取元组返回值时应考虑使用析构函数,但要注意误标识的问题。

  • 结构体一定要提供自定义的析构方法。

  • 类构造函数、ToString覆写和析构方法一定要匹配函数中字段的顺序。

  • 如果一个结构体有多个构造函数,那么可以考虑提供多个析构方法。

  • 应考虑对大型的值元组立即进行析构。规模大于16个字节的大型ValueTuple的重复拷贝开销很大。注意:在32位操作系统中,引用变量总是4个字节,而在64位操作系统中总是8个字节。

  • X 如果不清楚字段的出现顺序,就不要在类上暴露析构方法。

  • X 不要声明具有相同参数数量的多个析构方法。

out变量

C# 7对调用具有“out”参数的函数提供了两种语法。一种是在函数调用中声明变量。例如:

if (int.TryParse(s, out var i))
{
    Console.WriteLine(i);
}

另一种用法是使用“通配符”,完全无需顾及输出参数。例如:

if (int.TryParse(s, out _))
{
    Console.WriteLine("success");
}

如果你使用过C# 7预览版,那么你可能已经注意到,忽略参数由原来的使用星号(“*”)改为使用下划线了。这一语法修改的部分原因在于,下划线已在函数式编程语言中广为使用。还可考虑使用关键字“void”或“ignore”。

虽然通配符用起来非常便利,但另一方面也意味着存在API设计上的缺陷。大多数情况下仅提供一个忽略out参数的重载函数即可,out参数一般也会被忽略。

out变量的指导原则

  • 考虑使用元组返回替代out参数。

  • X 应避免使用out或ref参数(参见“Framework设计指南”)。

  • 考虑提供忽略out参数的重载函数,使得不再需要使用通配符。

译者注: 本文在InfoQ发表后,原文作者根据社区的反馈对部分内容进行了更新:“我们不再建议完全避免使用大型的ValueTuple,而是建议开发人员应考虑尽快对它们进行析构。拷贝大型ValueTuple的开销依然很大。与将每个值作为out参数传递相比,拷贝的开销更大。”

局部函数和迭代器

局部函数(Local Function)是一个很有意思的概念,乍一看仿佛是一种略为简洁的匿名函数创建语法。我们能从下面的例子中发现差别:

public DateTime Max_Anonymous_Function(IList<DateTime> values)
{
    Func<DateTime, DateTime, DateTime> MaxDate = (left, right) =>
    {
        return (left > right) ? left : right;
    };

    var result = values.First();
    foreach (var item in values.Skip(1))
        result = MaxDate(result, item);
    return result;
}

public DateTime Max_Local_Function(IList<DateTime> values)
{
    DateTime MaxDate(DateTime left, DateTime right)
    {
        return (left > right) ? left : right;
    }

    var result = values.First();
    foreach (var item in values.Skip(1))
        result = MaxDate(result, item);
    return result;
}

然而,只有深入地接触局部函数,才能发现其中的引入入胜之处。

匿名函数与局部函数的对比

正常创建一个匿名函数时,总是会相应地创建一个用于存储该函数的隐含类。该隐含类将会创建一个实例,并存储在类的静态字段中。因此,隐含类一旦创建,就不再需要更多的开销。

反之,本地函数不需要隐含类,而是与其父函数一样,表示为同一个类中的静态方法。

闭包(Closure)

如果一个函数中的变量被自身所包含的匿名函数或局部函数引用,则称为形成了一个“闭包”,因为这种行为“包含”(Close-over)或“捕获”(Capture)了局部函数。下面给出一个例子:

public DateTime Max_Local_Function(IList<DateTime> values)
{
    int callCount = 0;

    DateTime MaxDate(DateTime left, DateTime right)
    {
        callCount++; <--变量callCount被闭包。
        return (left > right) ? left : right;
    }

    var result = values.First();
    foreach (var item in values.Skip(1))
        result = MaxDate(result, item);
    return result;
}

每次调用一个包含匿名函数的函数时,需要新建一个隐含类实例。这种设计确保了每次调用函数时,函数中具有对父函数与匿名函数间共享数据的拷贝。

这种设计的缺点在于,每次调用匿名函数时需要实例化一个新的对象。由于这对垃圾回造成了压力,因此增加了使用的开销。

使用局部函数时会创建一个隐含结构体,而非一个隐含类。这允许局部函数持续存储预调用的数据,同时消除了对单个对象实例化的需求。类似于匿名方程,局部函数也是物理地存储在隐含结构体中。

委托(Delegates)

在创建匿名函数或局部函数时,很多情况下会将函数打包为一个委托,这样就可以在事件处理器或是LINQ表达式中使用它。

从定义上看,匿名函数当然是匿名的。因此要使用匿名函数,通常需要将匿名函数以委托的形式存储在变量或参数中。

委托不能指向结构体,除非将委托装箱(Box)。但这种语法很奇怪。因此如果你创建了一个指向局部函数的委托,编译器将会创建一个隐含类,而不是一个隐含结构体。如果该局部函数是一个闭包,那么在每次调用父函数时,需要新建一个隐含类的实例。

迭代器(Iterator)

在C#中,如果函数使用了yield return暴露一个IEnumerable<T>,那么就无法立刻对函数的参数进行验证。需要等待在返回的匿名枚举器上调用MoveNext后,参数才会得到验证。

这在VB中并不是一个问题,因为VB支持匿名迭代器。下面是MSDN中给出的一个例子:

Public Function GetSequence(low As Integer, high As Integer) _
As IEnumerable
    ' 验证参数。
    If low < 1 Then Throw New ArgumentException("low is too low")
    If high > 140 Then Throw New ArgumentException("high is too high")

    ' 返回一个匿名迭代器方法。
    Dim iterateSequence = Iterator Function() As IEnumerable
                              For index = low To high
                                  Yield index
                              Next
                          End Function
    Return iterateSequence()
End Function

在当前的C#版本中,GetSequence及其迭代器分别是两个完全独立的函数。使用C# 7,可用局部函数将两者组合在一起。例如:

public IEnumerable<int> GetSequence(int low, int high)
{
    if (low < 1)
        throw new ArgumentException("low is too low");
    if (high > 140)
        throw new ArgumentException("high is too high");

    IEnumerable<int> Iterator()
    {
        for (int i = low; i <= high; i++)
            yield return i;
    }

    return Iterator();
}

迭代器需要构建一个状态机,因此在行为上类似于闭包,需根据隐含类以委托的形式返回。

匿名函数和局部函数的指导原则

  • 在不需要委托时,一定要使用本地函数,而非匿名函数,尤其是涉及闭包的情况下。

  • 所需的参数需要验证时,一定要使用局部迭代器。

  • 可以考虑将局部函数定义在一个函数体的开始或结束处,这样可以从观感上将局部函数与它们的父函数区分开来。

  • X 对性能敏感的代码中,应避免使用具有委托的闭包。这一原则同样适用于匿名函数和局部函数。

引用返回(Ref Return)、局部引用(Ref Local)和引用属性(Ref Property)

结构体具有一些有意思的性能特性。由于结构体的存储与其父数据结构一致,因此没有正常对象那样的头部开销。这意味着可以将结构体密集地打包到一个数组中,这样很少的或几乎没有空间浪费。这种设计不但降低了整体内存开销,而且提供了极大的本地性,使得CPU的微小缓存得到了很好的利用。这就是结构体颇受高性能应用开发人员喜爱的原因所在。

但是如果结构体过于庞大,这时就必须提高警惕,避免生成不必要的结构体拷贝。Microsoft的指南中给出的建议大小是16个字节,足够存储两个双精度型或是四个整型。16个字节并不多,如有必要可使用位域(Bit-field)进行扩展。

对可变结构体要尤为谨慎。如果在使用可变结构体时想要修改原始结构体中的数据,非常容易意外地更改结构体的拷贝。

局部引用

一种可行的做法是使用智能指针,这样永远不需要生成拷贝。下面给出了一些对性能敏感的代码,来自于我曾开发的一个ORM项目:

for (var i = 0; i < m_Entries.Length; i++)
{
    if (string.Equals(m_Entries[i].Details.ClrName, item.Key, StringComparison.OrdinalIgnoreCase)
        || string.Equals(m_Entries[i].Details.SqlName, item.Key, StringComparison.OrdinalIgnoreCase))
    {
        var value = item.Value ?? DBNull.Value;

        if (value == DBNull.Value)
        {
            if (!ignoreNullProperties)
                parts.Add($"{m_Entries[i].Details.QuotedSqlName} IS NULL");
        }
        else
        {
            m_Entries[i].ParameterValue = value;
            m_Entries[i].UseParameter = true;
            parts.Add($"{m_Entries[i].Details.QuotedSqlName} = {m_Entries[i].Details.SqlVariableName}");
        }

        found = true;
        keyFound = true;
        break;
    }
}

你首先会注意到,代码中并没有使用for-each语句。为避免拷贝的开销,代码必须使用旧类型的循环。即便如此,所有的读取和写入也是在m_Entries数组值上直接执行的。

使用C# 7的局部引用,可以在不更改语义的情况下显著地减少混乱。例如:

for (var i = 0; i < m_Entries.Length; i++)
{
    ref Entry entry = ref m_Entries[i]; //创建一个引用
    if (string.Equals(entry.Details.ClrName, item.Key, StringComparison.OrdinalIgnoreCase)
        || string.Equals(entry.Details.SqlName, item.Key, StringComparison.OrdinalIgnoreCase))
    {
        var value = item.Value ?? DBNull.Value;

        if (value == DBNull.Value)
        {
            if (!ignoreNullProperties)
                parts.Add($"{entry.Details.QuotedSqlName} IS NULL");
        }
        else
        {
            entry.ParameterValue = value;
            entry.UseParameter = true;
            parts.Add($"{entry.Details.QuotedSqlName} = {entry.Details.SqlVariableName}");
        }

        found = true;
        keyFound = true;
        break;
    }
}

这是因为“局部引用”本身就是一个安全的指针。我们称之为“安全”,是因为编译器禁止它指向任何短暂(Ephemeral)类型,例如一般函数的返回结果。

你可能会考虑,是否可以使用“ref var entry = ref m_Entries[i];”。虽然在语法上是合法的,但是你却不能这样做。因为这样会在代码中引发混乱。在声明和表达式中,或者全部使用引用,或者全都不要使用引用。

引用返回

引用返回是对局部引用特性的补充,它允许创建无需拷贝的函数。继续看我们给出的例子,我们将其中的搜索操作抽出,并置入自己的静态函数中。

static ref Entry FindColumn(Entry[] entries, string searchKey)
{
    for (var i = 0; i < entries.Length; i++)
    {
        ref Entry entry = ref entries[i]; //创建一个引用
        if (string.Equals(entry.Details.ClrName, searchKey, StringComparison.OrdinalIgnoreCase)
            || string.Equals(entry.Details.SqlName, searchKey, StringComparison.OrdinalIgnoreCase))
        {
            return ref entry;
        }
    }
    throw new Exception("Column not found");
}

在上面的例子中,我们返回了一个对数组元素的引用。当然也可以返回对对象字段、引用属性(参见下节)和引用参数的引用。

ref int Echo(ref int input)
{
    return ref input;
}
ref int Echo2(ref Foo input)
{
    return ref Foo.Field;
}

引用返回具有一个有意思的特性,就是调用者可以选择是否使用它。下面两行代码是同等有效的:

Entry copy = FindColumn(m_Entries, "FirstName");
ref Entry reference = ref FindColumn(m_Entries, "FirstName");

引用返回和引用属性

你还可以创建具有引用返回风格的属性,这仅适用于只读属性。例如:

public ref int Test { get { return ref m_Test; } }

对于不可变结构体,这个模式看上去非常简单。调用者无需付出额外开销,就可以将其作为一个引用值或是正常值读取,正如在代码中所看到的。

但是对于可变结构体,事情就发生了有意思的变化。首先,这种设计修复了一个老问题,就是会意外地通过属性而修改返回的结构体。但它只是让修改不再产生作用。考虑如下的类:

public class Shape
{
    Rectangle m_Size;
    public Rectangle Size { get { return m_Size; } }
}
var s = new Shape();
s.Size.Width = 5;

在C# 1中,Size类不能更改。在C# 6中,代码会触发一个编译器错误。而在C# 7中,只需添加ref就能正常运行。代码如下:

public ref Rectangle Size { get { return ref m_Size; } }

第一眼看去,代码像是会立刻阻止覆写Size。但事实上,你依然可以编写如下的代码:

var rect = new Rectangle(0, 0, 10, 20);
s.Size = rect;

虽然属性是“只读”的,但是代码会按预期运行。编译器能理解代码并不会返回一个Rectangle对象,而是返回一个指向保存Rectangle对象位置的指针。

现在还有一个问题,就是其中的不可变结构体不再是不可变了。尽管我们不能更改单个字段,但是可以通过引用属性替换整个值。C#禁止该语法并给出警告。例如:

readonly int m_LineThickness;
public ref int LineThickness { get { return ref m_LineThickness; } }

鉴于C#并没有提供类似于只读引用返回的定义,因此不能创建指向只读字段的引用。

引用返回和索引器(Indexer)

引用返回和局部引用都需要给定一个固定的引用点,这可能是它们的最大局限性所在。考虑下面的代码:

ref int x = ref myList[0];

该代码是无效的。因为列表不同于数组,在读取列表值时,会创建结构体的一个副本。下面是List<T>的实际实现,引用自Microsoft的“Reference Source”:

public T this[int index] {
    get {
        // 下面的编码技巧可以减少一次范围检查。
        if ((uint) index >= (uint)_size) {
            ThrowHelper.ThrowArgumentOutOfRangeException();
        }
        Contract.EndContractBlock();
        return _items[index]; <-- 返回做了一个拷贝。
    }

这同样适用于ImmutableArray<T>,以及通过IList<T>接口访问正常数组。但是,你可以实现自己的List<T> ,将索引声明为引用返回。代码如下:

public ref T this[int index] {
    get {
        // 下面的编码技巧可以减少一次范围检查。
        if ((uint) index >= (uint)_size) {
            ThrowHelper.ThrowArgumentOutOfRangeException();
        }
        Contract.EndContractBlock();
        return ref _items[index]; <-- 以指针形式返回引用。
    }

如果采取这一做法,需要显式地实现IList<T>和IReadOnlyList<T>接口。因为引用返回的签名不同于普通返回值,并不能满足接口的要求。

鉴于索引器事实上只是一种特殊的属性,因此具有和引用属性一样的限制。这意味着,你不能显式地声明名称以set为开头的函数(即setter)。同时,索引器也是可写的。

引用返回、局部引用和引用属性的指导原则

  • 考虑对操作数组的函数使用引用返回,而不是索引值。

  • 考虑在具有结构体的自定义集合类中使用引用返回,而不是正常的返回。

  • 要将包含可变结构体的属性暴露为引用属性。

  • X 不要将包含不可变结构体的属性暴露为引用属性。

  • X 不要在不可变类或只读类上暴露引用属性。

  • X 不要在不可变或只读集合类上暴露引用索引器。

ValueTask和通用异步返回类型(Generalized Async Return Type)

创建Task类主要针对简化多线程编程。Task类创建了一个通道,使得开发人员可以将耗时长的操作推入线程池中,并稍后在UI线程中读回结果。Task类在fork-join风格的并发编程中效果显著。

但是随着.NET 4.5中引入了async/await,Task类的一些缺陷开始显现。正如我们曾在2011年就撰文指出的(参见“.NET 4.5中任务并行类库的改进”一文),创建Task对象所需时间会超出我们可接受的范围,需要对Task类的内部实现机制进行重写。重写后达到了“Task<Int32>的创建时间降低了49-55%,对象的大小减少了52%。”

这一步非常好,但Task类依然需要分配内存。如果在更紧凑的循环中使用Task类,依然会生成大量的垃圾。下面给出一个这样的例子:

while (await stream.ReadAsync(buffer, offset, count) != 0)
{
    //处理缓存。
}

在前文中多次提及,高性能C#代码的关键在于降低内存分配,并减少随后的GC循环。Microsoft的Joe Duffy在博客文章“异步化所有事情”中是这样写的:

首先,大家是否还记得曾经的Midori项目。Midori要实现的是一个完整的操作系统,有效地使用垃圾回收所得到的内存。从该项目中,我们学到了适当运作此类项目的关键经验教训。我要强调的一点,应该像避免瘟疫一样避免夸大的内存分配,即使是短生命的内存分配。早期在.NET领域有一个广泛传播的口头禅:“Gen0集合是无价的”。不幸的是,这句话影响了很多的.NET库代码,完全驴头不对马嘴。Gen0集合导致了暂时性中断、弄脏的缓存,并在高度并发系统中引入了高频问题。

真正的解决方案是创建并使用基于结构体的Task类,而不是使用在堆上分配的Task类。实际上是使用ValueTask<T>名称创建类,并在System.Threading.Tasks.Extensions库中发布。await已对所有暴露了正确方法的类工作了,因此当前可以调用它。

手工暴露ValueTask<T>

如果预期结果在大部分时间中是同步时,并且开发人员想要去除无必要的内存分配,这正是ValueTask<T>的一个基本用例。一开始,我们假定有一个基于Task类的传统异步方法:

public async Task<Customer> ReadFromDBAsync(string key)

我们使用一个缓存方法包裹(Wrap)该方法:

public ValueTask<Customer> ReadFromCacheAsync(string key)
{
    Customer result;
    if (_Cache.TryGetValue(key, out result))
        return new ValueTask<Customer>(result); //没有分配no allocation

    else
        return new ValueTask<Customer>(ReadFromCacheAsync_Inner(key));
}

然后添加一个Helper方法,构建异步状态机。

async Task<Customer> ReadFromCacheAsync_Inner(string key)
{
    var result = await ReadFromDBAsync(key);
    _Cache[key] = result;
    return result;
}

完成上述代码后,调用者就可以使用与ReadFromDBAsync相同的语法去调用ReadFromCacheAsync:

async Task Test()
{
    var a = await ReadFromCacheAsync("aaa");
    var b = await ReadFromCacheAsync("bbb");
}

通用异步(Generalized Async)

上面的编程模式虽然并不难理解,但是实现起来却十分冗长。我们知道,代码编写得越冗长,越易于包含简单的错误。因此在C# 7的当前提议中,提供了通用异步返回(Generalized Async Return)。

根据当前的设计,只能对返回Task、Task<T>或void的函数使用async关键字。在提议实现后,通用异步返回将会扩展该能力到任何“类似于Task”的类上。我们这里所说的“类似于Task”,是指任何具有AsyncBuilder属性的类。这表明Helper类一直用于创建“类似于Task”的对象。

根据特性设计记录,Microsoft估计可能将会有五个人实际创建“类似于Task”的类,这些类将会被广泛接受。其余的人更有可能是去使用这五个类中的一个。下面给出对前面的例子应用新语法后的代码:

public async ValueTask<Customer> ReadFromCacheAsync(string key)
{
    Customer result;
    if (_Cache.TryGetValue(key, out result))
    {
        return result; //没有做分配。
    }
    else
    {
        result = await ReadFromDBAsync(key);
        _Cache[key] = result;
        return result;
    }
}

正如你所看到的,我们消除了Helper方法。新的实现看上与其它的异步方法一样,只是没有返回类型。

何时使用ValueTask<T>

可以使用ValueTask<T>替代Task<T>吗?这没有必要。解释原因稍有难度,所以我们直接引用了文档:

如果方法很有可能会同步地给出操作结果,或是由于方法每次调用时都要分配一个新的Task<TResult>以至于被频繁调用时的开销过高,这时方法可返回该值类型的一个实例。

使用ValueTask<TResult>替代Task<TResult>时存在着权衡。例如,虽然在成功地同步返回结果的情况下,ValueTask<TResult>会少做一次内存分配,但是ValueTask<TResult>还是包括两个字段,其中作为引用类型的Task<TResult>构成一个字段。这意味着在方法调用结束时会返回两个字段的数据,而不是一个字段,即需要拷贝更多的数据。这同样意味着如果在async方法中有一个只返回其中一个字段的方法在等待状态,那么该async方法的状态机将会增大,因为这时需要被存储的结构体具有两个字段,而不是一个引用。

更进一步,如果使用中不只是需要通过await消费异步操作的结果,那么ValueTask<TResul>会产生更错综复杂的编程模型,进而导致事实上分配了更多的内存。例如,假定有一个方法返回一个使用被缓存的Task作为通用结果的Task<TResult>,或是返回一个ValueTask<TResult>。当消费者想将返回结果作为Task<TResult>使用,正如在Task.WhenAll和Task.WhenAny方法中的用法,那么首先需要调用ValueTask<TResult>.AsTask将ValueTask<TResult>转化为Task<TResult>。但是调用ValueTask<TResult>.AsTask会导致一次内存分配,这在一开始就使用缓存的Task<TResult>的情况下是本可以避免的。

正由于此,所有的异步方法默认应返回一个Task或是Task<TResult>,除非性能分析表明使用ValueTask<TResult>要优于使用Task<TResult>。并不存在非泛型的ValueTask<TResult>,因为当返回Task的方法异步成功完成时,可使用Task.CompletedTask属性交回成功完成的单例(Singleton)。

这段话相当长,我们概括为下面的指导原则。

ValueTask<T>的指导原则

  • 当对性能敏感的代码通常同步返回结果时,考虑使用ValueTask<T>。

  • 当存在内存压力问题并且不能存储Task时,考虑使用ValueTask<T>

  • X 避免在公开API中暴露ValueTask<T>,除非存在显著的性能影响。

  • X 不要在调用Task.WhenAll或WhenAny方法时使用ValueTask<T>。

表达式体成员(Expression Bodied Members)

表达式体成员使得开发人员可以在声明简单函数时不使用大括号。对于传统的四行函数,通常能缩减为一行。例如:

public override string ToString()
{
    return FirstName + " " + LastName;
}
public override string ToString() => FirstName + " " + LastName;

需格外小心的是,不要过度使用该特性。例如,如果要实现在FirstName为空时不会生成开头处的空格,可以这样编写代码:

public override string ToString() => !string.IsNullOrEmpty(FirstName) ? FirstName + " " + LastName : LastName;

但是,还需要检查是否存在LastName同时缺失的情况:

public override string ToString() => !string.IsNullOrEmpty(FirstName) ? FirstName + " " + LastName : (!string.IsNullOrEmpty(LastName) ? LastName : "No Name");

正如在本例中所看到的,使用该特性后,很快就会失去对代码的控制。因此,虽然将多个分支条件串联在一起或是使用空值合并(null-coalescing)操作符可以实现不少功能,但是应尽量克制使用这样的设计。

表达式体属性(Expression Bodied Properties)

表达式体属性是在C# 6中新提出的特性,对于使用Get/Set方法处理属性通知等事情的MVVM模型,该特性非常有用。

下面给出一个C# 6代码:

public string FirstName
{
    get { return Get<string>(); }
    set { Set(value); }
}

在C# 7中实现为:

public string FirstName
{
    get => Get<string>();                      
    set => Set(value);              
}

虽然代码的行数并未减少,但是不少代码行中的噪音(line-noise)消失了。对于属性这样的规模很小但是重复出现的实体,即使减少一个比特都会产生聚沙成塔的效果。

如果想了解Get/Set工作方式的详细信息,可参见“C#和VB.NET获得Windows Runtime支持和异步方法”一文中的“CallerMemberName”部分。

表达式体构造函数(Expression Bodied Constructors)

表达式体构造函数同样是C# 7新引入的特性。下面给出一个例子:

class Person
{
    public Person(string name) => Name = name;
    public string Name { get; }
}

这里的用法非常受限。代码只在没有参数或是一个参数时工作。一旦添加了另一个需为字段或属性的参数,必须切换回传统的构造函数。该用法也不能初始化其它字段,或是钩到事件处理器(但是可以做参数验证,参见下文“Throw表达式”一章内容)。

因此,我们的建议是忽略该特性。它只是让单参数的构造函数看上去不同于一般的构造函数而已,对减少代码量的贡献很小。

表达式体析构函数(Expression Bodied Destructors)

为使C#更为一致,C# 7允许表达式体成员是一个析构函数,正如表达式体成员可以是一个方法或一个构造函数。

为避免有人忘记了析构的概念,我们对此稍作解释。在C#中,析构函数事实上是覆写了System.Object中Finalize方法,虽然C#并不用以这一方式表述。例如:

~UnmanagedResource()
{
    ReleaseResources();
}

该语法存在一个问题,就是构函数看上去类似于一个构造函数,导致易被忽视。另一个问题是,它模仿了C++中的析构语法,但是在C++中析构语法具有完全不同的语义。该语法已经这样地使用很久了,所以让我们继续使用这一语法:

~UnmanagedResource() => ReleaseResources();

该代码只有一行,易于被忽视,它实现了将对象加入到终结器队列的周期中。这并非一个无关紧要的属性或是一个ToString方法,而是一个值得关注的重要操作。我们再一次建议不要使用该特性。

表达式体成员的指导原则

  • 对简单属性不要使用表达式体成员。

  • 对于调用同一函数中其它重载的方法,一定要使用表达式体成员。

  • 考虑对非关键函数使用表达式体成员。

  • X 不要在表达式体成员中使用多于一个条件(a ? b : c),或是使用空值合并(x ?? y)。

  • X 不要对构造函数和析构函数使用表达式体成员。

throw表达式

编程语言通常可将粗略地分成两类:

  • 凡事皆表达式;

  • 语句、声明和表达式分别是独立的概念。

前一类的例子是Ruby语言,Ruby中的声明也是表达式。与之相对比,后一类的代表性例子是Visual Basic。VB的语句和表达式间有着明显的差别。例如,if语句在独立使用时与作为大型表达式的一部分使用时,具有完全不同的语法。

C#基本上可以归为第二类,但是由于其源自于C语言,也可将赋值语句看成是表达式。在C#中允许编写如下代码:

while ((current = stream.ReadByte()) != -1)
{
    //执行具体工作的代码。
}

C# 7首次允许非赋值语句做为表达式使用。无需对语法做任何更改,就可在正常表达式的任意位置放置“throw”语句。下面是Mads Torgersen在发行声明中所给出的例子:

class Person
{
    public string Name { get; }

    public Person(string name) => Name = name ?? throw new ArgumentNullException("name");

    public string GetFirstName()
    {
        var parts = Name.Split(' ');
        return (parts.Length > 0) ? parts[0] : throw new InvalidOperationException("No name!");
    }

    public string GetLastName() => throw new NotImplementedException();
}

很容易看出每个例子所执行的功能。但是如果我们移动了代码中throws表达式的位置,那么会发生什么?例如:

return (parts.Length == 0) ? throw new InvalidOperationException("No name!") : parts[0];

现在代码就不容易读懂了。虽然左右两边的语句是相关的,但是中间的语句与两者完全无关。从结构上看,第一个版本左边给出的是“正确路径”,右边给出的是错误路径。第二个版本中,错误路径将正确路径分隔为两部分,破坏了整个流程。

(点击放大图像)

C# 7编程模式与实践

让我们再看一个例子。在下面的代码中,我们添加了一个函数调用:

void Save(IList<Customer> customers, User currentUser)
{
    if (customers == null || customers.Count == 0) throw new ArgumentException("No customers to save");

    _Database.SaveEach("dbo.Customer", customers, currentUser);
}

void Save(IList<Customer> customers, User currentUser)
{
    _Database.SaveEach("dbo.Customer", (customers == null || customers.Count == 0) ? customers : throw new ArgumentException("No customers to save"), currentUser);
}

这时我们发现代码行过于冗长,尽管有时用LINQ也会编写出十分长的代码行。为了改进代码的可读性,我们使用橙色标记条件部分,函数调用蓝色标出,函数参数标为黄色,错误路径标为红色。

(点击放大图像)

C# 7编程模式与实践

这样我们就能看出,上下文是如何随参数位置的改变而发生变化的。

throw表达式的指导原则

  • 在赋值和返回语句中,考虑将throw表达式置于条件(a ? b : c)和空值合并(x ?? y)操作符的左侧。

  • X 不要将throw表达式置于条件操作符的中间位置。

  • X 不要在函数的参数列表中放置throw表达式。

要详细了解异常是如何影响API设计的,参见“.NET异常设计原则”一文。

模式匹配与switch语句的改进

模式匹配改进了switch语句,但并未影响API的设计。因此,虽然模式匹配的确可以简化异构集合类的操作,但是如有可能,最好还是使用共享接口和多态。

这也就是说,有一些实现细节值得考虑。看一下在八月份的发布中所给出的例子:

switch(shape)
{
    case Circle c:
        WriteLine($"circle with radius {c.Radius}");
        break;
    case Rectangle s when (s.Width == s.Height):
        WriteLine($"{s.Width} x {s.Height} square");
        break;
    case Rectangle r:
        WriteLine($"{r.Width} x {r.Height} rectangle");
        break;
    default:
        WriteLine("<unknown shape>");
        break;
    case null:
        throw new ArgumentNullException(nameof(shape));
}

以前,case表达式中选项的出现次序是无关紧要的。但是在C# 7中提供了类似于Visual Basic的机制,switch语句几乎是严格地按声明次序进行求值。这一方式对于when表达式同样适用。

实际上,正如在一系列的if-else-if语句中那样,最常见的情况应该成为switch语句块的第一个选项。类似地,如果存在开销很大的情况检查,应该将该选项尽可能置于switch语句底部,使得只是在有必要时才被执行。

唯一例外是default语句。无论出现在switch语句的位置,它总是最后处理。但是随处放置default会使代码难以理解,因此我推荐总是将default语句置于switch的最后位置。

模式匹配表达式

switch语句可能是C#中最常用的模式匹配语句,但并非是唯一的方式。任一在运行时求值的布尔表达式,都可以包括一个模式表达式。

下面给出的例子用于确定变量“o”是否为一个字符串。如果是,则将该变量解析为一个整型数:

if (o is string s && int.TryParse(s, out var i))
{
    Console.WriteLine(i);
}

请注意,模式表达式是如何新建一个变量“s”,并稍后被TryParse重用。这种方法可以串联使用,构建更复杂的表达式。例如:

if ((o is int i) || (o is string s && int.TryParse(s, out i)))
{
    Console.WriteLine(i);
}

为了进行比较,下面给出C# 6风格的代码:

if (o is int)
{
    Console.WriteLine((int)o);
}
else if (o is string && int.TryParse((string) o, out i))
{
    Console.WriteLine(i);
}

虽然现在下结论说新模式匹配比旧方式更为高效还为时尚早,但是新方式确实消除了一些冗余的类型检查。

共同维护最新的文档

C# 7的特性依然是鲜活的,要了解这些特性是如何作用于现实世界的,还有许多值得学习的内容。因此,如果你对一些特性持有异议,或是发现指南中所缺少的内容,请告知我们。

关于本文作者

C# 7编程模式与实践Jonathan Allen的首份工作是在上世纪九十年代末,实现的是一个诊所的MIS项目,Allen将该项目逐步由基于Access和Excel升级成一个企业级解决方案。在从事为财政部门编写自动交易系统代码的工作五年之后,他成为了一名项目顾问,参与了多个行业的项目,包括机器人仓库UI、癌症研究软件中间层、主要房地产保险企业的大数据需求等。在闲暇时,他喜欢研究并撰文介绍16世纪的格斗术。

原文地址:http://www.infoq.com/cn/articles/Patterns-Practices-CSharp-7



.NET社区新闻,深度好文,欢迎访问公众号文章汇总 http://www.csharpkit.com

C# 7编程模式与实践

相关文章