いい感じにuseするのを助けてくれるApp::perlimportsのご紹介

これはPerlアドベントカレンダー2021の12日目の記事です。

昨日はshogo82148さんで、Perl 5.35.5 の iterating over multiple values at a time を先取りでした。

今回はPerlでもいい感じのモジュールのImportをしてくれるApp::perlimportsの紹介です。

App::perlImports

このモジュールはThe Perl and Raku Conference 2021で作者本人からトークされていて、スライド及び動画が上がっています。

www.youtube.com

スライドはGitHub上にHTMLがあるので手で落とす必要があるようです...。(GitHubPagesは特に使っていない模様)

curl https://raw.githubusercontent.com/oalders/presentations/main/slides/6-perlimports/remark.html -o perlimports.html && open perlimports.html

「Where Did That Symbol Come From?」とトークタイトルが示す通り、Perlのシンボルがどこからimportされたかを明確にしたいというのが実装の動機になっているようです。合わせて動き自体はgolangでimportしているモジュールをいい感じに管理してくれるgoimportsを参考にしているようです。

このモジュールについて説明するにはまず、Perlのモジュールロードがどのように行われているかを確認する必要があります。ざっと見てみましょう。

Perlのモジュールロード

Perlはモジュールをロードする方法に主にuseを使った方法とrequireを使った方法があります。*1 歴史的経緯でrequireの方が先に出ていて、 Perl5でuseが導入されています。 requireはモジュールロード以外に外部ファイルの読み込みにも使われます。

pointoht.ti-da.net

近年はuseを使ってモジュールロードをするのが一般的です。

例えば、PerlにデフォルトでバンドルされているHTTPクライアントのHTTP::Tinyを使う場合は次のようにロードします。

use HTTP::Tiny;

my $ua = HTTP::Tiny->new();

ロードタイミング

Perl動的言語でありますが、useを使ったモジュールロードの処理は、該当のPerlコードをPerlインタプリタが読み込む、コンパイルタイムに行われます。 例えば存在しないHogeモジュールをロードしようとすると、次のようにprintが走ることはなくエラーで死にます。

#!/usr/bin/env perl

use strict;
use warnings;
print "hello";
use Hoge;
❯ perl hoge.pl
Can't locate Hoge.pm in @INC (you may need to install the Hoge module) (@INC contains: /Users/anatofuz/.plenv/versions/5.32.0/lib/perl5/site_perl/5.32.0/darwin-2level /Users/anatofuz/.plenv/versions/5.32.0/lib/perl5/site_perl/5.32.0 /Users/anatofuz/.plenv/versions/5.32.0/lib/perl5/5.32.0/darwin-2level /Users/anatofuz/.plenv/versions/5.32.0/lib/perl5/5.32.0) at hoge.pl line 2.
BEGIN failed--compilation aborted at hoge.pl line 2.

対して、各関数呼び出しなどの処理は実行時に解決されます。 そのためuseを忘れても、対象のメソッドを実行しない限りは処理が止まることはありません。 例えば次のコードは、HTTP::Tinyをuseしていませんが、呼び出す前にdie(Perlでのエラー終了)をして処理を止めるので、ロードエラーは発生しません。

#!/usr/bin/env perl
use strict;
use warnings;


print "hello\n";
die;

my $ua = HTTP::Tiny->new();
$ua->get('https://example.com');
perl hoge.pl
hello
Died at hoge.pl line 6.

useの処理

ではuseは何をしているのでしょうか。Perldoc.jpを見るとこう書かれています。

指定したモジュールから、現在のパッケージにさまざまな内容をインポートします; 多くは、パッケージのサブルーチン名や、変数名に別名を付けることで、 実現されています。 これは、以下は等価ですが

BEGIN { require Module; Module->import( LIST ); }

https://perldoc.jp/func/use

