LemonHX

LemonHX

CEO of Limit-LAB 喜欢鼓捣底层的代码,意图改变世界
twitter
tg_channel

論宏:程式語言表述力的靈魂

我現在有一個思考就是 Macro 其實代指的東西有點太多了,至少我腦子裡面可以被稱之為 Macro 的東西有很多,
我先把我腦子裡面的東西列出來,然後再看看這些東西之間有什麼聯繫。

  1. Preprocessor:簡單的文本替換,不涉及語法或語義分析。
  2. Syntactic macro:在語法層面上操作和生成代碼,理解語言的語法結構。
  3. Partial elaborator:在展開代碼時進行一定程度的語義分析,確保代碼的正確性。
  4. Ad hoc elaborator:高度靈活的代碼生成系統,允許開發者根據需要定制代碼展開規則。

可能有一些你看過有一些你沒看過,我挨個兒說一下,他們之間的聯繫和區別。

  1. String replacing macro (Preprocessor):

    • 這種宏通常是通過簡單的文本替換來實現的,比如 C 語言中的#define宏。這種宏在編譯之前進行文本替換,不涉及語法分析或語義理解。它們的作用確實是在編譯之前對源代碼進行預處理。
  2. Common Lisp 中的 macro (Syntactic macro):

    • Common Lisp 中的宏是 “語法宏”(syntactic macro),它們在編譯時展開,並且可以操作和生成 Lisp 代碼。這些宏不僅僅是文本替換,它們可以理解 Lisp 的語法結構,並且可以生成新的語法結構。
  3. Scheme 中的 macro (Hygenic macro) / Partial elaborator:

    • Scheme 中的宏(特別是衛生宏,hygenic macro)在展開時會避免變量捕獲(variable capture)等問題,確保宏展開後的代碼不會意外地改變程序的含義。它們在展開代碼時會進行一定程度的語義分析,確保展開後的代碼在語義上是正確的。
  4. Racket 中的 macro / Lean 中的 template / Template Haskell 中的 template (Ad hoc elaborator):

    • 在 Racket、Lean 和 Template Haskell 中,宏或模板系統更加靈活和強大,允許開發者根據需要定義複雜的代碼生成規則。這些系統允許開發者根據具體需求進行定制化的代碼生成和展開,具有很高的靈活性和表達能力。

文法和語義#

我來詳細解釋 Preprocessor(預處理器)Syntactic Macro(語法宏)Elaborator(展開器 /elaborator) 的區別,並結合 文法(Syntax)語義(Semantics) 來進一步說明。

1. Preprocessor(預處理器)#

  • 關鍵詞:文本替換、無文法意識、無語義意識。
  • 定義:預處理器是在編譯之前對源代碼進行簡單文本替換的工具。它不關心代碼的文法結構或語義含義,只是機械地進行字符串替換。
  • 文法:預處理器不解析代碼的文法結構,因此它無法區分代碼中的語法元素(如變量、函數、表達式等)。
  • 語義:預處理器不關心代碼的語義,它只是將一種字符串替換為另一種字符串,替換後的代碼是否合法由編譯器或解釋器後續處理。
  • 例子
    • C 語言中的 #define 宏:
      #define MAX(a, b) ((a) > (b) ? (a) : (b))
      
      這裡 MAX(a, b) 會被簡單地替換為 ((a) > (b) ? (a) : (b)),預處理器不會檢查 ab 是否是合法的表達式。
    • 預處理器也可以用於條件編譯:
      #ifdef DEBUG
      printf("Debug mode\n");
      #endif
      
      這裡的 #ifdef#endif 是預處理指令,它們會根據條件決定是否包含某段代碼。

