Jai 语言概览

介绍吹哥的 Jai 编程语言以及我的使用心得体会。
发布于 2026-07-08

引言🔗

我参与了 Jai 语言的 beta,到目前为止在业余时间用了快一年了。我觉得 Jai 很棒,他很有可能在这一批新的系统编程语言里脱颖而出。本文会着重介绍 Jai 的语言特性和功能,后面再谈谈我的想法。

Jai 是 Jonathan Blow 这十余年一直在开发的一款新的计算机编程语言。如果你关注独立游戏历史你大概率对 Jonathan Blow 这个名字不陌生,后文会简称他为吹哥。他在 Indie Game The Movie 里出镜,他的游戏 Braid 是最早的独立游戏之一。在他开发完 The Witness 后他遇到了一个问题:是继续把时间花在用 C++ 做续作,还是干脆设计一个新的编程语言来彻底解决 C++ 这个问题。吹哥选择了后者,然后在这十年里设计并实现了 Jai 语言,用这个新的语言做了新的引擎,并开发了新的游戏 《沉星之序》(Order of the Sinking Star),这个游戏将在今年发售。Jai 语言在前几年开启了小范围的非公开 beta,按吹哥的说法他会在新游戏发售后发布 Jai 语言,并配上一个小的游戏引擎 demo。

特色功能🔗

Jai 的定位是一个系统编程(System Programming)语言,对标的语言中经典的是 C/C++,现代的有 Rust/Zig/Odin。至于什么是系统编程语言,你可以理解为用来实现游戏引擎,浏览器,操作系统这些大型系统级软件的编程语言。这类软件的复杂程度导致他们必须选择这些我们平常说的底层语言来实现。

我们从一个 hello world 开始:

#import "Basic";

main :: () {
    print("hello %\n", "world");
}

Jai 的编译器就叫做 jai,调用它把上面的程序编译成 binary 并且执行:

> jai helloworld.jai
Stats for Workspace ("Target Program"):
Lexer lines processed: 10035 (15847 including blank lines, comments.)
Front-end time: 0.041784 seconds.
llvm      time: 0.061989 seconds.

Compiler  time: 0.103773 seconds.
Link      time: 0.209867 seconds.
Total     time: 0.313640 seconds.

>.\helloworld.exe
hello world

通过编译器的输出你会发现它是基于 LLVM 实现的,也要通过 linker 来生成最终的 binary。跟所有系统编程语言一样 Jai 是一个静态类型,编译到对应目标平台指令集的机器码的编程语言。我觉得要介绍 Jai 不如从它与众不同的"特色功能"开始:compile time execution,context,和 metaprogramming。

Compile Time Execution🔗

编译时执行现在已经不是新鲜的概念了,C++ 里对应的就是 constexpr 相关的一系列功能,Zig 里对应关键字 comptime。但是 Jai 在这方面跟上面两个例子是有本质的不同的:Jai 可以在编译时执行任意 Jai 代码。

我想会有不少人跟我一样对 Jai 最初的印象都是吹哥在早期的一个视频 demo:他实现了一个 Space Invaders,一个简单而无聊的小游戏。随后他改了几行代码,把这个游戏整个在编译期间执行,你需要在这个游戏里获得一定分数才能让编译通过,不然编译就会失败。这个 demo 给我印象非常深以至于我后面时不时会想起它。

所以在我拿到 Jai 的时候我第一时间就想复现这个 demo。实际上这个小游戏的确还在,但是它被设计成展示多平台支持和编译脚本,要改成编译时执行需要改比较多的代码,然后又碰到一些 bug 导致我没法复现那个 demo。但我用一个简单一点的例子我重现了类似的效果。

jai 附带的 examples/d3d11_example 里面是一个简单的 d3d11 画一个三角形的例子。你可以把他编译成 exe 并执行:

jai\examples\d3d11_example>jai .\d3d11_example.jai 
jai\examples\d3d11_example>.\d3d11_example.exe
Created D3D11 device 181_309c_0588 with feature level _11_1
Create swap chain
Create a render target view
Setup the viewport
Compile the vertex shader
Compile the pixel shader
Create vertex buffer

d3d11_example

这个例子主要展示了 Jai 如何调用 win32/dxgi 这些系统 API,如何调用附带的 d3dcompiler dll 来编译 shader,然后在这 500 行内用 DX11 在屏幕上画一个三角形。你顺着看就会发现 Jai 附带一个模块 "Bindings_Generator",它是通过 libclang 编译头文件来生成对应的 Jai bindings,这些都很酷。

然后你可以打开 "d3d11_example.jai" 文件,在最末尾加上一行代码:

#run main();

Jai 里面特殊的调用都是以 # 开头,被称作 "directives",这里 #run 用来在编译时执行任意代码。#run main() 就是字面上的把 main() 在编译时就跑一遍。重新编译这个程序:

# added `#run main()`
jai\examples\d3d11_example>jai .\d3d11_example.jai 

你会发现在编译出 exe 之前它就弹出窗口把三角形画出来了。这就是 Jai 编译时可以执行任意代码的意思:它甚至可以编译时执行 main()

.................................
这一段落留点时间给你思考一下到底发生了什么。
.................................

你可能会说 C++ 和 Zig 也都可以在编译时执行任意代码,这两者的确有一个共性:他们编译时执行的语言是一个严格的子集,有诸多限制,而且跟其语言最终 runtime 执行的环境是完全不相干的两个世界。一个标志性的例子就是 C++ constexpr 不能调用 std::cout/printf(),Zig 在 comptime 里面不能调用 std.debug.print 而需要跑特殊的 @compileLog。如果你手头可以写点代码尝试的话,你会发现 C++/Zig 在这个情况下会报出非常夸张的错误,并不能简单的告诉你他们编译时不能跑这些。

究其原因我认为就是 C++/Zig 的编译时执行的实现,就是它编译器本身的一个子集。Zig 方面它是在做 semantic pass,做 ZIR -> AIR 转换的时候顺便执行的。而 Clang 我并不知道它是怎么执行的,但我知道的是它有一个正在进行的计划就是实现一个 Constant Interpreter 来实现一个专门的 bytecode VM 来跑 constexpr,很大一块原因就是为了提升效率。

实际上 Zig/C++ 编译时执行的这个情况可以说是他们语言设计上的一个选择,他们可以利用编译时的这个“世界”做很多特别的事情,比如 Zig 就可以用 comptime 函数来返回一个类型,这也是 Zig 里面 generic 的实现方式,而 Jai 的编译时执行的设计是:运行时能做什么,编译时就能做什么,没有例外。

在上面编译时跑 d3d11_example main() 的例子里展示了,Jai 在编译时能 print 能做 IO,能够加载其他 module,能够调用 win32 API,能够加载并且调用 dll 函数。这基本上展现了 Jai 能够在编译时正常使用它所有的功能,那么它是如何实现的呢?

#dump 这个 directive 可以加在任意函数声明后来查看它的 bytecode:

#import "Basic";

main :: () #dump { /// <- added #dump here
    print("hello %\n", "world");
}

编译可以看到:

>jai .\helloworld.jai

Disassembly of 'main' in Workspace 2 at helloworld.jai:3
- Stack size 96

-------- Basic Block 0 -------- defines v1-12 --------

           (no dominating)

   0|   stack_node
   1|          lea   v2, stack+0x20
   2|     constant   v3 = 8
   3|          mov   [v2], v3 :8b
  ......
  20|         call   print (v2, v6, v12) -> v1
  21|       return

Jai 实现上是有一套他自己的 stack VM bytecode 的。我估计在编译时先是把代码编译到 AST 然后到这个 bytecode,如果是进最终 binary 就走最后生成代码,如果是 #run 那么就做 interpret 执行,然后两者在 semantic,在语义上是相同的。在这个条件下有些很有意思的现象:

作为一个新的系统编程语言,Jai 可以轻松调用 C/C++ 的静态和动态库。这里是一个调用 Jai 附带的 stb_image 中一个函数 stbi_info 的例子:

#import "Basic"; 
#import "stb_image";

example :: () {
    x,y,comp: s32;
    if stbi_info("a.jpg", *x, *y, *comp) print("% % %\n", x, y, comp);
    else print("fail!");
}

main :: () {
    #run example();  /// compile time calls into stb_image.dll
    example();       /// runtime statically links stb_image.lib
}

jai/modules/stb_image/windows 下可以找到有动态库 stb_image.dll 和静态库 stb_image.lib,在默认的情况下正好对应编译时和运行时两个情况:

  • #run example(); - 编译时调用的是 stb_image.dll
  • main :: () { example(); } - 运行时则是通过静态链接 stb_image.lib 找到函数,最终 binary 并不依赖 dll。

当我刚搞清楚这个的时候有一种开窍了的感觉:Jai 的编译时执行同样映射到外部库调用,并且跟动态链接库这个历史悠久的功能完美的对应了起来,甚至有一种数学公式推导出来的对称感。

Jai 允许通过 #library,no_dll 来只链接静态库,也允许只有 dll 的情况。横向比较的话,Zig 在这块特别出彩,Zig Build System 可以直接编译 C/C++ 代码,跟 Zig 最后编译出来的 objects 混在一起 link。Zig 本身也花很多功夫把各种版本的 libc 和标准库都带上,来实现任意平台的 cross compiling。Jai 也附带了 libclang 但主要是用来跑头文件来辅助生成 bindings。同时内置了 BuildCPP 模块用各个平台标准的 toolchain 来编译 C/C++ 代码。

另外一方面,Jai 有非常强大的 metaprogramming 的功能,后文会展开讲。而其中很重要的一部分就是 introspect/reflection API。Jai 实现了 first class type,Type 代表一个类型可以作为函数参数,可以作为 struct member。

#import "Basic";

Foo :: struct {
    t: Type; /// <- Type as struct field
}

bar :: (t : Type) { /// <- Type as function argument
    print("%\n", t);
}

main :: () {
    // both prints "Foo"
    #run bar(Foo);
    bar(Foo);
}

Jai 的 Type 在编译时和运行时都是可以用的,而且它在运行时是附带完整的类型信息的。横向比起来 Zig 有类似的 @typeInfo 但是它在运行时是不存在的,这个很符合它硬核系统编程的身份;C++ 里面有 std::typeid 但它在运行时只有一个数字 id 和名字。而 Jai 在运行时的类型信息是完整的。下面的 list_members() 实现列出一个 struct 所有成员的名字和类型:

#import "Basic";

list_members :: ($t: Type)
{
    ti := type_info(t);
    assert(ti.type == .STRUCT, "accept only struct");
    tis := cast(*Type_Info_Struct)ti;
    /// `for` loop auto declares scoped variable `it` as iterator
    for tis.members print("%: %\n", it.name, it.type.*);
}

User :: struct {
    id: s32;
    name : string;
    age : s32;
}

main :: () {
    #run list_members(User);
    list_members(User);
}

/// both prints
id: {INTEGER, 4}
name: {STRING, 16}
age: {INTEGER, 4}

实际上 Jai 可以通过 #type_info_none 给指定的 struct 去掉 type info。但这里跟编译时执行最相关的一个启示是:Jai 的编译时反射和运行时反射是用的一摸一样的 API。

跟其他语言比较起来:C++ 26 Reflection 实现的 enum_to_string 大概长这样:

template <typename E>
  requires std::is_enum_v<E>
