优化弹性字符串文字解析

Optimizing flex string literal parsing

本文关键字:文字 字符串 优化      更新时间:2023-10-16

我开始为我的编程语言编写一个词法分析器。

此语言中的字符串文本以"开头,在遇到未转义的"时结束。保留内部的所有内容(包括换行符),除了转义序列(通常的ns、ts、"s 等,以及使用字符的 ASCII 代码转义字符的方法,例如9797)。

这是我到目前为止编写的代码:

%{
#include <iostream>
#define YY_DECL extern "C" int yylex()
std::string buffstr;
%}
%x SSTATE
%%
"                   {
buffstr.clear();
BEGIN(SSTATE);
}
<SSTATE>\[0-9]{1,3} {
unsigned code = atoi(yytext + 1);
if (code > 255) {
std::cerr << "SyntaxError: decimal escape sequence larger than 255 (" << code << ')' << std::endl;
exit(1);
}
buffstr += code;
}
<SSTATE>\a          buffstr += 'a';
<SSTATE>\b          buffstr += 'b';
<SSTATE>\f          buffstr += 'f';
<SSTATE>n           buffstr += 'n';
<SSTATE>r           buffstr += 'r';
<SSTATE>t           buffstr += 't';
<SSTATE>v           buffstr += 'v';
<SSTATE>\\         buffstr += '';
<SSTATE>\"         buffstr += '"';
<SSTATE>\.          {
std::cerr << "SyntaxError: invalid escape sequence (" << yytext << ')' << std::endl;
exit(1);
}
<SSTATE>"           {
std::cout << "Found a string: " << buffstr << std::endl;
BEGIN(INITIAL);
}
<SSTATE>.            buffstr += yytext[0];
.                    ;
%%
int main(int argc, char** argv) {
yylex();
}

它运行完美,但如您所见,它并没有特别优化。

它为正在解析的字符串文本中的每个字符将一个字符附加到 std::string 一次,这并不理想。

我想知道是否有更好的方法,例如存储指针并增加长度,然后使用std::string(const char* ptr, size_t lenght)构建字符串。

有吗?会是什么?

可能是提供的代码对于所有实际目的来说足够快,并且在您真正观察到它是一个瓶颈之前,您不必担心优化它。词法扫描,即使是低效的扫描,也很少对编译时间做出重要贡献。

但是,某些优化是直截了当的。

最简单的方法是观察到大多数字符串不包含转义序列。因此,应用通常的优化技术,即选择低洼的水果,我们首先在一个模式中处理没有转义序列的字符串,甚至不经过单独的词法状态。[注1]

"[^"\]*"   { yylval.str = new std::string(yytext + 1, yyleng - 2); 
return T_STRING;
}

(F)lex 提供了它找到的令牌长度yyleng,因此从来没有任何理由用strlen重新计算长度。在这种情况下,我们不希望字符串中使用双引号,因此我们选择从第二个字符开始yyleng - 2字符。

当然,我们需要处理转义码;我们可以使用类似于你的开始条件来做到这一点。只有当我们在字符串文字中找到转义字符时,我们才会进入这个开始条件。[注2]为了抓住这种情况,我们依靠 (f)lex 实现的最大咀嚼规则,即匹配时间最长的模式击败碰巧在同一输入点匹配的任何其他模式。[注3]由于我们已经匹配了任何以">开头且在结束前不包含反斜杠的标记,因此我们可以添加一个非常相似的模式,而无需结束引号,该模式仅在第一条规则不匹配的情况下匹配,因为与结束引号匹配要长一个字符。

"[^"\]*     { yylval.str = new std::string(yytext + 1, yyleng - 1);
BEGIN(S_STRING);
/* No return, so the scanner will continue in the new state */
}

S_STRING状态下,我们仍然可以匹配不包含反斜杠的序列(不仅仅是单个字符),从而大大减少动作执行和字符串附加的数量:

(起始条件中的支撑模式列表是弹性扩展。

<S_STRING>{
[^"\]+       { yylval.str->append(yytext, yyleng); }
\n           { (*yylval.str) += 'n'; }
/* Etc. Handle other escape sequences similarly */
\.           { (*yylval.str) += yytext[1]; }
\n          { /* A backslash at the end of the line. Do nothing */ }
"            { BEGIN(INITIAL); return T_STRING; }
/* See below */
}

当我们最终找到一个未转义的双引号(它将与最后一个模式匹配)时,我们首先重置词法状态,然后返回已完全构造的字符串。

该模式实际上\n与行末尾的反斜杠匹配。 通常完全忽略此反斜杠和换行符,以便允许在多个源行上继续使用长字符串。如果您不想提供此功能,只需将.模式更改为(.|n)

如果我们找不到未转义的双引号怎么办?也就是说,如果不小心省略了右双引号怎么办?在这种情况下,我们将以S_STRING开始条件结束,因为字符串没有被引号终止,因此回退模式将匹配。在S_STRING模式中,我们需要添加另外两种可能性:

<S_STRING>{
// ... As above
<<EOF>>      |
\           { /* Signal a lexical error */ }
}

这些规则中的第一个捕获简单的未终止字符串错误。第二个捕获了反斜杠后面没有合法字符的情况,给定其他规则,只有在反斜杠是具有未终止字符串的程序中的最后一个字符时,才会发生这种情况。虽然这不太可能,但它可能会发生,所以我们应该抓住它。


进一步的优化相对简单,尽管我不推荐它,因为它大多只是使代码复杂化,而且好处是微不足道的。(正是出于这个原因,我没有包含任何示例代码。

在开始条件下,反斜杠(几乎)总是导致将单个字符附加到我们正在累积的字符串中,这意味着我们可能会调整字符串的大小以执行此追加,即使我们只是调整了它的大小以附加非转义字符。相反,我们可以在操作中的字符串中添加一个与非转义字符匹配的附加字符。(由于 (f)lex 将输入缓冲区修改为 NUL 终止令牌,因此令牌后面的字符将始终是 NUL,因此将追加的长度增加 1 会将此 NUL 而不是反斜杠插入字符串。但这并不重要。

然后,处理转义字符的代码需要替换字符串中的最后一个字符,而不是将单个字符附加到字符串中,从而避免一次追加调用。当然,在我们不想插入任何内容的情况下,我们需要将字符串的大小减少一个字符,如果有一个转义序列(例如 unicode 转义)向字符串添加多个字节,我们需要做一些其他杂技。

简而言之,我认为这是一种黑客攻击,而不是优化。但就其价值而言,我过去做过这样的事情,所以我也必须承认过早优化的指控。


笔记

  1. 您的代码仅打印出令牌,这使得很难知道将字符串传递给解析器的设计是什么。我在这里假设一个或多或少的标准策略,其中语义值yylval是一个联合,其成员之一是std::string*(而不是std::string)。我不解决由此产生的内存管理问题,但%destruct声明将有很大帮助。

  2. 在此答案的原始版本中,我建议通过使用与反斜杠匹配的模式作为尾随上下文来捕获这种情况:

    "[^"\]*/\    { yylval.str = new std::string(yytext + 1, yyleng - 1);
    BEGIN(S_STRING);
    /* No return, so the scanner will continue in the new state */
    }
    

    但是使用最大咀嚼规则更简单、更通用。

  3. 如果多个模式具有相同的最长匹配项,则以扫描仪描述中的第一个模式为准。