C-10-快速语法参考-二-

96 阅读27分钟

C#10 快速语法参考(二)

原文:C# 10 Quick Syntax Reference

协议:CC BY-NC-SA 4.0

十四、静态

static关键字可用于声明无需创建类实例就能访问的字段和方法。静态(类)成员只存在于一个副本中,该副本属于类本身,而实例(非静态)成员是作为每个新对象的新副本创建的。这意味着静态方法不能使用实例成员,因为这些方法不是实例的一部分。另一方面,实例方法可以使用静态成员和实例成员。

class Circle
{
  // Instance variable (one per object)
  public float r = 10F;

  // Static/class variable (only one instance)
  public static float pi = 3.14F;

  // Instance method
  public float GetArea()
  {
    return ComputeArea(r);
  }

  // Static/class method
  public static float ComputeArea(float a)
  {
    return pi*a*a;
  }
}

访问静态成员

要从类外部访问静态成员,先使用类名,然后使用点运算符。该操作符与用于访问实例成员的操作符相同,但是要访问它们,需要一个对象引用。对象引用不能用于访问静态成员。

class MyApp
{
  static void Main()
  {
    float f = Circle.ComputeArea(Circle.pi);
  }
}

静态方法

静态成员的优点是它们可以被其他类使用,而不必创建该类的实例。因此,当只需要变量的一个实例时,应该将字段声明为静态的。如果方法执行独立于任何实例变量的泛型函数,则应该将它们声明为静态的。一个很好的例子是System.Math类,它提供了大量的数学方法。这个类只包含静态成员和常量。

static void Main()
{
  double pi = System.Math.PI;
}

静态字段

静态字段的优势在于,它们在应用的整个生命周期中都存在。因此,静态字段可以用来记录一个方法被调用的次数。

static int count = 0;
public static void Dummy()
{
  count++;
}

静态字段的默认值在第一次使用之前只设置一次。

静态类

如果一个类只包含静态成员和常量字段,它也可以被标记为static。静态类不能被继承或实例化到对象中。尝试这样做将导致编译时错误。

static class MyCircle {}

静态构造函数

静态构造函数可以执行初始化类所需的任何操作。通常,这些操作涉及初始化静态字段,这些字段在声明时无法初始化。如果它们的初始化需要不止一行或一些其他逻辑被初始化,这可能是必要的。

class MyClass
{
  static int[] array = new int[5];
  static MyClass()
  {
    for(int i = 0; i < array.Length; i++)
      array[i] = i;
  }
}

与常规的实例构造函数不同,静态构造函数只运行一次。当创建类的实例或引用类的静态成员时,这将自动发生。静态构造函数不能被直接调用,也不能被继承。如果静态字段也有初始值设定项,那么这些初始值将在静态构造函数运行之前被赋值。

静态局部函数

局部函数自动捕获其封闭范围的上下文,使其能够引用自身外部的成员,如父方法的局部变量。

string GetName()
{
  string name = "John";
  return LocalFunc();
  string LocalFunc() { return name; }
}

从 C# 8 开始,static 修饰符可以应用于局部函数来禁用这种行为。然后,编译器将确保静态局部函数不会引用其自身范围之外的任何成员。以这种方式限制访问有助于简化调试,因为您将知道本地函数不会修改任何外部变量。

string GetName()
{
  string name = "John";
  return LocalFunc(name);
  static string LocalFunc(string s) { return s; }
}

扩展方法

C# 3.0 中增加的一个特性是扩展方法,它提供了一种在现有类的定义之外添加新实例方法的方式。扩展方法必须在静态类中定义为static,关键字this用在第一个参数上来指定扩展哪个类。

static class MyExtensions
{
  // Extension method
  public static int ToInt(this string s) {
    return Int32.Parse(s);
  }
}

extension 方法对于其第一个参数类型的对象是可调用的,在本例中是string,就好像它是那个类的实例方法一样。不需要对静态类的引用。

class MyApp
{
  static void Main() {
    string s = "10";
    int i = s.ToInt();
  }
}

因为扩展方法有一个对象引用,所以它可以使用它正在扩展的类的实例成员。但是,它不能使用由于其访问级别而不可访问的任何类的成员。扩展方法的好处在于,它们使您能够向类中“添加”方法,而不必修改或派生原始类型。

十五、属性

C# 中的属性提供了通过称为访问器的特殊方法读写字段来保护字段的能力。它们通常被声明为public,具有与它们要保护的字段相同的数据类型,后跟属性名和定义getset访问器的代码块。属性的命名约定是使用 PascalCase,与方法相同。

class Time
{
  private int sec;
  public int Seconds
  {
    get { return sec; }
    set { sec = value; }
  }
}

请注意,上下文关键字对应于分配给属性的值。属性是作为方法实现的,但是使用起来就像是字段一样。

static void Main()
{
  Time t = new Time();
  t.Seconds = 5;
  int s = t.Seconds; // 5
}

财产优势

由于在先前定义的属性中没有特殊的逻辑,它在功能上与公共字段相同。然而,一般来说,由于属性带来的许多优点,公共字段永远不应该在真实世界的编程中使用。

首先,属性允许开发人员改变属性的内部实现,而不破坏任何正在使用它的程序。这对于已发布的类尤其重要,因为其他开发人员可能正在使用这些类。例如,在Time类中,字段的数据类型可能需要从int更改为byte。使用属性,这种转换可以在后台处理。但是,对于公共字段,更改已发布类的基础数据类型可能会中断任何正在使用该类的程序。

class Time
{
  private byte sec;
  public int Seconds
  {
    get { return (int)sec; }
    set { sec = (byte)value; }
  }
}

属性的第二个优点是,它们允许在允许更改之前对数据进行验证。例如,可以通过以下方式防止向seconds字段分配负值:

class Time
{
  private int sec;
  public int Seconds
  {
    get { return sec; }
    set
    {
      if (value > 0)
        sec = value;
      else
        sec = 0;
    }
  }
}

属性不必与实际字段相对应。他们也可以计算自己的价值。数据甚至可以来自类的外部,比如来自数据库。也没有什么可以阻止程序员在访问器中做其他事情,比如保存一个更新计数器。

public int Hour
{
  get
  {
    return sec / 3600;
  }
  set
  {
    sec = value * 3600;
    count++;
  }
}
private int count = 0;

只读和只写属性

任何一个访问器都可以省略。如果没有set访问器,该属性将变为只读,而通过省去get访问器,该属性将变为只写。

// Read-only property
private int Seconds
{
  public get { return sec; }
}

// Write-only property
private int Seconds
{
  public set { sec = value; }
}

属性访问级别

