この記事はPerl Advent Calendar 2025 19日目の記事です。
昨日は、
id:mackee_w のXS::Parse::Infix::FromPerlで勝手にパイプライン演算子を追加する でした。Pure Perl で演算子を追加できるなんて最高にPerlっぽい内容でした。今日は、この記事の続きのような内容で、Pure Perlではなく、XSを利用して文法を拡張する話です。
id:polamjag のYAPC::Fukuokaの発表の通り、いつXSを書くかわかりませんからね。では早速やっていきましょう。
解説題材の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を題材にして解説しました。複雑な文法の拡張になれば、もちろん理解、実装の難度は上がるのは確かですが、今回の解説で処理の流れが掴みやすくなったら嬉しいです!より詳しいことは、コチラのリポジトリを見てみてください!
*1:もちろんObject::Padのような例外はあります。複合的な文法拡張の場合はこうなっている印象です。