Должны ли широта, долгота и высота иметь свой собственный тип в Haskell?

Для людей, интересующихся этой темой: принятый ответ включает некоторые концепции, которые, как мне кажется, хорошо описаны здесь. А именно, различия между ключевыми словами data, newtype и instance и способами их использования.


Я начал изучать Haskell, как неделю назад (из Python и С#), и я хочу реализовать класс GeographicPosition, который хранит широту, долготу и высоту.

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

Если взять, например, X, Y и Z в декартовом ( "прямоугольном" ) пространстве, все они означают одно и то же, имеют один и тот же диапазон (от -inf до +inf), будучи ортогональным и однородным.

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

Различия друг от друга, очевидно (по крайней мере для меня), что они не "одно и то же", в том смысле, что X, Y и Z "одно и то же" в декартовой системе. Я не могу просто перевернуть начало и притворяться, что Широта теперь долгота в том смысле, что я могу притворяться, что X теперь Y, и такой.

Итак, вопрос:

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

Я бы представлял себе что-то вроде

data Position = Position { latitude :: Latitude,
                           longitude :: Longitude,
                           elevation :: Elevation }

вместо более очевидного, позиционного

data Position = Position RealFloat RealFloat RealFloat 

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

Ответы

Ответ 1

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

Начните с создания простых конструкторов newtype:

newtype Latitude = Latitude Double deriving (Eq, Show, Ord)

newtype Longitude = Longitude Double deriving (Eq, Show, Ord)

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

normalize :: Double -> Double -> Double
normalize upperBound x
    | x >  upperBound = normalize upperBound $ x - upperBound
    | x < -upperBound = normalize upperBound $ x + upperBound
    | otherwise       = x

normLat :: Latitude -> Latitude
normLat (Latitude x) = Latitude $ normalize 90 x

normLong :: Longitude -> Longitude
normLong (Longitude x) = Longitude $ normalize 180 x

(Примечание: это не самое эффективное решение, но я хотел бы сохранить его простым в иллюстративных целях)

И теперь вы можете использовать их для создания "умных конструкторов". Это, по существу, то, что тип Data.Ratio.Ratio делает с помощью функции %, чтобы обеспечить предоставление аргументов Integral и уменьшить эту долю, и не экспортирует фактический конструктор данных :%.

mkLat :: Double -> Latitude
mkLat = normLat . Latitude

mkLong :: Double -> Longitude
mkLong = normLong . Longitude

Это функции, которые вы экспортировали из своего модуля, чтобы никто не злоупотреблял типами Latitude и Longitude. Затем вы можете написать экземпляры типа Num, которые вызывают normLat и normLong внутренне:

instance Num Latitude where
    (Latitude x) + (Latitude y) = mkLat $ x + y
    (Latitude x) - (Latitude y) = mkLat $ x - y
    (Latitude x) * (Latitude y) = mkLat $ x * y
    negate (Latitude x) = Latitude $ negate x
    abs (Latitude x) = Latitude $ abs x
    signum (Latitude x) = Latitude $ signum x
    fromInteger = mkLat . fromInteger

И аналогично для Longitude.

Затем вы можете безопасно выполнять арифметику значений Latitude и Longitude, не беспокоясь о том, что они когда-либо выходят за пределы допустимых границ, даже если вы загружаете их в функции из других библиотек. Если это похоже на шаблонный, это так. Возможно, есть более эффективные способы сделать это, но после небольшой настройки у вас есть совместимый API, который сложнее сломать.


Одна действительно приятная функция, реализующая класс Num typeclass, дает вам возможность конвертировать целочисленные литералы в ваши пользовательские типы. Если вы реализуете класс Fractional со своей функцией fromRational, вы получите полные числовые литералы для ваших типов. Предполагая, что вы оба реализованы правильно, вы можете делать такие вещи, как

> 1 :: Latitude
Latitude 1.0
> 91 :: Latitude
Latitude 1.0
> 1234.5 :: Latitude
Latitude 64.5

Конечно, вам нужно убедиться, что функция normalize на самом деле является той, которую вы хотите использовать, существуют различные реализации, которые вы могли бы подключить там, чтобы получить полезные значения. Вы можете решить, что хотите Latitude 1 + Latitude 90 == Latitude 89 экземпляр Latitude 1 (где значения "отскакивают" назад после достижения верхней границы), или вы можете их обернуть на нижнюю границу, чтобы Latitude 1 + Latitude 90 == Latitude -89, или вы можете сохраните его, как он есть здесь, где он просто добавляет или вычитает границу до тех пор, пока она не окажется в пределах диапазона. Это зависит от вас, какая реализация подходит для вашего случая использования.

Ответ 2

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

module Position (
    Position, --export position type but not its constructor and field accessor.
    mkPosition, -- smart constructor for creating Positions
    foo -- your other public functions
) where 

-- Named fields and named conventions should be enough to
-- keep my code sane inside the module
data Position = Position {
  latitude :: Double,
  longitude :: Double,
  elevation :: Double
} deriving (Eq, Show)

mkPosition :: Double -> Double -> Double -> Position
mkPosition lat long elev =
   -- You can use this function to check invariants
   -- and guarantee only valid Positions are created.

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

Подробнее о http://www.haskell.org/haskellwiki/Smart_constructors