C-9-和--NET5-高级教程-五-

96 阅读57分钟

C#9 和 .NET5 高级教程(五)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

八、使用接口

本章通过研究基于接口的编程主题,建立在您当前对面向对象开发的理解之上。在这里,您将学习如何定义和实现接口,并逐渐理解构建支持多种行为的类型的好处。在这个过程中,您将会看到几个相关的主题,比如获取接口引用、实现显式接口和构造接口层次结构。您还将研究几个在?NET 核心基本类库。还涵盖了 C# 8 中关于接口的新特性,包括默认接口方法、静态成员和访问修饰符。正如您将看到的,您的自定义类和结构可以自由地实现这些预定义的接口,以支持一些有用的行为,如对象克隆、对象枚举和对象排序。

了解接口类型

在本章开始,请允许我提供一个接口类型的正式定义,它随着 C# 8.0 的引入而改变。在 C# 8.0 之前,接口只不过是一组命名的抽象成员。回想一下第六章中的抽象方法是纯协议,因为它们不提供默认的实现。接口定义的具体成员取决于它所建模的确切行为。换句话说,一个接口表达了一个给定的类或结构可能选择支持的行为。此外,正如你将在本章看到的,一个类或结构可以支持任意多的接口,从而支持(本质上)多种行为。

C# 8.0 中引入的默认接口方法功能允许接口方法包含一个实现,该实现可能会也可能不会被实现类重写。本章后面会有更多的介绍。

正如您可能猜到的那样。NET Core 基本类库附带了许多预定义的接口类型,这些接口类型由各种类和结构实现。例如,正如您将在第二十一章中看到的,ADO.NET 提供了多个数据提供程序,允许您与特定的数据库管理系统进行通信。因此,在 ADO.NET 下,您有许多连接对象可供选择(SqlConnectionOleDbConnectionOdbcConnection等)。).此外,第三方数据库供应商(以及众多开源项目)提供。NET 库与大量其他数据库(MySQL、Oracle 等)进行通信。),所有这些都包含实现这些接口的对象。

尽管每个连接类都有一个惟一的名称,在不同的名称空间中定义,并且(在某些情况下)捆绑在不同的程序集中,但是所有连接类都实现了一个名为IDbConnection的公共接口。

// The IDbConnection interface defines a common
// set of members supported by all connection objects.
public interface IDbConnection : IDisposable
{
   // Methods
   IDbTransaction BeginTransaction();
   IDbTransaction BeginTransaction(IsolationLevel il);
   void ChangeDatabase(string databaseName);
   void Close();
   IDbCommand CreateCommand();
   void Open();
   // Properties
   string ConnectionString { get; set;}
   int ConnectionTimeout { get; }
   string Database { get; }

   ConnectionState State { get; }
}

Note

按照惯例,。NET 接口名称的前缀是大写字母 I 。当您创建自己的自定义接口时,最好也这样做。

此时不要关心这些成员做什么的细节。简单地理解一下,IDbConnection接口定义了一组所有 ADO.NET 连接类共有的成员。鉴于此,可以保证每个连接对象都支持诸如Open()Close()CreateCommand()等成员。此外,由于接口成员总是抽象的,每个连接对象都可以以自己独特的方式自由实现这些方法。

在阅读本书的剩余部分时,您将会接触到。NET 核心基本类库。正如您将看到的,这些接口可以在您自己的定制类和结构上实现,以定义与框架紧密集成的类型。同样,一旦你理解了接口类型的有用性,你肯定会找到构建你自己的接口类型的理由。

接口类型与抽象基类

