Perl Hackers Hub

第77回モジュールの自動インポートによる開発効率向上 ~PPIによる静的解析と、モジュールロードによる動的解析の組み合わせで実現(1)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはmomochiさんで、テーマは「モジュールの自動インポートによる開発効率向上」です。

本稿のサンプルコードは、執筆時点(2023年1月)で最新のPerl 5.36.0で動作確認を行っています。サンプルコードは、本誌サポートサイトから入手できます。

自動インポートで定型作業の効率化

Perlでは、モジュールを利用するにはuse文を追加し、モジュールが不要になった場合はuse文を削除するのが定型作業としてあります。コードベースが複雑になるほど、人間がuse文を過不足なく管理するにはコストを要します。そこで、自動インポートによって過不足なくuse文を管理することで開発効率を向上できます。

たとえば、Go言語ではgoimportsを使うと自動で必要なモジュールをインポートするので、効率的に開発できます。Perlは自由な書き方ができるので、同様の自動インポートをPerlで実現するのは難しいと思われるかもしれませんが、実はPerlでも同様の機能を実現できます。

本稿では、⁠そもそも自動インポートってどうやって動いているの?」という疑問を解消するべく、自動インポートを実現するための考え方を紹介します。そして、具体例を通して、Perlで簡易的な自動インポートが実現できる構成になっています。

Perlでインポート/エクスポートするしくみ

自動インポートを実装するには、インポート/エクスポートを実現するために広く用いられているExporterモジュールのしくみについて知る必要があります。

シンボルテーブルと型グロブ

Exporterのしくみを理解するために、まずはシンボルテーブルと型グロブについて解説します。

シンボルテーブルとは、変数自体を格納するグローバルアクセス可能なハッシュです。シンボルテーブルはキーと値のペアによって、変数名からその値への対応を表現します。たとえばFooパッケージ上で、our $iのようにパッケージ変数を宣言すると、パッケージに紐付ひもづいたFoo::iがシンボルテーブルに登録されます。

Perlでは同一の変数名でも$@といったシジルと呼ばれる添え字によって変数のデータ型が異なります。そのため、シンボルテーブルには同名でもそれぞれのデータ型への対応を持つ必要があります。Perlでは、*symとすることでsymという名の変数のすべてのデータ型を表現できます。これは型グロブと呼ばれます。したがって、シンボルテーブルは変数名をキー、型グロブを値として持ちます。

型グロブに対する代入はエイリアス操作を行います。たとえば*sym2 = *sym1とすると、sym1のすべてのデータ型にsym2でアクセス可能となります。特定のデータ型のみをエイリアスするには、*sym2 = \$sym1のようにリファレンスを代入します。

Exporterによるインポート/エクスポート

Exporterの一般的な使い方を示し、Exporterによるインポート/エクスポートのしくみを解説します。

エクスポートしたいモジュール
package ExModule;
use Exporter 'import';
# エクスポートしたいシンボルを定義
our @EXPORT = qw(foo);
sub foo { print 'Hello World'; }
呼び出し側
use ExModule qw(foo);
foo();

このとき、ExModule上で何が起きているかを見ていきます。Exporterimportメソッドを最小限にしたコードを次に示します。

Exporterのimportメソッド
package Exporter;
sub import {
  my $pkg = shift;
  # 呼び出したパッケージ名
  my $callpkg = caller(0);

  # Exporterのimportを使うかどうか                      ¬
  if ($pkg eq "Exporter" && @_ && $_[0] eq "import") {   |
    # importをエイリアス                                 |
    *{$callpkg."::import"} = \&import;                   |❶
    return;                                              |
  }                                                      」

  # ExModuleでour宣言された@EXPORT変数                  ¬
  my $exports = \@{"$pkg\::EXPORT"};                     |
  # 呼び出し側の関数としてエイリアス                     |
  for (@$expors} {                                       |❷
    *{"$callpkg\::$_"} = \&{"$pkg\::$_"};                |
  }                                                      」
}

呼び出し側がExModuleをインポートし、foo()を実行するときの全体の処理を解説します。

はじめに、ExModuleExporter->import('import')が実行されます。これは、Exporterで定義されたimportメソッドを使うという意味になります。❶が実行され、型グロブに対して代入することでExporterimportメソッドをエイリアスします。

次に、呼び出し側でExModule->import('foo')が実行されます。❷が実行され、ExModuleで定義した@EXPORT変数の値を取得し、呼び出し側の関数としてfooをエイリアスします。

最後に、呼び出し側でfoo()が実行されます。Perlでは名前解決する際にmy宣言やour宣言された変数がなければパッケージ変数であると仮定して探索します。したがって、呼び出し側のシンボルテーブル内でfooが探索され、これはエイリアス済みなので名前解決されます。

以上のとおり、Exporterはシンボルテーブルと型グロブを巧みに利用することで、インポート/エクスポートのしくみを実現しています。

静的解析による自動インポート

Perlでは、パッケージ名を指定して関数を呼び出すことができます。しかし、指定されたパッケージ名がインポートされていないとエラーとなります。したがって、パッケージ名を取得して必要なモジュールを自動でインポートできれば、モジュールがインポート済みかどうかを確認する必要がなくなって楽になります。

