文章
Get Programming with Haskell-22
在阅读完第 22 课后,你将能够: • 访问命令行参数。 • 使用传统的 I/O 交互方式。 • 使用惰性求值编写 I/O 代码,使 I/O 更容易。
与命令行交互和惰性 I/O#
通常,当人们初次学习 I/O 和 Haskell 时,他们会认为 I/O 对 Haskell 来说是一个挑战,因为 Haskell 完全是关于纯粹程序的,而 I/O 恰恰是不纯的。但是,还有另一种看待 I/O 的方式,这种方式使得 I/O 非常适合 Haskell,而在其他编程语言中则显得有些笨拙。通常,在任何语言中处理 I/O 时,我们都会谈到 I/O 流,但是流是什么?理解 I/O 流的一个好方法是将其视为一个惰性求值的字符列表。STDIN 将用户输入流式传输到程序中,直到最终到达末尾。但是这个末尾并不总是已知的(理论上可能永远不会发生)。这正是使用惰性求值时在 Haskell 中思考列表的方式。 这种 I/O 视图几乎在所有编程语言中读取大文件时都使用。通常,在操作大型文件之前将其读入内存是不切实际的,甚至是不可能的。但是想象一下,一个给定的大型文件仅仅是一些分配给变量的文本,而该变量是一个惰性列表。正如你之前学到的,惰性求值允许你操作无限长的列表。无论你的输入有多大,如果你将问题视为一个大型列表,你都可以处理它。 在本课中,你将看到一个简单的问题,并以几种方式解决它。你想要做的只是创建一个程序,该程序读取用户输入的任意长的数字列表,然后将它们全部加起来并将结果返回给用户。在此过程中,你将学习如何编写传统的 I/O,以及如何使用惰性求值来找到一种更容易推理解决方案的方法。
22.1 以非惰性的方式与命令行交互#
首先,让我们设计一个命令行工具,它可以读取用户输入的一系列数字并将它们全部加起来。你将创建一个名为 sum.hs 的程序。在前一课中,你处理了接收用户输入并对其执行计算。这次棘手的是,你事先不知道用户将输入多少项。
解决这个问题的一种方法是允许用户将一个值作为程序的参数输入;例如:
$ ./sum 4
"enter your numbers"
3 5 9 2 5
"your total is 42"
要获取参数,你可以使用 System.Environment 模块中的 getArgs 函数。getArgs 的类型签名如下:
getArgs :: IO [String]
因此,你在 IO 的上下文中获得一个字符串列表。以下是在你的 main 函数中使用 getArgs 的示例。
import System.Environment
main :: IO ()
main = do
args <- getArgs
为了了解 getArgs 的工作方式,最好打印出你拥有的所有参数。因为你知道 args 是一个列表,所以你可以使用 map 来遍历每个值。但是你遇到了一个问题,因为你正在 IO 类型的 do 语句的上下文中工作。
你想要的是这样的:
map putStrLn args
但是 args 不是一个普通的列表,而 putStrLn 也不是一个普通的函数。你可以在 IO 上下文中的值列表上使用 map 的一个特殊版本,该版本在 IO 的上下文中操作列表(技术上,是任何 Monad 类型类的成员)。为此,有一个特殊的辅助函数叫做 mapM(M 代表 Monad)。
import System.Environment
main :: IO ()
main = do
args <- getArgs
mapM putStrLn args
现在当你编译你的程序时,你仍然会得到一个错误:
Couldn't match type '[()]' with '()'
GHCi 报错是因为 main 的类型应该是 IO (),但是你可能还记得 map 总是返回一个列表。问题是你只想遍历 args 并执行一个 IO action。你不在乎结果,也不想在最后得到一个列表。为了解决这个问题,还有另一个函数叫做 mapM_(注意下划线)。它的工作方式与 mapM 完全相同,但会丢弃结果。通常,在 Haskell 中,以一个下划线结尾的函数表示你正在丢弃结果。通过这个小的重构,你就可以开始了:
Haskell
import System.Environment
main :: IO ()
main = do
args <- getArgs
mapM_ putStrLn args
你可以尝试几个命令,看看会得到什么:
$ ./sum
$ ./sum 2
2
$ ./sum 2 3 4 5
2
3
4
5
现在你可以添加逻辑来捕获你的参数。你也应该处理用户未能输入参数的情况。你会将其视为 0 行。另请注意,你第一次使用了 print 函数。print 函数是 (putStrLn . show) 的简写,可以更轻松地打印任何类型的值。
import System.Environment
main :: IO ()
main = do
args <- getArgs
let linesToRead = if length args > 0
then read (head args)
else 0 :: Int
print linesToRead
现在你知道你需要多少行了,你需要重复调用 getLine。Haskell 还有一个用于这种迭代的有用函数,叫做 replicateM。replicateM 函数接受一个表示重复次数的值和一个 IO action,并按预期重复该 action。你需要导入 Control.Monad 才能使用它。
import Control.Monad
main :: IO ()
main = do
args <- getArgs
let linesToRead = if length args > 0
then read (head args)
else 0
numbers <- replicateM linesToRead getLine
print "sum goes here"
好的,你快完成了!记住 getLine 在 IO 上下文中返回一个 String。在你可以计算所有这些参数的总和之前,你需要将它们转换为 Int 类型,然后才能返回这个列表的总和。
import System.Environment
import Control.Monad
main :: IO ()
main = do
args <- getArgs
let linesToRead = if length args > 0
then read (head args)
else 0 :: Int
numbers <- replicateM linesToRead getLine
let ints = map read numbers :: [Int]
print (sum ints)
这花了一些功夫,但是现在你拥有了一个工具,可以让用户输入任意数量的整数,然后你可以将它们加总:
$ ./sum 2
4
5
9
$ ./sum 4
1
2
3
4
10
即使在这个简单的程序中,你也已经了解了许多用于处理用户输入的工具。表 22.1 列出了一些在 IO 类型中进行迭代的有用函数。 表 22.1 在 IO 上下文中进行迭代的函数
| **函数** | **行为** |
| `mapM` | 接受一个 IO action 和一个普通列表,对列表中的每个项执行该 action,并在 IO 上下文中返回一个列表。 |
| `mapM_` | 与 `mapM` 相同,但它会丢弃返回值(注意下划线)。 |
| `replicateM` | 接受一个 IO action,一个整数 n,然后重复执行该 IO action n 次,并在一个 IO 列表中返回结果。 |
| `replicateM_` | 与 `replicateM` 相同,但它会丢弃返回值。 |
接下来,你将看到如果使用惰性求值,这会变得多么容易。
22.2 使用惰性 I/O 交互#
你上一个程序可以工作,但存在一些问题。首先,它要求用户输入所需的具体行数。你的 sum 程序的用户需要事先知道这一点。如果用户正在记录博物馆的访客人数,或者将另一个程序的输出通过管道输入到你的程序中呢?回想一下,拥有 IO 类型的主要目的是将绝对必须在 I/O 中工作的函数与更通用的函数分开。理想情况下,你希望尽可能多的程序逻辑位于 main 函数之外。在这个程序中,你的所有逻辑都包装在 IO 中,这表明你没有很好地抽象出你的整体程序。这部分是因为太多的 I/O 行为与你的程序应该做的事情交织在一起。
这个问题的根本原因是,你将你的 I/O 数据视为必须立即处理的值序列。另一种方法是以与 Haskell 中任何其他列表相同的方式来思考来自用户的数据流。与其将每个数据片段视为离散的用户交互,不如将整个交互视为来自用户的字符列表。如果你将输入视为一个 Char 列表,那么设计你的程序并忘记所有混乱的 I/O 部分就容易得多。为此,你只需要一个特殊的 action:getContents。getContents action 允许你将 STDIN 的 I/O 流视为一个字符列表。
你可以将 getContents 与 mapM_ 一起使用,看看它的行为有多么奇怪。在本节中,你将使用一个名为 sum_lazy.hs 的新文件。
main :: IO ()
main = do
userInput <- getContents
mapM_ print userInput
getContents action 会读取输入,直到接收到文件结束信号(end-of-file)。对于普通的文本文件,这是文件的末尾,但对于用户输入,你必须手动输入(通常在大多数终端中是 Ctrl-D)。在运行这个程序之前,考虑到惰性求值,值得思考一下会发生什么。在严格(非惰性)语言中,你可能会认为必须等到手动输入 Ctrl-D 后,你的输入才会回显给你。让我们看看在 Haskell 中会发生什么:
$ ./sum_lazy
hi
'h'
'i'
'\n'
what?
'w'
'h'
'a'
't'
'?'
'\n'
正如你所见,由于 Haskell 可以处理惰性列表,它能够在您输入文本后立即处理!这意味着你可以以有趣的方式处理持续的交互。
22.2.1 将你的问题视为一个惰性列表#
使用 getContents,你可以重写你的程序,这次完全忽略 I/O 直到稍后。你现在只需要处理一个由数字和换行符 \n 组成的字符列表。这是一个示例列表。
sampleData = ['6','2','\n','2','1','\n']
如果你可以编写一个将此转换为 Int 列表的函数,你就一切就绪了!有一个用于 String 的有用函数可以使此操作变得简单。lines 函数允许你按行分割字符串。这是在 GHCi 中使用你的 sampleData 的示例:
GHCi> lines sampleData
["62","21"]
Data.List.Split 模块包含一个比 lines 更通用的函数 splitOn,它可以根据另一个字符串分割一个 String。Data.List.Split 不是 base Haskell 的一部分,但包含在 Haskell Platform 中。如果你没有使用 Haskell Platform,你可能需要安装它。splitOn 函数是在处理文本时一个非常有用的函数。这是 lines 如何用 splitOn 编写的。
myLines = splitOn "\n"
有了 lines,你只需要将 read 函数映射到你的新列表上,你就会得到你的 Int 列表。你将创建一个 toInts 函数来完成此操作。
toInts :: String -> [Int]
toInts = map read . lines
使这个函数与 I/O 一起工作非常容易。你将其应用于你用 getContents 捕获的 userInput。
main :: IO ()
main = do
userInput <- getContents
let numbers = toInts userInput
print (sum numbers)
正如你所见,你的最终 main 函数比你的第一个版本简洁得多。现在你可以编译你的程序并进行测试:
$ ./sum_lazy
4
234
23
1
3
<ctrl-d>
265
这比以前好得多,因为你的代码更简洁,并且用户在开始时不必担心列表中有多少个数字。在本课中,你已经了解了如何构建你的程序,使其以类似于大多数其他编程语言的方式工作。你从用户那里请求数据,处理该数据,然后从用户那里请求更多输入。在这种模型中,你正在执行严格的 I/O,这意味着你在获取每个数据片段时都会对其进行求值。在许多情况下,如果你将用户输入视为一个普通的惰性 Char 列表,你可以更容易地抽象出几乎所有非 I/O 代码。最后,你只需要在一个地方将你的列表视为 I/O:当你第一次接收到它时。这允许你将所有其余的代码编写为在 Haskell 中操作普通列表的代码。
总结#
在本课中,我们的目标是向你介绍在 Haskell 中编写简单命令行界面的方法。最常见的方法是将 I/O 视为任何其他编程语言一样。你可以使用 do-notation 创建一个 IO action 的过程式列表,并以这种方式构建与 I/O 的交互。一种更有趣的方法,在除 Haskell 之外的少数语言中才有可能实现,是利用惰性求值。通过惰性求值,你可以将整个输入流视为一个惰性求值的字符列表 [Char]。你可以通过编写纯函数,就好像它们只是在处理 [Char] 类型一样,从而大幅简化你的代码。让我们看看你是否理解了。
Q22.1 编写一个程序 simple_calc.hs,该程序读取包含两个数字相加或相乘的简单方程式。程序应该在用户每输入一行时解决该行方程式。
Q22.2 编写一个程序,允许用户选择 1 到 5 之间的一个数字,然后打印一句名言(名言由你选择)。打印名言后,程序将询问用户是否想要另一句。如果用户输入 n,程序结束;否则,用户将获得另一句名言。程序重复此过程,直到用户输入 n。尝试使用惰性求值,并将用户输入视为一个列表,而不是在末尾递归调用 main。