【问题标题】:Is there a nice way to make function signatures more informative in Haskell?有没有一种很好的方法可以让函数签名在 Haskell 中提供更多信息?
【发布时间】:2012-09-07 03:48:15
【问题描述】:

我意识到这可能被认为是一个主观的问题,或者可能是一个离题的问题,所以我希望与其关闭它,不如将它迁移给程序员。

我开始学习 Haskell,主要是为了我自己的启迪,我喜欢很多支持该语言的想法和原则。在参加了一个语言理论课后,我开始对函数式语言着迷,我们在那里玩了 Lisp,而且我听到了很多关于 Haskell 高效的好消息,所以我想我自己去研究一下。到目前为止,我喜欢这门语言,除了一件我无法摆脱的事情:那些母亲 effing 函数签名。

我的专业背景主要是做 OO,尤其是 Java。我工作过的大多数地方都遵循了许多标准的现代教条。敏捷、清洁代码、TDD 等。经过几年的工作,它绝对成为了我的舒适区;特别是“好”代码应该是自我记录的想法。我已经习惯了在 IDE 中工作,其中带有非常描述性签名的冗长冗长的方法名称对于智能自动完成和用于导航包和符号的大量分析工具来说不是问题;如果我可以在 Eclipse 中按 Ctrl+Space,然后通过查看方法的名称和与其参数关联的本地范围变量来推断方法正在做什么,而不是拉起 JavaDocs,我就像一头猪一样高兴。

这显然不是 Haskell 社区最佳实践的一部分。我已经阅读了很多关于这个问题的不同意见,我知道 Haskell 社区认为它的简洁性是一个“专业人士”。我经历过How To Read Haskell,我理解很多决定背后的基本原理,但这并不意味着我喜欢它们;一个字母的变量名等对我来说并不有趣。我承认,如果我想继续使用这种语言,我必须习惯这一点。

但我无法克服函数签名。举个例子,取自Learn you a Haskell[...]'s section on function syntax:

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!"

我意识到这是一个愚蠢的例子,它只是为了解释守卫和类约束而创建的,但是如果您要检查 只是该函数的签名,您将不知道它的哪个论点旨在成为重量或高度。即使您使用FloatDouble 代替任何类型,它仍然无法立即辨别。

起初,我以为我会很可爱、聪明、聪明,并尝试使用具有多个类约束的更长类型变量名来欺骗它:

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'

不完全理解为什么这不起作用,我开始在谷歌上搜索,我什至发现了这个建议命名参数的小帖子,具体来说,spoofing named parameters via newtype,但这似乎有点多。

没有可接受的方法来制作信息丰富的函数签名吗? “Haskell 方式”只是为了把所有事情都搞砸了吗?

【问题讨论】:

  • 顺便说一句,〜在类型级别表示“等于”,所以错误是说身高和体重不是同一类型,除法运算符要求其操作数相同类型。
  • @hzap 这很有意义。谢谢。
  • 我使用 Haskell 的次数越多,我就越欣赏“通过newtype 欺骗命名参数”的方法。特别是对于这种width/height 问题。它使代码更清晰、更易于维护,并有助于维度分析。如果你做对了,样板文件就会减少,类型签名会更有用,并且错误会更少。
  • 您可以采用 TypeFamilies 的方式并将类型相等添加到您的约束中。我不认为这种方式是必要的,或者更清楚,但你可以(RealFloat weight, weight ~ height) =&gt; ...

标签: haskell syntax coding-style functional-programming


【解决方案1】:

类型签名不是 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 cmets。


请注意,在像 Haskell 那样具有强大且富有表现力的类型系统的语言中,首选的做法是尝试使尽可能多的错误作为类型错误可检测到。因此,像bmiTell 这样的函数会立即向我发出警告信号,原因如下:

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

为了提高类型安全性而经常做的一件事就是创建新类型,正如您在链接中找到的那样。我真的不认为这与命名参数传递有太大关系,更多的是关于创建一个明确表示 height 的数据类型,而不是您可能想用数字。所以我不会让 newtype 值只出现在通话中;无论我在哪里获得高度数据 from ,我都会使用 newtype 值,并将它作为高度数据而不是数字传递,这样我就可以获得类型安全(和文档)的好处到处。只有当我需要将值传递给对数字而不是高度进行运算的东西(例如 bmiTell 中的算术运算)时,我才会将值解包为原始数字。

请注意,这没有运行时开销; newtypes 的表示方式与 newtype 包装器“内部”的数据相同,因此 wrap/unwrap 操作在底层表示上是无操作的,并且在编译期间被简单地删除。它只在源代码中添加了额外的字符,但这些字符正是您正在寻找的文档,并具有由编译器强制执行的额外好处; Java 风格的签名告诉你哪个参数是重量,哪个是高度,但编译器仍然无法判断你是否不小心将它们传递给错误的方式!

