什么时候构造函数抛出异常?

时间:2008-09-16 21:58:08

标签: exception language-agnostic constructor

构造函数何时抛出异常是正确的? (或者在目标C的情况下:什么时候初始化者返回nil是正确的?)

在我看来,如果对象不完整,构造函数应该失败 - 因此拒绝创建对象。即,构造函数应该与其调用者签订合同,以提供一个功能和工作对象,可以在其上有意义地调用方法?那是合理的吗?

25 个答案:

答案 0 :(得分:256)

构造函数的工作是将对象置于可用状态。基本上有两种思想流派。

一组赞成两阶段建设。构造函数只是将对象置于睡眠状态,在该状态下它拒绝做任何工作。还有一个额外的功能可以进行实际的初始化。

我从来没有理解这种方法背后的原因。我坚定地支持一阶段建设,在建造后对象完全初始化并可用。

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

答案 1 :(得分:55)

Eric Lippert says有4种例外。

  • 致命异常不是你的错,你无法阻止它们,你也无法明智地清除它们。
  • Boneheaded异常是你自己的错误,你可以阻止它们,因此它们是你代码中的错误。
  • 不幸的例外是不幸的设计决定的结果。在完全非特殊情况下抛出了异常情况,因此必须始终抓住并处理。
  • 最后,外生异常似乎有点像烦恼的异常,除了它们不是不幸的设计选择的结果。相反,它们是不整洁的外部现实影响你美丽,清晰的程序逻辑的结果。

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

任何代码都不应该出现Boneheaded异常,因此它们就会出现。

构造函数不应抛出异常异常(示例为Int32.Parse()),因为它们没有非特殊情况。

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

答案 2 :(得分:30)

通过将对象初始化与构造分离,无法获得通常。 RAII是正确的,对构造函数的成功调用应该导致完全初始化的活动对象,否则它应该失败,并且任何代码路径中任何点的 ALL 失败都应该抛出异常。除了某种程度上的额外复杂性之外,您不会通过使用单独的init()方法获得任何收益。 ctor契约应该是它返回一个功能有效的对象,或者它自己清理并抛出。

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

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

答案 3 :(得分:6)

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

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

答案 4 :(得分:5)

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

例如,如果构造函数应该分配1024 KB的ram,并且它不能这样做,它应该抛出一个异常,这样构造函数的调用者知道该对象不准备使用并且那里是一个需要修复的错误。

半初始化和半死的对象只会导致问题和问题,因为调用者真的无法知道。当出现问题时,我宁愿让构造函数抛出错误,而不是依赖编程来运行对isOK()函数的调用,该函数返回true或false。

答案 5 :(得分:4)

构造函数抛出异常是合理的,只要它能够正常清理它。如果您遵循RAII范例(资源获取是初始化),那么 非常常见,以便构造函数执行有意义的工作;如果无法完全初始化,那么编写良好的构造函数将依次清理它。

答案 6 :(得分:4)

据我所知,没有人提出一个相当明显的解决方案,它体现了一阶段和两阶段建设的最佳状态。

注意:这个答案假定为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));
        }
    }
}

通过私有构造函数的单阶段

那么我们如何才能将异常保留在构造函数之外,并阻止自己对将立即丢弃的对象执行堆分配?它非常基本:我们将构造函数设为私有,并通过指定用于执行实例化的静态方法创建实例,从而在 验证后仅执行堆分配

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客户端。

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,则不应该真正验证对象本身,而是在尝试使用对象时无效地识别对象上的值,因为从技术上讲,这些值不是&n;&t #34;无效&#34;关于DTO。

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

答案 7 :(得分:4)

它总是非常狡猾,特别是如果你在构造函数中分配资源;根据您的语言,析构函数不会被调用,因此您需要手动清理。这取决于对象的生命周期何时以您的语言开始。

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

答案 8 :(得分:3)

请参阅C ++常见问题解答部分17.217.4

一般情况下,我发现代码更容易移植和维护结果如果构造函数被编写以便它们不会失败,并且可能失败的代码放在一个单独的方法中,该方法返回错误代码并将对象保留在惰性状态。

答案 9 :(得分:3)

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

答案 10 :(得分:3)

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

看到这个问题:

How do you prevent leaks when raising an exception in init?

答案 11 :(得分:2)

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

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

答案 12 :(得分:2)

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

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

This page对C ++中的情况进行了详尽的讨论。

答案 13 :(得分:1)

OP的问题带有“与语言无关”的标记...对于所有语言/情况,都无法以相同的方式安全地回答该问题。

以下C#示例的类层次结构引发了类B的构造函数,在主体IDisposeable.Dispose退出时跳过对类A的using的立即调用,跳过对类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.
    }
}

答案 14 :(得分:1)

我不确定任何答案都可以完全与语言无关。某些语言以不同方式处理异常和内存管理

