在 Haskell 中将类型类及其实例拆分为不同的子模块 连贯性孤立实例

问题描述

我目前正在编写一个小型帮助程序库,但我遇到了其中一个模块中源代码非常庞大的问题。 基本上,我声明了一个新的参数类型类,并希望为两个不同的 monad 堆栈实现它。

我决定将 type-class 的声明及其实现拆分为不同的模块,但我不断收到有关孤立实例的警告。

据我所知,如果可以在没有实例的情况下导入数据类型,即如果它们位于不同的模块中,则可能会发生这种情况。但是我在每个模块中都有类型声明和实例实现。

为了简化整个示例,这是我现在所拥有的: 首先是模块,我在这里定义了一个类型类

-- File ~/library/src/Lib/API.hs 
module Lib.API where

-- Lots of imports

class (Monad m) => MyClass m where
  foo :: String -> m () 
  -- More functions are declared

然后是具有实例实现的模块:

-- File ~/library/src/Lib/FirstImpl.hs
{-# LANGUAGE TypeSynonymInstances #-}
{-# LANGUAGE FlexibleInstances #-}
module Lib.FirstImpl where

import Lib.API
import Data.IORef
import Control.Monad.Reader

type FirstMonad = ReaderT (IORef String) IO

instance MyClass FirstMonad where
  foo = undefined

它们都列在我项目的 .cabal 文件中,没有实例也无法使用 FirstMonad,因为它们定义在一个文件中。

但是,当我使用 stack ghci lib 启动 ghci 时,我收到了下一个警告:

~/library/src/Lib/FirstImpl.hs:11:1: warning: [-Worphans]
    Orphan instance: instance MyClass FirstMonad
    To avoid this
        move the instance declaration to the module of the class or of the type,or
        wrap the type with a newtype and declare the instance on the new type.
   |
11 | instance MyClass FirstMonad where
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^...
Ok,two modules loaded

我缺少什么,有什么方法可以将类型类声明及其实现拆分为不同的子模块?

解决方法

为避免这种情况,您可以将类型包装在 newtype

newtype FirstMonad a = FirstMonad (ReaderT (IORef String) IO a)

但是在深入考虑您觉得需要孤立实例之后,您可以取消警告:

{-# OPTIONS_GHC -fno-warn-orphans #-}

详情

连贯性

例如,现在考虑以下定义:

data A = A

instance Eq A where
   ...

可以看作是基于类型的重载。在上面,检查相等性 (==) 可以在各种类型下使用:

f :: Eq a => a -> a -> a -> Bool
f x y z = x == y && y == z

g :: A -> A -> A -> Bool
g x y z = x == y && y == z

f的定义中,类型a是抽象的,受约束Eq,但在g中,类型A是具体的。前者从约束中导出方法,而 Haskell 也在后者中可以导出。如何派生就是将 Haskell 细化为没有类型类的语言。这种方式称为字典传递

class C a where
  m1 :: a -> a

instance C A where
  m1 x = x

f :: C a => a -> a
f = m1 . m1

它将被转换:

data DictC a = DictC
  { m1 :: a -> a
  }

instDictC_A :: DictC A
instDictC_A = DictC
  { m1 = \x -> x
  }

f :: DictC a -> a -> a
f d = m1 d . m1 d

如上,将一个名为dictionary的数据类型对应一个类型类,并传递该类型的值。

Haskell 有一个约束 a type may not be declared as an instance of a particular class more than once in the program。这会导致各种问题。

class C1 a where
  m1 :: a

class C1 a => C2 a where
  m2 :: a -> a

instance C1 Int where
  m1 = 0

instance C2 Int where
  m2 x = x + 1

f :: (C1 a,C2 a) => a
f = m2 m1

g :: Int
g = f

此代码使用类型类的继承。它派生出以下详细代码。

  { m1 :: a
  }

data DictC2 a = DictC2
  { superC1 :: DictC1 a,m2 :: a -> a
  }

instDictC1_Int :: DictC1 Int
instDictC1_Int = DictC1
  { m1 = 0
  }

instDictC2_Int :: DictC2 Int
instDictC2_Int = DictC2
  { superC1 = instDictC1_Int,m2 = \x -> x + 1
  }

f :: DictC1 a -> DictC2 a -> a
f d1 d2 = ???

g :: Int
g = f instDictC1_Int instDictC2_Int

那么,f 的定义是什么?实际上,定义如下:

f :: DictC1 a -> DictC2 a -> a
f d1 d2 = m2 d2 (m1 d1)

f :: DictC1 a -> DictC2 a -> a
f _ d2 = m2 d2 (m1 d1)
  where
    d1 = superC1 d2

你确认打字没有问题吗?如果 Haskell 可以将 Int 重复定义为 C1 的一个实例,那么 superC1 中的 DictC2 会被细化填充,该值可能与传递的 DictC1 a 不同调用 f 时到 g

让我们看更多例子:

h :: (Int,Int)
h = (m1,m1)

当然,详细说明是其中之一:

h :: (Int,Int)
h = (m1 instDictC1_Int,m1 instDictC1_Int)

但如果可以重复定义实例,还可以考虑以下阐述:

h :: (Int,m1 instDictC1_Int')

因此,两个相同的类型应用于两个不同的实例。例如,两次调用同一个函数,但可能通过不同的算法返回不同的值。

上面的例子有点夸张,但是下一个例子呢?

instance C1 Int where
  m1 = 0

h1 :: Int
h1 = m1

instance C1 Int where
  m1 = 1

h2 :: (Int,Int)
h2 = (m1,h1)

在这种情况下,很可能在 m1 中使用不同的实例 h1,在 m1 中使用 h2。 Haskell 往往更喜欢基于 equational reasoning 的转换,所以 h1 不能直接替换为 m1 会是个问题。

通常,类型系统包括解析类型类的实例。在这种情况下,请在检查类型时解析实例。并通过在检查类型时制作的派生树来详细说明代码。这种转换有时除了类型类外,还适用于隐式类型转换、记录类型等。那么,这些情况可能会导致上述问题。这个问题可以形式化如下:

将类型的派生树转换为语言时,在一种类型的两个不同的派生树中,转换的结果不会在语义上变得等效。

如前所述,即使应用任何匹配类型的实例,它通常也必须通过类型检查。但是,使用一个实例细化的结果可能与解析其他实例后细化的结果不同。反之亦然,如果没有这个问题,可以获得一定的类型系统保证。这种保证,是上面形式化的问题不起作用的类型系统和属性详细说明的组合,通常称为一致性。有一些方法可以保证一致性,Haskell 将实例定义对应类型类的数量限制为一个,以保证一致性。

孤立实例

Haskell 的做法说起来容易,但有一些问题。比较有名的是孤儿实例。 GHC,在类型声明 T 作为 C 的实例中,实例的处理取决于声明是否在具有声明 TC 的同一模块中.特别是,不在同一个模块中,称为孤儿实例,GHC 会发出警告。为什么它是这样工作的?

首先,在 Haskell 中,实例在模块之间隐式传播。具体规定如下:

模块内范围内的所有实例总是被导出,任何导入都会从导入的模块中引入所有实例。因此,当且仅当导入声明链指向包含实例声明的模块时,实例声明才在范围内。 --5 Modules

我们无法阻止,无法控制。首先,Haskell 决定让我们将一种类型定义为一个实例,因此不必介意。顺便说一句,有这样的规定也不错,实际上Haskell的编译器必须根据规定解析实例。当然,编译器不知道哪些模块有实例,最坏的情况下必须检查所有模块。这也困扰着我们。如果两个重要的模块将每个实例定义保持在同一类型,则所有具有其导入链的模块都将不可用,从而发生冲突。

好吧,要将类型用作类的实例,我们需要它们的信息,因此我们将查看具有声明的模块。那么,第三方篡改模块就不会发生。因此,如果其中一个模块包含实例声明,编译器可以看到带有实例的必要信息,我们很高兴启用加载模块保证它们没有冲突。因此,建议将类型作为类的实例放置在具有声明类型或类的同一模块中。相反,建议尽可能避免孤儿实例。因此,如果想将一个类型作为一个独立的实例,通过newtype创建一个新的类型,以便只改变一个实例的语义,将类型声明为实例。

另外,GHC 内部标记模块有孤儿实例,有孤儿实例的模块在其依赖模块的接口文件中枚举。然后,编译器引用所有列表。因此,要使孤儿实例一次,具有该实例的模块的接口文件,当所有模块依赖于该模块重新编译时,如果发生任何更改,将重新加载。所以,孤儿实例对编译时间影响不好。

详情在CC BY-SA 4.0 (C) Mizunashi Mana下

原文是続くといいな日記 – 型クラスの Coherence と Orphan Instance

2020-12-22 雾崎明仁修改翻译