Janus(ヤヌス)の紹介 -- stdin/stdoutからの入門

檜山正幸 (HIYAMA Masayuki)
Thu Jan 20 2005:start
Thu Jan 20 2005:prefinal

Janus(ヤヌス)コンポネント・アーキテクチャの導入的な解説をする。 Janusの起源はUnix流のパイプ&フィルターだから、パイプ&フィルターと、 それらに入出力ポートを与えているstdin/stdoutを説明の素材とする。

目次

1. はじめに

「このサイトについて」の記事 「コンポネント・アーキテクチャ」の最後のほうで、Janus(ヤヌス)という名 のコンポネント・アーキテクチャについて予告した。そのサワリを この記事で紹介したい。

Janusについて一番強く言いたいことは、それが、Unix流のパイプ&フィルター の直接の拡張になっていることだ。「直接の」と形容詞を付けているのは、パ イプ&フィルターをJanusのなかに忠実に再現することができるからだ。それ に、パイプ&フィルターは、僕がJanusを考えた動機(因縁というべきか)で もあるし、指導原理であり続けたし、精神的な支えでさえあった。

という事情でこの記事では、パイプ&フィルターを出発点にして、Janusの概 念を(少しだけ)解説する。

2. パイプ&フィルター、あるいはロスト・パラダイス

Unix(*注1)環境は、テキスト処理にはとても便利だ。テキスト(キャラクタ 列)を処理する小道具がいろいろと備わっている。それらの道具達(tools) の多くはフィルターとして実装されている。フィルターは、標準入力(stdin) からテキストのストリームを読んで、標準出力(stdout)に処理結果を書き出 すプログラムだ。

注1

単に「Unix」と書いたら、元祖Unixを起源とするOS一般のことだとする。

個々のフィルターの機能は単純でも、それをパイプで繋ぐと驚くような処理 ができる。例えば、次のようなフィルター達を考えよう。

  1. word : ホワイトスペースを改行に置き換えることにより、テキストを語 (word)に区切る(MS Wordじゃないよ)。出力は、1語が1行になったテ キスト。
  2. tolower : 英字の大文字を小文字(lower case)に変換する。英大文字 以外には何もしない。
  3. sort : 行単位のソート。入力行をアルファベット順に整列して出力とする。
  4. uniq : 同じ行が続いたときに、それを1行にする。-c オプション を付けると、同じ行が何行あったか(整数値)を行末に付加する。

さて、file.txtが英文のテキストだとする(残念ながら、日本語だとwordが うまく機能しない)。次のパイプライン(*注2)により、file.txtのなかに含ま れる語(英単語)(*注3)の出現回数一覧表ができてしまう。

  cat file.txt | word | tolower | sort | uniq -c > words.list
注2

cat file.txt を使う代わりに word < file.txt でもいいのだが、リダイレ クト記号「<」は、見た目がパイプラインの向きと逆行するので、僕はどうも 嫌いだ。パイプラインの左端にファイル名を書いたら入力として解釈される仕 様ならいいのに、と思う。

注3

ほんとは英単語じゃなくて、単なるトークンだけどね。

単機能のフィルター達のセットは、パイプラインで組み合わせ、シェルスク リプトで制御すれば、強力で柔軟な道具箱となる。フィルターは、使って便利 だし、それを作るのもとても楽チンだ。つまり、「使って天国、作って天国」とい うパラダイスだった。

だが、このパラダイスは失われてしまった。いやっ、今も確かに存在はして いるのだが、その領地は縮小し、存在感は薄くなった。ユーザーインターフェー スはキャラクタ・ベースからGUIになり、コマンドラインの使用者は相対的に 減っている。GUIのなかでパイプ&フィルターの出番はない。

3. パラダイスよ再び、だが足もとから

