上一节创建了一个用户定义的异常。下面介绍有关异常的第二个示例,这个示例称为SolicitColdCall,它包含两个嵌套的try块,说明了如何定义自定义异常类,再从try块中抛出另一个异常。
这个示例假定一家销售公司希望有更多的客户。该公司的销售部门打算给一些人打电话,希望他们成为自己的客户。用销售行业的行话来讲,就是“陌生电话”(cold-calling)。为此,应有一个文本文件存储这些陌生人的姓名,该文件应有良好的格式,其中第一行包含文件中的人数,后面的行包含这些人的姓名。换言之,正确的格式如下所示。
4
George Washington
Benedict Arnold
John Adams
Thomas Jefferson
这个示例的目的是在屏幕上显示这些人的姓名(由销售人员读取),这就是为什么只把姓名放在文件里,但没有电话号码的原因。
程序要求用户输入文件的名称,然后读取文件,并显示其中的人名。这听起来是一个很简单的任务,但也会出现两个错误,需要退出整个过程:
- 用户可能输入不存在的文件名。这作为FileNotFound异常来捕获。
- 文件的格式可能不正确,这里可能有两个问题。首先,文件的第一行不是整数。第二,文件可能没有第一行指定的那么多人名。这两种情况都需要在一个自定义异常中处理,我们已经专门为此编写了ColdCallFileFormatException异常。
还会有其他问题,虽然不至于退出整个过程,但需要删除某个人名,继续处理文件中的下一个人名(因此这需要在内层的try块中处理)。一些人是商业间谍,为销售公司的竞争对手工作,显然,我们不希望不小心打电话给他们,让这些人知道我们要做的工作。为简单起见,假定姓名以B开头的那些人是商业间谍。这些人应在第一次准备数据文件时从文件中删除,但为防止有商业间谍混入,需要检查文件中的每个姓名,如果检测到一个商业间谍,就应抛出一个SalesSpyFoundException异常,当然,这是另一个自定义异常对象。
最后,编写一个类ColdCallFileReader来实现这个示例,该类维护与cold-call文件的连接,并从中检索数据。我们将以非常安全的方式编写这个类,如果其方法调用不正确,就会抛出异常。例如,如果在文件打开前,调用了读取文件的方法,就会抛出一个异常。为此,我们编写了另一个异常类UnexpectedException。
捕获用户定义的异常
用户自定义异常的代码示例使用了如下名称空间:
System
System.IO
首先是SolicitColdCall示例的Main()方法,它捕获用户定义的异常。注意,下面要调用System.IO名称空间和System名称空间中的文件处理类。
static void Main(string[] args)
{
Console.WriteLine("Please type in the name of the file containing the names of the people to be cold called >");
string path_ = @"C:\Users\singh\Desktop\";
string fileName = Console.ReadLine();
ColdCallFileReaderLoop(path_ + fileName);
}
static void ColdCallFileReaderLoop(string fileName)
{
var peopleToRing = new ColdCallFileReader();
try
{
peopleToRing.Open(fileName);
for(int i = 0;i<peopleToRing.NPeopleToRing;i++)
{
peopleToRing.ProcessNextPerson();
}
System.Console.WriteLine("All callers processed correctly");
}
catch(FileNotFoundException)
{
System.Console.WriteLine($"The file {fileName} does not exist");
}
catch(ColdCallFileFormatException ex)
{
System.Console.WriteLine($"The file {fileName} appers to have been corrupted");
System.Console.WriteLine($"Details of problem are: {ex.Message}");
if(ex.InnerException != null)
{
System.Console.WriteLine($"Inner exception was: {ex.InnerException.Message}");
}
}
catch(Exception ex)
{
System.Console.WriteLine($"Exception occurred:\n {ex.Message}");
}
finally
{
peopleToRing.Dispose();
}
}
}
这段代码基本上只是一个循环,用来处理文件中的人名。开始时,先让用户输入文件名,再实例化ColdCallFileReader类的一个对象,这个类稍后定义,正是这个类负责处理文件中数据的读取。注意,是在第一个try块的外部读取文件——这是因为这里实例化的变量需要在后面的catch块和finally块中使用,如果在try块中声明它们,它们在try块的闭合花括号处就超出了作用域,这会导致异常。
在try块中打开文件(使用ColdCallFileReader.Open()方法),并循环处理其中的所有人名。ColdCallFileReader.ProcessNextPerson()方法会读取并显示文件中的下一个人名,而ColdCallFileReader.NPeopleToRing属性则说明文件中应有多少个人名(通过读取文件的第一行来获得)。有3个catch块,其中两个分别用于处理FileNotFoundException和ColdCallFileFormatException异常,第3个则用于处理任何其他.NET异常。
在FileNotFoundException异常中,我们会为它显示一条消息,注意在这个catch块中,根本不会使用异常实例,原因是这个catch块用于说明应用程序的用户友好性。异常对象一般会包含技术信息,这些技术信息对开发人员很有用,但对于最终用户来说则没有什么用,所以本例将创建一条更简单的消息。
对于ColdCallFileFormatException异常的处理程序,则执行相反的操作,说明了如何获得更完整的技术信息,包括内层异常的细节(如果存在内层异常)。
最后,如果捕获到其他一般异常,就显示一条用户友好信息,而不是让这些异常由.NET运行库处理。注意,我们选择不处理没有派生自System.Exception异常类的异常,因为不直接调用非.NET的代码。
finally块清理资源。在本例中,这是指关闭已打开的任何文件。ColdCallFileReader.Dispose()方法完成了这个任务。
注:C# 提供了一个using语句,编译器自己会在使用该语句的地方创建一个try/finally块,该块调用finall块中的Dispose方法。实现了一个Dispose方法的对象就可以使用using语句。
抛出用户定义的异常
下面看看处理文件读取,以及(可能)抛出用户定义的异常类ColdCallFileReader的定义。因为这个类维护一个外部文件连接,所以需要确保它遵循释放对象的规则,正确地释放它。这个类派生自IDisposable类。
首先声明一些私有字段和属性:
public class ColdCallFileReader : IDisposable
{
private FileStream _fileStream;
private StreamReader _streamReader;
private uint _nPeopleToRing;
public uint NPeopleToRing
{
get
{
if(_isDisposed)
{
throw new ObjectDisposedException("peopleToRing");
}
if(!_isOpen)
{
throw new UnexpectedException("Attempted to access cold-call file that is not open");
}
return _nPeopleToRing;
}
}
private bool _isDisposed = false;
private bool _isOpen = false;
}
FileStream和StreamReader都在System.IO名称空间中,它们都是用于读取文件的基类。FileStream基类主要用于连接文件,StreamReader基类则专门用于读取文本文件,并实现ReadLine()方法,该方法读取文件的一行文本。
_isDisposed字段表示是否调用了Dispose()方法,我们选择实现ColdCallFileReader异常,这样,一旦调用了Dispose()方法,就不能重新打开文件连接,重新使用对象了。_isOpen字段也用于错误检查——在本例中,检查StreamReader基类是否连接到打开的文件上。
打开文件和读取第一行的过程——告诉我们文件中有多少个人名——由Open()方法处理:
public void Open(string fileName)
{
if(_isDisposed)
{
throw new ObjectDisposedException("peopleToRing");
}
_fileStream = new FileStream(fileName,FileMode.Open);
_streamReader = new StreamReader(_fileStream);
try
{
string firstLine = _streamReader.ReadLine();
_nPeopleToRing = uint.Parse(firstLine);
_isOpen = true;
}
catch(FormatException ex)
{
throw new ColdCallFileFormatException($"First line isn't an integer {ex.Message}");
}
}
与ColdCallFileReader异常类的所有其他方法一样,该方法首先检查再删除对象后,客户端代码是否不正确地调用了它,如果是,就抛出一个预定义的ObjectDisposedException异常对象。Open()方法也会检查_isDisosed字段,看看是否已调用Dispose()方法。因为调用Dispose()方法会告诉调用者现在已经处理完对象,所以,如果已经调用了Dispose()方法,就说明有一个试图打开新文件连接的错误。
接着,这个方法包含前两个内层的try块,其目的是捕获因为文件的第一行没有包含一个整数而抛出的任何错误。如果出现这个问题,.NET运行库就抛出一个FormatException异常,该异常捕获并转换为一个更有意义的异常,这个更有意义的异常表示cold-call文件的格式有问题。注意System.FormatException异常表示与基类数据类型相关的格式问题,而不是与文件有关,所以在本例中它不是传递回主调例程的一个特别有用的异常。新抛出的异常会被最外层的try块捕获。因为这里不需要清理资源,所以不需要finally块。
如果一切正常,就把_isOpen字段设置为true,表示现在有一个有效的文件连接,可以从中读取数据。
ProcessNextPerson()方法也包含一个内层try块:
public void ProcessNextPerson()
{
if(_isDisposed)
{
throw new ObjectDisposedException("peopleToRing");
}
if(!_isOpen)
{
throw new UnexpectedException("Attempt to access coldcall file that is not open");
}
try
{
string name = _streamReader.ReadLine();
if(name == null)
{
throw new ColdCallFileFormatException("Not enough names");
}
if(name[0] == 'B')
{
throw new SalesSpyFoundException($"Sales spy found, with name: {name}");
}
System.Console.WriteLine(name);
}
catch(SalesSpyFoundException ex)
{
System.Console.WriteLine(ex.Message);
}
finally
{
}
}
这里可能存在两个与文件相关的错误(假定实际上有一个打开的文件连接,ProcessNextPerson()方法会先进行检查)。第一,读取下一个人名时,可能发现这是一个商业间谍。如果发生这种情况,在这个方法中就使用第一个catch块捕获异常。因为这个异常已经在循环中被捕获,所以程序流会继续在程序的Main()方法中执行,处理文件中的下一个人名。
如果读取下一个人名,发现已经到达文件的末尾,就会发生错误。StreamReader对象的ReadLine()方法的工作方式是:如果到达文件末尾,它就会返回一个null,而不是抛出一个异常。所以,如果找到一个null字符串,就说明文件的格式不正确,因为文件的第一行的数字要比文件中的实际人数多。如果发生这种错误,就抛出一个ColdCallFileFormatException异常,它由外层的异常处理程序捕获(使程序终止执行)。
同样,这里不需要finally块,因为没有要清理的资源,但这次要放置一个空的finally块,表示在这里可以完成用户希望完成的任务。
这个示例就要完成了。ColdCallFileReader异常类还有另外两个成员:NPeopleToRing属性返回文件中应有的人数,Dispose()方法可以关闭已打开的文件。注意Dispose()方法仅返回它是否被调用——这是实现该方法的推荐方式。它还检查在关闭前是否有一个文件流要关闭。这个例子说明了防御编码技术:
public uint NPeopleToRing
{
get
{
if(_isDisposed)
{
throw new ObjectDisposedException("peopleToRing");
}
if(!_isOpen)
{
throw new UnexpectedException("Attempted to access cold-call file that is not open");
}
return _nPeopleToRing;
}
}
public void Dispose()
{
if(_isDisposed)
{
return;
}
_isDisposed = true;
_isOpen = false;
_fileStream?.Dispose();
_fileStream = null;
}
定义用户定义的异常类
最后,需要定义3个异常类。定义自己的异常非常简单,因为几乎不需要添加任何额外的方法。只需要实现构造函数,确保基类的构造函数正确调用即可。下面是实现SalesSpyFoundException异常类的完整代码:
public class SalesSpyFoundException: Exception
{
public SalesSpyFoundException(string message)
:base(message){}
public SalesSpyFoundException(string message,Exception innerException)
:base(message,innerException){}
}
注意,这个类派生自Exception异常类,正是我们期望的自定义异常。实际上,如果要更正式地创建它,可以把它放在一个中间类中,例如,ColdCallFileException异常类,让它派生于Exception异常类,再从这个类派生出两个异常类,并确保处理代码可以很好地控制哪个异常处理程序处理哪个异常即可。但为了使这个示例比较简单,就不这么操作了。
在SalesSpyFoundException异常类中,处理的内容要多一些。假定传送给它的构造函数的信息仅是找到的间谍名,从而把这个字符串转换为更明确的错误信息。我们还提供了两个构造函数,其中一个构造函数的参数只是一条信息,另一个构造函数的参数是一个内层异常。在定义自己的异常类时,至少把这两个构造函数都包括进来(尽管以后将不能在示例中使用SalesSpyFoundException 异常类的第2个构造函数)。
对于ColdCallFileFormatException异常类,规则是一样的,但不必对消息进行任何处理:
public class ColdCallFileFormatException: Exception
{
public ColdCallFileFormatException(string message):base(message){}
public ColdCallFileFormatException(string message,Exception innerException)
:base(message,innerException){}
}
最后是UnexpectedException异常类,它看起来与ColdCallFileFormatException异常类是一样的:
public class UnexpectedException: Exception
{
public UnexpectedException(string message)
:base(message){}
public UnexpectedException(string message,Exception innerException)
:base(message,innerException){}
}
下面准备测试该程序。首先,使用people.txt文件,其内容已经在前面列出了。
4
George Washington
Benedict Arnold
John Adams
Thomas Jefferson
它有4个名字(与文件中第一行给出的数字匹配),包括一个间谍。接着,使用下面的people2.txt文件,它有一个明显的格式错误:
49
George Washington
Benedict Arnold
John Adams
Thomas Jefferson
最后,尝试执行该例子,但指定一个不存在的文件名people3.txt,对这3个文件名运行程序3次,得到的结果如下:
Please type in the name of the file containing the names of the people to be cold called
people.txt
George Washington
Sales spy found, with name: Benedict Arnold
John Adams
Thomas Jefferson
All callers processed correctly
PS D:\vscodesamples\用户定义的异常类> dotnet run
people2.txt
George Washington
Sales spy found, with name: Benedict Arnold
John Adam
Thomas Jefferson
The file C:\Users\singh\Desktop\people2.txt appers to have been corrupted
Details of problem are: Not enough names
PS D:\vscodesamples\用户定义的异常类> dotnet run
Please type in the name of the file containing the names of the people to be cold called
people3.txt
The file C:\Users\singh\Desktop\people3.txt does not exist
PS D:\vscodesamples\用户定义的异常类>
调用者信息
在处理错误时,获得错误发生位置的信息常常是有帮助的。本章全面介绍的#line预处理器指令用于改变代码的行号,获得调用堆栈的更好的信息。为了从代码中获得行号、文件名和成员名,可以使用C#编译器直接支持的特性和可选参数。这些特性包括CallerLineNumber、CallerFilePath和CallerMemberName,它们定义在System.Runtime.CompilerServices名称空间中,可以应用到参数上。对于可选参数,当没有提供调用信息时,编译器会在调用方法时为它们使用默认值。有了调用者信息特性,编译器不会填入默认值,而是填入行号、文件名和成员名称。
代码示例CallerInformation使用了如下名称空间:
System
System.Runtime.CompilerServices
下面代码段中的Log方法演示了这些特性的用法。这段代码将信息写入控制台中:
public void Log([CallerLineNumber] int line = -1,[CallerFilePath] string path = null,
[CallerMemberName] string name = null)
{
System.Console.WriteLine((line < 0)?"No line":"line" +line);
System.Console.WriteLine((path == null?"No file path":path));
System.Console.WriteLine((name == null)?"No member name":name);
System.Console.WriteLine();
}
下面在几种不同的场景中调用该方法。在下面的Main()方法中,分别使用Progrm类的一个实例来调用Log()方法,在属性的set访问器中调用Log()方法,以及在一个lambda表达式中调用Log()方法。这里没有为该方法提供参数值,所以编译器会为其填入值:
static void Main(string[] args)
{
var p = new Program();
p.Log();
p.SomeProperty = 33;
Action action = ()=>p.Log();
action();
}
private int _someProperty;
public int SomeProperty
{
get=>_someProperty;
set
{
Log();
_someProperty = value;
}
}
运行此程序的结果如下所示。在调用Log()方法的地方,可以看到行号、文件名和调用者的成员名。对于Main()方法中调用的Log()方法,成员名为Main。对于属性SomeProperty的set访问器中调用的Log()方法,成员名为SomeProperty。lambda表达式中的Log()方法没有显示生成的方法名,而是显示了调用该lambda表达式的方法的名称(Main),这当然更加有用。
line11
D:\vscodesamples\CallerInformation\Program.cs
Main
line32
D:\vscodesamples\CallerInformation\Program.cs
SomeProprety
line13
D:\vscodesamples\CallerInformation\Program.cs
Main
在构造函数中使用Log()方法时,调用者成员名显示为ctor。在析构函数中,调用者成员名为Finalize,因为它是生成的方法的名称。
注:CallerMemberName属性的一个很好的用途是用在INotifyPropertyChanged接口的实现中。该接口要求在方法的实现中传递属性的名称。