如果您习惯于使用循环进行命令式编程,您实际上可以使用直接递归将命令式解决方案直接翻译成 Haskell。
请记住,这不是获得有效解决方案的最简单或最佳方法,但最好学习该技术,以便您了解更多内容惯用的解决方案正在为您抽象。
基本原则是将每个循环替换为递归函数,并将每个可变变量替换为该函数的累加器参数。在循环的迭代中修改变量 within 时,只需创建一个新变量;在在次循环迭代之间修改它的地方,用不同的参数代替该参数调用循环函数。
举个简单的例子,考虑计算一个整数列表的总和。在 C 中,可能会这样写:
struct ListInt { int head; struct ListInt *tail; }
int total(ListInt const *list) {
int acc = 0;
ListInt const *xs = list;
while (xs != NULL) {
acc += xs->head;
xs = xs->tail;
}
return acc;
}
我们可以把它翻译成低级的 Haskell:
total :: [Int] -> Int
total list
= loop
0 -- acc = 0
list -- xs = list
where
loop
:: Int -- int acc;
-> [Int] -- ListInt const *xs;
-> Int
loop acc xs -- loop:
| not (null xs) = let -- if (xs != NULL) {
acc' = acc + head xs -- acc += xs->head;
xs' = tail xs -- xs = xs->tail;
in loop acc' xs' -- goto loop;
-- } else {
| otherwise = acc -- return acc;
-- }
外部函数total 设置初始状态,内部函数loop 处理对输入的迭代。在这种情况下,total 在循环后立即返回,但如果循环后有更多代码来处理结果,那将进入total:
total list = let
result = loop 0 list
in someAdditionalProcessing result
在 Haskell 中,帮助函数通过将结果列表添加到带有 : 的累加器列表的开头 来累积结果列表是非常常见的,然后在循环后反转该列表,因为将值附加到列表的 end 成本要高得多。您可以将此模式视为将列表用作堆栈,其中: 是“推送”操作。
此外,我们可以立即进行一些简单的改进。首先,访问器函数head 和tail 可能会在我们的代码错误并且我们在空列表上调用它们时抛出错误,就像访问NULL 指针的head 或tail 成员(虽然异常比段错误更清晰!),所以我们可以简化它并且使用模式匹配而不是守卫和head/tail:
loop :: Int -> [Int] -> Int
loop acc [] = acc
loop acc (h : t) = loop (acc + h) t
最后,这种递归模式恰好是一个折叠:累加器有一个初始值,为输入的每个元素更新,没有复杂的递归。所以整个事情可以用foldl'来表达:
total :: [Int] -> Int
total list = foldl' (\ acc h -> acc + h) 0 list
然后缩写:
total = foldl' (+) 0
因此,为了解析您的格式,您可以采用类似的方法:不是整数列表,而是字符列表,而不是单个整数结果,而是复合数据类型,但整体结构非常相似:
parse :: String -> ([Char], [Int], [Int])
parse input = let
(…, …, …) = loop ([], [], []) input
in …
where
loop (…, …, …) (c : rest) = … -- What to do for each character.
loop (…, …, …) [] = … -- What to do at end of input.
如果有不同的子解析器,您将在命令式语言中使用状态机,您可以使累加器包含不同状态的数据类型。例如,这是一个用空格分隔的数字的解析器:
import Data.Char (isSpace, isDigit)
data ParseState
= Space
| Number [Char] -- Digit accumulator
numbers :: String -> [Int]
numbers input = loop (Space, []) input
where
loop :: (ParseState, [Int]) -> [Char] -> [Int]
loop (Space, acc) (c : rest)
| isSpace c = loop (Space, acc) rest -- Ignore space.
| isDigit c = loop (Number [c], acc) rest -- Push digit.
| otherwise = error "expected space or digit"
loop (Number ds, acc) (c : rest)
| isDigit c = loop (Number (c : ds), acc) rest -- Push digit.
| otherwise
= loop
(Space, read (reverse ds) : acc) -- Save number, expect space.
(c : rest) -- Repeat loop for same char.
loop (Number ds, acc) [] = let
acc' = read (reverse ds) : acc -- Save final number.
in reverse acc' -- Return final result.
loop (Space, acc) [] = reverse acc -- Return final result.
当然,如您所知,这种方法很快就会变得非常复杂!即使您将代码编写得非常紧凑,或者将其表示为折叠,如果您在单个字符和解析器状态机级别工作,也需要大量代码来表达您的意思,并且有很多机会错误。更好的方法是在这里考虑工作中的数据流,并将高级组件的解析器放在一起。
例如,上述解析器的意图是做以下事情:
这可以用words 和map 函数非常直接地表达:
numbers :: String -> [Int]
numbers input = map read (words input)
一个可读的行而不是几十行!显然这种方法更好。考虑如何以这种风格表达您尝试解析的格式。如果您想避免使用 split 之类的库,您仍然可以使用 base 函数(例如 break、span 或 takeWhile)编写一个函数来拆分分隔符上的字符串;然后您可以使用它将输入拆分为记录,并将每个记录拆分为字段,并相应地将字段解析为整数或文本名称。
但在 Haskell 中解析的首选方法根本不是手动拆分输入,而是使用像 megaparsec 这样的 解析器组合器 库。在base 中也有解析器组合器,在Text.ParserCombinators.ReadP 下。有了这些,您可以通过将子解析器与标准接口(@987654357@、Applicative、Alternative 和 Monad)相结合,在抽象中表达解析器,而无需讨论拆分输入,例如:
import Data.Char (isDigit)
import Text.ParserCombinators.ReadP
( endBy
, eof
, munch1
, readP_to_S
, skipSpaces
, skipSpaces
)
numbers :: String -> [Int]
numbers = fst . head . readP_to_S onlyNumbersP
where
onlyNumbersP :: ReadP [Int]
onlyNumbersP = skipSpaces *> numbersP <* eof
numbersP :: ReadP [Int]
numbersP = numberP `endBy` skipSpaces
numberP :: ReadP Int
numberP = read <$> munch1 isDigit
这是我在您的情况下推荐的方法。解析器组合器也是在实践中轻松使用应用程序和 monad 的绝佳方式。