August 3, 2022

Что если написать код на D в духе Elm?

Я люблю эксперименты, особенно когда это касается языков программирования. И вот, однажды мне пришла в голову переписать какой-нибудь пример с Elm на D. Но перед тем как собственно рассказывать, что из этого получилось, давайте разберёмся, что это за фрукты и с чем их едят.

Elm

Elm — это язык с функциональным программированием для веб приложений в духе Haskell. Если вы забыли (или не знали) функциональное программирование представляет программу как функцию близкую к математическому смыслу, которая будет вычисляться. Например Фибоначчи будет вычисляться так:

fib n = case n of
  1 -> 1
  2 -> 2
  _ -> (fib n - 1) + (fib n - 2)

В то время как для более нормальных языков вы будете указывать их скорее всего как набор шагов:

function fib(n) {
  let fib1 = 1;
  let fib2 = 1;
  if(n == 1 || n == 2) {
    return 1;
  }
  
  for(let i = 2; i <= n; i++) {
    let tmp = fib2;
    fib2 = fib1 + fib2;
    fib1 = tmp;
  }
  return fib2;
}

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

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

Но как же TypeScript (или ReScript/ReasonML)? В отличии от Elm они привязаны непосредственно к JS, таким образом предоставляя в принципе меньшие гарантии. Не то что бы это плохо, но это довольно существенное отличие, о котором надо помнить.

D

D aka Dlang — внебрачный сын С++ и Java. По задумке он более простой и одновременно мощный язык чем С++, но в своё время не взлетел (по мнению плюсовиков из-за наличия GC) и теперь где-то пылится. Впрочем, язык работоспособен и вполне сгодится если вы хотите менее популярную и более простую альтернативу С++.

Принципиальных отличий для тех кто видел С-подобные языки (С++, С#, Java...) будет маловато, но хочется обратить внимание на такой момент:

В традиционных языках шаблоны/дженерики пишутся через <…>:

template foo<T> {...}
foo<int>

В D же они чаще всего определяются через круглые скобочки и используются через восклицательный знак:

template foo(T) {...}
foo!(int)
foo!int // так можно если параметр всего один

А с остальным мы уже по ходу дела разберёмся.

Суть

Как вы наверняка догадываетесь, традиционные способы сделать приложение как например, в React тут не работают. С другой стороны Model-View-Controller внезапно, это не особо мешает. Кому интересно вникнуть — можно почитать гайд на сайте Elm, там всё просто и понятно даже для маленьких || тупых.

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

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

Модули

В Elm есть модули. Мы их видим буквально же первой строчкой:

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

В D тоже есть модули. Никаких проблем? Увы, есть. Дьявол в деталях модуль Html экспортирует символ Html, который мы добавляем в глобальное пространство имён чтобы не писать каждый раз Html.Html, а просто Html. Но в D у нас происходит конфликт имён: как понять где мы имеем в виду модуль Html, а где тип Html? Никак, переименовывайте модуль или не экспортируйте в глобальное пространство имён.

Я решил пойти путём наименьшего сопротивления и переименовал модуль в Html_. Получилось так:

import Browser;
import Html_ : Html, button, div, text;
import Html_.Events : onClick;

Вариант является почти полным аналогом. Неплохо, но впоследствии нам потребуется ещё парочка импортов поэтому пишем:

import std.sumtype;
import std.conv : to;

Main

Сердце кода — это функция с которой всё начинается. В Elm всё прекрасно и красиво:

main =
  Browser.sandbox { init = init, update = update, view = view }

Я не буду вдаваться в детали, но замечу, что в фигурных скобочках объявляется структура. Ну знаете, как словарь (ключ -> значение), только статический. И тут возникает проблема — анонимных структур в D нет. То есть нам надо где-то её объявить, присвоив имя, а потом её создать.

Погодите, но мы не знаем типов init, update и view заранее, а значит структуру надо делать шаблонной. А автовывод сложных шаблонных типов, почему-то не работает — подозреваю что такие конструкции его не учили прожевывать. И вот, шаг за шагом у нас получается чудовище (Франкенштейн одобряет):

void main() {
    Browser.sandbox!(Model, Msg)(Sandbox!(Model, Html!Msg function(Model), Model function(Msg, Model))(init, &view, &update));
}

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

void main() {
    Browser.sandbox!(Model, Msg)(init, &view, &update);
}

Код стал читаемее, но автовывод типов все ещё по нам плачет. Да и уже не 1 в 1.

Model

Модель в MVC это данные. А данные у нас в примере максимально простые — один Int. Для того чтобы модель была расширяемая, мы добавляем псевдоним (alias) типа и определяем изначальное значение:

type alias Model = Int

init : Model
init =
  0

Тут у нас уже никаких проблем нет и код получается плюс минус идентичный:

alias Model = int;

Model init = 0;

Update

Вот тут начинаются причуды архитектуры. Функция update принимает все события происходящие в приложения и на них реагирует. На практике это не очень страшно (мы можем маршрутизировать события сами), но это довольно сильно отличается от традиционного React и иже с ним. Код такой:

type Msg
  = Increment
  | Decrement

update : Msg -> Model -> Model
update msg model =
  case msg of
    Increment ->
      model + 1

    Decrement ->
      model - 1

Сначала поясним что за Msg странный такой. Это тип-сумма олицетворяющая тип событий. Немного упрощая, нам могут приходить события либо вида Increment, либо Decrement — а все другие приходить не могут. Совсем никак не могут.

С чтением case у вас не должно возникнуть сложностей — это обычный switch-case только с невидимым return. Аналогично, с описанием типа функции тоже не должно быть проблем.

Переписывать код на D будет немного сложно. Дело в том что Increment и Decrement это конструкторы типа Msg. То есть у нас возникает некоторое раздвоение личности, которое многим более консервативным языкам не нравится и они хотят интерпретировать Increment и Decrement как самостоятельные типы. А Msg — как их объединение. Тут ничего не попишешь и придётся играть по их правилам. Помните import std.sumtype? Вот он нам сейчас и пригодится:

struct Increment {}
struct Decrement {}
alias Msg = SumType!(Increment, Decrement);

Model update(Msg msg, Model model) {
    return msg.match!(
        (Increment _) => model + 1,
        (Decrement _) => model - 1
    );
}

Отдельно хочется упомянуть match. На самом деле я наврал и case-of является не switch-case, он имеет больше скрытой мощи. Он имеет нечто, что называют pattern matching. Функция match в D создана чтобы достичь этой мощи, но более неудобным путём.

View

Модель и Контроллер у нас есть, остаётся Представление. Тут всё просто, собираем DOM на пальцах:

view : Model -> Html Msg
view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

Функции вызываются без скобочек и через пробел — но стоит лишь привыкнуть и вы с лёгкостью прочитаете код.

Ещё один момент, а именно Html Msg. Это дженерик (или шаблон) и Msg подставляется прямо в тип, поэтому на D его надо переписать в Html!Msg:

Html!Msg view(Model model) {
    return div([], [
        button([ onClick(Decrement()) ], [ text("-") ]),
        div([], [ text(model.to!string) ]),
        button([ onClick(Increment()) ], [ text("+") ])
    ]);
}

Итог

Писать на D словно ты пишешь на Elm можно. Я не пробовал воспроизвести логику (хотя бы потому что D не компилируется в JS), но выглядит вполне возможным написание и всей остальной логики.

Из минусов — нет анонимных структур и автовывода шаблонных типов. Но жить можно (не обязательно хорошо).