双相元编程:一种新语言设计方法


本文讨论了编程语言的一种趋势,即允许相同的语法表达

  • 在两个不同阶段或环境(上下文)中执行的计算
  • 同时保持跨阶段(上下文)的一致行为。
  • 这些阶段通常在时间上(运行时间)或空间上(运行地点)有所不同。

作者提供了三种体现这种“双相编程(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("std").testing.expect;

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

test
"fibonacci" {
   
// 运行时测试斐波那契
    try expect(fibonacci(7) == 13);

   
//在编译时测试斐波那契
    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 旨在让您编写将基础架构资源和应用程序逻辑组合在一起的库和应用程序,通过该语言所称的预检和飞行代码。下面是一个示例程序来演示:

// Import some libraries.
bring s3;
bring lambda;
bring redis;
bring triggers;

// 定义我们的抽象。
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
        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
        this._bucket.putObject(key, value);
        this._redis.set(key, value);
    }

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

let cache = new Cache();

//每小时清空缓存一次。
let schedule = new triggers.Schedule(rate: 1h);
schedule.onTick(inflight () => {
    cache.reset();
});

//创建一个 AWS Lambda 函数来执行一些虚假的业务逻辑。
let fn = new lambda.Function(inflight (key) => {
    let value = cache.get(key!);
    return
"Found value: " + value;
});

// 将功能发布到公共 URL。
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 在顶层引入变量)。