Постоянный рефакторинг - это хорошо

Не существует идеального линейного процесса разработки

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

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

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

Вот проблема

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

type Target
    = Twenty
    | Nineteen
    | Eighteen
    | Seventeen
    | Sixteen
    | Fifteen
    | Bullseye
type TargetStatusValue
    = Unopened Int
    | Opened
    | Closed
type alias TargetStatus =
    { target : Target
    , status : TargetStatusValue
    }
type alias Player =
    { name : String
    , status : List TargetStatus
    , score : Int
    }

Здесь у каждого игрока есть список целей на доске для дартса. Каждый целевой статус - это запись, содержащая цель и состояние этой цели. Все это выглядит довольно просто и дает прекрасную краткую запись Player. Когда я создавал это, мне нравился тот факт, что в нем было всего три элемента, и вся сложность была скрыта за этим единственным элементом status.

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

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

initStatus : List TargetStatus
initStatus =
    [ { target = Fifteen
      , status = UnOpened 0
      }
    , { target = Sixteen
      , status = UnOpened 0
      }
    , { target = Seventeen
      , status = UnOpened 0
      }
    , { target = Eighteen
      , status = UnOpened 0
      }
    , { target = Nineteen
      , status = UnOpened 0
      }
    , { target = Twenty
      , status = UnOpened 0
      }
    , { target = Bullseye
      , status = UnOpened 0
      }
    ]
initPlayer1 : Player
initPlayer1 =
    { name = "Player 1"
    , status = initStatus
    , score = 0
    }

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

Проблема начинается, когда мне нужно написать простую функцию для поиска значения определенного типа Target. Я хотел создать функцию с такой сигнатурой.

getTargetStatus : List TargetStatus -> Target -> TargetStatusValue

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

getTargetStatus statusList target =
    statusList
        |> List.filter (\elem -> elem.target == target)
        |> .status

Здесь проблема. List.filter возвращает список, поскольку может быть несколько совпадений этого типа Target. Это первый признак того, что наши типы не совсем правильные. Можно использовать эти определения типов и построить список, который не имеет смысла в проблемной области. У нас никогда не должно быть нескольких целей одного типа в этом списке.

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

getTargetStatus statusList target =
    statusList
        |> List.filter (\elem -> elem.target == target)
        |> List.head
        |> .status

Это также не может быть скомпилировано, поскольку List.head возвращает тип Maybe. Если результирующий список пуст, ничего не будет возвращено. Опять же, я попытался рационализировать это, сказав себе, что я как разработчик могу гарантировать, что данные никогда не будут недействительными, поэтому я подделал и создал этот код, который действительно компилировался.

getTargetStatus statusList target =
    statusList
        |> List.filter (\elem -> elem.target == target)
        |> List.head
        |> Maybe.withDefault
            { target = target, status = Unopened 0}
        |> .status

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

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

type TargetStatusValue
    = Unopened Int
    | Opened
    | Closed
    | IllegalStatus
getTargetStatus statusList target =
    statusList
        |> List.filter (\elem -> elem.target == target)
        |> List.head
        |> Maybe.withDefault
            { target = target, status = IllegalStatus }
        |> .status

Это дает понять, что произошло что-то плохое, но теперь мне придется усложнить свое приложение, чтобы учитывать это потенциальное значение каждый раз, когда я сопоставляю шаблон с TargetStatusValue. Даже тогда все еще непонятно, что мне делать, если я обнаруживаю это недопустимое состояние. У меня вылетает приложение? Я просто игнорирую это? В любом случае ясно, что разрешение этого двусмысленного и незаконного состояния в моем коде не сделает меня счастливым в будущем.

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

Рефакторинг для удаления недопустимого состояния

Похоже, что корень этой проблемы в том, что я решил сохранить возможности оценки в списке. Это упростило мой объект игрока и могло позволить мне позже расширить игру для поддержки других правил. Например, если бы я хотел набрать новое имя, которое позволяло бы оценивать цели 13 и 14, мне просто нужно было бы изменить одну структуру. Но на самом деле я пишу приложение, чтобы набрать очки в одной конкретной игре в дартс, и я даже не знаю, как оцениваются другие игры в дартс. Я четко сохраняю открытые возможности, которые, скорее всего, никогда не осуществятся. Я нарушаю принцип тебе это не понадобится.

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

Моя результирующая запись игрока выглядит так.

type alias Player =
    { name : String
    , status15 : TargetStatus
    , status16 : TargetStatus
    , status17 : TargetStatus
    , status18 : TargetStatus
    , status19 : TargetStatus
    , statusBullseye : TragetStatus
    , score : Int
    }

и инициализация принимает эту форму.

initPlayer1 : Player
initPlayer1 =
    { name = "Player 1"
    , status15 = Upopened 0
    , status16 = Unopened 0
    , status17 = Unopened 0
    , status18 = Unopened 0
    , status19 = Unopened 0
    , status20 = Unopened 0
    , statusBullseye = Unopened 0
    , score = 0
    }

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

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

type alias Player =
    { name : String
    , statuses : PlayerStatuses
    , score : Int
    }
type alias PlayerStatuses =
    { status15 : TargetStatusValue
    , status16 : TargetStatusValue
    , status17 : TargetStatusValue
    , status18 : TargetStatusValue
    , status19 : TargetStatusValue
    , statusBullseye : TargetStatus
    }

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

Вставать и снова бегать

Хотя это изменение относительно простое, оно дается не бесплатно. После того, как я внес это изменение, мне пришлось вернуться и реорганизовать свое приложение, чтобы использовать эту новую структуру. Учитывая, что я обнаружил эту проблему при написании первого теста для ее использования, изменений нет. В основном это вопрос обновления тестовых данных для моих модельных тестов, чтобы использовать эту новую структуру. С помощью компилятора Elm, который помог мне в этом процессе, я смог обновить все примерно за 10 минут.

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

Заключение

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

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