可以限制访问者的访问级别。例如,为了防止从类外部修改属性,可以将set访问器设为私有。

private set { sec = value; }

属性本身的访问级别也可以更改,以限制两个访问者。默认情况下,访问器是公共的,属性本身是私有的。

private int Seconds
{
  public get { return sec; }
  public set { sec = value; }
}

自动实现的属性

getset访问器直接对应于一个字段的属性是很常见的。因此,有一种简化的方式来编写这样的属性,即省去访问器代码块和私有字段。这种语法是在 C# 3.0 中引入的,被称为自动实现的属性。

class Time
{
  public int Seconds { get; set; }
}

C# 6 中的自动属性增加了两个额外的功能。首先,可以将初始值设置为声明的一部分。第二,通过省略set访问器,可以将 autoproperty 设置为只读。这样的属性只能在构造函数中设置,或者作为声明的一部分设置,如下所示:

class Time
{
  // Read-only auto-property with initializer
  public System.DateTime created { get; }
    = System.DateTime.Now;
}

从 C# 9 开始,set 访问器可以由 init 访问器替换,只允许在对象构造期间设置属性。此访问器类型仅允许用于实例属性,不允许用于静态属性。

class Time
{
  public int Seconds { get; init; }
}

这个 init-only setter 可以在构造函数中初始化,作为声明的一部分,或者如下例所示使用对象初始化器。对象初始值设定项允许将值赋给任何可访问的字段或属性,而不必调用构造函数。

static void Main()
{
  Time t = new Time() { Seconds = 5; }
  t.Seconds = 5; // Error: Seconds not settable
}

十六、索引器

索引器允许一个对象被当作一个数组。它们以与属性相同的方式声明,只是使用了关键字this而不是名称,并且它们的访问器接受参数。在下面的例子中,索引器对应于一个名为data的对象数组,因此索引器的类型被设置为object:

class Array
{
  object[] data = new object[10];
  public object this[int index]
  {
    get { return data[index]; }
    set { data[index] = value; }
  }
}

get访问器从对象数组中返回指定的元素,而set访问器将值插入到指定的元素中。有了索引器,就可以创建该类的一个实例,并将其用作数组来获取和设置元素。

static void Main()
{
  Array a = new Array();
  a[5] = "Hello World";
  object o = a[5]; // Hello World
}

索引器参数

索引器的参数列表类似于方法的参数列表,只是它必须至少有一个参数,并且不允许使用refout修饰符。例如,如果有一个二维数组,列和行索引可以作为单独的参数传递。

class Array
{
  object[,] data = new object[10, 10];
  public object this[int i, int j]
  {
    get { return data[i, j]; }
    set { data[i, j] = value; }
  }
}

index 参数不必是整数类型。对象也可以像索引参数一样被传递。然后可以使用get访问器返回传递的对象所在的索引位置。

class Array
{
  object[] data = new object[10];
  public int this[object o]
  {
    get { return System.Array.IndexOf(data, o); }
  }
}

索引器重载

这两种功能都可以通过重载索引器来提供。参数的类型和数量将决定调用哪个索引器。

class Array
{
  object[] data = new object[10];
  public int this[object o]
  {
    get { return System.Array.IndexOf(data, o); }
  }

  public object this[int i]
  {
    get { return data[i]; }
    set { data[i] = value; }
  }
}

请记住,在真正的程序中,范围检查应该包含在访问器中,以避免因试图超出数组长度而导致的异常。

public object this[int i]
{
  get {
    return (i >= 0 && i < data.Length) ? data[i] : null;
  }
  set {
    if (i >= 0 && i < data.Length)
      data[i] = value;
  }
}

范围和指数

C# 8 引入了两个新的操作符来对集合(比如数组)进行切片。范围运算符(x..y)指定元素范围的开始和结束索引。这种操作的结果可以直接用于循环或存储在系统中。范围类型。

int[] b = { 1, 2, 3, 4, 5 };
foreach (int n in b[1..3]) {
  System.Console.Write(n); // "23"
}

System.Range range = 0..3; // 1st to 3rd
foreach (int n in b[range]) {
  System.Console.Write(n); // "123"
}

在 C# 8 中引入的第二个操作符被命名为 hat 操作符(^)。它用作前缀,从数组末尾开始计算索引。可以使用该系统存储索引。索引类型。

string s = "welcome";
System.Index first = 0;
System.Index last = ¹;
System.Console.WriteLine($"{s[first]}, {s[last]}"); // "w, e"

这两个运算符可以在同一个表达式中组合使用,如下例所示。请注意,范围运算符的起点或终点都可以省略,以包含所有剩余的元素。

string s = "welcome";
System.Console.WriteLine(s[⁴..]); // "come"

十七、接口

接口用于指定派生类必须实现的成员。它们是用关键字interface定义的,后跟一个名称和一个代码块。他们的命名惯例是以大写字母I开始,然后每个单词都大写。

interface IMyInterface {}

接口签名

接口代码块只能包含方法、属性、索引器和事件的签名,以及 C# 8 的默认实现。指定签名时,接口成员的主体被分号替换。接口成员不能有任何限制性访问修饰符,因为它们总是公共的。

interface IMyInterface
{
  // Interface method
  int GetArea();

  // Interface property
  int Area { get; set; }

  // Interface indexer
  int this[int index] { get; set; }

  // Interface event
  event System.EventHandler MyEvent;
}

界面示例

在下面的例子中,用一个名为Compare的方法定义了一个名为IComparable的接口:

interface IComparable
{
  int Compare(object o);
}

接下来定义的类Circle通过使用与继承相同的符号来实现这个接口。然后,Circle类必须定义Compare方法,该方法将返回圆半径之间的差值。除了与接口中定义的成员具有相同的签名之外,实现的成员必须是公共的。

class Circle : IComparable
{
  int r;
  public int Compare(object o)
  {
    return r - (o as Circle).r;
  }
}

尽管一个类只能从一个基类继承,但它可以实现任意数量的接口。这是通过在基类之后的逗号分隔列表中指定接口来实现的。

功能界面

演示了接口的第一个用途,即定义一个类可以共享的特定功能。它允许程序员在不知道类的实际类型的情况下使用接口成员。举例来说,下面的方法采用两个IComparable对象并返回最大的一个。这个方法将为实现IComparable接口的同一个类的任何两个对象工作,因为这个方法只使用通过那个接口公开的功能。

static object Largest(IComparable a, IComparable b)
{
  return (a.Compare(b) > 0) ? a : b;
}

类接口

使用接口的第二种方法是为一个类提供一个实际的接口,通过这个接口可以使用这个类。这样的接口定义了使用该类的程序员需要的功能。

