web-dev-qa-db-ja.com

基本クラスコンストラクターで仮想メソッドを呼び出す

子クラスが有効な状態にない可能性があるため、基本クラスのコンストラクターから仮想メソッドを呼び出すことは危険な場合があることを私は知っています。 (少なくともC#では)

私の質問は、仮想メソッドがオブジェクトの状態を初期化するものである場合はどうなりますか?それは良い習慣ですか、それとも最初にオブジェクトを作成し、次に状態をロードする2段階のプロセスである必要がありますか?

最初のオプション:(コンストラクターを使用して状態を初期化します)

public class BaseObject {
    public BaseObject(XElement definition) {
        this.LoadState(definition);
    }

    protected abstract LoadState(XElement definition);
}

2番目のオプション:(2ステップのプロセスを使用)

public class BaseObject {
    public void LoadState(XElement definition) {
        this.LoadStateCore(definition);
    }

    protected abstract LoadStateCore(XElement definition);
}

最初の方法では、コードのコンシューマーは1つのステートメントでオブジェクトを作成および初期化できます。

// The base class will call the virtual method to load the state.
ChildObject o = new ChildObject(definition)

2番目の方法では、コンシューマーはオブジェクトを作成してから状態をロードする必要があります。

ChildObject o = new ChildObject();
o.LoadState(definition);
32
Yona

(この回答はC#とJavaに適用されます。C++はこの問題で異なる動作をすると思います。)

コンストラクターで仮想メソッドを呼び出すことは確かに危険ですが、場合によっては最もクリーンなコードになる可能性があります。

私は可能な限りそれを避けようとしますが、デザインを曲げることなく大きく。 (たとえば、「後で初期化する」オプションは不変性を禁止します。)コンストラクターで仮想メソッドをdo使用する場合は、それを文書化します非常に強く。関係者全員がそれが何をしているのかを知っている限り、それがあまりにも多くの問題を引き起こすことはないはずです。ただし、最初の例で行ったように、可視性を制限しようとします。

編集:ここで重要なことの1つは、初期化の順序でC#とJava)の間に違いがあることです。次のようなクラスがある場合:

public class Child : Parent
{
    private int foo = 10;

    protected override void ShowFoo()
    {
        Console.WriteLine(foo);
    }
}

ここで、ParentコンストラクターはShowFooを呼び出し、C#では10を表示します。Javaの同等のプログラムは0を表示します。

39
Jon Skeet

C++では、基本クラスコンストラクターで仮想メソッドを呼び出すと、派生クラスがまだ存在しないかのようにメソッドが呼び出されます(存在しないため)。つまり、呼び出しはコンパイル時に、基本クラス(または派生元のクラス)で呼び出す必要のあるメソッドに解決されます。

GCCでテストすると、コンストラクターから純粋仮想関数を呼び出すことができますが、警告が表示され、リンク時間エラーが発生します。この動作は標準では定義されていないようです。

「メンバー関数は、抽象クラスのコンストラクタ(またはデストラクタ)から呼び出すことができます。作成中のオブジェクトに対して直接または間接的に純粋仮想関数を仮想呼び出し(class.virtual)する効果。そのようなコンストラクタ(またはデストラクタ)からの(または破棄された)は定義されていません。」

10
Greg Rogers

C++では、仮想メソッドは、構築されているクラスのvtableを介してルーティングされます。したがって、この例では、BaseObjectの構築中に、呼び出すLoadStateCoreメソッドがないため、純粋仮想メソッドの例外が生成されます。

関数が抽象的ではなく、単に何もしない場合、関数が実際に呼び出されない理由を思い出そうとして、プログラマーがしばらく頭を悩ませることがよくあります。

このため、C++ではこの方法を実行することはできません...

4
Rob Walker

C++の場合、基本コンストラクターは派生コンストラクターの前に呼び出されます。つまり、仮想テーブル(派生クラスのオーバーライドされた仮想関数のアドレスを保持する)はまだ存在しません。このため、実行することは非常に危険なことと見なされます(特に、関数が基本クラスで純粋仮想である場合...これにより純粋仮想例外が発生します)。

これを回避する方法は2つあります。

  1. 構築と初期化の2段階のプロセスを実行します
  2. 仮想関数を、より厳密に制御できる内部クラスに移動します(上記のアプローチを利用できます。詳細については、例を参照してください)。

(1)の例は次のとおりです。

class base
{
public:
    base()
    {
      // only initialize base's members
    }

    virtual ~base()
    {
      // only release base's members
    }

    virtual bool initialize(/* whatever goes here */) = 0;
};

class derived : public base
{
public:
    derived ()
    {
      // only initialize derived 's members
    }

    virtual ~derived ()
    {
      // only release derived 's members
    }

    virtual bool initialize(/* whatever goes here */)
    {
      // do your further initialization here
      // return success/failure
    }
};

(2)の例は次のとおりです。

class accessible
{
private:
    class accessible_impl
    {
    protected:
        accessible_impl()
        {
          // only initialize accessible_impl's members
        }

    public:
        static accessible_impl* create_impl(/* params for this factory func */);

        virtual ~accessible_impl()
        {
          // only release accessible_impl's members
        }

        virtual bool initialize(/* whatever goes here */) = 0;
    };

    accessible_impl* m_impl;

public:
    accessible()
    {
        m_impl = accessible_impl::create_impl(/* params to determine the exact type needed */);

        if (m_impl)
        {
            m_impl->initialize(/* ... */);  // add any initialization checking you need
        }
    }

    virtual ~accessible()
    {
        if (m_impl)
        {
            delete m_impl;
        }
    }

    /* Other functionality of accessible, which may or may not use the impl class */
};

アプローチ(2)は、ファクトリパターンを使用して、accessibleクラスに適切な実装を提供します(これにより、baseクラスと同じインターフェイスが提供されます)。ここでの主な利点の1つは、accessible_implの仮想メンバーを安全に利用できるaccessibleの構築中に初期化を取得できることです。

4
Brian

C++については、ScottMeyerの対応する記事を読んでください。

構築中または破壊中に仮想関数を呼び出さないでください

ps:記事のこの例外に注意してください:

LogTransaction関数はトランザクションでは純粋仮想であるため、問題は実行前にほぼ確実に明らかになります。定義されていない限り(可能性は低いですが、possible)プログラムはリンクしません:リンカーはTransaction :: logTransactionの必要な実装を見つけることができません。

3
Özgür

投稿に示されているように、コンストラクターでXElementをとるクラスがある場合、XElementが由来する可能性があるのは派生クラスだけです。では、すでにXElementを持っている派生クラスに状態をロードしてみませんか。

あなたの例には状況を変えるいくつかの基本的な情報が欠けているか、正確な情報を伝えたばかりなので、基本クラスからの情報で派生クラスに戻る必要はありません。

つまり.

public class BaseClass
{
    public BaseClass(XElement defintion)
    {
        // base class loads state here
    }
}

public class DerivedClass : BaseClass
{
    public DerivedClass (XElement defintion)
        : base(definition)
    {
        // derived class loads state here
    }
}

そうすれば、コードは本当に単純で、仮想メソッド呼び出しの問題は発生しません。

3
Greg Beech

C++の場合、標準のセクション12.7、パラグラフ3がこのケースをカバーしています。

要約すると、これは合法です。実行中のコンストラクターのタイプに合った正しい関数に解決されます。したがって、例をC++構文に適合させると、BaseObject::LoadState()を呼び出すことになります。 ChildObject::LoadState()にアクセスすることはできません。クラスと関数を指定してアクセスしようとすると、未定義の動作が発生します。

抽象クラスのコンストラクターについては、セクション10.4の段落6で説明しています。簡単に言うと、メンバー関数を呼び出すことはできますが、コンストラクターで純粋仮想関数を呼び出すことは未定義の動作です。そうしないでください。

3
David Thornley

C++では、基本クラス内から仮想関数を呼び出すことは完全に安全です-それらが非純粋である限り、いくつかの制限があります。しかし、あなたはそれをすべきではありません。コメントと適切な名前(initializeなど)を使用してそのような初期化関数として明示的にマークされている非仮想関数を使用して、オブジェクトをより適切に初期化します。それを呼び出すクラスで純粋仮想として宣言されている場合でも、動作は定義されていません。

呼び出されるバージョンは、コンストラクター内から呼び出すクラスの1つであり、派生クラスのオーバーライドではありません。これは仮想関数テーブルとはあまり関係がありませんが、その関数のオーバーライドがまだ初期化されていないクラスに属している可能性があるという事実があります。したがって、これは禁止されています。

C#とJavaでは、コンストラクターの本体に入る直前に行われるデフォルトの初期化などがないため、これは問題ではありません。 C#では、本体の外部で行われるのは、私が信じている基本クラスまたは兄弟コンストラクターを呼び出すことだけです。ただし、C++では、その関数のオーバーライドによって派生クラスのメンバーに対して行われた初期化は、派生クラスのコンストラクター本体に入る直前にコンストラクター初期化子リストを処理しているときにそれらのメンバーを構築するときに取り消されます。

編集:コメントがあるので、少し説明が必要だと思います。これが(考案された)例です。仮想の呼び出しが許可され、呼び出しによって最終的なオーバーライドがアクティブになると仮定します。

struct base {
    base() { init(); }
    virtual void init() = 0;
};

struct derived : base {
    derived() {
        // we would expect str to be "called it", but actually the
        // default constructor of it initialized it to an empty string
    }
    virtual void init() {
        // note, str not yet constructed, but we can't know this, because
        // we could have called from derived's constructors body too
        str = "called it";
    }
private:
    string str;
};

この問題は、C++標準を変更して許可することで実際に解決できます。コンストラクターの定義、オブジェクトの有効期間などを調整します。何を定義するためのルールを作成する必要がありますstr = ...;はまだ構築されていないオブジェクトを意味します。そして、その効果がinitを呼び出した人にどのように依存するかに注意してください。私たちが得た機能は、私たちが解決しなければならない問題を正当化するものではありません。したがって、C++は、オブジェクトの構築中に動的ディスパッチを禁止するだけです。

通常、これらの問題は、貪欲なベースコンストラクターを使用することで回避できます。あなたの例では、XElementをLoadStateに渡しています。基本コンストラクターで状態を直接設定できるようにすると、子クラスはコンストラクターを呼び出す前にXElementを解析できます。

public abstract class BaseObject {
   public BaseObject(int state1, string state2, /* blah, blah */) {
      this.State1 = state1;
      this.State2 = state2;
      /* blah, blah */
   }
}

public class ChildObject : BaseObject {
   public ChildObject(XElement definition) : 
      base(int.Parse(definition["state1"]), definition["state2"], /* blah, blah */) {
   }
}

子クラスがかなりの作業を行う必要がある場合は、静的メソッドにオフロードできます。

1
Mark Brackett