构建可组合的有向图(扫描仪生成器的 Thompson 构造算法)

Building composable directed graphs (Thompson's construction algorithm for scanner generator)

我目前正在编写一个基于 Thompson's construction algorithm 的扫描仪生成器,以将正则表达式转换为 NFA。基本上,我需要解析一个表达式并从中创建一个有向图。我通常将我的有向图存储为邻接表,但这次,我需要能够非常有效地将现有有向图组合成一个新的有向图。我不能每次读到一个新字符时都复制我的邻接表。

我正在考虑创建一个非常轻量级的 NFA 结构,它不会拥有自己的 nodes/states。

struct Transition {
  State* next_state;
  char transition_symbol;
};

struct State {
  std::vector<Transition> transitions;
};

struct NFA {
  State* start_state;
  State* accepting_state;
};

这样我就可以简单地重新分配指针来创建新的 NFA。我的所有状态都将存储在一个中央位置(NFABuilder?)。组合将通过外部函数完成,如下所示:

NFA create_trivial_nfa(char symbol) {
  State* start_state = new State();
  State* accepting_state = new State();
  start_state->transitions.emplace_back(accepting_state, symbol);
  // Something must own start_state and accepting_state
  return NFA{start_state, accepting_state};
}

NFA concatenate_nfas(NFA&& nfa0, NFA&& nfa1) {
  nfa0.accepting_state->transitions.emplace_back(nfa1.start_state, '[=11=]');
  return NFA{nfa0.start_state, nfa1.accepting_state};
}

在这里,我将使用移动语义来明确 nfa0 和 nfa1 不再用作独立的 NFA(因为我修改了它们的内部状态)。

这种方法是否有意义,或者是否存在我尚未预料到的问题?如果确实有意义,那么所有这些状态的所有者应该是什么?我还预计我的过渡会出现填充问题。当打包在向量中时,Transition 的大小为 16 个字节而不是 9 个(在 64 位架构上)。这是我应该担心的事情,还是只是宏伟计划中的噪音? (这是我的第一个编译器。我正在关注 Engineering a Compiler, by Cooper & Torczon

Thompson构造的本质在于它创建了一个具有以下特征的NFA:

  1. 最多有2|R|个状态,其中|R|是正则表达式的长度。

  2. 每个状态要么恰好有一个标有字符的 out 转换,要么最多有两个 ε 转换。 (也就是说,没有状态同时具有标记转换和 ε 转换。)

后一个事实表明将状态表示为

struct State {
  std::vector<std::tuple<char, State*>> transitions;
}

(这是您的代码的略微缩写)是一种非常高的开销表示,其中开销与 std::vector 的开销有很大关系,该 std::vector 用于恰好保持一个或两个转换单个过渡的填充。此外,上述表示没有提供表示 ε 转换的明确技术,除非打算为 ε 保留一个字符代码(因此无法在正则表达式中使用该字符代码)。

更实际的表示可能是

enum class StateType { EPSILON, IMPORTANT };
struct State {
  StateType type;
  char      label;
  State*    next[2];
};

(假设我们可以使用标记值来指示 next[1] 不适用,该公式不会将转换数存储在 next 中。或者,我们可以在这种情况下只需设置 next[1] = next[0];。记住它只对 ε 状态有影响。)

此外,由于我们知道 NFA 中不超过 2|R| State 个对象,我们可以用小整数替换 State* 指针。这将对可以处理的正则表达式的大小设置某种限制,但是遇到千兆字节的正则表达式是非常罕见的。使用连续的整数而不是指针也会使某些图算法更易于管理,特别是对子集构造至关重要的传递闭包算法。

关于 Thompson 算法构建的 NFA 的另一个有趣的事实是,States 的入度也被限制为 2(同样,如果有两个 in-transitions,两者都是 ε transitions)。这使我们能够避免过早地创建子机的最终状态(如果子机是连接的左侧参数,则不需要)。相反,我们可以只用三个索引来表示子机:起始状态的索引,以及最多两个内部状态的索引,一旦添加,就会转换到最终状态。

我认为以上内容与 Thompson 的原始实现相当接近,尽管我确信他使用了更多的优化技巧。但值得一读 Aho、Lam、Sethi 和 Ullman ("Dragon Book") 的第 3.9 节,它描述了优化状态机构造的方法。

独立于理论归约,值得注意的是,除了关键字模式的 trie 之外,词法分析中的大多数状态转换都涉及字符集而不是单个字符,而且这些字符集通常非常大,特别是如果词法分析的单位是 Unicode 代码点而不是 ascii 字符。使用字符集而不是字符确实会使子集构建算法复杂化,但通常会显着减少状态计数。