本節では、静的解析器として代表的なPPIモジュールを利用して呼び出された関数を解析し、その関数が定義されているモジュールをインポートする方法を解説します。

静的解析器PPIモジュール

PPIは、Perlのソースコードを入力として、内部でソースコードを意味のある単位で分けたPDOMPerl Document Object Modelと呼ばれるデータを木構造で保持します。具体例として、次のソースコードをPPIに入力したときに得られる木構造を示します。

入力されるソースコード
use Test;
得られる木構造
PPI::Document
  PPI::Statement::Include
    PPI::Token::Word 'use'
    PPI::Token::Whitespace ' '
    PPI::Token::Word 'Test'
    PPI::Token::Structure ';'
  PPI::Token::Whitespace '\n'

PPIの使い方について詳しくは、本誌Vol.118の本連載第63回「PPIとPerl::Tidyを組み合わせて作るコード整形ツール」⁠谷脇真琴著)[1]や本誌Vol.81の本連載第27回「Perlにおける静的解析」⁠川上大喜著)[2]を参照してください。

関数の呼び出し方から特徴を分析

PPIで解析するには、解析対象の特徴を分析することが重要です。呼び出された関数を解析したいので、関数の呼び出し方の特徴を考えていきます。

Perlでは、パッケージを指定して関数を呼び出す方法はさまざまありますが、ここでは一般的な呼び出し方として次の2通りを扱います。

パターン1:ModuleA::func
シンボルテーブルから関数を呼び出す
パターン2:ModuleB->method
ModuleB::method("ModuleB")と等価

パターン1と2から、呼び出される関数は[モジュール名][:: or ->][関数名]となっていることがわかります。

PPIによる静的解析で自動インポートを実現

関数の呼び出し方の特徴を考えたので、この特徴を利用して関数が呼び出されている箇所を解析し、必要なモジュールを抽出して、インポートしていきます。

呼び出された関数を解析する

PPIで関数の呼び出し方をどのように解析すればよいかを知るために、関数の呼び出し方で挙げたパターン1と2をPDOMに変換します。

2パターンの関数呼び出しをPDOMに変換する
my $source = <<'EOS';
ModuleA::func;
ModuleB->method;
EOS

my $doc = PPI::Document->new(\$source);
# PDOMを木構造でダンプする
my $dumper = PPI::Dumper->new($doc);
$dumper->print;
2パターンの関数呼び出しをPDOMに変換した結果
# パターン1:ModuleA::func
PPI::Token::Word 'ModuleA::func'

# パターン2:ModuleB->method
PPI::Token::Word 'ModuleB'
PPI::Token::Operator '->'
PPI::Token::Word 'method'

結果から、単語を表すPPI::Token::Wordを解析すると、インポートが必要なモジュールを抽出できることがわかります。

インポートが必要なモジュールを抽出する

まずは、パターン1のモジュールと関数を抽出します。モジュール名の先頭は大文字で始まるというPerlの規則を利用して、正規表現でモジュールと関数を抽出します。

# $wordはPPI::Token::Wordである
my ($module, $func) = $word =~ /((?:[A-Z]\w*::)+)(\w+)/;
# 末尾に::が付いているので削除
$module = substr($module, 0, -2);

次に、パターン2のモジュールと関数を抽出します。PPI::Token::Wordmethod_callメソッドを利用して、パターン2であることを判定できます。sprevious_siblingで空白など無用なものを除いて、メソッド名の前にあるモジュールを取得します。

my $prev = $word->sprevious_sibling;
if($word->method_call && $prev eq '->') {
  my $module = $prev->sprevious_sibling;
  my $func = $word;
}

以上から、パッケージを指定して関数が呼ばれている箇所を解析し、インポートが必要なモジュールを抽出できました。

抽出したモジュールをインポートする

対象となるファイル上で、インポート済みではないモジュールだけインポートする必要があります。PPIを利用すると、use文やrequire文を表すPPI::Statement::Includeによって、すでにインポートされたモジュールの一覧を取得できます。

インポート済みのモジュールとインポートされていないモジュールの差分をArray::Diffモジュールで計算すると、インポートされていないモジュールに限定できます。

# $filenameは対象となるファイル名
my $doc = PPI::Document->new($filename);
my $incs = $doc->find('PPI::Statement::Include');
my $inc_modules = [ map { $_->module } @$incs ];

# $maybe_need_modulesは抽出したモジュール
my $need_modules = Array::Diff->diff(
  $inc_modules, $maybe_need_modules
)->added;

次に、対象となるファイルにインポートします。インポートするモジュールのuse文をPPIを利用して先頭に挿入することで、必要なモジュールをインポートします。

# PPI::Statement::Includeに変換する
my $create_inc = sub {
my $str = shift;
my $d = PPI::Document->new(\"$str");
$d->find_first('PPI::Statement::Include')->clone;
};
for my $module (@$need_modules) {
my $inc = $create_inc->("use $module;");
$doc->first_element->insert_before($inc);
}
# もとのファイルの内容にuse文が追加された状態
my $formatted = $doc->serialize;

以上から、対象となるファイルに必要なモジュールをインポートした内容を得られました。自動インポート後の結果を利用してもとのファイルを置換すると、use文の漏れを防ぐことができます。

おすすめ記事

記事・ニュース一覧