发布于 2026-01-06 2 阅读
0

Haskell 疯狂教程:你好,monad!简介 纯函数 Effects 修复导入语句 Effects 与 monad 结合使用 结论 DEV 全球展示挑战赛 由 Mux 呈现:展示你的项目!

Haskell for madmen: Hello, monad!

引言

纯函数

效果

修复导入语句

将效应与单子结合起来

结论

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

这是我博客上的文章。如果您喜欢这个系列,请考虑请我喝杯咖啡

引言

本章我们将编写一个“Hello World”程序。在 Haskell 中实现这一点需要用到 monad,这是范畴论中的一个概念。你可能会觉得,对于打印输出这样简单的事情来说,这未免过于复杂,但你错了,打印输出本身并不简单。想想缓冲区、编码、并发……如果你以编写“Hello World”程序的难易程度来评判一门语言,那么你选择的很可能是一门隐藏了重要“细节”的语言。

我们首先要了解纯函数,也就是没有任何副作用的函数,以及它们的类型。纯函数是 Haskell 的默认行为,但它不允许写入输出,而输出本身就是一种(副作用)。

接下来我们将介绍 IO monad,它是 Haskell 处理必要副作用的方式。这将最终使我们能够编写 helloworld 程序。

本章可能是本教程中最难的部分,也是它适合疯狂学习的原因。

纯函数

本质上,Haskell 中的每个函数都只有一个输入和一个输出。我们仍然可以通过将输出本身也写成一个函数来使用“多个”输入。让我们看看加法运算是如何实现的:

(+) 3 7
Enter fullscreen mode Exit fullscreen mode

用空格分隔两个值是函数应用,f a是将参数a应用于函数f

Haskell 中的函数应用是左结合的,因此上面的代码等价于:

((+) 3) 7
Enter fullscreen mode Exit fullscreen mode

这里,我们有一个函数(+),对其应用3。结果是一个新函数,它接受一个数字并加上 3。然后,我们对这个新函数应用7

现在该讨论类型了。如前所述,一个函数只有一个输入和一个输出,我们使用箭头来表示类型。一个函数如果接受一个类型a作为输入并输出另一个类型,则b其类型a -> b为 `(a, b)`。因此,一个函数如果接受一个类型为 `a` 的输入a并生成一个类型为 `b` 的函数b -> c,其类型为 `(a, b)` 。a -> (b -> c)对于我们的函数 `(a, b)` (+),这意味着其类型必须是:

(+) :: Int -> (Int -> Int)
Enter fullscreen mode Exit fullscreen mode

(注:在 Haskell 中, :: 表示类型声明)

由于类型声明中的箭头是右结合的,因此在这种情况下括号是多余的,我们也可以这样写:

(+) :: Int -> Int -> Int
Enter fullscreen mode Exit fullscreen mode

因为我们知道函数不能有副作用,所以类型声明几乎准确地告诉我们函数要做什么:它将计算Int给定的两个其他Int值。不会发生其他任何事情,而且当我们计算这个函数的值时,这也不会有任何影响。

我们不必一次性列举所有论点。以下论点完全有效:

add3 :: Int -> Int
add3 = (+) 3

ten :: Int
ten = add3 7
Enter fullscreen mode Exit fullscreen mode

这个(+)函数有点特殊。使用括号内的符号作为函数名会将其变成中缀运算符。因此,我们也可以写出更自然的求和表达式:

ten :: Int
ten = 3 + 7
Enter fullscreen mode Exit fullscreen mode

最后,关于惰性求值需要说明一点。在上面的例子中,`x`ten本身并没有值10,而是一个表达式,其结果为 `x` 10。由于引用透明性,编译器可以决定不立即计算 `x` 的值ten,而是传递其表达式的引用3 + 7,并在真正需要时才进行求值。这不仅可以避免不必要的计算,还可以让我们使用无限数据结构。例如:

infiniteListOf1s :: [Int]
infiniteListOf1s = 1 : infiniteListOf1s
Enter fullscreen mode Exit fullscreen mode

