有没有一种很好的方法可以让函数签名在Haskell中提供更多信息?

时间:2012-09-14 00:42:04

标签: haskell syntax coding-style functional-programming

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

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

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

这显然不是Haskell社区最佳实践的一部分。我已经阅读了很多关于此事的不同意见,我理解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,但这似乎有点多。

是否没有可接受的方法来制作信息功能签名? “哈斯克尔之路”只是对哈多克说出了一切废话吗?

6 个答案:

答案 0 :(得分:79)

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

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

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

答案 2 :(得分:27)

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

答案 3 :(得分:14)

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代码将缩短一个因子(在我的情况下,我发现大约10个与等效的OO或非oo命令式代码相比),因此显示gchi中的定义通常是明智的。我们应该提交功能请求。

答案 4 :(得分:12)

试试这个:

type Height a = a
type Weight a = a

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

答案 5 :(得分: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,所以类型签名会记录正确的参数顺序,如果你混淆它们就会出现编译时错误。