最近,我一直在开发一款音乐应用程序,它需要将音乐序列(如旋律)从服务器发送到客户端。为此,我们使用一种名为ABC的格式。(您可以在此处阅读ABC音符的工作原理。)。我们选择ABC是因为它比MIDI更易读,非常简洁,而且是一个标准,所以我们不会在JSON上重新发明一些格式。
ABC是一种简单的格式,在用字符串表示音乐方面做得很好。例如,贝多芬的“Für Elise”中的一小段大家都知道:
字母对应于特定的音高,字母前面的字符代表意外音(锐音和降音),字母后面的数字代表该音符应该演奏的持续时间。ABC有更多的特性(它支持静止、系带、横梁、横线等等),但是这个简单的示例让您了解了该格式是如何工作的。
该应用程序需要将字符串转换为某种结构,以便定序器知道如何播放。简而言之,我们需要解析这个字符串。
乍一看,这个问题似乎很容易。拆分空格,并编写一些正则表达式将每个音符转换为某种值。对于笔记解析,表达式如下所示:
几乎不可能通过查看来确定这个正则表达式的作用。特别是对于ABC这样的代码,它使用标点符号表示意外(^、_和=)以及八度调整(';和,),真的很难区分哪些字符是我们试图匹配的文字字符,哪些是正则表达式控制字符,它们告诉模式匹配这个字符中的零个或多个字符,或者可选地匹配那个字符,依此类推。
这里我要承认的一件事是,尽管正则表达式令人费解,但它是您可以编写的最简洁的语言之一。
正则表达式的另一个问题是组成组件不容易组合。例如,我们上面的贝多芬序列没有任何休止符,但它们通常看起来就像一个带有z的音符,而不是音高的字母。例如,半休息可能看起来像z/2。
理想情况下,我们希望能够重用持续时间模式,但它目前被困在那个巨大的正则表达式中。为了将这些正则表达式组合在一起,我们必须首先将它们分开,然后应用一些字符串连接:
让意外模式=";(\\^+|_+|=)?";让letterPattern=";([a-Ga-G])";让octaveAdjumentPattern=";(,+|';+)?";让durationPattern=";(\\d*)(\\/)?(\\d*)";让restPattern=";NSRegularExpression(Pattern:acterentalPattern+letterPattern+octaveAdjumentPattern+durationPattern)让rest Regex=try!NSRegularExpression(Pattern:restPattern+durationPattern。
这确实是获得正则表达式可组合性的唯一方法。当然,因为这些只是我们正在连接的字符串,所以无法知道我们的连接是否表示底层正则表达式的有效转换,因为它在编译时不会被检查。
最后,即使我们成功地组合了正则表达式模式,将其编译为正则表达式,并解析了字符串,我们实际上仍然没有任何有用的东西!我们有一堆捕获组需要做手术才能得到最终结果。例如,正则表达式中的第一个捕获组给我们提供了意外字符串。这可以是多一个插入符号(^)(锐化)、多一个下划线(_)(平坦)或等号(=)(自然),但是正则表达式没有告诉我们它是哪一个,所以我们需要进一步解析它。
let numAccidilents=match.captureGroups[0].text.countlet意外字符=match.captureGroups[0].text.firstswitch意外字符{case";^";:Return.Accidental(NumAccidents)案例";_";:Return.Accidental(-numAccidilents)案例";=";:Return.Natural alcase";";:Return.Unitalcase";";:Return.Underalcase";";";:Return.UnitalCase";";
需要手动步骤将匹配的子字符串转换为我们的域对象,这有点烦人。
我们需要更仔细地研究这个问题的第一个线索是函数签名。我们解析序列的策略是消耗字符串前面的一小部分。例如,对于便条,我们的内容如下所示:
返回(T?,Substring)元组的函数看起来非常熟悉。我在Pointfree代码中看到过关于组合解析的引用:
这被称为组合解析器,因为它与计算机科学中称为组合器的一个有点晦涩难懂的部分有关。我不完全理解它们,但我会把它留给丹尼尔·斯坦伯格来分享一些非常酷的例子。
我想,如果我们无论如何都要解析文本并返回这种格式的内容,那么组合解析器可能会非常合适。因此,我对这个主题做了一些研究,并开始使用这个新策略重新实现我们的解析过程。解析器的核心是一个只有一个函数的结构:
您给它一个字符串(本例中是一个子字符串,出于性能原因),它使用缓冲区前面的字符,看看是否可以从这些字符构造一个泛型A。如果成功,则返回A。如果失败,则返回nil,确保不修改输入参数。
使用解析器组合器,可以编写简洁、类型安全、可组合的代码,与正则表达式一样有效地解析字符串。例如,下面是如何检查文字的:
静态函数文本(_string:string)->;Parser<;void>;{return Parser<;void>;(run:{input in if input.hasPrefix(String){input.removeFirst(string.count)return()}Else{return nil}})}。
如果输入具有我们要查找的字符串的前缀,请从前面删除该数量的字符并返回一个有效值(在本例中为void)。如果不匹配,则返回nil-no match。我不会在这里详细介绍所有其他的帮助程序-您可以在Pointfree示例代码中找到很多帮助程序。
使用这个帮助器和其他帮助器,您可以将意外的解析从上面重写为如下所示:
让意外解析器:parser<;意外>;=Parsers.oneOf([Parsers.Repeating(";^";).map({.意外($0.count)}),Parsers.Repeating(";_";).map({.意外(-$0.count)}),Parsers.Genal(";=";).map({.Natural}),Parsers.wen
首先,它的可读性很强。您可以看到它匹配的是什么文字字符,因为它们仍然是字符串,但是所有的正则表达式控制字符都已更改为命名函数,对于不是正则表达式专家的人来说是可以访问的。
第二,它非常容易组合。此解析器由较小的解析器组成,如.repeating或.cripal,本身也由较大的解析器组成,如用于注释、REST等的解析器。下面是笔记解析器中的意外解析器。
var noteParser:parser<;Note>;{Return Parsers.zip(acuentalParser,PitchParser,durationParser).map({意外,音调,返回说明中的持续时间(意外:意外,音调:音调,持续时间:持续时间)})}。
(zip按顺序匹配提供给它的所有解析器,如果任何解析器失败,则会失败。)。在本例中,看到所有类型如何整齐排列也是非常棒的。您可以从编译器那里得到很多保证,确保一切正常。
与正则表达式相比,它的最后一个好处是生成您期望的实际值,这是一个偶然的结果,而不是您必须进一步处理的子字符串捕获组。特别是使用map操作,您可以将简单的结果(如子字符串或void)转换为特定于域的结果(如意外或备注)。
这与regexes相比,唯一真正失去的地方是简洁性,当您添加regexes所需的转换代码时,基本上是轻而易举的。
现在,我们已经完全用纯SWIFT从头开始编写了一个解析解决方案,只使用子字符串和块等工具。当然,高度调优并用C语言编写的正则表达式应该能够在速度方面大大超过此代码?
不对。通过使用这种新方法,我们能够全面加快25%的解析速度。老实说,这让我很震惊。如果代码变得更好(假设它不是关键路径,用户不会注意到等等),我通常会稍微降低速度,但在这种情况下,这甚至不是问题,因为更好的代码也是更快的代码。双赢!我认为很多速度优势来自使用子字符串。修剪子字符串的前部实际上是自由的,这使得这些操作中的许多操作都非常快。
我还想进一步了解几个领域,比如取消解析(如果我们想将序列结构转换回ABC字符串怎么办),以及当指针位于大海捞针的任意位置时解析器是如何执行的,但是在得到这些结果后很难让我回到正则表达式。