0

I have the following data declaration to represent temperatures:

data Temp = Kelvin Float | Celsius Float | Fahrenheit Float deriving Show

-- Functions for conversion between temperatures
kelvToCels :: Temp -> Temp
kelvToCels (Kelvin k) = Celsius (k-273.15)

kelvToFahr :: Temp -> Temp
kelvToFahr (Kelvin k) = Fahrenheit (((9/5)*(k-273.15))+32)

celsToKelv :: Temp -> Temp
celsToKelv (Celsius c) = Kelvin (c+273.15)

celsToFahr :: Temp -> Temp
celsToFahr (Celsius c) = Fahrenheit (((9/5)*c)+32)

fahrToKelv :: Temp -> Temp
fahrToKelv (Fahrenheit f) = Kelvin ((5/9)*(f-32)+273.15) 

fahrToCels :: Temp -> Temp
fahrToCels (Fahrenheit f) = Celsius ((f-32)/(9/5))

I want to be able to compare temperatures, such that

> (Celsius 100) == (Fahrenheit 212.0) evaluates to true.

Here are my attempts:

instance Eq Temp where
   Celsius c == Fahrenheit f = 
    (celsToFahr c) == f

Result: ghci error because the c and f on the RHS are Floats instead of Temps, so heres a 'fix':

instance Eq Temp where
   Celsius c == Fahrenheit f = 
    (celsToFahr (Celsius c)) == (Fahrenheit f)

This compiles with no errors, however (Celsius 100) == (Fahrenheit 212.0) throws an exception: Non-exhaustive patterns in function ==

I would also like to make an instance of Ord, to redefine compare in a similar manner.

I've reached a dead end, and I can't find any examples similar to mine, so any piece of advice is greatly appreciated. Thanks in advance.

juancho
  • 79
  • 9
  • 1
    `Non-exhaustive patterns in function ==` is trying to tell you that you need to write a case for `Farenheit a == Farenheit b`. If you compiled with `-Wall` it would tell you that you have non-exhaustive patterns too. – AJF Jun 11 '19 at 16:42
  • See also [better exception for non-exhaustive patterns in case](https://stackoverflow.com/q/2737650/791604). – Daniel Wagner Jun 11 '19 at 16:53
  • It's a rather unfortunate design. How many cases do you need to implement a temperature difference,or an average, or pretty much any operation? – n. m. could be an AI Jun 11 '19 at 17:26
  • @n.m. what do you mean? – juancho Jun 11 '19 at 17:28
  • You need a lot of cases to compare two temperatures, then you need a lot of cases to subtract two temperatures, then you need a lot of cases to average two temperatures, then you need to revise all your code when someone asks you to add Rankine degrees. – n. m. could be an AI Jun 12 '19 at 03:58

2 Answers2

11

I recommend that you never write an incomplete pattern match. Thinking about what this means for your xToY functions, it means they should be able to handle any input -- and so their names should change to just toY.

I would also represent the guarantee that we know which constructor is used by returning a Float (which clearly cannot be labeled by the wrong constructor) rather than a Temp (which could). So:

toKelvin :: Temp -> Float
toKelvin (Fahrenheit f) = (5/9)*(f-32)+273.15
toKelvin (Celsius c) = c+273.15
toKelvin (Kelvin k) = k

Similarly for toCelsius and toFahrenheit. If you really wanted to, you could then separately write something like

normalizeKelvin :: Temp -> Temp
normalizeKelvin = Kelvin . toKelvin

but whether this is sensible or not depends a lot on how you plan to use this code.

Given that, we can now write an Eq instance which isn't recursive by just choosing one of the scales as the natural one and converting to it*. So:

instance Eq Temp where
    t == t' = toKelvin t == toKelvin t'

Note that here we are dispatching from the Temp instance to the Float instance of Eq when we call (==), unlike your code, which dispatched from the Temp instance back to another call to the Temp instance of Eq.

*If you are paranoid about rounding, you could first check whether a conversion is needed at all. So:

instance Eq Temp where
    Fahrenheit f == Fahrenheit f' = f == f'
    Celsius c == Celsius c' = c == c'
    t == t' = toKelvin t == toKelvin t'
Daniel Wagner
  • 145,880
  • 9
  • 220
  • 380
  • I like the idea of the toY function instead of xToY. I don't understand, however, why you wouldn't make it Temp -> Temp. I suppose you do this with the . operator in normalizeX? Kelvin . toKelvin in english would be "apply the Kelvin constructor to the result of toKelvin X", correct? – juancho Jun 11 '19 at 17:13
  • 2
    @juancho I like your English pronunciation of `Kelvin . toKelvin`. I think I addressed why I prefer returning `Float` to `Temp` from `toKelvin` in the answer, but I'll try to expand on it a bit. I think of `Temp` as the type of a temperature in some scale that may not be known until runtime. But we *know* the scale used in the return value of `toKelvin` at compile time, so using the type that says we don't is misleading. 1/2 – Daniel Wagner Jun 11 '19 at 17:17
  • 2
    You could imagine a more advanced version of this that had separate types for "we know it's Kelvin", "we know it's Celsius", and "we know it's Fahrenheit". I'd be fine with that too. Going down this road quickly leads to fairly advanced type-level topics, though; the simpler version gets you much of the benefit with little cost. I definitely would want to keep `normalizeKelvin` as a separate operation, though: its use should be viewed as an explicit declaration by the programmer that they are willing to throw away compile-time information about what scale is used. 2/2 – Daniel Wagner Jun 11 '19 at 17:19
  • 1
    Once you have `fromX` and `toY`, you don't really need three data constructors. Just keep everything in Kelvins, always. – n. m. could be an AI Jun 11 '19 at 17:30
  • Great explanation. So the recursive definition for the Eq instance is a problem, and the conversion from X to a float is what avoids this recursion, concluding in a (==) check between floats. – juancho Jun 11 '19 at 17:32
  • @juancho Making `(==)` recursive as you did is not a problem, necessarily; you just didn't write enough cases in your `(==)` implementation. But my proposed way results in many fewer cases to have to consider, so it's easier to get it right. =) – Daniel Wagner Jun 11 '19 at 17:37
  • I see. So how about this for an Ord instance: instance Ord Temp where t `compare` t' = (toKelvin t) `compare` (toKelvin t') – juancho Jun 11 '19 at 17:37
  • @juancho Makes sense to me! – Daniel Wagner Jun 11 '19 at 17:38
  • Great! Thanks a lot mate. Cheers. – juancho Jun 11 '19 at 17:38
