首页 文章

有没有一种很好的方法可以让Haskell中的函数签名更具信息性?

提问于
浏览
51

我意识到这可能被认为是一个主观或可能是一个非主题的问题,所以我希望它不会被关闭,而是会被迁移,也许是程序员 .

我开始学习Haskell,主要是为了我自己的教化,我喜欢支持语言的很多想法和原则 . 在参加了我们玩Lisp的语言理论课后,我对函数式语言着迷了,而且我听说过很多关于Haskell有多高效的好东西,所以我想我会自己调查一下 . 到目前为止,我喜欢这种语言,除了一件我无法摆脱的事情:那些母亲正在使用功能签名 .

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

这显然不是Haskell社区最佳实践的一部分 . 我已经完成了How To Read Haskell,我理解了很多决定背后的理由,但它对我来说并不是很有趣 . 我承认,如果我想继续使用该语言,我将不得不习惯这一点 .

但我无法克服功能签名 . 以这个例子为例,从Learn you a 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!"

我意识到这是一个愚蠢的例子,它只是为了解释警卫和类约束而创建的,但是如果你只检查那个函数的签名,你就不知道它的哪个参数意味着权重或高度 . 即使您使用 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之路”只是为了Haddock的废话吗?

6 回答

  • 12

    类型签名不是Java样式的签名 . Java样式的签名将告诉您哪个参数是权重,哪个是高度,因为它将参数名称与参数类型混合在一起 . Haskell不能将此作为一般规则,因为函数是使用模式匹配和多个方程定义的,如:

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

    这里第一个参数在第一个方程中命名为 f ,在第二个方程中 _ (这几乎意味着"unnamed") . 第二个参数在两个等式中都没有名称;在它的第一部分有名称(程序员可能会认为它是"the xs list"),而在第二部分它是一个完全文字的表达 .

    然后是无点定义,如:

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

    类型签名告诉我们它采用 [[a]] 类型的参数,但该参数的名称在系统中不显示 anywhere .

    在函数的单个等式之外,用于引用其参数的名称无论如何都是无关紧要的 except 作为文档 . 由于在Haskell中很好地定义了函数's parameter isn'的"canonical name"的想法,信息的位置“ bmiTell 的第一个参数代表权重而第二个代表高度”在文档中,而不是在类型签名中 .

    我完全同意,功能所做的事情应该从关于它的“公共”信息中清楚地看出来 . 在Java中,这是函数的名称,以及参数类型和名称 . 如果(通常)用户需要更多信息,请将其添加到文档中 . 在Haskell中,有关函数的公共信息是函数的名称和参数类型 . 如果用户需要更多信息,请将其添加到文档中 . 注意Haskell的IDE,如Leksah,很容易向你展示Haddock的评论 .


    请注意,在像Haskell这样具有强大且富有表现力的类型系统的语言中,首选的方法是尝试尽可能多地将错误视为类型错误 . 因此,像 bmiTell 这样的功能会立即向我发出警告标志,原因如下:

    • 它需要两个代表不同内容的相同类型的参数

    • 如果以错误的顺序传递参数,它会做错事

    • 这两种类型没有自然位置(作为 ++ 的两个 [a] 参数)

    通常用于增加类型安全性的一件事就是制作新类型,就像你找到的链接一样 . 我并不认为这与命名参数传递有很大关系,更多的是关于创建一个明确表示 height 的数据类型,而不是您可能想要用数字来衡量的任何其他数量 . 所以我不会只在呼叫时出现newtype值;我将使用newtype值,无论我从哪里得到高度数据,并将其作为高度数据而不是数字传递,以便我在各处获得类型安全(和文档)的好处 . 当我需要将值传递给对数字而不是高度进行操作的东西(例如 bmiTell 中的算术运算)时,我只会将值解包为原始数字 .

    请注意,这没有运行时开销; newtypes与数据"inside" newtype包装器的表示方式相同,因此wrap / unwrap操作在底层表示中是no-ops,并且在编译期间被简单地删除 . 它只在源代码中添加了额外的字符,但这些字符正好是您无法通过错误的方式传递它们的文档!

  • 37

    还有其他选择,取决于你想要的类型有多愚蠢和/或迂腐 .

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

    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)
    

    我认为,做到这一点并不像我们应有的那样普遍 . 这是一个额外的打字(ha,ha),但它不仅使你的类型签名更加信息,即使扩展了类型同义词,它让类型检查器捕获,如果你错误地使用权重作为高度等 . 使用 GeneralizedNewtypeDeriving 扩展,即使对于通常无法派生的类型类,您甚至可以获得自动实例 .

  • 14

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

  • 79

    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中获取没有参数名称的类型签名,但是ghci用于源代码的交互式开发 . 你的库或模块不应该没有文档和hacky,你应该使用非常轻量级的语法haddock文档系统并在本地安装haddock . 更合理的投诉版本是没有:v命令打印函数bmiTell的源代码 . 度量标准表明,相同问题的Haskell代码将缩短一个因子(在我的情况下,我发现与等效的OO或非oo命令式代码相比大约为10),因此在gchi中显示定义通常是明智的 . 我们应该提交功能请求 .

  • 27

    试试这个:

    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 所以类型签名会记录正确的参数顺序,如果你混淆它们会得到一个编译时错误 .

相关问题