コールバック (情報工学)

同期的なコールバック方式では、ある関数の引数として渡されたコールバック関数は、その関数内でのみ使われ、関数が終了した後は使われない。
非同期的なコールバック方式では、最初にコールバック関数を登録し、後で必要になったときに呼び出す。

コールバック: callback)とは、コンピュータプログラミングにおいて、あるサブルーチン(関数など)を呼び出す際に別のサブルーチンを途中で実行するよう指定する手法のこと[1]。呼び出し側(caller)が事前に用意・登録したサブルーチンを、呼び出し先(callee)のコードが「呼び出し返す」ように動作することから、電話回線におけるコールバックcallback)のアナロジーとして命名された手法である。これにより、下位レベル(フレームワーク)の抽象化層が上位レベルの層(アプリケーション)で定義されたサブルーチン(関数など)を呼び出せるようになる。このとき、他の関数の引数として渡される関数は、コールバック関数callback function)と呼ばれる。関数が第一級オブジェクトである言語において、コールバック関数を引数として受け取る関数は高階関数である。

一般に、まず上位レベルのコードが下位レベルのコードにある関数fnを呼び出すときに、別の関数fcへのポインタ参照を引数として渡す。このとき渡されるのは、言語によっては関数オブジェクトデリゲートクロージャなどの形でデータとサブルーチンをカプセル化したものである場合もある。コールバック関数のよくある使い方のひとつは、下位レベルの関数fnを実行中に、引数として渡されたコールバック関数fcを所望の回数呼び出して、部分タスクを実行するというものである。関数fnを抜けた後は、そのコールバック関数fcはもはや使われることはない。別の方式では、下位レベル関数は渡されたコールバック関数を「ハンドラ」(handler)として登録(メモリ上のどこかに保存)し、下位レベルの層で非同期的に(何らかのシグナルやイベントに対する反応の一部として)後で呼び出すのに使う。コールバック関数を一度登録すると、登録解除するまで何度も使われる可能性がある。

コールバックは、ポリモーフィズムジェネリックプログラミングの単純化された代替手法であり、ある関数の正確な(実際の)動作は、その下位レベル関数に渡される関数ポインタ(ハンドラ)によって変わってくる。これは、コード再利用の非常に強力な技法と言える。構造や作法がよく似ているプログラムであれば、共通部分をフレームワークによって記述してしまい、フレームワークが用意したカスタマイズポイントに適合するコードのみをアプリケーション側でコールバック関数として記述するだけで済むので、アプリケーションごとにすべてのコードを最初から最後まで書き下す必要がなくなる。

背景

[編集]

コールバックを使う意義を理解するため、連結リスト上の各要素に対して様々な処理を行うという問題を考える。ひとつの手法として、リスト上でのイテレータで各オブジェクトについて処理をするという方法がある。これは実際、最も一般的な手法だが、理想的な方法というわけではない。イテレータを制御するコード(例えば for 文)はリストを辿る処理が存在すると、その度に複製が必要となる。さらに、リストの更新が非同期プロセスで行われている場合、イテレータでリストを辿っている間に要素を飛ばしてしまったり、リストを辿れなくなったりする可能性がある。

代替手法として、新たにライブラリ関数を作り、適当な同期を施して必要な処理を行うようにする。この手法でもリストを辿る必要が生じる度に同様の関数を呼び出す必要がある。この方式は様々なアプリケーションで使われる汎用ライブラリにはふさわしくない。ライブラリ開発ではあらゆるアプリケーションのニーズを予測することはできないし、アプリケーション開発ではライブラリの実装の詳細を知る必要がないのが望ましい。

コールバックが、この問題の解決策となる。リストを辿るプロシージャを書くとき、そのプロシージャがアプリケーションが各要素についての処理を行うコードを提供するようにする。これにより、柔軟性を損なわずに明確にライブラリとアプリケーションを区別することができる。

コールバックは実行時束縛の一種と見ることもできる。

静的型付け言語の場合は、コールバック関数のシグネチャ(引数の数およびデータ型の順序)や戻り値の型といった呼び出しインターフェイスがコンパイル時に確定する。渡せる関数の名前は不問だが、この呼び出しインターフェイスに静的に適合するコールバック関数のみを渡すことができる。動的型付け言語の場合は、コールバック関数の引数の数のみが一致していればよい。

[編集]

以下のC言語コードは、配列を検索して 5 より大きい値を持つ最初の要素を探す処理を行うものである。まず、イテレータを使った直接的なコードを示す。

