在构造函数中的异常处理

8

你可以在构造函数内部使用 throwtry-catch 吗?
如果可以,那么拥有一个可能抛出异常的构造函数参数有什么用处呢?

以下是一个示例构造函数:

public Chat()
{
    chatClient = new Client(Configuration.LoginEmail, Configuration.LoginPassword);
    chatRoom = chatClient.JoinRoom(Configuration.RoomUrl);
}

chatRoom = chatClient.JoinRoom(Configuration.RoomUrl);这行代码可能会抛出异常。


1
如果构造函数执行任何工作,例如“加入房间”等操作,则通常会出现代码异味。您应该将此操作移动到单独的函数中。 - André Snede
1
请参见 https://dev59.com/fnVD5IYBdhLWcg3wHn6d,该问题与此问题类似但不完全相同,并且有很好的答案。 - Preston Guillot
在这种情况下,Eric J.的答案是正确的。 - André Snede
@Enigmativity 构造函数中抛出的异常是构造函数的一个重点之一;确保对象以有效状态启动,并在参数不允许时抛出异常。只有在静态构造函数中它们才是不好的;在实例构造函数中,它们会说“对不起,您试图将此实例放入错误状态”,但是一旦您说“对不起,您已经将此类置于错误状态”,那么该类就没有任何用处了。 - Jon Hanna
希望明天我也能记得。现在我只是浏览一些代码的测试运行,幸运的是它们并没有花费太长时间,以至于让我有足够的时间来作出完整的回答 :) - Jon Hanna
显示剩余2条评论
3个回答

27

在适当的情况下抛出异常是构造函数工作的一部分。

让我们考虑为什么我们需要构造函数。

其中一个部分是方便拥有设置各种属性的方法,并且可能会执行一些更高级的初始化工作(例如,FileStream 实际上将访问相关文件)。但是,如果始终如此方便,我们就不会有时候发现成员初始化器很方便了。

构造函数的主要原因是使我们能够维护对象不变量。

对象不变量是我们可以在每个方法调用的开始和结束时对对象说的话。(如果它被设计用于并发使用,我们甚至会有在方法调用期间保持的不变量)。

Uri 类的不变量之一是,如果 IsAbsoluteUri 为 true,则 Host 将是一个有效的主机字符串(如果 IsAbsoluteUri 为 false,则 Host 可能是一个有效的主机,如果它是方案相关的,或者访问它可能会导致一个 InvalidOperationException)。

因此,当我正在使用这样一个类的对象并已检查了 IsAbsoluteUri 后,我知道我可以访问 Host 而不会出现异常。我也知道它确实是一个主机名,而不是例如有关贝珍石在中世纪和近代早期信仰中的避邪作用的简短论文。

好吧,将这样的论文放入代码中并不太可能,但是将某些垃圾放入对象中的代码肯定是存在的。

维护不变量归结为确保对象持有的值组合始终合理。这必须在任何更改对象的属性设置器或方法(或通过使对象不可变,因为如果从未进行更改,则永远不会有无效更改)以及最初设置值的方法(即构造函数中)中完成。

在强类型语言中,我们从类型安全性中获得了一些检查(必须在015之间的数字永远不会被设置为"Modern analysis has found that bezoars do indeed neutralise arsenic.",因为编译器根本不允许您这样做),但其余部分呢?

考虑带有参数的 List<T> 构造函数。其中一个接受一个整数,并相应地设置内部容量,另一个接受要填充列表的 IEnumerable<T>。这两个构造函数的开头如下:

public List(int capacity)
{
    if (capacity < 0) throw new ArgumentOutOfRangeException("capacity", capacity, SR.ArgumentOutOfRange_NeedNonNegNum);

/* … */

public List(IEnumerable<T> collection)
{
    if (collection == null)
        throw new ArgumentNullException("collection");

如果您调用new List<string>(-2)new List<int>(null),则会引发异常,而不是得到一个无效的列表。

关于这种情况需要注意的是,他们可能本可以为调用方“修复”问题。在这种情况下,将负数视为与0相同,将空枚举视为与空枚举相同应该是安全的。但他们仍然选择抛出异常。为什么呢?

好吧,我们在编写构造函数(以及其他方法)时需要考虑三种情况:

  1. 调用方给了我们可以直接使用的值。

嗯,那就使用它们。

  1. 调用方提供了我们根本无法有意义地使用的值。(例如,从枚举中设置一个未定义的值)。

肯定要抛出异常。

  1. 调用方提供了我们可以转换为有用值的值。(例如,将结果数量限制为负数,我们可以认为是与零相同的)。

这是更棘手的情况。我们需要考虑:

  1. 意思是否明确?如果有多种方式可以考虑它的“真正”含义,那么抛出异常。

  2. 调用方是否有可能以合理的方式得出这个结果?如果该值只是纯粹的愚蠢,那么调用方在传递给构造函数(或方法)时可能犯了一个错误,而您在隐藏他们的错误时并没有对他们做任何好处。首先,他们很可能在其他调用中犯下其他错误,但这是表现出来的明显情况。

如果有疑问,请抛出异常。一方面,如果您对应该做什么感到不确定,那么调用方很可能会对您应该做什么感到不确定。另一方面,将会抛出异常的代码转换为不会抛出异常的代码比将不会抛出异常的代码转换为会抛出异常的代码更好,因为后者更有可能将可工作的用法转换为破损的应用程序。

到目前为止,我们只看了可以视为验证的代码;我们被要求做一些愚蠢的事情,而我们拒绝了。另一种情况是当我们被要求执行某些合理的操作(或愚蠢的操作,但我们无法检测出来)而我们不能成功完成时。

new FileStream(@"D:\logFile.log", FileMode.Open);

在这个调用中,没有任何无效的内容应该明确失败。所有验证检查都应该通过。希望以读取模式打开位于D:\logFile.log的文件,并通过FileStream对象让我们可以访问它。

但是,如果没有D:\logFile.log呢?或者没有D:\(这与内部代码可能以不同的方式失败相同),或者我们没有权限打开它。或者它被另一个进程锁定了呢?

在所有这些情况下,我们都未能完成所请求的操作。返回代表尝试读取将全部失败的对象毫无意义!因此,我们在这里抛出异常。

好的。现在考虑采用路径作为参数的StreamReader()的情况。它的工作方式类似于(为了举例而进行了一些调整):

public StreamReader(String path, Encoding encoding, bool detectEncodingFromByteOrderMarks, int bufferSize)
{
    if (path==null || encoding==null)
        throw new ArgumentNullException((path==null ? "path" : "encoding"));
    if (path.Length==0)
        throw new ArgumentException(Environment.GetResourceString("Argument_EmptyPath"));
    if (bufferSize <= 0)
        throw new ArgumentOutOfRangeException("bufferSize", Environment.GetResourceString("ArgumentOutOfRange_NeedPosNum"));
    Contract.EndContractBlock();

    Stream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, DefaultFileStreamBufferSize, FileOptions.SequentialScan, Path.GetFileName(path), false, false, true);
    Init(stream, encoding, detectEncodingFromByteOrderMarks, bufferSize, false);
}

这里介绍了可能发生异常的两种情况。首先,我们对虚假参数进行验证。之后,我们调用了FileStream构造函数,该构造函数反过来可能会引发异常。

在这种情况下,异常被允许通过。

现在,我们需要考虑的情况要稍微复杂一些。

在本回答开头考虑的大多数验证情况下,无论我们以何种顺序进行操作都不会真正影响什么。使用方法或属性时,我们必须确保已将事物更改为有效状态或抛出异常并保持事物不变,否则即使引发异常,对象仍可能处于无效状态(通常做所有验证然后再更改任何内容就足够了)。由于在这种情况下我们不会返回一个对象,因此构造函数中事物的顺序并没有多大关系,所以如果引发异常,则未将任何垃圾放入应用程序。

但是,在上面调用new FileStream()时,可能会产生副作用。重要的是,只有在完成了任何可能引发异常的情况之后才尝试执行它。

同样地,在实践中这很容易做到。将所有验证检查放在前面是很自然的,大多数情况下这就是你需要做的所有事情。但是,一个重要的情况是如果您正在构造过程中获得未管理的资源。如果在这样的构造函数中引发异常,则意味着该对象未被构建。因此它不会被最终化或处置,并且因此未受到释放未管理的资源。

有关避免这种情况的几个指南:

  1. 首先不直接使用未管理的资源。如果可能的话,请通过包装它们的托管类来处理,以便那个对象负责。

  2. 如果确实必须使用未管理的资源,请不要执行任何其他操作。

如果您需要具有未管理的资源和其他状态的类,则结合上述两个指南;创建一个仅处理未管理的资源的包装类,并在您的类中使用它。

  1. 最好使用SafeHandle尽可能持有指向未管理的资源的指针。这很好地解决了第二点的许多问题。

那么,关于捕获异常呢?

我们当然可以这样做。问题是,在捕获到某些东西时应该怎么做?请记住,我们必须创建与我们要求的对象相匹配的对象,或引发异常。大多数情况下,如果我们在其中一些尝试中失败,则没有任何方法可以成功构建对象。因此,我们很可能只是让异常通过,或者捕获异常仅抛出从调用构造函数方面更合适的不同异常。

但是如果我们可以在catch之后继续有意义地进行下去,那么是允许的。

所以总的来说,“你可以在构造函数中使用throw或try和catch吗?”的答案是:“可以”。

但是有一个问题。如上所述,在构造函数中抛出异常的好处在于任何new要么得到一个有效的对象,要么抛出异常;没有中间状态,要么有该对象,要么没有。

然而,静态构造函数是整个类的构造函数。如果实例构造函数失败,你不会得到一个对象,但如果静态构造函数失败,你将不会得到一个类!

在应用程序的余生(严格来说,在应用程序域的余生)内,你几乎注定无法再尝试利用该类或从该类派生的任何类。因此,在静态类中抛出异常通常是一个非常糟糕的主意。如果有可能某些尝试失败的事情会在另一个时间尝试成功,则不应在静态构造函数中这样做。

唯一需要在静态构造函数中抛出异常的情况是当你希望应用程序完全失败时。例如,在缺少重要配置设置的Web应用程序中抛出异常很有用;当然,每个请求都失败并显示相同的错误消息是很烦人的,但这意味着你一定会解决该问题!


3
我本可以更简洁地表达相同的内容,但是这篇文章是在我忙着做家务和照顾孩子的空余时间里断断续续写成的,因此有一些像帕斯卡为写了封长信而道歉的元素。 - Jon Hanna
不用了,留着吧。等有需要引起关注的问题再问吧。我的声望已经足够了 :) - Jon Hanna
1
你的书什么时候出版? - maracuja-juice
@Marimba 2010年2月 - Jon Hanna
我从来不太喜欢在构造函数中抛出异常,因为这会给调用代码带来一种尴尬的负担,让真正的问题变得模糊不清。 - Chris O
1
@ChrisO 我会说相反的。如果一个类不抛出异常,那么就会给程序员带来极其尴尬的负担,需要不断地检查它是否处于有效状态。 - Jon Hanna

