Ruby/KAKASI拡張モジュール


この記事は翔泳社の月刊誌『TransTECH』2000年3月号(pp 137-142)に掲載され出版社の了解を得て公開しているものです (残念ながら『TransTECH』は同年4月号で休刊)。 掲載にあたっては翔泳社の登尾さんにお世話になりました。

この原稿はRDで書き、 入稿はRDとRDtool(toshさん作)のrd2で生成したHTML、 それと w3m で整形したテキストファイルを 束ねて渡しました。 あなたがいま読んでいるこのHTMLファイルもrd2で生成したものです。 なお校正はこのファイルには反映しておらず入稿時のものです。

後藤謙太郎
(c) GOTO Kentaro 2000

拡張モジュールとは

RubyやPerl,Pythonといったスクリプト言語では,機能を拡張するためにCなどのコンパイラ言語で書かれたライブラリのことを拡張モジュール (extension module)といいます.わざわざ拡張モジュールを書く理由としては,既存ライブラリの利用や速度向上などが挙げられます.

筆者はRubyを愛用していますが,Rubyの拡張モジュール作成は容易です.詳しいやり方はRubyに同梱されているREADME.EXT.jpに書かれてるので,ここではより手っ取り早く入門することを目指し,本稿では実用的で小さな例としてKAKASIをRubyから使う拡張モジュールRuby/KAKASIを取り上げます.

KAKASIとは

KAKASIは漢字かな混じり文をひらがなやローマ字に変換することを目的として高橋裕信さんによって作成されたプログラムと辞書の総称であり,GPLで配布されています[KP].KAKASIの本体はkakasi(1)というコマンドです.

|% cat foo.txt
|窓の外を誰かが歩いている
|% kakasi -s -JH < foo.txt
|まど の そと を だれか が あるい ている
|% kakasi -s -U -Ja -Ha < foo.txt
|MADO no SOTO wo DAREKA ga ARUI teiru

なぜ拡張モジュールなのか

筆者がKAKASIをRubyから利用するにあたって当初はforkした kakasi(1)とパイプを使ってプロセス間通信するという方法を取ろうとしましたが,kakasi(1)が出力する長さは事前には分からないため,この方法ではかなり面倒なことに気づきました.kakasi(1)の内部に立ち入るほうがはるかに楽なので,拡張モジュールの作成を決めました.

幸い,最近のKAKASIを導入するとlibkakasi.aが同時にインストールされます.libkakasiのドキュメントREADME.libによれば,次の3つの関数が提供されています.

kakasi_getopt_argv()は文字列配列を渡すとkakasi(1)の引数として解析し,KAKASIを初期化します.kakasi_do()は渡された文字列を処理し,内部でmalloc()した文字列に代入してを返します(注意)kakasi_close_kanwadict()は辞書を閉じ,再度 kakasi_getopt_argv()で初期化できる状態にします.

RubyのGC

RubyもGC(garbage collection -- ゴミ集め)を採用しています.GCとは使われなくなった領域を自動的に解放する仕組みのことです.GCの対象にするにはオブジェクトを生成する際に領域を解放する関数をGCに教えてやらなければなりません.組み込みのクラスに関しては用意された生成用の関数がこの辺のことをやってくれるのですが,いずれにせよユーザが単にmalloc(3)しただけでは,使われなくなってもゴミとして回収してくれないのでユーザの責任で解放する必要があります.

libkakasi のkakasi_do()は内部でmalloc()して文字列を返すため,kakasi_do()の返すポインタが指す領域は,こちらでfree() してやらなければなりません.今回の作成において注意しなければならない点はこれだけです.

仕様

Kakasiというモジュールを設け,そのモジュール関数として, Kakasi::kakasi(opt, src)を作ることにしました. optはオプションをあらわす文字列で,srcはKAKASIに処理される文字列で,結果を値として返します.KAKASIの初期化等は必要に応じて内部で面倒みます.

ちなみにKakasiモジュールのユーザは次のようなコードを補うことで Kakasi::kakasiを1引数をとる文字列のメソッドにすることが出来ます.

|require "kakasi"
|
|class String
|  def kakasi(opt)
|    Kakasi::kakasi(opt,self)
|  end
|end

このように組み込みクラスにメソッドを追加できる動的な性質はRubyのうれしい点のひとつです.

