为什么我必须调用 `sum` 两次才能对 `Maybe Integer` 的列表求和?

Why do I have to call `sum` twice to sum a list of `Maybe Integer`s?

我写了一个非常简单的练习题的解决方案:

Calculate the number of grains of wheat on a chessboard given that the number on each square doubles. Write code that shows how many grains are on a given square, and the total number of grains on the chessboard.

如果输入小于 1 或大于 64,函数必须 return 一个 Maybe Integer 和 return Nothing

square :: Integer -> Maybe Integer
square n = if (n < 1 || n > 64) then Nothing else Just (2^(pred n))
total :: Integer
total = sum (fmap sum (map square [1..64]))

我尝试将 fmap sum 应用于 GHCI 中 map squareMaybe Integer 的列表)的一些测试输出,并惊讶地发现它是 return 的列表整数(sans Just)而不是它们的总和。所以在上面的解决方案中,我第二次应用 sum 来实际获得总和。

我想从概念上理解为什么会这样:换句话说,为什么 sum 在这种情况下表现得像一个 Maybe Ints 到 Ints 的转换器,而不是添加东西?

我已经解决了一些依赖辅助函数的类似练习,以避免对 Maybe 值进行计算的复杂性,也许在这种情况下我应该只计算 total 的值而不使用 square,即:

total = sum [2^n | n <- [0..63]]

然而,由于我已经编写了一个有用的函数,所以我的第一直觉是重用它,这导致了一些无法预料的行为。

我们来看一个例子。

map square [0..2] = [Nothing, Just 1, Just 2]
fmap sum (map square [0..2]) = [sum Nothing, sum (Just 1), sum (Just 2)]

由于Maybe是一个Foldable容器,计算其元素的总和是有意义的:

sum Nothing = 0
sum (Just a) = a

所以

fmap sum (map square [0..2]) = [0, 1, 2]

现在我不知道你实际上希望用 Maybe 做什么,但这就是你得到你得到的东西的原因。

我们来看看sum的类型:

sum :: (Foldable t, Num a) => t a -> a

通常,对于初学者来说,这可以通过假设 t ~ [] 来简化,因此我们使用 sum 作为

sum :: Num a => [a] -> a

如果我们尝试在您的示例中使用此类型的 sum,我们将收到类型错误,因为您有一个 Maybe 数字列表,而不是数字列表。相反,您编写 fmap sum [Just 1],将 sumfmap 专门化为:

sum :: Maybe Integer -> Integer
fmap :: (Maybe Integer -> Integer) -> [Maybe Integer] -> [Integer]

所以问题实际上不是“为什么 sum 不添加东西”,而是“当给定一个 Maybe Integer 时,sum 怎么能有一个有意义的定义?”。 =31=]

如果您不熟悉如何将 sum 解释为 FoldableMaybe 如何折叠,则回答该问题的一种方法是尝试实施它自己。实际上只有一种合理的实现方式:

sum :: Maybe Integer -> Integer
sum Nothing = 0
sum (Just x) = x

对吧?有人问你“这里的数字总数是多少”,然后给了你 0 个或 1 个数字。很容易加起来。这正是 sum 为 Maybe 工作的方式,除了它通过 Foldable 而不是专用于 Maybe。

在此之后,当然很简单:您已经将 [Maybe Integer] 变成了 [Integer],当然 summing 得到了非 Nothing 个条目。

值得内化的一件事;当您将函数映射到列表1 时,结果是总是 将成为具有相同数量元素的列表。该函数将分别应用于列表的每个元素;它不能将它们组合成一个汇总值。这不是 fmap 所做的。

因此,考虑到该原则,我们知道 fmap sum squares(其中 squares = map square [1..64])不可能得出 squares 的总和。它将是 [ sum (square 1), sum (square 2), ... , sum (square 64) ]。如果我们真的想把它们加起来,我们将需要再次对整个列表应用 sum

这只是解释了 sum (square 1) 等实际上在做什么(即 sum 在应用于 Maybe 值时做了什么)。 sum 的正确类型是 sum :: (Foldable t, Num a) => t a -> aFoldable 基本上是结构的类型-class,您可以按顺序扫描 0 个或多个元素(本质上:那些可以转换为列表的元素)。 sum 所做的只是将存在的元素相加,无论元素有多少(如果没有元素,则使用 0 作为“起始值”)。 Maybe 有一个 Foldable 实例;它总是有 0 或 1 个元素,并且 sum 可以将这些元素相加,就像它可以将恰好只有 0 或 1 个元素的列表相加一样。

