Blog::kobaken

prove t/foo/bar/baz.t

XS::Parse::Keyword でPerlの文法を拡張をする

この記事はPerl Advent Calendar 2025 19日目の記事です。

昨日は、id:mackee_wXS::Parse::Infix::FromPerlで勝手にパイプライン演算子を追加する でした。Pure Perl演算子を追加できるなんて最高にPerlっぽい内容でした。今日は、この記事の続きのような内容で、Pure Perlではなく、XSを利用して文法を拡張する話です。id:polamjagYAPC::Fukuokaの発表の通り、いつXSを書くかわかりませんからね。では早速やっていきましょう。

polamjag.hatenablog.jp


解説題材のSyntaxモジュール紹介

解説題材は、拙作のSyntax::Keyword::Assert にします。このSyntaxモジュールは、次のようにassertキーワードを提供します。assert関数ではないです。Syntaxモジュールとは、まだ通称になっていないと思いますが、keyword-pluginを用いて、Perlの文法拡張を行うモジュールのことを指しています。新たにキーワードを追加する場合は、Syntax::Keyword::空間、演算子を追加する場合は、Syntax::Operator::空間に追加されることが多いです。*1

use Syntax::Keyword::Assert;

sub div($x, $y) {
   assert($y != 0);
   $x / $y
}

このassert キーワードは、次のコードと等価となります。(等価となるようにPerlコンパイラに指示をしています。)

PERL_ASSERT_ENABLED 環境変数 が無効な時、assertに関する分岐処理が無くなり、実行時コストを完全にゼロにできるのが特徴です。assert関数だった場合はこうはいきません。

# PERL_ASSERT_ENABLED 環境変数 が有効な時
sub div ($x, $y) {
   unless ($y != 0) {
      die "Assertion failed ($y)"
   }
   $x / $y
}

# PERL_ASSERT_ENABLED 環境変数 が無効な時
sub div ($x, $y) {
   $x / $y
}

もしPure Perlで同様のことをしたい場合、定数畳み込みを活用すれば同じ機能を実現できます。けれど、これは冗長です。

use constant ASSERT_ENABLED => $ENV{PERL_ASSERT_ENABLED};

sub div ($x, $y) {
   if (ASSERT_ENABLED) {
     unless ($y != 0) {
        die "Assertion failed ($y)"
     }
   }
   $x / $y
   }
}

一般化すれば、最も高い効果を最も簡潔に表現しうる黒魔術モジュール = Syntaxモジュールです。 異論はあると思います。


文法拡張はどうやったら追加できるのか?

黒魔術と言っていますが、ヘルパーのXS::Parse::Keyword を利用すれば、敷居は高くないです。Claude Code (Opus 4.5) への依頼も十分できます。

文法拡張の方法をAssert.xs を抜粋して解説していきます。

1. ブートストラップ処理

まず、ブートストラップ処理(起動処理)について解説します。

# Assert.xs
MODULE = Syntax::Keyword::Assert    PACKAGE = Syntax::Keyword::Assert

BOOT:
  boot_xs_parse_keyword(0.36);

  XopENTRY_set(&xop_assert, xop_name, "assert");
  XopENTRY_set(&xop_assert, xop_desc, "assert");
  XopENTRY_set(&xop_assert, xop_class, OA_UNOP);
  Perl_custom_op_register(aTHX_ &pp_assert, &xop_assert);

  register_xs_parse_keyword("assert", &hooks_assert, NULL);

...

MODULE = Syntax::Keyword::Assert PACKAGE = Syntax::Keyword::Assert

XSファイルの標準的な宣言で、このコードが属するモジュールとパッケージを定義しています。

BOOT:

BOOT: はモジュールがロードされた時に一度だけ実行される初期化コードです。

boot_xs_parse_keyword(0.36);

Paul Evans氏の XS::Parse::Keyword モジュールを初期化しています。このモジュールはPerlパーサーにフックして新しいキーワードを追加するためのフレームワークです。

XopENTRY_set(&xop_assert, xop_name, "assert"); # デバッグやイントロスペクション用のOP名を登録
XopENTRY_set(&xop_assert, xop_desc, "assert"); # OPの説明文字列を登録。(雑な説明ですね😀)
XopENTRY_set(&xop_assert, xop_class, OA_UNOP); # OA_UNOP(単項演算子)を指定して、引数を1つ取ることを登録
Perl_custom_op_register(aTHX_ &pp_assert, &xop_assert); # `pp_assert` 関数をカスタムOPとして登録

xop_assert は XOP 構造体で、OPのメタデータを保持します。XOP (eXtended OP) は Perl 5.14 で導入された構造体で、カスタムOPのメタデータを保持します。これによって、B::Conciseなどのデバッグツールが意味のある情報を表示できます。

pp_assert は実際の処理を行う関数(PP = Push/Pop、Perlのスタックマシン操作)です。Perlの各演算子は対応するPP関数を持ちます。例えば、== 演算子(数値等価比較)はコチラで定義されいます。

register_xs_parse_keyword("assert", &hooks_assert, NULL);

ブートストラップ処理の最後に、パーサーに assert キーワードを登録しますhooks_assert はパース時のコールバック関数群を含む構造体で、以下のようなフックを定義します:

  • parse - キーワード後の構文をパース
  • build - パース結果からOPツリーを構築

処理の流れをまとめると次のようになります。

Perlコード: assert($x > 0);
    ↓