#include <stdio.h>  static void find(const int array[], int length) {     int i;     for (i = 0; i < length; ++i) {         if (array[i] > 5) {             break;         }     }      if (i < length) {         printf("Item at index %d\n", i);     } else {         printf("Not found.\n");     } }  int main(void) {     int array[] = { 5, -6, 1, 8, 10 };     find(array, (int)(sizeof(array) / sizeof(*array)));     return 0; } 

次に、コールバックを使った間接的なコードを示す。

/* ライブラリヘッダー (library.h) */ #ifndef MY_LIBRARY_HEADER_ALREADY_INCLUDED #define MY_LIBRARY_HEADER_ALREADY_INCLUDED  typedef int TraverseCallbackFunctionType(int index, int item, void *param); /* ライブラリ関数のプロトタイプ宣言 */ extern int traverseWith(const int array[], int length, TraverseCallbackFunctionType *callback, void *param); #endif 
/* ライブラリコード (library.c) */ #include "library.h"  int traverseWith(const int array[], int length, TraverseCallbackFunctionType *callback, void *param) {     int exitCode = 0;     int i;     for (i = 0; i < length; ++i) {         exitCode = callback(i, array[i], param);         if (exitCode) {              break;         }     }     return exitCode; } 
/* アプリケーションコード (app.c) */ #include <stdio.h> #include "library.h"  /* コールバック関数の実装 */ static int compare(int index, int item, void *param) {     if (item > 5) {         *(int *)param = index;         return 1;     } else {         return 0;     } }  /* ライブラリ関数を呼び出す本体 */ static void find(const int array[], int length) {     int index;     int found;     found = traverseWith(array, length, compare, &index);     if (found) {         printf("Item at index %d\n", index);     } else {         printf("Not found.\n");     } }  int main(void) {     int array[] = { 5, -6, 1, 8, 10 };     find(array, (int)(sizeof(array) / sizeof(*array)));     return 0; } 

コールバック関数compareif文の条件を変更すれば、「5より大きい」以外の要素を検索するのにも使える。traverseWith関数には、コールバックが自身の目的のために受け取る追加の引数paramがある点に注意されたい。通常のコールバックでは、そのような引数をスコープ外のアプリケーションデータへのポインタに利用する。これは静的スコープ方式の言語(C や C++)でのみ必要とされる(ただし、C++ を含めたオブジェクト指向言語には別の解決策がある)。動的スコープの言語(関数型言語など)ではクロージャによって自動的にアプリケーションデータへのアクセスが可能となる。例として同じプログラムを LISP で書いた場合を示す。

 ; ライブラリコード  (defun traverseWith (array callback)    (let ((exitCode nil)          (i 0))      (while (and (not exitCode) (< i (length array)))        (setq exitCode (callback i (aref array i)))        (setq i (+ i 1)))      exitCode))    ; アプリケーションコード  (let (index found)    (setq found (traverseWith array (lambda (idx item)                                      (if (<= item 5) nil                                        (setq index idx)                                        t))))) 

この場合、コールバック関数は使う時点で定義されており、"index" を名前で参照している。これらの例では同期に関する考慮は省略されているが、traverseWith 関数を同期できるように対処するのは容易である。さらに重要なことは、同期するかしないかをその関数の修正だけで対処できる点である。

実装

[編集]

コールバックの形式はプログラミング言語によって異なる。

  • C言語C++では、他の関数に関数へのポインタを引数として渡すことができる。標準Cライブラリにて配列のソート処理を提供するqsort()はコールバックを利用した例である[注釈 1]
  • SchemeML といった関数型言語などでは、他の関数の引数としての関数へのポインタをより一般化したクロージャが利用可能である。
  • 動的型付け言語(インタプリタ指向のスクリプト言語など)では、関数 B に引数として関数 A の「名前」を渡すことができ、B が A を eval によって呼び出すことができる。
  • オブジェクト指向言語では、何らかの抽象インタフェースを実装したオブジェクトを使うことができ、実装の詳細を隠蔽できる。そのようなオブジェクトを使えば、アプリケーション固有のコードを独自に実装できる。これは一種のコールバックであると同時に、操作対象のデータが付属している。この考え方は、Visitor パターンObserver パターンStrategy パターンといった各種デザインパターンの実装に活用されている。
  • C++では、classまたはstructに、任意の引数リストを持つ独自の関数呼び出し演算子オーバーロードoperator()(...)を定義することができ、オブジェクトが関数呼び出し操作の独自の実装をすることができる。Standard Template Library はそういった(関数オブジェクトと呼ばれる)オブジェクトを受け付ける。関数呼び出し演算子が定義された型のインスタンスfは、関数型あるいは関数ポインタ型を使った場合とまったく同じ形f(...)で(あたかも関数指示子であるかのように)関数呼び出しを記述することができるため、テンプレートで統一的に扱うのに都合がよい。

特殊な例

[編集]

