Perl スクリプトに assertion code を埋め込みたいのだが。。。2006年10月03日 18時11分30秒

それっぽいモジュールは存在するのだが。。。

一応 Test::Harness::Assert というのが現行の Perl には標準ライブラリとして備わっている。ちなみに、Carp::Assert なんてのも存在していて、(エーゴが苦手なりに) 流し読んでみた感触からしてこっちの方がやりたいことに近いようではあるのだが、残念ながらこちらは標準ライブラリではないため、自分が書いたプログラムのユーザーにモジュールのインストールを要求しなければならないことになり、プログラムの配布を考えている場合にはあまり現実的ではない。

Test::Harness::Assertassert (3) (所謂 ANSI C の assert マクロ) に似たインタフェースを提供してくれるようだが、assert (3) と違って本番リリース向けにはコンパイルの対象から外されるということは無い。assertion check はして欲しいが、常にして欲しいわけではなく、開発時にのみ必要なのである。しかしながら、プログラムリリースに向けて assertion code を取り除いたバージョンを生成する、といった煩わしい作業は、できれば避けたい。

プリプロセッサを利用する方法

まず、徒労に終わった調査結果を一つ示しておこう (有益な情報を得たい人はこのセンテンスは読み飛ばすべし)。Perl の -P オプションに目を付けてみた。こいつは C コンパイラのプリプロセッサを実行してから、通常の Perl による処理を行う、というもの。これによって、#if#endif#define などといったディレクティブが使えるようになる。例えば、以下の簡素なテストコードを考えてみる。

use strict;
use Test::Harness::Assert;

#ifdef _DEBUG

assert(0);  # Assertion faild!!

#endif

このプログラムは、以下のように動作する。

  • 普通に実行すると、assert 関数が実行され、Assertion check に失敗して例外を発生する。
  • perl -P test.pl とした場合、#ifdef#endif の間はプリプロセッサによって読み飛ばされるためにコンパイルされず、assert 関数は実行されない。

一見上手く動いているように見えるが、重大な問題が 2 点ほどある。

  • Windows 環境においては、C 開発環境がインストールされていることを期待せねばならない (つまり、普通の Windows ユーザーは perl -P動作させることすらできない)。ActiveState は gcc 互換のプリプロセッサまで提供してくれるわけではないようだ。
  • プリプロセッサの起動に時間がかかる。そもそもリリース時の動作を重くしたくないからこういうことを試しているのに、本末転倒である。尤も、1回の処理時間が非常に長いプログラムであれば、プリプロセッサの起動時間を考慮しても有益な対処法となりうる可能性はある。

自分で実装する

そんなわけで、究極的には結局こういう結論に至ることになる。

まず、方針を立ててみよう。

  1. Perl で記述する以上、記述したコードはコンパイルされることは避けられない。そこで、assertion check を呼び出すインタフェースとなる関数自体はコンパイルされても仕方ないものとする。
  2. しかしながら、assertion check の内容となる比較演算のコードについては、本番リリース時には実行されないのはもちろん、コンパイルすらできればされないで欲しい

上記の 2 を満たすために、Perl の eval 関数が文字列を受け取る場合、文字列として記述された Perl コードは実行フェーズにおいてコンパイルされる という性質を利用することができる。

次のコードを考えてみよう。

use strict;

our $my_debug_flag = 1;     # 実際には、十分ユニークな変数名を使用する。
                            # また、コマンドラインオプションなどによって
                            # 外部から指定可能なようにするべきである。

sub assert ($) {
    $my_debug_flag  or return;
    eval $_[0];
    $@  and die $@;
}

my $vari = 1;

assert <<ENDLINE;

    $vari == 0  or die "Assertion faild!!";     # 必ず失敗する

ENDLINE

ありがたいことに Perl は Javascript とは違って、文字列として記述し、eval に渡された Perl スクリプトであっても、スコープは有効である。これがクロージャに対しても同様 (つまり、静的スコープも保持される) であることは、以下のコードによって検証できる。

2006/10/4 1:04AM 訂正: ヒアドキュメント内で変数が展開されるので、結果スコープが生きているように見えるだけである。実際にはナマの値が埋め込まれたテキストが eval される為、例えば関数にオブジェクトなどのリファレンスを渡すような記述はできない
use strict;

our $my_debug_flag = 1;

sub assert ($) {
    $my_debug_flag  or return;
    eval $_[0];
    $@  and die $@;
}