interface IMyClass
{
  void Exposed();
}
class MyClass : IMyClass
{
  public void Exposed() {}
  public void Hidden()  {}
}

然后,程序员可以通过这个接口查看类的实例,方法是将对象封装在接口类型的变量中。

IMyInterface m = new MyClass();

这种抽象提供了两个好处。首先,它使其他程序员更容易使用该类,因为他们现在只能访问与他们相关的成员。其次,它使类更加灵活,因为只要遵循接口,它的实现就可以改变,而不会被使用该类的其他程序员注意到。

默认实现

C# 8 增加了为接口成员创建默认实现的能力。考虑以下简单日志记录接口的示例:

interface ILogger
{
  void Info(string message);
}

class ConsoleLogger : ILogger
{
  public void Info(string message)
  {
    System.Console.WriteLine(message);
  }
}

通过提供一个默认的实现,这个现有的接口可以用一个新的成员来扩展,而不会破坏任何使用该接口的类。

interface ILogger
{
  void Info(string message);
  void Error(string message)
  {
    System.Console.WriteLine(message);
  }
}

十八、抽象

抽象类提供了部分实现,其他类可以在此基础上构建。当一个类被声明为抽象类时,这意味着除了正常的类成员之外,该类还可以包含必须在派生类中实现的不完整成员。

抽象成员

任何需要主体的成员都可以被声明为抽象的,比如方法、属性和索引器。这些成员没有实现,只指定它们的签名,而它们的主体用分号替换。

abstract class Shape
{
  // Abstract method
  public abstract int GetArea();

  // Abstract property
  public abstract int area { get; set; }

  // Abstract indexer
  public abstract int this[int index] { get; set; }

  // Abstract event
  public delegate void MyDelegate();
  public abstract event MyDelegate MyEvent;

  // Abstract class
  public abstract class InnerShape {};
}

抽象示例

在下面的例子中,该类有一个名为GetArea的抽象方法:

abstract class Shape
{
  protected int x = 100, y = 100;
  public abstract int GetArea();
}

如果一个类是从这个抽象类派生的,那么它将被强制重写这个抽象成员。这不同于virtual修饰符,它指定可以有选择地覆盖成员。

class Rectangle : Shape
{
  public override int GetArea() { return x * y; }
}

派生类也可以被声明为抽象的,在这种情况下,它不必实现任何抽象成员。

abstract class Rectangle : Shape {}

抽象类也可以从非抽象类继承。

class NonAbstract {}
abstract class Abstract : NonAbstract {}

如果基类有虚成员,这些虚成员可以被重写为抽象成员,以强制进一步的派生类为它们提供新的实现。

class Vehicle
{
  void virtual Move() {}
}

abstract class Car : Vehicle
{
  void abstract override Move() {}
}

抽象类可以用作接口来保存由派生类构成的对象。

Shape s = new Rectangle();

不可能实例化抽象类。即便如此,抽象类可能有构造函数,可以通过使用base关键字从派生类中调用这些构造函数。

Shape s = new Shape(); // compile-time error

抽象类和接口

抽象类在许多方面类似于接口。两者都可以定义派生类必须实现的成员签名,但是它们都不能被实例化。关键区别首先是抽象类可以包含抽象和非抽象成员,而接口只能包含抽象成员和默认实现。第二,一个类可以实现任意数量的接口,但只能从一个类继承,不管是不是抽象的。

// Defines default functionality and definitions
abstract class Shape
{
  protected int x = 100, y = 100;
  public abstract int GetArea();
}

// Class is a Shape
class Rectangle : Shape { /* ... */ }

// Defines an interface or a specific functionality
interface IComparable
{
  int Compare(object o);
}

// Class can be compared
class MyClass : IComparable { /* ... */ }

抽象类就像非抽象类一样,可以扩展一个基类并实现任意数量的接口。但是,接口不能从类继承。它可以从另一个接口继承,这有效地将两个接口合并为一个。

十九、命名空间

命名空间提供了一种将相关顶级成员分组到层次结构中的方法。它们也用于避免命名冲突。没有包含在命名空间中的顶级成员(如类)被认为属于默认命名空间。它可以通过包含在命名空间块中而移动到另一个命名空间。命名空间的命名约定与类的相同,每个单词最初都是大写的。

namespace MyNamespace
{
  class MyClass {}
}

嵌套命名空间

名称空间可以嵌套任意多级,以进一步定义名称空间层次结构。

namespace Product
{
  namespace Component
  {
    class MyClass {}
  }
}

更简洁的写法是用点分隔名称空间。

namespace Product.Component
{
  class MyClass {}
}

请注意,在项目中的另一个类中再次声明同一个命名空间的效果与两个命名空间包含在同一个块中的效果相同,即使该类位于另一个源代码文件中。

命名空间访问

要从另一个命名空间访问一个类,需要指定它的完全限定名。

namespace Product.Component
{
  public class MyClass {}
}

namespace OtherProduct
{
  class MyApp
  {
    static void Main()
    {
      Product.Component.MyClass myClass;
    }
  }
}

文件范围的命名空间

C# 10 为源文件只包含一个名称空间的典型情况引入了一种不太冗长的名称空间格式。然后,可以在文件的开头将命名空间指定为声明,而不必将其所有成员括在花括号({})中。

namespace Product.Component;
public class MyClass {} // belongs to Product.Component

在文件范围的命名空间声明之后定义的任何实体都将属于该命名空间。

使用指令

通过在名称空间中包含一个using指令,可以缩短完全限定名。然后,可以在代码文件中的任何位置访问该命名空间的成员,而不必在每个引用前添加命名空间。在代码文件中,必须将using指令放在所有其他成员之前。

using Product.Component;

拥有对这些成员的直接访问权限意味着,如果当前命名空间中存在冲突的成员签名,则包含的命名空间中的成员将被隐藏。因此,要在下面的示例中使用导入的命名空间中的类,由于命名冲突,必须再次指定完全限定名:

using Product1.Component;

namespace Product1.Component
{
  public class MyClass
  {
    public static int x;
  }
}

namespace Product2
{
  public class MyClass
  {
    static void Main()
    {
      int x = Product1.Component.MyClass.x;
    }
  }
}

为了简化这种引用,可以将using指令改为将名称空间分配给一个别名。

using MyAlias = Product1.Component;
// ...
int x = MyAlias.MyClass.x;

更简单的方法是使用相同的别名符号,将完全限定类名定义为代码文件的新类型。

using MyType = Product1.Component.MyClass;
// ...
int x = MyType.x;

在 C# 6 中增加了一个using static指令。此指令仅将该类型的可访问静态成员导入当前命名空间。在下面的例子中,由于using static指令的原因,Math类的静态成员可以被无限制地使用:

