如何避免在基础构造函数中调用虚拟方法

9

我在一个库中有一个抽象类。我试图使其尽可能易于正确实现该类的派生类。问题是我需要通过三个步骤来初始化对象:获取文件,执行一些中间步骤,然后处理文件。第一步和最后一步特定于派生类。以下是一个简化的示例。

abstract class Base
{
    // grabs a resource file specified by the implementing class
    protected abstract void InitilaizationStep1();

    // performs some simple-but-subtle boilerplate stuff
    private void InitilaizationStep2() { return; }

    // works with the resource file
    protected abstract void InitilaizationStep3();

    protected Base()
    {
        InitilaizationStep1();
        InitilaizationStep2();
        InitilaizationStep3();
    }
}

当然,问题在于构造函数中的虚方法调用。如果使用该类时不能确保派生类完全初始化,那么库的消费者将发现自己受到了限制。
我可以将逻辑从构造函数中提取到受保护的Initialize()方法中,但是实现者可能直接调用Step1()Step3()而不是调用Initialize()。问题的关键在于,如果跳过Step2(),则不会出现明显的错误;只有在某些情况下性能非常糟糕。
我觉得无论哪种方式,未来的库用户都将不得不解决一个严重而非显而易见的“陷阱”。是否有其他设计可以实现这种初始化?
如果需要,我可以提供更多细节;我只是试图提供最简单的示例来表达问题。
8个回答

11
我建议创建一个抽象工厂,负责使用模板方法来初始化你的派生类实例。例如:

我会考虑创建一个抽象工厂,它负责使用模板方法来实例化和初始化你的派生类。

举个例子:

public abstract class Widget
{
    protected abstract void InitializeStep1();
    protected abstract void InitializeStep2();
    protected abstract void InitializeStep3();

    protected internal void Initialize()
    {
        InitializeStep1();
        InitializeStep2();
        InitializeStep3();
    }

    protected Widget() { }
}

public static class WidgetFactory
{
    public static CreateWidget<T>() where T : Widget, new()
    {
        T newWidget = new T();
        newWidget.Initialize();
        return newWidget;
    }
}

// consumer code...
var someWidget = WidgetFactory.CreateWidget<DerivedWidget>();

如果你愿意使用IoC容器来处理这个职责,那么这个工厂代码可以得到极大的改进...

如果你无法控制派生类,那么可能无法防止它们提供可以被调用的公共构造函数 - 但至少可以建立一种消费者可以遵循的使用模式。

不能总是防止你的类的用户自食其果 - 但是,当他们熟悉设计时,你可以提供基础设施来帮助消费者正确使用你的代码。


他的问题是如何清晰地实现抽象类,而不是创建派生版本。 - user117499
2
我不同意。他正在寻找一种模式,其中派生类将执行结构化初始化,并且他担心这些派生类可能无法按适当的顺序执行所有必要的初始化步骤。执行构造和初始化的工厂可以帮助解决此问题。特别是因为在构造函数中调用虚拟方法(通常)是一种不良做法的替代方案。 - LBushkin
LBushkin对我的需求是正确的。我不确定你的建议是否是抽象工厂的字面实现,但我确实喜欢泛型可以减轻实现者的负担,因为不需要DerivedFactory类。要求Derived的编写者将构造函数设置为受保护的是相当合理的。我的主要关注点是调用代码的简单性成本。此外,为了API的一致性,我可能会希望将所有对象生成移动到工厂中。这可能总体上是有益的,但这是我必须考虑的事情。 - Isabelle Wedin
“我正在努力尽可能地使实现这个类的派生变得简单易行。” - user117499
如果客户端通过调用默认构造函数直接创建Widget继承类的实例,那么怎样才能帮助解决这个问题?如果客户端不知道应该使用某种工厂,该怎么办? - EngineerSpock

4
那样的内容过于复杂,放在任何一个类的构造函数里都不合适,更不用说基类了。我建议你将其分解成一个单独的Initialize方法。

1
我同意。另一种方法是在创建过程中服务化此对象所依赖的内容。 - Keith Adler
2
我喜欢你的解决方案,但是考虑另一种可能性,可以将步骤1和3的代码拆分成一对接口,并将它们作为参数传递给构造函数。构造函数可以在适当的时机调用它们来完成各自的任务。简而言之,这可能是那种组合优于继承的情况之一。 - Steven Sudit
2
这并没有解决他对于调用步骤顺序的担忧,以及简化派生类创建的方法。 - user117499
2
首先,除非这个类层次结构本身就固有一个init方法需要按照这个顺序执行三个步骤,否则不应该由基类强制实施。其次,如果确实是固有的,那么基类的Initialize方法可以调用虚拟的Step1、Step2、Step3初始化方法,按顺序执行:模板函数模式。 - John Saunders
你刚才说过,实现是与类固有的。文件被使用这一事实是一个实现问题。映射存在也是实现问题。它们不应该成为这些类的本质部分。我建议你将此代码拆分成一个辅助类层次结构,并由原始类调用。 - John Saunders
显示剩余2条评论

