在Haskell中,有没有一种好的方法使函数签名更加信息丰富?

55
我意识到这可能被认为是一个主观的或者离题的问题,所以我希望它不会被关闭,而是被迁移,也许到程序员网站。
我开始学习Haskell,主要是为了自己的启发,我喜欢很多支持这种语言的思想和原则。在上一门语言理论课程中,我们玩过Lisp,我对函数式语言产生了浓厚的兴趣。我一直听说Haskell可以提高生产力,所以我决定自己探究一下。到目前为止,我喜欢这种语言,除了一件事情,那就是这些该死的函数签名。
我的专业背景主要是面向对象编程,尤其是Java。我工作的大多数地方都深入推崇现代标准教条,如敏捷开发、代码整洁、测试驱动开发等。在这样的方式下工作了几年后,它已经成为了我的舒适区;特别是"好"的代码应该是自文档化的这个想法。我已经习惯了在集成开发环境中工作,在那里长而冗长的方法名和非常描述性的签名不是问题,因为有智能自动补全和大量分析工具来浏览包和符号;如果我可以在Eclipse中按Ctrl+空格键,然后从方法的名称和与其参数相关联的本地作用域变量推断出它在做什么,而不是查看JavaDocs,那么我就会非常高兴。
这明显不是Haskell社区最佳实践的一部分。我阅读了很多不同意见,我理解Haskell社区认为简洁性是一个优点。我查看了如何阅读Haskell,我理解了很多决策背后的原理,但这并不意味着我喜欢它们;单个字母的变量名等对我来说不是很有趣。我承认如果我想继续使用这种语言进行编程,我必须适应这种情况。
但我无法接受函数签名。例如,从学习Haskell[...]的函数语法部分引用以下示例:
bmiTell :: (RealFloat a) => a -> a -> String  
bmiTell weight height  
    | weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"  
    | weight / height ^ 2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"  
    | weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"  
    | otherwise                   = "You're a whale, congratulations!"

我知道这只是一个愚蠢的例子,只是为了解释守卫和类约束。但如果你仅仅看这个函数的签名,你就不知道哪个参数是重量或高度。即使你使用Float或Double代替任何类型,也不会立即可辨。
起初,我认为我会很聪明地使用更长的类型变量名称和多个类约束来欺骗它。
bmiTell :: (RealFloat weight, RealFloat height) => weight -> height -> String

这个命令出现了一个错误(顺便提一下,如果有人能够解释这个错误,我会非常感激):
Could not deduce (height ~ weight)
    from the context (RealFloat weight, RealFloat height)
      bound by the type signature for
                 bmiTell :: (RealFloat weight, RealFloat height) =>
                            weight -> height -> String
      at example.hs:(25,1)-(27,27)
      `height' is a rigid type variable bound by
               the type signature for
                 bmiTell :: (RealFloat weight, RealFloat height) =>
                            weight -> height -> String
               at example.hs:25:1
      `weight' is a rigid type variable bound by
               the type signature for
                 bmiTell :: (RealFloat weight, RealFloat height) =>
                            weight -> height -> String
               at example.hs:25:1
    In the first argument of `(^)', namely `height'
    In the second argument of `(/)', namely `height ^ 2'
    In the first argument of `(<=)', namely `weight / height ^ 2'

由于不完全理解为什么它没有起作用,我开始在谷歌上搜索,甚至找到了这篇小文章,建议使用命名参数,具体来说是通过newtype欺骗命名参数, 但那似乎有点过头了。

是否没有可接受的方法来构建信息丰富的函数签名?“Haskell Way”难道只是在每个地方都要加上注释吗?


7
关于你提到的,符号“~”在类型层面上意味着“等于”,因此该错误提示表明身高和体重不是相同类型,而除法运算符要求其操作数具有相同的类型。 - hzap
@hzap,那很有道理。谢谢。 - Doug Stephen
6
我越多使用 Haskell,就越欣赏“通过 newtype 模拟命名参数”的方法。特别是对于这种 宽度/高度 问题。它使代码清晰易懂、更易于维护,并且强烈有助于尺寸分析。如果你做得对,样板代码很少,类型签名更有用,而且错误更少。 - John L
2
你可以使用TypeFamilies的方式,将类型相等性添加到你的约束中。我不认为这种方式是必要的或更清晰的,但你可以这样做。(RealFloat weight, weight ~ height) => ... - Sarah
6个回答

81

类型签名不是Java风格的签名。Java风格的签名会将参数名称与参数类型混合在一起,从而只能告诉你哪个参数是重量,哪个参数是高度。Haskell通常无法这样做,因为函数是使用模式匹配和多方程式定义的,例如:

