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

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


当前回答

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

注意:这个答案假设是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方法之一。

其他回答

参见c++常见问题解答第17.2和17.4节。

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

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

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

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

Throwing an exception during construction is a great way to make your code way more complex. Things that would seem simple suddenly become hard. For example, let's say you have a stack. How do you pop the stack and return the top value? Well, if the objects in the stack can throw in their constructors (constructing the temporary to return to the caller), you can't guarantee that you won't lose data (decrement stack pointer, construct return value using copy constructor of value in stack, which throws, and now have a stack that just lost an item)! This is why std::stack::pop does not return a value, and you have to call std::stack::top.

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

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

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

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

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

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

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

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

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

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

Constructors are just a particular, but not special, case for that advice. So the question becomes, what invariants should a class have? Advocates of a separate initialization method, to be called after construction, are suggesting that the class has two or more operating mode, with an unready mode after construction and at least one ready mode, entered after initialization. That is an additional complication, but acceptable if the class has multiple operating modes anyway. It is hard to see how that complication is worthwhile if the class would otherwise not have operating modes.

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

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