将 type-类 及其实例拆分到 Haskell 中的不同子模块

Splitting type-classes and their instances to the different submodules in Haskell

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

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

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

为了简化整个示例,这是我现在拥有的: 首先是模块,我在其中定义了一个类型-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

我错过了什么,有什么方法可以将 type-class 声明及其实现拆分到不同的子模块中吗?

为避免这种情况,您可以在 newtype

中换行
newtype FirstMonad a = FirstMonad (ReaderT (IORef String) IO a)

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

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

详情

一致性

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

data A = A

instance Eq A where
   ...

可以看作是基于类型的重载。上面的 Checking equality (==) 可以在各种类型下使用:

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是具体的。前者从constrains中导出方法,而Haskell在后者中同样可以导出。推导的方法就是将Haskell阐述成没有类型class的语言。这种方式叫做字典传递.

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的数据类型对应一个类型class,并传递该类型的值

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

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

  { 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的一个实例,DictC2中的superC1会在细化过程中被填充,其值可能与DictC1 a 在调用 g.

时传递给 f

让我们看更多的例子:

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

当然,阐述是一个:

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

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

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

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

上面的例子有点夸张,下一个例子怎么样?

instance C1 Int where
  m1 = 0

h1 :: Int
h1 = m1

instance C1 Int where
  m1 = 1

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

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

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

When convert derivation tree of type into language, in two different derivation tree of one type, results of conversion don't become semantically equivalent.

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

孤立实例

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

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

All instances in scope within a module are always exported and any import brings all instances in from the imported module. Thus, an instance declaration is in scope if and only if a chain of import declarations leads to the module containing the instance declaration. --5 Modules

我们无法阻止,无法控制。首先,Haskell 决定让我们定义一个类型为一个实例,所以不必介意。顺便说一句,有这样的规定就好了,实际上 Haskell 的编译器必须按照规定解析实例。当然,编译器不知道哪些模块有实例,在最坏的情况下必须检查所有模块。这也困扰着我们。如果两个重要的模块将每个实例定义都指向相同的类型,则所有具有导入链的模块都将变得不可用,以便发生冲突。

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

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

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

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

2020-12-22 雾崎明仁修译