逐步执行

do 块可以逐行执行。从上一节的程序开始:

  let stdin ← IO.getStdin
  let stdout ← IO.getStdout
  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

标准 IO

第一行是 let stdin ← IO.getStdin,其余部分是:

  let stdout ← IO.getStdout
  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

要执行使用 let 语句,首先求值箭头右侧的表达式(在本例中为 IO.getStdIn)。 因为该表达式只是一个变量,所以查找它的值。结果值是一个内置的 IO 原语活动。 下一步是执行此 IO 活动,结果是一个表示标准输入流的值,其类型为 IO.FS.Stream。 然后将标准输入与箭头左侧的名称(此处为 stdin)关联,以用于 do 块的其余部分。"

执行第二行 let stdout ← IO.getStdout 的过程类似。 首先,求值表达式 IO.getStdout,得到一个 IO 活动,该活动将返回标准输出。 接下来,执行此活动,实际返回标准输出。最后,将此值与 do 块的其余部分关联起来,并命名为 stdout。"

提问

现在已经有了 stdinstdout,该代码块的其余部分包括一个问题和一个答案:

  stdout.putStrLn "How would you like to be addressed?"
  let input ← stdin.getLine
  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

该代码块中的第一个语句 stdout.putStrLn "How would you like to be addressed?" 由一个表达式组成。要执行一个表达式,首先要对其进行求值。在这种情况下,IO.FS.Stream.putStrLn 的类型为 IO.FS.Stream → String → IO Unit。这意味着它是一个接受流和字符串并返回 IO 活动的函数。 该表达式使用访问器记法进行函数调用。 此函数应用于两个参数:标准输出流和字符串。表达式的值为一个 IO 活动, 该活动将字符串和换行符写入输出流。得到此值后,下一步是执行它,这会导致字符串和换行符写入到 stdout。仅由表达式组成的语句不会引入任何新变量。

下一条语句是 let input ← stdin.getLineIO.FS.Stream.getLine 的类型为 IO.FS.Stream → IO String, 这意味着它是一个从流到 IO 活动的函数,该函数将返回一个字符串。 同样,这也是访问器表示法的示例。此 IO 活动被执行时,程序会等待用户键入一行完整的输入。 假设用户输入了「David」,则结果行(「David\n」)会与 input 关联,其中转义序列 \n 表示换行符。

  let name := input.dropRightWhile Char.isWhitespace
  stdout.putStrLn s!"Hello, {name}!"

下一行 let name := input.dropRightWhile Char.isWhitespace 是一个 let 语句。 与本程序中的其他 let 语句不同,它使用 := 而不是 。这意味着将计算表达式, 但结果值不必是 IO 活动,并且不会执行。在这种情况下,String.dropRightWhile 接受一个字符串和一个字符的谓词,并返回一个新字符串,其中字符串末尾满足谓词的所有字符都会被删除。例如,

#eval "Hello!!!".dropRightWhile (· == '!')

会产生

"Hello"

#eval "Hello...   ".dropRightWhile (fun c => not (c.isAlphanum))

会产生

"Hello"

其中所有非字母数字的字符均已从字符串的右侧删除。在程序的当前行中, 空格符(包括换行符)从输入字符串的右侧删除,得到 「David」, 它在代码块的剩余部分与 name 关联。

向用户问好

do 块中剩余要执行的只有一条语句:

  stdout.putStrLn s!"Hello, {name}!"

传递给 putStrLn 的字符串参数通过字符串插值构建,生成字符串 "Hello, David!"。 由于此语句是一个表达式,因此它被求值以生成一个 IO 活动, 该活动会将此字符串后加上换行符打印到标准输出。表达式求值后,将执行生成的 IO 活动,从而生成问候语。

IO 活动作为值

在上面的描述中,可能很难看出为什么需要区分求值表达式和执行 IO 活动。 毕竟,每个活动在生成后都会立即执行。为什么不干脆在求值期间执行副作用, 就像在其他语言中所做的那样呢?