之前我曾经在编码标准下工作,要求从不使用异常,只在初始化器上使用错误代码,因为开发人员被语言烧坏,处理异常很差。没有垃圾收集的语言将以非常不同的方式处理堆和堆栈,这对于非RAII对象可能很重要。尽管团队决定保持一致非常重要,因此默认情况下他们知道是否需要在构造函数之后调用初始化程序。所有方法(包括构造函数)也应该很好地记录它们可以抛出的异常,因此调用者知道如何处理它们。

我一般都支持单阶段构造,因为很容易忘记初始化一个对象,但是有很多例外。

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

答案 15 :(得分:1)

OO中的通常合同是对象方法确实起作用。

所以作为一个证据,永远不要从构造函数/ init返回一个僵尸对象。

僵尸不起作用,可能缺少内部组件。只是一个等待发生的空指针异常。

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

与所有经验法则一样,有一个“例外”。

特定界面完全有可能签订合同 存在允许通过异常的“初始化”方法。 补充此接口的对象可能无法正确响应除属性设置器之外的任何调用,直到调用初始化为止。我在启动过程中将它用于OO操作系统中的设备驱动程序,并且它是可行的。

通常,您不需要僵尸对象。在像Smalltalk这样的语言中,变得,事情变得有点模糊,但过度使用成为也是一种糟糕的风格。 让对象在原位变为另一个对象,因此不需要信封包装(Advanced C ++)或策略模式(GOF)。

答案 16 :(得分:1)

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

这个问题已经很好地描述了here,请查看第10项,编写异常安全的代码。

答案 17 :(得分:1)

我无法解决Objective-C中的最佳实践,但在C ++中,构造函数抛出异常是好的。特别是因为没有其他方法可以确保在不使用isOK()方法的情况下报告构造中遇到的异常情况。

函数try块功能专门用于支持构造函数成员初始化中的失败(尽管它也可用于常规函数)。这是修改或丰富将抛出的异常信息的唯一方法。但是由于它的原始设计目的(在构造函数中使用),它不允许异常被一个空的catch()子句吞噬。

答案 18 :(得分:1)

是的,如果构造函数无法构建其内部部分之一,那么它可以 - 通过选择 - 抛出(并以某种语言声明)explicit exception的责任,在构造函数文档中适当注明。

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

如果由于调用者而失败(就像调用者提供的null参数,被调用的构造函数需要非null参数),构造函数将抛出未经检查的运行时异常。

答案 19 :(得分:0)

我只是在学习Objective C,所以我无法从经验中说出来,但我确实在苹果的文档中读过这篇文章。

http://developer.apple.com/documentation/Cocoa/Conceptual/CocoaFundamentals/CocoaObjects/chapter_3_section_6.html

它不仅会告诉您如何处理您提出的问题,而且还可以很好地解释它。

答案 20 :(得分:0)

我所看到的有关异常的最佳建议是,如果且仅当替代方案未能满足后置条件或维持不变量时抛出异常。

该建议根据您应该已经做出的设计决策(不变和后置条件),用一个技术性的,精确的问题取代了一个不明确的主观决定(这是一个好主意)。

构造函数只是该建议的一个特定但非特殊的情况。那么问题就变成了,一个班级应该有哪些不变量?在构造之后调用的单独初始化方法的倡导者建议该类具有两个或更多操作模式,在构造之后具有未准备模式并且至少一个< em> ready 模式,在初始化后输入。这是一个额外的复杂功能,但如果该类有多种操作模式,则可以接受。如果该课程没有其他操作模式,很难看出这种复杂情况是多么值得。

请注意,将设置推送到单独的初始化方法不会使您避免抛出异常。现在,初始化方法将抛出构造函数可能抛出的异常。如果为未初始化的对象调用它们,则类的所有有用方法都必须抛出异常。

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

答案 21 :(得分:0)

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

答案 22 :(得分:0)

对我而言,这是一个有点哲学的设计决定。

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

其他一些方法是init()方法,它带有一些自己的问题。其中一个是确保init()实际被调用。

变体使用惰性方法在第一次调用accessor / mutator时自动调用init(),但这需要任何潜在的调用者必须担心对象有效。 (与“它存在,因此它是有效的哲学”相反)。

我已经看到了各种提议的设计模式来处理这个问题。例如能够通过ctor创建一个初始对象,但必须调用init()来获取带有加速器/ mutator的包含的初始化对象。

每种方法都有起伏不定;我成功地使用了所有这些。如果你没有在创建它们的瞬间创建现成的对象,那么我建议使用大量的断言或异常来确保用户在init()之前不进行交互。

<强>附录

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

答案 23 :(得分:0)

严格地说,从Java的角度来看,每次使​​用非法值初始化构造函数时,都应该抛出异常。这样它就不会构建成糟糕的状态。

答案 24 :(得分:-1)

ctors不应该做任何“聪明”的事情,所以无论如何都不需要抛出异常。如果要执行更复杂的对象设置,请使用Init()或Setup()方法。