调用父类方法
2005年8月11日
调用父类方法是一种轻微的代码异味(或者如果你喜欢的话,反模式),它在面向对象框架中时不时地出现。它的症状很容易发现。你继承自一个父类来接入某个框架。文档中会写着类似“为了做你自己的事情,只需子类化 process 方法。但是,重要的是要记住,你的方法要从调用父类方法开始”。一个例子可能是这样的。
public class EventHandler ... public void handle (BankingEvent e) { housekeeping(e); } public class TransferEventHandler extends EventHandler... public void handle(BankingEvent e) { super.handle(e); initiateTransfer(e); }
每当你必须记住每次都要做某事时,那就是一个糟糕的 API 信号。相反,API 应该为你记住这些内部调用。通常的做法是将 handle 方法变成一个模板方法,像这样。
public class EventHandler ... public void handle (BankingEvent e) { housekeeping(e); doHandle(e); } protected void doHandle(BankingEvent e) { } public class TransferEventHandler extends EventHandler ... protected void doHandle(BankingEvent e) { initiateTransfer(e); }
这里,父类定义了公共方法,并提供了一个单独的方法(通常称为钩子方法)供子类重写。子类编写者现在不必担心调用父类方法。此外,父类编写者可以自由地在子类方法之后添加调用,如果她愿意的话。
定义钩子方法有几种方法。在本例中,我展示了一个空实现。如果许多子类不需要提供自己的附加行为,这将很有用。如果许多子类必须做同样的事情,你可以考虑一个默认实现,它本身也可以是一个模板方法,以允许在通用方案中进行变异。如果每个子类都应该提供唯一的行为,那么你可以在父类中将钩子方法设为抽象方法。
其中一个问题是,通常没有办法表明某个方法是钩子方法,即框架编写者希望被重写的方法。你确实看到了约定(handle/doHandle 是一个常见的约定),但大多数情况下,你必须解释它是如何工作的。解释它的最佳方法之一是举个例子。当我查看其中一个案例时,我通常发现最好的做法是查看现有的子类,看看它做了什么。
在本例中,只有一个相对明显的钩子方法。然而,通常情况下,有很多方法,所有这些方法都可能是钩子,具体取决于子类想要控制多少。快速浏览一下我的 HTML 布局类的抽象基类,可以看到六个可以重写的方法。一些子类想要做一些简单的事情,并重写小的钩子;而另一些子类需要做更复杂的事情,并重写更大范围的方法。
在某些语言中,你可以密封 handle 方法,以防止子类重写它。由于我有一个启用态度,我通常不愿意这样做 - 尤其是在继承实际上是一个公开接口的情况下。支持密封的论点是,它意味着子类不能破坏你的父类。我不认为重写错误的东西是“破坏父类”。毕竟,子类和父类必须紧密协作,继承是一种非常亲密的关系。要么整个事情都能正常工作,要么就无法工作。密封可以很好地表明某人正在重写错误的东西,因此,我倾向于将其用于非公开接口(即子类是同一个代码库的一部分)。我对密封的问题是,子类可能想要重写 handle 调用来做一些特别复杂的事情。我无法预测子类的需求,因此我宁愿说“继续,但后果自负”,而不是“不”。如果它是一个代码库,那么我们总是可以在需要时取消密封。
多级钩子
因此,我希望你能明白,你不应该依赖调用父类方法来处理这种情况。但是,当你有多个框架级别时,就会出现一个复杂情况。
这里,我将切换到一个真实的例子,一个不时出现的例子:JUnit。JUnit 使用一个模板方法来控制其测试用例的整体运行,它看起来像这样
public abstract class TestCase public void runBare() throws Throwable { setUp(); try { runTest(); } finally { tearDown(); } } protected void setUp() throws Exception { } protected void tearDown() throws Exception { }
这是一个熟悉的模板方法,有两个空钩子方法用于重写。到目前为止,一切都很好;你可以根据需要轻松地添加自己的设置和拆卸代码。
当用户想要从 JUnit 派生另一个框架时,就会出现复杂情况。有很多例子,但让我们举一个简单的例子,我们有一个项目特定的约定,比如这样。
public class AlphaTestCase extends TestCase protected void setUp() throws Exception { alphaProjectSetup(); }
这就是你遇到调用父类方法问题的地方。因此,如果我们使用前面提到的建议,我们可以这样重新定义它。
public class AlphaTestCase extends TestCase... final protected void setUp() throws Exception { alphaProjectSetup(); doSetUp(); } protected void doSetUp() throws Exception { }
虽然这可以工作,但它会导致混淆任何熟悉 JUnit 的人。他们参与过的每个项目,他们读过的每一本书都告诉他们应该重写 setUp,而不是这个新奇的 doSetUp。这是一个使用 final 的好例子,因为人们很可能会感到困惑。但即使使用 final,不同的设置方法也会造成非常痛苦的混淆。
还有另一种选择。第二级框架可以重写 setUp 的调用者。
public class AlphaTestCase extends TestCase... public void runBare() throws Throwable { alphaProjectSetup(); setUp(); try { runTest(); } finally { tearDown(); } } protected void setUp() throws Exception { }
现在每个人都可以像往常一样使用 setUp。框架编写者也有更多选择,如果他们想在有趣的地方添加其他行为,他们可以这样做。这始终是一个值得考虑的选择 - 如果模板方法在你的位置不起作用,请考虑向上移动一级。
当然,没有免费的模板。在某些情况下,你无法做到这一点,因为你不知道该怎么做,因为源代码不可用。在其他情况下,框架编写者有一个指导态度,并且已经密封了调用方法,阻止你重写它。
即使你能够做到,也有一些需要注意的地方。如果主要框架编写者需要更改你重写的方法(实际上 JUnit 在 2004 年 10 月 9 日就做了),该怎么办。与往常一样,继承的力量伴随着责任,你必须密切关注父类中发生的更改。
现在出现的一种新选择是使用注解。JUnit 和其他基于 Java 的框架在过去几个月里一直在使用注解,遵循NUnit的先例。注解允许你为方法提供比名称更多的元数据,从而在这种情况下为你提供更多选择。但这将是另一天的主题。