using static System.Math;

public class Circle
{
  public double Radius { get; set; }
  public double Area
  {
    get { return PI * Pow(radius, 2); }
  }
}

源文件通常以多个 using 指令开始,这些指令在许多文件中是相同的。这在 C# 10 中用全局修饰符解决了,它使得 using 指令应用于项目中的所有源文件。这样,在项目的任何源文件中,常用的命名空间只需指定一次。

// Usable in all source files
global using System.IO;
global using System.Collections;
global using System.Threading;

顶级语句

C# 9 增加了顶级语句,允许省略 Main 方法及其周围的类。这使得将规范的“Hello World”程序简化为一行代码成为可能。

System.Console.WriteLine("Hello World"); // "Hello World"

省略的类和 Main 方法会自动生成,所以这只是编译器的一个特性。在顶层键入的任何语句都将被移到 Main 方法中。用法指令可以出现在顶级语句之前,任何类型定义或命名空间都必须放在顶级语句之下。

using System;

// Moved to Main method
Person p = new() { Name = "Sam" };
Console.WriteLine($"Hi {p.Name}"); // "Hi Sam"

class Person
{
  public string? Name { get; set; }
}

顶层语句对于编写短小精悍的程序很有用。请注意,由于一个程序只能有一个 Main 方法,所以一个项目只能有一个包含顶级语句的文件。

二十、枚举类型

一个枚举是一种特殊的值类型,由一系列命名的常量组成。要创建一个,可以使用enum关键字,后跟一个名称和一个代码块,该代码块包含一个以逗号分隔的常量元素列表。

enum State { Running, Waiting, Stopped };

此枚举类型可用于创建保存这些常量的变量。为了给enum变量赋值,从enum访问元素,就好像它们是一个类的静态成员一样。

State state = State.Running;

枚举示例

switch语句提供了一个枚举何时有用的好例子。与使用普通常量相比,枚举的优点是允许程序员清楚地指定允许哪些常量值。这提供了编译时类型安全,并且 IntelliSense 还使这些值更容易记住。

switch (state)
{
  case State.Running: break;
  case State.Waiting: break;
  case State.Stopped: break;
}

枚举常量值

通常不需要知道enum常量所代表的实际常量值,但有时这很有用。默认情况下,第一个元素的值为0,每个后续元素的值都要高一个。

enum State
{
  Running, // 0
  Waiting, // 1
  Stopped  // 2
};

这些默认值可以通过给常量赋值来覆盖。这些值可以通过表达式计算得出,并且不必是唯一的。

enum State
{
  Running = 0, Waiting = 3, Stopped = Waiting + 1
};

枚举常量类型

常量元素的底层类型被隐式地指定为int,但是这可以通过在枚举名称后使用一个冒号,后跟所需的整数类型来更改。

enum MyEnum : byte {};

枚举访问级别和范围

枚举的访问级别与类的访问级别相同。默认情况下,它们是内部的,但也可以声明为公共的。虽然枚举通常是在顶层定义的,但是它们也可以包含在一个类中。在类中,默认情况下他们拥有私有访问权限,并且可以设置为任何一种访问级别。

枚举方法

一个枚举常量可以被转换成一个int并且ToString方法可以用来获得它的名字。

static void Main()
{
  State state = State.Run;
  int i = (int)state; // 0
  string t = state.ToString(); // "Run"
}

System.Enum类中有几个枚举方法可用,比如GetNames()获得包含enum常量名称的数组。注意,这个方法将一个类型对象(System.Type)作为它的参数,它是使用typeof操作符检索的。

enum Colors { Red, Green };
static void Main()
{
  foreach (string name in System.Enum.GetNames(typeof(Colors)))
  {
    System.Console.Write(name); // "RedGreen"
  }
}

二十一、异常处理

异常处理允许程序员处理程序中可能出现的意外情况。例如,考虑使用System.IO名称空间中的StreamReader类打开一个文件。要查看该类可能引发的异常类型,可以将光标悬停在 Visual Studio 中的类名上。例如,你可能会看到System.IO例外FileNotFoundExceptionDirectoryNotFoundException。如果这些异常中的任何一个发生,程序将终止并显示一条错误消息。

using System;
using System.IO;

class ErrorHandling
{
  static void Main()
  {
    // Run-time error
    StreamReader sr = new StreamReader("missing.txt");
  }
}

Try-Catch 语句

为了避免程序崩溃,必须使用try-catch语句捕捉异常。该语句由一个包含可能导致异常的代码的try块和一个或多个catch子句组成。如果try块成功执行,程序将在try-catch语句后继续运行。然而,如果出现异常,执行将被传递到能够处理该异常类型的第一个catch模块。

try {
  StreamReader sr = new StreamReader("missing.txt");
}
catch {
  Console.WriteLine("File not found");
}

捕捉块

由于前面的catch块没有被设置为处理任何特定的异常,它将捕获所有的异常。这相当于捕获了System.Exception类,因为所有的异常都源自这个类。

catch (Exception) {}

为了捕捉更具体的异常,需要将那个catch块放在更一般的异常之前。

catch (FileNotFoundException) {}
catch (Exception) {}

catch块可以有选择地定义一个异常对象,该对象可以用来获得关于异常的更多信息,比如错误的描述。

catch (Exception e) {
  Console.WriteLine("Error: " + e.Message);
}

异常过滤器

C# 6 中增加了异常过滤器,允许catch块包含条件。使用when关键字将条件附加到catch块。只有当条件评估为true时,匹配的异常才会被捕获,如下例所示:

try {
  StreamReader sr = new StreamReader("missing.txt");
}
catch (FileNotFoundException e)
when (e.FileName.Contains(".txt")) {
  Console.WriteLine("Missing file: " + e.FileName);
}

使用异常过滤器时,相同的异常类型可能出现在多个catch子句中。此外,在某些情况下,更一般的异常可以放在更具体的异常之前。在下一个示例中,通过调用日志记录方法作为异常筛选器来记录所有异常。因为该方法返回false,所以一般的异常不会被捕获,从而允许另一个catch块处理该异常。

using System;
using System.IO;

static class ErrorHandling
{
  // Extension method
  public static bool LogException(this Exception e)
  {
    Console.Error.WriteLine($"Exception: {e}");
    return false;
  }

  static void Main()
  {
    try {
      var sr = new StreamReader("missing.txt");
    }
    catch (Exception e) when (LogException(e)) {
      // Never reached
    }
    catch (FileNotFoundException) {
      // Actual handling of exception
    }
  }
}

最终阻止

