Сравнение различных подходов к очистке кода 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-м варианте).
Ответы на данный момент уже доказали, что существуют лучшие и другие варианты, которые могут улучшить читаемость по сравнению с решениями, которые я придумал. Не стесняйтесь, дайте мне знать в комментариях!