Let-полиморфизм и обобщение

Почему let id = ... позволяет применять id к разным типам, а аргумент функции — нет.

Обобщение (generalization) — превращение типа со свободными переменными в полиморфную схему forall a. ..., которую можно специализировать под каждое использование.

Проблема

Алгоритм W из прошлого урока выводит id : t4 для \x.x. Но если связать let id = \x. x и использовать его дважды — на числе и на строке, — наивный вывод свяжет t4 с первым же использованием (скажем, Int) и отвергнет второе (String). Одна переменная не может быть и Int, и String. Нужно сделать id по-настоящему полиморфным.

Решение Милнера: обобщать на let

Идея ML: при let-привязке вывести тип правой части, затем обобщить его свободные переменные в схему forall a. a -> a. При каждом использовании схему инстанцируют — подставляют свежие переменные. Тогда первое использование возьмёт a := Int, второе — a := String, и они не конфликтуют, потому что переменные разные.

let id = \x. x          -- вывод: id : t -> t
                        -- обобщение: id : forall a. a -> a
in id 5                  -- инстанс: a := Int   => id : Int -> Int
   id "hi"               -- инстанс: a := String=> id : String -> String

Ключевая тонкость: обобщают только переменные, которые не связаны в окружении (не «торчат» из внешнего контекста). Поэтому параметр лямбды НЕ полиморфен внутри своего тела — он фиксирован, пока функция не применена. Это различие «let-полиморфизм есть, lambda-полиморфизма нет» — суть системы Хиндли — Милнера.

Демонстрация: инстанцирование схемы

Смоделируем схему forall [a]. body и операцию инстанцирования — замену квантифицированных переменных свежими. Увидим, как два использования получают независимые типы.

_k = [0]
def fresh():
    _k[0]+=1; return "t"+str(_k[0])

# схема: (список квантифицированных имён, тип как строка с этими именами)
scheme = (["a"], "a -> a")

def instantiate(sch):
    quantified, body = sch
    mapping = {q: fresh() for q in quantified}
    out = body
    for q, fr in mapping.items():
        out = out.replace(q, fr)
    return out

print("схема id     :", "forall " + " ".join(scheme[0]) + ". " + scheme[1])
print("использ. #1  :", instantiate(scheme), "   (затем a := Int)")
print("использ. #2  :", instantiate(scheme), "   (затем a := String)")
print("две разные переменные => конфликта Int/String нет")

Вывод:

схема id     : forall a. a -> a
использ. #1  : t1 -> t1    (затем a := Int)
использ. #2  : t2 -> t2    (затем a := String)
две разные переменные => конфликта Int/String нет

Каждое использование берёт свежие t1, t2 — поэтому одно может стать Int, другое String. Это и есть механизм, благодаря которому Haskell и OCaml позволяют переиспользовать полиморфные значения свободно.

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

Обобщение формально: generalize(Γ, τ) квантифицирует те свободные переменные τ, которых нет в свободных переменных контекста Γ. Инстанцирование instantiate(σ) заменяет квантифицированные переменные свежими. Ограничение «только на let» сохраняет разрешимость и принципиальность: полный лямбда-полиморфизм — это System F, чей вывод уже неразрешим (раздел 8).

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

  • Обобщать переменные, торчащие из контекста. Обобщать можно только локально-свободные; иначе вывод станет неверным.
  • Ждать полиморфизма от параметра лямбды. В ML параметр мономорфен в своём теле; полиморфен только let-связанный идентификатор.
  • Не инстанцировать при использовании. Без свежих переменных на каждом использовании полиморфизм «схлопнется» в первый встреченный тип.

Итоги

  • Let-полиморфизм обобщает тип правой части в схему forall a. ....
  • Каждое использование инстанцирует схему свежими переменными — конфликтов нет.
  • Обобщают только переменные, не связанные внешним контекстом.
  • Параметр лямбды мономорфен; полный лямбда-полиморфизм — это уже System F.
Проверьте себя
1. Что делает обобщение (generalization) в let-полиморфизме?
AУдаляет тип
BПревращает тип со свободными переменными в схему forall a. ...
CФиксирует тип в Int
DЗапрещает использование
2. Почему let id = \x.x работает и на числе, и на строке?
Aid магически меняет тип
BКаждое использование инстанцирует схему forall a. a->a свежей переменной
Cid игнорирует тип
DЭто ошибка
3. Полиморфен ли параметр лямбды в своём теле (в системе Хиндли — Милнера)?
AДа, всегда
BНет: параметр мономорфен, полиморфен только let-связанный идентификатор
CТолько Int
DЗависит от ОС