2

你可以在构造函数中使用throw或try和catch吗?

两者都是可能的。

如果对象实例在构造过程中可能发生异常,而且你能够采取措施来解决它,那么就捕获并尝试修复它。

如果你对异常无能为力,通常最好允许其传播(而不是将对象实例留在未正确初始化的状态)。


1
如果在实例构造函数中抛出异常,则对象的正常生命周期将被中断。因此,如果对象有析构函数来清理任何资源,则构造函数中抛出的异常将阻止析构函数运行,从而形成内存泄漏。如果在构造函数或任何字段中分配了任何可处理资源,则始终捕获、清理并重新抛出异常。
如果静态构造函数中抛出异常,则该类不再处于可以被AppDomain使用的状态。因此,总是捕获静态构造函数中的异常并处理它们。不要重新抛出异常,也不要让它们未被捕获。
在任何构造函数中捕获和处理异常都是可以的。
但是,与正常情况一样,编写代码以避免异常比让它们发生好1000倍。
参见MSDN Constructor DesignEric Lippert's Vexing Exceptions

2
通常避免异常比让它们发生更好,但是当你正在编写构造函数并考虑当给你错误的参数时该怎么做时,抛出异常比试图回避异常好一千倍。如果结果不好,那么让调用者解决问题而不是掩盖问题。 - Jon Hanna

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接