constexpr std::string enum_to_string(E value) {
  template for (constexpr auto e : std::meta::enumerators_of(^^E)) {
    if (value == [:e:]) {
      return std::string(std::meta::identifier_of(e));
    }
  }

  return "<unnamed>";
}

它这里引入了全新的语法 ^^[:e:], template for 我也没见过,然后 std::meta 也是配套的 STL 新功能。如果你想实现一套运行时也可以用的版本,我猜测你要通过这个 reflection 把信息都存一份到自己的数据结构里然后实现一套类似的函数。

Jai 的 Reflection 模块里有一个类似功能的函数 enum_value_to_name

enum_value_to_name :: (e_value: $Enum) -> string {
    info := type_info(Enum);
    #assert type_of(info.*) == Type_Info_Enum;

    for info.values {
        if it == xx e_value {
            return xx info.names[it_index];
        }
    }

    return "";
}

它这里就是普通的 Jai 程序,编译时和运行时都能用,会返回一样的结果。这里举 C++ 作为反例更多是为了展示想设计一套编译时和运行时都能用的 API 实际上就是很难。一个类似的情况是 C#:在运行时用 GetType()System.Reflection 来做运行时反射,这套从 C# 1.0 开始就存在了。

但后续 .NET 6 引入 Incremental Source Generators 的时候又有了一个新的问题:它需要对还没有编译的 C# 代码做分析来生成代码,这个时候老的 System.Reflection 肯定用不上,因为你的目标代码还没编译好。C# 在这里就需要用它编译器 Roslyn Microsoft.CodeAnalysis.CSharp 这一套看起来很类似但又完全不一样的 API。究其原因就是 System.Reflection 操作的对象是 C# dll,类比为运行时;而 Microsoft.CodeAnalysis.CSharp 操作的对象是 C# 源代码,类比为编译时。

而 Jai 把这两者统一合并到了一起,你就总是用 type_info() 就完事了。那么为什么 Jai 能做到这点呢?我觉得这跟他独特的编译模式有关系。对于下面这个例子:

#import "Basic";
#run print("will this be printed?");
foo :: () { doesnt_exist(); }

这里 foo() 里面调用了不存在的函数,最终编译是不通过的,那么上面的 #run print 会被执行吗?

答案是不一定,因为 Jai 的编译__并不是__ parse->sema->codegen 这样线性跑下来的,而是其中几个步骤是会循环检测 dependency 来反复跑直到所有代码都处理完或者出错为止。这个在后文会展开讲到。

总结下来:Jai 可以在编译时执行__任意__ Jai 代码。你可以感觉的出来要做到这个是要单独花功夫设计并实现,并且做起来是有些难度的,不然早就应该有语言做到了,但据我所知 Jai 应该是第一个做到这个的系统编程语言。我感觉像这样把复杂的东西包装成简单,一致且准确的抽象给你,是 Jai 里面反复出现的一个模式,这样对于 Jai 的用户来说反而能减轻思考上的包袱。

最后一个例子:Jai 有支持 inline assembly,所以他也支持编译时跑 assembly。

do_some_work :: (a: int, b: int) -> int {
    #asm { add a, b; }
    return a;
}

A :: #run do_some_work(10, 13);
main :: () { 
    print("A: %\n", A); /// prints 23
}
#import "Basic";

Context🔗

Context 是 Jai 引入的一个新概念,它对应的是一个总是可用的 context struct instance。它被用来解决的最基础的一个问题就是管理内存分配的 allocator。

我感觉 allocator 这个古老而又基础的概念又被重新重视了起来。一方面是新的系统编程语言比如 Zig/Odin/Jai 开始着重谈论这个;另一方面像 wasm 目标的流行给一些更简单的 allocator 创造了需求。而且现代的编程性能优化很大一块都是要优化内存布局优化 data locality,allocator 在其中的重要程度更是提高了。

但关于 allocator 最让人兴奋的点其实是在不同的场景里你可以使用特殊的 allocator 来获得远超通用算法的性能:比如你有一些东西要逐步分配但是可以一起释放,那就是 arena allocator 的标准用例;比如游戏开发里可以用 bump allocator 作为 frame temp allocator,每一帧里所有的临时 alloc 都用它然后在每帧结束的时候清空。这些算法都不难,但他们的难点可能是在“软件工程”上,你要怎么把 allocator API 暴露给用户。

最经典的做法其实就是摆烂,比如 C 就只有 malloc()/free(),所有内存分配都走这个全局统一的接口然后指望底层的算法够厉害。还有就是像 C++ 里一样简单封装来假装解决了这个问题,比如 std::vector 就有一个 allocator template argument 但从来没有人用它,后续在 C++ 17 增加的 std::pmr::polymorphic_allocator 再做一点努力但情况基本没变化。

Zig 在这方面走的是另外一个极端:它所有的 allocator 调用都需要显式的传一个 allocator 参数。 一个标志性的例子就是对标 C++ 里 std::vector::push_back(),Zig 里的 array_list.append() 需要传入 allocator。在 Zig 0.16 版本里面彻底把 containers 跟 allocator 分开让这个设计更为明显。Zig 最核心的几个准则里就有一条是 "No hidden memory allocations",这就是它语言一部分,也是因为是这样严苛的设计催生了 TigerBeetle 这样没有运行时动态 alloc 的数据库

个人体验下来 Zig 写起来每一个可能产生内存分配的地方都很显眼,而因为这个原因你会更倾向于避免产生 alloc,这个对性能的确是好的;但是坏处是你会发现需要 alloc 的地方真的很多导致 zig 写起来感觉很 verbose。比如你程序写的好好的马上要调用 std.fs.path.join,然后发现它需要一个 allocator 但是你正在写的这个函数 scope 里面并没有一个 allocator,你就要想我是全局准备一个 temp allocator 呢还是跟着这个 scope 准备一个,还是其实我这个返回的结果是有更长的生命周期的需要某个指定的 heap allocator,那么这个 allocator 要跟着参数一路顺着 callstack 从上传下来,有好多函数都要加这个参数。这些的确都是硬核高性能系统编程的一部分,但要正确的处理它需要你花费精力。某种程度上这也解释了为啥 C/C++ 在这个方面摆烂:因为不好弄所以干脆放弃。这两个情况我都归结成编程语言没有提供足够的工具来解决 allocator 选择的这个问题。

Jai 通过 context 功能来尝试解决这个问题,我们从一个动态长度数组的例子开始:Jai 内置了动态长度的数组,语法是 [..]

arr : [..]string;
array_add(*arr, "foo"); /// add an item
array_free(arr);        /// free memory

我们希望一个动态数组是 "allocator aware" 的,需要它:

  1. 在创建的时候主动的选择 allocator。
  2. 能隐式的根据当前环境来选择指定的 allocator。
  3. array_free() 的时候能找到分配时用的 allocator 来做 free。

那么在 Jai 里我们需要做什么让 [..] 成为 "allocator aware" 呢?

你估计已经猜到了,Jai 的动态长度数组默认就满足上面三点。Jai [..] 对应的 struct 中记录自己的 allocator:

Resizable_Array :: struct {
    count      : s64;
    data       : *void;
    allocated  : s64;

    allocator  : Allocator; /// <- stores allocator
}

Allocator :: struct {
    proc: Allocator_Proc;
    data: *void;
}

跟 C++/Zig 相比 Jai 的选择是需要动态分配内存的数据结构都显式的记录自己的 allocator,这个设计是一个利弊权衡后的选择:代价是内存上每个数据结构付出两个指针大小的 overhead,以及每一次 allocate 时候都要做没法 inline 的函数调用,但是优势是更大的灵活性。

比如我们想要一个 [..] 使用临时的不需要释放的 allocator,Jai 里面有一个内置全局的 temp 可以用:

arr : [..]string;
arr.allocator = temp; /// <- use temp allocator

这样 arr 在需要分配内存的时候会通过 temp 调用一个快速简单的分配函数,在释放的时候会通过 temp 调用一个空的函数来跳过释放。这样我们做到了上面提到的 1 和 3。

那么如果你没有显式的设置 arr.allocator 直接使用,这里对应的就是上面 2 的情况:Jai 根据你的配置选择一个 allocator。在这个动态数组需要 "grow" 的时候会检查自己 allocator 是否有设置,如果没有则会执行到 remember_allocators

remember_allocators :: (data: *$T) {
    if context.allocator.proc {
        data.allocator      = context.allocator;
    } else {
        data.allocator      = context.default_allocator;
    }
}

这是 Jai 里对数据结构约定的一个 convention,在没有显式设置 allocator 的情况下调用 remember_allocators 来选择 context.allocator 记录在数据结构上,而这里的 context 就是这套机制的核心。

在 Jai 语言里 context 是一个特殊的自动声明的变量,相当于你的每一个函数都隐式的多接受一个 context 参数,它是 thread local 的,从整个程序的入口开始顺着 callstack 传给你的每一个函数,你可以在任意地方访问,或者修改它。

类比的话我觉得 context 有一点像 Lua API 里的 lua_State: Lua 整个 C-API 所有函数第一个参数都是 lua_State *L。在 Jai 里面逻辑上所有的函数都有一个 context 参数,但是它是被隐藏起来的。实现上这块肯定有优化,因为函数对应的 Type_Info_Procedure 里有一个 Flags.HAS_NO_CONTEXT 来标记这个函数是否用到了 context

Context 跟你代码的 control flow 或者说是 callstack 是强相关的。接着上面的例子比如我想要从这里开始往后全都使用 temp

context.allocator = temp;

arr : [..]string;
array_add(*arr, "foo"); /// <- implicit uses `temp` allocator

除了直接读写 context 还有一个 push_context 更能展现其跟 callstack 的关系:push_context 在它的 scope 里面用一起新的 context,在 scope 之外则完全不受影响。

temp_context := context; /// <- make a copy of current context
temp_context.allocator = temp;

push_context temp_context {
  arr1 : [..]string; /// <- uses `temp`
}

arr2 : [..]string; /// <- uses default allocator

这个例子看起来就很有 C++ RAII 那一套的感觉了,实际用例里用户大多也是根据 scope 来操作 context 的内容。

context 这个变量本身其实没有太多的魔法,它就是一个 struct instance:

/// default context struct
Context_Base :: struct {
    context_info:  *Type_Info_Struct; 
    thread_index   : u32;
    allocator      := default_allocator;
    /// ...
    default_allocator :: Allocator.{runtime_support_default_allocator_proc, null};
}

用户可以通过 #add_context primitive 来给 context 添加成员:

#add_context num: int = 33;
foo :: () {
    context.num = 253;
    print("%\n", context.num);
}

既然 context 是一个 struct 那么 #add_context 是怎么给这个 struct 添加成员的呢?实际上 Jai 在编译时生成一个新的 struct 类型把所有 #add_context 提供的成员全都塞进去,你可以用 #Context 来拿到这个类型。用上文例子里的 list_members() 查看其成员:

list_members(#Context);

/// prints
base: {STRUCT, 96}
num: {INTEGER, 8}
print_style: {STRUCT, 424}

其中 base 正是一个 Context_Base,而 print_styleprint() 所在的 module 里面添加的成员。这个新生成的类型会被作为你整个程序的 #Context

熟悉 C++ 的读者会发现一个问题,跨文件,跨 module 的 #add_context 会更改这个随处可见的 context 的类型和大小,相当于是在一个所有 compile unit 都引用的头文件里做修改。那对应到 C++ 这个情况是很难处理快速增量编译,DLL 边界的问题的。而这部分也是 Jai 编译模型的一个特色,它的每次编译都是从 *.jai 代码编译到 binary 的全量编译,没有增量编译没有缓存。这种编译模型跟 Jai 的编译时执行就很匹配,同时 Jai 很看重的一个指标就是编译速度,吹哥最近的一个演示里面是35 万行编译时间 3.15秒。显然做到这个是不容易的,Jai 编译器本身是多线程的,可以通过这个简单的程序验证:

#import "Basic";
#run print("1");
#run print("2");
#run print("3");
/// prints in random order

同时 Jai 自己实现了一个非 LLVM 的后端用命令行 -x64 开启,可以获得更快的迭代版本编译速度。Jai 是完全支持静态动态库的,上面提到的库之间 context 差异它有一个内置的 Remap_Context 专门来处理。我个人感觉超高速的全量编译肯定是 Jai 不会放弃的一个基础,在代码量再上一个数量级的时候可能会遇到新的问题需要新的解决方案,但真的碰到这个问题的时候 Jai 作为一个编程语言已经成功了。

就我个人来说 Context 是我在看 Jai 的演示和文档时候印象最深的一个功能,但实际自己写起来以后我发现自己主动用它的情况并不多。很大一部分原因是 Jai 标准库已经把 context 常用的东西以及规范都做好了简单的程序并不用太在意它,另一方面这是一个为大型软件项目设计的功能。比如在 Unreal Engine 里 UWorld 就是一个很适合被放到 context 里的东西,这样可能所有 Actor/Component 都不用存自己的 World。Context 这套机制算是 Jai 的一个发明,其实际效果和影响可能要等更多人用上后才会有个结论。

最后 Jai 里还有一个特殊的 ,, operator 来操作 context。很多时候你只是想要类似 push_context 的行为但其实又只是想要改其中一个值,Jai 这里在函数调用的参数里面通过 ,, 让你像额外添加参数一样来修改 context 里的值:

s := join("hello", "world",, allocator=temp);

/// equivalent as above
old_allocator := context.allocator;
if temp.proc context.allocator = temp; /// <- `allocator` field only special check
s := join("hello", "world");
context.allocator = old_allocator;

Jai 还有一溜这种很酷很特别,又不太常见的小功能,后文会再提到。

Metaprogramming🔗

Metaprogramming 翻译过来“元编程”跟元宇宙一样很难从概念上讲清楚。"meta" 作为前缀我一般理解为“xx的xx”,所以“元宇宙”就是“宇宙的宇宙”,然后“元编程”就是“编程的编程”:在编译时对程序做编程。一个 C++ 里最简单的 template 的例子:

template <typename T>
T add(T a, T b) {
    return a + b;
}

add(1,2);        /// instantiate for int
add(3.0f, 4.0f); /// instantiate for float

其中 instantiate add() 的行为在编译时生成了新的 add<int>()/add<float>() 函数,这个行为就是在编译时对程序的“编程”,可以算作 metaprogramming 的一个例子。但是你可以注意到 C++ 的 metaprogramming 的机制一部分是通过 template 来实现的,它本身是叠加在 C++ class/function 这些基础之上的一套概念,它们之间的区别之大可以被认为是两种语言,有不同的语法语义。

而 Jai 在编译时可以跑它完整的 Jai 语言,所以它 metaprogramming 的底子打的就很好。Jai 在此之上提供了非常完善的配套功能,使得它这块的天花板无限高;但很酷的是 Jai 也保持了简单的东西足够简单,使得 metaprogramming 门槛低随手就能用上。这块实在是太抽象了我们还是从具体的例子入手。

C++ 让人诟病的一点是你要无脑的写一些重复的代码,这种被称作 "boilerplate",一个最经典的例子就是 struct operator==:

struct Person
{
    std::string name;
    int age;

    bool operator==(const Person& other) const { return name == other.name && age == other.age; }
};

对每个需要的 struct 你都要无脑的遍历所有成员写一遍,以后添加成员的时候还得想起来改这里。C++ 的设计者们也认为这是个问题所以在 C++20 里面允许 operator ==() == default 以及新的 "<=> spaceship operator"。

Jai 语言里也允许 operator overloading,同样的也没有默认 struct operator== 的实现,但是 Jai 通过 metaprogramming 给你提供了工具来轻易的避免这些 boilerplate:

/// a generic `operator ==`
operator == :: (a: $T, b: T) -> bool
{
    #insert -> string {
        sb: String_Builder;
        info := cast(*Type_Info_Struct) type_info(T);
        for info.members {
            if it.flags & (.CONSTANT | .OVERLAY | .IMPORTED) continue;
            if it.type.runtime_size == 0 continue;
            print_to_builder(*sb, "if a.% != b.% return false;\n", it.name, it.name);
        }
        return builder_to_string(*sb);
    }
    return true;
}

Person :: struct {
    name: string;
    age: int;
}
Point :: struct {
    x: int;
    y: int;
}

/// it works on all structs
main :: () {
  p1 := Person.{"Alice", 30};
  p2 := Person.{"Alice", 30};
  assert(p1 == p2);

  p3 := Point.{1,2};
  p4 := Point.{3,4};
  assert(p3 != p4);
}
#import "Basic";

在上面这段代码中我们声明了函数 operator == :: (a: $T, b: T) -> bool,这个函数声明在语义上跟 C++ 里面 template <typename T> bool operator==(const T& a, const T& b) 可以说是等价的。Jai 在遇到 a == b 的时候会去找所有叫 operator == 的函数并且对参数类型做匹配,这块跟 C++ 也非常类似。

在这个函数内有一个 #insert directive,这是 Jai metaprogramming 中的一个方式:把它的参数作为代码插入在这个地方。其内容则是根据 Type_Info_Struct 编辑所有的成员,以字符串操作的形式来生成对应的检查代码。后面 main() 中我们分别对 PersonPoint== 判断来确保它生效。

jai 命令编译过这个文件以后,检查当前目录下的 .build 文件夹,这里是 Jai 用来放临时文件的目录,里面有 obj/lib 文件,同时还有一些 .added_strings_wX.jai。打开它们寻找有如下内容的:

// Workspace: Target Program

//
// #insert text. Generated from /tmp/eq.jai:4.
//
if a.name != b.name return false;
if a.age != b.age return false;

//
// #insert text. Generated from /tmp/eq.jai:4.
//
if a.x != b.x return false;
if a.y != b.y return false;

这里正是上面 #insert 在编译时执行的结果。Jai 一部分 metaprogramming 的功能就是以编译时执行字符串拼接来提供给你的。用 print() 来生成代码乍看上去非常低端,但我觉得这是一个非常精妙的选择。

像上面看到的 Jai 所有 #insert 生成的代码都会真的被写到文件里,你在开发的时候就可以查看生成的文件来确认你是否拼对了来更方便的迭代(因为 Jai 是先生成再编译,生成的代码编译不过你也可以看到它错误的展开),在调试的时候可以断点到生成的代码里面,调试信息也能正确的工作。其实 C/C++ 里面用 macro 跟这个方式很类似,但是 macro 一方面其功能更受限,另一方面它就没有把中间代码生成出来,你最后调试的时候断点到 macro 的地方就很恶心。

另外比起生成字符串来表示代码,是不是直接生成 AST nodes 会更高级一点呢?如果你有在实际项目里做过代码生成的话你多半会认同:比起用 API 来构建代码,直接把它当字符串最后 print() 出来是最灵活,最直观的。还是 C# Source Generator 的例子,直接输出代码比用 API 拼大部分情况会更简单行数更少,事后看起来也更容易看懂。通过 AST 来做 metaprogramming 更适合的情况是来查阅和修改你已经写好的程序,而这也是 Jai 提供的 metaprogramming 功能的另一组成部分。

接下来,我们声明一个函数 foo() 里面 print() 出字符串,之后我们通过 metaprogramming 生成一个函数 FOO(),功能跟 foo() 相同但是把所有里面用到的字符串变成大写:

foo :: () {
    print("hello ");
    print("world!\n");
}

/// synthesize `FOO()` and change its literals all uppercase
#insert -> Code {
    root, nodes := compiler_get_nodes(code_of(foo)); /// Code -> Code_Node
    header := cast(*Code_Procedure_Header)root;
    header.name = to_upper_copy(header.name);

    body_nodes: [..] *Code_Node;
    get_recursive_nodes_breadth_first(header.body_or_null, *body_nodes);

    for body_nodes {
        if it.kind != .LITERAL continue;
        literal := cast(*Code_Literal) it;
        if literal.value_type != .STRING continue;
        literal._string = to_upper_copy(literal._string);
    }

    decl := New(Code_Declaration);
    decl.* = {
        kind = .DECLARATION,
        name = header.name,
        expression = root,
        flags = .IS_CONSTANT,
    };
    return compiler_get_code(decl); /// Code_Node -> Code
}

main :: () {
    foo(); /// prints "hello world!"
    FOO(); /// prints "HELLO WORLD!"
}

#import "Basic";
#import "Compiler";
#import "String";

首先我们通过 code_of(foo) 来拿到 foo() 对应的 Code。Jai 里的 Code 类型代表代码,它更像是一个 handle 类型本身没有太多信息,我们需要引用 Compiler 模块用里面的 compiler_get_nodes() 来拿到其对应的 AST 节点,对其做处理以后,通过 compiler_get_code() 把它再转回为 Code 最终通过 #insert 加入到程序里。后面我们调用了 FOO() 这个在代码看不到的函数。跟上面 #insert -> string 的例子不同的是,#insert -> Code 产生的代码是不会被写到 .added_strings_wX.jai 里的。用这个方式的优势是可以根据已有的 AST 来操作,更高级的用例你甚至可以在 AST 层次修改已有的代码。

上面的例子中我们直接通过 #insert 来做 metaprogramming,但实际使用上更多情况是使用 macro 来做这些。如果说 "programming" 中基本单位是函数,那么 "metaprogramming" 的基本单位就是 macro。Jai 语言里的 macro 跟普通函数语法是一样的,唯一的区别是有一个 #expand directive。跟普通的函数相比,macro 总是在编译时执行,同时可以通过 ` (quote) 来访问上一层 scope 中的变量,还可以用 ``return` 来给上一层 scope 返回:

#import "Basic";

