Ограничение роста деревьев: семейства типов и классы типов

Этот вопрос касается разработки абстракций вокруг идиомы Trees That Grow.

Предположим, у меня есть очень простое синтаксическое дерево:

data Expr χ  
  = Lam  (Lamfam χ) Name (Ty χ) (Expr χ)
  | App  (Appfam χ) (Expr χ)
  | Unit (Ufam χ)

data Ty χ  
  = UnitTy (Utyfam χ)
  | Arrow (Arrfam χ) (Ty χ) (Ty χ)

type family Lamfam χ
type family Appfam χ
type family Ufam χ
type family Utyfam χ
type family Arrfam χ

Мой вопрос возникает при поиске способа наложения ограничений на этот набор семейств типов. В документе предлагается одно решение: виды ограничений:

type ForAllχ (c :: * -> Constraint) χ
  = (c (LamFam χ), c (Appfam χ), c (Ufam χ), c (Utyfam χ), c (Arrfam χ))

  
Forallχ Show χ => Show (Ty χ) where   
  show = ...

Forallχ Show χ => Show (Exp χ)  
  show = ...

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

Предположим, например, что я пишу двунаправленный алгоритм проверки типов, который является общим по параметру χ.

infer env term = case term of 
    ...
    Lam ex name ty body -> do
      ret_ty <- infer (insert name ty env) body
      pure $ Arr ??? a ret_ty
    ...

Нам нечего подключать туда, где стоит ???. Чтобы решить эту проблему, может быть, я хочу, чтобы была функция lamToArr :: (Lamfam χ) -> (Arrfam χ), которая позволяет типу решать, как информация о расширении проходит через средство проверки типов.

infer lamtoArr env term = case term of 
    ...
    Lam ex name ty body -> do
      ret_ty <- infer lamtoArr (insert name ty env) body
      pure $ Arr lamtoArr a ret_ty
    ...

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

class Checker χ where  
  lamToArr :: (Lamfam χ) -> (Arrfam χ)
  unitToTy :: (Ufam χ) -> (Utyfam χ)

Однако это приводит к ошибке типа:

    • Couldn't match type: Lamfam χ0
                     with: Lamfam χ
      Expected: Lamfam χ -> Arrfam χ
        Actual: Lamfam χ0 -> Arrfam χ0
      NB: ‘Lamfam’ is a non-injective type family
      The type variable ‘χ0’ is ambiguous
    • In the ambiguity check for ‘lamToArr’
      To defer the ambiguity check to use sites, enable AllowAmbiguousTypes
      When checking the class method:
        lamToArr :: forall χ. Checker χ => Lamfam χ -> Arrfam χ
      In the class declaration for ‘Checker’
   |
41 |   lamToArr :: (Lamfam χ) -> (Arrfam χ)
   |   ^^^^^^^^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Эта ошибка предполагает, что отключение проверки неоднозначности и/или создание инъективных семейств типов (не совсем уверен, что это означает) может быть решением, но я хотел проверить, является ли что-либо из этого хорошим решением моей проблемы в первую очередь, и если да, то какой из этих двух подходов мне больше подходит?

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

Стоит ли изучать 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 называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
4
0
65
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

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

В вашем случае (Lamfam χ) -> (Arrfam χ) неоднозначно, потому что нет гарантии, что либо Lamfam χ, либо Arrfam χ достаточно, чтобы сказать, какой χ вы хотите на месте вызова. Например, только что проанализированный AST без аннотаций может установить для них оба значения data SrcLoc.

Здесь нет проблем с AllowAmbiguousTypes. В нем говорится, что вы соглашаетесь писать аннотации типа или TypeApplications иногда на сайтах звонков. Вы можете сделать эти семейства типов инъективными, используя TypeFamilyDependencies аннотации:

type family Lamfam χ = λ | λ -> χ