鉴于你在第六章中的工作,接口类型可能看起来有点像抽象基类。回想一下,当一个类被标记为抽象时,它可以定义任意数量的抽象成员来为所有派生类型提供多态接口。然而,即使一个类定义了一组抽象成员,它也可以自由定义任意数量的构造函数、字段数据、非抽象成员(带实现)等等。接口(在 C# 8.0 之前)只包含成员定义。现在,有了 C# 8,接口可以包含成员定义(比如抽象成员)、具有默认实现的成员(比如虚方法)和静态成员。真正的区别只有两个:接口不能有非静态的构造函数,一个类可以实现多个接口。接下来我们将详细讨论第二点。

由抽象父类建立的多态接口有一个主要的限制,即只有 派生类型支持由抽象父类定义的成员。然而,在更大的软件系统中,开发多个除了System.Object之外没有公共父类的类层次结构是很常见的。假设抽象基类中的抽象成员只适用于派生类型,您就没有办法在不同的层次结构中配置类型来支持相同的多态接口。首先,创建一个名为 CustomInterfaces 的新控制台应用项目。将以下抽象类添加到项目中:

namespace CustomInterfaces
{
  public abstract class CloneableType
  {
    // Only derived types can support this
    // "polymorphic interface." Classes in other
    // hierarchies have no access to this abstract
   // member.
    public abstract object Clone();
  }
}

根据这个定义,只有扩展了CloneableType的成员才能支持Clone()方法。如果你创建了一组新的类,但没有扩展这个基类,你就不能获得这个多态接口。同样,回想一下 C# 不支持类的多重继承。因此,如果你想创造一个“是-a”Car和“是-a”CloneableTypeMiniVan,你是无法做到的。

// Nope! Multiple inheritance is not possible in C#
// for classes.
public class MiniVan : Car, CloneableType
{
}

正如你可能猜到的那样,接口类型来帮忙了。定义接口后,它可以由任何类或结构、任何层次结构、任何命名空间或任何程序集(用任何。NET 核心编程语言)。如你所见,接口是高度多态的*。考虑标准。NET 核心接口命名为ICloneable,定义在System命名空间中。这个接口定义了一个名为Clone()的方法。*

public interface ICloneable
{
  object Clone();
}

如果你检查。NET 核心基础类库,你会发现很多看似不相关的类型(System.ArraySystem.Data.SqlClient.SqlConnectionSystem.OperatingSystemSystem.String等。)都实现了这个接口。尽管这些类型没有共同的父类型(除了System.Object,但是您可以通过ICloneable接口类型对它们进行多态处理。

首先,清除Program.cs代码并添加以下内容:

using System;
using CustomInterfaces;

Console.WriteLine("***** A First Look at Interfaces *****\n");
CloneableExample();

接下来,将下面名为CloneMe()的局部函数添加到顶级语句中。该函数接受一个ICloneable接口参数,该参数接受实现该接口的任何对象。下面是功能代码:

static void CloneableExample()
{
  // All of these classes support the ICloneable interface.
  string myStr = "Hello";
  OperatingSystem unixOS =
    new OperatingSystem(PlatformID.Unix, new Version());

  // Therefore, they can all be passed into a method taking ICloneable.
  CloneMe(myStr);
  CloneMe(unixOS);
  static void CloneMe(ICloneable c)
  {
    // Clone whatever we get and print out the name.
    object theClone = c.Clone();
    Console.WriteLine("Your clone is a: {0}",
      theClone.GetType().Name);
  }
}

当您运行这个应用时,每个类的类名通过您从System.Object继承的GetType()方法打印到控制台。正如将在第十七章中详细解释的,这个方法允许你在运行时理解任何类型的组成。无论如何,上一个程序的输出如下所示:

***** A First Look at Interfaces *****
Your clone is a: String
Your clone is a: OperatingSystem

抽象基类的另一个限制是每个派生类型必须与一组抽象成员竞争并提供一个实现。为了解决这个问题,回想一下你在第六章中定义的形状层次。假设您在名为GetNumberOfPoints()Shape基类中定义了一个新的抽象方法,它允许派生类型返回呈现形状所需的点数。

namespace CustomInterfaces
{
  abstract class Shape
  {
...
    // Every derived class must now support this method!
   public abstract byte GetNumberOfPoints();
  }
}

显然,唯一有分数的职业是Hexagon。然而,有了这次更新,每个派生类(CircleHexagonThreeDCircle)现在都必须提供这个函数的具体实现,即使这样做毫无意义。同样,接口类型提供了一个解决方案。如果你定义了一个代表“拥有点”行为的接口,你可以简单地把它插入到Hexagon类型中,而不去碰CircleThreeDCircle

Note

在我的记忆中,C# 8 中对接口的改变可能是对现有语言特性最重要的改变。如前所述,新的接口功能使它们更接近抽象类的功能,增加了一个类实现多个接口的能力。我的建议是在这些水域中小心行事,运用常识。仅仅因为你能做某事并不意味着你应该做。

定义自定义接口

现在,您已经更好地理解了接口类型的总体作用,让我们来看一个定义和实现定制接口的例子。从您在第六章创建的 Shapes 解决方案中复制Shape.csHexagon.csCircle.csThreeDCircle.cs文件。完成之后,将定义以形状为中心的类型的名称空间重命名为CustomInterfaces(只是为了避免在新项目中导入名称空间定义)。现在,在您的项目中插入一个名为IPointy.cs的新文件。

在语法层面,接口是使用 C# interface关键字定义的。与类不同,接口从不指定基类(甚至不指定System.Object;然而,正如你将在本章后面看到的,一个接口可以指定基本接口。在 C# 8.0 之前,接口成员从不指定访问修饰符(因为所有接口成员都是隐式公共和抽象的)。C# 8.0 中的新特性,私有、内部、受保护甚至静态成员也可以被定义。稍后将对此进行更多介绍。为了让球滚动起来,这里有一个用 C# 定义的自定义接口:

namespace CustomInterfaces
{
  // This interface defines the behavior of "having points."
  public interface IPointy
  {
    // Implicitly public and abstract.
    byte GetNumberOfPoints();
  }
}

C# 8 中的接口不能定义数据字段或非静态构造函数。因此,以下版本的IPointy将导致各种编译器错误:

// Ack! Errors abound!
public interface IPointy
{
  // Error! Interfaces cannot have data fields!
  public int numbOfPoints;
  // Error! Interfaces do not have nonstatic constructors!
  public IPointy() { numbOfPoints = 0;}
}

无论如何,这个初始的IPointy接口定义了一个方法。接口类型也能够定义任意数量的属性原型。例如,我们可以更新IPointy接口来使用一个读写属性(注释掉)和一个只读属性。Points属性取代了GetNumberOfPoints()方法。

// The pointy behavior as a read-only property.
public interface IPointy
{
  // Implicitly public and abstract.
  //byte GetNumberOfPoints();

  // A read-write property in an interface would look like:
  //string PropName { get; set; }

  // while a write-only property in an interface would be:
   byte Points { get; }
}

Note

接口类型也可以包含事件(见第十二章)和索引器(见第十一章)定义。

接口类型本身毫无用处,因为你不能像分配一个类或结构那样分配接口类型。

// Ack! Illegal to allocate interface types.
IPointy p = new IPointy(); // Compiler error!

在被类或结构实现之前,接口不会带来太多好处。这里,IPointy是一个表示“有积分”行为的接口。这个想法很简单:形状层次结构中的一些类有点(如Hexagon),而另一些类(如Circle)没有点。

实现接口

当一个类(或结构)选择通过支持接口来扩展其功能时,它会在类型定义中使用逗号分隔的列表。请注意,直接基类必须是冒号运算符后列出的第一项。当您的类类型直接从System.Object派生时,您可以简单地列出该类支持的接口(或多个接口),因为如果您没有另外说明,C# 编译器将从System.Object扩展您的类型。与此相关的一点是,鉴于结构总是从System.ValueType派生而来(参见第章第四部分),只需在结构定义后直接列出每个接口。思考下面的例子:

// This class derives from System.Object and
// implements a single interface.
public class Pencil : IPointy
{...}

// This class also derives from System.Object
// and implements a single interface.
public class SwitchBlade : object, IPointy
{...}

// This class derives from a custom base class
// and implements a single interface.
public class Fork : Utensil, IPointy
{...}

// This struct implicitly derives from System.ValueType and
// implements two interfaces.
public struct PitchFork : ICloneable, IPointy
{...}

要明白,对于不包含默认实现的接口项来说,实现接口是一个要么全有要么全无的命题。支持类型不能有选择地选择它将实现哪些成员。鉴于IPointy接口定义了一个只读属性,这并不是太大的负担。然而,如果你正在实现一个定义了 10 个成员的接口(比如前面显示的IDbConnection接口),那么这个类型现在负责充实所有 10 个抽象成员的细节。

对于这个例子,插入一个名为Triangle的新类类型,它“是-a”Shape并支持IPointy。注意,只读Points属性的实现(使用表达式主体成员语法实现)只是返回正确的点数(三)。

using System;
namespace CustomInterfaces
{
  // New Shape derived class named Triangle.
  class Triangle : Shape, IPointy
  {
    public Triangle() { }
    public Triangle(string name) : base(name) { }
    public override void Draw()
    {
      Console.WriteLine("Drawing {0} the Triangle", PetName);
    }

    // IPointy implementation.
    //public byte Points
    //{
      //    get { return 3; }
    //}
    public byte Points => 3;
  }
}

现在,更新您现有的Hexagon类型来支持IPointy接口类型。

using System;
namespace CustomInterfaces
{
  // Hexagon now implements IPointy.
  class Hexagon : Shape, IPointy
  {
    public Hexagon(){ }
    public Hexagon(string name) : base(name){ }
    public override void Draw()
    {
      Console.WriteLine("Drawing {0} the Hexagon", PetName);
    }

    // IPointy implementation.
    public byte Points => 6;
  }
}

综上所述,图 8-1 所示的 Visual Studio 类图使用流行的“棒棒糖”符号说明了与IPointy兼容的类。再次注意,CircleThreeDCircle没有实现IPointy,因为这种行为对这些类没有意义。

img/340876_10_En_8_Fig1_HTML.jpg

图 8-1。

形状层次结构,现在带有接口

Note

若要在类设计器中显示或隐藏接口名称,请右键单击接口图标,然后选择折叠或展开选项。

在对象级别调用接口成员

既然已经有了一些支持IPointy接口的类,下一个问题就是如何与新功能交互。与给定接口提供的功能进行交互的最直接方式是直接从对象级别调用成员(假设接口成员没有显式实现;您可以在“实现显式接口”一节中找到更多的细节)。例如,考虑以下代码:

Console.WriteLine("***** Fun with Interfaces *****\n");
// Call Points property defined by IPointy.
Hexagon hex = new Hexagon();
Console.WriteLine("Points: {0}", hex.Points);
Console.ReadLine();

在这种情况下,这种方法工作得很好,假设您知道Hexagon类型已经实现了正在讨论的接口,因此有一个Points属性。但是,其他时候,您可能无法确定给定类型支持哪些接口。例如,假设您有一个包含 50 个Shape兼容类型的数组,其中只有一部分支持IPointy。显然,如果你试图在一个没有实现IPointy的类型上调用Points属性,你会收到一个错误。那么,如何动态地确定一个类或结构是否支持正确的接口呢?

在运行时确定类型是否支持特定接口的一种方法是使用显式强制转换。如果类型不支持请求的接口,您会收到一个InvalidCastException。若要妥善处理这种可能性,请使用结构化异常处理,如下例所示:

...
// Catch a possible InvalidCastException.
Circle c = new Circle("Lisa");
IPointy itfPt = null;
try
{
  itfPt = (IPointy)c;
  Console.WriteLine(itfPt.Points);
}
catch (InvalidCastException e)
{
  Console.WriteLine(e.Message);
}
Console.ReadLine();

虽然您可以使用try / catch逻辑并抱乐观态度,但是在调用接口成员之前确定支持哪些接口是最理想的。让我们看看这样做的两种方法。

获取接口引用:as 关键字

你可以通过使用第六章中介绍的as关键字来确定一个给定的类型是否支持一个接口。如果对象可以被视为指定的接口,则返回一个对相关接口的引用。如果没有,您将收到一个null参考。因此,在继续之前,一定要检查null值。

...
// Can we treat hex2 as IPointy?
Hexagon hex2 = new Hexagon("Peter");
IPointy itfPt2 = hex2 as IPointy;
if(itfPt2 != null)
{
  Console.WriteLine("Points: {0}", itfPt2.Points);
}
else
{
   Console.WriteLine("OOPS! Not pointy...");
}
Console.ReadLine();

注意,当你使用as关键字时,你不需要使用try / catch逻辑;如果引用不是null,那么您知道您正在调用一个有效的接口引用。

获取接口引用:is 关键字(更新于 7.0)

你也可以使用关键字is检查一个实现的接口(也在第六章中首次讨论)。如果有问题的对象与指定的接口不兼容,则返回值false。如果在语句中提供变量名,则该类型被赋给该变量,从而消除了进行类型检查和强制转换的需要。前面的示例在此处更新:

Console.WriteLine("***** Fun with Interfaces *****\n");
...
if(hex2 is IPointy itfPt3)
{
  Console.WriteLine("Points: {0}", itfPt3.Points);
}
else
{
  Console.WriteLine("OOPS! Not pointy...");
}
 Console.ReadLine();

默认实现(新 8.0)

如前所述,C# 8.0 增加了接口方法和属性拥有默认实现的能力。添加一个名为IRegularPointy的新接口来表示一个规则形状的多边形。代码如下所示:

namespace CustomInterfaces
{
  interface IRegularPointy : IPointy
  {
    int SideLength { get; set; }
    int NumberOfSides { get; set; }
    int Perimeter => SideLength * NumberOfSides;
  }
}

向项目中添加一个名为Square.cs的新类,继承Shape基类,并实现IRegularPointy接口,如下所示:

namespace CustomInterfaces
{
  class Square: Shape,IRegularPointy
  {
    public Square() { }
    public Square(string name) : base(name) { }
    //Draw comes from the Shape base class
    public override void Draw()
    {
      Console.WriteLine("Drawing a square");
    }

    //This comes from the IPointy interface
    public byte Points => 4;
    //These come from the IRegularPointy interface
    public int SideLength { get; set; }
    public int NumberOfSides { get; set; }
    //Note that the Perimeter property is not implemented
  }
}

这里我们无意中引入了在接口中使用默认实现的第一个“陷阱”。在IRegularPointy接口上定义的Perimeter属性没有在Square类中定义,这使得它不能从Square的实例中访问。要查看实际情况,创建一个Square类的新实例,并将相关值输出到控制台,如下所示:

Console.WriteLine("\n***** Fun with Interfaces *****\n");
...
var sq = new Square("Boxy")
  {NumberOfSides = 4, SideLength = 4};
sq.Draw();
//This won’t compile
//Console.WriteLine($"{sq.PetName} has {sq.NumberOfSides} of length {sq.SideLength} and a perimeter of {sq.Perimeter}");

相反,Square实例必须被显式地转换为IRegularPointy接口(因为这是实现所在的地方),然后才能访问Perimeter属性。将代码更新为以下内容:

Console.WriteLine($"{sq.PetName} has {sq.NumberOfSides} of length {sq.SideLength} and a perimeter of {((IRegularPointy)sq).Perimeter}");

解决这个问题的一个选择是始终对类型的接口进行编码。将Square实例的定义从Square改为IRegularPointy,如下所示:

IRegularPointy sq = new Square("Boxy") {NumberOfSides = 4, SideLength = 4};

这种方法的问题是Draw()方法和PetName属性没有在接口上定义,导致编译错误。

虽然这是一个微不足道的例子,但它确实展示了默认接口的一个问题。在您的代码中使用该特性之前,请确保您衡量了调用代码必须知道实现存在于何处的含义。

静态构造函数和成员(新 8.0)

C# 8.0 中接口的另一个新增功能是拥有静态构造函数和成员的能力,它们的功能与类定义中的静态成员相同,但都是在接口上定义的。用一个示例静态属性和一个静态构造函数更新IRegularPointy接口。

interface IRegularPointy : IPointy
{
  int SideLength { get; set; }
  int NumberOfSides { get; set; }
  int Perimeter => SideLength * NumberOfSides;

  //Static members are also allowed in C# 8
  static string ExampleProperty { get; set; }

  static IRegularPointy() => ExampleProperty = "Foo";
}

静态构造函数必须是无参数的,并且只能访问静态属性和方法。若要访问接口静态属性,请将以下代码添加到顶级语句中:

Console.WriteLine($"Example property: {IRegularPointy.ExampleProperty}");
IRegularPointy.ExampleProperty = "Updated";
Console.WriteLine($"Example property: {IRegularPointy.ExampleProperty}");

请注意,静态属性必须从接口而不是实例变量中调用。

作为参数的接口

假设接口是有效的类型,你可以构造将接口作为参数的方法,如本章前面的CloneMe()方法所示。对于当前的例子,假设您已经定义了另一个名为IDraw3D的接口。

namespace CustomInterfaces
{
  // Models the ability to render a type in stunning 3D.
  public interface IDraw3D
  {
    void Draw3D();
  }
}

接下来,假设您的三个形状中的两个(ThreeDCircleHexagon)已经被配置为支持这个新行为。

// Circle supports IDraw3D.
class ThreeDCircle : Circle, IDraw3D
{
...
  public void Draw3D()
    =>  Console.WriteLine("Drawing Circle in 3D!"); }
}

// Hexagon supports IPointy and IDraw3D.
class Hexagon : Shape, IPointy, IDraw3D
{
...
  public void Draw3D()
    => Console.WriteLine("Drawing Hexagon in 3D!");
}

图 8-2 展示了更新后的 Visual Studio 类图。

img/340876_10_En_8_Fig2_HTML.jpg

图 8-2。

更新的形状层次结构

如果您现在定义了一个将IDraw3D接口作为参数的方法,那么您可以有效地发送任何实现IDraw3D的对象。如果试图传入不支持必要接口的类型,则会收到编译时错误。考虑在您的Program类中定义的以下方法:

// I'll draw anyone supporting IDraw3D.
static void DrawIn3D(IDraw3D itf3d)
{
  Console.WriteLine("-> Drawing IDraw3D compatible type");
  itf3d.Draw3D();
}

您现在可以测试Shape数组中的一个项目是否支持这个新接口,如果支持,就将其传递给DrawIn3D()方法进行处理。

Console.WriteLine("***** Fun with Interfaces *****\n");
Shape[] myShapes = { new Hexagon(), new Circle(),
  new Triangle("Joe"), new Circle("JoJo") } ;
for(int i = 0; i < myShapes.Length; i++)
{
  // Can I draw you in 3D?
  if (myShapes[i] is IDraw3D s)
  {
    DrawIn3D(s);
  }
}

下面是更新后的应用的输出。注意,只有Hexagon对象在 3D 中打印出来,因为Shape数组的其他成员没有实现IDraw3D接口。

***** Fun with Interfaces *****
...
-> Drawing IDraw3D compatible type
Drawing Hexagon in 3D!

作为返回值的接口

接口也可以用作方法返回值。例如,您可以编写一个方法,该方法采用一组Shape对象,并返回对第一个支持IPointy的项的引用。

// This method returns the first object in the
// array that implements IPointy.
static IPointy FindFirstPointyShape(Shape[] shapes)
{
  foreach (Shape s in shapes)
  {
    if (s is IPointy ip)
    {
      return ip;
    }
  }
  return null;
}

您可以按如下方式与此方法交互:

Console.WriteLine("***** Fun with Interfaces *****\n");
// Make an array of Shapes.
Shape[] myShapes = { new Hexagon(), new Circle(),
                 new Triangle("Joe"), new Circle("JoJo")};

// Get first pointy item.
IPointy firstPointyItem = FindFirstPointyShape(myShapes);
// To be safe, use the null conditional operator.
Console.WriteLine("The item has {0} points",
  firstPointyItem?.Points);

接口类型数组

回想一下,同一个接口可以由许多类型实现,即使它们不在同一个类层次结构中,并且没有超过System.Object的公共父类。这可以产生一些强大的编程结构。例如,假设您已经在您当前的项目中开发了三个新的类类型来建模厨房用具(通过KnifeFork类)和另一个建模园艺设备(à la PitchFork)。这里显示了类的相关代码,更新后的类图如图 8-3 所示:

img/340876_10_En_8_Fig3_HTML.jpg

图 8-3。

回想一下,接口可以“插入”类层次结构中任何部分的任何类型

//Fork.cs
namespace CustomInterfaces
{
  class Fork : IPointy
  {
    public byte Points => 4;
  }
}
//PitchFork.cs
namespace CustomInterfaces
{
  class PitchFork : IPointy
  {
    public byte Points => 3;
  }
}
//Knife.cs.cs
namespace CustomInterfaces
{
  class Knife : IPointy
  {
    public byte Points => 1;
  }
}

如果您定义了PitchForkForkKnife类型,那么您现在可以定义一个IPointy兼容对象的数组。假设这些成员都支持相同的接口,那么您可以遍历数组并将每一项视为一个IPointy兼容的对象,而不管类层次结构的总体多样性。

...
// This array can only contain types that
// implement the IPointy interface.
IPointy[] myPointyObjects = {new Hexagon(), new Knife(),
  new Triangle(), new Fork(), new PitchFork()};

foreach(IPointy i in myPointyObjects)
{
  Console.WriteLine("Object has {0} points.", i.Points);
}
Console.ReadLine();

为了强调这个例子的重要性,请记住:当你有一个给定接口的数组时,这个数组可以包含任何实现这个接口的类或结构。

自动使用实现接口

尽管基于接口的编程是一种强大的技术,但是实现接口可能需要大量的输入。鉴于接口是一组命名的抽象成员,您需要在支持行为的每个类型上键入每个接口方法的定义和实现。因此,如果您想要支持一个总共定义了五个方法和三个属性的接口,您需要考虑所有八个成员(否则您将会收到编译器错误)。

正如您所希望的那样,Visual Studio 和 Visual Studio 代码都支持各种工具,这些工具可以减轻实现接口的负担。通过一个简单的测试,将一个 final 类插入到当前名为PointyTestClass的项目中。当您向一个类类型添加一个像IPointy这样的接口(或者任何这样的接口)时,您可能已经注意到,当您完成输入接口名称时(或者当您将鼠标光标放在代码窗口中的接口名称上时),Visual Studio 和 Visual Studio 代码都添加了一个灯泡,它也可以用 Ctrl+句点(.)组合键。当你点击灯泡时,会出现一个下拉列表,允许你实现接口(见图 8-4 和 8-5 )。

img/340876_10_En_8_Fig5_HTML.jpg

图 8-5。

使用 Visual Studio 自动实现接口

img/340876_10_En_8_Fig4_HTML.jpg

图 8-4。

使用 Visual Studio 代码自动实现接口

注意,您有两个选择,第二个(显式接口实现)将在下一节中讨论。暂时选择第一个选项,您会看到 Visual Studio/Visual Studio 代码已经生成了存根代码供您更新。(注意默认实现抛出一个System.NotImplementedException,显然可以删除。)

namespace CustomInterfaces
{
  class PointyTestClass : IPointy
  {
    public byte Points => throw new NotImplementedException();
  }
}

Note

Visual Studio /Visual Studio 代码还支持提取接口重构,可从“快速操作”菜单的“提取接口”选项中获得。这允许您从现有的类定义中提取新的接口定义。例如,您可能正在编写一个类,这时您突然意识到可以将行为一般化到一个接口中(从而打开了替代实现的可能性)。

显式接口实现

如本章前面所示,一个类或结构可以实现任意数量的接口。考虑到这一点,您总是有可能实现包含相同成员的接口,因此有名称冲突要处理。为了说明解决此问题的各种方式,请创建一个名为 InterfaceNameClash 的新控制台应用项目。现在设计三个接口,表示实现类型可以将其输出呈现到的不同位置。

namespace InterfaceNameClash
{
  // Draw image to a form.
  public interface IDrawToForm
  {
    void Draw();
  }
}

namespace InterfaceNameClash
{
  // Draw to buffer in memory.
  public interface IDrawToMemory
  {
    void Draw();
  }
}

namespace InterfaceNameClash
{
  // Render to the printer.
  public interface IDrawToPrinter
  {
    void Draw();
  }
}

注意,每个接口都定义了一个名为Draw()的方法,具有相同的签名。如果您现在想要在名为Octagon的单个类类型上支持这些接口中的每一个,编译器将允许以下定义:

using System;
namespace InterfaceNameClash
{
  class Octagon : IDrawToForm, IDrawToMemory, IDrawToPrinter
  {
   public void Draw()
   {
      // Shared drawing logic.
      Console.WriteLine("Drawing the Octagon...");
    }
  }
}

尽管代码可以干净地编译,但是您可能会遇到一个问题。简单地说,提供Draw()方法的单一实现并不允许您根据从Octagon对象获得的接口采取独特的行动。例如,下面的代码将调用相同的Draw()方法,而不管您获得哪个接口:

using System;
using InterfaceNameClash;

Console.WriteLine("***** Fun with Interface Name Clashes *****\n");
// All of these invocations call the
// same Draw() method!
Octagon oct = new Octagon();

// Shorthand notation if you don't need
// the interface variable for later use.
((IDrawToPrinter)oct).Draw();

// Could also use the "is" keyword.
if (oct is IDrawToMemory dtm)
{
  dtm.Draw();
}

Console.ReadLine();

显然,将图像呈现到窗口所需的代码与将图像呈现到网络打印机或内存区域所需的代码完全不同。当您实现几个具有相同成员的接口时,您可以使用显式接口实现语法来解决这种名称冲突。考虑以下对Octagon类型的更新:

class Octagon : IDrawToForm, IDrawToMemory, IDrawToPrinter
{
   // Explicitly bind Draw() implementations
   // to a given interface.
   void IDrawToForm.Draw()
   {
     Console.WriteLine("Drawing to form...");
   }
   void IDrawToMemory.Draw()
   {
     Console.WriteLine("Drawing to memory...");
   }
   void IDrawToPrinter.Draw()
   {
     Console.WriteLine("Drawing to a printer...");
   }
}

如您所见,当显式实现接口成员时,一般模式可以分解为:

returnType InterfaceName.MethodName(params){}

请注意,使用此语法时,不需要提供访问修饰符;显式实现的成员自动是私有的。例如,以下是非法语法:

// Error! No access modifier!
public void IDrawToForm.Draw()
{
   Console.WriteLine("Drawing to form...");
}

因为显式实现的成员总是隐式私有的,所以这些成员在对象级别不再可用。事实上,如果您将点运算符应用于一个Octagon类型,您会发现 IntelliSense 不会向您显示任何Draw()成员。正如所料,您必须使用显式转换来访问所需的功能。顶层语句中的前一段代码已经使用了显式强制转换,因此它可以使用显式接口。

Console.WriteLine("***** Fun with Interface Name Clashes *****\n");
Octagon oct = new Octagon();

// We now must use casting to access the Draw()
// members.
IDrawToForm itfForm = (IDrawToForm)oct;
itfForm.Draw();

// Shorthand notation if you don't need
// the interface variable for later use.
((IDrawToPrinter)oct).Draw();

// Could also use the "is" keyword.
if (oct is IDrawToMemory dtm)
{
  dtm.Draw();
}
Console.ReadLine();

虽然当您需要解决名称冲突时,这种语法非常有用,但是您可以使用显式接口实现来简单地隐藏对象级别的更多“高级”成员。这样,当对象用户应用点运算符时,用户将只能看到该类型整体功能的一个子集。但是,那些需要更高级行为的人可以通过显式强制转换提取所需的接口。

设计接口层次结构

接口可以排列在接口层次结构中。像类层次结构一样,当一个接口扩展一个现有的接口时,它继承了由父类定义的抽象成员。在 C# 8 之前,派生接口从不继承真正的实现。相反,派生接口只是用额外的抽象成员扩展了它自己的定义。在 C# 8 中,派生接口继承了默认实现,扩展了定义,并可能添加新的默认实现。

当您希望在不破坏现有代码库的情况下扩展现有接口的功能时,接口层次结构会很有用。为了进行说明,创建一个名为 InterfaceHierarchy 的新控制台应用项目。现在,让我们设计一组新的以渲染为中心的接口,这样IDrawable就是家谱的根。

namespace InterfaceHierarchy
{
  public interface IDrawable
  {
    void Draw();
  }
}

鉴于IDrawable定义了一个基本的绘制行为,您现在可以创建一个派生接口,用修改后的格式来扩展这个接口。这里有一个例子:

namespace InterfaceHierarchy
{
  public interface IAdvancedDraw : IDrawable
  {
    void DrawInBoundingBox(int top, int left, int bottom, int right);
    void DrawUpsideDown();
  }
}

根据这种设计,如果一个类要实现IAdvancedDraw,那么它现在需要实现继承链中定义的每个成员(特别是Draw()DrawInBoundingBox()DrawUpsideDown()方法)。

using System;
namespace InterfaceHierarchy
{
  public class BitmapImage : IAdvancedDraw
  {
    public void Draw()
    {
      Console.WriteLine("Drawing...");
    }

    public void DrawInBoundingBox(int top, int left, int bottom, int right)
    {
      Console.WriteLine("Drawing in a box...");
    }

    public void DrawUpsideDown()
    {
      Console.WriteLine("Drawing upside down!");
    }
  }
}

现在,当您使用BitmapImage时,您可以在对象级别调用每个方法(因为它们都是public),以及通过强制转换提取对每个支持的接口的引用。

using System;
using InterfaceHierarchy;

Console.WriteLine("***** Simple Interface Hierarchy *****");

// Call from object level.
BitmapImage myBitmap = new BitmapImage();
myBitmap.Draw();
myBitmap.DrawInBoundingBox(10, 10, 100, 150);
myBitmap.DrawUpsideDown();

// Get IAdvancedDraw explicitly.
if (myBitmap is IAdvancedDraw iAdvDraw)
{
  iAdvDraw.DrawUpsideDown();
}
Console.ReadLine();

默认实现的接口层次结构(新 8.0)

当接口层次结构还包括默认实现时,下游接口可以选择从基接口继承该实现,或者创建一个新的默认实现。将IDrawable接口更新如下:

public interface IDrawable
{
  void Draw();
  int TimeToDraw() => 5;
}

接下来,将顶级语句更新为以下内容:

Console.WriteLine("***** Simple Interface Hierarchy *****");
...
if (myBitmap is IAdvancedDraw iAdvDraw)
{
  iAdvDraw.DrawUpsideDown();
  Console.WriteLine($"Time to draw: {iAdvDraw.TimeToDraw()}");
}
Console.ReadLine();

这段代码不仅会编译,而且会为TimeToDraw()方法输出一个值 5。这是因为默认实现会自动结转到后代接口。将BitMapImage转换为IAdvancedDraw接口提供了对TimeToDraw()方法的访问,即使BitMapImage实例不能访问默认实现。要证明这一点,请输入以下代码并查看编译错误:

//This does not compile
myBitmap.TimeToDraw();

如果下游接口想要提供自己的默认实现,它必须隐藏上游实现。例如,如果IAdvancedDraw TimeToDraw()方法需要 15 个单位来绘制,则将接口更新为以下定义:

public interface IAdvancedDraw : IDrawable
{
  void DrawInBoundingBox(
    int top, int left, int bottom, int right);
  void DrawUpsideDown();
  new int TimeToDraw() => 15;
}

当然,BitMapImage类也可以自由实现TimeToDraw()方法。与IAdvancedDraw TimeToDraw()方法不同,这个类只需要 实现 方法,而不需要隐藏它。

public class BitmapImage : IAdvancedDraw
{
...
  public int TimeToDraw() => 12;
}

当将BitmapImage实例转换为IAdvancedDrawIDrawable接口时,实例上的方法仍然被执行。将此代码添加到顶级语句中:

//Always calls method on instance:
Console.WriteLine("***** Calling Implemented TimeToDraw *****");
Console.WriteLine($"Time to draw: {myBitmap.TimeToDraw()}");
Console.WriteLine($"Time to draw: {((IDrawable) myBitmap).TimeToDraw()}");
Console.WriteLine($"Time to draw: {((IAdvancedDraw) myBitmap).TimeToDraw()}");

结果如下:

***** Simple Interface Hierarchy *****
...
***** Calling Implemented TimeToDraw *****
Time to draw: 12
Time to draw: 12
Time to draw: 12

接口类型的多重继承

与类类型不同,一个接口可以扩展多个基本接口,允许您设计一些强大而灵活的抽象。创建一个名为 MiInterfaceHierarchy 的新控制台应用项目。这是另一个接口集合,对各种渲染和形状抽象进行建模。注意,IShape接口同时扩展了IDrawableIPrintable

//IDrawable.cs
namespace MiInterfaceHierarchy
{
  // Multiple inheritance for interface types is A-okay.
  interface IDrawable
  {
    void Draw();
  }
}

//IPrintable.cs
namespace MiInterfaceHierarchy
{
  interface IPrintable
  {
    void Print();
    void Draw(); // <-- Note possible name clash here!
  }
}

//IShape.cs
namespace MiInterfaceHierarchy
{
  // Multiple interface inheritance. OK!
  interface IShape : IDrawable, IPrintable
  {
    int GetNumberOfSides();
  }
}

图 8-6 显示了当前的接口层次。

img/340876_10_En_8_Fig6_HTML.jpg

图 8-6。

与类不同,接口可以扩展多种接口类型

此时,百万美元的问题是“如果你有一个支持IShape的类,需要实现多少个方法?”答案是:视情况而定。如果你想提供一个简单的Draw()方法的实现,你只需要提供三个成员,如下面的Rectangle类型所示:

using System;

namespace MiInterfaceHierarchy
{
  class Rectangle : IShape
  {
    public int GetNumberOfSides() => 4;
    public void Draw() => Console.WriteLine("Drawing...");
    public void Print() => Console.WriteLine("Printing...");
  }
}

如果您希望每个Draw()方法都有特定的实现(在这种情况下最有意义),您可以使用显式接口实现来解决名称冲突,如下面的Square类型所示:

namespace MiInterfaceHierarchy
{
  class Square : IShape
  {
    // Using explicit implementation to handle member name clash.
    void IPrintable.Draw()
    {
      // Draw to printer ...
    }
    void IDrawable.Draw()
    {
      // Draw to screen ...
    }
    public void Print()
    {
      // Print ...
    }

    public int GetNumberOfSides() => 4;
  }
}

理想情况下,此时您会对使用 C# 语法定义和实现自定义接口的过程感到更加舒适。老实说,基于接口的编程可能需要一段时间才能适应,所以如果你实际上仍然有点挠头,这是完全正常的反应。

但是,请注意,接口是。NET 核心框架。不管你开发的应用是什么类型(基于网络的,桌面图形用户接口,数据访问库,等等)。),使用接口将是这个过程的一部分。总结一下到目前为止的情况,记住接口在以下情况下非常有用:

  • 您有一个单一的层次结构,其中只有派生类型的子集支持一个公共行为。

  • 您需要对一个常见的行为进行建模,这个行为存在于多个层次结构中,除了System.Object之外没有共同的父类。