BEGIN { }はBEGIN ブロックと呼ばれるもので*2コンパイルタイムに実行させるという処理です。 中ではrequireを使い、モジュールを読み込んだ後に、そのモジュールにあるimportメソッドを実行しています。

つまり、前述したPerlのモジュールロードのuserequireを比較すると次のような違いがあることがわかります。

項目 use require
ロードタイミング コンパイルタイム 実行時
副作用 importを実行する 読み込みのみ

整理したところで、このimportとは一体何でしょうか。 一般的には、これはモジュールで定義しているメソッドを、呼び出し元のコードで定義したかのように使うために実行されるものです。 詳しく見てみましょう。

import

Perlでは特定のモジュールをuseすると使えるようになるメソッドがいくつか存在します。 例えばコアモジュールのJSONを扱うライブラリであるJSON::PPをuseすると、任意のPerlのハッシュをJSON文字列化できるencode_jsonが使えるようになります。

use strict;
use warnings;

use JSON::PP;

print encode_json({hoge => 'hello', isBool => \0});

encode_jsonJSON::PPで定義されているメソッドなので、通常はJSON::PP->encode_jsonのように使う必要があります。 importは特定のメソッドを、useしたタイミングで通常の関数のように呼び出せるようによしなにやってくれる処理になっています。

この処理のことをPerlでは関数のエクスポートなどと読んでいます。 近年ではExporterモジュールが行っています。 あるモジュールで、Exporterモジュールを継承、もしくはuseすると、そのモジュール内で自動的に関数のエクスポート処理をimport関数として登録してくれます。

あくまでExporterを使った場合なので、独自にimport関数を定義することも可能です。 その場合は通常の関数のように呼び出せるようによしなにやってくれる処理以外の処理をuse時に行うように差し込むことが可能となります。 例えばClass::Accessor::LiteはimportをExport以外の目的に使っており、オブジェクトのアクセサを生成する処理を行っています。

use Class::Accessor::Lite (
    new => 1,
    rw  => [ qw(foo bar) ],
    ro  => [ qw(baz) ],
    wo  => [ qw(hoge) ],
);

Perlでモジュールをuseする際に、exportしたい関数名の指定ではないような引数を渡しているモジュールは、ほぼ独自にimportを書いていると考えても良いでしょう。

use時に差し込まれるモノたち

Exporterを使っているモジュールがuseされたときに差し込むものは、モジュール側で定義されている@EXPORT配列に積んだシンボルになっています。((ここでのシンボルはPerlのメソッド(関数、サブルーチン)の他に、スカラ変数や配列も含みますが、Exporterを使う上ではメソッド以外のシンボルを使うと問題が発生しがちなので、基本はメソッドのことです ))

例えば、Hoge::Firstモジュールをuseしたときに、hogeメソッドを差し込みたい場合は、次の様に定義します。

package Hoge::First;
use strict;
use warnings;
use feature qw/say/;

use Exporter 'import';

our @EXPORT = (qw/hoge/); #サブルーチンの名前を文字列で配列の中にいれるとEXPORTされる

sub hoge {
  say 'this is first!';
}

1;

Perl@EXPORTに積まれているオブジェクトをすべてロードします。 このためuseしているモジュールが、内部で@EXPORTに何かしら詰めていた場合、予期せぬシンボルをロードする可能性があります。 また、あるPerlファイルの中で出てきているメソッドが、一体どのモジュールから提供されているか、はたまたデフォルトのメソッドであるかの判断が難しくなってしまいます。 使用しているメソッドが、どのモジュールから提供されているのが解りづらいのは、リファクタリングの結果すでにあるモジュールが提供しているメソッドを使わなくなったのに、useし続けてしまうモジュールが出たり、逆にプログラミングしている上で使えるだろうと思って書いたメソッドが、実はuseしないといけなかったなどの問題を誘発しがちです。

