Elm — это функциональный язык, компилируемый в JavaScript. Данная статья является кратким введением в Elm, она поможет быстро вникнуть в суть и начать писать код на Elm. Ключевыми фичами языка являются:

  • Отсутствие ошибок в рантайме. В отличие от JavaScript, код, написанный на Elm, не выдает рантайм ошибок. Elm использует выведение типов для обнаружения проблем во время компиляции и выдает дружелюбные подсказки. Таким образом, ошибки никогда не доходят до конечного пользователя. В NoRedInk написано 36 тысяч строк на Elm, и за год промышленной эксплуатации код ни разу не упал в рантайме.
  • Высокая производительность. Elm использует собственную реализацию виртуальной DOM, ориентированную на простоту и скорость. Все значения в Elm иммутабельны, и бенчмарки показывают, что это выгодно сказывается на генерации действительно быстрого JavaScript кода:

Содержание

Основы языка

Первая часть статьи покрывает основы и синтаксис языка.

Хеллоу ворлд

Напишем первое приложение на Elm. Создадим директорию для приложения и выполним в командной строке:

elm package install elm-lang/html

Так мы создадим модуль html . Затем добавим файл Hello.elm со следующим кодом:

module Hello exposing (..)

import Html exposing (text)

main =
    text "Hello"

Перейдем в эту директорию и выполним:

elm reactor

Мы должны увидеть следующее:

elm reactor 0.17.0
Listening on http://0.0.0.0:8000/

Откроем http://0.0.0.0:8000/ в браузере и выберем Hello.elm. Мы должны увидеть Hello на открывшейся странице.

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

Давайте рассмотрим, что мы вообще имеем.

Объявление модуля

module Hello exposing (..)

Каждый модуль в Elm должен начинаться с объявления модуля, в данном случае модуль называется Hello. По соглашению, название файла и наименование модуля должны совпадать, т.е. Hello.elm содержит module Hello.

Часть объявления — exposing (..)  — указывает, какие функции и типы данный модуль выставляет «наружу» для других модулей, использующих этот. В данном случае, мы выставляем все — (..) .

Импорты

import Html exposing (text)

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

Этот модуль содержит множество функций для работы с html. Мы будем использовать .text , поэтому мы импортируем эту функцию в текущий неймспейс, используя exposing .

Main

main =
    text "Hello"

Фронт-энд приложения на Elm стартуют с вызова функции main , которая возвращает элемент для отрисовки на странице. В данном случае — Html элемент, создаваемый функцией text .

Elm reactor

Команда elm reactor  создает сервер, который компилирует Elm код на лету. Реактор полезен для разработки приложений, чтобы не сильно заморачиваться насчет настройки процесса сборки. Тем не менее, реактор имеет свои ограничения, поэтому позже мы перейдем к собственному процессу сборки.

Значимые типы

Начнем со строк:

> "hello"
"hello"
> "hello" ++ "world"
"helloworld"
> "hello" ++ " world"
"hello world"

Elm использует оператор (++)  для конкатенации строк. Заметим, что обе строки сохраняют свою форму до сложения — при сложении «hello» и «world» у нас не будет пробелов.

Ничего особенного и в вычислениях:

> 2 + 3 * 4
14
> (2 + 3) * 4
20

В отличие от JavaScript’а, Elm различает целые и дробные числа. Поэтому, так же, как и в Python 3, имеется и целочисленное деление — //, и с плавающей запятой — /.

> 9 / 2
4.5
> 9 // 2
4

Основы функций

Рассмотрим основы синтаксиса языка для знакомства с  функциями, сигнатурами функций, частичным применением функий и pipe-оператором.

Функции

Рассмотрим функцию в Elm:

add : Int -> Int -> Int
add x y =
    x + y

Первая строка — пример сигнатуры функции. Сигнатуры не обязательны в Elm, но рекомендованы к использованию, т.к. это придает ясности назначению функции.

Данная функция add  принимает два целых числа (Int -> Int ) и возвращает другое целое (третий -> Int ).

Вторая строка — объявление функции. Параметры — x  и y .

Далее мы видим тело функции — x + y  — которое просто возвращает сумму двух параметров.

Мы можем вызвать эту функцию так:

add 1 2

Группировка скобками

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

add 1 (divide 12 3)

Здесь результат divide 12 3  передается add  в качестве второго параметра.

Для сравнения, в многих языках это выглядело бы так:

add(1, divide(12, 3))

Частичное применение

В Elm мы можем взять функцию, например add  из примера выше, и вызвать ее, передав только один аргумент, т.е. add 2 .

