你必须声明一个函数的类型吗?

Do you have to declare a function's type?

关于 Haskell 我不完全理解的一件事是声明函数及其类型:这是您 必须 做的事情还是只是您 应该为了清楚起见?或者在某些情况下您需要这样做,但不是全部?

不需要 声明任何仅使用标准 Haskell 类型系统功能的函数的类型。 Haskell 98 指定了 全局类型推断,这意味着所有顶级绑定的类型都保证是可推断的。

但是,为顶级定义包含类型注释是一种很好的做法,原因如下:

  • 验证推断的类型是否符合您的期望

  • 帮助编译器在类型不匹配时生成更好的诊断消息

  • 最重要的是,记录您的意图并使代码对人类更易读!

至于 where 子句中的定义,这是一个风格问题。传统的风格是省略它们,部分原因是在某些情况下,它们的类型 不能 显式地写在 ScopedTypeVariables 扩展名之前。我认为省略作用域类型变量是 1998 年和 2010 年标准中的一个错误,而 GHC 今天是事实上的标准编译器,但它仍然是一个非标准扩展。无论如何,在可能的情况下为重要代码包含注释是一种很好的做法,并且对您作为程序员很有帮助。

在实践中,通常使用 一些 语言扩展来使类型推断复杂化或使其“不可判定”,这意味着,至少对于 任意 程序,不可能 总是 推断出一个类型,或者至少是一个独特的“最佳”类型。但是为了可用性,扩展通常被非常小心地设计成只在你实际使用它们的地方需要注释。

例如,GHC(和标准 Haskell)只会推断具有 top-level foralls 的多态类型,通常完全隐式. (它们可以使用 ExplicitForAll 显式编写。)如果您需要使用 RankNTypes 将多态函数作为参数传递给另一个函数,例如 (forall t. …) -> …,这需要注释来覆盖编译器的假设你的意思是 forall t. (… -> …),或者你错误地将函数应用于不同的类型。

如果一个扩展需要注释,关于何时何地必须包含它们的规则通常记录在 GHC 用户指南等地方,并在指定该功能的论文中正式指定。

简短回答:函数在“绑定”中定义,并在“类型签名”中声明其类型。绑定的类型签名在语法上始终是可选的,因为语言不需要在任何特定情况下使用它们。 (除了绑定之外,有些地方需要类型签名,例如 class 定义或数据类型声明,但我认为根据该语言的语法,尽管我可能会忘记一些奇怪的情况。)不需要它们的原因是编译器通常(但并非总是)可以在其类型检查操作中找出函数本身的类型。

但是,有些程序可能无法编译,除非将类型签名添加到绑定,有些程序可能无法编译,除非删除类型签名,因此有时需要使用它们,有时则不能使用它们(至少不是没有语言扩展和对其他附近类型签名的语法进行一些更改以使用扩展)。

为每个顶级绑定包含类型签名被认为是最佳实践,如果任何顶级绑定缺少关联的类型签名,GHC -Wall 标志将警告您。这样做的基本原理是顶级签名 (1) 为您的代码的“接口”提供文档,(2) 数量不多以至于它们会使程序员负担过重,并且 (3) 通常为编译器提供足够的指导与完全省略类型签名相比,您会收到更好的错误消息。

如果您查看几乎所有真实世界的 Haskell 源代码(例如,浏览 Hackage 上任何体面的库的源代码),您会看到正在使用此约定——所有顶级绑定具有类型签名,并且类型签名在其他上下文中很少使用(在表达式或 wherelet 子句中)。我鼓励任何初学者在他们正在学习的代码中使用相同的约定 Haskell。这是一个好习惯,可以避免许多令人沮丧的错误消息。

长答案:

在 Haskell 中,一个绑定为一段代码分配了一个名称,就像函数 hypo 的以下绑定:

 hypo a b = sqrt (a*a + b*b)

编译绑定(或相关绑定的集合)时,编译器会对涉及的表达式和子表达式执行类型检查操作。

正是这种类型检查操作允许编译器确定上述表达式中的变量 a 必须是具有 Num t 约束的某种类型 t (为了支持 * 操作),a*a 的结果将是相同类型 t,这意味着 b*bb也属于同一类型 t(因为只有两个相同类型的值可以与 + 相加),因此 a*a + b*b 属于 t 类型,并且所以 sqrt 的结果必须 是同一类型 t 必须顺便有一个 Floating t 约束来支持 sqrt 手术。在此类型检查期间收集的信息和推导的类型关系允许编译器自动为 hypo 函数推断通用类型签名,即:

hypo :: (Floating t) => t -> t -> t

Num t 约束没有出现,因为它被 Floating t 隐含)。

因为编译器可以学习(大多数)绑定名称的类型签名,例如 hypo,自动作为类型检查操作的副作用,程序员根本不需要显式提供这些信息,这就是语言使类型签名成为可选的动机。语言对类型签名的唯一要求是 如果 提供它们,它们必须出现在与关联绑定相同的声明列表中(例如,两者必须出现在同一模块中,或者在同一个 where 子句或其他任何内容中,并且没有绑定就不能有类型签名),一个绑定最多只能有一个类型签名(没有重复的类型签名,即使它们相同,不像例如在 C 中),并且类型签名中提供的类型不得与类型检查的结果冲突。

该语言允许类型签名和绑定以任何顺序出现在同一声明列表中的任何位置,并且不要求它们彼此相邻,因此以下是有效的 Haskell 代码:

double :: (Num a) => a -> a
half x = x / 2
double x = x + x
half :: (Fractional a) => a -> a

然而,不推荐这种愚蠢行为,惯例是将类型签名紧接在相应绑定之前,但一个例外是在同一类型的多个绑定之间共享类型签名,其定义如下:

ex1, ex2, ex3 :: Tree Int
ex1 = Leaf 1
ex2 = Node (Leaf 2) (Leaf 3)
ex3 = Node (Node (Leaf 4) (Leaf 5)) (Leaf 5)

在某些情况下,编译器无法自动推断绑定的正确类型,可能需要类型签名。以下绑定需要类型签名,没有它就无法编译。 (技术问题是toList是用多态递归写的。)

data Binary a = Leaf a | Pair (Binary (a,a)) deriving (Show)

-- following type signature is required...
toList :: Binary a -> [a]
toList (Leaf x) = [x]
toList (Pair b) = concatMap (\(x,y) -> [x,y]) (toList b)

在其他情况下,编译器可以自动推断出绑定的正确类型,但无法在类型签名中表达该类型(至少,如果没有对标准语言的一些 GHC 扩展)。这种情况最常发生在 where 子句中。 (技术问题是类型变量没有作用域,go 的类型涉及 myLookup 类型签名中的类型变量 a。)