既然您已经深入研究了构建和实现自定义接口的细节,本章的剩余部分将研究。NET 核心基本类库。正如您将看到,您可以实现标准。NET 核心接口,以确保它们无缝集成到框架中。

IEnumerable 和 IEnumerator 接口

开始检查实现现有。NET 核心接口,我们先来看看IEnumerableIEnumerator的作用。回想一下,C# 支持一个名为foreach的关键字,它允许你迭代任何数组类型的内容。

// Iterate over an array of items.
int[] myArrayOfInts = {10, 20, 30, 40};

foreach(int i in myArrayOfInts)
{
  Console.WriteLine(i);
}

虽然看起来只有数组类型可以使用这个构造,但事实是任何支持名为GetEnumerator()的方法的类型都可以被foreach构造求值。举例来说,首先创建一个名为 CustomEnumerator 的新控制台应用项目。接下来,将第七章的 SimpleException 示例中定义的Car.csRadio.cs文件复制到新项目中。确保将类的名称空间更新为CustomEnumerator

现在,插入一个名为Garage的新类,它在一个System.Array中存储一组Car对象。

using System.Collections;

namespace CustomEnumerator
{
  // Garage contains a set of Car objects.
  public class Garage
  {
    private Car[] carArray = new Car[4];

    // Fill with some Car objects upon startup.
    public Garage()
    {
      carArray[0] = new Car("Rusty", 30);
      carArray[1] = new Car("Clunker", 55);
      carArray[2] = new Car("Zippy", 30);
      carArray[3] = new Car("Fred", 30);
    }
  }
}

理想情况下,使用foreach构造遍历Garage对象的子项会很方便,就像数据值数组一样。

using System;
using CustomEnumerator;

// This seems reasonable ...
Console.WriteLine("***** Fun with IEnumerable / IEnumerator *****\n");
Garage carLot = new Garage();

// Hand over each car in the collection?
foreach (Car c in carLot)
{
  Console.WriteLine("{0} is going {1} MPH",
    c.PetName, c.CurrentSpeed);
}
Console.ReadLine();

遗憾的是,编译器通知您,Garage类没有实现名为GetEnumerator()的方法。这个方法由隐藏在System.Collections名称空间中的IEnumerable接口形式化。

Note

在第十章中,你将学习泛型的角色和System.Collections.Generic名称空间。正如您将看到的,这个名称空间包含了IEnumerable / IEnumerator的通用版本,提供了一种更加类型安全的方式来迭代条目。

支持这种行为的类或结构宣称它们可以向调用者公开所包含的项目(在本例中,是关键字foreach本身)。这个标准接口的定义如下:

// This interface informs the caller
// that the object's items can be enumerated.
public interface IEnumerable
{
   IEnumerator GetEnumerator();
}

如您所见,GetEnumerator()方法返回了对另一个名为System.Collections.IEnumerator的接口的引用。该接口提供了允许调用者遍历兼容IEnumerable的容器所包含的内部对象的基础设施。

// This interface allows the caller to
// obtain a container's items.
public interface IEnumerator
{
   bool MoveNext ();  // Advance the internal position of the cursor.
   object Current { get;}  // Get the current item (read-only property).
   void Reset (); // Reset the cursor before the first member.
}

如果您想更新Garage类型来支持这些接口,您可以走很长的路,手动实现每个方法。虽然你当然可以自由地提供定制版本的GetEnumerator()MoveNext()CurrentReset(),但是有一个更简单的方法。由于System.Array类型(以及许多其他集合类)已经实现了IEnumerableIEnumerator,您可以简单地将请求委托给System.Array,如下所示(注意,您需要将System.Collections名称空间导入到您的代码文件中):

using System.Collections;
...
public class Garage : IEnumerable
{
  // System.Array already implements IEnumerator!
  private Car[] carArray = new Car[4];

  public Garage()
  {
    carArray[0] = new Car("FeeFee", 200);
    carArray[1] = new Car("Clunker", 90);
    carArray[2] = new Car("Zippy", 30);
    carArray[3] = new Car("Fred", 30);
  }

  // Return the array object's IEnumerator.
  public IEnumerator GetEnumerator()
    => carArray.GetEnumerator();
}

在您更新了您的Garage类型之后,您可以在 C# foreach构造中安全地使用该类型。此外,鉴于GetEnumerator()方法已经被公开定义,对象用户也可以与IEnumerator类型交互。

// Manually work with IEnumerator.
IEnumerator carEnumerator = carLot.GetEnumerator();
carEnumerator.MoveNext();
Car myCar = (Car)i.Current;
Console.WriteLine("{0} is going {1} MPH", myCar.PetName, myCar.CurrentSpeed);

然而,如果您喜欢在对象级隐藏IEnumerable的功能,只需使用显式接口实现。

// Return the array object's IEnumerator.
IEnumerator IEnumerable.GetEnumerator()
  => return carArray.GetEnumerator();

