56

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

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

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

这显然不是 Haskell 社区最佳实践的一部分。我已经阅读了很多关于这个问题的不同意见,我知道 Haskell 社区认为它的简洁性是一个“专业人士”。我已经阅读了如何阅读 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!"

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

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

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 方式”只是为了把所有事情都搞砸吗?

4

6 回答 6

82

类型签名不是 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]论点++

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

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

于 2012-09-14T01:49:48.573 回答
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)

我认为,这样做远没有应有的普遍性。这是一些额外的输入(哈,哈),但它不仅使您的类型签名即使扩展了类型同义词也能提供更多信息,如果您错误地将重量用作高度等,它会让类型检查器发现。使用该GeneralizedNewtypeDeriving扩展,您甚至可以获得通常无法派生的类型类的自动实例。

于 2012-09-14T01:42:26.843 回答
27

黑线鳕和/或还查看函数方程(您将事物绑定到的名称)是我告诉正在发生的事情的方式。您可以将 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 已确定(根据您的使用)它们必须具有相同的类型。所以它抱怨它无法确定weight并且height是相同的类型,即使它们必须是(也就是说,您提出的类型签名不够严格,并且会允许对函数的无效使用)。

于 2012-09-14T00:50:25.840 回答
14

weightheight必须与您划分它们的类型相同(没有隐式强制转换)。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 中显示定义通常是明智的。我们应该提交一个功能请求。

于 2012-09-14T01:41:46.480 回答
13

尝试这个:

type Height a = a
type Weight a = a

bmiTell :: (RealFloat a) => Weight a -> Height a -> String
于 2012-09-14T01:41:13.430 回答
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所以类型签名记录了正确的参数顺序,如果你把它们混在一起,你会得到一个编译时错误。

于 2012-09-14T15:41:30.733 回答