2018 年 6 月

第 33 卷,第 6 期

.Net framework 的元组时遇到问题:C# 元组是为什么收到中断准则

通过标记 Michaelis |年 6 月 2018

返回在 2017 年 8 月发行的 MSDN 杂志我编写了深入文章 C# 7.0 和它对元组的支持 (msdn.com/magazine/mt493248)。在时间我 glossed 通过包含元组类型引入具有 C# 7.0 (内部类型 ValueTuple <> …) 的分隔线几条准则的结构良好的值类型,即事实:

• 不要声明为公共或受保护的字段 (改为在两侧加上一个属性)。

• 未定义可变值类型。

• 不要创建值类型大于 16 个字节的大小。

这些准则了就地以来 C# 1.0,并且尚未此处在 C# 7.0,它们已引发到时段定义 System.ValueTuple <> … 数据类型。从技术上讲,System.ValueTuple <> … 是一系列的数据类型相同的名称,但不同 arity (具体而言,多个类型参数)。因此特别有关这些 long 类型的值遵循的准则不再适用此特定数据类型是什么?可以在其中这些准则将适用的情况来了解如何和-或不会应用-帮助我们不断改进其应用程序与定义的值类型?

让我们开始讨论重点封装和属性与字段的好处。例如,考虑表示圆的周长部分弧线值类型。中所示的半径,圆、 圆弧中的第一个点的起始角度 (以度为单位) 和弧线中的最后一个点的扫描角度 (以度为单位) 由定义图 1

图 1 定义一段弧线,

public struct Arc
{
  public Arc (double radius, double startAngle, double sweepAngle)
  {
    Radius = radius;
    StartAngle = startAngle;
    SweepAngle = sweepAngle;
  }

  public double Radius;
  public double StartAngle;
  public double SweepAngle;

  public double Length
  {
    get
    {
      return Math.Abs(StartAngle - SweepAngle)
        / 360 * 2 * Math.PI * Radius;
    }
  }

  public void Rotate(double degrees)
  {
    StartAngle += degrees;
    SweepAngle += degrees;
  }

  // Override object.Equals
  public override bool Equals(object obj)
  {
    return (obj is Arc)
      && Equals((Arc)obj);
  }

        // Implemented IEquitable<T>
  public bool Equals(Arc arc)
  {
    return (Radius, StartAngle, SweepAngle).Equals(
      (arc.Radius, arc.StartAngle, arc.SweepAngle));
  }

  // Override object.GetHashCode
  public override int GetHashCode() =>
    return (Radius, StartAngle, SweepAngle).GetHashCode();

  public static bool operator ==(Arc lhs, Arc rhs) =>
    lhs.Equals(rhs);

  public static bool operator !=(Arc lhs, Arc rhs) =>
    !lhs.Equals(rhs);
}

不要声明为公共或受保护的字段

在此声明中,弧线与定义的特征的三个公共字段是圆弧的值类型 (使用关键字结构定义)。是,我可能已使用属性,但我选择了使用在此示例中的公共字段,具体而言,因为它违反了第一个准线-不声明为公共或受保护的字段。

通过综合利用公共字段,而不是属性,弧线定义缺少最基本的面向对象的设计原则-封装。例如,如果我决定更改该内部数据结构,以使用 radius,例如,开始角度和弧线的长度,而不是扫描角度?这样将会很明显破坏接口为弧线和所有客户端将被强制进行代码更改。

同样,与 Radius、 StartAngle 和圆周上位于中的定义,我有任何验证。Radius,例如,无法分配小的负的值。和虽然 StartAngle 和圆周上位于负值可能非常大,不会大于 360 度的值。遗憾的是,因为使用公共字段定义圆弧,是无法添加验证,以防止这些值。是,我可以在版本 2 中添加验证,通过将字段更改为属性,但这样做将会破坏弧线结构的版本兼容性。任何现有的已编译调用字段的代码将会破坏在运行时,为将任何代码 (即使重新编译),将传递该字段作为 ref 参数。

给定字段不应为公共或受保护的准则,值得注意的是属性,尤其是在具有默认值变得更加轻松比包装属性,以支持 C# 6.0 属性初始值设定项,谢谢的显式字段定义。例如,下面的代码:

public double SweepAngle { get; set; } = 180;

是比这更简单:

private double _SweepAngle = 180;

public double SweepAngle {
  get { return _SweepAngle; }
  set { _SweepAngle = value; }
}

属性初始值设定项支持非常重要的因为没有它,需要初始化自动实现的属性需要随附的构造函数。因此,那么准则:"对字段考虑自动实现的属性"(甚至私有字段) 使好的效果,同时,因为代码更简洁并且不再可以修改从其包含的属性之外的字段。所有这些倾向于另一项准则,"请避免访问从其包含的属性之外的字段"强调甚至从其他类成员的前面所述数据封装原则。