2. Syntactic Macro(語法宏)#

  • 關鍵詞:文法意識、語法樹操作、無語義意識。
  • 定義:語法宏是一種在編譯時展開的機制,它操作的是代碼的語法結構(通常是抽象語法樹,AST)。語法宏可以生成新的語法結構,但它不關心生成的代碼的語義是否正確。
  • 文法:語法宏理解代碼的文法結構,因此它可以操作語法樹(AST)。例如,它可以識別函數調用、變量聲明等語法元素,並生成新的語法結構。
  • 語義:語法宏不關心代碼的語義。它只負責生成語法結構,生成的代碼是否合法由後續的編譯器或解釋器檢查。
  • 例子
    • Common Lisp 中的宏:
      (defmacro unless (condition &body body)
        `(if (not ,condition) (progn ,@body)))
      
      這個宏定義了一個 unless 結構,它會在編譯時展開為 if 表達式。宏展開時操作的是語法樹,但它不會檢查 conditionbody 的語義是否正確。
    • Scheme 中的 syntax-rules 宏:
      (define-syntax unless
        (syntax-rules ()
          ((_ condition body ...)
           (if (not condition) (begin body ...)))))
      
      這個宏也是操作語法樹,但它通過衛生宏(hygenic macro)機制避免了變量捕獲問題。

3. Elaborator(展開器 /elaborator)#

  • 關鍵詞:文法意識、語義意識、類型檢查、代碼生成。
  • 定義:Elaborator 是一種更高級的代碼展開機制,它不僅操作語法樹,還會進行語義分析(如類型檢查、作用域分析等)。Elaborator 通常用於將高級語言特性(如模式匹配、類型類、依賴類型等)展開為更低級的核心語言。
  • 文法:Elaborator 理解代碼的文法結構,因此它可以操作語法樹。
  • 語義:Elaborator 關心代碼的語義。它在展開代碼時會進行語義分析,確保生成的代碼在語義上是正確的(例如,類型正確、變量作用域正確等)。
  • 例子
    • Lean 中的 Elaborator:
      Lean 語言中的 Elaborator 負責將高級語法(如依賴類型、模式匹配)展開為核心語言。它會進行類型檢查和語義分析,確保生成的代碼是合法的。
      def add (x y : Nat) : Nat := x + y
      
      這裡的 add 函數定義會被 Elaborator 展開為更底層的核心語言表示,同時進行類型檢查。
    • Idris 中的 Elaborator:
      Idris 中的 Elaborator 負責將高級語法(如類型類、依賴類型)展開為核心語言。例如:
      add : Nat -> Nat -> Nat
      add x y = x + y
      
      這裡的 add 函數定義會被 Elaborator 展開為更底層的表示,並進行類型檢查。

對比總結#

特性PreprocessorSyntactic MacroElaborator
文法意識
語義意識
操作對象文本字符串語法樹(AST)語法樹(AST)
是否生成合法代碼不一定(依賴後續編譯器)不一定(依賴後續編譯器)是(會進行語義檢查)
典型例子C 語言的 #defineCommon Lisp 宏Lean、Idris 的 Elaborator

那你說了半天我也沒看懂什麼是Ad hoc elaborator#

什麼是 Ad hoc? 老生常談的問題了#

  • Ad hoc 是一個拉丁語短語,意思是 “專門為某種目的設計的” 或 “特設的”。在編程語言中,Ad hoc 通常用來描述一種非通用針對特定問題的解決方案。
  • Ad hoc elaborator 可以理解為一種針對特定需求設計的代碼展開機制。它不像通用的 Elaborator 那樣有嚴格的規則和約束,而是允許開發者根據需要靈活地定義代碼生成邏輯。

為什麼 Racket 的宏是 Ad hoc elaborator?#

Racket 的宏系統非常強大且靈活,允許開發者根據需要定義複雜的代碼生成規則。以下是 Racket 宏被稱為 Ad hoc elaborator 的原因:

靈活性#

  • Racket 的宏系統允許開發者定義任意複雜的代碼轉換規則。開發者可以根據具體需求設計宏,而不受語言核心語法的限制。
  • 例如,Racket 的宏可以操作語法樹(AST),生成新的語法結構,甚至可以引入新的語言特性(如 DSL,領域特定語言)。

層級(Phase)和命名空間(Namespace)#

  • Racket 的宏系統引入了 層級(phase) 的概念,允許宏在不同的編譯階段運行。每個階段都有自己的命名空間,避免了命名衝突。
    • 例如,Racket 的宏可以在 編譯時 運行,生成代碼,而這些代碼可以在 運行時 使用。
    • 這種分層機制使得宏可以更靈活地處理代碼生成和展開。
  • 其他語言的宏系統通常沒有這種層級和命名空間的概念,因此它們的宏功能相對受限。

針對特定問題的解決方案#

  • Racket 的宏系統允許開發者根據需要定義特設的代碼生成規則。例如:
    • 你可以定義一個宏來簡化某種特定的編程模式。
    • 你可以定義一個宏來實現某種領域特定語言(DSL)。
  • 這種針對特定問題的設計正是 Ad hoc 的核心特徵。

與通用 Elaborator 的區別#

  • 通用的 Elaborator(如 Lean 或 Idris 中的 Elaborator)通常有嚴格的規則和約束,例如類型檢查和語義分析。
  • Racket 的宏系統則更加靈活,允許開發者根據需要定義代碼生成規則,而不受嚴格的語義約束。因此,它更像是一種 Ad hoc elaborator

Racket 宏的層級和命名空間#

Racket 的宏系統引入了 層級(phase)命名空間(namespace) 的概念,這使得它的宏系統更加靈活和強大。

層級(Phase)#

  • Racket 的宏系統支持多階段編譯。每個階段都有自己的語法環境和命名空間。
    • 例如,宏可以在編譯時運行,生成代碼,而這些代碼可以在運行時使用。
    • 這種分層機制避免了命名衝突,並允許宏在不同的階段操作代碼。
  • 其他語言的宏系統通常沒有這種層級的概念,因此它們的宏功能相對受限。

命名空間(Namespace)#

  • Racket 的宏系統為每個階段提供了獨立的命名空間。這意味著:
    • 宏可以在編譯時定義和使用變量,而不會與運行時的變量衝突。
    • 這種命名空間機制使得宏可以更靈活地操作代碼,而不會引入意外的副作用。

為什麼其他語言的宏不是 Ad hoc elaborator?#

  • Common Lisp 的宏:雖然 Common Lisp 的宏非常強大,但它們沒有層級和命名空間的概念,所以不符合在特定問題上靈活定制的 Ad hoc 特性。
  • Scheme 的宏:Scheme 的宏(特別是衛生宏)雖然避免了變量捕獲問題,但它們的功能相對受限,無法像 Racket 的宏那樣靈活地定義代碼生成規則。

ElaboratorCompile time execution 有什麼區別呢?#

本章節的 指的是 Elaborator,而不是 Syntactic Macro

其實思考到這個方向你快摸到核心了,現在我們來深入探討 宏(elaborator)編譯時執行(compile-time execution) 的關係,以及為什麼 elaborator 需要 compile-time execution 的配合才能實現更強大的功能。

宏(Elaborator)的作用#

  • Elaborator 的主要任務是將高級語言特性(如模式匹配、類型類、依賴類型等)展開為更低級的核心語言表示。
  • 它不僅僅是簡單的語法轉換,還會進行語義分析(如類型檢查、作用域分析等),以確保生成的代碼在語義上是正確的。

編譯時執行(Compile-time Execution)的作用#

  • 編譯時執行 是指在編譯階段執行某些代碼,以生成或優化最終的程序。
  • 編譯時執行的代碼通常是 basic form evaluator,它可以在編譯時計算常量表達式、展開宏、優化代碼等。

Elaborator 和 Compile-time Execution 的關係#

Elaborator 和 Compile-time Execution 是相輔相成的,它們共同作用以實現更強大的編譯時功能。

Elaborator 需要 Compile-time Execution 的支持#

  • Elaborator 在展開高級語言特性時,通常需要執行一些計算。例如:
    • 在依賴類型系統中,類型檢查可能需要在編譯時計算某些表達式。
    • 在模式匹配中,模式匹配的展開可能需要在編譯時計算某些條件。
  • 這些計算需要 compile-time execution 的支持,否則 Elaborator 無法完成其任務。

Compile-time Execution 需要 Elaborator 的支持#

  • Compile-time Execution 通常需要操作語法樹(AST),而 Elaborator 負責將高級語言特性展開為語法樹。
  • 例如,在編譯時執行某些代碼時,可能需要先通過 Elaborator 將高級語言特性展開為更低級的表示,然後再進行計算。

具體例子#

依賴類型系統#

  • 在依賴類型系統(如 Idris 或 Lean)中,類型檢查可能需要在編譯時計算某些表達式。
  • 例如:
    add : (n : Nat) -> (m : Nat) -> Nat
    add n m = n + m
    
    這裡的類型檢查可能需要在編譯時計算 n + m 的類型。Elaborator 負責將 add 函數展開為核心語言表示,而 Compile-time Execution 負責在編譯時計算類型。

模式匹配#

  • 在模式匹配中,模式匹配的展開可能需要在編譯時計算某些條件。
  • 例如:
    factorial : Nat -> Nat
    factorial 0 = 1
    factorial n = n * factorial (n - 1)
    
    這裡的模式匹配需要在編譯時展開為 if-else 結構。Elaborator 負責將模式匹配展開為核心語言表示,而 Compile-time Execution 負責在編譯時計算條件。

Racket 的宏系統#

  • Racket 的宏系統允許在編譯時執行任意代碼,以生成新的語法結構。
  • 例如:
    (define-syntax (unless stx)
      (syntax-case stx ()
        [(_ condition body ...)
         #'(if (not condition) (begin body ...))]))
    
    這裡的 unless 宏在編譯時展開為 if 表達式。Elaborator 負責將 unless 展開為 if 表達式,而 Compile-time Execution 負責在編譯時計算 conditionbody

為什麼 Elaborator 需要 Compile-time Execution 才能做得完美?#

  • 動態計算:Elaborator 在展開高級語言特性時,通常需要動態計算某些表達式。這些計算需要在編譯時完成,因此需要 Compile-time Execution 的支持。
  • 優化:Compile-time Execution 可以在編譯時優化代碼,例如常量折疊、死代碼消除等。這些優化需要 Elaborator 的支持,以將高級語言特性展開為更低級的表示。
  • 靈活性:Compile-time Execution 允許在編譯時執行任意代碼,這使得 Elaborator 可以更靈活地處理代碼生成和展開。

等等,我還是有點疑惑,你看看人家 zig 為什麼嫌宏麻煩但是引入了 compile-time execution 之後看起來也挺靈活的?#

我們需要從編程語言設計的底層機制出發,分析僅依賴 compile-time execution(編譯時執行) 而缺乏 elaborator(展開器)macro(宏) 系統的根本缺陷。這裡將聚焦兩個核心問題:

  1. 類型系統的剛性束縛:編譯時執行只能操作 base form(基礎形式),無法突破語言設計者預設的抽象邊界。
  2. 元編程的維度坍塌:編譯時執行的元編程能力僅停留在 值計算 層面,而缺乏對語言 語法結構編譯管線 的深度控制。

類型系統的剛性束縛#

根本矛盾:當編譯時執行只能操作 base form(基礎形式),意味著所有元編程行為都被限制在語言原生的類型系統和語法結構內,無法突破語言設計者預設的抽象邊界。

類型推導的不可擴展性#

  • Zig 的局限:Zig 的編譯時函數(comptime)雖然能計算值,但無法生成新的類型系統規則。例如:
    // 無法在編譯時定義新的類型推導規則
    const MyType = comptime {
        // 假設想根據上下文自動推導出特定類型
        return if (some_condition) u32 else f64; // 必須顯式返回類型
    };
    
    這種代碼生成是靜態的,無法根據上下文動態推導類型,導致類型系統的靈活性受限。
  • 對比 Elaborator:在依賴類型語言(如 Lean)中,Elaborator 可以動態生成類型約束:
    def myFunction (x : Nat) :=
      if x > 0 then x + 1 else "Error"  -- 類型系統會根據條件分支自動推導出 `Nat ⊕ String`
    
    這裡的類型推導是動態的,而 Zig 的編譯時執行無法做到這一點。

語法與語義的強耦合#

  • Zig 的困境:編譯時執行的代碼必須嚴格遵循 Zig 的語法和類型規則,無法定義新的語法糖或語義規則。例如:
    • 無法實現類似 Rust 的 ? 錯誤傳播語法糖。
    • 無法定義類似 SQL 的查詢語法。
  • Elaborator 的解決方案:Racket 的宏可以定義新的控制流語義:
    (define-syntax-rule (?? expr default)
      (if (not (null? expr)) expr default))
    
    這種宏可以在語法層面重新定義 ?? 運算符的行為,而 Zig 的編譯時執行無法實現類似功能。

元編程的維度坍塌#

核心問題:Compile-time execution 的元編程能力僅停留在 值計算 層面,而缺乏對語言 語法結構編譯管線 的深度控制。

語法樹操作的缺失#

  • Zig 的硬傷:Zig 無法直接操作抽象語法樹(AST),所有編譯時生成的代碼必須通過字符串拼接或模板化的代碼結構實現。例如:
    // 通過字符串拼接生成代碼(類似 C 預處理器)
    const code = comptime {
        var buf: [100]u8 = undefined;
        _ = std.fmt.bufPrint(&buf, "fn foo() void {{}}");
        return buf[0..];
    };
    
    這種方式本質上還是文本替換,容易引入安全漏洞(如注入攻擊),且無法進行靜態分析。
  • Elaborator 的優勢:Rust 的過程宏(proc macro)可以直接操作 AST:
    #[derive(Debug)]  // 過程宏自動生成 `Debug` trait 的實現代碼
    struct Point { x: i32, y: i32 }
    
    這種 AST 層面的操作是類型安全和可分析的,而 Zig 的文本替換方式無法實現類似功能。

編譯管線的不可干預性#

  • Zig 的封閉性:Zig 的編譯管線是固定的,開發者無法插入自定義的編譯階段(如自定義優化、代碼變換)。例如:
    • 無法實現類似 LLVM 的中間表示(IR)自定義優化。
    • 無法在編譯時注入動態生成的代碼段(如 JIT 編譯)。
  • 對比 Lisp 家族:Lisp 的宏系統允許在任意編譯階段插入代碼變換:
    (defmacro at-compile-time (&body body)
      `(eval-when (:compile-toplevel) ,@body))
    
    這種能力使得 Lisp 可以自由控制編譯管線,而 Zig 的編譯時執行無法突破語言預設的管線階段。

