最終更新日時:
が更新

履歴 編集

Boost.Signals 設計の論拠

本ドキュメントは、Boost.Signals ライブラリに対してなされたいくつかの大きな設計上の決定に関して、その背後にある論拠を解説する。

目次

スロットの定義の選択

スロットの定義は、シグナル・スロットライブラリによって異なる。 Boost.Signals では、スロットは非常に緩やかな方法で定義されている: それは、シグナルによって指定された型のパラメタを与えて呼び出すことが可能であり、その戻り値がシグナルが想定する結果の型に変換可能であるような 任意の関数オブジェクトである。 しかしながら Boost.Signals を構築するに先立って考慮された別の定義は、それに関連した利点と欠点を持つ。

  • 特定の基底クラスから派生したスロット: 一般にこのような枠組みでは、すべてのユーザ定義スロットを、スロットを呼び出す仮想関数を定義したあるライブラリ指定の Slot 抽象クラスから派生させることを要求する。 アダプタを用いることで、このような定義を Boost.Signals によって用いられているのと似た定義に変換することが可能だが、そうすると元々の仕様が内部で仮想関数を利用するという実装に結びつけられてしまう。 このアプローチは、オブジェクト指向の観点から実装とユーザインターフェースを単純化する利点がある。
  • プリミティブの集合から構築されたスロット: この枠組みでは、スロットは (しばしば共通の抽象基底クラスから派生した) 限られた型の集合を持つ。 それは、しばしば自由関数ポインタやメンバ関数ポインタからの変換を含むライブラリ定義のプリミティブの集合から構築され、制限された引数結合能力を持つ。 このようなアプローチは適度に単純でほとんどの場合をカバーするが、スロットの構築に関しては柔軟性に乏しい。 関数オブジェクト構成のためのライブラリは非常に高度なものとなり、そのような機能強化を組み込むことはシグナル・スロットライブラリの範囲を超える。 したがって Boost.Signals は引数結合や関数オブジェクト構築プリミティブを含めずに、通常の関数オブジェクトの構成についての情報を発見する well-defined なインターフェースを用いる。

スロットの定義に満足できないユーザは、既定のスロット関数型を特定の用途にあった別のものに置き換えることが可能である。

ユーザレベルの接続管理

ユーザは、シグナルからスロットへの接続と来たるべき切断に関して、洗練された制御を必要とする。 Boost.Signals が採用しているアプローチは、接続状態問い合わせと手動での切断、ならびに破棄状態における自動切断を可能にする connection オブジェクトを返すことである。 他に見込みがあるインターフェースとして、次のようなものがある:

  • 切断のためにスロットを渡す: このインターフェースのモデルでは、sig.connect(slot) によって接続されたスロットの切断は sig.disconnect(code) によって行われる。 内部的にはスロット比較を用いた線形検索が実行され、見つかるとそれがリストから削除される。 不幸なことに接続状態を問い合わせることも、一般に線形時間の操作となる。 このモデルは、スロットが単純な関数ポインタ、メンバ関数ポインタや制限された構築子と引数結合子よりも複雑なものとなった場合、実装上の理由からも失敗する。 なぜならこのモデルは関数オブジェクトの比較に依存しているが、一般の関数オブジェクトは比較可能ではないからだ。
  • 切断のためにトークンを渡す: このアプローチでは、スロットを容易に比較可能なトークン (例: 文字列) によって識別する。 これによって、スロットを一般の関数オブジェクトにすることが可能になる。 このアプローチは本質的には Boost.Signals が採用しているアプローチと等価だが、いくつかの理由でよりエラーを生じやすい傾向にある:
    • 接続と切断を対にしなければならないため、動的メモリ確保に際して newdelete を対にするときに背負い込む問題と同種の問題を生じる。 この種のエラーはシグナル・スロットの実装においては大失敗ではないだろうが、その検出は一般に自明ではない。
    • トークンは固有でなければならない、さもなければ同名の二つのスロットが区別不能になる。 多くの接続が動的に生成される環境では、名前生成がユーザにとって付加的な作業となる。 またトークンの固有性は、すでに利用されているトークンを用いてスロットを接続しようと試みたときに追加のエラーを生じさせることになる。
    • さらなるパラメタ化が必要となる、なぜならトークン型はユーザ定義であるからだ。 付加的なパラメタ化は学習曲線を険しくし、単純なインターフェースを過度に複雑にする。

Boost.Signals では、この種類のインターフェースは名前付き接続の機構を介してサポートされている。 それは、オブジェクトベースの接続管理の枠組みである connection を補う。

