Разделение классов типов и их экземпляров на разные подмодули в Haskell

В настоящее время я пишу небольшую вспомогательную библиотеку, и я столкнулся с проблемой действительно огромного исходного кода в одном из модулей. По сути, я объявляю новый параметрический класс типов и хочу реализовать его для двух разных стеков монад.

Я решил разделить объявление 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 без экземпляра, потому что они определены в одном файле.

Однако, когда я запускаю ghci с помощью stack ghci lib, я получаю следующее предупреждение:

~/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

Что мне не хватает, и есть ли способ разделить объявления классов типов и их реализации на разные подмодули?

Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
0
288
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Чтобы этого избежать, вы можете обернуть текст 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

Как указано выше, сделайте так, чтобы тип данных, называемый словарем, соответствовал классу типа, и передайте значение типа.

В Haskell есть ограничение: тип не может быть объявлен как экземпляр определенного класса более одного раза в программе. Это вызывает различные проблемы.

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, Int)
h = (m1 instDictC1_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 часто предпочитает преобразование, основанное на рассуждениях по уравнениям, поэтому проблема заключается в том, что h1 нельзя заменить напрямую на m1.

Как правило, система типов включает разрешение экземпляров классов типов. В таком случае разрешайте экземпляры при проверке типов. А коды разрабатываются по дереву вывода, составленному при проверке типов. Такое преобразование иногда адаптируется помимо класса типа, в частности, неявного преобразования типа, типа записи и т.д. Затем эти случаи, возможно, вызывают проблему, как указано выше. Эту задачу можно формализовать следующим образом:

При преобразовании дерева вывода типа в язык в двух разных деревьях вывода одного типа результаты преобразования не становятся семантически эквивалентными.

Как уже говорилось, даже применяйте любой экземпляр, соответствующий типу, и, как правило, он должен пройти проверку типа. Однако результат обработки с использованием экземпляра, возможно, отличается от результата обработки после разрешения другого экземпляра. Наоборот, если у вас нет этой проблемы, вы можете получить определенную гарантию системы типов. Эта гарантия, сочетание системы типов, на которую не работает формализованная выше проблема, и свойства разработки, обычно называется когерентностью. Есть некоторый способ гарантировать согласованность, Haskell ограничивает количество определений экземпляров, соответствующих классу типов, до одного, чтобы гарантировать согласованность.

Бесхозный экземпляр

Легко сказать, как работает Haskell, но есть некоторые проблемы. Довольно известным является экземпляр-сирота. GHC, в объявлении типа T как экземпляре C обработка экземпляра зависит от того, находится ли объявление в том же модуле, в котором есть объявление T или C. В частности, не в том же модуле, который называется потерянным экземпляром, предупредит GHC. Почему, как это работает?

Во-первых, в Haskell экземпляры неявно распространяются между модулями. Это оговаривается следующим образом:

Все экземпляры в пределах модуля всегда экспортируются, и любой импорт приносит все экземпляры из импортированного модуля. Таким образом, объявление экземпляра находится в области видимости тогда и только тогда, когда цепочка объявлений импорта ведет к модулю, содержащему объявление экземпляра. --5 модулей

Мы не можем остановить это, не можем это контролировать. Во-первых, Haskell решил позволить нам определять один тип как один экземпляр, так что об этом не нужно думать. Кстати, хорошо, что есть такой регламент, на самом деле компилятор Haskell должен разрешать экземпляры в соответствии с регламентом. Конечно, компилятор не знает, какие модули имеют экземпляры, в худшем случае он должен проверять все модули. Нас это тоже смущает. Если два важных модуля привязывают каждое определение экземпляра к одному и тому же типу, все модули, цепочки импорта которых включают модули, становятся недоступными из-за конфликта.

Что ж, чтобы использовать тип как экземпляр класса, нам нужна информация о них, поэтому мы перейдем к модулю, в котором есть объявления. Тогда не произойдет того, что третья сторона возится с модулем. Поэтому, если какой-либо из модулей включает объявление экземпляра, компилятор может видеть необходимую информацию с экземплярами, мы рады, что включение загрузки модулей гарантирует отсутствие конфликтов. По этой причине рекомендуется тип как экземпляр класса, помещенный в тот же модуль, который имеет объявление типа или класса. Наоборот, рекомендуется избегать экземпляров-сирот, насколько это возможно. Следовательно, если вы хотите создать тип как независимый экземпляр, создайте новый тип с помощью newtype, чтобы изменить только семантику экземпляра, объявив тип как экземпляр.

Кроме того, GHC помечает внутренние модули как экземпляры-сироты, а экземпляры-сироты модулей перечисляются в файлах интерфейса их зависимых модулей. И тогда компилятор ссылается на весь список. Таким образом, чтобы один раз сделать потерянный экземпляр, файл интерфейса модуля, который имеет экземпляр, когда все модули зависят от перекомпиляции модуля, будет перезагружен, если что-то изменится. Таким образом, экземпляр-сирота плохо влияет на время компиляции.

Деталь находится под CC BY-SA 4.0 (C) Мизунаси Мана

Оригинал 続くといいな日記 – 型クラスの Coherence と Orphan Instance

22.12.2020 отредактировано и переведено Акихито Кирисаки

Хотя это правильно, стоит указать, почему появляется предупреждение: а именно, потому что это экземпляр синонима типа. Несмотря на точный язык предупреждения, на самом деле это говорит о том, что этот экземпляр является сиротой, потому что он не находится в модуле, где определено ReaderT. Изменение синонима типа на новый тип, как предлагает этот ответ, исправляет это.

DDub 22.12.2020 15:11

Итак, я вас правильно понимаю: основная проблема в том, что объявление синонима типа не привязывает его к модулю, в котором он объявлен, а фактическое предложение instance относится не к синониму, а к исходному типу, синонимом которого он является? В этом случае требуется поместить экземпляр в модуль, где был объявлен самый внешний тип синонима.?

ntwwwnt 22.12.2020 15:51

Другие вопросы по теме