Singeli
介绍:Singeli 作为解释器 | Singeli 作为编译器 -> Purity 和 Ford 编写一个 min filter
Singeli 是一种用于构建高性能算法(包括 SIMD)的领域特定语言,它提供了对对应于单条指令的代码的灵活抽象。它使用 BQN 实现,包含一个生成 IR 的前端和一个将其转换为 C 的后端(IR 很简单,因此可以支持其他后端,如 LLVM 或机器码,而无需太多工作)。
在 CBQN 中,生产代码有 5k 行,我认为 Singeli 已经可用了!另请参阅 SingeliSort 和 1brc。核心语言现在应该是稳定的(过去的破坏性更改大约经过了 6 个月的弃用期)。标准包含 对于舒适地编程很重要,但在某些领域不够稳固,特别是更复杂的 SIMD 指令。但如果出现更好的设计,我们可能会引入新的包含,而不是进行重大的破坏性更改。
要编译 input.singeli:
$ singeli input.singeli [-o output.c]
有关选项,请参阅 $ singeli -h 或 此章节。要将 singeli 作为可执行文件运行,请确保 CBQN 已作为 bqn 安装在可执行文件路径中,或者以 /path/to/bqn singeli … 的形式调用。
调试编译错误相对容易,因为它们会附带解析或堆栈跟踪(如果没有,那就是一个 bug——请报告),并且 show{} 可以在编译时打印您想要的内容。在运行时,由 include 'debug/printf' 提供的 lprintf{} 会打印您传递给它的值,而生成的 C 代码相当冗长,但它嵌入了源函数和变量名称,您可以利用这些来定位问题。在工具方面,交互式 Singeli playground 是一种很好的方式,可以让您在不经历繁琐的编译-调试循环的情况下让代码的某些部分正常工作,而 singeli-lsp 则提供具有解析和名称解析的高级编辑功能。
Singeli 的早期设计讨论在 topanswers.xyz 进行;现在它位于 BQN forum。
语言概览
Singeli 主要是一种元编程语言。其目的是围绕 CPU 指令构建抽象,以创建大量专用代码。源代码倾向于在编译时执行复杂操作,以生成在运行时执行相对简单操作的程序,因此最好将您的思维集中在编译时发生的事情上。
抽象的主要工具是生成器。使用 {parameters} 编写,生成器执行与 C 宏、C++ 模板或泛型类似的任务,但提供了更大的灵活性。它们在编译期间展开,并构成一种图灵完备的语言。生成器使用词法作用域并允许递归调用。下面是一个调用另一个生成器的生成器:
def gen{func, arg} = func{arg, arg + 1}
事实上,如果 + 被定义,它也是一个生成器。Singeli 运算符不是内置的;相反,用户将每个运算符声明为生成器的中缀或前缀语法。例如,来自 include/skin/cop.singeli 的以下行将 + 定义为优先级为 30 的左结合中缀运算符(可以有一个中缀定义和一个前缀定义)。
oper + __add infix left 30
生成器可以通过额外的定义进行扩展。每个定义在其定义域上覆盖之前的定义,这意味着应用生成器时会向后搜索当前作用域中所有可见的定义,直到找到一个匹配的。上面的 gen 适用于任意两个参数,但可以使用类型和条件来缩小定义的范围:
def gen{x:T, n, n, S if 'number'==kind{n} and T<=S} = {
cast{S, x} + n
}
这里,x 必须是一个带类型的值,并且具有类型 T,两个 n 参数必须匹配。并且 if 之后的条件必须成立。否则,将尝试之前的定义,如果没有剩余的定义,则会报错。
此处的最终目标是定义供其他程序使用的函数。函数在顶层使用 fn 关键字和括号语法声明,可能带有类似生成器的参数。类型使用 value:type 语法而非 type value。
fn demo{T}(a:T, len:u64) : void = {
while (len > 0) {
a = a + 1
len = len - 1
}
}
在此可以看到,运行时执行的代码采用命令式风格编写。提供了 if、else 和 while 控制结构。语句由换行符或分号分隔;这两个字符是等价的。
如果可能,迭代应使用 for-each 循环。在 SIMD 编程中,这些循环非常重要,并且有多种变体,需考虑向量化和展开。因此,Singeli 不提供单一的 for 循环结构,而是提供一种定义循环的通用机制。以下是定义的样子:
def for{vars,begin,end,iter} = {
i:u64 = begin
while (i < end) {
iter{i, vars}
++i
}
}
虽然此循环是一个普通生成器(允许其他循环调用它),但调用它作用于变量和代码块时使用了特殊语法。以下是包含两个指针的示例:
@for (src,dst over i from 0 to len) {
src = 1 + dst
}
在循环外部,这些变量是指针,而在循环内部,它们是单个值。是 iter 生成器执行了这种转换,使用给定的索引 i 来索引 vars 中的每个指针。每个变量都会跟踪其值是否在循环内部被设置,如果确实如此,则写入内存。
生成器
生成器本质上是一个编译时函数,它接受称为参数的值并返回一个结果。有几种创建生成器的方式:
# 匿名生成器,立即调用
def a_sq = ({x} => x*x){a}
# 具有两个情况的命名生成器
def min{a, b} = a
def min{a, b if b<a} = b
# 生成的函数
fn triple{T}(x:T) = x + x + x
这些实际上都是同一回事:在 {} 中有一个参数列表,以及一个定义。当被调用时,主体会在参数设置为提供的值的情况下求值。根据定义的不同,这种求值可能不会以一个静态值结束,而是以一个在运行时执行的计算结束(对于函数,这总是会发生)。生成器是动态类型的,并且自然对参数或结果没有约束。但是参数列表可以包含条件,这些条件决定了是否接受特定的参数集。这些将在下一节中描述;首先让我们浏览每种情况的语法。
匿名生成器是这样编写的 {params} => body。如果它不是整个表达式,通常需要用括号括起来。否则,例如,{params} 可能会被解释为对前一个词的生成器调用。body 可以是单个表达式,也可以是由 {} 包围的由多个表达式组成的代码块。
命名生成器是一条语句而非表达式:def name{params} = body。name 后面可以跟随多个参数列表,从而定义嵌套生成器。这种形式也允许多个定义。当调用生成器时,会从最后一个定义开始扫描这些定义,以找到适用于给定参数的那个。也就是说,在应用生成器时,它会测试参数是否满足其所有条件,如果匹配则执行,否则调用前一个定义。有时以相同方式扩展许多生成器会很有用;请参阅 extend 以了解此用例。
函数也可以具有生成器参数。这种情况在 functions 部分中进行了讨论。
参数匹配
只有当生成器定义中指定的参数列表与给定的参数列表匹配时,该定义才会被使用,从而允许多个定义同时生效。除了名称之外,参数列表还可以包含隐式和显式约束,例如:
- 参数的数量
- 两个同名的参数必须匹配
par:typ参数必须是一个带类型的值- 显式条件
if cond必须成立(结果为1)
完整的匹配与解构]系统描述于def之下。整体参数列表的匹配方式与元组相同。这也意味着(至多)一个参数槽可以是可变长度的,如果它被标记了前导的...。这个“收集”参数对应任意数量的输入,并被定义为这些值的元组。例如,def tup{...t} = t是内置函数tup的一种可能实现,该函数返回所有参数的元组。
展开语法
在调用生成器时,任何参数槽前都可以加上...,将其从元组展开为多个参数。例如,gen{a, ...tup{b, c}, d}展开为gen{a, b, c, d}。任何表达式都可以跟随其后:虽然...不是一个运算符,但它表现得像具有无限低的优先级。
偏函数应用
通过在调用语法中用.替代一个或多个参数,可以对生成器进行偏函数应用。这不会调用生成器,而是创建一个新生成器,其参数用于那些.位置。例如,gen{., 5, .}是一个具有两个参数的生成器,当在{a, b}上调用时,结果是gen{a, 5, b}。
一个参数槽也可以被替换为...,且后面不跟任何表达式。这代表任意数量的参数,因此例如当gen{., 5, ...}在一个或多个值上被调用时,第一个值被用于.,其余的值在...所在的位置末尾传递。
值的种类
生成器是值的一种——也就是说,它在编译时是一等公民。与大多数值一样,它在运行时并不存在。希望它已经完成了所需的工作!事实上,它是较为复杂的值类型之一。以下是完整列表:
| 类型 | 摘要 |
|---|---|
| number | 编译时的高精度数字 |
| symbol | 编译时的字符串 |
| tuple | 编译时的值列表 |
| generator | 在编译时接收并返回值 |
| type | 运行时值可以具有的具体类型 |
| constant | 编译时已知的带类型值 |
| register | 带类型的值,直到运行时才确定 |
| function | 在运行时接收并返回带类型的值 |
| label | goto{} 的目标 |
最简单的类型在本节中讨论,其他类型在下方有专门章节。
数字是浮点数,具有足够的精度,可以精确表示双精度浮点数和 64 位整数(有符号或无符号)。具体来说,它们被实现为双精度浮点数的对,在双精度浮点数的相同指数范围内提供约 105 位的精度。数字字面量语法支持科学计数法和最高 36 进制,描述见下方。
符号是 Unicode 字符串,使用单引号作为字面量书写:'symbol'。它们与 emit{} 生成器一起用于发出指令,并在 export{} 中用于标识应暴露的函数名。
常量由一个值和一个类型组成。当对诸如数字这样的值进行类型转换时,它们就会出现,例如通过创建变量 v:f64 = 6 或显式 cast{f64, 6}。在编程中,常量的工作方式类似于寄存器(变量),因此无需特别考虑它们。如果需要某个值具有特定类型——例如,在调用可能接受多种不同类型的函数时——只需对编译时值进行类型转换即可。
标签用于 goto{} 以及此处](#program) 描述的相关内置函数。
数字字面量
number = repeat* ( scientific | base )
scientific = digit+ ( "." digit+ )? "e" "-"? digit+
base = ( "0x" | digit+ "b" ) ( digit | letter )+
repeat = digit+ ( "r" | "d" | "w" )
Singeli 中的数字以数字开头,但也可以包含字母、小数点 . 以及内部的 -;开头的 - 不会被解析为数字的一部分,而是作为单独的运算符。数字字面量中的字母不区分大小写,并且可以包含下划线,下划线会被忽略。支持典型的科学计数法,例如 45 或 1.3e-12。
十六进制整数字面量可以使用 x 编写,例如 0xf3cc0,但 b 可用于更通用的基数。在 b 之前,2 到 36 之间的十进制数表示基数,例如 2b110101 或 32b0jbm1。与十六进制一样,数字 9 后面跟着 a。
额外的前缀允许指定数字的重复。这会按原样重复尾数数字(包括前导 0),并保持基数和指数不变。最低位数字始终保持其相对于小数点的位置。可以指定多个前缀作为文档形式;它们必须具有相同的含义。
r:重复完整的数字序列,3r12表示121212d:重复到指定的数量,5r123表示23123(从最低位数字开始)w:重复到指定的位宽,10w0x4f表示0x34f(基数必须是 2 的幂)
定义
def 语句,其语法为 def target = value,用于进行编译时定义。以这种方式定义的值在其作用域内是常量(遵循词法作用域规则),例外情况是,如果它是一个生成器,它仍然可以通过生成器定义语句(def target{params} = ...)进行扩展。更准确地说,该定义在编译时保持常量。如果它是一个 寄存器,它将始终指向同一个寄存器,但该寄存器在运行时可以是可变的,允许其值被自由更改为同类型的另一个值。使用 def 创建的每个可变寄存器副本都会反映这些更改,并且对其赋值会改变其值。
匹配
定义的目标可以是一个普通名称,但为了解构元组和类型以及添加条件,也允许其他形式。其中一些形式对于生成器参数比 def 更有用,事实上,运算符 = 和 == 不允许出现在 def 目标的顶层。
以下是简单目标:
- 一个名称,例如
param,匹配任何值 _,匹配任何值但不为其分配名称- 一个数字或符号字面量,仅匹配该值
(expr),其中表达式expr在求值时必须匹配参数
以下是复合目标:
a=b,独立匹配目标a和ba==b,等价于a=(b)a:T,匹配具有类型T的带类型值a*T,匹配指针类型[k]T,匹配向量类型{a,b,...},匹配元组或元组类型
每个运算符(=、==、:、*、[k])都是右结合的,也就是说,左侧只能是一个简单目标或元组,而右侧则延伸到表达式的末尾。例如,a:P=*V=[k]T 分组为 a:(P=(*(V=[k]T))),以匹配一个类型为 P、*V 和 *[k]T 的变量 a。
此外,任何目标后面都可以跟一个条件,例如 if a < 5。这里 if 必须位于任何运算符之后,但它可以出现在元组匹配器的任何组件中,或者出现在向量类型匹配器的括号部分 [k] 中。
元组包含零个或多个目标(即使没有目标,内部也可以包含一个 if 条件),并且仅匹配长度相同的元组或元组类型。然而,它也可以包含一个“收集”的变长槽位,以开头的 ... 表示,该槽位匹配零个或多个值——因此允许的值总数等于非收集目标的数量,或者更多。这些值被视为一个元组。因此,目标 {...a} 与 a 相同,只是它要求匹配的值必须是一个元组,如果它恰好是一个元组类型,则会被转换为类型元组。
最后一个隐式条件是,任何出现多次的名称必须被分配给匹配的值,因此 {a,a} 匹配 tup{3,3} 但不匹配 tup{3,4}。这不适用于占位符 _,因此 {_,_} 匹配任意元组。
括号表达式和条件可以自由使用目标中其他地方的名称。这是通过在所有其他匹配要求检查完毕且名称定义之后对它们求值来实现的。因此,{a if show{b>a}, b, b} 是一个完全有效的目标,并且 show 仅在两个 b 值都匹配时才会显示。if 条件的位置完全不影响其含义,除了条件的相对顺序控制它们的求值顺序。
请注意,a:T 中的类型也是一个目标!像 a:[8]i16 这样的目标会同时分配给 a 和 i16。要指定确切类型,请使用括号,如 a:([8]i16)。
运算符
运算符由字符 !$%&*+-/<=>?\^|~ 组成。任意数量的这些字符都会组合在一起形成一个单独的标记,除非被空格分隔。此外,非 ASCII 字符也可以用作运算符,并且它们不会相互组合。
oper 语句定义一个新的运算符,并适用于作用域中后续的所有代码(运算符通过 Pratt 解析器处理,这自然允许了这一点)。以下是取自 include/skin/cop.singeli 的 - 的两个声明。
oper - __neg prefix 30
oper - __sub infix left 30
声明列出了运算符的拼写、生成器、形式(对于中缀运算符,包括元数和结合性)以及优先级。声明之后,应用该运算符会执行关联的生成器。更准确地说,每次都会在使用运算符的作用域中查找生成器名称:默认情况下,oper 语句仅将运算符与一个名称关联,而不与任何特定值关联。若要分配特定值,请使用带有值(任何表达式)的声明,例如 oper - (__neg) prefix 30。
一个运算符最多可以有一个中缀定义和一个前缀定义。前缀运算符没有结合性(因为运算符不能用作操作数,它们总是从右向左执行),而中缀运算符可以声明为 left、right 或 none。使用 none 时,在运算符被多次应用的模糊情况下会发生错误。优先级可以是任何数字,数字越大,绑定越紧密。
可以在调用运算符之前向其传递参数,例如 a -{b} c。这会被转换为生成器调用 __sub{b}{a,c}。元数是在使用运算符语法调用时确定的,不依赖于任何更早的生成器语法调用。
类型
Singeli 的类型系统由以下类型种类组成:基本 void 和原始类型,以及由多个底层类型构建的复合类型。
| 类型种类 | 描述 | 示例显示 |
|---|---|---|
void | 无 | void |
primitive | 一个数字 | i32 |
vector | 同类型值的列表 | [4]i32 |
pointer | 指向内存的指针 | *f64 |
function | 指向代码的指针 | (i8,u1)->void |
tuple | 多个值 | tup{u1,[2]u32} |
显示形式并不总是有效的 Singeli 代码。Void 和原始类型是内置名称,但可以被覆盖。记法 [4]i32 解析为 __vec{4,i32},其中 __vec{} 也是一个内置名称。而 * 表示内置的 __pnt{},它不是自动定义的,但属于 skin/c 的一部分。为函数给出的记法不能使用,且元组 tup{u1,[2]u32} 在技术上与实际元组类型是不同的值,但可以在期望类型的地方作为类型使用。
基本类型由一个表示类型的字母加上以位为单位的宽度来表示。支持类型的列表如下所示。请注意布尔数据使用 u1 表示。布尔向量(例如 [128]u1)是一个重要的用途。
unsigned: u1 u8 u16 u32 u64
signed int: i8 i16 i32 i64
float: f32 f64
向量类型表示值应存储在向量或 SIMD 寄存器中,后端会根据目标架构限制可使用的向量大小。
函数
生成器非常适合编译时计算,但所有运行时计算都在函数中进行。函数使用括号语法声明和调用:
fn times{T}(a:T, b:T) = a*b # 类型通用函数
fn square{T}(x:T) : T = {
times{T}(x, x)
}
函数体可以是一个普通表达式,如上文的 a*b 所示,也可以用花括号 {} 包裹以允许多个语句。它返回最后一个表达式的值。返回类型在参数列表之后用 : 给出,但通常可以省略(如果省略且函数体使用花括号,则 = 也是可选的)。
像上面 {T} 这样的函数参数与任意生成器参数略有不同:对于每组唯一的参数,该函数只会被生成一次。生成后,其句柄会被保存,以便后续调用立即返回已保存的函数(相比之下,声明变量的生成器每次都会创建一个新的)。这避免了生成包含大量函数副本的源代码,并且使得像 square{T} 这样的函数能够包含递归。
调用函数的括号语法实际上只是使用 call{} 内置生成器的一种更优雅的方式。与生成器一样,这样的调用支持 spread 参数,使用 ...。
函数参数可以具有元组类型;在 Singeli 的编译输出中,它会被展平为多个参数。可以使用 ... 语法将其变为一个收集参数:在函数 fn f(...a:tup{i8,i16}, b:i32) 中,a 是一个寄存器元组,但该函数以 f(a0, a1, b) 的形式调用,带有 a0:i8 和 a1:i16。由于 a 的长度可以根据其类型确定,因此任意数量的参数都可以加上 ... 前缀,这与 gathered parameters 不同。
Export
export 内置函数导出值,供调用语言使用。在 C 中,这意味着在输出文件中定义了一个具有该名称的非-static 常量。
export{'some_function', fn{i32}} # 导出为 some_function()
export{tup{'fn', 'alias'}, fn{i16}} # 以两个名称导出
export{'twelve', cast{i16, 12}} # 导出一个带类型的数字
Registers
函数的参数,以及它在运行时操作的变量,由称为寄存器的带类型槽位表示。寄存器是一等值,意味着它们可以在编译时传递和操作。例如,生成器可以接受一个寄存器作为参数并设置其值。通过传递参数或 def 语句创建的寄存器副本,表现出别名行为,类似于按引用传递。
在函数中,可以使用 name : type = value 语法声明寄存器,如果值已经具有类型,则可以省略类型。初始值是必需的。函数参数也声明为寄存器,但它不使用初始值,因为该值在函数调用时传入。
x:i32 = 25 # 指定类型:数字是无类型的
y := x + 1 # 类型推断
寄存器的运行时值可以使用 name = value 语法更改,无需 :。
x = x + 1 # 递增
这不会改变 x 或 y 的编译时值,它是一个寄存器。声明和重新赋值在运行时本质上做的是同一件事,但在编译时它们是两件不同的事。声明基本上是一个捆绑了初始值赋值的 def 语句。赋值是一个操作(实际上是一个内置运算符),作用于一个寄存器和一个值,并且可以用于生成器调用。左侧可以是一个完整的表达式,只要它在编译时解析为一个寄存器——例如尝试 (if (0) a; else b) = c。内置的 file skin/cmut(skin/c 的一部分)为 C 运算符 +=、/=、>>= 等定义了生成器,因此你可以编写:
x += 1
++x
在运行时,一个寄存器一次只表示一个值,因此每当它被使用时,可见的将是其当前值。如果你想将值保存到某处,请创建另一个寄存器,例如 x0 := x。即使在生成器中也可以这样做,使得 def clone{old} = { new:=old } 可以复制任何输入的值。因此,虽然由于 Singeli 没有后缀运算符,skin/cmut 不会定义 x++,但有可能创建一个生成器,该生成器复制参数,将其递增,并返回副本。
控制流
Singeli 的内置控制流语句是 if-(else) 和 (do)-while。其语义与 C 语言完全相同,语法也非常相似。你可以使用单个语句,或者用花括号括起来的多个语句。以下是一些示例:
if (a < 4) a = a + 1
if (a > b) { c = a }
else { c = b }
do {
doThing{c}
c = c + 1
} while (c < a)
条件的结果必须具有布尔类型 u1,或者是一个值为 0 或 1 的编译时数字。数字选项允许 if 用作编译时开关:如果条件为 0,则不会编译 "if" 块;如果条件为 1,则不会编译 "else" 块。
在任何 if 或 while 条件中,可以使用伪运算符 and、or 和 not 来创建复合条件。这些是通过操纵跳转来实现的,从不使用逻辑指令。and 和 or 是短路求值的,这意味着如果结果已由第一部分确定,则不会评估条件的第二部分。
For 循环
用于“循环”的 A 看起来非常像正在高级和低级语言中变得常见的 for-each 循环。表象具有欺骗性,因为它实际上是一种特殊的生成器调用,可以在执行时评估代码块。但它很好地覆盖了 for-each 功能,前提是使用了正确的 for 生成器。
# 使用特定范围循环遍历三个指针
# 描述符中的表达式(如 len/2)只会被评估一次
@for (dst, a, b over i from len/2 to len) {
dst = a + b + i
}
# 使用 "in" 为元素指定与指针不同的名称
@other_for (x in src over n) x = 2*x
@ 之后的名称只是一个名称,并作为生成器被调用。以下对 for 的定义给出了一个典型的类 C 循环。它接收一个评估代码块的生成器 iter;细节将在下文讨论。
def for{vars,begin,end,iter} = {
i:u64 = begin
while (i < end) {
iter{i, vars}
++i
}
}
描述符
描述符 是括号中的部分,列出了用于循环的变量和范围。它为上述 for 生成器提供 vars(指针元组)、begin 和 end 参数,并定义了在主循环代码块中使用的名称,这些名称随后构成 iter 参数。
以下是该模式。方括号 [] 表示可选部分,而 … 表示可以出现更多可能位于指针中的名称,以逗号分隔。
[name ["in" pointer], … "over"] [index ["from" begin] "to"] end
选项真多!你可以使用任意数量的指针,也可以省略索引——不过有个奇怪的地方是,如果你希望起始值不同于默认的 0,就必须包含它。下面列出了一些具有典型解释的可能性。
@for (num):重复num次@for (x in arr over len):遍历元素@for (i to num):遍历一个范围@for (i from a to b):同上,使用半开区间[a,b)@for (dst, src over i to len):使用索引遍历两个指针
除了固定的默认起始点 0 之外,起点和终点的解释完全由用于循环的生成器决定。它可以忽略这些值并始终恰好运行一次,反向运行,或者做其他事情。
For 生成器
一旦描述符和主体被解析,以下值会被传递给 @ 之后命名的任何生成器,循环的结果就是输出的内容。vars 等名称仅用于说明,因为在 Singeli 编译中它们是无名的。
vars:一个元组,包含 "over" 之前列出的所有指针的值begin:"from" 之后表达式的值,或数字 0end:末尾表达式的值,如果存在 "to" 则是 "to" 之后的值iter:运行 for 循环主体的生成器
大多数时候,生成器会求值该块——否则有什么意义呢?这是通过 iter{index, ptrs} 完成的,其中 ptrs 是一个指针元组(它没有由 for 循环创建的 match vars),而 index 是一个索引。该生成器:
- 使用
load{p, index}从每个指针加载一个值, - 使用这些加载的值和
index求值该块, - 使用
store{p, index, new_value}存储任何被修改的值,并且 - 返回块求值的结果(很可能被忽略)
以下是一些示例。
# 标准的 for 循环,再次出现
def for{vars,begin,end,iter} = {
i:u64 = begin
while (i < end) {
iter{i, vars}
++i
}
}
# 在编译时展开的循环,使用递归实现
# 假设 begin 和 end 是常量,以使用编译时 if
# 每次 iter{} 调用都编译为带有常量索引的代码
def for_const{vars,begin,end,iter} = {
if (begin < end) {
for_const{vars,begin,end-1,iter}
iter{end-1, vars}
}
}
# 遍历长度为 vlen 的向量
def for_vec{vlen}{vars,begin,end,iter} = {
# 将每个指针转换为向量
def vptr{ptr:P} = reinterpret{*[vlen]eltype{P}, ptr}
def vvars = each{vptr, vars}
# 向量部分的端点
def vb = (begin+(vlen-1))/vlen # 向上取整
def ve = end/vlen # 向下取整
# 执行循环
for{ vars, begin, vlen*vb, iter}
for{vvars, vb, ve, iter}
for{ vars, vlen*ve, end, iter}
}
for_vec 展示了这种方法的灵活性。由于 for 是一个普通的生成器,可以调用它以避免在到处重写相同的基于 while 的逻辑。并且由于任何指针都可以传递给 iter,因此具有不同类型 vvars 的修改后的指针也是合法的。这意味着 iter 需要处理两种不同的变量类型,T 和 [vlen]T——幸运的是,Singeli 的设计正是为了支持这种多态性。而且这是一个你可以控制的要求,因为你可以决定使用哪种 for 循环。最后,for_vec 在作为循环被调用之前,会接受另一个参数。例如,它被调用时带有 @for_vec{8} (…)。
Match
match 结构执行编译时的情况匹配,类似于 switch-case 语句。它以匿名形式封装了多个 def 语句的功能。例如,这个 match 语句:
def result = match (x, y) {
{{a,b}, c} => b
{a, {b,c}} => tup{a,c}
{...args} => 0
}
等价于以下序列,除了它不定义 temp。
def temp{...args} = 0
def temp{a, {b,c}} = tup{a,c}
def temp{{a,b}, c} = b
def result = temp{x, y}
注意顺序是相反的:在 match 中,将使用第一个匹配的情况。匹配的值(上述的 (x, y))也可以省略以创建匿名生成器。match (x, y) {…} 的行为与 (match {…}){x, y} 完全相同。
Extend
extend 关键字允许对生成器进行可编程扩展,以便轻松地将同一扩展应用于多个生成器。它主要用于更“内部”的 Singeli 定义,例如 arch/c 中的定义。简而言之,这段重复的代码:
def sin{arg if arg<0} = -sin{-arg}
def tan{arg if arg<0} = -tan{-arg}
可以重写为如下形式:
def extend odd{fn} = {
def fn{arg if arg<0} = -fn{-arg}
}
extend odd{sin}
extend odd{tan}
def extend 语句创建了一种特殊类型的生成器,只能由 extend 语句调用。该语句(可以出现在顶层或任何其他位置)看起来像生成器调用,但上述参数 sin 和 tan 都必须是名称——extend 机制允许调用修改它们的值。生成器(上述的 odd)可以是任何表达式,但在大多数情况下,如果是复合表达式,则需要加括号。这意味着可以通过 extend (gen{args}){g0, g1} 传递额外信息,其中 gen 是一个普通生成器,其主体返回一个使用 def extend 定义的生成器。
顶层
顶层代码是构成文件、local 块或 if_inline/else 语句主体的语句。可以在 def 或类似块上下文中使用的语句也可以在此处使用,但这里还增加了一些功能。
另一种区分导致某些特性无法在顶层使用。这是因为在运行时求值的代码必须出现在函数内部。如果意图是构建一个独立的可执行文件,这可以是 main 函数。此外,在函数外部声明的变量是常量,无法使用 = 进行修改。但是,如果它是一个指向数组的指针,则可以像往常一样在 load 和 store 指令中使用。
包含文件
include 语句可以在程序的顶层使用。它将指定的文件作为当前文件的一部分进行求值。文件名是一个符号,如果它以 . 开头,则从相对路径加载;否则,从 Singeli 的内置脚本(保存在 include/ 源文件夹中)加载。option -l 也可以用于指定额外的路径。
include 'arch/c' # 内置库
include './things' # 相对路径
因此,被包含文件的定义会影响包含它的文件、包含 该 文件的任何文件,以及在该文件之后包含的其他文件。
Local
文件包含的深远影响可能并非适用于所有定义,因此 local 关键字将顶层定义限制为仅适用于当前文件,而不影响包含它的其他任何内容。
local def fn{a,b} = b # 局部生成器 local b:u8 = 3 # 局部类型化常量 local oper ++ merge infix left 30 # 局部运算符 local include 'skin/c' # 局部大量运算符
local 关键字限制了编译时值和运算符定义的作用域。它在运行时不执行任何操作:所有函数等仍然被放置在一个大的输出文件中。
每个作用域内,一个文件只能被包含一次;首次之后的包含将被忽略。
对于更大的定义集合,local 还允许使用块语法。块的内容表现得像通过 local include 包含的独立文件,并且块内允许更多的 local 语句——它们将在块内生效,但不会作用于文件的其余部分。
local {
# 这些内容可以被文件的其余部分看到,
# 但在文件外部不可见
include 'skin/c'
def t = 5
def s = t + 1
}
if_inline
顶层的 if 语句目前表现为 if_inline;它们很快将改为表现为普通的 if。
顶层允许使用 if_inline 语句,其基本语法与 if 相同,但求值规则截然不同。这套机制相当混乱,希望最终能找到更好的解决方案;目前,请尽量避免使用这些!
if_inline 语句的条件在一个特殊的作用域中求值,该作用域只能访问内置的 Singeli 定义,因此例如 if_inline (hasarch{'SSSE3'}) 可以工作,但 if_inline (debug) 不行,其中 debug 之前已被定义。这是因为语句的主体没有自己的作用域,而是其定义和运算符声明会影响其出现的作用域——由于 if_inline 语句的结果未被使用,这是主体定义事物的唯一方式。但如果条件可以从该作用域读取,则会产生可能导致悖论的循环依赖。
Main
为了构建独立的 Singeli 程序,一个 main 块定义了将在程序被调用时运行的代码。一个完整的版本可能如下所示:
main(argc, argv) : i32 = {
# 这里 argc:i32 和 argv:**u8
}
两个参数 argc 和 argv 分别是命令行参数的数量及其值,与 C 语言类似。只能指定它们的名称;它们将隐式地被赋予类型 i32 和 **u8。可以给出更少的名称(单个名称将具有类型 i32),如果没有参数,括号是可选的。结果类型可以是 i32 或 void,如果省略,则像典型函数一样进行推断。这意味着 main 可以简单为一个表达式:
include 'debug/printf'
main = lprintf{'Hello'}
Config
config 语句允许将编译时配置作为参数传递给构建过程。它的语法与 def 相同,但定义的必须是一个单一的名称,而不能进行解构。因此,config var = 4 通常赋予 var 值 4。但如果构建时传递了参数 -c var='conf',则它将被定义为 'conf'。命令行参数可以是任何代码,其行为等同于直接写在定义中,这意味着它可以访问局部运算符和定义。
内置生成器
以下生成器在任何程序中都是预定义的。它们被放置在主程序的父作用域中,因此这些名称可以被程序遮蔽。
程序
| 语法 | 结果 |
|---|---|
emit{type,op,args…} | 调用指令 op(一个符号,由后端解释) |
call{fun,args…} | 调用一个函数 |
return{result} | 从当前函数返回 result(如果返回类型为 void 则是可选的) |
export{name,value} | 导出值供调用语言使用 |
require{name} | 从调用语言请求某些内容,例如 C 头文件 |
makelabel{} | 创建一个标签值 |
setlabel{label} | 将标签设置为当前位置 |
setlabel{} | setlabel{makelabel{}} 的简写 |
goto{label} | 跳转到为标签设置的位置 |
架构
| 语法 | 结果 |
|---|---|
setarch{feature…} | 设置当前函数的特性集 |
addarch{feature…} | 向当前函数的特性集添加特性 |
hasarch{feature…} | 如果所有特性都存在则返回 1,否则返回 0 |
listarch{} | 当前特性集,表示为符号元组 |
witharch{fn,feat…} | 返回一个生成器,类似于生成的函数 fn,但使用给定的特性集 |
架构特性是一个大写符号,例如 'AVX2'。每个函数创建时都带有一组此类特性,可以在函数外部使用 witharch{} 设置,或在函数内部使用 setarch{} 和 addarch{} 设置。
值
| 语法 | 结果 |
|---|---|
is{a,b} | 如果参数匹配则返回 1,否则返回 0 |
hastype{val} | 如果 val 是带类型的值(常量、寄存器、函数)则返回 1 |
hastype{val,type} | 如果 val 是给定类型的带类型值则返回 1 |
type{val} | 返回 val 的类型 |
kind{val} | 返回一个指示值种类的符号 |
__set{reg,val} | 为寄存器 reg 设置值 val,等同于 reg = val。 |
undefined{type} | 给定类型的未指定或未初始化值 |
undefined{type,len} | 指向具有给定元素类型和长度的未定义数组的指针 |
show{vals…} | 用于调试:打印参数,如果恰好有一个参数则返回该参数 |
可能的 kind 结果包括 number、constant、symbol、tuple、generator、type、register 和 function。
类型
| 语法 | 结果 |
|---|---|
width{type} | type 所占用的位数 |
eltype{type} | 向量或指针类型的底层类型 |
vcount{[n]t} | 向量类型中 n 的元素数量 |
cast{type,val} | 将 val 转换为给定类型 |
quality{type} | 原始类型的品质:无符号 'u'、整型 'i' 或浮点型 'f' |
isfloat{type} | 如果 type 是浮点型则为 1,否则为 0 |
issigned{type} | 如果 type 是有符号整型则为 1,否则为 0 |
isint{type} | 如果 type 是整型则为 1,否则为 0 |
typekind{type} | 指示类型性质的符号 |
primtype{q, w} | 具有品质 q 和宽度 w 的原始类型 |
__pnt{t} | 元素为 t 的指针类型 |
__vec{n,t} | 元素为 t 且长度为 n 的向量类型;等价于 [n]t |
tuptype{t…} | 具有给定元素类型的元组类型 |
fntype{t…, res} | 具有给定参数类型和结果类型 res 的函数类型 |
unfntype{ft} | 分解函数类型,将 fntype 参数作为元组给出 |
可能的 typekind 结果为 void、primitive、vector、pointer、function 和 tuple。
类型转换
转换生成器在特定条件下将无类型常量转换为有类型常量,或在类型之间进行转换。在每种情况下,无类型常量都可以转换为包含它的任何类型。类型之间转换的条件和行为如下所示。
| 语法 | 若已指定类型时的要求 | 转换 |
|---|---|---|
cast{type,val} | val 的类型与 type 匹配 | 值相同 |
promote{type,val} | val 的类型是 type 的子集 | 值相同 |
reinterpret{type,val} | val 的类型与 type 具有相同的宽度 | 二进制表示相同 |
生成器
| 语法 | 结果 |
|---|---|
bind{gen,param…} | gen{param…, ...} |
withenv{name,value,gen} | gen,但在求值期间将环境中的 name 设置为 value |
getenv{name,default?} | 环境中 name 的值,若不存在则为 default 或错误 |
hasenv{name} | name 在环境中是否有值 |
hasenv{name,value} | name 在环境中是否设置为 value |
memoize{gen} | gen,但若参数与之前的调用匹配,则返回已保存的结果 |
withenv 和 getenv 所使用的程序环境将符号(表中的 name)与值关联。这是一种动态作用域形式,即关联根据生成器调用栈创建和释放。
元组
| 语法 | 结果 |
|---|---|
tup{elems…} | 创建一个参数元组 |
range{len} | 元组 0, 1,… len-1 |
merge{tups…} | 将多个元组中的元素连接为一个元组 |
length{tuple} | 返回元组或符号的长度 |
select{tuple,ind} | 从元组中选择第 ind 个元素(从 0 开始索引) |
slice{tup,start,end} | 从 start 到(可选的)end 进行切片,类似于 Javascript |
apply{gen,tuple} | gen{...tuple} |
each{gen,tuple…} | 将生成器映射到给定的元组上 |
symchars{symbol} | 返回符号的字符,作为符号的元组 |
fmtnat{num} | 将自然数格式化为符号 |
findmatches{i, f} | 返回一个元组,对于 f 的每个元素,包含 i 中所有匹配元素的索引 |
生成器 merge{} 和 slice{} 在应用于符号而非元组时也能正常工作。
算术
算术函数以双下划线命名,因为它们旨在被别名为运算符。默认定义作用于编译时数字,有时也作用于类型。展示了数字的 fn{x} 或 fn{x,y} 定义,采用类 C 语法,外加 ** 用于指数运算和 // 用于向下取整除法。
__neg | __shr | __shl | __add | __sub | __mul | __div | __mod |
|---|---|---|---|---|---|---|---|
-x | x//2**y | x*2**y | x+y | x-y | x*y | x/y | x%y |
__and | __or | __xor | __not |
|---|---|---|---|
x&y | x|y | x^y | !x |
__eq | __ne | __lt | __gt | __le | __ge |
|---|---|---|---|---|---|
x==y | x!=y | x<y | x>y | x<=y | x>=y |
以下内置函数与 C 语言 math.h 中同名的函数功能相同。
__abs | __floor | __ceil | __min | __max |
|---|---|---|---|---|
| 绝对值 | 向下取整 | 向上取整 | 最小值 | 最大值 |
内置算术运算广泛适用于元组,这意味着如果一个或两个参数是元组,它将递归地对它们进行映射,直到遇到非元组为止。
命令行选项
singeli 命令的参数是输入文件和选项,顺序任意。所有选项都有一个带一个 - 的短格式和一个带两个的长格式。除 -h 外,所有选项后都跟随一个额外的参数。
-h, --help: 打印所有编译选项的简短描述。
-o, --out: 编译输出的文件路径;否则直接打印到 stdout。
-t, --target: 输出类型:c 用于 C 代码,cpp 用于 C++(避免 "crosses initialization" 错误)以及 ir 用于 Singeli IR。IR 格式可能不稳定,该设置目前仅用于开发。
-a, --arch: 目标系统中的架构特性列表。
-i, --infer: 推断类型,或何时假设一个架构特性隐含另一个:strict 仅使用指定的依赖关系,loose 基于当前存在的架构进行假设。
-l, --lib: 在 include 语句中搜索的库路径。因此 -l path 意味着 include 'x' 将检查 lib/x.singeli,而 -l lib=path 意味着 include 'lib/x' 将检查 path/x.singeli。按顺序搜索 -l 所隐含的所有路径,随后是 Singeli 的内置包含。
-c, --config: 指定 config 变量的值。例如,config var=4 通常作为 def var=4 使用,但使用 -c var='conf' 时它将作为 def var='conf' 使用。
-p, --pre: 放置在生成的 C 输出之前的前言。
-n, --name: 用于 C 函数和全局变量的前缀,而不是 si_(Singeli 的缩写)。如果要在同一项目中包含多个使用 Singeli 构建的文件,这有助于避免名称冲突。
-os, --show: show{} 内置函数的目标。默认设置为 stdout;也可以设置为 stderr、none 以丢弃它,或者设置为 file= 后跟一个路径,以便在程序退出时写入文件。
-oe, --errout: 编译失败时错误的目标。默认设置为 stderr;其他选项与 -os 相同。此外,bqn 与 none 相同,只是错误会终止当前编译,而不是整个程序。
-d, --deplog: 一个文件路径。一个依赖项列表——编译期间读取的文件——将被放置在此文件中。这样,增量编译器框架可以检查这些文件,以确定是否需要重新运行此编译。