為什麼 Elaborator 需要 Compile-time Execution 才能做得完美?#

  • 動態計算:Elaborator 在展開高級語言特性時,通常需要動態計算某些表達式。這些計算需要在編譯時完成,因此需要 Compile-time Execution 的支持。
  • 優化:Compile-time Execution 可以在編譯時優化代碼,例如常量折疊、死代碼消除等。這些優化需要 Elaborator 的支持,以將高級語言特性展開為更低級的表示。
  • 靈活性:Compile-time Execution 允許在編譯時執行任意代碼,這使得 Elaborator 可以更靈活地處理代碼生成和展開。

那為什麼我看著你說的東西 C艹 都能寫出來但是寫得不好看以及我不敢用,造成這個的原因是什麼呢?#

C++ 的模板(template)constexpr概念(concept)系統確實存在一些設計上的問題和不明確的地方。我們可以從 模板作為 Elaborator 的作用constexpr 和模板的關係 以及 概念系統的引入 三個方面來批判 C++ 的設計。

C++ 模板作為 Elaborator 的作用#

  • C++ 模板 是一種編譯時代碼生成機制,允許開發者編寫通用代碼(如泛型編程)。它可以在編譯時生成具體的代碼實例。
  • 作為 Elaborator 的局限性
    1. 語法複雜,難以理解
      • C++ 模板的語法非常複雜,尤其是涉及到模板特化、SFINAE(Substitution Failure Is Not An Error)等技術時,代碼可讀性和可維護性大大降低。
      • 例如,模板元編程(Template Metaprogramming, TMP)常常需要編寫晦澀難懂的代碼,這違背了 Elaborator 應該清晰、易用的原則。
    2. 缺乏語義分析
      • C++ 模板本質上是一種語法替換機制,它不進行深層次的語義分析。例如,模板無法直接檢查類型是否滿足某些語義約束(在 C++20 之前)。
      • 這導致模板錯誤信息通常非常晦澀,難以調試。
    3. 編譯時計算能力有限
      • C++ 模板的編譯時計算能力依賴於模板元編程,這種方式不僅難以使用,而且性能開銷較大。
      • 例如,計算斐波那契數列的模板元編程代碼:
        template<int N>
        struct Fibonacci {
            static const int value = Fibonacci<N-1>::value + Fibonacci<N-2>::value;
        };
        template<>
        struct Fibonacci<0> {
            static const int value = 0;
        };
        template<>
        struct Fibonacci<1> {
            static const int value = 1;
        };
        
        這種代碼不僅難以編寫,而且可讀性極差。

