构造函数何时抛出异常是正确的?(或者在Objective C的情况下:什么情况下init ` er才应该返回nil?)
在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。也就是说,构造函数应该与它的调用者有一个合同,以提供一个函数和工作对象,在哪些方法可以被有意义地调用?这合理吗?
构造函数何时抛出异常是正确的?(或者在Objective C的情况下:什么情况下init ` er才应该返回nil?)
在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。也就是说,构造函数应该与它的调用者有一个合同,以提供一个函数和工作对象,在哪些方法可以被有意义地调用?这合理吗?
当前回答
对我来说,这是一个有点哲学的设计决策。
拥有实例是非常好的,只要它们存在就有效,从ctor时间开始。对于许多重要的情况,如果无法进行内存/资源分配,则可能需要从ctor抛出异常。
其他一些方法是init()方法,它本身存在一些问题。其中之一是确保init()实际被调用。
一个变体使用惰性方法在第一次调用访问器/突变器时自动调用init(),但这要求任何潜在的调用者都必须担心对象是否有效。(而不是“它存在,因此它是有效的哲学”)。
我也看到过处理这个问题的各种设计模式。例如,可以通过ctor创建初始对象,但必须调用init()来获得包含的、初始化的具有访问器/突变器的对象。
每种方法都有其利弊;我已经成功地使用了所有这些方法。如果不是在创建对象的那一刻就创建现成的对象,那么我建议使用大量的断言或异常,以确保用户在init()之前不会进行交互。
齿顶高
我是从c++程序员的角度写的。我还假设您正确地使用了RAII习惯用法来处理抛出异常时释放的资源。
其他回答
构造函数的任务是使对象进入可用状态。关于这个问题,基本上有两种观点。
一组人赞成两阶段建设。构造函数只是将对象带入一个休眠状态,在这种状态下它拒绝做任何工作。还有一个额外的函数来进行实际的初始化。
我一直不明白这种方法背后的原因。我坚决支持单阶段构造,即对象在构造后完全初始化并可用。
如果单阶段构造函数未能完全初始化对象,则应该抛出。如果对象不能初始化,则必须不允许它存在,因此构造函数必须抛出。
参见c++常见问题解答第17.2和17.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客户端,相反,如果在尝试执行请求时收到授权错误,您可以简单地重新创建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方法之一。
当构造函数无法完成所述对象的构造时,它应该抛出异常。
例如,如果构造函数应该分配1024 KB的ram,但它没有这样做,它应该抛出一个异常,这样构造函数的调用者就知道对象还没有准备好使用,并且在某个地方存在需要修复的错误。
半初始化和半死亡的对象只会引起问题和问题,因为调用者确实没有办法知道。我宁愿让我的构造函数在出错时抛出一个错误,而不是不得不依赖编程来运行返回true或false的isOK()函数的调用。
对我来说,这是一个有点哲学的设计决策。
拥有实例是非常好的,只要它们存在就有效,从ctor时间开始。对于许多重要的情况,如果无法进行内存/资源分配,则可能需要从ctor抛出异常。
其他一些方法是init()方法,它本身存在一些问题。其中之一是确保init()实际被调用。
一个变体使用惰性方法在第一次调用访问器/突变器时自动调用init(),但这要求任何潜在的调用者都必须担心对象是否有效。(而不是“它存在,因此它是有效的哲学”)。
我也看到过处理这个问题的各种设计模式。例如,可以通过ctor创建初始对象,但必须调用init()来获得包含的、初始化的具有访问器/突变器的对象。
每种方法都有其利弊;我已经成功地使用了所有这些方法。如果不是在创建对象的那一刻就创建现成的对象,那么我建议使用大量的断言或异常,以确保用户在init()之前不会进行交互。
齿顶高
我是从c++程序员的角度写的。我还假设您正确地使用了RAII习惯用法来处理抛出异常时释放的资源。