Сравнение различных подходов к очистке кода Elm

Некоторое время назад Ричард Фельдман выступил с прекрасным докладом на тему «сделать невозможные состояния невозможными». Это действительно прижилось в сообществе Вязов. Ключевым моментом было более или менее:

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

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

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

Эта статья не столько о том, почему - делать невозможные состояния невозможными - это хорошо. Это больше похоже на упражнение, чтобы выяснить, как это делать. И сравните несколько разных способов сделать это.

Пример использования

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

  • Если в раскрывающемся списке не выбрана страна, вы не можете выбрать город; раскрывающийся список городов отключен.
  • Раскрывающийся список городов может быть пустым, если город еще не выбран.
  • Когда вы выбираете другую страну, раскрывающийся список города сбрасывается. Если вы снова выберете ту же страну, любой ранее выбранный город будет сохранен.

Версия 1: Два Maybes - Наивная модель данных

Моя первоначальная (наивная) модель для этого была:

type alias Model =
    { country : Maybe Country
    , city : Maybe City
    }

Эта модель данных фактически допускает невозможные состояния, например:

{ country = Nothing
, city = Just "Paris"
}

Технически разрешено, но нельзя выбрать город без страны.

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

update : Msg -> Model -> Model
update msg model =
    case msg of
        CountryPicked pickedCountry ->
            let
                newCity =
                    if model.country /= Just pickedCountry then
                        Nothing
                    else
                        model.city
             in
                { model             
                | country = Just pickedCountry
                , city = newCity
                }
     
        CityPicked pickedCity ->
            { model
            | city = 
                model.country
                |> Maybe.andThen (always <| Just pickedCity)
            }

Ветвь для обработки CountryPicked (из раскрывающегося списка) должна проверить, изменилось ли model.country, и, если да, сбросить model.city на Nothing.

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

view : Model -> Html Msg
view model =
    let
        (selectedCountry, cityDropDownDisabled) =
            case model.country of
                Just country ->
                    (Just country, False)
                Nothing ->
                    (Nothing, True)
        selectedCity = model.city
    in
        ...

Красиво, коротко и довольно читабельно.

Версия 2: Может быть, возможно - более чистая модель данных без невозможных состояний.

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

type alias Model =
    { destination : Maybe CitySelection }
type alias CitySelection = 
    { country : Country
    , city : Maybe City
    }

Модель может иметь назначение. И если это так - в форме CitySelection - в пункте назначения должна быть как минимум страна, а также может быть выбран или не выбран город.

Это делает невозможным сохранение города в модели данных, если нет страны.

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

update : Msg -> Model -> Model
update msg model =    
    case msg of
        CountryPicked newCountry ->
            { model
                | destination =
                    case model.destination of
                        Just { country, city } ->
                            if newCountry /= country then
                                Just 
                                    { country = newCountry
                                    , city = Nothing 
                                    }
                            else
                                Just 
                                    { country = country
                                    , city = city 
                                    }
                        Nothing ->
                            Just 
                                { country = newCountry
                                , city = Nothing 
                                }
            }
 
        CityPicked newCity ->
            { model
                | destination =
                    case model.destination of
                        Just { country, city } ->
                            Just 
                                { country = country
                                , city = Just newCity 
                                }
                        Nothing ->
                            Nothing
            }

О боже, это действительно удлиняет функцию обновления. Конечно, здесь меньше места для ошибок, потому что у нас никогда не может быть выбранного города без страны, но это дольше. И со всеми ветвями Just и Nothing он действительно не стал более читабельным.

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

А как насчет функции view для этой модели данных?

view : Model -> Html Msg
view model =
    let
        (selectedCountry, cityDropDownDisabled, selectedCity) =
            case model.destination of
                Just { country, city } ->
                    (Just country, False, city)
  
                Nothing ->
                    (Nothing, True, Nothing)
    in
        ...

Довольно коротко и чисто, так что это хорошо. Но можно ли улучшить и нашу функцию обновления?

Версия 3: Union Typed - более чистая модель данных с типом union

Есть еще один способ очистить модель данных: с помощью типов объединения.

type alias Model =
    { destination : Destination }
type Destination =
    NotChosen
    | ToCountry Country
    | ToCity Country City

