构造函数何时抛出异常是正确的?(或者在Objective C的情况下:什么情况下init ` er才应该返回nil?)
在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。也就是说,构造函数应该与它的调用者有一个合同,以提供一个函数和工作对象,在哪些方法可以被有意义地调用?这合理吗?
构造函数何时抛出异常是正确的?(或者在Objective C的情况下:什么情况下init ` er才应该返回nil?)
在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。也就是说,构造函数应该与它的调用者有一个合同,以提供一个函数和工作对象,在哪些方法可以被有意义地调用?这合理吗?
当前回答
构造函数抛出异常是合理的,只要它正确地清理了自己。如果您遵循RAII范式(资源获取即初始化),那么构造函数通常会做有意义的工作;如果构造函数不能完全初始化,那么编写良好的构造函数将自行清理。
其他回答
如果无法创建有效对象,则绝对应该从构造函数抛出异常。这允许您在类中提供适当的不变量。
在实践中,你可能必须非常小心。记住,在c++中,析构函数不会被调用,所以如果你在分配资源后抛出,你需要非常小心地正确处理它!
本页对c++中的情况进行了全面的讨论。
我不确定是否有答案可以完全与语言无关。有些语言处理异常和内存管理的方式不同。
I've worked before under coding standards requiring exceptions never be used and only error codes on initializers, because developers had been burned by the language poorly handling exceptions. Languages without garbage collection will handle heap and stack very differently, which may matter for non RAII objects. It is important though that a team decide to be consistent so they know by default if they need to call initializers after constructors. All methods (including constructors) should also be well documented as to what exceptions they can throw, so callers know how to handle them.
我通常支持单阶段构造,因为很容易忘记初始化对象,但也有很多例外。
Your language support for exceptions isn't very good. You have a pressing design reason to still use new and delete Your initialization is processor intensive and should run async to the thread that created the object. You are creating a DLL that may be throwing exceptions outside it's interface to an application using a different language. In this case it may not be so much an issue of not throwing exceptions, but making sure they are caught before the public interface. (You can catch C++ exceptions in C#, but there are hoops to jump through.) Static constructors (C#)
我所见过的关于异常的最好建议是,当且仅当替代方案是未能满足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语言和库中通过抛出异常来表示。
Eric Lippert说有四种例外。
Fatal exceptions are not your fault, you cannot prevent them, and you cannot sensibly clean up from them. Boneheaded exceptions are your own darn fault, you could have prevented them and therefore they are bugs in your code. Vexing exceptions are the result of unfortunate design decisions. Vexing exceptions are thrown in a completely non-exceptional circumstance, and therefore must be caught and handled all the time. And finally, exogenous exceptions appear to be somewhat like vexing exceptions except that they are not the result of unfortunate design choices. Rather, they are the result of untidy external realities impinging upon your beautiful, crisp program logic.
构造函数本身不应该抛出致命异常,但它执行的代码可能会导致致命异常。像“内存不足”这样的事情不是您可以控制的,但是如果它发生在构造函数中,嘿,它就发生了。
愚蠢的异常永远不应该出现在任何代码中,所以它们应该被清除。
构造函数不应该抛出恼人的异常(例如Int32.Parse()),因为它们没有非异常情况。
最后,应该避免外生异常,但如果在构造函数中执行的某些操作依赖于外部环境(如网络或文件系统),则抛出异常是合适的。
参考链接:https://blogs.msdn.microsoft.com/ericlippert/2008/09/10/vexing-exceptions/
据我所知,没有人能提出一个相当明显的解决方案,同时体现了一阶段和两阶段结构的优点。
注意:这个答案假设是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方法之一。