@Rays
2017-05-09T21:36:07.000000Z
字数 19861
阅读 1814
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的新特性。
通常在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是可变的。Mads Torgersen给出了这样的解释:
为什么通常可变结构体是不好的,不要应用于元组?下面给出原因。
如果你按正常的封装方式编写了一个可变结构体,并且其中具有私有的状态,还有公开的修改器(Mutator)属性和方法,那么你可能就会陷入一些严重的错误中。因为只要结构体是保持在只读变量中,那么修改器就会默默地工作于结构体的一个拷贝上!
但是元组的确有公开的可变字段。它在设计上并未考虑修改器,因此不存在出现上述现象的风险。
此外,ValueTuple是结构体,而结构体在传递时需要进行拷贝。结构体并不直接在线程间共享,也不承担“共享可变状态”的风险。这不同于System.Tuple家族的类型,这些类型也是类。为确保线程安全,需要这些类型是不可变的。
注意,这里Torgersen所指的是“字段”,而不是“属性”。对于使用元组返回函数结果的反射库,这会导致问题。
✔ 当字段列表规模较小并不会发生更改时,考虑使用元组返回,而不是out
参数。
✔ 对元组返回中的描述性名字使用帕斯卡拼写法(PascalCase),这会使得元组字段看上去就像是正常的类和结构中的属性。
✔ 在不进行解析就读取元组返回时,使用var
,以避免意外地误标字段。
✘ 如果想要对返回值使用反射,应避免返回值元组。
✘ 如果在未来的版本中可能会返回额外的字段,那么就不要在公开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个字节。
✘ 如果不清楚字段的出现顺序,就不要在类上暴露析构方法。
✘ 不要声明具有相同参数数量的多个析构方法。
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或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;
}
然而,只有深入地接触局部函数,才能发现其中的引入入胜之处。
正常创建一个匿名函数时,总是会相应地创建一个用于存储该函数的隐含类。该隐含类将会创建一个实例,并存储在类的静态字段中。因此,隐含类一旦创建,就不再需要更多的开销。
反之,本地函数不需要隐含类,而是与其父函数一样,表示为同一个类中的静态方法。
如果一个函数中的变量被自身所包含的匿名函数或局部函数引用,则称为形成了一个“闭包”,因为这种行为“包含”(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;
}
每次调用一个包含匿名函数的函数时,需要新建一个隐含类实例。这种设计确保了每次调用函数时,函数中具有对父函数与匿名函数间共享数据的拷贝。
这种设计的缺点在于,每次调用匿名函数时需要实例化一个新的对象。由于这对垃圾回造成了压力,因此增加了使用的开销。
使用局部函数时会创建一个隐含结构体,而非一个隐含类。这允许局部函数持续存储预调用的数据,同时消除了对单个对象实例化的需求。类似于匿名方程,局部函数也是物理地存储在隐含结构体中。
在创建匿名函数或局部函数时,很多情况下会将函数打包为一个委托,这样就可以在事件处理器或是LINQ表达式中使用它。
从定义上看,匿名函数当然是匿名的。因此要使用匿名函数,通常需要将匿名函数以委托的形式存储在变量或参数中。
委托不能指向结构体,除非将委托装箱(Box)。但这种语法很奇怪。因此如果你创建了一个指向局部函数的委托,编译器将会创建一个隐含类,而不是一个隐含结构体。如果该局部函数是一个闭包,那么在每次调用父函数时,需要新建一个隐含类的实例。
在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();
}
迭代器需要构建一个状态机,因此在行为上类似于闭包,需根据隐含类以委托的形式返回。
✔ 在不需要委托时,一定要使用本地函数,而非匿名函数,尤其是涉及闭包的情况下。
✔ 所需的参数需要验证时,一定要使用局部迭代器。
✔ 可以考虑将局部函数定义在一个函数体的开始或结束处,这样可以从观感上将局部函数与它们的父函数区分开来。
✘ 对性能敏感的代码中,应避免使用具有委托的闭包。这一原则同样适用于匿名函数和局部函数。
结构体具有一些有意思的性能特性。由于结构体的存储与其父数据结构一致,因此没有正常对象那样的头部开销。这意味着可以将结构体密集地打包到一个数组中,这样很少的或几乎没有空间浪费。这种设计不但降低了整体内存开销,而且提供了极大的本地性,使得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#并没有提供类似于只读引用返回的定义,因此不能创建指向只读字段的引用。
引用返回和局部引用都需要给定一个固定的引用点,这可能是它们的最大局限性所在。考虑下面的代码:
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
)。同时,索引器也是可写的。
✔ 考虑对操作数组的函数使用引用返回,而不是索引值。
✔ 考虑在具有结构体的自定义集合类中使用引用返回,而不是正常的返回。
✔ 要将包含可变结构体的属性暴露为引用属性。
✘ 不要将包含不可变结构体的属性暴露为引用属性。
✘ 不要在不可变类或只读类上暴露引用属性。
✘ 不要在不可变或只读集合类上暴露引用索引器。
创建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>的一个基本用例。一开始,我们假定有一个基于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");
}
上面的编程模式虽然并不难理解,但是实现起来却十分冗长。我们知道,代码编写得越冗长,越易于包含简单的错误。因此在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>替代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>。
✔ 当存在内存压力问题并且不能存储Task时,考虑使用ValueTask<T>
✘ 避免在公开API中暴露ValueTask<T>,除非存在显著的性能影响。
✘ 不要在调用Task.WhenAll
或WhenAny
方法时使用ValueTask<T>。
表达式体成员使得开发人员可以在声明简单函数时不使用大括号。对于传统的四行函数,通常能缩减为一行。例如:
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)操作符可以实现不少功能,但是应尽量克制使用这样的设计。
表达式体属性是在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”部分。
表达式体构造函数同样是C# 7新引入的特性。下面给出一个例子:
class Person
{
public Person(string name) => Name = name;
public string Name { get; }
}
这里的用法非常受限。代码只在没有参数或是一个参数时工作。一旦添加了另一个需为字段或属性的参数,必须切换回传统的构造函数。该用法也不能初始化其它字段,或是钩到事件处理器(但是可以做参数验证,参见下文“Throw表达式”一章内容)。
因此,我们的建议是忽略该特性。它只是让单参数的构造函数看上去不同于一般的构造函数而已,对减少代码量的贡献很小。
为使C#更为一致,C# 7允许表达式体成员是一个析构函数,正如表达式体成员可以是一个方法或一个构造函数。
为避免有人忘记了析构的概念,我们对此稍作解释。在C#中,析构函数事实上是覆写了System.Object
中Finalize
方法,虽然C#并不用以这一方式表述。例如:
~UnmanagedResource()
{
ReleaseResources();
}
该语法存在一个问题,就是构函数看上去类似于一个构造函数,导致易被忽视。另一个问题是,它模仿了C++中的析构语法,但是在C++中析构语法具有完全不同的语义。该语法已经这样地使用很久了,所以让我们继续使用这一语法:
~UnmanagedResource() => ReleaseResources();
该代码只有一行,易于被忽视,它实现了将对象加入到终结器队列的周期中。这并非一个无关紧要的属性或是一个ToString
方法,而是一个值得关注的重要操作。我们再一次建议不要使用该特性。
✔ 对简单属性不要使用表达式体成员。
✔ 对于调用同一函数中其它重载的方法,一定要使用表达式体成员。
✔ 考虑对非关键函数使用表达式体成员。
✘ 不要在表达式体成员中使用多于一个条件(a ? b : c
),或是使用空值合并(x ?? y
)。
✘ 不要对构造函数和析构函数使用表达式体成员。
编程语言通常可将粗略地分成两类:
前一类的例子是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表达式置于条件(a ? b : c
)和空值合并(x ?? y
)操作符的左侧。
✘ 不要将throw表达式置于条件操作符的中间位置。
✘ 不要在函数的参数列表中放置throw表达式。
要详细了解异常是如何影响API设计的,参见“.NET异常设计原则”一文。
模式匹配改进了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