构造函数什么时候抛出异常合适?(或者在 Objective C 的情况下:init'er 什么时候返回 nil 是正确的?)
在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。即,构造函数应该与其调用者签订合同,以提供可以有意义地调用哪些方法的功能和工作对象?这合理吗?
构造函数什么时候抛出异常合适?(或者在 Objective C 的情况下:init'er 什么时候返回 nil 是正确的?)
在我看来,如果对象不完整,构造函数应该失败——因此拒绝创建对象。即,构造函数应该与其调用者签订合同,以提供可以有意义地调用哪些方法的功能和工作对象?这合理吗?
构造函数的工作是使对象进入可用状态。基本上有两种思想流派。
一组赞成两阶段建设。构造函数只是将对象带入休眠状态,在这种状态下它拒绝做任何工作。还有一个额外的函数可以进行实际的初始化。
我从来不明白这种方法背后的原因。我坚定地支持一阶段构造,其中对象在构造后完全初始化并可用。
如果无法完全初始化对象,一阶段构造函数应该抛出。如果对象不能被初始化,就一定不允许它存在,所以构造函数必须抛出。
Eric Lippert 说有 4 种例外。
你的构造函数不应该自己抛出一个致命的异常,但是它执行的代码可能会导致一个致命的异常。诸如“内存不足”之类的事情不是您可以控制的,但是如果它发生在构造函数中,嘿,它就会发生。
愚蠢的异常绝不应该出现在您的任何代码中,因此它们是正确的。
构造函数不应该抛出令人烦恼的异常(例如Int32.Parse()
),因为它们没有非异常情况。
最后,应该避免外生异常,但是如果你在构造函数中做一些依赖于外部环境(如网络或文件系统)的事情,那么抛出异常是合适的。
参考链接:https ://blogs.msdn.microsoft.com/ericlippert/2008/09/10/vexing-exceptions/
将对象初始化与构造分离通常不会获得任何好处。RAII 是正确的,对构造函数的成功调用应该导致完全初始化的活动对象或者应该失败,并且任何代码路径中任何点的所有失败都应该总是抛出异常。使用单独的 init() 方法除了在某种程度上增加了复杂性之外,您什么也得不到。ctor 合约应该是它返回一个功能有效的对象,或者它自己清理并抛出。
考虑一下,如果你实现一个单独的 init 方法,你仍然必须调用它。它仍然有可能抛出异常,它们仍然必须被处理,并且它们实际上总是必须在构造函数之后立即调用,除非现在你有 4 个可能的对象状态而不是 2 个(IE、构造、初始化、未初始化、并且失败与有效且不存在)。
无论如何,我在 25 年的 OO 开发案例中遇到过,似乎单独的 init 方法可以“解决一些问题”是设计缺陷。如果您现在不需要一个对象,那么您现在不应该构造它,如果您现在确实需要它,那么您需要初始化它。KISS 应该始终是遵循的原则,以及任何接口的行为、状态和 API 应该反映对象做什么而不是如何做的简单概念,客户端代码甚至不应该知道对象有任何种类需要初始化的内部状态,因此 init after 模式违反了这一原则。
据我所知,没有人提出一个相当明显的解决方案,它体现了一级和二级结构的最佳效果。
注意:此答案假定 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() 函数的调用。
它总是很狡猾,尤其是当你在构造函数中分配资源时;根据您的语言,不会调用析构函数,因此您需要手动清理。这取决于对象的生命周期在您的语言中是如何开始的。
我真正做到的唯一一次是当某处存在安全问题时,这意味着不应该创建对象,而不是不能创建对象。
构造函数抛出异常是合理的,只要它正确地清理自己。如果您遵循RAII范式(资源获取即初始化),那么构造函数做有意义的工作是很常见的;如果不能完全初始化,编写良好的构造函数将依次清理自身。
如果您正在编写 UI 控件(ASPX、WinForms、WPF...),则应避免在构造函数中引发异常,因为设计器(Visual Studio)在创建控件时无法处理它们。了解您的控件生命周期(控件事件)并尽可能使用延迟初始化。
请注意,如果您在初始化程序中抛出异常,如果任何代码使用该[[[MyObj alloc] init] autorelease]
模式,您最终会泄漏,因为异常将跳过自动释放。
看到这个问题:
如果您无法创建有效对象,您绝对应该从构造函数中抛出异常。这使您可以在类中提供适当的不变量。
在实践中,您可能必须非常小心。请记住,在 C++ 中,不会调用析构函数,因此如果在分配资源后抛出异常,则需要非常小心地正确处理!
此页面对 C++ 中的情况进行了彻底的讨论。
如果您无法在构造函数中初始化对象,则抛出异常,例如非法参数。
作为一般经验法则,应始终尽快抛出异常,因为当问题的根源更接近发出错误信号的方法时,它会使调试更容易。
是的,如果构造函数未能构建其内部部分之一,则可以选择由其负责抛出(并以某种语言声明)显式异常,并在构造函数文档中适当地注明。
这不是唯一的选择:它可以完成构造函数并构建一个对象,但方法“isCoherent()”返回 false,以便能够发出不连贯状态的信号(在某些情况下可能更可取,以便避免由于异常导致执行工作流的残酷中断)
警告:正如 EricSchaefer 在他的评论中所说,这可能会给单元测试带来一些复杂性(由于触发条件,抛出会增加函数的圈复杂度它)
如果由于调用者而失败(如调用者提供的空参数,被调用的构造函数需要一个非空参数),构造函数无论如何都会抛出未经检查的运行时异常。
在构造过程中抛出异常是使代码更复杂的好方法。看似简单的事情突然变得困难了。例如,假设您有一个堆栈。你如何弹出堆栈并返回顶部值?好吧,如果堆栈中的对象可以在它们的构造函数中抛出(构造临时返回给调用者),你不能保证你不会丢失数据(递减堆栈指针,使用 value in 的复制构造函数构造返回值堆栈,它会抛出,现在有一个堆栈刚刚丢失了一个项目)!这就是为什么 std::stack::pop 不返回值,而你必须调用 std::stack::top 的原因。
这个问题在这里有很好的描述,检查第 10 条,编写异常安全代码。
OO 中通常的约定是对象方法确实起作用。
因此,作为推论,永远不要从构造函数/初始化中返回僵尸对象。
僵尸不起作用,可能缺少内部组件。只是等待发生的空指针异常。
很多年前,我第一次在 Objective C 中制作僵尸。
像所有经验法则一样,有一个“例外”。
完全有可能一个特定的接口可能有一个合同,说存在一个允许异常的“初始化”方法。实现此接口的对象可能无法正确响应除属性设置器之外的任何调用,直到调用了 initialize。我在引导过程中将它用于 OO 操作系统中的设备驱动程序,它是可行的。
通常,您不想要僵尸对象。在 Smalltalk 之类的语言中,使用become时会变得有点杂乱无章,但过度使用become也是不好的风格。成为让一个对象就地变为另一个对象,因此不需要信封包装器(高级 C++)或策略模式(GOF)。
我无法解决 Objective-C 中的最佳实践,但在 C++ 中,构造函数抛出异常是可以的。特别是因为没有其他方法可以确保在不调用 isOK() 方法的情况下报告构造中遇到的异常情况。
函数 try 块特性是专门为支持构造函数成员初始化中的失败而设计的(尽管它也可以用于常规函数)。这是修改或丰富将抛出的异常信息的唯一方法。但是由于其最初的设计目的(在构造函数中使用),它不允许异常被空的 catch() 子句吞噬。
我不确定任何答案是否完全与语言无关。某些语言处理异常和内存管理的方式不同。
我之前在编码标准下工作过,要求从不使用异常,并且只在初始化程序上使用错误代码,因为开发人员已经被处理异常的语言所困扰。没有垃圾收集的语言将处理堆和堆栈非常不同,这可能对非 RAII 对象很重要。尽管团队决定保持一致很重要,但他们默认知道是否需要在构造函数之后调用初始化程序。所有方法(包括构造函数)也应该详细记录它们可以抛出的异常,以便调用者知道如何处理它们。
我通常赞成单阶段构造,因为很容易忘记初始化对象,但也有很多例外。
new
和delete
OP 的问题有一个“与语言无关”的标签……对于所有语言/情况,这个问题不能以同样的方式安全地回答。
以下 C# 示例的类层次结构抛出类 B 的构造函数,IDisposeable.Dispose
在退出 main时跳过对类 A 的立即调用,跳过using
对 A 类资源的显式处置。
例如,如果 A 类创建了一个Socket
连接到网络资源的 at 结构,那么在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.
}
}
严格来说,从 Java 的角度来看,任何时候你用非法值初始化一个构造函数,它都应该抛出一个异常。这样它就不会在糟糕的状态下构建。
对我来说,这是一个有点哲学的设计决定。
从 ctor 时间开始,拥有只要它们存在就有效的实例是非常好的。对于许多不平凡的情况,如果无法进行内存/资源分配,这可能需要从 ctor 抛出异常。
其他一些方法是 init() 方法,它本身就有一些问题。其中之一是确保 init() 实际被调用。
一个变体使用惰性方法在第一次调用访问器/修改器时自动调用 init(),但这要求任何潜在的调用者都必须担心对象是否有效。(与“它存在,因此它是有效的哲学”相反)。
我也看到了各种提议的设计模式来处理这个问题。例如能够通过 ctor 创建一个初始对象,但必须调用 init() 来获得一个包含访问器/突变器的初始化对象。
每种方法都有其起伏;我已经成功地使用了所有这些。如果您没有从创建即用型对象开始制作它们,那么我建议使用大量断言或异常以确保用户不会在 init() 之前进行交互。
附录
我是从 C++ 程序员的角度写的。我还假设您正确使用 RAII 习惯用法来处理引发异常时释放的资源。
使用工厂或工厂方法创建所有对象,可以避免无效对象,而不会从构造函数中抛出异常。如果创建方法能够创建一个对象,则创建方法应返回所请求的对象,否则返回 null。您在处理类用户的构造错误时失去了一点灵活性,因为返回 null 并不能告诉您创建对象时出了什么问题。但它也避免了每次请求对象时增加多个异常处理程序的复杂性,以及捕获不应处理的异常的风险。
我见过的关于异常的最佳建议是,当且仅当替代方案是未能满足后置条件或保持不变量时才抛出异常。
该建议用基于您应该已经做出的设计决策(不变和后置条件)的技术性、精确性问题取代了不清楚的主观决定(这是一个好主意)。
构造函数只是该建议的一个特殊情况,但不是特殊情况。那么问题就变成了,一个类应该有哪些不变量?提倡在构造后调用单独的初始化方法,是建议该类具有两种或多种操作模式,构造后具有未就绪模式,并且在初始化后进入至少一种就绪模式。这是一个额外的复杂性,但如果该类无论如何都有多种操作模式,则可以接受。如果该类没有操作模式,那么很难看出这种复杂性是多么值得。
请注意,将设置推送到单独的初始化方法中并不能避免引发异常。您的构造函数可能抛出的异常现在将由初始化方法抛出。如果为未初始化的对象调用类的所有有用方法,则它们都必须抛出异常。
另请注意,避免构造函数抛出异常的可能性很麻烦,而且在许多标准库中在许多情况下是不可能的。这是因为这些库的设计者认为从构造函数中抛出异常是个好主意。特别是,任何尝试获取不可共享或有限资源(例如分配内存)的操作都可能失败,并且该失败通常在 OO 语言和库中通过抛出异常来指示。