2006/06/12(月)定義済の関数を調べる
can メソッドの罠
Perlのマニュアルによれば、オブジェクトのメソッドを調べるには、すべてのクラスで継承される UNIVERSAL クラスの can メソッドを用いて
if ($obj->can("function_name")) { $obj->function_name(); } if (MYCLASS->can("function_name")) { MYCLASS->function_name(); }
としろと書いてあります。実際この判定はうまく動き、スケルトンシステム(Satsukiシステム)が今ほど本格的になる前のテンプレートに少し毛が生えた程度のころは、この can メソッドを使って関数呼び出しと変数の判別をしていました*1。
この can メソッドには罠があって、オートローダー(AUTOLOAD 関数/メソッド)が定義されているときcanメソッドは常に true を返します。AUTOLOAD というのは、もともとはPerlの関数を必要なときに動的ロードする仕組みであって、Satsukiシステムでは独自のオートローダーを定義しています*2。さらに、呼び出された関数が未定義のときに呼び出されるという仕組みを用いて、あたかも関数のような動作を動的生成ということも可能です。AUTOLOADは、未定義関数をロードして代理で呼び出すなり、あたかもその関数が存在するように振る舞うなりすればいいということになります。
# オブジェクト内のハッシュを返すメソッド # 呼び出した未定義関数はグローバル変数「our $AUTOLOAD」に入っている。 sub AUTOLOAD { # $AUTOLOAD='MYCLASS::FUNCTION' → $func='FUNCTION' my $func = substr($AUTOLOAD, rindex($AUTOLOAD, '::')+2); my $self = shift; return $self->{$func}; }
canメソッドが正常に働くとき、働かないとき
Satsukiシステムのオートローダーは、
Auth.pm Auth_auto.pm Auth_auto2.pm
という具合に、目的の関数が見つかるまで次々と連番のモジュールをロードしていくという方式で*3、AUTOLOAD内で次々とモジュールをロードしながら、canメソッドを使用して目的の関数が見つかったか調べる方法を取っていました。そして見つからなかったら失敗(die)するように作ってありました。
実際この方式はうまく動作していまして、このことからAUTOLOAD関数が存在するクラス内のcanはそのAUTOLOAD関数内では正常に判別できることがわかります。たぶんperl自身がcanの中でこの辺をこまめに判別しているようです。
AUTOLOADの暴走(無限ループ)
しかし更にややこしい問題が発生しました。SatsukiシステムのDBモジュール*4は、そのAPIが規定されていることが利点となり、どのような下位モジュールに対してもキャッシュを行うDB_cache.pmというものが存在します。この中では、select のみオーバーライドしキャッシュを行い、その他の関数はcanで調べてその関数が存在するときは下位のモジュールの関数を呼び出すという仕組みにしていました。この下位モジュールを呼び出す仕組みでAUTOLOADを使用していたのです。
分かりずらいので図式すると、今このサーバではPostgreSQLおよびこのキャッシュモジュールを使用しています。このときデータベースへのアクセスは、
(キャッシュなり)Diary.pm → DB_pg.pm (キャッシュあり)Diary.pm → DB_cache.pm → DB_pg.pm
となっています。Diary.pm からは、キャッシュがあろうがなかろうが、DBモジュールAPIを満たすものに対するアクセスしか行っていないため何ら違いはありません。
ここで、プログラム中にたまたま Diary.pm から DB_pg.pm に存在しない関数xyz()を読んでしまったのが問題の始まりでした*5。
モジュール | AUTOLOAD内 |
---|---|
DB_cache.pm | DB_pg.pm に xyz() が存在しないのに xyz() を呼び出している |
DB_pg.pm | xyz() のロードに成功していないのに*6、なぜかxyz()が存在すると思って呼びに行く |
結果として「DB_cache.pm → DB_pg.pm → DB_cache.pm → …」という無限ループに陥ってしまいました。DB_cache はその仕組み上、DB_pg.pm を動的に継承してしまうため、DB_pg への関数呼び出しは、例えDB_pg自身からであってもすべて DB_cache を経由します。それでこういうことが起こったわけです。
しかし、どちらのモジュールでも、こういうことを避けるために「canメソッドによって関数が実在するか調べていた」ハズです。調べてみると、どちらのcanも、どんなメソッドに対しても成功していました。ありもしないでたらめな関数でもなんでもif ($obj->can("xxx"))が成立していたわけです。
これが最初に言った、AUTOLOAD関数が定義されているときcanメソッドは常に true を返すという罠です。オブジェクトを動的に継承したために(実際に継承するのではなく、あたかも継承したようにみせかけた*7ために)、自分のクラス外のcanが常に成功したわけです。
解決策
はじめからcanなんて必要なかったんじゃないかというくらい、実に簡単な解決策でした。DB_pg.pm、DB_cache.pmをそれぞれ次のようにしました。
sub AUTOLOAD { (略) if (defined &$AUTOLOAD) { $can=1; last; }
sub can { my ($self, $func) = @_; my $func = $self->{_super_class} . '::' . $func; return (defined &$func); }
そしてたぶん、この方法の方が速く動作します。
注意
ここでは便宜上DB_pg.pm内に AUTOLOAD が存在するとして説明しましたが、実際には、Autoloader.pmからAUTOLOAD関数をインポートしています。