☰
Penrose Style Collectors 深度指南:在 Style 语言中实现跨匹配聚合
2026/9/27 8:47:21 网站建设 项目流程
  • 开发工具
  • 数据可视化

【免费下载链接】penrose

Create beautiful diagrams just by typing notation in plain text.

项目地址:https://gitcode.com/gh_mirrors/pe/penrose
点击查看免费下载

Collectors(收集器)是 Penrose Style 语言中一类特殊的样式块头(header),它把多个 Substance 变量聚合成一个"集合",从而让 Style 程序能够对一组匹配做求和、求平均、计数、横向分布等聚合操作。本文以官方参考文档为基础,结合仓库中 Style 语法解析器 与 Style 编译器 的实现,以及 examples 目录 中的真实 Style 程序,系统讲解 Collectors 的语法、子句语义、分组机制、listof/numberof表达式,并给出可直接复用的实战模式。读完本文,你将能够在自己的 Penrose 图中用几行 Style 代码完成原本需要反复匹配才能实现的聚合布局。

为什么需要 Collectors:聚合的难题

在 Selectors 参考文档 中我们看到,Selector 负责把 Style 变量与 Substance 变量进行匹配,产生多个相互独立的匹配(match)。每次匹配都是独立执行的,这带来一个根本性的限制:

无法实现需要跨多个匹配进行聚合的功能。

例如,我们无法计算一个 Selector 所匹配到的所有Substance 变量的center字段之和——因为每个匹配各自为政,彼此看不到对方。

Collectors 正是为解决这类聚合需求而设计的:它引入"Substance 变量集合"这一概念,把若干 Substance 变量收集到一个命名集合中,然后在样式块体内像操作普通变量一样操作这个集合,例如求总和、算平均、取最大最小值、做均布约束等。

Collectors 语法总览

Collector 的完整语法如下:

collect <COLLECT> into <INTO> where <WHERE> with <WITH> foreach <FOREACH> { }

其中where、with、foreach三个子句都是可选的;如果某个子句内容为空,则整个子句必须省略(不能写空子句)。四个占位符的含义分别是:

  • <COLLECT>:一个对象声明(object declaration),它是被收集的对象。收集得到的对象通过<INTO>子句给出的名字在样式块体内访问。
  • <INTO>:为集合指定的名字。集合包含所有<COLLECT>匹配到的 Substance 对象;在样式块体内,<INTO>概念上代表一个Substance 对象列表。
  • <WHERE>:与标准forallSelector 中where子句含义相同,是一组分号分隔的关系约束,只有满足这些约束的匹配才会被收集。
  • <WITH>:分号分隔的对象声明列表。with中的对象不会被收集,但可以在where子句中使用。
  • <FOREACH>:分号分隔的对象声明列表。foreach中的对象同样不被收集,但它们用于把<COLLECT>收集到的对象分组。整个collect块会针对<FOREACH>子句的每一次不同匹配运行一次;foreach列表可以包含多个声明。

从语法解析器的实现可以印证这一结构:在 Style.ne 中,collector产生式枚举了collect、into、where、with、foreach五个部分的所有合法排列组合(共 20 种),并将结果构造成tag: "Collector"的 AST 节点,字段分别为repeatable、head、into、where、with、foreach。也就是说,where/with/foreach三个子句顺序可以任意调换,只要collect ... into ...在前即可。

一个完整的运行示例:按集合分组收集元素

文档给出了一个最典型的场景:Substance 程序定义了两个集合s1、s2,分别包含元素e1, e2与e3, e4, e5:

Set s1 Element e1, e2 In(e1, s1) In(e2, s1) Set s2 Element e3, e4, e5 In(e3, s2) In(e4, s2) In(e5, s2)

对应的 Collector 写法:

collect Element e into es where In(e, s) foreach Set s { ... }

在上述 Substance 程序下,这个 Collector 会运行两次:

  • 第一次:es -> [e1, e2],s -> s1
  • 第二次:es -> [e3, e4, e5],s -> s2

可以看到,全体元素[e1, e2, e3, e4, e5]被按照它们所属的Set对象拆分成了两个分组。这正是foreach子句的作用:<COLLECT>(即e)负责收集,<FOREACH>(即s)负责定义"分组的键"。

这个分组过程在编译器中有非常清晰的实现。在 Style.ts 的 collectSubsts 函数 中:

  1. 编译器先把collect、with、foreach三部分的声明合并(见 getDecls),再对 Substance 程序做整体匹配,得到一组原始替换(substitution);
  2. 对每一条替换,取出<COLLECT>对应的对象,以及foreach中每个分组变量对应的对象;
  3. 用分组变量对象集合的"唯一名字串"作为桶(bucket)的键(subObjectToUniqueName(...).join(" "));
  4. 把<COLLECT>对象放入对应桶中,最终每个桶产出一个CollectionSubst,其collContent就是该分组下的对象列表。

