Masteries

技術的なことや仕事に関することを書いていきます.

Perlで @EXPORT を @EXPORT_OK に置き換える

この記事は「はてなエンジニア Advent Calendar 2021」の12日目の記事です.

qiita.com

昨日は id:cohalz の「distrolessのnonrootイメージを使おう」でした.

cohalz.co

関数のエクスポート

Perlのパッケージには, "エクスポート"という概念があります. 例えば, 次のようなモジュールがあったとしましょう:

package MyPkg;

sub f1 { ... }
sub f2 { ... }
sub f3 { ... }

パッケージMyPkgに定義されたf1, f2, f3の関数をパッケージの外から呼び出すには, 通常次のように呼び出す必要があります:

use MyPkg;

MyPkg::f1();
MyPkg::f2();
MyPkg::f3();

いちいち MyPkg を書くのは面倒, ということで用意されたのが Exporter モジュールです.

metacpan.org

このモジュールを使うと, グローバル変数@EXPORT自動的にエクスポートする関数を, @EXPORT_OKエクスポート可能な関数 を, それぞれ定義することができます. 例えば, MyPkgExporter を使って, 次のように書き換えたとしましょう:

package MyPkg;

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

sub f1 { ... }
sub f2 { ... }
sub f3 { ... }

この場合, f1, f2 の関数は自動的にエクスポートされるため, 次のようにして呼び出すことができます:

use MyPkg;

f1();
f2();
f3(); # 関数 f3 は @EXPORT で指定していないため呼び出せない(存在しない関数を呼び出したことになる)

f3 を呼び出したい時は, 次のようにMyPkguseするときに, 「この関数を利用します」と指定しなければなりません:

use MyPkg qw(f3);

f3(); # use するときに, f3 を呼び出せるよう設定しているので呼び出せる

@EXPORTの問題点

@EXPORT@EXPORT_OK, どう使い分けるべきでしょうか. 先程の例では, Exporterモジュールを使って関数をエクスポートしているパッケージが, MyPkgだけだったので, どちらでも問題ないように思えます. しかし, 複数のパッケージでExporterモジュールを使い, @EXPORTで関数をエクスポートしていると, どうなるでしょうか.

use MyPkg1;
use MyPkg2;
use MyPkg3;

exported_function();

パッと見で, 「exported_functionはどのパッケージで定義されているんだ...?」となりますよね. このため, Perl::Critiでも Perl::Critic::Policy::Modules::ProhibitAutomaticExportation というルールが用意されていて, @EXPORT を使って関数をエクスポートしようとすると警告してくれるようにすることができます.

metacpan.org

@EXPORT を @EXPORT_OK に置き換える

これを自動的に置き換えるにはどうすればいいでしょうか. 今回はPerlの静的解析ツールであるところのPPIを使って解決しました:

metacpan.org

use strict;
use warnings;

my $pkg = 'Foo';
my $path = 'lib/Foo.pm';

# $pkg においてエクスポートされている関数の名前を取得する
my $exported_function_names = get_exported_function_names($pkg);

# `git grep` を使って, $pkg を使っているファイルを探す
my @files_using_exported_function = split /\n/, `git grep --name-only --word-regexp 'use $pkg;' t/`;

# `replace_file` で, $pkg を使っているファイルについて利用する関数を明示するよう書き換える
replace_file($_, $exported_function_names) for @files_using_exported_function;

# $pkg の @EXPORT を @EXPORT_OK に書き換える
replace_export_ok($path);

# スペシャルサンクス: id:mackee_w & id:xtetsuji
# この関数で行っている, 変数でパッケージ名を指定してグローバル変数を読み出す方法がわからなかったのでアドバイス頂きました
sub get_exported_function_names {
    my $pkg = shift;
    no strict qw(refs);
    return [ @{ $pkg . '::EXPORT' } ];
}

sub replace_export_ok {
    my ($path) = @_;

    my $document = PPI::Document->new($path);

    my $export = $document->find(
        sub { $_[1]->isa('PPI::Token') && $_[1]->content eq '@EXPORT' }
    );

    return undef unless $export;

    $export->[0]->set_content('@EXPORT_OK');
    $document->save($path);
}

sub replace_file {
    my ($path, $exported_function_names) = @_;

    my $document = PPI::Document->new($path);

    # $exported_function_names に存在する関数のうち,
    # $path で実際に使っているものを探す
    my $used_exported_function_names = $document->find(
        sub { $_[1]->isa('PPI::Token') && any { $_[1]->content eq $_ } $exported_function_names->@* }
    );

    return undef unless $used_exported_function_names;

    my $unique_used_exported_function_names = [ uniq sort map { $_->content } $used_exported_function_names->@* ];

    # $pkg を use している部分を探して...
    my $use_statement = $document->find(
        sub { $_[1]->isa('PPI::Statement::Include') && $_[1]->module eq $pkg }
    )->[0];

    my $package_word = $use_statement->find(
        sub { $_[1]->isa('PPI::Token::Word') && $_[1]->content eq $pkg }
    )->[0];

    # `use $pkg;` を `use $pkg qw( ... );` のように書き換える
    # ... は $path で実際に使っているエクスポートされた関数の名前になる
    $package_word->insert_after(
        PPI::Token::Word->new(sprintf(" qw(%s)", join(' ', $unique_used_exported_function_names->@*)))
    );

    $document->save($path);
}

この例では, lib/Foo.pm にあるパッケージFooにある@EXPORT@EXPORT_OKに置き換えています. lib/Foo.pmだけでなく, Gitのgit grepを使って, 実際にFooを使っているファイルの中身も書き換えて, 置き換え後も正しくテストが通るようにしています. 実際にとあるPerlプロダクトで試してみましたが, 95%くらいはこの素朴なスクリプト(をベースにしたスクリプト)で一括置換することができました.

実は今回始めてPPIを使ったのですが, ドキュメントとにらめっこして1時間ちょっとくらいで素朴な置き換えスクリプトが実装できて便利でした(ので, おそらくもっと良い書き方はありそうです). PPIを使うことで, こういった細かい気になりポイントをガッとリファクタリング出来ることがわかったので, これからも良いアイデアが浮かんだらやっていきたいと思いました.

明日の担当は...

id:Windymelt さんです. よろしくおねがいします.

blog.3qe.us