統合子インターフェース

統合子のインターフェースは、 C++ 標準ライブラリのアルゴリズム呼び出しに類似するように選択された。 スロット呼び出しの結果を入力イテレータによってアクセスされる単なる値のシーケンスのように見せることで、統合子のインターフェースは熟達した C++ にとってもっとも自然なものとなっただろう。 競合するインターフェース設計では、概して統合子を Signals ライブラリに特化した (そして限定された) インターフェースにしたがって構築する必要がある。 一般に、このようなインターフェースはシグナル・スロットライブラリのより直裁的な実装を可能にする一方で、統合子を他のシグナル・スロットライブラリはジェネリックアルゴリズムで再利用することは残念ながら不可能となり、特定の統合子インターフェースを学ぶことが学習曲線をやや険しくする。

Signals における統合子の形式は、統合子が通信に際して、より複雑な "push" 方式ではなく "pull" 方式を利用することを基礎としている。 "pull" 機構では統合子の状態はスタックとプログラムカウンタ中に保持できる。 なぜなら新しいデータを必要とする (つまり次のスロットを呼び出して戻り値を受け取る) 際には、いつでも統合子のコードから戻ることなしに即座にデータを受け取ることができる単純なインターフェースが存在するからだ。 これは、シグナル呼び出しの度に統合子の手続きが呼び出されるため、統合子が全状態をクラスメンバに保持しなければならない "push" 機構と対照的だ。 例として、スロット呼び出しの最大要素を戻す統合子を比較してみる。 もし最大要素 100 を超えたら、それ以上のスロットは呼び出さないものとする。

Pull

struct pull_max {
    typedef int result_type;

    template<typename InputIterator>
    result_type operator()(InputIterator first, InputIterator last) {
        if (first == last)
            throw std::runtime_error("Empty!");

        int max_value = *first++;
        while(first != last && *first <= 100) {
            if (*first > max_value)
                max_value = *first;
            ++first;
        }

        return max_value;
    }
};

Push

struct push_max {
    typedef int result_type;

    push_max() : max_value(), got_first(false) {}

    // returns false when we want to stop
    bool operator()(int result) {
        if (result > 100)
            return false;

        if (!got_first) {
            got_first = true;
            max_value = result;
            return true;
        }

        if (result > max_value)
            max_value = result;

        return true;
    }

    int get_value() const {
        if (!got_first)
            throw std::runtime_error("Empty!");
        return max_value;
    }

private:
    int  max_value;
    bool got_first;
};

これらの例において注意すべき点がいくつかある。 "pull" 版は、 value_type が汎整数型であるような入力イテレータシーケンスに基づいた再利用可能な関数オブジェクトであり、意図も非常に直裁的である。 一方 "push" 方式は呼び出し側の特定のインターフェースに依拠しており、たいていは再利用不可能である。 また決定に際して余分な状態値、たとえば要素を一つでも受け取ったか、を必要とする。 一般にコードの品質と利用しやすさは主観的なものだが、明らかに "pull" 方式は短く、再利用性に富み、たいていはシグナル・スロットライブラリの文脈外であっても、書き、理解するのが容易である。

"pull" 統合子インターフェースのコストは Signals ライブラリ自身の実装において支払われている。 呼び出し中 (例: 参照外し演算子実行中) のスロット切断を正しく扱うために、切断されたスロットを飛ばすイテレータを構築しなければならない。 加えてイテレータはそれぞれのスロットに渡す実引数の集合を持ち運ぶ必要があり(これらの実引数を格納した構造体への参照で十分であるが)、複数回の参照外しが複数回のスロット呼び出しとならないよう、スロット呼び出しの結果をキャッシュしなければならない。 これは明らかに大きなオーバーヘッドを必要とするが、スロット呼び出しの全過程を考えると、このオーバーヘッドは "push" 方式におけるオーバーヘッドとほぼ等価であると考えられる。 我々は統合子の状態検出を複雑にする代わりに、イテレーションと参照外しを行う制御構造が複雑になるように逆転させたのである。

接続インターフェース: += 演算子

