尽管这个操作类似于映射,但是,真的实现,还需要作出重要的设计选择。分栏部分可能递归地包含多个部分,所以,文档是一种树形结构,我们需要决定以哪种顺序处理节点:
1、从根部开始,对所有嵌套在其中的部分,递归地调用映射操作。
2、从叶子开始,首先处理嵌套最深的部分,然后,返回到包含它们的部分。
在处理列表时,顺序无关紧要,但是,对于树形结构,却是相当重要的。想象一下,我们有一个文档,包含一个垂直分栏部分,和两个带有文本的水平分栏部分,如果我们运行映射函数,合并只含有文本的分栏部分,会发生什么事?
■ 如果从根开始,我们会对垂直的分栏部分调用合并函数。函数不可能合并列,因为它们还都包含其他的分栏部分;那么,它会递归地处理两个水平分栏部分。这些部分只包含文本,所以,函数会将它们合并。结果,我们会得到有两行文本的文档。
■ 如果开始从叶子,首先,对原始文本部分调用函数,应该保持不变。下一步,它将处理两个水平分栏部分,它们都只包含文本,所以,函数对其进行合并。最后,对垂直分栏部分的根调用函数,现在,其中只包含两个文本部件。结果,我们会得到只有一行文本的部分。
第二种顺序更可取,因为我们合并所有部分,使其只包含文本。对于这个实现,就是首先递归处理所有给定部分的子部分,之后,运行由用户指定的处理函数。
现在,我们知道了函数应该如何运行,下面要看看用户如何调用函数。当考虑使用高阶函数时,首要的是类型。下面是我们要实现的函数签名,与处理列表函数的对比(DocumentPart 简称为 DP):
List.map : (‘a -> ‘b) ->list<‘a> -> list<‘b>
mapDocument : (DP -> DP) -> DP ->DP
两个函数之间有一个重要的不同。在列表中,作为第一个参数的函数,只处理列表中的一个元素,第二个参数和结果都是元素的列表;在文档中,我们不区分一个部分与整个文档,作为第一个参数的处理函数,通常只处理它获取的输入的根部分,不会递归处理嵌套的部分。MapDocument 函数用递归遍历文档树的代码,补充了(complements)只处理一个部分的函数,这样,我们只用转换一个文档部分的函数,就能递归处理整个文档了,看清单 7.15。
清单 7.15 文档的映射操作 (F#)
let rec mapDocument f docPart =
let processed =
match docPart with <-- 递归处理嵌套部分
| TitledPart(tx,content) –>
TitledPart(tx, mapDocument f content) [1]
| SplitPart(orientation,parts) –>
letmappedParts = parts |> List.map (mapDocument f) [2]
SplitPart(orientation, mappedParts)
| _ –> docPart
f(processed) <-- 运行指定函数
回顾一下,函数首先递归处理当前部分的每个子部分,之后,运行指定的处理函数。递归处理相当简单,我们使用模式匹配处理两种包含子部分的部分。对于标题部分[1],我们处理正文,并返回包含原始标题的新的 TitledPart;对于分栏部分,使用 List.map 获得每个列或行的新版本,并使用这个结果去构造新的 SplitPart。我们递归处理了部件之后,就把它作为参数值给指定的处理函数,并返回结果。
现在,我们有高阶函数,我们就来看一下,如何用它来合并文本元素,这对于适应文档的布局是有帮助的:在宽屏幕上,我们想要显示更多的列,但在窄屏幕上,一列更具多可读性。清单 7.16 显示了如何把只包含文本的分栏部分收缩成一个部分。
清单 7.16 收缩包含文本的分栏部分 (F#)
let isText(part) = | 检查是否为文本部分
match part with | TextPart(_) ->true | _ –> false |
let shrinkDocument part =
match part with
| SplitPart(_, parts) whenList.forall isText parts –> [1]
let res =
List.fold(fun st (TextPart(tx)) -> [2]
{ Text = st.Text + " " + tx.Text <-- 连接文本,返回字体
Font = tx.Font } )
{ Text = ""; Font = null } parts <-- 从空字符串、空字体开始
TextPart(res)
| part –> part <-- 忽略其他情况
let doc =loadPart(XDocument.Load(@"C:\...\document.xml").Root)
let shrinkedDoc = doc |> mapDocumentshrinkDocument
在这个处理函数中,我们需要检查给定的部分是否是只包含文本部分的 SplitPart。第一个条件可以通过使用模式匹配,直接进行检查,而第二个条件,是在模式的 when 子句中指定的。我们写了一个工具函数 isText,来检查是否是 TextPart 部分,然后,在 List.forall 使用,检查所有部分是否都满足条件[1]。
接下来,我们使用 fold 把列表中的所有部分聚合成一个部分。我们已经知道,每个子部分都是TextPart,所以,写 lambda 函数聚合结果时[2],可以直接作为模式来使用。编译器不能验证其正确性,所以,会发出警告。当发现警告时,应该始终保持谨慎,但是在这里,我们可以放心地忽略它。在大型项目中,需要消除所有编译器警告,可能会用 match 结构重写代码,在不该到达的分支中调用 failwith 函数。聚合使用 TextContent 类型作为状态,初始值设置为没有文本内容的字符串,也不设置字体。在每一步,我们把当前部分现有的字符串连值连接起来,并使用当前的字体。我们没有用复杂的方式处理字体,所以,最终得到的字体是最后部分所使用的。
可以图在 7.5 中看到操作的最终结果。
图 7.5 原始和更新的文档;在新文档中,只包含纯文本的分栏部分,被合并成一个文本部分。
我们前面提到过,这个类似映射的操作是我们为处理文档提供的几个有用操作中的一个,在下一节,我们将看到另一个操作,把文档聚合成一个值。