通过这样做,偶然的对象用户将不会发现GarageGetEnumerator()方法,而foreach构造将在必要时在后台获得接口。

用 yield 关键字构建迭代器方法

有一种替代方法可以通过迭代器构建与foreach循环一起工作的类型。简单地说,迭代器是一个成员,它指定了容器的内部项在被foreach处理时应该如何返回。举例来说,创建一个名为 CustomEnumeratorWithYield 的新控制台应用项目,并插入上一个示例中的CarRadioGarage类型(同样,将您的名称空间定义重命名为当前项目)。现在,对当前的Garage型进行如下改装:

public class Garage : IEnumerable
{
...
  // Iterator method.

  public IEnumerator GetEnumerator()
  {
    foreach (Car c in carArray)
    {
      yield return c;
    }
  }
}

注意,GetEnumerator()的这个实现使用内部foreach逻辑遍历子项,并使用yield return语法将每个Car返回给调用者。yield关键字用于指定返回给调用者的foreach结构的值。当到达yield return语句时,存储容器中的当前位置,下次调用迭代器时从这个位置重新开始执行。

迭代器方法不需要使用foreach关键字来返回其内容。也可以将这个迭代器方法定义如下:

public IEnumerator GetEnumerator()
{
   yield return carArray[0];
   yield return carArray[1];
   yield return carArray[2];
   yield return carArray[3];
}

在这个实现中,注意到GetEnumerator()方法在每次传递时都显式地向调用者返回一个新值。在这个例子中这样做没有什么意义,因为如果您要向carArray成员变量添加更多的对象,那么您的GetEnumerator()方法现在将会不同步。然而,当您想从一个方法返回本地数据以便用foreach语法处理时,这个语法会很有用。

具有本地功能的保护子句(新 7.0)

在第一次迭代项目(或访问任何元素)之前,不会执行GetEnumerator()方法中的任何代码。这意味着如果在yield语句之前有一个异常,它不会在方法第一次被调用时抛出,而只会在第一个MoveNext()被调用时抛出。

为了测试这一点,将GetEnumerator方法更新为:

public IEnumerator GetEnumerator()
{
  //This will not get thrown until MoveNext() is called
  throw new Exception("This won't get called");
  foreach (Car c in carArray)
  {
    yield return c;
  }
}

如果你像这样调用这个函数并且不做任何其他事情,那么这个异常永远不会被抛出:

using System.Collections;
...
Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
IEnumerator carEnumerator = carLot.GetEnumerator();

Console.ReadLine();

直到调用MoveNext()代码才会执行,并抛出异常。根据您的程序的需要,这可能非常好。但也可能不会。您的GetEnumerator方法可能有一个保护子句,它需要在方法第一次被调用时执行。例如,假设列表是从数据库中收集的。您可能想要检查数据库连接是否可以在方法被调用时打开,而不是在列表被迭代时打开。或者您可能想要检查Iterator方法的输入参数(接下来将介绍)的有效性。

从第四章回忆 C# 7 局部函数特性;局部函数是其他函数内部的私有函数。通过将yield return移动到从方法主体返回的局部函数中,顶级语句中的代码(在局部函数返回之前)会立即执行。调用MoveNext()时执行本地函数。

将方法更新为:

public IEnumerator GetEnumerator()
{
  //This will get thrown immediately
  throw new Exception("This will get called");

  return ActualImplementation();

  //this is the local function and the actual IEnumerator implementation
  IEnumerator ActualImplementation()
  {
    foreach (Car c in carArray)
    {
      yield return c;
    }
  }
}

通过将调用代码更新为以下代码来测试这一点:

Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
try
{
  //Error at this time
  var carEnumerator = carLot.GetEnumerator();
}
catch (Exception e)
{
  Console.WriteLine($"Exception occurred on GetEnumerator");
}
Console.ReadLine();

随着对GetEnumerator()方法的更新,异常被立即抛出,而不是在调用MoveNext()时抛出。

构建命名迭代器

有趣的是,yield关键字在技术上可以用在任何方法中,不管它的名字是什么。这些方法(技术上称为命名迭代器)的独特之处还在于它们可以接受任意数量的参数。当构建一个命名迭代器时,要注意该方法将返回IEnumerable接口,而不是预期的IEnumerator兼容类型。举例来说,您可以将下面的方法添加到Garage类型中(使用局部函数来封装迭代功能):

public IEnumerable GetTheCars(bool returnReversed)
{
  //do some error checking here
  return ActualImplementation();

  IEnumerable ActualImplementation()
  {
    // Return the items in reverse.
    if (returnReversed)
    {
      for (int i = carArray.Length; i != 0; i--)
      {
        yield return carArray[i - 1];
      }
    }
    else
    {
      // Return the items as placed in the array.
      foreach (Car c in carArray)
      {
        yield return c;
      }
    }
  }
}

注意,如果传入参数的值为true,新方法允许调用者以顺序和逆序获取子项。现在,您可以与您的新方法进行如下交互(确保注释掉GetEnumerator()方法中的throw new异常语句):

Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();

// Get items using GetEnumerator().
foreach (Car c in carLot)
{
  Console.WriteLine("{0} is going {1} MPH",
    c.PetName, c.CurrentSpeed);
}

Console.WriteLine();

// Get items (in reverse!) using named iterator.
foreach (Car c in carLot.GetTheCars(true))
{
  Console.WriteLine("{0} is going {1} MPH",
    c.PetName, c.CurrentSpeed);
}
Console.ReadLine();

您可能同意,命名迭代器是有用的构造,因为单个定制容器可以定义多种方式来请求返回的集合。

因此,为了总结构建可枚举对象的内容,请记住,要让您的自定义类型使用 C# foreach关键字,容器必须定义一个名为GetEnumerator()的方法,该方法已经由IEnumerable接口类型形式化。此方法的实现通常通过简单地将它委托给持有子对象的内部成员来实现;然而,也可以使用yield return语法来提供多个“命名迭代器”方法。

可克隆的接口

您可能还记得第六章中的,System.Object定义了一个名为MemberwiseClone()的方法。这个方法用来获得当前对象的一个浅拷贝。对象用户不直接调用此方法,因为它是受保护的。然而,在克隆过程中,一个给定的对象可能会调用这个方法本身。举例来说,创建一个名为CloneablePoint的新控制台应用项目,它定义了一个名为Point的类。

using System;

namespace CloneablePoint
{
  // A class named Point.
  public class Point
  {
    public int X {get; set;}
    public int Y {get; set;}

    public Point(int xPos, int yPos) { X = xPos; Y = yPos;}
    public Point(){}

    // Override Object.ToString().
    public override string ToString() => $"X = {X}; Y = {Y}";
  }
}

给定你已经知道的引用类型和值类型(见第四章,你知道如果你把一个引用变量赋给另一个,你有两个引用指向内存中的同一个对象。因此,下面的赋值操作导致对堆上同一个Point对象的两次引用;使用任一引用的修改都会影响堆上的同一对象:

Console.WriteLine("***** Fun with Object Cloning *****\n");
// Two references to same object!
Point p1 = new Point(50, 50);
Point p2 = p1;
p2.X = 0;
Console.WriteLine(p1);
Console.WriteLine(p2);
Console.ReadLine();

当您想让您的自定义类型能够向调用者返回其自身的相同副本时,您可以实现标准的ICloneable接口。如本章开头所示,该类型定义了一个名为Clone()的方法。

public interface ICloneable
{
  object Clone();
}

显然,Clone()方法的实现因类而异。但是,基本功能是相同的:将成员变量的值复制到一个相同类型的新对象实例中,并将其返回给用户。为了说明这一点,请考虑下面对Point类的更新:

// The Point now supports "clone-ability."
public class Point : ICloneable
{
  public int X { get; set; }
  public int Y { get; set; }

  public Point(int xPos, int yPos) { X = xPos; Y = yPos; }
  public Point() { }

  // Override Object.ToString().
  public override string ToString() => $"X = {X}; Y = {Y}";

  // Return a copy of the current object.
  public object Clone() => new Point(this.X, this.Y);
}

这样,您可以创建Point类型的精确独立副本,如以下代码所示:

Console.WriteLine("***** Fun with Object Cloning *****\n");
...
// Notice Clone() returns a plain object type.
// You must perform an explicit cast to obtain the derived type.
Point p3 = new Point(100, 100);
Point p4 = (Point)p3.Clone();

// Change p4.X (which will not change p3.X).
p4.X = 0;

// Print each object.
Console.WriteLine(p3);
Console.WriteLine(p4);
Console.ReadLine();

虽然当前的Point实现符合要求,但是您可以稍微简化一下。因为Point类型不包含任何内部引用类型变量,您可以将Clone()方法的实现简化如下:

// Copy each field of the Point member by member.
public object Clone() => this.MemberwiseClone();

但是,请注意,如果Point包含任何引用类型成员变量,MemberwiseClone()将复制对这些对象的引用(即,浅层复制)。如果你想支持真正的深度拷贝,你需要在克隆过程中创建一个引用类型变量的新实例。接下来我们来看一个例子。

一个更复杂的克隆例子

现在假设Point类包含一个PointDescription类型的引用类型成员变量。这个类维护一个点的友好名称以及一个标识号,表示为一个System.Guid(一个全局唯一标识符【GUID】是一个统计上唯一的 128 位数字)。下面是实现过程:

using System;

namespace CloneablePoint
{
  // This class describes a point.
  public class PointDescription
  {
    public string PetName {get; set;}
    public Guid PointID {get; set;}

    public PointDescription()
    {
      PetName = "No-name";
      PointID = Guid.NewGuid();
    }
  }
}

Point类本身的初始更新包括修改ToString()来说明这些新的状态数据,以及定义和创建PointDescription引用类型。为了让外界给Point起一个昵称,还需要更新传递给重载构造函数的参数。

public class Point : ICloneable
{
  public int X { get; set; }
  public int Y { get; set; }
  public PointDescription desc = new PointDescription();

  public Point(int xPos, int yPos, string petName)
  {
    X = xPos; Y = yPos;
    desc.PetName = petName;
  }
  public Point(int xPos, int yPos)
  {
    X = xPos; Y = yPos;
  }
  public Point() { }

  // Override Object.ToString().
  public override string ToString()
     => $"X = {X}; Y = {Y}; Name = {desc.PetName};\nID = {desc.PointID}\n";

  // Return a copy of the current object.
  public object Clone() => this.MemberwiseClone();
}

注意,您还没有更新您的Clone()方法。因此,当对象用户请求使用当前实现进行克隆时,将获得浅层(逐个成员)拷贝。举例来说,假设您已经更新了调用代码,如下所示:

Console.WriteLine("***** Fun with Object Cloning *****\n");
...
Console.WriteLine("Cloned p3 and stored new Point in p4");
Point p3 = new Point(100, 100, "Jane");
Point p4 = (Point)p3.Clone();

Console.WriteLine("Before modification:");
Console.WriteLine("p3: {0}", p3);
Console.WriteLine("p4: {0}", p4);
p4.desc.PetName = "My new Point";
p4.X = 9;

Console.WriteLine("\nChanged p4.desc.petName and p4.X");
Console.WriteLine("After modification:");
Console.WriteLine("p3: {0}", p3);
Console.WriteLine("p4: {0}", p4);
Console.ReadLine();

请注意,在下面的输出中,虽然值类型确实已经更改,但是内部引用类型保持相同的值,因为它们“指向”内存中相同的对象(具体来说,请注意这两个对象的昵称现在都是“My new Point”)。

***** Fun with Object Cloning *****
Cloned p3 and stored new Point in p4
Before modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

p4: X = 100; Y = 100; Name = Jane;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

Changed p4.desc.petName  and p4.X
After modification:
p3: X = 100; Y = 100; Name = My new Point;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

p4: X = 9; Y = 100; Name = My new Point;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

为了让您的Clone()方法对内部引用类型进行完整的深度复制,您需要配置由MemberwiseClone()返回的对象,以说明当前点的名称(System.Guid类型实际上是一个结构,因此数字数据确实被复制了)。下面是一个可能的实现:

// Now we need to adjust for the PointDescription member.
public object Clone()
{
  // First get a shallow copy.
  Point newPoint = (Point)this.MemberwiseClone();

  // Then fill in the gaps.
  PointDescription currentDesc = new PointDescription();
  currentDesc.PetName = this.desc.PetName;
  newPoint.desc = currentDesc;
  return newPoint;
}

如果您再次运行应用并查看输出(如下所示),您会看到从Clone()返回的Point确实复制了它的内部引用类型成员变量(注意宠物名称现在对于p3p4都是惟一的)。

***** Fun with Object Cloning *****
Cloned p3 and stored new Point in p4
Before modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 51f64f25-4b0e-47ac-ba35-37d263496406

p4: X = 100; Y = 100; Name = Jane;
ID = 0d3776b3-b159-490d-b022-7f3f60788e8a

Changed p4.desc.petName  and p4.X
After modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 51f64f25-4b0e-47ac-ba35-37d263496406

p4: X = 9; Y = 100; Name = My new Point;
ID = 0d3776b3-b159-490d-b022-7f3f60788e8a

总结一下克隆过程,如果你有一个只包含值类型的类或结构,使用MemberwiseClone()实现你的Clone()方法。但是,如果您有一个维护其他引用类型的自定义类型,您可能希望创建一个新的对象,该对象考虑每个引用类型成员变量以获得“深层副本”

IComparable 接口

System.IComparable接口指定了一种行为,允许基于某个指定的键对对象进行排序。以下是正式的定义:

// This interface allows an object to specify its
// relationship between other like objects.
public interface IComparable
{
  int CompareTo(object o);
}

Note

这个接口的通用版本(IComparable<T>)提供了一种更加类型安全的方式来处理对象之间的比较。你将在第十章中研究泛型。

创建一个名为 ComparableCar 的新控制台应用项目,从第七章的 SimpleException 示例中复制CarRadio类,并将每个文件的名称空间重命名为ComparableCar。通过添加一个新属性来表示每辆汽车的唯一 ID 和一个修改后的构造函数,从而更新Car类:

using System;
using System.Collections;

namespace ComparableCar
{
  public class Car
  {
...
    public int CarID {get; set;}
    public Car(string name, int currSp, int id)
    {
      CurrentSpeed = currSp;
      PetName = name;
      CarID = id;
    }
...
  }
}

现在假设您有一个如下的Car对象数组:

using System;
using ComparableCar;
Console.WriteLine("***** Fun with Object Sorting *****\n");

// Make an array of Car objects.
Car[] myAutos = new Car[5];
myAutos[0] = new Car("Rusty", 80, 1);
myAutos[1] = new Car("Mary", 40, 234);
myAutos[2] = new Car("Viper", 40, 34);
myAutos[3] = new Car("Mel", 40, 4);
myAutos[4] = new Car("Chucky", 40, 5);

Console.ReadLine();

System.Array类定义了一个名为Sort()的静态方法。当您对一组内部类型(intshortstring等)调用此方法时。),您可以按数字/字母顺序对数组中的项目进行排序,因为这些固有的数据类型实现了IComparable。然而,如果您将一个Car类型的数组发送到Sort()方法中,情况会怎样呢?

