雙相超程式設計:一種新語言設計方法

banq發表於2024-07-03


本文討論了程式語言的一種趨勢,即允許相同的語法表達

  • 在兩個不同階段或環境(上下文)中執行的計算
  • 同時保持跨階段(上下文)的一致行為。
  • 這些階段通常在時間上(執行時間)或空間上(執行地點)有所不同。

作者提供了三種體現這種“雙相程式設計(biphasic programming)”概念的語言示例:

  1. Zig:Zig 允許使用“comptime”關鍵字在編譯時執行普通函式,提供與基礎語言相同的表達能力.這使得原始碼中的構建時間和執行時執行之間能夠無縫切換。
  2. Winglang:Winglang 是一種用於編寫雲應用程式的程式語言,其設計採用了雙相概念。它提供預檢程式碼(在編譯時執行以定義雲基礎設施)和執行中程式碼(在執行時執行以與基礎設施互動)。兩個階段具有相同的語法,但具有不同的規則和功能。
  3. React 伺服器元件 (RSC):RSC 允許 React 元件指定它們應該在伺服器端還是客戶端渲染,從而實現伺服器渲染和客戶端渲染元件的靈活組合。此方法旨在透過最小化伺服器和客戶端之間傳輸的動態 HTML 和元件資訊量來最佳化頁面效能。

作者提出,雙相規劃可用於解決各種問題,探索這些解決方案規則之間的重疊和差異可能會產生有趣的見解文章還提到,雖然編譯時程式碼執行並不是一個新想法,但 Zig 的方法似乎避免了其他超程式設計 系統的幾個缺點。

雙相程式設計問題
雖然雙相程式設計可以在表達力、效能和靈活性方面帶來好處,但開發人員必須做好準備,以應對在專案中採用這種模式所帶來的日益增加的複雜性和潛在挑戰

  1. 複雜性增加:雙相程式設計要求開發人員在同一程式碼庫中管理兩個不同的執行階段(例如編譯時和執行時),從而增加了一層複雜性。這會增加認知負擔,使程式碼更難理解和維護。
  2. 引數化和資料需求:雙相模型通常需要更多的引數和資料來捕捉兩個階段的細微差別,與更簡單的單相模型相比,這使得它們更難以擬合和驗證。
  3. 工具和生態系統支援:現有的開發工具、庫和框架可能不完全支援雙相程式設計正規化,需要開發人員投入時間和精力來構建定製解決方案或調整他們的工作流程。
  4. 效能權衡:在編譯時執行程式碼的能力可以提供效能優勢,但也可能引入新的效能考慮,例如增加編譯時間或快取和記憶的潛在問題。
  5. 採用和學習曲線:雙相程式設計代表了傳統程式設計模型的轉變,開發人員在加入團隊並將新方法整合到現有程式碼庫和開發實踐中時可能會面臨阻力或挑戰。
  6. 除錯和故障排除:將程式碼執行分為兩個不同的階段可能會使除錯和解決問題變得更加困難,因為根本原因可能隱藏在編譯時和執行時環境之間的互動中

1、案例:Zig
Zig一種系統程式語言,可讓您編寫高效能程式碼,並相對輕鬆地逐步採用到 C/C++ 程式碼庫中。

它的主要創新之一是一種名為“comptime”的全新超程式設計方法,可讓您在編譯時執行普通函式。

與 C、C++ 和 Rust 中的預處理系統和宏系統相比,comptime 的獨特之處在於,它透過“comptime”關鍵字為您提供了與基礎語言相同的2表達能力,而不是引入只有高階使用者才可能想要學習的完全獨立的領域特定語言。

const expect = @import(<font>"std").testing.expect;

fn fibonacci(index: u32) u32 {
    if (index < 2) return index;
    return fibonacci(index - 1) + fibonacci(index - 2);
}

test
"fibonacci" {
   
// 執行時測試斐波那契<i>
    try expect(fibonacci(7) == 13);

   
//在編譯時測試斐波那契<i>
    try comptime expect(fibonacci(7) == 13);
}

作為雙相程式設計的一種情況,comptime 允許 Zig 使用者在原始碼中無縫切換在構建時執行程式碼和在執行時執行程式碼,而不會帶來陡峭的學習曲線。

它改變了開發人員的思維模式,不再將超程式設計視為高階魔法,而是將其視為一種最佳化工具,還可以利用它來實現泛型和其他程式碼生成用途。

不管怎樣,編譯時程式碼執行並不是一個全新的想法。然而,Zig 的方法似乎確實避免了一些缺點。例如,與 Rust 及其const 函式不同,Zig 不會對 comptime 函式強制使用函式著色。同樣,與 C++ 的模板系統不同,Zig 不會引入任何用於表示泛型的新語法。與支援 hygenic 宏的 Scheme 和 Racket 等 Lisp 相比,Zig 並不要求所有內容都是列表。