コールバック関数は、例外処理を実現する手段としてもよく使われ、状況によって副作用を伴う処理を可能としたり、何らかの処理途中の情報を収集するのに使われたりする。割り込みハンドラは、オペレーティングシステム (OS) でハードウェアの何らかの状況に対応するのに使われる。また、シグナルハンドラはアプリケーションが OS に登録し、OS が呼び出す。イベントハンドラは、プログラムが受信した非同期的な入力を処理する。

副作用のないコールバック関数を「純粋コールバック関数; pure callback function」と呼ぶ。場合によっては、純粋コールバック関数が必要とされることもある。

特殊なコールバックとして「述語コールバック; predicate callback」がある。これは純粋コールバック関数の一種で、引数は1つだけで、リターン値はブーリアン型である。これは、データの集まりからある条件に適合するものだけを選別するときに使われる。

イベント駆動型プログラミングでは、Observer パターン的な方式がよく使われ、マルチキャスト型のコールバックが可能となっている。この場合、コールバックは予め登録され、対応するイベントが発生したときに呼び出される。プログラミング言語やフレームワークによっては、この機構を直接サポートしている場合もある。例えば、.NET言語(C#VB.NET)のマルチキャストデリゲート[4][5]およびイベント[6][7]Qtの signal と slot などが挙げられる。

POSIXスレッド(Pthreads)やWindows APIでは、スレッドを起動する関数において、そのスレッドのエントリーポイントとなる関数(スレッド関数)へのポインタを渡す[8][9]。このスレッド関数もコールバック関数の一種である。メインスレッドのエントリーポイントはmain関数であるが、これもアプリケーションコードによって明示的に呼び出すものではなく、ランタイムライブラリから暗黙的に呼び出されるという意味で広義のコールバック関数の一種とみなすことができる。非同期のコールバック関数は、登録用の関数を呼び出したスレッド上で実行されることもあれば、別のスレッド上で実行されることもある。

問題点

[編集]

コールバック方式は、サブルーチン(関数)を直接呼び出すのではなく、別のサブルーチンの中で間接的に呼び出すため、プログラムの構造が複雑・不明瞭になりがちである。特に入門者にとっては、直接的・具体的なプログラムよりも間接的・抽象的なプログラムは理解が難しい。また、コールバック関数を呼び出すフレームワーク側のソースコードが公開されていない場合は、デバッガーでステップインすることができないので、デバッグが困難になることもある(コールバック関数内にブレークポイントを置くことはできるが、コールバック関数を実際に呼び出す部分はブラックボックスのため、実行時の追跡がいったん分断されることになる)。

コールバック関数が満たすべき要件に正しく適合しない関数を渡すこともできてしまうため、問題が発生しやすくなることもある。例えばコールバック関数の呼び出し元が例外の発生を想定していないのにコールバック関数の中で例外をスローしてアプリケーションをクラッシュさせてしまったり、できる限り速やかに応答を返さなければならないコールバック関数の中で長時間かかる処理を実行してアプリケーションをハングアップさせてしまったり、といった問題が容易に発生しうるが、コールバックによる間接的な呼び出し構造となっている場合は、問題の原因がどこにあるのかということに気づきにくくなる。このような問題を回避するために、コールバック関数が満たすべき要件について文書化が必要となる。

また、入出力や通信などの所要時間が予測できない処理を実行する場合、特にJavaScriptでは非同期処理が必須となるが、非同期処理の結果通知をコールバックで受けて、さらに別の非同期処理を実行する、といった形でネストしていくと、プログラム構造が非常に複雑で分かりにくいスパゲティコードと化してしまう。このような状況をコールバック地獄callback hell)と呼ぶこともある[10]。この問題に関しては、FutureやPromiseをサポートするライブラリや、async/await構文といった解決策も考案されている[11]

ループ内でコールバック関数を繰り返し呼び出すと、関数呼び出しのオーバーヘッドが蓄積して、場合によっては無視できないほどの速度差が生じることもある。汎用性や再利用性よりも速度が重視されるケースでは、コールバック関数ではなくループを直接記述したほうがよい場合もある。C++のアルゴリズム関数テンプレートでは、述語オブジェクトに関数ポインタよりも関数オブジェクトを渡すことで、コンパイル時にインライン展開される可能性が高くなるなどの理由があるため、C由来のstd::qsort()よりもstd::sort()関数テンプレートのほうが好まれる[12]

脚注

[編集]

注釈

[編集]
  1. ^ クイックソートで実装されることが多いため、qsort()という名前になっているが、C/C++の標準規格ではアルゴリズムや計算量などに関して何も規定や保証をしていない[2][3]

出典

[編集]

外部リンク

[編集]

関連項目

[編集]