C# 13 和 .NET 9 全知道 :6 实现接口和继承类 (2)
定义和处理事件
您现在已看到代表如何实现事件最重要的功能:定义一个可以被完全不同的代码块实现的方法签名,调用该方法以及任何连接到代表字段的任何其他方法。
但是关于事件呢?它们的内容可能没有你想的那么多。
在将方法分配给委托字段时,您不应使用我们之前示例中使用的简单赋值运算符。
代表是组播的,这意味着您可以为单个代表字段分配多个代表。我们本可以使用 += 运算符,以便向同一代表字段添加更多方法。当调用代表时,所有分配的方法都会被调用,尽管您无法控制它们的调用顺序。不要使用事件来实现购票排队系统;否则,数百万 Swifties 的愤怒将降临于您。
如果 Shout 委托字段已经引用了一个或多个方法,通过分配另一个方法,该方法将替换所有其他方法。对于用于事件的委托,我们通常想确保程序员只使用 += 运算符或 -= 运算符来分配和删除方法:
- 为了执行此操作,在 Person.cs 中,将 event 关键字添加到委托字段声明中,如下代码所示:
public event EventHandler? Shout;
构建 PeopleApp 项目并注意编译错误信息,如下所示输出:
Program.cs(41,13): error CS0079: The event 'Person.Shout' can only appear on the left hand side of += or -=
这是(几乎) event 关键字所做的一切!如果你永远不会将多个方法分配给委托字段,那么从技术上讲,你不需要事件,但仍然是一个好习惯来表明你的意图,并且你期望将委托字段用作事件。
- 在 Program.cs 中,修改注释和方法赋值,使用 += 代替仅使用 = ,如以下代码所示:
// Assign the method to the Shout event delegate.
harry.Shout += Harry_Shout;
- 运行 PeopleApp 项目并注意它和之前的行为相同。
- 在 Program.EventHandlers.cs 中,为 Harry 的 Shout 事件创建第二个事件处理器,如下所示代码:
// Another method to handle the event received by the harry object.
private static void Harry_Shout_2(object? sender, EventArgs e)
{
WriteLine("Stop it!");
}
在 Program.cs 中,在将 Harry_Shout 方法分配给 Shout 事件的语句之后,添加一个语句将新的事件处理器也附加到 Shout 事件,如下所示的高亮代码:
// Assign the method(s) to the Shout event delegate.
harry.Shout += Harry_Shout;
harry.Shout += Harry_Shout_2;
运行 PeopleApp 项目,查看结果。注意,每当事件被触发时,两个事件处理器都会执行,这仅在愤怒等级达到三或更高时发生,如下所示输出:
Harry is this angry: 3.
Stop it!
Harry is this angry: 4.
Stop it!
在 Windows 桌面开发中,想象你有三个按钮: AddButton 、 SaveButton 和 DeleteButton 。每个按钮的功能都非常不同。良好的做法是为它们的事件 Click 创建三个方法,分别命名为 AddButton_Click 、 SaveButton_Click 和 DeleteButton_Click 。每个方法都会有不同的实现代码。
但是现在,想象你有 26 个按钮: AButton , BButton , CButton ,以此类推,直到 ZButton 。每个按钮都具有相同的功能:通过人们名字的第一个字母过滤人员列表。良好的做法是创建一个方法来处理它们的 Click 事件,可能命名为 AtoZButtons_Click 。该方法将包含一个实现代码,该代码将使用 sender 参数来了解哪个按钮被点击,从而确定如何应用过滤,但除此之外,所有按钮都是相同的。
这是关于事件的内容。现在,让我们看看接口。
实现接口
接口是实现标准功能并连接不同类型以创造新事物的方式。想想看,它们就像乐高TM积木顶部的凸起,允许它们“粘”在一起,或者插头和插座的标准电气规范。
如果一个类型实现了接口,那么它就向.NET 的其余部分承诺支持特定的功能。因此,它们有时被描述为契约。
常用接口
表 6.1 显示了您类型可能实现的一些常见接口:
界面 | 方法(们) | 描述 |
IComparable | CompareTo(other) | 这定义了一个类型实现以对其实例进行排序或排序的比较方法。 |
IComparer | Compare(first, second) | 这定义了一个二级类型实现以对或排序一级类型实例的比较方法。 |
IDisposable | Dispose() | 这定义了一种释放非托管资源的方法,比等待终结器更高效。请参阅本章后面的“释放非托管资源”部分以获取更多详细信息。 |
IFormattable | ToString(format, culture) | 这定义了一种文化感知的方法,将对象值格式化为字符串表示。 |
IFormatter | Serialize(stream, object) Deserialize(stream) | 这定义了将对象转换为字节流以及从字节流转换回对象的方法,用于存储或传输。 |
IFormatProvider | GetFormat(type) | 这定义了一种基于语言和地区格式化输入的方法。 |
表 6.1:您类型可能实现的一些常见接口
比较排序时对象
其中最常用的接口之一,您希望在表示数据的类型中实现的是 IComparable 。如果一个类型实现了 IComparable 接口之一,那么包含该类型实例的数组和集合可以进行排序。
这是对排序概念的一种抽象示例。要对任何类型进行排序,最低的功能是能够比较两个项目并决定哪个在前。如果一个类型实现了这种最低功能,那么排序算法就可以使用它以任何排序算法想要的方式对那个类型的实例进行排序。
IComparable 接口有一个名为 CompareTo 的方法。这有两个变体,一个与可空的 object 类型一起工作,另一个与可空的泛型类型 T 一起工作,如下面的代码所示:
namespace System
{
public interface IComparable
{
int CompareTo(object? obj);
}
public interface IComparable<in T>
{
int CompareTo(T? other);
}
}
in 关键字指定类型参数 T 是逆变,这意味着可以使用比指定更少的派生类型。例如,如果 Employee 从 Person 派生,那么两者可以相互比较。
例如, string 类型通过返回 -1 如果 string 应该在比较 string 之前排序, 1 如果它应该在比较之后排序,以及 0 如果它们相等来实现 IComparable 。 int 类型通过返回 -1 如果 int 小于正在比较的 int , 1 如果它更大,以及 0 如果它们相等来实现 IComparable 。
CompareTo 返回值可以总结如表 6.2 所示:
这之前其他 | 这是等于其他 | 这之后其他 |
-1 | 0 | 1 |
表 6.2:CompareTo 返回值摘要
在实现 IComparable 接口及其 CompareTo 方法之前,让我们看看在未实现此接口的情况下尝试对 Person 实例数组进行排序会发生什么,包括一些是 null 或它们的 Name 属性有 null 值的实例:
- 在 PeopleApp 项目中,添加一个名为 Program.Helpers.cs 的新类文件。
- 在 Program.Helpers.cs 中,删除任何现有的语句。然后定义一个用于 partial Program 类的函数,该函数将输出作为参数传递的一个人群的所有名称,并在之前添加一个标题,如下面的代码所示:
using Packt.Shared;
partial class Program
{
private static void OutputPeopleNames(
IEnumerable<Person?> people, string title)
{
WriteLine(title);
foreach (Person? p in people)
{
WriteLine(" {0}",
p is null ? "<null> Person" : p.Name ?? "<null> Name");
/* if p is null then output: <null> Person
else output: p.Name
unless p.Name is null then output: <null> Name */
}
}
}
在 Program.cs 中添加创建 Person 实例数组的语句,调用 OutputPeopleNames 方法将项目写入控制台,然后尝试对数组进行排序并将项目再次写入控制台,如下所示:
Person?[] people =
{
null,
new() { Name = "Simon" },
new() { Name = "Jenny" },
new() { Name = "Adam" },
new() { Name = null },
new() { Name = "Richard" }
};
OutputPeopleNames(people, "Initial list of people:");
Array.Sort(people);
OutputPeopleNames(people,
"After sorting using Person's IComparable implementation:");
- 运行 PeopleApp 项目,将抛出异常。正如消息所述,要修复问题,我们的类型必须实现 IComparable ,如下所示输出:
Unhandled Exception: System.InvalidOperationException: Failed to compare two elements in the array. ---> System.ArgumentException: At least one object must implement IComparable.
- 在 Person.cs 中,继承自 object 后,添加一个逗号并输入 IComparable<Person?> ,如下代码所示:
public class Person : IComparable<Person?>
- 您的代码编辑器将在新代码下画一条红色波浪线以警告您,您尚未实现您承诺的方法。您的代码编辑器可以为您编写骨架实现。
- 点击灯泡,然后点击实现接口。
- 向下滚动到 Person 类的底部以找到为您编写的函数,如下所示代码:
public int CompareTo(Person? other)
{
throw new NotImplementedException();
}
- 删除引发 NotImplementedException 错误的语句。
- 添加处理输入值变体的语句,包括 null 。调用 Name 字段的 CompareTo 方法,该方法使用 string 类型的 CompareTo 实现。返回结果,如下代码所示:
int position;
if (other is not null)
{
if ((Name is not null) && (other.Name is not null))
{
// If both Name values are not null, then
// use the string implementation of CompareTo.
position = Name.CompareTo(other.Name);
}
else if ((Name is not null) && (other.Name is null))
{
position = -1; // this Person precedes other Person.
}
else if ((Name is null) && (other.Name is not null))
{
position = 1; // this Person follows other Person.
}
else // Name and other.Name are both null.
{
position = 0; // this and other are at same position.
}
}
else if (other is null)
{
position = -1; // this Person precedes other Person.
}
else // this and other are both null.
{
position = 0; // this and other are at same position.
}
return position;
我们选择通过比较它们的 Name 字段来比较两个 Person 实例。因此, Person 实例将按名称字母顺序排序。 null 值将被排序到集合的底部。在返回之前存储计算出的 position 对于调试很有用。我还使用了比编译器需要的更多圆括号,以便使我更容易阅读代码。如果您更喜欢较少的括号,那么请随意删除它们。
同时,请注意,最终的 else 语句永远不会执行,因为 if 和 else if 子句的逻辑意味着它只有在 this (当前对象实例)是 null 时才会执行。在这种情况下,方法无论如何都无法执行,因为对象根本不存在!我编写了 if 语句来详尽地涵盖 null 和 null 的所有组合,但对于这些组合中的最后一个,在实践中实际上永远不会发生。
- 运行 PeopleApp 项目。注意这次它应该正常工作,按名称字母顺序排序,如下所示输出:
Initial list of people:
Simon
<null> Person
Jenny
Adam
<null> Name
Richard
After sorting using Person's IComparable implementation:
Adam
Jenny
Richard
Simon
<null> Name
<null> Person
- 好的实践:如果您想对您的类型实例的数组或集合进行排序,那么实现 IComparable 接口。
使用独立类比较对象
有时,您可能无法访问类型的源代码,并且它可能没有实现 IComparable 接口。幸运的是,还有另一种对类型实例进行排序的方法。您可以创建一个实现略微不同接口的独立类型,命名为 IComparer :
- 在 PacktLibrary 项目中,添加一个名为 PersonComparer.cs 的新类文件,包含一个实现 IComparer 接口的类,该类将比较两个人,即两个 Person 实例。通过比较它们的 Name 字段长度来实现,如果名字长度相同,则按字母顺序比较名字,如下面的代码所示:
namespace Packt.Shared;
public class PersonComparer : IComparer<Person?>
{
public int Compare(Person? x, Person? y)
{
int position;
if ((x is not null) && (y is not null))
{
if ((x.Name is not null) && (y.Name is not null))
{
// If both Name values are not null...
// ...then compare the Name lengths...
int result = x.Name.Length.CompareTo(y.Name.Length);
// ...and if they are equal...
if (result == 0)
{
// ...then compare by the Names...
return x.Name.CompareTo(y.Name);
}
else
{
// ...otherwise compare by the lengths.
position = result;
}
}
else if ((x.Name is not null) && (y.Name is null))
{
position = -1; // x Person precedes y Person.
}
else if ((x.Name is null) && (y.Name is not null))
{
position = 1; // x Person follows y Person.
}
else // x.Name and y.Name are both null.
{
position = 0; // x and y are at same position.
}
}
else if ((x is not null) && (y is null))
{
position = -1; // x Person precedes y Person.
}
else if ((x is null) && (y is not null))
{
position = 1; // x Person follows y Person.
}
else // x and y are both null.
{
position = 0; // x and y are at same position.
}
return position;
}
}
在 Program.cs 中,添加语句以使用替代实现来排序数组,如下所示代码:
Array.Sort(people, new PersonComparer());
OutputPeopleNames(people,
"After sorting using PersonComparer's IComparer implementation:");
运行 PeopleApp 项目,查看按姓名长度排序然后按字母顺序排序的人员结果,如下所示输出:
After sorting using PersonComparer's IComparer implementation:
Adam
Jenny
Simon
Richard
<null> Name
<null> Person
这次,当我们对 people 数组进行排序时,我们明确要求排序算法使用 PersonComparer 类型,以便按名字长度从短到长排序,如 Adam ,以及从长到短排序,如 Richard 。当两个或更多名字的长度相等时,它们将按字母顺序排序,如 Jenny 和 Simon 。
隐式和显式接口实现
接口可以隐式和显式实现。隐式实现更简单且更常见。显式实现只有在类型必须具有相同名称和签名的多个方法时才是必要的。就我个人而言,我唯一记得需要显式实现接口的情况是在编写这本书的代码示例时。
例如, IGamePlayer 和 IKeyHolder 可能都有一个名为 Lose 的方法,因为游戏和密钥都可能丢失。接口的成员始终且自动是 public ,因为它们必须可供其他类型实现!
在一个必须实现两个接口的类型中,只有一种实现可以是隐式方法。如果两个接口可以共享相同的实现,则没有问题,但如果不行,那么其他 Lose 方法必须以不同的方式实现并显式调用,如下面的代码所示:
public interface IGamePlayer // Defaults to internal.
{
void Lose(); // Defaults to public. Could be set to internal.
}
public interface IKeyHolder
{
void Lose();
}
public class Human : IGamePlayer, IKeyHolder
{
// Implicit implementation must be public.
public void Lose() // Implicit implementation.
{
// Implement losing a key.
WriteLine("Implementation for losing a key.");
}
// Explicit implementation can be any access modifier.
void IGamePlayer.Lose() // Defaults to private.
{
// Implement losing a game.
WriteLine("Implementation for losing a game.");
}
}
Human human = new();
human.Lose(); // Calls implicit implementation of losing a key.
// Outputs: Implementation for losing a key.
((IGamePlayer)human).Lose(); // Calls explicit implementation of losing a game.
// Outputs: Implementation for losing a game.
// Alternative way to do the same.
IGamePlayer player = human as IGamePlayer;
player.Lose(); // Calls explicit implementation of losing a game.
// Outputs: Implementation for losing a game.
尽管在 Human 中实现 IGamePlayer.Lose 是 private ,但 IGamePlayer.Lose 成员本身有一个 public 访问修饰符,所以如果我们将 Human 实例转换为接口类型,那么那个 Lose 实现是可访问的。
警告!实现类型中的方法访问修饰符必须与接口中的方法定义匹配。例如,接口中的 Lose 方法为 public ,因此类中的方法实现也必须是 public 。
定义具有默认实现的接口
C# 8 引入的一项语言特性是接口的默认实现。这允许接口包含实现。这打破了定义契约的接口与实现它们的类和其他类型之间的清晰分离。一些.NET 开发者认为这是一种语言的扭曲。
让我们看看它的实际效果:
- 在 PacktLibrary 项目中,添加一个名为 IPlayable.cs 的新文件,并修改语句以定义一个具有两个方法用于 Play 和 Pause 的公共 IPlayable 接口,如下所示:
namespace Packt.Shared;
public interface IPlayable
{
void Play();
void Pause();
}
在 PacktLibrary 项目中,添加一个名为 DvdPlayer.cs 的新类文件,并修改文件中的语句以实现 IPlayable 接口,如下所示:
namespace Packt.Shared;
public class DvdPlayer : IPlayable
{
public void Pause()
{
WriteLine("DVD player is pausing.");
}
public void Play()
{
WriteLine("DVD player is playing.");
}
}
这很有用,但如果我们决定添加一个名为 Stop 的第三种方法呢?在 C# 8 之前,一旦在原始接口中实现至少一种类型,这将是不可能的。接口的一个主要特征是它是一个固定的合同。
C# 8 允许在发布后向接口添加新成员,前提是新成员具有默认实现。C# 纯粹主义者不喜欢这个想法,但出于实际原因,例如避免破坏性更改或必须定义全新的接口,这很有用,其他语言如 Java 和 Swift 也启用了类似的技术。
支持默认接口实现需要对底层平台进行一些基本更改,因此仅在目标框架为.NET 5 或更高版本、.NET Core 3 或更高版本或.NET Standard 2.1 时才支持 C#,因此它们不支持.NET Framework。
让我们向接口添加一个默认实现:
- 修改 IPlayable 接口以添加一个具有默认实现的 Stop 方法,如以下代码所示:
namespace Packt.Shared;
public interface IPlayable
{
void Play();
void Pause();
void Stop() // Default interface implementation.
{
WriteLine("Default implementation of Stop.");
}
}
- 构建 PeopleApp 项目,并注意尽管 DvdPlayer 类没有实现 Stop ,项目仍然编译成功。未来,我们可以在 DvdPlayer 类中实现它,以覆盖 Stop 的默认实现。
尽管存在争议,接口中的默认实现可能在定义接口时已知最常见的实现场景中很有用。因此,最好是在接口中一次性定义该实现,然后大多数实现该接口的类型可以继承它,而无需实现自己的。然而,如果接口定义者不知道成员应该如何或甚至能否实现,那么添加默认实现就是徒劳的,因为它总会被替换。
思考一下本章前面看到的 IComparable 接口。它定义了一个 CompareTo 方法。那个方法的默认实现可能是什么?我个人认为,显然没有哪个默认实现会有任何实际意义。我能想到的最不糟糕的实现是将调用两个对象上的 ToString 方法返回的 string 值进行比较。然而,每种类型实际上都应该实现自己的 CompareTo 方法。你可能会在 99.9%的接口中找到相同的情况。
现在让我们看看如何处理空值。
与空值一起工作
如果变量还没有值怎么办?我们如何表示这一点?C# 有一个 null 值的概念,可以用来表示变量尚未设置。
如果您不确定.NET 中引用类型和值类型之间的区别,那么在继续阅读本节之前,我建议您阅读以下仅在网络上可用的部分:https://github.com/markjprice/cs13net9/blob/main/docs/ch06-memory.md。
将值类型设置为可空
默认情况下,像 int 和 DateTime 这样的值类型必须始终有值,因此得名。有时,例如,在读取允许空、缺失或 null 值的数据库中存储的值时,允许值类型为 null 是方便的。我们称这种值为可空值类型。
您可以通过在声明变量时将问号作为后缀来启用此功能。
让我们看一个例子。我们将创建一个新的项目,因为一些空值处理选项是在项目级别设置的:
- 使用您喜欢的代码编辑器,将名为 NullHandling 的新控制台应用程序/ console 项目添加到 Chapter06 解决方案中。
- 在 NullHandling.csproj 中,添加一个 <ItemGroup> 以全局和静态导入 System.Console 类。
- 在 Program.cs 中,删除现有的语句,然后添加声明和赋值的语句,包括 null ,两个 int 变量,一个后缀为 ? ,另一个不是,如下面的代码所示:
int thisCannotBeNull = 4;
thisCannotBeNull = null; // CS0037 compiler error!
WriteLine(thisCannotBeNull);
int? thisCouldBeNull = null;
WriteLine(thisCouldBeNull);
WriteLine(thisCouldBeNull.GetValueOrDefault());
thisCouldBeNull = 7;
WriteLine(thisCouldBeNull);
WriteLine(thisCouldBeNull.GetValueOrDefault());
构建项目并注意编译错误,如下所示输出:
Cannot convert null to 'int' because it is a non-nullable value type
注释掉导致编译错误的语句,如下所示代码:
//thisCannotBeNull = null; // CS0037 compiler error!
运行项目并查看结果,如下所示输出:
4
0
7
7
第二行是空的,因为它输出的是 null 值。
- 添加使用替代语法的语句,如下所示代码所示:
// The actual type of int? is Nullable<int>.
Nullable<int> thisCouldAlsoBeNull = null;
thisCouldAlsoBeNull = 9;
WriteLine(thisCouldAlsoBeNull);
- 点击 Nullable<int> 并按 F12,或右键单击选择转到定义。
- 请注意,通用的值类型 Nullable<T> 必须有一个类型 T ,该类型是 struct ,或者是一个值类型。它具有如 HasValue 、 Value 和 GetValueOrDefault 等有用的成员,如图 6.1 所示:
良好实践:当你在 struct 类型后附加一个 ? 时,你会将其转换为另一种类型。例如, DateTime? 变为 Nullable<DateTime> 。