作为try-catch语句的最后一个子句,可以添加一个finally块。该块用于清理在try块中分配的某些资源。通常,一旦不再需要有限的系统资源和图形组件,就需要以这种方式释放它们。无论是否有异常,finally块中的代码将一直执行。即使try块以跳转语句结束,比如return,情况也是如此。

在前面使用的例子中,如果在try块中打开的文件被成功打开,那么它应该被关闭。这将在下一个代码段中正确完成。为了能够从finally子句访问StreamReader对象,必须在try块之外声明它。请记住,如果您忘记关闭流,垃圾收集器最终会为您关闭它,但最好自己动手。

StreamReader sr = null;
try {
  sr = new StreamReader("missing.txt");
}
catch (FileNotFoundException) {}
finally {
  if (sr != null) sr.Close();
}

前面的语句称为try-catch-finally语句。也可以省去catch块来创建一个try-finally语句。该语句不会捕捉任何异常。相反,它将确保正确处置在try块中分配的任何资源。如果分配的资源不抛出任何异常,这可能是有用的。例如,这样一个类在System.Drawing名称空间中将是Bitmap

using System.Drawing;
// ...
Bitmap b = null;
try {
  b = new Bitmap(100, 50);
  System.Console.WriteLine(b.Width); // "100"
}
finally {
  if (b != null) b.Dispose();
}

注意,当使用控制台项目时,需要手动添加对System.Drawing程序集的引用,以便可以访问这些成员。为此,请在“解决方案资源管理器”窗口中右击“引用”文件夹,然后选择“添加引用”。然后从“程序集➤框架”中,选择System.Drawing程序集,并单击“确定”将其引用添加到您的项目中。

using 语句

using语句为编写try-finally语句提供了更简单的语法。该语句以using关键字开始,后面是括号中指定的要获取的资源。然后,它包含一个代码块,其中可以使用所获得的资源。当代码块执行完毕,自动调用对象的Dispose方法进行清理。这个方法来自于System.IDisposable接口,所以指定的资源必须实现这个接口。以下代码执行与上一示例相同的功能,但代码行更少:

using System.Drawing;
// ...
using (Bitmap b = new Bitmap(100, 50)) {
  System.Console.WriteLine(b.Width); // "100"
} // disposed

C# 8 通过允许使用声明进一步简化了资源管理。这消除了对花括号的需要,因为当资源处理程序超出范围时,它将被自动处理掉。

void MyBitmap()
{
  using Bitmap b = new Bitmap(100, 50);
  System.Console.WriteLine(b.Height); // "50"
} // disposed

抛出异常

当出现方法无法恢复的情况时,它可以生成一个异常,通知调用方该方法已失败。这是通过使用关键字throw后跟一个从System.Exception派生的类的新实例来完成的。

static void MakeError()
{
  throw new System.DivideByZeroException("My Error");
}

然后,异常将沿调用方堆栈向上传播,直到被捕获。如果调用者捕捉到异常,但无法从中恢复,那么可以只使用throw关键字来重新抛出异常。如果没有更多的try-catch语句,程序将停止执行并显示错误信息。

static void Main()
{
  try {
    MakeError();
  }
  catch {
    throw; // rethrow error
  }
}

作为一个语句,throw关键字不能在需要表达式的上下文中使用,例如在三元语句中。C# 7.0 改变了这一点,允许将throw也用作表达式。这扩展了可能引发异常的位置,例如在以下空合并表达式中:

using System;
class MyClass
{
  private string _name;
  public string name
  {
    get => _name;
    set => _name = value ?? throw new
      ArgumentNullException(nameof(name)+" was null");
  }

  static void Main()
  {
    MyClass c = new MyClass();
    c.name = null; // exception: name was null
  }
}

注意这里使用的nameof表达式,它是在 C# 6 中引入的。该表达式将括号内的符号转换为字符串。如果重命名属性,这样做的好处就会显现出来,因为 IDE 可以找到并重命名该符号。如果使用了字符串,情况就不是这样了。

二十二、运算符重载

运算符重载允许在一个或两个操作数属于某个类的情况下重新定义和使用运算符。如果操作正确,这可以简化代码,并使用户定义的类型像简单类型一样易于使用。

运算符重载示例

在这个例子中,有一个名为Number的类,它有一个整数字段和一个用于设置该字段的构造函数。还有一个静态的Add方法,将两个Number对象加在一起,并将结果作为一个新的Number对象返回。

class Number
{
  public int value;
  public Number(int i) { value = i; }
  public static Number Add(Number a, Number b) {
    return new Number(a.value + b.value);
  }
}

可以使用Add方法将两个Number实例添加在一起。

Number a = new Number(10), b = new Number(5);
Number c = Number.Add(a, b);

二元运算符重载

运算符重载的作用是简化语法,从而为类提供更直观的接口。要将Add方法转换为加法符号的重载方法,请将方法名替换为operator关键字,后跟要重载的运算符。关键字和操作符之间的空格可以选择省略。注意,要使一个操作符重载方法工作,它必须同时被定义为publicstatic

class Number
{
  public int value;
  public Number(int i) { value = i; }
  public static Number operator +(Number a, Number b) {
    return new Number(a.value + b.value);
  }
}

由于该类现在重载加法符号,因此该运算符可用于执行所需的计算。

Number a = new Number(10), b = new Number(5);
Number c = a + b;

一元运算符重载

加法是二元运算符,因为它需要两个操作数。要重载一元运算符,如 increment ( ++),可以使用单个方法参数。

public static Number operator ++(Number a)
{
  return new Number(a.value + 1);
}

请注意,这将重载增量运算符的后缀和前缀版本。

Number a = new Number(10);
a++;
++a;

返回类型和参数

重载一元运算符时,返回类型和参数类型必须是封闭类型。另一方面,当重载大多数二元运算符时,返回类型可以是任何类型,除了void,并且只有一个参数必须是封闭类型。这意味着可以用其他方法参数进一步重载二元运算符,例如,允许将一个Number和一个int相加。

public static Number operator +(Number a, int b)
{
  return new Number(a.value + b);
}

过载运算符

C# 允许重载几乎所有的运算符,如下表所示。组合赋值运算符不能显式重载。相反,当它们对应的算术运算符或按位运算符重载时,它们会被隐式重载。

|

二元运算符

|

一元运算符

|

不可超越

| | --- | --- | --- | | + - * / % (+= -= *= /= %=)``& &#124; ^ << >> (&= &#124;= ^= <<= >>=)``== != > < >= <= | + - ! ~ ++ -- true false | && &#124;&#124; = . [ ] ( ) :: ?: ?? -> => new as is sizeof typeof nameof |