(注: : 是类型为“前缀”的运算符 a -> [a] -> [a],例如
1 : [2,3] = [1, 2, 3]

这个列表是无限的!因为 Haskell 是非严格求值的,所以这没问题。编译器会通过惰性求值确保我们只计算实际需要的那部分列表。因此,只要我们不尝试读取整个列表,就不会进入无限循环。

效果

现在,让我们运用以上知识,来看一下该命令生成的项目stack new

我们app/Main.hs发现以下内容:

module Main where

import Lib

main :: IO ()
main = someFunc
Enter fullscreen mode Exit fullscreen mode

这应该会让你感到困惑。所以让我们逐个分析各个部分。

module Main where声明模块名称。“Main”是一个特殊名称,正如您可能预料到的那样。

import Lib导入 Lib 模块并将所有符号Lib导出添加到当前命名空间。这包括 `<string>` someFunc,但我们无法从导入语句中看出,稍后我们将把这行代码修改为更具信息量的内容。

main :: IO ()声明被调用值的类型mainIO ()。这很正常,你现在可能会感到困惑。为什么这不是一个函数?这些神秘的括号是什么?什么是IO?

为什么它不是main函数?正如前面提到的,函数根据一个值计算另一个值,但这并非计算机程序的本质。我们想要的是一个能够执行实际操作的程序,而函数仅仅是一个静态的公式。main它应该是一个操作/效果列表,例如向标准输出写入数据或监听 HTTP 请求。

那么 `unit` 是什么呢()?这是一种名为`unit`的特殊类型。每种类型都有一个或多个元素,例如布尔值有 `true`True和`false` 两种元素False,无符号 8 位整数的元素范围是 0 到 2^8。而 `unit` 类型只有一个元素,即 `unit` 本身。这意味着 `unit` 类型的值本身不包含任何信息。编写一个输出 `unit` 的纯函数是没有意义的,因为我们可以直接代入结果,而无需计算该函数的值。然而,`unit` 类型的行为很像数字 1,并且在与其他类型结合使用时有很多用途。其中一种用途就是像这里这样使用 `unit`。()在 Haskell 中,`unit` 的类型和元素都写成 `unit(1)`。

IO如果你足够细心,可能会注意到我们似乎像应用函数一样在类型声明中应用了它。就像值有类型一样,类型也有种类。`a` Int()`b`、String`c`、Char`d` 等的类型是*`a`,`b` 的类型IO是`c` * -> *。就像函数应用一样,IO ()应用于 `a` 的 `a`()的类型IO因此是 `c` *。至于 `a` 的“含义” IO,它将用纯函数计算的类型转换为用副作用计算的类型。我们稍后会学习如何使用非纯函数。

综上所述,main根据其类型,IO ()它是一个利用副作用进行计算的单元。由于()不包含任何信息,IO ()它只是一系列“没有”输出的副作用/指令。这几乎就是我们通常在程序中寻找的东西!

修复导入语句

在讨论了类型之后main,我们来看看下一行,也就是main 函数的导入语句main = someFunc。这有点令人失望,因为它仅仅引用了另一个值。该值someFunc由模块导出Lib。我们几乎只能猜测这一点,因为当前的导入语句并没有明确指出。当模块数量增加时,这就会成为一个问题。所以,让我们提前解决这个问题,将该导入语句修改为:

import Lib (someFunc)
Enter fullscreen mode Exit fullscreen mode

这样只会 暴露someFunc`from` Lib,并准确地告诉我们符号的来源。我们还可以添加导入限定符,强制我们显式地提及模块名称:

import qualified Lib
main = Lib.someFunc
Enter fullscreen mode Exit fullscreen mode

而且两者兼顾也完全合理:

import qualified Lib
import Lib (someFunc)
Enter fullscreen mode Exit fullscreen mode

这将公开someFunc,但允许您通过上面的显式表示法访问其他符号。

将效应与单子结合起来

现在是时候看看发生了什么someFunc。但是,我们可以在哪里找到Lib定义它的模块呢?如果您没有做任何更改,它应该在src/Lib.hs(模块名称必须与相对路径名匹配)。您可以在中更改哪些目录属于您的项目package.yaml

文件内容应如下所示:

module Lib
    ( someFunc
    ) where

someFunc :: IO ()
someFunc = putStrLn "someFunc"
Enter fullscreen mode Exit fullscreen mode

someFunc我们之前已经见过模块声明了。在这个例子中,它还指定了要导出哪些符号。

someFuncString "someFunc"显然,它被定义为应用于 .的应用putStrLnputStrLn它是标准prelude的一部分,每个文件都会隐式导入它。它的类型是String -> IO (),正如你可能预期的那样,它的作用是将字符串写入标准输出(不刷新缓冲区)。

但是,如何才能实现多个效果呢?是否存在某种函数,其类型IO () -> IO () -> IO ()能够将两个参数中的效果合并,而我们需要在每个地方都繁琐地添加它?嗯,这样的函数确实存在,但还有更好的方法。

正如我们所见,IO它具有某种类型* -> *。当一种类型* -> *遵循某些规则时,我们称其为单子。特别地,对于任何单子m,以下函数必须存在:

return :: a -> m a
fmap :: (a -> b) -> m a -> m b
(>>=) :: m a -> (a -> m b) -> m b
Enter fullscreen mode Exit fullscreen mode

IO是一个单子。代入IOm,我们知道至少有以下函数:

return :: a -> IO a
fmap :: (a -> b) -> IO a -> IO b
(>>=) :: IO a -> (a -> IO b) -> IO b
Enter fullscreen mode Exit fullscreen mode

return它只是简单地将纯计算伪装成非纯计算。类似地,fmap它将一个处理纯值的函数转换成一个处理非纯值的函数。例如,如果我们读取一个输入字符串,该字符串的类型为 `int` IO String,但我们的纯函数只能处理 `int` 类型的值String!`!`通过将函数提升fmap到 I/O 级别来解决这个问题。` !` 则稍微复杂一些。你可以把它想象成 Unix 管道。它接收一个非纯计算的值,并将该值传递给下一个非纯计算。(>>=)

我们来看几个例子,请花点时间好好理解一下:

computeHelloWorld :: IO String
computeHelloWorld = return "Hello, World!"

computeHelloWorldLength :: IO Int
computeHelloWorldLength = fmap length computeHelloWorld

greetTheWorld :: IO ()
greetTheWorld = computeHelloWorld >>= putStrLn
Enter fullscreen mode Exit fullscreen mode

putStrLn是类型为的输出写入函数String -> IO ()

为了举一个更实用的例子,我们使用getLine前奏部分的内容。它具有类型IO String,并且会从标准输入 (stdin) 获取一行。现在我们可以这样做:

nameToGreeting :: String -> String
nameToGreeting name = "Hello " ++ name ++ ", I am monad."

greetPerson :: IO ()
greetPerson =
  fmap nameToGreeting getLine >>= putStrLn
Enter fullscreen mode Exit fullscreen mode

这段代码不太容易阅读,如果我们在从标准输入读取数据之前还要向标准输出写入数据,情况会变得更糟。幸运的是,Haskell 为 monad 提供了一种语法糖,即 do 代码块。

greetPerson :: IO ()
greetPerson =
  do
    putStrLn "I am monad, what is your name?"
    personName <- getLine
    putStrLn ("Hello, " ++ personName ++ "! What shall we *do* together?")
Enter fullscreen mode Exit fullscreen mode

do 代码块的类型将是其最后一个元素的类型。

附注

假设我们有类型IO (IO a)。根据(>>=),我们知道它等价于IO ()。因为对于任意,foo :: IO (IO a)我们可以执行bar = foo >>= id,得到bar :: IO a。但根据 ,return bar我们foo又得到 !换句话说,无论你将一个单子应用于一个类型多少次,它都等价于(严格来说是同构的)该单子的一次应用。这就是著名的短语“单子只是自函子范畴中的幺半群”的含义(某种程度上)。幺半群是指在重复应用某个操作时保持不变的类型。

结论

你已经学习了如何使用单子(monad)在纯函数式语言中表示效果。通常情况下,最好尽可能避免使用IO单子。虽然我们完全可以用单子编写整个程序,但这会让我们失去 Haskell 编程的许多优势。

我个人认为,IO monad 并非表示副作用的理想方式,但它却是目前通用函数式纯编程语言的标准做法。一些有趣的替代方案包括唯一性类型(Clean 语言中使用)、自由 monad(Haskell)和模型更新系统(Elm)。

你也了解了惰性求值。它既有优点也有缺点,但这超出了本章的讨论范围。

本章的最终代码分支

文章来源:https://dev.to/drbearhands/haskell-for-madmen-hello-monad-3926