别担心,我们不会直接处理任何数据作为程序员,我们显然希望自动化此类任务,而我们以电子表格形式处理数据的最简单方法是CSV文件
在本章中,我们将介绍如何解析此类文件,从中提取有意义的数据,以及如何有效地处理结构化数据我们将学习如何泛化数据的追加和切片,以及如何将类型类引入组合以帮助我们编写程序本章将首先介绍 CSV 文件以及如何使用 Haskells 记录语法对它们进行建模我们将学习如何使用 Ever 类型对错误进行编码我们将介绍如何使用美元运算符和某些语言扩展来简化我们的语法在此之后,我们将创建自己的类型类来概括数据类型的用例同时我们将学习类型类 半群和 Monoid ,它们代表什么以及它们的用途本章将继续展示非常重要的概念,例如 Functor 类型类和折叠,以使您熟悉核心函数式编程概念我们通过将所有这些新学到的技能集中在一个简短的编程中来结束本章,让我们的库投入使用最后,我们还将创建的二进制文件安装到本地计算机5.1 表格数据建模在本章中,我们将介绍如何使用终端中的 CSV 文件虽然这些文件对于人类来说并不难解析,但一旦它们变大,使用起来可能会非常麻烦这通常需要我们默认使用一些带有图形用户界面的工具来处理数据我们可以做得更好
我们的工具将具有许多显示和转换此表格数据的功能这些功能包括:读取 CSV 文件并将其打印为 ASCII 表支持带和不带标头的文件附加两个 CSV 文件按搜索词筛选表中的行将表格切割成特定列范围的可能性计算每列的非空行数并在打印表中查看结果或者,将转换的结果写入 CSV 格式的文件举个小例子,让我们看看如果我们的程序要读取文件、搜索字符串并切片一些列会是什么样子
shell $ head -n 4 cities.csv"LatD","LatM","LatS","NS","LonD","LonM","LonS","EW","City","State" 41, 5, 59, "N", 80, 39, 0, "W", "Youngstown", OH 42, 52, 48, "N", 97, 23, 23, "W", "Yankton", SD 46, 35, 59, "N", 120, 30, 36, "W", "Yakima", WA[...]shell $ csview --in=cities.csv --with-header --search=Ya --slice="8,10"-------------------| City | State |+---------+-------+| Yankton | SD || Yakima | WA |-------------------
在我们开始构建这样的工具之前,我们必须了解这种文件格式CSV(逗号分隔值)文件是包含数据记录行的简单文本文件这些记录包含以逗号分隔的值因此,CSV 文件包含逗号分隔值行此外,第一行可以(可选)被视为标题,为列提供标题听起来很简单,不是吗?可悲的是,尽管文件格式在某种程度上是一成不变的(RFC 4180给出了它的规范),但似乎每个实现都使用自己的规则来构建CSV文件(我们也会常见的一般约束是 CSV 文件是使用通用字符编码(UTF-8、ASCII 等)的纯文本每行包含一条以换行符分隔的记录在整个文件中使用相同的分隔符将记录划分为字段(不必是逗号)每条记录的字段数量相同可能包含可选的标题行可以在使用双引号的字段中使用引号因此,用于检查和分析此类文件中数据的工具必须具有一些选项来处理解析其内容的多种不同方法让我们讨论一下应用程序的目标以及它应该做什么我们想要一个读取 CSV 文件并以表格形式打印它们的应用程序此外,我们想要搜索值并计算数据的统计信息工作流将如下所示:读取参数并推断解析选项和请求的功能将 CSV 文件解析为数据结构对数据执行搜索和/或计算统计信息将数据写回文件可选择以人类可读的形式打印回信息在我们担心从命令行读取参数之前,我们应该首先定义如何在程序中表示 CSV 文件,然后讨论解析
5.1.1 打破记录与往常一样,我们首先创建一个新项目 堆栈新csview .正如我们所讨论的,CSV文件包含文本在这个项目中,我们希望摆脱使用 String 类型,并使用更常见的方法来处理 Haskell 中的文本Text 类型,它是性能更好的字符串打包表示形式为了使用这种类型,我们首先必须将文本包合并到我们的项目中我们编辑 package.yaml 文件中的依赖项部分以包含此包当我们使用它时,我们还可以更改可执行文件的名称并删除 -exe 后缀稍后,我们想在我们的机器上本地安装此应用程序,以便可能需要一个更干净的名称
dependencies:- base >= 4.7 && < 5- text...executables: csview: main: Main.hs ...
文本数据类型可以从 Data.Text 模块导入,该模块包含大量处理文本的函数,因为我们无法再使用 Data.List 中的函数为了执行从字符串到文本的转换,我们可以使用打包和解包功能:ghci> import Data.Textghci> myString = "Hello Text"ghci> :t pack myStringpack myString :: Textghci> :t unpack (pack myString)unpack (pack myString) :: String
该模块包含许多函数,这些函数取代了我们通常从 Data.List 模块获得的功能,例如 null 或 长度 .由于模式匹配不适用于文本,因此我们必须使用函数uncons将文本分解为字符为了简洁起见,阅读一些模块文档留给读者,以便更好地了解数据类型现在,我们都准备好考虑在我们的程序中对CSV文件进行建模由于通常不键入CSV文件,因此无论内容如何,都可以将其中的值视为文本但是,我们希望假设CSV文件包含任意类型的信息,以便我们以后可以在“类型化”CSV表上构造算法此类文件中的列以及可选标头可以用简单列表表示因此,我们可以创建一个这样的类型:
type Column a = [a]type Csv a = (Maybe [Text], [Column a])
在这种类型中,元组的第一个元素是可选的(由 May 表示)标头,第二个元素表示我们文件的列,每列都是一个字段列表基于这个定义,我们可以定义函数来从中检索值:header :: Csv a -> Maybe [Text]header = fstcolumns :: Csv a -> [Column a]columns = snd
我们之前在第4章中看到过snd回顾一下,fst 检索元组的第一个元素,snd 检索元组的第二个元素这些定义看起来不错,但我们可以做得更好我们在这里面临一些问题:类型是类型同义词,因此我们无法通过模块导出列表隐藏其构造每次扩展类型时,都需要更改与类型关联的函数或构造更多函数如果代码中没有注释,则不清楚元组中的字段代表什么幸运的是,我们可以通过使用 Haskells 记录语法来解决这些问题它使我们能够为数据构造函数的字段命名让我们看看这次通过正确定义类型来了解实际语法,并在此过程中派生 Show 的实例具有此类型的新模块的代码如清单 5.1 所示清单 5.1.使用记录语法的 CSV 文件内容的数据类型
module Csv whereimport qualified Data.Text as T #1type Column a = [a]data Csv a = Csv #2 { header :: Maybe [T.Text], #3 columns :: [Column a] } deriving (Show) #4
我们为 Data.Text 创建了一个限定导入,因为它具有许多函数,其名称将与前奏导入冲突如我们所见,记录语法允许我们按名称识别数据构造函数的字段这还有一个额外的效果字段的名称用作函数,从数据类型中检索所述字段
ghci> :{ghci| data Record = Recordghci| { field1 :: Int,ghci| field2 :: Stringghci| }ghci| :}ghci> :t field1field1 :: Record -> Intghci> :t field2field2 :: Record -> Stringghci> field1 (Record {field1 = 1, field2 = ""})1
这意味着,如果我们要通过一个新字段扩展此数据类型,我们将自动添加一个用于检索该字段的新功能此外,我们的类型现在与最小版本的文档捆绑在一起,因为名称表示字段的用途最后,由于我们现在有一个类型的构造函数,我们可以将其从模块导出中排除(类似于我们在第 4 章中为 AssocMap 类型所做的那样),以确保不能使用无效数据构造值注意由于字段名称创建函数,因此名称可能会发生冲突
当使用具有相同字段名称的记录时,我们在将它们导入模块时必须非常小心标准做法是用一些标识缩写作为字段名称的前缀,例如类型名称的首字母缩略词(使用 csvHeader 或 csv_header 而不是 header)当使用记录语法构造值时,我们可以像前面的例子一样专门命名字段及其值,或者我们可以简单地将这些值写下为构造函数的字段,顺序与我们要填充的字段相同
ghci> data Record = Record {field1 :: Int, field2 :: String} deriving Showghci> Record {field1 = 100, field2 = "Hello"}Record {field1 = 100, field2 = "Hello"}ghci> Record 100 "Hello"Record {field1 = 100, field2 = "Hello"}
当字段仅部分提供时,我们看到的行为与我们已经知道的代数数据类型的构造函数类似它们被部分应用并计算为一个函数,该函数在提供所有缺失的字段后计算为记录类型ghci> :t RecordRecord :: Int -> String -> Recordghci> :t Record 100Record 100 :: String -> Record
请务必注意,不支持同时使用未命名字段和命名字段构造数据类型ghci> Record 100 {field2 = "Hello"}<interactive>:25:8: error: • Couldn't match expected type ‘Int’ with actual type ‘Record’ • In the first argument of ‘Record’, namely ‘100 {field2 = "Hello"}’ In the expression: Record 100 {field2 = "Hello"} In an equation for ‘it’: it = Record 100 {field2 = "Hello"}
还可以通过显式命名字段并为现有记录指定字段值来修改记录ghci> :{ghci| f :: Record -> Recordghci| f rec = rec {field1 = 0}ghci| :}ghci> f $ Record 100 "Hello"Record {field1 = 0, field2 = "Hello"}
我们在这里没有明确介绍的是,记录语法可以与多个构造函数结合使用,为每个构造函数使用不同的字段但是,它的工作方式类似于具有单个构造函数的记录接下来,我们想看看这些构造函数,以及如何确保只用它们构造合理的数据5.1.2 智能结构有时,我们希望为类型指定类型本身无法确保的属性在我们的例子中,标题中的字段数需要等于列数,并且每列也需要具有相同的大小在第 4 章中,在使用 AssocMap 类型时,我们完全不允许直接构造该类型的值,并且只能通过我们导出的某些函数来实现但是,对于CSV情况,我们希望允许这样做,但我们希望进行某种错误检查这确保了我们不必在每个处理它的函数中检查此类型的不变量这就把我们带到了智能构造函数的主题我们可以通过只允许 Csv 值由专用函数构建来确保这些属性,该函数检查参数的有效性如果出现问题,此功能通常会简单地使程序崩溃,虽然我们也会这样做,但我们也想要一个安全的版本该函数应该是什么样子的?safeMkCsv :: Maybe [T.Text] -> [Column a] -> ???
也许 Csv 似乎是一个明智的选择,但只是默默地失败而不让这个函数的用户知道出了什么问题,这是不礼貌的我们想要的是一个返回特定错误或实际 Csv 值的类型其类型可能如下所示:data ErrorOrCsv a = Error String | Value (Csv a)
幸运的是,已经存在一种非常相似的类型,它具有称为 要么 data Either a b = Left a | Right b
此类型可以以多种方式使用,但它最常用于编码错误的可能性在这种情况下,“右”编码正确的值,“左”编码错误值因此,如果我们想有一个错误消息或一个值,我们可以像这样编码它:type ErrorOrValue a = Either String a
正是这种类型,我们可以将其用于我们的功能我们可以快速检查标题的长度是否与输入中的列数匹配此外,我们必须检查每列是否具有相同的长度我们可以通过使用 Data.List 模块中的 nubBy 函数来做到这一点它具有与 nub 相同的功能,但接收一个二进制函数作为谓词,用于确定列表中的两个值何时相等如果此函数的结果长度小于或等于 5,我们知道所有列都有相同数量的元素如清单 2.<> 所示清单 5.2.在从参数构造 CSV 类型之前检查参数是否有效的函数
safeMkCsv :: Maybe [T.Text] -> [Column a] -> Either String (Csv a)safeMkCsv mHeader columns | not headerSizeCorrect = #1 Left "Size of header row does not fit number of columns" | not columnSizesCorrect = #2 Left "The columns do not have equal sizes" | otherwise = Right Csv {header=mHeader, columns=columns} #3 where headerSizeCorrect = #4 M.maybe True (\h -> L.length h == L.length columns) mHeader columnSizesCorrect = #5 L.length (L.nubBy (\x y -> length x == length y) columns) <= 1
这里我们假设 M 是 Data 的合格导入在本书的其余部分,我们将假设我们的代码有一些合格的导入T 代表 Data.Text , L 代表 Data.List, M 代表 Data.May, E 代表 data.both回到代码:我们称这个函数为“安全”,因为如果参数无效,它不会使我们的程序崩溃就像 也许我们对类型中出错的可能性进行编码一样但是,此函数要求我们在每次使用该函数时都与类型进行模式匹配,即使我们已经确保满足不变量为了使我们的生活更轻松,我们创建了此函数的“不安全”版本,如果参数不是我们期望的那样,它就会崩溃注意有时“safe”函数的名称中没有特殊的前缀,而“不安全”功能实际上被称为unsafeName,以表示使用它的严重危险为此,我们使用引发异常的 error 函数:ghci> :t errorerror :: [Char] -> aghci> error "Oh no!" Exception: Oh no!CallStack (from HasCallStack): error, called at <interactive>:... in interactive:Ghci1
就像 也许 类型具有自己的 Data.也许 模块和 也许 快速使用它的功能一样,要么 :ghci> import Data.Eitherghci> :t eithereither :: (a -> c) -> (b -> c) -> Either a b -> c
任一函数接收两个函数,一个用于左情况,一个用于右情况,以最终生成某个常见类型的值在我们的例子中,我们可以通过使用左情况的错误函数和右情况的 id 函数来构造 mkCsv,它只是返回它的参数生成的单行代码如清单 5.3 所示清单 5.3.一个不安全的函数,如果 CSV 值的参数无效,则会崩溃
mkCsv :: Maybe [T.Text] -> [Column a] -> Csv amkCsv header columns = E.either error id $ safeMkCsv header columns #1
现在我们可以测试我们的智能构造函数:ghci> safeMkCsv (Just ["First", "Second"]) [[1,2], [3,4]]Right (Csv {header = Just ["First","Second"], columns = [[1,2],[3,4]]})ghci> safeMkCsv (Just ["First", "Second"]) [[1,2], [3,4,5]]Left "The columns do not have equal sizes"ghci> safeMkCsv (Just ["First"]) [[1,2], [3,4]]Left "Size of header row does not fit number of columns"ghci> mkCsv Nothing [[1,2], [3,4,5]] Exception: The columns do not have equal sizes
但是我们应该使用哪个构造函数呢?mkCsv 固有的问题在于它会产生一个异常(如果未捕获)导致程序崩溃此外,从其类型签名中无法立即看出它是否这样做,因此其他人可能会在不知道它可能造成什么破坏的情况下使用它5.1.3 美元,美元符号,你们mkCsv 函数具有一个我们迄今为止尚未见过的新运算符 $ 这是一个复杂性不能小,但优雅的运算符更加伟大它的完整定义如清单 5.4 所示清单 5.4.哈斯克尔美元运营商($) :: (a -> b) -> a -> b($) f x = f x
它只是将函数应用于值它可以用来很好地编写函数应用程序,而无需在其最后一个参数上使用括号:
ghci> either (+1) (2) (Right 100)200ghci> either (+1) (2) $ Right 100200
当参数变成大项时,这非常有用:someFunc ... = someOtherFunc ... $ anotherFunc ... $ yetAnotherFunc ... $ andYetAnotherFunc ...
然而,这还不是故事的全部:ghci> :i ($)($) :: (a -> b) -> a -> binfixr 0 $
当获得有关此运算符的更多信息时,我们看到一个特殊的注释: 中缀 0 $ .这告诉我们,这个运算符是用中缀样式编写的,具有正确的关联性,附加优先级为 0右关联性意味着如果有多个具有相同优先级的运算符,则操作从右侧分组优先级 0 指定其他运算符的优先级高于 $(除非它们的优先级也为 0)为什么这有帮助?ghci> :i (++)(++) :: [a] -> [a] -> [a]infixr 5 ++ghci> map (10) [1..5] ++ [6..10][10,20,30,40,50,6,7,8,9,10]ghci> map (10) $ [1..5] ++ [6..10][10,20,30,40,50,60,70,80,90,100]
在这里我们看到,由于追加运算符的优先级为 5,因此它的绑定力比 $ 强因此,在此示例中,在将最终结果传递给 map 函数的调用之前,会完成其评估注意$在Haskell代码中无处不在这就是为什么熟悉它的复杂性和用法很重要的原因但是,使用它肯定不是必须的如果你觉得使用括号更舒服,你应该坚持使用它们在本书的过程中,我们将多次遇到这个运算符如果看起来令人困惑,请记住您可以随时用括号重写术语现在我们可以构建 CSV 值,并为外部提供一个安全的接口,保证不会产生错误的值
5.2 类对类接下来,我们要构造函数以更好地使用我们的 CSV 表,即确定列数和行数的函数使用记录语法可能会变得有些复杂,因此我们想看一些语言扩展,这些扩展使使用它们更容易一些处理记录的默认方式迫使我们写下要访问的字段并为其命名
f Csv {header=h, columns=c} = ...
我们可以省略不需要的字段f Csv {columns=c} = ...
但是,如果我们不在乎给一个字段一个新名字呢?f Csv {columns=columns} = ...
很明显,省略给这个字段一个多余的名称是有意义的使用 NamedFieldPuns 语言扩展,我们可以做到这一点:f Csv {columns} = ...
这仍然要求我们列出要使用的所有字段一旦我们使用具有大量字段的记录,这就会变得笨拙但是,我们可以使用 RecordWildCard 语言扩展使所有字段都可用其名称:f Csv {..} = ...
现在可以快速访问记录中的字段,而无需写下来这使我们能够构造函数来计算行数和列数为了做到这一点,我们可以假设所有列都有相同数量的元素(因为这是我们类型的不变性)因此,从列中计算行数是微不足道的确定列数需要我们要么取标题元素的数量,要么(如果它没有)列的数量我们可以使用 may 函数轻松做到这一点如清单 5.5 所示清单 5.5.用于计算 CSV 文件中的行数和列数的函数
numberOfRows :: Csv a -> IntnumberOfRows Csv {..} = #1 case columns of [] -> 0 #2 (x : _) -> length x #3numberOfColumns :: Csv a -> IntnumberOfColumns Csv {..} = M.maybe (length columns) length header #4
现在我们有一堆函数来构建和描述我们的 CSV 表,我们需要考虑转换它们我们希望能够组合CSV表,将它们切割并计算它们的统计信息让我们从组合 CSV 表开始5.2.1 我可以默认吗?当组合两个CSV表时,我们要考虑添加两个完全不同的表,这意味着它们的行数可能不同,一个可能有标题,而另一个没有如图5.1所示图 5.1.追加两个数据表时的不同方案这给我们带来了挑战,因为我们需要能够用空值填充表对于标头,这很简单,因为我们可以简单地将它们留空但是,在考虑用任意类型的空值填充表时,这并不容易由于我们的表是 Csv a 类型,它们可以是任何东西因此,我们需要为要与 CSV 文件一起使用的类型定义默认值出于我们的目的,我们将自己限制为 Int 和简单文本值形式的数字为了定义这些值并在以后正确限制我们的追加函数的类型,我们可以为此定义一个类型类
定义类型类很简单它以类的简单名称开头:
class HasDefault a where
HasDefault 是我们类的名称,a 是我们现在为其定义类型表达式的类型变量此类型变量在类中具有词法作用域,因此如果类型包含变量 a,则它与将为其创建此类的后续实例的类型相关我们的类的目的只是给我们一个简单的默认值,所以我们只定义一个类型的常量默认值,它将为每个实例唯一定义然后我们可以 为Int 和 文本 .当然,我们选择的值对我们的目的有意义,而不是定义通常适用于所有应用程序的类型类对于我们的 CSV 文件,我们将选择 0 作为默认的 Int 值和一个空字符串作为我们的文本值类的实例声明与类本身类似其代码如清单 5.6 所示清单 5.6.键入默认值的类和实例class HasDefault a where #1 defaultValue :: ainstance HasDefault Int where #2 defaultValue = 0instance HasDefault T.Text where #3 defaultValue = ""
但是等一下:为什么我们能够使用简单字符串作为 Text 值的空字符串?这是由于重载字符串语言扩展启用此扩展后,我们可以将值写为字符串,稍后使用 IsString 类型类中的 fromString 方法将其转换为实际类型
class IsString a where fromString :: String -> a
方便文本具有所述类的实例其他类型,如 ByteString 也可以与此扩展一起使用注意与 OverloadString 和 IsString 类似,存在 OverloadLists 扩展和 IsList 类型类它使我们能够写下列表并将其转换为其他类型这在定义类似于列表但语法繁琐的数据类型时非常有用由于此扩展实际上不会伤害任何人,并且在使用这些类型构建项目时非常有用,因此我们希望全局启用此扩展我们可以在项目的 package.yaml 文件中执行此操作在那里,我们可以添加一个默认扩展列表,其中列出了所有已启用的扩展当我们这样做时,我们还可以添加前面提到的RecordWildCards和NamedFieldPuns扩展,因为它们也是普遍有用的:default-extensions: - OverloadedStrings - RecordWildCards - NamedFieldPuns
这将为我们项目中的每个模块(不包括外部库)启用这些扩展最好添加扩展以增加语言的语法和功能,而不是像 ScopedTypeVariables 那样从根本上改变语言工作方式的扩展5.2.2 把东西拼凑在一起现在我们已经有序地完成了我们的项目,我们可以开始附加 Csv 值了我们的函数将通过检查其中一个值是否有标题来组合标题如果没有,新值也不会有标头否则,任何缺少的标头都将被拟合长度的空字符串列表所取代通过用默认值填充较短的列来追加列这段代码如清单 5.7 所示示例 5.7.附加两个 CSV 值的功能appendCsv :: (HasDefault a) => Csv a -> Csv a -> Csv a #1appendCsv a b = Csv { header = if M.isNothing (header a) && M.isNothing (header b) then Nothing #2 else Just $ header' a ++ header' b, #3 columns = appendColumns (columns a) (columns b) } where header' csv = #4 M.fromMaybe (L.replicate (numberOfColumns csv) "") (header csv) appendColumns colsA colsB = map (\cols -> cols ++ fillA) colsA #5 ++ map (\cols -> cols ++ fillB) colsB where fillA = replicate (numberOfRows b - numberOfRows a) defaultValue fillB = replicate (numberOfRows a - numberOfRows b) defaultValue
我们可以立即看到该算法仅有效,因为我们假设所有列都具有相同的大小否则,我们需要检查最大列大小并将所有列填充到该大小锻炼appendCsv 在不存在任何值时发明默认值或者,我们可以简单地从较短的 CSV 文件中截取行实现此替代追加操作观察这些策略如何类似于我们在数据库中发现的外部和内部连接但是,我们不会在某个值或谓词上联接 CSV 文件,而只是盲目地附加列编写对 csv 类型执行联接的函数此函数应接收一些布尔谓词作为参数,用于决定是否应连接行我们可以观察到的其他东西是这个函数的属性当我们想要附加三个 Csv 值时,我们可以这样做:appendCsv a (appendCsv b c) 但是,顺序不一定重要我们也可以这样做: appendCsv (appendCsv a b) c 这个属性称为结合性,是一个基本的数学概念它让我们不考虑操作的顺序
这个属性非常重要,以至于Haskell有自己的类型类,专用于具有这种关联操作的类型:半组此类型类最重要的操作是 <> 它是这种类型的二进制关联操作按照惯例,它应该是一个关联函数,这意味着 <> (b <> c) == (a <> b) <> c 对于该类型的所有值 a、b 和 c但是,编译器不会检查或证明此属性您自己必须确保属性正确为什么这很重要?假设您有一个并行评估的大型数据结构,如列表为了节省内存,您希望在元素完成评估后立即组合元素,但您无法保证它们何时完成如果数据结构中的类型值具有 Semigroup 类型类的实例,则没关系
因此,我们函数的类型可能如下所示:
parallelCombine :: Semigroup a => [a] -> a
对于结果的编译,重要的是以正确的顺序添加它们,以便评估最终结果的项中的任何 a <> b 都不能更改为 b <> a 半群类型类的扩展是 Monoid 类型类:清单 5.8.单体的类签名class Semigroup a => Monoid a where mempty :: a #1 mappend :: a -> a -> a #2 mconcat :: [a] -> a #3 {-# MINIMAL mempty #-}
mappend 与 <> 是相同的函数,默认情况下它的定义是 mappend = (<>) 然而,Monoid 也具有一个名为 mempty 的常量,它是关于 mappend 的中性元素,这意味着属性 mappend mempty a == a 和 mappend a mempty == a holdMcOncat看起来非常可疑,就像我们以前的组合函数一样让我们看看 Monoid 的实际应用:ghci> [1,2,3] <> mempty <> [4,5,6][1,2,3,4,5,6]ghci> mconcat [[1,2,3], mempty, [4,5,6]][1,2,3,4,5,6]
嗯,这看起来是不是很熟悉?列表有一个 Semigroup 和 Monoid 的实例,它们的定义再清楚不过了:instance Semigroup [a] where (<>) = (++)instance Monoid [a] where mempty = [] mconcat xss = [x | xs <- xss, x <- xs]
这也意味着我们可以以这种方式使用字符串ghci> hw = "Hello" <> " " <> "World"ghci> hw"Hello World"ghci> :t hwhw :: (Semigroup a, Data.String.IsString a) => aghci>
事实上,它适用于存在 Semigroup 和 IsString 实例的所有类型字符串和文本也是如此
这就是为什么在许多代码库中,您经常看到字符串值被追加<>而不是 ++重要虽然目前 mappend 的实现方式可能与<>不同,但在您的实现中永远不应该如此
将来,mappend 将从此类型类中完全删除,并替换为 <> 但是,有些类型我们无法定义半群或幺半群,因为存在多个关联函数
Int 就是一个很好的例子,我们可以在定义中使用加法和乘法但是,不存在规范的选择这就是为什么存在 Sum 和 Product 类型的原因,它们包装 Int 并为类型类的实例选择适当的函数现在我们了解了这些类型类,为什么不为这两个类型实现实例呢?半群的二元关联函数已经由 appendCsv 给出我们唯一需要的拼图是 mempty 值,一个 appendCsv 的中性值 .哪个 Csv 值在追加后不会更改另一个值?当然只有一个空的 Csv 值
空是指没有标题和列的 Csv同样重要的是,我们的 appendCsv 有一个类型约束,该约束也需要由类实例满足其代码如示例 5.9 所示清单 5.9.Csv 类型的半群和幺半群实例
instance (HasDefault a) => Semigroup (Csv a) where (<>) = appendCsv #1instance (HasDefault a) => Monoid (Csv a) where mempty = Csv {header = Nothing, columns = []} #2
我们现在还可以免费使用 mconcat 函数来连接 Csv 值列表锻炼我们对 Csv 实例的定义是正确的,但我们怎么能确定呢?为此,我们需要查看 appendCsv 函数,特别是我们需要检查数据是如何附加的为什么我们对mempty的定义是正确的?试着弄清楚为什么这些类型类的法律成立
合并多个CSV表很好,但是将它们切开呢?为此,我们想推广一种切割数据结构的方法5.3 切片和切丁当像 Semigroup 这样的类型类可用于组合数据时,在定义如何切割数据时,类型类可能会派上用场实际上,我们希望提取表的一部分,以将它们作为新表使用这可以扩展到其他数据结构,例如列表为此,我们想问一个问题:我们可以概括切割数据结构吗?在对数据进行切片时,我们要在数据结构中选择一定的范围我们将假设它是可索引的然后,只需指定两个索引即可定义范围执行的操作是分为三个部分但是,只有中间部分最令人感兴趣现在,我们可以考虑要通过类型类公开的方法一个必不可少的方法是一个切片方法,它允许我们根据两个索引剪切数据结构的一部分但是,由于分区免费为我们提供了我们感兴趣的切片,我们还应该有一个切片分区方法
此外,我们已经可以从 slicePartition 方法实现 slice 方法
此类的实现如清单 5.10 所示清单 5.10.可切片数据的类定义
class Sliceable a where slice :: Int -> Int -> a -> a #1 slice idx1 idx2 xs = #2 let (_, s, _) = slicePartition idx1 idx2 xs in s slicePartition :: Int -> Int -> a -> (a, a, a) #3
在此代码中,我们可以看到如何将默认行为添加到方法中在一个类中,声明的方法对于其他定义是已知的我们为slice定义的实现是当实例中未显式定义时自动推断的默认实现我们将在新的 src/Data 目录中创建一个名为 Data.Sliceable 的新模块,该模块将包含可切片数据所需的所有代码当用GHCi检查这个定义时,我们发现:ghci> :i Sliceabletype Sliceable :: -> Constraintclass Sliceable a where slice :: Int -> Int -> a -> a slicePartition :: Int -> Int -> a -> (a, a, a) {-# MINIMAL slicePartition #-}
就像许多其他类一样,我们看到一个只包含我们的slicePartition方法的最小定义,因为slice可以从中推断出来我们现在可以使用 take 和 drop 函数为列表构造一个实例,该函数要么从列表中获取一定数量的元素返回它,要么从列表中删除一定数量的元素并返回其余元素从一个索引到另一个索引的列表“切片”是一个列表,其中包含从原始列表上的第一个索引开始到第二个索引结束的元素,不包括该索引中的元素这给我们留下了一个列表,其长度是两个指数的差值这段代码如示例 5.11 所示清单 5.11.列表的可切片类型类的实例
instance Sliceable [a] where slicePartition idx1 idx2 xs = #1 ( take idx1 xs, #2 take (idx2 - idx1) $ drop idx1 xs, #3 drop idx2 xs #4 )
这个定义现在会自动产生切片函数ghci> slicePartition 2 8 [0..9]([0,1],[2,3,4,5,6,7],[8,9])ghci> slice 2 8 [0..9][2,3,4,5,6,7]
此外,我们可以为 Maybe 找到此类的另一个实例如果其中的类型是可切片的,那么我们可以切片这种类型的 May要么分区的部分放在 Just 中,要么每个分区都是 Nothing.该实例在清单 5.12 中定义示例 5.12.也许的可切片类型类的实例instance Sliceable a => Sliceable (Maybe a) where #1 slicePartition idx1 idx2 Nothing = (Nothing, Nothing, Nothing) #2 slicePartition idx1 idx2 (Just xs) = let (hd, s, tl) = slicePartition idx1 idx2 xs #3 in (Just hd, Just s, Just tl) #4
现在,我们可以在任何具有可切片实例的 May a 上使用拼接(当然目前只有列表)ghci> slicePartition 2 8 $ Just [0..9](Just [0,1],Just [2,3,4,5,6,7],Just [8,9])ghci> slice 2 8 $ Just [0..9]Just [2,3,4,5,6,7]ghci> slice 2 8 NothingNothing
这两个实例对于我们的 Csv 类型都很重要,因为它本质上由列表的 可能和列表的列表组成在对CSV表进行切片时,我们按列切割表格并从这些切片创建新值图5.2对此进行了说明图 5.2.CSV 表的切片切片 CSV 可以通过分别切片标题和列来实现我们已经创建的实例可以用于此目的其代码如示例 5.13 所示清单 5.13.CSV 表的可切片类型类的实例
instance Sliceable (Csv a) where slicePartition idx1 idx2 Csv {..} = let (headerHd, headerSpl, headerTl) = slicePartition idx1 idx2 header #1 (columHd, columnSpl, columnTl) = slicePartition idx1 idx2 columns #2 in ( Csv {header = headerHd, columns = columHd}, #3 Csv {header = headerSpl, columns = columnSpl}, #3 Csv {header = headerTl, columns = columnTl} #3 )
使用它,我们可以切片任意 Csv 值锻炼slicePartition 可用于编写更多函数,因为它将值分解为可以彼此独立使用的较小部分一个函数可能是 sliceMap,它将函数映射到切片上,但不映射其他部分,然后再次附加这些部分完全多态地编写该函数在类型约束中需要第二个类型类
同样,您可以编写一个 sliceDelete 函数,用于从某个值中删除切片这也需要你考虑类型类
5.3.1 出口和再出口最后,我们可以为 Csv 类型编写一个实例,让我们逐列拆开一个 CSV 表,在此之前,我们应该考虑项目结构以及导入哪些其他模块的定义在Haskell中,将模块导入其他模块是共享代码的好方法,但它会带来问题名称冲突和(更糟糕的)周期性导入会使项目难以编译为了避免这些问题,最好捆绑具有相同概念的模块,以便在单个文件中方便地重新导出在我们的项目中,我们将CSV功能捆绑到一个名为Csv的子目录中,该子目录将包含与Csv类型一起使用的模块在此目录中,我们可以首先定义一个类型模块,该模块将导出所需的所有类型Csv 就是其中一种类型接下来是用于定义工具的转换和数据转换的转换模块这是我们的 HasDefault 类型类可以驻留的地方我们的目录结构将如下所示:
src├── Csv│ ├── Conversion.hs│ └── Types.hs└── Data └── Sliceable.hs
现在,我们想在顶层添加一个 Csv 模块,以从 Csv 子目录中公开模块 我们可以使用模块导出列表,通过使用模块关键字引用模块来做到这一点如清单 5.14 所示示例 5.14.模块再导出的示例module Csv ( module Csv.Conversion, #1 module Csv.Types, )whereimport Csv.Conversion #2import Csv.Types
这将使我们能够只导入单个模块并获得我们需要的所有定义,而无需担心周期性导入此模块也可以是需要来自多个模块的定义的代码的主页我们对 appendCsv 的定义以及 Semigroup 和 Monoid 的实例就是这样的候选者
现在是时候测试我们的功能了在这样做之前,我们需要将CSV表放入我们的程序中出于这个原因,我们想探索解析CSV文件
5.4 查看模式接下来,我们希望为程序提供一种读取和解析 CSV 文件的方法为此,我们需要编写函数来读取此类文件的文件内容,解析分隔符和字段并将它们写入数据结构解析 CSV 文件是一个困难的话题,因为每个应用程序似乎都以不同的方式处理这些文件,尤其是在选择分隔符和换行符时标头在 CSV 文件中也是可选的,但没有可靠的方法来确定 CSV 文件是否具有标头因此,我们应该提供解析它们的选项我们可以轻松地使用类型对这些选项进行建模如示例 5.15 所示示例 5.15.CSV 文件解析选项
data Separators = Separators #1 { lineSeparator :: Char, fieldSeparator :: Char } deriving (Eq, Show)data HeaderOption = WithHeader | WithoutHeader #2 deriving (Eq, Show)data CsvParseOptions = CsvParseOptions #3 { separators :: Separators, headerOption :: HeaderOption } deriving (Eq, Show)defaultSeparators :: SeparatorsdefaultSeparators = Separators #4 { lineSeparator = '\n', fieldSeparator = ',' }defaultOptions :: CsvParseOptionsdefaultOptions = CsvParseOptions #5 { separators = defaultSeparators, headerOption = WithoutHeader }
这些选项可以存在于新的 Csv.Parsing 模块中,我们也可以在 Csv 模块中重新导出该模块稍后,我们可以通过命令行设置这些选项作为明智的默认设置,我们将假设文件不包含标题,并且字段用逗号分隔在考虑解析规则时,我们需要做出一些假设和规则,我们如何处理CSV文件就像开头提到的,我们认为CSV文件是:每行包含一条以换行符分隔的记录在整个文件中使用相同的分隔符将记录划分为字段每条记录的字段数量相同可能包含可选的标题行可以在使用双引号的字段中使用引用文本,从而可以在引用的文本中使用行分隔符此外,我们将使用一些额外的规则将跳过空行双引号可以使用前面的反斜杠进行转义我们希望我们的解析器在解析失败时具有一定的描述性我们的意思是解析器应该能够向我们指示两行包含不同数量的字段虽然这已经可以通过我们的智能构造函数实现,但我们还希望获取有关解析失败的行号的信息为此,我们需要首先将一些文本拆分为单独的行,然后将这些行拆分为字段并检查长度是否匹配首先,让我们担心将文本拆分为行和字段幸运的是,Data.Text 已经提供了用于拆分的函数,即在布尔谓词上拆分文本的 split 和 splitOn,在特定子字符串上进行拆分ghci> :t T.splitT.split :: (Char -> Bool) -> T.Text -> [T.Text]ghci> :t T.splitOnT.splitOn :: T.Text -> T.Text -> [T.Text]ghci> T.split (== '\n') "a\nb\nc"["a","b","c"]ghci> T.splitOn "\n" "a\nb\nc"["a","b","c"]
但是,将行拆分为字段要复杂一些我们不允许简单地拆分字段分隔符,因为字段可以引用:ghci> T.splitOn "," "abc, \"def, ghi\", jkl"["abc"," \"def"," ghi\""," jkl"]
在字符串中,我们必须使用反斜杠来转义字符,使其被视为字符串的一部分引用使我们的工作更加困难,因为双引号可以在我们的 CSV 表中单独引用我们需要构造一个函数来扫描行并决定新字段何时开始为此,我们需要能够有效地使用文本 缺点和 snoc 是可用于在文本前置和附加 Char 的函数它们的对应部分是 uncons 和 unsnoc 可用于安全地从文本中获取第一个和最后一个字符此外,stripPrefix 和 stripSuffix 可用于从另一个文本的开头或结尾(如果存在)中删除某些文本使用它,我们可以在文本中查找双引号甚至转义双引号ghci> T.uncons "Hello"Just ('H',"ello")ghci> T.uncons ""Nothingghci> T.stripPrefix "AB" "ABCDE"Just "CDE"ghci> T.stripPrefix "AB" "CDEFG"Nothing
使用这些函数需要我们对它们的结果进行模式匹配,或者使用我们知道处理 可能 类型的典型函数但是,我们希望对前缀或字符匹配的大小写进行区分这里不首选嵌套大小写匹配,因为它很快就会变得麻烦,并且定义不再清晰splitFields t = case ... of ... -> case ... of ... -> case ... of ... -> case ... of ... ... -> ... ... -> ... ... -> ...
处理此问题的更好方法是所谓的视图模式,这是一种特殊的语法,由另一种称为视图模式的语言扩展启用这些模式允许您将表达式与模式组合在一起,以提供类似防护的模式匹配让我们先看一个简单的例子我们可以通过设置相应的参数在 GHCi 中启用语言扩展它们是以 X 为前缀的扩展名使用它我们可以尝试扩展:ghci> :set -XViewPatternsghci> :{ghci| null :: [a] -> Boolghci| null (length -> 0) = Trueghci| null _ = Falseghci| :}ghci> null []Trueghci> null [1,2,3]False
在此示例中,我们使用长度 → 0 作为构建 null 函数的视图视图将长度函数应用于第一个参数,其结果是模式与 0 匹配只有当此匹配成功时,整个模式才会匹配函数的参数也可以在视图和模式绑定的变量中使用,可以在定义中使用我们可以快速使用它来构造一个行为类似于 stripPrefix 但不返回 May 的函数,如果找不到前缀,则返回未更改的 Text 值
ghci> :{ghci| stripPrefix' :: T.Text -> T.Text -> T.Textghci| stripPrefix' p (T.stripPrefix p -> Just t) = tghci| stripPrefix' _ t = tghci| :}ghci> stripPrefix' "abc" "abcde""de"ghci> stripPrefix' "ABC" "abcde""abcde"
此语法很有帮助,因为我们可以使用它们定义多个按顺序检查的模式从本质上讲,我们可以分解嵌套案例...具有此语法的语句如果我们想在 Text 值中搜索第一个引号的索引,同时忽略转义引号,我们可以通过使用两种模式递归地执行此操作一种模式检查是否找到转义的双引号作为前缀,另一种模式检查值中的第一个字符对第一个字符进行简单的大小写区分即可完成其余工作生成的函数可在清单 5.16 中找到清单 5.16.使用视图模式在其参数中查找下一个未转义双引号的索引的函数findNextQuote :: T.Text -> IntfindNextQuote (T.stripPrefix "\\\"" -> Just rest) = #1 2 + findNextQuote rest #2findNextQuote (T.uncons -> Just ('"', rest)) #3 | c == '"' = 0 #4 | otherwise = 1 + findNextQuote rest #5
此函数假定其参数包含此类双引号否则,函数将失败这是因为我们的模式匹配并不详尽我们可以通过添加另一种模式来使其详尽无遗:findNextQuote (T.null -> True) = error "Double-quote not found"
当将 GHC 与 -Wall 参数一起使用时,启用所有警告,它仍然会警告我们模式并不详尽这是因为编译器无法推断出如果值为非空,则一个模式始终匹配,而另一个模式在值为空时完全匹配因此,模式匹配是详尽的,但编译器无法弄清楚显然,使用 _ 模式会更明智,但这证明了视图模式可能很难理解,因为它们可以使用任意代码注意视图模式可以使代码更具可读性,尤其是在需要复杂的模式匹配来区分大小写时但是,我们必须意识到,推理某种模式是正确的变得更加困难我们的代码显然可以通过使用 Maybe 而不是失败来改进但是,当我们想要递归地使用 Maybe 值时,我们再次必须对它们进行模式匹配或对该类型使用一些专用函数我们想要的是一个函数,它要么让我们在 Just 中的值中添加一些东西,要么只是保持整个值 Nothing.幸运的是,这样的函数存在,称为 fmap .这让我们用更好的类型重写函数就像 map 使用函数 (a → b) 将 [a] 映射到 [b] 一样,fmap 映射 也许 a 到 也许 b
ghci> fmap (+1) $ Just 1Just 2ghci> fmap (+1) NothingNothingghci> fmap show $ Just 100Just "100"
事实上,fmap 不仅被 May 使用,而且被 Functor 类型类使用我们将在本章后面探讨这种类型的类现在我们只需要知道我们可以用 <$> 运算符简化表达式:fmap f $ x 变为 f <$> x 要构造一个将文本拆分为 CSV 字段的函数,我们需要逐个字符遍历文本,首先检查转义引号,然后检查引号、字段分隔符,然后检查杂项字符对于转义的双引号或不相关的字符,我们可以简单地将其添加到字段中双引号迫使我们搜索下一个未转义的双引号,字段分隔符使我们开始一个新字段我们可以使用累加器跟踪此字段并递归拆分字段在带引号的字符串中,我们必须跟踪转义的带引号的字符串,并从最终结果中删除转义代码如示例 5.17 所示清单 5.17.使用视图模式查找下一个未转义双引号的索引的函数
splitFields :: T.Text -> [T.Text]splitFields t | T.null t = [] #1 | otherwise = map T.strip $ splitFields' "" t #2 where separator :: T.Text separator = ... splitFields' :: T.Text -> T.Text -> [T.Text] splitFields' field (T.stripPrefix "\\\"" -> Just rest) = splitFields' (T.snoc field '"') rest #3 splitFields' field (T.stripPrefix "\"" -> Just rest) = let idx = M.fromMaybe (T.length rest) $ findNextQuote rest #4 field' = T.replace "\\\"" "\"" $ T.take idx rest #5 rest' = T.drop idx rest in splitFields' (field <> field') #6 (maybe "" snd $ T.uncons rest') #7 splitFields' field (T.stripPrefix separator -> Just rest) = field : splitFields' "" rest #8 splitFields' field (T.uncons -> Just (c, rest)) = splitFields' (T.snoc field c) rest #9 splitFields' field _ = [field] #10 findNextQuote :: T.Text -> Maybe Int findNextQuote (T.stripPrefix "\\\"" -> Just rest) = (+ 2) <$> findNextQuote rest #11 findNextQuote (T.uncons -> Just (c, rest)) #12 | c == '"' = Just 0 | otherwise = (+ 1) <$> findNextQuote rest findNextQuote _ = Nothing #13
有点奇怪的是,我们允许字段将普通文本与引用文本混合在一起有些人会说它实现得不好,我称之为功能分隔符故意留空,因为我们希望将此函数合并到更大的函数构造中因此,稍后将填充此值重要我们在这里创建的 splitFields 函数是一个合适的解析器的糟糕版本,仅用于突出显示视图模式虽然此函数对于 CSV 文件可能很好,但对于更复杂的文件格式,以这种方式编写解析器几乎是不可能的我们将在第 9 章探讨如何编写真正的解析器我们解析的困难部分已经完成我们可以将文件拆分为行并将这些行拆分为单个字段现在,我们解析的最后一部分首先是根据我们拥有的解析选项解析标头或不解析标头,其次,检查是否所有行都具有相同数量的字段,并且如果字段数不匹配,则失败并显示适当的错误消息锻炼当我们使用报价时,我们应该继续讨论这个主题,因为它对于以后处理 Csv 值的打印很重要编写两个函数,执行文本的引用和取消引用取消引号意味着,如果值以双引号开头和结尾,则所有转义的双引号 ( \“ ) 将替换为普通双引号,并且将删除将文本括起来的双引号引用的意思恰恰相反
unquote :: T.Text -> T.Textquote :: T.Text -> T.Text
我们现在能够对文件进行解析,并将其内容转换为我们可以处理的数据接下来,我们应该负责所述数据的验证5.5 返回折叠为了对解析的文件内容执行验证,我们必须检查每行是否具有相同数量的字段让我们先检查单行的正确长度将文件拆分为几行后,我们会看到要检查的文件列表当然,我们知道如何使用递归和模式匹配来解决这个问题,但让我们退后一步我们以前无数次遇到过这个问题我们遍历列表等数据结构并从中计算一些最终结果的策略是这样布置的:递归枚举数据结构的每个元素在累加器参数中保留某些状态在每个步骤上计算一个新的累加器,给定数据结构中的一个元素和上一步的累加器一旦数据结构为空(已达到最后的递归步骤),只需返回累加器这是一个强有力的策略事实上,它是如此强大,以至于它几乎普遍用于函数式编程,因为它基本上取代了对循环的需求这个概念众所周知的是折叠在我们讨论折叠可以做什么之前,让我们用一个更有趣的例子来解释这个概念想象一下,你面前有一条小路那条小径上散落着不同口味的硬糖,只是躺在地板上(当然是在包装纸里)我们将如何建模?也许有一个列表:data Candy = Lemon | Apple | Coffee | Caramel deriving (Eq, Show)type CandyTrail = [Candy]
现在想象一下,你想走在这条小路上,你找到的每一个硬糖你都会选择如何处理它们拿起它们,把它们放在你的手中,下次遇到一个,你可以根据你手中的糖果和你找到的那个来做出决定这也意味着你显然是从空手开始的,一旦你走到了小径的尽头,你只剩下手中的东西了这看起来像这样:walkOnTrail :: (a -> Candy -> a) -> a -> CandyTrail -> awalkOnTrail _ hand [] = handwalkOnTrail f hand (x : xs) = walkOnTrail f (f hand x) xs
有了这个高阶函数,我们现在可以执行多个操作例如:我们只能收集水果糖果或只收集最后五个,因为只有这么多可以放在我们的手掌中collectFruits :: CandyTrail -> [Candy]collectFruits = walkOnTrail ( \hand c -> if isFruity c then hand ++ [c] else hand ) []collectLastFive :: CandyTrail -> [Candy]collectLastFive = walkOnTrail ( \hand c -> if length hand == 5 then tail hand ++ [c] else hand ++ [c] ) []
现在想象一下,糖果轨迹只是一个简单的列表(或者更一般地说,任何可以以与列表类似的方式遍历的数据结构)概括 walkOnTrail 使我们得出左折叠的定义列表上此折叠的定义如清单 5.18 所示示例 5.18.列表上的左折函数foldLeft :: (b -> a -> b) -> b -> [a] -> bfoldLeft _ z [] = z #1foldLeft f z (x : xs) = foldLeft f (f z x) xs #2
这称为左折,因为元素是从左到右评估的或者,可以通过首先遍历列表,然后应用函数来创建右折如清单 5.19 所示清单 5.19.列表上的右折函数foldRight :: (a -> b -> b) -> b -> [a] -> bfoldRight _ z [] = z #1foldRight f z (x : xs) = f x $ foldRight f z xs #2
折叠是一个如此笼统的概念,它有自己的类型类 可折叠 .该类提供了一个称为折叠器的右折叠以及许多其他方便的函数,如空,长度,elem,总和,最大值和最小值然而,最小定义只需要实现折叠器函数,因为所有其他函数都可以从折叠器推断出来锻炼再次查看可折叠类型类首先,尝试仅使用折叠器来实现其中的便利功能只能为列表实现这些函数完成此操作后,查看此类型的简单二叉树,并为其实现 Foldable 实例相对于树木的左/右折叠是什么?
data Tree a = Leaf a | Node (Tree a) a (Tree a)
在谈论列表和类似列表的结构时,折叠是最容易想象的,因为元素的布局顺序可以从左到右或相反遍历Data.List 和 Data.Text 都分别为列表和文本提供折叠和折叠实现但是,可以为任何数据结构定义折叠,因为严格不需要遵循特定的顺序它可能适用于可折叠的实例,但折叠也可以根据例如树遍历来定义数学中一个更广泛的概念是同胚,它允许我们将折叠推广到任何代数数据结构但是,这些概念在很大程度上与日常编程无关我们通常的左右折叠都很好注意折叠也存在于其他非函数式编程语言中,例如Python和Java中的reduce以及C++中的累加当我们折叠一个数据结构时,我们本质上所做的是在具有某种功能的结构元素上构建一个术语由于我们很懒惰,因此在我们没有以某种方式强制评估该术语之前,不会对该术语进行评估我们可以想象表达式看起来像下面的例子:
ghci> foldl (\a d -> "(" <> a <> "+" <> show d <> ")") "0" [1..9]"(((((((((0+1)+2)+3)+4)+5)+6)+7)+8)+9)"ghci> foldr (\d a -> "(" <> show d <> "+" <> a <> ")") "0" [1..9]"(1+(2+(3+(4+(5+(6+(7+(8+(9+0)))))))))"
使用折叠,我们基本上从数据结构构建一个新表达式图5.3对此进行了说明图 5.3.表达式如何从折叠构建的示例Haskell的懒惰可能会产生一些意想不到的后果,这些后果在附录B中进行了讨论简而言之,它可能导致灾难性的内存效率低下为了对抗懒惰的这些影响,有时会出现严格的折叠版本在我们的 Data.List 和 Data.Text 模块中,它们被称为 foldl' 一般来说:如果累加函数是惰性的(更具体地说,在第二个参数中不严格),应该使用 foldr,如果不是 foldl' 是首选此外,在某些情况下,我们不想为累加器提供折叠的“起始值”,而是使用数据结构的第一个元素对于折叠的这种变体,许多模块提供了一个后缀为 1 的折叠函数,例如 foldl1,以指示要处理的数据结构需要至少有一个元素细心的读者可能已经发现,我们可以在迄今为止看到的项目中使用折叠的许多其他功能最后一章甚至有一个关于在函数addEdge和buildDiGraph中推广递归模式的练习本练习的解决方案是实现一个折叠,该折叠执行针对元素列表中的单个元素的操作5.5.1 对与错的细线现在我们知道什么是折叠以及如何使用它们,我们可以使用它来验证 CSV 文件中的读取行为了做到这一点,我们必须按顺序遍历行,将它们拆分为字段,然后验证它们的长度,同时我们需要跟踪我们所在的行号当然,对于任何阅读第3章的人来说,在一堆行中添加行号都是小儿科游戏使用 zip 将数字添加到列表中并过滤以摆脱空行很简单我们的解析函数,我们称之为 parseCsv ,接收 CsvParseOptions 类型的选项和文本类型的原始文件内容,并返回 Csv 文本或错误消息此结果可以使用 .我们不开始将文本字段解析为任何其他类型的原因是,在此阶段,我们只对检查 CSV 表的结构是否格式不正确感兴趣,而不是检查其中的数据是否有意义这将在稍后完成为了检查正确的字段数量,我们查看第一行,然后验证所有其他行是否具有相同数量的字段执行解析的函数如清单 5.20 所示示例 5.20.列表上的左折函数
parseCsv :: CsvParseOptions -> T.Text -> Either String (Csv T.Text)parseCsv options raw = case lines of [] -> safeMkCsv Nothing [] #1 ((_, firstLine) : rest) -> let expectedLength = length $ splitFields firstLine #2 in case headerOption options of WithHeader -> let headerFields = splitFields firstLine in mkCsv (Just headerFields) #3 <$> parseColumns expectedLength rest WithoutHeader -> mkCsv Nothing <$> parseColumns expectedLength lines #4 where lines :: [(Int, T.Text)] lines = L.filter (\(_, t) -> not $ T.null t) $ #5 L.zip [1 ..] $ #6 T.split #7 (== (lineSeparator $ separators options)) raw splitFields :: T.Text -> [T.Text] ... #8 parseColumns :: Int -> [(Int, T.Text)] -> Either String [[T.Text]] ... #9
在这个函数中,我们再次使用 fmap(以其运算符形式 <$> )将 Left 从 parseRows 传递到我们自己的返回值这样,我们可以从嵌套函数调用的深处返回错误值,而无需显式执行模式匹配通常,我们只需要担心函数产生正确的结果错误状态由 fmap 自动合并现在我们可以处理parseColumns,它将把行分成字段并验证每行中的字段数量此外,我们希望将解析的行转换为我们可以在 Csv 中使用的列正如我们所讨论的,折叠可能是此函数的正确解决方案折叠按顺序遍历每行,将其转换为字段并验证其预期长度,返回带有相应错误消息的 Left 或成功时返回 Right在我们的折叠中,我们必须检查我们之前是否发现了错误并返回它,否则我们继续添加解析的行将行转换为列可以通过“旋转”我们的结果来完成行列表通过翻转构成此列表的两个维度而变为列列表这可以通过执行此操作的 Data.List 模块中的转置函数来完成
ghci> transpose [[1,2,3], [4,5,6]][[1,4],[2,5],[3,6]]
同样,我们可以使用 fmap 向外传播错误状态此函数的代码如示例 5.21 所示示例 5.21.将文本行解析为 CSV 表中的列的函数parseColumns :: Int -> [(Int, T.Text)] -> Either String [[T.Text]]parseColumns expectedLength lines = L.transpose #1 <$> L.foldl parseRow (Right []) lines #2 where parseRow :: Either String [[T.Text]] -> (Int, T.Text) -> Either String [[T.Text]] parseRow mRows (lNum, line) = E.either #3 Left ( \rows -> let fields = splitFields line #4 in if length fields /= expectedLength then Left $ #5 "Number of fields in line " <> show lNum <> " does not match" <> " expected length of " <> show expectedLength <> "! Actual length is " <> show (length fields) <> "!" else Right $ rows ++ [fields] #6 ) mRows
将我们已经构建的所有功能放在一起,为我们提供了parseCsv函数,该函数可以从简单的文本中解析Csv值为了方便起见,我们可以创建两个函数,使用 parseCsv 函数包装不同的选项,一个使用默认选项解析,另一个使用默认选项和可选标头解析为此,我们需要更新之前创建的默认选项为此,我们可以通过在记录标识符后面的大括号中指定记录中的新值来做到这一点代码如示例 5.22 所示示例 5.22.用于分析 CSV 表的帮助程序函数
parseWithHeader :: T.Text -> Either String (Csv T.Text)parseWithHeader = parseCsv (defaultOptions {headerOption = WithHeader}) #1parseWithoutHeader :: T.Text -> Either String (Csv T.Text)parseWithoutHeader = parseCsv defaultOptions #2
现在我们可以开始解析一些文件了本章的代码存储库提供了一些CSV文件,我们可以用来测试我们的新功能
ghci> file <- readFile "cities.csv"ghci> fileText = T.pack fileghci> Right csv = parseWithHeader fileTextghci> header csvJust ["LatD","LatM","LatS","NS","LonD","LonM","LonS","EW","City","State"]ghci> numberOfRows csv128
我们已经走了很长一段路,能够解析和验证文件,但我们只能将这些文件中的值解释为 文本 .这不适用于更复杂的任务接下来,我们希望专注于将这些文本值映射到我们可以更好地使用的内容5.6 我们将乐趣放在映射中现在,我们能够解析 CSV 文件,我们仍然需要将文本类型转换为可用于计算统计信息的内容我们主要对要被视为这样的数值感兴趣这首先提出了一个问题:我们如何转换 Csv 值中的所有字段?我们想执行一个转换,更改所有值,类似于 map 如何用于列表或 fmap 如何转换 May 的内部值想一想,fmap从何而来?它的名字有些奇怪GHCi能够告诉我们一些关于它的信息:ghci> :t fmapfmap :: Functor f => (a -> b) -> f a -> f bghci> :i fmaptype Functor :: ( -> ) -> Constraintclass Functor f where fmap :: (a -> b) -> f a -> f b
fmap 函数源于一个名为 函子 的类型类它的类型与列表的映射类型惊人地相似:ghci> :t mapmap :: (a -> b) -> [a] -> [b]ghci> :t fmapfmap :: Functor f => (a -> b) -> f a -> f b
那么,函子的目的是什么?它允许我们定义可以映射的类型大多数情况下,这些是包含可映射值的容器更具体地说,这些类型包含可以映射的自由类型变量我们在类型表达式中尚未看到的是像 f a 这样的类型f 是函子的类型,a 是其类型变量什么类型可以代替f?例如,也许因为它包含一个自由类型变量名单是另一个候选人实际上,这两种类型都具有函子类型类的实例我们已经看过 也许 的 fmap,这没什么特别的列表的fmap更无聊:它只是地图
但是,让我们回到使用类型变量讨论类型显然,并非每种类型都具有此属性例如:国际 .这意味着我们无法实例化 Int 的函子我们实际上可以在GHCi的输出中看到这一点
ghci> :i fmaptype Functor :: ( -> ) -> Constraint
在这里,我们看到函子类型种类类型之于类型,类型之于值就像值具有某种类型一样,类型也有某种类型您在日常编程中遇到的常见类型很容易阅读:(读作类型)是一个单态类型,如 Int 、也许是字符串或也许 [布尔] → 是采用参数的类型通过扩展,接受两个参数的类型表示为 → → ,可以读作 → ( → )(接受参数并计算为接受另一个参数的类型)约束 用作约束的类型,例如在类约束中现在我们了解如何阅读 ( → ) →约束 .它是一种采用另一种类型的类型,它将类型作为参数,作为参数并成为约束让我们使用 GHCi 中的 :kind 命令(可以缩写为 :k)来探索更多种类:ghci> :kind IntInt :: ghci> :kind [][] :: -> ghci> :kind [Int][Int] :: ghci> :kind Functor []Functor [] :: Constraint
这也是一个重要的区别Int 是一种类型[] 是参数化类型函子 [] 不是一个类型,而是一个约束,因此我们不能用它来定义新的数据类型ghci> newtype MyFun = MyFun (Functor [])<interactive>...: error: • Expected a type, but 'Functor []' has kind 'Constraint' • In the type '(Functor [])' In the definition of data constructor 'MyFun' In the newtype declaration for 'MyFun'
但是,使用 ConstraintKind 语言扩展,可以创建具有约束类型的同义词,从而允许我们定义组合其他约束类型的新约束类型ghci> :set -XConstraintKindsghci> type ShowFunctor f a = (Show a, Functor f)ghci> :k ShowFunctorShowFunctor :: ( -> ) -> -> Constraintghci> :{ghci| showAll :: ShowFunctor f a => f a -> f Stringghci| showAll xs = fmap show xsghci| :}ghci> :t showAllshowAll :: (Show a, Functor f) => f a -> f Stringghci> showAll [1,2,3]["1","2","3"]
在考虑我们可以查看哪些类型时,函子的类型( → )是关键部分我们只能用这种类型实例化类型类
这与半群、单体和我们的可切片形成鲜明对比,它们是 →约束现在,我们已经结束了对各种世界的游览,我们可以专注于转换 Csv 中的值 .我们知道函子类型类定义了一个我们可以使用的映射函数幸运的是,csv 的类型是 → ,这使得可以为类型类定义实例
fmap 的实现将简单地转换 CSV 列中的每个元素代码如示例 5.23 所示清单 5.23.用于 Csv 的函子类型类的实例
instance Functor Csv where fmap f csv@(Csv {columns}) = #1 csv {columns = fmap (fmap f) columns} #2
在这个定义中,我们看到了另一个语法技巧,Haskell已经袖手旁观@ 称为模式绑定它将模式的一部分绑定到名称在我们的函数中,模式匹配的第二个参数绑定到名称 csv,因此“不匹配”值可以在函数定义和列字段中访问使用 fmap,我们现在可以自由地转换我们的 Csv 值最终,我们希望将解析后的CSV表中的文本转换为我们可以处理的内容,即指定的数字和文本值为此,我们希望能够将文本转换为其他数据类型此外,我们希望稍后将这些数据类型转换回文本,以便将其打印到终端为此,我们可以再次使用类型类FromText 和 ToText 可以提供从 Text 解析值并将其转换回 Text 的方法对于转换为 Int ,我们可以从 Text.Read 模块中使用 readMay此函数能够将具有 Read 类型类实例的值从 String 分析为各自的值,如果此转换失败,则返回 Nothingghci> import Text.Readghci> :t readMaybereadMaybe :: Read a => String -> Maybe aghci> readMaybe "100" :: Maybe IntJust 100ghci> readMaybe "abc" :: Maybe IntNothing
这可用于编写将文本转换为 Int 值的函数我们将使用 要么 将失败的解析包装为错误消息示例 5.24 中给出了此代码示例 5.24.用于将文本转换为其他值的类型类class FromText a where #1 fromText :: T.Text -> Either String ainstance FromText Int where fromText raw = case readMaybe (T.unpack raw) of #2 Nothing -> Left $ #3 "Failed to parse '" <> T.unpack raw <> "' as an integer" Just x -> Right x #4instance FromText T.Text where fromText = Right #5
请注意类型推断如何能够确定多态读取可能需要返回 也许int同样,可以定义 ToText 类型类其代码如示例 5.25 所示示例 5.25.用于将文本转换为其他值的类型类class ToText a where #1 toText :: a -> T.Textinstance ToText Int where toText = T.pack . show #2instance ToText T.Text where toText = id #3
在这个定义中,我们看到了另一个新的运算符一个简单的点 ( . )这个运算符就是我们以前在数学中可能见过的函数组合简单地说,它接收两个函数作为参数,第一个应用第二个函数,而不是第一个函数它的定义非常简单:ghci> (.) f g x = f (g x)ghci> f = (+1) . (3)ghci> f 1031
它允许我们在使用部分功能应用程序的同时快速将功能链接在一起这个定义很短此外,此运算符甚至可用于编写存储在数据结构中的函数
ghci> f = foldl (flip (.)) idghci> f [(+1), (100), (/50)] 14.0
最后,对于 CSV 文件的转换,我们希望定义表中可以出现哪种值文本,没有任何特殊解释或操作整数,可用于筛选或求和空值,表示缺失值或空值我们可以定义具有抽象数据类型的字段的类型,也可以定义可用于分析的 Csv 值的类型同义词data DataField = IntValue Int | TextValue T.Text | NullValue deriving (Eq, Show)type DataCsv = Csv DataField
现在,剩下的就是为我们的类型类构造实例,并构造一个将 Csv 值转换为 DataCsv 的函数DataField 的 fromText 实现可能是最有趣的我们将尝试首先将字段解释为整数,如果失败,我们将它视为普通文本其代码如示例 5.26 所示示例 5.26.用于将文本转换为其他值的类型类instance FromText DataField where fromText "" = Right NullValue #1 fromText t = let mInt = fromText t :: Either String Int #2 in Right $ E.either (const $ TextValue t) IntValue mInt #3instance ToText DataField where toText (IntValue i) = toText i #4 toText (TextValue t) = toText t #4 toText NullValue = defaultValue #5instance HasDefault DataField where defaultValue = NullValue #6toDataCsv :: Csv T.Text -> DataCsvtoDataCsv = fmap (E.fromRight defaultValue . fromText) #7toDataCsv' :: (ToText a) => Csv a -> DataCsvtoDataCsv' = toDataCsv . fmap toText #8
最后一个函数 toDataCsv' 可用于任何可以转换为文本的值,这使其成为一个强大的工具,用于将任意 Csv 值转换为我们的 DataCsv 类型现在,我们能够解析,切片,附加和转换我们的CSV文件,我们应该担心如何将它们呈现给外界5.7 漂亮的小印刷品如果没有将我们的 CSV 表输出到终端或文件的可能性,我们的工具将是不完整的为此,我们要创建一个名为 Csv.Print 的模块,该模块将包含所有必要的打印和写入功能让我们首先解决如何将 Csv 转换为 CSV 文件格式并将其写入文件系统的问题为此,我们要考虑甚至可以转换为文本的 Csv,因此无论其类型参数是什么,都需要有一个实例 ToText .将数据类型转换为 Csv 文本后,我们可以通过以下方式将 Csv 的列转换为行 转置 .从那里开始,我们必须使用 Data.Text 模块中的插层来组合行中的字段它将文本值列表的每个元素与连接每个相邻对的一些文本组合在一起我们可以使用它来用逗号分隔字段,用换行符分隔行在此之后,问题是如何将文本写入文件Data.Text.IO 提供了readFile和writeFile等功能,可以直接处理文本而不是字符串
提供将 Csv 写入文件的函数的代码如清单 5.27 所示Data.Text.IO 模块作为 TIO 导入此外,此模块中还启用了视图模式示例 5.27.将 csv 值写入文件的函数
toFileContent :: (ToText a) => Csv a -> T.TexttoFileContent (fmap toText -> Csv {..}) = #1 let rows = L.transpose columns #2 in T.intercalate "\n" $ #3 L.map (T.intercalate ",") $ #4 M.maybe rows (: rows) header #5writeCsv :: (ToText a) => FilePath -> Csv a -> IO ()writeCsv path = TIO.writeFile path . toFileContent #6
观察我们在本章中了解的不同语言扩展(RecordWildCards和ViewPatterns)和运算符($和...)如何在这里很好地结合在一起Haskell(以及一般的函数式编程)都是关于函数的组合,在这里我们有一个不同功能组合的主要例子,这些功能组合在一起构建的东西比它们各部分的总和要大得多最后,我们想解决漂亮的印刷,即以人类可读的形式显示信息的艺术当然,这件事是非常主观的这就是为什么我们要主要使用本章的这一部分作为练习虽然我们将讨论架构的结构,但实际的实现将留给您,读者让我们讨论一下漂亮打印的界面应该是什么样子的我们面临的一个问题是 Csv 是参数化的它可以包含任何类型,甚至是无法打印的类型所以我们应该将漂亮的打印限制为至少可以转换为 文本 .此外,在我们的打印中,我们希望打印每列的摘要这在我们的 Csv 类型中没有显式建模为了对这个附加约束进行建模,并更好地区分正常值和可以很好地打印的值,我们定义了一个新的类型,如示例 5.28 所示示例 5.28.将 csv 值写入文件的函数
data PrettyCsv = PrettyCsv { pc_header :: Maybe [T.Text], #1 pc_columns :: [[T.Text]], #2 pc_summaries :: Maybe [T.Text] #3 } deriving (Eq, Show)
请注意,我们如何使用类型的缩写 ( pc ) 作为字段的前缀,以避免与我们的 Csv 类型的字段名称发生任何冲突理论上,我们应该对 Csv 类型做同样的事情使用新类型,我们可以将 Csv 类型转换为 PrettyCsv 类型,然后我们可以使用它来添加摘要并创建一些漂亮的输出供用户惊叹然而,美是主观的,所以我不想在石头上为它设定一定的标准现在的任务是为漂亮的打印编写算法为此,您可以使用示例 5.29 中所示的模板代码示例 5.29.将 csv 值写入文件的函数
fromCsv :: (ToText a) => Csv a -> PrettyCsvfromCsv = ... #1withSummaries :: (ToText a) => PrettyCsv -> [a] -> PrettyCsvwithSummaries pcsv summaries = E.either error id $ safeWithSummaries pcsv summariessafeWithSummaries :: (ToText a) => PrettyCsv -> [a] -> Either String PrettyCsvsafeWithSummaries = ... #2pretty :: PrettyCsv -> Stringpretty = T.unpack . prettyTextprettyText :: PrettyCsv -> T.TextprettyText = ... #3
对于实现,您可以使用上一练习中的引号和取消引号函数考虑如何使用它们来提供定义明确的明确数据此项目的源代码包含这些函数的可能实现5.7.1 使用你所拥有的东西到目前为止,我们已经对 CSV 表实现了一些有用的操作,以及一种将它们显示到屏幕上并带有其他摘要的方法现在是时候用有意义的东西来填充这些列的摘要了为此,我们希望在每列的基础上折叠我们的 Csv 值,以将一列中的所有值合并为一个摘要我们可以通过映射每列并单独折叠它们来做到这一点如清单 5.30 所示清单 5.30.在 CSV 表上折叠,将每列合并为一个值foldCsv :: (a -> b -> b) -> b -> Csv a -> [b]foldCsv f z (Csv {columns}) = map (foldr f z) columns #1
遗憾的是,我们不能使用此折叠来创建可折叠 Csv 的实例,因为类型类中的 foldCsv 和折叠器的类型不匹配这种折叠是一个相当特殊的情况处理此数据的另一个有用的操作是对表的行执行搜索操作与列表的过滤函数类似,我们可以为 Csv 定义一个过滤函数为此,我们需要将列转置为行,过滤它们,然后将它们转置回来如示例 5.31 所示清单 5.31.用于筛选 CSV 表的函数filterCsv :: (a -> Bool) -> Csv a -> Csv afilterCsv p csv@(Csv {columns}) = #1 let rows = L.transpose columns #2 filtered = L.filter (any p) rows #3 in csv {columns = L.transpose filtered} #4
any 是列表的函数,如果给定谓词对任何列表元素有效,则返回 True这我们接受至少有一个字段基于谓词有效的所有行现在,我们已经构造了两个非常通用的函数,我们可以定义更具体的功能在 DataCsv 值上,我们可以计算所有非空值,因为空值用 空值表示 ,使用 foldCsv 同样,我们可以计算特定出现的确切次数 数据字段 .最后,我们要定义一个过滤器,如果 Csv 的条目包含某个 文本 .为此,CSV 的多态类型需要具有 ToText 的实例这些函数的实现如清单 5.32 所示示例 5.32.基于折叠和筛选的 CSV 表操作countNonEmpty :: DataCsv -> [Int]countNonEmpty = foldCsv f 0 #1 where f NullValue acc = acc f _ acc = acc + 1countOccurences :: DataField -> DataCsv -> [Int]countOccurences df = foldCsv (\x acc -> if x == df then acc + 1 else acc) 0 #2searchText :: (ToText a) => T.Text -> Csv a -> Csv asearchText t = filterCsv (\f -> toText f `contains` t) #3contains :: T.Text -> T.Text -> Boolcontains h n | T.null h = T.null n #4 | otherwise = length (T.splitOn n h) /= 1 #5
这些功能现在已成为我们 CSV 操作的基础我们可以将它们全部放入一个名为 Csv.Operations 的新模块中,然后再次从 Csv 模块重新导出此模块锻炼当然,还有各种其他可能的摘要和过滤器,只是等待实现
例如,计算特定文本在列中出现的频率或某个数值高于或低于某个指定量的频率的摘要对于喜欢冒险的人:您可能想尝试正则表达式包并基于正则表达式在 Csv 上实现过滤器这将要求您阅读软件包文档
5.7.2 听命在这个项目中,我们实现了大量的功能来处理CSV文件我们可以解析它们,切片,将它们放在一起,映射它们,也可以将它们写入文件或将它们打印到终端但是,到目前为止,我们只有这个松散的功能,背后没有真正的用途现在,我们将看看如何将我们的代码转换为真正的工具为此,我们将再次查看从命令行解析参数,但是这次我们想使用我们新发现的技能来编写小解析器,告诉我们是否已设置某些键首先,我们需要以某种方式包装来自 System.Environment 的 getArgs 函数以返回 Text 而不是 String 让我们再看看它的类型:
getArgs :: IO [String]
在这里,我们看到我们需要映射从此 IO 操作返回的列表并将所有 String 元素打包到 Text 中虽然,我们已经知道如何使用 do 表示法来实现这一点,但存在一种更简单的方法:IO 有一个函子的实例这意味着,我们可以通过使用 fmap 映射 IO 操作的内部类型和值 <$> .在这种情况下,我们希望将一个函数映射到包含 字符串列表 .其代码如下所示:
getArguments :: IO [T.Text]getArguments = map T.pack <$> getArgs
getArgs 的结果正在与 T.pack 映射<$>使地图T.pack直接对参数进行操作但是,很明显,这意味着在查看 fmap 的类型时,结果本身就是 IO .这是一般规则在列表上使用 fmap 进行操作时,结果将保持为列表,如果我们正在使用 也许它会保持 也许 .由此,我们可以创建更多的 IO 操作,我们可以用来解析我们的参数这些函数的实现将留给读者,即使项目存储库将包含它们的完整源代码在这里,我们想讨论参数应该如何结构化和格式化我们程序的所有参数都应以-- .开头任何不是布尔标志但包含数据的内容都应采用如下格式:--argument-name=value,其中值可以是某种指定形式应存在以下类型的参数:布尔值,如果设置了参数,则设置为 True,否则设置为 False(例如 --argument-name)char,表示输入的 char,但前提是它是单个字符(例如 --argument-name=,但不是 --argument-name=abc)文本,表示值中输入的文本间隔,表示为由单个逗号分隔的 Int 的间隔(例如 --argument-name=1,5)应驻留在其自己的模块中的相应函数包括:getBool :: T.Text -> IO BoolgetChar :: T.Text -> IO (Maybe Char)getText :: T.Text -> IO (Maybe T.Text)getInterval :: T.Text -> IO (Maybe (Int, Int))
在我们的示例中,这些函数在名为 Util.Arguments 的模块中实现,该模块将在代码中以其限定名称 Args 进行寻址同样,轮到您实现这些功能了(或者如果您觉得懒惰,只需从代码存储库中复制它们)它有助于编写一个从参数中读取一般值的帮助程序,因为它们都应该具有 --key=value 的形式注意当然,大多数人不会实现自己的参数解析器,因为为此目的已经存在许多库其中之一是optparse-applicative,您也可以将其用于此项目5.7.3 功能选择完成参数解析后,我们可以考虑如何将我们迄今为止构建的所有不同功能组合在一起为此,我们希望使用参数实用程序来定义解析器的行为,如何转换数据以及如何打印或写入数据我们希望能够支持以下参数:--in=<path> ,使用 <path> 的 CSV 文件作为主要输入--append=<path> 使用 <path> 处的 CSV 文件作为附加输入,该输入追加到来自主输入的数据的右侧--字段分隔符=<char> ,指定用于主输入和附加输入文件的 CSV 解析的字段分隔符--with-header ,使解析器在主输入文件和其他输入文件中查找标头--slice=<x>,<y> ,将在指定的索引处对生成的 CSV 进行切片--search=<term> ,将在 CSV 中筛选包含搜索词的行--count-non-empty,将摘要添加到每列中有多少个非空字段的漂亮输出中--no-pretty ,将禁用默认启用的漂亮版本的 CSV 的输出--out=<path> ,指定写入生成的 CSV 的路径(在所有转换完成后),如果路径为 -由于我们希望有多个参数来读取 CSV 文件并在解析中使用其他标志,因此我们希望将读取文件的功能捆绑在其自己的 IO 操作中它根据给定的参数创建解析选项并相应地解析此操作的代码如示例 5.33 所示示例 5.33.使用参数指定的分析选项读取文件的操作parseInFile :: T.Text -> IO (Either String (Csv.Csv T.Text))parseInFile key = do mInFile <- Args.getText key #1 mFieldSep <- Args.getChar "field-separator" #1 hasHeader <- Args.getBool "with-header" #1 let separators = Csv.defaultSeparators { Csv.fieldSeparator = M.fromMaybe #2 (Csv.fieldSeparator Csv.defaultSeparators) mFieldSep } headerOpt = if hasHeader #3 then Csv.WithHeader else Csv.WithoutHeader parseOpts = Csv.CsvParseOptions { Csv.separators = separators, Csv.headerOption = headerOpt } case mInFile of Just inFile -> do contents <- TIO.readFile $ T.unpack inFile #4 return $ Csv.parseCsv parseOpts contents #5 _ -> return $ Left "argument not set" #6
构造完这个函数后,我们可以考虑如何编写我们的主动作很明显,我们首先必须检查是否设置了 in 参数如果没有,我们可以简单地打印错误消息并结束程序main :: IO ()main = do mCsv <- parseInFile "in" case mCsv of Left _ -> putStrLn "no input file given (do so with --in=...)" Right csv -> do ...
下一步将更具挑战性我们必须对我们解析的 csv 执行转换我们应该怎么做呢?虽然我们可以检查每个参数,然后基于它们执行转换,但可以说有一种更好的做事方式由于函数只是值,我们当然可以像处理任何其他数据一样使用它们这也意味着我们可以收集它们(通过将它们放入列表中)并从中计算新值(或本例中的函数)这是我们未来的策略,我们将参数查找的最终结果映射到操作,然后将这些操作组合成单个转换操作,该操作可以针对所讨论的 Csv 值调用其代码如示例 5.34 所示清单 5.34.读取可选参数并从中派生行为...Right csv -> do mAppend <- eitherToMaybe <$> parseInFile "append" #1 mSliceInterval <- Args.getInterval "slice" #1 mSearch <- Args.getText "search" #1 let mAppendOp = fmap (flip (<>)) mAppend #2 mSliceOp = fmap (uncurry slice) mSliceInterval #2 mSearchOp = fmap Csv.searchText mSearch #2 transformOp = foldl #3 (\t mOp -> (M.fromMaybe id mOp) . t) id [mAppendOp, mSliceOp, mSearchOp] dataCsv = Csv.toDataCsv $ transformOp csv #4...eitherToMaybe :: Either b a -> Maybe aeitherToMaybe (Left _) = NothingeitherToMaybe (Right x) = Just x
这里使用的uncurry函数采用二进制函数并创建一个一元函数,接收两个参数的元组,产生相同的结果它的双重功能是咖喱功能我们在这里使用 uncurry 来获取切片函数接受 mSliceInterval 中的间隔元组注意咖喱这个名字源于咖喱的概念它告诉我们,任何具有多个参数的函数都可以重写为一元函数序列您可能还记得第二章中的示例,其中我们探讨了如何使用两个匿名的一元函数(f = \x → \y → ... )构造二进制函数正是这个概念,让我们可以将任何具有有限参数的函数重写为一元函数序列它是由数学家Haskell Curry推广的,这个概念和我们在本书中学习的编程语言就是以他的名字命名的由于此处显示的函数组合有时很难理解,因此我们希望快速深入研究解释在函数式编程中,函数是一等对象这是一个基本概念,因为它允许我们将函数视为值我们可以将它们传递给其他函数,从其他函数接收它们,给它们一个名称,将它们放入数据结构中,并从函数中计算新函数通常非常重视函数的组成我们使用多态性来简化在更多上下文中的函数,不一定是为了节省我们为一堆类型编写专用函数的时间,而是因为它使组合更容易事实上,我们在声明式编程时所做的只是组合函数,因为我们没有像命令式语言那样定义一系列操作,而是描述如何将某些函数的结果传递给其他函数我们本质上只是组合函数我们是否使用 let-bindings 或使用点和美元运算符来做到这一点是无关紧要的这基本上就是我们在上面的代码示例中看到的,我们计算一个最终函数,以从一堆其他(可选)函数转换我们的 Csv我们是否明确地写下来或通过折叠来做到这一点并不重要
ghci> f = (+1) . (10) . (+10)ghci> f 0101ghci> g = foldl (.) id [(+1), (10), (+10)]ghci> g 0101
但是,动态的“计算”功能具有优势在我们的例子中,我们使用可选参数来通知包含可选函数的数据结构,然后在最终结果中使用,但是这个函数列表可以根据其他一些参数动态计算,这允许我们定义非常复杂的数据流,而无需显式定义此类数据流它只是从函数列表的单次折叠开始因此,在这种情况下,控制流遵循数据流注意在许多语言中,类似的方法可以在回调中找到,回调是提供给其他函数并从其中调用的函数在那里,控制流也可能受到数据流的影响与我们方法的主要区别在于组成回调并没有真正组合,因为许多语言无论如何都不会明确允许您编写函数在我们的程序中,我们现在处于通过命令行参数公开功能的地步,现在只需要担心向外界呈现我们的数据我们希望允许用户将 CSV 表写入文件或将文件内容打印到 stdout 以进行进一步的数据操作或者,用户应该得到一些漂亮的数据表示幸运的是,我们不必学习任何新的花哨功能来实现这一点这取决于一些模式匹配和一个函数的一点帮助,该函数为我们提供了一种语法上很好的方法来选择性地执行 IO 操作when :: Bool -> IO () -> IO ()when True act = actwhen False _ = return ()unless :: Bool -> IO () -> IO ()unless b = when (not b)
使用除非我们可以写下更大的表达式,而不必将其包装成大小写或 if .其代码如示例 5.35 所示清单 5.35.读取可选参数并从中派生行为mOut <- Args.getText "out" #1case mOut of Just "-" -> TIO.putStrLn $ Csv.toFileContent dataCsv #2 Just fp -> Csv.writeCsv (T.unpack fp) dataCsv #3 _ -> do countNonEmpty <- Args.getBool "count-non-empty" #1 let mSummary = #4 if countNonEmpty then Just $ Csv.countNonEmpty dataCsv else Nothing noPrettyOut <- Args.getBool "no-pretty" #1 unless noPrettyOut $ #5 TIO.putStrLn $ #6 Csv.prettyText $ maybe #7 id (flip Csv.withSummaries) mSummary (Csv.fromCsv dataCsv)
我们的实施到此结束我们已经构建了一个库,能够将CSV文件解析为我们可以构建算法的数据模型我们介绍了处理数据的一般概念,例如 Semigroup 和 Monoid 类型类,甚至介绍了我们自己的切片数据并处理与文本之间的转换然后,我们将功能公开给外界,并使它们可访问和可配置锻炼现在轮到你了我们已经为能够读取,操作和写入CSV文件的工具创建了基线,但我们尚未释放其全部潜力通过实施更多过滤器、搜索策略或从 CSV 创建输出的方式来扩展程序以下是一些帮助您入门的想法:CSV 表上的联接数值过滤器 ( > n , < n )可配置的文件输出格式为输入和追加指定多个文件查找和替换行上的字符串将 CSV 表导出到 SQL 创建语句支持更多数据类型,如浮点数或布尔值分隔符探查,这意味着程序推断文件中可能使用了哪些分隔符更冒险的人可以尝试实现一些需要更多工作的额外功能:CSV 文件与 JSON 文件之间的转换为此,请查看 aeson 包具有终端用户界面的交互模式,用于浏览更大的 CSV 表看看砖包,它可以帮助你在命令行上构建用户界面它具有可用于将表格打印到屏幕上的表格类型最后,我们想在我们的计算机上安装我们的工具这一步当然是可选的,但当我们构建有用的软件时,这是值得的通过运行堆栈安装,我们的程序被构建并安装到本地目录将这个目录的路径放在环境的 PATH 变量中后,您可以使用该程序
在我们的例子中,csview 是一个可执行文件,我们现在可以用来满足我们所有的 CSV 探索需求
5.8 小结记录可用于通过自动生成的访问功能对复杂数据进行建模智能构造函数是用于生成值并另外检查这些值的属性的函数可以激活 RecordWildCard 和 NamedFieldPuns 以使处理记录更容易半群和幺半群是指定类型关联函数的类型类通过使用类型类,我们可以将行为推广到各种类型,并用它们创建更通用的代码模块再导出可用于将多个模块导出与单个模块捆绑在一起ViewPatterns 允许我们在单个模式中执行嵌套模式匹配折叠用于将复杂数据减少到单个值函子用于映射类型中包含的数据
(图片来源网络,侵删)
0 评论