第 3 章 每一行都至关重要(Every line counts)
本章内容包括:
- 在 Haskell 中实现副作用与输入/输出
- 在含副作用的代码中使用纯(无副作用的)代码
- 与操作系统环境交互
- 在程序中加入命令行参数解析器
在上一章中,我们学习了如何编写用于字符串转换的算法,并探索了 Haskell 编程的基本概念;不过,当时我们只能在 GHCi 中测试功能。现在,我们希望将注意力转向编写第一个真正的程序——一个可以从命令行运行的程序。
在命令行中工作,或在类 Unix 系统上编写自动化脚本时,人们常常使用多个程序组成的“管道”(pipeline)来完成特定任务,比如搜索或转换数据。为了方便进程间通信,通常使用文本流(streams of text)在不同进程之间传递信息,而这通常通过管道符号 | 来实现。图 3.1 展示了这种通过 shell 命令和管道来转换数据的思路。
图 3.1 使用 shell 管道命令转换数据的示例
为了让这种机制发挥作用,Unix 环境中提供了许多工具来执行基础任务,这些工具通过管道组合起来,就能完成更复杂的操作。其中之一是 nl,即 “number lines” 的缩写。它的功能非常简单:打印文件内容并为每一行编号。换句话说,它能为任意文本生成一个带行号的清单。示例如下:
$ nl testFile.txt
1 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas a
2 mattis nisi, eleifend auctor erat. Vivamus nisl sapien, suscipit non
3 gravida sed, mattis a velit. Fusce non iaculis urna, a volutpat leo.
4 Ut non mauris vel massa tincidunt mattis. Sed eu viverra lectus.
5 Donec pulvinar justo quis condimentum suscipit. Donec vel odio eu
6 odio maximus consequat at at tellus. Ut placerat suscipit vulputate.
7 Donec eu eleifend massa, et aliquet ipsum.
8 Mauris eget massa a tellus tristique consequat. Nunc tempus sit amet
9 est sit amet malesuada. Curabitur ultrices in leo vitae tristique.
10 Suspendisse potenti. Nam dui risus, gravida eu scelerisque sit amet,
11 tincidunt id neque. In sit amet purus gravida, venenatis lectus sit
12 amet, gravida massa. In iaculis commodo massa, in viverra est mollis
13 et. Nunc vehicula felis a vestibulum egestas. Phasellus eu libero sed
14 odio facilisis feugiat id quis velit. Proin a ex dapibus, lacinia dui
15 at, vehicula ipsum.
这个工具可以以多种方式使用。例如:
- 为文件的每一行编号;
- 在输出中搜索某个字符串,然后去掉匹配内容,仅保留行号; 结果就是包含目标字符串的行号,这在查找源代码引用时非常有用;
- 另一个例子是,先创建一个数据项列表,根据某个条件进行排序,再为结果编号; 这样可以得到一个带索引的有序清单。
那为什么不趁早试试看,用 Haskell 自己重写这样一个小工具呢?虽然只使用纯函数和简单定义的编程方式很舒服,但对于程序员来说,更有价值的是去探索如何在一个以“纯函数式编程”著称的语言中实现输入与输出(即副作用)。无副作用的函数如何与 I/O 这种典型的副作用兼容?这是我们希望在学习初期就提出并回答的问题。
当然,我们把这当作一次学习练习。真正的 nl 工具有许多功能,比如各种格式化选项,我们不会完全复现;但它缺少一些有趣的功能,例如反向编号,这是我们想要添加的。因此,让我们迈出编写“真实”程序的第一步——动手实现一个为文件行编号的工具!
本章首先介绍 do 记法(do notation),并讲解如何在 Haskell 中执行输入输出操作。接着,我们将学习如何把第二章学到的语法和函数整合到这种新写法中。最后,在了解了如何与操作系统环境交互之后,我们将开始构建这个工具的基础,并在第 4 章中通过一个简单的示例程序完成它。
3.1 与外部交互(Talking to the outside)
我们遇到的第一个显而易见的挑战,源自 Haskell 的纯函数式设计。在 Haskell 中,函数不允许产生副作用,也不允许与外部环境交互。
一个函数的结果只能依赖于它的输入参数,除此之外什么也不能依赖。因此,在函数中我们无法读取文件,也不能访问操作系统的环境。但问题来了——那 Haskell 程序又是怎么实现这些(看似简单)功能的呢?答案在一个特殊的类型中:IO。在上一章中,我们已经看到,程序中的 main “函数”其实并不是一个真正的函数,而是一个类型为 IO () 的值。
通常我们称这种值为 IO 动作(IO action)。IO 动作可以被执行(invoke),并且必须返回某种值。对于 main 动作,它返回的其实是 (),也就是唯一属于 () 类型的值。这种类型被称为 unit(单元类型),相当于“什么都不返回”的占位符。
3.1.1 简单输入输出(Simple actions for input and output)
我们来看几个 IO 动作的例子:
getLine :: IO String
putStrLn :: String -> IO ()
这两个动作分别与外部环境进行输入和输出交互。 getLine 从标准输入(stdin)中读取一行字符串,以换行符结束,通常就是从命令行中输入的文本。 因此它的类型是 IO String,表示这是一个会返回 String 的 IO 动作。而 putStrLn 则会把一个字符串打印到标准输出(stdout),并在末尾自动添加一个换行符。它的类型 String -> IO () 表示:putStrLn 本身是一个函数,调用它会产生一个 IO 动作。换句话说:IO 动作是“与外界交互的描述”,但仅仅存在并不会触发它,只有在被求值(evaluated)时才会真正执行。
我们来看一个完整的例子,它从用户那里读取一行文字并打印回去:
清单 3.1:读取并输出用户输入的简单程序
module Main (main) where -- #1
main :: IO ()
main = do -- #2
line <- getLine -- #3
putStrLn line -- #4
注解:
--1从Main模块导出main动作--2开始一个do代码块--3执行getLine并将结果绑定为line--4执行putStrLn line,输出字符串
这里出现了一种新语法,叫做 do 记法(do notation)。它以关键字 do 开头,表示开始一个 IO 动作的定义。在这个 do 块中,我们可以顺序调用其他 IO 动作。注意:在纯函数中执行 IO 动作是不被允许的,必须在 do 块或 IO 语境中执行。使用 <- 语法,我们可以让程序在运行到这一行时执行某个 IO 动作,并把结果绑定到变量上。这样,“交互的描述”就变成了真正的执行。最后一个语句决定整个 do 块(即整个 IO 动作)的返回类型。由于 putStrLn line 的类型是 IO (),因此整个 main 的类型正好匹配。
💡 注意:
do记法不仅用于IO,它其实是任何 Monad(单子) 的语法糖。 不过我们暂时先不展开,后面章节会详细讨论。
需要注意的是,这种语法看起来和感觉上都很像命令式编程,因为这些动作(action)是从上到下依次执行的。然而,不要将它与命令式编程混为一谈;我们依然在编写函数式程序。这种语法的作用是组合动作,因为这些动作可以用来表示任意复杂的行为。例如:
- 在屏幕上打印文本
- 通过网络接口发送数据
- 构建并向用户显示图形界面(GUI)
实际上,任何程序本身都是一个 IO 动作,因为程序的入口点 main 的类型就是 IO ()。因此,通常来说,我们无法“逃离”一个 IO 动作——也就是说,不能在纯代码中直接指定去执行某个 IO 动作。当一个 IO 动作返回一个值时,这个值必须在另一个 IO 动作中被处理。
3.1.2 模拟循环(Simulating a loop)
让我们用刚学的语法编写一个交互式动作:读取用户输入的每一行,并在前面加上递增的行号——一个简化版的交互式 nl。先写一个最简单的版本:
interactiveLines :: IO ()
interactiveLines = do
line <- getLine
putStrLn ("1. " ++ line)
然而,这样的程序只会打印一次编号。我们需要重复这个过程,并且同时递增行号。那该如何实现呢?首先,在 Haskell 中我们没有循环结构,并且数据是不可变的,因此我们无法像其他语言那样通过循环来让动作反复执行并递增计数器。为了实现类似“循环”的效果,我们可以再次调用这个动作本身。
interactiveLines :: IO ()
interactiveLines = do
line <- getLine
putStrLn ("1. " ++ line)
interactiveLines
但是,我们该如何递增计数器呢?到目前为止,每一行都会始终被视为第一行。我们需要做的是通过参数化这个动作来实现。也就是说,定义一个接收计数器作为参数并返回一个 IO 动作的函数:
interactiveLines :: Int -> IO ()
interactiveLines counter = do
line <- getLine
putStrLn (show counter ++ ". " ++ line)
interactiveLines (counter + 1)
我们可以使用 show 函数将任意数字转换为字符串,并在这里用它来转换计数器。在进入递归调用之前,我们递增计数器,以便显示下一个编号。
💡注意:
show函数可以用来将多种类型的值转换为字符串。在第 5 章中,当我们讨论类型类(type classes)时,会学习如何识别哪些类型可以使用show。
需要理解的是,这种做法之所以可行,是因为我们构造了一个会计算出 IO 动作的函数。当然,这个函数的结果会根据传入的参数而变化。因此,对于每一个新的计数器值,我们实际上都在调用一个新的动作。仅仅在 do 块中调用 interactiveLines 是不够的,因为由于缺少参数,它并不是一个 IO 动作。简而言之,类型 Int -> IO () 不是一个 IO 动作!
3.1.3 跳出递归动作(Breaking out of a recursive action)
现在我们已经找到了一种实现“循环”的方法,那么接下来就需要找到一种跳出循环的办法。一个简单的终止条件是:当用户输入为空时,停止计数并退出循环。这可以通过使用 null 函数(用于检查列表是否为空)以及 if-then-else 结构来实现。目前我们可以假设 null 的类型为:String -> Bool。不过,我们还需要确定当列表(即输入)为空时该怎么做。当我们处在一个 IO 动作的 do 块 中时,最后一个表达式必须是一个 IO 动作。这意味着在 if-then-else 语句中,两个分支都必须返回一个 IO 动作。当然,如果输入不为空,我们可以简单地递归调用当前的动作;但如果输入为空呢?我们需要找到一种方法来生成一个类型为 IO ()、但什么也不做的 IO 动作。为此,我们可以使用一个非常有用的函数,名为 return:
return :: a -> IO a
它可以用来将一个值包装成一个 IO 动作(其中 a 表示任意类型都可以被使用)。不过,你不应该把这个 return 理解为从动作中“返回”,因为实际上并不是这样。根据定义,一个 IO 动作的返回值,是该动作中最后被求值的那个子动作的结果!
myAction :: IO Int
myAction = do
return 1
return 2
return 3
这个动作的结果永远是 3,相当于:
myAction :: IO Int
myAction = do
_ <- return 1
_ <- return 2
return 3
通过使用 _ <-,我们只是丢弃了返回的值。这通常是我们对待类型为 IO () 的动作的方式,因为这种类型的值不包含任何有用信息,我们并不关心它。
在我们的 interactiveLines 示例中,我们可以使用 return 函数来构造 else 分支中的 IO 动作。然而,当输入不为空时,我们需要执行两个 IO 动作。但要注意——仅仅把两个 IO 动作上下写在一起并不会构成一个新的 IO 动作。因此,必须将它们放在一个 do 块 中,因为 do 块本身定义了一个新的 IO 动作。如下所示的代码清单展示了这一点。
清单 3.2 带计数的交互式输入(递归实现)
interactiveLines :: Int -> IO ()
interactiveLines counter = do
line <- getLine -- #1
if null line -- #2
then return () -- #3
else do -- #4
putStrLn (show counter ++ ". " ++ line) -- #5
interactiveLines (counter + 1) -- #6
--1从输入读取一行--2检查是否为空--3若为空,返回一个“什么都不做”的 IO 动作--4否则开启新的do块--5输出编号后的文本--6递归调用自身,计数器加 1
我们现在得到了第一个程序原型——一个简单但完整的 IO 动作演示。在 GHCi 中,可以用多行输入(: { ... :})来测试它:
ghci> :{
ghci| interactiveLines 1
ghci| :}
ghci> interactiveLines 1
Hello
1. Hello
IO
2. IO
Action
3. Action
ghci>
我们可以看到,输入一行文本后,它会被打印出来,唯独空行不会。在 GHCi 中使用 IO 动作的行为与在普通程序中略有不同。请记住,GHCi 会对输入的语句立即求值,因此,只要在命令行中输入一个 IO 类型的语句,它就会被执行:
ghci> getLine
Hello
"Hello"
当 IO 动作返回的不是 () 时(例如返回字符串),GHCi 会自动打印出返回值。
3.2 动作中的纯函数(Pure functions inside of actions)
现在我们已经为程序写出了第一个原型,是时候思考如何组织它的结构了:程序的哪些部分可以保持纯函数式,哪些部分会产生副作用?
注意 再次强调,副作用指的是函数内部与外部环境的交互行为。 在本章中,我们主要关注与操作系统的交互。 不过,这个概念也可以包括可变状态或多线程中的共享资源等。
我们的程序需要从操作系统中读取两类数据:
- 由于我们正在编写一个命令行工具,用户需要通过命令行参数配置其行为;
- 程序需要读取这些参数所指定的文件内容。
这些功能(包括最终输出结果)都涉及与外部环境交互,因此必须在 IO 动作 中实现。而对文件中读取的多行内容进行编号这样的逻辑,则完全可以用纯代码实现。
3.2.1 读取与修改用户输入(Reading and modifying user input)
这就引出了一个问题:如何在 IO 动作中使用纯函数代码?当然,这有多种方式可以实现。正如我们之前看到的,我们可以在执行 IO 动作的函数中传入参数。而在这过程中,我们完全可以用纯函数去变换这些参数。假设我们想写一个程序,从用户那里读取一行输入,并打印出它的大写版本。 这与第 3.1 节中的程序类似,只是在打印前对变量 line 做了一个小小的修改。要将一个字符转换为其大写形式,我们可以使用 Data.Char 模块中的 toUpper 函数。不过,仅仅转换一个字符是不够的,我们需要对字符串中的每个字符都进行转换。还记得第一章中的 map 函数吗?它正是我们所需要的工具。通过 map 与 toUpper 的组合,我们就能轻松构造出一个将字符串转为大写的函数。接着,我们可以把它加入之前的简单程序中:
**清单 3.3 一个读取用户输入并输出其大写形式的程序 **
module Main (main) where
import Data.Char -- #1
main :: IO ()
main = do
line <- getLine -- #2
putStrLn (map toUpper line) -- #3
- #1 引入
Data.Char模块中的所有定义 - #2 从用户读取一行输入
- #3 将输入转换为大写并打印结果
这个程序突出了一个关键点:我们可以在实际程序中使用纯代码(没有副作用的代码)。副作用的处理则通过我们定义的 IO 操作(actions) 来完成,这些操作会调用我们的纯代码。例如,像 map 和 toUpper 这样的函数完全没有副作用,而 IO 操作则负责处理副作用,并且这些副作用不会直接暴露给程序的其他部分。这一思想在图 3.2 中有所说明。当然,Haskell 中的异常处理要比这复杂得多,但这是一种很好的出发点:可以将 IO 操作看作一个为我们管理副作用的环境。
图 3.2 展示了副作用、动作与纯代码之间的交互关系。
一般来说,我们应尽量减少非纯代码(IO 动作)的比例,并增加纯代码的比例。为什么?因为这能让代码更容易推理与测试。纯函数无需外部环境即可通过单元测试或性质测试来验证行为。因此,只有在确实必要时才应使用 IO 动作,其余逻辑都应保持纯净。
3.2.2 纯代码与非纯代码之间的数据流(Data flow between pure and impure code)
既然我们已经知道如何在 IO 动作中使用纯函数,现在可以更细致地思考我们的 nl 工具了。它需要哪些组件和功能?哪些是纯的,哪些是不纯的?处理文件行内容、解析命令行参数等操作都是纯的,因为这些操作只需对字符串进行转换或模式匹配。相反,读取命令行参数、读取文件内容、输出结果等操作会与外部环境交互,因此属于非纯动作。
| 类型 | 职责 |
|---|---|
| 纯(函数) | 解析参数为行编号、过滤、格式化 |
| 非纯(动作) | 读取参数读取文件打印输出 |
我们可以开始思考如何把这些任务组合在一起。图 3.3 展示了这个工具的不同组件及它们之间的数据流关系(箭头表示数据流动方向)。
图 3.3 为文本行编号的工具的组成部分与操作流程(箭头表示数据流向)
我们之所以对这些组件做如此明确的区分,并不是因为编译后的程序真的会保留这种界限——实际上,编译后它们都会融合为一个整体。而是在编写代码的过程中,明确副作用的边界有助于我们理解程序语义。既然提到了副作用,让我们开始处理第一个副作用: 从命令行读取参数!
3.3 从环境中读取数据(Reading from the environment)
任何程序的重要组成部分之一都是与外部环境进行交互。运行在现代操作系统上的程序通常可以访问命令行参数和系统环境变量。
Haskell 提供了一个专门处理这些内容的模块 —— System.Environment。
这个模块提供了一些函数,可以用于从程序的运行环境中获取信息,例如:
- 程序参数(
getArgs) - 程序名(
getProgName) - 与某个键相关联的环境变量值(
lookupEnv)
在我们的工具中,我们只对命令行参数感兴趣。
3.3.1 解析命令行参数(Parsing command line arguments)
getArgs 是一个 IO 动作,它会返回一个字符串列表(类型为 IO [String]),其中包含完整的命令行参数列表。因此,读取参数非常简单,难点在于如何解析参数,因为它们通常遵循某种固定的格式。为了让格式保持简单,我们暂时只让它包含一个文件名。因此,运行程序的命令形式应该是:nl <filename>如果用户没有输入任何参数,我们就输出一段帮助文本;如果输入了至少一个参数,我们将第一个参数视为文件名,忽略其余部分。
如果你还没有创建新的 Stack 项目,现在正是好时机。像之前一样,我们可以进入想要的位置,然后通过以下命令创建一个名为 lnums 的新项目:
stack new lnums
这一次,我们不会忽略 Main.hs 文件,而是直接在其中实现命令行参数的解析。在我们这个简化的示例中,程序需要检查是否提供了命令行参数——更具体地说,就是检查 getArgs 返回的列表中是否恰好只有一个值。我们可以使用纯函数结合**模式匹配(pattern matching)**来实现这一点。如果我们想匹配一个只包含单个元素的列表,可以使用如下模式:[x]这个单独的元素会被我们在模式中使用的名称(这里是 x)绑定。这种写法等价于使用如下形式的模式:x : []。下面是一个用于解析文件名参数的简要示例:
清单 3.4 简单的参数解析函数
parseArguments :: [String] -> FilePath
parseArguments [filePath] = filePath -- #1
parseArguments _ = undefined -- #2
#1 当输入列表正好包含一个元素时,返回该文件路径。 #2 当输入不匹配时(为空或包含多个参数),程序会崩溃。
这里我们使用了 FilePath 类型(它实际上是 String 的类型同义词),这样可以明确地表示函数返回的是一个文件路径。由于 FilePath 只是 String 的别名,因此两者可以互换使用。此外我们还看到了 _ 通配符模式。它始终可以匹配任何输入,但不会绑定变量,因此输入会被丢弃。
💡 注意:在模式匹配中,使用通配符
_作为最后一个匹配项会让模式匹配变得“穷尽”(即总会匹配成功)。
不过,这段代码显然不够好,因为一旦用户输入错误,程序就会崩溃。我们应该更“礼貌”一些,不是直接崩溃,而是提示用户哪里出了问题。这就引出了一个问题: **我们该如何优雅地表示错误?**如何让函数在返回“纯值”的同时,表示可能的失败?答案是:使用一个新的类型 —— Maybe!
3.3.2 用 Maybe 表示错误(Encoding errors with Maybe)
Maybe 类型用于表示“可能缺失的值”。也就是说,一个 Maybe 值要么是一个实际的值,要么什么都没有。它和列表类似,也是带类型参数的类型。例如:
[Int]是整数列表Maybe Int是“可能存在的整数”
它的定义如下:
data Maybe a = Just a | Nothing
这是一种新的语法,我们将在后续频繁遇到。 data 关键字用于定义代数数据类型(ADTs),通过**构造子(constructors)**来表示不同的可能值。在这里:
Just是一个带参数的构造子,表示存在一个值;Nothing是一个空构造子,表示不存在值。
当我们遇到某种类型的 Maybe 值时,可以确定它一定是 Just ... 或者 Nothing。这让我们可以编写不会崩溃的纯函数:如果操作失败,只需返回 Nothing 即可。
💡 注意:
Maybe不仅用于表示函数失败,也可表示可选参数或可选返回值。 但最常见的用途仍然是表示“可能不存在的值”。
这个类型在 Haskell 中已经被内置提供了,因此我们可以对清单 3.4 中的函数进行修改,得到清单 3.5 中的定义。我们不再使用 undefined 导致程序崩溃,而是返回一个 Nothing 值!当然,函数的类型也随之改变为 Maybe FilePath,因为我们返回的不仅仅是一个文件路径,而是一个可能包含文件路径的 Maybe 类型。如果确实有文件路径可用,我们就用 Just 构造器将其包装起来。
清单 3.4 使用Maybe的简单参数解析函数
parseArguments :: [String] -> Maybe FilePath
parseArguments [filePath] = Just filePath -- #1
parseArguments _ = Nothing -- #2
#1 用
Just构造子包裹返回值 #2 用Nothing表示解析失败
这个函数现在不会再导致程序崩溃了,因为我们可以将错误状态编码进类型中。但接下来问题是:我们该如何使用这个值呢?幸运的是,我们已经知道答案了——**模式匹配(pattern matching)!**就像对列表进行匹配一样,我们也可以匹配 Maybe 类型的特定构造器。这样就可以提取出 Just 构造器中包含的值。由于我们无法Nothing 中提取值,因此必须提供一个默认值。这就引出了清单 3.6 中函数的定义。
清单 3.6 使用默认值从 Maybe 中提取值的函数
fromMaybe :: a -> Maybe a -> a
fromMaybe _ (Just v) = v -- #1
fromMaybe v Nothing = v -- #2
#1 从
Just构造器中提取被包装的值#2 当
Maybe值为Nothing时,使用默认值
这个函数可以用来**“跳出”一个 Maybe 类型**。当我们想要将可能失败的多个计算进行串联时,这非常有用。在这种情况下,我们可以通过模式匹配检测到失败,并轻松地改变控制流或使用默认值作为回退。下面展示了 fromMaybe 的实际用法:
ghci> fromMaybe 0 (Just 100 :: Maybe Int)
100
ghci> fromMaybe 0 (Nothing :: Maybe Int)
0
幸运的是,我们不需要自己实现这些函数! Data.Maybe 模块已经为我们提供了大多数常用的函数。表 3.1 总结了其中一些与 Maybe 类型常搭配使用的函数。
表 3.1 与 Maybe 类型一起使用的常用函数
| 名称 | 类型 | 描述 |
|---|---|---|
| maybe | b -> (a -> b) -> Maybe a -> b | 如果 Maybe 是 Nothing,返回默认值;否则应用函数到 Just 中的值。 |
| fromMaybe | a -> Maybe a -> a | 与上面类似,但不需要提供函数。 |
| catMaybes | [Maybe a] -> [a] | 从 Maybe 列表中提取所有 Just 值,忽略 Nothing。 |
| mapMaybe | (a -> Maybe b) -> [a] -> [b] | 将函数映射到列表上,只保留结果为 Just 的值(等价于 catMaybes (map f xs))。 |
Maybe 类型在标准库以及许多外部库中都会频繁出现,因为它是表示缺失值或失败计算的理想选择。一个典型的例子是 Data.List 模块中的 elemIndex 函数:它要么返回列表中某个元素的索引(被 Just 包裹),要么在元素不存在时返回 Nothing。
练习:编写一个安全的索引函数
在第 2 章中,我们曾实现过一个简陋版本的函数,用来获取列表中某个元素的索引。该函数的类型是:
indexOf :: Char -> [Char] -> Int
但是当要查找的元素不存在时,它会导致程序崩溃。现在请重新编写这个函数,使用 Maybe 来安全地表示结果。新的类型应为:
indexOf :: Char -> [Char] -> Maybe Int
3.4 示例:读取并打印命令行参数(Example: Reading and printing a command line argument)
现在,我们可以使用刚刚实现的 fromMaybe 函数,当用户忘记提供文件路径时打印一条有帮助的提示文字。
我们还可以使用 maybe 函数,根据传入的文件路径决定该执行的操作:如果路径缺失,则打印帮助信息;否则,从解析出的参数构造相应的操作。下面的代码演示了这一思路。程序会在有参数时打印参数内容,否则打印帮助文本。
代码清单 3.7 如果没有提供参数则打印帮助文本的程序
module Main (main) where
import System.Environment
printHelpText :: String -> IO ()
printHelpText msg = do
putStrLn (msg ++ "\n")
progName <- getProgName -- #1
putStrLn ("Usage: " ++ progName ++ " <filename>") -- #2
parseArguments :: [String] -> Maybe FilePath
parseArguments [filePath] = Just filePath -- #3
parseArguments _ = Nothing -- #3
main :: IO ()
main = do
cliArgs <- getArgs -- #4
let mFilePath = parseArguments cliArgs
maybe -- #5
(printHelpText "Missing filename") -- #6
(\filePath -> putStrLn filePath) -- #7
mFilePath
- #1 获取程序被调用时的名称
- #2 打印使用说明
- #3 解析文件名参数
- #4 获取命令行参数
- #5 根据解析结果选择要执行的
IO操作 - #6 如果解析失败则打印帮助文本
- #7 如果解析成功则打印文件名
这段代码综合了我们目前所学的内容:它从环境中读取参数 → 解析 → 根据结果决定是打印帮助信息还是打印传入的文件名。
💡 注意 当定义局部变量是某种
Maybe类型时,习惯上会在变量名前加小写字母 m。 例如,mFilePath表示一个“可能包含文件路径”的Maybe值。
这个例子演示了如何使用 Maybe 类型来实现纯函数式的错误处理,并根据结果灵活改变控制流。令人惊讶的是,这里我们执行的“动作”本身也可以是函数的结果——这一点在 maybe 函数的使用中表现得很清楚。根据解析结果的不同,maybe 会为不同的动作(action)求值。换句话说,纯函数 maybe 决定了程序要在外部环境中执行什么操作。在学习 Haskell 时,理解这一点非常重要:不仅函数是值,**动作(actions)**本身也是值。
3.4.1 let 关键字
在我们的例子中,还出现了一个新关键字——let。它用于在动作(actions)中创建定义(称为 let 绑定),类似于其他语言中的局部变量定义。最基本的用法如下:
let <标识符> = <表达式>
例如:
let x = 1 + 1
但请不要被这种语法所迷惑——我们并不是在创建可变变量。 通过 let 创建的标识符必须被视为不可变的数据或常量。
💡 注意 我们也可以使用
let绑定来定义函数,只需在标识符后面加上参数即可,例如:let functionName arg1 arg2 arg3 = …这样做可以在定义内部函数时使用外层函数的参数。
这在让嵌套表达式更清晰,或在多次使用同一个表达式时,尤其有用。我们可以在之前的示例程序中看到这种用法:
main = do
line <- getLine
putStrLn (map toUpper line)
表达式 map toUpper line 可以在使用之前绑定到一个新的标识符上:
main = do
line <- getLine
let upperCaseLine = map toUpper line
putStrLn upperCaseLine
let 关键字还可以在普通函数定义之外使用,因为它本身就是表达式的一部分。例如:
ghci> let x = 1 + 1 in x + 1 :: Int
3
这里我们将表达式 1 + 1 绑定到标识符 x,并显式声明其类型为 Int。当 let 用于表达式中时,必须用 in 关键字结束,以指明该绑定在什么表达式范围内有效。
3.4.2 使用 stack 运行程序(Running the program with stack)
接下来,我们来测试清单 3.7 中代码的运行行为。我们可以通过 stack run 来构建并运行可执行文件。不过要注意:向程序传递参数时必须使用特殊的语法格式:
stack run <传递给 stack 的参数> -- <传递给程序的参数>
也就是说,当我们要向可执行文件传递参数时,必须在参数前加上 --。首先测试不带参数的情况:
$ stack run
Missing file name
Usage: lnums-exe <file name>
程序成功打印了帮助信息。这里读取到的程序名是 lnums-exe。在本例中,项目名是 lnums,而 stack 会自动为可执行文件加上 -exe 后缀。再试一次,带上一个参数:
$ stack run -- Testpath
Testpath
程序正确地解析并打印了该参数。至此,我们已经实现了一个可扩展的命令行参数解析器的基础。
现在,我们的程序基础已经完成:能够读取、解析命令行参数并作出响应。这是程序中最主要的非纯(impure)部分。下一步,我们将学习如何从文件系统读取文件内容并对其进行行号编号——这就是下一章的主题。
小结(Summary)
- IO 动作用于与操作系统环境交互,如文件或网络的输入输出。
- Haskell 运行时系统负责执行 IO 动作的求值顺序,我们只需指定动作的排列顺序。
- do 语法用于定义动作序列及其结果如何传递。
- 可以通过递归函数来模拟循环。
return用于将值包装成 IO 动作,而不是从动作中“返回”。- 使用
if-then-else时,两分支必须产生相同类型的动作。 - 动作中的表达式可由纯函数构成,因此可在不纯代码中使用纯逻辑。
- Maybe 是一个代数数据类型,用于在可能出错的函数中安全返回值,避免
undefined。 - let 关键字用于在动作或表达式中创建局部绑定。