myLookup :: Eq a => a -> [(a,b)] -> Maybe b
myLookup k = go
  where -- go :: [(a,b)] -> Maybe b
        go ((k',v):rest) | k == k'   = Just v
                         | otherwise = go rest
        go [] = Nothing

go 的标准 Haskell 中没有适用于此处的类型签名。但是,如果启用扩展,并且还修改 myLookup 本身的类型签名以限定类型变量的范围,则可以编写一个扩展。

myLookup :: forall a b. Eq a => a -> [(a,b)] -> Maybe b
myLookup k = go
  where go :: [(a,b)] -> Maybe b
        go ((k',v):rest) | k == k'   = Just v
                         | otherwise = go rest
        go [] = Nothing

将类型签名放在所有顶级绑定上并在其他地方谨慎使用它们被认为是最佳做法。 -Wall 编译器标志打开 -Wmissing-signatures 警告,警告任何缺少的顶级签名。

我认为,主要动机是顶级绑定是最有可能在整个代码中的多个位置使用的绑定,它们与定义它们的位置有一定距离,并且类型签名通常提供简洁的有关函数的作用及其用途的文档。考虑我多年前编写的数独解算器的以下类型签名。对这些函数的作用有很大的疑问吗?

possibleSymbols :: Index -> Board -> [Symbol]
possibleBoards :: Index -> Board -> [Board]
setSymbol :: Index -> Board -> Symbol -> Board

虽然编译器自动生成的类型签名也可以作为不错的文档,并且可以在 GHCi 中进行检查,但在源代码中包含类型签名会很方便,作为一种编译器检查注释的形式记录绑定的目的。

任何 Haskell 花点时间尝试使用不熟悉的库、阅读别人的代码或阅读自己过去的代码的程序员都知道顶级签名作为文档有多么有用。 (无可否认,对 Haskell 的经常性批评是,有时类型签名是 唯一的 库文档。)

第二个动机是在开发和重构代码时,类型签名可以更容易地“控制”类型和定位错误。在没有任何签名的情况下,编译器可以为代码推断出一些非常疯狂的类型,并且生成的错误消息可能令人费解,通常会识别出与底层错误无关的代码部分。

例如,考虑这个程序:

data Tree a = Leaf a | Node (Tree a) (Tree a)

leaves (Leaf x) = x
leaves (Node l r) = leaves l ++ leaves r
hasLeaf x t = elem x (leaves t)

main = do
  -- some tests
  print $ hasLeaf 1 (Leaf 1)
  print $ hasLeaf 1 (Node (Leaf 2) (Leaf 3))

函数 leaveshasLeaf 编译正常,但 main 吐出以下级联错误(此帖子的缩写):

Leaves.hs:12:11-28: error:
    • Ambiguous type variable ‘a0’ arising from a use of ‘hasLeaf’
      prevents the constraint ‘(Eq a0)’ from being solved.
      Probable fix: use a type annotation to specify what ‘a0’ should be.
Leaves.hs:12:19: error:
    • Ambiguous type variable ‘a0’ arising from the literal ‘1’
      prevents the constraint ‘(Num a0)’ from being solved.
      Probable fix: use a type annotation to specify what ‘a0’ should be.
Leaves.hs:12:27: error:
    • No instance for (Num [a0]) arising from the literal ‘1’
Leaves.hs:13:11-44: error:
    • Ambiguous type variable ‘a1’ arising from a use of ‘hasLeaf’
      prevents the constraint ‘(Eq a1)’ from being solved.
      Probable fix: use a type annotation to specify what ‘a1’ should be.
Leaves.hs:13:19: error:
    • Ambiguous type variable ‘a1’ arising from the literal ‘1’
      prevents the constraint ‘(Num a1)’ from being solved.
      Probable fix: use a type annotation to specify what ‘a1’ should be.
Leaves.hs:13:33: error:
    • No instance for (Num [a1]) arising from the literal ‘2’

使用程序员提供的顶级类型签名:

leaves :: Tree a -> [a]
leaves (Leaf x) = x
leaves (Node l r) = leaves l ++ leaves r

hasLeaf :: (Eq a) => a -> Tree a -> Bool
hasLeaf x t = elem x (leaves t)

一个单个错误立即定位到有问题的行:

leaves (Leaf x) = x
                  ^
Leaves.hs:4:19: error:
    • Occurs check: cannot construct the infinite type: a ~ [a]

初学者可能不理解“发生检查”,但至少在寻找正确的地方进行简单修复:

leaves (Leaf x) = [x]

那么,为什么不在所有地方添加类型签名,而不仅仅是在顶层?好吧,如果你真的试图在语法上有效的地方添加类型签名,你会编写如下代码:

{-# LANGUAGE ScopedTypeVariables #-}
hypo :: forall t. (Floating t) => t -> t -> t
hypo (a :: t) (b :: t) = sqrt (((a :: t) * (a :: t) :: t) + ((b :: t) * (b :: t) :: t) :: t) :: t

所以你想在某处画线。反对为 letwhere 子句中的所有绑定添加它们的主要论点是,这些绑定通常是短绑定,一目了然,并且它们已本地化到您尝试的代码无论如何理解“一次全部”。签名作为文档的用处也可能较小,因为这些子句中的绑定更有可能引用和使用其他附近的参数绑定或中间结果,因此它们不像顶级绑定那样“自包含”。签名仅记录了绑定正在执行的操作的一小部分。例如,在:

qsort :: (Ord a) => [a] -> [a]
qsort (x:xs) = qsort l ++ [x] ++ qsort r
      where -- l, r :: [a]
            l = filter (<=x) xs
            r = filter (>x)  xs
qsort [] = []

where 子句中具有类型签名 l, r :: [a] 不会增加太多。还有一个额外的复杂性,你需要 ScopedTypeVariables 扩展来编写它,如上所述,所以这可能是省略它的另一个原因。

正如我所说,我认为任何 Haskell 初学者都应该被鼓励采用类似的编写顶级类型签名的约定,最好是在开始编写附带的绑定之前编写顶级签名。这是利用类型系统来指导设计过程和编写好的 Haskell 代码的最简单方法之一。