【讨论】:

  • +1 很好的解释——为什么我们不能像 Java 那样做,为什么类型安全是要走的路——可读性是更重要的正确性的副作用——Doug 担心他可能不会Ben 说,一定要确定哪个方向,让编译器强制执行正确的方向。很棒。
  • +1 因为当我在大学学习 Haskell 时,我讨厌它 - 但我不仅理解并喜欢您的解释,我还真的有一刻觉得自己爱上了 Haskell。
  • @Ben 起初我对上面所说的 newtype 技巧犹豫不决,但看到你这样做的理由不仅使签名冗长,而且似乎还引入了额外级别的类型安全就像一个很好的论点,也是我喜欢的论点。我仍在适应语言的简洁性。我工作的实验室的原则之一是代码应该非常像英语,以至于任何脱离上下文的随机行都应该被非编码人员理解,并且可读性比正确更重要。我必须承认,我在 Haskell 中永远不会有那种冗长程度。
  • @DougStephen:作为一名使用 C# 多年的工业程序员,学术背景有限,在空闲时间学习 Haskell 是为了好玩——这些完全令人难以置信 对我来说,在评论中列出的实用编码指南太多了。让代码读起来像英语? 真的吗?我们没有从之前的错误中学到任何东西吗?
  • @DougStephen:是的,此类指南的根本错误是认为它们实际上以一种有意义的方式使代码更易于理解。他们没有。无论如何,英语都不足以准确描述非平凡的代码,而且较长的名称在阅读代码时具有更高的认知负荷。更不用说 90% 的完整描述通常比 10% 的描述更容易产生误导,因为很难注意到它所做的比它声称的要多。对不起,他们真的很糟糕。
【解决方案2】:

还有其他选择,具体取决于您希望自己的类型变得多么愚蠢和/或迂腐。

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

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 扩展,您甚至可以获得自动实例,即使对于通常无法派生的类型类也是如此。

【讨论】:

  • 在你打开 GeneralizedNewtypeDeriving 之前,我一直和你在一起。还是投了赞成票。
  • @JohnL:我不喜欢那个扩展,我接受吗?对于用作美化类型同义词的newtypes,这对我来说似乎是合理的......
  • @MichaelLitchard 我插入我自己的博客:joyoftypes.blogspot.com/2012/08/…
  • 我认为新类型不应该是“重量”,而是一个合适的单位,比如“公斤”。这样你就可以准确地知道它应该包含什么,当你将它与其他单位组合时你会得到什么,你可以自然地添加一个距离和一个长度。这可能听起来像吹毛求疵,但想想像“睡眠(时间 a)”之类的东西。时间是分、秒还是其他单位?
  • @JohnL:如果给每个扩展分配一个从 0 到 1 的值,这样 1 是纯善的,0 是完全恶毒的,IncoherentInstances 将位于大约 -0.28+0.96i .
【解决方案3】:

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 是同一类型,即使它们必须是相同的类型(也就是说,您提出的类型签名不够严格,并且会允许对函数的无效使用)。

【讨论】:

  • 明白了。我认为我的大脑仍在努力区分类型和类型类的想法。但现在我看到它是这样说的。
  • +1 表示Haddock individual parameters。虽然我仍然会推荐 newtype 方法。
【解决方案4】:

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 确实使用了一个非常不同的范例,你必须小心不要假设在另一种语言中重要的东西在这里很重要。当你在舒适区之外时,你学习得最多。这就像来自伦敦的人出现在多伦多并抱怨这座城市令人困惑,因为所有街道都是一样的,而来自多伦多的人可能会声称伦敦令人困惑,因为街道上没有规律。你所说的混淆被Haskellers称为清晰。

如果你想回到更加面向对象的清晰目标,那么让 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 中明确表达的一种方式。我真的不相信您使用 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

缺乏类型安全性,而且

newtype FilePath = FilePath String

或者更聪明的东西会是一个更好的主意。有关类型安全的非常重要的观点,请参阅 Ben 的回答。

[*] 好的,你可以在 ghci 中做 :t 并获得类型签名而不需要参数名称,但 ghci 是用于源代码的交互式开发。你的库或模块不应该保持无文档和hacky,你应该使用非常轻量级的语法haddock文档系统并在本地安装haddock。您投诉的更合理版本是没有 :v 命令打印您的函数 bmiTell 的源代码。指标表明,针对相同问题的 Haskell 代码会缩短一个因子(与等效的 OO 或非 oo 命令式代码相比,我发现在我的情况下大约有 10 个),因此在 gchi 中显示定义通常是明智的。我们应该提交功能请求。

【讨论】:

  • 赞成,因为 Person 记录是更好的方法,恕我直言。即使您引入了新类型来区分身高和体重,爱丽丝的体重除以理查德的身高也不是 BMI。
  • 感谢@Zopa 的编辑/更正。在有人奇怪地拒绝它后,我恢复了它。
  • 我喜欢你的解释,但觉得“更好的方法”是记录 newtype - 这样,你们俩 1) 关联重量/同一个人的身高; 2) 使用类型系统确保您使用的是重量和高度,而不仅仅是两个任意的 Floating/RealFloat 类型的数字。
  • @BMeph 我同意。 OP 让我相信 newtype 是不需要的,我应该说明他们的情况,但 Ben 做得非常好。他是一个很好的答案 - 如果你没有,请投票!是的,记录类型+新类型很好。 MathematicalOrchid 也对长长的参数列表提出了很好的观点。我认为这里有很多很好的答案。
【解决方案5】:

试试这个:

type Height a = a
type Weight a = a

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

【讨论】:

    【解决方案6】:

    可能与带有两个参数的函数无关,然而...如果您有一个函数需要大量参数,类型相似或顺序不明确,它可能值得定义一个代表它们的数据结构。例如,

    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 中的 :i 下看到。打钩。如果你错过了可选参数,你甚至可以做一些。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2012-08-08
    • 2013-03-16
    • 1970-01-01
    • 2011-12-08
    • 1970-01-01
    • 2017-01-08
    • 2021-09-30
    相关资源
    最近更新 更多