@INCフックの挙動
Perl5.38のアップデート内容を調べているときに@INCフックの挙動に詳しくついて調べたのでアウトプットする。
requireのドキュメントに記述されていること読みながらを試していった内容をまとめた。
@INCフック とは
Perlでuseやrequireなどでモジュールをロードするとき、 @INC
に格納されているディレクトリのリストからモジュール名に対応するファイルパスを検索するといった処理が実行されるが、@INC
に文字列ではなくコードリファレンスなどを入れることでモジュールのロード処理にフック処理を入れることができる。
これを@INCフックと呼ぶ。
あまり使いどころが思い浮かばないが、Carmelで利用されているのでパッケージマネージャなどを作っていると使いたくなることがありそう。
@INCフックの種類
フックはCodeRef、オブジェクト、 ArrayRefのいずれかの形式ですることができる。
CodeRefによるフック
@INC
を走査しているときにCodeRefが出現した場合、CodeRefがblessされていてかつINC、INCDIRメソッドが実装されていない限り、このサブルーチンはINCフックとして扱われ、2つの引数を渡して呼び出される。
引数は第1引数がそのCodeRef自身で、第2引数がロードしようとしているモジュールのファイル名。
返り値は何も返さないか、以下の順序で最大4つの値のリストを返す。
必要のない引数は抜く。代わりにundefなど渡す必要もない。
- 文字列へのスカラリファレンス。参照している文字列はファイルやジェネレータの出力の先頭部分に追加される
- モジュールファイルの内容を読み込むためのファイルハンドル
- CodeRef
- CodeRefのオプション。3 のサブルーチンの第2引数に渡される
何も返さない場合(空リスト、undef、上記に一致しない引数)が返された場合は require は @INC
の残りの要素を調べる。
これらの仕様通りに動くのか実際にコードを書いて確認してみる。
文字列へのスカラリファレンスのみ返す@INCフック
このコードは Ghost というモジュールをロードしようとしたときのみ、モジュールが存在しなくても@INCフックによって $code
の内容を代わりにロードするようにしている。
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::do_something
を呼び出すことができているので、文字列へのリファレンスを返すとそれをパースすることがわかる。
ファイルハンドルを返す@INCフック
本体を実装する前に同じディレクトリに次のようなファイルを hoge.txt
として保存しておく。
package Ghost; use v5.38; sub do_something { warn q{I'm ghost.} } 1;
Ghost というモジュールをロードしようとしたとき、このファイルを@INCフックの中でopenしてそのファイルハンドルを返すようにする。
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フック
コードジェネレータは1回の呼び出しごとに $_
の内容を1行ごとパースするコードに追加していき、返り値が真の場合は生成を続け、偽の場合生成を終了するという挙動になっている。
コードジェネレータに渡される引数の第1引数は意味のない引数(実際には 0
)で、第2引数はフックの最後の引数が渡される。
このコードでは @lines
の内容を1行ずつ $_
に書き込んで1を返し、全部書き込み終わっていたら0を返すように実装している。
フックの最後の返り値が { options => 1 }
になっているので、ジェネレータの第2引数に渡される値も{ options => 1 }
になる。
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 "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.
動かしてみるとコードジェネレーターによって @lines
から追加されていった内容がパースされ Ghost::do_something
が呼び出せるようになっていることがわかる。
ソースフィルターであるCodeRefを返す@INCフック
先程の hoge.txt
の内容を1行ずつ読み込んで置換するソースフィルタを実装する。
ソースフィルターにはファイルハンドルから読み込まれたコードが1行ずつ $_
に格納されて呼び出され、コードを書き換えたい場合は $_
の内容を書き換える。
ファイルハンドルからそれ以上読み込める行がない場合は $_
が空になるので、空になるまでは真値を返し空になったら偽値を返す。途中で偽値を返すと読み込まれる行もそこで終わる。
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.
今回の場合は 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と同様の値を期待する。
このコードではINCHookerクラスにINCメソッドを実装し、そのオブジェクトを生成して @INC
にpushしている。
INCシンボルは強制的にmainパッケージに宣言されるため、完全修飾名、今回だと INCHooker::INC
で宣言する必要があることに注意。
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();
また、返り値のリストを @INC
にpushするINCDIRメソッドを実装することもできる。
Perl5.38で実装された。
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の返り値が追加されていることがわかる。
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引数にはファイル名が渡されて呼び出される。
どちらの形式でも配列リファレンスの中身を変更することで呼び出しと呼び出しの間で状態を受け渡すことができる。
例えば以下のようなことができる。
この配列リファレンスによるフックでは配列リファレンスの1番目の要素にこのフックでロードしたモジュールを記録するようにして、 Phantom.pm
をロードしたときすでに Ghost.pm
がロードされていたら Phantom.pm
のコードを変更するようになっている。
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; } }; }, +{}, # この値が $loaded_module_map になる ];
これにより、Phantom::nothing_to_do
の挙動を次のように変化させることができる。
先に Ghost.pm
をロードしていない場合
require Phantom; Phantom->nothing_to_do(); # ... at /loader/0x55caef508960/Phantom.pm line 4.
先に Ghost.pm
をロードしている場合
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
にロードしたモジュールに対応する値をセットしない場合
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
にロードしたモジュールに対応する値をセットした場合
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 を返す。
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;
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の走査をコントロールできるようになったらしい。
(実際に5.37.7以前のバージョンで実行してみたがセグフォは起こせなかったので詳細な条件は不明)
$INC による@INCのイテレーション制御
perl5.37.7 からrequire時に実行される@INC配列の走査処理のイテレーションを制御する機能が追加された。
@INCフックの中ではインデックスが $INC
に格納されるようになり、 $INC
を書き換えると次にチェックされる@INCの要素は $INC
の値の次の要素(undefの場合は0)になる。
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 の終了時に元に戻される。