// Sort my cars? Not yet!
Array.Sort(myAutos);

如果您运行这个测试,您会得到一个运行时异常,因为Car类不支持必要的接口。当您构建定制类型时,您可以实现IComparable来允许您的类型的数组被排序。当你充实了CompareTo()的细节后,将由你来决定订购操作的基线是什么。对于Car型,内部的CarID似乎是合乎逻辑的候选。

// The iteration of the Car can be ordered
// based on the CarID.
public class Car : IComparable
{
...
  // IComparable implementation.
  int IComparable.CompareTo(object obj)
  {
    if (obj is Car temp)
    {
      if (this.CarID > temp.CarID)
      {
        return 1;
      }
      if (this.CarID < temp.CarID)
      {
        return -1;
      }
      return 0;
    }
    throw new ArgumentException("Parameter is not a Car!");
  }
}

如您所见,CompareTo()背后的逻辑是根据特定的数据点,针对当前实例测试传入的对象。CompareTo()的返回值用于发现该类型是小于、大于还是等于与之比较的对象(见表 8-1 )。

表 8-1。

比较返回值

|

返回值

|

描述

| | --- | --- | | 任何小于零的数字 | 在排序顺序中,此实例位于指定对象之前。 | | 零 | 此实例等于指定的对象。 | | 任何大于零的数字 | 在排序顺序中,此实例位于指定对象之后。 |

假设 C# int数据类型(这只是System.Int32的简写)实现了IComparable,那么您可以简化前面的CompareTo()实现。您可以如下实现CarCompareTo():

int IComparable.CompareTo(object obj)
{
  if (obj is Car temp)
  {
    return this.CarID.CompareTo(temp.CarID);
  }
  throw new ArgumentException("Parameter is not a Car!");
}

在这两种情况下,为了让您的Car类型理解如何将自己与相似的对象进行比较,您可以编写以下用户代码:

// Exercise the IComparable interface.
// Make an array of Car objects.
...
// Display current array.
Console.WriteLine("Here is the unordered set of cars:");
foreach(Car c in myAutos)
{
  Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}

// Now, sort them using IComparable!
Array.Sort(myAutos);
Console.WriteLine();

// Display sorted array.
Console.WriteLine("Here is the ordered set of cars:");
foreach(Car c in myAutos)
{
  Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
Console.ReadLine();

下面是前面代码清单的输出:

***** Fun with Object Sorting *****
Here is the unordered set of cars:
1 Rusty
234 Mary
34 Viper
4 Mel
5 Chucky

Here is the ordered set of cars:
1 Rusty
4 Mel
5 Chucky
34 Viper
234 Mary

使用 IComparer 指定多个排序顺序

在这个版本的Car类型中,您使用汽车的 ID 作为排序顺序的基础。另一种设计可能使用汽车的昵称作为排序算法的基础(按字母顺序列出汽车)。现在,如果你想构建一个既可以通过 ID 排序又可以通过昵称排序的Car会怎么样呢?如果这是您感兴趣的行为类型,您需要与另一个名为IComparer的标准接口交朋友,该接口在System.Collections名称空间中定义如下:

// A general way to compare two objects.
interface IComparer
{
  int Compare(object o1, object o2);
}

Note

这个接口的通用版本(IComparer<T>)提供了一种更加类型安全的方式来处理对象之间的比较。你将在第十章中研究泛型。

IComparable接口不同,IComparer通常是而不是在您试图排序的类型(即Car)上实现的。相反,您可以在任意数量的助手类上实现这个接口,每个助手类对应一个排序顺序(昵称、汽车 ID 等)。).目前,Car型已经知道如何根据内部汽车 ID 与其他汽车进行比较。因此,允许对象用户按昵称对一组Car对象进行排序将需要一个额外的实现IComparer的助手类。下面是代码(确保在代码文件中导入System.Collections名称空间):

using System;
using System.Collections;

namespace ComparableCar
{
  // This helper class is used to sort an array of Cars by pet name.
  public class PetNameComparer : IComparer
  {
    // Test the pet name of each object.
    int IComparer.Compare(object o1, object o2)
    {
      if (o1 is Car t1 && o2 is Car t2)
      {
        return string.Compare(t1.PetName, t2.PetName,
          StringComparison.OrdinalIgnoreCase);
      }
      else
      {
        throw new ArgumentException("Parameter is not a Car!");
      }
    }
  }
}

对象用户代码可以使用这个助手类。System.Array有几个重载的Sort()方法,其中一个恰好接受一个实现IComparer的对象。

...
// Now sort by pet name.
Array.Sort(myAutos, new PetNameComparer());