map :: (a -> b) -> [a] -> [b]
map f (x:xs) = f x : map f xs
map _ [] = []

这里,第一个参数在第一个方程中被命名为f,而在第二个方程中则被命名为_(这基本上意味着“未命名”)。第二个参数在任何一个方程中都没有名称;在第一个方程的前部分有名称(程序员可能会认为它是“xs列表”),而在第二个方程中它是一个完全的文字表达式。

然后还有像无点定义这样的:

concat :: [[a]] -> [a]
concat = foldr (++) []

这个函数的类型签名告诉我们它接受一个类型为[[a]]的参数,但是系统中任何地方都没有出现这个参数的名称。

在函数的单个等式之外,它用来引用其参数的名称无论如何都不重要,除了作为文档。由于在Haskell中,“规范名称”的概念并不清晰,因此关于“bmiTell的第一个参数代表体重,第二个参数代表身高”的信息应该在文档中而不是在类型签名中。

我完全同意,一个函数所做的事情应该从其可获得的“公共”信息中清晰明了。在Java中,这是函数的名称、参数类型和名称。如果(通常)用户需要更多信息,您可以在文档中添加它。在Haskell中,有关函数的公共信息是函数的名称和参数类型。如果用户需要更多信息,则在文档中添加它。请注意,对于Haskell的IDE(如Leksah),可以轻松显示Haddock注释。


请注意,在具有强大且表达能力强的类型系统(如Haskell)的语言中,通常最好尝试将尽可能多的错误检测为类型错误。因此,像bmiTell这样的函数立即引起我的警惕,原因如下:

  1. 它接受表示不同事物的两个相同类型的参数
  2. 如果传递参数顺序错误,它将执行错误的操作
  3. 这两种类型没有自然位置(就像++的两个[a]参数一样)

为增加类型安全性,经常会使用创建新类型的方式,就像你找到的链接中所示。我认为这与命名参数传递没有多大关系,更多地是关于创建一个表示高度的数据类型,而不是想用数字测量其他任何量。因此,在哪里获取高度数据,我都会使用新类型值,而不仅仅在调用时出现,将其作为高度数据传递而不是作为数字传递,以获得类型安全性(和文档说明)的好处。只有当需要将其传递给针对数字而非高度进行操作的事物时,例如bmiTell内部的算术运算,我才会将该值展开为原始数字。

请注意,这不会产生运行时开销;新类型与新类型包装器“内部”的数据表示相同,因此包装/解包操作在底层表示上是无操作的,并且在编译期间被简单地移除。它只是在源代码中添加了一些额外的字符,但这些字符恰好是您寻求的文档说明,具有由编译器强制执行的附加好处。Java样式的签名告诉您哪个参数是重量,哪个是高度,但如果您意外地交换了它们,编译器仍然无法发现。


13
优秀的解释,点赞!为什么我们不能按照Java的方式来做,为什么类型安全是正确的选择,可读性只是更重要的正确性的副作用。Doug担心自己可能不确定哪个方向是正确的,Ben建议让编译器强制执行正确的方式。太棒了! - AndrewC
4
因为在大学里学Haskell时,我非常讨厌它,但通过你的解释,不仅理解了它,而且还喜欢上了Haskell,我甚至觉得自己曾经短暂地爱上了它。+1 - m-smith
12
作为一名在工业界使用C#多年、拥有有限学术背景并在空余时间里学习Haskell的程序员,我必须说这些准则对于实际编码简直是糟糕到无以复加的程度。让代码读起来像英语?真的吗?我们没有从之前的错误中学到任何东西吗? - C. A. McCann
10
是的,这些指南中的根本错误在于认为它们确实以有意义的方式使代码更容易理解。它们并不能。英语不够精确,无法准确描述非平凡的代码,而较长的名称在阅读代码时会增加认知负荷。更不用说90%完整的描述往往比10%的更具误导性,因为很难注意到它正在执行比它声称要做的更多的操作。很抱歉,但它们真的很糟糕。 - C. A. McCann
1
@DougStephen:我说的更多是关于C#而不是Haskell,从涉及大量用户界面和数据操作逻辑的“平凡”行业编码的角度出发,这受到我多年来所接触的各种代码库的启发。正如你可能已经注意到的那样,Haskell代码往往会在其他方向上出现偏差。:] - C. A. McCann
显示剩余4条评论

36

根据你对类型的愚蠢和/或过分追求,还有其他可选项。

例如,你可以这样做...

