🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

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 是什麼

在直接將 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 的形式提供給用戶。

image.png

AST 在中文中稱為抽象語法樹,是將程序源碼分解為有意義的單位並以語法成立的結構形式表示為樹狀結構。極端來說,只要有 AST,幾乎可以執行任何程序。

通常獲得 AST 需要經過詞法分析和句法分析。詞法分析簡單來說是分析字符的分隔以及組合的位置,句法分析則是分析這些組合是否符合語言規範的排列順序。再加上語義分析,以便為 AST 附加類型信息和作用域信息。

在這裡獲取的類型信息對於將 C# 編譯為 PostScript 是極其重要的。例如,在 C# 中,+ 運算符的左右數據類型可以影響運算的類型。

  • 當左右均為整數時,執行簡單的算術加法。
  • 當類型為字串和整數時,會將整數轉換為字串後再進行字串的連接。

這類模式若沒有類型信息,就無法判斷應生成哪一條指令。

本來進行編譯器實驗時,基本上需要實作分析處理。如果想建立擁有自訂語法的語言,就必須根據該規範實作這些過程。
另一方面,如 C# 這樣的高級編程語言有著非常複雜的定義。光是簡單地創建 AST 就是相當困難的工作。然而,使用 Roslyn,可以在不進行任何實作的情況下,獲得包含 var 這類型推斷關鍵字的 AST,真是個好時代。

PostScript 是什麼

這次實作的編譯器將 C# 的編譯結果輸出為 PostScript 格式。PostScript 是一組用於表示靜態印刷內容的指令串,但也可以寫一些小邏輯。

這種 PostScript 是一種頁面描述語言,可以看作一種 DSL。更嚴格地說,PostScript 本身是通用語言,上面還有一層印刷 DSL 的結構。
例如,當向印表機發送印刷指令時,必須告知頁面上的內容應該在哪裡、以何種大小繪製。如果每次都傳送位圖數據,大小將會變得相當龐大。
因此,現代所說的腳本語言便是用來定義印刷內容的頁面描述語言。

PostScript 本是頁面描述語言,但[^2]奇怪的是,它具有操作數堆疊和執行堆疊。並且擁有一些算術運算的指令。狀況越來越複雜。
例如,以下片段在 PostScript 印表機上運行 1 + 2 * 3 + 4

1 2 3 mul add 4 add

從左到右依次讀取。12 等都是所謂的字面量。直接放入操作數堆疊。muladd 則顯而易見是指令,從操作數堆疊中取出兩項計算後再放入操作數堆疊。最終,操作數堆疊上留下 11

因此,PostScript 雖是頁面描述語言,卻具備了堆疊 VM 的程式語言特性。儘管如此,手動撰寫 PostScript 做各種奇怪的事情相當麻煩,幾乎類似手動撰寫堆疊 VM 的組合語言,希望能用更高級的語言來編寫……所以這次讓 C# 幫忙了。

cspsc

因此,我實作了 CSharp-PostScript 編譯器,簡稱 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 可以更簡單地寫,但為了在 PostScript 中重現 C# 的控制結構和變數作用域,稍微複雜了一些。

% 編譯器生成的部分已略過,適當插入了縮排。
/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 寫論文的人來說,這是一個熟悉的工具。最終結果顯示,費波那契數列計算是完美的。

image.png

我想要進一步利用印表機的 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%,圓周率計算成功!

image.png

cspsc 的內部運作

稍微介紹一下編譯器的內部實作。

AST 是使用 CSharpSyntaxWalker 這個類別將其映射至 PostScript。這是一種稱為訪問者模式的技術,Roslyn 可以適當處理樹狀結構並依次呼叫所需的函數。我們開發者只需對應到節點的函數來進行操作[^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。而且訪問者會評估運算子的優先級,以及 () 等順序,後再呼叫 VisitBinaryExpression

也就是說,即使是需要正確考量優先級的計算 (1 + 2) + 3 * 4,只需愚直地生成代碼,也能得到與 C# 代碼相符的正確調用結果[^5]。

生成與二元運算子對應的代碼時,AST 中獲得的表達式和操作數的類型信息非常重要。在示例代碼中,簡單地判斷當任一操作數為 string 時生成 __concat 的實作[^6]。實際上,具體實作會進行更複雜的判定,例如在 double % double 的情況下插入轉型為 int,在 string + int 的情況下將整數轉換為字串等等細節。

此外,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 % 返回 a * 2 的表達
                   % PostScript 中雖無返回,但有簡單的例外機制,所以利用此表現返回
                   % 1 stop 於此即為返回
    end            % 清理作用域
  }
  stopped { pop } if
  count 1 roll cleartomark count -1 roll % 清理堆疊幀,看似咒語實際上真的是咒語。
  end
} def

