六、理解继承和多态
第五章研究了 OOP 的第一个支柱:封装。那时,您学习了如何用构造函数和各种成员(字段、属性、方法、常量和只读字段)构建一个定义良好的类类型。本章将关注 OOP 的其余两个支柱:继承和多态。
首先,您将学习如何使用继承构建相关类的家族。正如您将看到的,这种形式的代码重用允许您在父类中定义公共功能,这些功能可以被子类利用,也可能被子类修改。在这个过程中,您将学习如何使用虚拟和抽象成员建立一个进入类层次结构的多态接口,以及显式转换的角色。
本章将通过研究。NET 基础类库:System.Object
。
理解继承的基本机制
回想一下第五章中的内容,继承是 OOP 的一个方面,有助于代码重用。具体来说,代码重用有两种风格:继承(“is-a”关系)和包容/委托模型(“has-a”关系)。让我们从检查“是-a”关系的经典继承模型开始这一章。
当您在类之间建立“is-a”关系时,您正在构建两个或更多类类型之间的依赖关系。经典继承背后的基本思想是,可以使用现有的类作为起点来创建新的类。从一个简单的例子开始,创建一个名为BasicInheritance.
的新控制台应用项目。现在假设您已经设计了一个名为Car
的类,它模拟了汽车的一些基本细节。
namespace BasicInheritance
{
// A simple base class.
class Car
{
public readonly int MaxSpeed;
private int _currSpeed;
public Car(int max)
{
MaxSpeed = max;
}
public Car()
{
MaxSpeed = 55;
}
public int Speed
{
get { return _currSpeed; }
set
{
_currSpeed = value;
if (_currSpeed > MaxSpeed)
{
_currSpeed = MaxSpeed;
}
}
}
}
}
注意,Car
类使用封装服务来控制对私有currSpeed
字段的访问,该字段使用一个名为Speed
的公共属性。此时,你可以锻炼你的Car
类型如下:
using System;
using BasicInheritance;
Console.WriteLine("***** Basic Inheritance *****\n");
// Make a Car object, set max speed and current speed.
Car myCar = new Car(80) {Speed = 50};
// Print current speed.
Console.WriteLine("My car is going {0} MPH", myCar.Speed);
Console.ReadLine();
指定现有类的父类
现在假设您想要构建一个名为MiniVan
的新类。像基本的Car
一样,您希望定义MiniVan
类来支持最大速度、当前速度和名为Speed
的属性的数据,以允许对象用户修改对象的状态。显然,Car
和MiniVan
类是相关的;其实可以这么说,aMiniVan
就是-a 型的Car
。“is-a”关系(正式名称为经典继承)允许您构建扩展现有类功能的新类定义。
将作为新类基础的现有类被称为基类、超类或父类。基类的作用是为扩展它的类定义所有公共数据和成员。扩展类被正式称为派生或子类。在 C# 中,在类定义上使用冒号操作符来建立类之间的“is-a”关系。假设您已经编写了以下新的MiniVan
类:
namespace BasicInheritance
{
// MiniVan "is-a" Car.
sealed class MiniVan : Car
{
}
}
目前,这个新类还没有定义任何成员。那么,从Car
基类扩展MiniVan
你得到了什么?简单地说,MiniVan
对象现在可以访问父类中定义的每个公共成员。
Note
尽管构造函数通常被定义为公共的,但派生类从不继承父类的构造函数。构造函数仅用于构造定义它们的类,尽管派生类可以通过构造函数链调用它们。这将很快涉及到。
给定这两个类类型之间的关系,您现在可以像这样使用MiniVan
类:
Console.WriteLine("***** Basic Inheritance *****\n");
.
// Now make a MiniVan object.
MiniVan myVan = new MiniVan {Speed = 10};
Console.WriteLine("My van is going {0} MPH", myVan.Speed);
Console.ReadLine();
同样,请注意,尽管您没有向MiniVan
类添加任何成员,但是您可以直接访问父类的公共Speed
属性,因此可以重用代码。这比创建一个与Car
有相同成员的MiniVan
类,比如一个Speed
属性,要好得多。如果您确实在这两个类之间复制了代码,那么您现在需要维护两个代码体,这无疑是对您时间的浪费。
永远记住,继承保持封装;因此,下面的代码会导致编译器错误,因为私有成员永远不能从对象引用中访问:
Console.WriteLine("***** Basic Inheritance *****\n");
...
// Make a MiniVan object.
MiniVan myVan = new MiniVan();
myVan.Speed = 10;
Console.WriteLine("My van is going {0} MPH",
myVan.Speed);
// Error! Can't access private members!
myVan._currSpeed = 55;
Console.ReadLine();
与此相关,如果MiniVan
定义了自己的成员集,它仍然不能访问Car
基类的任何私有成员。记住,私有成员只能被定义它的类访问*。例如,MiniVan
中的以下方法会导致编译器错误:*
// MiniVan derives from Car.
class MiniVan : Car
{
public void TestMethod()
{
// OK! Can access public members
// of a parent within a derived type.
Speed = 10;
// Error! Cannot access private
// members of parent within a derived type.
_currSpeed = 10;
}
}
关于多个基类
说到基类,重要的是要记住 C# 要求一个给定的类只有一个直接基类。不可能创建直接从两个或更多基类派生的类类型(这种技术在非托管 C++中受支持,被称为多重继承,或简称为 MI )。如果您试图创建一个指定两个直接父类的类,如下面的代码所示,您将收到编译器错误:
// Illegal! C# does not allow
// multiple inheritance for classes!
class WontWork
: BaseClassOne, BaseClassTwo
{}
正如您将在第八章中看到的。NET 核心平台允许给定的类或结构实现任意数量的离散接口。通过这种方式,C# 类型可以展示许多行为,同时避免与 MI 相关的复杂性。使用这种技术,你可以构建复杂的接口层次来模拟复杂的行为(同样,参见第八章)。
使用 sealed 关键字
C# 提供了另一个关键字sealed
,它阻止了继承的发生。当你将一个类标记为sealed
时,编译器不允许你从这个类型派生。例如,假设您已经决定进一步扩展MiniVan
类是没有意义的。
// The MiniVan class cannot be extended!
sealed class MiniVan : Car
{
}
如果您(或您的队友)试图从这个类派生,您将会收到一个编译时错误。
// Error! Cannot extend
// a class marked with the sealed keyword!
class DeluxeMiniVan
: MiniVan
{
}
大多数情况下,在设计实用程序类时,密封一个类是最有意义的。例如,System
名称空间定义了许多密封类,比如String
类。因此,就像MiniVan
一样,如果您试图构建一个扩展了System.String
的新类,您将会收到一个编译时错误。
// Another error! Cannot extend
// a class marked as sealed!
class MyString
: String
{
}
Note
在第四章中,你学到了 C# 结构总是隐式密封的(见表 4-3 )。因此,您永远不能从另一个结构派生一个结构,从一个结构派生一个类,或者从一个类派生一个结构。结构只能用于建模独立的、原子的、用户定义的数据类型。如果你想利用“是-a”关系,你必须使用类。
正如您所猜测的,在本章的剩余部分,您将会了解到更多关于继承的细节。现在,只要记住冒号操作符允许您建立基类/派生类关系,而sealed
关键字防止后续继承发生。
重温 Visual Studio 类图
在第二章中,我简要提到了 Visual Studio 允许你在设计时可视化地建立基类/派生类关系。为了利用 IDE 的这一方面,第一步是在当前项目中包含一个新的类图文件。为此,访问项目➤添加新项菜单选项,并单击类图图标(在图 6-1 ,我将文件从ClassDiagram1.cd
重命名为Cars.cd
)。
图 6-1。
插入新的类图
单击“添加”按钮后,您将看到一个空白的设计器图面。若要向类设计器添加类型,只需将每个文件从解决方案资源管理器窗口拖到图面上。还记得,如果您从可视化设计器中删除一个项(只需选择它并按 delete 键),这不会破坏关联的源代码,而只是将该项从设计器图面中移除。图 6-2 显示了当前的等级结构。
图 6-2。
Visual Studio 的视觉设计器
除了简单地显示当前应用中类型的关系之外,回想一下第二章中的内容,您还可以使用类设计器工具箱和类细节窗口创建新类型并填充它们的成员。
如果您想在本书的剩余部分使用这些可视化工具,请随意。但是,一定要确保您分析了生成的代码,以便您对这些工具为您做了什么有一个坚实的理解。
理解 OOP 的第二个支柱:继承的细节
既然您已经看到了继承的基本语法,让我们创建一个更复杂的例子,并了解构建类层次结构的众多细节。为此,你将重用你在第五章中设计的Employee
类。首先,创建一个名为 Employees 的新 C# 控制台应用项目。
接下来,将您在第五章的 EmployeeApp 示例中创建的Employee.cs
、Employee.Core.cs
和EmployeePayTypeEnum.cs
文件复制到 Employees 项目中。
Note
之前。NET Core 中,需要在.csproj
文件中引用的文件才能在 C# 项目中使用它们。与。NET Core 中,当前目录结构中的所有文件都会自动包含到您的项目中。只需将这两个文件从另一个项目复制到当前项目目录中,就足以将它们包含在您的项目中。
在开始构建一些派生类之前,有两个细节需要注意。因为最初的Employee
类是在一个名为 EmployeeApp 的项目中创建的,所以该类被包装在一个同名的。NET 核心命名空间。第十六章将详细考察名称空间;然而,为了简单起见,将当前名称空间(在所有三个文件位置中)重命名为Employees
,以匹配您的新项目名称。
// Be sure to change the namespace name in both C# files!
namespace Employees
{
partial class Employee
{...}
}
Note
如果你在第五章中修改Employee
类的时候移除了默认构造函数,确保把它添加回类中。
第二个细节是从章节 5 示例的Employee
类的不同迭代中移除任何注释代码。
Note
作为健全性检查,编译并运行您的新项目,方法是在命令提示符下(在您的项目目录中)输入dotnet run
,或者如果您使用的是 Visual Studio,则按 Ctrl+F5。程序此时不会做任何事情;但是,这将确保您没有任何编译器错误。
您的目标是创建一系列类来模拟公司中各种类型的员工。假设您想要利用Employee
类的功能来创建两个新类(SalesPerson
和Manager
)。新的SalesPerson
级“is-an”Employee
(as is aManager
)。请记住,在经典继承模型下,基类(如Employee
)用于定义所有后代共有的一般特征。子类(比如SalesPerson
和Manager
)扩展了这个通用功能,同时增加了更多的特定功能。
对于您的示例,您将假设Manager
类通过记录股票期权的数量来扩展Employee
,而SalesPerson
类维护销售的数量。插入一个新的类文件(Manager.cs
),该文件用以下自动属性定义了Manager
类:
// Managers need to know their number of stock options.
class Manager : Employee
{
public int StockOptions { get; set; }
}
接下来,添加另一个新的类文件(SalesPerson.cs
),该文件使用拟合自动属性定义了SalesPerson
类。
// Salespeople need to know their number of sales.
class SalesPerson : Employee
{
public int SalesNumber { get; set; }
}
既然已经建立了“是-a”关系,SalesPerson
和Manager
已经自动继承了Employee
基类的所有公共成员。举例来说,按如下方式更新顶级语句:
// Create a subclass object and access base class functionality.
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
SalesPerson fred = new SalesPerson
{
Age = 31, Name = "Fred", SalesNumber = 50
};
用 Base 关键字调用基类构造函数
目前,SalesPerson
和Manager
只能使用“免费”的默认构造函数来创建(参见第五章)。记住这一点,假设您已经向Manager
类型添加了一个新的七参数构造函数,调用如下:
...
// Assume Manager has a constructor matching this signature:
// (string fullName, int age, int empId,
// float currPay, string ssn, int numbOfOpts)
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
如果你看一下参数列表,你可以清楚地看到这些参数大部分应该存储在由Employee
基类定义的成员变量中。为此,您可以在Manager
类上实现这个自定义构造函数,如下所示:
public Manager(string fullName, int age, int empId,
float currPay, string ssn, int numbOfOpts)
{
// This property is defined by the Manager class.
StockOptions = numbOfOpts;
// Assign incoming parameters using the
// inherited properties of the parent class.
Id = empId;
Age = age;
Name = fullName;
Pay = currPay;
PayType = EmployeePayTypeEnum.Salaried;
// OOPS! This would be a compiler error,
// if the SSN property were read-only!
SocialSecurityNumber = ssn;
}
这种方法的第一个问题是,如果您将任何属性定义为只读(例如,SocialSecurityNumber
属性),您就不能将传入的string
参数赋给这个字段,如这个自定义构造函数的最终代码语句所示。
第二个问题是,你已经间接地创建了一个相当低效的构造函数,假设在 C# 下,除非你另有说明,否则基类的默认构造函数是在派生构造函数的逻辑被执行之前自动调用的。在这之后,当前的实现访问Employee
基类的许多公共属性来建立它的状态。因此,在创建一个Manager
对象的过程中,您实际上已经完成了八次点击(六次继承属性和两次构造函数调用)!
为了帮助优化派生类的创建,最好实现子类构造函数来显式调用适当的自定义基类构造函数,而不是默认构造函数。这样,您就能够减少对继承的初始化成员的调用次数(从而节省处理时间)。首先,确保您的Employee
父类具有以下六个参数的构造函数:
// Add to the Employee base class.
public Employee(string name, int age, int id, float pay, string empSsn, EmployeePayTypeEnum payType)
{
Name = name;
Id = id;
Age = age;
Pay = pay;
SocialSecurityNumber = empSsn;
PayType = payType;
}
现在,让我们改进Manager
类型的定制构造函数,使用base
关键字调用这个构造函数。
public Manager(string fullName, int age, int empId,
float currPay, string ssn, int numbOfOpts)
: base(fullName, age, empId, currPay, ssn,
EmployeePayTypeEnum.Salaried)
{
// This property is defined by the Manager class.
StockOptions = numbOfOpts;
}
这里,base
关键字挂在构造函数签名上(很像在第五章中讨论的使用this
关键字链接单个类上的构造函数的语法),它总是指示派生构造函数正在将数据传递给直接的父构造函数。在这种情况下,您显式地调用了由Employee
定义的六参数构造函数,并在创建子类的过程中节省了不必要的调用。此外,您向Manager
类添加了一个特定的行为,因为支付类型总是被设置为Salaried.
,自定义的SalesPerson
构造函数看起来几乎相同,除了支付类型被设置为Commission.
// As a general rule, all subclasses should explicitly call an appropriate
// base class constructor.
public SalesPerson(string fullName, int age, int empId,
float currPay, string ssn, int numbOfSales)
: base(fullName, age, empId, currPay, ssn,
EmployeePayTypeEnum.Commission)
{
// This belongs with us!
SalesNumber = numbOfSales;
}
Note
每当子类想要访问由父类定义的公共或受保护成员时,可以使用base
关键字。此关键字的使用不限于构造函数逻辑。在本章后面的多态性检查中,你会看到以这种方式使用base
的例子。
最后,回想一下,一旦您将自定义构造函数添加到类定义中,默认构造函数就会被自动移除。因此,一定要为SalesPerson
和Manager
类型重新定义默认构造函数。这里有一个例子:
// Add back the default ctor
// in the Manager class as well.
public SalesPerson() {}
保守家庭秘密:受保护的关键字
正如您已经知道的,公共项可以从任何地方直接访问,而私有项只能由定义它们的类访问。回想一下第五章中的内容,C# 领先于许多其他现代对象语言,并提供了一个额外的关键字来定义成员可访问性:protected
。
当基类定义受保护的数据或受保护的成员时,它建立了一组可以被任何后代直接访问的项。如果你想让SalesPerson
和Manager
子类直接访问由Employee
定义的数据扇区,你可以如下更新原始的Employee
类定义(在EmployeeCore.cs
文件中):
// Protected state data.
partial class Employee
{
// Derived classes can now directly access this information.
protected string EmpName;
protected int EmpId;
protected float CurrPay;
protected int EmpAge;
protected string EmpSsn;
protected EmployeePayTypeEnum EmpPayType;...
}
Note
约定是受保护的成员被命名为 Pascal-Case(EmpName
)而不是下划线-Camel-Case ( _empName
)。这不是语言的要求,而是一种常见的代码风格。如果您决定像我在这里所做的那样更新名称,请确保重命名属性中的所有支持方法,以匹配 Pascal 大小写受保护的属性。
在基类中定义受保护成员的好处是,派生类型不再需要使用公共方法或属性间接访问数据。当然,可能的问题是,当派生类型可以直接访问其父类型的内部数据时,就有可能意外地绕过公共属性中的现有业务规则。当您定义受保护成员时,您在父类和子类之间创建了一个信任级别,因为编译器不会捕捉到任何违反您的类型的业务规则的情况。
最后,请理解,就对象用户而言,受保护的数据被视为私有数据(因为用户“不属于”家庭)。因此,以下行为是非法的:
// Error! Can't access protected data from client code.
Employee emp = new Employee();
emp.empName = "Fred";
Note
虽然protected
字段数据可以打破封装,但是定义protected
方法是非常安全的(也是非常有用的)。在构建类层次结构时,通常定义一组只供派生类型使用的方法,而不是供外界使用的方法。
添加密封类
回想一下,一个密封的类不能被其他类扩展。如上所述,这种技术最常用于设计实用程序类。然而,当构建类层次结构时,您可能会发现继承链中的某个分支应该被“封顶”,因为进一步扩展血统是没有意义的。例如,假设您已经向您的程序(PtSalesPerson
)添加了另一个类,它扩展了现有的SalesPerson
类型。图 6-3 显示了当前的更新。
图 6-3。
PtSalesPerson 类
是一个代表兼职销售人员的类。为了便于讨论,假设您希望确保没有其他开发人员能够从PTSalesPerson
继承子类。为了防止其他人扩展一个类,使用sealed
关键字。
sealed class PtSalesPerson : SalesPerson
{
public PtSalesPerson(string fullName, int age, int empId,
float currPay, string ssn, int numbOfSales)
: base(fullName, age, empId, currPay, ssn, numbOfSales)
{
}
// Assume other members here...
}
了解记录类型的继承(新 9.0)
新的 C# 9.0 记录类型也支持继承。要探索这一点,请暂停 Employees 项目中的工作,并创建一个名为 RecordInheritance 的新控制台应用。添加两个名为Car.cs
和MiniVan.cs,
的新文件,并将以下记录定义代码添加到各自的文件中:
//Car.cs
namespace RecordInheritance
{
//Car record type
public record Car
{
public string Make { get; init; }
public string Model { get; init; }
public string Color { get; init; }
public Car(string make, string model, string color)
{
Make = make;
Model = model;
Color = color;
}
}
}
//MiniVan.cs
namespace RecordInheritance
{
//MiniVan record type
public sealed record MiniVan : Car
{
public int Seating { get; init; }
public MiniVan(string make, string model, string color, int seating) : base(make, model, color)
{
Seating = seating;
}
}
}
注意,这些使用记录类型的例子和前面使用类的例子没有太大的区别。属性和方法上的受保护访问修饰符行为相同,记录类型上的密封访问修饰符防止其他记录类型从密封记录类型派生。您还会发现本章的其余主题也与继承的记录类型有关。这是因为记录类型只是不可变类的一种特殊类型(详见第五章)。
记录类型还包括对其基类的隐式转换,如下面的代码所示:
using System;
using RecordInheritance;
Console.WriteLine("Record type inheritance!");
Car c = new Car("Honda","Pilot","Blue");
MiniVan m = new MiniVan("Honda", "Pilot", "Blue",10);
Console.WriteLine($"Checking MiniVan is-a Car:{m is Car}");
正如所料,检查m
的输出是Car
返回 true,如下面的输出所示:
Record type inheritance!
Checking minvan is-a car:True
重要的是要注意,即使记录类型是专门的类,也不能在类和记录之间交叉继承。明确地说,类不能从记录类型继承,记录类型也不能从类继承。考虑下面的代码,注意最后两个例子不能编译:
namespace RecordInheritance
{
public class TestClass { }
public record TestRecord { }
//Classes cannot inherit records
// public class Test2 : TestRecord { }
//Records types cannot inherit from classes
// public record Test2 : TestClass { }
}
继承也适用于位置记录类型。在项目中创建一个名为PositionalRecordTypes.cs
的新文件。将以下代码添加到您的文件中:
namespace RecordInheritance
{
public record PositionalCar (string Make, string Model, string Color);
public record PositionalMiniVan (string Make, string Model, string Color)
: PositionalCar(Make, Model, Color);
}
添加以下代码,以说明您已经知道的事实,即位置记录类型的工作方式与记录类型完全相同:
PositionalCar pc = new PositionalCar("Honda", "Pilot", "Blue");
PositionalMiniVan pm = new PositionalMiniVan("Honda", "Pilot", "Blue", 10);
Console.WriteLine($"Checking PositionalMiniVan is-a PositionalCar:{pm is PositionalCar}");
与继承的记录类型相等
回想一下第五章,记录类型使用值语义来确定相等性。关于记录类型的另一个细节是记录的类型是平等考虑的一部分。例如,考虑以下平凡的例子:
public record MotorCycle(string Make, string Model);
public record Scooter(string Make, string Model) : MotorCycle(Make,Model);
忽略通常继承的类扩展基类的事实,这些简单的例子定义了具有相同属性的两种不同的记录类型。当创建属性值相同的实例时,由于类型不同,它们无法通过相等性测试。以下面的代码和结果为例:
MotorCycle mc = new MotorCycle("Harley","Lowrider");
Scooter sc = new Scooter("Harley", "Lowrider");
Console.WriteLine($"MotorCycle and Scooter are equal: {Equals(mc,sc)}");
Record type inheritance!
MotorCycle and Scooter are equal: False
包容/委托的编程
回想一下,代码重用有两种形式。你刚刚探索了经典的“是”的关系。在检查 OOP 的第三个支柱(多态性)之前,让我们检查一下“has-a”关系(也称为包容/委托模型或聚合)。返回到 Employees 项目,创建一个名为BenefitPackage.cs
的新文件,并添加代码来模拟雇员福利包,如下所示:
namespace Employees
{
// This new type will function as a contained class.
class BenefitPackage
{
// Assume we have other members that represent
// dental/health benefits, and so on.
public double ComputePayDeduction()
{
return 125.0;
}
}
}
显然,在BenefitPackage
类和雇员类型之间建立“is-a”关系是很奇怪的。(Employee
“is-a”BenefitPackage
?我不这么认为。)然而,应该清楚的是,可以在两者之间建立某种关系。简而言之,你想表达的想法是,每个员工都“有-a”BenefitPackage
。为此,您可以如下更新Employee
类定义:
// Employees now have benefits.
partial class Employee
{
// Contain a BenefitPackage object.
protected BenefitPackage EmpBenefits = new BenefitPackage();
...
}
至此,您已经成功地包含了另一个对象。但是,向外界公开所包含对象的功能需要委托。委托是简单地将公共成员添加到使用被包含对象功能的包含类的行为。
例如,您可以更新Employee
类,使用自定义属性公开包含的empBenefits
对象,并使用名为GetBenefitCost()
的新方法在内部使用其功能。
partial class Employee
{
// Contain a BenefitPackage object.
protected BenefitPackage EmpBenefits = new BenefitPackage();
// Expose certain benefit behaviors of object.
public double GetBenefitCost()
=> EmpBenefits.ComputePayDeduction();
// Expose object through a custom property.
public BenefitPackage Benefits
{
get { return EmpBenefits; }
set { EmpBenefits = value; }
}
}
在下面更新的code
中,注意如何与由Employee
类型定义的内部BenefitsPackage
类型交互:
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
...
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
double cost = chucky.GetBenefitCost();
Console.WriteLine($"Benefit Cost: {cost}");
Console.ReadLine();
了解嵌套类型定义
第五章简要提到了嵌套类型的概念,这是对你刚刚检查过的“has-a”关系的一个改进。在 C#(以及其他。NET 语言),可以直接在类或结构的范围内定义类型(枚举、类、接口、结构或委托)。当您这样做时,嵌套(或“内部”)类型被视为嵌套(或“外部”)类的成员,并且在运行时看来,可以像任何其他成员(字段、属性、方法和事件)一样进行操作。用于嵌套类型的语法非常简单。
public class OuterClass
{
// A public nested type can be used by anybody.
public class PublicInnerClass {}
// A private nested type can only be used by members
// of the containing class.
private class PrivateInnerClass {}
}
尽管语法相当清楚,但理解您为什么想要这样做可能并不容易。要理解这种技术,请思考嵌套类型的以下特征:
-
嵌套类型允许您完全控制内部类型的访问级别,因为它们可以被私有声明(回想一下,非嵌套类不能使用
private
关键字声明)。 -
因为嵌套类型是包含类的成员,所以它可以访问包含类的私有成员。
-
通常,嵌套类型只在作为外部类的助手时有用,并不打算供外部世界使用。
当一个类型嵌套另一个类类型时,它可以创建该类型的成员变量,就像对任何数据点一样。但是,如果要使用包含类型之外的嵌套类型,必须用嵌套类型的范围来限定它。考虑以下代码:
// Create and use the public inner class. OK!
OuterClass.PublicInnerClass inner;
inner = new OuterClass.PublicInnerClass();
// Compiler Error! Cannot access the private class.
OuterClass.PrivateInnerClass inner2;
inner2 = new OuterClass.PrivateInnerClass();
为了在雇员的例子中使用这个概念,假设您现在已经将BenefitPackage
直接嵌套在了Employee
类类型中。
partial class Employee
{
public class BenefitPackage
{
// Assume we have other members that represent
// dental/health benefits, and so on.
public double ComputePayDeduction()
{
return 125.0;
}
}
...
}
嵌套过程可以像你要求的那样“深”。例如,假设您想要创建一个名为BenefitPackageLevel
的枚举,它记录了员工可能选择的各种福利级别。为了以编程方式强制Employee
、BenefitPackage
和BenefitPackageLevel
之间的紧密连接,可以如下嵌套枚举:
// Employee nests BenefitPackage.
public partial class Employee
{
// BenefitPackage nests BenefitPackageLevel.
public class BenefitPackage
{
public enum BenefitPackageLevel
{
Standard, Gold, Platinum
}
public double ComputePayDeduction()
{
return 125.0;
}
}
...
}
由于嵌套关系,请注意如何要求您使用此枚举:
...
// Define my benefit level.
Employee.BenefitPackage.BenefitPackageLevel myBenefitLevel =
Employee.BenefitPackage.BenefitPackageLevel.Platinum;
太棒了!至此,您已经接触了许多关键字(和概念),它们允许您通过传统的继承、包容和嵌套类型来构建相关类型的层次结构。如果细节现在还不清楚,不要担心。在本书的剩余部分,您将构建一些额外的层次结构。接下来,让我们检查 OOP 的最后一个支柱:多态性。
理解 OOP 的第三个支柱:C# 的多态支持
回想一下,Employee
基类定义了一个名为GiveBonus()
的方法,最初实现如下(在更新它以使用属性模式之前):
public partial class Employee
{
public void GiveBonus(float amount) => _currPay += amount;
...
}
因为这个方法是用public
关键字定义的,所以现在可以给销售人员和经理(以及兼职销售人员)发放奖金。
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
// Give each employee a bonus?
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
chucky.GiveBonus(300);
chucky.DisplayStats();
Console.WriteLine();
SalesPerson fran = new SalesPerson("Fran", 43, 93, 3000, "932-32-3232", 31);
fran.GiveBonus(200);
fran.DisplayStats();
Console.ReadLine();
当前设计的问题是,公共继承的GiveBonus()
方法对所有子类的操作都是一样的。理想情况下,销售人员或兼职销售人员的奖金应该考虑销售数量。或许经理们应该获得额外的股票期权,同时增加工资。考虑到这一点,您突然面临一个有趣的问题:“相关类型如何对同一请求做出不同的响应?”再次,很高兴你问了!
使用虚拟和覆盖关键字
多态性为子类提供了一种方法,通过使用称为方法覆盖的过程,来定义其基类定义的方法的自己的版本。要改进您当前的设计,您需要理解virtual
和override
关键字的含义。如果一个基类想要定义一个方法,使得可以被子类(但不是必须)覆盖,它必须用virtual
关键字标记这个方法。
partial class Employee
{
// This method can now be "overridden" by a derived class.
public virtual void GiveBonus(float amount)
{
Pay += amount;
}
...
}
Note
标有virtual
关键字的方法(不足为奇)被称为虚拟方法。
当子类想要改变虚拟方法的实现细节时,它使用关键字override
来实现。例如,SalesPerson
和Manager
可以如下覆盖GiveBonus()
(假设PTSalesPerson
不会覆盖GiveBonus()
,因此,简单地继承由SalesPerson
定义的版本):
using System;
class SalesPerson : Employee
{
...
// A salesperson's bonus is influenced by the number of sales.
public override void GiveBonus(float amount)
{
int salesBonus = 0;
if (SalesNumber >= 0 && SalesNumber <= 100)
salesBonus = 10;
else
{
if (SalesNumber >= 101 && SalesNumber <= 200)
salesBonus = 15;
else
salesBonus = 20;
}
base.GiveBonus(amount * salesBonus);
}
}
class Manager : Employee
{
...
public override void GiveBonus(float amount)
{
base.GiveBonus(amount);
Random r = new Random();
StockOptions += r.Next(500);
}
}
注意每个被覆盖的方法是如何使用base
关键字自由利用默认行为的。
这样,您不需要完全重新实现GiveBonus()
背后的逻辑,而是可以重用(并且可能扩展)父类的默认行为。
还假设Employee
类的当前DisplayStats()
方法已经被虚拟声明。
public virtual void DisplayStats()
{
Console.WriteLine("Name: {0}", Name);
Console.WriteLine("Id: {0}", Id);
Console.WriteLine("Age: {0}", Age);
Console.WriteLine("Pay: {0}", Pay);
Console.WriteLine("SSN: {0}", SocialSecurityNumber);
}
通过这样做,每个子类都可以覆盖这个方法来显示销售额(对于销售人员)和当前股票期权(对于经理)。例如,考虑一下Manager
的DisplayStats()
方法版本(SalesPerson
类将以类似的方式实现DisplayStats()
来显示销售额)。
//Manager.cs
public override void DisplayStats()
{
base.DisplayStats();
Console.WriteLine("Number of Stock Options: {0}", StockOptions);
}
//SalesPerson.cs
public override void DisplayStats()
{
base.DisplayStats();
Console.WriteLine("Number of Sales: {0}", SalesNumber);
}
现在每个子类都可以解释这些虚方法对自己的意义,每个对象实例都表现为一个更加独立的实体。
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
// A better bonus system!
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
chucky.GiveBonus(300);
chucky.DisplayStats();
Console.WriteLine();
SalesPerson fran = new SalesPerson("Fran", 43, 93, 3000, "932-32-3232", 31);
fran.GiveBonus(200);
fran.DisplayStats();
Console.ReadLine();
以下输出显示了到目前为止您的应用可能的测试运行:
***** The Employee Class Hierarchy *****
Name: Chucky
ID: 92
Age: 50
Pay: 100300
SSN: 333-23-2322
Number of Stock Options: 9337
Name: Fran
ID: 93
Age: 43
Pay: 5000
SSN: 932-32-3232
Number of Sales: 31
用 Visual Studio/Visual Studio 代码重写虚拟成员
您可能已经注意到,当您重写一个成员时,您必须回忆每个参数的类型——更不用说方法名和参数传递约定(ref
、out
和params
)。Visual Studio 和 Visual Studio 代码都有一个有用的功能,您可以在重写虚拟成员时加以利用。如果在类类型的范围内键入单词override
(然后按空格键),IntelliSense 将自动显示在父类中定义的所有可重写成员的列表,不包括已经被重写的方法。
当您选择一个成员并按下 Enter 键时,IDE 会自动为您填充方法存根。请注意,您还会收到一条代码语句,该语句调用您的父版本的虚拟成员(如果不需要,您可以随意删除这一行)。例如,如果您在重写DisplayStats()
方法时使用了这种技术,您可能会发现以下自动生成的代码:
public override void DisplayStats()
{
base.DisplayStats();
}
密封虚拟成员
回想一下,sealed
关键字可以应用于一个类类型,以防止其他类型通过继承来扩展它的行为。您可能还记得,您密封了PtSalesPerson
,因为您认为其他开发人员进一步扩展这条继承线是没有意义的。
另一方面,有时您可能不想密封整个类,而只想防止派生类型重写特定的虚方法。例如,假设您不希望兼职销售人员获得定制的奖金。为了防止PTSalesPerson
类覆盖虚拟的GiveBonus()
方法,您可以有效地将该方法密封在SalesPerson
类中,如下所示:
// SalesPerson has sealed the GiveBonus() method!
class SalesPerson : Employee
{
...
public override sealed void GiveBonus(float amount)
{
...
}
}
这里,SalesPerson
确实覆盖了在Employee
类中定义的虚拟GiveBonus()
方法;但是,它已经明确标记为密封。因此,如果您试图在PtSalesPerson
类中覆盖此方法,您将会收到编译时错误,如以下代码所示:
sealed class PTSalesPerson : SalesPerson
{
...
// Compiler error! Can't override this method
// in the PTSalesPerson class, as it was sealed.
public override void GiveBonus(float amount)
{
}
}
理解抽象类
目前,Employee
基类已经被设计为向它的后代提供各种数据成员,以及提供两个可能被给定后代覆盖的虚方法(GiveBonus()
和DisplayStats()
)。虽然这一切都很好,但目前的设计有一个相当奇怪的副产品;您可以直接创建Employee
基类的实例。
// What exactly does this mean?
Employee X = new Employee();
在这个例子中,Employee
基类的唯一真正目的是为所有子类定义公共成员。十有八九,你不希望任何人创建这个类的直接实例,原因是Employee
类型本身是一个过于一般化的概念。例如,如果我走到你面前说“我是一名员工”,我敢打赌你的第一个问题会是“你是哪种员工?你是顾问、培训师、行政助理、文字编辑还是白宫助理?”
鉴于许多基类往往是相当模糊的实体,对于这个例子来说,更好的设计是防止在代码中直接创建新的Employee
对象。在 C# 中,您可以通过在类定义中使用abstract
关键字来以编程方式强制实现这一点,从而创建一个抽象基类。
// Update the Employee class as abstract
// to prevent direct instantiation.
abstract partial class Employee
{
...
}
这样,如果您现在试图创建一个Employee
类的实例,就会出现一个编译时错误。
// Error! Cannot create an instance of an abstract class!
Employee X = new Employee();
乍一看,定义一个不能直接创建实例的类似乎很奇怪。然而,回想一下,基类(抽象或非抽象)是有用的,因为它们包含了派生类型的所有公共数据和功能。使用这种形式的抽象,你能够模拟一个雇员的“想法”是完全有效的;它只是不是一个具体的实体。还要明白,虽然你不能直接创建一个抽象类的实例,但是当派生类被创建时,它仍然在内存中被组装。因此,当派生类被分配时,抽象类定义任意数量的被间接调用的构造函数是非常好的(也是常见的)。**
至此,您已经构建了一个相当有趣的员工层次结构。在本章的后面,当你研究 C# 转换规则的时候,你会给这个应用添加更多的功能。在此之前,图 6-4 说明了您当前设计的症结所在。
图 6-4。
员工层级
理解多态接口
当一个类被定义为抽象基类时(通过abstract
关键字),它可以定义任意数量的抽象成员。当你想定义一个不提供默认实现,但是必须由每个派生类负责的成员时,可以使用抽象成员。通过这样做,您在每个后代上实施了一个多态接口,让他们去处理提供抽象方法背后的细节的任务。
简单来说,抽象基类的多态接口只是指它的一组虚拟和抽象方法。这比第一眼看到的要有趣得多,因为 OOP 的这一特性允许您构建易于扩展和灵活的软件应用。举例来说,在 OOP 支柱概述中,你将实现(并稍微修改)第五章中简要介绍的形状层次。首先,创建一个名为 Shapes 的新 C# 控制台应用项目。
在图 6-5 中,注意到Hexagon
和Circle
类型都扩展了Shape
基类。像任何基类一样,Shape
定义了许多成员(在本例中是一个PetName
属性和一个Draw()
方法),这些成员是所有后代共有的。
图 6-5。
形状层次结构
与雇员层次结构非常相似,您应该能够判断出您不希望允许对象用户直接创建Shape
的实例,因为它是一个太抽象的概念。同样,为了防止直接创建Shape
类型,您可以将其定义为一个抽象类。同样,假设您希望派生类型唯一地响应Draw()
方法,让我们将其标记为virtual
并定义一个默认实现。请注意,构造函数被标记为 protected,因此它只能从派生类中调用。
// The abstract base class of the hierarchy.
abstract class Shape
{
protected Shape(string name = "NoName")
{ PetName = name; }
public string PetName { get; set; }
// A single virtual method.
public virtual void Draw()
{
Console.WriteLine("Inside Shape.Draw()");
}
}
注意,虚拟的Draw()
方法提供了一个默认的实现,它只是打印出一条消息,通知您正在调用Shape
基类中的Draw()
方法。现在回想一下,当一个方法用virtual
关键字标记时,该方法提供了一个所有派生类型自动继承的默认实现。如果子类这样选择,它可以覆盖该方法,但是没有来覆盖该方法。鉴于此,考虑下面的Circle
和Hexagon
类型的实现:
// Circle DOES NOT override Draw().
class Circle : Shape
{
public Circle() {}
public Circle(string name) : base(name){}
}
// Hexagon DOES override Draw().
class Hexagon : Shape
{
public Hexagon() {}
public Hexagon(string name) : base(name){}
public override void Draw()
{
Console.WriteLine("Drawing {0} the Hexagon", PetName);
}
}
当你再次记住子类从来不需要覆盖虚方法时,抽象方法的用处就变得非常清楚了(就像在Circle
的例子中一样)。因此,如果您创建一个Hexagon
和Circle
类型的实例,您会发现Hexagon
知道如何正确地“绘制”自己,或者至少向控制台输出一条适当的消息。然而Circle
却不止是有点困惑。
Console.WriteLine("***** Fun with Polymorphism *****\n");
Hexagon hex = new Hexagon("Beth");
hex.Draw();
Circle cir = new Circle("Cindy");
// Calls base class implementation!
cir.Draw();
Console.ReadLine();
现在考虑前面代码的以下输出:
***** Fun with Polymorphism *****
Drawing Beth the Hexagon
Inside Shape.Draw()
很明显,对于当前的等级制度来说,这不是一个明智的设计。为了强制每个子类覆盖Draw()
方法,您可以将Draw()
定义为Shape
类的一个抽象方法,根据定义,这意味着您不提供任何默认实现。在 C# 中,要将一个方法标记为抽象的,可以使用abstract
关键字。请注意,抽象成员不提供任何实现。
abstract class Shape
{
// Force all child classes to define how to be rendered.
public abstract void Draw();
...
}
Note
抽象方法只能在抽象类中定义。如果您尝试不这样做,您将被发出一个编译器错误。
标有abstract
的方法是纯协议。它们只是定义名称、返回类型(如果有的话)和参数集(如果需要的话)。这里,抽象的Shape
类通知派生的类型“我有一个名为Draw()
的方法,它没有参数,也不返回任何东西。如果你从我这里得到,你就能弄清楚细节。”
鉴于此,您现在有义务在Circle
类中覆盖Draw()
方法。如果不这样做,Circle
也被认为是一个不可创建的抽象类型,必须用abstract
关键字来修饰(这在本例中显然没有用)。下面是代码更新:
// If we did not implement the abstract Draw() method, Circle would also be
// considered abstract, and would have to be marked abstract!
class Circle : Shape
{
public Circle() {}
public Circle(string name) : base(name) {}
public override void Draw()
{
Console.WriteLine("Drawing {0} the Circle", PetName);
}
}
简而言之,你现在可以假设从Shape
派生的任何东西确实有一个唯一版本的Draw()
方法。为了说明多态性的全部情况,考虑下面的代码:
Console.WriteLine("***** Fun with Polymorphism *****\n");
// Make an array of Shape-compatible objects.
Shape[] myShapes = {new Hexagon(), new Circle(), new Hexagon("Mick"),
new Circle("Beth"), new Hexagon("Linda")};
// Loop over each item and interact with the
// polymorphic interface.
foreach (Shape s in myShapes)
{
s.Draw();
}
Console.ReadLine();
下面是修改后的代码的输出:
***** Fun with Polymorphism *****
Drawing NoName the Hexagon
Drawing NoName the Circle
Drawing Mick the Hexagon
Drawing Beth the Circle
Drawing Linda the Hexagon
这段代码很好地说明了多态性。虽然不能直接创建一个抽象基类的实例(??),但是你可以自由地存储对任何带有抽象基类变量的子类的引用。因此,当您创建一个由Shape
组成的数组时,该数组可以保存从Shape
基类派生的任何对象(如果您试图将Shape
不兼容的对象放入该数组,您会收到一个编译器错误)。
*鉴于myShapes
数组中的所有元素确实都是从Shape
派生的,你知道它们都支持相同的“多态接口”(或者更直白地说,它们都有一个Draw()
方法)。当您迭代Shape
引用的数组时,底层类型是在运行时确定的。此时,内存中调用了正确版本的Draw()
方法。
这种技术也使得安全地扩展当前层次变得简单。例如,假设您从抽象的Shape
基类(Triangle
、Square
等)派生了更多的类。).由于多态接口,您的foreach
循环中的代码不需要做任何改动,因为编译器强制要求只有与Shape
兼容的类型才放在myShapes
数组中。
了解成员隐藏
C# 提供了一个与方法覆盖逻辑相反的功能,称为隐藏。从形式上讲,如果一个派生类定义了一个与基类中定义的成员相同的成员,那么派生类就隐藏了父类的版本。在现实世界中,当您从一个不是您(或您的团队)自己创建的类中创建子类时(例如当您购买第三方软件包时),这种情况发生的可能性最大。
为了便于说明,假设您从同事(或同学)那里收到一个名为ThreeDCircle
的类,该类定义了一个名为Draw()
的不带参数的子例程。
class ThreeDCircle
{
public void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
你认为ThreeDCircle
是-aCircle
,所以你从你现有的Circle
类型中派生出来。
class ThreeDCircle : Circle
{
public void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
重新编译后,您会发现以下警告:
'ThreeDCircle.Draw()' hides inherited member 'Circle.Draw()'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword.
问题是您有一个派生类(ThreeDCircle
),它包含一个与继承方法相同的方法。要解决这个问题,您有几个选择。您可以使用override
关键字简单地更新Draw()
的子版本(正如编译器所建议的)。使用这种方法,ThreeDCircle
类型能够根据需要扩展父类的默认行为。但是,如果您没有访问定义基类的代码的权限(这也是许多第三方库中的情况),您将无法作为虚拟成员修改Draw()
方法,因为您没有访问代码文件的权限!
作为一种选择,您可以将new
关键字包含到派生类型的违规Draw()
成员中(在本例中为ThreeDCircle
)。这样做明确表明派生类型的实现是有意设计来有效忽略父版本的(同样,在现实世界中,如果外部软件以某种方式与您当前的软件冲突,这可能是有帮助的)。
// This class extends Circle and hides the inherited Draw() method.
class ThreeDCircle : Circle
{
// Hide any Draw() implementation above me.
public new void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
还可以将new
关键字应用于从基类继承的任何成员类型(字段、常量、静态成员或属性)。作为进一步的例子,假设ThreeDCircle
想要隐藏继承的PetName
属性。
class ThreeDCircle : Circle
{
// Hide the PetName property above me.
public new string PetName { get; set; }
// Hide any Draw() implementation above me.
public new void Draw()
{
Console.WriteLine("Drawing a 3D Circle");
}
}
最后,请注意,仍有可能使用显式强制转换来触发被隐藏成员的基类实现,如下一节所述。以下代码显示了一个示例:
...
// This calls the Draw() method of the ThreeDCircle.
ThreeDCircle o = new ThreeDCircle();
o.Draw();
// This calls the Draw() method of the parent!
((Circle)o).Draw();
Console.ReadLine();
了解基类/派生类转换规则
现在你可以构建一个相关类类型的家族,你需要学习类的规则转换操作。为此,让我们回到本章前面创建的雇员层次结构,并向Program
类添加一些新方法(如果您正在学习,请在 Visual Studio 中打开 Employees 项目)。如本章后面所述,系统中的最终基类是System.Object
。因此,Object
一切事物都“是-个”并能被如此对待。鉴于这一事实,在对象变量中存储任何类型的实例都是合法的。
static void CastingExamples()
{
// A Manager "is-a" System.Object, so we can
// store a Manager reference in an object variable just fine.
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
}
在 Employees 项目中,Managers
、SalesPerson
和PtSalesPerson
类型都扩展了Employee
,因此您可以在一个有效的基类引用中存储任何这些对象。因此,下列语句也是合法的:
static void CastingExamples()
{
// A Manager "is-a" System.Object, so we can
// store a Manager reference in an object variable just fine.
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// A Manager "is-an" Employee too.
Employee moonUnit = new Manager("MoonUnit Zappa", 2, 3001, 20000, "101-11-1321", 1);
// A PtSalesPerson "is-a" SalesPerson.
SalesPerson jill = new PtSalesPerson("Jill", 834, 3002, 100000, "111-12-1119", 90);
}
类类型之间转换的第一条法则是,当两个类通过“is-a”关系相关联时,将派生对象存储在基类引用中总是安全的。形式上,这被称为隐式强制转换,因为根据遗传法则“它 9 就是工作的”。这导致了一些强大的编程结构。例如,假设您已经在当前的Program
类中定义了一个新方法。
static void GivePromotion(Employee emp)
{
// Increase pay...
// Give new parking space in company garage...
Console.WriteLine("{0} was promoted!", emp.Name);
}
因为这个方法只接受一个类型为Employee
的参数,考虑到“is-a”关系,您可以有效地将来自Employee
类的任何后代直接传递给这个方法。
static void CastingExamples()
{
// A Manager "is-a" System.Object, so we can
// store a Manager reference in an object variable just fine.
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// A Manager "is-an" Employee too.
Employee moonUnit = new Manager("MoonUnit Zappa", 2, 3001, 20000, "101-11-1321", 1);
GivePromotion(moonUnit);
// A PTSalesPerson "is-a" SalesPerson.
SalesPerson jill = new PtSalesPerson("Jill", 834, 3002, 100000, "111-12-1119", 90);
GivePromotion(jill);
}
给定从基类类型(Employee
)到派生类型的隐式转换,前面的代码进行编译。但是,如果您也想推广弗兰克·扎帕(目前存储在一个通用的System.Object
引用中)呢?如果您将frank
对象直接传递给这个方法,您会发现如下编译器错误:
object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// Error!
GivePromotion(frank);
问题是你试图传入一个没有被声明为Employee
而是更一般的System.Object
的变量。考虑到object
比Employee
在继承链中处于更高的位置,编译器将不允许隐式强制转换,以尽可能保证代码的类型安全。
即使您可以知道object
引用指向内存中的Employee
兼容类,编译器也不能,因为这要到运行时才能知道。您可以通过执行显式强制转换来满足编译器。这是造型的第二条法则:在这种情况下,可以使用 C# 造型运算符进行显式向下造型。执行显式强制转换时要遵循的基本模板如下所示:
(ClassIWantToCastTo)referenceIHave
因此,要将对象变量传递给GivePromotion()
方法,您可以编写以下代码:
// OK!
GivePromotion((Manager)frank);
使用 C# 作为关键字
请注意,显式强制转换是在运行时进行评估的,而不是在编译时。为了便于讨论,假设您的 Employees 项目有一个本章前面创建的Hexagon
类的副本。为简单起见,您可以将以下类添加到当前项目中:
class Hexagon
{
public void Draw()
{
Console.WriteLine("Drawing a hexagon!");
}
}
尽管将 employee 对象转换为 shape 对象完全没有意义,但可以编译如下代码,而不会出错:
// Ack! You can't cast frank to a Hexagon, but this compiles fine!
object frank = new Manager();
Hexagon hex = (Hexagon)frank;
然而,您会收到一个运行时错误,或者更正式地说,一个运行时异常。第七章将研究结构化异常处理的全部细节;然而,目前值得指出的是,当您执行显式强制转换时,您可以使用try
和catch
关键字来捕获无效强制转换的可能性(同样,参见第七章了解全部细节)。
// Catch a possible invalid cast.
object frank = new Manager();
Hexagon hex;
try
{
hex = (Hexagon)frank;
}
catch (InvalidCastException ex)
{
Console.WriteLine(ex.Message);
}
显然,这是一个人为的例子;在这种情况下,你绝不会费心在这两种类型之间进行选择。然而,假设您有一个System.Object
类型的数组,其中只有少数包含与Employee
兼容的对象。在这种情况下,您希望确定数组中的某个项是否兼容,如果兼容,则执行强制转换。
C# 提供了关键字as
来在运行时快速确定一个给定的类型是否与另一个兼容。当您使用as
关键字时,您可以通过检查null
返回值来确定兼容性。请考虑以下几点:
// Use "as" to test compatibility.
object[] things = new object[4];
things[0] = new Hexagon();
things[1] = false;
things[2] = new Manager();
things[3] = "Last thing";
foreach (object item in things)
{
Hexagon h = item as Hexagon;
if (h == null)
{
Console.WriteLine("Item is not a hexagon");
}
else
{
h.Draw();
}
}
在这里,循环遍历对象数组中的每一项,检查每一项与Hexagon
类的兼容性。如果(且仅如果!)找到一个与Hexagon
兼容的对象,调用Draw()
方法。否则,您只需报告项目不兼容。
使用 C# is 关键字(更新 7.0、9.0)
除了as
关键字,C# 语言还提供了is
关键字来确定两个项目是否兼容。然而,与as
关键字不同,如果类型不兼容,is
关键字返回false
,而不是null
引用。目前,GivePromotion()
方法已经被设计成接受从Employee
派生的任何可能的类型。考虑下面的更新,它现在检查以查看传入的是哪种“雇员类型”:
static void GivePromotion(Employee emp)
{
Console.WriteLine("{0} was promoted!", emp.Name);
if (emp is SalesPerson)
{
Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
((SalesPerson)emp).SalesNumber);
Console.WriteLine();
}
else if (emp is Manager)
{
Console.WriteLine("{0} had {1} stock options...", emp.Name,
((Manager)emp).StockOptions);
Console.WriteLine();
}
}
这里,您正在执行运行时检查,以确定传入的基类引用实际上指向内存中的什么。在确定接收的是SalesPerson
还是Manager
类型之后,您就可以执行显式强制转换来访问该类的专用成员。还要注意,您不需要将您的造型操作包装在一个try
/ catch
构造中,因为您知道如果您进入任一个if
范围,造型是安全的,给定您的条件检查。
在 C# 7.0 中新增的关键字is
也可以将转换后的类型赋给一个变量,如果转换有效的话。这通过防止“双重转换”问题清理了前面的方法。在前面的示例中,第一次强制转换是在检查类型是否匹配时完成的,如果匹配,则变量必须再次强制转换。考虑对前面方法的更新:
static void GivePromotion(Employee emp)
{
Console.WriteLine("{0} was promoted!", emp.Name);
//Check if is SalesPerson, assign to variable s
if (emp is SalesPerson s)
{
Console.WriteLine("{0} made {1} sale(s)!", s.Name,
s.SalesNumber);
Console.WriteLine();
}
//Check if is Manager, if it is, assign to variable m
else if (emp is Manager m)
{
Console.WriteLine("{0} had {1} stock options...",
m.Name, m.StockOptions);
Console.WriteLine();
}
}
C# 9.0 引入了额外的模式匹配功能(在第三章中介绍)。这些更新的模式匹配可以与关键字is
一起使用。例如,要检查雇员是否不是Manager
和SalesPerson,
,请使用以下代码:
if (emp is not Manager and not SalesPerson)
{
Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
Console.WriteLine();
}
用 is 关键字丢弃(新 7.0)
关键字is
也可以与丢弃变量占位符结合使用。如果您想在您的if
或switch
语句中创建一个总汇,您可以如下操作:
if (obj is var _)
{
//do something
}
这将匹配所有内容,所以要注意使用丢弃比较器的顺序。更新后的GivePromotion()
方法如下所示:
if (emp is SalesPerson s)
{
Console.WriteLine("{0} made {1} sale(s)!", s.Name, s.SalesNumber);
Console.WriteLine();
}
//Check if is Manager, if it is, assign to variable m
else if (emp is Manager m)
{
Console.WriteLine("{0} had {1} stock options...", m.Name, m.StockOptions);
Console.WriteLine();
}
else if (emp is var _)
{
Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
Console.WriteLine();
}
最后的if
语句将捕获任何不是Manager, SalesPerson,
或PtSalesPerson.
的Employee
实例。记住,你可以降级为基类,所以PtSalesPerson
将注册为SalesPerson
.
重温模式匹配(新 7.0)
第三章 ?? 介绍了 C# 7 的模式匹配特性以及 C# 9.0 的更新。现在你已经对选角有了坚定的认识,是时候举个更好的例子了。现在可以干净地更新前面的示例,以使用模式匹配switch
语句,如下所示:
static void GivePromotion(Employee emp)
{
Console.WriteLine("{0} was promoted!", emp.Name);
switch (emp)
{
case SalesPerson s:
Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
s.SalesNumber);
break;
case Manager m:
Console.WriteLine("{0} had {1} stock options...",
emp.Name, m.StockOptions);
break;
}
Console.WriteLine();
}
当将一个when
子句添加到case
语句时,对象的完整定义在被转换为时可供使用。例如,SalesNumber
属性只存在于SalesPerson
类中,而不存在于Employee
类中。如果第一个case
语句中的转换成功,变量s
将保存一个SalesPerson
类的实例,因此case
语句可以更新为:
case SalesPerson s when s.SalesNumber > 5:
对is
和switch
语句的这些新添加提供了很好的改进,有助于减少执行匹配的代码量,如前面的例子所示。
用 switch 语句丢弃(新 7.0)
丢弃也可以用在switch
语句中,如下面的代码所示:
switch (emp)
{
case SalesPerson s when s.SalesNumber > 5:
Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
s.SalesNumber);
break;
case Manager m:
Console.WriteLine("{0} had {1} stock options...",
emp.Name, m.StockOptions);
break;
case Employee _:
Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
break;
}
每个传入的类型都已经是一个Employee,
,所以最后的case
语句总是真的。然而,正如在第三章中介绍模式匹配时所讨论的,一旦匹配成功,就会退出switch
语句。这证明了获得正确订单的重要性。如果最后一条语句被移到最上面,就不会有Employee
被提升。
理解超级父类:System。目标
为了结束这一章,我想检查一下超级父类的细节:Object
。当您在阅读前一节时,您可能已经注意到您的层次结构中的基类(Car
、Shape
、Employee
)从来没有显式地指定它们的父类。
// Who is the parent of Car?
class Car
{...}
在。NET Core universe,每个类型最终都是从一个名为System.Object
的基类派生出来的,可以用 C# object
关键字(小写 o )来表示。Object
类为框架中的每种类型定义了一组公共成员。事实上,当您构建一个没有显式定义其父类的类时,编译器会自动从Object
中派生出您的类型。如果你想弄清楚你的意图,你可以自由地如下定义从Object
派生的类(然而,同样,没有必要这样做):
// Here we are explicitly deriving from System.Object.
class Car : object
{...}
像任何类一样,System.Object
定义了一组成员。在下面的正式 C# 定义中,注意其中一些项被声明为virtual
,它指定一个给定的成员可以被一个子类覆盖,而其他的被标记为static
(因此在类级别被调用):
public class Object
{
// Virtual members.
public virtual bool Equals(object obj);
protected virtual void Finalize();
public virtual int GetHashCode();
public virtual string ToString();
// Instance-level, nonvirtual members.
public Type GetType();
protected object MemberwiseClone();
// Static members.
public static bool Equals(object objA, object objB);
public static bool ReferenceEquals(object objA, object objB);
}
表 6-1 提供了一些你最可能使用的方法所提供的功能的概要。
表 6-1。
System.Object
的核心成员
对象类的实例方法
|
生命的意义
|
| — | — |
| Equals()
| 默认情况下,只有当被比较的项目引用内存中的同一个项目时,该方法才返回true
。因此,Equals()
用于比较对象引用,而不是对象的状态。通常,只有当被比较的对象具有相同的内部状态值(即基于值的语义)时,该方法才会被覆盖以返回true
。 |
| | 注意,如果你覆盖了Equals()
,你也应该覆盖GetHashCode()
,因为这些方法被Hashtable
类型内部使用来从容器中检索子对象。 |
| | 还记得在第四章中,ValueType
类覆盖了所有结构的这个方法,所以它们使用基于值的比较。 |
| Finalize()
| 目前,您可以理解调用这个方法(当被覆盖时)是为了在对象被销毁之前释放所有分配的资源。我将在第九章中详细介绍 CoreCLR 垃圾收集服务。 |
| GetHashCode()
| 该方法返回一个标识特定对象实例的int
。 |
| ToString()
| 这个方法使用<namespace>.<type name>
格式(称为完全限定名)返回这个对象的字符串表示。这个方法通常会被一个子类覆盖,以返回一个表示对象内部状态的名称-值对的标记化字符串,而不是它的完全限定名。 |
| GetType()
| 这个方法返回一个Type
对象,它完整地描述了你当前引用的对象。简而言之,这是一个对所有对象都可用的运行时类型识别(RTTI)方法(在第十六章有更详细的讨论)。 |
| MemberwiseClone()
| 这个方法的存在是为了返回当前对象的一个成员接一个成员的副本,这在克隆对象时经常用到(见第八章)。 |
为了演示由Object
基类提供的一些默认行为,创建一个名为 ObjectOverrides 的最终 C# 控制台应用项目。插入一个新的 C# 类类型,它包含以下名为Person
的类型的空类定义:
// Remember! Person extends Object.
class Person {}
现在,更新您的顶级语句,以便与System.Object
的继承成员进行交互,如下所示:
Console.WriteLine("***** Fun with System.Object *****\n");
Person p1 = new Person();
// Use inherited members of System.Object.
Console.WriteLine("ToString: {0}", p1.ToString());
Console.WriteLine("Hash code: {0}", p1.GetHashCode());
Console.WriteLine("Type: {0}", p1.GetType());
// Make some other references to p1.
Person p2 = p1;
object o = p2;
// Are the references pointing to the same object in memory?
if (o.Equals(p1) && p2.Equals(o))
{
Console.WriteLine("Same instance!");
}
Console.ReadLine();
}
以下是当前代码的输出:
***** Fun with System.Object *****
ToString: ObjectOverrides.Person
Hash code: 58225482
Type: ObjectOverrides.Person
Same instance!
注意ToString()
的默认实现如何返回当前类型的完全限定名(ObjectOverrides.Person
)。正如你将在第十五章的构建定制名称空间的检查中看到的,每个 C# 项目都定义了一个“根名称空间”,它与项目本身同名。在这里,您创建了一个名为ObjectOverrides
的项目;因此,Person
类型和Program
类都被放在了ObjectOverrides
名称空间中。
Equals()
的默认行为是测试两个变量是否指向内存中的同一个对象。在这里,您创建了一个名为p1
的新的Person
变量。此时,一个新的Person
对象被放在托管堆上。p2
也是Person
类型。然而,您不是在创建一个新的实例,而是将这个变量分配给引用p1
。因此,p1
和p2
都指向内存中的同一个对象,变量o
(类型object
,为了更好的测量,它被抛出)也是如此。假设p1
、p2
和o
都指向相同的存储位置,则相等测试成功。
虽然System.Object
的固定行为在很多情况下可以满足要求,但是对于你的自定义类型来说,重写这些继承的方法是很常见的。举例来说,更新Person
类以支持一些表示个人名字、姓氏和年龄的属性,每个属性都可以由自定义构造函数设置。
// Remember! Person extends Object.
class Person
{
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public int Age { get; set; }
public Person(string fName, string lName, int personAge)
{
FirstName = fName;
LastName = lName;
Age = personAge;
}
public Person(){}
}
超驰系统。Object.ToString()
您创建的许多类(和结构)可以受益于覆盖ToString()
来返回类型当前状态的字符串文本表示。这对于调试非常有帮助(还有其他原因)。你如何选择构造这个字符串是个人的选择;但是,推荐的方法是用分号分隔每个名称-值对,并将整个字符串放在方括号内(许多类型在。NET 核心基类库遵循这种方法)。为您的Person
类考虑以下被覆盖的ToString()
:
public override string ToString() => $"[First Name: {FirstName}; Last Name: {LastName}; Age: {Age}]";
鉴于Person
类只有三条状态数据,所以ToString()
的实现非常简单。然而,永远记住一个适当的ToString()
覆盖也应该考虑到继承链上的定义的任何数据。
当您为一个扩展自定义基类的类重写ToString()
时,首先要做的是使用base
关键字从父类获取ToString()
值。获得父级的字符串数据后,可以追加派生类的自定义信息。
超驰系统。对象。等于()
让我们也覆盖Object.Equals()
的行为来处理基于值的语义。回想一下,默认情况下,只有当被比较的两个对象引用内存中的同一个对象实例时,Equals()
才会返回true
。对于Person
类,如果被比较的两个变量包含相同的状态值(例如,名字、姓氏和年龄),实现Equals()
以返回true
可能会有所帮助。
首先,注意到Equals()
方法的传入参数是一个通用的System.Object
。鉴于此,您的首要任务是确保调用者确实传入了一个Person
对象,并且作为额外的保护措施,确保传入的参数不是一个null
引用。
在您建立了调用者已经向您传递了一个分配的Person
之后,实现Equals()
的一种方法是对传入对象的数据和当前对象的数据进行逐字段比较。
public override bool Equals(object obj)
{
if (!(obj is Person temp))
{
return false;
}
if (temp.FirstName == this.FirstName
&& temp.LastName == this.LastName
&& temp.Age == this.Age)
{
return true;
}
return false;
}
这里,您将对照您的内部值检查传入对象的值(注意使用了this
关键字)。如果每个对象的名称和年龄都相同,那么就有两个对象具有相同的状态数据,因此返回true
。任何其他的可能性导致返回false
。
虽然这种方法确实有效,但是您可以想象为可能包含几十个数据字段的非平凡类型实现一个定制的Equals()
方法会有多费力。一个常见的捷径是利用您自己的ToString()
实现。如果一个类有一个基本且正确的ToString()
实现,它包含了继承链上的所有字段数据,那么你可以简单地比较对象的字符串数据(检查是否为空)。
// No need to cast "obj" to a Person anymore,
// as everything has a ToString() method.
public override bool Equals(object obj)
=> obj?.ToString() == ToString();
请注意,在这种情况下,您不再需要检查传入参数的类型是否正确(在本例中是 a Person
),因为。NET 支持一个ToString()
方法。更好的是,您不再需要执行逐个属性的相等检查,因为您现在只是测试从ToString()
返回的值。
超驰系统。Object.GetHashCode()
当一个类覆盖了Equals()
方法时,你也应该覆盖GetHashCode()
的默认实现。简单地说,散列码是一个数值,它将一个对象表示为一个特定的状态。例如,如果您创建两个保存值Hello
的string
变量,您将获得相同的哈希代码。然而,如果其中一个string
对象全部是小写的(hello
,您将获得不同的散列码。
默认情况下,System.Object.GetHashCode()
使用对象在内存中的当前位置来产生哈希值。但是,如果您正在构建一个自定义类型,并打算存储在一个Hashtable
类型中(在System.Collections
名称空间中),您应该总是覆盖这个成员,因为Hashtable
将在内部调用Equals()
和GetHashCode()
来检索正确的对象。
Note
更具体地说,System.Collections.Hashtable
类在内部调用GetHashCode()
来获得对象所在位置的大致信息,但是对Equals()
的后续(内部)调用确定了精确匹配。
虽然在这个例子中你不打算把你的Person
放入System.Collections.Hashtable
中,但是为了完整起见,让我们覆盖GetHashCode()
。有许多算法可以用来创建散列码——有些很奇特,有些则不那么奇特。大多数时候,您可以通过利用System.String
的GetHashCode()
实现来生成一个散列码值。
假设String
类已经有了一个可靠的哈希代码算法,它使用String
的字符数据来计算哈希值,如果您可以在您的类中识别出一个对于所有实例都应该是唯一的字段数据(比如一个社会保险号),只需在该字段数据点上调用GetHashCode()
。因此,如果Person
类定义了一个SSN
属性,您可以编写以下代码:
// Assume we have an SSN property as so.
class Person
{
public string SSN {get; } = "";
public Person(string fName, string lName, int personAge,
string ssn)
{
FirstName = fName;
LastName = lName;
Age = personAge;
SSN = ssn;
}
// Return a hash code based on unique string data.
public override int GetHashCode() => SSN.GetHashCode();
}
如果使用读写属性作为哈希代码的基础,将会收到警告。一旦创建了对象,哈希代码应该是不可变的。在前面的例子中,SSN 属性只有一个get
方法,该方法使属性成为只读的,并且只能在构造函数中设置。
如果您找不到唯一的string
数据的单点,但是您已经覆盖了ToString()
(它满足只读约定),那么在您自己的字符串表示上调用GetHashCode()
。
// Return a hash code based on the person's ToString() value.
public override int GetHashCode() => ToString().GetHashCode();
测试修改后的 Person 类
现在您已经覆盖了Object
的virtual
成员,更新顶层语句来测试您的更新。
Console.WriteLine("***** Fun with System.Object *****\n");
// NOTE: We want these to be identical to test
// the Equals() and GetHashCode() methods.
Person p1 = new Person("Homer", "Simpson", 50,
"111-11-1111");
Person p2 = new Person("Homer", "Simpson", 50,
"111-11-1111");
// Get stringified version of objects.
Console.WriteLine("p1.ToString() = {0}", p1.ToString());
Console.WriteLine("p2.ToString() = {0}", p2.ToString());
// Test overridden Equals().
Console.WriteLine("p1 = p2?: {0}", p1.Equals(p2));
// Test hash codes.
//still using the hash of the SSN
Console.WriteLine("Same hash codes?: {0}", p1.GetHashCode() == p2.GetHashCode());
Console.WriteLine();
// Change age of p2 and test again.
p2.Age = 45;
Console.WriteLine("p1.ToString() = {0}", p1.ToString());
Console.WriteLine("p2.ToString() = {0}", p2.ToString());
Console.WriteLine("p1 = p2?: {0}", p1.Equals(p2));
//still using the hash of the SSN
Console.WriteLine("Same hash codes?: {0}", p1.GetHashCode() == p2.GetHashCode());
Console.ReadLine();
输出如下所示:
***** Fun with System.Object *****
p1.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p2.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p1 = p2?: True
Same hash codes?: True
p1.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p2.ToString() = [First Name: Homer; Last Name: Simpson; Age: 45]
p1 = p2?: False
Same hash codes?: True
使用系统的静态成员。目标
除了您刚才检查的实例级成员之外,System.Object
还定义了两个静态成员,它们也测试基于值或基于引用的相等性。考虑以下代码:
static void StaticMembersOfObject()
{
// Static members of System.Object.
Person p3 = new Person("Sally", "Jones", 4);
Person p4 = new Person("Sally", "Jones", 4);
Console.WriteLine("P3 and P4 have same state: {0}", object.Equals(p3, p4));
Console.WriteLine("P3 and P4 are pointing to same object: {0}",
object.ReferenceEquals(p3, p4));
}
在这里,您可以简单地发送两个对象(任何类型)并允许System.Object
类自动确定细节。
输出(从顶级语句调用时)如下所示:
***** Fun with System.Object *****
P3 and P4 have the same state: True
P3 and P4 are pointing to the same object: False
摘要
本章探讨了继承和多态的作用和细节。在这些页面中,向您介绍了许多新的关键字和令牌来支持这些技术。例如,回想一下冒号标记用于建立给定类型的父类。父类型能够定义任意数量的虚拟和/或抽象成员来建立多态接口。派生类型使用override
关键字覆盖这样的成员。
除了构建大量的类层次结构之外,本章还研究了如何在基类和派生类之间进行显式转换,并通过深入研究。NET 基础类库:System.Object
。**
七、了解结构化异常处理
在本章中,你将学习如何通过使用结构化异常处理来处理 C# 代码中的运行时异常。你不仅会研究允许你处理这些事情的 C# 关键字(try
、catch
、throw
、finally
、when
),而且你还会理解应用级和系统级异常的区别,以及System.Exception
基类的作用。该讨论将引入构建自定义异常的主题,并最终快速浏览一些 Visual Studio 的以异常为中心的调试工具。
错误、错误和异常的颂歌
不管我们(有时是膨胀的)自我告诉我们什么,没有一个程序员是完美的。编写软件是一项复杂的任务,考虑到这种复杂性,即使是最好的软件也经常会出现各种问题*。有时问题是由糟糕的代码引起的(比如溢出数组的边界)。其他时候,问题是由伪造的用户输入引起的,这些用户输入在应用的代码库中没有考虑到(例如,分配给值Chucky
的电话号码输入字段)。现在,不管问题的原因是什么,最终的结果都是应用不能像预期的那样工作。为了帮助构建即将到来的结构化异常处理的讨论,请允许我提供三个常用的以异常为中心的术语的定义。*
** bug:简单来说,就是程序员犯的错误。例如,假设您正在使用非托管 C++进行编程。如果您未能删除动态分配的内存,从而导致内存泄漏,那么您就有一个 bug。
-
用户错误:另一方面,用户错误通常是由运行你的应用的人引起的,而不是由创建它的人引起的。例如,一个终端用户在文本框中输入了一个格式错误的字符串,如果您不能在代码库中处理这个错误的输入,他很可能会产生一个错误*。*
-
异常(Exceptions):异常通常被认为是运行时的异常,在编写应用时很难解释清楚。可能的例外包括尝试连接到不再存在的数据库、打开损坏的 XML 文件或尝试联系当前脱机的计算机。在每一种情况下,程序员(或最终用户)对这些“异常”情况几乎没有控制力。
给定这些定义,应该很清楚。NET 结构化异常处理是一种处理运行时异常的技术。然而,即使对于那些你看不到的 bug 和用户错误,运行时通常也会生成一个相应的异常来识别即将发生的问题。举几个例子。NET 5 基础类库定义了众多的异常,比如FormatException
、IndexOutOfRangeException
、FileNotFoundException
、ArgumentOutOfRangeException
等等。
在。NET 命名法中,异常说明了 bug、虚假用户输入和运行时错误,尽管程序员可能会将这些视为不同的问题。然而,在我走得太远之前,让我们形式化一下结构化异常处理的角色,看看它与传统的错误处理技术有什么不同。
Note
为了使本书中使用的代码示例尽可能简洁,我不会捕捉基类库中给定方法可能抛出的每个可能的异常。当然,在你的产品级项目中,你应该充分利用本章介绍的技术。
的作用。NET 异常处理
之前。NET 中,Windows 操作系统下的错误处理是一个混乱的技术大杂烩。许多程序员在给定应用的上下文中使用他们自己的错误处理逻辑。例如,开发团队可以定义一组表示已知错误条件的数字常量,并将它们用作方法返回值。举例来说,考虑下面的部分 C 代码:
/* A very C-style error trapping mechanism. */
#define E_FILENOTFOUND 1000
int UseFileSystem()
{
// Assume something happens in this function
// that causes the following return value.
return E_FILENOTFOUND;
}
void main()
{
int retVal = UseFileSystem();
if(retVal == E_FILENOTFOUND)
printf("Cannot find file...");
}
这种方法不太理想,因为常量E_FILENOTFOUND
只不过是一个数值,对于如何处理这个问题来说远远不是一个有用的代理。理想情况下,您希望将错误的名称、描述性消息和其他关于该错误条件的有用信息打包到一个定义明确的包中(这正是结构化异常处理中发生的情况)。除了开发人员的特别技术之外,Windows API 还定义了数百个错误代码,这些错误代码来自于#defines
、HRESULT
以及简单布尔值(bool
、BOOL
、VARIANT_BOOL
等)的太多变体。).
这些老技术的明显问题是严重缺乏对称性。每种方法都或多或少地适合于给定的技术、给定的语言,甚至可能是给定的项目。为了结束这种疯狂。NET 平台提供了发送和捕获运行时错误的标准技术:结构化异常处理。这种方法的美妙之处在于,开发人员现在有了一种统一的错误处理方法,这种方法对于所有面向。NET 平台。因此,C# 程序员处理错误的方式在语法上类似于 VB 程序员,或者使用 C++/CLI 的 C++程序员。
额外的好处是,用于跨程序集和计算机边界抛出和捕获异常的语法是相同的。例如,如果您使用 C# 构建一个 ASP.NET 核心 RESTful 服务,您可以使用允许您在同一个应用的方法之间抛出异常的相同关键字,向远程调用者抛出一个 JSON 错误。
另一个好处是。NET exceptions 的一个特点是,异常不是接收一个神秘的数值,而是包含问题的可读描述的对象,以及首先触发异常的调用堆栈的详细快照。此外,您可以为最终用户提供帮助链接信息,将用户指向一个提供错误详细信息的 URL,以及自定义的程序员定义的数据。
的组成部分。NET 异常处理
使用结构化异常处理进行编程涉及到四个相关实体的使用。
-
表示异常详细信息的类类型
-
在正确的情况下,向调用者抛出异常类实例的成员
-
调用者端调用易发生异常的成员的代码块
-
调用者端的代码块将处理(或捕捉)发生的异常
C# 编程语言提供了五个关键字(try
、catch
、throw
、finally
和when
,允许您抛出和处理异常。代表当前问题的对象是一个扩展了System.Exception
的类(或其派生)。鉴于这一事实,让我们来看看这个以异常为中心的基类的作用。
系统。异常基类
所有异常最终都是从System.Exception
基类派生的,而基类又是从System.Object
派生的。这个类的关键是(注意,其中一些成员是虚拟的,因此可能被派生类重写):
public class Exception : ISerializable
{
// Public constructors
public Exception(string message, Exception innerException);
public Exception(string message);
public Exception();
...
// Methods
public virtual Exception GetBaseException();
public virtual void GetObjectData(SerializationInfo info,
StreamingContext context);
// Properties
public virtual IDictionary Data { get; }
public virtual string HelpLink { get; set; }
public int HResult {get;set;}
public Exception InnerException { get; }
public virtual string Message { get; }
public virtual string Source { get; set; }
public virtual string StackTrace { get; }
public MethodBase TargetSite { get; }
}
正如您所看到的,由System.Exception
定义的许多属性实际上是只读的。这是因为派生类型通常会为每个属性提供默认值。例如,IndexOutOfRangeException
类型的默认消息是“索引超出了数组的界限。”
表 7-1 描述了System.Exception
最重要的成员。
表 7-1。
System.Exception
类型的核心成员
系统。异常属性
|
生命的意义
|
| — | — |
| Data
| 这个只读属性检索一组键值对(由实现IDictionary
的对象表示),这些键值对提供了额外的、程序员定义的关于异常的信息。默认情况下,此集合为空。 |
| HelpLink
| 此属性获取或设置详细描述错误的帮助文件或网站的 URL。 |
| InnerException
| 此只读属性可用于获取导致当前异常发生的以前异常的信息。先前的异常通过将它们传递到最新异常的构造函数中来记录。 |
| Message
| 此只读属性返回给定错误的文本描述。错误消息本身被设置为构造函数参数。 |
| Source
| 此属性获取或设置引发当前异常的程序集或对象的名称。 |
| StackTrace
| 此只读属性包含一个字符串,该字符串标识触发异常的调用序列。正如您可能猜到的那样,该属性在调试期间或者如果您想要将错误转储到外部错误日志中时非常有用。 |
| TargetSite
| 这个只读属性返回一个MethodBase
对象,该对象描述了关于抛出异常的方法的许多细节(调用ToString()
将通过名称识别该方法)。 |
最简单的例子
为了说明结构化异常处理的有用性,您需要创建一个在正确的(或者可以说是异常)情况下抛出异常的类。假设您已经创建了一个新的 C# 控制台应用项目(名为 SimpleException ),它定义了由“has-a”关系关联的两个类类型(Car
和Radio
)。Radio
类型定义了打开或关闭无线电电源的单一方法。
using System;
namespace SimpleException
{
class Radio
{
public void TurnOn(bool on)
{
Console.WriteLine(on ? "Jamming..." : "Quiet time...");
}
}
}
除了通过包含/委托利用Radio
类之外,Car
类(如下所示)的定义方式是,如果用户将Car
对象加速到超过预定义的最大速度(使用名为MaxSpeed
的常量成员变量指定),其引擎就会爆炸,导致Car
不可用(由名为_carIsDead
的私有bool
成员变量捕获)。
除此之外,Car
类型还有一些属性来表示当前速度和用户提供的“昵称”,以及各种构造函数来设置新的Car
对象的状态。下面是完整的定义(带代码注释):
using System;
namespace SimpleException
{
class Car
{
// Constant for maximum speed.
public const int MaxSpeed = 100;
// Car properties.
public int CurrentSpeed {get; set;} = 0;
public string PetName {get; set;} = "";
// Is the car still operational?
private bool _carIsDead;
// A car has-a radio.
private readonly Radio _theMusicBox = new Radio();
// Constructors.
public Car() {}
public Car(string name, int speed)
{
CurrentSpeed = speed;
PetName = name;
}
public void CrankTunes(bool state)
{
// Delegate request to inner object.
_theMusicBox.TurnOn(state);
}
// See if Car has overheated.
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed > MaxSpeed)
{
Console.WriteLine("{0} has overheated!", PetName);
CurrentSpeed = 0;
_carIsDead = true;
}
else
{
Console.WriteLine("=> CurrentSpeed = {0}",
CurrentSpeed);
}
}
}
}
}
接下来,更新您的Program.cs
代码以强制Car
对象超过预定义的最大速度(在Car
类中设置为 100),如下所示:
using System;
using System.Collections;
using SimpleException;
Console.WriteLine("***** Simple Exception Example *****");
Console.WriteLine("=> Creating a car and stepping on it!");
Car myCar = new Car("Zippy", 20);
myCar.CrankTunes(true);
for (int i = 0; i < 10; i++)
{
myCar.Accelerate(10);
}
Console.ReadLine();
执行代码时,您会看到以下输出:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
Zippy has overheated!
Zippy is out of order...
引发一般异常
现在您已经有了一个函数类,我将演示抛出异常的最简单方法。如果调用者试图加速Car
超过其上限,当前的Accelerate()
实现简单地显示一条错误消息。
如果用户试图在汽车遇到制造者后加速汽车,要改进这个方法抛出一个异常,您需要创建并配置一个新的System.Exception
类实例,通过类构造函数设置只读Message
属性的值。当你想把异常对象发送回调用者时,使用 C# throw
关键字。下面是对Accelerate()
方法的相关代码更新:
// This time, throw an exception if the user speeds up beyond MaxSpeed.
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed >= MaxSpeed)
{
CurrentSpeed = 0;
_carIsDead = true;
// Use the "throw" keyword to raise an exception.
throw new Exception($"{PetName} has overheated!");
}
Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
}
}
在研究调用者如何捕捉这个异常之前,让我们先来看几个有趣的地方。首先,当您抛出一个异常时,总是由您来决定到底是什么构成了问题中的错误,以及何时应该抛出一个异常。这里,你假设如果程序试图增加一个Car
对象的速度超过最大值,应该抛出一个System.Exception
对象来指示Accelerate()
方法不能继续(这可能是也可能不是一个有效的假设;这将是您根据您正在创建的应用做出的判断。
或者,您可以实现Accelerate()
来自动恢复,而不需要首先抛出异常。总的来说,异常应该仅在满足更多的终止条件时抛出(例如,找不到必要的文件、无法连接到数据库等),而不是用作逻辑流机制。确切地决定抛出异常的理由是一个您必须始终应对的设计问题。就目前的目的而言,假设要求一辆注定要失败的汽车加速会引发一个异常。
其次,注意最后一个else
是如何从方法中移除的。当抛出一个异常时(由框架或者手动使用一个throw
语句),控制权返回给调用方法(或者由try
catch 中的catch
块)。这样就不需要最后的else
。是否保留可读性取决于您和您的编码标准。
在任何情况下,如果您在此时使用顶级语句中的先前逻辑重新运行应用,异常最终将被抛出。如以下输出所示,不处理此错误的结果并不理想,因为您会收到一个详细的错误转储,然后程序终止(带有您的特定文件路径和行号):
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
Unhandled exception. System.Exception: Zippy has overheated!
at SimpleException.Car.Accelerate(Int32 delta) in [path to file]\Car.cs:line 52
at SimpleException.Program.Main(String[] args) in [path to file]\Program.cs:line 16
捕捉异常
Note
对于那些即将到来的。NET 5 从 Java 背景,理解类型成员不是用它们可能抛出的异常集(换句话说。NET Core 不支持检查异常)。不管是好是坏,您不需要处理给定成员抛出的每个异常。
因为Accelerate()
方法现在抛出一个异常,调用者需要准备好处理这个异常,如果它发生的话。当你调用一个可能抛出异常的方法时,你使用了一个try
/ catch
块。在您捕获异常对象之后,您能够调用异常对象的成员来提取问题的细节。
你如何处理这些数据很大程度上取决于你自己。您可能希望将此信息记录到报告文件中,将数据写入事件日志,向系统管理员发送电子邮件,或者向最终用户显示问题。在这里,您只需将内容转储到控制台窗口:
// Handle the thrown exception.
Console.WriteLine("***** Simple Exception Example *****");
Console.WriteLine("=> Creating a car and stepping on it!");
Car myCar = new Car("Zippy", 20);
myCar.CrankTunes(true);
// Speed up past the car's max speed to
// trigger the exception.
try
{
for(int i = 0; i < 10; i++)
{
myCar. Accelerate(10);
}
}
catch(Exception e)
{
Console.WriteLine("\n*** Error! ***");
Console.WriteLine("Method: {0}", e.TargetSite);
Console.WriteLine("Message: {0}", e.Message);
Console.WriteLine("Source: {0}", e.Source);
}
// The error has been handled, processing continues with the next statement.
Console.WriteLine("\n***** Out of exception logic *****");
Console.ReadLine();
本质上,try
块是一段可能在执行过程中抛出异常的语句。如果检测到异常,程序执行流程被发送到适当的catch
模块。另一方面,如果try
块中的代码没有触发异常,那么catch
块将被完全跳过,一切正常。以下输出显示了该程序的测试运行:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
*** Error! ***
Method: Void Accelerate(Int32)
Message: Zippy has overheated!
Source: SimpleException
***** Out of exception logic *****
正如您所看到的,在一个异常被处理之后,应用可以从catch
块之后的点继续运行。在某些情况下,给定的异常可能非常关键,足以保证终止应用。然而,在很多情况下,异常处理程序中的逻辑将确保应用能够继续愉快地运行(尽管它的功能可能会稍微差一些,比如不能连接到远程数据源)。
作为表达式抛出(新 7.0)
在 C# 7 之前,throw
是一个语句,这意味着你只能在允许语句的地方抛出异常。在 C# 7.0 和更高版本中,throw
也可以作为表达式使用,并且可以在任何允许表达式的地方被调用。
配置异常的状态
目前,Accelerate()
方法中配置的System.Exception
对象只是建立一个暴露给Message
属性的值(通过一个构造函数参数)。然而,如表 7-1 所示,Exception
类还提供了许多附加成员(TargetSite
、StackTrace
、HelpLink
和Data
),这些成员可用于进一步限定问题的性质。为了更好地展示当前的例子,让我们逐个分析这些成员的更多细节。
TargetSite 属性
属性允许您确定关于抛出给定异常的方法的各种细节。如前面的代码示例所示,打印TargetSite
的值将显示抛出异常的方法的返回类型、名称和参数类型。然而,TargetSite
不仅仅返回一个香草味的字符串,而是一个强类型的System.Reflection.MethodBase
对象。此类型可用于收集有关违规方法以及定义违规方法的类的大量详细信息。为了说明,假设前面的catch
逻辑已经更新如下:
...
// TargetSite actually returns a MethodBase object.
catch(Exception e)
{
Console.WriteLine("\n*** Error! ***");
Console.WriteLine("Member name: {0}", e.TargetSite);
Console.WriteLine("Class defining member: {0}",
e.TargetSite.DeclaringType);
Console.WriteLine("Member type: {0}",
e.TargetSite.MemberType);
Console.WriteLine("Message: {0}", e.Message);
Console.WriteLine("Source: {0}", e.Source);
}
Console.WriteLine("\n***** Out of exception logic *****");
Console.ReadLine();
这一次,您使用MethodBase.DeclaringType
属性来确定抛出错误的类的完全限定名(在本例中为SimpleException.Car
)以及MethodBase
对象的MemberType
属性来标识引发该异常的成员类型(例如属性与方法)。在这种情况下,catch
逻辑将显示以下内容:
*** Error! ***
Member name: Void Accelerate(Int32)
Class defining member: SimpleException.Car
Member type: Method
Message: Zippy has overheated!
Source: SimpleException
StackTrace 属性
属性允许您识别导致异常的一系列调用。请注意,永远不要设置StackTrace
的值,因为它是在创建异常时自动建立的。举例来说,假设你再次更新了你的catch
逻辑。
catch(Exception e)
{
...
Console.WriteLine("Stack: {0}", e.StackTrace);
}
如果您要运行该程序,您会发现下面的堆栈跟踪被打印到控制台(当然,您的行号和文件路径可能不同):
Stack: at SimpleException.Car.Accelerate(Int32 delta)
in [path to file]\car.cs:line 57 at <Program>$.<Main>$(String[] args)
in [path to file]\Program.cs:line 20
从StackTrace
返回的string
记录了导致抛出该异常的调用序列。注意这个string
最下面的行号如何标识序列中的第一个调用,而最上面的行号标识违规成员的确切位置。显然,这些信息在给定应用的调试或日志记录过程中非常有用,因为您能够“跟踪”错误的来源。
HelpLink 属性
虽然TargetSite
和StackTrace
属性允许程序员了解给定的异常,但是这些信息对最终用户没有什么用处。正如您已经看到的,System.Exception.Message
属性可以用来获取可以显示给当前用户的可读信息。此外,HelpLink
属性可以被设置为将用户指向包含更多详细信息的特定 URL 或标准帮助文件。
默认情况下,由HelpLink
属性管理的值是一个空字符串。使用对象初始化更新异常,以提供更有趣的值。下面是对Car.Accelerate()
方法的相关更新:
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed >= MaxSpeed)
{
CurrentSpeed = 0;
_carIsDead = true;
// Use the "throw" keyword to raise an exception and
// return to the caller.
throw new Exception($"{PetName} has overheated!")
{
HelpLink = "http://www.CarsRUs.com"
};
}
Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
}
}
现在可以更新catch
逻辑来打印帮助链接信息,如下所示:
catch(Exception e)
{
...
Console.WriteLine("Help Link: {0}", e.HelpLink);
}
数据属性
System.Exception
的Data
属性允许你用相关的辅助信息(比如时间戳)填充一个异常对象。Data
属性返回一个实现名为IDictionary
的接口的对象,该接口在System.Collections
名称空间中定义。第八章研究了基于接口编程的角色,以及System.Collections
名称空间。目前,只需理解字典集合允许您创建一组使用特定键检索的值。观察Car.Accelerate()
方法的下一次更新:
public void Accelerate(int delta)
{
if (_carIsDead)
{
Console.WriteLine("{0} is out of order...", PetName);
}
else
{
CurrentSpeed += delta;
if (CurrentSpeed >= MaxSpeed)
{
Console.WriteLine("{0} has overheated!", PetName);
CurrentSpeed = 0;
_carIsDead = true;
// Use the "throw" keyword to raise an exception
// and return to the caller.
throw new Exception($"{PetName} has overheated!")
{
HelpLink = "http://www.CarsRUs.com",
Data = {
{"TimeStamp",$"The car exploded at {DateTime.Now}"},
{"Cause","You have a lead foot."}
}
};
}
Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
}
}
为了成功地枚举键值对,请确保您为System.Collections
名称空间添加了一个using
指令,因为您将在包含实现顶级语句的类的文件中使用一个DictionaryEntry
类型:
using System.Collections;
接下来,您需要更新catch
逻辑来测试从Data
属性返回的值不是null
(默认值)。之后,使用DictionaryEntry
类型的Key
和Value
属性将定制数据打印到控制台。
catch (Exception e)
{
...
Console.WriteLine("\n-> Custom Data:");
foreach (DictionaryEntry de in e.Data)
{
Console.WriteLine("-> {0}: {1}", de.Key, de.Value);
}
}
有了这个,这就是你看到的最终输出:
***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
*** Error! ***
Member name: Void Accelerate(Int32)
Class defining member: SimpleException.Car
Member type: Method
Message: Zippy has overheated!
Source: SimpleException
Stack: at SimpleException.Car.Accelerate(Int32 delta) ...
at SimpleException.Program.Main(String[] args) ...
Help Link: http://www.CarsRUs.com
-> Custom Data:
-> TimeStamp: The car exploded at 3/15/2020 16:22:59
-> Cause: You have a lead foot.
***** Out of exception logic *****
Data
属性是有用的,因为它允许你装入关于手边错误的定制信息,而不需要构建一个新的类类型来扩展Exception
基类。尽管Data
属性可能很有帮助,但是,开发人员构建强类型异常类仍然很常见,这些类使用强类型属性来处理自定义数据。
这种方法允许调用者捕捉一个特定的exception
派生类型,而不必挖掘数据集合来获得额外的细节。为了理解如何做到这一点,您需要研究系统级异常和应用级异常之间的区别。
系统级异常(系统。系统异常)
那个。NET 5 基础类库定义了很多最终从System.Exception
派生的类。例如,System
名称空间定义了核心异常对象,如ArgumentOutOfRangeException
、IndexOutOfRangeException
、StackOverflowException
等等。其他命名空间定义反映该命名空间行为的异常。例如,System.Drawing.Printing
定义打印异常,System.IO
定义基于输入/输出的异常,System.Data
定义以数据库为中心的异常,等等。
引发的异常。NET 5 平台被(恰当地)称为系统异常。这些异常通常被认为是不可恢复的致命错误。系统异常直接从一个名为System.SystemException
的基类派生而来,这个基类又从System.Exception
派生而来(?? 又从System.Object
派生而来)。
public class SystemException : Exception
{
// Various constructors.
}
假定System.SystemException
类型除了一组自定义构造函数之外没有添加任何额外的功能,您可能会奇怪为什么SystemException
首先会存在。简单地说,当一个异常类型从System.SystemException
派生时,您能够确定。NET 5 运行库是引发异常的实体,而不是执行应用的代码库。您可以使用is
关键字非常简单地验证这一点。
// True! NullReferenceException is-a SystemException.
NullReferenceException nullRefEx = new NullReferenceException();
Console.WriteLine(
"NullReferenceException is-a SystemException? : {0}",
nullRefEx is SystemException);
应用级异常(系统。应用异常)
鉴于这一切。NET 5 异常是类类型,你可以自由地创建你自己的特定于应用的异常。然而,因为System.SystemException
基类表示从运行时抛出的异常,您可能会自然地认为您应该从System.Exception
类型中派生您的自定义异常。你可以这样做,但是你可以从System.ApplicationException
类派生。
public class ApplicationException : Exception
{
// Various constructors.
}
和SystemException
一样,ApplicationException
除了一组构造函数之外,没有定义任何额外的成员。从功能上来说,System.ApplicationException
的唯一目的是识别错误的来源。当您处理源自System.ApplicationException
的异常时,您可以假设该异常是由正在执行的应用的代码库引发的,而不是由。NET 核心基本类库或。NET 5 运行时引擎。
构建自定义异常,取 1
虽然您总是可以抛出System.Exception
的实例来发出运行时错误信号(如第一个示例所示),但有时构建一个代表您当前问题的独特细节的强类型异常是有利的。例如,假设您想要构建一个定制的异常(名为CarIsDeadException
)来表示加速一辆注定失败的汽车的错误。第一步是从System.Exception
/ System.ApplicationException
派生一个新类(按照惯例,所有异常类名都以Exception
后缀结尾)。
Note
通常,所有定制的异常类都应该被定义为公共类(回想一下,非嵌套类型的默认访问修饰符是 internal)。原因是异常通常在程序集边界之外传递,因此调用代码基应该可以访问异常。
创建一个名为 CustomException 的新控制台应用项目,将之前的Car.cs
和Radio.cs
文件复制到您的新项目中,并将定义Car
和Radio
类型的名称空间从SimpleException
更改为CustomException
。接下来,添加一个名为CarIsDeadException.cs
的新文件,并添加以下类定义:
using System;
namespace CustomException
{
// This custom exception describes the details of the car-is-dead condition.
// (Remember, you can also simply extend Exception.)
public class CarIsDeadException : ApplicationException
{
}
}
与任何类一样,您可以自由地包含任意数量的自定义成员,这些成员可以在调用逻辑的catch
块中调用。您也可以自由地重写由父类定义的任何虚拟成员。例如,您可以通过覆盖虚拟的Message
属性来实现CarIsDeadException
。
同样,在抛出异常时,构造函数允许发送方传入时间戳和错误原因,而不是填充数据字典(通过Data
属性)。最后,可以使用强类型属性获得时间戳数据和错误原因。
public class CarIsDeadException : ApplicationException
{
private string _messageDetails = String.Empty;
public DateTime ErrorTimeStamp {get; set;}
public string CauseOfError {get; set;}
public CarIsDeadException(){}
public CarIsDeadException(string message,
string cause, DateTime time)
{
_messageDetails = message;
CauseOfError = cause;
ErrorTimeStamp = time;
}
// Override the Exception.Message property.
public override string Message
=> $"Car Error Message: {_messageDetails}";
}
这里,CarIsDeadException
类维护一个私有字段(_messageDetails
),表示关于当前异常的数据,可以使用自定义构造函数来设置。从Accelerate()
方法中抛出这个异常非常简单。简单地分配、配置和抛出一个CarIsDeadException
类型,而不是一个System.Exception
。
// Throw the custom CarIsDeadException.
public void Accelerate(int delta)
{
...
throw new CarIsDeadException(
$"{PetName} has overheated!",
"You have a lead foot", DateTime.Now)
{
HelpLink = "http://www.CarsRUs.com",
};
...
}
为了捕捉这个传入的异常,现在可以更新您的catch
范围来捕捉一个特定的CarIsDeadException
类型(然而,考虑到CarIsDeadException
是一个System.Exception
,捕捉一个System.Exception
也是允许的)。
using System;
using CustomException;
Console.WriteLine("***** Fun with Custom Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Trip exception.
myCar.Accelerate(50);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
Console.WriteLine(e.ErrorTimeStamp);
Console.WriteLine(e.CauseOfError);
}
Console.ReadLine();
因此,既然您已经理解了构建自定义异常的基本过程,那么是时候在这些知识的基础上进行构建了。
构建自定义异常,取 2
当前的CarIsDeadException
类型已经覆盖了虚拟的System.Exception.Message
属性来配置一个定制的错误消息,并且提供了两个定制的属性来处理额外的数据位。然而,实际上,您不需要覆盖虚拟的Message
属性,因为您可以简单地将传入的消息传递给父类的构造函数,如下所示:
public class CarIsDeadException : ApplicationException
{
public DateTime ErrorTimeStamp { get; set; }
public string CauseOfError { get; set; }
public CarIsDeadException() { }
// Feed message to parent constructor.
public CarIsDeadException(string message, string cause, DateTime time)
:base(message)
{
CauseOfError = cause;
ErrorTimeStamp = time;
}
}
注意,这次您已经用而不是定义了一个字符串变量来表示消息,并且用而不是覆盖了Message
属性。相反,您只是将参数传递给基类构造函数。使用这种设计,定制的异常类只不过是从System.ApplicationException
派生的一个唯一命名的类(如果合适的话还有附加属性),没有任何基类覆盖。
如果您的大多数(如果不是全部)自定义异常类都遵循这个简单的模式,请不要感到惊讶。很多时候,自定义异常的作用不一定是提供从基类继承的功能之外的额外功能,而是提供一个强名称类型来清楚地标识错误的性质,以便客户端可以为不同类型的异常提供不同的处理程序逻辑。
构建自定义异常,取 3
如果您想要构建一个真正整洁、适当的自定义异常类,您需要确保您的自定义异常执行以下操作:
-
源自
Exception
/ApplicationException
-
定义默认构造函数
-
定义一个设置继承的
Message
属性的构造函数 -
定义一个处理“内部异常”的构造函数
为了完成您对构建定制异常的检查,下面是CarIsDeadException
的最后一次迭代,它说明了这些特殊构造函数中的每一个(属性如前面的示例所示):
public class CarIsDeadException : ApplicationException
{
private string _messageDetails = String.Empty;
public DateTime ErrorTimeStamp {get; set;}
public string CauseOfError {get; set;}
public CarIsDeadException(){}
public CarIsDeadException(string cause, DateTime time) : this(cause,time,string.Empty)
{
}
public CarIsDeadException(string cause, DateTime time, string message) : this(cause,time,message, null)
{
}
public CarIsDeadException(string cause, DateTime time, string message, System.Exception inner)
: base(message, inner)
{
CauseOfError = cause;
ErrorTimeStamp = time;
}
}
随着对您的定制异常的更新,将Accelerate
方法更新为以下内容:
throw new CarIsDeadException("You have a lead foot",
DateTime.Now,$"{PetName} has overheated!")
{
HelpLink = "http://www.CarsRUs.com",
};
假定构建自定义异常遵循。NET 核心最佳实践的区别仅仅在于它们的名称,您会很高兴地知道 Visual Studio 提供了一个名为Exception
的代码片段模板,它将自动生成一个新的异常类,该类遵循。NET 最佳实践。要激活它,在编辑器中键入exc
并按 Tab 键(在 Visual Studio 中,按 Tab 键两次)。
处理多个异常
最简单的形式是,一个try
块有一个catch
块。然而,在现实中,您经常会遇到这样的情况:一个try
块中的语句可能会触发许多可能的异常。创建一个名为 ProcessMultipleExceptions 的新 C# 控制台应用项目;将前面的 CustomException 示例中的Car.cs
、Radio.cs
和CarIsDeadException.cs
文件复制到新项目中,并相应地更新您的名称空间名称。
现在,更新Car
的Accelerate()
方法,如果传递一个无效参数(可以假设是任何小于零的值),也抛出一个预定义的基类库ArgumentOutOfRangeException
。注意,这个异常类的构造函数将有问题的参数的名称作为第一个string
,后面跟着一条描述错误的消息。
// Test for invalid argument before proceeding.
public void Accelerate(int delta)
{
if (delta < 0)
{
throw new ArgumentOutOfRangeException(nameof(delta),
"Speed must be greater than zero");
}
...
}
Note
nameof()
操作符返回一个表示对象名称的字符串,在本例中是变量 delta。当需要字符串版本时,这是引用 C# 对象、方法和变量的更安全的方式。
catch
逻辑现在可以对每种类型的异常做出具体的响应。
using System;
using System.IO;
using ProcessMultipleExceptions;
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Trip Arg out of range exception.
myCar.Accelerate(-10);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
当您创作多个catch
块时,您必须意识到当一个异常被抛出时,它将被第一个适当的 catch 处理。为了准确地说明“第一个适当的”catch 的含义,假设您用一个额外的catch
作用域改进了前面的逻辑,该作用域试图通过捕获一个一般的System.Exception
来处理CarIsDeadException
和ArgumentOutOfRangeException
之外的所有异常,如下所示:
// This code will not compile!
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Trigger an argument out of range exception.
myCar.Accelerate(-10);
}
catch(Exception e)
{
// Process all other exceptions?
Console.WriteLine(e.Message);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
这个异常处理逻辑会生成编译时错误。问题是第一个catch
块可以处理从System.Exception
派生的任何东西(给定“is-a”关系),包括CarIsDeadException
和ArgumentOutOfRangeException
类型。因此,最后两个catch
区块是不可及的!
要记住的经验法则是确保你的catch
块的结构是这样的,第一个 catch 是最具体的异常(即异常类型继承链中最具派生性的类型),最后一个catch
是最一般的异常(即给定异常继承链的基类,在这里是System.Exception
)。
因此,如果你想定义一个catch
块来处理任何超过CarIsDeadException
和ArgumentOutOfRangeException
的错误,你可以写如下:
// This code compiles just fine.
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
// Trigger an argument out of range exception.
myCar.Accelerate(-10);
}
catch (CarIsDeadException e)
{
Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message);
}
// This will catch any other exception
// beyond CarIsDeadException or
// ArgumentOutOfRangeException.
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Console.ReadLine();
Note
只要有可能,总是支持捕获特定的异常类,而不是一般的System.Exception
。虽然它可能在短期内使生活变得简单(你可能会想“啊!这抓住了我不关心的所有其他事情。”),从长远来看,您可能会以奇怪的运行时崩溃告终,因为您的代码中没有直接处理更严重的错误。记住,处理System.Exception
的最后一个catch
块实际上非常通用。
通用 catch 语句
C# 还支持一个“通用”catch
作用域,该作用域不显式接收由给定成员抛出的异常对象。
// A generic catch.
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
myCar.Accelerate(90);
}
catch
{
Console.WriteLine("Something bad happened...");
}
Console.ReadLine();
显然,这不是处理异常的最有用的方法,因为您无法获得关于所发生错误的有意义的数据(例如方法名、调用堆栈或自定义消息)。尽管如此,C# 确实允许这样的构造,当您希望以一种通用的方式处理所有错误时,这是很有帮助的。
再次引发异常
当你捕捉到一个异常时,允许一个try
块中的逻辑将异常重新抛出到调用栈中的前一个调用者。为此,只需在catch
块中使用throw
关键字。这将异常沿调用逻辑链向上传递,如果您的catch
块只能部分处理手边的错误,这将很有帮助。
// Passing the buck.
...
try
{
// Speed up car logic...
}
catch(CarIsDeadException e)
{
// Do any partial processing of this error and pass the buck.
throw;
}
...
请注意,在这个示例代码中,CarIsDeadException
的最终接收者是。因为它是重新引发异常的顶级语句。因此,您的最终用户会看到系统提供的错误对话框。通常,您只会将部分处理的异常重新引发给有能力更优雅地处理传入异常的调用方。
还要注意,您没有显式地重新抛出CarIsDeadException
对象,而是使用了不带参数的throw
关键字。您没有创建新的异常对象;您只是重新抛出原始异常对象(及其所有原始信息)。这样做可以保留原始目标的上下文。
内部异常
正如您可能会怀疑的那样,在您处理另一个异常时触发一个异常是完全可能的。例如,假设您正在一个特定的catch
范围内处理一个CarIsDeadException
,在这个过程中,您试图将堆栈跟踪记录到您的C:
驱动器上一个名为carErrors.txt
的文件中(您必须指定您正在使用System.IO
命名空间来获得对这些以 I/O 为中心的类型的访问)。
catch(CarIsDeadException e)
{
// Attempt to open a file named carErrors.txt on the C drive.
FileStream fs = File.Open(@"C:\carErrors.txt", FileMode.Open);
...
}
现在,如果指定的文件不在您的C:
驱动器上,那么对File.Open()
的调用将导致一个FileNotFoundException
!在本书的后面,您将了解关于System.IO
名称空间的所有内容,在这里,您将发现如何在试图打开文件之前以编程方式确定文件是否存在于硬盘上(从而完全避免异常)。然而,为了保持对异常主题的关注,假设已经引发了异常。
当您在处理另一个异常时遇到另一个异常时,最佳实践表明,您应该将新的异常对象作为“内部异常”记录在与初始异常类型相同的新对象中。(真是拗口!)您需要为正在处理的异常分配一个新对象的原因是,记录内部异常的唯一方法是通过构造函数参数。考虑以下代码:
using System.IO;
//Update the exception handler
catch (CarIsDeadException e)
{
try
{
FileStream fs =
File.Open(@"C:\carErrors.txt", FileMode.Open);
...
}
catch (Exception e2)
{
//This causes a compile error-InnerException is read only
//e.InnerException = e2;
// Throw an exception that records the new exception,
// as well as the message of the first exception.
throw new CarIsDeadException(
e.CauseOfError, e.ErrorTimeStamp, e.Message, e2); }
}
注意,在这种情况下,我将FileNotFoundException
对象作为第四个参数传递给了CarIsDeadException
构造函数。在配置了这个新对象之后,您将它在调用堆栈中向上抛给下一个调用者,在本例中是顶级语句。
假设在顶级语句之后没有“下一个调用者”来捕捉异常,您将再次看到一个错误对话框。与再次引发异常的行为非常相似,记录内部异常通常只有在调用者有能力首先捕获异常时才有用。如果是这种情况,调用者的catch
逻辑可以使用InnerException
属性提取内部异常对象的细节。
最终块
一个try
/ catch
作用域也可以定义一个可选的finally
块。一个finally
块的目的是确保一组代码语句将总是执行,不管是否有异常(任何类型)。举例来说,假设您希望在退出程序之前总是关闭汽车的收音机,而不考虑任何已处理的异常。
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
myCar.CrankTunes(true);
try
{
// Speed up car logic.
}
catch(CarIsDeadException e)
{
// Process CarIsDeadException.
}
catch(ArgumentOutOfRangeException e)
{
// Process ArgumentOutOfRangeException.
}
catch(Exception e)
{
// Process any other Exception.
}
finally
{
// This will always occur. Exception or not.
myCar.CrankTunes(false);
}
Console.ReadLine();
如果您没有包含finally
块,无线电将不会在遇到异常时关闭(这可能有问题,也可能没有问题)。在一个更真实的场景中,当您需要处理对象、关闭文件或从数据库中分离(或其他)时,一个finally
块确保了一个适当清理的位置。
异常过滤器
C# 6 引入了一个新的子句,可以通过关键字when
放在catch
范围内。当您添加这个子句时,您有能力确保只有当代码中的某些条件为真时,才会执行catch
块中的语句。该表达式必须计算为布尔值(真或假),可以通过在when
定义中使用简单的代码语句或在代码中调用额外的方法来获得。简而言之,这种方法允许您向异常逻辑添加“过滤器”。
考虑下面修改过的异常逻辑。我在CarIsDeadException
处理程序中添加了一个when
子句,以确保catch
块永远不会在星期五被执行(这是一个人为的例子,但是谁会希望他们的汽车在周末前抛锚呢?).注意,when
子句中的单个布尔语句必须用括号括起来。
catch (CarIsDeadException e) when (e.ErrorTimeStamp.DayOfWeek != DayOfWeek.Friday)
{
// This new line will only print if the when clause evaluates to true.
Console.WriteLine("Catching car is dead!");
Console.WriteLine(e.Message);
}
虽然这个例子非常牵强,但是使用异常过滤器的一个更实际的用途是捕获SystemException
s。例如,假设您的代码正在将数据保存到数据库,则会引发一个一般的异常。通过检查消息和异常量据,可以根据导致异常的原因创建特定的处理程序。
使用 Visual Studio 调试未处理的异常
Visual Studio 提供了许多工具来帮助您调试未处理的异常。假设您已经将一个Car
对象的速度提高到超过最大值,但是这次没有麻烦将您的调用封装在一个try
块中。
Car myCar = new Car("Rusty", 90);
myCar.Accelerate(100);
如果在 Visual Studio 中启动调试会话(使用“调试”➤“启动调试”菜单选项),Visual Studio 会在引发未捕获的异常时自动中断。更好的是,你会看到一个窗口(见图 7-1 )显示Message
属性的值。
图 7-1。
用 Visual Studio 调试未处理的自定义异常
Note
如果未能处理由。NET 基类库,Visual Studio 调试器在调用有问题的方法的语句处中断。
如果您点击查看详细信息链接,您将找到关于对象状态的详细信息(参见图 7-2 )。
图 7-2。
查看异常详细信息
摘要
在本章中,您研究了结构化异常处理的作用。当一个方法需要向调用者发送一个错误对象时,它会通过 C# throw
关键字分配、配置并抛出一个特定的System.Exception
派生类型。调用者能够使用 C# catch
关键字和可选的finally
作用域来处理任何可能的异常。从 C# 6.0 开始,增加了使用可选的when
关键字创建异常过滤器的能力,C# 7 扩展了抛出异常的位置。
当您创建自己的定制异常时,您最终会创建一个从System.ApplicationException
派生的类类型,这表示一个从当前执行的应用抛出的异常。相反,从System.SystemException
派生的错误对象表示由。NET 5 运行时。最后但同样重要的是,本章说明了 Visual Studio 中可用于创建自定义异常的各种工具(根据。NET 最佳实践)以及调试异常。*