Избегайте примитивной одержимости

Начиная с более выразительных типов

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

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

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

Типовая разработка

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

  1. Чрезмерное использование примитивных типов
    Обычным запахом является использование необработанных строк и чисел, когда вы можете создать более выразительный и узкий тип данных. Например, представление типа сотрудника в виде строки допускает бесконечное количество возможностей (многие из которых недопустимы). В качестве альтернативы создание типа объединения, допускающего только допустимые значения (например, Hourly, Exempt, Manager, и т. Д.), Является более выразительным и создает меньше возможностей для ошибки.
  2. Способность отображать недействительные состояния
    Здесь есть некоторое совпадение с вышеизложенным, но я думаю, что стоит остановиться на этом отдельно. Если вы обнаружите, что у вас есть несколько примитивов в записи, и определенные комбинации действительно никогда не должны происходить, это еще один признак того, что вам следует подумать о более выразительном типе объединения. Я написал более подробную статью об этом несколько месяцев назад.
  3. Чрезмерное использование типов Maybe
    Замечательно, что у Elm нет типов данных nil или null. Это позволяет избежать множества ошибок времени выполнения. Однако обратите внимание на ситуации, когда вы просто заменяете null типом Maybe. Если вы обнаружите, что в вашем коде много конструкций Maybe.withDefault или Maybe.map, это может быть признаком того, что тип объединения более высокого уровня - лучшее решение.

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

Пример - подсчет очков в игре в дартс

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

  • Доска для дартса состоит из 20 пронумерованных сегментов, но в крикете подсчет очков имеет значение только в сегментах с номерами 15–20 или во внутреннем или внешнем "яблочко". Любая другая позиция может считаться промахом, так как не влияет на результат.
  • Каждый сегмент может быть оценен как одиночный, двойной или тройной. Внешнее кольцо представляет собой двойную область. Меньшее кольцо около середины - тройная зона. Любая другая часть сегмента считается единой.
  • Игроки должны набрать по три очка в каждом сегменте, чтобы открыть этот сегмент. Они могут получить эти очки за любую комбинацию одиночных, парных и тройных ударов. Когда сегмент открыт, он остается открытым до тех пор, пока второй игрок не накопит свои три очка на этом сегменте. После того, как оба игрока сделают это, сегмент считается закрытым.

Давайте посмотрим на определения типов, которые я создал для представления этих возможностей.

Моделирование целей

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

type Target
    = Twenty
    | Nineteen
    | Eighteen
    | Seventeen
    | Sixteen
    | Fifteen
    | Bullseye

Состояние цели моделирования

Для каждой цели мне нужно было представить ее статус. Мишень может быть открытой, закрытой или неоткрытой. Также в случае неоткрытой цели он также может иметь 0–2 балла, накопленных к открытию этой цели. Я создал этот тип, чтобы представить эти возможности.

type TargetStatusValue
    = Unopened Int
    | Opened
    | Closed

В какой-то момент я подумал, что параметр Unopened должен быть не целым числом. Учитывая, что здесь есть только три возможности (0–2), мне не совсем нравится делать это неограниченное целое число. Тем не менее, я также буду выполнять математические вычисления, используя это значение, поэтому мне показалось, что имеет смысл использовать здесь примитивное значение. В противном случае мне нужно было бы разворачивать и преобразовывать это каждый раз, когда мне нужно было выполнить вычисление. Я могу пересмотреть это решение по мере разработки приложения.

Моделирование игрока

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

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

type alias TargetStatus =
    { target : Target
    , status : TargetStatusValue
    }

type alias Player =
    { name : String
    , status : List TargetStatus
    , score : Int
    , currentDart : Maybe Int
    }

Создание модели верхнего уровня

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

type PlayerID
    = Player1
    | Player2

type alias Model =
    { player1 : Player
    , player2 : Player
    , currentTurn : PlayerID
    }

После этого нам нужно создать исходную модель.

Создание исходной модели

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

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
      }
    ]

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

initPlayer1 : Player
initPlayer1 =
    { name = "Player 1"
    , status = initStatus
    , score = 0
    , currentDart = Just 1
    }
initPlayer2 : Player
initPlayer2 =
    { name = "Player 2"
    , status = initStatus
    , score = 0
    , currentDart = Nothing
    }

Глядя на это состояние, запах текущего дротика становится еще более ясным. Обратите внимание, что единственное допустимое состояние - один игрок имеет статус Nothing, а другой - статус Just num. Из-за ошибки кодирования мы могли попасть в недопустимое состояние. Я определенно хочу вернуться и изменить модель, чтобы удалить эту потенциально недопустимую комбинацию состояний.

Наконец, давайте смоделируем начальное состояние для модели верхнего уровня.

initModel : Model
initModel =
    { player1 = initPlayer1
    , player2 = initPlayer2
    , currentTurn = Player1
    }

Это довольно просто. Но обратите внимание, что теперь у нас есть дополнительная возможность для незаконного состояния. Учитывая, что мы храним активного игрока в модели, а текущий дротик внутри каждого игрока, существует больше возможностей для недопустимых состояний. Что, если для currentTurn установлено значение Player1, а у игрока 1 currentDart of Nothing? Это недопустимое состояние, но компилятор не сможет его обнаружить.

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

Рефакторинг, чтобы избежать недействительных состояний

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

type alias Player =
    { name : String
    , status : List TargetStatus
    , score : Int
    }
type alias Model =
    { player1 : Player
    , player2 : Player
    , currentTurn : PlayerID
    , currentDart : Int
    }

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

Конечно, исходная модель должна быть обновлена ​​соответствующим образом, чтобы соответствовать этим изменениям модели.

initPlayer1 : Player
initPlayer1 =
    { name = "Player 1"
    , status = initStatus
    , score = 0
    }
initPlayer2 : Player
initPlayer2 =
    { name = "Player 2"
    , status = initStatus
    , score = 0
    }
initModel : Model
initModel =
    { player1 = initPlayer1
    , player2 = initPlayer2
    , currentTurn = Player1
    , currentDart = 1
    }

Теперь, когда у нас есть достаточно прочная модель, мы рассмотрим моделирование типов сообщений.

Создание типов сообщений

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

type Magnitude
    = Single
    | Double
    | Triple
type BullseyeMagnitude
    = Inner
    | Outer
type Msg
    = HitFifteen Magnitude PlayerID
    | Hitixteen Magnitude PlayerID
    | HitSeventeen Magnitude PlayerID
    | HitEightteen Magnitude PlayerID
    | HitNineteen Magnitude PlayerID
    | HitTwenty Magnitude PlayerID
    | HitBullseye BullseyeMagnitude PlayerID
    | Miss

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

Подводя итоги

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

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

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