また、同じ名前のオブジェクトをロードしてしまうと、呼び出し順序によって使われる関数が異なってしまいます。 例えばHoge::FirstHoge::SecondモジュールでそれぞれhogeをEXPORTしてみます。

package Hoge::First;
use strict;
use warnings;
use feature qw/say/;

use Exporter 'import';

our @EXPORT = (qw/hoge/);

sub hoge {
  say 'this is first!';
}

1;
package Hoge::Second;
use strict;
use warnings;
use feature qw/say/;

use Exporter 'import';

our @EXPORT = (qw/hoge/);

sub hoge {
  say 'this is second!';
}

1;
#!/usr/bin/env perl
# main.pl
use strict;
use warnings;

use Hoge::First;
use Hoge::Second;

hoge();

実行すると、最後にロードされたSecondが出てきます。

❯ perl -Ilib hoge.pl
this is second!

@EXPORTはこのような思いがけない処理が走る可能性があるので、Exporterモジュールのコメントにも「とりあえずEXPORTするのはやめよう」といったことが書かれています。 pointoht.ti-da.net

さらにはPerlのLinterであるPerl::Criticにも@EXPORTを警告するルールが追加されています。

metacpan.org

とはいえ関数のエクスポートは便利なので、それはそれで使用したいです。 Perlでは@EXPORTの他に@EXPORT_OKがあります。これはデフォルトではすべてをロードせず、use時にエクスポートしたい関数名を書くことを強制させる機能です。

例えばこんな感じにuse時に関数名を指定します。

use JSON qw(encode_json);

実は@EXPORTで宣言している場合もuse時に関数名を指定している場合はそのメソッドしかエクスポートされません。関数名を何も指定せずに()を書いた場合は何もエクスポートされないため、宣言的に関数名を書くようにするとほぼ@EXPORT_OKと同様の振る舞いをします。

自分が書いているコードに出てくるメソッドの宣言元をはっきりさせる単純な解決方法はEXPORTをやめ、Hoge::Foo->bar()の様にフルパスでメソッドを実行することですが、テストヘルパなどEXPORTした方が利便性が高いものもあるでしょう。 decode_utf8など、EXPORTされたメソッドを使うのがイディオム的に認知されているものもあります。 エクスポートした関数を使うのと、どこから取り込んできたのかの視認性を高めるには、use時にエクスポートしたい関数名を漏れなく書くしか無いでしょう。 しかしこれを人間がやるのは面倒!!! ということで作られたのがperlimportsです。

....と、ここまで書いたところで奇跡的に裏番組のはてなエンジニア Advent Calendar 2021id:papixさんが紹介している内容と前提知識が同じだった!!そんなことってあるんだ

papix.hatenablog.com

あらためてApp::perlImports

  • どこから来たのかわからないシンボルをエクスポートしているuseを特定したい
  • @EXPORTを全部インポートするのではなく、一部だけ指定してインポートしたい

これらの課題に対して、golangのgoimportsを参考に、構文解析ライブラリPPIの力を借りて誕生したのがApp::perlimportsです。 metacpan.org

著者のOALDERSさんはLWPなどのメンテナもやられています。

使ってみる

例えば次の様なモジュールがあったとしましょう。

package MyPkg::One;

use strict;
use warnings;

use Exporter 'import';
our @EXPORT = qw(f1);

sub f1 {
  print "f1\n";
}

1;
package MyPkg::Two;

use strict;
use warnings;

use Exporter 'import';
our @EXPORT_OK = qw(f2);

sub f2 {
  print "f2\n";
}

1;
package MyPkg::Three;

use strict;
use warnings;

use Exporter 'import';
our @EXPORT_OK = qw(f3);

sub f3 {
  print "f3\n";
}

1;
package MyPkg::Four;

use strict;
use warnings;

use Exporter 'import';
our @EXPORT = qw(f4);

sub f4 {
  print "f4\n";
}

1;

これをhoge.plから以下の様に呼び出します。