当然,效果 对零或一个数字求和只是如果输入为零,结果为 0,如果输入为 1,则结果等于输入数字。 + 这个“总和”实际上从来没有被调用过,这让它感觉有点毫无意义。但是 sum 不知道;它适用于任何 Foldable,无论它们包含多少元素。而Maybe并不知道它最终会被用来“求和而不实际添加”;它只是实现了 Foldable 以便任何其他想要从结构中扫描可变数量元素的函数都可以扫描 Maybes.

如果您认为这很愚蠢,请不要将 sum 用于该工作;请改用 fromMaybe 0


1 您可以将其推广到列表之外的其他函子;当您 fmap 一个数据结构上的函数时,结果将具有与输入完全相同的结构。只有“叶节点”会有所不同。

首先,

中的fmap
total = sum (fmap sum (map square [1..64]))

只是 map:

total = sum ( map sum (map square [1..64]))

map通过组合它们的映射函数来组合,

total = sum ( map (sum . square)  [1..64] )

并且 map 将其函数应用于列表的每个元素,

total = sum [sum (square x) | x <- [1..64] ]

虽然组合函数分步查找结果,

total = sum [sum y    | x <- [1..64], y      <- [square x] ]

而内部 sum 正在处理 square 的结果,即 Maybe 数字,

total = sum [    n    | x <- [1..64], Just n <- [square x] ]
      = sum [    n    | x <- [1..64], Just n <- list (square x) ]
                                          where list y = [y]

并将 sum y 变成 n 是可行的,因为 sum 找到了一个和,而 0 是它的标识,所以添加一个 0 是相同的因为根本不添加任何东西。意思是,Just n 模式匹配 失败 square 产生 Nothing (在我们的例子中是 never,但没关系)所以 that x 导致它被 跳过 .

total = sum [    n    | x <- [1..64], n <- maybeToList (square x) ]
      = sum [    n    | x <- [1..64], n <- case (square x) of
                                                    Just n -> [n]
                                                    Nothing -> [] ]

但是,我们知道这永远不会发生,所有这些相同号码的重新包装都是徒劳的,所以最后你的代码相当于

total = sum [    n    | x <- [1..64], Just n <- [Just (2^(x-1))] ]
      = sum [    n    | x <- [1..64],      n <- [      2^(x-1) ] ]
      = sum [ 2^(x-1) | x <- [1..64] ]

如您所愿。

眼见为实(好吧,记住/想象,这在使用高阶函数时是必需的……当然除非一个人有清晰的记忆,所以对他们来说相信 看到)。它甚至可能为我们提供进一步简化代码、进行一些转换,甚至最终可能进行一些计算优化的新想法:

total = sum    [ 2^x | x <- [0..63] ]
      = sum    [ product (replicate x 2) | x <- [0..63] ]
      = sum $  [ product (replicate 0 2) ] 
            ++ [ product (replicate x 2) | x <- [1..63] ]
      = sum $  [ product [] ] 
            ++ [ 2 * product (replicate (x-1) 2) | x <- [1..63] ]
      = sum $  [ 1 ] 
            ++ [ 2 * product (replicate x     2) | x <- [0..62] ]
      = sum [1] + sum ( [2*n0] ++
               [ 2 * product (replicate x     2) | x <- [1..62] ] )
           where { n0=1 }
      = n0 + sum [n1] + sum( [2*n1] ++
               [ 2 * product (replicate x     2) | x <- [2..62] ] )
           where { n0=1 ; n1=2*n0 }
      = n0 + n1 + n2 + sum( [2*n2] ++
               [ 2 * product (replicate x     2) | x <- [3..62] ] )
           where { n0=1 ; n1=2*n0 ; n2=2*n1 }
      = ....
      = sum . take 64 $ iterate (2*) 1

它们又出现了,棋盘的 64 个方格,以及第一粒米粒,它翻了一番,又翻了一番,就像我们之前沿着这些方格走的那样。