В Serokell мы занимаемся не только коммерческими проектами, но стараемся изменить мир к лучшему. Например, работаем над улучшением главного инструмента всех хаскелистов – Glasgow Haskell Compiler (GHC). Мы сосредоточились на расширении системы типов под впечатлением от работы Ричарда Айзенберга "Зависимые типы в Haskell: теория и практика".  В нашем блоге Владислав уже рассказывал о том, почему в Haskell не хватает зависимых типов и как мы планируем их добавить. Мы решили перевести этот пост на русский, чтобы как можно больше разработчиков могло использовать зависимые типы и сделать дальнейший вклад в развитие Haskell как языка.  Зависимые типы – это то, чего мне больше всего не хватает в Haskell. Давайте обсудим почему. От кода мы хотим:  С имеющимся технологиями редко удается добиться всех трех характеристик, но с поддержкой зависимых типов Haskell задача упрощается. В основе Haskell лежит простая система: полиморфное лямбда-исчисление с ленивыми вычислениями, алгебраическими типами данных и классами типов. Именно это сочетание особенностей языка позволяет нам писать элегантный, поддерживаемый и в то же время производительный код. Чтобы обосновать это утверждение, вкратце сравним Haskell с более популярными языками. Языки с небезопасным доступом к памяти, такие как C, приводят к самым серьезным ошибкам и уязвимостям (например, переполнение буфера, утечки памяти). Иногда такие языки нужны, но чаще всего их применение – идея так себе.  Языки с безопасным доступом к памяти образуют две группы: те, что полагаются на сборщик мусора, и Rust. По всей видимости, Rust уникален в том, что предлагает безопасный доступ к памяти без сборщика мусора. Также есть уже не поддерживаемый Cyclone и другие исследовательские языки в этой группе. Но в отличие от них, Rust находится на пути к популярности. Недостаток в том, что несмотря на безопасность, управление памятью в Rust нетривиально и выполняется вручную. В приложениях, которые могут позволить себе применение сборщика мусора, время разработчиков лучше потратить на решение других задач. Остаются языки со сборщиками мусора, которые мы далее разобьем на две категории, основываясь на их системе типов. Динамически типизированные (или, скорее, монотипизированные) языки, такие как JavaScript или Clojure, не предоставляют статический анализ, а следовательно не могут обеспечить тот же уровень уверенности в правильности кода (и нет, тесты не могут заменить типы – нужно и то, и другое!).  Статически типизированные языки, такие как Java или Go, часто имеют сильно ограниченную систему типов. Это вынуждает программистов писать избыточный код и пускать в дело небезопасные возможности языка. Например, отсутствие обобщенных типов в Go вынуждает использовать interface{} и приведение типов времени выполнения. Также нет разделения между вычислениями с побочными эффектами (ввод, вывод) и чистыми вычислениями.  Наконец, среди языков с безопасным доступом к памяти, сборщиком мусора и мощной системой типов, Haskell выделяется ленивостью. Ленивые вычисления крайне полезны для написания композируемого, модульного кода. Они позволяют разложить на вспомогательные определения любые части выражений, в том числе конструкции, задающие поток управления.  Haskell кажется почти идеальным языком, пока вы не осознаете насколько он далек от полного раскрытия своего потенциала с точки зрения статической проверки по сравнению со средствами доказательства теорем, такими как Agda. В качестве простого примера того, где система типов Haskell недостаточно мощна, рассмотрим оператор индексирования списка из Prelude (или индексирование массива из пакета primitive): Ничто в этих сигнатурах типов не отражает требования, что индекс должен быть неотрицательным и меньше длины коллекции. Для программного обеспечения с высокими требованиями к надежности это недопустимо.  Средства доказательства теорем (например, Coq) — программные инструменты, которые позволяют с помощью компьютера разрабатывать формальные доказательства математических теорем. Для математика использование таких средств похоже на написание доказательств на бумаге. Различие в беспрецедентной строгости, требуемой компьютером, чтобы установить правильность такого доказательства.  Для программиста, однако, средство доказательства теорем не так уж отличается от компилятора для эзотерического языка программирования с невероятной системой типов (и, возможно, интегрированной средой разработки), и посредственным (или даже отсутствующим) всем остальным. Средство доказательства теорем – это, по сути, языки программирования, авторы которых все время потратили на разработку системы типизации и забыли, что программы еще надо запускать. Заветная мечта разработчиков верифицированного программного обеспечения – это средство доказательства теорем, которое было бы хорошим языком программирования с высококачественными генератором кода и средой выполнения. В данном направлении экспериментировали в том числе создатели Idris. Но это язык со строгими (энергичными) вычислениями, а его реализация на данный момент не отличается стабильностью. Среди всех средств доказательства теорем больше всего по душе хаскелистам Agda. Во многом она похожа на Haskell, но с более мощной системой типов. Мы в Serokell применяем её, чтобы доказывать различные свойства наших программ. Мой коллега Даня Рогозин написал серию статей об этом.  Вот тип функции lookup аналогичной оператору (!!) из Haskell: Первый параметр здесь имеет тип List A, который соответствует  в Haskell. Однако мы даем ему имя xs, чтобы обращаться к нему в оставшейся части сигнатуры типа. В Haskell мы можем обращаться к аргументам функции только в теле функции на уровне термов: А вот в Agda мы можем ссылаться на это значение xs и на уровне типов, что мы и делаем во втором параметре lookup, Fin (length xs). Функция, ссылающаяся на свой параметр на уровне типов, называется зависимой функцией и является примером зависимых типов.  Второй параметр в lookup имеет тип Fin n для n ~ length xs. Значение типа Fin n соответствует числу в диапазоне [0, n), так что Fin (length xs) это неотрицательное число меньше длины входного списка. Именно это нам и нужно, чтобы представить валидный индекс элемента списка. Грубо говоря, lookup ["x","y","z"] 2 проверку типов пройдет, а lookup ["x","y","z"] 42 не пройдет. Когда дело доходит до запуска программ на Agda, мы можем скомпилировать их в Haskell с помощью бэкенда MAlonzo. Но производительность генерируемого кода будет неудовлетворительна. Это не вина MAlonzo: ему приходится вставлять многочисленные unsafeCoerce, чтобы GHC принимал код, уже проверенный Agda. Но тот же unsafeCoerce снижает производительность (по итогам обсуждения этой статьи выяснилось, что проблемы с производительностью возможно вызваны иными причинами – прим. автора). Это ставит нас в затруднительное положение: нам приходится использовать Agda для моделирования и формальной проверки, а затем заново реализовывать ту же функциональность на Haskell. При такой организации рабочих процессов наш код на Agda выступает в качестве спецификации, проверяемой компьютером. Это лучше спецификации на естественном языке, но далеко от идеала. Цель – если код скомпилировался, то он будет работать в соответствии со спецификацией.   Стремясь к статическим гарантиям языков с зависимыми типами, GHC прошел долгий путь. В него добавляли расширения, увеличивающие выразительность системы типов. Я начал использовать Haskell, когда GHC 7.4 был новейшей версией компилятора. Уже тогда у него были основные расширения для продвинутого программирования на уровне типов: RankNTypes, GADTs, TypeFamilies, DataKinds, и PolyKinds. И все же полноценных зависимых типов в Haskell нет до сих пор: ни зависимых функций (Π-типов), ни зависимых пар (Σ-типов). С другой стороны, хотя бы кодировка для них у нас есть! Нынешние практики таковы: Это приводит к значительному количеству избыточного кода, но библиотека singletons автоматизирует его генерацию посредством Template Haskell.  Так что самые смелые и решительные могут закодировать зависимые типы в Haskell уже сейчас. В качестве демонстрации вот реализация функции lookup аналогичная варианту на Agda: И вот сессия GHCi, показывающая, что lookupS действительно отклоняет слишком большие индексы: Этот пример показывает, что осуществимое не означает практичное. Я рад, что в Haskell есть языковые возможности для реализации lookupS, но в то же время меня беспокоит возникающая при этом ненужная сложность. Вне исследовательских проектов такой стиль кода я бы не посоветовал. В этом конкретном случае, мы могли бы достичь такого же результата при меньшей сложности, используя индексированные длиной векторы. Однако прямой перевод кода с Agda лучше раскрывает проблемы, с которыми приходится иметь в других обстоятельствах.  Вот некоторые из них: Неудобство – это меньшая из проблем. Хуже то, что эти возможности языка работают ненадежно. Например, я сообщил о проблеме #12564 еще в 2016 году, а еще есть #12088 того же года. Обе проблемы препятствуют реализации программ более продвинутых, чем примеры из учебников (вроде индексирования списков). Эти баги GHC до сих пор не исправлены, и причина, как мне кажется, в том, что у разработчиков просто не хватает времени. Количество людей, активно работающих над GHC, удивительно мало, поэтому до некоторых вещей не доходят руки. Ранее я упомянул, что от кода мы хотим всех трех свойств, так что вот таблица, иллюстрирующая текущее положение дел:  Из трех доступных вариантов каждый имеет свои недостатки. Тем не менее, мы можем их исправить: Хорошая новость в том, что все три варианта сходятся в одной точке (в каком-то смысле). Представьте себе самое минимальное расширение стандартного Haskell, которое добавляет зависимые типы, и следовательно, позволяет гарантировать корректность кода способом его составления. Код на Agda можно компилировать (транспилировать) в этот язык без unsafeCoerce. А Haskell с расширениями – это, в некотором смысле, незаконченный прототип этого языка. Что-то понадобится улучшить, а что-то убрать, но в конечном итоге, мы достигнем желаемого результата. Хорошим показателем прогресса можно считать упрощение библиотеки singletons. По мере реализации зависимых типов в Haskell, обходные пути и специальная обработка частных случаев, реализованные в singletons, становятся не нужны. В конечном итоге нужда в этом пакете пропадет полностью. Например, в 2016 году с помощью расширения -XTypeInType я убрал KProxy из SingKind и SomeSing. Это изменение стало возможным благодаря объединению типов и видов. Сравните старые и новые определения: В старых определениях k встречается исключительно в позициях вида, справа от аннотаций вида t :: k. Мы используем kparam :: KProxy k для переноса k в типы. В новых определениях k свободно перемещается между позициями вида и типа, так что нам больше не нужен KProxy. Причина в том, что начиная с GHC 8.0 типы и виды относятся к одной и той же синтаксической категории. В стандартном Haskell есть три полностью разделенных мира: термы, типы и виды. Если посмотреть на исходный код GHC 7.10, то можно увидеть обособленный синтаксический анализатор для видов и обособленную проверку. В GHC 8.0 их больше нет: синтаксический анализатор и проверка для типов и видов общие.  В Haskell с расширениями вид – это всего лишь роль, в которой выступает тип: В GHC 8.0–8.4 все еще оставались некоторые различия между разрешением имен в типах и видах. Но к GHC 8.6 я их свел к минимуму: создал расширение StarIsType и внес функциональность TypeInType в PolyKinds. Оставшиеся различия я сделал предупреждением к GHC 8.8, и полностью устранил в GHC 8.10 (перевод данного параграфа обновлен, в оригинале проделанные работы описываются как будущие задачи – прим. автора). Каков следующий шаг? Давайте взглянем на SingKind в последней версии singletons:  Семейство типов Demote необходимо для учета расхождений между отношением типизации a :: t и отношением назначения вида t :: k. Чаще всего (для алгебраических типов данных), Demote это тождественное отображение: Следовательно, Demote (Either [Bool] Bool) = Either [Bool] Bool. Это наблюдение побуждает нас сделать следующее упрощение: Demote не понадобился! И, в самом деле, это сработало бы как в случае Either [Bool] Bool, так и с другими алгебраическими типами данных. На практике, однако, мы имеем дело и с неалгебраическими типами данных: Integer, Natural, Char, Text, и так далее. Если их использовать в качестве видов, они не населены: 1 :: Natural верно на уровне термов, но не на уровне типов. Из-за этого мы имеем дело с такими определениями: Решение этой проблемы в повышении примитивных типов. Например, Text определен так: Если мы как положено повысим ByteArray# и Int# на уровень типов, то сможем использовать Text вместо Symbol. Сделав то же самое с Natural и, возможно, парой других типов, можно избавиться от Demote, так ведь? Увы, не так. В вышесказанном я закрыл глаза на самый главный тип данных: функции. У них тоже особый инстанс Demote: ~> это тип, с помощью которого в singletons кодируются функции на уровне типов, основываясь на закрытых семействах типов и дефункционализации. Сперва может показаться неплохой идеей объединить ~> и ->, так как оба означают тип (вид) функции. Проблема в том, что -> в позиции типа и -> в позиции вида означают разные вещи. На уровне термов, все функции из a в b имеют тип a -> b. На уровне типов же, только конструкторы из a в b имеют тип a -> b, но не синонимы типов и не семейства типов. В целях вывода типов, GHC предполагает, что из f a ~ g b следует f ~ g и a ~ b, что верно для конструкторов, но не для функций – оттого и ограничение. Следовательно, чтобы повышать функции на уровень типов, но сохранить вывод типов, нам придется вынести конструкторы в отдельный тип. Назовем его a :-> b, для него действительно будет верно, что из f a ~ g b следует f ~ g и a ~ b. Остальные функции по-прежнему будут иметь тип a -> b. Например, Just :: a :-> Maybe a, но при этом isJust :: Maybe a -> Bool. Когда с Demote будет покончено, последний шаг – избавиться от самого Sing. Для этого нам понадобится новый квантор, гибрид между forall и ->. Давайте посмотрим на функцию isJust повнимательнее: Функция isJust параметризована типом a, а затем значением x :: Maybe a. Эти два параметра обладают различными свойствами: Чтобы выместить Sing, нам нужен квантор явный и релевантный, подобно a -> b, и в то же время зависимый, подобно forall (a :: k). t. Обозначим его как foreach (a :: k) -> t. Чтобы выместить SingI, также введем неявный релевантный зависимый квантор, foreach (a :: k). t. В результате singletons будут не нужны, так как мы только что добавили зависимые функции в язык. С повышением функций на уровень типов и квантором foreach, мы сможем переписать lookupS следующим образом: Короче код не стал, все-таки singletons довольно неплохо прячет избыточный код. Однако новый код намного проще: больше нет Demote, SingKind, SList, SNil, SCons, fromSing. Нет использования TemplateHaskell, так как теперь мы можем вызывать функцию len напрямую вместо создания семейства типов Len. Производительность тоже будет лучше, так как больше не нужно преобразование fromSing. Нам все еще приходится переопределять length как len, чтобы возвращать индуктивно определенный N вместо Int. Пожалуй, эту проблему лучше не рассматривать в рамках добавления зависимых типов в Haskell, ведь Agda тоже использует индуктивно определенный N в функции lookup. В некоторых аспектах Haskell с зависимыми типами даже проще, чем стандартный Haskell. Все-таки в нем термы, типы и виды объединены в общий однородный язык. Я легко могу представить себе написание кода в таком стиле в коммерческом проекте, чтобы формально доказать правильность ключевых компонентов приложений. Многие библиотеки на Haskell смогут предоставить более безопасные интерфейсы без сложности, сопряженной с применением singletons. Добиться этого будет не просто. Перед нами много инженерных проблем, затрагивающих все компоненты GHC: синтаксический анализатор, разрешение имен, проверку типов, и даже язык Core. Все понадобится модифицировать, или даже полностью перепроектировать. Chief executive officer @ Serokell