【问题标题】:When do I need type annotations?什么时候需要类型注释?
【发布时间】:2017-12-15 04:44:02
【问题描述】:

考虑这些函数

{-# LANGUAGE TypeFamilies #-}

tryMe :: Maybe Int -> Int -> Int
tryMe (Just a) b = a
tryMe Nothing b  = b

class Test a where
    type TT a
    doIt :: TT a -> a -> a

instance Test Int where
    type TT Int = Maybe Int
    doIt (Just a) b  = a
    doIt (Nothing) b = b

这行得通

main = putStrLn $ show $ tryMe (Just 2) 25

这不是

main = putStrLn $ show $ doIt (Just 2) 25
{- 
  • Couldn't match expected type ‘TT a0’ with actual type ‘Maybe a1’
  The type variables ‘a0’, ‘a1’ are ambiguous
-}

但是,如果我为第二个参数指定类型,它确实有效

main = putStrLn $ show $ doIt (Just 2) 25::Int

这两个函数的类型签名似乎是相同的。为什么需要对类型类函数的第二个参数进行注解?另外,如果我只将第一个参数注释到Maybe Int 它仍然不起作用。为什么?

【问题讨论】:

  • GHC 担心有人会定义一个instance Test Integer,在这种情况下,实例的选择会很模糊。
  • GHC 和许多编译器一样,允许单独编译:我们可以单独编译每个模块。 GHC 无法知道在某些尚未编译的模块中是否有您的类的另一个实例。因此,如果它致力于它现在可以看到的唯一实例,那将是错误的。为了告诉它这个承诺是正确的,我们必须限制 out call 以便它只能与那个实例相关。由于第二个参数中的数字字面量可以是任何类型,因此我们必须明确其类型。
  • 你已经非常巧妙地提出了两个完全不同的问题(“为什么我需要在这个程序上使用类型注释”和“为什么类型注释放在 Maybe Int 参数上时没有相同的影响”)。答案和评论者似乎选择忽略第二个问题(也许是正确的),因此您可能应该为第二个问题发布一个单独的问题。简短的回答是,type families aren't injective
  • @user2407038,可以使用单射类型族解决第二个问题,或者(可能更好)使用与第一个“取反”的另一个类型族。

标签: haskell types typeclass type-families


【解决方案1】:

用这个表达式:

putStrLn $ show $ tryMe (Just 2) 25

我们已经掌握了这些起始信息:

putStrLn :: String -> IO ()
show :: Show a => a -> String
tryMe :: Maybe Int -> Int -> Int
Just :: b -> Maybe b
2 :: Num c => c
25 :: Num d => d

(我在各处都使用了不同的类型变量,因此我们可以更轻松地在同一范围内同时考虑它们)

类型检查器的工作基本上是为所有这些变量找到要选择的类型,然后确保参数和结果类型对齐,并且所有必需的类型类实例都存在。

在这里我们可以看到应用于两个参数的tryMe 将是Int,因此a(用作show 的输入)必须是Int。这要求有一个Show Int 实例;确实有,所以我们完成了a

同样tryMe 想要一个Maybe Int,我们有应用Just 的结果。所以b必须是Int,而我们使用JustInt -> Maybe Int

Just 已应用于2 :: Num c => c。我们决定它必须应用于Int,所以c 必须是Int。如果我们有Num Int,我们可以这样做,并且我们这样做了,所以c 被处理了。

剩下25 :: Num d => d。它用作tryMe 的第二个参数,它期望Int,所以d 必须是Int(再次解除Num 约束)。

然后我们只需要确保所有参数和结果类型对齐,这很明显。这主要是对上述内容的重新散列,因为我们通过选择类型变量的唯一可能值使它们排列在一起,所以我不会详细讨论。

现在,这有什么不同?

putStrLn $ show $ doIt (Just 2) 25

好吧,让我们再看一遍:

putStrLn :: String -> IO ()
show :: Show a => a -> String
doIt :: Test t => TT t -> t -> t
Just :: b -> Maybe b
2 :: Num c => c
25 :: Num d => d

show 的输入是将doIt 应用于两个参数的结果,所以它是t。所以我们知道at 是同一类型,这意味着我们需要Show t,但我们还不知道t 是什么,所以我们必须回到那个。

应用Just 的结果在我们想要TT t 的地方使用。所以我们知道Maybe b 必须是TT t,因此Just :: _b -> TT t。我使用 GHC 的 partial type signature syntax 编写了 _b,因为这个 _b 不像我们之前的 b。当我们有Just :: b -> Maybe b 时,我们可以为b 选择我们喜欢的任何类型,而Just 可以具有该类型。但是现在我们需要一些特定但未知的类型_b,这样TT t 就是Maybe _b。我们还没有足够的信息来知道该类型是什么,因为在不知道t 的情况下,我们不知道我们正在使用哪个实例对TT t 的定义。

Just 的参数是2 :: Num c => c。所以我们可以知道c 也必须是_b,这也意味着我们将需要一个Num _b 实例。但由于我们不知道_b 是什么,所以我们无法检查是否存在Num 实例。我们稍后再讨论。

最后25 :: Num d => d 用于doIt 想要t 的地方。好的,所以d 也是t,我们需要一个Num t 实例。同样,我们仍然不知道t 是什么,所以我们无法检查。

总之,我们已经想通了:

putStrLn :: String -> IO ()
show :: t -> String
doIt :: TT t -> t -> t
Just :: _b -> TT t
2 :: _b
25 :: t

还有这些限制有待解决:

Test t, Num t, Num _b, Show t, (Maybe _b) ~ (TT t)

(如果你以前没见过,~ 是我们写的两个类型表达式必须是同一事物的约束)

我们被困住了。我们在这里无法进一步了解,因此 GHC 将报告类型错误。您引用的特定错误消息抱怨我们无法判断TT tMaybe _b 是相同的(它调用类型变量a0a1),因为我们没有足够的信息来选择它们的具体类型(它们是模棱两可的)。

如果我们为表达式的某些部分添加一些额外的类型签名,我们可以更进一步。添加25 :: Int1 立即让我们读出tInt。现在我们可以到达某个地方了!让我们将其修补到我们尚未解决的约束中:

Test Int, Num Int, Num _b, Show Int, (Maybe _b) ~ (TT Int)

Num IntShow Int 是显而易见的并且是内置的。我们也有Test Int,这给了我们TT Int = Maybe Int 的定义。所以(Maybe _b) ~ (Maybe Int),因此_b 也是Int,这也允许我们解除Num _b 约束(又是Num Int)。同样,现在很容易验证所有参数和结果类型是否匹配,因为我们已将所有类型变量填充到具体类型。

但是为什么你的其他尝试没有成功?让我们尽可能回到没有额外类型注释的情况:

putStrLn :: String -> IO ()
show :: t -> String
doIt :: TT t -> t -> t
Just :: _b -> TT t
2 :: _b
25 :: t

还需要解决这些限制:

Test t, Num t, Num _b, Show t, (Maybe _b) ~ (TT t)

然后添加Just 2 :: Maybe Int。因为我们知道这也是Maybe _bTT t,这告诉我们_bInt。我们现在也知道我们正在寻找一个给我们TT t = Maybe IntTest 实例。但这实际上并不能确定t 是什么!也可能有:

instance Test Double where
    type TT Double = Maybe Int
    doIt (Just a) _ = fromIntegral a
    doIt Nothing b = b

现在可以选择t 作为IntDouble;两者都可以与您的代码一起正常工作(因为25 也可以是Double),但会打印不同的东西!

很容易抱怨因为t 只有一个实例,而TT t = Maybe Int 我们应该选择那个实例。但是实例选择逻辑被定义为不以这种方式猜测。如果您处于可能应该存在另一个匹配实例的情况,但由于代码中的错误而不存在(例如,忘记导入定义它的模块),然后它不会提交它可以看到的唯一匹配实例。只有当它知道没有其他实例可能适用时,它才会选择一个实例。2

所以“只有一个实例TT t = Maybe Int”的论点并没有让 GHC 向后工作以解决 t 可能是 Int

一般而言,对于类型族,您只能“向前工作”;如果您知道要应用类型族的类型,则可以从中看出结果类型应该是什么,但是如果您知道结果类型,则不能识别输入类型。这常常令人惊讶,因为普通类型构造函数确实让我们以这种方式“逆向工作”;我们使用上面的这个从Maybe _b = Maybe Int 得出_b = Int 的结论。这只适用于新的data 声明,应用类型构造函数总是保留结果类型中的参数类型(例如,当我们将Maybe 应用到Int 时,结果类型是Maybe Int)。相同的逻辑不适用于类型族,因为可能有多个类型族实例映射到同一个类型,即使没有要求存在可识别的模式将结果类型中的某些内容连接到输入类型(我可以有type TT Char = Maybe (Int -> Double, Bool)

所以你经常会发现,当你需要添加一个类型注解时,你会经常发现在一个类型是一个类型族的结果的地方添加一个是行不通的,你需要而是将输入固定到类型族(或其他需要与其相同的类型)。


1 请注意,您在问题main = putStrLn $ show $ doIt (Just 2) 25::Int 中引用的行实际上不起作用。 :: Int 签名绑定“尽可能远”,因此您实际上声称整个表达式 putStrLn $ show $ doIt (Just 2) 25Int 类型,而它必须是 IO () 类型。我假设当你真正检查它时,你在25 :: Int 周围加上括号,所以putStrLn $ show $ doIt (Just 2) (25 :: Int)

2 对于 GHC 认为不可能有任何其他匹配实例的“某些知识”有特定的规则。我不会详细讨论它们,但基本上当你有instance Constraints a => SomeClass (T a) 时,它必须能够仅通过考虑SomeClass (T a) 位来明确选择一个实例;它无法查看=> 箭头左侧的约束。

【讨论】:

    【解决方案2】:

    所有这些讨论都很好,但还没有明确说明在 Haskell 中 数字文字是多态的。您可能知道这一点,但可能没有意识到它与这个问题有关。在表达式中

    doIt (Just 2) 25
    

    25 没有Int 类型,它有Num a => a 类型——也就是说,它的类型只是一些数字类型,等待额外的信息来准确确定它。让这件事变得棘手的是,具体的选择可能会影响第一个参数的类型。因此 amalloy 的评论

    GHC 担心有人可能会定义 instance Test Integer,在这种情况下,实例的选择会很模糊。

    当您提供该信息时——它可以来自参数或结果类型(因为doIt 的签名中的a -> a 部分)——通过编写任何一个

    doIt (Just 2) (25 :: Int)
    doIt (Just 2) 25 :: Int   -- N.B. this annotates the type of the whole expression
    

    那么具体的实例是已知的。

    请注意,您不需要类型族来产生这种行为。这是类型类解析课程的标准。出于同样的原因,下面的代码会产生同样的错误。

    class Foo a where
        foo :: a -> a
    
    main = print $ foo 42
    

    您可能想知道为什么这种情况不会发生在类似的情况下

    main = print 42
    

    这是一个很好的问题,leftroundabout 已经解决了。这与 Haskell 的 defaulting rules 有关,它们非常专业,我认为它们只不过是一种 hack。

    【讨论】:

    • 很好,将 default () 写入 GHCi 使其无法推断出像 42 这样简单的东西,它只是抛出一个错误,指出类型不明确。
    【解决方案3】:

    我什么时候需要在 Haskell 中转换类型?

    仅在编译器无法证明两种类型相等但您知道它们相等的非常模糊的伪依赖类型设置中;在这种情况下,您可以unsafeCoerce 他们。 (这就像 C++'reinterpret_cast,即它完全绕过类型系统,只是将内存位置视为包含您告诉它的类型。这确实非常不安全!)

    然而,这根本不是你要说的。添加像::Int 这样的本地签名不会 执行任何强制转换,它只是向类型检查器添加提示。需要这样的提示不足为奇:您没有在任何地方指定 a 应该是什么; show 的输入是多态的,doIt 的输出是多态的。但是编译器必须知道它是什么,才能解析关联的TT;选择错误的a 可能会导致与预期完全不同的行为。

    更令人惊讶的是,实际上,有时您可以省略这些签名。这可能的原因是 Haskell,尤其是 GHCi,有defaulting rules。当你写例如show 3,你又得到了一个模棱两可的 a 类型变量,但 GHC 认识到 Num 约束可以由 Integer 类型“自然地”满足,所以它只需要这个选项。
    在 REPL 中快速评估某些东西时,默认规则很方便,但它们很容易依赖,因此我建议您在适当的程序中永远不要这样做

    现在,这并不意味着您应该始终将:: Int 签名添加到任何子表达式。这确实意味着,作为一项规则,您的目标应该是使 函数参数的多态性始终低于结果。我的意思是:如果可能的话,任何局部类型变量都应该从环境中推断。那么指定最终结果的类型就足够了。

    不幸的是,show 违反了该条件,因为它的参数是多态的,变量a 根本不会出现在结果中。因此,这是您无法获得签名的功能之一。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2018-12-10
      • 2015-01-19
      • 1970-01-01
      • 1970-01-01
      • 2019-03-01
      • 2012-09-16
      • 1970-01-01
      相关资源
      最近更新 更多