パーサーが "assert" を認識 (register_xs_parse_keyword)
    ↓
hooks_assert でパース処理
    ↓
OPツリー構築時に pp_assert を使用 (Perl_custom_op_register)
    ↓
実行時に pp_assert が呼ばれる

2. パーサーフックの設定

hooks_assertに、assert キーワードの構文をどのようにパースするかを定義しています。このフックは全体で3つのパートに分かれています。

static const struct XSParseKeywordHooks hooks_assert = {
  .permit_hintkey = "Syntax::Keyword::Assert/assert",  // 1. キーワードを有効にする条件
  .pieces = (const struct XSParseKeywordPieceType[]) { // 2. 構文の定義
    XPK_ARGS(
      XPK_TERMEXPR_SCALARCTX,
      XPK_OPTIONAL(XPK_COMMA),
      XPK_TERMEXPR_SCALARCTX_OPT
    ),
    {0}
  },
  .build = &build_assert, // 3. OPツリーを構築する関数
};

permit_hintkey = "Syntax::Keyword::Assert/assert",

このキーワードが有効になる条件を指定します。Perlのレキシカルヒント(%^H)にこのキーが存在するときだけ assert がキーワードとして認識されます。Assert.pm 側のimport処理周りで、このキーの上げ下げをします。

.pieces = (const struct XSParseKeywordPieceType[]) {
    XPK_ARGS(
        XPK_TERMEXPR_SCALARCTX,          // 必須: 条件式
        XPK_OPTIONAL(XPK_COMMA),         // 省略可: カンマ
        XPK_TERMEXPR_SCALARCTX_OPT       // 省略可: メッセージ
    ),
    {0}  // 終端
},

この構文定義で、assert(条件) または assert(条件, カスタムメッセージ)という構文を指定しています。

.build = &build_assert,

パースが完了した後、OPツリーを構築する関数へのポインタです。

3. OPツリーを構築する

パース処理ができたら、次はOPツリーの構築をします。次は、Syntax::Keyword::Assert のOPツリー構築処理からカスタムエラーメッセージの処理を抜いたものです。

assert(EXPR) と書いた場合に、assert_enabledがtrueならpp_assertを実行するOPツリーを作り、falseなら何もしないOPツリーを作っています。

static int build_assert(pTHX_ OP **out, XSParseKeywordPiece *args[], size_t nargs, void *hookdata)
{
    OP *condop = args[0]->op;

    // アサーション有効 → 実際にチェックするOPを作成
    if (assert_enabled) {
          // Other expressions: use pp_assert
          *out = newUNOP_CUSTOM(&pp_assert, 0, condop);
    }
    // アサーション無効 → 何もしないOPを作成
    else {
        // do nothing.
        op_free(condop);
        *out = newOP(OP_NULL, 0);
    }

    return KEYWORD_PLUGIN_EXPR;
}

...

static int build_assert(
    pTHX_                        // Perlインタプリタコンテキスト(マルチスレッド用)
    OP **out,                    // 出力: 構築したOPを格納する場所
    XSParseKeywordPiece *args[], // パース結果の配列
    size_t nargs,                // 引数の数
    void *hookdata               // register時に渡したユーザーデータ(今回はNULL)
)

まず、関数シグネチャはこの通りです。argsとoutが肝です。

OP *condop = args[0]->op;

pieces の定義を思い出すと、次の通りなので、assert($ok) の場合、args[0]->op は $ok を表すOPツリーです。

XPK_ARGS(
    XPK_TERMEXPR_SCALARCTX,      // args[0] ← これ
    XPK_OPTIONAL(XPK_COMMA),     // args[1]
    XPK_TERMEXPR_SCALARCTX_OPT   // args[2]
)

*out = newUNOP_CUSTOM(&pp_assert, 0, condop);

newUNOP_CUSTOM はカスタム単項OP(Unary Operator)を作成するPerl内部関数です。実行時にまず、子OPのcondopを実行し、assert($x > 0) なら、$x > 0 の結果をスタックにPushします。次に op_ppaddr (pp_assert) を呼び出します。このスタックの値がFalseならcroakします。

4. 処理の実態

Parseし、OPツリーを組み立てたら、最後に実処理です。Syntax::Keyword::Assert の実処理は次のようになっています。

static XOP xop_assert; // OPのメタデータ(名前、説明など)
static OP *pp_assert(pTHX) // 処理の実態
{
  dSP;
  SV *val = POPs; // 1. スタックから値を取得

  if(SvTRUE(val)) // 2. 真ならそのまま返す
    RETURN;

  // 3. 偽ならエラーメッセージを作成してcroak
  SV *msg = sv_2mortal(newSVpvs("Assertion failed ("));
  sv_catsv_unqq(msg, val);
  sv_catpvs(msg, ")");
  croak_sv(msg);
}

これで、パース、OPツリー構築、実処理と一通りのコードを追うことができました。

最後に

XS::Parse::Keywordを利用して、Perlの文法を拡張する方法を Syntax::Keyword::Assertを題材にして解説しました。複雑な文法の拡張になれば、もちろん理解、実装の難度は上がるのは確かですが、今回の解説で処理の流れが掴みやすくなったら嬉しいです!より詳しいことは、コチラのリポジトリを見てみてください!

github.com

*1:もちろんObject::Padのような例外はあります。複合的な文法拡張の場合はこうなっている印象です。