Типовые переменные и унификация

Сердце вывода типов: как из уравнения «этот тип = тот тип» получить значения неизвестных.

Унификация — поиск подстановки типовых переменных, при которой два типовых выражения становятся синтаксически равными.

Постановка задачи

Во время вывода мы накапливаем уравнения вида «тип A должен равняться типу B», где в типах встречаются неизвестные — типовые переменные a, b, c. Решить уравнение значит найти, чем заменить переменные, чтобы стороны совпали. Например, из a -> b = Int -> c следует a = Int и b = c. Эта подстановка и есть результат.

унифицировать (a -> b)  с  (Int -> c)
  => сопоставить аргументы:  a = Int
  => сопоставить результаты: b = c
решение: { a := Int, b := c }

Правила унификации

Алгоритм рекурсивен и разбирает четыре случая.

  • Переменная против чего угодно: a с T — связываем a := T (если a ещё свободна).
  • Одинаковые конструкторы: A1 -> B1 с A2 -> B2 — унифицируем A1 с A2, затем B1 с B2.
  • Разные конструкторы: Int с Bool или Int со стрелкой — ошибка («не совпадают»).
  • Occurs-check: связать a с типом, где a уже встречается (a = a -> b), нельзя — это бесконечный тип. Именно этот случай отвергает самоприменение x x.

Демонстрация: рабочая унификация

Реализуем унификацию с подстановкой и occurs-check. Типы: переменная ("v", имя) или конструктор ("c", имя, аргументы); стрелка — конструктор "->" с двумя аргументами.

def TV(n):      return ("v", n)
def TC(n, *a):  return ("c", n, tuple(a))
def arrow(a, b):return TC("->", a, b)

def show(t):
    if t[0]=="v": return t[1]
    if t[1]=="->":
        l=show(t[2][0])
        if t[2][0][1]=="->" if t[2][0][0]=="c" else False: l="("+l+")"
        return l+" -> "+show(t[2][1])
    if t[2]: return t[1]+" "+" ".join(show(x) for x in t[2])
    return t[1]

def walk(t, s):
    while t[0]=="v" and t[1] in s:
        t = s[t[1]]
    return t

def occurs(v, t, s):
    t = walk(t, s)
    if t[0]=="v": return t[1]==v
    return any(occurs(v, a, s) for a in t[2])

def unify(a, b, s):
    a = walk(a, s); b = walk(b, s)
    if a[0]=="v" and b[0]=="v" and a[1]==b[1]:
        return s
    if a[0]=="v":
        if occurs(a[1], b, s):
            raise TypeError("бесконечный тип: " + a[1] + " в " + show(b))
        ns = dict(s); ns[a[1]] = b; return ns
    if b[0]=="v":
        return unify(b, a, s)
    if a[1]!=b[1] or len(a[2])!=len(b[2]):
        raise TypeError("не совпадают: " + show(a) + " и " + show(b))
    for x, y in zip(a[2], b[2]):
        s = unify(x, y, s)
    return s

def resolve(t, s):
    t = walk(t, s)
    if t[0]=="v": return t
    return TC(t[1], *[resolve(a, s) for a in t[2]])

s = unify(arrow(TV("a"), TV("b")), arrow(TC("Int"), TV("c")), {})
print("a =", show(resolve(TV("a"), s)))
print("b =", show(resolve(TV("b"), s)))
try:
    unify(TV("a"), arrow(TV("a"), TV("b")), {})
except TypeError as e:
    print("occurs:", e)
try:
    unify(TC("Int"), TC("Bool"), {})
except TypeError as e:
    print("clash :", e)

Вывод:

a = Int
b = c
occurs: бесконечный тип: a в a -> b
clash : не совпадают: Int и Bool

Три исхода — успех с подстановкой, ошибка occurs-check, конфликт конструкторов — это ровно три вердикта, которые выдаёт вывод типов в Haskell/OCaml под капотом.

Как работает под капотом

Подстановка s — это «текущие знания» о переменных. Функция walk следует по цепочке связываний, отвечая «чему сейчас равна переменная». Алгоритм Робинсона (1965), который мы реализовали, всегда находит наиболее общий унификатор (most general unifier): любое другое решение получается из него дальнейшей подстановкой. Именно поэтому вывод даёт самый общий (принципиальный) тип.

Частые ошибки

  • Забыть occurs-check. Без него унификация a = a -> b зациклится или построит бесконечный тип — классический баг.
  • Не применять подстановку транзитивно. Если a := b, а позже b := Int, то a тоже Int; walk обязан проходить цепочку.
  • Считать унификацию симметричной по конструкторам. Связывание идёт только «переменная := тип»; два разных конструктора не унифицируются никогда.

Итоги

  • Унификация решает уравнения между типами, находя подстановку переменных.
  • Случаи: переменная (связать), одинаковые конструкторы (рекурсия), разные (ошибка), occurs (бесконечный тип).
  • Occurs-check отвергает самоприменение и бесконечные типы.
  • Алгоритм Робинсона даёт наиболее общий унификатор — основу принципиальных типов.
Проверьте себя
1. Что находит унификация для уравнения a -> b = Int -> c?
AНичего
BПодстановку a := Int, b := c
Ca := c
Dошибку
2. Зачем нужен occurs-check?
AДля скорости
BЧтобы отвергнуть связывание вида a = a -> b (бесконечный тип)
CДля красоты
DОн не нужен
3. Что гарантирует алгоритм унификации Робинсона?
AСамый конкретный тип
BНаиболее общий унификатор (most general unifier)
CОтсутствие переменных
DЛинейное время всегда