Почему хаскель не подходит для скриптования?
Прежде чем обсуждать недостатки такого безусловно примечательного инструмента следует определиться с таким самоочевидным понятием как скриптование. Речь пойдёт в первую очередь о так называемых скриптах пользователя: небольших программах обеспечивающих интерфейс взаимодействия пользователя и более сложной системы. Например скрипты emacs’а, vim’а или даже acpid’а. В определённой степени можно говорить и командной оболочке операционной системы в принципе с некоторыми оговорками. Следует различать пользовательские скрипты и приложения для одного запуска: последние могут сколь угодно сложны, громоздки, запутаны, не очевидны и нечитабельны, поскольку читать их никто не будет и пишутся они обычно на одном дыхание, а со своими скриптами вам скорее всего ещё долгое время жить. Итак пользовательский скрипт это программа:
- предоставляющая интерфейс к более сложной системе чем система самого скрипта (в противном случае было бы проще поправить код системы)1;
- небольшая (в противном случае кто кого скриптует и кто куда встроен2);
- понятная (поскольку никто даже комментариев не пишет, не говоря уж про документацию);
- как правило не критичная по производительности.
Для таких задач традиционно используются shell, lua, js, scheme и прочие языки поразительно схожие с этими синтаксисом и семантикой (хотя некоторые умудряются для этого сравнительно успешно использовать целый python). Что объединяет эти языки? Слабая (у всех кроме scheme) динамическая типизация и позднее связывание. Оно и понятно, считается что если убрать из программы аннотацию типов то она станет выглядеть проще. Однако на сдачу мы получаем невозможность проверить хотя бы наличие вызываемых функций и существование используемых переменных не говоря уж о разумности применения одного к другому. Поскольку покрывать тестами подобного рода код будут немного позже того как к нему напишут документацию, то ситуация выглядит так себе.
При этом следует понимать, что статическая типизация и ранее связывание вообще говоря совсем не требуют аннотации типов. Компилятор зачастую может понять какой тип имеет то или иное выражение просто из контекста используемых функций. Компилятор может осуществлять вывод типов даже в таком языке как C++, где система типов вообще один из кругов Ада. Для более простого языка это осуществить ещё проще. Всё что ему нужно это твёрдая уверенность, что в середине выполнения программы не появятся новые функции, а семантика существующих типов не изменится. Довольно забавно, но для очерченных нами задач это практически всегда верно поскольку для решения простых задач такие динамические возможности едва ли понадобятся, а сложные мы вроде бы решать и не собирались3.
Теперь посмотрим в свете наших задач на Haskell. Итак Haskell это ленивый, функциональный язык программирования с полиморфизмом на классах типов:
- вывод типов вместо динамической типизации: прекрасно работает;
- функции высшего порядка вместо метапрограммирования: с учётом возможности определять операторы и тем что у основных конкурентов нет разделения на время компиляции и время исполнения, ок;
- полиморфизм всего и вся вместо динамической диспетчеризации: вполне хватает;
Плюс сопоставление с образцом, которое у конкурентов как правило отсутствует (ну кроме scheme и прочих лиспов). Что не так? А не так примерно следующее:
Плохая стандартная библиотека. Многие вещи сделаны плохо поскольку пришли к нам из тех времён когда функциональные языки не умели готовить. Многие вещи сделаны плохо поскольку сделано с учётом особенностей реализации ghc. Так например несмотря на полиморфизм, большая часть функций Prelude мономорфна по используемому контейнеру. Или по используемому числовому типу. В той же стандартной библиотеке есть полиморфные функции, но они запрятаны в другие модули и часто создают конфликт имён, разрешение которого портит всю радость. Ну и за Monad который не Functor отдельное спасибо.
Сложная стандартная библиотека. В Prelude есть целых два типа для для обозначения целого знакового числа: Int (для числа размером в машинное слово) и Integer (для целого числа бесконечной точности). В Data.Int можно найти целые числа другого размера, а в Data.Word беззнаковые целые числа. Такое разнообразие типов безусловно не доведёт до добра скриптописателя. Он может
испугатьсяначать решать проблемы производительности и не допишет свой скрипт никогда, поскольку будет писать стандартные функции для работы с Word8, отсутствующие в стандартной библиотеке. Для сравнение Lua использует только один тип — Number, который представляет собой закоробкованное число с плавающей запятой двойной точности (то есть Double).Стандарт языка. Haskell2010 довольно простой язык у которого есть всё чтобы писать на нём программы. Однако программы эти будут содержать чёртову уйму бойлерплейта, интересных структур данных и коллизий пространств имён. Можно воспользоваться расширениями GHC. Код безусловно станет лаконичнее и яснее, но возникнут другие проблемы: выбор языковых средств (самый банальный пример это функциональные зависимости vs семейства типов), неоднозначности приводящие к трудности проверки типов, усложнение системы типов настолько что язык становится понятным вообще только компилятору. А ещё расширений так много что весь GHC-haskell не знает никто. Вместе с этим, стандартная библиотек старается по возможности избегать языковых расширений, что делает её именно такой как было описано в начале.
Реализация. Мне на сегодняшний день доводилось иметь дело с тремя реализациями haskell.
GHC. Мы говорим haskell подразумеваем GHC. Мы говорим о стандартной библиотеке haskell подразумеваем base, а вовсе не haskell2010. Если вы хотите использовать существующие библиотеки то вам придётся использовать GHC. Впрочем это очень хороший компилятор с очень мощной и гибко настраиваемой рантайм-системой: в то время как кто-то всё ещё считает ссылки и никак не выпилит GIL, рантайм-система GHC вертит тысячами тредов и дефрагментирует кучу. Однако у этого всего есть оборотная сторона. GHC громоздок, как в сборке так и в виде библиотеки, а его рантайм избыточен и на небольших приложениях выглядит как лазерная атака из космоса для того чтобы поджарить тосты. Несмотря на все ухищрения и тюнинг рантайма ваше приложение вряд ли будет использовать меньше мегабайта под кучу. Не исключено, что больше тоже вряд ли, но тем не менее.
Jhc/Ajhc. Компилятор haskell в C. Если GHC придерживается традиционной модели компиляции, где есть раздельная компиляция различных единиц трансляции, а затем линковка, то Jhc проводит анализ, трансформации и компиляцию всего кода проекта. Полученный код на C отчаянно пытается управлять памятью при помощи выведенных регионов, но не справляется и всё в итоге опять скатывается в сканирующую сборку мусора. Эта реализация намного проще и компактнее, но по своей природе больше стремиться быть хаскелем для микроконтроллеров. Язык который она поддерживает — Haskell98 и несколько GHC-шных расширений того времени. Для нашей задачи она бесполезна.
Haste. Компилятор haskell в JS. Несмотря на жутковатый мапинг данных хаскеля в данные JS и время от времени возникающие проблемы с продолжениями, эта реализация ближе всего к нашим задачам. Haste использует фронтенд ghc и работает уже непосредственно с stg-кодом. Это решает проблему расширений, поскольку почти все они реализованы на уровне фронтенда. И всё же это 40 мегабайт компилятора и 13 — nodejs. Многовато. Кроме того v8 жрёт памяти как не в себя и встраивается только инженерами гугла.
Слишком много приведений типов. Вот код на хаскеле:
avrg xs = sum xs / length xs
Среднее по списку значение это сумма всех значений в списке разделённая на длину списка. Всё хорошо? Нет. Оператор (/) не определён для целочисленного типа. Длинна списка это значение типа Int. Но даже если она будет Integer или сферический целочисленный тип в вакууме (Integral) ничего не изменится: делитель, делимое и результат должны быть одного типа, а в данном случае нас интересует именно нецелочисленный результат4. Нужно писать как-то так:
avrg xs = sum xs / fromIntegral (length xs)
В lua нет такой проблемы поскольку как уже писалось выше там всего один тип для записи чисел вообще. Однако в C (от которого в известной степени хаскель унаследовал примитивные типы) такой проблемы тоже нет: компилятор просто неявно приводит целое число к какой-нибудь плавучке.
Пущей объективности ради следует заметить, что языки из круга конкурентов совсем не обязательно лишены подобных недостатков. И тем не менее будем считать что всё дело именно в них. Если всё перечисленное плохо и не нужно, то что нужно?
Компилятор некого семантического надмножества Haskell2010. Стандарт довольно ядра языка довольно лаконичен. Возможно можно было бы исключить из него newtype поскольку он опять же заставляет задумываться, а в нашем случае это плохо. Из расширений стоит оставить:
Многопараметрические классы типов и функциональные зависимости. Концептуальным решением было бы даже ограничиться классами где все параметры кроме одного зависимы5.
Перегрузка оставшихся немногочисленных литералов. В стандарте перегружены числовые литералы и do-нотация. Соответственно остаётся перегрузить строковые (есть в ghc) и списки (есть в ghc).
GADT’ы. Упрощают описание древовидных структур и гетерогенных коллекций.
Data/Generic и прочее обобщённое программирование. В плане унылости мало что может потягаться с case’ом по двенадцати конструкторам, одиннадцать веток которого не делают ничего.
Управляемое неявное приведение типов.
Компиляция неразделяемого кода. Шаблонный полиморфизм как в C++ и jhc. Тотальный инлайнинг и трансформация всего кода программы (интересно, насколько это реально в условиях just it time компиляции).
Кодогенератор в некую простую существующую переносимую виртуальную машину или даже язык. Предпочтительнее система со сканирующей сборкой мусора (что-то кондовое вроде mark & sweep) и без jit-компилятора. Писать свою динамическую среду исполнения не стоит. Их уже итак больше чем нужно.
Простая стандартная библиотека.
Было бы забавно обозвать монаду аппликативным функтором, но не понятно насколько это будет корректно.
Foldable и Traversable сделать классами от двух переменных где тип содержимого зависит от типа контейнера.
Не хранить строки в виде списка чаров. Это вызывает слишком много волнения и других строк. По большому счёту строка это массив кодпоинтов, но непонятно как тогда осуществлять паттерн-матчинг.
Стандартизованный механизм и формат исключений (вот уже GADT и в стандартной библиотеке).
Стандартные монадные трансформеры? Почему бы и нет. Плюс монада для логического вывода с бэктрекингом.
Регулярные выражения и комбинаторы парсеров. Последнее вообще киллер-фича функциональшины и ML-подобных.
Стандартные контейнеры типа Map, Set и так далее.
Парсеры для работы c json, xml, yaml.
Ну или у вас просто нет возможности поправить код системы. Но тогда горе побеждённым! Какой смысл обсуждать достоинства и недостатки brainfuck, если это единственный доступный вам способ изменить поведение вашей системы?↩
Угу. Например в код emacs на elisp встроено небольшое ядро на C.↩
Существует спорное утверждение, что динамика очень нужна для создание EDSL’ей которые действительно позволяют повысить простоту кода и довольно эффективно съесть сложность и громоздкость кода.↩
Иначе у нас бы использовалось целочисленное деление.↩
Да-да. Это семейства типов. Но они выглядят более громоздкими. Функциональные зависимости более лаконичны что-ли.↩