sub test_func ($) {
    my $val = shift;
    my $closure = sub {
        assert <<ENDLINE;
            0 <= $val && $val < 128     or die "Assertion faild!!";
ENDLINE
        print "val = $val\n";
    }
}


my $c1 = test_func 0;
my $c2 = test_func 75;
my $c3 = test_func 130;

&$c1;
&$c2;
&$c3;   # ←こいつは失敗するはず

と、ここまで書いた内容をただ読んでいる人にしてみれば、この方法は実にうまくいっているように見えるかもしれないが、実際には重大な欠陥がある。上記のコードを実行すると出力結果は以下のようになるが、これが示してくれているように、この方法では、assertion check に失敗した箇所が分からないのである。

$ perl test.pl
val = 0
val = 75
Assertion faild!! at (eval 3) line 1.
$

この問題を回避するには更に一工夫が必要だ。その解決手段を示す実証コードが以下に示すものとなる。

use strict;

package MyException;    # 実際にはもちっとマシな名前をつけよう ;)
use overload '""' => \&what;

sub throw {
    my ($invocant, $what) = @_;
    my $class = ref $invocant || $invocant;
    
    my $self = { 'what' => $what };
    
    die bless $self, $class;    # 値は返さず即座に例外を飛ばす
}

sub what {
    my $self = shift;
    
    $self->{'what'}
}


package main;

our $my_debug_flag = 1;

sub assert ($) {
    $my_debug_flag  or return;
    my (undef, $file, $line) = caller;
    eval $_[0];
    if (ref $@ eq 'MyException'){
        print STDERR "$0: Assertion faild ($@) at $file line $line\n";
        exit 255;
    }
    elsif ($@) {
        die $@;
    }
}

sub test_func ($) {
    my $val = shift;
    my $closure = sub {
        assert <<ENDLINE;
            # \ を 3 つ書くところがポイント
            0 <= $val && $val < 128
                or throw MyException "over limit (\\\$val == $val)";
ENDLINE
        print "val = $val\n";
    }
}


my $c1 = test_func 0;
my $c2 = test_func 75;
my $c3 = test_func 130;

&$c1;
&$c2;
&$c3;   # 失敗しやがれっ

出力結果は以下の通り。

$ perl test.pl
val = 0
val = 75
test.pl: Assertion faild (over limit ($val == 130)) at test.pl line 42
$

例外オブジェクトを生成するということで、一見すると重い処理を行っていて効率悪そうに見えるが、実際にはデバック実行時にしか生成されないので、動作コストを考慮しても悪く無いソリューションであると思えるのだが、どうだろうか? もっとも、Perl スクリプトを色付けしてくれるエディターでは、ヒアドキュメントは一色にまとめられることが少なくないため、プログラムとしては若干見づらいという意見もあるかもしれないが。。。


Wed Oct 4 01:01:23 JST 2006 - 追記

一部訂正。。。この方法を用いる場合、検証コード内では関数、メソッドへのリファレンス渡しができません。更に言うと、オブジェクトのインスタンスメソッド呼び出しすらできません

んで、代替策を検討の末、結局こんな感じに落ち着きますた。

use strict;

our $my_debug_flag = 1;

sub assert ($) {
    $my_debug_flag  or return;
    eval { $_[0]->() };
    if (ref $@ eq 'MyException'){
        print STDERR "$0: Assertion faild ($@) at $file line $line\n";
        exit 255;
    }
    elsif ($@) {
        die $@;
    }
}

sub test_func ($) {
    my $val = shift;
    my $closure = sub {
        assert sub {
            0 <= $val && $val < 128 or die "over limit (\$val == $val)";
        };
        print "val = $val\n";
    }
}


my $c1 = test_func 0;
my $c2 = test_func 75;
my $c3 = test_func 130;

&$c1;
&$c2;
&$c3;   # 失敗しやがれっ

eval にブロックを渡し、そのブロック内で、assert 関数の引数に渡された無名関数を実行する。検証コードはコンパイルはされちゃうが、デバッグモードでなければ実行はされないという寸法。簡潔のため、例外オブジェクトの部分もばっさり削ってますが、入力にファイルを扱う場合など、もっと情報が欲しい場合には、例外オブジェクトを併用すること自体は悪くないと思う。ってなんだか言い訳がましいな(汗。

ちなみに、出力結果はこんな感じ。

$ perl test.pl
val = 0
val = 75
over limit ($val == 130) at test.pl line 17.

しかし効率はどうなんだろう。。。毎回クロージャを生成することになるわけだよなぁ。うーん。。。