tmytのらくがき

個人の日記レベルです

CsPscのフォローアップ記事です

久しぶりにアドベントカレンダーというものを書いたのですが、その時にC#をRoslynで解析し、ASTをPostScriptに落としてC#のようなプログラムをプリンタで実行できて楽しいね!!というジョークコンパイラを実装しました。

CSharp-PostScript Comiler 縮めてcspsc は、RoslynでASTをなめて、ツリーをPostScriptに変換するだけのもはやコンパイラか?といわれると怪しいやつです。かっこよく言うと、「C#向けのPostScriptバックエンド」です。かっこつけすぎました。 これは1200行ほどの1ファイルで実装されています。

github.com

アドベントカレンダーではプリンタでプログラムが実行できて楽しいね!!Roslyn便利だね!!ぐらいのところまでにとどめておいたのですが、そこそこちゃんと作ってあるコンパイラで、紹介していない機能がたくさんあったのでせっかくなのでフォローアップという形でこっちに放流します。

文中のコードはコンパイルが通ることを確認しているのでPostScriptにコンパイルしてあほだな~って眺めるのがいいかも1

組み込み属性

PostScriptをC#から生成するにあたって、PostScriptの全命令をC#マッピングするのがめちゃくちゃだるかったので属性でさぼったりしました。そのため、変な属性が2個だけ定義されています。

DllImportAttribute

DllImportAttribute自体は普通のC#にも存在しているんですが、このコンパイラはDllImportAttributeの解釈を大幅にさぼっているので本物より実装が雑です。具体的には、引数を全く解釈していません。

さらに、この属性はRoslynをごまかしてDiagnosisを正しく通過したうえでコンパイル時に関数名をPostScriptの命令に置換するためのマーカーでしかなく、関数名は常に ToLowerCase されます。

// たとえば
[DllImport("何を書いてもいい")]
extern static void ShowPage();

// 空はRoslynがエラーにするのでなんか書いてね
// [DllImport("")]
// extern static void sub();

[DllImport("🔢")]
extern static double Sqrt(double x);

[DllImport("関数名の大文字小文字がめちゃくちゃでもいい")]
extern static void MoVeTo(double x, double y);

PSFontAttribute

せっかくなのでフォントを設定したいと思います。ただ、こんなジョークコンパイラにおいてフォントなんて最初の1回だけ設定できれば十分すぎます。そのため、Assembly属性として設定することでなんかC#っぽい見た目になるな!ということで、Assembly属性を通してページのフォントを設定できます。

// PostScriptフォント名、フォントサイズで渡す
[assembly:PSFont("Courier", 12)]

組み込み関数

組み込み関数がいくつかあります。紹介するまでもないものとしてConsole.WriteLine, Console.Write のシノニムをイメージした WriteLine, Write がありますが本物と違いFormatを受け付けないかつ、string しか受け付けないので比較的さぼった実装になっています。

これらは WriteLine, Write のシノニムです。実はシノニムですらなくて、コンパイル時には __print, __println という関数呼び出しに置換されます。Cのコンパイラだとよくある関数だと思ってたら実はマクロで、libcに置き換わってた!みたいなそういうタイプの呼び出しです。

// これが
println("Hello, World");
// こう
// (Hello, World) __println

__sym

フォントを設定するにはPSFontAttributeを使うと書いてあるのですが、やっぱりPostScriptの全機能をフル活用したいかなあと思いまして、C#からPostScriptで言うところのNameを生成する関数です。PostScriptのNameに該当する機能がC#にはないのですが、RubyJavaScriptでは言えばSymbolが近い概念です。

こんなのいつ使うんだといわれるとかなり微妙なんですが、findfontをDllImportしたときとかに使えます。

[DllImport("x")]
extern static object FindFont(PsSym sym);

FindFont(__sym("Courier"));

__asm

これはもはやC#なのかわからなくなってきましたが、任意の位置に任意のPostScript命令を直接Emitできます。 いわば、PostScript界のインラインアセンブラです。

/*
// 普通に関数定義もできる
int Fib(int n){
  if(n < 2) return n;
  return Fib(n - 1) + Fib(n - 2);
}
*/
// けどインラインアセンブラで書いたほうがオーバーヘッドが少なくて高速
[DllImport("x")]
extern static int Fib(int n);
__asm("/fib { dup 1 gt { dup 1 sub fib exch 2 sub fib add } if } def");

println(Fib(10)); // 55