use strict;
use warnings;
use MyPkg::One;
use MyPkg::Two;
use MyPkg::Three;
use MyPkg::Four;


f1();
f2();
MyPkg::Three->f3();

hoge.plの状況は次のとおりです。

  • f1はMyPkg::OneがEXPORTしているものを使っている
  • f2はMyPkg::TwoがEXPORT_OKしているが、コード上は指定を忘れている
  • f3はフルパスで呼び出している
  • f4はuseしているが特に使っていない

このコードはそのまま実行すると, f2の解釈で実行時エラーが発生します。また、どのモジュールからエクスポートされているのかパット見ではわかりません。 さらにMyPkg::Fourは必要がないf4をエクスポートしてしまっています。

❯ perl -Ilib hoge.pl
f1
Undefined subroutine &main::f2 called at hoge.pl line 10.

これをApp::perlimportsに通すと、以下のようにコードが書き換わります。

❯ perlimports --libs lib  hoge.pl
use strict;
use warnings;
use MyPkg::One qw( f1 );
use MyPkg::Two qw( f2 );
use MyPkg::Three ();
use MyPkg::Four ();


f1();
f2();
MyPkg::Three->f3();

見ると次の様な変換が行われています。

  • f1,f2がEXPORT元のモジュールが明記されるようになった
  • エクスポートした関数を使用していないThreeFourは、空配列を指定することになり、何もエクスポートしなくなっている

変換されたコードはそのまま動かす事ができます。(今回はパイプで動かしています) こうすると、実行時エラーを未然に防ぐことが可能になり、どのモジュールからエクスポートしているか、パット見でわかるようになりました。

❯ perlimports --libs lib  hoge.pl | perl -Ilib
f1
f2
f3

さらに、無駄にロードしているMyPkg::Fourを削除することも可能です。

❯ perlimports --libs lib --no-preserve-unused  hoge.pl
use strict;
use warnings;
use MyPkg::One qw( f1 );
use MyPkg::Two qw( f2 );
use MyPkg::Three ();


f1();
f2();
MyPkg::Three->f3();

こうすると、無駄なファイルをuseし続ける事もなくなり便利ですね!

インストール方法

cpanmもしくはcpmを使ってインストールしましょう

$ cpanm App::perlimports
$ cpm install -g App::perlimports
$ plenv rehash

使い方

CLIツールとして提供されているので、perltidyの様にコマンドで実行します。

$  perlimports --libs lib,local/lib/perl5 -f hoge.pl

主要なオプションを見てみます。

  • --libs
    • Perl-Iと同様。基本はlibを指定することになるでしょう
    • カンマ区切りです
    • Carton, cpmでinstallしたローカルディレクトリ上のモジュールも含めたい場合は、local/lib/perl5を加えます
  • -f
    • 変換対象のファイル名
  • --no-padding
    • perlimportsが変換したあと、モジュール名の前後に余計な空白をいれない
    • デフォルトではuse Foo qw( bar baz );の様になるが、--no-paddingを指定するとuse Foo qw(bar baz);になる
  • --inplace-edit, -i
    • デフォルトでは変換したコードは標準出力に出るが、このオプションを指定すると変換元のファイルを上書きする
  • --no-preserve-unused
    • 使っていなさそうなモジュールをuseしないようにする

注意点

自動でuseしてくれる訳ではない

下手にgoimportsを使ったことがあると誤解しやすい点で、perlimportsの(現状)の興味の対象はEXPORTしたシンボルをいい感じに特定することのみです。goimportsはimportの追加などもしていましたが、それに対応する機能はありません。モジュールのuseし忘れなどは興味の範囲外になっています。

例えばMyPkg::One->f1()の様なメソッド呼び出しをしていて、MyPkg::Oneをuseし忘れた場合、perlimportsはこれを補完しません。 また、そもそもuseしているモジュールがない状態で実行しても補完はしてくれません。

