For people interested in this topic: the accepted answer involves some concepts that I think are well described here. Namely, differences between
data
,newtype
andinstance
keywords, and ways to use them.
I started to learn Haskell like a week ago (coming from Python and C#), and I want to implement a class GeographicPosition
, that stores Latitude, Longitude and Elevation.
Specifically, I'd like to do it in the most elegant, functional, "unit-of-measurement" aware way.
If we take, for example, X, Y and Z in cartesian ("rectangular") space, they all mean the same thing, have the same range (from -inf
to +inf
), being orthogonal and uniform.
Now with Latitude, Longitude and Elevation this is not like that. Longitude, for example, is periodic, Latitude has some maximum rangess at the poles (which are themselves singularities), and the elevation has a minimum absolute value at the center of the earth (another singularity).
Singularities apart, it is obvious (to me at least) that they are not "the same thing", in the sense that X, Y and Z are "the same thing" in a cartesian system. I cannot simply flip the origin and pretend that Latitude is now Longitude in the way that I can pretend that X now is Y, and such.
So the question is:
Should Latitude, Longitude and Elevation have their own numerical type in a type representing geographical position in Haskell? What would be a good type signature for that (a minimal sample code would be great)
I would imagine something along like
data Position = Position { latitude :: Latitude,
longitude :: Longitude,
elevation :: Elevation }
instead of the more obvious, position-based
data Position = Position RealFloat RealFloat RealFloat
but I don't know which style is more advised. It seems like Bounded
is an interesting construct too, but I didn't quite understood how to use it in this case.
I personally would make a type for them, and if you really want to make sure to keep things within their periodic bounds then this is a great opportunity to ensure that.
Start by making simple newtype
constructors:
newtype Latitude = Latitude Double deriving (Eq, Show, Ord)
newtype Longitude = Longitude Double deriving (Eq, Show, Ord)
Note that I didn't use RealFloat
, because RealFloat
is a typeclass, not a concrete type so it can't be used as a field to a constructor. Next, write a function to normalize these values:
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
(Note: This is not the most efficient solution, but I wanted to keep it simple for illustrative purposes)
And now you can use these to create "smart constructors". This is essentially what the Data.Ratio.Ratio
type does with the %
function to ensure that you provide Integral
arguments and reduce that fraction, and it doesn't export the actual data constructor :%
.
mkLat :: Double -> Latitude
mkLat = normLat . Latitude
mkLong :: Double -> Longitude
mkLong = normLong . Longitude
These are the functions that you'd export from your module to ensure that no one misuses the Latitude
and Longitude
types. Next, you can write instances like Num
that call normLat
and normLong
internally:
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
And similarly for Longitude
.
You can then safely perform arithmetic on Latitude
and Longitude
values without worrying about them ever going outside valid bounds, even if you're feeding them into functions from other libraries. If this seems like boilerplate, it is. Arguably, there are better ways to do this, but after a little bit of set up you have a consistent API that is harder to break.
One really nice feature implementing the Num
typeclass gives you is the ability to convert integer literals to your custom types. If you implement the Fractional
class with its fromRational
function, you'll get full numeric literals for your types. Assuming you have both implemented properly, you can do things like
> 1 :: Latitude
Latitude 1.0
> 91 :: Latitude
Latitude 1.0
> 1234.5 :: Latitude
Latitude 64.5
Granted, you'll need to make sure that the normalize
function is actually the one you want to use, there are different implementations that you could plug in there to get usable values. You might decide that you want Latitude 1 + Latitude 90 == Latitude 89
instance of Latitude 1
(where the values "bounce" back after the reach the upper bound), or you can have them wrap around to the lower bound so that Latitude 1 + Latitude 90 == Latitude -89
, or you can keep it how I have it here where it just adds or subtracts the bound until it's within range. It's up to you which implementation is right for your use case.
An alternative to using separate types for every field is to use encapsulation: create an abstract data type for your Positions, make all the fields private and only let users interact with positions using the public interface you provide.
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.
The main advantage of doing this is that there is less type system boilerplate and the types you work with are simpler. As long as your library is small enough, you can keep all the functions in your head the naming conventions + testing should be enough to make your functions bug free and respecting the Position invariants.
See more on http://www.haskell.org/haskellwiki/Smart_constructors
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With