可見 C# 正逐字轉換。特別是在從堆疊中取出參數的地方,從右到左地將變數儲存[^7]。這是因為調用方從左開始評估引數,並從左到右依次壓入堆疊。此外,使用 markcleartomark 生成堆疊幀,並使用 stopstopped 表現從深層嵌套返回的機制。

這些實作運行得相當良好。初期的版本並不允許提前返回,因此在 if-else 結構中需在兩個分支都包括是否有返回。透過利用這簡單的例外機制,撤除了限制,使得能夠執行更自由且更具 C# 特性的代碼。

這次的編譯器由於使用了 Roslyn,因此必須在 .NET 平台上實作。既然做出了編譯器,當然希望能夠輕鬆在網路上執行……出於這樣的心情,我將編譯器建立為 .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 完成實作,不過因為是 Advent Calendar,這次編譯器還是親手寫了[^8]。

過程中也有向 Claude 和 ChatGPT 請教代碼評論,但 LLM 對 PostScript 了解得不完全。雖然大致上知道這是個堆疊 VM,但不理解每條指令會引起的副作用,因此經常出現不切實際的評論與建議,總是需要指出正確的語言規範。這樣的對話一直反覆進行,甚至因此鬧得不可開交[^9]。

不過,C# 的語言規範及 Roslyn AST 的解析似乎在學習資源中還算充足,能得到相當不錯的評論意見。

可以說,由於據飼的 PostScript 應用程序編程語言的 OSS 數量少,對 PostScript 的認識亦不多。因此,必須多實作一些使用 PostScript 的應用公開於 OSS,讓 LLM 更加了解 PostScript!

此次實作的編譯器可以在 https://cspsc.utatane.dev/ 進行實際操作和互動。此頁面可立即檢查編譯結果的 PostScript 及渲染出的 PDF。輸出的 PostScript 大致上應能正常運行,但有時堆疊可能會不平衡,導致無法運行。若無法執行可建立問題回報,或輕聲告訴我 X 社交媒體等。

相關資源

[^1]: 語言規範中提到「THE POSTSCRIPT® LANGUAGE is a simple interpretive programming language with powerful graphics capabilities.」,可見它自認為是程序語言。
[^2]: Adobe 本身最初就是在設計一種頁面描述語言的同時,亦設計其具備程序語言的功能。若認為無法進行通用編程,可能是時代的遺忘所致。
[^3]: ref/out/in 關鍵字,out var _ 的丟棄,命名元組等也已對應。若無 Roslyn 的 SemanticModel/OperationalModel,估計還需兩年才能完成。
[^4]: 實際上 C# 編譯器也大致如此,瀏覽樹狀結構,生成中間表示(IR),然後生成 IL。
[^5]: 另外,1 2 add 3 4 mul add。這類值通常稱為常數摺疊,會在編譯階段完成計算,這裡的情況會以 15 來發射。
[^6]: 實際上,真正的 C# 編譯器在 string + string 非常量時,會轉換為 String::Concat 的調用,因此實作相似。
[^7]: 這種將參數如何堆疊,誰負責清理堆疊,返回值的位置等規則總結為調用約定(Calling Convention)或應用程序二進制界面(ABI)。即是在為 PostScript 製作 ABI,將 C# 轉換至 PostScript。
[^8]: 但是說到最終網頁 UI 是用 Claude Code 實作的。嵌入 Monaco 編輯器,添加調整器,運行 WASM 版 Ghostscript 程序來顯示 PDF 渲染,這樣的實作簡直繁瑣無比。
[^9]: 夜以繼日地與 ChatGPT 爭論,最終鬧得不可開交,甚至埋怨「我不會再和您交流了!」。


原文出處:https://qiita.com/tmyt/items/994d1552497dd5202aa4


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬10   ❤️5
427
🥈
我愛JS
📝2   💬8   ❤️4
91
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付