そのためモジュールのuse忘れなどのimportエラーは別系統で判断する必要があります。

❯ cat hoge.pl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib";

use DDP {deparse => 1, hash_max => 0};

f1();
~/workspace/test3 via 🐪 v5.34.0
❯ perlimports --libs lib -f hoge.pl
use strict;
use warnings;
use FindBin ();
use lib "$FindBin::Bin/lib";

use DDP {deparse => 1, hash_max => 0};

f1();

関数エクスポート以外のuseは工夫の余地がある

また、Class::Accessor::LiteClass::Enumemonのようなuse時にエクスポートする関数名を指定する以外に、独自にimportsを定義しているモジュールは、perlimportsはuseしたものを使っていないと見なしてしまいます。これはperiimportsの作者によって名指しでperlimportsの自動整形下に置かない様に書かれているモジュール群か、構文解析の結果自動調整を逃れたもの以外は、空配列のimportに変換されてしまいます。

❯ cat hoge.pl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib";

use DDP {deparse => 1, hash_max => 0};
use Class::Accessor::Lite (
    rw => [qw/ hoge/],
);

f1();


~/workspace/test3 via 🐪 v5.34.0
❯ perlimports --libs lib,local/lib/perl5 -f hoge.pl
use strict;
use warnings;
use FindBin ();
use lib "$FindBin::Bin/lib";

use DDP {deparse => 1, hash_max => 0};
use Class::Accessor::Lite ();

f1();

これを回避するには、調整したくないモジュールをuseしている行の後ろに特定のアノテーション(## no perlimports)を書くか*3コマンド実行時の引数でモジュール名を直接列挙、またはモジュール名が列挙されたファイルを渡すことで回避可能です。この指定には正規表現が使用可能です。

作者にPRを送るとignoreするモジュールに追加してくれる様なので、普段使うモジュールで管理されるとまずいものがあればPRを送ると良いでしょう。

~/workspace/test3 via 🐪 v5.34.0
❯ cat hoge.pl
use strict;
use warnings;
use FindBin;
use lib "$FindBin::Bin/lib";

use DDP {deparse => 1, hash_max => 0};
use Class::Accessor::Lite (
    rw => [qw/ hoge/],
);

f1();
~/workspace/test3 via 🐪 v5.34.0
❯ perlimports --libs lib,local/lib/perl5 --ignore-modules-pattern '^Class::' -f hoge.pl
use strict;
use warnings;
use FindBin ();
use lib "$FindBin::Bin/lib";

use DDP {deparse => 1, hash_max => 0};
use Class::Accessor::Lite (
    rw => [qw/ hoge/],
);

f1();

メソッドの追加などの副作用で持つモジュールをuseしている場合は、--no-preserve-unusedに気をつける

Perlのモジュールでは、useすると自分以外のモジュールのメソッドをオーバーライドやメソッドを名前空間に追加するものがあります。 例えばHTTP::Message::PSGIは、useするとHTTP::Request名前空間to_psgiがメソッドとして追加されます。

これはuseすることで効果を発揮するモジュールであるため、HTTP::Message::PSGIがEXPORTしているメソッドを直接使っていないケースや、HTTP::Message::PSG->で何かを呼び出しているコードが無い限り、perlimportsで--no-preserve-unusedオプションを付けて実行してしまうと、何も意味がないモジュールを呼び出しているとuseを削除されてしまいます。

実際はHTTP::Message::PSGIはperlimports側で明示的に整形する対象に含まないように指定されているので、この様なことは起きないのですが、自分たちで書いているライブラリに似た挙動があるものがあれば、使用する際は気をつけましょう。


明日はid:mp0liiu さんで、immutableなコレクションを作るです!

*1:動的ロードなどを考慮するとこの限りではない

*2:awkから来ていた気がする

*3:ブロック的に囲うことも可能です

https://metacpan.org/pod/perlimports#ANNOTATIONS/IGNORING-MODULES