答案有两个。首先,将求值与执行分开意味着程序必须明确说明哪些函数可以产生副作用。 由于没有副作用的程序部分更适合数学推理,无论是在程序员的头脑中还是使用 Lean 的形式化证明工具, 这种分离可以更容易地避免错误。其次,并非所有 IO 活动都需要在它们产生时执行。 在不执行活动的情况下提及活动的能力能够将普通函数用作控制结构。

例如,函数 twiceIO 活动作为其参数,返回一个新的活动,该活动将第一个活动执行两次。

def twice (action : IO Unit) : IO Unit := do
  action
  action

例如,执行

twice (IO.println "shy")

会打印

shy
shy

这可以推广为一个通用函数,它可以运行底层活动任意次:

def nTimes (action : IO Unit) : Nat → IO Unit
  | 0 => pure ()
  | n + 1 => do
    action
    nTimes action n

Nat.zero 的基本情况下,结果是 pure ()。函数 pure 创建一个没有副作用的 IO 活动, 但返回 pure 的参数,在本例中是 Unit 的构造子。作为不执行任何活动且不返回任何有趣内容的活动, pure () 既非常无聊又非常有用。在递归步骤中,do 块用于创建一个活动,该活动首先执行 action, 然后执行递归调用的结果。执行 nTimes (IO.println "Hello") 3 会输出以下内容:

Hello
Hello
Hello

除了将函数用作控制结构体之外,IO 活动是一等值的事实意味着它们可以保存在数据结构中供以后执行。 例如,函数 countdown 接受一个 Nat 并返回一个未执行的 IO 活动列表,每个 Nat 对应一个:

def countdown : Nat → List (IO Unit)
  | 0 => [IO.println "Blast off!"]
  | n + 1 => IO.println s!"{n + 1}" :: countdown n

此函数没有副作用,并且不打印任何内容。例如,它可以应用于一个参数,并且可以检查结果活动列表的长度:

def from5 : List (IO Unit) := countdown 5

此列表包含六个元素(每个数字一个,外加一个对应零的 "Blast off!" 活动):

#eval from5.length
6

函数 runActions 接受一个活动列表,并构造一个按顺序运行所有活动的单个活动:

def runActions : List (IO Unit) → IO Unit
  | [] => pure ()
  | act :: actions => do
    act
    runActions actions

其结构本质上与 nTimes 相同,只是没有一个对每个 Nat.succ 执行的活动, 而是在每个 List.cons 下的活动将被执行。类似地,runActions 本身不会运行这些活动。 而是创建一个将要运行这些活动的新活动,并且该活动必须放置在将作为 main 的一部分执行的位置:

def main : IO Unit := runActions from5

运行此程序会产生以下输出:

5
4
3
2
1
Blast off!

当运行此程序时会发生什么?第一步是求值 main,它产生如下输出:

main
===>
runActions from5
===>
runActions (countdown 5)
===>
runActions
  [IO.println "5",
   IO.println "4",
   IO.println "3",
   IO.println "2",
   IO.println "1",
   IO.println "Blast off!"]
===>
do IO.println "5"
   IO.println "4"
   IO.println "3"
   IO.println "2"
   IO.println "1"
   IO.println "Blast off!"
   pure ()

产生的 IO 活动是一个 do 块。然后逐个执行 do 块的每个步骤,产生预期的输出。 最后一步 pure () 没有产生任何作用,它的存在只是因为 runActions 的定义需要一个基本情况。

练习

在纸上逐步执行以下程序:

def main : IO Unit := do
  let englishGreeting := IO.println "Hello!"
  IO.println "Bonjour!"
  englishGreeting

在逐步执行程序时,要察觉到何时正在求值表达式,以及何时正在执行 IO 活动。 当执行 IO 活动产生副作用时,请将其写下来。在纸上执行完毕后,使用 Lean 运行程序, 并仔细检查你对副作用的预测是否正确。