マクロを作る


コンパイル時計算

原点に立ち戻り, Hello World プログラムを思い出しましょう。マクロを利用した Hello World プログラムを再掲します。

using System.Console;

macro HelloWorld()
{
   WriteLine("Hello, world!");
   <[ ]>;
}
HelloWorld();

このプログラムは,最初のコードをマクロライブラリーとしてコンパイルし,後のコードをマクロライブラリーを参照してコンパイルすると,コンパイル成功時に "Hello, world!" とメッセージを表示するのでした。そしてコンパイルされたプロセスアセンブリを実行しても何も起こらないのでした。 "Hello, world!" の出力は WriteLine によるものなのは明らかです。では <[ ]> は何でしょうか。

macro の記述方法を見ると概ね関数に見えます。関数だとみなすと,関数は最後に評価された値が返り値になるので, <[ ]> が返り値だと予想できます。マクロが概ね関数というのは間違っておらず,実際関数のように処理されます。

Nemerle の式は <[ ]> で囲うことで,プログラムの一部ではなく,式を表すオブジェクトになります。つまり <[ ]> は Nemerle の式のリテラル表現です。文字列が "" で空文字列を表すように, <[ ]> も単なる空の式を表していたのです。

次のように実際の式を <[ ]> で囲ってみたらどうなるでしょうか。

using System.Console;

macro HelloWorld()
{
   WriteLine("Hello, world!");
   <[ WriteLine("Hello, again."); ]>;
}

これをマクロライブラリーとして再コンパイルして使います。

ncc.exe /t:library /o:hello-macro.dll /r:Nemerle.Compiler.dll hello-macro.n
ncc.exe /o:hello-main.exe /m:hello-macro.dll hello-main.n

コンパイル時に "Hello, world!" と出力されたののは最初と変わりません。では,出力されたプロセスアセンブリを実行するとどうなるでしょうか。

hello-main.exe

今度は "Hello, again." と表示されたはずです。これは明らかに,マクロ中に付け加えたコード断片である WriteLine("Hello, again."); が実行された結果です。メインコード中のマクロ呼び出しが,マクロの返り値で置き換えられたと容易に想像できるでしょう。

以上をまとめると以下の通りです。

  • マクロのコードはコンパイル時に実行される。
  • <[ ]> で囲まれたコード断片は,マクロ呼び出し元に置き換えられる。

コンパイル時にコードが置き換えられるだけでは直接コードを書くのとそれほど大きな違いはありません。コンパイル時に計算させる内容をコード側からパラメーターとして与えたいし,コンパイル時に計算した結果をコードで利用したいです。前者は単純に macro のパラメーターを定義すればよく,後者は計算結果を変数束縛しコード断片中で参照すれば良いです。コード断片中で外部の変数を参照するには,スプライス文字列と同じように $ を付けます。

macro CompileTimeSqrt(x : double)
{
   def root = System.Math.Sqrt(x);
   <[ $root ]>;
}
def sqrt2 = CompileTimeSqrt(2.0);
def sqrt3 = CompileTimeSqrt(3.0);

新規構文

前の章で説明したように forwhen といった構文はマクロによるものです。 Nemerle ではこのような新規構文を定義するのは容易です。新規構文を定義するには syntax キーワードを用います。必要なトークンと式をカンマ区切りにして与えてやるだけです。

[Record]
public struct Interval[T]
{
   private Lower : T;
   private Upper : T;
}
macro IntervalLiteral(lower, upper)
syntax ("interval", "(", lower, "to", upper, ")")
{
   <[ Interval($lower, $upper) ]>;
}
_ = interval (1 to 3);  // Interval(1, 3) と同じ
_ = interval (0.5 to 1.5);  // Interval(0.5, 1.5) と同じ

注意すべき点として,構文に括弧を使う場合,その括弧は対応づいている必要があります。また,括弧で始まる構文も定義できません。つまり次のような文法定義は不正です。

macro InvalidSyntax(foo)
syntax ("[(", foo, "])")
{
   // 文法が括弧で始まっているし括弧の対応も間違っている
}

こういった不正な文法を定義したマクロを参照して不正な文法を記述するとコンパイルエラーになります。しかし不正な文法を定義するマクロライブラリー自身は正常にコンパイルできてしまうので注意が必要です。

構文の一部が省略可能な文法を定義したい場合は,次のように Optional を使います。

macro Log(x, logBase)
syntax ("log", Optional("[", logBase, "]"), "(", x, ")")
{
   match (logBase)
   {
      | null => <[ System.Math.Log($x); ]>;
      | _ => <[ System.Math.Log($x, $logBase); ]>;
   }
}
_ = log[10](System.Math.E);  // System.Math.Log(System.Math.E, 10);
_ = log(10);  // System.Math.Log(10);

型操作

マクロを使うことでクラスや関数の定義をコンパイル時に修正することができます。例えばレコードマクロはコンパイル時にフィールドに値を設定するためのコンストラクターを生成します。また,契約プログラミングマクロは,コードが満たすべき性質を本処理と異なる場所に記述し,コンパイル時にその条件を例外処理としてコード中に差し込むという処理を行っています。

.NET Framework の属性が AttributeUsage 属性によりその属性がメタデータを付与することができる対象を指定できます。属性と同様にマクロも MacroUsage 属性でコンパイル時の処理対象を MacroTargets 列挙型により指定することができます。マクロが処理できる対象は以下の 7 つです。

