Эссе о разработке игр, мышлении и книгах

Опыт использования Julia

Логотип Julia

Из-за непрекращающегося бардака в мире решил отвлечься от стресса и в итоге три недели учился кодить на Julia — портировал с Python один из своих экспериментальных проектов.

Я уже писал про впечатления от документации Julia — «теорию», а сейчас, так сказать, будет «практика».

Экспериментальный проект

Для экспериментов я выбрал собственный проект на Python, вдохновлённый лекциями о процедурной генерации контента.

Цели у оригинального проекта были следующие:

  • Подготовить фреймворк для собственных экспериментов с процедурной генерацией.
  • Создать DSL поверх Python, который будет также выразителен, как конфиги из лекций.
  • Поиграться с клеточными автоматами.

Процедурная генерация и клеточные автоматы хорошо ложатся на Julia, как язык для написания числодробилок. В то же время концепция фреймворка предполагает декомпозицию сущностей в достаточной степени, чтобы проверить основные фичи языка.

Поэтому я решил сделать копию проекта на Julia и получить новую площадку для экспериментов, а-ля Сказка для Python.

Код обоих проектов открыт:

В каждом проекте есть каталог examples с примерами использования библиотек.

Также заранее извиняюсь за то, что код не вылизан и, тем более, не доведён до идеоматичного состояния — эксперименты-с.

Оговорки

Само собой я не мог за три недели исследовать все особенности языка. Смотрел только то, что надо было для проекта.

Вот эти вещи я не трогал:

  • Тесты. Думаю с ними всё хорошо или будет хорошо, когда напишут хороший фреймворк.
  • Макросы. Выглядят удобными и относительно простыми.
  • Автоматическое / полуавтоматическое распараллеливание.
  • Хардкорную оптимизацию скорости вычислений.
  • Хардкорную интроспекцию.

Поскольку в своей жизни я много писал и на C++ и на Python, моё мнение о Julia, как о чём-то концептуально среднем между ними, должно быть более-менее объективным.

Молодость языка

Julia — молодой язык и обладает всеми соответствующими проблемами.

Библиотеки и документация имеют ожидаемое качество:

  1. Мало сторонних библиотек, особенно мало production ready.
  2. Многие сторонние библиотеки находятся в активной разработке: ломаются и чинятся.
  3. Скудная документация у сторонних библиотек. Бывает, её совсем нет. Та, что есть, раскрывает частные случаи, которыми занимается автор библиотеки.
  4. Официальная документация выглядит достаточно полной, но нет-нет, да упустит какой-нибудь нюанс.
  5. В официальной документации акценты сделаны на вещи важные для числодробилок, а не для разработки «обычного» софта.
  6. База знаний в интернете ещё не сформирована, не всегда получится найти решение даже простого вопроса. Многие обсуждения относятся к старым версиям языка.

