关于语言不可知:构造函数什么时候抛出异常?

关于语言不可知:构造函数什么时候抛出异常?

When is it right for a constructor to throw an exception?

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

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


构造函数的工作是使对象进入可用状态。关于这一点,基本上有两个学派。

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

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

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


埃里克·利珀特说有4种例外。

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

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

Bonehead异常不应该出现在任何代码中,所以它们是正确的。

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

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


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

考虑一下,如果您实现一个单独的init方法,您仍然需要调用它。它仍然有可能引发异常,仍然需要处理异常,而且实际上,无论如何都必须在构造函数之后立即调用异常,除非现在您有4个可能的对象状态而不是2个(即,已构造、已初始化、未初始化和失败的vs只是有效和不存在的vs)。

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


因为一个部分创建的类所能引起的所有麻烦,我认为永远不会。

如果需要在构造期间验证某些内容,请将构造函数设置为私有的,并定义一个公共静态工厂方法。如果某个方法无效,则该方法可以引发。但是,如果所有东西都签出了,它将调用构造函数,这保证不会抛出。


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

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

半初始化半死的对象只会导致问题和问题,因为调用方确实无法知道。我宁可让我的构造函数在出错时抛出一个错误,也不必依靠编程来运行对isok()函数的调用,该函数返回true或false。


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

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


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

注:此答案假设C,但原则可应用于大多数语言。

首先,两者的好处:

一级

一个阶段的构建可以防止对象处于无效状态,从而防止各种错误的状态管理以及随之而来的所有错误。但是,这会让我们中的一些人感到奇怪,因为我们不希望构造函数抛出异常,有时当初始化参数无效时,这就是我们需要做的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
    }
}

两阶段验证法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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));
        }
    }
}

通过私人建造商的单阶段

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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客户机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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方法本身之一。


只要一个构造函数能正确地清理自己,它就有理由抛出一个异常。如果您遵循RAII范式(资源获取就是初始化),那么对于一个构造函数来说,做有意义的工作是很常见的;如果一个编写良好的构造函数不能完全初始化,那么它将依次在自身之后进行清理。


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

请参阅此问题:

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


如果要编写UI控件(aspx、winforms、wpf…),则应避免在构造函数中引发异常,因为设计器(Visual Studio)在创建控件时无法处理这些异常。了解控制生命周期(控制事件),尽可能使用延迟初始化。


参见C++FAQ第17.2和17.4节。

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


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

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


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

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

本页对C++的情况进行了深入的讨论。


是的,如果构造函数未能构建它的内部部分之一,那么它可以(通过选择)抛出(并且用某种语言声明)一个显式异常,这在构造函数文档中得到了适当的注意。

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

如果由于调用方的原因而失败(如调用方提供的空参数,其中被调用的构造函数需要一个非空参数),则该构造函数无论如何都将引发未检查的运行时异常。


在构造期间抛出异常是使代码更加复杂的一个好方法。看似简单的事情突然变得困难起来。例如,假设您有一个堆栈。如何弹出堆栈并返回顶值?好吧,如果堆栈中的对象可以抛出其构造函数(构造临时对象以返回调用方),则不能保证不会丢失数据(减小堆栈指针,使用堆栈中值的复制构造函数构造返回值,这将引发,现在有一个刚丢失项的堆栈)!这就是std::stack::pop不返回值的原因,您必须调用std::stack::top。

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


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

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


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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
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.
    }
}

我不确定任何答案是否完全是语言不可知论。有些语言处理异常和内存管理的方式不同。

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

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

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

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

因此,作为corolary,不要从constructor/init返回僵尸对象。

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

很多年前,我第一次在目标C中制造僵尸。

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

一个特定的接口完全有可能有一个契约说存在允许通过异常进行初始化的方法。在调用Initialize之前,包含此接口的对象可能无法正确响应除属性设置器之外的任何调用。在引导过程中,我将它用于OO操作系统中的设备驱动程序,这是可行的。

一般来说,你不需要僵尸对象。在像smalltalk这样的语言中,become的用法会变得有点流行,但是become的过度使用也是不好的风格。变为一个对象,在一个对象中就地变成另一个对象,因此不需要信封包装器(高级C++)或策略模式(GOF)。


严格地说,从Java的观点来看,任何时候初始化具有非法值的构造函数时,它都会抛出异常。这样它就不会在坏的状态下被构造。


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

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

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

变量在第一次调用访问器/赋值函数时使用了一种惰性方法来自动调用init(),但这要求任何潜在的调用方都必须担心对象是否有效。(与"它存在,因此它是有效的哲学"相反)。

我也看到了各种各样的设计模式来处理这个问题。例如,能够通过ctor创建初始对象,但必须调用init()才能使用accesors/mutators获得包含的、初始化的对象。

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

补遗

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


我只是在学习目标C,所以我不能从经验中说出来,但我确实在苹果的文档中读到了这一点。

http://developer.apple.com/documentation/cocoa/conceptive/cocoafundamentals/cocoaObjects/chapter_3_section_6.html

它不仅能告诉你如何处理你问的问题,而且能很好地解释它。


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


对于异常,我所看到的最好的建议是,如果(并且仅当)另一种情况是未能满足post条件或维护不变量,则抛出异常。

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

对于这个建议,构造函数只是一个特殊的,但不是特殊的情况。所以问题变成了,类应该有什么不变量?在构造之后调用的单独初始化方法的倡导者建议类有两个或多个操作模式,在构造之后有一个未读模式,在初始化之后至少有一个就绪模式。这是一个额外的复杂问题,但如果类无论如何都有多个操作模式,则可以接受。如果类没有其他的操作模式,那么很难看到这种复杂性是如何值得的。

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

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


ctors不应该做任何"聪明"的事情,所以也不需要抛出异常。如果要执行更复杂的对象设置,请使用init()或setup()方法。


推荐阅读

    探探语言设置|探探怎么设置语言

    探探语言设置|探探怎么设置语言,,1. 探探怎么设置语言打开探探软件,然后就有消息提示的红点,点开就行了!其实这些软件都是挺简单的操作的,都是

    git设置编码|git语言设置

    git设置编码|git语言设置,,git设置编码点击cap4j搜索从git直接链接上拉代码。git语言设置Git是一个开源的分布式版本控制系统,可以有效、高

    目标焊机快捷键|目标焊接工具

    目标焊机快捷键|目标焊接工具,,1. 目标焊接工具焊工实训的目的?培养一个合格的焊工,国家是要花费很大的财力合物力的。比如说,造船厂的焊工,建

    区域语言设置|区域语言设置工具

    区域语言设置|区域语言设置工具,,区域语言设置工具你好,大致的方法如下,可以参考:1、按下键盘的windows 图标,再开始菜单中单击“设置”;出现的

    c4d语言设置|c4d汉语设置

    c4d语言设置|c4d汉语设置,,1. c4d汉语设置mac版的C4D是这样的,中文字体是有的,但是是以拼音的形式存在,比如黑体就是ht。中文字体以拼音方式

    电脑宣传语|电脑宣传语言

    电脑宣传语|电脑宣传语言,,1. 电脑宣传语言1.我做好了与你过一辈子的打算,也做好了你随时要走的准备,2.每段青春都会苍老,但我希望记忆里的你

    office语言设置|微软office语言设置

    office语言设置|微软office语言设置,,微软office语言设置一、首先点击桌面左下角“WIN键”。二、弹出选项内点击“所有程序”。三、接着点