Это вернет другую функцию со значением 2 , привязанным в качестве первого аргумента. Вызов этой функции со вторым аргументом вернет 2 + значение этого аргумента :

add2 = add 2
add2 3 -- result 5

На сигнатуру функции, например add : Int -> Int -> Int , можно взглянуть с другой стороны — это функция, которая принимает одно целое число и возвращает другую функцию. Полученная функция принимает еще одно целое и возвращает результат.

Частичное применение очень полезно в Elm, т.к. делает код более читаемым и позволяет передавать состояние между функциями в приложении.

Pipe оператор

Как показано выше, можно вкладывать вызовы функций так:

add 1 (multiply 2 3)

Это примитивный пример, но представим более сложный:

sum (filter (isOver 100) (map getCost records))

Такой код сложно читать, потому что он выполняется «изнутри». Pipe-оператор позволяет нам записывать такие выражения в более читаемом виде:

3
|> multiply 2
|> add 1

Это основывается на частичном применении. В этом примере значение три передается частично примененной функции multiply 2 . Ее результат, в свою очередь, передается другой частично примененной функции add 1 .

Используя pipe оператор, мы можем переписать сложный пример выше вот так:

records
|> map getCost
|> filter (isOver 100)
|> sum

Еще про функции

Типы переменных

Рассмотрим функцию с такой сигнатурой:

indexOf : String -> Array String -> Int

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

Но что если у нас есть массив целых чисел? Нам бы не удалось использовать эту функцию. Однако, мы можем сделать эту функцию обобщенной (generic), используя переменные типа или подмены (stand-ins) вместо конкретных типов переменных.

indexOf : a -> Array a -> Int

После замены String на a , сигнатура теперь означает, что indexOf  берет значение любого типа a  и массив этого же типа a , и возвращает целое число. До тех пор, пока типы совпадают, компилятор будет доволен. Мы можем вызывать indexOf  со String  и массивом String  или Int  и массивом Int , и это будет работать.

Таким же образом функции можно делать более обобщенными. Мы можем иметь несколько переменных типа:

switch : ( a, b ) -> ( b, a )
switch ( x, y ) =
( y, x )

Такая функция принимает кортеж (tuple) типов a  и b , и возвращает кортеж b  и а . Все эти вызовы валидны:

switch (1, 2)
switch ("A", 2)
switch (1, ["B"])

Функции как аргументы

Рассмотрим сигнатуру:

map : (Int -> String) -> List Int -> List String

Такая функция:

  • Принимает функцию: часть (Int -> String)
  • Список целых чисел
  • Возвращает список строк

Нас интересует часть (Int -> String). Она говорит о том, что должна быть передана функция, соответствующая сигнатуре (Int -> String).

Например, toString  из стандартной библиотеки является такой функцией. Поэтому мы можем вызвать функцию map  вот так:

map toString [1, 2, 3]

Но Int  и String  слишком конкретны. Поэтому чаще сигнатуры будут выглядеть так:

map : (a -> b) -> List a -> List b

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

Например, имея функции с такими сигнатурами:

convertStringToInt : String -> Int
convertIntToString : Int -> String
convertBoolToInt : Bool -> Int

Мы можем вызвать обобщенную map вот так:

map convertStringToInt ["Hello", "1"]
map convertIntToString [1, 2]
map convertBoolToInt [True, False]

Выражения If

Если нам требуется ветвление, мы используем if-выражение:

> if True then "hello" else "world"
"hello"

 

> if False then "hello" else "world"
"world"

Ключевые слова if  then  else  используются для разделения условия и двух веток, поэтому нам не нужны скобки.

Elm не имеет представления о «правдивости», поэтому числа, строки, списки не могут использоваться в качестве логических значений. Если мы попытаемся это сделать, Elm сообщит нам, что нам следует использовать настоящие булевские значения.

Напишем функцию, которая ответит, больше ли переданное число, чем 9000:

> over9000 powerLevel = \
|   if powerLevel > 9000 then "It's over 9000!!!" else "meh"
<function>

> over9000 42
"meh"

> over9000 100000
"It's over 9000!!!"

Использование обратного слэша в REPL позволяет нам разбивать код на несколько строк, что мы и используем в функции over9000  выше. Более того, это best practice — всегда переносить тело функции на строчку ниже, такой код более однообразен и проще читается.

Списки

Списки являются одной из самых распространенных структур данных в Elm. Они содержат последовательность как-либо связанных данных, как и массивы в JavaScript.

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

> names = [ "Alice", "Bob", "Chuck" ]
["Alice","Bob","Chuck"]