0

I would recommend you avoid having three different possible representations of a temperature. This just leads to lots of runtime branching and conversion. It does make sense to have a dedicated type for temperatures, and it makes sense to keep the scale used a private implementation detail, but sticking to one convention simplifies things.

module Physics.Quantities.Temperature (Temperature) where
newtype Temp = KelvinTemp { getKelvinTemperature :: Double }
  deriving (Eq, Ord)

Note that I don't export the Kelvin-specific constructor, so for anybody using this type it can't matter which temperature scale is used. And because the internal representation is fixed, the compiler can figure out the Eq and Ord instances by itself.

Now ok, obviously you'll still need to be able to actually get stuff done, so you will need accessors. One way is simple read-in-this-scale like

toCelsius :: Temp -> Double
toCelsius (KelvinTemp tK) = tK - waterTriplePointInK

But those would be one-way, not allowing you to create temperature values again. An elegant way of achieving this is to use bidirectional functions – isomorphisms. The most popular representation is the one from the lens library:

import Control.Lens

kelvin :: Iso' Temp Double
kelvin = iso getKelvinTemperature KelvinTemp

celsius :: Iso' Temp Double
celsius = iso (\(Temp tK) -> tK - waterTriplePointInK)
              (\tC -> Temp $ tC + waterTriplePointInK)
 where waterTriplePointInK = 273.15

fahrenheit :: Iso' Temp Double
fahrenheit = iso (\(Temp tK) -> (tK - fahrZeroInK)/fahrScaleFact)
                 (\tF -> Temp $ tF*fahrScaleFact + fahrZeroInK)
 where fahrZeroInK = 255.372
       fahrScaleFact = 5/9

Now you can do stuff like

*Main> let tBoil :: Temp; tBoil = 100^.from celsius
*Main> tBoil^.fahrenheit
212.00039999999993

*Main> 37^.from celsius.fahrenheit
98.60039999999992

*Main> 4000^.from kelvin.celsius
3726.85

If you really want to have different representations for the different scales, here's another approach that's both more well-typed and will avoid runtime branching:

{-# LANGUAGE DataKinds, KindSignatures, MultiParamTypeClasses #-}

data TemperatureScale = KelvinSc | CelsiusSc | FahrenheitSc

newtype     KelvinTemperature = Kelvin     {getKelvinTemperature    ::Double}
newtype    CelsiusTemperature = Celsius    {getCelsiusTemperature   ::Double}
newtype FahrenheitTemperature = Fahrenheit {getFahrenheitTemperature::Double}

type family Temperature (sc :: TemperatureScale) where
  Temperature 'KelvinSc     = KelvinTemperature
  Temperature 'CelsiusSc    = CelsiusTemperature
  Temperature 'FahrenheitSc = FahrenheitTemperature

class ConvTemperature t t' where
  convTemperature :: Temperature t -> Temperature t'

instance ConvTemperature KelvinSc  KelvinSc        where convTemperature = id
instance ConvTemperature CelsiusSc CelsiusSc       where convTemperature = id
instance ConvTemperature FahrenheitSc FahrenheitSc where convTemperature = id
instance ConvTemperature KelvinSc FahrenheitSc where
  ...
...

If you're really serious about it, check out the units package, which does all of this and much more.

leftaroundabout
  • 117,950
  • 5
  • 174
  • 319