- 开发工具
- 数据可视化
【免费下载链接】penrose
Create beautiful diagrams just by typing notation in plain text.
索引语句(Indexed Statements)是 Penrose 的 Substance 声明式语言中用于批量生成对象与关系的关键语法:通过模板标识符加for i in [x, y]索引子句,一行代码即可展开为成百上千条普通语句,是绘制海量点、边、迭代序列等高密度图元时的核心手段。本文以官方参考文档 indexed-statements.md 为骨架,结合 Substance 语法文件 与 Substance 编译器实现 的源码证据,完整讲解索引语句的展开规则、多变量笛卡尔积、where条件过滤、已知解析缺陷及与重复语义的交互,读完即可在自己的 Domain + Style + Substance 三元组中熟练编写高表达力的索引式图程序。
什么是索引语句
Substance 语言允许用户定义"可展开为多条语句的索引表达式"。一条索引语句就是一条普通的 Substance 单条语句(对象声明、谓词应用、函数/构造器绑定、标签语句等),只不过其中的标识符带有模板后缀,并追加一个索引子句(indexing clause)。最常见的用法是声明一组索引变量:
Vector v_i for i in [0, 2] -- 等价于 Vector v_0, v_1, v_2这里的v_i就是一个模板标识符(templated identifier)。它仍是合法的 Substance 标识符(由字母、数字与下划线组成),区别在于:最后一个下划线及其后的子串表示一个索引变量。上例中_i表示索引变量i,其取值来自范围[0, 2],即0, 1, 2,因此该行被展开为三条语句Vector v_0、Vector v_1、Vector v_2。
范围i in [x, y]的语义
:::infoi in [x, y](其中x、y为给定数值)要求i是整数、i ≥ x且i ≤ y。因此类似Vector v_i for i in [3, 0]的写法不产生任何展开结果——不存在同时满足"至少为 3 且至多为 0"的整数i。 :::
这一点与编译器的实现完全一致:在 Substance.ts 的evalISet函数 中,引擎会先检查范围上下界high、low是否为整数(Number.isInteger,否则抛出BadSetIndexRangeError),随后当high < low时直接返回空列表(return ok([])),表示该索引集为空。展开时使用Range(low, high + 1)生成闭区间[low, high]内的全部整数值——注意两端都是包含的。
语法层面的支撑
在 Substance.ne 中,索引语句由以下规则解析:
stmt_iset -> stmt __ iset:任意普通语句stmt后紧跟一个iset索引子句;iset -> "for" sepBy1[range_assign, ","] ("where" boolean_expr)?:for后至少一个范围赋值(sepBy1保证非空),多个范围用逗号分隔;where子句可选;range_assign -> identifier "in" int_range:形如i in [0, 2];int_range -> "[" number "," number "]":闭区间语法。
同时for、in、where、mod均被注册为关键字(见 Substance.ne 词法定义),因此不能把它们当作普通标识符使用。解析结果被标记为StmtSet节点,编译期再交给checkStmtISetHelper做类型检查与展开(见下文"展开的实现"一节)。
模板标识符与普通标识符的遮蔽(Shadowing)
当模板标识符与程序中的普通标识符同名时,在索引语句内部,普通标识符会被遮蔽(shadow):只要某个标识符出现在索引语句中,且它的下划线后缀碰巧与索引变量同名,那么它将被视为模板标识符,而不再指向此前声明的对象。参考文档给出了完整的对照示例:
Vector v_i, v_j, vec1 Vector v_i for i in [0, 10] -- ok: 展开为 v_0, v_1, v_2, ..., v_10 Vector v_j for i in [0, 1] -- error: 范围 `i in [0, 1]` 没有定义 `j` 的取值 -- 注意:此处的 v_j 并不指代上面声明的 v_j -- 因为它出现在索引语句中,被视为模板标识符 Orthogonal(v_i, vec1) for i in [0, 3] -- ok: 展开为 Orthogonal(v_0, vec1); Orthogonal(v_1, vec1); -- Orthogonal(v_2, vec1); Orthogonal(v_3, vec1) -- 注意:vec1 不含下划线,因此被当作普通 Substance 变量 Orthogonal(v_i, v_j) for i in [0, 5] -- error: 范围 `i in [0, 5]` 没有定义 `j` 的取值这里的关键判别规则是:标识符中最后一个下划线之后的子串必须与某个已声明的索引变量同名,才会被当作模板标识符进行替换;否则(如vec1)保持普通变量语义。而一旦判定为模板标识符,即使它与之前声明过的对象重名,也会被遮蔽,不再引用旧对象。
多索引变量与笛卡尔积展开
当一条索引语句包含多个模板变量和多个范围时,Substance 会取各范围取值的笛卡尔积(Cartesian product)——即所有索引组合都会被枚举。官方文档示例:
Orthogonal(v_i, v_j) for i in [0, 1], j in [1, 2] -- 展开为 Orthogonal(v_0, v_1); Orthogonal(v_0, v_2); -- Orthogonal(v_1, v_1); Orthogonal(v_1, v_2)即i ∈ {0, 1}、j ∈ {1, 2}的 2×2=4 种组合全部生成。这正是evalISet中 笛卡尔积实现 的行为:每个范围被展开为[[name, value]]形式的候选值列表(例如i in [1, 3]生成[["i", 1]], [["i", 2]], [["i", 3]]),然后通过cartesianProduct逐步合并所有变量,得到一组组"索引变量 → 整数值"的替换(substitution)。最后,如果存在where条件,再对每个替换求值布尔表达式,仅保留条件为真的组合。
需要注意,同一索引子句中不允许重复声明同一个索引变量——evalISet会先做去重检查,发现重复即抛出DuplicateIndexError。例如for i in [0, 2], i in [1, 3]是非法的。
where 条件过滤(Conditional Filtering)
有时我们并不想要全部笛卡尔积组合。Substance 允许在where子句中写一个布尔表达式来过滤组合:所有使布尔表达式为假的组合都会被丢弃。官方文档给出了五类典型场景:
Vector v_i for i in [0, 10] where i % 2 == 0 -- 偶数下标:0, 2, 4, 6, 8, 10 Orthogonal(v_i, v_j) for i in [0, 2], j in [0, 2] where i <= j -- 三角范围:[0, 0], [0, 1], [0, 2], [1, 1], [1, 2], [2, 2] Orthogonal(v_i, v_j) for i in [0, 3], j in [0, 3] where i + 1 == j -- 相邻对:[0, 1], [1, 2], [2, 3] Edge(v_i, v_j) for i in [0, 4], j in [0, 4] where j == (i + 1) mod 5 -- 循环对:[0, 1], [1, 2], [2, 3], [3, 4], [4, 0] Orthogonal(v_i, v_j) for i in [0, 3], j in [0, 3] where i % 2 == 0 && j == i + 1 -- 两两不相交的对:[0, 1], [2, 3]布尔表达式的构成
where子句中的布尔表达式可以包含:
- 布尔常量:
true与false; - 一元逻辑运算符:
!(逻辑非),后接一个布尔表达式; - 二元逻辑运算符:
&&(逻辑与)、||(逻辑或),连接两个布尔表达式; - 数值比较:
==(等于)、!=(不等于)、<(小于)、>(大于)、<=(小于等于)、>=(大于等于),比较两个数值表达式。
数值表达式的构成
数值表达式可以包含:
- 浮点常量(注意范围
[x, y]的上下界虽按数值解析,但引擎要求其为整数值); - 范围中定义的索引变量,如
for i in [0, 2]中的i; - 一元数值运算符:
-(取负),后接数值表达式; - 二元数值运算符:
+(加)、-(减)、*(乘)、/(除)、%或mod(取模,二者在语法树中统一映射为%操作符)、^(幂),连接两个数值表达式。
从 Substance.ne 表达式文法 可以看到完整的层级:boolean_expr(||)→boolean_term(&&、!)→boolean_factor(括号、布尔常量、比较表达式);数值侧为expr(+/-)→term(*///%/mod/一元-)→factor(^、括号、数字、标识符)。
运算符优先级与括号
默认的运算优先级与其他主流编程语言一致:^的绑定最强,其次依次是* / % mod、+ -,再往下是比较运算符,最后是!、&&、||。括号( )可以用来覆盖默认优先级(boolean_factor与factor均允许括号包裹子表达式),如示例中的(i + 1) mod 5。
在evalCond(Substance.ts)中,&&与||都实现了短路求值(short-circuiting):&&的左侧为假时直接返回假而不求值右侧,||的左侧为真时直接返回真。这不仅是一种性能优化,也保证了某些组合下不求值右侧表达式(例如避免0 % 0之类的除零错误)的行为可预期。
已知缺陷:+1/-1的 tokenizer 解析问题
:::warning
由于内部 tokenizer 的一个缺陷,形如+1的表达式无论出现在程序中的什么位置,总是被解析为单个 token+1(表示整数"正一"),而不是+与1两个 token;同理,-1总是被解析为单个 token-1,而不是-与1。
因此,2+1总是被解释为2与+1,而不是预期的2、+、1;2-1同理被解释为2与-1。换句话说,它们被当作"两个数字紧挨着"而不是"一个数字、一个运算符、另一个数字"。该缺陷会导致如下报错:
Error: Syntax error at line 1 col 39: Node n0_i for i in [0,15] where i == 2+1 ^ Unexpected int_literal token: "+1".因为+运算符被吸收进了+1这个 token,解析器再也找不到+运算符了。
:::
从词法层面看,根因在 Substance.ne 的 float_literal 规则:float_literal: /([+-]?([0-9]*[.])?[0-9]+)/允许数字字面量以可选的正负号开头,因此+1、-1会被词法分析器(基于 moo 的正则优先级匹配)整体吞成一个 token,抢先于二元运算符+/-被匹配。该问题已被记录在项目的问题追踪器中(issue #1516)。
规避方法:除非确实需要写-1、+3这类带符号的数,否则务必在+与-运算符两侧留出空格,写成2 + 1、n - 1等形式,即可绕开该缺陷。这也是官方文档中所有示例(如i + 1 == j)都带空格的原因。
索引语句与重复语义(Duplications)的交互
索引语句展开后生成的是若干条普通 Substance 语句,因此原有的重复语义同样适用:
- 对象重复声明是错误:同一名字的对象只能声明一次;
- 谓词(关系)重复应用是允许的:同一关系的多次声明不构成错误。
官方文档示例:
Vector v_0 Vector v_i for i in [0, 2] -- error: `v_0` 已被声明,不能重复声明 Vector v_i, v_j for i in [0, 2], j in [0, 2] -- error: `v_0` 已被声明,不能重复声明 Orthogonal(v_0, v_1) Orthogonal(v_i, v_j) for i in [0, 2], j in [0, 2] where i != j -- ok: 重复的谓词应用是允许的第一条Vector v_i for i in [0, 2]展开后包含Vector v_0,与上文手动声明的Vector v_0冲突,因此报错;而Orthogonal谓词即使展开结果与手动声明的Orthogonal(v_0, v_1)重合,也不会报错。这一语义意味着:在编写索引声明时,要留意展开结果是否会与你已声明的对象重名;而谓词类索引语句则可以放心地与其他索引语句产生"重叠"的关系声明。
访问索引集合中的单个元素
索引语句展开时,会把索引变量替换为整数值的字符串,从而生成形如v_0、v_2的标识符。这些生成后的标识符与普通标识符完全等价,只要它们真实存在(即确实被某条索引语句展开出来),就可以在后续语句中直接引用:
Vector v_i for i in [0, 2] LinearlyDependent(v_0, v_2) -- ok注意此处v_0、v_2能够被引用,前提是上文的Vector v_i for i in [0, 2]恰好覆盖了这两个下标;如果引用v_3,则会因对象未声明而报错。这一特性使得我们可以"先批量声明、后选择性使用",在构造依赖特定下标之间的关系时非常实用(例如只让首尾向量满足共线关系)。
展开的实现原理与真实用例
编译期的展开流程
在 Substance.ts 中,索引语句的展开发生在编译的类型检查阶段:
checkStmt遇到StmtSet节点时调用 checkStmtISetHelper,根据内部语句的类型分发:Decl、DeclList、Bind、DeclBind、ApplyPredicate、AutoLabel、LabelDecl、NoLabel均支持索引化;其余类型返回UnsupportedIndexingError。这意味着对象声明、绑定、谓词应用乃至 Label/AutoLabel/NoLabel 标签语句都可以带索引子句;evalISet完成范围合法性检查、笛卡尔积展开与where过滤,产出一组Map<索引变量, 数值>替换;- 对每个替换调用对应的
substISet*函数(如substISetPredicate、substISetDeclBind、substISetLabelDecl等,见 Substance.ts 第 709 行起的替换函数族),把语句中的模板标识符逐一替换为具体下标,生成独立的编译后语句。
解析层的测试用例见 SubstanceParser.test.ts,覆盖了声明索引(Set A for i in [0, 10])、多范围索引(for i in [0, 10], j in [1, 5])、绑定式索引(Set A := MakeSet(hello_j) for j in [0, 20])、带where的谓词索引(Edge(a_i, a_j) for i in [0, 20], j in [20, 30] where ...)以及标签索引(Label x_i "abcde" for i in [0, 10])。
仓库中的真实用例
packages/examples中有大量索引语句的实际运用,可以作为最佳实践参考:
- curve-examples/curve.substance:
Point p_i for i in [1, 16]声明 16 个采样点,再用In(c, p_i) for i in [1, 16]把它们批量放入曲线c; - geometric-queries/disjoint/disjoint.substance:
Line L_i for i in [1, 100]一行声明 100 条线段,配合 Style 端的约束批量布局; - fractals/chaos-game/sierpinski-triangle.substance:
Point p_i := NextPoint(p_j) for i in [1, 500], j in [0, 500] where i == j + 1通过绑定式索引语句(:=)迭代生成 500 个点,是"索引 + 多变量 + where 条件"三者组合的典型范例; - fractals/ifs/ifs.substance:
Point p_i := NextPoint(p_j, f_k) for i in [1, 500], j in [0, 500], k in [1, 2] where i == 2 * j + k演示了三个索引变量与更复杂条件表达式的用法。
结合 Domain 概述 与 Style 概述 可以理解完整的定位:索引语句只负责"声明什么对象、什么关系",具体如何绘制仍完全由 Style 程序决定。掌握了本节的展开规则、where过滤、遮蔽语义与+/-空格规避技巧,你就能在 Substance 概述 与 单条语句参考 的基础上,高效写出规模化的 Penrose 图程序。
- 开发工具
- 数据可视化
【免费下载链接】penrose
Create beautiful diagrams just by typing notation in plain text.
相关推荐
AutoX常见问题解答:新手必知的15个关键问题
AutoX常见问题解答:新手必知的15个关键问题 AutoX是一款强大的Android平台JavaScript自动化工具,无需root权限即可实现UI自动化操作
移动开发RPAOPA 数据过滤策略的 Partial Evaluation 原理详解:从 Rego 条件集到 SQL WHERE 子句
OPA 数据过滤策略的 Partial Evaluation 原理详解:从 Rego 条件集到 SQL WHERE 子句 导读 本文以 Open Policy
后端认证鉴权云原生SwiftFormat where子句格式化:约束条件样式全解析
SwiftFormat where子句格式化:约束条件样式全解析 引言:where子句的格式困境 在Swift开发中,泛型约束和条件扩展的where子句常常成为
开发工具代码质量CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考