图 (graph)
目录
简介
EnTT 并不旨在提供处理图所需的一切。因此,任何在 graph 子模块中寻找这些内容的人都会失望。
但事实恰恰相反。该子模块非常精简,仅包含开发某些工具(如 flow builder)所严格必需的数据结构和算法。
数据结构
正如简介中所预期的,目的不是提供所有可能适合表示和处理图的数据结构。随着时间的推移,可能会添加或完善许多数据结构。然而,我想劝阻任何对此主题抱有紧密排期期望的人。
本节介绍的数据结构主要用于开发和支持同属该子模块的一些工具。
邻接矩阵
邻接矩阵旨在表示有向图或无向图:
entt::adjacency_matrix<entt::directed_tag> adjacency_matrix{};
directed_tag 类型将图 创建 为有向图。还有一个 undirected_tag 对应物将其创建为无向图。
其接口与 C 语言中典型的双重索引略有不同,并提供了一个 C++ 程序员可能更熟悉的 API。因此,元素的访问和修改通过 contains、insert 和 erase 函数进行,而不是对 operator[] 进行双重调用:
if(adjacency_matrix.contains(0u, 1u)) {
adjacency_matrix.erase(0u, 1u);
} else {
adjacency_matrix.insert(0u, 1u);
}
insert 和 erase 都是 幂等 (idempotent) 函数,如果元素已存在或已被删除,则不会产生任何影响。
前者返回一个 std::pair,包含指向该元素的迭代器和一个指示该元素是否为新插入的布尔值。后者返回删除的元素数量(0 或 1)。
邻接矩阵在构造时使用元素(顶点)数量进行初始化,但以后也可以使用 resize 函数调整大小:
entt::adjacency_matrix<entt::directed_tag> adjacency_matrix{3u};
为了访问所有顶点,该类提供了一个名为 vertices 的函数,该函数返回一个适合此目的的可迭代对象:
for(auto &&vertex: adjacency_matrix.vertices()) {
// ...
}
使用以下代码段可获得相同的结果,因为顶点是普通的无符号整数值:
for(auto last = adjacency_matrix.size(), pos = {}; pos < last; ++pos) {
// ...
}
至于访问边,有几个函数可用。
当目的是访问给定邻接矩阵的所有边时,edges 函数返回一个可迭代对象,用于将它们作为顶点对获取:
for(auto [lhs, rhs]: adjacency_matrix.edges()) {
// ...
}
如果目标是访问给定顶点的所有入边 (in-edges) 或出边 (out-edges),则 in_edges 和 out_edges 函数专为此设计:
for(auto [lhs, rhs]: adjacency_matrix.out_edges(3u)) {
// ...
}
这两个函数都期望将要访问(即返回其入边或出边)的顶点作为参数。
最后,邻接矩阵是一个分配器感知 (allocator-aware) 容器,并提供人们期望从此类容器中获得的大部分功能,例如 clear 或 get_allocator 等。
Graphviz dot 语言
作为最流行的格式之一,该库提供了将图转换为 Graphviz dot 代码段的最低限度支持。
最简单的方法是将输出流和图传递给 dot 函数:
std::ostringstream output{};
entt::dot(output, adjacency_matrix);
还可以提供一个回调 (callback),将顶点传递给该回调,并可用于根据需要向输出添加 (dot) 属性:
std::ostringstream output{};
entt::dot(output, adjacency_matrix, [](auto &output, auto vertex) {
out << "label=\"v\"" << vertex << ",shape=\"box\"";
});
当用户想要将外部管理的数据关联到正在转换的图时,第二种模式特别方便。
Flow builder
Flow builder 用于从 tasks 和 resources 创建 execution graphs。
其实现尽可能通用,并且不与库的任何其他部分绑定。
该类被设计为一种 状态机 (state machine),其中附加了特定的 task,并指定了以只读或读写模式访问的 resources。
API 中的大多数函数还返回 flow builder 本身,这符合构建器类的常识 API。
一旦注册了所有 tasks 并将 resources 分配给它们,就会以邻接矩阵的形式向用户返回一个 execution graph。
该图包含以 顶点 (vertices) 形式分配给 flow builder 的所有 tasks。顶点 本身用作索引,以获取注册期间传递的标识符。
Tasks 与 resources
尽管这些术语在文档中被广泛使用,但 flow builder 并没有 tasks 和 resources 的真正概念。
该类主要使用 标识符 (identifiers),即 id_type 类型的值。换句话说,tasks 和 resources 都由整数值标识。
这允许不将类本身与库的其余部分或任何特定的数据结构耦合。另一方面,它要求用户跟踪标识符与操作或实际数据之间的关联。
一旦创建了 flow builder(不需要构造函数参数),首先要做的就是绑定一个 task。这告诉 builder 谁 打算消耗紧随其后指定的 resources:
entt::flow builder{};
builder.bind("task_1"_hs);
该示例使用 EnTT 的 hashed string 为 task 生成标识符。
事实上,使用 id_type 作为标识符类型并非偶然。事实上,它与内部的 hashed string 类非常匹配。此外,如果用户想依赖内部 RTTI 系统,它也是该系统哈希函数返回的相同类型。
然而,作为一个整数值,它让用户在必要时完全自由地依赖自己的工具。
一旦将 task 与 flow builder 关联,它也就相应地分配了只读或读写 resources:
builder
.bind("task_1"_hs)
.ro("resource_1"_hs)
.ro("resource_2"_hs)
.bind("task_2"_hs)
.rw("resource_2"_hs)
如前所述,许多函数返回 builder 本身,因此很容易连接不同的调用。
同样在 resources 的情况下,它们由 id_type 类型的数值标识。如上所述,这种选择并非完全随机。这与库提供的工具非常契合,同时留下了最大的灵活性空间。
最后,ro 和 rw 函数都提供了一个接受迭代器对的重载,以便可以一次性传递一个范围的 resources。
重新绑定 (Rebinding)
flow 类是基于 resource 的,而不是基于 task 的。这意味着图的生成是由 resources 驱动的,而不是由 flow 定义期间 tasks 的 出现 顺序驱动的。
尽管这个概念特别重要,但在绝大多数情况下它几乎无关紧要。然而,当 重新绑定 (rebinding) resources 或 tasks 时,它变得相关。
事实上,没有什么能阻止将元素重新绑定到 flow。
然而,其行为因情况而异,并且有一些值得了解的细微差别。
在不替换 task 的情况下直接重新绑定 resource,会简单地导致该 task 对该 resource 的访问模式被更新:
builder.bind("task"_hs).rw("resource"_hs).ro("resource"_hs)
在这种情况下,resource 以只读模式访问,无论第一次调用 rw 如何。
在幕后,该调用实际上并没有 替换 前一次调用,而是排队在其后,在生成图时覆盖它。因此,大量的 resource 重新绑定甚至可能影响处理时间(很难观察到,但理论上是可能的)。
重新绑定 resources 并将其与 tasks 的更改结合起来,则具有更多的影响。
如前所述,图的生成是从 resources 开始而不是从 tasks 开始的。因此,结果可能不如预期:
builder
.bind("task_1"_hs)
.ro("resource"_hs)
.bind("task_2"_hs)
.ro("resource"_hs)
.bind("task_1"_hs)
.rw("resource"_hs);
这里发生的情况是,resource 首先 看到 来自第一个 task 的只读访问请求,然后是来自第二个 task 的只读请求,最后是来自第一个 task 的读写请求。
尽管这种定义可能会被算作错误,但生成的图可能出乎意料。事实上,它包含一条从第二个 task 发出并指向第一个 task 的单边。
为了直观地理解发生的事情,只需考虑一个 task 永远不会有指向自身的边这一事实。
虽然不明显,但这种方法与其他任何解决方案一样,有其优点和缺点。例如,在基于 resource 的图生成的上下文中,创建循环实际上很简单:
builder
.bind("task_1"_hs)
.rw("resource"_hs)
.bind("task_2"_hs)
.rw("resource"_hs)
.bind("task_1"_hs)
.rw("resource"_hs);
正如预期的那样,这种定义导致创建两条边,从而在两个 tasks 之间定义一个循环。
作为一般规则,强烈建议不要重新绑定 resources 和 tasks,因为如果用户不知道自己在做什么,可能会导致微妙的 bug。
然而,一旦理解了基于 resource 的图生成机制,它就可以为专业用户提供灵活性和一系列原本无法访问的可能性。
Fake resources 与执行顺序
Flow builder 不提供指定一个 task 应该在另一个 task 之前或之后运行的能力。
事实上,resources 上的 注册 顺序也决定了在生成 execution graph 期间处理 tasks 的顺序。
然而,有一种方法可以 强制 两个进程的执行顺序。
简而言之,由于以相反的模式访问 resource 需要顺序调度而不是并行调度,因此可以利用 Fake resources 来主导执行顺序:
builder
.bind("task_1"_hs)
.ro("resource_1"_hs)
.rw("fake"_hs)
.bind("task_2"_hs)
.ro("resource_2"_hs)
.ro("fake"_hs)
.bind("task_3"_hs)
.ro("resource_2"_hs)
.ro("fake"_hs)
此代码段强制 task_1 在 task_2 和 task_3 之前 执行。这是因为前者对另一个 tasks 也想以只读模式访问的 Fake resource 设置了读写要求。
同样,可以强制一个 task 在特定组 之后 运行:
builder
.bind("task_1"_hs)
.ro("resource_1"_hs)
.ro("fake"_hs)
.bind("task_2"_hs)
.ro("resource_1"_hs)
.ro("fake"_hs)
.bind("task_3"_hs)
.ro("resource_2"_hs)
.rw("fake"_hs)
在这种情况下,由于有许多进程想要读取特定的 resource,它们将并行执行,从而强制 task_3 在所有其他 tasks 之后运行。
Sync points
有时,将 sync point(同步点)的角色分配给节点很有用。
无论它是访问新 resources 还是仅仅作为一个分水岭 (watershed),将此角色分配给顶点的过程始终相同。首先将其绑定到 flow builder,然后调用 sync 函数:
builder.bind("sync_point"_hs).sync();
为这种类型的节点分配 标识 (identity) 的选择在于,通常情况下,它们也会对 resources 执行操作。
如果不是这种情况,仍然可以创建分配了空 tasks 的 no-op(无操作)顶点。
Execution graph
一旦正确注册了 resources 及其使用者,此工具的目的就是生成一个 execution graph,该图考虑所有指定的约束,以返回顶点的最佳调度:
entt::adjacency_matrix<entt::directed_tag> graph = builder.graph();
搜索主顶点(即没有入边的顶点)通常是第一步要求:
for(auto &&vertex: graph) {
if(auto in_edges = graph.in_edges(vertex); in_edges.begin() == in_edges.end()) {
// ...
}
}
然后可以通过其他函数(例如 out_edges 以检索给定 task 的子节点,或 edges 以获取标识符)来实例化 execution graph。