C# 7 中的模范和实践
原文地址:https://www.infoq.com/articles/Patterns-Practices-CSharp-7
关键点
- 遵循 .NET Framework 设计指南,时至今日,仍像十年前首次出版一样适用。
- API 设计至关重要,设计不当的API大大增加错误,同时降低可重用性。
- 始终保持"成功之道":只做正确的事,避免犯错。
- 去除 "line noise" 和 "boilerplate" 类型的代码以保持关注业务逻辑
- 在为了性能牺牲而可读性之前请保持清醒
C# 7 一个主要的更新是带来了大量有趣的新特性。虽然已经有很多文章介绍了 C# 7 可以做哪些事,但关于如何用好 C# 7 的文章还是很少。遵循 .NET Framework设计指南中 的原则,我们首先通过下面的策略,获取这些新特性的最佳做法。
元组返回结果
在 C# 以往的编程中,从一个函数中返回多个结果可是相当的乏味。Output 关键词是一种方法,但如果对于异步方法不适用。Tuple<T>(元组) 尽管啰嗦,又要分配内存,同时对于其字段又不能有描述性名称。自定义的结构优于元组,但在一次性代码中滥用会产生垃圾代码。最后,匿名类型和动态类型(dynamic) 的组合非常慢,又缺乏静态类型检查。
所有的这一切问题,在新的元组返回语法中得到了解决。下面是旧语法的例子:
public (string, string) LookupName(long id) // tuple return type { return ("John", "Doe"); // tuple literal } var names = LookupName(0); var firstName = names.Item1; var lastName = names.Item2;
这个函数实际的返回类型是 ValueTuple<string, string>。顾名思义,这是类似 Tuple<T> 类的轻量级结构。这解决了类型膨胀的问题,但和 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 解释了原因:
下面的原因解释了可变结构为何经常是坏的设计,请不要用于元组。
如果您以常规方式封装可变结构体,使用私有、公共的访问器,那么您将遇到一些意外惊吓。原因是尽管这些结构体被保存在只读变量中,访问器将悄悄在结构体的副本中生效!然而,元组只有公共的、可变的字段。由于这种设计没有访问器,因此不会有上述现象带来的风险。
再且因为它们是结构体,当它们被传递时会被复制。线程之间不直接共享,也不会有 “共享可变状态” 的风险。这与 System.Tuple 系列的类型相反,为了线程安全需要保证其不可变。
[译者]:Mutator的翻译参考https://en.wikipedia.org/wiki/Mutator_method#C.23_example,为 C# 中的访问器
注意他说的是“字段”,而不是“属性”。这可能会导致基于反射的库会有问题,这将对返回元组结果的方法造成毁灭。
元组返回结果指南
? 当返回结果的列表字段很小且永不会改变时,考虑使用元组返回结果而不是 out 参数。
? 在元组返回结果中使用帕斯卡(PascalCase)来命名描述性字段。这使得元组字段看起来像普通类和结构体上的属性。
? 在读取元组返回值时不要使用var来解构(deconstructing) ,避免意外搞错字段。
? 期望的返回值中用到反射的避免使用元组。
? 在公开的 APIs 中请不要使用元组返回结果,如果在将来的版本中需要返回其他字段,将字段添加到元组返回结果具有破坏性。
(译者:deconstructing 的翻译参考 https://zhuanlan.zhihu.com/p/25844861 中对deconstructing的翻译,下面的部分名词也是如此)
解构多值返回结果
回到 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 已经事先被声明了。
解构器
尽管名字很像 “析构(destructor)”,但解构器与对象销毁无关。正如构造函数将独立的值组合成一个对象一样,解构器同样是组合和分解对象。解构器允许任何类提供上述的解构语法。让我们来分析一下 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);
你可能会好奇为什么使用 output 参数,而不是元组。一部分原因是性能,这样就减少了需要复制的数量。但最主要的原因是微软还为重载打开了一道门。
继续我们的研究,注意到 Rectangle 还有第二个构造函数:
public Rectangle(Point location, Size size);
我们同样为它匹配一个解构方法:
public void Deconstruct(out Point location, out Size size); var (location, size) = myRectangle;
有多少个不同数量的构造参数就有多少个解构函数。即使你显式地指出类型,编译器也无法确定有哪些解构方法可以使用。
在 API 设计中,结构通常能从解构中受益。类,特别是模型或者DTOs,如 Customer 和 Employee 可能不应该有解构方法,它们没有方法解决诸如:"应该是 (firstName, lastName, phoneNumber, email)" 还是 " (firstName, lastName, email, phoneNumber)" 的问题。某种程度来说,大家都应该开心。
解构器指南
? 考虑在读取元组返回值时使用解构,但要注意避免搞错标签。
? 为结构提供自定义的解构方法。
? 记得匹配类的构造函数中字段的顺序,重写 ToString 。
? 如果结构具有多个构造函数,考虑提供对应的解构方法。
? 考虑立即解构大值元组。大值元组的总大小超过16个字节,这可能带来多次复制的昂贵代价。请注意,引用类型的变量在32位操作系统中的大小总是4字节,而在64位操作系统是8字节。
? 当不知道在类中字段应以何种方式排序时,请不要使用解构方法。
? 不要声明多个具有同等数量参数的解构方法。
Out 变量
C# 7 为 带有 "out" 变量的调用函数提供了两种新的语法选择。现在可以在函数调用中这样声明变量。
if (int.TryParse(s, out var i)) { Console.WriteLine(i); }
另一种选择是完全使用"下划线",忽略out 变量。
if (int.TryParse(s, out _)) { Console.WriteLine("success"); }
如果你使用过 C# 7 预览版,可能会注意到一点:对被忽略的参数使用星号(*)已被更改为用下划线。这样做的部分原因是在函数式编程中通常出于同样的目的使用了下划线。其他类似的选择包括诸如"void" 或者 "ignore" 的关键字。
使用下划线很方便,同时意味着 API中的设计缺陷。在大多数情况中,更好的方法是对忽视的 out 参数简单地提供一个方法重载。
Out 变量指南
? 考虑用元组返回值替代 out参数。
? 尽量避免使用 out 或者 ref 参数。[详情见 框架设计指南 ]
? 考虑对忽视的 out 参数提供重载,这样就不需要用下划线了。
局部方法和迭代器
局部方法是一个有趣的概念。乍一看,就像是创建匿名方法的一种更易读的语法。下面看看他们的不同。
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; }
然而,一旦你开始深入了解,一些有趣的内容将会浮现。
匿名方法 vs. 局部方法
当你创建一个普通的匿名方法时,总是会创建一个对应的隐藏类来存储该匿名方法。该隐藏类的实例将被创建并存储在该类的静态字段中。因此,一旦创建,没有额外的开销。
反观局部方法,不需要隐藏类。相反,局部方法表现为其静态父方法。
闭包
如果您的匿名方法或局部方法引用了外部变量,则产生"闭包"。下面是示例:
public DateTime Max_Local_Function(IList<DateTime> values) { int callCount = 0; DateTime MaxDate(DateTime left, DateTime right) { callCount++; <--The variable callCount is being closed over. return (left > right) ? left : right; } var result = values.First(); foreach (var item in values.Skip(1)) result = MaxDate(result, item); return result; }
对于匿名方法来说,隐藏类每次创建新实例时都要求外部父方法被调用。这确保每次调用时,会在父方法和匿名方法共享数据副本。
这种设计的缺点是每次调用匿名方法需要实例化一个新对象。这就带来了昂贵的使用成本,同时加重垃圾回收的压力。
反观局部方法,使用隐藏结构取代了隐藏类。这就允许继续存储上一次调用的数据,避免了每次都要实例化对象。与匿名方法一样,局部方法实际存储在隐藏结构中。
委托
创建匿名方法或局部方法时,通常会将其封装到委托,以便在事件处理程序或者 LINQ 表达式中调用。
根据定义,匿名方法是匿名的。所以为了使用它,往往需要当成委托存储在一个变量或参数。
委托不可以指向结构(除非他们被装箱了,那就是奇怪的语义)。所以如果你创建了一个委托并指向一个局部方法,编译器将会创建一个隐藏类代替隐藏结构。如果该局部方法是一个闭包,那么每次调用父方法时都会创建一个隐藏类的新实例。
迭代器
在C#中,使用 yield 返回的 IEnumerable<T> 不能立即验证其参数。相反,直到在匿名枚举器中调用 MoveNext,才可以对其参数进行验证。
这在 VB 中不是问题,因为它支持 匿名迭代器。下面有一个来自MSDN的示例:
Public Function GetSequence(low As Integer, high As Integer) _ As IEnumerable ‘ Validate the arguments. If low < 1 Then Throw New ArgumentException("low is too low") If high > 140 Then Throw New ArgumentException("high is too high") ‘ Return an anonymous iterator function. 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(); }
迭代器需要构建一个状态机,所以它们的行为就像在隐藏类中作为委托返回闭包。
匿名方法和局部方法指南
? 当不需要委托时,使用局部方法代替匿名方法,尤其是涉及到闭包。
? 当返回一个需要验证参数的 IEnumerator 时,使用局部迭代器。
? 考虑将局部方法放到方法的开头或结尾处,以便与父方法区分来。
? 避免在性能敏感的代码中使用带委托的闭包,这适用于匿名方法和局部方法。
引用返回、局部引用以及引用属性
结构具有一些有趣的性能特性。由于他们与其父数据结构一起存储,没有普通类的头开销。这意味着你可以非常密集地存储在数组中,很少或不浪费空间。除了减少内存总体开销外,还带来了极大的优势,使 CPU 缓存更高效。这就是为什么构建高性能应用程序的人喜欢结构。
但是如果结构太大的话,需要避免不必要的复制。微软的指南建议为16个字节,足够存储2个 doubles 或者 4 个 integers。这不是很多,尽管有时可以使用位域 (bit-fields)来扩展。
局部引用
这样做的一个方法是使用智能指针,所以你永远不需要复制。这里有一些我仍然使用的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。为了避免复制,仍然使用旧式的 for 循环。即使如此,所有的读和写操作都是直接在 m_Entries 数组中操作。
使用 C# 7 的局部引用,明显地减少混乱而不改变语义。
for (var i = 0; i < m_Entries.Length; i++) { ref Entry entry = ref m_Entries[i]; //create a reference 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; } }
这是因为 "局部引用" 真的是一个安全的指针。我们之所以说它 “安全” ,是因为编译器指向不允许任何临时变量,诸如普通方法的结果。
如果你很想知道 " ref var entry = ref m_Entries[i];" 是不是有效的语法(是的),无论如何也不能这么做,会造成混乱。 ref 既是用于声明,又不会被用到。(译者:这里应该是指 entry 的 ref 修饰吧)
引用返回
引用返回丰富了本地方法,允许创建无副本的方法。
继续之前的示例,我们可以将搜索结果输出推到其静态方法。
static ref Entry FindColumn(Entry[] entries, string searchKey) { for (var i = 0; i < entries.Length; i++) { ref Entry entry = ref entries[i]; //create a reference 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; } }
引用返回和索引器
对于引用返回和局部引用最大的限制可能就是需要一个固定的指针。
考虑这行代码:
ref int x = ref myList[0];
这样的代码无效,因为列表不像数组,在读取其值时会创建一个副本结构。下面是对 List<T> 实现 引用的源码:
public T this[int index] { get { // Following trick can reduce the range check by one if ((uint) index >= (uint)_size) { ThrowHelper.ThrowArgumentOutOfRangeException(); } Contract.EndContractBlock(); return _items[index]; <-- return makes a copy }
这同样适用于 ImmutableArray<T> 和 访问 IList<T> 接口的普通数组。但是,您可以实现自己的List<T>,将其索引定义为引用返回。
public ref T this[int index] { get { // Following trick can reduce the range check by one if ((uint) index >= (uint)_size) { ThrowHelper.ThrowArgumentOutOfRangeException(); } Contract.EndContractBlock(); return ref _items[index]; <-- return ref makes a reference }
如果你这么做,需要明确实现 IList<T> 和 IReadOnlyList<T> 接口。这是因为引用返回具有与普通返回值不同的签名,因此不能满足接口的要求。
由于索引器实际上只是专用属性,它们与引用属性具有相同的限制; 这意味着您无法显式定义 setter,而索引器却是可写的。
引用返回、局部引用和引用属性指南
? 在使用数组的方法中,考虑使用引用返回而不是索引值
? 在拥有结构的自定义集合类中,对索引器考虑使用引用返回代替一般的返回结果。
? 将包含可变结构体的属性暴露为引用属性。
? 不要将包含不可变结构的属性暴露为引用属性。
? 不要在不可变或只读类上暴露引用属性。
? 不要在不可变或只读集合类上暴露引用索引器。
ValueTask 和通用异步返回类型
当Task类被创建时,它的主要角色是简化多线程编程。它创建一种将长时间运行的操作推入线程池的通道,并在 UI线程上推迟读取结果。而当你使用 fork-join 模式并发时,效果显著。
随着.NET 4.5中引入了 async/await ,一些缺陷也开始显现。正如我们在2011年的反馈(详见 Task Parallel Library Improvements in .NET 4.5),创建一个 Task对象所花费的时间比可接受的时间长,因此必须重写其内部,结果是创建Task<Int32> 所需的时间缩短了49%至55%,并在大小上减小了52%。
这是很好的一步,但 Task 仍然分配了内存。所以当你在紧凑循环中使用它,如下所示将产生大量的垃圾。
while (await stream.ReadAsync(buffer, offset, count) != 0) { //process buffer }
而且如前所述, C# 高性能代码的关键在于减少内存分配和随后的GC循环。微软的Joe Duffy在 Asynchronous Everything 的文章中写到:
首先,请记住,Midori 被整个操作系统用于内存垃圾回收。我们必须学到了一些必要的经验教训,以便充分发挥作用。但我想说的主要是避免不必要的分配,分配越多麻烦越多,特别是短命对象。早期 .NET世界中流传着一句口头禅:Gen0 集合是无代价的。不幸的是,这形成了很多.NET的库代码滥用。Gen0 集合存在着中断、弄脏缓存以及在高并发的系统中有高频问题。
这里的真正解决方案是创建一个基于结构的 task,而不是使用堆分配的版本。这实际上是以System.Threading.Tasks.Extensions 中的 ValueTask<T>创建。并且因为 await 已经任何暴露的方法中工作了,所以你可以使用它。
手动暴露ValueTask<T>
ValueTask<T>的基本用例是预期结果在大部分时间是同步的,并且想要消除不必要的内存分配。首先,假设你有一个传统的基于 task 的异步方法。
public async Task<Customer> ReadFromDBAsync(string key)
然后我们将其封装到一个缓存方法中:
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)); }
并添加一个辅助方法来构建异步状态机。
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 的提议是提供通用异步返回结果。
根据目前的设计,你只能使用异步关键字,并且方法返回 Task、Task<T>或者 void。一旦实现,通用异步返回结果将会扩展到任何 tasklike 方法上去。一些人认为 tasklike 需要有一个 AsyncBuilder 属性。这表明辅助类被用于创建 tasklike 对象。
在这个设计的注意事项中,微软估计大概有五种人实际上会创建 tasklike 类,从而被普遍接受。其他人都很可能也像这五分之一。这是我们上面使用新语法的例子:
public async ValueTask<Customer> ReadFromCacheAsync(string key) { Customer result; if (_Cache.TryGetValue(key, out result)) { return result; //no allocation } else { result = await ReadFromDBAsync(key); _Cache[key] = result; return result; } }
如您所见,我们已经去除了辅助方法,除了返回类型,它看起来像任何其他异步方法一样。
何时使用 ValueTask<T>
所以应该使用 ValueTask<T> 代替 Task<T>? 完全不必要,这可能有点难以理解,所以我们将引用相关文档:
方法可能会返回一个该值类型的实例,当它们的操作可以同时执行,同时被频繁唤起(invoked)。这时,对于Task<TResult>,每一次调用都是昂贵的成本,应该被禁止。
使用 ValueTask<TResult> 代替 Task<TResult> 需要权衡利弊。例如,虽然 ValueTask<TResult> 可以避免分配,并且成功返回结果是可以同步返回的。然而它需要两个字段,而 Task<TResult> 作为引用类型只是一个字段。这意味着调用方法最终返回的是两个数据而不是一个数据,这就会有更多的数据被复制。同时意味着如果在异步方法中需要等待时,只返回其中一个,这会导致该异步方法的状态机变得更大。因为要存储两个字段的结构而不是一个引用。
再进一步,使用者通过 await 来获取异步操作的结果,ValueTask<TResult> 可能会导致更复杂的模型,实际上就会导致分配更多的内存。例如,考虑到一个方法可能返回一个普通的已缓存 task 的结果Task<TResult>,或者是一个 ValueTask<TResult>。如果调用者的预期结果是 Task<TResult>,可以被诸如 Task.WhenAll 和 Task.WhenAny 的方法调用,那么 ValueTask<TResult> 首先需要使用 ValueTask<TResult>.AsTask 将其自身转换为 Task<TResult> ,如果 Task<TResult> 在第一次使用没有被缓存了,将导致分配。
因此,Task的任何异步方法的默认选择应该是返回一个 Task 或Task<TResult>。除非性能分析证明使用 ValueTask<TResult> 优于Task<TResult>。Task.CompletedTask 属性可能被单独用于传递任务成功执行的状态, ValueTask<TResult> 并不提供泛型版本。
这是一段相当长的段落,所以我们在下面的指南中总结了这一点。
ValueTask <T>指南
? 当结果经常被同步返回时,请考虑在性能敏感代码中使用 ValueTask<T>。
? 当内存压力是个问题,且 Tasks 不能被缓存时,考虑使用 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;
但是,你可能会遇到 last name 同时为空。
public override string ToString() => !string.IsNullOrEmpty(FirstName) ? FirstName + " " + LastName : (!string.IsNullOrEmpty(LastName) ? LastName : "No Name");
如您所见,很容易得意忘形地使用这个功能。所以当你遇到有多分支条件或者 null合并操作时,请克制使用。
表达式体属性
表达式体属性是 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 To Get Windows Runtime Support, Asynchronous Methods。
表达式体构造函数
表达式体构造函数是C# 7 的新特性。下面有一个例子:
class Person { public Person(string name) => Name = name; public string Name { get; } }
这里的用法非常有限。它只有在零个或者一个参数的情况下才有效。一旦需要将其他参数分配给字段/属性时,则必须用回传统的构造函数。同时也无法初始化其他字段,解析事件处理程序等(参数验证是可能的,请参见下面的“抛出表达式”。)
所以我们的建议是简单地忽略这个功能。它只是将单参数构造函数看起来与一般的构造函数不同而已,同时让代码大小减少而已。
析构表达式
为了使 C# 更加一致,析构被允许写成和表达式的成员一样,就像用在方法和构造函数一样。
对于那些忘记析构的人来说,C# 中的析构是在 Finalize 方法上重写System.Object。虽然 C# 不这样表达:
~UnmanagedResource() { ReleaseResources(); }
这种语法的一个问题是它看起来很像一个构造函数,因此可以很容易地被忽略。另一个问题是它模仿 C ++中的析构语法,却是完全不同的语义。但是已经被使用了这么久,所以我们只好转向新的语法:
~UnmanagedResource() => ReleaseResources();
现在我们有一行孤立的、容易忽略的代码,用于终结对象生命周期。这不是一个简单的 属性 或 ToString 方法,而是很重大的操作,需要显眼一些。所以我建议不要使用它。
表达式体成员指南
? 为简单的属性使用表达式体成员。
? 为方法重载使用表达式体成员。
? 简单的方法考虑使用表达式体成员。
? 不要在表达式体成员使用多分支条件(a?b:c)或 null 合并运算符(x ?? y)。
? 不要为 构造函数 和 析构函数 中使用表达式成员。
抛出表达式
表面上,编程语言一般可以分为两种:
- 一切都是表达式
- 语句、声明和表达式都是独立的概念
Ruby是前者的一个实例,甚至其声明也是表达式。相比之下,Visual Basic代表后者,语句和表达式之间有很强的区别。例如,对于 "if" 而言,当它独立存在时,以及作为表达式中的一部分时,是完全不同的语法。
C#主要是第二阵营,但存在着 C语言的遗产,允许你处理语句,当成表达式一样。可以编写如下代码:
while ((current = stream.ReadByte()) != -1) { //do work; }
首先,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(); }
在这些例子中,很容易看出会发生什么情况。但是如果我们移动抛出表达式的位置呢?
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并不难看。但是为了更好地阅读代码,我们使用橙色标记条件,蓝色标记函数调用,黄色标记函数参数,红色标记错误分支。
这样可以看到随着参数改变位置,上下文如何变化。
抛出表达式指南
? 在分支/返回语句中,考虑将抛出表达式放在条件(a?b:c)和 null 合并运算符(x ?? y)的右侧。
? 避免将抛出表达式放到条件运算的中间位置。
? 不要将抛出表达式放在方法的参数列表中。
有关异常如何影响 API设计的更多信息,请参阅 Designing with Exceptions in .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 表达式同样适用。
实际上,您希望最常见的情况是 switch 语句中的第一种情况,就像在一系列 if-else-if 语句块中一样。同样,如果任何检查特别昂贵,那么它应该越靠近底部,只在必要时才执行。
顺序规则的例外是默认情况。它总是被最后处理,不管它的实际顺序是什么。这会使代码更难理解,所以我建议将默认情况放在最后。
模式匹配表达式
虽然 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)在90年代末期开始从事卫生诊所的MIS项目,从 Access 和 Excel 到企业解决方案。在为金融部门编写自动化交易系统五年之后,他成为各种项目的顾问,包括机器人仓库的UI,癌症研究软件的中间层以及房地产保险公司的大数据需求。在空闲时间,他学习和书写16世纪以来的武术知识。