本ドキュメントは、 Boost.Signals ライブラリに対してなされたいくつかの大きな設計上の決定に関して、 その背後にある論拠を解説する。
スロットの定義は、シグナル・スロットライブラリによって異なる。 Boost.Signals では、スロットは非常に緩やかな方法で定義されている: それは、シグナルによって指定された型のパラメタを与えて呼び出すことが可能であり、 その戻り値がシグナルが想定する結果の型に変換可能であるような 任意の関数オブジェクトである。 しかしながら Boost.Signals を構築するに先立って考慮された別の定義は、 それに関連した利点と欠点を持つ。
Slot
抽象クラスから派生させることを要求する。
アダプタを用いることで、
このような定義を Boost.Signals によって用いられているのと似た定義に変換することが可能だが、
そうすると元々の仕様が内部で仮想関数を利用するという実装に結びつけられてしまう。
このアプローチは、オブジェクト指向の観点から実装とユーザインターフェースを単純化する利点がある。スロットの定義に満足できないユーザは、 既定のスロット関数型を特定の用途にあった別のものに置き換えることが可能である。
ユーザは、シグナルからスロットへの接続と来たるべき切断に関して、
洗練された制御を必要とする。
Boost.Signals が採用しているアプローチは、
接続状態問い合わせと手動での切断、ならびに破棄状態における自動切断を可能にする connection
オブジェクトを返すことである。
他に見込みがあるインターフェースとして、次のようなものがある:
sig.connect(slot)
によって接続されたスロットの切断は
sig.disconnect(code)
によって行われる。
内部的にはスロット比較を用いた線形検索が実行され、
見つかるとそれがリストから削除される。
不幸なことに接続状態を問い合わせることも、一般に線形時間の操作となる。
このモデルは、
スロットが単純な関数ポインタ、
メンバ関数ポインタや制限された構築子と引数結合子よりも複雑なものとなった場合、
実装上の理由からも失敗する。
なぜならこのモデルは関数オブジェクトの比較に依存しているが、
一般の関数オブジェクトは比較可能ではないからだ。new
と delete
を対にするときに背負い込む問題と
同種の問題を生じる。
この種のエラーはシグナル・スロットの実装においては大失敗ではないだろうが、
その検出は一般に自明ではない。 Boost.Signals では、
この種類のインターフェースは名前付き接続の機構を介してサポートされている。
それは、オブジェクトベースの接続管理の枠組みである connection
を補う。
統合子のインターフェースは、 C++ 標準ライブラリのアルゴリズム呼び出しに類似するように選択された。 スロット呼び出しの結果を入力イテレータによってアクセスされる単なる値のシーケンスのように見せることで、 統合子のインターフェースは熟達した C++ にとってもっとも自然なものとなっただろう。 競合するインターフェース設計では、 概して統合子を Signals ライブラリに特化した (そして限定された) インターフェースにしたがって構築する必要がある。 一般に、 このようなインターフェースはシグナル・スロットライブラリのより直裁的な実装を可能にする一方で、 統合子を他のシグナル・スロットライブラリはジェネリックアルゴリズムで再利用することは残念ながら不可能となり、 特定の統合子インターフェースを学ぶことが学習曲線をやや険しくする。
Signals における統合子の形式は、 統合子が通信に際して、 より複雑な "push" 方式ではなく "pull" 方式を利用することを基礎としている。 "pull" 機構では統合子の状態はスタックとプログラムカウンタ中に保持できる。 なぜなら新しいデータを必要とする (つまり次のスロットを呼び出して戻り値を受け取る) 際には、 いつでも統合子のコードから戻ることなしに即座にデータを受け取ることができる単純なインターフェースが存在するからだ。 これは、 シグナル呼び出しの度に統合子の手続きが呼び出されるため、 統合子が全状態をクラスメンバに保持しなければならない "push" 機構と対照的だ。 例として、 スロット呼び出しの最大要素を戻す統合子を比較してみる。 もし最大要素 100 を超えたら、それ以上のスロットは呼び出さないものとする。
Pull | Push |
---|---|
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; } }; |
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
が提案されたことがある。
この構文が却下された理由は、いくつかある:
sig += slot
と同程度に強力である。
connect()
と +=
を比較してタイプ数を節約できることは、
本質的に無視できる。
さらに、
connect()
呼び出しは +=
のオーバーロードよりも読みやすいと主張できる。+=
演算子の戻り値に関して曖昧さが生じる:
sig += slot1 += slot2
を可能にするために戻り値はシグナル自身への参照であるべきだろうか、
それとも新規に作成されたシグナル・スロット接続を表す connection
を返すべきだろうか?+=
を追加したのなら、
切断のための演算子 -=
を追加するのは自然なことだろう。
しかしながら、
これはライブラリが一般の関数オブジェクトを暗黙のうちにスロットにしようとする場合に問題を生じさせる。
なぜならスロットはもはや比較可能ではなくなるからだ
(このトピックに関する議論は ユーザレベルの接続管理 を参照のこと)。
operator+=
を含めた場合、
次に複数スロットをサポートする +
演算子を付け足すことも素朴な追加だろう。
この後にシグナルへの代入が続く。
だが、
これは任意の二つの関数オブジェクトを受理可能な +
の実装を必要とし、
技術的に実行不可能である。
trackable
の論拠 trackable
クラスは自動的な接続寿命管理に関する主要なユーザインターフェースであり、
その設計はユーザに直接的に影響を及ぼす。
二つの点がもっとも目立っている:
それは trackable
をコピーする際の奇妙な振る舞いと、
そして自動切断管理に関係する型を作成するには trackable
から派生することを要求するという制約である。
trackable
コピー時の振る舞い trackable
のコピー時の振る舞いは、
本質的に trackable
部分オブジェクトは決してコピーされないということである;
コピー操作はほとんど何も行わない。
これを理解するためにシグナル・スロット接続の性質を調べ、
接続が接続状態にある実体を基礎としていることに注目しよう;
実体が破棄されると接続も破棄される。
したがって trackable
部分オブジェクトがコピーされると、
接続をコピーすることが不可能になる。
なぜなら接続は目標となる実体を参照しているのではなく、
その源となる実体を参照しているからだ。
この理由はシグナルがコピー不可能である理由と対をなしている:
シグナルに接続されたスロットは特定のシグナルに接続されているのであって、
シグナル中のデータに接続されているのではない。
trackable
から派生させる理由 trackable
た正しく働くために二つの制約が存在する:
trackable
は、
このオブジェクトに対してなされた全接続を追跡する記憶域を持つ必要がある。trackable
は、
オブジェクトが破棄されるとき、
その接続を切断するために通告を受ける必要がある。trackable
から派生させることはこれらの二つの指針を満足する。
我々は、これに勝る解決策を発見していない。
libsigc++ は、 当初は C++ で GTK の C 言語インターフェースをラップしようという提唱の一部として開始され、 Karl Nelson によって保守される別個のライブラリに成長した C++ のシグナル・スロットライブラリである。 libsigc++ と Boost.Signals には多くの類似点があり、 実際のところ Boost.Signals は Karl Nelson と libsigc++ に強く影響されている。 それぞれのライブラリを大雑把に調査すると、 シグナル構築や接続の利用法、 自動的な接続寿命管理に関して類似した構文を見つけるだろう。 これらのライブラリを区別する、設計上の大きな差異もいくつか存在する。
Microsoft は .NET フレームワークと、 それに関連した一連の言語、言語拡張を登場させたが、 そのうちの一つがデリゲートである。 デリゲートはシグナルとスロットに類似しているが、 ほとんどの C++ のシグナル・スロットの実装と比較して限定されたものとなっている。 デリゲートは
this
によってメソッドを呼び出さねばならない。