Perl の @INC フックの挙動調査
概要
Perl でモジュールをロードするとき、モジュール名に対応するファイルパスを検索するといった処理が実行される。
ファイルパスの検索は配列 @INC に格納されているディレクトリのリストで行われる。
@INC にコードリファレンスなどを入れることで、モジュールのロード処理にフック処理を入れることができ、そのことを@INCフックと呼ぶ。
この記事ではその @INC フックの挙動について調査する。
仕様については require のドキュメントに記述されている。
@INC フックの種類
フックには INC フィルタと INCDIR フックの 2 種類があり、CodeRef、オブジェクト、 ArrayRefのいずれかの形式でフックをすることができる。
CodeRefによるフック
@INCを走査しているときにCodeRefが出現した場合、CodeRefがblessされていてINCDIRフックをサポートしていない限り、このサブルーチンはINCフックとして扱われて、2つの引数を渡して呼び出される。
引数は第1引数がそのCodeRef自身で、第2引数がロードしようとしているモジュールのファイル名。
返り値は何も返さないか、以下の順序で最大4つの値のリストを返す。必要のない引数は抜く。代わりにundefを渡す必要もない
1. 文字列へのスカラリファレンス。参照している文字列はファイルやジェネレータの出力の先頭部分に追加される
2. モジュールファイルの内容を読み込むためのファイルハンドル
3. サブルーチンへの参照。2. のファイルハンドルがない場合はジェネレータとして扱われる。サブルーチンは呼び出しごとに1行のソースコードを生成し、その行を $_ に書き込んで1を返し、最後にファイルの終了時に0を返すことが期待される。2. のファイルハンドルがある場合、このサブルーチンは、$_ に読み込まれた行を持つソースフィルタとして動作するように呼び出される。歴史的な理由により、このサブルーチンは意味のない引数 (実際には常に数値の 0) を第1引数に受け取る。
4. サブルーチンのオプション。この引数は 3. のサブルーチンの第2引数に渡される。
何も返さない場合(空リスト, undef, 上記に一致しない引数)が返された場合は require は @INC の残りの要素を調べる。
かなり難しい仕様なので直接 require のコードを読んだほうが理解しやすそうだが、XSで書かれているため読むのが難しい。
実際に実装してみてCodeRefによるフックの挙動を確認してみる。
まず文字列へのスカラリファレンスのみ返す@INCフックを実装する。
code:perl
use v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
if ($filename eq 'Ghost.pm') {
my $code = << 'EOS';
package Ghost;
use v5.38;
sub do_something { warn q{I'm ghost.} }
1;
EOS
return (\$code);
}
else {
return;
}
};
require Ghost;
Ghost::do_something(); # I'm ghost. at /loader/0x5556a2cf03e8/Ghost.pm line 3.
Ghost というモジュールをロードしようとしたときのみ、モジュールが存在しなくても@INCフックによって代わりの内容をロードできるようにしている。
名前空間 Ghost の do_something サブルーチンを呼び出すことができたので、ドキュメントに書いてあるとおり文字列へのリファレンスを返すとそれをパースしていることがわかった。
次にファイルハンドルを返す@INCフックを実装する。
本体を実装する前に同じディレクトリに次のようなファイルを用意する。
code:perl hoge.txt
package Ghost;
use v5.38;
sub do_something { warn q{I'm ghost.} }
1;
このファイルを@INCフックの中でopenしてそのファイルハンドルを返す
code:perl
use v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
if ($filename eq 'Ghost.pm') {
open my $fh, '<', './hoge.txt';
return $fh;
}
else {
return;
}
};
Ghost::do_something(); # I'm ghost. at /loader/0x564580d38368/Ghost.pm line 3.
hoge.txt の内容がパースされて Ghost::do_something を呼び出せるようになっている。
次にコードジェネレータであるCodeRefを返す@INCを実装する。
code:perl
se v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
if ($filename eq 'Ghost.pm') {
my $code = << 'EOS';
package Ghost;
use v5.38;
sub do_something {
warn "I'm ghost.";
}
EOS
my @lines = split /\n/, $code;
(
sub {
my ($zero, $option) = @_; # $zero = 0, $option = +{ options => 1 }
my $line = shift @lines;
if ( defined $line ) {
$_ = $line;
return 1;
}
else {
return 0;
}
},
+{ options => 1 },
);
}
else {
return;
}
};
require Ghost;
Ghost::do_something(); # I'm ghost. at /loader/0x560ef23850b8/Ghost.pm line 4.
コードジェネレータは1回の呼び出しごとに $_ の内容を1行ごとパースするコードに追加していくという使われ方をするので、 @lines の内容を1行ずつ $_ に書き込んで1を返し、全部書き込み終わっていたら0を返すように実装している。
コードジェネレータに渡される引数は第1引数は 0 で、第2引数はフックの最後の返り値になっている。このコードではフックの最後の返り値が +{ options => 1 } になっているのでコードジェネレータの第2引数にも +{ options => 1 } が渡されている。
次にソースフィルターであるCodeRefを返す@INCを実装する。
code:perl
use v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
if ($filename eq 'Ghost.pm') {
open my $fh, '<', './hoge.txt';
return (
$fh,
sub {
if ( $_ ne '' ) {
$_ =~ s/ghost/not ghost/g;
return 1;
}
else {
return 0;
}
},
);
}
else {
return;
}
};
require Ghost;
Ghost::do_something(); # I'm not ghost. at /loader/0x5627bee92078/Ghost.pm line 3.
先程の hoge.txt の内容を1行ずつ読み込んで置換するソースフィルタになっている。
ソースフィルターにはファイルハンドルから読み込まれたコードが1行ずつ $_ に格納されて呼び出され、コードを書き換えたい場合は $_ の内容を書き換える。
返り値はファイルハンドルからそれ以上読み込める行がない場合は $_ が空になるので、空になるまでは真値を返し空になったら偽値を返す。途中で偽値を返すとコードジェネレータ同様読み込まれる行もそこで終わってしまう。
今回の場合は s/ghost/not ghost/g にマッチする行のみ置き換わるようになっているので sub do_something { warn q{I'm ghost.} } の行が sub do_something { warn q{I'm not ghost.} } に置換され、 Ghost::do_something を呼び出すと I'm not ghost. というwarningが出るようになっている。
オブジェクトによるフック
フックがオブジェクトの場合は INC が実装されている必要がある。
INC メソッドは第1引数はオブジェクト自身、第2引数はファイル名が渡され、返り値はCodeRefと同様の値を期待する。
code:perl
package INCHooker {
use v5.38;
sub new($class, %args) {
return bless +{ %args }, $class;
}
sub INCHooker::INC {
my ($self, $filename) = @_;
if ($filename eq 'Ghost.pm') {
my $code = << 'EOS';
package Ghost;
use v5.38;
sub do_something {
warn "I'm ghost.";
}
EOS
my @lines = split /\n/, $code;
(
sub {
my $line = shift @lines;
if ( defined $line ) {
$_ = $line;
return 1;
}
else {
return 0;
}
},
);
}
else {
return;
}
}
}
use v5.38;
my $hooker = INCHooker->new;
push @INC, $hooker;
require Ghost;
Ghost::do_something();
このコードでは INCHooker クラスに INC メソッドを実装し、そのオブジェクトを生成して@INCにpushしている。
INCシンボルは強制的にmainパッケージに宣言されるため、完全修飾名、今回だと INCHooker::INC で宣言する必要があることに注意。
また、返り値のリストを @INC にpushする INCDIR メソッドを実装することもできる。Perl5.38で実装された。
code:perl
package INCHooker {
use v5.38;
sub new($class, %args) {
return bless +{ %args }, $class;
}
sub INCDIR {
return ('/usr/local/lib/perl5', 'tmp');
}
}
use v5.38;
my $hooker = INCHooker->new;
push @INC, $hooker;
require Ghost;
Ghost::do_something();
これだとエラーになるがエラー内容の@INCを確認するとINCDIRの返り値が追加されていることがわかる。
code:text
Can't locate Ghost.pm in @INC (... INCHooker=HASH(0x55ecde25c5e8) /usr/local/lib/perl5 tmp) at object.pl line 20.
1つのクラスに INCDIR メソッドと INC メソッドが両方とも実装されていた場合は INC メソッドのみ利用された。
配列リファレンスによるフック
フックが配列リファレンスの場合、最初の要素は前述のサブルーチンリファレンスかオブジェクトでなければならない。
最初の要素がINCまたはINCDIRメソッドをサポートするオブジェクトである場合、そのメソッドはオブジェクトを第1引数、要求されたファイル名を第2引数、フック配列のリファレンスを第3引数として呼び出される。
最初の要素がサブルーチンである場合、第1引数には配列リファレンス自身が、第2引数にはファイル名が渡されて呼び出される。
どちらの形式でも配列リファレンスの中身を変更することで呼び出しと呼び出しの間で状態を受け渡すことができる。
使いどころは思いつかないが例えば以下のようなことができる。
code:perl
use v5.38;
my $phantom_code = << 'EOS';
package Phantom;
use v5.38;
sub do_something {
warn "I'm ghost.";
}
EOS
push @INC, [
sub {
my ($arrayref, $filename) = @_;
my ($coderef, $loaded_module_map) = @$arrayref;
$loaded_module_map->{$filename} = 1;
my $code = do {
if ($filename eq 'Ghost.pm') {
my $code = << 'EOS';
package Ghost;
use v5.38;
sub disappear {
warn "There was nothing...";
}
EOS
}
elsif ($filename eq 'Phantom.pm') {
my $code = << 'EOS';
package Phantom;
use v5.38;
sub nothing_to_do {
warn "...";
}
EOS
$loaded_module_map->{'Ghost.pm'}
? $code =~ s/"\.\.\."/"There was a ghost."/gr
: $code;
}
};
return unless defined $code;
my @code_lines = split /\n/, $code;
return sub {
my $line = shift @code_lines;
if ( defined $line ) {
$_ = $line;
return 1;
}
else {
return 0;
}
};
},
+{},
];
この配列リファレンスによるフックでは配列リファレンスの1番目の要素にこのフックでロードしたモジュールを記録するようにして、 Phantom.pm をロードしたときすでに Ghost.pm がロードされていたら Phantom.pm のコードを変更するようになっている。
これにより、Phantom::nothing_to_do の挙動を次のように変化させることができる。
先に Ghost.pm をロードしていない場合
code:perl
require Phantom;
Phantom->nothing_to_do(); # ... at /loader/0x55caef508960/Phantom.pm line 4.
先に Ghost.pm をロードしている場合
code:perl
require Ghost;
require Phantom;
Phantom->nothing_to_do(); # There was a ghost. at /loader/0x55caef508960/Phantom.pm line 4.
%INCへの値のセット
@INCフックは %INC にロードしたモジュールに対応する値をセットすることもできる。
@INCフックで特に %INC に値をセットしない場合はフック自身をセットする。
%INC にロードしたモジュールに対応する値をセットしない場合
code:perl
use v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
return unless $filename eq 'Ghost.pm';
my $code = << 'EOS';
package Ghost;
use v5.38;
sub do_something {
warn "I'm ghost.";
}
EOS
my @lines = split /\n/, $code;
return sub {
my $line = shift @lines;
if ( defined $line ) {
$_ = $line;
return 1;
}
else {
return 0;
}
};
};
require Ghost;
warn $INC{'Ghost.pm'}; # CODE(0x55785f2c8078) at set_percent_inc.pl line 31.
%INC にロードしたモジュールに対応する値をセットした場合
code:perl
use v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
return unless $filename eq 'Ghost.pm';
$INC{'Ghost.pm'} = './Ghost.pm';
my $code = << 'EOS';
package Ghost;
use v5.38;
sub do_something {
warn "I'm ghost.";
}
EOS
my @lines = split /\n/, $code;
return sub {
my $line = shift @lines;
if ( defined $line ) {
$_ = $line;
return 1;
}
else {
return 0;
}
};
};
require Ghost;
warn $INC{'Ghost.pm'}; # ./Ghost.pm at set_percent_inc.pl line 31.
フックを用いて@INCを書き換える
フックを使って@INC配列を書き換えることもできる。
@INCを書き換える場合は undef を返す。
code:perl
use v5.38;
push @INC, sub {
my ($__sub__, $filename) = @_;
state $i = 0;
push @INC, sub {
warn ++$i;
return if $i >= 3;
push @INC, __SUB__;
};
return;
};
require Ghost;
code:text
1 at modify_atinc.pl line 9.
2 at modify_atinc.pl line 9.
3 at modify_atinc.pl line 9.
Can't locate Ghost.pm in @INC (you may need to install the Ghost module)
5.37.7 より前のバージョンではこの挙動は安定しておらずセグフォなどを引き起こすことがあったが、それ以降は挙動は安定しフックで@INCの走査をコントロールできるようになった?
$INC による@INCのイテレーション制御
perl5.37.7 からrequire時に実行される@INC配列の走査処理のイテレーションを制御する機能が追加された。
@INCフックの中ではインデックスが $INC に格納されるようになり、 $INC を書き換えると次にチェックされる@INCの要素は $INC の値の次の要素(undefの場合は0)になる。
code:perl
push @INC, sub {
splice @INC, $INC, 1; # このフックを @INC から取り除く
unshift @INC, sub { warn "A" };
undef $INC; # @INC のイテレータをリセットし先頭から実行し直す(上の行でunshiftしたフックが最初に実行される)
};
例えば上記のフックを存在しないモジュールを require することで実行すると、フックは@INCから自分自身を取り除き、require するたびに警告を発する新しいフックを先頭に追加した上で、@INC のイテレータをリセットし先頭から実行し直し、 A という警告が表示されるようになる。
5.37.7より前のバージョンでは新しく追加されたフックを即座に使用させたり、イテレータの前にある@INC内の変更された要素をチェックする方法がなかったため、警告は2回目のrequire呼び出しのときにしか発生しなかった。
requireを実行する前に$INCに何らかの値を設定しても require の実行には全く影響しないし、$INC に値が設定されていた場合は require の終了時に元に戻される。