Миран Липовача - Изучай Haskell во имя добра!
Отличная фигура за 15 минут
Теперь подумаем, как бы мы представили некую геометрическую фигуру в языке Haskell. Один из способов – использовать кортежи. Круг может быть представлен как (43.1, 55.0, 10.4), где первое и второе поле – координаты центра, а третье – радиус. Вроде бы подходит, но такой же кортеж может представлять вектор в трёхмерном пространстве или что-нибудь ещё. Лучше было бы определить свой собственный тип для фигуры. Скажем, наша фигура может быть кругом или прямоугольником.
data Shape = Circle Float Float Float | Rectangle Float Float Float Float
Ну и что это? Размышляйте следующим образом. Конструктор для значения Circle содержит три поля типа Float. Когда мы записываем конструктор значения типа, опционально мы можем добавлять типы после имени конструктора; эти типы определяют, какие значения будет содержать тип с данным конструктором. В нашем случае первые два числа – это координаты центра, третье число – радиус. Конструктор для значения Rectangle имеет четыре поля, которые также являются числами с плавающей точкой. Первые два числа – это координаты верхнего левого угла, вторые два числа – координаты нижнего правого угла.
Когда я говорю «поля», то подразумеваю «параметры». Конструкторы данных на самом деле являются функциями, только эти функции возвращают значения типа данных. Давайте посмотрим на сигнатуры для наших двух конструкторов:
ghci> :t Circle
Circle :: Float –> Float –> Float –> Shape
ghci> :t Rectangle
Rectangle :: Float –> Float –> Float –> Float –> Shape
Классно, конструкторы значений – такие же функции, как любые другие! Кто бы мог подумать!..
Давайте напишем функцию, которая принимает фигуру и возвращает площадь её поверхности:
area :: Shape –> Float
area (Circle _ _ r) = pi * r ^ 2
area (Rectangle x1 y1 x2 y2) = (abs $ x2 – x1) * (abs $ y2 – y1)
Первая примечательная вещь в объявлении – это декларация типа. Она говорит, что функция принимает фигуру и возвращает значение типа Float. Мы не смогли бы записать функцию типа Circle –> Float, потому что идентификатор Circle не является типом; типом является идентификатор Shape. По той же самой причине мы не смогли бы написать функцию с типом True –> Int. Вторая примечательная вещь – мы можем выполнять сопоставление с образцом по конструкторам. Мы уже записывали подобные сопоставления раньше (притом очень часто), когда сопоставляли со значениями [], False, 5, только эти значения не имели полей. Только что мы записали конструктор и связали его поля с именами. Так как для вычисления площади нам нужен только радиус, мы не заботимся о двух первых полях, которые говорят нам, где располагается круг.
ghci> area $ Circle 10 20 10
314.15927
ghci> area $ Rectangle 0 0 100 100
10000.0
Ура, работает! Но если попытаться напечатать Circle 10 20 5 в командной строке интерпретатора, то мы получим ошибку. Пока Haskell не знает, как отобразить наш тип данных в виде строки. Вспомним, что когда мы пытаемся напечатать значение в командной строке, интерпретатор языка Haskell вызывает функцию show, для того чтобы получить строковое представление значения, и затем печатает результат в терминале. Чтобы определить для нашего типа Shape экземпляр класса Show, модифицируем его таким образом:
data Shape = Circle Float Float Float | Rectangle Float Float Float Float
deriving (Show)
Не будем пока концентрировать внимание на конструкции deriving (Show). Просто скажем, что если мы добавим её в конец объявления типа данных, Haskell автоматически определит экземпляр класса Show для этого типа. Теперь можно делать так:
ghci> Circle 10 20 5
Circle 10.0 20.0 5.0
ghci> Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0
Конструкторы значений – это функции, а значит, мы можем их отображать, частично применять и т. д. Если нам нужен список концентрических кругов с различными радиусами, напишем следующий код:
ghci> map (Circle 10 20) [4,5,6,6]
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]
Верный способ улучшить фигуру
Наш тип данных хорош, но может быть и ещё лучше. Давайте создадим вспомогательный тип данных, который определяет точку в двумерном пространстве. Затем используем его для того, чтобы сделать наши фигуры более понятными:
data Point = Point Float Float deriving (Show)
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)
Обратите внимание, что при определении точки мы использовали одинаковые имена для конструктора типа и для конструктора данных. В этом нет какого-то особого смысла, но если у типа данных только один конструктор, как правило, он носит то же имя, что и тип. Итак, теперь у конструктора Circle два поля: первое имеет тип Point, второе – Float. Так легче разобраться, что есть что. То же верно и для прямоугольника. Теперь, после всех изменений, мы должны исправить функцию area:
area :: Shape –> Float
area (Circle _ r) = pi * r 2
area (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 – x1) * (abs $ y2 – y1)
Единственное, что мы должны поменять, – это образцы. Мы игнорируем точку у образца для круга. В образце для прямоугольника используем вложенные образцы при сопоставлении для того, чтобы получить все поля точек. Если бы нам нужны были точки целиком, мы бы использовали именованные образцы. Проверим улучшенную версию:
ghci> area (Rectangle (Point 0 0) (Point 100 100))
10000.0
ghci> area (Circle (Point 0 0) 24)
1809.5574
Как насчёт функции, которая двигает фигуру? Она принимает фигуру, приращение координаты по оси абсцисс, приращение координаты по оси ординат – и возвращает новую фигуру, которая имеет те же размеры, но располагается в другом месте.
nudge :: Shape –> Float –> Float –> Shape
nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b
= Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))
Всё довольно очевидно. Мы добавляем смещение к точкам, определяющим положение фигуры:
ghci> nudge (Circle (Point 34 34) 10) 5 10
Circle (Point 39.0 44.0) 10.0
Если мы не хотим иметь дело напрямую с точками, то можем сделать вспомогательные функции, которые создают фигуры некоторого размера с нулевыми координатами, а затем их подвигать.
Во-первых, напишем функцию, принимающую радиус и создающую круг с указанным радиусом, расположенный в начале координат:
baseCircle :: Float –> Shape
baseCircle r = Circle (Point 0 0) r
Добавим функцию, которая по заданным ширине и высоте создаёт прямоугольник соответствующего размера. При этом левый нижний угол прямоугольника находится в начале координат:
baseRect :: Float –> Float –> Shape
baseRect width height = Rectangle (Point 0 0) (Point width height)
Теперь создавать формы гораздо легче: достаточно создать форму в начале координат, а затем сдвинуть её в нужное место:
ghci> nudge (baseRect 40 100) 60 23
Rectangle (Point 60.0 23.0) (Point 100.0 123.0)
Фигуры на экспорт
Конечно же, вы можете экспортировать типы данных из модулей. Чтобы сделать это, запишите имена ваших типов вместе с именами экспортируемых функций. В отдельных скобках, через запятую, укажите, какие конструкторы значений вы хотели бы экспортировать. Если хотите экспортировать все конструкторы значений, просто напишите две точки (..).
Если бы мы хотели поместить функции и типы, определённые выше, в модуль, то могли бы начать как-то так:
module Shapes
( Point(..)
, Shape(..)
, area
, nudge
, baseCircle
, baseRect
) where
Запись Shape(..) обозначает, что мы экспортируем все конструкторы данных для типа Shape. Тот, кто импортирует наш модуль, сможет создавать фигуры, используя конструкторы Rectangle и Circle. Это то же самое, что и Shape (Rectangle, Circle), но короче.
К тому же, если мы позже решим дописать несколько конструкторов данных, перечень экспортируемых объектов исправлять не придётся. Всё потому, что конструкция .. автоматически экспортирует все конструкторы соответствующего типа.
Мы могли бы не указывать ни одного конструктора для типа Shape, просто записав Shape в операторе экспорта. В таком случае тот, кто импортирует модуль, сможет создавать фигуры только с помощью функций baseCircle и baseRect.
Помните, конструкторы данных – это простые функции, принимающие поля как параметры и возвращающие значение некоторого типа (например, Shape) как результат. Если мы их не экспортируем, то вне модуля они будут недоступны. Отказ от экспорта конструкторов данных делает наши типы данных более абстрактными, поскольку мы скрываем их реализацию. К тому же, пользователи нашего модуля не смогут выполнять сопоставление с образцом для этих конструкторов данных. Это полезно, если мы хотим, чтобы программисты, импортирующие наш тип, работали только со вспомогательными функциями, которые мы специально для этого написали. Таким образом, у них нет необходимости знать о деталях реализации модуля, и мы можем изменить эти детали, когда захотим – лишь бы экспортируемые функции работали как прежде.