it-swarm.cn

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

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

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

193
Mark R Lindsey

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

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

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

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

255
Sebastian Redl

Eric Lippert说 有4种例外。

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

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

任何代码都不应该出现斩首异常,所以它们就是正确的。

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

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

54
Jacob Krall

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

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

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

30
Alhazred

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

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

6
Michael L Perry

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

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

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

5
Denice

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

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

4
blowdart

构造函数抛出异常是合理的,只要它能够正常清理它。如果你遵循 _ raii _ paradigm(资源获取是初始化)那么它很常见的构造函数做有意义的工作;如果无法完全初始化,那么编写良好的构造函数将依次清理。

4
Matt Dillard

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

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

单阶段通过私有构造函数

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

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方法之一。

4
cwharris

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

看到这个问题:

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

3
stevex

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

3
Nick

参见C++ FAQ sections 17.217.4

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

3
moonshadow

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

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

2
user14070

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

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

这个页面 对C++中的情况进行了详尽的讨论。

2
Luke Halliwell

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

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

1
mlbrock

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

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

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

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

是的,如果构造函数无法构建其内部部分之一,那么它可以 - 通过选择 - 抛出(并以某种语言声明) 显式异常 ,在构造函数文档中正确指出。

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

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

1
VonC

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

这个问题很好描述 这里 ,检查第10项,编写异常安全的代码。

1
Don Neufeld

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

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

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

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

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

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

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

1
Tim Williscroft

OP的问题有一个“与语言无关”的标签......对于所有语言/情况,这个问题不能以同样的方式安全回答。

下面的C#示例的类层次结构抛出了B类的构造函数,在退出main的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.
    }
}
1
Ashley

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

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

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

0
Scott Swezey

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

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

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

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

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

0
Raedwald

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

0
scubabbl

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

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

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

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

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

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

附录

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

0
nsanders

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

0
Tegan Mulholland