Свои типы данных: data

Ключевое слово data позволяет создать собственный тип, описав, какие значения в нём вообще возможны.
Хотите тип «направление», у которого ровно четыре значения? Или «точку» из двух координат? data описывает это в одну строку — и компилятор гарантирует, что других значений не будет.

Собственные типы делают код выразительным и безопасным. Простейший вид — перечисление: список возможных значений через вертикальную черту:

data Direction = North | South | East | West

turnBack :: Direction -> Direction
turnBack North = South
turnBack South = North
turnBack East  = West
turnBack West  = East

Имена North, South и так далее — это конструкторы. Значение типа Direction может быть только одним из них. Невозможно создать «пятое направление» — компилятор не позволит.

Конструкторы с полями

Конструктор может нести данные. Так получаются типы-произведения — значение «склеено» из нескольких частей:

data Point = Point Double Double

data Person = Person String Int   -- имя и возраст

distanceFromZero :: Point -> Double
distanceFromZero (Point x y) = sqrt (x*x + y*y)

nameOf :: Person -> String
nameOf (Person n _) = n

Здесь Point — и имя типа, и имя конструктора (их часто называют одинаково). Конструктор Point берёт две Double и собирает значение, а сопоставление с образцом (Point x y) достаёт поля обратно.

data Person = Person String Int
                    |      |   |
                   тег    имя возраст

Person "Ада" 36   собирает значение
(Person n a)      разбирает: n="Ада", a=36

Записи: именованные поля

Когда полей много, удобнее record-синтаксис — он сразу даёт функции-аксессоры:

data User = User
  { userName :: String
  , userAge  :: Int
  }

-- userName и userAge теперь функции:
-- userName (User "Ада" 36)  ==  "Ада"

В Python ближайший аналог — классы данных и перечисления:

# Та же идея на Python: свои типы данных
from dataclasses import dataclass
from enum import Enum

class Direction(Enum):
    NORTH = 1; SOUTH = 2; EAST = 3; WEST = 4

@dataclass
class Person:
    name: str
    age: int

p = Person("Ада", 36)
print(p.name, p.age)        # Ада 36
print(Direction.NORTH)      # Direction.NORTH

Моделируй домен типами

Главная сила data в том, что точный тип делает неверные состояния попросту непредставимыми. Если статус заказа может быть только «создан», «оплачен» или «отменён», заведите перечисление из трёх конструкторов — и в программе физически не возникнет четвёртого, «невозможного» состояния, которое в языках с числовыми кодами легко породить опечаткой. Типы-суммы (через |) и типы-произведения (конструктор с полями) комбинируются: можно описать «либо ошибка с текстом, либо успех с данными» одним выразительным типом. Record-синтаксис добавляет именованные поля и бесплатные аксессоры, а deriving избавляет от рутины. Подход «сначала смоделируй данные, потом пиши функции» — фирменный стиль Haskell: чем аккуратнее типы отражают предметную область, тем больше ошибок ловит компилятор и тем меньше проверок приходится писать руками.

Как это мыслить

Сначала смоделируйте данные, потом пишите функции. Спросите себя: «какие значения вообще допустимы?» Если ответ — фиксированный набор, делайте перечисление; если комбинация полей — тип-произведение. Чем точнее тип, тем меньше неверных состояний сможет возникнуть.

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

Частые ошибки

  • Путать имя типа и конструктора. В data Point = Point ... первое — тип, второе — способ создать значение; совпадение имён — частая, но допустимая практика.
  • Забывать deriving. Чтобы печатать или сравнивать значения, добавьте deriving (Show, Eq) — об этом в следующем уроке.
  • Имена конструкторов с маленькой буквы. Типы и конструкторы пишутся с заглавной.

Best practices

  • Моделируйте предметную область типами — это убирает «невозможные состояния».
  • Для многих полей берите record-синтаксис: читаемее и сразу есть аксессоры.
  • Имена типов и конструкторов — с заглавной буквы, это требование языка.

Итог. data создаёт собственные типы: перечисления через |, типы-произведения с полями, записи с именованными полями. Точные типы делают неверные состояния непредставимыми — и в этом огромная сила Haskell.

Проверьте себя
1. Что описывает объявление data Direction = North | South | East | West?
AФункцию с четырьмя аргументами
BТип с ровно четырьмя возможными значениями (конструкторами)
CСписок строк
DЧетыре отдельные переменные
2. Зачем нужны конструкторы с полями, например Point Double Double?
AЧтобы запретить создавать значения
BЧтобы значение несло в себе несколько данных (тип-произведение)
CЧтобы ускорить компиляцию
DЭто синоним функции print