[周译见] C# 7 中的模范和实践

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

关键点

  • 遵循 .NET Framework 设计指南,时至今日,仍像十年前首次出版同样适用。
  • API 设计相当重要,设计不当的API大大增长错误,同时下降可重用性。
  • 始终保持"成功之道":只作正确的事,避免犯错。
  • 去除 "line noise" 和 "boilerplate" 类型的代码以保持关注业务逻辑
  • 在为了性能牺牲而可读性以前请保持清醒

C# 7 一个主要的更新是带来了大量有趣的新特性。虽然已经有不少文章介绍了 C# 7 能够作哪些事,但关于如何用好 C# 7 的文章仍是不多。遵循 .NET Framework设计指南中 的原则,咱们首先经过下面的策略,获取这些新特性的最佳作法。github

元组返回结果

在 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世纪以来的武术知识。

相关文章
相关标签/搜索