因此 "collect块对每个 distinct 的 foreach 匹配运行一次" 并非文档层面的空泛承诺,而是编译器用 Map 分桶实现的确定行为。

Collector 块体内的能力与限制

在 Collector 的块体内,几乎可以做 Selector Block 中能做的一切事情,唯一的例外是:只能访问<INTO>与<FOREACH>中声明的样式变量。也就是说,where与with子句中的对象只参与匹配条件的判定,不会进入块体的可见作用域。

这一点与 Selector 的差异非常关键:Selector 中forall、with声明的变量在块体内都可以访问(详见 Selectors 文档),而 Collector 刻意收窄了作用域,以换取"集合"这一抽象。实践中这意味着:如果你需要在块体内使用某个辅助对象,请把它放进foreach(并接受它会带来分组的副作用),或者依赖集合访问表达式去提取集合内对象的字段。

Repeatable 与 Non-Repeatable 匹配

Collector 运行的是与 Selector完全相同的底层匹配算法(参见 Selectors 文档的匹配算法章节)。因此默认情况下,它同样不允许两个样式变量映射到同一个 Substance 变量。

要覆盖这一默认行为,与 Selector 中一样(参见 Repeatable vs Non-Repeatable 章节),可以在collect关键字后加上repeatable关键字:

collect repeatable Set s into ss where Subset(s, a) with Set a

在 Style.ne 的语法中,("repeatable" __):?是一个可选前缀;在 Style.ts 的 getSubsts 函数 中,repeatable为true时直接返回去重后的全部替换,否则还会经过uniqueKeysAndVals过滤掉"同一组键值"的重复映射。此外,匹配算法本身带有去重机制:如果两个映射对应相同的 Substance 对象集合与相同的关系集合,则只会触发一次(参见 Selectors 的匹配去重章节)。

集合访问表达式:listof

在 Collector 块体内,<INTO>名字概念上是一个 Substance 对象列表。要访问集合中每个元素的字段,需要使用Collection Access(集合访问)表达式:

listof <FIELD NAME> from <COLLECTION NAME>

该表达式取出集合<COLLECTION NAME>中每个 Substance 变量的<FIELD NAME>字段,并将这些字段编译成一个合适的列表。例如,假设elements是<INTO>名字,概念上代表对象列表[e1, e2, ..., en],那么listof field from elements就会产出列表[e1.field, e2.field, ..., en.field]。

由于技术约束,并非任意字段类型都能被收集成列表——例如 Penrose 不支持"颜色列表",如果e1.field是颜色,就无法放入列表中。文档明确给出了允许的类型映射表:

field的类型收集成的类型
FloatV(数值)VectorV(向量)
VectorV(向量)MatrixV(矩阵)
ListV(列表)LListV(列表的列表)
TupV(2 元组)PtListV(二维点列表)
某种 shape(图形)ShapeListV(图形列表)

例如,若e1.field、e2.field等都是数值,那么listof field from elements会得到一个包含全部取值的向量,可以直接把它塞进average、sum等聚合函数。

从语法层面看,Style.ne 的 sty_var_expr 产生式 定义了"listof" _ identifier _ "from" _ path形式,生成tag: "CollectionAccess"节点。编译期求值则发生在 Style.ts 的 CollectionAccess 分支:编译器遍历集合中每个 Substance 对象,构造`唯一名`.字段的路径并在符号表中取值,最后调用collectIntoVal根据元素的实际类型组装成向量、矩阵、点列表或图形列表(对应 Style.ts 附近的类型分支逻辑,使用llistV、ptListV、shapeListV等构造器)。

集合计数:count 与 numberof

统计集合中元素的数量是一类常见需求,尤其是在计算两个对象之间的间距时。利用前面提到的listof表达式,可以这样写:

collect Object o into os { total = count(listof someprop from os) }

count函数接收一个数值向量并返回向量中元素的个数。这种方法虽然可行,但存在两个隐含假设:每个Object o都必须具有someprop字段,且该字段必须是数值。

为了简化这种写法,Collector 专门提供了numberof表达式:

numberof <COLLECTION NAME>

它直接返回<COLLECTION NAME>中的元素个数,不依赖任何具体字段。编译器对它的实现非常直接——evalNumberOf 函数 检查路径解析结果是否为集合,若是则返回substanceObjects.length作为浮点数值。

顺带一提,与numberof并列,语法解析器还支持nameof表达式(Style.ne),它返回某个 Substance 对象的名字字符串(evalNameOf 实现)。虽然官方 Collector 文档没有展开介绍它,但它在仓库示例中常用于拼接错误信息文本,例如 table.style 中的"Error: philosopher " + (nameof p) + " uses fork " + (nameof f) + " not adjacent to them"。