前節の最後で「GUIのなかでパイプ&フィルターの出番はない」と書いたが、 ほんとうだろうか? 確かに、オリジナルのパイプ&フィルターは、活躍の舞 台を狭められている。窮地に活路を見いだすために、まずはここで、オリジナ ルのパイプ&フィルターの性質を列挙してみよう。

  1. 入出力されるデータは、テキスト(キャラクタ列)ストリームである。
  2. ユーザーとの対話は、キーボートからの入力と端末画面への出力である。
  3. フィルターとフィルターは、パイプにより1次元的に結合される。

パイプ&フィルターに入出力されるデータの原子(基本単位)はキャラクタ だが、これを一般のイベントなども許すように拡張はできるだろう。そうすれ ば、ユーザーのマウスイベントなども入力として扱える。出力として、描画要 求などを発行できれば、グラフィカルなレンダリングを引き起こすこともでき る。つまり、GUIに適合することが不可能とは思えない。

パイプは、1本の出力ポートを1本の入力ポートに繋ぐだけだが、なにもポー トを1本に限定することはない。多数のポートを持つ実体をうまく管理制御 できるなら、2次元的(あるいはより多次元的)な繋ぎ方もサポートできるは ずだ。ポートの型(タイプ)もテキスト(キャラクタ)型に限定せずに、最近 のプログラミング言語のように整備された型システムを備えてもいいだろう。

Janusは、「テキスト(キャラクタ)型→より一般の型」「1本のポート→任 意本数のポートセット」のような拡張を行う試みである。だが、安直に拡張し ようと思っても、そうそうウマクいくものではない。パイプ&フィルターのメ カニズムを根本から反省しないと、拡張もままならないのである。そこで次節か ら、足もとを固める意味で、パイプ&フィルターについて、もっと調べてみよ う。

4. いちばん簡単なフィルター

この世でいちばん簡単なフィルターは次のようなものだろう(Hello, world じゃないよ)。

int c;
while ((c=getchar()) != EOF) {
  putchar(c);
}

標準入力をそのまま標準出力に書き出すだけだ。だがそれでも、「ファイル の表示」、「キーボードからの入力をファイルに落とす」、「ファイルのコピー」 の目的でちゃんと使える。このプログラムをinoutという名で呼んで、以下で 例に使う。

さて、Cのプログラムとライブラリの知識が少しある方ならば、getchar()が getc(stdin)のことであり、putchar()はputc(c, stdout)のことなのはご存じ だろう(*注4)。stdinとstdoutをオブジェクト(Javaでいえば、ReaderとWriter)の ように思って、getc(stdin)をstdin.getc()、putc(c, stdout)を stdout.putc(c)と書くことにしよう。

注4

「ご存じだろう」とか言っているこのワタシがゴゾンジじゃなかったりする。 putc(c, stdout)かputc(stdout, c)か、どうしても思い出せなかった。

ファイルの終わりではEOF(実際は-1)を返すという仕様はどうも気に入らな いので、iseof()(is end of file)というメソッドを導入する。Java風のイ ンターフェース構文を使えば、stdinとstdoutの仕様は次のように書ける (*注5)。つまり、オブジェクトとしてのstdin/stdoutは、これらの仕様を満た して(インターフェースをimplementsして)いる。

interface TextInput {
 boolean iseof();
 char getc() throws TextInputException;
}

interface TextOutput {
 void putc(char c) throws TextOutputException;
}
注5

CのAPIでは、文字を表すためにintを使う。これは、EOFやエラーの判定のた めだ。判定メソッドiseofや例外を使えば、戻り値に変な情報を詰め込む必要 はないだろう。

古典的なC風ネーミングとJava風の概念(例外など)がごちゃ混ぜになってい て、はなはだ不格好だが、話のなりゆき上、これでかんべんしてほしい。まー、 ともかく、stdinはTextInput型であり、stdoutはTextOutput型ということにな る。そして、TextInput型、TextOutput型は、インターフェースとして定義さ れていることが重要である。

念のため、例として挙げたinoutを、いま導入した記法で書き換えておくと、 次のようになる(例外はハンドルしてない、気になる人はtry/catchで囲んで ね)。

