[关闭]
@Rays 2017-05-09T21:36:07.000000Z 字数 19861 阅读 1800

C# 7编程模式与实践

Microsoft


摘要: C# 7是一个重大更新,其中提供了很多有意思的新功能。虽然已有大量的文章介绍这些功能可以做什么,但是鲜有文章介绍应如何使用这些功能。本文将过一遍《.NET设计规范:.NET约定惯用法与模式》一书中给出的指导原则,力图从C# 7的新特性中收获更多。

作者: Jonathan Allen

正文:

本文要点

  • 应遵循《.NET设计规范:.NET约定惯用法与模式》一书。和十年前第一版出版时一样,书中给出的原则在当前依然有指导意义。
  • API设计是最重要的。设计不好的API会在极大地增加软件缺陷,同时降低可重用性。
  • 时刻牢记“良性循环”(Pit of Success)这一哲理:让正确的事情更易于做,让犯错误更加困难。
  • 移除“线路噪音”(Line Noise)和“样板”(Boilerplate)代码,聚焦于对业务逻辑的关注。
  • 出于性能考虑而牺牲代码清晰度前,请认真考虑一下。

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中新提供了元组返回语法,它解决了全部上述问题。下面给出一个基本语法的例子:

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

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

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

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

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

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

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

在编译器看来,ab同是(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,以避免意外地误标字段。
✘ 如果想要对返回值使用反射,应避免返回值元组。
✘ 如果在未来的版本中可能会返回额外的字段,那么就不要在公开API上使用元组返回。在元组返回中添加字段是一种破坏性变更。

析构多值返回

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

  1. (string first, string last) = LookupName(0);
  2. (var first, var last) = LookupName(0);
  3. var (first, last) = LookupName(0);
  4. (first, last) = LookupName(0);

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

析构函数

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

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

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

  1. public void Deconstruct(out int x, out int y, out int width, out int height)
  2. {
  3. x = X;
  4. y = Y;
  5. width = Width;
  6. height = Height;
  7. }
  8. var (x, y, width, height) = myRectangle;
  9. Console.WriteLine(x);
  10. Console.WriteLine(y);
  11. Console.WriteLine(width);
  12. Console.WriteLine(height);

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

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

  1. public Rectangle(Point location, Size size);

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

  1. public void Deconstruct(out Point location, out Size size);
  2. 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个字节。
✘ 如果不清楚字段的出现顺序,就不要在类上暴露析构方法。
✘ 不要声明具有相同参数数量的多个析构方法。

out变量

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

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

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

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

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

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

out变量的指导原则

✔ 考虑使用元组返回替代out参数。
✘ 应避免使用out或ref参数(参见“Framework设计指南”)。
✔ 考虑提供忽略out参数的重载函数,使得不再需要使用通配符。

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

局部函数和迭代器

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

  1. public DateTime Max_Anonymous_Function(IList<DateTime> values)
  2. {
  3. Func<DateTime, DateTime, DateTime> MaxDate = (left, right) =>
  4. {
  5. return (left > right) ? left : right;
  6. };
  7. var result = values.First();
  8. foreach (var item in values.Skip(1))
  9. result = MaxDate(result, item);
  10. return result;
  11. }
  12. public DateTime Max_Local_Function(IList<DateTime> values)
  13. {
  14. DateTime MaxDate(DateTime left, DateTime right)
  15. {
  16. return (left > right) ? left : right;
  17. }
  18. var result = values.First();
  19. foreach (var item in values.Skip(1))
  20. result = MaxDate(result, item);
  21. return result;
  22. }

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

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

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

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

闭包(Closure)

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

  1. public DateTime Max_Local_Function(IList<DateTime> values)
  2. {
  3. int callCount = 0;
  4. DateTime MaxDate(DateTime left, DateTime right)
  5. {
  6. callCount++; <--变量callCount被闭包。
  7. return (left > right) ? left : right;
  8. }
  9. var result = values.First();
  10. foreach (var item in values.Skip(1))
  11. result = MaxDate(result, item);
  12. return result;
  13. }

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

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

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

委托(Delegates)

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

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

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

迭代器(Iterator)

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

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

  1. Public Function GetSequence(low As Integer, high As Integer) _
  2. As IEnumerable
  3. ' 验证参数。
  4. If low < 1 Then Throw New ArgumentException("low is too low")
  5. If high > 140 Then Throw New ArgumentException("high is too high")
  6. ' 返回一个匿名迭代器方法。
  7. Dim iterateSequence = Iterator Function() As IEnumerable
  8. For index = low To high
  9. Yield index
  10. Next
  11. End Function
  12. Return iterateSequence()
  13. End Function

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

  1. public IEnumerable<int> GetSequence(int low, int high)
  2. {
  3. if (low < 1)
  4. throw new ArgumentException("low is too low");
  5. if (high > 140)
  6. throw new ArgumentException("high is too high");
  7. IEnumerable<int> Iterator()
  8. {
  9. for (int i = low; i <= high; i++)
  10. yield return i;
  11. }
  12. return Iterator();
  13. }

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

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

✔ 在不需要委托时,一定要使用本地函数,而非匿名函数,尤其是涉及闭包的情况下。
✔ 所需的参数需要验证时,一定要使用局部迭代器。
✔ 可以考虑将局部函数定义在一个函数体的开始或结束处,这样可以从观感上将局部函数与它们的父函数区分开来。
✘ 对性能敏感的代码中,应避免使用具有委托的闭包。这一原则同样适用于匿名函数和局部函数。

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

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

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

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

局部引用

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

  1. for (var i = 0; i < m_Entries.Length; i++)
  2. {
  3. if (string.Equals(m_Entries[i].Details.ClrName, item.Key, StringComparison.OrdinalIgnoreCase)
  4. || string.Equals(m_Entries[i].Details.SqlName, item.Key, StringComparison.OrdinalIgnoreCase))
  5. {
  6. var value = item.Value ?? DBNull.Value;
  7. if (value == DBNull.Value)
  8. {
  9. if (!ignoreNullProperties)
  10. parts.Add($"{m_Entries[i].Details.QuotedSqlName} IS NULL");
  11. }
  12. else
  13. {
  14. m_Entries[i].ParameterValue = value;
  15. m_Entries[i].UseParameter = true;
  16. parts.Add($"{m_Entries[i].Details.QuotedSqlName} = {m_Entries[i].Details.SqlVariableName}");
  17. }
  18. found = true;
  19. keyFound = true;
  20. break;
  21. }
  22. }

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

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

  1. for (var i = 0; i < m_Entries.Length; i++)
  2. {
  3. ref Entry entry = ref m_Entries[i]; //创建一个引用
  4. if (string.Equals(entry.Details.ClrName, item.Key, StringComparison.OrdinalIgnoreCase)
  5. || string.Equals(entry.Details.SqlName, item.Key, StringComparison.OrdinalIgnoreCase))
  6. {
  7. var value = item.Value ?? DBNull.Value;
  8. if (value == DBNull.Value)
  9. {
  10. if (!ignoreNullProperties)
  11. parts.Add($"{entry.Details.QuotedSqlName} IS NULL");
  12. }
  13. else
  14. {
  15. entry.ParameterValue = value;
  16. entry.UseParameter = true;
  17. parts.Add($"{entry.Details.QuotedSqlName} = {entry.Details.SqlVariableName}");
  18. }
  19. found = true;
  20. keyFound = true;
  21. break;
  22. }
  23. }

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

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

引用返回

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

  1. static ref Entry FindColumn(Entry[] entries, string searchKey)
  2. {
  3. for (var i = 0; i < entries.Length; i++)
  4. {
  5. ref Entry entry = ref entries[i]; //创建一个引用
  6. if (string.Equals(entry.Details.ClrName, searchKey, StringComparison.OrdinalIgnoreCase)
  7. || string.Equals(entry.Details.SqlName, searchKey, StringComparison.OrdinalIgnoreCase))
  8. {
  9. return ref entry;
  10. }
  11. }
  12. throw new Exception("Column not found");
  13. }

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

  1. ref int Echo(ref int input)
  2. {
  3. return ref input;
  4. }
  5. ref int Echo2(ref Foo input)
  6. {
  7. return ref Foo.Field;
  8. }

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

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

引用返回和引用属性

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

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

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

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

  1. public class Shape
  2. {
  3. Rectangle m_Size;
  4. public Rectangle Size { get { return m_Size; } }
  5. }
  6. var s = new Shape();
  7. s.Size.Width = 5;

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

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

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

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

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

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

  1. readonly int m_LineThickness;
  2. public ref int LineThickness { get { return ref m_LineThickness; } }

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

引用返回和索引器(Indexer)

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

  1. ref int x = ref myList[0];

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

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

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

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

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

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

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

✔ 考虑对操作数组的函数使用引用返回,而不是索引值。
✔ 考虑在具有结构体的自定义集合类中使用引用返回,而不是正常的返回。
✔ 要将包含可变结构体的属性暴露为引用属性。
✘ 不要将包含不可变结构体的属性暴露为引用属性。
✘ 不要在不可变类或只读类上暴露引用属性。
✘ 不要在不可变或只读集合类上暴露引用索引器。

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类,依然会生成大量的垃圾。下面给出一个这样的例子:

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

在前文中多次提及,高性能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类的传统异步方法:

  1. public async Task<Customer> ReadFromDBAsync(string key)

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

  1. public ValueTask<Customer> ReadFromCacheAsync(string key)
  2. {
  3. Customer result;
  4. if (_Cache.TryGetValue(key, out result))
  5. return new ValueTask<Customer>(result); //没有分配no allocation
  6. else
  7. return new ValueTask<Customer>(ReadFromCacheAsync_Inner(key));
  8. }

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

  1. async Task<Customer> ReadFromCacheAsync_Inner(string key)
  2. {
  3. var result = await ReadFromDBAsync(key);
  4. _Cache[key] = result;
  5. return result;
  6. }

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

  1. async Task Test()
  2. {
  3. var a = await ReadFromCacheAsync("aaa");
  4. var b = await ReadFromCacheAsync("bbb");
  5. }

通用异步(Generalized Async)

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

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

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

  1. public async ValueTask<Customer> ReadFromCacheAsync(string key)
  2. {
  3. Customer result;
  4. if (_Cache.TryGetValue(key, out result))
  5. {
  6. return result; //没有做分配。
  7. }
  8. else
  9. {
  10. result = await ReadFromDBAsync(key);
  11. _Cache[key] = result;
  12. return result;
  13. }
  14. }

正如你所看到的,我们消除了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.WhenAllTask.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>
✘ 避免在公开API中暴露ValueTask<T>,除非存在显著的性能影响。
✘ 不要在调用Task.WhenAllWhenAny方法时使用ValueTask<T>。

表达式体成员(Expression Bodied Members)

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

  1. public override string ToString()
  2. {
  3. return FirstName + " " + LastName;
  4. }
  5. public override string ToString() => FirstName + " " + LastName;

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

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

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

  1. 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代码:

  1. public string FirstName
  2. {
  3. get { return Get<string>(); }
  4. set { Set(value); }
  5. }

在C# 7中实现为:

  1. public string FirstName
  2. {
  3. get => Get<string>();
  4. set => Set(value);
  5. }

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

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

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

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

  1. class Person
  2. {
  3. public Person(string name) => Name = name;
  4. public string Name { get; }
  5. }

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

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

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

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

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

  1. ~UnmanagedResource()
  2. {
  3. ReleaseResources();
  4. }

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

  1. ~UnmanagedResource() => ReleaseResources();

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

表达式体成员的指导原则

✔ 对简单属性不要使用表达式体成员。
✔ 对于调用同一函数中其它重载的方法,一定要使用表达式体成员。
✔ 考虑对非关键函数使用表达式体成员。
✘ 不要在表达式体成员中使用多于一个条件(a ? b : c),或是使用空值合并(x ?? y)。
✘ 不要对构造函数和析构函数使用表达式体成员。

throw表达式

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

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

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

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

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

  1. class Person
  2. {
  3. public string Name { get; }
  4. public Person(string name) => Name = name ?? throw new ArgumentNullException("name");
  5. public string GetFirstName()
  6. {
  7. var parts = Name.Split(' ');
  8. return (parts.Length > 0) ? parts[0] : throw new InvalidOperationException("No name!");
  9. }
  10. public string GetLastName() => throw new NotImplementedException();
  11. }

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

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

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

(点击图片可放大)

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

  1. void Save(IList<Customer> customers, User currentUser)
  2. {
  3. if (customers == null || customers.Count == 0) throw new ArgumentException("No customers to save");
  4. _Database.SaveEach("dbo.Customer", customers, currentUser);
  5. }
  6. void Save(IList<Customer> customers, User currentUser)
  7. {
  8. _Database.SaveEach("dbo.Customer", (customers == null || customers.Count == 0) ? customers : throw new ArgumentException("No customers to save"), currentUser);
  9. }

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

(点击图片可放大)

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

throw表达式的指导原则

✔ 在赋值和返回语句中,考虑将throw表达式置于条件(a ? b : c)和空值合并(x ?? y)操作符的左侧。
✘ 不要将throw表达式置于条件操作符的中间位置。
✘ 不要在函数的参数列表中放置throw表达式。

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

模式匹配与switch语句的改进

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

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

  1. switch(shape)
  2. {
  3. case Circle c:
  4. WriteLine($"circle with radius {c.Radius}");
  5. break;
  6. case Rectangle s when (s.Width == s.Height):
  7. WriteLine($"{s.Width} x {s.Height} square");
  8. break;
  9. case Rectangle r:
  10. WriteLine($"{r.Width} x {r.Height} rectangle");
  11. break;
  12. default:
  13. WriteLine("<unknown shape>");
  14. break;
  15. case null:
  16. throw new ArgumentNullException(nameof(shape));
  17. }

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

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

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

模式匹配表达式

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

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

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

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

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

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

  1. if (o is int)
  2. {
  3. Console.WriteLine((int)o);
  4. }
  5. else if (o is string && int.TryParse((string) o, out i))
  6. {
  7. Console.WriteLine(i);
  8. }

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

共同维护最新的文档

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

关于本文作者

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

查看英文原文: Patterns and Practices in C# 7

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注