仓库实战案例剖析

文档之外,仓库的 examples 目录 提供了大量 Collector 的真实用法,是理解这些特性的最佳补充教材。

数组模型:numberof 统计元素个数

在 arrayModels.style 中,用numberof把每个数组的元素个数写入scalar:

collect Element e into es where inArray(e, a, z) foreach Array a { scalar a.numElements = numberof es }

这里foreach Array a让每个数组各跑一次 Collector,numberof es就是该数组的元素数量。同文件稍后(arrayModels.style)还展示了更复杂的聚合:用average(listof u from es)、sum(listof width from es)、maxList(listof v from es) - minList(listof v from es)计算数组外框的中心与尺寸,把listof的"字段提取 + 聚合函数"组合运用得淋漓尽致。

冰柱图文件系统:sum + 均布约束

在 icicle-plot-file-system.style 中,listof直接喂给约束与布局函数:

collect Entry e into es where entries(d, e) foreach Dir d { override d.width = sum(listof width from es) ensure distributeHorizontally(listof icon from es) }

目录的宽度等于其下所有条目的宽度之和(sum(listof width from es)),条目的图标则通过distributeHorizontally(listof icon from es)做水平均布——这里listof icon收集的是图形(shape),对应类型映射表中的ShapeListV。

集合土豆:分组布局与全局平衡

set-potato.style 展示了两个思路:一是按集合分组,让集合内元素垂直均布并计算集合高度:

collect Element e into es where SetHasElement(s, e) foreach Set s { points = listof icon from es ensure distributeVertically(points, Global.pointSpread) override s.icon.ry = (numberof es - 0.5) * Global.pointSpread + Global.setHeightPadding avgCenter = averagePoint(listof center from es) ensure equal(avgCenter[1], s.center[1]) }

二是不带 foreach的全局聚合——把所有集合的中心收集起来求平均,再约束整个画布的平衡:

collect Set s into ss { avgCenter = averagePoint(listof center from ss) ensure equal(norm(avgCenter - Global.box.center), 0) }

注意第二个 Collector 没有where、with、foreach子句,这正是文档所说"空子句必须省略"的合法形态;此时收集范围就是 Substance 程序中所有Set对象。

消息传递时间线:平均坐标对齐

在 timeline-message.style 中,用average(listof yCoord from ms)约束消息整体的垂直位置,与listof xCoord、foreach Time t配合完成多参与方时间线的排布。这些案例共同说明了 Collector 的典型应用模式:先按某个关键对象分组,再对组内字段做统计或均布。

常见错误与注意事项

使用 Collector 时,有几类典型的编译期错误值得注意,仓库的 Error.ts 给出了它们的精确诊断信息:

  • 对集合使用点操作符:集合不是一个普通对象,不能直接写es.somefield。错误信息会明确提示:"...是一个集合,不能使用点操作符访问。要访问集合中每个成员的字段,请使用集合访问表达式listof field from 集合名"。
  • 在需要字面值的位置使用集合:当某个上下文要求非集合的数值/字面量,而路径解析结果是一个集合时,编译器会报错,并建议改用listof ... from ...或numberof 集合名。
  • listof的类型约束:如果字段类型不在类型映射表中(例如颜色),无法编译成列表,设计时应提前规划好被收集字段的类型。
  • 作用域限制:Collector 块体内只能访问<INTO>与<FOREACH>中的样式变量,where/with中的变量不可见,误用会得到未定义变量错误。

总结

Collectors 是 Penrose Style 语言中实现"跨匹配聚合"的核心机制,其设计可以概括为四步:collect声明被收集对象,into给它一个集合名,where/with约束参与匹配的对象,foreach决定分组方式;随后在块体内用listof ... from ...提取字段、用numberof计数,配合sum、average、minList、maxList、distributeHorizontally/distributeVertically等函数完成统计与布局。

本文所引用的语法与实现细节均来自仓库源码:Style 语法文件、Style 编译器、Style 编译测试(其中 Style.test.ts 直接验证了numberof ts与count(listof value from ts)的求值结果),以及 examples 中的多个真实 Style 程序。若想进一步掌握 Selector 与 Collector 共用的匹配算法,建议继续阅读 Selectors 参考文档 与 Substance 语言总览。

  • 开发工具
  • 数据可视化

【免费下载链接】penrose

Create beautiful diagrams just by typing notation in plain text.

项目地址:https://gitcode.com/gh_mirrors/pe/penrose
点击查看免费下载
上一篇:7种截图模式:Ksnip跨平台截图工具终极指南
下一篇:10分钟掌握node-fetch断点续传:基于Range请求头的终极文件下载指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询