実装と注意点

先にRubyの拡張モジュールの呼び出される仕組みについて説明しておきます.拡張モジュールはRubyで書かれたライブラリと同様にrequireでロードします.たとえば,

|require "kakasi"

ならば,ライブラリのあるディレクトリ$:が順に検索され, kakasi.rb もしくは kakasi.so(Win32ならkakasi.dll)がみつかったらロードされます.拡張子が.so(または.dll)ならば,そのライブラリの関数Init_kakasi()がまず呼び出され初期化されます.拡張モジュールの初期化の主な仕事は,Cの関数名とメソッド名の結びつけと,クラスやモジュール,定数の定義などです.またこの関数のように初期化関数の名前のアンダースコア「_」以降はファイルのベースネームと一致していなければなりません.

なお,初期化関数を含むファイルの名前を拡張モジュール名.cにしておくとMakefileの作成が非常に簡単になります.

以下にKakasi拡張モジュールの実装であるkakasi.cを掲げます.

|| 0 /*
|| 1  *  kakasi.c -- Kakasi module
|| 2  *  Copyright (C) 1999,2000 GOTO Kentaro
|| 3  */
|| 4 
|| 5 #include <string.h>
|| 6 #include "ruby.h"
|| 7 #include "libkakasi.h"
|| 8 
|| 9 #define OPTMAX 1024
||10 #define min(x,y) ((x)<(y) ? (x) : (y))
||11 
||12 static int dic_closed = 1, len = 0;
||13 static char prev_opt_ptr[OPTMAX];
||14 
||15 static VALUE
||16 rb_kakasi_kakasi(obj, opt, src)
||17     VALUE obj, opt, src;
||18 {
||19     int argc = 0, i = 0;
||20     char **argv, **opts;
||21     char *buf, *opt_ptr, *t;
||22     VALUE dst;
||23 
||24     Check_Type(src, T_STRING);
||25 
||26     /* return "" immediately if source str is empty */
||27     if (RSTRING(src)->len == 0)
||28         return rb_str_new2("");
||29 
||30     Check_Type(opt, T_STRING);
||31 
||32      /* initialize kakasi iff opt != previous opt */
||33     if (0 == len || 0 != strncmp(RSTRING(opt)->ptr, prev_opt_ptr, 
||34                                  min(RSTRING(opt)->len, len))) {
||35         strncpy(prev_opt_ptr, RSTRING(opt)->ptr, RSTRING(opt)->len);
||36         len = RSTRING(opt)->len; 
||37 
||38         if (len + 1 > OPTMAX) {
||39             rb_raise(rb_eArgError, "too long 1st arg (should be < 1023)");
||40         }
||41 
||42         if (!dic_closed) {
||43             kakasi_close_kanwadict();
||44             dic_closed = 1;
||45         }
||46 
||47         argv = opts = ALLOCA_N(char*, RSTRING(opt)->len);
||48         *opts++ = "kakasi";
||49         argc++;
||50 
||51         opt_ptr = ALLOCA_N(char, 1 + RSTRING(opt)->len);
||52         strncpy(opt_ptr, RSTRING(opt)->ptr, RSTRING(opt)->len);
||53         opt_ptr[RSTRING(opt)->len] = '\0';
||54 
||55         if (*opts++ = strtok(opt_ptr, " \t")) {
||56             argc++;
||57             while (t = strtok(NULL, " \t")) {
||58                 *opts++ = t;
||59                 argc++;
||60             }
||61         }
||62         
||63         if (0 != kakasi_getopt_argv(argc, argv))
||64             rb_raise(rb_eRuntimeError, "failed to initialize kakasi");
||65         dic_closed = 0;
||66     }
||67 
||68     dst = rb_str_new2("");
||69     while (i < RSTRING(src)->len) {
||70       if (*(RSTRING(src)->ptr + i) != '\0') {
||71         buf = kakasi_do((RSTRING(src)->ptr + i));
||72         rb_str_concat(dst, rb_str_new2(buf));
||73         free(buf);
||74         while (*(RSTRING(src)->ptr + i) != '\0') {
||75           i++;
||76         }
||77       }
||78       if (i == RSTRING(src)->len) {
||79         break;
||80       }
||81       rb_str_concat(dst, rb_str_new("\0", 1));
||82       i++;
||83     }
||84 
||85     return dst;
||86 }
||87 
||88 void
||89 Init_kakasi()
||90 {
||91     VALUE mKakasi = rb_define_module("Kakasi");
||92 
||93     rb_define_module_function(mKakasi, "kakasi", rb_kakasi_kakasi, 2);
||94     rb_define_const(mKakasi, "KAKASI_VERSION", rb_str_new2("2000-01-23"));
||95 }