MacroTargets 列挙型の値 対象
Assembly アセンブリ
Class 型 (クラス,インターフェイス,バリアント,…)
Method 関数・メソッド
Field フィールド
Property プロパティ
Event イベント
Parameter パラメーター

さらに MacroUsage 属性には,対象が型付けのどの段階 (フェーズ) で処理されるかを MacroPhase 列挙型により指定する必要があります。フェーズは以下の 4 通りあります。

MacroPhase 列挙型の値 フェーズ
None 指定なし
BeforeInheritance 型付けプロセスの始まる前で,抽象構文木としての情報のみを持っている状態
BeforeTypedMembers 対象が実際の型・メンバーとしてリンクされる前
WithTypedMembers 型付けの最後の段階

MacroUsage 属性に必須なパラメーターは MacroTargetsMacroPhase の 2 つですが, AttributeUsage 属性と同様に,そのマクロが派生型に継承されるかを示す Inherited や,同じマクロを同じ対象に複数使用できるかを示す AllowMultiple が指定できます。

マクロは,それぞれの対象・フェーズに応じて,以下の型付け情報をパラメーターとして持ちます。

MacroTargets MacroPhase
BeforeInheritance BeforeTypedMembers WithTypedMembers
Assembly なし
Class TypeBuilder TypeBuilder
Method TypeBuilder,
ParsedMethod (Nemerle.Compiler.Parsetree.ClassMember.Function)
TypeBuilder,
MethodBuilder
Field TypeBuilder,
ParsedField (Nemerle.Compiler.Parsetree.ClassMember.Field)
TypeBuilder,
FieldBuilder
Property TypeBuilder,
ParsedProperty (Nemerle.Compiler.Parsetree.ClassMember.Property)
TypeBuilder,
PropertyBuilder
Event TypeBuilder,
ParsedEvent (Nemerle.Compiler.Parsetree.ClassMember.Event)
TypeBuilder,
EventBuilder
Parameter TypeBuilder,
ParsedMethod,
ParsedParameter (Nemerle.Compiler.Parsetree.PParameter)
TypeBuilder,
MethodBuilder,
ParameterBuilder (Nemerle.Compiler.Typedtree.TParameter)

括弧が付いた型は,括弧内の型が実際の型です。

必須パラメータの他に,さらに必要に応じてマクロごとのパラメーターを与えることもできます。

では,実際の実装例を見てみましょう。

using Nemerle;
using Nemerle.Compiler;

[MacroUsage(MacroPhase.BeforeInheritance, MacroTargets.Method)]
macro IgnoreImplementation(_ : TypeBuilder, method : ParsedMethod)
{
   method.Body = <[ ]>;
}

<[ ]> は空の式を表すので,このマクロは関数の本体を空の式にするという風に読めます。実際その通りに動作します。実際にその通りに動作するかをチェックするには,例えば次のような関数を定義して呼んでみればわかります。

[IgnoreImplementation]
public PrintMessage(name : string) : void
{
   System.Console.WriteLine($"Hello, $name.");
}

これをうまく応用すれば, 未実装マクロが作れますね。

もう少し長い例を挙げてみます。

using System.Threading;
using Nemerle;
using Nemerle.Compiler;

public interface IOrdered
{
   Order : long { get; }
}
   
[MacroUsage(MacroPhase.BeforeInheritance, MacroTargets.Class, Inherited = false, AllowMultiple = false)]
macro InstantiationOrder(builder : TypeBuilder)
{
   builder.AddImplementedInterface(<[ IOrdered ]>);
   def counterField = Macros.NewSymbol("counter");
   <[ decl:
      private static mutable $(counterField.Id : usesite) = 0L;
   ]>
   |> builder.Define;
   def orderField = Macros.NewSymbol("order");
   <[ decl:
      [RecordIgnore]
      private $(orderField.Id : usesite) : long = Interlocked.Increment(ref $(counterField.Id : usesite));
   ]>
   |> builder.Define;
   def orderProperty = Macros.NewSymbol("Order");
   <[ decl:
      private $(orderProperty.Id : usesite) : long
         implements IOrdered.Order
      {
         get { $(orderField.Id : usesite); }
      }
   ]>
   |> builder.Define;
}

これは IOrdered インターフェイスを自動実装するマクロです。コード中の <[ decl: ... ]> はフィールドや関数の宣言をしています。宣言は式ではないので,このような特別な記法が用意されています。 Macros.NewSymbol はその名の通りシンボルを作成する関数で,ユニークな名前のシンボルを作成するのに使用します。

実際に以下のように使用できます。

using System.Console;
using Nemerle;
using Nemerle.Utility;

[Record]
[InstantiationOrder]
public class Person
{
   [Accessor]
   private name : string;
   [Accessor]
   private age : int;
}

def alice = Person("Alice", 23);
def bob = Person("Bob", 45);
def charles = Person("Charles", 67);

alice : IOrdered |> _.Order |> WriteLine;  // 1
bob : IOrdered |> _.Order |> WriteLine;  // 2
charles : IOrdered |> _.Order |> WriteLine;  // 3

オブジェクトが生成された順番に Order プロパティがインクリメントされているのがわかります。