比较运算符以及truefalse必须成对重载。例如,重载等于运算符意味着不等于运算符也必须重载。

真假运算符重载

注意上表中的truefalse被认为是操作符。通过重载它们,一个类的对象可以在条件语句中使用,其中该对象需要被评估为布尔类型。重载它们时,返回类型必须是bool

class Number
{
  public int value;
  public Number(int i) { value = i; }
  public static bool operator true(Number a) {
    return (a.value != 0);
  }
  public static bool operator false(Number a) {
    return (a.value == 0);
  }
}
class MyApp
{
  static void Main()
  {
    Number number = new Number(10);
    if (number) System.Console.Write("true");
    else System.Console.Write("false");
  }
}

二十三、自定义转换

本章介绍如何为对象定义自定义类型转换。如下例所示,一个名为Number的类是用一个int属性和一个构造函数创建的。通过自定义类型转换,允许一个int被隐式转换成这个类的对象成为可能。

class Number
{
  public Number(int i) { Value = i; }
  public int Value { get; init; }
}

隐式转换方法

为此,需要向类中添加一个隐式转换方法。此方法的签名看起来类似于一元运算符重载所使用的签名。它必须声明为public static并包含operator关键字。但是,指定的不是运算符符号,而是返回类型,这是转换的目标类型。单个参数将保存要转换的值。还包含了implicit关键字,它指定该方法用于执行隐式转换。

public static implicit operator Number(int value)
{
  return new Number(value);
}

有了这个方法,一个int可以被隐式地转换成一个Number对象。

Number number = 5; // implicit conversion

可以添加另一个转换方法来处理相反方向的转换,从一个Number对象到一个int

public static implicit operator int(Number number)
{
  return number.Value;
}

显式转换方法

为了防止编译器进行潜在的非预期的对象类型转换,可以将转换方法声明为explicit而不是implicit

public static explicit operator int(Number number)
{
  return number.Value;
}

explicit关键字意味着程序员必须指定一个显式强制转换来调用类型转换方法。特别是,如果转换的结果导致信息丢失,或者如果转换方法可能引发异常,则应该使用显式转换方法。

Number number = 5;
int value = (int)number; // explicit conversion

二十四、结构体

C# 中的struct关键字用于创建值类型。一个struct类似于一个类,因为它表示一个主要由字段和方法成员组成的结构。然而,struct是值类型,而类是引用类型。因此,struct变量直接存储struct的数据,而类变量只存储对内存中分配的对象的引用。

结构变量

与类共享大部分相同的语法。例如,下面的struct被命名为Point,由两个公共字段组成:

struct Point
{
  public int x, y;
}

给定这个struct定义,Point类型的变量可以使用new操作符以熟悉的方式初始化。

Point p = new Point();

当以这种方式创建一个struct变量时,将调用默认的构造函数,将字段设置为它们的默认值。与类不同,struct s 也可以不使用new操作符进行实例化。这些字段将保持未分配状态。然而,类似于试图使用局部未初始化变量时,编译器不允许读取字段,直到它们被初始化。

Point q;
int y = q.x; // compile-time error

结构构造函数

除了不能包含析构函数之外,类可以包含相同的成员。当定义一个构造函数时,编译器将强制所有的struct字段都被赋值,以避免与未赋值变量相关的问题。

struct Point
{
  public int x, y;
  public Point(int x, int y)
  {
    this.x = x;
    this.y = y;
  }
}

给定这个定义,下面的语句都将创建一个字段初始化为零的Point。请注意,无参数构造函数是由编译器自动提供的,它将字段初始化为默认值。

Point p1 = new Point();
Point p2 = new Point(0, 0);

或者,default 关键字可用于初始化所有字段都设置为默认值的结构,与编译器的无参数构造函数相同。从 C# 10 开始,无参数构造函数也可以是用户定义的,在这种情况下,它的行为可以不同于默认的初始化,如下所示:

// Top-level statements
Size s1 = default(Size); // s1.size = 0
Size s2 = new Size(); // s2.size = 1

struct Size
{
  public int size;
  public Size() { this.size = 1; }
}

结构字段初始化器

可以为struct中的字段分配初始值。在 C# 10 之前,这只允许声明为conststatic的字段。

struct MyStruct
{
  public int x = 1, y = 1; // allowed (C# 10)
  public int z { get; set; } = 1; // allowed (C# 10)
  public static int myStatic = 5; // allowed
  public const int myConst = 10; // allowed
}

结构继承

一个struct不能从另一个struct或类继承,也不能是基类。这也意味着struct成员不能被声明为protectedprivate protectedprotected internal,并且struct方法不能被标记为virtualStruct s 隐式继承自System.ValueType,后者又继承自System.Object。虽然struct不支持用户定义的继承,但是它们可以像类一样实现接口。

结构指南

类型通常用于表示轻量级的类,这些类封装了一小组相关的变量。使用struct而不是类的主要原因是为了获得值类型语义。例如,简单类型实际上都是struct类型。对于这些类型,赋值复制值比复制引用更自然。

由于性能原因,s 也很有用。一个struct在内存方面比一个类更有效率。它不仅比一个类占用更少的内存,而且也不需要像引用类型对象那样为它分配内存。此外,一个类需要两个内存空间,一个用于变量,一个用于对象,而struct只需要一个。这对于操作大量数据结构的程序来说有很大的不同。请记住,使用struct s 进行赋值和参数传递通常比使用引用类型代价更高,因为这种操作需要复制整个struct

二十五、记录

C# 9 引入了记录类型,它用基于值的相等行为定义了引用类型。它们可以通过两种不同的方式创建。首先,以与类相同的方式,但是改为使用 record 关键字。

public record Person {}

记录可以包含类可以包含的任何内容,但是它们主要用于封装不可变的数据,即一旦对象被创建就不能改变的数据。因此,它们通常只包含 init 属性来存储它们的数据,因为这保持了记录的不变性。

public record Person
{
  public string name { get; init; }
  public int age { get; init; }
}

可以使用对象初始化器块创建该记录的实例,就像使用类或结构一样。

var p = new Person { name = "John", age = 22 };

创建记录的第二种方法是使用所谓的位置参数形式,如下所示。当使用这种更简洁的语法时,编译器将自动生成 init-only 属性以及这些属性的构造函数。

public record Person(string name, int age);

第二种方法保持记录不变,并隐式生成一个用于设置指定参数的构造函数。

var p = new Person("Sam", 20);

定义记录时,位置参数可以与常规声明形式结合使用。

public record Person(string name, int age)
{
  public string? country { get; init; }
}

创建此记录的实例时,必须只指定位置参数。如果未指定,任何其他属性将被设置为默认值。

