Is Two-Step Initialization a Solution or a Symptom?

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

有时,在对象的构造函数完成后,执行额外的初始化步骤可能看起来很实际。然而,我认为这种需求表明存在潜在的设计缺陷,比如对象的可变性、基类的脆弱性、层次违规和抽象不明确。构造函数应该对所有情况都足够好。如果不够好,就重构对象。

通常情况下是这样的(我在Apache Kafka中找到的):

预计该对象将以这种方式使用:

可能有实际的理由支持这种“两阶段构建”方法,正如微软所述,它被宣传为对象创建的“始终更安全”的方法。然而,我相信这些理由都意味着一个有缺陷的设计,并应该作为重构的催化剂。

考虑一个Java类,它是一个可自动关闭的类,在其构造函数中打开一个流,并合理地期望在close()方法中关闭该流。

然而,如果在构造函数中引发了运行时异常,流将不会关闭,资源将会泄漏。

close() 方法不会被 try-with-resources 语句调用,因为对象尚未完全构建并且其初始化尚未完成。然而,即使初始化尚未完成,FileInputStream 的实例仍将执行部分工作:它将打开文件。但它永远不会关闭文件。

两步初始化可能是一种解决方案:

现在,代码可以以以下方式使用,这样确实更安全,因为流将始终关闭:

尽管这可能是一个不错的变通办法,但它只是设计缺陷的掩饰:属性的可变性。一个更好的解决方案是消除Book的可变性,并移除init()方法。

因此,流的初始化应该在Book对象之外完成,然后作为构造函数的参数提供给它(注意in字段的final修饰符)。

然后,这是我们如何使用它的方式:

现在,流和书都肯定会关闭。

问题的根本原因在于in属性的可变性,这会导致资源泄漏的潜在问题。如果我们事先同意每个对象都必须是不可变的,这个问题就不会首先出现。我们不需要像两步初始化这样的变通方法,因为我们不会遇到一个属性可能保持未初始化状态的类。看来,这个例子再次证明了对象不可变性的好处。

考虑这个父类,它有一个不可变属性 title

现在,让我们再次扩展它(注意,author属性是不可变的)。

在我们完成以下操作后,你认为会打印出什么?

This is what:

Title: Object Thinking
Author: null

为什么在构造函数中提供了字符串 “David West” 后,”author” 打印为 “null”?原因是在初始化 “this.author” 之前,父类的构造函数 super() 被调用。Product 类的构造函数调用了它自己的 虚函数 print(),而派生类 Book 覆盖了这个方法。这个问题更普遍地被称为 “脆弱的基类” 问题:基类调用自己的方法,期望它按照定义运行,但是这个方法在派生类中意外地被替换为不同的实现,导致意外和错误的行为。这种方法替换的潜在可能性是使基类变得脆弱的原因。

使用两阶段构造可以解决这个问题,将属性初始化保留在构造函数中,同时将 “打印” 功能转移到一个新的 init() 方法中。然而,这种方法只是掩盖了潜在的设计缺陷:类的固有脆弱性。

一个更全面的解决方案是双重的。首先,保持构造函数无代码,如前面建议的。其次,选择 组合优于继承,也是之前建议的方法。具体做法是:

现在,这两个类都被标记为final,这意味着无法覆盖它们的任何方法。Book类不再继承Product,而是将其封装为一个实例。Book类中的print()方法负责管理打印功能,并将部分责任委托给product.print()。如果我们从一开始就相互同意所有构造函数都应保持无代码,并且禁止使用实现继承,那么这样的设计就成为唯一可行的选择。

如果你已经在Java中编写了足够长的时间,你肯定会发现这种设计方法非常熟悉。

在这里,构造函数为四个对象属性分配默认值,而init()方法随后根据“配置”DTO中的值进行更新。与一系列setter调用相比,这种对象初始化的方法可能更具吸引力,因为它确保同时分配所有必要的属性,没有遗漏。通过孤立的setter无法保证这种保证。此外,DTO可以从XML或JSON文件中自动填充,当传递给init()方法时,进一步简化了代码。

然而,这只是掩盖了潜在的设计缺陷:这个类太庞大了。这与代码行数无关,而是与它试图抽象的各种外部元素有关:Internet地址、TCP端口、登录字符串、密码,以及可能在未来还有更多。虽然目前Config DTO由于其适度的大小已经足够使用,但它似乎为未来的扩展铺平了道路,诱使程序员按照自己的意愿对其进行增加。随着Config的大小增加,Database中的属性数量也会增加。它们很可能很快就会不同步。随着时间的推移,可能会很难判断构造Database对象是否准备就绪,或者是否需要预备性的init()调用。此外,仅仅调用init()是否足以充分利用该对象?

如果我们最初同意对象不应该封装超过三个属性,正如之前所建议的,我们将会重构这个类。init()方法将被移除,所有必要的参数将通过其主要或次要构造函数传递。保持所有属性不可变也会很有帮助。

在这种特殊情况下,似乎甚至建造者设计模式也比init()方法更好。

请考虑以下两个Java类,它们互相依赖:

显然,无法实例化BookOrder,因为每个都需要先实例化另一个。伴随着属性的可变性和设置器的两阶段构建可能看起来是一个解决方案。

现在,可以实例化它们:

然而,这个设计存在一个重大缺陷:直到它们各自的 init() 方法被调用之前,orderbook 对象都处于不完整的状态。随着代码的完善,其原始作者了解到正确的方法调用顺序:首先是构造函数,然后是 setter,最后才是 init() 方法。然而,将来其他人修改代码时,很容易忽略方法调用之间的时序耦合。在 setter 之前无意中调用 init() 可能会导致难以诊断的运行时错误。

这里的根本问题源于 BookOrder 设计中存在的违反分层原则:它们是相互依赖的。如果我记得正确的话,Martin Fowler 提出了 “一层只能访问它下面的层”。在我们的书籍和订单设计中,这些层之间没有明确的区分:无法确定哪一个是基础。实例化的困难只是这个问题的体现,而两步初始化更像是一个临时解决方法,而不是实际的解决方案。

我不知道如何直接改进这段代码。很可能整个架构需要进行彻底的重新评估,引入更合适的抽象来替代 BookOrder

似乎两步初始化并没有真正解决问题,它只是暂时掩盖了问题。虽然你可以在对象中使用init()方法,但它们看起来像是写着“我没有正确设计这个类!”的旗帜。

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-11-18 at 05:27

sixnines availability badge   GitHub stars