Flow typing: как Crystal сужает типы переменных
Смотрим на короткий код:
my_var = 5
# my_var: Int32
if some_complex_condition()
my_var = "hello!"
# my_var: String
end
# my_var: Int32 | StringПервая строка присваивает 5, и до входа в if компилятор знает точно: Int32. Внутри ветки my_var получает строку — и здесь тип сужается до String. А после if? Один путь оставил число, другой заменил его строкой. Гарантировать что-то одно компилятор уже не может, поэтому он выводит объединение типов: Int32 | String. Это и есть flow typing — тип переменной пересчитывается по мере движения по веткам кода, а не фиксируется один раз при объявлении.
Предположим, дальше вы вызываете строковый метод. Компилятор не пропустит вызов: переменная сейчас имеет тип Int32 | String, а метод есть только у String. Придётся добавить явную проверку — if my_var.is_a?(String). Внутри неё набор возможных типов сужается до одного, и вызов становится допустимым. Такое сужение называется narrowing.
Сравните с языками, где тип переменной жёстко привязан к объявлению. Там пришлось бы либо заводить две отдельные переменные, либо заранее объявлять что-то вроде общего супертипа. Crystal (а похожая логика есть и в TypeScript) обходится без этого: одна переменная живёт с разными типами в разных участках, а проверки вы добавляете только там, где без них не обойтись. Компилируемый язык при этом ощущается почти как динамический — но цена за такую гибкость платится на этапе компиляции, а не в рантайме.
Borrow checker в Rust: статическая защита от гонок данных
Два потока одновременно обращаются к одной переменной. Первый пишет туда новое значение, второй в этот же момент читает. Что увидит читатель — старое, новое или смесь байтов, зависит от планировщика и тайминга. Это и есть гонка данных: результат не воспроизводим, а баг может не проявляться неделями.
Обычный способ борьбы — блокировка чтения-записи. Она разрешает много читателей или одного писателя, но не оба режима сразу. Синхронизировать чтения между собой не нужно: достаточно упорядочить записи относительно всех остальных обращений. Проблема в том, что такую блокировку можно забыть поставить. Компилятор о ней ничего не знает и молчит.
Rust переносит это правило на этап компиляции. Компилятор требует, чтобы выполнялось одно из двух:
- либо ровно одна изменяемая ссылка
&mut T; - либо одна или несколько неизменяемых ссылок
&T.
Одновременно и то и другое — нельзя. Если у вас на руках &mut T, никто другой не держит ни &mut T, ни &T. Если у вас &T, изменяемых ссылок нет ни у кого. Ровно та же логика, что у блокировки чтения-записи, только проверяется она до запуска программы, а не во время.
Дополнительно любая ссылка не может пережить владельца. Пока владелец жив, ссылки валидны; владелец вышел из области видимости — ссылки тоже должны исчезнуть. Так Rust ловит не только гонки, но и висячие указатели.
Что это даёт на практике? Предположим, вы запускаете несколько потоков, которые считают статистику по общему буферу. С неизменяемыми ссылками на буфер потоки читают его параллельно, и это безопасно. Как только одному потоку понадобилось писать, он берёт изменяемую ссылку — и компилятор не пропустит код, где в этот момент кто-то ещё читает. Вместо редкого падения под нагрузкой вы получаете ошибку сборки.
Цена — сложность. Компилятор задаёт вопросы, на которые в динамических языках отвечать не приходится, и поначалу код приходится перестраивать. Но для конкурентных программ эта сложность не придумана компилятором, а присуща самой задаче: вам в любом случае нужно решить, кто и когда имеет право писать. Rust лишь заставляет принять это решение явно, а не оставляет на удачу.
Контрактное программирование в D: пред- и постусловия
Как вы обычно проверяете, что февраль не вернёт 30 дней? Скорее всего, никак — просто надеетесь, что isLeapYear написана правильно. В D такую надежду можно превратить в проверяемое утверждение, и компилятор не даст её проигнорировать.
Функция daysInFebruary(int year) в языке D может выглядеть так:
int daysInFebruary(int year)
out (result) {
assert((result == 28) || (result == 29));
} do {
return isLeapYear(year) ? 29 : 28;
}Блок out (result) — это постусловие. Оно выполняется после того, как тело функции вернуло значение, и получает это значение под именем result. Условие result == 28 || result == 29 описывает обязательство: функция не имеет права вернуть ничего другого. Если кто-то отредактирует тело и случайно вернёт 30, сработает assert — вы узнаете об ошибке раньше, чем она доберётся до пользователя.
Здесь стоит развести два инструмента, которые D разделяет намеренно. assert сигнализирует о нарушении инварианта программы: если он сработал — это баг в вашем коде. enforce, наоборот, выбрасывает исключение из-за внешних обстоятельств:
enforce(length >= 7, "Must be at least 7.");Разница в семантике принципиальна. Ввод пользователя короче семи символов — не баг программы, а некорректные данные извне, и здесь уместно исключение. А вот daysInFebruary, вернувшая 30, — именно баг: ни одно внешнее условие не способно заставить февраль длиться тридцать дней. Поэтому постусловие выражается через assert, а не через enforce.
Такая запись переносит проверку из головы читателя в код. Вам не нужно извлекать намерение автора из комментария // вернёт 28 или 29 — обязательство сформулировано на языке, который D понимает и проверяет. Подробнее синтаксис контрактов описан в спецификации языка.
Инварианты уровня класса в D: согласованность данных объекта
В классе BankAccount есть поле balance, и над ним висит правило: баланс никогда не уходит в минус. Вопрос не в том, как это правило сформулировать, а в том, где его проверять. В D ответ — блок invariant():
class BankAccount {
private double balance;
invariant() {
balance >= 0; // проверяется всегда
}
this(double initialBalance)
in (initialBalance >= 0, "Initial balance cannot be negative")
{
balance = initialBalance;
}
void deposit(double amount)
in (amount > 0, "Deposit amount must be positive")
out (; balance >= 0)
{
balance += amount;
}
void withdraw(double amount)
in (amount > 0, "Withdrawal amount must be positive")
in (amount <= balance, "Insufficient funds")
out (; balance >= 0, "Balance must remain non-negative")
{
balance -= amount;
}
double getBalance()
out (result; result >= 0, "Balance returned must be non-negative")
{
return balance;
}
}Здесь видны три роли. Блок in (...) — пред-условие: он проверяет аргументы до входа в тело. out (...) — постусловие: оно получает результат (а для методов без возврата — доступ к состоянию после вызова) и проверяет, что метод оставил объект в допустимом виде. А invariant() — общее правило объекта: не «на входе» и не «на выходе», а всегда, когда объект существует. Именно оно делает правило balance >= 0 свойством класса, а не свойством каждого отдельного метода.
Теперь сравните с привычным способом. Без invariant() вам пришлось бы вписывать assert balance >= 0 в конец deposit, в конец withdraw, в конструктор, а потом — в любой новый метод, который вы добавите через полгода. Один забытый вызов, и правило перестаёт действовать: метод возвращает отрицательный баланс, и никто об этом не узнаёт. С блоком invariant() забыть невозможно — проверка привязана к самому объекту, а не к вашему списку мест, куда её нужно вставить.
Отдельно про in и out: они не дублируют invariant(), а дополняют его. withdraw объявляет два пред-условия — сумма положительна и не превышает баланс, — и постусловие, что баланс остался неотрицательным. Постусловие с сообщением "Balance must remain non-negative" укажет на конкретный метод, если что-то сломается, тогда как invariant() сообщит лишь о нарушении общего правила. Это разделение полезно: по сообщению об ошибке сразу видно, дело в аргументах, в логике метода или в состоянии всего объекта.
Есть здесь и смысловая разница между двумя способами сообщить о проблеме. assert сигнализирует о нарушении инварианта программы: сработал — значит, в коде логическая ошибка. enforce, наоборот, выбрасывает исключение из-за внешней причины — скажем, пользователь ввёл слишком короткий пароль (enforce(length >= 7, "Must be at least 7.")). Для BankAccount это означает: отрицательный initialBalance или нехватка средств на счёте — повод не для assert, а для исключения, потому что причина во входных данных, а не в баге. А вот invariant() ловит именно баг: если после withdraw баланс ушёл в минус, значит, метод написан неверно.
Итог по фрагменту: invariant() убирает ручные проверки из начала и конца каждого метода, привязывая правило к классу, а не к дисциплине программиста. Пред- и постусловия остаются там, где они уместны — для аргументов конкретного вызова и его результата. Вместе они дают то, чего не даёт россыпь отдельных assert: одно место, где сформулировано правило, и гарантия, что оно проверяется при каждом входе и выходе из любого метода.
// пример из статьи: Programming in D (ddili.org)
int daysInFebruary(int year)
out (result) {
assert((result == 28) || (result == 29));
} do {
return isLeapYear(year) ? 29 : 28;
}Разница подходов: от документации к встроенным проверкам
Синтаксическая поддержка контрактов — это не просто «красивый синтаксис». Это разница между двумя способами поддерживать одно и то же правило. В первом случае вы пишете проверку руками и следите, чтобы её не забыли вызвать. Во втором — компилятор берёт эту обязанность на себя. Разберём на примерах.
Контракты в D vs. ручные проверки. Допустим, функция daysInFebruary(year) обязана возвращать только 28 или 29. Без поддержки языка вы либо ставите assert внутри тела — и надеетесь, что кто-то не удалит его при рефакторинге, — либо выносите проверку в отдельную функцию и вызываете её вручную в каждом месте возврата. В D то же самое правило записывается через out (result) { assert((result == 28) || (result == 29)); } do { ... }: пост-условие отделено от логики, компилятор гарантирует его выполнение, а читающий код сразу видит контракт, не выискивая assert среди вычислений.
Ещё нагляднее — разница между assert и enforce. Оба проверяют условие, но означают разное. assert(always_positive > 0) фиксирует нарушение инварианта программы — это баг в вашем коде. enforce(length >= 7, "Must be at least 7.") бросает исключение из-за внешней причины: пользователь ввёл слишком короткую строку, среда подвела. Когда оба случая пишутся одним if (...) throw, смысл теряется, а диагностика на проде страдает. Синтаксис заставляет вас выбрать намерение явно.
Сужение типов: ручная проверка vs. автоматическое. В Crystal переменная может менять тип по ходу жизни, и компилятор отслеживает это в каждой точке программы:
my_var = 5 # Int32
if some_complex_condition()
my_var = "hello!" # String
end
# здесь my_var имеет тип Int32 | StringПопытка вызвать строковый метод на my_var не скомпилируется — тип всё ещё объединение. Добавляете if my_var.is_a?(String) — и внутри ветки компилятор сужает тип до String. Без такого анализа вам пришлось бы либо приводить типы вручную с риском упасть в рантайме, либо держать отдельные переменные под каждую ветку и самим отслеживать, что где присвоено. Ручная работа здесь — это постоянные проверки и приведение, которые язык берёт на себя.
Заимствование в Rust vs. ручная синхронизация. Правила заимствования (&mut T — ровно одна мутабельная ссылка, либо любое число &T) — близкий родственник readers-writer lock: синхронизировать нужно только записи относительно чтений. Разница в том, что блокировку вы расставляете и снимаете сами, а компилятор Rust вставляет проверки в момент сборки. Цена — время компиляции и сложность, которую всё равно пришлось бы нести при написании конкурентного кода. Выгода — гонки данных отсекаются статически, до запуска.
Итог по секции: перенос идей (контракты, сужение типов, заимствование) в компилятор — это перенос рутины из головы разработчика в проверяемую машиной форму. Каждый раз, когда правило можно выразить декларативно вместо серии ручных if, вы получаете и меньше ошибок, и более читаемый код.
Что стоит перенять: выводы и один совет
Три идеи из этой статьи объединяет одно: они переносят проверки туда, где их нельзя случайно обойти — в компилятор или в синтаксис языка. Это меняет не столько код, сколько момент, когда вы узнаёте об ошибке.
Первый вывод — про время обнаружения. Flow typing ловит несоответствие типа в точке, где переменная используется после ветвления, borrow checker — гонку данных ещё до запуска программы, а контракты вроде in/out в D срабатывают на каждом входе и выходе из функции. Во всех трёх случаях ошибка всплывает у вас на машине, а не у пользователя в проде в три часа ночи. Разница между отладкой стека в логах и красной строкой в редакторе — это разница в часах работы.
Второй вывод — про цену. Каждая из этих проверок либо не стоит ничего в рантайме, либо стоит ровно столько, сколько нужно для самой проверки. Flow typing — это вывод типов, который происходит при компиляции. Borrow checker — compile-time анализ без сборщика мусора. Контракты можно включать в отладочных сборках и отключать в релизных. Вы не платите за безопасность производительностью, вы платите за неё вниманием при написании кода.
Третий вывод — про читаемость. Правило, записанное как invariant() { balance >= 0; }, нельзя забыть вызвать или случайно удалить при рефакторинге: оно часть объявления класса. Сравните с соглашением «не забудь проверить баланс в начале и конце каждого метода» — такое соглашение живёт в головах и в вики, а не в коде.
Практический совет: начните с контрактов. Не переписывайте проект на Rust и не мигрируйте на Crystal — возьмите одну функцию, у которой есть условие на вход и обещание на выход, и вынесите эти условия в явные проверки на границе функции. Даже без синтаксиса D это уже дисциплинирует: условие перестаёт быть комментарием и становится строкой, которую видно при чтении и которую можно включить в тестовой сборке. Если язык поддерживает сужение типов, добавьте проверку is_a? вместо приведения — компилятор сам проследит, что после неё тип действительно сузился. А когда наберётся десяток таких функций, станет видно, где проверки дублируются, и это будет сигналом, что пора вынести инвариант на уровень объекта.