var p1 = new Person("Eric", 15);
var p2 = new Person("Elena", 27) { country = "Greece" };

记录行为

尽管 record 是一种引用类型,但编译器会自动实现方法来强制基于值的相等性。如果两个记录实例的所有字段和属性的值都相等,则这两个记录实例相等。这与类不同,类中两个对象变量只有引用同一个对象才相等,即所谓的引用相等。

var p1 = new Person("Jack", 30);
var p2 = new Person("Jack", 30);
bool b1 = p1.Equals(p2); // true
bool b2 = (p1 == p2); // true
bool b3 = (p1 != p2); // false

记录支持继承,允许新的记录类型向现有记录类型添加属性。这是使用记录而不是结构的主要原因之一。一个记录只能从另一个记录或系统继承。对象类。注意,构造基本记录所需的参数需要从派生记录的参数列表中传递。

public record Person(string name);
public record Student(string name, string subject) : Person(name);

static void Main()
{
  var student = new Student("Daryn", "Math");
}

记录类型也有一个编译器生成的 ToString 方法。此方法返回所有公共字段和属性的名称和值。

var s = new Student("Ace", "Law");
s.ToString(); // "Student { name = Ace, subject = Law }"

不可变记录实例的属性不能被修改,但是可以通过使用带有表达式的将它们复制到新记录中。这个表达式使得改变被复制的不可变记录中的特定属性成为可能,即所谓的非破坏性突变。

var s = new Student("Jay", "Bio");
var c1 = s with {}; // copy record
var c2 = s with { name = "Sara" }; // copy and alter record

记录 struts

从 C# 10 开始,可以使用记录结构声明来声明值类型记录。引用类型记录可以选择以 class 关键字作为后缀,以阐明这两种类型之间的区别。

public readonly record struct Pet(string name); // value type record
public record Fruit(string name); // reference type record
public record class Person(string name); // reference type record

这里包含 readonly 关键字是为了使记录结构不可变,因为与记录类(引用类型记录)不同,它们在默认情况下不是不可变的。像记录类一样,记录结构可以用标准属性语法、位置参数或两者的组合来定义。

// Positional parameters and standard properties
public readonly record struct Pet(string name)
{
  public int age { get; init; } = 0;
}

对于记录结构类型,生成一个无参数的构造函数,它将每个字段设置为其默认值,就像使用常规结构一样。与结构类一样,记录结构也生成一个主构造函数,其参数与记录声明的位置参数相匹配。

var p1 = new Pet("Lucy"); // primary constructor
var p2 = new Pet(); // parameterless constructor
var p3 = new Pet("Jack") { age = 15 }; // constructor and initializer

记录指南

记录对于简洁地定义包含很少或没有行为的类型的数据非常有用。不可变记录尤其有助于防止在数据对象被传递和被其他方法无意中更改时引入的潜在错误。

当您想要基于值的相等和比较,但是想要使用引用变量以便在传递记录对象时不复制值时,record 类是更可取的。相比之下,当您需要记录的特性(包括继承)时,record struct 非常有用,但它具有基于值的语义,类似于可以有效复制的小型结构。

二十六、预处理器

C# 包括一组预处理指令,主要用于条件编译。虽然 C# 编译器没有单独的预处理器,但与 C 和 C++编译器一样,这里显示的指令会像有预处理器一样进行处理。也就是说,它们似乎是在实际编译发生之前被处理的。

|

管理的

|

描述

| | --- | --- | | #if``#elif``#else``#endif | 如果否则如果其他如果…就会结束 | | #define``#undef | 符号定义符号未定义 | | #error``#warning``#line | 产生错误生成警告设置行号 | | #region``#endregion | 标记部分开始标记部分结束 |

预处理器语法