> List.isEmpty names
False

> List.length names
3

> List.reverse names
["Chuck","Bob","Alice"]

> numbers = [1,4,3,2]
[1,4,3,2]

> List.sort numbers
[1,2,3,4]

> double n = n * 2
<function>

> List.map double numbers
[2,8,6,4]

Повторимся — все элементы списка должны принадлежать одному типу.

Кортежи

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

> import String
> goodName name = \
|   if String.length name <= 20 then \
|     (True, "name accepted!") \
|   else \
|     (False, "name was too long; please limit it to 20 characters")

> goodName "Tom"
(True, "name accepted!")

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

Записи

Запись — это набор пар ключ-значение, аналог объекта из JavaScript или Python. Посмотрим на базовые примеры использования:

> point = { x = 3, y = 4 }
{ x = 3, y = 4 }

> point.x
3

> bill = { name = "Gates", age = 57 }
{ age = 57, name = "Gates" }

> bill.name
"Gates"

Итак, мы можем создавать записи, используя фигурные скобки и обращаться к полям через точку. Elm также имеет вариант обращения к записи, который работает как функция. Предваряя имя переменной точкой, мы говорим «получить значение поля с таким именем». Это означает, что .name  — функция, которая получает значение поля name  записи.

> .name bill
"Gates"

> List.map .name [bill,bill,bill]
["Gates","Gates","Gates"]

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

> under70 {age} = age < 70
<function>

> under70 bill
True

> under70 { species = "Triceratops", age = 68000000 }
False

То есть мы можем передать любую запись, если у нее есть поле age , которое содержит число.

Также часто требуется обновить данные в записи:

> { bill | name = "Nye" }
{ age = 57, name = "Nye" }

> { bill | age = 22 }
{ age = 22, name = "Gates" }

Здесь важно отметить, что мы не производим деструктивные обновления. Когда мы обновляем какие-то поля записи bill, мы, на самом деле, создаем новую запись, нежели перезаписываем старую. Elm делает это эффективно, используя общие данные, где это возможно. Если мы обновим одно из десяти полей, новая запись разделит значения девяти оставшихся полей с исходной записью.

Сравнение объектов и записей

Записи в Elm похожи на объекты в JavaScript, но есть некоторые существенные отличия. Основные заключаются в том, что при работе с записями:

  • Нельзя обратиться к полю, которого нет
  • Никакое поле не может быть неопределенным (undefined , да) или содержать null
  • Нельзя создать рекурсивную запись, используя this  или self .

Elm поощряет строгое разделение данных и логики, и возможность использовать this , в основном, используется для нарушения такого разделения. Это общая проблема всех объектно-ориентированных языков, которую Elm сознательно обходит стороной.

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

Импорты и модули

В Elm мы импортируем модуль, используя ключевое слово import :

import Html

Так мы сделали импорт модуля Html  из стандартной библиотеки. После этого, мы можем использовать функции и типы из этого модуля, указывая полный путь:

Html.div [] []

Мы можем также сделать импорт модуля и выставить из него конкретные функции и типы:

import Html exposing (div)

div  теперь находится в текущей области видимости. Поэтому мы можем вызвать ее напрямую:

div [] []

Мы можем даже выставить все содержимое модуля:

import Html exposing (..)

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

Модули и типы с одинаковыми именами

Множество модулей экспортируют одноименные типы. Например, модуль Html  содержит тип Html , а модуль Task  — тип Task .

Поэтому эта функция, которая возвращает Html элемент:

import Html

myFunction : Html.Html
myFunction =
...

Эквивалентна этой:

import Html exposing (Html)

myFunction : Html
myFunction =
...

В первом случае, мы импортируем только Html  модуль и используем полный путь Html.Html .

Во втором — мы выставляем тип Html  из модуля Html , а затем используем этот тип напрямую.

Объявления модулей

Когда мы создаем модуль в Elm, мы добавляем объявление module  наверх документа:

module Main exposing (..)

Main  — наименование модуля. exposing (..) означает, что мы хотим выставить все функции и типы этого модуля. Elm ожидает этот модуль в файле Main.elm, т.е. файле с таким же именем, что у модуля.

Мы можем иметь более сложную структуру файлов в приложении. Например, файл Players/Utils.elm должен иметь следующую декларацию:

module Players.Utils exposing (..)

Импортировать этот модуль в любом месте приложения можно так:

import Players.Utils

Union типы

В Elm, Union тип используется для множества вещей из-за своей чрезвычайной гибкости. Union тип выглядит так:

type Answer = Yes | No

