写点什么

C# 7 编程模式与实践

2017 年 5 月 14 日

本文要点

  • 应遵循《.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。创建Tuple过于啰嗦,需要做内存分配,并且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类,是一个轻量级的结构体。它解决了类型膨胀(Type Bloat)问题,但是依然没有解决描述性名称这一困扰 Tuple的问题。我们看一下如下的例子:

复制代码
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结构体。因为在获得结果时,属性是位于函数本身上,而这个信息丢失了。

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

复制代码
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,那么就无法立刻对函数的参数进行验证。需要等待在返回的匿名枚举器上调用 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的实际实现,引用自 Microsoft 的“Reference Source”

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

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

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

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

鉴于索引器事实上只是一种特殊的属性,因此具有和引用属性一样的限制。这意味着,你不能显式地声明名称以 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的创建时间降低了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名称创建类,并在 System.Threading.Tasks.Extensions 库中发布。await 已对所有暴露了正确方法的类工作了,因此当前可以调用它。

手工暴露 ValueTask

如果预期结果在大部分时间中是同步时,并且开发人员想要去除无必要的内存分配,这正是 ValueTask的一个基本用例。一开始,我们假定有一个基于 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或 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

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

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

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

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

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

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

ValueTask的指导原则

  • 当对性能敏感的代码通常同步返回结果时,考虑使用 ValueTask
  • 当存在内存压力问题并且不能存储 Task 时,考虑使用 ValueTask
  • X 避免在公开 API 中暴露 ValueTask,除非存在显著的性能影响。
  • X 不要在调用 Task.WhenAll 或 WhenAny 方法时使用 ValueTask

表达式体成员(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];现在代码就不容易读懂了。虽然左右两边的语句是相关的,但是中间的语句与两者完全无关。从结构上看,第一个版本左边给出的是“正确路径”,右边给出的是错误路径。第二个版本中,错误路径将正确路径分隔为两部分,破坏了整个流程。

(点击放大图像)

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

复制代码
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 也会编写出十分长的代码行。为了改进代码的可读性,我们使用橙色标记条件部分,函数调用蓝色标出,函数参数标为黄色,错误路径标为红色。

(点击放大图像)

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

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

关于本文作者

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

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

2017 年 5 月 14 日 17:522884
用户头像

发布了 226 篇内容, 共 59.5 次阅读, 收获喜欢 14 次。

关注

评论

发布
暂无评论
发现更多内容

熬夜不睡觉整理ELK技术文档,从此摆脱靠百度的工作(附源码)

996小迁

Java 编程 架构 面试 ELK

《华为数据之道》读书笔记:第 4 章 面向“业务交易”的信息架构建设

方志

数据中台 数字化转型 数据治理

推荐几款MySQL相关工具

Simon

MySQL 工具 percona server

表格控件Spread.NET V14.0 发布:支持 .NET 5 和 .NET Core 3.1

Geek_Willie

京东千亿订单背后的纵深安全防御体系

京东智联云开发者

安全 网络 云服务 云安全

SpringBoot-技术专题-如何提高吞吐量

李浩宇/Alex

家庭留白、中屏崛起与硬件棋局

脑极体

架构师训练营 W06 作业

Geek_f06ede

Alibaba官方发文:阿里技术人的成长路径与方法论

Java架构师迁哥

Java踩坑记系列之Arrays.AsList

Java老k

Java

讯飞推出充电宝式便携拾音器,重新定义传统拾音

Talk A.I.

肝了一周的 UDP 基础知识终于出来了。

cxuan

计算机网络 计算机基础

「干货总结」程序员必知必会的十大排序算法

bigsai

排序 排序算法 快速排序

面试者必看:Java8中的默认方法

Silently9527

java8 默认方法

开源认证和访问控制的利器keycloak使用简介

程序那些事

程序那些事 授权框架 开源认证框架 keycloak 认证授权

为什么说应用架构需要分类思维?

Java架构师迁哥

甲方日常 57

句子

工作 随笔杂谈 日常

大厂经验:一套Web自动曝光埋点技术方案

阿亮

埋点 曝光埋点 点击埋点 自动化埋点

Architecture Phase1 Week10:Summarize

phylony-lu

极客大学架构师训练营

架构师训练营 - 第五周学习总结

joshuamai

计算机核心课程必读书目——《高级数据结构:理论与应用》

计算机与AI

数据结构 算法

“奋斗者”号下潜10909米:我们为什么要做深海探索?

脑极体

Thread.start() ,它是怎么让线程启动的呢?

小傅哥

Java 线程 JVM 小傅哥 Thread

java: Compilation failed: internal java compiler error解决办法

LSJ

IDEA

802.11抓包软件对比之Microsoft Network Monitor

IoT云工坊

wifi 嵌入式 抓包

重点人员管控系统开发方案,智慧警务平台搭建

WX13823153201

一期二班-吴水金-第六课作业

吴水金

架构师训练营 - 第五周课后练习

joshuamai

Java踩坑记系列之BigDecimal

Java老k

BigDecimal

关于 AWS Lambda 中的冷启动,你想了解的信息都在这!

donghui2020

Serverless Faas 函数计算

成德眉资现代农业园区大联动促发展,“1链3e”引领四市农业产业数字化建设

CNG农业公链

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

C# 7编程模式与实践-InfoQ