Boost.Signals は、接続に関して sig.connect(slot) 形式の構文をサポートしている。 しがしながら、より簡潔な構文である (そして他のシグナル・スロット実装で用いられている) sig += slot が提案されたことがある。 この構文が却下された理由は、いくつかある:

  • 不要である: Boost.Signals によって提供される接続に関する構文は、 sig += slot と同程度に強力である。 connect()+= を比較してタイプ数を節約できることは、本質的に無視できる。 さらに、 connect() 呼び出しは += のオーバーロードよりも読みやすいと主張できる。
  • 戻り値型の曖昧さ: += 演算子の戻り値に関して曖昧さが生じる: sig += slot1 += slot2 を可能にするために戻り値はシグナル自身への参照であるべきだろうか、 それとも新規に作成されたシグナル・スロット接続を表す connection を返すべきだろうか?
  • -=, + 演算子への橋渡し: 接続のための演算子 += を追加したのなら、切断のための演算子 -= を追加するのは自然なことだろう。 しかしながら、これはライブラリが一般の関数オブジェクトを暗黙のうちにスロットにしようとする場合に問題を生じさせる。 なぜならスロットはもはや比較可能ではなくなるからだ (このトピックに関する議論は ユーザレベルの接続管理 を参照のこと)。

    operator+= を含めた場合、次に複数スロットをサポートする + 演算子を付け足すことも素朴な追加だろう。 この後にシグナルへの代入が続く。 だが、これは任意の二つの関数オブジェクトを受理可能な + の実装を必要とし、技術的に実行不可能である。

trackable の論拠

trackable クラスは自動的な接続寿命管理に関する主要なユーザインターフェースであり、その設計はユーザに直接的に影響を及ぼす。 二つの点がもっとも目立っている: それは trackable をコピーする際の奇妙な振る舞いと、そして自動切断管理に関係する型を作成するには trackable から派生することを要求するという制約である。

trackable コピー時の振る舞い

trackable のコピー時の振る舞いは、本質的に trackable 部分オブジェクトは決してコピーされないということである; コピー操作はほとんど何も行わない。 これを理解するためにシグナル・スロット接続の性質を調べ、接続が接続状態にある実体を基礎としていることに注目しよう; 実体が破棄されると接続も破棄される。 したがって trackable 部分オブジェクトがコピーされると、接続をコピーすることが不可能になる。 なぜなら接続は目標となる実体を参照しているのではなく、その源となる実体を参照しているからだ。 この理由はシグナルがコピー不可能である理由と対をなしている: シグナルに接続されたスロットは特定のシグナルに接続されているのであって、シグナル中のデータに接続されているのではない。

trackable から派生させる理由

trackable た正しく働くために二つの制約が存在する:

  • trackable は、このオブジェクトに対してなされた全接続を追跡する記憶域を持つ必要がある。
  • trackable は、オブジェクトが破棄されるとき、その接続を切断するために通告を受ける必要がある。

明らかに trackable から派生させることはこれらの二つの指針を満足する。 我々は、これに勝る解決策を発見していない。

libsigc++

libsigc++ は、当初は C++ で GTK の C 言語インターフェースをラップしようという提唱の一部として開始され、Karl Nelson によって保守される別個のライブラリに成長した C++ のシグナル・スロットライブラリである。 libsigc++ と Boost.Signals には多くの類似点があり、実際のところ Boost.Signals は Karl Nelson と libsigc++ に強く影響されている。 それぞれのライブラリを大雑把に調査すると、シグナル構築や接続の利用法、自動的な接続寿命管理に関して類似した構文を見つけるだろう。 これらのライブラリを区別する、設計上の大きな差異もいくつか存在する。

  • スロットの定義: libsigc++ におけるスロットは、ライブラリによって提供される一連のプリミティブを用いて作成される。 これらのプリミティブによってオブジェクトを (ライブラリの一部として) 結合させ、シグナルと引数と戻り値型をスロットの引数と戻り値型に適合させることが可能になる(既定では libsigc++ は Boost.Signals よりも型に関して厳密である)。 このアプローチと Boost.Signals によって採用されているアプローチの比較はスロットの定義の選択 にある。

  • 統合子/マーシャラーインターフェース: libsigc++ において Boost.Signals の統合子に相当するものはマーシャラーである。 マーシャラーは 統合子インターフェース で記述されている "push" インターフェースに類似しており、 そこでこの件に関する厳密な議論がなされている。

.NET デリゲート

Microsoft は .NET フレームワークと、それに関連した一連の言語、言語拡張を登場させたが、そのうちの一つがデリゲートである。 デリゲートはシグナルとスロットに類似しているが、ほとんどの C++ のシグナル・スロットの実装と比較して限定されたものとなっている。 デリゲートは

  • デリゲートと呼び出すものの間で、厳密な型の一致を要求する。
  • 戻り値型を許さない。
  • 事前に結合された this によってメソッドを呼び出さねばならない。

Doug Gregor

Last modified: Fri Oct 11 05:41:04 EDT 2002