Опять же, город без страны невозможен. А теперь данные более читабельны, потому что абстрактные Just и Nothing заменены более реальными терминами.

Функция update для работы с выпадающим списком теперь выглядит следующим образом.

update : Msg -> Model -> Model
update msg model =
    case msg of 
        CountryPicked newCountry ->
            { model
                | destination =
                    case model.destination of
                        NotChosen ->
                            ToCountry newCountry
                        ToCountry oldCountry ->
                            ToCountry newCountry
  
                        ToCity oldCountry oldCity ->
                            if newCountry /= oldCountry then
                                ToCountry newCountry
                            else
                                ToCity oldCountry oldCity
             }
        CityPicked newCity ->
            { model
                | destination =
                    case model.destination of
                        NotChosen ->
                            NotChosen
  
                        ToCountry oldCountry ->
                            ToCity oldCountry newCity
  
                        ToCity oldCountry oldCity ->
                            ToCity oldCountry newCity
            }

Несколько более читабельно, но все же довольно многословно. В основном потому, что теперь мне приходится иметь дело со всеми тремя ветвями Destination twice.

К счастью, функция просмотра в этом варианте все еще довольно короткая и приятная:

view : Model -> Html Msg
view model =
    let
        (selectedCountry, cityDropDownDisabled, selectedCity) =
            case model.destination of
                NotChosen ->
                    (Nothing, True, Nothing)
  
                ToCountry country ->
                    (Just country, False, Nothing)
  
                ToCity country city ->
                    (Just country, False, Just city)
    in
        ...

Но, тем не менее, у нас есть долгая функция обновления.

Версия 4: Изменено Msg - Изменить сообщения тоже?

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

Также можно изменить нашу Msg структуру, чтобы пользователь мог отправлять только действительные пункты назначения:

type Msg =
  DestinationPicked Destination

Теперь функция обновления действительно стала бы короткой и приятной:

update : Msg -> Model -> Model
update msg model =
    case msg of 
        DestinationPicked newDestination ->
            { model
                | destination =
                    newDestination
            }

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

type Msg =
    CountryPicked Country      -- : Country -> Msg
    | CityPicked City          -- : City -> Msg

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

view : Model -> Html Msg
view model =
    let 
        ...
        countryPickMsg : Country -> Msg
        countryPickMsg newCountry =
            case model.destination of
                NotChosen ->
                    ToCountry newCountry
  
                ToCountry oldCountry ->
                    ToCountry newCountry
  
                ToCity oldCountry oldCity ->
                    if newCountry /= oldCountry then
                        ToCountry newCountry
                    else
                        ToCity oldCountry oldCity
  
        cityPickMsg : City -> Msg
        cityPickMsg newCity =
            case model.destination of
                NotChosen ->
                    NotChosen
  
                ToCountry oldCountry ->
                    ToCity oldCountry newCity
  
                ToCity oldCountry oldCity ->
                    ToCity oldCountry newCity

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

Сразу очевидно, что этот вариант не устраняет сложность / необходимую логику в нашем коде, но перемещает его из обновления в нашу функцию просмотра.

Выпадающее меню страны теперь должно знать о выбранном городе, чтобы предотвратить сброс города, если пользователь выберет ту же страну.

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

Это не обязательно все плохие новости: «работа» представления заключается в том, чтобы вычесть из модели, какое взаимодействие с пользователем разрешено. И для этого даже для одного раскрывающегося списка может потребоваться больше контекста, чем просто значения, с которыми он работает.

Любимый?

Ниже представлено упрощенное сравнение вариантов:

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

На данный момент мне больше всего нравится вариант Union Type. Хотя функция обновления длиннее и многословнее, я считаю, что код легче всего читать. В общем: если дать вашим Maybe-подобным типам более явные имена, ваш код будет легче читать. Так что я думаю, что в данном случае это тоже помогло этому варианту.

Однако 4-й вариант по-прежнему заинтриговал меня как интересный подход. Изначально я создал его, чтобы доказать, что упрощение update функций может зайти слишком далеко. Но похоже, что возникает еще одна тема: «сделать невозможное обновление невозможным»…

Лучшие способы сделать невозможные состояния невозможными?

Несомненно, есть более эффективные способы рефакторинга этого примера и его вариантов. Конечно, поможет извлечение функций из функции update (или функции view в 4-м варианте).

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