MPがありません。

$liiu->mp == 0

@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など渡す必要もない。

  1. 文字列へのスカラリファレンス。参照している文字列はファイルやジェネレータの出力の先頭部分に追加される
  2. モジュールファイルの内容を読み込むためのファイルハンドル
  3. CodeRef
    • 2 のファイルハンドルがない場合はジェネレータとして扱われる。サブルーチンは呼び出しごとに1行のソースコードを生成し、その行を $_ に書き込んで1を返し、最後にファイルの終了時に0を返すことが期待される
    • 2 のファイルハンドルがある場合、このサブルーチンは $_ に読み込まれた行を持つソースフィルタとして動作するように呼び出される。歴史的な理由により、このサブルーチンは意味のない引数 (実際には常に数値の 0) を第1引数に受け取る。
  4. 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 の終了時に元に戻される。