什么时候构造函数抛出异常?(或者在目标C的情况下:什么时候初始化者返回nil是正确的?)
在我看来,如果对象不完整,构造函数应该失败 - 因此拒绝创建对象.即,构造函数应该与其调用者签订合同,以提供一个功能和工作对象,可以在其上有意义地调用方法?这合理吗?
构造函数的工作是将对象置于可用状态.基本上有两种思想流派.
一组赞成两阶段建设.构造函数只是将对象置于睡眠状态,在该状态下它拒绝做任何工作.还有一个额外的功能可以进行实际的初始化.
我从来没有理解这种方法背后的原因.我坚定地支持一阶段建设,其中对象在施工后完全初始化并可用.
如果一阶段构造函数无法完全初始化对象,则应该抛出它们.如果无法初始化对象,则不能允许它存在,因此构造函数必须抛出.
Eric Lippert说有4种例外.
致命的例外不是你的错,你不能阻止他们,你不能明智地清理它们.
Boneheaded异常是你自己的错误,你可以防止它们,因此它们是你的代码中的错误.
不幸的例外是不幸的设计决策的结果.在完全非特殊情况下抛出异常情况,因此必须始终抓住并处理.
最后,外生异常似乎有点像烦恼的异常,除了它们不是不幸的设计选择的结果.相反,它们是不整洁的外部现实影响你美丽,清晰的程序逻辑的结果.
您的构造函数不应该自己抛出致命异常,但它执行的代码可能会导致致命的异常.像"内存不足"这样的东西不是你可以控制的东西,但如果它出现在构造函数中,嘿,它就会发生.
任何代码都不应该出现斩首异常,所以它们就是正确的.
Int32.Parse()
构造函数不应抛出Vexing异常(示例是),因为它们没有非特殊情况.
最后,应避免使用外部异常,但如果您在构造函数中执行某些依赖于外部环境(如网络或文件系统)的操作,则抛出异常是合适的.
有一般没什么可通过从建筑离婚对象的初始化获得.RAII是正确的,对构造函数的成功调用应该导致完全初始化的活动对象,否则它应该失败,并且任何代码路径中任何点的所有失败都应该抛出异常.除了某种程度上的额外复杂性之外,您不会通过使用单独的init()方法获得任何收益.ctor契约应该是它返回一个功能有效的对象,或者它自己清理后抛出.
考虑一下,如果你实现一个单独的init方法,你仍然需要调用它.它仍然有可能抛出异常,它们仍然必须被处理,它们实际上总是必须在构造函数之后立即被调用,除了现在你有4个可能的对象状态而不是2(IE,构造,初始化,未初始化,并失败vs只有效和不存在).
无论如何,我在25年的OO开发案例中遇到过,似乎单独的init方法"解决了一些问题"是设计缺陷.如果您现在不需要对象,那么您现在不应该构建它,如果您现在需要它,那么您需要初始化它.KISS应该始终遵循的原则,以及任何接口的行为,状态和API应该反映对象做什么的简单概念,而不是它如何做,客户端代码甚至不应该知道对象有任何种类需要初始化的内部状态,因此模式后的init违反了这个原则.
由于部分创建的类可能导致的所有麻烦,我会说永远不会.
如果需要在构造期间验证某些内容,请将构造函数设为私有并定义公共静态工厂方法.如果某些内容无效,该方法可以抛出.但如果一切都检出,它会调用构造函数,保证不会抛出.
只要构造函数正确地清理自身,构造函数就抛出异常是合理的。如果遵循RAII范式(资源获取就是初始化),那么构造函数执行有意义的工作是很普遍的。一个编写良好的构造函数如果无法完全初始化,则会依次清除。
当构造函数无法完成所述对象的构造时,它应抛出异常.
例如,如果构造函数应该分配1024 KB的ram,并且它不能这样做,它应该抛出一个异常,这样构造函数的调用者知道对象没有准备好被使用并且有一个错误某处需要修复.
半初始化和半死的对象只会导致问题和问题,因为调用者无法知道.当出现问题时,我宁愿让构造函数抛出错误,而不是依赖编程来运行对isOK()函数的调用,该函数返回true或false.
据我所知,没有人提出一个相当明显的解决方案,该方案体现了一阶段和两阶段结构的优点。
注意:此答案假定使用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 TaskCreate(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
方法本身之一。