constexpr 和模板的關係不明確#

  • constexpr 是 C++11 引入的特性,允許在編譯時計算常量表達式。
  • 問題
    1. 與模板的功能重疊
      • constexpr 和模板都可以用於編譯時計算,但它們的設計目的和實現方式完全不同。
      • 模板主要用於代碼生成,而 constexpr 主要用於常量計算。這種功能重疊導致開發者在使用時容易混淆。
    2. constexpr 的限制
      • constexpr 函數和變量有嚴格的限制,例如不能使用動態內存分配、不能有副作用等。這限制了它的表達能力。
      • 例如,constexpr 函數無法直接操作模板生成的類型,導致兩者之間的協作不夠順暢。
    3. 缺乏統一的編譯時計算模型
      • C++ 沒有提供一個統一的編譯時計算模型,模板和 constexpr 是兩種獨立的機制。這增加了語言複雜性,也增加了開發者的學習成本。

C++ 概念系統的引入#

  • 概念(Concepts) 是 C++20 引入的特性,用於約束模板參數,提高模板的可讀性和錯誤信息。
  • 問題
    1. 引入時機過晚
      • 概念系統在 C++20 才引入,而模板系統早在 C++98 就已經存在。這意味著在概念引入之前,C++ 開發者已經忍受了多年的晦澀模板錯誤信息。
    2. 與模板的集成不夠自然
      • 概念系統的設計並沒有完全解決模板的複雜性問題。例如,概念仍然需要與模板語法結合使用,導致代碼依然不夠直觀。
      • 例如:
        template<typename T>
        requires Integral<T>
        T add(T a, T b) {
            return a + b;
        }
        
        雖然概念提高了代碼的可讀性,但語法依然複雜。
    3. 概念的表達能力有限
      • 概念系統主要用於類型約束,但它無法完全替代模板元編程的功能。例如,概念無法直接用於編譯時計算或代碼生成。

總結#

宏系統的設計水平直接決定語言的「自演化能力」。真正的語言大師級工具(如 Lisp、Racket)將宏視為語言本身的一等公民,而非事後補救的補丁機制。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。