Answer  может быть либо Yes , либо No . Union типы удобны для приведения кода к более обощенному виду. Например, функция, объявленная так:

respond : Answer -> String
respond answer =
...

Может принять Yes  или No  в качестве перового аргумента, например, respond Yes  — корректный вызов.

Union типы также называют тэгами в Elm.

Содержимое

Union типы могут содержать данные внутри:

type Answer = Yes | No | Other String

В данном случае, тэг Other  будет содержать некую строку. Мы можем вызвать respond  вот так:

respond (Other "Hello")

Нужно использовать скобки, иначе Elm воспримет это как передачу двух аргументов функции respond .

В качестве конструктора

Рассмотрим, как мы прикрепляем данные к Other :

Other "Hello"

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

type Answer = Message Int String

Мы можем создать тэг Message  вот так:

Message 1 "Hello"

Мы можем использовать частичное применение, так же, как с обычными функциями. Обычно полученные функции называют конструкторами, т.к. мы можем использовать их для сборки полноценного экземпляра, т.е. использовать Message , чтобы получить (Message 1 "Hello") .

Вложенность

Очень распространена вложенность Union типов:

type OtherAnswer = DontKnow | Perhaps | Undecided
type Answer = Yes | No | Other OtherAnswer

Затем мы можем вызывать функцию respond , которая ожидает Answer , вот так:

respond (Other Perhaps)

Переменные типа

Также можно использовать переменные типа в Union типах:

type Answer a = Yes | No | Other a

Это Answer , который может содержать данные разных типов, например, Int , String .

Например, функция respond  могла бы выглядеть так:

respond : Answer Int -> String
respond answer =
...

Здесь мы объявляем, что тип a  должен быть Int , используя сигнатуру Answer Int .

Таким образом, мы можем вызвать respond  так:

respond (Other 123)

Но вызов respond (Other "Hello")  не удастся, потому что respond  ожидает целое число на месте a .

Типичный юзкейс

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

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

Обычно для этого используются union типы:

type Action = LoadUsers | AddUser | EditUser UserId

Псевдонимы типов (Type aliases)

В Elm псевдонимы типов, как следует из названия — это псевдонимы для всяких штук. Например, в языке есть типы Int  и String . Мы можем создать для них псевдонимы:

type alias PlayerId = Int
type alias PlayerName = String

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

label: Int -> String

Мы можем переписать ее вот так:

label: PlayerId -> PlayerName

Так гораздо понятнее, с чем именно она работает.

Записи

Определение записи в Elm выглядит так:

{ id : Int
, name : String
}

Если бы нам потребовалась функция, которая принимает запись, мы бы записали ее сигнатуру так:

label: { id : Int, name : String } -> String

Довольно многословно, но здесь могут пригодиться псевдонимы:

type alias Player =
{ id : Int
, name : String
}

label: Player -> String

Так мы создаем псевдоним Player , который указывает на определение записи, а затем мы используем этот псевдоним в сигнатуре функции.

Constructors

Псевдонимы типов могут использоваться как функции-конструкторы. Т.е. мы можем создать экземпляр записи, применив псевдоним, как функцию.

type alias Player =
{ id : Int
, name : String
}

Player 1 "Sam"
==> { id = 1, name = "Sam" }

Здесь мы создали псевдоним Player . Затем, мы вызываем Player  как функцию с двумя параметрами, что вернет нам запись с корректными атрибутами. Отметим, что порядок аргументов определяет, какие значения получат атрибуты.

Тип Unit

Пустой кортеж — ()  — называется Unit. Он так распространен, что заслуживает некоторого пояснения.

Рассмотрим псевдоним типа с переменной типа (представленной a ):

type alias Message a =
{ code : String
, body : a
}

Мы можем создать функцию, которая ожидает Message  с атрибутом body  типа String  вот так:

readMessage : Message String -> String
readMessage message =
...

Или функцию, которая ожидает Message, у которого body  — список чисел:

readMessage : Message (List Int) -> String
readMessage message =
...

Но как насчет функции, которой не требуется значение в body ? Для этого мы используем юнит, показывая, что тело сообщения (body ) должно быть пустым:

readMessage : Message () -> String
readMessage message =
...

Эта функция принимает Message  с пустым телом. Это означает не любое значение, а именно пустое.

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

Task

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

Типичная задача (task) содержит ошибку и результат:

Task error result
  • Иногда нам нужна задача, ошибка которой может быть безопасно проигнорирована: Task () result
  • Или проигнорирован результат: Tasks error ()
  • Или и то, и другое: Task () ()