此时允许返回到 C# 7.0 元组类型 ValueTuple <>...。有关公开字段参考,尽管 ValueTuple < T1、 T2 >,例如,定义,如下所示:

public struct ValueTuple<T1, T2>
  : IComparable<ValueTuple<T1, T2>>, ...
{
  public T1 Item1;
  public T2 Item2;
  // ...
}

哪些部分构成 ValueTuple <> … 特殊?与大多数数据结构,C# 7.0 元组,之后称为元组,不是整个对象 (如个人或 CardDeck 对象) 有关。相反,它也是有关任意组合运输出于,因此它们无法从没有使用 out 或 ref 参数会花费时间的方法返回的各个部分。Mads Torgersen 使用碰巧相同的总线的人员的一组类比-bus 就像一个元组,其中的人员是类似的元组中的项。项的分组在一起返回的元组参数中由于它们为所有目标以返回到调用方,不是因为它们一定彼此的任何其他关联。事实上,则很可能将然后检索此元组中的值然后单独而不是作为一个单元处理这些调用方。

各个项,而不是整个重要性使较少引人注目的封装的概念。假设元组中的项可以是完全无关,是通常无需在其两侧以此方式,例如,更改 Item1,可能会影响 Item2。(相比之下,更改弧线长度将需要更改一个或两个角度因此封装是必需的。) 此外,没有为存储元组中的项无效值。针对项本身的数据类型,而不在一个元组上的项属性的分配,则将强制执行任何验证。

出于此原因,元组的属性未提供任何值,并且没有任何传入的将来值,它们可以提供。简单地说,如果你要定义数据,也无需验证可变类型,也可能使用字段。你可能想要利用属性的另一个原因是具有不同可访问性之间 getter 和 setter。但是,假设可接受的可变性,你不打算或者具有不同的 getter/setter 可访问性,充分利用属性。这全都引发另一个问题 — 应元组类型是可变?

未定义可变值类型

要考虑的下一步准则是可变的值类型。同样,弧线示例 (在代码中所示图 2) 违反了准线。如果你理解这一点很明显,值类型将传递一个副本,因此无法更改副本将不会从调用方的可观测对象。但是,尽管中的代码图 2演示的概念,仅修改的副本,代码的可读性却没有。从可读性的角度,看起来弧线更改。

图 2 的值类型会复制以便调用方不观察到更改

[TestMethod]
public void PassByValue_Modify_ChangeIsLost()
{
  void Modify(Arc paramameter) { paramameter.Radius++; }
  Arc arc = new Arc(42, 0, 90);
  Modify(arc);
  Assert.AreEqual<double>(42, arc.Radius);
}

什么是令人困惑是,为了使开发人员需要值复制行为,他们将需要知道弧线是值类型。但是,没有任何明显表示值类型行为 (但是若要公平地讲,Visual Studio IDE 将显示值类型作为结构如果将鼠标悬停在数据类型) 的源代码。您可能可能会说 C# 程序员应知道值类型与引用类型语义,以便在行为图 2预期。但是,考虑中的方案图 3当复制行为不是那么明显。

图 3 可变值类型的意外行为

public class PieShape
{
  public Point Center { get; }
  public Arc Arc { get; }

  public PieShape(Arc arc, Point center = default)
  {
    Arc = arc;
    Center = center;
  }
}

public class PieShapeTests
{
  [TestMethod]
  public void Rotate_GivenArcOnPie_Fails()
  {
    PieShape pie = new PieShape(new Arc(42, 0, 90));
    Assert.AreEqual<double>(90, pie.Arc.SweepAngle);
    pie.Arc.Rotate(42);
    Assert.AreEqual<double>(90, pie.Arc.SweepAngle);
  }
}

请注意,尽管调用弧的旋转函数,圆弧,事实上,永远不会旋转。为什么会这样呢?此令人困惑的行为是由于两个因素的组合。首先,弧是后,即可通过值而不是按引用传递的值类型。因此,调用饼图。弧线返回弧线,而不是返回的构造函数中实例化的弧线的同一个实例的副本。如果该项第二个因素,这不会出现问题。旋转的调用旨在修改存储在饼图,弧的实例,但事实上,它会修改从弧线属性返回的副本。这就是为什么和我们有准线、"未定义可变值类型。"