このプログラムは大きくわけて,

  1. ヘッダファイルのインクルードとマクロ(5-10行)

  2. static変数の定義(12-13行)

  3. rb_kakasi_kakasi()の定義(15-86行)

  4. 拡張モジュール初期化関数(88-95行)

の4つの部分からなります.先に初期化関数からみておきます.

拡張モジュールの初期化関数(88-95行)

91行目ではモジュールKakasiを定義しています.引数はモジュール名をあらわす文字列です.

93行目で,rb_define_module_function()で2引数のモジュール関数 Kakasi::kakasiを定義しています.第4引数は,Cの関数の第2引数以降の形式をあらわし,非負のときは引数の個数をあらわします.

94行目は,定数KAKASI_VERSIONrb_define_const()で定義しています.第3引数に現れるrb_str_new2()については後で述べます.

以下最初から順にポイントを示します.

ヘッダファイルとマクロ定義部

6,7行目のruby.h,libkakasi.hはそれぞれRubyの C API と libkakasiを使うために必要です.9行目のOPTMAXkakasi_getopt_argv()に渡す文字列の長さの最大値です.

static変数の定義部

12行目と13行目で定義したstatic変数は以下のような高速化のためです.上で定めた仕様では,必要に応じてkakasi_getopt_argv()を呼び出すことにしていますが,呼び出しの必要性は,Kakasi::kakasiに渡された第1引数が前回の呼び出しと異なることで判定します.そのために,前回の呼び出しの際の引数を覚えておく必要があります.13行目のprev_opt_ptrはそのための配列で,12行目のlenはその長さを表します.また辞書が開いているかどうかを表す変数としてdic_closedを用意しています.

rb_kakasi_kakasi()の定義

宣言

15行目からがKakasi::kakasiの実装であるrb_kakasi_kakasi()です.この関数の型のようにRubyのオブジェクトはCの世界ではVALUEという型を持ちます.Rubyのメソッドに対応するCの関数は,第1引数にselfを受けるための引数が来ます.よって,この関数が呼ばれるとobjにはRubyの selfが代入されますが,ここでは使いません.

残りの引数は93行目で指定した通り,2個のメソッドの引数optsrcです.これらの引数もRubyのオブジェクトなので型はVALUEで す.

VALUEは実際は構造体へのポインタで,Cのデータと橋渡しするためのためにいろいろなマクロが提供されています.24行目ではnのクラスを検 査するマクロCheck_Typeを使って,文字列クラスをあらわすデータタイプT_STRINGであるかを判定しています.実行時にデータタイプが合わなければ例外TypeErrorが発生します.

RubyとCのデータのやりとり

27行目でsrcが空文字列かどうかを調べます.VALUE型のデータは適切な型にキャストしてやらないと,実体であるCの構造体にアクセス出来ません.このキャストは対象が文字列ならばRSTRING()というマクロがやってくれます.Rubyの文字列はlenというメンバにその長さが入っていますので,これが0かどうかをみればよいわけです.またptrというメンバにはCの文字列へのポインタが入っています.

逆にCの文字列からRubyの文字列を作るにはいくつかの関数がありますが,ここではrb_str_new2()を使っています(28行目).

Cの文字列とRubyの文字列

47-66行目ではoptkakasi_getopt_argv()に渡すため文字列配列 argvに分解します.まず,47行目で領域を確保しています.マクロ ALLOCA_N()は第1引数で指定された型の配列を第2引数の値だけ alloca(3)を使って確保します.

文字列配列への分解はstrtok(3)を使うことにしました. strtok()は引数を書き換えながら走査するので,壊されても構わないように51-53行目でopt_ptrにコピーして渡しています.

