2.4 動的な多相性と静的な多相性
「2.1.3 オブジェクト指向プログラミングの概要」では,「多相性というのは,同じ名前の同じ形式の操作が,異なる振る舞いをすることです」と解説しました.ここでは,C++を用いた多相性の実現方法についてより詳しく解説していきます.
2.4.1 仮想関数と多相オブジェクト
「2.1.3 オブジェクト指向プログラミングの概要」では,「特に断ることなく多相性といえば,動的な多相性を指すことが多いようです」とも述べました.動的な多相性というのは,実際にどんな振る舞いをするかが実行時に決定されることを意味しています.
これまで何度か例として取り上げたFILE型を用いたストリームの操作にしても,実際にどこに出力するのか,すなわち,ハードディスクに対してなのか,フラッシュメモリに対してなのか,シリアルポートに対してなのか,画面に対してなのかは,実行時に解決されます.出力先によって当然処理が変わりますから,振る舞いが動的に決定されるわけです.
FILE型の場合には,どんな方法で実行時に振る舞いを変えるかは,ライブラリの中に隠蔽されてしまっており,実装によって実現方法もさまざまです.しかし,そのつど,実現方法を考えなければならないのは効率が悪いので,言語仕様で動的な多相性をサポートできるのであればそれに越したことはありません.
C++の仮想関数は,まさしく動的な多相性を実現するための言語仕様です.実際,C++では,仮想関数が1つでもあるクラスのことを「多相クラス」,多相クラスのオブジェクトのことを「多相オブジェクト」といいます.
実例として,ログを出力するためのクラスを考えてみましょう.
次のコードを見てください.
class log
{
public:
void output(const char *message)
{
while (*message != '\0')
{
putc(*message++);
}
}
private:
virtual void putc(char c) = 0;
};
まずは,ログの出力先に依存しない基底クラスとしてlogクラスを定義しました.logクラスは,outputという文字列を出力するメンバー関数と,出力対象に対して1文字を出力するputcという純粋仮想関数を持つ抽象クラスです.これだけでは,何の役にも立ちませんので,logクラスを継承して,シリアルポートにログを出力するためのlog_serialというクラスを定義してみましょう.
class log_serial : public log
{
private:
virtual void putc(char c)
{
// シリアルポートへの出力処理
}
};
実際のシリアルポートへの出力処理は環境に依存するのでここでは割愛しますが,上記のように仮想関数putcを上書きしてやれば,出力対象に応じた処理を書き分けられることが理解できるはずです.ここではシリアルポートへの出力用のクラスを定義しましたが,ハードディスクに書き込むためのクラスも,画面に出力するためのクラスも,デバッガのコンソールに出力するためのクラスも,すべて同じようにして定義することができます.
そして,これらのクラスを利用するには,次のように,つねに基底クラスへの参照かポインタを使って操作を行うようにすれば,実際の出力対象が何であるかを気にすることなく,アプリケーションでログ機能を使うことができるようになります.
void func(log& l)
{
l.output("func関数に入りました");
…
l.output("func関数から抜けます");
}
int main()
{
log_serial logobj;
func(logobj);
return 0;
}
上記の例では,func関数の立場からすれば出力対象を動的に切り替えることができるようになっていますが,main関数の中でlog_serialを決め打ちで定義しているため,アプリケーション全体からすれば必ずしも動的に切り替えられるわけではありません.本当の意味で動的に切り替えるには次のようにしなければなりません.
int main()
{
int target;
… // 何らかの方法で変数targetに出力対象を設定
log* logptr = 0;
switch (target)
{
case SERIAL:
logptr = new log_serial; < 動的に多相オブジェクトを生成する
break;
case DEBUGGER:
logptr = new log_debugger;
break;
…
}
func(*logptr);
…
}
このようにすることで,本当の意味での動的な切り替えが実現できるようになります.ただ,mallocが避けられるのと同様の理由で,組込み開発ではnewを避けることも多いでしょうから,その意味では次のようにしてもよいかもしれません.
int main()
{
int target;
… // 何らかの方法で変数targetに出力対象を設定
log* logptr = 0;
// 切り替え可能な種類の数だけあらかじめ用意しておく
log_serial logobj_serial;
log_debugger logobj_debugger;
…
switch (target)
{
case SERIAL:
logptr = &logobj_serial;
break;
case DEBUGGER:
logptr = &logobj_debugger;
break;
…
}
func(*logptr);
…
}
この方法であれば,若干メモリ消費量は増える可能性はありますが*14,newを使わなくても動的に切り替えることができるようになります.
2.4.2 テンプレートによる静的な多相性
先ほどは,仮想関数を用いた動的な多相性の実現方法について解説しました.確かに,オブジェクトの動作を抽象化し,具体的な振る舞いを切り替えられるようにすることには意味がありますが,多くの場合,動的に切り替えなければならないことはあまりありません.特に組込み開発ではその傾向が顕著ではないでしょうか? ここでは,ソースコード上での抽象的な記述を可能にしたまま,コンパイル時に振る舞いを決定することでオーバーヘッドを最小限に抑え,実行時に実際にどのように振る舞うかの予測を容易にする方法として,静的な多相性の実現方法について解説します.
静的な多相性については,すでに「2.1.3 オブジェクト指向プログラミングの概要」でも触れたように,関数の多重定義やテンプレートを利用します.多重定義に関してはあえて説明するまでもありませんので,ここではテンプレートを用いた静的な多相性に絞ってお話しすることにしましょう.
前項のlogクラスですが,何らかの方法で仮想関数putcの振る舞いを変更できれば多相性を実現することができます.Cに慣れた方であれば,コールバック関数を使う方法を考えるでしょうが,それでは動的な多相性は実現できても静的な多相性を実現することはできません.
class log
{
public:
explicit log(void (*putc)(char)) // ← これでは動的な多相性と変わらない
: p_putc(putc)
{
}
…
private:
void (*p_putc)(char);
};
あるいは,マクロを使って,putcの定義内容を切り替える方法もあるでしょう.しかし,その場合には,複数のログオブジェクトで出力対象を変更することができなくなってしまいます.
#define putc(c) putc_serial(c)
class log
{
public:
…
};
テンプレートを使えば,次のようにlogクラステンプレートを定義することができます.
template <class Putter>
class log
{
public:
void output(const char* message)
{
while (*message != '\0')
{
Putter::putc(*message);
}
}
};
そして,次のように使うことができます.
template <class Putter>
void func(log<Putter>& l)
{
l.output("func関数に入りました");
…
l.output("func関数から抜けます");
}
class serial_putter
{
public:
static void putc(char c)
{
// シリアルポートへの出力処理
}
};
int main()
{
log<serial_putter> logobj;
func(logobj);
return 0;
}
上記の例では,func関数テンプレートは出力対象が何であるかを意識しなくても済むように抽象化されています.どこに出力するかは,実引数であるlogobjの型によって決定されます.そして,それはコンパイル時に決定されます.logオブジェクトを複数作った場合でも,次のように,問題なく利用することができます.
int main()
{
log<serial_putter> logobj1;
func(logobj1);
log<debugger_putter> logobj2;
func(logobj2);
return 0;
}
ただし,この方法にも1つ欠点があります.上記の例でいうと,最初のfuncと2番目のfuncは,同じ関数テンプレートを利用していますが,別々の実体(関数のコード)が展開されてしまいます.そのため,数多くの種類を同時に利用する場合には,コードがどうしても肥大化しがちです.ただ,実際には,選択可能な種類は豊富にあったとしても,同時に利用する種類は少数であることが多いので,それほど気にする必要はないのかもしれません.
2.4.3 テンプレートを使った排他制御
組込み開発では,排他制御を行う機会も多くあります.排他制御の方法は1種類ではなく,割り込み禁止のほか,OSがある場合には,セマフォやミューテックスなど,いくつかの手法の中から適したものを選択する必要があります.アプリケーション開発では,ここはこの方法で排他制御を行うべきということを決定できても,ライブラリの設計時にはどんな手法で排他制御を行うべきか決定できないこともあります.こうした場合にも多相性を利用するとよいでしょう.
しかし,排他制御のために動的な多相性を使うのはオーバーヘッドが大きすぎます.場合によっては,「排他制御不要」という“手法”を選択することもあるのですから.こんなときにも,テンプレートを使った静的な多相性が役に立ちます.
template <class Mechanism>
class lock
{
public:
lock()
{
Mechanism::lock();
}
~lock()
{
Mechanism::unlock();
}
};
template <class Mechanism>
void func()
{
lock<Mechanism> lockobj;
… // クリティカルセクション
}
このようにすることで,funcクラステンプレートは排他制御の手法をアプリケーション任せにし,しかも,まったくオーバーヘッドを伴わない実装が可能になります.なお,func関数テンプレートは実引数を頼りにテンプレートを解決することができませんので,明示的にfunc
この方法であれば,排他制御不要の場合でも,次のようにすれば,not_lockmechanismクラスのlockおよびunlockはインライン置換されることが期待できるので,不要なコードはほぼ発生しないと考えられます.
class not_lock_mechanism
{
public:
static void lock() {} // ← インライン
static void unlock() {} // ← インライン
};
int main()
{
func<not_lock_mechanism>();
return 0;
}
2.4.4 ファンクタを用いたスマートなコールバック
logクラステンプレートやlockクラステンプレートのような方法は,かなり複雑なクラスにでも適用できます.しかし,単に1文字を出力するだけのような単純な処理をカスタマイズしたいだけであれば,やや大げさかもしれません.そこで,Cでもおなじみのコールバック関数をC++風にする方法を紹介することにしましょう.
1文字出力するだけの処理をカスタマイズしたいのであれば,次のように,実際に処理を行う関数の引数などを用いて,カスタマイズしたい関数へのポインタを渡すのが普通です.
void func(void (*putc)(char))
{
…
(*putc)(c);
…
}
しかし,この方法にも欠点があります.putcに固定の引数を与えたい場合,たとえばfputcのようにFILE型へのポインタが必要な場合などは,それらの引数も別途funcに渡す必要があります.しかし,どんな引数が必要かはコールバック関数ごとに変わる可能性があるので,なかなかうまくいかないことがあります.また,コールバック関数はインライン置換されることがありませんので,何度も呼び出される場合には,どうしてもオーバーヘッドが大きくなります.具体的には,クイックソートを行うqsort関数に渡す比較関数などがそうです.どんなに高速なアルゴリズムを用いても,これでは比較関数の呼び出しオーバーヘッドが全体の足を引っ張ってしまいます.
こうした問題を解決するのが「ファンクタ」です.
まずは,実際のコードをご覧ください.
class putter_serial
{
public:
explicit putter_serial(int ch)
: channel(ch)
{
}
void operator()(char c) const
{
// シリアルポートへの出力処理
}
private:
int channel;
};
template <class Putter>
void func(Putter putter)
{
…
putter(c);
…
}
putter_serialクラスは,コンストラクタでシリアルポートのチャンネルを受け取ります.もちろん,チャンネル以外の情報を渡すことも可能です.そして,関数呼び出し演算子を多重定義しています.そのため,次のように,putter_serial型のオブジェクトに括弧を付けて引数を与えれば,関数と同じように使うことができるのです.
putter_serial putter(1);
putter('A');
チャンネル情報はオブジェクトの中に抱えていますから,func関数には別途渡す必要がありません.しかも,この多重定義された関数呼び出し演算子は,クラス定義の中に直接記述されているので,インライン関数として扱われます.このように,関数呼び出し演算子を多重定義したクラスのオブジェクトのことを「ファンクタ」(Functor)または「関数オブジェクト」といいます.ファンクタは,「スマート関数」ともいうべき“気の利いた”関数として扱うことができるのです.このへんのことについては,「2.2.9 演算子の多重定義」でも簡単に触れました.
もう1つ,ファンクタを受け取る関数の場合の利点を紹介しましょう.先ほどのfunc関数テンプレートですが,putterとして渡すことができるのはファンクタだけではありません.ごく普通の関数へのポインタも渡すことができます.たとえば,次のようにです.この場合,テンプレート引数Putterの型はvoid (*)(char)型になるわけです.
void putc_debugger(char c);
int main()
{
func(&putc_debugger);
return 0;
}
こうした関数テンプレートを使うことも,静的な多相性を実現するうえでは重要なテクニックです.C++の標準ライブラリの中でも,こうしたファンクタを受け取る関数は多数提供されています.C++に慣れないうちは,かなりトリッキーなテクニックに見えるかもしれませんが,これらはすでに確立された一般的なテクニックなのです.