Этот вопрос касается разработки абстракций вокруг идиомы 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 χ)
| ^^^^^^^^
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Эта ошибка предполагает, что отключение проверки неоднозначности и/или создание инъективных семейств типов (не совсем уверен, что это означает) может быть решением, но я хотел проверить, является ли что-либо из этого хорошим решением моей проблемы в первую очередь, и если да, то какой из этих двух подходов мне больше подходит?
Общая цель состоит в том, чтобы ограничение действительно описывало, как информация передается между типами для конкретного алгоритма/этапа, предоставляя только то, что нужно для этого конкретного этапа. Это позволило бы мне обновлять или расширять расширения дерева без необходимости обновления основных алгоритмов.
В вашем случае (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
.