четверг, 19 марта 2015 г.

Первые впечатления от Haskell

Где-то с полгода я начал подумывать о том, чтобы изучить новый язык программирования. У меня было три кандидата: Haskell, Rust и Clojure. В тот момент я как раз дочитывал книгу FP in Scala, откуда я узнал про монады, функторы и прочие функциональные абстракции, базирующиеся на концепции чистоты. Однако понимание этих абстракций мне казалось неполным без практики их использования в функционально чистом языке. Таким образом, я сделал логичный, по моему мнению, вывод и продолжил изучать ФП на Haskell, лишённого всего этого мусора альтернативных парадигм, который присутствует в Rust и Clojure.

Итак, общее впечатление следующее – Haskell мне определённо нравится. Т.е. если оценивать мой уровень удовлетворённости языком по десятибалльной шкале, то Haskell – это где-то 8.5/10 (Scala – 6/10, Java 8 – 4/10).

Что мне больше всего понравилось Haskell? В первую очередь, конечно же чистота языка. Чистота в обоих смыслах: чистота функций и полное отсутствие ООП-парадигмы. Это очень здорово, что ты можешь посмотреть на сигнатуру функции и понять, может ли она производить побочные эффекты или нет. Это позволяет соединять мелкие функции друг с другом, образуя более крупные, которые будут работать предсказуемым образом. Отсутствие ООП означает, что нет возможности сделать страшные вещи вроде непроверенных приведений из базового класса в подкласс. Да, есть алгебраические типы данных, скажете вы, но количество "наследников" ADT всегда ограничено (data Color = R | G | B) и фиксировано в текущем модуле, так что вы всегда можете проверить, что перебрали все варианты, сопоставляя выражения с образцами в вашей функции.

В целом, Haskell выглядит чрезвычайно продуманным языком. Если написан код, то его сложно интерпретировать двумя способами. Например, нельзя перепутать применение функции и ссылку на функцию, как в Scala, т.к. в Haskell функции являются объектами первого класса. Или, например, нельзя перепутать функцию с типом или классом, т.к. все функции должны начинаться с маленькой буквы, а типы/классы – с большой. Вообще, Haskell задумывался таким образом, чтобы код на нём не получался двусмысленным. В этом он, конечно, уступает LISP, в котором программист пишет сразу разобранное синтаксическое дерево, но среди многих других нелисповых языков Haskell в этом плане сильно впереди. Например, в некоторых языках есть фичи вроде перегрузки функций, функций с аргументами по умолчанию и переменным количеством аргументов. В Haskell ничего этого нету, из-за чего порой код получается немножко длиннее, но зато мы получаем другое ценное свойство – возможность легко читать и размышлять над кодом программы.

Ещё одна важная особенность языка – единообразие всего и вся. Например, в Java у нас есть примитивы, интерфейсы, классы, перечисления, массивы, аннотации, но в Haskell есть только один способ объявить тип – ADT. Вообще ADT – это то, чего мне остро не хватает в ООП-языках, а в Scala выглядит довольно громоздко и порой работает криво (вроде случая, когда у Nil тип Nil, а не List). Или, например, в Scala можно сделать функции каррированными, а можно и не сделать. В Haskell же все функции каррированы, а значит все функции могут быть частично применены.

Синтаксис Haskell очень приятен. Благодаря системе вывода типов Хиндли-Милнера типы выражений можно вообще не указывать, в итоге некоторые выражения выглядят настолько короткими и лаконичными, что могут посостязаться в этом плане даже с динамическими языками. Фигурные скобки, точки с запятой не нужны. В Haskell нету такого обилия круглых скобок, которые есть во многих других языках. Это достигается благодаря тому, что применение функции пишется не посредством указания аргументов в скобках, а посредством перечисления аргументов через пробел. В итоге, частичное применение аргументов работает просто перечислением лишь части аргументов. В Haskell есть строгие правила индентации, которые запрещают писать код с какими-попало отступами. В итоге код становится читабельнее.

Свойство языка, без которого разговор о Haskell был бы бессмысленным – это полиморфизм. Вряд ли будет преувеличением, если я скажу, что Haskell является языком с максимальным количеством переиспользованного кода. Любая хоть сколько-нибудь повторяющаяся функциональность выносится в абстракцию. Если две функции делают похожие вещи, то у них выделяется абстрактная часть и выносится в полиморфную функцию. В типичном коде Haskell большой процент функций является функциями высшего порядка, часто перегруженными. Общие свойства типов выносятся в классы: Eq, Show, Num, Read, Bounded, ... Некоторые типы требуют классов с более сложным видом: (* -> *) -> Constraint вместо * -> Constraint. Среди таких классов монады, функторы, аппликативные функторы. Но и на этом не останавливаются. Монады генерализуются ещё дальше – в стрелки и т.д.

Наконец, стоит упомянуть ещё об одной особенности Haskell, которая отличает его от других языков: ленивости. В Haskell выражения не вычисляются до тех пор, пока не потребуются. В итоге можно делать совершенно обалденные вещи вроде бесконечных списков, рекурсивных типов и бесплатного потокового ввода-вывода. Потоковый I/O – это когда ты не читаешь/пишешь всё сразу, а частями, по мере необходимости. В итоге, программа которая читает содержимое файла и пишет его в другой файл выглядит так, как будто она читает всё сразу, но на самом деле не выделяет при этом в памяти блоки размером с весь файл.

Среди прочих приятных "мелочей" – статическая компиляция, кроссплатформенность, легковесные потоки (bye-bye код с асинхронными callback'ами), потокобезопасность, огромное количество готовых библиотек.

Пожалуй хватит о преимуществах. Теперь поговорим о недостатках. Их не так много, но они есть.

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

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

Наконец, в-третьих – это большое количество расширений компилятора. Для меня как Java программиста вообще такое слово незнакомо. Т.е. Java-код либо компилируется, либо нет, и не существует никаких расширений, которые можно подключить. В Haskell же есть огромное количество расширений, которые нужно помнить. Практически ни одна библиотека не написана исключительно на чистом Haskell – везде используется как минимум одно-два расширения. Некоторые библиотеки используют десятки расширений, и код таких библиотек понять для меня пока нереально.