add_1_return_on_2 :: () #expand {
    `x += 1;
    if `x == 2 `return; 
}

main :: () {
    x := 0;
    defer print("x: %\n", x); /// defer executes on scope exit
    add_1_return_on_2();
    add_1_return_on_2();
    add_1_return_on_2();
    add_1_return_on_2();
}
/// prints "x: 2"

这个例子中的功能 C/C++ 中使用 #define 来做的 macro 也可以做到,但区别是 C/C++ macro 是用 preprocessor 来实现的,它本质是另一个小的编程语言,跟 C/C++ 是没啥关系的,它只做文本处理并不理解 C/C++ 代码的语义。Zig 能在编译期间执行的函数又是 zig 所有函数中的一个子集,它缺少了 ` 这种操作上层 scope 的能力。Rust 里有 macro 但感觉不太一样:macro_rules! 我觉得基本上也算是字符串操作,而 procedural macro 则是用 Rust 语言来处理 tokens,它甚至不需要这里的 tokens 是正确的 Rust 代码,同样也缺少 `的功能。

Jai 的 macro 本质上就是一个编译时的执行的函数,它本身参与 Jai 的语法检查,类型检查。Jai 跟 C++ 一样支持 function overloading,而 Jai 的 macro 甚至跟其他所有同名的函数一起参与 overload resolution。在 Jai 语言中强力的编译时执行能力加上 Type_InfoCode 这些方便的 API,Jai 语言下 metaprogramming 能做到的事情可以说只被你的想象力所限制。下面来看两个例子:

Jai 内置的 Hash_Table 模块可以用 table_add(*table, key, value); 来往里面添加成员。很多情况下我在代码里面写的时候,变量名其实就已经是 key 了,比如 one := 1,那么我要写 table_add(*t, "one", one);,这里有一个明显的冗余。Jai 里面可以通过 macro 来轻松的拿到这个信息:

#import "Basic";
#import "Compiler";
#import "Hash_Table";

table_add_named :: (table: *Table(string, int), $args: ..Code) #expand {
    #insert -> string {
        sb : String_Builder;
        for args {
            node := compiler_get_nodes(it);
            assert(node.kind == .IDENT);
            ident := cast(*Code_Ident) node;
            print_to_builder(*sb, "table_add(table, \"%\", #insert args[%]);\n", ident.name, it_index);
        }
        return builder_to_string(*sb);
    }
}

main :: () {
    t : Table(string, int);
    one := 1; two := 2; four := 4;
    table_add_named(*t, one, two, four);
    for t print("%=% ", it_index, it);
    /// prints "four=4 two=2 one=1"
}

我们实现了 table_add_named() macro,通过 $args: ..Code 来接收任意多的参数,用 compiler_get_nodes() 来查询它的 AST 节点然后通过 #insert 插入字符串拼好的 table_add() 调用。

下一个例子是在大型项目中的场景。比如我是一个模块的作者,我会在某个地方留一个 callback 的点让用户能够提供一个函数来做到不修改我的代码的情况下,修改和定制我这边的一部分行为。这个情况下挺常见的一个情况是我需要决定这个 callback 要暴露什么参数,随着迭代可能还要加新的参数进去。Jai 在这个情况下可以提供一个 macro 来做到可以访问 parent scope 所有变量的"超级 callback":

#import "Basic";

amacro :: () #expand { array_add(*`ints, 253); }
bmacro :: () #expand { `s = "HELLO WORLD!"; }

foo :: ($Cb : ()) {
    ints :[..]int;
    s := "hello world!";

    Cb();

    print("%,%\n", ints, s);
}

main :: () {
    foo(amacro); /// prints "[253],hello world!"
    foo(bmacro); /// [],HELLO WORLD!
}

可能有人会说这个暴露太多内部状态,或者说 callback 的用户要回头去看 callsite 才能知道要怎么写这个 callback,这个违反很多传统上“软件工程”上的规范。但这里主要是为了展示 Jai 功能的一个例子,不过下文也会提到,Jai 在编程语言的功能取舍和程序应该怎么组织上有自己的一套逻辑和品味。

除了上面演示的内容之外,Jai metaprogramming 还有很重要的一块被称作 "metaprogram"。它可以说就是 Jai 的 build system,所以这个名字还挺合理的,因为它就是“程序的程序”。

我们不带任何参数执行 jai 编译器可以看到:

> jai
You need to provide an argument telling the compiler what to compile! Sorry. Pass -help for help.

这串错误提示来自 jai/modules/Default_Metaprogram.jai,也就是说 Jai 编译器的默认命令行处理并不是写死在编译器里,而是可以由用户用 Jai 语言来自定义的。我们可以用 --- 来给 Jai 编译器传最底层的参数,其中 meta 可以用来切换使用的 metaprogram。

# list jai native options
> jai --- help
Developer options: import_dir name, meta metaprogram_name, no_jobs, randomize, seed some_number, extra, chaos.
# uses `jai/modules/Minimal_Metaprogram.jai`
> jai --- meta Minimal_Metaprogram

在处理 build system 的部分 Jai metaprogram 跟别的语言没有太多区别,你可以选择要编译的文件,选择输出文件类型和名字,选择优化等级这些。在 C/C++ 的情况下你往往需要一个别的脚本语言来做 build system,比如提到名字就让人心里一紧的 CMake。Rust 和 Zig 的 build system 都允许你用本语言写一段程序来控制编译过程,但两者都会把这个程序编译成 binary 然后执行它来控制编译过程。

Jai metaprogram 则是直接在编译时执行的。这个除了很酷之外,更重要的是这样允许用户通过 Jai 代码来控制编译过程。下面这个例子是一个 metaprogram,执行它会把文件里的两段 string 编译到 strlits binary,在编译过程中记录下所有用到的 string literal 并在编译结束时 print 出来。

#import "Basic";
#import "Compiler";

__MAIN :: #string __END__
main :: () {
    foo();
}
#import "Basic";
__END__

__FOO :: #string __END__
foo :: () {
    print("hello world!");
}
__END__

strlits : [..]string;
foo_inserted : bool;

message_loop :: (w: Workspace) {
    while true {
        message := compiler_wait_for_message();
        if message.kind == {
            case .COMPLETE; break; /// end of compilation
            case .TYPECHECKED;
                typechecked := cast(*Message_Typechecked) message;
                for typechecked.all
                    for it.subexpressions
                        add_node_strlit(it);
            case .PHASE;
                phase := cast(*Message_Phase) message;
                if phase.phase == .TYPECHECKED_ALL_WE_CAN && !foo_inserted {
                    /// insert `foo()` late to demonstrate phased compile
                    add_build_string(__FOO, w);
                    foo_inserted = true;
                }
        }
    }
}

/// collect string literals into `strlits`
add_node_strlit :: (node: *Code_Node) {
    if !node || node.kind != .LITERAL return;
    literal := cast(*Code_Literal) node;
    if literal.value_type != .STRING return;
    array_add_if_unique(*strlits, literal._string);
}

build :: () {
    w := compiler_create_workspace("Target Program");
    options := get_build_options(w);
    copy_commonly_propagated_fields(get_build_options(), *options);
    options.output_type = .EXECUTABLE;
    options.output_executable_name = "strlits";
    set_build_options(options, w);

    compiler_begin_intercept(w);
    add_build_string(__MAIN, w); /// insert `main()` first

    message_loop(w);

    compiler_end_intercept(w);
    set_build_options_dc(.{do_output=false});

    for strlits print(">>> %\n", it);
}

#run build();
/// prints
/// >>> Runtime_Support
/// >>> Inf
/// >>> NaN
/// ....

这个例子是本文中最复杂的一个,需要展开讲讲:

首先 __MAIN__FOO 两个字符串用 #string __END__ 声明,这个是 Jai 里定义多行 string 的方式。这里用 string 替代文件这样整个例子可以在单个文件里完成。

build() 是这个 metaprogram 的主体,它首先做的是调用 compiler_create_workspace() 创建一个新的 Workspace,每个 Workspace 对应一个输出的 binary。接下来从 compiler_begin_intercept() 开始我们“拦截” Jai 编译器的编译过程,这个是 Jai metaprogram 里面非常固定的一个模式:

compiler_begin_intercept(w);
while true {
    message := compiler_wait_for_message();
    /// handle compiler message
}
compiler_end_intercept(w);

compiler_begin_intercept()compiler_end_intercept() 期间,我们通过 compiler_wait_for_message() 来拿到编译器在编译到特定时刻发布的 message,这样用户就有机会通过 metaprogram 参与定制编译流程。中间处理 message 的过程我们抽出来到 message_loop() 里。

在上面的这个例子里面,我们首先要处理 message.kind == .COMPLETE 的情况,到这里编译流程完全结束,用户的 message loop 也要退出。之后我们要处理 message.kind == .TYPECHECKED 的情况,这里代表的是 Jai 对一部分代码完成了类型检查,这意味着这一部分的代码语义解析已经完成并且固定。这里我们通过 add_node_strlit() 来访问这部分代码的 AST 来找到所有的 string literal 存到 strlits array 里。这样在 .COMPLETE 的时候我们就准确的拿到了整个程序里所有的 string literals。注意这里我们拿到的不仅仅是我们这个程序里面的两个,还包括了引用的标准库中的所有 string literals,说明我们控制的是完整的包括编译标准库功能的编译流程。

就拿上面这个例子来说,在其他编程语言很少有能轻易做到这个的。C/C++ 需要做到这个简单的可以尝试直接 parse 代码但复杂一点的情况会出错,高级一点的可以写 clang 工具或者 plugin 来做到这个但工作量比较大,最后还要嵌入到你的 build system 里面。C# 可以通过检视生成的 DLL 拿到各种信息还可以做修改的,但这个是脱离编译流程的。

因为我们实际是真的在参与 Jai 编译器的编译流程,我们除了能够查看 AST 和对应的 Type_Info 还能修改他们。Jai 现在的版本下是允许一定程度的修改,但是这一块目前限制比较多,吹哥也有提到这个是 Jai 在公开发布前还需要优化的几个问题之一。但我感觉在目前的框架下是可以做到很复杂的修改的,这块如果做的好那限制只有你的想象了。

话说回来,为什么 Jai metaprogramming 功能这么厉害?回想一下前面 #insert 例子,Jai 是可以在当前编写的代码文件里面,访问当前同一个文件里面的 CodeType_Info,但你写的这个文件明明就还在被编译,这是怎么做到的呢?

我觉得这个实现肯定很复杂,但是很关键的一点我们还是可以从上面的例子里面窥见端倪。上面的 message_loop() 中还处理了一个 message.kind == .PHASE && message.phase == .TYPECHECKED_ALL_WE_CAN 的情况。在编译刚开始的时候我们仅仅把 __MAIN 放入了 workspace 参加编译。Jai 根据现在 workspace 里已有的代码来尽可能的做类型检查,到发现没有再有进展的时候就会发出这个 TYPECHECKED_ALL_WE_CAN message。这里我们再通过 add_build_string(__FOO, w) 来把剩下的代码也放入,让编译器继续下去最终能够完成编译。如果我们注释掉 add_build_string(__FOO, w) 再运行,会发现编译会因为找不到 foo() 的定义而失败:

# comment out `add_build_string(__FOO, w)`
> jai strlits.jai
In Workspace 3 ("Target Program"):
.build/.added_strings_w3.jai:7,5: Error: Undeclared identifier 'foo'.

    main :: () {
        foo();

这个比较好的证明了 Jai 编译流程不是跟大多数语言一样 parse->sema->codegen 这样线性的,而是根据代码内部不同部分的依赖多次反复的跑 parse->sema 到所有的类型检查结束再到下一步进行 codegen。

我感觉 Jai 内部应该是根据代码之间相互引用的关系来构建了一个 dependency graph,这样它就能从 graph edge 开始做类型检查,再跑编译时执行,一轮一轮的沿着这个 graph 来编译直到编译完成。当然这个 dependency graph 也能检测到错误的 dependency,一个最简单的例子:

a :: b;
b :: a;

需要提到的是 Jai 里面声明顺序可以是任意的,编译这段代码 Jai 会报错,明确的检测到了循环依赖:

The program contains circular dependencies.

Cycle:
  [0] declaration of 'a' (cycle.jai:1)
      depends on [1]
  [1] declaration of 'b' (cycle.jai:2)
      depends on [0]

总体来说,Jai 通过提供了在静态类型的系统开发语言中罕见而强大的 metaprogramming 功能。我听说过 lisp/scheme 有这个 "code as data" 的概念但我始终没太理解它具体指的是什么。在我试了试 Jai metaprogramming 后我又猛然想起这个概念,它指的可能就是用户可以像 data 一样来读取你代码的信息,并且根据这个来做修改和生成新的代码。

更好的 C++🔗

跟 Jai 最具渊源的编程语言就是 C++。吹哥提到过他决定要开发 Jai 就是因为不想再写 C++ 做游戏,而 Jai 编译器就是用 C++ 实现的。吹哥特地提到过他不会做 bootstrapping - 不会尝试用 Jai 语言实现 Jai 编译器,所以 Jai 跟 C++ 可以说是永远脱不开关系。实际上 Jai 很多的功能和选择都很明显的是以 C++ 为基准而设计的。就我个人看来 Jai 就是更好的 C++,它自己的定位和提供的功能跟 C++ 接近,又去掉了一些恼人的部分,再做了一些优化和创新,整体呈现出一种又老又新的感觉,非常酷。这一章节我会逐一介绍 Jai 里跟 C++ 相关的部分。

Procedural SFINAE🔗

我们从一个我觉得最标志性的 Jai 比 C++ 做的更好的功能例子开始。熟悉 C++ 的读者肯定熟悉 SFINAE,它最经典的用例就是 std::enable_if,以下是一个例子:

#include <iostream>
#include <type_traits>

template <typename T>
std::enable_if_t<std::is_integral_v<T>>
foo(T value) { std::cout << "integral: " << value << "\n"; }

template <typename T>
std::enable_if_t<std::is_floating_point_v<T>>
foo(T value) { std::cout << "float: " << value << "\n"; }

int main() {
    foo(253);    /// prints "integral: 253"
    foo(1.23);  /// prints "float: 1.23"
}

C++ 编译过程中碰到一个函数调用,比如 foo(xxx) 而叫做 foo() 的函数又有多个的时候需要做 overload resolution 来选择出唯一正确的选择。C++ SFINAE 允许我们一定程度上控制这个过程,但要这么做需要你用 std::enable_if 这些像一个迷你的声明式语言的机制。

Jai 也允许 function overloading,同样要处理 overload resolution 这个问题并提供了工具给用户来控制这个过程。在 Jai 语言中你可以在函数声明后用 #modify 添加一段代码,"procedurally" 来控制这个过程:

#import "Basic";

foo :: (value: $T)
#modify { return (cast(*Type_Info) T).type == .INTEGER; }
{
    print("integral: %\n", value);
}

foo :: (value: $T)
#modify { return (cast(*Type_Info) T).type == .FLOAT; }
{
    print("float: %\n", value);
}

main :: () {
    foo(253);   // prints "integral: 253"
    foo(1.23);  // prints "float: 1.23"
}

这个 Jai 的例子做的事跟上面 C++ 几乎是一样的。#modify 里的内容就是在编译时执行的 Jai 代码,这里检查类型信息用也就是上面反复出现的 Type_Info。我感觉 Jai 的这个做法更直观,而且你要会的还是你已经熟悉的 Jai 语法和 API,功能上用户能做的也更多:比如我可以轻易的根据类型的名字做判断,或者根据全局信息的信息,甚至每次做 resolution 的时候都打个 log。

更酷的是 #modify 叫做 "modify" 原因是它还可以在参与编译前修改参数。下面例子中我们修改了 Buf 的长度保证它总是 2 的幂:

#import "Bit_Operations";

Buf :: struct (N: int)
#modify {
    /// round up to next power of 2
    N = 1 << bit_scan_reverse(N - 1);
    return true;
}
{
    data: [N] u8;
}

写这篇文章的时候我才发现 C++20 中开始允许 requires() 来调用一个 consteval 函数来做选择,这其实也印证 Jai 这样设计的正确性。我感觉 Jai 里有些功能都是先重新发明 C++ 里对应的部分,再把它做的更好同时还能获得新的扩展。下面要讲到的 using 正是另一个范例。

更好的 using🔗

C++ 中的 using keyword 有多个用处,Jai 复用了这个 keyword 并基于其 using namespace 这个用例展开为一系列强相关的功能。

在 C++ 比如我想要把一个 namespace foo 中所有声明都引入当前的 scope,我可以用 using namespace foo; 来做。 Jai 语言里没有 namespace,但是它把这个概念扩展到了所有的东西上面:只要这个东西有成员,用户就可以用 using 把他们都引入当前 scope 里面来:

ShapeType :: enum {
    RECT;
    CIRCLE;
}

Shape :: struct {
    type: ShapeType;
    name: string;
}

Rect :: struct {
    /// using struct
    #as using base: Shape;
    width, height: float;
}

Circle :: struct {
    #as using base: Shape;
    radius: float;
}

/// using argument
show :: (using s: Shape) {
    print("%:'%'\n", type, name);
}

main :: () {
    /// using enum
    using ShapeType; 
    t := CIRCLE;
    c := Circle.{
        type = t,
        name = "my circle",
        radius = 2.53,
    };

    /// `c.name` is shorthand for `c.base.name`
    assert(*c.name == *c.base.name);
    /// `#as` allow auto convert from Rect/Circle to Shape
    show(c);
}
#import "Basic";

在这个例子中你可以看到 using 在不同的位置都是逻辑上相同的功能。比较酷的一个情况是用在函数的参数上,可以做到类似 C++ implicit this 的效果,但可以用在任何参数上。这也对应上面提到的 Jai 里把类似功能组织成一个统一的抽象,你只要记得 using 可以把一个东西里面所有内容引入到当前 scope 就好了,这样可以减轻用户思维上的负担。

另外这个例子同时演示了 Jai 语言中类似 C++ class 继承的例子,区别是 Jai 中没有 C++ 的继承只有组合。Circle/Rect都有一个 base : Shape 的成员,特殊的地方是:usingShape 的所有成员都导入这个新的 struct,所以在使用 c: Circle 的时候你可以写 c.name 也可以写 c.base.name 两者是等价的。#as 标记新的 struct 可以自动转换成 Shape,这样你在传参数的时候 Jai 会自动帮你处理转换。using #as 经常在一起被使用,另外单个 struct 里面可以有多个 using #as,只要相互名字不冲突它都能正常使用。

Jai 没有 namespace 但是它用 module 来组织代码。使用 module 时默认引入所有的成员,但你也有机会给他设置一个名字,这个跟 Python 有点类似。同样的你也再用 using 再把它成员暴露出来:

/// import all names from "Basic"
#import "Basic";
/// scope under "StringLib"
StringLib :: #import "String";

main :: () {
    /// using module
    using StringLib;
    print(to_upper_copy("hello world!"));
}

移除的功能🔗

虽然说 Jai 跟 C++ 关系密切,但是说回到语义上 Jai 可能更接近 C:它没有所谓的"对象模型",没有继承,没有成员函数,所以就没有 this/virtual/override 这些配套的经典 OOP 功能。Jai struct 允许静态成员,也允许成员是函数指针,但都没有 C++ 对应的语义,你可以理解 Jai 所有的 struct 都是 C++ POD 类型,所有的函数都是 free function。

Bar :: struct {
    /// function pointer member
    foo := () { print("hello world!\n"); }
    /// const, static function pointer member
    FOO :: () { print("HELLO WORLD!\n"); }
}

#run {
    b : Bar;
    b.foo();
    b.FOO();
    Bar.FOO();
}
#import "Basic";

基于这个前提,Jai 也没有一些我们耳熟能详的 C++ 功能:

  • 没有 constructor/destructor。
  • 没有 const
  • 没有 private/protected/public
  • 没有 exceptions。
  • 没有 reference,即没有这个 const Foo&

这些其实都可以通过“Jai 比较像 C”一句话带过去,但我觉得这个其实比较能代表 Jai 语言的设计理念和重心,值得单独拿来讲讲。

我们把以 Java/C#/C++ 为代表的这一套基于 class,继承和以及 override 的模式称作经典面向对象编程模型。大部分程序员对这个都已经烂熟于心了,但是我感觉从 2010 年前后大家开始探索新的别的编程模式:Go 和 Rust 在 2010 年前后发布,两者都没有选择实现这套经典面向对象。C++ 本身也于 C++ 17 引入了 std::variant/std::visit 来允许这种 tagged union 的多态方式。再到 Zig/Odin/Jai 这一批也是选择了更接近 C 的语义。其原因肯定很多,要我来说的话经典面向对象作为一个 90 年代开始的潮流,到现在的三十年后大家对它的优势和局限性有了更清晰的理解和认识,开始朝别的方向探索。

Jai 选择没有实现 C++ const 以及 private/protected/public。我个人理解这些功能的收益不够大,比不上其引入的复杂度和编译时间上的损失。C++ 中正确的使用 const 会使得代码数量增加不少,设计的时候你也要在这块花费精力想,迭代的时候也会碰到少写了个 const 或者不小心访问了 private/protected,如果你项目编译时间长那就是白浪费几分钟。就算你精妙的设计并正确的使用了这些,之后偶尔也会碰到绕不过去的情况,这时候又要用 mutable/friend 来绕开。实际上 const 并不能让编译器识别并优化它,它跟 public/protected/private 一样更多是 API 边界的标识。换句话说,要是编译时间半小时的项目能选择删掉所有的 const/private/protected/public 然后编译速度快就算三分钟,要是问我我肯定是愿意的。

Jai 没有 destructor,而是选择了类似 Go/Zig 的 defer。这个方式更灵活,而且 Jai 可以通过 macro 给上层 scope 里做 "quote" defer,可以用它实现 C++ RAII 的效果:

scoped_store_then_reset :: (v: *$T, new_value: T) #expand {
    stored := v.*;
    v.* = new_value;
    `defer v.* = stored;
}

main :: () {
    x := 253;
    {
        scoped_store_then_reset(*x, 123);
        print("%\n", x); /// prints "123"
    }
    print("%\n", x); /// prints "253"
}
#import "Basic";

Jai 没有 constructor。我认为 C++ constructor 是一个 "misfeature"(而且有人也这么认为)。C++ 里有一系列问题都是由 constructor 引起:explicit, NRVO, static initialization order fiasco,perfect forwarding 等等,它也是为什么 C++ 有十种始终初始化方式的最根本原因。在这方面 Jai 显然是精心设计过的。如果你在 struct 声明的时候给成员赋了默认值,Jai 会给这个 struct 生成一个 initializer 函数,你可以用 initializer_of() 拿到它:

Foo :: struct {
    s : string;
    i : int;
}

Bar :: struct {
    s :string = "hello world!";
    i :int = 10;
}

#assert initializer_of(Foo) == null; /// no initializer for Foo
#assert type_info(type_of(initializer_of(Bar))).type == .PROCEDURE;

如果你在 stack 上声明一个 Bar 那么 Jai 会自动把它的内容设置成你设置好的默认值。但如果你直接操作内存的话,你需要手动调用 initializer 来设置其初始值:

/// builtin heap allocate procedure
New :: ($T: Type, $initialized := true) -> *T {
    memory := alloc(size_of(T));
    #if initialized {
        ini :: initializer_of(T);
        #if ini  inline ini(memory);
        else     memset(memory, 0, size_of(T));
    }
    return cast(*T) memory;
}

这里很重要的一点:默认值必须是一个编译时已知的常量:

foo :: () -> int { return 43; }
Baz :: struct {
    /// i1 := foo();  ///<- won't compile
    i2 := #run foo(); ///<- ok since it resolves to a constant
}

这样就确保了 initializer 是不会执行任何外部函数的,所以 initializer 能保证没有 side effects。这就是它跟 C++ constructor 最关键的区别:因为它没有 side effects 你可以安全的跳过它,因为你知道你只是跳过了设置初始值而没有跳过重要的函数调用;同时它也不存在调用顺序的问题,因为它的效果是局部的。

拿着这个设计再去跟 C/C++ 对比,首先它解决了 C struct 没法设默认值的问题,但同时又绕过了 C++ constructor 会遇到的一溜问题,同时它足够灵活,允许你在高性能需求的时候安全的跳过初始化。这一块目前看来我觉得 Jai initializer 的这个设计完美的解决了这个问题,而且它显然是一个在评估在 C/C++ 的情况下精心设计出来的策略。

Jai 没有 exceptions。我觉得你要是能耐心看到这里就不太需要我解释为什么 exceptions 并不是一个好主意,大型 C++ 项目没有用 exceptions 的,新的编程语言也都抛弃了 exceptions。

最后 Jai 没有 reference,只有指针。因为 C++ reference 用 & 来声明,Jai 里面干脆把 & 直接去掉了。有人会说没有 & 那怎么取地址呢?Jai 里面指针相关的所有东西都用 * 来解决:

Point :: struct { x,y: float; }

main :: () {
    x := 1.23;
    px := *x;    /// pointer to `x`
    px.* = 2.0;  /// deref and write 
    assert(x == 2.0);

    p := Point.{x, 2.34};
    pp := *p;    /// pointer to 'p'
    pp.x = 1.23; /// auto deref `pp`
    p.x = 1.23;  /// identical to above
}
#import "Basic";

上面的例子还演示了 Jai 遇到 struct member pointer 的时候会跟 Go 语言里一样 auto dereference,而且如果是多层 struct 嵌套的情况下也能嵌套 auto dereference。

因为 Jai 去掉了 reference 并做 auto dereference,在 Jai 编程里就直接把 C++ 中常会碰到的 ./-> 弄混这一类问题都给干掉了,这也是 Jai 简化程序员思维上的负担的一个例子。

值得一提的是曾经 Jai 曾经用过 <<ptr 作为 pointer dereference 的语法,这个跟现在的 ptr.* 比起来有点过于离谱。这也能证明 Jai 的设计也不是一上来就做到尽善尽美,跟所有的软件开发一样得先探索问题领域才能迭代出好的方案。

更好的功能🔗

Jai 在很多方面比 C++ 里对应的部分做的更好。上面的 #modifyusing 就是两个我能想得到的最突出的例子。这里我会列出其他 Jai 做的更好的地方。

C++11 作为 "modern C++" 的开始,引入的最成功最流行的功能可能就是 lambda 以及 closure。Jai 里也可以用 => 来声明一个 lambda:

Person :: struct {
    name: string;
    age: int;
}

main :: () {
    ls := Person.[
        .{"Alice", 38},
        .{"Bob", 24},
        .{"Charlie", 30},
    ];

    /// `=>` lambda
    quick_sort(ls, p => p.age);
    assert(ls[0].name == "Bob");
}
#import "Basic";
#import "Sort";

但是 Jai 没有 closure,即没有 C++ 的那个 [foo] lambda capturing。我们来看看 C++ closure 最常用的两个场景,第一个是我想在当前 scope 里写一个小的本地的工具函数,capture 一个局部变量从而减少代码量。这个用例 Jai 通过 macro 能够完美支持:

main :: () {
    acc := 0;
    /// note that local macro don't need to `acc
    inc :: () #expand { acc += 1; }

    inc(); inc(); inc();
    assert(acc == 3);
}
#import "Basic";