之前,在 C# 7.0 中的元组忽略此原则和公开的根据定义,使 ValueTuple <> … 可变公共字段。尽管此冲突,ValueTuple <> … 不会降低作为弧线的同一个缺点。原因是修改此元组的唯一方法是通过项字段。但是,C# 编译器不允许从包含类型 (包含类型是引用类型、 值类型或甚至数组或其他类型的集合) 返回的字段 (或属性) 的修改。例如,下面的代码将不进行编译:

pie.Arc.Radius = 0;

也不将此代码:

pie.Arc.Radius++;

这些语句会失败并显示消息"错误 CS1612:无法修改 PieShape.Arc 的返回值因为它不是一个变量。" 换而言之,那么准则不一定准确。而不是避免所有可变值类型,关键是避免变异的函数 (读/写属性是允许)。智慧,当然,假设对中所示的值语义图 2是足够明显的这样的: 预期的固有值类型行为。

不创建值类型大于 16 个字节

由于值类型复制的频率,则需要此原则。事实上,除了 ref 或 out 参数,值类型会在复制几乎每次他们要访问。无论分配到另一个值类型实例都是如此 (如弧线 = 中的弧线图 3) 或方法调用 (如所示的 Modify(arc)图 2)。出于性能原因,那么准则是保持较小值类型大小。

事实是,ValueTuple <> … 罐装的大小通常会大于 128 位 (16 个字节) 因为 ValueTuple <> … 可能包含七个各个项 (和更多如果指定另一个元组的第八个类型参数)。原因,然后,C# 7.0 元组定义为值类型?

如前所述,此元组作为一种语言功能,使多个返回值不通过所需的复杂语法会引入 out 或 ref 参数。常规模式,然后,是构造并返回一个元组,然后解构其回位于调用方。事实上,通过返回的参数传递入堆栈的元组是类似于传递自变量在堆栈中向上方法调用的组。换而言之,返回的元组是具有单个参数列表的对称密钥就而言内存副本。

如果声明为引用类型,此元组,则它将为构造堆上的类型,并将其初始化的项值与所需-可能复制值或到堆的引用。两种方式的内存复制操作是必需的类似于的值类型的内存复制。此外,更高版本时引用元组将不再可访问的时间点,在垃圾回收器将需要恢复内存。换而言之,引用元组仍涉及内存复制,以及其他压力垃圾回收器,从而更高效的选择的值类型的元组。(在极少数情况下,值元组不是效率更高,则无法仍使用引用类型版本,元组 <> …。)

在完全交文章的主要主题,注意等于和 GetHashCode 中的实现图 1。你可以看到元组如何提供用于实现 Equals 和 GetHashCode 的快捷方式。有关详细信息,请参阅"重写相等性和 GetHashCode 到使用元组"。

总结

初看上去它可能看起来令人惊讶的元组定义为不可变的值类型。毕竟,.NET 核心和.NET Framework 中找到的不可变的值类型的数目很小,并且没有长期编程不可变且封装具有属性的值类型的调用的准则。此外,还向 F #、 的压力变大 C# 语言设计器提供一种速记来声明不可变变量或定义不可变的类型的不可变的默认方法特性的影响。(虽然没有此类速记我们正在考虑 C# 8.0,只读的结构已添加到 C# 7.2 作为一种方法验证结构不可变。)

但是,当你深入了解详细信息,你将看到大量的重要因素。这些工作包括:

• 引用类型施加额外的性能影响与垃圾回收。

• 元组是通常暂时的。

• 元组项都具有可预见不必具有属性的封装。

较大 (由值类型准则) • 甚至元组不具有大量的内存复制操作以外的引用元组实现。

总之,还有很多倾向于使用而不考虑适用的标准准则的公共字段的值类型的元组的因素。在结束时,指导原则是仅该指南。不会忽视它们,但会给定足够-建议我,显式记录-原因,它是确定以有时颜色位于线外部。

有关定义值类型和重写 Equals 和 GetHashCode 指南的详细信息,请参阅 9 和 10 我基本 C# 簿中的章节:"基本 C# 7.0"(IntelliTect.com/EssentialCSharp),这需要在五月为 out。


Mark Michaelis 是 IntelliTect 的创始人,担任首席技术架构师和培训师。有关近二十他已被 Microsoft 最有价值,起 2007年已 Microsoft 区域主管。对多个 Microsoft 软件设计的 Michaelis 充当查看包括 C#、 Mi crosoft Azure、 SharePoint 和 Visual Studio ALM 的团队。他在开发人员大会说出并编写了大量丛书包括其最新"Es sential C# 6.0 (版本 5)"(itl.tc/EssentialCSharp)。可通过他的 Facebook facebook.com/Mark.Michaelis、博客 IntelliTect.com/Mark、Twitter @markmichaelis 或电子邮件 mark@IntelliTect.com 与他取得联系。