while (!stdin.iseof()) {
  char c = stdin.getc();
  stdout.putc(c);
}

5. フィルターのプロファイル

前節で導入したようなフィルターに対して、プログラム内で使っているAPIを 明示的に宣言したい。次のような記述を使うことにしよう。

profile StdFilter {
 input:
   use TextInput;
 output:
   use TextOutput;
}

この記述の内容は当たり前すぎるが、次のことを主張している。

  1. データの入力には、TextInputという名前のインターフェースを使用する。
  2. データの出力には、TextOutputという名前のインターフェースを使用する。

つまり、TextInputとTextOutputをIOに使うようなフィルターは、StdFilter という名前で分類されることになる。上の"profile"からはじまる記述は、その ような分類指標の宣言であり、その分類指標を‘プロファイル’と呼ぶわけだ (この言葉は憶えておいてね)。この宣言でStdFilterという名のプロファイ ルが定義される。そして、先のプログラムinoutはStdFilterの実例(*注6)で ある。実は、Unix流のフィルターはすべてStdFilterになっていると言ってよ い。 となると、スタンダード(Std)でないフィルターもあるのか、と疑問を感じ るだろう。答えは「ある」だが、その話の前に、パイプの機能に触れておかな くてはならない。

注6

「クラスのインスタンス」とか言いたくなるかもしれないが、やめたほうがいい。 プロファイルはクラスとは異なる概念だ。そもそも、Janusアーキテクチャで は、オブジェクト指向のクラス概念はほとんど出てこない。実装手段としてク ラスがあれば便利、という程度だ。

6. パイプは何をしているのか

Unixでは、フィルターを繋ぐ構文には、パイプ記号(縦棒)を使う。このパ イプ記号に対応するソフトウェア的な実体はあるのだろうか -- 実はあるんですね。 パイプの実体は、キャラクタを溜めるバッファ(正確には、キューまたはFIFO) だ。このバッファは、プロセス(あるいはスレッド)制御もしなくてはならない。

オブジェクトとしてのパイプの実装は、だいたい次のような感じだろう(正 確には知らないけど)。

・ パイプに書き出すメソッドputc(c)

  1. バッファに余裕があれば、バッファにキャラクタを置いて戻る。
  2. バッファが一杯のときは、putcを呼び出したプロセスをそのまま待ち状態 にする。

・ パイプから読み込むメソッドgetc()

  1. バッファにキャラクタがあれば、それを取り出して返す。
  2. バッファが空のときは、getcを呼び出したプロセスをそのまま待ち状態に する。

Unixでは、パイプ記号の処理はシェルとOS本体が担当している。ユーザープ ログラムとしてパイプを書く必要はないが、絶対に書けないわけでもないだろ う。仮に、ユーザーがパイプのようなバッファ・オブジェクトを書いたとして、 そのプロファイルはどうなるだろうか? 答えを言ってしまえば、次のように なる。

profile StdPipe {
 input:
   provide TextOutput;
 output:
   provide TextInput;
}

ちょっと分かりにくいでしょうね。input側にTextOutputが書いてあって、 output側にTextInputがあるし、provideってキーワードが出てくるし…… ハ イッ、説明しましょう。

まず、キーワードprovideから。これは、useと対になっている。useは、ある プログラムが利用するAPIを宣言している。つまり、「プログラム内ではこれこ れのAPIを使う予定だ」(実際に使わなくてもいい)と明言しているのがuseなの だ。一方で、provideは、あるプログラム(カーネルやライブラリも含める) が提供しているAPIの宣言である。だから、provideで宣言されたインターフェー スは、そのプログラム内で実装しなくてはならない。言葉を換えれば、useは “利用権限”を宣言し、provideは“実装義務”を宣言する。権限は保証され るべきだし、義務は履行されなくてはならない(*注7)

注7