53行目でnul文字'\0'を追加しているのを理解するにはRubyの文字列の性質を知る必要があるでしょう.Rubyの文字列はCの文字列と違い,nulを含めることが出来ます.逆にいえば,nulが文字列の終端を意味しません. strtok()はそんなことを知らないので,ちゃんとnulで終わらせてから渡す必要があるのです.ちなみに,このようなCの文字列との違いはPerlや Pythonでも同様です.

KAKASIによる変換

68-81行目が実際のkakasi_do()による変換です.結果はdstに追加していきます.srcがヌルを含んでいた場合を考慮してやや複雑ですが,キモは73行目で,このbufの指す領域はkakasi_do()内 でmalloc()されたモノなのでfree()しています.

コンパイル

Rubyでは次のような'extconf.rb'を実行すれば,Rubyのインストール状況に合ったMakefileファイルが作成されます.

|% cat exconf.rb
|require "mkmf"
|
|$CFLAGS += " -I /usr/local/include "
|$LOCAL_LIBS += " -L /usr/local/lib -lkakasi "
|create_makefile("kakasi")
|% ruby extconf.rb

これを使って make するとkakasi.soのような名前のRuby用の実行時ライブラリが出来ます.

さっそく使ってみましょう.まずは動作確認のために漢字とカタカナをローマ字に読み下してみます(kakasi -Ja -Ka相当).

|% cat testkakasi.rb
|require "kakasi"
|puts Kakasi::kakasi("-Ja -Ka", "ドッペル玄関")
|% ruby testkakasi.rb
|dopperugenkan

確かに"ドッペル玄関"がdopperugenkanと読み下されています.めでたしめでたし.

kakasi-000123.tar.gzに含まれるプログラムwdcntは引数として与えられたテキストファイルをKAKASIのわかち書き機能で単語に分解し,頻度順に出力するプログラムです.出力は「頻度 # 単語」という形になっているので直接 gnuplot でプロットできます.試しに国民生活白書[WP]の単語を数えて横軸を順位,縦軸を頻度にとった両対数グラフにしました(図1).頻度は出現数をのべ単語数で割った相対頻度です.Zipfの法則[Man]と比較するため,順位の0.85乗に逆比例する関数のグラフを併記しています.

wp.png
図1: わかち書き機能による単語の頻度測定. 横軸は頻度順位,縦軸は相対頻度. G.K. Zipf は順位 r に対して相対頻度 f(r)1/(r log 1.78R) で近似できることを発見した(Rは異なり語の数).現在 Zipf's law と呼ばれるものである. B.B. Mandelbrot はこれを補正し,パラメータ a > 0, c を使って (r-c)-a に比例するとした.

あとはmake installで,拡張モジュールが適切なディレクトリにインストールされます.

フィードバック

この拡張モジュールは1999年の7月に最初の版を公開しました[Got].当初はoptsrcがnulを含む場合のことを考慮していなかったため,不具合がユーザからruby-ext[RE]で指摘されました.意見を取り込み,現在の形になりました.

また,このままで問題がないわけではありません.たとえば,日本語で終わる文字列をJISで出力したとき,終わりの状態がJISのままになってしまうという問題があります.

|p Kakasi::kakasi("-ojis", "かな")
|  #=> "\e$B$+$J"

しかし,これは拡張モジュールのバグというよりもKAKASIの(仕様の)バグとも呼べるようなモノなので,現在KAKASIの修正を提案中です.この号が出ているころにはこの点を改善した新しいKAKASIが出ているかも知れません.

REFERENCES

[MI]

まつもとゆきひろ, 石塚圭樹, ``オブジェクト指向スクリプト言語Ruby'', アスキー出版局

[KP]

KAKASI Project, ``KAKASI - 漢字→かな(ローマ字)変換プログラム'', <URL:http://kakasi.namazu.org/>

[Got]

Gotoken, ``Ruby/Kakasi'', <URL:http://www.ruby-lang.org/en/raa.html#Ruby%2FKAKASI>

[WP]

経済企画庁, ``平成11年度国民生活白書'', <URL:http://www.epa.go.jp/99/c/19991210wp-seikatsu/19991210wp-seikatsu.html>

[Man]

ベンワー. B. マンデルブロー, ``フラクタル幾何学'', 広中平祐監訳, 日経サイエンス社.

[RE]

ruby-extメーリングリスト, <URL:http://www.ruby-lang.org/ja/ml.html>