TL;DR: Zig 支援一種雙相程式設計形式,其中相同的函式可以在兩個不同的階段執行,這兩個階段在時間上(構建時間與執行時間)和空間上(在構建系統上與在執行二進位制檔案的機器上)有所不同。

2、案例:React 伺服器元件
我注意到的第二個雙相程式設計示例是React Server Components (RSC)。React 本身並不是一門語言,但作為一個 JavaScript Web 框架,它作為編寫和編寫大型網站的 UI 元件及其相關 UI 邏輯的基礎系統,擁有相當大的知名度。

最近,前端 JavaScript 生態系統一直在進行大量探索,以找出如何最有效地在伺服器或客戶端上呈現 UI 元件以提高頁面效能。已經提出了許多解決方案,其中最雄心勃勃的解決方案之一就是 RSC。

RSC 背後的想法是允許 React 元件指定它應該在伺服器端還是客戶端呈現,並允許這些元件自由組合在一起。

例如,

  • 元件Feed可能在伺服器上呈現(因為它需要從資料庫獲取 feed 項列表),
  • 而每個子元件FeedItem可以在客戶端呈現(因為它們是項狀態的純函式),
  • 而FeedItemPreview可能在伺服器上呈現(因為它需要從資料庫獲取項的內容)。

開發人員可以選擇在哪裡計算哪些元件,底層引擎(通常是生成伺服器端程式碼和客戶端程式碼的 JavaScript 打包器)會最佳化所有內容,以便在需要時在伺服器或客戶端上呈現元件,從而最大限度地減少來回傳輸的動態 HTML 和元件資訊量。

讓這一切正常執行並穩定下來仍是一項艱鉅的工作。但我認為該正規化是雙相程式設計的一個有趣例子。

有很多方法可以減少需要在客戶端瀏覽器上傳送和執行的程式碼量,並將更多工作轉移到伺服器上,但當今大多數現有解決方案都要求開發人員將 React 元件視為純客戶端抽象,或純伺服器端抽象。

例如,要麼在伺服器上呈現整個頁面,要麼在客戶端呈現整個頁面,反之亦然。如果引擎可以得到足夠的最佳化並且生成的程式碼可以足夠除錯,那麼採用 React 元件模型並讓開發人員切換元件的呈現位置似乎是一種強大的抽象。

React Server Components 承諾一種雙相程式設計形式,其中可以使用相同的 JavaScript + JSX 語法來表示在伺服器或客戶端上呈現的元件,並且可以靈活組合。伺服器端和客戶端渲染同時進行,但它們在空間上有所不同(在伺服器上與在瀏覽器上)。