権限と義務とかの言葉から、バートランド・メイヤーの契約(contract)の 概念を思い起こす人もいるだろう。ハイそのとおり、僕はメイヤーのファンな ので、契約概念はよく使う。

次に、input側にTextOutputが書いてある理由を説明しよう。とりあえずは次の 図を眺めてほしい。

FIG: 接続されたフィルターとパイプ

/* filter-pipe */

fというフィルターとgというフィルターの間に、パイプpがはさまっている。 「f | g」というパイプラインにおいて、縦棒の代わりにpを使えば、「f p g」 という3つのコンポネントを左から右に直列結合することになる。ただし、pは、 fやgのようなフィルターとは性格が異なっている。どう異なっているかという と、putcやgetcを利用するのではなくて提供(実装)しているのである。

さて、「f p g」のような左から右への直列結合において、左を入力側、右を 出力側と呼ぶことにする。そうすると、パイプpの入力側はフィルターfの出力 側とインターフェースすることになる -- おっと、「インターフェースする」 という変な日本語が分かりにくかったかもしれない。説明しよう: fは、イン ターフェースTextOutputのputcを使って出力するが、そのputc呼び出しを受け 止めているのはpなのだ。「fはputcの呼び側で、pはputcの呼ばれ側」、「fは putcの利用側、pはputcの実装/提供側」になる。メソッドputcを含む TextOutputという共通のインターフェースによって、fとpがこの順で接してい る。 このことを、「fとpは、TextOutputによりインターフェースしている」と表現 したい。

もう一度同じことを繰り返し言うと、パイプpの入力側(図の左側)は、putc の実装を要求されているのだ。それを宣言しているのが、次の部分である。

 input:
   provide TextOutput;

同様に、パイプpの出力側(図の右側)は、getc, iseofの実装を要求されて いる。よって、次のように宣言を書く。

 output:
   provide TextInput;

7. コールバック方式による入力

フィルターを書くときは、入力のためにgetcを繰り返し使う。つまりこれ は、Pull型の入力を行っていることになる。もし、XMLのSAXのようなPush型の 入力を使えばどうなるだろうか。言い換えると、コールバックとかハンドラー によって入力が処理される方式を考えてみるのだ。

仮に、文字の入力を通知するためのハンドラーをhandlec(c)としよう (handleCharacter(c)のほうが気持ちいいけど、古典的ネーミング・コンベンショ ンで話を始めてしまったのでねぇ)。handlec(c)は、フィルターの外部から呼 ばれる(外部から文字がpushされる)。

この前提で、inoutを書き換えると、handlecを実装するだけでOKだから、コー ドはもっと簡単になってしまう。

void handlec(char c) {
  stdout.putc(c);
}

新しく書き換えたinoutのプロファイルを考えてみる。 もちろん、StdFilterとは異なるプロファイルとなるはずだ。pushされるフィ ルターだから、PushFilterとでも名付ければ、そのプロファイル宣言は次のように なる。

profile PushFilter {
 input:
   provide TextHandler;
 output:
   use TextOutput;
}

入力のためには、handlecの実装義務を負うので、input側にprovide宣言文が来 る。出力には、StdFilterと同様に(コールバックではなくて) コールベースAPIを利用(use)しているので変化はない。

ところで、インターフェースTextHandlerの定義は明示してなかった。 handlec以外に、End Of Fileに相当するclosedというメソッドも入れておこう。 (openedのような、初期化のトリガーは不要としよう)。closedの引数には、 なんらかのステータスコードが入っているとする。

interface TextHandler {
  void handlec(char c) throws TextHandlerException;
  void closed(int code);
}

inoutでも、(もし継承のようなメカニズムが使えないなら)実際はclosedも 実装しなくてはならない(ほとんど、どうでもいいことだけど)。

void closed(int code) {
 /* do nothing */
}

8. 組み合わせ色々

今までに、TextInput、TextOutput、TextHandlerという3つのインターフェー スを示した。これらのインターフェースから作られるプロファイルはどのくらい あるだろうか。