预处理器指令很容易与普通编程代码区分开来,因为它们以一个散列符号(#)开始。除了单行注释之外,它们必须总是占据一个独立的行。可以选择在散列符号前后包含空白。

#line 1 // set line number

条件编译符号

条件编译符号是使用后跟符号名称的#define指令创建的。当一个符号被定义时,它将导致一个使用该条件的条件表达式被评估为true。从创建该符号的那一行开始,该符号将只在当前源文件中定义。

#define Symbol

#undef(未定义)指令可以禁用先前定义的符号。

#undef Symbol

条件编译

#if#endif指令指定了基于给定条件将被包含或排除的一段代码。最常见的情况是,这个条件是一个条件编译符号。

#if Symbol
 // ...
#endif

就像 C# if语句一样,#if指令可以选择包含任意数量的#elif ( else if)指令和一个最终的#else指令。条件指令也可以嵌套在另一个条件节中。在更长的条件中,向#endif指令添加注释是一个很好的做法,有助于跟踪它们对应于哪个#if指令。

#if Professional
 // ...
#elif Advanced || Enterprise
 // ...
#else
  #if Debug
 // ...
  #endif // Debug
#endif // Professional

诊断指令

有两种诊断指令:#error#warning#error指令用于通过产生编译错误来中止编译。该指令可以选择接受一个提供错误描述的参数。

#if Professional && Enterprise
 #error Build cannot be both Professional and Enterprise
#endif

与 error 类似,#warning指令会生成一条编译警告消息。该指令不会停止编译。

#if !Professional && !Enterprise
 #warning Build should be Professional or Enterprise
#endif

行指令

另一个影响编译器输出的指令是#line。该指令用于更改行号,也可以更改编译过程中出现错误或警告时显示的源文件名。这在使用将源文件合并成中间文件,然后编译的程序时非常有用。

#line 500 "MyFile"
#error MyError // MyError on line 500

区域指令

最后两条指令是#region#endregion。它们限定了可以使用 Visual Studio 的大纲功能展开或折叠的代码部分。

#region MyRegion
#endregion

正如条件指令一样,区域可以嵌套任意多级。

#region MyRegion
 #region MySubRegion
 #endregion
#endregion

二十七、委托

委托是一种用于引用方法的类型。这允许将方法赋给变量并作为参数传递。委托的声明指定委托类型的对象可以引用的方法签名。按照惯例,委托的名字是每个单词的首字母大写,然后在名字的末尾加上Delegate

delegate void PrintDelegate(string str);

匹配委托签名的方法可以分配给这种类型的委托对象。

class MyApp
{
  static void Print(string s)
  {
    System.Console.WriteLine(s);
  }
  static void Main()
  {
    PrintDelegate d = Print;
  }
}

这个委托对象的行为就像它是方法本身一样,不管它是引用静态方法还是实例方法。对对象的方法调用将由委托转发给方法,任何返回值都将通过委托传递回来。

PrintDelegate d = Print;
d("Hello"); // "Hello"

这里用来实例化委托的语法实际上是 C# 2.0 中引入的简化符号。实例化委托的向后兼容方式是使用常规引用类型初始化语法。

PrintDelegate d = new PrintDelegate(Print);

匿名方法

C# 2.0 还引入了匿名方法,可以将匿名方法分配给委托对象。匿名方法是通过使用关键字delegate后跟方法参数列表和主体来指定的。这可以简化委托的实例化,因为不必为了实例化委托而定义单独的方法。

PrintDelegate f = delegate(string s)
{
  System.Console.WriteLine(s);
};

λ表达式

C# 3.0 更进一步,引入了 lambda 表达式。它们实现了与匿名方法相同的目标,但是语法更简洁。lambda 表达式被写成一个参数列表,后跟 lambda 运算符(=>)和一个表达式。

delegate int IntDelegate(int i);

class MyApp
{
  static void Main()
  {
    // Anonymous method
    IntDelegate a = delegate(int x) { return x * x; };

    // Lambda expression
    IntDelegate b = (int x) => x * x;

    a(5); // 25
    b(5); // 25
  }
}

lambda 必须与委托的签名匹配。通常,编译器可以从上下文中确定参数的数据类型,因此不需要指定它们。如果 lambda 只有一个输入参数,括号也可以省略。

IntDelegate c = x => x * x;

如果不需要输入参数,则必须指定一组空括号。

delegate void EmptyDelegate();
// ...
EmptyDelegate d = () =>
  System.Console.WriteLine("Hello");

只执行一条语句的 lambda 表达式称为表达式 lambda 。lambda 的表达式也可以用花括号括起来,以允许它包含多个语句。这种形式叫做语句λ

IntDelegate e = (int x) => {
  int y = x * x;
  return y;
};

表达式主体成员

Lambda 表达式提供了一种定义类成员的快捷方式,当成员只包含一个表达式时。这被称为表达式体定义。考虑下面的类:

class Person
{
  public string name { get; } = "John";
  public void PrintName() {
    System.Console.WriteLine(name);
  }
}

这些成员体可以重写为表达式体,这样更容易阅读。

class Person
{
  public string name => "John";
  public void PrintName() =>
    System.Console.WriteLine(name);
}

在 C# 6 中为方法和get属性增加了对实现成员体作为 lambda 表达式的支持。C# 7.0 扩展了允许成员的列表,包括构造函数、析构函数、set属性和索引器。为了说明这一点,下面是一个将表达式体用于同时具有setget访问器的构造函数和属性的示例:

class Person
{
  private string name;
  public string Name
  {
    get => name;
    set => name = value;
  }
  public Person(string n) => Name = n;
}

类型推理

从 C# 10 开始,使用类型推断让编译器自动推断 lambda 的委托类型成为可能。这使得存储对 lambda 的引用成为可能,而无需首先声明合适的委托类型或显式使用. NET 中可用的预定义委托类型之一。

var pow = (int x) => x * x;

请注意,在使用类型推断时必须指定参数类型,因为编译器不知道类型。如果返回类型不明确,也必须显式指定,如下例所示:

var select = object (bool b) => b ? 0 : "one";

捕捉外部变量

lambda 可以引用其周围范围内的变量。当以这种方式捕获变量时,该变量将保留在范围内,直到委托被销毁。

static void Main()
{
  int x = 0;
  var updateX = (int i) => x = i; // capture local var x

  System.Console.WriteLine(x); // "0"
  updateX(5);
  System.Console.WriteLine(x); // "5"
}

从 C# 9 开始,static 修饰符可以应用于 lambda 表达式和匿名方法,以防止局部变量或实例成员被意外捕获。这样的 lambda 仍然可以引用其周围范围内的静态和常量成员。

class MyApp
{
  static int y = 5;
  static void Main()
  {
   var f1 = static (int x) => x + MyApp.y; // ok
   int z = 0;
   var f2 = static (int x) => x + z; // error
  }
}

多播代理

委托对象可能引用多个方法。这种对象称为多播委托,它引用的方法包含在所谓的调用列表中。要将另一个方法添加到委托的调用列表中,可以使用加法运算符或加法赋值运算符。

class MyApp
{
  delegate void StringDelegate();
  static void Hi()  { System.Console.Write("Hi"); }
  static void Bye() { System.Console.Write("Bye"); }

  static void Main()
  {
    StringDelegate del = Hi;
    del = del + Hi;
    del += Bye;
  }
}

类似地,要从调用列表中删除一个方法,需要使用减法或减法赋值操作符。

del -= Hi;

当调用多播委托对象时,调用列表中的所有方法都将按照它们被添加到列表中的顺序用相同的参数调用。

del(); // "HiBye"

如果委托返回值,则只返回最后调用的方法的值。同样,如果委托有一个out参数,它的最终值将是最后一个方法分配的值。

委托签名

如上所述,如果一个方法与委托的签名相匹配,那么它就可以被分配给委托对象。但是,方法不必与签名完全匹配。委托对象还可以引用一个方法,该方法具有比委托中定义的返回类型更派生的返回类型,或者具有作为相应委托的参数类型的祖先的参数类型。

class Base {}
class Derived : Base {}

delegate Base ChildDelegate(Derived d);

class MyApp
{
  static Derived Test(Base o)
  {
    return new Derived();
  }

  static void Main()
  {
    ChildDelegate del = Test;
  }
}

作为参数的委托

委托的一个重要属性是它们可以作为方法参数传递。为了展示这样做的好处,将定义两个简单的类。第一个是名为PersonDB的数据存储类,它有一个包含几个名字的数组。它还有一个方法,该方法将委托对象作为其参数,并为数组中的每个名称调用该委托。

delegate void ProcessPersonDelegate(string name);

class PersonDB
{
  string[] list = { "John", "Sam", "Dave" };

  public void Process(ProcessPersonDelegate del)
  {
    foreach(string s in list) del(s);
  }
}

第二个类是Client,会用到存储类。它有一个创建PersonDB实例的Main方法,它用一个在Client类中定义的方法调用该对象的Process方法。

class Client
{
  static void Main()
  {
    PersonDB p = new PersonDB();
    p.Process(PrintName);
  }

  static void PrintName(string name)
  {
    System.Console.WriteLine(name);
  }
}

这种方法的好处在于,它允许将数据存储的实现与数据处理的实现分开。storage 类只处理存储,不知道对数据进行了什么处理。这允许以更通用的方式编写存储类,而不是该类必须实现客户端可能希望对数据执行的所有潜在处理操作。有了这个解决方案,客户端可以简单地将自己的处理代码插入到现有的存储类中。