置换解析器,不验证

2021-01-15 22:27:05

一段时间以来,“解析,不验证”一直是我最喜欢的编程文章之一。这篇文章的主旨是,当以类型驱动的方式编写时,您的贪婪口号应该是:

可以通过查看两个非常相似的函数来解释解析和验证之间的核心区别:

parseInt ::字符串->也许Int parseInt str = Text.Read.readMaybe str validateInt :: String-> Bool validateInt str = Text.Read.readMaybe str / =什么都没有

如您所见,它们看起来非常相似。主要区别在于parseInt返回一个有用的值,即我们要解析的Int,而validateInt接受了该有用的值并将其丢弃。在奇妙的Haskell迷你模式手册中也提到了“证据”模式。

此处的关键问题是,通过调用返回Bool的函数,您会丢失有关较早执行的验证的信息。相反,您可以通过对验证或结果进行显式模式匹配来保留此信息。

在本文中,我想通过一个实际的例子来展示将这个概念发挥到极致的力量。带我们去…

-拜尔(出生年)-伊尔(签发年)-年(到期年)-hgt(身高)-hcl(头发颜色)-ecl(眼睛颜色)-pid(护照ID)-cid(国家ID)

除cid字段外,所有字段都是必填字段,cid字段是可选字段。请注意,这些字段可以按任何顺序写入,这将在以后变得很重要。我们的批次由多行护照组成,并用空行(input.txt)隔开:

ecl:gry pid:860033327 eyr:2020 hcl:#fffffdbyr:1937 iyr:2017 cid:147 hgt:183cmiyr:2013 ecl:amb cid:350 eyr:2023 pid:028048884hcl:#cfa07d byr:1929hcl:#ae17e1 iyr:2013eyr :2024ecl:brn pid:760753108 byr:1931hgt:179cmhcl:#cfa07d eyr:2025 pid:166559648iyr:2011 ecl:brn hgt:59in

第三本护照很有趣:唯一缺少的字段是可选的cid,这使其有效。

第四本护照缺少两个字段,cid和byr。缺少身份证件是可以的,但缺少伯尔先生则不能,因此该护照无效。

让我们编写一些代码来打开文件并解析每组护照字段:

模块Main,在其中导入合格的Data.List.Split作为S main :: IO()main =做内容<-readFile" input.txt" let条目=映射parseEntry(S.splitOn" \ n \ n"内容)打印条目数据PassportEntry =派生(显示)parseEntry :: String->的PassportEntry PassportEntry parseEntry文本=未定义

这里没什么好想的,我们使用splitpackage中的Data.List.Split进行繁重的工作。并且parseEntry的实现已被方便地取消了代码的编译范围。

现在,我们的PassportEntry数据结构应该如何?我最终希望将护照表示为:

数据Passport = Passport {birthYear :: Int,issueYear :: Int,expirationYear :: Int,height :: String,hairColor :: String,eyeColor :: String,passportId :: String,countryId :: Maybe Int}

如果我们想象顺序地解析每个字段,那么我们将无法在单个操作中构造该数据结构。在准备好创建适当的护照之前,我们必须累积数据。

存储字段的一种方法是将它们插入哈希。首先,我们将使用自定义数据类型来表示哈希键。为什么?我们真的不希望稍后在比较" ecl"和" elc"。我们将使用Data.HashMap.Strict模块中的HashMap:

将合格的Data.HashMap.Strict导入为HM数据PassportField = BirthYear |发行年份|到期年份|身高|发色| EyeColor | PassportId | CountryId派生(Eq,Show)类型PassportEntry = HM.HashMap PassportField字符串

当然,事情不可能那么容易。我们还需要使我们的类型实现成为Hashable类型类:

{-#LANGUAGE DeriveGeneric#-}导入合格的Data.HashMap.Strict作为HM导入Data.Hashable导入GHC.Generics(Generic)数据PassportField = BirthYear |发行年份|到期年份|身高|发色| EyeColor | PassportId | CountryId派生(Eq,Show,Generic)实例Hashable PassportField类型PassportEntry = HM.HashMap PassportField字符串

不用担心我们添加的内容。只要把它们当作上帝赐予的真理。 👼

import Data.Maybe(mapMaybe)将合格的Data.Char导入为char parseEntry :: String-> PassportEntry parseEntry行= HM.fromList $ mapMaybe parseTag $ S.splitWhen Char。 isSpace行parseTag :: String->也许(PassportField,String)parseTag值= case S.splitOn":" [" byr" ,byr]->只是(BirthYear,byr)[" iyr" ,iyr]->只是(IssueYear,iyr)[" eyr" ,eyr]->只是(ExpirationYear,eyr)[" hgt" ,高度]->只是(身高,身高)[" hcl" ,颜色]->只是(HairColor,color)[" ecl" ,颜色]->只是(EyeColor,color)[" pid" ,pid]->只是(PassportId,pid)[" cid" ,cid]->只是(CountryId,cid)_->没有

我们尝试将每个字段(例如byr:2002)解析为PassportFieldtype,然后最终使用HM.fromList构建哈希。我们可以进行以下讨论:

前奏> :l Main.hs * Main> main [fromList [(CountryId," 147"),(BirthYear," 1937"),(IssueYear," 2017"),(HairColor," #fffffd"),(ExpirationYear," 2020"),(EyeColor," gry"),(Height," 183cm"),(PassportId,& #34; 860033327")],从列表[(CountryId," 350"),(出生年份," 1929"),(IssueYear," 2013") ,(HairColor,"#cfa07d"),(ExpirationYear," 2023"),(EyeColor," amb"),(PassportId," 028048884&# 34;)],fromList [(BirthYear," 1931"),(IssueYear," 2013"),(HairColor,"#ae17e1"),(ExpirationYear, " 2024"),(EyeColor," brn"),(Height," 179cm"),(PassportId," 760753108")], fromList [(IssueYear," 2011"),(HairColor,"#cfa07d"),(ExpirationYear," 2025"),(EyeColor,&#34 ; brn" ),(高度," 59in"),(PassportId," 166559648")]]]

现在,我们的目标是验证这些组中的哪一个有效。首先,我们应该定义一个必填字段列表:

main :: IO()main =做内容<-readFile" input.txt"让entry =映射parseEntry(S.splitOn" \ n \ n"内容)打印$长度$过滤器isEntryValid条目

运行此生成2,这是正确的答案!如果您觉得自己需要重新整理,这里是到目前为止我们编写的所有代码。

-拜尔(生日)-四位数;在1920年至2002年之间。-年(发行年份)-四位数;在2010年至2020年之间。-eyr(有效年)-四位数;在2020年至2030年之间。-hgt(高度)-一个数字,后跟cm或in:-如果为cm,则该数字必须介于150和193之间。-如果为cm,则该数字必须介于59和76之间。-hcl(毛发颜色)-'#'后面跟着六个字符0-9或af。-ecl(眼睛颜色)-以下之一:-pid(护照ID)-九位数字。-cid(国家ID)-被忽略,缺少或没有。

这些新要求有点烦人。我们检查所有必填字段是否存在的简单方法将不再起作用。我们可以改为执行isFieldValid函数来检查所有字段是否有效。

isFieldValid ::(PassportField,String)-> Bool isFieldValid(field,value)= BirthYear的case字段->令v =长度值== 4的inInt值。 v> = 1920&& v< = 2002 IssueYear->令v =长度值== 4的inInt值。 v> = 2010&& v< = 2020年有效期->令v =长度值== 4的inInt值。 v> = 2020&& v< = 2030高度->案例跨度Char。 (num," cm")->的isDigit值设n = toInt num in n> = 150& n< = 193(num," in")->设n = toInt num in n> = 59&& n< = 76 _->假发色-> case(length value,value)为(7,':rest)->全部(`elem` allowedHexChars)其余_->假EyeColor->值`elem` validEyeColors PassportId->长度值== 9&&所有字符。 isDigit值CountryId->所有字符。 isDigit值toInt ::字符串-> Int toInt =读取validEyeColors :: [String] validEyeColors = [" amb" ," blu" ,&nbn" ,&gry" ," grn" ," hzl" ,&oth" ] allowedHexChars :: [Char] allowedHexChars = [' 0' ..' 9' ]<> [' a' ..' f' ]

isEntryValid :: PassportEntry->布尔isEntryValid条目= requiredFieldsPresent&& allFieldsValid其中requiredFieldsPresent =所有(“ HM.member”条目)requiredFields allFieldsValid =所有isFieldValid(HM.toList条目)

在我们的第二个数据样本上运行该程序将得到1,这足以解决代码的到来挑战,并为我们赢得那些甜蜜的星星。

如果我们回顾一下代码的当前状态,可以看到我们正在做很多验证。

我们做了很多工作来验证某些东西是否有效,然后将其全部扔出窗口以返回一个微不足道的布尔。十六世纪的德国人会偷偷地告诉我们:

使用我们当前的代码,我们知道哪本护照有效,但是我们无法提取有效护照的眼睛颜色。这就是为什么我们前面提到这种Passport表示的原因:

数据Passport = Passport {birthYear :: Int,issueYear :: Int,expirationYear :: Int,height :: String,hairColor :: String,eyeColor :: String,passportId :: String,countryId :: Maybe Int}

如果我们有一个像parsePassport这样的函数,它从String变为MaybePassport,那么我们可以编写如下代码:

但是,不要让自己过分领先。让我们尝试重构当前代码以执行类似的操作。首先,我们可以尝试编写一个像这样的函数:

此函数采用一系列护照字段的中间表示形式,并返回“已验证”护照。我们还可以使用以下技巧重用isFieldValid函数:

parseField ::(PassportField,String)->也许(PassportField,String)parseField元组=如果isFieldValid元组,则只是元组,否则

我们仍然在重用验证逻辑,但是最终返回了一些有用的东西。记住,我们正在缓慢地将代码从验证数据迁移到解析数据。

使用新的帮助程序,我们最终可以实现entryToPassport函数。我们将分两个步骤进行操作。首先,我们将获得必填字段的所有值:

getAllRequiredFields :: PassportEntry->也许[String] getAllRequiredFields e =遍历(\ field-> do v<-HM.lookup field e(_field,text)<-parseField(field,v)返回text)requiredFields

遍历魔术可以确保我们将要查找的所有值包装在Just中,如果其中任何一个无效,则为Nothing。好的,我们现在就可以开始滚动!

entryToPassport :: PassportEntry->也许Passport entryToPassport entry = Just [byr,iyr,eyr,hgt,hcl,ecl,pid]的case getAllRequiredFields条目->只需$护照{birthYear = toInt byr,issueYear = toInt iyr,expirationYear = toInt eyr,height = hgt,hairColor = hcl,eyeColor = ecl,passportId = pid,countryId = toInt< $> HM。查找CountryId条目} _->没有

我们最终不得不传递字符串值,需要再次将其解析为所需的确切类型。另外,我们需要将这些值传递到列表中,并希望不要弄乱字段的顺序。 Soit远非完美,但我们正在取得进展。

为了在我们的main中使用此功能,我们从以下位置替换main函数的最后一行:

运行此ontest批处理仍会返回1,这表明我们没有做任何事情。

不过,这段代码令我特别不满意的一件事:我们使用的护照的中间表示形式没有实域值。没有人关心PassportField和PassportEntry,但是我们需要具有这些类型才能构建我们的Passport。

不仅如此,拥有这些中间类型还意味着当我们将它们转换为所需的数据类型时,有一些错误等待发生:

Shotgun解析是一种编程反模式,解析和输入验证代码与处理代码混合并分布在处理代码之间,从而在输入处抛出一堆检查,并希望在没有任何系统理由的情况下,一个或另一个将捕获所有“不良”信息案件。

亚历克西斯·金(Alexis King)在“解析,不验证”一书中继续描述了它与解析和验证之间的特定关系:

shot弹枪解析与验证有什么关系可能尚不明显,毕竟,如果您事先进行了所有验证,就可以减轻of弹枪解析的风险。问题在于,基于验证的方法使得很难或不可能确定所有事情是否都经过了预先验证,或者是否确实可能发生了一些所谓的“不可能”情况。整个计划都必须假设,不仅在任何地方都可能引发异常,而且这是经常性的。

我们将使用monsec的parsercombinator库parsec编写相同的程序。我最近接触了Parsercombinator的出色演练,我一直建议阅读。

它通过使用输入字符串中的输入字符并返回具有两个值的元组来工作:

第一个值是输入字符串的剩余值,以便其他解析器可以继续解析其余的输入。

第二个值包含解析错误或类型为a的正确解析值。

-拜尔(生日)-四位数;在1920年到2002年之间。byrParser ::解析器Int byrParser = do P.string" byr" P.char':'值<-P.count 4个P.digit P.spaces,让int =读取值保护(int> = 1920& int< = 2002)return int-iyr(Issue Year)-四位数字;在2010年至2020年之间。iyrParser :: Parser Int iyrParser = do P.string" iyr" P.char':'值<-P.count 4个P.digit P.spaces,让int =读取值保护(int> = 2010& int< = 2020)return int-eyr(有效年)-四位数字;在2020年到2030年之间。eyrParser :: Parser Int eyrParser = do P.string" eyr" P.char':'值<-P.count 4个P.digit P.spaces,让int =读取值保护(int> = 2020&& int< = 2030)返回int

在这里,我们使用保护功能引入一个断言,当不满足条件时,该断言将使解析器失败。总的来说,我觉得这段代码可读性很强,但是我们可能想提取一个可重用的帮助器来分析几年:

yearParser ::字符串-> (Int,Int)-> Parser Int yearParser值(rangeStart,rangeEnd)=做P.string值P.char':'值<-P.count 4个P.digit P.spaces,让int =读取值保护(int> = rangeStart&& int< = rangeEnd)返回int byrParser :: Parser Int byrParser = do yearParser&#34 ; byr" (1920年,2002年)iyrParser :: Parser Int iyrParser = do yearParser" iyr" (2010年,2020年)eyrParser :: Parser Int eyrParser = do yearParser" eyr" (2020,2030)

这就是编写解析器组合器的美妙之处。它们非常易于重用和组合。

现在我们要为高度字段编写一个解析器。使用更专业的数据类型来表示可能会很好:

-hgt(高度)-一个数字,后跟cm或in:–如果为cm,则该数字必须介于150和193之间。–如果为cm,则该数字必须介于59和76之间。heightParser :: Parser Height heightParser =做P.string" hgt" P.char':'位数<-P.many1 P.digit let value =读取的数字结果<-InCms _ unitParser值大小写结果_->保护(值> = 150&&值< = 193)InInches _->保护(值> = 59&&值< = 76)P.spaces返回结果

unitParser :: Int->分析器高度unitParser值=让cmParser =执行P.string" cm" return(InCms value)inParser =做P.string" in"在P.choice [cmParser,inParser]中返回(InInches值)

其余的解析器基本上是对英语要求的逐步翻译:

-hcl(发色)-a'#'随后是六个字符0-9或a-f。 hairColorParser ::解析器字符串hairColorParser = do P.string" hcl" P.char':'夏尔'#' v<-P.count 6(P.oneOf" 0123456789abcdef")P.spaces返回v

-pid(护照ID)-一个九位数的数字。 passwordIdParser ::解析器字符串passwordIdParser =做P.string" pid" P.char':' v<-P.count 9个P位数字P.spaces返回v

-cid(国家/地区ID)-已忽略,缺失或缺失。 countryIdParser ::解析器Int countryIdParser =做P.string" cid" P.char':'值<-P.many1 P.digit P.spaces返回$读取值

-ecl(眼睛颜色)-其中之一:amb blu brn gry grn hzl oth。 eyeColorParser ::解析器字符串eyeColorParser = do P.string" ecl" P.char':' v<-P.choice $ map(P.try。P.string)[" amb" ," blu" ,&nbn" ,&gry" ," grn" ," hzl" ,&oth" P.spaces返回v

您会注意到我们必须在最后一个片段中使用此神秘的P.try函数。当我们需要在输入字符串中向前看时,这非常有用。考虑一下blu和brn的示例:消费了初始b字符后,我们进入了blu分支。如果此时遇到r字符,我们意识到我们需要返回并选择brn分支。但是默认情况下,解析将停止,因为我们已经消耗了第一个字符。 P.try会这样做,因此我们的解析器不会假装使用任何输入,因此我们可以继续尝试其他替代方案。

现在,我们已经为每个字段编写了解析器。现在是时候将它们组合在一起了……

由于我们的解析器尝试一次消耗一个字符,因此我们该如何编写一个必须处理随机输入的字符呢?

此模块实现置换解析器。排列短语是元素(可能是不同类型)的序列,其中每个元素恰好出现一次且顺序无关紧要。一些置换元素可能是可选的。

permute是最后一个调用,它将包装所有内容并返回某些解析器。

< $$>用于将我们解析的所有字段分配给某些内容。在这种情况下,它将是护照。

PassportParser :: Parser Passport PassportParser =置换$ Passport< $$> byrParser< ||> iyrParser< ||> P.try eyrParser< ||> P.try heightParser< ||> P.try hairColorParser< ||> P.try eyeColorParser< ||> PassportIdParser< |?> (没什么,只要< $> countryId

......