TextInput、TextOutput、TextHandlerをそれぞれI、O、Hと略記することにす る。useとprovideというキーワードもu、pと書こう。すると、略記した記号は、 uI、pI、uO、pO、uH、pHという6つの語になる。いままでに出てきた、 StdFilter、StdPipe、PushFilterのプロファイルは、それぞれ、(input: uI, output: uO)、(input: pO, output: pI)、(input: pH, output: uO)となる。 可能な組み合わせは、6つの語から任意に2つ取って並べる方法の総数だから、 6×6=36通りになる。

では、可能な36の組み合わせすべてに意味があるだろうか。たとえば、 (input: uO, output: pH)なら、入力にputcを使い、出力のためにhandlecを実 装することになる。常識的に考えて、これは変である。無意味そうだ。実は、 “常識的に考える”のをやめれば、36の組み合わせすべてに意味を与えることがで きる。しかし、今この話をしてしまうとストーリーが発散しそうだから、とり あえずは、意味がありそうな組み合わせだけを考えることにしよう。

さて、プロファイル(input: X, output: Y)を持つコンポネント(一般化された フィルター)fと、プロファイル(input: S, output: T)を持つコンポネントgが、 この順で直列結合可能な条件を求めよう。どんなコンポネントでも繋げるわけ ではない。出力と入力がきちんと対応してないとデータ(あるいはイベント) の受け渡しができない。その「きちんと対応」とは、次のことである。

  1. fの出力とgの入力のインターフェースが一致している(同じインターフェー スである)。
  2. fが出力インターフェースをuseしているなら、gは同じインターフェース をprovideしている。逆に、fが出力インターフェースをprovideしている なら、gは同じインターフェースをuseしている。

たとえば、fがoutput: uOという出力プロファイルを持つなら、gはinput: pOと いう入力プロファイルを持たなくてはならない。 2つのStdFilterが、StdPipeをあいだにはさんで結合できたのは、フィルター 出力側のoutput: uOに対してパイプがinput: pOとなっていて、パイプの output: pIに対してフィルター入力側のinput: uIが対応していたからである (図を描くと分かりやすいよ、自分で描いてみよう)。

9. Janusの宣伝

ここまでに述べてきた事はと言うと、パイプ&フィルターの概念を分析し、 プロファイルという概念を導入しただけだ。だがそれでも、プロファイルは Janusのキー・コンセプトなので、この記事により、Janusの雰囲気が少しでも 伝わればうれしい。とはいえやはり、これだけでは「いったい、何のことやら?」 だろうとは思う。今後、いろんな角度から、Janusの詳細を説明していくつも りだ。

ここで、Janusがパイプ&フィルターをどのように拡張しているかをまとめて みよう。

  1. より多様な型の採用:キャラクタ列だけでなく、任意のデータや任意のイ ベントを扱えるようにする。
  2. コンポネント配置の多次元化: 複数のコンポネントの繋ぎ方を直線から、 2次元(あるいはより多次元)のネットワーク状にする。
  3. 対称性、極性の導入: 双方向通信を導入し、入力と出力の取り替えも自 由に行えるようにする。

最初の項目に出てくる“型”は、インターフェースのことである。より正確 にいえば、インターフェース(計算科学では、これをシグニチャと呼ぶ)に制 約条件を加えた“仕様”である。だから実は、型と仕様は同義語なのだ。 型/仕様に関しては、2004年6月の「JavaWorld DAY」で話したことがあり、 フォローページ(他サイト)も 作った。が、このサイトは、なんといまだに(Thu Jan 20 2005時点で)未完 成だ。もう少し書き足す気は今でも残っている。また、型/仕様に関する話題 はChimairaサイトでも展開するだろう。