type Meaning a b = a

bmiTell :: (RealFloat a) => a `Meaning` weight -> a `Meaning` height -> String  
bmiTell weight height = -- etc.

...但这非常愚蠢、可能会产生混淆,并且在大多数情况下没有帮助。对于这种情况,同样适用的是需要使用语言扩展:

bmiTell :: (RealFloat weight, RealFloat height, weight ~ height) 
        => weight -> height -> String  
bmiTell weight height = -- etc.

稍微更明智的做法是这样的:

type Weight a = a
type Height a = a

bmiTell :: (RealFloat a) => Weight a -> Height a -> String  
bmiTell weight height = -- etc.

... 但这还有点傻,而且在 GHC 展开类型同义词时往往会丢失。

真正的问题在于您正在将附加语义内容附加到相同多态类型的不同值上,这与语言本身的规范相违背,因此通常不是惯用法。

当然,一种选择是仅处理不具信息量的类型变量。但如果两个相同类型的东西之间存在重要区别,而这种区别从它们给定的顺序并不明显,那么这种选择就不太令人满意。

我建议您尝试使用 newtype 包装器来指定语义:

newtype Weight a = Weight { getWeight :: a }
newtype Height a = Height { getHeight :: a }

bmiTell :: (RealFloat a) => Weight a -> Height a -> String  
bmiTell (Weight weight) (Height height)

我认为这种做法远不如应有的普及。虽然需要多打一点字(哈哈),但不仅可以使您的类型签名在展开类型同义词时更具信息性,而且还可以让类型检查器捕获错误使用重量作为高度等的情况。通过 GeneralizedNewtypeDeriving 扩展,您甚至可以获得对通常无法派生的类型类的自动实例。


3
@JohnL:你对那个扩展程序不感冒吗?对于被用作夸张类型同义词的newtype,我觉得这似乎是合理的... - C. A. McCann
1
是的,只要不将其与GADTs或TypeFamilies混合使用,我认为GeneralizedNewtypeDeriving就可以了。 - Erik Hesselink
4
@MichaelLitchard 我推荐一下我的博客:http://joyoftypes.blogspot.com/2012/08/generalizednewtypederiving-is.html。 - Philip JF
2
我认为新类型不应该是“Weight”,而应该是一个适当的单位,比如“kg”。这样你就知道它应该包含什么,当你将其与其他单位组合时会得到什么,你也可以自然地添加距离和长度。这可能听起来像吹毛求疵,但想想“sleep(Time a)”这样的东西。时间是以分钟、秒还是其他什么形式表示的呢? - Gurgeh
3
如果将每个扩展分配一个从0到1的值,其中1表示完全善意,0表示完全邪恶,“IncoherentInstances”大约位于-0.28+0.96i左右。 - C. A. McCann
显示剩余7条评论

26

通过查看 Haddocks 和函数方程式(您绑定事物的名称)来了解正在发生的事情。您可以单独为每个参数编写 Haddock,像这样:

bmiTell :: (RealFloat a) => a      -- ^ your weight
                         -> a      -- ^ your height
                         -> String -- ^ what I'd think about that

因此,它不仅仅是一堆解释所有东西的文本。

你的可爱类型变量无法工作的原因是因为你的函数是:

(RealFloat a) => a -> a -> String

但是你尝试的更改:

(RealFloat weight, RealFloat height) => weight -> height -> String

等同于这个:

(RealFloat a, RealFloat b) => a -> b -> String

因此,在这个类型标识中,你已经表示前两个参数有不同的类型,但是GHC已经确定(基于你的使用)它们必须具有相同的类型。所以它抱怨无法确定weightheight是否是相同的类型,即使它们必须是相同的(也就是说,你提议的类型标识不够严格,将允许函数的无效使用)。


明白了,我想我的大脑还在努力区分类型和类型类的概念。但是现在看到这样表述,这就有意义了。 - Doug Stephen
2
虽然我仍然建议使用 newtype 方法,但对于 Haddock individual parameters,请点赞。 - leftaroundabout

13

weight必须与height的类型相同,因为您正在将它们除以(不允许隐式转换)。weight ~ height表示它们是相同类型。 GHC详细解释了为什么需要weight ~ height,很抱歉。您可以使用类型族扩展中的语法告诉它/您想要的内容:

{-# LANGUAGE TypeFamilies #-}
bmiTell :: (RealFloat weight, RealFloat height,weight~height) => weight -> height -> String
bmiTell weight height  
  | weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"  
  | weight / height ^ 2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"  
  | weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"  
  | otherwise                   = "You're a whale, congratulations!"

然而,这也不是理想的选择。你必须记住,Haskell使用非常不同的编程范式,不能假设在其他语言中重要的东西在这里也很重要。当你走出舒适区时,才会学到最多。就像来自伦敦的人去多伦多,抱怨城市很混乱,因为所有的街道都一样;而多伦多的人可能会认为伦敦很混乱,因为街道没有规律。你所谓的晦涩难懂,在Haskeller看来却是清晰易懂。
如果你想回到更加面向对象的目的明确性,那么让bmiTell仅针对人员工作,如下:
data Person = Person {name :: String, weight :: Float, height :: Float}
bmiOffence :: Person -> String
bmiOffence p
  | weight p / height p ^ 2 <= 18.5 = "You're underweight, you emo, you!"  
  | weight p / height p ^ 2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"  
  | weight p / height p ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"  
  | otherwise                   = "You're a whale, congratulations!"

我相信这是在面向对象编程中表达清晰的方式。我真的不认为你使用OOP方法参数的类型来获取此信息,你必须秘密地使用参数名称来获得更清晰的信息,而不是类型,如果你排除了在问题中读取参数名称,期望Haskell告诉你参数名称是不公平的。[见下方*] Haskell中的类型系统非常灵活和强大,请不要因为一开始让你感到陌生就放弃它。

如果您真的想让类型告诉您,我们可以为您做到:

type Weight = Float -- a type synonym - Float and Weight are exactly the same type, but human-readably different
type Height = Float

bmiClear :: Weight -> Height -> String
....

这是处理表示文件名的字符串时使用的方法,因此我们定义:
type FilePath = String
writeFile :: FilePath -> String -> IO ()  -- take the path, the contents, and make an IO operation

这能够满足你想要的清晰度。然而,人们认为

type FilePath = String

缺乏类型安全性,而这是IT技术中的一个常见问题。
newtype FilePath = FilePath String

或者更聪明的做法是一个更好的主意。请参见Ben的答案,了解关于类型安全的非常重要的观点。

[*] 好吧,你可以在ghci中执行:t并获得没有参数名称的类型签名,但ghci是用于交互式开发源代码的。您的库或模块不应该保持未记录和hacky状态,您应该使用极其轻量级的语法haddock文档系统并在本地安装haddock。您的抱怨更合理的版本将是没有:v命令打印函数bmiTell的源代码。度量表明,相同问题的Haskell代码将短约10倍(我发现与等效的OO或非oo命令式代码相比),因此在gchi内显示定义通常是明智的。我们应该提交一个功能请求。


4
我会尽力进行翻译,请您确认以下内容是否正确:因为个人记录是更好的方法,所以我点了赞。 即使您已经引入了新类型来区分身高和体重,Alice的体重除以Richard的身高也不是BMI。 - Zopa
感谢@Zopa的编辑/更正。在有人奇怪地拒绝之后,我恢复了它。 - AndrewC
2
我喜欢你的解释,但是我认为“更好的方法”是同时拥有记录和newtype - 这样,您既可以1)将重量/身高与同一人相关联;2)使用类型系统确保您正在使用重量和身高,而不仅仅是两个任意的Floating / RealFloat类型数字。 - BMeph
@BMeph 我同意。原帖让我认为不需要 newtype,我应该表述他们的观点,但是 Ben 做得很好。他的回答非常棒 - 如果你还没有的话,请点赞!是的,记录类型 + newtype 很好。MathematicalOrchid 也提出了关于参数列表过长的好观点。我认为这里有很多好的答案。 - AndrewC

12

试一下这个:

type Height a = a
type Weight a = a

bmiTell :: (RealFloat a) => Weight a -> Height a -> String

12

对于只有两个参数的函数可能不相关,但是……如果您有一个需要大量相似类型或者顺序不明确的参数的函数,定义一个代表它们的数据结构可能是值得的。例如,

data Body a = Body {weight, height :: a}

bmiTell :: (RealFloat a) => Body a -> String

现在你可以编写以下内容:

bmiTell (Body {weight = 5, height = 2})
或者
bmiTell (Body {height = 2, weight = 5})

这样做可以使代码正确无误并且易于被任何人阅读。对于有大量参数的函数而言,这样做更有价值。但如果只有两个参数,我会像其他人一样使用 newtype,以便类型签名记录正确的参数顺序,并在混淆它们时获得编译时错误。


...因此,单独的参数具有名称,在ghci下可见。没错。如果你错过了可选参数,甚至可以将其中一些参数设为Maybe。 - AndrewC

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