参考例にもあるように、__asm で関数定義した場合C#側にはシンボル情報がやってこないのでマッピングのためにDllImportを書きます。この時順番はなんでもいいです。

ref/in/out引数、Tuple引数、Tuple戻り値

アドカレではそんなに言及してないんですが、真面目にコンパイラが実装されているのでref/in/out 引数に無駄に対応しています。 さらに、Tuple構造体がない代わりに、Tuple記法に対応しているのでNamedTupleの定義、分解ができます。

ref/in/out

ref/in/out は引数を渡すときに辞書に包んで関数を呼び、関数は引数がref/in/outの時に辞書から取り出してローカル変数を定義し、関数から抜けるとき辞書に書き戻す。そして呼び出し元は辞書から取り出してローカル変数に書き戻す。という手順を踏んでいます。

つまりこれ、Boxing/Unboxingですね!PostScriptの辞書を使ってref/in/out 指定された変数をBoxing/Unboxingすることで参照渡しをなんだかうまいこと実現できているのです。

% void X(out int n) { n = 10; }
% X(out var k);
/X {
  2 dict begin
    /n_ref exch def
    /n n_ref /value get def
    mark {
      /n 10  dup 3 1 roll store
      pop
    } stopped { pop } if
    cleartomark n_ref /value n put
    end
  }
def
/k 0 def
/k_ref << /value k >> def
k_ref X /k k_ref /value get store

この参照渡し駆使すると地獄のようだけどValidな構文が出来上がって地獄だった。例えば

int n(ref int a, out int b, int c){
  return (b = a += c) * 2;
}
int x = 1;
x = n(ref x, out var y, 3); // 8 

refのaにcを足したものをoutのbに代入したものを2倍したものがnの戻り値でそれをもとのxに代入するというレビューで見たら発狂しそうな実装なんですが、なんかこれたまたま動きます。びっくりですね。

さらにここにTupleを融合させます。

(int a, int b) m(ref int k, out (int y, int z) t){
    t = (k += 2, k *= 3);
    return (t.y, t.z * k);
}

int x = 10;
var t1 = m(ref x, out var t2);
println($"{x}, {t1.a}, {t1.b}, {t2.y}, {t2.z}"); // 36, 12 1296, 12, 36

書いてて何かいてんのかわからないぐらい狂気なので詳細は避けますが、検算したところおそらく出力は正しく、C#の評価順序とも一致した正しい挙動になっていそうです。正直ここまでちゃんと動くとは思ってなかった。

Roslynのごまかしかた

Roslynもこんな使い方をされるとは思ってなかった気がします。ただPostScriptを出力するためにいい感じにASTを吐いてほしいし、なんならC#の構文としておかしいものは事前に全部エラーにしてほしい、ただそうすると組み込み関数とかもエラーにされると困る。ので適当なクラスをでっちあげてRoslynをごまかします。

using System.Runtime.InteropServices;

namespace CsPsc.Preamble
{
    public static class Intrinsics
    {
        [DllImport("cspsc_intrinsics", EntryPoint = "println")]
        public static extern void println(string s);

        [DllImport("cspsc_intrinsics", EntryPoint = "print")]
        public static extern void print(string s);

        [DllImport("cspsc_intrinsics", EntryPoint = "__asm")]
        public static extern void __asm(string s);

        [DllImport("cspsc_intrinsics", EntryPoint = "__sym")]
        public static extern PsSym __sym(string name);
    }

    [System.AttributeUsage(System.AttributeTargets.Assembly)]
    public class PSFontAttribute : System.Attribute
    {
        public string FontFace { get; }
        public int FontSize { get; }

        public PSFontAttribute(string fontFace, int fontSize)
        {
            FontFace = fontFace;
            FontSize = fontSize;
        }
    }

    public class PsSym { }
}

この名前空間をツリーのパース時にだけusing static Preamble.Intrinsics するようにしてあります。その結果Roslynは正当なC#の呼び出しだと思っているのにその先ではILを1文字もEmitせずに、全部PostScriptしか吐かない。というジョークコンパイラとして成り立つ仕組みになっていました。

正直まさかここまでうまくPostScriptにコンパイルできるとも思ってなかったのにRoslynすごいなーPostScriptすごいなーとかおもって適当に作ってたらそこそこにC#が動く、可動式ジョークとしていいポイントに収まったんじゃないかなと思っています。

Roslynで嘘コンパイラ作るの楽しいからみんなもやってみるといいよ

github.com


  1. 実行はWebエディタを用意してあるのでそこで試してみるのが手っ取り早いです。CsPsc Playground