コンポネント配置を多次元化するためには、従来のフィルターのように入出 力ポートが高々1本ずつでは足りない。任意の数のポートを許すべきだ。(そし て各ポートは、“仕様による型”で型付けされる。)また、コンポネント結合 としては、直列結合だけでなく並列結合も行いたい。こうなると、コンポネン トがつながった全体はけっこう複雑なものになる。なにか系統的な制御や計算 のメカニズムが欲しくなる -- それはもう(概念的には)存在している。この サイトの別の記事に出てきた図をまとめたページ がある。 このページをちょっと眺めてほしい(もとの記 事は読まなくてよい)。なにやら、箱やヒモがのたうっている。このような図 が系統的な制御や計算のメカニズムを与える。

ついでに「ETBダイヤグラム」という記事も眺めて ほしい(読まなくてよい)。僕が、箱(ボックス)とヒモ(ワイヤー)をいじ くり回すのに熱心なのは、多次元化されたパイプラインの配管作業の練習のた めだ。だが、その配管作業は、そんなに簡単なものではない。どこらへんが困 難かは記事「高次元のパイピング」に書い ておいた。

さて、最後の項目の「対称性、極性」は、現時点(Thu Jan 20 2005)ではま だどこにも書いていない(*注8)。対称性は、パイプ&フィルターが単方向の通信(情 報フロー)しかサポートしていなかったのを双方向化することだ。こうすると、 かえって話が単純になる(*注9)。それに、対話性が導入できる。もともと僕 は、対話的システムのためにJanusを構想したのだから、対話性がないとオ ハナシにならない

注8

Tue Jan 25 2005 追記:「Janus(ヤヌス)の紹介 2 -- フィルターからコンポネントへ」に書きました。

注9

んーと、まー、複雑になる点もあるんだけどね :-)

「対称性」と共に最後の項目に出現する「極性」(polarity)とは、「プラ スとマイナス」とか「NとS」のような、反対の性質を持つ二値量のことである。 とりあえずは、メソッドの呼び側と呼ばれ側をプラスとマイナスの符号で区別 することだと理解しておけばよい。そうすると、電気のプラスとマイナスのよ うに、二極のあいだの流れが発生したりする。 あるいは磁石のNとSで、同極どおしだと反発してくっつかないけど異極の対は 引き合ってくっつくのと似たような現象が生じる。

余談だが、「電気のプラスとマイナス」「磁石のNとS」といった話は、どう も単なる例え話ではないレベルでの類似性らしい。つまり、計算現象と物理現 象の構造的な同一性かもしれない。興味があれば、 「形式言語理論への疑問など」という記事の第1節とそこで引用している Mark William Hopkinsの記事をご覧あれ。 「JavaWorld DAY」フォローページ (他サイト)で、力学や幾何学を引き合いに出しているのも同様な事情が 背景にある -- 計算現象と物理現象は、おそらくは通底している。

話を戻そう。フィルターに対称性、極性を持ち込むことを、僕は「双面化」とでも呼びた い。つまり、今までのフィルターは顔が1つしかなくて、1つの方向性しか持て なかったが、2つの顔(双面)があれば、2方向を同時に相手できるだろう、っ てことだ。ご想像のとおり、これが「Janus(双面神)」と名付けた由来であ る(もっと詳しくは、記事「双面神Janus」を参照)。

ところで、「主要な話題」でも述べたように、僕の ターゲットはXMLである。XMLの(いや、XMLに限らないが)構文論は、形式言 語理論に依存している。構文とソフトウェアを並行に議論することが僕の希望 である。となれば、XML構文とJanusコンポネントに並行性(「平行性」と書い た方がいいのかな?)があるはずだが…… 大丈夫、あります。ただし、形式 言語理論のほうを再定式化しなくてはならない。これ(形式言語理論の再定式 化)についても、近いタイミングで、簡単な事例による説明を開始するつもり だ。形式言語理論に対する僕の疑問(つうか不満)は、 「形式言語理論への疑問など」に書いた。

Tue Jan 25 2005 追記:この記事の続きは、「Janus(ヤヌス)の紹介 2 -- フィルターからコンポネントへ」だ。