// Dump sorted array.
Console.WriteLine("Ordering by pet name:");
foreach(Car c in myAutos)
{
  Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
...

自定义属性和自定义排序类型

值得指出的是,当按照特定的数据点对Car类型进行排序时,可以使用定制的静态属性来帮助对象用户。假设Car类已经添加了一个名为SortByPetName的静态只读属性,该属性返回实现IComparer接口的对象实例(在本例中为PetNameComparer);一定要导入System.Collections

// We now support a custom property to return
// the correct IComparer interface.
public class Car : IComparable
{
...
  // Property to return the PetNameComparer.
  public static IComparer SortByPetName
    => (IComparer)new PetNameComparer();}

对象用户代码现在可以使用强关联属性按昵称排序,而不是“必须知道”才能使用独立的PetNameComparer类类型。

// Sorting by pet name made a bit cleaner.
Array.Sort(myAutos, Car.SortByPetName);

理想情况下,在这一点上,你不仅理解如何定义和实现你自己的接口,而且理解它们的有用性。可以肯定的是,每个专业都有接口。NET 核心命名空间,在本书的剩余部分,您将继续使用各种标准接口。

摘要

一个接口可以被定义为一个命名的抽象成员的集合。通常认为接口是一个给定类型可以支持的行为。当两个或多个类实现同一个接口时,即使类型是在唯一的类层次结构中定义的,也可以用相同的方式对待每种类型(基于接口的多态性)。

C# 提供了关键字interface来允许你定义一个新的接口。如您所见,使用逗号分隔的列表,一个类型可以支持任意多的接口。此外,允许构建从多个基本接口派生的接口。

除了构建自定义接口之外。NET 核心库定义了几个标准(即框架提供的)接口。正如您所看到的,您可以自由地构建实现这些预定义接口的自定义类型,以获得一些理想的特征,如克隆、排序和枚举。*

九、了解对象生存期

至此,您已经学习了很多关于如何使用 C# 构建自定义类类型的知识。现在您将看到运行时如何通过垃圾收集 ( GC )来管理分配的类实例(又名对象)。C# 程序员从不直接从内存中释放托管对象(回想一下,C# 语言中没有delete关键字)。更确切地说。NET 核心对象被分配到一个叫做托管堆的内存区域,在那里它们将被垃圾收集器“在将来的某个时候”自动销毁

在您查看了收集过程的核心细节之后,您将学习如何使用System.GC类类型以编程方式与垃圾收集器进行交互(对于您的大多数项目来说,这通常不是必需的)。接下来,您将研究如何使用虚拟的System.Object.Finalize()方法和IDisposable接口来构建以可预测和及时的方式释放内部非托管资源的类。

您还将深入研究中介绍的垃圾收集器的一些功能。NET 4.0,包括后台垃圾收集和使用泛型System.Lazy<>类的惰性实例化。当你完成这一章的时候,你会对如何做有一个坚实的理解。NET 核心对象由运行库管理。

类、对象和引用

为了构建本章所涉及的主题,进一步澄清类、对象和引用变量之间的区别是很重要的。回想一下,类只不过是描述这种类型的实例在内存中的外观和感觉的蓝图。当然,类是在一个代码文件中定义的(按照惯例,在 C# 中使用一个*.cs扩展名)。考虑在名为 SimpleGC 的新 C# 控制台应用项目中定义的以下简单的Car类:

namespace SimpleGC
{
  // Car.cs
  public class Car
  {
    public int CurrentSpeed {get; set;}
    public string PetName {get; set;}

    public Car(){}
    public Car(string name, int speed)
    {
      PetName = name;
      CurrentSpeed = speed;
    }
    public override string ToString()
      => $"{PetName} is going {CurrentSpeed} MPH";
    }
  }
}

定义了一个类之后,你可以使用 C# new关键字分配任意数量的对象。但是,请理解,new关键字返回的是对堆上对象的引用,而不是实际的对象。如果将引用变量声明为方法范围内的局部变量,它将存储在堆栈中,供应用进一步使用。当您想要调用对象上的成员时,将 C# 点运算符应用于存储的引用,如下所示:

using System;
using SimpleGC;
Console.WriteLine("***** GC Basics *****");

// Create a new Car object on the managed heap.
// We are returned a reference to the object
// ("refToMyCar").
Car refToMyCar = new Car("Zippy", 50);

// The C# dot operator (.) is used to invoke members
// on the object using our reference variable.
Console.WriteLine(refToMyCar.ToString());
Console.ReadLine();

图 9-1 说明了类、对象和引用关系。

img/340876_10_En_9_Fig1_HTML.jpg

图 9-1。

对托管堆上对象的引用

Note

回想一下第四章,结构是值类型,它们总是被直接分配到堆栈上,从不放在。NET 核心托管堆。只有在创建类的实例时,才会发生堆分配。

对象生存期的基础

当您构建 C# 应用时,假设。NET Core runtime environment 会处理托管堆,无需您的直接干预。事实上……NET 核心内存管理很简单。

Rule

使用new关键字将一个类实例分配到托管堆上,然后忘掉它。

一旦实例化,垃圾收集器将销毁不再需要的对象。当然,下一个明显的问题是“垃圾收集器如何确定何时不再需要某个对象?”简短的(即不完整的)答案是,垃圾收集器仅在对象被你的代码库的任何部分不可达时才从堆中移除该对象。假设您的Program类中有一个方法,它分配一个本地Car对象,如下所示:

static void MakeACar()
{
  // If myCar is the only reference to the Car object, it *may* be destroyed when this method returns.
  Car myCar = new Car();
}

请注意,这个Car引用(myCar)是直接在MakeACar()方法中创建的,没有被传递到定义范围之外(通过返回值或ref / out参数)。因此,一旦这个方法调用完成,myCar引用不再可达,关联的Car对象现在是垃圾收集的候选对象。但是要知道,你不能保证这个对象会在MakeACar()完成后立即从内存中被回收。在这一点上,您可以假设的是,当运行时执行下一次垃圾收集时,可以安全地销毁myCar对象。

您肯定会发现,在垃圾收集环境中编程极大地简化了您的应用开发。与之形成鲜明对比的是,C++程序员痛苦地意识到,如果他们不能手动删除堆分配的对象,内存泄漏就不远了。事实上,跟踪内存泄漏是非托管环境中编程最耗时(也是最乏味)的方面之一。通过允许垃圾收集器负责销毁对象,内存管理的负担已经从您的肩上卸下,并放在运行时的肩上。

新 CIL

当 C# 编译器遇到new关键字时,它向方法实现中发出一个 CIL newobj指令。如果您编译当前的示例代码,并使用ildasm.exe研究产生的程序集,您会在MakeACar()方法中找到以下 CIL 语句:

.method assembly hidebysig static
          void  '<<Main>$>g__MakeACar|0_0'() cil managed
{
    // Code size       8 (0x8)
    .maxstack  1
    .locals init (class SimpleGC.Car V_0)
    IL_0000: nop
    IL_0001: newobj     instance void SimpleGC.Car::.ctor()
    IL_0006: stloc.0
    IL_0007: ret
  } // end of method '<Program>$'::'<<Main>$>g__MakeACar|0_0'

在检查确定何时从托管堆中移除对象的确切规则之前,让我们更详细地检查一下 CIL newobj指令的作用。首先,要理解托管堆不仅仅是运行时访问的随机内存块。那个。NET Core 垃圾收集器是一个相当整洁的堆管家,因为它会压缩空的内存块(必要时)以达到优化的目的。

为了有助于这项工作,托管堆维护一个指针(通常称为下一个对象指针新对象指针),该指针准确地标识下一个对象将位于何处。也就是说,newobj指令告诉运行时执行以下核心操作:

  1. 计算要分配的对象所需的内存总量(包括数据成员和基类所需的内存)。

  2. 检查托管堆,确保确实有足够的空间来承载要分配的对象。如果有,则调用指定的构造函数,最终向调用者返回内存中新对象的引用,该对象的地址恰好与下一个对象指针的最后位置相同。

  3. 最后,在将引用返回给调用方之前,将下一个对象指针向前移动,指向托管堆上的下一个可用槽。

图 9-2 说明了基本过程。

img/340876_10_En_9_Fig2_HTML.jpg

图 9-2。

将对象分配到托管堆的详细信息

当应用忙于分配对象时,托管堆上的空间最终可能会变满。当处理newobj指令时,如果运行时确定托管堆没有足够的内存来分配所请求的类型,它将执行垃圾收集以尝试释放内存。因此,垃圾收集的下一个规则也很简单。

Rule

如果托管堆没有足够的内存来分配请求的对象,将发生垃圾回收。

然而,这种垃圾收集是如何发生的,取决于你的应用使用哪种垃圾收集。在这一章的后面,你会看到不同之处。

将对象引用设置为空

C/C++程序员经常将指针变量设置为null,以确保它们不再引用非托管内存。鉴于此,您可能想知道在 C# 下将对象引用分配给null的最终结果是什么。例如,假设MakeACar()子程序已经更新如下:

static void MakeACar()
{
  Car myCar = new Car();
  myCar = null;
}

当您将对象引用分配给null时,编译器会生成 CIL 代码,确保引用(在本例中为myCar)不再指向任何对象。如果您再次使用ildasm.exe来查看修改后的MakeACar()的 CIL 代码,您会发现ldnull操作码(它将一个null值推送到虚拟执行堆栈上)后跟一个stloc.0操作码(它将null引用设置到变量上)。

  .method assembly hidebysig static
          void  '<<Main>$>g__MakeACar|0_0'() cil managed
  {
    // Code size       10 (0xa)
    .maxstack  1
    .locals init (class SimpleGC.Car V_0)
    IL_0000: nop
    IL_0001: newobj     instance void SimpleGC.Car::.ctor()
    IL_0006: stloc.0
    IL_0007: ldnull
    IL_0008: stloc.0
    IL_0009: ret
  } // end of method '<Program>$'::'<<Main>$>g__MakeACar|0_0'

然而,你必须明白的是,给null分配一个引用并不会以任何方式迫使垃圾收集器在那个时刻启动,并从堆中移除对象。您唯一完成的事情是显式地剪切引用和它以前指向的对象之间的连接。鉴于这一点,在 C# 下设置对null的引用远不如在其他基于 C 的语言中这样做重要;但是,这样做肯定不会造成什么伤害。

确定对象是否是活动的

现在,回到垃圾收集器如何确定何时不再需要某个对象的主题。垃圾收集器使用以下信息来确定对象是否是活动的:

  • 堆栈根:编译器和堆栈审核器提供的堆栈变量

  • 垃圾收集句柄:指向可从代码或运行时引用的托管对象的句柄

  • 静态数据:应用领域中可以引用其他对象的静态对象

在垃圾回收过程中,运行时将调查托管堆上的对象,以确定应用是否仍然可以访问这些对象。为此,运行时将构建一个对象图,它表示堆上每个可到达的对象。在第二十章讨论对象序列化的时候,对象图会有一些详细的解释。现在,只要理解对象图用于记录所有可到达的对象。同样,请注意垃圾收集器不会两次绘制同一个对象,从而避免 COM 编程中令人讨厌的循环引用计数。

假设托管堆包含一组名为 A、B、C、D、E、F 和 g 的对象,在垃圾收集期间,会检查这些对象(以及它们可能包含的任何内部对象引用)。在构建了图之后,不可到达的对象(可以假设是对象 C 和 F)被标记为垃圾。图 9-3 为刚刚描述的场景绘制了一个可能的对象图(你可以使用短语取决于需要来阅读方向箭头;比如 E 依赖于 G 和 B,A 不依赖于任何东西等等。).

img/340876_10_En_9_Fig3_HTML.jpg

图 9-3。

构建对象图是为了确定应用根可以访问哪些对象。

在对象被标记为终止后(在这种情况下是 C 和 F,因为它们在对象图中没有被考虑),它们被从内存中清除。此时,堆上的剩余空间被压缩,这又导致运行时修改底层指针集以指向正确的内存位置(这是自动且透明地完成的)。最后但同样重要的是,下一个对象指针被重新调整,指向下一个可用的槽。图 9-4 显示了最终的重新调整。

img/340876_10_En_9_Fig4_HTML.jpg

图 9-4。

干净而紧实的堆

Note

严格地说,垃圾收集器使用两个不同的堆,其中一个专门用于存储大型对象。考虑到重定位大型对象可能带来的性能损失,在收集周期中很少查询这个堆。英寸 NET Core,大型堆可以按需压缩,或者在达到绝对或百分比内存使用的可选硬限制时压缩。

了解对象生成

当运行时试图定位不可访问的对象时,它不会检查托管堆上的每个对象。显然,这样做需要相当长的时间,尤其是在较大的(即真实世界的)应用中。

为了帮助优化这个过程,堆上的每个对象都被分配给一个特定的“代”世代背后的思想很简单:一个对象在堆上存在的时间越长,它就越有可能留在那里。例如,定义桌面应用主窗口的类将会一直在内存中,直到程序终止。相反,最近才放入堆中的对象(比如在方法范围内分配的对象)很可能很快就无法访问。给定这些假设,堆上的每个对象都属于以下代之一的集合:

  • 第 0 代:标识一个新分配的从未被标记为收集的对象(大对象除外,它们最初被放在第 2 代收集中)。大多数对象在第 0 代中被回收用于垃圾收集,并且现在存活到第 1 代。

  • 第 1 代:标识在垃圾收集中幸存的对象。这一代还充当短寿命对象和长寿命对象之间的缓冲。

  • 第 2 代:标识一个在垃圾收集器的多次扫描中幸存下来的对象,或者一个在第 2 代收集中开始的非常大的对象。

Note

第 0 代和第 1 代被称为短暂代。正如在下一节中所解释的,您将看到垃圾收集过程确实以不同的方式对待短暂的世代。

垃圾收集器将首先调查所有第 0 代对象。如果标记和清除(或者更直白地说,清除)这些对象导致了所需的空闲内存量,则任何幸存的对象都被提升到第 1 代。要查看对象的生成如何影响收集过程,请思考图 9-5 ,该图描述了一旦回收了所需的内存,一组幸存的第 0 代对象(A、B 和 E)是如何提升的。

img/340876_10_En_9_Fig5_HTML.jpg

图 9-5。

在垃圾收集中幸存的第 0 代对象被提升到第 1 代

如果已经评估了所有第 0 代对象,但是仍然需要额外的内存,则调查第 1 代对象的可达性并相应地收集。幸存的第 1 代对象随后被提升到第 2 代。如果垃圾收集器仍然需要额外的内存,则评估第 2 代对象。在这一点上,如果第 2 代对象在垃圾收集中幸存下来,它仍然是第 2 代对象,给定了对象代的预定义上限。

底线是,通过给堆上的对象分配一个世代值,较新的对象(如局部变量)将被快速移除,而较旧的对象(如程序的主窗口)不会经常被“打扰”。

当系统物理内存不足时,当托管堆上分配的内存超过可接受的阈值时,或者当应用代码中调用GC.Collect()时,就会触发垃圾收集。

如果这看起来比自己管理内存更好,那么请记住,垃圾收集的过程是有代价的。虽然垃圾收集肯定会受到好的或坏的影响,但垃圾收集的时间和收集的内容通常不受开发人员的控制。当执行垃圾收集时,会占用 CPU 周期,这会影响应用的性能。接下来的部分将研究不同类型的垃圾收集。

短暂的世代和片段

如前所述,第 0 代和第 1 代是短命的,被称为短暂代。这些代被分配在一个被称为短暂段的内存段中。当垃圾收集发生时,由垃圾收集获得的新段成为新的临时段,并且包含超过第 1 代的对象的段成为新的第 2 代段。

短暂段的大小因多种因素而异,例如垃圾收集类型(接下来将介绍)和系统的容量。表 9-1 显示了短暂段的不同大小。

表 9-1。

短暂的段大小

|

垃圾收集类型

|

32 位

|

64 位

| | --- | --- | --- | | 工作站 | 16 兆字节 | 256 兆字节 | | 计算机网络服务器 | 64 兆字节 | 4 GB | | 具有 4 个以上逻辑 CPU 的服务器 | 32 兆字节 | 2 GB | | 具有 8 个以上逻辑 CPU 的服务器 | 16 兆字节 | 1 GB |

垃圾收集类型

运行库提供了两种类型的垃圾收集:

  • 工作站垃圾收集:这是为客户端应用设计的,也是独立应用的默认设置。工作站 GC 可以是后台的(接下来将介绍)或非并发的。

  • 服务器垃圾收集:这是为需要高吞吐量和可伸缩性的服务器应用设计的。服务器 GC 可以是后台或非并发的,就像工作站 GC 一样。

Note

这些名称表示工作站和服务器应用的默认设置,但垃圾收集的方法可通过机器的runtimeconfig.json或系统环境变量进行配置。除非计算机只有一个处理器,否则它将始终使用工作站垃圾收集。

工作站 GC 发生在触发垃圾收集的同一个线程上,并保持与触发时相同的优先级。这可能会导致与应用中其他线程的竞争。

服务器垃圾回收发生在设置为THREAD_PRIORITY_HIGHEST优先级的多个专用线程上(线程在第十五章中介绍)。每个 CPU 都有一个专用堆和专用线程来执行垃圾收集。这可能导致服务器垃圾收集变得非常耗费资源。

后台垃圾收集

从……开始。NET 4.0(并继续在。NET Core),垃圾收集器能够在清理托管堆上的对象时处理线程挂起,使用后台垃圾收集。尽管有其名称,但这并不意味着所有的垃圾收集现在都发生在额外的后台执行线程上。相反,如果后台垃圾回收正在对非 phe pharmal 代中的对象进行,则。NET Core runtime 现在能够使用一个专用的后台线程来收集临时代上的对象。

与此相关的是。NET 4.0 和更高版本的垃圾收集得到了改进,进一步减少了涉及垃圾收集细节的给定线程必须挂起的时间。这些变化的最终结果是,清理第 0 代或第 1 代中未使用的对象的过程得到了优化,可以提高程序的运行时性能(这对需要较小且可预测的 GC 停止时间的实时系统非常重要)。

但是,请理解,这种新的垃圾收集模型的引入对您如何构建自己的。NET 核心应用。实际上,您可以简单地允许垃圾收集器在没有您直接干预的情况下执行它的工作(并且很高兴微软的人正在以透明的方式改进收集过程)。

系统。GC 类型

mscorlib.dll程序集提供了一个名为System.GC的类类型,允许您使用一组静态成员以编程方式与垃圾收集器进行交互。现在,请注意,您很少(如果有的话)需要在代码中直接使用这个类。通常,只有在创建内部使用非托管资源的类时,才会用到System.GC的成员。如果您正在构建一个类,该类使用。NET 核心平台调用协议,或者可能是因为一些非常低级和复杂的 COM 互操作逻辑。表 9-2 提供了一些更有趣的成员的概要(参考。NET Framework SDK 文档以了解完整的详细信息)。

表 9-2。

选择System.GC类型的成员

|

系统。GC 成员

|

描述

| | --- | --- | | AddMemoryPressure() RemoveMemoryPressure() | 允许您指定一个数值来表示调用对象在垃圾收集过程中的“紧急程度”。请注意,这些方法应该依次改变压力,因此,永远不要移除超过您添加的总量的压力。 | | Collect() | 强制 GC 执行垃圾回收。该方法已被重载,以指定要收集的代,以及收集的模式(通过GCCollectionMode枚举)。 | | CollectionCount() | 返回一个数值,表示给定层代被扫描的次数。 | | GetGeneration() | 返回对象当前所属的层代。 | | GetTotalMemory() | 返回托管堆上当前分配的估计内存量(以字节为单位)。一个布尔参数指定调用在返回之前是否应该等待垃圾回收。 | | MaxGeneration | 返回目标系统支持的最大代数。微软旗下。NET 4.0,有三个可能的代:0,1,2。 | | SuppressFinalize() | 设置一个标志,指示指定对象不应调用其Finalize()方法。 | | WaitForPendingFinalizers() | 挂起当前线程,直到所有可终结的对象都已终结。该方法通常在调用GC.Collect()后直接调用。 |

为了说明如何使用System.GC类型来获得各种以垃圾收集为中心的细节,请将 SimpleGC 项目的顶级语句更新为以下内容,它使用了GC的几个成员:

using System;

Console.WriteLine("***** Fun with System.GC *****");

// Print out estimated number of bytes on heap.
Console.WriteLine("Estimated bytes on heap: {0}",
  GC.GetTotalMemory(false));

// MaxGeneration is zero based, so add 1 for display
// purposes.
Console.WriteLine("This OS has {0} object generations.\n",
 (GC.MaxGeneration + 1));

Car refToMyCar = new Car("Zippy", 100);
Console.WriteLine(refToMyCar.ToString());

// Print out generation of refToMyCar object.
Console.WriteLine("Generation of refToMyCar is: {0}",
  GC.GetGeneration(refToMyCar));
Console.ReadLine();

运行此命令后,您应该会看到类似如下的输出:

***** Fun with System.GC *****

Estimated bytes on heap: 75760
This OS has 3 object generations.

Zippy is going 100 MPH
Generation of refToMyCar is: 0

在下一节中,您将探索表 9-2 中的更多方法。

强制垃圾收集

同样,垃圾收集器的全部目的是代表您管理内存。然而,在一些罕见的情况下,使用GC.Collect()以编程方式强制垃圾收集可能是有益的。以下是您可能会考虑与收集流程进行交互的两种常见情况:

  • 您的应用将要进入一个代码块,您不希望被可能的垃圾收集中断。

  • 您的应用刚刚分配完大量的对象,您希望尽快移除尽可能多的已获得的内存。

如果您确定让垃圾收集器检查无法访问的对象是有益的,您可以显式触发垃圾收集,如下所示:

...
// Force a garbage collection and wait for
// each object to be finalized.
GC.Collect();
GC.WaitForPendingFinalizers();
...

当您手动强制垃圾收集时,您应该总是调用GC.WaitForPendingFinalizers()。使用这种方法,您可以放心,在您的程序继续之前,所有的可终结对象(在下一节中描述)都有机会执行任何必要的清理。在引擎盖下,GC.WaitForPendingFinalizers()会在收集过程中挂起调用线程。这是一件好事,因为它确保您的代码不会调用当前正在被销毁的对象上的方法!

还可以向GC.Collect()方法提供一个数值,该数值标识将在其上执行垃圾收集的最老的代。例如,要指示运行时只调查第 0 代对象,您应该编写以下代码:

...
// Only investigate generation 0 objects.
GC.Collect(0);
GC.WaitForPendingFinalizers();
...

同样,Collect()方法可以作为第二个参数传入GCCollectionMode枚举的值,以精确调整运行时应该如何强制垃圾收集。这个enum定义了以下值:

public enum GCCollectionMode
{
  Default,  // Forced is the current default.
  Forced,   // Tells the runtime to collect immediately!
  Optimized // Allows the runtime to determine whether the current time is optimal to reclaim objects.
}

与任何垃圾收集一样,调用GC.Collect()会提升幸存的代。举例来说,假设您的顶级语句已经更新如下:

Console.WriteLine("***** Fun with System.GC *****");

// Print out estimated number of bytes on heap.
Console.WriteLine("Estimated bytes on heap: {0}",
  GC.GetTotalMemory(false));

// MaxGeneration is zero based.
Console.WriteLine("This OS has {0} object generations.\n",
  (GC.MaxGeneration + 1));
Car refToMyCar = new Car("Zippy", 100);
Console.WriteLine(refToMyCar.ToString());

// Print out generation of refToMyCar.
Console.WriteLine("\nGeneration of refToMyCar is: {0}",
  GC.GetGeneration(refToMyCar));

// Make a ton of objects for testing purposes.
object[] tonsOfObjects = new object[50000];
for (int i = 0; i < 50000; i++)
{
  tonsOfObjects[i] = new object();
}

// Collect only gen 0 objects.
Console.WriteLine("Force Garbage Collection");
GC.Collect(0, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();

// Print out generation of refToMyCar.
Console.WriteLine("Generation of refToMyCar is: {0}",
  GC.GetGeneration(refToMyCar));

// See if tonsOfObjects[9000] is still alive.
if (tonsOfObjects[9000] != null)
{
  Console.WriteLine("Generation of tonsOfObjects[9000] is: {0}", GC.GetGeneration(tonsOfObjects[9000]));
}
else
{
  Console.WriteLine("tonsOfObjects[9000] is no longer alive.");
}

// Print out how many times a generation has been swept.
Console.WriteLine("\nGen 0 has been swept {0} times",
  GC.CollectionCount(0));
Console.WriteLine("Gen 1 has been swept {0} times",
  GC.CollectionCount(1));
Console.WriteLine("Gen 2 has been swept {0} times",
  GC.CollectionCount(2));
Console.ReadLine();

这里,我特意创建了一个大的对象类型数组(准确地说是 50,000 个)用于测试目的。以下是该程序的输出:

***** Fun with System.GC *****

Estimated bytes on heap: 75760
This OS has 3 object generations.

Zippy is going 100 MPH
Generation of refToMyCar is: 0
Forcing Garbage Collection
Generation of refToMyCar is: 1
Generation of tonsOfObjects[9000] is: 1

Gen 0 has been swept 1 times
Gen 1 has been swept 0 times
Gen 2 has been swept 0 times

在这一点上,我希望您对对象生命周期的细节感觉更舒服。在下一节中,您将通过解决如何构建可终结对象以及可处置对象来进一步研究垃圾收集过程。请注意,只有在构建维护内部非托管资源的 C# 类时,通常才需要下列技术。

构建可终结的对象

在第六章的中,你了解到最高基础类的。NET Core,System.Object,定义了一个名为Finalize()的虚方法。该方法的默认实现不做任何事情。

// System.Object
public class Object
{
  ...
  protected virtual void Finalize() {}
}

当您为您的定制类重写Finalize()时,您建立了一个特定的位置来为您的类型执行任何必要的清理逻辑。假设这个成员被定义为 protected,那么就不可能通过点运算符从类实例中直接调用对象的Finalize()方法。相反,垃圾收集器会在从内存中移除对象之前调用对象的Finalize()方法(如果支持的话)。

Note

在结构类型上覆盖Finalize()是非法的。考虑到结构是值类型,从一开始就不会在堆上分配,因此也不会被垃圾收集,这是很有意义的。但是,如果您创建了一个包含需要清理的非托管资源的结构,那么您可以实现IDisposable接口(稍后描述)。记住从第四章中得知ref结构和只读ref结构不能实现接口,但是可以实现Dispose()方法。

当然,在“自然”垃圾收集期间,或者当您通过GC.Collect()以编程方式强制收集时,对Finalize()的调用将(最终)发生。在的早期版本中。网(不是。NET Core),在应用关闭时调用每个对象的终结器。英寸 NET Core 中,没有任何方法可以强制执行终结器,即使应用被关闭。

现在,不管你的开发人员本能告诉你什么,你的大多数 C# 类都不需要任何显式的清理逻辑或自定义终结器。原因很简单:如果你的类只是利用其他的托管对象,所有的东西最终都会被垃圾回收。只有在使用非托管资源(如原始 OS 文件句柄、原始非托管数据库连接、非托管内存块或其他非托管资源)时,才需要设计一个可以自我清理的类。在下面。NET 核心平台,非托管资源是通过使用平台调用服务(PInvoke)直接调用操作系统的 API 获得的,或者是一些复杂的 COM 互操作性方案的结果。鉴于此,考虑垃圾收集的下一个规则。

Rule

重写Finalize()的唯一令人信服的理由是,如果您的 C# 类通过 PInvoke 或复杂的 COM 互操作性任务(通常通过由System.Runtime.InteropServices.Marshal类型定义的各种成员)使用非托管资源。原因是在这些情况下,您正在操作运行时无法管理的内存。

超驰系统。Object.Finalize()

在极少数情况下,当您构建使用非托管资源的 C# 类时,您显然希望确保底层内存以可预测的方式释放。假设您已经创建了一个名为 SimpleFinalize 的新 C# 控制台应用项目,并插入了一个名为MyResourceWrapper的类,该类使用了一个非托管资源(无论是什么),并且您想要覆盖Finalize()。在 C# 中这样做的奇怪之处在于,你不能使用预期的override关键字。

using System;
namespace SimpleFinalize
{
  class MyResourceWrapper
  {
    // Compile-time error!
    protected override void Finalize(){ }
  }
}

相反,当您想要配置您的自定义 C# 类类型来覆盖Finalize()方法时,您可以使用(类似 C++)析构函数语法来达到相同的效果。这种替代形式覆盖虚拟方法的原因是,当 C# 编译器处理终结器语法时,它会自动在隐式覆盖的Finalize()方法中添加大量必需的基础结构(马上就会显示)。

C# 终结器看起来与构造函数相似,因为它们的名称与定义它们的类相同。此外,终结器以波浪号(~)为前缀。然而,与构造函数不同,终结器从不接受访问修饰符(它们被隐式保护),从不接受参数,并且不能重载(每个类只有一个终结器)。

下面是一个为MyResourceWrapper定制的终结器,它在被调用时会发出一声系统哔哔声。显然,这个例子只是为了教学目的。现实世界中的终结器除了释放任何非托管资源之外什么都不会做,并且不会与其他托管对象交互,甚至是那些被当前对象引用的对象,因为你不能假设它们在垃圾收集器调用你的Finalize()方法的时候还活着。

using System;
// Override System.Object.Finalize() via finalizer syntax.
class MyResourceWrapper
{
    // Clean up unmanaged resources here.
    // Beep when destroyed (testing purposes only!)
  ~MyResourceWrapper() => Console.Beep();
}

如果您使用ildasm.exe检查这个 C# 析构函数,您会看到编译器插入了一些必要的错误检查代码。首先,你的Finalize()方法范围内的代码语句被放在一个try块内(参见第七章)。相关的finally块确保基类的Finalize()方法将总是执行,不管在try范围内遇到任何异常。

  .method family hidebysig virtual instance void
  Finalize() cil managed
  {
    .override [System.Runtime]System.Object::Finalize
    // Code size       17 (0x11)
    .maxstack  1
    .try
    {
      IL_0000:  call  void [System.Console]System.Console::Beep()
      IL_0005: nop
      IL_0006: leave.s    IL_0010
    }  // end .try
    finally
    {
      IL_0008:  ldarg.0
      IL_0009:  call instance void [System.Runtime]System.Object::Finalize()
      IL_000e:  nop
      IL_000f:  endfinally
    }  // end handler
    IL_0010:  ret
  } // end of method MyResourceWrapper::Finalize

如果您随后测试了MyResourceWrapper类型,您会发现当终结器执行时,系统会发出嘟嘟声。

using System;
using SimpleFinalize;

Console.WriteLine("***** Fun with Finalizers *****\n");
Console.WriteLine("Hit return to create the objects ");
Console.WriteLine("then force the GC to invoke Finalize()");
//Depending on the power of your system,
//you might need to increase these values
CreateObjects(1_000_000);
//Artificially inflate the memory pressure
GC.AddMemoryPressure(2147483647);
GC.Collect(0, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
Console.ReadLine();

static void CreateObjects(int count)
{
  MyResourceWrapper[] tonsOfObjects =
    new MyResourceWrapper[count];
  for (int i = 0; i < count; i++)
  {
    tonsOfObjects[i] = new MyResourceWrapper();
  }
  tonsOfObjects = null;
}

Note

保证这个小的控制台应用强制垃圾收集的唯一方法。NET Core 是在内存中创建大量的对象,然后将它们设置为null。如果您运行这个示例应用,请确保按 Ctrl+C 组合键来停止程序执行和所有的蜂鸣声!

详述最终确定过程

务必记住,Finalize()方法的作用是确保. NET 核心对象在被垃圾收集时能够清理非托管资源。因此,如果您正在构建一个不使用非托管内存的类(这是最常见的情况),那么终结化就没什么用了。事实上,如果可能的话,你应该设计你的类型来避免支持一个Finalize()方法,原因很简单,终结需要时间。

当您将对象分配到托管堆上时,运行时会自动确定您的对象是否支持自定义的Finalize()方法。如果是这样,该对象被标记为可终结的,并且指向该对象的指针被存储在一个名为终结队列的内部队列中。终结队列是由垃圾收集器维护的表,它指向在从堆中移除对象之前必须终结的每个对象。

当垃圾收集器确定是时候从内存中释放一个对象时,它检查终结队列中的每个条目,并将对象从堆中复制到另一个被称为终结可达表的托管结构中(通常缩写为 freachable ,发音为“eff-reachable”)。此时,会产生一个单独的线程,以便在下一次垃圾收集时为可访问表上的每个对象调用Finalize()方法。考虑到这一点,至少需要两次垃圾收集才能真正终结一个对象。

底线是,虽然对象的终结确实确保了对象可以清理非托管资源,但它本质上仍然是不确定的,并且由于额外的幕后处理,速度会慢得多。

建造一次性物品

正如您所看到的,当垃圾回收器开始工作时,终结器可以用来释放非托管资源。然而,考虑到许多非托管对象是“珍贵的项目”(如原始数据库或文件句柄),尽快释放它们而不是依赖垃圾回收可能是有价值的。作为覆盖Finalize()的替代方法,您的类可以实现IDisposable接口,它定义了一个名为Dispose()的方法,如下所示:

public interface IDisposable
{
  void Dispose();
}

当您实现IDisposable接口时,假设当对象用户结束使用对象时,对象用户在允许对象引用脱离范围之前手动调用Dispose()。通过这种方式,对象可以对非托管资源执行任何必要的清理,而不会被放在终结队列中,也不会等待垃圾回收器触发类的终结逻辑。

Note

当对象用户(不是垃圾收集器)调用Dispose()方法时,非ref结构和类类型都可以实现IDisposable(不同于为类类型保留的覆盖Finalize())。第四章介绍了一次性ref结构。

为了演示此接口的用法,创建一个名为 SimpleDispose 的新 C# 控制台应用项目。下面是一个更新的MyResourceWrapper类,它现在实现了IDisposable,而不是覆盖System.Object.Finalize():

using System;
namespace SimpleDispose
{
  // Implementing IDisposable.
  class MyResourceWrapper : IDisposable
  {
    // The object user should call this method
    // when they finish with the object.
    public void Dispose()
    {
      // Clean up unmanaged resources...
      // Dispose other contained disposable objects...
      // Just for a test.
      Console.WriteLine("***** In Dispose! *****");
    }
  }
}

请注意,Dispose()方法不仅负责释放该类型的非托管资源,还可以在任何其他包含的可处置方法上调用Dispose()。与Finalize()不同,在Dispose()方法中与其他托管对象通信是非常安全的。原因很简单:垃圾收集器对IDisposable接口毫无头绪,永远不会调用Dispose()。因此,当对象用户调用此方法时,该对象仍然在托管堆上生活,并且可以访问所有其他堆分配的对象。这里显示的调用逻辑很简单:

using System;
using System.IO;
using SimpleDispose;
Console.WriteLine("***** Fun with Dispose *****\n");
// Create a disposable object and call Dispose()
// to free any internal resources.
MyResourceWrapper rw = new MyResourceWrapper();
rw.Dispose();
Console.ReadLine();

当然,在您尝试对一个对象调用Dispose()之前,您会希望确保该类型支持IDisposable接口。虽然通过查阅文档,你通常会知道哪些基类库类型实现了IDisposable,但是可以使用第六章中讨论的isas关键字来完成编程检查。

Console.WriteLine("***** Fun with Dispose *****\n");
MyResourceWrapper rw = new MyResourceWrapper();
if (rw is IDisposable)
{
  rw.Dispose();
}
Console.ReadLine();

这个例子揭示了另一个关于内存管理的规则。

Rule

如果对象支持IDisposable,那么在您直接创建的任何对象上调用Dispose()是一个好主意。您应该做的假设是,如果类设计者选择支持Dispose()方法,那么该类型需要执行一些清理工作。如果你忘记了,记忆最终会被清理掉(所以不要惊慌),但这可能会花费不必要的时间。

前面的规则有一个警告。实现了IDisposable接口的基类库中的许多类型为Dispose()方法提供了一个(有点混乱的)别名,试图让以处置为中心的方法听起来对定义类型来说更自然。举例来说,虽然System.IO.FileStream类实现了IDisposable(因此支持一个Dispose()方法),但它也定义了以下用于相同目的的Close()方法:

// Assume you have imported
// the System.IO namespace...
static void DisposeFileStream()
{
  FileStream fs = new FileStream("myFile.txt", FileMode.OpenOrCreate);

  // Confusing, to say the least!
  // These method calls do the same thing!
  fs.Close();
  fs.Dispose();
}

虽然“关闭”一个文件比“处理”一个文件感觉起来更自然,但这种清理方法的重叠可能会令人困惑。对于少数提供别名的类型,只要记住如果一个类型实现了IDisposable,调用Dispose()总是安全的。

重用 C# using 关键字

当您处理实现了IDisposable的托管对象时,通常使用结构化异常处理来确保类型的Dispose()方法在发生运行时异常时被调用,如下所示:

Console.WriteLine("***** Fun with Dispose *****\n");
MyResourceWrapper rw = new MyResourceWrapper ();
try
{
  // Use the members of rw.
}
finally
{
  // Always call Dispose(), error or not.
  rw.Dispose();
}

虽然这是防御性编程的一个很好的例子,但事实是,很少有开发人员会对在一个try / finally块中包装每一个一次性类型的前景感到兴奋,只是为了确保调用Dispose()方法。为了以一种不那么突兀的方式实现相同的结果,C# 支持一种特殊的语法,如下所示:

Console.WriteLine("***** Fun with Dispose *****\n");
// Dispose() is called automatically when the using scope exits.
using(MyResourceWrapper rw = new MyResourceWrapper())
{
  // Use rw object.
}

如果您使用ildasm.exe查看下面的top-level statements的 CIL 代码,您会发现using语法确实扩展到了try / finally逻辑,并带有对Dispose()的预期调用:

.method private hidebysig static void
    '<Main>$'(string[] args) cil managed
{
...
  .try
  {
  }  // end .try
  finally
  {
      IL_0019:  callvirt   instance void [System.Runtime]System.IDisposable::Dispose()
  }  // end handler
} // end of method '<Program>$'::'<Main>$'

Note

如果您试图“使用”一个没有实现IDisposable的对象,您将会收到一个编译器错误。

虽然这种语法不需要在try / finally逻辑中手动包装可处置对象,但不幸的是,C# using关键字现在有了双重含义(导入名称空间和调用Dispose()方法)。然而,当你使用支持IDisposable接口的类型时,这种语法结构将确保一旦using块退出,被“使用”的对象将自动调用它的Dispose()方法。

还要注意,在一个using范围内声明多个相同类型的对象是可能的。如您所料,编译器将注入代码来调用每个声明对象上的Dispose()

// Use a comma-delimited list to declare multiple objects to dispose.
using(MyResourceWrapper rw = new MyResourceWrapper(), rw2 = new MyResourceWrapper())
{
  // Use rw and rw2 objects.
}

使用声明(新 8.0)

C# 8.0 中的新特性是使用声明添加了*。using 声明是前面带有关键字using的变量声明。除了用大括号({})标记的显式代码块之外,这在功能上与上一个问题中的语法相同。*

将以下方法添加到您的类中:

private static void UsingDeclaration()
{
  //This variable will be in scope until the end of the method
  using var rw = new MyResourceWrapper();
  //Do something here
  Console.WriteLine("About to dispose.");
  //Variable is disposed at this point.
}

接下来,将以下调用添加到顶级语句中:

Console.WriteLine("***** Fun with Dispose *****\n");
...
Console.WriteLine("Demonstrate using declarations");
UsingDeclaration();
Console.ReadLine();

如果您使用 ILDASM 检查新方法,您将(如您所料)发现与以前相同的代码。

.method private hidebysig static
  void  UsingDeclaration() cil managed
{
...
  .try
  {
...
  }  // end .try
  finally
  {
    IL_0018: callvirt instance void
      [System.Runtime]System.IDisposable::Dispose()
...
  }  // end handler
  IL_001f: ret
} // end of method Program::UsingDeclaration

这个新特性本质上是编译器的魔法,节省了几次击键。使用时要小心,因为新语法不像以前的语法那样明确。

构建可终结和可释放的类型

至此,您已经看到了构造清理内部非托管资源的类的两种不同方法。一方面,您可以使用终结器。使用这种技术,您可以放心地知道对象在垃圾收集时(无论何时)会自行清理,而不需要用户交互。另一方面,您可以实现IDisposable来为对象用户提供一种一旦完成就清理对象的方法。但是,如果调用者忘记调用Dispose(),非托管资源可能会无限期地保留在内存中。

正如您可能会怀疑的那样,将这两种技术混合到一个类定义中是可能的。通过这样做,您可以获得两种模式的优点。如果对象用户确实记得调用Dispose(),您可以通过调用GC.SuppressFinalize()通知垃圾收集器绕过终结过程。如果对象用户忘记调用Dispose(),该对象将最终被终结,并有机会释放内部资源。好消息是对象的内部非托管资源将以某种方式被释放。

下面是MyResourceWrapper的下一个迭代,它现在是可终结和可处置的,在一个名为FinalizableDisposableClass的 C# 控制台应用项目中定义:

using System;

namespace FinalizableDisposableClass
{
  // A sophisticated resource wrapper.
  public class MyResourceWrapper : IDisposable
  {
    // The garbage collector will call this method if the object user forgets to call Dispose().
    ~MyResourceWrapper()
    {
      // Clean up any internal unmanaged resources.
      // Do **not** call Dispose() on any managed objects.
    }
    // The object user will call this method to clean up resources ASAP.
    public void Dispose()
    {
      // Clean up unmanaged resources here.
      // Call Dispose() on other contained disposable objects.
      // No need to finalize if user called Dispose(), so suppress finalization.
      GC.SuppressFinalize(this);
    }
  }
}

注意,这个Dispose()方法已经更新为调用GC.SuppressFinalize(),通知运行时当这个对象被垃圾回收时,不再需要调用析构函数,因为非托管资源已经通过Dispose()逻辑被释放了。

正式的处置模式

MyResourceWrapper的当前实现工作得相当好;然而,你也有一些小缺点。首先,Finalize()Dispose()方法都必须清理相同的非托管资源。这可能导致重复代码,这很容易成为维护的噩梦。理想情况下,您应该定义一个私有的 helper 函数,这两种方法都可以调用它。

接下来,您希望确保Finalize()方法不会试图释放任何托管对象,而Dispose()方法应该这样做。最后,您还想确定对象用户可以安全地多次调用Dispose()而不会出错。目前,Dispose()方法没有这种保护措施。

为了解决这些设计问题,微软定义了一个正式的、初步的和适当的处理模式,在健壮性、可维护性和性能之间取得了平衡。下面是MyResourceWrapper的最终(带注释)版本,它使用了这个官方模式:

class MyResourceWrapper : IDisposable
{
  // Used to determine if Dispose() has already been called.
  private bool disposed = false;

  public void Dispose()
  {
    // Call our helper method.
    // Specifying "true" signifies that the object user triggered the cleanup.
    CleanUp(true);

    // Now suppress finalization.
    GC.SuppressFinalize(this);
  }

  private void CleanUp(bool disposing)
  {
    // Be sure we have not already been disposed!
    if (!this.disposed)
    {

      // If disposing equals true, dispose all managed resources.
      if (disposing)
      {
        // Dispose managed resources.
      }
      // Clean up unmanaged resources here.
    }
    disposed = true;
  }
  ~MyResourceWrapper()
  {
    // Call our helper method.
    // Specifying "false" signifies that the GC triggered the cleanup.
    CleanUp(false);
  }
}

注意,MyResourceWrapper现在定义了一个名为CleanUp()的私有 helper 方法。通过将true指定为参数,您表明对象用户已经启动了清理,因此您应该清理所有托管的非托管的资源。然而,当垃圾收集器启动清理时,您在调用CleanUp()时指定false,以确保内部可处置对象是而不是被处置的(因为您不能假设它们仍然在内存中!).最后但同样重要的是,在退出CleanUp()之前,将bool成员变量(disposed)设置为true,以确保Dispose()可以被多次调用而不出错。

Note

在一个对象被“释放”后,客户端仍然有可能调用其上的成员,因为它仍然在内存中。因此,一个健壮的资源包装类还需要用额外的编码逻辑来更新该类的每个成员,实际上就是说,“如果我被释放,什么也不做,从该成员返回。”

为了测试MyResourceWrapper的最终迭代,将您的Program.cs文件更新如下:

using System;
using FinalizableDisposableClass;

Console.WriteLine("***** Dispose() / Destructor Combo Platter *****");

// Call Dispose() manually. This will not call the finalizer.
MyResourceWrapper rw = new MyResourceWrapper();
rw.Dispose();

// Don't call Dispose(). This will trigger the finalizer when the object gets GCd.
MyResourceWrapper rw2 = new MyResourceWrapper();

请注意,您在rw对象上显式调用了Dispose(),因此析构函数调用被取消了。但是,你已经“忘记”在rw2对象上调用Dispose();不用担心,当对象被垃圾回收时,终结器仍然会执行。

这就结束了您对运行时如何通过垃圾收集来管理对象的研究。虽然关于收集过程还有一些额外的(有点深奥的)细节我还没有在这里介绍(比如弱引用和对象复活),但是您现在已经处于自己进一步探索的最佳位置。作为本章的总结,您将研究一个名为对象的惰性实例化的编程特性。

理解惰性对象实例化

当您创建类时,您可能偶尔需要在代码中考虑一个特定的成员变量,这可能实际上永远都不需要,因为对象用户可能不会调用使用它的方法(或属性)。很公平。但是,如果正在讨论的成员变量需要大量内存来实例化,这可能会有问题。

例如,假设您正在编写一个封装数字音乐播放器操作的类。除了预期的方法,如Play()Pause()Stop(),您还想提供返回一组Song对象的能力(通过一个名为AllTracks的类),这些对象代表设备上的每一个数字音乐文件。

如果您想继续操作,请创建一个名为 LazyObjectInstantiation 的新控制台应用项目,并定义以下类类型:

//Song.cs
namespace LazyObjectInstantiation
{
  // Represents a single song.
  class Song
  {
    public string Artist { get; set; }
    public string TrackName { get; set; }
    public double TrackLength { get; set; }
  }
}

//AllTracks.cs
using System;
namespace LazyObjectInstantiation
{
  // Represents all songs on a player.
  class AllTracks
  {
    // Our media player can have a maximum
    // of 10,000 songs.
    private Song[] _allSongs = new Song[10000];

    public AllTracks()
    {
      // Assume we fill up the array
      // of Song objects here.
      Console.WriteLine("Filling up the songs!");
    }
  }
}

//MediaPlayer.cs
using System;
namespace LazyObjectInstantiation
{
  // The MediaPlayer has-an AllTracks object.
  class MediaPlayer
  {
    // Assume these methods do something useful.
    public void Play() { /* Play a song */ }
    public void Pause() { /* Pause the song */ }
    public void Stop() { /* Stop playback */ }
    private AllTracks _allSongs = new AllTracks();

    public AllTracks GetAllTracks()
    {
      // Return all of the songs.
      return _allSongs;
    }
  }
}

MediaPlayer的当前实现假设对象用户想要通过GetAllTracks()方法获得歌曲列表。那么,如果对象用户需要获得这个列表呢?在当前实现中,AllTracks成员变量仍将被分配,从而在内存中创建 10,000 个Song对象,如下所示:

using System;
using LazyObjectInstantiation;

Console.WriteLine("***** Fun with Lazy Instantiation *****\n");

// This caller does not care about getting all songs,
// but indirectly created 10,000 objects!
MediaPlayer myPlayer = new MediaPlayer();
myPlayer.Play();

Console.ReadLine();

显然,您不希望创建 10,000 个没有人会使用的对象,因为这将给。NET Core 垃圾收集器。虽然您可以手动添加一些代码来确保 _ allSongs对象仅在使用时才被创建(可能使用工厂方法设计模式),但是有一种更简单的方法。

基础类库提供了一个名为Lazy<>的有用的泛型类,在mscorlib.dllSystem命名空间中定义。这个类允许你定义数据,除非你的代码库实际使用它,否则不会被创建。由于这是一个泛型类,因此必须指定首次使用时要创建的项的类型,该类型可以是带有。NET 核心基类库或您自己创作的自定义类型。要启用AllTracks成员变量的惰性实例化,您可以简单地将MediaPlayer代码更新为:

// The MediaPlayer has-an Lazy<AllTracks> object.
class MediaPlayer
{
...
  private Lazy<AllTracks> _allSongs = new Lazy<AllTracks>();
  public AllTracks GetAllTracks()
  {
    // Return all of the songs.
    return _allSongs.Value;
  }
}

除了将AllTracks成员变量表示为Lazy<>类型之外,请注意之前的GetAllTracks()方法的实现也被更新了。具体来说,您必须使用Lazy<>类的只读Value属性来获取实际存储的数据(在本例中,是维护 10,000 个Song对象的AllTracks对象)。

通过这个简单的更新,请注意只有在真正调用了GetAllTracks()时,下面更新的代码才会间接分配Song对象:

Console.WriteLine("***** Fun with Lazy Instantiation *****\n");

// No allocation of AllTracks object here!
MediaPlayer myPlayer = new MediaPlayer();
myPlayer.Play();

// Allocation of AllTracks happens when you call GetAllTracks().
MediaPlayer yourPlayer = new MediaPlayer();
AllTracks yourMusic = yourPlayer.GetAllTracks();

Console.ReadLine();

Note

惰性对象实例化不仅有助于减少不必要对象的分配。如果给定成员有昂贵的创建代码,比如调用远程方法、与关系数据库通信等,也可以使用这种技术。

定制惰性数据的创建

当您声明一个Lazy<>变量时,实际的内部数据类型是使用默认的构造函数创建的,如下所示:

// Default constructor of AllTracks is called when the Lazy<>
// variable is used.
private Lazy<AllTracks> _allSongs = new Lazy<AllTracks>();

虽然在某些情况下这可能没问题,但是如果AllTracks类有一些额外的构造函数,并且您希望确保调用正确的构造函数,该怎么办呢?此外,如果在生成Lazy<>变量时,您有一些额外的工作要做(不仅仅是创建AllTracks对象),那该怎么办?幸运的是,Lazy<>类允许您指定一个泛型委托作为可选参数,这将指定一个在创建包装类型期间调用的方法。

讨论中的泛型委托属于类型System.Func<>,它可以指向一个方法,该方法返回由相关的Lazy<>变量创建的相同数据类型,并且可以接受多达 16 个参数(使用泛型类型参数类型化)。在大多数情况下,您不需要指定任何参数来传递给由Func<>指向的方法。此外,为了大大简化所需的Func<>的使用,我推荐使用 lambda 表达式(参见第十二章来学习或回顾委托/lambda 关系)。

考虑到这一点,下面是MediaPlayer的最终版本,它在创建包装的AllTracks对象时添加了一些定制代码。记住,这个方法在退出之前必须返回一个由Lazy<>包装的类型的新实例,你可以使用你选择的任何构造函数(这里,你仍然调用默认的构造函数AllTracks)。

class MediaPlayer
{
...
  // Use a lambda expression to add additional code
  // when the AllTracks object is made.
  private Lazy<AllTracks> _allSongs =
    new Lazy<AllTracks>( () =>
      {
        Console.WriteLine("Creating AllTracks object!");
        return new AllTracks();
      }
  );

  public AllTracks GetAllTracks()
  {
    // Return all of the songs.
    return _allSongs.Value;
  }
}

太好了。我希望你能看到Lazy<>类的用处。本质上,这个泛型类允许您确保昂贵的对象仅在对象用户需要时才被分配。

摘要

本章的目的是揭开垃圾收集过程的神秘面纱。正如您所看到的,垃圾收集器只有在无法从托管堆获取必要的内存时(或者当开发人员调用GC.Collect())才会运行。当发生收集时,您可以放心,Microsoft 的收集算法已经通过使用对象生成、用于对象终结的辅助线程以及专用于承载大型对象的托管堆进行了优化。

本章还演示了如何使用System.GC类类型以编程方式与垃圾收集器交互。如前所述,真正需要这样做的唯一时间是在构建操作非托管资源的可终结或可释放的类类型时。

回想一下,可终结类型是在垃圾回收时提供了析构函数(有效地覆盖了Finalize()方法)来清理非托管资源的类。另一方面,可处置对象是实现IDisposable接口的类(或非ref结构),当对象用户使用完所述对象时,应该调用该接口。最后,您了解了混合两种方法的官方“处置”模式。

本章最后看了一个名为Lazy<>的泛型类。正如您所看到的,您可以使用这个类来延迟创建一个昂贵的(就内存消耗而言)对象,直到调用者真正需要它。通过这样做,您可以帮助减少存储在托管堆上的对象数量,还可以确保只在调用者真正需要时才创建昂贵的对象。