Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Несколько лет назад были очень популярны статьи о том, „как ввести типы в Питон“, вернее добавить статическую типизацию. Язык Питон очень хорош для написания простых программ с небольшим количеством ветвлений, но в кровавом энтерпрайзе со временем система разрастается, программы усложняются, обрастают if-ами, обработками ошибок, сложным наследованием… И вот тогда отсутствие статической типизации даёт о себе знать. Появляются неожиданные падения, ошибки, программу становится трудно анализировать.
Да, можно использовать „фашистские“ правила кодирования, два-три разных линтера, "import typing“, писать километры тестов, cython, mypy (который, кстати, через запуск показывал предупреждение в одном моём скрипте, чем страшно меня удивил) и т.д. Но все эти меры паллиативны, убивают скорость и простоту разработки.
Почему бы не рассмотреть другие языки? Посмотрим же, что нам нужно от замены: лёгкость среды, те же платформы, где блистает Python, статическая типизация с выводом типов. Хотя кого я обманываю? Всем понятно, что выбранный для проекта язык в первую очередь зависит от компетенций команды, стандартов компании и т.д. Тем не менее, за сравнение вроде бы не бьют?
Давайте сформулируем, что же мы хотим от этого „заменителя Питона“. В качестве обязательных условий возьмём:
- Близость семантики к Питону.
- Компилируемость. (если уж типы проверяем, почему бы и не докомпилировать?)
- Вывод типов. (иначе можно взять cython)
- Желательно побыстрее, но это не критично. (где критично, там пишут библиотеки для Python на C).
- Хочется не терять „лёгкость“ Python – скорость разработки, быстроту выполнения „Hello world“ов (меня, лично, раздражают скрипты на 3 строки и пол минуты загрузки).
- И, конечно, общая приспособленность для „кровавого ынтерпрайза“ — хорошая поддержка разной сложной бизнес-логики и прочего ужаса.
Если мы посмотрим на какой-нибудь рейтинг языков, например TIOBE или PYPL, мы увидим, что из более-менее популярных подходят Rust, Swift, Kotlin. Увы и ах, но Rust не имеет сборщика мусора, Swift не кроссплатформен, Kotlin требует jvm. Мотаем дальше!
Во второй десятке PYPL мы находим Haskell – это уже ближе, хотя Haskell ленивый, чистый, но он со сборщиком мусора, native code, доступен более-менее на тех же платформах, что и Python. Но вот пятый пунктым Haskell не вышел — в его сообществе как-то принято считать, что пользователь всегда может чуть-чуть подождать. Конечно есть Clean с его великолепной эргономикой (0.1 сек на сборку „Hello World“), но сообщества вокруг него, к сожалению, нет. В общем, за медлительность и ленивость отказать, но Хаскель всё-таки стоит записать в маленькую чёрную книжечку.
Условно „промотав дальше“ мы найдём OCaml – вот этот язык уже значительно ближе к Python, чем Haskell. Собственно говоря, OCaml имеет все вышеперечисленные свойства, плюс, в отличие от Haskell, здесь почти нет монад (ну, если „на шкаф не залезать“). По пятому пункту полный зачёт — творцы из Инрии маниакально относятся к производительности компилятора, он получается даже быстрее Клина.
Сравнение языков
Итак, что же у нас есть из возможностей Python, а чего нет? Если просто взглянуть на две программы на этих языка станет очевидно одно – у них катастрофически различается синтаксис, „они похожи друг на друга, как мел и сыр.“ Кроме того, как известно, Ocaml — это функциональный язык, а Python — императивный и, вдобавок, объектно-ориентированный. Фиаско?
К счастью, оба языка не являются „ортодоксальными“ представителями своих семейств, поддерживая и другие парадигмы. В Ocaml'е есть объектно-ориентированная подсистема, и он допускает императивный код, а Python содержит ряд возможностей характерных для функциональных языков (кортежи, анонимные функции, функции высших порядков и т.д.). Посмотрим, достаточно ли этого для полноценной замены.
Так что там с императивностью в „функциональном языке“? Ну, во-первых, в отличие от Haskell в Ocaml есть побочные эффекты и даже оператор „точка с запятой“. Поэтому вполне можно награфоманить
let () =
begin
Printf.printf "Hello ";
Printf.printf "world!\n"
end
Имеются стандартные циклы for
и while
. Правда „верблюжий“ for слегка отличается от цикла for в Python. Но это лишь потому, что for в Python „шагает не в ногу“ — это же ведь foreach.
Есть в Ocaml и ссылки — тип ref
, позволяющие создавать ну совершенно императивный код, императивнее некуда:
let factorial n =
let z = ref 1 in
for i = 1 to n do
z := !z * i
done;
!z
И тут скептически настроенный читатель спросит — что же, теперь везде ставить эти галочки!?! Ну конечно же нет, в Ocaml есть и приветствуется name shadowing или переиспользование имён (в отличие от Haskell). Поэтому, если бы в Python мы хотели написать
def quad_sqr(r):
r = 2*r
return r*r
в Ocaml мы напишем
let quad_sqr r =
let r = 2*r in (* тут мы переиспользуем имя, а не присваиваем! *)
r*r
То есть, код получается в „псевдоимперативном“ стиле — вроде бы мы и не изменяем ничего, а по смыслу мы как бы и делаем присвоение (знакомые с компиляторами вспомнят про SSA форму, в которую переводится и C++ в clang, и Rust). Причём мы же можем и тип поменять! То есть, поведение при name shadowing получается очень похожим на Python, где мы тоже можем написать
...
x = 1
x = "uno"
...
...
let x = 1 in
let x = "uno" in
...
Кроме того, в Ocaml, как и в Python, нет не только функции Main
, но даже и символа Start
. Выражения верхнего уровня просто начинают выполняться сверху вниз, как будто интерпретатор их обычным образом читает.
В результате, cтруктура программ на этих языках очень похожа. И там, и там сперва подключаются библиотечные модули, а затем идёт список последовательно вычисляемых выражений. Правда, в Ocaml функции — это тоже значения, поэтому их нельзя вызывать до того, как они вычислены, то есть выше по тексту, а в Python можно. То есть, в Ocaml нужны небольшие указания компилятору, чтобы из функции f
вызвать g
, если текст g
расположен ниже (то есть, функции взаимно рекурсивные).
Поскольку структура небольших программ на этих языках очень похожа, зачастую можно просто механически перевести с одного языка на другой:
import os
x = 8
def f(z):
return z + x
print(f(2))
open Unix
let x = 8
let f z = z + x
Printf.printf "%d\n" (f 2)
Как вы уже заметили, типы я нигде не указывал. Это потому, что указывать их и не нужно — система типов позволяет выводить их почти всегда. Не сказать, что это так уж замечательно всегда и везде, но это факт. Кто не верит, может убедиться почитав исходники компилятора, например https://github.com/ocaml/ocaml/blob/trunk/typing/patterns.ml Заодно, кстати, станет понятно, где же у вывода типов тёмная сторона.
И там, и там есть объекты. Правда в OCaml их не любят, а в Python обожают. Если честно, мне кажется, что в кровавом энтерпрайзе могли бы немножечко и перестать приносить жертвы богам ООП.
Зато в Ocaml есть и широко используются модули! Благодаря прекрасному разработчику по имени Alain Frisch они даже могут быть значениями! Почти как в 1ML. Причём модули могут быть параметризованы, то есть, это функции над модулями (в Ocaml они называются функторами). Таким образом, модули слегка дублируют объекты, а значит и в Ocaml тоже можно реализовать огромное количество примеров из книги четырёх.
И да, кровавый энтерпрайз функторы в Ocaml любит точно также, как классы в Python. И тоже злоупотребляет.
Различия
Меня всегда удивляло, что многие знатоки Python относят списковые включения (list comprehensions) к „нефункциональному стилю“, подразумевая под функциональщиной map
, filter
и прочие функции высших порядков. Всё-таки, даже в ортодоксально функциональных языках вроде Miranda есть и то, и другое.
А вот в Ocaml этих самых list comprehensions нет. Совсем нет, да и не было никогда. Зато там есть функции высших порядков вроде List.map
и анонимные функции, а также стащенный из Haskell оператор ($)
превратившийся в очки (@@)
.
Кроме того, в Ocaml есть алгебраические типы и прекрасно разработанный и оптимизированный pattern-matching со всеми прелестями:
match (price, quantity) with
| (None, _) -> 0.0
| (Just x, v) when x > 0.0 -> x *. v
| (Just x, _) -> failwith "negative price"
Зато, как вы могли заметить, в этом чудесном отрывке, манящим нас в мир корректной бизнес логики, есть один странный оператор, вот этот — (*.)
. Очень похоже на умножение, но зачем там ещё точка? Увы и ах, но это результат отсутствия классов типов (traits
в Rust) — отдельный оператор умножения чисел с плавающей точкой. То есть, для целых в Ocaml у нас есть обычные +-*/
, а для „вещественных“ вот этот ужас: +. -. *. /
Конечно, если нужно писать большое количество формул, то можно просто переопределить операторы +-*/
. Но это всё равно неудобно. Увы, такова плата за строгую типизацию без классов типов.
Также без классов типов нет очень важного — простого интерфейса отладочной печати, то есть питоновского „print“. Конечно, есть масса разных реализаций с помощью препроцессоров PPX, но это всё не то. Гораздо проще было бы, позволяй компилятор генерировать функции toString
и print
. Причём, что очень обидно, компилятор умеет генерировать бинарные представления любых объектов — см. модуль Marshal
.
Таким образом, Ocaml всё же неидеальная замена Python. Главных недостатков два: нет классов типов и удобной отладочной печати любых структур.
Экосистема
Экосистема программ вокруг языка сейчас далеко не ограничивается одним компилятором/интерпретатором. Это и поддержка разных операционных систем, редакторов, отладчиков и т.д. Разумеется, поскольку Ocaml — это не язык из первой десятки, можно ожидать, что какие-то части программ экосистемы будут недоделаны, а какие-то вообще отсутствовать. Поэтому пробежимся по ним.
Компиляторы и интерпретаторы
Начнём, разумеется, с главного, с того, что вообще позволяет работать с языком не только в тетрадке — с компиляторов/интерпретаторов. Для Python'а существует масса интерпретаторов, включая интерпретаторы с JIT, все они на слуху, но основной, конечно, это cpython. В Ocaml всё значительно беднее, собственно компилятор и интерпретатор существуют в единственном экземпляре, github:/ocaml/ocaml.git, продукция Научно Исследовательского Института Inria.
Есть разные вариации этого компилятора, подточенные под разные ситуации (фирмы Lexifi, OcamlPRO, Facebook и д.р.). Основные целевые архитектуры компилятора — x86, ARM и POWER. Относительно недавно вместо MIPS добавлена архитектура RISC-V.
Вместе с компилятором в поставке Ocaml идёт интерпретатор байткода, ocamlrun. Скорость интерпретации примерно такая же, как у интерпретатора Python без JIT. Зато, ровно также как и Python интерпретатор Ocaml может работать на всём, на чём можно скомпилировать C-шный код.
Таким образом, компилятор в машинный код у Ocaml есть примерно на тех же плаформах, где у Python есть интерпретаторы с поддержкой JIT. А простой интерпретатор работает более-менее везде, хотя и с ограничениями. Самой-самой-самой основной целевой платформой Ocaml, как для Python'а, является Linux/x86-64. Таким образом, тут мы можем констатировать идеальную замену.
Библиотеки
Набор библиотек, с одной стороны, относительно скуден и изначально заточен под нужды компиляции, но с другой стороны, есть всё необходимое (по крайней мере мне).
Так для работы с JSON есть yojson, для асинхронного кода — lwt или async, для YML — yml, для тестирования — OUnit/OUnit2/QCheck и т.д. Если чего-то не хватает, но есть Cшная библиотека, можно сделать обёртку для подключения с помощью FFI (Foreign Function Interface), затратив примерно столько же времени, сколько стоит обёртка для Python'а.
Кроме того, есть же JaneStreet, давно работающая с Ocaml в совершенно разных областях с совершенно разными задачами. Поэтому можно воспользоваться библиотеками этой конторы, правда они сильно связаны друг с другом.
Отдельное слово стоит сказать про стандартную библиотеку. Она традиционна весьма бедна и консервативна. Тем не менее, за последнее десятилетие она подросла до более-менее разумного охвата. Разумеется, есть альтернативные стандартные библиотеки, в частности JaneStreet Core, batteries, ExtLib.
Посмотреть на доступные библиотеки можно тут — https://opam.ocaml.org/packages/
Системы сборки
Следующий важный инструмент — это система сборки. И тут, конечно, у Ocaml царит жуткий зоопарк. Складывается ощущение, что система сборки — это, что каждый программист на Ocaml обязан написать. К счастью, на данный момент вроде бы нашёлся „победитель“ — это система Dune. Несмотря на ряд недостатков, обусловленных происхождением из кровавого энтерпрайза с Wall Street, она имеет и ряд достоинств — потрясающую скорость, чудовищную масштабируемость, (в частности умение собирать сразу тучу проектов максимально быстрым образом), постоянные обновления для поддержки всего нового и прогрессивного.
Поэтому в большинстве проектов используется именно Dune, но (помним про кровавый энтерпрайз) с обвязкой в виде простого Makefile для удобства (например https://github.com/ocaml-community/sedlex/blob/master/Makefile ).
У Python, говорят, тоже есть какие-то сборки. Но любим мы его ведь не за это? Поэтому перейдём к следующему акту Марлезонского балета.
Пакетные менеджеры
И у Python, и у Ocaml есть пакетные менеджеры. У Python это pip, а у Ocaml — это OPAM (http://opam.ocaml.org). Оба менеджера предоставляют примерно одинаковые базовые возможности, но pip позволяет создавать пакеты wheel, а OPAM имеет механизм switch (позволяет использовать разные компиляторы с разными наборами установленных пакетов, для проверки под разными версиями экосистем).
Конечно, напрямую механизм wheel копировать в OPAM нет необходимости — можно ведь распространять бинарные скомпилированные программы (с байткодом, увы, это не получится — интерпретатор читает магическое число у программ и не запускает те, что были собраны с компилятором другой версии). Но, надо отметить, что очень не хватает в репозитории OPAM срезов длительной поддержки (для знатоков экосистемы Haskell, OPAM реализует hackage, но не реализует stackage). Отсутствие LTS срезов мешает делать автоматическое пакетирование программ и библиотек Ocaml в дистрибутивах Linux. Будем надеятся, что его всё-таки реализуют.
Линтеры и авто-форматирование, поддержка в IDE и прочее
Тут просто — доведённых до ума линтеров в Ocaml нет, а ocamlformat есть. Поскольку в сообществе особого орднунга нет, в природе наблюдаются несколько разных более-менее стандартных настроек этого ocamlformat. Поскольку язык компилируемый, всё очевидно и просто.
Вопрос с линтерами не закрыт отчасти потому, что они не особо и нужны. С одной стороны, язык компилируемый, поэтому на неиспользуемые переменные укажет компилятор. С другой стороны, язык значительно проще, чем С++, поэтому из предупреждений PVS-studio релевантны для Ocaml ну дай бог 5% (всякое связанное с булевскими операциями и одинаковыми ветками match/if).
Поддержка Ocaml в VSCode есть, причём достаточно хорошая. Дело в том, что ещё до появления стандарта LSP для языка Ocaml были разработаны плагины для Vim и Emacs, реализующие, грубо говоря, Intellisense. Поэтому существующая пара реализаций LSP для Ocaml умеют показывать выведенный тип переменных и выражений, компенсируя тёмную сторону Хиндли-Милнера.
Подсветка синтаксиса Ocaml есть, в целом, везде, где есть, скажем Cшная (кроме Хабра, конечно).
Документация генерируется с помощью двух утилит — старой ocamldoc и модной молодёжной odoc. Они совместимы по языку разметки комментариев, немного напоминая Doxygen, с которого вроде бы все и пошли. Или от чего-то другого?
В минимальной поставке компилятора включены базовые инструменты для генерации парсеров — Ocamllex и Ocamlyacc. Это, творчески сделанные порты lex и yacc (bison). В принципе, если вы по какой-то причине собираетесь сделать парсер для C/С++ на flex/bison, настоятельно рекомендую сделать сперва прототип на Ocamllex/Ocamlyacc, а потом его портировать на пару flex/bison. Такой подход убережёт от огромного количества проблем, т.к. Ocamllex/Ocamlyacc генерируют полностью типизированный код, который проверяется компилятором от и до.
Но, разумеется, помимо этих древних инструментов есть и другие генераторы, в частности, очень продвинутый Menhir, а также парсер-комбинаторы, в частности наиболее популярный сейчас Angstrem.
Развитие и люди
По сравнению с Python, Ocaml развивается значительно расслабленнее, сдержаннее и совместимее с собой. Программы 90х, использующие только Stdlib, скорее всего придётся совсем совсем слегка подработать напильником, но программы десятилетней давности будут работать как есть. Как правило, место приложения напильника и угол подпилки очевидны из выданных компилятором ошибок.
Неторопливость проявляется, например, в том, что очень интересная оптимизация TRMC (Tail Recursion Modulo Constructor) добралась до кода через 6 (шесть) лет после того, как её предложили. С другой стороны, вот вот уже появится „локальный термояд“ — близко завершение проекта Ocaml Multicore, реализующего „нормальную“ поддержку многопоточности. В общем, движение есть, прогресса… тоже есть.
С людьми в Ocaml всё обстоит сильно иначе, чем в Python'е. Толп нет, но народ присутствует. Как правило, программисты на Ocaml умеют писать на C и догадываются о наличии других языков программирования. То есть, относительно квалифицированы. :-)
В ряде мест Ocaml и родственный ему SML используются для обучения студентов программированию. В принципе, семантика Ocaml значительно интуитивнее, чем семантика Python — ядро языков семейства ML изложено с примерами на буквально 40 страницах „Введения в Стандартный ML“ Р. Харпера. Полная сводка синтаксиса — https://ocamlpro.github.io/ocaml-cheat-sheets/ocaml-lang.pdf
Ocaml считается наиболее удобным языком для построения компиляторов, поэтому он популярен среди компиляторщиков, особенно из Франции. Слово программа в переводе на французский пишется logiciel, и это не просто так: Ocaml широко применяется для разработки разных инструментов верификации, доказательства и т.д.
Другая часть сообщества — это любители Web, уставшие от JavaScript'а. Мне трудно судить, насколько это направление перспективно, но люди есть и что-то делают. Ключевые слова — bucklescript (транспайлер из Ocaml в JavaScript, генерирующий высококачественный код) и ReasonML — альтернативный синтаксис для Ocaml.
Третья часть — это Jane Street, где Ocaml пытаются воткнуть буквально везде. И хотя они открыли достаточно большое кол-во крайне полезного кода, их библиотеки — это немножко „кубик-рубик-монолит“. Просто по структуре и целям „кровавого энтерпрайза“ как-то само собой получается, что все библиотеки стремятся слепиться в единый ком. Разумеется, их можно упорядочить и разделить, но это отдельная деятельность, которой, как правило, занимаются когда уж совсем допекло.
Основное место общения людей из разных областей сейчас — https://discuss.ocaml.org/
и Github.
Основной сайт языка — https://ocaml.org
Сборник документации — https://ocamlverse.github.io/
Заключение
Таким образом, Ocaml вполне подходит для замены Python в разработках программ со сложной бизнес-логикой несмотря на некоторые недостатки.
Решающий плюс — развитая система алгебраических типов данных, включающая GADT и Polymorphic Variants (алгебраические типы без объявления), и отлично проработанный быстрый pattern-matching.
Никакие линтеры не позволят языку с динамической типизацией получить то, что имеет язык со статической типизацией „по праву рождения“. Увы и ах, но если язык позволяет подменять методы классов, этим неизбежно будет кто-то абсолютно легитимно пользоваться. И никакой линтер не способен изменить решения человека, написавшего вон тот библиотечный код 5 лет назад.
Серьёзные минусы Ocaml — отсутствие классов типов вообще и класса Show в частности. Да, есть возможность используя разные PPX реализовать относительно лёгкую отладочную печать, но это неудобно. С моей точки зрения, даже в Haskell нужно вставить обязательную генерацию какого-нибудь DebugShow для всех объектов вообще. А про Ocaml тут и говорить нечего.
Поэтому, конечно, не надо рассматривать эту статью как призыв бежать и менять Python на Ocaml везде и всюду. Да, статическая типизация в больших системах помогает, но есть ситуации, когда нам лучше, чтобы программа даже не была корректной, а уже как-то запускалась. Характерный пример — Wolfram или Jupyter notebooks. Согласитесь, если бы для вычисления какого-то выражения в них требовалась корректность всех ячеек, это была бы просто боль.
Тем не менее, когда разрабатываемая система содержит огромное количество ветвлений, её функциональность нельзя покрыть тестами из-за комбинаторного взрыва. Да и тесты далеко не бесплатны (бесстыдно занимаюсь самоцитированием — https://habr.com/ru/post/560370/ ). И тогда возникает время переходить к статической типизации.
Иной вариант — это прототипирование системы с заведомо сложной бизнес логикой, когда статическая типизация заставит учесть все возможные комбинации. А затем прототип можно переписать на Питон. Выглядит странно, но подобные случаи я видел.