tl;dr
- C#のサブセットを定義した。そしてRoslynを活用してPostScriptへのコンパイラを実装した
- コンパイラサービスをHTTPで実装し、Cloudflare Containersで公開した
- プリンタは貴重な計算資源!ご家庭のプリンタにもっと計算させよう!!!
はじめに
「PostScriptはチューリング完全である」これはとても有名な話です。PostScriptといえばPDFの前身であるデータフォーマットのようなものと思われているかもしれません。
PostScriptはページ記述言語として設計されました。これはプリンタに対して印刷するべき内容を定義し、プリンタの動作を制御するためのものです。ここまでだとプリンタに送るジョブデータのフォーマットに見えます。
ですがその実態はスタックベースの仮想マシン(スタックVM)で、変数が定義できたり、条件分岐やループなどの制御構文が使えます。これはもうほとんど汎用プログラミング言語1と言えます。
あるとき、自宅でゴロゴロしているときにそういやPostScriptってチューリング完全だったよなぁ…WASMがPostScriptにコンパイルできたらC++やGoなどで実装したプログラムがプリンタ上で動いておもしろいんじゃないか…そう思い立ち、WASMからPostScriptへの変換についてちょっと調べてみました。
しかし、WASMは低レベルVMでありながらモジュールシステム、関数テーブル、豊富な命令セット、線形メモリというメモリモデルの違いなどを持つ複雑な仮想マシンです。
特に高級言語が出力したWASMをPostScriptで実行することを考えると、PostScript上にWASM VM全体を実装するのとほとんど変わりがない複雑さでした。
一見すると簡単そうに見えるスタックVMからスタックVMへの変換ですが実際はかなり大変でした。
そこで、もっと高レベルなプログラミング言語から直接PostScriptにコンパイルすることを考えました。結果的に、スタックVMからスタックVMへの変換よりももっと高レベルな言語からのコンパイルのほうがはるかに簡単であるようでした。
特にC#ではRoslynという強力なコンパイラサービスが提供されています。これを用いることでC#のコードを正確に解釈し、型推論済みのASTを簡単に取得できます。このASTを活用することで、C#のサブセット言語を簡単に定義し、PostScriptへのコンパイラを実装することができました。
Roslyn is なに
C#を直接PostScriptにコンパイルするにあたって、Roslynとその生成されるASTを活用しました。
RoslynはC#のコンパイラの各ステージをAPIとして提供し、静的解析ツールや、リファクタリングツールなどが最新のC#の構文を正確に理解できるようになること(とか)を目指して公開されている一連のコンパイラサービスです。
RoslynはC#のコードを当然ですが正確に解釈します。なぜなら我々が普段使っているC#のコンパイラはRoslynを使って実装されているからです。
そして、RoslynはC#のコードから正確に型をトラッキングできます。これも普段使っているC#のコンパイラと同じだからです。
たとえば、Roslynでは次のコードを正確に解釈し、A の型が string であるとわかります。
string f() { return "A"; }
var A = f();
そして、このコードを解釈したRoslynは利用者にはASTという形で提供します。
このASTとは日本語では抽象構文木と呼ばれるもので、プログラムのソースコードを意味のある単位でトークンに分解し、構文として成立する形にまとめ、木構造で表したものです。極論、ASTがあればどんなプログラムでも実行ができるそういったものです。
通常ASTを得るには、字句解析・構文解析を経る必要があります。字句解析というのは簡単に言えば文字の区切りやまとまりがどこかを解析すること。構文解析というのはそのまとまりが言語仕様を満足した並び順になっているかを解析することです。加えて意味解析をすることで、ASTに型情報やスコープ情報を付加します。
ここで得られる型情報は、C#をPostScriptへコンパイルするという点で極めて重要な要素です。たとえばC#では+演算子の左辺・右辺の型によって演算の種類が変わることもあります。
- 整数と整数の場合、単純な算術加算
- 文字列と整数の場合、文字列に変換したうえで文字列の結合
こういったパターンは型情報がないとどの命令を生成していいのかの判断がつかないのです。
本来、コンパイラ遊びをする場合基本的には解析処理を実装する必要があります。たとえば独自の構文をもった言語を作りたい場合はその仕様に沿ってこれらを実装する必要があります。
一方で、C#のような高度なプログラミング言語は非常に複雑な定義になっています。単純にASTを作るだけでもとてつもなく大変な作業です。ですが、Roslynを使うことでこれらの実装に一切手を付けることなく、var のような型推論キーワードがどういった型になっているのかというところまで含んだASTが一発で手に入ります。なんともいい時代ですね。
PostScript is なに
今回実装するコンパイラは、C#からのコンパイル結果をPostScriptという形式で出力します。PostScriptは静的な印刷コンテンツを表現する命令列ですが、少しだけロジックを書くこともできます。
このPostScriptは、ページ記述言語という一種のDSLのようなものです。もう少し厳密な表現をすると、PostScript自体は汎用言語でその上に印刷DSLが乗っているような2層構造です。
たとえばプリンタに印刷命令を送るとき、そのページのどこにどんなものがどんな大きさで書かれるべきなのか。というデータを都度ビットマップで送信していてはサイズも大きくなってしまいます。
そこで、現代で言えばスクリプト言語のようなもので印刷内容を定義するための言語をページ記述言語といいます。
PostScriptはもともとページ記述言語だったはずなのですが2奇妙なことに、Operand StackとExecution Stackを持っています。そしていくつかの算術演算のような命令を持っています。なんだか雲行きが怪しくなってきました。
たとえば次のスニペットはPostScriptプリンタ上で1 + 2 * 3 + 4を実行します。
1 2 3 mul add 4 add
左から順番に読んでいきます。1や2などはいわゆるリテラルです。Operand Stackにそのまま積みます。mulやaddは見ての通り、命令です。Operand Stackから2個とって計算し、Operand Stackに1個積みます。最終的にOperand Stackには11が残ります。
このようにPostScriptはページ記述言語でありながら、スタックVMのような仕組みを持ったプログラミング言語なのです。といってもPostScriptを手書きして変なことをたくさんさせるのはまぁまぁめんどくさいです。ほとんどスタックVMのアセンブラを手書きしてるのに似たり寄ったりの書き味なのでできればもっと高級な言語で書きたい…そこで今回はC#に登場してもらいました。
cspsc
というわけで、CSharp-PostScript Compilerを実装しました。縮めてcspscです。このコンパイラはC#の言語機能のうち、基本的な機能はたいてい実装されています3。実装されていないものについてはこんな感じです。
- classをはじめとするユーザー定義型
- stringを除く参照型のすべて
- async/await
- namespace
- goto
- try-catch-finally/throw
- 配列を除くnew演算子
- ToString, Length を除く組み込みメソッド
namespaceはあっても使わないし、async/awaitはプリンタの世界に非同期の概念がないし、classを実装し始めるとPostScript上でCLR実装してるみたいな感じになりかけるのでとりあえず無視しました。このあたりの機能がなくても、C#で書いた変なロジックをプリンタに実行させてプリンタを計算機にすることは十分に達成できます。
手始めにフィボナッチ数列を計算させてみます。この程度ならPostScriptを手書きしても余裕なんですが、せっかくなのでコンパイルしたコードで実行してもらいます。
int Fib(int n){
if (n <= 1) return n;
return Fib(n - 2) + Fib(n - 1);
}
for(var i = 0; i < 10; ++i){
println(Fib(i + 1).ToString());
}
そしてコンパイル結果がこちらです。まぁなんかめんどくさそうな雰囲気が一発で伝わってきそうな謎の文字列がいい感じに出力されていそうです。
PostScriptを手書きすればもっとシンプルに書くことができます。ただ、C#の制御構造や変数スコープをPostScriptで再現するために少しだけ複雑になっています。
% コンパイラ生成個所はスキップしています。適宜インデントは挿入しました。
/Fib {
1 dict begin /n exch def
mark
{
n 1 le { n 1 stop } if
n 2 sub Fib n 1 sub Fib add 1 stop
} stopped { pop } if
count 1 roll cleartomark count -1 roll
end
} def
1 dict begin /i 0 def
{
i 10 lt not { exit } if
{
i 1 add Fib 20 string cvs __println
exit
} __loop
/i i 1 add store
i pop
} loop
end
showpage
これを本当ならばPostScriptプリンタに送信して実行…したいところなんですが、あいにく手元にPostScriptを解釈できるプリンタがなかったため今回はGhostscriptというPostScriptインタプリタソフトウェアに解釈していただきます。TeXで論文を書いたりしている皆さんにはおなじみのあれですね。結果を見ると、完璧にフィボナッチ数列が計算できています。
もう少しプリンタのCPUパワーを使いたいので、モンテカルロ法で円周率を計算させてみたくなりました。円周率を計算するプログラムはめんどうだったのでClaudeに生成してもらっています。
using System.Runtime.InteropServices;
[DllImport("ps")]
static extern int Rand();
const int N = 10000000;
const int RAND_MAX = 2147483647; // 2^31 - 1
int inside = 0;
for (int i = 0; i < N; i++)
{
double x = (double)Rand() / RAND_MAX;
double y = (double)Rand() / RAND_MAX;
if (x * x + y * y <= 1.0)
{
inside++;
}
}
double pi = 4.0 * inside / N;
println($"Trials: {N}");
println($"Estimated PI: {pi}");
これをコンパイルして実行すると…3.14188 と表示されていて誤差0.00914%で円周率が計算できていますね!
inside cspsc
コンパイラが内部でどんなことをやっているのか少しだけ紹介します。
ASTをCSharpSyntaxWalkerというクラスを使ってPostScriptにマッピングしていっています。これはVisitorパターンと呼ばれるもので、Roslyn側がTreeを適切に処理し必要な関数を順次呼び出してくれます。我々開発者は、ノードに応じた関数で好きなことをするだけでいいのです4。
たとえば、二項演算子を評価するときに呼ばれるVisitBinaryExpressionというものがあります。二項演算子というのはつまるところ1 + 2のような演算子の左右にオペランドがあるパターンの演算のことです。今回のコンパイラが二項演算子を評価している箇所の実装を紹介します。
public override void VisitBinaryExpression(BinaryExpressionSyntax node)
{
var hasStringOperand = IsString(node.Left) || IsString(node.Right);
base.Visit(node.Left);
base.Visit(node.Right);
Emit(hasStringOperand ? "__concat" : GetOperatorPostScript(node.Kind()));
}
スタックVMなので、左辺値(lhs)、右辺値(rhs)、演算子という順でスタックに積むだけでC#からPostScriptへ変換できます。しかもVisitorは演算子の優先度や、()などの順序をすべて評価し並べ替えた状態でVisitBinaryExpressionを呼び出してくれます。
つまり、(1 + 2) + 3 * 4 のような優先度をちゃんと考慮しないといけない計算でも呼ばれるがままに左辺、右辺、演算子…左辺、右辺、演算子…といった感じでコードを愚直に生成するとそれはC#のコードと一致した正しい呼び出し結果になるのです5。
二項演算子に対応したコードを生成する時に重要なのが、ASTから得られる式やオペランドの型情報です。例示のコードでは、簡単にどちらかのオペランドがstringだった場合に__concatを生成するような実装6になっています。実際の実装ではもう少し込み入った判定をしており、double % doubleの場合にintへのキャストを挟んだり、string + intの場合にintをToStringするなどの細かい対応が含まれています。
また、C#には当然ですが変数のスコープと、関数、ループなどの制御構文があります。これらをPostScript上で再現するためにいくつかのテクニックが用いられています。
PostScriptも変数のスコープのような概念を持っています。今回はこのスコープをC#と整合するようにしてコードを生成しています。たとえばこの簡単な関数定義があります。
int f(int n, int m){
var a = n + m;
return a * 2;
}
コンパイラではこの関数をこんな感じにコンパイルしています。
/f % 関数名の `f`
{
2 dict begin % パラメータの数に合わせたスコープを生成
/m exch def % パラメータをスタックから取り出して変数に保存(右から)
/n exch def %
mark % スタックフレームを生成
{
1 dict begin % このブロックスコープに含まれるローカル変数の数に合わせたスコープを生成
/a n m add def % var の定義。 a = n + m を表現している
a 2 mul 1 stop % return a * 2 を表現している。
% PostScriptにはreturnがない代わりに簡単な例外機構があるのでreturnに活用している
% 1 stop がここではreturn
end % スコープをクリーンアップ
}
stopped { pop } if
count 1 roll cleartomark count -1 roll % スタックフレームのクリーンアップ。まるで呪文だが、実際呪文。
end
} def
C#が逐語的に変換されていることがわかります。とくにパラメータをスタックから取り出す個所では、右から順番に変数へ保存しています7。これは呼び出し元が引数を左から評価し、左から順番にスタックへ積むためです。また、markとcleartomarkを活用してスタックフレームを生成したり、stopとstoppedを活用して深いネストからの早期returnを表現しています。
これらは実際になかなかうまいこと動いていて、初期の実装では早期returnを認めていなかったのでif-elseには両分岐でreturnのあるなしを合わせる必要がありました。ですがこの簡単な例外機構を活用することで制限を撤廃でき、より自由でC#らしいコードを実行することができるようになっています。
今回のコンパイラはRoslynを使うことから .NET で実装する必要がありました。せっかく作ったのでWebから気軽に実行したい…そんな気持ちから、コンパイラを .NET 10でAOT済み、自己完結、単一ファイル、実行バイナリとして作成し、Cloudflare Containers へデプロイしています。
コンテナウォームアップの時間があるにせよ5秒程度でコンテナ起動, レスポンス生成までが実現できているのでなかなかうまくいっているようです。
おわりに
今回はC#をRoslynで解析して、ASTを活用することでC#のサブセット言語を定義し、PostScriptプリンタをC#のような言語が実行できる汎用計算機と言い張りました。皆さんのご家庭のプリンタもこれで計算資源として活用することができます。
これを実装したことでC#のASTの舐め方に少し明るくなったこと、PostScriptを微妙に手書きできるようになったことが今回得られた成果です。たとえば、X dup 1 eq { pop (1) show } { dup 2 eq { pop (2) show } { pop } ifelse } ifelse みたいなのをサクサク手書きできるようになりました。まったくもって使えるシーンが思い浮かびません。
普段はClaude Codeで実装まですることが多いんですが、アドカレということなので今回のコンパイラはせっかくなので手書きしました8。
要所要所でClaudeやChatGPTにコードレビューをお願いしたりもしていましたが、LLMが微塵もPostScriptを理解していない。スタックVMのようなものというふんわりとした知識はあるものの、各命令がどういう副作用を引き起こすかがわかっていない。結果、的外れなレビューや提案が多くて正しい言語仕様はこうだからその指摘は正しくない。みたいなやり取りを繰り返すことになりました9。
ただ、C#の言語仕様・Roslyn ASTの舐め方はそこそこ学習ソースに含まれているようでまぁまぁなレビューコメントを得ることができました。
やっぱり、PostScriptをアプリケーションプログラミング言語として採用したOSSが少ないせいでPostScriptに対する知識が少ないようです。LLMにもっとPostScriptを知ってもらいたいので、PostScriptでアプリケーションをたくさん実装してOSSで公開していかないといけませんね!
今回実装したコンパイラは https://cspsc.utatane.dev/ で実際に触って遊ぶことができます。このページでは、コンパイル結果のPostScriptとレンダリングされたPDFを即座に確認することができます。出力されたPostScriptはだいたい動くと思いますが、時々スタックのバランスが壊れて動かないことがあるかもしれません。その時はIssueを作るか、そっとXとかで教えてください。
関連リソース
-
言語仕様に "THE POSTSCRIPT® LANGUAGE is a simple interpretive programming language with powerful graphics capabilities." と書いてあるのでプログラミング言語である自覚はあるらしい ↩
-
そもそも最初からAdobeはページ記述言語でありながら、プログラミング言語として設計している。汎用プログラミングができないと思っていたのは時代が忘れてしまったからかもしれない。 ↩
-
ref/out/inキーワード、
out var _のDiscard、Named Tupleなども対応しています。これはRoslynのSemanticModel/OperationalModelがなければあと2年はかかっていたと思います。 ↩ -
実際のC#コンパイラも似たような感じでツリーを舐めて、Intermediate Representation(IR)を生成して、ILを生成しているらしい。 ↩
-
ちなみに、
1 2 add 3 4 mul addです。そもそもこういう値は通常、定数畳み込みと言ってコンパイル時にすべて計算を終えてこの場合だと15としてEmitされます。 ↩ -
実は本物のC#のコンパイラも
string + stringで定数にならないものはString::Concatの呼び出しに変換されているので似たような実装になっていますね。 ↩ -
こういう引数をどの順で積むとか、だれがスタックをクリーンアップするかとか、戻り値をどこに書くかとかをまとめたものを呼び出し規約(Calling Convention)とか、Application Binary Interface(ABI)とか呼んだりします。つまりPostScript向けのABIをでっちあげてC#から変換してるってことですね。 ↩
-
とは言ったものの、Web UIはClaude Codeで実装しています。Monaco Editor埋めて、Resizerつけて、WASM版Ghostscriptを動かしてPDFレンダラ表示するという自明な実装があまりにもめんどくさかった… ↩
-
夜な夜なChatGPTと言い合いをして、もういい!!あんたなんて知らない!!!!みたいな喧嘩別れを繰り返したりもしました。 ↩