1

乍一看,我建议将这种逻辑移动到依赖于此初始化的方法中。类似于

public class Base
{
   private void Initialize()
   {
      // do whatever necessary to initialize
   }

   public void UseMe()
   {
      if (!_initialized) Initialize();
      // do work
   }
}

你和ValdV提出了类似的建议;我随意地回应了他的建议:https://dev59.com/REjSa4cB1Zd3GeqPF4Pb#1155502。 - Isabelle Wedin

1

由于步骤1“获取文件”,因此最好有Initialize(IBaseFile)并跳过步骤1。这样,消费者可以以任何方式获取文件-因为它本来就是抽象的。您仍然可以提供一个返回文件的抽象“StepOneGetFile()”,因此如果他们选择,他们可以以这种方式实现它。

DerivedClass foo = DerivedClass();
foo.Initialize(StepOneGetFile('filepath'));
foo.DoWork();

我喜欢将单独的对象推入初始化方法的想法;这样可以消除Step1()和Step3()方法可能产生的漏洞。这会给派生类带来更多的负担,但它使得使用基类的方式只有一种。虽然路线更长,但更为直接。我认为我会根据我的需求保持Initialize为protected,但原则是相同的。 - Isabelle Wedin

1

编辑:我不知道为什么回答了C++。对不起。 对于C#,我建议不要使用Create()方法 - 使用构造函数并确保对象从一开始就处于有效状态。C#允许从构造函数进行虚拟调用,如果您仔细记录其预期功能和前后条件,则可以使用它们。我第一次推断是C++,因为它不允许从构造函数进行虚拟调用。

将单独的初始化函数设置为private。它们既可以是private也可以是virtual。然后提供一个公共的、非虚拟的Initialize()函数,按正确的顺序调用它们。

如果您想确保在创建对象时发生所有事情,请将构造函数设置为protected,并在您的类中使用静态的Create()函数,在返回新创建的对象之前调用Initialize()


私有虚函数的意义在哪里 - 它无法被覆盖。 - John Saunders
@John:抱歉。C#允许从构造函数进行虚拟调用,如果您仔细记录它们的预期功能和前后条件,则可以使用它们。我第一次推断是C++,因为它不允许从构造函数进行虚拟调用。 - Sam Harwell
Create() 方法,我认为它类似于工厂模式。虽然它的功能有限,但在 API 大小方面占用的空间更小,也许可以满足我在这种情况下的需求。你建议不要使用它;我是否忽略了一些关键限制?这是我以前使用过的一种模式(用于不太重要的代码),看起来效果还不错。说服我它是个坏主意,否则我会认真考虑它。 :) - Isabelle Wedin

1
在许多情况下,初始化工作涉及分配一些属性。可以将这些属性本身设为抽象,并让派生类重写它们并返回某个值,而不是将该值传递给基础构造函数进行设置。当然,这个想法是否适用取决于您特定类的性质。无论如何,在构造函数中有那么多代码是不好的。

一个有趣的想法,但不幸的是在我的情况下不适用。无论如何还是谢谢! - Isabelle Wedin

1

您可以采用以下技巧来确保初始化按正确顺序执行。假设您在基类中实现了一些其他方法(DoActualWork),这些方法依赖于初始化。

抽象类 Base
{
    private bool _initialized;
protected abstract void InitilaizationStep1(); private void InitilaizationStep2() { return; } protected abstract void InitilaizationStep3();
protected Initialize() { // 在此处调用虚拟方法是安全的 InitilaizationStep1(); InitilaizationStep2(); InitilaizationStep3();
// 将对象标记为已正确初始化 _initialized = true; }
public void DoActualWork() { if (!_initialized) Initialize(); Console.WriteLine("我们现在肯定已经初始化了"); } }

当我研究使用这种模式时,我发现我的类有太多的入口点,使得这种方法变得不干净和实用。特别是,有许多重载用于接收绑定信息和以各种格式检索该信息。这反过来又暗示我可能想将这些职责重构为一个新类。这是个好主意,尽管可能有点hacky。当多个方法需要适当的初始化时,将公共代码分解出来是否会带您走向工厂模式的方向?无论如何,感谢您的建议和启发。 - Isabelle Wedin

0

我不会这样做。通常情况下,在构造函数中进行任何“真正”的工作最终都会变成一个糟糕的想法。

至少,应该有一个单独的方法来从文件加载数据。你可以进一步提出一个论点,让一个单独的对象负责从文件构建你的一个对象,将“从磁盘加载”和内存操作对象的关注点分离。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接