将数字函数作为Num的实例?

11

我希望能够在Haskell中使用二进制运算符组合数字函数。因此,例如,对于一元数字函数:

f*g

应该翻译为:

\x -> (f x)*(g x)

同样地,加法也可以这样做。制作自己的运算符相当简单,但我真的很想将 Num a => a -> a 函数实例化为 Num,但我不确定如何做到这一点。

我还希望使这个元数通用,但在Haskell中编写通用函数的困难程度可能太大了,因此最好定义单独的 Num a => a -> a -> aNum a => a -> a -> a -> a等实例,直到某个相当大的数字为止。


2
如果你为 Num a, Num b => a -> b 定义了一个 Num 的实例,你就不必担心多个元数的实例问题。 - genisage
2个回答

15

惯用方法

(->) a有一个Applicative实例,这意味着所有函数都是可应用函子。组合任何函数的现代惯用方法是使用Applicative,像这样:

(*) <$> f <*> g
liftA2 (*) f g -- these two are equivalent

这使操作更加清晰。这两种方法都稍微冗长一些,但在我看来,它们更清楚地表达了组合模式。
此外,这是一种更通用的方法。如果你理解了这个习惯用法,你就能够将它应用到许多其他情境中,而不仅仅是Num。如果你不熟悉Applicative,可以从Typeclassopedia开始学习。如果你有理论倾向,可以查看McBride和Patterson的著名文章。(为记录,我在这里使用“习惯用法”一词,但也意识到了双关语。)

Num b => Num (a -> b)

你想要的实例(以及其他实例)都可以在NumInstances包中找到。你可以复制@genisage发布的实例;它们在功能上是相同的。 (@genisage更明确地写出了它,比较这两个实现可能会很有启发性。) 导入Hackage上的库的好处是可以为其他开发者突出显示你正在使用一个孤立的实例。
然而,Num b => Num (a -> b)存在一个问题。简而言之,2现在不仅是一个数字,而且还是一个具有无限个参数的函数,它忽略了所有这些参数。2(3 + 4)现在等于2。任何将整数文字用作函数的使用几乎肯定会得到意外和不正确的结果,并且除了运行时异常外,没有办法警告程序员。
正如2010 Haskell Report第6.4.1节所述,“整数文字表示对适当类型的值应用函数fromInteger。”这意味着在你的源代码或GHCi中写212345等同于写fromInteger 2fromInteger 12345。因此,任一表达式都具有类型Num a => a
因此,fromInteger 在 Haskell 中是绝对普及的。通常情况下,这非常好用;当你在源代码中写入一个数字时,你得到的就是一个适当类型的数字。但是对于函数的 Num 实例,fromInteger 2 的类型很可能是 a -> Integera -> b -> Integer。实际上,GHC 会愉快地将文字 2 替换为一个函数,而不是一个数字,而且还是一个特别危险的函数,它会丢弃任何给它的数据。(fromInteger n = \_ -> nconst n;即抛弃所有参数,只给出 n。)
通常情况下,你可以不实现不适用的类成员,或者用 undefined 实现它们,两种方法都会产生运行时错误。出于同样的原因,这也不是解决当前问题的方法。
更明智的实例:伪 Num a => Num (a -> a) 如果你愿意限制自己只使用类型为 Num a => a -> a 的一元函数进行乘法和加法,我们可以在一定程度上改善 fromInteger 的问题,或者至少使 2 (3 + 5) 等于 16 而不是 2。答案就是将 fromInteger 3 定义为 (*) 3 而不是 const 3
instance (a ~ b, Num a) => Num (a -> b) where
  fromInteger = (*) . fromInteger
  negate      = fmap negate
  (+)         = liftA2 (+)
  (*)         = liftA2 (*)
  abs         = fmap abs
  signum      = fmap signum

 

ghci> 2 (3 + 4)
14
ghci> let x = 2 ((2 *) + (3 *))
ghci> :t x
x :: Num a => a -> a
ghci> x 1
10
ghci> x 2
40

请注意,尽管这可能在道德上等同于Num a => Num (a -> a),但必须使用等式约束(需要GADTsTypeFamilies)来定义它。否则,对于像(2 3) :: Int这样的内容,我们将会得到模棱两可的错误。我不想解释为什么,抱歉。基本上,等式约束a ~ b => a -> b允许在推断期间将b的推断或声明类型传播到a
有关此实例如何工作以及原因的详细解释,请参见Numbers as multiplicative functions (weird but entertaining)中的答案。
孤立实例警告
请勿公开任何包含以下任一实例的模块,或导入包含以下任一实例的模块,或导入包含以下任一实例的模块的模块......,除非您了解孤立实例问题并相应地警告用户。

这里似乎是本末倒置了。在数学中,对于函数和其他结构,点对点地定义算术运算符是非常常见的。 - idontgetoutmuch
第二个编码并不像人们期望的那样工作:2 3 5 被计算为 (\n -> (2 * n) * (3 * n)) 5,因此等于 150 而不是 30 - effectfully

8

具有通用性的元数实例

instance Num b => Num (a->b) where
    f + g = \x -> f x + g x
    f - g = \x -> f x - g x
    f * g = \x -> f x * g x
    negate f = negate . f
    abs f = abs . f
    signum f = signum . f
    fromInteger n = \x -> fromInteger n

编辑:正如Christian Conkle所指出的,这种方法存在问题。如果您计划将这些实例用于任何重要事项或只是想了解问题,您应该阅读他提供的资源并自行决定是否符合您的需求。我的意图是提供一种简单的方式,使用自然符号来玩弄数字函数,并尽可能简单地实现。


对于允许任意元数的目的而言,a上的限制是不必要的。实际上,它比没有限制更不灵活:例如,您无法组合两个类型为String -> Int的函数。 - Christian Conkle
谢谢你发现了这个。 - genisage
1
经过进一步的思考,我越来越确信这种方法存在严重缺陷。如果你需要进行大量算术运算,以至于与使用“Applicative”相比减少了行噪声,那么混淆和/或意外使用“fromIntegral”的风险似乎太大了。 - Christian Conkle
@ChristianConkle 关于孤立实例的部分确实令人担忧,但如果你担心意外使用 fromIntegral,只需添加一个更易识别的函数,比如 toFunc = fromInteger - genisage
1
这不是问题所在。问题在于编译器使用fromInteger来解析所有整数字面值。现在突然间,编译器将开始推断出2 :: Num b => a -> b,这可能会导致错误的行为和/或难以理解的错误。 - Christian Conkle
我想指出的是,如果您查看NumInstances包的源代码,代码与我提供的完全相同,只是他们使用了一些等效的抽象,而我认为这只会使它更加混乱。我可以同意,在大多数情况下,Num实例不是处理函数操作的最佳方式,但这是OP要求的,所以我提供了一个。 - genisage

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接