注意如果 macro 也是在这个 scope 声明的,那引用上层 scope 的变量可以省略 `。我觉得在这个用例下 Jai macro 比 C++ closure 要好。最主要的原因是同样的用例下你如果是用 C++ closure 的话,你实际声明了一个函数然后做了函数调用,但是你期望的是通过 C++ 编译器的优化来把这个 closure 魔法一样的"compile away",也就是 C++ 所说的 "zero cost abstraction"。但天下没有免费的午餐,应用到编程也是一样:"zero cost abstraction" 里的 cost 实际上 cost 在了编译时间上,而且在复杂的情况下你并没有办法能保证它会被优化掉。到 Jai 这里用 macro 能保证它在 debug/release build 下都不会产生函数调用,macro 在语义上也更贴合用户在这里想要做的事情。

C++ closure 还有一个比较大的用例就是作为 callback 来方便的 capture 一些参数。Jai 的确没有直接对应的功能,我记得吹哥也提到过不排除以后会添加 closure。但如果这块你用的多的话应该能通过 metaprogramming 做出针对你具体问题的方案。

C++ 的里泛型("generics")需要通过 template 来实现,它这样设计是因为 C++ 要在代码上兼容 C 所以这个只能叠加在 C 语法上。这产生了一个问题就是你想要在通常的函数和 classtemplate 之间切换要改很多代码。Jai 作为一个新设计的语言这方面做的就好很多:它在泛型和普通函数/struct 之间区别比较小,你可以不太费力的在两者之间切换:

/// poly procedure - argument with type parameters
square :: (x: $T) -> T { return x * x; }

/// poly struct - struct with type parameters
NamedArray :: struct (T: Type, N: int) {
    name : string;
    data : [N]T;
}

/// procedure take poly as argument need no special syntax
dump :: (arr : NamedArray) {
    print("%: ", arr.name);
    for arr.data print("% ", it);
    print("\n");
}

/// poly argument 
apply :: (arr : *NamedArray, f: (arr.T) -> arr.T) { for *arr.data it.* = f(it.*); }

main :: () {
    arr := NamedArray(int, 4).{ "Foo", .[1,2,3,4] };
    dump(arr); /// prints "Foo: 1 2 3 4"
    apply(*arr, square);
    dump(arr); /// prints "Foo: 1 4 9 16"
}
#import "Basic";

Jai 这里把泛型分别称作 "polymorphic struct/procedures"。在把 polymorphic struct 作为参数传的时候你总是可以用其全名比如 NamedArray($T, $N),但是 Jai 特地设计成你在几乎所有情况下都可以省略其类型参数。比如上面的例子中 apply() 的第二个参数可以直接用 arr.T 来直接引用它的类型。这样做的好处就是你可以无痛的切换是否是泛型:运气好的话你只需要改 struct 声明就直接搞定了。但是运气差的情况也有,因为 polymorphic struct/procedure 是编译时的类型,你没法拿到 polymorphic procedure 的地址,同样它们的 Type_Info 也不存在于运行时(但是编译时是有的)。

我记得我在最开始用 Jai 的时候有个疑问是为啥 macro 调用和声明没有特别的形式,比如 Rust 里面 macro 调用要加一个 ! 比如 dbg!(x)。现在我大概能理解一点,因为 Jai 里面普通的函数,泛型和 macro 它们其实都是函数,type_info 公用同一种类型 Type_Info_Procedure。而正是因为这一点,它们全都能同样的参与 overload resolution,也都能用 #modify block。我觉得这也是一个 Jai 用统一的抽象包装复杂功能的又一个例子。

剩下还有一些零星的点。这些都不是 Jai 独有的,新的一批编程语言基本上都做到了这些:

  • Jai 没有 C/C++ "header",有正常工作的 module 系统。
  • Jai 不需要 forward declaration,允许任意声明顺序,比如 #import 可以放在文件末尾。
  • 在 debug 编译模式下,会做数组越界和整数 overflow 这些基本的检查,数字相互 cast 也会在可能的情况下检查目标类型是否存的下。
  • 你肯定知道 "pass by value" 和 "pass by reference" 的区别,那 Jai 是 "pass by" 什么呢?答案是小于 8 bytes 的是 pass by value,大于 8 bytes 的则是看起来像是 pass by value 但实际可能会被优化成 pass by reference:这个情况下参数会被标记为 immutable 让你不容易区分出它到底是 pass by 啥。

面向大型项目🔗

C++ 有一个优势就是他是为数不多能 scale 到百万行代码级别的编程语言之一。Jai 显然还没有这个量级的代码库存在,他的编译模型在这个量级能否正常工作也是一个存疑的问题,但是 Jai 设计上很多功能是我觉得在大型多平台软件开发下很管用。

首先是内存分配。Jai 的逻辑是所有的 allocate 都要通过 context.allocator 做一次函数调用,这个函数调用没法被 inline,一定会在运行时发生。这个规则意味着你可以定制所有你用到的第三方库的 allocator,只要这个库是用 Jai 写的就没有例外。同时因为 allocator 调用不会被 inline,你可以在运行时切换 allocator 让同一份代码有不同的内存分配逻辑,比如需要调试的时候我可以换成 debug allocator,然后如果这个程序是一个命令行程序我可以安全的用 temp allocator 不用管释放。这个灵活性是系统编程语言里罕见的,但是代价也很明显就是内存分配会变的比较贵。我觉得这也可以说是一个好的取舍,因为动态内存分配在现代的计算机系统下它就是最耗时的操作之一。根据 Jai 的逻辑你要做的是通过 Jai 提供的功能来避免耗时的动态内存分配。一个例子就是 Jai 没有提供 C++ std::string 这样动态分配内存的封装,它内置的 string 就只是指针加长度。在 Jai 里你可以用编译时的常量 string,运行时有需要可以用 tprint() 来用 temp allocator 来 format 一个临时的 string,抑或干脆用固定长度的数组存放,这些情况你都可以用 Jai string 来指代,它内置的 String 模块也支持所有这些用例。

C++ 里支持多平台最常用的一个功能就是 #define/#ifdef/#if 这些 preprocessor 功能。就像上面提到的它们是在 C++ 之外的一个语言,不理解 C++ 的语义。到 C++ 17 引入了 if constexpr,这个又没法取代 #if 因为它只能在函数内生效。Jai 提供了 static if,语法也是 #if。它就像是上面提到的两个 C++ 功能的完美结合:

/// assume running on Windows
#if OS == .WINDOWS {
    os :: "Windows";
} else {
    os :: "Others";
    /// other branch will be parsed but not type checked
    call_undefined_functions();
}

get_os :: () => os;
main :: () {
    /// compile time call functions
    #if #run get_os() == "Windows" {
        s := "microsoft!";
    } else {
        s := "others!";
    }

    /// note that `s` is available here
    print("hello %\n", s); /// prints "hello microsoft!"
}
#import "Basic";

#if 参数必须是 constant expression,也可以通过 #run 来执行代码。它可以在任何地方使用,如果在函数内的话它被特地设计成不会产生一个新的 scope,这样里面定义的变量后续能访问到。和 C++ if constexpr 类似的一个设计是 else branch 内容需要能够被解析但是不会做类型检查,这个比 C++#ifdef 更好的点在于 C++ 里你根本不知道 #else 那边能不能编译的过。

Jai 实现了能正常工作的 module。这个功能作为大型项目代码重用的最关键的一部分,Jai 通过 module parameters 让 module 用户灵活的定制 module 的行为。受益于 Jai 编译时的灵活度,用户可以通过 module parameters 做的事非常多。这里我们先写一个 module 文件 mod.jai:

#module_parameters (DEBUG : bool = false, Type_Indicator : Type = void);

#if Type_Indicator == void {
    Data_Type :: float;
    FMT :: "%: % %\n";
} else {
    Data_Type :: Type_Indicator.Data_Type;
    FMT :: Type_Indicator.FMT;
}

Point :: struct {
    x,y: Data_Type;
    #if DEBUG {
        debug_name : string;
    }
}

points : [..]Point;
add_point :: (p: Point) { array_add(*points, p); }
dump_points :: () {
#if DEBUG
    for points print(FMT, it.debug_name, it.x, it.y);
else
    for points print(FMT, it_index, it.x, it.y);
}

#import "Basic";

这里 #module_parameters 声明我这个 mod 接受的参数。使用这个 module 则在同路径下创建下面内容的问题:

ModDebug :: #import,file "mod.jai"(
    DEBUG=true,
    Type_Indicator = struct{
        Data_Type :: s32;
        FMT :: "[%2,%3](%1)\n";
    }
);
ModDefault :: #import,file "mod.jai";

main :: () {
    {
        using ModDebug;
        add_point({1,2, "point1"});
        add_point({3,4, "point2"});
        dump_points();
    }

    {
        using ModDefault;
        add_point({1.2, 2.3});
        add_point({3.4, 4.5});
        dump_points();
    }
}

Jai 有一套寻找 module 在哪里的规则,但是它很方便的提供了 #import,file 来根据文件路径找。这里我们第一次 #import 提供了 module parameters DEBUG = true 和一个 Type_Indicator:在 mod.jai 里面我们根据这个给 Point 添加了新的成员,其 x,y 的类型也通过 Type_Indicator 被设置成了整数类型。接下来我们又再次 #import 了同一份 module,并分别给两次不同的名字。Jai 里面允许 module 在同一个程序里被多次引用,而且会根据其 module parameters 来做去重。

mod.jai 里面我们还演示了通过 Type_Indicator struct 来做类似 C++ template 的 duck typing。这个 struct 本身没有任何特殊性,这里利用它作为一个编译时的 container 来把一组编译时的常量传给 module。作为一个 module 的作者我可以把我这个模块里的基础类型作为参数暴露出来,让用户在引用的时候决定。

熟悉 C++ 的读者肯定会想起 STL,它很多算法可以作用到任意类型。这方面 Rust 也有例子比如 SerDe 整个库都是围绕它提供的几个 trait 来做的没有实际的类型。Jai 这里比较特别的一点是 Jai 更多的是允许用户代码来修改 module 代码里的类型和行为,上面提到的 context 也是这个逻辑的一部分。Jai 能做到这一点也是建立在其总是重新编译所有代码编译模型上的。

上面提到了 Jai module 可以以不同的 parameters 来多次 #import 成相互独立的实例,那么有时候我希望我整个程序里引用的这个 module,包括我的引用和来自第三方代码的引用都能使用同一组参数。这个情况 Jai 提供了 program parameters 来处理。以最基本的 Basic module 为例:

/// in `Basic/module.jai`
#module_parameters () (MEMORY_DEBUGGER := false, ENABLE_ASSERT := true /*...*/) {}

/// in your main program
#import "Basic"()(ENABLE_ASSERT=false);

program parameters 接在 module parameters 后面,它必须在你编译的程序的第一个文件里设定并生效,然后你程序里每次遇到这个都会以同样的参数来被 import。Jai 的意思就是你作为 module 的作者来决定我的参数适合放在 module parameters 还是 program parameters,然后库的作者根据这个来在引用时决定参数。

最后我们来看一个 metaprogram 上的例子。有的项目里面有测试函数我们希望能把他们收集起来集中调用,在 C++ 里面我们会通过 macro 配合 C++ static constructor 的方式来做 "auto registration"。Jai 里面可以通过 metaprogram 来干净的处理这个问题:

#import "Basic";
#import "Compiler";

__MAIN :: #string DONE
#placeholder tests; /// <- will be filled by metaprogram

add :: () { assert(1 + 1 == 2); } @Test
minus :: () { assert(1 - 1 == 0); } @Test
main :: () { for tests it(); }

#import "Basic";
DONE

tests: [..] *Code_Procedure_Header;
collected := false;

message_loop :: (w: Workspace) {
    while true {
        message := compiler_wait_for_message();
        if message.kind == {
          case .COMPLETE; break;
          case .TYPECHECKED;
            tc := cast(*Message_Typechecked) message;
            /// collecting procedures marked with @Test
            for header : tc.procedure_headers
                for header.expression.notes
                    if it.text == "Test" {
                        array_add(*tests, header.expression);
                        break;
                    }
          case .PHASE;
            phase := cast(*Message_Phase) message;
            if phase.phase == .TYPECHECKED_ALL_WE_CAN && !collected {
                /// fill `tests`
                sb: String_Builder;
                append(*sb, "tests :: #type () -> ().[\n");
                for tests {
                    append(*sb, it.name);
                    append(*sb, ",\n");
                }
                append(*sb, "];\n");
                add_build_string(builder_to_string(*sb), w);
                collected = true;
            }
        }
    }
}

build :: () {
    w := compiler_create_workspace("Target Program");

    options := get_build_options(w);
    copy_commonly_propagated_fields(get_build_options(), *options);
    options.output_type = .EXECUTABLE;
    options.output_executable_name = "tests";
    set_build_options(options, w);

    compiler_begin_intercept(w, .SKIP_EXPRESSIONS_WITHOUT_NOTES);
    add_build_string(__MAIN, w);

    message_loop(w);

    compiler_end_intercept(w);
    set_build_options_dc(.{do_output=false});
}
#run build();

上面的例子中演示了两个新的功能:Jai 允许在大部分声明处添加 @Foo 来添加用户信息,这里叫做 "note" 跟 C# attributes 类似。然后在 __MAIN 程序里有一个 #placeholder tests,它允许声明占位符变量让后面 metaprogram 填充。比起 C++ 的做法这里不需要测试函数特殊处理逻辑更灵活,比如我们完全可以根据函数名来做判定。另外它也不会影响生成代码,没有 static constructor 的一系列问题。metaprogram 可以拿到整个程序信息并且根据它来添加代码,它能做到的事情理论上也非常多。如果你熟悉 Unreal 那 UHT 的所有东西都可以通过 metaprogram 实现(事实上很多部分都不需要因为 Jai 本来就有运行时的 Type_Info)。你可以根据自己项目的规则来写 linter(Jai 附带的例子里面就有)。这些都是大型项目里很常见的用例,Jai 通过 metaprogram 把实现这些东西的门槛大幅降低。

快乐编程🔗

回头看吹哥十一年前第一个关于他要做新的编程语言的视频,他就提到了认为 "joy of programming" 非常重要,并直接影响到程序员的效率和产出,他的新语言要在这方面花功夫。从结果上来讲我感觉的确可以体会到写 Jai 会更快乐,接下来我会展开讲讲为什么快乐,以及 Jai 里面一些有趣的创新小功能。

首先我能想到的 "joy of programming" 的最反面的例子,就是 Java 要求的文件命名要求。Java 要求每个 publicclass/interface/enum 必须在同名 .java 文件里,同时 namespace 要对应同名的文件夹。比如说我有一个 public enum Color,这个代码必须得放在 com/example/foo/Color.java,中间的文件夹也一个都不能少。这个设计很符合所谓“软件工程”,程序员不需要想代码放在哪个文件里,然后这些文件维护都应该交给 IDE 负责等等。我觉得这个设计代表着 Java 的设计者并不信任 Java 程序员能把命名这件事处理好,想通过加以限制的办法来解决这个问题。这个限制就是一个不太受欢迎,减少 "joy" 的设计。

一个正向的快乐编程的例子就是 Python metaprogramming,其形式是通过超强的 operator overloading,让库的作者通过这个做出各种各样的 DSL。比如简单的一行 c = a + b,在 numpy 里面可以是矩阵运算,在 pytorch 里面就变成了 GPU 上跑的 tensors,在 SymPy 里就是描述符号运算关系。Python 的设计者把它的对象模型设计成开放的灵活的,给 Python 程序员很大的舞台来做它们想做的事情。从结果上看 Python 从最开始的 perl 替代到 web 开发再到数据科学,一路走下来现在竟然成为了 AI 开发最主流的语言。

这两个例子分别对应的就是限制和开放,而 Jai 的设计显然是选择了后者。编译时执行加上 metaprogramming 上限非常高,一个经常被提到的例子就是有人用 Jai 实现了一个 lisp,它的做法是你在 Jai 代码里面直接 #import lisp 代码,然后在 metaprogram 里面检测 .FAILED_IMPORT message,意味着编译器找不到对应的 module。这个时候它再在 metaprogram 里面读取这个 lisp 代码把他转成 Jai 代码,再通过 Jai 的 Compiler.provide_import() 塞回去然后继续编译。Jai 允许这个用例正是因为其设计者把相关的 API 实现并开放出来,目标就是更大程度的解除限制让 Jai 的用户能够自由探索其可能性,这种开放的思路我觉得正是 "joy of programming" 最关键的一环。

还有就是编译速度,不同数量级的编译速度直接影响程序员工作的方式,如果是几秒钟那你基本上就能保持在当前的上下文里,如果是像大型 C++ 项目里面一两分钟起步的情况下,基本开始编译你人就已经切到浏览器冲浪了。C++ 最被人诟病的一点就是其编译速度,我的观点是 C++ 的编译速度随着其新标准的发布新功能的叠加,最好的情况是在不启用新功能的条件编译速度维持不变,但最可能的情况下编译速度会一直降低。有的大型项目单纯切换到 C++20 标准编译耗时就增加 12%。Jai 的快速全量编译是他的设计目标之一,而他能做到这一点除了多线程这些以外我觉得最重要的是要在语法设计,功能设计这些上面下了不少功夫。目前从结果上看起来效果非常好,但我很好奇代码量再上到百万级的时候 Jai 这个总是全量编译的模式行不行得通,到时候有没有合适 Jai 的解决办法。

Jai 在语法上有一个侧重点就是他允许你写尽可能简短,一个标志性的例子就是 if/for/while 这些 control flow 关键字,Jai 里允许你省略 () 的同时还允许你省略 {}

if x > 5 print("true");
while x < 0 x -= 1;

然后 for 默认就会声明两个变量 itit_index 分别指循环的元素和他当前的 index:

t : Table(string, int);
for t print("%=% ", it_index, it);

这里 for 的展开方式你可以实现一个 for_expansion macro 来自定义。同时 Jai 从 C++ 的众多功能里面挑选了 operator overloading,允许你 overload 常见的数学符号。上面提到过的 using 被设计在可以用在任何地方,这个也是让你简化重复代码非常重要的工具:

left_plane :: (using m: Matrix4) -> Vector4 {
    using ret : Vector4;
    /// xyzw is from ret, _12/etc is from m
    x = _41 + _11;
    y = _42 + _12;
    z = _43 + _13;
    w = _44 + _14;
    return ret;
}

我觉得总体上 Jai 是倾向于让你写更简短的代码的,让你在适合时候来一点 "code golf",这也是程序员快乐的一部分。

其他功能🔗

这里列举一些上文没有提到的 Jai 里语言我觉得很酷的小功能。

Jai 里提供 cast 关键字来做 explicit cast。因为 casting 很麻烦所以 Jai 还提供了 xx 关键字来根据 type inference 来做自动 cast。

x :s32;
x = cast(s32) 1.23;
x = xx 1.23; /// equivalent as above

Jai 默认所有的变量都会被初始化成 0,这个我觉得就是正确的做法。当然你也可以用 --- 来跳过初始化。

Foo :: struct { buf: [1024 * 4]u8; }
foo :Foo = ---; /// skip init for large structs

Jai 里通过 .{} 来声明 struct literal,用 .[] 来声明 array literal。在你觉得类型应该能省略的时候,它大概率就能省略。

Foo :: struct { s: string; i: int; }

f1 := Foo.{"a", 1};
f2 := Foo.{i = 2};
f3 :Foo = .{s = "b"};

a1 := Foo.[.{"c", 3}, {"d", 4}];
a2 : [2]Foo = .[.{}, .{}];

Jai 有 C 风格的 union,但是对于 aliasing 类型的用例 Jai 经过一些迭代目前选择使用 #overlay 来实现,下面是其内置 Vector3 的例子:

Vector3 :: struct {
    x, y, z: float;

    #overlay (x) xy: Vector2;
    #overlay (y) yz: Vector2;
    #overlay (x) component: [3] float;
}

v := Vector3.{};
v.xy = .{1,2}; /// use overlay xy

Jai 正确的实现了嵌套的 /**/ block comment,一个简单但很少语言正确实现的东西:

/* foo
  /* bar */
  baz
*/

Jai 里面没有字符对应的 literals,这样在语法规则上直接省掉了处理 'x' 这个情况,它使用 #char 来把单个字符长度的 string 转成 u8:

ch :u8 = #char "a";

Jai 在函数参数上很灵活,提供了 named argument,optional argument,以及类似 Python 的 spread varargs:

foo :: (a: string = "hello", v: ..string) { print_vars(a, v); }

main :: () {
    foo();
    foo("a", "b", "c");
    foo(v = ..string.["d", "e"], a = "f");
}
#import "Basic";
#import "Print_Vars";

比如说你的一个 Jai 代码文件有点长了,你想要先简单的把一部分拆出去成为单独的文件,但又没有决定好要把它拆成独立的可以复用的 module。这个时候你可以把你要拆出去的东西单独开一个文件,然后使用 #load "foo.jai"。它跟 #import 不太一样但反而跟 C++ #include 很像,再加上 Jai 里面声明顺序可以是任意的,可以保证我随便抽东西出去都能编译。同时这个 foo.jai 里面也可以有自己的 scope 可以通过 #scope_file/#scope_export 来控制暴露哪些声明。

最后还有一个非常私人的小功能,Jai 可以用 \ 来对齐 identifier:

current_time: float64;
last\  _time: float64;
print("% %", current_time, last_time); /// it's just `last_time`

Opinions🔗

上面介绍了 Jai 语言的各种功能。这一节我会讲讲我对 Jai 和其他编程语言的想法。显然这些都是我的主观看法,这里的描述不一定准确的更不一定是对的。

首先是房间里的大象 C++。我突然想起这篇 2020 年的文章:The Day The Standard Library Died,作者开头就描述了这样一个场景:

"在布拉格的会议中,C++ 组委会经过一系列投票最后达成一致决定:不对 C++ ABI 做 breaking change。投票后没有人鼓掌。我不知道我们是否清楚我们究竟做了什么,但我想这一系列后果中没有好的结果。"

这里说的应该是 C++ 组委会开会讨论 C++23 的事。简单讲就是经过讨论下来决定:与其修好 STL 里的 bug 和做性能优化,组委会觉得部分用户不用重新编译它们的代码更重要。在此之后 Google 的开发者们决定放弃继续改善 C++,把精力放在它们自己的 Carbon 语言上。

... 然后快进到 2026 年你会发现:世界还是在正常运转,C++26 标准里加入了万众期待的反射,新的 3A 游戏也还都是 C++ 写的,Google 的 Carbon 还在做但离正式发布还早。这个微妙的状况我觉得就是 C++:它很难变的更好但他会一直在。如果你在 2026 年想开一个新项目并且希望你的代码库在 50 年后还能用,那 C/C++ 仍然是你最好的选择。多方势力已经花了无数的成本跟它们深度绑死,它们会继续花钱花资源保证它一直能用。我看到过一个说法:C ABI, Win32 API 和 x86 ISA 会永远的存在下去,跟人类的历史一样长,差不多也是这个感觉。

但是正是 C++ 生态系统的发展开启了探索其他系统编程语言的大门,那就是 LLVM。它从千禧年初开始经过二十多年的发展到现在成为历史上最受欢迎的编译器框架。Apple 是 LLVM 早期推手,并赞助其开发者做 Clang 实现一个完整的 C/C++ 编译器。到现在三大主机平台除了微软都是使用 LLVM + Clang 作为编译器和工具链,Chrome 浏览器现在所有平台都指定用 Clang 编译。LLVM 注重模块化设计,允许其部分功能被别的软件作为一个库引用,这是之前像 GCC 这些都没有做到的。而正是这一点加上其逐渐完善的功能,开发者可以用 LLVM 实现产品级的编程语言。在这方面最早的成功例子就是 Rust,再之后 LLVM 的作者在苹果做了 Swift,到更后面 Zig/Odin/Jai 也都在 LLVM 基础上开发的。在我看来 LLVM 最重要的点是允许新的编程语言开发者把精力放在你语言的创新上,你知道你可以靠 LLVM 就能达到一个跟 Clang 一个档次的代码生成和平台支持。如果没有 LLVM 很多东西比如像 Windows 平台编译生成 Visual Studio 能理解的 PDB 就能挡住大部分人。LLVM 也是上文提到多方势力深入持续投入的一个项目,它的存在让新的指令集和新的编程语言有更大的可能成功。

然后就是 C++ 日常开发里挑选你喜欢的功能也很关键,Orthodox C++ 是一份很好的参考。

Rust 已经是一个成功的编程语言了,表现就是现在到处都有 Rust 的全职工作在招聘。Rust 在吹哥的描述里面符合 "big agenda language",它把 safe 作为最重要最核心的点。这个在服务器端很重要但是放在游戏开发来说可能并不是很合适,这也是为什么 Rust 这么多年很少有商业游戏使用的原因。就我个人体验来说,Rust 抛开 lifetime 以外它也有很多很酷的新点子,比如其简洁的语法,详细的编译错误提示,cargo 包管理这些,让人意识到底层开发并不一定非要是坐牢般的体验。它也是第一个非 C/C++ 的 LLVM 编程语言大用户,协助其通用性和边界问题的排查,同时让其他人看到通过 LLVM 实现新的编程语言这条路走的通。

Zig 是这一批新语言里我比较熟悉的,它在某些部分跟 Jai 有些类似,比如 comptime 和显式的 allocator。它的 slogan 里第一条就是 "No hidden control flow",这个严格的执行到了设计的方方面面,所以 Zig 写起来我感觉会有一点 verbose,但在适合的场合下这可能反而是它的优势。Zig 本身的在开发模式上会被人拿来跟 Jai 比的一点就是 Zig 很早就公开发布并吸纳了很多社区开发者,而更早开始开发的 Jai 到现在还是封闭测试。我觉得 Zig 靠他更早的公布积累了关注度和用户,才让这个项目拿到足够的赞助能够设立基金会允许其开发者全职开发。但这样做的缺点就是它的确更难做 breaking change,虽然 Zig 作者在这方面态度非常坚决的做它认为的 breaking change,但有些太基础的东西比如函数声明的确不太可能变了。而且在现阶段跟 Zig 会有点痛苦,比如你的代码一阵子没碰过半年一年拿最新版本编发现改了半天都编不过,不过这个情况显然会在 1.0 后好转。Zig 语言和它配套的工具链也有不少创新,比如 zig cc 可能是目前 Windows 平台上最方便的 Clang toolchain,下载后打开即用。同时它有市面上最强的 cross compiling 支持,为了做到这点 Zig 甚至打包了各个平台的 libc 以及 windows.h 这些个平台常见 header。Zig 也是当下 target WASM 最合适的语言之一,因为它语义简单没有 runtime 有很好的 32bit 支持。

Odin 是一个明显受到 Jai 启发的编程语言,包括 context 和声明语法 ::,以及最近添加的内置 dynamic array。比起 Zig/Jai,Odin 在其设计上的迭代已经快结束了,最近也公布了 1.0 的消息。值得一提的是 Odin 的作者在开发初期就搞定了钱和用户的问题:其作者认识的一家软件公司在其开发早期就选定 Odin 来开发它们的商业产品。

最后再说说 Jai。我首先泼泼冷水:Jai 可能很难取得像 Rust 相同级别,主流意义上的成功。原因跟 Jai 语言的设计实现本身无关,它缺少的是像大型企业那样的资源,行动力和号召力。比方说 Go 如果不是 Google 做的,Swift 如果不是 Apple 做的两者都很难成功。另一方面 Jai 目前看来缺少的配套很多,它连网站都还没有,也没有文档没有社区。吹哥之前提到过他后续并不想把所有精力放在 Jai 维护上,他还是想要做游戏,所以以后 Jai 是怎样的开发维护模式也都很难说。我能想象的到的最好的模式就是类似现在 Lua 的感觉,以开源的 license 公开代码但并不由社区维护,同时以较低的频率更新版本。

但对我来说 Jai 的出现实在是太棒了,我觉得在游戏开发领域它是我目前看下来最有竞争力的 C++ 替代。吹哥自己的游戏说明 Jai 能完美的支撑中小型项目,甚至连 Switch 2 都跑通了,这说不定是近年主机平台上唯一一个非 C/C++ 直接编译到 machine code 的例子。我觉得现在计算机行业的走势就是从单一的主流选择往允许更多选择的转型阶段:几十年前家用电脑的操作系统是有好几家竞争对手相互竞争的,慢慢发展走到了微软苹果二选一,然后到现在像 Bazzite/SteamOS 这种小众选择变得可行,有数量少但坚挺的支持者。我觉得编程领域特别是游戏开发未来也会是类似的展开。另外一方面,从上文你应该能看出来 Jai 带来的新点子和好主意太多了,从小的具体的语法,到编译时执行,context 这些,有些已经被借鉴了,有些在 Jai 被发布后一定会变的更流行,人们会说卧槽竟然还可以这样我都没想到。我相信 Jai 对后续计算机编程语言的发展也是有重要积极意义的。

Jai 为啥现在还没发布呢?吹哥之前有提到过他觉得有些功能还没做完,一个就是上面提到的 metaprogramming 过程中修改已有的代码,这块还有提升的空间。另一方面就是 SIMD 支持感觉还在设计阶段。但最主要的原因显然就是吹哥现在在忙他今年要发售的游戏:沉星之序。

Order of the Sinking Star🔗

《沉星之序》就是吹哥跟 Jai 同期开发的那个游戏,如果你这几年有看过吹哥直播切片的话你肯定对它不陌生。在之前的 Steam 新品节上游戏的 Demo 已经上线,要是用一句话描述的话就是“开放世界推箱子”。吹哥之前提到过他的计划是把这个游戏做完,然后会把这个游戏的引擎公开发布,估计 Jai 就是这个时候同期公开发布。Jai 编译器的开源则会再晚一点。

如果你对 Jai 语言感兴趣,你现在可以试玩 Demo 并 Wishlist 这个游戏,看看 Jai 语言的项目跑起来是怎么样的。你还可以看看吹哥关于 Jai 语言的一系列视频。他有参加过几次 Wookash Podcast 谈到了很多 Jai 相关的讯息,本文中说“吹哥提到过”的基本都是这些播客里听来的。