ChatGPT解决这个技术问题 Extra ChatGPT

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

构造函数什么时候抛出异常合适? (或者在 Objective C 的情况下:init'er 什么时候返回 nil 是正确的?)

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


S
Sebastian Redl

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

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

我从来不明白这种方法背后的原因。我坚定地支持一阶段构造,其中对象在构造后完全初始化并可用。

如果无法完全初始化对象,一阶段构造函数应该抛出。如果对象不能被初始化,就一定不允许它存在,所以构造函数必须抛出。


具有一级构造函数的类不能通过子类化轻松地用于单元测试。
两阶段构建适用于异常无法正常工作或未实施的环境。 MFC 使用两阶段构造,因为最初编写它时,Visual C++ 没有 C++ 异常工作。 Windows CE 直到 v4.0 和 MFC 8.0 才获得 C++ 异常。
@EricSchaefer:对于单元测试,我觉得最好模拟依赖项,而不是使用子类化。
当一组对象需要相互链接以正常运行时,可能需要两个阶段构造函数。两阶段方法对于依赖注入很有用。
@Patrick:不确定您的意思-如果您执行“new Foo”并且Foo的构造函数抛出,该语言将回收内存。如果您在构造函数中分配内存并且除了析构函数之外没有提供释放它的方法,那么如果您稍后在 cnostructor 中抛出,该语言将不会回收它。但是,无论如何,您应该立即将每个分配包装在一个 RAII 对象中。
u
unional

Eric Lippert says 有 4 种异常。

致命异常不是您的错,您无法阻止它们,也无法明智地清除它们。

愚蠢的异常是您自己的错误,您可以阻止它们,因此它们是您代码中的错误。

令人烦恼的异常是不幸的设计决策的结果。令人烦恼的异常是在完全非异常的情况下抛出的,因此必须一直被捕获和处理。

最后,外生异常似乎有点像令人烦恼的异常,只是它们不是不幸的设计选择的结果。相反,它们是杂乱的外部现实影响你美丽、清晰的程序逻辑的结果。

你的构造函数不应该自己抛出一个致命的异常,但是它执行的代码可能会导致一个致命的异常。诸如“内存不足”之类的事情不是您可以控制的,但是如果它发生在构造函数中,嘿,它就会发生。

愚蠢的异常绝不应该出现在您的任何代码中,因此它们是正确的。

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

最后,应该避免外生异常,但是如果你在构造函数中做一些依赖于外部环境(如网络或文件系统)的事情,那么抛出异常是合适的。

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


那么 Argument[Null]Exception 在这个方案中的位置在哪里呢?这是一个愚蠢的异常,所以不应该抛出?或者它是一个致命的异常,因此可以被抛出?
我现在意识到我没有包含指向原始文章的链接,blogs.msdn.com/b/ericlippert/archive/2008/09/10/… ArgumentException/ArgumentNullException 之类的东西是愚蠢的:只是调用代码中的简单错误。他说:“修复你的代码,使其永远不会触发愚蠢的异常——‘索引超出范围’异常不应该在生产代码中发生。”
@alasairs:您绝对应该抛出 ArgumentExceptions,因为唯一的选择是假装参数有效,而实际上它们无效。 (导致 NullReferenceExceptions,或者可能更糟糕的事情。)但就像 Jacob 所说,你永远不应该抓住它们。
肯定会抛出 ArgumentException / ArgumentNullException / ArgumentoutOfRangeException 异常来保护构造函数或方法中的参数,这对于任何构造函数或方法 publicprotected 来说只是普通常识防御性编程?毕竟,您无法确保所有调用代码都将传递有效参数。但是,对于您可以控制的 privateinternal,则可能不需要它们,因为程序员 ARE 控制着调用代码。
@Dib 是的。您同意 Joren 和我对愚蠢例外的评论。绝对扔掉它们;永远不要抓住他们。
佚名

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

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

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


“仅在需要时构建”的一个反例。如果在循环体中需要它,则在此声明它会在范围退出时将其销毁,这可能会造成浪费。具有单独的构造(在更大范围内)和初始化/重新初始化(在循环内)允许为后续迭代重用资源,而不是完全清理并不得不多次重建它们。
如果在初始化中有重要的逻辑要完成,并且您希望有各种可以重用该逻辑的 c'tors,那么从 DRY 的角度来看,拥有一个单独的 init() 是正确的,这是 KISS 的一部分。初始化仍然可以在构建时“在幕后”完成。
为调用构造函数的对象创建工厂方法,然后调用init方法
c
cwharris

据我所知,没有人提出一个相当明显的解决方案,它体现了一级和二级结构的最佳效果。

注意:此答案假定 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 客户端,相反,如果您在尝试执行请求时收到授权错误,您可以简单地重新创建 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 方法本身之一。


M
Michael L Perry

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

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


我想说的情况恰恰相反——如果我们不想要部分创建的对象,构造函数应该在出现问题时抛出——这样调用者就会知道出了问题。
@TimBezhashvyly:因为它不符合现实。在现实世界中,有些故障是无法预测的,只能检测到。
@BenVoigt 这不是借口。
@TimBezhashvyly:只有当您声称该规则涵盖了所有场景时,您才使用这个词。这个规则不行。假装它是没有用的。 “没用”是网站批准的实际投反对票的原因之一......而且你对人们选择不投赞成票感到不安,这是一个具有近乎无限正当理由的决定。
@BlairConrad 在这种情况下,构造函数将是私有的。静态工厂方法有效地成为构造函数。没有人说我们不应该在构造时抛出异常......我们只是在争论开发人员不应该从构造函数中发起异常。工厂方法还能够处理从构造函数中抛出的异常,在不幸的情况下会导致这样的事情发生,并从中恢复。异常不必是应用程序的结束。
D
Denice

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

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

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


b
blowdart

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

我真正做到的唯一一次是当某处存在安全问题时,这意味着不应该创建对象,而不是不能创建对象。


我不知道为什么有人评价这个...对我来说,在构造函数中抛出异常时,这似乎是一件很好的事情。我确信有不止几个项目存在内存泄漏,因为在抛出异常后没有调用析构函数。
我是投反对票的人。在任何语言中,单一责任原则都适用:如果一个对象负责管理资源,它不应该做任何其他事情,因此它的构造函数抛出的唯一情况是它无法获取资源。我知道的每一种语言都会可靠地破坏完全构造的对象,因此这些资源会得到处理。如果您希望一个对象手动管理多个资源,您只会遇到麻烦,这只是一个错误。问题不在于抛出构造函数。
M
Matt Dillard

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


@cgreen 请重新检查这些帖子上的日期。该博客条目来自 2008 年 12 月 3 日——上面的帖子来自 2008 年 9 月 16 日——在该博客帖子出现前将近三个月。
@cgreeno:这篇博文是在该博文发布前三个月发布的,如何复制它?
@JeffAtwood 在发表评论之前,我仔细检查了日期。博客帖子日期有时会更改,以使它们看起来更相关。但是,我将删除该评论以避免任何争议,因为我可能是错的并且没有证据证明这一点。
m
moonshadow

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

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


N
Nick

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


C
Community

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

看到这个问题:

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


这似乎是特定于语言的。您能否举一个这种模式很常见的语言示例(我猜代码看起来像 TCL 或 Objective-C)。
L
Luke Halliwell

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

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

This page 对 C++ 中的情况进行了彻底的讨论。


佚名

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

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


D
Don Neufeld

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

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


这可能是一个很好的论据,可以确保复制构造函数不能抛出,而不是普通的构造函数。
T
Tim Williscroft

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

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

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

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

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

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

通常,您不想要僵尸对象。在 Smalltalk 之类的语言中,使用 become 时会变得有点杂乱无章,但过度使用 become 也是不好的风格。成为让一个对象就地变为另一个对象,因此不需要信封包装器(高级 C++)或策略模式(GOF)。


m
mlbrock

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

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


C
Community

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

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

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


D
Denise Skidmore

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

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

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

您对异常的语言支持不是很好。

你有一个紧迫的设计理由仍然使用 new 和 delete

您的初始化是处理器密集型的,应该与创建对象的线程异步运行。

您正在创建一个 DLL,该 DLL 可能会在其与使用不同语言的应用程序的接口之外引发异常。在这种情况下,可能不是不抛出异常的问题,而是确保它们在公共接口之前被捕获。 (您可以在 C# 中捕获 C++ 异常,但需要跳过一些障碍。)

静态构造函数 (C#)


A
Ashley

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.
    }
}

s
scubabbl

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


n
nsanders

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

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

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

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

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

每种方法都有其起伏;我已经成功地使用了所有这些。如果您没有从创建即用型对象开始制作它们,那么我建议使用大量断言或异常以确保用户不会在 init() 之前进行交互。

附录

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


T
Tegan Mulholland

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


R
Raedwald

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

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

构造函数只是该建议的一个特殊情况,但不是特殊情况。那么问题就变成了,一个类应该有哪些不变量?主张在构造后调用单独的初始化方法,是建议该类具有两种或多种操作模式,构造后具有未就绪模式,初始化后进入至少一种就绪模式。这是一个额外的复杂性,但如果该类无论如何都有多种操作模式,则可以接受。如果该类没有操作模式,那么很难看出这种复杂性是多么值得。

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

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