Теория языков программирования
Вывод типов: Hindley-Milner
Как Haskell понимает тип выражения без единой аннотации? За этим стоит алгоритм 1982 года - Algorithm W Дамаса-Милнера, - который до сих пор является основой вывода типов в Haskell, OCaml, Rust и десятках других языков.
- **Haskell / OCaml:** полный вывод типов - можно писать целые программы без аннотаций, компилятор выведет всё сам
- **Rust:** borrow checker использует унификацию для вывода времён жизни (lifetimes), а trait bounds расширяют HM constraints
- **TypeScript:** `const x = [1, 2, 3].map(n => n * 2)` - тип `number[]` выведен автоматически через flow-анализ, вдохновлённый HM
Hindley-Milner: алгоритм вывода типов без аннотаций
В 1969 году математик Дж. Роджер Хиндли доказал теорему о наиболее общем типе для комбинаторной логики. В 1978 году Робин Милнер независимо разработал систему типов ML с выводом и let-полиморфизмом. Алгоритм W - практическая реализация Милнера. Луис Дамас в 1982 году завершил формализацию. Hindley-Milner лежит в основе OCaml, Haskell, SML, Elm, F#, Rust (частично). TypeScript выбрал bidirectional type checking вместо HM из-за совместимости с JavaScript. Алгоритм позволяет писать код без единой аннотации типа и получать корректную типизацию - фундаментальный результат теории типов.
Предварительные знания
Unification - решение уравнений типов
Когда код пишете `f(x) = x + 1`, компилятор должен определить тип `f` без единой аннотации. Как? Он строит **уравнения типов** и решает их - точно так же, как код решаете систему уравнений в алгебре.
**Unification** (унификация) - это алгоритм нахождения подстановки (substitution), которая делает два типа равными. Если `τ₁ = α → Int` и `τ₂ = Bool → β`, то подстановка `{α ↦ Bool, β ↦ Int}` унифицирует их: оба становятся `Bool → Int`.
Формально, подстановка (substitution) - это отображение `S: TypeVar → Type`. Применение подстановки `S` к типу `τ` записывается как `S(τ)`. Унификация двух типов `τ₁` и `τ₂` ищет такую `S`, что `S(τ₁) = S(τ₂)`.
| Уравнение типов | Подстановка | Результат |
|---|---|---|
| α = Int | {α ↦ Int} | Подставляем α во все уравнения |
| α → β = Int → Bool | {α ↦ Int, β ↦ Bool} | Рекурсивная декомпозиция |
| α = α → Int | ❌ ОШИБКА | Occurs check: бесконечный тип |
| Int = Bool | ❌ ОШИБКА | Конфликт: разные конструкторы |
**Occurs check** - ключевая проверка при унификации. Если переменная `α` встречается внутри типа `τ`, то уравнение `α = τ` не имеет конечного решения. Например, `α = List α` потребовало бы бесконечный тип `List (List (List ...))`. Без occurs check алгоритм зациклится.
Что произойдёт при попытке унифицировать α = List α?
Let-полиморфизм
Рассмотрим функцию идентичности: `let id = λx.x`. Какой у неё тип? Она принимает что угодно и возвращает то же самое. Интуитивно тип - `∀a. a → a` (для любого типа `a`, принимает `a` и возвращает `a`). Это **полиморфный** тип.
Но полиморфизм возникает не везде. **Let-полиморфизм** (также Дамаса-Милнера) говорит: тип обобщается (generalization) только при `let`-привязке. Переменные, связанные через `λ`, остаются **мономорфными** внутри тела λ.
**Правило generalization:** после вывода типа `τ` для `let x = e`, все свободные переменные типов в `τ`, которые НЕ встречаются в окружении (context), обобщаются квантором `∀`. Так `id: α → α` становится `id: ∀α. α → α`.
| Выражение | Тип | Полиморфное? |
|---|---|---|
| let id = λx.x | ∀a. a → a | ✅ Да - let обобщает |
| λf. (f 1, f true) | Ошибка типов | ❌ f мономорфна |
| let f = λx.x in (f 1, f true) | (Int, Bool) | ✅ Да - f обобщена при let |
| let const = λx.λy.x | ∀a b. a → b → a | ✅ Оба a и b обобщены |
- Rank-1 (Hindley-Milner) — ∀ только на верхнем уровне типа. let id = λx.x : ∀a. a → a. Полная автоматическая инференция. Используется: Haskell 98, OCaml, SML.
- Rank-N (System F) — ∀ может быть вложенным: (∀a. a → a) → Int. Требует аннотаций типов. Вывод типов неразрешим в общем случае. Используется: Haskell с RankNTypes.
Hindley и Milner: независимое открытие
Роджер Хиндли доказал существование principal type для комбинаторной логики в 1969 году. Робин Милнер независимо переоткрыл этот результат для ML в 1978 году и реализовал его в компиляторе. Луис Дамас формализовал алгоритм в своей диссертации 1985 года. Система часто называется Hindley-Milner или Damas-Milner.
Любая функция с переменной типа автоматически полиморфна
Полиморфизм возникает только при let-generalization - когда свободные переменные типов обобщаются квантором ∀ в момент let-привязки. λ-bound переменные остаются мономорфными.
Без этого ограничения вывод типов стал бы неразрешимым (это доказано для System F / Rank-2+ типов). Let-полиморфизм - это компромисс между выразительностью и разрешимостью.
Почему выражение `λf → (f 5, f "hello")` вызывает ошибку типов в ML/Haskell?
Constraint Solving - генерация и решение ограничений
Вывод типов можно разделить на два этапа: **(1) генерация ограничений** (constraints) из выражения и **(2) решение** этих ограничений через унификацию. Такое разделение делает алгоритм модульным и расширяемым.
На первом этапе мы обходим AST выражения, назначаем свежие переменные типов и генерируем уравнения вида `τ₁ = τ₂`. На втором - последовательно унифицируем все уравнения, накапливая подстановку.
**Constraint-based подход** используется в современных компиляторах (GHC, OCaml, Rust) вместо чистого Algorithm W, потому что он лучше масштабируется: constraints можно собирать параллельно, а решать - в оптимальном порядке. Также ошибки типов становятся более информативными.
При выводе типа для `λf. λx. f x` генерируется constraint `τ_f = τ_x → τ_result`. Что означает этот constraint?
Algorithm W Дамаса-Милнера
**Algorithm W** - формальный алгоритм вывода типов, опубликованный Луисом Дамасом и Робином Милнером в 1982 году. Он объединяет генерацию constraints и unification в единую рекурсивную процедуру, обходящую AST выражения.
Ключевое свойство Algorithm W: он всегда находит **principal type** (наиболее общий тип). Если выражение имеет тип `Int → Int`, но также `∀a. a → a`, алгоритм вернёт `∀a. a → a` - любой другой допустимый тип является подстановкой principal type.
**Generalize** в Case 4 (Let) - это то, что отличает `let` от `λ`. Свободные переменные типов в `τ₁`, не встречающиеся в контексте `Γ`, обобщаются квантором `∀`. Именно поэтому `let id = λx.x` даёт полиморфный `∀a. a→a`, а `λf. f 1; f true` - ошибку.
| Свойство | Algorithm W | Constraint-based |
|---|---|---|
| Стиль | Bottom-up, рекурсивный | Двухфазный: генерация + решение |
| Principal type | ✅ Гарантирован | ✅ Гарантирован |
| Ошибки | Первое несовпадение | Все сразу, лучше диагностика |
| Расширяемость | Сложно добавить type classes | Модульное расширение constraints |
| Реализации | Классический ML, SML | GHC (OutsideIn), OCaml, Rust |
Algorithm W используется (с расширениями) в **Haskell** (GHC - OutsideIn с type classes), **OCaml**, **Rust** (частично, с trait bounds), **F#**, **Elm**, **PureScript**. TypeScript вдохновлён HM, но использует структурную типизацию и bidirectional inference.
**Сложность Algorithm W:** O(n) в типичных случаях, но теоретически может быть экспоненциальным (DEXPTIME-complete). На практике это не проблема - патологические случаи требуют глубоко вложенных let-выражений, которые редко встречаются в реальном коде.
Вывод типов - это просто подстановка типов, компилятор угадывает тип по значениям
Вывод типов - формальный алгоритм, основанный на унификации уравнений типов. Он не угадывает, а доказывает единственный наиболее общий тип из структуры выражения.
Компилятор не смотрит на значения (они неизвестны при компиляции). Он анализирует только СТРУКТУРУ выражения: какие операции применяются, как комбинируются функции. Это чисто синтаксический анализ с формальными гарантиями.
Что гарантирует Algorithm W при успешном завершении?
Ключевые идеи
- **Unification** решает уравнения типов через подстановку - если `α = Int → β` и `β = Bool`, то `α = Int → Bool`
- **Occurs check** предотвращает бесконечные типы: `α = List α` не имеет конечного решения
- **Let-полиморфизм** обобщает типы только при `let`-привязке - λ-bound переменные остаются мономорфными
- **Algorithm W** рекурсивно обходит AST и гарантирует нахождение **principal type** - наиболее общего типа выражения
- Современные компиляторы используют **constraint-based** вариант HM для лучшей диагностики ошибок и расширяемости
Связанные темы
Вывод типов - центральная тема теории языков программирования, связанная с системами типов, формальной логикой и дизайном компиляторов:
- Системы типов — HM - конкретная система типов с полной инференцией; более сильные системы (System F, зависимые типы) требуют аннотаций
- Лямбда-исчисление — HM типизирует simply-typed λ-calculus с let-полиморфизмом - это формальная основа ML-семейства
- Generics и параметрический полиморфизм — Let-полиморфизм HM - это и есть параметрический полиморфизм; generics в Java/C# вдохновлены этой идеей
Вопросы для размышления
- Почему разработчики Hindley-Milner выбрали let-полиморфизм вместо полного полиморфизма System F? Какой компромисс за этим стоит?
- Если Algorithm W находит principal type, почему в реальном коде всё равно рекомендуют писать аннотации типов для top-level функций?
- TypeScript использует структурную типизацию и bidirectional inference вместо классического HM. Какие преимущества и недостатки это даёт по сравнению с OCaml/Haskell?