我還想特別提到Electric Clojure ,這是我在[url=https://systemsdistributed.com/]Systems Distributed[/url]的一次閃電演講中發現的這個專案,它採用了類似的想法,在前端/後端邊界上提供強大的組合,但使用的是 Clojure 語言。

3、案例:Winglang
我對“雙相程式設計”理念如此好奇的很大一部分原因是,在過去的兩年裡,我一直在研究Winglang,這是一種用於編寫雲應用程式的新程式語言,它在設計中大量採用了這一概念。這個專案是我介紹的三個例子中最年輕的一個(它只開發了兩年),但在本文中,我將嘗試儘可能簡短地介紹它,以便為其雙相型別系統提供足夠的背景資訊。

Winglang 背後的要點是,由於擁有大量計算資源,AWS、Azure 和 GCP 等主要雲提供商能夠為開發人員提供各種可擴充套件的高階服務,如佇列、釋出-訂閱主題、工作流、流、儲存桶等。通俗地說,這些通常被稱為資源。Terraform和CloudFormation等基礎設施即程式碼工具使得使用 JSON 或[url=https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cloudformation-overview.html]YAML[/url]管理這些資源成為可能。

原則上,使用這些資源構建複雜的應用程式應該不難。但是,如果您的應用程式足夠大並且擁有許多資源,那麼將每個無伺服器函式或容器服務與其所需資源的許可權和配置明確連線起來就很容易出錯。圍繞這些資源設計自定義介面也很困難。

Winglang 旨在讓您編寫將基礎架構資源和應用程式邏輯組合在一起的庫和應用程式,透過該語言所稱的預檢和飛行程式碼。下面是一個示例程式來演示:

<font>// Import some libraries.<i>
bring s3;
bring lambda;
bring redis;
bring triggers;

// 定義我們的抽象。<i>
class Cache {
    _redis: redis.Redis;
    _bucket: s3.Bucket;
    new() {
        this._redis = new redis.Redis();
        this._bucket = new s3.Bucket();
    }

    pub inflight get(key: str): str {
       
// Check Redis first, otherwise fall back to S3<i>
        let var value = this._redis.get(key);
        if value == nil {
            value = this._bucket.getObject(key);
            this._redis.set(key, value!);
        }
        return value!;
    }

    pub inflight set(key: str, value: str) {
       
// Update S3 and redis with the new entry<i>
        this._bucket.putObject(key, value);
        this._redis.set(key, value);
    }

    pub inflight reset() {
        this._redis.flush();
        this._bucket.empty();
    }
}

let cache = new Cache();

//每小時清空快取一次。<i>
let schedule = new triggers.Schedule(rate: 1h);
schedule.onTick(inflight () => {
    cache.reset();
});

//建立一個 AWS Lambda 函式來執行一些虛假的業務邏輯。<i>
let fn = new lambda.Function(inflight (key) => {
    let value = cache.get(key!);
    return
"Found value: " + value;
});

// 將功能釋出到公共 URL。<i>
fn.expose();

在程式的頂層範圍內,所有程式碼都是預檢程式碼。除其他外,我們可以定義類、例項化資源並呼叫預檢函式(如onTick()和expose())來擴充和建立基礎架構。這些語句在編譯時執行。

但無論inflight使用關鍵字在哪裡,我們都會引入一個程式碼範圍,該程式碼只能在應用程式部署到雲後執行。

get()、set()和reset()都是預檢函式。

可以將 Winglang 的預檢/執行中區別與 Zig 的計算時間/執行時區別進行比較。但由於這兩種語言是圍繞不同的用例構建的,因此它們的設計截然不同,這可能並不奇怪。例如,Zig 的計算時間旨在避免所有潛在的副作用,而 Winglang 的預檢鼓勵副作用,以便您可以改變基礎設施圖。

Wing 提供了一種雙相程式設計形式,其中可以執行程式碼來定義雲基礎設施,或與雲基礎設施進行互動。這兩個階段稱為預檢和飛行,在時間(編譯時與執行時)和空間上有所不同(預檢在構建系統上執行,而飛行程式碼可以在支援 JavaScript 執行時的任何計算系統上執行)。

超程式設計總結
一個要點是,這種雙相程式設計可用於解決許多不同的問題。在 Zig 中,它使人們能夠輕鬆進行編譯時超程式設計。在 React 中,它使編寫更專業和最佳化的前端應用程式成為可能。在 Wing 中,它允許您對分散式程式的基礎設施和應用程式問題進行建模。這太酷了!

但這裡可能還有更多值得探索的地方:比如這些雙相解決方案的規則如何重疊或不同。

  • 在 Zig 中,您可以在 comptime 執行的每個函式也可以在執行時安全執行——因此我們可以說,哪些函式可以在 comptime 執行以及哪些函式可以在執行時執行之間存在子集關係。
  • 這同樣適用於 React Server Components——您可以在客戶端上呈現的任何元件也可以在伺服器上呈現。
  • 但在 Wing 中,預檢和檢修兩個階段是嚴格分開的,因此要表示可以在任一階段執行的程式碼,您需要為這些函式新增單獨的標籤(如“非階段函式”)。

另一個懸而未決的問題是瞭解雙相程式設計在多大程度上代表了無法用普通語言表達的能力。?

  • Zig 需要為這個 comptime 事物新增一個新的關鍵字 

但是否有其他現有語言可以讓你做到這一點,也許在使用者空間?
將其作為專用語言功能提供是否會提供任何改進的安全性或錯誤處理?

  • 超程式設計系統與雙相程式設計有關。例如,C 預處理可以被認為是雙相程式設計,因為它允許您在前處理器中執行程式碼,這是執行時之前的編譯階段。但它不滿足我提供的定義,因為前處理器只進行文字替換,而 C 的前處理器宏是有限的——ifdef 與真正的 if 語句完全不同。另一方面,Lisp 風格的衛生宏(如 Scheme 和 Racket 中的宏)是透過支援與基礎語言相同的表達能力的函式來表達的,所以我認為可以說 Lisp 提供了一些最古老的雙相程式設計示例 
  • 根據Zig 文件,comptime 表示式在某些方面受到限制 - 例如,它們不能呼叫外部函式、包含return或try表示式或執行副作用。但是,該語言的很大一部分是可用的,並且所包含的示例表明 comptime 函式不需要明確標記為這樣,這有助於使該功能感覺更普通 

JavaScript 不是最快的語言,但它可靠且擁有廣泛的生態系統。我們有興趣在未來支援其他語言 

網友討論:
1、我喜歡 "雙相 "這個詞!在 Javascript 網路開發中,以前的術語是 "同構 "或 "通用"。我認為這些術語並沒有真正流行起來。
近十年來,我一直在伺服器端和瀏覽器端渲染相同的 React 元件,我發現了一些非常好的模式,而這些模式在其他地方並不多見。

以下是我在個人專案中使用的架構模式。為了好玩,我開始用 F# 編寫,並使用 Fable 編譯成 JS:
https://fex-template.fly.dev

一個基本要素是將 express 移植到瀏覽器,並恰如其分地命名為 browser express:
https://github.com/williamcotton/browser-express

有了它,您不僅可以編寫雙相使用者介面元件,還可以編寫路由處理程式。在我看來,透過大量使用其他 React 框架的經驗,這種方法遠遠優於主流框架所採用的方法,甚至優於 React 開發人員所期望的工具使用方式。一個很好的副作用是,網站在啟用 Javascript 後也能正常執行。這也意味著互動時間是即時的。

它始終關注請求本身,透過瀏覽器中的點選和表單釋出事件建立模擬 HTTP 請求。它圍繞處理傳入請求和傳出響應的中介軟體進行了適當的架構,併為瀏覽器或伺服器執行時提供了並行的中介軟體。它使用連結和表單等網頁和瀏覽器原生概念來處理使用者輸入,而不是透過 React 中的受控表單來加倍處理瀏覽器的狀態。我不禁注意到,React 正在開始摒棄受控表單。他們終於意識到這種設計是錯誤的。

因為程式碼是以這種雙相的方式編寫的,並且注入了執行時上下文,所以避免了瀏覽器或伺服器執行時的任何條件。在我看來,將檔案標記為 "使用客戶端 "或 "使用伺服器 "是一種漏洞百出的抽象。

總之,我很喜歡這篇文章,並打算在實踐中使用這個術語!

2、最終,編譯時和執行時之間的任何區別都會被消解。其他一些二分法的例子也可以透過類似的通用酸來部分消解:

  • 動態型別與靜態型別,這是一個連續體,JIT 和編譯可以從兩端進行攻擊--在某種意義上,動態型別的程式也是靜態型別的--所有函式型別都是依賴函式型別,所有值型別都是和型別。畢竟,從屬和的一個項、一個從屬對只是一個盒裝值。
  • 單態化與多型化--透過表/介面/協議,大致以指令快取密度換取資料快取密度
  • RC vs GC vs 堆分配,透過編譯器輔助證明記憶體所有權關係,說明這應該如何發生
  • 將堆疊和指令指標特權化,而不是讓這種瞬態程式狀態成為與其他資料結構一樣的一流資料結構,以實現你自己的共同程式和其他任何東西:Zig 決定,記憶體分配不應被賦予特權,以至於成為一種 "隱形設施",讓人以為它是全域性性的。
  • 我們可以使用指標函式,當你恰好知道需要多少專案,以及如何訪問、擁有、分配和取消分配這些專案時,這些函式就能以更有效的方式透明地進行單形態化。
  • 取而代之的是,在最佳化程式碼的過程中,或多或少都要考慮到記憶體使用、執行效率、指令密度、表示語義的清晰度等等等等。

目前,我們有一些奇怪的孤立方式,可以在某些語言中實現特定的特權,並對你能走多遠設定了相當武斷的界限。我希望有一天,我們能有一種語言,能將所有這些決策制定和工程設計溶解到通用的設施中,在這種設施中,語言可以是你需要的任何東西--它只是一箇中立的基底,用於表達計算,以及你想如何生產出可以以各種方式執行的機器製品。

據推測,未來這樣的語言,如果真的存在,應該會從今天的證明助手中衍生出來。

3、程式語言和程式碼的其他 "雙相 "特性:

  • - 由內聯程式碼註釋生成的文件(Knuth 的識字程式設計)
  • - 測試程式碼

我們可以擴充套件到

  • - 安全性(超越 perl 汙點)
  • - O(n) 執行時和記憶體分析
  • - 並行或聚類
  • - 延遲預算

對於那些有學術傾向的人來說,形式語言語義,如 https://en.wikipedia.org/wiki/Denotational_semantics 與運算等比較。

4、“雙相程式設計”也存在於 Apache Spark、Tensorflow 等框架、Gradle 等構建工具以及程式碼優先工作流引擎中。第一階段的執行會生成一個稍後要執行的程式碼 DAG。在我看來,對於新手來說,最難的事情是第一階段和第二階段的程式碼交錯在一起,沒有直接明確的界限(第一階段的程式碼類似於內部 DSL)。

5、雙相程式設計的另一個示例是使用 DSL 生成解析器的解析器生成器,例如 Tree Sitter 或 Lezer。

6、作者是自鳴得意的反 Lisp 狂人:Lisp 中並非所有東西都是列表。
事實上,Lisp 和 Forth 是最強大的“雙相”語言之一,因為完整語言中的兩種表示式都可以在編譯時進行求值。
Pre-Scheme 是 Scheme 的一個無 GC、靜態型別的“系統”子集,它允許您使用完整的 Scheme 語言來處理任何可以在編譯時進行可證明求值的表示式(例如,使用 DEFINE 在頂層引入變數)。

 

相關文章