Семантика и прагматика имеют существенные недоработки:

  1. Плохо и медленно работают глобальные переменные. Всю логику рекомендуется оборачивать в функции.
  2. Аналогичные проблемы есть у замыканий.
  3. Поддержка многопоточности ещё в стадии экспериментальной реализации.
  4. Управление пакетами сделали через консоль языка, а не консоль системы. Видимо, идея растёт из популярности jupyter в научном сообществе.
  5. Нет операторов вида «+=», «x += y» — это синоним для «x = x + y». Привет лишние копирования.
  6. В цикле «for» при итерации с распаковкой параметров обязательно надо указывать скобки, например: «for (x, y) in coordinates».
  7. Для структуризации сложных модулей используется прямая подстановка текста файлов (а-ля #include в препроцессоре C).
  8. Есть заметные недоработки в в логике работы с памятью, о них скажу отдельно.

Несмотря на молодость, язык обладает читабельными сообщениями об ошибках и удобной системой профилирования.

Профилирование я бы даже назвал лучшим из тех, что видел. Для каждой строчки можно легко посмотреть количество аллокаций памяти и подробную информацию о преобразованиях типов.

Скорость языка, в виду использования JIT и LLVM нареканий не вызывает.

Странности

В Julia есть ряд решений, которые лично мне не нравятся или даже нравятся, но мешают. При этом они не являются существенными помехами для использования языка.

Расширение файлов с кодом «.jl» я постоянно путаю с «.js».

Все индексы начинаются с 1 — это традиционная разница между «обычным» и научным программированием.

Несмотря на близкий к Python синтаксис, Julia имеет много мелких отличий, о которые постоянно спотыкаешься:

  1. Не надо ставить «:» после управляющих конструкций. Это круто, но питонист всё равно будет его ставить.
  2. Любой блок кода надо явно завершать ключевым словом «end». Как питонист я категорически не одобряю это решение. В итоговом DSL строки  с «end» занимают чуть-ли не половину кода.
  3. Отличается синтаксис подстановки значений в строки: вместо фигурных скобок используются круглые.
  4. Строки обозначаются только двойными кавычками, одинарные кавычки обозначают символ.

Система типов

Система типов оставила приятные впечатления: гибкая и простая для соответствующего уровня гибкости. Ориентирована на лаконичное описания множеств типов, на которые может ссылаться переменная.

На текущий момент система типов не приняла окончательный вид, необходимый для полноценного и удобного использования. Есть ряд проблем, вызванных молодостью языка, которые, надеюсь, будут исправлены в будущем:

  1. Неудобно описывать взаимно рекурсивные типы.
  2. Нет простого способа добавить свой алиас для примитивного типа вроде Int64. Чтобы алиас считался полноценным типом, необходимо описать структуру с единственным полем и переопределить для неё все необходимые операции. На ходу ввести такие типы как скорость или расстояние не получится.
  3. Не хватает модификаторов вроде «const», ограничивающих свойства типов полей в структурах. Это особенно критично в виду большого косяка с семантикой выделения памяти, о котором расскажу в конце поста.
  4. Нельзя указать ограничения шаблонных типов на уровне модуля. Например, если структура A может иметь поля типа T, где T — шаблонный параметр, хотелось бы указать, что T является подтипом Number в единственном месте модуля, а не в каждом объявлении функции.

Сложность создания алиасов для примитивных типов особенно мешает при попытке использовать на максимум диспатчинг методов.

Например, если есть несколько методов определения расстояния (манхэттенского, евклидового), было бы удобно, чтобы они возвращали расстояния разных типов. Тогда можно было бы специализировать дальнейшие вызовы функций под работу с конкретным типом расстояний.

Dynamic typing + Multiple dispatch + JIT

То, что я назвал бы эмерджентной killer feature.

Описывать эти особенности по отдельности нет смысла.

Динамическая типизация и JIT есть в большом количестве языков и давно разжёваны.

Множественная диспетчеризация — не такое частое явление, но без первых двух не создала бы должный эмерджентный эффект.

В частности, у диспетчеризации Julia есть аналоги в C++ — статическая перегрузка функций и шаблоны — недостаточно гибкие из-за статической природы языка.

В Python, как и в других динамических языках, есть множество реализующих её библиотек — недостаточно быстрых из-за динамической природы языков.

Можно сказать, Julia занимает сейчас довольно уникальную и выгодную позицию.

Благодаря удачному сочетанию фич язык поощряет полное разделение описания данных и алгоритмов, собирая их только во время выполнения программы.

Фактически, программирование на Julia предполагает разработку «соглашений» — протоколов работы с данными.

С одной стороны они используются высокоуровневыми алгоритмами, которые ничего не знают о данных.

С другой — реализуются отдельно для конкретных структур данных, которые ничего не знают об алгоритмах.

Протокол — это минимальный набор базовых операций над данными. Для примера,  протокол работы со стеком это операции push и pop.

В Julia рекомендуется считать доступ к любым атрибутам структур приватной операцией, допустимой только в функциях протокола. Это уменьшает вероятность возникновения случайных связей между алгоритмами и данными.

Таким образом, разработка на Julia заключается в том, чтобы определить протокол, который требуют используемые библиотеки, и забиндить на него функции работы со своими структурами данных.

Это сдвигает фокус разработки с иерархических систем на композиционные, о преимуществе чего я недавно писал.

Выглядит как идеальная штука для плагинов, бизнес-логики и подобных вещей.

Есть, конечно, и минусы.

Разработка протоколов, при всей их простоте, — задача творческая, а значит не решается с первой попытки. Это увеличивает риск несовместимости между разными версиями одной и той же библиотеки. Равно как и выдвигает повышенные требования к компетентности разработчиков библиотек для Julia.

Писать на Julia как на обычном динамическом языке, чтобы в будущем по взмаху волшебной палочки ускорить его типами и JIT, не получится. Если не продумать заранее соответствующую особенностям языка архитектуру, то весь динамический код придётся переписывать. На сколько этот процесс будет становиться легче по мере изучения идиом языка, я сказать затрудняюсь.

Поскольку код Julia компилируется, то первый запуск программы или запуск после большого количества изменений будет долгим.

Соответственно, будущее Julia, как языка быстрого прототипирования, а-ля Python, пока выглядит неубедительно.

Неортогональная логика работы с памятью

При работе с памятью есть несколько важных аспектов, которые так или иначе должен контролировать язык программирования:

  • Где выделять память: на стеке или в куче.
  • Когда выделять и освобождать память.
  • Как хранить и передавать данные между кусками кода: по значению (копированием) или по ссылке.

Традиционно эти вопросы решаются двумя способами:

  • Программисту дают возможность явно указывать, что должно происходить.
  • Язык декларирует использование одного конкретного подхода, убирая выбор как таковой.

Например:

  • В C++ программист сам решает когда и где выделяется память, когда освобождать, а также как данные передавать между функциями.
  • Python постулирует выделение памяти в куче и передачу объектов по ссылкам. За некоторыми исключениями, вроде семантики целых чисел. Выделяется и освобождается память автоматически.

Разработчики Julia нашли третий способ — логика работы с памятью зависит от описания типов.

Краткая суть работы с памятью в Julia:

  • Структуры данных делятся на изменяемые и неизменяемые. Чтобы сделать структуру изменяемой, её надо объявить с модификатором mutable. Дизайн языка предполагает, что работа идёт в основном с неизменяемыми структурами.
  • Память для изменяемых структур выделяется на куче отдельно для каждой структуры, передаются они по ссылке.
  • Память  для неизменяемых структур выделяется на стеке (по возможности), передаются они по значению.
  • Кроме изменчивости, структура данных обладает свойством isbitstype — является ли она plain data.
  • Plain data — это неизменяемые структуры без ссылок на другие объекты.
  • Массив plain data структур — это кусок памяти, зарезервированный под последовательность значений структур.
  • Массив не plain data структур — это кусок памяти, зарезервированный под последовательность указателей на структуры, даже если данные в них неизменяемые.

Такая логика неудобна при разработке обычного (не математического) софта. Банально нельзя быстро пройтись по массиву структур и изменить одно поле в них. Но это не самое страшное.

Страшно вот что:

  • Необходимо держать в голове два набора правил работы с объектами: когда оперируешь единичными объектами и когда оперируешь их массивами.
  • Изменение в структуре данных влияют на выделение памяти для всех структур, которые прямо или косвенно её используют. Независимо от того, владеет разработчик их описанием или нет, знает о них или нет.

Первую проблему пережить можно — в любом языке есть нюансы, о которых просто надо помнить.

А вот вторая — это жестокость, как она есть. Особенно для новичков.

Непредсказуемость изменений

В Julia нельзя предсказать, как изменение структуры данных повлияет на производительность.

Добавление модификатора mutable или поля со ссылкой на изменяющуюся структуру может вызвать взрывной рост мелких аллокаций памяти в случайном месте и, соответстветствующее замедление выполнения программы.

Просто из-за того, что ваша структура встраивается в какие-нибудь внутренние структуры данных тридевятой библиотеки. Они не обязаны быть сложными. Достаточно временного массива с обнуляемыми значениями, то есть с типом Union{Nothing, MyType}. Такие массивы примечательны ещё и тем, что из-за внутренней логики они будут выделять память даже при присваивании значения по индексу.

Этот «нюанс» крайне усложняет разработку с использованием сторонних библиотек как чёрных ящиков.

Программисту приходится знать точную логику работы всех зависимостей своего проекта, в контексте всех типов, которые могут в них просочиться.

По крайней мере до тех пор, пока он не проникнется духом Julia и не начнёт писать полностью идиоматический код, в возможности чего я сомневаюсь.

Цена забывчивости

Разработку естественно начинать с упрощенного описания типов, по необходимости конкретизируя его.

Если вы решили уточнить описание структуры, например, заменив поле абстрактного типа на шаблонное поле конкретного типа, то убедитесь, что параметр шаблона прокинут везде, где используется структура.

Julia никак не отследит забытый абстрактный тип поля (и будет права), что обеспечит вам незабываемое время поиска источника аллокаций при обращении, например, к атрибуту структуры.

Вечное профилирование

В итоге буквально любая операция в Julia: получение элемента по индексу в массиве, сравнение, получение значения поля структуры — может инициировать неожиданные выделения памяти, поиск и устранение которых выглядит сложным занятием, особенно для новичков.

Фактически, половину из трёх недель портирования я занимался профилированием кода.

Конечно, по мере изучения языка время профилирования уменьшается, но высокий порог входа в разработку на Julia вырисовывается вполне конкретно.

Обнадёживает только то, что исправить проблемы может быть не так сложно и в какой-нибудь Julia 3.0 разработчики расширят набор модификаторов в объявлении структур и разрешат явно определять логику работы с памятью.

Вердикт

Julia — молодой самобытный язык с большими перспективами и заметными «детскими» проблемами.

Язык однозначно ориентирован на научную и околонаучную среду, что создаёт препятствия для развития в качестве языка общего назначения.

Скорее всего язык production ready для большинства математических задач и не production ready для разработки «обычного» софта с длительным сроком поддержки. Это печалит. Рекомендую ждать успешные примеры сложных проектов на Julia.

У Julia есть все шансы дозреть к версии 3.0 и отгрызть значительный кусок почти у каждого из топовых языков. Если его разработчики отвлекутся от математики и обратят внимание на остальной мир:

  • Доработают работу с памятью.
  • Упростят переход от динамических прототипов к статически типизированным проектам.
  • Уменьшат порог входа в язык.

Если сделать это не получится, то Julia станет ещё одним полумёртвым нишевым языком программирования.

Кстати, если вам некуда приложить усилия и язык вам нравится, то сейчас отличное время, чтобы стать мейнтейнером какого-нибудь системообразующего пакета. Берёте любой известный пакет на Python, портируете на Julia, ?????, профит. Или заведите блог о Julia.

Независимо от судьбы языка, я ожидаю, что следующие большие изменения в индустрии произведёт язык реализующий сочетание Dynamic typing + Multiple dispatch + JIT.