构造函数何时抛出异常是正确的?

构造函数何时抛出异常是正确的?(或者在Objective C的情况下:什么情况下init ` er才应该返回nil?)

在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。也就是说,构造函数应该与它的调用者有一个合同,以提供一个函数和工作对象,在哪些方法可以被有意义地调用?这合理吗?

92307 次浏览

由于部分创建的类可能导致的所有麻烦,我认为永远不会。

如果需要在构造过程中验证某些内容,请将构造函数设为私有并定义一个公共静态工厂方法。如果某些东西无效,该方法可以抛出。但是如果一切都检查出来了,它就调用构造函数,保证不会抛出。

这总是很危险的,特别是当你在构造函数中分配资源时;根据你的语言,析构函数不会被调用,所以你需要手动清理。这取决于在您的语言中对象的生命周期何时开始。

我真正这么做的唯一一次是在某个地方出现了安全问题,这意味着对象不应该,而不是不能,被创建。

构造函数的任务是使对象进入可用状态。关于这个问题,基本上有两种观点。

一组人赞成两阶段建设。构造函数只是将对象带入一个休眠状态,在这种状态下它拒绝做任何工作。还有一个额外的函数来进行实际的初始化。

我一直不明白这种方法背后的原因。我坚决支持单阶段构造,即对象在构造后完全初始化并可用。

如果单阶段构造函数未能完全初始化对象,则应该抛出。如果对象不能初始化,则必须不允许它存在,因此构造函数必须抛出。

参见c++常见问题解答17.217.4部分。

一般来说,我发现如果构造函数被编写,那么它们就不会失败,那么移植和维护结果的代码就会更容易,而可能失败的代码则放在一个单独的方法中,该方法返回错误代码并使对象处于惰性状态。

构造函数抛出异常是合理的,只要它正确地清理了自己。如果你遵循RAII范式(资源获取是初始化),那么构造函数做有意义的工作是很常见的;如果构造函数不能完全初始化,那么编写良好的构造函数将自行清理。

是的,如果构造函数未能构建其内部部分之一,它可以-通过选择-抛出显式异常(并在某些语言中声明)的责任,在构造函数文档中适当地指出。

这不是唯一的选择:它可以完成构造函数并构建一个对象,但是使用方法'isCoherent()'返回false,以便能够表示一个不一致的状态(在某些情况下可能更可取,以避免由于异常而导致执行工作流的残酷中断)
警告:正如EricSchaefer在他的评论中所说,这可能会给单元测试带来一些复杂性(抛出会由于触发它的条件而增加函数的圈复杂度)

如果它因为调用者而失败(比如调用者提供了一个空参数,而被调用的构造函数需要一个非空参数),构造函数无论如何都会抛出一个未检查的运行时异常。

在构造过程中抛出异常是使代码更加复杂的好方法。看似简单的事情突然变得很难。例如,假设你有一个堆栈。如何弹出堆栈并返回顶部值?好吧,如果堆栈中的对象可以抛出它们的构造函数(构造临时函数以返回给调用者),你不能保证不会丢失数据(递减堆栈指针,使用堆栈中value的复制构造函数构造返回值,这会抛出,现在有一个刚刚丢失一项的堆栈)!这就是为什么std::stack::pop不返回值,你必须调用std::stack::top。

此问题已详细描述在这里,检查项目10,正在编写异常安全代码。

如果无法创建有效对象,则绝对应该从构造函数抛出异常。这允许您在类中提供适当的不变量。

在实践中,你可能必须非常小心。记住,在c++中,析构函数不会被调用,所以如果你在分配资源后抛出,你需要非常小心地正确处理它!

这个页面对c++中的情况进行了彻底的讨论。

严格地从Java的角度来说,任何时候初始化使用非法值的构造函数时,它都应该抛出异常。这样它就不会被构造成一个糟糕的状态。

对我来说,这是一个有点哲学的设计决策。

拥有实例是非常好的,只要它们存在就有效,从ctor时间开始。对于许多重要的情况,如果无法进行内存/资源分配,则可能需要从ctor抛出异常。

其他一些方法是init()方法,它本身存在一些问题。其中之一是确保init()实际被调用。

一个变体使用惰性方法在第一次调用访问器/突变器时自动调用init(),但这要求任何潜在的调用者都必须担心对象是否有效。(而不是“它存在,因此它是有效的哲学”)。

我也看到过处理这个问题的各种设计模式。例如,可以通过ctor创建初始对象,但必须调用init()来获得包含的、初始化的具有访问器/突变器的对象。

每种方法都有其利弊;我已经成功地使用了所有这些方法。如果不是在创建对象的那一刻就创建现成的对象,那么我建议使用大量的断言或异常,以确保用户在init()之前不会进行交互。

齿顶高

我是从c++程序员的角度写的。我还假设您正确地使用了RAII习惯用法来处理抛出异常时释放的资源。

OO中通常的约定是对象方法确实有功能。

因此,作为一个推论,永远不要从构造函数/init中返回僵尸对象。

僵尸没有功能,可能缺少内部组件。只是一个空指针异常等待发生。

很多年前,我第一次在Objective C中制作僵尸。

就像所有的经验法则一样,也有一个“例外”。

特定的接口完全有可能有一个这样说的契约 存在一个允许触发异常的方法“initialize”。 在调用initialize之前,实现此接口的对象可能不会正确响应除属性设置器之外的任何调用。在引导过程中,我将此用于OO操作系统中的设备驱动程序,并且它是可行的

一般来说,你不需要僵尸对象。在Smalltalk这样带有成为的语言中,事情会变得有点冒火,但过度使用成为也是糟糕的风格。be允许一个对象在原地变成另一个对象,因此不需要信封包装器(高级c++)或策略模式(GOF)。

Eric Lippert说有4种异常。

  • 致命异常不是您的错,您无法阻止它们,也无法理智地清除它们。
  • 愚蠢的异常是你自己的错误,你本可以阻止它们,因此它们是你代码中的bug。
  • 令人烦恼的异常是不幸的设计决策的结果。令人烦恼的异常是在完全非异常的情况下抛出的,因此必须一直捕获和处理。
  • 最后,外生异常看起来有点像令人烦恼的异常,只不过它们不是不幸的设计选择的结果。相反,它们是不整洁的外部现实影响到您漂亮、清晰的程序逻辑的结果。

构造函数本身不应该抛出致命异常,但它执行的代码可能会导致致命异常。像“内存不足”这样的事情不是您可以控制的,但是如果它发生在构造函数中,嘿,它就发生了。

愚蠢的异常永远不应该出现在任何代码中,所以它们应该被清除。

构造函数不应该抛出恼人的异常(例如Int32.Parse()),因为它们没有非异常情况。

最后,应该避免外生异常,但如果在构造函数中执行的某些操作依赖于外部环境(如网络或文件系统),则抛出异常是合适的。

参考链接:https://blogs.msdn.microsoft.com/ericlippert/2008/09/10/vexing-exceptions/

我不能说明Objective-C中的最佳实践,但在c++中,构造函数抛出异常是可以的。特别是没有其他方法可以确保在不调用isOK()方法的情况下报告构造过程中遇到的异常情况。

函数try块特性是专门为支持构造函数成员初始化失败而设计的(尽管它也可以用于常规函数)。这是修改或丰富将要抛出的异常信息的唯一方法。但由于其最初的设计目的(在构造函数中使用),它不允许空catch()子句包含异常。

当构造函数无法完成所述对象的构造时,它应该抛出异常。

例如,如果构造函数应该分配1024 KB的ram,但它没有这样做,它应该抛出一个异常,这样构造函数的调用者就知道对象还没有准备好使用,并且在某个地方存在需要修复的错误。

半初始化和半死亡的对象只会引起问题和问题,因为调用者确实没有办法知道。我宁愿让我的构造函数在出错时抛出一个错误,而不是不得不依赖编程来运行返回true或false的isOK()函数的调用。

如果无法在构造函数中初始化对象,则抛出异常,一个例子是非法参数。

根据一般经验,应该尽可能快地抛出异常,因为当问题的根源接近发出错误信号的方法时,这会使调试变得更容易。

将对象初始化与构造分离不会得到任何东西。RAII是正确的,对构造函数的成功调用要么会导致完全初始化的活动对象,要么会失败,并且在任何代码路径中的任何一点上所有失败都应该抛出异常。通过使用单独的init()方法,除了在某种程度上增加复杂性之外,您什么也得不到。ctor契约要么返回一个有效的函数对象,要么清理自己并抛出。

考虑一下,如果你实现了一个单独的init方法,你仍然必须调用它。它仍然有可能抛出异常,它们仍然必须被处理,而且它们实际上总是必须在构造函数之后立即被调用,除了现在你有4种可能的对象状态而不是2种(IE,已构造,初始化,未初始化,失败vs只是有效和不存在)。

无论如何,我在25年的OO开发案例中遇到过,一个单独的init方法似乎可以“解决一些问题”,这是设计缺陷。如果你现在不需要一个对象,那么你现在就不应该构造它,如果你现在确实需要它,那么你需要初始化它。应该始终遵循KISS原则,以及任何接口的行为、状态和API应该反映对象做了什么,而不是如何做的简单概念,客户端代码甚至不应该知道对象有任何需要初始化的内部状态,因此init after模式违反了这一原则。

对所有对象创建使用工厂或工厂方法,可以避免无效对象,而不会从构造函数抛出异常。创建方法应该返回被请求的对象(如果它能够创建一个对象),如果不能,则返回null。在处理类的用户中的构造错误时,您失去了一点灵活性,因为返回null并不能告诉您在对象创建中发生了什么错误。但它也避免了每次请求对象时增加多个异常处理程序的复杂性,以及捕获不应该处理的异常的风险。

如果你正在编写ui控件(ASPX, WinForms, WPF,…),你应该避免在构造函数中抛出异常,因为设计器(Visual Studio)在创建控件时无法处理它们。了解你的控件生命周期(控件事件),尽可能使用惰性初始化。

注意,如果在初始化式中抛出异常,如果任何代码使用[[[MyObj alloc] init] autorelease]模式,则最终会泄漏,因为异常将跳过自动释放。

看这个问题:

在init中引发异常时如何防止泄漏?< / >

据我所知,没有人能提出一个相当明显的解决方案,同时体现了一阶段和两阶段结构的优点。

注意:这个答案假设c#,但原则可以应用于大多数语言。

一、两者的好处:

单程

一级构造通过防止对象以无效状态存在而使我们受益,从而防止了各种错误的状态管理和随之而来的所有错误。然而,这让我们中的一些人感到奇怪,因为我们不希望我们的构造函数抛出异常,而当初始化参数无效时,有时我们需要这样做。

public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }


public Person(string name, DateTime dateOfBirth)
{
if (string.IsNullOrWhitespace(name))
{
throw new ArgumentException(nameof(name));
}


if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(dateOfBirth));
}


this.Name = name;
this.DateOfBirth = dateOfBirth;
}
}

两阶段验证法

两阶段构造的好处是允许在构造函数外部执行验证,因此避免了在构造函数内部抛出异常的需要。然而,这给我们留下了“无效”实例,这意味着我们必须跟踪和管理实例的状态,或者在堆分配后立即丢弃它。这就引出了一个问题:为什么我们要在一个我们最终甚至不使用的对象上执行堆分配,从而进行内存收集?

public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }


public Person(string name, DateTime dateOfBirth)
{
this.Name = name;
this.DateOfBirth = dateOfBirth;
}


public void Validate()
{
if (string.IsNullOrWhitespace(Name))
{
throw new ArgumentException(nameof(Name));
}


if (DateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
}
}
}

通过私有构造函数实现单阶段

那么,我们如何在构造函数中保持异常,并防止自己对立即被丢弃的对象执行堆分配呢?这是非常基本的:我们将构造函数设为私有,并通过指定执行实例化的静态方法创建实例,因此堆分配只需要< em > < / em >后验证。

public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }


private Person(string name, DateTime dateOfBirth)
{
this.Name = name;
this.DateOfBirth = dateOfBirth;
}


public static Person Create(
string name,
DateTime dateOfBirth)
{
if (string.IsNullOrWhitespace(Name))
{
throw new ArgumentException(nameof(name));
}


if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
}


return new Person(name, dateOfBirth);
}
}

通过私有构造函数异步单级

除了前面提到的验证和堆分配预防的好处之外,前面的方法还为我们提供了另一个漂亮的优点:异步支持。这在处理多阶段身份验证时非常方便,例如在使用API之前需要检索承载令牌。这样,您就不会得到一个无效的“签出”API客户端,相反,如果在尝试执行请求时收到授权错误,您可以简单地重新创建API客户端。

public class RestApiClient
{
public RestApiClient(HttpClient httpClient)
{
this.httpClient = new httpClient;
}


public async Task<RestApiClient> Create(string username, string password)
{
if (username == null)
{
throw new ArgumentNullException(nameof(username));
}


if (password == null)
{
throw new ArgumentNullException(nameof(password));
}


var basicAuthBytes = Encoding.ASCII.GetBytes($"{username}:{password}");
var basicAuthValue = Convert.ToBase64String(basicAuthBytes);


var authenticationHttpClient = new HttpClient
{
BaseUri = new Uri("https://auth.example.io"),
DefaultRequestHeaders = {
Authentication = new AuthenticationHeaderValue("Basic", basicAuthValue)
}
};


using (authenticationHttpClient)
{
var response = await httpClient.GetAsync("login");
var content = response.Content.ReadAsStringAsync();
var authToken = content;
var restApiHttpClient = new HttpClient
{
BaseUri = new Uri("https://api.example.io"), // notice this differs from the auth uri
DefaultRequestHeaders = {
Authentication = new AuthenticationHeaderValue("Bearer", authToken)
}
};


return new RestApiClient(restApiHttpClient);
}
}
}

根据我的经验,这种方法的缺点很少。

通常,使用这种方法意味着您不能再将该类用作DTO,因为在没有公共默认构造函数的情况下反序列化到对象是困难的。但是,如果您使用对象作为DTO,则不应该真正验证对象本身,而应该在尝试使用对象上的值时使它们无效,因为从技术上讲,这些值对于DTO来说并不是“无效”的。

这也意味着当您需要允许IOC容器创建对象时,您将最终创建工厂方法或类,否则容器将不知道如何实例化对象。然而,在很多情况下,工厂方法本身就是Create方法之一。

我所见过的关于异常的最好建议是,当且仅当替代方案是未能满足post条件或保持不变时,抛出异常。

该建议将不明确的主观决策(是否是好主意)替换为基于设计决策(不变条件和后置条件)的技术精确问题。

构造函数只是该建议的一种特殊情况,但并不特殊。那么问题来了,一个类应该有哪些不变量?主张在构造之后调用单独的初始化方法的人建议类有两个或多个操作模式,构造之后有一个没准备的模式,初始化之后至少有一个准备好了模式。这是一个额外的复杂性,但如果类有多种操作模式,这是可以接受的。如果这个职业没有操作模式的话,很难看出这种复杂性是有价值的。

请注意,将setup推入单独的初始化方法中并不能避免抛出异常。构造函数可能抛出的异常现在将由初始化方法抛出。如果为未初始化的对象调用类中所有有用的方法,都必须抛出异常。

还要注意,避免构造函数抛出异常的可能性是很麻烦的,在许多标准库中,在许多情况下不可能的。这是因为这些库的设计者认为从构造函数抛出异常是个好主意。特别是,任何试图获取不可共享或有限资源(例如分配内存)的操作都可能失败,而这种失败通常在OO语言和库中通过抛出异常来表示。

我不确定是否有答案可以完全与语言无关。有些语言处理异常和内存管理的方式不同。

我以前在编码标准下工作过,这些标准要求永远不使用异常,并且只在初始化器上使用错误代码,因为开发人员已经被这种语言处理异常不当弄得精疲力竭。没有垃圾收集的语言处理堆和堆栈的方式非常不同,这对于非RAII对象可能很重要。重要的是,团队决定保持一致,这样他们就知道默认情况下是否需要在构造函数之后调用初始化函数。所有的方法(包括构造函数)都应该有良好的文档记录,说明它们可以抛出哪些异常,以便调用者知道如何处理它们。

我通常支持单阶段构造,因为很容易忘记初始化对象,但也有很多例外。

  • 您的语言对异常的支持不是很好。
  • 你有一个紧迫的设计理由仍然使用newdelete
  • 初始化是处理器密集型的,应该对创建对象的线程异步运行。
  • 您正在创建一个DLL,该DLL可能会在其接口之外抛出异常,以使用不同语言的应用程序。在这种情况下,问题可能不是不抛出异常,而是确保在公共接口之前捕获异常。(你可以在c#中捕获c++异常,但有很多障碍。)
  • 静态构造函数(c#)

OP的问题有一个“语言不可知论”的标签…对于所有语言/情况,这个问题不能以同样的方式安全地回答。

下面的c#示例的类层次结构抛出类B的构造函数,在退出主类的using时跳过对类A的IDisposeable.Dispose的立即调用,跳过对类A资源的显式处理。

例如,如果类A在构造时创建了一个Socket,连接到一个网络资源,在using块之后可能仍然是这种情况(一个相对隐藏的异常)。

class A : IDisposable
{
public A()
{
Console.WriteLine("Initialize A's resources.");
}


public void Dispose()
{
Console.WriteLine("Dispose A's resources.");
}
}


class B : A, IDisposable
{
public B()
{
Console.WriteLine("Initialize B's resources.");
throw new Exception("B construction failure: B can cleanup anything before throwing so this is not a worry.");
}


public new void Dispose()
{
Console.WriteLine("Dispose B's resources.");
base.Dispose();
}
}
class C : B, IDisposable
{
public C()
{
Console.WriteLine("Initialize C's resources. Not called because B throws during construction. C's resources not a worry.");
}


public new void Dispose()
{
Console.WriteLine("Dispose C's resources.");
base.Dispose();
}
}




class Program
{
static void Main(string[] args)
{
try
{
using (C c = new C())
{
}
}
catch
{
}


// Resource's allocated by c's "A" not explicitly disposed.
}
}