Неявно, семейство типов должно быть функцией, вход которой χ однозначно определяет его вывод λ. Это просто добавляет зависимость в другом направлении, что для любого вывода λ, полученного из приложения Lamfam χ, должно быть достаточно, чтобы сказать, каким был ввод χ. Это довольно строгое требование, которое сводит на нет некоторые предполагаемые преимущества TTG (избегание типов-оболочек). Также есть некоторые проблемы с выразительностью проверки инъективности в текущем GHC. Тем не менее, когда он работает, вывод и сообщения об ошибках становятся более полезными. Распространенной идиомой для создания инъективного семейства является сохранение входного типа в качестве параметра фантомного типа. В основном, замените такие вещи:

newtype Name = Name String
newtype Id   = Id   Int

type family Namefam χ
type instance Namefam Parsed  = Name
type instance Namefam Renamed = Id    -- conflict
type instance Namefam Typed   = Id    -- conflict

С такими вещами:

newtype Name  = Name String
newtype Id' χ = Id'  Int
-- or: Const Id χ

type family Namefam χ
type instance Namefam 'Parsed      = Name
type instance Namefam 'Renamed     = Id' 'Renamed
type instance Namefam 'Typechecked = Id' 'Typechecked

Однако это немного сбивает с толку. Обычно с TTG средство проверки типов не будет полиморфным в χ, оно изменит χ с 'Renamed на 'Typechecked, реаннотируя дерево. В этом случае у вас есть конкретные доступные типы, и вам не нужен класс типов.

Ваш class Checker χ описывает только набор состояний AST, в которых у вас достаточно информации для запуска проверки типов. Так что я не знаю, действительно ли она имеет значение как абстракция, если только вы не хотите иметь возможность повторно проверять тип программы между проходами, скажем, чтобы убедиться, что ничего не было нарушено переписыванием.

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

data Pass
  = Parsed
  | Renamed
  | Typechecked

(Их всего несколько, потому что сейчас они используют TTG только для внешнего интерфейса, но если вы пишете компилятор, использующий TTG с самого начала, вы, безусловно, можете использовать его для большей части конвейера.)

Далее, вместо прямого индексирования AST и точек расширения с этим типом, например HsExpr 'Parsed, они используют GADT GhcPass (например, Sing Pass с singletons).

data GhcPass (c :: Pass) where
  GhcPs :: GhcPass 'Parsed
  GhcRn :: GhcPass 'Renamed
  GhcTc :: GhcPass 'Typechecked

-- Type synonyms as a shorthand for tagging
type GhcPs   = GhcPass 'Parsed      -- Output of parser
type GhcRn   = GhcPass 'Renamed     -- Output of renamer
type GhcTc   = GhcPass 'Typechecked -- Output of typechecker

Наконец, они добавляют класс IsPass (например, SingI или KnownNat), который позволяет унифицировать диспетчеризацию по типу.

class IsPass p where
  ghcPass :: GhcPass p

instance IsPass 'Parsed where
  ghcPass = GhcPs

instance IsPass 'Renamed where
  ghcPass = GhcRn

instance IsPass 'Typechecked where
  ghcPass = GhcTc

Это используется особенно для красивой печати, а иногда и для построения деревьев. Это означает, что вместо того, чтобы носить с собой набор всех ограничений, которые им могут понадобиться, как в ForAllχ c χ, они могут использовать IsPass p => HsExpr (GhcPass p). С χ, предоставленным сопоставлением с образцом, каждый c χ становится доступным. Примечательно, что вы даже можете использовать такие ограничения по мере необходимости, без какого-либо конкретного класса:

pprIfPs :: forall p. IsPass p => (p ~ 'Parsed => SDoc) -> SDoc
pprIfPs pp = case ghcPass @p of
  GhcPs -> pp
  _ -> empty

Если вы хотите написать много кода с полиморфной передачей, я считаю, что гораздо проще и понятнее использовать семейство данных или GADT вместо TTG. Сообщения компилятора гораздо полезнее, и есть больше инструментов для сокращения шаблонов, таких как StandaloneDeriving.

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